1
0
Fork 0
mirror of https://github.com/imjasonh/nescript synced 2026-07-09 17:28:00 +00:00
nescript/examples/war/deck.ne

173 lines
4.9 KiB
Text
Raw Normal View History

2026-04-15 15:22:20 +00:00
// war/deck.ne — queue operations on the three u8[52] buffers.
//
// Each of deck_a, deck_b, and pot is a 52-entry circular buffer
// storing packed rank/suit bytes. The front index is the next
// card to draw; the count is the number of cards currently in
// the buffer. Everything wraps mod 52 — since 52 isn't a power
// of two, we use `if idx >= 52 { idx -= 52 }` instead of `%` to
// avoid the expensive software mod routine.
// ── Helpers shared by every deck ──────────────────────────
//
// Wrap a (front + count) sum back into the 0..51 range. Only
// needed inside push_back because `front` and `count` are both
// ≤ 51 by construction, so the sum is at most 102 and one
// subtraction is enough.
W0110 inline fallback warning + docs refresh W0110: when a function marked `inline` has a body shape the IR lowerer can't splice (conditional early return, loops, nested control flow, empty void body), the analyzer now emits a warning at the declaration site so the declined hint is visible instead of silently falling back to a regular JSR. Implementation: - New `W0110` error code in `src/errors/diagnostic.rs` (warning level). - New `pub fn can_inline_fun(return_type, body) -> bool` in `src/ir/lowering.rs`, extracted from the existing capture logic so the analyzer and the IR lowerer share the same eligibility rules and can never drift. - New `check_inline_declinability` analyzer pass called from the tail of `analyze_program`, mirroring the existing `check_sprite_scanline_budget` / `check_unreachable_states` passes. Emits W0110 with help + note text pointing at the two accepted body shapes. - `capture_inline_bodies` now defers to `can_inline_fun` instead of duplicating the match pattern, so the two sides stay in lockstep by construction. Four regression tests in `src/analyzer/tests.rs` cover the conditional-return and while-loop declines plus the two accepted shapes (single-return expression, void sequence). Example source cleanups: `wrap52` in `examples/war/deck.ne` and `abs_diff` in both `examples/arrays_and_functions.ne` and `examples/loop_break_continue.ne` drop the `inline` keyword. All three were dead hints — the `inline` was being silently declined before this change, so removing it is source-only; the three ROMs are byte-identical, all 32 emulator goldens still match. Docs refresh - `docs/language-guide.md`: rewrote the Inline Functions section (real behaviour + W0110), added W0105/W0106/W0107/W0108/W0109/ W0110 to the warnings table, added the `debug.sprite_overflow*` builtins + sprite-per-scanline mitigations section to the Debug Mode docs, added a `cycle_sprites` statement entry and cross-referenced it from `draw`. - `docs/nes-reference.md`: fleshed out the "NEScript Memory Usage" block with the full ZP + high-RAM layout, including the new `$07EF` / `$07FC` / `$07FD` slots for sprite cycling and the debug sprite-overflow telemetry. - `docs/future-work.md`: documented all four debug query builtins in the "What ships today" block; updated the open "OAM allocation strategy" question to reference the shipped `cycle_sprites` path and ask about an automatic-flicker game attribute as a follow-up. - `docs/architecture.md`: updated the `ir/` and `optimizer/` module summaries to describe real inline splicing (now in lowering, not the optimizer). - `README.md`: reframed the `inline` bullet from "hint" to "real splicing for single-return / void-body shapes"; expanded the debug-support bullet to mention the four query builtins and their stripping in release builds; added a new bullet for the three-layer sprite-per-scanline mitigations; bumped the test count from 497 → 694; updated the war.ne entry to mention the seven compiler bugs are all fixed and point readers at `git log` (instead of the deleted COMPILER_BUGS.md). - `examples/README.md`: same `git log`-pointing rewrite for the war.ne entry. Deletions - `examples/war/COMPILER_BUGS.md` is removed. All seven catalogued bugs are fixed; the file's historical value lives in `git log` now. Every source-code comment and doc reference to the file has been updated to either point at `git log` or just describe the bug in place. Test count: 616 unit + 75 integration + 3 doctests = 694 total. Clippy / fmt clean. 32/32 emulator goldens match. https://claude.ai/code/session_0143dTgh3UeRrtfHgQwzcv5z
2026-04-15 23:19:07 +00:00
//
// Not marked `inline`: the conditional early return is one of
// the body shapes the inliner declines (W0110), and living
// with the JSR is cheaper than rewriting this as `v % DECK_SIZE`
// (which would pull in the software modulo routine).
fun wrap52(v: u8) -> u8 {
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
if v >= DECK_SIZE {
return v - DECK_SIZE
2026-04-15 15:22:20 +00:00
}
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
return v
2026-04-15 15:22:20 +00:00
}
// ── deck_a ────────────────────────────────────────────────
fun deck_a_empty() -> u8 {
if deck_a_count == 0 {
return 1
}
return 0
}
fun draw_front_a() -> u8 {
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
var card: u8 = deck_a[deck_a_front]
2026-04-15 15:22:20 +00:00
deck_a_front = wrap52(deck_a_front + 1)
deck_a_count -= 1
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
return card
2026-04-15 15:22:20 +00:00
}
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
fun push_back_a(card: u8) {
var slot: u8 = wrap52(deck_a_front + deck_a_count)
deck_a[slot] = card
2026-04-15 15:22:20 +00:00
deck_a_count += 1
}
// ── deck_b ────────────────────────────────────────────────
fun deck_b_empty() -> u8 {
if deck_b_count == 0 {
return 1
}
return 0
}
fun draw_front_b() -> u8 {
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
var card: u8 = deck_b[deck_b_front]
2026-04-15 15:22:20 +00:00
deck_b_front = wrap52(deck_b_front + 1)
deck_b_count -= 1
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
return card
2026-04-15 15:22:20 +00:00
}
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
fun push_back_b(card: u8) {
var slot: u8 = wrap52(deck_b_front + deck_b_count)
deck_b[slot] = card
2026-04-15 15:22:20 +00:00
deck_b_count += 1
}
// ── pot ───────────────────────────────────────────────────
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
fun push_back_pot(card: u8) {
pot[pot_count] = card
2026-04-15 15:22:20 +00:00
pot_count += 1
}
fun clear_pot() {
pot_count = 0
}
// Transfer every card currently in the pot into deck_a, in FIFO
// order (so the face-up and face-down cards layer naturally).
fun pot_to_a() {
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
var i: u8 = 0
while i < pot_count {
push_back_a(pot[i])
i += 1
2026-04-15 15:22:20 +00:00
}
pot_count = 0
}
fun pot_to_b() {
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
var i: u8 = 0
while i < pot_count {
push_back_b(pot[i])
i += 1
2026-04-15 15:22:20 +00:00
}
pot_count = 0
}
// ── Init + shuffle ────────────────────────────────────────
//
// Build a 52-card "master deck" in deck_a's backing array using
// a rank-major loop: for each rank 1..13, each suit 0..3, write
// the packed byte (rank << 4) | suit into deck_a[i]. Then
// bounded-random-swap-shuffle it. Finally, split the first 26
// into deck_b (copied), leaving the second 26 in deck_a's first
// half, and reset both queues' cursors.
//
// The random-swap shuffle is a bounded alternative to
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
// Fisher-Yates: it does 200 swaps between two random indices,
// where each index is rand() & 0x3F (0..63) and the swap is
// only done when both indices are < 52. Uses only bitwise ops
// (no multiply, no divide).
2026-04-15 15:22:20 +00:00
fun build_master_deck() {
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
var r: u8 = 1
var i: u8 = 0
while r <= RANK_KING {
var s: u8 = 0
while s < 4 {
2026-04-15 15:22:20 +00:00
// Pack rank into the high nibble, suit into the low.
// rank fits in 4 bits (max 13) and suit fits in 2
// bits, so the shift-and-or is exact.
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
var packed: u8 = r << 4
deck_a[i] = packed | s
i += 1
s += 1
2026-04-15 15:22:20 +00:00
}
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
r += 1
2026-04-15 15:22:20 +00:00
}
}
fun shuffle_deck_a() {
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
var k: u8 = 0
while k < 200 {
var i: u8 = rand_u8() & 0x3F
var j: u8 = rand_u8() & 0x3F
if i < DECK_SIZE {
if j < DECK_SIZE {
var tmp: u8 = deck_a[i]
deck_a[i] = deck_a[j]
deck_a[j] = tmp
2026-04-15 15:22:20 +00:00
}
}
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
k += 1
2026-04-15 15:22:20 +00:00
}
}
// After shuffle, split deck_a's 52 cards into two halves: the
// first 26 stay in deck_a, the second 26 move into deck_b.
// Reset both queues' front/count cursors in the process.
fun split_decks() {
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
var i: u8 = 0
while i < HALF_DECK {
deck_b[i] = deck_a[HALF_DECK + i]
i += 1
2026-04-15 15:22:20 +00:00
}
deck_a_front = 0
deck_a_count = HALF_DECK
deck_b_front = 0
deck_b_count = HALF_DECK
pot_count = 0
}
fun init_and_shuffle_decks() {
build_master_deck()
shuffle_deck_a()
split_decks()
}