The previous commit parked the sprite-0 hit anchor on top of
the visible HUD coin at NT col 2, row 1 — the hit therefore
fired at dot 19 of scanline 15 (the HUD's last scanline) and
jsnes's PPU model applied the scroll change to the rest of that
same scanline, smearing the bottom row of every HUD glyph as
`camera_x` drifted. The score "0"/"2" digit, the heart, and
the lives "3" each flickered a handful of different pixel
patterns per frame as the scroll shifted. An in-harness
check for unique HUD states across the 180-frame window saw
~20-40 distinct states.
Fix: move both anchors off the HUD row entirely.
- `TILE_SPRITE0_ANCHOR` still has its single opaque pixel at
row 7, col 3, but now draws at OAM `(248, 8)` so the pixel
lands at screen `(251, 16)` — the first scanline of the
playfield.
- New `TILE_BG_ANCHOR` (tile 28) mirrors it with a single opaque
pixel at row 0, col 3; the map pre-paints it at NT `(col 31,
row 2)` via the new `a` legend entry. Its one opaque pixel
lands at the same `(251, 16)`, so the PPU's sprite-0 hit
fires there instead.
- An explicit `scroll(0, 0)` right before `sprite_0_split`
defensively re-latches scroll to zero in case jsnes has
carried stale `$2005` state over from the previous frame.
With the hit on scanline 16, `$2005` writes in HBLANK of
scanline 16 (or thereabouts) only affect scanline 17 onward;
rows 8-15 all render at scroll=0 and the HUD glyphs stay
pixel-stable. The unique-state count across the 180-frame
harness drops from ~40 to 4 — and those 4 correspond to the
legitimate score / lives transitions (initial title, post-
stomp-1, post-stomp-2, etc.), not per-frame jitter.
Column 31 and OAM x=248 both sit inside jsnes's right-edge
overscan so the anchor pixels are invisible in the committed
golden. Goldens + gif refreshed.
The status bar now paints into NT row 1 (coin + score digits on
the left, heart + lives digit on the right) using the `bg3`
sub-palette that matches `sp0` pixel-for-pixel. A single OAM
slot-0 anchor sprite sits over the coin tile; its one opaque
pixel lines up with the coin's bottom row so sprite-0 hit fires
at scanline 15, and a trailing `sprite_0_split(camera_x, 0)`
latches the playfield scroll starting at scanline 16. NT rows
0-1 stay pinned while scanlines 16+ scroll with the camera.
Score / lives updates are shadow-compared (`last_score`,
`last_lives`) so the VRAM ring sees an entry only when the
backing state actually changes — most frames append zero bytes.
OAM footprint drops from 5 sprites per frame down to 1.
Tile pipeline gains a 27th entry — a 7-transparent-row + 1-pixel
anchor — so the sprite-0 hit lands on scanline 15 instead of
scanline 8 (the latter would smear the HUD glyphs across the
split). `gen_platformer_tiles.rs` is updated in lockstep.
Ancillary changes: `bg3` retuned from `[yellow, orange,
dk_orange]` to `[red, orange, white]` (matching `sp0`);
`palette_map` row 0 flips from bg0 to bg3; legend gains `o`, `h`,
`0`, `3` so the initial map can preload the static HUD tiles and
the committed nametable already reads "coin 00 ... heart 3" on
frame 0.
`docs/future-work.md` loses the sprite-0 HUD follow-up section
(this commit lands it). Goldens + gif refreshed.
Root cause: `ZP_DIV_REMAINDER`, `ZP_MUL_RESULT_HI`, and
`ZP_CURRENT_STATE` all live at `$03`. The divide routine was
zeroing the byte on entry (`LDA #0; STA $03`) and writing the
running remainder there on every one of its 8 iterations; the
multiply routine accumulated its running product there. Any
multi-state program doing `u8 / 10` in `on_frame` had its state
ID clobbered on the way out of the routine — the next main-loop
dispatch read `$03 == 0` (or whatever the remainder happened to
be), matched state 0, and handed control to `Title` instead of
the current state. The platformer's HUD hit this once per blink
period: Playing survived exactly one frame, then Title took over
for 20 frames, then the cycle repeated.
Fix: rewrite both runtime routines to keep their running
accumulators in register A instead of `$03`. The new contracts:
- `__divide`: input `A = dividend`, `$02 = divisor`. Output
`A = remainder`, `$04 = quotient`. The algorithm shifts the
dividend-turning-into-quotient through `$04` (same as before)
and rotates the extracted bits into `A`, comparing and
subtracting directly without ever touching `$03`.
- `__multiply`: input `A = multiplicand`, `$02 = multiplier`.
Output `A = product` (low 8 bits — high byte discarded for
`u8 * u8 → u8` as before, but not via a `$03` write). The
multiplicand gets shifted left each iteration via `$04` and
the running sum stays in `A`.
`IrOp::Div` lowering gains one extra `LDA $04` after the JSR to
pick up the quotient; `IrOp::Mod` loses the old `LDA $03` since
the remainder is already in A. Net callsite cost is one
instruction either way.
Added two regression tests — `divide_routine_does_not_touch_zp_03`
and `multiply_routine_does_not_touch_zp_03` — that walk the
emitted instruction stream and fail loudly on any ZeroPage($03)
access, so a future refactor can't silently reintroduce the
alias.
Rebuilt the three ROMs that use `/` or `*` (bitwise_ops,
mmc1_banked, platformer) and re-baselined the platformer audio
golden — the new instruction count shifts vblank-relative audio
timing by a few cycles, as the CLAUDE.md audio-churn note warns.
Pixel goldens and docs/platformer.gif stay byte-identical. The
platformer HUD is back on native `stomp_count / 10` + `% 10`;
the subtraction-loop workaround is gone.
docs/future-work.md gains a new section describing the planned
sprite-0 hit upgrade for the platformer HUD (carry-over task
from the branch).
Upgrades the platformer's "live coin count" into a proper heads-up
display that stays pinned to the top of the viewport while the
nametable scrolls. Left side: coin icon + two-digit stomp tally.
Right side: red heart icon + single-digit lives counter. Both ride
through the GameOver screen without jumping position, so the death
banner reads as a continuation of the same run.
Wire-up: three new cross-state bits — score now accumulates across
lives, `lives` starts at 3 and decrements in `GameOver.on_enter`,
and the GameOver → Playing retry bounces to Title instead when the
last heart is spent (Title's `on_enter` refills both).
Tile pipeline: ten decimal digits + a heart glyph added to the
committed Tileset (generator source in `scripts/gen_platformer_tiles.rs`
kept in sync). Digits use `c` (white) so they read against the
sky; the heart uses `a` (red) to match the cap/brick palette.
Division workaround: the obvious `stomp_count / 10` / `% 10` pair
miscompiles near state transitions — the built ROM cycles
Title → Playing → Title once per blink period with Playing
surviving exactly one frame. Swapping both calls for repeated
`while r >= 10 { r -= 10 }` helpers fixes it. Documented as a
new entry in `docs/future-work.md` so the next person reaching
for `/` or `%` knows to check there first.
Goldens, docs/platformer.gif, and the top-level + examples README
entries all refreshed in the same commit.
`remove_dead_loads` now scans past opcodes that touch neither A nor
the flags an LDA sets, so a redundant LDA gets caught by its
successor's overwrite even when an index load or counter bump sits
between them. The extension covers LDX/LDY/INX/INY/DEX/DEY and the
flag ops (CLC/SEC/CLI/SEI/CLD/SED/CLV) alongside the INC/DEC/STX/STY
opcodes the pass already stepped past.
The highest-leverage case is the shape every single-tile `draw`
emits. After copy propagation and dead-store elimination do their
work, the stream reads:
LDA #<y> ; stray producer, value never consumed
LDY oam_cursor
LDA #<y> ; real load before STA
STA $0200,Y
The first LDA was surviving because the pass bailed on the LDY.
With the step-past, it drops. One LDA gone per draw, 2 bytes each.
Measured LDA-count reduction on committed examples:
platformer 242 → 221 (-21, -8.7 %)
war 785 → 754 (-31, -4.0 %)
pong 843 → 827 (-16, -1.9 %)
**Audio goldens.** The cycle savings shift the main-loop/NMI boundary
in audio-emitting programs, which re-times which frame each SFX
trigger lands in. Six audio hashes re-baseline as a result:
audio_demo, friendly_assets, noise_triangle_sfx, platformer, pong,
war. All 50 PNG goldens, the platformer/war/pong demo gifs, and
every non-audio program stay byte-identical. The re-baselined
output is still sample-accurate; what changed is the first-SFX
offset within the captured 132 084-sample window. This is the
audio-shift tradeoff documented in future-work.
Two new peephole unit tests lock in the behaviour:
- `dead_load_elim_steps_past_ldx_ldy` — the DrawSprite shape folds.
- `dead_load_elim_preserves_lda_when_used_by_shift` — a subsequent
ASL on A keeps the LDA alive across an intervening LDY.
Also updates future-work.md to reflect the shipped change and the
remaining register-allocator wins worth chasing next.
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.
Closes the §A follow-up gap: ordering compares (`<`, `<=`, `>`, `>=`)
on signed integer types now use the canonical 6502 `CMP / SBC / BVC /
EOR #$80` overflow-correction idiom so the N flag reflects the true
sign of the difference, instead of the previous BCC/BCS-based path
that always treated `$FFxx` as greater than `$00yy`.
The same change also fixes narrow-to-wide widening: assigning a
runtime `i8` expression to an `i16` variable now sign-extends the
high byte via a new `IrOp::SignExtend` op instead of zero-extending
it, so `var w: i16 = some_i8_neg` round-trips negative values.
The lowerer tracks signedness on each IR temp (analogous to the
existing `wide_hi` map) and threads it onto the new `Signedness`
field of `CmpLt`/`CmpGt`/`CmpLtEq`/`CmpGtEq` and their 16-bit
variants. The optimizer's constant-folder uses the same flag to
fold compares correctly under either signedness. Casts to `u8`/`u16`
strip the signed flag so an explicit `as` opt-out stays unsigned.
`examples/signed_compare.ne` exercises both bit widths through the
emulator harness — the four pip sprites at the top of the screen
show three lit (signed-correct) and one dark (would only light if
the compare regressed to unsigned semantics).
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
Each $2006 write inside __vram_buf_drain updates the PPU's `t`
(scroll) register, so leaving it pointing at the last buffer
entry's address shifted the next frame's rendering up/right by
however many cells we wrote past $2000. Reset by writing $00 to
$2006 twice (clears `t` and resets the write-toggle to high)
followed by $00 to $2005 twice (zero X/Y scroll). The HUD demo
golden flips from "smileys offset by ~16px" to the intended
red bar with white hearts and a yellow score digit.
https://claude.ai/code/session_01F7dHsgh7UX7SAK3wZ7JiKc
The previous version of hud_demo passed `score & 0x0F` and tile
index `1` (= Heart) to nt_set / nt_fill_h, but the demo had no
Heart sprite declared and tile 1 in CHR was uninitialized garbage.
The result was a screen of blue smileys with a tiny red strip in
the corner — the buffer mechanism worked, but the visual gave no
sense that anything HUD-shaped was happening.
This commit makes the HUD actually look like a HUD:
- 12 sprite declarations (Bar, Heart, Digit0..9, Ball) that the
compiler lays into CHR at known tile indices in declaration
order. Tile-index constants (`BAR_TILE`, `HEART_TILE`,
`DIGIT_BASE`) match that order so the call sites can use names
instead of magic numbers.
- bg1 palette restructured to `[red, white, yellow]` so pixel-art
characters resolve to visible colours: `#` = red (background
fill), `%` = white (heart shape), `@` = yellow (digit strokes).
- Background pre-paints row 1 with the solid `Bar` (red) tile
via a `legend { "B": 1 }` entry, giving the HUD a uniform red
canvas for individual cell writes to land on.
- Eight `nt_attr` calls at startup paint the entire top metatile
row (4 rows × 32 cols) with sub-palette 1 so the HUD chrome
reads as visually distinct from the playfield.
The result at frame 180 is unmistakably HUD-shaped: a yellow-on-
red status bar at the top of the screen above blue playfield with
a yellow ball bouncing around. Per-frame cost still scales with
what changed — `last_score` / `last_lives` shadow-compares mean
the buffer stays empty on the ~58 of 60 frames where nothing
ticks.
Tests: 758 pass. Clippy clean. 48/48 emulator goldens match.
Follow-up to 807c9c7 (the VRAM update buffer core). Adds the
realistic-HUD example the core was missing, plus a language-guide
section that explains when and how to use the three buffer
intrinsics.
**examples/hud_demo.ne**
A bouncing-ball playfield with a classic status bar across the
top:
- 5-cell lives indicator that ticks down once per second and
resets at zero, drawn via `nt_fill_h` (plus a second
`nt_fill_h` to erase the stale tail).
- Score counter at the right edge that bumps on every wall
bounce, drawn via `nt_set`.
- One-shot `nt_attr` call on the first frame flipping the
top-left metatile group to sub-palette 1 (the red HUD
palette) so the UI chrome reads as distinct from the
playfield.
The demo's point is the `last_score != score` / `last_lives !=
lives` shadow-compare pattern: on the ~58-of-60 frames where
nothing changed, the buffer stays empty and drain work is zero.
That's the whole reason the VRAM buffer exists — per-frame cost
scales with what moved, not with HUD complexity. Committed
`.nes` + pixel/audio goldens.
**docs/language-guide.md**
New "VRAM Update Buffer" section between "Hardware Intrinsics"
and "Inline Assembly". Covers:
- Why user code can't just poke `$2006` / `$2007` directly.
- The three intrinsics + their coordinate systems (cell, not
pixel).
- The HUD pattern with a ready-to-paste code snippet and a
pointer at `examples/hud_demo.ne`.
- A per-entry budget table + worked 1000-cycle drain example
against the ~2273-cycle vblank budget.
- Known limits: horizontal-only, no overflow check,
no coalescing — all already tracked under `future-work.md` §G.
**examples/README.md**
`vram_buffer_demo.ne` reframed as the minimal test-case exercise
it actually is, with a pointer at `hud_demo.ne` for the realistic
pattern. New table row for `hud_demo.ne`.
All 758 tests pass. Clippy clean. 48/48 emulator goldens match.
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.
Closes seven of the cc65/nesdoug parity gaps catalogued in
docs/future-work.md in a single pass. All of the new features are
gated on marker labels so programs that don't use them produce
byte-identical ROM output (every pre-existing committed .nes file
round-trips unchanged).
Language / runtime additions:
- `rand8()` / `rand16()` / `seed_rand(u16)` intrinsics backed by a
16-bit Galois LFSR (~30 bytes of runtime, ~40 cycles per draw).
Reset path seeds state to 0xACE1 so the first draw is useful even
without explicit seeding.
- `p1.button.a.pressed` / `.released` edge-triggered input via a
new ReadInputEdge IR op plus an NMI-side prev-frame snapshot into
$07E6/$07E7, gated on the `__edge_input_used` marker.
- `set_palette_brightness(level)` builtin mapping levels 0..8 to
PPU mask emphasis bytes (`$2001`) for neslib-style screen fades.
- `mapper: AxROM` (iNES 7) with automatic 32 KB PRG padding so
emulators that enforce mapper-7's 32 KB page size boot cleanly.
- `mapper: CNROM` (iNES 3) with a reset-time CHR bank 0 select.
- `--fceux-labels <prefix>` CLI flag emitting per-bank `.nl` label
files and a `.ram.nl` file for FCEUX's debugger.
Tests + examples:
- Five new example programs with committed .nes ROMs and
pixel+audio goldens: prng_demo, edge_input_demo,
palette_brightness_demo, axrom_simple, cnrom_simple.
- Seven integration tests covering JSR emission, the
omitted-when-unused invariant, the NMI prev-input snapshot, the
correct mapper numbers for AxROM/CNROM, and negative tests for
unknown button names and bad rand8 arity.
- `is_intrinsic()` now runs in expression-position Call paths too,
so `var x = rand8(1, 2)` errors at compile time instead of
silently dropping the extra arguments.
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.
Follow-up to the silent-drop audit. The old ABI passed every
parameter through four fixed zero-page transport slots `$04-$07`,
imposing a hard 4-param cap (E0506) that didn't compose with
structs/arrays/u16s and fell back to "pack args into a global"
workarounds whenever a function needed five things. The transport
scheme also cost every non-leaf call a 4-LDA/STA spill prologue
(~28 cycles, 16 bytes) to copy args out of ZP before the next
nested `JSR` could clobber them.
Replace it with a hybrid convention keyed on leaf-ness:
- **Leaf callees** (no nested `JSR` in body, ≤4 params):
unchanged. Caller stages args into `$04-$07`; body reads those
slots directly for its entire lifetime. No prologue copy.
Fastest path, 3-cycle ZP stores + 3-cycle ZP loads, preserves
the SHA-256 leaf-primitive optimisation that motivated the
original fast path.
- **Non-leaf callees** (body contains a nested `JSR`, OR ≥5
params): direct-write. Caller stages each argument straight
into the callee's analyzer-allocated parameter RAM slot,
bypassing the transport slots entirely. No prologue copy on
the callee side. Saves ~24 cycles and ~16 bytes per call vs
the old transport-then-spill path, and — crucially — scales
past 4 params because the per-param slots live wherever the
analyzer put them rather than in a fixed ZP window.
The analyzer's ceiling moves from 4 to 8. Functions with 5–8
params are silently promoted to the non-leaf convention (even if
their body has no nested `JSR`), which pays the direct-write cost
rather than the prologue-copy cost — still cheaper than the old
ABI. Declarations with 9+ params still emit E0506.
### Implementation
- `function_is_leaf` now also requires `param_count <= 4`.
- `IrCodeGen::new` populates `non_leaf_param_addrs: HashMap<String,
Vec<u16>>` — for every non-leaf function, the ordered list of
addresses its parameters occupy. Callers use this to route each
arg directly to the right slot.
- `IrOp::Call` branches on presence in the map: non-leaf → direct-
write, leaf (or absent — 0-arg case) → ZP transport.
- `gen_function` no longer emits a prologue. Leaves didn't have
one; non-leaves had a 4-LDA/STA copy that is now unnecessary
because args arrive pre-written to the slot.
- The previous `leaf_functions: HashSet<String>` field is
removed; leaf-ness is now inferred from absence-in-
`non_leaf_param_addrs` at the call site.
### Tests and regressions
- `eight_param_non_leaf_function_stages_every_arg_at_its_allocated_slot`
compiles an 8-param function, scans PRG for a distinct
`LDA #\$NN / STA <addr>` per arg (immediates `0x11..0x88`), and
asserts that STAs to the `$04-$07` range are strictly fewer
than 8 — proof the old transport path is gone for this call.
- `non_leaf_call_direct_writes_args_to_callee_param_slots`
replaces the old `gen_function_prologue_spills_params_to_local_ram`
test with a dual assertion: (a) no `LDA \$04` prologue at the
callee entry, and (b) the caller-side STA lands at the
analyzer-allocated param slot, not at `\$04-\$07`.
- `analyze_rejects_function_with_more_than_4_params` renamed and
rewritten for the new 8-param cap.
- `feature_canary.ne` gains a 6-param `sum6` call (1+2+3+4+5+6 =
21) as check 8. The canary stays green (all eight checks
pass), so the committed golden is unchanged.
### Blast radius
- Six example ROMs change bytes (arrays_and_functions, function_chain,
mmc1_banked, pong, sha256, war) because their non-leaf call sites
pick up the shorter staging sequence.
- Pong and war audio hashes refresh (pure layout-timing shift; no
behavioural change in the 180-frame no-input window). docs/pong.gif
and docs/war.gif stay byte-identical.
- `examples/function_chain.ne`'s header comment updated to
document the leaf vs non-leaf split it exercises.
- `docs/language-guide.md` parameter-count section and E0506 entry
updated to reflect the new rule.
All 720 Rust tests pass; all 35 emulator goldens pass.
https://claude.ai/code/session_01AoQ678uVeqpyayvWHpfDhC
Phase 5 of the post-PR-#31 audit, and the structural piece that
closes the failure mode the earlier phases couldn't fix alone.
The audit's recurring diagnosis: pixel/audio goldens capture
*whatever* the program does, not what it *should* do. A silent
drop in codegen is still deterministic — the golden locks in
the broken behaviour and every future run agrees with it. That's
how state-locals, uninitialized struct-field writes, `on exit`
handlers, and `slow` placement each sat broken for months-to-a-
year in a green CI.
The canary inverts the relationship: the committed golden is a
solid-green universal backdrop that only appears when every
round-trip check passes. Each check writes a distinctive constant
through one language construct, reads it back, and clears
`all_ok` on mismatch. A final `if all_ok == 0 { set_palette Fail }`
flips the entire screen red for the rest of the run.
Checks cover the silent-drop shapes caught by this audit:
- state-local variable write-read (PR #31)
- uninitialized struct-field write-read (caught by phase 1)
- u8 / u16 globals (u16 exercises both StoreVar + StoreVarHi)
- array-element write at nonzero index
- `slow`-placed global still round-trips
- function call return value
The canary doesn't use `debug.assert` on purpose — debug-only
ops get stripped in release and the emulator harness runs
release builds. The palette swap works in release and is what
the harness pixel-diff sees.
### Why this matters as a long-lived test
The harness already had 34 pixel goldens covering full-program
behaviour, but none of them exist specifically to fail if a
*specific language feature* silently drops. The canary does.
Every silent-drop bug the audit found would have flipped it
red the moment the check was added, which is the "behaviour
assertion that can't be satisfied by silence" the plan called
for.
### Harness footprint
`tests/emulator/goldens/feature_canary.{png,audio.hash}` +
`examples/feature_canary.{ne,nes}`. 35/35 ROMs match their
goldens with the canary added. Listed in both README tables.
https://claude.ai/code/session_01AoQ678uVeqpyayvWHpfDhC
Phase 3/4 of the post-PR-#31 audit.
### Call args > 4 is now an assert
`IrOp::Call` silently `.take(4)`-d the arg list with a comment
claiming the analyzer's E0506 check made the extras unreachable.
Replace with an explicit `assert!(args.len() <= 4, ...)` so if
the analyzer ever regresses, the codegen crashes loudly instead
of miscompiling the call. Iterate over all args (not just the
first 4) since the assert guarantees correctness.
### CLAUDE.md: new-feature PR checklist
Document the lesson the audit taught: every new language-feature
PR must include (1) an example exercising it, (2) a runtime
*behaviour* assertion (not just a "ROM validates" shape check),
(3) a negative test for invalid use. Call out the specific
address-map lookup pattern (`if let Some(&addr) = map.get(..)`
with no else) that shipped the state-local bug, and recommend
the `IrCodeGen::var_addr` / explicit `.unwrap_or_else(|| panic!)`
idiom instead.
Chose not to add a regex-based CI tripwire for "silently" /
"for now" comments because the false-positive rate against
legitimate design decisions ("silently truncate to 8 bits per
the cast spec", etc.) would train contributors to ignore it.
The durable checklist in CLAUDE.md is what next agents need.
https://claude.ai/code/session_01AoQ678uVeqpyayvWHpfDhC
Phase 2 of the post-PR-#31 audit. The codebase had four documented
"silently skip" paths that parsed user intent but produced no code.
Each one was the same shape as the state-local bug: the analyzer
accepted the program, the IR lowered the construct, but somewhere
downstream the emitted code was dropped on the floor — and a pixel
golden that captured the broken behaviour locked it in as correct.
Fix each per the plan, either by implementing the feature or
rejecting the program at the analyzer.
### on_exit handlers now actually run
`IrOp::Transition` used to comment "on_exit of the current state
isn't called here because we don't know from an IR op alone which
state we're leaving." The codegen emitted the exit handler's body
as an IR function but never JSR'd it. Three example programs
(pong, war, state_machine) relied on `stop_music` or mode-flag
translation inside `on exit` that had been silently never running.
Emit a small CMP-chain against `ZP_CURRENT_STATE` before each
transition: for every state that declares an on_exit, compare the
current index, branch past on miss, JSR the exit handler on match,
then JMP to the shared done-label so only the leaving state's
handler fires. The chain is inlined at each transition site
(bounded by the number of states declaring on_exit) rather than
factored into a single trampoline — simpler to reason about, and
transitions are rare enough that the extra bytes don't matter.
Pong / war / state_machine ROMs change because the dispatch code
is now emitted. Video goldens stay byte-identical (no transitions
happen within the 180-frame harness window under no-input). Pong
and war audio hashes shifted from pure code-layout timing and are
regenerated. `docs/pong.gif` and `docs/war.gif` are byte-identical.
### State-local array initializers now refuse to compile (E0601)
`src/ir/lowering.rs:887` had the comment "Array initializers for
state-locals aren't supported yet... Programs that try this should
get a diagnostic from the analyzer; for now, silently skip." The
analyzer never actually emitted that diagnostic. Verified by
compiling `state Main { var buf: u8[4] = [10,20,30,40] ... }`:
the program built a valid ROM with no trace of 10/20/30/40 in PRG.
Add E0601 to the analyzer's state-local pass. The IR lowerer's
defensive `continue` stays in place as a belt-and-braces guard.
### `on scanline` without MMC3 is now E0603
Previously E0203 ("invalid operation for type") which is a
miscategorisation — the feature is unsupported on the current
mapper, not a type error. Dedicated E0603 makes the future-work
shape explicit.
### `slow` variables now actually live outside zero page
`Placement::Slow` was parsed into the AST but `allocate_ram`
ignored it, so `slow var cold: u8` still landed in ZP like any
other u8. Wire `var.placement` through `allocate_ram_with_placement`
and skip the ZP branch when `Slow` is set. `Fast` remains
advisory (the existing default already prefers ZP for u8 vars),
validated by W0107.
### Other address-map silent drops hardened
Alongside the var_addrs hardening from phase 1, three `state_indices`
lookup sites that did `.copied().unwrap_or(0)` or silent `if let`
are now explicit panics: scanline IRQ dispatch, MMC3 reload, and
`IrOp::Transition`. A miss in any of them is a compiler bug, not
valid input — the analyzer catches unknown state names upstream.
### Regression guards
Four new tests would have failed against the old silently-dropping
code paths:
- `analyze_state_local_array_initializer_rejected` — expects E0601.
- `analyze_on_exit_declaration_accepted` — expects no errors.
- `analyze_slow_var_forced_out_of_zero_page` — expects alloc
address >= $0100.
- `transition_dispatches_leaving_states_on_exit_handler` — counts
distinct JSR targets in the PRG before/after adding `on exit` to
a state; the exit-bearing build must have more.
All 720 tests pass. All 34 emulator goldens pass after the pong/war
audio hash refresh.
https://claude.ai/code/session_01AoQ678uVeqpyayvWHpfDhC
Phase 1 of the post-PR-#31 audit. The PR #31 state-local bug had a
specific shape: analyzer allocated a slot, codegen looked it up by
VarId, silently emitted nothing on miss. Six sites in gen_op plus
the global-initializer loop and the parameter-shuffle prologue all
used the same `if let Some(&addr) = self.var_addrs.get(var) { ... }`
pattern with no else branch. Any future allocation-map desync would
slip through the same crack.
Replace every site with a new `IrCodeGen::var_addr(VarId) -> u16`
helper that panics with an explicit "compiler bug" message on miss.
An IR op referencing an unmapped VarId is not valid input — it means
the analyzer and lowerer disagreed on what to allocate, and we want
that crash to surface in CI rather than be absorbed by whatever
zero-filled RAM happened to sit at the read.
Running cargo test against the hardened lookup surfaced exactly the
bug shape the plan predicted: uninitialized struct globals (e.g.
`var p: Point` with no literal initializer) never had their flattened
field VarIds (`"p.x"`, `"p.y"`) registered in var_addrs. The IR
lowerer's `get_or_create_var("p.x")` minted a VarId, the analyzer's
`flatten_struct_fields` allocated an address for it, but IrCodeGen::new
only populated var_addrs from `ir.globals`, which doesn't contain
synthesized field entries for uninitialized structs. Every `p.x = N`
silently compiled to nothing.
Fix by exposing the IR lowerer's name→VarId map on IrProgram and
joining it with the analyzer's allocations in IrCodeGen::new. Every
allocated name that the lowerer knows about now gets a var_addrs
entry. Example ROMs are byte-identical (no example relied on the
dropped writes), but the bug was reachable — any user program with
a plain `var pos: Point` declaration and field writes would have hit
it silently.
Add `uninitialized_struct_field_store_emits_sta_to_allocated_address`
as a byte-level regression guard: compile `p.x = 123` and scan PRG
for `LDA #\$7B / STA <addr>`. Fails against the old silently-dropping
codegen.
https://claude.ai/code/session_01AoQ678uVeqpyayvWHpfDhC
Both examples declared their gameplay variables at the top level
even though every read and write happened inside one specific state.
That pattern hid the overlay feature from new users and kept the
state-local code path from being exercised outside the dedicated
`state_machine.ne` demo (which is how the "state-locals silently
drop their writes" bug survived so long).
`coin_cavern.ne`: the five Playing-only physics/position/inventory
vars (`player_x`, `player_y`, `player_vy`, `on_ground`,
`coins_left`) move onto Playing's state block. `score` stays
global because GameOver-era code could reasonably grow to read it.
The `on_enter` body loses its redundant resets — the declared
initializers on the state-locals re-run on every entry, so
retrying after `transition Title` comes back to a fresh state.
`platformer.ne`: player physics, camera, liveness, animation
phase, and the autopilot budget (`player_y`, `on_ground`,
`rise_count`, `fall_vy`, `camera_x`, `anim_tick`, `alive`,
`auto_jumps`) all move onto Playing. `frame_tick` and
`stomp_count` stay global — Title reads the former to
auto-advance, GameOver reads the latter to tally coins on the
death screen. The analyzer now overlays Title's `blink`,
Playing's eight physics vars, and GameOver's `linger` starting
at the same ZP byte (`$1A`), so the three scenes share a
9-byte window instead of each claiming their own slots.
Byte-level ROM bytes for both examples shift because variable
addresses moved. Video goldens stay pixel-identical (the harness
doesn't see Playing in coin_cavern, and the pre-transition
Title→Playing timing in platformer is preserved); the platformer
audio hash needed one more refresh because the now-slightly-shorter
reset prologue shifts APU writes within each frame.
https://claude.ai/code/session_015kvJu3iEFLSRJoShPBfm3X
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
The `seg.ooffs` field in our ca65 .dbg output was off by 16 — it was
emitting the PRG-relative fixed-bank offset when ca65's convention
(and Mesen's DbgImporter.cs:301 math:
`Address = val - seg.start + ooffs - headerSize`) expects the raw
output-file offset, *including* the iNES header. The practical
consequence: every label Mesen resolved via the .dbg was 16 bytes
short of its true PRG offset, which silently corrupted source-line
mapping for the first bytes of each function.
Fix is a one-liner — drop the `saturating_sub(16)` and feed
`linked.fixed_bank_file_offset` straight into the ooffs field. Unit
tests in debug_symbols.rs updated to assert the new values (ooffs=16
for NROM, 16+16K*N for banked).
The Mesen probe (`tests/mesen/probe.lua`) is expanded in the same
change, because the sabotage test that caught this bug is also the
cleanest demonstration the probe is working:
* checks all four entry-point labels resolve and land inside the
fixed bank's CPU window ($C000-$FFFF);
* asserts the linker's relative ordering (main_loop < Main_frame
< nmi);
* registers a startFrame callback, waits three frames, and verifies
PC is still in the fixed bank + that `emu.read(main_loop.address,
nesPrgRom)` returns 0xA5 (the LDA-zp opcode the runtime always
places as main_loop's first instruction). The 0xA5 constant is
what catches the ooffs regression — a less-specific "not 0xFF"
check coincidentally passed even with ooffs=0 because the shifted
address still landed on real code.
Verified locally by running the probe against hello_sprite's ROM
with four different `.dbg` mutations and confirming each triggers
the expected exit code.
https://claude.ai/code/session_01DfN3pKJLryr7vvNFBpcqmC
Move the Mesen2 .dbg validation job from its own workflow file into
the existing ci.yml so all CI lives in one place. Same job content
and same workarounds (settings.json shim, GLOBALIZATION_INVARIANT,
xvfb-run) — only the file location changed. The MESEN_VERSION env
var moves up to the workflow-level env block.
https://claude.ai/code/session_01DfN3pKJLryr7vvNFBpcqmC
Run Mesen2's `--testRunner` mode in CI, point it at a NEScript-built
ROM + auto-loaded .dbg, and assert via Lua that the four entry-point
labels (`nmi`, `irq`, `Main_frame`, `main_loop`) we promised to emit
actually resolve. Failures encode which assertion broke into the exit
code so CI can report it without stdout (Mesen's emu.log is internal).
The setup needed three workarounds, each documented inline in the
workflow:
* `touch settings.json` to skip Mesen's first-run GUI wizard, which
blocks the --testRunner dispatch in Program.cs:74. Contents don't
matter — Configuration.Deserialize falls back to defaults on parse
error.
* `DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1` to keep .NET from loading
system libstdc++ via libicuuc. On Ubuntu 24.04 the dual libstdc++
presence (system + MesenCore.so's bundled static copy) crashes
MesenCore's static regex initialiser with std::bad_cast before any
user code runs.
* `xvfb-run` because Mesen's Avalonia UI calls XOpenDisplay before
--testRunner is dispatched.
This is a separate workflow file from ci.yml because it depends on the
40 MB Mesen2 binary download + xvfb + sdl2, none of which the existing
jobs need. Cached by Mesen version so reruns are fast.
https://claude.ai/code/session_01DfN3pKJLryr7vvNFBpcqmC
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
Move the six gate-marker label emissions (__mul_used, __div_used,
__oam_used, __default_sprite_used, __p1_input_used, __p2_input_used)
out of the inline IR-op lowering paths and into a new
`emit_trailing_markers()` helper that runs once at the end of
`generate()`. The IR walk now just flips a bool per marker; the
label emit happens after every instruction has been lowered, so
the marker never lands in the middle of a peephole-sensitive
sequence.
Fixes a real peephole interaction that surfaced after rebasing on
main's `codegen: skip parameter-spill prologue for leaf functions`
+ `peephole: drop dead LDA #imm before mem-INC/DEC + JMP`
improvements: an inline `__oam_used:` label inside `IrOp::DrawSprite`
split the dead-load-elimination block, leaving the `STA $130 /
LDA $130` redundant store+load pair that main's peephole would
otherwise have collapsed to a plain `LDA #imm`. The stale bytes
shifted the NMI handler by a few bytes, which shifted `on frame`
execution enough that `examples/palette_and_background.ne` captured
phase 1 (WarmReds) at frame 180 instead of phase 2 (CoolBlues).
Regenerates every example ROM against the new codegen (all gate
behaviour is unchanged — the linker still sees the same markers,
just at the tail of the user stream instead of interleaved) and
updates the goldens that shifted: seven audio-hash drifts (all
audio-bearing programs, same cycle-accurate-APU-timing story as
every prior NMI layout change) and two pixel goldens — the one-
pixel sprite-position drift in `comparisons.png` that we already
tolerate, plus the phase-capture flip in
`palette_and_background.png`.
https://claude.ai/code/session_016kM6P7PukktBDqTZexrrAN
* Tighten the OAM-DMA gate: the `has_oam` flag now ORs
`__sprite_cycle_used` in addition to `__oam_used`. A hypothetical
program that calls `cycle_sprites` without ever drawing would
otherwise compile to an NMI that advances \$07EF each frame but
never actually runs the DMA the cycling is meant to perturb.
The stronger gate keeps the two markers semantically coupled
(cycling presupposes DMA) and adds a test that verifies the
DMA trigger is emitted for a cycle-only program.
* Drop the unused `NmiOptions::any_input()` helper. The only
consumer (`gen_nmi`) reads the two flags inline and I never
wired up a second caller.
* Fix the cycle-count claim in `NmiOptions::has_p2_input` /
`has_p1_input` docstrings: LDA abs + LSR A + ROL zp is 11
cycles per port, ×8 loop iterations = ~88 cycles, not the "~30"
I wrote in the original commit. Also notes the ~12-cycle strobe
+ scaffold overhead that disappears when both ports are unused.
No behavioural change — all 622 Rust tests and 33 emulator
goldens still pass unchanged.
https://claude.ai/code/session_016kM6P7PukktBDqTZexrrAN
With `has_p1_input` false, drop the three-instruction JOY1 shift
block from the NMI's input loop. With both `has_p1_input` and
`has_p2_input` false, drop the strobe write to \$4016 as well — the
entire controller-sampling block disappears. Audio- or compute-only
programs that never touch `button.*` pay zero cycles for input
sampling.
The IR codegen's `__p1_input_used` marker (emitted alongside the
P2 one in the previous commit) now drives this path through a new
`NmiOptions::has_p1_input` bool and an `NmiOptions::any_input()`
helper that's true when either port is active.
Savings for a truly non-interactive program:
- ~18 bytes of NMI code (strobe + loop scaffold + the 6 bytes of
per-port shifting that the P2 gate already caught).
- ~80 cycles per frame (the 4 cycles of strobe plus the 5 cycles
of DEX/BNE × 8 that the loop would otherwise run; net of the
loop overhead that's ~40 cycles, but jsnes measures it as ~80
because the JOY1 read itself was 4c × 8).
Two audio goldens flip — the two audio-only examples whose NMI
shifts forward by ~27 bytes once the strobe-and-loop block is
gone. Same cycle-accurate-APU-timing drift as every prior NMI
layout change.
https://claude.ai/code/session_016kM6P7PukktBDqTZexrrAN
Drop the three-instruction JOY2 shift block (`LDA $4017 / LSR A /
ROL ZP_INPUT_P2`) from inside the NMI's 8-iteration input loop
when user code never reads controller 2. IR codegen emits the
`__p2_input_used` marker from `IrOp::ReadInput(_, 1)`; the linker
threads the flag through a new `NmiOptions::has_p2_input` bool,
and `gen_nmi` writes the shift block only when the flag is set.
Savings for single-player programs:
- ~6 bytes of NMI code.
- ~30 cycles per frame (3 instructions × 8 loop iterations, each
6-8 cycles depending on addressing — LDA abs is 4, LSR A is 2,
ROL zp is 5, so ~11 cycles × 8 = ~88 cycles; rounded down for
the page-crossing penalty landing differently in the new layout).
This commit also fixes the IR codegen to drop the matching
`__p1_input_used` marker from `IrOp::ReadInput(_, 0)`, even though
the next commit is the one that actually consumes it. Landing the
two markers together keeps the IR codegen's per-op bookkeeping
coherent.
Six audio goldens flip (every program that reads input + plays
audio) with the expected NMI-layout-shift cycle drift.
https://claude.ai/code/session_016kM6P7PukktBDqTZexrrAN
Skip the OAM DMA (LDA#0/STA \$2003 + LDA#2/STA \$4014) inside the
NMI handler and the `\$FE` hide-sentinel fill of the \$0200 OAM
shadow inside `gen_init` for programs that never `draw`. Both are
gated on the `__oam_used` marker the IR codegen now drops at the
first `IrOp::DrawSprite`.
Savings per NMI for a non-drawing program:
- ~520 cycles (the DMA is 513 cycles plus the 4 register writes),
- ~9 bytes of NMI code,
- ~4 bytes of init code (the \$FE swap is replaced by a plain
zero-fill of \$0200-\$02FF alongside the rest of the 2 KB RAM
clear).
Plumbed by:
- New `NmiOptions::has_oam: bool`, threaded through `gen_nmi`.
- `gen_init(has_oam: bool)` parameter controlling the inner-loop
OAM fill. Existing runtime tests all migrate to `gen_init(true)`
to preserve their legacy assertions.
- Linker computes `has_oam = has_label(user_code, "__oam_used")`
once and feeds it to both call sites, and the existing
`has_visual_output` predicate reuses the same lookup rather than
re-scanning user_code.
sfx_pitch_envelope is the one audio-only example; its audio
golden flips by the usual cycle-accurate-APU-register-write-timing
drift caused by the NMI layout shifting ~14 bytes earlier.
https://claude.ai/code/session_016kM6P7PukktBDqTZexrrAN
Drop the built-in smiley from CHR tile 0 unless something in the
program actually references it. The marker fires when either:
1. `IrOp::DrawSprite` lowering falls back to tile 0 because the
sprite name doesn't resolve to a user declaration, or
2. The same lowering sees a runtime `frame:` override (which
could index any tile, including 0).
A third source of dependency — a background nametable entry of 0 —
is detected in the linker by scanning `bg.tiles` for zeros. This
preserves the smiley for programs like `examples/friendly_assets`
that use tile 0 as a background placeholder, even though their
draws resolve to user-declared sprites.
Programs whose draws all resolve to explicitly-declared sprites
with static frames AND whose backgrounds reference tiles 1+ now
leave CHR tile 0 as an all-zero blank, freeing 16 CHR bytes that
the user can treat as an always-transparent background tile.
Verified against the current example set: `sprites_and_palettes`
and `auto_chr_background` reclaim tile 0; every other example
keeps it (either they fall back to tile 0 via an undeclared draw
name or their background tilemap references tile 0).
All 33 emulator goldens still pass — removing an unreferenced CHR
tile can't change observable output.
https://claude.ai/code/session_016kM6P7PukktBDqTZexrrAN
Add an `__oam_used` marker dropped by IrOp::DrawSprite codegen, and
compute a `has_visual_output` flag in the linker from the marker
plus the presence of any user palette / sprite / background. When
that flag is false — i.e. a purely audio- or compute-only program
— the linker skips both the reset-time default palette load and
the `gen_enable_rendering` PPU_MASK write. `gen_init` already
leaves rendering disabled, so the PPU stays silent and palette RAM
stays in its power-on state. ~72 bytes reclaimed for non-visual
programs.
Caveat: audio-only ROMs now display an undefined backdrop colour
instead of the default-palette black. jsnes renders that as a
mid-grey; Mesen/real hardware may vary. Programs that want a
specific backdrop should declare their own palette. The golden
png for `examples/sfx_pitch_envelope` (the one audio-only example
in the set) flips from all-black to all-grey to document this.
`__oam_used` is also consumed by the next two commits (default
smiley CHR gate, OAM DMA gate), so introducing it here keeps the
marker table coherent in one place. Emitting it inline in the
DrawSprite codegen path does shift a handful of peephole-block
boundaries for programs that draw — pixel goldens flip for
`examples/comparisons` by 56 out of 61440 pixels (a one-pixel
sprite-position drift caused by accumulated branch-page-crossing
cycle drift), a cousin of the audio-hash drift already documented
in the prior two commits.
https://claude.ai/code/session_016kM6P7PukktBDqTZexrrAN
The reset-time "no user palette" path was emitting 32 unrolled
`LDA #imm / STA $2007` pairs (~170 bytes) to write the built-in
palette. Replace it with the same indirect-loop loader the
user-palette path already uses (runtime::gen_initial_palette_load),
with the 32-byte default palette spliced into PRG under a
`__default_palette` data block. Net saving is ~120 bytes — ~20
bytes of code + 32 bytes of data vs ~170 bytes of unrolled stores.
Delete `Linker::gen_palette_load` (dead after the refactor) and its
unit test. Replace with two tests covering the observable
behaviour: the default palette bytes appear in PRG when no user
palette is declared, and the `__default_palette` label is
suppressed when the user does declare a palette.
Audio goldens flip again for audio_demo, noise_triangle_sfx, and
sfx_pitch_envelope. These are the three audio examples that don't
declare their own palette — shrinking the default-palette load
shifts their audio tick's absolute address by ~120 bytes, which
changes branch page-crossing timing and therefore the exact APU
register write sample offsets. Same class of drift as the
mul/divide gating commit.
https://claude.ai/code/session_016kM6P7PukktBDqTZexrrAN
Drop __mul_used from IrOp::Mul codegen and __div_used from IrOp::Div
/ IrOp::Mod codegen (modulo reuses the same routine). The linker
skips gen_multiply / gen_divide for programs that never emit the
markers, following the same pattern already used by __audio_used /
__ppu_update_used / __sprite_cycle_used.
The optimizer already rewrites multiplies and divides by constant
powers of two into shifts (and modulo by constant powers of two
into masks), so the markers only fire for genuinely runtime math.
A program like `examples/comparisons.ne` that never multiplies or
divides now reclaims ~56 bytes of PRG; programs that use only one
of the two reclaim the other's share.
Audio goldens flip for every example that uses audio. The .ne
sources are unchanged and the pixel goldens are byte-identical —
the audio stream differs only because removing the math routines
shifts the audio tick's absolute address in PRG by 56 bytes, which
changes which of its internal branches cross 6502 page boundaries
and therefore the per-frame cycle count of a single NMI by 1-5
clocks. Over 180 frames the accumulated drift shifts APU register
write timing enough to render a different digital sample stream
at the same logical wave shape. Expected consequence of ROM-layout
change under cycle-accurate emulation; documented path per
CLAUDE.md "Updating goldens".
https://claude.ai/code/session_016kM6P7PukktBDqTZexrrAN
Anchored on commit 33640f8; the milestone is closed:
#1 33640f8..0b5470b codegen: skip leaf prologue spill
#4 0b5470b..726faef sha256: specialize rotr_wk per amount
#2 726faef..0600f5b codegen: fuse compare-then-branch
#3df71c2b peephole: drop dead LDA #imm
#6f2623cb sha256/computing: track byte offsets directly
#54afd196 ir: inline-asm {param} substitution after splice
6696d79 codegen+ir: code-review followups (UTF-8, leaf,
tests)
The how-it-works writeups live in each commit's message. The
explanatory inline comments in `IrCodeGen::new` (leaf detection),
`gen_block` (cmp/branch fusion), `peephole.rs::remove_dead_loads`
(JMP-following), `try_inline_call_stmt` (inline-asm const-arg
constraint), and the new `function_is_leaf_detects_jsr_emitting_ops`
+ `inline_fun_with_asm_param_*` tests are the ongoing reference.
https://claude.ai/code/session_01FRmSBruVWCufm3LsUVMs8v
Three follow-ups from a fresh review of the perf milestone:
1. **UTF-8 safety in `substitute_asm_vars` and
`substitute_inline_const_params`.** Both walked the asm body
byte-by-byte and emitted each non-substituted byte via
`out.push(bytes[i] as char)` — a Latin-1 reinterpretation that
mangles non-ASCII characters in inline-asm comments. The brace-
level scan stays byte-based (braces can't appear inside a UTF-8
continuation), but the verbatim copy now uses
`out.push_str(&body[i..i + ch_len])` with `ch_len` derived from
the lead byte. Pre-existing latent bug in `substitute_asm_vars`,
freshly introduced in `substitute_inline_const_params` —
fixed in both, with a shared lead-byte length helper.
2. **`function_is_leaf` is now exhaustive on `IrOp`.** The match
used to be selective: `Call`/`Mul`/`Div`/`Mod`/`Transition`/
`InlineAsm` were checked, everything else fell through with
`_ => {}`. A new variant added later that secretly emitted a
JSR (e.g. a future `Mul16` calling `__multiply16`) would have
silently broken any leaf function that touched it. Listed
every current variant explicitly so the compiler errors at
the match arm if a new variant ships, and added a
`function_is_leaf_detects_jsr_emitting_ops` test that walks
the known JSR-emitting constructs (Call, *, /, %, asm with
JSR token) and asserts each disqualifies leafness.
3. **Cleanups.** `gen_block` now binds the fused-cmp dest temp
inside the original tuple instead of re-matching
`block.ops.last().unwrap()` to retire it. New
`inline_fun_with_asm_param_cascades_through_nested_inline`
test exercises the eval_const → const_args_stack path that
lets the inner of two nested inline funs see its outer's
parameter as the constant the top-level call passed. Defensive
comment on `body_has_inline_asm` explaining why it deliberately
doesn't recurse (relies on `is_splicable_void_stmt`'s
no-control-flow guarantee).
ROMs and goldens unchanged — all the changes are non-observable
through the existing example surface. Verified: cargo
test/clippy/fmt clean on rustc 1.95.0; emulator harness 34/34;
reproducibility diff clean; demo gifs byte-match fresh captures.
https://claude.ai/code/session_01FRmSBruVWCufm3LsUVMs8v
`inline fun` worked for plain-NEScript bodies but blew up on any
function that used inline asm with `{name}` substitution. The
codegen's `substitute_asm_vars` runs against the analyzer's
per-function scope; after splicing, the scope is the *caller*,
where the inlined fun's parameters don't exist. The result was
`{dst}` left as a literal token in the spliced asm body, and
the asm parser failing with `bad number `{dst}``.
Fix: at inline-expansion time (`try_inline_call_stmt`), when the
splicer detects a `Statement::InlineAsm` in the body and the
call site passed a compile-time constant for a parameter,
pre-substitute `{param}` with `#$<value>` so the spliced body
parses as an immediate-mode operand. The substitution is done
via a new `inline_const_args_stack` parallel to the existing
`inline_subs_stack`, populated from the args' `eval_const`
results.
When *any* arg is non-constant the splicer refuses to inline an
asm-containing function and falls back to a regular `Call` op —
preserving correctness, just at the cost of the JSR/RTS apparatus
the user was hoping to avoid. The fallback is exercised by the
new `inline_fun_with_asm_falls_back_for_runtime_arg` test.
`eval_const` is also extended to consult the same const-args
stack, so a nested inline like `inline_outer(K)` →
`inner(outer_param)` correctly recognises `outer_param` as the
constant `K` and recurses the substitution. Without this, the
cascade stopped at the first level.
Two new tests in `src/ir/tests.rs` lock in the behaviour:
- `inline_fun_with_asm_param_substitutes_immediate` — verifies
`{param}` becomes `#$<value>` in the spliced body and no
Call op is left.
- `inline_fun_with_asm_falls_back_for_runtime_arg` — verifies
the fallback path emits a Call op.
The SHA-256 example doesn't itself opt into the new feature for
its primitives (full inlining would balloon ROM by 5-10 KB —
the call sites add up fast at 1500+ source-level uses); that's
left as a future opportunity. Hash output unchanged; emulator
harness 34/34; reproducibility diff clean.
https://claude.ai/code/session_01FRmSBruVWCufm3LsUVMs8v
The phased compression driver was computing the schedule/round
*index* per iteration and then shifting it left by 2 to get the
byte offset (`schedule_one(i << 2)`, `round_one(r << 2)`). The
shift compiles to two ASLs per iteration — cheap, but pure dead
work since the byte offset is just the previous one + 4.
Track the byte offset as the loop counter and bump it by 4 each
iteration. The schedule and round APIs already wanted byte
offsets, so the call sites also get a touch shorter (no more
intermediate `var i: u8 = first_idx + step`).
Strict cycle savings are tiny — a handful per iteration — so
this is more about not leaving obviously redundant work in the
inner loop than a meaningful perf win. Hash output unchanged
(still AE9145DB…4E0D for "NES"); no other examples affected;
emulator harness 34/34.
https://claude.ai/code/session_01FRmSBruVWCufm3LsUVMs8v
The IR codegen lowers `i -= 1` (and friends) into a `LoadImm temp,
1; Sub d, i, temp; StoreVar i, d` triple, and the optimizer
strength-reduces the Sub+StoreVar pair into `DEC i`. The
constant-load-into-A that used to feed the Sub stays around as a
dead `LDA #1`:
LDA #1
DEC ZeroPage(rem)
JMP Label("__ir_blk_while_cond_…")
`remove_dead_loads` was set up to drop exactly this pattern but
gave up at the trailing `JMP` because it couldn't reason about
flow. Extend it to follow one unconditional `JMP <label>` to its
target and resume the dead-store scan from the next instruction.
The first instruction past the loop-condition label is reliably an
`LDA loop_var`, which overwrites A without reading it — so the
`LDA #1` is correctly identified as dead.
Conditional branches still end the scan (their not-taken path is
unconstrained) and only one JMP is followed (to keep the analysis
local). For SHA-256 specifically this drops two `LDA #1`s per
iteration of the rotate/shift bit-loops — about 1K cycles per
block. The same pattern fires across most examples' loop tails.
Verified: cargo test/clippy/fmt clean on rustc 1.95.0; emulator
harness 34/34; reproducibility diff clean; SHA-256 of "NES" still
computes to AE9145DB…4E0D. The cycle drift refreshes the four
audio hashes / golden frames timing-sensitive examples already
tracked.
https://claude.ai/code/session_01FRmSBruVWCufm3LsUVMs8v