1
0
Fork 0
mirror of https://github.com/imjasonh/nescript synced 2026-07-08 08:55:38 +00:00
Commit graph

21 commits

Author SHA1 Message Date
Claude
7294ae3efa
analyzer/lowering: support nested struct fields and array struct fields
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
2026-04-15 02:19:49 +00:00
Claude
9b54ff83c0
audio: always enable all four tone channels on sfx trigger
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
2026-04-14 12:09:46 +00:00
Claude
2fe943b056
codegen: user code in switchable banks via cross-bank trampolines
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).
2026-04-14 11:41:20 +00:00
Claude
201664ea04
audio: triangle and noise sfx channels
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
2026-04-14 10:42:53 +00:00
Claude
169a481099
feat(platformer): add stomp-or-die enemy collisions, live HUD, GameOver state
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
2026-04-13 20:23:07 +00:00
54910f2498
Merge branch 'main' into claude/test-platformer-game-6S3TX 2026-04-13 15:55:50 -04:00
Claude
5e3e68ca11
docs: regenerate platformer.gif and lock it as a CI invariant
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
2026-04-13 18:04:17 +00:00
Claude
629fdcfce0
fix(optimizer): preserve cross-block LoadImm uses in const_fold DCE
`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
2026-04-13 16:29:44 +00:00
Claude
48832ccb13
language: pleasant asset syntax for palettes, CHR, bg, sfx, music
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
2026-04-13 16:09:53 +00:00
Claude
688d9afcec
platformer: end-to-end side-scroller demo + three runtime bug fixes
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
2026-04-13 13:04:26 +00:00
Claude
d98c7f3d82
palette/background: first-class declarations with reset-time load and runtime swaps
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
2026-04-13 11:11:33 +00:00
Claude
d4c9898682
examples: add uxrom_banked and jsnes golden
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
2026-04-13 02:04:25 +00:00
Claude
b708eb5554
main: write the real mapper byte into the iNES header
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
2026-04-13 01:27:42 +00:00
Claude
a3b0ea34a0
audio: update audio_demo golden, revert unrelated main.rs mapper fix
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
2026-04-13 01:20:32 +00:00
Claude
33537a32a9
tests/emulator: record audio goldens alongside screenshots
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
2026-04-12 22:33:48 +00:00
Claude
2d66b68c7f
tests/emulator: byte-exact golden-image diffs
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
2026-04-12 21:30:18 +00:00
Claude
7899714af1
examples: 4 new programs covering MMC3 + other e2e gaps
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
2026-04-12 20:48:31 +00:00
Claude
54acb9ee38
Bug B: runtime OAM cursor so draw inside loops actually works
`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
2026-04-12 20:20:20 +00:00
Claude
f49dbce686
Fix three compiler bugs exposed by array-using examples
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
2026-04-12 19:32:22 +00:00
Claude
1525922faa
examples: add 5 new programs covering match/loop/logic/bitwise/scanline
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
2026-04-12 19:05:18 +00:00
Claude
81f3fd7de0
Add jsnes emulator harness and fix four codegen bugs it surfaced
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
2026-04-12 18:46:58 +00:00