Visitar URL original
polyself: handle a second form change that happens during a polymorph by davidbau · Pull Request #1681 · NetHack/NetHack · GitHub
Skip to content

polyself: handle a second form change that happens during a polymorph - #1681

Open
davidbau wants to merge 4 commits into
NetHack:NetHack-5.0from
davidbau:bugreport/10-polyself-reentrancy
Open

davidbau wants to merge 4 commits into
NetHack:NetHack-5.0from
davidbau:bugreport/10-polyself-reentrancy

Conversation

@davidbau

@davidbau davidbau commented Sep 18, 2026 •

Copy link
Copy Markdown

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:

  • an impossible() when removing a light source that was never created;
  • equipment cleanup running twice, including a second artifact blast;
  • break_armor() removing equipment according to a form the hero no longer has.

This PR fixes those cases by:

  • updating the hero's light source as soon as the form changes;
  • returning from polymon() if a nested call changed the form again;
  • stopping 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.

davidbau and others added 3 commits September 18, 2026 11:13
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
@davidbau

Copy link
Copy Markdown
Author

Updated the PR source branch with commit 97b8435. The reentrancy checks now use a monotonic uasmon_generation counter, incremented at the end of every set_uasmon() installation, including same-form reinstallations. polymon() and break_armor() compare saved generations at their reentrant boundaries, closing the reachable ABA route through trap-triggered polyself().

The public formal verification report is here:
https://github.com/davidbau/nethack-bugreport/blob/main/bugs/10-polyself-reentrant-form-changes/REPORT.md

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Polymorphing can trigger a 2nd form change that is mishandled

1 participant