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

74 commits

Author SHA1 Message Date
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
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
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
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
Claude
9a539ea068
compiler: audio driver, u16 arithmetic, multi-scanline, slot recycling
Five language features and optimizations from the planned-work backlog:

- **Minimal audio driver**: `play`/`start_music`/`stop_music` now generate
  APU pulse-1/pulse-2 writes from a builtin SFX/music name table, and
  the NMI handler gains a `JSR __audio_tick` splice (via the linker's
  `__audio_used` marker lookup) that ages an SFX countdown counter and
  mutes pulse 1 when the tone expires. Programs that never trigger
  audio pay zero ROM cost.

- **u16 arithmetic and comparisons**: new IR ops `LoadVarHi`, `StoreVarHi`,
  `Add16`, `Sub16`, and six `Cmp*16` variants. The lowering context
  tracks variable types via the analyzer's symbol table and routes
  expressions through the 8-bit or 16-bit path based on operand width.
  Add16 emits `CLC;ADC;ADC` with carry propagating naturally into the
  high byte; compares dispatch high-byte-first with a short-circuit
  low-byte fallback. Fixes a silent miscompile where `big += 1` on a
  u16 var only incremented the low byte.

- **Multi-scanline handlers per state**: `gen_scanline_irq` now
  dispatches on `(current_state, ZP_SCANLINE_STEP)` and reloads the
  MMC3 counter with the delta to the next scanline in the same state.
  `gen_scanline_reload` resets the step counter at the top of each
  NMI so a state with multiple handlers fires them in ascending line
  order. Previously only the first handler per state ever fired.

- **IR temp slot recycling**: `build_use_counts` pre-scans each
  function to count per-temp uses; `retire_op_sources` decrements
  the counts after each op and pushes dead slots back onto
  `free_slots` for later allocation. `bitwise_ops.ne` used to crash
  (debug) or miscompile (release) once it hit 128 concurrent temps;
  with recycling the same function now uses ~4 slots instead of 136.

- **INC/DEC peephole fold + improved dead-load elimination**:
  `fold_inc_dec` collapses `LDA addr; CLC; ADC #1; STA addr` into
  a single `INC addr` (and the SEC/SBC variant into `DEC addr`),
  saving 5 bytes and 5 cycles per increment. The fold is suppressed
  when the next instruction reads carry. `remove_dead_loads` now
  walks past INC/DEC/STX/STY (which don't touch A) to find the
  actual next A-use, catching more dead loads.

Tests: 331 unit + 39 integration (up from 313 + 37), including new
guards for audio, u16, multi-scanline, and slot recycling.

https://claude.ai/code/session_01A8qk3gw2jWSzdiXBZPZSFE
2026-04-12 22:21:53 +00:00
Claude
a757336681
Remove the legacy AST codegen — IR path is canonical now
The `--use-ast` path through `src/codegen/mod.rs` was a strictly
inferior subset of the IR codegen. Building every example with
`--use-ast` through the jsnes harness:

- `arrays_and_functions` — fully black (array init + function
  return values + OAM-in-loop all broken)
- `structs_enums_for` — fully black (struct literal is a no-op,
  all fields stay at 0)
- `inline_asm_demo` — fully black
- `bitwise_ops`, `loop_break_continue` — below sprite floors
  (static `next_oam_slot` bug B)
- `match_demo` — panics at compile time with
  `branch offset 153 out of range` (AST's if/else-chain
  desugaring of `match` emits short branches that can't reach
  the far arms in a multi-arm match)

Six of fourteen examples are non-functional under `--use-ast`.
The other eight happen to fall inside the subset AST handles
(no arrays, no structs, no function return values, no
multi-sprite loops, no long match chains).

`docs/future-work.md` already listed "Once working, delete the
AST-based codegen entirely" as the intended direction. It's
working, so this commit does the deletion.

What's removed:
- The `CodeGen` struct, its impl block, and every helper in
  `src/codegen/mod.rs` (the AST codegen body) — ~1150 lines.
  The file is now a module header that re-exports `IrCodeGen`.
- `src/codegen/tests.rs` — 15 AST-specific instruction-pattern
  tests. Every feature they covered has an equivalent test in
  `src/codegen/ir_codegen.rs::{tests,more_tests}` already.
- The `--use-ast` CLI flag and its branch in `src/main.rs`.
- `compile_with_ir_codegen` in `tests/integration_test.rs` —
  `compile()` now does what it did, so they merged. All 40
  integration tests go through the IR path.
- Outdated sections in `docs/future-work.md` that described the
  IR codegen as "not yet implemented" and listed AST codegen
  gaps as priority work.

What's kept:
- `src/codegen/ir_codegen.rs` — the real codegen.
- `src/codegen/peephole.rs` — post-codegen cleanup pass, now
  run unconditionally from `main.rs`.

Test plan:
- `cargo test --release` — 313 unit + 37 integration tests pass
  (was 328 + 37; the 15 dropped are the deleted AST-specific
  tests).
- `cargo fmt --check` clean.
- `cargo clippy --release --all-targets -- -D warnings` clean.
- `node tests/emulator/run_examples.mjs` — 14/14 ROMs render
  above their per-example nonBlack floors.
- The one tightening: `sprite_resolution_uses_tile_index` was
  asserting on the old static-slot encoding
  (`A9 01 8D 01 02`). Updated to the cursor-based form
  (`A9 01 99 01 02`, i.e. STA AbsoluteY).

Net diff: 1581 deletions, 62 insertions.

https://claude.ai/code/session_014Z5y3Q9krLcAxYpZQJhZ5V
2026-04-12 20:37:59 +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
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
Claude
629e596ec7
Codegen: skip OAM clear loop in handlers that don't draw
The OAM clear at frame-handler entry is only needed if the
handler actually modifies OAM — handlers that never call \`draw\`
don't need to reset the shadow buffer at all, since the previous
handler's contents are still correct (or the startup zero-fill
still applies). Adds a \`function_draws_sprites\` helper that
walks the IR ops once and emits the clear loop only when at least
one \`DrawSprite\` exists.

Handlers like a title screen that doesn't render sprites pay zero
cycles for the OAM clear now.

https://claude.ai/code/session_01W6eQFStA66EuMKHUFo2rx3
2026-04-12 18:06:02 +00:00
Claude
c10251338d
Analyzer: E0203 for bare return in typed function
A \`return\` with no value inside a function that has a declared
return type is an error — the caller expects a value. Previously
it compiled silently and the callee returned whatever was in A
from the last computation.

https://claude.ai/code/session_01W6eQFStA66EuMKHUFo2rx3
2026-04-12 18:01:07 +00:00
Claude
2fd3a6e545
IR codegen: clear OAM shadow at frame handler entry
Each frame handler now begins with a short 64-slot loop that
writes \$FE to the Y-position byte of every OAM entry, hiding any
sprites the previous frame drew that the current frame doesn't.
Without this, stopping a \`draw\` call one frame leaves the sprite
lingering at its last position forever.

Cost: ~384 cycles per frame (2.3% of the ~16 666 cycles available
between vblanks). The per-frame \`draw\` calls overwrite the slots
they actually use, so the clear is free at runtime for sprites
that ARE drawn.

https://claude.ai/code/session_01W6eQFStA66EuMKHUFo2rx3
2026-04-12 17:58:36 +00:00
Claude
d2075bc8f9
Codegen: richer A-value tracking via equivalence classes
Previously \`remove_redundant_loads\` tracked only a single value
for A at a time, so \`STA addr\` would overwrite the prior
\`Imm(v)\` with \`Zp(addr)\`, losing the ability to eliminate a
following \`LDA #v\`. Now A's known value is an equivalence class
(\`Vec<AValue>\`): every \`STA addr\` *adds* the address to the
class instead of replacing it, so

    LDA #5
    STA \$10
    LDA #5   ; eliminated — A still equals 5

collapses to just two instructions. Adding this also catches the
earlier \`STA \$10; LDA \$10\` idiom because \`Zp(\$10)\` is now part
of the class after the STA.

Any op that clobbers A clears the whole class; writes via STX/STY
or INC/DEC to an address in the class invalidate just that entry.

Instruction counts tighten on every example, most visibly on
bouncing_ball (123→120) and arrays_and_functions (247→245).

https://claude.ai/code/session_01W6eQFStA66EuMKHUFo2rx3
2026-04-12 17:56:11 +00:00
Claude
6e007774e4
CLI: --call-graph flag
Prints a tree view of every function/handler and its direct
callees, plus the max call depth reached from each state-handler
entry point. Useful for stack-budget investigation since the NES
has only 256 bytes of stack.

    === Call Graph (max depth: 2 / 8) ===
    Main::frame (max depth 2)
      ├── clamp
      ├── clamp
      └── check_collision
    check_collision
      ├── abs_diff
      └── abs_diff
    abs_diff
      └── (leaf)
    clamp
      └── (leaf)

Max-depth labels are only shown on entry points where the analyzer
has computed a depth; transitive callees print without a label so
the output isn't confusing.

https://claude.ai/code/session_01W6eQFStA66EuMKHUFo2rx3
2026-04-12 17:46:13 +00:00
Claude
db234a6ca7
CLI: --memory-map flag
Dumps a human-readable table of variable allocations sorted by
address, separated into zero-page and main RAM sections with a
final byte-usage summary. Struct fields show up as individual
entries under their synthetic \`var.field\` names.

Example output for examples/structs_enums_for.ne:

    === NEScript Memory Map ===
    Zero Page (\$00-\$FF):
      \$00-\$0F  [SYSTEM]  reserved (frame flag, input, state, params, scratch)
      \$0010    [USER]    enemy_y (u8)
      \$0011    [USER]    i (u8)

    RAM (\$0200-\$07FF):
      \$0200-\$02FF  [SYSTEM]  OAM shadow buffer
      \$0300        [USER]    player.x (u8)
      \$0301        [USER]    player.y (u8)
      \$0302        [USER]    player.vx (u8)
      ...

    Zero Page: 2/128 bytes used
    Main RAM:  11/1280 bytes used

https://claude.ai/code/session_01W6eQFStA66EuMKHUFo2rx3
2026-04-12 17:43:39 +00:00
Claude
496925344d
Language: poke() and peek() hardware intrinsics
Common PPU/APU/mapper access previously required either variable
aliases or inline asm. Now two built-in intrinsics handle the
single-register case directly:

    poke(0x2006, 0x3F)     // STA \$3F, \$2006
    poke(0x2006, 0x00)
    poke(0x2007, 0x0F)
    var status: u8 = peek(0x2002)

- Analyzer: \`poke\` / \`peek\` are recognized as built-in intrinsics
  so they don't require a function declaration. Arity is still
  checked (E0203 on mismatch).
- IR: new \`IrOp::Poke(u16, IrTemp)\` and \`IrOp::Peek(IrTemp, u16)\`
  variants carrying the compile-time constant address.
- IR lowering: recognizes the \`poke\`/\`peek\` call names, evaluates
  the address as a const expression, and emits the intrinsic op.
  Falls back to a regular call if the address isn't a constant.
- IR codegen: emits a single LDA/STA in ZP or absolute mode based
  on whether the address fits in a byte.
- Optimizer: Poke has a source temp (liveness), Peek has a dest
  (new value); both pass through the existing passes.

https://claude.ai/code/session_01W6eQFStA66EuMKHUFo2rx3
2026-04-12 17:40:34 +00:00
Claude
a45944e0f9
Language: raw asm { ... } blocks
raw asm {
        LDA #\$42
        STA \$00
    }

Unlike regular \`asm\`, \`raw asm\` does not perform \`{var}\`
substitution — the body is passed to the inline parser verbatim.
Useful for writing completely unmanaged bytes that don't rely on
the analyzer's variable allocations, e.g. mapper init snippets.

Implementation:
- Parser: \`KwRaw\` followed by \`KwAsm\` emits
  \`Statement::RawAsm(body, span)\`.
- IR lowering: prepends a \`\\0RAW\\0\` marker to the body when
  emitting \`IrOp::InlineAsm\` so the codegen can distinguish raw
  from regular without adding a second op variant.
- IR codegen: strips the marker and skips substitution when present.
- AST codegen: same, handling \`Statement::RawAsm\` directly.

https://claude.ai/code/session_01W6eQFStA66EuMKHUFo2rx3
2026-04-12 17:34:17 +00:00
Claude
a283f87b79
Inline asm: {var} placeholder substitution
Within \`asm { ... }\` blocks, \`{name}\` is replaced with the
resolved hex address of the variable at codegen time. The lexer's
asm-body capture now balances nested braces so it doesn't cut off
at the first \`{x}\`. Both IR and AST codegen paths preprocess the
body before passing to the inline parser:

    var counter: u8 = 0
    on frame {
        asm {
            LDA {counter}
            CLC
            ADC #\$01
            STA {counter}
        }
    }

Zero-page addresses become \`\$XX\`, absolute addresses become
\`\$XXXX\`. Unknown names pass through unchanged so the asm parser
can surface the "unknown mnemonic" / "unexpected token" error.

https://claude.ai/code/session_01W6eQFStA66EuMKHUFo2rx3
2026-04-12 17:30:21 +00:00
Claude
f95130346a
Parser: accept ; as optional statement separator
Newlines remain the primary separator, but \`;\` now lets short
statements share a line for readability:

    on frame {
        a += 1; b += 2
        if button.a { a -= 1; b -= 1 }
    }

Implementation: \`parse_block\` consumes any \`;\` tokens between
statements.

https://claude.ai/code/session_01W6eQFStA66EuMKHUFo2rx3
2026-04-12 17:23:05 +00:00
Claude
f0264b124a
Language: match statement
match state {
        Title => { if button.start { state = Playing } }
        Playing => { /* ... */ }
        GameOver => { if button.a { state = Title } }
        _ => {}
    }

- Lexer: \`match\` keyword and \`=>\` (FatArrow) token
- Parser: \`parse_match\` after the existing loop constructs. Each
  arm is \`pattern => { body }\`, with \`_\` as the catch-all. The
  match scrutinee is parsed with struct-literal restriction enabled
  so the following \`{\` is unambiguously the match body, not a
  struct literal.
- The parser desugars match directly into an if/else-if chain so
  the analyzer, IR lowering, and codegen don't need new AST variants
  — each arm becomes \`scrutinee == pattern\` as the condition, and
  the default arm (if any) becomes the final \`else\` block.

Tests cover parse + full pipeline integration for state-style
dispatch using an enum.

https://claude.ai/code/session_01W6eQFStA66EuMKHUFo2rx3
2026-04-12 17:21:00 +00:00
Claude
c8ae433a7c
Language: struct literals
struct Vec2 { x: u8, y: u8 }
    var pos: Vec2 = Vec2 { x: 100, y: 50 }
    on frame {
        pos = Vec2 { x: pos.x + 1, y: pos.y }
    }

- AST: new \`Expr::StructLiteral(name, fields, span)\` variant
- Parser: in expression position, \`Ident {\` enters struct-literal
  mode when the new \`restrict_struct_literals\` flag is off.
  \`if\`/\`while\`/\`for\` conditions set the flag so the \`{\` keeps
  going to the following block. Condition contexts can still use
  struct literals by parenthesizing them.
- Analyzer: validates that the struct type exists, each named field
  belongs to it, and each field value has a compatible type.
- IR lowering: desugars \`var = StructLiteral { ... }\` (both in
  assignments and variable initializers) into per-field StoreVar
  operations against the analyzer-synthesized \`var.field\`
  variables. No IR type for struct values is needed.
- AST codegen: no-op (legacy path).
- examples/structs_enums_for.ne now uses a struct literal for the
  initial \`player\` state instead of per-field assignments.

https://claude.ai/code/session_01W6eQFStA66EuMKHUFo2rx3
2026-04-12 17:15:57 +00:00
Claude
2e5933ccca
Codegen: extend A-tracking and copy-propagation to absolute addresses
Both peephole passes now track \`Absolute\` addresses in addition to
zero-page. This lets function-local variables (which live in RAM at
\$0300+) benefit from the same redundant-load elimination and
operand-rewriting that globals at ZP addresses already enjoyed.

Before:
    LDA \$04         ; load param
    STA \$0300       ; store to t
    LDA \$0300       ; reload t
    STA \$81         ; store to temp
    LDA \$0300       ; reload t again
    STA \$82         ; store to temp
    LDA \$81
    CLC
    ADC \$82
    STA \$0300
    LDA \$0300
    RTS

After:
    LDA \$04
    STA \$0300
    CLC
    ADC \$0300
    STA \$0300
    RTS

Renames the A-tracking enum's \`Addr\` variant to \`Zp\` alongside a
new \`Abs\` variant, matching the \`Source\` type used by copy prop.

https://claude.ai/code/session_01W6eQFStA66EuMKHUFo2rx3
2026-04-12 17:04:59 +00:00
Claude
34b312537d
IR codegen: allocate storage for function-local variables
Function bodies can declare local variables with \`var NAME: u8 = …\`.
Previously the lowering created a VarId for them but didn't track it
on the \`IrFunction.locals\` list, so the IR codegen had no address
to map it to and \`LoadVar\` / \`StoreVar\` silently did nothing. The
generated function body read and wrote random temp slots.

Fixes:

- Lowering: replaced the per-function \`locals\` local with a
  long-lived \`current_locals\` field; \`lower_function\` resets it
  on entry and moves it into the \`IrFunction\` at exit. Each
  \`Statement::VarDecl\` inside a function body appends to
  \`current_locals\`.
- IR codegen: iterate every function's \`locals\` list. Params 0..4
  still map to \$04-\$07, and the remaining locals get addresses in
  main RAM starting at \$0300. Each function's locals are disjoint,
  so nested calls don't corrupt each other's state.
- Integration test \`program_with_function_local_variables\`
  exercises nested calls with function-local state to guard against
  regression.

https://claude.ai/code/session_01W6eQFStA66EuMKHUFo2rx3
2026-04-12 17:01:12 +00:00
Claude
240da57b54
Language: for i in start..end loops
Adds a \`for NAME in START..END { BODY }\` half-open range loop:

    for i in 0..8 {
        total += arr[i]
    }

- Lexer: \`for\`, \`in\` keywords and the \`..\` range operator
- AST: new \`Statement::For\` variant with var/start/end/body
- Parser: \`parse_for\` after \`while\` / \`loop\`
- Analyzer: registers the loop variable as a u8 symbol for the body
  (restoring any shadowed outer symbol afterwards), allocates it via
  the normal RAM allocator, and tracks it as "used"
- IR lowering: desugars to \`var = start; while var < end { body;
  var = var + 1 }\` using a \`for_step\` continue-edge block so
  \`continue\` properly increments the index
- AST codegen: no-op (legacy path doesn't need for loops)
- Tests: parse + full-pipeline integration

https://claude.ai/code/session_01W6eQFStA66EuMKHUFo2rx3
2026-04-12 16:55:18 +00:00
Claude
8f0e0635df
IR lowering: constant expression folding
Constants can now reference other constants and use arithmetic /
bitwise / comparison operators. The lowering runs \`eval_const\` on
each \`ConstDecl\` and \`VarDecl\` initializer, threading the existing
\`const_values\` map for identifier lookups.

Examples that now work:

    const BASE: u8 = 10
    const OFFSET: u8 = BASE + 5       // 15
    const MASK: u8 = FLAG_A | FLAG_B  // bitwise-or of two earlier consts
    var counter: u8 = BASE            // initializer is now 10

Enum variants are registered before constants so a const like
\`const FIRST: u8 = VariantA\` also resolves at compile time.

https://claude.ai/code/session_01W6eQFStA66EuMKHUFo2rx3
2026-04-12 16:48:29 +00:00
Claude
0a3e830acf
Codegen: peephole branch folding + dead JMP elimination
Two new passes that save 2 instructions per conditional:

- \`fold_branch_over_jmp\` rewrites \`BNE taken; JMP else_block;
  taken:\` into \`BEQ else_block; taken:\`. Emitted by the IR codegen
  for every \`if\` without a matching \`else\`. Bounded lookahead of
  ~60 instructions keeps the rewritten branch within the signed
  8-bit reach (-128..+127).
- \`remove_jmp_to_next_label\` drops \`JMP foo; foo:\` since the JMP
  is a no-op — execution falls through to the label anyway.

Instruction count impact (IR vs AST, lower is better):

                         before            after
    arrays_and_functions 262 vs 236   ->   243 vs 235
    bouncing_ball        135 vs 144   ->   122 vs 143
    coin_cavern          290 vs 291   ->   261 vs 289
    hello_sprite          73 vs  64   ->    64 vs  63
    mmc1_banked          261 vs 247   ->   235 vs 246
    sprites_and_palettes 121 vs 113   ->   108 vs 112
    state_machine        233 vs 229   ->   209 vs 228

IR codegen now beats the AST codegen on 5 of 7 examples and is
competitive on the other two.

https://claude.ai/code/session_01W6eQFStA66EuMKHUFo2rx3
2026-04-12 16:44:27 +00:00
Claude
e16b765959
Parser: audio statements (play / start_music / stop_music)
The \`play\`, \`start_music\`, and \`stop_music\` keywords were lexed
but not parsed. Programs using them failed with a generic "unexpected
token" error. Now:

- \`Statement::Play(sfx_name, span)\`
- \`Statement::StartMusic(track_name, span)\`
- \`Statement::StopMusic(span)\`

parse successfully and flow through analyzer / IR lowering / codegen
as no-ops. Semantics match the spec but produce no output — no audio
driver exists yet. Users who need sound can still wire in custom
code via \`asm { ... }\` blocks.

https://claude.ai/code/session_01W6eQFStA66EuMKHUFo2rx3
2026-04-12 16:38:59 +00:00
Claude
59970a4a45
Analyzer: E0502 on assignment to undefined variable
Previously an assignment to an undeclared name was silently accepted
because the IR lowering would synthesize a fresh VarId for it. Now
the analyzer emits E0502 with the same "did you mean" suggestion
path as read-side undefined variable errors.

https://claude.ai/code/session_01W6eQFStA66EuMKHUFo2rx3
2026-04-12 16:35:49 +00:00
Claude
1778ae0ec8
IR codegen: fix function call target label + parameter passing
Two bugs in the IR codegen's function call path:

1. \`IrOp::Call\` was emitting \`JSR __fn_name\` but functions are
   defined at \`__ir_fn_name\`. The JSR went to an unresolved label
   and the generated ROM would jump to garbage.

2. Caller stored arguments to ZP \$04-\$07 but the callee's
   \`LoadVar\` for each parameter VarId resolved to a temp slot at
   \$80+ because parameter VarIds weren't in \`var_addrs\`. The
   callee never read the arguments.

Fixes:

- Register each function's first four parameter VarIds at
  \$04-\$07 in \`var_addrs\` during codegen construction, so
  \`LoadVar\` for a parameter emits \`LDA \$04\` / \`\$05\` / etc.
- Call emits \`JSR __ir_fn_{name}\` matching the function label.

Adds a unit test that exercises both sides of the contract:
the caller must STA to \$04/\$05 and JSR to \`__ir_fn_sum\`, and the
callee must LDA from \$04 to read the first parameter.

https://claude.ai/code/session_01W6eQFStA66EuMKHUFo2rx3
2026-04-12 16:33:06 +00:00
Claude
08322abda4
Codegen: on_scanline per-state dispatch + NMI reload
Extends the \`on_scanline\` codegen to support multiple scanline
handlers across states:

- \`__irq_user\` now dispatches by \`current_state\`: each state with a
  scanline handler gets a CMP/BNE/JSR entry in the dispatch table.
  States without a handler fall through to just acknowledge the IRQ.
- New \`__ir_mmc3_reload\` helper that (re)loads the MMC3 counter
  latch based on \`current_state\`. States without a scanline handler
  fall through to disable the IRQ (\$E000 write).
- Linker detects the \`__ir_mmc3_reload\` label in user code and
  splices a JSR into it at the top of the NMI handler, so the
  counter is reloaded once per frame with the current state's
  target scanline.
- IRQ handler no longer re-enables IRQ on ACK (the NMI reload now
  handles that) so it won't fire multiple times per frame.
- Program init chooses the start state's scanline count (if any) or
  the first scanline handler found as a fallback.

Also fixes \`dump_asm\`: a \`NOP\` with a \`Label\` operand is a label
definition, but any other opcode with a \`Label\` operand is a real
instruction like \`JSR foo\`. The old dump was hiding JSR/JMP targets.

https://claude.ai/code/session_01W6eQFStA66EuMKHUFo2rx3
2026-04-12 16:29:15 +00:00
Claude
359241d906
Codegen: MMC3 on_scanline IRQ dispatch (minimal)
Wires \`on scanline(N)\` handlers through IR lowering and codegen:

- IR lowering: each scanline handler becomes a regular IR function
  named \`{state}_scanline_{N}\`
- IR codegen: when any scanline handler exists, emits MMC3 IRQ setup
  at program start (\$C000 latch, \$C001 reload, \$E001 enable, CLI)
  and a \`__irq_user\` handler that saves registers, acknowledges via
  \$E000, JSRs the scanline handler, restores registers, and RTIs
- Linker: vector table prefers \`__irq_user\` over the default \`__irq\`
  stub when both exist

Scope of this first pass is intentionally minimal: supports ONE
scanline handler per program (the first one found in IR function
order). Per-state dispatch and multi-scanline reload will come later.

https://claude.ai/code/session_01W6eQFStA66EuMKHUFo2rx3
2026-04-12 16:22:54 +00:00
Claude
225d40cea5
Language: struct types
Adds composite \`struct\` types with field access:

    struct Vec2 { x: u8, y: u8 }

    var pos: Vec2
    pos.x = 100
    pos.y = pos.x + 5

- Lexer: \`struct\` keyword
- AST: \`StructDecl\` with \`StructField\` list; \`NesType::Struct(name)\`
  for struct-typed variable declarations; \`Expr::FieldAccess\` and
  \`LValue::Field\` for reads/writes
- Parser: top-level \`struct Name { field: type, ... }\` (optional
  trailing comma) and \`ident.field\` syntax in both expression and
  lvalue position
- Analyzer: \`register_struct\` computes contiguous field offsets
  (no padding) and stores them in \`struct_layouts\`. Struct variables
  synthesize a \`VarAllocation\` per field under the name
  \`"struct_var.field"\`, and \`Expr::FieldAccess\` / \`LValue::Field\`
  resolve against those. Unknown struct types and unknown fields
  emit E0201.
- IR lowering + AST codegen: treat struct field access as ordinary
  variable access against the synthetic per-field symbols. No new IR
  ops are needed.

v1 structs only support primitive fields (u8/i8/bool). Nested structs,
u16 fields, and array fields are not yet allowed.

https://claude.ai/code/session_01W6eQFStA66EuMKHUFo2rx3
2026-04-12 16:18:05 +00:00
Claude
9c102cb68e
Codegen: copy propagation + dead LDA elimination in peephole
Adds two new passes that dramatically close the IR-vs-AST output
size gap:

- **copy_propagate_temps**: tracks the source of each \`STA\` into an
  IR temp slot (\$80-\$FF). Subsequent \`LDA\` of that slot is rewritten
  to load from the original source directly. The same rewrite applies
  to arithmetic/logical ops that read a temp (\`ADC\`/\`SBC\`/\`AND\`/
  \`ORA\`/\`EOR\`/\`CMP\`). The now-unused \`STA\` is picked up by the
  dead-store pass on the next iteration.
- **remove_dead_loads**: drops an \`LDA\` whose value is immediately
  overwritten by another A-clobbering load (\`LDA\`/\`PLA\`/\`TXA\`/\`TYA\`).

The existing dead-store pass is also extended to scan through labels
and branches within a function body, since IR temp slots are
SSA-unique per function. Function boundaries (\`__ir_fn_\`/\`__fn_\` labels,
\`RTS\`/\`RTI\`) still stop the scan.

Instruction count comparison (IR vs AST codegen on examples/):

                             before            after
    arrays_and_functions.ne  359 vs 236   ->   264 vs 236  (-95 ir)
    bouncing_ball.ne         192 vs 144   ->   135 vs 144  (-57 ir, IR now wins)
    coin_cavern.ne           412 vs 291   ->   290 vs 291  (-122 ir, IR ties)
    hello_sprite.ne          116 vs 64    ->    73 vs 64
    mmc1_banked.ne           378 vs 247   ->   261 vs 247
    sprites_and_palettes.ne  194 vs 113   ->   121 vs 113
    state_machine.ne         323 vs 229   ->   233 vs 229

IR is now competitive with the AST codegen on every example and
beats it on bouncing_ball and coin_cavern.

https://claude.ai/code/session_01W6eQFStA66EuMKHUFo2rx3
2026-04-12 16:06:26 +00:00
Claude
e1bddbc0db
Codegen: A-value tracking peephole for redundant LDA elimination
Tracks what the accumulator currently holds across a linear run of
instructions. When we see \`LDA addr\` or \`LDA #val\` and A is
already known to hold that value, the load is dropped. A-modifying
ops (ADC, SBC, shifts, transfers from X/Y, etc.) clear the tracker.
Any control-flow instruction or label resets it too, so JSR / branches
remain safe.

Reduces STA/LDA count in bouncing_ball from 113 to 108.

https://claude.ai/code/session_01W6eQFStA66EuMKHUFo2rx3
2026-04-12 11:42:04 +00:00
Claude
dbfcb313bc
Language: enum declarations
Adds \`enum Name { V1, V2, ... }\` as a top-level declaration. Each
variant is registered as a \`u8\` constant equal to its index in the
declaration. Variant names are global and must be unique across all
enums and other symbols (E0501 on collision).

- Lexer: \`enum\` keyword
- AST: \`EnumDecl { name, variants, span }\` field on \`Program\`
- Parser: top-level \`enum\` syntax with optional trailing commas
- Analyzer: \`register_enum\` flattens variants into the symbol table
- IR lowering and AST codegen: variants resolve through the existing
  \`const_values\` path
- Tests cover parsing, duplicate detection, and usage

https://claude.ai/code/session_01W6eQFStA66EuMKHUFo2rx3
2026-04-12 11:36:44 +00:00
Claude
ae051f5909
CLI: --dump-ir flag + IrProgram::pretty
Adds \`IrProgram::pretty()\` for a readable IR dump and wires it to a
new \`--dump-ir\` build flag. Useful for debugging IR-level issues
(optimizer, lowering). Refactored \`compile()\` to take a
\`CompileOptions\` struct to keep clippy's \`fn-params-excessive-bools\`
lint happy.

https://claude.ai/code/session_01W6eQFStA66EuMKHUFo2rx3
2026-04-12 11:31:01 +00:00
Claude
8b35ba9e13
Codegen: peephole dead-store elimination for IR temps
Adds a per-instruction scan: for each \`STA slot\` where slot is an
IR temp (\$80-\$FF), walks forward through the instruction stream.
If the slot is overwritten with no intervening read, the original
STA is dead and is removed. Conservatively bails at any control-flow
boundary (jump, branch, call, return, label definition) since a
jumper or callee might read the slot.

Complements the existing peephole passes.

https://claude.ai/code/session_01W6eQFStA66EuMKHUFo2rx3
2026-04-12 11:27:16 +00:00
Claude
3f87aa1e30
Analyzer: W0103 also fires on unused state-local variables
Previously the unused-variable warning only ran on globals. Now it
walks \`state.locals\` too, so state-scoped variables that are
declared and never read also emit W0103. Factored out to
\`check_unused_var\` to share the \`_\`-prefix exemption and help text.

https://claude.ai/code/session_01W6eQFStA66EuMKHUFo2rx3
2026-04-12 11:21:53 +00:00
Claude
b7c806523d
Codegen: peephole pass for redundant STA/LDA pairs
Adds src/codegen/peephole.rs with two passes that run to fixed point
after codegen:
- \`STA slot; LDA slot\` over an IR temp (\$80-\$FF): the LDA is
  redundant since A already holds the value. Dropped.
- \`LDA addr; STA addr\` (same address): the STA is a no-op since
  the byte was just loaded from that slot. Dropped.

Conservative on user variables (not IR temps) so intervening IRQs or
function calls can't invalidate the optimization. This is the biggest
single win for IR codegen output quality, since IR codegen currently
stores every temp to ZP regardless of reuse.

https://claude.ai/code/session_01W6eQFStA66EuMKHUFo2rx3
2026-04-12 11:19:56 +00:00
Claude
121b0b1968
Inline assembly: asm { ... } blocks
- Lexer: after \`asm\` keyword, next \`{\` triggers raw-text capture of
  the body until the matching \`}\`, emitted as a new \`AsmBody\` token
- Parser: \`asm { ... }\` produces \`Statement::InlineAsm(body, span)\`
- Analyzer: treats inline asm as opaque (no checks)
- IR: new \`IrOp::InlineAsm(String)\` variant that passes the body
  through the optimizer unchanged
- \`src/asm/inline_parser.rs\`: minimal 6502 mnemonic parser supporting
  every addressing mode we emit elsewhere (immediate, ZP/ABS with X/Y,
  indirect, indirect-X/Y, labels, branches, implied, accumulator)
- Both IR and AST codegen splice parsed instructions inline
- Integration test covers a mix of implied + immediate + ZP + A modes

https://claude.ai/code/session_01W6eQFStA66EuMKHUFo2rx3
2026-04-12 11:16:18 +00:00
Claude
f8743cf95e
Analyzer: W0104 warn on dead code after terminator
When a block contains a statement after \`return\`, \`break\`,
\`continue\`, or \`transition\`, the trailing statement is unreachable
and the analyzer now emits W0104 pointing at it. The warning includes
a secondary label on the terminator showing where execution stops.

Adds \`Statement::span\` helper to the AST so diagnostics can point at
arbitrary statements cleanly.

https://claude.ai/code/session_01W6eQFStA66EuMKHUFo2rx3
2026-04-12 11:08:14 +00:00
Claude
e688397594
Analyzer: E0301 error on RAM exhaustion
The linear RAM allocator now checks for overflow. The zero-page region
is capped at \$80 (leaving \$80-\$FF for IR codegen temp slots), and the
main RAM allocator stops at \$0800 (end of NES internal RAM). Overflow
emits E0301 with a helpful note pointing at the declaration.

https://claude.ai/code/session_01W6eQFStA66EuMKHUFo2rx3
2026-04-12 11:05:13 +00:00
Claude
1e4e384501
Analyzer: W0101 warn on expensive multiply/divide
Emits W0101 on \`a * b\`, \`a / b\`, \`a % b\` expressions where both
operands are non-constant (neither is an integer literal). These lower
to calls into the software multiply/divide routines and can blow the
6502 cycle budget. Multiplying by a constant is still silent because
the optimizer can strength-reduce power-of-2 cases to shifts and small
factors to add chains.

https://claude.ai/code/session_01W6eQFStA66EuMKHUFo2rx3
2026-04-12 11:01:03 +00:00
Claude
4e05c008ce
Parser: E0505 error on multiple 'start' declarations
Previously a second \`start X\` statement silently overrode the first.
Now emits E0505 at parse time.

https://claude.ai/code/session_01W6eQFStA66EuMKHUFo2rx3
2026-04-12 10:59:06 +00:00
Claude
814cbe54bd
Parser/analyzer: on scanline(N) handlers
Adds parser support for \`on scanline(N) { ... }\` event handlers
inside state bodies. The analyzer enforces that scanline handlers
are only allowed with the MMC3 mapper (the only mapper we target
with scanline IRQ support), and validates the body like any other
state handler.

Codegen for the scanline IRQ vector is still TODO — the handler
is parsed and validated but not yet hooked up to the MMC3 IRQ
counter. When we get to it, the linker needs to install an IRQ
handler that writes to \$C000/\$C001/\$E000/\$E001.

https://claude.ai/code/session_01W6eQFStA66EuMKHUFo2rx3
2026-04-12 10:57:34 +00:00
Claude
21a74652f2
Analyzer: W0102 warn on infinite loop without yield
Emits W0102 when a \`loop\` body contains no \`break\`, \`return\`,
\`transition\`, or \`wait_frame\`, since such loops spin forever and
prevent vblank from being handled. The check recurses into nested
if/while bodies so that \`break\` behind a condition still counts.

https://claude.ai/code/session_01W6eQFStA66EuMKHUFo2rx3
2026-04-12 10:54:30 +00:00
Claude
6f0e4a0a74
Analyzer: validate return statement types
Tracks the current function's declared return type and checks that
`return value` statements match it. Returning from a void function
with a value produces E0203; returning a value with the wrong type
produces E0201. State handler `return value` is still silently
accepted (the value is discarded).

https://claude.ai/code/session_01W6eQFStA66EuMKHUFo2rx3
2026-04-12 10:52:06 +00:00