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–)

Comparison-window triage stack

2026-03-21 - first fix the command-boundary ownership invariant, not monster math

2026-03-21 - pre-key owner drain is the right seam family, but the naive loop is too broad

2026-03-21 - even the narrower same-step replay drain is not keepable

2026-03-21 - the resumed . seam is caused by _gameLoopStep() returning after one deferred timed turn

2026-03-21 - continuing once after pendingTravelTimedTurn is not enough

2026-03-21 - removing the fresh-key admission pattern still does not move the first seam

2026-03-21 - the bad near=1 is caused by two extra hero hops before gas spore 27’s next turn

2026-03-19 - object age and thrown-kill ownership

2026-03-20 - stepped rust traps must actually fire in domove()

2026-03-19 - live level identity is a state invariant, not an align_shift() heuristic

2026-03-26 - remove stray JS-only ^moveamt[...] instrumentation before 033 dochug triage

Prompt-owned commands should share command finalization

Exact visible-hostile run stop is required after the owner fix

The remaining gas-spore seam is one target refresh too early

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)

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:

  1. keep the simple unconditional status refresh in generic input.more(),
  2. track whether the visible top line was created after a prior page break (headless putstr_message now receives that context as fromTopMoreBoundary),
  3. in headless internal putstr_message() more() waits, suppress status refresh only when all are true:
    • activeGame?.context?.mon_moving
    • fromTopMoreBoundary
    • not in a sleep/wake boundary (sleepWakeBoundary)
  4. treat this as a narrow replay/display staging rule, not a reason to revive broad _botlStepIndex gating in generic more().

Validated effect:

  1. hi11_seed1100_wiz_zap-deep_gameplay returns to full green,
  2. seed331_tourist_wizard_gameplay returns to full screen/color green,
  3. targeted control seed323_caveman_wizard_gameplay stays green,
  4. full PES gameplay suite returns to 216/216 passing.

Failure mode:

  1. gameplay RNG/events can remain fully green,
  2. but the status line flips to HP:0 one --More-- too early,
  3. creating a pure screen-only divergence like hi11 step 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:

  1. Added optional display RNG caller tagging in JS via RNG_LOG_DISP_CALLERS=1 (used with RNG_LOG_DISP=1 and RNG_LOG_TAGS=1).
  2. 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:

  1. C consumes display RNG with large ranges (for this file, ~drn2(7320)), not ~drn2(357).
  2. A JS port that samples bogusmons.length directly will drift display RNG and hallucination names/screens even when gameplay RNG stays aligned.

Fix pattern:

  1. parse compiled encrypted bogusmon data using parseEncryptedDataFile();
  2. apply C get_rnd_line-style offset selection with display RNG;
  3. 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:

  1. Remove off-FOV trap synthesis paths that converted trap.tseen into immediate loc.mem_trap display glyphs.
  2. Keep visible-cell trap rendering unchanged (trap.tseen still 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):

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:

  1. use the transformed gameplay view to identify the first authoritative parity step,
  2. then use a raw-window view to see which hidden raw command bundle drifted first,
  3. fix the earliest raw owner, not the later visible symptom.

Tooling:

  1. scripts/movement-propagation.mjs now supports:
    • --raw-from <N> --raw-to <M>
    • --raw-find-mismatch
  2. this prints side-by-side C raw key/topline and JS raw key/topline from live replay.

Concrete seed031 example:

  1. the transformed gameplay view points to a late throw/camera symptom,
  2. but the raw window shows the earlier real drift:
    • raw 463: C u, JS l
    • raw 468: C still on pre-stairs movement, JS already at >
  3. that means the true owner is the earlier pre-stairs movement bundle, not the later throw or camera code. Fix:
  4. Keep the existing nonblocking --More-- boundary behavior for parity.
  5. Replace hardcoded line 0 with VERSION_STRING (Royal Jelly branding + version.js commit number).
  6. 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:

  1. Prefer loc.wall_info when present; otherwise use low bits of flags.
  2. Remove synthetic border D_LOCKED fallback from W mapdump 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:

  1. it inserts extra keys (#, command text, terminator) into the same parser path as player commands;
  2. it can perturb prompt/message timing and --More-- boundaries;
  3. it creates “Unknown command” artifacts and step-alignment confusion that look like gameplay bugs but are harness-induced.

Policy:

  1. keep mapdump capture out-of-band (harness env/log channels), not as in-band gameplay commands;
  2. do not mutate recorded key streams for diagnostics;
  3. 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:

  1. preserve both options.datetime and options.recordedAt in session metadata;
  2. allow explicit replay policy for fixed datetime source;
  3. default to session-declared datetime for strict reproducibility.

Current replay selector modes:

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:

  1. keep runmode_delay_output() async,
  2. await it from domove_core(),
  3. preserve C runmode gating (tport, leap modulo-7, crawl extra 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:

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:


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.

See lua_converter_fixes.md.

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:

  1. 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.

  2. “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.

  3. 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.

  4. Name functions after their C counterparts. mhitm_knockback, not mhitm_knockback_rng. The _rng suffix 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:

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:

  1. First key (e.g. r for read) → rhack() called → command blocks on await nhgetch() → doesn’t settle in 1ms → stored as pendingCommand
  2. Next key (e.g. i to select item) → pushed into input queue → pendingCommand receives it → may settle (command completes) or stay pending (more input needed)
  3. pendingKind tracks special handling: 'extended-command' for #, 'inventory-menu' for i/I, null for 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:

  1. wiz_level_tele as a no-time command (ECMD_OK semantics), and
  2. quest-locate pager side effects in goto_level (com_pager("quest_portal") bootstraps Lua and consumes rn2(3), rn2(2) via nhlib.lua shuffle).

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:

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.

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:

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)

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:

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:

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:

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:

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:

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:

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:

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:

  1. rn2(lth) — picks position in string
  2. rn2(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:

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:

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:

  1. Deferred boundary pass-through: The command key itself (s after 9) appears as a step but was consumed by runmode_delay_output, not parse(). Emit an empty pass-through frame; do not execute the command again.

  2. OCC-HANDLER (non-zero comp step with game.occupation): Loop occ.fn → movemon → simulateTurnEnd until ownComp >= stepTarget. When the occupation ends mid-step (NONOCC), consume subsequent 0-comp buffer steps as new commands.

  3. 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:

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:

  1. Missing objects cascade. A vault gold stub consumes RNG without creating gold → no ^place events → monsters don’t path toward gold that doesn’t exist → pet AI diverges → RNG shifts → every subsequent turn is wrong. The failure manifests in dog_move, but the root cause is in mklev.

  2. 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.

  3. 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:

  1. Game orchestration — one shared run_command() function in allmain.js used by nethack.js (browser) and headless_runtime.js (tests/selfplay), so what you test is what you deploy.
  2. 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:


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)

Coordinate/levregion stair helpers (2026-02-24)

lspo entrypoint-name parity pass (2026-02-24)

sp_lev helper-name parity batch (2026-02-24)

sel_set_ter call-site parity (2026-02-24)

mapfragment runtime-enable for replace_terrain (2026-02-24)

mkmaze waterlevel + nhlua/nhlsel mapchar bridge (2026-02-24)

nhlsel/nhlua C-name wrapper parity pass (2026-02-24)

mkmaze ownership consolidation for water/baalz helpers (2026-02-24)

mkmaze water-state save/restore hardening (2026-02-24)

mkmaze walkfrom/deadend ownership tightening (2026-02-24)

safepet force-fight parity in domove attack path (2026-02-24)

uhitm improvised-weapon opening message parity (2026-02-24)

mkmaze protofile special-level loading parity (2026-02-24)

sp_lev trap coordinate resolution tightening (2026-02-24)

mklev ordinary-room amulet gate parity (2026-02-24)

sp_lev trap coord normalization parity (2026-02-24)

makelevel amulet flag propagation + bubble bound fix (2026-02-24)

mkmaze fixup/water matrix tightening (2026-02-24)

coupled A/B parity fix pattern: movemon pass shape + moveloop sequencing (2026-02-24)

seed1 event-order parity: iterate order + niche/statue object paths (2026-02-24)

mkmaze water runtime scaffold tightening (2026-02-24)

mkgrave headstone parity + seed100 recapture (2026-02-24)

Meta-lesson: event parity is a high-value bug finder (2026-02-24)

Knight pony start-inventory parity (2026-02-24)

Medusa statue reroll parity helper reuse (2026-02-24)

Water-plane hero coupling hook (2026-02-24)

mkmaze fumaroles C-path scaffold (2026-02-24)

objnam helper-surface closure for codematch (2026-02-24)

objnam readobjnam staged parser parity pass (2026-02-24)

objnam naming helper de-stub + codematch audit pass (2026-02-24)

objnam wizterrainwish in-map mutation path (2026-02-24)

Trap generation + setup port tightening (2026-02-24)

cmdq infrastructure port (2026-02-24)

CQ_REPEAT wiring in runtime command flow (2026-02-24)

CQ_REPEAT doagain execution wiring (2026-02-24)

cmdq ownership moved into rhack (2026-02-24)

Meta-lesson: event parity can unlock PRNG parity (2026-02-25)

Meta-lesson: avoid fixture-overfit when capture timing is suspect (2026-02-25)

Translator safe-scaling lesson: string-pointer style ports must be gated (2026-02-27)

Translator safety lint now blocks pointer-string traps pre-stitch (2026-02-27)

Translator batch-control lesson: prefer allowlist stitching for surgical batches (2026-02-27)

Monster-throw input handoff can consume replay command keys (2026-03-01)

tmp_at overlay erase must redraw current map state, not cached cells (2026-03-01)

m_throw timing parity: resolve impact before per-step animation frame (2026-03-01)

Ctrl+A repeat semantics with count prefixes (2026-03-02)

Seed110 throw-frame parity and visible monster-item naming (2026-03-02)

Seed7 tutorial regression root cause: lost special-level generation context (2026-03-02)

Level flag naming migration: rndmongen -> nomongen (2026-03-02)

Event parity migration: dog diagnostics now strict (2026-03-02)

Cursor parity for count-prefix replay frames (2026-03-02)

seed301 kick-door RNG mismatch triage (2026-03-04)

Engraving/trap helper correctness hardening (2026-03-04)

Wear prompt parity: GETOBJ_DOWNPLAY behavior for W (2026-03-04)

Invisible marker stale-clear on monster death (2026-03-04)

seed307 message-step shift diagnosis (2026-03-04)

seed307 follow-up: fixture skew confirmed by re-record (2026-03-04)

#loot take-out menu key-capture fix (seed031) (2026-03-05)

seed311 startup + wield prompt parity improvements (2026-03-05)

weapon_hit_bonus must special-case P_NONE (2026-03-05)

Trapdoor fall parity depends on immediate deferred_goto ordering (2026-03-05)

Trapdoor --More-- must defer post-turn processing to dismissal key (2026-03-05)

Monster trapdoor migration + MMOVE status handling (2026-03-05)

Hero dart/arrow trap handling was missing in domove spot-effects path (2026-03-05)

makemon_rnd_goodpos must use cansee semantics, not couldsee (2026-03-05)

Keep magic_negation inventory-only; fix owornmask state instead (2026-03-05)

Remove duplicate seduction teleport “vanishes!” message in JS (2026-03-05)

rndmonst_adj must use live hero level (u.ulevel) outside level-gen (2026-03-05)

mkcorpstat must restart corpse timeout under zombify context (2026-03-05)

Step-356 pet-position skew needs real-position localization (2026-03-05)

Seed322 dog/combat skew is downstream of earlier local-neighborhood drift (2026-03-05)

Seed322 --More--/combat-message parity lessons (2026-03-05)

Seed322 passive-rust/glove-contact parity unlocked later screen frontier (2026-03-05)

Seed323 timeout is pending-command/topline paging drift, not loop crash (2026-03-05)

Seed323 tutorial prompt leak is a rerecord-startup boundary bug (2026-03-05)

Seed42 corpse stacking drift came from lspo_object special-obj spe handling (2026-03-05)

Wizard-session Medusa drift: somex/somey eval order mattered on this toolchain (2026-03-05)

Rolling-boulder launch parity: endpoint and door checks tightened (2026-03-05)

Seed323 mapdump parity fix: maketrap terrain normalization for pit/hole/trapdoor (2026-03-05)

Stair transition --More-- lesson: C DOES block (2026-03-05)

Startup pet peace_minded context is role-sensitive (2026-03-05)

Themed-room trap postprocess coords were shifted by one X (2026-03-05)

seed329 pet ranged-target scan off-by-one consumed extra rnd(5) (2026-03-05)

seed329 mapdump H parity: fountain blessedftn union byte (2026-03-05)

seed321 mapdump R parity: des.region needs topologize-style border stamping (2026-03-05)

Trap type constants are inconsistent across JS files (2026-03-05)

display.putstr_message() bypasses pline _lastMessage tracking (2026-03-05)

Forest centaur gets BOW+ARROW, not CROSSBOW (2026-03-05)

topologize double edge-stamping in sp_lev rooms (2026-03-05)

des.terrain() must route through sel_set_ter for horizontal flag (2026-03-05)

C harness –More– race condition (isUnknownSpaceAlias) (2026-03-05)

intemple() was dead code — check_special_room must call it (2026-03-05)

mk_knox_portal wizard mode bypass (2026-03-05)

Wall glyph rendering: set_wall_state() never called in JS (2026-03-05)

const.js auto-import now covers include/*.h const-style macros (2026-03-06)


mcanmove/mcansee Default Initialization

const.js generator: enums + post-symbol pass + non-emittable blacklist (2026-03-06)

kick/monmove C-path closure for door and tame postmove logic (2026-03-06)

kick town-watch and monmove monster-data fallback closure (2026-03-06)

mfndpos tame ALLOW_M semantics fixed for occupied peaceful squares (2026-03-06)

dochug phase-3 undirected spell attempt restored before movement (2026-03-06)

dochug pre-move useless-spell filters aligned to C (2026-03-06)

seed332 hatch_egg timeout path: RNG parity closure (2026-03-06)

seed332 hatch egg lifecycle cleanup: event parity closure (2026-03-06)

replay input-boundary contract (architecture simplification, 2026-03-06)

seed325 digging branch alignment push (2026-03-06)

seed325 regression correction: wall-dig sound RNG gating (2026-03-06)

gameplay replay option parity: verbose default-on restoration (2026-03-06)

unseen map memory parity: preserve remembered glyphs in newsym() (2026-03-06)

dosounds parity: remove stale allmain ambient-sound stub (2026-03-06)

Throw/topline freeze boundary: C more() blocks before throw cleanup (2026-03-07)

"You die..." forces more() before prompt concatenation (2026-03-07)

Recorder datetime parity guard for Luck-sensitive replays (2026-03-06)

Recorder datetime helper tests (2026-03-06)

Browser keylogs now preserve deterministic datetime (2026-03-22)

m_move parity: restore missing Tengu early-teleport branch (2026-03-06)

dochug postmove unification groundwork (2026-03-06)

Vision pointer-table parity fix removes replay live-lock hotspot (2026-03-06)

postmov redraw ordering: avoid eager new-tile newsym in m_move (2026-03-06)

Comparison artifact screen-window context for boundary triage (2026-03-06)

place_object() now marks OBJ_FLOOR (2026-03-06)

mhitu parity: reveal hider/eel attacker on hit (2026-03-06)

domove locked-door bump now routes through autounlock pick path (2026-03-06)

trap visibility ordering fix for stepped arrow/dart traps (2026-03-06)

seed325 teleport-trap visibility alignment unmasked later frontier (2026-03-06)

Stair –More– boundary: preserve step alignment without cursor drift (2026-03-06)

Lesson: vault_occupied returns ‘\0’ which is truthy in JS

Lesson: spurious rn2(20) in hack.js domove_attackmon_at

Lesson: eat.js eating paths must dispatch to cpostfx/fpostfx

Seed328 hideunder boundary + corpse naming fidelity (2026-03-07)

doname(corpse) must include monster type even when xname(corpse) is generic (2026-03-07)

Lesson: m_ap_type must use numeric constants, not strings

Lesson: seed328 screen divergence is stale-glyph rendering model difference

Lesson: Gate-2 postmov refactors need explicit A/B parity proof

Lesson: C-faithful postmov trap-before-dig can land as a neutral semantic slice

Lesson: non-pet postmov trap-boundary refactor can reveal a later seed325 frontier

Lesson: door-before-dig ordering slice can be adopted incrementally when neutral

Lesson: placing maybe_spin_web in postmov tail is a safe cleanup slice

Lesson: shared postmov tail helper keeps pet/non-pet sequencing aligned

Lesson: include after_shk_move in shared postmov tail

Lesson: postmov tail must run meatmetal/meatobj/meatcorpse before pickup

Lesson: mthrowu drop-through path must preserve ship_object breaktest RNG

Lesson: hatch_egg must gate RNG on get_obj_location eligibility

Lesson: treat mcanmove as C boolean (0 and false both immobile)

Lesson: route monster-lethal flow through end.c path, not ad-hoc wizard bypass

Lesson: wizard Die? [yn] (n) must be strict-keyed and deferred after --More--

Lesson: monster light sources must attach to live monster pointers (not anything wrappers)

Lesson: wizard death flow must hand off to disclose prompt in the same key cycle

Refinement: move popup-specific pending-state rendering behind runtime API

Guardrail: architecture contract test for replay render ownership

Hardening: move blocked-popup redraw trigger to input-wait boundary

Manual-direct early drift reduction: #untrap + trap object map wiring + autounlock occupation ordering (2026-03-07)

Manual-direct seed032: stepped-trap seen-escape parity moved frontier (2026-03-07)

seed327 vault/Knox parity: floating Ludios source and mk_knox_portal gate ordering (2026-03-07)

postmov parity slice: iron-bars handling before mdig_tunnel (2026-03-07)

postmov parity slice: trapped-door and doorbuster handling in moved-cell phase (2026-03-07)

SDOOR wall-angle rendering parity (2026-03-07)

postmov parity slice: door branch gated by !passes_walls && !can_tunnel (2026-03-07)

postmov parity slice: amorphous-under-door branch restored (2026-03-07)

makelevel branch snapshot parity (seed332) (2026-03-07)

--More--/quest pager boundary refinement (seed325 spillover reduction) (2026-03-07)

dogfood poison/trap bit alias + vegetarian corpse taste index (2026-03-07)

corpse-eat RNG ordering cleanup (2026-03-07)

yn prompt + rush prefix command fidelity fixes (2026-03-07)

locked #loot autounlock parity in pickup path (2026-03-07)

trapped-box lockflow now calls chest_trap before dex exercise (2026-03-07)

pickup selector boundary for multi-item , (2026-03-07)

prompt-completion cursor/status boundary (2026-03-07)

des.grave now uses true HEADSTONE engraving path (2026-03-07)

potion quaff dispatch fixed from name-heuristic to otyp dispatcher (2026-03-07)

stepped-trap path now clears run/multi like C dotrap() nomul(0) (2026-03-07)

prompt-handled keys now honor time consumption in run_command() (2026-03-07)

healing/attribute exercise faithfulness cleanup (2026-03-07)

remove duplicate seer RNG scheduling in run_command() turn wrappers (2026-03-07)

run-step smudge gating now follows C domove_attempting semantics (2026-03-07)

trap confirm uses pre-cleared nopick command prefix (2026-03-07)

replay wait-site tracing + tele_trap() await fix (2026-03-07)

pending wait-site now resolves to gameplay frames (2026-03-07)

dbgmapdump: step-targeted compact mapdump captures (2026-03-07)

throw prompt parity: allow swap-weapon in dothrow selection (2026-03-07)

dbgmapdump refinement: actionable JS transition diffs + aligned C replay keys (2026-03-07)

uhitm non-weapon melee ordering: tool-class misc damage parity (2026-03-07)

New Skill: dbgmapdump-parity (2026-03-07)

apply/getobj invalid-letter parity: preserve repeated invalid-object –More– loop (2026-03-07)

apply dispatch gap fixed: lamp/candle paths now wired in doapply (2026-03-07)

run-mode parity: uppercase direction run uses context.run=1 (2026-03-07)

kick_ouch parity: subtract from current HP (uhp), not max HP (uhpmax) (2026-03-07)

wipe_engr_at event-call parity hypothesis was invalid (2026-03-07)

Diagnostic: detect stale manual-direct sessions via wipe/movemon skew (2026-03-07)

Replay pending-boundary trace now includes --More-- state snapshot (2026-03-07)

Runtime-owned boundary diagnostics API (2026-03-07)

dothrow object-prompt parity: include swap weapon path and --More-- flow (2026-03-07)

mhitu AD_LEGS now applies wounded-legs state (2026-03-07)

pager quick-look pre-getpos prompt now matches C (2026-03-07)

getpos TIP_GETPOS boundary: separate tip acknowledgement from goal prompt (2026-03-07)

save confirmation parity: Really save? and silent cancel (2026-03-07)

dofire no-quiver path must show You have no ammunition readied. before prompt (2026-03-07)

#untrap no-target wording should be You know of no traps there. (2026-03-07)

gold pickup singular article: a gold piece with running-total suffix (2026-03-07)

dofire invalid inventory letter must emit You don't have that object.--More-- loop (2026-03-07)

armor naming + text-window dismissal semantics in look_here popups (2026-03-07)

seed031 menu-boundary parity pass: pickup selection/menu rendering/help-? item list (2026-03-08)

event-parity unblinding: shift-aware event stream diagnostic (2026-03-08)

two-weapon --More-- boundary needed deferred turn after dismissal (2026-03-08)

input-boundary ownership: stack-first prompt/more + regression guard tests (2026-03-08)

Mapdump engraving section (E) for provenance debugging (2026-03-08)

dbgmapdump C-side E accuracy tightening + snapshot source upgrade (2026-03-08)

Overlay/getobj --More-- boundary and cursor parity tightening (2026-03-08)

C harness setup: fail-fast checks for critical instrumentation markers (2026-03-08)

seed031 event parity restored (2026-03-08)

seed032 event parity recovery via fresh harness rerecord (2026-03-08)

stop_occupation boundary await correctness (2026-03-08)

seed033 occupation boundary advance: missmu now stops occupation (2026-03-08)

seed033 step-108 occupation/timeout boundary: wounded-legs timeout now follows C path (2026-03-08)

seed033 drop/getobj count boundary: * 10 d now follows C count path (2026-03-08)

seed033 farlook/getpos headless description path (2026-03-08)

Input boundary stack hardening: prompt owner is strict key consumer (2026-03-08)

seed033 whatdoes parity: intro --More-- boundary + C-style response formatting (2026-03-08)

travel/getpos prompt boundary: tip-aware verbose sequencing + invalid-target hold (2026-03-08)

Added unconditional test_move event instrumentation in both C and JS (2026-03-08)

^test_move instrumentation moved to opt-in + per-session rerecord env (2026-03-08)

Browser/headless --More-- drift audit: async queue resume mismatch (2026-03-08)

Dig failure messaging boundary: missing await in occupation path (2026-03-08)

CODEMATCH invent.c surface closure: display_pickinv/display_inventory/ddoinv (2026-03-08)

CODEMATCH invent.c ledger accuracy pass + C-name wrappers (2026-03-08)

CODEMATCH vision.c ledger correction slice (2026-03-08)

vision.c howmonseen closure slice (2026-03-08)

detect.c map_redisplay wiring closure (2026-03-08)

seed033 travel/getpos boundary localization + getpos cleanup (2026-03-08)

display.c warning/monster helper runtime closure (2026-03-08)

display.c newsym warning-vs-monster glyph parity fix (2026-03-08)

display.c warning sense gating refinement (2026-03-08)

display.c show_glyph runtime closure (2026-03-08)

display.h visibility helper wrappers closure (2026-03-08)

display.c unmap callchain closure (unmap_invisible/unmap_object) (2026-03-08)

display.c map-location callchain closure (map_object/map_trap/map_background) (2026-03-08)

display.c redraw entrypoint closure (docrt_flags/docrt/cls) (2026-03-08)

display runtime safety hardening (glyph_at/feel_newsym/underlay modes) (2026-03-08)

display legacy helper stabilization (swallow_to_glyph/wall-info helpers) (2026-03-08)

seed033 late-window boundary split (step 935/936) (2026-03-08)

^runstep command-boundary instrumentation (C+JS) (2026-03-09)

^runstep ordering refinement for seed033 event parity (2026-03-09)

seed033: replay-boundary split persists after rerecord (2026-03-09)

seed033: door/kick parity fixes moved first RNG divergence 996 -> 1295 (2026-03-09)

seed033: command-boundary runstep count-prefix metadata and menu toggle parity (2026-03-09)

seed033: C-faithful menu input boundaries for D and DEL prompts (2026-03-09)

seed033: C-faithful v version-banner message and --More-- boundary (2026-03-09)

Comparison artifacts: event stream now matches comparator policy exactly (2026-03-09)

runstep diagnostics: include pickup/nopick/travel state (2026-03-09)

dbgmapdump context/flags expansion + compare default fix (2026-03-09)

Milestone checkpoint: seed031/032/033 all green at 48a9f0da (2026-03-09)

dbgmapdump input-boundary capture: prefer auto_inp over auto_key (2026-03-09)

CODEMATCH do.c ledger accuracy pass (2026-03-09)

CODEMATCH calendar.c ledger accuracy pass (2026-03-09)

CODEMATCH detect.c: reveal_terrain_getglyph helper (2026-03-09)

CODEMATCH timeout.c: property_by_index closure (2026-03-09)

CODEMATCH large stale-ledger batch: bridge/vault/quest/wizard/stairs (2026-03-09)

CODEMATCH large stale-ledger batch II: core gameplay modules (2026-03-09)

CODEMATCH conservative repo-wide stale-ledger pass (2026-03-09)

CODEMATCH rnd.c closure + live wrapper wiring (2026-03-09)

CODEMATCH sounds.c backend surface closure (2026-03-09)

CODEMATCH wizcmds.c closure (2026-03-09)

CODEMATCH topten.c closure (2026-03-09)

CODEMATCH o_init.c discovery-surface closure (2026-03-09)

CODEMATCH save.c architecture-accurate classification (2026-03-09)

CODEMATCH dokick.c stale-ledger closure (2026-03-09)

CODEMATCH region.c wrapper closure + force-field N/A classification (2026-03-09)

CODEMATCH selvar.c compatibility-surface closure (2026-03-09)

CODEMATCH metrics refresh after March codematch burndown (2026-03-09)

CODEMATCH restore.c architecture-accurate closure (2026-03-09)

CODEMATCH pager.c compatibility-surface big bite (2026-03-09)

CODEMATCH rumors.c compatibility-surface closure (2026-03-09)

CODEMATCH dungeon.c mapseen + level-helper compatibility slice (2026-03-09)

CODEMATCH mkobj.c compatibility-surface closure (2026-03-09)

CODEMATCH mon.c compatibility-surface closure (2026-03-09)

CODEMATCH trap.c compatibility-surface closure (2026-03-09)

nhgetch cleanup: stale topline ack when switching wrap->raw (2026-03-10)

CODEMATCH makemon.c closure slice: mongen and propagation helpers (2026-03-10)

CODEMATCH do.c closure slice: command-surface wrappers (2026-03-10)

CODEMATCH ball.c closure slice: C-name compatibility aliases (2026-03-10)

CODEMATCH glyphs.c cleanup: classify non-runtime customization internals as N/A (2026-03-10)

CODEMATCH questpgr.c cleanup: classify Lua pager path as N/A (2026-03-10)

CODEMATCH cmd.c cleanup: close residual missing rows (2026-03-10)

CODEMATCH spell.c closure slice: compatibility surfaces for missing function names (2026-03-10)

CODEMATCH lock.c + getpos.c closure slice: lock wrappers and stale ledger cleanup (2026-03-10)

CODEMATCH bones.c closure slice: remaining missing helper surfaces (2026-03-10)

CODEMATCH multi-file closure slice: drawing.c + artifact.c + end.c ledger hygiene (2026-03-10)

CODEMATCH engrave.c closure slice: missing persistence/occupation surfaces (2026-03-10)

CODEMATCH multi-file closure slice: engrave.c + track.c remaining missing rows (2026-03-10)

CODEMATCH multi-file closure slice: hacklib.c + teleport.c missing surfaces (2026-03-10)

CODEMATCH multi-file closure slice: were.c + wield.c + worn.c + worm.c surfaces (2026-03-10)

CODEMATCH multi-file closure slice: insight.c + iactions.c missing surfaces (2026-03-10)

CODEMATCH multi-file closure slice: decl/monst/mplayer/objects/polyself/priest/shknam/steal/steed (2026-03-10)

CODEMATCH closure slice: monmove.c onscary completion (2026-03-10)

CODEMATCH multi-file closure slice: worn.c + steal.c (2026-03-10)

CODEMATCH multi-file closure slice: spell.c + explode.c + mcastu.c surfaces (2026-03-10)

CODEMATCH combat closure: mhitm.c helper stubs (noises, slept_monst, rustm) (2026-03-10)

CODEMATCH blindness boundary + restore-ability closure (toggle_blindness, peffect_restore_ability) (2026-03-10)

CODEMATCH read.c + mhitu.c chunk (seffect_* wiring + AD_TLPT/erosion status cleanup) (2026-03-10)

seed331 status-row stale-at-death fix (2026-03-10)

CODEMATCH uhitm.c AD-branch alignment (AD_WERE/AD_PEST/AD_FAMN) (2026-03-10)

CODEMATCH uhitm.c theft/disease branch expansion (AD_SGLD/AD_SEDU/AD_SSEX + AD_DISE) (2026-03-10)

CODEMATCH uhitm.c digestion/hallucination branch closure (AD_DGST + AD_HALU) (2026-03-10)

CODEMATCH uhitm.c branch expansion (AD_TLPT/AD_ENCH/AD_POLY/AD_SLIM) (2026-03-10)

2026-03-10: mhitu larger branch slice (AD_DGST / AD_PEST / AD_SSEX)

2026-03-10: stealamulet implementation + mhitu AD_SAMU await wiring

2026-03-10: were.c control-flow fidelity pass (you_were / you_unwere)

2026-03-10: Canonical scorefiles for deterministic C endgame captures

2026-03-10: mhitu branch-closure chunk (AD_CURS + AD_FAMN) and stale ledger fixes

2026-03-10: mhitu AD_DETH C-branch alignment pass

2026-03-10: mhitu multi-function C-flow pass (AD_DRIN/AD_SLOW/AD_STON)

2026-03-10: uhitm m-vs-m erosion/curse branch closure (AD_RUST/CORR/DCAY/CURS)

2026-03-10: ASYNC_CLEANUP runtime diagnostics and origin-await cleanup

2026-03-10: uhitm m-vs-m AD_STON stub closure

2026-03-10: uhitm m-vs-m theft/disease branch fidelity (AD_SGLD/SEDU/DISE)

2026-03-10: async m-vs-m AD runtime path for teleport/slime fidelity

2026-03-10: artifact is_magic_key bless/curse rules aligned to C role split

2026-03-10: potion command parity batch for dodip/dip_into

2026-03-10: artifact invoke selection now composes with inventory GETOBJ constants

2026-03-10: artifact invoke C-semantics follow-up (retouch + cancel paths)

2026-03-10: artifact non-power invoke cleanup (crystal ball + carried-only no-op)

2026-03-10: read.seffect_charging non-confused getobj/recharge path restored

2026-03-10: read.seffect_light non-confused path aligned to C litroom/lightdamage

2026-03-10: read.seffect_taming maybe_tame flow aligned to C

2026-03-10: read.seffect_fire blessed target flow and confused edge-cases

2026-03-10: read.seffect_earth + seffect_punishment moved onto shared C-style paths

2026-03-10: read.seffect_earth level/terrain gating tightened to C shape

2026-03-10: punish/unpunish state moved from flag-only to object-backed flow

2026-03-11: potion blindness/hallucination/levitation state transitions aligned to C order

2026-03-11: POT_WATER lycanthropy side-effects aligned to C branches

2026-03-11: invent.c surface de-stub batch (display_used_invlets/doperminv/dotypeinv/dounpaid/menu_identify/reroll_menu)

2026-03-11: monmove mind_blast hero-effect fidelity slice

2026-03-11: monmove mind_blast victim lock-on messaging + wakeup parity

2026-03-11: mind_blast victim-message experiment rollback (parity hygiene)

2026-03-11: revive_corpse floor-visibility messaging parity (seed322 screen fix)

2026-03-11: prayer lava-trouble rescue path no longer silently no-ops

2026-03-11: mapseen_temple parity closure (remove last explicit STUB row)

2026-03-11: trap dng_bottom/hole_destination context hardening

2026-03-11: dungeon hole_destination quest cutoff closure

2026-03-11: anti-magic trap monster-visibility gating parity

2026-03-11: polymorph trap monster visibility-gating parity

2026-03-11: trap/zap deltrap callsite argument-order correctness pass

2026-03-11: dodip fountain/sink object-first flow correction

2026-03-11: #dip prompt parity + dipfountain erosion topline alignment

2026-03-11: extcmd sit support + completion timing parity guard

2026-03-11: #dip no-potion branch needs object-specific topline

2026-03-11: harden spoteffects runtime context to unblock pending polymorph session

2026-03-11: wizGenesis prompt parity improvements for pending extcmd chat session

2026-03-11: stabilize potion.dodip headless/unit path after prompt refactor

2026-03-11: extcmd chat parity brought to green and promoted from pending

2026-03-11: split wizard level-port vs level-change semantics (pending seed500)

2026-03-11: close seed500_extcmd_enhance with C-faithful wizard enhance menu rendering

2026-03-11: seed503 controlled-polymorph path bring-up (partial)

2026-03-11: seed503 boundary and RNG-shape alignment (incremental, no regressions)

2026-03-11: seed503 polymorph equipment-drop slot fix (incremental)

2026-03-11: discovery-credit parity for startup vs in-moveloop (seed503 advance)

2026-03-11: seed503 fully green (movement RNG + polymorph/status fidelity)

2026-03-11: C-faithful immobile-turn draining in run_command (pending session lift)

2026-03-11: Fix booze confusion RNG drift via canonical hunger-state defaults

2026-03-11: Pending parity lift for sit/wear prompt semantics (t01_s650)

2026-03-11: Fix blessed monster-detection redraw semantics (t06_s621, t06_s622)

2026-03-11: Potion status delayed-killer parity + petrification cure call fix

Validation:

2026-03-11: C-faithful invisibility / see-invisible potion semantics

Validation:

2026-03-11: C-faithful peffect_acid behavior and side effects

Validation:

2026-03-11: Additional C-faithful potion alignment (speed/ability/gain-level)

Validation:

2026-03-11: C-faithful peffect_sickness branch parity

Validation:

2026-03-11: Spellbook-known false negative from num_spells signature drift

Validation:

2026-03-11: docast/getspell menu text and wizard column alignment

Validation:

2026-03-11: Spell casting state alignment (pw) and wizard school baseline

Validation:

2026-03-11: C-faithful directional spell dispatch (spelleffects/weffects)

Validation:

2026-03-11: C-faithful pick-axe apply boundary (unwielded case)

Validation:

2026-03-11: pick-axe direction prompt made C-faithful (dynamic dig dir list)

Validation:

2026-03-12: Theme06 seed632 parity fix (mthrowu broken-drop RNG + rust-trap mon messaging)

Validation:

2026-03-12: Theme01 sit-session coverage gains come from stable local scenarios

Validation:

2026-03-12: spell coverage via confusion-cast micro-session

Practical lesson:

Validation:

2026-03-12: Theme04 dig bundle extension (t04_s701_w_digext2_gp)

Why this approach:

Validation:

2026-03-12: Steed parity blocker triage (#ride wiring + doride flow)

Follow-up: keep u.usteed in monster list while mounted

2026-03-12: Steed movement/newsym dogmove alignment follow-up

Outcome:

Follow-up: ridden steed must bypass dog_goal()

Validation:

Follow-up: mounted hero movement budget must use steed mcalcmove

Validation:

Follow-up: mounted mattacku must include C steed-target diversion

Validation:

Follow-up: steed dismount landing_spot and unnamed-steed message parity

Validation:

mkmap mines-init crash fix from wizload coverage preflight

wizloaddes wizard prelude alignment for pending minefill parity

wizload branch topology correction and branch placement guardrails

wizloaddes level-name resolution: accept registered special levels beyond otherSpecialLevels

wizload minefill: two-stage fixup + C-style made_branch gate

wizloaddes explicit variant fidelity for minetn-* names

selection.floodfill C-faithfulness: start-tile matching and per-cell predicate coords

Wizload RNG phase analyzer for normalized/raw alignment triage

place_lregion(LR_BRANCH) must delegate to place_branch(0,0) for made_branch parity

Wizload fixup phase instrumentation (WEBHACK_WIZLOAD_FIXUP_TRACE)

Branch lregion placement is room-gated in no-room special layouts

getpos travel cursor duplicate-describe caused seed033 drift (fixed 2026-03-12)

Wizload minetown topology parity: bound_digging maze-offset quirk

Wizload minetown final parity: post-goto checkpoint timing and level-1 upstairs guard

moveloop turn-end bookkeeping: align settrack/moves boundary to C order

mondata locomotion table hardening: avoid latent runtime crash

wiz_identify input ownership: route Ctrl+I through inventory menu path

monmove async correctness: await Vrock gas cloud side-effect in monflee

vision parity: gas-cloud LOS must consult active region records

t11_s742 parity: preserve C message boundary ownership at survive exits; wizard trap rendering in map memory

t11_s742 wizard-identify parity: startup inventory known must mirror C init interplay

seed322 drift: fog-cloud gas TTL must key off canonical monster identity

2026-03-13 parity hardening: trap memory rendering and prompt-line lifecycle

2026-03-13 seed331 death-boundary fix: remove duplicate monster-death finalization and defer savebones while end prompts are active

2026-03-13 quaff stack-weight drift: use useup() (not raw quan--) after potion effects

2026-03-13 startup spellbook-ID overreach: skip P_NONE books in wizard passive ID

2026-03-13 search parity hardening: explicit doserach0(aflag=0) now includes C monster-reveal path

2026-03-13 hallucination redraw + pet display ownership: move redraws to C-faithful dochug/postmov boundaries

2026-03-13 getobj prompt wrapping parity: remove hard truncation, render two-line prompt at COLNO-1

2026-03-13 zap boundary/wrapper parity: route directional zap through prompt boundary and await buzz/ubuzz

2026-03-13 hi11 follow-up: zhitu resistance port + regen_hp Upolyd branch

2026-03-13 command-boundary umovement invariant: restore C input-loop precondition

2026-03-13 dobuzz parity tranche for hi11: wall-bounce position update + C dice semantics + kill-path handlers

2026-03-13 pet eat/message boundary stale-cell fix: refresh pet squares before blocking --More--

2026-03-13 Oracle-level rndmonst_adj + dosounds parity bring-up (t11_s744)

2026-03-13 hi11 zap-death boundary follow-up: route beam self-hit death through losehp() and narrow lifesave stop scope

2026-03-13 hi11 zap tranche: destroy-items RNG flow port (zhitu paths)

2026-03-13: blind monster visibility and debug mapdump pet state

2026-03-13: hero fire-hit parity depends on async armor-erosion messaging

2026-03-13: sink gameplay parity requires C-local quaff branch + place_object() ordering

2026-03-13: hi11 death-boundary drift was partly a combat-message fidelity bug, then a lifesave attack-tail bug

2026-03-13: queued canned commands need an explicit command-boundary more() when a topline message is live

2026-03-13 15:08 - hi11 lifesave stop should finish current movemon pass, then stop before next cycle

2026-03-13: Tame-kill thunder + awaited player item destruction moved hi11 screen drift later

2026-03-13 15:55 - lifesave stop flag must not abort inner mattacku loop

2026-03-13: hi11 display drift moved from step 401 to step 407 via blindness + invisible-memory fixes

2026-03-13 16:15 - lifesave stop must not cut off later movemon passes in the same monster phase

2026-03-13 - dochug() MMOVE_MOVED must not fall through to generic Phase 4

2026-03-13 - dochug() still blocks MMOVE_DONE from the generic attack block

2026-03-13 - getdir() prompt cursor sits one column past the visible prompt text

2026-03-13 - wizard.c:cuss() uses com_pager("demon_cuss"), not a fixed fallback line

2026-03-13 16:51 - hi11 death-status boundary fixed by restoring deferred botl semantics

2026-03-13 17:36 - hi11 zap invalid-inventory boundary fixed by prompt-owned visual –More–

2026-03-13 22:10 - pnd_s1200 prayer/search pending-session frontier moved from step 868 to 904

2026-03-13: blind : look_here path consumes time and pages before object text

2026-03-13: do_break_wand() must confirm before consuming time or RNG

2026-03-14: Per-step map-cell caching removes masked hallucination repaint drift

2026-03-14: tty_getlin() accepts at most COLNO - 1 characters

2026-03-14: Blind feel_location() must clear stale invisible markers

2026-03-14 - t11_s744: death-staging more() refresh must respect frozen-turn state

2026-03-14: Blind feel_location() must also mark adjacent squares as seen

2026-03-14: mhitm corpse creation must redraw the death square immediately

2026-03-14: pnd_s1200 blind prompt/display cleanup moved to final cursor-only gap

2026-03-14: blind dolook() must request a visible --More-- marker

2026-03-14: rloc_to_core() must remove the monster from its old square before newsym(oldx, oldy)

2026-03-14: replacement topline pages need an immediate status redraw for encumbrance transitions

2026-03-14: death-staging status refresh differs for sleep wakeups vs other helpless states

2026-03-14: avoid unconditional hallucination-name draws in m-vs-m elemental messaging

2026-03-14: repaint parity needs exact C schemas before callsite matching is meaningful

2026-03-14: getlin() owns an early repaint boundary before prompt text appears

2026-03-14: mdamageu still needs losehp-style deferred status bookkeeping

2026-03-14: shopkeeper capital must enter minvent, not just burn RNG

2026-03-14: mkgrave() must bury created objects, not discard them after RNG

2026-03-14: untimed pending-topline command tails should use flush_screen(1), not docrt()

2026-03-14: sink-ring burial must remove inventory and enter buried chain

2026-03-14: hold_another_object() must drop overflow wishes back out of inventory

2026-03-14: read prompt repaint ownership is split between prompt entry and follow-up message paths

2026-03-14: getobj() dirties status before validating the chosen inventory letter

2026-03-14: repaint debugging needs a separate owner/context channel

2026-03-14: Wizard terrain/trap wishes need live map context, C-visible messages, and trap replacement

2026-03-14

2026-03-14

2026-03-14: untimed getobj feedback should suppress only the autosave-tail repaint

2026-03-14

2026-03-14: repaint parity needs message-window cursor ownership, pre-more flushes, and visible-status consumption

Cancelled #cast direction still reuses the previous direction

Shopkeepers must use move_special() path selection, not one-tile chase

Pre-move wielding must require exact NEED_WEAPON

mattacku() needs the internal AT_WEAP wield branch too

poisoned() must use C-style composite dice logging

mfndpos() must gate obstructed tiles through rockok/treeok

Monster poly traps must call real newcham()

C repaint debug can identify hidden disp.botl writers inside a single message window

2026-03-14 20:06: melee nomul(0) dirty bit must defer across pending topline flush

2026-03-14 20:38: new shop+read+zap coverage session exposed menu-boundary bugs before gameplay drift

Wizard identify display uses display-only full names

2026-03-14 20:50: identify_pack() needed grouped PICK_ANY overlay semantics to bring hi13 green

Wizard identify display uses shared paged overlay ownership

Canonical one-key inventory inspection commands must be live in cmd.js

2026-03-14 21:58: quest-artifact wishes need both readobjnam() short-circuiting and inventory-add side effects

Help menu wizard entry must use the C-visible selector letter

2026-03-14 22:26: #invoke must normalize ECMD_TIME into { tookTime } or monster turns vanish

Cancelled #cast direction must not clear the release message row

2026-03-14 23:05: artifact cooldown penalties use C d() logging, not Lua-style d()

Internal mattacku() wield turns must print the visible wield message

2026-03-14 23:15: cmdassist invalid-direction help must dismiss on --More-- keys only

2026-03-14 23:30: wizard quest-artifact pages need fullscreen NHW_TEXT, and tired-artifact ignores use object grammar

2026-03-14 23:55: reconnaissance-first session design is the right way to reach difficult gameplay branches

2026-03-15 00:20: shop reconnaissance needs both compact and wizload checkpoint support, and minetn-5 is the cleaner first shop candidate

2026-03-15: post-hi15 coverage refresh still leaves branch coverage below 60%, and structured reconnaissance should drive the next vault session

2026-03-14: show_map_spot() must restore trap/object glyphs after newsym() during mapping

2026-03-15: invalid throw prompt needs a visible --More-- owner

wizload nhlib shuffle and special-checkpoint flip duplication were both real RNG debt for new Minetown shop coverage

2026-03-15: generated stairs must go through mkstairs()

Minetown wizload deferred finalize needs the earlier boundary hardening, and dopay() must import GOLD_PIECE

hi15 shop-pay bring-up: maze-extents split, wall-state mirroring, and first real payment path

hi15 shop-pay bring-up: unpaid pickup needs real shop billing and deferred turn ownership

hi15 shopkeeper movement after wizload: use ESHK state, not stale root shadows

hi15 shop payment bring-up: ESHK billing state and billed-purchase messaging

hi15 final shop-payment gold sync: partial coin-stack payment must update player.gold

Gameplay recon: explicit #dumpsnap checkpoints are usable inside ordinary session probes

Knox generator parity: await selection.iterate() when the callback is async

sp_lev object-form des.monster({...}) must stay random when id/class is omitted

Knox parity: irregular zoo fill and arrival-wall lighting (2026-03-15)

monmulti() must roll before class and racial bonuses

Vault coverage reconnaissance: scan seed/dlevel pairs first, then route the winning vault (2026-03-15)

hi17 ordinary vault route: vaults must stay typed, and guards must stay on the gd_move() path (2026-03-15)

hi18 polymorph-web route: #monster must dispatch the real C ability ladder (2026-03-15)

hi19 mind-flayer route: polymorph must redraw the hero glyph and sync form strength immediately (2026-03-15)

Monster multishot naming affects real page boundaries

Filtered wizard identify menus must not inject synthetic gold

Wizard identify menu now matches C pick-any paging and selection ordering

Text popup teardown must not rerender full map during detect browse_map

2026-03-15 boundary drift from command finalization and detect overlays

t11_s755 green: #sit --More-- ownership plus faithful monster auto-wear

2026-03-18: m prefix on wait/search must be consumed by that one command

2026-03-18: hero-kill XP must use experience() before newexplevel()

2026-03-18: remove deferred #sit continuation; keep --More-- inline

2026-03-19 - seed032 run-corner fix: reset last_str_turn for ordinary run/rush

2026-03-19 - restoring branch/fill-level generation flushed out latent monster-inventory placeholders

2026-03-19 - stair traversal must not acknowledge --More-- before goto_level

2026-03-19: Is_stronghold() must not consume RNG via special-level variant selection

2026-03-19: depth() must use dungeon depth_start, not ledger numbering

2026-03-19: special-level random alignment can still depend on live dungeon context

mineralize() gem count uses branch-local dunlev, not absolute depth

Use focused mfndpos / MONMOVE_TRACE instrumentation for ordinary monster seams

Runtime special-level identity matters for align_shift() on protofile branch fillers

Thrown-hit parity: hmon() must own both damage dispatch and death resolution

2026-03-20: Pickup menu ordering must use cxname_singular() like C sortloot()

2026-03-20: mpickstuff() must not inherit the search-path ROCK skip

2026-03-20: Mixed floor piles with gold must still use Pick up what?

2026-03-20: addinv() stack merges must respect C’s hallucination-aware knowledge gates

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

  1. Confirmed the regression was from the OTHER engineer’s commit (not ours) by checking out the old mkobj.js and verifying the session passed.
  2. Added temporary console.log in stackobj() to print dknown values when same-type objects fail to merge: STACKOBJ_NOMATCH otyp=471 name=rock dknown=false/true.
  3. The false/true mismatch immediately identified the root cause.

General principle

When a correct parity fix regresses a session:

2026-03-20: T armor removal must reuse armoroff() timing, not a JS-only occupation

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

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:

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

2026-03-20: LVLINIT_MINES must clear stale mazelevel after mkmap()

2026-03-20: turn-end random spawn depth must use level_difficulty()

2026-03-21: Stage repeat-owner rewrites must preserve counted-command multi

2026-03-21: Stage B1 should split JS moveloop helpers before moving travel ownership

2026-03-21: Stage B2a can extract the movement-repeat slice safely before moving ownership

2026-03-21: Stage B2b showed the owner move alone does not fix seed031

2026-03-21: Deeper C read found missing repeat-slice invariants beyond ownership

2026-03-21: Local Stage C3 invariants alone were not sufficient

2026-03-21: The next target is JS’s split travel ownership, not a prompt-owned frame

2026-03-21: Top-level repeat-slice substitution helped later spillover, but prompt-finalization specialization did not

Validation:

Lesson:

2026-03-21: Reordering the top-level travelPath branch was safe but not sufficient

2026-03-21: _ travel is resumed through the pending command, not promptStep()

2026-03-21: Combined owner correction reduced spillover but still did not move the first seam

2026-03-21: The first resumed _ slice already contains C’s later positive-repeat monster work

2026-03-21: The concrete collapsed boundary is finalizeTimedCommand() -> local repeatLoop()

2026-03-21: The local collapsed boundary is the dominant spillover contributor

2026-03-21: Full deferred-travel implementation still collapsed to the same partial result

2026-03-21: Positive travel still lacks a C-faithful no-input owner in _gameLoopStep()

2026-03-21: A positive-multi lane inside _gameLoopStep() is still too fused

2026-03-21: One positive-repeat slice per _gameLoopStep() return moves seed031 later

2026-03-21: Exact C stop-before-attack is now the right local probe, but it exposes a narrower remaining bug

2026-03-21: Deferring repeated travel timed turns helps, but monster 27 still gets its target too early

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:

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:

  1. uncommon() missing Inhell conditional (makemon.js:422-432)
  2. Missing G_NOHELL check in rndmonst_adj loop body — C’s makemon.c:1686-1687 excludes G_NOHELL monsters when in hell. JS had no separate check after uncommon().
  3. rndmonnum_adj Plan B fallback — C’s mkobj.c:407 uses Inhell ? G_NOHELL : G_HELL for exclude flags. JS always used G_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

2026-03-21: The remaining gas-spore near seam is still cross-step, not yet a proven monster-state bug

2026-03-21: the command-boundary invariant was useful, but the active fix target has shifted to a slice-level target-refresh invariant

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

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

2026-03-21 - seed031 apply/pick-axe prompt and occupation ownership

2026-03-21 - seed031 monster pickup how_lost and minventory merge parity

2026-03-21 - seed031: pickup menu weapon subclass ordering was still non-C-faithful

2026-03-22 - seed031: branch-local level changes were reusing stale depth cache entries

2026-03-22 - seed031: gnome candle quantity and hero-hit mbhitm() reveal path

2026-03-22 - seed031: aklys stand-off range unblocks current-square monster pickup

2026-03-22 - seed031: await monster wand-hit death path before continuing beam travel

2026-03-22 - seed031: restore monster-wand floor-object hits via bhito

2026-03-22 - seed031: don’t reset ux0/uy0 in generic command parsing

2026-03-22 - seed031: resume existing meals through start_eating()

2026-03-22 - seed031: chest take-out submenu was erasing map rows under the menu

2026-03-22 - dbgmapdump: C-side manual-direct captures must use normalized replay keys

2026-03-22 - dbgmapdump: manual-direct C snapshots need replay chargen metadata and post-step capture phases

2026-03-22 - dbgmapdump: report prompt-owned steps that have no post-step C boundary

2026-03-22 - dbgmapdump: expand JS victual state instead of collapsing it to an object ref

2026-03-22 - dbgmapdump: add debug-only monster movement/flee state to N rows

2026-03-22 - resumed eatfood() floor-object checks must pass game.map

2026-03-22 - delobj() must honor floor map context

2026-03-22 - floorfood() / objectsAt() must agree on top-of-pile order

2026-03-22 - after the floor-meal fixes, the live seed031 seam moved into tame-pet dog_invent()

2026-03-22 - pet oeaten timing must shorten meating

2026-03-22 - bones timing belongs in really_done(), and bones depth is branch-adjusted

2026-03-22 - remaining seed031 screen seams are tty/windowport details, not gameplay

2026-03-22 - seed031 late screen seam is a hallucination display-stream mismatch, not gameplay


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:

  1. Stale travelPath: domove_core didn’t clear travelPath before calling findtravelpath. When findtravelpath returned false (player at target), the old path was reused, causing an extra move.
  2. Ghost travel continuation: _gameLoopStep’s travel fallback checked travelPath without checking context.travel. After nomul(0)end_running(true) cleared context.travel, the stale travelPath re-triggered dotravel_target, consuming replay keys.
  3. Run/travel batching: _gameLoopStep used return after runMovementRepeatSlice, causing the replay to advance keys between continuation steps. C’s moveloop_core loops without reading keys. Changed to continue.

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

2026-03-23 - manual-direct replay after V4 cleanup still needs transformed startup semantics

2026-03-22 - hallucinating menu overlays need a frozen underlay

2026-03-22 - frozen-overlay snapshots must handle both browser and headless cell shapes

2026-03-23 - seed031 late endgame parity: score/tombstone/topten are real progress; remaining seam is final conduct state

2026-03-23 — wizard-mode welcome, AUTOCOMPLETE, and remaining 14 failures

Wizard-mode lore+welcome fix (+7 sessions)

AUTOCOMPLETE flag matching (+1 session)

Map session –More– investigation

Remaining 14 failure categories

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:

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:

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:

  1. Understand why C consumes nhgetch keys during travel that JS doesn’t
  2. Check if C’s travel loop has an explicit nhgetch for interruption checking that JS’s runMovementRepeatSlice lacks
  3. 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:

Impact

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:

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:

  1. Split steps at every nhgetch() call (matching C’s recording)
  2. 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:

Validated effect

On current origin/main (f133e84c), this moved seed032_manual_direct.session.json first RNG divergence:

Guardrails:

Next seam

The next seed032 blocker after this fix is later pet/monster behavior:


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:

Guardrails:

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:

  1. JS clears topMessage at different points than C clears toplines
  2. JS’s flush_screen(1) at line 286 doesn’t match C’s more() call in update_topl line 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

2026-03-28 — duplicate pre-check_special_room() shop greeting was stale, but not the active blocker

2026-03-28 — preserve empty-string nomovemsg in unmul() to avoid fake “You can move again.”

2026-03-28 — goto_level() punished descent must run drag_down() before arrival relocation

2026-03-28 — combining single-object floor feedback on current main moved seed032 from 390 to 485

2026-03-28 — implementing #apply leash handling fixed the seed032 leash seam

2026-03-28 — unpaid shop drops must call sellobj() in dropz()

2026-03-28 — menu_drop() needs a real in-band PICK_ANY selection loop

2026-03-28 — lowercase shop drops must use deliberate sellobj_state()

2026-03-28 — unpaid pickup quote must not split into a second pickup ack owner

2026-03-28 — scroll punishment must use the live game map when calling punish()

2026-03-28 — hero movement must run punishment drag logic in domove_core()

Session 36 Findings (March 28, 2026)

Tutorial spell clearing (seed033 +112 RNG)

Dlvl display bug (seed031 +178 screens)

HP display timing at –More– boundaries

seed033 tutorial drop and falling parity (597 -> 628)

seed033 tutorial boulder squeeze prompt ownership

seed328 Medusa level divergence

seed033 post-tutorial-dnum fix

seed033 stair arrival + boulder squeeze inline (March 29, 2026)

Dead monster flee/passive guard (March 29, 2026)

HP display timing investigation (March 29, 2026)

Priest AI dispatch + do_attack_core area sweep (March 29, 2026)

hitum mhitm_knockback parity fix (March 29, 2026)

Potion makeknown calls (March 29, 2026)

Display-only failure class analysis (March 29, 2026)

seed033 upstairs return must prefer actual stairway arrival over dndest

seed033 tutorial exit must restore cached main level and hero position