1
0
Fork 0
mirror of https://github.com/imjasonh/nescript synced 2026-07-10 01:37:45 +00:00
Commit graph

22 commits

Author SHA1 Message Date
Claude
82b3d0d20a
metatiles + collision: metatileset, room, paint_room, collides_at
Closes §H. 2×2 metatiles and a parallel collision map are now a
first-class construct. `metatileset Name { metatiles: [{ id, tiles,
collide }, ...] }` declares a library of 2×2 tile bundles. `room Name
{ metatileset: M, layout: [...] }` lays them out on a 16×15 grid. The
compiler expands each room at compile time into:

- a 960-byte nametable (`__room_tiles_<name>`)
- a 64-byte attribute table (`__room_attrs_<name>`)
- a 240-byte collision bitmap (`__room_col_<name>`)

`paint_room Name` reuses the vblank-safe `load_background` update
machinery for the nametable blit and installs the collision bitmap
pointer into `ZP_ROOM_COL_LO`/`ZP_ROOM_COL_HI` (ZP $18/$19).
`collides_at(x, y)` JSRs into a small runtime helper that reads
`(room_col),Y` with `Y = (y & 0xF0) | (x >> 4)` and returns 0/1.
The helper links in only when the `__collides_at_used` marker is
emitted, so programs that declare a room but never query it pay
zero bytes for the subroutine.

`parse_byte_array` grows a `[value; count]` shortcut — 240-entry
`layout` arrays are unwieldy to spell out a byte at a time.

See `examples/metatiles_demo.ne` for the end-to-end flow: a probe
sprite bounces off walls via `collides_at` and lands on the left
side of the playfield at frame 180 — direct evidence that the
collision query works.

Also defers the register-allocator work from §"Code quality /
tooling" and documents the audio-goldens constraint in future-work
so the next agent sees it.
2026-04-19 01:28:17 +00:00
Claude
9719dc4111
ir/codegen: signed comparison lowering for i8/i16
Closes the §A follow-up gap: ordering compares (`<`, `<=`, `>`, `>=`)
on signed integer types now use the canonical 6502 `CMP / SBC / BVC /
EOR #$80` overflow-correction idiom so the N flag reflects the true
sign of the difference, instead of the previous BCC/BCS-based path
that always treated `$FFxx` as greater than `$00yy`.

The same change also fixes narrow-to-wide widening: assigning a
runtime `i8` expression to an `i16` variable now sign-extends the
high byte via a new `IrOp::SignExtend` op instead of zero-extending
it, so `var w: i16 = some_i8_neg` round-trips negative values.

The lowerer tracks signedness on each IR temp (analogous to the
existing `wide_hi` map) and threads it onto the new `Signedness`
field of `CmpLt`/`CmpGt`/`CmpLtEq`/`CmpGtEq` and their 16-bit
variants. The optimizer's constant-folder uses the same flag to
fold compares correctly under either signedness. Casts to `u8`/`u16`
strip the signed flag so an explicit `as` opt-out stays unsigned.

`examples/signed_compare.ne` exercises both bit widths through the
emulator harness — the four pip sprites at the top of the screen
show three lit (signed-correct) and one dark (would only light if
the compare regressed to unsigned semantics).
2026-04-19 00:17:34 +00:00
Claude
807c9c7318
compiler: VRAM update buffer (nt_set / nt_attr / nt_fill_h)
Closes the highest-priority remaining catalogue item (§G). User
code queues PPU writes during `on frame` via three new intrinsics;
the NMI drains the 256-byte ring at `$0400-$04FF` to `$2007`
during vblank. Programs that never touch the buffer pay zero
bytes and zero cycles for the feature — verified by the existing
46 ROMs all matching their goldens with no drift.

Also fixes the failing CI Format check from 7b4570e by running
cargo fmt across the working tree.

**Runtime:**
- New `runtime::gen_vram_buf_drain` emits the drain routine
  (`__vram_buf_drain`). Walks entries `[len][addr_hi][addr_lo]
  [byte_0]...[byte_(len-1)]` and stops at `len == 0`. Uses
  `LDA $0400,X` indexed-absolute so no ZP scratch is needed.
  Drain costs ~12 setup cycles + 8 cycles per data byte; the
  256-byte buffer can hold ~50 single-tile writes that drain
  in roughly 1000 cycles, well inside the ~2273-cycle vblank.
- `NmiOptions` gains `has_vram_buf`. The NMI JSRs the drain
  after the existing palette/background handshake (compiler-
  queued PPU writes win priority for vblank cycles).

**IR + codegen:**
- Three new ops `IrOp::NtSet`, `IrOp::NtAttr`, `IrOp::NtFillH`.
- The codegen helpers compute the PPU address inline:
  `$2000 + y*32 + x` for nametable, `$23C0 + (y/4)*8 + (x/4)`
  for attribute. Each append lays down a fresh `0` sentinel so
  the NMI sees a well-formed buffer regardless of whether more
  entries get appended later in the frame.
- `__vram_buf_used` marker drops on first use; gates the
  runtime splice + NMI JSR.

**Analyzer:**
- AST-walking helper `program_uses_vram_buf` detects intrinsic
  use at analyze-init time so the user-RAM bump pointer can
  start at `$0500` (past the buffer) rather than the legacy
  `$0300`. Programs that don't use the buffer keep the legacy
  start.
- Three intrinsic names registered in `is_intrinsic` /
  `is_void_intrinsic` with arity checks.

**Tests + example:**
- `examples/vram_buffer_demo.ne` exercises all three intrinsics
  on a backgrounded program — three single-tile score writes,
  a 16-tile horizontal fill, and an attribute write that flips
  the top-left metatile group's palette to red. Committed
  golden + audio hash.
- Four new integration tests: byte-level JSR-to-drain
  assertion, drain-omitted-when-unused, RAM-bump assertion for
  programs that DO use the buffer, and arity enforcement for
  `nt_set`.

**CI fix:**
- `cargo fmt` ran across the tree. Picks up a one-line fmt
  diff in `tests/integration_test.rs` that the prior commit
  shipped without running fmt, causing the Format CI job to
  fail on `7b4570e`.

All 758 tests pass. Clippy clean. 47/47 emulator goldens match.
2026-04-18 21:14:31 +00:00
Claude
e0b268eea9
compiler: GNROM / debug port / sprite flicker / fade / sprite-0 split + docs
Another batch from the cc65/nesdoug gap catalogue. All six items
gated on marker labels (or default-false attributes) so existing
programs produce byte-identical ROMs — every pre-existing .nes
file round-trips unchanged.

**Language / runtime additions:**

- `mapper: GNROM` (iNES 66). Combines AxROM's 32 KB PRG pages with
  CNROM's 8 KB CHR banks in a single `$8000` register. Linker
  pads single-page ROMs to 32 KB to match mapper-66 expectations.
- `game { debug_port: fceux | mesen | 0xXXXX }`. `debug.log`,
  `debug.assert`, and the `__debug_halt` sentinel now target a
  user-selected address. `fceux` (default, $4800) and `mesen`
  ($4018) are named aliases; custom hex addresses are accepted
  for unusual debuggers.
- `game { sprite_flicker: true }`. IR lowerer injects an
  `IrOp::CycleSprites` at the top of every `on frame` handler,
  which flips on the rotating-OAM NMI variant with no per-site
  boilerplate. Default false so existing ROMs keep their layout.
- `fade_out(step_frames)` / `fade_in(step_frames)` builtins.
  Blocking helpers that walk brightness 4 → 0 or 0 → 4 with
  `step_frames` frames between each step. Runtime splices
  `__fade_out`, `__fade_in`, and a callable `__wait_frame_rt`
  helper when the builtin is used. Zero-guard on step_frames
  prevents a pathological 256-frame spin when the caller
  accidentally passes 0.
- `sprite_0_split(scroll_x, scroll_y)` intrinsic. Emits a
  two-phase busy-wait on `$2002` bit 6 (wait-for-clear,
  wait-for-set) then writes the new scroll values to `$2005`.
  Works on any mapper — unlike `on_scanline(N)` which requires
  MMC3. Enables HUD-over-playfield scrolling on NROM/UxROM/MMC1.

**Docs:**

- New paragraph in the language guide explaining the no-recursion
  design choice and the explicit-stack workaround pattern.
- `future-work.md` updated to mark the shipped items out of the
  catalogue; remaining items reshuffled in the priority ranking.
- README + examples/README updated with the new mapper and
  builtins.

**Tests:**

- 12 new integration tests covering: GNROM header emission,
  debug-port targeting (fceux/mesen/custom), unknown-alias
  rejection, sprite_flicker on/off/bad-value, fade_out JSR + marker
  coupling, fade omitted-when-unused, fade-in-expression rejected,
  sprite_0_split byte-level busy-wait verification, sprite_0_split
  arity enforcement, sprite_0_split omitted-when-unused, and an
  extended void-intrinsic-in-expression-position test covering the
  three new void builtins.
- `nes2_mapper_high_nibble_in_byte_8_is_zero_for_small_mappers`
  extended to include GNROM.
- Four new examples with committed .nes ROMs + pixel/audio
  goldens: `gnrom_simple`, `auto_sprite_flicker`, `fade_demo`,
  `sprite_0_split_demo`.

All 752 tests pass. Clippy clean. 44/44 emulator goldens match.
2026-04-18 19:31:55 +00:00
Claude
f4968256f4
review: tighten PRNG / void-intrinsic / FCEUX path handling
Follow-up cleanup on the cc65 parity batch. Addresses issues found
during a post-commit code review.

**Correctness fixes:**

- `rand8()` / `rand16()` at statement position (result discarded)
  were being eliminated by DCE because `op_dest` returned
  `Some(dest)` for Rand8/Rand16 even though the ops have a visible
  side effect — advancing the PRNG state. Now `op_dest` returns
  `None` for both, keeping the JSR regardless of liveness. New
  regression test `rand8_statement_survives_dce`.
- Void-only intrinsics (`poke`, `seed_rand`, `set_palette_brightness`)
  used in expression position (e.g. `var x = seed_rand(42)`) were
  panicking the linker with an unresolved `__ir_fn_X` label. The
  analyzer now emits E0203 with a clear message; new
  `void_intrinsic_in_expression_position_errors` test covers all
  three names.
- Statement-position `rand8()` / `rand16()` weren't lowered at all
  (they fell through to the default Call path). Now both lower to
  their IR op with a fresh temp that nothing reads; the JSR still
  runs so the PRNG state advances.
- `--fceux-labels foo.nes` was producing `foo.0.nl` because
  `PathBuf::with_extension` replaces instead of appends. Rewritten
  to literally append `.<bank>.nl` / `.ram.nl` to the OsString, so
  users get the FCEUX-expected `foo.nes.<bank>.nl` naming.
- Linker now asserts CNROM / AxROM don't accept user-declared
  switchable PRG banks — their page sizes don't fit the 16 KB per
  bank model, and silently producing a mis-sized ROM is worse than
  a loud panic.

**PRNG cleanup:**

- Removed the stream-of-consciousness comment block in `gen_prng`
  that described three abandoned algorithms before landing on the
  actual Galois LFSR.
- Simplified `__rand16` to a single JSR + LDX instead of two
  JSRs + TAY/TYA round-trip — a single shift already produces 16
  fresh bits, the doubled call just burned ~40 cycles. The golden
  PNG for `prng_demo` was regenerated to reflect the new sequence.
- Rewrote the `gen_prng` doc comment to accurately describe the
  algorithm as a Galois LFSR (it was mislabelled as xorshift).
- Rewrote the `gen_palette_brightness` doc comment with a proper
  table of level→mask mappings — the prior prose description
  didn't match the actual table values.

**Tests:**

- Three new unit tests in `linker::debug_symbols` covering the
  FCEUX `.nl` renderer: user-facing labels only, empty output when
  no user labels exist, and deterministic sorting in `.ram.nl`.
- Extended `nes2_mapper_high_nibble_in_byte_8_is_zero_for_small_mappers`
  to cover AxROM + CNROM.
- Renumbered priority list in future-work.md after removing the
  shipped sections (J, K, N, parts of V and Y).

All 737 tests + 40/40 emulator goldens still green.
2026-04-18 18:48:55 +00:00
Claude
7507459787
compiler: PRNG / edge input / palette fade / AxROM / CNROM / FCEUX labels
Closes seven of the cc65/nesdoug parity gaps catalogued in
docs/future-work.md in a single pass. All of the new features are
gated on marker labels so programs that don't use them produce
byte-identical ROM output (every pre-existing committed .nes file
round-trips unchanged).

Language / runtime additions:
- `rand8()` / `rand16()` / `seed_rand(u16)` intrinsics backed by a
  16-bit Galois LFSR (~30 bytes of runtime, ~40 cycles per draw).
  Reset path seeds state to 0xACE1 so the first draw is useful even
  without explicit seeding.
- `p1.button.a.pressed` / `.released` edge-triggered input via a
  new ReadInputEdge IR op plus an NMI-side prev-frame snapshot into
  $07E6/$07E7, gated on the `__edge_input_used` marker.
- `set_palette_brightness(level)` builtin mapping levels 0..8 to
  PPU mask emphasis bytes (`$2001`) for neslib-style screen fades.
- `mapper: AxROM` (iNES 7) with automatic 32 KB PRG padding so
  emulators that enforce mapper-7's 32 KB page size boot cleanly.
- `mapper: CNROM` (iNES 3) with a reset-time CHR bank 0 select.
- `--fceux-labels <prefix>` CLI flag emitting per-bank `.nl` label
  files and a `.ram.nl` file for FCEUX's debugger.

Tests + examples:
- Five new example programs with committed .nes ROMs and
  pixel+audio goldens: prng_demo, edge_input_demo,
  palette_brightness_demo, axrom_simple, cnrom_simple.
- Seven integration tests covering JSR emission, the
  omitted-when-unused invariant, the NMI prev-input snapshot, the
  correct mapper numbers for AxROM/CNROM, and negative tests for
  unknown button names and bad rand8 arity.
- `is_intrinsic()` now runs in expression-position Call paths too,
  so `var x = rand8(1, 2)` errors at compile time instead of
  silently dropping the extra arguments.
2026-04-18 18:13:18 +00:00
Claude
f7012c6533
codegen: make var_addrs misses panic loudly and fix latent struct-field silent drop
Phase 1 of the post-PR-#31 audit. The PR #31 state-local bug had a
specific shape: analyzer allocated a slot, codegen looked it up by
VarId, silently emitted nothing on miss. Six sites in gen_op plus
the global-initializer loop and the parameter-shuffle prologue all
used the same `if let Some(&addr) = self.var_addrs.get(var) { ... }`
pattern with no else branch. Any future allocation-map desync would
slip through the same crack.

Replace every site with a new `IrCodeGen::var_addr(VarId) -> u16`
helper that panics with an explicit "compiler bug" message on miss.
An IR op referencing an unmapped VarId is not valid input — it means
the analyzer and lowerer disagreed on what to allocate, and we want
that crash to surface in CI rather than be absorbed by whatever
zero-filled RAM happened to sit at the read.

Running cargo test against the hardened lookup surfaced exactly the
bug shape the plan predicted: uninitialized struct globals (e.g.
`var p: Point` with no literal initializer) never had their flattened
field VarIds (`"p.x"`, `"p.y"`) registered in var_addrs. The IR
lowerer's `get_or_create_var("p.x")` minted a VarId, the analyzer's
`flatten_struct_fields` allocated an address for it, but IrCodeGen::new
only populated var_addrs from `ir.globals`, which doesn't contain
synthesized field entries for uninitialized structs. Every `p.x = N`
silently compiled to nothing.

Fix by exposing the IR lowerer's name→VarId map on IrProgram and
joining it with the analyzer's allocations in IrCodeGen::new. Every
allocated name that the lowerer knows about now gets a var_addrs
entry. Example ROMs are byte-identical (no example relied on the
dropped writes), but the bug was reachable — any user program with
a plain `var pos: Point` declaration and field writes would have hit
it silently.

Add `uninitialized_struct_field_store_emits_sta_to_allocated_address`
as a byte-level regression guard: compile `p.x = 123` and scan PRG
for `LDA #\$7B / STA <addr>`. Fails against the old silently-dropping
codegen.

https://claude.ai/code/session_01AoQ678uVeqpyayvWHpfDhC
2026-04-17 23:49:29 +00:00
Claude
f128170abf
compiler: satisfy four new clippy 1.95 lints so CI stays green
Rust stable rolled to 1.95.0 today (2026-04-16), which fires four
new warning-by-default lints on pre-existing code. All four have
mechanical fixes suggested by clippy itself:

  - `collapsible_match` (2x) in `src/analyzer/mod.rs` — merge the
    `if args.len() != N` guard into the `match` arm pattern. One
    diagnostic-push per arm, shape is identical to before.
  - `unnecessary_sort_by` in `src/optimizer/mod.rs` — replace
    `sort_by(|a, b| b.1.cmp(&a.1))` with
    `sort_by_key(|(_, c)| std::cmp::Reverse(*c))`.
  - `manual_checked_ops` in `src/optimizer/mod.rs::Div` folding —
    replace `if vb == 0 { 0 } else { va / vb }` with
    `va.checked_div(vb).unwrap_or(0)`. Same `x / 0 == 0` fallback.
  - `map_unwrap_or` in `src/main.rs` — `std::fs::metadata(...).
    map(|m| m.len()).unwrap_or(0)` → `.map_or(0, |m| m.len())`.

Verified with both the old 1.94.1 stable and the freshly-installed
1.95.0: `cargo clippy --all-targets -- -D warnings` is clean on
both; `cargo fmt --check` is clean; `cargo test --all-targets`
still passes 616 + 3 + 75; the emulator harness still matches
all 34 goldens byte-for-byte.

This unblocks the SHA-256 PR (imjasonh/nescript#28) and any
other PRs that run CI against Rust 1.95.

https://claude.ai/code/session_01FRmSBruVWCufm3LsUVMs8v
2026-04-16 15:15:24 +00:00
Claude
5e5bed39a5
sprite-per-scanline: add cycle_sprites runtime flicker + debug telemetry
W0109 (shipped last commit) catches the 8-sprites-per-scanline
hardware limit at compile time for static layouts, but the
dynamic case — enemy formations, projectile clusters, animated
NPCs where coordinates come from variables — was still silent.
This change adds two layers of defense on top of W0109:

Layer 2: `cycle_sprites` runtime flicker intrinsic
  New keyword statement that rotates the OAM DMA start offset
  one slot per call. When called once per `on frame`, the PPU's
  sprite evaluation picks up a different subset of the 12+
  overlapping sprites each frame, so the permanent-dropout
  failure mode becomes visible flicker — the classic NES
  technique used by Gradius, Battletoads, and every shmup.

  Implementation:
    - Lexer keyword `KwCycleSprites` and parser production.
    - AST `Statement::CycleSprites(Span)`.
    - `IrOp::CycleSprites` lowered by the IR pass.
    - Codegen emits `LDA $07EF / CLC / ADC #4 / STA $07EF` with
      natural u8 wrap, plus a one-shot `__sprite_cycle_used`
      marker label the first time it fires.
    - Linker detects the marker and switches `gen_nmi` to the
      cycling variant, which reads the rotating offset from
      `$07EF` into OAM_ADDR before the DMA instead of writing
      a literal 0. Programs that don't call `cycle_sprites`
      skip the marker and get byte-identical ROM output.

Layer 3: debug-mode sprite overflow telemetry
  Mirrors the frame-overrun pair (`debug.frame_overrun_count` /
  `debug.frame_overran`). In debug builds the NMI handler reads
  `$2002` at the top of vblank, masks bit 5 (the PPU's sprite
  overflow flag), and if set bumps a cumulative counter at
  `$07FD` plus a sticky bit at `$07FC`. The sticky bit clears
  on every `wait_frame`.

  New debug builtins:
    - `debug.sprite_overflow_count()` → u8 peek of $07FD
    - `debug.sprite_overflow()` → u8 peek of $07FC (sticky bit)

  The hardware flag has well-known quirks but is correct for
  the overwhelming majority of cases and costs ~15 cycles per
  frame to sample. Release builds emit no overflow-check code
  at all, so the four bytes at `$07EF` / `$07FC`-`$07FD` stay
  free for user allocation.

Related changes:
  - `gen_nmi` now takes an `NmiOptions` struct. Four bool
    parameters tripped clippy's `fn_params_excessive_bools`.
  - CLI `build` now renders analyzer warnings on a successful
    build. Previously warnings were silently dropped unless
    the user also ran `nescript check`, which made W0109
    effectively invisible to CI and local dev alike. Existing
    pre-existing W0103 / W0106 warnings on `coin_cavern`,
    `mmc3_per_state_split`, `sprites_and_palettes` surface
    too — not regressions, just now visible.

New example: `examples/sprite_flicker_demo.ne`
  Draws 12 sprites into a 4-pixel band, W0109 fires at compile
  time with nine labels pointing at the offenders, and a
  `cycle_sprites` call at the end of `on frame` turns the
  hardware dropout into flicker. The committed emulator golden
  captures one frame of the cycling pattern (deterministic).

Tests:
  - `runtime::tests::nmi_debug_mode_samples_sprite_overflow`
  - `runtime::tests::nmi_sprite_cycle_variant_reads_rotating_offset`
  - `ir_codegen::*::debug_sprite_overflow_count_loads_07fd`
  - `ir_codegen::*::debug_sprite_overflow_flag_loads_07fc`
  - `ir_codegen::*::wait_frame_clears_sprite_overflow_sticky_in_debug_mode`
  - `ir_codegen::*::wait_frame_release_does_not_touch_sprite_overflow_sticky`
  - `ir_codegen::*::cycle_sprites_emits_marker_and_add4`
  - `ir_codegen::*::cycle_sprites_marker_dedup_across_multiple_calls`
  - `ir_codegen::*::program_without_cycle_sprites_emits_no_marker`
  - `analyzer::*::accepts_debug_sprite_overflow_builtins`
  - `analyzer::*::rejects_unknown_debug_method_lists_all_four_known_names`
  - `analyzer::*::accepts_cycle_sprites_statement`

Docs: `examples/war/COMPILER_BUGS.md` §4 now describes all three
layers (W0109, `cycle_sprites`, debug telemetry) with reasoning
for when each applies. `README.md` and `examples/README.md` add
the new example to their tables.

All 32 emulator goldens still match — the cycling is opt-in
and programs that don't call `cycle_sprites` or enable debug
mode are byte-identical to the pre-change output.

https://claude.ai/code/session_0143dTgh3UeRrtfHgQwzcv5z
2026-04-15 22:07:19 +00:00
Claude
2fe943b056
codegen: user code in switchable banks via cross-bank trampolines
Adds a `bank Foo { fun bar() { ... } }` parser form so user functions
can opt into living in a switchable PRG bank instead of the fixed
bank, plus the IR codegen, runtime, and linker work to make calls
across the bank boundary actually run. Programs that don't use the
new syntax produce byte-identical ROMs to before — verified by
rebuilding every existing example and diffing.

Pipeline shape:

* Parser accepts both `bank Foo: prg` (legacy reserved slot) and
  `bank Foo { fun ... }` (functions land in the named bank). Nested
  functions get tagged `bank: Some("Foo")` on the FunDecl + IrFunction.
* Analyzer bumps the user zero-page start past `$10` whenever the
  program declares any banked function, so `__bank_select`'s STA into
  ZP_BANK_CURRENT can't clobber a user variable. Programs without
  banked functions keep the legacy `$10` start.
* IrCodeGen emits each banked function into its own per-bank
  instruction stream (`banked_streams: HashMap<String, Vec<Instruction>>`)
  while the fixed-bank stream gets the dispatcher loop + state
  handlers + top-level functions, exactly like before. Cross-bank
  calls from the fixed bank rewrite `JSR __ir_fn_<name>` to
  `JSR __tramp_<name>`; in-bank calls stay direct. Banked → fixed
  calls are direct (the fixed bank is always mapped at $C000-$FFFF).
  Banked → other-banked calls aren't supported in this pass and
  panic loudly during codegen.
* Runtime's `gen_bank_trampoline` takes the trampoline label and
  entry label as parameters now (one trampoline per banked function,
  not one per bank) so the linker can request any number of stubs.
* Linker assembles banked banks twice: a discovery pass to learn
  each bank's labels, then a final pass that seeds the merged label
  table so banked code can JSR into the fixed bank's runtime helpers
  (math, audio, etc.). The fixed-bank assembler is also seeded with
  the cross-bank labels so the trampolines' `JSR __ir_fn_<name>`
  resolves into the bank's $8000 window. New `asm::assemble_with_labels`
  / `asm::assemble_discover_labels` helpers wire this up.
* PrgBank carries `Vec<Instruction>` + a list of `BankTrampoline`
  requests now, replacing the old `data: Vec<u8>` + single
  `entry_label: Option<String>` shape. The compiler populates both
  from the codegen output; the linker's two-pass assembly handles
  the rest.

New example: `examples/uxrom_user_banked.ne` puts a sprite-stepping
helper inside `bank Extras { fun step_animation() { ... } }`. The
fixed-bank state handler calls it via the generated trampoline, and
the harness golden locks in pixel + audio output at frame 180.

UxROM is the only mapper exercised by the new example. MMC1 and
MMC3 also work through the same path (the linker emits the right
mapper-specific bank-select code), but no example uses them yet —
the existing `mmc1_banked.ne` / `mmc3_per_state_split.ne` keep
their fixed-bank-only layout.

Limitations carried forward:
* No banked → banked cross-bank calls (panics in codegen).
* No greedy size-packing; placement is explicit-only.
* MMC3 state handlers don't get banked (the per-state split path
  is untouched).
2026-04-14 11:41:20 +00:00
Claude
629fdcfce0
fix(optimizer): preserve cross-block LoadImm uses in const_fold DCE
`const_fold_block`'s per-block dead-code pass was collecting temp
usage from only the block it was folding, so a `LoadImm` whose
destination is consumed by a *sibling* block (for example via the
merge block's branch terminator) was incorrectly treated as dead
and dropped. The `and` / `or` short-circuit lowering emits exactly
that shape: the false path writes `LoadImm(result, 0)` and joins
with the right path at an `and_end` / `or_end` block whose branch
terminator reads `result`. After the DCE the false path's store
was gone, leaving the zero-page result slot to carry whatever value
the *previous* `and` / `or` evaluation had written there — stale
data that bled into subsequent conditional branches.

I found this while instrumenting `examples/platformer.ne` through a
puppeteer-driven jsnes harness, stepping one frame at a time and
snapshotting the full zero-page trace of each scenario (title-skip,
hold-right, hold-left, jump-spam, coin-drift, enemy-stomp, long-run).
In a clean idle run the enemy-1 stomp bounce (`rise_count = 6`,
`fall_vy = 0`) fired at emulator frames 83 and 96 with `camera_x`
= 61 and 74, i.e. with `e1_sx` = 39 and 26, nowhere near the
intended `[72, 96)` pickup window. The trigger turned out to be
the slot alias: every time `c2_sx` landed in its pickup window
(so the coin-2 `and` stored 1 into ZP(130)) and the player was
mid-fall at or past `player_y = 152`, the enemy-1 stomp `and`
short-circuited to its false path, left ZP(130) at 1, and the
stomp `if` fired on stale data.

The fix is to compute function-wide source-operand usage once before
folding each function's blocks and OR it into the per-block liveness
check, so a LoadImm is only dropped if nobody — neither its own
block nor any other block in the function — reads the temp. Added a
regression test (`const_fold_preserves_loadimm_used_by_sibling_branch`)
that builds the exact CFG shape the `and` lowering emits and
verifies the false-path `LoadImm(result, 0)` survives optimization.

Impact on the example ROMs:

- `examples/platformer.nes`: enemy-1 stomp now fires only when
  `e1_sx ∈ [72, 96)`, as the source intends. The pixel golden is
  unchanged (`player_y` converges back to the ground line before
  frame 180), but the audio hash flips because the spurious
  `play hit` sfx calls during coin-2 passage are gone. Committing
  the new `tests/emulator/goldens/platformer.audio.hash`.

- `examples/logic_ops.nes`, `examples/bitwise_ops.nes`,
  `examples/match_demo.nes`, `examples/mmc3_per_state_split.nes`,
  `examples/two_player.nes`: byte-different but observably
  unchanged — their pixel + audio goldens still match to the byte.
  They exercise `and` / `or` in the source and now compile through
  the corrected DCE.

All other example ROMs are byte-identical to pre-fix. `cargo fmt`,
`cargo clippy --all-targets`, `cargo test --release` (498 tests),
and `tests/emulator/run_examples.mjs` (22/22 goldens) are clean.

https://claude.ai/code/session_013Bi4H4YQ5or5HtMB4doUFi
2026-04-13 16:29:44 +00:00
Claude
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
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
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
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
d609b77cd7
IR codegen: scroll, debug.log, debug.assert
Adds Scroll / DebugLog / DebugAssert variants to IrOp, wires them into
the IR lowering, optimizer liveness tracking, and IR codegen. Scroll
emits two PPU $2005 writes; debug statements emit writes to $4800 when
IrCodeGen::with_debug(true) is set, stripped otherwise. These were the
last feature gaps versus the AST codegen path, so IR codegen is now a
full replacement.

https://claude.ai/code/session_01W6eQFStA66EuMKHUFo2rx3
2026-04-12 10:43:53 +00:00
Claude
8a6441071e
IR codegen: Player 2 controller support
ReadInput now takes an explicit player index (0 = P1, 1 = P2). The IR
lowering for ButtonRead threads the player through, and the IR codegen
selects the correct zero-page input byte ($01 for P1, $08 for P2).

https://claude.ai/code/session_01W6eQFStA66EuMKHUFo2rx3
2026-04-12 10:40:42 +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
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
192d9c5c3d
M2: Add call graph analysis, recursion detection, and optimizer
Analyzer extensions:
- Call graph construction from function bodies and state handlers
- DFS-based recursion detection (direct and mutual) with E0402 errors
- Max call depth computation per entry point with E0401 enforcement
- Function declarations registered as symbols (E0503 for undefined calls)
- Collects calls from all statement/expression types recursively

Optimizer (new module):
- Constant folding: evaluate known-constant arithmetic at compile time
- Dead code elimination: remove ops with unused destination temps
- Both operate per-basic-block in a single pass

171 tests total (22 new: 6 analyzer + 11 IR lowering + 5 optimizer)

https://claude.ai/code/session_01W6eQFStA66EuMKHUFo2rx3
2026-04-11 23:32:12 +00:00