Repository navigation
Conversation
polymon() has one early exit, the "cannot become that" return 0. Several of the calls it makes afterwards can revert the hero mid-function, and two of them already carry a FIXME? saying so: expels() (polyself.c:931) and spoteffects(TRUE) (polyself.c:973). When one of those reverts the hero, rehumanize() runs its own cleanup -- encumber_msg() at polyself.c:1410 and retouch_equipment(2) at 1415 -- and then control returns into polymon(), which does both again at 1019 and 1021. retouch_equipment()'s nesting counter (artifact.c:2659) does not help, because the two calls are sequential rather than nested: the first has already returned and decremented nesting back to 0, so the second clears the bypass bits and re-scans all of gi.invent. Player-visible effect: a hero carrying a cross-aligned artifact is blasted twice for a single polymorph, with a second damage roll. Guard at each boundary where a re-entrant callback may have replaced the form being configured. The test is u.umonnum != mntmp rather than !Upolyd because a nested callback can install a different monster form as well as reverting to human (instapetrify() and selftouch() both call polymon() directly). Returning 1 keeps the "polymorph happened" contract for polyself(), which only ever does (void) polymon(...). Recorded reproducers, analysis and verification: https://github.com/davidbau/nethack-bugreport/tree/main/bugs/06-polymon-nested-rehumanize Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> (cherry picked from commit 8076a18)
The hero's form is installed by set_uasmon() (polyself.c:815, called
from polymon()), but the hero's LS_MONSTER light source is created in
polyself()'s made_change: block (polyself.c:720-730), which only runs
after polymon() has returned. Between those two points the hero is a
light-emitting monster with no light source, and polymon() does a lot of
work in there that can revert the form: break_armor(), drop_weapon(),
expels(), spoteffects(), retouch_equipment(2). Any of them can take
u.mh below 1, at which point losehp() (hack.c:4267) calls rehumanize(),
which deletes on the basis of the current form without checking that a
source exists (polyself.c:1393):
del_light_source: not found type=2, id=0x...
Program in disorder! (Saving and reloading may fix this problem.)
Please report these messages to devteam@nethack.org.
The assumption that fails is that being in a light-emitting form implies
a light source exists. Two hand-placed workarounds for the same split
already exist -- the old_light = 0 at polyself.c:581 and the explicit
delete before slime_dialogue()'s polymon() call at timeout.c:488.
Three consequences follow from the split, of which the impossible() is
only the first. If the hero was already glowing, made_change: deletes a
second time after a nested rehumanize() already did. And the roughly
twenty direct polymon() callers outside polyself.c never run
made_change: at all, so a fire vortex who catches lycanthropy
(were.c:209) keeps its light source forever: were.c has no light
handling, and a werewolf does not emits_light(), so rehumanize() will
not delete it later either.
Move the transition into a helper called from every place that installs
a form, so the two can never be out of step: polymon() and polyman()
(which covers newman() and rehumanize()). The made_change: block and its
two workarounds are then redundant and removed, and slimed_to_death()'s
comment is updated to say that polymon() now does the light bookkeeping.
emits_light() (mondata.h:178) returns only 0 or 1, so the == 1 ? ++
normalisation to 2 is the only radius arithmetic and it is preserved
verbatim.
The invariant this establishes, true at every observable point: once
gy.youmonst.data names a light-emitting form its LS_MONSTER source
already exists, and once a non-emitting form is installed the old source
is already gone. The direct polymon() callers get correct handling for
free.
Behaviour change, deliberate: the light now appears at set_uasmon()
rather than after polymon() returns, so anything inside polymon() that
consults the light map sees the new form's light instead of the old
state.
Recorded reproducers, analysis and verification:
https://github.com/davidbau/nethack-bugreport/tree/main/bugs/07-polyself-light-delete-before-create
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit 9a5fcf7)
break_armor() caches the hero's form at entry (polyself.c:1160) and then asks that cached uptr what to strip: breakarm(uptr), sliparm(uptr), nohands(uptr), verysmall(uptr), slithy(uptr), uptr->mlet == S_CENTAUR, is_whirly(uptr), has_head(uptr). Its own gloves block calls drop_weapon(0) at polyself.c:1254. If the wielded item is an artifact whose #invoked levitation is holding the hero up, releasing it ends that levitation -- finesse_ahriman() says so -- so freeinv() reaches float_down() and the hero lands on whatever is below. If that is lava and the water walking boots are still worn (they come off in the next sub-block), lava_effects() takes its survivable branch at trap.c:6811, losehp() drives u.mh below 1, and rehumanize() runs. The hero is human again, inside break_armor(). break_armor() then resumes with uptr still pointing at the old form and strips a human's shield by a newt's nohands, then a human's water walking boots by a newt's verysmall. With the boots gone the next lava_effects() no longer qualifies for the losehp() branch and goes straight to done(BURNING). The hero does not die of lava damage -- a level 30 Valkyrie absorbs d(6,6) indefinitely. They die because the game took their water walking boots off while they were standing in lava. uptr is stale but valid: gy.youmonst.data always points into the static mons[] table, so this is a logic error, not memory unsafety. Stop when the form being worked on is no longer the hero's form, at three sub-block boundaries: after the gloves block, before the boots block, and before the eyewear block. Checking at boundaries rather than mid-block matters, because each sub-block pairs an _off() with a dropp() and bailing between them would leave an item half-removed. The invariant this restores: break_armor() only ever strips gear the hero's current form cannot wear. Recorded reproducer, analysis and verification: https://github.com/davidbau/nethack-bugreport/tree/main/bugs/08-break-armor-stale-form Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> (cherry picked from commit e386569)
davidbau
added a commit
to davidbau/nethack-bugreport
that referenced
this pull request
Sep 18, 2026
Bugs 06, 07 and 08 are filed upstream as one issue, since they are one mechanism, with the three commits as a companion pull request: NetHack/NetHack#1682 NetHack/NetHack#1681 The issue carries the symptom tables, the three scenarios with their browser replays, and the proposed invariant with its two obligations. The PR carries a summary plus everything from the invariant down: the part-by-part walkthrough, the reasoning for why each piece is correct, the verification, and the credit. GitHub reports it mergeable against the current NetHack-5.0 tip, 3 commits, 2 files, +71/-28. Upstream's README invites GitHub pull requests and bug reports, so both are in keeping; the issue offers to split into three if that suits their triage better. Also corrected the diffstat in bug 10, which said +70/-27 and -2 from before the timeout.c comment fix landed. It is +70/-25 in polyself.c and +1/-3 in timeout.c, which is the +71/-28 GitHub shows. Status cells for 06, 07, 08 and 10 now point at the issue and the PR in both the index and the README.
Replace form-number and monster-pointer identity checks with a monotonic uasmon_generation serial. Increment it at the end of set_uasmon(), including same-form reinstallations, and compare saved generations at every polymon and break_armor reentrant boundary. This closes the reachable ABA route through trap-triggered polyself.\n\nCo-Authored-By: Codex GPT-5 <noreply@openai.com>\nAgent: agent:xorn session:43208d29-391f-48dc-ab4a-f9f4af7ded26
Author
|
Updated the PR source branch with commit 97b8435. The reentrancy checks now use a monotonic The public formal verification report is here: |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
A polymorph can trigger another form change while
polymon()is still applying the first form's effects. When that happens, the remaining code can use stale information about the hero's form.This can cause three visible bugs:
impossible()when removing a light source that was never created;break_armor()removing equipment according to a form the hero no longer has.This PR fixes those cases by:
polymon()if a nested call changed the form again;break_armor()between equipment blocks if the form changed.The changes are split into three independent commits. Each bug was reproduced before and after the fix using the same seed, datetime, and keystream. Repros and full analysis are available here.
AI agents helped find and test the edge cases; I reviewed the patch.
Fixes #1682.