1
0
Fork 0
mirror of https://github.com/imjasonh/nescript synced 2026-07-08 17:06:04 +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
This commit is contained in:
Claude 2026-04-15 15:57:26 +00:00
parent 155a0e7096
commit 4e8e349d7c
No known key found for this signature in database
8 changed files with 334 additions and 169 deletions

View file

@ -395,6 +395,17 @@ impl LoweringContext {
fn lower_function(&mut self, fun: &FunDecl) {
self.next_temp = 0;
// Clear the wide-temp tracking map. `wide_hi` records "this
// low temp has its high byte at this other temp" entries
// produced by `make_wide`; without clearing it, the entries
// from previous functions leak into the next function and
// get matched against fresh temp IDs (since next_temp resets
// to 0). That manifests as `is_wide(t)` spuriously returning
// true and, worse, `widen(t)` returning a stale `hi` temp ID
// that collides with a later `fresh_temp()` allocation —
// producing 16-bit IR ops where the destination temp is
// *also* one of the source temps. See COMPILER_BUGS.md §6.
self.wide_hi.clear();
self.current_blocks = Vec::new();
self.current_locals = Vec::new();
@ -460,6 +471,12 @@ impl LoweringContext {
fn lower_handler(&mut self, name: &str, block: &Block, state: &StateDecl) {
self.next_temp = 0;
// Same per-function reset as `lower_function`. See the
// commentary there and COMPILER_BUGS.md §6 for why this is
// critical — without it, state-handler bodies pick up wide
// temp pairs left over from the previous function and emit
// catastrophically wrong 16-bit IR ops.
self.wide_hi.clear();
self.current_blocks = Vec::new();
// Seed `current_locals` with the state's declared locals so any
// `VarDecl` inside the handler body — tracked by

View file

@ -740,3 +740,64 @@ fn lower_modulo_emits_mod_op_not_load_imm_zero() {
.collect::<Vec<_>>()
);
}
#[test]
fn wide_hi_does_not_leak_between_functions() {
// Regression test for COMPILER_BUGS.md §6: the IR lowerer's
// `wide_hi` map used to persist across function boundaries
// even though `next_temp` resets to 0 per function. A
// function whose body had no u16 ops would inherit stale
// `(temp_id -> high_byte)` entries from earlier functions
// and emit `CmpEq16` (or other 16-bit ops) where the
// destination temp aliased one of the source temps.
//
// The shape that reproduces it: function A bumps a u16
// global (creating wide entries); function B does u8 ==
// const compares against a u8 global. Pre-fix, function B's
// last few comparisons would lower to `CmpEq16`. Post-fix,
// they all stay narrow.
let ir = lower_ok(
r#"
game "Test" { mapper: NROM }
var clock: u16 = 0
var phase: u8 = 0
var hits: u8 = 0
fun bump_a() { hits += 1 }
fun bump_b() { hits += 2 }
fun bump_c() { hits += 3 }
fun bump_d() { hits += 4 }
on frame {
clock += 1
if phase == 0 { bump_a() }
if phase == 1 { bump_b() }
if phase == 2 { bump_c() }
if phase == 3 { bump_d() }
wait_frame
}
start Main
"#,
);
let frame_fn = ir
.functions
.iter()
.find(|f| f.name.contains("frame"))
.expect("frame handler should exist");
let mut wide_eq_dest_aliases = 0;
for op in frame_fn.blocks.iter().flat_map(|b| &b.ops) {
if let IrOp::CmpEq16 {
dest, b_hi, a_hi, ..
} = op
{
// The dest of a 16-bit compare must never alias one
// of its operand high bytes — that's the symptom of
// bug #6 from war/COMPILER_BUGS.md.
if dest == b_hi || dest == a_hi {
wide_eq_dest_aliases += 1;
}
}
}
assert_eq!(
wide_eq_dest_aliases, 0,
"wide CmpEq16 destination aliased a source operand — wide_hi leaked between functions"
);
}