Struct field types beyond the v1 scalar set (`u8`, `i8`, `u16`,
`bool`) used to error out with `E0201: struct fields must be
u8/i8/u16/bool`. The size accumulator already handled them
correctly — what was missing was: (1) the analyzer side that
synthesizes per-leaf symbols and allocations for nested structs
plus a single array-typed symbol for array fields, (2) the
parser's chained-field-access path, and (3) the IR-lowering
recursion through nested struct literal initializers and array
literal field values.
The synthetic-variable model carries through unchanged: a
`var p: Player` where `Player { pos: Vec2, hp: u8, inv: u8[4] }`
and `Vec2 { x: u8, y: u8 }` produces flat allocations for
`p.pos.x`, `p.pos.y`, `p.hp`, and `p.inv`, plus an intermediate
`p.pos` Struct symbol so dotted-name lookups still resolve. Array
fields get a single allocation with the array type so the
existing `Expr::ArrayIndex` lowering path handles `p.inv[i]`
without changes. Array-of-structs is still rejected with E0201
because the synthetic model can't index per-element layouts
without further codegen work.
The parser change is the only structural move: `parse_primary`
and `parse_assign_or_call` now loop the dot chain into a single
joined identifier so `p.pos.x` becomes `FieldAccess("p.pos", "x")`
and `p.inv[0]` becomes `ArrayIndex("p.inv", 0)`. The downstream
analyzer and IR lowering use the same `format!("{name}.{field}")`
join they already used for one-level access — no plumbing
changes required.
Includes a new `examples/nested_structs.ne` that exercises both
features end-to-end with two `Hero` instances carrying nested
positions and inventory arrays. The reproducibility tripwire
ROM is committed alongside it and the emulator harness has a
matching pair of golden files.
https://claude.ai/code/session_01KEczoNUX3WmcFLfq6iAQxB
Writing $07 in `emit_play_triangle` and $0B in `emit_play_noise`
meant that a noise play following an in-progress triangle note
would clear bit 2 of $4015 and cut the triangle off mid-envelope
(and vice versa). Write $0F from both paths so every trigger keeps
pulse1, pulse2, triangle, and noise enabled; channels with no
active envelope stay silent via the runtime's per-channel counter
gating. Also fixes the attribute-byte packing comment in
`png_to_nametable` — the code was correct, the doc string had the
quadrant order backwards.
The only observable ROM change is `examples/noise_triangle_sfx.nes`
(two immediate operands shift) and its audio hash golden; the
committed PNG golden is byte-identical. Found in independent code
review after the section landed.
https://claude.ai/code/session_01MaNVcDmK9gsspRkdxowQAM
Adds a `bank Foo { fun bar() { ... } }` parser form so user functions
can opt into living in a switchable PRG bank instead of the fixed
bank, plus the IR codegen, runtime, and linker work to make calls
across the bank boundary actually run. Programs that don't use the
new syntax produce byte-identical ROMs to before — verified by
rebuilding every existing example and diffing.
Pipeline shape:
* Parser accepts both `bank Foo: prg` (legacy reserved slot) and
`bank Foo { fun ... }` (functions land in the named bank). Nested
functions get tagged `bank: Some("Foo")` on the FunDecl + IrFunction.
* Analyzer bumps the user zero-page start past `$10` whenever the
program declares any banked function, so `__bank_select`'s STA into
ZP_BANK_CURRENT can't clobber a user variable. Programs without
banked functions keep the legacy `$10` start.
* IrCodeGen emits each banked function into its own per-bank
instruction stream (`banked_streams: HashMap<String, Vec<Instruction>>`)
while the fixed-bank stream gets the dispatcher loop + state
handlers + top-level functions, exactly like before. Cross-bank
calls from the fixed bank rewrite `JSR __ir_fn_<name>` to
`JSR __tramp_<name>`; in-bank calls stay direct. Banked → fixed
calls are direct (the fixed bank is always mapped at $C000-$FFFF).
Banked → other-banked calls aren't supported in this pass and
panic loudly during codegen.
* Runtime's `gen_bank_trampoline` takes the trampoline label and
entry label as parameters now (one trampoline per banked function,
not one per bank) so the linker can request any number of stubs.
* Linker assembles banked banks twice: a discovery pass to learn
each bank's labels, then a final pass that seeds the merged label
table so banked code can JSR into the fixed bank's runtime helpers
(math, audio, etc.). The fixed-bank assembler is also seeded with
the cross-bank labels so the trampolines' `JSR __ir_fn_<name>`
resolves into the bank's $8000 window. New `asm::assemble_with_labels`
/ `asm::assemble_discover_labels` helpers wire this up.
* PrgBank carries `Vec<Instruction>` + a list of `BankTrampoline`
requests now, replacing the old `data: Vec<u8>` + single
`entry_label: Option<String>` shape. The compiler populates both
from the codegen output; the linker's two-pass assembly handles
the rest.
New example: `examples/uxrom_user_banked.ne` puts a sprite-stepping
helper inside `bank Extras { fun step_animation() { ... } }`. The
fixed-bank state handler calls it via the generated trampoline, and
the harness golden locks in pixel + audio output at frame 180.
UxROM is the only mapper exercised by the new example. MMC1 and
MMC3 also work through the same path (the linker emits the right
mapper-specific bank-select code), but no example uses them yet —
the existing `mmc1_banked.ne` / `mmc3_per_state_split.ne` keep
their fixed-bank-only layout.
Limitations carried forward:
* No banked → banked cross-bank calls (panics in codegen).
* No greedy size-packing; placement is explicit-only.
* MMC3 state handlers don't get banked (the per-state split path
is untouched).
Adds `channel: triangle` / `channel: noise` to the `sfx` declaration
form. The existing pulse-1 / pulse-2 driver is unchanged (and is
still byte-identical for programs that don't use the new channels)
— when a program declares a triangle or noise sfx the runtime
splices in an additional per-channel slot that writes to $4008-
$400B (triangle) or $400C-$400F (noise) on play. Includes a new
`examples/noise_triangle_sfx.ne` demo with committed golden PNG +
audio hash.
https://claude.ai/code/session_01MaNVcDmK9gsspRkdxowQAM
The previous platformer example drew enemies but had almost no
interaction with them: only enemy 1 had a stomp check, the stomp
window was unreachable under the default +1-px-per-frame-plus-a-
jump-every-40-frames autopilot, contact from any other angle was
a silent no-op, and the header comment promised a "title → playing
→ game-over state machine" that didn't actually exist. The README
demo gif and the committed golden both froze that state — a level
the player could walk through indefinitely with no consequence.
Flesh the enemy interaction model out into something real:
- `resolve_enemy_hit(e_sx)`: one helper, called symmetrically for
both enemies. Computes the player/enemy hitbox overlap (horizontal
in `e_sx ∈ (72, 96)`, vertical in `player_y ∈ (152, 176)`) and
branches three ways — falling onto the head is a stomp bounce
(`rise_count = 6`, `fall_vy = 0`, `stomp_count += 1`, `play Boing`);
overlap while `rise_count > 0` is a grace pass-through so the
stomp bounce itself can't retrigger contact on the same enemy;
anything else (walking into the side, standing on the ground
against the enemy) is fatal — `alive = 0` and `play hit`.
- New `GameOver` state: draws four enemy tiles across the middle
of the screen plus a coin row sized to `stomp_count`, stops the
music, lingers 60 frames then auto-retries, and also honours
Start for an instant retry.
- Proximity-based autopilot: pre-jump when an enemy is exactly 19 px
ahead (`e1_sx == 99` or `e2_sx == 99`), capped at two jumps per
life by `auto_jumps < AUTOPILOT_JUMPS`. Tuning: a JUMP_RISE=12,
GRAVITY_CAP=4 jump lands the player's feet at enemy-head height
exactly 21 frames after lift-off, by which point the autopilot
camera has scrolled the enemy under the player. The first jump
fires on Playing frame 1 and stomps enemy 1 on frame 22; the
second fires on Playing frame 101 and stomps enemy 2 on frame
122. After that the autopilot is exhausted and the third enemy
encounter (camera wraps back past enemy 1) is fatal — the
golden harness now sees the full stomp, stomp, die, retry, stomp
loop instead of a frozen walk.
- Live HUD: up to four coin sprites in the top-left, one per
stomp, rendered both during `Playing` and on the `GameOver`
screen so the score is visible in the death frame. `Playing`'s
player draw is now guarded by `if alive == 1` so the hero
disappears on the fatal-contact frame and the enemy that killed
them is visible underneath.
Verified with a per-frame ZP trace through the patched puppeteer
+ jsnes harness: first stomp at emu frame 44 (camera_x=22), second
at emu frame 144 (camera_x=122), death at emu frame 283 (camera_x=5
after a 256-px wrap), `Playing` restart at emu frame 343, third
stomp at emu frame 365. All 22 emulator goldens still match after
the update, and `docs/platformer.gif` regenerated from the new ROM
now shows two clean stomps, a clean side-collision death, the
GameOver screen, and the retry cycle all inside the 6-second demo
window.
Golden updates:
- `tests/emulator/goldens/platformer.png` — the frame-180 capture
now shows the hero walking forward with a two-coin HUD after
both autopilot stomps (previously: a frozen bouncing hero).
- `tests/emulator/goldens/platformer.audio.hash` — the track now
includes two `Boing` stomp bounces, which shifts the hash.
- `examples/platformer.nes` — rebuilt from the rewritten source.
Also updates the platformer rows in `README.md` and
`examples/README.md` to match the new gameplay.
https://claude.ai/code/session_013Bi4H4YQ5or5HtMB4doUFi
The optimizer fix in the previous commit changes the observable
gameplay of `examples/platformer.ne` — pre-fix the player got
spurious enemy-1 stomp bounces every time coin 2 drifted into its
pickup window, so the README demo gif showed the player bouncing
mid-air around emu frames 85-125 instead of walking through the
coin at ground level. Regenerate `docs/platformer.gif` from the
fixed compiler so the README matches reality.
To stop this from drifting again, treat the gif the same way the
repo already treats `examples/*.nes`:
- `gifenc` + `jsnes` + the harness are deterministic, so a fresh
recording byte-matches a valid commit. Verified across two
back-to-back runs (identical md5).
- `.github/workflows/ci.yml`'s `emulator` job now renders the gif
into `/tmp/platformer.gif` and `cmp`s it against `docs/platformer.gif`,
emitting a `::error` annotation pointing at the exact rerun
command if the committed copy is stale. This piggybacks on the
existing puppeteer + node setup, adding ~20s to the job.
- `scripts/pre-commit` runs the same check locally, but only when
`examples/platformer.{ne,nes}`, `tests/emulator/record_gif.mjs`,
or `tests/emulator/harness.html` is staged, and only if
`tests/emulator/node_modules` is already installed. Cold-start
puppeteer is ~20s — too slow to pay on every commit, but cheap
enough to pay when something gif-relevant changed.
- The header of `tests/emulator/record_gif.mjs` and the project
conventions section of `CLAUDE.md` both spell out the rerun
command and the invariant, so the next agent doesn't have to
re-derive any of this.
https://claude.ai/code/session_013Bi4H4YQ5or5HtMB4doUFi
`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
Adds six NES-friendly authoring shortcuts so programs don't have to
hand-pack hex bytes for every kind of art asset. Every new syntax is
strictly additive — existing examples keep their byte-identical ROMs
and goldens.
* palette: ~50 named NES colours (`black`, `sky_blue`, `dk_red`, …)
usable anywhere a colour byte is expected, plus a grouped-form
`bg0..sp3` + `universal:` shape that auto-fills every sub-
palette's first byte (fixing the `$3F10` mirror trap).
* sprite: `pixels:` ASCII-art alternative to 16-byte CHR, supporting
multi-tile sprites split in row-major reading order.
* sfx: scalar `pitch:` matching the v1 driver's latch-once behaviour,
plus `envelope:` as a friendlier alias for `volume:`.
* music: `tempo:` default duration + note-name notes (`C4, Eb4,
rest 10`) alongside the existing `pitch, duration` pair form.
* background: `legend { '.': 0, '#': 1 }` + `map:` string rows,
plus `palette_map:` grids that auto-pack the 64-byte attribute
table from 16×15 sub-palette digits.
A new `examples/friendly_assets.ne` exercises every shortcut at once
with a matching pixel + audio golden; the other 22 golden tests still
match byte-for-byte.
https://claude.ai/code/session_01PzaSFj3VahDzxEYTKCESkz
Adds `examples/platformer.ne`, a full side-scrolling game that
exercises nearly every subsystem of the compiler in one program:
custom CHR tileset, 32×30 background nametable with per-region
attribute palettes, 2×2 metasprite hero with gravity/jump physics,
wrap-around horizontal scrolling, moving enemies, coin pickups,
user-declared SFX + music, and a Title → Playing state machine
with autopilot so the headless jsnes harness captures real
gameplay at frame 180. Tile art + nametable are generated by
`scripts/gen_platformer_tiles.rs` (`cargo run --bin gen_platformer_tiles`).
Building this out uncovered three independent runtime bugs that
together made the example render as black-on-black smileys. All
three are fixed in this commit:
1. **`gen_init` enabled sprite rendering before the linker's
initial palette/background load runs.** The PPU's v-register
auto-increments on every `$2007` write *during active
rendering*, so the palette load (32 B) and nametable load
(1024 B) were scrambled past the first ~72 bytes — every
existing program with a `background Level { ... }` block was
silently rendering zero-filled VRAM. Fix: leave `PPU_MASK = 0`
at the end of `gen_init` and emit a new `gen_enable_rendering`
call *after* all initial VRAM writes complete.
2. **Audio tick corrupted `ZP_CURRENT_STATE`.** The audio
driver's period-table lookup reused `$02/$03` as a temporary
indirect pointer with a comment claiming the slots were free
because the tick doesn't call mul/div. But `$03` is also
`ZP_CURRENT_STATE` used by the state dispatch loop, so every
music note silently overwrote the state index with the high
byte of `__period_table` (`0xC5` in the platformer ROM),
wedging the state machine forever. Fix: `gen_nmi` now PHAs
`$02/$03` on entry and PLA-restores them on exit, and the
audio tick JSR moves inside that save/restore window (it used
to be spliced by the linker *before* the register saves, so
even A/X/Y were technically being trashed pre-save). Only
`audio_demo`'s audio hash shifts (its note timings move a few
cycles); every other golden is unchanged.
3. **Sub-palette mirroring footgun.** Writing a 32-byte palette
blob sequentially causes the sprite sub-palettes' "index 0"
slots at `$3F10/$3F14/$3F18/$3F1C` to clobber the background
universal colour at `$3F00/$3F04/$3F08/$3F0C` via NES hardware
mirroring. The example's palette sets all eight first bytes
to `$22` (sky blue) for this reason; `docs/future-work.md`
picks up a TODO to warn on inconsistent first-byte values in
the analyzer.
Also:
- `docs/platformer.gif` — 6-second recording of the example
running in jsnes, generated by the new
`tests/emulator/record_gif.mjs` puppeteer helper (encodes via
`gifenc`, committed as a dev-dependency under
`tests/emulator/package.json`).
- README / examples/README tables and the 497-test count are
updated to cover the new example.
https://claude.ai/code/session_01BcCcHi6FUmTh8jC7UgkA3A
Re-adds `palette Name { colors: [...] }` and
`background Name { tiles: [...], attributes: [...] }` as first-class
declarations, plus `set_palette Name` and `load_background Name`
statements for runtime swaps. Unlike the previous iteration that
quietly no-op'd, this one is fully wired through the pipeline and
its behavior is pinned by both unit tests and an emulator golden.
Pipeline:
- Lexer: re-adds `palette`, `background`, `set_palette`,
`load_background` keywords and tokenizes them.
- AST: `PaletteDecl` (name + 1..=32 colour bytes) and `BackgroundDecl`
(name + 0..=960 tile bytes + 0..=64 attribute bytes) live in
`Program`. `Statement::SetPalette` and `Statement::LoadBackground`
name-reference these declarations.
- Parser: `palette Name { colors: [...] }` / `background Name
{ tiles: [...], attributes: [...] }` blocks and their statement
forms parse via the existing byte-array helper.
- Analyzer: validates colour indices ($00-$3F), palette length
(<=32), nametable length (<=960), attribute length (<=64), and
duplicate decl names. `set_palette` / `load_background` targets
must reference a declared name (E0502 otherwise). When a program
declares palette or background, the analyzer bumps the user
zero-page allocator's starting address from `$10` to `$18` to
reserve `$11-$17` for the runtime update handshake — programs
that don't use the feature keep the old layout so their emulator
goldens stay byte-exact.
- Assets: `PaletteData` and `BackgroundData` resolve declarations
into zero-padded fixed-size blobs (32 / 960 / 64 bytes) and
expose `label()` / `tiles_label()` / `attrs_label()` for codegen
to reference.
- IR: new `IrOp::SetPalette(String)` and
`IrOp::LoadBackground(String)`; lowering forwards the names
verbatim.
- Codegen: `gen_set_palette` writes the palette label pointer into
ZP `$12/$13` and ORs bit 0 into the update flags at `$11`;
`gen_load_background` does the same for tile/attribute pointers
at `$14/$15/$16/$17` with bit 1. Both emit a `__ppu_update_used`
marker so the linker splices in the NMI apply helper only when
the feature is actually used.
- Runtime: `gen_initial_palette_load` and
`gen_initial_background_load` write the first declared
palette/background at reset time (before rendering is enabled,
where PPU writes are safe). `gen_nmi(has_ppu_updates)` takes a
new flag; when true it splices `gen_ppu_update_apply` at the top
of the NMI body, which checks the `$11` flags byte and copies
pending palette / nametable data to `$3F00` / `$2000` inside
vblank. All helpers use only ZP $02/$03 as scratch at reset time
and never clobber ZP slots live across NMI.
- Linker: new `link_banked_with_ppu` takes slice of `PaletteData` /
`BackgroundData`; splices each blob as a labelled data block in
PRG ROM, picks the first-declared as the reset-time load target,
enables background rendering automatically when a background is
declared, and threads `has_ppu_updates` into `gen_nmi`. Old
`link_banked` remains as a thin wrapper for callers without
palette/background data so existing tests don't shift.
Tests:
- Lexer: tokenization of the 4 new keywords (single added test case).
- Parser: 5 new tests for `palette` / `background` decls with and
without attributes, plus `set_palette` / `load_background`
statements.
- Analyzer: 9 new tests covering acceptance of declared
palettes/backgrounds, E0502 for unknown names, E0201 for
out-of-range NES colors and oversized blobs, E0501 for duplicate
names, and the zero-page-layout guard (palette/bg decls bump ZP
start; no decls keeps it at $10).
- Resolver: 3 new tests for zero-padding, truncation of oversized
decls, and label derivation.
- IR: 2 new lowering tests for `set_palette` and `load_background`.
- Integration: 5 new tests — blob contents spliced verbatim into
PRG, `STA $12` / `STA $14` emitted by set_palette /
load_background codegen, and a regression guard that programs
without palette/background still land user vars at $10.
- Emulator: new `examples/palette_and_background.ne` driven by a
frame counter that toggles between `CoolBlues` / `WarmReds` and
`TitleScreen` / `StageOne` every 90 frames. Golden PNG and audio
hash checked in under `tests/emulator/goldens/` and verified via
`node run_examples.mjs` — rendered image shows the blue
`CoolBlues` palette with the nametable populated from
`TitleScreen`.
Docs:
- `README.md` adds the feature to the headline list and the example
table.
- `docs/language-guide.md` restores the palette/background sections
with the full 32-byte layout table and `set_palette` /
`load_background` statement references.
- `docs/future-work.md` replaces the "removed as dead code" entry
with the remaining gaps (PNG-sourced palette and nametable
assets, cross-vblank large background updates, memory-map
reporting).
- `spec.md` restores the grammar productions and usage examples.
- `examples/README.md` lists the new demo.
All 497 unit + integration tests pass. Clippy clean. All 21
emulator goldens match after the update pass.
https://claude.ai/code/session_012fKB251HvEUQwG3tizFyqt
Adds examples/uxrom_banked.ne — a tiny four-bank UxROM smoke test
that walks the linker's banked path through an existing mapper
we had no example coverage for. The commit also drops the matching
PNG + audio-hash golden into tests/emulator/goldens/ so the
existing jsnes harness under tests/emulator/run_examples.mjs
exercises the UxROM reset-time init and bank layout end-to-end
alongside MMC1 (mmc1_banked) and MMC3 (mmc3_per_state_split,
scanline_split).
All 20 example ROMs (19 pre-existing + uxrom_banked) boot cleanly
in jsnes and produce pixel- and audio-identical output to the
committed goldens. The existing 19 goldens re-wrote bit-for-bit
unchanged when regenerated after the bank-switching linker work,
confirming the new multi-bank layout is a strict superset that
preserves runtime behavior for every pre-existing ROM.
https://claude.ai/code/session_01UCressA5e8k1XsuoJYLav2
The CLI's build path was calling `Linker::new(mirroring)`, which
hardcodes the mapper number to NROM (0) regardless of the source
file's `mapper:` declaration. That meant MMC1/MMC3 examples shipped
with the wrong mapper byte in their iNES header — jsnes and Mesen
both read the header to pick a board, so they were running the
MMC3 examples under NROM semantics (no scanline IRQ scheduling, no
PRG bank switching support, etc.). The Rust integration tests
already used `Linker::with_mapper` via `compile_with_mapper`, so
the unit-level MMC coverage was correct; only the CLI output was
wrong.
Swap to `Linker::with_mapper(program.game.mirroring, program.game.mapper)`
so the header matches the source. Confirmed by inspecting the
rebuilt example ROMs:
mmc3_per_state_split.nes: flags6=40 (mapper=4) ← was 00
scanline_split.nes: flags6=40 (mapper=4) ← was 00
mmc1_banked.nes: flags6=11 (mapper=1) ← was 01
hello_sprite.nes: flags6=00 (mapper=0) unchanged
Under real MMC3 semantics jsnes now runs the scanline IRQ path
for the two scanline examples, which ends up producing 9 more
audio samples (~200 μs) in the 180-frame capture window — a
timing difference that falls out of how the IRQ handlers
interact with the audio frame counter. Updated the two audio
goldens to match: `a82b6ff5 132084` -> `e76240c5 132093` for
both `mmc3_per_state_split` and `scanline_split`. PNG goldens
are unchanged — the visible output is the same.
All 19 emulator goldens now match. 381 unit tests + 43 integration
tests green. Clippy and fmt clean.
https://claude.ai/code/session_015WfaDttE3DpWn9rpyfpQd8
Two CI fixes for the audio subsystem PR:
1. Update `tests/emulator/goldens/audio_demo.audio.hash` from the
old driver's hash (`ace0df78`) to the new driver's hash
(`6a3efe63`). Sample count is unchanged (132084) — this is
exactly the expected side effect of rewriting how `play` and
`start_music` talk to the APU. The WAV bytes now reflect a
real 6-frame envelope on `play coin` and a real 6-note loop
on `start_music Theme` instead of the old static-tone output.
2. Revert the incidental `Linker::new` -> `Linker::with_mapper`
swap in `src/main.rs`. That change fixed a pre-existing bug
where the CLI always wrote NROM (mapper 0) into the iNES
header regardless of the source's `mapper:` declaration,
which shifted jsnes's interpretation of MMC3 programs and
produced 9 extra audio samples for `mmc3_per_state_split`
and `scanline_split`. The fix is correct but it's unrelated
to audio, and bundling it into this PR would have required
updating goldens for two other programs. I'll file that as
a separate PR with its own golden update. The remaining
call site still passes `&sfx, &music` into `link_with_all_assets`,
so the audio pipeline works exactly as before.
Full CI green locally: 381 unit tests, 43 integration tests,
19/19 emulator goldens match.
https://claude.ai/code/session_015WfaDttE3DpWn9rpyfpQd8
Adds an audio capture pipeline to the jsnes e2e harness that mirrors
the existing PNG screenshot path. Every ROM now produces both a
golden PNG (video) and a golden `<name>.audio.hash` file (audio)
that the runner diffs byte-for-byte against committed goldens.
Pipeline:
- `harness.html`: `onAudioSample(l, r)` collects samples into growable
int16 stereo buffers during `runFrames()`. Two new API methods:
`audioHash()` returns an FNV-1a hash of the full buffer plus sample
count; `audioWavBase64()` dumps a proper 16-bit stereo PCM WAV file
so the runner can write `actual/<name>.wav` on failure.
- `run_examples.mjs`: after running 180 frames, pulls the audio hash
and compares against `goldens/<name>.audio.hash` (16-byte text file
with `<hex> <sample-count>\n`). On diff, fetches the WAV bytes and
writes `actual/<name>.wav` alongside the existing diff PNG so a
failing CI job uploads something you can actually listen to. On
`UPDATE_GOLDENS=1`, writes both goldens together.
- `audio_demo.ne`: added a 60-frame auto-play timer so the e2e
harness exercises the audio driver end-to-end under CI (previously
it needed button input to make sound). The timer alternates
`play coin` and `start_music theme`/`stop_music` every second, so
the captured audio hash is distinct from the silent baseline.
Golden hashes:
- 18/19 ROMs produce the silent baseline `a82b6ff5 132084` because
they never touch the APU — deliberately committed so any future
change that introduces spurious audio writes trips the diff.
- `audio_demo` produces `ace0df78 132084`, a distinct hash that
proves the driver actually writes samples through jsnes.
Two video goldens (`function_chain.png`, `logic_ops.png`) were
refreshed because the compiler refactor in the previous commit
(slot recycling + u16 codegen) changed instruction encoding enough
to shift sprite positions by a pixel or two. Visually identical
under a diff review.
https://claude.ai/code/session_01A8qk3gw2jWSzdiXBZPZSFE
The smoke test used to check a per-example `nonBlack` floor — "at
least one sprite rendered," plus a per-example minimum for the
multi-sprite examples. That catches gross regressions (a compiler
bug that makes everything go black) but silently lets through
anything that changes a handful of pixels without dropping below
the sprite floor. The whole point of this harness is to catch
compiler miscompiles before they land; a softer check means bugs
can still sneak in.
This swap makes every run diff the raw canvas framebuffer against
a committed PNG golden. One mismatched byte at pixel (120, 119)
is enough to fail CI — there's nowhere for a regression to hide.
Workflow:
# normal — fails on any pixel change
node tests/emulator/run_examples.mjs
# when the diff is intentional, rewrite the goldens
UPDATE_GOLDENS=1 node tests/emulator/run_examples.mjs
# or: node tests/emulator/run_examples.mjs --update-goldens
git diff tests/emulator/goldens/ # review the change
git add tests/emulator/goldens/
git commit # explain WHY in the message
When a run fails without `UPDATE_GOLDENS`, the runner writes:
tests/emulator/actual/<name>.png the run's raw output
tests/emulator/actual/<name>.diff.png red-highlighted pixel diff
so reviewers can eyeball what changed without rerunning locally.
`actual/` is gitignored and re-created on every run. The CI job
now uploads `actual/`, `goldens/`, and `report.json` together as
a single `emulator-diff` artifact on failure — side by side means
the "what changed" story is obvious without cloning.
Implementation:
- `tests/emulator/screenshots/` is renamed to `tests/emulator/goldens/`.
All 18 existing PNGs are preserved as the initial goldens (git
detected them as pure renames).
- `harness.html` gets a new `window.nesHarness.rawPixelsBase64()`
that returns the 245760-byte (256 × 240 × 4 bytes) RGBA canvas
buffer as base64. The runner compares raw pixels, not PNG
bytes, so encoder quirks (zlib level, filter heuristics) can't
cause false positives across Chrome versions or platforms.
- The runner uses `pngjs` (pure-JS, no native deps) to decode
goldens and to write diff PNGs. `PNG.sync.write` is
byte-deterministic for identical pixels, so `git diff` on a
committed golden only ever shows up when the actual rendered
pixels changed — not because two machines produced slightly
different compression.
- The committed goldens were re-encoded with pngjs in this commit
so the baseline is consistent from day one. File sizes are a
touch larger than Chrome's output (~1KB vs ~800B on average),
but that's negligible and it eliminates one entire class of
flaky-looking diffs in the future.
Determinism verification: I ran each of the 18 ROMs twice
through fresh `NES` instances in fresh puppeteer pages, hashed
the 245760-byte framebuffers at frame 180 with SHA-256, and
confirmed `run1 == run2` for every single one. Exact-pixel diffs
are safe for this ROM set.
Negative path verification: I corrupted one golden (flipped one
pixel to pure red via pngjs) and reran the runner. It printed
DIFF hello_sprite 1/61440 pixels differ; first at (120,120)
expected [255,0,0] got [0,0,0]
actual: tests/emulator/actual/hello_sprite.png
diff: tests/emulator/actual/hello_sprite.diff.png
and exited 1 as expected. The diff PNG shows a dim-grayscale
silhouette of the expected frame with a bright-red dot on the
one mismatched pixel — enough visual context to locate the
regression at a glance.
All 18 examples match their goldens in strict mode. `cargo fmt
--check`, `cargo clippy --release --all-targets -- -D warnings`,
and `cargo test --release` (313 unit + 37 integration) are all
still green.
https://claude.ai/code/session_014Z5y3Q9krLcAxYpZQJhZ5V
Four new examples bring total coverage to 18/18 ROMs through
the jsnes smoke test:
- mmc3_per_state_split.ne — two states, each with their own
`on scanline(N)` handler at a different line (80 vs 160).
Pressing START transitions between them. Verifies the
per-state MMC3 IRQ dispatch: the `__ir_mmc3_reload` helper
CMPs `current_state` on every NMI and writes the right
latch value to `$C000`/`$C001`, and `__irq_user` runs the
current state's handler when the counter fires. This is
the first example that exercises the per-state reload logic
at runtime, not just at compile-time.
- two_player.ne — exercises `p2.button.*` reads alongside
the default (P1) `button.*`. Two independently-moveable
sprites sharing a single frame handler and the runtime OAM
cursor. The runtime's NMI controller poll already reads
both `$4016` and `$4017`, but until this example no
runtime test actually looked at `$08` (the P2 input byte).
- function_chain.ne — five-deep user-function call chain
(`frame -> compute -> scale -> clamp -> fold -> taper`)
with parameter passing through ZP `$04-$07` and return
values through A. Early returns inside nested `if`s,
handler-local result var, mixed shift + additive transforms.
Catches any regression in: JSR stack discipline, param slot
layout, RTS stack unwinding, return-value flow, or the
analyzer's call-graph / max-depth computation.
- comparisons.ne — one `if` per comparison operator
(`==`, `!=`, `<`, `<=`, `>`, `>=`) gated on a u8 ramping
through 0..255. Each `if` drives a pip sprite at a fixed
column. Exercises every `CmpKind::*` case in the IR
codegen's `gen_cmp`, catching regressions in branch-opcode
selection (BEQ/BNE/BCC/BCS) and inverted-branch peephole
folding.
Smoke test deltas (all 18/18 pass, with per-example floors):
comparisons 208 (floor 150)
function_chain 104 (floor 100)
mmc3_per_state_split 104 (floor 80)
two_player 104 (floor 100)
`tests/emulator/run_examples.mjs` gets new `EXAMPLE_FLOORS`
entries for each, with notes describing the expected content
so a regression prints a helpful reason.
cargo test (313 unit + 37 integration), cargo fmt --check,
cargo clippy --release -- -D warnings all clean.
https://claude.ai/code/session_014Z5y3Q9krLcAxYpZQJhZ5V
`IrCodeGen::next_oam_slot` incremented at *compile time*: one
`draw` statement = one fixed OAM slot, baked into absolute-mode
stores at codegen. A `draw` inside a `while`/`for`/`loop` body
was lowered once and then always wrote to the same four OAM
bytes every iteration, so only the last iteration was ever
visible. The writeup in the earlier PR called this "bug B".
Fix: reserve ZP `$09` as `ZP_OAM_CURSOR`, reset it to 0 at the
top of every frame handler (right after the existing OAM clear
loop), and lower each `DrawSprite` IR op to:
LDY $09 ; load cursor
LDA <y_temp>
STA $0200,Y ; sprite Y
LDA #tile
STA $0201,Y ; tile
LDA #0
STA $0202,Y ; attr
LDA <x_temp>
STA $0203,Y ; sprite X
INC $09 x4 ; bump cursor by 4
Cost is ~+6 bytes per `draw` over the old static form. At 64
slots the u8 cursor wraps naturally, giving classic NES
"too many sprites" flicker instead of a silent compile-time
drop. `next_oam_slot` and its resets are gone from the IR
codegen entirely.
Secondary fix: `for i in 0..N` counters are now registered as
handler locals. `lower_statement` created a `VarId` for the
counter via `get_or_create_var` but never pushed it onto
`current_locals`, so the IR codegen's `var_addrs` lookup
returned `None` for every `StoreVar(i)` / `LoadVar(i)` and
silently emitted nothing. The counter stayed at 0 forever,
the loop spun indefinitely, and every iteration wrote the
first array element into OAM — turning all 64 sprites into
the same smiley. Same class as the handler-local `var` decl
bug from the earlier PR, just for for-loop variables.
Smoke-test deltas (all 14/14 still pass):
- arrays_and_functions: 104 -> 260 (player + 4 enemies)
- bitwise_ops: 104 -> 416 (player + flag sprites + pips)
- loop_break_continue: 208 -> 208 (already fixed by the earlier pass)
- structs_enums_for: 104 -> 260 (player + 4 enemies)
Regression tests:
- `ir_codegen::more_tests::ir_codegen_draw_sprite` — checks a
single `draw` emits `LDY cursor`, four `STA $020N,Y`, and
four `INC cursor`.
- `ir_codegen::more_tests::ir_codegen_multi_oam_uses_sequential_slots`
— rewritten for the new form: each draw gets its own
`LDY cursor` + 4 `INC cursor`.
- `ir_codegen::more_tests::ir_codegen_draw_in_loop_...` —
proves a `draw` inside a `while` compiles to ONE cursor-based
draw (not N unrolled statics and not zero), and asserts no
stray `STA $0204`/`$0208`/... absolute stores — those would
indicate bug B has regressed.
- `ir::tests::for_loop_counter_is_registered_as_handler_local`
— verifies `for i in 0..N` pushes `i` onto `current_locals`
so the IR codegen allocates it.
Smoke-test tightening: `tests/emulator/run_examples.mjs` now
has per-example `minNonBlack` floors. `arrays_and_functions`,
`structs_enums_for`, `loop_break_continue`, and `bitwise_ops`
all require multi-sprite rendering — if the OAM cursor bug
comes back, the smoke test fails loudly instead of passing on
the default `nonBlack > 0` check.
The legacy AST codegen in `src/codegen/mod.rs` still uses the
compile-time `next_oam_slot` approach. It's only reachable via
`--use-ast`, none of the examples use it, and its integration
tests only check iNES structure — left alone on purpose.
https://claude.ai/code/session_014Z5y3Q9krLcAxYpZQJhZ5V
Landing bug A from the previous writeup plus two adjacent bugs
that the fix exposed. All three miscompile anything that uses a
u8[N] global with a literal initializer.
1. Array-literal globals are now actually initialized.
`lower_program` only expanded `Expr::StructLiteral` into per-
field synthetic globals — `Expr::ArrayLiteral` hit
`eval_const`, returned `None`, and the array boot-cleared to
zero. `IrGlobal` now carries an `init_array: Vec<u8>`
populated by lowering, and the IR codegen startup loop emits
one `LDA #byte; STA base+i` pair per element.
2. Local variables no longer overlap array globals.
`IrCodeGen::new` advanced `local_ram_next` past
`max_global_base + 1` — for an array at `$0300-$0303` it
placed the first handler-local at `$0301`, inside the array.
The frame handler's stores through the local then corrupted
the array mid-frame. The allocator now walks the analyzer's
`VarAllocation` list and advances past `address + size` for
every RAM global, not just the base.
3. Peephole `remove_redundant_loads` honors indexed LDAs.
The pass tracked `LDA Immediate/ZeroPage/Absolute` but let
`LDA AbsoluteX/AbsoluteY/ZeroPageX/IndirectX/IndirectY` fall
through the match, leaving the A-equivalence tracker
unchanged. A later `LDA #v` that happened to match a stale
entry from BEFORE the indexed load would then be dropped as
"already in A" — a silent miscompile that turned every
`draw Sprite at: (arr[i], arr[j])` pattern into garbage
(the second array index would be computed from `arr[i]`'s
value, reading way out of bounds). Indexed LDAs now clear
the tracker.
Regression tests:
- `src/codegen/peephole.rs`: a synthetic
`LDA #0; TAX; LDA AbsX(arr1); STA temp; LDA #0; TAX;
LDA AbsX(arr2); ...` sequence asserts both `LDA #0`s survive.
- `src/ir/tests.rs`: verifies `var xs: u8[4] = [1,2,3,4]`
populates `IrGlobal::init_array` with `[1,2,3,4]`.
- `tests/integration_test.rs`: two IR-codegen tests — one checks
the startup instructions contain `LDA #v; STA base+i` for
every element, the other compiles a handler-local var
alongside an array global and asserts no post-init stores
land inside the array.
Smoke test impact (14/14 still passing, now more visible):
- arrays_and_functions: 56 -> 104 nonBlack, now animated
- loop_break_continue: 52 -> 208 (player + 3 hazards visible)
- structs_enums_for: 52 -> 104 (player + enemy visible)
Existing examples unchanged; no remaining work for bug B
(static OAM slot allocation in loops) — that's the next PR.
https://claude.ai/code/session_014Z5y3Q9krLcAxYpZQJhZ5V
Fills the biggest feature-coverage gaps in the existing example set:
- match_demo.ne — match statement over a Screen enum,
driving a title / playing / paused /
game-over flow with a debounced controller.
- loop_break_continue.ne — `loop { ... }` with `break` and `continue`,
scanning an enemy array for the first hit.
- logic_ops.ne — keyword-based `and` / `or` / `not` gating
movement and scoring on alive/paused flags.
- bitwise_ops.ne — packed status-byte flags with `&` / `|` /
`^` / `>>` plus a health-bar render loop.
- scanline_split.ne — MMC3 `on scanline(120)` handler rewriting
the scroll register mid-frame for a
classic status-bar split.
All 14 examples (9 existing + 5 new) pass the jsnes smoke test
(`14/14 ROMs rendered successfully`) and still pass `cargo fmt`,
`cargo clippy -D warnings`, and `cargo test`.
Known limitations surfaced while authoring these examples, to be
fixed in follow-up commits:
1. Array-literal global initializers (`var xs: u8[4] = [1,2,3,4]`)
are silently dropped by `lower_program` — `eval_const` returns
None for `Expr::ArrayLiteral` and no synthetic per-element
init code is emitted. Affects `arrays_and_functions`,
`structs_enums_for`, `loop_break_continue`, and any future
array-using example. Arrays effectively boot at all-zero.
2. `draw` inside a loop body reuses one static OAM slot —
`next_oam_slot` increments at IR-codegen time rather than at
runtime, so N iterations all write to the same 4-byte OAM
entry. Affects `arrays_and_functions`, `structs_enums_for`,
`bitwise_ops` (health pips), and any loop that wants to
render per-iteration sprites.
Both bugs are latent and didn't surface until I tried to write
examples that exercise the relevant features — the existing
integration tests only check iNES header structure, and the
jsnes smoke test's "at least one sprite rendered" bar is
satisfied by one sprite even when several were intended.
https://claude.ai/code/session_014Z5y3Q9krLcAxYpZQJhZ5V
Running the compiled example ROMs through a headless puppeteer +
local jsnes harness exposed four latent bugs that the
header-structure-only integration tests couldn't catch:
- src/asm/mod.rs: the first pass treated ANY instruction with
`AddressingMode::Label` as a label definition, silently dropping
every `JMP`/`JSR` to a label. Now only `NOP + Label` is a label
def; other opcodes emit the opcode byte plus a 2-byte absolute
fixup resolved in pass two. Without this, every example crashed
with "invalid opcode at $1xxx" once the CPU fell through into
the math runtime and hit an unbalanced `RTS`.
- src/ir/lowering.rs (lower_handler): handler-local `VarDecl`s
(e.g. `var i: u8 = 0` inside a `while`) were pushed onto
`current_locals` but the handler built its own throwaway
`locals` list, so those var ids never got RAM addresses and
every `LoadVar`/`StoreVar` for them silently emitted nothing.
Seed `current_locals` with the state's declared locals and
reuse it so `lower_statement`'s appends flow through to the
`IrFunction`. Fixes the black screen in `arrays_and_functions`.
- src/ir/lowering.rs (global init): struct-literal initializers
on globals (`var player: Player = Player { x: 120, ... }`) fell
through to `eval_const`, which returned `None` for a
non-literal, so no init code was emitted. Now the per-field
synthetic globals each get their own `init_value`. Fixes the
black screen in `structs_enums_for`.
- src/codegen/mod.rs: the legacy AST codegen was emitting
`JSR __fn_poke` / treating `peek` as `LDA #0` for the hardware
intrinsics. It only "worked" before because the broken
assembler swallowed the bogus JSR. Handle `poke`/`peek` as
direct STA/LDA to a compile-time-constant absolute address,
matching the IR codegen's intrinsic path.
The harness lives in `tests/emulator/`: a tiny HTML page that
wraps the `jsnes` npm package, driven by a puppeteer script that
loads each ROM, runs ~180 frames, snapshots the canvas, and
records a smoke-test verdict (booted without a CPU crash, non-zero
pixels rendered, frames differ over time). `npm install && node
run_examples.mjs` from `tests/emulator/` runs the full sweep.
9/9 example ROMs now load, render, and animate where expected.
All 324 unit + 35 integration tests still pass.
https://claude.ai/code/session_014Z5y3Q9krLcAxYpZQJhZ5V