Debugging in-process replay failures
How to find and fix the class of bugs where a replay passes on its own but fails when run after another replay in the same process (the "in-process batch").
The bug class
ZC normally runs one quest/game per process. The in-process batch harness runs many replays back-to-back in one process to flush out global/static state that isn't reset on game start. A leftover value from game A bleeds into game B and desyncs it.
Every fix so far has the same shape: a file-static or global that is implicitly
"fresh" at process start but never re-initialized when a new game begins. The fix is
almost always a one-line reset in init_game_vars() (src/zc/zelda.cpp) or
HeroClass::init() (src/zc/hero.cpp), next to the existing resets there.
Examples already fixed (good reference for the pattern):
next_uidsprite counter (src/sprite.cpp) →sprite::reset_uid_counter()hero_newstep/hero_newstep_diagcollision step (src/zc/hero.cpp)z3step,Hero.hitdir,Hero.holditem,climb_cover_x/y, scroll-from statepalette_user_tintedscript tint (a render-only leak — see below)subscreen_open,screen_states(already cleared viaclear_screen_states())
Tools
Engine flags (build: cmake --build build --config Release -t zplayer, exe at
build/Release/zplayer):
-replay-batch <batch file>: run every replay in the batch file in one process. Each line is<absolute-path> [frame-limit]. Mode comes from a bare-assert/-replay/-updateflag. Always pass-headlessfor hunts.-state-hash-log <file>: append per-frame FNV-1a hashes of game-state regions (hero, rng, globalRAM, objectRAM, sprites, viewport, ffc, screen, scroll) to a file. Also dumpsHEROFIELDS(fullHeroClass::debug_state_string()) at frame 0.
Runner (tests/run_replay_tests.py):
--batch --seed N --frame 1000: shuffle all replays into batches (seeded) and batch-run each batch. Writesfailing_batch_NN.txtfor each failing batch and prints a re-run command.--replay-batch <file>: run exactly the replays in one batch file (in order, in one process) and report per-replay pass/fail with the failing frame. This is the reliable pass/fail signal.tests/replays_vary_seed.sh N ...: run N seeds, stop at first failure.
Scratch repros live in .tmp/failure_groups/ (gitignored, absolute paths). The
durable regression suite is tests/replay_batches/*.txt — committed batch files
with paths relative to the repo root, run by tests/test_replays.py::test_batches
(it rewrites them to absolute paths and asserts every replay in each batch passes).
Workflow
Find a failing batch.
python tests/run_replay_tests.py --batch --seed 6014 --frame 1000. Copy thefailing_batch_NN.txtfiles somewhere safe.Find which replay actually fails.
--replay-batch failing_batch_NN.txt. The runner prints❌ … failure on frame X. Note: a replay that fails solo is NOT an in-process leak — verify solo first and skip it if it fails alone.Bisect the poisoning predecessor. Build a 2-line batch file:
<predecessor> 1000then<target> 1000. Try each predecessor (and combinations — some need two). Reproduce the failure with the runner, not by grepping engine output.Pin the diverging subsystem with
-state-hash-log. Run the target solo and poisoned, each with its own log, then diff the target's lines column-by-column to find the first frame and the first differing column:./build/Release/zplayer -assert -headless -replay-batch solo.txt -state-hash-log /tmp/s.log ./build/Release/zplayer -assert -headless -replay-batch pois.txt -state-hash-log /tmp/p.log paste <(grep 'TARGET.zplay frame=' /tmp/s.log) <(grep 'TARGET.zplay frame=' /tmp/p.log) \ | awk '{split($0,a,"\t");split(a[1],s," ");split(a[2],p," "); for(i=2;i<=length(s);i++) if(s[i]!=p[i]) print s[2],"DIFF:",s[i],"vs",p[i]}' | headDrill to the exact field. If the
hero/herointcolumn diverges, diff the frame-0HEROFIELDSlines token-by-token (watch out: a naivegrep '[a-z_]+='drops camelCase fields likediagonalMovement). If the differing value isn't indebug_state_string, temporarily add the suspect field to it (or add a temp hash column todump_state_hashesinsrc/zc/ffscript.cpp) and rebuild. Iterate until you see the exact leaked field.Confirm before fixing. Always prove the field leaks with the instrument first. Many plausible suspects (climb_cover, hitdir, z3step) turned out to be real leaks that were not the cause of a given test — fixing them didn't flip the test. Confirm the field differs at the first divergence frame, fix it, and re-verify with the runner.
Fix by resetting the global/static on game start in
init_game_vars()orHeroClass::init()(mind thereplay_version_check(N)guard pattern used byz3step— it preserves old-replay behavior while fixing new ones). Add a comment explaining the leak. Rebuild and confirm the repro passes via the runner.Add a regression batch file — REQUIRED for every in-process leak fix. Create
tests/replay_batches/<name>.txtwith the minimal<predecessor> <frame>/<target> <frame>lines that reproduced the failure, using paths relative to the repo root (e.g.tests/replays/playground/ghost_armos.zplay 200) and a leading#comment describing the leak it guards.test_batchespicks it up automatically; run it to confirm it passes now (and ideally that it fails before your fix). This is what prevents the leak from silently coming back.Commit each distinct leak as its own
fix(zc): …commit (engine fixes go at the tail of history); commit the regression batch file with it.
Reading a .roundtrip file (what diverged, in replay terms)
When an -assert replay fails, the engine writes <name>.zplay.roundtrip next to the
replay output (e.g. .tmp/test_results/<id>/<run>/batch_<g>/<name>.zplay.roundtrip); the
runner also embeds it in the failure output. It is the most direct view of what diverged,
in the replay's own event vocabulary — often more legible than a state-hash diff.
It is a side-by-side annotation (written in replay_save, src/zc/replay.cpp):
- The left column is what this run actually did (the recomputed
record_log). - Where a step differs from the recording, the line is annotated
… « <step>. The right side of«is what should have happened — the recorded/expected step (replay_log). - Trailing lines with a blank left and
« <step>are recorded steps the run never reached (it diverged or ended early). A run that desyncs and quits early looks like a few«-only lines at the bottom.
The first « line is the first divergence. Read it as: left = what your (poisoned) run
produced, right = what the correct (solo) run produced.
Step syntax (from print_step): M k v = metadata header; C <frame> g <hash> = per-frame
framebuffer (gfx) hash; C <frame> <text> = engine comment (init_game, cont_game,
getitem <name>, sfx <name>, screen-state, …); D <frame> [k] <btn/key> = press, U =
release; Q <frame> <quit> = the Quit value was set. The diverging step usually names the
subsystem that leaked — a menu action, an item grant, a Quit, a warp.
Worked example (terror_27 → hero_of_dreams): left showed D 1024 k F6 → Q 1024 1 →
C 1025 init_game (the poisoned run's F6 press quit immediately); the « right side
showed D 1024 k F6 / U 1025 k F6 / D 1035 Start / … (the recording's F6 opened a menu the
player navigated and continued from). Conclusion: the F6 menu took the wrong path when
poisoned → F6-menu availability state leaked from the previous quest.
Render-only leaks (game state matches, replay still fails)
If all -state-hash-log columns match but the replay still fails, the leak is in
rendered output, not game state. Debug replays record a per-frame framebuffer hash
(the g comments, via replay_step_gfx / hash_bitmap); the runner's "failure on frame X"
can be a gfx-hash mismatch. The state-hash instrument can't see this by default.
To pin it, temporarily add a gfx column to dump_state_hashes that calls a helper
wrapping hash_bitmap(framebuf) (expose one from replay.cpp). The framebuffer hash is
computed on a 24bpp conversion, so it reflects the palette — index-based game
state can match while colors differ.
Worked example (the palette_user_tinted fix): terror_of_necromancy_demo6_34, poisoned by
enigma, had identical game state but a constant framebuffer diff from frame 1. The map
viewer (ViewMap) made it visually obvious. Chain: gfx differs → palette (RAMpal) differs
→ nonblack=14 vs 234 (game palette blacked, GUI colors survive) → narrowed within
init_game() to the step between loadfullpal and loadlvlpal → loadpalset/loadlvlpal
route to tempgreypal instead of RAMpal (and restoreTint blacks RAMpal) when
isUserTinted() is true → palette_user_tinted leaked true from enigma's script tint.
Fix: isUserTinted(false) in init_game_vars().
Gotchas
- Don't trust
grep unexpectedon headless-replay-batch.-unexpected.pngfiles are only written in non-headless / snapshot mode. Use the-state-hash-logdiff, or the runner's--replay-batchpass/fail, as the source of truth. - Some replays need two predecessors to reproduce; one alone won't poison.
-snapshotand some report features reset per-replay in batch — use single-replay mode when you need them.- A divergence in
heroint(the comprehensive hero hash) with no visible/gameplay effect can be a harmless leaked field; confirm it actually causes the failure before committing.