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

Fix three compiler bugs exposed by array-using examples

Landing bug A from the previous writeup plus two adjacent bugs
that the fix exposed. All three miscompile anything that uses a
u8[N] global with a literal initializer.

1. Array-literal globals are now actually initialized.
   `lower_program` only expanded `Expr::StructLiteral` into per-
   field synthetic globals — `Expr::ArrayLiteral` hit
   `eval_const`, returned `None`, and the array boot-cleared to
   zero. `IrGlobal` now carries an `init_array: Vec<u8>`
   populated by lowering, and the IR codegen startup loop emits
   one `LDA #byte; STA base+i` pair per element.

2. Local variables no longer overlap array globals.
   `IrCodeGen::new` advanced `local_ram_next` past
   `max_global_base + 1` — for an array at `$0300-$0303` it
   placed the first handler-local at `$0301`, inside the array.
   The frame handler's stores through the local then corrupted
   the array mid-frame. The allocator now walks the analyzer's
   `VarAllocation` list and advances past `address + size` for
   every RAM global, not just the base.

3. Peephole `remove_redundant_loads` honors indexed LDAs.
   The pass tracked `LDA Immediate/ZeroPage/Absolute` but let
   `LDA AbsoluteX/AbsoluteY/ZeroPageX/IndirectX/IndirectY` fall
   through the match, leaving the A-equivalence tracker
   unchanged. A later `LDA #v` that happened to match a stale
   entry from BEFORE the indexed load would then be dropped as
   "already in A" — a silent miscompile that turned every
   `draw Sprite at: (arr[i], arr[j])` pattern into garbage
   (the second array index would be computed from `arr[i]`'s
   value, reading way out of bounds). Indexed LDAs now clear
   the tracker.

Regression tests:
- `src/codegen/peephole.rs`: a synthetic
  `LDA #0; TAX; LDA AbsX(arr1); STA temp; LDA #0; TAX;
   LDA AbsX(arr2); ...` sequence asserts both `LDA #0`s survive.
- `src/ir/tests.rs`: verifies `var xs: u8[4] = [1,2,3,4]`
  populates `IrGlobal::init_array` with `[1,2,3,4]`.
- `tests/integration_test.rs`: two IR-codegen tests — one checks
  the startup instructions contain `LDA #v; STA base+i` for
  every element, the other compiles a handler-local var
  alongside an array global and asserts no post-init stores
  land inside the array.

Smoke test impact (14/14 still passing, now more visible):
- arrays_and_functions:  56 -> 104 nonBlack, now animated
- loop_break_continue:   52 -> 208 (player + 3 hazards visible)
- structs_enums_for:     52 -> 104 (player + enemy visible)

Existing examples unchanged; no remaining work for bug B
(static OAM slot allocation in loops) — that's the next PR.

https://claude.ai/code/session_014Z5y3Q9krLcAxYpZQJhZ5V
This commit is contained in:
Claude 2026-04-12 19:32:22 +00:00
parent 1525922faa
commit f49dbce686
No known key found for this signature in database
9 changed files with 317 additions and 10 deletions

View file

@ -854,3 +854,141 @@ fn ir_codegen_multi_oam() {
let rom_data = compile_with_ir_codegen(source);
rom::validate_ines(&rom_data).expect("should be valid iNES");
}
#[test]
fn ir_codegen_array_literal_globals_emit_per_byte_init() {
// Regression test: `var xs: u8[4] = [10, 20, 30, 40]` used to
// compile to a zero-initialized array because `eval_const`
// returned `None` for `Expr::ArrayLiteral` and no startup
// stores were emitted. The fix captures the literal values
// in `IrGlobal::init_array` and has the IR codegen emit one
// `LDA #imm; STA base+i` per byte during startup.
use nescript::asm::{AddressingMode, Opcode};
use nescript::codegen::IrCodeGen;
let source = r#"
game "ArrLit" { mapper: NROM }
var xs: u8[4] = [10, 20, 30, 40]
on frame { wait_frame }
start Main
"#;
let (prog, diags) = nescript::parser::parse(source);
assert!(diags.is_empty(), "parse errors: {diags:?}");
let prog = prog.unwrap();
let analysis = analyzer::analyze(&prog);
let mut ir_program = ir::lower(&prog, &analysis);
optimizer::optimize(&mut ir_program);
let xs_addr = analysis
.var_allocations
.iter()
.find(|a| a.name == "xs")
.expect("xs should be allocated")
.address;
let codegen = IrCodeGen::new(&analysis.var_allocations, &ir_program);
let instructions = codegen.generate(&ir_program);
// For each element, look for `LDA #val` followed shortly by
// `STA absolute(xs_addr + i)`. We don't require them to be
// adjacent because the peephole passes can reshuffle, but a
// store of the correct value to the correct address must
// exist.
for (i, &expected) in [10u8, 20, 30, 40].iter().enumerate() {
let target = xs_addr + i as u16;
let has_store = instructions.windows(2).any(|w| {
matches!(w[0].mode, AddressingMode::Immediate(v) if v == expected)
&& w[0].opcode == Opcode::LDA
&& w[1].opcode == Opcode::STA
&& matches!(w[1].mode, AddressingMode::Absolute(a) if a == target)
});
assert!(
has_store,
"expected `LDA #{expected}; STA ${target:04X}` for xs[{i}] but did not find it"
);
}
}
#[test]
fn ir_codegen_locals_do_not_overlap_array_globals() {
// Regression test for the local-allocator off-by-array-size
// bug. `IrCodeGen::new` used to start handler-local vars at
// `max_global_base + 1`, which for an array global at
// `$0300-$0303` put the first local at `$0301` — inside the
// array. Any store through that local then corrupted the
// array mid-frame. The fix advances past the global's END,
// not its base.
//
// We verify by asking the IR codegen what addresses it
// assigned. Since `var_addrs` is private, we check indirectly
// via emitted instructions: any `STA $030N` for N > 3 that
// isn't part of the startup init must be writing to a local
// whose address is outside the array. If the bug regressed,
// we'd see `STA $0302` or similar in the frame handler's
// computation code.
use nescript::asm::{AddressingMode, Opcode};
use nescript::codegen::IrCodeGen;
let source = r#"
game "LocalVsArr" { mapper: NROM }
var xs: u8[4] = [11, 22, 33, 44]
on frame {
var tmp: u8 = 0
tmp = xs[0]
tmp += 1
wait_frame
}
start Main
"#;
let (prog, diags) = nescript::parser::parse(source);
assert!(diags.is_empty(), "parse errors: {diags:?}");
let prog = prog.unwrap();
let analysis = analyzer::analyze(&prog);
let mut ir_program = ir::lower(&prog, &analysis);
optimizer::optimize(&mut ir_program);
let xs_alloc = analysis
.var_allocations
.iter()
.find(|a| a.name == "xs")
.expect("xs should be allocated");
let xs_base = xs_alloc.address;
let xs_end = xs_base + xs_alloc.size; // one past last element
let codegen = IrCodeGen::new(&analysis.var_allocations, &ir_program);
let instructions = codegen.generate(&ir_program);
// Collect the (ordered) list of `STA absolute` targets and
// immediate values preceding each store. The first four
// stores into `[xs_base, xs_end)` should be the `LDA #imm;
// STA addr` init pairs — those are fine. Any STA into the
// array AFTER the init sequence would indicate a local var
// was allocated inside the array.
let mut init_stores_seen = 0usize;
for w in instructions.windows(2) {
if w[1].opcode != Opcode::STA {
continue;
}
let AddressingMode::Absolute(addr) = w[1].mode else {
continue;
};
if addr < xs_base || addr >= xs_end {
continue;
}
if w[0].opcode == Opcode::LDA
&& matches!(w[0].mode, AddressingMode::Immediate(_))
&& init_stores_seen < 4
{
init_stores_seen += 1;
continue;
}
panic!(
"store into xs array (${addr:04X}) after init sequence — \
local probably overlapping with array global"
);
}
assert_eq!(
init_stores_seen, 4,
"expected 4 init stores for xs[0..4], found {init_stores_seen}"
);
}