mirror of
https://github.com/imjasonh/nescript
synced 2026-07-18 06:36:57 +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:
parent
155a0e7096
commit
4e8e349d7c
8 changed files with 334 additions and 169 deletions
BIN
examples/war.nes
BIN
examples/war.nes
Binary file not shown.
|
|
@ -418,6 +418,96 @@ None — we just live with the JSR overhead and the bug-2 fallout.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 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
|
## Verification path after fixes
|
||||||
|
|
||||||
Once any of the bugs above are fixed in the compiler, the
|
Once any of the bugs above are fixed in the compiler, the
|
||||||
|
|
|
||||||
|
|
@ -121,191 +121,187 @@ state Playing {
|
||||||
draw_table()
|
draw_table()
|
||||||
|
|
||||||
// ── Phase dispatch ───────────────────────────────
|
// ── Phase dispatch ───────────────────────────────
|
||||||
// The phases share a simple "timer hits target, advance"
|
// We use `match` instead of a flat if-chain so each
|
||||||
// shape. Each arm of the if-chain is self-contained and
|
// frame only runs the FIRST matching arm. A naive
|
||||||
// ends either with a set_phase call or a fall-through
|
// chain like `if phase == X { ... } if phase == Y {...}`
|
||||||
// that waits for more input / time.
|
// would let one phase transition into another (via
|
||||||
|
// `set_phase`) and then run the next phase's body in
|
||||||
if phase == P_WAIT_A {
|
// the same frame, which advances animation timers twice
|
||||||
// Human prompt: hint blink above the deck.
|
// and made the card-fly overshoot its endpoint by
|
||||||
if a_is_cpu == 0 {
|
// FLY_STEP every time.
|
||||||
if (phase_timer & 32) == 0 {
|
match phase {
|
||||||
draw_word_press(8, 200)
|
P_WAIT_A => {
|
||||||
}
|
// Human prompt: hint blink above the deck.
|
||||||
if button.a or button.start {
|
if a_is_cpu == 0 {
|
||||||
begin_draw_a()
|
if (phase_timer & 32) == 0 {
|
||||||
}
|
draw_word_press(8, 200)
|
||||||
} else {
|
}
|
||||||
// CPU draws after a short delay.
|
if button.a or button.start {
|
||||||
if phase_timer >= CPU_THINK_FRAMES {
|
begin_draw_a()
|
||||||
begin_draw_a()
|
}
|
||||||
|
} else {
|
||||||
|
// CPU draws after a short delay.
|
||||||
|
if phase_timer >= CPU_THINK_FRAMES {
|
||||||
|
begin_draw_a()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
P_FLY_A => {
|
||||||
|
step_fly_pos()
|
||||||
if phase == P_FLY_A {
|
draw_flying_card(fly_x, fly_y)
|
||||||
step_fly_pos()
|
if phase_timer >= FRAMES_FLY {
|
||||||
draw_flying_card(fly_x, fly_y)
|
set_phase(P_WAIT_B)
|
||||||
if phase_timer >= FRAMES_FLY {
|
|
||||||
set_phase(P_WAIT_B)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if phase == 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()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
P_WAIT_B => {
|
||||||
|
// A's card is now parked in its play slot.
|
||||||
if phase == P_FLY_B {
|
draw_card_face(PLAY_A_X, PLAY_Y, card_a)
|
||||||
// A is in place; B is flying.
|
if b_is_cpu == 0 {
|
||||||
draw_card_face(PLAY_A_X, PLAY_Y, card_a)
|
if (phase_timer & 32) == 0 {
|
||||||
step_fly_pos()
|
draw_word_press(208, 200)
|
||||||
draw_flying_card(fly_x, fly_y)
|
}
|
||||||
if phase_timer >= FRAMES_FLY {
|
if p2.button.a or p2.button.start or button.a or button.start {
|
||||||
set_phase(P_REVEAL)
|
begin_draw_b()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if phase_timer >= CPU_THINK_FRAMES {
|
||||||
|
begin_draw_b()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
P_FLY_B => {
|
||||||
|
// A is in place; B is flying.
|
||||||
if phase == P_REVEAL {
|
draw_card_face(PLAY_A_X, PLAY_Y, card_a)
|
||||||
draw_card_face(PLAY_A_X, PLAY_Y, card_a)
|
step_fly_pos()
|
||||||
draw_card_face(PLAY_B_X, PLAY_Y, card_b)
|
draw_flying_card(fly_x, fly_y)
|
||||||
if phase_timer >= FRAMES_REVEAL {
|
if phase_timer >= FRAMES_FLY {
|
||||||
set_phase(P_RESOLVE)
|
set_phase(P_REVEAL)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
P_REVEAL => {
|
||||||
|
draw_card_face(PLAY_A_X, PLAY_Y, card_a)
|
||||||
if phase == P_RESOLVE {
|
draw_card_face(PLAY_B_X, PLAY_Y, card_b)
|
||||||
// Both cards go into the pot regardless of outcome.
|
if phase_timer >= FRAMES_REVEAL {
|
||||||
push_back_pot(card_a)
|
set_phase(P_RESOLVE)
|
||||||
push_back_pot(card_b)
|
}
|
||||||
var pf_r: u8 = compare_cards(card_a, card_b)
|
|
||||||
if pf_r == 1 {
|
|
||||||
play CheerA
|
|
||||||
set_phase(P_WIN_A)
|
|
||||||
}
|
}
|
||||||
if pf_r == 2 {
|
P_RESOLVE => {
|
||||||
play CheerB
|
// Both cards go into the pot regardless of outcome.
|
||||||
set_phase(P_WIN_B)
|
push_back_pot(card_a)
|
||||||
|
push_back_pot(card_b)
|
||||||
|
pf_result = compare_cards(card_a, card_b)
|
||||||
|
if pf_result == 1 {
|
||||||
|
play CheerA
|
||||||
|
set_phase(P_WIN_A)
|
||||||
|
}
|
||||||
|
if pf_result == 2 {
|
||||||
|
play CheerB
|
||||||
|
set_phase(P_WIN_B)
|
||||||
|
}
|
||||||
|
if pf_result == 0 {
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if pf_r == 0 {
|
P_WIN_A => {
|
||||||
// It's a tie — but only enter the war flow if both
|
draw_card_face(PLAY_A_X, PLAY_Y, card_a)
|
||||||
// sides actually have cards left to bury. If a
|
draw_card_face(PLAY_B_X, PLAY_Y, card_b)
|
||||||
// player ran out of cards on this very tie, the
|
if phase_timer >= FRAMES_FLY {
|
||||||
// OTHER player wins by default and takes the pot.
|
pot_to_a()
|
||||||
if deck_a_count == 0 {
|
set_phase(P_CHECK)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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()
|
pot_to_b()
|
||||||
|
set_phase(P_CHECK)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
P_WAR_BURY => {
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
P_CHECK => {
|
||||||
|
if deck_a_count == 0 {
|
||||||
winner = 1
|
winner = 1
|
||||||
transition Victory
|
transition Victory
|
||||||
}
|
}
|
||||||
if deck_b_count == 0 {
|
if deck_b_count == 0 {
|
||||||
pot_to_a()
|
|
||||||
winner = 0
|
winner = 0
|
||||||
transition Victory
|
transition Victory
|
||||||
}
|
}
|
||||||
play WarFlash
|
// No winner yet — start the next round.
|
||||||
set_phase(P_WAR_BANNER)
|
card_a = 0
|
||||||
|
card_b = 0
|
||||||
|
set_phase(P_WAIT_A)
|
||||||
}
|
}
|
||||||
}
|
_ => {}
|
||||||
|
|
||||||
if phase == 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 {
|
|
||||||
pot_to_a()
|
|
||||||
set_phase(P_CHECK)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if phase == 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if phase == 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if phase == P_WAR_BURY {
|
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if phase == P_CHECK {
|
|
||||||
if deck_a_count == 0 {
|
|
||||||
winner = 1
|
|
||||||
transition Victory
|
|
||||||
}
|
|
||||||
if deck_b_count == 0 {
|
|
||||||
winner = 0
|
|
||||||
transition Victory
|
|
||||||
}
|
|
||||||
// No winner yet — start the next round.
|
|
||||||
card_a = 0
|
|
||||||
card_b = 0
|
|
||||||
set_phase(P_WAIT_A)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,7 @@ var a_is_cpu: u8 = 1 // bool-ish
|
||||||
var b_is_cpu: u8 = 1
|
var b_is_cpu: u8 = 1
|
||||||
var phase: u8 = 0 // one of the P_* constants
|
var phase: u8 = 0 // one of the P_* constants
|
||||||
var phase_timer: u8 = 0 // counts up during each phase
|
var phase_timer: u8 = 0 // counts up during each phase
|
||||||
|
var pf_result: u8 = 0 // P_RESOLVE comparison result (1=A, 2=B, 0=tie)
|
||||||
|
|
||||||
// ── Animation state ───────────────────────────────────────
|
// ── Animation state ───────────────────────────────────────
|
||||||
// Shared by every card-fly phase. We step (fly_x, fly_y) by a
|
// Shared by every card-fly phase. We step (fly_x, fly_y) by a
|
||||||
|
|
|
||||||
|
|
@ -395,6 +395,17 @@ impl LoweringContext {
|
||||||
|
|
||||||
fn lower_function(&mut self, fun: &FunDecl) {
|
fn lower_function(&mut self, fun: &FunDecl) {
|
||||||
self.next_temp = 0;
|
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_blocks = Vec::new();
|
||||||
self.current_locals = Vec::new();
|
self.current_locals = Vec::new();
|
||||||
|
|
||||||
|
|
@ -460,6 +471,12 @@ impl LoweringContext {
|
||||||
|
|
||||||
fn lower_handler(&mut self, name: &str, block: &Block, state: &StateDecl) {
|
fn lower_handler(&mut self, name: &str, block: &Block, state: &StateDecl) {
|
||||||
self.next_temp = 0;
|
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();
|
self.current_blocks = Vec::new();
|
||||||
// Seed `current_locals` with the state's declared locals so any
|
// Seed `current_locals` with the state's declared locals so any
|
||||||
// `VarDecl` inside the handler body — tracked by
|
// `VarDecl` inside the handler body — tracked by
|
||||||
|
|
|
||||||
|
|
@ -740,3 +740,64 @@ fn lower_modulo_emits_mod_op_not_load_imm_zero() {
|
||||||
.collect::<Vec<_>>()
|
.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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1 +1 @@
|
||||||
37075c32 132084
|
4651593c 132084
|
||||||
|
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 9.8 KiB After Width: | Height: | Size: 9.9 KiB |
Loading…
Add table
Add a link
Reference in a new issue