Implements two items from docs/future-work.md's language-feature gaps:
NES 2.0 header support: `RomBuilder` gains a `header_format` field
and a matching `enable_nes2()` method. When enabled, byte 7 bits 2-3
are set to `10` and bytes 8-15 are populated per the NES 2.0 spec
(submapper, PRG/CHR MSBs, PRG/CHR RAM, timing). The header stays
16 bytes. Programs opt in via `game Foo { header: nes2 }`; the
default remains iNES 1.0 so every committed example ROM is byte
identical. `validate_ines` now detects and reports which format it
parsed.
u16 struct fields: the analyzer's `register_struct` accepts `u16`
fields with a two-byte size and the struct-variable allocator tracks
per-field sizes so the synthesized `pos.x`/`pos.y` globals get the
right address span. IR lowering's `LValue::Field` and
`Expr::FieldAccess` follow the same wide path as u16 globals, and
struct-literal initialization writes both bytes for u16 fields.
Array and nested-struct fields stay rejected with a clearer
message. Existing u8/i8/bool struct programs are unaffected.
https://claude.ai/code/session_01MaNVcDmK9gsspRkdxowQAM
Adds two items from the "Code quality / tooling" section of
docs/future-work.md. Both make it easier to chase regressions
without touching codegen.
- `nescript build --no-opt` skips the IR optimizer pass so
optimizer-introduced miscompiles can be bisected against the
unoptimized output. Threaded through CompileOptions and gated
at the single optimizer call site in src/main.rs. Covered by a
new integration test that compiles the same program twice
(opt on / opt off) and asserts both outputs are valid iNES
ROMs with matching headers and reset vectors.
- A criterion-based `benches/compile.rs` harness that times the
full parse -> analyze -> lower -> optimize -> codegen -> link
pipeline on every examples/*.ne file. Sources are pre-read
into memory so file I/O stays off the hot loop, and each
example gets its own Criterion group for easy regression
spotting.
Committed ROM bytes under examples/*.nes are unchanged; the
emulator goldens under tests/emulator/goldens/ are untouched.
Extends the analyzer with the warnings listed under "Error message
polish" in docs/future-work.md:
- W0102 (existing): now also fires for `while true { ... }` and
`loop { if cond { continue } }`. `continue` is explicitly not
counted as an exit — the loop still spins forever.
- W0105 (new): palette declarations whose sub-palette first-bytes
disagree. The NES mirrors $3F10/$3F14/$3F18/$3F1C onto $3F00/
$3F04/$3F08/$3F0C, so a 32-byte sequential write silently
overwrites the background universal colour; the grouped
`universal:` form auto-fixes this so only flat `colors: [...]`
declarations can trip it.
- W0106 (new): a call at statement position whose callee has a
declared return type — the value is silently dropped.
- W0107 (new): `fast` variables with fewer than three observed
reads+writes, which don't justify holding a scarce zero-page
slot. Leading-underscore names are exempt.
All four are warnings only — no IR, codegen, or runtime changes,
so every committed example ROM rebuilds byte-identical and no
emulator goldens flip. Tests added in src/analyzer/tests.rs.
One legitimate W0106 surfaces on examples/platformer.ne (the
`resolve_enemy_hit` helper uses early-return for control flow
but its return value is discarded by the caller); fixing it
would shift the ROM and flip goldens, so it is left in place
as an informational hint rather than a hard fix.
https://claude.ai/code/session_01MaNVcDmK9gsspRkdxowQAM
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
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
The optimizer fix in the previous commit changes the observable
gameplay of `examples/platformer.ne` — pre-fix the player got
spurious enemy-1 stomp bounces every time coin 2 drifted into its
pickup window, so the README demo gif showed the player bouncing
mid-air around emu frames 85-125 instead of walking through the
coin at ground level. Regenerate `docs/platformer.gif` from the
fixed compiler so the README matches reality.
To stop this from drifting again, treat the gif the same way the
repo already treats `examples/*.nes`:
- `gifenc` + `jsnes` + the harness are deterministic, so a fresh
recording byte-matches a valid commit. Verified across two
back-to-back runs (identical md5).
- `.github/workflows/ci.yml`'s `emulator` job now renders the gif
into `/tmp/platformer.gif` and `cmp`s it against `docs/platformer.gif`,
emitting a `::error` annotation pointing at the exact rerun
command if the committed copy is stale. This piggybacks on the
existing puppeteer + node setup, adding ~20s to the job.
- `scripts/pre-commit` runs the same check locally, but only when
`examples/platformer.{ne,nes}`, `tests/emulator/record_gif.mjs`,
or `tests/emulator/harness.html` is staged, and only if
`tests/emulator/node_modules` is already installed. Cold-start
puppeteer is ~20s — too slow to pay on every commit, but cheap
enough to pay when something gif-relevant changed.
- The header of `tests/emulator/record_gif.mjs` and the project
conventions section of `CLAUDE.md` both spell out the rerun
command and the invariant, so the next agent doesn't have to
re-derive any of this.
https://claude.ai/code/session_013Bi4H4YQ5or5HtMB4doUFi
`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
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
The previous commit wired the emulator job to read the committed
examples/*.nes directly, with the rationale that the `examples`
job's reproducibility diff would catch "stale committed ROMs"
at PR time. That reasoning is wrong and defeats the point of
the emulator harness.
Failure mode: a compiler change lands with correctly-rebuilt
ROMs (passing the examples job) but introduces a rendering
regression that flips a golden. The emulator job would catch
this today because it runs the harness against freshly-compiled
ROMs. With the previous commit's shortcut, the harness would
boot the committed ROMs — which the PR author just rebuilt to
match — so the mismatch wouldn't show up until a second commit
touched anything else. That's exactly the kind of silent-failure
hole the golden harness exists to plug.
Fix: emulator job rebuilds the compiler (toolchain + cache +
`cargo build --release`) and compiles every .ne into the
workspace before running the harness, same as the pre-ROMs-
committed era. The committed ROMs keep their review/demo role
(clone-and-play, diff visibility in PRs) but the test job
always validates the working compiler, not a frozen snapshot.
CLAUDE.md updated to match: the harness runs against whatever
sits in examples/*.nes, so iterating locally still means
rebuilding the ROM(s) you care about first.
https://claude.ai/code/session_01BcCcHi6FUmTh8jC7UgkA3A
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
The GIF made it into the README in the previous commit but the
source link was buried inside an italic caption. Promote it to
its own "Source:" line right under the image so readers see it
without having to parse a sentence, and add a pointer to the
tile generator binary next to it.
https://claude.ai/code/session_01BcCcHi6FUmTh8jC7UgkA3A
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
New top-level `CLAUDE.md` so future AI agents (and humans) don't have
to rederive the project conventions from the existing docs. Covers:
- The phase layout under `src/` and the expectation that every
module has a co-located `tests.rs`.
- The core `cargo` commands and the fact that `fmt` and `clippy`
are mandatory before committing.
- The jsnes emulator harness end-to-end:
- File layout (`harness.html`, `run_examples.mjs`, `goldens/`,
`actual/`).
- How to run it locally (build release compiler, compile every
example, `npm install`, `node run_examples.mjs`).
- How to update goldens with `UPDATE_GOLDENS=1` and the rules for
when that's allowed.
- The procedure for adding a new example (build, golden, verify,
README).
- What the harness catches (codegen / runtime / PPU / APU / asset
regressions) vs. what it doesn't (input, beyond frame 180).
- How the CI job in `.github/workflows/ci.yml` wires everything up
and uploads `actual/` on failure.
- Zero-page allocation rules (runtime reserves `$00-$0F`,
palette/bg reserves `$11-$17` conditionally, user vars `$10+` or
`$18+`, IR temps `$80+`) so the next agent doesn't accidentally
collide with the runtime.
- "Things to avoid" list: no backwards-compat shims, no silencing
goldens without understanding the diff, no committing the
gitignored harness directories.
https://claude.ai/code/session_012fKB251HvEUQwG3tizFyqt
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
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
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
Prior to this commit the linker always shipped a single 16 KB PRG
bank regardless of the declared mapper, so the README's MMC1/UxROM/
MMC3 support was aspirational. This commit gives the three banked
mappers a real multi-bank ROM layout:
* RomBuilder.set_prg_banks() writes any number of 16 KB banks
back-to-back so the iNES header reflects the true PRG size.
* Linker.link_banked() places switchable banks first, fixed bank
last, so the fixed bank maps to $C000-$FFFF (the address window
where vectors and the runtime live).
* runtime::gen_mapper_init() emits reset-time mapper config:
MMC1 serial-writes a control-register value that pins the last
bank at $C000 with the correct mirroring, UxROM relies on the
power-on default, MMC3 writes the $8000/$8001/$A000/$E000
registers to get a known PRG and mirroring state.
* runtime::gen_bank_select() is a mapper-specific subroutine
(callable with the target bank in A) that maps any physical
bank to $8000-$BFFF.
* runtime::gen_bank_trampoline() generates a cross-bank call
stub in the fixed bank that saves the caller's bank, switches,
JSRs the target, and restores the fixed bank.
* The CLI and integration helper thread declared `bank X: prg`
declarations through to the linker so MMC1/UxROM/MMC3 programs
actually produce multi-bank ROMs.
Coverage:
* Runtime unit tests (18 new): mapper init patterns for every
supported mapper, bank-select signatures, trampoline dispatch
order, UxROM bus-conflict table contents.
* RomBuilder tests (6 new): multi-bank layout, padding,
byte-level fidelity, per-bank size validation, legacy
single-bank fallback.
* Linker tests (13 new): multi-bank ROM sizes across MMC1/
UxROM/MMC3, fixed-bank placement, switchable-bank payload
fidelity, bank-select subroutine detection, NROM rejection
of switchable banks.
* Integration e2e tests (16 new): compile real .ne sources
through the full pipeline and assert on iNES headers,
mapper init signatures in the fixed bank, vector locations,
and a regression check against `examples/mmc1_banked.ne`.
Total: 474 tests pass under `cargo test` with
`RUSTFLAGS="-D warnings"`.
https://claude.ai/code/session_01UCressA5e8k1XsuoJYLav2
The CLI's build path was calling `Linker::new(mirroring)`, which
hardcodes the mapper number to NROM (0) regardless of the source
file's `mapper:` declaration. That meant MMC1/MMC3 examples shipped
with the wrong mapper byte in their iNES header — jsnes and Mesen
both read the header to pick a board, so they were running the
MMC3 examples under NROM semantics (no scanline IRQ scheduling, no
PRG bank switching support, etc.). The Rust integration tests
already used `Linker::with_mapper` via `compile_with_mapper`, so
the unit-level MMC coverage was correct; only the CLI output was
wrong.
Swap to `Linker::with_mapper(program.game.mirroring, program.game.mapper)`
so the header matches the source. Confirmed by inspecting the
rebuilt example ROMs:
mmc3_per_state_split.nes: flags6=40 (mapper=4) ← was 00
scanline_split.nes: flags6=40 (mapper=4) ← was 00
mmc1_banked.nes: flags6=11 (mapper=1) ← was 01
hello_sprite.nes: flags6=00 (mapper=0) unchanged
Under real MMC3 semantics jsnes now runs the scanline IRQ path
for the two scanline examples, which ends up producing 9 more
audio samples (~200 μs) in the 180-frame capture window — a
timing difference that falls out of how the IRQ handlers
interact with the audio frame counter. Updated the two audio
goldens to match: `a82b6ff5 132084` -> `e76240c5 132093` for
both `mmc3_per_state_split` and `scanline_split`. PNG goldens
are unchanged — the visible output is the same.
All 19 emulator goldens now match. 381 unit tests + 43 integration
tests green. Clippy and fmt clean.
https://claude.ai/code/session_015WfaDttE3DpWn9rpyfpQd8
Two CI fixes for the audio subsystem PR:
1. Update `tests/emulator/goldens/audio_demo.audio.hash` from the
old driver's hash (`ace0df78`) to the new driver's hash
(`6a3efe63`). Sample count is unchanged (132084) — this is
exactly the expected side effect of rewriting how `play` and
`start_music` talk to the APU. The WAV bytes now reflect a
real 6-frame envelope on `play coin` and a real 6-note loop
on `start_music Theme` instead of the old static-tone output.
2. Revert the incidental `Linker::new` -> `Linker::with_mapper`
swap in `src/main.rs`. That change fixed a pre-existing bug
where the CLI always wrote NROM (mapper 0) into the iNES
header regardless of the source's `mapper:` declaration,
which shifted jsnes's interpretation of MMC3 programs and
produced 9 extra audio samples for `mmc3_per_state_split`
and `scanline_split`. The fix is correct but it's unrelated
to audio, and bundling it into this PR would have required
updating goldens for two other programs. I'll file that as
a separate PR with its own golden update. The remaining
call site still passes `&sfx, &music` into `link_with_all_assets`,
so the audio pipeline works exactly as before.
Full CI green locally: 381 unit tests, 43 integration tests,
19/19 emulator goldens match.
https://claude.ai/code/session_015WfaDttE3DpWn9rpyfpQd8
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
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
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
The smoke test used to check a per-example `nonBlack` floor — "at
least one sprite rendered," plus a per-example minimum for the
multi-sprite examples. That catches gross regressions (a compiler
bug that makes everything go black) but silently lets through
anything that changes a handful of pixels without dropping below
the sprite floor. The whole point of this harness is to catch
compiler miscompiles before they land; a softer check means bugs
can still sneak in.
This swap makes every run diff the raw canvas framebuffer against
a committed PNG golden. One mismatched byte at pixel (120, 119)
is enough to fail CI — there's nowhere for a regression to hide.
Workflow:
# normal — fails on any pixel change
node tests/emulator/run_examples.mjs
# when the diff is intentional, rewrite the goldens
UPDATE_GOLDENS=1 node tests/emulator/run_examples.mjs
# or: node tests/emulator/run_examples.mjs --update-goldens
git diff tests/emulator/goldens/ # review the change
git add tests/emulator/goldens/
git commit # explain WHY in the message
When a run fails without `UPDATE_GOLDENS`, the runner writes:
tests/emulator/actual/<name>.png the run's raw output
tests/emulator/actual/<name>.diff.png red-highlighted pixel diff
so reviewers can eyeball what changed without rerunning locally.
`actual/` is gitignored and re-created on every run. The CI job
now uploads `actual/`, `goldens/`, and `report.json` together as
a single `emulator-diff` artifact on failure — side by side means
the "what changed" story is obvious without cloning.
Implementation:
- `tests/emulator/screenshots/` is renamed to `tests/emulator/goldens/`.
All 18 existing PNGs are preserved as the initial goldens (git
detected them as pure renames).
- `harness.html` gets a new `window.nesHarness.rawPixelsBase64()`
that returns the 245760-byte (256 × 240 × 4 bytes) RGBA canvas
buffer as base64. The runner compares raw pixels, not PNG
bytes, so encoder quirks (zlib level, filter heuristics) can't
cause false positives across Chrome versions or platforms.
- The runner uses `pngjs` (pure-JS, no native deps) to decode
goldens and to write diff PNGs. `PNG.sync.write` is
byte-deterministic for identical pixels, so `git diff` on a
committed golden only ever shows up when the actual rendered
pixels changed — not because two machines produced slightly
different compression.
- The committed goldens were re-encoded with pngjs in this commit
so the baseline is consistent from day one. File sizes are a
touch larger than Chrome's output (~1KB vs ~800B on average),
but that's negligible and it eliminates one entire class of
flaky-looking diffs in the future.
Determinism verification: I ran each of the 18 ROMs twice
through fresh `NES` instances in fresh puppeteer pages, hashed
the 245760-byte framebuffers at frame 180 with SHA-256, and
confirmed `run1 == run2` for every single one. Exact-pixel diffs
are safe for this ROM set.
Negative path verification: I corrupted one golden (flipped one
pixel to pure red via pngjs) and reran the runner. It printed
DIFF hello_sprite 1/61440 pixels differ; first at (120,120)
expected [255,0,0] got [0,0,0]
actual: tests/emulator/actual/hello_sprite.png
diff: tests/emulator/actual/hello_sprite.diff.png
and exited 1 as expected. The diff PNG shows a dim-grayscale
silhouette of the expected frame with a bright-red dot on the
one mismatched pixel — enough visual context to locate the
regression at a glance.
All 18 examples match their goldens in strict mode. `cargo fmt
--check`, `cargo clippy --release --all-targets -- -D warnings`,
and `cargo test --release` (313 unit + 37 integration) are all
still green.
https://claude.ai/code/session_014Z5y3Q9krLcAxYpZQJhZ5V
Fallout from removing the `--use-ast` flag and the AST codegen.
Four references in `docs/future-work.md` and one in `plan.md` were
still implying a parallel AST code path existed:
- `future-work.md` "--debug CLI flag wired" entry — said the flag
threads through `CodeGen::with_debug` (the old AST codegen's
builder). Updated to `IrCodeGen::with_debug`.
- `future-work.md` IR debug.log/assert entry — had a parenthetical
"(same behavior as AST codegen)" that no longer makes sense
without an AST codegen to compare against. Dropped.
- `future-work.md` inline assembly entry — said "Both IR and AST
codegen splice parsed instructions directly into the output
stream." There's only the IR codegen now. Updated.
- `future-work.md` `on scanline(N)` entry — described scanline
handling as "codegen (MMC3 IRQ vector wiring) is still TODO."
That wiring has been in place for a while: the IR codegen
emits `__irq_user` + `__ir_mmc3_reload` with per-state
dispatch, and `examples/scanline_split.ne` and
`examples/mmc3_per_state_split.ne` both exercise it in the
jsnes smoke test. Rewrote the entry to match reality.
- `plan.md` M2 "Compiler phases built" list — had "Codegen from
IR (replacing direct AST codegen)." The present-tense
parenthetical read as if AST codegen still existed; stripped
it. M1's "direct AST → 6502, skip IR for this milestone" line
is left as a historical milestone scope description.
One intentional mention remains: `future-work.md` "Recently
completed" bullet explicitly notes the AST-based path and the
`--use-ast` flag were removed. That's the correct tombstone.
https://claude.ai/code/session_014Z5y3Q9krLcAxYpZQJhZ5V
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
The `--use-ast` path through `src/codegen/mod.rs` was a strictly
inferior subset of the IR codegen. Building every example with
`--use-ast` through the jsnes harness:
- `arrays_and_functions` — fully black (array init + function
return values + OAM-in-loop all broken)
- `structs_enums_for` — fully black (struct literal is a no-op,
all fields stay at 0)
- `inline_asm_demo` — fully black
- `bitwise_ops`, `loop_break_continue` — below sprite floors
(static `next_oam_slot` bug B)
- `match_demo` — panics at compile time with
`branch offset 153 out of range` (AST's if/else-chain
desugaring of `match` emits short branches that can't reach
the far arms in a multi-arm match)
Six of fourteen examples are non-functional under `--use-ast`.
The other eight happen to fall inside the subset AST handles
(no arrays, no structs, no function return values, no
multi-sprite loops, no long match chains).
`docs/future-work.md` already listed "Once working, delete the
AST-based codegen entirely" as the intended direction. It's
working, so this commit does the deletion.
What's removed:
- The `CodeGen` struct, its impl block, and every helper in
`src/codegen/mod.rs` (the AST codegen body) — ~1150 lines.
The file is now a module header that re-exports `IrCodeGen`.
- `src/codegen/tests.rs` — 15 AST-specific instruction-pattern
tests. Every feature they covered has an equivalent test in
`src/codegen/ir_codegen.rs::{tests,more_tests}` already.
- The `--use-ast` CLI flag and its branch in `src/main.rs`.
- `compile_with_ir_codegen` in `tests/integration_test.rs` —
`compile()` now does what it did, so they merged. All 40
integration tests go through the IR path.
- Outdated sections in `docs/future-work.md` that described the
IR codegen as "not yet implemented" and listed AST codegen
gaps as priority work.
What's kept:
- `src/codegen/ir_codegen.rs` — the real codegen.
- `src/codegen/peephole.rs` — post-codegen cleanup pass, now
run unconditionally from `main.rs`.
Test plan:
- `cargo test --release` — 313 unit + 37 integration tests pass
(was 328 + 37; the 15 dropped are the deleted AST-specific
tests).
- `cargo fmt --check` clean.
- `cargo clippy --release --all-targets -- -D warnings` clean.
- `node tests/emulator/run_examples.mjs` — 14/14 ROMs render
above their per-example nonBlack floors.
- The one tightening: `sprite_resolution_uses_tile_index` was
asserting on the old static-slot encoding
(`A9 01 8D 01 02`). Updated to the cursor-based form
(`A9 01 99 01 02`, i.e. STA AbsoluteY).
Net diff: 1581 deletions, 62 insertions.
https://claude.ai/code/session_014Z5y3Q9krLcAxYpZQJhZ5V
`IrCodeGen::next_oam_slot` incremented at *compile time*: one
`draw` statement = one fixed OAM slot, baked into absolute-mode
stores at codegen. A `draw` inside a `while`/`for`/`loop` body
was lowered once and then always wrote to the same four OAM
bytes every iteration, so only the last iteration was ever
visible. The writeup in the earlier PR called this "bug B".
Fix: reserve ZP `$09` as `ZP_OAM_CURSOR`, reset it to 0 at the
top of every frame handler (right after the existing OAM clear
loop), and lower each `DrawSprite` IR op to:
LDY $09 ; load cursor
LDA <y_temp>
STA $0200,Y ; sprite Y
LDA #tile
STA $0201,Y ; tile
LDA #0
STA $0202,Y ; attr
LDA <x_temp>
STA $0203,Y ; sprite X
INC $09 x4 ; bump cursor by 4
Cost is ~+6 bytes per `draw` over the old static form. At 64
slots the u8 cursor wraps naturally, giving classic NES
"too many sprites" flicker instead of a silent compile-time
drop. `next_oam_slot` and its resets are gone from the IR
codegen entirely.
Secondary fix: `for i in 0..N` counters are now registered as
handler locals. `lower_statement` created a `VarId` for the
counter via `get_or_create_var` but never pushed it onto
`current_locals`, so the IR codegen's `var_addrs` lookup
returned `None` for every `StoreVar(i)` / `LoadVar(i)` and
silently emitted nothing. The counter stayed at 0 forever,
the loop spun indefinitely, and every iteration wrote the
first array element into OAM — turning all 64 sprites into
the same smiley. Same class as the handler-local `var` decl
bug from the earlier PR, just for for-loop variables.
Smoke-test deltas (all 14/14 still pass):
- arrays_and_functions: 104 -> 260 (player + 4 enemies)
- bitwise_ops: 104 -> 416 (player + flag sprites + pips)
- loop_break_continue: 208 -> 208 (already fixed by the earlier pass)
- structs_enums_for: 104 -> 260 (player + 4 enemies)
Regression tests:
- `ir_codegen::more_tests::ir_codegen_draw_sprite` — checks a
single `draw` emits `LDY cursor`, four `STA $020N,Y`, and
four `INC cursor`.
- `ir_codegen::more_tests::ir_codegen_multi_oam_uses_sequential_slots`
— rewritten for the new form: each draw gets its own
`LDY cursor` + 4 `INC cursor`.
- `ir_codegen::more_tests::ir_codegen_draw_in_loop_...` —
proves a `draw` inside a `while` compiles to ONE cursor-based
draw (not N unrolled statics and not zero), and asserts no
stray `STA $0204`/`$0208`/... absolute stores — those would
indicate bug B has regressed.
- `ir::tests::for_loop_counter_is_registered_as_handler_local`
— verifies `for i in 0..N` pushes `i` onto `current_locals`
so the IR codegen allocates it.
Smoke-test tightening: `tests/emulator/run_examples.mjs` now
has per-example `minNonBlack` floors. `arrays_and_functions`,
`structs_enums_for`, `loop_break_continue`, and `bitwise_ops`
all require multi-sprite rendering — if the OAM cursor bug
comes back, the smoke test fails loudly instead of passing on
the default `nonBlack > 0` check.
The legacy AST codegen in `src/codegen/mod.rs` still uses the
compile-time `next_oam_slot` approach. It's only reachable via
`--use-ast`, none of the examples use it, and its integration
tests only check iNES structure — left alone on purpose.
https://claude.ai/code/session_014Z5y3Q9krLcAxYpZQJhZ5V
Landing bug A from the previous writeup plus two adjacent bugs
that the fix exposed. All three miscompile anything that uses a
u8[N] global with a literal initializer.
1. Array-literal globals are now actually initialized.
`lower_program` only expanded `Expr::StructLiteral` into per-
field synthetic globals — `Expr::ArrayLiteral` hit
`eval_const`, returned `None`, and the array boot-cleared to
zero. `IrGlobal` now carries an `init_array: Vec<u8>`
populated by lowering, and the IR codegen startup loop emits
one `LDA #byte; STA base+i` pair per element.
2. Local variables no longer overlap array globals.
`IrCodeGen::new` advanced `local_ram_next` past
`max_global_base + 1` — for an array at `$0300-$0303` it
placed the first handler-local at `$0301`, inside the array.
The frame handler's stores through the local then corrupted
the array mid-frame. The allocator now walks the analyzer's
`VarAllocation` list and advances past `address + size` for
every RAM global, not just the base.
3. Peephole `remove_redundant_loads` honors indexed LDAs.
The pass tracked `LDA Immediate/ZeroPage/Absolute` but let
`LDA AbsoluteX/AbsoluteY/ZeroPageX/IndirectX/IndirectY` fall
through the match, leaving the A-equivalence tracker
unchanged. A later `LDA #v` that happened to match a stale
entry from BEFORE the indexed load would then be dropped as
"already in A" — a silent miscompile that turned every
`draw Sprite at: (arr[i], arr[j])` pattern into garbage
(the second array index would be computed from `arr[i]`'s
value, reading way out of bounds). Indexed LDAs now clear
the tracker.
Regression tests:
- `src/codegen/peephole.rs`: a synthetic
`LDA #0; TAX; LDA AbsX(arr1); STA temp; LDA #0; TAX;
LDA AbsX(arr2); ...` sequence asserts both `LDA #0`s survive.
- `src/ir/tests.rs`: verifies `var xs: u8[4] = [1,2,3,4]`
populates `IrGlobal::init_array` with `[1,2,3,4]`.
- `tests/integration_test.rs`: two IR-codegen tests — one checks
the startup instructions contain `LDA #v; STA base+i` for
every element, the other compiles a handler-local var
alongside an array global and asserts no post-init stores
land inside the array.
Smoke test impact (14/14 still passing, now more visible):
- arrays_and_functions: 56 -> 104 nonBlack, now animated
- loop_break_continue: 52 -> 208 (player + 3 hazards visible)
- structs_enums_for: 52 -> 104 (player + enemy visible)
Existing examples unchanged; no remaining work for bug B
(static OAM slot allocation in loops) — that's the next PR.
https://claude.ai/code/session_014Z5y3Q9krLcAxYpZQJhZ5V
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
Runs the jsnes + puppeteer harness on every push/PR so the same
execution-level bugs that the test suite previously couldn't catch
(missing JMP/JSR fixups, orphaned handler locals, uninitialized
struct fields, silently-swallowed intrinsic calls) will be caught in
CI going forward.
The job installs the handful of shared libs Chrome for Testing needs
on the ubuntu-latest runner, builds the compiler in release, compiles
every example, then drives each ROM through the harness. On failure
it uploads the captured screenshots and the JSON report as a build
artifact so regressions are easy to triage without re-running
locally.
Kept separate from the existing `Build Examples` job so compile-time
and execution-time issues report independently.
https://claude.ai/code/session_014Z5y3Q9krLcAxYpZQJhZ5V
Running the compiled example ROMs through a headless puppeteer +
local jsnes harness exposed four latent bugs that the
header-structure-only integration tests couldn't catch:
- src/asm/mod.rs: the first pass treated ANY instruction with
`AddressingMode::Label` as a label definition, silently dropping
every `JMP`/`JSR` to a label. Now only `NOP + Label` is a label
def; other opcodes emit the opcode byte plus a 2-byte absolute
fixup resolved in pass two. Without this, every example crashed
with "invalid opcode at $1xxx" once the CPU fell through into
the math runtime and hit an unbalanced `RTS`.
- src/ir/lowering.rs (lower_handler): handler-local `VarDecl`s
(e.g. `var i: u8 = 0` inside a `while`) were pushed onto
`current_locals` but the handler built its own throwaway
`locals` list, so those var ids never got RAM addresses and
every `LoadVar`/`StoreVar` for them silently emitted nothing.
Seed `current_locals` with the state's declared locals and
reuse it so `lower_statement`'s appends flow through to the
`IrFunction`. Fixes the black screen in `arrays_and_functions`.
- src/ir/lowering.rs (global init): struct-literal initializers
on globals (`var player: Player = Player { x: 120, ... }`) fell
through to `eval_const`, which returned `None` for a
non-literal, so no init code was emitted. Now the per-field
synthetic globals each get their own `init_value`. Fixes the
black screen in `structs_enums_for`.
- src/codegen/mod.rs: the legacy AST codegen was emitting
`JSR __fn_poke` / treating `peek` as `LDA #0` for the hardware
intrinsics. It only "worked" before because the broken
assembler swallowed the bogus JSR. Handle `poke`/`peek` as
direct STA/LDA to a compile-time-constant absolute address,
matching the IR codegen's intrinsic path.
The harness lives in `tests/emulator/`: a tiny HTML page that
wraps the `jsnes` npm package, driven by a puppeteer script that
loads each ROM, runs ~180 frames, snapshots the canvas, and
records a smoke-test verdict (booted without a CPU crash, non-zero
pixels rendered, frames differ over time). `npm install && node
run_examples.mjs` from `tests/emulator/` runs the full sweep.
9/9 example ROMs now load, render, and animate where expected.
All 324 unit + 35 integration tests still pass.
https://claude.ai/code/session_014Z5y3Q9krLcAxYpZQJhZ5V
Updates the feature list to mention structs, enums, for loops,
match, inline asm + \`{var}\` substitution, \`poke\`/\`peek\`
intrinsics, and the new \`--memory-map\` / \`--call-graph\`
diagnostics. Adds the two new examples (structs_enums_for and
inline_asm_demo) to the table.
https://claude.ai/code/session_01W6eQFStA66EuMKHUFo2rx3
The OAM clear at frame-handler entry is only needed if the
handler actually modifies OAM — handlers that never call \`draw\`
don't need to reset the shadow buffer at all, since the previous
handler's contents are still correct (or the startup zero-fill
still applies). Adds a \`function_draws_sprites\` helper that
walks the IR ops once and emits the clear loop only when at least
one \`DrawSprite\` exists.
Handlers like a title screen that doesn't render sprites pay zero
cycles for the OAM clear now.
https://claude.ai/code/session_01W6eQFStA66EuMKHUFo2rx3
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
A \`return\` with no value inside a function that has a declared
return type is an error — the caller expects a value. Previously
it compiled silently and the callee returned whatever was in A
from the last computation.
https://claude.ai/code/session_01W6eQFStA66EuMKHUFo2rx3
Each frame handler now begins with a short 64-slot loop that
writes \$FE to the Y-position byte of every OAM entry, hiding any
sprites the previous frame drew that the current frame doesn't.
Without this, stopping a \`draw\` call one frame leaves the sprite
lingering at its last position forever.
Cost: ~384 cycles per frame (2.3% of the ~16 666 cycles available
between vblanks). The per-frame \`draw\` calls overwrite the slots
they actually use, so the clear is free at runtime for sprites
that ARE drawn.
https://claude.ai/code/session_01W6eQFStA66EuMKHUFo2rx3
Previously \`remove_redundant_loads\` tracked only a single value
for A at a time, so \`STA addr\` would overwrite the prior
\`Imm(v)\` with \`Zp(addr)\`, losing the ability to eliminate a
following \`LDA #v\`. Now A's known value is an equivalence class
(\`Vec<AValue>\`): every \`STA addr\` *adds* the address to the
class instead of replacing it, so
LDA #5
STA \$10
LDA #5 ; eliminated — A still equals 5
collapses to just two instructions. Adding this also catches the
earlier \`STA \$10; LDA \$10\` idiom because \`Zp(\$10)\` is now part
of the class after the STA.
Any op that clobbers A clears the whole class; writes via STX/STY
or INC/DEC to an address in the class invalidate just that entry.
Instruction counts tighten on every example, most visibly on
bouncing_ball (123→120) and arrays_and_functions (247→245).
https://claude.ai/code/session_01W6eQFStA66EuMKHUFo2rx3