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

41 commits

Author SHA1 Message Date
Claude
548787ac8a
W0110 inline fallback warning + docs refresh
W0110: when a function marked `inline` has a body shape the IR
lowerer can't splice (conditional early return, loops, nested
control flow, empty void body), the analyzer now emits a
warning at the declaration site so the declined hint is
visible instead of silently falling back to a regular JSR.

Implementation:
  - New `W0110` error code in `src/errors/diagnostic.rs` (warning level).
  - New `pub fn can_inline_fun(return_type, body) -> bool` in
    `src/ir/lowering.rs`, extracted from the existing capture
    logic so the analyzer and the IR lowerer share the same
    eligibility rules and can never drift.
  - New `check_inline_declinability` analyzer pass called from
    the tail of `analyze_program`, mirroring the existing
    `check_sprite_scanline_budget` / `check_unreachable_states`
    passes. Emits W0110 with help + note text pointing at the
    two accepted body shapes.
  - `capture_inline_bodies` now defers to `can_inline_fun`
    instead of duplicating the match pattern, so the two sides
    stay in lockstep by construction.

Four regression tests in `src/analyzer/tests.rs` cover the
conditional-return and while-loop declines plus the two
accepted shapes (single-return expression, void sequence).

Example source cleanups: `wrap52` in `examples/war/deck.ne`
and `abs_diff` in both `examples/arrays_and_functions.ne` and
`examples/loop_break_continue.ne` drop the `inline` keyword.
All three were dead hints — the `inline` was being silently
declined before this change, so removing it is source-only;
the three ROMs are byte-identical, all 32 emulator goldens
still match.

Docs refresh
  - `docs/language-guide.md`: rewrote the Inline Functions section
    (real behaviour + W0110), added W0105/W0106/W0107/W0108/W0109/
    W0110 to the warnings table, added the `debug.sprite_overflow*`
    builtins + sprite-per-scanline mitigations section to the
    Debug Mode docs, added a `cycle_sprites` statement entry and
    cross-referenced it from `draw`.
  - `docs/nes-reference.md`: fleshed out the "NEScript Memory
    Usage" block with the full ZP + high-RAM layout, including
    the new `$07EF` / `$07FC` / `$07FD` slots for sprite cycling
    and the debug sprite-overflow telemetry.
  - `docs/future-work.md`: documented all four debug query
    builtins in the "What ships today" block; updated the open
    "OAM allocation strategy" question to reference the shipped
    `cycle_sprites` path and ask about an automatic-flicker
    game attribute as a follow-up.
  - `docs/architecture.md`: updated the `ir/` and `optimizer/`
    module summaries to describe real inline splicing (now
    in lowering, not the optimizer).
  - `README.md`: reframed the `inline` bullet from "hint" to
    "real splicing for single-return / void-body shapes";
    expanded the debug-support bullet to mention the four
    query builtins and their stripping in release builds; added
    a new bullet for the three-layer sprite-per-scanline
    mitigations; bumped the test count from 497 → 694; updated
    the war.ne entry to mention the seven compiler bugs are all
    fixed and point readers at `git log` (instead of the
    deleted COMPILER_BUGS.md).
  - `examples/README.md`: same `git log`-pointing rewrite for
    the war.ne entry.

Deletions
  - `examples/war/COMPILER_BUGS.md` is removed. All seven
    catalogued bugs are fixed; the file's historical value
    lives in `git log` now. Every source-code comment and doc
    reference to the file has been updated to either point at
    `git log` or just describe the bug in place.

Test count: 616 unit + 75 integration + 3 doctests = 694 total.
Clippy / fmt clean. 32/32 emulator goldens match.

https://claude.ai/code/session_0143dTgh3UeRrtfHgQwzcv5z
2026-04-15 23:19:07 +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
d6cb84a5bd
compiler: close out bug #4 (W0109 sprite-per-scanline) and bug #5 (real inlining)
Fixes the last two deferred compiler bugs catalogued in
examples/war/COMPILER_BUGS.md, finishing the bug-cleanup arc on
the War branch.

Bug #5 — `inline fun` inliner
  Previously the `inline` keyword was parsed into `FunDecl.is_inline`
  and then dropped on the floor: every call site emitted a regular
  `JSR` through the $04-$07 transport slots. Now the IR lowerer
  captures inline function bodies up front in
  `LoweringContext::capture_inline_bodies` and rewrites call sites
  at lowering time. Two body shapes are supported:

    1. Single-return expression — the body is re-lowered in place
       of the `Call` op with the parameter names substituted to
       fresh IR temps for each argument.
    2. Void multi-statement body whose every statement is one of
       Assign/Call/Draw/Scroll/SetPalette/LoadBackground/WaitFrame/
       Play/StartMusic/StopMusic/InlineAsm/RawAsm/DebugLog/DebugAssert
       — the statements are spliced into the caller's block with
       the same parameter substitution machinery.

  Control-flow-heavy inline bodies (conditional early returns,
  loops, transitions) fall back to a regular out-of-line call with
  no diagnostic. That's predictable and documented in the bug-tracking
  doc. Nested inline expansion uses a substitution-frame stack so
  an inline calling another inline sees the right arguments.

  A codegen follow-up was needed because bug #3's scope-qualified
  local names broke `{result}` substitution in inline asm. The
  codegen now tracks `current_fn_scope_prefix` per function and the
  InlineAsm op tries the qualified name first before falling back
  to the bare name.

Bug #4 — W0109 sprite-per-scanline static check
  Adds a new warning code W0109 and an analyzer pass
  `check_sprite_scanline_budget` that walks each state's `on_frame`
  handler, collects literal-coordinate `draw` statements (including
  metasprite expansion via dx/dy offsets), and iterates scanlines
  0..240 to count how many 8x8 sprites overlap each line. When a
  scanline has > 8, the analyzer emits W0109 with labels pointing
  at each offending draw site plus a help message about staggering
  y-rows and a note explaining the hardware dropout. Non-literal
  coordinates are skipped (static analysis can't resolve them).
  Nested `if`/`while`/`for`/`loop` blocks are unioned conservatively.

Tests added
  src/ir/tests.rs
    - inline_fun_expression_body_emits_no_call_at_use_site
    - inline_fun_void_body_statements_are_spliced
    - inline_fun_with_conditional_return_compiles_as_regular_call
    - inline_fun_nested_inlines_substitute_correctly
  src/analyzer/tests.rs
    - analyze_sprite_scanline_budget_warns_over_eight
    - analyze_sprite_scanline_budget_ok_when_staggered
    - analyze_sprite_scanline_budget_skips_dynamic_coords
    - analyze_sprite_scanline_budget_expands_metasprites
    - analyze_sprite_scanline_budget_recurses_into_if

COMPILER_BUGS.md
  Bugs #4 and #5 marked **FIXED** in the status table, with full
  reproduction/root-cause/fix/regression-test write-ups updated in
  place. All seven catalogued bugs now have shipped fixes.

Artifact churn
  - examples/war.nes and examples/inline_asm_demo.nes rebuild
    byte-shifted (different JSR targets post-inliner).
  - tests/emulator/goldens/war.audio.hash shifts from 143660f to
    13443e28 — the inliner removes JSRs to set_phase, which nudges
    NMI sampling timing. No pixel diff; behavior is unchanged.

https://claude.ai/code/session_0143dTgh3UeRrtfHgQwzcv5z
2026-04-15 21:33:00 +00:00
Claude
76dd8eacb0
compiler: fix three scoping bugs; war: revert all local/param workarounds
Three related scoping bugs from examples/war/COMPILER_BUGS.md,
all fixed in one pass because they're different layer
manifestations of the same "flat global namespace" problem:

## §3: function-local `var` declarations lived in one namespace

`src/analyzer/mod.rs::register_var` inserted every `var` it
saw — top-level, state-local, AND function-body local — into
the same `self.symbols: HashMap<String, Symbol>`. Two different
functions declaring `var i` collided on E0501, which is why
every local in war/*.ne had a function-prefix like `dfa_card`
or `dwp_px`.

Fix: add a `current_scope_prefix: Option<String>` to the
Analyzer, set it to `Some("<fn_name>")` when checking a
function body (or `Some("Title__frame")` for state handler
bodies), and have `register_var` store the declaration under
an internal key `"__local__{prefix}__{name}"`. New
`resolve_symbol` / `resolve_key` helpers try the
scope-qualified key first and fall back to the bare key for
globals / consts / enum variants / state-level vars / function
names. Every existing `self.symbols.get(name)` inside
body-checking code was swapped over.

Two `var i` declarations inside the SAME function body still
collide with E0501 — we scoped per function body, not per
nested block. Per-block scoping would require live-range
analysis to reuse RAM slots.

## §1b: same-named params across functions shared VarIds

`src/ir/lowering.rs::get_or_create_var` looked up names in a
single global `var_map`, so two functions both with a `card:
u8` parameter resolved to the same `VarId`. Whichever function
was lowered last won the zero-page slot mapping, silently
rerouting the other function's param reads to the wrong slot.

Fix: the IR lowerer now mirrors the analyzer's scope logic.
`LoweringContext` gains a `current_scope_prefix` field that
gets set in `lower_function` / `lower_handler`, and
`get_or_create_var` uses a new `scoped_key` helper that
prepends `"__local__{prefix}__"` when the qualified key exists
in `var_map` or `var_types`. Each function's parameters and
locals therefore get distinct VarIds, and the codegen's
`var_addrs` map naturally has no collisions.

## §2: param transport slots $04-$07 clobbered across nested JSRs

Parameters were passed AND kept in `$04-$07` for the lifetime
of a function. Any nested call overwrote those slots with its
own arguments, so the caller's params were silently corrupted
as soon as it invoked anything. Every war helper that took
params and called other helpers (draw_card_face, push_back_a,
etc) snapshotted its params into fresh locals at the top of
the body.

Fix: in `codegen/ir_codegen.rs::IrCodeGen::new`, every
function-local — including parameters — now gets a dedicated
per-function RAM slot at `$0300+`. Parameters are still passed
via the zero-page transport slots `$04-$07` as the calling
convention, but `gen_function` now emits a **prologue** at
every function entry:

    LDA $04
    STA <param_0_addr>
    LDA $05
    STA <param_1_addr>
    ... etc, up to 4 ...

By the time the body runs, every parameter lives in the
function's dedicated RAM slot, so any nested call can freely
clobber $04-$07 (writing its own arguments there) without
corrupting the caller's saved parameters. Costs 4 LDA/STA
pairs (≈ 20 bytes of ROM, 16 cycles) at every function entry
— worth it to make the calling convention sound.

## War cleanup

With all three fixes in place, every workaround prefix in
`examples/war/*.ne` is gone:

- `card_rank(card)` instead of `card_rank(crk_c)` — bug #1b
- `compare_cards(a, b)` instead of `compare_cards(cmp_a, cmp_b)`
- `push_back_a(card)` instead of `push_back_a(pba_in)` — bug #1b
- `var card: u8 = draw_front_a()` in bury_from_* — bug #3
- `var i: u8 = 0` freely in multiple functions — bug #3
- `fun push_back_a(card)` body no longer snapshots `card` into
  `pba_card` before calling wrap52 — bug #2
- `fun draw_card_face` body no longer snapshots x/y/card into
  locals before calling card_rank/card_suit — bug #2
- `draw_word_player` steps its own x without needing a
  `dwp_px` accumulator to avoid the `x + N` arg compilation
  quirk — that quirk was a downstream symptom of bug #2 and
  is also gone

The source is now about 300 lines shorter and significantly
more readable.

## Regression tests

Seven new tests nail these bugs down:

- `analyzer::tests::analyze_allows_same_local_name_in_two_functions`
- `analyzer::tests::analyze_allows_same_param_name_in_two_functions`
- `analyzer::tests::analyze_allows_same_local_name_in_two_state_handlers`
- `analyzer::tests::analyze_still_rejects_duplicate_local_in_same_function`
- `codegen::ir_codegen::gen_function_prologue_spills_params_to_local_ram`

Plus the four param-arity tests from the earlier E0506 fix
and the wide_hi-leak regression test from the previous
compiler fix. Total suite: 591 unit tests, all passing.

## Golden drift

The prologue change adds a few cycles to every function entry,
which shifts NMI sampling by a handful of cycles and flips
the audio-hash of any example that plays sfx or music
(platformer, war). `arrays_and_functions.png` also picks up a
1-pixel shift in its enemy positions due to the same timing
drift. All three golden updates are pure "compiler produces
different but functionally-identical output" — no game
behavior changed.

## What's still open in COMPILER_BUGS.md

- §4: 8-sprites-per-scanline hardware limit is invisible to
  user code. A static analyzer hint could help; deferred.
- §5: `inline` keyword is silently declined for short
  functions that the optimizer's inliner doesn't recognize
  (it only removes empty functions). Deferred pending a real
  single-return-expression inlining pass.

https://claude.ai/code/session_0143dTgh3UeRrtfHgQwzcv5z
2026-04-15 20:33:41 +00:00
Claude
e10d09db76
examples/war: redesign card art — opaque bodies, 16x16 pips, checkerboard back
The first-pass card tiles were riddled with palette-0 transparent
pixels that let the felt background bleed through every rank
glyph, small suit, and big pip. At arm's length the cards looked
like they had green specks eating them. This commit rewrites all
of the card art from scratch:

- **Opaque card bodies.** Every pixel inside a card tile is now
  either white (palette 2), red (1), or black (3). No `.` values
  anywhere on a face or back tile. The green felt only shows
  outside the card rectangle, where it should.

- **Readable rank glyphs.** The 13 rank tiles (A, 2-9, 10, J, Q, K)
  are now drawn as bold black strokes on a solid white body. The
  "holes" of the letters (e.g. the triangle inside an "A") are
  white, not transparent.

- **16x16 big pips.** The big centre pip is now a 4-tile (16x16)
  shape split into TL/TR/BL/BR quadrants per suit. Previously it
  was a 2-tile (16x8) half-height strip that looked cramped. The
  TL/TR quadrants kept their existing tile indices (28-35) so the
  shift is local; the new BL/BR quadrants are appended after the
  BIG WAR letters at tiles 88-95 to avoid renumbering the entire
  alphabet / digits / UI tile range.

- **Distinct suit shapes.** Spade is a smooth teardrop with a
  short stem and base; heart is two symmetric lobes with a V
  bottom; diamond is a clean rhombus; club is three circles
  joined over a stem. Side-by-side they are unmistakable.

- **Clean checkerboard card back.** The old card back was a
  diamond lattice that had the same transparent-bleed problem
  and looked noisy anyway. Replaced with a crisp 2-pixel black-
  and-white checkerboard that tiles seamlessly across the card
  back's 16x24 footprint.

- **draw_card_face now emits 6 sprites in a rank/suit + 4-tile
  big-pip layout.** The previous 6-sprite layout was
  `[rank][ssuit] / [pipL][pipR] / [blankL][blankR]`; the new
  one is `[rank][ssuit] / [pipTL][pipTR] / [pipBL][pipBR]` with
  the bottom row carrying the bottom half of the big pip
  instead of being wasted blank tiles.

Also:
- New constants TILE_PIP_TL_BASE / TR / BL / BR replace the old
  TILE_PIP_L_BASE / TILE_PIP_R_BASE in constants.ne.
- Refreshed war.nes and the goldens. Every emulator harness
  test still passes (31/31).

https://claude.ai/code/session_0143dTgh3UeRrtfHgQwzcv5z
2026-04-15 18:31:15 +00:00
Claude
9137b1f713
examples/war: polish pass + README entry + plan close-out
End-of-implementation polish for the War example after the
compiler bugs were fixed:

- Title state now calls draw_big_war_banner instead of inlining
  12 draws — same pixel output, fewer lines.
- P_WAR_BURY redraws the previous round's face-up cards while
  the noise thumps fire so the table doesn't look empty for
  24 frames between the WAR banner and the new face-ups.
- Drop draw_word_war from render.ne (orphaned by the BIG WAR
  metasprite).
- Refresh comments in background.ne (now references the real
  felt tile) and deal_state.ne (drop the stale FRAMES_DEAL_STEP
  reference now that the deal pace is hard-coded at 2 frames).
- README.md and examples/README.md gain a war row.
- PLAN.md marks every implementation step complete and records
  the design revisions made along the way.
- Refresh the war audio hash to match the new ROM (the title
  screen helper change shifts one frame of pulse-2 timing
  enough to flip the FNV-1a). The frame-180 PNG is unchanged.

https://claude.ai/code/session_0143dTgh3UeRrtfHgQwzcv5z
2026-04-15 16:08:03 +00:00
Claude
4e8e349d7c
ir: clear wide_hi between functions to fix 16-bit op aliasing
The IrLowerer's wide_hi map records "this u8 temp's high byte
lives at this other temp" pairs whenever a 16-bit value is
produced. Both lower_function and lower_handler reset next_temp
to 0 at the start of each function, but neither cleared wide_hi
— so stale (low_id -> high_id) entries from earlier functions
leaked into subsequent ones.

When a fresh function reused those temp IDs for unrelated u8
expressions, is_wide() returned spurious true and widen() handed
back stale (lo, hi) pairs whose hi happened to coincide with the
*next* temp ID fresh_temp() was about to allocate. The result
was 16-bit IR ops (CmpEq16 in particular) where the destination
temp aliased one of the source operand high bytes — for War this
made `match phase` arms past P_WIN_B impossible to enter and the
game would freeze with both face-up cards on the table forever.

Fix: clear wide_hi alongside the next_temp reset in both
lower_function and lower_handler. Adds a regression test
(ir::tests::wide_hi_does_not_leak_between_functions) that
constructs a function whose body has no u16 ops but follows a
function that does, and asserts no CmpEq16 op aliases its dest
with an operand high byte.

Also:
- Convert the war Playing state's phase machine from an
  if-chain to a `match`, which is what tripped this bug to the
  surface (it was lurking in earlier ROMs too but their layouts
  never produced the dest/source collision shape).
- Refactor begin_draw_a/b to set fly_card / fly_face_up via
  globals before calling arm_fly, since arm_fly only takes 4
  params (the v0.1 ABI limit, now diagnosed by E0506).
- Hoist the P_RESOLVE comparison result to the global pf_result
  to dodge the param-clobbering issue documented in
  examples/war/COMPILER_BUGS.md §2.
- Document the bug as item #6 in COMPILER_BUGS.md with a
  minimal repro and reproducer-test pointer.
- Refresh the war golden + audio hash to match the new ROM.

https://claude.ai/code/session_0143dTgh3UeRrtfHgQwzcv5z
2026-04-15 15:57:26 +00:00
Claude
8ababdcec4
examples/war: working end-to-end War card game
A complete, playable port of the card game War: title screen with
0/1/2 player menu, animated deal, sliding cards, deck-count HUD, a
"WAR!" tie-break with buried cards, and a victory screen with a
fanfare. Source split across examples/war/*.ne (constants, assets,
audio, deck/queue logic, RNG, render helpers, and one state file
per game state) and pulled in via examples/war.ne.

Drives nearly every NEScript subsystem at once: custom 88-tile
sprite sheet (card frames, ranks, suits, font, BIG WAR letters);
felt background nametable; pulse-1 / pulse-2 / noise sfx; looping
march on pulse 2; an 8-bit Galois LFSR PRNG; queue-based decks
that conserve cards across rounds; a phase machine inside the
Playing state that handles draw/reveal/win/war/check; and an
autopilot that boots straight into 0-PLAYERS mode so the headless
jsnes harness captures real gameplay at frame 180.

While building this I uncovered five compiler bugs / limitations
in the v0.1 implementation; each is documented with a minimal
reproduction, root cause, current workaround, and proposed fix in
examples/war/COMPILER_BUGS.md. The most painful was the
parameter-VarId aliasing one (#1b) — two functions sharing a
parameter NAME end up sharing a single zero-page slot mapping
across the whole program. Once those compiler bugs are fixed, the
workarounds in war/*.ne should be reverted in the same PR.

https://claude.ai/code/session_0143dTgh3UeRrtfHgQwzcv5z
2026-04-15 15:22:20 +00:00
Claude
86db78a31f
examples/war: add implementation plan for the War card game
Living design doc for a production-grade War example: file layout
under examples/war/, sprite/tile/RAM budgets, card encoding, RNG +
shuffle strategy, state machine shape, audio design, and a 12-step
implementation checklist. The plan itself will be updated in-flight
as each step lands.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

https://claude.ai/code/session_01KEczoNUX3WmcFLfq6iAQxB
2026-04-15 02:37:19 +00:00
Claude
7294ae3efa
analyzer/lowering: support nested struct fields and array struct fields
Struct field types beyond the v1 scalar set (`u8`, `i8`, `u16`,
`bool`) used to error out with `E0201: struct fields must be
u8/i8/u16/bool`. The size accumulator already handled them
correctly — what was missing was: (1) the analyzer side that
synthesizes per-leaf symbols and allocations for nested structs
plus a single array-typed symbol for array fields, (2) the
parser's chained-field-access path, and (3) the IR-lowering
recursion through nested struct literal initializers and array
literal field values.

The synthetic-variable model carries through unchanged: a
`var p: Player` where `Player { pos: Vec2, hp: u8, inv: u8[4] }`
and `Vec2 { x: u8, y: u8 }` produces flat allocations for
`p.pos.x`, `p.pos.y`, `p.hp`, and `p.inv`, plus an intermediate
`p.pos` Struct symbol so dotted-name lookups still resolve. Array
fields get a single allocation with the array type so the
existing `Expr::ArrayIndex` lowering path handles `p.inv[i]`
without changes. Array-of-structs is still rejected with E0201
because the synthetic model can't index per-element layouts
without further codegen work.

The parser change is the only structural move: `parse_primary`
and `parse_assign_or_call` now loop the dot chain into a single
joined identifier so `p.pos.x` becomes `FieldAccess("p.pos", "x")`
and `p.inv[0]` becomes `ArrayIndex("p.inv", 0)`. The downstream
analyzer and IR lowering use the same `format!("{name}.{field}")`
join they already used for one-level access — no plumbing
changes required.

Includes a new `examples/nested_structs.ne` that exercises both
features end-to-end with two `Hero` instances carrying nested
positions and inventory arrays. The reproducibility tripwire
ROM is committed alongside it and the emulator harness has a
matching pair of golden files.

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

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

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

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

https://claude.ai/code/session_01MaNVcDmK9gsspRkdxowQAM
2026-04-14 12:38:49 +00:00
Claude
9b54ff83c0
audio: always enable all four tone channels on sfx trigger
Writing $07 in `emit_play_triangle` and $0B in `emit_play_noise`
meant that a noise play following an in-progress triangle note
would clear bit 2 of $4015 and cut the triangle off mid-envelope
(and vice versa). Write $0F from both paths so every trigger keeps
pulse1, pulse2, triangle, and noise enabled; channels with no
active envelope stay silent via the runtime's per-channel counter
gating. Also fixes the attribute-byte packing comment in
`png_to_nametable` — the code was correct, the doc string had the
quadrant order backwards.

The only observable ROM change is `examples/noise_triangle_sfx.nes`
(two immediate operands shift) and its audio hash golden; the
committed PNG golden is byte-identical. Found in independent code
review after the section landed.

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

Pipeline shape:

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

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

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

Limitations carried forward:
* No banked → banked cross-bank calls (panics in codegen).
* No greedy size-packing; placement is explicit-only.
* MMC3 state handlers don't get banked (the per-state split path
  is untouched).
2026-04-14 11:41:20 +00:00
Claude
201664ea04
audio: triangle and noise sfx channels
Adds `channel: triangle` / `channel: noise` to the `sfx` declaration
form. The existing pulse-1 / pulse-2 driver is unchanged (and is
still byte-identical for programs that don't use the new channels)
— when a program declares a triangle or noise sfx the runtime
splices in an additional per-channel slot that writes to $4008-
$400B (triangle) or $400C-$400F (noise) on play. Includes a new
`examples/noise_triangle_sfx.ne` demo with committed golden PNG +
audio hash.

https://claude.ai/code/session_01MaNVcDmK9gsspRkdxowQAM
2026-04-14 10:42:53 +00:00
Claude
169a481099
feat(platformer): add stomp-or-die enemy collisions, live HUD, GameOver state
The previous platformer example drew enemies but had almost no
interaction with them: only enemy 1 had a stomp check, the stomp
window was unreachable under the default +1-px-per-frame-plus-a-
jump-every-40-frames autopilot, contact from any other angle was
a silent no-op, and the header comment promised a "title → playing
→ game-over state machine" that didn't actually exist. The README
demo gif and the committed golden both froze that state — a level
the player could walk through indefinitely with no consequence.

Flesh the enemy interaction model out into something real:

- `resolve_enemy_hit(e_sx)`: one helper, called symmetrically for
  both enemies. Computes the player/enemy hitbox overlap (horizontal
  in `e_sx ∈ (72, 96)`, vertical in `player_y ∈ (152, 176)`) and
  branches three ways — falling onto the head is a stomp bounce
  (`rise_count = 6`, `fall_vy = 0`, `stomp_count += 1`, `play Boing`);
  overlap while `rise_count > 0` is a grace pass-through so the
  stomp bounce itself can't retrigger contact on the same enemy;
  anything else (walking into the side, standing on the ground
  against the enemy) is fatal — `alive = 0` and `play hit`.

- New `GameOver` state: draws four enemy tiles across the middle
  of the screen plus a coin row sized to `stomp_count`, stops the
  music, lingers 60 frames then auto-retries, and also honours
  Start for an instant retry.

- Proximity-based autopilot: pre-jump when an enemy is exactly 19 px
  ahead (`e1_sx == 99` or `e2_sx == 99`), capped at two jumps per
  life by `auto_jumps < AUTOPILOT_JUMPS`. Tuning: a JUMP_RISE=12,
  GRAVITY_CAP=4 jump lands the player's feet at enemy-head height
  exactly 21 frames after lift-off, by which point the autopilot
  camera has scrolled the enemy under the player. The first jump
  fires on Playing frame 1 and stomps enemy 1 on frame 22; the
  second fires on Playing frame 101 and stomps enemy 2 on frame
  122. After that the autopilot is exhausted and the third enemy
  encounter (camera wraps back past enemy 1) is fatal — the
  golden harness now sees the full stomp, stomp, die, retry, stomp
  loop instead of a frozen walk.

- Live HUD: up to four coin sprites in the top-left, one per
  stomp, rendered both during `Playing` and on the `GameOver`
  screen so the score is visible in the death frame. `Playing`'s
  player draw is now guarded by `if alive == 1` so the hero
  disappears on the fatal-contact frame and the enemy that killed
  them is visible underneath.

Verified with a per-frame ZP trace through the patched puppeteer
+ jsnes harness: first stomp at emu frame 44 (camera_x=22), second
at emu frame 144 (camera_x=122), death at emu frame 283 (camera_x=5
after a 256-px wrap), `Playing` restart at emu frame 343, third
stomp at emu frame 365. All 22 emulator goldens still match after
the update, and `docs/platformer.gif` regenerated from the new ROM
now shows two clean stomps, a clean side-collision death, the
GameOver screen, and the retry cycle all inside the 6-second demo
window.

Golden updates:
- `tests/emulator/goldens/platformer.png` — the frame-180 capture
  now shows the hero walking forward with a two-coin HUD after
  both autopilot stomps (previously: a frozen bouncing hero).
- `tests/emulator/goldens/platformer.audio.hash` — the track now
  includes two `Boing` stomp bounces, which shifts the hash.
- `examples/platformer.nes` — rebuilt from the rewritten source.

Also updates the platformer rows in `README.md` and
`examples/README.md` to match the new gameplay.

https://claude.ai/code/session_013Bi4H4YQ5or5HtMB4doUFi
2026-04-13 20:23:07 +00:00
54910f2498
Merge branch 'main' into claude/test-platformer-game-6S3TX 2026-04-13 15:55:50 -04:00
Claude
ad951a835f
examples: adopt the friendly asset syntax
Rewrites every example with non-trivial asset declarations to use
the pleasant QoL syntax introduced in the previous commit. Every
example still compiles to a byte-identical ROM (verified by a
temp-path diff before committing), so the committed `.nes` files
and the 23 emulator goldens are unchanged.

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

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

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

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

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

https://claude.ai/code/session_01PzaSFj3VahDzxEYTKCESkz
2026-04-13 18:04:21 +00:00
Claude
629fdcfce0
fix(optimizer): preserve cross-block LoadImm uses in const_fold DCE
`const_fold_block`'s per-block dead-code pass was collecting temp
usage from only the block it was folding, so a `LoadImm` whose
destination is consumed by a *sibling* block (for example via the
merge block's branch terminator) was incorrectly treated as dead
and dropped. The `and` / `or` short-circuit lowering emits exactly
that shape: the false path writes `LoadImm(result, 0)` and joins
with the right path at an `and_end` / `or_end` block whose branch
terminator reads `result`. After the DCE the false path's store
was gone, leaving the zero-page result slot to carry whatever value
the *previous* `and` / `or` evaluation had written there — stale
data that bled into subsequent conditional branches.

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

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

Impact on the example ROMs:

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

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

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

https://claude.ai/code/session_013Bi4H4YQ5or5HtMB4doUFi
2026-04-13 16:29:44 +00:00
Claude
48832ccb13
language: pleasant asset syntax for palettes, CHR, bg, sfx, music
Adds six NES-friendly authoring shortcuts so programs don't have to
hand-pack hex bytes for every kind of art asset. Every new syntax is
strictly additive — existing examples keep their byte-identical ROMs
and goldens.

  * palette: ~50 named NES colours (`black`, `sky_blue`, `dk_red`, …)
    usable anywhere a colour byte is expected, plus a grouped-form
    `bg0..sp3` + `universal:` shape that auto-fills every sub-
    palette's first byte (fixing the `$3F10` mirror trap).
  * sprite: `pixels:` ASCII-art alternative to 16-byte CHR, supporting
    multi-tile sprites split in row-major reading order.
  * sfx: scalar `pitch:` matching the v1 driver's latch-once behaviour,
    plus `envelope:` as a friendlier alias for `volume:`.
  * music: `tempo:` default duration + note-name notes (`C4, Eb4,
    rest 10`) alongside the existing `pitch, duration` pair form.
  * background: `legend { '.': 0, '#': 1 }` + `map:` string rows,
    plus `palette_map:` grids that auto-pack the 64-byte attribute
    table from 16×15 sub-palette digits.

A new `examples/friendly_assets.ne` exercises every shortcut at once
with a matching pixel + audio golden; the other 22 golden tests still
match byte-for-byte.

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

Benefits:

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

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

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

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

https://claude.ai/code/session_01BcCcHi6FUmTh8jC7UgkA3A
2026-04-13 15:12:39 +00:00
Claude
688d9afcec
platformer: end-to-end side-scroller demo + three runtime bug fixes
Adds `examples/platformer.ne`, a full side-scrolling game that
exercises nearly every subsystem of the compiler in one program:
custom CHR tileset, 32×30 background nametable with per-region
attribute palettes, 2×2 metasprite hero with gravity/jump physics,
wrap-around horizontal scrolling, moving enemies, coin pickups,
user-declared SFX + music, and a Title → Playing state machine
with autopilot so the headless jsnes harness captures real
gameplay at frame 180. Tile art + nametable are generated by
`scripts/gen_platformer_tiles.rs` (`cargo run --bin gen_platformer_tiles`).

Building this out uncovered three independent runtime bugs that
together made the example render as black-on-black smileys. All
three are fixed in this commit:

1. **`gen_init` enabled sprite rendering before the linker's
   initial palette/background load runs.** The PPU's v-register
   auto-increments on every `$2007` write *during active
   rendering*, so the palette load (32 B) and nametable load
   (1024 B) were scrambled past the first ~72 bytes — every
   existing program with a `background Level { ... }` block was
   silently rendering zero-filled VRAM. Fix: leave `PPU_MASK = 0`
   at the end of `gen_init` and emit a new `gen_enable_rendering`
   call *after* all initial VRAM writes complete.

2. **Audio tick corrupted `ZP_CURRENT_STATE`.** The audio
   driver's period-table lookup reused `$02/$03` as a temporary
   indirect pointer with a comment claiming the slots were free
   because the tick doesn't call mul/div. But `$03` is also
   `ZP_CURRENT_STATE` used by the state dispatch loop, so every
   music note silently overwrote the state index with the high
   byte of `__period_table` (`0xC5` in the platformer ROM),
   wedging the state machine forever. Fix: `gen_nmi` now PHAs
   `$02/$03` on entry and PLA-restores them on exit, and the
   audio tick JSR moves inside that save/restore window (it used
   to be spliced by the linker *before* the register saves, so
   even A/X/Y were technically being trashed pre-save). Only
   `audio_demo`'s audio hash shifts (its note timings move a few
   cycles); every other golden is unchanged.

3. **Sub-palette mirroring footgun.** Writing a 32-byte palette
   blob sequentially causes the sprite sub-palettes' "index 0"
   slots at `$3F10/$3F14/$3F18/$3F1C` to clobber the background
   universal colour at `$3F00/$3F04/$3F08/$3F0C` via NES hardware
   mirroring. The example's palette sets all eight first bytes
   to `$22` (sky blue) for this reason; `docs/future-work.md`
   picks up a TODO to warn on inconsistent first-byte values in
   the analyzer.

Also:

- `docs/platformer.gif` — 6-second recording of the example
  running in jsnes, generated by the new
  `tests/emulator/record_gif.mjs` puppeteer helper (encodes via
  `gifenc`, committed as a dev-dependency under
  `tests/emulator/package.json`).
- README / examples/README tables and the 497-test count are
  updated to cover the new example.

https://claude.ai/code/session_01BcCcHi6FUmTh8jC7UgkA3A
2026-04-13 13:04:26 +00:00
Claude
d98c7f3d82
palette/background: first-class declarations with reset-time load and runtime swaps
Re-adds `palette Name { colors: [...] }` and
`background Name { tiles: [...], attributes: [...] }` as first-class
declarations, plus `set_palette Name` and `load_background Name`
statements for runtime swaps. Unlike the previous iteration that
quietly no-op'd, this one is fully wired through the pipeline and
its behavior is pinned by both unit tests and an emulator golden.

Pipeline:

- Lexer: re-adds `palette`, `background`, `set_palette`,
  `load_background` keywords and tokenizes them.
- AST: `PaletteDecl` (name + 1..=32 colour bytes) and `BackgroundDecl`
  (name + 0..=960 tile bytes + 0..=64 attribute bytes) live in
  `Program`. `Statement::SetPalette` and `Statement::LoadBackground`
  name-reference these declarations.
- Parser: `palette Name { colors: [...] }` / `background Name
  { tiles: [...], attributes: [...] }` blocks and their statement
  forms parse via the existing byte-array helper.
- Analyzer: validates colour indices ($00-$3F), palette length
  (<=32), nametable length (<=960), attribute length (<=64), and
  duplicate decl names. `set_palette` / `load_background` targets
  must reference a declared name (E0502 otherwise). When a program
  declares palette or background, the analyzer bumps the user
  zero-page allocator's starting address from `$10` to `$18` to
  reserve `$11-$17` for the runtime update handshake — programs
  that don't use the feature keep the old layout so their emulator
  goldens stay byte-exact.
- Assets: `PaletteData` and `BackgroundData` resolve declarations
  into zero-padded fixed-size blobs (32 / 960 / 64 bytes) and
  expose `label()` / `tiles_label()` / `attrs_label()` for codegen
  to reference.
- IR: new `IrOp::SetPalette(String)` and
  `IrOp::LoadBackground(String)`; lowering forwards the names
  verbatim.
- Codegen: `gen_set_palette` writes the palette label pointer into
  ZP `$12/$13` and ORs bit 0 into the update flags at `$11`;
  `gen_load_background` does the same for tile/attribute pointers
  at `$14/$15/$16/$17` with bit 1. Both emit a `__ppu_update_used`
  marker so the linker splices in the NMI apply helper only when
  the feature is actually used.
- Runtime: `gen_initial_palette_load` and
  `gen_initial_background_load` write the first declared
  palette/background at reset time (before rendering is enabled,
  where PPU writes are safe). `gen_nmi(has_ppu_updates)` takes a
  new flag; when true it splices `gen_ppu_update_apply` at the top
  of the NMI body, which checks the `$11` flags byte and copies
  pending palette / nametable data to `$3F00` / `$2000` inside
  vblank. All helpers use only ZP $02/$03 as scratch at reset time
  and never clobber ZP slots live across NMI.
- Linker: new `link_banked_with_ppu` takes slice of `PaletteData` /
  `BackgroundData`; splices each blob as a labelled data block in
  PRG ROM, picks the first-declared as the reset-time load target,
  enables background rendering automatically when a background is
  declared, and threads `has_ppu_updates` into `gen_nmi`. Old
  `link_banked` remains as a thin wrapper for callers without
  palette/background data so existing tests don't shift.

Tests:

- Lexer: tokenization of the 4 new keywords (single added test case).
- Parser: 5 new tests for `palette` / `background` decls with and
  without attributes, plus `set_palette` / `load_background`
  statements.
- Analyzer: 9 new tests covering acceptance of declared
  palettes/backgrounds, E0502 for unknown names, E0201 for
  out-of-range NES colors and oversized blobs, E0501 for duplicate
  names, and the zero-page-layout guard (palette/bg decls bump ZP
  start; no decls keeps it at $10).
- Resolver: 3 new tests for zero-padding, truncation of oversized
  decls, and label derivation.
- IR: 2 new lowering tests for `set_palette` and `load_background`.
- Integration: 5 new tests — blob contents spliced verbatim into
  PRG, `STA $12` / `STA $14` emitted by set_palette /
  load_background codegen, and a regression guard that programs
  without palette/background still land user vars at $10.
- Emulator: new `examples/palette_and_background.ne` driven by a
  frame counter that toggles between `CoolBlues` / `WarmReds` and
  `TitleScreen` / `StageOne` every 90 frames. Golden PNG and audio
  hash checked in under `tests/emulator/goldens/` and verified via
  `node run_examples.mjs` — rendered image shows the blue
  `CoolBlues` palette with the nametable populated from
  `TitleScreen`.

Docs:

- `README.md` adds the feature to the headline list and the example
  table.
- `docs/language-guide.md` restores the palette/background sections
  with the full 32-byte layout table and `set_palette` /
  `load_background` statement references.
- `docs/future-work.md` replaces the "removed as dead code" entry
  with the remaining gaps (PNG-sourced palette and nametable
  assets, cross-vblank large background updates, memory-map
  reporting).
- `spec.md` restores the grammar productions and usage examples.
- `examples/README.md` lists the new demo.

All 497 unit + integration tests pass. Clippy clean. All 21
emulator goldens match after the update pass.

https://claude.ai/code/session_012fKB251HvEUQwG3tizFyqt
2026-04-13 11:11:33 +00:00
Claude
fdb1ec7c91
cleanup: fix silent miscompiles and delete dead code exposed by code review
Two correctness bugs were silently producing wrong ROMs:

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

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

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

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

Doc cleanup:

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

Tests:

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

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

https://claude.ai/code/session_012fKB251HvEUQwG3tizFyqt
2026-04-13 02:47:37 +00:00
Claude
d4c9898682
examples: add uxrom_banked and jsnes golden
Adds examples/uxrom_banked.ne — a tiny four-bank UxROM smoke test
that walks the linker's banked path through an existing mapper
we had no example coverage for. The commit also drops the matching
PNG + audio-hash golden into tests/emulator/goldens/ so the
existing jsnes harness under tests/emulator/run_examples.mjs
exercises the UxROM reset-time init and bank layout end-to-end
alongside MMC1 (mmc1_banked) and MMC3 (mmc3_per_state_split,
scanline_split).

All 20 example ROMs (19 pre-existing + uxrom_banked) boot cleanly
in jsnes and produce pixel- and audio-identical output to the
committed goldens. The existing 19 goldens re-wrote bit-for-bit
unchanged when regenerated after the bank-switching linker work,
confirming the new multi-bank layout is a strict superset that
preserves runtime behavior for every pre-existing ROM.

https://claude.ai/code/session_01UCressA5e8k1XsuoJYLav2
2026-04-13 02:04:25 +00:00
Claude
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
33537a32a9
tests/emulator: record audio goldens alongside screenshots
Adds an audio capture pipeline to the jsnes e2e harness that mirrors
the existing PNG screenshot path. Every ROM now produces both a
golden PNG (video) and a golden `<name>.audio.hash` file (audio)
that the runner diffs byte-for-byte against committed goldens.

Pipeline:
- `harness.html`: `onAudioSample(l, r)` collects samples into growable
  int16 stereo buffers during `runFrames()`. Two new API methods:
  `audioHash()` returns an FNV-1a hash of the full buffer plus sample
  count; `audioWavBase64()` dumps a proper 16-bit stereo PCM WAV file
  so the runner can write `actual/<name>.wav` on failure.

- `run_examples.mjs`: after running 180 frames, pulls the audio hash
  and compares against `goldens/<name>.audio.hash` (16-byte text file
  with `<hex> <sample-count>\n`). On diff, fetches the WAV bytes and
  writes `actual/<name>.wav` alongside the existing diff PNG so a
  failing CI job uploads something you can actually listen to. On
  `UPDATE_GOLDENS=1`, writes both goldens together.

- `audio_demo.ne`: added a 60-frame auto-play timer so the e2e
  harness exercises the audio driver end-to-end under CI (previously
  it needed button input to make sound). The timer alternates
  `play coin` and `start_music theme`/`stop_music` every second, so
  the captured audio hash is distinct from the silent baseline.

Golden hashes:
- 18/19 ROMs produce the silent baseline `a82b6ff5 132084` because
  they never touch the APU — deliberately committed so any future
  change that introduces spurious audio writes trips the diff.
- `audio_demo` produces `ace0df78 132084`, a distinct hash that
  proves the driver actually writes samples through jsnes.

Two video goldens (`function_chain.png`, `logic_ops.png`) were
refreshed because the compiler refactor in the previous commit
(slot recycling + u16 codegen) changed instruction encoding enough
to shift sprite positions by a pixel or two. Visually identical
under a diff review.

https://claude.ai/code/session_01A8qk3gw2jWSzdiXBZPZSFE
2026-04-12 22:33:48 +00:00
Claude
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
7899714af1
examples: 4 new programs covering MMC3 + other e2e gaps
Four new examples bring total coverage to 18/18 ROMs through
the jsnes smoke test:

- mmc3_per_state_split.ne — two states, each with their own
  `on scanline(N)` handler at a different line (80 vs 160).
  Pressing START transitions between them. Verifies the
  per-state MMC3 IRQ dispatch: the `__ir_mmc3_reload` helper
  CMPs `current_state` on every NMI and writes the right
  latch value to `$C000`/`$C001`, and `__irq_user` runs the
  current state's handler when the counter fires. This is
  the first example that exercises the per-state reload logic
  at runtime, not just at compile-time.

- two_player.ne — exercises `p2.button.*` reads alongside
  the default (P1) `button.*`. Two independently-moveable
  sprites sharing a single frame handler and the runtime OAM
  cursor. The runtime's NMI controller poll already reads
  both `$4016` and `$4017`, but until this example no
  runtime test actually looked at `$08` (the P2 input byte).

- function_chain.ne — five-deep user-function call chain
  (`frame -> compute -> scale -> clamp -> fold -> taper`)
  with parameter passing through ZP `$04-$07` and return
  values through A. Early returns inside nested `if`s,
  handler-local result var, mixed shift + additive transforms.
  Catches any regression in: JSR stack discipline, param slot
  layout, RTS stack unwinding, return-value flow, or the
  analyzer's call-graph / max-depth computation.

- comparisons.ne — one `if` per comparison operator
  (`==`, `!=`, `<`, `<=`, `>`, `>=`) gated on a u8 ramping
  through 0..255. Each `if` drives a pip sprite at a fixed
  column. Exercises every `CmpKind::*` case in the IR
  codegen's `gen_cmp`, catching regressions in branch-opcode
  selection (BEQ/BNE/BCC/BCS) and inverted-branch peephole
  folding.

Smoke test deltas (all 18/18 pass, with per-example floors):

    comparisons           208  (floor 150)
    function_chain        104  (floor 100)
    mmc3_per_state_split  104  (floor 80)
    two_player            104  (floor 100)

`tests/emulator/run_examples.mjs` gets new `EXAMPLE_FLOORS`
entries for each, with notes describing the expected content
so a regression prints a helpful reason.

cargo test (313 unit + 37 integration), cargo fmt --check,
cargo clippy --release -- -D warnings all clean.

https://claude.ai/code/session_014Z5y3Q9krLcAxYpZQJhZ5V
2026-04-12 20:48:31 +00:00
Claude
1525922faa
examples: add 5 new programs covering match/loop/logic/bitwise/scanline
Fills the biggest feature-coverage gaps in the existing example set:

- match_demo.ne          — match statement over a Screen enum,
                           driving a title / playing / paused /
                           game-over flow with a debounced controller.
- loop_break_continue.ne — `loop { ... }` with `break` and `continue`,
                           scanning an enemy array for the first hit.
- logic_ops.ne           — keyword-based `and` / `or` / `not` gating
                           movement and scoring on alive/paused flags.
- bitwise_ops.ne         — packed status-byte flags with `&` / `|` /
                           `^` / `>>` plus a health-bar render loop.
- scanline_split.ne      — MMC3 `on scanline(120)` handler rewriting
                           the scroll register mid-frame for a
                           classic status-bar split.

All 14 examples (9 existing + 5 new) pass the jsnes smoke test
(`14/14 ROMs rendered successfully`) and still pass `cargo fmt`,
`cargo clippy -D warnings`, and `cargo test`.

Known limitations surfaced while authoring these examples, to be
fixed in follow-up commits:

1. Array-literal global initializers (`var xs: u8[4] = [1,2,3,4]`)
   are silently dropped by `lower_program` — `eval_const` returns
   None for `Expr::ArrayLiteral` and no synthetic per-element
   init code is emitted. Affects `arrays_and_functions`,
   `structs_enums_for`, `loop_break_continue`, and any future
   array-using example. Arrays effectively boot at all-zero.

2. `draw` inside a loop body reuses one static OAM slot —
   `next_oam_slot` increments at IR-codegen time rather than at
   runtime, so N iterations all write to the same 4-byte OAM
   entry. Affects `arrays_and_functions`, `structs_enums_for`,
   `bitwise_ops` (health pips), and any loop that wants to
   render per-iteration sprites.

Both bugs are latent and didn't surface until I tried to write
examples that exercise the relevant features — the existing
integration tests only check iNES header structure, and the
jsnes smoke test's "at least one sprite rendered" bar is
satisfied by one sprite even when several were intended.

https://claude.ai/code/session_014Z5y3Q9krLcAxYpZQJhZ5V
2026-04-12 19:05:18 +00:00
Claude
28cb739840
examples: inline_asm_demo.ne
A small example that exercises all three inline-assembly paths in
one program:

- \`asm { ... }\` with \`{var}\` substitution for \`result\` and
  \`frame_count\`
- \`poke(addr, value)\` for a hardware register write (PPU \$2005)
- A helper function whose body is hand-written 6502 that still
  reads and writes NEScript-owned local variables

Useful as a short reference for anyone learning the escape hatches
to hand-written 6502.

https://claude.ai/code/session_01W6eQFStA66EuMKHUFo2rx3
2026-04-12 18:02:59 +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
d5eb3b0dda
examples: structs_enums_for.ne demo
A small platformer scaffold that exercises every recent language
feature in one place:

- \`struct Player { ... }\` with u8 and bool fields
- \`enum Direction { Up, Down, Left, Right }\`
- \`enum AnimFrame { Idle, Run1, Run2 }\`
- \`for i in 0..4 { ... }\` looping over a fixed-size array
- Struct field assignment and reads
- Enum variant assignment

Compiles through the default IR codegen and links cleanly.

https://claude.ai/code/session_01W6eQFStA66EuMKHUFo2rx3
2026-04-12 17:06:39 +00:00
Claude
b8c9e41276
Add README, LICENSE, examples, fix draw parser lookahead
README: project overview, quick start, feature list, example table
LICENSE: MIT
4 new examples covering all language features:
  - arrays_and_functions: arrays, while loops, inline/regular functions
  - state_machine: multi-state flow with on enter/exit handlers
  - sprites_and_palettes: inline CHR data, palette switching, scroll, cast
  - mmc1_banked: MMC1 mapper, bank declarations, software multiply

Parser fix: draw statement keyword-arg parsing now checks for ':'
lookahead before consuming an identifier, preventing it from eating
the next statement as a keyword argument (e.g., `i += 1` after a draw).

https://claude.ai/code/session_01W6eQFStA66EuMKHUFo2rx3
2026-04-12 00:38:19 +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
fd5a940c89
Add example builds to CI, document sprite name behavior
- CI: new "Build Examples" job compiles all .ne files and validates
  the output ROMs have correct iNES headers
- Examples: add notes explaining that sprite names (Smiley, Ball) are
  parsed but not yet resolved — all draws use the built-in CHR tile 0.
  Custom sprite declarations come in M3.
- Codegen: explicit `let _ = &draw.sprite_name` to document the
  intentional skip, with comment about M2/M3 scope

https://claude.ai/code/session_01W6eQFStA66EuMKHUFo2rx3
2026-04-11 22:55:43 +00:00
Claude
a20ddcbbab
Add example games and quick-start guide
Two example .ne programs with compiled .nes ROMs:
- hello_sprite: d-pad controlled sprite movement
- bouncing_ball: auto-bouncing sprite with edge detection

Includes README with build instructions and emulator setup.

https://claude.ai/code/session_01W6eQFStA66EuMKHUFo2rx3
2026-04-11 22:17:18 +00:00