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

12 commits

Author SHA1 Message Date
Claude
0602fd9590
analyzer+codegen: lift the 4-param ceiling via a direct-write calling convention
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
2026-04-18 02:34:56 +00:00
Claude
c09f9c0caa
codegen: emit gate markers at end of generate() to protect peephole
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
2026-04-16 21:31:47 +00:00
Claude
53c454669d
runtime: gate controller-1 reads, skip whole input block when unused
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
2026-04-16 21:15:09 +00:00
Claude
0de1d60c33
runtime: gate controller-2 reads in NMI on __p2_input_used
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
2026-04-16 21:15:09 +00:00
Claude
7533ac281e
linker: skip default palette + rendering enable for non-visual ROMs
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
2026-04-16 21:15:08 +00:00
Claude
37974611ae
linker: shrink default palette load from inline stores to loop
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
2026-04-16 21:15:08 +00:00
Claude
033d399565
runtime: gate __multiply / __divide on usage markers
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
2026-04-16 21:15:08 +00:00
Claude
0600f5b872
codegen: fuse compare-then-branch to drop boolean materialization
Every NEScript condition (`if x < N`, `while i < end`, etc.)
lowers in two IR ops: `CmpX(d, a, b)` materializes a 0/1
boolean into temp `d`, and the block's terminator
`Branch(d, t, f)` reads `d` and branches on it. The codegen
faithfully emitted both halves — `LDA / CMP / branch-to-true /
LDA #0 / JMP done / true: LDA #1 / done:`, then later
`LDA d_slot / BNE branch_t / JMP branch_f` — about 14 cycles +
13 bytes per condition.

The 6502's natural pattern is one `CMP` + one branch on the
flags it just set: 8 cycles, no register-clobber, no temp slot.
Detect the canonical pattern in `gen_block` (last op is an 8-bit
`CmpX` whose dest temp is what the terminator branches on, with
no other uses) and emit the fused form directly via a new
`gen_cmp_branch` helper. The temp's allocation, store, load, and
the terminator's branch fall away.

Bookkeeping subtlety: the source temps `a`/`b` must be retired
*after* the fused emit, not before — the original `gen_op` order
is "emit body of op, then `retire_op_sources`". Decrementing
their use counts before the CMP would free their slots while
they were still live; `load_temp(a)` would then re-allocate `a`
to whatever stale slot the free list popped next. Got hit by
this on the first attempt — the SHA-256 example dutifully
returned all-zero hashes until the order was fixed.

Updated `ir_codegen_local_label_suffix_is_bank_namespaced`: the
test was relying on `if x == 0` to emit `__ir_cmp_*` labels for
its bank-namespacing check, which the fusion now collapses into
direct branches. Switched the test source to a shift-by-variable
pattern (`x = x << n`), which always emits `__ir_shift_loop_*`
labels regardless of future cmp/branch optimizations.

Cycle savings: ~6 cycles per condition. The SHA-256 rotate
loops alone account for ~9K cycles per block. Across all
examples the cycle drift shows up as audio-tick phase shifts
in five timing-sensitive ROMs (`audio_demo`, `friendly_assets`,
`noise_triangle_sfx`, `platformer`, `sfx_pitch_envelope`); the
goldens for those are refreshed in this commit, plus
`platformer.gif` (the only demo gif whose bytes actually moved).

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.

https://claude.ai/code/session_01FRmSBruVWCufm3LsUVMs8v
2026-04-16 17:10:02 +00:00
Claude
0b5470b054
codegen: skip parameter-spill prologue for leaf functions
Leaf functions — those that never JSR another routine from inside
their body — don't need to spill the `$04..$07` parameter
transport slots into per-function RAM, because nothing inside the
body clobbers those slots. Detect them in `IrCodeGen::new` via a
linear scan over each function's IR ops, point their parameters
at `$04..$07` directly in `var_addrs` (and in a parallel
`leaf_param_overrides` map for inline-asm `{name}` substitution),
and have `gen_function` skip the spill prologue.

The "leaf" predicate is conservative: any of `IrOp::Call`, `Mul`,
`Div`, `Mod`, `Transition`, or an inline-asm body containing a
`JSR` token disqualifies the function. SetPalette /
LoadBackground / PlaySfx / StartMusic / DebugLog / DebugAssert
were verified by inspection to not emit JSRs.

Per call to a leaf primitive: `LDA $04 / STA <local> / LDA $05 /
STA <local+1>` is now omitted — saves 12 cycles and 12 bytes of
code per call. Across the SHA-256 example's ~5500 leaf-primitive
calls per block, that's ~66K cycles saved per compression — about
2.2 frames at NTSC.

The fix also touches every committed `examples/*.nes` (the leaf
prologue was emitted by every fun with params, not just the SHA
ones), so 9 ROMs and the same three timing-sensitive goldens
(war.png + platformer/pong/war audio hashes) get refreshed; the
two committed gifs that drifted do too.

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.

https://claude.ai/code/session_01FRmSBruVWCufm3LsUVMs8v
2026-04-16 16:47:07 +00:00
Claude
20a244b9e7
examples: regenerate ROMs, gifs, and goldens after codegen local fix
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
2026-04-16 16:12:46 +00:00
Claude
76dd8eacb0
compiler: fix three scoping bugs; war: revert all local/param workarounds
Three related scoping bugs from examples/war/COMPILER_BUGS.md,
all fixed in one pass because they're different layer
manifestations of the same "flat global namespace" problem:

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

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

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

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

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

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

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

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

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

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

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

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

## War cleanup

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

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

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

## Regression tests

Seven new tests nail these bugs down:

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

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

## Golden drift

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

## What's still open in COMPILER_BUGS.md

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

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

Benefits:

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

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

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

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

https://claude.ai/code/session_01BcCcHi6FUmTh8jC7UgkA3A
2026-04-13 15:12:39 +00:00