1
0
Fork 0
mirror of https://github.com/imjasonh/nescript synced 2026-07-11 02:02:58 +00:00
nescript/examples/war/deal_state.ne

88 lines
3.4 KiB
Text
Raw Normal View History

2026-04-15 15:22:20 +00:00
// war/deal_state.ne — the Deal state.
//
// Shuffles the deck on entry, then runs a brief dealing animation
// before transitioning into Playing. The animation shows a single
// face-down "in flight" card sprite alternating between A's deck
// and B's deck while a FlipCard sfx clicks on each dealt step.
// The deck counts tick up alongside so it looks like the stacks
// are actually growing.
2026-04-15 15:22:20 +00:00
//
// Pace: one dealt card every 2 frames → 104 frames for the full
// 52-card deal. Combined with the title's 45-frame autopilot,
// Playing starts at roughly frame 150, leaving ~30 frames before
// the jsnes harness captures at frame 180 — enough for the
// CPU-think delay and the start of A's first fly.
2026-04-15 15:22:20 +00:00
state Deal {
on enter {
init_and_shuffle_decks()
// Visually pretend both decks start empty and grow during
// the animation — we animate a `visible_count` counter
// on each side. The underlying deck_*_count starts at
// HALF_DECK after init_and_shuffle_decks; we override the
// on-screen count via deal_next.
deal_next = 0
deal_timer = 0
}
on frame {
global_tick += 1
deal_timer += 1
// ── Dealing tick ─────────────────────────────────
// Deal one card every 2 frames until we've laid down
// all 52. Play a FlipCard sfx on each dealt step for
// the rhythmic click.
if deal_timer >= 2 {
deal_timer = 0
if deal_next < DECK_SIZE {
deal_next += 1
play FlipCard
}
}
// ── Rendering ────────────────────────────────────
// Draw the "table" furniture: two deck stacks in their
// resting position and the running card counts. The
// deal_next counter controls how many of the 52 have
// "landed", so the first half goes to A and the second
// half to B — matching the actual split_decks() logic.
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 dealt_a: u8 = deal_next
var dealt_b: u8 = 0
if dealt_a > HALF_DECK {
dealt_b = dealt_a - HALF_DECK
dealt_a = HALF_DECK
2026-04-15 15:22:20 +00:00
}
// Both decks drawn as card backs whenever they have at
// least one card. Before that, skip the draw so the slot
// is empty.
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 dealt_a > 0 {
2026-04-15 15:22:20 +00:00
draw_card_back(DECK_A_X, DECK_Y)
}
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 dealt_b > 0 {
2026-04-15 15:22:20 +00:00
draw_card_back(DECK_B_X, DECK_Y)
}
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_count(COUNT_A_X, COUNT_Y, dealt_a)
draw_count(COUNT_B_X, COUNT_Y, dealt_b)
2026-04-15 15:22:20 +00:00
// ── Flying card ──────────────────────────────────
// A single face-down card bouncing between the centre
// and each deck. The x position alternates based on the
// low bit of deal_next (even → going to A, odd → B).
if deal_next < DECK_SIZE {
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 fly_x_pos: u8 = DECK_A_X + 32
2026-04-15 15:22:20 +00:00
if (deal_next & 1) != 0 {
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_x_pos = DECK_B_X - 32
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_card_back(fly_x_pos, 96)
2026-04-15 15:22:20 +00:00
}
// ── Transition ───────────────────────────────────
if deal_next >= DECK_SIZE {
transition Playing
}
}
}