1
0
Fork 0
mirror of https://github.com/imjasonh/nescript synced 2026-07-09 01:16:12 +00:00
nescript/examples/war/play_state.ne

298 lines
11 KiB
Text
Raw Normal View History

2026-04-15 15:22:20 +00:00
// war/play_state.ne — the Playing state and its inner phase machine.
//
// `phase` cycles through the P_* constants defined in
// war/constants.ne. `phase_timer` counts frames inside the
// current phase and is reset to 0 whenever the phase changes.
// Set the current phase and zero the timer in one shot. Inlined
// so each call site is just two stores.
inline fun set_phase(p: u8) {
phase = p
phase_timer = 0
}
// Draw the steady-state table furniture: the two deck card backs
// (if non-empty) and the running card counts. Used as the base
// layer every frame; phase-specific sprites are layered on top.
fun draw_table() {
if deck_a_count > 0 {
draw_card_back(DECK_A_X, DECK_Y)
}
if deck_b_count > 0 {
draw_card_back(DECK_B_X, DECK_Y)
}
draw_count(COUNT_A_X, COUNT_Y, deck_a_count)
draw_count(COUNT_B_X, COUNT_Y, deck_b_count)
}
// Bury helper for a war: move one card from the deck into the
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
// pot (face-down). Must be called with a non-empty deck.
2026-04-15 15:22:20 +00:00
fun bury_from_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 c: u8 = draw_front_a()
push_back_pot(c)
2026-04-15 15:22:20 +00:00
}
fun bury_from_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 c: u8 = draw_front_b()
push_back_pot(c)
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
// Draw the BIG WAR banner — three 16×16 metasprite letters at
// the centre of the screen. 12 sprites total, drawn in the
// centre row so they don't conflict with the deck stacks (rows
// 64-87) or the face-up cards (rows 128-151).
2026-04-15 15:22:20 +00:00
fun draw_big_war_banner(x: u8, y: u8) {
// BIG W
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
draw Tileset at: (x, y) frame: TILE_BIG_W_TL
draw Tileset at: (x + 8, y) frame: TILE_BIG_W_TR
draw Tileset at: (x, y + 8) frame: TILE_BIG_W_BL
draw Tileset at: (x + 8, y + 8) frame: TILE_BIG_W_BR
2026-04-15 15:22:20 +00:00
// BIG 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
draw Tileset at: (x + 20, y) frame: TILE_BIG_A_TL
draw Tileset at: (x + 28, y) frame: TILE_BIG_A_TR
draw Tileset at: (x + 20, y + 8) frame: TILE_BIG_A_BL
draw Tileset at: (x + 28, y + 8) frame: TILE_BIG_A_BR
2026-04-15 15:22:20 +00:00
// BIG R
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
draw Tileset at: (x + 40, y) frame: TILE_BIG_R_TL
draw Tileset at: (x + 48, y) frame: TILE_BIG_R_TR
draw Tileset at: (x + 40, y + 8) frame: TILE_BIG_R_BL
draw Tileset at: (x + 48, y + 8) frame: TILE_BIG_R_BR
2026-04-15 15:22:20 +00:00
}
// Begin the A-side draw animation: pull the top card off deck_a,
// stash it as the face-up `card_a`, arm the fly state for the
// deck → play slide, and play the click sfx.
//
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
// fly_card / fly_face_up are written directly to globals
// instead of being passed to arm_fly, because arm_fly already
// takes 4 parameters and the v0.1 ABI caps function signatures
// at 4 parameters (E0506).
2026-04-15 15:22:20 +00:00
fun begin_draw_a() {
if deck_a_count > 0 {
card_a = draw_front_a()
fly_card = card_a
fly_face_up = 1
// dx_sign 0 = move right (DECK_A_X = 32 → PLAY_A_X = 96).
// dy_sign 0 = move down (DECK_Y = 64 → PLAY_Y = 128).
arm_fly(DECK_A_X, DECK_Y, 0, 0)
play FlipCard
set_phase(P_FLY_A)
}
}
// Begin the B-side draw. dx_sign 1 = move left
// (DECK_B_X = 208 → PLAY_B_X = 144). dy_sign 0 = move down.
fun begin_draw_b() {
if deck_b_count > 0 {
card_b = draw_front_b()
fly_card = card_b
fly_face_up = 1
arm_fly(DECK_B_X, DECK_Y, 1, 0)
play FlipCard
set_phase(P_FLY_B)
}
}
state Playing {
on enter {
set_phase(P_WAIT_A)
card_a = 0
card_b = 0
pot_count = 0
}
on frame {
global_tick += 1
phase_timer += 1
draw_table()
// ── Phase dispatch ───────────────────────────────
ir: clear wide_hi between functions to fix 16-bit op aliasing The IrLowerer's wide_hi map records "this u8 temp's high byte lives at this other temp" pairs whenever a 16-bit value is produced. Both lower_function and lower_handler reset next_temp to 0 at the start of each function, but neither cleared wide_hi — so stale (low_id -> high_id) entries from earlier functions leaked into subsequent ones. When a fresh function reused those temp IDs for unrelated u8 expressions, is_wide() returned spurious true and widen() handed back stale (lo, hi) pairs whose hi happened to coincide with the *next* temp ID fresh_temp() was about to allocate. The result was 16-bit IR ops (CmpEq16 in particular) where the destination temp aliased one of the source operand high bytes — for War this made `match phase` arms past P_WIN_B impossible to enter and the game would freeze with both face-up cards on the table forever. Fix: clear wide_hi alongside the next_temp reset in both lower_function and lower_handler. Adds a regression test (ir::tests::wide_hi_does_not_leak_between_functions) that constructs a function whose body has no u16 ops but follows a function that does, and asserts no CmpEq16 op aliases its dest with an operand high byte. Also: - Convert the war Playing state's phase machine from an if-chain to a `match`, which is what tripped this bug to the surface (it was lurking in earlier ROMs too but their layouts never produced the dest/source collision shape). - Refactor begin_draw_a/b to set fly_card / fly_face_up via globals before calling arm_fly, since arm_fly only takes 4 params (the v0.1 ABI limit, now diagnosed by E0506). - Hoist the P_RESOLVE comparison result to the global pf_result to dodge the param-clobbering issue documented in examples/war/COMPILER_BUGS.md §2. - Document the bug as item #6 in COMPILER_BUGS.md with a minimal repro and reproducer-test pointer. - Refresh the war golden + audio hash to match the new ROM. https://claude.ai/code/session_0143dTgh3UeRrtfHgQwzcv5z
2026-04-15 15:57:26 +00:00
// We use `match` instead of a flat if-chain so each
// frame only runs the FIRST matching arm. A naive
// chain like `if phase == X { ... } if phase == Y {...}`
// would let one phase transition into another (via
// `set_phase`) and then run the next phase's body in
// the same frame, which advances animation timers twice
// and made the card-fly overshoot its endpoint by
// FLY_STEP every time.
match phase {
P_WAIT_A => {
// Human prompt: hint blink above the deck.
if a_is_cpu == 0 {
if (phase_timer & 32) == 0 {
draw_word_press(8, 200)
}
if button.a or button.start {
begin_draw_a()
}
} else {
// CPU draws after a short delay.
if phase_timer >= CPU_THINK_FRAMES {
begin_draw_a()
}
2026-04-15 15:22:20 +00:00
}
ir: clear wide_hi between functions to fix 16-bit op aliasing The IrLowerer's wide_hi map records "this u8 temp's high byte lives at this other temp" pairs whenever a 16-bit value is produced. Both lower_function and lower_handler reset next_temp to 0 at the start of each function, but neither cleared wide_hi — so stale (low_id -> high_id) entries from earlier functions leaked into subsequent ones. When a fresh function reused those temp IDs for unrelated u8 expressions, is_wide() returned spurious true and widen() handed back stale (lo, hi) pairs whose hi happened to coincide with the *next* temp ID fresh_temp() was about to allocate. The result was 16-bit IR ops (CmpEq16 in particular) where the destination temp aliased one of the source operand high bytes — for War this made `match phase` arms past P_WIN_B impossible to enter and the game would freeze with both face-up cards on the table forever. Fix: clear wide_hi alongside the next_temp reset in both lower_function and lower_handler. Adds a regression test (ir::tests::wide_hi_does_not_leak_between_functions) that constructs a function whose body has no u16 ops but follows a function that does, and asserts no CmpEq16 op aliases its dest with an operand high byte. Also: - Convert the war Playing state's phase machine from an if-chain to a `match`, which is what tripped this bug to the surface (it was lurking in earlier ROMs too but their layouts never produced the dest/source collision shape). - Refactor begin_draw_a/b to set fly_card / fly_face_up via globals before calling arm_fly, since arm_fly only takes 4 params (the v0.1 ABI limit, now diagnosed by E0506). - Hoist the P_RESOLVE comparison result to the global pf_result to dodge the param-clobbering issue documented in examples/war/COMPILER_BUGS.md §2. - Document the bug as item #6 in COMPILER_BUGS.md with a minimal repro and reproducer-test pointer. - Refresh the war golden + audio hash to match the new ROM. https://claude.ai/code/session_0143dTgh3UeRrtfHgQwzcv5z
2026-04-15 15:57:26 +00:00
}
P_FLY_A => {
step_fly_pos()
draw_flying_card(fly_x, fly_y)
if phase_timer >= FRAMES_FLY {
set_phase(P_WAIT_B)
2026-04-15 15:22:20 +00:00
}
ir: clear wide_hi between functions to fix 16-bit op aliasing The IrLowerer's wide_hi map records "this u8 temp's high byte lives at this other temp" pairs whenever a 16-bit value is produced. Both lower_function and lower_handler reset next_temp to 0 at the start of each function, but neither cleared wide_hi — so stale (low_id -> high_id) entries from earlier functions leaked into subsequent ones. When a fresh function reused those temp IDs for unrelated u8 expressions, is_wide() returned spurious true and widen() handed back stale (lo, hi) pairs whose hi happened to coincide with the *next* temp ID fresh_temp() was about to allocate. The result was 16-bit IR ops (CmpEq16 in particular) where the destination temp aliased one of the source operand high bytes — for War this made `match phase` arms past P_WIN_B impossible to enter and the game would freeze with both face-up cards on the table forever. Fix: clear wide_hi alongside the next_temp reset in both lower_function and lower_handler. Adds a regression test (ir::tests::wide_hi_does_not_leak_between_functions) that constructs a function whose body has no u16 ops but follows a function that does, and asserts no CmpEq16 op aliases its dest with an operand high byte. Also: - Convert the war Playing state's phase machine from an if-chain to a `match`, which is what tripped this bug to the surface (it was lurking in earlier ROMs too but their layouts never produced the dest/source collision shape). - Refactor begin_draw_a/b to set fly_card / fly_face_up via globals before calling arm_fly, since arm_fly only takes 4 params (the v0.1 ABI limit, now diagnosed by E0506). - Hoist the P_RESOLVE comparison result to the global pf_result to dodge the param-clobbering issue documented in examples/war/COMPILER_BUGS.md §2. - Document the bug as item #6 in COMPILER_BUGS.md with a minimal repro and reproducer-test pointer. - Refresh the war golden + audio hash to match the new ROM. https://claude.ai/code/session_0143dTgh3UeRrtfHgQwzcv5z
2026-04-15 15:57:26 +00:00
}
P_WAIT_B => {
// A's card is now parked in its play slot.
draw_card_face(PLAY_A_X, PLAY_Y, card_a)
if b_is_cpu == 0 {
if (phase_timer & 32) == 0 {
draw_word_press(208, 200)
}
if p2.button.a or p2.button.start or button.a or button.start {
begin_draw_b()
}
} else {
if phase_timer >= CPU_THINK_FRAMES {
begin_draw_b()
}
2026-04-15 15:22:20 +00:00
}
}
ir: clear wide_hi between functions to fix 16-bit op aliasing The IrLowerer's wide_hi map records "this u8 temp's high byte lives at this other temp" pairs whenever a 16-bit value is produced. Both lower_function and lower_handler reset next_temp to 0 at the start of each function, but neither cleared wide_hi — so stale (low_id -> high_id) entries from earlier functions leaked into subsequent ones. When a fresh function reused those temp IDs for unrelated u8 expressions, is_wide() returned spurious true and widen() handed back stale (lo, hi) pairs whose hi happened to coincide with the *next* temp ID fresh_temp() was about to allocate. The result was 16-bit IR ops (CmpEq16 in particular) where the destination temp aliased one of the source operand high bytes — for War this made `match phase` arms past P_WIN_B impossible to enter and the game would freeze with both face-up cards on the table forever. Fix: clear wide_hi alongside the next_temp reset in both lower_function and lower_handler. Adds a regression test (ir::tests::wide_hi_does_not_leak_between_functions) that constructs a function whose body has no u16 ops but follows a function that does, and asserts no CmpEq16 op aliases its dest with an operand high byte. Also: - Convert the war Playing state's phase machine from an if-chain to a `match`, which is what tripped this bug to the surface (it was lurking in earlier ROMs too but their layouts never produced the dest/source collision shape). - Refactor begin_draw_a/b to set fly_card / fly_face_up via globals before calling arm_fly, since arm_fly only takes 4 params (the v0.1 ABI limit, now diagnosed by E0506). - Hoist the P_RESOLVE comparison result to the global pf_result to dodge the param-clobbering issue documented in examples/war/COMPILER_BUGS.md §2. - Document the bug as item #6 in COMPILER_BUGS.md with a minimal repro and reproducer-test pointer. - Refresh the war golden + audio hash to match the new ROM. https://claude.ai/code/session_0143dTgh3UeRrtfHgQwzcv5z
2026-04-15 15:57:26 +00:00
P_FLY_B => {
// A is in place; B is flying.
draw_card_face(PLAY_A_X, PLAY_Y, card_a)
step_fly_pos()
draw_flying_card(fly_x, fly_y)
if phase_timer >= FRAMES_FLY {
set_phase(P_REVEAL)
}
2026-04-15 15:22:20 +00:00
}
ir: clear wide_hi between functions to fix 16-bit op aliasing The IrLowerer's wide_hi map records "this u8 temp's high byte lives at this other temp" pairs whenever a 16-bit value is produced. Both lower_function and lower_handler reset next_temp to 0 at the start of each function, but neither cleared wide_hi — so stale (low_id -> high_id) entries from earlier functions leaked into subsequent ones. When a fresh function reused those temp IDs for unrelated u8 expressions, is_wide() returned spurious true and widen() handed back stale (lo, hi) pairs whose hi happened to coincide with the *next* temp ID fresh_temp() was about to allocate. The result was 16-bit IR ops (CmpEq16 in particular) where the destination temp aliased one of the source operand high bytes — for War this made `match phase` arms past P_WIN_B impossible to enter and the game would freeze with both face-up cards on the table forever. Fix: clear wide_hi alongside the next_temp reset in both lower_function and lower_handler. Adds a regression test (ir::tests::wide_hi_does_not_leak_between_functions) that constructs a function whose body has no u16 ops but follows a function that does, and asserts no CmpEq16 op aliases its dest with an operand high byte. Also: - Convert the war Playing state's phase machine from an if-chain to a `match`, which is what tripped this bug to the surface (it was lurking in earlier ROMs too but their layouts never produced the dest/source collision shape). - Refactor begin_draw_a/b to set fly_card / fly_face_up via globals before calling arm_fly, since arm_fly only takes 4 params (the v0.1 ABI limit, now diagnosed by E0506). - Hoist the P_RESOLVE comparison result to the global pf_result to dodge the param-clobbering issue documented in examples/war/COMPILER_BUGS.md §2. - Document the bug as item #6 in COMPILER_BUGS.md with a minimal repro and reproducer-test pointer. - Refresh the war golden + audio hash to match the new ROM. https://claude.ai/code/session_0143dTgh3UeRrtfHgQwzcv5z
2026-04-15 15:57:26 +00:00
P_REVEAL => {
draw_card_face(PLAY_A_X, PLAY_Y, card_a)
draw_card_face(PLAY_B_X, PLAY_Y, card_b)
if phase_timer >= FRAMES_REVEAL {
set_phase(P_RESOLVE)
2026-04-15 15:22:20 +00:00
}
ir: clear wide_hi between functions to fix 16-bit op aliasing The IrLowerer's wide_hi map records "this u8 temp's high byte lives at this other temp" pairs whenever a 16-bit value is produced. Both lower_function and lower_handler reset next_temp to 0 at the start of each function, but neither cleared wide_hi — so stale (low_id -> high_id) entries from earlier functions leaked into subsequent ones. When a fresh function reused those temp IDs for unrelated u8 expressions, is_wide() returned spurious true and widen() handed back stale (lo, hi) pairs whose hi happened to coincide with the *next* temp ID fresh_temp() was about to allocate. The result was 16-bit IR ops (CmpEq16 in particular) where the destination temp aliased one of the source operand high bytes — for War this made `match phase` arms past P_WIN_B impossible to enter and the game would freeze with both face-up cards on the table forever. Fix: clear wide_hi alongside the next_temp reset in both lower_function and lower_handler. Adds a regression test (ir::tests::wide_hi_does_not_leak_between_functions) that constructs a function whose body has no u16 ops but follows a function that does, and asserts no CmpEq16 op aliases its dest with an operand high byte. Also: - Convert the war Playing state's phase machine from an if-chain to a `match`, which is what tripped this bug to the surface (it was lurking in earlier ROMs too but their layouts never produced the dest/source collision shape). - Refactor begin_draw_a/b to set fly_card / fly_face_up via globals before calling arm_fly, since arm_fly only takes 4 params (the v0.1 ABI limit, now diagnosed by E0506). - Hoist the P_RESOLVE comparison result to the global pf_result to dodge the param-clobbering issue documented in examples/war/COMPILER_BUGS.md §2. - Document the bug as item #6 in COMPILER_BUGS.md with a minimal repro and reproducer-test pointer. - Refresh the war golden + audio hash to match the new ROM. https://claude.ai/code/session_0143dTgh3UeRrtfHgQwzcv5z
2026-04-15 15:57:26 +00:00
}
P_RESOLVE => {
// Both cards go into the pot regardless of outcome.
push_back_pot(card_a)
push_back_pot(card_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 result: u8 = compare_cards(card_a, card_b)
if result == 1 {
ir: clear wide_hi between functions to fix 16-bit op aliasing The IrLowerer's wide_hi map records "this u8 temp's high byte lives at this other temp" pairs whenever a 16-bit value is produced. Both lower_function and lower_handler reset next_temp to 0 at the start of each function, but neither cleared wide_hi — so stale (low_id -> high_id) entries from earlier functions leaked into subsequent ones. When a fresh function reused those temp IDs for unrelated u8 expressions, is_wide() returned spurious true and widen() handed back stale (lo, hi) pairs whose hi happened to coincide with the *next* temp ID fresh_temp() was about to allocate. The result was 16-bit IR ops (CmpEq16 in particular) where the destination temp aliased one of the source operand high bytes — for War this made `match phase` arms past P_WIN_B impossible to enter and the game would freeze with both face-up cards on the table forever. Fix: clear wide_hi alongside the next_temp reset in both lower_function and lower_handler. Adds a regression test (ir::tests::wide_hi_does_not_leak_between_functions) that constructs a function whose body has no u16 ops but follows a function that does, and asserts no CmpEq16 op aliases its dest with an operand high byte. Also: - Convert the war Playing state's phase machine from an if-chain to a `match`, which is what tripped this bug to the surface (it was lurking in earlier ROMs too but their layouts never produced the dest/source collision shape). - Refactor begin_draw_a/b to set fly_card / fly_face_up via globals before calling arm_fly, since arm_fly only takes 4 params (the v0.1 ABI limit, now diagnosed by E0506). - Hoist the P_RESOLVE comparison result to the global pf_result to dodge the param-clobbering issue documented in examples/war/COMPILER_BUGS.md §2. - Document the bug as item #6 in COMPILER_BUGS.md with a minimal repro and reproducer-test pointer. - Refresh the war golden + audio hash to match the new ROM. https://claude.ai/code/session_0143dTgh3UeRrtfHgQwzcv5z
2026-04-15 15:57:26 +00:00
play CheerA
set_phase(P_WIN_A)
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
if result == 2 {
ir: clear wide_hi between functions to fix 16-bit op aliasing The IrLowerer's wide_hi map records "this u8 temp's high byte lives at this other temp" pairs whenever a 16-bit value is produced. Both lower_function and lower_handler reset next_temp to 0 at the start of each function, but neither cleared wide_hi — so stale (low_id -> high_id) entries from earlier functions leaked into subsequent ones. When a fresh function reused those temp IDs for unrelated u8 expressions, is_wide() returned spurious true and widen() handed back stale (lo, hi) pairs whose hi happened to coincide with the *next* temp ID fresh_temp() was about to allocate. The result was 16-bit IR ops (CmpEq16 in particular) where the destination temp aliased one of the source operand high bytes — for War this made `match phase` arms past P_WIN_B impossible to enter and the game would freeze with both face-up cards on the table forever. Fix: clear wide_hi alongside the next_temp reset in both lower_function and lower_handler. Adds a regression test (ir::tests::wide_hi_does_not_leak_between_functions) that constructs a function whose body has no u16 ops but follows a function that does, and asserts no CmpEq16 op aliases its dest with an operand high byte. Also: - Convert the war Playing state's phase machine from an if-chain to a `match`, which is what tripped this bug to the surface (it was lurking in earlier ROMs too but their layouts never produced the dest/source collision shape). - Refactor begin_draw_a/b to set fly_card / fly_face_up via globals before calling arm_fly, since arm_fly only takes 4 params (the v0.1 ABI limit, now diagnosed by E0506). - Hoist the P_RESOLVE comparison result to the global pf_result to dodge the param-clobbering issue documented in examples/war/COMPILER_BUGS.md §2. - Document the bug as item #6 in COMPILER_BUGS.md with a minimal repro and reproducer-test pointer. - Refresh the war golden + audio hash to match the new ROM. https://claude.ai/code/session_0143dTgh3UeRrtfHgQwzcv5z
2026-04-15 15:57:26 +00:00
play CheerB
set_phase(P_WIN_B)
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
if result == 0 {
ir: clear wide_hi between functions to fix 16-bit op aliasing The IrLowerer's wide_hi map records "this u8 temp's high byte lives at this other temp" pairs whenever a 16-bit value is produced. Both lower_function and lower_handler reset next_temp to 0 at the start of each function, but neither cleared wide_hi — so stale (low_id -> high_id) entries from earlier functions leaked into subsequent ones. When a fresh function reused those temp IDs for unrelated u8 expressions, is_wide() returned spurious true and widen() handed back stale (lo, hi) pairs whose hi happened to coincide with the *next* temp ID fresh_temp() was about to allocate. The result was 16-bit IR ops (CmpEq16 in particular) where the destination temp aliased one of the source operand high bytes — for War this made `match phase` arms past P_WIN_B impossible to enter and the game would freeze with both face-up cards on the table forever. Fix: clear wide_hi alongside the next_temp reset in both lower_function and lower_handler. Adds a regression test (ir::tests::wide_hi_does_not_leak_between_functions) that constructs a function whose body has no u16 ops but follows a function that does, and asserts no CmpEq16 op aliases its dest with an operand high byte. Also: - Convert the war Playing state's phase machine from an if-chain to a `match`, which is what tripped this bug to the surface (it was lurking in earlier ROMs too but their layouts never produced the dest/source collision shape). - Refactor begin_draw_a/b to set fly_card / fly_face_up via globals before calling arm_fly, since arm_fly only takes 4 params (the v0.1 ABI limit, now diagnosed by E0506). - Hoist the P_RESOLVE comparison result to the global pf_result to dodge the param-clobbering issue documented in examples/war/COMPILER_BUGS.md §2. - Document the bug as item #6 in COMPILER_BUGS.md with a minimal repro and reproducer-test pointer. - Refresh the war golden + audio hash to match the new ROM. https://claude.ai/code/session_0143dTgh3UeRrtfHgQwzcv5z
2026-04-15 15:57:26 +00:00
// It's a tie — but only enter the war flow if both
// sides actually have cards left to bury. If a
// player ran out of cards on this very tie, the
// OTHER player wins by default and takes the pot.
if deck_a_count == 0 {
pot_to_b()
winner = 1
transition Victory
}
if deck_b_count == 0 {
pot_to_a()
winner = 0
transition Victory
}
play WarFlash
set_phase(P_WAR_BANNER)
2026-04-15 15:22:20 +00:00
}
ir: clear wide_hi between functions to fix 16-bit op aliasing The IrLowerer's wide_hi map records "this u8 temp's high byte lives at this other temp" pairs whenever a 16-bit value is produced. Both lower_function and lower_handler reset next_temp to 0 at the start of each function, but neither cleared wide_hi — so stale (low_id -> high_id) entries from earlier functions leaked into subsequent ones. When a fresh function reused those temp IDs for unrelated u8 expressions, is_wide() returned spurious true and widen() handed back stale (lo, hi) pairs whose hi happened to coincide with the *next* temp ID fresh_temp() was about to allocate. The result was 16-bit IR ops (CmpEq16 in particular) where the destination temp aliased one of the source operand high bytes — for War this made `match phase` arms past P_WIN_B impossible to enter and the game would freeze with both face-up cards on the table forever. Fix: clear wide_hi alongside the next_temp reset in both lower_function and lower_handler. Adds a regression test (ir::tests::wide_hi_does_not_leak_between_functions) that constructs a function whose body has no u16 ops but follows a function that does, and asserts no CmpEq16 op aliases its dest with an operand high byte. Also: - Convert the war Playing state's phase machine from an if-chain to a `match`, which is what tripped this bug to the surface (it was lurking in earlier ROMs too but their layouts never produced the dest/source collision shape). - Refactor begin_draw_a/b to set fly_card / fly_face_up via globals before calling arm_fly, since arm_fly only takes 4 params (the v0.1 ABI limit, now diagnosed by E0506). - Hoist the P_RESOLVE comparison result to the global pf_result to dodge the param-clobbering issue documented in examples/war/COMPILER_BUGS.md §2. - Document the bug as item #6 in COMPILER_BUGS.md with a minimal repro and reproducer-test pointer. - Refresh the war golden + audio hash to match the new ROM. https://claude.ai/code/session_0143dTgh3UeRrtfHgQwzcv5z
2026-04-15 15:57:26 +00:00
}
P_WIN_A => {
draw_card_face(PLAY_A_X, PLAY_Y, card_a)
draw_card_face(PLAY_B_X, PLAY_Y, card_b)
if phase_timer >= FRAMES_FLY {
2026-04-15 15:22:20 +00:00
pot_to_a()
ir: clear wide_hi between functions to fix 16-bit op aliasing The IrLowerer's wide_hi map records "this u8 temp's high byte lives at this other temp" pairs whenever a 16-bit value is produced. Both lower_function and lower_handler reset next_temp to 0 at the start of each function, but neither cleared wide_hi — so stale (low_id -> high_id) entries from earlier functions leaked into subsequent ones. When a fresh function reused those temp IDs for unrelated u8 expressions, is_wide() returned spurious true and widen() handed back stale (lo, hi) pairs whose hi happened to coincide with the *next* temp ID fresh_temp() was about to allocate. The result was 16-bit IR ops (CmpEq16 in particular) where the destination temp aliased one of the source operand high bytes — for War this made `match phase` arms past P_WIN_B impossible to enter and the game would freeze with both face-up cards on the table forever. Fix: clear wide_hi alongside the next_temp reset in both lower_function and lower_handler. Adds a regression test (ir::tests::wide_hi_does_not_leak_between_functions) that constructs a function whose body has no u16 ops but follows a function that does, and asserts no CmpEq16 op aliases its dest with an operand high byte. Also: - Convert the war Playing state's phase machine from an if-chain to a `match`, which is what tripped this bug to the surface (it was lurking in earlier ROMs too but their layouts never produced the dest/source collision shape). - Refactor begin_draw_a/b to set fly_card / fly_face_up via globals before calling arm_fly, since arm_fly only takes 4 params (the v0.1 ABI limit, now diagnosed by E0506). - Hoist the P_RESOLVE comparison result to the global pf_result to dodge the param-clobbering issue documented in examples/war/COMPILER_BUGS.md §2. - Document the bug as item #6 in COMPILER_BUGS.md with a minimal repro and reproducer-test pointer. - Refresh the war golden + audio hash to match the new ROM. https://claude.ai/code/session_0143dTgh3UeRrtfHgQwzcv5z
2026-04-15 15:57:26 +00:00
set_phase(P_CHECK)
2026-04-15 15:22:20 +00:00
}
}
ir: clear wide_hi between functions to fix 16-bit op aliasing The IrLowerer's wide_hi map records "this u8 temp's high byte lives at this other temp" pairs whenever a 16-bit value is produced. Both lower_function and lower_handler reset next_temp to 0 at the start of each function, but neither cleared wide_hi — so stale (low_id -> high_id) entries from earlier functions leaked into subsequent ones. When a fresh function reused those temp IDs for unrelated u8 expressions, is_wide() returned spurious true and widen() handed back stale (lo, hi) pairs whose hi happened to coincide with the *next* temp ID fresh_temp() was about to allocate. The result was 16-bit IR ops (CmpEq16 in particular) where the destination temp aliased one of the source operand high bytes — for War this made `match phase` arms past P_WIN_B impossible to enter and the game would freeze with both face-up cards on the table forever. Fix: clear wide_hi alongside the next_temp reset in both lower_function and lower_handler. Adds a regression test (ir::tests::wide_hi_does_not_leak_between_functions) that constructs a function whose body has no u16 ops but follows a function that does, and asserts no CmpEq16 op aliases its dest with an operand high byte. Also: - Convert the war Playing state's phase machine from an if-chain to a `match`, which is what tripped this bug to the surface (it was lurking in earlier ROMs too but their layouts never produced the dest/source collision shape). - Refactor begin_draw_a/b to set fly_card / fly_face_up via globals before calling arm_fly, since arm_fly only takes 4 params (the v0.1 ABI limit, now diagnosed by E0506). - Hoist the P_RESOLVE comparison result to the global pf_result to dodge the param-clobbering issue documented in examples/war/COMPILER_BUGS.md §2. - Document the bug as item #6 in COMPILER_BUGS.md with a minimal repro and reproducer-test pointer. - Refresh the war golden + audio hash to match the new ROM. https://claude.ai/code/session_0143dTgh3UeRrtfHgQwzcv5z
2026-04-15 15:57:26 +00:00
P_WIN_B => {
draw_card_face(PLAY_A_X, PLAY_Y, card_a)
draw_card_face(PLAY_B_X, PLAY_Y, card_b)
if phase_timer >= FRAMES_FLY {
pot_to_b()
set_phase(P_CHECK)
}
2026-04-15 15:22:20 +00:00
}
ir: clear wide_hi between functions to fix 16-bit op aliasing The IrLowerer's wide_hi map records "this u8 temp's high byte lives at this other temp" pairs whenever a 16-bit value is produced. Both lower_function and lower_handler reset next_temp to 0 at the start of each function, but neither cleared wide_hi — so stale (low_id -> high_id) entries from earlier functions leaked into subsequent ones. When a fresh function reused those temp IDs for unrelated u8 expressions, is_wide() returned spurious true and widen() handed back stale (lo, hi) pairs whose hi happened to coincide with the *next* temp ID fresh_temp() was about to allocate. The result was 16-bit IR ops (CmpEq16 in particular) where the destination temp aliased one of the source operand high bytes — for War this made `match phase` arms past P_WIN_B impossible to enter and the game would freeze with both face-up cards on the table forever. Fix: clear wide_hi alongside the next_temp reset in both lower_function and lower_handler. Adds a regression test (ir::tests::wide_hi_does_not_leak_between_functions) that constructs a function whose body has no u16 ops but follows a function that does, and asserts no CmpEq16 op aliases its dest with an operand high byte. Also: - Convert the war Playing state's phase machine from an if-chain to a `match`, which is what tripped this bug to the surface (it was lurking in earlier ROMs too but their layouts never produced the dest/source collision shape). - Refactor begin_draw_a/b to set fly_card / fly_face_up via globals before calling arm_fly, since arm_fly only takes 4 params (the v0.1 ABI limit, now diagnosed by E0506). - Hoist the P_RESOLVE comparison result to the global pf_result to dodge the param-clobbering issue documented in examples/war/COMPILER_BUGS.md §2. - Document the bug as item #6 in COMPILER_BUGS.md with a minimal repro and reproducer-test pointer. - Refresh the war golden + audio hash to match the new ROM. https://claude.ai/code/session_0143dTgh3UeRrtfHgQwzcv5z
2026-04-15 15:57:26 +00:00
P_WAR_BANNER => {
draw_card_face(PLAY_A_X, PLAY_Y, card_a)
draw_card_face(PLAY_B_X, PLAY_Y, card_b)
// Flashing big "WAR" banner — only drawn on alternate
// 8-frame windows so the title strobes for emphasis.
if (phase_timer & 8) != 0 {
draw_big_war_banner(96, 80)
}
if phase_timer >= FRAMES_BANNER {
set_phase(P_WAR_BURY)
}
2026-04-15 15:22:20 +00:00
}
ir: clear wide_hi between functions to fix 16-bit op aliasing The IrLowerer's wide_hi map records "this u8 temp's high byte lives at this other temp" pairs whenever a 16-bit value is produced. Both lower_function and lower_handler reset next_temp to 0 at the start of each function, but neither cleared wide_hi — so stale (low_id -> high_id) entries from earlier functions leaked into subsequent ones. When a fresh function reused those temp IDs for unrelated u8 expressions, is_wide() returned spurious true and widen() handed back stale (lo, hi) pairs whose hi happened to coincide with the *next* temp ID fresh_temp() was about to allocate. The result was 16-bit IR ops (CmpEq16 in particular) where the destination temp aliased one of the source operand high bytes — for War this made `match phase` arms past P_WIN_B impossible to enter and the game would freeze with both face-up cards on the table forever. Fix: clear wide_hi alongside the next_temp reset in both lower_function and lower_handler. Adds a regression test (ir::tests::wide_hi_does_not_leak_between_functions) that constructs a function whose body has no u16 ops but follows a function that does, and asserts no CmpEq16 op aliases its dest with an operand high byte. Also: - Convert the war Playing state's phase machine from an if-chain to a `match`, which is what tripped this bug to the surface (it was lurking in earlier ROMs too but their layouts never produced the dest/source collision shape). - Refactor begin_draw_a/b to set fly_card / fly_face_up via globals before calling arm_fly, since arm_fly only takes 4 params (the v0.1 ABI limit, now diagnosed by E0506). - Hoist the P_RESOLVE comparison result to the global pf_result to dodge the param-clobbering issue documented in examples/war/COMPILER_BUGS.md §2. - Document the bug as item #6 in COMPILER_BUGS.md with a minimal repro and reproducer-test pointer. - Refresh the war golden + audio hash to match the new ROM. https://claude.ai/code/session_0143dTgh3UeRrtfHgQwzcv5z
2026-04-15 15:57:26 +00:00
P_WAR_BURY => {
// Keep the previous round's face-up pair on the
// table while the buries thump in — visually the
// tied cards stay lit until the new pair lands.
draw_card_face(PLAY_A_X, PLAY_Y, card_a)
draw_card_face(PLAY_B_X, PLAY_Y, card_b)
ir: clear wide_hi between functions to fix 16-bit op aliasing The IrLowerer's wide_hi map records "this u8 temp's high byte lives at this other temp" pairs whenever a 16-bit value is produced. Both lower_function and lower_handler reset next_temp to 0 at the start of each function, but neither cleared wide_hi — so stale (low_id -> high_id) entries from earlier functions leaked into subsequent ones. When a fresh function reused those temp IDs for unrelated u8 expressions, is_wide() returned spurious true and widen() handed back stale (lo, hi) pairs whose hi happened to coincide with the *next* temp ID fresh_temp() was about to allocate. The result was 16-bit IR ops (CmpEq16 in particular) where the destination temp aliased one of the source operand high bytes — for War this made `match phase` arms past P_WIN_B impossible to enter and the game would freeze with both face-up cards on the table forever. Fix: clear wide_hi alongside the next_temp reset in both lower_function and lower_handler. Adds a regression test (ir::tests::wide_hi_does_not_leak_between_functions) that constructs a function whose body has no u16 ops but follows a function that does, and asserts no CmpEq16 op aliases its dest with an operand high byte. Also: - Convert the war Playing state's phase machine from an if-chain to a `match`, which is what tripped this bug to the surface (it was lurking in earlier ROMs too but their layouts never produced the dest/source collision shape). - Refactor begin_draw_a/b to set fly_card / fly_face_up via globals before calling arm_fly, since arm_fly only takes 4 params (the v0.1 ABI limit, now diagnosed by E0506). - Hoist the P_RESOLVE comparison result to the global pf_result to dodge the param-clobbering issue documented in examples/war/COMPILER_BUGS.md §2. - Document the bug as item #6 in COMPILER_BUGS.md with a minimal repro and reproducer-test pointer. - Refresh the war golden + audio hash to match the new ROM. https://claude.ai/code/session_0143dTgh3UeRrtfHgQwzcv5z
2026-04-15 15:57:26 +00:00
// Bury up to 3 face-down cards from each deck, then
// draw a new face-up pair. We don't animate each
// individual buried card; just play a noise thump
// per buried card and advance the counters.
if phase_timer == 1 {
if deck_a_count > 0 { bury_from_a() }
if deck_b_count > 0 { bury_from_b() }
play ThudDown
}
if phase_timer == 4 {
if deck_a_count > 0 { bury_from_a() }
if deck_b_count > 0 { bury_from_b() }
play ThudDown
}
if phase_timer == 7 {
if deck_a_count > 0 { bury_from_a() }
if deck_b_count > 0 { bury_from_b() }
play ThudDown
}
if phase_timer == 10 {
// Draw new face-ups for the comparison. If either
// side has run out of cards, the OTHER side wins
// and takes the entire pot — we transition straight
// to Victory.
if deck_a_count == 0 {
pot_to_b()
winner = 1
transition Victory
}
if deck_b_count == 0 {
pot_to_a()
winner = 0
transition Victory
}
card_a = draw_front_a()
card_b = draw_front_b()
}
if phase_timer >= FRAMES_BURY + 16 {
set_phase(P_REVEAL)
}
2026-04-15 15:22:20 +00:00
}
ir: clear wide_hi between functions to fix 16-bit op aliasing The IrLowerer's wide_hi map records "this u8 temp's high byte lives at this other temp" pairs whenever a 16-bit value is produced. Both lower_function and lower_handler reset next_temp to 0 at the start of each function, but neither cleared wide_hi — so stale (low_id -> high_id) entries from earlier functions leaked into subsequent ones. When a fresh function reused those temp IDs for unrelated u8 expressions, is_wide() returned spurious true and widen() handed back stale (lo, hi) pairs whose hi happened to coincide with the *next* temp ID fresh_temp() was about to allocate. The result was 16-bit IR ops (CmpEq16 in particular) where the destination temp aliased one of the source operand high bytes — for War this made `match phase` arms past P_WIN_B impossible to enter and the game would freeze with both face-up cards on the table forever. Fix: clear wide_hi alongside the next_temp reset in both lower_function and lower_handler. Adds a regression test (ir::tests::wide_hi_does_not_leak_between_functions) that constructs a function whose body has no u16 ops but follows a function that does, and asserts no CmpEq16 op aliases its dest with an operand high byte. Also: - Convert the war Playing state's phase machine from an if-chain to a `match`, which is what tripped this bug to the surface (it was lurking in earlier ROMs too but their layouts never produced the dest/source collision shape). - Refactor begin_draw_a/b to set fly_card / fly_face_up via globals before calling arm_fly, since arm_fly only takes 4 params (the v0.1 ABI limit, now diagnosed by E0506). - Hoist the P_RESOLVE comparison result to the global pf_result to dodge the param-clobbering issue documented in examples/war/COMPILER_BUGS.md §2. - Document the bug as item #6 in COMPILER_BUGS.md with a minimal repro and reproducer-test pointer. - Refresh the war golden + audio hash to match the new ROM. https://claude.ai/code/session_0143dTgh3UeRrtfHgQwzcv5z
2026-04-15 15:57:26 +00:00
P_CHECK => {
2026-04-15 15:22:20 +00:00
if deck_a_count == 0 {
winner = 1
transition Victory
}
if deck_b_count == 0 {
winner = 0
transition Victory
}
ir: clear wide_hi between functions to fix 16-bit op aliasing The IrLowerer's wide_hi map records "this u8 temp's high byte lives at this other temp" pairs whenever a 16-bit value is produced. Both lower_function and lower_handler reset next_temp to 0 at the start of each function, but neither cleared wide_hi — so stale (low_id -> high_id) entries from earlier functions leaked into subsequent ones. When a fresh function reused those temp IDs for unrelated u8 expressions, is_wide() returned spurious true and widen() handed back stale (lo, hi) pairs whose hi happened to coincide with the *next* temp ID fresh_temp() was about to allocate. The result was 16-bit IR ops (CmpEq16 in particular) where the destination temp aliased one of the source operand high bytes — for War this made `match phase` arms past P_WIN_B impossible to enter and the game would freeze with both face-up cards on the table forever. Fix: clear wide_hi alongside the next_temp reset in both lower_function and lower_handler. Adds a regression test (ir::tests::wide_hi_does_not_leak_between_functions) that constructs a function whose body has no u16 ops but follows a function that does, and asserts no CmpEq16 op aliases its dest with an operand high byte. Also: - Convert the war Playing state's phase machine from an if-chain to a `match`, which is what tripped this bug to the surface (it was lurking in earlier ROMs too but their layouts never produced the dest/source collision shape). - Refactor begin_draw_a/b to set fly_card / fly_face_up via globals before calling arm_fly, since arm_fly only takes 4 params (the v0.1 ABI limit, now diagnosed by E0506). - Hoist the P_RESOLVE comparison result to the global pf_result to dodge the param-clobbering issue documented in examples/war/COMPILER_BUGS.md §2. - Document the bug as item #6 in COMPILER_BUGS.md with a minimal repro and reproducer-test pointer. - Refresh the war golden + audio hash to match the new ROM. https://claude.ai/code/session_0143dTgh3UeRrtfHgQwzcv5z
2026-04-15 15:57:26 +00:00
// No winner yet — start the next round.
card_a = 0
card_b = 0
set_phase(P_WAIT_A)
2026-04-15 15:22:20 +00:00
}
ir: clear wide_hi between functions to fix 16-bit op aliasing The IrLowerer's wide_hi map records "this u8 temp's high byte lives at this other temp" pairs whenever a 16-bit value is produced. Both lower_function and lower_handler reset next_temp to 0 at the start of each function, but neither cleared wide_hi — so stale (low_id -> high_id) entries from earlier functions leaked into subsequent ones. When a fresh function reused those temp IDs for unrelated u8 expressions, is_wide() returned spurious true and widen() handed back stale (lo, hi) pairs whose hi happened to coincide with the *next* temp ID fresh_temp() was about to allocate. The result was 16-bit IR ops (CmpEq16 in particular) where the destination temp aliased one of the source operand high bytes — for War this made `match phase` arms past P_WIN_B impossible to enter and the game would freeze with both face-up cards on the table forever. Fix: clear wide_hi alongside the next_temp reset in both lower_function and lower_handler. Adds a regression test (ir::tests::wide_hi_does_not_leak_between_functions) that constructs a function whose body has no u16 ops but follows a function that does, and asserts no CmpEq16 op aliases its dest with an operand high byte. Also: - Convert the war Playing state's phase machine from an if-chain to a `match`, which is what tripped this bug to the surface (it was lurking in earlier ROMs too but their layouts never produced the dest/source collision shape). - Refactor begin_draw_a/b to set fly_card / fly_face_up via globals before calling arm_fly, since arm_fly only takes 4 params (the v0.1 ABI limit, now diagnosed by E0506). - Hoist the P_RESOLVE comparison result to the global pf_result to dodge the param-clobbering issue documented in examples/war/COMPILER_BUGS.md §2. - Document the bug as item #6 in COMPILER_BUGS.md with a minimal repro and reproducer-test pointer. - Refresh the war golden + audio hash to match the new ROM. https://claude.ai/code/session_0143dTgh3UeRrtfHgQwzcv5z
2026-04-15 15:57:26 +00:00
_ => {}
2026-04-15 15:22:20 +00:00
}
}
}