Commit 76d0fd0 moved function-locals from a codegen-minted
`$0300+` absolute range into the analyzer's zero-page
allocations so inline-asm `{param}` substitutions resolve
correctly (compiler-bugs.md #1). Observable semantics are
preserved — the analyzer + codegen now agree, and every
primitive that used to work still does — but the emitted ROM
bytes change whenever a function reads or writes a local,
because zero-page addressing uses a 2-byte instruction and
absolute addressing uses 3.
Consequences that need regenerated artifacts:
- **Twelve committed `.nes` files are stale.** Same source, new
compiler, different bytes. The `Build Examples` CI job
rebuilds each example into a tmp path and diffs against the
committed ROM, so any drift is a hard failure. Rebuilt all
twelve (arrays_and_functions, bitwise_ops, coin_cavern,
function_chain, loop_break_continue, mmc1_banked, platformer,
pong, sprites_and_palettes, state_machine, structs_enums_for,
war).
- **Three goldens drift by one animation frame.** Zero-page
addressing shaves a cycle per local access, which over a full
frame handler shifts timing-sensitive sequences by a cycle or
two. war's dealing animation and platformer + pong's audio
tick stream catch the shift at frame 180 — war's card under
player A's deck is now one frame earlier in its slide, and all
three programs' captured audio buffers start from slightly
different envelope positions. The new goldens (`war.png` + the
three `.audio.hash` files) reflect the same code compiled with
the cycle-count-corrected primitives.
- **`platformer.gif` and `war.gif` rebuild.** Same one-frame
timing drift, integrated across 360 frames of captured
gameplay — the emulator job's gif-reproducibility check
wouldn't pass without the refresh. `pong.gif` happened to
byte-match the old capture after rebuild.
All verified:
- `cargo clippy --all-targets -- -D warnings` clean on both
rustc 1.94.1 and 1.95.0.
- `cargo test --all-targets` — 616 + 3 + 75 tests pass.
- Full emulator harness — 34/34 ROMs match goldens.
- Committed-ROM reproducibility diff clean — every
`examples/*.ne` compiles byte-identical to its committed
`.nes`.
- `docs/{platformer,war,pong}.gif` byte-match fresh captures.
- SHA-256 of "NES" still computes to `AE9145DB…4E0D`.
https://claude.ai/code/session_01FRmSBruVWCufm3LsUVMs8v
`const_fold_block`'s per-block dead-code pass was collecting temp
usage from only the block it was folding, so a `LoadImm` whose
destination is consumed by a *sibling* block (for example via the
merge block's branch terminator) was incorrectly treated as dead
and dropped. The `and` / `or` short-circuit lowering emits exactly
that shape: the false path writes `LoadImm(result, 0)` and joins
with the right path at an `and_end` / `or_end` block whose branch
terminator reads `result`. After the DCE the false path's store
was gone, leaving the zero-page result slot to carry whatever value
the *previous* `and` / `or` evaluation had written there — stale
data that bled into subsequent conditional branches.
I found this while instrumenting `examples/platformer.ne` through a
puppeteer-driven jsnes harness, stepping one frame at a time and
snapshotting the full zero-page trace of each scenario (title-skip,
hold-right, hold-left, jump-spam, coin-drift, enemy-stomp, long-run).
In a clean idle run the enemy-1 stomp bounce (`rise_count = 6`,
`fall_vy = 0`) fired at emulator frames 83 and 96 with `camera_x`
= 61 and 74, i.e. with `e1_sx` = 39 and 26, nowhere near the
intended `[72, 96)` pickup window. The trigger turned out to be
the slot alias: every time `c2_sx` landed in its pickup window
(so the coin-2 `and` stored 1 into ZP(130)) and the player was
mid-fall at or past `player_y = 152`, the enemy-1 stomp `and`
short-circuited to its false path, left ZP(130) at 1, and the
stomp `if` fired on stale data.
The fix is to compute function-wide source-operand usage once before
folding each function's blocks and OR it into the per-block liveness
check, so a LoadImm is only dropped if nobody — neither its own
block nor any other block in the function — reads the temp. Added a
regression test (`const_fold_preserves_loadimm_used_by_sibling_branch`)
that builds the exact CFG shape the `and` lowering emits and
verifies the false-path `LoadImm(result, 0)` survives optimization.
Impact on the example ROMs:
- `examples/platformer.nes`: enemy-1 stomp now fires only when
`e1_sx ∈ [72, 96)`, as the source intends. The pixel golden is
unchanged (`player_y` converges back to the ground line before
frame 180), but the audio hash flips because the spurious
`play hit` sfx calls during coin-2 passage are gone. Committing
the new `tests/emulator/goldens/platformer.audio.hash`.
- `examples/logic_ops.nes`, `examples/bitwise_ops.nes`,
`examples/match_demo.nes`, `examples/mmc3_per_state_split.nes`,
`examples/two_player.nes`: byte-different but observably
unchanged — their pixel + audio goldens still match to the byte.
They exercise `and` / `or` in the source and now compile through
the corrected DCE.
All other example ROMs are byte-identical to pre-fix. `cargo fmt`,
`cargo clippy --all-targets`, `cargo test --release` (498 tests),
and `tests/emulator/run_examples.mjs` (22/22 goldens) are clean.
https://claude.ai/code/session_013Bi4H4YQ5or5HtMB4doUFi
The compiler is deterministic: rebuilding any example produces
a byte-identical ROM, verified across all 22 examples and all
four mappers (NROM, MMC1, UxROM, MMC3). That means the .nes
files are reproducible artefacts and can live next to their
sources without drift.
Benefits:
- Users can clone the repo and open any example in an emulator
without installing a Rust toolchain or running the compiler.
- The emulator harness can trust examples/*.nes directly, so its
CI job no longer needs a compiler build or a "compile all
examples" loop — it just boots jsnes against the committed
ROMs and diffs each against its golden.
- ROM diffs in PRs are now meaningful: "this compiler change
flipped 17 bytes in hello_sprite.nes" is visible review
signal, not hidden behind the emulator golden.
Guard rails so the ROMs don't drift from their sources:
- .gitignore no longer excludes *.nes.
- The `examples` CI job rebuilds every .ne into /tmp and fails
loudly (with a GitHub error annotation pointing at the exact
cargo command to rerun) if any committed ROM differs.
- scripts/pre-commit does the same check locally.
- CLAUDE.md now states that editing a .ne file requires
rebuilding its .nes in the same commit, so future agents
won't miss the invariant.
Total footprint: 22 ROMs, 624 KB (avg 28 KB each — most are
NROM 24 KB; two banked examples are larger).
https://claude.ai/code/session_01BcCcHi6FUmTh8jC7UgkA3A