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
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
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
Commit 76d0fd0 moved function-locals from a codegen-minted
`$0300+` absolute range into the analyzer's zero-page
allocations so inline-asm `{param}` substitutions resolve
correctly (compiler-bugs.md #1). Observable semantics are
preserved — the analyzer + codegen now agree, and every
primitive that used to work still does — but the emitted ROM
bytes change whenever a function reads or writes a local,
because zero-page addressing uses a 2-byte instruction and
absolute addressing uses 3.
Consequences that need regenerated artifacts:
- **Twelve committed `.nes` files are stale.** Same source, new
compiler, different bytes. The `Build Examples` CI job
rebuilds each example into a tmp path and diffs against the
committed ROM, so any drift is a hard failure. Rebuilt all
twelve (arrays_and_functions, bitwise_ops, coin_cavern,
function_chain, loop_break_continue, mmc1_banked, platformer,
pong, sprites_and_palettes, state_machine, structs_enums_for,
war).
- **Three goldens drift by one animation frame.** Zero-page
addressing shaves a cycle per local access, which over a full
frame handler shifts timing-sensitive sequences by a cycle or
two. war's dealing animation and platformer + pong's audio
tick stream catch the shift at frame 180 — war's card under
player A's deck is now one frame earlier in its slide, and all
three programs' captured audio buffers start from slightly
different envelope positions. The new goldens (`war.png` + the
three `.audio.hash` files) reflect the same code compiled with
the cycle-count-corrected primitives.
- **`platformer.gif` and `war.gif` rebuild.** Same one-frame
timing drift, integrated across 360 frames of captured
gameplay — the emulator job's gif-reproducibility check
wouldn't pass without the refresh. `pong.gif` happened to
byte-match the old capture after rebuild.
All verified:
- `cargo clippy --all-targets -- -D warnings` clean on both
rustc 1.94.1 and 1.95.0.
- `cargo test --all-targets` — 616 + 3 + 75 tests pass.
- Full emulator harness — 34/34 ROMs match goldens.
- Committed-ROM reproducibility diff clean — every
`examples/*.ne` compiles byte-identical to its committed
`.nes`.
- `docs/{platformer,war,pong}.gif` byte-match fresh captures.
- SHA-256 of "NES" still computes to `AE9145DB…4E0D`.
https://claude.ai/code/session_01FRmSBruVWCufm3LsUVMs8v
The compiler is deterministic: rebuilding any example produces
a byte-identical ROM, verified across all 22 examples and all
four mappers (NROM, MMC1, UxROM, MMC3). That means the .nes
files are reproducible artefacts and can live next to their
sources without drift.
Benefits:
- Users can clone the repo and open any example in an emulator
without installing a Rust toolchain or running the compiler.
- The emulator harness can trust examples/*.nes directly, so its
CI job no longer needs a compiler build or a "compile all
examples" loop — it just boots jsnes against the committed
ROMs and diffs each against its golden.
- ROM diffs in PRs are now meaningful: "this compiler change
flipped 17 bytes in hello_sprite.nes" is visible review
signal, not hidden behind the emulator golden.
Guard rails so the ROMs don't drift from their sources:
- .gitignore no longer excludes *.nes.
- The `examples` CI job rebuilds every .ne into /tmp and fails
loudly (with a GitHub error annotation pointing at the exact
cargo command to rerun) if any committed ROM differs.
- scripts/pre-commit does the same check locally.
- CLAUDE.md now states that editing a .ne file requires
rebuilding its .nes in the same commit, so future agents
won't miss the invariant.
Total footprint: 22 ROMs, 624 KB (avg 28 KB each — most are
NROM 24 KB; two banked examples are larger).
https://claude.ai/code/session_01BcCcHi6FUmTh8jC7UgkA3A