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

143 commits

Author SHA1 Message Date
d86cb2f491
Merge pull request #20 from imjasonh/claude/complete-future-work-vYs1O 2026-04-15 07:05:16 -04:00
Claude
e8d602c1bc
codereview: address six findings from a fresh review pass
A focused review of the branch surfaced two correctness bugs and
four important polish items in the new features. None of the
existing example goldens shift — every fix is gated on conditions
that don't fire in the committed examples.

**debug.frame_overran() never reset between frames in implicit
wait_frame programs.** The IR-level WaitFrame op cleared $07FE,
but the implicit main-loop flag-clear that runs between dispatch
iterations only cleared ZP_FRAME_FLAG. A program whose
`on frame { ... }` body had no explicit `wait_frame` would latch
$07FE to 1 on the first miss and never reset, breaking
`debug.assert(not debug.frame_overran())` guards. The dispatch
loop now also clears $07FE in debug builds, mirroring the
WaitFrame path. New regression test asserts the main loop emits
exactly one STA $07FE in a no-wait_frame debug build.

**Metasprite base-tile resolution silently miscompiled for
`@chr` / `@binary` sprites.** The IR lowering walks
`program.sprites` to compute base tile indices but assumes
1 tile per non-Inline source, while the real asset resolver
reads the file. The analyzer now hard-rejects the combination
with a clear "use inline pixels" hint instead of letting it
compile to a visual glitch. New analyzer test
`analyze_metasprite_with_external_chr_sprite_errors` covers it.

**next_sprite_tile capping silently allowed CHR overlap.** The
pipeline used `.min(255)` which would let a background tile
overwrite a sprite tile when the sprite range filled the
pattern table. Now hard-errors via CompileError::AssetResolution
when the sprite range >= 256 *and* the program declares any
`@nametable(...)` background. Inline backgrounds aren't affected.

**Linker silently truncated background CHR overflow.** The
`if end <= chr.len()` guard at the CHR copy site dropped any
auto-CHR bytes that would have run past the pattern table.
Replaced with a debug assertion since the resolver should
have caught it upstream — defense in depth.

**Stale comment in nested_structs.ne** said struct literals
"don't accept array fields yet" while the example itself
demonstrates inline array fields working through
`expand_struct_literal_init`. Comment updated.

**Misleading sentinel comment in audio.rs** described the pitch
envelope's trailing zero as a runtime sentinel; in practice the
volume tick `JMP`s to `__audio_sfx_done` first and the pitch
update block never reads the trailing byte. Rewrote the comment
to clarify it's padding for predictable blob length.

Also tidies up two minor items the reviewer flagged:

- `flatten_struct_fields` rebuilt the `struct_sizes` HashMap on
  every leaf field; hoisted the snapshot to the function entry.
- Integration tests called `resolve_backgrounds(..., 0)` (the new
  `next_sprite_tile` parameter); changed to `1` so a future
  PNG-nametable test fixture won't accidentally overwrite the
  runtime smiley at tile 0.

https://claude.ai/code/session_01KEczoNUX3WmcFLfq6iAQxB
2026-04-15 03:56:42 +00:00
Claude
45bb84dddc
linker: test that BackgroundData::chr_bytes lands at chr_base_tile
Adds a focused unit test that constructs a `SpriteData` at tile 1
and a `BackgroundData` whose `chr_bytes` claim tiles 5-6, then
verifies the linker's CHR ROM placement preserves the smiley at
tile 0, the sprite at tile 1, leaves tiles 2-4 untouched, and
copies the background blob at the requested base offset. Catches
any future regression in the
`BackgroundData::chr_base_tile` → CHR ROM splice that
`assets: auto-generate CHR data from @nametable() PNG sources`
introduced.

https://claude.ai/code/session_01KEczoNUX3WmcFLfq6iAQxB
2026-04-15 03:44:01 +00:00
Claude
6501f105bd
docs/future-work: prune items shipped on this branch
- PNG-sourced assets: drop the "automatic CHR generation" TODO
  now that `png_to_nametable_with_chr` ships with the resolver.
- User code distribution: drop the banked → banked TODO; only
  greedy size-packing and the MMC3 per-state-handler split remain.
- Language feature gaps: drop the metasprite row from the post-v0.1
  table and add a paragraph describing the new `metasprite` syntax.
  Drop the "nested struct / array struct field" gap; replace it
  with a note about the still-rejected array-of-structs case.
- Audio pipeline: note the new pulse pitch envelope path; replace
  the "pitch latches once" TODO with the triangle/noise extension.
- Debug instrumentation: note `debug.frame_overrun_count()` and
  `debug.frame_overran()`; drop the matching "richer telemetry" TODO.

Items kept (and unchanged) include the WASM build target, register
allocator, fixed-point arithmetic, text/HUD, tilemaps, SRAM, DMC,
multi-channel tracker, NSF/FTM imports, debug.overlay, per-state
background swaps, and the four open design questions.

https://claude.ai/code/session_01KEczoNUX3WmcFLfq6iAQxB
2026-04-15 03:31:58 +00:00
Claude
cc3f7eec7e
assets: auto-generate CHR data from @nametable() PNG sources
`background Foo @nametable("file.png")` previously decoded the PNG
into a tile-index table and an attribute table but left CHR
generation to the user — they had to supply matching tiles via a
separate `sprite Tileset @chr(...)` declaration in the same
deduplication order, which was both error-prone and the main thing
keeping the shortcut form from being a one-liner.

The CHR pipeline now closes the gap. `png_to_nametable_with_chr`
returns a `PngNametable` carrying the tile-index table, the
attribute table, *and* a per-tile CHR blob encoded with the same
brightness-bucketing `png_to_chr` already uses for sprites. The
resolver passes `next_sprite_tile` (computed from the resolved
sprite list) so each background's CHR allocation slots in
immediately after the sprite range, and rewrites the nametable
indices to point at the actual physical tile numbers. The linker
copies each background's `chr_bytes` into CHR ROM at
`chr_base_tile * 16`, so the final image renders without any
user-supplied CHR.

`BackgroundData` carries `chr_bytes` and `chr_base_tile` so the
linker has everything it needs at a glance. Inline `tiles:` /
`attributes:` declarations leave them empty and behave exactly
like before — that path doesn't auto-generate CHR because the
user is implicitly opting into "I'll provide tiles myself" by
typing the indices out by hand.

The new `examples/auto_chr_background.ne` is a 256×240 grayscale
gradient committed alongside its `auto_chr_bg.png` source; the
emulator harness verifies the rendered output against a
committed golden so a regression in the dedupe/encode/linker
plumbing fails CI loudly. Existing example ROMs are byte-
identical because their backgrounds either have no PNG source or
already provided their own CHR.

https://claude.ai/code/session_01KEczoNUX3WmcFLfq6iAQxB
2026-04-15 03:29:58 +00:00
Claude
6b080316a4
parser/lowering: declarative metasprites for multi-tile sprite groups
Multi-tile sprites used to require one hand-written `draw` per tile,
e.g. the four-call sequence in `examples/platformer.ne`'s
`draw_player()`. The new `metasprite Name { ... }` declaration
collects parallel `dx`/`dy`/`frame` arrays plus a reference to the
underlying sprite, and `draw Name at: (x, y)` expands to one OAM
slot per tile in the IR lowering — the codegen sees N regular
DrawSprite ops, so the runtime OAM cursor allocator picks them up
without any metasprite-specific awareness.

The metasprite's `frame:` array is interpreted *relative to the
underlying sprite's base tile*: index 0 means "the first tile this
sprite owns", which is the natural reading for a 16×16 hero whose
pixel art the asset resolver split into four consecutive tiles.
The lowering walks `program.sprites` to compute base tile indices
the same way `assets::resolve_sprites` would, then folds the base
into each frame entry before storing the metasprite info. Sprites
sourced from external `@chr(...)` / `@binary(...)` files whose
bytes aren't available at parse time fall back to a one-tile
assumption — those programs are rare and can declare metasprites
against pixel-art sprites instead.

The new `examples/metasprite_demo.ne` declares a 16×16 hero sprite
and arranges its four tiles into a metasprite, then sweeps the
hero across the screen so the harness captures it mid-motion.
The new keyword is added to the lexer/token list, and the parser
accepts `sprite:` (the otherwise-keyword) as a property name in
metasprite bodies so the natural spelling parses.

https://claude.ai/code/session_01KEczoNUX3WmcFLfq6iAQxB
2026-04-15 03:13:30 +00:00
Claude
9878b7d87d
audio: per-frame pitch envelopes for pulse SFX
Pulse-channel sfx with a multi-byte `pitch:` array used to silently
ignore everything past the first byte — the runtime audio tick
latched the period at trigger time and never updated it. Programs
that wanted a frequency sweep had no way to express it.

The compiler now compiles a per-frame pitch envelope blob alongside
the existing volume envelope when `decl.pitch` has more than one
distinct value. The blob is padded (or truncated) to the volume
envelope's length and ends in a zero sentinel so the runtime
walker stops both pointers on the same NMI. Sfx with a single
scalar pitch (or an array where every byte is the same) keep their
historical "no pitch blob, latch once" path and emit byte-identical
ROM bytes.

The runtime gains two new pieces, both gated on a new
`__sfx_pitch_used` codegen marker so programs without varying-pitch
sfx pay zero bytes:

1. `gen_audio_tick` emits a per-frame pitch update block inside
   the SFX tick: read a byte through `(AUDIO_SFX_PITCH_PTR),Y`,
   write it to `$4002` (pulse-1 period low), advance the pointer.
   The block bails on a zero high-byte pointer so a single
   program can mix scalar-pitch and varying-pitch sfx without
   one clobbering the other.

2. `emit_play_pulse` seeds `AUDIO_SFX_PITCH_PTR_LO/HI` with the
   pitch-blob label for varying-pitch sfx and zeros it for
   scalar-pitch sfx. The per-call branch is skipped entirely
   when the program has no varying-pitch sfx anywhere.

The new `examples/sfx_pitch_envelope.ne` exercises the path with
a 16-frame siren sweep. Triangle and noise per-frame pitch are
deferred — they share the same data shape but the runtime ticks
for those channels still write only their volume registers, see
docs/future-work.md for the gap.

https://claude.ai/code/session_01KEczoNUX3WmcFLfq6iAQxB
2026-04-15 02:54:56 +00:00
Claude
db3a4adc57
codegen: support banked → banked cross-bank function calls
Programs that put functions in switchable banks can now call across
bank boundaries — `bank A { fun step() { helper() } }` where
`helper` lives in `bank B` used to panic in the IR codegen. Three
small pieces unblock it:

1. **Generic trampoline.** `runtime/gen_bank_trampoline` no longer
   takes a `fixed_bank_index` argument. Instead it reads the
   caller's current bank from `ZP_BANK_CURRENT`, pushes it on the
   hardware stack, switches to the target, JSRs the entry, then
   pulls and restores the saved bank. The same per-callee stub
   works for fixed→banked and banked→banked direction; nested
   trampolines compose because each PHA/PLA pair sits inside its
   own JSR/RTS frame. `gen_mapper_init` seeds `ZP_BANK_CURRENT`
   with the fixed bank index for any banked mapper so the very
   first cross-bank call from the fixed bank still restores to
   the fixed bank (matching pre-banked-banked semantics).

2. **Codegen drops the panic.** The `Some(from), Some(to)` arm in
   the call-resolution switch now emits `JSR __tramp_<name>` like
   the fixed→banked case instead of panicking. Banked→fixed calls
   still go direct (the fixed bank is always mapped at $C000).

3. **Bank-namespaced local labels.** Two banks emitting the same
   `__ir_cmp_e_8` would trip the linker's discovery-pass duplicate-
   label check the moment any banked code generated a comparison.
   The new `local_label_suffix` helper prefixes the suffix with the
   current bank name when banked code is being emitted, leaving
   fixed-bank label generation untouched (so existing examples are
   byte-identical apart from the trampoline / init bytes
   themselves).

The new `examples/uxrom_banked_to_banked.ne` demonstrates the path
end-to-end: `bank Logic { fun step() { ... clamp() } }` calls
`bank Helpers { fun clamp() { ... } }` once per frame. The harness
golden is committed alongside it. The five existing banked example
ROMs change byte-for-byte because of the new trampoline shape and
the seed-ZP_BANK_CURRENT init, but their emulator goldens still
match exactly — observable behaviour is unchanged.

https://claude.ai/code/session_01KEczoNUX3WmcFLfq6iAQxB
2026-04-15 02:37:19 +00:00
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
d4e613fb7c
debug: add debug.frame_overrun_count() and debug.frame_overran() builtins
The frame-overrun counter at $07FF was previously only readable
via `peek(0x07FF)`, which forces every program that wants to
guard against missed frames to know the magic address. This adds
two named query expressions:

- `debug.frame_overrun_count()` — cumulative miss count since reset
- `debug.frame_overran()` — sticky bit cleared by the next wait_frame,
  so `debug.assert(not debug.frame_overran())` catches a miss in the
  previous window without waiting for the counter to roll over.

The sticky bit lives at $07FE alongside the existing counter and
is set inside the same NMI-time overrun branch. Release builds
emit none of the runtime side: the NMI handler still skips both
writes, the codegen `wait_frame` only clears $07FE in debug mode,
and committed example ROMs stay byte-identical.

The new expression form parses through `parse_primary`'s `KwDebug`
arm, so the existing `debug.log(...)` / `debug.assert(...)`
*statement* parser stays untouched. The analyzer rejects unknown
methods with E0201 and stray arguments with E0203 so typos don't
silently compile to a zero load.

https://claude.ai/code/session_01KEczoNUX3WmcFLfq6iAQxB
2026-04-15 01:57:45 +00:00
bf7819ca0f
Merge pull request #18 from imjasonh/claude/complete-future-work-LQyos 2026-04-14 09:06:41 -04:00
Claude
2966a6ab17
pipeline: share a single compile function across CLI, bench, and tests
The compile bench had a hand-maintained parallel copy of
`src/main.rs::compile`, and that copy went out of sync after
bank switching landed — the bench kept handing the linker
`PrgBank::empty(...)` slots even though the CLI started
populating per-bank instruction streams + trampoline requests.
The assembler then panicked with `unresolved label:
'__tramp_step_animation'` on `uxrom_user_banked.ne` under
`cargo test --all-targets`, which is what CI runs. A plain
`cargo test --release` (what CLAUDE.md used to document) never
builds the bench so the bug slipped through local validation.

Fix:

- New `nescript::pipeline` module with `compile_source(source,
  source_dir, &CompileOptions)` that owns the full
  `parse → analyze → lower → optimize → codegen → peephole →
  link` pipeline including the per-bank stream + trampoline
  reconstruction. Returns a `CompileOutput` carrying the ROM,
  the linker result, analysis, IR, assets, instructions, and
  source-loc markers so downstream tools have one place to
  pull metadata from.
- `src/main.rs::compile` reduces to file I/O + preprocessing +
  a single `compile_source` call + CLI-only side effects
  (`--dump-ir`, `--call-graph`, `--asm-dump`, `--memory-map`,
  `--symbols`, `--source-map`).
- `benches/compile.rs::compile_pipeline` becomes a one-line
  `compile_source` call. It is now structurally impossible for
  the bench to drift from the CLI path.
- `tests/integration_test.rs::compile_with_debug_artifacts`
  likewise delegates to `compile_source`. This also fixes a
  latent bug in the helper where it used `Linker::with_mapper`
  without `.with_header(...)` — programs opting into
  `header: nes2` would have quietly got an iNES 1.0 header
  through this path.
- `CLAUDE.md`: updated the "Running the basics" section to
  specify `cargo test --all-targets` (plain `cargo test` skips
  benches) and to point at `scripts/pre-commit` with the exact
  install command. Also installed the hook in this worktree.

All 24 existing `examples/*.nes` rebuild byte-identical through
the new pipeline. 624 tests + all 25 emulator goldens pass.

https://claude.ai/code/session_01MaNVcDmK9gsspRkdxowQAM
2026-04-14 13:02:58 +00:00
Claude
889074a415
bench: populate per-bank trampolines in compile bench
The compile benchmark was building each example via an in-memory
pipeline that mirrored the CLI except for one thing — it always
handed the linker empty `PrgBank::empty(...)` slots. That stayed
silently fine until `uxrom_user_banked.ne` started nesting a
function inside a `bank` block: the IR codegen emits
`JSR __tramp_step_animation` at the call site, and with no
trampoline request on the `Extras` bank the assembler's fixup
pass panics with "unresolved label". Local `cargo test` missed
it because the bench is only compiled under `--all-targets`,
which is what CI runs.

Fix: reconstruct the same `banked_streams` + `bank_trampolines`
dance `src/main.rs` already does for the real build path, and
thread the header format through `with_header` for parity.

https://claude.ai/code/session_01MaNVcDmK9gsspRkdxowQAM
2026-04-14 12:47:31 +00:00
Claude
c5297567d2
codereview: address four residual concerns from the hardware review
- Analyzer: new `W0108` warning when an array's byte size exceeds
  256. The codegen lowers `arr[i]` to `LDA base,X` and the 6502's
  X register is 8 bits, so elements past byte 255 are unreachable.
  The old debug bounds check silently skipped arrays in that range;
  it now clamps the compare to 255 and the analyzer diagnoses the
  declaration up front.

- UxROM `__bank_select`: the routine previously wrote the bank
  number to a fixed `$FFF0`, which works on emulators that don't
  simulate bus conflicts (jsnes, Mesen permissive) but is broken
  on real hardware because a single ROM byte can't match every
  possible bank number. Fixed by `TAX; STA __bank_select_table,X`
  — the store lands at `table + bank_num`, whose ROM byte is
  exactly `bank_num`, so CPU bus = A = ROM = no conflict. New
  `LabelAbsoluteX` addressing-mode variant in the assembler
  resolves the table's base address through the existing fixup
  pass. The two existing UxROM example ROMs shift a few bytes
  but their goldens still match (jsnes is bus-conflict-permissive).

- Source maps: new `source_map_survives_aggressive_peephole_folding`
  regression test. The reviewer was worried peephole could drop
  `__src_<N>` labels and silently leave stale source-map entries.
  Peephole actually treats labels as block boundaries and never
  deletes them — the test pins that down by compiling a program
  tailored to trip every peephole fold and asserting every
  codegen-recorded source marker survives into the final linker
  label table.

- Frame-overrun counter: new `debug_frame_overrun_counter_reads_back_from_user_code`
  end-to-end test that proves the contract works: NMI emits
  `INC $07FF`, user `peek(0x07FF)` lowers to `LDA $07FF`, and the
  RAM allocator doesn't hand out `$07FF` to a user variable.

https://claude.ai/code/session_01MaNVcDmK9gsspRkdxowQAM
2026-04-14 12:38: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
359c945c30
docs: update future-work to reflect shipped items
Trims entries that landed on claude/complete-future-work-LQyos
(PNG assets, user code banking, NES 2.0 header, u16 struct fields,
triangle/noise sfx, .mlb/source-map/bounds-check/overrun
instrumentation, --no-opt, bench scaffolding, W0102/W0105-W0107
warnings) and keeps the remaining genuine gaps.

https://claude.ai/code/session_01MaNVcDmK9gsspRkdxowQAM
2026-04-14 11:43:59 +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
8610aecdac
assets: PNG-sourced palettes and nametables, plus --memory-map PRG reporting
Implements three items from docs/future-work.md's
"PNG-sourced palette and nametable assets" section:

- `palette Name @palette("file.png")` — the parser accepts a PNG
  shortcut form; the asset resolver decodes the image via the
  new `png_to_palette` helper, mapping each pixel's RGB to the
  nearest NES master-palette index and building a 32-byte blob
  that enforces the universal-first-byte convention (same as
  the grouped-form parser). Errors cleanly on missing files or
  more than 16 unique colours.

- `background Name @nametable("file.png")` — the parser accepts
  a PNG shortcut form; the resolver decodes a 256×240 image into
  a 960-byte tile-index table (deduplicating up to 256 unique
  8×8 tiles) plus a 64-byte attribute table (bucketed by
  average quadrant brightness). CHR data is not yet generated
  automatically — callers still need to provide matching CHR
  via the existing sprite / `@chr(...)` pipeline; the
  limitation is documented on the `png_to_nametable` helper
  and can be lifted in a follow-up.

- `--memory-map` now prints a "PRG ROM data blobs" section
  listing each palette (32 B) and background (960 + 64 B)
  under its linker-assigned label, plus a grand total. The
  memory-map code is factored into `write_memory_map` which
  takes a writer so unit tests can drive it against a
  `Vec<u8>`. Memory-map printing moved to after the link step
  so palette/background CPU addresses are available.

Call-site changes: `resolve_palettes` and `resolve_backgrounds`
now take a `source_dir` path and return `Result<_, String>`
because PNG decoding can fail. Updated the CLI driver,
benches/compile.rs, and every integration-test compile helper.

All 23 committed examples rebuild byte-identical; 525 lib
tests + 72 integration tests + 3 bin tests pass; clippy clean.
2026-04-14 03:01:32 +00:00
Claude
b575921c8e
debug: add symbol export, source maps, bounds checks, overrun counter
Implements four items from docs/future-work.md's "Debug instrumentation"
section so debugging on real ROMs is no longer a guessing game:

1. Mesen `.mlb` symbol export via `--symbols <path>`. The linker now
   returns a `LinkedRom { rom, labels, fixed_bank_file_offset }` struct
   from `link_banked_with_ppu_detailed`; `src/linker/debug_symbols.rs`
   renders that plus the analyzer's var allocations into a Mesen-
   compatible label listing (function entry points get `P:` entries
   at PRG-relative offsets; user vars get `R:` entries).

2. Source maps via `--source-map <path>`. IR lowering now emits a
   `SourceLoc(span)` op before every statement; the codegen turns each
   one into a `__src_<N>` label-definition pseudo-op and records the
   span in a side table. Source-marker emission is opt-in
   (`with_source_map(true)`) because labels become peephole block
   boundaries — leaving the markers off preserves byte-identical
   release ROMs.

3. Array bounds checking under `--debug`. Every `ArrayLoad` /
   `ArrayStore` now emits a `CMP #size; BCC ok; JMP __debug_halt; ok:`
   guard, and the codegen emits one shared `__debug_halt` trap at the
   end of the fixed bank (writes $BC to the debug port then wedges in
   a tight `JMP $`). Release builds skip the whole thing.

4. Frame-overrun detection under `--debug`. `gen_nmi` now takes a
   `debug_mode` flag; when on, it checks `ZP_FRAME_FLAG` at the top of
   the handler and increments a counter at `$07FF`
   (`DEBUG_FRAME_OVERRUN_ADDR`) if the flag was still set — meaning
   the main loop didn't reach `wait_frame` before the next vblank.
   User code can read the counter via `peek(0x07FF)`. This is the
   abbreviated form the future-work doc suggested: a bump-a-counter
   hook rather than a full cycle-budget tracker, which would need a
   new builtin. The codegen emits a `__debug_mode` marker label in
   debug mode so the linker can select the overrun-aware NMI variant.

Release ROMs for every committed example are byte-identical before
and after this change (verified with `git diff examples/` after a
full rebuild). All 512 lib tests and 71 integration tests pass;
`cargo fmt` clean; `cargo clippy --all-targets -- -D warnings` clean.

https://claude.ai/code/session_01MaNVcDmK9gsspRkdxowQAM
2026-04-14 02:39:36 +00:00
Claude
33351f8b32
lang: NES 2.0 headers and u16 struct fields
Implements two items from docs/future-work.md's language-feature gaps:

NES 2.0 header support: `RomBuilder` gains a `header_format` field
and a matching `enable_nes2()` method. When enabled, byte 7 bits 2-3
are set to `10` and bytes 8-15 are populated per the NES 2.0 spec
(submapper, PRG/CHR MSBs, PRG/CHR RAM, timing). The header stays
16 bytes. Programs opt in via `game Foo { header: nes2 }`; the
default remains iNES 1.0 so every committed example ROM is byte
identical. `validate_ines` now detects and reports which format it
parsed.

u16 struct fields: the analyzer's `register_struct` accepts `u16`
fields with a two-byte size and the struct-variable allocator tracks
per-field sizes so the synthesized `pos.x`/`pos.y` globals get the
right address span. IR lowering's `LValue::Field` and
`Expr::FieldAccess` follow the same wide path as u16 globals, and
struct-literal initialization writes both bytes for u16 fields.
Array and nested-struct fields stay rejected with a clearer
message. Existing u8/i8/bool struct programs are unaffected.

https://claude.ai/code/session_01MaNVcDmK9gsspRkdxowQAM
2026-04-14 02:05:51 +00:00
Claude
65a63f9a68
tooling: add --no-opt CLI flag and criterion compile benchmarks
Adds two items from the "Code quality / tooling" section of
docs/future-work.md. Both make it easier to chase regressions
without touching codegen.

- `nescript build --no-opt` skips the IR optimizer pass so
  optimizer-introduced miscompiles can be bisected against the
  unoptimized output. Threaded through CompileOptions and gated
  at the single optimizer call site in src/main.rs. Covered by a
  new integration test that compiles the same program twice
  (opt on / opt off) and asserts both outputs are valid iNES
  ROMs with matching headers and reset vectors.

- A criterion-based `benches/compile.rs` harness that times the
  full parse -> analyze -> lower -> optimize -> codegen -> link
  pipeline on every examples/*.ne file. Sources are pre-read
  into memory so file I/O stays off the hot loop, and each
  example gets its own Criterion group for easy regression
  spotting.

Committed ROM bytes under examples/*.nes are unchanged; the
emulator goldens under tests/emulator/goldens/ are untouched.
2026-04-14 01:43:51 +00:00
Claude
d2cfce595b
analyzer: add four new warning diagnostics
Extends the analyzer with the warnings listed under "Error message
polish" in docs/future-work.md:

- W0102 (existing): now also fires for `while true { ... }` and
  `loop { if cond { continue } }`. `continue` is explicitly not
  counted as an exit — the loop still spins forever.
- W0105 (new): palette declarations whose sub-palette first-bytes
  disagree. The NES mirrors $3F10/$3F14/$3F18/$3F1C onto $3F00/
  $3F04/$3F08/$3F0C, so a 32-byte sequential write silently
  overwrites the background universal colour; the grouped
  `universal:` form auto-fixes this so only flat `colors: [...]`
  declarations can trip it.
- W0106 (new): a call at statement position whose callee has a
  declared return type — the value is silently dropped.
- W0107 (new): `fast` variables with fewer than three observed
  reads+writes, which don't justify holding a scarce zero-page
  slot. Leading-underscore names are exempt.

All four are warnings only — no IR, codegen, or runtime changes,
so every committed example ROM rebuilds byte-identical and no
emulator goldens flip. Tests added in src/analyzer/tests.rs.

One legitimate W0106 surfaces on examples/platformer.ne (the
`resolve_enemy_hit` helper uses early-return for control flow
but its return value is discarded by the caller); fixing it
would shift the ROM and flip goldens, so it is left in place
as an informational hint rather than a hard fix.

https://claude.ai/code/session_01MaNVcDmK9gsspRkdxowQAM
2026-04-14 01:35:08 +00:00
68184b7296
Merge pull request #17 from imjasonh/claude/test-platformer-game-6S3TX 2026-04-13 17:26:06 -04: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
20cf4f323c
Merge pull request #16 from imjasonh/claude/nes-language-features-CJrlq
Add friendly asset syntax for palettes, sprites, backgrounds, SFX, and music
2026-04-13 15:55:32 -04:00
Claude
ad951a835f
examples: adopt the friendly asset syntax
Rewrites every example with non-trivial asset declarations to use
the pleasant QoL syntax introduced in the previous commit. Every
example still compiles to a byte-identical ROM (verified by a
temp-path diff before committing), so the committed `.nes` files
and the 23 emulator goldens are unchanged.

  * platformer.ne — the centerpiece end-to-end demo:
      - `palette Main` goes to grouped form with a shared
        `universal: 0x22` (sky blue), one shared colour per
        sub-palette, and named NES colours throughout; the
        long-standing `$3F10` mirror-trap warning is now handled
        by the parser and the manual pitfall comment is gone.
      - `sprite Tileset` is 15 tiles of ASCII pixel art instead
        of 240 bytes of inline hex.
      - `background Level` uses a `legend { '.': 15, '#': 9, ... }`
        block plus `map:` strings for the 32×30 nametable, and
        `palette_map:` rows for the attribute table. The map
        reads top-down like the rendered screen.
      - SFX latch-once `pitch: 0x30` scalars + `envelope:` alias.
      - `music Theme` uses note names + `tempo: 10` default.
  * audio_demo.ne — scalar sfx pitches, `envelope:` alias, and a
    note-name `C4, E4, G4, ...` music track.
  * palette_and_background.ne — grouped CoolBlues / WarmReds
    palettes with `universal: black` + named colours, plus
    `legend` + `map:` tilemaps for the two backgrounds.
  * sprites_and_palettes.ne — Arrow and Heart sprites rewritten
    as `pixels:` ASCII art.

Along the way, two small parser extensions support the rewrites:

  - `parse_pixel_art` now accepts `a/b/c` as aliases for `#/%/@`,
    matching the vocabulary every NES editor (and our own
    gen_platformer_tiles.rs generator) uses.
  - `palette_map_to_attrs` allows up to 16 metatile rows (the
    full attribute-table coverage, including the off-screen
    bottom half) and auto-replicates row 14 → row 15 when only
    15 rows are supplied so the visible bottom of the screen
    gets consistent sub-palette assignments by default. The old
    15-row cap couldn't match a hand-packed `0xAA` attribute
    table for the last row; the platformer required this to
    stay byte-identical.

`scripts/gen_platformer_tiles.rs` is updated to emit the new
syntax directly (pixel-art `pixels:` block + `legend`/`map:`/
`palette_map:` for the background), so regenerating the
platformer tiles stays a one-liner.

474 lib tests + 64 integration tests pass (3 new parser tests
for `palette_map:` 15/16/17 rows and the `abc` alias). All 23
emulator goldens still match pixel- and sample-for-sample.

https://claude.ai/code/session_01PzaSFj3VahDzxEYTKCESkz
2026-04-13 18:04:21 +00: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
59905147b4
Merge pull request #15 from imjasonh/claude/platformer-game-demo-WK8XS
Commit built .nes ROMs alongside .ne sources
2026-04-13 11:23:25 -04:00
Claude
12b54a715e
ci: emulator job must still rebuild ROMs from source
The previous commit wired the emulator job to read the committed
examples/*.nes directly, with the rationale that the `examples`
job's reproducibility diff would catch "stale committed ROMs"
at PR time. That reasoning is wrong and defeats the point of
the emulator harness.

Failure mode: a compiler change lands with correctly-rebuilt
ROMs (passing the examples job) but introduces a rendering
regression that flips a golden. The emulator job would catch
this today because it runs the harness against freshly-compiled
ROMs. With the previous commit's shortcut, the harness would
boot the committed ROMs — which the PR author just rebuilt to
match — so the mismatch wouldn't show up until a second commit
touched anything else. That's exactly the kind of silent-failure
hole the golden harness exists to plug.

Fix: emulator job rebuilds the compiler (toolchain + cache +
`cargo build --release`) and compiles every .ne into the
workspace before running the harness, same as the pre-ROMs-
committed era. The committed ROMs keep their review/demo role
(clone-and-play, diff visibility in PRs) but the test job
always validates the working compiler, not a frozen snapshot.

CLAUDE.md updated to match: the harness runs against whatever
sits in examples/*.nes, so iterating locally still means
rebuilding the ROM(s) you care about first.

https://claude.ai/code/session_01BcCcHi6FUmTh8jC7UgkA3A
2026-04-13 15:21:38 +00:00
Claude
57faf9e36a
commit built ROMs alongside .ne sources
The compiler is deterministic: rebuilding any example produces
a byte-identical ROM, verified across all 22 examples and all
four mappers (NROM, MMC1, UxROM, MMC3). That means the .nes
files are reproducible artefacts and can live next to their
sources without drift.

Benefits:

- Users can clone the repo and open any example in an emulator
  without installing a Rust toolchain or running the compiler.
- The emulator harness can trust examples/*.nes directly, so its
  CI job no longer needs a compiler build or a "compile all
  examples" loop — it just boots jsnes against the committed
  ROMs and diffs each against its golden.
- ROM diffs in PRs are now meaningful: "this compiler change
  flipped 17 bytes in hello_sprite.nes" is visible review
  signal, not hidden behind the emulator golden.

Guard rails so the ROMs don't drift from their sources:

- .gitignore no longer excludes *.nes.
- The `examples` CI job rebuilds every .ne into /tmp and fails
  loudly (with a GitHub error annotation pointing at the exact
  cargo command to rerun) if any committed ROM differs.
- scripts/pre-commit does the same check locally.
- CLAUDE.md now states that editing a .ne file requires
  rebuilding its .nes in the same commit, so future agents
  won't miss the invariant.

Total footprint: 22 ROMs, 624 KB (avg 28 KB each — most are
NROM 24 KB; two banked examples are larger).

https://claude.ai/code/session_01BcCcHi6FUmTh8jC7UgkA3A
2026-04-13 15:12:39 +00:00
f82284e98e
Merge pull request #14 from imjasonh/claude/platformer-game-demo-WK8XS
Add platformer example and fix PPU rendering initialization
2026-04-13 09:19:36 -04:00
43dc1ace49
Update README to simplify platformer example description
Removed detailed description of the platformer example and updated source reference.
2026-04-13 09:09:36 -04:00
Claude
0a6be600f2
readme: surface platformer source link under the demo GIF
The GIF made it into the README in the previous commit but the
source link was buried inside an italic caption. Promote it to
its own "Source:" line right under the image so readers see it
without having to parse a sentence, and add a pointer to the
tile generator binary next to it.

https://claude.ai/code/session_01BcCcHi6FUmTh8jC7UgkA3A
2026-04-13 13:08:11 +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
958f41d340
Merge pull request #13 from imjasonh/claude/code-review-cleanup-uXlrt 2026-04-13 07:41:58 -04:00
Claude
25946f74ba
docs: add CLAUDE.md documenting the jsnes harness and repo conventions
New top-level `CLAUDE.md` so future AI agents (and humans) don't have
to rederive the project conventions from the existing docs. Covers:

- The phase layout under `src/` and the expectation that every
  module has a co-located `tests.rs`.
- The core `cargo` commands and the fact that `fmt` and `clippy`
  are mandatory before committing.
- The jsnes emulator harness end-to-end:
  - File layout (`harness.html`, `run_examples.mjs`, `goldens/`,
    `actual/`).
  - How to run it locally (build release compiler, compile every
    example, `npm install`, `node run_examples.mjs`).
  - How to update goldens with `UPDATE_GOLDENS=1` and the rules for
    when that's allowed.
  - The procedure for adding a new example (build, golden, verify,
    README).
  - What the harness catches (codegen / runtime / PPU / APU / asset
    regressions) vs. what it doesn't (input, beyond frame 180).
  - How the CI job in `.github/workflows/ci.yml` wires everything up
    and uploads `actual/` on failure.
- Zero-page allocation rules (runtime reserves `$00-$0F`,
  palette/bg reserves `$11-$17` conditionally, user vars `$10+` or
  `$18+`, IR temps `$80+`) so the next agent doesn't accidentally
  collide with the runtime.
- "Things to avoid" list: no backwards-compat shims, no silencing
  goldens without understanding the diff, no committing the
  gitignored harness directories.

https://claude.ai/code/session_012fKB251HvEUQwG3tizFyqt
2026-04-13 11:38:54 +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
fdb1ec7c91
cleanup: fix silent miscompiles and delete dead code exposed by code review
Two correctness bugs were silently producing wrong ROMs:

  - `x << n` / `x >> n` always shifted by 1, regardless of `n`, because
    the IR lowering for `BinOp::ShiftLeft`/`ShiftRight` hardcoded the
    count. Now eval_const the RHS into a compile-time count; fall back
    to a new `IrOp::ShiftLeftVar` / `ShiftRightVar` (runtime loop) when
    the amount isn't constant. Strength reduction folds the variable
    form back to a fixed count once the optimizer knows the value.

  - `x / n` / `x % n` always returned 0, because the lowering emitted
    `LoadImm(t, 0)` for `BinOp::Div`/`Mod` with a comment saying the
    runtime call was "TODO for now". Added real `IrOp::Div` and
    `IrOp::Mod`, wired them through use-counting and DCE, gave codegen
    `__divide`-based implementations, and taught strength reduction to
    rewrite power-of-two divisors into shifts and modulo-by-2ⁿ into
    AND masks. Constant folding now handles `Mul`/`Div`/`Mod`/shifts
    too, which were previously left for the codegen to emit inefficient
    software calls.

Dead code removed (no backward-compat shims kept):

  - `src/debug/` entirely. `DebugSymbols`, `SourceMap`, and the
    Mesen/.sym emitters had no callers outside their own tests;
    `main.rs` never wrote a symbol file. Documented the intent in
    `docs/future-work.md` so it comes back intentionally if needed.
  - `ErrorCode::E0202` (invalid cast) and `E0403` (unreachable state):
    defined, formatted, and marked `#[allow(dead_code)]` but never
    emitted. W0104 now carries the unreachable-state semantics too.
  - `Level::Info`: never constructed.
  - `load_background` / `set_palette` statements and their
    `BackgroundDecl` / `PaletteDecl` parser support: parsed and
    silently dropped by IR lowering (`// TODO: implement in asset
    pipeline`). Removed keywords, AST variants, parser paths, analyzer
    arms, and tests. `docs/future-work.md` documents the runtime
    palette/nametable design for when it comes back.

Doc cleanup:

  - `docs/architecture.md` was describing files that don't exist
    (`analyzer/types.rs`, `optimizer/const_fold.rs`, `codegen/regalloc.rs`,
    `rom/header.rs`, `debug/symbols.rs`, …). Rewrote it to match the
    real flat `mod.rs` + `tests.rs` layout and the real pipeline order.
  - `docs/future-work.md` was a hybrid of open work and "recently
    completed" entries that duplicated the active stubs at the top of
    the file. Collapsed to just the gaps that are actually still open.
  - `README.md` claimed Mesen symbol export and 210 tests; updated both.
  - `docs/language-guide.md` and `spec.md` described `palette` decls,
    `set_palette` / `load_background`, `debug.overlay`, and error codes
    that were never emitted. Trimmed.
  - Stale comments on `Statement::Play`/`StartMusic`/`StopMusic`
    claimed the audio subsystem was "a no-op at codegen time".

Tests:

  - Regression tests for every fix above (`lower_shift_left_with_literal
    _count_uses_that_count`, `lower_shift_right_with_variable_count
    _uses_runtime_variant`, `lower_divide_emits_div_op_not_load_imm
    _zero`, `lower_modulo_emits_mod_op_not_load_imm_zero`,
    `strength_reduce_div_by_power_of_two`, `strength_reduce_mod_by
    _power_of_two`, `strength_reduce_shift_var_with_constant_amount`).
  - Renamed the `program_with_sprites_and_palette` integration test
    (which was exercising the now-removed `load_background`/`set_palette`)
    to `program_with_inline_sprite_chr`.

`examples/sprites_and_palettes.ne` lost its `palette`/`set_palette`
usage. Nothing in the emulator test presses A, so the headless
jsnes render shouldn't move, but the golden may need regeneration
via `UPDATE_GOLDENS=1` if it does.

https://claude.ai/code/session_012fKB251HvEUQwG3tizFyqt
2026-04-13 02:47:37 +00:00
971a128d0b
Merge pull request #12 from imjasonh/claude/implement-bank-switching-DXH1Y 2026-04-12 22:10:35 -04: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
3307f75da6
banks: implement multi-bank PRG layout and bank-switching runtime
Prior to this commit the linker always shipped a single 16 KB PRG
bank regardless of the declared mapper, so the README's MMC1/UxROM/
MMC3 support was aspirational. This commit gives the three banked
mappers a real multi-bank ROM layout:

  * RomBuilder.set_prg_banks() writes any number of 16 KB banks
    back-to-back so the iNES header reflects the true PRG size.
  * Linker.link_banked() places switchable banks first, fixed bank
    last, so the fixed bank maps to $C000-$FFFF (the address window
    where vectors and the runtime live).
  * runtime::gen_mapper_init() emits reset-time mapper config:
    MMC1 serial-writes a control-register value that pins the last
    bank at $C000 with the correct mirroring, UxROM relies on the
    power-on default, MMC3 writes the $8000/$8001/$A000/$E000
    registers to get a known PRG and mirroring state.
  * runtime::gen_bank_select() is a mapper-specific subroutine
    (callable with the target bank in A) that maps any physical
    bank to $8000-$BFFF.
  * runtime::gen_bank_trampoline() generates a cross-bank call
    stub in the fixed bank that saves the caller's bank, switches,
    JSRs the target, and restores the fixed bank.
  * The CLI and integration helper thread declared `bank X: prg`
    declarations through to the linker so MMC1/UxROM/MMC3 programs
    actually produce multi-bank ROMs.

Coverage:

  * Runtime unit tests (18 new): mapper init patterns for every
    supported mapper, bank-select signatures, trampoline dispatch
    order, UxROM bus-conflict table contents.
  * RomBuilder tests (6 new): multi-bank layout, padding,
    byte-level fidelity, per-bank size validation, legacy
    single-bank fallback.
  * Linker tests (13 new): multi-bank ROM sizes across MMC1/
    UxROM/MMC3, fixed-bank placement, switchable-bank payload
    fidelity, bank-select subroutine detection, NROM rejection
    of switchable banks.
  * Integration e2e tests (16 new): compile real .ne sources
    through the full pipeline and assert on iNES headers,
    mapper init signatures in the fixed bank, vector locations,
    and a regression check against `examples/mmc1_banked.ne`.

Total: 474 tests pass under `cargo test` with
`RUSTFLAGS="-D warnings"`.

https://claude.ai/code/session_01UCressA5e8k1XsuoJYLav2
2026-04-13 01:50:51 +00:00
08d9798940
Merge pull request #11 from imjasonh/claude/complete-audio-subsystem-1RHhq 2026-04-12 21:31:03 -04: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
d42540f45e
audio: complete the subsystem — asset pipeline, user decls, tracker-style driver
The audio subsystem was a sketch: `play name` / `start_music name` /
`stop_music` parsed, lowered, and emitted a few hardcoded register
writes from a builtin name table. No user-declared effects, no
per-frame envelope, no note streams, no real engine.

This flesh-out brings audio up to the quality bar of the rest of
the compiler (sprites, palettes, bank switching, scanline IRQ,
etc.) with a full data-driven pipeline:

## Asset pipeline (new `src/assets/audio.rs`)

- `sfx Name { duty, pitch, volume }` blocks compile into per-frame
  pulse-1 envelopes. Pitch/volume arrays must match in length; each
  entry is one NMI's worth of `$4000` data.
- `music Name { duty, volume, repeat, notes }` blocks compile into
  flat `(pitch, duration)` streams for pulse 2. Pitch 0 is a rest,
  1-60 indexes a builtin period table covering C1-B5.
- `resolve_sfx` / `resolve_music` walk the program for `play` /
  `start_music` references and append builtin fallbacks for any
  name that isn't user-declared — so `play coin` still works
  without a `sfx Coin { ... }` block.
- Builtin effects (coin, jump, hit, click, cancel, shoot, step)
  and tracks (theme, battle, victory, gameover) synthesize through
  the same compile path as user decls — one data model, one driver.

## Runtime engine (`src/runtime/mod.rs`)

- `gen_audio_tick()` walks both channels every NMI: reads one
  envelope byte through `(ZP_SFX_PTR),Y` -> writes `$4000`,
  advances ptr, mutes on zero sentinel. Music decrements the note
  counter, advances to the next `(pitch, dur)` pair on zero, looks
  up the period through `(__period_table),Y`, loops on `0xFF 0xFF`.
- `gen_period_table()` emits a 60-entry equal-tempered table
  (A4 = 440 Hz, NTSC 1.789773 MHz CPU clock) with length-counter
  load bits pre-baked into each high byte.
- `gen_data_block()` emits a label + raw-bytes pseudo pair so
  user sfx/music data can be spliced into PRG with regular labels
  that the two-pass assembler resolves.
- New ZP layout: `$05/$06` music loop base, `$07` music state
  (duty/volume/loop/active), `$0C-$0F` sfx and music pointers.

## IR codegen (`src/codegen/ir_codegen.rs`)

- `with_audio(sfx, music)` registers compile-time trigger constants
  per blob name.
- `gen_play_sfx` emits: write period to `$4002`/`$4003`, load
  envelope pointer into `ZP_SFX_PTR` via SymbolLo/SymbolHi of
  `__sfx_<name>`, mark the sfx counter active.
- `gen_start_music` stamps the header byte into `ZP_MUSIC_STATE`
  with the active bit OR'd in, seeds both ptr and loop base from
  `__music_<name>`, primes the duration counter.
- `gen_stop_music` mutes pulse 2 and clears state.

## Linker (`src/linker/mod.rs`)

- New `link_with_all_assets(user_code, sprites, sfx, music)` path
  that splices driver body, period table, and each sfx/music data
  blob into PRG — all guarded on the `__audio_used` marker so
  silent programs pay zero ROM cost.

## Assembler (`src/asm/opcodes.rs`, `src/asm/mod.rs`)

- New `AddressingMode::Bytes(Vec<u8>)` variant for raw-data
  pseudo-instructions. `NOP+Bytes(v)` emits the payload verbatim,
  letting the linker splice ROM data tables into a code section
  and still have `Label` / `SymbolLo` / `SymbolHi` fixups resolve
  correctly in the same assembly pass.

## Analyzer

- `play` / `start_music` now validate the name against user decls
  and builtin tables. Unknown names emit E0505 with a helpful list
  of builtins — previously a typo would silently compile to no-op.

## Parser

- New `sfx_decl` / `music_decl` grammar with property-style
  configuration. Strict validation: duty 0-3, volume 0-15, pitch
  arrays must match volume length, music notes must come in pairs,
  pitch 0-60, duration ≥ 1.

## Tests

+170 new tests across every layer:
- `src/assets/audio.rs`: 17 tests (compile, resolve, builtins,
  shadowing, label sanitation, nested reference walks)
- `src/parser/tests.rs`: 13 tests (valid/invalid sfx + music
  declarations, property validation, play/start_music/stop_music)
- `src/analyzer/tests.rs`: 7 tests (builtin acceptance, user decl
  acceptance, unknown-name rejection)
- `src/runtime/tests.rs`: 10 tests (audio tick labels, RTS end,
  $4000 write, $4004 mute, period table assembly, A4 = 440 Hz,
  length counter bits, data block verbatim emit)
- `src/linker/tests.rs`: 4 tests (sfx/music blob placement,
  pointer resolution, elision when unused)
- `src/codegen/ir_codegen.rs`: rewrote the 4 existing audio tests
  to match the new data-driven contract
- `tests/integration_test.rs`: 4 end-to-end tests including a
  user-declared `sfx` + `music` program that verifies bytes land
  in PRG ROM at the right addresses

## Docs

- New Audio section in `docs/language-guide.md` with syntax
  reference, builtin tables, and an explanation of how the
  driver works at compile and run time.
- `docs/architecture.md` updated to reflect the real audio
  pipeline instead of the old "audio import stubs" stub.
- `docs/future-work.md` moves audio from "status: minimal" to
  "status: full subsystem" with a narrower list of follow-up work
  (triangle/noise/DMC channels, NSF/FTM imports, richer envelopes).
- `examples/audio_demo.ne` rewritten to showcase user-declared
  `sfx LongCoin`, `sfx Zap`, `music Theme`, still demonstrating
  builtin fallback via `play coin`.

Total: 424 tests passing (381 unit + 43 integration), clippy clean,
fmt clean, all 19 examples compile.

https://claude.ai/code/session_015WfaDttE3DpWn9rpyfpQd8
2026-04-13 01:10:21 +00:00
c5c8c38a54
Merge pull request #10 from imjasonh/claude/implement-planned-features-HCf0L 2026-04-12 18:55:19 -04:00