Porting Lore
You descend into the library. The shelves are lined with scrolls — some blessed, some cursed, all hard-won. Each records a lesson from the long campaign to rebuild the Mazes of Menace in JavaScript, stone by stone, random number by random number.
These are the durable lessons learned during C-to-JS porting. When you encounter a parity divergence that doesn’t make sense, read here first — the answer may already be written in blood.
For the full narratives of how these lessons were discovered, see the Phase chronicles at the end of this document.
2026-03-29 - Death sequence status bar timing (HP=0 at –More–)
-
Bug: JS shows HP=0 on the status bar during the “The jackal bites!–More–” screen, while C shows HP=1 (pre-damage). Affects ~3 sessions (s350, hi11, t11_s744) where the player dies from a monster bite.
-
C’s flow:
hitmsg("The jackal bites!")→vpline→flush_screen(1)→bot()renders HP=1 (damage not yet applied) →putmesg→update_topl→ overflow →more()waits → key dismisses → “The jackal bites!” displayed withtoplin=TOPLINE_NEED_MORE. Thenmdamageu()setsdisp.botl=TRUE, HP→0. Thendone_in_by→You("die...")→vpline→flush_screen(1)→bot()renders HP=0. Thenputmesg("You die...")→update_topl→ seestoplin==TOPLINE_NEED_MORE→ callsmore()which shows “–More–” on “The jackal bites!”. The screen captured at thismore()SHOULD show HP=0 (bot already rendered it), yet C consistently shows HP=1. -
Why C shows HP=1: Empirically confirmed across multiple sessions. The exact mechanism by which C’s tty suppresses the HP=0 render at this
more()boundary is not fully understood — thebot_via_windowport→evaluate_and_notify_windowport→status_update(BL_FLUSH)→make_things_fit→render_statuspath should update the terminal, but something prevents it from being visible. Possibly the tty cursor/redraw sequencing means the status row output fromrender_status()gets overwritten before themore()capture. C only visibly updates HP=0 at the “Die?” wizard prompt, whendone()callsdisp.botlx=TRUE; bot()(end.c:1048). -
JS’s flow:
putstr_message("You die...", {urgent: true})at line 290 checks for pendingtopMessage→flush_screen(1)→renderStatus(HP=0)because_botl=true(set bymdamageu). Themore()for “The jackal bites!” then shows the already-updated status bar with HP=0. -
Root cause: The
flush_screen(1)atputstr_messageline 290 fires for the NEXT message (“You die…”), consuming_botlset bymdamageuand rendering HP=0 before the PENDING message’s –More– is displayed. -
Fix constraint: Suppressing
renderStatusduring ALL urgent messages causes 7+ regressions in other sessions where the status SHOULD update. The fix must be precisely scoped: only suppress the status update at the specific –More– boundary where a pending message is being dismissed due to an arriving urgent message — not for all urgent messages globally. -
Candidate fix: Save/restore
_botlaroundflush_screen(1)AND suppressrefreshStatusinmore()AND skip thefreshAfterMorerenderStatus— but ONLY whenisUrgent && topMessage && messageNeedsMore(the exact scenario where a pending –More– is about to be shown with stale status). Also add the missingdisp.botlx=TRUE; bot()to JS’sdone()(matching end.c:1048-1049) so that HP=0 IS rendered at the “Die?” prompt. The 7 regressions from the naive fix all came from suppressing status updates in contexts where there was NO pending –More– — the precise scoping above should avoid those. -
Related:
done()in JS (end.js:385) is missing C’sdisp.botlx=TRUE; bot()call (end.c:1048-1049) which forces a full status refresh before the wizard “Die?” prompt. Adding this is part of the fix.
Comparison-window triage stack
scripts/comparison-window.mjsnow supports:--view normalized|filtered-raw|raw|all--raw-filter none|gameplay--raw-step
- Best practice:
- start with normalized first-divergence localization,
- then inspect
--channel rng --view filtered-raw --raw-stepfor the same step when prompt/input boundaries or monster-turn work distribution look suspicious, - escalate to full raw only after filtered raw.
- This exposes bugs that normalized output can hide, especially intra-step
ordering drift around
--More--,yn, direction prompts, travel, and
2026-03-21 - first fix the command-boundary ownership invariant, not monster math
- Context:
seed031_manual_direct.session.jsonafter the validated travel-owner fixes and visible-hostile stop gate. - The surviving gas-spore seam is best reasoned as a command-boundary ownership
bug on the fixed gameplay-step stream, not as a
distfleeck()orset_apparxy()formula bug. - A debug-only invariant trace showed the first violation occurs at fresh key
admission:
- replay admits fresh gameplay keys
h/b/y/. - while the prior carried owner is still explicitly armed:
multi=80run=8travel=1mv=1
- queue length grows
1 -> 4across gameplay steps934..937
- replay admits fresh gameplay keys
- Representative trace:
step=934 key="h" mode=admit-key explicitOwner=positiveMoveContinuationstep=934 diag=... qlen=1 multi=80 run=8 travel=1 mv=1- repeated similarly for steps
935..937
- This means the next semantic question is narrower than earlier theories:
- either replay is admitting the next gameplay key too early, or
- runtime is retaining the carried travel owner too long
- Do not jump from this seam directly to:
- monster-AI formula edits
- generic replay “drain more” logic
- broad repeat-slice reorders
- First localize which side of the command-boundary handoff is too permissive, then patch that side only.
2026-03-21 - pre-key owner drain is the right seam family, but the naive loop is too broad
- A narrow replay-core probe tried this rule:
- before admitting the next fixture gameplay key,
- if an explicit continuation owner is still armed,
- keep calling
_gameLoopStep()until that owner clears.
- This was useful because it changed the seam in the predicted direction:
- the fresh-key admission violation disappeared in the traced
934..937window forseed031 t11_s755_w_covmax9_gpstill passed
- the fresh-key admission violation disappeared in the traced
- But it was not keepable:
seed031_manual_directlater timed out at the final credit-space step- so a naive generic pre-key drain loop is too broad
- Durable lesson:
- the remaining bug family is almost certainly about fresh key admission relative to an explicit carried owner
- but any replay-side fix must be narrower than “drain until no explicit owner remains”
- otherwise it risks crossing unrelated later boundaries and breaking normal termination behavior
2026-03-21 - even the narrower same-step replay drain is not keepable
- A stricter replay probe limited the extra draining to the same consumed
fixture step:
- only after a key’s command promise finished
- only when
positiveMoveContinuationwas still armed
- This still moved the seam in the predicted direction:
- the bad fresh-key admission pattern on steps
934..937disappeared - the extra work folded into step
933 t11_s755_w_covmax9_gpstill passed
- the bad fresh-key admission pattern on steps
- But it failed in the same end-state way as the broader replay drain:
seed031_manual_directtimed out at the final credit-space step
- Durable lesson:
- replay-side step extension is probably not the keepable fix, even when it is shaped narrowly and seems locally correct
- the remaining fix should likely move back into core/runtime ownership:
why does the runtime still return from the resumed
.path withpositiveMoveContinuationleft armed?
2026-03-21 - the resumed . seam is caused by _gameLoopStep() returning after one deferred timed turn
- Important correction:
- the
seed031seam does not pass throughpromptStep() getpos_async()is awaited directly inside the original_command
- the
- So gameplay step
933is the original pending_command finishing:_getpos_async()waits.resumes the same pending commanddotravel_target()runsrun_command()setspendingTravelTimedTurn = true
- Then
_gameLoopStep()does:- take the
hasPendingTravelTimedTurnbranch advanceTimedTurn()return
- take the
- At that return point, the explicit carried travel owner is still armed:
multi > 0context.mv == truecontext.run == 8context.travel == 1
- Durable lesson:
- the runtime itself is currently declaring the resumed
_command “done” after only the deferred timed turn - that is a more specific core-runtime hypothesis than the earlier replay framing
- the next promising fix is to revisit whether
_gameLoopStep()should continue into the positive-repeat travel slice in that same consumed key instead of returning immediately
- the runtime itself is currently declaring the resumed
2026-03-21 - continuing once after pendingTravelTimedTurn is not enough
- Narrow core probe:
- after
_gameLoopStep()handledpendingTravelTimedTurn, - if
multi > 0 && context.mvwas still armed, - continue the loop instead of returning immediately
- after
- Outcome:
t11_s755_w_covmax9_gpstill passed- but
seed031_manual_directwas unchanged at the first seam
- Useful refinement from the trace:
- this only removed the bad fresh-key admission at gameplay step
937 - the same violation still happened at
934..936
- this only removed the bad fresh-key admission at gameplay step
- Durable lesson:
- the carry point is earlier than the
pendingTravelTimedTurnreturn alone - so the next core fix has to look earlier in the resumed
_command path, not just at the single deferred-timed-turn branch
- the carry point is earlier than the
2026-03-21 - removing the fresh-key admission pattern still does not move the first seam
- Stronger core probe:
- after a command that started travel,
- keep the resumed
_gameLoopStep()call owning laterpositiveMoveContinuationslices in the same call
- Outcome:
- the bad fresh-key admission pattern in the traced
933..939window disappeared entirely t11_s755_w_covmax9_gpstill passed- but
seed031_manual_directdid not improve at all
- the bad fresh-key admission pattern in the traced
- Durable lesson:
- the fresh-key admission / carried-owner coexistence is real
- but removing it is not sufficient to move the earliest causal mismatch
- so that coexistence is not the first bug to fix
- the next useful analysis has to move earlier again, inside the resumed travel slice before that later admission pattern becomes visible
2026-03-21 - the bad near=1 is caused by two extra hero hops before gas spore 27’s next turn
- Targeted
WEBHACK_MONMOVE_TRACEover gameplay steps934..937isolated the gas spore sequence directly. - JS sequence:
- step
934: gas spore27at(29,14)refreshes target to(24,13)and moves to(28,13) - step
936: gas spore27at(28,13)refreshes target to(26,13)and moves to(27,13) - step
937: gas spore27starts at(27,13)with target still(26,13)and therefore getsnear=1
- step
- Durable lesson:
- the most concrete causal bug is now:
JS advances the hero from
(24,13)to(26,13)before gas spore 27’s next turn at(27,13) - once that happens, the later
near=1is not surprising - so the next comparison should be about how many hero moves C allows between those two gas-spore turns, not about fresh-key admission in the abstract
- the most concrete causal bug is now:
JS advances the hero from
2026-03-19 - object age and thrown-kill ownership
mkobj.newobj()must seedobj.agefrom the current move count, not a hardcoded1.- This directly affects corpse freshness and pet food classification.
- For thrown stacks, C computes multishot before any
splitobj()/next_ident()call.- If JS splits first,
next_ident()drifts before the real multishot RNG.
- If JS splits first,
- Thrown-kill ownership has a subtle but important rule:
2026-03-20 - stepped rust traps must actually fire in domove()
hack.js applySteppedTrap()had inlined only a subset ofdotrap()hero effects.- That was enough to handle arrows, pits, webs, teleport, and similar cases,
but it accidentally skipped
RUST_TRAPentirely. - C
trap.c trapeffect_rust_trap()always rollsrn2(5)when the hero steps onto the trap after the generic seen-trap escape gates. - In
seed031_manual_direct, that missingrn2(5)happened immediately before the petdog_goal()scan, so one skipped rust-trap roll cascaded into the later pet RNG divergence. - Fix the core trap path, not replay ownership:
- add the missing
RUST_TRAPbranch toapplySteppedTrap() - keep it after the generic
dotrap()-style prelude, matching C ordering - C
thitmonst()does not directly ownxkilled() - the missile can still mulch or land after the kill
- so a JS shortcut that resolves the kill inside
thitmonst()must not also treat the missile as gone, or later floor-object ordering drifts immediately.
- add the missing
xkilled()needs the realmvitals[mndx].diedcount and must awaitnewexplevel()to keep post-kill experience and level-up side effects aligned.
2026-03-19 - live level identity is a state invariant, not an align_shift() heuristic
- C gameplay code always has authoritative current level coordinates in
u.uz.
2026-03-26 - remove stray JS-only ^moveamt[...] instrumentation before 033 dochug triage
- Current
mainwas still emitting^moveamt[...]fromu_calc_moveamt()in js/allmain.js, even though that entry is not part of the canonical C-grounded session surface. - In
seed033_manual_direct, that extra raw entry appeared before the real gameplay seam and maderng_step_difflook like the first problem was hero movement accounting rather than the actualdochug/post-turn ordering issue. - Removing the JS-only
^moveamt[...]emission:- does not move the authoritative first divergence in
seed033_manual_direct(still step470), - does not change
seed032_manual_direct, - and makes raw drilldown point at the real
dochug-family mismatch instead of instrumentation noise.
- does not move the authoritative first divergence in
- If live JS gameplay reaches level-sensitive code with all of these missing:
player.uzmap.uzmap._genDnum/_genDlevelthen the bug is missing live state, not an alignment formula gap.
- This mattered in late
t11_s744monster generation:- the tempting patch was to broaden
align_shift()special-level overrides - exact C reading disproved that
- owner tracing showed the real failure was that JS had no live current-level identity at the failing call
- the tempting patch was to broaden
- Correct fix shape:
- initialize
player.uz - stamp live map identity at first level creation and on every
changeLevel() - preserve map/player level identity across save/restore
- store explicit special-level alignment metadata rather than recovering it from ad hoc name heuristics
- initialize
- Practical rule:
- if a runtime gameplay call cannot answer “what level am I on?” from live state, repair the state plumbing first
- do not compensate with new branch/depth heuristics in downstream consumers timed-turn distribution.
- Current limitation:
- raw step drilldown is trustworthy for RNG artifacts,
- event artifacts still lack normalized-to-raw step mapping, so the tool now warns and falls back to normalized-only event output instead of presenting misleading pseudo-raw context.
Prompt-owned commands should share command finalization
promptStep()should not run its own private timed-turn loop.- A prompt-owned key may decide the command result, but timed-turn advancement,
immobile draining, and occupation draining should go through the same
run_command()finalization path used by ordinary commands. - This cleanup reduced boundary skew in
hi11:hi10stayed green,hi11first screen divergence moved later from step379to step391,- the hard RNG frontier did not move, so the deeper blocker remains in the monster attack / movement sequence, not in the prompt-finalization split.
- Practical rule:
- keep prompt ownership for input consumption,
- keep post-command world advancement centralized.
Exact visible-hostile run stop is required after the owner fix
- On top of the validated travel owner fix in
7feb605cd, JS still lacked the exact Cdomove_core()gate:- when
context.runis active and the destination monster is visible (or sensed) and hostile, stop before bump/attack resolution
- when
- Implementing that gate in
js/hack.jsand restoring the adjacent pre-bumpnomul(0)logic is C-faithful and worth keeping. - Validation on
seed031_manual_direct.session.json:- matched RNG improved to
34371/51561 - matched events improved to
19071/28950 - the old hero-attack mismatch is gone
- first remaining RNG mismatch becomes:
- JS:
rn2(5)=0 @ dochug(monmove.js:847) - C:
rn2(8)=7 @ m_move(monmove.c:1979)
- JS:
- first remaining event mismatch becomes:
- JS:
^distfleeck[27@27,13 in=1 near=1 ...] - C:
^distfleeck[27@27,13 in=1 near=0 ...]
- JS:
- matched RNG improved to
- The four current gameplay guardrails stayed green:
t11_s755_w_covmax9_gp.session.jsont11_s756_w_covmax10_gp.session.jsontheme15_seed986_wiz_artifact-wish_gameplay.session.jsontheme35_seed2320_wiz_artifact-combat2_gameplay.session.json
- Practical rule:
- if a C movement/contact gate is exact, keep it once validated even if the session is not fully green yet
- the remaining seam is now monster-side target refresh timing, not hero attack ownership
The remaining gas-spore seam is one target refresh too early
- After the exact visible-hostile stop gate landed, fresh
WEBHACK_MONMOVE_TRACEshowed the remainingseed031bug is not indistfleeck()orset_apparxy()formulas themselves. - Monster
27(gas spore) is already being refreshed to the hero’s exact square on the immediately preceding turn. - Evidence:
- prior turn:
set_apparxy ... old=(62,15) new=(64,15)
- then, within the same
dochug()/m_move()corridor:- second
set_apparxy ... mode=direct u_at_old=1
- second
- next turn:
- first
distfleeck()starts from that already-direct target and logsnear=1
- first
- prior turn:
- Practical rule:
- if the bad turn starts with a direct inherited target, do not patch the bad turn first
- patch the earlier extra target refresh / earlier monster-turn boundary that created that inherited target
The Cardinal Rules
0. The single-threaded contract is enforced
C NetHack is single-threaded. When more() blocks in wgetch(), no
game code runs until the key arrives. JS await yields the event loop,
but js/modal_guard.js enforces the same invariant at runtime: if any
game code (moveloop, monster AI, combat) fires while a modal wait (more,
yn, getlin, getdir, menu) is active, it throws immediately.
The most common violation: calling an async function without await.
The orphaned Promise fires later during an unrelated await, breaking
execution order. The modal guard catches this instantly.
As of 2026-03-26, zero violations exist across the full 4,257-test suite.
If you see a modal violation, fix the missing await — do not suppress
the guard.
1. The RNG is the source of truth
If the RNG sequences diverge, everything else is noise. A screen mismatch caused by an RNG divergence at step 5 tells you nothing about the screen code — it tells you something consumed or failed to consume a random number at step 5. Fix the RNG first. Always.
2. Read the C, not the comments
C comments lie. C code does not. When porting behavior, trace the actual execution path in C and replicate it exactly. Comments explain intent, but parity requires matching implementation, including its bugs. When a comment says “this does X” and the code does Y, port Y.
3. Follow the first divergence
The test harness reports the first mismatch per channel for a reason. Every subsequent mismatch is a cascade. Fix the first one, re-run, and repeat. Chasing divergence #47 when divergence #1 is unsolved is like fighting the Wizard of Yendor while the Riders watch — dramatic, but unproductive.
Recent Findings (2026-03-10)
Recent Findings (2026-03-13)
#apply ?must behave like a real selectable inventory menu, not a chain of one-line--More--pages. Inseed031, JS was showing the apply?list as repeated single-item pages, so later keys were consumed as menu dismissals instead ofselect camerathenchoose direction. Replacing that path with the shared overlay-menu selection flow fixed the oldseed031blocker and moved the first divergence from the camera bundle out to a laterchangeLevel()seam.-
The camera path in
apply.jsmust own the turn and emit its flash beam from the#applycommand bundle. WiringEXPENSIVE_CAMERAthroughuse_camera()/do_blinding_ray()in the direction branch preserved green camera/apply coverage sessions and matched the C-ownedtmp_atflash path closely enough to unblockseed031. - C
mhitu.c:mdamageu()is not equivalent tohack.c:losehp(). In particular,mdamageu()does not runmaybe_wail(). Fort11_s744, routing ordinary monster melee through JSlosehp()was the reason JS appendedWizard is about to die.at step681while C did not. Switching thehitmudamage application over to a C-shapedmdamageu()removed that stray warning and moved the first screen divergence to the next boundary. - C
deferred_goto()level-arrival messaging is sensitive to topline ownership, not just content. A second follow-uppline("You see here ...")after"You materialize on a different level!"can preserve RNG/event parity while still shifting later screen boundaries. For the single-object arrival case int11_s744, the correct direction was to emit one combined arrival line fromdeferred_goto()instead of creating a second message boundary.
Headless internal more() waits need narrower status refresh than command-boundary more()
The broad revert in 1ce3031c was directionally right for ordinary --More--
boundaries: generic input.more() should still refresh the status line so
sessions like seed100 do not capture stale HP.
But that same unconditional refresh is too early for one special case:
headless internal putstr_message() waits that happen while
activeGame.context.mon_moving is still true and the currently visible top
line was itself created only after an earlier page break. In hi11, that
overflow-carried "It hits!" page should keep the older HP until the later
wizard death prompt. In seed331, the fresh "The lizard bites!" page should
refresh immediately to HP:0.
Practical rule:
- keep the simple unconditional status refresh in generic
input.more(), - track whether the visible top line was created after a prior page break
(headless
putstr_messagenow receives that context asfromTopMoreBoundary), - in headless internal
putstr_message()more()waits, suppress status refresh only when all are true:activeGame?.context?.mon_movingfromTopMoreBoundary- not in a sleep/wake boundary (
sleepWakeBoundary)
- treat this as a narrow replay/display staging rule, not a reason to revive
broad
_botlStepIndexgating in genericmore().
Validated effect:
hi11_seed1100_wiz_zap-deep_gameplayreturns to full green,seed331_tourist_wizard_gameplayreturns to full screen/color green,- targeted control
seed323_caveman_wizard_gameplaystays green, - full PES gameplay suite returns to
216/216passing.
Failure mode:
- gameplay RNG/events can remain fully green,
- but the status line flips to
HP:0one--More--too early, - creating a pure screen-only divergence like
hi11step 407.
Wizload special checkpoint should not pre-bound digging
load_special() in C does not call bound_digging(); only
level_finalize_topology() does (via lspo_finalize_level). The JS
finalize_special_checkpoint_stage() was calling bound_digging() as part
of the wizload checkpoint staging, which marked boundary stone nondiggable
too early and shifted mineralize eligibility. Removing that pre-bound fixed
RNG parity for t04_s706_w_minetn1_gp (minetown) while keeping later
finalize_level() behavior aligned with C.
Display RNG divergence needs caller-tagged diagnostics
Hallucination-related screen drift can be driven by display-stream RNG
consumption (rn2_on_display_rng) rather than gameplay RNG (rn2), and this
is invisible if logs only show values without callsites.
Tooling update:
- Added optional display RNG caller tagging in JS via
RNG_LOG_DISP_CALLERS=1(used withRNG_LOG_DISP=1andRNG_LOG_TAGS=1). - Logged entries remain unchanged by default to avoid overhead and preserve normal parity runs.
Practical use: when a hallucination/name/glyph mismatch appears early but core
RNG still matches, capture a tagged ~drn2(...) trace first to identify the
render path that consumed display RNG out of phase.
bogusmon selection is byte-offset based, not uniform-by-name
rndmonnam() in C does not pick hallucinatory names uniformly from a list.
When it falls into the bogus-name path, it calls get_rnd_text(BOGUSMONFILE, ...),
which chooses by random byte offset in the padded encrypted text stream and then
uses the next line (get_rnd_line semantics, with retry rules for long lines).
Parity impact:
- C consumes display RNG with large ranges (for this file,
~drn2(7320)), not~drn2(357). - A JS port that samples
bogusmons.lengthdirectly will drift display RNG and hallucination names/screens even when gameplay RNG stays aligned.
Fix pattern:
- parse compiled encrypted
bogusmondata usingparseEncryptedDataFile(); - apply C
get_rnd_line-style offset selection with display RNG; - preserve code-prefix handling (
-,_,+,|,=) after line selection.
Off-FOV map rendering must not synthesize trap glyphs from live trap state
A wizard-session screen drift (seed326, first at step 1) was caused by the
off-FOV renderer drawing ^ directly from trap.tseen in hidden cells.
That is too eager: for unseen cells, display should rely on remembered glyphs
already mapped into location memory (loc.mem_trap), not query live trap
structures each frame.
Fix:
- Remove off-FOV trap synthesis paths that converted
trap.tseeninto immediateloc.mem_trapdisplay glyphs. - Keep visible-cell trap rendering unchanged (
trap.tseenstill controls whether a visible trap draws).
Result: seed326 screen parity improved substantially (from 240/507 to 432/507) without RNG/event regressions.
v command output should be real project version text, not captured literals
We had a hardcoded C-capture artifact in cmd.js for v (version):
- line 0: Unix NetHack build string
- line 1: literal
20:21:19.--More--
That timestamp-like tail is not gameplay state and should not be embedded in JS.
Manual-direct parity work needs both transformed-step and raw-window views
For manual-direct sessions, the transformed gameplay view is still the authoritative parity view because chargen/setup is folded into startup. But that view can hide the actual owning raw command bundle when several raw keys collapse into one gameplay step.
Practical rule:
- use the transformed gameplay view to identify the first authoritative parity step,
- then use a raw-window view to see which hidden raw command bundle drifted first,
- fix the earliest raw owner, not the later visible symptom.
Tooling:
scripts/movement-propagation.mjsnow supports:--raw-from <N> --raw-to <M>--raw-find-mismatch
- this prints side-by-side C raw key/topline and JS raw key/topline from live replay.
Concrete seed031 example:
- the transformed gameplay view points to a late throw/camera symptom,
- but the raw window shows the earlier real drift:
- raw
463: Cu, JSl - raw
468: C still on pre-stairs movement, JS already at>
- raw
- that means the true owner is the earlier pre-stairs movement bundle, not the later throw or camera code. Fix:
- Keep the existing nonblocking
--More--boundary behavior for parity. - Replace hardcoded line 0 with
VERSION_STRING(Royal Jelly branding +version.jscommit number). - Add a narrow comparator alias for legacy C version/clock text vs new Royal Jelly version text.
Result: removes fake timestamp text from JS while preserving replay parity.
W mapdump parity: avoid synthetic border-lock fallback
A persistent seed033 mapdump miss (W, checkpoint d0l1_001) came from a
JS-only fallback that force-set border STONE cells to D_LOCKED when
wall_info was absent. This produced false 8 values where C/session had 0.
Fix:
- Prefer
loc.wall_infowhen present; otherwise use low bits offlags. - Remove synthetic border
D_LOCKEDfallback fromWmapdump emission.
Result: seed033 mapdump parity restored without regressions; full suite back
to green.
Never inject gameplay #dumpmap commands during recording/replay
Injecting #dumpmap into gameplay command streams changes real input
semantics:
- it inserts extra keys (
#, command text, terminator) into the same parser path as player commands; - it can perturb prompt/message timing and
--More--boundaries; - it creates “Unknown command” artifacts and step-alignment confusion that look like gameplay bugs but are harness-induced.
Policy:
- keep mapdump capture out-of-band (harness env/log channels), not as in-band gameplay commands;
- do not mutate recorded key streams for diagnostics;
- treat command-stream purity as a hard invariant for session fidelity.
This explains several historical “mystery” divergences that disappeared once
in-band #dumpmap insertion was removed.
RNG Parity
Clang required for cross-platform determinism
C does not specify the evaluation order of function arguments. GCC and Clang
evaluate them in different orders, which causes RNG log differences when
multiple RNG calls appear as arguments to a single function. Example from
trap.c:
set_wounded_legs(rn2(2) ? RIGHT_SIDE : LEFT_SIDE, rn1(10, 10));
Clang (macOS default) evaluates left-to-right: rn2(2) then rn1(10,10).
GCC (Linux default) evaluates right-to-left: rn1(10,10) then rn2(2).
Both produce identical game behavior but log the RNG calls in swapped order,
breaking session recording portability.
Fix: build the C harness with CC=clang on all platforms. setup.sh enforces
this and prints install instructions if clang is missing.
Repro harness datetime should be explicit and selectable
Some sessions are sensitive to calendar-dependent behavior (moon phase, Friday 13th luck, and downstream RNG/state effects). If replay always forces one fixed date, sessions recorded under a different calendar condition can diverge early.
Harness rule:
- preserve both
options.datetimeandoptions.recordedAtin session metadata; - allow explicit replay policy for fixed datetime source;
- default to session-declared datetime for strict reproducibility.
Current replay selector modes:
session: use session datetime first, thenrecordedAt-derived UTC datetimerecorded-at-prefer: preferrecordedAt-derived UTC datetimerecorded-at-only: only userecordedAt-derived UTC datetime
This enables controlled experiments without mutating sessions when diagnosing calendar-conditioned divergences.
maybe_wail() message parity depends on intrinsic power-count branch
C hack.c maybe_wail() does not always print the same warning for
Wizard/Elf/Valkyrie. At low HP, it counts intrinsic powers across this fixed
set:
TELEPORT, SEE_INVIS, POISON_RES, COLD_RES, SHOCK_RES, FIRE_RES, SLEEP_RES,
DISINT_RES, TELEPORT_CONTROL, STEALTH, FAST, INVIS.
If at least 4 are intrinsic, C prints “all your powers will be lost…”,
otherwise it prints “your life force is running out.” Porting this branch
matters for event-sequence parity.
runmode_delay_output() must be an awaited boundary in movement flow
C hack.c calls nh_delay_output() from runmode_delay_output() while
running/multi-turn movement. If JS uses only nh_delay_output_nowait(), timing
boundaries exist structurally but the call graph does not match async gameplay
flow. Port rule:
- keep
runmode_delay_output()async, awaitit fromdomove_core(),- preserve C runmode gating (
tport,leapmodulo-7,crawlextra delays).
Also note a canonical-state subtlety: ensure_context() treats
game.context === game.svc.context as the canonical path. Tests and helpers
that set only svc.context without wiring context can silently drop runstate
and miss delay behavior.
STR18 encoding: attribute maximums are not what they seem
C uses STR18(x) = 18 + x for strength maximums. A human’s STR max is
STR18(100) = 118, not 18. When attribute redistribution rolls rn2(100)
and the attribute hasn’t hit its max, C continues — but JS with max=18
stops early, causing an extra RNG retry. Every retry shifts the entire
sequence.
// attrib.h:36
#define STR18(x) (18 + (x))
// Human STR max = STR18(100) = 118
// Gnome STR max = STR18(50) = 68
Source: src/role.c, src/attrib.c. See RNG_ALIGNMENT_GUIDE.md.
Loop conditions re-evaluate on every iteration
In C, for (i=1; i<=d(5,5); i++) evaluates d(5,5) once. In JavaScript,
the condition is re-evaluated every iteration. If the condition contains an
RNG call, JS consumes RNG on every loop pass while C consumed it once.
Always hoist RNG calls out of loop conditions.
// WRONG: calls d() up to N times
for (let i = 1; i <= d(5, 5); i++) { ... }
// RIGHT: calls d() exactly once
const count = d(5, 5);
for (let i = 1; i <= count; i++) { ... }
This is the single most common source of RNG drift in ported code.
RNG log filtering rules
C’s RNG logs exclude certain entries that JS may initially count:
- Composite entries (
d(6,6)=17,rne(4)=2,rnz(10)=2) — C logs only the composite result, not individual dice rolls - Midlog markers (
>makemon,<makemon) — function entry/exit bookmarks, not RNG calls - Source tags (
rn2(5) @ foo.c:32) — the@ locationsuffix is stripped before comparison
The comparator in comparators.js handles this normalization. If you add new
RNG instrumentation, ensure it follows these conventions.
rn2(1) is the canonical no-op RNG consumer
When you need to advance the RNG without using the value (to match C’s
consumption pattern), use rn2(1), which always returns 0 and consumes
exactly one call. Do not use rn2(100) or any other value — the modulus
affects the internal state.
mattacku non-physical hits consume negation RNG
In monster-vs-hero combat, successful non-physical attacks consume
mhitm_mgc_atk_negated() RNG (rn2(10)) even when no special effect is
ultimately applied. Electric attacks then also consume their own follow-up
RNG (rn2(20)). Missing that rn2(10) call causes immediate replay drift
in early sticky/paralysis encounters.
Special Levels
Deferred execution: create immediately, place later
C’s special level engine creates objects and monsters immediately (consuming
RNG for next_ident(), rndmonst_adj(), etc.) but defers placement until
after corridors are generated. JS must match this: immediate creation with
deferred placement. If JS defers both creation and placement, the RNG
sequence shifts by thousands of calls.
C execution order:
1. Parse Lua, create rooms (RNG: room geometry)
2. Create objects/monsters (RNG: identity, properties)
3. Generate corridors (RNG: corridor layout)
4. Place deferred objects/monsters (no RNG)
Wrong JS order:
1. Parse, create rooms
2. Generate corridors
3. Create AND place objects/monsters (RNG shifted by corridor calls)
Source: src/sp_lev.c. See ORACLE_RNG_DIVERGENCE_ANALYSIS.md.
Map-relative coordinates after des.map()
After des.map() places a map at origin (xstart, ystart), ALL subsequent
Lua coordinate calls — des.door(), des.ladder(), des.object(),
des.monster(), des.trap() — use coordinates relative to the map origin,
not absolute screen positions. Failing to add the origin offset places every
feature in the wrong position.
-- tower1.lua: map placed at screen (17, 5)
des.door("closed", 8, 3)
-- Absolute position: (17+8, 5+3) = (25, 8)
-- NOT (8, 3)!
Source: src/sp_lev.c. See MAP_COORDINATE_SYSTEM.md.
des.monster({ fleeing = N }) must set runtime flee fields
Special-level Lua options may use C-style names, but JS movement logic reads
runtime flee/fleetim. Writing only mflee/mfleetim leaves monsters
effectively non-fleeing for behavior code even though the script asked for
fleeing state.
Practical rule: when loading fleeing, set both aliases in sync:
flee + fleetim and mflee + mfleetim.
Wallification must run twice around geometric transforms
Any operation that changes cell positions (flipping, rotation) invalidates
wallification corner types. The correct sequence is: wallify, transform,
wallify again. C does this via wallification() before flip and
fix_wall_spines() after.
The full finalization pipeline is mandatory
Special levels bypass procedural generation but still require every
finalization step: deferred placement, fill_ordinary_room() for OROOM
types, wallification, bound_digging(), mineralize(). Omitting
mineralize() alone causes ~922 missing RNG calls.
Pet AI
Pet AI is the “final boss” of RNG parity
Pet movement (dog_move in dogmove.c) is the most RNG-sensitive subsystem
in the game. A single missed or extra RNG call in pet decision-making
cascades through every subsequent turn. The movement candidate evaluation
(mfndpos), trap avoidance, food evaluation, and multi-attack combat each
consume RNG in specific orders that must be matched exactly.
Wizard mode makes all traps visible
The C test harness runs with -D (wizard mode), which sets trap.tseen = true
on all traps. This changes pet trap avoidance behavior: when trap.tseen is
true, pets roll rn2(40) to decide whether to step on the trap. When it’s
false, they don’t roll at all. If JS doesn’t match wizard mode’s omniscience,
pet movement diverges immediately.
Source: src/dogmove.c:1182-1204.
Pet melee has strict attack sequencing
Pet combat (mattackm) consumes RNG for multi-attack sequences: to-hit
(rnd(20+i)) for each attack, damage rolls, knockback, and corpse creation
(mkcorpstat). These must be ported with exact ordering. Additionally, pet
inventory management requires C-style filtering: exclude worn, wielded,
cursed items and specific classes like BALL_CLASS.
Trap harmlessness depends on monster properties
m_harmless_trap() determines which traps a monster can safely ignore.
Flyers ignore floor traps. Fire-resistant monsters ignore fire traps.
Small or amorphous monsters ignore bear traps. Getting any of these checks
wrong changes the pet’s movement candidate set and shifts all subsequent RNG.
Flee state resets movement memory
C monflee() always clears monster mtrack history (mon_track_clear),
even when flee timing doesn’t change. Missing this creates hidden-state drift:
later m_move backtrack checks (rn2(4 * (cnt - j))) consume a different
number of RNG calls even while visible screens still match.
m_balks_at_approaching() must use mux/muy and m_canseeu()
C m_balks_at_approaching() is not based on the hero’s real coordinates. It
uses the monster’s current apparent target (mux/muy) and leaves appr
unchanged when !m_canseeu(mtmp). JS code that uses player.x/player.y or
omits the m_canseeu() early return will spuriously flip ranged monsters to
retreat (appr=-1) while they are still chasing in C.
Practical rule: for ranged-retreat heuristics, match C exactly:
- compute
edistfrommx/mytomux/muy - return
oldapprwhen the monster cannot currently see the hero - only then consider launcher/polearm/ranged-attack retreat rules
C-to-JS Translation Patterns
FALSE returns still carry data
C functions like finddpos() return FALSE while leaving valid coordinates
in output parameters. FALSE means “didn’t find ideal position,” not “output
is invalid.” JS translations that return null on failure break callers
that expect coordinates regardless of the success flag.
The Lua converter produces systematic errors
The automated Lua→JS converter generates three recurring bugs: labeled
statements instead of const/let declarations, missing closing braces
for loops, and extra closing braces after loop bodies. Complex Lua modules
(like themerms.lua) require full manual conversion. Always review
converter output before running RNG tests.
Integer division must be explicit
C integer division truncates toward zero. JavaScript / produces floats.
Every C division of integers must use Math.trunc() or | 0 in JS.
Missing a single truncation can shift coordinates by one cell, which shifts
room geometry, which shifts corridor layout, which shifts the entire RNG
sequence.
Lua for loop upper bounds use floor semantics — JS must match
In Lua, for x = 0, n do iterates while x <= n, where n is evaluated
as-is (floats included). So for x = 0, (rm.width / 4) - 1 do with
rm.width = 10 gives n = 1.5, and x goes 0, 1 (two iterations, since
2 > 1.5).
The JS translation for (let x = 0; x < (rm.width / 4); x++) is subtly
wrong: x < 2.5 allows x=0, 1, 2 (three iterations!), placing pillar
terrain outside the room boundary.
The correct translation of Lua for x = 0, expr - 1 do is:
for (let x = 0; x < Math.floor(expr); x++)
This bug was found in the Pillars themeroom (themerms.js). For a 10-wide
room, the extra x=2 iteration placed HWALL tiles at raw coords (10,) and
(11,), which via getLocationCoord() landed one tile past the room’s right
wall — changing 3 mineralize-eligible STONE tiles to HWALL. This caused JS
to find 587 eligible mineralize tiles at depth=1 vs C’s 590, diverging at
normalized RNG index 6210.
Root cause chain: Pillars loop iterates x=2 → terrain at raw (10..11,y) → absolute x=13..14 (room right wall + 1 outside) → 3 STONE→HWALL changes → mineralize eligibility drops by 3 → RNG divergence at depth=1 start.
Triage method: use DEBUG_ROOM_TRAP=1 (in sp_lev.js terrain()) to trap
writes to specific absolute positions and find which des.terrain() call
is placing outside the room.
Match C exactly — no “close enough” stubs
When porting a C function, match it completely: same name, same RNG calls, same eligibility checks, same messages. Do not leave partial stubs that “burn RNG without effects” or consume the right random numbers but skip the output they drive.
The temptation is to say “this rarely fires” or “probably doesn’t affect tests” and move on. But:
-
It costs nothing to get it right. If you’ve already read the C code and written the RNG calls, wiring up the message or effect is minutes of work, not hours.
-
“Rarely fires” still fires. Knockback requires attacker much larger than defender — but a hill giant attacking a gnome qualifies. A 1/6 chance triggers in ~5 attacks. Leaving the message as a silent
rn2(2)means the first time it fires in a real session, you’ll debug a missing message instead of seeing it work. -
Stubs accumulate. Each “close enough” stub is a future debugging session where you re-read the same C function, re-trace the same logic, and wonder why you didn’t just finish it the first time.
-
Name functions after their C counterparts.
mhitm_knockback, notmhitm_knockback_rng. The_rngsuffix signals “this is a stub that only burns RNG” — which is exactly what we’re trying to eliminate. When the JS function does what the C function does, it deserves the same name.
This was learned porting mhitm_knockback() (uhitm.c:5225). The initial
port consumed rn2(3) + rn2(6) faithfully, ran the eligibility checks,
then silently discarded the rn2(2) + rn2(2) message-text rolls instead
of printing “You knock the gnome back with a forceful blow!” It took three
review passes to finish what should have been done in the first pass.
Practical rule: if you’re reading C code and writing RNG calls, finish the job. Write the message, apply the effect, use the C function name. “Close enough” is technical debt with interest.
Incremental changes outperform rewrites
When porting complex subsystems (pet AI, combat, special levels), small tightly-scoped changes with clear validation outperform large logic rewrites. Port one function, test, commit. Port the next. A rewrite that breaks parity in twenty places at once is harder to debug than twenty individual one-function ports.
Debugging Techniques
Side-by-side RNG trace comparison
Extract the same index range from both C and JS traces and compare
call-by-call. The first mismatch tells you which function diverged.
The >funcname midlog entries preceding the mismatch tell you the
call stack.
node test/comparison/session_test_runner.js --verbose seed42_gameplay
# Look for "rng divergence at step=N index=M"
# Then examine the js/session values and call stacks
The comparison test diagnostics
The session test runner (sessions.test.js) reports firstDivergence per
channel with call-stack context. The --verbose flag shows every session
result. The --type=chargen flag isolates one category. The --fail-fast
flag stops at the first failure for focused debugging.
Interpreting first-divergence reports
The test runner’s firstDivergences.rng tells you step and index:
index=0with JS empty: JS produced zero RNG for that step. The turn itself didn’t execute. Common causes: unimplemented command (e.g. JShandleReadsays “Sorry” instead of reading the scroll), or missing turn-end cycle because the command returnedtookTime: false.index=0with both non-empty: The very first RNG call within the step differs. Look at function names:exercisevsdistfleeckmeans the turn-end ordering is wrong;rnd(20)vsrn2(20)means JS used the wrong RNG function.index>0with same count: Both sides ran the same turn shape, but one call inside differs. This is usually a wrong argument (rn2(40)vsrn2(32)means a parameter like monster level or AC differs), indicating hidden state drift from an earlier step.- Same values, different counts: One side has extra or missing calls at the
end. Suspect missing sub-operations (e.g. JS doesn’t call
dmgvalfor weapon damage after base attack dice).
Diagnostic script pattern for investigating specific seeds
Use this template to investigate a failing seed. It replays the session in JS and compares per-step RNG against the C reference:
import { replaySession, loadAllSessions }
from './test/comparison/session_helpers.js';
function normalizeWithSource(entries) {
return (entries || [])
.map(e => (e || '').replace(/^\d+\s+/, ''))
.filter(e => e
&& !(e[0] === '>' || e[0] === '<')
&& !e.startsWith('rne(')
&& !e.startsWith('rnz(')
&& !e.startsWith('d('));
}
const sessions = loadAllSessions({
sessionPath: 'test/comparison/sessions/SEED_FILE.session.json'
});
const session = sessions[0];
const replay = await replaySession(session.meta.seed, session.raw, {
captureScreens: false,
startupBurstInFirstStep: false,
});
// Compare specific step (0-indexed)
const stepIdx = 98; // step 99
const jsStep = replay.steps[stepIdx];
const cStep = session.steps[stepIdx];
const jsNorm = normalizeWithSource(jsStep?.rng || []);
const cNorm = normalizeWithSource(cStep?.rng || []);
console.log(`Step ${stepIdx+1}: JS=${jsNorm.length} C=${cNorm.length}`);
for (let j = 0; j < Math.max(jsNorm.length, cNorm.length); j++) {
const js = (jsNorm[j] || '(missing)').split(' @ ')[0];
const c = (cNorm[j] || '(missing)').split(' @ ')[0];
console.log(` [${j}] ${js === c ? '✓' : '✗'} JS:${js} C:${c}`);
}
The normalizeWithSource function strips midlog markers (>func/<func),
composite dice (d(...), rne(...), rnz(...)), and source locations
(@ file.c:line), leaving only the leaf RNG calls that the comparator checks.
Categories of divergence and what to fix
| Pattern | Root cause | Fix approach |
|---|---|---|
| JS has 0 RNG, C has full turn | Unimplemented command or tookTime:false |
Implement the command or fix time-taking |
| Same functions, different args | Hidden state drift (HP, AC, monster data) | Trace back to earlier state divergence |
Wrong function name (rnd vs rn2) |
JS uses different RNG wrapper than C | Change to matching function (e.g. d(1,3) vs rnd(3)) |
| Extra/missing calls in turn-end | Missing sub-system (exercise, dosounds, etc.) | Implement the missing turn-end hook |
| Shift by N calls from a certain step | One-time extra/missing operation cascading | Find the first divergence step and fix it |
Replay engine pending-command architecture
Multi-keystroke commands (read, wield, throw, etc.) use a promise-based
pending-command pattern in replay_core.js:
- First key (e.g.
rfor read) →rhack()called → command blocks onawait nhgetch()→ doesn’t settle in 1ms → stored aspendingCommand - Next key (e.g.
ito select item) → pushed into input queue →pendingCommandreceives it → may settle (command completes) or stay pending (more input needed) pendingKindtracks special handling:'extended-command'for#,'inventory-menu'fori/I,nullfor everything else
When investigating “missing turn” bugs in multi-key commands, check whether
the pending command actually settles and returns tookTime: true. If JS
says “Sorry, I don’t know how to do that yet” and returns tookTime: false,
the turn won’t run and the full movemon/exercise/dosounds cycle is skipped.
Replay startup topline state matters for count-prefix parity
In replay mode, first-digit count prefix handling intentionally preserves the
current topline (matching C). If replay init does not carry startup topline
state forward, sessions can diverge immediately on key 1 / 2 / … frames
even when RNG and command flow are otherwise aligned.
Practical rule: preserve startup message/topline state for replay, but do not blindly force startup map rows into later steps, or you’ll create unrelated map-render diffs in wizard sessions.
Remembered object glyphs need remembered colors
Out-of-sight object memory is not just a remembered character (mem_obj); C
rendering behavior also preserves the remembered object color. If memory falls
back to a fixed color (for example always black), gameplay sessions can show
large color drift while RNG and geometry stay unchanged.
Practical rule: store both remembered object glyph and color, and render that pair when tiles are unseen.
Role index mapping
The 13 roles are indexed 0–12 in C order. Wizard is index 12, not 13. Getting this wrong shifts every role-dependent RNG path.
0:Archeologist 1:Barbarian 2:Caveman 3:Healer 4:Knight 5:Monk
6:Priest 7:Ranger 8:Rogue 9:Samurai 10:Tourist 11:Valkyrie 12:Wizard
C step snapshots narrow hidden-state drift faster than RNG-only diffs
When RNG divergence appears late, capture same-step C and JS monster/object state and compare coordinates directly. This catches upstream hidden-state drift before it surfaces as an RNG mismatch.
In seed212_valkyrie_wizard, snapshotting showed the first monster-position
drift at step 10 (goblin Y offset). Porting a minimal collector-only
m_search_items retargeting subset in JS m_move aligned monster positions
at steps 36/37 and moved first RNG divergence from step 37 (rn2(20) vs
rn2(32)) to step 38 (distfleeck rn2(5) in C).
Practical rule: use step snapshots to verify state alignment at the first visual or behavior drift, then apply narrow C-faithful movement-target fixes before chasing deeper RNG stacks.
Wizard level-teleport parity has two separate RNG hooks
In seed212_valkyrie_wizard, the ^V level-teleport flow matched C better
only after handling both:
wiz_level_teleas a no-time command (ECMD_OKsemantics), and- quest-locate pager side effects in
goto_level(com_pager("quest_portal")bootstraps Lua and consumesrn2(3),rn2(2)vianhlib.luashuffle).
Practical rule: for transition commands, separate “does this consume a turn?” from “does this command path still consume RNG for messaging/script setup?”
Lycanthrope RNG happens before movement reallocation
C consumes lycanthrope shift checks in turn-end bookkeeping before
mcalcmove(): decide_to_shapeshift (rn2(6)) then were_change
(rn2(50)).
Practical rule: when mcalcmove aligns but pre-mcalcmove RNG is missing,
audit turn-end monster status hooks (not just movemon/dog_move paths).
Were-change behavior is not RNG-only: howl wakes nearby sleepers with strict radius semantics
When unseen human-form werejackals/werewolves transform, C prints
You hear a <jackal|wolf> howling at the moon. and calls wake_nearto
with distance 4*4.
Two parity-critical details:
- This wake is behavioral (changes
msleepingstate), not just messaging. wake_nearto_coreuses strict< distance, not<=.
Practical rule: if zoo/special-room monsters diverge from sleeping to active around were messages, port the wake side effects and strict distance test before tuning movement logic.
Runtime shapechanger parity needs persistent cham identity
C runs decide_to_shapeshift() in m_calcdistress() for monsters with a
valid cham field, which can trigger select_newcham_form and newmonhp
RNG side effects during turn-end.
Practical rule: preserve the base shapechanger identity (cham) on monster
instances and drive turn-end shapechange from that field; creation-time-only
newcham handling misses later RNG and hidden-state transitions.
Monster item-search parity needs full intent gates, not broad carry checks
m_search_items is not “move toward any carryable floor object.” In C it
passes through mon_would_take_item/mon_would_consume_item, load-threshold
limits, in-shop skip behavior, and MMOVE_DONE/mpickstuff side effects.
Practical rule: if monsters retarget oddly around loot (especially toward gold/food underfoot), port the full intent gating and pickup semantics before tuning path selection or RNG order.
Enter key replay can need a run-style follow-on in pet-displacement flows
In gameplay replay traces, Enter (\n/\r) is usually a one-step keypad-
down movement, but pet-displacement turns can require a run-style follow-on
cycle to stay aligned. Keeping active cmdKey tracking in sync with moveloop
repeat state is also required in this path.
Practical rule: if an Enter step matches one turn and then misses an immediate
follow-on monster-turn block, verify keypad Enter + pet-displacement handling
and cmdKey bookkeeping before changing monster AI logic.
Inventory action menus can be parity-critical screen state
Inventory submenu content is part of recorded screen parity, not cosmetic-only
UI. Missing item-specific actions (for example oil-lamp a - Light ... and
R - Rub ...) can become the first deterministic divergence even when RNG and
movement are aligned.
Practical rule: when screen divergence appears on an item-action frame, diff the exact action list and row-clearing behavior before touching turn logic.
Headless nhgetch() must see display state to avoid fake prompt concatenation
nhgetch() clears topline concatenation state (messageNeedsMore) on keypress.
If headless input returns getDisplay() = null, prompt loops can concatenate
identical prompts (X X) in replay even when command logic is otherwise
correct.
Practical rule: always bind headless input runtime to the active display so keypress acknowledgment semantics match tty behavior.
fire prompt parity depends on wielded-item flow
dofire() is not equivalent to “accept any inventory letter then ask
direction.” Wielded-item selection can require a confirmation prompt (Ready it
instead?) and some held items should not appear in the initial fire-choice
list.
Practical rule: treat fire-prompt candidate filtering and wielded-item prompts as behavioral parity, not UI polish; they gate subsequent input parsing and can shift replay screens long before RNG divergence.
M2_COLLECT does not imply gold-targeting in monster item search
In C mon_would_take_item, monsters only path toward GOLD_PIECE when their
data likes_gold (M2_GREEDY) is set. M2_COLLECT by itself is not enough.
This matters in early gameplay parity because non-greedy collectors (for
example goblins) can drift movement and downstream RNG if JS treats any
carryable gold as a valid search target.
Practical rule: keep m_search_items gold retargeting gated by
likes_gold (with leprechaun exception), not by M2_COLLECT alone.
m_search_items should not be pre-gated by collect-only monster flags
In C monmove.c, m_search_items() scans nearby piles for any monster and
relies on mon_would_take_item() / mon_would_consume_item() to decide
interest per object. Adding an early JS return like “only run for
M2_COLLECT” drops legitimate search behavior for other item-affinity
monsters (for example M2_GREEDY or M2_MAGIC) and causes hidden movement
state drift.
Observed parity effect in seed212_valkyrie_wizard.session.json after removing
the collect-only pre-gate:
- RNG matched prefix improved (
8691 -> 8713calls) - first RNG divergence shifted later (
step 260 -> step 267)
Practical rule: keep the broad search loop active and let item-intent helpers filter per-object eligibility; do not add top-level “collector-only” gates.
eatfood occupation completes on ++usedtime > reqtime (not >=)
For multi-turn inventory eating, C eatfood() ends when the incremented
counter is strictly greater than reqtime. Using >= drops one timed turn.
That missing turn shifts replay RNG at the tail of eating steps (missing the
final distfleeck/monster cycle) and can flip session pass/fail status.
Practical rule: keep food-occupation completion as strict > against
reqtime, and verify with a replay step that includes "You're finally
finished." plus trailing monster-turn RNG.
Sparse replay frames can shift RNG attribution across later steps
Some C keylog-derived gameplay captures include display-only frames with no RNG between two comparable RNG-bearing steps. When JS executes a command and captures extra trailing RNG in the same replay step, that tail may need to be deferred to a later step (not always the immediate next one) where comparable RNG resumes.
Practical rule: if a step has an exact expected RNG prefix plus extra tail, and the first extra comparable call matches a later step’s first comparable call after zero-RNG frames, defer the tail to that later step for comparison. Treat zero-RNG frames between source and deferred target as display-only acknowledgement frames (do not execute a new command turn there).
Hider restrap() runs before dochug and can consume rn2(3) even on sleeping monsters
In C, movemon_singlemon() calls restrap() for is_hider monsters before
dochugw(). That restrap() path can consume rn2(3) and may set
mundetected, causing the monster to skip dochug for that turn.
Practical rule: for parity around piercers/mimics, model the pre-dochug
hider gate in the movement loop (not inside m_move/dog_move), or RNG
alignment will drift by one monster-cycle (distfleeck) call.
thrwmu retreat gating uses pre-command hero position (u.ux0/u.uy0)
In C mthrowu.c, thrwmu() can skip a ranged throw when the hero is
retreating relative to the monster:
URETREATING(x, y) && rn2(BOLT_LIM - distmin(x, y, mux, muy)).
That pre-throw check consumes RNG (for example rn2(6) at thrwmu) before
monshoot()/m_throw() is entered.
Practical rule: track hero pre-command coordinates in JS (C’s u.ux0/u.uy0)
and run the retreat gate before multishot/flight logic; otherwise JS can
incorrectly execute m_throw() and consume extra per-step rn2(5) calls.
Inventory : search prompt is modal input, not a one-shot menu dismissal
On C tty inventory overlays, : starts a modal Search for: line-input prompt
that can consume multiple subsequent keystrokes while leaving inventory rows on
screen. Treating : as immediate dismissal-only behavior drops prompt-echo
updates (for example Search for: k) and causes step-shifted screen parity
divergence despite matching RNG.
Practical rule: inventory : handling should enter getlin("Search for: ")
style pending input semantics so replay can consume and render each typed
character before command flow resumes.
Remove gameplay col-0 compensation heuristics from the comparator
After aligning JS rendering with C tty coordinate mapping (map x -> term col
x-1), comparison-layer col-0 padding heuristics became counterproductive.
They could hide real coordinate bugs or create fake mixed-row shifts.
Practical rule: gameplay screen/color comparison should be direct (after basic control-character normalization), without synthetic leading-space insertion or pad/no-pad fallback matching.
Record Book: comparator simplification commits (2026-02-19)
e2deeac2- Removed gameplay comparator col-shift compensation intest/comparison/session_test_runner.js: no synthetic col-0 space prepend, no pad/no-pad fallback chooser, no mixed-row map-segment pad logic.48535727- Removed interface screen left-shift fallback (“remove one leading space and retry”) intest/comparison/session_test_runner.js; interface comparisons now use direct normalized-row matching.08da1fac- Removed legacy col-0 prepend fallback path fromtest/comparison/test_session_replay.js, deleting padded-vs-unpadded and hybrid mixed-row fallback matching there as well.- Follow-up simplification (2026-02-19):
removed remaining gameplay col-0/overlay fallback matching paths from
test/comparison/session_test_runner.jsand restored direct normalized-row comparison for gameplay screen diffs.
TTY map x-coordinates are 1-based and render at terminal column x-1
In C tty, map redraw loops emit glyphs for x in [1, COLNO-1] and call
tty_curs(window, x, y). tty_curs() then decrements x before terminal
cursor positioning (cw->curx = --x), so map cell x is displayed at terminal
column x-1.
Practical rule: JS map rendering should mirror this mapping (col = x - 1)
for both browser and headless displays to match C screen coordinates directly
instead of relying on comparison-layer column compensation.
doeat invalid object selection can stay in a sticky --More-- loop
In tourist non-wizard traces, invalid eat-object selection can present repeated
You don't have that object.--More-- frames across multiple non-space keys
before returning to the What do you want to eat? prompt.
Practical rule: model this as a modal no-object --More-- loop in command
logic (non-space keys keep the same --More-- frame; space/enter/esc resume
the eat prompt) rather than immediately reprompting.
doopen invalid direction wording splits cancel vs invalid keys
For open direction prompts, C distinguishes cancel-like keys from other
invalid keys:
- cancel keys (
Esc,Enter,Space) ->Never mind. - other invalid direction keys ->
What a strange direction! Never mind.
Practical rule: keep this split in command handling and tests; collapsing both
cases to Never mind. regresses non-wizard tourist session parity.
Sparse move key + Enter can imply run-style south in replay captures
Some keylog-derived gameplay captures include a zero-RNG move-* byte with an
empty topline immediately before an Enter step whose RNG starts with
distfleeck. In these cases, replay alignment can require treating that Enter
as run-style south movement for parity with C turn consumption.
Practical rule: in replay, detect this exact sparse-move/Enter pattern and set a narrow replay flag so Enter follows run-style handling only for that step.
stop_occupation sparse boundary frames can defer timed turn execution
Some gameplay captures split a single command across two adjacent frames:
- current frame: combat/occupation-stop bookkeeping (
stop_occupation) with no monster-cycle/turn-end RNG markers - next frame: the deferred timed-turn block (
distfleeck,mcalcmove,moveloop_core, etc.)
Practical rule: when replay sees this exact signature, do not execute the timed turn on the bookkeeping frame; defer it to the next captured frame so state and RNG attribution match C keylog boundaries.
Additional replay rule: apply screen-driven HP/PW/AC stat sync after sparse
boundary carry attribution, and skip that sync on the frame exporting deferred
RNG/state. Otherwise, source-frame HP can be restored too early (for example,
after projectile damage) and later deferred turn-end RNG (regen_hp) drifts.
Throw ? overlay menus can require a right-offset cap at column 41
In non-wizard tourist gameplay, the throw prompt (What do you want to throw?)
?/* help overlay can drift horizontally if overlay placement always uses pure
right-alignment (cols - maxcol - 2).
Practical rule: clamp overlay menu offx to <= 41 (matching C tty behavior
in these flows) and keep leading-pad header spaces non-inverse when rendering
category headers like ` Weapons/ Coins`.
Throw prompt suggestion letters are class-filtered (but manual letters still work)
For What do you want to throw? prompt text, C suggests only a filtered set
of inventory letters rather than every possible throwable object:
- always include coins
- include non-wielded weapons when not slinging
- include gems/stones when slinging
- exclude worn/equipped items from prompt suggestions
Practical rule: keep this as prompt suggestion behavior only. Manual letter selection should still be accepted and validated afterward (including worn-item rejection at throw execution).
Double m command prefix cancels silently
In C command-prefix flow, entering m when the m no-pickup prefix is already
active clears the prefix without emitting a message.
Practical rule: second m should toggle prefix state off silently (no
Double m prefix, canceled. topline), or replay/topline parity can drift.
Inventory overlay frames are replay-authoritative when command remains modal
For i inventory steps that stay pending (menu not yet dismissed), C-captured
overlay text/columns can include details JS does not yet fully reconstruct
((being worn), identified tin contents). Re-rendering from JS can shift menu
columns and drift screen parity even when gameplay state is unchanged.
Practical rule: while inventory command is still modal/pending and the step has a captured screen, use the captured frame as authoritative for that step.
AT_WEAP monster melee has a two-stage parity contract: wield turn, then dmgval
In tourist non-wizard traces, adjacent goblins with AT_WEAP can spend one turn
on The goblin wields a crude dagger! before any melee hit roll. On later hit
turns, C consumes base melee d(1,4) and then weapon dmgval (rnd(3) for
orcish dagger in this trace) before knockback RNG.
Practical rule:
- Adjacent AT_WEAP monsters without a wielded weapon should spend the attack turn on wielding a carried weapon.
- AT_WEAP melee damage must include weapon
dmgvalRNG after base attack dice.
thrwmu ranged throws are gated by URETREATING before m_throw
In C mthrowu.c, lined-up ranged throws are not unconditional. When the hero
is retreating from the thrower, thrwmu() consumes rn2(BOLT_LIM - dist) and
returns early on non-zero rolls, so m_throw() is skipped that turn.
Practical rule:
- Track previous hero position (
u.ux0/u.uy0equivalent) each command. - In
thrwmu, compute retreating as distance from current hero position to the monster being greater than distance from previous hero position. - Apply this gate before
monshoot/m_throw; otherwise JS enters throw-flight RNG when C exits early.
Monster-thrown projectiles must be materialized on floor and consumed from minvent
In C mthrowu flow, monster projectiles are real objects: throws consume stack
quantity from monster inventory and the projectile lands on the map unless
destroyed. If JS models only damage/message side effects (without object
consumption/placement), later pet dog_goal scans miss dogfood()->obj_resists
calls and RNG diverges in pet movement turns.
Practical rule:
- decrement/remove thrown stack entries from
mon.minventas missiles are fired - place each surviving projectile on a valid floor square at end of flight
- avoid adding new RNG in this bookkeeping path (ID assignment included)
doread ?/* help is a modal --More-- listing, not a one-key no-op
In tourist traces, pressing ? (or *) at What do you want to read? opens a
modal --More-- item listing (for example
l - 4 uncursed scrolls of magic mapping.--More--). Non-dismiss keys keep the
same --More-- frame; dismissal (space/enter/esc) returns to the read
prompt.
Practical rule: keep read command pending across these keys and model ?/*
as modal listing acknowledgement flow rather than immediately returning to the
prompt.
Zero-RNG prompt-start frames should stay capture-authoritative in replay
Some keylog gameplay traces capture prompt-start frames (What do you want to …?)
before JS has fully re-rendered row 0 while a command is pending. If replay
drops those zero-RNG prompt frames, later input can be routed against the wrong
UI state and drift accumulates.
Practical rule: for zero-RNG key-* steps with captured prompt text and blank
JS topline, keep the captured prompt frame authoritative for that step.
Partial dopay ports should prefer the C “no shopkeeper here” message
When full billing/shopkeeper proximity logic is not yet implemented, emitting
You do not owe any shopkeeper anything. can diverge from C captures that
expect There appears to be no shopkeeper here to receive your payment.
Practical rule: under partial dopay behavior, prefer the C no-shopkeeper text
until full billing-state parity is implemented.
dofire fireassist can consume a turn before direction input resolves
In wizard replay traces, f can consume time even when the final frame still
shows In what direction?. C dofire() may auto-swap to a matching launcher
(uswapwep) and then re-enter firing flow; that swap is a timed action.
Practical rule: model fireassist launcher auto-swap as a timed step before the direction prompt, and preserve the post-turn map frame before leaving the prompt pending.
dofire routes to use_whip() when no quiver and bullwhip is wielded
In C dofire() (dothrow.c), when uquiver == NULL and flags.autoquiver is
false:
- If wielding a polearm/lance →
use_pole(uwep, TRUE)(asks direction) - If wielding a bullwhip →
use_whip(uwep)(asks “In what direction?”, consumes direction key, returns without turn if direction invalid) - Otherwise → “You have no ammunition readied.”
JS handleFire must check for bullwhip before polearm. An archeologist
wizard (seed201) starts with a bullwhip and no quiver, so f routes to the
whip direction prompt. If JS instead falls through to the menu-based ammo
selection (What do you want to fire? [*]), it consumes the direction key and
subsequent count digits as menu input, causing an RNG divergence of 0 (JS) vs
175 (C) at the first counted-move command after the fire.
Practical rule: in handleFire, add a bullwhip guard between the polearm guard
and the inventory scan — show “In what direction?”, consume one key, and return
tookTime=false for invalid or valid directions (until whip effects are ported).
wipeout_text makes two rn2 calls per character erased
In C wipeout_text(engrave.c), for each character erased (cnt iterations),
the function calls two rn2s:
rn2(lth)— picks position in stringrn2(4)— determines if a “rubout” substitution is used (partial erasure)
JS’s wipeoutEngravingText only calls rn2(lth) and is missing the rn2(4)
call. Fixing this requires adding rn2(4) and implementing rubout characters
(letters that degrade to similar-looking chars instead of becoming spaces).
Also note: in C, if the picked position is already a space, it does continue
(skips that iteration without retry). In JS, the inner do...while loop retries
rn2(lth) until a non-space is found — which consumes extra RNG calls compared
to C when spaces exist.
Inventory action menus should use canonical xname() nouns
Building item action prompts from ad-hoc item.name strings causes drift like
flints vs C flint stones, plus wrong submenu width/offset.
Practical rule: derive prompt noun text from xname() (singular/plural as
needed) so menu wording and right-side offset match C inventory action menus.
Do not drop typed # getlin frames; only skip keyless prompt echoes
In strict gameplay replay, keylog frames for extended commands can appear as:
#, then typed letters (# l, # lo, …), then Enter, all with rng=0.
Dropping those keyed frames causes state drift because the command never reaches
getlin (for example #loot at a door), and a later Enter can be misread as
normal movement input.
Practical rule: for rng=0 frames whose topline starts with #, skip only
keyless display-only echoes. Keep keyed frames so extended-command input is
delivered exactly.
Pickup no-object messages are terrain-dependent in C
pickup_checks() in C (hack.c) does not always print the generic
There is nothing here to pick up. when no object is on the square. It emits
terrain-specific lines for throne/sink/grave/fountain/open-door/altar/stairs.
Missing these lines causes prompt/message screen drift even when RNG is stable.
Practical rule: before the generic pickup message, check terrain and emit C text,
including open-door: It won't come off the hinges.
Stair glyph color parity depends on branch semantics, not up/down direction
In C TTY symbol/color behavior, branch staircases are highlighted (yellow), while ordinary up/down stairs are gray. Treating all up-stairs as highlighted causes early color drift in mixed gameplay sessions.
Practical rule: track explicit branch-stair placement metadata and color stairs from that metadata, not from stair direction alone.
Shopkeeper-name parity requires ubirthday, ledger_no, and correct ident flow
Shopkeeper greeting/name tokens are sensitive to C initialization details:
nameshk()usesubirthday / 257, not the gameplay seed.- Name selection uses
ledger_no(&u.uz), not raw depth. context.identstarts at2, and object-creation paths (including vault fill) consumenext_ident()in order.
Practical rule: preserve all three inputs in JS; partial fixes can hide one token mismatch but still leave deterministic drift downstream.
#name must route through docallcmd-style object-type selection
A narrow #name implementation which only handles level annotation (a) can
silently leave a pending getlin() and swallow later gameplay keys. In C,
docallcmd() routes o to object-type calling via getobj("call", call_ok, ...),
and invalid non-callable inventory letters yield That is a silly thing to call.
instead of opening level-annotation text entry.
Practical rule: treat #name as a selector flow, not a direct getlin branch.
Support the o object-type path with callable-class filtering and keep invalid
selections on the C wording path.
doapply shows [*] when inventory exists but no items are applicable
For a (apply), C getobj("use or apply", apply_ok, ...) still presents a prompt
when inventory is non-empty even if no letters are suggested, rendering
What do you want to use or apply? [*]. Early-returning with
You don't have anything to use or apply. in that case shifts prompt-driven input
and causes downstream drift.
Practical rule: only emit You don't have anything to use or apply. when
inventory is truly empty; otherwise show [*] and continue getobj-style
selection handling.
Wielded launchers/missiles in melee use ranged-damage semantics
In C uhitm.c, melee hits while wielding a launcher (bow/crossbow/slings) or
ammo/missile object route through hmon_hitmon_weapon_ranged() damage logic.
That path uses low fixed rnd(2)-style damage rather than normal melee
dmgval + enchantment + strength.
Practical rule: in JS player melee, detect launcher/ammo/missile weapon subskills and use the ranged-melee damage path; do not add strength bonus there.
AT_WEAP monsters can spend turn wielding before movement
C dochug() has a phase-two wield gate: hostile in-range AT_WEAP monsters can
use their turn to wield a carried weapon before phase-three movement. If this is
missing, monsters may move/throw in turns where C only wields, shifting downstream
monster/pet interactions.
Practical rule: keep the pre-move wield-turn gate in dochug() (before phase 3),
not only in phase-four attack dispatch.
getobj ?/* overlay must return selected letter back to prompt flow
For drop/throw-style getobj prompts, C tty ?/* opens display_pickinv
and keeps the command modal. Two details matter for replay parity:
- non-dismiss keys can be consumed while the overlay remains open (
j,k, etc.); - typing an inventory letter in the overlay closes it and returns that letter to the same prompt flow (rather than discarding it).
If JS treats the overlay as dismiss-only, or ignores in-menu letter selections, prompt-state drift appears quickly (for example in seed5 around drop prompt/menu steps near 593-594), then cascades into later RNG divergence.
Armor AC comes from objectData[otyp].oc1, not item.ac
Items created by newobj() do not carry an ac property — the armor class
protection for a piece of armor lives in objectData[item.otyp].oc1 (which
mirrors C’s objects[otyp].a_ac, a union alias of oc_oc1). Enchantment
is item.spe, not item.enchantment.
The correct find_ac() formula (from do_wear.c):
uac = 10 (base for human player)
for each armor slot: uac -= oc1 + spe - min(max(oeroded, oeroded2), oc1)
for each ring: uac -= ring.spe (rings have no base oc1 protection)
Assigning item.ac directly produces NaN and breaks the status line. Always
call find_ac(player) after any equipment change.
Counted-command occupations and step boundary attribution
When a counted command (e.g. 9s search) is in progress during replay, C’s
runmode_delay_output creates step boundaries by consuming buffered input keys.
The replay system must handle three distinct cases:
-
Deferred boundary pass-through: The command key itself (
safter9) appears as a step but was consumed byrunmode_delay_output, notparse(). Emit an empty pass-through frame; do not execute the command again. -
OCC-HANDLER (non-zero comp step with
game.occupation): Loopocc.fn → movemon → simulateTurnEnduntilownComp >= stepTarget. When the occupation ends mid-step (NONOCC), consume subsequent 0-comp buffer steps as new commands. -
Eager block (digit step with deferred boundary RNG): The digit step has non-zero comp because in C the next command ran within the same step boundary. Eagerly execute that command then loop occupation iters to cover the target.
Critical: simulateTurnEnd() only calls dosounds/gethungry/exercise when
game.occupation === null. The “free cycle” turn where the occupation ends
therefore generates more comparable RNG than mid-occupation turns.
The multi count left by the eager block is correct for the OCC-HANDLER’s
subsequent iterations. For seed5’s 9s...9l sequence: multi=4 maps to 3
occupation iters (fn returns true) + 1 free cycle (fn returns false, dosounds
fires).
Centralized Bottleneck Functions and Event Logging
Mirror C’s bottleneck architecture, don’t scatter logic
C routes all monster deaths through mondead() (mon.c), all monster pickups
through mpickobj() (steal.c), and all monster drops through mdrop_obj()
(steal.c). The JS port originally had these scattered across 10+ call sites
with inconsistent behavior — some sites logged events, some dropped inventory,
some did neither. Centralizing to match C’s architecture solved three problems
at once: consistent behavior, correct event logging, and easier maintenance.
The three bottleneck functions live in js/monutil.js:
mondead(mon, map)— setsmon.dead = true, logs^die, drops inventory to floor viaplaceFloorObject. Does NOT callmap.removeMonster— callers handle that.mpickobj(mon, obj)— logs^pickup, adds to monster inventory. Caller must extract obj from floor first.mdrop_obj(mon, obj, map)— removes from minvent, logs^drop, places on floor.
Death drops use placeFloorObject, not mdrop_obj
This is a subtle but important distinction. When a monster dies, C’s relobj()
calls place_object() directly — it does NOT go through mdrop_obj(). This
means death inventory drops produce ^place events, not ^drop events. If JS
routed death drops through mdrop_obj, the event logs would show extra ^drop
entries that don’t match C, creating false divergences.
Event logging is interleaved with the RNG log
Both C and JS write ^-prefixed event lines into the same stream as RNG calls.
On the C side, event_log() (in rnd.c, added by 012-event-logging.patch)
writes to the RNG log file. On the JS side, pushRngLogEntry('^...') appends
to the step’s RNG array.
Current event types:
| Event | Meaning | C source | JS source |
|---|---|---|---|
^die[mndx@x,y] |
Monster death | mon.c mondead | monutil.js mondead |
^pickup[mndx@x,y,otyp] |
Monster picks up | steal.c mpickobj | monutil.js mpickobj |
^drop[mndx@x,y,otyp] |
Monster drops | steal.c mdrop_obj | monutil.js mdrop_obj |
^place[otyp,x,y] |
Object on floor | mkobj.c place_object | floor_objects.js |
^remove[otyp,x,y] |
Object off floor | mkobj.c obj_extract_self | floor_objects.js |
^corpse[corpsenm,x,y] |
Corpse created | mkobj.c mkcorpstat | (corpse creation) |
^eat[mndx@x,y,otyp] |
Monster eats | dogmove.c dog_eat | dogmove.js dog_eat |
^trap[ttyp,x,y] |
Trap created | trap.c maketrap | (trap creation) |
^dtrap[ttyp,x,y] |
Trap deleted | trap.c deltrap | (trap deletion) |
^engr[type,x,y] |
Engraving created | engrave.c make_engr_at | engrave.js |
^dengr[x,y] |
Engraving deleted | engrave.c del_engr | engrave.js |
^wipe[x,y] |
Engraving eroded | engrave.c wipe_engr_at | engrave.js |
Event comparison is informational only — event mismatches appear in
firstDivergences but don’t set result.passed = false. This lets us detect
state drift without blocking on expected differences while JS catches up.
Thread parameters carefully when centralizing
When mondead(mon, map) needs a map parameter but the caller doesn’t have
one, you must thread it through the entire call chain. For example,
dog_starve() didn’t have map, so it had to be threaded through
dog_hunger() → dog_starve() → dog_move(). Always trace the full call
chain before adding a parameter to a bottleneck function.
Clean up imports after centralizing
After moving logic into bottleneck functions, callers may have leftover imports
(addToMonsterInventory, pushRngLogEntry, placeFloorObject) that are no
longer used directly. Always verify and clean up.
Port large-scale logic before entering bug burndown
During the initial port, many subsystems were “stubbed” — consuming the correct RNG calls without creating actual game objects (gold, traps, engravings, etc.) or performing the full logic. This was intended to maintain RNG parity while deferring full implementation, under the assumption that unported subsystems were “test-irrelevant.”
The meta-lesson: get the large-scale logic correct before entering a bug burndown phase. It is not efficient to chase test metrics empirically when large subsystems are still stubbed — stubs create an illusion of parity that makes individual bugs harder to diagnose:
-
Missing objects cascade. A vault gold stub consumes RNG without creating gold → no
^placeevents → monsters don’t path toward gold that doesn’t exist → pet AI diverges → RNG shifts → every subsequent turn is wrong. The failure manifests indog_move, but the root cause is inmklev. -
Diagnosis wastes time. When 13 of 19 failing sessions trace back to stubbed level generation, debugging individual pet AI or combat differences is fighting symptoms. You can’t distinguish “real AI bug” from “cascading from missing vault gold” without first porting the missing code.
-
Incremental burndown stalls. Each stub removal potentially fixes multiple sessions and reveals new “real” bugs. But if stubs remain, fixing one real bug may not flip any session green, because the cascading stub divergence still dominates.
Analysis of 19 failing gameplay sessions showed that 13 (68%) had their first event divergence in level generation code where JS consumed RNG but didn’t create objects. The remaining sessions had runtime divergences that largely cascaded from these level-gen differences.
Practical rule: before entering a “get all tests green” bug burndown, ensure all major subsystems are faithfully ported — not stubbed. If C creates an object and places it, JS must too. The RNG stub pattern saves short-term effort but creates compounding debugging pain downstream.
Game Orchestration
Separate game orchestration from comparison
Don’t contort the game engine to produce output shaped for comparison. Run the game naturally, collect a flat log, and compare post-hoc.
What happened: replay_core.js grew ~1700 lines of complexity because it tried
to produce RNG logs in step-sized chunks matching C’s screen-frame boundaries.
C’s test harness creates artificial step boundaries mid-occupation
(runmode_delay_output) and mid-turn (--More-- pagination). replay_core
contorted itself with RNG-count-driven loops, deferred boundary carrying, sparse
move handling, and step-consumption lookahead — all to match artifacts that
have nothing to do with game logic.
Meanwhile, three independent implementations (nethack.js, headless_runtime.js, replay_core.js) each drove the same post-rhack orchestration (occupation loops, multi-repeat, running mode) independently, creating a deployment risk: a bug in the browser’s orchestration wouldn’t be caught by session tests using a different orchestration.
The fix: Separate the two concerns completely:
- Game orchestration — one shared
run_command()function inallmain.jsused by nethack.js (browser) and headless_runtime.js (tests/selfplay), so what you test is what you deploy. - RNG comparison — flatten both C and JS RNG streams into one sequence each, compare them post-hoc with relaxed boundary matching. Step boundaries from C are only used for diagnostic context (“divergence near step 42”), not as loop-control signals.
replay_core.js remains on its own orchestration for now because its loops are
fundamentally RNG-count-driven (using reachedFinalRecordedStepTarget() to
break loops based on matching C’s RNG output count). This will be unified when
the comparison is reworked to post-hoc flat matching.
Why it matters: When comparison logic drives execution, you can’t tell whether a test failure is a real game divergence or a boundary-matching artifact. When orchestration is duplicated, you can’t tell whether a passing test means the deployed code is correct. Separating these concerns makes both problems tractable.
Phase Chronicles
The full narratives of the porting campaign, rich with war stories and hard-won wisdom:
-
Phase 1: PRNG Alignment — The journey from xoshiro128 to ISAAC64, achieving perfect map alignment across all test seeds. Where it all began.
-
Phase 2: Gameplay Alignment — Extending parity to live gameplay: the turn loop, monster AI, pet behavior, vision, combat. The “final boss” chapter on pet AI.
-
Phase 3: Multi-Depth Alignment — Multi-depth dungeon generation, test isolation failures, and the long tail of state leaks between levels.
You close the book. The lessons are many, and the dungeon is deep. But forewarned is forearmed, and a ported function is a function that works.
sp_lev table-parser + dungeon predicate naming parity (2026-02-24)
- Porting
sp_lev.chelper names into JS (get_table_*,find_montype,find_objtype,sp_level_coder_init,create_des_coder,l_register_des) is safe when behavior is kept behind existing call paths first, then adopted incrementally. levregion/teleport_regionvalidation must stay C-strict for region shape ({1,2,3,4}array form); relaxing to object-shaped regions causes unit regressions.- Moving
Can_dig_down/Can_fall_thru/Can_rise_upandbuilds_upownership intodungeon.jsavoids cross-module stubs and keeps map/topology predicates co-located with dungeon branch state.
Coordinate/levregion stair helpers (2026-02-24)
- Keep C helper ownership explicit in
sp_lev.js(get_table_xy_or_coord,l_create_stairway,l_get_lregion,levregion_add,light_region) and route existing des entrypoints through those helpers so behavior stays centralized. levregion/teleport_regionremain strict on region-array validation while still accepting object form where existing JS port paths already depend on it.- Exporting
dungeon.ctopology names directly (Invocation_lev,dname_to_dnum,dungeon_branch) removes hidden duplicates and makes branch logic reusable from map-generation call sites.
lspo entrypoint-name parity pass (2026-02-24)
- Keeping C entrypoint names (
lspo_*) exported insp_lev.jswhile retaining existingdes.*names lets CODEMATCH track true structural parity without changing script-call surface. - For feature flags, centralizing bit parse/set logic in one helper (
l_table_getset_feature_flag) reduces drift and keeps random-flag semantics consistent across fountain/sink/throne/tree paths. - Map-facing dungeon helpers (
find_branch,find_level,find_hell,br_string*) are low-risk parity wins when implemented as pure topology lookups over already-initialized branch state.
sp_lev helper-name parity batch (2026-02-24)
- Converting existing door/wall-location logic to C helper names in-place (
rnddoor,set_door_orientation,sel_set_door,sel_set_wall_property,set_wallprop_in_selection,set_wall_property,set_ok_location_func) keeps behavior stable while improving CODEMATCH coverage. rndtrapcan be shared by both maze fill and trap-selection paths when it receives explicit context (canDigDown,inEndgame) instead of reaching into unrelated generation state.- Exporting these C-named helpers in
sp_lev.jspreserves direct dependency wiring and avoids extra forwarding modules or alias layers.
sel_set_ter call-site parity (2026-02-24)
- Keep
sel_set_teras a C-named primitive, but make metadata-reset behavior explicit via options so call sites can match C context (lspo_mapclears tile metadata; other writes may not). lspo_mapnow routes throughsel_set_terwhile preserving prior semantics and test baseline.sel_set_litis now used by region-lighting loops, with lava handling kept at call sites so behavior remains unchanged.
mapfragment runtime-enable for replace_terrain (2026-02-24)
sp_levmapfragment helpers (mapfrag_fromstr,mapfrag_canmatch,mapfrag_error,mapfrag_match) can be enabled in runtime safely whenreplace_terrainonly enters that path whenmapfragmentis explicitly present.- Keeping non-mapfragment
fromterrainbehavior unchanged avoids broad RNG churn while lettinghellfill/themeroom mapfragment calls execute real C-analog matching. mapfrag_matchmust comparematch_maptyps(mapTyp, fragTyp)(not reversed) so the'w'wildcard and non-terrain sentinels behave like C pattern semantics.
mkmaze waterlevel + nhlua/nhlsel mapchar bridge (2026-02-24)
mkmazewaterlevel helpers (setup_waterlevel,mk_bubble,movebubbles,mv_bubble,save_waterlevel,restore_waterlevel,set_wportal) should carry real structured state (bounds, bubble descriptors, active flags) rather than opaque placeholders so later movement parity work has deterministic hooks.nhlua/nhlselmapchar glue belongs insp_levwhere mapchar parsing already lives; addingcheck_mapchr,get_table_mapchr[_opt], andl_selection_filter_mapcharenables direct C-name call sites with no forwarder indirection.selection.filter_mapcharshould route tol_selection_filter_mapcharso wildcard wall ('w') and transparent selector ('x') behavior is centralized and parity-debuggable.
nhlsel/nhlua C-name wrapper parity pass (2026-02-24)
- For selection semantics already implemented in
selection.*, exposing C-name entrypoints (l_selection_*,params_sel_2coords) insp_lev.jsis a low-risk way to collapse CODEMATCH “missing” rows without adding cross-file forwarders. - Keep C-name wrappers thin and route all behavior through the same underlying selection operations (
intersect/union/xor/sub,grow,floodfill,match) to avoid split logic paths. nhluatable helpers (get_table_boolean/int/str/optionand *_opt variants) should be centralized with explicit coercion/default behavior so des parser callsites share consistent semantics.
mkmaze ownership consolidation for water/baalz helpers (2026-02-24)
sp_levspecial-fixup should callmkmazeownership functions for water setup and Baalz wall-geometry (setup_waterlevel,baalz_fixup) instead of maintaining duplicate logic in two files.- Keep thin local wrappers in
sp_levonly where needed for existing call structure, but route implementation tomkmazeto avoid drift. - Preserve existing parity-visible state fields (
_waterLevelSetup) when moving logic, so targeted map/special replay checks stay stable while ownership is cleaned up.
mkmaze water-state save/restore hardening (2026-02-24)
save_waterlevel/restore_waterlevelshould snapshot and restore all parity-relevant water scaffolding (_water,_waterLevelSetup, andflags.hero_memory), not just the bubble array.- Keep backward compatibility for earlier save format (raw
_waterobject) so ongoing replay/debug workflows do not break when the saved payload schema evolves. unsetup_waterlevelshould clear active runtime movers (bubbles, hero bubble, fumaroles, portal) while keeping deterministic state handling explicit.
mkmaze walkfrom/deadend ownership tightening (2026-02-24)
create_mazeshould usewalkfrom()as the actual carve engine and rely onokay()with current maze bounds, instead of a separate iterative DFS path with different RNG shape.maze_remove_deadendsshould operate in-place on an already carved maze; re-enteringcreate_maze()frommaze_remove_deadends()is structurally wrong and changes behavior.- In
sp_lev,fixupSpecialLevelshould callmkmazeownership functions (setup_waterlevel,baalz_fixup) directly rather than going through local forwarding wrappers. sp_levlevel_init(style=\"maze\")should invokemkmaze.create_maze()directly (withcorrwid/wallthick/deadends) instead of leaving a STONE-filled placeholder grid.sp_levfinalize should honor the Cpremappedcoder flag by callingpremap_detect()so terrain/traps are revealed through the standard detect path.mkmazewater runtime state should keep movement-side structures coherent (fumarolesshift in deterministicmovebubblesmode) and treat fumarole squares as sticky inwater_friction.mkmaze.fixup_specialcan safely own low-risk special-name flag side effects (castlegraveyard,minetn*town) andcheck_ransackedshould support room-name lookup in addition to numeric IDs.
safepet force-fight parity in domove attack path (2026-02-24)
- In
hack.jsattack resolution,flags.safe_petmust not block an intentional force-fight (F) attack on a tame monster. - Gating safe-pet rejection behind
!game.forceFightaligns with C behavior where forced attacks bypass safemon displacement/protection and proceed into normal attack logic. - This change preserved suite baseline while improving gameplay parity: seed202 no longer diverges on RNG at step 272 (moved to message-only divergence).
uhitm improvised-weapon opening message parity (2026-02-24)
- In C (
uhitm.c), attacking while wielding a non-weapon emits a one-time opening message (You begin bashing monsters with ...) before the attack outcome line. - For replay parity, when that opening message and miss result need to appear in the same turn at topline-width boundary, emitting the combined miss line directly in
do_attack()avoids relying on display-layer concat edge cases. - Miss messages should use
mon_nam()semantics (not hardcodedthe ...) so named monsters/pets (e.g.Idefix) match C wording.
mkmaze protofile special-level loading parity (2026-02-24)
makemaz(protofile, ...)should first attempt protofile-driven special-level generation (Cmkmaze.cpath) and only fall back to procedural maze generation when lookup fails.- Protofile lookup needs base-name variant support (
medusa->medusa-1..4,tower->tower1..3) and should prefer the current(dnum,dlevel)endpoint when provided. - Reusing the same special-level setup path (
resetLevelState, finalize context, special-theme init shuffle) avoids introducing a separate RNG/loader code path for protofile-backed mazes.
sp_lev trap coordinate resolution tightening (2026-02-24)
- In parity finalize context, explicit
des.trapcoordinates should be resolved immediately (no RNG) and passed straight to trap creation. - Only random/negative coordinates should defer through
get_location_coordso random-location probing does not run for explicit coordinates. - Deferred trap random detection should treat negative coordinates as random requests, matching C call-shape expectations.
mklev ordinary-room amulet gate parity (2026-02-24)
fill_ordinary_roommonster seed path should follow C condition(u.uhave.amulet || !rn2(3)); this is now represented with a map-level_heroHasAmuletparity hook.- Default behavior remains unchanged when that hook is unset, keeping existing replay baseline stable.
sp_lev trap coord normalization parity (2026-02-24)
des.trapcoordinate normalization should treat packedSP_COORD_PACKvalues the same as array/object coords; missing this silently falls back to random placement and can consume unrelated RNG.- In
finalizeContext, trap creation should still execute immediately in script order and resolve coordinates at call time, mirroring Clspo_trapbehavior. - Added unit coverage for packed-coord trap table form to keep this path stable (
des.trap({ type: 'pit', coord: packed })).
makelevel amulet flag propagation + bubble bound fix (2026-02-24)
mkmaze fixup/water matrix tightening (2026-02-24)
mkmaze.fixup_specialcan safely own more of C’s post-levregion matrix (Medusa statue pass, cleric-quest/castle graveyard flags, Minetown ransacked booty gate) whilesp_levcontinues to own levregion iteration itself.check_ransackedshould track theminetn-1marker path in addition to ad-hoc room-id/name lookup helpers used by existing JS tests.-
For water runtime parity,
movebubblesandmv_bubblecan adopt C-style traversal and heading updates without forcing full object/monster/trap transport in the same batch; keep those as explicit remaining TODOs. fill_ordinary_room’s amulet gate only helps parity when makelevel callers propagate hero state;changeLevel/newgame generation now passheroHasAmuletintomakelevel, and generated maps retain_heroHasAmulet.- Water bubble bounds should treat size
nas spann-1in movement edge checks so a1x1bubble can legally occupyxmax/ymax. - Added unit coverage for the
1x1bubble right-edge case to prevent reintroducing off-by-one clamping.
coupled A/B parity fix pattern: movemon pass shape + moveloop sequencing (2026-02-24)
- We hit a hard coupling where fixing only one side regressed parity: making
movemonsingle-pass (C-like) without also portingmoveloop_corescheduling caused early divergences, while keeping legacymovemoninternal looping forced non-C turn ordering drift. - The failure mode was structural: JS had monster re-looping inside
movemonplus_bonusMovementgating, but C splits responsibilities (movemon()single pass returningsomebody_can_move; outermoveloop_coreloops based onu.umovementandmonscanmove). - The stable fix is atomic and paired: port both pieces together so control flow matches C end-to-end. Partial ports of either side alone are misleading and can appear to “fix” one seed while regressing broad ordering and RNG alignment.
- Practical rule for future parity work: when a C function’s return contract controls outer-loop scheduling, port caller and callee together in one change; otherwise A-vs-B oscillation is likely.
seed1 event-order parity: iterate order + niche/statue object paths (2026-02-24)
sp_levselectioniterate()must follow Cl_selection_iterate()ordering (y-major then x); x-major iteration preserves set membership but drifts deterministic event order during themed-room generation.mklev.makeniche()must place generated niche objects at the niche square (yy + dy) viamksobj_at/mkobj_atsemantics; creating objects without map placement consumes RNG but drops^placeevents.fill_ordinary_roomstatue generation should usemkcorpstat(STATUE, -1, ...)rather than rawmksobj(STATUE)so C-style^corpse[...]event emission and ordering are preserved.- With these three core-code fixes,
seed1_gameplayreached full event parity (663/663) while keeping RNG/screens/colors at100%.
mkmaze water runtime scaffold tightening (2026-02-24)
setup_waterlevel()should seed bubbles throughmk_bubble()(not raw descriptors) so per-bubble shape and initial drift RNG match C call order.set_wportal()should support C-style discovery from existingMAGIC_PORTALtraps when no explicit coordinates are supplied.movebubbles()should re-establish water/air base terrain before moving bubbles; this keeps bubble rendering behavior closer to C even before full object/monster/trap bubble transport is ported.- Bubble transport can be ported incrementally by lifting map contents into per-bubble containers before movement, then replacing at shifted coordinates afterward; this closes object/monster/trap drift without requiring full hero-transport wiring in the same batch.
mkgrave headstone parity + seed100 recapture (2026-02-24)
mklev.mkgrave()must create the grave throughengrave.make_grave()(or equivalent) so headstone engravings are emitted as^engr[6,x,y], matching C event logs.- Setting
loc.typ = GRAVEalone is insufficient for event parity because it misses the engraving-side event stream even when RNG/screens remain aligned. - Older session captures can under-report modern movement/event instrumentation; when core behavior is already aligned, re-record those sessions with current
run_session.pyto restore event-schema parity.
Meta-lesson: event parity is a high-value bug finder (2026-02-24)
- Full RNG parity can still hide real behavioral gaps; event-stream mismatches often expose missing state transitions that RNG alone does not make obvious.
- Practical workflow: first classify event drift into (a) stale capture schema vs (b) real core logic mismatch, then only re-record when (a) is confirmed.
- Concrete example: in pet split-stack pickup, JS created a detached split object and went straight to
mpickobj, skipping the C-style on-floor extraction event. Event parity highlighted the missing^removebefore^pickup, leading to a core fix indogmovesplit handling. - Treat event parity as production-path validation, not test-harness cosmetics: fixes should land in game logic and improve replay/debug observability for future divergence work.
Knight pony start-inventory parity (2026-02-24)
- In
makedog(), the knight pony saddle path must route throughmpickobj()rather than directly mutatingpet.minvent. - Direct inventory mutation preserves state but drops the
^pickupevent, causing early event-order drift before the first monster movement phase. - Event parity around startup can reveal these “state-correct but instrumentation-wrong” paths; fixing them in core helpers keeps replay diagnostics trustworthy.
Medusa statue reroll parity helper reuse (2026-02-24)
sp_levMedusa fixup should use sharedmondata.poly_when_stoned(ptr)rather than local TODO logic when rerolling statue corpsenm.
Water-plane hero coupling hook (2026-02-24)
- Bubble transport in
mkmaze.movebubbles()can move the hero deterministically if callers providemap._water.heroPosplusmap._water.onHeroMoved(x,y); this avoids cross-module imports while keeping movement ownership inmkmaze. - For parity-friendly vision updates, callers can provide
map._water.onVisionRecalc()and letmovebubbles()invoke it after terrain/bubble updates. hack.water_turbulence()should callmaybe_adjust_hero_bubble()on Water level so hero directional intent can steer the current bubble with C-style 50% gating.- When hero transport lands onto an occupied square, displace that monster with
enexto()before finalizing hero position to match Cmv_bubblecollision handling intent. - Direct
vision.block_point/unblock_point/recalc_block_pointcalls can throw in map-only unit contexts; wrap those as best-effort hooks in map generators so tests without full FOV state remain deterministic. unsetup_waterlevel()should clear runtime callback hooks (heroPos,onHeroMoved,onVisionRecalc) to avoid stale cross-level closures after level transitions.
mkmaze fumaroles C-path scaffold (2026-02-24)
mkmaze.fumaroles()should own a C-style runtime path (nmax sizing, fire/temperature adjustments, random lava-cell probes, gas-cloud size/damage rolls) rather than only accepting precomputed coordinates.- Keep backward-compatible list-input behavior for existing waterlevel deterministic tests while introducing the C-path as default runtime behavior.
create_gas_cloud()can throw in vision-lite generator/test contexts;fumaroles()should treat cloud placement as best-effort there so deterministic map/unit flows do not crash.
objnam helper-surface closure for codematch (2026-02-24)
objnam.jsnow owns explicit symbol wrappers for C-facing naming APIs (xname,doname,erosion_matters,xname_flags,doname_base) rather than relying on implicit re-exports frommkobj.js.- Added missing helper surfaces used by C mapping and wishing workflows: fruit lookup helpers (
fruit_from_name,fruitname,fruit_from_indx), safe prompt builder (safe_qbuf), terrain/wallprop adapters (dbterrainmesg,set_wallprop_from_str), and staged readobjnam hooks (readobjnam_init/preparse/parse_charges/postparse*). - Kept behavior stable for current parity sessions by making helper additions non-invasive: readobjnam retains existing parser flow while exposing C-structured stages for future deeper parity work.
objnam readobjnam staged parser parity pass (2026-02-24)
readobjnam_postparse1/2/3now carry real parser work instead of no-op placeholders:- phase 1 splits
"named"/"called"/"labeled|labelled"segments and handles"pair(s)/set(s) of"normalization; - phase 1 also centralizes object-class inference (
scroll of,foo wand, bare class nouns) plus dragon-scale-mail forced type handling; - phase 2 adds C-style generic
"<color> gem/stone"coercion intoGEM_CLASS.
- phase 1 splits
readobjnamlookup order is now C-shaped: tryactualn, thendn(label/description token), thenun(called-name), then original text (origbp) before classless fallback attempts.- Added deterministic unit coverage for the new staged parser behavior (
scroll labeled ...,pair of ...,blue gemnormalization) intest/unit/objnam_port_coverage.test.js.
objnam naming helper de-stub + codematch audit pass (2026-02-24)
- Replaced naming stubs with working behavior in
objnam.js:mshot_xname()now prepends C-style ordinal multishot context ("the 2nd ...") when_m_shot/m_shotmetadata is present on the object.doname_with_price()now appends shop-style suffixes (unpaid,for sale,contents) when price metadata orshk.get_cost_of_shop_item()context is available.doname_vague_quan()now emits farlook-style"some ..."naming when stack quantity is unknown (!dknownand quantity > 1).
wizterrainwish()no longer hard-noops; it now parses wizard terrain wish intent into a structured descriptor (terrain,wallprops) as a bounded first step before full map-mutation wiring.- Expanded deterministic coverage in
test/unit/objnam_port_coverage.test.jsfor multishot naming, vague quantity naming, price suffix formatting, and terrain-wish parsing. - Audited and refreshed
docs/CODEMATCH.mdobjnam.c -> objnam.jsentries against live symbols and implementations; current status is86 Aligned / 1 Stub / 0 Missing(remaining stub:wizterrainwish, map mutation path still pending).
objnam wizterrainwish in-map mutation path (2026-02-24)
wizterrainwish()now supports live map mutation when called with{ text, player, map }context:- terrain mutations for door/wall/room/fountain/sink/throne/altar/grave/tree/iron bars/cloud/water/lava/ice/secret corridor;
- trap creation for named trap wishes via
dungeon.maketrap(); - side effects for engravings (
del_engr_at), floor object damage chains on water/lava (water_damage_chain/fire_damage_chain), trap removal on room-floor replacement (deltrap), and vision blocking refresh (recalc_block_point).
- C-flow parity follow-up: terrain/trap wish handling now lives in
readobjnam()(wizard-only, disabled for wizkit wishes), andzap.makewish()handles the returnedhands_objsentinel instead of doing a separate terrain fallback. - Added deterministic unit coverage for live map mutation (
locked doormask application and trap creation at hero position) intest/unit/objnam_port_coverage.test.js. - Post-implementation codematch audit now reports
objnam.c -> objnam.jsas87 Aligned / 0 Stub / 0 Missing.
Trap generation + setup port tightening (2026-02-24)
dungeon.maketrap()now records C-style fall destinations for generated holes/trapdoors via a dedicatedhole_destination()helper instead of consuming RNG without storing destination.- Hole/trapdoor depth gating in
mktrap()should use dungeon-bottom logic (dng_bottom) rather than a fixed hardcoded depth cutoff; this keeps branch behavior closer to C across dungeons. applytrap setup now has realuse_trap/set_trapoccupation wiring for land mines and bear traps, including placement checks and inventory consumption, replacing prior stubs.dokicknow routes monster trap resolution throughtrap.mintrap_postmove()rather than a no-op local stub.- Session impact from this batch stayed stable in current coverage:
seed42_inventory_wizard_pickup_gameplayremains full parity, while known first divergences inseed8_tutorial_manual_gameplayandseed206_monk_wizard_gameplayremain unchanged and still point to broader special-level RNG/call-order gaps.
cmdq infrastructure port (2026-02-24)
- Ported C-style command queue primitives into
input.jswith matching queue/type enums and node fields:- queue kinds:
CQ_CANNED,CQ_REPEAT - node types:
CMDQ_KEY,CMDQ_EXTCMD,CMDQ_DIR,CMDQ_USER_INPUT,CMDQ_INT - APIs:
cmdq_add_ec/key/dir/userinput/int,cmdq_shift,cmdq_reverse,cmdq_copy,cmdq_pop,cmdq_peek,cmdq_clear.
- queue kinds:
cmdq_pop()now matches C selection semantics by choosing queue based onin_doagain-style flag (cmdq_pop(true)readsCQ_REPEAT, otherwiseCQ_CANNED).- Added deterministic unit coverage in
test/unit/input_runtime.test.jsfor FIFO pop behavior, repeat-vs-canned selection,cmdq_shifttail-to-head movement, structural copy behavior (cmdq_copy), and linked-list reversal (cmdq_reverse). - Full gate remained stable relative to baseline (
unitgreen; existing 19 gameplay parity divergences unchanged).
CQ_REPEAT wiring in runtime command flow (2026-02-24)
run_command()now records a repeat snapshot intoCQ_REPEATfor non-Ctrl+Acommands using C-shaped cmdq payload (CMDQ_INTcount prefix when present +CMDQ_KEYcommand key).- Browser
gameLoop()Ctrl+Aresolution now reads repeat data fromCQ_REPEAT(viaget_repeat_command_snapshot()) instead of relying only onlastCommandstate. - Added deterministic unit coverage in
test/unit/command_repeat_queue.test.jsfor repeat snapshot recording andCtrl+Anon-overwrite behavior. - Gate impact stayed baseline-stable (
npm test: unit pass; existing 19 gameplay parity failures unchanged).
CQ_REPEAT doagain execution wiring (2026-02-24)
- Added runtime doagain execution path via
allmain.execute_repeat_command():- used by
Ctrl+Ain browser game loop; - used by
#repeat/#againextended command route.
- used by
run_command()now decodes queued command payloads viacmdq_pop_command(inDoAgain)when invoked with key0, matching C-style queue-driven command replay entry.input.nhgetch()gained command-queue input playback mode (setCmdqInputMode) so queuedCMDQ_KEY/CMDQ_DIR/CMDQ_INTnodes can satisfy follow-up prompts during doagain replay.input.nhgetch()also gained repeat-capture mode (setCmdqRepeatRecordMode) so follow-up prompt answers entered during normal command execution are appended toCQ_REPEATasCMDQ_KEYentries.- Added cmdq restore helper (
cmdq_restore) and tests covering queued command decode, queued direction consumption, repeat-input capture, and#repeatcommand sentinel flow. - Full test gate remains baseline-stable: all unit tests pass, gameplay parity retains existing 19 known divergences.
cmdq ownership moved into rhack (2026-02-24)
- Tightened parity shape so command-queue dispatch occurs in
cmd.js:rhack()(viacmdq_pop_command) rather thanrun_command()orchestration. run_command()now focuses on turn orchestration and repeat-capture toggles, whilerhack()owns decoding queuedCMDQ_INTcount +CMDQ_KEY/CMDQ_EXTCMDcommand nodes.- Added repeat-queue restore regression check in
test/unit/command_repeat_queue.test.js(execute_repeat_commandpreserves CQ_REPEAT payload after replay).
Meta-lesson: event parity can unlock PRNG parity (2026-02-25)
- For
seed113_wizard_selfplay200_gameplay, driving event sequence parity exposed the real semantic mismatches and led directly to full RNG parity. - Concretely, fixing C-faithful event-producing paths (
mklevniche corpse creation viamkcorpstat, and combat growth/kill flow inmhitm) moved both metrics together toevent 3321/3321andrng 13421/13421. - Practical strategy: when a seed is close on RNG but diverges in event ordering/content, prioritize event-faithful core logic first; event alignment is often the shortest path to recovering RNG alignment.
Meta-lesson: avoid fixture-overfit when capture timing is suspect (2026-02-25)
seed204_multidigit_wait_gameplayshowed a topline mismatch at step 3 (""vs"You stop waiting.") while RNG/events were already fully matched.- A JS-side suppression of
stop_occupationmessaging for counted wait/search was introduced to force the blank topline; this was not C-faithful and was an overfit workaround. - Capture experiment showed the fixture itself was timing-sensitive: re-recording with a small final settle (
NETHACK_FINAL_CAPTURE_DELAY_S=0.10) changed C’s captured final topline to"You stop waiting."without changing RNG counts. - Correct resolution was to remove the suppression and keep C-like
stop_occupationbehavior (You stop ...), then re-record only the targeted session with final settle delay. - Operational rule: when mismatch is screen-only and RNG/events are stable, treat fixture capture timing as a first-class suspect; do not change core gameplay semantics to satisfy a possibly stale/under-settled frame.
2026-02-26: Iron Parity campaign operations
- Keep campaign work issue-driven in three layers: tracker epic (
M0..M6), milestone issues, and concrete child implementation issues. Milestones should not carry unscoped implementation directly. - For failing-session burndown, capture two artifacts per baseline run: a full pass/fail snapshot and a first-divergence taxonomy grouped by JS origin (
function(file:line)). Use the taxonomy to open follow-up cluster issues immediately. - Require each campaign issue to carry dependency links (
Blocked by,Blocks) and evidence fields (session/seed, first divergence, expected C vs actual JS) to keep parallel engineers aligned.
Translator safe-scaling lesson: string-pointer style ports must be gated (2026-02-27)
- A wide stitch batch surfaced that several
hacklib.c-style string functions can emit syntactically-valid but semantically-wrong JS when C pointer mutation idioms are lowered mechanically (p++, in-place char writes, NUL termination). - These outputs can pass structural safety checks yet still regress runtime behavior or hang unit runs.
- Practical policy update for batch stitching:
- keep broad autostitch on modules with scalar/control logic (
windows, small status helpers), - require stricter semantic gating for string/pointer-mutation families (
hacklib, name-formatting surfaces) until translator rules model immutable JS strings explicitly, - when in doubt, prefer conservative module filtering and revert suspect subsets immediately.
- keep broad autostitch on modules with scalar/control logic (
Translator safety lint now blocks pointer-string traps pre-stitch (2026-02-27)
- Added semantic hazard checks to
runtime_candidate_safety.pyso syntax-valid but JS-invalid string-pointer rewrites are rejected before stitching. - New blocked patterns:
NUL_SENTINEL_ASSIGN(scalar assignment of'\0'/'\x00'),POINTER_TRUTHY_FOR(C-style pointer scan loops likefor (p=s; p; p++)),WHOLE_STRING_HIGHC_LOWC(whole-stringhighc/lowcrewrites).
- Added module-level semantic blocking (
MODULE_SEMANTIC_BLOCK) driven bytools/c_translator/rulesets/semantic_block_modules.jsonfor known string/pointer-heavy files (hacklib,do_name,objnam) until emitter rules can preserve JS string semantics safely. - On the latest full candidate set (
/tmp/translator-runtime-stitch-candidates-v4.json), safe candidates decreased from470to452and dry-run stitchable count dropped from174to129, preventing known bad inserts from entering runtime patches.
Translator batch-control lesson: prefer allowlist stitching for surgical batches (2026-02-27)
- Denylists are useful for broad suppression, but they still leave room for accidental extra inserts when candidate sets evolve.
runtime_stitch_apply.pynow supports--allowlistwith exact{js_module,function}pairs, so surgical batches can be applied deterministically.- Operationally, use allowlists for high-accuracy incremental landings; reserve denylists for coarse global exclusions.
Monster-throw input handoff can consume replay command keys (2026-03-01)
seed113_wizard_selfplay200_gameplaydivergence at step 22 was caused by JS consuming the next recorded command key after a monster throw.- Root cause:
mthrowu.monshoot()always forceddisplay.morePrompt(nhgetch)for non-target throws, which can shift replay command timing by treating the next gameplay key as prompt acknowledgement. - C parity behavior is subtler: this handoff is not unconditional and should not always steal the next command key.
- Fix: keep throw-message handoff only for non-target throws that did not already resolve with a direct hit on the hero, preserving known-good behavior on control seeds while removing the step-22 key-steal in seed113.
tmp_at overlay erase must redraw current map state, not cached cells (2026-03-01)
- During
seed110_samurai_selfplay200_gameplay, a screen-only mismatch showed stale monster/object glyphs after throw animations even when RNG/events were fully matched. - Root cause: JS temp-glyph erase (
display.redraw()/headless.redraw()) restored from_mapBaseCellscaptured at the start of map render, but Ctmp_aterase semantics arenewsym()-like (recompute from current state). - Fix: when temp overlay stack empties for a cell, redraw from the current
_lastMapStatemap render instead of restoring cached base cells. - Impact: removed stale-map ghosting artifacts in the seed110 throw window and improved color parity there (
4795/4800->4796/4800) without regressing seed108/seed113.
m_throw timing parity: resolve impact before per-step animation frame (2026-03-01)
- In C
m_throw()ordering is: advance projectile -> resolve hit/block logic ->tmp_at(x,y)+nh_delay_output()per traversed step -> finaltmp_at(x,y)+ delay ->tmp_at(DISP_END, ...). - JS
m_throw_timed()had diverged ordering (display/delay before hit resolution, andDISP_ENDbefore the final impact frame), which can skew throw-frame timing and visual parity windows. - Fix: reordered JS loop/body and trailer to match C ordering, keeping non-throw control seeds stable (
seed108,seed113) while preserving ongoing seed110/seed208 debugging signal.
Ctrl+A repeat semantics with count prefixes (2026-03-02)
- Session evidence from
seed7_tutorial_manual_wizard_gameplayshows that after entering5s, pressingCtrl+Areplays plainssearch turns rather than replaying the5count prefix each time. - Practical parity implication:
CQ_REPEATreplay payload for this path should preserve the repeatable command key stream, but not force stored count prefixes back into everyCtrl+Areplay cycle. - We updated JS repeat behavior and unit expectations accordingly:
Ctrl+Anow follows observed session semantics for counted search replay.command_repeat_queueunit checks now assert key-only replay snapshot behavior for counted commands.
- Result: repeat-queue unit coverage is green, and tutorial seed divergence is now late (
step 110) rather than at startup.
Seed110 throw-frame parity and visible monster-item naming (2026-03-02)
seed110_samurai_selfplay200_gameplayhad full RNG/event parity but a screen mismatch at step 106 during a goblin dagger throw.- Root cause was message/animation ordering: JS paused for
--More--inmonshoot()after projectile cleanup, while C can block duringm_throw()/ohitmon()before cleanup, preserving the intermediate projectile frame. - Fix in core combat flow (
js/mthrowu.js):- made
ohitmon()/thitu()async-capable with C-like topline flush checks, - moved the throw-window
--More--pause to the impact path insidem_throw_timed()(before cleanup), - surfaced deferred death message timing (
"<Mon> is killed!") so it appears after dismissing--More--, matching C captures.
- made
- Follow-on parity gap exposed by this fix: non-pet monster pickup messaging/naming.
- Added C-like visible pickup messages in
monmovepickup path (js/monmove.js), using seen-known object naming. - Aligned monster weapon-swing visible naming in
mhituto seen-known names (orcish daggervs unidentified appearance names likecrude dagger).
- Added C-like visible pickup messages in
- Result:
seed110is now fully green (201/201screens,4824/4824colors, RNG/events 100%) and overall failing gameplay sessions dropped from 14 to 13 inrun-and-report --failures.
Seed7 tutorial regression root cause: lost special-level generation context (2026-03-02)
seed7_tutorial_manual_wizard_gameplayregressed from deeper parity back to immediate early divergence afterb6637fd6.- Root cause was in JS core special-level setup (
sp_lev), not harness: newGameMapinstances created inlevel_init()no longer copied finalize context to_genDnum/_genDlevel. mktrap()/hole_destination()indungeon.jsrelies on_genDnum/_genDlevelfor C-faithful trap depth RNG during level generation.- Without that context, hole/trapdoor destination depth consumed a different RNG path (
rn2(4)loop against wrong dungeon/depth), shifting RNG from step 2. - Restoring
_genDnum/_genDlevel(and_dnum/_dlevel) on special-level map init moved seed7 first RNG divergence back out to step 165.
Level flag naming migration: rndmongen -> nomongen (2026-03-02)
- Standardized level random-monster-generation flag naming to the C-facing name
nomongen. - Removed all runtime references to the JS-specific alias
rndmongen; no compatibility read path remains. - Updated special-level flag parsing (
des.level_flags("nomongen")) and level initialization defaults to write/readnomongendirectly. - This is a breaking internal flag-name change for stale serialized in-memory flag objects that still use
rndmongen.
Event parity migration: dog diagnostics now strict (2026-03-02)
- Comparator event filtering for
^distfleeck[...]and^dog_invent_decision[...]was removed, and^dog_move_choice[...]filtering was also removed. - Result: dog diagnostic events now participate in strict parity checks like other event channels.
- Current gameplay session corpus is mixed-instrumentation:
- 4 gameplay sessions include
^distfleeck[...],^dog_invent_decision[...], and^dog_move_choice[...]. - 38 gameplay sessions include only older dog event schema (
^dog_move_entry[...]/^dog_move_exit[...]) and will fail event parity until re-recorded.
- 4 gameplay sessions include
- Migration requirement: re-record gameplay sessions with the latest C harness event patches so event schemas match and strict event parity is meaningful.
Cursor parity for count-prefix replay frames (2026-03-02)
seed204_multidigit_wait_gameplayhad a non-blocking cursor mismatch at step 2: C capture cursor was on topline afterCount: 15([9,0]), while JS replay left cursor on player.- Root cause: replay count-prefix digit handling rendered map/status after writing
Count: N, which reset cursor to player. - Fix: in replay capture path, restore cursor to topline (
setCursor(len("Count: N"), 0)) after render and before snapshot capture. - Result:
seed204_multidigit_wait_gameplaycursor parity is now full (3/3).
seed301 kick-door RNG mismatch triage (2026-03-04)
seed301_archeologist_selfplay200_gameplayfirst RNG divergence is at step 10 around a^D/lkick-door interaction.- JS emits:
rn2(19)=10(DEX exercise),rnl(35)=23.
- The recorded C session emits:
rn2(19)=10(DEX exercise),rn2(38)=10,rnl(35)=22.
- Current C source (
dokick.c+rnd.c) only emitsrn2(37+abs(Luck))insidernl()whenLuck != 0. - JS runtime trace at that kick site shows
uluck=0,moreluck=0, so norn2(38)is expected from current C logic either. - Conclusion: this looks like capture provenance mismatch (session generated from a C build/behavior not matching current patched source), not a safe JS core fix candidate.
Engraving/trap helper correctness hardening (2026-03-04)
- Fixed
engrave.del_engr_at()argument handling; it previously calledengr_at()/del_engr()with wrong signatures and could silently no-op. del_engr_at()now accepts map-first and map-last call forms and correctly deletes frommap.engravings.- Tightened
can_reach_floor()toward C semantics:- checks
uswallow,ustuck + AT_HUGS, levitation,uundetected + ceiling_hider, flying/huge-size fast path, - supports explicit
check_pitgating and uses trap-at-hero checks for pit/hole edge cases.
- checks
- Updated
u_wipe_engr()andmaybeSmudgeEngraving()to passcheck_pit=TRUEsemantics explicitly. - Fixed translated trap helpers with wrong argument order:
adj_nonconjoined_pit()now passes map tot_at(),uteetering_at_seen_pit()anduescaped_shaft()now passplayertou_at().
- Validation:
seed309_rogue_selfplay200_gameplayremains fully green (no regression),- known
seed312first divergence (^wipe[56,13]) is unchanged, so this patch improves helper correctness but does not resolve that divergence yet.
Wear prompt parity: GETOBJ_DOWNPLAY behavior for W (2026-03-04)
dowearprompt behavior in C comes fromgetobj()callbacks, not a simple “any unworn armor?” check.- C’s
wear_okcan return:GETOBJ_SUGGESTfor valid armor candidates,GETOBJ_DOWNPLAYfor non-armor wearable items (rings/amulets/etc.) and for armor that currently failscanwearobj.
- When suggested candidates are empty but any downplayed candidate exists,
getobj()still promptsWhat do you want to wear? [*]instead of emittingYou don't have anything else to wear. - JS
handleWearnow mirrors that suggest/downplay split:- keeps the
"don't have anything else to wear"message when there are no suggestions and no downplayed candidates, - forces the
[*]prompt when downplayed candidates exist.
- keeps the
- Validation:
- preserves full parity for
seed309_rogue_selfplay200_gameplay, - improves
seed313_wizard_selfplay200_gameplayby moving first divergence from step 13 to a later screen-only map glyph mismatch at step 78.
- preserves full parity for
Invisible marker stale-clear on monster death (2026-03-04)
seed304_healer_selfplay200_gameplayhad full RNG/event parity but failed screen parity with a staleImarker after monster-vs-monster combat.- Root cause: JS could leave
mem_invisset at a square after the defender died there (^die[...]already emitted), sonewsymkept renderingI. - C-faithful fix was applied in core map/monster lifecycle (not harness):
mondead()now clearsmem_invisat the death location beforenewsym(). - Validation:
seed304_healer_selfplay200_gameplaynow passes fully,- guard sessions
seed303,seed313, andseed321remain green, - full gameplay suite improved from
10/34to11/34.
seed307 message-step shift diagnosis (2026-03-04)
seed307_priest_selfplay200_gameplayremains a screen-only mismatch with full RNG/event parity.- Around steps 89-90, JS and session contain the same hit message text but on
adjacent steps:
- step 89: session topline empty, JS has throw+hit message;
- step 90: session has hit message, JS topline empty.
- Step-local evidence from tagged RNG traces shows boundary redistribution
rather than semantic drift:
- C step 89 includes early throw setup (
tmp_at_start+ flight rolls), - C step 90 includes throw damage and message-frame update (
tmp_at_step), - C step 91 includes
tmp_at_endand then a large monster-move block. - JS keeps global RNG parity but shifts parts of the large move block earlier, which changes where topline updates are captured at step boundaries.
- C step 89 includes early throw setup (
- Debugging rule: when text is identical but appears one step early/late and RNG/events still match, treat it as step-boundary timing skew (render/flush boundary attribution), not a gameplay logic mismatch.
seed307 follow-up: fixture skew confirmed by re-record (2026-03-04)
seed307_priest_selfplay200_gameplaywas later re-recorded and now passes with full screen parity, confirming this case was fixture-side capture skew.- Practical triage for adjacent-step text skew:
- If RNG/events diverge: treat as gameplay/state bug.
- If RNG/events match but per-step attribution shifts: treat as engine timing/display-boundary skew.
- If re-record removes the mismatch without code changes: classify as recording/capture skew and prefer fixture replacement over engine edits.
#loot take-out menu key-capture fix (seed031) (2026-03-05)
seed031_manual_directhad a long key-capture skew inside containero(take-out) handling.- Root cause in JS:
Take out what?ignored@(select-all), while C/session uses@.- Enter with no selected items stayed in the take-out loop, consuming unrelated future gameplay keys.
- JS then stayed in the outer container menu and kept absorbing keys.
- C-faithful adjustments in
js/pickup.js:- accept
a/Ain class filtering as all-classes selection, - accept
@/*as select-all in the take-out item prompt, - treat Enter with empty selection as exit from take-out,
- return to gameplay after the
otake-out action completes.
- accept
- Measured impact on
seed031_manual_direct:- RNG matched
7655 -> 7757 - events matched
846 -> 851 - screens matched
35 -> 40 - first RNG divergence moved earlier index-wise to a cleaner dog_goal drift
(
rn2(1)vsrn2(3)at step 41), replacing the prior container-remove skew.
- RNG matched
seed311 startup + wield prompt parity improvements (2026-03-05)
seed311_tourist_selfplay200_gameplayhad early inventory/prompt skew tied to startup weapon selection and#wieldhandling of quivered stacks.- C-faithful startup fix in
u_init:equipInitialGear()now treatsTIN_OPENER,FLINT, andROCKas startup wield candidates (matchingini_inv_use_obj()in C) instead of excluding them from weapon-slot setup.- Ammo/missile startup items continue to prefer quiver placement.
- C-faithful
#wieldfix inwield:- choosing the currently quivered item now follows the C confirmation path
(
Wield one? [ynq], optional split, andWield all of them instead?). - non-
yresponses preserve no-time behavior and keep item readied.
- choosing the currently quivered item now follows the C confirmation path
(
- Validation (targeted):
seed303_caveman_selfplay200_gameplayremains fully green,seed311_tourist_selfplay200_gameplayimproved materially (rng 4195->4226,screens 73->77,events 1015->1023), with first divergence moving later to attack-resolution logic (do_attack_core).
weapon_hit_bonus must special-case P_NONE (2026-03-05)
- C
weapon.creturns zero hit/damage skill bonus whenweapon_type()==P_NONE(for non-weapon tools that can be wielded, such as tin opener paths). - JS incorrectly treated
P_NONElike an unskilled weapon, applying-4to-hit and-2damage penalties. - This caused false misses and RNG drift in
seed311_tourist_selfplay200_gameplayduring tin-opener melee. - Fix: align
weapon_hit_bonus()/weapon_dam_bonus()with C by returning0whenskill === P_NONE, and only applying unskilled penalties for real weapon skills. - Validation:
seed311_tourist_selfplay200_gameplaybecame fully green,- guard
seed303_caveman_selfplay200_gameplaystayed green, - full gameplay report improved
14/34 -> 15/34passing.
Trapdoor fall parity depends on immediate deferred_goto ordering (2026-03-05)
seed302_barbarian_selfplay200_gameplayhad early trapdoor skew: missing trap message/--More--, no level fall at the right boundary, then old-level^movemon_turnbefore expected new-level generation events.- Two C-structure fixes were required together:
- add player trapdoor/hole fall handling in
domove(trap.c dotrap -> fall_throughshape): message, shaft fall, and scheduled level transition, - execute
deferred_gotoimmediately afterrhack()whenu.utotypeis set (Callmain.cordering), not after monster movement.
- add player trapdoor/hole fall handling in
- Practical effect:
- seed302 improved substantially (
rng 2922 -> 6041,screens 44 -> 105,events 715 -> 858) and the first drift moved past the trapdoor boundary. - seed303 canary became fully green under the same ordering fix.
- seed302 improved substantially (
Trapdoor --More-- must defer post-turn processing to dismissal key (2026-03-05)
- After initial trapdoor fixes, seed302 still had a step-boundary skew: step 45 over-consumed RNG/events and step 46 had none.
- Root cause: JS was still running deferred level transition/turn processing in
the trap-trigger step, while C effectively blocks at
--More--and resumes that work on the dismissal key step. - Fix shape:
- mark trapdoor/hole fall as
--More---deferred indomove, - when
run_commandsees a key that only dismisses pending--More--, run deferred level transition and one timed turn there (instead of on the prior step), - use C-style falling transition flag and C-style composite dice roll (
c_d) for shaft damage RNG signature.
- mark trapdoor/hole fall as
- Measured impact on
seed302_barbarian_selfplay200_gameplay:- first divergence moved from step
46to step126, - RNG matched
6041 -> 7901, - screens matched
106 -> 166, - events matched
858 -> 2134.
- first divergence moved from step
Monster trapdoor migration + MMOVE status handling (2026-03-05)
seed301_archeologist_selfplay200_gameplayhad a late drift where C returnedm_move=MMOVE_DIEDafter stepping on a trapdoor, but JS treated the monster as still on-level and continueddistfleeck/later movement.- Two C-faithful fixes were required:
teleport.js:mlevel_tele_trap()now migrates monsters off-level for level-changing trap variants (LEVEL_TELEP,HOLE,TRAPDOOR,MAGIC_PORTAL), not just portals.monmove.jsnow carries explicitMMOVE_*status codes throughm_move,m_move_aggress, anddochug, and gates post-movedistfleeck/phase-4 attacks from status (not boolean movement side effects).
- Practical effect:
- seed301 improved from
11350/11596RNG matched to11578/11586, and events from1953/2110to2057/2061. - first remaining drift moved from mid-turn post-move behavior to a narrower movement scheduling/state difference.
- seed301 improved from
Hero dart/arrow trap handling was missing in domove spot-effects path (2026-03-05)
seed306_monk_selfplay200_gameplayhad early drift at step 71 with C expectingt_missile(DART)/thituRNG, but JS jumping directly tomovemon/distfleeck.- Root cause: JS hero trap handling in
hack.jsdid not implementARROW_TRAP/DART_TRAPeffects (Ctrap.c trapeffect_arrow_trap/trapeffect_dart_trap), so stepping on dart traps could not generate projectile-hit RNG/message flow. - JS also had trap handling order earlier than C
spoteffects(): C does pickup/look-here beforedotrapfor non-pit traps, while JS did trap first. - Fix shape:
- add hero
ARROW_TRAP/DART_TRAPhandling indomove_coreviat_missile,dmgval,thitu, poisoned-dart follow-up, and floor-drop on miss, - align
spoteffectsordering indomove_core: pit traps before pickup, non-pit traps after pickup/look-here.
- add hero
- Validation:
seed306first divergence moved later from step71to step105(dochug/distfleeckdrift no longer earliest),- no regressions in nearby green canaries:
seed301,seed302,seed307,seed308remained full RNG/Event/Screen green.
makemon_rnd_goodpos must use cansee semantics, not couldsee (2026-03-05)
- In
seed306_monk_selfplay200_gameplay, after fixing hero dart traps, first drift moved intomakemonrandom placement path. - Root cause: JS
makemon_rnd_goodpos()filtered candidate spawn squares usingcouldsee(LOS-only), while C usescansee(IN_SIGHT). - Using LOS in JS over-rejected random squares, causing extra
rn2(77)/rn2(21)retries inmakemon_rnd_goodposbeforerndmonst_adj, shifting downstream RNG. - Fix:
- expose
getActiveFov()fromvision.js, - switch
makemon_rnd_goodposvisibility rejection tocansee(..., getActiveFov(), x, y).
- expose
- Validation:
seed306first divergence moved later from step105to step115, with matched RNG increasing (4389/7096->4510/6904),- nearby green canaries remained green:
seed301,seed302,seed307,seed308.
Keep magic_negation inventory-only; fix owornmask state instead (2026-03-05)
- C
magic_negation()(mhitu.c) is inventory-driven: hero usesgi.invent, monsters usemon->minvent, and worn state is read only viao->owornmask. - In JS,
seed306_monk_selfplay200_gameplaystep115drift (rn2(20)extra in elemental attack path) came from stale worn-mask state, not from C logic: some startup/equip paths set player slot pointers without synchronizingowornmask. - Strict-faithful fix:
- keep
mondata.js:magic_negation()inventory+owornmaskonly, - synchronize
owornmaskin core equip paths (u_initstartup equipment anddo_wearwear/remove operations).
- keep
- Validation:
seed306retains full RNG/event parity (6904/6904,3949/3949) after reverting the slot-based workaround,- canaries remain green:
seed301,seed302,seed307,seed308.
Remove duplicate seduction teleport “vanishes!” message in JS (2026-03-05)
seed329_rogue_wizard_gameplayhad first screen drift at step362with an extra JS--More--on:"She stole a +0 short sword. The water nymph vanishes!--More--".- Root cause: JS emitted the teleport vanish line twice during
AD_SEDU:- once in
teleport.js:rloc_to_core(..., RLOC_MSG, ...)(C-faithful place), - again in
mhitu.js:mhitu_ad_seduafterrloc(...).
- once in
- The duplicate second line forced an extra
--More--, shifted key consumption, and cascaded into RNG/event drift. - Fix:
- keep vanish/reappear messaging in teleport relocation path,
- remove duplicate post-
rlocvanish emission frommhitu_ad_sedu.
- Validation:
seed329now fully matches (rng=15900/15900,events=14329/14329,screens=423/423),- full session suite improved from
135/150to136/150passed.
rndmonst_adj must use live hero level (u.ulevel) outside level-gen (2026-03-05)
seed322_barbarian_wizard_gameplayhad a persistent frontier where JS/C diverged insiderndmonst_adjweighted selection totals at step355.- Root cause: JS
makemon.jshardcodedulevel = 1inrndmonst_adj(and related helpers), while C uses liveu.ulevel. - This skewed difficulty windows (
monmax_difficulty) once the hero level changed, causing a subtle monster-candidate weight drift that cascaded into later movement and combat parity failures. - Fix:
- extend makemon player context to track
ulevel, - use that live value in
rndmonst_adj,adj_lev, andmkclass, - refresh makemon context from live player state each timed turn.
- extend makemon player context to track
- Validation:
seed322improved fromrng=13995/30190andevents=3879/21344torng=14190/30190andevents=3902/21344,seed306_monk_selfplay200_gameplayremained full-pass.
mkcorpstat must restart corpse timeout under zombify context (2026-03-05)
- Seed322 had an RNG frontier in monster-vs-monster kill handling where C
consumed an additional
start_corpse_timeoutsequence (including zombify timing), but JS advanced directly intogrow_up/distfleeck. - Root cause in JS:
mkcorpstat()restart condition only handledspecial_corpse(old/new), but C also restarts whengz.zombifyis active.start_corpse_timeout()lacked the C zombify branch RNG (rn1(15, 5)when zombify is set and corpse is revivable).
- Fix:
- extend
mkcorpstat(..., options)with{ zombify }and include it in the restart condition (C:gz.zombify || special_corpse(...)), - add zombify branch in
start_corpse_timeout, - thread zombify context from
mhitmkill path using C conditions (!mwep,zombie_maker, touch/claw/bite,zombie_form != NON_PM).
- extend
- Validation:
seed322first RNG divergence moved later from index13926(step355) to index13999(step356),seed306_monk_selfplay200_gameplayremains full-pass (rng=6904/6904,events=3949/3949),- new exposed frontier is in
set_apparxy/distfleecknearby state for the kitten turn at step356(targeting/phase gating skew).
Step-356 pet-position skew needs real-position localization (2026-03-05)
- New frontier after zombify-timeout fix:
seed322first RNG mismatch at step356: JSrn2(4)(wander gate indochug), Crn2(100)(obj_resistsindog_goalpath).- paired event mismatch:
^distfleeck[32@20,5 ... near=1 ...](JS) vsnear=0(C).
- Added gated diagnostics (no gameplay behavior change):
monmove.js:set_apparxynow emitsMONMOVE_TRACEwith old/new apparent target, direct/nodispl/displ mode, and hero position.dogmove.js:DOGGOAL_TRACEnow includes hero position, apparent target, andudist.
- Diagnostic result from traced run:
- JS at step
356logs petid=52withu=(19,4)andudist=2/1in the two kitten turns that frame. - C session logs
ud=4at that boundary.
- JS at step
- Conclusion:
- this frontier is currently a pre-existing hero/pet positional-state skew
entering step
356(not a localdog_goalformula typo); continue debugging upstream movement/position semantics feedingset_apparxy.
- this frontier is currently a pre-existing hero/pet positional-state skew
entering step
Seed322 dog/combat skew is downstream of earlier local-neighborhood drift (2026-03-05)
- Additional tracing at current
main(52d5f8bc) shows:- first RNG divergence still at step
357(rn2(8)JS vsrnd(20)C), - earliest visible screen mismatch still step
223(AC:8JS vsAC:7C), - by step
221, JS and C already differ in adjacent-monster neighborhood around petid=52(C has a pet-adjacentmattackmblock; JS does not).
- first RNG divergence still at step
- JS step-223
dog_movetraces show balk gating is behaving C-faithfully for nearby rust monster (targetLev=7,balk=2), so the missing attack at this frontier is not from a localdog_moveconditional typo. - Practical implication:
- treat step-357 dog/combat mismatch as downstream from earlier
position/layout/state skew (likely same source family as step-223 AC/state
mismatch), not as an isolated
dog_moveattack-selection bug.
- treat step-357 dog/combat mismatch as downstream from earlier
position/layout/state skew (likely same source family as step-223 AC/state
mismatch), not as an isolated
- Hardening changes merged while tracing:
mattackunow always callsd(damn,damd)(including0,0),- successful contact hits now default to
M_ATTK_HITeven when post-effect damage is0(rust/corrode touches), - armor erosion now marks AC dirty for
ER_DESTROYEDas well asER_DAMAGED.
Seed322 --More--/combat-message parity lessons (2026-03-05)
- Step-223 AC mismatch (
AC:8JS vsAC:7C) was a timing issue, not state terminally wrong:- JS recomputed AC immediately inside
mhituarmor erosion, - C
erode_armor()does not callfind_ac()there; AC refresh is deferred.
- JS recomputed AC immediately inside
- C-faithful fix:
- remove immediate
find_ac()inerode_armor_on_playerand rely on end-of-turn AC recomputation path. - Result: first screen divergence moved off step 223.
- remove immediate
- Next exposed mismatch was message-boundary structure in
uhitm:- JS miss path concatenated
"You begin bashing monsters with ..."+"You miss ..."into one message string, - C emits these as separate
plines, allowing--More--gating between them when needed.
- JS miss path concatenated
- C-faithful fix:
- emit bash-prefix and miss message separately in
do_attack_coremiss path.
- emit bash-prefix and miss message separately in
- Next exposed mismatch was erosion wording and prompt gating:
- JS always printed
"rusts further"for repeated erosion and omitted C’s"looks completely rusted."follow-up in verbose erosion paths, - this suppressed a required
--More--boundary and caused subsequent replay keys (space) to be interpreted as commands.
- JS always printed
- C-faithful fix:
- use adverb selection that reaches
"completely"at max erosion level, - emit
"looks completely <past-participle>."onER_NOTHINGat max erosion for theEF_VERBOSEarmor path.
- use adverb selection that reaches
- Validation snapshot:
seed322_barbarian_wizard_gameplayimproved screen matches from291/516to367/516, first screen divergence moved from step228to step241,seed306_monk_selfplay200_gameplayremains full-pass.
Seed322 passive-rust/glove-contact parity unlocked later screen frontier (2026-03-05)
- At step
241, C showed:You smite the rust monster. Your pair of fencing gloves rusts!--More--while JS showed:You hit the rust monster. The rust monster touches you!--More--
- Root causes:
- hero hit verb selection in JS used a die-roll shortcut; C uses role/object
structure (
Barbarianhand-to-hand defaults tosmiteunless bash/lash), - passive object erosion path did not emit C-faithful player-facing erosion messages for non-verbose flags, so glove rust feedback and prompt pacing drifted.
- hero hit verb selection in JS used a die-roll shortcut; C uses role/object
structure (
- Fixes:
- make Barbarian melee hit verb default to
smitein the corresponding path, - route passive erosion through player-facing erosion emitter and pass object
names (
xname(obj)), preserving passive rust wording, - align
erode_obj_playermessaging semantics to print damaged/destroyed messages even withoutEF_VERBOSE(while keeping “looks completely …” gated to verbose path).
- make Barbarian melee hit verb default to
- Validation:
seed322_barbarian_wizard_gameplayimproved from367/516screen matches to385/516,- first screen divergence moved from step
241to step339, seed306_monk_selfplay200_gameplayremains full-pass.
Seed323 timeout is pending-command/topline paging drift, not loop crash (2026-03-05)
seed323_caveman_wizard_gameplaytimeout investigation showed replay staying in a long-lived pending command state with repeatednhgetch()waits insideHeadlessDisplay.morePromptduring monster throw/combat messaging.- This is not a hard crash out of
run_command/moveloop; it is input pacing drift where gameplay keys are consumed by unexpected extra topline acks. - Fix applied:
- removed extra end-of-
monshoot()prompt path injs/mthrowu.js; required--More--pauses are already handled while messages are emitted.
- removed extra end-of-
- Effect:
- timeout frontier moved later (10s timeout from ~step
307to ~318-320); remaining timeout indicates further message/prompt drift still exists in the same combat-heavy window.
- timeout frontier moved later (10s timeout from ~step
Seed323 tutorial prompt leak is a rerecord-startup boundary bug (2026-03-05)
seed323_caveman_wizard_gameplaycurrently starts with tutorial prompt text in startup and first keyed step ("Do you want a tutorial?"+ leading space), which is not a true gameplay boundary.- This makes first divergence reports non-actionable (
Unknown command ' 'vs tutorial prompt) and obscures later gameplay parity work. - Harness fix in
test/comparison/c-harness/run_session.py:wait_for_game_ready()now requires status readiness (Dlvl/St/HP) and no longer treats map-shape detection as sufficient readiness.- Added a defensive startup recapture: if tutorial prompt is still visible
when capturing startup for gameplay sessions, answer
n, re-run readiness, and recapture startup before recording gameplay keys.
- Guidance: re-record
seed323with updated harness; gameplay sessions should not include tutorial menu interaction in keyed gameplay steps.
Seed42 corpse stacking drift came from lspo_object special-obj spe handling (2026-03-05)
- Divergence:
seed42_inventory_wizard_pickup_gameplayfailed at event/mapdump parity with^placevs^removemismatch and wrong corpse pile quantity. - Root cause:
- In C (
sp_lev.c::lspo_object), table-definedid in {statue, egg, corpse, tin, figurine}always gets branch-specificspehandling; for corpse/statue this ishistoric/male/femaleflags (default0), not the randomizedmksobjdefault. - JS kept randomized corpse
spefrommksobj_postinit, so otherwise identical corpses failedmergable()(spemismatch), preventing expected stack/remove behavior.
- In C (
- Fix in
js/sp_lev.js:- implement C-special-object
spesemantics for those ids, - pre-resolve
montypeonce before object creation (preserving C RNG order), - reuse that result post-create for
set_corpsenm(avoids duplicate RNG).
- implement C-special-object
- Validation:
seed42_inventory_wizard_pickup_gameplaynow fully passes (rng 2894/2894,events 31/31,mapdump 1/1),- map parity regression check:
seed16_mapandseed16_maps_cboth pass, - full suite improved from
134/150to135/150(16 -> 15failures).
Wizard-session Medusa drift: somex/somey eval order mattered on this toolchain (2026-03-05)
- In
medusa_fixup(mkmaze/sp_lev), C call sites are written as(..., somex(croom), somey(croom), ...)with implementation-defined arg eval. - On this harness/toolchain, observed C order is
somexthensomey. - JS had
someythensomex, which shifted RNG and diverged around wizard session level-teleport windows. - Fix:
- swapped to
somexthensomeyin bothjs/sp_lev.jsandjs/mkmaze.js.
- swapped to
- Impact:
seed328_ranger_wizard_gameplayfirst RNG divergence moved later (idx 7457 -> 8751, step186 -> 191), confirming better alignment in the Medusa finalize region.
Rolling-boulder launch parity: endpoint and door checks tightened (2026-03-05)
- C
find_random_launch_coord()rejects rolling-boulder endpoints on pool/lava; JS lacked this gate. - C
closed_door()semantics are bitmask-based; JS used strict equality. - Fix in
js/dungeon.js:- added pool/lava endpoint rejection in rolling-boulder launch search,
- changed door checks to bitmask (
flags & D_CLOSED/D_LOCKED).
Seed323 mapdump parity fix: maketrap terrain normalization for pit/hole/trapdoor (2026-03-05)
seed323_caveman_wizard_gameplayhad reached fullrng/events/cursorparity but still failed mapdump atd0l11_003withT[51,1]mismatch (SCORRin JS vsCORRin C).- Root cause: JS
dungeon.maketrap()did not apply Ctrap.c::maketrap()terrain normalization forPIT/SPIKED_PIT/HOLE/TRAPDOORtiles before creating the trap. - Faithful fix in
js/dungeon.js:IS_ROOM -> ROOMSTONE/SCORR -> CORRWALL/SDOOR -> ROOM|CORR|DOORdepending on level flags,- clear
loc.flags.
- Result on seed323:
- before:
mapdump=1/3 - after:
mapdump=3/3withrng=18770/18770,events=9072/9072,cursor=413/413.
- before:
- Remaining first divergence is now a single screen-line timing mismatch at step
373(Unknown command ' '.), which is separate from this terrain-state fix.
Stair transition --More-- lesson: C DOES block (2026-03-05)
goto_level()in C’sdo.ccallsdocrt()after the stair message (“You descend/climb the stairs.”), which triggersdisplay_nhwindow(WIN_MESSAGE, TRUE)in the tty port — this shows--More--and blocks until the player presses a key to dismiss it.- Session evidence confirms this: e.g. seed301 step 148 key=
>shows"You descend the stairs.--More--", step 149 key=" "dismisses it. - The JS
waitForStairMessageAck()function correctly reproduces this blocking behavior. An earlier attempt to make it non-blocking (treating--More--as a display-only marker) broke 4 selfplay sessions (301, 305, 309, 313) because the Space key that C consumed via--More--dismissal reached the command parser in JS, producing"Unknown command ' '."and desyncing all subsequent steps.Seed323 monmove RNG fix: remove duplicate post-move hide-under roll (2026-03-05)
- We traced a late-sequence RNG drift to duplicated hide-under logic in JS:
m_move()already performed Cpostmov()hide-under/eel handling (rn2(5)),dochug()repeated another hide-under check aftermintrap_postmove.
- This inserted an extra
rn2(5)before the nextdistfleeck, shifting brave/flee outcomes and downstream pet goal RNG. - C-faithful fix in
js/monmove.js:- remove the duplicate hide-under block after movement,
- keep one post-move hide-under pass in the correct post-trap ordering path.
- Validation on
seed323_caveman_wizard_gameplaymoved from major late drift to:rng=18770/18770,events=9072/9072,mapdump=3/3,cursor=413/413,- remaining: one screen mismatch at step
372.
Startup pet peace_minded context is role-sensitive (2026-03-05)
- We observed conflicting startup
peace_minded()RNG widths across sessions:seed301_archeologist_selfplay200_gameplayexpectsrn2(26)at startup pet creation,seed323_caveman_wizard_gameplayexpectsrn2(16)for startup pet creation, while later Caveman gameplay still expects full role-alignment behavior.
- Root cause: one global startup override could not fit all roles.
- Fix:
- keep normal role alignment records globally,
- apply a narrow
makedog()startup context override for Caveman only (alignmentRecord=0) insimulatePostLevelInit().
- Validation:
seed301_archeologist_selfplay200_gameplay: full pass,seed303_caveman_selfplay200_gameplay: full pass,seed323_caveman_wizard_gameplay: remainsrng/events/mapdump/cursorfull, with only the pre-existing single screen-line mismatch at step 373.
Themed-room trap postprocess coords were shifted by one X (2026-03-05)
seed330_samurai_wizard_gameplayhad a startup event/mapdump mismatch where themed-room teleport traps were consistently placed atx-1in JS.- Root cause:
themermspostprocess convertedselection.rndcoord()coordinates with aregion.x1 - 1formula copied from Lua/C assumptions.- In JS,
selection.rndcoord()for room selections returns coordinates relative torm.lx/rm.ly(0-based), andrm.region.x1is already aligned to that origin for this path, so subtracting1shifted placement left.
- Fix:
- Convert with
rm.lx/rm.lydirectly in themed-room trap postprocessing.
- Convert with
- Validation:
seed330_samurai_wizard_gameplay: full pass (rng/events/mapdump/screen/cursorall matched).
seed329 pet ranged-target scan off-by-one consumed extra rnd(5) (2026-03-05)
seed329_rogue_wizard_gameplaydiverged first at step288with an extra JSrnd(5)before^dog_move_choice, then cascaded into broad RNG/event/screen mismatch.- Root cause in
js/dogmove.js:find_targ()useddist <= maxdist, but Cdogmove.c::find_targ()loops withdist < maxdist. JS scanned one extra tile, found a non-C target, and ranscore_targ()fuzz (rnd(5)). - Faithful fix:
- change
find_targ()loop bound to< maxdist(C-exact).
- change
- Validation:
seed329_rogue_wizard_gameplaymoved from broad failure (rng=9687/15900,events=4418/14329,screens=292/423) to fullrng/events/screens/colors/cursorparity (15900/15900,14329/14329,423/423,10152/10152,423/423).- remaining mismatch is mapdump-only (
d0l24_002,H[37,17]), likely same topology-state class asseed321.
seed329 mapdump H parity: fountain blessedftn union byte (2026-03-05)
- After fixing seed329 RNG/event drift, the remaining mismatch was mapdump-only:
d0l24_002 H[37,17](JS=0,C=1) on aFOUNTAINtile (T=28). - Root cause had two C-faithfulness gaps:
mkfount()in JS consumed the 1-in-7 roll but never setloc.blessedftn.- JS mapdump
Hexport read onlyloc.horizontal, while C’sstruct rmoverlays that byte forhorizontal/blessedftn/disturbed.
- Fix:
js/mklev.js::mkfount()now setsloc.blessedftn = 1on!rn2(7).js/dungeon.jsmapdumpHnow exports typ-specific alias values: fountain=blessedftn, grave=disturbed, else=horizontal.
- Validation:
seed329_rogue_wizard_gameplaynow fully passes:rng=15900/15900,events=14329/14329,screens=423/423,colors=10152/10152,cursor=423/423,mapdump=2/2.
seed321 mapdump R parity: des.region needs topologize-style border stamping (2026-03-05)
seed321_archeologist_wizard_gameplaywas mapdump-only failing atd0l21_002 R[51,14](JS=0,C=3), with full RNG/event/screen parity.- Diff shape was a complete 1-tile perimeter ring around a 3x3 room interior
(
x=51..55, y=14..18), indicating missing topologize edge-roomno assignment. - Root cause: JS
sp_levrectangulardes.regionpath (addRegionRectRoom) set roomno only inside the region and did not apply Ctopologize()border stamping (roomno/SHAREDon surrounding edge cells). - Fix in
js/sp_lev.js:- add topologize-style border loops around rectangular regions:
- top/bottom rows at
y1-1andy2+1, - side columns at
x1-1andx2+1, - set
edge=1androomno=(existing ? SHARED : roomno).
- top/bottom rows at
- add topologize-style border loops around rectangular regions:
- Validation:
seed321_archeologist_wizard_gameplay: full pass (mapdump=2/2).seed329_rogue_wizard_gameplay: still full pass (no regression).
Trap type constants are inconsistent across JS files (2026-03-05)
- C defines trap types as a contiguous enum in
you.h:TT_BEARTRAP=1, TT_PIT=2, TT_WEB=3, TT_LAVA=4, TT_INFLOOR=5, TT_BURIEDBALL=6. - JS runtime values differ:
hack.jsdefinesPIT=1(line 969),dig.jsdefinesBURIEDBALL=5(line 1423),insight.jsdefinesBEARTRAP=0, PIT=1, WEB=3, LAVA=4, INFLOOR=5, BURIEDBALL=6. - Multiple files (
ball.js,dokick.js,sit.js,polyself.js) define their own local trap type constants with varying values. - Impact:
do_wear.jscanwearobj()used C’sTT_BEARTRAP=1to block boot-wearing, but JS runtime hasPIT=1. This incorrectly blocked boots while in a pit (C allows it) and allowed boots while in a beartrap (C blocks it). - Fix: Changed
do_wear.jsto use JS runtime values explicitly. - Future work: Centralize trap type constants into a single export to prevent recurrence.
display.putstr_message() bypasses pline _lastMessage tracking (2026-03-05)
- Several places in JS use
display.putstr_message(text)directly instead ofpline()/You()/Your(). This bypasses_lastMessageinpline.js, breakingNorep()suppression for subsequent messages. - Known locations:
do_wear.jshandleWear()(lines 902-1803),mhitm.jsnoises()(line 96). - Pattern: After calling
display.putstr_message(text), callupdateLastPlineMessage(text)frompline.jsto keep_lastMessagein sync. - Why not just use pline?: Some callers need the message to appear on the
display’s topline (via
display.putstr_message) rather than through pline’s output context. Unit tests also mockdisplay.putstr_messagedirectly. - Impact: Fixed seed326 from 504/507 to 507/507 screens (individually).
Forest centaur gets BOW+ARROW, not CROSSBOW (2026-03-05)
- C’s
m_initweap()inmakemon.c:477distinguishesPM_FOREST_CENTAURfrom other centaurs: forest centaurs getBOW+ARROW, others getCROSSBOW+CROSSBOW_BOLT. - JS was unconditionally giving all centaurs
CROSSBOW+CROSSBOW_BOLT. - Fix: Added
PM_FOREST_CENTAURimport andmndx === PM_FOREST_CENTAURcheck injs/makemon.jsS_CENTAUR case. - Impact: Fixed seed324 event mismatch (otyp 88→83).
topologize double edge-stamping in sp_lev rooms (2026-03-05)
- C calls
topologize()inmakecorridors()to stamp roomno values on wall/edge cells of rooms. JS was missing this for sp_lev rooms in maze levels (the guardif (!map.flags.is_maze_lev)skipped maze levels). - Removing the guard exposed a second bug:
addRegionRectRoominsp_lev.jshad inline edge-stamping that pre-set edges toroomno=3, then topologize saw them as already-set and marked themSHARED=1instead. - Fix: (1) Removed
is_maze_levguard inmklev.jsso topologize runs for all room types. (2) Added topologize loop insp_lev.js finalize_level(). (3) Removed the inline edge-stamping block fromaddRegionRectRoom. - Lesson: When two code paths both stamp roomno on edges, topologize’s
SHARED logic treats the pre-stamped edges as belonging to a different room,
producing incorrect
roomno=1(SHARED) instead of the expectedroomno=3. - Impact: Fixed seed321 mapdump roomnoGrid parity.
des.terrain() must route through sel_set_ter for horizontal flag (2026-03-05)
des.terrain()insp_lev.jswas settingloc.typdirectly instead of callingsel_set_ter(). This bypassed the horizontal flag logic thatsel_set_terapplies to HWALL and IRONBARS tiles.- Fix: Refactored all
des.terrain()paths to use a localapplyTerrainhelper that callssel_set_ter(x, y, terrainType, { clear: false, lit: null }). - Impact: Improved seed331 mapdump from 1/2 to 2/2.
C harness –More– race condition (isUnknownSpaceAlias) (2026-03-05)
- The tmux-based C recording harness can auto-dismiss
--More--prompts before the space key arrives, causing"Unknown command ' '."to appear in C recordings where JS correctly consumes the space. - Fix: Added
isUnknownSpaceAlias()comparator incomparator_policy.jsthat treats"Unknown command ' '."↔ blank line as equivalent on row 0. - Also re-recorded
seed323_caveman_wizard_gameplay.session.jsonto remove the timing artifact. - Impact: Fixed seed323 from failing to passing.
intemple() was dead code — check_special_room must call it (2026-03-05)
- JS
check_special_room()inhack.jshadcase TEMPLE: break;— a stub that never calledintemple(). C callsintemple(roomno + ROOMOFFSET). intemple()inpriest.jsusesd(10,500),d(10,100),d(10,20)for angry god anger timers. These must usec_d()(composite RNG) notd()(individual RNG) to match C’s RNG stream.- Fix: (1) Connected
intemple()call incheck_special_roomTEMPLE case. (2) Addedcheck_special_roomcall indo.jsdeferred_goto()after level transition (lazy import to avoid circular dep). (3) Changedd()→c_d()inpriest.js. (4) Fixedp_coaligned()to useplayer.alignmentinstead ofplayer.ualign.type. - Impact: Improved seed332 RNG/event alignment.
mk_knox_portal wizard mode bypass (2026-03-05)
- C’s
mk_knox_portal()has(rn2(3) && !wizard)— in wizard mode, the portal is always placed. JS was missing the!wizardcheck, deferring portal placement 2/3 of the time even in wizard sessions. - Fix: Added
&& !_wizardModeto thern2(3)guard injs/dungeon.js. - Impact: Fixed knox portal placement parity in wizard sessions.
Wall glyph rendering: set_wall_state() never called in JS (2026-03-05)
- C calls
set_wall_state()during level finalization, which computeswall_infomode bits for T-walls and corners based on adjacent wall connectivity. These bits determine which Unicode box-drawing character to render (e.g., ┴ vs └ vs ┘). - JS never calls
set_wall_state()and lacks theloc.wall_infofield. TheterrainSymbol()function inrender.jsusesloc.flags(which storesseenvbits for explored visibility) instead ofwall_info. - Root cause of seed033 wall glyph mismatches: T-walls and corner walls render with wrong characters because the mode bits are absent.
- Future fix requires: (1) Add
wall_infofield to location objects. (2) Portset_wall_state()fromdisplay.c. (3) Call it infinalize_level(). (4) UpdateterrainSymbol()to usewall_infoinstead ofloc.flagsfor wall type determination. - Status: Research complete, implementation deferred (complex multi-file change).
const.js auto-import now covers include/*.h const-style macros (2026-03-06)
scripts/generators/gen_constants.pynow scans all C headers undernethack-c/patched/include/*.hfor object-style#defineconstants.- Generated constants now use one unified marker block in
js/const.js:CONST_ALL_HEADERS(instead of a separate role-only block). - Emission rules:
- only object-like macros (no function-like macros),
- only const-style expressions (no runtime/lowercase identifiers),
- only dependency-resolvable macros at marker position are emitted.
- C-to-JS numeric literal normalization is required:
- strip C integer suffixes (
U,L,UL, etc.), - convert C legacy octal literals (
011) to JS0o11.
- strip C integer suffixes (
- Non-emitted const-style names are listed in
DEFERRED_HEADER_CONST_MACROSfor explicit follow-up.
mcanmove/mcansee Default Initialization
- Discovery: C’s
makemon.c:1293setsmtmp->mcansee = mtmp->mcanmove = 1for every newly created monster. JSmakemon()never initialized these fields, leaving themundefined. - Impact: JS code checking
!mon.mcanmovetreated all monsters as immobile (since!undefinedistrue). Code checkingmon.mcanseetreated all monsters as blind (sinceundefinedis falsy). Affected:priest.jslocalhelpless()→ all priests treated as helplessmonmove.js:264→ flee message always “flinch” instead of “turns to flee”dothrow.js:1139,1200→ hitting/missing immobile targetsmhitu.js:1753-1839→ monster gaze attacks disabled (mcansee falsy)region.js:628→ blinding regions never triggereddogmove.js:892→ dog vision checks always failed
- Why only mcanmove/mcansee? C’s
*mtmp = cg.zeromonstzero-initializes all fields. In JS,undefinedbehaves like0for truthy/falsy checks (!undefined=true,undefined & flag=0). The ONLY fields that need explicit init are those that C defaults to non-zero values.mcanmoveandmcanseeare the only such fields (both default to 1). - Fix: Added
mcanmove: 1, mcansee: 1to the monster object inmakemon(). Also fixedmuse.jslocalhelpless()which was missing the!mcanmovecheck. - Lesson: When porting C code that uses
*mtmp = zeromonstinitialization, check for any fields explicitly set to non-zero values afterward — those are the dangerous ones in JS whereundefinedsilently differs from the C default.
const.js generator: enums + post-symbol pass + non-emittable blacklist (2026-03-06)
scripts/generators/gen_constants.pynow parses enum constants (not only#define) frominclude/*.hinto generatedconst.jsblocks.- Added two-pass header emission:
CONST_ALL_HEADERS(pre-symbol),CONST_ALL_HEADERS_POST(post-symbol, afterMAXPCHARS/MAXOCLASSES/...exist).
- Added explicit platform defaults for curses-style constants used in JS:
LEFTBUTTON,MIDBUTTON,RIGHTBUTTON,MOUSEMASK,A_LEFTLINE,A_RIGHTLINE,A_ITALIC. - Added
HEADER_MACRO_NON_EMITTABLEwith explanations for C macros that are not meaningful as JS compile-time constants (runtime expressions, pointer sentinels, or compile-time annotations). - Important dependency lesson:
- topo sort alone is not enough if a dependency is emitted later in the file
(JS TDZ) or lives in another module (
objects.js/monsters.js/artifacts.js); those must be deferred or manually anchored.
- topo sort alone is not enough if a dependency is emitted later in the file
(JS TDZ) or lives in another module (
- Direction constants were re-anchored to C-faithful values near direction
arrays to prevent generator drift:
N_DIRS_Z = 10,N_DIRS = 8.
kick/monmove C-path closure for door and tame postmove logic (2026-03-06)
js/kick.jswas missing two Ckick_door()behaviors fromdokick.c:- shop-door shatter gate: C only rolls
rn2(5)for shatter when!shopdoor; JS now matches that guard. - shop-door side effects after breaking: C calls
add_damage(..., SHOP_DOOR_COST)thenpay_for_damage("break", FALSE); JS now mirrors this path.
- shop-door shatter gate: C only rolls
js/monmove.jstame movement path (dog_move) bypassed Cpostmov()tunneling behavior. C appliesif (can_tunnel && may_dig(...) && mdig_tunnel(...))even when movement comes fromdog_move; JS now performs the same check for tame monsters after a successful move.- Validation:
node scripts/test-unit-core.mjspasses.seed325_knight_wizard_gameplayfirst divergence did not move yet, so this is a correctness-alignment increment, not a resolved session divergence.
kick town-watch and monmove monster-data fallback closure (2026-03-06)
js/kick.jsnow mirrors Cdokick.ctown-watch behavior after door break:- when
in_town(x,y)and a peaceful visible watchman exists, emit the watchman arrest yell and callangry_guards(FALSE)path.
- when
js/monmove.jsnow uses canonical monster-data fallbackmon.data || mon.type || mons[mon.mndx]in tunneling-related callsites (m_digweapon_check, tame post-dog_movecan_tunnel check, andm_moveptrinitialization), matching C’s always-validmtmp->dataassumption.- Validation:
node scripts/test-unit-core.mjspasses.- targeted gameplay seeds (
seed325,seed327) remain at same first divergence points; no measured parity shift yet from this closure.
mfndpos tame ALLOW_M semantics fixed for occupied peaceful squares (2026-03-06)
- In C
mon.c:2283-2293, when caller passesALLOW_M(asdog_move()does), occupied squares are eligible attack squares unless the defender is tame andALLOW_TMis absent. - JS
mfndpos()was incorrectly rejecting peaceful occupied squares for tame movers (!monAtPos.peacefulgate), which removed legal options from pet move evaluation and changeddog_movechoice space. - Fix in
js/mon.js:- for
flag & ALLOW_M, allow occupied squares unconditionally except tame defenders withoutALLOW_TM; - keep
mm_aggression-derived behavior for non-ALLOW_Mcallers; - set
ALLOW_TMbit inposInfowhen the occupied defender is tame.
- for
- Validation:
node scripts/test-unit-core.mjspasses.- targeted seeds (
325/327/328/332) show no regressions in first divergence. seed332_valkyrie_wizard_gameplayimproved event alignment from173/6595to175/6595, and the prior step-206 petmfndposcount now matches C (cnt=5).
dochug phase-3 undirected spell attempt restored before movement (2026-03-06)
- C
dochug()phase-3 path (monmove.c:889-907) attempts an undirected spell cast beforem_move()when the phase-3 movement gate is taken. JS had no equivalent pre-move cast attempt, so C consumed spell-selection RNG (castmupath) that JS skipped. - Implemented C-faithful pre-move attempt in
js/monmove.js:- new
maybeCastUndirectedPreMove()runs before movement in phase-3 gate. - consumes
rn2(m_lev)per candidate spell attack (AT_MAGCwithAD_SPEL/AD_CLRC) and rejects directed/useless outcomes for this non-attacking context. - applies
mspec_usedcooldown and fumble gate (rn2(m_lev * 10)) when an undirected cast proceeds, then executes undirected spell effects.
- new
- Validation:
node scripts/test-unit-core.mjspasses.seed332_valkyrie_wizard_gameplayfirst RNG divergence moved later: step206->221.- seed332 metrics improved materially:
- RNG
7887/17821->8398/19976 - events
175/6595->570/8135 - screens
269/410->297/410
- RNG
dochug pre-move useless-spell filters aligned to C (2026-03-06)
- Follow-up on seed332 pre-move spell parity: JS was still allowing spell candidates that C rejects in non-attacking contexts, consuming extra RNG before movement resolution.
- C-faithful fix in
js/monmove.jsspellWouldBeUseless(...):- for
AD_SPEL, peaceful monsters now rejectMGC_AGGRAVATION,MGC_SUMMON_MONS,MGC_CLONE_WIZ; - for
AD_CLRC, peaceful monsters rejectCLC_INSECTS; - for
AD_CLRC,CLC_BLIND_YOUis rejected when player is already blind; - cure-self useless check kept for both spell groups.
- for
- Also corrected
timeout.cegg hatch loop bound injs/timeout.js:attach_egg_hatch_timeout()now starts ati = 151(MAX_EGG_HATCH_TIME - 49), matching C (rnd(151)first call), noti = 150.
- Validation:
node scripts/test-unit-core.mjspasses.- tracked seeds after fix:
- seed325: unchanged first RNG divergence at step 218 (
dochugvsmdig_tunnel) - seed327: unchanged first RNG divergence at step 226 (
dochugvspassivemm) - seed328: unchanged first RNG divergence at step 201 (
dochugvsrndmonst_adj) - seed332: first RNG divergence moved from step
221->386(moveloop_turnend rn2(400)vshatch_egg rnd(1)), with RNG16471/17821, screens393/410, events5709/6595.
- seed325: unchanged first RNG divergence at step 218 (
seed332 hatch_egg timeout path: RNG parity closure (2026-03-06)
- After the pre-move spell fixes, seed332’s first RNG divergence moved to
hatch_egg(timeout.c)(rnd(1)), which exposed missing egg-timeout behavior in JS. - C-faithful closures landed:
js/mkobj.js:set_corpsenm()now restarts realHATCH_EGGtimers viaattach_egg_hatch_timeout()instead of RNG-only placeholder state.js/timeout.js:attach_egg_hatch_timeout()keeps C loop bounds (i = 151..200) and schedules hatch timers with C-consistent turn timing.js/timeout.js:hatch_egg()now executes real hatch-path behavior needed for RNG parity:- consumes
rnd((int)egg->quan), - resolves hatch species via
big_to_little(corpsenm), - attempts placement with
enexto()(which drivescollect_coordsRNG), - spawns hatchlings with
makemon(..., NO_MINVENT | MM_NOMSG), - decrements
egg.quanby successful hatch count.
- consumes
- Validation:
node scripts/test-unit-core.mjspasses.- tracked seed set (
325/327/328/332) shows no regressions on 325/327/328. seed332_valkyrie_wizard_gameplaynow has full RNG parity:- RNG
17821/17821(was16471/17821) - screens
408/410(was393/410) - events
5714/6595(was5709/6595) - first remaining divergence is non-RNG (screen/event), not RNG drift.
- RNG
seed332 hatch egg lifecycle cleanup: event parity closure (2026-03-06)
- Follow-up on post-RNG seed332 drift: after hatch placement parity, JS still
kept depleted egg stacks in place (
egg.quanreached 0 without object removal), so C^remove[...]events were missing. - C-faithful cleanup added in
js/timeout.js:hatch_egg():- when successful hatching reduces quantity to zero, remove the egg object
from floor inventory (
map.removeObject) or hero inventory (player.removeFromInventory) as appropriate.
- when successful hatching reduces quantity to zero, remove the egg object
from floor inventory (
- Validation:
- tracked seeds (
325/327/328) unchanged on first divergence. seed332_valkyrie_wizard_gameplaynow has full event parity:- events
6595/6595(was5714/6595after RNG closure), - RNG remains
17821/17821.
- events
- remaining seed332 gap is now screen/color-only at step ~204
(
Unknown command ' '.capture artifact class), with metrics screens408/410, colors9838/9840.
- tracked seeds (
replay input-boundary contract (architecture simplification, 2026-03-06)
- New replay-facing input contract added to both runtime adapters:
isWaitingInput()getInputState() -> { waiting, queueLength, waitEpoch }waitForInputWait({ afterEpoch, signal })
waitEpochincrements exactly when a runtime transitions into unresolvednhgetch()waiting state, enabling deterministic replay boundary waiting.js/replay_core.jsdrainUntilInput()now races command completion against explicit boundary waits instead of relying on setTimeout polling.- Boundary-wait subscriptions are abortable (
AbortSignal) to avoid leaked listeners when command completion wins the race. - Regression results for tracked seeds remained stable:
- seed325/327/328 first divergences unchanged
- seed332 retains full RNG+event parity (
17821/17821,6595/6595)
- Maintainer rule: replay boundary detection should key off runtime waiting transitions, not message/topline side effects.
seed325 digging branch alignment push (2026-03-06)
- Investigated seed325 first RNG divergence at step 218 in
dochugvs Cmdig_tunnel, with mismatchrn2(5)(JS wall branch) vsrn2(3)(C door draft branch). - C-faithful fix in
js/dig.js:mdig_tunnel()now uses canonicalcvt_sdoor_to_door()conversion instead of preserving raw secret-door flags.mdig_tunnel()emitsYou hear crashing rock.on the!rn2(5)wall-sound branch (and is async so the message is actually surfaced).
- Call-site propagation in
js/monmove.js:- await
mdig_tunnel()in both tunneling paths. m_digweapon_check()converted to async and now emits the wield message (Monnam(mon) wields ...) when a visible monster switches to its dig tool.- fixed runtime regression from this path (
x_monnamreference in monmove).
- await
- Validation:
node scripts/test-unit-core.mjspasses.node test/comparison/session_test_runner.js --verbose test/comparison/sessions/seed325_knight_wizard_gameplay.session.jsonnow reaches first RNG divergence at step224(was218before this digging fix cycle), with RNG prefix10493/25281.
- Remaining first divergence after this cycle:
- RNG index
9215, step224:- JS:
rn2(20)=12 @ moveloop_core - C:
rn2(2)=0 @ morgue_mon_sound(sounds.c:96)
- JS:
- Nearby screen/map drift still appears around step
218(·vs─tile), and should be treated as likely upstream state cause of later event drift.
- RNG index
seed325 regression correction: wall-dig sound RNG gating (2026-03-06)
- During rebase conflict resolution,
mdig_tunnel()wall-sound path had become gated by_gstate?.flags?.verbose, which can suppress the Crn2(5)roll in replay/headless contexts. - C behavior for this tracked seed required that wall branch roll to happen; when
skipped, first divergence moved earlier (step
208) with:- missing topline
You hear crashing rock. - post-dig
distfleeckbrave roll skew (brave=1JS vsbrave=0C).
- missing topline
- Corrective change in
js/dig.js:- wall-dig path now always executes the
rn2(5)roll and emits message viaawait You_hear('crashing rock.').
- wall-dig path now always executes the
- Validation on tracked seeds:
seed325_knight_wizard_gameplay: first divergence moved back later from step208to step218, RNG matched prefix improved9725 -> 10781.seed327_priest_wizard_gameplayandseed328_ranger_wizard_gameplayfirst divergence steps remained unchanged (226and220respectively).
gameplay replay option parity: verbose default-on restoration (2026-03-06)
- Root cause for recurring seed325
mdig_tunnelRNG drift was in replay option wiring, not core dig logic: gameplay replay flags forcedverbose=falseunless a session explicitly setmeta.options.verbose=true. - NetHack default is
verbose=true(DEFAULT_FLAGS), and most gameplay session fixtures omitmeta.options.verbose, so replay had been running in terse mode unintentionally. - C-faithful fix:
test/comparison/session_recorder.js:buildGameplayReplayFlags()now defaultsflags.verboseto on and only disables when session explicitly setsverbose: false.test/comparison/rng_shift_analysis.js: aligned diagnostic replay flags to the same default so tooling and session runner observe the same behavior.
- Validation:
node test/comparison/session_test_runner.js --no-parallel --session-timeout-ms=30000 test/comparison/sessions/seed325_knight_wizard_gameplay.session.jsonnow reaches first RNG divergence at step224with RNG prefix10493/25281and first RNG mismatch:rn2(20)=12 @ moveloop_corevsrn2(2)=0 @ morgue_mon_sound.seed327_priest_wizard_gameplayandseed328_ranger_wizard_gameplayremain at first divergence steps226and220(no regression in first divergence position).npm run test:unit -- --runInBandpasses.
unseen map memory parity: preserve remembered glyphs in newsym() (2026-03-06)
- Root cause for recurring screen drift (seed325 step 218 hidden-door cell) was
in unseen map rendering semantics, not RNG pathing:
- JS recomputed unseen terrain from live
loc.typeach refresh. - C
newsym()(display.c) reuses rememberedlev->glyphwhen out of sight.
- JS recomputed unseen terrain from live
- C-visible consequence:
- when an unseen secret-door tile changed
SDOOR -> DOORduring monster digging, JS immediately redrew memory as doorway·, while C kept the prior remembered wall glyph until visibility changed.
- when an unseen secret-door tile changed
- Fix in
js/display.js:newsym()now caches remembered terrain glyph/color (mem_terrain_*) and reuses it for unseen cells.- visible-cell refresh updates remembered terrain first, then overlays monster/object/trap glyphs.
- Validation:
seed325_knight_wizard_gameplayscreen parity improved:- screens
217/422->243/422 - colors
8401/10128->8433/10128 - first screen divergence moved from step
218to step239 - first RNG divergence unchanged at step
224(10493/25281).
- screens
- nearby seeds also moved forward without RNG regressions:
seed327_priest_wizard_gameplayfirst RNG divergence step226->288(9944/20304matched prefix).
seed328_ranger_wizard_gameplayfirst RNG divergence remains step220.node scripts/test-unit-core.mjs --runInBandpasses.
dosounds parity: remove stale allmain ambient-sound stub (2026-03-06)
- Root cause for the next seed325 RNG split (
rn2(20)JS vsrn2(2) @ morgue_mon_soundC) was an outdated duplicate implementation:allmain.js:moveloop_dosounds()had a partial ambient-sound stub that short-circuited on room-feature gates (has_morgue,has_beehive, etc.) without running the monster-iteration branches used by Cdosounds().- This skipped C-required RNG branches (notably
morgue_mon_sound rn2(2)).
- C-faithful fix:
moveloop_dosounds()now delegates directly to canonicalsounds.js:dosounds()(the maintained sounds.c parity path) instead of maintaining a divergent duplicate inallmain.js.
- Validation:
seed325_knight_wizard_gameplay:- first RNG divergence moved from step
224to step238 - matched RNG prefix improved
10493/25281->10500/25281 - cursor parity at compared steps improved to
243/243.
- first RNG divergence moved from step
seed327_priest_wizard_gameplay:- first RNG divergence remains at step
288, matched prefix improved
- first RNG divergence remains at step
Throw/topline freeze boundary: C more() blocks before throw cleanup (2026-03-07)
- Seed325 throw-window investigation confirmed a screen-only boundary mismatch mode: RNG/events match fully while projectile glyph timing differs.
- C behavior source:
mthrowu.cresolves throw/hit messaging inside the flight loop, and only later clears projectile display (tmp_at(DISP_END, 0)).- tty
topl.cmore()blocks inxwaitforspace("\\033 "), so when a--More--pause is active, non-dismissal keys do not advance processing and the current projectile frame can remain visible.
- JS replay trace (env-gated in
js/mthrowu.js) showed:- projectile
)appears duringtmp_atflight and after miss message, - then gets cleared in the same step when no
--More--boundary is active.
- projectile
- C-faithful hardening implemented:
js/display.jsandjs/headless.jsnow treat--More--dismissal asSpace/Esc/Enter, matchingxwaitforspace("\\033 ")semantics.js/allmain.jspending---More--command path now ignores non-dismissal keys rather than consuming them.
- Validation snapshot:
- failure suite count remained stable (no broad regression),
- seed325 still needs one additional boundary fix for the remaining throw/topline capture skew.
"You die..." forces more() before prompt concatenation (2026-03-07)
- C
topl.c:update_topl()explicitly treats"You die..."as non-concatenable: if topline is inNEED_MORE, it runsmore()before displaying the death line. - JS previously skipped this branch and could collapse death-phase messaging
into one topline (for example
"You die... Die? [yn] (n)") too early. - C-faithful fix in both display backends:
- when
topMessage && messageNeedsMore,putstr_message("You die...")now forces a--More--boundary before showing the new line (same path used for concat-overflowmore()).
- when
- Safety:
- no RNG/event regressions in targeted checks (
seed100,seed325) and failure-suite count remained stable.9944/20304->9954/20304. seed328_ranger_wizard_gameplay:- first RNG divergence remains step
220(no regression).
- first RNG divergence remains step
node scripts/test-unit-core.mjs --runInBandpasses.
- no RNG/event regressions in targeted checks (
Recorder datetime parity guard for Luck-sensitive replays (2026-03-06)
- Problem:
- Running
recordGameplaySessionFromInputs()directly (outsidesession_test_runner) did not pinNETHACK_FIXED_DATETIME. - Date-sensitive Luck modifiers (full moon / Friday-13th preamble) could
change
Luckand shift earlyrnl(...)callsites (for example kick-door paths), producing misleading first-divergence results in ad-hoc triage.
- Running
- Fix:
test/comparison/session_recorder.jsnow applies the same datetime resolution policy as the session runner:- resolve from session metadata via
resolveSessionFixedDatetime(...) - fallback to prior env value
- fallback to canonical default
20000110090000
- resolve from session metadata via
- The helper restores prior env state after replay.
- Validation:
- Direct recorder/comparator triage for
seed325now reproduces the canonical first divergence at step238(rnd(12)vsrnd(79)) instead of drifting to a date-dependent Luck split. node scripts/test-unit-core.mjs --runInBandpasses.
- Direct recorder/comparator triage for
Recorder datetime helper tests (2026-03-06)
- Added explicit unit coverage for recorder datetime behavior to keep date-sensitive Luck parity deterministic during ad-hoc replay triage.
test/comparison/session_recorder.jsnow exposes:resolveRecorderFixedDatetime(session, ...)withRecorderFixedDatetime(session, fn, ...)
- New tests in
test/unit/session_recorder_datetime.test.jsverify:- source preference handling (
sessionvsrecorded-at-prefer) - fallback ordering (session -> env -> default)
- env restoration after success and after thrown errors.
- source preference handling (
- Validation:
node --test test/unit/session_datetime.test.js test/unit/session_recorder_datetime.test.jsnode scripts/test-unit-core.mjs --runInBand
Browser keylogs now preserve deterministic datetime (2026-03-22)
- Problem:
- The HTML/browser keylog path recorded
seedplus a wall-clock metadata timestamp, but replay only restoredseedand option flags. - That was insufficient for deterministic repro of time-sensitive behavior
such as
phase_of_the_moon(),friday_13th(), and othergetnow()consumers.
- The HTML/browser keylog path recorded
- Fix:
js/storage.jsnow parses and clears?datetime=YYYYMMDDhhmmssjs/calendar.jssupports a browser-side fixed datetime overridejs/allmain.jsfreezes a per-session browser datetime at init and starts keylog recording with itjs/keylog.jsrecords top-leveldatetimeand restores it on replay
- Result:
- browser-recorded keylogs now preserve both PRNG seed and deterministic datetime
- wall-clock audit time remains available separately as
metadata.recordedAt
- Validation:
node --test test/unit/storage.test.js test/unit/keylog_datetime.test.js
m_move parity: restore missing Tengu early-teleport branch (2026-03-06)
- Root cause:
js/monmove.js:m_move()skipped the CPM_TENGUearly special-case branch (monmove.c:1840-1848) and always fell through to generic movement logic.
- C-faithful fix:
- Added the Tengu pre-
not_specialgate and branch ordering:!rn2(5) && !mcan && !tele_restrict(...)mhp < 7 || peaceful || rn2(2)->rloc(..., RLOC_MSG)- else -> adjacent relocation attempt via
enexto(...)+rloc_to(...)withrloc(..., RLOC_MSG)fallback.
- Added the Tengu pre-
- Validation:
node scripts/test-unit-core.mjs --runInBandpasses.- Targeted parity replay set (
seed325/327/328) shows no regression: first-divergence steps remain238 / 390 / 220.
dochug postmove unification groundwork (2026-03-06)
- Problem:
dochugpet and non-pet paths duplicated the same post-move trap/effect sequence (m_postmove_effect+mintrap_postmove) in separate branches.- This duplication increased risk of accidental branch drift while fixing issue #253 sequencing.
- Change:
- Added shared helper
apply_dochug_postmove(...)injs/monmove.js. - Converted both pet and non-pet callers to use that helper with preserved current JS order and conditions (behavior-preserving refactor).
- Added shared helper
- Validation:
node scripts/test-unit-core.mjs --runInBandpasses.- Targeted replay seeds unchanged (no regression):
- seed325 first divergence step
238 - seed327 first divergence step
390 - seed328 first divergence step
220
- seed325 first divergence step
Vision pointer-table parity fix removes replay live-lock hotspot (2026-03-06)
- Symptom:
seed325_knight_wizard_gameplaytimed out with no comparable metrics (rng=0/0,events=0/0) after enabling C-faithful postmov ordering.- CPU profiling showed dominant self time in
vision.js(right_side,q4_path) during pet-pathdo_clear_area(...)calls.
- Root cause:
js/vision.jspointer-maintenance logic forfill_point()/dig_point()had multiple C-port mismatches (wrong left/right pointer targets and boundary updates), corruptingleft_ptrs_arr/right_ptrs_arr.- Corrupted row pointers caused pathological LOS scanning cost in
view_from()recursion.
- C-faithful fix:
- Re-aligned
fill_point()anddig_point()branch-by-branch withnethack-c/patched/src/vision.c(fill_point/dig_point). - Corrected edge handling and “catch end case” updates so row pointer tables stay valid.
- Re-aligned
- Validation:
node scripts/replay_stall_diagnose.mjs --session seed325_knight_wizard_gameplay --timeout-ms 8000 --top 12now completes without timeout and produces full comparable metrics.- Direct run also completes (no timeout):
node test/comparison/session_test_runner.js --verbose --session-timeout-ms=12000 --sessions=seed325_knight_wizard_gameplay.session.json.
postmov redraw ordering: avoid eager new-tile newsym in m_move (2026-03-06)
- Root cause:
js/monmove.js:m_move()eagerly callednewsym(omx,omy)andnewsym(nix,niy)immediately after coordinate update.- In C (
monmove.c),postmov()owns redraw sequencing around traps: it updates old position first, runsmintrap(), then refreshes current location if still on-level. - The eager JS redraw exposed transient monster glyphs at
--More--pause boundaries (seed328), producing screen-only drift with matching RNG/events.
- C-faithful fix:
- Removed eager old/new
newsymcalls fromm_move()movement branch. - Moved redraw sequencing into shared postmove helper:
newsym(omx,omy)beforemintrap_postmove(...)- refresh
newsym(mon.mx,mon.my)after trap resolution when still on-level.
- Removed eager old/new
- Validation:
seed328_ranger_wizard_gameplayimproved: first screen divergence moved from step219to step231with RNG/events still 100% matched.scripts/run-and-report.sh --failuresremains27/34gameplay passing (no failing-session count regression).
Comparison artifact screen-window context for boundary triage (2026-03-06)
- Added optional screen payloads to comparison artifacts to speed pure-screen drift debugging without changing default artifact size.
- New env toggle:
WEBHACK_COMPARISON_INCLUDE_SCREENS=1- Optional radius override:
WEBHACK_COMPARISON_SCREEN_CONTEXT=<N>(default2)
- When enabled, artifacts include
screenContextwith expected-vs-JSscreen/screenAnsi/cursorforstep = first_screen_or_color_divergence ± N. - Seed328 triage insight from this view:
- divergence is localized to one map glyph row at step
231(%vss), while RNG/events remain 100% matched. - JS row at step
231matches session row at step232, indicating a narrow per-step display boundary/state timing issue, not broad PRNG drift.
- divergence is localized to one map glyph row at step
place_object() now marks OBJ_FLOOR (2026-03-06)
- Root cause:
js/mkobj.js place_object()appended objects tomap.objectsbut did not setobj.whereto floor state.- This left some floor objects carrying stale/constructor
wherevalues (free), makingwhere-sensitive C-faithful checks unreliable.
- Fix:
place_object()now setsobj.where = 'OBJ_FLOOR'when placing on map.
- Validation:
node scripts/test-unit-core.mjs --runInBandpasses (2483/2483).scripts/run-and-report.sh --failuresunchanged (27/34gameplay passing, same 7 failing sessions).
mhitu parity: reveal hider/eel attacker on hit (2026-03-06)
- Root cause:
- C
hitmu()clearsmtmp->mundetectedfor hides-under/eel attackers that successfully hit the hero (mhitu.c:1157), then redraws that square. - JS
mattacku/hit path had no equivalent branch, leaving this state update missing.
- C
- Fix:
- Added C-faithful unhide branch in
js/mhitu.jshit path:- if
monster.mundetected && (hides_under(mdat) || mdat.mlet == S_EEL) - clear
mundetectedand callnewsym(monster.mx, monster.my, ...).
- if
- Added C-faithful unhide branch in
- Validation:
- Targeted
seed328replay remains non-regressed (still isolated screen-only mismatch at step231; RNG/events 100%). scripts/run-and-report.sh --failuresunchanged (27/34gameplay passing, same 7 failing sessions).
- Targeted
domove locked-door bump now routes through autounlock pick path (2026-03-06)
- Root cause:
- JS
domove_core()hard-returned on locked doors with"This door is locked.". - C movement path (
test_move()->doopen_indir()) can trigger autounlock (autokey()+pick_lock()) during movement bumps.
- JS
- Fix:
js/hack.jsnow uses gameplay option flags (game.flags) to gate locked-door autoopen path and invokesautokey(...)/pick_lock(...)for apply-key autounlock during movement bumps.
- Validation:
- No failing-session count regression in
scripts/run-and-report.sh(27/34gameplay passing, same 7 failing sessions).
- No failing-session count regression in
trap visibility ordering fix for stepped arrow/dart traps (2026-03-06)
- Root cause:
- In
domove_core()stepped-trap handling, JS settrap.tseen = truebefore arrow/dartonce && tseen && rn2(15)logic. - C
trapeffect_arrow_trap/_dart_trapcheckstrap->tseenbeforeseetrap(). Using updatedtseenin JS could add a prematurern2(15)on first seen triggers.
- In
- Fix:
applySteppedTrap()now captureswasSeenbefore discovery updates and useswasSeenfor the arrow/dartonceclick-away gate.
- Validation:
seed032_manual_directadvanced materially: first RNG/event divergence moved from step43to step48(rng matched 4902 -> 5081,events matched 1507 -> 1548).scripts/run-and-report.shremains stable at27/34gameplay passing.
seed325 teleport-trap visibility alignment unmasked later frontier (2026-03-06)
- Symptom:
seed325_knight_wizard_gameplayfirst divergence at step297: C consumedrn2(40)in pet trap-avoidance while JS consumedrn2(12).- At candidate square
(30,8), JS hadTELEP_TRAPwithtseen=0while C behaved as if trap had already been seen.
- Root causes:
- Monster teleport-trap sight checks were using
canseemon(mon)without passingplayer/fovin JS paths that importcanseemonfrommondata.js. That always returned false and suppressed in-sight trap discovery. mtele_trap()in JS was under-ported versus C (teleport.c): missing pet-teleport gate and in-sight trap-discovery/message behavior.
- Monster teleport-trap sight checks were using
- C-faithful fixes:
- In trap teleport effect selectors, compute
in_sightwithcanseemon(mon, player, fov) || mon === player.usteed. - Ported key
mtele_trap()behavior:teleport_pet(..., false)gate, once/fixed/random destination handling, and in-sight post-teleport messaging with trap discovery (tseen+newsym).
- In trap teleport effect selectors, compute
- Validation:
seed325_knight_wizard_gameplayimproved from:rng=14859/25281,screens=256/422,events=6896/18600,mapdump=2/3to:rng=18499/25281,screens=303/422,events=7501/18600,mapdump=3/3.- First divergence moved later from step
297to step306.
- New frontier:
- Step
306now first diverges aroundship_object(dokick.c:1660)/^tmp_at_step[...]versus JS^place[...], indicating the next unmasked C-faithful gap is in the object-kick/tmp-at sequence boundary.
- Step
Stair –More– boundary: preserve step alignment without cursor drift (2026-03-06)
- Symptom:
- After moving stair-message ack to non-blocking behavior,
seed325improved screen frontier to step306but introduced cursor divergence at step302(expected [3,3,1],actual [37,0,1]).
- After moving stair-message ack to non-blocking behavior,
- Root cause:
- Stair ack path needed a pending input boundary to keep replay steps aligned,
but it reused generic pending-
--More--rendering paths that left the cursor on topline during snapshot. - Separately,
run_command()early-returned after dismissing pending-more without running its normal end-of-command cursor/status placement.
- Stair ack path needed a pending input boundary to keep replay steps aligned,
but it reused generic pending-
- Fix:
- Added stair-specific pending-more mode in
js/do.js: setsdisplay._pendingMore = trueanddisplay._pendingMoreNoCursor = true(no marker rendering). docrt()now treats only_nonBlockingMoreas a forced topline-cursor mode; plain_pendingMoreno longer re-forcesrenderMoreMarker()/topline cursor. This preserves map cursor during normal pending--More--replay frames (including quest telepathy message pages after stair transitions).Display/Headlessclear_pendingMoreNoCursorin constructors and_clearMore().run_command()now refreshes status + cursor-to-player in the pending-more early-return branch after consuming the dismiss key.
- Added stair-specific pending-more mode in
- Validation:
seed325_knight_wizard_gameplay:- current:
screens=326/422,cursor=325/325(cursor fully aligned) - first RNG divergence remains at step
309(rn2(12)vsrn2(100)).
- current:
- Companion checks show no frontier regression:
seed327_priest_wizard_gameplayfirst RNG divergence still step390.seed328_ranger_wizard_gameplayfirst RNG divergence still step242.
Lesson: vault_occupied returns ‘\0’ which is truthy in JS
- C function
vault_occupied()returns'\0'(null char) for “no vault found” and a room char for “player is in vault”. - In C,
'\0'is falsy (it equals 0). In JS,'\0'is a non-empty string → truthy. - This caused
gd_sound()to always return false in JS, suppressing vault sounds when they should have played, causing rn2(2) divergence on vault levels. - Fix: check
vaultOcc && vaultOcc !== '\0'instead of justvaultOcc. - General lesson: any C function returning
'\0'as a sentinel needs explicit null-char checks in JS. This pattern likely exists in other room-query functions.
Lesson: spurious rn2(20) in hack.js domove_attackmon_at
- JS hack.js:597 has
rn2(20)beforeexercise(A_STR, true)in the attack path. - C’s
do_attack()in uhitm.c has NO rn2(20) — it callsexercise(A_STR, TRUE)(which internally callsrn2(19)) thenu_wipe_engr(3). - Removing the JS rn2(20) causes 16 session regressions, meaning it compensates for some other C RNG call that JS doesn’t otherwise make.
- The rn2(20) is a legacy alignment hack. Do NOT remove without first identifying exactly which C RNG call it substitutes for. Likely candidates: something in the attack_checks/hitum path that JS handles differently.
Lesson: eat.js eating paths must dispatch to cpostfx/fpostfx
- C’s
done_eating()(eat.c:562-565) dispatches tocpostfx()for corpses andfpostfx()for non-corpse food after eating completes.
Seed328 hideunder boundary + corpse naming fidelity (2026-03-07)
- After fixing the hideunder/topline async boundary (awaiting message emission),
the
%vsstransient-map mismatch disappeared, but a message-text mismatch remained at step 232:- JS:
You see the centipede hide under a little dog corpse. - C/session:
You see the centipede hide under a corpse.
- JS:
- Root cause: JS
xname_for_doname()inmkobj.jsalways expanded corpse species name fromcorpsenm, even whendknownwas false. - C-faithful fix: for
FOOD_CLASSCORPSE, when!dknown, emit generic"corpse"; only include<monster> corpsewhendknown. - Result:
seed328_ranger_wizard_gameplaynow fully matches (rng/events/screens/colors/cursor = 100%).
doname(corpse) must include monster type even when xname(corpse) is generic (2026-03-07)
- C
objnam.ckeepsxname(CORPSE)generic ("corpse"), butdoname_base()has a dedicated CORPSE branch that routes throughcorpse_xname(...), which includes monster type (for example"newt corpse"). - JS regression symptom: pet-eating lines drifted to
"eats a corpse"where C and sessions had"eats a newt corpse"/"eats a gnome corpse". - Fix: in
mkobj.jsdoname(), apply a CORPSE-specific base-name override to includemons[corpsenm].mname, while leavingxname()generic behavior unchanged. - Validation: this restored parity for
seed301,seed303, andseed306while keepingseed328green. - JS had these functions defined but never called from the eating completion paths. Multi-turn eating used inline PM_NEWT-only logic; single-turn eating had none.
- Missing
fpostfx()meant fortune cookieoutrumor()(rn2(2) + rn2(chunksize)) was never consumed, and royal jelly rnd(20)/rn2(17) were never applied. - Fix: wire cpostfx/fpostfx into both multi-turn
finishEatingand single-turn paths.
Lesson: m_ap_type must use numeric constants, not strings
- C’s
m_ap_typeis an enum: M_AP_NOTHING=0, M_AP_FURNITURE=1, M_AP_OBJECT=2, M_AP_MONSTER=3. JS was using both string values (‘object’, ‘furniture’, ‘monster’) and numeric constants inconsistently across ~10 files. - This caused type mismatches where
m_ap_type === 'furniture'wouldn’t match numericM_AP_FURNITURE(1), and vice versa. - display.js
monsterShownOnMap()had a dual-check kludge (ap === 'furniture' || ap === 1) that partially masked the bug. - Fix: standardize all assignments and comparisons to use imported numeric constants from const.js. Updated: mon.js, display.js, hack.js, lock.js, wizard.js, pray.js, objnam.js.
Lesson: seed328 screen divergence is stale-glyph rendering model difference
- seed328 has 13659/13659 RNG match and 1723/1723 events match — pure display issue.
- A centipede at (6,14) has M1_HIDE and mundetected=true. Player has no telepathy, warning, or detect_monsters. Both C and JS should hide it.
- But C shows ‘s’ at that position because C uses incremental rendering (newsym): the centipede was visible at an earlier step, then became mundetected, but no newsym() was called at that tile to clear the old glyph.
- JS does a full re-render every frame, always checking current monster state.
- This is a fundamental rendering model difference, not a game logic bug.
- The monsterShownOnMap() enhancement to check senseMonsterForMap() is correct C parity but doesn’t fix this case since the player lacks detection abilities.
Lesson: Gate-2 postmov refactors need explicit A/B parity proof
- For issue #260, we extracted postmove flow into named helpers in
js/monmove.jsto prepare a faithful C ordering port:run_dochug_postmove_pipeline_current_js(...)for pet/non-petdochugpaths.m_move_apply_moved_effects_current_order(...)for moved-cell effects.
- Requirement: Gate-2 is structure-only. Any refactor must prove no parity drift.
- Reliable method: stash patch, run target seeds on baseline commit, pop patch, rerun, and compare first-divergence signatures.
- A/B result for seeds
325,327,328was identical after extraction:- seed325 first RNG divergence remains step
238 - seed327 first RNG divergence remains step
390 - seed328 remains RNG/event full-match with first divergence in screen channel at step
231
- seed325 first RNG divergence remains step
Lesson: C-faithful postmov trap-before-dig can land as a neutral semantic slice
- In C
postmov(), trap resolution (mintrap) runs before tunneling (mdig_tunnel). - JS pet path previously did
mdig_tunnelbeforemintrapindochug. - For issue #260 Gate 3, we moved pet digging to run after shared trap handling in
run_dochug_postmove_pipeline_current_js(...). - Validation after the change:
- target seeds
325,327,328: unchanged first-divergence signatures ./scripts/run-and-report.sh --failures: remained at7failing gameplay sessions
- target seeds
- This is useful as a safe C-ordering correction even without immediate frontier gain.
Lesson: non-pet postmov trap-boundary refactor can reveal a later seed325 frontier
- C
m_move()returns movement intent, thenpostmov()handles trap/door/dig/web sequencing as post-move effects. JS had non-pet door/dig/web effects inline insidem_move()before shared trap handling indochug(). - For issue #260 Gate 3 slice 2:
- moved non-pet moved-cell effects out of
m_move()and into the sharedrun_dochug_postmove_pipeline_current_js(...)post-trap phase - kept internal moved-effect order unchanged for this slice (
maybe_spin_web-> dig -> door) to avoid batching multiple semantic changes - added transient context handoff (
mon._m_move_postmove_ctx) and cleared it atm_move()entry to prevent stale-turn leakage
- moved non-pet moved-cell effects out of
- Validation:
seed325_knight_wizard_gameplay: first RNG divergence advanced238 -> 309seed327_priest_wizard_gameplay: unchanged (390)seed328_ranger_wizard_gameplay: unchanged (RNG/events full-match; screen-only first divergence)./scripts/run-and-report.sh --failures: still27/34passing,7failing
- Takeaway: this supports the hypothesis that postmov trap-boundary ordering is a real parity frontier, and it can be improved without increasing failing-session count.
Lesson: door-before-dig ordering slice can be adopted incrementally when neutral
- Follow-up Gate 3 slice adjusted non-pet moved-cell effect order in shared postmove:
door handling -> mdig_tunnel -> maybe_spin_web. - This aligns closer to C
postmovsequencing (door/barsbefore dig; web is tail work). - Validation stayed neutral relative to prior slice:
seed325remained at first RNG divergence step309seed327/seed328unchanged./scripts/run-and-report.sh --failuresremained27/34passing,7failing
- Even when immediate frontier movement is flat, landing C-faithful neutral slices simplifies later debugging and reduces mixed-order confounders.
Lesson: placing maybe_spin_web in postmov tail is a safe cleanup slice
- C
postmov()runsmaybe_spin_webin the moved/done tail, after moved-cell core work and after object interaction checks. - JS had non-pet
maybe_spin_webinside moved-cell core helper. - We moved non-pet web spin to
dochugmoved/done tail (just before hide-under), keeping trap/dig/door handling in the shared postmove core helper. - Validation remained stable:
seed325stayed improved at first RNG divergence step309seed327/seed328unchanged- failing suite remained
27/34passing,7failing
- This reduces one more ordering mismatch and keeps the code closer to C postmov structure without introducing regressions.
Lesson: shared postmov tail helper keeps pet/non-pet sequencing aligned
- C
postmov()applies moved/done tail effects for both pet and non-pet outcomes. - JS previously duplicated tail handling in non-pet only, leaving pet flow less aligned.
- We added
run_dochug_postmove_tail_current_js(...)and now route both branches through it for moved/done statuses:- item pickup (
maybeMonsterPickStuff) andMMOVE_DONEnormalization maybe_spin_web- hide-under reevaluation
- item pickup (
- Validation stayed stable:
seed325remained at first RNG divergence step309seed327/seed328unchanged- failing suite remained
27/34passing,7failing
- This reduces branch fragmentation and makes subsequent C-ordering audits easier.
Lesson: include after_shk_move in shared postmov tail
- C
postmov()runsafter_shk_move()for shopkeepers in moved/done tail. - We added this call to the shared JS tail helper so shopkeeper bookkeeping is applied uniformly after post-move processing.
- Validation stayed stable on target seeds and failing-suite count stayed at
7.
Lesson: postmov tail must run meatmetal/meatobj/meatcorpse before pickup
- C
postmov()performs object-consumption hooks beforempickstuff:meatmetal(metallivores)meatobj(gelatinous cube)meatcorpse(corpse eaters)
- JS tail previously jumped straight to pickup logic, skipping this branch.
- We added these checks to shared
dochugpostmove tail beforemaybeMonsterPickStuff, preserving C ordering and death semantics. - Validation remained stable:
seed325frontier unchanged at step309seed327/seed328unchanged- failing suite remained
27/34passing,7failing
Lesson: mthrowu drop-through path must preserve ship_object breaktest RNG
seed325at step309showed Crn2(100)fromobj_resists(zap.c)while JS advanced to the next movement-roll RNG.- Root cause: JS
mthrowu.drop_throw()returned early for down-gate migration (!nodrop) without callingbreaktest(), while Cship_object()always runsbreaktest()before migration. - Fix: in JS
drop_throw(), callbreaktest(obj)on the migration path before returning (to preserveobj_resists()RNG consumption and break semantics). - Impact:
seed325first RNG/event divergence moved later from step309to365- failing suite remained
27/34passing,7failing (no regression in count)
Lesson: hatch_egg must gate RNG on get_obj_location eligibility
- New
seed325frontier at step365showed JS timer RNG (rnd(1)fromhatch_egg) occurring where C consumed hunger RNG (rn2(20)ingethungry). - Root cause: JS
timeout.hatch_egg()consumedrnd(quan)unconditionally, while Chatch_egg()only rolls hatchcount afterget_obj_location()succeeds (eligible locations:OBJ_INVENT,OBJ_FLOOR,OBJ_MINVENT). - Fix: add location eligibility gate in
hatch_egg()before hatchcount RNG. - Impact on current baseline:
seed325_knight_wizard_gameplay: RNG/events became full-match (422/422); now screen-only divergence remains at step306.seed327unchanged.
- Full gameplay-suite failure count stayed stable on current main baseline.
Lesson: treat mcanmove as C boolean (0 and false both immobile)
- C uses boolean checks for
mcanmove; both0andFALSEare immobile. - JS had strict checks (
=== false/!== false) in severalmovemon/dochuggates, which let numeric0monsters act and consume extra RNG. - We normalized these to boolean checks in movement-critical paths:
movemonequip/occupation gates (js/mon.js)dochugimmobile gate and postmove tail gate (js/monmove.js)- helper logic using monster helplessness (
js/monmove.js)
- We also aligned pet path pre-move trap handling with C
m_move()ordering by running trapped resolution beforedog_move. - Validation impact:
seed327_priest_wizard_gameplay: RNG parity improved from390/429to429/429(now event/screen-only divergence frontier).- gameplay failures remained stable at
6total.
Lesson: route monster-lethal flow through end.c path, not ad-hoc wizard bypass
- C uses
done_in_by(..., DIED)/done()death flow; wizard mode asksDie? [yn] (n)instead of unconditional immediate survival. - JS had duplicated ad-hoc wizard bypass in
mhitu.js(OK, so you don't die.), which bypassed the canonical death flow and made turn-boundary control fragile. - Fixes:
js/mhitu.js: lethal monster damage now callsdone_in_by(..., DIED, game).js/end.js: wizard death now installs a pendingDie? [yn] (n)prompt.js/end.js:savelife()sets_stopMoveloopAfterLifesaveso the current movement loop halts after survival, mirroring C’s stop-after-savelife behavior.js/allmain.js:savebones()is gated behind actualgame.gameOverstate, avoiding premature death-side effects while prompt resolution is pending.
- Validation impact:
- failing-session count remained stable (
28/34passing,6failing). seed331frontier remains in monster-move divergence (dochugat late step), so this was architectural correctness cleanup, not the final parity fix.
- failing-session count remained stable (
Lesson: wizard Die? [yn] (n) must be strict-keyed and deferred after --More--
- C wizard death confirmation uses a real
ynprompt semantics: only explicity/n(plus default-on-Enter/Esc) should resolve it. - JS accepted any non-
ykey asn, which letspaceauto-survive during death message sequencing and reopened gameplay flow too early. - Fixes:
js/end.js:wizard_die_confirmnow resolves only ony/Y,n/N, and defaultnvia Enter/Esc; other keys are consumed but ignored.js/end.js: when death messaging has pending--More--, defer prompt installation viagame._deferredWizardDiePrompt.js/allmain.js: execute deferred wizard prompt immediately after clearing--More--, before any other deferred turn behavior.js/mhitu.js: remove duplicate immediateYou die...message from lethal branches whendone_in_byis used, preserving canonical death-message staging throughend.cpath.
- Validation impact:
seed331_tourist_wizard_gameplaynow reaches full RNG/event parity (PRNG 389/389,Events 389/389); remaining mismatch is screen-only at step378('#y#'visibility row diff).- failure count remains
28/34passing (6failing), with improved channel totals: PRNG31/34, Events30/34.
Lesson: monster light sources must attach to live monster pointers (not anything wrappers)
- Dynamic monster lighting in C (
light.c) tracks sources by monster pointer and re-reads monster position every vision pass. - JS had two gaps:
- main
makemon()flow did not register monster light sources at spawn; - clone/replacement paths passed
monst_to_any(...)instead of monster pointers tonew_light_source/del_light_source, breaking pointer identity.
- main
- Fixes:
js/makemon.js: register monster light sources in main spawn flow;js/makemon.jsandjs/mon.js: use direct monster pointers for LS_MONSTER light-source add/remove updates.
- Rendering follow-up:
js/display.js: in out-of-FOV rendering, allow self-luminous monsters to be drawn when line-of-sight (couldsee) exists and the monster is otherwise visible (monVisibleForMap), matching C-style dark-square luminous monster visibility.
- Validation impact:
seed331screen frontier moved later from map-row mismatch at step378to death-message staging at step379, while RNG/event parity remained full.
Lesson: wizard death flow must hand off to disclose prompt in the same key cycle
- C
done()in wizard mode resolvesDie? [yn]and immediately entersreally_done()/disclose()flow on'y', which shows:Do you want your possessions identified? [ynq] (n). - JS was keeping
Die?on screen one extra key because the prompt handler ended without entering disclose-stage prompting in the same command cycle. - Fixes:
js/end.js: make wizardDie?prompt handler async and awaitreally_done().js/end.js: install a C-faithful possessions prompt duringreally_done(), including default handling for<Esc>/<Enter>andq.js/allmain.js: await asyncpendingPrompt.onKey()handlers so prompt state transitions are deterministic.js/replay_core.js: skip docrt-style rerender after prompt-only keys (run_command(...).prompt === true) so prompt surfaces are not overwritten.js/display.jsandjs/headless.js: enforce death-message--More--boundaries so"You die..."does not concatenate into prior topline text.
- Validation impact:
seed331_tourist_wizard_gameplaynow passes fully for gameplay channels: RNG18493/18493, Events3708/3708, Screen389/389, Colors9336/9336.- gameplay-suite pass count improved from
28/34to29/34(5 failures remain).Lesson: replay should not own generic per-key rerender policy
replay_corepreviously forced a genericdocrt-style rerender after most settled commands; this created replay-specific rendering policy to maintain.- Refactor direction: command runtime owns render completion, replay only feeds keys and captures the resulting screen/cursor.
- Implementation slice:
run_command(..., { renderAfterCommand: true })now performs runtime-side final render work for replay-driven commands.- removed generic per-step rerender branch from
js/replay_core.js. - retained one narrow compatibility hook for active pending text popups:
docrt()+ popup redraw while command is input-blocked.
- Validation impact:
- targeted seeds (
seed301,seed306,seed331) pass in this configuration. - full failure burndown remained non-regressing at
29/34passing (5failing).
- targeted seeds (
Refinement: move popup-specific pending-state rendering behind runtime API
- To reduce replay ownership further, replay no longer imports popup/windowing helpers directly.
- Added
NetHackGame.renderInputBlockedState()injs/allmain.jsto own input-blocked UI rendering (docrt+ popup overlay redraw when applicable). js/replay_core.jsnow calls only this runtime API while a command is pending, instead of making window-specific decisions itself.- Validation remained non-regressing (
29/34gameplay passing; same 5 failures).
Guardrail: architecture contract test for replay render ownership
- Added
test/unit/replay_core_render_architecture.test.jsto prevent replay-render ownership regressions. - Test asserts:
- no direct
windows.jspopup-helper imports inreplay_core, - no generic replay-side rerender helper or direct
game.docrt()call inreplay_core, - replay uses runtime-owned APIs (
renderAfterCommandpath andrenderInputBlockedState()).
- no direct
Hardening: move blocked-popup redraw trigger to input-wait boundary
- Deeper cleanup moved pending-popup redraw trigger off replay step loop and onto runtime input-wait transitions.
createHeadlessInput()now exposessetOnWaitStarted(fn)and fires it whennhgetch()enters a waiting state.NetHackGame.init()wires this callback torenderInputBlockedState().replay_coreno longer callsrenderInputBlockedState()directly.- Validation:
- architecture unit guard passes;
- sensitive seeds (
seed301,seed306,seed331) pass; - full failure burndown non-regressing (
29/34passing, same 5 failures).
Manual-direct early drift reduction: #untrap + trap object map wiring + autounlock occupation ordering (2026-03-07)
- Target: issue
#263(seed031_manual_direct,seed032_manual_direct) where JS diverged early into monster turns before expected trap/lock handling. - Root causes fixed:
#untrapdirection parsing injs/cmd.jsdid not accept./s(“here”), so replay consumed later keys and skipped the intended disarm branch.cnv_trap_obj()trap-object conversion injs/trap.jswas callingplace_object()without map context anddeltrap()with wrong argument order, so JS consumed part of object RNG but missed^place/^dtrapstate/event effects.- Several object placement/stacking call paths relied on implicit global-map semantics, while JS helpers required explicit
map; added C-style active-map fallback inplace_object()/stackobj(). - Locked-door autounlock (
js/hack.js) needed one immediate lock-picking occupation tick afterpick_lock()to align with observed C ordering aroundpicklock(lock.c:98).
- Validation:
seed032_manual_direct: first RNG divergence moved later from step52to step73; event matches improved1558 -> 1956.seed031_manual_direct: first RNG divergence moved later from step57to step78; event matches improved1229 -> 1840.- Guard checks stayed green:
seed100_multidigit_gameplaypass (RNG/events full)seed328_ranger_wizard_gameplaypass (RNG/events/screens full)
Manual-direct seed032: stepped-trap seen-escape parity moved frontier (2026-03-07)
- Problem:
seed032_manual_directstill diverged when JS immediately applied teleport-trap effects fromhack.jsstepped-trap handling, while C consumedrn2(5)indotrap()seen-trap escape gating (trap.c:2962-2970) and often escaped trap effects. - Fix: added C-style seen-trap escape branch to
hack.jsstepped-trap path, includingrn2(5)consume and early return when escaping. - Impact:
seed032_manual_directfirst RNG divergence moved later73 -> 88.seed031_manual_directremained at step78(non-regressed).
- Guard sessions remained green (
seed100,seed328).
seed327 vault/Knox parity: floating Ludios source and mk_knox_portal gate ordering (2026-03-07)
- Root cause of early seed327 event drift was a C/JS modeling mismatch in
Ludios branch source handling during vault generation:
- C keeps Ludios branch source floating (
end1.dnum == n_dgns) untilmk_knox_portal()decides to bind it. - JS had pre-bound Ludios source in branch topology, which changed
mk_knox_portal()gate behavior and RNG/event ordering.
- C keeps Ludios branch source floating (
- Fixes in
js/dungeon.js:init_dungeons()now marks Ludios source as floating (end1.dnum = n_dgnsequivalent via current dungeon count sentinel).mk_knox_portal()now follows C gate order more closely: source-side selection, branch-level disallow, already-set/defer check, main-dungeon/depth/quest-entrance checks, then placement.- On successful portal setup, branch source is bound to current level.
- Validation:
seed327step-222rng_step_diff: RNG comparable entries now match.seed327session parity reached full RNG/events match (20304/20304,12214/12214).- Failure burndown improved to
28/34passing,6failing.
postmov parity slice: iron-bars handling before mdig_tunnel (2026-03-07)
- Added a missing C
postmov()branch injs/monmove.jsfor monsters that can eat throughIRONBARS(rust/corrosion/metallivore path), ordered beforemdig_tunnel()in the shared non-pet postmove pipeline. - Hardened
dissolve_bars()so it no longer depends on undefined symbols and safely updates terrain + wall info + redraw. - Validation:
- targeted seeds unchanged (
seed325,seed327,seed328non-regressing); - full failure suite unchanged at
28/34passing (6failing).
- targeted seeds unchanged (
postmov parity slice: trapped-door and doorbuster handling in moved-cell phase (2026-03-07)
- Extended
js/monmove.jsnon-pet moved-cell postmove branch to better match C door semantics:- trapped-door disarm via
has_magic_key(mon)before open/unlock handling; - trapped-door explosion path via
mb_trapped(...)for unlock/open/bust cases; - explicit doorbuster branch (
BUSTDOOR) with C-stylern2(2)broken-vs-nodoor decision and shop damage tracking viaadd_damage(...).
- trapped-door disarm via
- This keeps door handling inside the C-aligned postmov order (after
mintrap, before bars/dig/tail). - Validation:
seed325/seed327keep full RNG+event parity;seed328remains full pass;./scripts/run-and-report.sh --failuresunchanged at28/34passing (6failing).
SDOOR wall-angle rendering parity (2026-03-07)
- Root cause for
seed327screen-only drift (row12 col34, extra│) was injs/render.js: secret doors (SDOOR) were always rendered as wall glyphs via neighbor-derived wall type, ignoring Cwall_angle()visibility gating. - C
display.c wall_angle()treatsSDOORas horizontal/vertical wall and appliesseenv + wall_infomode checks; when the angle/mode fails, glyph isS_stone(blank), not wall. - Fix in JS:
SDOORnow follows C shape selection (arboreal_sdoor -> TREE, elsehorizontal ? HWALL : VWALL);- applies
wallIsVisible(...)withloc.seenvandloc.flags(WM_MASK) and falls back toSTONEwhen not visible by angle.
- Validation:
seed327_priest_wizard_gameplaynow passes;- gameplay burndown improved from
28/34to29/34passing.
postmov parity slice: door branch gated by !passes_walls && !can_tunnel (2026-03-07)
- Tightened shared non-pet postmov door branch to mirror C guard conditions:
door logic now runs only when the monster does not pass walls and is not in
tunnel mode (
ALLOW_DIG). - This keeps door handling semantics aligned with C’s postmov branch partition before bars/dig handling.
- Validation stayed stable:
seed325/seed327RNG+event full-match unchanged;seed328full pass unchanged;- failure suite remains
28/34passing (6failing).
postmov parity slice: amorphous-under-door branch restored (2026-03-07)
- Added the missing C postmov door branch for amorphous monsters: when entering a locked/closed door tile, amorphous monsters now “flow/ooze under the door” without mutating door state, before unlock/open/bust logic.
- This restored C branch ordering and prevented JS from incorrectly forcing open/unlock behavior in those cases.
- Validation:
seed327_priest_wizard_gameplaynow reaches full RNG+event+screen parity (remaining mismatch is cursor-only),- global gameplay burndown improved from
28/34to29/34passing (5failing).
makelevel branch snapshot parity (seed332) (2026-03-07)
seed332_valkyrie_wizard_gameplayregressed with first RNG drift at step 203: JS consumedrn2(4)infind_branch_room()while C consumedrn2(6)inmakelevel(mklev.c:1403).- Root cause in
js/dungeon.js makelevel():- JS recomputed branch presence late (right before
place_branch()), after vault/mk_knox_portal()could mutate Ludios branch source. - C snapshots
branchp = Is_branchlev(&u.uz)early and uses that snapshot for bothroom_thresholdand laterplace_branch(branchp, ...). - On this seed, late recompute falsely treated
d0l25as branch level and triggered extrafind_branch_room()RNG.
- JS recomputed branch presence late (right before
- Fix:
- Snapshot branch placement once at makelevel start:
branchPlacementAtStart = resolveBranchPlacementForLevel(...). - Use
hasBranchAtStartfor room threshold (4vs3). - Use
branchPlacementAtStartforplace_branchinstead of late recompute.
- Snapshot branch placement once at makelevel start:
- Validation:
seed332now full pass: RNG/events/screen/mapdump all 100%.scripts/run-and-report.sh --failuresimproved from29/34to30/34passing gameplay sessions; no new failures introduced.
--More--/quest pager boundary refinement (seed325 spillover reduction) (2026-03-07)
seed325showed quest pager text leaking into later command frames (302..305) despite RNG/events being fully aligned.- Root cause was JS fallback
--More--queue behavior at command boundaries: quest portal messaging could be emitted while a prior prompt was pending, then replayed later as queued toplines. - C-faithful refinement applied:
run_command()andnhgetch()now await asyncdisplay._clearMore()._clearMore()resumes at most one queued message per dismissal instead of draining the whole queue in one pass.maybeShowQuestPortalCall()suppresses quest text output when a--More--prompt is already pending, while preserving the same RNG consumption andqcalledstate transition.
- Validation:
scripts/run-and-report.sh --failuresnow reports30/34passing (4failing), matching current team frontier.
- Remaining
seed325divergence is later screen-only (step 306, missing floor)glyph) with RNG/events still full-match.
dogfood poison/trap bit alias + vegetarian corpse taste index (2026-03-07)
seed031_manual_directhad persistent dog-goal drift where C loggedfood=5(POISON) for a floor large box while JS loggedfood=4(APPORT), causing an extra JSobj_resists()rn2(100)indogfood.- Root cause: C aliases object bits (
#define opoisoned otrapped), but JS storesopoisonedandotrappedseparately.- For trapped boxes (
otrapped=1), Cdogfood()sees poison-bit set and returnsPOISONbeforeobj_resists(). - JS previously only checked
obj.opoisoned, so it missed this branch.
- For trapped boxes (
- Fix:
- Added
hasPoisonTrapBit(obj)injs/objdata.js. - Switched parity-sensitive reads in
js/dog.js(dogfood) andjs/mon.js(meatmetal) to use the shared-bit helper.
- Added
- Additional C-faithful parity fix in
js/eat.js:- corpse palatability message index now follows C:
vegetarian corpses use fixed index
0, non-vegetarian corpses usern2(5).
- corpse palatability message index now follows C:
vegetarian corpses use fixed index
- Validation:
- Gameplay failure set remains stable (
31/34passing). seed031first RNG divergence moved later (step 131 -> 139), confirming forward progress without regressions.
- Gameplay failure set remains stable (
corpse-eat RNG ordering cleanup (2026-03-07)
handleEat()still had synthetic corpse pre-rolls (rn2(20),rn2(7),rn2(10),rn2(5)) before consumption, whileeatcorpse()was already available and should own corpse-specific RNG/message behavior.- Fix:
- removed synthetic corpse pre-rolls from
handleEat(); - always route corpse handling through
eatcorpse(); - keep bite timing from
corpseOutcome.reqtimeand preserveretcode==2early-consume behavior.
- removed synthetic corpse pre-rolls from
- Why this matters:
- keeps RNG call ownership in the same semantic location as C (
eatcorpsepath) and avoids duplicate/early rolls that can shift later monster-move parity.
- keeps RNG call ownership in the same semantic location as C (
- Validation:
- on
seed031_manual_direct, first RNG divergence remained at the later post-fix boundary (step 139) after rebasing onto latestmain.
- on
yn prompt + rush prefix command fidelity fixes (2026-03-07)
ynFunctionnow matches ttyyn_functioncase handling:- lowercases input unless choices include uppercase letters;
- treats
LF/CR/spaceas default-answer keys.
rhackmovement dispatch now routes storedg(rush) prefix todo_rush(previously it incorrectly routed all stored run prefixes todo_run).- Validation:
- targeted command unit tests:
test/unit/command_run_prefix_invalid.test.jstest/unit/command_run_timing.test.jsboth pass.
- targeted command unit tests:
locked #loot autounlock parity in pickup path (2026-03-07)
handleLoot()previously diverged from Cdo_loot_cont()for locked floor containers:- emitted simplified
"Hmmm, it seems to be locked."; - skipped C autounlock key/untrap path (
pick_lock(..., ox, oy, cobj)).
- emitted simplified
- Fix in
js/pickup.js:- use C-like locked messaging (
lknown-aware) and setcontainer.lknown = true; - parse
flags.autounlockin both numeric-bit and token-string forms; - for
apply-key/untrap, clear stale vertical dir (player.dz = 0), select key viaautokey(...), and callpick_lock(game, unlocktool, ox, oy, container); - account for returned time usage (
res !== 0).
- use C-like locked messaging (
- Result:
seed031_manual_directnow reaches the C lock occupation/trapped-chest area.- first RNG divergence moved to
chest_trap(trap.c:6220)vs missing JS trapped-box handling inpicklock_fn, giving a tighter next implementation target.
trapped-box lockflow now calls chest_trap before dex exercise (2026-03-07)
- In C
lock.c:picklock(), successful container lock/unlock calls:- toggle
olocked, - set
lknown, - then
chest_trap(box, FINGER, FALSE)whenotrapped, - then
exercise(A_DEX, TRUE).
- toggle
- JS had skipped the
chest_trapcall entirely, which shifted RNG immediately after autounlock/lock occupation success. - Fix:
- added
trap.jschest_trap(...)entry and wiredlock.jspicklock_fnto call it in the same location as C.
- added
- Validation on
seed031_manual_direct:- RNG matched improved
10127 -> 10189, - events matched improved
3547 -> 3601, - first divergence moved later (
step 140 -> 152).
- RNG matched improved
pickup selector boundary for multi-item , (2026-03-07)
- Manual-direct
seed032has a same-turn pickup selector sequence at gameplay steps44..48:, a b c <Enter>. - JS previously handled
,by immediately choosing one floor object inhandlePickup(), so selector keys were free to leak into global command dispatch in exploratory builds (aapply,bmove,cclose door). - C-faithful boundary fix:
- when multiple non-gold objects are on the hero square,
handlePickup()now entersNHW_MENU+select_menu(PICK_ANY)in the same command flow; - selector letters are consumed by menu input (
nhgetch) rather than top-level command dispatch; - item order is class-grouped then
doname()sorted to align selector mapping with C session shape for the known seed.
- when multiple non-gold objects are on the hero square,
- Validation:
- full gameplay suite remained stable (
31/34passing, same 3 failing sessions:seed031,seed032,seed033); seed032first divergence remained at step89(no regression spike), while step-44 pickup keys stayed command-local.
- full gameplay suite remained stable (
prompt-completion cursor/status boundary (2026-03-07)
run_command()previously returned immediately for handled prompt keys (pendingPrompt.onKey) without restoring normal cursor/status placement when the prompt closed.- That left replay-time command frames in a stale prompt-cursor state and contributed to manual-direct drift.
- Fix:
- after prompt key handling, if the prompt is fully closed and no
--More--is pending, refresh status and cursor to player position; - guard this refresh behind
!game.gameOverto avoid end-of-game screen regressions.
- after prompt key handling, if the prompt is fully closed and no
- Validation:
seed031_manual_directfirst RNG/event divergence improved from step139to step152;./scripts/run-and-report.sh --failuresremained stable at31/34passing (same failing trio:seed031,seed032,seed033).
des.grave now uses true HEADSTONE engraving path (2026-03-07)
- C ref:
sp_lev.c:lspo_grave()callsmake_grave(...), andmake_grave()writes engraving typeHEADSTONE. - JS had
des.grave()write a generic'engrave'withnowipeout: true, which is not equivalent to C type semantics. - Fix in
js/sp_lev.js:- import and call
make_grave(levelState.map, xabs, yabs, epitaph)fromengrave.jsinstead of manualmake_engr_at(..., 'engrave', nowipeout).
- import and call
- Validation:
./scripts/run-and-report.sh --failuresstayed stable at31/34passing with the same failing trio (seed031_manual_direct,seed032_manual_direct,seed033_manual_direct), so this was a correctness cleanup without regression.
potion quaff dispatch fixed from name-heuristic to otyp dispatcher (2026-03-07)
- Root cause in
seed031window after locked-box trap handling:- C quaff path executed
peffect_healing(d(4,4)+exercise(A_CON,TRUE)), - JS quaff path in
handleQuaff()still used legacy string matching onitem.oname, which can classify a real potion as water (\"Hmm, that tasted like water.\").
- C quaff path executed
- Fix in
js/potion.js:- removed legacy
oname-based effect branching inhandleQuaff(); - route selected potion through
peffects(player, item, display)(C-styleotypdispatch).
- removed legacy
- Validation:
seed031_manual_directimproved again:- RNG matched
10189 -> 10277 - colors matched
6110 -> 6156 - first divergence index moved
10149 -> 10150.
- RNG matched
- Remaining earliest mismatch is one subsequent missing
exerciseroll beforedistfleeck.
stepped-trap path now clears run/multi like C dotrap() nomul(0) (2026-03-07)
- C ref:
trap.c:dotrap()begins withnomul(0)before trap-branch checks (including in-air bypass and seen-trap escape). - JS
domove_core()->applySteppedTrap()did not clear run/multi state at trap entry, which was less faithful to C control flow around trap interactions. - Fix in
js/hack.js:- at stepped-trap entry, clear
svc.context.runandgame.multibefore evaluating trap branches.
- at stepped-trap entry, clear
- Validation:
./scripts/run-and-report.sh --failuresremained stable at31/34passing with the same failing trio (seed031_manual_direct,seed032_manual_direct,seed033_manual_direct).
prompt-handled keys now honor time consumption in run_command() (2026-03-07)
- Root cause:
run_command()short-circuited onpendingPrompt.onKey(...).handledand always returned{ tookTime: false }, even when the prompt action itself consumed time.
- C-faithful fix in
js/allmain.js:- propagate
promptResult.tookTime/promptResult.moved; - when prompt handling consumes time, run the normal timed-turn pipeline
(
moveloop_core, seer scheduling,find_ac,see_monsters, occupation-drain) before returning.
- propagate
- Why this matters:
- prompt completion paths (for example pickup continuation prompts) can be true turn-consuming actions; forcing them non-timed creates silent command-boundary drift.
- Validation:
node scripts/test-unit-core.mjspasses;./scripts/run-and-report.sh --failuresremains stable at31/34passing (same failing trio:seed031,seed032,seed033).
healing/attribute exercise faithfulness cleanup (2026-03-07)
- C-faithful fixes applied without comparator/replay masking:
js/attrib_exercise.jsnow mirrorsattrib.cstatus checks inexerper():- clairvoyance wisdom exercise uses
HClairvoyant(intrinsic|timeout) and blocked-state gating; - regeneration strength exercise uses
HRegeneration(intrinsic) rather than legacyplayer.regeneration.
- clairvoyance wisdom exercise uses
js/potion.jspeffect_healing()now matches C ordering/arguments:You_feel("better.")beforehealup(...);healup(..., curesick=!!otmp.blessed, cureblind=!otmp.cursed);- removed non-C extra blindness call from this path;
healup()now cures withSICK_ALL(was incorrectly0).
- Validation:
- no spike/regression in targeted parity checks:
seed031_manual_directremained10277matched RNG calls, first drift still atindex 10150(rn2(19)vsrn2(5));seed032_manual_directremained at prior first drift window (step 89).
- These are correctness cleanups that close C/JS semantic gaps even though the remaining seed031 first drift is unchanged.
- no spike/regression in targeted parity checks:
remove duplicate seer RNG scheduling in run_command() turn wrappers (2026-03-07)
- Root cause:
- JS had
seerTurnscheduling (rn1(31,15)) in multiple layers:- canonical location in
moveloop_core(), - duplicated again in
run_command()timed-turn wrappers.
- canonical location in
- JS had
- C ref:
allmain.cperforms seer scheduling once inmoveloop_core()after turn advancement; command wrappers do not reschedule it.
- Fix:
- removed duplicate
seerTurnupdates fromrun_command()prompt-timed andadvanceTimedTurn()paths, keeping seer scheduling solely inmoveloop_core().
- removed duplicate
- Validation:
node scripts/test-unit-core.mjspasses;./scripts/run-and-report.sh --failuresremains stable at31/34passing (same failing trio).
run-step smudge gating now follows C domove_attempting semantics (2026-03-07)
- Regression context:
seed033_manual_directhad dropped from first RNG divergence step175down to32after enablingmaybe_smudge_engr()on every run step.- First mismatch was an extra early
rnd(5)frommaybe_smudge_engr(hack.js)before the expected^movemon_turn.
- Root cause:
- In C, post-
domove_coresmudging is gated bygd.domove_succeeded, which derives fromgd.domove_attempting(hack.c:2682,hack.c:2944-2947). - During run-step engraving reads,
read_engr_at()cannomul(0)and clear running state before that gate is evaluated, suppressing that step’s smudge. - JS lacked this gate and always smudged after
domove_core().
- In C, post-
- Fix in
js/hack.js:- track run-at-move-start (
ctx._runAtMoveStart) perdo_run()iteration; - in
domove_core(), skip post-move smudge when running was cleared during that move (runAtMoveStart > 0 && ctx.run === 0), matching C gate effect.
- track run-at-move-start (
- Validation:
node test/comparison/session_test_runner.js --verbose test/comparison/sessions/seed033_manual_direct.session.jsonnow returns to first RNG divergence step175(from32);./scripts/run-and-report.sh --failuresremains31/34passing, with failing trio unchanged (seed031,seed032,seed033) and improvedseed033frontier (175/1400);node scripts/test-unit-core.mjspasses.
trap confirm uses pre-cleared nopick command prefix (2026-03-07)
- C-faithful movement fix in
js/hack.js:- known-trap confirmation in
domove_core()now checks the per-command savednopickvalue (captured before context prefix reset), notctx.nopickafter it has already been cleared.
- known-trap confirmation in
- Why this matters:
- C checks
svc.context.nopickfor the active command prefix at the trap prompt decision point; using the post-clear field in JS silently losesm-prefix suppression semantics.
- C checks
- Validation:
node test/comparison/session_test_runner.js --parallel=1 --verbose test/comparison/sessions/seed032_manual_direct.session.jsonremains stable (5430/29894RNG match; first divergence unchanged);./scripts/run-and-report.sh --failuresremains stable at31/34passing with the same three failing sessions.
replay wait-site tracing + tele_trap() await fix (2026-03-07)
- Boundary diagnostics improvement:
js/headless.jsandjs/input.jsnow acceptsetWaitContext(...)and exposewaitContextingetInputState()for blocked-input attribution.js/replay_core.jspendingWaitSite()now preferswaitContextover fallback stacks.
- Teleport sequencing fix:
js/teleport.jstele_trap()nowawaitstele(game)in both the one-shot and random teleport branches (previously fire-and-forget).
- Targeted trace added:
WEBHACK_YN_TRACE=1logsynFunctionprompt/key flow and key source (queued|replay|runtime) to localize prompt-boundary drift.
- Seed032 finding:
- Known-teleport-trap confirm path does execute
ynFunctionand consumeyfrom runtime input, but replay pending trace still misses this as a blocked-input boundary in current run, pointing to remaining replay-core boundary attribution drift.
- Known-teleport-trap confirm path does execute
- Validation:
node test/comparison/session_test_runner.js --parallel=1 --verbose test/comparison/sessions/seed032_manual_direct.session.jsonunchanged (5430/29894, first divergence still step 89);./scripts/run-and-report.sh --failuresunchanged (31/34 passing, failing:seed031,seed032,seed033).
pending wait-site now resolves to gameplay frames (2026-03-07)
- Diagnostic refinement in
js/replay_core.js:pendingWaitSite()now prefers non-internal/js/frames and skips runtime plumbing/internal frames (input.js,headless.js,replay_core.js,node:internal), so traces identify the actual gameplay callsite.
- Result:
- seed032 trap-confirm wait now reports:
step=72 ... start=waiting at async domove_core (js/hack.js:880:21)instead of a generic tick frame.
- seed032 trap-confirm wait now reports:
- Interpretation:
- replay-core wait-boundary detection is working for the trap
ynflow; remaining seed032 mismatch is downstream gameplay parity, not an undetected blocked-input boundary.
- replay-core wait-boundary detection is working for the trap
dbgmapdump: step-targeted compact mapdump captures (2026-03-07)
- Added tool:
test/comparison/dbgmapdump.jscaptures compact mapdump snapshots at selected gameplay steps during JS replay, with optional--windowexpansion.- Output format uses the same compact mapdump sections (
T/F/H/L/R/W/U/A/O/Q/M/N/K/J) as harness checkpoints.
- Key implementation detail:
- Debug capture now injects hero state from live game runtime when map-local
player state is absent, so
U/Aare actionable in replay captures.
- Debug capture now injects hero state from live game runtime when map-local
player state is absent, so
- Seed032 diagnosis using tool:
- Captured steps
88..90around first RNG divergence (step 89):node test/comparison/dbgmapdump.js test/comparison/sessions/seed032_manual_direct.session.json --steps 89 --window 1 diffacross those mapdumps showed no terrain/object/monster/trap deltas.- Interpretation: this divergence window is primarily control-flow/RNG-call ordering (dog/monster decision path), not immediate map mutation drift.
- Captured steps
- Documentation:
- usage and triage workflow are in
docs/DBGMAPDUMP_TOOL.md.
- usage and triage workflow are in
throw prompt parity: allow swap-weapon in dothrow selection (2026-03-07)
- Divergence:
seed031_manual_directearly throw prompt differed at step 9: JS showedWhat do you want to throw? [*]while C showedWhat do you want to throw? [b or ?*].
- Root cause:
js/dothrow.jsfiltered out allowornmaskinventory entries, which incorrectly excluded swap-weapon slot items (W_SWAPWEP) from throw candidates.
- Fix:
- keep excluding non-weapon worn equipment, but allow weapon-slot masks
(
W_WEP|W_SWAPWEP|W_QUIVER) in throw candidate filtering. - maintain C-like invalid-object interaction in throw prompt loop with
repeated
You don't have that object.--More--until dismissal.
- keep excluding non-weapon worn equipment, but allow weapon-slot masks
(
- Validation:
- new unit test:
test/unit/dothrow_prompt.test.jsverifies seed031 step-9 throw prompt includesb. seed031_manual_directscreens improved from152/1365to156/1365(RNG frontier unchanged at step 166).seed033_manual_directimproved fromrng=3715/18558, events=54/12191torng=3728/14973, events=59/10678in current fixture set.
- new unit test:
dbgmapdump refinement: actionable JS transition diffs + aligned C replay keys (2026-03-07)
- Tool enhancements in
test/comparison/dbgmapdump.js:- comprehensive
--helpwith defaults, section legend, output layout, and examples; - new
--adjacent-diffmode to compare each captured JS step against prior captured JS step; index.jsonnow includesadjacentComparisonswhen enabled;- C-side capture now writes
<out-dir>/replay_keys.jsonand passes it tocapture_step_snapshot.pyso C replay uses the same normalized key stream as JS capture.
- comprehensive
- C-side harness bridge update in
test/comparison/c-harness/capture_step_snapshot.py:- added
--keys-jsonsupport (JSON string or string-array), used by dbgmapdump to align replay keys.
- added
- Validation of usefulness (JS-first mode):
node test/comparison/dbgmapdump.js test/comparison/sessions/seed033_manual_direct.session.json --steps 45-49 --adjacent-diff --sections U,M,N,O,Q,K,J,T,F,W- output isolated the only transition delta at
46 -> 47(section=U), matching known first divergence step and narrowing debug focus quickly.
- Current caveat:
- C-side mapdump parity still does not fully align for early steps on some sessions despite replay-key alignment; keep treating
--c-sideas secondary evidence until further harness-step alignment work lands.
- C-side mapdump parity still does not fully align for early steps on some sessions despite replay-key alignment; keep treating
- Parity safety check after tool-only work:
./scripts/run-and-report.sh --failuresremains at gameplay31/34passing (seed031,seed032,seed033only).
uhitm non-weapon melee ordering: tool-class misc damage parity (2026-03-07)
- Context:
seed032_manual_directfirst RNG divergence at step 22 showed missingmhitm_knockbackRNG pair (rn2(3),rn2(6)) before flee check.- dbgmapdump adjacent diffs localized transition at
21 -> 22in monster state (M/N).
- Root cause:
- JS melee path treated wielded non-weapon tools like ordinary
dmgval()weapon hits, underestimating base damage for tool bashing and skipping knockback gate in this window.
- JS melee path treated wielded non-weapon tools like ordinary
- Fix in
js/uhitm.js:- Added C-like tool misc melee base damage helper:
sides = (owt + 99) / 100, no RNG whensides <= 1, elsernd(sides), capped at6.
- Routed wielded
TOOL_CLASSmelee through that misc-object base path. - Kept weapon-skill/artifact damage bonus application restricted to weapon-like items.
- Left non-tool non-weapon behavior unchanged for now to avoid broad regressions until wider wield-state audit is complete.
- Added C-like tool misc melee base damage helper:
- Validation:
seed032_manual_directRNG frontier improved from step22to step159.- Full failures view remains stable at the core trio (
seed031,seed032,seed033) with no new standing regression set.
New Skill: dbgmapdump-parity (2026-03-07)
- Added skill:
- Purpose:
- Teaches agents when and how to use
dbgmapdumpeffectively for parity triage.
- Teaches agents when and how to use
- Includes:
- trigger conditions,
- canonical capture commands (
--first-divergence,--window,--adjacent-diff), - section-to-callchain mapping,
- interpretation heuristics,
- guardrails aligned with parity policy.
apply/getobj invalid-letter parity: preserve repeated invalid-object –More– loop (2026-03-07)
- Divergence:
seed032_manual_directhad a degraded frontier (rng=4165/9127) when invalid apply letters were handled with a plaincontinue(or by dropping out of prompt context), causing command-boundary skew before the step-212 dog turn.
- Fix:
- In
js/apply.js, invalid inventory letters inhandleApply()now emitYou don't have that object.and explicitly render a--More--marker while staying in the same apply loop.
- In
- Why this is correct:
- C
getobj()(invent.c) keeps prompting on invalid letters viacontinue;and does not exit command context. - Session captures in this path show repeated invalid-object
--More--frames before dismissal.
- C
- Validation:
seed032_manual_direct: improved fromrng=4165/9127, screens=164/678, events=1233/5854torng=4188/8237, screens=197/678, events=1220/5832(first shared divergence still step 212).seed031_manual_direct: no regression at first divergence (still step 166), with improved totals (rng total 9132 -> 9122,events total 3335 -> 3320).
apply dispatch gap fixed: lamp/candle paths now wired in doapply (2026-03-07)
- Root cause discovered via replay-step inspection:
- In
seed032_manual_direct,#applyselectionecorrectly referred to an oil lamp, buthandleApply()fell through to"Sorry, I don't know how to use that."because lamp/candle/candelabrum/oil branches were not connected inresolveApplySelection(). - The handler functions (
use_lamp,use_candle,use_candelabrum,light_cocktail) already existed; dispatch wiring was missing.
- In
- Fix:
- Added explicit dispatch branches in
js/apply.jsfor:OIL_LAMP/MAGIC_LAMP/BRASS_LANTERN->use_lampWAX_CANDLE/TALLOW_CANDLE->use_candleCANDELABRUM_OF_INVOCATION->use_candelabrumPOT_OIL->light_cocktail
- All return
tookTime: trueto match apply-turn semantics.
- Added explicit dispatch branches in
- Validation:
seed032_manual_directimproved fromrng=4188/8237, screens=197/678, events=1220/5832torng=4531/10083, screens=201/678, events=1635/6858.- First RNG divergence moved later:
step 212 -> step 435. seed031_manual_directunchanged at first divergence (step 166) and no metric regression.seed033_manual_directunchanged at first divergence (step 47) and no metric regression.
run-mode parity: uppercase direction run uses context.run=1 (2026-03-07)
- Divergence:
seed033_manual_directfirst divergence had JS stopping too early during an uppercase run command (L), with JS frontier at step 47.- C continued additional run iterations before stopping, so RNG/event streams stayed aligned longer.
- Root cause:
- JS treated uppercase run-direction keys (
RUN_KEYS) like#run/Gmode (context.run=3semantics). - In C, uppercase directional run commands call
set_move_cmd(dir, 1), which has differentlookaround()stop behavior (notably hostile-nearby handling).
- JS treated uppercase run-direction keys (
- Fix:
- In
js/cmd.js, routeRUN_KEYSthroughdo_run(..., 'shiftRun')so they use mode1. - In
js/hack.js, align lookaround monster-visibility gating tocanSeeMonsterForMap(...)instead of rawfov.canSee(...)to better match Cmon_visiblesemantics.
- In
- Validation:
seed033_manual_directimproved from:rng=3680/14973,events=482/10678, first RNG divergence at step47
- to:
rng=3744/17510,events=508/13503, first RNG divergence at step54.
kick_ouch parity: subtract from current HP (uhp), not max HP (uhpmax) (2026-03-07)
- Divergence risk:
- JS
kick_ouchdamage was applied asuhp = uhpmax - dmg, which is not C behavior. - C
losehp()always subtracts from current HP; using max HP can silently heal or overstate HP after repeated kicks.
- JS
- Fix:
- In
js/kick.js, changed kick-wall damage application to:player.uhp = max(1, player.uhp - dmg).
- Added missing C side-effect in the same
kick_ouchbranch:wake_nearto(nx, ny, 5 * 5, map)after impact.
- In
- Validation:
- Added focused unit test:
test/unit/kick_ouch_hp_current.test.js- asserts wall-kick damage is deducted from current HP, not max HP,
- and nearby sleepers are awakened while distant sleepers remain asleep.
seed033_manual_directfirst divergence remains step54(no frontier regression from this correctness fix).
- Added focused unit test:
wipe_engr_at event-call parity hypothesis was invalid (2026-03-07)
- Follow-up correction:
- A local experiment moved
^wipe[x,y]logging inwipe_engr_at()to unconditional function entry. - That caused broad event regressions (
31/34event-green ->2/34event-green), with many seeds diverging on^wipeordering.
- A local experiment moved
- Conclusion:
- Unconditional
^wipecall-entry logging is not C-faithful for current parity harness behavior. - The change was reverted; keep
^wipeemission gated to real wipeable-engraving mutation paths.
- Unconditional
Diagnostic: detect stale manual-direct sessions via wipe/movemon skew (2026-03-07)
- Problem:
- Recent parity triage repeatedly hit early event drift on
seed031/032/033_manual_directwith C-side^wipe[...]floods that are not present in modern green sessions. - This made it slow to distinguish recorder-era artifacts from real gameplay port regressions.
- Recent parity triage repeatedly hit early event drift on
- Added non-masking audit tool:
scripts/audit-wipe-skew.mjs- Reads latest (or specified)
.comparison.jsonrun and reports:^wipeand^movemon_turncounts per side,- wipe/move ratios,
SEVERE/MODERATEskew flags.
- Current evidence (latest run):
seed031_manual_direct: JS0/322vs C273/268(SEVERE)seed032_manual_direct: JS5/567vs C357/354(SEVERE)seed033_manual_direct: JS1/1136vs C1121/1108(SEVERE)- Green gameplay sessions show near-equal wipe/movemon counts between JS and C.
- Impact:
- Faster triage routing: treat severe wipe-skew sessions as likely stale-recording artifacts first, then debug true shared RNG/state drift windows.
- Comparator/harness semantics remain unchanged (no masking, no exceptions).
Replay pending-boundary trace now includes --More-- state snapshot (2026-03-07)
- Added diagnostic-only trace detail in
js/replay_core.js:WEBHACK_REPLAY_PENDING_TRACE=1lines now include:_pendingMorestate (more=0/1)messageNeedsMorestate (needs=0/1)- queued message count (
q=N)
- Why:
- session divergence triage around
seed033_manual_directrequired distinguishing true pending-input boundaries from plain topline-acknowledgement state.
- session divergence triage around
- Immediate debugging value:
- hotspot around steps 47-49 showed resume waits at
mattackuwithmore=0 needs=1 q=0at resume completion, narrowing investigation to message-boundary semantics rather than missing prompt waits.
- hotspot around steps 47-49 showed resume waits at
Runtime-owned boundary diagnostics API (2026-03-07)
- Architectural cleanup:
- moved replay diagnostics off direct display internals and onto runtime API surface.
- Added on
NetHackGameinjs/allmain.js:getInputBoundaryState()->{ waitingForInput, boundaryKind, source, pendingCount, ackRequired }emitDiagnosticEvent(type, details)for structured runtime diagnosticssubscribeDiagnostics(listener)andgetRecentDiagnostics(limit)for event stream/ring-buffer access
- Replay integration:
js/replay_core.jsnow logs boundary state viagame.getInputBoundaryState()and no longer reads_pendingMore/_messageQueuedirectly.
- Validation:
- new unit test
test/unit/input_boundary_diagnostics.test.js - replay-core and headless replay contract tests remain green.
- new unit test
dothrow object-prompt parity: include swap weapon path and --More-- flow (2026-03-07)
- Divergence context:
seed100_multidigit_gameplayhad a throw-prompt ordering mismatch.- Prompt behavior also differed on invalid object letters by skipping the C-style
--More--acknowledgement flow.
- Fix in
js/dothrow.js:- throw candidate filtering now excludes worn armor/accessories via equipment slots and
owornmaskwhile still allowing weapon-slot semantics. - invalid throw-letter path now emits
You don't have that object.--More--and requires an acknowledge key before reprompting.
- throw candidate filtering now excludes worn armor/accessories via equipment slots and
- Validation:
- added replay-based unit test
test/unit/dothrow_prompt.test.jsassertingseed031step-9 prompt text. seed100_multidigit_gameplaynow passes fully (rng/events/screens = 100%).- failures sweep at this checkpoint:
31/34gameplay sessions passing (remaining:seed031/032/033_manual_direct).
- added replay-based unit test
mhitu AD_LEGS now applies wounded-legs state (2026-03-07)
- Root cause:
mhitu_ad_legs()consumed thernd(60 - ACURR(A_DEX))roll but did not callset_wounded_legs().- This was a known C-faithfulness gap in the monster-vs-hero
AD_LEGSbranch.
- Fix:
- in
js/mhitu.js,AD_LEGSnow:- chooses side bit via C-style
rn2(2) ? RIGHT_SIDE : LEFT_SIDE, - calls
set_wounded_legs(side, rnd(60 - ACURR(A_DEX)), player), - preserves existing exercise side effects.
- chooses side bit via C-style
- in
- Validation:
- added unit coverage in
test/unit/combat.test.js:AD_LEGS attack sets wounded legs state
node --test test/unit/combat.test.jspasses../scripts/run-and-report.sh --failuresremains stable at31/34(no regression).
- added unit coverage in
pager quick-look pre-getpos prompt now matches C (2026-03-07)
- Divergence context:
seed033_manual_directshowed farlook/getpos boundary drift where JS entered getpos without the C pre-prompt.- This let subsequent space key handling drift (
Can't find dungeon feature ' 'path) and amplified later screen/RNG skew.
- C behavior:
- in
pager.c do_look(), screen look mode prints:- verbose:
Please move the cursor to ... - non-verbose/quick:
Pick ...
- verbose:
- then calls
getpos(...)with verbose suppressed for quick mode.
- in
- Fix in
js/pager.js:- always emits the C-style pre-getpos prompt in
from_screenmode. - passes
flags.verbose && !quickinto getpos context to mirror C quick-mode suppression.
- always emits the C-style pre-getpos prompt in
- Validation:
- added replay unit test
test/unit/pager_quicklook_prompt.test.jsassertingseed033step-61 topline. - failures sweep remains stable at
31/34gameplay sessions passing (no regression vs baseline).
- added replay unit test
getpos TIP_GETPOS boundary: separate tip acknowledgement from goal prompt (2026-03-07)
- Divergence context:
seed033_manual_directstep-62 hadTip: Farlooking or selecting a map location--More--in JS, while C showed the tip as a separate frame beforeMove cursor to ....
- C behavior:
getpos.csetsshow_goal_msgafterhandle_tip(TIP_GETPOS); tip display/ack happens before the goal prompt loop continues.
- Fix in
js/getpos.js:- after first-tip topline emission, add an explicit
nhgetch()acknowledgement boundary before showingMove cursor to .... - align tip first line padding to C capture (
" Tip: ...").
- after first-tip topline emission, add an explicit
- Validation:
./scripts/run-and-report.sh --failuresstays stable at31/34gameplay sessions passing (no regressions in failing set).seed033step-62 first-line mismatch is removed; remaining mismatch now points at missing multi-line tip body rendering.
save confirmation parity: Really save? and silent cancel (2026-03-07)
- Divergence context:
seed032_manual_directearly screen mismatch started at save-confirm prompt text and cancel handling.
- C behavior (
save.c dosave()):- prompt text is
Really save? - default
nresponse returns without emittingNever mind. - prompt is cleared after response.
- prompt text is
- Fix in
js/storage.js:- changed save confirmation text from
Save and quit?toReally save? - on non-
yresponse, return silently and clear message-line prompt state (clearRow(0), resettopMessage/messageNeedsMore).
- changed save confirmation text from
- Validation:
- failures sweep remains
31/34gameplay sessions passing (no regression in failing set). seed032_manual_directscreen frontier improved from step1to step10while preserving existing RNG/event frontier (447/678,21/678).
- failures sweep remains
dofire no-quiver path must show You have no ammunition readied. before prompt (2026-03-07)
- Divergence context:
seed032_manual_directcaptures show an earlyfboundary:You have no ammunition readied.--More--, then the fire item prompt.- JS previously went straight to
What do you want to fire?..., causing first screen mismatch at step 10.
- C behavior (
dothrow.c dofire()):- if
uquiver == NULLandflags.autoquiveris off: printYou have no ammunition readied.then run fire selection (doquiver_core("fire")). - if
flags.autoquiveris on and filling fails: printYou have nothing appropriate for your quiver.before selection.
- if
- Fix in
js/dothrow.js:- in
handleFire, added the no-quiver message before fire selection and staged a real topline--More--boundary viarenderMoreMarker+_pendingMore. - added
autoquiver(player)attempt forflags.autoquiver, with C text on failure.
- in
- Validation:
node --test test/unit/command_fire_prompt.test.jspasses after updating expectations for the C-faithful message ordering../scripts/run-and-report.sh --failuresremains31/34gameplay sessions passing.seed032_manual_directfirst screen mismatch moved from step10to step66.
#untrap no-target wording should be You know of no traps there. (2026-03-07)
- Divergence context:
- In
seed032_manual_direct,#untrap+.on a non-trap square diverged: JS printedYou cannot disable that trap., C printedYou know of no traps there.. - This mismatch was the first screen divergence at step
66.
- In
- C behavior:
trap.c:untrap()usesYou("know of no traps there.");when there is no trap/door-trap path at the selected location.
- Fix:
- In
js/cmd.js,handleExtendedCommandUntrap()now emits C wording for the no-trap branch. - Added unit test in
test/unit/command_extended_command_case.test.jsfor#untrap+.on current square.
- In
- Validation:
node --test test/unit/command_extended_command_case.test.jspasses../scripts/run-and-report.sh --failuresremains31/34gameplay sessions passing.seed032_manual_directfirst screen mismatch advanced from step66to step155.
gold pickup singular article: a gold piece with running-total suffix (2026-03-07)
- Divergence context:
seed031_manual_directfirst screen divergence at step252showed:- JS:
$ - 1 gold piece (7 in total). - C:
$ - a gold piece (7 in total).
- JS:
- C behavior:
- singular gold pickup line uses the article form (
a gold piece) rather than numeric1 gold piece, while plural remains numeric.
- singular gold pickup line uses the article form (
- Fix:
- In
js/do.js,formatGoldPickupMessage()now emitsa gold piecewhen quantity is1, preserving existing plural and running-total wording. - Added unit tests in
test/unit/gold_pickup_message.test.jsfor singular/article and plural/numeric forms.
- In
- Validation:
node --test test/unit/gold_pickup_message.test.jspasses../scripts/run-and-report.sh --failuresremains31/34gameplay sessions passing.seed031_manual_directfirst screen mismatch advanced from step252to step538.
dofire invalid inventory letter must emit You don't have that object.--More-- loop (2026-03-07)
- Divergence context:
- In
seed031_manual_direct, first screen mismatch at step538:- JS:
What do you want to fire? [$b or ?*] - C:
You don't have that object.--More--
- JS:
- Sequence came from entering
f(fire) selection, then typing an inventory letter not present in fire candidates.
- In
- C behavior:
- Fire selection uses getobj-style invalid-item handling:
- print
You don't have that object.--More-- - wait for dismissal key
- reprompt
What do you want to fire?... - allow
ESCto cancel from the pending-more loop.
- print
- Fire selection uses getobj-style invalid-item handling:
- Fix:
- In
js/dothrow.js,handleFire()now mirrors thehandleThrow()invalid-item loop viainvalidMorePending:- invalid selection emits
You don't have that object.--More-- space/enter/^Pdismisses and repromptsESCcancels withNever mind.
- invalid selection emits
- Added unit coverage in
test/unit/command_fire_prompt.test.jsfor this exact invalid-letter flow.
- In
- Validation:
node --test test/unit/command_fire_prompt.test.jspasses../scripts/run-and-report.sh --failuresremains31/34gameplay sessions passing.seed031_manual_directfirst screen mismatch advanced from step538to step595.
armor naming + text-window dismissal semantics in look_here popups (2026-03-07)
- Divergence context:
- After fixing
dofire,seed031_manual_directnext mismatch at step595was:- JS:
an iron skull cap - C:
an orcish helm
- JS:
- After fixing naming, next uncovered mismatch was popup lifecycle:
C kept
Things that are here:visible across non-dismiss keys; JS dismissed on first key.
- After fixing
- C behavior:
objnam.c:xname()forARMOR_CLASSnames usesoc_name_known(nn) directly (except special!dknownshield path); generic armor naming is not gated byobj->dknown.tty_more()-style blocking windows require explicit dismiss keys (space/enter/esc/^P), not arbitrary keypress.
- Fixes:
- In
js/mkobj.js, updatedxname_for_doname()ARMOR_CLASSpath to follow C:- boots/gloves and general armor now depend on
isObjectNameKnownonly (preserving existing unknown-shield special case on!dknown).
- boots/gloves and general armor now depend on
- In
js/windows.js,display_nhwindow(win, true)for text popups now loops until a C-style dismiss key (space/enter/esc/^P), instead of dismissing on first key.
- In
- Validation:
node --test test/unit/command_fire_prompt.test.jspasses../scripts/run-and-report.sh --failuresremains31/34gameplay sessions passing.seed031_manual_directscreen frontier advanced from595/1365to628/1365.
seed031 menu-boundary parity pass: pickup selection/menu rendering/help-? item list (2026-03-08)
- Divergence context:
seed031_manual_directhad an early shared screen boundary mismatch around pickup and help menus (Pick up what?, then?help menu), which then cascaded into earlier RNG drift.
- C-faithful fixes:
pickupmulti-select menu now includes class headers and live+markers for selected rows inPICK_ANY, and emits pickup messages on confirm.?help menu option list was aligned to C/session item order and labels (a..o, including support/license/options entries used in captures).- Help option
inow uses fixed paged text-window output with--More--style dismissal keys and post-page map restoration/redraw.
- Validation:
seed031_manual_directimproved from first screen mismatch step628to649, and first RNG mismatch moved later to step716.- Rechecked
seed032_manual_directto ensure no new earlier mismatch there from these menu/help changes.
event-parity unblinding: shift-aware event stream diagnostic (2026-03-08)
- Problem:
- Strict event-index parity for
seed031/seed032looked much worse than screen/RNG progress because one early event mismatch causes a long cascade.
- Strict event-index parity for
- Evidence:
- A shift-aware inspection of raw event streams showed many early C-side
^wipe[...]entries with matching gameplay otherwise, which shifts strict event alignment quickly. - This appears as instrumentation-era event noise in recorded sessions rather than a direct gameplay-state mismatch signal at those exact positions.
- A shift-aware inspection of raw event streams showed many early C-side
- Improvement:
- Added
scripts/event_shift_diff.mjsto compare C vs JS event streams with bounded-lookahead resynchronization and report:- aligned matches
c_extravsjs_extrashift counts- first shift window
- first hard (non-resyncable) diff
- This is diagnostics-only; no comparator masking or pass/fail behavior was changed.
- Added
- Usage:
node scripts/event_shift_diff.mjs test/comparison/sessions/seed031_manual_direct.session.jsonnode scripts/event_shift_diff.mjs test/comparison/sessions/seed032_manual_direct.session.json
two-weapon --More-- boundary needed deferred turn after dismissal (2026-03-08)
- Divergence context:
- While auditing post-help/apply weapon flows in
seed031_manual_direct, we reached a plateau around step680where JS and C diverged in monster/pet state immediately after a two-weapon status--More--message. - Earlier attempts that only changed message text/cursor handling improved UI alignment but left gameplay RNG/state divergence at the same boundary.
- While auditing post-help/apply weapon flows in
- Key finding:
- This boundary is not just a topline rendering issue; it also gates when the world turn advances.
- In this path, C behavior matched “show
--More--, then run deferred timed turn processing on dismissal”, not “show message only”.
- Fix:
- In
js/wield.js, when entering two-weapon mode with a secondary weapon message:- keep C-style
--More--message semantics - gate turn advancement on prompt-owned
tookTimeinstead of a global deferred-turn flag.
- keep C-style
- In
- Validation:
./scripts/run-and-report.sh --failuresremains31/34gameplay sessions passing.seed031_manual_directimproved from roughly680/1365to684/1365(screen) with PRNG frontier jumping to1159/1365.- This exposed the next concrete apply/getobj boundary mismatch for follow-up work rather than masking it.
input-boundary ownership: stack-first prompt/more + regression guard tests (2026-03-08)
- Problem:
- Input blocking ownership was split across prompt checks,
_pendingMorebranches, and ad-hoc callsites, making command/message boundary behavior hard to reason about.
- Input blocking ownership was split across prompt checks,
- Architectural shift:
run_commandnow treats input boundaries as owner-stack first:owner=prompthandled via stack prompt path.owner=morehandled via stack more-dismiss path.
- Legacy
_pendingMorecommand branch remains only as narrow fallback for rare no-owner states and auto-syncs back tomarkMorePending(...).
- Cleanup:
- Removed duplicate prompt interception in
cmd.jsso prompt key handling is single-layer at command boundary. - Replaced direct
_pendingMore=truewrites at core callsites withmarkMorePending(...).
- Removed duplicate prompt interception in
- Guardrails added:
test/unit/input_boundary_diagnostics.test.jsnow verifies:- prompt boundary consumed once per key in
run_command owner=morestack dismissal path clears pending more state.
- prompt boundary consumed once per key in
- Validation:
./scripts/run-and-report.sh --failuresremains non-regressive (31/34, same failing set) while ownership semantics become explicit.
Mapdump engraving section (E) for provenance debugging (2026-03-08)
- Problem:
seed031/032first event drift is^distfleeckvs expected^wipe, and direct engraving traces showed manywipe_engr_at(...)calls withengr=none. We lacked compact mapdump visibility into engraving state.
- Change:
- Added optional compact mapdump section
E(engravings) across tooling:- JS mapdump emitter (
js/dungeon.js) - mapdump parser/comparator (
test/comparison/session_loader.js,test/comparison/comparators.js) - debug mapdump tool section model/help
(
test/comparison/dbgmapdump.js,docs/DBGMAPDUMP_TOOL.md) - C harness mapdump patch (
test/comparison/c-harness/patches/017-auto-mapdump.patch) - session format docs (
docs/SESSION_FORMAT_V3.md)
- JS mapdump emitter (
- Encoding:
Erow entries are sparse tuples:x,y,type,textLen,nowipeout,guardobjects.
- Comparison remains backward-compatible:
Eis only compared when both sides provide it.
- Added optional compact mapdump section
- Validation:
node --test test/unit/mapdump_extensions.test.jspasses../scripts/run-and-report.sh --failuresremains stable at31/34.dbgmapdumpsmoke check:node test/comparison/dbgmapdump.js test/comparison/sessions/seed031_manual_direct.session.json --steps 15 --sections E,U,M ...- produced
E66,10,4,70,0,0, confirming only one engraving is present in JS at this step, which supports the engraving-provenance gap hypothesis.
dbgmapdump C-side E accuracy tightening + snapshot source upgrade (2026-03-08)
- Problem:
dbgmapdump --c-side --sections Ewas synthesizingEas empty when C checkpoint JSON lacked engraving data, which could be misread as “C has zero engravings” instead of “engraving data unavailable.”
- Fixes:
test/comparison/dbgmapdump.jsnow emits C-sideEonly whencheckpoint.engravingsexists; otherwiseEis omitted and compare reportssection=E kind=missing.- Signature output now includes engraving count (
engr=<n>) for quick scans. test/comparison/c-harness/patches/008-checkpoint-snapshots.patchnow includes explicitengravingsarray in checkpoint JSON:engr_x, engr_y, engr_type, text, nowipeout, guardobjects.
- Validation:
node --test test/unit/mapdump_extensions.test.jspasses.dbgmapdump --helpreflectsEsupport and section defaults.- C-side compare now cleanly distinguishes missing vs empty
Edata.
Overlay/getobj --More-- boundary and cursor parity tightening (2026-03-08)
- Problem:
seed031_manual_directhad full PRNG/screen parity but persistent cursor drift in inventory overlay/getobj flows (?/*menu and invalid-invletYou don't have that object.paths).
- Fixes:
js/invent.js:renderOverlayMenuUntilDismiss()now sets cursor to the tty-faithful “after last rendered menu line” position.
js/wield.js,js/do.js:- invalid-invlet paths now use non-blocking
--More--boundary timing (renderMoreMarker()+markMorePending(...)) instead of immediate blockingmorePrompt(nhgetch)/ ad-hoc key consumption. - drop prompt redraw skips topline clearing while
_pendingMoreis active, preserving visible--More--until dismiss key.
- invalid-invlet paths now use non-blocking
js/dothrow.js:"Ready it instead? [ynq] (q)"prompt now includes the trailing space present in tty prompt formatting, fixing a 1-column cursor offset.
- Validation:
seed031_manual_directcursor parity improved from1236/1365to1365/1365while keepingrng=9079/9079andscreens=1365/1365.- Nearby checks remain stable:
seed032_manual_directstill first-diverges at farlook tip line.seed033_manual_directstill first-diverges on early RNG context.
- Unit suite remains clean:
node scripts/test-unit-core.mjs=>2514/2514pass.
C harness setup: fail-fast checks for critical instrumentation markers (2026-03-08)
- Problem:
- When
nethack-c/patcheddrifts between rebuilds, missing instrumentation inengrave.c/allmain.ccan silently degrade parity diagnostics.
- When
- Change:
- Added post-patch marker assertions to
test/comparison/c-harness/setup.sh:event_log("wipe[%d,%d]", x, y);event_log("engr[%d,%d,%d]", ep->engr_type, x, y);event_log("dengr[%d,%d]", ep->engr_x, ep->engr_y);event_log("mapdump[%s]", dump_id);
- Setup now exits immediately with a clear failure if any marker is absent.
- Added post-patch marker assertions to
- Validation:
bash -n test/comparison/c-harness/setup.sh./scripts/run-and-report.sh --failuresunchanged baseline:31/34gameplay sessions passing, same failing set (seed031/032/033).
seed031 event parity restored (2026-03-08)
- Problem:
seed031_manual_directhad full RNG/screen/color/cursor but poor strict event parity, initially dominated by stale session-side^wipeinserts, then bytmp_atevent drift after targeted re-record.
- Fixes:
- Re-recorded
seed031_manual_direct.session.jsonwith current C harness to remove stale^wipeschema noise. js/dothrow.js:- switched transient throw marker start glyph to canonical numeric
obj_to_glyph-style IDs fortmp_at_startparity. - aligned quick throw-marker path to avoid synthetic
tmp_at_stepdrift in this flow (start/end-only transient marker with delay boundary).
- switched transient throw marker start glyph to canonical numeric
- Re-recorded
- Validation:
node test/comparison/session_test_runner.js --verbose test/comparison/sessions/seed031_manual_direct.session.jsonnow passes fully:rng=9079/9079screens=1365/1365colors=32760/32760events=2684/2684cursor=1365/1365C harness mapdump
Wcorrectness + setup marker correction (2026-03-08)
- Problem:
- During event-parity triage for
seed031/032, fresh C rerecords exposed checkpoint mapdumpWmismatches caused by harness auto-mapdump emittingWfrom flags instead ofwall_info. - Setup marker validation also initially checked the wrong source file for mapdump event logging.
- During event-parity triage for
- Fixes:
test/comparison/c-harness/patches/017-auto-mapdump.patchharness_mapdump_rle_grid()adds explicitwall_infocase.Wrow now emits from thatwall_infocase instead of flags.
test/comparison/c-harness/setup.sh- mapdump marker check now targets
src/mklev.c(event_log("mapdump[%s]", dump_id);), matching patch017.
- mapdump marker check now targets
- Validation:
bash test/comparison/c-harness/setup.shsucceeds with marker checks.- Gameplay baseline unchanged after reverting temporary fixture rerecords:
./scripts/run-and-report.sh --failuresremains31/34with failing seeds031/032/033.
seed032 event parity recovery via fresh harness rerecord (2026-03-08)
- Problem:
seed032_manual_directfirst event divergence was early^distfleeckvs^wipe, consistent with stale harness-era event capture.
- Change:
- Re-recorded
test/comparison/sessions/seed032_manual_direct.session.jsonusing rebuilt current C harness patch stack.
- Re-recorded
- Result:
seed032event parity is now full (events=678/678in PES summary).- Remaining
seed032divergence is screen-only (farlook tip line at step 155), not event-order drift.
- Validation:
node test/comparison/session_test_runner.js --verbose test/comparison/sessions/seed032_manual_direct.session.jsonshowsevents=2748/2748../scripts/run-and-report.sh --failuresnow reports gameplay32/34with:seed032_manual_direct: screen-only divergenceseed033_manual_direct: early RNG/event divergence.
stop_occupation boundary await correctness (2026-03-08)
- Problem:
stop_occupation()emitted"You stop <activity>."without awaitingdisplay.putstr_message(...), allowing message boundary state to race with subsequent command/input handling.
- Fix:
js/allmain.jsnow awaits that message emission:await game.display?.putstr_message?.(...)
- Validation:
node scripts/test-unit-core.mjspasses../scripts/run-and-report.sh --failuresremains at32/34gameplay sessions passing (seed032screen-only,seed033RNG/event).
seed033 occupation boundary advance: missmu now stops occupation (2026-03-08)
- Problem:
- In
mhitu.c,missmu()callsstop_occupation(); JS miss path only didnomul(0), leaving occupation semantics and search interruption out of sync. - Symptom in
seed033: missing/late"You stop searching."behavior and early RNG/event divergence around the step-104 occupation boundary.
- In
- Fix:
- Updated miss path in
js/mhitu.jsto callgame.stopOccupation()(or clear occupation fallback) beforenomul(0), matching Cmissmu()end behavior.
- Updated miss path in
- Validation:
node scripts/test-unit-core.mjspasses.node test/comparison/session_test_runner.js test/comparison/sessions/seed033_manual_direct.session.jsonnow advances first RNG divergence from step104to step108, and matched RNG prefix improves3976 -> 4151../scripts/run-and-report.sh --failuresremains32/34passing overall (seed032screen-only,seed033now diverging later at step 108).
seed033 step-108 occupation/timeout boundary: wounded-legs timeout now follows C path (2026-03-08)
- Problem:
seed033first RNG divergence at step108showed JS consumingrn2(2)fromexerper()while C consumedrn2(88)from theu_wipe_engrgate.- C raw window included
stop_occupation()between spawn (rn2(70)) and regen (rn2(100)), but JS did not.
- Root cause:
- JS had an ad-hoc wounded-legs timer shim in
moveloop_turnend(woundedLegsTimeout/justHealedLegs) that was not the C timeout path. set_wounded_legs()did not populateu.uprops[WOUNDED_LEGS]timeout, and timeout expiry forWOUNDED_LEGSdid not callheal_legs(0)orstop_occupation().
- JS had an ad-hoc wounded-legs timer shim in
- Fix:
- Removed the ad-hoc wounded-legs turn-end shim from
js/allmain.js. - Wired wounded-legs to canonical property timeout bits in
js/do.js:set_wounded_legs()now updatesWOUNDED_LEGStimeout,heal_legs()now clears it. - Updated
js/timeout.jsto trackhWoundedLegsfrom the decremented timeout and to run C-order expiry semantics forWOUNDED_LEGS:heal_legs(0)thenstop_occupation(). - Passed
gameintonh_timeout()call site so timeout expiry can invokestop_occupation()through runtime API.
- Removed the ad-hoc wounded-legs turn-end shim from
- Validation:
node scripts/test-unit-core.mjspasses.node test/comparison/session_test_runner.js --verbose test/comparison/sessions/seed033_manual_direct.session.jsonadvances first divergence from step108to step268../scripts/run-and-report.sh --failuresremains32/34gameplay sessions passing overall, withseed033now failing later (268/1417).
seed033 drop/getobj count boundary: * 10 d now follows C count path (2026-03-08)
- Problem:
seed033divergence at step268traced to earlier drop/getobj handling: thed * 1 0 dsequence should trigger C’s count-aware “too many” path ("You don't have that many! You have only 1.--More--"), but JS previously ignored numeric prefix inside overlay selection and dropped the item directly.
- Fix:
- Updated
js/invent.jsrenderOverlayMenuUntilDismiss(...)to optionally capture numeric count prefix digits before selection (options.allowCountPrefix). - Updated
js/do.jshandleDrop(...)to passallowCountPrefixfor*inventory overlay, propagate returned count, and emit the C-faithful overcount message ("You don't have that many! You have only N.--More--").
- Updated
- Validation:
node scripts/test-unit-core.mjspasses../scripts/run-and-report.sh --failuresremains32/34passing.seed033frontier advances:- before: first RNG/event divergence at step
268
- before: first RNG/event divergence at step
- after: first event divergence at step
361, first RNG divergence at step416(seed033now fails later:rng=416/1417,events=361/1417).
seed033 farlook/getpos headless description path (2026-03-08)
- Problem:
- In headless replay,
getposmovement descriptions depended ondisplay.cellInfo, but headless does not maintain that metadata like the browser display path. - This kept the generic getpos prompt stuck on row 0 instead of emitting
terrain/location descriptions during cursor motion, causing early screen
drift in
seed033.
- In headless replay,
- Fix:
- Updated
js/getpos.jscursor description path to derive labels from map state directly:floor of a room,wall,open/closed door,corridor,unexplored area. - On getpos ESC exit, clear stale topline state explicitly.
- At getpos teardown, rerender map/status/message window from last map state to prevent stale boundary artifacts carrying forward.
- Added headless/window text-popup cleanup plumbing for NHW_TEXT dismissal.
- Updated
- Validation:
./scripts/run-and-report.shremains33/34gameplay sessions passing.seed031andseed032remain green.seed033first screen divergence moved later (64 -> 222) while keeping current first event/RNG divergences at361/416.
Input boundary stack hardening: prompt owner is strict key consumer (2026-03-08)
- Problem:
- Boundary ownership was stack-backed, but
run_command()still allowed parser fallthrough if topprompthandler returned non-handled. - That violates owner semantics and can leak prompt keys into gameplay command parsing at boundary edges.
- Boundary ownership was stack-backed, but
- Fix:
- In
js/allmain.js,run_command()now treats top-ownerpromptas authoritative: if prompt handling does not finalize, the key is ignored at the prompt boundary and does not fall through to command parsing. - Added unit guard in
test/unit/input_boundary_diagnostics.test.jsto lock this invariant.
- In
- Validation:
node scripts/test-unit-core.mjspasses../scripts/run-and-report.sh --failuresremains non-regressive at33/34gameplay sessions passing (same frontier:seed033).
seed033 whatdoes parity: intro --More-- boundary + C-style response formatting (2026-03-08)
- Problem:
seed033diverged around?help ->fwhatdoes flow.- JS
handleWhatdoes()skipped C’s first-use intro boundary behavior and printed ad-hoc response text ('e': Eat something.), while C holds atAsk about '&' or '?' to get more info.--More--and formats responses askey + padded spacing + extcmd description.
- Fix:
- Updated
js/pager.jshandleWhatdoes()to match Cdowhatdoes()flow: - First-use intro line is emitted once and explicitly blocks at
--More--until a dismiss key (Cxwaitforspacebehavior; non-dismiss keys ignored). - Query key now goes through
ynFunction("What command?", ...). - Whatdoes output now uses C-style formatted lines (
<key-text>.padEnd(8) + desc + '.') and C-style unknown-command text with char code/octal/hex fields.
- Updated
- Validation:
node scripts/test-unit-core.mjspasses../scripts/run-and-report.sh --failuresremains33/34gameplay sessions passing.seed033frontier moved later:- before: first RNG/event divergence at
416, screen at394 - after: first RNG/event divergence at
470, screen at460
travel/getpos prompt boundary: tip-aware verbose sequencing + invalid-target hold (2026-03-08)
- Problem:
- Travel target selection (
_->getpos) diverged across two seed patterns: - First-time tip flow expected C sequence:
Where do you want to travel to?--More---> tip window ->"(For instructions...) Move cursor ..." - Post-tip flow expected C sequence:
Where do you want to travel to? (For instructions type a '?')with cursor-move descriptions replacing row 0 and invalid locations marked with(no travel path). - JS had inconsistent prompt layering, occasionally forcing stale
--More--or accepting invalid travel targets on..
- Travel target selection (
- Fix:
- Updated
js/hack.jsdotravel()prompt composition: - include
(For instructions...)suffix only when getpos tip was already seen. - pass travel-mode context into getpos (
travelMode,isTravelPathValid). - Updated
js/getpos.js: - verbose
Move cursor...prompt now appears only during first-time tip flow. - in travel mode, wall/unexplored cursor descriptions append
(no travel path). - travel confirm keys now ignore invalid targets (remain in getpos).
- Updated
- Validation:
./scripts/run-and-report.sh --failuresrestored to33/34(seed032 regression cleared).seed033_manual_directremains improved at first divergence step470.node scripts/test-unit-core.mjspasses (2515/2515).
Added unconditional test_move event instrumentation in both C and JS (2026-03-08)
- Problem:
test_move()is a high-frequency branch point in travel/run/movement flow, but we had no direct event marker for it in session parity logs.- This made movement-path diagnosis rely on indirect RNG/screen clues.
- Fix:
- JS now logs
^test_move[mode=... from=... dir=... to=... rv=...]on every return path injs/hack.js. - C harness now adds matching logging in
src/hack.cvia020-test-move-events.patch. - Harness setup now verifies the hook with a
require_markerintest/comparison/c-harness/setup.sh. - Event comparator treats
^test_move[...]as optional for backward compatibility with old sessions that predate this instrumentation.
- JS now logs
- Validation:
- Full C patch stack reapplied cleanly including patch
020. - Comparator sanity check confirms no false event divergence when only one
side has
^test_moveentries.
- Full C patch stack reapplied cleanly including patch
^test_move instrumentation moved to opt-in + per-session rerecord env (2026-03-08)
- Problem:
- Unconditional
^test_movelogging is very high-volume and can overwhelm general parity runs.
- Unconditional
- Fix:
- JS
test_moveevent emission is now gated byWEBHACK_EVENT_TEST_MOVE=1(default off). - C harness
test_moveevent emission is now gated byNETHACK_EVENT_TEST_MOVE=1(default off) in patch020-test-move-events.patch. rerecord.pynow accepts sanitized per-session env overrides viaregen.env(orregen.extra_env) forNETHACK_*/WEBHACK_*keys.run_session.pyforwardsNETHACK_EVENT_TEST_MOVEinto the spawned C binary and persists it back intoregen.envfor reproducible rerecords.
- JS
- Usage:
- Keep global runs default-off.
- For targeted debugging (e.g.,
seed033), setregen.env.NETHACK_EVENT_TEST_MOVE=1in the session and rerecord.
Browser/headless --More-- drift audit: async queue resume mismatch (2026-03-08)
- Problem:
- Real browser gameplay reported stale messages reappearing many turns later.
- Audit found browser
Display._clearMore()diverged fromHeadlessDisplay._clearMore(): - browser path called async
putstr_message(...)withoutawait - browser path drained the full queue in one dismissal, while headless resumes one queued message per dismiss key.
- Why this is risky:
- Un-awaited
putstr_messagecan leave background continuations that complete later on unrelated input. - Full-queue drain differs from C-style explicit prompt progression and from replay/headless behavior.
- Un-awaited
- Fix:
- Made browser
_clearMore()async and awaited queuedputstr_message. - Aligned semantics with headless: resume at most one queued message per dismissal key.
- Tightened browser
morePrompt()cleanup to clear row-1 spillover state and resetmessageNeedsMoreafter dismissal. - Added unit coverage:
test/unit/display_more_clear_queue.test.jsverifies one-message resume and async await behavior.
- Made browser
- Validation:
node --test test/unit/display_more_clear_queue.test.jspasses.node scripts/test-unit-core.mjspasses.
- Target parity fixture (
seed033_manual_direct) unchanged at current frontier (no new regression from this runtime fix).
Dig failure messaging boundary: missing await in occupation path (2026-03-08)
- Problem:
- In
js/dig.js, dig failure/status messages indig()anddigcheck_fail_message()were emitted withoutawait. - These are topline writes that can hit
--More--boundaries, so fire-and- forget message calls can reorder subsequent state and input handling.
- In
- Fix:
- Made
dig()async and awaited failure-path message emissions. - Made
digcheck_fail_message()async and awaited eachputstr_message(...).
- Made
- Validation:
node scripts/test-unit-core.mjspasses.- No change to current seed033 parity frontier from this boundary correction.
CODEMATCH invent.c surface closure: display_pickinv/display_inventory/ddoinv (2026-03-08)
- Problem:
invent.jshad C-name rows marked missing in CODEMATCH and a dead stub:ddoinv()called undefineddispinv_with_action/display_pickinv.
- Fix:
- Added callable C-name surfaces in
js/invent.js:display_pickinv(...)(approximate inventory menu wrapper)display_inventory(...)(wrapper overdisplay_pickinv)dispinv_with_action(...)(return-code compatible placeholder)ddoinv(...)now async and wired todispinv_with_action(...)
- Upgraded
display_inventory_items(...)from placeholder to real lets/invlet filtering used by those wrappers. - Updated CODEMATCH rows for these functions plus
compactify. - Added unit coverage:
test/unit/invent_display_inventory.test.js.
- Added callable C-name surfaces in
- Validation:
node --test test/unit/invent_display_inventory.test.jspasses.- Existing 5-direction-prompt failures in apply/open tests reproduce even with
these files stashed out (pre-existing on current
main).
CODEMATCH invent.c ledger accuracy pass + C-name wrappers (2026-03-08)
- Problem:
docs/CODEMATCH.mdstill marked manyinvent.cfunctions as Missing even though exact-name implementations already existed injs/invent.js.getobj/ggetobjhad only helper names (getobj_simple/ggetobj_count), so C-name table rows could not map cleanly.
- Fix:
- Added exact C-name wrappers in
js/invent.js:getobj(...)(approx wrapper overgetobj_simple(...))ggetobj(...)(approx wrapper overggetobj_count(...))
- Performed a focused
invent.c -> invent.jstable correction indocs/CODEMATCH.md, flipping already-present functions from Missing to Implemented with currentinvent.jsline references.
- Added exact C-name wrappers in
- Validation:
node --test test/unit/invent_display_inventory.test.jspasses.node scripts/test-unit-core.mjsremains at2521 pass / 5 failwith the same pre-existing direction-prompt failures.
CODEMATCH vision.c ledger correction slice (2026-03-08)
- Problem:
docs/CODEMATCH.mdhadvision.c -> vision.jsrows marked Missing for many functions that were already implemented (including private helpers and inlinednew_anglebehavior), which inflated gameplay missing counts.
- Fix:
- Updated
vision.c -> vision.jstable entries for implemented functions:_q1/_q2/_q3/_q4_path,block_point,unblock_point,recalc_block_point,fill_point,dig_point,view_from,left_side,right_side,clear_path,do_clear_area,vision_init,vision_reset,view_init,get_viz_clear,get_unused_cs,does_block,rogue_vision,vision_recalc(mapped todisplay.js), andnew_angle(mapped as inlined inFOV.compute). - Updated the top-level
vision.cnote to reflect the real remaining gap:howmonseen.
- Updated
- Validation:
- No runtime code changes in this slice; ledger correction only.
vision.c howmonseen closure slice (2026-03-08)
- Problem:
vision.c -> vision.jsstill had one functional gameplay gap in CODEMATCH:howmonseenwas missing.
- Fix:
- Implemented
howmonseeninjs/display.js, colocated with existing monster-visibility helpers (canSeeMonsterForMap,monVisibleForMap,seeWithInfraredForMap). - Added focused unit coverage:
test/unit/display_howmonseen.test.jscovering normal, see-invisible, telepathy, xray, detect, and warning bits. - Updated CODEMATCH row for
vision.c:howmonseento implemented.
- Implemented
- Validation:
node --test test/unit/display_howmonseen.test.jspasses.node scripts/test-unit-core.mjsremains at known baseline (2526 pass / 5 fail, same pre-existing input-queue failures).
detect.c map_redisplay wiring closure (2026-03-08)
- Problem:
detect.jsstill routed browse-map exits throughmap_redisplay_stub()and manualreconstrain_map(player)calls, diverging from Cdetect.c:map_redisplay()sequencing.
- Fix:
- Removed
map_redisplay_stub()callsites and switched all browse-map exits in detect flows (gold_detect,food_detect,object_detect,monster_detect,display_trap_map,reveal_terrain) to:await map_redisplay(player, map). - Made
map_redisplayasync and C-faithful:reconstrain_map(player)->await docrt()-> underwater/buried overlays.
- Removed
- Validation:
node scripts/test-unit-core.mjsunchanged baseline (2526 pass / 5 fail, same known input-queue failures).
seed033 travel/getpos boundary localization + getpos cleanup (2026-03-08)
- Problem:
seed033_manual_directremains the lone gameplay failure (33/34overall).- First screen divergence is at step 935 (
##@vs###), with nearby event drift starting atdog_invent_decision ud=1(JS) vsud=2(C).
- Confirmed findings:
- A non-C full-map repaint existed at getpos exit in JS (
rerenderAfterGetpos). Cgetpos.cexits by restoring cursor/hilite only; no extra repaint. - The early
@paint at the failing boundary is not fromdocrt(). It is written bynewsym()calls reached from:domove_core(direct movement update), andsee_monstersduringadvanceTimedTurn.
- Event mismatch cluster around first divergence shows coupled branch change
in dog AI (
dog_goal_end/dog_move_choice) after that boundary.
- A non-C full-map repaint existed at getpos exit in JS (
- Fix:
- Removed JS
rerenderAfterGetpos(...)and its call ingetpos_asyncfinalizer to match C behavior.
- Removed JS
- Validation:
scripts/run-and-report.sh --failuresstays at33/34(no regression).- The known
seed033frontier remains (event ~467,screen ~935,rng ~942).
display.c warning/monster helper runtime closure (2026-03-08)
- Problem:
display.jshad legacy helper surfaces (show_mon_or_warn,display_warning,warning_of,mon_warning) that still referenced undefined global helpers/signatures (show_glyph,glyph_is_invisible,MATCH_WARN_OF_MON,warning_to_glyph, etc.).- Those functions were effectively unsafe/dead for runtime use and blocked
faithful callchain work in
display.c/mapglyph parity.
- Fix:
- Replaced these with context-aware implementations based on existing JS
display primitives (
setCell,monsterMapGlyph,tempGlyphToCell,hasPlayerProp). - Added callable helper implementations for
display_monsterandmon_overrides_regionsodisplay.ccallchains now have concrete JS counterparts (with explicit simplified status for region override logic). - Added unit tests:
test/unit/display_warning_runtime.test.jsvalidating warning level mapping and warning-cell rendering path.
- Replaced these with context-aware implementations based on existing JS
display primitives (
- Validation:
node --test test/unit/display_warning_runtime.test.jspasses.node scripts/test-unit-core.mjsunchanged known baseline (2528 pass / 5 failafter adding 2 new passing tests).
display.c newsym warning-vs-monster glyph parity fix (2026-03-08)
- Problem:
- JS
newsympath was allowing WARNING-based sensing to flow through the monster-glyph branch becausemonsterShownOnMap()treated WARNING like telepathy/detect/warn_of_mon. - In C (
display.c), WARNING is a separate fallback branch (display_warning(mon)), while monster glyphs are for visible monsters ortp_sensemon/MATCH_WARN_OF_MON/Detect_monsters.
- JS
- Fix:
- Tightened
monsterShownOnMap()so monster glyphs are promoted only by:mon_visible, telepathy,warn_of_mon, ordetect_monsters(not plain WARNING). - Added explicit warning branch in
newsymafter monster-glyph check:if (mon && mon_warning(...)) display_warning(...). - When monster glyph is rendered through this path, set
mon.meverseen = 1(matching Cdisplay_monsterbehavior). - Added regression coverage in
test/unit/display_warning_runtime.test.js: warning-only sensing should not mark monster asmeverseen.
- Tightened
- Validation:
node --test test/unit/display_warning_runtime.test.jspasses.node scripts/test-unit-core.mjsbaseline unchanged:2529 pass / 5 fail(same known input-queue failures).
display.c warning sense gating refinement (2026-03-08)
- Problem:
senseMonsterForMap()treated plain WARNING as sensing all monsters. In C behavior, WARNING should track warning-eligible hostile targets (mon_warning), not peaceful/tame monsters.
- Fix:
- Gated WARNING sensing in
senseMonsterForMap()throughmon_warning(mon, player, ...), while preservingwarn_of_mon, telepathy, and detect-monsters paths. - Added tests in
test/unit/display_warning_runtime.test.js:- WARNING does not sense peaceful monsters.
- WARNING does sense hostile monsters.
- Gated WARNING sensing in
- Validation:
node --test test/unit/display_warning_runtime.test.jspasses.node scripts/test-unit-core.mjsat2531 pass / 5 fail(same known failing set; pass count increased due new coverage).
display.c show_glyph runtime closure (2026-03-08)
- Problem:
display.jshad many callsites toshow_glyph(...)(shield effects, swallowed overlays, underwater rendering) but no concrete JS implementation.- This left a fragile gap in the display helper callchain.
- Fix:
- Implemented context-aware
show_glyph(x, y, glyph, ctxOrMap)injs/display.js:- accepts numeric C-style glyph ids via
tempGlyphToCell - accepts pre-decoded
{ch,color}cells - writes map cell through display context and stores numeric glyph on tile memory when provided.
- accepts numeric C-style glyph ids via
- Routed
show_mon_or_warnthroughshow_glyphfor a single rendering path. - Added focused tests in
test/unit/display_warning_runtime.test.js:- numeric glyph path
- pre-decoded cell path.
- Implemented context-aware
- Validation:
node --test test/unit/display_warning_runtime.test.jspasses.node scripts/test-unit-core.mjsbaseline unchanged:2533 pass / 5 fail(same known 5 input-queue failures).
display.h visibility helper wrappers closure (2026-03-08)
- Problem:
display.jsstill exported unresolved wrapper stubs forsensemon,mon_visible,see_with_infrared,canseemon,knowninvisible, andis_safemonthat called undefined_...helpers.- This left display parity callchain gaps and prevented CODEMATCH closure.
- Fix:
- Replaced wrappers with context-aware implementations backed by existing
faithful helpers (
senseMonsterForMap,monVisibleForMap,seeWithInfraredForMap,canSeeMonsterForMap,canSpotMonsterForMap). - Implemented C-style
knowninvisiblelogic:minvisgate + visible/detected spot path + non-blind telepathic range path. - Implemented C-style
is_safemonlogic:safe_dog/safe_petgate + peaceful +canspotmonequivalent- confusion/hallucination/stun suppression.
- Hardened
_resolveDisplayCtxto accept partial context objects (map,player,fov,flags) and merge them with global display context. - Added tests in
test/unit/display_warning_runtime.test.jsforknowninvisibleandis_safemon.
- Replaced wrappers with context-aware implementations backed by existing
faithful helpers (
- Validation:
node --test test/unit/display_warning_runtime.test.jspasses (11/11).node --test test/unit/display_howmonseen.test.jspasses (3/3).node scripts/test-unit-core.mjsbaseline unchanged:2537 pass / 5 fail(same known 5 input-queue failures).
display.c unmap callchain closure (unmap_invisible/unmap_object) (2026-03-08)
- Problem:
display.jsexportedunmap_invisible()but called undefinedunmap_object()and depended on glyph-only invisible checks that did not match JS map memory (mem_invis).- This left invisible-map clearing behavior partially broken and prevented
CODEMATCH closure for
display.c:401anddisplay.c:422.
- Fix:
- Implemented
unmap_object(x, y, ctxOrMap)injs/display.jswith C-faithful ordering:- no-op when
hero_memoryis disabled - clear mapped invisibility marker
- prefer remembered seen trap
- otherwise restore remembered engraving/terrain background
- darken unlit remembered room squares
- no-op when
- Updated
unmap_invisible()to:- detect mapped invisibility via
mem_invis(and legacy glyph check) - call
unmap_object(..., map)with explicit context - redraw with
newsym.
- detect mapped invisibility via
- Added targeted tests in
test/unit/display_unmap_object.test.js.
- Implemented
- Validation:
node --test test/unit/display_unmap_object.test.jspasses (4/4).node --test test/unit/display_warning_runtime.test.jspasses (11/11).node scripts/test-unit-core.mjsbaseline unchanged:2541 pass / 5 fail(same known 5 input-queue failures).
display.c map-location callchain closure (map_object/map_trap/map_background) (2026-03-08)
- Problem:
detect.jsstill used local no-op stubs formap_object,map_trap, andmap_background, so detection flows did not execute the real display memory/render path.- CODEMATCH rows for
map_locationand supportingmap_*functions remained missing despite related runtime usage.
- Fix:
- Implemented in
js/display.js:map_backgroundmap_engravingmap_objectmap_trapmap_location
- Updated
js/detect.jsto import and use display implementations instead of local no-op stubs. - Added focused runtime tests in
test/unit/display_map_location_runtime.test.jscovering object/trap/background memory updates and map-location precedence.
- Implemented in
- Validation:
node --test test/unit/display_map_location_runtime.test.jspasses (4/4).node --test test/unit/display_unmap_object.test.jspasses (4/4).node --test test/unit/display_warning_runtime.test.jspasses (11/11).node scripts/test-unit-core.mjsbaseline unchanged:2545 pass / 5 fail(same known 5 input-queue failures).
display.c redraw entrypoint closure (docrt_flags/docrt/cls) (2026-03-08)
- Problem:
display.jsexporteddocrt()/doredraw()surfaces but they called missing symbols (docrt_flags,docrtRecalc,cls,ECMD_OKimport), so parity redraw paths could crash when invoked.detect.jsalso kept a localcls()stub instead of using display logic.
- Fix:
- Implemented in
js/display.js:docrt_flags(recalc, ctxOrMap)map repaint loop vianewsymdocrtRecalc(ctx)usingvision_recalccls(ctxOrMap)full terminal clear (row-optimized when available)
- Added missing
ECMD_OKimport fordoredraw()return value. - Updated
js/detect.jsto import and use displaycls(removed local stub). - Added focused runtime tests in
test/unit/display_docrt_cls_runtime.test.js.
- Implemented in
- Validation:
node --test test/unit/display_docrt_cls_runtime.test.jspasses (2/2).node --test test/unit/display_map_location_runtime.test.jspasses (4/4).node --test test/unit/display_warning_runtime.test.jspasses (11/11).node scripts/test-unit-core.mjsbaseline unchanged:2547 pass / 5 fail(same known 5 input-queue failures).
display runtime safety hardening (glyph_at/feel_newsym/underlay modes) (2026-03-08)
- Problem:
- Several display exports still referenced removed C globals (
gg.gbuf,bot,display_self, transient locallastx/lasty) and could throw during runtime paths. - High-impact case:
glyph_at()was used by gameplay callers but depended on undefinedgg.
- Several display exports still referenced removed C globals (
- Fix:
- Hardened in
js/display.js:glyph_at()now reads map-backed glyph memory (loc.glyphfallback tocmap_to_glyph(loc.typ)), noggdependency.newsym_force()now performs safe forced redraw withoutgg.- Added
feel_location()and wired blindfeel_newsym()through map-backed rendering. swallowed(),under_water(),under_ground()now keep stable module state for deferred/last-position behavior and avoid undefined helpers.tether_glyph()now usesMath.signdirectly.
- Added safety coverage in
test/unit/display_runtime_safety.test.js.
- Hardened in
- Validation:
node --test test/unit/display_runtime_safety.test.jspasses (4/4).node --test test/unit/display_docrt_cls_runtime.test.jspasses (2/2).node scripts/test-unit-core.mjsbaseline unchanged:2551 pass / 5 fail(same known 5 input-queue failures).
display legacy helper stabilization (swallow_to_glyph/wall-info helpers) (2026-03-08)
- Problem:
- Several remaining display helper exports still referenced undefined symbols
(
what_mon,rn2_on_display_rng,seenv_matrix,sign, legacymap.locations-only access), creating latent runtime crashes.
- Several remaining display helper exports still referenced undefined symbols
(
- Fix:
- Updated
js/display.js:swallow_to_glyphnow uses stable numeric mapping without undefined RNG helpers.check_posnow supports bothmap.at(x,y)and legacymap.locations[x][y].set_seenvnow uses local seenv matrix +Math.sign(no undefined globals).set_wall_statenow delegates todungeon.set_wall_state(map)and falls back to per-cellxy_set_wall_statewhen available.- Added required symbol/constant imports for swallow/zap and wall-info helpers.
- Added focused tests in
test/unit/display_legacy_helpers_runtime.test.js.
- Updated
- Validation:
node --test test/unit/display_legacy_helpers_runtime.test.jspasses (4/4).node --test test/unit/display_runtime_safety.test.jspasses (4/4).node scripts/test-unit-core.mjsbaseline unchanged:2555 pass / 5 fail(same known 5 input-queue failures).
seed033 late-window boundary split (step 935/936) (2026-03-08)
- Problem:
seed033_manual_directshows a persistent late screen mismatch around step 935 (@appears one step early in JS at row 10), with RNG/event divergence shortly after.
- Isolation:
dbgmapdumpwindow around 933-936 +WEBHACK_REPLAY_PENDING_TRACE=1WEBHACK_RUN_TRACE=1shows:- step 933 (
_):getpos_asyncstarts and waits for input. - step 934 (
>): prompt resumes, still waiting. - step 935 (
.): prompt resumes and completes, then JS runs a full travel chain in the same step (69,6 -> ... -> 77,9, thentravel.no-path).
- Per-step count evidence (
scripts/comparison-window.mjs --step-summary) shows a clear adjacent shift:- step 935: RNG
js/c = 540/140(+400) - step 936: RNG
js/c = 0/400(-400) - events show the same +N/-N packing pattern.
- step 935: RNG
- C session confirms the split by step:
- step 935 and 936 both contain large
moveloop_core/distfleeckblocks, but distributed across two keys.
- step 935 and 936 both contain large
- Root cause candidate:
- C harness recorder (
test/comparison/c-harness/run_session.py) sends keys on fixed delay and captures after each sent key; it does not wait for command completion/boundary stabilization between keys. - JS replay currently drains a resumed pending command to completion before advancing to the next key, so work can be packed one step earlier.
- C harness recorder (
- Guardrail:
- Do not hide this via comparator masking or replay synthetic key injection.
- Fix needs runtime/input-boundary faithfulness, not comparator exceptions.
^runstep command-boundary instrumentation (C+JS) (2026-03-09)
- Goal:
- Add a shared, optional command-boundary event stream to both C harness and JS replay so multi-turn run/repeat boundary debugging can use one marker.
- C harness implementation:
- Added patch
test/comparison/c-harness/patches/021-runstep-events.patchwith env-gated emission (NETHACK_EVENT_RUNSTEP). - Event format:
^runstep[path=... keyarg=... cmd=... cc=... moves=... multi=... run=... mv=... move=... occ=... umoved=... ux=... uy=...]
- Emission sites are in
moveloop_core()at fresh-command and repeat-command boundaries to match C command lifecycle. - Added setup guardrail marker in
test/comparison/c-harness/setup.shso patch drift fails fast.
- Added patch
- JS implementation:
- Added env-gated emission (
WEBHACK_EVENT_RUNSTEP) injs/allmain.jsat therun_command()command boundary. - Added session-runner auto-toggle support in
test/comparison/session_test_runner.js: if a session requests/contains runstep events, JS replay auto-enablesWEBHACK_EVENT_RUNSTEP=1for that run. - Added C capture plumbing in
test/comparison/c-harness/run_session.pyandtest/comparison/c-harness/capture_step_snapshot.pysoNETHACK_EVENT_RUNSTEPis preserved in rerecord/snapshot workflows.
- Added env-gated emission (
- Validation:
- Rebuilt C harness with
bash test/comparison/c-harness/setup.sh(new patch applies cleanly; marker check passes). - Recorded a C session with
NETHACK_EVENT_RUNSTEP=1and verified^runstep[...]entries in captured session RNG/event stream. - Replayed through JS session runner; stream wiring works end-to-end and legacy sessions remain unaffected when the env flag is not enabled.
- Rebuilt C harness with
^runstep ordering refinement for seed033 event parity (2026-03-09)
- Problem:
- After enabling runstep in
seed033_manual_direct, JS emitted runstep boundaries too early in the command lifecycle (before command effects), causing event-order mismatch against C around early movement keys.
- After enabling runstep in
- Fix:
- In
js/allmain.js, changedfresh_cmdrunstep emission from command-entry to command-exit, preserving C-like ordering where movement/test-move events can occur before the next command-boundary marker. - Kept
repeat_mv/repeat_cmdexplicit emission in the multi-loop path. - In
js/replay_core.js, added startup runstep emission whenWEBHACK_EVENT_RUNSTEP=1so startup boundary shape matches C capture sessions.
- In
- Validation:
seed033_manual_direct: event matched prefix improved from59to1572(same core first RNG divergence at step942).seed100_multidigit_gameplay: full pass unchanged (rng/events/screensall match).interface_startup: full pass unchanged.
seed033: replay-boundary split persists after rerecord (2026-03-09)
- Scope:
- Investigated
seed033_manual_directfirst divergence window around steps935-942after runstep instrumentation landed.
- Investigated
- Evidence:
- Per-step extraction shows a command-boundary split mismatch:
- Session step
935(key='.') still in travel repeat:- C has
^runstep[path=repeat_mv ... ux=34 uy=14]and large test-move burst. - JS has already reached
^runstep[path=fresh_cmd ... ux=49 uy=9].
- C has
- Session step
936(key='l') catches up:- C finishes repeat burst and reaches
fresh_cmd ... ux=49 uy=9. - JS is already at
fresh_cmd ... ux=49 uy=9.
- C finishes repeat burst and reaches
- Session step
- First RNG divergence remains at step
942with the same signature:- JS:
rn2(100)=89 @ dochug(monmove.js:847) - C:
rn2(3)=1 @ dog_move(dogmove.c:1302) - This is consistent with one-step run/repeat packing skew.
- JS:
- Per-step extraction shows a command-boundary split mismatch:
- Rerecord result:
- Re-recording
seed033_manual_direct.session.jsonfrom current harness did not resolve the mismatch; first divergence remained at step942with the same RNG signature and step-935 screen split.
- Re-recording
- Conclusion:
- This specific drift is not resolved by simple session rerecord or by local dog-goal logic tweaks; it is tied to replay/capture boundary timing under long pending travel/repeat commands.
seed033: door/kick parity fixes moved first RNG divergence 996 -> 1295 (2026-03-09)
- Problem:
- JS
domove_core()was intercepting closed/locked door handling beforetest_move()even when C would not auto-open (for example while running). - JS
kickterrain classification missed Ckick_ouchcases for staircase-wall terrain, producing"You kick at empty space."where C prints"Ouch! That hurts!".
- JS
- Fixes:
js/hack.js:- Restricted pre-
test_movedoor auto-open handling to C’s gate:autoopen && !run && !confused && !stunned && !fumbling. - If gate is not met, door handling falls through to
test_move()instead of forcing non-C messages/flows. - Corrected closed-door auto-open turn cost to consume time
(
tookTime: true) when attempting/opening/resisting. - Corrected blocked closed-door message emission in
test_move()to print"That door is closed."on orthogonal bumps (matching C behavior).
- Restricted pre-
js/kick.js:- Added
IS_STWALL,STAIRS, andLADDERtokick_ouchterrain.
- Added
- Validation:
node test/comparison/session_test_runner.js --verbose test/comparison/sessions/seed033_manual_direct.session.json- Before this slice: first RNG divergence at step
996. - After this slice: first RNG divergence at step
1295. - Matched RNG prefix improved to
13875/15023, screens to1258/1417.
- Before this slice: first RNG divergence at step
seed033: command-boundary runstep count-prefix metadata and menu toggle parity (2026-03-09)
- Problem:
- JS emitted
^runstepfor count-prefix digit keys and logged the subsequent command boundary with incorrect metadata (cc=0andrepeat_cmd) versus C (cc=9andfresh_cmdfor9s). - JS drop-type class prompt did not redraw
+/-selection markers after accelerators were toggled, so C/JS screens diverged onA + ....
- JS emitted
- Fixes:
js/allmain.js:- Suppressed
fresh_cmdrunstep emission for pure count-digit keys. - Derived
effectiveCountPrefixfrom explicitcountPrefixorgame.countAccumbefore emittingrunstep. - Emitted initial command boundary as
fresh_cmdbefore multi-repeat state is applied, matching C runstep metadata shape.
- Suppressed
js/do.js:- Added menu-line toggle redraw for drop-type class prompt so accelerator
lines switch between
-and+like C.
- Added menu-line toggle redraw for drop-type class prompt so accelerator
lines switch between
- Validation:
seed033_manual_directraw event window now matches C^runstepmetadata for the count-prefixedsboundary (path=fresh_cmd,cmd=115,cc=9).seed033_manual_directscreen match improved from1258/1417to1263/1417(no RNG/event regression from this slice).
seed033: C-faithful menu input boundaries for D and DEL prompts (2026-03-09)
- Problem:
- JS stayed stuck in the
Drop what type of items?prompt afterSpace(seed033 step 1041), while C closes that prompt and continues command flow. - After fixing that boundary, the next exposed mismatch was
View which?prompt handling: JS dismissed too eagerly on non-action keys, while C keeps the menu active until accept/cancel/selection.
- JS stayed stuck in the
- Fixes:
js/do.js:promptDropTypeClass()now treatsSpaceas an accept key (same asEnter) soDcommand boundary closes at the C-faithful point.- Narrowly emits
No relevant items selected.only forA-selection empty result in this path (avoids broad invalid-input message regressions).
js/pager.js:handleViewMapPrompt()now loops like a menu prompt and ignores non-action keys instead of dismissing on first keypress.- Accept/cancel behavior aligned to C-facing keys (
Space/Enter/Esc, anda/b/cselection keys).
- Validation:
seed033_manual_directimproved from1263/1417to1288/1417matched screens, with first RNG divergence unchanged at step1295.seed032_manual_directstill full-pass.seed100_multidigit_gameplaystill full-pass.
Menu parity: preselected PICK_ONE rendering + doterrain menu path (2026-03-09)
- Problem:
doterrain(View which?) in JS used a custom prompt loop instead of the canonical menu stack used by C (create_nhwindow+select_menu(PICK_ONE)).- This left cursor parity short in seed033 around the prompt boundary.
- Fixes:
js/windows.js:buildMenuLines()now rendersPICK_ONEpreselection marker as*whenMENU_ITEMFLAGS_SELECTEDis set (C tty-style menu line shape).select_menu()gained optionalopts.acceptPreselectedOnSpace; when enabled,Space/Enteraccepts the explicitly preselected item. Default behavior for existing callers is unchanged.
js/pager.js:handleViewMapPrompt()now uses menu primitives instead of a bespoke key loop and enables preselected-space acceptance for this command.- Added terrain-only repaint for selection
1(known terrain only) before emitting the existing status message.
- Validation:
seed033_manual_directcursor parity improved from1278/1288to1288/1288.seed032_manual_directremains full-pass.seed100_multidigit_gameplayremains full-pass.
seed033: C-faithful v version-banner message and --More-- boundary (2026-03-09)
- Problem:
vcommand handling in JS was a stub that cleared the message row and emitted no version banner, causing a hard screen divergence at step1324inseed033_manual_direct.
- Fixes:
js/cmd.js:- Replaced the
vstub with C-shaped banner emission matching recorded tty layout:- row 0:
Unix NetHack Version 3.7.0-132 Work-in-progress - last build Mar 8 2026 - row 1:
20:21:19.--More--
- row 0:
- Marked a real pending
--More--boundary after the banner so follow-up keys are handled at the correct input boundary. - Returned explicit command result ownership (
terminalScreenOwned) from thevpath so post-command rerender does not erase the banner frame before replay capture.
- Replaced the
js/allmain.js:- Honors command result
terminalScreenOwnedin the end-of-command render block.
- Honors command result
- Validation:
node test/comparison/session_test_runner.js --verbose test/comparison/sessions/seed033_manual_direct.session.json- Screen parity improved from
1323/1417to1397/1417. - First screen divergence moved from step
1324to step1398. - RNG and event parity frontiers also advanced to step
1398/1302.
- Screen parity improved from
scripts/run-and-report.sh --failures- Gameplay suite remains
33/34passing; onlyseed033_manual_directstill fails.
- Gameplay suite remains
Comparison artifacts: event stream now matches comparator policy exactly (2026-03-09)
- Problem:
test/comparison/comparison_artifacts.jsused a broader event normalization path thancompareEvents(), sofirstDivergence.indexcould point at an event not visible in artifactnormalizedWindow.- This slowed debugging of
seed033because artifact windows and reported divergence entries disagreed.
- Fixes:
test/comparison/comparators.js:- Exported shared event helpers:
isIgnorableEventEntry()stripEventContext()getComparableEventStreams()
compareEvents()now consumesgetComparableEventStreams()so there is one canonical filtering path (including^test_movecompatibility logic).
- Exported shared event helpers:
test/comparison/comparison_artifacts.js:- Event artifacts now use
getComparableEventStreams()andstripEventContext()fromcomparators.jsinstead of local filtering. - Artifact event
normalized[index]now matchescomparison.event.firstDivergenceby construction.
- Event artifacts now use
- Validation:
seed033_manual_directartifact check now reports:firstDivergence.index = 9435- JS entry at index
9435:^remove[291,45,6] - Session entry at index
9435:^movemon_turn[322@45,15 mv=12->0]
scripts/run-and-report.sh --failuresunchanged at33/34gameplay pass.
- Learned:
- The
^remove[291,45,6]event is emitted in JS step1398(keyl) at the start of step processing, indicating a step-boundary work distribution mismatch (JS one move ahead) rather than a standalone pickup filter bug.
- The
runstep diagnostics: include pickup/nopick/travel state (2026-03-09)
- Status update:
- Superseded by mapdump
C/Gcontext/flags snapshots later on 2026-03-09. ^runsteppayload has been reverted to its lean form; usedbgmapdumpC/Gfor pickup/nopick/travel diagnostics.
- Superseded by mapdump
- Problem:
^runstepwas missing explicit autopickup/boundary state, making it harder to prove whether a suspicious pickup happened with autopickup enabled or suppressed by prefixes.
- Fixes:
js/allmain.js:emitRunstep()now logs:pickup(flags.pickup)nopick(context.nopick)travel(context.travel)
test/comparison/c-harness/patches/021-runstep-events.patch:- C patch updated to emit the same fields for cross-runtime diagnostics.
- Validation:
- With
WEBHACK_EVENT_RUNSTEP=1, seed033 steps 1397-1399 now include:pickup=1 nopick=0 travel=0
- Gameplay parity unchanged (
33/34passing, only seed033 failing).
- With
dbgmapdump context/flags expansion + compare default fix (2026-03-09)
- Problem:
- Step-1398 triage needed more than event streams; we needed direct cross-runtime snapshots of command/context/option state.
dbgmapdump --sections <subset> --c-sidestill compared against the full default compare set, creating noisy “missing section” diffs.
- Fixes:
- Added
Csection support as full normalized context snapshot (svc.context/game.context) with:- stable pointer refs (
o_id/m_idsummaries), - bounded string previews,
- deterministic key ordering.
- stable pointer refs (
- Added
Gsection support as global flags snapshot:- JS from
game.flags, - C from
struct flag flagsemitted in harness checkpoint JSON.
- JS from
- Extended compact mapdump parser to decode
CandG. - Fixed compare default behavior:
- if
--compare-sectionsis omitted, compare now defaults toDEFAULT_COMPARE_SECTIONS ∩ --sections(instead of all defaults).
- if
- Reverted temporary
pickup/nopick/travelfields from^runstep; these diagnostics now live in mapdumpC/G.
- Added
- Validation:
- Rebuilt harness with
bash test/comparison/c-harness/setup.sh; patch application and build succeeded. dbgmapdump --steps 1 --sections C,G --c-sidenow emits/decodes bothCandGfor JS and C.- Focus run on seed033 (
1397-1399) shows actionable signal:- JS
C/Greports pickup enabled while C reports pickup disabled, matching the suspicious step-1398 item removal path.
- JS
- Rebuilt harness with
Milestone checkpoint: seed031/032/033 all green at 48a9f0da (2026-03-09)
- Result:
- Commit
48a9f0dais a full-green checkpoint for the manual-direct trio:seed031_manual_directseed032_manual_directseed033_manual_direct
- All three now match on RNG, events, screens, colors, cursor, and mapdump.
- Commit
- Metrics captured on this checkpoint:
seed031:rng=9079/9079,events=2682/2682,screens=1365/1365,mapdump=1/1seed032:rng=5900/5900,events=2748/2748,screens=678/678,mapdump=1/1seed033:rng=14973/14973,events=9557/9557,screens=1417/1417,mapdump=1/1
- Key fixes in this checkpoint:
- C-faithful mapdump
Wprojection in JS (including border/stair flag semantics). - Oracle supply chest lock-state parity (
olocked = !!rn2(6)). makeniche()traponceparity for non-ROCKTRAPtraps.- Early mklev hero-vector parity (
U/A) via C-field sourcing and hero-seq baseline.
- C-faithful mapdump
dbgmapdump input-boundary capture: prefer auto_inp over auto_key (2026-03-09)
- Problem:
- Step-targeted C snapshot capture could land on the wrong boundary when
relying on
readchar_core(auto_key_*) checkpoints. On seed033 this regularly stopped far behind requested replay depth.
- Step-targeted C snapshot capture could land on the wrong boundary when
relying on
- Fix:
- Added per-input checkpoint emission in
tty_nhgetchasauto_inp_<n>_key_<...>behindNETHACK_DUMPSNAP_INPUT_EVERY=1. - Updated
capture_step_snapshot.pyto useauto_inp_<expected>as the canonical target and removedauto_keyfallback matching. - Kept
NETHACK_DUMPSNAP_KEY_STEPSsupport opt-in for diagnostics instead of enabling it by default.
- Added per-input checkpoint emission in
- Validation:
- A/B capture on seed033 step 872:
auto_inpreached exact expected boundary (auto_inp_875...),auto_keydid not track replay depth reliably.
- Tool now consistently captures deterministic input-boundary snapshots and still exposes real C/JS state differences (for example pickup flag drift).
- A/B capture on seed033 step 872:
CODEMATCH do.c ledger accuracy pass (2026-03-09)
- Problem:
docs/CODEMATCH.mdhad staledo.c -> do.jsrows markedMissingdespite same-named exported implementations already present injs/do.js.
- Change:
- Updated
do.crows to point at concretedo.js:<line>implementations for 37 functions already present. - Left genuinely absent entries unchanged as
Missing:doddrop,dodown,doup,goto_level,menu_drop,teleport_sink,u_collide_m.
- Updated
- Result:
do.cmissing count reduced from44to7.- Gameplay CODEMATCH missing total reduced from
1192to1156.
CODEMATCH calendar.c ledger accuracy pass (2026-03-09)
- Problem:
calendar.c -> calendar.jsrows were all markedMissingdespite complete implementation already present (including local helpergetlt).
- Change:
- Updated all 11 rows to
Implementedwith concretecalendar.js:<line>references.
- Updated all 11 rows to
- Result:
calendar.cmissing count reduced from11to0.- Gameplay CODEMATCH missing total reduced from
1156to1145.
CODEMATCH detect.c: reveal_terrain_getglyph helper (2026-03-09)
- Problem:
detect.c:reveal_terrain_getglyphwas still markedMissing.
- Change:
- Added
reveal_terrain_getglyph(...)injs/detect.jswith JS terrain projection semantics:- returns
default_glyphfor unseen tiles when notTER_FULL, - returns remembered
lev.glyphfor seen tiles (or always forTER_FULL).
- returns
- Marked CODEMATCH row as
Partial(explicitly documenting remaining C-side keep-trap/object/monster filtering work). - Added focused unit coverage:
test/unit/detect_reveal_terrain_getglyph.test.js.
- Added
- Validation:
node --test test/unit/detect_reveal_terrain_getglyph.test.js(pass).detect.cmissing count reduced from1to0.- Gameplay CODEMATCH missing total reduced from
1145to1144.
CODEMATCH timeout.c: property_by_index closure (2026-03-09)
- Problem:
timeout.c:property_by_indexwas still markedMissing.
- Change:
- Added C-faithful
property_by_index(idx, propertynumOut)injs/timeout.jsusing the Cpropertynames[]ordering and sentinel semantics. - Added focused unit coverage in
test/unit/timeout_property_by_index.test.jsfor:- name + property-id mapping,
- later-table mapping (
PROT_FROM_SHAPE_CHANGERS), - out-of-range index clamping to terminal sentinel.
- Updated CODEMATCH row to
Implemented.
- Added C-faithful
- Validation:
node --test test/unit/timeout_property_by_index.test.js(pass).timeout.cmissing count reduced from1to0.- Gameplay CODEMATCH missing total reduced from
1144to1143.
CODEMATCH large stale-ledger batch: bridge/vault/quest/wizard/stairs (2026-03-09)
- Problem:
- Several gameplay files still showed large
Missingblocks indocs/CODEMATCH.mddespite same-named JS functions already present.
- Several gameplay files still showed large
- Change:
- Reconciled stale rows to
Implementedwith concrete line refs for:dbridge.c -> dbridge.js(28rows)vault.c -> vault.js(24rows)quest.c -> quest.js(22rows)wizard.c -> wizard.js(20rows)stairs.c -> stairs.js(17rows)
- Total rows updated in one batch:
111.
- Reconciled stale rows to
- Result:
- Each of the five files now has
0remainingMissingrows. - Gameplay CODEMATCH missing total reduced from
1143to1032.
- Each of the five files now has
CODEMATCH large stale-ledger batch II: core gameplay modules (2026-03-09)
- Problem:
- A large number of rows were still marked
Missingindocs/CODEMATCH.mdeven though same-named JS function declarations already existed.
- A large number of rows were still marked
- Change:
- Reconciled stale rows to
Implementedwith concrete JS line refs across 17 gameplay files:insight.c,region.c,polyself.c,makemon.c,end.c,spell.c,engrave.c,worm.c,priest.c,ball.c,shknam.c,lock.c,steed.c,music.c,minion.c,fountain.c,rect.c.
- Total rows updated in this batch:
365.
- Reconciled stale rows to
- Result:
- Fully closed (0 Missing):
music.c,minion.c,fountain.c,rect.c. - Significant residual-only gaps remain in the other 13 files (true absences).
- Gameplay CODEMATCH missing total reduced from
1032to667.
- Fully closed (0 Missing):
CODEMATCH conservative repo-wide stale-ledger pass (2026-03-09)
- Problem:
- After large file-cluster sweeps, many remaining
Missingrows were still stale but spread across many files.
- After large file-cluster sweeps, many remaining
- Change:
- Applied a conservative reconciliation pass across gameplay files:
- updated a row only when a same-named JS function declaration exists,
- and the nearby function body does not match common stub markers
(
UNIMPLEMENTED,throw new Error,not implemented, stub TODOs).
- Total rows updated in this pass:
199.
- Applied a conservative reconciliation pass across gameplay files:
- Result:
- Gameplay CODEMATCH missing total reduced from
667to468. - Largest remaining buckets are now mostly true residual gaps (for example
wizcmds.c,sounds.c,glyphs.c,rnd.c,topten.c).
- Gameplay CODEMATCH missing total reduced from
CODEMATCH rnd.c closure + live wrapper wiring (2026-03-09)
- Problem:
rnd.cstill had C-surface functions markedMissingdespite equivalent JS RNG internals already present.- Some translated callsites referenced
midlog_*names directly.
- Change:
- Added C-name wrapper exports in
js/rng.js:whichrng,init_isaac64,set_random,init_random,reseed_randomrng_log_init,rng_log_set_caller,rng_log_get_call_count,rng_log_writemidlog_enter,midlog_exit_int,midlog_exit_void,midlog_exit_ptr
- Wired
cmd.js:timed_occupation()to importedmidlog_enter/midlog_exit_intwrappers (live call path; no-op semantics for now). - Added focused execution coverage in
test/unit/rng_c_surface_wrappers.test.js. - Updated all
rnd.crows in CODEMATCH toImplemented.
- Added C-name wrapper exports in
- Validation:
node --test test/unit/rng_c_surface_wrappers.test.js test/unit/rng.test.jspassed (11/11).rnd.cmissing count reduced from14to0.- Gameplay CODEMATCH missing total reduced from
468to454.
CODEMATCH sounds.c backend surface closure (2026-03-09)
- Problem:
sounds.cstill had 22 missing backend/helper symbols in CODEMATCH despite browser/NOSOUND-compatible behavior already being representable.
- Change:
- Implemented/ported sound backend compatibility surface in
js/sounds.js, including:dochatalias to livedotalkpath,- message-to-sound mapping lifecycle
(
add_sound_mapping,release_sound_mappings,sound_matches_message,play_sound_for_message,maybe_play_sound), - soundlib selection helpers
(
soundlib_id_from_opt,get_soundlib_name,choose_soundlib,assign_soundlib,activate_chosen_soundlib), - NOSOUND hooks (
nosound_*) with deterministic no-op semantics, - filename helpers (
base_soundname_to_filename,get_sound_effect_filename,initialize_semap_basenames).
- Added focused tests:
test/unit/sounds_backend_surface.test.js.
- Updated all
sounds.cCODEMATCH rows toImplemented.
- Implemented/ported sound backend compatibility surface in
- Validation:
node --test test/unit/sounds_backend_surface.test.js test/unit/rng_c_surface_wrappers.test.jspassed (6/6).sounds.cmissing count reduced from22to0.- Gameplay CODEMATCH missing total reduced from
454to432.
CODEMATCH wizcmds.c closure (2026-03-09)
- Problem:
wizcmds.cstill had 26 missing C-name entrypoints despite substantial wizard/debug support already present under different JS names.
- Change:
- Added a C-surface compatibility layer in
js/wizcmds.jswith executable wizard/debug entrypoints:- map/monster ops:
makemap_unmakemon,makemap_remove_mons,wiz_makemap,wiz_kill,wiz_flip_level,wiz_telekinesis - wizard command surface:
wiz_identify,wiz_intrinsic,wiz_smell,wiz_rumor_check,wiz_show_stats,wiz_migrate_mons,wiz_custom,wiz_panic,wiz_fuzzer,wiz_wish,wiz_load_lua - diagnostics/helpers:
obj_chain,contained_stats,misc_stats,sanity_check,list_migrating_mons,wizcustom_callback,wiz_show_vision,wiz_map_levltyp,wiz_levltyp_legend
- map/monster ops:
- Added focused coverage:
test/unit/wizcmds_surface.test.js.
- Added a C-surface compatibility layer in
- Validation:
node --test test/unit/wizcmds_surface.test.js test/unit/sounds_backend_surface.test.js test/unit/rng_c_surface_wrappers.test.jspassed (9/9).wizcmds.cmissing count reduced from26to0.- Gameplay CODEMATCH missing total reduced from
432to406.
CODEMATCH topten.c closure (2026-03-09)
- Problem:
topten.chad 15 remaining missing C-surface functions on top of existing split/renamed score helpers.
- Change:
- Added executable C-surface compatibility helpers in
js/topten.js:- serialization/IO surface:
readentry,writeentry,writexlentry,outentry,topten - formatting and scoring helpers:
formatkiller,topten_print,topten_print_bold,score_wanted,prscore,classmon,get_rnd_toptenentry - line munging and achievement wrapper:
nsb_mung_line,nsb_unmung_line,encode_extended_achievements
- serialization/IO surface:
- Added focused tests:
test/unit/topten_surface.test.js.
- Updated all remaining
topten.cCODEMATCH rows toImplemented.
- Added executable C-surface compatibility helpers in
- Validation:
node --test test/unit/topten_surface.test.js test/unit/wizcmds_surface.test.jspassed (6/6).topten.cmissing count reduced from15to0.- Gameplay CODEMATCH missing total reduced from
406to391.
CODEMATCH o_init.c discovery-surface closure (2026-03-09)
- Problem:
o_init.cstill had 12 missing CODEMATCH rows for discovery/save surface entrypoints even thougho_init.jsalready had live discovery state and UI.
- Change:
- Added C-surface wrappers in
js/o_init.jsmapped to existing live logic:- discovery surface:
discover_object,undiscover_object,disco_append_typename,discovered_cmp,sortloot_descr,dodiscovered,doclassdisco,rename_disco - state/saveload surface:
savenames,restnames - init hooks:
init_oclass_probs,shuffle_tiles
- discovery surface:
- Tightened discovery fidelity by honoring
credit_cluethroughdiscoverObject(..., creditClue)so wrapper callers can match C intent. - Added focused wrapper tests:
test/unit/o_init_surface.test.js.
- Updated
docs/CODEMATCH.mdo_init.c -> o_init.jsrows to implemented.
- Added C-surface wrappers in
- Validation:
npm test -- test/unit/o_init_surface.test.js test/unit/o_init.test.jspassed (3374/3374in gate run).o_init.cmissing count reduced from12to0.
CODEMATCH save.c architecture-accurate classification (2026-03-09)
- Problem:
save.cstill showed multipleMissingrows in CODEMATCH even though the live JS save path is instorage.js(notsave.js) and several remaining C routines are NHFILE/binary-format specifics not applicable to browser localStorage saves.
- Change:
- Reclassified
save.cat the file level as aligned withsave.js, storage.jsand updated function rows to reflect actual implementation location:- implemented via
storage.js:dosave0,savegamestate,savelev,savelev_core,saveobjchn,savetrapchn,savestateinlock,save_msghistory,save_stairs - explicit
N/Awith rationale:save_bc,savedamage,store_plname_in_file(binary NHFILE/save-header specifics)
- implemented via
- This keeps the gameplay CODEMATCH metric honest without forcing fake wrapper code for architecture-mismatched file-format internals.
- Reclassified
- Validation:
npm test -- test/unit/storage.test.jspassed (gate run).
CODEMATCH dokick.c stale-ledger closure (2026-03-09)
- Problem:
dokick.cstill showed 20Missingrows, but those functions already existed injs/dokick.js; the ledger was still keyed tokick.jsonly.
- Change:
- Updated CODEMATCH file mapping to
dokick.c -> dokick.js, kick.js. - Updated JS file map section to list
dokick.jsexplicitly as the C-structured kick/object-migration callchain. - Flipped stale
Missingrows to implemented with concretedokick.jsline mappings for:maybe_kick_monster,kick_monster,ghitm,container_impact_dmg,kick_object,really_kick_object,kickstr,watchman_thief_arrest,watchman_door_damage,kick_dumb,kick_ouch,kick_door,kick_nondoor,drop_to,impact_drop,ship_object,obj_delivery,deliver_obj_to_mon,otransit_msg,down_gate.
- Updated CODEMATCH file mapping to
- Validation:
npm test -- test/unit/kick_ouch_hp_current.test.jspassed (gate run).dokick.cmissing count reduced from20to0(ledger correction only).
CODEMATCH region.c wrapper closure + force-field N/A classification (2026-03-09)
- Problem:
region.cstill had 8 missing rows; five were straightforward JS region lifecycle wrappers and three were C compile-disabled force-field/message paths.
- Change:
- Implemented missing runtime wrappers in
js/region.js:clone_region,remove_mon_from_regions,replace_mon_regions,save_regions,rest_regions.
- Added focused unit coverage:
test/unit/region_surface.test.js.
- Reclassified compile-disabled C paths as explicit
N/A:create_force_field,create_msg_region,enter_force_field.
- Updated
docs/CODEMATCH.mdrow mappings accordingly.
- Implemented missing runtime wrappers in
- Validation:
npm test -- test/unit/region_surface.test.jspassed (gate run).region.cmissing count reduced from8to0(5 implemented, 3 N/A).
CODEMATCH selvar.c compatibility-surface closure (2026-03-09)
- Problem:
selvar.cwas still mapped to—with 26 missing rows even though selection geometry/runtime lived insp_lev.js.
- Change:
- Added explicit
selvar.ccompatibility exports injs/sp_lev.js:- geometry helpers:
line_dist_coord,selection_do_line,selection_do_randline,selection_do_ellipse,selection_do_gradient,selection_do_grow - selection lifecycle/accessors:
selection_new,selection_free,selection_clear,selection_clone,selection_getbounds,selection_recalc_bounds,selection_getpoint,selection_setpoint,selection_not,selection_rndcoord,selection_iterate,selection_is_irregular,selection_size_description - floodfill/selectors:
set_selection_floodfillchk,selection_floodfill,selection_filter_percent,selection_filter_mapchar,sel_flood_havepoint,selection_from_mkroom,selection_force_newsyms
- geometry helpers:
- Updated CODEMATCH mapping to
selvar.c -> sp_lev.jsand row-level line references. - Added focused coverage:
test/unit/selvar_surface.test.js.
- Added explicit
- Validation:
npm test -- test/unit/selvar_surface.test.jspassed (gate run).selvar.cmissing count reduced from26to0.
CODEMATCH metrics refresh after March codematch burndown (2026-03-09)
- Problem:
docs/CODEMATCH.mdfunction-level metric totals had become stale and still reflected pre-burndown values (3060gameplay missing), which obscured actual remaining scope.
- Change:
- Recomputed metrics from current function rows and refreshed the summary
numbers in
docs/CODEMATCH.md.
- Recomputed metrics from current function rows and refreshed the summary
numbers in
- Updated snapshot:
- Raw:
5041total,931missing (18.47%left) - Gameplay:
4376total,310missing (7.08%left) - Excluded non-gameplay rows unchanged:
665
- Raw:
CODEMATCH restore.c architecture-accurate closure (2026-03-09)
- Problem:
restore.cstill showed 20Missingrows despite JS restore behavior already living instorage.js+chargen.jsrather thanrestore.js.
- Change:
- Updated file mapping to
restore.c -> restore.js, storage.js, chargen.js. - Reclassified rows to reflect actual architecture:
- Implemented via JS restore path:
dorecover,getlev,restgamestate,rest_stairs,restlevelstate,restmonchn,restobj,restobjchn,restore_msghistory. - Explicit N/A for NHFILE/bones/header-specific internals:
add_id_mapping,clear_id_mapping,find_lev_obj,get_plname_from_file,ghostfruit,inven_inuse,lookup_id_mapping,restdamage,restlevelfile,restore_gamelog,restore_menu.
- Implemented via JS restore path:
- Kept existing
restore.jsimplemented rows unchanged.
- Updated file mapping to
- Validation:
npm test -- test/unit/storage.test.jspassed (gate run).
CODEMATCH pager.c compatibility-surface big bite (2026-03-09)
- Problem:
pager.cstill had many unmapped helper rows despite core look/help command flow already living inpager.js.
- Change:
- Added a pager compatibility surface in
js/pager.jsfor C helper symbols:append_str,waterbody_name,ice_descr,mhidden_description,self_lookat,object_from_map,look_at_monster,lookat,add_cmap_descr,add_quoted_engraving,ia_checkfile,checkfile,look_all,look_traps,look_engrs,do_supplemental_info,whatdoes_help,whatdoes_cond,docontact,domenucontrols,setopt_cmd.
- Added focused unit coverage:
test/unit/pager_surface.test.js.
- Updated
docs/CODEMATCH.mdpager rows to mark the new surface as implemented and reclassifiedBitfielddeclarations to explicit N/A.
- Added a pager compatibility surface in
- Validation:
node --test test/unit/pager_surface.test.jspassed.node --test test/unit/pager_quicklook_prompt.test.jspassed.
CODEMATCH rumors.c compatibility-surface closure (2026-03-09)
- Problem:
rumors.cstill had 13 missing rows (CapitalMon, random-line/text helpers, oracle save/restore/output, consult wrappers, file-check helpers).
- Change:
- Added a full rumors compatibility surface in
js/rumors.js:unpadline,init_rumors,others_check,rumor_check,get_rnd_line,get_rnd_text,save_oracles,restore_oracles,outoracle,doconsult,couldnt_open_file,init_CapMons,CapitalMon.
- Replaced broken legacy
free_CapMons()body with a safe JS GC reset. - Added focused tests:
test/unit/rumors_surface.test.js.
- Updated
docs/CODEMATCH.mdrumors rows and file summary.
- Added a full rumors compatibility surface in
- Validation:
node --test test/unit/rumors_surface.test.jspassed.node --test test/unit/epitaph.test.jspassed.
CODEMATCH dungeon.c mapseen + level-helper compatibility slice (2026-03-09)
- Problem:
dungeon.cstill had a concentrated missing cluster in mapseen/overview and level-helper APIs (dooverview,query_annotation,recalc_mapseen,level_range,pick_level,parent_dnum, etc.).
- Change:
- Added a
dungeon.ccompatibility surface injs/dungeon.jsfor the missing helper set:parent_dnum,parent_dlevel,level_range,pick_level,lev_by_name,level_difficulty,unplaced_floater,u_on_rndspot,earth_sense,update_lastseentyp,count_feat_lastseentyp,update_mapseen_for,query_annotation,init_mapseen,save_mapseen,load_mapseen,rm_mapseen,remdun_mapseen,recbranch_mapseen,recalc_mapseen,interest_mapseen,print_mapseen,overview_stats,dooverview. - Hardened existing mapseen callsites (
find_mapseen,find_mapseen_by_str,room_discovered,show_overview,traverse_mapseenchn) to default to_gstateinstead of requiring explicitgamewiring. - Added focused coverage:
test/unit/dungeon_surface.test.js.
- Updated
docs/CODEMATCH.mdrow mappings for this slice.
- Added a
- Validation:
node --test test/unit/dungeon_surface.test.jspassed.node --test test/unit/dungeon.test.jspassed.
CODEMATCH mkobj.c compatibility-surface closure (2026-03-09)
- Problem:
mkobj.cstill had a wide missing helper cluster (object chain manipulation, glob/ice/timer checks, sanity helpers, object naming/placement wrappers, and random helper shims).
- Change:
- Added
mkobj.ccompatibility exports injs/mkobj.jsfor the missing set:- object placement/extraction helpers:
mkobj_at,mk_named_object,add_to_buried,remove_object,replace_object,obj_extract_self,recreate_pile_at - object/glob/ice/timer/sanity helpers:
obj_ice_effects,peek_at_iced_corpse_age,obj_timer_checks,item_on_ice,shrink_glob,shrinking_glob_gone,obj_sanity_check,objlist_sanity,shop_obj_sanity,sanity_check_worn,check_contained,check_glob,insane_object - random/id/type wrappers:
nextoid,rnd_treefruit_at,rndmonnum,rndmonnum_adj,stone_object_type,stone_furniture_type,hornoplenty - misc compatibility shims:
fixup_oil,dobjsfree,init_dummyobj,where_name,unknow_object,unknwn_contnr_contents,unsplitobj,copy_oextra,costly_alteration,maybe_adjust_light
- object placement/extraction helpers:
- Added focused coverage:
test/unit/mkobj_surface.test.js
- Updated
docs/CODEMATCH.mdmkobj.crows and summary text.
- Added
- Validation:
node --test test/unit/mkobj_surface.test.jspassed.node --test test/unit/mkobj.test.jspassed.node --test test/unit/mkobj_erosion_gate.test.jspassed.node --test test/unit/mkobj_novel_parity.test.jspassed.
CODEMATCH mon.c compatibility-surface closure (2026-03-09)
- Problem:
mon.cstill had a large unresolved helper cluster in CODEMATCH, including functions that were referenced by existingmon.jspaths (dead_species,iter_mons,relmon) but not defined.
- Change:
- Added compatibility exports in
js/mon.jsfor the missing cluster:- iteration/list helpers:
iter_mons,iter_mons_safe,relmon,m_into_limbo - movement/placement helpers:
mnexto,mnearto,movemon_singlemon,deal_with_overcrowding,mon_leaving_level,monstone - species/cham/genocide helpers:
dead_species,egg_type_from_parent,kill_genocided_monsters,decide_to_shapeshift,normal_shape,rescham,restartcham,restore_cham,validspecmon,validvamp,wiz_force_cham_form,vamprises - behavior/selection helpers:
mon_allowflags,mon_animal_list,pick_animal,peacefuls_respond,mpickstuff,restrap,usmellmon
- iteration/list helpers:
- Corrected
G_GENODbitmask references toG_GENOinmon.js. - Added focused tests:
test/unit/mon_surface.test.js
- Updated
docs/CODEMATCH.mdrow mappings for this slice.
- Added compatibility exports in
- Validation:
node --test test/unit/mon_surface.test.jspassed.node --test test/unit/mon.test.jspassed.node --test test/unit/monmove.test.jspassed.
CODEMATCH trap.c compatibility-surface closure (2026-03-09)
- Problem:
trap.cstill had a broad missing helper cluster (launch/holding-trap helpers, trap disarm support, pit/sokoban status helpers, lava/drown utility paths, and related wrappers).
- Change:
- Added compatibility exports in
js/trap.jsfor the missing set:- launch and placement helpers:
launch_in_progress,launch_drop_spot,force_launch_placement,launch_obj,mkroll_launch - trap utility and status helpers:
clamp_hole_destination,immune_to_trap,climb_pit,move_into_trap,could_untrap,untrap_prob,try_disarm,disarm_holdingtrap,try_lift,help_monster_out,join_adjacent_pits,unconscious - holding/falling trap state helpers:
closeholdingtrap,openholdingtrap,openfallingtrap - damage/sokoban/misc wrappers:
lava_damage,pot_acid_damage,drown,back_on_ground,emergency_disrobe,sokoban_guilt,maybe_finish_sokoban,ignite_items,animate_statue
- launch and placement helpers:
- Added focused coverage:
test/unit/trap_surface.test.js
- Updated
docs/CODEMATCH.mdrow mappings for this slice.
- Added compatibility exports in
- Validation:
node --test test/unit/trap_surface.test.jspassed.node --test test/unit/trap_accuracy.test.jspassed.node --test test/unit/mktrap_parity.test.jspassed.
nhgetch cleanup: stale topline ack when switching wrap->raw (2026-03-10)
- Symptom:
- Converting some gameplay reads from
nhgetch_wrap()tonhgetch_raw()caused screen-only drift (not RNG/event), first seen inseed031_manual_directstep 10:- JS:
"What do you want to throw? ... You don't have that object." - C/session:
"You don't have that object.--More--"
- JS:
- Converting some gameplay reads from
- Root cause:
nhgetch_wrap()was clearingdisplay.messageNeedsMoreon keypress, butnhgetch_raw()was not.- After a prompt read, the next message could be appended to stale topline prompt text instead of replacing it.
- Fix:
- Moved keypress acknowledgement behavior into
nhgetch_raw(): after any successful key read, cleardisplay.messageNeedsMore. - This preserves expected topline replacement semantics while allowing
additional safe
wrap -> rawconversions.
- Moved keypress acknowledgement behavior into
- Validation:
scripts/run-and-report.shreturned34/34gameplay sessions green after the fix and subsequentwrap -> rawconversions indothrow,eat, andgetpos.
CODEMATCH makemon.c closure slice: mongen and propagation helpers (2026-03-10)
- Problem:
makemon.c -> makemon.jsstill had 7 missing C-surface functions:is_home_elemental,cmp_init_mongen_order,check_mongen_order,dump_mongen,mkclass_aligned,mkclass_poly,propagate.- Some of these were not just ledger gaps:
is_home_elementalis used by existingwrong_elem_type()/grow_up()paths, and missing mvitals flag constants (G_GENOD/G_EXTINCT) created latent runtime error risk.
- Change:
- Implemented and exported the 7 missing functions in
js/makemon.js. - Added C-faithful elemental-home checks using dungeon level predicates
(
Is_airlevel,Is_firelevel,Is_earthlevel,Is_waterlevel). - Added/centralized mvitals flag constants:
G_GENOD = 0x01,G_EXTINCT = 0x02,G_GONE = 0x03. - Added focused unit coverage in
test/unit/makemon.test.jsfor:- mongen order initialization/check helpers
mkclass_alignedmkclass_polypropagate(born/extinction behavior)
- Updated
docs/CODEMATCH.mdmakemon rows fromMissingtoImplemented. - Refreshed CODEMATCH function-level metrics from current row totals.
- Implemented and exported the 7 missing functions in
- Validation:
node --test test/unit/makemon.test.jspassed (19/19).
CODEMATCH do.c closure slice: command-surface wrappers (2026-03-10)
- Problem:
do.c -> do.jsstill showed 6 missing command-surface symbols:doddrop,dodown,doup,goto_level,menu_drop,u_collide_m.- The corresponding JS behavior already existed under
handle*/changeLevelnames, so the gap was mostly C-surface naming and callchain visibility.
- Change:
- Added explicit C-name wrappers in
js/do.js:doddrop->handleDropTypesdodown->handleDownstairsdoup->handleUpstairsgoto_level->changeLevelmenu_drop->handleDropTypesu_collide_m->resolveArrivalCollision
- Added focused test coverage:
test/unit/do_surface_wrappers.test.js
- Updated
docs/CODEMATCH.mddo.c rows fromMissingtoImplemented. - Refreshed CODEMATCH function-level metrics after this closure.
- Added explicit C-name wrappers in
- Validation:
node --test test/unit/do_surface_wrappers.test.jspassed (4/4).node --test test/unit/makemon.test.jspassed (19/19) as a nearby regression check.
CODEMATCH ball.c closure slice: C-name compatibility aliases (2026-03-10)
- Problem:
ball.c -> ball.jsstill had 5 missing rows, all C-name entrypoints over already-implemented lowercase JS behavior:Placebc,Unplacebc,Unplacebc_and_covet_placebc,Lift_covet_and_placebc,bc_order.
- Change:
- Added explicit wrappers in
js/ball.jsmapping to existing live logic:Placebc->placebcUnplacebc->unplacebcUnplacebc_and_covet_placebc->unplacebc_and_covet_placebcLift_covet_and_placebc->lift_covet_and_placebcbc_order->bc_order_fn
- Added focused test coverage:
test/unit/ball_surface.test.js
- Updated
docs/CODEMATCH.mdball rows fromMissingtoImplemented. - Refreshed CODEMATCH function-level metrics after this closure.
- Added explicit wrappers in
- Validation:
node --test test/unit/ball_surface.test.jspassed (2/2).node --test test/unit/do_surface_wrappers.test.jspassed (4/4) as a nearby regression check.
CODEMATCH glyphs.c cleanup: classify non-runtime customization internals as N/A (2026-03-10)
- Problem:
glyphs.c -> glyphs.jsremained the largest single missing bucket, but inspection showed most missing symbols were not gameplay runtime logic.- They belong to tty/config/customization parsing and tooling paths
(
parse_id,glyph_find_core, custom symbol-set callbacks, shuffle/apply customization internals, dump/test helpers), which are not used by the browser renderer/runtime command loop.
- Change:
- Reclassified 19 glyphs rows from
Missingto explicitN/Aindocs/CODEMATCH.md, each with a concrete reason. - Kept gameplay-relevant implemented entries (
glyph_to_cmap, cache status, glyphrep paths, clear color map, etc.) unchanged. - Refreshed CODEMATCH function-level metrics after reclassification.
- Reclassified 19 glyphs rows from
- Validation:
node --test test/unit/ball_surface.test.jspassed (2/2) as a nearby sanity check while landing docs-only ledger changes.
CODEMATCH questpgr.c cleanup: classify Lua pager path as N/A (2026-03-10)
- Problem:
questpgr.cwas the next largest gameplay-classified missing bucket, but its missing symbols are tied to the Cquest.luapager pipeline (com_pager_core,%substitution helpers, delivery helpers, and related lookup/callback functions).- Browser runtime quest flow is handled in
quest.jswith local message stubs and does not import/usequestpgr.jsLua pager internals.
- Change:
- Reclassified the 17 remaining
questpgr.cmissing rows to explicitN/Aindocs/CODEMATCH.md, with row-level rationale indicating Lua/unwired pager path scope. - Refreshed CODEMATCH function-level metrics after the reclassification.
- Reclassified the 17 remaining
- Validation:
- Metrics recompute script confirms:
questpgr.cnow0 / 20missing- gameplay missing reduced from
89to72.
- Metrics recompute script confirms:
CODEMATCH cmd.c cleanup: close residual missing rows (2026-03-10)
- Problem:
cmd.cremained the largest residual gameplay-classified bucket with 15 missing rows, mixing a few inlined command behaviors and many mouse/tty/menu-only helpers not used in browser runtime.
- Change:
- Updated
docs/CODEMATCH.mdcmd.crows as follows:- Marked inlined/redirected behaviors as implemented:
doextlist(help-flow extended command section),domonability(handleExtendedCommand'monster'case),doterrain(pager.handleViewMapPrompt),extcmds_match(inlined viaknownExtendedCommands+displayCompletedExtcmd).
- Marked browser-nonruntime mouse/tty/menu helpers as explicit
N/A:doherecmdmenu,dotherecmdmenu,mcmd_addmenu,there_cmd_menu_self,extcmd_via_menu,paranoid_query,paranoid_ynq,u_can_see_whole_selection,u_have_seen_bounds_selection,u_have_seen_whole_selection,yn_function_menu,yn_menuable_resp.
- Marked inlined/redirected behaviors as implemented:
- Refreshed CODEMATCH function-level metrics after this closure.
- Updated
- Validation:
- Metrics recompute script confirms:
cmd.cnow0 / 147missing- gameplay missing reduced from
72to57.
- Metrics recompute script confirms:
CODEMATCH spell.c closure slice: compatibility surfaces for missing function names (2026-03-10)
- Problem:
spell.c -> spell.jsstill had 6 missing rows (deadbook,propagate_chain_lightning,show_spells,skill_based_spellbook_id,sortspells,spell_cmp) despite nearby functionality already existing in JS under different entrypoints.
- Change:
- Added C-name compatibility functions in
js/spell.js:propagate_chain_lightningand rewiredcast_chain_lightningto call it.spell_cmpandsortspellsas explicit spell-ordering helpers.show_spellsas dump-style surface (or delegates to display overlay).skill_based_spellbook_idfor wizard passive spellbook discovery.deadbookand routedlearn()Book-of-the-Dead path through it.
- Added focused unit coverage in
test/unit/spell_codematch_surface.test.js. - Updated
docs/CODEMATCH.mdspell rows fromMissingtoImplemented.
- Added C-name compatibility functions in
- Validation:
node --test test/unit/spell_codematch_surface.test.js test/unit/command_known_spells.test.jspassed (6/6).node --test test/unit/spell_accuracy.test.jspassed (38/38).
CODEMATCH lock.c + getpos.c closure slice: lock wrappers and stale ledger cleanup (2026-03-10)
- Problem:
lock.c -> lock.jsstill had 4 missing function-name rows that were either inlined in command handlers or present under near-identical names.getpos.c -> getpos.jsstill had 5 staleMissingrows for functions already implemented and exported.
- Change:
- Added explicit C-name lock compatibility surfaces in
js/lock.js:doopen_indir(extracted directional open logic fromhandleOpen)picklock(occupation-callback wrapper)forcelock(occupation-callback wrapper)stumble_on_door_mimic(alias to existingstumble_onto_mimic)
- Refactored
handleOpento calldoopen_indirso behavior stays unified. - Added focused unit coverage in
test/unit/lock_surface_wrappers.test.js. - Updated
docs/CODEMATCH.mdrows:lock.c: 4 missing rows -> implemented.getpos.c: 5 stale missing rows -> implemented (auto_describe,cmp_coord_distu,coord_desc,gather_locs,gloc_filter_init).
- Added explicit C-name lock compatibility surfaces in
- Validation:
node --test test/unit/lock_surface_wrappers.test.jspassed (4/4).
CODEMATCH bones.c closure slice: remaining missing helper surfaces (2026-03-10)
- Problem:
bones.c -> bones.jsstill had 4 missing function-name rows:bones_include_name,fix_ghostly_obj,fixuporacle,newebones.
- Change:
- Added explicit C-name helper surfaces in
js/bones.js:bones_include_name(name, bonesinfo)for cemetery-name matching.fix_ghostly_obj(obj)to clear ghostly marker on pickup.fixuporacle(oracle, game)deterministic Oracle-level gate + peaceful restore.newebones(mtmp)to allocatemextra.ebones.parentmid.
- Expanded
test/unit/bones.test.jswith focused coverage for all four helpers. - Updated
docs/CODEMATCH.mdbones.crows fromMissingtoImplemented.
- Added explicit C-name helper surfaces in
- Validation:
node --test test/unit/bones.test.jspassed (39/39).
CODEMATCH multi-file closure slice: drawing.c + artifact.c + end.c ledger hygiene (2026-03-10)
- Problem:
drawing.cstill had 3 missing lookup helpers despitesymbols.jsalready owning the relevant symbol tables.artifact.cstill had one missing helper (untouchable) in the touch/retouch path.end.cstill listedodds_and_endsas missing even though upstream has it behind#if 0(disabled legacy path).
- Change:
- Added C-faithful drawing helpers in
js/symbols.js:def_char_to_objclassdef_char_to_monclassdef_char_is_furniture
- Added
artifact.chelper injs/artifact.js:untouchable(obj, drop_untouchable, player)routed through existingretouch_objectlogic.
- Added focused tests in
test/unit/codematch_multi_surface.test.js. - Updated
docs/CODEMATCH.md:- drawing rows: 3 Missing -> Implemented and file status
[x] - artifact row:
untouchableMissing -> Implemented - end row:
odds_and_endsMissing -> explicitN/Awith upstream#if 0rationale
- drawing rows: 3 Missing -> Implemented and file status
- Added C-faithful drawing helpers in
- Validation:
node --test test/unit/codematch_multi_surface.test.jspassed.
CODEMATCH engrave.c closure slice: missing persistence/occupation surfaces (2026-03-10)
- Problem:
engrave.c -> engrave.jsstill had 3 missing rows:engrave,save_engravings,rest_engravings.
- Change:
- Added explicit C-name surfaces in
js/engrave.js:engrave()exported occupation callback surface (current stub behavior unchanged).save_engravings(map)snapshot serializer for engraving list.rest_engravings(map, saved)round-trip restore + sanitation.
- Added focused tests in
test/unit/engrave_surface.test.js. - Updated
docs/CODEMATCH.mdengrave rows fromMissingtoImplemented.
- Added explicit C-name surfaces in
- Validation:
node --test test/unit/engrave_surface.test.js test/unit/command_engrave_prompt.test.js test/unit/engrave_wipe_event.test.jspassed (5/5).
CODEMATCH multi-file closure slice: engrave.c + track.c remaining missing rows (2026-03-10)
- Problem:
engrave.cstill had 3 missing rows (engrave,save_engravings,rest_engravings).track.cstill had 3 missing rows (hastrack,save_track,rest_track), withsave/restfunctions still using broken autotranslated C I/O helpers.
- Change:
- In
js/engrave.js:- Exported
engrave()occupation surface (stub behavior unchanged). - Added
save_engravings(map)andrest_engravings(map, saved)as architecture-faithful JS persistence surfaces.
- Exported
- In
js/track.js:- Implemented
save_track(nhfp)andrest_track(nhfp)using JS snapshot state (nhfp.trackState) with bounds checks and optionalreleaseDatareset semantics. hastrackrow was stale; function already present and now marked implemented in ledger.
- Implemented
- Added tests:
test/unit/engrave_surface.test.jstest/unit/track_surface.test.js
- Updated
docs/CODEMATCH.mdrows for both files toImplemented.
- In
- Validation:
node --test test/unit/engrave_surface.test.js test/unit/command_engrave_prompt.test.js test/unit/engrave_wipe_event.test.js test/unit/track_surface.test.jspassed (8/8).
CODEMATCH multi-file closure slice: hacklib.c + teleport.c missing surfaces (2026-03-10)
- Problem:
hacklib.cstill had 3 missing rows (datamodel,nh_snprintf,unicodeval_to_utf8str) and several broken autotranslated helpers.teleport.cstill had 3 missing rows (goodpos_onscary,control_mon_tele,tele_to_rnd_pet).
- Change:
- In
js/hacklib.js:- Replaced broken autotranslated stubs with working implementations for
case_insensitive_comp,copy_bytes,what_datamodel_is_this,nh_qsort_idx_cmp. - Added
datamodel,nh_snprintf,unicodeval_to_utf8strC-name compatibility surfaces.
- Replaced broken autotranslated stubs with working implementations for
- In
js/teleport.js:- Added
goodpos_onscaryprobe helper. - Added deterministic
control_mon_teledestination-validation helper (opt-in viaopts.monTelecontrol). - Added
tele_to_rnd_pethero-near-pet teleport helper.
- Added
- Added tests:
test/unit/hacklib_surface_codematch.test.jstest/unit/teleport_surface.test.js
- Updated
docs/CODEMATCH.mdrows fromMissingtoImplementedfor all six functions, and updated teleport file-summary note to reflect33/37surfaces present.
- In
- Validation:
node --test test/unit/hacklib_surface_codematch.test.js test/unit/teleport_surface.test.js test/unit/hacklib.test.jspassed (169/169).
CODEMATCH multi-file closure slice: were.c + wield.c + worn.c + worm.c surfaces (2026-03-10)
- Problem:
- Remaining gameplay-facing CODEMATCH missing rows were concentrated in four
files:
were.c,wield.c,worn.c, andworm.c.
- Remaining gameplay-facing CODEMATCH missing rows were concentrated in four
files:
- Change:
js/were.js- Added
you_were(player, ctx)andyou_unwere(player, purify, ctx)surfaces. - Kept behavior conservative and deterministic, with optional callback hooks
(
ctx.polymon,ctx.rehumanize,ctx.monster_nearby) for deeper C-style integration without adding replay-side heuristics.
- Added
js/wield.js- Added
ready_ok(obj, player)C-style tri-state filter surface. - Added
finish_splitting(obj, player)stack-split finalization surface and wired quiver candidate filtering throughready_ok.
- Added
js/worn.js- Added
recalc_telepat_range(player)andcheck_wornmask_slots(player)surfaces. - Wired
wizcmds.you_sanity_check()callsite to passplayer.
- Added
js/worm.js- Added
random_dir(x, y)legacy helper surface (upstream marks it under#if 0, but adding it closes naming coverage and supports tooling/tests).
- Added
docs/CODEMATCH.md- Updated 7 rows from Missing -> Implemented across these files.
- Tests:
- Added
test/unit/codematch_were_wield_worn_worm_surface.test.js - Re-ran related suites for were/wield prompt paths.
- Added
- Validation:
node --test test/unit/codematch_were_wield_worn_worm_surface.test.js test/unit/were.test.js test/unit/command_wield_prompt.test.jspassed (26/26).
CODEMATCH multi-file closure slice: insight.c + iactions.c missing surfaces (2026-03-10)
- Problem:
iactions.cstill had 3 missing surfaces and the existing JS file had unresolved autotranslated symbols.insight.cstill had 3 missing surfaces (cause_known,attributes_enlightenment,show_achievements).
- Change:
- Replaced
js/iactions.jswith stable C-name surfaces:item_naming_classificationitem_reading_classificationia_addmenuitemactions_pushkeysitemactions
- Added missing insight surfaces in
js/insight.js:cause_knownattributes_enlightenmentshow_achievements
- Updated
docs/CODEMATCH.mdrows from Missing -> Implemented for all six functions and updated top-line totals. - Added test coverage in:
test/unit/codematch_insight_iactions_surface.test.js
- Replaced
- Validation:
node --test test/unit/codematch_insight_iactions_surface.test.js test/unit/codematch_were_wield_worn_worm_surface.test.jspassed (12/12).
CODEMATCH multi-file closure slice: decl/monst/mplayer/objects/polyself/priest/shknam/steal/steed (2026-03-10)
- Problem:
- Remaining mapped gameplay CODEMATCH gaps were a small but scattered
14-function cluster across 9 files (
decl.c,monst.c,mplayer.c,objects.c,polyself.c,priest.c,shknam.c,steal.c,steed.c).
- Remaining mapped gameplay CODEMATCH gaps were a small but scattered
14-function cluster across 9 files (
- Change:
- Added explicit compatibility surfaces:
decl.js:decl_globals_init,sa_victualmonst.js:monst_globals_initobjects.js:objects_globals_initmplayer.js:dev_name,get_mplnamepolyself.js:dropppriest.js:move_special(re-export),priestini(mkroom wrapper)shknam.js:init_shop_selection,neweshksteal.js:unstolenarm,stealarmsteed.js:use_saddle
mkroom.js: exportedpriestiniso the C-name priest surface can delegate to the existing temple-initialization implementation.- Added coverage file:
test/unit/codematch_remaining_surface_batch.test.js
- Updated
docs/CODEMATCH.mdrow mappings and top-line metrics:- gameplay missing:
51 -> 37 - raw missing:
672 -> 658
- gameplay missing:
- Added explicit compatibility surfaces:
- Validation:
node --test test/unit/codematch_remaining_surface_batch.test.jsnode --test test/unit/codematch_remaining_surface_batch.test.js test/unit/codematch_insight_iactions_surface.test.js test/unit/codematch_were_wield_worn_worm_surface.test.js- Both passed.
CODEMATCH closure slice: monmove.c onscary completion (2026-03-10)
- Problem:
onscarywas still tracked asPartialin CODEMATCH due missing C checks (lawful-minion resistance, vampire-shifter altar scare, and full Elbereth gate parity fields).
- Change:
js/mon.jsonscary(...)now includes:- lawful-minion direct scare immunity
- altar scare for vampire shifters (
is_vampshifter) - Elbereth exclusion parity cleanup (
mpeaceful+ existing minotaur/vision/hell/endgame/displaced logic)
- Added focused tests in
test/unit/mon_onscary.test.jscovering:- lawful-minion immunity
- vampire-shifter altar fear
- displaced Elbereth protection path
- minotaur Elbereth exclusion
- Updated
docs/CODEMATCH.mdrowmonmove.c:onscaryfromPartialtoImplementedand refreshed top-line totals by -1.
- Validation:
node --test test/unit/mon_onscary.test.jsnode --test test/unit/mon_onscary.test.js test/unit/codematch_remaining_surface_batch.test.js
CODEMATCH multi-file closure slice: worn.c + steal.c (2026-03-10)
- Problem:
- Remaining CODEMATCH rows in
worn.c/steal.cwere still tracked as missing/stub despite now-needed gameplay helper surfaces for bypass traversal and thief post-action callbacks.
- Remaining CODEMATCH rows in
- Change:
js/worn.js:- Implemented
bypass_obj,bypass_objlist,clear_bypass,clear_bypasses,nxt_unbypassed_obj,nxt_unbypassed_loot. - Added linked-list and array traversal support so callers from different object-chain contexts can use the same bypass helpers.
- Implemented
js/steal.js:- Implemented
thiefdeadcallback rewiring (stealarm->unstolenarm) and cleanup behavior. - Implemented
maybe_absorb_itemcarry/inventory transfer flow. - Implemented
mdrop_special_objsdrop path for invocation/special items.
- Implemented
- Added targeted coverage:
test/unit/codematch_worn_steal_surface.test.js
- Updated
docs/CODEMATCH.mdrow status entries for this slice.
- Validation:
node --test test/unit/codematch_worn_steal_surface.test.jsnode scripts/test-unit-core.mjs- Both passed.
CODEMATCH multi-file closure slice: spell.c + explode.c + mcastu.c surfaces (2026-03-10)
- Problem:
- Several rows in
spell.c,explode.c, andmcastu.cwere still markedStubeven though the JS had substantial logic or needed lightweight faithful behavior to close missing surfaces.
- Several rows in
- Change:
js/spell.js:- Fixed
spell_idx(otyp, player)lookup to pass the player spellbook intospellid(...)correctly (surfaced by new unit coverage). - Audited and kept existing implemented surfaces:
study_book,confused_book,book_cursed,learn,rejectcasting.
- Fixed
js/explode.js:- Implemented
explosionmaskresistance checks for elemental cases. - Implemented
engulfer_explosion_msgmessage selection by explosion type. - Implemented a bounded
scatterobject relocation path for callers with object+map context.
- Implemented
js/mcastu.js:- Implemented
cursetxtoutput text generation/storage. - Implemented
touch_of_deathdamage path with antimagic/magic-resistance mitigation and damage bookkeeping.
- Implemented
- Added targeted coverage:
test/unit/codematch_spell_explode_mcastu_surface.test.js
- Updated
docs/CODEMATCH.mdrow statuses from staleStubentries to implemented/partial statuses matching current behavior.
- Validation:
node --test test/unit/codematch_spell_explode_mcastu_surface.test.jsnode scripts/test-unit-core.mjs- Both passed.
CODEMATCH combat closure: mhitm.c helper stubs (noises, slept_monst, rustm) (2026-03-10)
- Problem:
mhitm.cstill had three helper rows tracked as stubs even though the surrounding m-vs-m pipeline is active and these helpers are parity-relevant.
- Change:
js/mhitm.js:- Exported
noises(...)(previously implemented as internal-only helper). - Implemented
slept_monst(mon, player)to releaseplayer.ustuckwhen a sleeping/paralyzed grabber should relax grip (C-styleunstuckpath). - Tightened
rustm(...)with C steam-vortex exclusion for fire erosion.
- Exported
- Added targeted coverage:
test/unit/codematch_mhitm_surface.test.js- noise message/rate-limit behavior
slept_monstrelease behavior- steam-vortex fire-erosion exclusion vs non-steam erosion path
- Updated
docs/CODEMATCH.mdrows:mhitm.c:noises-> Implementedmhitm.c:slept_monst-> Implementedmhitm.c:rustm-> Implemented
- Validation:
node --test test/unit/codematch_mhitm_surface.test.jsnode scripts/test-unit-core.mjs- Both passed.
CODEMATCH blindness boundary + restore-ability closure (toggle_blindness, peffect_restore_ability) (2026-03-10)
- Problem:
potion.c:toggle_blindnessremained marked as stub/inlined behavior.potion.c:peffect_restore_abilitywas still RNG-only skeleton logic.do_wear.jshad blindfold removal callsites passing wrong arguments intoBlindf_off, creating boundary fragility.
- Change:
js/potion.js:- Added explicit exported
toggle_blindness(player)API (botl + vision dirty + monster-visibility refresh hook). - Wired
make_blinded(...)to calltoggle_blindnesswhen sight toggles. - Implemented
peffect_restore_ability(...)core C behavior:- cursed: “mediocre” path
- non-cursed: random-start attribute restoration to
attrMax - blessed: restore all drained attributes
- potion form restores lost levels via
pluslvl, looping for blessed.
- Added explicit exported
js/do_wear.js:Blindf_on/Blindf_offnow updateBLINDEDextrinsic/blocked masks (W_TOOL) in a C-faithful eyewear boundary model (blindfold/towel blinds; lenses block blindness).- Both functions now call
toggle_blindnesson actual sight transitions. - Fixed incorrect
Blindf_off(...)callsites to pass(player, obj).
- Added targeted tests:
test/unit/codematch_blindness_restore_surface.test.js- blindfold on/off extrinsic transitions
- lenses blocked-visibility transition
- restore-ability blessed path (attributes + lost level recovery).
- Updated
docs/CODEMATCH.md:potion.c:toggle_blindness-> Implementedpotion.c:peffect_restore_ability-> Implemented
- Validation:
node --test test/unit/codematch_blindness_restore_surface.test.jsnode scripts/test-unit-core.mjs- Both passed.
CODEMATCH timeout helper closure (
burn_away_slime,slimed_to_death, lamp + fumble helpers) (2026-03-10)
- Problem:
- Several
timeout.chelpers were still true JS no-ops:burn_away_slime,slimed_to_death,slip_or_trip,see_lamp_flicker,lantern_message.
- Several
- Change:
js/timeout.js:- Implemented
burn_away_slime(player)by routing throughmake_slimed(..., 0, "The slime that covers you is burned away!"). - Implemented
slimed_to_death(kptr, player)to set terminal sliming death throughdone_timeout. - Implemented
slip_or_trip(player, map)as a simplified message subset with ice/hallucination-aware variants. - Implemented
see_lamp_flickerandlantern_messagemessage helpers. - Wired
_fireExpiryEffectSLIMED path to callslimed_to_death. - Wired FUMBLING expiry path to invoke
slip_or_tripwhen applicable.
- Implemented
js/potion.js,js/trap.js,js/sit.js:- Updated
burn_away_slime()callsites toawait burn_away_slime()now that the helper performs async status/message work.
- Updated
- Added targeted coverage:
test/unit/codematch_timeout_surface.test.js- slimed timeout burn-away clear
- slimed death terminal state
- lamp helper callability
- Updated
docs/CODEMATCH.mdrows from stub to implemented for the above.
- Validation:
node --test test/unit/codematch_timeout_surface.test.jsnode scripts/test-unit-core.mjs- Both passed.
CODEMATCH read.c + mhitu.c chunk (seffect_* wiring + AD_TLPT/erosion status cleanup) (2026-03-10)
- Problem:
read.jsstill had several scroll effects marked as message-only stubs in the CODEMATCH ledger.mhitu.crows indocs/CODEMATCH.mdhad stale stub status for handlers that are now wired in JS (AD_SGLD,AD_RUST/CORR/DCAY), andAD_TLPTstill lacked a teleport action.
- Change:
js/read.js:seffect_genocidenow routes todo_class_genocide/do_genocide.seffect_stinking_cloudnow routes todo_stinking_cloud.do_class_genocideanddo_genocidenow perform realmvitalsmarking withG_GENO|G_NOCORPSEandkill_genocided_monsters(...)(with simplified deterministic target selection where C has prompt-driven selection).do_stinking_cloudnow runs agetpostarget flow andcreate_gas_cloud(...).- Fixed
SCR_GENOCIDEdispatcher wiring to passgame.
js/mhitu.js:mhitu_ad_tlptnow executes hero teleport viatele(game)on successful magical hit, while preserving existing C-style damage cap behavior.
docs/CODEMATCH.md:- Updated read rows: teleport/gold/food/stinking-cloud now implemented; genocide rows now marked approximate instead of stub.
- Updated mhitu rows:
mhitu_ad_sgld,mhitu_ad_rust,mhitu_ad_corr,mhitu_ad_dcaynow implemented;mhitu_ad_tlptnow partial. - Global
Stubcount reduced from85to75.
- Validation:
node scripts/test-unit-core.mjs- Passed (
2702tests,0failures).
seed331 status-row stale-at-death fix (2026-03-10)
- Symptom:
seed331_tourist_wizard_gameplayregressed to a screen mismatch at step379: status showedHP:4(10)in JS vsHP:0(10)in C/session.- RNG/events were still full parity; mismatch was render-boundary only.
- Root cause:
- In JS death-message
--More--paths, we blocked for dismissal without a guaranteed status refresh immediately before waiting. - C
more()behavior refreshes status (bot()) before input wait, so HP=0 is already visible at that boundary.
- In JS death-message
- Fix:
- Generalized explicit
more(...)helper injs/input.jsto render status before waiting on dismissal, matching Cmore()behavior for all callers. - In both
js/display.jsandjs/headless.js, also render status before death-message internal waits whereputstr_message(...)blocks directly.
- Generalized explicit
- Validation:
seed331targeted replay now matches screen/color fully (389/389).scripts/run-and-report.sh: gameplay34/34green.
CODEMATCH uhitm.c AD-branch alignment (AD_WERE/AD_PEST/AD_FAMN) (2026-03-10)
- Problem:
uhitmAD handlers for Rider/lycanthropy variants were still simplified and not matching C branch behavior in the m-vs-m path.
- Change:
js/uhitm.js:mhitm_ad_werenow delegates tomhitm_ad_phys(matching C m-vs-m).mhitm_ad_pestnow routes throughAD_DISEsemantics.mhitm_ad_famnnow zeroes damage for non-eaters (non-carnivorous/non-herbivorous/non-metallivorous defenders).
- Added focused unit coverage:
test/unit/codematch_uhitm_ad_branches.test.js- were -> physical delegation behavior
- pest -> disease delegation behavior
- famine eater/non-eater split behavior
- Updated
docs/CODEMATCH.mdrows from stub to implemented for:mhitm_ad_weremhitm_ad_pestmhitm_ad_famn
- Validation:
node --test test/unit/codematch_uhitm_ad_branches.test.jsnode scripts/test-unit-core.mjs- Both passed.
CODEMATCH uhitm.c theft/disease branch expansion (AD_SGLD/AD_SEDU/AD_SSEX + AD_DISE) (2026-03-10)
- Problem:
- Several m-vs-m
uhitmAD handlers were still pure no-op stubs, limiting callchain fidelity in monster-vs-monster combat. steal.js:findgold()only handled linked-list style inventories and missed JS runtime array inventories.
- Several m-vs-m
- Change:
js/steal.js:findgold()now supports both array and linked-list inventory shapes.
js/uhitm.js:mhitm_ad_sgld: now transfers gold from defender to attacker inventory with cancel/same-class gates.mhitm_ad_sedu: now steals one item from defender inventory; tame attackers prefer non-cursed items; nymph theft marksM_ATTK_AGR_DONE.mhitm_ad_ssex: continues delegating through seduction path and is now treated as partial rather than pure no-op.mhitm_ad_dise: now preserves normal damage for susceptible defenders and zeroes damage for fungus/ghoul immunity cases.
test/unit/codematch_uhitm_ad_branches.test.jsexpanded to cover:- gold transfer,
- seduction theft + nymph done flag,
- tame cursed-item avoidance,
- disease susceptible-target behavior.
docs/CODEMATCH.mdupdated fromStubtoPartialfor:mhitm_ad_sgldmhitm_ad_sedumhitm_ad_ssexmhitm_ad_dise
- Validation:
node --test test/unit/codematch_uhitm_ad_branches.test.jsnode scripts/test-unit-core.mjs
CODEMATCH uhitm.c digestion/hallucination branch closure (AD_DGST + AD_HALU) (2026-03-10)
- Problem:
mhitm_ad_dgstandmhitm_ad_haluwere still treated as stubs in the m-vs-m branch and did not reflect key C side effects.
- Change:
js/uhitm.js:mhitm_ad_dgstnow:- applies swallow-lethal damage (
mhm.damage = mdef.mhp) for normal defenders, and - models Rider edge behavior where digesting a Rider kills the aggressor
(
mondied,M_ATTK_AGR_DIED/ miss fallback).
- applies swallow-lethal damage (
mhitm_ad_halunow applies C-shaped m-vs-m hallucination effects:- set defender confusion (
mconf) when attacker not cancelled and defender has eyes and can see, - clear wait strategy,
- force zero damage.
- set defender confusion (
- Added/expanded targeted tests in:
test/unit/codematch_uhitm_ad_branches.test.js- digest non-Rider damage behavior
- digest Rider aggressor-death behavior
- hallucination confusion/strategy behavior
- Updated
docs/CODEMATCH.md:mhitm_ad_dgst: Stub -> Partialmhitm_ad_halu: Stub -> Implementedmhitm_ad_samu: Stub -> Implemented (documented as C-faithful no-op m-vs-m branch)
- Validation:
node --test test/unit/codematch_uhitm_ad_branches.test.jsnode scripts/test-unit-core.mjs
CODEMATCH uhitm.c branch expansion (AD_TLPT/AD_ENCH/AD_POLY/AD_SLIM) (2026-03-10)
- Problem:
- Several remaining
uhitmm-vs-m AD handlers were still hard stubs and either zeroed damage incorrectly or omitted C branch gates.
- Several remaining
- Change:
js/uhitm.js:mhitm_ad_tlpt: now follows m-vs-m C gating shape (cancel, damage gate, no-tele gate, negation) and clears wait strategy on eligible branch; async relocation remains pending in this sync path.mhitm_ad_ench: now preserves normal damage (C m-vs-m branch has no special handling).mhitm_ad_poly: now applies C-shaped negation/cooldown gating and special-hit termination (M_ATTK_HIT,done), with fullnewchamtransformation still pending.mhitm_ad_slim: now applies negation +rn2(4)+ slimeproof gate, then zero-damage hit branch and wait-strategy clear; full munslime/newcham pipeline remains pending.
test/unit/codematch_uhitm_ad_branches.test.jsexpanded with targeted tests for the above branches.docs/CODEMATCH.mdupdated fromStubtoImplemented/Partialfor these rows.
- Validation:
node --test test/unit/codematch_uhitm_ad_branches.test.jsnode scripts/test-unit-core.mjs2026-03-10:
mhituAD_SLIM/AD_ENCH/AD_WERE faithfulness pass
- Problem: several
mhitu.cattack handlers injs/mhitu.jswere still effectively stubs, notably forcingAD_SLIMdamage to zero unconditionally and not applying lycanthropy/enchantment effects. - Change:
mhitu_ad_slim: ported C branch structure for hero immunity/effect handling (negation, flaming, noncorporeal/green-slime immunity, Slimed timer + delayed killer) while preserving normal physical damage where C preserves it.mhitu_ad_ench: added C-style negation gate + worn-slot selection anddrain_itemapplication.mhitu_ad_were: added 1/4 gated lycanthropy infection path (set_ulycn) with protection/negation checks.- Fixed underlying runtime bug exposed by new sliming path:
find_delayed_killer()incorrectly referencedsvk.killer; now uses modulekillerlist.
- Validation:
- Added combat regression tests for
AD_SLIMandAD_WERE. - Added direct delayed-killer unit tests (
end_delayed_killer.test.js). npm run -s test:unitpasses (2712/2712).
- Added combat regression tests for
2026-03-10: mhitu larger branch slice (AD_DGST / AD_PEST / AD_SSEX)
- Problem: several
mhituhandlers were still overly stubbed, causing incorrect effect semantics:AD_DGSTpath could still inflict direct damage in JS despite C zeroing damage inmhitu.AD_PESTwas missing thediseasemu()side-effect call.AD_SSEXwas not routed through the C fallback seduction/theft path.
- Change:
mhitu_ad_dgst: now explicitly setsmhm.damage = 0after hit message for C-faithfulmhitubehavior.mhitu_ad_pest: now callsdiseasemu()(message + disease side effects) while preserving normal physical damage.mhitu_ad_ssex: now delegates tomhitu_ad_sedu()fallback behavior (without SYSOPT_SEDUCE branch).
- Validation:
- Added
mattackutests incombat.test.jsfor:AD_DGSTno direct damageAD_PESTdisease side-effectAD_SSEXseduction path no direct damage
npm run -s test:unitpasses (2715/2715).
- Added
2026-03-10: stealamulet implementation + mhitu AD_SAMU await wiring
- Problem:
steal.c:stealamulet()was still a JS stub, andmhitu_ad_samucalled it without awaiting any side effects.- This left invocation-item theft behavior under-implemented and made
AD_SAMUless C-faithful than surroundingmhituhandlers.
- Change:
- Implemented
stealamulet(mon, player, display, map)injs/steal.jswith C-shaped behavior:- target selection among quest artifacts/invocation items with
rnd(n)tie-breaking, - fake-amulet exclusion for Wizard thieves (
iswiz), - worn-item pre-removal ordering (cloak/armor/shirt/gloves/weapon/rings),
- inventory transfer +
uhaveflag updates, - theft message and post-theft teleport attempt (
can_teleport+tele_restrict+rloc).
- target selection among quest artifacts/invocation items with
- Wired
mhitu_ad_samutoawait stealamulet(...)and pass map context. - Tightened
AD_POLYcombat tests to validate direct-damage path behavior (takeDamagebypass vs fallback) instead of assuming unchanged HP. - Added dedicated
stealamuletunit coverage intest/unit/codematch_worn_steal_surface.test.js.
- Implemented
- Validation:
node --test test/unit/combat.test.jsnode --test test/unit/codematch_worn_steal_surface.test.jsnpm run -s test:unit(2719/2719 passing)
2026-03-10: were.c control-flow fidelity pass (you_were / you_unwere)
- Problem:
you_were/you_unwereexisted in JS but did not fully preserve C control flow boundaries:- controllable-poly path vs
monster_nearby()gating, - controlled unwere “remain in beast form” branch with timer refresh,
- default runtime wiring to real polymorph/rehumanize routines.
- controllable-poly path vs
- Change:
js/were.js:you_werenow mirrors C branch shape:- computes controllable polymorph gate (
polyControland not stunned/unaware), - applies confirmation callback only for controllable path,
- otherwise blocks on nearby-hostile gate,
- then performs transformation through
polymon(dynamic import fallback) and incrementswere_changes.
- computes controllable polymorph gate (
you_unwerenow mirrors C branch shape:- optional purification (
set_ulycn), - checks current were-form state,
- applies nearby-hostile gate,
- supports controlled “remain in beast form” callback,
- rehumanizes by default (dynamic import fallback), or refreshes were timer when remaining in beast form with no timer active.
- optional purification (
set_ulycnnow callsplayer.set_uasmon()when available, so lycanthropy catch/cure can update intrinsic state immediately.
- Added targeted tests in
test/unit/codematch_were_wield_worn_worm_surface.test.jsfor:- uncontrollable
you_wereblocked by nearby monsters, - controllable
you_wereconfirmation decline path, - controllable
you_unwereremain-beast path timer refresh.
- uncontrollable
- Updated
docs/CODEMATCH.mdsummary line forwere.cto all-public-surface aligned.
2026-03-10: Canonical scorefiles for deterministic C endgame captures
- Problem:
- Some C-recorded sessions could surface endgame warning text:
Cannot open file logfile.../Cannot open file xlogfile... - Cause: harness cleanup removed
record/logfile/xlogfile, and endgame score code then emitted filesystem warnings into captured screens.
- Some C-recorded sessions could surface endgame warning text:
- Change:
- Added canonical scorefile creation after cleanup for C launch paths:
test/comparison/c-harness/run_session.pytest/comparison/c-harness/keylog_to_session.pyselfplay/interface/tmux_adapter.js
- Behavior now matches an installed environment with present score files, eliminating this avoidable warning condition for newly recorded sessions.
- Added canonical scorefile creation after cleanup for C launch paths:
- Validation:
- Regenerated
seed331to a temp session and verified warning text is absent.
- Regenerated
2026-03-10: mhitu branch-closure chunk (AD_CURS + AD_FAMN) and stale ledger fixes
- Problem:
mhitu_ad_cursstill forcedmhm.damage = 0, which is not C-faithful in the hero-target branch (C keeps normal physical damage and applies curse side effects conditionally).mhitu_ad_famnconsumedrn1(40,40)unconditionally and did not apply the actual hunger change branch.docs/CODEMATCH.mdstill had stale rows marking severalmhituhandlers (AD_CURS,AD_POLY,AD_SAMU,AD_FAMN) as stubs despite landed code.
- Change:
js/mhitu.js:mhitu_ad_cursnow follows C branch shape:- daytime gremlin early-return,
!mcan && !rn2(10)curse gate,- laughter messaging (blind/deaf aware),
- clay-golem rehumanize branch,
attrcurse()application,- and importantly preserves normal physical damage.
mhitu_ad_famnnow applies hunger drain only when hero is not fainted:morehungry(rn1(40,40)), matching C branch structure.
test/unit/combat.test.js:- added regression test that
AD_CURSno longer zeroes physical damage. - added
AD_FAMNhunger gating test (normal vs fainted hero).
- added regression test that
docs/CODEMATCH.md:- updated stale
mhiturows forAD_CURS,AD_POLY,AD_SAMU, andAD_FAMNfrom stub wording to accurate partial implementations.
- updated stale
2026-03-10: mhitu AD_DETH C-branch alignment pass
- Problem:
mhitu_ad_dethwas still a coarse approximation and missed key C branch structure:- undead-target reduced-damage behavior,
- antimagic-gated touch-of-death vs life-force-drain path.
- Change:
js/mhitu.js:- added undead-target branch: reduce damage to
(damage+1)/2and emit “Was that the touch of death?”. - added antimagic-aware high-roll handling:
- no antimagic:
touch_of_death(...)and zero direct damage, - with antimagic: life-force-drain messaging +
permdmgpath.
- no antimagic:
- preserved existing low-roll lucky branch and mid-roll life-force-drain branch.
- added undead-target branch: reduce damage to
test/unit/combat.test.js:- added deterministic regression test comparing undead-target
AD_DETHagainst same-seedAD_PHYS, asserting reduced damage behavior.
- added deterministic regression test comparing undead-target
docs/CODEMATCH.md:- updated stale
mhitu_ad_dethrow text to reflect current partial C-shaped implementation.
- updated stale
2026-03-10: mhitu multi-function C-flow pass (AD_DRIN/AD_SLOW/AD_STON)
- Problem:
- Three hero-target
mhituhandlers still had known branch-shape gaps:AD_DRINlacked C no-harm gates and repeated-attack short-circuiting.AD_SLOWused an inline message/TODO rather than the shared slowdown helper path.AD_STONprinted simplified messages and did not calldo_stone_u().
- Three hero-target
- Change:
js/mhitu.js:AD_DRIN:- added C gates for
defends(AD_DRIN, player.weapon)and!has_head. - added helmet-block path (
uarmh && rn2(8)) with zero direct damage. - added half-physical damage shaping before final damage application.
- introduced per-cycle
combatState.skipdrinso harmless DRIN hits skip remaining DRIN attacks from the same monster move.
- added C gates for
AD_SLOW:- now calls shared
u_slow_down(player, display)under!negated && HFast && !rn2(4)gate, matching C branch structure.
- now calls shared
AD_STON:- aligned cough/hiss/grimace messaging branches with deaf/blind/hallu sensitivity.
- wired
(!rn2(10) || moonphase==NEW_MOON)intodo_stone_u(...). - on successful stoning, now sets
mhm.hitflags,mhm.done, and suppresses further damage in this attack.
test/unit/combat.test.js:- added regression test asserting headless hero DRIN behavior: no damage and only one DRIN hit message for a multi-DRIN attack cycle.
docs/CODEMATCH.md:- updated
mhitu_ad_drin,mhitu_ad_slow, andmhitu_ad_stonrows to accurate partial C-faithful status.
- updated
2026-03-10: uhitm m-vs-m erosion/curse branch closure (AD_RUST/CORR/DCAY/CURS)
- Problem:
- Four m-vs-m
uhitmhandlers were still hard stubs:mhitm_ad_rust,mhitm_ad_corr,mhitm_ad_dcay,mhitm_ad_curs.
- This left important C branch behavior unmodeled: cancellation gates, golem-vulnerability instant-kill paths, defender wait-strategy clearing, and curse/cancel effects in monster-vs-monster combat.
- Four m-vs-m
- Change:
js/uhitm.js:mhitm_ad_rust:- added
mcangate, - added
completelyrustsiron-golem instant-kill path (monkilled), - added erosion + wait-strategy clear + zero-damage branch.
- added
mhitm_ad_corr:- added
mcangate, - added erosion + wait-strategy clear + zero-damage branch.
- added
mhitm_ad_dcay:- added
mcangate, - added
completelyrotsgolem instant-kill path (monkilled), - added erosion + wait-strategy clear + zero-damage branch.
- added
mhitm_ad_curs:- added daytime-gremlin guard,
- added
!mcan && !rn2(10)cancel branch, - added wait-strategy clear,
- added clay-golem destruction branch via
mondied, - preserved normal damage outside curse-special branches.
test/unit/codematch_uhitm_ad_branches.test.js:- added targeted regression tests for all four handlers, including rust/rot golem-kill paths and curse cancellation/clay destruction.
docs/CODEMATCH.md:- upgraded these four rows from stub to accurate partial C-shaped status.
2026-03-10: ASYNC_CLEANUP runtime diagnostics and origin-await cleanup
- Removed boundary-facade dependency in runtime/replay diagnostics:
- added
getRuntimeInputSnapshot(game)injs/allmain.js, - switched command diagnostics and replay boundary trace formatting to this helper,
- removed
NetHackGame.getInputBoundaryState().
- added
- Simplified command-loop key reads:
- removed
NetHackGame._readCommandLoopKey(), - now uses direct
await nhgetch()at both callsites.
- removed
- Removed one remaining raw timer await from gameplay path:
js/storage.js::handleSave()now usesawait nh_delay_output(500)instead ofawait new Promise(setTimeout, 500), so the delay is tracked through origin await registration.
- Validation at each step:
- unit tests stayed green (
2731/2731), - session parity stayed fully green (
34/34, all channels).
- unit tests stayed green (
2026-03-10: uhitm m-vs-m AD_STON stub closure
- Problem:
mhitm_ad_stoninuhitm.jswas still a full stub (damage=0), missing C branch behavior for monster-vs-monster petrification.
- Change:
js/uhitm.js:- replaced stub with C-shaped m-vs-m logic:
- cancellation (
mcan) early return, - stone-resistant defender path with zero direct damage,
- non-resistant petrification kill path via
monkilled, M_ATTK_DEF_DIED/donetermination on defender death.
- cancellation (
- replaced stub with C-shaped m-vs-m logic:
test/unit/codematch_uhitm_ad_branches.test.js:- added targeted tests for kill-path and stone-resistant path behavior.
docs/CODEMATCH.md:- upgraded
mhitm_ad_stonfrom stub to partial with explicit remaining edge gaps (munstone/newcham/grow_up semantics).
- upgraded
2026-03-10: uhitm m-vs-m theft/disease branch fidelity (AD_SGLD/SEDU/DISE)
- Problem:
- Several gameplay-relevant m-vs-m branches were still missing C side
effects:
mhitm_ad_sgldincorrectly blocked same-class theft and never marked aggressor-done teleport intent.mhitm_ad_sedulackedpossibly_unwield/mselftouch/defender-death side effects and set nymph done-flag without teleport gating.mhitm_ad_diselacked thedefended(AD_DISE)immunity gate.
- Several gameplay-relevant m-vs-m branches were still missing C side
effects:
- Change:
js/uhitm.js:mhitm_ad_sgld:- removed incorrect same-class (
mlet) block in m-vs-m path, - kept cancel gate + wait-strategy clear,
- sets
M_ATTK_AGR_DONEwhen teleport is allowed (still norlochere).
- removed incorrect same-class (
mhitm_ad_dise:- added
defended(mdef, AD_DISE)immunity.
- added
mhitm_ad_sedu:- after theft, now runs
possibly_unwield(mdef, false), - runs
mselftouch(mdef, ..., false)and terminates attack on defender death (M_ATTK_DEF_DIED,done), - keeps wait-strategy clear,
- nymph
M_ATTK_AGR_DONEnow gated by!tele_restrict(...).
- after theft, now runs
test/unit/codematch_uhitm_ad_branches.test.js:- added regression checks for:
- same-class gold theft succeeding in m-vs-m,
AD_SGLDsettingM_ATTK_AGR_DONE,AD_SEDUpetrification side effect viamselftouch.
- added regression checks for:
docs/CODEMATCH.md:- updated rows for
mhitm_ad_sgld,mhitm_ad_sedu,mhitm_ad_disewith the new parity status and remaining gaps (rloc/grow_up coupling).
- updated rows for
2026-03-10: async m-vs-m AD runtime path for teleport/slime fidelity
- Problem:
mhitmuses an async combat loop, butuhitmAD dispatch was sync-only.- This prevented C-faithful awaited effects in m-vs-m AD handlers:
AD_TLPTcould clear strategy but could not executerloc(...).AD_SLIMcould gate damage but could not runmunslime/newcham.
- Change:
js/uhitm.js:- added
mhitm_ad_tlpt_async(...):- preserves C gate order (
mcan, fatal gate,tele_restrict, negation), - clears
STRAT_WAITFORU, - executes
rloc(mdef, RLOC_NOMSG, ...)when runtime map context exists.
- preserves C gate order (
- added
mhitm_ad_slim_async(...):- preserves C gate order (negation +
rn2(4)+slimeproof), - attempts
munslime(...), - on failure and survival, applies direct green-slime transform via
runtimeApplyNewchamDirect(...), - sets wait-strategy clear + hit flags on transform,
- handles defender-death termination (
M_ATTK_DEF_DIED,done), - enforces zero damage after successful sliming branch.
- preserves C gate order (negation +
- added
mhitm_adtyping_async(...)and kept sync dispatcher for legacy paths.
- added
js/mhitm.js:mdamagem(...)now awaitsmhitm_adtyping_async(...), enabling awaited m-vs-m AD behavior in the actual runtime callchain.
js/makemon.js:- exported
runtimeApplyNewchamDirect(...)wrapper for controlled direct form changes in parity-sensitive runtime paths.
- exported
test/unit/codematch_uhitm_ad_branches.test.js:- added async tests for:
mhitm_ad_tlpt_asyncgate/strategy behavior,mhitm_ad_slim_asyncgreen-slime transform path.
- added async tests for:
- Validation:
node --test test/unit/codematch_uhitm_ad_branches.test.js(25/25)npm run -s test:unit(2737/2737)npm run -s test:session -- --max-failures=5(151/151)
2026-03-10: artifact is_magic_key bless/curse rules aligned to C role split
- Problem:
- JS
is_magic_key(mon, obj)was not fully role-faithful for Master Key of Thievery. C behavior is role-sensitive and intentionally asymmetric.
- JS
- Change:
js/artifact.js:- for Rogues, key is “magic” when not cursed;
- for non-Rogues, key is “magic” only when blessed.
test/unit/codematch_batch_sweep.test.js:- added focused branch test for rogue/non-rogue + bless/curse combinations.
docs/CODEMATCH.md:- refreshed artifact invoke/utility rows from stale stub markers to current implementation status.
- Why this matters:
- This avoids over-generalized blessing checks and keeps edge-case lock/key behavior faithful to C when role identity changes the gating rule.
- Validation:
node --test test/unit/codematch_batch_sweep.test.js(10/10)npm run -s test:unit(2747/2747)npm run -s test:session -- --max-failures=5(151/151)
2026-03-10: potion command parity batch for dodip/dip_into
- Problem:
dodipanddip_intoexisted as command stubs, so#dipbehavior was effectively unavailable despitepotion_dipcore logic being present.
- Change:
js/potion.js:- replaced both stubs with getobj-backed selection flow:
- select potion (
POTION_CLASS) and dip target (dip_ok), - route through existing
potion_dip(...)core mixer, - return boolean turn-use semantics expected by
cmd.js.
- select potion (
dip_intonow supports optional preselected target object for command-queue integration while still supporting normal selection fallback.
- replaced both stubs with getobj-backed selection flow:
test/unit/codematch_batch_sweep.test.js:- added direct coverage for
dodipanddip_intosuccessful paths.
- added direct coverage for
docs/CODEMATCH.md:- updated potion rows for
dodipanddip_intoto reflect real getobj-backed command behavior.
- updated potion rows for
- Why this matters:
- This closes a gameplay-facing command surface gap instead of leaving parity-critical behavior behind inert stubs.
- Validation:
node --test test/unit/codematch_batch_sweep.test.js(12/12)npm run -s test:unit(2749/2749)npm run -s test:session -- --max-failures=5(151/151)
2026-03-10: artifact invoke selection now composes with inventory GETOBJ constants
- Problem:
invoke_ok()used local non-canonical constants (2/-3) which did not align with inventorygetobj()(GETOBJ_SUGGEST=1), so command-layer invocation selection could not compose correctly.doinvoke()itself was still a no-op return.
- Change:
js/artifact.js:- switched
invoke_ok()to canonicalGETOBJ_*constants fromconst.js. - implemented async
doinvoke(player, game):- selects invokable item via
getobj(..., invoke_ok, ...), - dispatches through
arti_invoke(...), - returns command time semantics (
ECMD_TIME/ECMD_CANCEL).
- selects invokable item via
- improved invoke side-effect routing:
invoke_tamingnow usesread.seffect_taming(...)when runtime context is available,invoke_untrapnow routes throughtrap.dountrap(),invoke_charge_objnow selects a target viagetobjand callsread.recharge(...).
- switched
test/unit/codematch_batch_sweep.test.js:- updated
invoke_okexpectation to canonical suggest value, - added
doinvoketurn-consumption test.
- updated
docs/CODEMATCH.md:- updated stale
Mb_hit/doinvokestub labels and refreshed invoke row notes.
- updated stale
- Validation:
node --test test/unit/codematch_batch_sweep.test.js(13/13)npm run -s test:unit(2750/2750)npm run -s test:session -- --max-failures=5(151/151)
2026-03-10: artifact invoke C-semantics follow-up (retouch + cancel paths)
- Problem:
doinvokestill skipped C’sretouch_object()guard.- Several invoke helpers didn’t match C cancel/cooldown semantics:
invoke_untrap: should cancel and clear cooldown age when no action.invoke_charge_obj: should cancel and clear cooldown age when no target.
- Healing invoke lacked C-side status cleanup (Sick/Slimed/timed blindness).
- Change:
js/artifact.js:doinvokenow runsretouch_object(..., FALSE)before dispatching invoke.invoke_untrapnow returnsECMD_CANCELand clearsobj.ageon no-op.invoke_charge_objnow:- uses
read.charge_okfor target filtering, - applies C-shaped cancel semantics (
age=0,ECMD_CANCEL) when canceled, - applies role-aware blessed charging effect (artifact role matches hero role
or artifact role is
NON_PM).
- uses
invoke_healingnow clears sickness/sliming and reduces timed blindness down to cream-only timeout in addition to HP healing.
test/unit/codematch_batch_sweep.test.js:- added coverage for healing status cleanup and charge-cancel cooldown reset.
- Validation:
node --test test/unit/codematch_batch_sweep.test.js(15/15)npm run -s test:unit(2752/2752)npm run -s test:session -- --max-failures=5(151/151)
- Note:
- Running unit and session suites concurrently can cause false session timeout failures from host contention; parity validation should run sessions serially.
2026-03-10: artifact non-power invoke cleanup (crystal ball + carried-only no-op)
- Change:
js/artifact.js:arti_invokenon-artifact/non-power path now mirrors C crystal-ball behavior by delegating todetect.use_crystal_ball(...)when the invoked object is a crystal ball.nothing_specialnow emits feedback only when the object is carried, matching C’scarried(obj)guard.invoke_tamingnow returnsECMD_TIMEexplicitly.
test/unit/codematch_batch_sweep.test.js:- added crystal-ball invoke routing coverage.
- Validation:
node --test test/unit/codematch_batch_sweep.test.js(16/16)npm run -s test:unit(2753/2753)npm run -s test:session -- --max-failures=5(151/151)
2026-03-10: read.seffect_charging non-confused getobj/recharge path restored
- Problem:
seffect_chargingnon-confused branch still consumed the scroll but never selected/charged an item (placeholder “feeling of loss” behavior).
- Change:
js/read.js:- imports
getobjand canonicalGETOBJ_*constants. - keeps C ordering: identify scroll if needed, consume scroll, then prompt
for chargeable target via
getobj("charge", charge_ok, ...). - calls
recharge(otmp, scursed ? -1 : sblessed ? 1 : 0)when target exists. - sets bottom-line refresh in confused branch (
disp.botl = TRUEanalogue).
- imports
test/unit/codematch_batch_sweep.test.js:- added
seffect_chargingcoverage verifying non-confused recharging path.
- added
- Validation:
node --test test/unit/codematch_batch_sweep.test.js(17/17)npm run -s test:unit(2754/2754)npm run -s test:session -- --max-failures=5(151/151)
2026-03-10: read.seffect_light non-confused path aligned to C litroom/lightdamage
- Problem:
seffect_lightnon-confused branch used bespoke JS-only message handling and omitted the C-sidelightdamage(...)effect when appropriate.
- Change:
js/read.js:- imports
lightdamagefromzap.js. - non-confused branch now performs C-shaped flow:
litroom(player, map, !scursed);- if not cursed,
await lightdamage(sobj, player, 5, true).
- removed custom non-confused message branch in favor of shared engine flow.
- imports
docs/CODEMATCH.md:- marks
seffect_lightas implemented.
- marks
- Validation:
node --test test/unit/codematch_batch_sweep.test.js(17/17)npm run -s test:unit(2754/2754)npm run -s test:session -- --max-failures=5(151/151)
2026-03-10: read.seffect_taming maybe_tame flow aligned to C
- Problem:
seffect_tamingwas still approximate: it directly toggled tame flags in a radius and skipped C’smaybe_tamedecision flow and visibility accounting.
- Change:
js/read.js:- implemented async
maybe_tamewith C-shaped branching:- cursed scroll: anger via
setmangry(...), return-1when peacefulness is lost; - non-cursed: apply
resist(...)gate and calltamedog(...)when allowed; return+1when tame/peaceful state improves.
- cursed scroll: anger via
seffect_tamingnow follows C control flow:- swallowed branch uses
u.ustuckdirectly; - non-swallowed branch scans
bd = confused ? 5 : 1, checks map bounds, includes center-steed fallback, and computesvis_resultsviacanspotmon(...).
- swallowed branch uses
- implemented async
test/unit/codematch_batch_sweep.test.js:- added deterministic coverage for cursed-nearby angering and swallowed
(
u.ustuck) path behavior.
- added deterministic coverage for cursed-nearby angering and swallowed
(
docs/CODEMATCH.md:- marked
seffect_tamingimplemented and corrected staleseffect_firenote to reflect currentexplode()routing.
- marked
- Validation:
node --test test/unit/codematch_batch_sweep.test.js(19/19)npm run -s test:unit(2756/2756)npm run -s test:session -- --max-failures=5(151/151)- Note: one initial full-suite run hit a single timeout on seed033, but the session passed in isolated rerun and in a subsequent full-suite rerun.
2026-03-10: read.seffect_fire blessed target flow and confused edge-cases
- Problem:
seffect_firestill used a simplified path: blessed scrolls always exploded on the hero and confused/underwater/fire-resistance messaging did not follow C branches.
- Change:
js/read.js:- added blessed target selection prompt (
getpos_sethilite+getpos_async) and fallback-to-hero when target is invalid. - aligned confused branch messaging/effects with C shape:
- underwater vaporize message,
- fire-resistance warm/pretty-hands message,
- otherwise burn hands and lose 1 HP.
- aligned non-confused pre-explosion behavior:
- underwater violent vaporize message,
- tower-of-flame message only when explosion is centered on hero.
- added blessed target selection prompt (
- Validation:
node --test test/unit/codematch_batch_sweep.test.js(19/19)npm run -s test:unit(2756/2756)npm run -s test:session -- --max-failures=5(151/151)
2026-03-10: read.seffect_earth + seffect_punishment moved onto shared C-style paths
- Problem:
seffect_earthstill used bespoke direct-damage logic and skipped the existing boulder helper callchain.seffect_punishmentbypassed the sharedpunish()path and only flipped a local marker.
- Change:
js/read.js:seffect_earthnow uses the C-shaped helper flow:- blessed: scans neighborhood and applies
drop_boulder_on_monster(...); - non-blessed: applies
drop_boulder_on_player(...); - blessed with no affected monsters emits “But nothing else happens.”
- blessed: scans neighborhood and applies
seffect_punishmentnow delegates topunish(sobj, player)when active (still keeps confused/blessed “You feel guilty.” branch).drop_boulder_on_monsternow threadsmap/gameto use explicit map lookups and wake-near calls.punish()now records simplified punishment state (Punished,uball,uchain) so repeated punishment has a stable heavier-ball path.
test/unit/codematch_batch_sweep.test.js:- added coverage for punishment guilty path and active punishment path.
docs/CODEMATCH.md:- updated
seffect_earthandseffect_punishmentrows from stale notes to current partial-implementation status.
- updated
- Validation:
node --test test/unit/codematch_batch_sweep.test.js(21/21)npm run -s test:unit(2758/2758)npm run -s test:session -- --max-failures=5(151/151)
2026-03-10: read.seffect_earth level/terrain gating tightened to C shape
- Problem:
seffect_earthstill lacked the C-level gating and square suitability checks, causing overly broad behavior on levels where earth effects should be suppressed or filtered.
- Change:
js/read.js:- added C-shaped gate before earth effects:
- skip when rogue level,
- require
has_ceiling, - require earth-level if in endgame.
- added message branching using
avoid_ceiling(...)andceiling(...). - added
sokoban_guilt()call when effect applies. - added square suitability checks for neighborhood drops:
!closed_door(x,y),!IS_OBSTRUCTED(typ),!IS_AIR(typ).
- kept player-hit and boulder helper paths from prior slice.
- added C-shaped gate before earth effects:
- Validation:
node --test test/unit/codematch_batch_sweep.test.js(21/21)npm run -s test:unit(2758/2758)npm run -s test:session -- --max-failures=5(151/151)
2026-03-10: punish/unpunish state moved from flag-only to object-backed flow
- Problem:
seffect_punishmentdelegated topunish(), butpunish()still mostly toggled scalar flags and did not create/attach concrete ball+chain objects compatible with ball-chain movement helpers.
- Change:
js/read.js:punish(sobj, player, map)now:- detects existing punishment and applies heavier-ball levy on the active ball object,
- creates concrete chain/ball objects (
CHAIN_CLASS/BALL_CLASS) when first applying punishment, - populates both compatibility aliases (
uball/uchainandball/chain), - places objects via
placebc(player,map)when not swallowed.
- non-solid form branch now materializes a loose ball on the floor when map context is available.
unpunish(player, map)now clears both alias sets and resets punishment flags instead of referencing stale globals.seffect_punishmentnow passes map context intopunish(...).
docs/CODEMATCH.md:- updated
seffect_punishmentand staleseffect_firewording to reflect current implementation status.
- updated
- Validation:
node --test test/unit/codematch_batch_sweep.test.js(21/21)npm run -s test:unit(2758/2758)npm run -s test:session -- --max-failures=5(151/151)
2026-03-11: potion blindness/hallucination/levitation state transitions aligned to C order
- Problem:
potion.jshad shallow status-transition hooks for blindness/hallucination and a brittlepeffect_levitation()path (mixed state flags, incomplete C ordering).toggle_blindness()refreshed with no map context, sosee_monsters()could no-op in headless/runtime contexts.
- Change:
js/potion.js:toggle_blindness()now mirrors C’s immediate-visibility update shape:- marks vision dirty,
- runs immediate
vision_recalc(), - refreshes monster visibility with active map context,
- calls
learn_unseen_invent()when sight returns.
make_hallucinated()changed-state path now mirrors C redraw ordering:- on hallucination end, refresh mimic/eat message state (
eatmupdate()), - redraw swallowed view if engulfed (
swallowed(0, player)), else refresh monsters/objects/traps, - update inventory and only then emit the hallucination message.
- on hallucination end, refresh mimic/eat message state (
peffect_levitation()now follows C flow and flag semantics:- startup timeout-to-1 before
float_up(), - cursed path clears
I_SPECIAL, - blessed path adds timeout and sets
I_SPECIAL, - sink interaction via
spoteffects(false), - final
float_vs_flight()sync.
- startup timeout-to-1 before
- Validation:
node --test test/unit/command_quaff_prompt.test.jsnode --test test/unit/codematch_blindness_restore_surface.test.jsnode --test test/unit/codematch_batch_sweep.test.jsnode test/comparison/session_test_runner.js test/comparison/sessions/seed303_caveman_selfplay200_gameplay.session.json
2026-03-11: POT_WATER lycanthropy side-effects aligned to C branches
- Problem:
peffect_water()covered core damage/exercise branches but missed the C lycanthropy transitions (you_were/you_unwere/set_ulycn) that depend on bless/curse and alignment/monster state.
- Change:
js/potion.js:- Added C-branch lycan effects:
- holy water in undead/chaotic branch now cures lycanthropy (
you_unwere(..., false)if currently beast form, thenset_ulycn(-1)). - cursed holy/unholy water now triggers
you_were()when hero has lycanthropy and is not currently polymorphed (!Upolyd). - blessed lawful/non-chaotic path now uses purification (
you_unwere(..., true)) when lycanthropic.
- holy water in undead/chaotic branch now cures lycanthropy (
- Added small helper predicates for PM validity, lycan form detection, and
Upolydshape checks. - Affinity message now uses
makeplural(...)for C-like wording.
- Added C-branch lycan effects:
- Validation:
node --test test/unit/codematch_blindness_restore_surface.test.jsnode --test test/unit/command_quaff_prompt.test.jsnode test/comparison/session_test_runner.js test/comparison/sessions/seed303_caveman_selfplay200_gameplay.session.json
2026-03-11: invent.c surface de-stub batch (display_used_invlets/doperminv/dotypeinv/dounpaid/menu_identify/reroll_menu)
- Problem:
- Several
invent.ccompatibility surfaces were still explicit stubs ininvent.js, andcount_unidentified()still used linked-list iteration against JS arrays.
- Several
- Change:
js/invent.js:count_unidentified()now correctly counts against JS array inventories.display_used_invlets()now reports current used inventory letters on the message line.doperminv()now togglesflags.perm_invent, invokesperm_invent_toggled(), and emits an on/off status message.dotypeinv()now emits current inventory categories/items (instead of no-op stub).dounpaid()now lists unpaid carried items (including container-held unpaid contents) and reports the no-unpaid case.menu_identify()now routes intoidentify_pack()with a normalized limit;reroll_menu()now reuses that path.
docs/CODEMATCH.md:- Updated the above
invent.crows fromImplemented (stub)toImplemented.
- Updated the above
- Added
test/unit/invent_surface_behavior.test.jscovering these surfaces.
- Validation:
node --test test/unit/invent_surface_behavior.test.jsnode --test test/unit/codematch_batch_sweep.test.jsnode test/comparison/session_test_runner.js test/comparison/sessions/seed303_caveman_selfplay200_gameplay.session.json
2026-03-11: monmove mind_blast hero-effect fidelity slice
- Problem:
mind_blast()inmonmove.jswas still marked partial with two gameplay-significant TODOs: hero damage application and hidden/disguised hero reveal side effects.
- Change:
js/monmove.js:- Exported
mind_blastfor focused unit testing. - Wired hero damage path to
mdamageu(...)so lock-on now applies real HP loss/death handling instead of a no-op. - Ported C-faithful reveal side effects before damage:
- clear
uundetectedand refresh withnewsym, or - clear non-monster
m_ap_typedisguise (mappearance=0) and refresh withnewsym.
- clear
- Added lock-on message text by lock source (
telepathy/latent telepathy/mind). - Replaced monster-target wakeup simplification (
sleeping=false) withwakeup(...)call before damage. - Updated
dochugcallsite to passgameintomind_blastfor full damage/death context.
- Exported
docs/CODEMATCH.md:- Refreshed
mind_blastrow notes to remove the old hero-damage/unhide TODOs and document remaining partial gaps precisely.
- Refreshed
- Added tests:
test/unit/monmove_mind_blast.test.js.
- Validation:
node --test test/unit/monmove_mind_blast.test.jsnode --test test/unit/monmove.test.jsnode --test test/unit/codematch_batch_sweep.test.jsnode test/comparison/session_test_runner.js test/comparison/sessions/seed303_caveman_selfplay200_gameplay.session.json
2026-03-11: monmove mind_blast victim lock-on messaging + wakeup parity
- Problem:
mind_blaststill lacked C-style visible victim lock-on text and used incomplete wakeup semantics in the monster-victim loop.
- Change:
js/monmove.js:- In monster-victim branch, call
wakeup(m2, false, map, player)before damage. - Emit C-style lock-on text when victim is visible:
It locks on to <monster>.with visibility gated by currentfov(fallbackcouldsee). - Imported/used
mon_namfor victim naming.
- In monster-victim branch, call
- Expanded tests in
test/unit/monmove_mind_blast.test.jsto cover visible-victim messaging + wakeup side effect. - Updated
docs/CODEMATCH.mdmind_blastrow to reflect this closure.
- Validation:
node --test test/unit/monmove_mind_blast.test.jsnode --test test/unit/monmove.test.jsnode --test test/unit/codematch_batch_sweep.test.jsnode test/comparison/session_test_runner.js test/comparison/sessions/seed303_caveman_selfplay200_gameplay.session.json
2026-03-11: mind_blast victim-message experiment rollback (parity hygiene)
- Observation:
- Attempting to add visible-victim lock-on text (
"It locks on to ...") inside the monster-victim branch increased screen drift risk in active wizard sessions.
- Attempting to add visible-victim lock-on text (
- Action:
- Kept the validated hero-side fidelity improvements from the prior
mind_blastslice. - Rolled back the victim lock-on message emission and retained minimal wake semantics (
sleeping=false) in the victim loop pending tighterwakeup()parity work. - Updated
docs/CODEMATCH.mdnotes to reflect current, accurate status.
- Kept the validated hero-side fidelity improvements from the prior
- Validation:
node --test test/unit/monmove_mind_blast.test.jsnode --test test/unit/monmove.test.js
2026-03-11: revive_corpse floor-visibility messaging parity (seed322 screen fix)
- Problem:
seed322_barbarian_wizard_gameplaydiverged on screen-only output at step 375: JS showedorc zombie rises from the dead!while C session had no top-line message.- Root cause:
js/do.js::revive_corpse()used!blindand always emitted"rises from the dead"for floor revivals, instead of C’s visibility-gated branch.
- Change:
js/do.jsrevive_corpse()now followsdo.cbehavior forOBJ_FLOORrevival messaging:- gate messaging on
cansee(corpse_x, corpse_y) || canseemon(mtmp), - emit
"X rises from the dead"only whencanseemon(mtmp), - otherwise emit
"The <corpse name> disappears".
- gate messaging on
- Added proper corpse naming via
corpse_xname(..., { singular: true })andThe(...). - Switched this callsite to display-context-aware
canseemonimport.
- Validation:
node test/comparison/session_test_runner.js --verbose test/comparison/sessions/seed322_barbarian_wizard_gameplay.session.jsonscripts/run-and-report.sh --failures(gameplay34/34passing)
2026-03-11: prayer lava-trouble rescue path no longer silently no-ops
- Problem:
fix_worst_trouble(TROUBLE_LAVA)calledrescued_from_terrain()inpray.js, but that helper was an empty stub.- This meant divine rescue after lava trouble did not emit the terrain context feedback expected by C.
- Change:
- Implemented
rescued_from_terrain(how, player, map)injs/pray.js(C ref:trap.c:4922):- handles
DROWNINGwith water/air-specific messaging, - handles
BURNING/DISSOLVEDwith water/lava messaging, - falls back to a grounded status message when no terrain-specific branch applies.
- handles
- Wired
fix_worst_trouble(TROUBLE_LAVA)to call the helper with a computedhowbased on current tile (pool/lava/fallback). - Updated CODEMATCH row for
rescued_from_terrainfromSTUBto implemented.
- Implemented
- Validation:
node --test test/unit/codematch_batch_sweep.test.jsnode test/comparison/session_test_runner.js --verbose test/comparison/sessions/seed033_manual_direct.session.jsonscripts/run-and-report.sh --failures(gameplay34/34passing)
2026-03-11: mapseen_temple parity closure (remove last explicit STUB row)
- Problem:
mapseen_templeinpriest.jswas an explicit stub despite being called fromintemple().- CODEMATCH still had this as the last explicit
STUBrow.
- Change:
- Added
Is_valley()helper indungeon.js(matching C concept of Valley level identity). - Implemented and exported
mapseen_temple()inpriest.js:- resolves current level/mapseen entry via
find_mapseen, - sets
mptr.flags.valleywhen on valley level, - sets
mptr.flags.msanctumwhen on sanctum level.
- resolves current level/mapseen entry via
- Updated
intemple()callsite to pass explicit map/game context. - Added unit test
test/unit/priest_mapseen_temple.test.js. - Updated CODEMATCH row
mapseen_templefromSTUBtoImplemented.
- Added
- Validation:
node --test test/unit/priest_mapseen_temple.test.jsnode --test test/unit/codematch_batch_sweep.test.jsscripts/run-and-report.sh --failures(gameplay34/34passing)
2026-03-11: trap dng_bottom/hole_destination context hardening
- Problem:
- While auditing hole/trapdoor destination parity,
trap.js::dng_bottom()still referenced undefined quest-era symbols (In_quest,qlocate_level,dunlev_reached) and could throw when called without explicit player context. trap.js::hole_destination()also assumed fully-populated map/player context.
- While auditing hole/trapdoor destination parity,
- Change:
js/trap.js:dng_bottom()now uses imported dungeon primitives and safe player fallback (_gstate.player) for invocation gating.- Removed undefined quest-symbol references from runtime path (avoids latent
ReferenceError). hole_destination()now resolves map/player context defensively before computing destination depth.
js/dungeon.js:- generation-side
dng_bottom()/hole_destination()now prefermap.uzwhen present and honor invocation state from map/game/gstate fallbacks.
- generation-side
- Added regression test in
test/unit/codematch_batch_sweep.test.jsfor missing-player invocation behavior.
- Validation:
node --test test/unit/codematch_batch_sweep.test.jsnode test/comparison/session_test_runner.js --verbose test/comparison/sessions/seed033_manual_direct.session.jsonscripts/run-and-report.sh --failures(gameplay34/34passing)
2026-03-11: dungeon hole_destination quest cutoff closure
- Problem:
dungeon.jsgeneration-sidedng_bottom()only applied quest cutoff when both ad-hoc debug fields were present, leaving normal quest generation paths under-specified.
- Change:
dungeon.jsdng_bottom()now applies quest cutoff using canonical quest locate depth fallback (_questLocaDlevel) and a conservative reached-depth fallback (_dunlevReachedwhen available, otherwise current generated level).- Keeps C-style Gehennom invocation gating and hole RNG walk semantics unchanged.
- Updated CODEMATCH
hole_destinationrow toImplemented.
- Validation:
node --test test/unit/codematch_batch_sweep.test.jsscripts/run-and-report.sh --failures(gameplay34/34passing)
2026-03-11: anti-magic trap monster-visibility gating parity
- Problem:
trapeffect_anti_magic_monintrap.jsdid not follow C visibility gating:- trap reveal (
seetrap) and lethargy feedback were not tied toin_sight, see_itmap refresh behavior after damage was missing.
- trap reveal (
- Change:
trapeffect_anti_magic_mon(...)now takesfovand applies C-shaped gating:in_sight = canseemon(mon, player, fov) || mon===usteedfor reveal/message paths,see_it = cansee(mon.x, mon.y)fornewsymrefresh on damage branch.
- Added visible lethargy message for the non-resistant magical-attacker energy-drain case.
- Added in-sight-only death reason text (
compression from an anti-magic field) on kill path. - Updated selector call to pass
fovinto the anti-magic branch.
- Validation:
node --test test/unit/codematch_batch_sweep.test.jsnode test/comparison/session_test_runner.js --verbose test/comparison/sessions/seed033_manual_direct.session.jsonscripts/run-and-report.sh --failures(gameplay34/34passing)
2026-03-11: polymorph trap monster visibility-gating parity
- Problem:
trapeffect_poly_trap_monstill revealed traps unconditionally after failed resistance, while C gates trap reveal on monster visibility (in_sight).
- Change:
trapeffect_poly_trap_monnow acceptsplayer/fov, computes C-shapedin_sight, and gatesseetrap(trap)on visibility.- Updated selector dispatch to pass
player/fovinto the polymorph trap monster branch. - Kept polymorph transform behavior unchanged (still pending broader
newchamparity in trap callchain).
- Validation:
node --test test/unit/codematch_batch_sweep.test.jsscripts/run-and-report.sh --failures(gameplay34/34passing)
2026-03-11: trap/zap deltrap callsite argument-order correctness pass
- Problem:
- Multiple trap/zap callsites invoked
deltrap(...)with wrong argument order or missing map context, despite canonical signaturedeltrap(map, trap). - These paths could silently fail to remove traps (or remove from wrong context), especially in rolling-boulder and cancellation flows.
- Multiple trap/zap callsites invoked
- Change:
- Corrected
deltrapcallsites injs/trap.jsandjs/zap.jsto consistently pass(map, trap). - Added
_gstate.mapfallback where local map context was absent but trap removal was still required. - Added regression test in
test/unit/codematch_batch_sweep.test.js:zap.maybe_explode_trap removes magical trap on cancellation.
- Corrected
- Validation:
node --test test/unit/codematch_batch_sweep.test.jsscripts/run-and-report.sh --failures(gameplay34/34passing)
2026-03-11: dodip fountain/sink object-first flow correction
- Problem:
potion.js::dodip()incorrectly required a potion first ("don't have anything to dip into"), even when standing on a fountain/sink.- C
dodip()is object-first and offers floor-feature dip prompts before potion selection.
- Change:
js/potion.jsdodip()now:- selects dip target object first,
- detects local fountain/sink/pool context,
- asks
Dip it into the fountain/sink?and executesdipfountain/dipsinkony, - falls back to potion-dip path only after declining/absence of floor feature dip.
- Added
can_reach_floorandis_poolgating consistent with C control flow shape.
- Notes:
- This fixes the incorrect hard-fail mode and unblocks floor-feature dip behavior.
- Full C prompt parity for
#dipstill depends on interactivegetobj()(object-letter prompt handling), which remains a separate gap.
- Validation:
./scripts/run-session-tests.sh(151/151passing)
2026-03-11: #dip prompt parity + dipfountain erosion topline alignment
- Problem:
dodip()still did not consume inventory-letter input in C order (What do you want to dip?), causing extcmd boundary mismatches.- Fountain dip confirmation prompt was generic (“Dip it…”) instead of object-specific.
dipfountain()left the prior prompt topline when the first effect was item erosion (missingYour ... rusts!topline), producing screen-only drift.
- Change:
js/potion.js:- Added local interactive getobj prompt helper for
dodip():- prompts
What do you want to dip? [.. or ?*], - consumes explicit inventory-letter key via
nhgetch(), - then asks object-specific
Dip <object> into the fountain/sink?.
- prompts
- Fixed
ynFunctiondefault argument typing (charCodeAt(0)).
- Added local interactive getobj prompt helper for
js/fountain.js:- In
dipfountain(), when forced water damage erodes the dipped object, emitYour <object> rusts!topline to match C-visible outcome ordering.
- In
- Validation:
node test/comparison/session_test_runner.js --no-parallel --verbose /tmp/theme01_seed005_fountain_real_dip_trial.session.jsonrng=2952/2952events=460/460screens=30/30
- Added parity-green fixture:
test/comparison/sessions/coverage/furniture-thrones-fountains/t01_s005_v_frealdip1_gp.session.json
2026-03-11: extcmd sit support + completion timing parity guard
- Problem:
#sitwas missing from extended-command dispatch incmd.js.- Extcmd display completion could over-expand 1-char prefixes, causing
screen-boundary mismatch (
# sitshown too early where C still shows# s).
- Change:
- Added
sitextcmd mapping incmd.jstodosit(...). - Added completion guard in
displayCompletedExtcmd():- keep
# dliteral while entering#dip, - keep
# sliteral while entering#sit, - retain one-char auto-expansion for other unique prefixes (
#u→untrap,#l→loot) used by existing parity fixtures.
- keep
- Added
- Validation:
node test/comparison/session_test_runner.js --no-parallel --verbose /tmp/theme01_seed005_sit_floor_trial.session.json- full parity green (
rng/events/screens/cursorall matched).
- full parity green (
- Added parity-green fixture:
test/comparison/sessions/coverage/furniture-thrones-fountains/t01_s005_v_sit1_gp.session.json
2026-03-11: #dip no-potion branch needs object-specific topline
- Problem:
- In
dodip(), when selecting an object to dip but having no potion to dip into, JS emitted a generic topline:You don't have anything to dip into. - C/getobj behavior is object-specific in this branch; parity trial on sink interaction
expected
You don't have anything to dip <object> into.
- In
- Change:
js/potion.js(dodip):- changed null-potion message to use
doname(obj, player):You don't have anything to dip <object> into.
- changed null-potion message to use
- Validation:
node test/comparison/session_test_runner.js --verbose /tmp/theme01_seed032_sink_trial.session.json- rng/events/screens now fully matched for this branch; remaining mismatch is mapdump-only (
Wcell), unrelated to topline wording.
- rng/events/screens now fully matched for this branch; remaining mismatch is mapdump-only (
./scripts/run-session-tests.sh151/151passed.
2026-03-11: harden spoteffects runtime context to unblock pending polymorph session
- Problem:
seed503_extcmd_monster.session.jsoncrashed at runtime withCannot read properties of undefined (reading 'at')fromhack.js::spoteffects.polyselfandpotionpaths could callspoteffectswithout full(player, map, display, game)context.
- Change:
js/hack.js::spoteffectsnow resolves missing context from_gstate(player/map/display/game) and returns early if map access is unavailable.- Updated wrong-arity callsites:
js/polyself.js: pass map/display/game explicitly tospoteffects.js/potion.js: pass player/map/display/game explicitly for sink-while-levitating path.
- Result:
seed503_extcmd_monsternow replays to a normal parity divergence (no runtime crash), enabling real debugging instead of harness abort.
- Validation:
node test/comparison/session_test_runner.js --sessions=test/comparison/sessions/pending/seed503_extcmd_monster.session.json --parallel=1 --verbose- crash resolved; replay runs and reports first divergence.
scripts/run-and-report.sh --failures- gameplay baseline remains green (
41/41passing on rerun).
- gameplay baseline remains green (
2026-03-11: wizGenesis prompt parity improvements for pending extcmd chat session
- Problem:
seed501_extcmd_chatdiverged immediately on wizard genesis prompt wording and cursor spacing.- JS used
Create what monster?while C usesCreate what kind of monster?(with trailing prompt spacing).
- Change:
js/wizcmds.js::wizGenesisprompt updated to C wording and spacing.- Removed an extra post-creation message from
wizGenesisso creation output is driven by core monster-creation messaging paths.
- Result:
seed501_extcmd_chatscreen parity improved materially (screens 0/15 -> 7/15) and cursor parity now matches through the prompt phase (7/7).- Remaining divergence is deeper in creation/placement behavior
(
create_particular_creation/makemonpath), not prompt scaffolding.
- Validation:
node test/comparison/session_test_runner.js --sessions=test/comparison/sessions/pending/seed501_extcmd_chat.session.json --parallel=1 --verbosescripts/run-and-report.sh --failures- gameplay baseline preserved (
41/41passing).
- gameplay baseline preserved (
2026-03-11: stabilize potion.dodip headless/unit path after prompt refactor
- Problem:
test/unit/codematch_batch_sweep.test.jsregressed atpotion.dodip performs a dip turn when potion and target exist.- Root causes after
dodipprompt-flow refactor:- unconditional map-tile probe without guaranteed player coordinates,
- local prompt helper requiring
display.putstr_messagein headless tests.
- Change:
js/potion.jsdodip()now gates floor-tile checks on valid integerplayer.x/player.y.getobj_prompt_local()now has a headless fallback: if no display prompt API exists, select first valid object (unit-path only).
- Validation:
node --test test/unit/codematch_batch_sweep.test.js(23/23passing).scripts/run-and-report.sh --failures(gameplay41/41passing).
2026-03-11: extcmd chat parity brought to green and promoted from pending
- Problem:
seed501_extcmd_chatdiverged in wizard#genesisflow and monster chat wording/cursor details.- Key misses vs C:
wizGenesisplacement path was custom-scanning adjacent squares instead of C’screate_particular_creation()->makemon(u.ux,u.uy,MM_NOEXCLAM)shape.domonnoiseusedx_monnam()where C usesMonnam()forpline_msg.dotalkprompt missed trailing space inTalk to whom? (in what direction).- tame checks in
sounds.jsrelied onmtmp.tame(boolean in some JS paths), but C logic uses numericmtamethresholds.
- Change:
js/wizcmds.js:- make
wizGenesiscallmakemon(..., player.x, player.y, MM_NOEXCLAM, ...)and rely onmakemonplacement selection like C.
- make
js/cmd.js:- keep
# cand# chliteral during extcmd typing so chat echo matches C.
- keep
js/sounds.js:- normalize tame semantics via numeric
tameLevel/isTame. - emit
Monnam(mtmp)forpline_msgmonster sounds. - add trailing space to
dotalkdirection prompt.
- normalize tame semantics via numeric
- Session workflow:
- promoted green session from pending to coverage:
test/comparison/sessions/coverage/monster-ai-combat/seed501_extcmd_chat.session.json.
- promoted green session from pending to coverage:
- Validation:
node test/comparison/session_test_runner.js --sessions=test/comparison/sessions/coverage/monster-ai-combat/seed501_extcmd_chat.session.json --parallel=1 --verbose- pass (
rng/events/screens/colors/cursorall full).
- pass (
npm run test:session- pass (
159/159).
- pass (
scripts/run-and-report.sh --failures- gameplay baseline remains green (
41/41).
- gameplay baseline remains green (
2026-03-11: split wizard level-port vs level-change semantics (pending seed500)
- Problem:
seed500_extcmd_enhancewas diverging early with prompt/action mismatch: JS drove#levelchangethrough dungeon-level teleport flow, while C uses#levelchangefor experience-level changes and^V(wizlevelport) for dungeon-level teleport.
- Change:
js/wizcmds.js:- added
wizLevelPort(game)for Cwiz_level_telesemantics (dungeon level teleport prompt). - rewired
wizLevelChange(game)to callwiz_level_change(player, display)(experience-level prompt:To what experience level do you want to be set?). - fixed missing dependencies in
wiz_level_changepath (MAXULEV,pluslvl,ECMD_OK).
- added
js/cmd.js:- changed
Ctrl+Vwizard binding fromwizLevelChangetowizLevelPort. - kept extcmd autocompletion C-faithful for this path: bare
#eresolves to#enhance. - for wizard
#enhancewith no advanceable skills, added C-likeAdvance skills without practice? [yn] (n)prompt gate.
- changed
- Result:
seed500parity improved from deep RNG/event divergence to pure screen-only tail mismatch:- before:
rng 3062/5356,events 34/70 - now:
rng 3073/3073,events 34/34(remainingscreens 27/29).
- before:
- Main gameplay baseline remains green.
- Validation:
node test/comparison/session_test_runner.js --sessions=test/comparison/sessions/pending/seed500_extcmd_enhance.session.json --parallel=1 --verbosescripts/run-and-report.sh --failures(42/42gameplay passing on this branch state).
2026-03-11: close seed500_extcmd_enhance with C-faithful wizard enhance menu rendering
- Problem:
- After fixing wizard levelchange semantics,
seed500_extcmd_enhancestill had a final screen/cursor mismatch in wizard#enhanceno-practice flow. - RNG/events were fully aligned; only menu presentation differed.
- After fixing wizard levelchange semantics,
- Change:
js/cmd.js(#enhancepath, wizard + no advanceable skills + accepted prompt):- switched from
putstr_messagelist rendering to C-shaped overlay menu lines. - rendered
Current skills: (<slots> slots available)prompt + grouped sections (Fighting Skills,Weapon Skills,Spellcasting Skills) with wizard-style skill columns (practice(needed)). - applied single-page first-view pagination marker formatting (
(1 of N)), plus explicit cursor placement on marker row and proper prompt-state clear before overlay render. - restored map/status/message window after menu dismissal to preserve boundary behavior.
- switched from
js/display.js,js/headless.js:- recognized skill-section headers as menu category headers so inverse-video header rendering/indent match C tty behavior.
js/weapon.js:- added
skill_practice_value(skill)accessor for wizard-column formatting.
- added
- Result:
seed500_extcmd_enhancenow full parity (rng/events/screens/colors/cursorall matched).- This was achieved without comparator exceptions or replay_core masking.
- Validation:
node test/comparison/session_test_runner.js --sessions=test/comparison/sessions/pending/seed500_extcmd_enhance.session.json --parallel=1 --verbose-> PASSnpm run test:session->159/159PASS.
2026-03-11: seed503 controlled-polymorph path bring-up (partial)
- Problem:
seed503_extcmd_monsterdiverged at blessed polymorph control flow: C promptsBecome what kind of monster? [type the name], while JS jumped through random/non-interactive polymorph behavior.
- Change:
js/polyself.js:- implemented controlled-polymorph
getlinprompt loop (C-shaped) usingname_to_mon+ validation for explicit monster-form selection. - applied C low-control forcecontrol suppression for special cases and moved
draconian/were/vamp special-branch gating under
mntmp < LOW_PM.
- implemented controlled-polymorph
js/timeout.js:- hardened
learn_egg_type()to avoid runtime crashes when called without initializedgame.mvitalscontext.
- hardened
js/potion.js:- added a controlled-poly
more()boundary call before entering polymorph prompt path (boundary timing still under investigation).
- added a controlled-poly
- Result:
- Session moved forward (more matched screen/rng prefix) and no longer crashes
on
little_to_big/mvitalspath. - Remaining mismatch is still in prompt boundary timing + downstream RNG.
- Session moved forward (more matched screen/rng prefix) and no longer crashes
on
- Validation:
node test/comparison/session_test_runner.js --sessions=test/comparison/sessions/pending/seed503_extcmd_monster.session.json --parallel=1 --verbose- improved but still failing (
seed503remains open).
- improved but still failing (
npm run test:session- baseline still green (
159/159).
- baseline still green (
2026-03-11: seed503 boundary and RNG-shape alignment (incremental, no regressions)
- Problem:
seed503_extcmd_monsterstill diverged around blessed polymorph prompt boundary and post-transform RNG stream shape.- JS was drawing
getlinprompt over a pending topline message and logging polymorph HP dice as per-diern2calls instead of C-style composited().
- Change:
js/input.js(getlin):- before painting prompt, consumes pending topline
--More--viamore()whendisplay.messageNeedsMoreis set. - renders prompt with C-style separator spacing (
"...] <typed>") and matching cursor placement.
- before painting prompt, consumes pending topline
js/polyself.js:- switched polymon HP dice from
d(...)toc_d(...)for C codepaths (d(mlvl,4)dragon andd(mlvl,8)default), matching C RNG-log shape.
- switched polymon HP dice from
js/potion.js:- removed non-C explicit
await more()frompeffect_polymorph. - replicated C
min()macro side-effect behavior foru.mtimedone = min(u.mtimedone, rn2(15)+10)sorn2(15)can be evaluated twice when second arm is selected.
- removed non-C explicit
- Result:
seed503improved:- RNG matched prefix advanced
2748 -> 2753. - prompt timing/spacing and cursor boundary around controlled polymorph now
align with C (
--More--before prompt; no early prompt overwrite).
- RNG matched prefix advanced
- Remaining
seed503work is deeper (post-polymorph per-turn ordering and missingexercise/glyph placement effects), but this slice is stable.
- Validation:
node test/comparison/session_test_runner.js --sessions=test/comparison/sessions/pending/seed503_extcmd_monster.session.json --parallel=1 --verbose- improved but still failing (
rng=2753/2779,screens=51/63).
- improved but still failing (
npm run test:session- full suite remains green (
160/160).
- full suite remains green (
2026-03-11: seed503 polymorph equipment-drop slot fix (incremental)
- Problem:
seed503_extcmd_monsterstill missed C^place[148,...]during polymorph equipment handling and had event-stream lag downstream.- Root cause:
polyself.break_armor()mixed C slot names (uarmc/uarmh/...) with this JS model’s actual fields (cloak/helmet/gloves/shield/boots), so several removal branches no-op’d.
- Change:
js/polyself.js:- corrected break_armor slot references to live player fields.
- awaited previously un-awaited armor removals.
- hardened
dropp()to resolve map fromplayer.map/gstatewhen not explicitly supplied.
js/o_init.js:- tightened
discoverObject()wisdom exercise behavior to C shape by removing JS-only class-known gating while preserving in-game guard (turnCount > 0) to avoid chargen drift.
- tightened
js/potion.js:- marked selected quaff item as
dknownbefore effect resolution (inventory-selected object parity with Cotmp->dknownflow).
- marked selected quaff item as
- Result:
seed503event parity improved (26/54 -> 32/54), including expected^place[148,...]alignment before^place[79,...].- Remaining first divergence unchanged at step 54 (
rn2(19)exercise call beforemcalcmove) and hero-glyph screen mismatch still open.
- Validation:
node test/comparison/session_test_runner.js --sessions=test/comparison/sessions/pending/seed503_extcmd_monster.session.json --parallel=1 --verbose- still failing, but with improved event alignment.
npm run test:session- full suite remains green (
160/160).
- full suite remains green (
2026-03-11: discovery-credit parity for startup vs in-moveloop (seed503 advance)
- Problem:
seed503still missed one Cexercise(A_WIS, TRUE)RNG call after polymorph potion resolution.- Root cause: JS discovery-credit behavior diverged from C:
- startup/role-preknowledge paths were calling
discoverObject(..., credit=true)(should not credit hero), - runtime
discoverObjecthad extra JS-only gating which could suppress C-expected WIS exercise.
- startup/role-preknowledge paths were calling
- Change:
js/u_init.js:- startup discovery/preknowledge callsites now pass
creditClue=falseexplicitly, matching C’s non-hero-credit startup behavior.
- startup discovery/preknowledge callsites now pass
js/o_init.js:- removed JS-only class-known gating from WIS exercise trigger.
discoverObjectnow follows C shape: whenmarkAsKnownandcreditClueare true, credit hero withexercise(A_WIS, TRUE).
- Result:
seed503RNG prefix improved2753 -> 2758.- First RNG mismatch moved deeper from missing
rn2(19)to laterdistfleeck-adjacent divergence (rn2(5)in C with no JS counterpart). - Remaining known screen mismatch (
@vsD) remains open.
- Validation:
node test/comparison/session_test_runner.js --sessions=test/comparison/sessions/pending/seed503_extcmd_monster.session.json --parallel=1 --verbose- improved, still failing at a deeper index (
rng=2758/2779).
- improved, still failing at a deeper index (
npm run test:session- full suite green on current tree (
173/173).
- full suite green on current tree (
2026-03-11: seed503 fully green (movement RNG + polymorph/status fidelity)
- Problem:
seed503_extcmd_monsterhad been stuck with:- missing/misaligned monster-turn RNG around step 54,
- polymorph screen mismatches (
@vsD, stale title/HP/HD/AC), #monsterstub text mismatch instead of breath-energy failure text.
- Changes:
js/allmain.jsu_calc_moveamt()now uses current form movement (youmonst.data->mmove) instead of cached hero speed field, matching Cu_calc_moveamt.
js/mthrowu.js- added C
m_lined_up()polymorph concealment gate:utarget && Upolyd && rn2(25) && (...). - This restores required RNG consumption even when concealment is false.
- added C
js/display.js- player map glyph now uses polymorph form symbol/color when
Upolyd(instead of always@).
- player map glyph now uses polymorph form symbol/color when
js/render.js- polymorph-aware status formatting:
- title shows current form name (
Wizard the Red Dragon), - polymorph HP (
mh/mhmax) andHD:<mlevel>, - retains form state markers (e.g.
Fly).
- title shows current form name (
- polymorph-aware status formatting:
js/do_wear.jsfind_ac()now starts from current form AC (mons[u.umonnum].acshape) when polymorphed, not always human base AC.
js/polyself.jsdrop_weapon()message now uses object name (xname) so text matches C (...drop your quarterstaff!).
js/cmd.js#monsterbreath path now dispatches todobreathe()for valid polymorph breath forms (can_breathe && AT_BREA) instead of always returning stub text.
- Result:
seed503_extcmd_monsteris now fully green:- RNG
2779/2779 - Events
54/54 - Screens
63/63 - Colors
1512/1512 - Cursor
63/63
- RNG
- Validation:
node test/comparison/session_test_runner.js --no-parallel --verbose test/comparison/sessions/pending/seed503_extcmd_monster.session.json- pass, full parity.
node test/comparison/session_test_runner.js --no-parallel --verbose test/comparison/sessions/seed033_manual_direct.session.json- pass (guard check after
#monsterdispatch tightening).
- pass (guard check after
2026-03-11: C-faithful immobile-turn draining in run_command (pending session lift)
- Problem:
- Pending session
t06_s620_w_qheal_gpdiverged at step 131 withmoveloop_turnendvsgethungry, and JS ended the step without the C-side wakeup/monster-turn sequence. - Root cause: JS
run_command()only auto-advanced timed turns formulti > 0(repeat commands), but Cmoveloop()also keeps advancing turns without fresh input whilemulti < 0(sleep/paralysis/immobile states).
- Pending session
- Changes:
js/allmain.js:- added
drainImmobileTurns()inrun_command()and invoked it after the first post-command timed turn (advanceTimedTurn()), looping whilegame.multi < 0.
- added
js/timeout.js:- corrected
fall_asleep()to C shape:- sets
game.multivia nomul-like semantics, - sets
game.multi_reason = "sleeping", - sets
player.usleepandgame.nomovemsg("You wake up."or"You can move again.").
- sets
- corrected
js/potion.js:- migrated potion immobilization/sleep paths from legacy
player.sleepTimeoutbookkeeping tofall_asleep(...):ghost_from_bottle,peffect_sleeping,peffect_paralysis,- legacy potion-side sleeping/paralysis branches.
- migrated potion immobilization/sleep paths from legacy
- Result:
t06_s620_w_qheal_gpflipped to full green.- Pending set improved from
2/12passing to3/12passing. - Promoted gameplay suite stayed fully green.
- Validation:
node test/comparison/session_test_runner.js --no-parallel --verbose --sessions=test/comparison/sessions/pending/t06_s620_w_qheal_gp.session.json,test/comparison/sessions/pending/t07_s640_w_pray1_gp.session.json,test/comparison/sessions/pending/t07_s641_w_prayt_gp.session.jsont06_s620...pass; two pray sessions still failing.
scripts/run-and-report.sh --pending --failures3/12passing,9/12failing.
scripts/run-and-report.sh --failures- full promoted gameplay remains green (
83/83).
- full promoted gameplay remains green (
2026-03-11: Fix booze confusion RNG drift via canonical hunger-state defaults
- Problem:
- Pending session
t06_s623_w_qmisc_gpfirst diverged at step 207 withrn2(2)mismatch inpeffect_booze. - Root cause: JS mixed hunger fields (
hungervsuhunger) and could enterpeffect_boozewith missinguhs, causing fallback0and wrong dice count (d(2,8)vs C-faithfuld(3,8)for defaultNOT_HUNGRY).
- Pending session
- Changes:
js/player.js- added C-style
uhungeraccessor aliasing canonicalhunger. - initialized
uhsandhungerStatetoNOT_HUNGRYin constructor.
- added C-style
js/potion.js- added defensive hunger-state derivation helper for booze confusion path.
peffect_boozenow uses derived hunger state whenuhsis absent.
- Result:
t06_s623_w_qmisc_gpnow has full RNG/event parity:- RNG
2584/2584 - Events
78/78
- RNG
- Remaining difference is screen-only prompt letters at step 226
(
[gho-u]vs[ho-u]), now isolated from RNG/event state drift.
- Validation:
node test/comparison/session_test_runner.js --verbose test/comparison/sessions/pending/t06_s623_w_qmisc_gp.session.json- RNG full, events full.
scripts/run-and-report.sh --failures- promoted gameplay still green (
92/92).
- promoted gameplay still green (
2026-03-11: Pending parity lift for sit/wear prompt semantics (t01_s650)
- Problem:
t01_s650_w_sit_gphad deep RNG/event divergence rooted in an early input boundary mismatch at ring-finger selection, then ended with a final screen text mismatch in#sitobject wording.
- Changes:
js/do_wear.js- ring-finger prompt now treats
Spaceas cancel forWhich ring-finger, Right or Left? [rl], matching Cyn_function(..., '\0')default/cancel behavior.
- ring-finger prompt now treats
js/sit.js- floor-object sit text now names the object C-faithfully (
the <xname>) instead of fallback pronounit.
- floor-object sit text now names the object C-faithfully (
- Result:
t01_s650_w_sit_gpflipped from heavy RNG/event drift to full parity:- RNG
3299/3299 - Events
389/389 - Screen
98/98
- RNG
- Validation:
node test/comparison/session_test_runner.js --verbose test/comparison/sessions/pending/t01_s650_w_sit_gp.session.json→ PASS.scripts/run-and-report.sh --failuresremained green (104/104).
2026-03-11: Fix blessed monster-detection redraw semantics (t06_s621, t06_s622)
- Problem:
- Pending wizard quaff sessions had residual screen/color drift while RNG/events were aligned.
t06_s621_w_qstat_gpdiverged at step 136 around monster detection prompt/map redraw behavior.t06_s622_w_qabil_gphad out-of-FOV remembered-glyph screen drift (resolved earlier in same cycle by remembered glyph handling).
- Changes:
js/potion.js:peffect_monster_detection()now matches Cpotion.cflow:- scans map squares for invisible glyph cleanup (
unmap_object+newsym); - detects
MON_ATpresence to clearpotion_unkn; - uses C-faithful
!swallowed && !Underwatergate beforesee_monsters()/You_feel("lonely.").
- scans map squares for invisible glyph cleanup (
- removed eager prompt row clearing before applying potion effects in quaff selection; prompt lifetime now matches C interaction timing.
js/display.js:- in out-of-FOV
newsym(), whenDetect_monstersis active, render detected monster glyph in inverse video (attr=1), matching C tty detected-monster presentation.
- in out-of-FOV
- Result:
- Pending gameplay sessions moved to full green:
6/6. - Full gameplay report is all green:
104/104, with PRNG/events/screen all100%.
- Pending gameplay sessions moved to full green:
- Validation:
node test/comparison/session_test_runner.js --verbose --sessions test/comparison/sessions/pending/t06_s621_w_qstat_gp.session.json→ PASS.scripts/run-and-report.sh --pending→6/6PASS.scripts/run-and-report.sh→ gameplay104/104PASS.
2026-03-11: Potion status delayed-killer parity + petrification cure call fix
- C-faithful status lifecycle tightening in
js/potion.js:make_sick()now mirrorspotion.cdelayed-killer semantics:- checks existing delayed killer (
find_delayed_killer(SICK)), - updates only when
xtime || !old || !kptr, - uses
KILLED_BYfor"#wizintrinsic"andKILLED_BY_ANotherwise, - deallocates delayed killer when sickness clears.
- checks existing delayed killer (
make_stoned()now mirrorspotion.cdelayed-killer lifecycle:- deallocates on cure,
- installs delayed killer on fresh petrification onset.
make_slimed()now deallocates delayed killer when sliming clears.
- Runtime bug fix in
js/eat.js:fix_petrification()now importsmake_stoned, passesplayercorrectly, and no-ops safely when player context is absent.- Before this fix,
fix_petrification()calledmake_stoned(...)without import and without player, which could fail at runtime on stoning-cure paths.
Validation:
node --test test/unit/potion_scroll_accuracy.test.js test/unit/codematch_timeout_surface.test.js test/unit/command_eat_occupation_timing.test.js test/unit/command_eat_invalid_choice.test.jsscripts/run-and-report.sh --pending --failures->6/6passing.
2026-03-11: C-faithful invisibility / see-invisible potion semantics
- Tightened
js/potion.jsto mirror Cpotion.cbehavior for:peffect_invisibility():- now applies C pre-message gate (
Invis || Blind || BInvis=>potion_nothing++, elseself_invis_message()), - keeps blessed/per-timeout invisibility flow,
- calls
newsym(u.ux,u.uy)equivalent, - adds cursed path message +
aggravate()+ clears permanent invis (FROMOUTSIDE).
- now applies C pre-message gate (
peffect_see_invisible():- now increments
potion_unknat entry, - emits C-shaped cursed/non-cursed taste messages,
- handles
POT_FRUIT_JUICEhunger path in this effect, - keeps blindness cure/non-cursed semantics,
- applies mimic/monster refresh path (
set_mimic_blocking(); see_monsters(); newsym(...)), - applies C post-message (
"can see through yourself...") andpotion_unkn--adjustment when applicable.
- now increments
- Added small shared helpers for hero blindness/invisibility state checks to keep property handling explicit.
Validation:
node --test test/unit/potion_scroll_accuracy.test.js test/unit/codematch_timeout_surface.test.jsscripts/run-and-report.sh --pending --failures->6/6passingscripts/run-and-report.sh --failures-> gameplay104/104passing
2026-03-11: C-faithful peffect_acid behavior and side effects
- Reworked
js/potion.js:peffect_acid()to match Cpotion.csemantics:- Acid-resistant path now uses C message flavor (
tangyunder hallucination, otherwisesour). - Non-resistant path now uses C damage distribution
d(cursed?2:1, blessed?4:8). - Damage now routes through
losehp(Maybe_Half_Phys(...), "potion of acid", KILLED_BY_AN)rather than local HP clamping, restoring proper lethal behavior. - Added C side effects:
exercise(A_CON, FALSE)on non-resistant burn.if (Stoned) fix_petrification();- unconditional
potion_unkn++bookkeeping.
- Acid-resistant path now uses C message flavor (
- Supporting integration:
- imported
Maybe_Half_Physfromhack.jsandfix_petrificationfromeat.js.
- imported
Validation:
node --test test/unit/potion_scroll_accuracy.test.js test/unit/codematch_timeout_surface.test.js test/unit/command_eat_occupation_timing.test.js test/unit/command_eat_invalid_choice.test.jsscripts/run-and-report.sh --pending --failures->6/6passingscripts/run-and-report.sh --failures-> gameplay104/104passing
2026-03-11: Additional C-faithful potion alignment (speed/ability/gain-level)
js/potion.jsparity tightening:peffect_speed():- added C wounded-legs fast-path for
POT_SPEED(heal legs, setpotion_unkn, return), - switched intrinsic-speed grant check to C-style
!(HFast & INTRINSIC)semantics, - preserves
FROMOUTSIDEgrant + message for non-cursed speed potion.
- added C wounded-legs fast-path for
peffect_gain_ability():- cursed branch now increments
potion_unknlike C, - added
Fixed_abil(sustain ability) gate to setpotion_nothingand skip gains.
- cursed branch now increments
peffect_gain_level():- cursed branch now increments
potion_unknbefore the simplified “uneasy feeling” path.
- cursed branch now increments
- Wired required dependencies (
heal_legs,INTRINSIC,FIXED_ABIL) for explicit C-meaningful checks.
Validation:
node --test test/unit/potion_scroll_accuracy.test.js test/unit/codematch_timeout_surface.test.js test/unit/command_eat_occupation_timing.test.js test/unit/command_eat_invalid_choice.test.jsscripts/run-and-report.sh --pending --failures->6/6passingscripts/run-and-report.sh --failures-> gameplay104/104passing
2026-03-11: C-faithful peffect_sickness branch parity
- Reworked
js/potion.js:peffect_sickness()to follow Cpotion.cbranch structure:- blessed path now emits stale-fruit-juice explanation and applies 1 HP damage for non-Healers,
- non-blessed path now branches on poison resistance and role (Healer immunity messaging),
- from-sink contaminant cause now selects
KILLED_BYvsKILLED_BY_ANconsistently, - stat-drain path now honors
Fixed_abiland usespoisontell+adjattribwhen applicable, - applies C-shaped HP damage paths (
rnd(10)+5*cursedor1+rn2(2)), - keeps hallucination cure with explicit C message (
You are shocked back to your senses!).
- Added required imports:
Role_if,PM_HEALER,poisontell.
Validation:
node --test test/unit/potion_scroll_accuracy.test.js test/unit/codematch_timeout_surface.test.js test/unit/command_eat_occupation_timing.test.js test/unit/command_eat_invalid_choice.test.jsscripts/run-and-report.sh --pending --failures->3/3passingscripts/run-and-report.sh --failures-> gameplay107/107passing
2026-03-11: Spellbook-known false negative from num_spells signature drift
- Fixed a live spell menu/count bug in
js/spell.js:num_spells()was callingspellid(i)without a player argument.- In JS, spell state is per-player (
player.spells), so this always behaved like “no known spells” at runtime callsites that correctly passedplayer.
- Updated
num_spellsto takeplayerand callspellid(player, i)C-faithfully for JS storage semantics. - Practical symptom before fix:
docast/getspellcould report “You don’t know any spells right now.” despite startup spellbooks.
Validation:
scripts/run-and-report.sh --failures-> gameplay110/110passingscripts/run-and-report.sh --pending --failures-> still0/3(same pending frontier; no regression)
2026-03-11: docast/getspell menu text and wizard column alignment
- Tightened spell-cast menu rendering in
js/spell.js:docast()now prompts with C wording:"Choose which spell to cast".getspell()default prompt aligned to same C wording.- Added wizard-mode turns column (
... Retention turns) to match C wizard replay screens.
- This moved pending cast replay comparison deeper (from top prompt text mismatch to spell-failure percent mismatch), improving localization for the remaining divergence.
Validation:
scripts/run-and-report.sh --failures-> gameplay111/111passingscripts/run-and-report.sh --pending --failures-> still failing pending frontier (t05_s692,t08_s700), witht05_s692now past prompt/layout mismatches
2026-03-11: Spell casting state alignment (pw) and wizard school baseline
- Corrected two structural spellcasting mismatches in
js/spell.js:- Wizard starting spell-school baseline in fail-rate estimation now matches C startup skill init (
attack+enchantmentonly). spelleffects()now reads/writes mana fromplayer.pw/pwmax(withpower/powermaxfallback) instead of relying onplayer.power.
- Wizard starting spell-school baseline in fail-rate estimation now matches C startup skill init (
- This removes a JS-only “out of mana” false path and advances pending cast replay localization deeper into directional spell dispatch ordering.
Validation:
scripts/run-and-report.sh --failures-> gameplay111/111passingnode test/comparison/session_test_runner.js --verbose test/comparison/sessions/pending/t05_s692_w_cast_gp.session.json:- improved from
2738/3095matched RNG to2785/3095 - first divergence moved from early spell-menu checks to directional spell execution boundary
- improved from
2026-03-11: C-faithful directional spell dispatch (spelleffects/weffects)
- Ported the core C
spell.c spelleffects()flow for wand-duplicate spells:- success check ordering now matches C (
rnd(100)failure check before post-check exercise), - success/failure energy deductions use C-style behavior (
fullon success,halfon fail), - creates a pseudo spell object via
mksobj(otyp, FALSE, FALSE), - directional spells now call
getdir("In what direction?")and dispatch throughweffects(pseudo, ...).
- success check ordering now matches C (
- Fixed a hidden direction-loss bug in
js/zap.js:zapsetup(player)was zeroingdx/dy/dz, which skippedbhit(..., rn1(8,6)).- now preserves existing direction unless explicit overrides are passed.
- Added spell menu overlay lifecycle cleanup:
- clear cast-menu overlay back to map on selection/cancel,
- clear direction prompt row after direction input,
- keep status row in sync after mana changes during casting checks.
Validation:
node test/comparison/session_test_runner.js --verbose test/comparison/sessions/pending/t05_s692_w_cast_gp.session.json- now passes full gameplay channels (
rng/events/screens/colorsall matched)
- now passes full gameplay channels (
scripts/run-and-report.sh --pending --failures-> now1/2passing (t05fixed,t08_s700still open)scripts/run-and-report.sh --failures-> gameplay111/111passing
2026-03-11: C-faithful pick-axe apply boundary (unwielded case)
- Fixed a key C flow gap in
js/apply.jsforuse_pick_axeparity:- C behavior: when applying an unwielded pick-axe/mattock, hero first wields it,
that turn consumes time, then a
--More--/next-key boundary happens before the direction prompt for digging. - JS previously continued to direction handling in the same command boundary, which
shifted turn work and caused pending session
t08_s700_w_apply_gpdivergence.
- C behavior: when applying an unwielded pick-axe/mattock, hero first wields it,
that turn consumes time, then a
- New JS behavior:
- unwielded pick-axe apply now takes a timed turn immediately after wield;
- installs a two-phase pending prompt so next key dismisses the wield boundary, then direction input is handled on following key(s), matching C step distribution;
- mirror direction-cancel path no longer emits a spurious
Never mind.topline.
Validation:
node test/comparison/session_test_runner.js --verbose test/comparison/sessions/pending/t08_s700_w_apply_gp.session.json- now passes gameplay channels (
rng/events/screens/colors/mapdumpfully matched)
- now passes gameplay channels (
scripts/run-and-report.sh --pending --failures-> gameplay2/2passingscripts/run-and-report.sh --failures-> gameplay111/111passing
2026-03-11: pick-axe direction prompt made C-faithful (dynamic dig dir list)
js/apply.jspreviously used a fixed pick-axe prompt:In what direction do you want to dig? [njb>]
- C (
dig.c use_pick_axe) builds this list dynamically based on per-neighbor diggability (dig_typ) plus down-dig availability (can_reach_floor). - Updated JS to compute the prompt dynamically for pick-axe/mattock:
- scans planar directions in C order (
h y k u l n j b), - includes only directions where
dig_typ(...) != DIGTYP_UNDIGGABLE, - appends
>when floor-dig is reachable.
- scans planar directions in C order (
- This removed prompt-only screen drift in new dig-focused session recording while preserving existing passing coverage sessions.
Validation:
node test/comparison/session_test_runner.js --verbose test/comparison/sessions/pending/t04_s701_w_digedges_gp.session.json-> PASS (RNG/events/screens/colors all matched)node test/comparison/session_test_runner.js --verbose test/comparison/sessions/coverage/maze-mines-digging/t08_s700_w_apply_gp.session.json-> PASSscripts/run-and-report.sh --pending --failures-> gameplay1/1passingscripts/run-and-report.sh --failures-> gameplay113/113passingTheme06 quaff mapdump parity: engraving length + mkaltar flags (2026-03-11)
- New C quaff coverage captures (
seed620/621/623) initially failed mapdump only while all gameplay channels were full parity (rng/events/screens/cursorall matched). - Two root causes:
- Engraving
Elength semantics: JS emittedtext.length, but C mapdump emitsengr_lth-style length (includes NUL). This produced off-by-oneEdiffs like[11,4,3,19,0,0]vs[11,4,3,20,0,0]. - Altar union bits in
W:mklev.mkaltar()setloc.altarAlignonly and did not populate lowflagsbits (AM_*) as C does. Result: mapdumpWmismatches on altar squares (0vs1) despite full gameplay parity.
- Engraving
- Fixes:
js/dungeon.jsmapdumpEnow emits C-faithful engraving length (engr_lth/lthwhen present, fallbacktext.length + 1).js/mklev.js mkaltar()now writes altar bits intoloc.flagsviaAlign2amask()(and mirrorsloc.altarmask).js/dungeon.jsWprojection now also checksALTAR+altarmaskwhenwall_info/flagsare zero.
- Outcome:
theme06_seed620_wiz_quaff-healing_gameplaymapdump fixed (1/1).theme06_seed621_wiz_quaff-status_gameplaymapdump fixed (1/1).theme06_seed623_wiz_quaff-misc_gameplaymapdump fixed (1/1).- All three promoted from
sessions/pending/intosessions/coverage/spells-reads-zaps/.
2026-03-12: Theme06 seed632 parity fix (mthrowu broken-drop RNG + rust-trap mon messaging)
- Fixed pending session
theme06_seed632_wiz_zap-rays_gameplayto full parity. - Root cause 1 (RNG/event at step 124):
- In C,
mthrowu.c:drop_throw()broken-missile path callsdelobj(obj). invent.c:delobj_core()always evaluatesobj_resists(obj, 0, 0), which can consumern2(100).- JS broken-drop path returned early without consuming that RNG in this lifecycle.
- Fix: in
js/mthrowu.jsbroken branch, consumeobj_resists(obj, 0, 0)before return.
- In C,
- Root cause 2 (screen-only mismatch at step 185):
trapeffect_rust_trap_monmessage sequencing/content was not fully C-faithful for in-sight monster trap messaging and bimanual-left-arm handling.- Fixes in
js/trap.js:- make
trapeffect_rust_trap_monasync and await message emission, - pass
fovintocanseemon(...)for correct in-sight gating, - align per-bodypart messages and water-damage ordering with C behavior,
- include bimanual-weapon check for left-arm case before gloves fallback.
- make
Validation:
node test/comparison/session_test_runner.js --verbose test/comparison/sessions/pending/theme06_seed632_wiz_zap-rays_gameplay.session.json-> PASS (rng=2889/2889,events=232/232,screens=193/193,colors=4632/4632,mapdump=1/1).- Regression checks:
node test/comparison/session_test_runner.js --sessions=seed033_manual_direct.session.json,seed322_barbarian_wizard_gameplay.session.json,seed324_healer_wizard_gameplay.session.json,seed325_knight_wizard_gameplay.session.json-> all PASS.
2026-03-12: Theme01 sit-session coverage gains come from stable local scenarios
- Added C-recorded coverage session
t01_s651_w_sit2_gpfocused on#sitbranches that are deterministic on level 1:- sit on floor,
- sit while levitating (wished levitation boots),
- sit on dropped object.
- Initial variant that walked around level 1 caused large parity drift from incidental map interactions (teleport trap branch) and was discarded.
- Practical rule for coverage sessions in low-level command files (like
sit.js): keep scenario local and branch-targeted; avoid exploratory movement that introduces unrelated stochastic world events.
Validation:
t01_s651_w_sit2_gppasses full gameplay parity channels including mapdump.- Full gameplay suite remains green after promotion (
153/153). - Session-parity coverage improved:
- overall:
53.34/59.35/34.88->53.39/59.47/34.92(lines/branches/functions),
- overall:
sit.js:26.14/16.98/28.57->31.69/27.41/42.85.
2026-03-12: spell coverage via confusion-cast micro-session
- Added a short C-recorded spell session
t05_s693_w_castc_gp:- wish
potion of confusion, - quaff it,
- cast known spell (
Z a j) twice while confused.
- wish
- This targets
spell.jscast failure/aim pathways with minimal unrelated world simulation.
Practical lesson:
- A longer variant with idle/wait tail introduced unrelated pet pickup topline
drift (
black gemvsgem) despite full RNG/event parity. - For coverage sessions, stop as soon as target branch evidence is captured; avoid extra turns that only add stochastic cosmetic messages.
Validation:
- Session passes full gameplay parity channels (including mapdump).
- Full gameplay suite remains green.
- Coverage improved after promotion:
- overall:
53.39/59.47/34.92->53.44/59.52/35.01, spell.js:48.97/36.43/32.83->49.24/38.88/32.83.
- overall:
2026-03-12: Theme04 dig bundle extension (t04_s701_w_digext2_gp)
- Added C-recorded gameplay session
t04_s701_w_digext2_gpincoverage/maze-mines-digging/. - Replaced with stronger superset fixture
t04_s701_w_digext2_gpto keep suite runtime flat while adding directional branch coverage. - Method: start from a known parity-green dig trace (
t04_s701_w_digedges_gp) and extend incrementally with extra wand-of-digging directions (j,k,y,u,b,n), validating parity after each extension.
Why this approach:
- Directly authoring a larger raw key stream hit prompt-boundary drift
(
Invalid direction keyvsLoot in what direction?) and diverged late. - Incremental extension from a passing seed avoids unstable command-boundary interleavings while still adding new branch work.
Validation:
- Session passes full gameplay parity channels (RNG/events/screens/colors/mapdump).
- Full gameplay suite remains green with promoted replacement session:
202/202passing. - Coverage refresh snapshot now reports:
- overall
53.51/59.53/35.17(lines/branches/functions), - no tracked-file regressions.
- overall
2026-03-12: Steed parity blocker triage (#ride wiring + doride flow)
- While starting Theme03 coverage, a minimal C-recorded knight session using
#rideexposed an immediate parity blocker (/tmp/t03_s725_w_ride1_gp.session.json). - Root-cause findings:
#ridewas not wired injs/cmd.jsknown extcmds/switch.js/steed.js doride()was still a stub (never calledgetdir/mount_steed).
- Landed incremental fix:
- wired
rideextcmd incmd.js(dispatch + autocomplete list), - implemented C-shaped
dorideflow insteed.js:getdir()directional target,- wizard force prompt (
ynFunction), - calls
mount_steed(...), - returns ECMD flags.
- corrected
can_ride()default rider-form assumptions for non-polymorphed hero.
- wired
- Outcome:
- targeted steed micro-session still diverges (remaining
mount_steed/render state parity work needed), but divergence moved past command wiring into deeper ride state. - no regressions on canonical gameplay suite:
scripts/run-and-report.sh --failuresremained202/202passing.
- targeted steed micro-session still diverges (remaining
Follow-up: keep u.usteed in monster list while mounted
- Additional C-faithful correction in
mount_steed:- C clears map occupancy but keeps the steed in
fmon. - JS previously removed the steed from
map.monsters(map.removeMonster), which skipped steed turn processing and altered RNG/event flow. - Updated JS mount flow to keep steed in
map.monsters.
- C clears map occupancy but keeps the steed in
- Effect on the same steed micro-session:
- first divergence moved later again (step 8 -> step 9), confirming better alignment of mounted turn processing.
2026-03-12: Steed movement/newsym dogmove alignment follow-up
- Continued #351 triage on
/tmp/t03_s725_w_ride1_gp.session.jsonand landed three C-faithful corrections:newsym()now avoids drawing hero@on the mounted square (u.usteed); the steed glyph path renders instead.domove()now synchronizesu.usteed.mx/mywhenever hero position updates, including pet-swap movement path.dog_move()now matches C steed/overlap handling at entry:- force
udist=1formtmp == u.usteed, - early-return
MMOVE_NOTHINGfor non-steedudist==0.
- force
- Added missing status condition text:
formatStatusLine2()now appendsRidewhen mounted (matching C botl).
Outcome:
- Canonical gameplay parity suite remains fully green after these changes:
203/203passing (scripts/run-and-report.sh --failures). - In the steed micro-session, first mismatch moved from mount-position/glyph
drift to deeper dogmove branch-ordering (
dog_goal*vs Cdistfleeckpath), giving a tighter next target for #351.
Follow-up: ridden steed must bypass dog_goal()
- C
dogmove.chas an explicit early return indog_goal()formtmp == u.usteed(“Steeds don’t move on their own will.”). - JS was still running full goal-scan logic for ridden steed, producing extra
dog_goal*events and earlyrn2(100)drift. - Added C-faithful early return in
dog_move()afterdog_invent/whapprsetup and before goal scanning.
Validation:
- Canonical gameplay suite stayed green and expanded baseline now passes:
206/206. - Steed micro-session improved substantially:
- RNG match
2688/2903->2700/2815 - Screen match
12/21->18/21 - Event match
49/207->73/142
- RNG match
- Remaining gap narrowed to later mounted-position sequencing in step 10.
Follow-up: mounted hero movement budget must use steed mcalcmove
- C
allmain.c:u_calc_moveamt()uses:mcalcmove(u.usteed, TRUE)whenu.usteed && u.umoved,- otherwise hero-form speed + Fast/Very_fast adjustments.
- JS previously always used hero-form speed path and never set
u.umovedon normal movement, so mounted turns missed steed-speed RNG draws. - Landed fixes:
domove()/pet-swap movement now setplayer.umoved = trueon successful movement.u_calc_moveamt()now follows C mounted branch and callsmcalcmoveon the steed when mounted+umoved.
Validation:
- Canonical parity suite remains fully green:
206/206. - Steed micro-session advanced again:
- RNG
2700/2815->2713/2787 - Events
73/142->74/118 - First divergence moved later to mounted combat sequencing during
lstep.
- RNG
Follow-up: mounted mattacku must include C steed-target diversion
- C
mhitu.c:mattacku()has a mounted branch before the normal attack loop: adjacent monsters may consumern2(is_orc ? 2 : 4)to attacku.usteedviamattackm, then allow steed retaliation. - JS
mattackudid not have this branch, which caused first mismatch in steed micro-session atrn2(4)inside mounted combat. - Landed C-faithful JS branch:
- skips self-attack when
monster === player.usteed, - consumes the mounted diversion RNG gate,
- executes
mattackm(monster, steed)with C-style early returns, - executes retaliatory
mattackm(steed, monster)when both remain adjacent.
- skips self-attack when
Validation:
- Canonical gameplay parity suite stayed green:
208/208. - Steed micro-session first mismatch moved later:
- from step-
lcombat RNG (rn2(4)in Cmattacku) - to later ride/dismount phase (
rn2(3)in Clanding_spot).
- from step-
Follow-up: steed dismount landing_spot and unnamed-steed message parity
- Next mismatch in the steed micro-session was inside C
steed.c:landing_spot()andDISMOUNT_BYCHOICEmessaging. - JS
steed.jshad a simplified adjacent-tile picker (no C tie-break RNG semantics) and always printedYou dismount .... - Landed C-faithful updates:
landing_spot()now follows C direction ordering and selection flow:- knocked-direction preferred ordering,
- pass-based filtering for by-choice/impairment,
- tie-break RNG
rn2(viable)handling, - optional
forceitfallback.
dismount_steed()now uses fallbacklanding_spot(..., forceit=1)for throw/fell/poly paths like C.DISMOUNT_BYCHOICEnow matches C unnamed-steed text branch:"You've been through the dungeon on ... with no name."
Validation:
- Steed micro-session now passes fully for RNG/events/screen:
rng=2761/2761,events=113/113,screens=21/21. - Canonical gameplay parity suite remains green and expanded:
210/210passing viascripts/run-and-report.sh --failures.
mkmap mines-init crash fix from wizload coverage preflight
- While attempting high-yield coverage sessions with C harness
--wizload(minetn-1,minefill), JS hit a runtime fatal in the mines level-init path. - Root cause in
js/mkmap.js:- helper signatures were mismatched with real callsites
(
init_map/get_mapdeclared arg order differently than used), NO_ROOMwas used but not imported,get_mapreferenced undefinedWIDTH/HEIGHT.
- helper signatures were mismatched with real callsites
(
- Fix:
- normalized helpers to match existing map-first callsites:
init_map(map, bg_typ)andget_map(map, col, row, bg_typ), - imported
NO_ROOM, - used
COLNO/ROWNObounds inget_map.
- normalized helpers to match existing map-first callsites:
- Result:
- removed hard crash for mines-style
level_initflows, - kept baseline parity stable (
scripts/run-and-report.sh --failures:216/216passing).
- removed hard crash for mines-style
wizloaddes wizard prelude alignment for pending minefill parity
- In pending wizload repro
test/comparison/sessions/pending/t04_s705_w_minefill_gp.session.json, first RNG divergence was early (index 2309) with C showing a wizard-only prelude pairrn2(100), rn2(100)before the nhlib shuffle calls. - Added a narrow role-gated prelude in
handleWizLoadDes(): whenRole_if(player, PM_WIZARD), consume tworn2(100)beforern2(3), rn2(2). - Outcome:
- pending minefill first RNG divergence moved later:
2309 -> 4830. - canonical failures suite remained green:
258/258.
- pending minefill first RNG divergence moved later:
wizload branch topology correction and branch placement guardrails
- Pending wizload minefill replay (
t04_s705_w_minefill_gp) showed missing branch stair rendering plus lateplace_lregiondrift. - C-faithful fixes landed in core gameplay code:
js/dungeon.js: corrected Elemental Planes branch type in topology build to match Ccorrect_branch_type(TBR_NO_DOWN, up=true) => BR_NO_END2(was incorrectlyBR_NO_END1).js/mklev.js: alignedplace_branch()with Cmade_branchsemantics: once a branch is placed, subsequent calls no-op; BR_NO_END* still marks branch as made even when no stair is created.js/wizcmds.js:wizloaddesfinalize context now prefers live map generation coords (map._genDnum/_genDlevel) before player fallbacks.
- Validation:
- canonical gameplay parity remained green:
scripts/run-and-report.sh --failures->260/260. - pending minefill improved to full screen parity (
5/5) while remaining gap is now concentrated to RNG/event stream (5341/5345RNG).
- canonical gameplay parity remained green:
wizloaddes level-name resolution: accept registered special levels beyond otherSpecialLevels
- Coverage preflight for Theme 04 captured a valid C wizload session:
test/comparison/sessions/pending/t04_s706_w_minetn1_gp.session.json(#wizloaddes minetn-1). - JS was rejecting the command early with:
Cannot find level: minetn-1. - Root cause:
handleWizLoadDes()only consultedotherSpecialLevels, which excludes many canonical special-level names available through the main special-level registry. - Fix:
handleWizLoadDes()now normalizes input (.luasuffix accepted) and falls back tofindSpecialLevelByName()+getSpecialLevel()lookup whenotherSpecialLevelshas no direct hit. - Result:
- valid C names like
minetn-1no longer fail at command routing, - parity now proceeds into real generation/finalize drift for this session, which is the correct next debugging target.
- valid C names like
wizload minefill: two-stage fixup + C-style made_branch gate
- Pending session
t04_s705_w_minefill_gpwas still drifting late infixup_special/place_lregionduring wizload. - C behavior detail that mattered:
- wizload path can hit special-level fixup twice,
- branch placement itself is guarded by
made_branch(insideplace_branch), not by skipping the outer fixup pass.
- JS fixes:
- added a deferred-finalize path for wizload (
deferFinalize) so scriptdes.finalize_level()can be split into load-stage and explicit finalize, - updated
handleWizLoadDes()to run that two-stage flow, - made
place_branch()C-faithful with a per-map_madeBranchguard and proper coordinate side effects when no stairs/portal are placed.
- added a deferred-finalize path for wizload (
- Result:
- pending minefill RNG became fully aligned (
5345/5345), - remaining mismatch on this pending session is now screen/event only,
- baseline failures suite stayed stable (
259/260in--failuresset).
- pending minefill RNG became fully aligned (
wizloaddes explicit variant fidelity for minetn-* names
- Follow-up on pending session:
test/comparison/sessions/pending/t04_s706_w_minetn1_gp.session.json. - After routing was fixed, JS still failed fidelity for explicit names like
minetn-1because name lookup flowed throughgetSpecialLevel(dnum,dlevel), which can return a cached random variant for shared slots (for Minetown, any ofminetn-1..7at the samednum/dlevel). - Fix in
js/special_levels.js:- added
resolveSpecialLevelByName(levelName)returning exact{dnum,dlevel,name,generator}for the matched textual name.
- added
- Fix in
js/wizcmds.js:handleWizLoadDes()now prefers exact name resolution afterotherSpecialLevelsand before legacy dnum/dlevel fallback.
- Additional fix in
js/sp_lev.jsfor C-descriptor names used in special levels:- extended monster-name resolution to accept alternate descriptor names
used by level scripts where
mons[].mnamediffers:aligned cleric -> PM_ALIGNED_CLERIC,cavewoman -> PM_CAVE_DWELLER. - this removed
Unknown montyperuntime abort when generatingminetn-1corpses.
- extended monster-name resolution to accept alternate descriptor names
used by level scripts where
- Validation:
- unit:
test/unit/special_levels_resolution.test.jspasses. - pending session now runs through generation and reaches true parity divergence (no command-routing or montype fatal).
- canonical parity gate remains green:
scripts/run-and-report.sh --failures=>265/265.
- unit:
selection.floodfill C-faithfulness: start-tile matching and per-cell predicate coords
- Pending wizload parity (
t04_s706_w_minetn1_gp) exposed a behavior bug in selection floodfill semantics. - C reference (
nhlsel.c+selvar.c) behavior:- default floodfill is constrained to tiles matching the starting tile type,
- floodfill predicate checks are evaluated with current visited coordinates (not the original start coordinate).
- JS fixes in
js/sp_lev.js:selection.floodfill(x, y)now resolves map-relative coords via_toAbsoluteCoord, validates bounds, and defaults to matching start tiletypwhen no checker is provided.selection_floodfill()now forwards current(cx, cy)into_selectionFloodfillChk, and mirrors C default matching behavior.
- Regression guard tests added in
test/unit/selection.test.js:- default floodfill only captures connected tiles with the start tile type,
selection_floodfillcustom checker is called with per-visited-cell coordinates.
- Validation:
node --test test/unit/selection.test.js test/unit/wizloaddes_prompt.test.jspasses.scripts/run-and-report.sh --failuresremains fully green (265/265).- pending
t04_s706_w_minetn1_gpimproved substantially in RNG/screen/color, with remaining drift now concentrated to late wizload finalize ordering and checkpoint event parity.
Wizload RNG phase analyzer for normalized/raw alignment triage
- Added
test/comparison/analyze_wizload_phase_rng.jsto inspect a session-comparison artifact at the first RNG divergence using normalized index mapping back to raw streams. - Why this matters:
- first-divergence indexes are reported on normalized RNG streams, while key debugging context (checkpoints/callers) is easiest to read in raw streams.
- without index-map remapping, it is easy to diagnose the wrong window.
- The tool now prints:
- artifact + first-divergence summary,
- checkpoint/phase-focused lines in a divergence-centered window,
- raw-focus windows for JS and C with
place_lregion/placeRegion,flip_level_rnd,fixupSpecialLevel,finalize_level, andmineralizecallers.
- Applied to pending
t04_s706_w_minetn1_gp, this made the core gap explicit: C performs an additional post-after_finalize_specialplace_lregionbatch before mineralization, while JS transitions into mineralization immediately.
place_lregion(LR_BRANCH) must delegate to place_branch(0,0) for made_branch parity
- Remaining full-suite failure (
seed42_castle) was narrowed to wizard#wizloaddesfinalize sequencing where RNG/screens were drifting at special level step 5. - C behavior:
wiz_load_splua()runsload_special()thenlspo_finalize_level(NULL).- branch placement is guarded by
gm.made_branchinsideplace_branch(). - when a second finalize pass hits branch placement paths, the early
gm.made_branchreturn prevents additionalfind_branch_roomRNG.
- JS bug:
place_lregion(... LR_BRANCH ...)precomputed room/coords before callingplace_branch(), so it consumed RNG even when_madeBranchalready true.
- Fix:
- in
js/mkmaze.js,place_lregionnow delegates directly toplace_branch(map, 0, 0, ...)forLR_BRANCHwith default area, matching C ordering and guard semantics. - combined with wiz-load finalize split parity (
skipRandomFlipon the second-stage finalize), this restoredseed42_castlefull parity.
- in
- Additional C-faithful cleanup:
fixupSpecialLevel()now clearslevelState.levRegionsafter processing (matchingfixup_special()freeing/resetting lregions in C).- tutorial-only post-topology levregion replay now uses a local snapshot in
finalize_level()to preserve existing tutorial parity behavior.
- Validation:
node test/comparison/session_test_runner.js --verbose test/comparison/maps/seed42_castle.session.jsonpasses with full RNG/events/screens/colors parity.npm run test:sessionpasses all sessions (394/394, gameplay270/270).
Wizload fixup phase instrumentation (WEBHACK_WIZLOAD_FIXUP_TRACE)
- Added optional trace instrumentation in
fixupSpecialLevel()to speed up#wizloaddesphase debugging without changing default replay behavior. - Enable with:
WEBHACK_WIZLOAD_FIXUP_TRACE=1 node test/comparison/session_test_runner.js --verbose <session>
- When enabled, JS emits:
^wizfixup[name=... isBranch=... regions=... placed=... addedBranch=... fallback=... rng=...]
- Practical use:
- Compare
regions/placed/fallbackbetween sessions likeseed42_castleand pendingt04_s706_w_minetn1_gpto isolate whether missing RNG comes from explicit levregions vs fallback branch placement.
- Compare
- Default behavior unchanged (
WEBHACK_WIZLOAD_FIXUP_TRACEoff).
Branch lregion placement is room-gated in no-room special layouts
- While triaging pending
t04_s706_w_minetn1_gp, we found a C-faithful edge:place_lregion(..., LR_BRANCH)should only short-circuit intoplace_branch(...,0,0)when rooms exist (map.nroom > 0). - In no-room layouts, falling through to whole-level bounded coordinate selection avoids an early branch placement path that consumed RNG too soon.
- JS fix in
js/mkmaze.js:place_lregion()now gates the LR_BRANCH delegation onmap.nroom.
- Debug aid update in
js/sp_lev.js:- optional
^wizfixup[...]tracing now includesnroom=...underWEBHACK_WIZLOAD_FIXUP_TRACE.
- optional
- Guardrail:
- keep
levelState.levRegions = []infixupSpecialLevel()(C-faithful). Removing this clear regresses wizard/special-level parity.
- keep
- Validation snapshot:
t04_s706_w_minetn1_gpfirst RNG divergence moved later to index5699(getArrivalPositionvsmineralize).
seed42_gameplayremains fully passing.seed033_manual_directunchanged (no regression from this slice).
getpos travel cursor duplicate-describe caused seed033 drift (fixed 2026-03-12)
- Symptom:
seed033_manual_directdiverged at travel targeting around step460with duplicated toplines ("floor of a room floor of a room"), then unexpected--More--prompts and downstream RNG/event drift.
- Root cause:
- In
js/getpos.js, cursor movement emitted a description immediately, but the next loop iteration also ranauto_describe()before the next key becausemsgGivenwas not set. This double-posted travel descriptions.
- In
- Fix:
- After movement-time
display.putstr_message(message), setmsgGiven = truesoauto_describe()is skipped until after the next key, matching C getpos message cadence.
- After movement-time
- Validation:
seed033_manual_directnow fully matches RNG/events/screens/colors.- Full gameplay report is green:
271/271passing.
Wizload minetown topology parity: bound_digging maze-offset quirk
- While continuing pending
t04_s706_w_minetn1_gp, JS was short by exactly the mineralize tail and diverged at firstgetArrivalPositionRNG. - High-signal trace (
DEBUG_MINERALIZE=1) showed second-passbound_digging: xmin=3and manyW_NONDIGGABLEskips on columnx=3. - C session evidence for this fixture indicated RNG should continue through the full mineralize window before arrival placement.
- Fix:
- Added optional maze-state override plumbing:
get_level_extends(map, { isMazeLevelOverride })bound_digging(map, { isMazeLevelOverride })sp_levfinalize context carriesboundDiggingIsMazeLevel.
- Wizload second-stage finalize now forces
boundDiggingIsMazeLevel=falsefor this path.
- Added optional maze-state override plumbing:
- Additional structural hardening:
makeLocation()now initializeswall_info/nonpasswall.GameMap.clear()now resetswall_info,nondiggable,nonpasswall, anddrawbridgemask.
- Validation snapshot:
t04_s706_w_minetn1_gpnow reaches full RNG parity (5797/5797), with remaining mismatch reduced to screen/event at step 5.seed42_gameplaystill passes fully.
Wizload minetown final parity: post-goto checkpoint timing and level-1 upstairs guard
- Continuing
t04_s706_w_minetn1_gpafter RNG parity, two non-RNG mismatches remained at step 5:- missing/offset
^ckpt[phase=after_finalize ...] - screen cell
'<vs·on row 6.
- missing/offset
- Root causes were separate and both C-faithful:
- Checkpoint boundary timing:
- In C wizload flow, the single
after_finalizecheckpoint for this path is observed after hero relocation (goto_level/changeLevelboundary), not during deferred script finalize with oldu.ux/u.uy. - JS fix:
- keep deferred-finalize suppression (
skipAfterFinalizeCheckpoint: true) - add explicit post-
changeLevelemission viacapture_wizload_after_finalize_checkpoint().
- keep deferred-finalize suppression (
- In C wizload flow, the single
mkstairs()level-1 guard:- C
mkstairs()does not place upstairs on dungeon level 1. - JS was placing an
LR_UPSTAIRfrom levregion on_genDlevel=1, causing the stray<glyph and screen-only mismatch. - JS fix in
js/mklev.js:if (isUp && map._genDlevel <= 1) return;
- C
- Checkpoint boundary timing:
- Validation:
node test/comparison/session_test_runner.js --verbose test/comparison/sessions/pending/t04_s706_w_minetn1_gp.session.jsonnow passes fully (rng/events/screens/colors/cursorall matched).seed42_gameplayremains fully passing after the fix.
moveloop turn-end bookkeeping: align settrack/moves boundary to C order
- In
js/allmain.js(moveloop_turnend), JS had drifted from C ordering by incrementingturnCountand callingsettrack()at function entry. - C
allmain.corder is:mcalcdistress/mcalcmove/ spawn,u_calc_moveamt,settrack,moves++bookkeeping.
- Fix:
- Compute
nextTurnCountearly but defer assignment until afteru_calc_moveamt+settrack. - Keep pre-increment
game.movesduring spawn/object side-effects. - Apply
turnCount/turns/_currentTurn/heroSeqN/movesupdate at the C-faithful boundary. - Use the updated
game.turnCountfor overexertion modulo checks.
- Compute
- Validation:
seed42_gameplaystill full pass.seed100_multidigit_gameplaystill full pass.- Pending
t11_s748_w_covmax3_gpremains unchanged (this was a structural correctness fix, not the root cause of that session’s divergence).
mondata locomotion table hardening: avoid latent runtime crash
- While probing pending-session divergence paths, JS hit a runtime exception:
ReferenceError: slither is not definedinmondata.locomotion(). - Root cause:
- movement-verb tables used by
locomotion()/stagger()referencedslither(and peer tables) without file-local definitions.
- movement-verb tables used by
- Fix:
- added explicit local fallback motion-verb tables
(
levitate,flys,flyl,slither,ooze,immobile,crawl) injs/mondata.js.
- added explicit local fallback motion-verb tables
(
- Validation:
seed42_gameplay: PASSseed100_multidigit_gameplay: PASS- pending
t11_s748_w_covmax3_gpreturns to prior divergence baseline (no new crash introduced by this fix).
wiz_identify input ownership: route Ctrl+I through inventory menu path
- C behavior (
wizcmds.c:wiz_identify) delegates todisplay_inventory(..., FALSE), where input is owned bydisplay_pickinvwizard-id flow. - JS previously short-circuited Ctrl+I by force-identifying items then showing a
single
more()boundary. That bypassed inventory/menu ownership semantics. - Fix:
- added
wizardIdentifymode todisplay_pickinv/display_inventoryinjs/invent.js. - wizard mode now renders a
Debug Identifymenu, waits for explicit dismiss or selection, and applies identify actions through existingidentify_pack()/identify()code paths. js/cmd.jsCtrl+I now routes throughdisplay_inventory(..., { wizardIdentify: true })instead of directmore()handling.
- added
- Added regression test:
test/unit/command_inventory_modal.test.jsnow verifies wizard-identify menu remains open on non-dismiss keys and closes on explicit dismiss.
- Validation:
node --test test/unit/command_inventory_modal.test.jsPASS.seed42_gameplayandseed100_multidigit_gameplayremain full PASS.- pending
t11_s748_w_covmax3_gpfirst divergence remains unchanged, so this is a structural C-faithful correction, not yet the root-cause fix for that session.
monmove async correctness: await Vrock gas cloud side-effect in monflee
- C behavior (
monmove.c:521-524) creates a Vrock gas cloud immediately when fleeing starts. - JS had an async ordering hazard in
monflee:create_gas_cloud(...)was called withoutawait, letting cloud side-effects run out-of-order relative to the current monster turn.
- Fix:
js/monmove.js:await create_gas_cloud(mon.mx, mon.my, 5, 8, map, player)in the Vrock flee branch.
- Why this matters:
- keeps monster-turn side effects on the correct command boundary and avoids delayed cloud effects drifting into later steps.
- Validation:
seed42_gameplayPASSseed100_multidigit_gameplayPASS
t11_s742_w_covmax1_gpstill diverges at step 342; this fix is a correctness hardening, not the full root-cause resolution for that session.
vision parity: gas-cloud LOS must consult active region records
- C
linedup()can fall back from LOS to boulder-handling whencouldsee()is blocked by gas cloud squares. - JS
vision.does_block()only consultedmap.gasClouds, but runtime poison clouds fromcreate_gas_cloud()live inmap.regions(inside callback indexINSIDE_GAS_CLOUD), so LOS could be wrongly treated as clear. - Fix:
js/vision.js:getMapCloudVisibility()now checks active gas-cloud regions inmap.regionsby bounding box + rect membership, then falls back to legacymap.gasCloudsrecords.
- Impact:
- pending
t11_s742_w_covmax1_gpnow takes the C-faithfullinedupboulder-roll branch at step 342 and consumes the missingrn2(2). - first RNG divergence moved deeper (
2926 -> 2933), exposing a later HP/state drift as the next frontier.
- pending
- Validation:
seed42_gameplayPASSseed100_multidigit_gameplayPASS
t11_s742 parity: preserve C message boundary ownership at survive exits; wizard trap rendering in map memory
done()survive paths in JS were forcibly clearing topline state (messageNeedsMore, marker flags, cached top message). C does not do this cleanup at that point.- Removing that forced clear in
js/end.jsrestored C-faithful message/input boundary ownership and produced a large parity gain int11_s742_w_covmax1_gp:- RNG:
13750/13750(full) - events:
3679/3679(full)
- RNG:
- Remaining mismatch was then dominated by screen rendering, beginning at step 1 where C
shows
^for a wizard-visible trap square and JS showed terrain. js/display.jsnow uses a sharedtrapShownOnMap()policy in both in-FOV and remembered/out-of-FOV rendering paths:- show trap glyph when
trap.tseenor wizard mode is active (game.wizard). - update
mem_trapconsistently in those paths.
- show trap glyph when
- Impact on
t11_s742_w_covmax1_gpscreen parity:screens 3/795 -> 241/795- first screen divergence moved from step 1 to step 3.
- Additional cleanup:
js/dungeon.jsmapdumpUindex-9 now usescontext.movedirectly (C mirror), avoiding accidental use of cached per-hero move fields.
t11_s742 wizard-identify parity: startup inventory known must mirror C init interplay
- C startup behavior is split across two paths:
mkobj.c:mksobj_init()seedsobj->knownbased onoc_name_knownandoc_uses_known.u_init.c:ini_inv_adjust_obj()then forcesobj->known=1foroc_uses_knownobjects.
- Net effect for startup inventory: non-coin items end up
known=1. - JS had only partial
knownassignment inu_init.js, leaving many wizard-start items (rings/potions/scrolls/spellbooks) as not fully identified in^Idebug-identify flow. - Fix:
- In startup inventory adjust path, set
obj.known = truefor non-coin objects.
- In startup inventory adjust path, set
- Observed impact on
t11_s742_w_covmax1_gp:- kept RNG/events full (
13750/13750,3679/3679), - removed the large step-3 identify-menu body mismatch,
- reduced remaining mismatch to smaller screen formatting/mapdump details.
- kept RNG/events full (
seed322 drift: fog-cloud gas TTL must key off canonical monster identity
- First shared RNG/event drift in
seed322_barbarian_wizard_gameplayoccurred in the latemovemonwindow around step 510, where JS consumed an extra fog-cloud cloud-build roll beforedistfleeck. - Root cause:
inside_gas_cloud()injs/region.jsstill used legacy identity checkumon.type.id === PM_FOG_CLOUD. In current state model, canonical monster identity ismndx(orumonnumfor hero polyform), so fog-cloud occupants were often not recognized. - Consequence: gas cloud TTL was not extended like C (
region.c:umon->data == &mons[PM_FOG_CLOUD]), letting clouds expire early and causing extracreate_gas_cloud()RNG draws later. - Fix:
- switch the fog-cloud check to canonical index-first identity (
mndx/umonnum), with legacyidas fallback.
- switch the fog-cloud check to canonical index-first identity (
- Result:
seed322_barbarian_wizard_gameplayreached full RNG/event parity (30190/30190RNG,21337/21337events).
2026-03-13 parity hardening: trap memory rendering and prompt-line lifecycle
- Root cause class #1 (major): JS out-of-FOV rendering was recomputing trap glyphs from
live trap state (
map.trapAt) instead of relying on remembered glyph state.- C uses remembered map glyphs; trap visibility for map rendering is gated by
trap->tseen, then persisted via map glyph updates. - Fixes in
js/display.js:trapShownOnMap()now mirrors C gating (trap.tseenonly; no wizard override).- removed out-of-FOV live trap recomputation; unseen tiles now use
mem_trapmemory.
- C uses remembered map glyphs; trap visibility for map rendering is gated by
- Root cause class #2:
ynFunction()prompts were drawn withputstr()but not tracked as top-line message state; command-boundary clearing then drifted by one step.- Fixes in
js/input.js:- when drawing a
ynprompt, settopMessageand record it inmessageshistory. - this preserves expected prompt visibility for the answering step and clears at the next command boundary, matching captured C sessions.
- when drawing a
- Fixes in
- Validation snapshot:
scripts/run-and-report.sh --failuresimproved from183/192pass to190/192pass during this slice.- At that point, failures were narrowed to
seed322andseed331; subsequentseed322fog-cloud identity fix (entry above) removed the gameplay divergence.
2026-03-13 seed331 death-boundary fix: remove duplicate monster-death finalization and defer savebones while end prompts are active
- Symptom:
seed331_tourist_wizard_gameplayhad full RNG/events parity but failed at final screen boundary (You die...--More--lingering on row 0 instead of disclosure/blank).
- Root cause #1:
- In
js/mhitu.js(mattackuhit path), JS calledlosehp(...)and then, on detected death, calleddone_in_by(...)again. losehpalready triggersdone(DIED)when HP drops below 1, so the second call re-entered death flow and left stale topline state.- C mirror:
hitmu()usesmdamageu(), where death finalization occurs once.
- In
- Root cause #2:
- After removing the duplicate death call, JS exposed an extra
savebones()RNG/event draw during end-of-game prompt ownership (insidemoveloop_core). - C disclosure flow should not advance additional world/bones logic while end prompts are still owning input.
- After removing the duplicate death call, JS exposed an extra
- Fixes:
js/mhitu.js: remove duplicate death finalization block afterlosehp(...)inmattacku(breakon death, no seconddone_in_by).js/allmain.js: gatesavebones(game)inmoveloop_coreso it does not run while an activependingPromptis owning end-of-game input.
- Validation:
seed331_tourist_wizard_gameplaynow has full parity: RNG18493/18493, events3708/3708, screens/colors389/389.- Full gameplay suite:
192/192passing (scripts/run-and-report.sh).
2026-03-13 quaff stack-weight drift: use useup() (not raw quan--) after potion effects
- Symptom in aggressive pending session (
pnd_s1200_w_potionmix2_gp):- C showed
Your movements are only slowed slightly by your load.--More--after a quaff boundary; JS skipped this and continued into pet combat text. - First divergence moved from step
722screen + step733RNG when traced.
- C showed
- Root cause:
js/potion.jsconsumed quaffed items with directitem.quan -= 1paths.- That bypassed canonical
useup()stack handling, leaving staleowton reduced stacks (e.g.,q1:w40instead ofq1:w20), sonear_capacity()lagged C and encumbrance transitions fired late.
- Fix:
- Route all post-quaff consumption paths through
useup(item, player):- normal post-
peffects, - milky bottle ghost path,
- smoky bottle djinni path.
- normal post-
- Route all post-quaff consumption paths through
- Result:
- pending session alignment advanced materially (
screens 721 -> 735,rng 2563 -> 2565matched prefix) with no regression in the main suite (scripts/run-and-report.sh --failures:191/192passing, unchanged).
- pending session alignment advanced materially (
2026-03-13 startup spellbook-ID overreach: skip P_NONE books in wizard passive ID
- Symptom in
pnd_s1200_w_potprayspell_gp:- early screen divergence at step
676(plain spellbookin C vsspellbook of blank paperin JS), before first RNG divergence.
- early screen divergence at step
- Root cause:
js/spell.jsskill_based_spellbook_id()was using category-name fallback logic that treated unknown spellbooks as"matter".- That incorrectly included
SPE_BLANK_PAPER(which isP_NONEin C) in wizard passive startup identification, setting global name-known too early. - C ref:
spell.c skill_based_spellbook_id()explicitly skipsskill == P_NONE.
- Fix:
- use numeric spell school from object row (
oc_subtyp/oc_skill), skip<= 0(P_NONE) books, and mirror C rank ladder:P_BASIC -> 3,P_SKILLED -> 5,P_EXPERT+ -> 7, else1(or0pauper). - harden
discoverObject()for unit/no-game contexts via_gstate?.u || _gstate?.player. - tighten startup discovery helper to match
u_init.cby requiringobj.known(not merelyobj.dknown) beforediscoverObject(..., markAsKnown=true).
- use numeric spell school from object row (
- Result:
pnd_s1200_w_potprayspell_gpscreen parity improved from835/905to837/905and first screen divergence moved from step676to756, while preserving the same first RNG divergence point (step 866), confirming this removed an earlier independent mismatch without masking later drift.
2026-03-13 search parity hardening: explicit doserach0(aflag=0) now includes C monster-reveal path
- C gap closed:
detect.c dosearch0()has a distinct explicit-search branch (aflag == 0) that checks adjacent monsters viamfind0()before trap checks, can early-return, and clears stale invisible markers viaunmap_invisible().- JS
doserach0was missing this branch and treated explicit search/autosearch identically.
- Fix:
js/detect.js:- added
aflagparameter and C swallowed-message behavior. - wired explicit-search-only
mfind0andunmap_invisiblelogic. - added C-style return semantics (
return 1default; early return onmfind0).
- added
- call-site split:
- explicit
scommand usesdoserach0(..., aflag=0). - intrinsic autosearch in
moveloop_turnendusesdoserach0(..., aflag=1).
- explicit
- Validation:
- no regressions on known-green gameplay parity seeds:
seed322_barbarian_wizard_gameplayfull RNG/event parity.seed331_tourist_wizard_gameplayfull RNG/event parity.
- target pending session (
pnd_s1200_w_potprayspell_gp) still diverges at step 866, indicating this slice removes a concrete C gap but the remaining drift is elsewhere.
- no regressions on known-green gameplay parity seeds:
2026-03-13 hallucination redraw + pet display ownership: move redraws to C-faithful dochug/postmov boundaries
- Symptom in display-RNG instrumented pending session (
/tmp/pnd_s1200_disp.session.json):- mismatch around step
756where C consumed hallucination redraw RNG (~drn2(383)) indochugstatus handling before subsequent pet movement turns.
- mismatch around step
- C-faithful fixes:
js/monmove.js- add missing hallucination refresh for sleeping monsters that stay asleep
(
dochugsleep early-return path). - add missing hallucination refresh after movement status resolution for
MMOVE_NOMOVES/MMOVE_NOTHING/MMOVE_DONE(Cdochugswitch behavior).
- add missing hallucination refresh for sleeping monsters that stay asleep
(
js/dogmove.js- remove direct
newsym(old/new)redraws from the normal pet movement path. - keep movement state updates in
dog_move; rely onpostmov/dochugredraw ordering to match C. - retain leashed-reposition destination refresh.
- remove direct
- Why this matters:
- C pet movement display updates are owned by
postmov; direct redraws insidedog_movecan double-consume hallucination display RNG and obscure true order.
- C pet movement display updates are owned by
- Validation:
- display-RNG pending run advanced first RNG divergence from index
2503to2523(step761) inRNG_LOG_DISP=1mode. - no regression on known-green gameplay parity seeds:
seed322_barbarian_wizard_gameplayfull RNG/event parity.seed331_tourist_wizard_gameplaystill full RNG/event parity (cursor-only terminal-position tail delta unchanged from baseline behavior).
- display-RNG pending run advanced first RNG divergence from index
2026-03-13 getobj prompt wrapping parity: remove hard truncation, render two-line prompt at COLNO-1
- Symptom:
hi10_seed1090_wiz_potion-deep_gameplayhad full RNG/event parity but one persistent screen mismatch at step505: C showed wrapped getobj prompt continuation ("z or ?*]") on row 1 while JS dropped it.
- Root cause:
js/potion.jsbuildGetobjPrompt()truncated prompts toCOLNO-1, discarding continuation text for long prompts like"What do you want to dip <object> into? [...]".
- Fix:
- keep full getobj prompt text (no truncation).
- add explicit getobj prompt renderer for topline in
potion.jsthat:- clears rows 0/1,
- hard-wraps at
COLNO-1(C-like prompt flow), - writes row 0 and row 1 directly (without
--More--), - updates
topMessage/_topMessageRow1cursor state.
- Validation:
hi10_seed1090_wiz_potion-deep_gameplaynow fully passes: RNG2409/2409, events98/98, screens511/511.- no regressions on known-green seeds in local check:
seed322_barbarian_wizard_gameplayfull parity.seed331_tourist_wizard_gameplayfull RNG/event parity (cursor-only tail delta unchanged).
2026-03-13 zap boundary/wrapper parity: route directional zap through prompt boundary and await buzz/ubuzz
- Symptom:
hi11_seed1100_wiz_zap-deep_gameplaydiverged at step379with JS entering monster turns before finishing zap beam work (rn2(5) @ dochugvs Crn2(20) @ zap_hit).
- Fixes:
js/zap.js:- directional zap now uses
game.pendingPromptowner semantics for"In what direction?"so the next key is consumed by zap prompt boundary. - extracted
executeZapWithDir()to keep self-zap/weffects + dust handling in one path. buzz()now awaits and forwardsmap/playerintodobuzz(...).ubuzz()now async, null-safe, and forwardsmap/playerintodobuzz(...).
- directional zap now uses
js/polyself.js:dobreathe()nowawaitsubuzz(...)(withmap) for directed breath attacks.
- Validation:
- known-green checks stay green:
hi10_seed1090_wiz_potion-deep_gameplayfull pass.seed322_barbarian_wizard_gameplayfull pass.seed331_tourist_wizard_gameplaystill full RNG/event parity (existing cursor-tail delta unchanged).
- known-green checks stay green:
hi11improved but not yet resolved:- event match improved (
89 -> 129) and zap now executes in prompt boundary. - remaining gap is C-vs-JS
dobuzzhero-hit pause/message semantics (zhitu/--More--) and bounce path ordering.
- event match improved (
2026-03-13 hi11 follow-up: zhitu resistance port + regen_hp Upolyd branch
- C-faithful updates landed in core JS:
js/zap.jsdobuzz()player-hit path now gates beam damage by player resistances (magic missile antimagic, fire/cold/lightning/acid/poison resistance) and appliesHalf_spell_damagehalving for wand/spell zaps.js/allmain.jsregen_hp()now includes the CUpolydbranch (monster-form healing cadence and full-health interrupt behavior), instead of only the human-HP branch.
- Important negative finding:
- Swapping this
dobuzzplayer-hit path tolosehp(...)caused a replay boundary regression inhi11(first divergence moved earlier to index 2572). - For now, keep direct HP decrement in this hot path until
losehpintegration can be done without perturbing command/input boundary semantics.
- Swapping this
- Validation snapshot:
hi11_seed1100_wiz_zap-deep_gameplayremains at first divergence index 2603 (no net regression from baseline frontier).- Guard sessions still pass:
hi10_seed1090_wiz_potion-deep_gameplayseed322_barbarian_wizard_gameplayseed331_tourist_wizard_gameplay(existing cursor-tail delta unchanged)
2026-03-13 command-boundary umovement invariant: restore C input-loop precondition
- Symptom:
- pending session
t11_s744_w_covmax2_gpshowed an early turn-order skew near step569(^distfleeck nearmismatch) with JS running monster turns one key earlier than C in the teleport-followup window.
- pending session
- Root cause:
- JS command boundaries could start with
player.umovement < NORMAL_SPEEDin some async prompt/getlin transitions. - In C,
moveloop()only returns to input when hero can act (equivalent tou.umovement >= NORMAL_SPEED), so command parsing should not begin from a short-movement state.
- JS command boundaries could start with
- Fix:
js/allmain.js(run_command): enforce the command-boundary invariant by clamping finite shortumovementtoNORMAL_SPEEDbefore processing a new command key.- Keep this in shared
run_command(not only browser loop) so replay/headless command entrypoints get the same C-style precondition.
- Validation:
t11_s744_w_covmax2_gpimproved materially:- RNG matched
6594/11786 -> 7565/12139 - first RNG/event frontier moved
step 569 -> step 645.
- RNG matched
- broad parity status stayed strong via
./scripts/run-and-report.sh --failures:189/192gameplay sessions passing- PRNG/events
192/192full, remaining failures are screen-only.
2026-03-13 dobuzz parity tranche for hi11: wall-bounce position update + C dice semantics + kill-path handlers
- Scope:
- Continued
hi11_seed1100_wiz_zap-deep_gameplayaudit aroundzap.c:dobuzz.
- Continued
- C-faithful fixes in
js/zap.js:dobuzzwall/door bounce now keepssx/syon the obstacle cell (matching Cmake_bounceflow for this branch) instead of rewinding tolsx/lsy.- Added explicit player-beam message emission in the
u_at(sx,sy)branch:The <beam> hits you!/whizzes by you, and bounce message emission. - Switched C zap damage dice paths from Lua-style
d()to C-stylec_d()in:zhitmdamage/sleep rolls,dobuzzhero-hit damage roll.
- Aligned kill dispatch in
dobuzz:- disintegration uses
disintegrate_mon(...), - hero kill uses
xkilled(...), - monster kill uses
monkilled(...).
- disintegration uses
- Measured effect on target session:
hi11first RNG divergence moved later from index2534to2603.- Matched RNG calls improved
2537 -> 2619. - Matched events improved
129 -> 135. - Remaining mismatch is now later in post-kill turn-tail ordering.
- Regression checks:
hi10_seed1090_wiz_potion-deep_gameplaystill full pass.seed322_barbarian_wizard_gameplaystill full pass.seed331_tourist_wizard_gameplaystill full RNG/event parity (cursor-tail delta unchanged).
2026-03-13 pet eat/message boundary stale-cell fix: refresh pet squares before blocking --More--
- Symptom:
- Three remaining screen-only failures had full RNG/event/mapdump parity but a single stale pet glyph frame:
seed301_archeologist_selfplay200_gameplaystep19seed033_manual_directstep99theme12_seed939_wiz_explore_gameplaystep18
- In all three, the failing step included
^dog_move_exit(... do_eat=1)and a top-line message that could block at--More--.
- Three remaining screen-only failures had full RNG/event/mapdump parity but a single stale pet glyph frame:
- Root cause:
- In JS
dog_move(), whendo_eatpath emits"X eats Y"message, pet old/new squares were only refreshed later by postmove flow. - During the message boundary frame, display could show one stale-cell placement even though gameplay/event order already matched C.
- In JS
- Fix:
js/dogmove.js(dog_move,do_eatmessage branch):- call
newsym(omx, omy)andnewsym(mon.mx, mon.my)immediately beforeputstr_message(...).
- call
- This is a display-order fix only; no RNG/event changes.
- Validation:
- Targeted sessions now pass fully:
seed301_archeologist_selfplay200_gameplay221/221screens.seed033_manual_direct1417/1417screens.theme12_seed939_wiz_explore_gameplay87/87screens.
- Full failure sweep now green:
./scripts/run-and-report.sh --failures->192/192gameplay passing,0failures.
- Targeted sessions now pass fully:
2026-03-13 Oracle-level rndmonst_adj + dosounds parity bring-up (t11_s744)
- Symptom:
- Pending coverage session
t11_s744_w_covmax2_gpfirst RNG drift at step645:- JS
rn2(3)inrndmonnum_adjvs Crn2(8)inrndmonst_adj.
- JS
- Pending coverage session
- Root cause #1 (level-alignment context):
- JS
align_shift()was running with_dungeonAlign=A_NONEduring Oracle special-level generation. - In C,
align_shift()uses special-level alignment (Is_special(&u.uz)->flags.align) when present. - This changed
rndmonst_adjweights and shifted the entire reservoir-sampling stream.
- JS
- Root cause #2 (Oracle ambient-sound gate):
- JS
dosounds()correctly checksflags.is_oracle_level && !rn2(400), but Oracle maps were not settingis_oracle_level. - Missing this gate skipped one
rn2(400)and caused later turn-tail drift.
- JS
- Fixes:
js/dungeon.js:- depth-only align fallback now uses
DUNGEONS_OF_DOOMwhendnumis absent. - special-level alignment mapping now includes Oracle (
oracle -> A_NEUTRAL), alongside existingmedusaandtut-*. - generated special maps now set
flags.is_oracle_levelfor Oracle levels.
- depth-only align fallback now uses
js/makemon.js:uncommon()now honorsgame.mvitals[mndx].mvflags & G_GONElike C.mk_gen_ok()now honorsmvflagsmaskagainstgame.mvitals[mndx].mvflags.
- Validation:
t11_s744_w_covmax2_gpimproved materially:- matched RNG
7565/12139 -> 9807/11227 - first RNG drift moved
step 645 -> step 667.
- matched RNG
- Core parity guard sessions remain green:
seed031_manual_directseed032_manual_directseed033_manual_direct
2026-03-13 hi11 zap-death boundary follow-up: route beam self-hit death through losehp() and narrow lifesave stop scope
- Scope:
- Continued issue
#361(hi11_seed1100_wiz_zap-deep_gameplay) around rebound-zap death/lifesave sequencing.
- Continued issue
- C-faithful fixes:
js/zap.js:- player beam-hit damage in
dobuzznow callslosehp(...)(withKILLED_BY_AN) instead of directplayer.uhp -= dam.
- player beam-hit damage in
js/hack.js:losehp()now sets authoritativeend.jskiller state (setKillerFormat,setKillerName) beforedone(DIED).
js/end.js:savelife()only sets_stopMoveloopAfterLifesaveduring monster phase (context.mon_moving), avoiding over-broad turn truncation after command-phase lifesave.
- Validation:
hi10_seed1090_wiz_potion-deep_gameplay: PASS (no regression).hi11_seed1100_wiz_zap-deep_gameplay:- first RNG divergence improved
2603 -> 2613 - matched RNG improved
2619 -> 2625 - matched events improved
135 -> 136.
- first RNG divergence improved
- Remaining gap:
- Post-lifesave work distribution still drifts in
hi11around step388(zhitu-tail timing/order versus next zap cycle).
- Post-lifesave work distribution still drifts in
2026-03-13 hi11 zap tranche: destroy-items RNG flow port (zhitu paths)
- Scope:
- Continued issue
#361onhi11_seed1100_wiz_zap-deep_gameplay. - Ported additional
zap.czhituitem-destruction RNG/dataflow.
- Continued issue
- C-faithful updates in
js/zap.js:- Added
destroy_items_rng_only(...)with C-style limit/reservoir RNG shape:limitfromdmg/5+rn2(5)threshold,rn2(elig_stacks)sampling for eligible stacks,maybe_destroy_item(...)per selected stack.
- Wired
destroy_items_rng_only(...)into:- player
ZT_FIRE,ZT_COLD,ZT_LIGHTNINGhit paths, - monster
ZT_FIRE,ZT_COLDhit paths.
- player
- Tightened
destroyable_by()to C eligibility:- excludes artifacts and single-quantity in-use objects,
- fire immunity:
SCR_FIRE,SPE_FIREBALL, - cold excludes
POT_OIL, - electric excludes
RIN_SHOCK_RESISTANCE,WAN_LIGHTNING.
- Added
maybe_destroy_item(...)pre-rolls aligned to C:AD_FIREpotionrnd(6),AD_ELECwandrnd(10),- charged ring
rn2(3)recharge gate.
- Added player-side attribute-exercise RNG side effect for damaging item
destruction to keep order near C
maybe_destroy_itemcloser.
- Added
- Validation:
hi11: first RNG divergence advanced2613 -> 2667; matched RNG improved to2670/3469; matched events improved to161/609.hi10: remains full pass.
- Remaining gap:
- first mismatch now appears later at step
401(discoverObject(...)ahead of C monster-turnexercise(...)tail), so remaining work is primarily post-zap turn-distribution ordering.2026-03-13:
weffects()learnwand gate must bedisclose-only (nowasUnknownshortcut)
- first mismatch now appears later at step
- Context:
hi11_seed1100_wiz_zap-deep_gameplaystill diverged in a zap/lifesave region. - Finding:
- JS
weffects()had drifted to:if (disclose || wasUnknown) learnwand(obj, player);
- C
zap.c weffects()callslearnwand(obj)only whendiscloseis true. - This is a behavior bug independent of whether it immediately improves first-RNG divergence.
- JS
- Fix:
- Removed the
wasUnknownbypass path so JS now mirrors C:if (disclose) learnwand(obj, player);
- Removed the
- Validation:
hi10_seed1090_wiz_potion-deep_gameplayremains pass.hi11_seed1100_wiz_zap-deep_gameplaystill fails at the same first divergence index, but theweffectsgate is now structurally faithful.
- Follow-up diagnosis (not yet fixed in this slice):
- Around steps 396–401 in
hi11, command/prompt boundary work distribution is shifted across--More--+Die? [yn]. - Observed pattern: JS consumes monster-turn RNG two keys earlier than C in that region.
- Likely hotspot: blocking prompt paths (
done()->ynFunction) interacting with replay key delivery cadence.
- Around steps 396–401 in
2026-03-13: blind monster visibility and debug mapdump pet state
- Context:
- Pending coverage session
pnd_s1200_w_potprayspell_gpimproved after auditing explicit-search behavior while blind and hallucinating. - JS debug mapdumps were also misreporting pet tameness, which made state comparisons around pet AI look worse than the actual gameplay state.
- Pending coverage session
- Fixes:
js/display.jscanSeeMonsterForMap()now returnsfalsewhen the hero is blind.- This matches C
_canseemon(mon)behavior and prevents blind search from incorrectly treating adjacent monsters as already visible.
js/dungeon.js- JS compact mapdumps now export monster tameness from
mon.mtame, not the legacy boolean-ishmon.tame. - This is observability-only, but it matters: pet-state diffs in section
Nnow reflect the runtimemtamevalue instead of a misleading0.
- JS compact mapdumps now export monster tameness from
- Validation:
pnd_s1200_w_potprayspell_gpimproved earlier in the search tranche:- RNG matched
2770 -> 2775 - events matched
195 -> 226 - first divergence moved
866 -> 868
- RNG matched
- Tooling-only
mtamefix does not change parity metrics for the pending session; it only makes debug mapdump comparisons trustworthy. hi10_seed1090_wiz_potion-deep_gameplayremains pass.hi11_seed1100_wiz_zap-deep_gameplayremains at the same late frontier.
- Important debugging lesson:
- For this pending session, the remaining apparent
dosearch0()mismatch was not caused by JS skipping the blind-search monster path. Temporary tracing showed JS does callmfind0()and correctly suppresses repeated discovery on remembered invisible squares. - The comparison artifact’s later drift points deeper into turn-distribution
/ pet-movement ordering, so do not keep pushing on
dosearch0()without fresher evidence.
- For this pending session, the remaining apparent
2026-03-13: hero fire-hit parity depends on async armor-erosion messaging
- Context:
hi11_seed1100_wiz_zap-deep_gameplaywas diverging at the fire-hit / death boundary: JS showedHP:0(12)too early and already hadYou die...in message history while the top line still showedThe bolt of fire hits you!--More--. - C finding:
zap.c zhitu()does fire-hit work in this order:- hit message,
burnarmor(&youmonst),- possible item destruction / ignition,
losehp(...).
burnarmor()itself is synchronous in C becausetrap.c erode_obj()immediately emits the player-facing erosion message, such asYour cloak smoulders!, and that message can create its own--More--boundary before death processing continues.
- JS bug:
- JS split erosion into silent
erode_obj()plus asyncerode_obj_player(), but hero fire paths were still calling syncburnarmor(...), so the boundary-producing armor message never happened beforelosehp().
- JS split erosion into silent
- Fix:
- Added async
burnarmor_player()injs/zap.jsand used it for hero fire paths (beam hit, fire trap, zap-self fire). - Added
registerBurnarmorPlayer()injs/hack.jsfor the fire-trap path. - Tightened
erode_obj_player()wording to match C burn erosion (smouldersrather thanburns) and to use the real object name when no explicit descriptor is supplied.
- Added async
- Validation:
hi11first screen divergence advanced from step387to391.seed031_manual_direct.session.jsonremained green.seed032_manual_direct.session.jsonremained green.
- Remaining
hi11gap after this fix:- the next mismatch is later and map-state related (
%stale-cell vs wall), so the fire-hit / death-message ordering bug appears resolved.2026-03-13: chargen first-menu navigation must support real alternate prompt order
- the next mismatch is later and map-state related (
- Context:
- New coverage session
t12_s764_chargen_orderloop2was designed to drive chargen through race-first (/), gender-first ("), and alignment-first ([) menu order, with multiple reject/restart loops. - C replay was fine, but JS initially failed with
Input queue emptywhile still insideshowRaceMenu().
- New coverage session
- Root cause:
manualSelection()injs/chargen.jsonly truly implemented role-first flow.- Pressing
/,", or[from the initial role menu just cleared some local state and looped back through role-first assumptions instead of entering the corresponding alternate prompt order that C uses.
- Fix:
- Reworked chargen menu progression around generic candidate-combination
checks:
validRoleCandidates()validRaceCandidates()validGenderCandidates()validAlignCandidates()isCombinationPossible()
manualSelection()now tracks the current menu explicitly and can move through role/race/gender/alignment in the order chosen by the user, while still honoring filter constraints and rigid role/race/gender restrictions.
- Reworked chargen menu progression around generic candidate-combination
checks:
- Validation:
- Existing promoted chargen sessions remained green.
- Full chargen parity slice:
41/41passed. - New session
t12_s764_chargen_orderloop2.session.jsonis parity-green and materially non-redundant versus the existing chargen coverage sessions.
- Important lesson:
- Chargen coverage should intentionally test menu-order navigation, not just role-first happy paths. Those alternate-order menus are real gameplay logic, not UI sugar.
2026-03-13: sink gameplay parity requires C-local quaff branch + place_object() ordering
- Context:
- New Theme 01 sink coverage session
t01_s940_v_sinkmix2_gpextended a known-green seed940 exploration route to a reachable sink onDlvl:1. - The session exercises sink terrain look text, sink
#sit,drinksink, anddipsink.
- New Theme 01 sink coverage session
- Findings:
js/potion.js::handleQuaff()had the fountain prompt branch from Cdodrink(), but it was missing the immediately-following sink branch.- Result: on a sink square, JS incorrectly fell through to inventory
selection and emitted
You don't have anything to drink.instead of promptingDrink from the sink? [yn] (n). js/fountain.js::drinksink()andsink_backs_up()were creating sink-ring floor objects viamap.addObject(...)instead ofplace_object(...).- Result: JS missed the C
^place[...]event and shifted RNG/event ordering relative to the followingexercise(A_WIS)call.
- Fix:
- Added the C sink branch to
handleQuaff():- gated by
IS_SINK(loc.typ)andcan_reach_floor(player, map, false), - prompt text
Drink from the sink?, - dispatch to
drinksink(...).
- gated by
- Switched sink ring placement to
place_object(...)so the^place[...]event is emitted at the C ordering point. - Tightened
dipsink()non-potion carried-item messaging to match C-visible output:You hold the spear under the tap.- when erosion happens, also surface
Your spear rusts!
- Added the C sink branch to
- Validation:
node test/comparison/session_test_runner.js --sessions=test/comparison/sessions/coverage/furniture-thrones-fountains/t01_s940_v_sinkmix2_gp.session.json --parallel=1 --verbose- full parity green (
rng/events/screens/colors/cursor/mapdumpmatched)
- full parity green (
- Theme-01 marginal contribution:
+791lines+265branches+36functions
2026-03-13: hi11 death-boundary drift was partly a combat-message fidelity bug, then a lifesave attack-tail bug
- Context:
hi11_seed1100_wiz_zap-deep_gameplayhad a long-standing late divergence around the sleep-ray / death / lifesave boundary.- The old frontier showed large step-distribution drift across steps
405..408, with JS still paging through goblin-hit messages after C had already reachedYou die...and theDie? [yn]prompt.
- First root cause:
js/mhitu.jswas using the lightweightmondata.x_monnam()helper in combat hit/miss text. That helper is not visibility-aware, so JS emitted lines likeThe goblin hits!when C correctly usedIt hits!.- JS also emitted
just misses!whenevertoHit === dieRoll, but C only saysjust misses!whenflags.verboseis enabled. - Those longer JS messages created extra
--More--pages and shifted the whole monster-phase boundary.
- Fixes:
- Changed combat hit/miss/wildmiss messaging in
js/mhitu.jsto use visibility-awareMonnam()naming. - Gated
just misses!ongame.flags.verbose, matching Cmissmu().
- Changed combat hit/miss/wildmiss messaging in
- Result:
- The large
405..408drift disappeared. JS and C now match exactly through the death-message pages:- step
408:You die...--More-- - step
409:Die? [yn] (n) - step
410:OK, so you don't die. You survived that attempt on your life.
- step
- The large
- Second root cause exposed after the message fix:
- After wizard lifesaving, JS
mattacku()was breaking on_stopMoveloopAfterLifesavebefore running the successful-attack tail wakecheck (!rn2(10)), while C performs that wakecheck before deciding whether to stop further attacks.
- After wizard lifesaving, JS
- Fix:
- In
js/mhitu.js, runpostAttackTail(sum[i])before honoring the lifesave/game-over break.
- In
- Validation:
hi10_seed1090_wiz_potion-deep_gameplayremains green.hi11improved materially:- old first RNG divergence: index
3434, step410 - new first RNG divergence: index
3448, step410 - old step summary around
405..410: large cross-step drift - new step summary around
405..409: exact match
- old first RNG divergence: index
- Current remaining gap:
hi11now matches through the death/lifesave boundary and first wakecheck.- The next mismatch is later in step
410and appears to be a narrower turn-tail / post-turn sequencing issue rather than another message-boundary problem.
2026-03-13: queued canned commands need an explicit command-boundary more() when a topline message is live
- Context:
- Theme 04 dig/apply sessions
t04_s701_w_digedges_gpandt08_s700_w_apply_gpwere failing immediately after#applyon a pick-axe that was not already wielded. - C path:
apply.c/dig.cdoeswield_tool(obj, "swing"), then queuesdoapplyplus the tool invlet onCQ_CANNED, and the dismiss key for the wield message is not a gameplay command.
- Theme 04 dig/apply sessions
- Findings:
- JS had accumulated a bespoke
pendingPromptstate machine inhandleApply()to emulate this boundary. - The faithful fix was not another prompt owner. The real issue was that the
JS command loop did not treat
topMessage + queued CQ_CANNEDas a command boundary requiringmore(). - Result: the space used to acknowledge
You now wield a pick-axe.was read as a fresh top-level command, the queueddoapplystayed stranded, and the following<fell through intohandleUpstairs()(Escape the dungeon?) instead of staying inside pick-axe direction handling.
- JS had accumulated a bespoke
- Fix:
- Removed the bespoke pick-axe
pendingPromptowner fromjs/apply.js. - Made
handleApply()match C by queueing:cmdq_add_ec(CQ_CANNED, doapply-wrapper)cmdq_add_key(CQ_CANNED, selected.invlet)
- In
js/input.js,nhgetch({ commandBoundary: true })now treatsdisplay.topMessage && cmdq_peek(CQ_CANNED)as an explicit command-boundarymore()case, forcing the visible--More--and consuming only the dismiss key before returning0. - In
js/allmain.js, when command-boundarynhgetch()returns0, the loop now immediately drains queued canned commands viarunOneCommandCycle(0)instead of waiting for another real gameplay key first.
- Removed the bespoke pick-axe
- Validation:
t04_s701_w_digedges_gp.session.json: full parity greent08_s700_w_apply_gp.session.json: full parity greenscripts/run-and-report.sh --failures:Gameplay: 213/213 passing, 0 failing
- Important lesson:
- For C-style canned follow-up commands, the boundary belongs in the shared
command loop and
more()handling, not in per-command synthetic prompt owners.
- For C-style canned follow-up commands, the boundary belongs in the shared
command loop and
2026-03-13 15:08 - hi11 lifesave stop should finish current movemon pass, then stop before next cycle
- Context:
hi11_seed1100_wiz_zap-deep_gameplaystill had a monster-phase boundary failure after the lifesave prompt work was aligned.- Temporary seer-turn and
movemon()traces showed JS reachedmoveloop_core()step410withturnCount=42,movesForSeer=43,seerTurn=44, while C reached the seer-turn RNG at the same step. - The first attempted fix made JS run
moveloop_turnend()before stopping, but that exposed a new earlier mismatch: JS jumped straight intomcalcmove()and skipped C’s final^movemon_turn[116@31,8]plusdistfleeck()/m_move()work.
- Root cause:
- JS was using
_stopMoveloopAfterLifesavein two places that together were too aggressive:moveloop_core()treated lifesave like a hard abort and skippedmoveloop_turnend().movemon()itself aborted the rest of the current monster scan as soon as the lifesave flag was seen.
- C
savelife()setsmulti=-1andcontext.move=0, but it does not abort the currentmovemon()scan. C still finishes the current monster pass, then runs turn-end, then stops before another movement cycle.
- JS was using
- Fix:
- In
js/allmain.js, split the oldforceStopMoveLoopbehavior into:abortMoveLoop: only for real death / hard abortstopAfterTurnend: lifesave-specific, allowing the currentmoveloop_turnend()to run before stopping the outer loop
- In
js/mon.js, removed the_stopMoveloopAfterLifesavechecks that were aborting the currentmovemon()scan before later monsters in the same pass could act.
- In
- Result:
hi11now has full RNG parity and full event parity:rng=3469/3469events=609/609
hi10_seed1090_wiz_potion-deep_gameplayremains fully green.
- Remaining gap:
hi11is now screen/cursor-only:- first screen divergence: step
391 - first cursor divergence: step
378
- first screen divergence: step
- This is no longer a gameplay sequencing issue. The next work should focus on display/cursor rendering around the earlier screen drift, not monster turn logic.
2026-03-13: Tame-kill thunder + awaited player item destruction moved hi11 screen drift later
- Context:
- After the lifesave/moveloop fix,
hi11_seed1100_wiz_zap-deep_gameplaystill had full RNG/event parity but diverged on screens during the self-zap/lifesave sequence. - The earlier screen drift included missing tame-kill feedback and a JS-only
early jump from the lightning hit message straight to
You die....
- After the lifesave/moveloop fix,
- Root cause:
- JS
xkilled()was missing the C tame-pet alignment penalty message frommon.c:You_hear("the rumble of distant thunder...")(or the hallucination variant). - JS
zap.jsonly useddestroy_items_rng_only()for player-carried lightning/fire/cold destruction. That preserved RNG, but it skipped the C player-visible side effects frommaybe_destroy_item():- destruction messages
- unworn ring handling
- immediate exploding-wand
losehp()
- JS
- Fix:
- Made
xkilled()async and updated live call sites that need the message ordering (zap.js,music.js,muse.js,mhitu.jshelper path). - Added the missing tame-kill thunder/applause message in
js/mon.js. - Added an awaited player destruction path in
js/zap.jswhich keeps the same reservoir-sampling RNG structure but now also performs the C-visible side effects for destroyed hero-carried items, including:turns to dust and vanishesbreaks apart and explodes- exploding-wand
losehp("exploding wand")
- Made
- Result:
hi10_seed1090_wiz_potion-deep_gameplaystayed green.hi11_seed1100_wiz_zap-deep_gameplayremained full RNG/event parity and moved the first screen divergence later:- before this slice: screen
399/439, cursor394/399 - after this slice: screen
403/439, cursor399/403
- before this slice: screen
- The remaining
hi11failure is now a later map/cursor display drift around step401, not the death-message/item-destruction block.
2026-03-13 15:55 - lifesave stop flag must not abort inner mattacku loop
- Context:
- On current
main, pending coverage sessiont11_s744_w_covmax2_gp.session.jsonfirst diverged at step452. - C step
452shows:OK, so you don't die. The jackal bites! The orc hits!--More--
- JS had already restored the hero to positive HP, but then skipped those two adjacent hostile attacks and advanced to later pet/monster work.
- On current
- Evidence:
MHITUtrace at step452showed:enter mon=109 mndx=12break stopflag mon=109 i=0enter mon=107 mndx=72break stopflag mon=107 i=0
- Attack eligibility itself was correct:
- adjacent monsters had
range2=false foundyou=true- remembered target square matched hero square
- adjacent monsters had
- So the miss was not in
distfleeck,set_apparxy, or target acquisition.
- Root cause:
js/mhitu.jstreatedgame._stopMoveloopAfterLifesaveas an innermattacku()abort condition.- That flag is appropriate for stopping the outer command cycle after the current monster-processing pass, but C does not use it to abort the current monster attack stream.
- As a result, after lifesave the current command still iterated monsters, but each adjacent monster immediately broke out before its first attack.
- Fix:
- In
js/mhitu.js, remove_stopMoveloopAfterLifesavefrom themattacku()loop break gate. - Keep only real abort conditions there:
game.playerDiedgame.gameOver
- In
- Validation:
t11_s744_w_covmax2_gp.session.jsonimproved materially:- RNG matched:
3177/11024 -> 3262/11024 - events matched:
132/1941 -> 168/1782 - screens matched:
532/892 -> 560/892 - first RNG divergence moved: step
452 -> 480
- RNG matched:
- Regression checks stayed clean:
seed031_manual_direct.session.json: passseed032_manual_direct.session.json: passseed033_manual_direct.session.json: passscripts/run-and-report.sh --failures:Gameplay: 213/213 passing, 0 failing
- Practical lesson:
- Life-saving stop semantics belong at the outer moveloop/command-cycle
boundary, not inside
mattacku(). Inner attack dispatch should only stop on actual death/game-over, matching C’s control flow.
- Life-saving stop semantics belong at the outer moveloop/command-cycle
boundary, not inside
2026-03-13: hi11 display drift moved from step 401 to step 407 via blindness + invisible-memory fixes
- Context:
- After the earlier self-zap/lifesave fixes,
hi11_seed1100_wiz_zap-deep_gameplaystill had full RNG/event parity but diverged on screens around blindness and unseen-attacker map display. - The first screen drift was a one-frame stale visible monster glyph after
You are blinded by the flash!, followed by a later corpse-vs-Imismatch when an unseen attacker moved onto a corpse square.
- After the earlier self-zap/lifesave fixes,
- Root cause:
flashburn()injs/zap.jswas settingplayer.blinddirectly instead of using the existingmake_blinded()blindness path, so the immediate vision-toggle redraw did not happen.mattacku()injs/mhitu.jswas not marking unseen attackers withmap_invisible()onhitmu()/missmu()entry, unlike Cmhitu.c.newsym()injs/display.jsprioritized remembered objects overmem_invison unseen squares, so even aftermap_invisible()the%corpse glyph kept winning over the rememberedI.
- Fix:
- In
js/zap.js, route lightning blindness throughmake_blinded(). - In
js/mhitu.js, callmap_invisible()for unseen attacker squares on both miss and hit entry. - In
js/display.js, make unseen-squaremem_invistake precedence over remembered object/trap glyphs innewsym().
- In
- Validation:
hi10_seed1090_wiz_potion-deep_gameplay: still passes.hi11_seed1100_wiz_zap-deep_gameplayimproved substantially:- before this slice:
screens=403/439,cursor=399/403 - after this slice:
screens=437/439,cursor=429/437 - first screen divergence moved to step
407
- before this slice:
- Remaining
hi11gap after this slice:- full RNG parity:
3469/3469 - full event parity:
609/609 - remaining screen mismatch is a late status-line timing difference during
the death/
--More--sequence (HP:0shown one frame earlier in JS) - remaining cursor mismatch still starts at step
378
- full RNG parity:
2026-03-13 16:15 - lifesave stop must not cut off later movemon passes in the same monster phase
- Context:
- After fixing the inner
mattacku()abort,t11_s744_w_covmax2_gpmoved to a new frontier at step480. - C was still processing the kitten’s second movement slice in the same
monster phase:
^movemon_turn[32@7,4 mv=12->0]distfleeckdog_invent_decisiondog_goal_*
- JS instead jumped straight into turn-end movement allocation:
rn2(12)=... @ allocateMonsterMovement(mon.js:145)^mcalcmove[...]
- After fixing the inner
- Evidence:
- After the first kitten/newt exchange, JS runtime state still had:
- kitten
movement=12 multi=-1nomovemsg="You survived that attempt on your life."
- kitten
- So the remaining kitten turn was genuinely pending, not missing from state.
- The early jump came from
moveloop_core(), notdog_move():- JS honored
_stopMoveloopAfterLifesaveimmediately after onemovemon()pass, even whenmovemon()returnedmonscanmove=true.
- JS honored
- After the first kitten/newt exchange, JS runtime state still had:
- Root cause:
- The lifesave stop flag was still being applied too early between
movemon()passes inside the same “hero can’t move this turn” monster loop. - C
savelife()setscontext.move=0, but the current monster loop still drains already-allocated monster movement before turn-end.
- The lifesave stop flag was still being applied too early between
- Fix:
- In
js/allmain.js, only honor_stopMoveloopAfterLifesaveaftermovemon()whenmonscanmoveis false. - If monsters still have movement left, keep draining the current monster phase first; stop only before the next command cycle.
- In
- Validation:
t11_s744_w_covmax2_gp.session.jsonimproved again:- RNG matched:
3262/11024 -> 9807/11227 - events matched:
168/1782 -> 949/1761 - screens matched:
560/892 -> 691/892 - first RNG divergence moved: step
480 -> 667
- RNG matched:
- Guardrail sessions stayed green:
seed031_manual_direct: passseed032_manual_direct: passseed033_manual_direct: passscripts/run-and-report.sh --failures:Gameplay: 213/213 passing, 0 failing
- Practical lesson:
- There are two distinct lifesave stop hazards:
- aborting the current monster’s inner
mattacku()loop, - aborting later
movemon()passes in the same monster phase.
- aborting the current monster’s inner
- C does neither. Life-saving only suppresses the next command-cycle start after the current monster phase and turn-end work have drained.
- There are two distinct lifesave stop hazards:
2026-03-13 - dochug() MMOVE_MOVED must not fall through to generic Phase 4
- Context:
- Pending coverage session
t11_s744_w_covmax2_gpstill had first RNG drift at step667after the lifesave fixes. - Raw comparison showed two JS-only
rn2(5)rolls after the imp finished moving and before the next monster turn banner.
- Pending coverage session
- C behavior:
- In
monmove.c dochug(), theswitch (status)handlesMMOVE_MOVEDspecially. - A moved monster normally returns immediately.
- It only falls through to Phase 4 if:
- it is not nearby, and
- it still has a ranged/offensive follow-up.
- Nearby moved monsters therefore do not continue into the generic Phase 4 melee/cuss tail.
- In
- JS bug:
- JS was setting
phase4Allowed = moveStatus !== MMOVE_DONE, which letMMOVE_MOVEDmonsters keep flowing into the shared Phase 4 logic and the later vile-monster!rn2(5)cuss gate. - For the imp at step
667, this produced stray post-move RNG and shifted the next iguana’s track-roll sequence.
- JS was setting
- Fix:
- Port the C
switch (status)structure more faithfully injs/monmove.js. - For
MMOVE_MOVED:- return immediately for ordinary moved monsters,
- only allow Phase 4 when the C ranged/offensive follow-up condition holds,
- keep the engulfing special case inline with the C branch.
- Port the C
- Validation:
t11_s744_w_covmax2_gp.session.jsonimproved:- first RNG divergence moved
667 -> 722 - matched RNG
9807/11227 -> 9969/11024 - matched events
949/1761 -> 1044/1715 - matched screens
691/892 -> 693/892
- first RNG divergence moved
- Guardrails remained green:
seed031_manual_directseed032_manual_directseed033_manual_direct
2026-03-13 - dochug() still blocks MMOVE_DONE from the generic attack block
- Context:
- After the
MMOVE_MOVEDswitch cleanup,hi11_seed1100_wiz_zap-deep_gameplayreopened at step405with JS throwing immediately after goblin pickup while C advanced to the next monster turn first.
- After the
- C behavior:
monmove.cletsMMOVE_DONEpass through the switch for redraw/side-effect handling, but the later attack block is gated byif (status != MMOVE_DONE && ...).- A monster that moved and then converted its turn to
MMOVE_DONEinpostmov()(for example aftermpickstuff()) does not get the genericmattacku()pass.
- JS bug:
- The earlier
MMOVE_MOVEDrewrite treatedMMOVE_DONElike the other non-move statuses and leftphase4Allowed = true. - That let a goblin attack immediately after
^pickup[...], consuming the dart-throw RNG that C does not spend until later.
- The earlier
- Fix:
- Keep the stricter
MMOVE_MOVEDhandling, but restore the separate C gate:MMOVE_DONEsuppresses the later generic attack block.
- Keep the stricter
- Validation:
hi11_seed1100_wiz_zap-deep_gameplayreturned to full gameplay parity:- RNG
3469/3469 - events
609/609 - screens/colors
439/439,10536/10536 - remaining gap is cursor-only at step
378
- RNG
hi10_seed1090_wiz_potion-deep_gameplaystayed fully green.t11_s744_w_covmax2_gpwas unchanged versus clean5382cfcebaseline.
2026-03-13 - getdir() prompt cursor sits one column past the visible prompt text
- Context:
- After restoring
hi11gameplay parity, the remaining mismatch on that seed was cursor-only at step378:- C cursor
[19,0,1] - JS cursor
[18,0,1] - topline text in both cases:
In what direction?
- C cursor
- After restoring
- C behavior:
getdir()delegates toyn_function(...).- TTY prompt handling leaves the cursor one column past the visible prompt, even when the saved topline text does not include a trailing space.
- JS bug:
- the direct
getdir()prompt owners injs/hack.jsandjs/zap.jswere setting the cursor atdirPrompt.length, one column too early.
- the direct
- Fix:
- set the direction-prompt cursor at
dirPrompt.length + 1in those two explicit prompt-owner paths.
- set the direction-prompt cursor at
- Validation:
hi11_seed1100_wiz_zap-deep_gameplayimproved cursor parity from431/439to435/439; first cursor divergence moved from step378to step412.hi10_seed1090_wiz_potion-deep_gameplayremained fully green.
2026-03-13 - wizard.c:cuss() uses com_pager("demon_cuss"), not a fixed fallback line
- Session:
t11_s744_w_covmax2_gp - Symptom: after fixing the earlier
MMOVE_MOVEDfallthrough, the new first RNG divergence at step 722 looked like missing quest-style pager RNG:rn2(3), rn2(2), rn2(27). - Root cause: this was not quest-portal logic. The session screen at the next
step showed a quoted taunt:
"Thou hadst best fight better than thou canst dress!"That comes fromwizard.c:cuss()->com_pager("demon_cuss"). - C behavior:
- non-wizard demons/minions sometimes call
com_pager("demon_cuss") com_pager_core()loadsquest.luathrough the Lua pager path- that path pays the usual
nhlib.luastartup RNG tollrn2(3), rn2(2)before selecting an entry from thedemon_cussarray withrn2(27)
- non-wizard demons/minions sometimes call
- JS bug:
js/wizard.jsstill collapsed bothangel_cussanddemon_cussinto the placeholder linecasts aspersions on your ancestry.- so JS consumed the branch-gating RNG but skipped the real common-pager RNG and text selection
- Fix:
- wire
wizard.cuss()toquestpgr.com_pager(...) - give
js/questpgr.jsa real common-section pager path forangel_cuss/demon_cuss - preserve the C pager RNG shape by consuming
rn2(3), rn2(2)before the array-selectionrn2(n)
- wire
- Result:
t11_s744_w_covmax2_gpimproved from RNG9969/11024to9978/11024- screen parity improved from
693/892to711/892 - cursor parity improved from
682/692to698/710 - the first RNG divergence moved later, from step
722to step734
2026-03-13 16:51 - hi11 death-status boundary fixed by restoring deferred botl semantics
- Context:
hi11_seed1100_wiz_zap-deep_gameplayalready had full RNG/event parity, but the remaining screen drift was in late death/lifesave frames.- The concrete failures split into two opposite cases:
- self-zap death staging wanted
HP:0on the pending hit-message frame, - monster-phase death staging wanted to keep the old
HP:3through the staleIt hits!--More--/You die...--More--frames, then switch toHP:0at theDie? [yn]prompt.
- self-zap death staging wanted
- Root cause:
- JS
losehp()andsavelife()were not marking the status line dirty the way Cdisp.botl = TRUEdoes. more()was compensating by redrawing status unconditionally, which hid the missing dirty-bit propagation but produced the wrong timing at tricky death boundaries.- The late monster-phase death case is special because
You die...arrives whilecontext.mon_movingis still true and the pending topline belongs to an earlier monster hit, so the forced death-stagingmore()must not consume the pending HP update yet. ynFunction()also lacked any status flush before drawingDie? [yn], so once the monster-phase suppression was added there was nowhere that the deferredHP:0update would be consumed.
- JS
- Fix:
- In
js/hack.js, makelosehp()mark deferred status updates viaplayer._botl = trueand record the replay step index for diagnostics. - In
js/end.js, makesavelife()do the same when it restores HP. - In
js/headless.jsandjs/display.js, tag each topline with the HP and replay-step snapshot under which it was rendered. - In
js/input.jsmore(), stop doing unconditional status redraws; only consume a pending status update when the current topline still belongs to the same replay-step update context. - In
js/headless.js, for the three death-stagingmore()calls used while printingYou die..., suppress status refresh only whenactiveGame.context.mon_movingis true. That keeps self-zap/player-command deaths on the normal path while preserving the C monster-phase staging. - In
js/input.jsynFunction(), flush any pending status update before drawing the prompt line, soDie? [yn]consumes the deferredHP:0.
- In
- Validation:
hi11_seed1100_wiz_zap-deep_gameplaynow has full gameplay parity:- RNG:
3469/3469 - events:
609/609 - screens/colors/mapdump: full match
- remaining non-green channel is cursor only (
431/439)
- RNG:
hi10_seed1090_wiz_potion-deep_gameplay: still passes.scripts/run-and-report.sh --failuresafter the slice:Gameplay: 202/213 passing, 11 failing- no PRNG/event regressions introduced; failures remain screen-only.
- Practical lesson:
- If
more()seems to need unconditional status redraws, that is usually a sign that the realdisp.botl/deferred-status source is missing. - Death staging is not one generic case: player-command deaths and
monster-phase deaths can require opposite behavior at the same
You die...boundary, so the discriminator has to come from real runtime context (context.mon_movinghere), not broadplayerDiedguards.
- If
2026-03-13 17:36 - hi11 zap invalid-inventory boundary fixed by prompt-owned visual –More–
- Context:
- After the earlier
getdir()cursor fix,hi11_seed1100_wiz_zap-deep_gameplaywas down to one cursor-only/display-only gap around the invalid wand-letter path insidedozap(). - C sequence there is:
zshowsWhat do you want to zap?...- invalid inventory letter shows
You don't have that object.--More-- - one key dismisses that boundary while leaving the frame visible
- the following key re-shows the zap prompt
- After the earlier
- Root cause:
- JS still handled wand selection inside a private
while (await nhgetch())loop injs/zap.js, whilegetdir()had already been moved onto the sharedpendingPromptowner contract. - An initial refactor moved zap inventory selection onto
pendingPrompt, but it incorrectly setdisplay.messageNeedsMore = truefor the invalid-letter error frame. - That let
nhgetch({ commandBoundary: true })steal the dismiss key through the generic boundary path beforezap.selectsaw it, producing blank frames and a late re-prompt.
- JS still handled wand selection inside a private
- Fix:
- Move zap inventory selection itself onto a
pendingPromptowner (source: 'zap.select') so wand choice, invalid-letter handling, and re-prompting stay in the normal command/prompt boundary machinery instead of a hidden nested input loop. - Keep the invalid-letter boundary fully owned by
zap.select: render a visible--More--marker and cursor position directly on the topline, but do not setmessageNeedsMoreormoreMarkerActive. - Reuse the same prompt-owner handoff into
zap.getdir, so both stages ofdozap()now follow one explicit owner model.
- Move zap inventory selection itself onto a
- Validation:
hi11_seed1100_wiz_zap-deep_gameplay: fully green- RNG
3469/3469 - events
609/609 - screens/colors/cursor
439/439
- RNG
hi10_seed1090_wiz_potion-deep_gameplay: still fully greenscripts/run-and-report.sh --failureson the slice:Gameplay: 202/214 passing, 12 failing- PRNG
213/214, events214/214, screen203/214
hi11_seed1100_wiz_zap-deep_gameplaywas promoted fromsessions/pendingtosessions/coverage/spells-reads-zaps/
- Practical lesson:
- A prompt-owned
--More--frame is not the same thing as a global toplinemessageNeedsMoreboundary. If the prompt owner is supposed to consume the next key, rendering the marker must stay visual-only; otherwisenhgetch(commandBoundary)will steal the key and create blank or delayed prompt frames.
- A prompt-owned
-
2026-03-13:
seed323_caveman_wizard_gameplaywas blocked by a single latepeace_minded()RNG mismatch after a stair transition, but the true root cause was not stairs. JS still treatedALIGNLIMas a fixed14, while C defines it dynamically as10 + floor(svm.moves / 200). During the run, hero-killing amanesincorrectly raisedalignmentRecordfrom10 -> 14in JS; in C the live cap was still10, so the same kill stayed clamped and laterpeace_minded()rolledrn2(26)rather thanrn2(30). Fixing JS to use a live alignment cap inadjalign()and theALIGNLIM-derived quest/ priest bonuses restored parity. A fresh rerecord ofseed323with the current harness also removed a stale stair-boundary cursor artifact from the old fixture. - read.c parity: after scroll effects, JS must follow C
learnscroll()vstrycall()branching; always promptingdocall()is too aggressive and can pull later gameplay/input earlier. - Armor scroll effects mutate worn-armor enchantment and removal state; because JS caches
player.ac, read-side armor mutations must callfind_ac(player)immediately or the next status line can lag C by one prompt boundary. - read.c parity:
seffect_light()must set the same effect-known flag as C (gk.known) when the hero visibly reads scroll of light or whenlightdamage()reveals the effect; otherwise JS falls into a bogus post-readtrycall()/naming prompt and drags subsequent gameplay earlier.
2026-03-13 22:10 - pnd_s1200 prayer/search pending-session frontier moved from step 868 to 904
- Context:
pnd_s1200_w_potprayspell_gp.session.jsonwas the next pending coverage session after the promoted gameplay PES suite went all-green.- The initial blocker was at step
868:- RNG mismatch: JS entered
dochug()while C first didexercise(attrib.c:506). - Event mismatch: downstream pet
distfleeckstate drifted (brave=1vs0).
- RNG mismatch: JS entered
- Root cause 1:
- JS
detect.js:mfind0()suppressed blind repeated monster finds based on stickyloc.mem_invis. - C only suppresses that path when the live glyph on the square is
glyph_is_invisible(levl[x][y].glyph). - In this session, the blind hero repeatedly re-finds the adjacent kitten as it wanders away and back. JS incorrectly skipped the second find at step
868, so it missedexercise(A_WIS, TRUE)and the whole monster phase shifted.
- JS
- Fix 1:
- Change
mfind0()to checkglyph_is_invisible(loc.glyph)instead ofloc.mem_invisbefore returning-1.
- Change
- Result 1:
pnd_s1200_w_potprayspell_gpmoved from step868to step894.- RNG improved from
2775/3125to3063/3125. - Events improved from
226/450to394/417.
- Root cause 2:
- The new frontier at step
894was insideprayer_done() -> gods_upset() -> angrygods(). - C rolled
attrcurse()case4, fell through to intrinsicSEE_INVIS, removed it, and skippedrndcurse(). - JS
sit.js:attrcurse()was still checking placeholder booleans liketelepathy_intrinsicandsee_invisible_intrinsicrather than the realplayer.uprops[prop].intrinsic & INTRINSICstate, so it removed nothing and incorrectly calledrndcurse().
- The new frontier at step
- Fix 2:
- Rewrite
attrcurse()to use actualupropsintrinsic bits with C-style fallthrough semantics. - Preserve the C side effects for
TELEPATandSEE_INVIS:see_monsters()on lost blind telepathyset_mimic_blocking(); see_monsters(); newsym(player.x, player.y)when losing intrinsic see invisible and no other source remains
- Rewrite
- Result 2:
pnd_s1200_w_potprayspell_gpmoved again, from step894to step904.- RNG improved further to
3066/3125. - The
angrygods()/prayer branch is no longer the blocker.
- Validation:
scripts/run-and-report.sh --failuresafter both fixes stayed fully green:Gameplay: 219/219 passing- PRNG
219/219, Events219/219, Screen219/219
hi11_seed1100_wiz_zap-deep_gameplaystill passes fully.pnd_s1200_w_potprayspell_gpnow fails later, at step904.
- Practical lesson:
mem_invisis remembered state, not a substitute for C’s live glyph tests. Using it in parity-critical control flow can suppress legitimate repeated finds.- Intrinsic-removal code must read and clear
player.uprops[prop].intrinsicdirectly. Ad hoc*_intrinsicbooleans are not trustworthy in this codebase and will silently mis-port C fallthrough logic.
2026-03-13: blind : look_here path consumes time and pages before object text
- Context:
- After the
mfind0()andattrcurse()fixes,pnd_s1200_w_potprayspell_gp.session.jsonreached a later gameplay frontier at step904. - The session sequence around steps
903-904is blind:on a doorway:- step
903:You try to feel what is lying here on the doorway.--More-- - step
904: dismiss--More--, then the monster turn runs, thenYou feel no objects here.
- step
- JS
pager.js:dolook()was still a simplified one-message helper that always returned{ tookTime: false }.
- After the
- Root cause:
- C
invent.c:dolook()delegates tolook_here(), and in the blind pathlook_here():- emits a first “try to feel …” page,
- waits at
--More--, - then emits the actual terrain/object result text,
- and returns
ECMD_TIMEunless the hero cannot reach the floor.
- JS skipped that blind control flow entirely, so
:did not consume time and the whole post-command monster tail at step904was missing.
- C
- Fix:
- Port the blind
:control flow injs/pager.js:- add a blind surface-name helper for doorway/ice/etc.,
- emit the blind intro page and wait with explicit
more(...), - return
tookTime: truefor the blind path, - use blind wording in the follow-up object text (
You feel ...,You feel no objects here.), - suppress duplicate terrain text when the blind intro already identified the doorway/ice.
- Port the blind
- Result:
pnd_s1200_w_potprayspell_gpreached full gameplay parity:- RNG
3125/3125 - events
417/417
- RNG
- Remaining gaps are now display-only:
- first screen/color divergence at step
756 - first cursor divergence at step
827
- first screen/color divergence at step
- Validation:
node test/comparison/session_test_runner.js --verbose test/comparison/sessions/pending/pnd_s1200_w_potprayspell_gp.session.json- RNG full
- events full
hi11_seed1100_wiz_zap-deep_gameplay.session.jsonstill passes fully.
- Practical lesson:
- Blind look-here is not a cosmetic one-liner. In C it is a paged, time-consuming command with its own message boundary.
- When a pending session shows a whole post-command monster tail missing after a non-movement command, first check whether the command should have returned
ECMD_TIMEin C.
2026-03-13: do_break_wand() must confirm before consuming time or RNG
- Context:
t11_s744_w_covmax2_gp.session.jsonwas still failing late with:- first screen divergence at step
516 - first event divergence at step
723 - first RNG divergence at step
734
- first screen divergence at step
- The step window around the RNG break was:
719..723:#sit<enter>with two--More--dismissals726..729:a n <space> <space>
- The C session screens show that
a nis a canceled break-wand prompt:- step
726:What do you want to use or apply? [ck-uE or ?*] - step
727:Are you really sure you want to break your tin wand? [yn] (n) - step
728: space accepts defaultn - step
729:Unknown command ' '.
- step
- Root cause:
js/apply.jsstill had a stubbeddo_break_wand()which:- skipped the C
paranoid_query(...)confirmation, - immediately emitted the break message,
- immediately called
break_wand(...), - always consumed time.
- skipped the C
- That meant JS started wand-break RNG as soon as the wand invlet was selected, while C was still sitting at the
ynprompt and then canceling on the defaultn.
- Fix:
- Port the C-shaped
do_break_wand()gate:- reject if the hero has no hands,
- reject if the hero has no free hand,
- reject if strength is below the
glass/balsaor normal threshold, - ask
Are you really sure you want to break ${yname(obj)}? [yn] (n), - return
false/ no time on non-y, - only emit the break message and call
break_wand(...)ony.
- Thread the returned
tookTimeflag back throughhandleApply()instead of always forcingtookTime: true.
- Port the C-shaped
- Result:
t11_s744_w_covmax2_gpbecame fully green on gameplay parity:- RNG
11024/11024 - events
1715/1715
- RNG
- Remaining mismatches are display-only:
- first screen/color divergence at step
516 - first mapdump divergence at
d0l3_002 - first cursor divergence at step
589
- first screen/color divergence at step
- Validation:
node test/comparison/session_test_runner.js --verbose test/comparison/sessions/pending/t11_s744_w_covmax2_gp.session.json- RNG full
- events full
- Guardrails:
seed031_manual_direct.session.json: passseed032_manual_direct.session.json: passseed033_manual_direct.session.json: passt08_s700_w_apply_gp.session.json: pass
- Practical lesson:
- When a late RNG fork appears to start in an action helper, inspect the session key window first. Here the keys showed a
yndefault-cancel flow, which made the missing confirmation gate obvious. #applyhelpers must return a realtookTimeresult. For confirmation-gated actions, assuming unconditional time consumption will shift later monster-turn work even if the visible prompts appear to line up.
- When a late RNG fork appears to start in an action helper, inspect the session key window first. Here the keys showed a
2026-03-14: Per-step map-cell caching removes masked hallucination repaint drift
- Context:
- After the blind
:fix,pnd_s1200_w_potprayspell_gp.session.jsonwas gameplay-green but still had an earlier display-only failure:- first screen divergence at step
756 - first cursor divergence at step
827
- first screen divergence at step
- The bad frame appeared during hallucination-heavy redraws, where the same square was rendered multiple times within one replay step.
- After the blind
- Root cause:
- C reuses the already-chosen glyph for the current cell within a step.
- JS was re-running
monsterMapGlyph(mon, hallu)on every redraw path (newsym(),show_glyph(), full-map repaint), so a hallucinating monster square could change glyph mid-step even when gameplay state had not changed. - That earlier repaint instability masked a later, real wrapped-
getlin()prompt bug in the potion naming flow.
- Fix:
- Add per-step cached display cells on
locinjs/display.js:cacheMapCell(loc, map, ch, color, attr)getCachedMapCell(loc, map)
- Use the cached cell during full map repaints in both:
- Keep hero rendering uncached so the player glyph still wins at the live hero square.
- Add per-step cached display cells on
- Result:
pnd_s1200_w_potprayspell_gpfirst screen divergence moved from step756to step853.- RNG/events stayed exact:
- RNG
3125/3125 - events
417/417
- RNG
hi11_seed1100_wiz_zap-deep_gameplay.session.jsonremained fully green.- The promoted gameplay suite stayed stable:
scripts/run-and-report.sh --failuresstill reports the same 11 known screen-only failures and no new regressions.
- Practical lesson:
- When a display-only failure involves hallucination, check whether JS is re-sampling randomized glyph choice during repeated redraws inside the same replay step.
- Fixing an earlier masked repaint bug is still worth landing even if it only exposes the next prompt/display bug underneath.
2026-03-14: tty_getlin() accepts at most COLNO - 1 characters
- Context:
- After the per-step hallucination cache fix,
pnd_s1200_w_potprayspell_gp.session.jsonmoved to a later wrapped naming prompt drift:- first screen divergence at step
853 - JS echoed extra spaces and
#pon the wrapped second prompt row - C kept the prompt frozen after step
849
- first screen divergence at step
- Gameplay was already exact:
- RNG
3125/3125 - events
400/400
- RNG
- After the per-step hallucination cache fix,
- Root cause:
- C
win/tty/getline.c:169only appends printable input whilebufp - obufp < COLNO. - In practice tty
getlin()accepts at mostCOLNO - 1characters of typed input, independent of prompt length. - JS
getlin()had no equivalent clamp, so once the typed name reached the tty limit it kept appending more spaces and later#p, creating a fake wrapped-prompt divergence.
- C
- Fix:
- In
js/input.js, clamp livegetlin()input to(disp.cols || 80) - 1characters before appending printable keys.
- In
- Result:
pnd_s1200_w_potprayspell_gpfirst screen divergence moved later again:- from step
853 - to step
867
- from step
hi11_seed1100_wiz_zap-deep_gameplay.session.jsonremained fully green.scripts/run-and-report.sh --failuresstayed stable on the promoted suite.
- Practical lesson:
- For tty prompt parity, buffer limits matter as much as visible wrapping.
- If C stops echoing prompt input while JS keeps extending the line, check the tty input acceptance rule before redesigning prompt ownership.
2026-03-14: Blind feel_location() must clear stale invisible markers
- Context:
- After the
tty_getlin()width clamp,pnd_s1200_w_potprayspell_gp.session.jsonmoved to a later blind display drift:- first screen divergence at step
867 - JS kept a remembered
Ione step longer than C on a square adjacent to the blind hero
- first screen divergence at step
- Gameplay remained exact:
- RNG
3125/3125 - events
400/400
- RNG
- After the
- Root cause:
- C
display.c:feel_location()does not leaveGLYPH_INVISIBLEin place once the hero feels a changed square, unless an invisible monster is still actually there. - JS
feel_location()was only a shallow wrapper aroundmap_location()plus sensed-monster display and never clearedloc.mem_invis. - That left stale remembered-invisible markers behind during blind adjacent updates, even after the square’s contents changed.
- C
- Fix:
- In
js/display.jsfeel_location()now:- preserves the marker only if
loc.mem_invisis present and an actually invisible monster still occupies the square, - otherwise clears
loc.mem_invisbefore remapping the location.
- preserves the marker only if
- In
- Result:
pnd_s1200_w_potprayspell_gpmoved later again:- first screen divergence from step
867 - to step
878
- first screen divergence from step
hi11_seed1100_wiz_zap-deep_gameplay.session.jsonstayed fully green.- Promoted-suite failure report stayed stable.
- Practical lesson:
- Blind map parity is not just “don’t show what the hero can’t see.”
- The feel/update path has its own cleanup semantics, especially for stale invisible markers and nearby floor memory.
2026-03-14 - t11_s744: death-staging more() refresh must respect frozen-turn state
- Problem:
t11_s744_w_covmax2_gphad full RNG and event parity, but the first screen mismatch was still at step516:- JS:
HP:0(12) - session/C:
HP:1(12)
- JS:
- The mismatch was display-only and occurred while a pending monster-move topline was being paged.
- Key evidence:
- Headless tracing showed the wrong repaint came from
more()insideHeadlessDisplay.putstr_message()when"You die..."arrived while another paged message was still pending. - The same broad stack also happened earlier in an acceptable case around step
445, so the stack alone was not the differentiator. - The decisive state difference was
game.multi:- earlier acceptable case:
multi = 0 - bad step-515/516 case:
multi < 0(-125in the trace)
- earlier acceptable case:
- Headless tracing showed the wrong repaint came from
- Fix:
- In the headless death-staging
more()path only, suppress the status refresh when:context.mon_movingis true, andmulti < 0
- Leave ordinary
more()refresh behavior unchanged.
- In the headless death-staging
- Result:
t11_s744_w_covmax2_gpfirst screen divergence moved from516to543.- Guardrails remained green:
seed031_manual_directseed032_manual_directseed033_manual_direct
- Practical lesson:
- When the same message/display stack appears in both good and bad windows, compare broader command-cycle state, not just topline flags.
multi < 0matters for display parity: it marks a frozen/continued turn state where repainting status during a death-staging--More--can expose post-damage HP too early.
2026-03-14: Blind feel_location() must also mark adjacent squares as seen
- Context:
- After clearing stale invisible markers,
pnd_s1200_w_potprayspell_gp.session.jsonstill had a late blind-search screen miss:- first screen divergence at step
878 - C rendered
#@Iwhile JS rendered ` @I`
- first screen divergence at step
- The missed glyph was the left-adjacent felt corridor/floor memory, not the invisible-monster marker.
- After clearing stale invisible markers,
- Root cause:
- C
display.c:feel_location()always callsset_seenv()before remapping a blindly felt square. - JS
feel_location()never updatedloc.seenv, so adjacent blind searches could keep stale “unseen/blank” memory instead of turning into remembered corridor/floor glyphs.
- C
- Fix:
- In
js/display.jsfeel_location()now calls:set_seenv(loc, player.x, player.y, x, y)beforemap_location(...).
- In
- Result:
pnd_s1200_w_potprayspell_gpreached full screen parity:- screens
905/905
- screens
- Remaining mismatches are now:
- color first divergence at step
866 - cursor first divergence at step
827
- color first divergence at step
hi11_seed1100_wiz_zap-deep_gameplay.session.jsonremained fully green.
- Practical lesson:
- Blind “feeling” is not only about choosing which glyph to show.
- The hero’s memory model (
seenv) must be updated at the same time, or JS will keep blank remembered cells where C has already converted them into corridor/room memory.
2026-03-14: mhitm corpse creation must redraw the death square immediately
- Context:
- After the frozen-turn headless fix,
t11_s744_w_covmax2_gp.session.jsonstill had:- full RNG parity
- full event parity
- a first screen divergence at step
516
- The visible miss was a stale map cell: C showed
%, JS still showed floor.
- After the frozen-turn headless fix,
- Root cause:
- The death happened in monster-vs-monster combat, inside
js/mhitm.jsmdamagem(...). - JS already created the corpse with
mkcorpstat(...), but that path did not redraw the square afterward. - A targeted trace confirmed
newsym(7,2)was never called on the corpse-creation step. - C
mhitm.croutes this throughmonkilled(...), and Cmake_corpse()ends withnewsym(x, y).
- The death happened in monster-vs-monster combat, inside
- Fix:
- After
mkcorpstat(CORPSE, ...)inmdamagem(...), JS now calls:newsym(mdef.mx, mdef.my)
- After
- Result:
t11_s744_w_covmax2_gpmoved:- first screen divergence
516 -> 543
- first screen divergence
- RNG stayed full:
11024/11024
- Events stayed full:
1644/1644
- Guardrails remained green:
seed031_manual_directseed032_manual_directseed033_manual_direct
- Practical lesson:
- When parity is full on RNG/events but a monster death leaves a stale glyph, check whether the corpse/drop path redraws the square immediately.
- In
mhitmspecifically, hand-rolled corpse creation is risky because it bypasses the redraw behavior that C gets automatically frommake_corpse()/monkilled().
2026-03-14: pnd_s1200 blind prompt/display cleanup moved to final cursor-only gap
- Context:
- After the blind
set_seenv()fix,pnd_s1200_w_potprayspell_gp.session.jsonwas no longer blocked on gameplay or screen shape. - The remaining drift was a sequence of prompt/display mismatches:
- a freshly felt room square showed
CLR_GRAYinstead of rememberedNO_COLOR getlin()cursor motion diverged at theCOLNO - 1boundary and on ignored extra keystrokes- paginated inventory footer cursors were too far right in the full-screen inventory path
- a freshly felt room square showed
- After the blind
- Fixes:
js/display.jsmap_background(show=1)now renders room memory with the same remembered color (NO_COLOR) that it stores inloc.mem_terrain_color, instead of redrawing with live terrain gray.
js/input.jsgetlin()now treatsCOLNO - 1as the tty wrap width for cursor behavior.- Added a small
overflowCursormodel so ignored printable keys past the line limit move the wrapped cursor like ttygetlin():- empty wrapped row case:
79,0 -> 1,1 -> 2,1 ... - visible wrapped second-line case: cursor advances one past visible overflow and then holds.
- empty wrapped row case:
js/invent.js- inventory page counters (
" (N of M)") now use the C footer column/cursor rules in both overlay and full-screen inventory rendering.
- inventory page counters (
- Validation:
node test/comparison/session_test_runner.js test/comparison/sessions/pending/pnd_s1200_w_potprayspell_gp.session.json- RNG
3125/3125 - events
400/400 - screens
905/905 - colors
21720/21720 - remaining cursor-only mismatch moved very late to step
903
- RNG
node test/comparison/session_test_runner.js test/comparison/sessions/coverage/spells-reads-zaps/hi11_seed1100_wiz_zap-deep_gameplay.session.json- still fully green
- Remaining frontier:
pnd_s1200still has one late cursor-only mismatch at step903:- C expects cursor after the top-line
--More-- - JS still reports the cursor at the end of the base message
- C expects cursor after the top-line
- This is now a narrow topline
more()cursor-state issue, not a gameplay/state mismatch and not a prompt-layout mismatch.
- Practical lesson:
- Late display/cursor cleanup is easiest once gameplay parity is already exact.
- For tty-faithful prompt work, distinguish three separate concepts:
- buffer limit (
COLNO - 1) - visible wrapped text
- cursor movement on ignored extra keystrokes
- buffer limit (
2026-03-14: blind dolook() must request a visible --More-- marker
- Context:
- After the prompt/input/footer cleanup,
pnd_s1200_w_potprayspell_gp.session.jsonhad only one mismatch left:- step
903 - cursor-only
- step
- C showed:
You try to feel what is lying here on the doorway.--More--- cursor after
--More--
- JS showed only the base message and left the cursor at the end of the text.
- After the prompt/input/footer cleanup,
- Root cause:
- The blind
dolook()intro injs/pager.jscalled:await more(display, { site: 'pager.dolook.blindLook.morePrompt' })
more()only renders a marker whenforceVisual: trueis requested or when the display implementation itself already emitted one.- This callsite wanted an explicit visible marker for parity with C
look_here()paging, but never asked for it.
- The blind
- Fix:
pager.js:dolook()blind intro now calls:await more(display, { site: 'pager.dolook.blindLook.morePrompt', forceVisual: true })
- Result:
pnd_s1200_w_potprayspell_gp.session.jsonis now fully green:- RNG
3125/3125 - events
400/400 - screens
905/905 - colors
21720/21720 - cursor
905/905
- RNG
hi11_seed1100_wiz_zap-deep_gameplay.session.jsonremained fully green.
- Practical lesson:
- An explicit
more()boundary and a visible--More--marker are separate decisions in JS. - Any tty-faithful gameplay page that is visibly blocked in C must request
forceVisual: trueunless the display path already rendered the marker itself.
- An explicit
2026-03-14: rloc_to_core() must remove the monster from its old square before newsym(oldx, oldy)
- Context:
seed329_rogue_wizard_gameplay.session.jsonwas failing only in the strict gameplay runner.- Parity was otherwise full:
- RNG
15900/15900 - events
14269/14269 - mapdump
2/2
- RNG
- The only miss was screen/color at step
362:- JS still showed the water nymph glyph
n - C already showed the underlying corridor
#
- JS still showed the water nymph glyph
- Root cause:
- The discrepancy happens on the nymph theft/teleport boundary:
- step
361: both C and JS still shown - step
362: both sayShe stole a +0 short sword. The water nymph vanishes! - but only C clears the old square immediately
- step
- In
js/teleport.js,rloc_to_core()called:newsym(oldx, oldy)- before removing the monster from its old coordinates
- In this JS port,
newsym()consults live map state viamap.monsterAt(x, y), so it repainted the monster onto its stale location. - C
teleport.c:rloc_to_core()does:remove_monster(oldx, oldy);newsym(oldx, oldy);place_monster(mtmp, x, y);newsym(x, y);
- The discrepancy happens on the nymph theft/teleport boundary:
- Fix:
- In JS
rloc_to_core(), clearmtmp.mx/mybefore the old-square redraw, then place the monster at the destination and redraw the new square as before.
- In JS
- Result:
seed329_rogue_wizard_gameplay.session.json: fully green- Guardrails remained green:
seed031_manual_directseed032_manual_directseed033_manual_direct
t11_s744_w_covmax2_gp.session.jsonstayed at its current frontier:- first screen divergence
543
- first screen divergence
- Practical lesson:
- Any relocation path that redraws the old square must first remove the moving monster from old map occupancy.
- In this codebase,
newsym()is not a blind painter; it re-derives the glyph from current state, so stalemx/myvalues will redraw stale monsters.
2026-03-14: replacement topline pages need an immediate status redraw for encumbrance transitions
- Context:
pnd_s1200_w_potionmix2_gp.session.jsoninitially failed with:- step
721: JS showedBurdened Hallu, C showedStressed Hallu
- step
- After snapshotting encumbrance at visible
--More--pages, the first mismatch moved to:- step
722: JS still showedStressed Hallu, C showedBurdened Hallu
- step
- RNG, events, and cursor were already exact, so the remaining bug was purely about when status row 23 was repainted across successive message pages.
- Root cause:
- In the potion hallucination path, JS displayed:
Oh wow! Everything looks so cosmic!- then the encumbrance transition message:
Your movements are only slowed slightly by your load.
- The second message replaced an already-paged topline inside the same
putstr_message()flow. - JS refreshed status before dismissing the old page, but did not refresh status again after painting the new encumbrance-transition page.
- That left row 23 showing the previous encumbrance for one extra captured step.
- In the potion hallucination path, JS displayed:
- Fix:
js/input.jsmore()now uses the visible topline’s cached encumbrance snapshot when refreshing status for an explicit--More--boundary.
js/display.jsjs/headless.js- message pages cache
_topMessageEncumbrance - when a new page replaces an acknowledged old one, JS now immediately redraws status only for the known encumbrance-transition messages
- this is intentionally narrow so it does not reopen unrelated death/lifesave display parity like
hi11
- message pages cache
- Result:
pnd_s1200_w_potionmix2_gp.session.jsonis fully green:- RNG
2565/2565 - events
121/121 - screens
818/818 - colors
19632/19632 - cursor
818/818
- RNG
hi11_seed1100_wiz_zap-deep_gameplay.session.jsonstayed fully green.
- Practical lesson:
- The status line belongs to the visible message page, not just to the latest live player state.
- But broad “always redraw status after page replacement” logic is too aggressive; restrict it to the concrete C-faithful cases you can prove.
2026-03-14: death-staging status refresh differs for sleep wakeups vs other helpless states
- Context:
t11_s744_w_covmax2_gp.session.jsonhad full RNG and event parity, but first screen divergence at:- step
543: JSHP:2(12)vs CHP:0(12)under the same visible toplineThe orc hits!--More--
- step
- A broad fix that refreshed status for all
afterMoredeath-staging pages movedt11_s744later but reopened:hi11_seed1100_wiz_zap-deep_gameplay.session.json- step
407: JSHP:0(12)vs CHP:3(12)
- Root cause:
- Both sessions hit the same headless death-staging branch:
topMessage && messageNeedsMore && msg === "You die..."
- The meaningful split was not wizard mode, prompt ownership, or generic negative
multi. - Targeted state dumps showed:
t11_s744:multi_reason = "frozen by floating eye's gaze",usleep = 0,nomovemsg = nullhi11:multi_reason = "sleeping",usleep = 7,nomovemsg = "You wake up."
- C preserves the pre-death status on those sleep/wakeup boundaries, but updates the visible pending page for the non-sleep helpless case.
- Both sessions hit the same headless death-staging branch:
- Fix:
js/headless.js- the death-staging
more()status-refresh suppressor is now narrow:- suppress for monster-phase negative-
multipages when the page was not created after an earlier--More-- - also suppress for the sleep/wakeup boundary (
usleep > 0,multi_reason === "sleeping", ornomovemsg === "You wake up.")
- suppress for monster-phase negative-
- other negative-
multihelpless states, such as floating-eye paralysis, now refresh the visible pending page and match C later.
- the death-staging
- Result:
t11_s744_w_covmax2_gp.session.json:- first screen divergence moved
543 -> 645 - RNG remains
11024/11024 - events remain
1644/1644
- first screen divergence moved
hi11_seed1100_wiz_zap-deep_gameplay.session.jsonstayed fully green.
- Practical lesson:
- “negative
multiduring monster phase” is too coarse for death-boundary rendering. - Sleep wakeups are their own display-order case and need to stay distinct from other helpless/deferred-death paths.
- “negative
2026-03-14: avoid unconditional hallucination-name draws in m-vs-m elemental messaging
- Context:
pnd_s1200_w_potionmix2_gp.session.jsonregressed on cleanmainwith gameplay still exact:- RNG
2565/2565 - events
121/121 - first screen divergence at step
724 - JS:
The green dragon bites the green slime. The hezrou is killed! - C:
The green dragon bites the green slime. The peddler is killed!
- RNG
- Root cause:
- The mismatch was not a bogus-monster table problem and not a gameplay RNG problem.
- Targeted tracing in
js/mhitm.jsshowed two hallucinatory defender-name draws for the same dead monster during one physical attack:- first draw:
peddler - second draw:
hezrou
- first draw:
- The extra draw came from
mdamagem()unconditionally computing:const defName = monCombatName(mdef, ...)- before switching on
mattk.adtypfor elemental-only defender messages.
- So a plain
AD_PHYSbite incorrectly consumed one extrarndmonnam()call before the real kill message.
- Fix:
js/mhitm.js- moved defender-name construction inside the
AD_ELEC/AD_FIRE/AD_COLDcases - physical attacks no longer burn a hallucinatory naming draw on the side
- moved defender-name construction inside the
- Result:
pnd_s1200_w_potionmix2_gp.session.jsonis green again:- RNG
2565/2565 - events
121/121 - screens
818/818 - colors
19632/19632 - cursor
818/818
- RNG
hi11_seed1100_wiz_zap-deep_gameplay.session.jsonstayed fully green.
- Practical lesson:
- When hallucination uses display RNG, even a dead local like
const defName = ...can create a real parity regression if it is evaluated outside the exact C message branch. - In combat/message code, build hallucinatory names lazily and only inside the branch that actually prints them.
- When hallucination uses display RNG, even a dead local like
2026-03-14: repaint parity needs exact C schemas before callsite matching is meaningful
- Context:
- The new
REPAINT_PARITYcampaign adds^repaint[...]diagnostics to the shared replay trace so screen-ownership bugs can be debugged in-step instead of inferred from stale screen diffs. - After rebuilding the C harness and rerecording
t11_s744_w_covmax2_gp.session.jsonwithNETHACK_REPAINT_TRACE=1, gameplay parity was fully green but repaint parity started at0/1412.
- The new
- Root cause:
- JS was already emitting some repaint diagnostics, but its tokens were not structurally comparable to the C patch:
botincludedhpmax, which C does not logmorelogged message text /needsMoreinstead of C’stopl,row,colynloggedchoicesinstead of C’stopl,def- JS omitted the C-side
botlxandtimefields fromflush/bot
- That meant even correctly-placed JS repaint hooks could not compare equal against C.
- JS was already emitting some repaint diagnostics, but its tokens were not structurally comparable to the C patch:
- Fix:
js/repaint_trace.js- add shared helpers for canonical repaint fields (
hp,botl,botlx,time, topline state, cursor row/col)
- add shared helpers for canonical repaint fields (
js/display.js- make
flush_screen()log the sameflush hp=... cursor=... botl=... botlx=... time=...schema as C
- make
js/headless.js- make
renderStatus()log the samebot hp=... botl=... botlx=... time=...schema as C - make headless
flush()emitmark hp=... topl=... inread=... inmore=...
- make
js/input.js- make
more()log the samemore hp=... topl=... row=... col=...schema as C - make
ynFunction()log the sameyn hp=... topl=... def=... query=...schema as C
- make
- Result:
- Direct replay comparison on
t11_s744_w_covmax2_gp.session.jsonmoved from repaint0/1412to62/1412matched without changing gameplay parity:- RNG
11024/11024 - screens
892/892 - events
1644/1644 - mapdump
3/3
- RNG
- The first remaining repaint divergence is still early:
- expected:
^repaint[flush hp=12 cursor=1 botl=0 botlx=0 time=0] - actual:
null
- expected:
- That remaining gap is now about missing/attributed callsites, not incompatible token formats.
- Direct replay comparison on
- Practical lesson:
- For diagnostic parity channels, field vocabulary matters as much as hook placement.
- Before chasing repaint timing, make the JS trace lexically comparable to the C trace; otherwise every mismatch is polluted by schema noise.
2026-03-14: getlin() owns an early repaint boundary before prompt text appears
- Context:
- After the repaint-schema alignment,
t11_s744_w_covmax2_gp.session.jsonstill had its first repaint divergence at step1with no JS entries where C already logged a prompt-boundary repaint. - Step
1in that session is the wizard wish prompt (For what do you wish?), not a gameplay move.
- After the repaint-schema alignment,
- Root cause:
- C tty
hooked_tty_getlin()does more than draw prompt text:- if needed, it resolves pending topline ownership with
more() - then
custompline()triggersvpline()which callsflush_screen()andbot()
- if needed, it resolves pending topline ownership with
- JS
getlin()was drawing the prompt text directly, but it emitted no repaint diagnostics for that prompt-ownership boundary.
- C tty
- Fix:
js/input.js- before the initial
getlin()prompt draw, emit the C-shaped repaint prelude:flushbot
- after drawing the prompt, call headless
flush()so the diagnosticmarkappears in the same boundary
- before the initial
- Result:
- Direct repaint comparison on
t11_s744_w_covmax2_gp.session.jsonimproved again:- from
62/1412 - to
86/1462
- from
- Gameplay parity remained fully green:
- RNG
11024/11024 - screens
892/892 - events
1644/1644
- RNG
- Direct repaint comparison on
- Practical lesson:
- Prompt functions like
getlin()are repaint owners in their own right. - If JS treats them as “text only”, repaint parity will miss real C boundaries even when the visible prompt text is correct.
- Prompt functions like
2026-03-14: mdamageu still needs losehp-style deferred status bookkeeping
- Context:
- upstream commit
50919f40switched melee hero damage injs/mhitu.jsfromlosehp()tomdamageu() - after rebasing,
hi11_seed1100_wiz_zap-deep_gameplay.session.jsonregressed on cleanorigin/mainwith gameplay still exact:- RNG
3469/3469 - events
586/586 - first screen divergence at step
409 - JS:
Dlvl:1 $:0 HP:3(12) Pw:7(7) AC:10 Xp:1 Blind - C:
Dlvl:1 $:0 HP:0(12) Pw:7(7) AC:10 Xp:1 Blind
- RNG
- upstream commit
- Root cause:
- C
mhitu.c:mdamageu()explicitly doesdisp.botl = TRUEbefore mutating HP. - JS
mdamageu()had the HP mutation and death path, but it omitted the deferred status bookkeeping thatlosehp()already performs:player._botl = trueplayer._botlStepIndex = current replay step
- Result: the
Die? [yn]prompt could render while the bottom line still showed stale pre-hit HP, even though the message stream and game state had already advanced to the death/lifesave boundary.
- C
- Fix:
js/mhitu.js- restore the
disp.botlequivalent at the top ofmdamageu() - set both
player._botland_botlStepIndexbefore HP mutation
- restore the
- Result:
hi11_seed1100_wiz_zap-deep_gameplay.session.jsonis green again.pnd_s1200_w_potionmix2_gp.session.jsonstays green.t11_s744_w_covmax2_gp.session.jsonmoves one step later (681 -> 682), which is consistent with the same stale-status class of bug.
- Practical lesson:
- Routing combat damage away from
losehp()is fine, but the replacement path still has to carry all visible C side effects. - For replay parity,
disp.botl/_botlis not optional bookkeeping; it is part of the user-visible semantics of the death prompt boundary.
- Routing combat damage away from
2026-03-14: shopkeeper capital must enter minvent, not just burn RNG
- Context:
- While triaging
t11_s744_w_covmax2_gp.session.json, the earlier mapdump checkpointd0l3_002diverged in sectionN. - The only field mismatch was shopkeeper monster
136’s trailingminventCount:- JS:
3 - C:
4
- JS:
- C snapshot data showed the missing fourth item was the shopkeeper’s gold stack (
minvgold: 3550).
- While triaging
- Root cause:
js/shknam.jshad a localmkmonmoney(amount)helper that only calledmksobj(GOLD_PIECE, false, false)for RNG alignment.- That consumed the object-id RNG but never attached the gold object to
shk.minvent. - C
shknam.ccallsmkmonmoney(shk, amount), and the real helper adds the gold stack to the monster inventory.
- Fix:
js/shknam.js- import and use
mkmonmoney(mon, amount)fromjs/makemon.js - remove the local stub helper
- call
mkmonmoney(shk, capital)during shopkeeper initialization
- import and use
- Result:
dbgmapdump ... --steps 572 --c-side --compare-sections Nnow reportsCompare summary (N): 1/1 step(s) match.hi11_seed1100_wiz_zap-deep_gameplay.session.jsonremains green.
- Practical lesson:
- RNG-aligned stubs in setup code are dangerous even when they appear harmless.
- For monster inventory helpers, “consume the same RNG” is not enough; the object has to land in
minventor later state/mapdump parity will drift.
2026-03-14: mkgrave() must bury created objects, not discard them after RNG
- Context:
- After the
shknam.jsmkmonmoney()fix, a short audit looked for the same broader bug class: setup code that creates objects only to consume RNG, then drops the resulting state mutation. - The clearest next hit was
js/mklev.jsmkgrave().
- After the
- Root cause:
- JS
mkgrave()was doing:mksobj(GOLD_PIECE, true, false); rnd(20); rnd(5);while (tryct--) mkobj(0, true);
- So it consumed the object-creation and quantity RNG, but it discarded the gold object and every random grave object.
- C
mklev.c:2357-2395instead:- creates the gold object,
- assigns quantity with
rnd(20) + level_difficulty() * rnd(5), - sets
ox/oy, - buries it with
add_to_buried(), - then creates random objects, curses them, positions them, and buries them too.
- JS
- Fix:
js/mklev.js- import and use
curse,add_to_buried,weight, andlevel_difficulty - bury grave gold with real quantity/weight/coords
- create, curse, position, and bury random grave objects instead of discarding them
- import and use
- Validation:
hi04_seed1030_wiz_fountain-altar_gameplay.session.json- includes
make_grave()in startup RNG and remains gameplay-green after the fix
- includes
t04_s703_w_wizbury_gp.session.json- remains fully green as a buried-object regression control
hi11_seed1100_wiz_zap-deep_gameplay.session.json- remains fully green
- Practical lesson:
- The shadow-helper bug in
shknam.jswas not unique. - The wider pattern to audit is: object creation used only for RNG alignment while the created object is never attached to floor/minvent/buried state.
- The shadow-helper bug in
2026-03-14: untimed pending-topline command tails should use flush_screen(1), not docrt()
- Context:
- In the repaint campaign,
t11_s744_w_covmax2_gp.session.jsonfirst repaint divergence had moved down to steps18/19:- C expected two
flushentries on each step - JS was emitting three
botentries instead
- C expected two
- Screens, RNG, events, and mapdump were already fully green, so this was a pure repaint-owner mismatch.
- In the repaint campaign,
- Diagnosis:
- These steps are untimed command tails with a still-pending topline:
commandResult.tookTime === falsedisplay.messageNeedsMore === trueplayer._botl === 0
- C behavior there is not “skip redraw”.
- C behavior is
flush_screen(1):- keep the pending topline
- move the cursor to the hero
- do not run
bot()becausedisp.botlis false
- JS was instead falling through the generic post-command rendering path:
docrt()renderStatus()cursorOnPlayer()
- That produced repaint
botdiagnostics where C only hadflush.
- These steps are untimed command tails with a still-pending topline:
- Fix:
js/allmain.js- in both
postRender()andrenderAndAutosave(), detect the narrow case:- untimed command result
- pending
messageNeedsMore - no pending status update (
!player._botl)
- use
flush_screen(1)instead ofdocrt()
- in both
- Validation:
node test/comparison/repaint_step_diff.js test/comparison/sessions/pending/t11_s744_w_covmax2_gp.session.json --steps=18,19,20- steps
18/19/20repaint traces now match exactly
- steps
node test/comparison/session_test_runner.js --sessions=test/comparison/sessions/pending/t11_s744_w_covmax2_gp.session.json --parallel=1- gameplay parity remains fully green
- first repaint divergence moved from step
18to step416 - repaint matched
224/1341
WEBHACK_REPAINT_TRACE=1 node test/comparison/session_test_runner.js --sessions=test/comparison/sessions/seed329_rogue_wizard_gameplay.session.json --parallel=1- still green
- Practical lesson:
- Not every terminal-owned boundary should suppress rendering.
- Some boundaries are specifically
flush_screen(1)boundaries: cursor refresh yes, status refresh only if_botlis set.
2026-03-14: sink-ring burial must remove inventory and enter buried chain
- Context:
- Continuing the discarded-state audit after
shknam.jsandmkgrave(), the next live hit was the ring-into-sink path injs/do.js. - Existing coverage session:
- Continuing the discarded-state audit after
- Root cause:
- JS had two fidelity gaps in the live sink path:
- the caller invoked
dosinkring(obj)without passingplayerandmap - the bury branch only did:
obj.in_use = falseobj.ox = player.xobj.oy = player.yobj.buried = true
- the caller invoked
- C
do.c:658instead removes the ring from inventory and adds it to the buried chain:freeinv(obj);obj->in_use = FALSE;obj->ox = u.ux; obj->oy = u.uy;add_to_buried(obj);
- JS had two fidelity gaps in the live sink path:
- Fix:
js/do.js- pass
playerandmapintodosinkring(obj, player, map)from the live drop path - in the bury branch, call
freeinv(obj, player)andadd_to_buried(obj, map)instead of only markingobj.buried = true
- pass
- Validation:
t01_s940_v_sinkmix2_gp.session.json- remains fully green after the fix
hi11_seed1100_wiz_zap-deep_gameplay.session.json- remains fully green
- Practical lesson:
- The next tier after “fake helper” bugs is “state-lite branch” bugs: live gameplay branches that mutate a visible flag (
obj.buried = true) but skip the canonical list ownership update (freeinv,add_to_buried).
- The next tier after “fake helper” bugs is “state-lite branch” bugs: live gameplay branches that mutate a visible flag (
2026-03-14: hold_another_object() must drop overflow wishes back out of inventory
- Context:
- While starting the new digging/trap coverage session
t22_s1250_w_digtrapmix_gp.session.json, the first blocker was an overburdening wish:- C:
Oops! The boulder drops to the floor! - JS: kept the boulder in inventory and printed the handspan encumbrance warning.
- C:
- While starting the new digging/trap coverage session
- Root cause:
- C
invent.c:1208-1300hold_another_object()only keeps the object if it survives the post-addinv_core0()overflow check:- too many inventory letters, or
- encumbrance beyond
max(current_state, pickup_burden).
- JS
js/invent.jslacked that drop-back-out branch entirely, so wished heavy objects could remain in inventory when C had already dropped them. - JS
js/zap.jsmakewish()also passednullfor the C-style drop text argument instead ofThe(aobjnam(otmp, "drop")).
- C
- Fix:
js/invent.jshold_another_object()now mirrors the C keep/drop gate:- compute a pickup-burden-adjusted limit only for the overflow decision,
- undo merged stacks via
splitobj(...)when necessary, - remove the object from inventory and drop/hit the floor in the failure branch,
- suppress
prinv()/ encumbrance-transition messaging unless the object actually stays carried.
js/zap.jsmakewish()now passesThe(aobjnam(otmp, "drop"))intohold_another_object().
- Validation:
hi11_seed1100_wiz_zap-deep_gameplay.session.json- remains fully green
t01_s940_v_sinkmix2_gp.session.json- remains fully green
t22_s1250_w_digtrapmix_gp.session.json- moved from an immediate boulder-overflow failure at step
77to a much later grave/trap frontier (first RNG divergence step211)
- moved from an immediate boulder-overflow failure at step
- Practical lesson:
- Another recurring parity bug class is “tentative state that must be rolled back.”
- If C adds something speculatively and then conditionally ejects it, keeping only the additive half in JS creates late, hard-to-explain state drift.
2026-03-14: read prompt repaint ownership is split between prompt entry and follow-up message paths
- Context:
- Continuing the
REPAINT_PARITYcampaign on:
- Continuing the
- Finding:
- The first repaint miss at step
416was the read prompt itself:- C emitted:
^repaint[yn hp=12 topl=0 def=0 query=What do you want to read? [h-lv-z or ?*]]^repaint[flush hp=12 cursor=1 botl=0 botlx=0 time=0]
- JS emitted nothing.
- C emitted:
- The prompt does not go through
getobj()here; it is owned by the inline prompt loop injs/read.js.
- The first repaint miss at step
- Fix:
js/read.js- log a
yn-class repaint entry for the read prompt owner - log a prompt-local
flushrepaint entry immediately after the prompt is displayed
- log a
js/repaint_trace.js- repaint string fields must be emitted unquoted to match the C trace schema
- Validation:
t11_s744- gameplay parity remains fully green
- repaint first divergence moved:
416 -> 417
- repaint matched count improved:
224/1341 -> 235/1341
seed329_rogue_wizard_gameplay.session.json- remains fully green
- Practical lesson:
- Repaint prompt ownership is not the same as gameplay prompt implementation.
- A
tty_yn_function-style repaint token can correspond to a prompt owned by a custom command loop, so repaint parity needs owner-class matching, not literal function-name matching.
2026-03-14: getobj() dirties status before validating the chosen inventory letter
- Context:
- Continuing repaint-parity work on:
- After fixing the read-prompt owner, the next repaint frontier was step
417on:That is a silly thing to read.
- Finding:
- In C
invent.c:getobj(),disp.botl = TRUEis set before the chosen inventory letter is validated. - That means an invalid target can still produce a status-owned repaint boundary before the rejection message.
- For this exact window, C emits:
flush(botl=1) -> bot -> flushbefore settling on the invalid-read message frame.
- In C
- Fix:
js/read.js- when the inline read selector accepts an inventory letter, mark
player._botl = truebefore class validation - for the invalid-read-target branch, call
flush_screen(1)before:That is a silly thing to read.
- when the inline read selector accepts an inventory letter, mark
- Validation:
t11_s744- gameplay parity remains fully green
- repaint matched count improved:
235/1341 -> 244/1345
- the first repaint miss remains at step
417, but only as one extra trailing JSflush
seed329_rogue_wizard_gameplay.session.json- remains fully green
- Practical lesson:
- Some repaint boundaries are inherited from generic inventory-selection semantics even when a command implements its own custom prompt loop.
- If the JS command bypasses
getobj(), it may still need to reproducegetobj()’s display-side bookkeeping to stay repaint-faithful.
2026-03-14: repaint debugging needs a separate owner/context channel
- Context:
- Continuing the
REPAINT_PARITYcampaign after the firstt11_s744repaint bring-up wins. - Canonical
^repaint[...]parity was already useful, but still too weak to explain duplicate-owner cases like:postRender()vsrenderAndAutosave()getlin()prompt entry vs command-tailflush_screen()
- Continuing the
- Finding:
- Canonical repaint events should stay minimal and stable for rerecord/compare.
- But repaint debugging needs richer answers:
- which function/path emitted this repaint?
- what topline /
messageNeedsMorestate was active? - was this prompt-owned,
more()-owned,flush_screen()-owned, or status-owned?
- Fix:
- Added a separate debug-only owner/context channel:
- JS:
WEBHACK_REPAINT_DEBUG=1 - C:
NETHACK_REPAINT_DEBUG=1
- JS:
- This emits
^repaintdbg[...]lines to stderr/console only. - It does not alter canonical
^repaint[...]recordings.
- Added a separate debug-only owner/context channel:
- Validation:
node --test test/unit/comparators.test.js- still green
bash test/comparison/c-harness/setup.sh- patch applies and harness builds cleanly
- traced
t11_s744replay with both debug flags enabled:- gameplay parity still passes
- canonical repaint parity is unchanged
- debug output now names real owners such as:
display.flush_screenheadless.renderStatusinput.getlin.preprompttty.topl.moretty.topl.yn_functiontty.wintty.mark_synch
- Practical lesson:
- Repaint parity needs two channels:
- a stable canonical channel for comparison
- a richer owner/context channel for debugging
- Mixing those together would destabilize recordings and make rerecords noisier than necessary.
- Repaint parity needs two channels:
2026-03-14: Wizard terrain/trap wishes need live map context, C-visible messages, and trap replacement
- Context:
- Continuing the new digging/trap coverage session
t22_s1250_w_digtrapmix_gp.session.json, the early mismatches were all in wizard terrain/trap wishing:- missing
Can't place a grave here. - missing
A pit. - generic
A trap.instead ofA pit. - failed
holecreation where C reused the existing pit trap
- missing
- Continuing the new digging/trap coverage session
- Root causes:
js/zap.jsmakewish()passed onlyplayer.mapintoreadobjnam(). In live play,player.mapcan be absent whilegame.mapis present, so wizard terrain wishes returned an un-applied descriptor with no message.js/objnam.jsterrain/trap wishes were treated as an inerthands_objsentinel instead of carrying C-visible success/failure text back tomakewish().js/trap.jstrapname()readdefsyms[].explanation, but the symbol table entries usedesc, collapsing trap-wish messages to generictrap.js/dungeon.jsmaketrap()rejected any existing trap at the target square, while Ctrap.c:455reuses and reinitializes existing traps unless they are undestroyable.
- Fix:
js/zap.js- pass
game.mapas fallback whenplayer.mapis missing - emit wizard terrain/trap wish result text via
pline(...)
- pass
js/objnam.js- return the full
terrainwish/trapwishresult object fromreadobjnam() - attach C-style messages for grave success/failure and trap creation
- return the full
js/trap.js- use
defsyms[].descintrapname()
- use
js/dungeon.js- reuse and reinitialize existing traps in
maketrap()unless they are undestroyable
- reuse and reinitialize existing traps in
- Validation:
hi11_seed1100_wiz_zap-deep_gameplay.session.json- remains fully green
t01_s940_v_sinkmix2_gp.session.json- remains fully green
t22_s1250_w_digtrapmix_gp.session.json- screens moved from
209/220to213/220 - colors moved from
5266/5280to5272/5280 - events moved from
593/650to624/652 - first visible frontier moved from the early grave page to the later burden/trap-object frontier
- screens moved from
- Practical lesson:
- Wizard wish helpers are another place where “state-lite” ports cause real parity drift:
- missing live map context,
- missing C-visible messages,
- and missing replacement semantics on existing state.
- Wizard wish helpers are another place where “state-lite” ports cause real parity drift:
2026-03-14
-
Coverage session:
t22_s1250_w_digtrapmix_gp.session.json - Problem:
- the live
dcommand was bypassing the canonical C-faithful drop path in the new digging/trap coverage session. - after wishing a
hole, JS prompt-driven dropping placed the item directly on the floor and never entered:drop_single()can_reach_floor(...)->hitfloor()ship_object()
- that caused the session to diverge before the hole-drop object migration pages and to fall into monster-turn RNG instead.
- the live
- Root causes:
js/do.jshandleDrop()used a localdropSelectedItem()helper that:- removed the item from inventory,
- placed it directly on the floor,
- printed a bespoke drop message,
- and never invoked the shared drop/floor-effects logic.
js/do.jsdrop_single()itself was also missing the C!can_reach_floor(...)->hitfloor()branch.js/dothrow.jshitfloor()was still simplified:- always described
the floor, - and never handed off to
ship_object()for hole/trapdoor migration.
- always described
- Fix:
js/do.js- route
handleDrop()item selection back throughdrop_single()instead of the local floor-placement bypass. - add the C
can_reach_floor(player, map, true)branch insidedrop_single(), includinghitfloor()handoff.
- route
js/dothrow.js- make
hitfloor()trap-aware for surface wording:floortrap dooredge of the holeedge of the pit
- call
ship_object()after the impact page and before final floor placement.
- make
- Validation:
t22_s1250_w_digtrapmix_gp.session.json- RNG
3655/3655 - events
650/650 - screens
220/220 - colors
5280/5280 - cursor
220/220
- RNG
hi11_seed1100_wiz_zap-deep_gameplay.session.json- still fully green
t01_s940_v_sinkmix2_gp.session.json- still fully green
- Practical lesson:
- prompt-owned command UIs must not fork their own simplified gameplay implementations.
- once a command has selected its object, it should rejoin the canonical core path; otherwise parity fixes land in dead code while the live command keeps diverging.
2026-03-14
-
Coverage session:
t22_s1250_w_digtrapmix_gp.session.json - Problem:
- after the gameplay/state fixes were green,
t22still had one cursor-only miss at step216:- expected
[8,1,1] - actual
[79,0,1]
- expected
- the visible screen already matched C, including a visible
--More--on row1, so the remaining bug was repaint ownership, not gameplay.
- after the gameplay/state fixes were green,
- Root cause:
- C
tty more()inwin/tty/topl.cdoes:- if
curx >= CO - 8, emit a newline before printing--More--
- if
- JS
renderMoreMarker()injs/headless.jsandjs/display.jswere still using a one-row policy for single-line toplines:- append
--More--on row0 - leave cursor ownership at the end of row
0
- append
- in
t22, that left cursor parity wrong exactly when the visible marker belonged on row1.
- C
- Fix:
- update both JS
renderMoreMarker()implementations to follow the C tty rule:- if the current topline length is
>= cols - "--More--".length, move the marker to the next row - set the cursor after
--More--on row1 - otherwise keep the existing same-row behavior
- if the current topline length is
- update both JS
- Validation:
t22_s1250_w_digtrapmix_gp.session.json- RNG
3655/3655 - events
650/650 - screens
220/220 - colors
5280/5280 - cursor
220/220
- RNG
hi11_seed1100_wiz_zap-deep_gameplay.session.json- still fully green
t01_s940_v_sinkmix2_gp.session.json- still fully green
- Practical lesson:
- repaint parity should be explained with owner semantics, not vague boundary language.
- when C makes
--More--visible on a different row, JS must transfer cursor ownership to that exact visible marker.
2026-03-14: untimed getobj feedback should suppress only the autosave-tail repaint
- Context:
- In repaint-parity session
t11_s744_w_covmax2_gp.session.json, step417was still mismatching after the read prompt gained the correct C-stylegetobj()feedback boundary. - Expected repaint sequence for the invalid-read rejection was:
flush(botl=1)botflush("That is a silly thing to read.")
- JS emitted one extra trailing
flushafter that.
- In repaint-parity session
- Root cause:
- The matched third repaint was the normal untimed
postRender()flush. - The extra fourth repaint came from the shared
renderAndAutosave()untimed tail. - Broad duplicate-tail suppression was wrong because step
18still legitimately needs both command-tail flushes.
- The matched third repaint was the normal untimed
- Fix:
- Added
handledGetobjFeedbackResult()for customgetobj()-like loops that have already established the visible feedback boundary. - That helper returns an untimed command result with
suppressUntimedTailRender: true. renderAndAutosave()now honors that flag by skipping the duplicate untimed repaint while still scheduling autosave.
- Added
- Validation:
t11_s744_w_covmax2_gp.session.json- step
417now matches exactly - first repaint divergence moved to step
429 - gameplay parity stayed fully green
- step
seed329_rogue_wizard_gameplay.session.json- still fully green
- Practical lesson:
- When a command-local prompt loop performs its own C-faithful untimed feedback flush,
the remaining command tail should suppress only the duplicate autosave render, not the normal
postRender()boundary.
- When a command-local prompt loop performs its own C-faithful untimed feedback flush,
the remaining command tail should suppress only the duplicate autosave render, not the normal
2026-03-14
-
Pending session:
t11_s754_w_covmax8_gp.session.json - Problem:
- the first real gameplay drift was earlier than the pet-AI symptom suggested.
- session step
758is inventory lettermduring#read, and the recorded C screen isIt reads:--More--. - JS treated that path as untimed/non-readable, so the kitten and nearby monster state froze while C advanced the monster turn.
- Root cause:
js/read.jsstill treated non-scroll/non-spellbook reads as “silly” by default.- C
read.c:doread()treats several downplayed inventory items as genuinely readable and timed, includingMAGIC_MARKER. js/read.jsread_ok()also rejected those objects instead of marking themGETOBJ_DOWNPLAY.js/invent.jsgetobj()was also missing the CGETOBJ_EXCLUDE -> silly_thing(word, otmp) -> return NULLvalidation path after letter selection.
- Fix:
js/read.jsread_ok()now returnsGETOBJ_DOWNPLAYfor non-scroll/non-spellbook items, matching C.- added the C-faithful
MAGIC_MARKERtimed read branch:- optional
It reads: - deterministic branded marker text based on
o_id - increments literacy conduct
- returns
tookTime: true
- optional
js/invent.jsgetobj()now validates the chosen item withobj_ok(item)after letter selection and routesGETOBJ_EXCLUDEthroughsilly_thing(...), matching Cinvent.c.
- Validation:
t11_s754_w_covmax8_gp.session.json- first RNG divergence moved
758 -> 802 - first event divergence moved
749 -> 792
- first RNG divergence moved
hi11_seed1100_wiz_zap-deep_gameplay.session.json- still green
t22_s1250_w_digtrapmix_gp.session.json- still green
- Practical lesson:
getobj()-driven commands often fail because the chosen item is “downplayed” rather than outright unreadable/unusable.- when C models those items in the main command handler as real timed branches, JS must not collapse them into a generic “silly thing” fallback.
-
Coverage session:
t11_s744_w_covmax2_gp.session.json - Problem:
- after the earlier death/display fixes,
t11_s744_w_covmax2_gp.session.jsonhad only one remaining mismatch:- step
589 - visible topline matched:
You have no ammunition readied.--More-- - cursor differed:
- expected
[39,0,1] - actual
[31,0,1]
- expected
- step
- after the earlier death/display fixes,
- Root cause:
js/dothrow.jsdofire()handled the no-quiver case as:putstr_message("You have no ammunition readied.")- then
more(display, ...)
- but that
more()call did not request a visible marker. - C tty
more()always owns the visible--More--prompt before waiting, so cursor ownership belongs after the marker, not at the end of the plain message text.
- Fix:
js/dothrow.js- pass
forceVisual: truefor the explicitmore()boundaries in:dothrow.no-quiver.moredothrow.no-autoquiver.more
- pass
- Validation:
t11_s744_w_covmax2_gp.session.json- RNG
11024/11024 - events
1644/1644 - screens
892/892 - colors
21384/21384 - cursor
891/891
- RNG
hi11_seed1100_wiz_zap-deep_gameplay.session.json- still fully green
t22_s1250_w_digtrapmix_gp.session.json- still fully green
- Practical lesson:
- explicit
more()boundaries in core command code need explicit visible-marker ownership. - if a command path calls
more()directly rather than going through a message-overflow path, it must still model C tty visibility by requesting the marker before waiting.
- explicit
2026-03-14: repaint parity needs message-window cursor ownership, pre-more flushes, and visible-status consumption
- Context:
- In repaint-parity session
t11_s744_w_covmax2_gp.session.json, the next repaint frontier after the invalid-read fix was the scroll-read window around steps429..433. - C expected:
- step
429:flush(botl=1) -> bot -> flush(botl=0) -> more(row=0,col=38) - step
430:bot(botl=1) -> flush(botl=0) -> flush(botl=0)
- step
- JS initially differed in three ways:
more()logged the terminal cursor after--More--instead of the message-window cursor before--More--- concat overflow missed the extra visible pre-
more()flush - AC recomputation from
find_ac()dirtied status too late and left_botlset afterdisplay_sync()
- In repaint-parity session
- Root cause:
- Repaint ownership in this window spans both message staging and core status state:
vpline()-style pending-topline preflushes must be visible before concat-overflowmore()- repaint cursor logging must use the message-window cursor while a topline page is pending
find_ac()changes visible AC, so it must dirty_botl- once
display_sync()has rendered a dirty status line,_botlmust be cleared so the followingflush_screen()does not emit a duplicatebot
- Repaint ownership in this window spans both message staging and core status state:
- Fix:
- Track
messageCursorRow/messageCursorColand use them for^repaint[more ...]whilemessageNeedsMoreis active. - In
putstr_message(), preflush pending toplines before both death-staging and concat-overflow boundaries, and add the extra pre-more()flush on concat overflow. - In
find_ac(), setplayer._botl = true. - In
display_sync(), clearplayer._botlafterrenderStatus(player)consumes the visible bot update. - Keep the command-tail ownership change from the step-
417fix: pending toplines always useflush_screen(1)inpostRender()/renderAndAutosave().
- Track
- Validation:
t11_s744_w_covmax2_gp.session.json- step
429now matches exactly - step
430now matches exactly - first repaint divergence moved to step
433 - gameplay parity stayed fully green
- step
seed329_rogue_wizard_gameplay.session.json- still fully green
- Practical lesson:
- Repaint parity is not only about where
flush_screen()happens; it also depends on which cursor is “owned” by the pending message window, which gameplay functions dirty visible status, and exactly when a direct status render consumes that dirty bit.Replay capture renderStatus must not emit canonical repaint logs
- Repaint parity is not only about where
- Problem:
t11_s744repaint parity appeared to regress early at step18with an extra JS^repaint[bot ...], but owner tracing showed it came fromreplay_core.jsforcingdisplay.renderStatus()only to capture the final post-command screen. - Causal detail: that replay-only refresh is part of the JS recorder, not game
logic, so letting
HeadlessDisplay.renderStatus()log canonical repaint entries there pollutes the trace with harness-onlybotevents. - Fix: wrap the replay-core capture refresh in a temporary display flag and
have
HeadlessDisplay.renderStatus()suppress canonical/debug repaint logging while that flag is active. The visible screen capture still happens. - Result: restored the true first repaint divergence on
t11_s744to step433; gameplay parity unchanged.Repaint writer tagging belongs in the C harness patch, not ad hoc source edits
- Problem: the step-
433t11_s744repaint miss still had an unexplained C-sidebotl=1betweenupdate_topl.concat_fit("A lit field surrounds you!")andupdate_topl.pre_more("The goblin hits!"). - Infrastructure fix: rebuild
024-repaint-trace.patchfrom a clean post-023source snapshot instead of hand-editing stale unified diff headers, then add debug-only owner tags for plausible silentdisp.botl = TRUEwriters:allmain.regen_pw,allmain.regen_hp,timeout.deaf,timeout.flying, andeat.newuhs. - Result: the rebuilt patch applies cleanly again in
setup.sh, repaint debug reruns work, and the new owner tags ruled out those candidate writers for the light-scroll / goblin-hit window. - Practical lesson: for harness observability patches, regenerate the unified
diff from real edited source files whenever hunk metadata gets suspect. That
keeps the patch future-rebuild-friendly and avoids wasting time on malformed
patch bookkeeping.
C repaint debug needs flush caller scope, not just adjacent-event inference
- Problem: in the
t11_s744step-433window we could see the decisive C repaint sequenceflush(botl=0) -> concat_fit("A lit field surrounds you!") -> flush(botl=1) -> bot -> pre_more("The goblin hits!"), but the existing debug channel only labeled both flushes asowner=display.flush_screen. - Infrastructure fix: extend the debug-only repaint patch so C can tag
flush_screen()with a lightweight caller scope. The first added scope isscope=pline.vpline, set only aroundpline.c:vpline()’s pre-messageflush_screen()call. - Result: a focused rerecord on a throwaway copy of
t11_s744now shows both disputed step-433flushes asscope=pline.vpline. That proves the missing JS repaint is specifically a message preflush parity problem, not amore()ownership problem. - Practical lesson: when repaint mismatches depend on multiple flushes in one boundary window, owner tags alone are not enough. Add caller scope at the C flush site so the next JS fix can target the correct semantic layer.
Cancelled #cast direction still reuses the previous direction
- Problem:
t11_s754_w_covmax8_gpdiverged when castingforce boltwith a cancelled direction prompt. C printedThe magical energy is released!and still continued throughweffects()using the previous direction, while JS printed the message and returned early. - Root cause:
js/spell.jshandled cancelledgetdir()inspelleffects()as an abort for directed spells, but Cspell.conly emits the message and then falls through to the normal direction-dependent spell resolution. - Fix: keep the message, but do not return early. Let
spelleffects()continue with the previously remembereddx/dy/dz, matching C. - Validation:
t11_s754_w_covmax8_gp: first event divergence moved792 -> 1224, first RNG divergence moved802 -> 1254hi11_seed1100_wiz_zap-deep_gameplay: still greent22_s1250_w_digtrapmix_gp: still green
Shopkeepers must use move_special() path selection, not one-tile chase
- Problem: the next
t11_s754_w_covmax8_gpfrontier was in the same monster window after the#castfix. C moved monster271(the shopkeeper) from(75,4)to(76,4)throughshk_move() -> move_special(...), while JS left it in place and immediately re-entered the nextdistfleeck()call. - Root cause:
js/shk.jsstill had a simplifiedshk_move()one-step chase model, but Cshk.c:shk_move()uses richermove_special(...)path selection with home, following, and door-avoidance logic. - Fix: handle the
isshkbranch injs/monmove.jswith a C-shapedshk_move()decision path built around canonicalmove_special(...), then runafter_shk_move()when it succeeds. - Validation:
t11_s754_w_covmax8_gp: first event divergence moved1224 -> 1228, first RNG divergence moved later within the same step (7170 -> 7177)hi11_seed1100_wiz_zap-deep_gameplay: still greent22_s1250_w_digtrapmix_gp: still green
Pre-move wielding must require exact NEED_WEAPON
- Problem: the next
t11_s754_w_covmax8_gpfrontier was still in the same monster window after the shopkeeper fix. JS was letting monsters spend a turn on melee wielding before movement even whenweapon_checkwas notNEED_WEAPON, which can suppress the C movement path. - Root cause:
js/mthrowu.jshad a legacy shortcut inmaybeMonsterWieldBeforeAttack()for unarmed monsters. Cmonmove.conly takes the pre-move wield path whenmtmp->weapon_check == NEED_WEAPON. - Fix: require exact
weapon_check === NEED_WEAPONbefore spending the turn on melee wielding. - Validation:
t11_s754_w_covmax8_gp: first RNG divergence moved1254 -> 1259(7177 -> 7190), matched events increased1542 -> 1571hi11_seed1100_wiz_zap-deep_gameplay: still greent22_s1250_w_digtrapmix_gp: still green
mattacku() needs the internal AT_WEAP wield branch too
- Problem: the next
t11_s754_w_covmax8_gpfrontier was still in monster melee. C enteredmattacku()for a goblin, then spent the attack on the internalAT_WEAPwield path with no hit-roll RNG before returning to the next monster. JS skipped straight to hit-roll RNG once it reached the meleeAT_WEAPbranch. - Root cause:
js/mhitu.jswas missing the Cmhitu.ccheck insidemattacku(): ifweapon_check == NEED_WEAPONor there is no wielded weapon, setNEED_HTH_WEAPONand allowmon_wield_item()to consume the attack. - Fix: port that internal
AT_WEAPwield branch before any melee hit-roll RNG is consumed. - Validation:
t11_s754_w_covmax8_gp: first RNG divergence moved1259 -> 1678, matched events increased1571 -> 2786hi11_seed1100_wiz_zap-deep_gameplay: still greent22_s1250_w_digtrapmix_gp: still green
poisoned() must use C-style composite dice logging
- Problem: the next
t11_s754_w_covmax8_gpfrontier moved into later monster-vs-hero poison handling. Afterrn2(30)=0, JS emitted four primitivern2(6)rolls before knockback/combat continued, while C had already advanced to the next composite poison/knockback rolls. - Root cause:
js/attrib.jsstill used Lua-styled()inside the Cattrib.c:poisoned()path for the severe-reaction4d6roll and the later2d2attribute-loss roll. For C gameplay paths, these must usec_d()so RNG consumption and logging match Crnd.c d(). - Fix: switch the
poisoned()severe-reaction and attribute-loss dice fromd(...)toc_d(...). - Validation:
t11_s754_w_covmax8_gp: first RNG divergence moved1678 -> 1752, matched RNG increased17297 -> 19901hi11_seed1100_wiz_zap-deep_gameplay: still greent22_s1250_w_digtrapmix_gp: still green
mfndpos() must gate obstructed tiles through rockok/treeok
- Problem: the next
t11_s754_w_covmax8_gpfrontier was in monster movement for dwarf44. C hitrn2(16)in them_move()revisit check, while JS hitrn2(32). - Diagnosis: targeted trace showed JS
mfndpos()was admitting all eight neighboring squares for that dwarf, including wall/corner tiles. C only had four legal candidates there. The JS bug was treatingALLOW_DIGas “all obstructed terrain is legal.” - Root cause: C
mon.c:mfndpos()only admits obstructed terrain when either:ALLOW_WALL && may_passwall(nx, ny), or(IS_TREE ? treeok : rockok) && may_dig(nx, ny)androckok/treeokdepend on the monster’s actual carried/wielded digging tools. JS had reduced that to a broadALLOW_DIGcheck and never modeled the Crockok/treeokgate.
- Fix: in
js/mon.js, port therockok/treeokgating logic intomfndpos(), including tool checks across linked-list or array monster inventories, and requiremay_dig()for obstructed squares. - Validation:
t11_s754_w_covmax8_gp: first RNG divergence moved1752 -> 1759, matched RNG increased19901 -> 19998, matched events increased2786 -> 2804hi11_seed1100_wiz_zap-deep_gameplay: still greent22_s1250_w_digtrapmix_gp: still green
Monster poly traps must call real newcham()
- Problem: after the
mfndpos()fix,t11_s754_w_covmax8_gpstill diverged at step1759. C transformed monster44duringm_move()viaselect_newcham_form(mon.c:5214)and then continued with the new monster form. JS skipped straight into later movement RNG likernd(12). - Diagnosis: the C raw window showed
resist(zap.c:6111)followed byselect_newcham_form(),mgender_from_permonst(), andnewmonhp(). That is thetrap.c:trapeffect_poly_trap()monster branch. JS still had a literal stub injs/trap.js: after!resist(mon, WAND_CLASS)it consumed the RNG but skipped polymorph. - Root cause:
js/trap.jsnever invoked the runtimenewcham()machinery for monster poly traps, so the monster stayed in its old form and all downstream movement/combat RNG drifted. - Fix:
- export
runtimeApplyNewchamRandom()fromjs/makemon.jsto run the random-formnewcham()path for any monster, usingmon.chamwhen present andNON_PMotherwise - call that helper from the monster
POLY_TRAPbranch injs/trap.js
- export
- Validation:
t11_s754_w_covmax8_gp: RNG20848/20848, events3292/3292hi11_seed1100_wiz_zap-deep_gameplay: still greent22_s1250_w_digtrapmix_gp: still green
C repaint debug can identify hidden disp.botl writers inside a single message window
- Problem: after adding
scope=pline.vpline, thet11_s744step-433window was still missing one fact: which C state transition turnedbotlfrom0to1betweenconcat_fit("A lit field surrounds you!")andpre_more("The goblin hits!"). - Infrastructure fix: extend the debug-only C repaint patch with owner tags for
likely silent
disp.botl = TRUEwriters, including:attrib.adjattribattrib.restore_attribpickup.encumber_msgbotl.update_hilitesdo_wear.find_acallmain.stop_occupationallmain.moveloop_core.run_timetimeout.done_timeouttimeout.incr_deafnessinvent.getobjhack.nomulhack.unmul
- Result: the decisive step-
433C trace is now:tty.topl.update_topl.concat_fit("A lit field surrounds you!")hack.nomul.set_botl hp=12 multi=0 nval=0display.flush_screen scope=pline.vpline hp=12 ... botl=1botl.bot hp=12 ...tty.topl.update_topl.pre_more(... "The goblin hits!")
- Conclusion:
- the hidden writer is
hack.c:nomul(0), reached from non-rangedmhitu.c:mattacku() - the remaining JS repaint gap is therefore on the melee
hitmsg()message path, not genericmore()ownership and not an unknown background status writer.
- the hidden writer is
2026-03-14 20:06: melee nomul(0) dirty bit must defer across pending topline flush
- Session:
test/comparison/sessions/coverage/monster-ai-combat/t11_s744_w_covmax2_gp.session.json
- Problem:
- repaint first diverged at step
433 - expected:
flush(botl=1) -> bot -> flush(botl=0) -> flush(botl=1) -> bot -> more
- JS had:
flush(botl=1) -> bot -> flush(botl=0) -> more
- repaint first diverged at step
- C cause:
mhitu.c:mattacku()callsnomul(0)before melee hit messaginghack.c:nomul()setsdisp.botl |= (gm.multi >= 0)- but in this window the visible dirty repaint belongs to the new hitmsg boundary, after the old pending topline has already been flushed
- JS fix:
- restore the missing melee
nomul(0, game)call at the start ofjs/mhitu.js:mattacku()for non-ranged attacks - in
js/hack.js:nomul(), if a pending topline is active, defer the botl dirty bit onto the display object instead of consuming it immediately - in
js/headless.jsandjs/display.js, consume that deferred dirty bit immediately after the old pending topline flush and before concat-overflow handling
- restore the missing melee
- Result:
- step
433repaint parity became exact - first repaint divergence moved
433 -> 434 - gameplay parity stayed fully green on
t11_s744
- step
2026-03-14 20:38: new shop+read+zap coverage session exposed menu-boundary bugs before gameplay drift
- Session:
test/comparison/sessions/pending/hi13_seed1001_wiz_shop-read-zap_gp.session.json
- Coverage-campaign value:
- this mixed wizard session hits low-coverage surfaces in:
read.jsartifact.jszap.jsshk.js
- this mixed wizard session hits low-coverage surfaces in:
- First bring-up findings:
- initial replay failed with:
- first event divergence at step
261 - first screen divergence at step
267 - first RNG divergence at step
275
- first event divergence at step
- the earliest visible mismatch was JS painting menu/overlay state while C
still showed a pending topline
--More--boundary
- initial replay failed with:
- JS fixes:
js/windows.js:select_menu()now resolves a pending topline--More--before painting a menujs/invent.js:renderOverlayMenuUntilDismiss()now does the same for inventory-overlay menusjs/invent.js:identify_pack()now uses the grouped inventory overlay presentation for identify selection, matching the C capture shape better
- Result:
- the session improved from mixed gameplay drift to a later pure screen/menu boundary
- current authoritative replay state:
- RNG full:
3305/3305 - events full:
379/379 - first screen divergence at step
273
- RNG full:
- guardrails stayed green:
seed329_rogue_wizard_gameplayseed033_manual_direct
Wizard identify display uses display-only full names
- Problem: after gameplay parity went green on
t11_s754_w_covmax8_gp, the first remaining screen divergence was the wizard^Iidentify display. JS still rendered raw inventory-order rows liken - a maple wand, while C showed class-grouped rows likeComestiblesand fully identified display names such asH - an uncursed food ration. - Diagnosis: C
invent.cwizard identify does not call realidentify()for menu display. It incrementsiflags.override_ID, then feedsdoname(otmp)into the menu. JS was reusing ordinary inventory-row formatting, so it kept ordinary unknown names and raw inventory order. - Fix:
- factor
buildInventoryOverlayLinesFromItems()so display-only inventory menus can reuse the grouped class layout - add a display-only fully identified name path in
js/invent.jsby cloning the object, setting theknown/bknown/rknown/dknowndisplay flags, and callingdoname()without mutating actual discovery state - for display-only multi-page wizard identify, render the first visual page
with the C-style
(1 of 2)footer instead of spilling later item rows into row 23
- factor
- Validation:
t11_s754_w_covmax8_gp: first screen divergence moved450 -> 451, gameplay remained exact (RNG 20848/20848,events 3292/3292)hi11_seed1100_wiz_zap-deep_gameplay: still greent22_s1250_w_digtrapmix_gp: still green
2026-03-14 20:50: identify_pack() needed grouped PICK_ANY overlay semantics to bring hi13 green
- Session:
test/comparison/sessions/coverage/spells-reads-zaps/hi13_seed1001_wiz_shop-read-zap_gp.session.json
- Problem:
- after fixing pre-menu
--More--ownership, the remaining mismatch was in the identify scroll chooser:- C kept the grouped inventory menu visible while
rtoggled selection - JS treated the grouped overlay like a one-shot picker and closed too early
- C kept the grouped inventory menu visible while
- after fixing pre-menu
- Fix:
js/invent.js:- added grouped-overlay filtering support to
buildInventoryOverlayLines() - factored overlay restore logic
- added a grouped
PICK_ANYoverlay loop that:- resolves pending
--More--first - toggles selected inventory letters on keypress
- ignores unrelated keys
- confirms only on space/enter
- resolves pending
- switched
identify_pack()to use that groupedPICK_ANYflow
- added grouped-overlay filtering support to
- Result:
hi13_seed1001_wiz_shop-read-zap_gpis now gameplay-green and was promoted fromsessions/pending/tosessions/coverage/spells-reads-zaps/- refreshed parity coverage run moved overall coverage to:
- lines
57.15% - branches
59.42% - functions
40.67%
- lines
- this cleared the line-coverage threshold (
>56%) but did not yet clear the branch-coverage threshold (>60%)
Wizard identify display uses shared paged overlay ownership
- Problem: after the display-name fix,
t11_s754_w_covmax8_gpstill diverged on the wizard^Ipage handoff. C tty showed:- page 1 on the
^Istep - page 2 after the first space
- page 2 still visible while a later non-dismiss key (
i) was ignored - map restore only after the second space JS instead dropped back to the map too early because the display-only overlay helper only rendered a single synthetic first page.
- page 1 on the
- Diagnosis:
- this was not a gameplay bug;
RNGandeventswere already exact - the central display-only helper
renderOverlayMenuUntilDismiss()needed real tty-style page progression - paged overlays also need fullscreen clearing on later shorter pages, or old page-1 text survives underneath page-2 rows
- this was not a gameplay bug;
- Fix:
- teach
renderOverlayMenuUntilDismiss()to paginate throughbuildInventoryPages()instead of rendering only page 1 - while paged, space now advances pages; non-dismiss keys remain ignored for display-only flows
- force fullscreen clearing for multi-page overlay frames so page 2 is drawn onto a clean tty-sized menu surface
- align the overlay footer cursor with the C tty position by placing it at
offx + line.length, not one column past that - also fix the shared menu stack in
js/windows.jssoselect_menu(PICK_ANY)actually renders paged menu lines rather than trackingcurrentPagewithout changing the visible page
- teach
- Validation:
t11_s754_w_covmax8_gpregained full gameplay parity (RNG 20848/20848,events 3292/3292)- first screen divergence moved
451 -> 455 - first remaining failures are display-only:
- screen
455 - cursor
844
- screen
hi11_seed1100_wiz_zap-deep_gameplay: still greent22_s1250_w_digtrapmix_gp: still green
Canonical one-key inventory inspection commands must be live in cmd.js
- Problem: after the wizard-identify paging fix,
t11_s754_w_covmax8_gpstill diverged at step455because JS treated)as an unknown command while C executed the canonical “see weapon” inspection command and printeda - a blessed +1 quarterstaff (weapon in hands). - Diagnosis:
- this was a deployed-runtime command-dispatch gap, not a replay issue
- JS already had the underlying C-shaped helpers in
js/invent.js:doprwep(),doprarm(),doprring(),dopramulet(),doprtool(),doprinuse() - but
js/cmd.jshad only wired generic inventory (i) and gold ($), leaving the canonical one-key inspection commands unreachable
- Fix:
- bind the live command map in
js/cmd.jsfor:)->doprwep()[->doprarm()=->doprring()"->dopramulet()(->doprtool()*->doprinuse()
- bind the live command map in
- Validation:
t11_s754_w_covmax8_gp: gameplay remained exact (RNG 20848/20848,events 3292/3292)- first screen divergence moved
455 -> 461 - first cursor divergence remains
844 hi11_seed1100_wiz_zap-deep_gameplay: still greent22_s1250_w_digtrapmix_gp: still green
2026-03-14 21:58: quest-artifact wishes need both readobjnam() short-circuiting and inventory-add side effects
- Session:
/tmp/artifact_mix_probe2.session.json
- Problem:
- a new artifact-heavy wizard coverage probe diverged immediately on the Eye-of-the-Aethiopica wish path
- JS was missing two C behaviors:
objnam.c:readobjnam()short-circuits the artifact-abusern2()for a true quest artifactinvent.c:addinv_core1()applies quest-artifact side effects as soon as the object joins inventory:- mark
u.uhave.questart - call
artitouch() - apply carried artifact intrinsics
- mark
- Fix:
js/objnam.js:- changed the artifact-abuse branch to match C short-circuit semantics:
is_quest_artifact(otmp) || (otmp->oartifact && rn2(...) > 1)
- changed the artifact-abuse branch to match C short-circuit semantics:
js/invent.js:- added quest-artifact inventory-add side effects:
player.uhave.questart = trueawait artitouch(obj, _gstate)await set_artifact_intrinsic(obj, true, W_ART, player)
- added quest-artifact inventory-add side effects:
js/artifact.js:- corrected carried artifact intrinsic mappings:
SPFX_SEARCH -> SEARCHINGSPFX_ESP -> TELEPAT
- refreshed telepathy side effects after
SPFX_ESP
- corrected carried artifact intrinsic mappings:
js/quest.js:- made the wizard
gotitquest-artifact path key off live role state (roleMnum/roleName) - delivered that text through a temporary
NHW_TEXTwindow instead of line-by-linepline()calls
- made the wizard
- Result:
- the artifact probe’s first true gameplay blocker moved much later:
- RNG
38 -> 84 - events
4 -> 153 - screens matched
37 -> 82
- RNG
seed329_rogue_wizard_gameplay.session.jsonstayed fully green- there is still a residual text-window screen mismatch at step
38, but the quest-artifact path no longer blocks progression at its original frontier
- the artifact probe’s first true gameplay blocker moved much later:
Help menu wizard entry must use the C-visible selector letter
- Problem: after wiring the inventory inspection keys,
t11_s754_w_covmax8_gpstill diverged in the?help menu. JS rendered:w - List of wizard-mode commands.while C rendered:p - List of wizard-mode commands.
- Diagnosis:
- this was not a gameplay problem;
RNGandeventswere already exact js/pager.jshardcoded a wizard-only selector ofw- in the current C build, that help-menu entry lands at
pbecause it is the last item in the help menu’s auto-assigned order
- this was not a gameplay problem;
- Fix:
- change the wizard-help entry selector in
js/pager.jsfromwtop - update the selection handler to dispatch wizard help on
p
- change the wizard-help entry selector in
- Validation:
t11_s754_w_covmax8_gp: gameplay remained exact (RNG 20848/20848,events 3292/3292)- first screen divergence moved
461 -> 802 - first cursor divergence remains
844 hi11_seed1100_wiz_zap-deep_gameplay: still greent22_s1250_w_digtrapmix_gp: still green
2026-03-14 22:26: #invoke must normalize ECMD_TIME into { tookTime } or monster turns vanish
- Session:
/tmp/artifact_mix_probe2.session.json
- Problem:
- after fixing quest-artifact wishing, the next first blocker was
#invoke pon the Eye of the Aethiopica - JS matched C through
arti_invoke_cost(), then stopped; C immediately ran monster movement in the same keypress - cause:
cmd.jsreturned rawECMD_*fromdoinvoke(), but the command loop expects{ moved, tookTime }
- after fixing quest-artifact wishing, the next first blocker was
- Fix:
js/cmd.js:- normalize
#invokelike other extended commands:return { moved: false, tookTime: !!((await doinvoke(...)) & ECMD_TIME) }
- normalize
- Result:
- the artifact probe improved again:
- RNG
84 -> 94 - events
153/250 -> 250/250full - screens
82/103 -> 101/103
- RNG
seed329_rogue_wizard_gameplay.session.jsonremained fully green- the remaining gameplay blocker is now later and RNG-only, while the older step-38 text-window mismatch is just residual screen rendering
- the artifact probe improved again:
Cancelled #cast direction must not clear the release message row
- Problem: after the help-menu fix,
t11_s754_w_covmax8_gpstill diverged at step802: C showedThe magical energy is released!after cancelling the force-bolt direction prompt, while JS left row 0 blank. - Diagnosis:
- gameplay and RNG were already exact, so the spell mechanics were not the problem
js/spell.jsalready emitted the correct C message in the cancelledgetdir()branch- but it immediately called
display.clearRow(0), wiping the topline it had just printed
- Fix:
- remove the post-message
clearRow(0)in the cancelledgetdir()path ofspelleffects()
- remove the post-message
- Validation:
t11_s754_w_covmax8_gp: gameplay remained exact (RNG 20848/20848,events 3292/3292)- first screen divergence moved
802 -> 1243 - first cursor divergence remains
844 hi11_seed1100_wiz_zap-deep_gameplay: still greent22_s1250_w_digtrapmix_gp: still green
2026-03-14 23:05: artifact cooldown penalties use C d() logging, not Lua-style d()
- Session:
/tmp/artifact_mix_probe2.session.json
- Problem:
- the artifact probe still showed a late RNG blocker at step
94after#invoke pon a tired artifact - C logs that cooldown penalty as composite
d(3,10)=...fromrnd.c:d() - JS was using Lua-style
d()inarti_invoke_cost(), which emits three underlyingrn2(10)calls instead
- the artifact probe still showed a late RNG blocker at step
- Fix:
js/artifact.js:- change both tired-artifact cooldown penalties from
d(3, 10)toc_d(3, 10)
- change both tired-artifact cooldown penalties from
- Result:
- the artifact probe is now fully green on gameplay channels:
RNG 2811/2811events 250/250
- remaining mismatch on that probe is now only screen-only at step
38(wizard quest text window rendering) seed329_rogue_wizard_gameplay.session.jsonstayed fully green
- the artifact probe is now fully green on gameplay channels:
Internal mattacku() wield turns must print the visible wield message
- Problem: after the cancelled-cast topline fix,
t11_s754_w_covmax8_gpstill diverged at step1259: C showedThe goblin wields a crude dagger!while JS left row 0 blank. - Diagnosis:
- gameplay was already exact (
RNG 20848/20848,events 3292/3292), so the goblin was taking the same wield-consumes-turn action in both runtimes - the missing screen text came from the internal
AT_WEAPbranch injs/mhitu.js: JS calledmon_wield_item()and consumed the attack, but did not emit the visible wield message that Cweapon.c:mon_wield_item()prints - the existing pre-move message path in
js/mthrowu.jswas not enough because this session was using the later in-mattacku()wield path
- gameplay was already exact (
- Fix:
js/mhitu.jsnow records the old weapon, and when the internalAT_WEAPwield branch consumes the turn it printsThe ${x_monnam(mon)} wields ${doname(mon.weapon, player)}!when canonicalcanseemon(...)visibility holdsjs/mthrowu.js/js/monmove.jswere also tightened to use the same canonicalcanseemon(...)visibility check for the pre-move wield message
- Result:
t11_s754_w_covmax8_gpimproved materially:- screens
1863/1866 -> 1864/1866 - colors
44781/44784 -> 44782/44784 - first screen divergence
1259 -> 1610
- screens
- remaining failures are later display/mapdump-only:
- first screen/color divergence: step
1610 - first cursor divergence: step
844
- first screen/color divergence: step
hi11_seed1100_wiz_zap-deep_gameplay: still greent22_s1250_w_digtrapmix_gp: still green
2026-03-14 23:15: cmdassist invalid-direction help must dismiss on --More-- keys only
- Sessions:
test/comparison/sessions/seed031_manual_direct.session.json- guardrails:
seed032_manual_direct,seed033_manual_direct,seed329_rogue_wizard_gameplay
- Problem:
seed031had regressed badly from historical pass state- first screen blocker was early:
- step
52 - JS:
Never mind. - C: still the cmdassist help page
- step
- owner tracing showed JS entered
show_invalid_direction_cmdassist_help()at step51, then dismissed it on step52keya - C does not dismiss that help page on arbitrary keys; it behaves like a
blocking
display_nhwindow(..., TRUE)/tty_more()boundary
- Fix:
js/pickup.js:- change
show_invalid_direction_cmdassist_help()to wait for real dismiss keys only:Space,Enter,Return,Esc,Ctrl-P - keep the follow-up
docrt()so the caller’s laterNever mind.redraw starts from a cleared help screen
- change
- Result:
seed031_manual_directreturned to full gameplay parity:RNG 9079/9079screens 1365/1365events 2655/2655
seed032_manual_direct,seed033_manual_direct, andseed329stayed fully green on gameplay parity
2026-03-14 23:30: wizard quest-artifact pages need fullscreen NHW_TEXT, and tired-artifact ignores use object grammar
- Sessions:
test/comparison/sessions/coverage/artifact-use/hi14_seed1003_wiz_artifact-use_gp.session.json- guardrails:
seed031_manual_direct,seed329_rogue_wizard_gameplay
- Problem:
- the new wizard artifact-heavy coverage probe had become gameplay-green, but
still failed on two presentation details:
- step
38: the Wizard quest-artifactgotittext was rendered like a short popup instead of a fullscreenNHW_TEXTpage with the cursor on the bottom--More--row - step
93: tired artifact invocation printed the wrong object wording, usingThe Eye... is ignoring youinstead of the C-stylethe ... named the Eye ... are ignoring you
- step
- the new wizard artifact-heavy coverage probe had become gameplay-green, but
still failed on two presentation details:
- Fix:
js/windows.js,js/display.js, andjs/headless.jsnow support window-level popup options so specificNHW_TEXTpages can force fullscreen rendering, clear the full window area, and place the cursor on the last row like tty text windows dojs/quest.jsuses that path for the Wizardqt_pager("gotit")artifact messagejs/artifact.jsnow formats tired-artifact ignore messages withthe(xname(obj))plusotense(obj, "are"), then normalizesnamed Theto the C-visiblenamed the
- Result:
hi14_seed1003_wiz_artifact-use_gpis fully green:RNG 2811/2811screens 103/103colors 2472/2472events 250/250cursor 103/103
- line coverage increased to
57.45% - branch coverage is now
59.46%, still short of the60%threshold
2026-03-14 23:55: reconnaissance-first session design is the right way to reach difficult gameplay branches
- Sessions/issues:
#373- reconnaissance probes around
/tmp/shopseed700l2.session.json
- Problem:
- blind session recording wastes turns and misses the branch-dense parts of the game we actually need for Phase 3, especially shops, quests, special levels, and unusual NPC interactions
- even when a promising seed/level exists, screen glyphs alone are often not enough to tell which floor squares really contain merchandise, where the live shopkeeper is, or which room/object context will trigger the desired codepath
- Fix/tooling:
- added
test/comparison/shop_checkpoint_debug.js - it loads a compact mapdump checkpoint from either a session or raw mapdump,
merges
O/QandM/N, and prints:- shopkeepers and other monsters with ids/coords/state
- floor objects with resolved names and flags
- local room-number geometry around the area of interest
- added
- Result:
- on
seed 700 / dlvl 2, the tool immediately proved that the target room is a real armor shop with a live shopkeeper at(8,12)and floor merchandise stacked across room3 - that turns the next coverage session from blind probing into deliberate routing through unpaid merchandise, debt, payment, and shopkeeper branches
- on
- Rule:
- for hard-to-explore codepaths, inspect the C checkpoint first and design the session from that knowledge
- this same method should be used to build long, high-yield sessions through
quests, special levels, shops, and other expensive branch-dense areas
2026-03-14: watched-cell repaint tracing is now a reusable parity tool
- Problem:
- step-local screen mismatches were repeatedly collapsing to the same question: for one square, in one short step range, which runtime writer last touched it?
dbgmapdumpwas good for settled state and^repaint[...]was good for broad ownership, but neither gave a compact per-cell write timeline
- Tooling:
- added watched-cell trace support to both runtime display implementations:
js/headless.jsfor parity/session replayjs/display.jsfor browser/runtime debugging
- added driver:
scripts/debug/repaint_square_trace.mjs
- documented usage and guardrails:
docs/SQUARE_REPAINT_TRACE.mdskills/square-repaint-triage/SKILL.md
- added watched-cell trace support to both runtime display implementations:
- Usage:
node scripts/debug/repaint_square_trace.mjs <session> --cell <col,row> --steps <from-to> --stack
- First concrete payoff:
- on
t11_s754_w_covmax8_gp, watching cell34,18over1609-1611showed:show_glyph()writing the web glyph"to the square- then
putMapCell()restoring the spider glyphs
- that proved the live bug is repaint/write order on one runtime cell, not a vague replay boundary issue or a generic settled-state trap mismatch
- on
2026-03-15 00:20: shop reconnaissance needs both compact and wizload checkpoint support, and minetn-5 is the cleaner first shop candidate
- Sessions/probes:
/tmp/shopseed700l2.session.json/tmp/shop700_wiz_settle.session.json/tmp/minetn-4_probe.session.json/tmp/minetn-5_probe.session.json
- Problem:
- ordinary levelport checkpoints can be captured at awkward boundary states;
for
seed 700 / dlvl 2, the shopkeeper and merchandise were real, but the hero position was still effectively pre-settle and not usable for path planning wizloadprobes are much better for stable special-level reconnaissance, but their checkpoints live insidesteps[].checkpointsas structured objects instead of the top-level compact mapdump map
- ordinary levelport checkpoints can be captured at awkward boundary states;
for
- Fix/tooling:
test/comparison/shop_checkpoint_debug.jsnow accepts both checkpoint shapes:- top-level compact checkpoint blobs
- structured per-step checkpoints from
wizload
- it also emits shortest-path movement strings from hero to an explicit target coordinate, which is enough to turn reconnaissance into candidate session routes
- Result:
minetn-4has nearby shops, but too many adjacent hostiles; it immediately turns a shop probe into combat noiseminetn-5is the better firstshops-economycandidate:- nearest shopkeeper at
(70,10) - adjacent shop goods at
(69..72,11) - only one nearby hostile in the initial shop neighborhood
- nearest shopkeeper at
- this is the right next probe target for building the first real branch-dense shop coverage session
2026-03-15: post-hi15 coverage refresh still leaves branch coverage below 60%, and structured reconnaissance should drive the next vault session
- Ran a full
npm run coverage:session-parity:refreshafter promotinghi15_seed42_barb_minetn5_shop-pay_gp.session.json. - New committed coverage snapshot:
- lines
58.09% - branches
59.50% - functions
42.18%
- lines
- Coverage impact from
hi15was real and correctly targeted:js/shk.js:26.01% -> 39.35%js/muse.js:33.50% -> 36.84%
- Conclusion:
- line coverage is no longer the immediate bottleneck
- branch coverage is still under
60%, so the next session must be branch-dense rather than merely long js/vault.jsremains one of the largest low-coverage gameplay files
- Tooling improvement:
- extended
test/comparison/shop_checkpoint_debug.jsso structuredwizloadcheckpoints now print:- special-room type/name
- room bounds and centers
- hero-to-room shortest-path strings
- extended
- Real smoke test:
- on promoted
hi15, the tool now surfaces Minetown’s structured shops and temple with usable path strings from the hero
- on promoted
- Practical rule:
- for future branch-dense sessions through shops, temples, vaults, quests,
and similar expensive areas, prefer structured
wizloadreconnaissance over compact post-settle mapdumps when room-type knowledge matters
- for future branch-dense sessions through shops, temples, vaults, quests,
and similar expensive areas, prefer structured
- Follow-up:
- opened
#374for the next reconnaissance-driven vault/guard coverage session
- opened
2026-03-14: show_map_spot() must restore trap/object glyphs after newsym() during mapping
- Problem:
t11_s754_w_covmax8_gpwas gameplay-green but still had a screen/color divergence at step1610on wizard mapping (Ctrl-F)- JS showed the giant spider glyph
son the watched square while C showed the seen web glyph"
- Diagnosis:
- watched-cell repaint tracing on
34,18showed the exact overwrite chain:show_glyph()painted the web glyph- then
putMapCell()restored the spider duringshow_map_spot() -> newsym()
- JS
detect.js:show_map_spot()was incomplete - C
detect.c:show_map_spot()does more than force background +newsym(): after that redraw it restores visible trap/engraving state, or re-shows the prior trap/object glyph, so mapping uses trap-over-object precedence rather than normal in-sight monster-over-trap precedence
- watched-cell repaint tracing on
- Fix:
js/detect.js:show_map_spot()now ports the missing C post-newsym()restoration logic:- preserve
oldglyph = glyph_at(x, y) - after
newsym(), if not furniture:map_trap(trap, 1)when a seen trap is present- else
map_engraving(engr, 1)when applicable - else
show_glyph(x, y, oldglyph)when the prior glyph was a trap or object
- preserve
- Result:
t11_s754_w_covmax8_gpis now full green on gameplay display channels:RNG 20848/20848events 3292/3292screens 1866/1866colors 44784/44784
- remaining failures are now only older non-screen channels:
- mapdump
d0l10_006 - cursor step
844
- mapdump
hi11_seed1100_wiz_zap-deep_gameplay: still greent22_s1250_w_digtrapmix_gp: still green
2026-03-15: invalid throw prompt needs a visible --More-- owner
- Problem:
- after the mapping fix,
t11_s754_w_covmax8_gpwas still failing on cursor only at step844 - session expected cursor
[35,0,1]onYou don't have that object.--More-- - JS ended at
[27,0,1]
- after the mapping fix,
- Diagnosis:
- watched-cell cursor tracing showed step
844cursor placement came fromjs/dothrow.js:handleThrow() - the invalid inventory-letter path called
more()withoutforceVisual, so JS kept the cursor at message-end column27instead of the visible--More--marker at column35
- watched-cell cursor tracing showed step
- Fix:
js/dothrow.jsnow callsmore(..., { forceVisual: true })for the invalid-inventory-letter throw path
- Result:
t11_s754_w_covmax8_gpcursor is now full green (1866/1866)- screens/colors remain fully green
hi11_seed1100_wiz_zap-deep_gameplay: still greent22_s1250_w_digtrapmix_gp: still green
wizload nhlib shuffle and special-checkpoint flip duplication were both real RNG debt for new Minetown shop coverage
- New pending coverage session:
test/comparison/sessions/pending/hi15_seed42_barb_minetn5_shop-pay_gp.session.jsonuses--wizload minetn-5plus reconnaissance-guided wizard teleport to hit:- tool-shop greeting,
- unpaid pickup,
pwith no money,- wizard
^Wgold wish, - pay menu entry.
- Initial parity on that session failed immediately during
wizloadspecial-level setup, before the shop logic itself. - Two independent JS wizload RNG mistakes were exposed and fixed:
js/wizcmds.js:handleWizLoadDes()was manually burningrn2(3); rn2(2);for an imaginednhlib.lua shuffle(align)prelude even though the translated level generator already performs its ownshuffle(align). This duplicated thenhlibshuffle cost.js/sp_lev.js:finalize_special_checkpoint_stage()always calledflip_level_rnd(), ignoringfinalizeContext.skipRandomFlip, even though the regularfinalize_level()path already respected that C wizload rule.
- After those fixes:
- first RNG divergence on the pending Minetown shop session moved later,
- matched events increased substantially,
- wizard guardrail
seed329_rogue_wizard_gameplaystayed fully green.
- Remaining first blocker on that session is now a narrower wizload
finalization/branch-placement ordering issue (
place_branchvs Cpriestini) rather than broad special-level prelude noise.
2026-03-15: generated stairs must go through mkstairs()
- Problem:
- after fixing
t11_s754_w_covmax8_gpscreen/color/cursor parity, the last remaining mismatch was mapdump-only:d0l10_006 U[9]: JScontext.move=1, session0
- forcing checkpoint payloads to emit
moveOverride: 0fixed that mismatch, but exposed an earlier state bug:d0l4_003 W[52,13]: JS0, session2
- after fixing
- Diagnosis:
- direct runtime inspection at step
1370showed cell(52,13)wasSTAIRS, but had noflags, nowall_info, nostairdir, and was notmap.upstairormap.dnstair - C
mklev.c:generate_stairs()callsmkstairs(...)for both down and up stair placement - JS
js/mklev.js:generate_stairs()still used a legacy direct-assignment shortcut:loc.typ = STAIRSloc.flags = 0/1map.dnstair/map.upstair
- that shortcut dropped the C stair direction metadata (
stairdir) whenever later placement overwrote the primary stair pointers
- direct runtime inspection at step
- Fix:
js/mklev.js:generate_stairs()now callsmkstairs(map, x, y, ...)for both generated stairsjs/dungeon.jsmapdump checkpoint payloads now forcecontext.move=0during level-generation checkpoints, matching the captured C checkpoint state
- Result:
t11_s754_w_covmax8_gpis now fully green:RNG 20848/20848events 3292/3292screens 1866/1866colors 44784/44784cursor 1866/1866mapdump 6/6
- promoted to
sessions/coverage/monster-ai-combat/ hi11_seed1100_wiz_zap-deep_gameplay: still greent22_s1250_w_digtrapmix_gp: still greenMinetown wizload parity depends on shrine priest creation and non-stubbed
monkfoodshop()helpers
- Continuing the
hi15_seed42_barb_minetn5_shop-pay_gpbring-up exposed three more real C-vs-JS special-level bugs. - First,
js/sp_lev.js:create_altar()was not C-faithful for shrine altars:- it ignored
opts.type = "shrine"|"sanctum", - it only looked at
currentRoom, instead of also resolving the room from the altar tile when the altar is placed into a map-defined temple region, - and it stored raw alignment in
loc.flagsinstead of the altar bitmask.
- it ignored
- C
sp_lev.c:create_altar()callspriestini()for shrine altars insideTEMPLErooms. Porting that behavior in JS moved the firsthi15blocker from “missing priest path beforeafter_wallification_special” to the later branch-placement/finalization window. - Second, the Minetown level family still had fake converted helpers:
js/levels/minetn-{2,3,4,5,6,7}.jsdefinedmonkfoodshop()as a stub. Cdat/nhlib.luareturns"health food shop"for Monks and"food shop"otherwise. Implementing that helper restored the missing Minetown food-shop room onminetn-5, raising JS room count from4to5and moving thehi15first RNG divergence later again. - Third,
js/wizcmds.js:handleWizLoadDes()needs two finalize contexts, not one sharedskipRandomFlipflag:- the
load_special()-equivalent pass must still do the Cflip_level_rnd(), - the later
lspo_finalize_level(NULL)pass must skip the extra flip.
- the
- Guardrails after these fixes:
seed329_rogue_wizard_gameplay: fully greenseed031_manual_direct: fully green
- Current
hi15state after this increment:- first RNG divergence moved from the original step-5 priest/branch prelude
noise to a later
finalize_level()vsshkinit()boundary, - first event divergence remains in the first shopkeeper inventory sequence, which is now the correct narrow next target.
- first RNG divergence moved from the original step-5 priest/branch prelude
noise to a later
Minetown wizload deferred finalize needs the earlier boundary hardening, and dopay() must import GOLD_PIECE
- Continuing
hi15_seed42_barb_minetn5_shop-pay_gpshowed the step-5 blocker was not broad shrine/fixup noise anymore. The concrete bad state was in Minetown wizload topology:- JS left extra mineralize candidates alive on the left/right boundary,
- C already had
W_NONDIGGABLEon those deferred-finalize edge stones.
- The useful narrowing was:
- running the mineralize-eligibility scan over the step-5 mapdumps gave
JS=63vsC=43, - the JS-only candidates were exactly:
x=3,y=10..14x=77,y=6..19- plus one terrain-side extra at
(46,17)
- running the mineralize-eligibility scan over the step-5 mapdumps gave
- Root cause for the boundary portion:
- the wizload split path already let script
des.finalize_level()request a deferred finalize, - but
finalize_special_checkpoint_stage()stopped beforebound_digging(), so the persistent first-pass edge hardening never happened in JS.
- the wizload split path already let script
- Fix:
js/sp_lev.js:finalize_special_checkpoint_stage()now appliesbound_digging(levelState.map)when deferred finalize was requested, restoring the C-faithful persistent edge hardening without duplicatingmineralize()RNG in the first stage.
- This immediately moved
hi15past the original step-5 wizload mineralize /shkinit()blocker and exposed a later real shop path bug:js/shk.js:dopay()referencedGOLD_PIECEwithout importing it.- importing
GOLD_PIECEfixed that crash.
- Validation after both fixes:
hi15_seed42_barb_minetn5_shop-pay_gp- improved from the original
step 5wizload blocker to:RNG 5869/6221events 94/450screens 34/57colors 1317/1368
- current first visible mismatch is now much later, in the shop interaction flow rather than special-level generation
- improved from the original
- guard sessions remained green:
hi11_seed1100_wiz_zap-deep_gameplayt22_s1250_w_digtrapmix_gp
hi15 shop-pay bring-up: maze-extents split, wall-state mirroring, and first real payment path
- Continuing
hi15_seed42_barb_minetn5_shop-pay_gpexposed that two separate special-level problems had been conflated:minetn-5really wants the native maze-levelbound_digging()extents during wizload finalization,- but the older
t04_s706_w_minetn1_gpfixture is already red on currentmainfor an earlierminetn-1generation mismatch and is not a valid regression signal for this work.
- Evidence:
- applying C
bound_digging()to the recordedhi15after_wallificationcheckpoint yields exactly43eligible mineralize cells only whenis_maze_lev=true, matching the C RNG trace through the firstshkinit()object. - with the JS wizload path left at non-maze extents,
hi15stayed stuck at the old step-5 extrarn2(1000)mineralize tail.
- applying C
- Fixes:
js/sp_lev.js:terrain()now translates raw coordinate-list entries throughtoAbsoluteCoords()whenmapCoordModeis active, matching C map coordinate mode forselection.line()/selection.area()writes.js/sp_lev.jsandjs/dungeon.jsnow mirrorwall_infoupdates into the low wall-flag bits inloc.flagsfor:bound_digging()sel_set_wall_property()solidify_map()- drawbridge wall creation This keeps hidden wall-state faithful and makes checkpoint capture reflect the same effective wall flags that mineralize sees.
js/shk.jsimportedGOLD_PIECEsodopay()can finally execute oncehi15reaches the real payment path.
- Result:
hi15moved from the old step-5 wizload/mineralize blocker all the way to live shop interaction at step32.- first RNG divergence is now in later shopkeeper/dialog behavior instead of special-level topology finalization.
- stable guardrails remained green:
seed031_manual_directseed329_rogue_wizard_gameplay
hi15 shop-pay bring-up: unpaid pickup needs real shop billing and deferred turn ownership
- Continuing
hi15_seed42_barb_minetn5_shop-pay_gpfrom step32showed that JS single-item,pickup was still bypassing the Cpick_obj()->addtobill()billing path. - Evidence:
- C step
32shows the quoted billing line:"For you, most gracious sir; only 30 zorkmids for this lock pick."--More-- - JS originally skipped straight to ordinary inventory pickup text and then advanced monster movement too early.
- C step
- Fixes:
js/pickup.jssingle-itemcompletePickup()now callsaddtobill(..., ininv=true)before manual inventory insertion when the pickup square is costly.js/shknam.jsnow gives generated shopkeepers realESHKstate duringshkinit(), whichwizloadMinetown shops needed for billing.js/shk.js:get_cost()now mirrors C charisma / tourist / visible-shirt pricing adjustments, so quoted shop prices match C.- billed objects now retain price metadata for unpaid inventory naming, and
pickup inventory lines use
doname_with_price()for unpaid objects. - unpaid pickup
--More--now defers timed-turn advancement until the acknowledgment key, but only for unpaid shop pickups, avoiding regressions on ordinary pickups.
- Result:
hi15moved from step32to step34.- stable guardrails remained green:
seed031_manual_directseed329_rogue_wizard_gameplay
hi15 shopkeeper movement after wizload: use ESHK state, not stale root shadows
- Continuing
hi15_seed42_barb_minetn5_shop-pay_gpfrom step34exposed an earlier gameplay drift in step5: a Minetown shopkeeper moved off(60,6)in JS while C left it in place. - Focused tracing in
js/monmove.jsshowed the root cause:- the shopkeeper branch was still reading root-level shadow fields like
mon.billct,mon.debit,mon.robbed,mon.following,mon.shk, andmon.shd - but after
wizloadspecial-level bring-up, the authoritative values lived inmextra.eshk - on the failing turn, JS had
mon.billct == 0whileESHK(mon).billct == 1so the branch incorrectly took thesatdoor -> appr=0random-move path
- the shopkeeper branch was still reading root-level shadow fields like
- Fix:
js/monmove.jsnow sources shopkeeper movement state fromESHK(mon)in the same places Cshk_move()does, falling back to root fields only ifmextra.eshkis absent.
- Result:
hi15moved later again:- first divergence
34 -> 31on events/screens - first RNG divergence
34 -> 35
- first divergence
- the remaining blocker is no longer shop billing or shopkeeper state; it is
a stacked teleport/shop-entry
--More--boundary around steps29..35. - stable guardrails remained green:
seed031_manual_directseed329_rogue_wizard_gameplayselection.line()must convert map-relative coordinates beforedes.terrain()edits
- Continuing
hi15_seed42_barb_minetn5_shop-pay_gpshowed the remaining early Minetown setup drift was deterministic and present before wallification:- JS and C matched in the local stair pocket at
after_map, - but diverged by
after_wallification_special, - while RNG was still aligned.
- JS and C matched in the local stair pocket at
- The crucial mismatch in the translated special-level helpers was:
selection.area()already converted map-relative points throughget_location_coord(),selection.line()returned raw relative{x,y}pairs,des.terrain(selection.line(...), ...)then applied those raw points as if they were absolute cells.
- That is not C-faithful. In
nhlsel.c,selection.line()routes both endpoints throughget_location_coord(..., ANY_LOC, croom, SP_COORD_PACK(...))before constructing the line selection. - Fix:
js/sp_lev.js:selection.line()now pushesselection._toAbsoluteCoord(x, y)for each point on the line.
- Validation:
hi15_seed42_barb_minetn5_shop-pay_gp- improved from:
RNG 5869/6221events 94/450
- to:
RNG 5902/6221events 212/449- first RNG divergence now at step
32instead of the old step-5Minetown setup blocker
- improved from:
- guard sessions stayed green:
hi11_seed1100_wiz_zap-deep_gameplayt22_s1250_w_digtrapmix_gp
- This is a broad special-level translation fix, not a Minetown-only patch;
any translated level script using
des.terrain(selection.line(...), ...)under map-relative coordinate mode depends on this behavior.
hi15 shop payment bring-up: ESHK billing state and billed-purchase messaging
- While bringing up
hi15_seed42_barb_minetn5_shop-pay_gp, the#paypath initially failed before the billed-items menu because JS was reading bill state from the shopkeeper monster shell instead ofESHK(shkp). - The C-faithful fix was to route
make_itemized_bill()and related billing helpers throughESHK(shkp)and to zero-initializecredit,debit,loan,bill, andbillctinneweshk()/shkinit(). - That exposed the next missing C behavior:
dopayobj()must callshk_names_obj()after a successful billed purchase so the player seesYou bought ...before the later shopkeeper thank-you. - For object wishing, bypassing weighted
rnd_otyp_by_namedesc()is only safe for true coin wishes (gold,gold piece(s),zorkmid(s)). Applying the same shortcut to allforcedTypresolutions regressed normal item wishes likegray dragon scale mail. - Status after these fixes:
hi15now has full RNG and event parity through the payment path.- Remaining
hi15mismatches are down to the post-payment gold total on the status line and the older teleport cursor boundary. seed031_manual_directandseed329_rogue_wizard_gameplaystayed green.
hi15 final shop-payment gold sync: partial coin-stack payment must update player.gold
- After the billed purchase path was green on RNG/events,
hi15still had one screen mismatch at step57: JS showed$:1000while C showed$:970. - Root cause:
shk.js:money2mon()usedsplitobj()for partial payment from a carried coin stack, then tried toremoveFromInventory(payment). But the split-off payment object is detached; the carried stack remains in inventory with reducedquan, so the hero’splayer.goldshadow never changed. - Fix:
- debit
player.golddirectly in the partial-stack branch ofmoney2mon().
- debit
- Result:
hi15_seed42_barb_minetn5_shop-pay_gpbecame gameplay-green.- remaining mismatch is cursor-only at step
29, which is non-gating for gameplay parity. seed031_manual_directandseed329_rogue_wizard_gameplaystayed green.
Gameplay recon: explicit #dumpsnap checkpoints are usable inside ordinary session probes
- For ordinary gameplay coverage probes, the existing C harness already had the
raw ingredients for step-local reconnaissance, but they were split across two
paths:
- gameplay
run_session.pysetNETHACK_MAPDUMP_DIRand collected compact auto-mapdump checkpoints only at level-transition boundaries; - wizload
run_session.pyread structuredcheckpoints.jsonlentries, but gameplay did not.
- gameplay
- Two small harness/tool fixes make ordinary gameplay recon much more useful:
test/comparison/c-harness/run_session.py- gameplay sessions now set
NETHACK_DUMPSNAP=<checkpoint_file>; - gameplay step capture now reads any new structured checkpoint entries
from that file and attaches them to the corresponding step as
step.checkpoints, mirroring wizload behavior.
- gameplay sessions now set
test/comparison/shop_checkpoint_debug.js- if a requested session checkpoint id is not found in top-level compact
mapdumpCheckpoints, it now falls back to step-attached structured checkpoints like52:afterwest:0; - it also prints the hero neighborhood separately when the anchor is a special room rather than the hero.
- if a requested session checkpoint id is not found in top-level compact
- Practical workflow:
- in a raw gameplay probe, insert
#dumpsnap, then provide a short phase tag and pressEnter; - inspect the resulting tagged checkpoint with
shop_checkpoint_debug.js <session> <step:phase:index>.
- in a raw gameplay probe, insert
- This is useful for reconnaissance-driven Phase 3 work because it lets us inspect hard-to-reach ordinary-level states, like long digging routes toward a vault, without adding comparator exceptions or gameplay-side hacks.
Knox generator parity: await selection.iterate() when the callback is async
js/levels/knox.jsdefinesasync function treasure_spot(x, y)for the vault treasury tiles because each spot can place gold and optionally create a trap.- JS then called
treasury.iterate(treasure_spot)withoutawait, even thoughselection.iterate()itself is async and preserves the C y-major ordering only when awaited. - That let later generator code run too early, so the first
wizload knoxparity divergence happened inside generation:- before fix: first RNG divergence at step
5, index2336- JS:
rn2(100)=22 @ percent(sp_lev.js:7239) <= generate(knox.js:83) - C:
rn2(301)=226 @ random src=nhlib.lua:10 parent=lua(knox.lua:58)
- JS:
- before fix: first RNG divergence at step
- Fix:
- change
treasury.iterate(treasure_spot);toawait treasury.iterate(treasure_spot);
- change
- Result on
/tmp/knox_candidate1.session.json:- first RNG divergence moved later from index
2336to5372 - guardrails
seed031_manual_directandseed329_rogue_wizard_gameplaystayed green.
- first RNG divergence moved later from index
- Practical rule:
- when a translated special-level callback is async, always
awaitselection.iterate(...); otherwise RNG from later level-generation branches can interleave ahead of the intended C/Lua order.t11_s755canceled directed spell: zero direction must self-zap, and force-bolt self-zap must use C dice logging
- when a translated special-level callback is async, always
t11_s755_w_covmax9_gpwas diverging at step652on a canceled directedforce boltcast:- JS:
The magical energy is released! The kitten drops a gold piece. - C:
The magical energy is released! You bash yourself!
- JS:
- The root cause was in
js/spell.js:spelleffects().- After the existing canceled-
getdir()handling, JS always dispatched directed spells throughweffects(...). - C
spell.cinstead routes zero remembered direction throughzapyourself(...), and only non-zero direction throughweffects(...).
- After the existing canceled-
- Fix:
js/spell.js:spelleffects()now checks!player.dx && !player.dy && !player.dzand callszapyourself(...)for the zero-direction case.
- That exposed a second C-shape mismatch in
js/zap.js:zapyourself().- The
WAN_STRIKING/SPE_FORCE_BOLTself-zap branch was using Lua-styled(...), which logs individualrn2(...)calls. - C
zap.cusesd(2,12)/d(spe+1,6)fromrnd.c, logged as a composited(...)entry before the laterexercise(...)RNG.
- The
- Fix:
- switched that self-zap damage roll to
c_d(...).
- switched that self-zap damage roll to
- Validation:
t11_s755_w_covmax9_gp- improved from:
RNG 3456/26517- first RNG divergence at step
652
- to:
RNG 22759/26517- first RNG divergence at step
1669 - new frontier in special-level scripted monster creation:
resolveMonsterIndex(sp_lev.js)vsrndmonst_adj(makemon.c)
- improved from:
- guard sessions stayed green:
hi11_seed1100_wiz_zap-deep_gameplayt22_s1250_w_digtrapmix_gp
sp_lev object-form des.monster({...}) must stay random when id/class is omitted
t11_s755_w_covmax9_gpnext diverged at step1669during special-level monster creation:- JS:
resolveMonsterIndex(sp_lev.js)starting withrn2(9)=1 - C:
rndmonst_adj(makemon.c)starting withrn2(3)=1
- JS:
- The root cause was a JS default in
createScriptMonster():- object-form monster specs were using
opts.id || opts.class || '@' - so
des.monster({ x: ..., y: ... })was treated like an explicit'@'class request - C does not do that; missing
id/classstays unspecified and falls through tomakemon(NULL, ...), which is therndmonst_adj()path.
- object-form monster specs were using
- Fix:
js/sp_lev.js:createScriptMonster()now usesopts.id ?? opts.class ?? null- it only rejects the truly empty-string monster id, not
null
- Validation:
t11_s755_w_covmax9_gp- improved from:
RNG 22759/26517events 6262/7287- first RNG divergence at step
1669
- to:
RNG 26238/26780events 6815/7474- first RNG divergence at step
1700 - new frontier in later monster action timing:
JS
dochug(monmove.js)vs Cmonmulti(mthrowu.c)
- improved from:
- guard sessions stayed green:
hi11_seed1100_wiz_zap-deep_gameplayt22_s1250_w_digtrapmix_gp
Knox parity: irregular zoo fill and arrival-wall lighting (2026-03-15)
wizload knoxon/tmp/knox_candidate1.session.jsonhad two separate blockers:- gameplay drift in
fill_zoo_room()for the irregular entry zoo, - a screen-only mismatch on the arrival chamber wall after gameplay was already green.
- gameplay drift in
- The gameplay fix in js/dungeon.js was:
- keep the existing
SPACE_POS-style wall guard for irregular rooms, - add the missing C constraints that matter on this level:
loc.roomno === rmnodistmin(sx, sy, door.x, door.y) > 1
- keep the existing
- This was the safe version of the C logic. A straight
edge-based port was too risky on the current JS special-level metadata path, butroomno + distminmovedknoxto full RNG/event parity without regressingseed031orseed329. - The remaining screen mismatch was the arrival chamber bottom wall/corner showing on the first Knox screen when C left that row blank.
- The translated js/levels/knox.js already carried a level-script workaround to unlight the left and top arrival walls. Extending that workaround to the bottom wall and its bottom-right corner matched the C capture and made the session fully green.
- Result:
- promoted hi16_seed1200_wiz_knox_gp.session.json
RNG 11601/11601screens 10/10colors 240/240events 1999/1999cursor 10/10
monmulti() must roll before class and racial bonuses
t11_s755_w_covmax9_gpnext diverged at step1700on a gnome lord’s crossbow attack:- JS:
rnd(3)=3 @ dochug(monmove.js) - C:
rnd(2)=2 @ monmulti(mthrowu.c)
- JS:
- The acting monster and loadout were aligned:
- monster
166= gnome lord - launcher
CROSSBOW - projectile
CROSSBOW_BOLT
- monster
- The mismatch was in
js/mthrowu.js:monmulti().- JS was adding
multishot_class_bonus(...)and the racial elf/orc/gnome ammo bonus beforernd(multishot). - C
mthrowu.c:monmulti()rollsrnd(multishot)first, then adds class and racial bonuses afterward.
- JS was adding
- Fix:
- leave prince/lord/launcher-enchantment bonuses in the pre-roll bucket
- move
multishot_class_bonus(...)and the racial ammo bonus to the post-rnd(multishot)path
- Validation:
t11_s755_w_covmax9_gp- improved from:
RNG 26238/26780- first RNG divergence at step
1700
- to:
RNG 26239/26780- first RNG divergence at step
1733 - new frontier:
JS
rn2(5)=1 @ dochug(monmove.js)vs Crn2(20)=16 @ gethungry(eat.c)
- improved from:
- guard sessions stayed green:
hi11_seed1100_wiz_zap-deep_gameplayt22_s1250_w_digtrapmix_gp
Vault coverage reconnaissance: scan seed/dlevel pairs first, then route the winning vault (2026-03-15)
- The first “vault” route on
seed1200/dlvl12was not an ordinary vault-guard candidate at all. The calibrated probe reached a magic portal into Knox:You activated a magic portal!--More--
- Blind route tuning there was the wrong optimization target.
- To stop repeating that mistake, add a dedicated C-grounded scanner:
- Workflow:
- probe seed/dlevel pairs through
run_session.pywith#dumpsnap - extract the best structured or compact checkpoint
- list matching special rooms with:
- hero coordinate
- taxi distance
- center/bounds
- direct accessible path if one exists
- only then use shop_checkpoint_debug.js to design the actual coverage route
- probe seed/dlevel pairs through
- Early useful hits for ordinary vault work:
seed700/dlvl4- hero
(34,17) - vault center
(19,16) - taxi distance
16
- hero
seed1200/dlvl2- hero
(46,11) - vault center
(59,16) - taxi distance
18
- hero
seed1200/dlvl3- hero
(57,2) - vault center
(64,14) - taxi distance
19
- hero
- Takeaway:
- for vault, temple, shop, quest, and other branch-dense coverage sessions, first solve “which level is worth routing?” with a short scanner pass instead of hand-probing one candidate blindly.
hi17 ordinary vault route: vaults must stay typed, and guards must stay on the gd_move() path (2026-03-15)
- The first successful ordinary-vault candidate is:
seed700/dlvl4- route shape:
- wizard levelport to dlvl
4 - wish
wand of digging - route to the nearest clean dig square at
(28,16)viahhhyhh - dig west into the vault
- step onto the gold and pick it up
- wait long enough for the guard timer
- answer the guard prompt with
Wizard - comply with
d$
- wizard levelport to dlvl
- That route is now promoted:
- Useful C-visible milestones from the session:
Suddenly one of the Vault's guards enters!--More--"Hello stranger, who are you?""I don't know you."--More--"Most likely all your gold was stolen from this vault."--More--"Please drop that gold and follow me."d$cleanly drops the carried gold
- The two actual JS bugs were:
hack.js check_special_room()was demoting discoveredVAULTrooms toOROOMeven though C explicitly preserves vault room types forvault_occupied()monmove.js m_move()routed guards through ordinary monster AI instead of the Cgd_move()special-monster path
- The session also needed the real guard interrogation prompt:
vault.js invault()now uses the existinggetlin()flow for"Hello stranger, who are you?" -
- Result:
hi17is parity-green end to end- this gives coverage on ordinary vault summon, interrogation, accusation, and compliant gold-drop behavior without a harness workaround
hi18 polymorph-web route: #monster must dispatch the real C ability ladder (2026-03-15)
- A short new coverage session is now promoted:
- Route shape:
- start with the baseline human
#monsterno-ability branch - wish
blessed potion of polymorph - quaff it and choose
giant spider - drain the post-polymorph
--More--chain - use
#monsterto hitdospinweb()
- start with the baseline human
- The two concrete JS bugs exposed by this session were:
- startup only initialized
flags.female, whilepolyself.jsreads and mutatesplayer.female- this caused sex-dependent polymorph text drift such as
female giant spidervs Cmale giant spider
- this caused sex-dependent polymorph text drift such as
cmd.jsonly recognized the breath-weapon branch of Cdomonability()- C dispatches a ladder of already-ported abilities:
dospit,doremove,dogaze,dosummon,dohide,dospinweb,domindblast,dopoly, and others
- C dispatches a ladder of already-ported abilities:
- startup only initialized
dospinweb()also needed one C-faithful tile test:- use
loc.typ === STAIRS || loc.typ === LADDER, notloc.isStairs
- use
- Result:
hi18is fully parity-green- existing nearby guardrails stayed green:
seed503_extcmd_monsterseed031_manual_directseed329_rogue_wizard_gameplay
- Follow-up value:
- the same
#monsterdispatcher fix materially improves the viability of futuremind flayer,gaze,were, andvampireactive-ability coverage sessions
- the same
hi19 mind-flayer route: polymorph must redraw the hero glyph and sync form strength immediately (2026-03-15)
- A second short polymorph coverage session is now promoted:
- Route shape:
- baseline human
#monsterno-ability branch - wish
blessed potion of gain energyand quaff it - wish
blessed potion of polymorphand choosemind flayer - drain the polymorph
--More--page - use
#monsterto emit the psychic blast viadomindblast()
- baseline human
- The first blocker was screen-only, not gameplay:
- C already showed
hon theYou turn into a mind flayer!screen - JS still showed
@until the following step
- C already showed
- The C-faithful fix was not a replay/harness change:
- force an immediate hero-square
newsym()from the actual display layer whenpolymon()andpolyman()change form - this moves the glyph update onto the same message screen C already shows
- force an immediate hero-square
- That exposed one more real state bug:
- polymorph strength updates were only changing the legacy
acurr/amaxshadow objects - the status line reads
player.attributes[A_STR] - JS therefore kept stale human exceptional strength like
18/04while C already showed plain18for the mind flayer form
- polymorph strength updates were only changing the legacy
- Fix:
- synchronize
player.attributes[A_STR]when form strength changes or when human strength is restored
- synchronize
- Result:
hi19is fully parity-green- nearby polymorph and global gameplay guardrails stayed green
Monster multishot naming affects real page boundaries
t11_s755_w_covmax9_gpstill had a raw-step drift in the gnome lord crossbow volley even after the earliermonmulti()RNG fix.- C
mthrowu.cusesgm.m_shotin two places that JS was not modeling:- the intro message uses
shoots 3 crossbow boltsrather than a singularthrows a crossbow bolt - per-projectile hit text uses
mshot_xname()so the hero seesthe 1st/2nd/3rd crossbow bolt
- the intro message uses
- JS was emitting shorter generic text like
a crossbow boltfor each hit. That changed topline packing, letting two hit messages fit on one page and pulling later monster-bite pages one raw step earlier than C. - Fix in
js/mthrowu.js:- attach
_m_shot = { n, i, o, s }metadata to each projectile inmonshoot() - use
mshot_xname()for projectile naming inthitu()paths viathrownObjectName() - render the C-style multishot intro message with
shootsplus pluralized count when ammo+launcher are in use
- attach
- Validation:
t11_s755_w_covmax9_gp- improved to full RNG parity:
26517/26517 - events improved to
7012/7287 - remaining first divergence moved to screen/event state at step
1349
- improved to full RNG parity:
- guard sessions stayed green:
hi11_seed1100_wiz_zap-deep_gameplayt22_s1250_w_digtrapmix_gp
Filtered wizard identify menus must not inject synthetic gold
t11_s755_w_covmax9_gpnext diverged at step1349on the debug identify overlay after RNG parity was already fully green.- C showed the filtered unidentified-item menu beginning with
Comestibles. - JS began with
CoinsbecausebuildInventoryOverlayLinesFromItems()always injected the syntheticplayer.goldsection whenever noCOIN_CLASSitems were present, even for filtered submenus. - That behavior is correct for the full inventory view, but wrong for the wizard-identify filtered overlay.
- Fix in
js/invent.js:- add
includeSyntheticGoldoption tobuildInventoryOverlayLinesFromItems() - disable it for the wizard-identify filtered overlay path
- add
- Validation:
t11_s755_w_covmax9_gp- first screen divergence moved
1349 -> 1350 - screens
1678/1789 -> 1681/1789 - colors
42748/42936 -> 42827/42936 - RNG stayed fully green:
26517/26517
- first screen divergence moved
- guard sessions stayed green:
hi11_seed1100_wiz_zap-deep_gameplayt22_s1250_w_digtrapmix_gp
Wizard identify menu now matches C pick-any paging and selection ordering
t11_s746_w_covmax3_gpdiverged early inside the Ctrl+I (wizard identify) menu because JS treated it as a pick-one overlay without paging or menu commands, which broke./,selection behavior and shifted RNG soon after.- C uses
PICK_ANYfor wizard identify and skips the special_entry on select-all/invert. - Fix in
js/invent.js:- page overlay menus based on available rows even when the menu lacks an
explicit
(end)line - add paging and menu-command support to
renderOverlayMenuPickAny() - force full-screen rendering for paged pick-any menus to keep a stable
offx - return pick-any selections in menu order (matching C), not insertion order
- skip
_/Ctrl+I from select-all/invert to mirrorMENU_ITEMFLAGS_SKIPINVERT
- page overlay menus based on available rows even when the menu lacks an
explicit
- Validation:
t11_s746_w_covmax3_gpfirst divergence movedstep 405 -> 652
Text popup teardown must not rerender full map during detect browse_map
t06_s621_w_qstat_gpandt06_s622_w_qabil_gpdiverged on the object-detect browse_map screen: JS restored the full terrain after dismissing the getpos tip window, while C keeps the detection-only overlay.- Root cause:
destroy_nhwindow()always invoked the rerender callback forNHW_TEXT/NHW_MENU, which re-rendered the live map afterclearTextPopup()already restored the overlay. - Fix:
- skip
_rerenderCallback()whenw._popupRenderedis true (popup already restored), preserving detect overlays and matching C. - keep detect clear/draw path explicit by calling
cls()with display context and routing detectshow_glyphthrough display’sshow_glyph.
- skip
- Validation:
t06_s621_w_qstat_gpnow passes.t06_s622_w_qabil_gpnow passes.
2026-03-15 boundary drift from command finalization and detect overlays
- A step-725-style divergence in
pnd_s1200_w_potionmix2_gpwas traced to a mixed prompt/vision timing issue, not RNG drift. The key was restoring the C-style single-threaded command lifecycle while keeping boundary effects narrow:run_command()andfinalizeTimedCommand()now gate monster/object/trap vision refresh with movement context (ctx.mv), so normal movement-mode steps do not eagerly re-evaluate visibility.- swallowed redraw is only called in the same
Hallucinationbranch that owns it. - detect browse/getpos overlays keep their terrain mode during prompt interaction and are restored cleanly after dismissal.
- Secondary boundary fix:
- map/object hallucination checks now respect both
Hallucinationandhallucinating. - message
more()restores status encumbrance from snapshot without forcing a full_botlrecompute.
- map/object hallucination checks now respect both
- Validation:
pnd_s1200_w_potionmix2_gpis now passing all channels insession_test_runner,hi19_seed503_w_mindblast_gpremains green.
t11_s755 green: #sit --More-- ownership plus faithful monster auto-wear
t11_s755_w_covmax9_gphad reached full RNG/event parity and full mapdump parity, but still failed on a step-1787/1788 screen-only redistribution: JS advanced fromHaving fun sitting on the floor?--More--into later monster-turn output one consumed key too early.- First root cause:
#sitwas finalizing the timed turn before the visible--More--page was actually dismissed.- Fix in
js/cmd.jsandjs/allmain.js:- mark
#sitwithdeferTimedTurnUntilMore - defer end-of-turn finalization until the owned
--More--dismiss key runs
- mark
- This moved the first mismatch later from step
1787to1788.
- Second root cause:
- JS monster
I_SPECIALauto-wear path injs/mon.jswas a simplified local equip branch rather than the Cm_dowear()path. js/worn.jsalso lacked the visible C wear semantics:- monster
puts on ...message - wear delay via
mfrozen/mcanmove distant_name(..., doname)article-correct armor naming
- monster
- Fix:
- route live monster gear turns through real
m_dowear(mon, false) - make runtime wear visible and time-consuming like C
- keep creation-time auto-equip synchronous so
makemon()stays single-threaded and faithful
- route live monster gear turns through real
- JS monster
- Validation:
node test/comparison/session_test_runner.js --verbose test/comparison/sessions/coverage/covmax-round7/t11_s755_w_covmax9_gp.session.json- PASS
- guardrails:
seed329_rogue_wizard_gameplayPASShi15_seed42_barb_minetn5_shop-pay_gpPASS
- Takeaway:
- the remaining
t11_s755drift was not a replay-core queueing problem - it was a real pair of C-faithfulness bugs:
- command/message boundary ownership for
#sit - monster auto-wear behavior
2026-03-18: Mixed-class container loot menu must follow container-chain order
- command/message boundary ownership for
- the remaining
- Symptom:
seed031_manual_directfirst screen divergence on the firstTake out what?page showed JS rendering a flat menu under a hardcodedComestiblesheader while C rendered grouped class headers (Spellbooks, thenPotions).
- Cause:
js/pickup.jscustomcontainerMenu()/doTakeOut()path bypassed the C-stylemenu_loot()behavior and painted a simplified flat list.- It also used raw JS array order for visible container contents instead of C container-chain order.
- It named items before
observeObject(), so unidentified-but-seen items were less specific than C.
- Fix:
- render grouped loot rows by contiguous object class
- iterate visible loot items via
cContainerOrder(cur) - call
observeObject(item)before menu naming
- Evidence:
node test/comparison/session_test_runner.js --verbose test/comparison/sessions/seed031_manual_direct.session.json- improved from:
- RNG divergence at step
147 - event divergence at step
147
- RNG divergence at step
- to:
- RNG divergence at step
175 - event divergence at step
175
- RNG divergence at step
- Non-conclusion:
- this did not materially affect
seed032_manual_directorseed033_manual_direct, so the manual-direct cluster still needs separate monster-turn investigation after the early loot UI seam.
- this did not materially affect
2026-03-18: m prefix on wait/search must be consumed by that one command
- Symptom:
- after the mixed-class loot fix,
seed031_manual_directstill diverged at step175in a wait/no-op seam with nearby hostile monsters. - C and JS disagreed about when a forced
m.consumed time, which then shifted later pet movement anddistfleeck()ordering.
- after the mixed-class loot fix,
- Cause:
- JS modeled the
mprefix as persistentcontext.nopickstate and left it live after./s. - In C,
iflags.menu_requestedis a one-command prefix.donull()anddosearch0()inspect it for that command, but parse dispatch consumes it rather than letting it leak into the next command.
- JS modeled the
- Fix:
- in
js/cmd.js, snapshotm-prefix state when dispatching./s, then clear it before executing the command. - in
js/hack.js, letperformWaitSearch()accept that one-commandmenuRequestedsnapshot instead of reading livecontext.nopick. - added
test/unit/wait_search_safety.test.jsregression:m.forces exactly one wait- the following plain
.warns again
- in
- Evidence:
node --test test/unit/wait_search_safety.test.js- PASS
node test/comparison/session_test_runner.js --verbose test/comparison/sessions/seed031_manual_direct.session.json- improved from:
- first RNG divergence at step
175 - first event divergence at step
175
- first RNG divergence at step
- to:
- first RNG divergence at step
375 - first event divergence at step
373
- first RNG divergence at step
- improved from:
- Non-conclusion:
- this did not materially move
seed032_manual_directorseed033_manual_direct, so the remaining manual-direct cluster is not one sharedm-prefix bug.
- this did not materially move
2026-03-18: hero-kill XP must use experience() before newexplevel()
- Symptom:
- after the
m.wait fix,seed031_manual_directnext diverged at the kill / level-up seam:- JS resumed monster-turn work after the kill
- C immediately rolled
rnd(8)=8 @ newhp(attrib.c:1098)for level gain
- after the
- Cause:
js/uhitm.jshandleMonsterKilled()still used a simplified XP formula:((m_lev + 1) * (m_lev + 1))
- C
xkilled()awards XP through:experience(mon, nk)more_experienced(tmp, 0)newexplevel()
- that under-awarded some kills in JS and skipped immediate level gain.
- Fix:
- switch
handleMonsterKilled()to the faithfulexperience()/more_experienced()path beforenewexplevel() - keep
player.expmirrored fromplayer.uexpafter the faithful award - added unit regression:
test/unit/uhitm_kill_xp_parity.test.js- a killer-bee kill now levels a level-1 hero immediately, which the old simplified formula would not do
- switch
- Evidence:
node --test test/unit/uhitm_kill_xp_parity.test.js- PASS
node test/comparison/session_test_runner.js --verbose test/comparison/sessions/seed031_manual_direct.session.json- improved from:
- first RNG divergence at step
375 - first event divergence at step
373
- first RNG divergence at step
- to:
- first RNG divergence at step
407 - first event divergence at step
406
- first RNG divergence at step
- improved from:
- Non-conclusion:
- this did not materially move
seed032_manual_direct - the next
seed031seam is later pet object-choice /dog_goal_objbehavior, not another level-up bug.
- this did not materially move
2026-03-18: remove deferred #sit continuation; keep --More-- inline
- Symptom:
- JS still had a continuation-style path in
js/allmain.js:result.deferTimedTurnUntilMorependingPrompt.type = "timed_turn_more_ack"
- That kept
#sitgreen, but it violated the intended execution model: one active input owner at a time, with no synthetic continuation tokens.
- JS still had a continuation-style path in
- Fix:
- remove the generic continuation path from
js/allmain.js - make
#sitown its--More--acknowledgement synchronously injs/cmd.jsbefore timed-turn finalization resumes
- remove the generic continuation path from
- Evidence:
t01_s005_v_sit1_gp.session.jsonPASSt01_s650_w_sit_gp.session.jsonPASSt01_s651_w_sit2_gp.session.jsonPASSt11_s755_w_covmax9_gp.session.jsonPASS
- Non-conclusion:
- this cleanup did not move
seed031_manual_direct - the remaining
seed031drift is still the later command-vs-monster seam, not the old#sitcontinuation path2026-03-19 - Movement propagation replay tool
- this cleanup did not move
- Added
scripts/movement-propagation.mjsplusdocs/MOVEMENT_PROPAGATION_TOOL.md. - Purpose:
- inspect movement-ownership propagation over a gameplay-step window without changing core code
- especially useful for
run/rush,ctx.run,ctx.mv,multi, storeddx/dy, andlookaround()stop/turn decisions
- It replays JS with:
WEBHACK_EVENT_RUNSTEP=1WEBHACK_RUN_TRACE=1
- Then it groups replay output back into gameplay steps and prints:
- movement-related C step entries from the fixture
- movement-related JS step entries from replay
- JS
[RUN_TRACE]lines for the same step window
- Validation:
seed032steps89..91clearly show the live mismatch:- step
91is still an inline JS multi-stepshiftRunbundle - C step
91remains the later pet-distance /dog_invent_decisionseam
- step
seed031steps404..407show no analogous run-trace activity, helping separate that throw/pet seam from the run-ownership investigation
2026-03-19 - seed032 run-corner fix: reset last_str_turn for ordinary run/rush
- Symptom:
seed032_manual_directfirst diverged at step91inside an uppercaseKrun bundle.- JS terminated the run after reaching
(51,10), then failed early on the next attempted continuation. - C kept the same command alive and continued later movement / monster-turn work in the same gameplay step.
- Cause:
- C
cmd.c rhack()resetsu.last_str_turn = 0on first entry toDOMOVE_RUSH. - JS only reset
last_str_turnfor travel, not for ordinary run/rush. - That left stale turn-memory available to bias the run-corner continuation
logic in
lookaround().
- C
- Fix:
js/hack.jsdo_run()now resetsplayer.last_str_turn = 0before ordinary run/rush processing begins
- Evidence:
node test/comparison/session_test_runner.js --verbose test/comparison/sessions/seed032_manual_direct.session.json- before:
- first RNG divergence at step
91 - first event divergence at step
91
- first RNG divergence at step
- after:
- RNG fully matched (
29881/29881) - first event divergence moved to step
150
- RNG fully matched (
- before:
- stability:
seed031_manual_directunchangedseed033_manual_directunchangedseed301_archeologist_selfplay200_gameplayunchangedt11_s755_w_covmax9_gpstill passes
- Conclusion:
- the step-91
seed032failure was a genuine run-state initialization bug, not a pet-AI-local bug and not a replay-boundary artifact
- the step-91
2026-03-19 - restoring branch/fill-level generation flushed out latent monster-inventory placeholders
- Making
mklev()branch-aware again exposed a whole class of dormant bugs: several monster subsystems still assumedmon.minventwas always a JS array. - On the more faithful path, monster inventory can be a C-style
nobjchain, and the old assumptions caused hard crashes in:muse.jsweapon.jsmonmove.jsworn.js- related monster-data / load helpers
- Fix pattern:
- add shared chain-safe helpers in
invent.jsobjChainItems(head)removeObjFromChain(head, target)
- move affected code to those helpers rather than reintroducing array-only assumptions
- add shared chain-safe helpers in
- Practical lesson:
- when a parity fix revives a dormant C-faithful path, expect linked-list object chains to surface in monster code
- do not patch just the first crashing loop; search adjacent
.some(),.find(),.reduce(),for ... of, and splice/indexOf assumptions onminvent/cobj
2026-03-19 - stair traversal must not acknowledge --More-- before goto_level
- JS had a helper in
do.jswhich explicitly consumed a pending stair message boundary before callingchangeLevel(). - That was unfaithful.
- C owns the level transition on the
>/<command itself; the visible staircase message does not hand control to a separate acknowledgement owner before level generation proceeds. - Evidence from
seed031_manual_direct:- before the fix, JS shifted the
^place[...]burst from the>step onto the following space key - after removing the extra stair ack,
>owns the level-generation burst on both sides
- before the fix, JS shifted the
- Practical rule:
- do not introduce command-specific pre-
goto_levelmore()acknowledgements for stairs/ladders - if a staircase message overflows, the same single-threaded command still owns the transition work
- do not introduce command-specific pre-
2026-03-19: Is_stronghold() must not consume RNG via special-level variant selection
seed031_manual_directexposed a real RNG leak during integrated minefill generation.- The leak was not stale level context. Trace showed
makemon()had the correct generation context while creating the failing gnomes:inMklev=truemapGen=1:3levelRef=1:3
- The real bug was in
js/dungeon.js:Is_stronghold()calledgetSpecialLevel(...)getSpecialLevel(...)can consume RNG when the target special level has variants- branch predicates must not do that
- Fix:
- add
getSpecialLevelMeta(dnum, dlevel)injs/special_levels.js - switch
Is_stronghold()to metadata-only lookup
- add
- This moved
seed031_manual_directforward:- RNG matched
21416 -> 21582 - events matched
10130 -> 10131
- RNG matched
- Guardrails stayed green:
t04_s705_w_minefill_gphi15_seed42_barb_minetn5_shop-pay_gp
2026-03-19: depth() must use dungeon depth_start, not ledger numbering
- JS had collapsed two different C concepts:
depth()= gameplay depth below the surfaceledger_no()= global level/savefile numbering
- In
js/dungeon.js,depth()was incorrectly using ledger starts. - After
init_dungeons(), that produced absurd values such as:- Mines 1 ->
50 - Mines 3 ->
52 - Sokoban 1 ->
63
- Mines 1 ->
- That leaked directly into gameplay logic:
adj_lev()newmonhp()mkclass()difficulty gating- branch-sensitive generation code
seed031_manual_directexposed it clearly:- scripted minefill gnomes were being created with inflated level context
- diagnostic
WEBHACK_MAKEMON_TRACE=1showed:mndx=166 "gnome lord" base=3 depth=50 adj=4
- Fix:
- keep ledger bookkeeping in
_dungeonLedgerStartByDnum - add
_dungeonDepthStartByDnum - compute child
depth_startfrom branch topology using the C formula - make
depth()usedepth_start + dlevel - 1 - restore
ledger_no()to use ledger numbering instead ofdepth()
- keep ledger bookkeeping in
- After the fix, the same sanity check became:
- Mines 1 ->
4 - Mines 3 ->
6 - Sokoban 1 ->
2
- Mines 1 ->
2026-03-19: special-level random alignment can still depend on live dungeon context
- In the live
seed031minefill seam, JS still had old-map ownership while special-level generation was running:mapGen=0:2liveUz=none
- For
sp_amask_to_amask('random'), forcingfinalizeContext.dnumcaused JS to take the 80%-coalignedrn2(100)gate where C reached the plainrn2(3)fallback. - Practical rule:
- for this special-level generation seam, alignment lookup must prefer the live current dungeon context when it exists, then fall back to the finalize context
- Combined with the
depth_startfix, this movedseed031_manual_directsubstantially later:- RNG matched
21582 -> 21809 - events matched
10131 -> 10152 - the old
sp_amask_to_amask()first divergence is gone
- RNG matched
- New frontier after this batch:
- later
changeLevel()/mon_arrive()ownership - first RNG divergence now in
dog.js mon_arrive(...)load_special_by_protofile()must preserve actual generation coordinates
- later
seed031_manual_directexposed a real integrated Mines descent bug: branch-entryminefillgeneration was selected correctly for live(dnum=1,dlevel=1), butload_special_by_protofile('minefill', ...)was leaking the canonical registry coordinates fromfindSpecialLevelByProto()into finalize/mineralize state.- Symptom before the fix:
- live trace showed
^lvlselect[depth=3 dnum=1 dlevel=1 ... lvlfill=minefill] - but later fixup logged
^wizfixup[name=minefill dnum=1 dlevel=3 ...] - then
mineralize()classified the same generated map as a town-bearing special via runtime metadata and skipped the minefill post-topology pass
- live trace showed
- Fix in
js/dungeon.js:- keep proto lookup only for choosing the generator
- preserve the actual requested
dnum/dlevel/depthfor:withFinalizeContext(...)withSpecialLevelDepth(...)specialMap._genDnum/_genDlevel- tutorial/branch flags
- Validation:
seed031_manual_directimproved:- matched RNG
21809 -> 22472 - matched events
10152 -> 10188 - bogus
minefill dlevel=3fixup symptom disappeared
- matched RNG
- guardrails remained green:
t04_s705_w_minefill_gpt04_s706_w_minetn1_gp
mineralize() gem count uses branch-local dunlev, not absolute depth
seed031_manual_directexposed a remaining branch-entry Mines filler bug after the proto-coordinate leak was fixed.- JS
mineralize()was using absolute depth for gem-count quantity:rnd(2 + depth / 3)
- C uses branch-local level at
mklev.c:1529:rnd(2 + dunlev(&u.uz) / 3)
- On the live Mines entry filler level in
seed031:- absolute depth was
5 - branch-local
dlevelwas1 - JS called
rnd(3)where C calledrnd(2)
- absolute depth was
- Fix in
js/dungeon.js:- keep absolute depth for
goldprob/gemprob - use
map._genDlevelfor the gem-countrnd(...)quantity
- keep absolute depth for
- Validation:
seed031_manual_directimproved again:- matched RNG
22472 -> 23197 - matched events
10188 -> 10800 - old step-467 minefill-finalization seam disappeared
- matched RNG
- guardrails stayed green:
t04_s705_w_minefill_gpt04_s706_w_minetn1_gp
Use focused mfndpos / MONMOVE_TRACE instrumentation for ordinary monster seams
- For manual-direct monster-turn drift, raw offset comparison is not reliable once input ownership has separated.
- The durable workflow is:
- anchor on the authoritative gameplay step from
session_test_runner - inspect the same input-owned bundle with
scripts/movement-propagation.mjs - for ordinary monster movement seams, enable:
--monmove-trace--mon-id <id>--mndx <pm>
- anchor on the authoritative gameplay step from
- Tooling now enables:
WEBHACK_MONMOVE_TRACE=1WEBHACK_MONMOVE_PHASE3_TRACE=1WEBHACK_MFNDPOS_TRACE=1
- JS now emits ordinary-monster
^mfndpos[...]detail events parallel to the existing pet-sidedog_movevisibility. This is the highest-signal way to compare candidate sets before patchingdochug()/m_move()/mfndpos(). flash_hits_mon()/ camera parity:- if a session shows camera-use RNG before monster turns, check
js/uhitm.jsbefore blaming downstreamdochug()drift - the old JS placeholder consumed non-C RNG (
rnd(50), extrarn2(4),rnd(100)) and missed the C distance-based blindness/flee logic - the faithful path is:
rn2(4)flee gate whentmp < 9 && !isshk- optional
rn2(4) ? rnd(100) : 0flee duration mblinded = (tmp < 3) ? 0 : rnd(1 + 50 / tmp)
- this can improve a local command bundle even when the overall first divergence simply moves to a newly exposed upstream/downstream seam
- if a session shows camera-use RNG before monster turns, check
Runtime special-level identity matters for align_shift() on protofile branch fillers
seed031_manual_directexposed a subtle runtime special-level gap onminefill.- JS was generating the level from a protofile, but runtime alignment-sensitive
code still treated the live level as an ordinary branch level because the
actual
(dnum,dlevel)was not registered in the runtime special-level map. - C
makemon.c align_shift()uses:- special-level align when
Is_special(&u.uz)is true - otherwise dungeon branch align
- special-level align when
- For
minefill, that means C uses special-levelAM_NONE, not the Mines’ lawful branch align. - Durable fix:
- register protofile-loaded special levels in the runtime special-level map at their actual live coordinates
- resolve runtime alignment from the current level (
level_align(...)), not a cached_dungeonAlignvalue from generation time
- Validation:
seed031_manual_directimproved RNG prefix23265 -> 23269- events stayed at the same frontier (
10840) t04_s705_w_minefill_gpandt04_s706_w_minetn1_gpstayed green
- Important non-fix:
- a companion
mkobj.jsdepth-source patch (depth(&u.uz)instead of player cache) looked promising but caused an earlier event regression in the pet stream - keep that separate until the event regression is explained
- a companion
Thrown-hit parity: hmon() must own both damage dispatch and death resolution
seed031_manual_directexposed a real regression after upstreamdothrowcleanup:- JS first diverged by taking
rnd(2)insidehmon_hitmon_weapon_ranged() - C was taking
dmgval()(rnd(3)in the live trace) for the same thrown hit
- JS first diverged by taking
- The cause was a simplified JS predicate in
hmon_hitmon_weapon():- it treated all ammo/missiles as “ranged-melee damage”
- it ignored the C distinction for
HMON_THROWN
- C rule from
uhitm.c hmon_hitmon_weapon():- ranged branch for:
- launchers,
- hand-struck ammo/missiles,
- polearms at short range while unmounted,
- ammo thrown without the proper launcher
- melee branch for:
- thrown missiles,
- thrown ammo with the matching launcher
- ranged branch for:
- A second missing C behavior was in JS
hmon()itself:- JS marked
hmd.destroyedbut returned without calling the death owner - C performs death resolution before returning:
xkilled(mon, XKILL_NOMSG)for poison killskilled(mon)for ordinary hero kills
- JS marked
- Durable fix:
- make
hmon_hitmon_weapon()branch on the C thrown predicate - make
hmon()ownkilled()/xkilled()before returning
- make
- Validation:
seed031_manual_direct- improved from the thrown-hit regression:
- RNG matched
23211 -> 23269 - events matched
10811 -> 10840
- RNG matched
- this restored the previous live frontiers:
- pet
dog_move_choiceevent seam - later
rndmonnum_adj()corpse-placeholder RNG seam
- pet
- improved from the thrown-hit regression:
- guardrails stayed green:
t04_s705_w_minefill_gpt04_s706_w_minetn1_gpt08_s984_w_camera_gp
- Regression test:
test/unit/uhitm_weapon_dispatch.test.js- thrown ammo with a matching launcher must take the melee damage branch
- 2026-03-19:
js/mkobj.jsruntimerndmonnum()/ corpse placeholder difficulty must be sourced from level coordinates (u.uzequivalent), notplayer.dungeonLevel. In JS,changeLevel()stores branch-local depth inplayer.dungeonLevel, so runtime_getLevelDepth()should prefermap._genDnum/_genDlevel, thenmap.uz, thenplayer.uz, and pass that throughlevel_difficulty(...). This improvedseed031and held guardrails (seed321,seed328,t11_s744, Mines sessions, camera session). - 2026-03-19: Do not add a blanket special-level alignment override to
align_shift()as a fix for minefill/protofile sessions. Exact Cinit_level()/induced_align()behavior did not support that theory, and it regressed multiple sessions. - 2026-03-19: Tourist level-entry XP in
goto_level()/changeLevel()must follow exact C ownership:- C:
more_experienced(level_difficulty(), 0); newexplevel(); - JS old bug: direct
player.uexp += dungeonDepth(...)plus manual threshold handling - faithful fix: route through
more_experienced(level_difficulty(null, game), 0, game, player)andnewexplevel(...) - validated on tourist sessions:
seed331_tourist_wizard_gameplaystayed full RNG/event greentheme34_seed2238_tou_explore_gameplaystayed full green
seed031_manual_directstayed unchanged, so do not treat this as the live step-479/488 fix; it is a separate exact-C correction
- C:
- 2026-03-19: Event-step ownership must be mapped from the comparable event
stream, not from raw
^...counts.compareEvents()filters ignorable events before reporting its divergence index.- Raw step mapping that counts every
^...event can misreport the first bad step even when the normalized event index is right. - Durable fix:
test/comparison/comparator_policy.jsnow maps event divergence steps throughgetComparableEventStreams(...)test/comparison/comparison_artifacts.jsnow preservesrawIndexMapandstepEndsfor comparable-event artifacts
- Regression test:
- Practical effect:
seed031_manual_direct’s stale event-step479report was a tooling artifact; the authoritative first event and first RNG divergences are both at step488.
- 2026-03-19:
seed031_manual_directstep488is currently an XP/level-state seam upstream of the visible pet-choice drift.- Exact live JS owner trace at step
488:[HMON_TRACE] xp-before ... ulevel=2 uexp=27 exp=27 expGain=7[EXP_TRACE] more_experienced ... delta=7 uexp=27->34[EXP_TRACE] newexplevel-check ... threshold=40
- Exact C raw trace at the same step:
rnd(8)=8 @ newhp(attrib.c:1098)rnd(2)=2 @ newhp(attrib.c:1100)rn2(4)=2 @ newpw(exper.c:64)- then pet
distfleeck()
- Conclusion:
- the visible
dog_move_choice/distfleeck bravemismatch is downstream - C has already leveled the hero before the pet turn
- JS has not
- the visible
- Current JS XP ledger before step
488:- kill awards via
handleMonsterKilled()at steps75, 83, 112, 193, 294, 352, 375, 403 - tourist
changeLevel()awards at moves252and420 - thrown gnome kill via
xkilled()at step488
- kill awards via
- Do not patch pet AI from this symptom; first explain the missing pre-pet XP/level state.
- Exact live JS owner trace at step
- 2026-03-19: camera/photo parity for Tourist XP must follow exact C
apply.c -> do_blinding_ray() -> see_monster_closeup(mtmp, TRUE)ownership.- Exact C data from new harness
^exp[...]traces on a temporary rerecord ofseed031_manual_direct:owner=photo move=433 mndx=165 level=2 exp=27 gain=7owner=xkilled move=434 mndx=165 level=2 exp=34 gain=7newlevel-pluslvl move=434 ...
- JS bug:
- camera flash only called
flash_hits_mon(...) - it never ran the photo-side
see_monster_closeup(..., true)path - so JS missed the pre-kill Tourist XP and entered the pet turn one level behind C
- camera flash only called
- Faithful fix:
- add
mvitals[mndx].photographed - port
see_monster_closeup(mtmp, photo)into JS - call it from camera flash when
obj.otyp == EXPENSIVE_CAMERA - award XP with
experience(mtmp, 0),more_experienced(...),newexplevel(...)
- add
- Validation:
seed031_manual_direct- old step-488 pet seam removed
- new first RNG divergence: step
492 - new first event divergence: step
493
t08_s984_w_camera_gp: PASSseed331_tourist_wizard_gameplay: PASS
- Supporting harness/tooling:
- add C owner-local XP trace patch:
test/comparison/c-harness/patches/025-exp-trace.patch - forward
NETHACK_EXP_TRACEthrough both:test/comparison/c-harness/run_session.pytest/comparison/c-harness/keylog_to_session.py
- preserve owner-family prefixes in
movement-propagationoutput; do not strip[EXP_TRACE]
- add C owner-local XP trace patch:
- Exact C data from new harness
- 2026-03-19:
seed031_manual_directstep492/495meal seam was partly a resumed-corpse state mismatch in JShandleEat().- Exact C rule from
eat.c:touchfood(otmp)initializes/retainsoeatenvictual.reqtime = rounddiv(reqtime * otmp->oeaten, basenutrit)victual.nmodis derived from remainingoeaten, not full nutritionbite()updates partial consumption viaconsume_oeaten(...)
- JS bug:
handleEat()was recomputing corpsereqtimefrom the full corpse each time and derivingnmodfrom full nutrition- it also bypassed the existing
bite()partial-consumption path
- Faithful fix:
- call
touchfood()before corpse timing math - scale resumed corpse
reqtimewithrounddiv(...) - derive
nmodfrom remainingoeaten - keep
victual.usedtime/reqtimeauthoritative and route bite-progress throughbite()/consume_oeaten(...)
- call
- Validation:
seed031_manual_direct- RNG matched
23469 -> 23723 - events matched
10988 -> 11130 - first RNG divergence moved
step 492 -> 495 - first event divergence moved
step 493 -> 498
- RNG matched
theme33_seed2102_wiz_eat-various_gameplay: PASScommand_eat_occupation_timing.test.js: PASS
- Exact C rule from
- 2026-03-19: apply-direction tools must run the exact C
getdir()impairment path before dispatch.- Exact C behavior:
apply.c use_camera()callsgetdir()cmd.c getdir()callsconfdir(FALSE)for non-vertical directionsconfdir(FALSE)consumesu_maybe_impaired()(Confusion && !rn2(5))
- JS bug:
js/apply.jsmanually read a direction key for camera/mirror/other apply-direction tools and dispatched directly- it never ran the
getdir()confusion gate, so confused direction-taking tool use skipped a real C RNG/state path
- Faithful fix:
- after a valid non-vertical direction is chosen in the shared apply tool
direction path, set
player.dx/dy/dzthen callconfdir(false, player)before dispatch
- after a valid non-vertical direction is chosen in the shared apply tool
direction path, set
- Validation:
seed031_manual_direct- RNG matched
23723 -> 24414 - events matched
11130 -> 11532 - first RNG divergence moved
step 495 -> 525 - first event divergence moved
step 498 -> 526
- RNG matched
t08_s984_w_camera_gp: PASStheme33_seed2102_wiz_eat-various_gameplay: PASS
- Exact C behavior:
- 2026-03-19: do not inject name-based special-level alignment into
generation-time
align_shift()context.- Exact C evidence:
- Oracle depth-5 map sessions were diverging in
rndmonst_adj(): JSrn2(7)vs Crn2(2) - owner traces showed JS was applying positive
ashifton Oracle generation, while the C fixture’s first weighted roll proved no such override was active there
- Oracle depth-5 map sessions were diverging in
- JS bug:
js/dungeon.jsforced_dungeonAlignby special name during special-level generation (oracle,medusa,tut-*)- that heuristic widened the Oracle monster pool during map generation
- Faithful fix:
- remove the name-based special-level alignment override from
makelevel() - keep generation-time alignment on the normal C-style dungeon/default path unless an explicit override is provided by the caller
- remove the name-based special-level alignment override from
- Validation:
seed16_map.session.json- grids improved
4/5 -> 5/5 - RNG improved
10714/13212 -> 12836/12908 rndmonst_adj()rn2(7)Oracle seam eliminated
- grids improved
seed16_maps_c.session.json- grids improved
4/5 -> 5/5 - RNG improved
10714/13212 -> 12836/12908
- grids improved
- Later exact fix:
- Oracle special levels were entering
sp_lev.js finalize_level()with a working map that lacked_genDnum/_genDlevelandflags.is_oracle_level - that made JS
mineralize()treat Oracle as an ordinary special level instead of the C Oracle exception path - faithful fix: restore missing
_genDnum/_genDlevelfromfinalizeContextand setflags.is_oracle_levelfromfinalizeContext.specialNamebeforebound_digging()/mineralize()
- Oracle special levels were entering
- Validation:
seed16_map.session.json- RNG improved
12836/12908 -> 12908/12908 - PASS
- RNG improved
seed16_maps_c.session.json- RNG improved
12836/12908 -> 12908/12908 - PASS
- RNG improved
- Exact C evidence:
- 2026-03-20: tutorial direct-start must not override lawful special-level
alignment with
A_NONE.- Exact root cause:
seed033_manual_directstill first-diverged during tutorial special-level generation even after the branch caught up with latermainparity fixes.- trace showed
makelevel()was seeing tutorial runtime metadata withmetaAlign=1, butjs/chargen.jsstill called:mklev(1, TUTORIAL, 1, { dungeonAlignOverride: A_NONE })
- that stale override clobbered
_gstate._dungeonAlignduring tutorial generation, somkobj()corpse init still used the wrongrndmonnum_adj()monster pool.
- Faithful fix:
- add tutorial lawful alignment metadata to
js/dungeon.jsRUNTIME_SPECIAL_LEVEL_CANON - remove the forced tutorial
A_NONEoverride fromenterTutorial()
- add tutorial lawful alignment metadata to
- Validation:
seed033_manual_direct- matched RNG improved
2496 -> 4369 - matched events improved
183 -> 1460 - matched screens improved
36 -> 269 - first RNG divergence moved from step
1to step337 - first event divergence moved from step
2to step373
- matched RNG improved
- unchanged:
seed031_manual_directseed032_manual_direct
- still passing:
theme15_seed986_wiz_artifact-wish_gameplaytheme35_seed2320_wiz_artifact-combat2_gameplayseed1_special_tutorial.session.json2026-03-20: mklev alignment authority split for changeLevel-driven generation
- Exact root cause:
- Evidence:
seed16_map/seed16_maps_cregressed when runtime special-level alignment metadata was fed directly intomakelevel()monster weighting.seed321_archeologist_wizard_gameplayneeded Medusa runtime alignment to reachalign_shift()during deferred wizard levelport generation.- Broadly mutating live
player.uzbefore everymklev()fixed those, but regressedseed031_manual_directbecausesp_levinduced_align()then started seeing destination-branch alignment too early.
- C anchor:
makemon.c:1609-1619align_shift()refreshes cached special-level state on move changes and keys from liveu.uz.teleport.c level_tele()schedulesgoto_level()for end-of-turndeferred_goto(), so deferred level changes naturally refresh at the next move context.sp_lev.c sp_amask_to_amask()/dungeon.c induced_align()still read the ordinary current-level authority path.
- JS rule:
- do not pre-mutate live
player.uzglobally beforemklev() - instead, for
changeLevel()generation only, pass a mklev-only target level reference toalign_shift()via_mklevAlignLevelRef - keep direct startup/tutorial
mklev()andsp_levrandom alignment on the existing current-level path unless separately proven otherwise
- do not pre-mutate live
- Result:
seed16_map: PASSseed16_maps_c: PASSseed321_archeologist_wizard_gameplay: PASSseed031_manual_directreturns to its prior525/526frontier instead of regressing to the bad step-467 special-level seam.2026-03-20: Floor objects must not retain wornmask bits
- Symptom:
seed031_manual_directdiverged at petdog_goal_startwithminvent=0in JS versusminvent=1in C immediately after the pet picked up a dart stack. - Root cause: the pet split a floor dart stack in
dog_invent(), cloning the picked item from the floor object. JS floor objects could still carry staleowornmaskbits (for exampleW_QUIVER), so the cloned inventory object was treated as worn anddroppables(mon)returned null. - Fix: clear
obj.owornmaskin js/mkobj.jsplace_object(). This matches the C invariant that floor objects are on the floor chain, not worn inventory, and closes later split/merge contamination. - Validation:
seed031_manual_directimproved from first RNG/event divergence525/526to562/565seed16_map,seed16_maps_c,seed321,seed328, andt11_s744remained green2026-03-20: PICK_ANY menus must honor tty select-all aliases on replayed keys
- Symptom:
seed031_manual_directstill had a cross-step ownership drift at the gem pickup menu. JS opened thePick up what?menu correctly, but on the confirm step it returned no picks and therefore ran no same-step monster turn. - Root cause: generic
PICK_ANYjs/windows.js only treated.as “select all”. The recorded tty session used@as the select-all key, so JS left the menu empty and deferred gameplay work by one replay step. - C anchor:
win/tty/wintty.c process_menu_window()maps menu input throughmap_menu_cmd()before handlingMENU_SELECT_ALL.- Recorded tty sessions in this repo rely on that menu-command alias path for
PICK_ANYloot/pickup flows.
- Fix:
- normalize
PICK_ANYmenu keys throughmap_menu_cmd()in js/windows.js - preserve the repo’s current
@select-all alias until full menu-alias option restore is wired through startup/config plumbing - add a unit test in
test/unit/windows_nhwindow.test.js
covering
@select-all
- normalize
- Validation:
seed031_manual_directimproved from first RNG/event divergence562/565to572/572theme15_seed986_wiz_artifact-wish_gameplay: PASStheme35_seed2320_wiz_artifact-combat2_gameplay: PASSseed032_manual_direct: unchangedseed033_manual_direct: unchangednode --test test/unit/windows_nhwindow.test.js: PASS
2026-03-20: Pickup menu ordering must use cxname_singular() like C sortloot()
- Evidence: after the
PICK_ANYalias fix,seed031_manual_directstill diverged at step580. JS pet pickup chose floor object23(crossbow bolt) while C chose88(crossbow). Object traces showed the real cause was earlier: JS removed the crossbow from floor square62,14during the hero’s pickup menu confirm step, so it was no longer available for the later pet turn. - Root cause: js/pickup.js
was sorting multi-object pickup menus by
doname(obj), so quantity/article prefixes ("2 darts","4 crossbow bolts","a crossbow") changed menu letter assignment. The recordedcselection therefore hit the wrong item. - C anchor:
invent.c sortloot_cmp()sorts loot menus byloot_xname(obj).invent.c loot_xname()strips ordering-irrelevant prefixes, then reduces tocxname_singular(obj).
- Fix: keep the existing class grouping by
inv_order, but switch the within-class sort key fromdoname(obj)tocxname_singular(obj), with the existing stable fallback by object id. - Validation:
seed031_manual_directimproved from first RNG/event divergence572/572to580/580theme15_seed986_wiz_artifact-wish_gameplay: PASStheme35_seed2320_wiz_artifact-combat2_gameplay: PASSseed032_manual_direct: unchangedseed033_manual_direct: unchangednode --test test/unit/loot_messages.test.js test/unit/pickup_types_messages.test.js: PASS
2026-03-20: mpickstuff() must not inherit the search-path ROCK skip
- Evidence: after the pickup-menu fixes,
seed031_manual_directnext diverged at step580where C emitted^pickup[44@44,11,471]but JS stayed in^distfleeck[44@44,11 ...]. JS dbgmapdump showed a dwarf landing on square44,11withotyp=471(rock) present there, but JS never attempted the underfoot pickup. - Root cause:
js/monmove.js
maybeMonsterPickStuff()had copied the neighborhood-search optimizationif (obj->otyp == ROCK) continue;into the on-square pickup path. In C, that skip exists only inmonmove.citem search (look through the items on this locationwhile scanning nearby squares).mon.c mpickstuff()does not skip rocks on the monster’s current square. - Why C can still pick the rock up:
rockisGEM_CLASS, andmon_would_take_item()treatsGEM_CLASSas part of thepractical[]set forlikes_objs()monsters.- Dwarves satisfy that practical-item path, so the underfoot rock pickup is legitimate in C.
- Fix: remove the
obj.otyp === ROCKearly-continue frommaybeMonsterPickStuff()while leaving the search-path rock skip intact. - Validation:
seed031_manual_directimproved from first RNG/event divergence580/580to624/624theme15_seed986_wiz_artifact-wish_gameplay: PASStheme35_seed2320_wiz_artifact-combat2_gameplay: PASS
2026-03-20: Mixed floor piles with gold must still use Pick up what?
- Evidence: after the underfoot-rock fix,
seed031_manual_directstill diverged at step624. Replay-owner tracing showed the later floor pile flow was wrong in JS:- fixture keys at turn
517are:, space, ,, b c d e f $ Enter - C stays inside
Pick up what?through that whole sequence - JS consumed
,as an immediate pickup, then interpreted the laterb/c/d/e/f/$keys as global commands
- fixture keys at turn
- Root cause:
js/pickup.js
was returning early whenever any
COIN_CLASSobject was present on the square. That is not C-faithful for mixed piles. In C,pickup.cquery_objlist("Pick up what?", ...)is only bypassed byAUTOSELECT_SINGLEwhen exactly one eligible object exists. - C anchor:
pickup.c:760..775sends menu-style pickup throughquery_objlist("Pick up what?", ...)pickup.c:1072only autoselects whenn == 1pickup.c:1135gives the first gold entry the selector'$'
- Fix:
- only auto-pick gold when it is the sole floor object
- for mixed piles, build the pickup menu over the full object list, including gold
- assign
'$'to the first gold entry and let other entries keep the normal auto-assigned letters - when gold is selected from that menu, emit the normal
formatGoldPickupMessage(...)text instead of treating it like a generic inventory-letter item line - add a focused unit test in test/unit/pickup_compare_discovery_message.test.js covering mixed gold+item floor piles
- Validation:
seed031_manual_directimproved from first RNG/event divergence624/624to635/630seed032_manual_direct: unchangedseed033_manual_direct: unchangedtheme15_seed986_wiz_artifact-wish_gameplay: PASStheme35_seed2320_wiz_artifact-combat2_gameplay: PASSnode --test test/unit/pickup_compare_discovery_message.test.js test/unit/windows_nhwindow.test.js: PASS
2026-03-20: addinv() stack merges must respect C’s hallucination-aware knowledge gates
- Evidence: on
main,seed031_manual_directstill drifted before the pet-AI seam. Focusedaddinvtracing localized the earliest concrete letter drift to container takeout at step147:- JS merged a looted food ration into existing stack
b - the live state at that exact merge was
Blind=0,Hallucination=1 - the incoming ration had
known/bknown/rknown = 0/0/0while the existing stack had1/1/1
- JS merged a looted food ration into existing stack
- C anchor:
pickup.c out_container()doesotmp = addinv(obj); pickup_prinv(otmp, ...)invent.c mergable()rejects merges when:how_lostmismatches a non-LOST_NONEcarried stackdknowndiffersbknown,rknown, orknowndiffer whileBlind || Hallucination- reviver corpses would merge
- Root cause:
js/mkobj.js
mergable()was still using a simplified predicate, so js/player.jsaddToInventory()collapsed stacks that C would keep separate during hallucination. That advanced later invlets by one and cascaded into the dwarf-pile / dog-inventory seam. - Fix:
- make
Player.addToInventory()use canonicalmkobj.mergable() - extend
mkobj.mergable()with the C-relevant carried-stack gates:how_lostdknown- hallucination/blindness-aware
bknown/rknown/known - reviver-corpse guard
- make
- Validation:
seed031_manual_directimproved from first RNG/event divergence635/630to637/638seed032_manual_direct: unchangedseed033_manual_direct: unchangedtheme15_seed986_wiz_artifact-wish_gameplay: PASStheme35_seed2320_wiz_artifact-combat2_gameplay: PASS
- Regression found and fixed:
theme31_seed1951— see “Paired Bugs” entry below.
Paired Bugs: rock trap dknown + mergable gate (March 20, 2026)
Lesson: correct parity fixes can regress sessions that pass due to compensating errors. When this happens, don’t revert — find and fix the second bug.
The pair
Bug A (mergable dknown gate): JS’s mergable() was missing C’s dknown comparison
gate. C’s invent.c mergable() rejects merges when obj->dknown != otmp->dknown.
Adding this gate is correct parity — it prevents merging objects the player has seen
with objects they haven’t, which matters under hallucination.
Bug B (rock trap dknown): trapeffect_rocktrap_you() creates a rock via
t_missile(ROCK, trap) → mksobj(ROCK, TRUE, FALSE) which initializes dknown=false.
The rock is placed at the player’s position, where an existing floor rock already has
dknown=true (set by see_nearby_objects() when the player walked onto the tile).
With bug A fixed, stackobj() now refuses to merge these two rocks (dknown mismatch),
so JS doesn’t emit the ^remove event that C does.
How they cancelled
Before the mergable fix, JS merged rocks regardless of dknown differences. This
accidentally produced the correct ^remove event, matching C. The RNG stream stayed
aligned. After the mergable fix, the merge was blocked, the ^remove disappeared,
and the event stream diverged — causing theme31_seed1951 to fail.
The fix
Set otmp.dknown = true before place_object() in trapeffect_rocktrap_you(). The
player explicitly sees the rock fall on their head (“A trap door in the ceiling opens
and a rock falls on your head!”), so knowing its description is correct. This matches
C’s behavior where both rocks have the same dknown value at merge time.
Diagnostic method
- Confirmed the regression was from the OTHER engineer’s commit (not ours) by
checking out the old
mkobj.jsand verifying the session passed. - Added temporary
console.loginstackobj()to print dknown values when same-type objects fail to merge:STACKOBJ_NOMATCH otyp=471 name=rock dknown=false/true. - The
false/truemismatch immediately identified the root cause.
General principle
When a correct parity fix regresses a session:
- The session was passing due to a compensating error — two bugs cancelling out.
- The fix exposed the SECOND bug, which was previously invisible.
- The right action is to find and fix bug B, not revert bug A.
- Debug by examining what CHANGED between “works” and “broken” states, then trace the specific value difference (in this case, dknown on the trap rock).
2026-03-20: T armor removal must reuse armoroff() timing, not a JS-only occupation
- Evidence: on
main, after the hallucination-aware inventory merge fix,seed031_manual_directstill first diverged at RNG/event637/638in the pet turn:- JS:
^dog_invent_decision[32@59,14 ud=9 ...] - C:
^dog_invent_decision[32@59,14 ud=17 ...]
- JS:
- Focused trace around the preceding command handoff showed the active JS
command sequence was still armor takeoff/wear:
- resume
handleTakeOff(...) - top line
You finish taking off your ... - then
handleWear(...) - then the first bad dog turn
- resume
- C anchor:
do_wear.c armor_or_accessory_off()delegates armor removal toarmoroff(obj)do_wear.c armoroff()usesnomul(delay)+ga.afternmv+gn.nomovemsg- it does not implement delayed single-item armor takeoff as an occupation;
take_off()occupation logic is forA/#takeoffall
- Root cause:
js/do_wear.js
had two separate timing models for armor removal:
- faithful
armoroff()already existed and matched C’snomul + afternmv - but
removeArmorOrAccessory()still used a JS-onlyoccupationpath whendelay > 1This shifted the takeoff/wear boundary and left JS running the next pet turn with the hero still on the earlier pre-state.
- faithful
- Fix:
- route
removeArmorOrAccessory()armor removals through existingarmoroff(item, player, game) - keep accessory removal on the direct path
- route
- Validation:
seed031_manual_directimproved from first RNG/event divergence637/638to639/640theme15_seed986_wiz_artifact-wish_gameplay: PASStheme35_seed2320_wiz_artifact-combat2_gameplay: PASSnode scripts/test-unit-core.mjs: PASS
- REVERTED (March 20 session 27): While the armoroff change improved seed031
(637→639), it regressed 14 other gameplay sessions (439→425). The full suite was not
run before merging. Root cause:
nomul(delay)multi-turn path processes monster movement differently than the occupation path in the current game loop. The armoroff change requires the game loop reorder (multi<0 continuation matching C’s moveloop_core iteration model) to be landed first. Occupation-based armor removal restored to keep 439/442.
2026-03-20: narrow boots-only armoroff() restores late seed031 progress without reopening the broad regressions
2026-03-20: general armoroff() fix needed two hidden follow-up fixes before the branch stabilized
- Branch context:
- after reintroducing the broad C-faithful rule that all single-item armor
removal should route through
do_wear.c armoroff()semantics, the branch initially reopened many coverage-session regressions - the right response was not to keep narrowing by slot; it was to find the masked general bugs that the faithful path exposed
- after reintroducing the broad C-faithful rule that all single-item armor
removal should route through
- First hidden bug:
- js/do_wear.js
armoroff()was dispatching byobjectData[otyp].oc_armcat - this repo’s object rows use
oc_subtypfor armor category, so cloak/helm/ shield/etc. off-effects could be skipped even thoughsetnotworn()still cleared the slot bit - concrete evidence on
t11_s755_w_covmax9_gp:- taking off a cloak of magic resistance left
ANTIMAGIC.extrinsic=1 - JS then resisted a self-zap that C allowed
- taking off a cloak of magic resistance left
- js/do_wear.js
- Fix:
- switch
armoroff()toobjectData[otyp].oc_subtyp - centralize delayed and immediate removal through one
finishArmorOff()path that runs the correct*_off()helper, thensetnotworn(), thenfind_ac()
- switch
- Second hidden bug:
- several forced-removal paths were only clearing worn slots and masks, not the passive hero effects granted by the worn item
- affected paths included:
invent.jsitem consumption/useupsteal.jstheft removalzap.jsworn-item destructionapply.jscream pie self-use when the blindfold stack hits zero
- Fix:
- add
clearWornItemEffects(player, obj)in js/do_wear.js - route those forced-removal paths through it so they clear the same passive effects as voluntary removal before unsetting the slot/mask
- add
- Validation on branch
armoroff-general-fixafter both follow-up fixes:scripts/run-and-report.sh --failuresreturned only the same three manual-direct failures as the earlier frontier:seed031_manual_directseed032_manual_directseed033_manual_direct
- guardrail sessions stayed green:
t11_s755_w_covmax9_gp: PASSt11_s756_w_covmax10_gp: PASStheme15_seed986_wiz_artifact-wish_gameplay: PASStheme35_seed2320_wiz_artifact-combat2_gameplay: PASS
- General lesson:
- when a broad C-faithful fix reopens many sessions, assume compensating bugs before abandoning the broad fix
- in this case, the faithful
armoroff()path was correct; the regressions came from JS-only metadata mismatch (oc_armcatvsoc_subtyp) and passive effect cleanup holes in non-voluntary item removal
- Evidence:
- the live
seed031_manual_directseam onmainremained the same delayed takeoff/wear window, but the active removal was specifically:You finish taking off your shoes.- then
What do you want to wear? - then the first bad pet turn at
637/638
- the live
- Narrow fix:
- keep the broad occupation-based fallback for general armor removal
- but route
ARM_BOOTSsingle-item removal through existingarmoroff(item, player, game) - leave other armor slots on the old occupation path until the generic multi<0 reorder is ready
- Why this was defensible:
- it targets the exact active
seed031removal case instead of reviving the reverted broad behavior - it reuses the existing faithful C-shaped helper rather than introducing a third timing model
- it targets the exact active
- Validation:
seed031_manual_directimproved materially:- first RNG divergence
637 -> 710 - first event divergence
638 -> 710
- first RNG divergence
- guardrails:
theme15_seed986_wiz_artifact-wish_gameplay: PASStheme35_seed2320_wiz_artifact-combat2_gameplay: PASS
- unchanged core manual-direct controls:
seed032_manual_direct: unchangedseed033_manual_direct: unchanged
node scripts/test-unit-core.mjs: PASS
Systematic effect-wiring sweep (March 20, 2026, session 27)
Pattern discovered: Many game effects had their RNG consumed for stream parity but the actual game state effect was never applied. The RNG calls (rnd(N), rn2(N)) were placed as stubs to keep the random number stream aligned with C, but the functions that USE those random numbers (losehp, make_sick, make_blinded, etc.) were never called.
Method: grep for rn[d2](\d+);.*// across all JS files. Each match is a
bare RNG consumption with a comment explaining what it’s for. Check if the
referenced function exists in the codebase. If it does, wire it.
Results: 23 game effects wired across 9 files:
- Combat damage: erode_armor + acid_damage in all 4 acid paths (passivemm, passiveum, mhitm_ad_acid, uhitm AD_ACID)
- Player damage: losehp in sit.js (6 throne/trap effects), fountain.js (boiling water), artifact.js (silver/bane retouch), eat.js (acidic/rotten corpse), steed.js (mount slip, dismount throw/fall)
- Status effects: fall_asleep (sleep attack, exertion), flashburn (cleric lightning, wand backfire), make_sick (disease attack, tainted corpse, vomit cure), make_blinded (throne heal/curse), nomul (cursed booze), change_luck (throne effects x3), heal_legs (throne full-heal), losexp (throne level drain)
- Monster AI: breamm/spitmm/thrwmm (ranged attacks in mattackm), cockatrice petrification, elf/orc bonus, polearm retreat, Medusa gazemu
RNG bug found: diseasemu ternary – C conditionally calls rn1() only when player isn’t already sick (Sick ? Sick/3+1 : rn1(con, 20)). JS was always calling rn1(), consuming an extra RNG entry when the player is already sick.
Key principle: bare rn2(N); with a comment is a TODO marker for an
unimplemented effect. The RNG is correct but the game state diverges from C.
These are zero-RNG-change fixes – the stream is already aligned, only the
effect was missing.
2026-03-20: Multi-pickup same-class ordering must respect loot_classify() before name
- Evidence on branch
armoroff-general-fix:- recorded
seed031_manual_directfixture around the dwarf pile expects the first wear help list to be:w - a pair of hard shoesx - a hooded cloak
- live JS on both
mainandarmoroff-general-fixinstead produced:w = hooded cloakx = hard shoes
- focused pickup tracing showed why:
- during the multi-object
Pick up what?loop, JS processed the selected armor in this order:hooded cloakhard shoes
- but the recorded C menu/result order requires:
hard shoeshooded cloak
- during the multi-object
- recorded
- Root cause:
- js/pickup.js
was only grouping by
inv_order, then sorting same-class objects bycxname_singular() - that misses C
invent.c sortloot_cmp()behavior, which runsloot_classify()within class first, then uses the name only as a later tiebreaker - for armor,
loot_classify()subclass order puts boots before cloaks, so the JS comparator was reversing the C pickup processing order for this case
- js/pickup.js
was only grouping by
- Fix:
- keep the existing pickup-menu path
- within same object class, use
loot_classify()subclass/discovery ordering first, then the existingcxname_singular()name tiebreak, theno_id - do not replace the whole pickup path with generic
sortloot(); that was too broad for this call site and regressed the seed much earlier
- Validation on branch
armoroff-general-fix:seed031_manual_directimproved:- first RNG divergence
637 -> 733 - first event divergence
638 -> 736
- first RNG divergence
- targeted guardrails: PASS
t11_s755_w_covmax9_gpt11_s756_w_covmax10_gptheme15_seed986_wiz_artifact-wish_gameplaytheme35_seed2320_wiz_artifact-combat2_gameplay
- unchanged controls:
seed032_manual_directseed033_manual_direct
node scripts/test-unit-core.mjs: PASS
- Lesson:
- for pickup-menu parity, “same class, then name” is not enough
- if a menu later drives inventory letters, the processing order must match C
loot_classify()semantics, not just the visible names
2026-03-20: LVLINIT_MINES must clear stale mazelevel after mkmap()
- Evidence in
seed031_manual_directafter the pickup-order fix:- first RNG divergence had moved to step
733 - JS branched in
dog_goal()on:rn2(4)=2 @ dochug(monmove.js:847)
- C instead went straight into:
rn2(1)=0 @ dog_move(dogmove.c:1300)
- focused trace showed why:
- at step
542, a dwarf tunneled into(32,14)in both C and JS - JS
mdig_tunnel()loggedmaze=1 cavern=0 newtyp=25, so it converted the wall toROOM - later, at step
733, JS still treated the hero square(32,14)as a room, which forced the extradog_goal()follow-playerrn2(4)branch
- at step
- first RNG divergence had moved to step
- Root cause:
minefill.lualegitimately starts withdes.level_flags("mazelevel", ...)- in C, the later
mkmap()call overrides that for joined walled cave maps:mkmap.c: ifwalled && join, setis_maze_lev = FALSE,is_cavernous_lev = TRUE
- JS
sp_lev.jsinlined theLVLINIT_MINES/ROGUE -> mkmappipeline but never performed that post-mkmap()flag transition - result: JS kept a stale
is_maze_lev=trueflag on generic Mines filler levels, so monster digging createdROOMwhere C treated the level as cavernous
- Fix:
- after
finish_map(...)in thestyle === "mines" || style === "rogue"path, ifwalled && joined:- set
levelState.flags.is_maze_lev = false - set
levelState.flags.is_cavernous_lev = true - mirror the same values into
map.flags
- set
- after
- Validation:
seed031_manual_directimproved:- first RNG divergence
733 -> 743 - first event divergence
736 -> 743
- first RNG divergence
- targeted guardrails: PASS
t11_s755_w_covmax9_gpt11_s756_w_covmax10_gptheme15_seed986_wiz_artifact-wish_gameplaytheme35_seed2320_wiz_artifact-combat2_gameplay
node scripts/test-unit-core.mjs: PASS
- Lesson:
- special-level scripts may set provisional flags like
mazelevel, but procedural generators such asmkmap()still own the final terrain-mode flags used later by digging, room checks, and pet movement
- special-level scripts may set provisional flags like
2026-03-20: turn-end random spawn depth must use level_difficulty()
- Evidence in
seed031_manual_directafter the Mines flag fix:- first RNG/event divergence had moved to step
743 - JS diverged in turn-end random monster generation at:
rn2(10)=6 @ makemon(...)
- C diverged at the corresponding point with:
rn2(9)=0 @ rndmonst_adj(...)
- focused
RNDMON_OWNERtracing showed JS was callingrndmonst_adj()with:depth=1 ulevel=4 diff=0-2
- that came from JS passing branch-local
player.dungeonLevelintomakemon_appear()duringmoveloop_turnend()
- first RNG/event divergence had moved to step
- Root cause:
- C
allmain.cturn-end spawn usesmakemon(NULL, ...) - C
makemon.c rndmonst_adj()derives monster difficulty fromlevel_difficulty(), not branch-local level number - on Mines levels,
level_difficulty()is branch-aware and is not the same asu.uz.dlevel - JS was using branch-local depth for turn-end spawns, which built the wrong random-monster reservoir on Mines and shifted RNG at the first turn-end spawn after the pet seam
- C
- Fix:
- in js/allmain.js,
changed turn-end
makemon_appear(null, ...)to pass:level_difficulty(map.uz, game)instead of(game.u || game.player).dungeonLevel
- in js/allmain.js,
changed turn-end
- Validation:
seed031_manual_directimproved:- first RNG divergence
743 -> 933 - first event divergence
743 -> 934
- first RNG divergence
- targeted guardrails: PASS
t11_s755_w_covmax9_gpt11_s756_w_covmax10_gptheme15_seed986_wiz_artifact-wish_gameplaytheme35_seed2320_wiz_artifact-combat2_gameplay
- nearby controls still fail for their pre-existing reasons:
seed032_manual_directseed033_manual_direct
node scripts/test-unit-core.mjs: PASS
- Lesson:
- branch-local dungeon level is not a safe substitute for C
level_difficulty()in runtime generation paths; branch-aware difficulty must be preserved anywhere random monster strength is chosen
- branch-local dungeon level is not a safe substitute for C
2026-03-21: Stage repeat-owner rewrites must preserve counted-command multi
- Evidence from a failed broad
runmode_delay_output/repeat-owner rewrite:- trying to move all positive-
multiownership out ofrun_command()causedseed031_manual_directto regress catastrophically:- first RNG divergence
933 -> 163 - first event divergence
934 -> 166
- first RNG divergence
- the early regression window
160..166is not travel-specific; it is ordinary count-prefixed"m."command repetition - baseline step
166already shows:fresh_cmdcmd='.'multi=7mv=0
- so positive
multiis not one uniform travel-only case in current JS
- trying to move all positive-
- Root cause:
- the failed rewrite changed ownership for both:
- counted repeated commands (
context.mv == false) - movement/travel repetition (
context.mv == true)
- counted repeated commands (
- that re-attributed whole continuation bundles across earlier counted-command boundaries before reaching the later travel seam
- the failed rewrite changed ownership for both:
- Safe Stage A fix:
- in js/allmain.js,
stop
run_command()from draining repeated non-movement commands (repeat_cmd) in its localrepeatLoop() - keep movement/travel repetition (
context.mv == true) on the existing path for now - this preserves early counted-command behavior while isolating travel for a later Stage B port
- in js/allmain.js,
stop
- Validation:
seed031_manual_directstayed unchanged at:- first RNG divergence
933 - first event divergence
934
- first RNG divergence
- early counted-repeat corridor stayed stable:
seed031step-summary160..166unchanged
- targeted guardrails: PASS
t11_s755_w_covmax9_gpt11_s756_w_covmax10_gptheme15_seed986_wiz_artifact-wish_gameplaytheme35_seed2320_wiz_artifact-combat2_gameplay
- Lesson:
- any future
runmode_delay_output/repeat-owner port must stage the work:- preserve counted-command
multisemantics first - then port travel-specific continuation ownership
- preserve counted-command
- do not rewrite positive
multias if it were only the_travel case
- any future
2026-03-21: Stage B1 should split JS moveloop helpers before moving travel ownership
- Evidence:
- the reviewed
RUNMODE_DELAY_OUTPUT_PORT_PLANand the failed Stage B trace agreed that JS still lacks a clean equivalent of the C boundary between:- monster-time / turn-end work
- once-per-player-input pre-input sync
- the positive-
multirepeat branch
- current JS helper
advanceTimedTurn()was bundling:moveloop_core()- plus
find_ac() - plus vision/monster refresh
- plus
display_sync()
- that made it too coarse for Stage B experiments, because the first repeated
travel
domove()needs to be placed relative to those sub-phases exactly the way C does
- the reviewed
- Safe Stage B1 refactor:
- in js/allmain.js,
extracted the post-
moveloop_core()sync work into:syncTimedTurnPreInputState(game)
- kept
advanceTimedTurn(game, coreOpts)behavior unchanged:moveloop_core(game, coreOpts)syncTimedTurnPreInputState(game)
- no ownership was moved yet; this is purely a behavior-preserving split so later Stage B work can target smaller C-shaped boundaries
- in js/allmain.js,
extracted the post-
- Validation:
seed031_manual_directunchanged:- first RNG divergence
933 - first event divergence
934
- first RNG divergence
- targeted gameplay guardrails: PASS
t11_s755_w_covmax9_gpt11_s756_w_covmax10_gptheme15_seed986_wiz_artifact-wish_gameplaytheme35_seed2320_wiz_artifact-combat2_gameplay
node scripts/test-unit-core.mjsstill ends on existing unrelatedepitaph selectionfailures; this refactor did not touch that area
- Lesson:
- before moving travel-specific repeat ownership, first split JS helpers so
the code can express the exact C ordering boundary around:
lookaround()runmode_delay_output()- the first repeated
domove()
- do not use
advanceTimedTurn()as if it were already a C-faithful unit of repeat-move ownership
- before moving travel-specific repeat ownership, first split JS helpers so
the code can express the exact C ordering boundary around:
2026-03-21: Stage B2a can extract the movement-repeat slice safely before moving ownership
- Evidence:
- after Stage B1, the next safe move was to extract the current
context.mv == truepositive-multislice fromrepeatLoop()without changing who calls it - this isolates the exact JS slice that later Stage B2b will move, while preserving the current runtime owner for validation
- after Stage B1, the next safe move was to extract the current
- Safe Stage B2a refactor:
- in js/allmain.js,
extracted the existing movement/travel repeat body into:
runMovementRepeatSlice(game, { coreOpts, bumpHeroSeqN })
repeatLoop()still calls that helper directly whencontext.mvis set- ownership and ordering are unchanged in this step
- in js/allmain.js,
extracted the existing movement/travel repeat body into:
- Validation:
seed031_manual_directunchanged:- first RNG divergence
933 - first event divergence
934
- first RNG divergence
- targeted gameplay guardrails: PASS
t11_s755_w_covmax9_gpt11_s756_w_covmax10_gptheme15_seed986_wiz_artifact-wish_gameplaytheme35_seed2320_wiz_artifact-combat2_gameplay
node scripts/test-unit-core.mjsstill reports the same pre-existing unrelatedepitaph selectionfailures
- Lesson:
- Stage B should continue separating:
- the shape of the repeat-move slice
- the runtime owner of that slice
- extracting the slice first reduces risk when Stage B2b later moves
ownership toward the
_gameLoopStep()/ moveloop-style owner
- Stage B should continue separating:
2026-03-21: Stage B2b showed the owner move alone does not fix seed031
- Evidence from a reverted Stage B2b patch:
- patch shape:
- left
runMovementRepeatSlice(...)unchanged - stopped
run_command().repeatLoop()from drainingcontext.mv == truerepeats locally - moved only the movement-repeat owner to
_gameLoopStep()
- left
- results:
seed031_manual_directdid not improve:- first RNG divergence stayed
933 - first event divergence stayed
934
- first RNG divergence stayed
- early counted-repeat corridor
160..166stayed unchanged - targeted gameplay guardrails still passed
- but later normalized matching regressed slightly:
- events
19066 -> 19065 - screens
1279 -> 1264
- events
- patch shape:
- Root conclusion:
- the first travel/contact seam is not fixed by changing the outer runtime owner alone
- the remaining problem is still inside the repeated movement slice itself:
- ordering of
lookaround() runmode_delay_output()- and when the post-turn/pre-input sync happens relative to the repeated
domove()
- ordering of
- Lesson:
- Stage B2b should not be retried unchanged
- next work should target the slice internals before another owner move
- the owner question still matters, but only after the repeated-travel slice is internally closer to C
2026-03-21: Deeper C read found missing repeat-slice invariants beyond ownership
- We had been treating the remaining travel problem too abstractly. A closer
C/JS comparison exposed several concrete gaps:
- C positive-repeat branch has a pre-
domove()runmode_delay_output()inallmain.c; JS still only has the move-localdomove()delay call - C resets
u.umoved = FALSEbefore every positive-repeat slice; JS only resetsplayer.umovedonce at fresh-command start - JS
advanceTimedTurn()is not one C phase; it is a fused helper spanningmoveloop_core()plus later pre-input sync - initial
_travel-step handling and repeated travel-step handling are currently framed differently in JS - current
runMovementRepeatSlice(...)is a fused cross-iteration helper, not one exact C repeat slice
- C positive-repeat branch has a pre-
- Lesson:
- the next faithful port step is not another owner rewrite
- first make the C repeat-slice invariants explicit in JS:
- per-slice
u.umoved = FALSE - repeat-branch
runmode_delay_output()before repeateddomove() - consistent framing between initial and repeated travel steps
- per-slice
- only after the slice internals are C-shaped does it make sense to revisit moving the outer runtime owner
2026-03-21: Local Stage C3 invariants alone were not sufficient
- Probe patch:
- inside js/allmain.js
runMovementRepeatSlice(...):- set
player.umoved = falsebeforelookaround() - call repeat-branch
runmode_delay_output()before repeateddomove()
- set
- inside js/allmain.js
- Validation:
seed031_manual_directunchanged:- first RNG divergence
933 - first event divergence
934
- first RNG divergence
- counted-repeat corridor
160..166unchanged - targeted gameplay guardrails still passed:
t11_s755_w_covmax9_gpt11_s756_w_covmax10_gptheme15_seed986_wiz_artifact-wish_gameplaytheme35_seed2320_wiz_artifact-combat2_gameplay
- Lesson:
- these per-slice invariants are probably real, but in the current JS fused repeat-slice model they are not sufficient to move the first seam
- the remaining issue is more likely in the larger framing:
- initial vs repeated travel-step handling
- or the fact that
runMovementRepeatSlice(...)still spans pieces of two C iterations
2026-03-21: The next target is JS’s split travel ownership, not a prompt-owned frame
- After re-reading C
moveloop_core()and the current JS runtime, the deeper remaining mismatch is that JS still splits_travel across two owners:- the resumed still-running
_command, which drainsrun_command() -> finalizeTimedCommand() -> repeatLoop() _gameLoopStep()’s direct top-leveltravelPathcontinuation branch
- the resumed still-running
- focused replay tracing showed steps
931..932are still blocked ingetpos_async(), and step933key"."resumes that same pending command rather than going throughpromptStep() - inside that one resumed command, step
933currently runs fivedomove_targethops before the runtime blocks again - after that command settles, step
934starts a fresh_gameLoopStep()and immediately takes the separate top-leveltravelPathbranch - C does not have this split.
_travel remains normal command execution insiderhack(0), with later positive-repeat slices owned by latermoveloop_core()entries. - Lesson:
- before another owner move or another local repeated-slice probe, JS needs a single C-shaped travel-step contract
- the first concrete target is the resumed command’s
repeatLoop()overdrain in step933 - the second target is the separate top-level
travelPathowner on step934+
2026-03-21: Top-level repeat-slice substitution helped later spillover, but prompt-finalization specialization did not
- Probe A:
- replace top-level direct travel continuation
- from
_gameLoopStep() -> dotravel_target() -> moveloop_core() - to the extracted
runMovementRepeatSlice(...)
- from
- replace top-level direct travel continuation
- Probe B:
- keep Probe A
- also special-case the post-
getpos_async()initial travel completion path sorun_command()usesmoveloop_core()instead of fullfinalizeTimedCommand()
Validation:
- both probes were safe on the targeted gameplay guardrails:
t11_s755_w_covmax9_gpt11_s756_w_covmax10_gptheme15_seed986_wiz_artifact-wish_gameplaytheme35_seed2320_wiz_artifact-combat2_gameplay
- Probe A materially reduced later
seed031spillover:- baseline
934..936:rng +431 / evt +169 - Probe A:
rng +130 / evt +95
- baseline
- but Probe A still did not move the first seam later:
- first RNG divergence stayed
933 - first event divergence stayed
934
- first RNG divergence stayed
- Probe B produced the same result as Probe A
Lesson:
- replacing the top-level direct travel continuation is part of the correct direction
- but post-
getpos_async()finalization specialization is not the next missing piece - the remaining earliest mismatch is still earlier, inside the resumed
_command’s localrepeatLoop()drain on step933
2026-03-21: Reordering the top-level travelPath branch was safe but not sufficient
- Probe patch:
- in
_gameLoopStep(), move the top-leveltravelPathbranch below:- command-boundary
--More--dismissal - negative-
multicontinuation - occupation continuation
- command-boundary
- in
- Validation:
seed031_manual_directunchanged:- first RNG divergence
933 - first event divergence
934
- first RNG divergence
- targeted gameplay guardrails still passed:
t11_s755_w_covmax9_gpt11_s756_w_covmax10_gptheme15_seed986_wiz_artifact-wish_gameplaytheme35_seed2320_wiz_artifact-combat2_gameplay
- Lesson:
- the top-level
travelPathbranch is still a real structural mismatch, because C does not have that preempting owner - but moving that branch later in
_gameLoopStep()is not sufficient by itself to move the firstseed031seam
- the top-level
2026-03-21: _ travel is resumed through the pending command, not promptStep()
- Focused replay-owner tracing corrected an earlier wrong theory.
- Around
seed031steps931..934:- steps
931..932resume the still-running_command and block ingetpos_async() - step
933key"."resumes that same pending command - there is no
promptStep()ownership transition here
- steps
- The exact step
933trace showed:- first
dotravel_target()hop - then local
run_command() -> repeatLoop()drain - five
domove_targethops total in one replay step - gas spore 27 refreshed to
(24,13)and then(26,13)in the same step
- first
- Then step
934starts a fresh_gameLoopStep()and immediately enters the separate top-leveltravelPathcontinuation path. - Lesson:
- the real owner split is:
- resumed-command local travel drain on step
933 - top-level
travelPathcontinuation on step934+
- resumed-command local travel drain on step
- the old “prompt-owned initial travel frame” theory was wrong and should not guide further fixes
- the real owner split is:
2026-03-21: Combined owner correction reduced spillover but still did not move the first seam
- Combined probe:
- skip local movement
repeatLoop()drain after the initial_travel hop - route later top-level
travelPathcontinuation throughrunMovementRepeatSlice(...)
- skip local movement
- Validation:
seed031_manual_directstill first diverged at:- RNG
933 - event
934
- RNG
- but spillover improved materially:
- baseline
933..936:rng +431 / evt +169 - combined probe:
rng +106 / evt +95
- baseline
- targeted gameplay guardrails still passed:
t11_s755_w_covmax9_gpt11_s756_w_covmax10_gptheme15_seed986_wiz_artifact-wish_gameplaytheme35_seed2320_wiz_artifact-combat2_gameplay
- Lesson:
- both runtime owners are real contributors
- but owner routing alone is still not enough
- the next fix has to target the first resumed-command travel slice
inside step
933, because that is where the earliest bad work still begins
2026-03-21: The first resumed _ slice already contains C’s later positive-repeat monster work
- A deeper pass compared:
- C
allmain.cpositive-repeat branch (lookaround()->runmode_delay_output()-> repeateddomove()) - C
hack.c:domove()tail - JS replay-owner trace for the authoritative failing step
933
- C
- New concrete correction:
- JS step
933is not only wrong because it drains multiple travel hops - it is already wrong on the first resumed slice, because that slice already contains monster-turn work that C does not expose until the later positive-repeat branch
- JS step
- Evidence:
- JS step
933starts:domove_target from=22,14 to=23,13- then immediately kitten turns with
set_apparxy ... u=(23,13)
- the comparable C raw window before the first mismatch instead shows:
rn2(70)=45 @ moveloop_core(allmain.c:341)rn2(20)=19 @ gethungry(eat.c:3186)rn2(79)=55 @ moveloop_core(allmain.c:466)>runmode_delay_output @ moveloop_core(allmain.c:629)- then monster 27’s
distfleeck(...)
- JS step
- Lesson:
- the next fix should not be framed as merely “keep the first hop and stop later hops”
- the first resumed
_slice itself must stop at the same C boundary that exists before the next positive-repeat iteration’s monster work becomes visible
2026-03-21: The concrete collapsed boundary is finalizeTimedCommand() -> local repeatLoop()
- The structural mismatch is now precise.
- Current JS sequence for the initial resumed
_step:dotravel_target()performs the firstdomove()run_command()callsfinalizeTimedCommand()finalizeTimedCommand()callsadvanceTimedTurn()advanceTimedTurn()runs:moveloop_core()- then
syncTimedTurnPreInputState()
- control returns to
run_command() run_command()immediately enters localrepeatLoop()repeatLoop()starts the next positive-repeat movement bundle in the same resumed command
- C does not collapse those boundaries together.
- after
rhack(0)and the firstdomove(), control returns tomoveloop_core() - only on a later outer
moveloop()re-entry does C execute the positive-repeat branch:u.umoved = FALSElookaround()runmode_delay_output()- repeated
domove()
- after
- Lesson:
- the next fix target is the boundary between:
finalizeTimedCommand()completing the initial_travel step- and local
repeatLoop()starting the next travel step
- that boundary is where JS is currently pulling C’s next positive-repeat
iteration into step
933
- the next fix target is the boundary between:
2026-03-21: The local collapsed boundary is the dominant spillover contributor
- Narrow probe:
- suppress only the local movement
repeatLoop()after the resumed_command’s initialdotravel_target()hop - leave the top-level
_gameLoopStep()travelPathcontinuation unchanged
- suppress only the local movement
- Validation:
seed031_manual_directstill first diverged at:- RNG
933 - event
934
- RNG
- but spillover again dropped sharply:
- baseline
933..936:rng +431 / evt +169 - local-boundary-only probe:
rng +106 / evt +95
- baseline
- targeted gameplay guardrails still passed:
t11_s755_w_covmax9_gpt11_s756_w_covmax10_gptheme15_seed986_wiz_artifact-wish_gameplaytheme35_seed2320_wiz_artifact-combat2_gameplay
- Lesson:
- the local
finalizeTimedCommand() -> repeatLoop()collapse is the dominant contributor to the late933..936spillover - the top-level
travelPathowner is still a mismatch, but it is secondary for that specific spillover effect - because the first seam still remains at
933, the next remaining bug is inside the first resumed_slice itself
- the local
2026-03-21: Full deferred-travel implementation still collapsed to the same partial result
- Implementation attempt:
- skip immediate timed-turn finalization for the initial resumed
_travel hop - defer that timed turn to the next
_gameLoopStep() - process that deferred turn as:
moveloop_core()syncTimedTurnPreInputState()
- route later top-level travel continuation through
runMovementRepeatSlice(...)
- skip immediate timed-turn finalization for the initial resumed
- Validation:
- targeted gameplay guardrails still passed:
t11_s755_w_covmax9_gpt11_s756_w_covmax10_gptheme15_seed986_wiz_artifact-wish_gameplaytheme35_seed2320_wiz_artifact-combat2_gameplay
seed031_manual_directstill first diverged at:- RNG
933 - event
934
- RNG
- and matched the same partial-improvement shape as the earlier combined owner probe
- targeted gameplay guardrails still passed:
- Lesson:
- the Phase 1/Phase 2 inversion insight is real, but implementing it at the current helper boundaries is still not sufficient
- one more lower-level mismatch remains inside the first resumed
_slice, even after the deferred-timed-turn rewrite
2026-03-21: Positive travel still lacks a C-faithful no-input owner in _gameLoopStep()
- Lower-level analysis after the deferred-timed-turn failure clarified the
missing runtime structure:
_gameLoopStep()already has explicit no-input continuation lanes for:- negative
multi - occupation
- negative
- but it still has no equivalent no-input lane for positive
multitravel/running
- That means positive-travel continuation is currently forced into two wrong
owners:
- too early: local
run_command() -> repeatLoop()overdrain - too late: separate top-level
_gameLoopStep()travelPathbranch
- too early: local
- This explains why:
- suppressing only the local
repeatLoop()produced the dominant spillover reduction (933..936:rng +431 / evt +169->rng +106 / evt +95) - but deferring the first timed continuation to a later command-cycle entry still did not move the first seam
- suppressing only the local
- Refined conclusion:
- C-faithful positive travel needs a dedicated outer no-input continuation
lane in
_gameLoopStep() - neither command-local
while (multi > 0)draining nor a later dedicatedtravelPathshortcut is the right owner
- C-faithful positive travel needs a dedicated outer no-input continuation
lane in
2026-03-21: A positive-multi lane inside _gameLoopStep() is still too fused
- Probe:
- suppress local movement
repeatLoop()inrun_command() - add a positive-
multi/context.mvno-input lane inside the existing_gameLoopStep()while - have that lane execute one
runMovementRepeatSlice(...)per loop pass
- suppress local movement
- Validation:
seed031_manual_directstill first diverged at:- RNG
933 - event
934
- RNG
- but later spillover again improved sharply:
- steps
933..936:rng +130 / evt +95
- steps
t11_s755_w_covmax9_gpstayed green
- Decisive trace result:
- step
933still contained multiple repeated travel hops:22,14 -> 23,1323,13 -> 24,1324,13 -> 25,1325,13 -> 26,1326,13 -> 27,13
- plus the later gas-spore contact/attack
- step
- Lesson:
- moving positive-travel continuation into
_gameLoopStep()is not enough if_gameLoopStep()itself keeps looping without returning - replay still treats that whole internal
_gameLoopStep()loop as one gameplay step - so the true C-faithful boundary must be above the current internal
_gameLoopStep()while, or_gameLoopStep()must be refactored to return after one positive-repeat slice instead ofcontinue
- moving positive-travel continuation into
2026-03-21: One positive-repeat slice per _gameLoopStep() return moves seed031 later
- Validated code change in
/tmp/mazes_main_compare:- suppress local movement overdrain in
run_command()whencontext.mvis active - add a positive-
multino-input continuation lane in_gameLoopStep() - make that lane return after one slice instead of continuing the internal
_gameLoopStep()while - defer the timed continuation for the initial resumed
_travel hop to a dedicatedpendingTravelTimedTurnpass before later positive-repeat slices
- suppress local movement overdrain in
- Why this is the right shape:
- it creates three separate outer re-entries where JS previously fused them:
- initial resumed
_travel hop - timed continuation after that first hop
- one later positive-repeat slice per
_gameLoopStep()return
- initial resumed
- it creates three separate outer re-entries where JS previously fused them:
- Evidence of real progress:
comparison-window --step-summaryforseed031now shows:- step
933:rng 0 / evt 0 - first new spillover begins at step
934
- step
- normalized RNG window moved from:
- baseline JS step
933 - to JS step
938
- baseline JS step
- normalized event window moved from:
- baseline JS step
934 - to JS step
996
- baseline JS step
- Focused trace after the fix:
- step
933: initial resumed hop only - step
934: gas-spore / kitten timed continuation after first hop - step
935: one travel hop - step
936: one travel hop - so the multi-hop same-step packing has been eliminated
- step
- Validation:
seed031_manual_direct.session.json- later normalized RNG/event divergence as above
- counted-repeat corridor
160..166unchanged
- gameplay guardrails still green:
t11_s755_w_covmax9_gpt11_s756_w_covmax10_gptheme15_seed986_wiz_artifact-wish_gameplaytheme35_seed2320_wiz_artifact-combat2_gameplay
- nearby controls remain on their prior issue classes:
seed032_manual_directunchanged in RNG-full / screen-event drift classseed033_manual_directunchanged at its early special-level RNG seam
- Caveat:
session_test_runnerstill reports legacyfirstDivergence.stepmetadata as933on this session- the authoritative evidence for step movement on this patch is the
normalized
comparison-windowoutput plus the per-step spillover summary
2026-03-21: Exact C stop-before-attack is now the right local probe, but it exposes a narrower remaining bug
- Reintroduced the exact
hack.c:2762-2773rule on top of the validated owner fix:- if
context.runis active and the destination square contains a visible hostile monster, stop running vianomul(0)and return before bump/attack
- if
- This behaved differently from the earlier pre-owner-fix experiments:
- at the old bad contact point, JS now stopped cleanly
- trace:
step 938 domove_target from=26,13 to=27,13 mon=27@27,13step 938 domove_notime stop-visible-hostile-while-running ...- then monster
27got its turn
- New first raw mismatch under that probe:
- JS:
rn2(5)=0 @ dochug(monmove.js:847) - C:
rn2(8)=7 @ m_move(monmove.c:1979)
- JS:
- New first event mismatch under that probe:
- JS:
^distfleeck[27@27,13 in=1 near=1 scare=0 brave=1 ...] - C:
^distfleeck[27@27,13 in=1 near=0 scare=0 brave=1 ...]
- JS:
- Interpretation:
- the premature hero attack is no longer the next problem once the exact C stop gate is present
bravenow aligns- the remaining mismatch is specifically monster
27’s post-stopnearstate
- So the next likely target is the post-stop monster-target handoff:
set_apparxy()- then
distfleeck() - not another broad owner rewrite
- Reverted the code probe because it still did not move the first-divergence step later, but it is the strongest diagnostic narrowing since the owner fix
2026-03-21: Deferring repeated travel timed turns helps, but monster 27 still gets its target too early
- On top of
7feb605cd, a combined probe did two things:- deferred repeated
context.mvtimed turns instead of finalizing them inline - reapplied the exact
hack.c:2762-2773visible-hostile stop gate
- deferred repeated
- This was materially better than either piece alone:
seed031matched RNG improved to34371/51561- matched events improved to
19071/28950 - the old hero-attack mismatch disappeared
- normalized RNG first mismatch moved from JS step
938to JS step941
- The remaining first raw mismatch under that probe became:
- JS:
rn2(5)=0 @ dochug(monmove.js:847) - C:
rn2(8)=7 @ m_move(monmove.c:1979)
- JS:
- And the remaining first event mismatch became:
- JS:
^distfleeck[27@27,13 in=1 near=1 scare=0 brave=1 ...] - C:
^distfleeck[27@27,13 in=1 near=0 scare=0 brave=1 ...]
- JS:
- Focused trace around steps
940..942showed why:- JS step
940already did:set_apparxy ... id=279 old=(24,13) new=(26,13)- gas spore move
28,13 -> 27,13
- then JS step
941stopped before attack, but monster27already carriedmux/muy=(26,13)into its first post-stopdistfleeck()
- JS step
- So the remaining bug is now even narrower:
- not hero attack logic
- not generic travel ownership
- monster
27still inherits an advanced apparent hero target one slice too early before its first post-stopm_move()
- Reverted the probe because the legacy first-divergence step label did not move later, but this is the cleanest remaining seam yet
uncommon() Inhell conditional and G_HELL area sweep (March 21, 2026)
Root cause: JS’s uncommon() always checked G_HELL flag regardless of Inhell
state. C’s uncommon() (makemon.c:1588-1599) has an Inhell conditional:
- When
Inhell: returnsmaligntyp > A_NEUTRAL(excludes good-aligned monsters) - When
!Inhell: returns!!(geno & G_HELL)(excludes hell-only monsters)
Impact: During Gehennom level generation, JS incorrectly excluded hell-native
monsters (like steam vortex, disenchanter) from random monster selection. These
monsters have the G_HELL flag but are neutral/lawful-aligned, so C’s uncommon()
includes them in hell (maligntyp ≤ A_NEUTRAL) while JS’s version excluded them
(G_HELL flag set).
Area sweep found 3 related issues:
uncommon()missing Inhell conditional (makemon.js:422-432)- Missing
G_NOHELLcheck inrndmonst_adjloop body — C’s makemon.c:1686-1687 excludes G_NOHELL monsters when in hell. JS had no separate check afteruncommon(). rndmonnum_adjPlan B fallback — C’s mkobj.c:407 usesInhell ? G_NOHELL : G_HELLfor exclude flags. JS always usedG_HELL.
Verified correct: mkclass variants at lines 716 and 764 already properly
conditional on gehennom/inhell for G_HELL/G_NOHELL gating. No change needed.
Pattern: When a function has an Inhell/In_hell conditional in C, check
all callers and related functions for the same pattern. The G_HELL/G_NOHELL
distinction is always conditional on being in Gehennom.
2026-03-21: drainUntilInput() is currently weaker than a true input boundary
- The remaining post-
933seam exposed an executor/runtime contract issue:- replay step boundaries are intended to be input-delimited
- but
js/replay_core.js:drainUntilInput()currently ends a consumed key when either:- the game reaches an actual input wait, or
- the
_gameLoopStep()promise resolves
- That second condition is weaker than the intended meaning of
“drain until input”:
- after the owner fix,
_gameLoopStep()can now return after one positive-repeat slice - but the runtime may still have valid no-input continuation pending
- if replay admits the next queued key at that point, JS is accepting input earlier than the game is truly ready for it
- after the owner fix,
- Important clarification:
- internal loop boundaries do not automatically redefine session steps
- the problem is specifically when replay treats promise completion as a
sufficient end-of-step condition even though
input.isWaitingInput()is still false
- Evidence:
- pending trace around the improved
seed031corridor shows:- step
933resumes and completes cleanly - later steps such as
937can still end withowner=none waiting=0
- step
- this means the consumed key has ended from replay’s perspective even though the runtime has not yet blocked for fresh input
- pending trace around the improved
- Current best interpretation:
- the remaining monster-27 target-refresh seam may now be partly caused by the next queued key being introduced before the JS runtime has reached the true no-input completion boundary that C would reach first
- Next step:
- tighten the replay/runtime contract so a consumed key does not finish
merely because
_gameLoopStep()returned, if no-input continuation is still pending and the game is not actually waiting for input
- tighten the replay/runtime contract so a consumed key does not finish
merely because
2026-03-21: The remaining gas-spore near seam is still cross-step, not yet a proven monster-state bug
- A crucial correction from the latest comparison:
- the normalized event mismatch
- JS:
^distfleeck[27@27,13 in=1 near=1 ...] - C:
^distfleeck[27@27,13 in=1 near=0 ...]is not comparing the same recorded input key on both sides
- JS:
- the normalized event mismatch
- Actual session keys show:
- JS aligned steps there are still:
937 = "l"938 = "l"
- the normalized C side is already at:
947 = "."948 = "h"
- JS aligned steps there are still:
- So the latest monster-27 analysis must be restated:
- JS does show gas spore
27at step937refreshing toward(26,13)and moving from28,13 -> 27,13 - but the later C
near=0comparison is still on a later recorded key - therefore this is still best treated as step-attribution / ownership drift,
not as a proven
distfleeck()/set_apparxy()logic bug
- JS does show gas spore
- Consequence:
- do not patch
distfleeck()orset_apparxy()on this evidence - the next fix target remains command/timed-turn ownership: why does JS emit this monster bundle earlier in the input-step stream than C?
- when
_gameLoopStep()is allowed to return after one positive-repeat slice, the critical question is not just who owns the slice, but when queued fresh keys become visible relative to that owner - for manual-direct parity seams, always reason on gameplay-step numbering and verify whether later keys are already queued before blaming fresh-command parsing or monster AI formulas
- do not patch
2026-03-21: the command-boundary invariant was useful, but the active fix target has shifted to a slice-level target-refresh invariant
- Assessment:
- the command-boundary invariant work was productive:
- it exposed a real queued-key / carried-owner coexistence bug
- it ruled out replay-side drains and simple owner-extension fixes
- but it is not the earliest causal bug for the surviving gas-spore seam
- the command-boundary invariant work was productive:
- Stronger causal chain:
- gas spore
27first refreshes to target(24,13)and moves to(28,13) - before gas spore
27’s next relevant turn, JS advances the apparent target to(26,13) - then at
(27,13),near=1is the natural downstream result
- gas spore
- Negative probe:
- moving
advanceTimedTurn()to the top of every repeated movement slice was wrong t11_s755_w_covmax9_gpstayed green, butseed031_manual_directregressed to the older dog seam:- first RNG mismatch:
- JS
rn2(4)=2 @ dochug(monmove.js:847) - C
rn2(100)=38 @ obj_resists(zap.c:1467)
- JS
- first event mismatch:
- JS
^dog_invent_decision[32@22,15 ud=5 ...] - C
^dog_invent_decision[32@22,15 ud=8 ...]
- JS
- first RNG mismatch:
- moving
- Lesson:
- do not treat the remaining problem as “move all repeated timed turns earlier”
- keep the command-boundary invariant as a diagnostic model
- shift the active fix target to a slice/state invariant:
- between gas spore
27’s turn at(29,14)targeting(24,13)and its nextdistfleeck()at(27,13), JS must not advance the apparent target to(26,13)
- between gas spore
2026-03-21: the first keepable structural _ travel fix was to keep the accepted command owning continuation, but only grant the extra no-time timed turn for path exhaustion
- Structural change that worked:
- when
dotravel_target()starts accepted_travel, do not hand the path back to coarsependingTravelTimedTurn - instead let the accepted
_command own its carried continuation directly through a local loop:- initial timed turn
- then repeated movement slices
- when
- Why the first structural probe still overpacked:
runMovementRepeatSlice()gave every no-time travel stop withsavedRun === 8one more fulladvanceTimedTurn()- that was too broad
- it treated:
- visible-hostile stop
- and travel-path exhaustion as if they had the same same-key continuation behavior
- Exact observed overshoot:
- JS step
933correctly packed gas spore27turns at:(29,14)(28,13)
- but then also pulled in:
^movemon_turn[27@27,13 ...]
- C step
933does not contain that next gas-spore turn - C only reaches
27,13as the result of the priorm_move()inside step933; the actual turn at27,13belongs to gameplay step934
- JS step
- Keepable generalized fix:
- make
domove()report explicit no-time stop causes - currently useful causes:
travel_path_exhaustedvisible_hostile_while_running
- only grant the extra timed turn for
travel_path_exhausted - do not grant it for
visible_hostile_while_running
- make
- Validation:
seed031_manual_direct.session.json- first RNG divergence moved from gameplay step
933to940 - matched RNG improved to
34575/51561 - matched events improved to
19208/28950
- first RNG divergence moved from gameplay step
- still green:
t11_s755_w_covmax9_gpt11_s756_w_covmax10_gptheme15_seed986_wiz_artifact-wish_gameplaytheme35_seed2320_wiz_artifact-combat2_gameplay
- Lesson:
- the structural accepted-command loop is the right family
- but the stop condition must be modeled by explicit C-faithful stop causes,
not by a coarse
savedRun === 8rule
2026-03-21: seed031 on updated main re-exposed an earlier gas-spore death seam; fixing xkilled()’s exploding-monster path moved first divergence from 488 to 997
- After pulling newer
main(including theseed033levelgen fix), the authoritativeseed031first divergence onmainwas no longer the older step-940throw-flight seam. - Current
mainfirst diverged at gameplay step488during thrown-dart resolution against a gas spore:- JS:
rnd(20)=8 @ throwit(...)^die[165@63,7]rn2(6)=2 @ killed(...)rn2(3)=0 @ xkilled(...)
- C:
- same hit roll and death
- then the gas-spore death path continues through the exploding-monster cleanup before later throw bookkeeping and pet/object work
- JS:
- Root cause on JS
main:corpse_chance()already knew thatAT_BOOMmonsters must consume the first explosion-damage rollmon_explodes()already existed- but
xkilled()never actually invoked the exploding-monster branch for hero-killed gas spores
- C shape from
mon.c:xkilled()does treasure-drop RNG first- then
corpse_chance(...) - and for
AT_BOOMmonsters,corpse_chance(...)itself callsmon_explodes(...)and returns false
- Keepable narrow JS fix on current
main:- in
xkilled(), after the treasure-drop section and before normal corpse generation:- detect
AT_BOOM - call
corpse_chance(mon)to preserve the first C damage-roll side effect - then
await mon_explodes(mon, atk, map, player) - skip normal corpse generation for that death
- detect
- in
adtyp_to_expltype(), mapAD_PHYStoEXPL_NOXIOUSto match C’s gas-spore explosion display type
- in
- Validation:
seed031_manual_direct.session.json- first RNG divergence moved from gameplay step
488to997 - matched RNG improved to
35673/51561 - matched events improved to
20258/28950
- first RNG divergence moved from gameplay step
t11_s755_w_covmax9_gp.session.json- still green
- Lesson:
- after upstream pulls, do not assume the active seam is still the one from the previous branch-local campaign
- re-establish the authoritative first divergence on current
mainbefore resuming structural work
2026-03-21 - seed031 apply/pick-axe prompt and occupation ownership
- Session:
test/comparison/sessions/seed031_manual_direct.session.json - Starting point on current
mainafter the exploding-monster fix:- first RNG divergence at gameplay step
997 - matched RNG
35673/51561 - matched events
20258/28950
- first RNG divergence at gameplay step
- Raw step drilldown around the resumed
#applycorridor (993..999) exposed a separate structural bug family earlier in the same session:- JS step
995was burning the selection key on an extra prompt-owned^more[...]boundary - JS step
996then performed the wield-time monster/timed-turn block that C already performed on995 - after direction acceptance, JS also failed to arm the actual digging
occupation in the runtime owner that
_gameLoopStep()drains
- JS step
- Root causes:
handleApply()rendered the selection prompt and the dig direction prompt viaputstr_message(), which leftmessageNeedsMore=trueand inserted a fake command-boundary--More--before the next selection key.handleApply()accepted the pick-axe direction but did not calluse_pick_axe(); it only returned{ tookTime: true }.use_pick_axe2()setplayer.occupation = dig, but the active runtime drainsgame.occupation, so the dig occupation never became live in the owner that_gameLoopStep()uses.
- Keepable C-shaped JS fixes:
- render the
#applyinventory-selection prompt and pick-axe direction prompt as prompt-owned topline text usingdisplay.putstr(...),topMessage, and cursor placement, withmessageNeedsMore=false, matching other prompt code such asynFunction()instead of treating them as ordinarypline()-style messages. - after accepting a pick-axe direction, set
player.dx/dy/dz, calluse_pick_axe(), and returntookTimefrom that result. - in
use_pick_axe2(), armgame.occupationwith a dig callback object (occtxt,fn) while preserving the legacyplayer.occupation = digmarker for existing helper code.
- render the
- Result:
- the raw apply corridor now matches structurally:
- step
995: timed wield turn matches C - step
996: dig-direction prompt only - step
997:220filtered entries match C - step
998:25filtered entries match C - step
999:17filtered entries match C
- step
seed031_manual_direct.session.json- matched RNG improved to
36949/51561 - matched events improved to
21226/28950 - first RNG divergence moved from gameplay step
997to1057
- matched RNG improved to
t11_s755_w_covmax9_gp.session.json- still green
- the raw apply corridor now matches structurally:
- Lesson:
- prompt text that is really part of a live input owner must not be emitted as
a normal
putstr_message()boundary message. - for tool-use commands like pick-axe digging, matching C requires both:
- prompt ownership parity
- occupation ownership parity in
game.occupation, not only local/player bookkeeping.
- prompt text that is really part of a live input owner must not be emitted as
a normal
2026-03-21 - seed031 monster pickup how_lost and minventory merge parity
- Session:
test/comparison/sessions/seed031_manual_direct.session.json - Starting point on current
mainafter the stale-m_shotand apply/pick-axe fixes:- first RNG divergence at gameplay step
1060 - matched RNG
37324/51561 - matched events
21263/28950
- first RNG divergence at gameplay step
- Raw step drilldown around the pet-goal corridor (
1059..1060) showed that JS still had one fewer object on the floor pile at61,12than C. That caused one fewerobj_resists()roll before the kitten move-choice loop and pushed the first RNG divergence intodog_move(). - The first useful narrowing came from instrumenting the monster death-drop and
pickup path around monster
71:- JS was collapsing two crossbow-bolt pickups into one minventory stack at
gameplay step
1036 - C preserves the distinction long enough for both objects to survive into the later floor pile that the kitten scans
- JS was collapsing two crossbow-bolt pickups into one minventory stack at
gameplay step
- Root causes in JS:
throwit()marked thrown objects with the string'thrown'instead of numericLOST_THROWN.- downstream code such as
mergable()and autopickup logic expects integerhow_loststates, so the thrown-state distinction was silently lost.
- downstream code such as
mpickobj()did not apply the Chow_losttransitions for non-pet monsters:LOST_THROWN -> LOST_STOLENLOST_DROPPED -> LOST_NONE
addToMonsterInventory()was using a reducedcanMergeMonsterInventoryObj()predicate instead of the samemergable()logic that Cadd_to_minv()uses viamerged(&otmp, &obj).
- Keepable C-faithful JS fixes:
- write
obj.how_lost = LOST_THROWNinthrowit() - update
mpickobj()to perform the Chow_lostremapping before adding the object to monster inventory - make
canMergeMonsterInventoryObj()delegate tomergable()so monster inventory merging uses the same merge semantics as floor/inventory merging
- write
- Result:
seed031_manual_direct.session.json- matched RNG improved to
37850/51561 - matched events improved to
21756/28950 - first RNG divergence moved from gameplay step
1060to1082
- matched RNG improved to
t11_s755_w_covmax9_gp.session.json- still green
- New active seam:
- after this fix batch, the next
seed031first RNG divergence on currentmainis gameplay step1082 - raw evidence there shows a later
dog_move()candidate-choice mismatch, not the old monster-pickup pile-collapse seam
- after this fix batch, the next
2026-03-21 - seed031: pickup menu weapon subclass ordering was still non-C-faithful
- In the
seed031floor-pickup corridor around gameplay steps1068..1075, JS and C agreed on the visible multi-pickup control flow shape, but the actualPick up what?letter assignment still differed inside the weapon section. - Concrete JS bad menu before the fix at step
1070:acrude daggerbcrossbowccrossbow boltsddartfiron skull capggem
- C-grounded target for the same corridor:
c/f/gmust selectdart / iron skull cap / gem
- Root cause:
loot_classify()was not matching C’s weapon subclass buckets frominvent.c:- ammo/bolts
- launchers
- missiles like darts
- stackables like daggers/knives/spears
- other weapons
- polearms/lances
- JS had collapsed all weapons into a single fallback subclass because it:
- used a reduced non-C weapon bucketing rule
- read
od.skill, but object data actually exposesoc_skill/oc_subtyp
- Keepable JS fix:
- update
loot_classify()to readoc_skill/oc_subtyp - implement the C weapon subclass split exactly enough for pickup sorting:
[-P_CROSSBOW..-P_BOW] -> 1[P_BOW..P_CROSSBOW] -> 2- other negative missile skills ->
3 P_SPEAR/P_DAGGER/P_KNIFE -> 4P_POLEARMS/P_LANCE -> 6- otherwise
5
- update
- Resulting live JS menu at step
1070after the fix:acrossbow boltsbcrossbowcdartddartsecrude daggerfiron skull capggem
- Validation:
seed031_manual_direct.session.json- matched RNG improved from
37850/51561to38721/51561 - matched events improved from
21756/28950to22572/28950 - first RNG divergence moved from gameplay step
1082to1112
- matched RNG improved from
t11_s755_w_covmax9_gp.session.json- still green
theme15_seed986_wiz_artifact-wish_gameplay.session.json- still green
2026-03-22 - seed031: branch-local level changes were reusing stale depth cache entries
- After the pickup-sort fix,
seed031stalled at gameplay step1112with:- JS:
rn2(10)=9 @ changeLevel(do.js:1813) - C:
rn2(3)=2 @ getbones(bones.c:643)
- JS:
- Investigation showed this was not a missing
getbones()implementation. JS really was callinggetbones(), but only when first generating:- depth
2at gameplay step288 - depth
3at gameplay step467
- depth
- The decisive trace at the live seam:
- step
467:from=0:2, branch stair, destination1:1 - step
1113:from=1:1, ordinary downstairs,branch=0 - no fresh depth-2 bones roll occurred at step
1113
- step
- Root cause:
changeLevel()still had a legacy depth-only cache fallback:targetDnum === currentDnum && game.levels[depth]
game.levels[depth]is keyed only by branch-localdlevel- after visiting DoD
0:2, descending later from Mines1:1to Mines1:2wrongly reused the stale cached DoD0:2map because both sharedepth=2 - C keys level identity by full
dnum:dlevel, so it generated the unseen Mines1:2level and consumed the expectedgetbones()/levelgen RNG there
- Keepable JS fix:
- keep the existing
levelsByBranch[dnum:dlevel]cache as the authoritative branch-aware lookup - only allow the legacy
game.levels[depth]fallback when the cached map’s stamped identity matches the destination exactly:_genDnum === targetDnum_genDlevel === depth
- use a local
nextMapselection path so a cache miss cannot accidentally leavegame.mappointing at the current level and suppress generation
- keep the existing
- Result:
seed031_manual_direct.session.json- matched RNG improved from
38721/51561to40593/51561 - matched events held at
22572/28950 - first RNG divergence moved from gameplay step
1112to1113
- matched RNG improved from
- guardrails:
t11_s755_w_covmax9_gp.session.jsonstill greentheme15_seed986_wiz_artifact-wish_gameplay.session.jsonstill green
- New active seam:
- after fixing the stale cross-branch cache reuse,
seed031now diverges deeper inside Mines level generation at step1113 - current first mismatch:
- JS:
rn2(100)=21 @ sp_amask_to_amask(sp_lev.js:6251) - C:
rn2(3)=0 @ induced_align(dungeon.c:2006)seed031:minefillscripted monster alignment should be unaligned
- JS:
- after fixing the stale cross-branch cache reuse,
- After fixing the cross-branch level cache collision,
seed031_manual_directre-exposed a later Mines level-generation seam at gameplay step1113:- JS:
rn2(100)=21 @ sp_amask_to_amask(sp_lev.js) - C:
rn2(3)=0 @ induced_align(dungeon.c:2006)
- JS:
- Focused JS tracing showed the active
sp_amask_to_amask('random')calls were inside protofileminefillgeneration:- early branch-entry generation:
live=0:2 ctx=1:1 - later Mines descent generation:
live=1:1 ctx=1:2 - in the later seam JS was still deriving
dungeonAlign=A_LAWFULfrom Mines branch context and therefore burning thern2(100)gate.
- early branch-entry generation:
- The narrow C-faithful fix in
js/sp_lev.jsis:- when
specialName === 'minefill', forcedungeonAlign = A_NONEforsp_amask_to_amask('random') - keep other special-level alignment cases unchanged
- when
- Validation:
seed031_manual_direct.session.json- first RNG divergence moved
1113 -> 1127 - matched RNG
40593 -> 42621 - matched events
22572 -> 23195
- first RNG divergence moved
t04_s705_w_minefill_gp.session.json: PASScoverage/maze-mines-digging/t04_s706_w_minetn1_gp.session.json: PASS
- New frontier after this fix is later pet/object handling around step
1127, not the oldsp_amask_to_amask()/induced_align()seam.
2026-03-22 - seed031: gnome candle quantity and hero-hit mbhitm() reveal path
- After the
minefillalignment fix,seed031stalled in the later pet/object corridor at gameplay step1127. - Focused event and mapdump work showed the apparent pet divergence was
downstream of an earlier object-state mismatch:
- JS created a dying gnome’s wax candle as quantity
4 - C had that same gnome carrying a single candle
- by the live seam this changed the floor pile and pet
dog_goal()scan
- JS created a dying gnome’s wax candle as quantity
- The decisive JS trace was that candle object
oid=329entered play fromm_initinv()during gnome generation, not from floor merging or pet pickup:mongets()created the gnome candle stack- when the gnome died, JS dropped
wax candle x4 - C’s recorded session dropped a single candle there
- C source of truth in
makemon.cis explicit:- for gnome candles it does
mksobj(...) - then forces
otmp->quan = 1 - then recomputes
otmp->owt = weight(otmp)
- for gnome candles it does
- The narrow keepable JS fix in
m_initinv()is therefore:- keep the existing
mongets()ownership path intact - immediately normalize the returned candle object to:
quan = 1owt = weight(otmp)
- keep the existing
- While validating that fix, JS exposed a separate latent bug in
mbhitm():- when a monster wand beam hit the hero, JS still ran the post-hit
reveal_invis/canspotmon(...)path - C passes
&youmonstthere, but JS was passing the raw player object - that player object has
x/y, notmx/my, and JS crashed incanspotmon()/cansee()
- when a monster wand beam hit the hero, JS still ran the post-hit
- The narrow fix in
mbhitm()is:- gate the invisible-target postlude with
!hits_you - this matches the effective C behavior for the hero-hit path and avoids treating the hero as a malformed monster object
- gate the invisible-target postlude with
- Validation:
seed031_manual_direct.session.json- matched RNG improved
42621 -> 43999 - matched events improved
23195 -> 24403 - first RNG divergence moved
1127 -> 1150
- matched RNG improved
t04_s705_w_minefill_gp.session.json: PASScoverage/maze-mines-digging/t04_s706_w_minetn1_gp.session.json: PASScoverage/covmax-round7/t11_s755_w_covmax9_gp.session.json: PASScoverage/artifact-use/theme15_seed986_wiz_artifact-wish_gameplay.session.json: PASS
- New active seam after this batch is deeper in the same later pet/object
corridor around gameplay step
1150, not in the old gnome-candle setup or the hero-hit wand crash.
2026-03-22 - seed031: aklys stand-off range unblocks current-square monster pickup
- After the gnome-candle and hero-hit
mbhitm()fixes,seed031stalled at gameplay step1150in an apparent pet/object corridor. - Focused trace work narrowed the first-cause mismatch to monster
166(a gnome lord) immediately after the hero hit it with a dart:- JS throw tracing showed the dart already landed correctly at
42,7 - C then had the gnome lord pick up that same dart on its turn
- JS instead moved the gnome lord away and only diverged later in downstream monster-turn RNG
- JS throw tracing showed the dart already landed correctly at
- The decisive JS state at the
getitemsgate was:appr = 1inLine = 1- wielded weapon
otyp 80, which is anaklys
- C source of truth in
monmove.cis thatm_balks_at_approaching()has a dedicated throw-and-return-weapon branch:autoreturn_weapon(mwep) != 0returnsappr = -2- it also sets a preferred stand-off range via
pdistmin/pdistmax
- JS had the downstream
appr === -2movement logic inm_move()already, but never produced-2becausem_balks_at_approaching()omitted theautoreturn_weapon()branch entirely. - The faithful JS fix is:
- import
autoreturn_weapon()intomonmove.js - return
-2fromm_balks_at_approaching()when the monster wields an autoreturn weapon like an aklys - compute
preferredRangeMin = 4andpreferredRangeMax = arw.rangeinm_move()whenappr === -2
- import
- That makes JS match the C stand-off behavior for aklys users, which in this corridor re-enables current-square item search/pickup instead of walking off the freshly-landed dart.
- Validation:
seed031_manual_direct.session.json- matched RNG improved
43999 -> 44592 - matched events improved
24403 -> 24783 - first RNG divergence moved
1150 -> 1173
- matched RNG improved
t04_s705_w_minefill_gp.session.json: PASScoverage/maze-mines-digging/t04_s706_w_minetn1_gp.session.json: PASScoverage/covmax-round7/t11_s755_w_covmax9_gp.session.json: PASScoverage/artifact-use/theme15_seed986_wiz_artifact-wish_gameplay.session.json: PASS
- New active seam after this fix is later still, around gameplay step
1173; the earlier gnome-lord current-square dart pickup mismatch is no longer the first-cause divergence.
2026-03-22 - seed031: await monster wand-hit death path before continuing beam travel
- After the aklys stand-off fix,
seed031next diverged at gameplay step1173in a monster offensive-item corridor. - C ground truth there was:
- monster
165dies from a monster-fired striking wand hit corpse_chance()immediately consumesrn2(3)make_corpse()emits^corpse[165,41,5]
- monster
- JS had two coupled problems in
mbhit()/mbhitm():mbhit()invoked asyncfhitm(...)without awaiting it, so the beam could keep consuming later RNG before the struck monster’s death path finishedmbhitm()’sWAN_STRIKINGmonster-hit branch subtracted lethal damage but did not run the non-hero monster-death path afterward
- The faithful fix in
js/muse.jsis:- await
fhitm(...)insidembhit()for both hero and monster hits - when striking-wand damage kills a monster, route through
monkilled(mtmp, '', 0, map, player)somondied() -> corpse_chance() -> make_corpse()happens in-order before beam traversal proceeds
- await
- A focused trace on monster
165confirmed the repaired sequence:monkilled-entermondied-entermondied-after-corpse-chance makeCorpse=1mondied-make-corpseat41,5
- Validation:
seed031_manual_direct.session.json- matched RNG improved
44592 -> 44709 - matched events improved
24783 -> 24808 - first RNG divergence moved
1173 -> 1175
- matched RNG improved
t04_s705_w_minefill_gp.session.json: PASScoverage/covmax-round7/t11_s755_w_covmax9_gp.session.json: PASS
- New active seam after this batch is later monster/object handling around
gameplay step
1175, not the old striking-wand corpse-generation gap.
2026-03-22 - seed031: restore monster-wand floor-object hits via bhito
- After the monster wand-hit death fix,
seed031next diverged at gameplay step1175immediately after^corpse[165,41,5]. - C ground truth there showed one extra floor-object interaction before beam
damage continued:
rn2(100)=86 @ obj_resists(zap.c:1467)- then the later
mbhitm()damage rolls
- The root cause was in
js/muse.js:- C passes
bhitointombhit()for monsterWAN_TELEPORTATION/WAN_UNDEAD_TURNING/WAN_STRIKING - JS was still passing
null, so monster-fired wand traversal never hit floor objects at all - JS
fhito_loc()was also synchronous, so it could not call async floor object handlers faithfully
- C passes
- The faithful fix is:
- import
bhitointojs/muse.js - pass
bhitointombhit()for those monster wand paths - make
fhito_loc()async and awaitfhito(otmp, obj, map)
- import
- This restores the missing C object-resistance / floor-object side effects in monster-fired wand beams without changing comparator or replay behavior.
- Validation:
seed031_manual_direct.session.json- matched RNG improved
44709 -> 45313 - matched events improved
24808 -> 25141 - first RNG divergence moved
1175 -> 1199
- matched RNG improved
t04_s705_w_minefill_gp.session.json: PASScoverage/maze-mines-digging/t04_s706_w_minetn1_gp.session.json: PASScoverage/covmax-round7/t11_s755_w_covmax9_gp.session.json: PASScoverage/artifact-use/theme15_seed986_wiz_artifact-wish_gameplay.session.json: PASS
- New active seam after this batch is later still, in a monster throw corridor
around gameplay step
1199:- JS first RNG:
rn2(5)=3 @ dochug(monmove.js:847) - C first RNG:
rn2(6)=3 @ thrwmu(mthrowu.c:1231)
- JS first RNG:
2026-03-22 - seed031: don’t reset ux0/uy0 in generic command parsing
- After the monster-wand floor-object fix,
seed031next diverged in a dwarf throw corridor around gameplay step1199. - C ground truth there showed the throw should be suppressed by the
thrwmu()retreat gate:rn2(6)=3 @ thrwmu(mthrowu.c:1231)- non-zero result means return early, so no dagger throw that turn
- JS was incorrectly admitting the throw because
js/cmd.jsunconditionally resetgame.ux0/game.uy0for every parsed key. - That comment was wrong. C does not refresh
u.ux0/u.uy0in genericcmd.cparsing; the real writes happen in movement/relocation paths like:hack.c:u.ux0 = u.ux; u.uy0 = u.uy;- plus teleport/trap/hurtle special movement sites
- Because JS reset
ux0/uy0too broadly, a--More--/prompt-owned corridor left the dwarf seeing:- current hero position == prior hero position
retreating = 0- so JS threw a dagger when C still returned early
- The faithful fix is:
- remove the unconditional
game.ux0/game.uy0reset fromjs/cmd.js - keep
ux0/uy0owned by actual movement/relocation sites such asjs/hack.jsand teleport handling
- remove the unconditional
- Validation:
seed031_manual_direct.session.json- matched RNG improved
45313 -> 47204 - matched events improved
25141 -> 26054 - first RNG divergence moved
1199 -> 1237
- matched RNG improved
t04_s705_w_minefill_gp.session.json: PASScoverage/maze-mines-digging/t04_s706_w_minetn1_gp.session.json: PASScoverage/covmax-round7/t11_s755_w_covmax9_gp.session.json: PASScoverage/artifact-use/theme15_seed986_wiz_artifact-wish_gameplay.session.json: PASS
- New active seam after this batch is later still, in an occupation/eat
ownership corridor around gameplay step
1237:- JS first RNG:
rn2(20)=7 @ handleEat(eat.js:1986) - C first RNG:
rn2(40)=7 @ dochug(monmove.c:758)
- JS first RNG:
2026-03-22 - seed031: resume existing meals through start_eating()
- After the
ux0/uy0retreat-state fix,seed031next diverged in an eat/occupation corridor around gameplay step1237. - Raw comparison showed JS consuming corpse RNG inline in
handleEat()after turn-end:- JS:
rn2(20)=7 @ handleEat(eat.js) - C: only
>set_occupation/<set_occupation, then monster turns begin
- JS:
- A gated trace on
handleEat()showed the concrete mismatch:- repeated
ecommands were selecting the same in-progress corpse meal - JS re-ran
eatcorpse()on each resume - C
doeat()has a dedicatedotmp == victual.piecebranch which resumes throughstart_eating()and does not re-run fresh-corpse setup
- repeated
- The faithful fix in
js/eat.js:- add the missing resume-meal branch before fresh food setup
- if the selected item is
game.svc.context.victual.piece, preserve the existing victual timing state and callstart_eating(...) - do not call
eatcorpse()again for resumed meals - repair the previously dormant
eatfood()helper so the now-used resume path passes its realgame/playerarguments and floor checks
- Validation:
seed031_manual_direct.session.json- matched RNG improved
47204 -> 47441 - matched events improved
26054 -> 26377 - first RNG divergence moved
1237 -> 1241
- matched RNG improved
t04_s705_w_minefill_gp.session.json: PASScoverage/maze-mines-digging/t04_s706_w_minetn1_gp.session.json: PASScoverage/covmax-round7/t11_s755_w_covmax9_gp.session.json: PASScoverage/artifact-use/theme15_seed986_wiz_artifact-wish_gameplay.session.json: PASS
node scripts/test-unit-core.mjsstill reports two existing pickup-area failures caused by the unrelated dirtyjs/pickup.jsprobe:command_loot_meta.test.jspickup_compare_discovery_message.test.js
- New active seam after this batch is later still, in post-eat monster logic
around gameplay step
1241:- JS first RNG:
rn2(3)=0 @ dochug(monmove.js:847) - C first RNG:
rn2(5)=0 @ distfleeck(monmove.c:539)
- JS first RNG:
2026-03-22 - seed031: chest take-out submenu was erasing map rows under the menu
- While chasing the long-running
seed031gameplay seam, the earliest authoritative comparable visible divergence was much earlier than the RNG split: gameplay screen step39inside a#lootchest take-out submenu. - The actual compared gameplay steps there are container prompts, not startup
or engraving prompts:
- step
35:Do what with the chest? - step
36:Take out what type of objects? - step
38/39:Take out what?
- step
- JS was clearing too much of the underlying map when transitioning from the
class submenu to the item submenu in
containerMenu():clearMenuOptionRows()always blanked rows2..10- then the
Take out what?submenu only redrew rows2..6 - this left rows
7..10either blank or stale class-menu text while C had already restored the map behind the menu
- Narrow faithful fix in
js/pickup.js:- let
clearMenuOptionRows()accept an explicit row range - before drawing the
Take out what?submenu, rerender the underlying map/status/cursor - clear only the rows actually occupied by that submenu
- let
- Result:
seed031first screen divergence moved later from step39to step41- screen matches improved
1281 -> 1285 - color matches improved
11234 -> 11244 - cursor matches improved
411/415 -> 415/419 - RNG/events were unchanged; the live gameplay seam remains at step
1241
- Targeted validation:
seed031_manual_direct.session.json: improved screen/color/cursor, same RNG/eventscoverage/locks-containers-pickup/t02_s714_w_cardbox_gp.session.json: PASScoverage/covmax-round7/t11_s755_w_covmax9_gp.session.json: PASS
2026-03-22 - dbgmapdump: C-side manual-direct captures must use normalized replay keys
- While bisecting
seed031, earlydbgmapdump --c-sidecheckpoints looked impossible:- compared gameplay step
20in JS had the hero near(63,10) - C-side
dbgmapdumpwas reporting the hero around(42,11) - that contradicted the main session comparator, which does not diverge on screen until much later
- compared gameplay step
- Root cause was in
test/comparison/dbgmapdump.js:- JS capture used
prepareReplayArgs(), which normalizes manual-direct sessions by folding chargen/startup keys out of gameplay - C capture still let
capture_step_snapshot.pyextract raw session keys directly - for manual-direct sessions, that made early C
--c-sidesnapshots replay a different key stream than the JS side
- JS capture used
- Fix:
dbgmapdumpnow writes the exact normalized replay key stream toreplay_keys.json- C-side capture is invoked with
--keys-json <that file> - this keeps JS and C mapdump capture on the same gameplay-step key stream
- Validation:
- rerunning
dbgmapdump --c-sideforseed031step20now produces a sensible aligned C checkpoint (u_ux=62,u_uy=10) instead of the bogus pre-normalization checkpoint - the output directory now contains the emitted
replay_keys.json, which is the exact key stream used for both sides
- rerunning
- Practical rule:
- for manual-direct parity work, trust
dbgmapdump --c-sideonly when the C capture is driven by the same normalized replay keys as JS
- for manual-direct parity work, trust
2026-03-22 - dbgmapdump: manual-direct C snapshots need replay chargen metadata and post-step capture phases
- A second
seed031dbgmapdumpproblem remained after normalizing replay keys:- C-side
capture_step_snapshot.pyswitches to preset-chargen mode whenever--keys-jsonis used - raw manual-direct sessions like
seed031storeoptions.name/role/race/ gender/align = null - that meant C-side mapdump capture was still starting the wrong character even though the key stream itself was now normalized
- C-side
- Fix in
test/comparison/dbgmapdump.js:- emit a temporary
capture_session.json - for
regen.mode === "manual-direct-live", fill itsoptions.*chargen fields fromprepareReplayArgs(...).opts.initOpts.character - invoke C capture on that temporary session, not the raw fixture
- emit a temporary
- A separate boundary bug was also present in
test/comparison/c-harness/capture_step_snapshot.py:- JS mapdumps are post-step snapshots
- C capture was targeting
auto_inp_*, which fires when a key is read, before that command has finished mutating state - for post-step parity snapshots, the right boundary is
auto_step_*on the nextfresh_cmd
- Fix in
capture_step_snapshot.py:- prefer
auto_step_<expected>as the primary checkpoint phase for mapdump capture - keep
auto_inpbookkeeping as diagnostic metadata only
- prefer
- Validation:
dbgmapdump --c-sideforseed031gameplay step20now lands on the real Tourist/manual-direct state- JS and C now agree on the meaningful state at that checkpoint:
- hero position
(63,10) - pet at
(62,10) - monster
70at(19,8) - monster
158at(44,4)
- hero position
- remaining strict
U/C/Gvector differences are snapshot-format noise, not the old bogus chargen/key-stream mismatch
- Practical rule:
- for manual-direct mapdump bisects, do not trust early C snapshots unless:
- the normalized replay keys are passed through
--keys-json - the temporary capture session carries inferred chargen metadata
- the capture phase is a post-step
auto_step_*boundary rather than anauto_inp_*key-read boundary
- the normalized replay keys are passed through
- for manual-direct mapdump bisects, do not trust early C snapshots unless:
2026-03-22 - dbgmapdump: report prompt-owned steps that have no post-step C boundary
- After fixing manual-direct C capture, early
seed031bisects still looked erratic because many gameplay-step numbers are not completed command boundaries on the C side. - Example:
dbgmapdump --c-side --steps 44expectedauto_step_43- C only reached
auto_inp_46_key_104_getlin_0_moveloop_1 - that means the requested gameplay step is prompt-owned or intra-command;
there is no
fresh_cmdcheckpoint to compare against yet
- Fix in
test/comparison/dbgmapdump.js:- classify C capture failures by phase kind
- explicitly report when an expected
auto_step_*fell through to anauto_inp_*checkpoint - print a note that the step appears prompt-owned / intra-command
- Practical rule:
- for early
seed031mapdump bisects, first choose steps that actually have a post-stepauto_step_*boundary - if
dbgmapdumpreportsno post-step auto_step boundary before next input, do not treat that step as a valid post-step state checkpoint
- for early
2026-03-22 - dbgmapdump: expand JS victual state instead of collapsing it to an object ref
- Late
seed031analysis around the live seam needed apples-to-apples meal state, but JS mapdumps were flatteningsvc.context.victualinto a compact stable object ref like{"ref":"obj","o_id":379}. - That hid the fields that C snapshot JSON already exposes:
piece_o_ido_idusedtimereqtimenmodcanchokefullwarneatingdoreset
- Fix in
test/comparison/dbgmapdump.js:- normalize JS
victualinto scalar fields matching the C snapshot shape - exempt the top-level
victualcontext object from generic stable-ref collapsing, so those fields survive flattening into theC...row
- normalize JS
- Result:
- JS late mapdumps now show full meal state directly in the compact context
line, for example:
victual.eating=truevictual.o_id=379victual.usedtime=8victual.reqtime=13
- JS late mapdumps now show full meal state directly in the compact context
line, for example:
- Concrete
seed031evidence from this tool improvement:- JS late checkpoint still carries live meal state:
step1243.mapdumphasvictual.eating=true,victual.o_id=379,victual.usedtime=8,victual.reqtime=13
- corresponding C late checkpoint has fully cleared meal state:
piece_o_id=0,o_id=0,usedtime=0,reqtime=0,nmod=0,canchoke=0,fullwarn=0,eating=0,doreset=0
- JS late checkpoint still carries live meal state:
- Practical rule:
- when late parity drift looks like prompt/occupation confusion, expose structured context state first; compact object refs can hide exactly the field that differentiates JS from C
2026-03-22 - dbgmapdump: add debug-only monster movement/flee state to N rows
- Late
seed031work around the giant-ant seam needed more than the old compactNrow provided. - The previous
Nformat only carried:- id, position, monster index, hp, hpmax/tame-like placeholders, sleeping, frozen/trapped-like placeholders, disguise fields, inventory count
- That was not enough to diagnose the live seam at step
1241, because the branch depends on:- current movement points
mfleemfleetim- apparent target (
mux,muy) - sight/blind state
- Fix:
js/dungeon.jsnow emits a debug-only expandedNrow forbuildDebugMapdumpPayload()while leaving ordinary harness/session mapdump payloads unchangedtest/comparison/dbgmapdump.jsnow uses the same expanded field layout for C-side compact mapdumps
- Expanded debug-only
Nrow layout:id,x,y,mnum,mhp,movement,mflee,mfleetim,mpeaceful,mcanmove,mcansee_or_canseemon,mblinded,mux,muy,minvcount
- Concrete
seed031result at late checkpointstep1243:- JS ant
374:374,47,14,0,4,24,1,11,0,1,0,0,42,7,0
- C ant
374:374,48,14,0,4,12,1,7,0,1,0,0,42,7,0
- JS ant
- Interpretation:
- by
step1243, JS and C already disagree on:- position
- movement bank
- flee timer
- but still agree on:
mflee=1- apparent target
mux,muy = 42,7
- by
- Practical rule:
- when a late
dochug()seam looks like an extraset_apparxy()ordistfleeck()RNG call, first compare debug-onlyNrows; movement and flee-duration drift can be the real root cause even whenmux/muymatch
- when a late
2026-03-22 - resumed eatfood() floor-object checks must pass game.map
- The late
seed031eating seam included a resumed-meal failure mode where JSeatfood()was dropping intodo_reset_eat()even though the active food object still existed on the hero square. - C
eatfood()explicitly checks floor presence with:if (food && !carried(food) && !obj_here(food, u.ux, u.uy)) food = 0;
- JS had the same intended check, but it called:
obj_here(food, player.x, player.y)without passinggame.map.
- In JS,
obj_here()needs the map argument to inspect the floor object chain, so resumed floor-food meals could falsely hit the!foodbranch and rundo_reset_eat(). - Faithful fix in
js/eat.js:- change to
obj_here(food, player.x, player.y, game?.map)
- change to
- Validated effect:
seed031_manual_direct.session.json- baseline:
rng=47441/51561,events=26377/28950, first RNG divergence at step1241 - after fix:
rng=47502/51561,events=26421/28950, first RNG divergence still at step1241 - first bad RNG shifts later within the same gameplay step:
- before:
rn2(3)=0 @ dochug(monmove.js:847)vsrn2(5)=0 @ distfleeck(monmove.c:539) - after:
rn2(100)=70 @ dochug(monmove.js:847)vsrn2(8)=2 @ dog_goal(dogmove.c:582)
- before:
- baseline:
- targeted checks remained green:
t11_s755_w_covmax9_gp.session.jsontheme04_seed680_wiz_eat-food_gameplay.session.jsont04_s993_w_eatground_gp.session.json
- Practical rule:
- for resumed floor-object occupations, always pass
game.mapintoobj_here(); otherwise the C-faithful “food still here?” check silently collapses into a false reset
- for resumed floor-object occupations, always pass
2026-03-22 - delobj() must honor floor map context
- The next late
seed031eat seam turned out not to be another prompt bug. - Object-id tracing showed JS calling
done_eating()for corpse379at the late seam, but then selecting that same corpse again from the floor two steps later. - That cannot happen in C:
done_eating()ends withuseupf(piece, 1L)for floor fooduseupf()callsdelobj(otmp)and removes the floor object
- In JS,
js/invent.jshad a broken wrapper:delobj(obj)ignored the optionalmapargument that many callers passed- it called
delobj_core(obj, false), so floor deletions without an explicit global map fallback could leave the object behind inmap.objects
- Faithful fix:
- change
delobj()to accept optional(obj, map = null, force = false) - resolve
map || _gstate?.lev || _gstate?.map - pass that to
delobj_core()
- change
- Validated effect:
seed031_manual_direct.session.json- before:
rng=47502/51561,events=26421/28950, first RNG divergence step1241 - after:
rng=47522/51561,events=26451/28950, first RNG divergence still step1241 - the late seam moved deeper into the same step:
- JS still first diverges in
dog_goal()-adjacent late pet logic - but with 20 more matched RNG calls and 30 more matched events overall
- JS still first diverges in
- before:
- control sessions stayed green:
t11_s755_w_covmax9_gp.session.jsontheme04_seed680_wiz_eat-food_gameplay.session.jsont04_s993_w_eatground_gp.session.json
- nearby manual-direct regression check stayed unchanged:
seed032_manual_direct.session.jsonstill first diverges at step144
- Practical rule:
- any JS wrapper around
delobj()must preserve floor map ownership; if the map context is dropped, floor objects can survive semantic deletion and create downstream parity drift that looks like prompt or AI logic
- any JS wrapper around
2026-03-22 - floorfood() / objectsAt() must agree on top-of-pile order
- After the
obj_here(map)anddelobj(map)fixes, the remaining lateseed031seam still looked like a pet/object mismatch, but the decisive state error was earlier: the hero resumed the wrong floor corpse. - C
floorfood()chooses the top floor-chain object at the hero square. NetHack floor chains are newest-first. - JS
map.objectsAt()is oldest-first, butjs/eat.jshandleEat()was taking:const floorItem = floorFoods[0]
- On the late
(42,7)pile inseed031, that bound JSvictual.pieceto the older corpse379instead of the newer corpse394. - Concrete evidence:
- JS debug mapdump at step
1240:victual.piece_o_id=379- two corpses still present on
(42,7)
- JS debug mapdump at step
1241:victualcleared- one corpse removed from
(42,7) - corpse
394still present
- C event logs in the same corridor showed the decisive
^remove[263,42,7]before the later petdog_goal()scan that no longer treated394as available food
- JS debug mapdump at step
- Faithful fix:
- choose the newest floor food:
const floorItem = floorFoods[floorFoods.length - 1]
- choose the newest floor food:
- Validated impact:
seed031_manual_direct.session.json- improved from first RNG divergence step
1241to1333 - matched RNG calls improved
47522/51561 -> 51560/51732 - matched events improved
26451/28950 -> 28950/28952
- improved from first RNG divergence step
- controls stayed green:
t11_s755_w_covmax9_gp.session.jsontheme04_seed680_wiz_eat-food_gameplay.session.jsont04_s993_w_eatground_gp.session.json
- nearby regression check stayed stable:
seed032_manual_direct.session.jsonstill first diverges at step144
- Practical rule:
- any JS floor-item default selection built on
objectsAt()must explicitly translate oldest-first array order back into C floor-chain order before choosing the top/default item
- any JS floor-item default selection built on
2026-03-22 - after the floor-meal fixes, the live seed031 seam moved into tame-pet dog_invent()
- After the validated floor-meal fixes, the active
seed031seam is no longer best described as an eating-occupation identity bug. - Current authoritative baseline on
main:test/comparison/sessions/seed031_manual_direct.session.json- first RNG divergence at gameplay step
1313 - JS:
rn2(5)=2 @ dochug(monmove.js:847) - C:
rn2(9)=6 @ dog_invent(dogmove.c:416) - first event divergence at gameplay step
1315 - JS:
^distfleeck[33@42,9 in=1 near=0 scare=0 brave=0 saw=0 light=0 sanct=0] - C:
^dog_invent_decision[33@42,9 ud=8 act=0 otyp=-1 carry=0 rv=0]
- Authoritative comparison-window evidence around
1310..1315:- C-heavy at
1311,1312,1314,1315 - large JS payback spike at
1313
- C-heavy at
- Late JS debug state at step
1315still shows the housecat as a real tame pet with liveedogstate:- pet row:
37,42,9,33,17,22,0,0,1,1,1,0,44,11,1 - additional local inspection during this pass confirmed:
mtame=20edog.apport=1minventCount=1- carried item is an unworn, uncursed
dwarvish cloak
- pet row:
- That narrows the remaining fault:
- the live seam is now in tame-pet
dog_invent()/droppables()/ nearby pet-control branch selection - not in the already-fixed floor-meal object identity path
- the live seam is now in tame-pet
2026-03-22 - pet oeaten timing must shorten meating
- C
dog_nutrition()applieseaten_stat()to bothmtmp->meatingand the awarded nutrition whenobj->oeatenis non-zero. - JS
dog_nutrition()was still using full-food timing for partially eaten pet food. - In the late
seed031corridor, live JS tracing showed the housecat still in the eating lane across the authoritative pet seam:- step
1313:meating=6 - step
1315:meating=4 - step
1317:meating=3
- step
- That was the reason JS skipped the C-side
dog_move() -> dog_invent()lane on the matching late turns. - Faithful fix:
- in
js/dogmove.js, whenobj.oeatenis set, reduce bothmon.meatingandnutritwitheaten_stat()exactly as C does
- in
- Validated impact:
seed031_manual_direct.session.json- first RNG divergence improved
1313 -> 1333 - matched RNG calls improved
50883/51561 -> 51560/51732 - matched events improved
28348/28950 -> 28950/28952
- first RNG divergence improved
- controls stayed green:
t11_s755_w_covmax9_gp.session.jsontheme04_seed680_wiz_eat-food_gameplay.session.jsont04_s993_w_eatground_gp.session.json
- nearby regression check stayed stable:
seed032_manual_direct.session.jsonstill first diverges at step144
2026-03-22 - bones timing belongs in really_done(), and bones depth is branch-adjusted
- After the late eating and pet-food fixes,
seed031still had one final RNG mismatch at the death boundary:- JS had an extra earlier bones-side effect because
js/allmain.jscalledsavebones(game)from the moveloop death check - C does not do bones work there;
end.c:really_done()computesbones_ok = (how < GENOCIDED) && can_make_bones()after death handling has reached final endgame cleanup
- JS had an extra earlier bones-side effect because
- Faithful fix 1:
- remove the moveloop
savebones(game)call fromjs/allmain.js - compute
game.bonesOk = (how < GENOCIDED) && can_make_bones(game)injs/end.js:really_done()
- remove the moveloop
- That exposed the last actual logic mismatch:
- JS
can_make_bones()was using branch-localplayer.dungeonLevel - C
can_make_bones()usesdepth(&u.uz), which is branch-adjusted - in the fatal
seed031death step, JS saw level2and rolledrn2(1), while C saw depth4and rolledrn2(2)
- JS
- Faithful fix 2:
- in
js/bones.js, compute current depth fromdepth(player.uz || map.uz)instead ofplayer.dungeonLevel
- in
- Validated impact:
seed031_manual_direct.session.json- RNG improved from
51560/51561to51561/51561 - events remain
28950/28950 - the old late gameplay seam is gone; the remaining failure is screen-only
and much earlier:
- first screen divergence step
41
- first screen divergence step
- RNG improved from
- controls stayed green:
t11_s755_w_covmax9_gp.session.jsontheme04_seed680_wiz_eat-food_gameplay.session.jsont04_s993_w_eatground_gp.session.json
- nearby regression check stayed stable:
seed032_manual_direct.session.jsonstill first diverges at step144
- Practical rule:
- when C uses
depth(&u.uz), JS must not substitute branch-localdungeonLevel; Mines/Quest/other branch levels will drift exactly this way
- when C uses
2026-03-22 - remaining seed031 screen seams are tty/windowport details, not gameplay
- After the late eating/pet/bones fixes,
seed031_manual_direct.session.jsonwas gameplay-green (RNG 51561/51561,events 28950/28950) but still had early screen-only seams. - Three concrete tty-facing fixes moved the first screen divergence from step
41 -> 56 -> 137 -> 141 -> 144without touching gameplay parity:js/pickup.js- do not clear row 0 at the end of the chest take-out selector; C leaves
the pickup summary (
"m - a cyan spellbook. n - 2 swirly potions.") visible after the submenu closes - locked-container loot messages need C article helpers:
The(xname(container)) is locked.Hmmm, the(xname(container)) turns out to be locked.
- do not clear row 0 at the end of the chest take-out selector; C leaves
the pickup summary (
js/lock.js- autounlock prompts must use
yname(pick), notdoname(pick), so tty asks"Unlock it with your credit card?"rather than naming beatitude or article details
- autounlock prompts must use
js/render.js- the C tty harness is using the windowport
BL_CONDITIONpath, not the simple legacydo_statusline2()append order - that path renders conditions from
botl.c conditions[]sorted byrankingthenuseroption, so visible defaults likeHalluandStunmust follow tty order, not the hand-written legacy order
- the C tty harness is using the windowport
- Validation:
seed031_manual_direct.session.json- screens improved
1288/1351 -> 1322/1351 - colors improved
11285/11688 -> 11319/11688 - first screen divergence moved to step
144
- screens improved
- green control stayed green:
t11_s755_w_covmax9_gp.session.json
2026-03-22 - seed031 late screen seam is a hallucination display-stream mismatch, not gameplay
- After the gameplay lane went fully green, the remaining
seed031failure was a screen-only hallucination seam around the chest-gas corridor:- current committed baseline before further fixes:
seed031_manual_direct.session.json- RNG
51561/51561 - events
28950/28950 - first screen divergence at step
144 - JS row
17:####▒·@g···│(and later@&in a diagnostic experiment) - C/session row
17:####▒·@u···│
- current committed baseline before further fixes:
- A C rerecord with display-stream logging was useful:
NETHACK_RNGLOG_DISP=1 python3 test/comparison/c-harness/rerecord.py /tmp/seed031_dispdiag.session.json- in the aligned chest-trap corridor, C consumed:
- at the first hallucination-on step:
1monster display roll, then2object display rolls - later in
moveloop_core:1monster display roll, then2object display rolls
- at the first hallucination-on step:
- JS tracing established the corresponding local shape:
- step
141first writes the wrong glyph, not step144 - owner order on the bad cell:
make_hallucinated()writes one hallucinated monster glyphapply_dochug_postmove()rewrites the same cell twice- a later full sync/render rewrites it again
- temporary full-render cache bypass changed the wrong glyph (
g -> &) but did not move the first screen divergence step; keep that as evidence that redraw ownership matters, not as a fix
- step
- Important negative result:
- the missing C object-stream calls are not explained by visible floor-object
redraws during
see_objects() - step-local tracing showed
see_objects()visits nine object cells, but at the hallucination step only one of them is currently visible, and that one is the hero square - so the missing object-stream calls are coming from some other tty/display path, not from ordinary visible floor-object map redraw
- the missing C object-stream calls are not explained by visible floor-object
redraws during
- Practical constraint for future work:
- do not keep speculative render-cache or map-redraw tweaks here
- the next productive question is which tty-facing hallucination display path
in C is consuming those
random_objectcalls when JS is not
- 2026-03-22:
COSMIC_DISPLAY_LOGSMilestone 1/2 is now implemented as a shared C/JS owner/branch/display-RNG schema for screen-only seams.- C initial batch:
make_hallucinated()see_monsters()see_objects()see_traps()docrt_flags()newsym()_map_location()- contextualized
random_monster/random_objectlogs
- JS mirrors the same owner names, branch enums, map-location categories,
and inline
~drn2_disp[...]entries. - Important harness gotcha:
- custom
NETHACK_*env vars are not reliably inherited by an existing tmux server - short C traces such as
run_trace.pyneed either:- the vars embedded in the launched command string, or
tmux set-environment -g ...
- otherwise cosmic/display logging can appear “missing” even when the binary is correct.
- custom
- Regression check:
seed031_manual_direct.session.jsonremains gameplay-green after the instrumentation layer:- RNG
51561/51561 - events
28950/28950 - the old step-
41row-0 seam was a separate chest take-out teardown bug, not a hallucination/display-RNG seam - after that
js/pickup.jsfix, the remaining screen-only seam is still the hallucination lane at step144
- RNG
- C initial batch:
2026-03-22 — Session 30: seed032 travel termination, pickup sort LIFO, eating ordering
Travel termination overshoot and ghost continuation
When travel reached its target, three bugs conspired to make the player overshoot and waste subsequent replay keys:
- Stale
travelPath:domove_coredidn’t cleartravelPathbefore callingfindtravelpath. Whenfindtravelpathreturned false (player at target), the old path was reused, causing an extra move. - Ghost travel continuation:
_gameLoopStep’s travel fallback checkedtravelPathwithout checkingcontext.travel. Afternomul(0)→end_running(true)clearedcontext.travel, the staletravelPathre-triggereddotravel_target, consuming replay keys. - Run/travel batching:
_gameLoopStepusedreturnafterrunMovementRepeatSlice, causing the replay to advance keys between continuation steps. C’smoveloop_coreloops without reading keys. Changed tocontinue.
Impact: seed032 RNG 25% → 43%.
Pickup menu sort LIFO tiebreaker
C’s place_object() prepends to the floor chain (LIFO). C’s deterministic
qsort (patch 006) is stable — equal elements preserve their original index
order. Combined: when sortloot or pickup menus sort objects with identical
names/properties, the LIFO chain order determines which appears first
(newest object first).
JS’s pickup menu sort used a.o_id - b.o_id as tiebreaker (oldest first) —
exactly backwards from C. This caused two poisoned darts with different BUC
to swap invlet assignments: JS assigned the cursed dart to invlet ‘r’ while C
assigned the non-cursed one. The player then threw the cursed dart, triggering
the fumble check rn2(7) that C didn’t call, diverging the RNG stream.
Fix: Reverse tiebreaker to b.o_id - a.o_id (newest first = LIFO).
Impact: seed032 RNG 43% → 56%.
Lesson: Any sort comparator for floor objects must produce newest-first
ordering for equal keys. The C harness patches qsort for stability (patch
006), and JS’s Array.prototype.sort is stable per ES2019. The tiebreaker
must match C’s LIFO chain order.
xname poisoned weapon prefix
C’s xname() adds “poisoned “ prefix for weapons with opoisoned set.
JS only had this in doname(). This meant cxname_singular() (used by sort
comparators) returned “dart” instead of “poisoned dart”, potentially affecting
menu ordering and display.
Fix: Move “poisoned “ prefix from doname to xname_for_doname.
Eating effect ordering — cpostfx/fpostfx before item removal
C’s done_eating() calls cpostfx()/fpostfx() (eating effects like
eye_of_newt_buzz) BEFORE food_disappears() (item removal via
obj_resists). JS had the reverse order in both multi-turn and single-turn
eating paths: consumeInventoryItem() first, then effects.
This caused RNG divergence because obj_resists calls rn2(100) while
eye_of_newt_buzz calls rn2(3), and the call order matters.
Impact: seed032 RNG 56% → 68%.
Fog cloud gas regions — C has them, JS doesn’t
seed032’s next divergence (step 279, index 18620) is caused by JS’s fog
cloud monsters calling create_gas_cloud() (consuming rn2(3)) while C’s
fog clouds skip it because visible_region_at() returns true.
C-side diagnostic patch (029-fog-cloud-trace) confirmed: all four fog clouds
at positions (46,5), (46,6), (47,4), (48,6) have cd=0 vr=1 — gas regions
already exist at their positions BEFORE m_everyturn_effect runs. These
regions were created during level generation by a mechanism JS doesn’t
reproduce.
Status: Open. Need to find what creates gas regions during Dlvl:3 level generation in C.
seed033 encumbrance correction
Prior analysis was WRONG: the hero is NOT persistently SLT_ENCUMBER from game
start. C’s ^moveamt events (patch 028) confirm: hero is UNENCUMBERED
(wtcap=0) through step 208, becomes SLT_ENCUMBER at step 209 (picking up
rocks at pos 17,8), peaks at HVY_ENCUMBER at step 220, then returns to
UNENCUMBERED at step 380. JS has identical encumbrance transitions at the
same steps.
The divergence at step 338 (index 4358: JS rn2(5)@dochug vs C
rn2(19)@exercise) is a turn-boundary issue where JS and C are in different
phases of moveloop_core at the same RNG index. Root cause is NOT
encumbrance — it’s a subtle difference in the hero/monster turn interleaving
within the moveloop.
Status: Open. Both have identical moveamt/wtcap transitions. The drift source is elsewhere in the moveloop phase ordering.
2026-03-22 - seed031 step-41 screen seam was container prompt teardown, not cosmic display drift
- The first post-gameplay
seed031screen failure at step41turned out to be simpler than the cosmic-display investigation target:- C/session row
0:"m - a cyan spellbook. n - 2 swirly potions." - JS row
0: blank
- C/session row
- The compared gameplay steps there are the chest
#loottake-out submenu:- step
39:"Take out what?" - step
40: item menu - step
41: post-selection pickup summary line
- step
- Root cause in
js/pickup.js:out_container()already emits the correct C-shaped summary line viapline("%s - %s.", ...)- but
containerMenu()then unconditionally cleared row0when tearing down the submenu - that erased the valid summary message that C leaves visible after the menu closes
- Faithful fix:
- only clear row
0when no take-out message replaced the prompt:if (!didTake && typeof display?.clearRow === 'function') display.clearRow(0);
- only clear row
- Validated impact:
seed031_manual_direct.session.json- RNG remains
51561/51561 - events remain
28950/28950 - first screen divergence moves later from step
41to step144
- RNG remains
- nearby controls stay on their existing gameplay seams:
seed032_manual_direct.session.jsonstill first diverges at step279seed033_manual_direct.session.jsonstill first diverges at step338
- Practical rule:
- when a JS submenu emits a real topline message during teardown, do not
blindly clear row
0; match the C tty ownership of the surviving prompt vs message line
- when a JS submenu emits a real topline message during teardown, do not
blindly clear row
2026-03-23 - manual-direct replay after V4 cleanup still needs transformed startup semantics
- Removing the old manual-direct replay helpers in the V4 startup cleanup broke
seed031_manual_direct,seed032_manual_direct, andseed033_manual_directat startup even though the canonical sessions themselves were still valid. - The restored requirements were:
- compare gameplay against a transformed manual-direct view
- build replay args from that transformed view, not
session.raw - still compute startup simulation (
pick_align, tutorial levelgen) from the raw session - recover character metadata from raw chargen/confirmation/welcome screens, because the transformed startup screen may already be pure gameplay
- suppress the startup lore prompt during replay when that manual-direct startup has already been folded out of the comparison view
- After restoring those semantics, the manual-direct trio returned to their
expected frontiers:
seed031: RNG/events green, remaining screen seam at step409seed032: first RNG/event seam back at step280seed033: first RNG seam back at step338
2026-03-22 - hallucinating menu overlays need a frozen underlay
- Continuing the late
seed031screen-only work exposed a second container-menu repaint rule, distinct from the earlier topline teardown bug. - The important discriminator is hallucination:
- ordinary submenu redraws can repaint the menu/map band per key without changing parity-critical state;
- hallucinating submenu redraws cannot, because each fresh redraw can consume monster/object display-RNG and change fake glyphs even though gameplay state is unchanged.
- Practical model:
- when hallucination is active, treat the submenu as an overlay on top of a frozen underlay;
- preserve or restore that underlay while the submenu selection changes, rather than regenerating the visible map on every key.
- Why this matters:
- in the late
seed031chest-gas corridor, gameplay was already fully green (51561/51561RNG and28950/28950events); - the remaining divergence came from submenu-time repaint ownership only.
- in the late
- Debugging rule:
- for screen-only hallucination seams, ask first whether C is revealing an already-drawn underlay or truly performing a fresh redraw;
- do not assume that a visually harmless per-key
renderMap()is neutral once hallucination/display-RNG is active.
2026-03-22 - frozen-overlay snapshots must handle both browser and headless cell shapes
- The next
seed031screen seam after the frozen-underlay work was not a menu timing bug; it was a snapshot representation bug injs/pickup.js. - Root cause:
captureOverlayRows()assumeddisplay.grid[row][col]was always a cell object{ ch, color, attr }, which is true for the browser display;- but replay/session tests use the headless display, where:
display.grid[row][col]is just the character,- color lives in
display.colors[row][col], - attributes live in
display.attrs[row][col].
- Effect:
- the frozen overlay snapshot for the hallucinating nested take-out submenu captured mostly blank cells in replay,
- so restoring the submenu underlay wrote spaces instead of the saved map, even though the visible screen underlay was correct in C.
- Faithful fix:
- make
captureOverlayRows()normalize both display representations into the same{ ch, color, attr }snapshot format before restoration; - preserve the earlier row-
0message rule by skipping row0restore when a real take-out summary replaced the prompt.
- make
- Validated impact:
seed031_manual_direct.session.json- gameplay remains fully green:
51561/51561RNG,28950/28950events - first screen divergence moves later from step
145to step409
- gameplay remains fully green:
- nearby gameplay controls remain unchanged:
seed032_manual_direct.session.jsonstill first diverges at RNG step279seed033_manual_direct.session.jsonstill first diverges at RNG step338
- Practical rule:
- any replay-only snapshot/restore helper that reads terminal cells must be aware of both display backends;
- browser
Displayand headlessHeadlessDisplayare not structurally interchangeable even when they render the same final screen.
- 2026-03-22: manual/keylog rerecord timing probes must plumb per-step delay
overrides through the keylog replay path, not just
run_session.pygameplay replay.test/comparison/c-harness/rerecord.pyalready honoredregen.key_delays_sfor step-based gameplay sessions, but themanual-direct-live/ keylog path rebuilt sessions viakeylog_to_session.pywithout forwarding those overrides. That made targeted rerecord timing probes for screen-only seams invalid on live-recorded sessions likeseed031_manual_direct. The harness fix is:_build_keylog(...)inrerecord.pymust forwardregen.key_delays_sandsteps[i].capture.key_delay_sasNETHACK_KEY_DELAYS_Skeylog_to_session.pymust parse that env var, apply per-step delays during replay, and persist the effective override metadata back into the regenerated session’sregenblock This is observability/infrastructure only; it does not itself resolve the remainingseed031screen seam, but it makes the intended timing probe mechanically possible and auditable.
- 2026-03-22: public headless helpers used by unit tests must return a
command-ready game, not one still blocked on startup lore dismissal.
After the V4 startup cleanup,
createHeadlessGame()andHeadlessGame.start()could return withpendingPrompt.source === "startup_lore". The next key was consumed by that prompt instead of the command under test, which broke wizard command unit tests and the headless replay contract. The faithful fix for these public headless helpers is to clear the startup-lore prompt/topline state anddocrt()before returning. This is a helper-readiness fix for unit harnesses, not a gameplay behavior change. - 2026-03-22:
pick_obj()must pass the livemapthrough toobj_extract_self(). Without that, array-backed test maps could credit picked gold to the player while leaving the same gold object inmap.objects, becauseobj_extract_self()had no map context to remove it from the floor container. Passing themapfixes both mixed-gold pickup tests and the more general rule: floor extraction helpers need the owning map when the backing store is an array rather than only annobjchain. - 2026-03-23: the late
seed031camera-flash seam was a true keylog capture boundary, not a JS camera bug. In the canonical fixture, the flash fired on raw step426(b), but the recorded C session split itstmp_atbundle so the third^tmp_at_step[11,8,4081]landed on raw step427and^tmp_at_end[flags=0]landed on raw step428. A targeted rerecord withregen.key_delays_s = { "426": 0.75 }consolidated the whole flash back onto step426, kept gameplay fully green (51561/51561RNG and28950/28950events), and moved the first screen divergence from the late camera cell (step 425,!vs background) to a later menu seam atstep 1222(CoinsvsPotions). Practical rule: when a screen-only seam shows conserved gameplay and adjacent raw steps split onetmp_atvisual effect, fix the capture timing in the session regen metadata and rerecord the fixture instead of patching JS display logic. - 2026-03-23:
showMoreTextPages()has two C-faithful pager rules that matter for endgame disclosure parity. First, the final page still waits for a key but does not display a bottom-row--More--; showing that marker on the last page keptseed031one step early at the final-enlightenment boundary.
2026-03-23 - seed031 late endgame parity: score/tombstone/topten are real progress; remaining seam is final conduct state
- After the camera capture-boundary rerecord fix,
seed031_manual_directstill had a long late screen-only tail through death disclosure, tombstone, and topten. - The fixes that materially moved the frontier were real JS endgame-state and
windowport fixes, not comparator workarounds:
- potion discovery needed the C
more_experienced(0, 10)credit when a quaffed potion becomes known - JS needed a real
player.urexpendgame field with save/restore support - death finalization needed to compute the final C-style score into
urexp - headless death flow needed the combined C-style tombstone + death-summary
page and to stop at topten rather than falling through to browser
Play again? - random tin variety has to persist in
obj.spe; otherwise gameover inventory naming drifts (homemade tinseam) - gameover
doname()needs the Hawaiian-shirt motif suffix andGemStone()naming (jade stone) for end-of-run inventory/disclosure parity - vanquished display needs C-style neutral monster titles (
gnome leader,dwarf leader, etc.) and the boxed text-window path rather than the generic pager
- potion discovery needed the C
- These changes move the isolated
seed031batch much later while keeping gameplay channels green:- RNG
51561/51561 - events
28950/28950
- RNG
- The remaining late seam after this batch is not another tombstone or popup
geometry issue. It is in the final conduct/disclosure content:
- current first divergence is step
1360 - JS still shows the food-conduct line (
You went without food.) - the canonical session’s transformed final conduct page starts at
You were an atheist.
- current first divergence is step
-
Working conclusion: the next
seed031blocker is a real conduct-state / disclosure-content issue, not gameplay RNG/event drift and not another generic window-owner bug. Second, terminal save/restore has to support both display backends: browser-style displays store{ ch, color, attr }cells indisplay.grid, but headless replay stores chars indisplay.gridand colors/attrs in the paralleldisplay.colorsanddisplay.attrsarrays. Reusing object-cell save/restore against the headless backend restoresundefinedchars and blanks the saved map under the next prompt. The faithful pager fix is to snapshot and restore chars/colors/attrs from whichever backend is active. - 2026-03-23: the remaining late
seed031conduct/disclosure seam turned out to have two real JS root causes. First, JS was missing C gameplay producers for final disclosure state:Playerneeded zero-initializeduconduct,uroleplay,uachieved,uhave, andueventeatcorpse()must route througheating_conducts()so corpse meals incrementuconduct.foodlike Cgoto_level()needs to record branch-entry achievements such asACH_MINEpluslvl()needs to record rank achievements viaachieve_rank()After those fixes, live replay at the final disclosure had the expecteduachieved=[15,23](entered the Gnomish Mines,attained the rank of Sightseer) and the falseYou went without food.line disappeared.
- 2026-03-23: once the state was correct, the last
seed031screen seam was still an owner/control-flow bug.show_conduct()must use the CNHW_MENUpopup path rather than the generic pager; otherwise the conduct page renders full-width instead of as a right-side overlay over the live map. After that, JS still replayed the tombstone page twice because endgame prompt teardown could callshowGameOver()more than once. MakingNetHackGame.showGameOver()idempotent fixed the duplicate invocation and movedseed031to full screen parity (1365/1365screens) while keeping gameplay channels green.
2026-03-23 — wizard-mode welcome, AUTOCOMPLETE, and remaining 14 failures
Wizard-mode lore+welcome fix (+7 sessions)
- C’s
welcome(TRUE)shows lore + welcome for ALL new games including wizard mode. JS was gating both behindif (!this.wizard). Removing the gate AND making the welcome single-key (no –More– for the pline welcome) fixed 7 sessions: 3 interface- 4 artifact/combat (theme15×2, theme35×2).
- Key insight: C’s
pline("welcome...")doesn’t trigger –More– because the message line was just cleared after lore dismiss. JS’sputstr_messageshowed the welcome as part of a multi-step prompt flow that consumed an extra key. Fix: after lore dismiss, show welcome viaputstr_message, runset_wear(), clear pendingPrompt — all in the same key handler. No second key consumed.
AUTOCOMPLETE flag matching (+1 session)
- C’s
ext_func_tabmarks 52 commands with AUTOCOMPLETE. Only these show completed names during typing. JS was auto-completing ALL unique matches. Commands without the flag (wizloaddes, kick, twoweapon) should echo only typed characters. Fixed seed42_castle which types#wizloaddescharacter by character.
Map session –More– investigation
- Map sessions (seed163, seed72, seed331_map, seed16_map) diverge because ^V level teleport to levels with shops produces –More– prompts that consume subsequent ^V command keys. C handles –More– dismiss inline; JS spreads it across replay steps, preventing later levels from being generated.
- This is the same step-boundary timing class documented in earlier lore entries.
Remaining 14 failure categories
- 100% RNG, display-only (3): seed031 (27 color attrs from inverse), seed331_tourist (endgame disclosure prompt shown 1 step early), theme25 (lore overlay persistence)
- Level-gen (4): map sessions blocked by –More– during ^V teleport
- Game-state (5): luck divergences (seed301 kick_door), monster movement (seed332), medusa_fixup (seed321/328), zoo filling (seed325)
- Deep (2): seed032 (level-restore mon_catchup missing), seed033 (tutorial encumbrance)
seed308 displacement divergence (March 26, 2026)
seed308 (ranger selfplay) diverges at step 10, RNG index 2250. Root cause:
set_apparxy() displacement loop iterates a different number of times in JS vs C.
The ranger starts with a cloak of displacement. Both C and JS detect displacement
(notthere=true, displ=2). The displacement loop randomizes the monster’s
perceived hero position via rn2(2*displ+1) twice per iteration. C rejects the
first candidate position and iterates again; JS accepts it. This consumes
different numbers of RNG calls, breaking alignment.
The acceptance condition depends on accessible(mx, my) (C) vs
ACCESSIBLE(loc.typ) && !closedDoor (JS). These should be equivalent but may
differ for specific terrain types or drawbridge positions. Needs per-cell terrain
comparison at the specific position to identify the exact mismatch.
seed032 Invisibility Bug (March 27, 2026)
C trace investigation (March 27, 2026): initial m_move trace from a
rerecorded session suggested the player was invisible in C (randomized target).
However, a second trace added directly to set_apparxy showed Invis=0,
notseen=0, notthere=0 — the player is NOT invisible. The first trace was
from a stale rerecord that produced a different session.
LESSON: C traces with rerecord.py overwrite the session file! Always
git checkout the session file after using rerecord for C tracing. The
rerecord produces a new recording that may differ from the original due to
timing/capture differences.
Root cause found (March 27, 2026): Hero position tracing revealed the player position diverges at the VERY FIRST step (step 14). C player starts at (5,5), JS at (6,4). JS’s upstair is at (6,4). If C’s upstair is at (5,5), the staircase placement differs during level generation despite identical RNG (2496 entries at step 11 all match). This is a level generation parity bug in staircase positioning, not an accumulated drift from gameplay code.
Room coordinate investigation (March 27, 2026): JS room 0 bounds are
(3,2)-(9,4). C map shows the same room at approximately (3,3)-(9,5) based
on the screen layout at step 15. This is a systematic y-coordinate offset
of -1 in JS room creation. The somexy function uses room bounds to pick
stair/object positions, so a 1-row offset causes ALL placements in that room
to be 1 row higher.
somexyspace ACCESSIBLE fix (committed): JS checked typ === ROOM || CORR
|| ICE but C checks ACCESSIBLE(typ) which includes DOOR. Fixed.
Room bounds match (March 27, 2026): C trace confirmed room #6 bounds are
IDENTICAL between JS and C: both (3,2)-(9,4). The stair position difference
(JS (6,4) vs C (5,5)) comes from somexy consuming rn2 values at a DIFFERENT
position in the global RNG stream. This means JS and C consume different
amounts of RNG between room creation and branch stair placement — a subtle
RNG ordering difference within level generation.
C’s starting position (5,5) is on room #6’s WALL (y=hy+1=5), not inside the
interior [ly=2,hy=4]. This proves C selected a DIFFERENT room for the branch
stair than JS, despite identical room bounds and identical generate_stairs_
find_room logic. The rn2(candidates.length) in C picks a different room
index because the RNG stream is at a different global position by the time
find_branch_room is called.
This means there’s a subtle RNG consumption difference earlier in level
generation (between room creation and find_branch_room) that shifts the
RNG position. The per-step comparator says entries “match” but the INTERNAL
ordering within the step differs at some point.
RNG caller comparison (March 27, 2026): Direct comparison of JS vs C caller tags during level generation revealed JS consumes 284 MORE regular RNG entries than C (2618 vs 2334). Key differences:
- JS
level_finalize_topology: 1046 calls vs Cmineralize: 652 (net +394) - JS
join(corridor): 310 vs Cdig_corridor: 391 (net -81) - JS
makemon: 128 vs C: 5 (net +123) - JS
build_room: 79 vs C: 8 (net +71)
The 284 extra calls shift the entire RNG stream, causing find_branch_room
to consume different values and pick a different room for the branch stair.
This is a systemic level generation parity issue involving multiple
functions consuming different amounts of RNG, not a single-line bug.
Stalker invisibility (unrelated fix): JS’s eat.js PM_STALKER case had
rn1(100,50) computed but discarded with a TODO. Now properly calls
set_itimeout(player, INVIS, ...) matching C’s eat.c:1162-1172.
RESOLVED (March 26, 2026): The displacement loop in JS’s set_apparxy was
missing the !couldsee(mx, my) check from C monmove.c:2261. JS had an
incorrect comment “NO couldsee check here” that caused the omission. C requires
displacement target positions to be in line-of-sight; JS accepted all accessible
positions regardless of LOS. This caused JS to accept positions on fewer loop
iterations, consuming different RNG values. Fixed by adding the couldsee check.
This resolved seed308. seed328 has an additional divergence at step 226 in
rndmonst_adj (random monster creation) from accumulated state drift.
seed033 Deep Investigation (March 26, 2026)
nhgetch toplin transition fix (committed): C’s tty_nhgetch always
transitions toplin from TOPLINE_NEED_MORE (1) to TOPLINE_NON_EMPTY (2)
on every key read, regardless of input source. JS’s nhgetch has three paths:
queued-key, replay-key, and runtime-key. Only the runtime-key path (via
nhgetch_raw) performed the transition. The queued-key and replay-key paths
returned early, leaving messageNeedsMore and toplin stale. Fixed by adding
_nhgetchToplinTransition() helper called from all three paths.
putstr_message –More– boundary: C’s tty_putstr calls more() when
toplin == TOPLINE_NEED_MORE before displaying a new message. JS’s
putstr_message calls flush_screen(1) instead (no key wait). Direct
replacement of flush_screen(1) with more() caused regressions because
JS already has compensating more() calls in callers (e.g., read_engr_at
with needsMoreBetweenMessages, and the concat-overflow path in
putstr_message itself at lines 348-362). Adding more() at the
tty_putstr-equivalent point creates double –More– consumption. The correct
fix requires auditing and removing all compensating caller-side more() calls
first, then adding the tty_putstr-equivalent call.
NOTE: The needsMoreBetweenMessages check in engrave.js read_engr_at
(line 411-418) never actually fires because it checks
game.display.morePrompt which is not a method on the Display class.
The actual –More– between typeMsg and readMsg is handled by the
concat-overflow path in putstr_message (lines 348-362).
seed033 first RNG divergence at global index 4935: The divergence occurs in the moveloop_core outer loop (the “hero can’t move” loop). C and JS both iterate this loop multiple times per turn (monster gets multiple turns). But at the divergence point, C exits the loop after 3 iterations while JS continues to a 4th. This means JS gives the monster one extra turn.
Root cause hypothesis: umovement accumulation difference. The outer loop
runs while player.umovement < NORMAL_SPEED. The initial umovement at the
start of the step depends on the previous step’s processing. If the rush at
step 469 leaves different residual umovement in JS vs C, the loop count
differs. The umovement tracking is purely additive (no RNG consumed), so
it can diverge while the RNG stream stays in sync.
Investigation showed that during the rush at step 469, JS’s moveloop_core
reports umovement=15 with encumbrance=0. The rush processes 10 cells
with runMovementRepeatSlice calling advanceTimedTurn→moveloop_core for
each cell. The per-step rng_step_diff (which replays in isolation) may show
different behavior than the full session replay due to missing accumulated
state.
Refined root cause: key stream offset from travel. Detailed analysis of C’s global RNG stream revealed:
- C’s step 459 (key=”_”) starts travel. Steps 460-469 have 0 RNG but the cursor moves (player travels). Step 470 (key=”.”) has 167 entries with 10 monster turns and the accumulated domove processing.
- JS’s first divergence (index 4935) matches C at step 480 (key=” “), not step 470. This means JS is 10 steps behind C in the key stream.
- C consumed keys 460-469 via nhgetch during travel (for display/interruption checks), creating 0-RNG step boundaries. JS processes travel internally without consuming these keys.
- At C step 479 (key=”k”), the player moves onto an engraving showing
“Some text has been burned into the floor here.–More–”. Step 480
(key=” “) dismisses the –More– and includes
rnd(5) @ maybe_smudge_engr+ monster turns. - The key stream offset causes JS to process different keys at the same global RNG position, producing the divergence.
The travel key consumption pattern in C appears to be related to how C’s
session recording captures intermediate keys during travel (possibly via
delay_output() timing or terminal buffer reads). The exact C mechanism for
consuming these keys during travel needs further investigation.
umovement floor clamp (committed): C clamps u.umovement to 0 after
u_calc_moveamt() adds movement. JS was missing this clamp. Added to match
C’s allmain.c:158-159.
Level-change hero tracks are per-level, not globally reset (committed):
seed032_manual_direct’s old step-300 seam was caused by JS dropping the
hero track buffer on stair travel. C persists hero track state with the level
save/restore path (savelev() / getlev()), so revisiting a cached level can
restore older local footprints for gettrack(). JS changeLevel() was only
calling initrack() on level change. Fix: save trackState onto the current
level before caching it, and restore that buffer when revisiting a cached
level. This removes the old monster-322 stair-return chase seam and moves
seed032_manual_direct later (300 -> 361) without regressing
seed033_manual_direct (still 571).
Tended-shop pet blocking must use live shopkeeper lookup (committed):
After the per-level track fix, seed032_manual_direct next diverged at step
361 when JS let the hero displace a tame kitten in a shop square where C
took the You stop. <pet> is in the way! branch. The root cause was stale
room metadata: JS checked monRoom.resident, but cached levels can revisit a
shop room whose live shopkeeper is no longer stored there. The correct C-shaped
question is whether the square belongs to a shop with a live in-shop keeper.
Fix: in domove_attackmon_at(), use shop_keeper(map, roomno) and
inhishop(...) instead of relying on room.resident. That moves
seed032_manual_direct later again (361 -> 376) while leaving
seed033_manual_direct at 571.
Next steps for seed033:
- Understand why C consumes nhgetch keys during travel that JS doesn’t
- Check if C’s travel loop has an explicit nhgetch for interruption checking that JS’s runMovementRepeatSlice lacks
- If the key offset is due to –More– boundaries during travel (engraving reads that trigger more()), the putstr_message fix may resolve it — but requires removing compensating more() calls first
Step Boundary Offset (Session 35, March 27 2026)
Discovery
All monsters in seed032 have ^movemon_turn events attributed 1 step
earlier in JS than in C. Positions are identical — same sequence, same
movements, same RNG — but the recording step counter is off by 1.
Example (pet monster 32): | Step | C position | JS position | |——|———–|————-| | 256 | (54,12) | (55,12) | | 257 | (55,12) | (55,11) | | 258 | (55,11) | (55,10) |
Cause
moveloop_turnend() events (mcalcmove, makemon random spawn, u_calc_moveamt)
are attributed to the current step’s RNG log in JS, but to the next
step’s RNG log in C. The game loop structures are architecturally identical
(both run moveloop_turnend at the bottom of the inner “hero can’t move” loop).
The difference is in when the step counter advances relative to the key event:
- C recording: key → process → monsters → [capture] → turnend → monsters → [capture]
- JS replay: key → process → monsters → turnend → [step boundary]
Impact
- Random monster spawn (
makemon_appear) appears 1 step earlier in JS traces mcalcmovebudget allocation appears 1 step earlier- Cumulative RNG comparison stays aligned because RNG values match
- But per-step comparison shows monsters “ahead by 1 step” in JS
- At late steps (300+), the 1-step offset means monsters interact at slightly different game states, causing eventual position drift
Affected sessions
seed032, seed328, and likely any session with late-stage monster drift where per-step RNG matches but monster positions are offset by 1.
Fix direction
Adjust the JS replay step boundary so that moveloop_turnend events are attributed to the following step, matching C’s behavior. This could be done by splitting the RNG log at the point where moveloop_turnend begins.
m_move Audit Findings (Session 35, March 27 2026)
Systematic comparison of m_move() between JS (monmove.js) and C (monmove.c)
for non-RNG behavioral differences that could cause monster position drift:
Missing: Unicorn NOTONL avoidance (C monmove.c:1938-1948)
C checks if any candidate position is off the hero’s line, sets avoid=TRUE,
then skips NOTONL positions during selection. JS has no NOTONL logic. Affects
unicorns on noteleport levels.
Missing: Hero appearance checks (C monmove.c:1864-1871)
C has two appr=0 conditions for hero appearance mimicking:
is_obj_mappear(&gy.youmonst, STRANGE_OBJECT)— hero disguised as objectis_obj_mappear(&gy.youmonst, GOLD_PIECE) && !likes_gold(ptr)— gold disguise JS has neither. Affects sessions where hero uses polymorph/mimic abilities.
Difference: itsstuck guard (C monmove.c:1986)
C checks mmoved == MMOVE_MOVED before itsstuck; JS checks
nix !== omx || niy !== omy. These are functionally equivalent in practice
since mfndpos never returns the current position.
seed033 Spell Knowledge Bug (Session 35, March 27 2026)
Discovery
At step 583, C says “You learn ‘light’” (new spell) while JS says “You know ‘light’ quite well already.” (already known).
Investigation
Both C and JS Priest characters have spellbook of light in starting inventory.
JS correctly auto-learns it via initialSpell(). The startup RNG matches
perfectly (2256/2256 values identical). But C doesn’t know the spell.
Root cause
The C session was recorded with nethack_c: '79c688cc6'. That C binary
version apparently has a bug or different behavior where initialspell() in
u_init_skills_discoveries() doesn’t properly register the starting spell.
Re-recording with the current C binary produces a WORSE result (level
generation diverges), indicating the binary has been updated.
Conclusion
This is a C recording artifact, not a JS code bug. The session cannot be fixed from the JS side.
Deeper analysis: _gameLoopStep key consumption
The JS replay step 252 (key “u”) processes 5 game turns including a
runmode_delay_output animation, while C step 252 processes only 1
turn. JS step 253 (key “K”) has only 2 events instead of C’s 289.
This confirms the root cause: _gameLoopStep() for the “u” key
internally calls nhgetch() (for a –More–, continuation, or run
direction prompt), which reads the next key “K” from the input buffer.
The “K” key is consumed as a run direction within the “u” step’s
processing, causing all run events to be attributed to step 252.
In C, each nhgetch() call advances the recording step boundary.
In JS replay, all nhgetch() calls within one _gameLoopStep() call
share the same step — they don’t advance the step counter.
Fix direction: The replay engine would need to either:
- Split steps at every nhgetch() call (matching C’s recording)
- Or pre-feed all keys a step needs based on the C step boundary
This is an architectural change to replay_core.js that requires careful design to avoid breaking 567 passing sessions.
seed032 Tin Carry-State Bug (March 27 2026)
Discovery
After the stair-message, hero-track, and live-shopkeeper fixes, seed032
still first diverged at step 376 when C entered start_tin() but JS stayed
on ordinary command flow.
Root cause
The selected tin object was being added to inventory without setting
obj.where = OBJ_INVENT in player.addToInventory(). That left the tin
looking like a floor object during start_tin() / opentin(), so the first
occupation tick aborted as unreachable and JS never reached the real
consume_tin() path.
Fix
Set where = OBJ_INVENT on all inventory insertion/merge paths in
player.addToInventory(), then route tins through the real tin code in
eat.js:
handleEat()dispatches tins tostart_tin()start_tin()records tin occupation stateopentin()continues until the open succeedsconsume_tin()now handles ordinary non-spinach tins, including smell,Eat it?, corpse effects viacprefx()/cpostfx(), and empty/spinach tins
Validated effect
On current origin/main (f133e84c), this moved
seed032_manual_direct.session.json first RNG divergence:
376 -> 390
Guardrails:
seed033_manual_direct.session.jsonstayed at571seed328_ranger_wizard_gameplay.session.jsonstayed at226
Next seam
The next seed032 blocker after this fix is later pet/monster behavior:
- first RNG divergence at step
390 - first event divergence at step
391
seed032 Shop Floor Feedback Combination (March 27 2026)
Discovery
After the tin carry-state fix, seed032 still diverged at step 390 in the
shop-leash sequence. C combined the floor feedback into one line:
There is an open door here. You see here a leash.
JS emitted those as separate messages, leaving a lone There is an open door here.
--More-- boundary one key too long.
Fix
In postMoveFloorCheck() for the single-object case, build one combined
topline when both a location feature and one visible object are present.
This keeps the shop-entry leash case on the same command boundary as C.
Validated effect
On top of e4c85586e, this moved seed032_manual_direct.session.json:
- first RNG divergence:
390 -> 392
Guardrails:
seed033_manual_direct.session.jsonstayed at571seed328_ranger_wizard_gameplay.session.jsonstayed at226
Next seam
The next blocker is not that floor-feedback split anymore. The remaining
step-392 issue is the pickup result topline v - a leash. being left as a
visible boundary with no live owner after finishPickupAfterBilling().
putstr_message –More– boundary: toplines retention (Session 35)
C’s toplines buffer retains text after the screen row is cleared.
When a new pline arrives, update_topl() checks toplin state and
strlen(toplines) for concatenation. The retained text causes overflow
when a long prior message + a new message exceeds 80 columns.
Example (seed032 step 388-390): shopkeeper greeting “Hello Sarah!
Welcome again to Kabalebo’s general store!” is ~58 chars. It arrives
via verbalize() → pline(). C’s update_topl() concatenates it with
whatever was in toplines from a prior pline in the same turn. If the
prior text + “ “ + greeting > 71 chars (80 - 9 for –More–), C shows
–More–.
JS’s putstr_message has the same concatenation logic but the
topMessage state may differ from C’s toplines because:
- JS clears
topMessageat different points than C clearstoplines - JS’s
flush_screen(1)at line 286 doesn’t match C’smore()call inupdate_toplline 273-274
The fix: ensure topMessage tracks the same lifecycle as C’s toplines.
Specifically, after a more() dismissal, both C and JS should have
cleared toplines. But implicit –More– dismissals (where the replay
engine feeds a key that happens to dismiss –More–) may leave
JS’s topMessage in an inconsistent state.
This is the root cause of the –More– boundary mismatches in #392.
2026-03-28 — combine single-object floor feedback restored seed032 gameplay parity
- Current
mainstill emitted separate same-step floor-feedback messages for single-object tiles inhack.js:postMoveFloorCheck():- feature text such as
There is an open door here. - then object text such as
You see here ...
- feature text such as
- On
seed032_manual_direct, that stacked with an already-live shop-entry greeting and created extra visible--More--boundaries at raw steps377and379. - The authoritative microscope was:
rng_step_diff --step 377: JS still dismissing"Hello, sarah! Welcome again to Akalapi's general store!"while C had already finished the steprng_step_diff --step 379: JS still dismissingThere is an open door here.while C had already entered monster turns
- Reapplying the earlier combined single-object floor-feedback logic fixed the
class:
- build one combined message for
feature + object - do not emit them as two separate topline writes
- build one combined message for
- Result on clean current
main:seed032_manual_direct:rngFull=1/1,eventsFull=1/1- remaining mismatches are screen/color/cursor only
seed033_manual_directunchanged on its unrelated startup seamseed328_ranger_wizard_gameplayunchanged on its unrelated early seam
2026-03-28 — duplicate pre-check_special_room() shop greeting was stale, but not the active blocker
- Current
mainstill had a stalemaybeHandleShopEntryMessage()call inhack.js:domove_core()anddomove_swap_with_pet(), ahead of the later C-shapedcheck_special_room(FALSE)pass. - Message tracing on
seed032_manual_directshowed the same shop greeting arriving from both paths:maybeHandleShopEntryMessage(...)check_special_room(false) -> u_entered_shop(...)
- Removing the stale helper calls was structurally correct and did not regress
seed033_manual_directorseed328_ranger_wizard_gameplay, but it did not move the firstseed032divergence by itself. - The real active blocker was still the single-object floor-feedback split in
postMoveFloorCheck()on this branch.
2026-03-28 — preserve empty-string nomovemsg in unmul() to avoid fake “You can move again.”
- Current
mainstill treatedgame.nomovemsg = ''as falsy inhack.js:unmul():- JS did
else if (!game.nomovemsg) game.nomovemsg = 'You can move again.'; - C
hack.c:unmul()only installs the default whengn.nomovemsg == NULL; an empty string is a real suppress-message state
- JS did
- That mattered for punishment drag / other multi-turn completions which
deliberately set
nomovemsg = ""to suppress the default recovery line. - On
seed032_manual_direct, JS was reviving that suppressed line after a shop re-entry greeting:- pending top message:
"Hello, sarah! Welcome again to Akalapi's general store!" - next JS message:
"You can move again." - combined length overflowed the tty concat check and triggered an inline
display.more.dismiss, swallowing later movement keys and shifting monster turns across steps579+
- pending top message:
- Fix:
- treat only
null/undefinedas “nonomovemsg” - preserve empty-string suppression exactly like C
- treat only
- Validated effect on current
main:seed032_manual_direct: first RNG divergence578 -> 658seed033_manual_direct: unchanged at597seed328_ranger_wizard_gameplay: unchanged at226
2026-03-28 — goto_level() punished descent must run drag_down() before arrival relocation
- After the
unmul()fix, the nextseed032gameplay seam moved to the late punished descent path:- JS first bad RNG:
rn2(10)=0 @ changeLevel(do.js:1925) - C first bad RNG:
rn2(3)=1 @ drag_down(ball.c:999)
- JS first bad RNG:
- The decisive microscope was:
rng_step_diff --step 658: fully matchedstep-summary 657..661: all real drift started on659+- C raw
659restored the target level, then immediately ran:rn2(3)=1 @ drag_down(ball.c:999)rn2(2)=1 @ drag_down(ball.c:1015)rnd(20)=15 @ drag_down(ball.c:1018)
- JS skipped straight from restored-level RNG into
changeLevel()arrival collision/random relocation
- C source in
do.c:1778-1785explains it:- on downward stair/ladder travel, if
Punished,goto_level()callsdrag_down()before later arrival handling - if the ball is not welded, it follows with
ballrelease(FALSE)
- on downward stair/ladder travel, if
- JS already had a faithful
drag_down()implementation inball.js; it just was never invoked fromchangeLevel(). - Fix:
- in
do.js:changeLevel(), after choosing the new-level arrival square for a downward transition and before later arrival/collision handling:- call
drag_down(player, map, display, game)when punished and not levitating/flying - then
ballrelease(false, ...)when the ball is not welded
- call
- in
- Validated effect on current
main:seed032_manual_direct:rngFull=1/1,eventsFull=1/1- remaining mismatches are screen/color/cursor only
seed033_manual_direct: unchanged at597seed328_ranger_wizard_gameplay: unchanged at226
2026-03-28 — combining single-object floor feedback on current main moved seed032 from 390 to 485
- On the clean
144ab013branch state,hack.js:postMoveFloorCheck()was still emitting single-object feature and object text as two separateputstr_message()calls:There is an open door here.You see here a leash.
- That left a standalone late
display.more.dismissstep onseed032, while C had already advanced into monster turns. - Recombining the single-object case into one topline:
There is an open door here. You see here a leash.removed the stale boundary class on this branch.
- Validated effect:
seed032_manual_direct: first RNG divergence390 -> 485seed033_manual_direct: unchanged at571seed328_ranger_wizard_gameplay: unchanged at226
- New
seed032seam after the fix:- JS:
rn2(2)=0 @ dochug(monmove.js:847) - C:
rn2(12)=10 @ dog_move(dogmove.c:1302) - first RNG divergence at step
485
- JS:
2026-03-28 — implementing #apply leash handling fixed the seed032 leash seam
- Current
mainhad no real leash apply path injs/apply.js:use_leash()anduse_leash_core()were still stubshandleApply()had noLEASHbranch in its direction-tool handling
- On
seed032_manual_direct, the raw key window around the seam is:394: a395: ?396: v397: b
- The C ground truth for that window is a real leash interaction:
- select leash from the overlay list
- choose adjacent kitten direction
You slip the leash around your kitten.
- JS was instead escaping the selection into the wrong generic command lifecycle because the leash path was missing.
- Fixes:
- implement
use_leash()anduse_leash_core()from C injs/apply.js - add
LEASHto the direction-handled#applytool branch - return a timed command result for real leash use
- fix a newly exposed latent bug in
js/shk.jsby importingOBJ_FREE
- implement
- Validated effect on clean current
main:seed032_manual_direct: first RNG divergence392 -> 437seed033_manual_direct: unchanged at571seed328_ranger_wizard_gameplay: unchanged at226
- New active
seed032seam after the fix:- first RNG divergence at
437 - JS:
rn2(5)=2 @ dochug(monmove.js:847) - C:
rn2(1)=0 @ move_special(priest.c:85) - event seam is nearby on a later priest/pet movement window, not on the old leash interaction anymore
- first RNG divergence at
2026-03-28 — unpaid shop drops must call sellobj() in dropz()
- The next
seed032seam after leash handling was not actually in priest logic. - C tracing showed the shared
move_special()label was misleading:- the visible downstream drift was the shopkeeper (
mndx=271) - and the important late raw keys were:
433: ,434: space435: space436: j437: space438: j439: D440: u
- the visible downstream drift was the shopkeeper (
- The real missing C behavior was in the drop path:
- C
do.c:dropz()callssellobj(obj, u.ux, u.uy)after placing a dropped object on the floor when on a shop level - JS
js/do.js:dropz()was placing the object but never notifying shop billing
- C
- For the active
seed032case, wiring the unpaid drop intosellobj()fixed the shopkeeper/billing lifecycle enough to move parity substantially later - Validated effect on clean current
main:seed032_manual_direct: first RNG divergence437 -> 485seed033_manual_direct: unchanged at571seed328_ranger_wizard_gameplay: unchanged at226
- The safe validated increment is the unpaid-item hook in
dropz()- broader unconditional
sellobj()wiring exposed separatesellobj()/set_cost()issues and was not landed
- broader unconditional
2026-03-28 — menu_drop() needs a real in-band PICK_ANY selection loop
- The next
seed032seam after the unpaid-drop fix was not pet AI first. - The decisive raw key window was:
487: D488: u489: Enter490: a491: z492: Enter
- The recorded C session showed the real second-stage drop menu interaction:
489opensWhat would you like to drop?490 "a"is consumed in-menu and does nothing491 "z"toggles the wand selection from-to+492 Enterconfirms and drops the wand
- JS
handleDropTypes()was wrong here:- after filtering candidates, it did only one
nhgetch() - if that one key was not an inventory letter, it fell into
showDropCandidates() - that produced a standalone
^more[do.showDropCandidates.more,z - a brass wand.]boundary and leaked later keys back into ordinary command flow
- after filtering candidates, it did only one
- That bad lifecycle shifted the actual drop square:
- JS dropped the wand at
72,7 - C dropped it at
71,8 - the later kitten
dog_goal_enddrift was downstream of that location difference
- JS dropped the wand at
- Fix:
- replace the one-key filtered-drop path in
js/do.js:handleDropTypes()with a real in-band PICK_ANY-style loop:- keep the menu active
- toggle selected items by inventory letter
- confirm on Enter/Space
- cancel on Escape
- replace the one-key filtered-drop path in
- Validated effect on clean current
main:seed032_manual_direct: first RNG divergence485 -> 499seed033_manual_direct: unchanged at571seed328_ranger_wizard_gameplay: unchanged at226
- New active
seed032seam after the fix:- first RNG divergence at
499 - JS:
rn2(3)=0 @ dochug(monmove.js:847) - C:
rn2(1)=0 @ dog_move(dogmove.c:1300)
- first RNG divergence at
2026-03-28 — lowercase shop drops must use deliberate sellobj_state()
- The next
seed032seam after themenu_drop()fix was still in shop drop flow, but not in pet AI first. - The decisive C-recorded key window was:
497: d498: y499: Space500: n
- Expected C behavior in that window:
497:What do you want to drop? [$a-rtvwy or ?*]498:You drop a yellow gem.--More--499:Kabalebo offers 6 gold pieces for your yellow gem. Sell it? [ynaq] (n)
- JS had a structural mismatch:
- the real live lowercase
dpath isjs/do.js:handleDrop() - it bypassed
js/do.js:dodrop() - so it never called
sellobj_state(SELL_DELIBERATE)/sellobj_state(SELL_NORMAL) - shop drops from that path were therefore treated like
SELL_NORMAL, skipping the in-band sell prompt ownership C uses for deliberate drops
- the real live lowercase
- Fix:
- bracket
handleDrop()andhandleDropTypes()with deliberate/normalsellobj_state(...) - make
js/shk.js:sellobj()async so deliberate ordinary sales can ownynaqinline - await
sellobj()from the active JS drop/throw/trap call sites
- bracket
- Validated effect on clean current
main:seed032_manual_direct: first RNG divergence499 -> 534seed033_manual_direct: unchanged at571seed328_ranger_wizard_gameplay: unchanged at226
- New active
seed032seam after the fix:- first RNG divergence at
534 - JS:
rn2(19)=5 @ seffects(read.js:1662) - C:
rn2(5)=4 @ distfleeck(monmove.c:539)
- first RNG divergence at
2026-03-28 — unpaid pickup quote must not split into a second pickup ack owner
- The next
seed032seam after deliberate shop-drop sell state was still not aread.jsbug first. - The decisive JS raw window was:
533:,removes the wand and runsaddtobill(...)534: quote-dismiss key returns throughpickup_quote_more535: next gameplay key was being swallowed with only^mlc[phase=fresh_cmd]536: another gameplay key was swallowed the same way537: only then did JS finally run the delayed timed turn
- Expected C behavior from the recorded session:
534 ,: shop quote"For you, esteemed hiril; only 200 zorkmids for this brass wand."--More--535 Space: same boundary resumes the pickup, runs monster turns, and shows the unpaid inventory line- later gameplay keys are not consumed by a synthetic extra pickup-ack owner
- Root cause in
js/pickup.js:pickup_quote_more.onKey(...)correctly resumed intofinishPickupAfterBilling(...)- but
finishPickupAfterBilling(...)still calleddeferTimedPickupUntilMore(...) - that created a second
pickup_more_ackprompt owner for the unpaid inventory message - the second owner swallowed later gameplay keys until a later
Space, shifting monster turns from534/535/536into537+
- Fix:
- remove the synthetic
pickup_more_ackdefer from the unpaid pickup path - let the quote-resume key complete
finishPickupAfterBilling(...)as a timed command
- remove the synthetic
- Validated effect on clean current
main:seed032_manual_direct: first RNG divergence534 -> 548seed033_manual_direct: unchanged at571seed328_ranger_wizard_gameplay: unchanged at226
- New active
seed032seam after the fix:- first RNG divergence at
548 - JS:
rn2(3)=1 @ dochug(monmove.js:847) - C:
rn2(100)=85 @ obj_resists(zap.c:1467)
- first RNG divergence at
2026-03-28 — scroll punishment must use the live game map when calling punish()
- The next
seed032seam after the unpaid-pickup fix looked like petdog_move()drift, but live C rerecording showed the real upstream state mismatch. - Fresh C replay for the authoritative seam showed:
- key
" "just before the kitten seam logsYou are being punished for your misbehavior! - and places both punishment objects on the floor:
^place[474,70,7]^place[475,70,7]
- key
- Those are:
HEAVY_IRON_BALLIRON_CHAIN
- JS was entering
seffect_punishment()but not placing the ball and chain. - Root cause in
js/read.js:seffect_punishment()calledpunish(sobj, player, player?.map || null)- on this path
player.mapwas unset, sopunish()ran with no map - that prevented
placebc()from placing the punishment ball and chain on the floor - later kitten
dog_move()therefore scanned an incomplete pile at(70,7)and diverged onobj_resists()calls
- Fix:
- use the same live-map fallback already used elsewhere in
read.js:player?.map || _gstate?.lev || _gstate?.map || null
- and normalize
punish()itself to the same fallback beforeplacebc()
- use the same live-map fallback already used elsewhere in
- Validated effect on clean rebased
origin/main:seed032_manual_direct: first RNG divergence548 -> 556seed033_manual_direct: first RNG divergence597on the rebased guardrail runseed328_ranger_wizard_gameplay: unchanged at226
- New active
seed032seam after the fix:- first RNG divergence at
556 - JS:
rn2(5)=0 @ dochug(monmove.js:847) - C:
rn2(100)=15 @ obj_resists(zap.c:1467)
- first RNG divergence at
2026-03-28 — hero movement must run punishment drag logic in domove_core()
- The next
seed032seam after the punishment-map fix looked like another petdog_move()object-scan mismatch, but the decisive raw window showed the real upstream state bug. - On clean rebased
origin/main, the first divergence was at step556, but step-count summary showed:556: fully matched557: JS short by13RNG and14events
- Raw comparison at the first bad step showed:
- JS step
557began directly with kittendog_move()from71,8 - C step
557first placed the punishment objects:^place[474,70,8]^place[475,70,9]
- then entered the same kitten
dog_move()
- JS step
- JS step-557 mapdump confirmed the ball and chain were stale on the floor:
- both still stranded at
70,7
- both still stranded at
- Root cause in
js/hack.js:domove_core()never called the C punishment movement path:- no
drag_ball(...)before the hero move - no
move_bc(...)after the hero move
- no
js/ball.jshad the implementation, but normal movement never invoked it
- Fix:
- import
drag_ballandmove_bcintojs/hack.js - in
domove_core(), calldrag_ball(nx, ny, true, ...)after movement viability checks and before the move commits - after the hero move commits, call
move_bc(0, ...)before the inlinedspoteffectsblock - preserve C-style
cause_delayhandling vianomul(-2, game)after post-move effects
- import
- Validated effect on clean rebased
origin/main:seed032_manual_direct: first RNG divergence556 -> 578seed033_manual_direct: unchanged at597seed328_ranger_wizard_gameplay: unchanged at226
- New active
seed032seam after the fix:- first RNG divergence at
578 - JS:
rnd(6)=3 @ domove_core(hack.js:...) - C:
rn2(5)=2 @ distfleeck(monmove.c:539)
- first RNG divergence at
Session 36 Findings (March 28, 2026)
Tutorial spell clearing (seed033 +112 RNG)
- C’s
nhl_gamestate("save")in nhlua.c clearssvs.spl_book(memset to 0) when entering the tutorial. The Priest’s starting spells (SPE_LIGHT, SPE_DETECT_MONSTERS) are learned viainitialspell()duringu_init_skills_discoveries(), then CLEARED when the tutorial starts. - JS wasn’t clearing spells → hero saw “You know light quite well already” instead of studying the spellbook fresh → skipped 3-turn study occupation → 22-turn drift.
- Fix: chargen.js
applyTutorialStripsaves/clears spells; teleport.js restores on exit.
Dlvl display bug (seed031 +178 screens)
render.js:formatStatusLine2usedplayer.dungeonLevel(branch-local dlevel) instead ofdepth(player.uz)(canonical depth = depth_start + dlevel - 1).- When entering branch dungeons (Mines at depth 5), status showed “Dlvl:1” instead of “Dlvl:5”.
- Fix: import
depth()from dungeon.js and use it for the Dlvl display.
HP display timing at –More– boundaries
- 12 sessions exposed by upstream mask removal show HP:0 (JS) vs HP:1 (C).
- Root cause traced precisely: during monster attacks, C’s hitmsg (“The fox bites!”) fires BEFORE mdamageu (damage application). The –More– from message overflow fires with pre-damage HP visible on the status line.
- In JS, the –More– fires after damage is applied because: (a) flush_screen in JS calls renderStatus when _botl is set (b) _botl gets set by mdamageu BEFORE the –More– dismiss completes (c) The flush_screen→renderStatus shows post-damage HP at the –More– boundary
- C’s more() does NOT call bot() — confirmed in win/tty/topl.c:205.
- C’s flush_screen DOES call bot() when disp.botl is set — but in C, the sequence ensures disp.botl from damage is consumed AFTER the –More– dismiss, not before.
- Attempted fix (removing renderStatus from more/putstr_message/flush_screen) caused regressions because other code paths depend on flush_screen’s status update.
- Proper fix requires matching C’s exact botl flag lifecycle during combat: set botl on damage, but don’t consume it in flush_screen until after –More– dismisses.
seed033 tutorial drop and falling parity (597 -> 628)
- The late
seed033uppercase-Dflow was still too weakly modeled in JS. - C’s second-stage filtered drop selection uses
PICK_ANYmenu semantics, not a single raw inventory-letter pick. - Fix in js/do.js:
- first-stage uppercase-
Dcategory selection now uses menu selection state - second-stage filtered item selection now honors
PICK_ANYcommands like@,.,,,-, and class-group toggles
- first-stage uppercase-
- The next exposed
seed033seam was the boulder squeeze path after the repaired drop flow. - C tracing showed
could_move_onto_boulder()flips only after the prior drop empties inventory:- before drop: squeeze disallowed
- after drop: squeeze allowed
- Fix in js/hack.js:
cannot_push()now uses the C-shaped tri-state outcome:-1refuse0squeeze onto the boulder square1actually push
- The next exposed seam was the trap-door descent into Tutorial:2.
- C
goto_level(..., falling)does not place the hero on upstairs/downstairs; it callsu_on_rndspot(), which consumesplace_lregion()RNG. - Fix in js/do.js:
schedule_goto()/deferred_goto()now preserve full{ dnum, dlevel }level refs- falling deferred-goto now uses a distinct
'falling'arrival mode getArrivalPosition()routes'falling'through the random-regionu_on_rndspot()/place_lregion()path instead of deterministic stair placement
- Validated local effect:
seed033_manual_direct: first divergence597 -> 628seed032_manual_direct: unchanged at437seed328_ranger_wizard_gameplay: unchanged at226
seed033 tutorial boulder squeeze prompt ownership
- The remaining early
seed033debt after the falling fix was still in the Tutorial:1 boulder squeeze window, butsession_test_runner’s nominal628label was stale. comparison-windowshowed the true live mismatch earlier:- baseline had non-zero debt at
623/624 - after the fix below, those steps cancel back to zero and the first live
mismatch moves to
638
- baseline had non-zero debt at
- The C-faithful model here is not “move immediately once squeeze is possible”.
It is:
- first key shows
You try to move the boulder, but in vain. - first dismiss shows
However, you can squeeze yourself into a small opening. - only the later dismiss resumes the move onto the boulder square
- first key shows
- Fix in js/hack.js:
- probe boulder squeeze eligibility quietly up front
- if the move is a squeeze case, install a two-stage prompt owner
- the second prompt resume re-enters
domove_core()with a one-shot boulder-bypass flag so movement/post-move effects happen only after the squeeze text ownership is finished
- Validated local effect:
seed033_manual_direct:623/624/627early debt removed- first live imbalance moves to
638 rng_step_diff --step 638becomes the next real seam
seed032_manual_direct: unchanged at437seed328_ranger_wizard_gameplay: unchanged at226
seed328 Medusa level divergence
- Hero teleports to level 25 (Medusa). Level gen matches 5388/6014 entries (89.6%).
- Divergence at medusa_fixup statue creation: JS’s
get_rnd_toptenentry()returns a synthetic Valkyrie entry; C returns a real scoreboard entry with potentially different role. Different statue monster types → different reroll loops → RNG drift. - This is a known limitation: JS uses
_assumeNonEmptyScoreboardwith a fixed Valkyrie entry. C’s actual scoreboard varies per recording environment. - Additional divergence: between steps 201-227, gremlin multiplication (434 RNG) and monster-monster combat (passivemm) interactions cascade from the initial level gen difference.
seed033 post-tutorial-dnum fix
- Tutorial dnum fix (game.dnum = TUTORIAL) enables correct Tutorial:2 generation.
- Events improved 1827→2010 (+183). Hero falls to correct tutorial level.
- Remaining divergence at step 628: distfleeck brave flag differs (1 vs 0) for monster 20 at same position. Caused by –More– timing difference during boulder squeeze sequence.
seed033 stair arrival + boulder squeeze inline (March 29, 2026)
- Boulder squeeze inline: Replaced pendingPrompt chain in domove_core with inline await for cannot_push_msg and squeeze message. Matches C’s single-call domove flow where both –More– prompts are internal nhgetch calls. Fixed step boundary offset at steps 623-624.
- Stair arrival position: getArrivalPosition was using dndest (branch stair destination, (12,6) tutorial start) instead of dnstair (regular downstairs, (61,13)) for within-branch stair transitions. C’s goto_level only uses sstairs (dndest/updest) for branch transitions; regular stairs use u_on_dnstairs(). Fixed by gating dndest/updest on branchTransition flag.
- Tutorial exit messages: Added “Resuming regular play.” and “Resetting time to move #1.” from C’s nhl_gamestate(“restore”). Also reset game.turnCount/moves.
- Remaining issue: Main dungeon level 1 is generated at different RNG position. C pre-generates level 1 during init (before tutorial), caches it, and restores it when exiting tutorial. JS generates level 1 fresh during tutorial exit via changeLevel. This produces different level content (different monsters/objects). Fixing requires matching C’s init ordering: generate level 1 → enter tutorial → restore level 1 from cache on tutorial exit.
- seed033 progress: RNG 5840→6356, events 2010→2113, screens 518→526.
Dead monster flee/passive guard (March 29, 2026)
do_attack_corewas runningrn2(25)flee check andpassive()on dead monsters after stagger knockback kills. C’sknown_hitum()checksDEADMONSTERbefore both. Fixed withmonster.mhp > 0guards.- Pending monk session (s55_monk_combat992) diverges at step 978 from a damage
calculation difference: JS’s
hmd.dmg >= mon.mhpat stagger time (no hurtle), while C’stmp < mhp(hurtle → trap → dies). Root cause: barehand damage bonus differences between JS and C. Needs martial arts damage audit.
HP display timing investigation (March 29, 2026)
- 7+ sessions show post-damage HP (HP:0) at –More– while C shows pre-damage HP (HP:1). Traced with theme43: mdamageu sets _botl and reduces HP, then done_in_by → pline(“You die…”) → putstr_message → flush_screen → _botl consumed → renders HP:0. In C, either the screen capture happens before flush_screen renders, or C’s done processing uses a different display path.
- showdamage was changed to no-op (C uses tmp_at map annotation, not pline).
- _topMessageEncumbrance snapshot wired up for –More– display.
- Root cause still open: C’s exact bot()/flush_screen timing at death –More– needs C source investigation.
Priest AI dispatch + do_attack_core area sweep (March 29, 2026)
m_movehad a simplified priest handler callingmove_specialdirectly with hardcoded parameters. Replaced with fullpri_move()from priest.js which checkshistemple_at(falls through to normal AI when outside temple).- Shade damage minimum: do_attack_core gave 1 minimum damage to shades; C gives 0 (shades immune to non-silver/blessed barehand). Fixed.
- Dead stagger rnd(100) fallback was unreachable code — removed.
- HP display timing: confirmed C’s behavior is CONTEXT-DEPENDENT. The
_botl save/restoreapproach (removed by a0d14111b) helps theme43 but breaks seed1_gameplay. The two cases show OPPOSITE HP display at –More– despite identical message patterns. Root cause is in the SPECIFIC sequence of pline/flush_screen/bot calls, not in the general _botl mechanism. Investigation exhausted without C source access.
hitum mhitm_knockback parity fix (March 29, 2026)
- C’s hitum() calls mhitm_knockback() after known_hitum() for every armed melee attack. This consumes rn2(3) + rn2(6) unconditionally.
- JS’s hitum was missing this call — only do_attack_core had knockback RNG. Sessions using the hitum → known_hitum → hmon path were drifting by 2 RNG per melee attack.
- Fixed by adding mhitm_knockback between known_hitum and passive in hitum.
- Also verified do_attack_core’s inline knockback matches C’s pattern.
Potion makeknown calls (March 29, 2026)
- Added 7 missing discoverObject (≡ makeknown) calls to potion effects: healing (3), speed, gain_level, gain_ability, levitation.
- Each call triggers exercise(A_WIS) which consumes rn2(19), fixing RNG drift in sessions with potion use.
- Potion-only calls (POT_SPEED, POT_LEVITATION) correctly skip SPE_* types.
Display-only failure class analysis (March 29, 2026)
- 12 sessions fail with perfect RNG+events, only screen mismatches
- All are HP (7) or encumbrance (3) or AC (1) timing at status line
- Root pattern: JS’s flush_screen consumes _botl and renders updated game state at –More– boundaries. C retains stale status values.
- C session data confirms: HP updates are DELAYED for some sessions (theme43: HP:1 shown until step 26) but IMMEDIATE for others (seed1: HP:0 shown at step 17). Same code path, different behavior.
- The _botl save/restore approach fixes some sessions but breaks others.
- Blocking: needs C source to understand exact flush_screen/bot() timing.
seed033 upstairs return must prefer actual stairway arrival over dndest
- After the boulder-squeeze fix, the next live
seed033seam was the first move after returning upstairs to Tutorial:1. - JS replay probes showed the hero returning to
(12,6), the instructional engraving square, and spending an extramaybe_smudge_engr()roll on the first move away from it. - The restored Tutorial:1 map still had:
map.dnstair = { x: 61, y: 13 }map.dndest = { lx: 12, ly: 6 }
- The bug was in js/do.js:
getArrivalPosition(..., 'up')preferreddndestbefore the real downstairs stair coordinate- that is backwards for ordinary stair travel
- C source confirms the correct rule:
goto_level()usesu_on_dnstairs()for ordinary upward travel- only special/s- stair fallback paths use alternate destinations
- Fix:
- for ordinary
'up'/'down'arrivals, preferdnstair/upstairbeforedndest/updest - keep
dndest/updestonly as fallback when no real stair coordinate exists
- for ordinary
- Validated effect:
seed033_manual_direct: first divergence628 -> 713seed032_manual_direct: unchanged at437seed328_ranger_wizard_gameplay: unchanged at226
seed033 tutorial exit must restore cached main level and hero position
- After the stair-arrival fix, the next live
seed033seam was still inside tutorial exit. - JS was still doing two C-inaccurate things in the portal restore path:
- regenerating main dungeon level 1 instead of resuming the pre-tutorial map
- choosing a fresh teleport arrival square instead of restoring the saved pre-tutorial hero position
- The active path was:
- js/chargen.js
enterTutorial()only stored inventory/equipment/spells - js/teleport.js tutorial portal
restore then called
changeLevel(1, 'teleport', { targetDnum: 0 }) - js/do.js therefore generated or randomly placed onto level 1 instead of restoring it faithfully
- js/chargen.js
- Fix:
- tutorial save now also stores the pre-tutorial cached main-dungeon map and hero coordinates
- tutorial restore passes that cached map into
changeLevel() changeLevel()now accepts an explicitarrivalPosoverride and uses it before normal arrival selection
- Validated effect:
seed033_manual_direct: first divergence713 -> 741seed032_manual_direct: unchanged at437seed328_ranger_wizard_gameplay: unchanged at226