1
0
Fork 0
mirror of https://github.com/imjasonh/nescript synced 2026-07-09 01:16:12 +00:00
nescript/examples/war/COMPILER_BUGS.md
Claude d6cb84a5bd
compiler: close out bug #4 (W0109 sprite-per-scanline) and bug #5 (real inlining)
Fixes the last two deferred compiler bugs catalogued in
examples/war/COMPILER_BUGS.md, finishing the bug-cleanup arc on
the War branch.

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

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

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

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

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

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

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

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

https://claude.ai/code/session_0143dTgh3UeRrtfHgQwzcv5z
2026-04-15 21:33:00 +00:00

676 lines
26 KiB
Markdown

# NEScript v0.1 — Compiler Bugs and Limitations Found While Building War
This document captures bugs and limitations discovered while
building `examples/war.ne`. Each entry includes a minimal
reproduction, the symptom we observed, the root cause, the
workaround originally used in `examples/war/*.ne`, and the
compiler fix that shipped (when shipped).
## Status summary
| # | Short name | Status | Fix commit | Regression test |
|---|---|---|---|---|
| 1 | `fun` with > 4 params silently drops the rest | **FIXED** (E0506 diagnostic) | `analyzer: reject functions with more than 4 parameters (E0506)` | `analyze_rejects_function_with_more_than_4_params`, `analyze_accepts_function_with_exactly_4_params` |
| 1b | Same-named params share VarIds across functions | **FIXED** (scope-qualified keys) | `analyzer/ir: scope function locals per function body` | `analyze_allows_same_param_name_in_two_functions` |
| 2 | Param transport slots $04-$07 clobbered by nested calls | **FIXED** (codegen prologue spill) | `codegen: spill parameters from $04-$07 into per-function RAM slots` | `codegen::ir_codegen::gen_function_prologue_spills_params_to_local_ram` |
| 3 | Function-local `var` declarations share one flat namespace | **FIXED** (scope-qualified keys) | `analyzer/ir: scope function locals per function body` | `analyze_allows_same_local_name_in_two_functions`, `analyze_allows_same_local_name_in_two_state_handlers`, `analyze_still_rejects_duplicate_local_in_same_function` |
| 4 | 8-sprites-per-scanline limit invisible to user code | **FIXED** (W0109 static analyzer warning) | `analyzer: add W0109 sprite-per-scanline budget check` | `analyze_sprite_scanline_budget_warns_over_eight`, `analyze_sprite_scanline_budget_ok_when_staggered`, `analyze_sprite_scanline_budget_skips_dynamic_coords`, `analyze_sprite_scanline_budget_expands_metasprites`, `analyze_sprite_scanline_budget_recurses_into_if` |
| 5 | `inline` keyword silently declined for short functions | **FIXED** (IR lowering now inlines expression and void bodies) | `ir: real inlining for single-return and void-body `inline fun`s` | `ir::tests::inline_fun_expression_body_emits_no_call_at_use_site`, `inline_fun_void_body_statements_are_spliced`, `inline_fun_with_conditional_return_compiles_as_regular_call`, `inline_fun_nested_inlines_substitute_correctly` |
| 6 | `wide_hi` IR map leaked between functions (u16→u8 aliasing) | **FIXED** (cleared per function) | `ir: clear wide_hi between functions to fix 16-bit op aliasing` | `ir::tests::wide_hi_does_not_leak_between_functions` |
**Once a fix lands, revert the workaround in `examples/war/*.ne`
in the same commit** so the example keeps the game honest and
the PR diff visibly proves the fix works end-to-end. All seven
catalogued bugs have now shipped their fixes; the example code
no longer carries any workaround comments.
---
## 1. Functions with more than 4 parameters silently corrupt the 5th+ *(FIXED)*
### Symptom
Calling a function with 5 or 6 parameters compiles cleanly, with
no warning or error, but at runtime the 5th and 6th parameter
values are silently replaced by garbage (typically the value of
parameter 3 or 4). Animations and state writes that depend on
those parameters behave as if zero was passed.
### Reproduction
```nescript
fun arm_fly(sx: u8, sy: u8, dxsign: u8, dysign: u8, card: u8, fu: u8) {
fly_x = sx
fly_y = sy
fly_dx_sign = dxsign
fly_dy_sign = dysign
fly_card = card // gets the value of dxsign instead!
fly_face_up = fu // gets the value of dxsign instead!
}
fun caller() {
arm_fly(32, 64, 0, 0, 147, 1)
// After this call:
// fly_x = 32, fly_y = 64, fly_dx_sign = 0, fly_dy_sign = 0
// fly_card = 0 (NOT 147)
// fly_face_up = 0 (NOT 1)
}
```
### Root cause
`src/codegen/ir_codegen.rs` (around line 240) iterates through
`func.locals` and assigns the first 4 entries to zero-page
parameter slots `$04`-`$07`:
```rust
for func in &ir.functions {
for (i, local) in func.locals.iter().enumerate() {
if i < func.param_count {
if i < 4 {
var_addrs.insert(local.var_id, 0x04 + i as u16);
...
}
} else {
...
}
}
}
```
The `if i < 4` guard silently drops the mapping for params 5+
without inserting any RAM allocation for them. The corresponding
caller-side codegen for `Call` writes only the first four
arguments. Result: params 5 and 6 are never passed and the
callee reads stale memory from $04-$07 in their place.
### Workaround used in `examples/war/`
`arm_fly` is split: the four "arming" parameters stay in the
function signature, and `fly_card` / `fly_face_up` are written to
the global state directly at every call site instead. See
`war/play_state.ne` (`begin_draw_a` / `begin_draw_b`).
### Fix proposal
Two reasonable options:
1. **Diagnose-only**: emit `E05XX too many parameters` when a
`fun` declaration has more than 4 params. This is the
smallest possible change and turns silent miscompiles into a
loud compile-time error. Should ship immediately even if
option 2 is also planned.
2. **Spill to RAM**: extend the calling convention so params
beyond the first four are passed via dedicated RAM slots in
the callee's local frame. The caller-side `Call` codegen
would write those slots before `JSR`, the callee-side prologue
could leave them as-is. This grows the per-function RAM
footprint but lets users write any signature they like.
---
## 1b. Function parameters with the same name in different functions share a VarId, which collides their zero-page slot mapping *(FIXED)*
### Symptom
Two unrelated functions whose parameters happen to be named the
same (e.g. both have a `card: u8` parameter, or both have an
`x: u8` parameter) end up reading parameters from the wrong
zero-page slot at runtime. One function reads `$04`, another
reads `$06`, a third reads `$05` — depending on the parameter's
*position* in whichever function is processed last by the
codegen.
This is a much sneakier sibling of bug #1: rather than dropping
a parameter past the 4th slot, it silently reroutes parameter
reads to slots that hold completely unrelated values from the
caller.
### Reproduction
```nescript
// Function A: card is the 1st parameter, expected at $04
fun push_back_a(card: u8) {
deck_a[deck_a_front] = card // reads from $06, not $04!
deck_a_count += 1
}
// Function B: card is the 3rd parameter, expected at $06
fun draw_card_face(x: u8, y: u8, card: u8) {
// ... uses card normally ...
}
```
The IR lowering assigns `card` a single shared `VarId` because
its `var_map` is global across all functions. The codegen then
walks each function in turn, inserting `(VarId(card), $0X)`
mappings into a single global `var_addrs` `HashMap` — and
whichever function comes last in iteration order wins the
mapping. If `draw_card_face` is processed after `push_back_a`,
`VarId(card)` ends up mapped to `$06`, and `push_back_a` then
reads its `card` parameter from `$06` (which holds whatever the
caller was using as a third argument — typically junk).
### Root cause
`src/ir/lowering.rs::get_or_create_var` looks up names in
`self.var_map`, which is shared across the whole program:
```rust
fn get_or_create_var(&mut self, name: &str) -> VarId {
if let Some(&id) = self.var_map.get(name) {
id
} else {
let id = VarId(self.next_var_id);
self.next_var_id += 1;
self.var_map.insert(name.to_string(), id);
id
}
}
```
`lower_function` calls `get_or_create_var(&param.name)` for each
parameter, so two different functions both with a `card`
parameter resolve to the same `VarId`. Once that single `VarId`
flows into the codegen, the per-function "this is param index N
of function F" relationship is lost — there's only one global
mapping per `VarId`.
### Workaround used in `examples/war/`
Every parameter name in the war source is unique across the
entire program. Function-locals were already prefixed by
function (see bug #3); we extended the same scheme to params:
`push_back_a(pba_arg_card: u8)` instead of
`push_back_a(card: u8)`, etc. The wrapping `pba_card` /
`pbb_card` / `dcf_card` snapshots from bug #2 stay because they
also help with the bug-2 clobbering.
### Fix
Both the analyzer and the IR lowerer now qualify function-body
`var` / parameter declarations with the enclosing function name
(or state handler name) under an internal key
`"__local__{scope}__{name}"`. Each function's locals and
parameters therefore get **distinct** symbol-table entries and
VarIds even when the source names collide.
Lookups inside a function body go through
`Analyzer::resolve_symbol` / `LoweringContext::scoped_key`,
which prefer the scope-qualified key over the bare one — so
a function-local `var x` correctly shadows a same-named global
(or another function's `var x`).
State-level locals (declared at `state Foo { var x: u8 }`
outside any handler) stay in the global namespace so every
handler in the state can read/write them across frames.
See `src/analyzer/mod.rs::resolve_symbol` / `resolve_key` /
`scoped_name` and `src/ir/lowering.rs::scoped_key`.
Together with fix #2 below, bugs #1b and #2 are completely
gone: the workaround-prefixed locals and params in `war/*.ne`
(the `dcf_`, `dwp_`, `pba_`, etc tags) are all reverted.
---
## 2. Function parameters share zero-page slots with nested calls — values clobbered across `JSR` *(FIXED)*
### Symptom
A function that takes parameters and then calls another function
sees its own parameters silently replaced by the inner call's
arguments. Any code path that reads the original parameter
*after* the inner call gets the wrong value.
### Reproduction
```nescript
fun draw_card_face(x: u8, y: u8, card: u8) {
var rank: u8 = card_rank(card) // x at $04 is now `card`
var suit: u8 = card_suit(card) // x at $04 is still `card`
// x is supposed to be 120 here, but it's actually `card`
var x1: u8 = x + 8 // computes card + 8, not 120 + 8
draw Tileset at: (x, y) frame: ... // draws at x = card, not 120
}
```
Concretely, calling `draw_card_face(120, 128, 0x93)` puts the
card sprite at `(0x93, 128)` — completely wrong.
### Root cause
Same allocator as bug #1: `func.locals[0..param_count]` are
mapped to `$04`, `$05`, `$06`, `$07`. The caller writes its own
arguments into the same zero-page slots before `JSR`, so the
caller's parameters at those slots get clobbered by the callee's
arguments. There is no save/restore wrapper around `JSR` and no
spill/reload pass to refresh the caller's parameters from a
backing copy.
### Workaround used in `examples/war/`
Every helper that takes parameters AND makes any nested function
call snapshots its parameters into fresh local variables at the
top of the function, then references the locals exclusively
throughout the body. See `war/render.ne::draw_card_face`,
`war/render.ne::draw_flying_card`, `war/deck.ne::push_back_a`,
`war/deck.ne::push_back_b`.
### Fix
`codegen::ir_codegen::IrCodeGen::new` now allocates every
function-local — including its parameters — into 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 4-instruction
**prologue** at every function entry:
```
LDA $04 ; transport slot 0
STA <param_0_addr>
LDA $05 ; transport slot 1
STA <param_1_addr>
... etc ...
```
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` (passing its own arguments to _its_ callee)
without corrupting the caller's saved parameters.
The cost is 4 LDA/STA pairs at every function entry (≈ 20
bytes of ROM, 16 cycles). Worth it to make the calling
convention sound.
See `codegen::ir_codegen::gen_function_prologue_spills_params_to_local_ram`
for the regression test.
---
## 3. Function-local variable names are in a flat global namespace *(FIXED)*
### Symptom
Two different functions cannot declare locals with the same
name. The compiler emits `E0501 duplicate declaration of '<name>'`
even though the locals are in disjoint scopes.
### Reproduction
```nescript
fun foo() {
var i: u8 = 0
while i < 10 { i += 1 }
}
fun bar() {
var i: u8 = 0 // E0501 duplicate declaration of 'i'
while i < 5 { i += 1 }
}
```
### Root cause
`src/analyzer/mod.rs::register_var` inserts every `var`
declaration into a single `self.symbols` map keyed only on the
variable's name, with no qualification by function or block:
```rust
fn register_var(&mut self, var: &VarDecl) {
if self.symbols.contains_key(&var.name) {
self.diagnostics.push(Diagnostic::error(
ErrorCode::E0501,
format!("duplicate declaration of '{}'", var.name),
var.span,
));
return;
}
...
}
```
`check_statement` calls `register_var` for every `Statement::VarDecl`
encountered while walking function bodies, so all locals across
all functions and all nested blocks land in the same namespace.
### Workaround used in `examples/war/`
Every function-local variable is prefixed with a short tag
identifying its enclosing function (e.g. `dfa_card` in
`draw_front_a`, `pba_slot` in `push_back_a`,
`dwp_px` in `draw_word_player`). This makes long files harder to
read but is fully mechanical.
### Fix
Same as #1b: the analyzer and IR lowerer now internally
qualify function-body `var` declarations with the enclosing
scope's name, so `foo`'s `var i` and `bar`'s `var i` resolve
to `__local__foo__i` and `__local__bar__i` respectively. The
two entries coexist peacefully in the (still-flat) symbol
table.
What *didn't* change: two `var i` declarations inside the
same function body still collide with E0501 (we scoped per
function body, not per nested block). That's a deliberate
trade-off — per-block scoping would require live-range
analysis to reuse RAM slots across blocks, which is a much
bigger change. The analyzer test
`analyze_still_rejects_duplicate_local_in_same_function`
pins this behaviour.
---
## 4. Per-frame sprite-per-scanline limit is invisible to user code *(FIXED)*
### Symptom
Drawing more than 8 sprites whose Y rectangles intersect a
single scanline causes the NES PPU to silently drop the excess
sprites past the 8th in OAM order. Letters or tiles just don't
render, and prior to this fix the compiler emitted no warning
even when the entire layout was a tree of literal coordinates
it could have checked.
### Reproduction
```nescript
// 9 letters all on the same Y row:
draw Letter at: (0, 100)
draw Letter at: (8, 100)
draw Letter at: (16, 100)
draw Letter at: (24, 100)
draw Letter at: (32, 100)
draw Letter at: (40, 100)
draw Letter at: (48, 100)
draw Letter at: (56, 100)
draw Letter at: (64, 100) // past budget — silently dropped
```
Pre-fix the compiler said nothing and the 9th letter never
showed up on hardware. Post-fix the analyzer emits:
```
warning[W0109]: state 'Main' draws 9 literal-coordinate sprites
overlapping scanline 100; the NES renders at
most 8 sprites per scanline
= help: stagger draws vertically by at least 8 pixels, reduce
the number of on-screen sprites, or split the draws across
`on_scanline` handlers
= note: the 9th and later sprites on a scanline are dropped
by the PPU, causing flicker or invisible objects on real
hardware
```
### Root cause
The 8-sprites-per-scanline cap is a real NES hardware
constraint, not a compiler bug — but NEScript had no static
check to catch the cases where user code makes the problem
obvious at compile time, even though the draw allocator is
sequential and the literal coords it sees are trivially
checkable.
### Workaround used in `examples/war/`
We staggered text rows by hand. The title screen's "WAR /
CARD GAME / 0 PLAYER / 1 PLAYER / 2 PLAYER" layout sits each
row at a different y so no scanline carries more than 7
sprites; the victory screen's "PLAYER X / WINS" wraps after
the player letter for the same reason. These layouts stay in
place post-fix — they now pass the analyzer cleanly because
they're under budget.
### Fix
`src/analyzer/mod.rs::check_sprite_scanline_budget` runs at
the end of `analyze_program`. For each state's `on_frame`
handler it walks the block tree (including nested
`if`/`while`/`for`/`loop`) collecting literal-coordinate
`draw` statements into a `Vec<(y, x, span)>`. Metasprite
draws expand into one tuple per tile via the metasprite's
`dx`/`dy` offset arrays, so a metasprite that covers four
tiles on the same y contributes four sprites to the overlap
count. Non-literal coordinates are skipped entirely because
the static analysis can't know where they land at runtime.
With the tuples collected, the analyzer iterates every
scanline 0..240 and counts sprites whose `y <= scanline <
y+8`. The worst scanline is cached and, if the count exceeds
8, a `W0109` diagnostic is emitted with labels pointing at
every draw site that contributed (deduplicated so metasprite
expansions don't spam the message).
Only `on_frame` is checked. `on_enter` / `on_exit` fire once
per transition and aren't the hot sprite path; checking them
would produce false positives on brief splash animations.
Conditional branches are unioned (conservative over-count) —
a sprite drawn inside an `if` counts for budget purposes even
if its runtime branch is exclusive with a sibling's. The
trade-off: the check stays local and simple, at the cost of
occasionally flagging hand-sliced layouts that the user knows
are actually safe.
### Regression tests
Five tests in `src/analyzer/tests.rs`:
- `analyze_sprite_scanline_budget_warns_over_eight` — nine
literal draws on the same `y` trips W0109.
- `analyze_sprite_scanline_budget_ok_when_staggered` — nine
draws each on a different `y` row are silent.
- `analyze_sprite_scanline_budget_skips_dynamic_coords`
draws with a `var`-backed `x` are skipped (no false
positive) because the analysis can't resolve them.
- `analyze_sprite_scanline_budget_expands_metasprites` — a
four-tile metasprite drawn three times trips W0109 because
the analyzer expands each draw into its per-tile offsets.
- `analyze_sprite_scanline_budget_recurses_into_if` — nine
draws inside an `if` block still trip W0109 (conservative
over-count).
---
## 5. The `inline` keyword is a hint and is silently ignored for short functions *(FIXED)*
### Symptom
Marking a tiny function `inline fun` did not inline it.
The compiler still emitted a real `JSR` with full parameter
passing through `$04`-`$07`, which meant the declared-inline
helpers in War (`card_rank`, `card_suit`, `set_phase`) still
paid the calling-convention overhead and still fell foul of
the bug-2 clobbering until the param-spill prologue landed.
### Reproduction
```nescript
inline fun card_rank(card: u8) -> u8 {
return card >> 4
}
```
Pre-fix, the asm dump showed `JSR __ir_fn_card_rank` at every
call site. Post-fix the body is spliced at each use and no
`JSR` is emitted at all.
### Root cause
The IR lowerer's old handling of `inline fun` was a no-op —
`is_inline` was read off the AST but the lowering path for
`Call` never branched on it. The optimizer passes also had
no inlining transform. So the keyword was parsed and then
dropped on the floor, producing regular out-of-line code.
### Fix
`src/ir/lowering.rs` now captures inline bodies up front in
`LoweringContext::capture_inline_bodies` and rewrites call
sites at lowering time. Two body shapes are supported:
1. **Single-return expression** (e.g. `return card >> 4`) —
captured as `InlineBody::Expression(Expr)`. At the call
site, the lowerer evaluates each argument into a fresh
temp, pushes a substitution frame mapping parameter names
to those temps, and recursively lowers the expression in
place of a `Call` op. No IR `Call`/`Return` ops are
emitted; the caller ends up with the same IR it would
have had if the expression were written directly.
2. **Void multi-statement body** — captured as
`InlineBody::Void(Vec<Statement>)`, but only when every
statement passes `is_splicable_void_stmt` (plain
assignments, statement-level calls, draws, palette/
background/scroll writes, `wait_frame`, inline asm, debug
builtins). Any control flow (`if`/`while`/`for`/`loop`/
`return`/`break`/`continue`/`transition`) disqualifies
the function from being inlined, and the call stays a
regular `Call`. This mirrors War's `set_phase` (a
four-statement global assign) and `reset_flight` (a
similar pattern).
Functions that are marked `inline` but have a body shape the
simple substitution machinery can't splice — notably ones
with conditional early returns like War's `wrap52` — fall
back to regular out-of-line calls with no diagnostic. That's
a deliberate trade-off: rather than refuse to compile the
program or emit a noisy warning, we degrade gracefully. The
`inline` keyword is now a best-effort hint whose "best
effort" is predictable and documented here.
### Substitution stack
Nested inline expansions push a fresh substitution frame so
an inline body calling another inline sees the inner
function's parameter substitutions, not its own.
`lookup_inline_sub` walks only the top of the stack because
inner bodies are lowered to completion before the stack is
popped, so an unambiguous "current" frame always exists. See
`LoweringContext::inline_subs_stack` and
`lower_expr::Expr::Ident` (which checks the substitution
stack before the global var table).
### Regression tests
Four tests in `src/ir/tests.rs`:
- `inline_fun_expression_body_emits_no_call_at_use_site`
a `return x * 2` inline emits no `Call`, just the multiply.
- `inline_fun_void_body_statements_are_spliced` — a void
three-statement inline compiles to three individual ops
at the caller, not a `Call`.
- `inline_fun_with_conditional_return_compiles_as_regular_call`
— a body with an `if ... return` pattern falls back to a
regular `Call` op.
- `inline_fun_nested_inlines_substitute_correctly` — inline A
calling inline B sees B's parameter substitutions, not A's.
---
## 6. `wide_hi` IR-lowering map leaked between functions and corrupted 16-bit ops *(FIXED)*
### Symptom
A function whose body had no 16-bit values whatsoever would
nonetheless emit `CmpEq16` (and other `Op16` variants) where the
*destination* temp aliased one of the *source* temps. The
resulting comparison effectively became "is this byte equal to
some uninitialised stack memory?", which in War caused the
phase-machine `match phase { ... }` dispatcher to skip the
`P_WIN_B` arm forever once the game first reached it — the game
would freeze with both cards face-up and "PLAYER B WINS" never
firing.
### Reproduction (pre-fix)
A handful of `u16` `+= 1` operations early in a state handler
followed by a long `match` chain on a `u8` was enough to trip it.
The minimum repro is roughly:
```nescript
var clock: u16 = 0
var phase: u8 = 0
on frame {
clock += 1 // wide op leaves wide_hi entries
match phase { // u8 match — should be 8-bit
0 => { phase = 1 }
1 => { phase = 2 }
2 => { phase = 3 }
3 => { phase = 4 }
4 => { phase = 5 }
5 => { phase = 6 }
6 => { phase = 7 }
7 => { /* corrupt — never matched */ }
_ => {}
}
}
```
The IR for the `phase == 7` arm came out as
`CmpEq16 { dest: T147, a_lo: T145, a_hi: T148, b_lo: T146,
b_hi: T147 }` — note `dest == b_hi`. The codegen happily emits
the corresponding 16-bit asm, but reads garbage for the `b_hi`
operand because it points at the same scratch slot the result
will be written to.
### Root cause
`src/ir/lowering.rs::IrLowerer` carries a `wide_hi: HashMap<IrTemp, IrTemp>`
that records "this low temp's high byte lives at this other
temp" pairs whenever a 16-bit value is produced. `lower_function`
and `lower_handler` both reset `next_temp = 0` at the start of
each function — but they did *not* clear `wide_hi`. Stale entries
from earlier functions stuck around and matched against fresh
temp IDs in subsequent functions (which start counting from 0
again), causing `is_wide(t)` and `widen(t)` to return spurious
"wide" results for what should have been narrow `u8` values.
When that happens inside `lower_binop`'s `Eq` path, `widen(r)`
returns the stale `(r, hi_r)` pair where `hi_r` happens to be the
*next* temp ID `fresh_temp()` will hand out a moment later — so
the `dest` temp and `b_hi` end up identical.
### Fix
`src/ir/lowering.rs`: in both `lower_function` and `lower_handler`,
add `self.wide_hi.clear();` immediately after `self.next_temp = 0;`.
Done in this PR.
### Why this didn't show up sooner
Every prior example either declared no `u16` globals at all, or
declared one and used it sparingly enough that the temp IDs
the leaked entries claimed never collided with the rest of the
function. War is the first example that combines a `u16`
free-running counter with a deep state machine that does many
`u8` comparisons in the same `on frame` body, which is exactly
the shape the bug needs to manifest.
### Regression test
`src/ir/tests.rs::wide_hi_does_not_leak_between_functions` (added
in this PR) compiles a two-function program where function A
uses a `u16 += 1` (creating wide entries) and function B does
`u8 == const` comparisons in a match. Pre-fix, the IR would emit
`CmpEq16` with aliased dest/source; post-fix it emits the
expected 8-bit `CmpEq`.
---
## Verification path after fixes
Once any of the bugs above are fixed in the compiler, the
corresponding workarounds in `examples/war/*.ne` should be
reverted in the same PR so:
- The example demonstrates idiomatic code, not workaround code.
- The PR's diff visibly proves the fix works end-to-end (the
workaround removal would otherwise be a silent regression).
- The committed `examples/war.nes` rebuilds byte-identically to
the reverted source, which the pre-commit hook enforces.
The relevant workaround sites are catalogued in each bug's
"Workaround used" section above; grep for the prefix tags
(`dcf_`, `dfa_`, `pba_`, `dwp_`, …) to find them all.