1
0
Fork 0
mirror of https://github.com/imjasonh/nescript synced 2026-07-09 09:18:01 +00:00
Commit graph

3 commits

Author SHA1 Message Date
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