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

39 commits

Author SHA1 Message Date
Claude
73dcf08c7a
analyzer+ir: automatically overlay state-local variables
Before this change, state-local variables (`state Foo { var x: u8 = 0 }`)
were silently no-ops: the analyzer allocated a ZP slot for them, but
the codegen's `var_addrs` map only covered IR globals and scope-qualified
function locals — so every `LoadVar` / `StoreVar` whose `VarId` pointed
at a state-local resolved to no address and emitted nothing. Existing
examples compiled and matched their goldens because none of them observed
the dropped writes within the 180-frame harness window.

The overlay changes the analyzer's state-local pass to snapshot both the
ZP and RAM cursors after the globals have been laid out, then rewind to
that snapshot before each state's locals and track the running max.
`ZP_CURRENT_STATE` keeps exactly one state active at runtime, so every
state's locals are mutually exclusive with every other state's and can
share the same bytes. The IR lowerer now pushes each state's locals into
the IR globals table (with `init_value=None`) so the codegen resolves
their addresses the same way it does program globals, and prepends the
declared initializers to each state's `on_enter` handler (synthesizing
an empty one where needed) so a freshly-entered state re-establishes its
bytes before user code runs.

`--memory-map` now tags each allocation with its owning state
(`[@Title]`, `[@Playing]`, ...) and counts distinct bytes rather than
summed allocation sizes so overlaid slots don't double-count. The
`AnalysisResult.state_local_owners` map exposes the ownership to any
tool that wants to group allocations the same way.

Only `state_machine.ne` and `platformer.ne` declare state-level vars,
so they're the only example ROMs whose bytes change. `platformer.ne`'s
audio golden shifts slightly (the now-working `blink` counter in Title
adds a few cycles per frame before the auto-transition to Playing, which
offsets APU register writes within each frame); its video golden and
every other example ROM stay byte-for-byte identical.

Fixes #22.

https://claude.ai/code/session_015kvJu3iEFLSRJoShPBfm3X
2026-04-17 02:20:07 +00:00
Claude
e4751df143
linker: add ca65-compatible --dbg output for source-level debugging
Emit a `.dbg` debug-info file in the same format `ld65` produces, so
Mesen / Mesen2 / fceuX pick it up automatically and enable source-line
stepping, labelled variable inspection, and symbol-based breakpoints
without manual address lookups. Closes #23.

The new `render_dbg` helper stitches together metadata the compiler
already surfaces (linker label table, IR codegen `__src_<N>` markers,
analyzer variable allocations) into the file/mod/seg/scope/span/line/sym
records documented at https://cc65.github.io/doc/debugfile.html. Each
source-loc marker becomes a span that stretches to the next marker
(so breakpoints cover every byte the statement compiled into) plus a
line record pointing into it; `seg.ooffs` tracks the fixed bank's
PRG-relative start so banked MMC1/UxROM/MMC3 ROMs map cleanly too.

Reuses the `.mlb` symbol-name filter so internal skip/block labels
stay out of the debugger's symbol browser. `--dbg` implies the same
`__src_` marker emission as `--source-map` but leaves release builds
byte-identical when neither flag is passed.

https://claude.ai/code/session_01DfN3pKJLryr7vvNFBpcqmC
2026-04-16 22:39:08 +00:00
Claude
6561daff35
linker: gate default smiley CHR tile on __default_sprite_used marker
Drop the built-in smiley from CHR tile 0 unless something in the
program actually references it. The marker fires when either:

  1. `IrOp::DrawSprite` lowering falls back to tile 0 because the
     sprite name doesn't resolve to a user declaration, or
  2. The same lowering sees a runtime `frame:` override (which
     could index any tile, including 0).

A third source of dependency — a background nametable entry of 0 —
is detected in the linker by scanning `bg.tiles` for zeros. This
preserves the smiley for programs like `examples/friendly_assets`
that use tile 0 as a background placeholder, even though their
draws resolve to user-declared sprites.

Programs whose draws all resolve to explicitly-declared sprites
with static frames AND whose backgrounds reference tiles 1+ now
leave CHR tile 0 as an all-zero blank, freeing 16 CHR bytes that
the user can treat as an always-transparent background tile.
Verified against the current example set: `sprites_and_palettes`
and `auto_chr_background` reclaim tile 0; every other example
keeps it (either they fall back to tile 0 via an undeclared draw
name or their background tilemap references tile 0).

All 33 emulator goldens still pass — removing an unreferenced CHR
tile can't change observable output.

https://claude.ai/code/session_016kM6P7PukktBDqTZexrrAN
2026-04-16 21:15:09 +00: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
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
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
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
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
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
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
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
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
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
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
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
d4daa6d0a9
docs: enum type + new CLI flags in the language guide
Documents the \`enum Name { Variant, ... }\` syntax and adds
\`--dump-ir\` and \`--use-ast\` to the CLI flag table. Also adds
an integration test covering enum-variant-as-condition and variant
assignment through the full compile pipeline.

https://claude.ai/code/session_01W6eQFStA66EuMKHUFo2rx3
2026-04-12 11:39:12 +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
5660a8b7c3
IR codegen: state dispatch, multi-OAM, and transition support
State machine dispatch:
- IrProgram now stores states (Vec<String>) and start_state
- Lowering captures state metadata before walking the AST
- IR codegen generates a main loop with vblank wait and CMP+BNE+JMP
  dispatch table, matching the AST codegen's layout
- Each frame handler ends with JMP __ir_main_loop
- current_state initialized to the start state's index at boot

Multi-OAM support:
- next_oam_slot counter, reset at the start of each _frame function
- Sequential allocation of 4-byte OAM entries at $0200 + slot*4
- Silently drops draws beyond slot 63 (OAM full)

Transition codegen:
- IrOp::Transition now looks up the target state's index from
  state_indices, writes it to ZP $03, and JMPs back to main loop
- Previously this was a no-op placeholder

Shared constants:
- ZP_FRAME_FLAG ($00) and ZP_CURRENT_STATE ($03) match AST codegen

Tests: 271 total (5 new IR codegen tests + 2 new integration tests)
All 7 examples compile through --use-ir, including multi-state games
and programs with transitions and functions.

https://claude.ai/code/session_01W6eQFStA66EuMKHUFo2rx3
2026-04-12 10:33:58 +00:00
Claude
1ede169f1e
Implement IR-based code generator (--use-ir)
New src/codegen/ir_codegen.rs walks IrProgram and emits 6502 instructions.
This enables optimizer passes to actually affect the output ROM.

Design:
- Each IR temp gets a zero-page slot at $80 + temp_index
- Functions reset the temp counter at entry (temps don't outlive functions)
- Globals map by name to their analyzer-assigned zero-page addresses
- Operands are loaded into A, computed, stored back to the dest slot

Handles all IrOp variants:
- LoadImm, LoadVar, StoreVar (basic loads/stores)
- Add/Sub (CLC+ADC / SEC+SBC)
- Mul (JSR __multiply runtime routine)
- And/Or/Xor (zero-page operands)
- ShiftLeft/ShiftRight (repeated ASL/LSR)
- Negate/Complement (EOR #$FF + optional two's complement)
- CmpEq/Ne/Lt/Gt/LtEq/GtEq (CMP + conditional branch + 0/1)
- ArrayLoad/ArrayStore (TAX + ZeroPageX/AbsoluteX)
- Call (ZP param passing + JSR)
- DrawSprite (OAM slot 0 write, uses sprite_tiles map)
- ReadInput (LDA $01, P1 input)
- WaitFrame (poll frame flag at $00)

All terminators:
- Jump (JMP to block label)
- Branch (LDA temp + BNE true / JMP false)
- Return (optional value in A + RTS)
- Unreachable (BRK)

IR lowering fixes:
- ReadInput now has a destination IrTemp (was a side-effect-only op)
- ButtonRead uses the proper input temp instead of uninitialized register
- Logical AND/OR use new emit_move helper (OR with zero) instead of
  bogus raw VarId for path merging

CLI:
- New --use-ir flag on `build` subcommand to opt in to IR codegen
- Default remains AST codegen (for now); IR codegen is experimental

All 7 examples compile through the IR pipeline and produce valid iNES ROMs.

Tests: 266 total (7 new ir_codegen unit + 2 new integration).

https://claude.ai/code/session_01W6eQFStA66EuMKHUFo2rx3
2026-04-12 10:23:43 +00:00
Claude
6430c3a935
Sprite resolution, asset wiring, shift-assign, unreachable state warning
Sprite/asset pipeline:
- Linker::link_with_assets() places sprite CHR data in ROM at correct tile
- assets::resolve_sprites() walks Program for inline sprite bytes
- CodeGen::with_sprites() maps sprite names to tile indices
- gen_draw() uses correct tile index from sprite declarations
- main.rs wires the full resolution pipeline

Shift-assign operators (<<= and >>=):
- AssignOp::ShiftLeftAssign and ShiftRightAssign variants
- Parser handles in both statement and array index contexts
- Codegen emits ASL A / LSR A
- IR lowering maps to ShiftLeft/ShiftRight ops

Unreachable state warning (W0104):
- BFS from start state finds reachable states via transitions
- States not reached produce W0104 warning

Error polish helpers:
- suggest_var_name() for "did you mean" suggestions
- emit_undefined_var() for E0502 with typo hints
- Used by analyzer for better diagnostics

242 tests pass, clippy clean.

https://claude.ai/code/session_01W6eQFStA66EuMKHUFo2rx3
2026-04-12 10:01:44 +00:00
Claude
40632f192c
Implement codegen for state dispatch, functions, arrays, math, scroll
State machine dispatch:
- Assign each state a numeric index, store in ZP $03
- Main loop dispatch table: CMP + BNE + JMP trampoline pattern
  (avoids branch range limits for large programs)
- on_enter/on_exit handlers generated as JSR targets
- Transition statement writes state index + JSR enter/exit handlers

Function calls:
- Function bodies emitted as labeled subroutines with RTS
- Statement::Call generates parameter passing via ZP + JSR
- Statement::Return generates RTS (with value in A if present)
- Parameter slots at ZP $04-$07

Break/continue:
- Loop stack tracks continue/break label pairs
- Break generates JMP to break_label
- Continue generates JMP to continue_label
- While and Loop push/pop the stack

Array indexing:
- LValue::ArrayIndex generates TAX + STA absolute,X
- Expr::ArrayIndex generates TAX + LDA absolute,X / ZP,X
- Compound array assignments (+=, -=, &=, |=, ^=) load-modify-store

Scroll:
- scroll(x, y) writes to PPU $2005 twice (X then Y)

Math:
- Multiply generates JSR __multiply (shift-and-add routine)
- Divide generates JSR __divide (restoring division)
- Modulo loads remainder from $03 after divide
- ShiftLeft generates ASL A, ShiftRight generates LSR A
- Math routines wired into linker

Error validations:
- E0203 for assignment to const variables
- Break/continue outside loop detection (in_loop tracking)

233 tests (8 new codegen + 2 analyzer + 2 integration), all passing.

https://claude.ai/code/session_01W6eQFStA66EuMKHUFo2rx3
2026-04-12 02:04:49 +00:00
Claude
5434dda114
M4+M5: Optimizer passes, type casting, bank switching, math runtime
Milestone 4 — Optimization & Polish:
- Strength reduction: multiply by power-of-2 → shift left
- Zero-page promotion analysis: rank variables by access frequency
- `as` type casting expression in parser/AST/analyzer
- `scroll(x, y)` statement
- `--asm-dump` flag for viewing generated assembly
- Extended optimizer tests (strength reduction, frequency analysis)

Milestone 5 — Bank Switching & Release:
- Mapper support: MMC1 (1), UxROM (2), MMC3 (4) in parser and ROM builder
- Bank declarations: `bank Name: prg` / `bank Name: chr`
- Linker::with_mapper for mapper-aware ROM generation
- Software multiply (8x8→16, shift-and-add algorithm)
- Software divide (8÷8→8, restoring division algorithm)
- ROM tests for mapper encoding round-trip
- Integration test for MMC1 compilation

210 tests total (18 new), all pre-commit checks pass.

https://claude.ai/code/session_01W6eQFStA66EuMKHUFo2rx3
2026-04-12 00:22:11 +00:00
Claude
058f7a87b6
M3: Asset pipeline, sprite/palette/background declarations, debug symbols
Parser extensions:
- sprite declarations with chr: @chr("file.png"), @binary("file.bin"), or inline [hex]
- palette declarations with colors: [0x0F, 0x00, 0x10, 0x20]
- background declarations with chr: asset source
- @chr/@binary asset source parsing
- load_background and set_palette statements
- --debug CLI flag (plumbed through, not yet wired to codegen)

Asset pipeline (new module):
- PNG → CHR tile conversion using image crate (8x8 tiles, 2-bitplane encoding)
- NES color palette table (all 64 standard NES colors as RGB)
- Nearest-color matching (Euclidean distance in RGB space)

Debug module (new):
- Source map (ROM address → source Span mapping)
- Debug symbols with variable address table
- Mesen-compatible .mlb label export
- .sym symbol table export

192 tests total (13 new: 5 parser + 3 asset + 5 debug)

https://claude.ai/code/session_01W6eQFStA66EuMKHUFo2rx3
2026-04-12 00:09:47 +00:00
Claude
0dc06f7f1a
M2: Wire IR pipeline, add Coin Cavern example and integration tests
Pipeline:
- main.rs now runs IR lowering and optimization before codegen
- IR is built and optimized but output still uses AST-based codegen
  (IR-based codegen is a future improvement)

Coin Cavern example (examples/coin_cavern.ne):
- 3-state game: Title → Playing → GameOver
- Functions (clamp_x), constants, gravity physics, coin collection
- Demonstrates most M2 language features

Integration tests (14 total, 7 new):
- program_with_functions: functions with params and return values
- program_with_while_loop: while loops compile correctly
- program_with_fast_slow_vars: placement hints accepted
- program_with_multi_state_transitions: 3-state cycle
- coin_cavern_compiles: full Coin Cavern example
- ir_pipeline_produces_ir: validates IR lowering + optimizer
- error_test_recursion_detected: E0402 for recursive functions

https://claude.ai/code/session_01W6eQFStA66EuMKHUFo2rx3
2026-04-11 23:34:35 +00:00
Claude
39ca246151
Implement NEScript compiler Milestone 1 ("Hello Sprite")
Complete implementation of the NEScript compiler pipeline for M1:
- Lexer: full tokenization with hex/binary/decimal literals, all keywords, operators
- Parser: recursive descent with Pratt expression parsing (M1 subset)
- Analyzer: symbol resolution, type checking, memory allocation
- 6502 Assembler: full opcode encoding table (~150 valid combinations)
- Code Generator: AST → 6502 instructions (direct, no IR for M1)
- Runtime: NES hardware init, NMI handler, controller read, OAM DMA
- Linker: NROM layout, vector table, palette loading, CHR data
- ROM Builder: iNES header generation, PRG/CHR padding
- CLI: `build` and `check` subcommands via clap

143 tests across all modules:
- 22 lexer tests (literals, keywords, operators, error recovery)
- 18 parser tests (expressions, statements, game structure, errors)
- 7 analyzer tests (symbol resolution, memory allocation, transitions)
- 30 assembler tests (every addressing mode, label resolution)
- 7 codegen tests (var init, arithmetic, buttons, draw, comparisons)
- 11 runtime tests (init sequence, NMI handler, controller read)
- 10 ROM builder tests (iNES format, mirroring, banking, validation)
- 5 linker tests (vector table, CHR data, palette loading)
- 7 integration tests (end-to-end compilation, error detection)

CI: GitHub Actions for check, fmt, clippy, test
Pre-commit: script for local fmt + clippy + test validation

https://claude.ai/code/session_01W6eQFStA66EuMKHUFo2rx3
2026-04-11 22:07:56 +00:00