Closes §H. 2×2 metatiles and a parallel collision map are now a
first-class construct. `metatileset Name { metatiles: [{ id, tiles,
collide }, ...] }` declares a library of 2×2 tile bundles. `room Name
{ metatileset: M, layout: [...] }` lays them out on a 16×15 grid. The
compiler expands each room at compile time into:
- a 960-byte nametable (`__room_tiles_<name>`)
- a 64-byte attribute table (`__room_attrs_<name>`)
- a 240-byte collision bitmap (`__room_col_<name>`)
`paint_room Name` reuses the vblank-safe `load_background` update
machinery for the nametable blit and installs the collision bitmap
pointer into `ZP_ROOM_COL_LO`/`ZP_ROOM_COL_HI` (ZP $18/$19).
`collides_at(x, y)` JSRs into a small runtime helper that reads
`(room_col),Y` with `Y = (y & 0xF0) | (x >> 4)` and returns 0/1.
The helper links in only when the `__collides_at_used` marker is
emitted, so programs that declare a room but never query it pay
zero bytes for the subroutine.
`parse_byte_array` grows a `[value; count]` shortcut — 240-entry
`layout` arrays are unwieldy to spell out a byte at a time.
See `examples/metatiles_demo.ne` for the end-to-end flow: a probe
sprite bounces off walls via `collides_at` and lands on the left
side of the playfield at frame 180 — direct evidence that the
collision query works.
Also defers the register-allocator work from §"Code quality /
tooling" and documents the audio-goldens constraint in future-work
so the next agent sees it.
Two stale items cleaned up after the VRAM-buffer HUD work:
- §G mentions `examples/hud_demo.ne` as the realistic companion
to the minimal `vram_buffer_demo.ne`, and documents the
`$2006`/`$2005` reset the NMI drain now performs so the PPU's
scroll latch stays clean across frames.
- Design question 3's sprite-flicker half is answered — both
`cycle_sprites` and `game { sprite_flicker: true }` ship — so
the question narrows to the still-open `draw ... priority:
pinned` modifier.
https://claude.ai/code/session_01F7dHsgh7UX7SAK3wZ7JiKc
Closes the highest-priority remaining catalogue item (§G). User
code queues PPU writes during `on frame` via three new intrinsics;
the NMI drains the 256-byte ring at `$0400-$04FF` to `$2007`
during vblank. Programs that never touch the buffer pay zero
bytes and zero cycles for the feature — verified by the existing
46 ROMs all matching their goldens with no drift.
Also fixes the failing CI Format check from 7b4570e by running
cargo fmt across the working tree.
**Runtime:**
- New `runtime::gen_vram_buf_drain` emits the drain routine
(`__vram_buf_drain`). Walks entries `[len][addr_hi][addr_lo]
[byte_0]...[byte_(len-1)]` and stops at `len == 0`. Uses
`LDA $0400,X` indexed-absolute so no ZP scratch is needed.
Drain costs ~12 setup cycles + 8 cycles per data byte; the
256-byte buffer can hold ~50 single-tile writes that drain
in roughly 1000 cycles, well inside the ~2273-cycle vblank.
- `NmiOptions` gains `has_vram_buf`. The NMI JSRs the drain
after the existing palette/background handshake (compiler-
queued PPU writes win priority for vblank cycles).
**IR + codegen:**
- Three new ops `IrOp::NtSet`, `IrOp::NtAttr`, `IrOp::NtFillH`.
- The codegen helpers compute the PPU address inline:
`$2000 + y*32 + x` for nametable, `$23C0 + (y/4)*8 + (x/4)`
for attribute. Each append lays down a fresh `0` sentinel so
the NMI sees a well-formed buffer regardless of whether more
entries get appended later in the frame.
- `__vram_buf_used` marker drops on first use; gates the
runtime splice + NMI JSR.
**Analyzer:**
- AST-walking helper `program_uses_vram_buf` detects intrinsic
use at analyze-init time so the user-RAM bump pointer can
start at `$0500` (past the buffer) rather than the legacy
`$0300`. Programs that don't use the buffer keep the legacy
start.
- Three intrinsic names registered in `is_intrinsic` /
`is_void_intrinsic` with arity checks.
**Tests + example:**
- `examples/vram_buffer_demo.ne` exercises all three intrinsics
on a backgrounded program — three single-tile score writes,
a 16-tile horizontal fill, and an attribute write that flips
the top-left metatile group's palette to red. Committed
golden + audio hash.
- Four new integration tests: byte-level JSR-to-drain
assertion, drain-omitted-when-unused, RAM-bump assertion for
programs that DO use the buffer, and arity enforcement for
`nt_set`.
**CI fix:**
- `cargo fmt` ran across the tree. Picks up a one-line fmt
diff in `tests/integration_test.rs` that the prior commit
shipped without running fmt, causing the Format CI job to
fail on `7b4570e`.
All 758 tests pass. Clippy clean. 47/47 emulator goldens match.
Another batch from the cc65/nesdoug catalogue. All gated on
parser-level opt-in or default-false attributes so existing
programs produce byte-identical ROMs (no committed .nes file
changed).
**§A — `i16` signed 16-bit type:**
- New `KwI16` lexer token, `NesType::I16` AST variant, parser
case in `parse_type`. Type-size and integer-type tables
treat `i16` like `u16` (2 bytes, integer).
- IR lowering accepts `i16` everywhere it accepts `u16` for
wide-load / wide-store / widen-narrow paths.
- New constant fold for `UnaryOp::Negate(IntLiteral(v))` that
emits the wide two's-complement form. Without it, `var vy:
i16 = -10` would zero-extend to `$00F6` (= 246) instead of
sign-extending to `$FFF6` (= -10). Negative literals now
store the right bytes.
- Comparisons reuse the existing unsigned 16-bit compare ops
(matching the existing `i8` behaviour). Documented in the
`NesType::I16` doc comment and in `future-work.md` §A.
- Example `examples/i16_demo.ne` with committed golden.
- Tests cover the literal-fold sign-extension and end-to-end
compile of the example.
**§S — SRAM / battery-backed saves:**
- New `save { var ... }` top-level block. Lexer + parser opt
into a dedicated `KwSave` token. Analyzer allocates save
vars from a separate `next_sram_addr` bump pointer starting
at `$6000`, capped at `$8000` (8 KB cartridge SRAM window).
- Linker reads `analysis.has_battery_saves` and flips iNES
byte-6 bit-1 via the new `RomBuilder::set_battery` /
`Linker::with_battery` chain.
- New `W0111` warning for save-var initializers — SRAM is
preserved across power cycles, so an init expression would
either silently never run or clobber persisted data on
every boot. The warning teaches the user about the
magic-byte sentinel pattern.
- Struct fields in save blocks are explicitly rejected for now
(the field-flattening path uses the main-RAM allocator).
- Example `examples/sram_demo.ne` with committed golden, plus
4 integration tests.
**§D (partial) — inline-asm `.label:` syntax:**
- Codegen-side mangler rewrites `.IDENT` → `__ilab_<N>_IDENT`
per inline-asm block, where `<N>` is the call site's
monotonic suffix. Two `asm { .loop: ... }` blocks in the
same function now coexist without colliding in the linker's
label table.
- Bounds checks on `.` placement: `$2002` and `name.field`
are unaffected; only `.IDENT` in label / branch context
triggers the rewrite. Two integration tests pin the
uniqueness and dollar-vs-dot disambiguation.
**§X follow-up — Mesen trace-log docs:**
- New "Debugger-assisted workflows" section in
`docs/nes-reference.md` walking through the Mesen / FCEUX
log workflows alongside the new `debug_port:` attribute.
**Misc:**
- `future-work.md` updated to mark the shipped items out of
the catalogue and reshuffle the priority ranking. Remaining
niche follow-ups (signedness on Cmp16, struct save fields,
inline-asm format specifiers) documented inline so future
passes know the design.
All 757 tests pass. Clippy clean. 46/46 emulator goldens match.
Another batch from the cc65/nesdoug gap catalogue. All six items
gated on marker labels (or default-false attributes) so existing
programs produce byte-identical ROMs — every pre-existing .nes
file round-trips unchanged.
**Language / runtime additions:**
- `mapper: GNROM` (iNES 66). Combines AxROM's 32 KB PRG pages with
CNROM's 8 KB CHR banks in a single `$8000` register. Linker
pads single-page ROMs to 32 KB to match mapper-66 expectations.
- `game { debug_port: fceux | mesen | 0xXXXX }`. `debug.log`,
`debug.assert`, and the `__debug_halt` sentinel now target a
user-selected address. `fceux` (default, $4800) and `mesen`
($4018) are named aliases; custom hex addresses are accepted
for unusual debuggers.
- `game { sprite_flicker: true }`. IR lowerer injects an
`IrOp::CycleSprites` at the top of every `on frame` handler,
which flips on the rotating-OAM NMI variant with no per-site
boilerplate. Default false so existing ROMs keep their layout.
- `fade_out(step_frames)` / `fade_in(step_frames)` builtins.
Blocking helpers that walk brightness 4 → 0 or 0 → 4 with
`step_frames` frames between each step. Runtime splices
`__fade_out`, `__fade_in`, and a callable `__wait_frame_rt`
helper when the builtin is used. Zero-guard on step_frames
prevents a pathological 256-frame spin when the caller
accidentally passes 0.
- `sprite_0_split(scroll_x, scroll_y)` intrinsic. Emits a
two-phase busy-wait on `$2002` bit 6 (wait-for-clear,
wait-for-set) then writes the new scroll values to `$2005`.
Works on any mapper — unlike `on_scanline(N)` which requires
MMC3. Enables HUD-over-playfield scrolling on NROM/UxROM/MMC1.
**Docs:**
- New paragraph in the language guide explaining the no-recursion
design choice and the explicit-stack workaround pattern.
- `future-work.md` updated to mark the shipped items out of the
catalogue; remaining items reshuffled in the priority ranking.
- README + examples/README updated with the new mapper and
builtins.
**Tests:**
- 12 new integration tests covering: GNROM header emission,
debug-port targeting (fceux/mesen/custom), unknown-alias
rejection, sprite_flicker on/off/bad-value, fade_out JSR + marker
coupling, fade omitted-when-unused, fade-in-expression rejected,
sprite_0_split byte-level busy-wait verification, sprite_0_split
arity enforcement, sprite_0_split omitted-when-unused, and an
extended void-intrinsic-in-expression-position test covering the
three new void builtins.
- `nes2_mapper_high_nibble_in_byte_8_is_zero_for_small_mappers`
extended to include GNROM.
- Four new examples with committed .nes ROMs + pixel/audio
goldens: `gnrom_simple`, `auto_sprite_flicker`, `fade_demo`,
`sprite_0_split_demo`.
All 752 tests pass. Clippy clean. 44/44 emulator goldens match.
Follow-up cleanup on the cc65 parity batch. Addresses issues found
during a post-commit code review.
**Correctness fixes:**
- `rand8()` / `rand16()` at statement position (result discarded)
were being eliminated by DCE because `op_dest` returned
`Some(dest)` for Rand8/Rand16 even though the ops have a visible
side effect — advancing the PRNG state. Now `op_dest` returns
`None` for both, keeping the JSR regardless of liveness. New
regression test `rand8_statement_survives_dce`.
- Void-only intrinsics (`poke`, `seed_rand`, `set_palette_brightness`)
used in expression position (e.g. `var x = seed_rand(42)`) were
panicking the linker with an unresolved `__ir_fn_X` label. The
analyzer now emits E0203 with a clear message; new
`void_intrinsic_in_expression_position_errors` test covers all
three names.
- Statement-position `rand8()` / `rand16()` weren't lowered at all
(they fell through to the default Call path). Now both lower to
their IR op with a fresh temp that nothing reads; the JSR still
runs so the PRNG state advances.
- `--fceux-labels foo.nes` was producing `foo.0.nl` because
`PathBuf::with_extension` replaces instead of appends. Rewritten
to literally append `.<bank>.nl` / `.ram.nl` to the OsString, so
users get the FCEUX-expected `foo.nes.<bank>.nl` naming.
- Linker now asserts CNROM / AxROM don't accept user-declared
switchable PRG banks — their page sizes don't fit the 16 KB per
bank model, and silently producing a mis-sized ROM is worse than
a loud panic.
**PRNG cleanup:**
- Removed the stream-of-consciousness comment block in `gen_prng`
that described three abandoned algorithms before landing on the
actual Galois LFSR.
- Simplified `__rand16` to a single JSR + LDX instead of two
JSRs + TAY/TYA round-trip — a single shift already produces 16
fresh bits, the doubled call just burned ~40 cycles. The golden
PNG for `prng_demo` was regenerated to reflect the new sequence.
- Rewrote the `gen_prng` doc comment to accurately describe the
algorithm as a Galois LFSR (it was mislabelled as xorshift).
- Rewrote the `gen_palette_brightness` doc comment with a proper
table of level→mask mappings — the prior prose description
didn't match the actual table values.
**Tests:**
- Three new unit tests in `linker::debug_symbols` covering the
FCEUX `.nl` renderer: user-facing labels only, empty output when
no user labels exist, and deterministic sorting in `.ram.nl`.
- Extended `nes2_mapper_high_nibble_in_byte_8_is_zero_for_small_mappers`
to cover AxROM + CNROM.
- Renumbered priority list in future-work.md after removing the
shipped sections (J, K, N, parts of V and Y).
All 737 tests + 40/40 emulator goldens still green.
Updates the cc65/nesdoug-gap catalogue sections to reflect what
landed in 7507459. Remaining items reshuffle: i16 and the VRAM
update buffer stay at the top of the priority ranking.
Enumerates the gaps between NEScript today and what the cc65/nesdoug
ecosystem exposes: i16/pointers/bitfields, VRAM update buffer,
metatiles, edge-triggered input, PRNG, palette fade, sprite-0 split,
additional mappers (AxROM/CNROM/UNROM-512/MMC5), FamiStudio import,
SRAM saves, PAL/NTSC abstraction, NSF output, Zapper/Power Pad,
configurable debug port, FCEUX .nl labels, and explicit bank hints.
Each item has a design sketch and the section ends with a priority
ranking. This is the planning doc the follow-up implementation
commits will chip away at.
Before this change, state-local variables (`state Foo { var x: u8 = 0 }`)
were silently no-ops: the analyzer allocated a ZP slot for them, but
the codegen's `var_addrs` map only covered IR globals and scope-qualified
function locals — so every `LoadVar` / `StoreVar` whose `VarId` pointed
at a state-local resolved to no address and emitted nothing. Existing
examples compiled and matched their goldens because none of them observed
the dropped writes within the 180-frame harness window.
The overlay changes the analyzer's state-local pass to snapshot both the
ZP and RAM cursors after the globals have been laid out, then rewind to
that snapshot before each state's locals and track the running max.
`ZP_CURRENT_STATE` keeps exactly one state active at runtime, so every
state's locals are mutually exclusive with every other state's and can
share the same bytes. The IR lowerer now pushes each state's locals into
the IR globals table (with `init_value=None`) so the codegen resolves
their addresses the same way it does program globals, and prepends the
declared initializers to each state's `on_enter` handler (synthesizing
an empty one where needed) so a freshly-entered state re-establishes its
bytes before user code runs.
`--memory-map` now tags each allocation with its owning state
(`[@Title]`, `[@Playing]`, ...) and counts distinct bytes rather than
summed allocation sizes so overlaid slots don't double-count. The
`AnalysisResult.state_local_owners` map exposes the ownership to any
tool that wants to group allocations the same way.
Only `state_machine.ne` and `platformer.ne` declare state-level vars,
so they're the only example ROMs whose bytes change. `platformer.ne`'s
audio golden shifts slightly (the now-working `blink` counter in Title
adds a few cycles per frame before the auto-transition to Playing, which
offsets APU register writes within each frame); its video golden and
every other example ROM stay byte-for-byte identical.
Fixes#22.
https://claude.ai/code/session_015kvJu3iEFLSRJoShPBfm3X
Emit a `.dbg` debug-info file in the same format `ld65` produces, so
Mesen / Mesen2 / fceuX pick it up automatically and enable source-line
stepping, labelled variable inspection, and symbol-based breakpoints
without manual address lookups. Closes#23.
The new `render_dbg` helper stitches together metadata the compiler
already surfaces (linker label table, IR codegen `__src_<N>` markers,
analyzer variable allocations) into the file/mod/seg/scope/span/line/sym
records documented at https://cc65.github.io/doc/debugfile.html. Each
source-loc marker becomes a span that stretches to the next marker
(so breakpoints cover every byte the statement compiled into) plus a
line record pointing into it; `seg.ooffs` tracks the fixed bank's
PRG-relative start so banked MMC1/UxROM/MMC3 ROMs map cleanly too.
Reuses the `.mlb` symbol-name filter so internal skip/block labels
stay out of the debugger's symbol browser. `--dbg` implies the same
`__src_` marker emission as `--source-map` but leaves release builds
byte-identical when neither flag is passed.
https://claude.ai/code/session_01DfN3pKJLryr7vvNFBpcqmC
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
- PNG-sourced assets: drop the "automatic CHR generation" TODO
now that `png_to_nametable_with_chr` ships with the resolver.
- User code distribution: drop the banked → banked TODO; only
greedy size-packing and the MMC3 per-state-handler split remain.
- Language feature gaps: drop the metasprite row from the post-v0.1
table and add a paragraph describing the new `metasprite` syntax.
Drop the "nested struct / array struct field" gap; replace it
with a note about the still-rejected array-of-structs case.
- Audio pipeline: note the new pulse pitch envelope path; replace
the "pitch latches once" TODO with the triangle/noise extension.
- Debug instrumentation: note `debug.frame_overrun_count()` and
`debug.frame_overran()`; drop the matching "richer telemetry" TODO.
Items kept (and unchanged) include the WASM build target, register
allocator, fixed-point arithmetic, text/HUD, tilemaps, SRAM, DMC,
multi-channel tracker, NSF/FTM imports, debug.overlay, per-state
background swaps, and the four open design questions.
https://claude.ai/code/session_01KEczoNUX3WmcFLfq6iAQxB
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
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
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
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
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
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
Lists structs, for loops, audio parsing, const folding, on_scanline
codegen, all new peephole passes, and the fixed function call ABI /
local variable allocation. Re-prioritizes remaining work.
https://claude.ai/code/session_01W6eQFStA66EuMKHUFo2rx3
The IR-based codegen now matches all features of the AST codegen
(state dispatch, multi-OAM, P1/P2 input, scroll, debug.log/assert),
so flip the default. The legacy AST codegen is still available via
--use-ast for comparison and fallback during validation.
https://claude.ai/code/session_01W6eQFStA66EuMKHUFo2rx3
resolve_sprites() now handles all three AssetSource variants:
- Inline(bytes): use directly (existing behavior)
- Binary(path): read raw bytes from file relative to source_dir
- Chr(path): convert PNG to CHR via png_to_chr()
Missing files are silently skipped rather than erroring, so
declarations can reference assets that haven't been added yet.
This keeps existing tests that use placeholder file paths working.
Updated future-work.md: moved include directive, P2 controller,
sprite resolution, shift-assign, debug statements, warnings, and
asset wiring to the "completed" section. Remaining work is IR codegen,
audio, on_scanline, and language features (structs/enums).
Tests: 257 total (3 new resolve_sprites tests)
https://claude.ai/code/session_01W6eQFStA66EuMKHUFo2rx3