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

6 commits

Author SHA1 Message Date
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
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
76d0fd0d28
codegen: reuse analyzer's local allocations so inline asm {param} works
Fixes compiler-bugs.md #1 — the inline-asm `{name}` resolver
looks parameters up in the analyzer's `VarAllocation` table
(because that's the only address map it has), but `IrCodeGen::new`
was minting a parallel `$0300+` range for every function-local and
ignoring what the analyzer had picked. The spill prologue wrote the
param to the codegen's private address, the inline asm read from
the analyzer's zero-page address, and nothing ever bridged the two
— `LDA {param}` would silently load whatever the RAM clear left at
the stale slot (always `0`).

Fix: drop the `local_ram_next` loop and just look each local up in
`allocations` by the analyzer's qualified name
(`__local__{scope}__{local}`). The scope string that `gen_function`
already computed for `substitute_asm_vars` is now shared with the
new address-seeding loop via a `scope_prefix_for_fn(&str)` helper,
so the two call sites can't drift. The analyzer's layout already
satisfies the "no overlapping live locals" invariant the codegen
was relying on — it scopes every local under
`__local__<scope>__<name>` so two functions with a parameter named
`x` land in different slots.

Updated `gen_function_prologue_spills_params_to_local_ram`: the
regression test for the War-era param clobbering bug was asserting
the spill's destination specifically had to be an absolute address
at `$0300+`. That's no longer the mechanism — the spill lands in
whatever slot the analyzer assigned, which is zero page when
there's room. The test now asserts the destination is *any*
address outside `$04-$07`, which is the actual invariant.

Reverted the `LDX $04` / `LDY $05` workaround in
`examples/sha256/sha_core.ne` — every primitive there now uses
`{dst}` / `{src}` / `{w_ofs}` / `{h_ofs}` / `{k_ofs}` substitution
as originally intended. The "Parameter convention" comment that
documented the workaround is gone.

Regenerated `tests/emulator/goldens/inline_asm_demo.png`: that
example's `times_four(input)` was previously returning `input`
verbatim because the inline asm's `LDA {result}` / `ASL A` /
`ASL A` / `STA {result}` operated on a zero-page byte that was
disconnected from the NEScript-level `result` variable. With the
fix, `times_four` correctly returns `input * 4`, so the
smiley-tracker's frame-180 position shifts by the expected
`(frame_count * 4) mod 256` delta. The other 33 ROMs remain
byte-identical.

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.
  - `cargo fmt --check` clean.
  - Full emulator harness: 34/34 ROMs match goldens.
  - SHA-256 of "NES" still computes to
    `AE9145DB5CABC41FE34B54E34AF8881F462362EA20FD8F861B26532FFBB84E0D`.
  - `--memory-map` output now reflects what the generated code
    actually reads and writes (previously the codegen's $0300+
    override was invisible to the dump).

https://claude.ai/code/session_01FRmSBruVWCufm3LsUVMs8v
2026-04-16 16:03:10 +00:00
Claude
d6cb84a5bd
compiler: close out bug #4 (W0109 sprite-per-scanline) and bug #5 (real inlining)
Fixes the last two deferred compiler bugs catalogued in
examples/war/COMPILER_BUGS.md, finishing the bug-cleanup arc on
the War branch.

Bug #5 — `inline fun` inliner
  Previously the `inline` keyword was parsed into `FunDecl.is_inline`
  and then dropped on the floor: every call site emitted a regular
  `JSR` through the $04-$07 transport slots. Now the IR lowerer
  captures inline function bodies up front in
  `LoweringContext::capture_inline_bodies` and rewrites call sites
  at lowering time. Two body shapes are supported:

    1. Single-return expression — the body is re-lowered in place
       of the `Call` op with the parameter names substituted to
       fresh IR temps for each argument.
    2. Void multi-statement body whose every statement is one of
       Assign/Call/Draw/Scroll/SetPalette/LoadBackground/WaitFrame/
       Play/StartMusic/StopMusic/InlineAsm/RawAsm/DebugLog/DebugAssert
       — the statements are spliced into the caller's block with
       the same parameter substitution machinery.

  Control-flow-heavy inline bodies (conditional early returns,
  loops, transitions) fall back to a regular out-of-line call with
  no diagnostic. That's predictable and documented in the bug-tracking
  doc. Nested inline expansion uses a substitution-frame stack so
  an inline calling another inline sees the right arguments.

  A codegen follow-up was needed because bug #3's scope-qualified
  local names broke `{result}` substitution in inline asm. The
  codegen now tracks `current_fn_scope_prefix` per function and the
  InlineAsm op tries the qualified name first before falling back
  to the bare name.

Bug #4 — W0109 sprite-per-scanline static check
  Adds a new warning code W0109 and an analyzer pass
  `check_sprite_scanline_budget` that walks each state's `on_frame`
  handler, collects literal-coordinate `draw` statements (including
  metasprite expansion via dx/dy offsets), and iterates scanlines
  0..240 to count how many 8x8 sprites overlap each line. When a
  scanline has > 8, the analyzer emits W0109 with labels pointing
  at each offending draw site plus a help message about staggering
  y-rows and a note explaining the hardware dropout. Non-literal
  coordinates are skipped (static analysis can't resolve them).
  Nested `if`/`while`/`for`/`loop` blocks are unioned conservatively.

Tests added
  src/ir/tests.rs
    - inline_fun_expression_body_emits_no_call_at_use_site
    - inline_fun_void_body_statements_are_spliced
    - inline_fun_with_conditional_return_compiles_as_regular_call
    - inline_fun_nested_inlines_substitute_correctly
  src/analyzer/tests.rs
    - analyze_sprite_scanline_budget_warns_over_eight
    - analyze_sprite_scanline_budget_ok_when_staggered
    - analyze_sprite_scanline_budget_skips_dynamic_coords
    - analyze_sprite_scanline_budget_expands_metasprites
    - analyze_sprite_scanline_budget_recurses_into_if

COMPILER_BUGS.md
  Bugs #4 and #5 marked **FIXED** in the status table, with full
  reproduction/root-cause/fix/regression-test write-ups updated in
  place. All seven catalogued bugs now have shipped fixes.

Artifact churn
  - examples/war.nes and examples/inline_asm_demo.nes rebuild
    byte-shifted (different JSR targets post-inliner).
  - tests/emulator/goldens/war.audio.hash shifts from 143660f to
    13443e28 — the inliner removes JSRs to set_phase, which nudges
    NMI sampling timing. No pixel diff; behavior is unchanged.

https://claude.ai/code/session_0143dTgh3UeRrtfHgQwzcv5z
2026-04-15 21:33:00 +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