1
0
Fork 0
mirror of https://github.com/imjasonh/nescript synced 2026-07-08 08:55:38 +00:00

fix(optimizer): preserve cross-block LoadImm uses in const_fold DCE

`const_fold_block`'s per-block dead-code pass was collecting temp
usage from only the block it was folding, so a `LoadImm` whose
destination is consumed by a *sibling* block (for example via the
merge block's branch terminator) was incorrectly treated as dead
and dropped. The `and` / `or` short-circuit lowering emits exactly
that shape: the false path writes `LoadImm(result, 0)` and joins
with the right path at an `and_end` / `or_end` block whose branch
terminator reads `result`. After the DCE the false path's store
was gone, leaving the zero-page result slot to carry whatever value
the *previous* `and` / `or` evaluation had written there — stale
data that bled into subsequent conditional branches.

I found this while instrumenting `examples/platformer.ne` through a
puppeteer-driven jsnes harness, stepping one frame at a time and
snapshotting the full zero-page trace of each scenario (title-skip,
hold-right, hold-left, jump-spam, coin-drift, enemy-stomp, long-run).
In a clean idle run the enemy-1 stomp bounce (`rise_count = 6`,
`fall_vy = 0`) fired at emulator frames 83 and 96 with `camera_x`
= 61 and 74, i.e. with `e1_sx` = 39 and 26, nowhere near the
intended `[72, 96)` pickup window. The trigger turned out to be
the slot alias: every time `c2_sx` landed in its pickup window
(so the coin-2 `and` stored 1 into ZP(130)) and the player was
mid-fall at or past `player_y = 152`, the enemy-1 stomp `and`
short-circuited to its false path, left ZP(130) at 1, and the
stomp `if` fired on stale data.

The fix is to compute function-wide source-operand usage once before
folding each function's blocks and OR it into the per-block liveness
check, so a LoadImm is only dropped if nobody — neither its own
block nor any other block in the function — reads the temp. Added a
regression test (`const_fold_preserves_loadimm_used_by_sibling_branch`)
that builds the exact CFG shape the `and` lowering emits and
verifies the false-path `LoadImm(result, 0)` survives optimization.

Impact on the example ROMs:

- `examples/platformer.nes`: enemy-1 stomp now fires only when
  `e1_sx ∈ [72, 96)`, as the source intends. The pixel golden is
  unchanged (`player_y` converges back to the ground line before
  frame 180), but the audio hash flips because the spurious
  `play hit` sfx calls during coin-2 passage are gone. Committing
  the new `tests/emulator/goldens/platformer.audio.hash`.

- `examples/logic_ops.nes`, `examples/bitwise_ops.nes`,
  `examples/match_demo.nes`, `examples/mmc3_per_state_split.nes`,
  `examples/two_player.nes`: byte-different but observably
  unchanged — their pixel + audio goldens still match to the byte.
  They exercise `and` / `or` in the source and now compile through
  the corrected DCE.

All other example ROMs are byte-identical to pre-fix. `cargo fmt`,
`cargo clippy --all-targets`, `cargo test --release` (498 tests),
and `tests/emulator/run_examples.mjs` (22/22 goldens) are clean.

https://claude.ai/code/session_013Bi4H4YQ5or5HtMB4doUFi
This commit is contained in:
Claude 2026-04-13 16:29:44 +00:00
parent 59905147b4
commit 629fdcfce0
No known key found for this signature in database
9 changed files with 111 additions and 8 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -218,13 +218,22 @@ fn strength_reduce_block(block: &mut IrBasicBlock) {
/// destination temps are no longer referenced anywhere in the block.
fn const_fold(program: &mut IrProgram) {
for func in &mut program.functions {
// Pre-compute function-wide source-operand usage: a LoadImm's
// destination may be read by an op in a sibling block or
// consumed by a branch/jump terminator in another block, so
// the per-block DCE below can't decide liveness by looking
// at its own block alone. Cf. the `and` / `or` short-circuit
// lowering: the false path writes `LoadImm(result, 0)` but
// `result` is read by the merge block's branch, not in the
// false block itself.
let func_used = collect_used_temps(func);
for block in &mut func.blocks {
const_fold_block(block);
const_fold_block(block, &func_used);
}
}
}
fn const_fold_block(block: &mut IrBasicBlock) {
fn const_fold_block(block: &mut IrBasicBlock, func_used: &HashSet<IrTemp>) {
let mut constants: HashMap<IrTemp, u8> = HashMap::new();
// First pass: fold arithmetic / comparison ops into LoadImm where possible.
@ -349,12 +358,21 @@ fn const_fold_block(block: &mut IrBasicBlock) {
}
}
// Second pass: remove LoadImm ops whose dest temps are no longer referenced
// as source operands by anything else in the block (ops + terminator).
let used = collect_used_temps_in_block(block);
// Second pass: remove LoadImm ops whose dest temps are no longer
// referenced locally AND aren't referenced function-wide. The
// function-wide check is what makes this pass correct in the
// presence of control-flow merges — a LoadImm written in one
// block and consumed in another (for example the `and`/`or`
// short-circuit false path, whose `LoadImm(result, 0)` is only
// read by the downstream merge block's branch terminator) must
// not be dropped. The previous implementation only consulted
// block-local usage and silently dropped these cross-block
// LoadImms, leaving the zero-page result slot to carry whatever
// value the *previous* AND/OR had written into it.
let used_local = collect_used_temps_in_block(block);
block.ops.retain(|op| {
if let IrOp::LoadImm(t, _) = op {
used.contains(t)
used_local.contains(t) || func_used.contains(t)
} else {
true
}
@ -397,7 +415,10 @@ fn collect_used_temps(func: &IrFunction) -> HashSet<IrTemp> {
used
}
/// Collect temps used as source operands within a single block (ops + terminator).
/// Collect temps used as source operands within a single block
/// (ops + terminator). Used by the per-block `LoadImm` DCE so we
/// can cheaply find local uses before falling back on the
/// function-wide liveness set.
fn collect_used_temps_in_block(block: &IrBasicBlock) -> HashSet<IrTemp> {
let mut used = HashSet::new();
collect_used_from_block(block, &mut used);

View file

@ -436,3 +436,85 @@ fn inline_removes_trivial() {
.any(|op| matches!(op, IrOp::Call(..)));
assert!(!has_call, "call to trivial function should be removed");
}
// ---------------------------------------------------------------------------
// Cross-block LoadImm liveness (regression for stale-and-result bug)
// ---------------------------------------------------------------------------
/// A `LoadImm(result, 0)` in a predecessor block whose only consumer
/// is a branch terminator in a *successor* block must not be dropped
/// by the const-folding pass. This is the shape the logical-`and`
/// short-circuit false path lowers to, and dropping the `LoadImm`
/// leaves the zero-page slot holding whichever value the previous
/// `and` evaluation wrote — which was observable in the platformer
/// example as spurious enemy-stomp bounces firing whenever coin 2
/// happened to be in its pickup window.
#[test]
fn const_fold_preserves_loadimm_used_by_sibling_branch() {
let cond = IrTemp(0);
let result = IrTemp(1);
let zero = IrTemp(2);
// Shape mirrors `lower_logical_and`: an entry block that branches
// on `cond`, a right-side block that writes `result` via an
// `Or(result, <true-case>, zero)`, a false-side block that only
// writes `LoadImm(result, 0)`, and a merge block whose terminator
// reads `result`.
let entry = IrBasicBlock {
label: "entry".to_string(),
ops: vec![IrOp::LoadImm(cond, 1)],
terminator: IrTerminator::Branch(cond, "right".to_string(), "false_path".to_string()),
};
let right = IrBasicBlock {
label: "right".to_string(),
ops: vec![IrOp::LoadImm(zero, 0), IrOp::Or(result, cond, zero)],
terminator: IrTerminator::Jump("end".to_string()),
};
// This is the block that previously tripped the bug: its
// `LoadImm(result, 0)` is the only op, and `result` is not used
// as a source operand anywhere *within* this block — but it is
// read by the `end` block's branch terminator.
let false_path = IrBasicBlock {
label: "false_path".to_string(),
ops: vec![IrOp::LoadImm(result, 0)],
terminator: IrTerminator::Jump("end".to_string()),
};
let end = IrBasicBlock {
label: "end".to_string(),
ops: vec![],
terminator: IrTerminator::Return(Some(result)),
};
let mut prog = IrProgram {
functions: vec![IrFunction {
name: "and_like".to_string(),
blocks: vec![entry, right, false_path, end],
locals: vec![],
param_count: 0,
has_return: true,
source_span: Span::new(0, 0, 0),
}],
globals: vec![],
rom_data: vec![],
states: vec![],
start_state: String::new(),
};
optimize(&mut prog);
// Find the false_path block post-optimization and verify that
// its LoadImm(result, 0) is still there.
let func = &prog.functions[0];
let fp = func
.blocks
.iter()
.find(|b| b.label == "false_path")
.expect("false_path block should survive as reachable");
assert!(
fp.ops
.iter()
.any(|op| matches!(op, IrOp::LoadImm(t, 0) if *t == result)),
"false-path LoadImm(result, 0) must survive optimization; block ops were {:?}",
fp.ops
);
}

View file

@ -1 +1 @@
7c8e60e4 132084
a00949db 132084