1
0
Fork 0
mirror of https://github.com/imjasonh/nescript synced 2026-07-11 18:20:47 +00:00
nescript/src/ir/tests.rs

668 lines
17 KiB
Rust
Raw Normal View History

use super::*;
use crate::analyzer;
use crate::parser;
fn lower_ok(input: &str) -> IrProgram {
let (prog, diags) = parser::parse(input);
assert!(diags.is_empty(), "parse errors: {diags:?}");
let prog = prog.unwrap();
let analysis = analyzer::analyze(&prog);
assert!(
analysis.diagnostics.iter().all(|d| !d.is_error()),
"analysis errors: {:?}",
analysis.diagnostics
);
lower(&prog, &analysis)
}
#[test]
fn lower_minimal_program() {
let ir = lower_ok(
r#"
game "Test" { mapper: NROM }
var px: u8 = 128
on frame { px = 1 }
start Main
"#,
);
assert_eq!(ir.globals.len(), 1);
assert_eq!(ir.globals[0].name, "px");
assert_eq!(ir.globals[0].init_value, Some(128));
// Should have at least one function (the frame handler)
assert!(!ir.functions.is_empty());
}
#[test]
fn lower_var_assignment() {
let ir = lower_ok(
r#"
game "Test" { mapper: NROM }
var x: u8 = 0
on frame { x = 42 }
start Main
"#,
);
let frame_fn = ir
.functions
.iter()
.find(|f| f.name.contains("frame"))
.unwrap();
// Should have a StoreVar op
let has_store = frame_fn
.blocks
.iter()
.flat_map(|b| &b.ops)
.any(|op| matches!(op, IrOp::StoreVar(..)));
assert!(has_store, "should emit StoreVar for assignment");
}
#[test]
fn lower_plus_assign() {
let ir = lower_ok(
r#"
game "Test" { mapper: NROM }
var x: u8 = 0
on frame { x += 5 }
start Main
"#,
);
let frame_fn = ir
.functions
.iter()
.find(|f| f.name.contains("frame"))
.unwrap();
let has_add = frame_fn
.blocks
.iter()
.flat_map(|b| &b.ops)
.any(|op| matches!(op, IrOp::Add(..)));
assert!(has_add, "should emit Add for += operator");
}
#[test]
fn lower_if_creates_branch() {
let ir = lower_ok(
r#"
game "Test" { mapper: NROM }
var x: u8 = 0
on frame {
if x == 0 { x = 1 }
}
start Main
"#,
);
let frame_fn = ir
.functions
.iter()
.find(|f| f.name.contains("frame"))
.unwrap();
let has_branch = frame_fn
.blocks
.iter()
.any(|b| matches!(&b.terminator, IrTerminator::Branch(..)));
assert!(
has_branch,
"if statement should produce a Branch terminator"
);
}
#[test]
fn lower_while_creates_loop() {
let ir = lower_ok(
r#"
game "Test" { mapper: NROM }
var x: u8 = 0
on frame {
while x < 10 { x += 1 }
}
start Main
"#,
);
let frame_fn = ir
.functions
.iter()
.find(|f| f.name.contains("frame"))
.unwrap();
// A while loop needs at least 3 blocks: condition check, body, and exit
assert!(
frame_fn.blocks.len() >= 3,
"while should create multiple blocks, got {}",
frame_fn.blocks.len()
);
}
#[test]
fn lower_button_read() {
let ir = lower_ok(
r#"
game "Test" { mapper: NROM }
var px: u8 = 0
on frame {
if button.right { px += 1 }
}
start Main
"#,
);
let frame_fn = ir
.functions
.iter()
.find(|f| f.name.contains("frame"))
.unwrap();
let has_input = frame_fn
.blocks
.iter()
.flat_map(|b| &b.ops)
.any(|op| matches!(op, IrOp::ReadInput(_, _)));
assert!(has_input, "button read should emit ReadInput op");
}
#[test]
fn lower_draw_sprite() {
let ir = lower_ok(
r#"
game "Test" { mapper: NROM }
var px: u8 = 0
var py: u8 = 0
on frame { draw Smiley at: (px, py) }
start Main
"#,
);
let frame_fn = ir
.functions
.iter()
.find(|f| f.name.contains("frame"))
.unwrap();
let has_draw = frame_fn
.blocks
.iter()
.flat_map(|b| &b.ops)
.any(|op| matches!(op, IrOp::DrawSprite { .. }));
assert!(has_draw, "should emit DrawSprite op");
}
#[test]
fn lower_constants_become_immediates() {
let ir = lower_ok(
r#"
game "Test" { mapper: NROM }
const SPEED: u8 = 3
var px: u8 = 0
on frame { px += SPEED }
start Main
"#,
);
let frame_fn = ir
.functions
.iter()
.find(|f| f.name.contains("frame"))
.unwrap();
// SPEED should be lowered to LoadImm(_, 3)
let has_imm3 = frame_fn
.blocks
.iter()
.flat_map(|b| &b.ops)
.any(|op| matches!(op, IrOp::LoadImm(_, 3)));
assert!(has_imm3, "constant should be inlined as LoadImm");
}
#[test]
fn lower_const_expressions_constant_fold() {
// Constants may reference earlier constants and use arithmetic.
// `B` resolves to `A + 3` = 8 at lowering time.
let ir = lower_ok(
r#"
game "Test" { mapper: NROM }
const A: u8 = 5
const B: u8 = A + 3
var x: u8 = B
on frame { wait_frame }
start Main
"#,
);
let x_global = ir.globals.iter().find(|g| g.name == "x").unwrap();
assert_eq!(x_global.init_value, Some(8));
}
#[test]
fn lower_const_bit_ops() {
// Bitwise constant folding should work for things like defining
// flags or masks based on other constants.
let ir = lower_ok(
r#"
game "Test" { mapper: NROM }
const FLAG_A: u8 = 1
const FLAG_B: u8 = 2
const BOTH: u8 = FLAG_A | FLAG_B
var x: u8 = BOTH
on frame { wait_frame }
start Main
"#,
);
let x_global = ir.globals.iter().find(|g| g.name == "x").unwrap();
assert_eq!(x_global.init_value, Some(3));
}
#[test]
fn lower_multiple_states() {
let ir = lower_ok(
r#"
game "Test" { mapper: NROM }
state Title {
on enter { wait_frame }
on frame { wait_frame }
}
state Game {
on frame { wait_frame }
}
start Title
"#,
);
// Should have: Title_enter, Title_frame, Game_frame
assert!(
ir.functions.len() >= 3,
"should have at least 3 functions for 2 states, got {}",
ir.functions.len()
);
let names: Vec<&str> = ir.functions.iter().map(|f| f.name.as_str()).collect();
assert!(
names.iter().any(|n| n.contains("Title_enter")),
"should have Title_enter handler"
);
assert!(
names.iter().any(|n| n.contains("Title_frame")),
"should have Title_frame handler"
);
assert!(
names.iter().any(|n| n.contains("Game_frame")),
"should have Game_frame handler"
);
}
#[test]
fn lower_op_count() {
let ir = lower_ok(
r#"
game "Test" { mapper: NROM }
var x: u8 = 0
on frame { x = 1 }
start Main
"#,
);
assert!(ir.op_count() > 0, "should have some IR ops");
}
#[test]
fn lower_wait_frame() {
let ir = lower_ok(
r#"
game "Test" { mapper: NROM }
on frame { wait_frame }
start Main
"#,
);
let frame_fn = ir
.functions
.iter()
.find(|f| f.name.contains("frame"))
.unwrap();
let has_wait = frame_fn
.blocks
.iter()
.flat_map(|b| &b.ops)
.any(|op| matches!(op, IrOp::WaitFrame));
assert!(has_wait, "should emit WaitFrame op");
}
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
2026-04-12 19:32:22 +00:00
#[test]
fn lower_debug_frame_overrun_count_emits_peek() {
// `debug.frame_overrun_count()` lowers to a Peek of the
// canonical $07FF runtime address. The release-mode codegen
// gating happens later — at the IR level we always emit the
// Peek so the optimizer/codegen has a single uniform shape.
let ir = lower_ok(
r#"
game "T" { mapper: NROM }
var n: u8 = 0
on frame {
n = debug.frame_overrun_count()
wait_frame
}
start Main
"#,
);
let frame_fn = ir
.functions
.iter()
.find(|f| f.name.contains("frame"))
.unwrap();
let peek_addr = frame_fn
.blocks
.iter()
.flat_map(|b| &b.ops)
.find_map(|op| match op {
IrOp::Peek(_, addr) => Some(*addr),
_ => None,
});
assert_eq!(
peek_addr,
Some(0x07FF),
"expected Peek($07FF) for frame_overrun_count"
);
}
analyzer/lowering: support nested struct fields and array struct fields Struct field types beyond the v1 scalar set (`u8`, `i8`, `u16`, `bool`) used to error out with `E0201: struct fields must be u8/i8/u16/bool`. The size accumulator already handled them correctly — what was missing was: (1) the analyzer side that synthesizes per-leaf symbols and allocations for nested structs plus a single array-typed symbol for array fields, (2) the parser's chained-field-access path, and (3) the IR-lowering recursion through nested struct literal initializers and array literal field values. The synthetic-variable model carries through unchanged: a `var p: Player` where `Player { pos: Vec2, hp: u8, inv: u8[4] }` and `Vec2 { x: u8, y: u8 }` produces flat allocations for `p.pos.x`, `p.pos.y`, `p.hp`, and `p.inv`, plus an intermediate `p.pos` Struct symbol so dotted-name lookups still resolve. Array fields get a single allocation with the array type so the existing `Expr::ArrayIndex` lowering path handles `p.inv[i]` without changes. Array-of-structs is still rejected with E0201 because the synthetic model can't index per-element layouts without further codegen work. The parser change is the only structural move: `parse_primary` and `parse_assign_or_call` now loop the dot chain into a single joined identifier so `p.pos.x` becomes `FieldAccess("p.pos", "x")` and `p.inv[0]` becomes `ArrayIndex("p.inv", 0)`. The downstream analyzer and IR lowering use the same `format!("{name}.{field}")` join they already used for one-level access — no plumbing changes required. Includes a new `examples/nested_structs.ne` that exercises both features end-to-end with two `Hero` instances carrying nested positions and inventory arrays. The reproducibility tripwire ROM is committed alongside it and the emulator harness has a matching pair of golden files. https://claude.ai/code/session_01KEczoNUX3WmcFLfq6iAQxB
2026-04-15 02:19:49 +00:00
#[test]
fn lower_nested_struct_literal_init_expands_to_leaves() {
// A `Hero { pos: Vec2 { x: 1, y: 2 }, hp: 100, inv: [3,4,5,6] }`
// initializer must produce one IrGlobal per leaf field with the
// right scalar `init_value` / per-element `init_array`. The
// intermediate `hero.pos` is registered with `size: 0` so name
// lookups still work but no separate init bytes are emitted.
let ir = lower_ok(
r#"
game "T" { mapper: NROM }
struct Vec2 { x: u8, y: u8 }
struct Hero { pos: Vec2, hp: u8, inv: u8[4] }
var hero: Hero = Hero { pos: Vec2 { x: 1, y: 2 }, hp: 100, inv: [3, 4, 5, 6] }
on frame { wait_frame }
start Main
"#,
);
let by_name = |n: &str| {
ir.globals
.iter()
.find(|g| g.name == n)
.unwrap_or_else(|| panic!("missing global: {n}"))
};
let pos_x = by_name("hero.pos.x");
let pos_y = by_name("hero.pos.y");
let hp = by_name("hero.hp");
let inv = by_name("hero.inv");
assert_eq!(pos_x.init_value, Some(1));
assert_eq!(pos_y.init_value, Some(2));
assert_eq!(hp.init_value, Some(100));
assert_eq!(inv.init_array, vec![3, 4, 5, 6]);
// The intermediate must exist with size 0 so codegen can
// resolve `hero.pos` lookups even though it carries no bytes.
let pos = by_name("hero.pos");
assert_eq!(pos.size, 0);
assert_eq!(pos.init_value, None);
assert!(pos.init_array.is_empty());
}
#[test]
fn lower_debug_frame_overran_emits_peek_07fe() {
let ir = lower_ok(
r#"
game "T" { mapper: NROM }
var n: u8 = 0
on frame {
n = debug.frame_overran()
wait_frame
}
start Main
"#,
);
let frame_fn = ir
.functions
.iter()
.find(|f| f.name.contains("frame"))
.unwrap();
let peek_addr = frame_fn
.blocks
.iter()
.flat_map(|b| &b.ops)
.find_map(|op| match op {
IrOp::Peek(_, addr) => Some(*addr),
_ => None,
});
assert_eq!(
peek_addr,
Some(0x07FE),
"expected Peek($07FE) for frame_overran"
);
}
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
2026-04-12 19:32:22 +00:00
#[test]
fn array_literal_global_init_is_captured() {
// Regression test: `var xs: u8[4] = [1, 2, 3, 4]` used to lose
// its initializer because `eval_const` returns None for
// `Expr::ArrayLiteral` and `init_value` ended up `None`. The
// fix captures the per-element values in a new `init_array`
// field so the IR codegen can emit one `LDA #imm; STA base+i`
// per byte at startup.
let ir = lower_ok(
r#"
game "Arr" { mapper: NROM }
var xs: u8[4] = [1, 2, 3, 4]
on frame { wait_frame }
start Main
"#,
);
let xs = ir
.globals
.iter()
.find(|g| g.name == "xs")
.expect("`xs` global should exist");
assert_eq!(
xs.init_array,
vec![1, 2, 3, 4],
"array literal initializer should populate init_array: {:?}",
xs.init_array
);
}
Bug B: runtime OAM cursor so `draw` inside loops actually works `IrCodeGen::next_oam_slot` incremented at *compile time*: one `draw` statement = one fixed OAM slot, baked into absolute-mode stores at codegen. A `draw` inside a `while`/`for`/`loop` body was lowered once and then always wrote to the same four OAM bytes every iteration, so only the last iteration was ever visible. The writeup in the earlier PR called this "bug B". Fix: reserve ZP `$09` as `ZP_OAM_CURSOR`, reset it to 0 at the top of every frame handler (right after the existing OAM clear loop), and lower each `DrawSprite` IR op to: LDY $09 ; load cursor LDA <y_temp> STA $0200,Y ; sprite Y LDA #tile STA $0201,Y ; tile LDA #0 STA $0202,Y ; attr LDA <x_temp> STA $0203,Y ; sprite X INC $09 x4 ; bump cursor by 4 Cost is ~+6 bytes per `draw` over the old static form. At 64 slots the u8 cursor wraps naturally, giving classic NES "too many sprites" flicker instead of a silent compile-time drop. `next_oam_slot` and its resets are gone from the IR codegen entirely. Secondary fix: `for i in 0..N` counters are now registered as handler locals. `lower_statement` created a `VarId` for the counter via `get_or_create_var` but never pushed it onto `current_locals`, so the IR codegen's `var_addrs` lookup returned `None` for every `StoreVar(i)` / `LoadVar(i)` and silently emitted nothing. The counter stayed at 0 forever, the loop spun indefinitely, and every iteration wrote the first array element into OAM — turning all 64 sprites into the same smiley. Same class as the handler-local `var` decl bug from the earlier PR, just for for-loop variables. Smoke-test deltas (all 14/14 still pass): - arrays_and_functions: 104 -> 260 (player + 4 enemies) - bitwise_ops: 104 -> 416 (player + flag sprites + pips) - loop_break_continue: 208 -> 208 (already fixed by the earlier pass) - structs_enums_for: 104 -> 260 (player + 4 enemies) Regression tests: - `ir_codegen::more_tests::ir_codegen_draw_sprite` — checks a single `draw` emits `LDY cursor`, four `STA $020N,Y`, and four `INC cursor`. - `ir_codegen::more_tests::ir_codegen_multi_oam_uses_sequential_slots` — rewritten for the new form: each draw gets its own `LDY cursor` + 4 `INC cursor`. - `ir_codegen::more_tests::ir_codegen_draw_in_loop_...` — proves a `draw` inside a `while` compiles to ONE cursor-based draw (not N unrolled statics and not zero), and asserts no stray `STA $0204`/`$0208`/... absolute stores — those would indicate bug B has regressed. - `ir::tests::for_loop_counter_is_registered_as_handler_local` — verifies `for i in 0..N` pushes `i` onto `current_locals` so the IR codegen allocates it. Smoke-test tightening: `tests/emulator/run_examples.mjs` now has per-example `minNonBlack` floors. `arrays_and_functions`, `structs_enums_for`, `loop_break_continue`, and `bitwise_ops` all require multi-sprite rendering — if the OAM cursor bug comes back, the smoke test fails loudly instead of passing on the default `nonBlack > 0` check. The legacy AST codegen in `src/codegen/mod.rs` still uses the compile-time `next_oam_slot` approach. It's only reachable via `--use-ast`, none of the examples use it, and its integration tests only check iNES structure — left alone on purpose. https://claude.ai/code/session_014Z5y3Q9krLcAxYpZQJhZ5V
2026-04-12 20:20:20 +00:00
#[test]
fn for_loop_counter_is_registered_as_handler_local() {
// Regression test for bug B's secondary fix: `for i in 0..N`
// implicitly declares the counter `i`, and the lowering must
// push it onto `current_locals` so the IR codegen can give
// it a backing address. Without this entry, every
// `LoadVar(i)` / `StoreVar(i)` in the desugared while loop
// silently emitted no code (the codegen's `var_addrs` lookup
// returned None), the counter stayed at 0, the loop spun
// forever, and any `draw` inside the loop kept writing to
// the first OAM slot with the index-0 array element.
let ir = lower_ok(
r#"
game "ForCounter" { mapper: NROM }
var xs: u8[4] = [1, 2, 3, 4]
var out: u8 = 0
on frame {
for i in 0..4 {
out = xs[i]
}
}
start Main
"#,
);
let frame_fn = ir
.functions
.iter()
.find(|f| f.name.contains("frame"))
.expect("frame handler should exist");
assert!(
frame_fn.locals.iter().any(|l| l.name == "i"),
"for-loop counter `i` should be registered as a handler local: {:?}",
frame_fn.locals
);
}
cleanup: fix silent miscompiles and delete dead code exposed by code review Two correctness bugs were silently producing wrong ROMs: - `x << n` / `x >> n` always shifted by 1, regardless of `n`, because the IR lowering for `BinOp::ShiftLeft`/`ShiftRight` hardcoded the count. Now eval_const the RHS into a compile-time count; fall back to a new `IrOp::ShiftLeftVar` / `ShiftRightVar` (runtime loop) when the amount isn't constant. Strength reduction folds the variable form back to a fixed count once the optimizer knows the value. - `x / n` / `x % n` always returned 0, because the lowering emitted `LoadImm(t, 0)` for `BinOp::Div`/`Mod` with a comment saying the runtime call was "TODO for now". Added real `IrOp::Div` and `IrOp::Mod`, wired them through use-counting and DCE, gave codegen `__divide`-based implementations, and taught strength reduction to rewrite power-of-two divisors into shifts and modulo-by-2ⁿ into AND masks. Constant folding now handles `Mul`/`Div`/`Mod`/shifts too, which were previously left for the codegen to emit inefficient software calls. Dead code removed (no backward-compat shims kept): - `src/debug/` entirely. `DebugSymbols`, `SourceMap`, and the Mesen/.sym emitters had no callers outside their own tests; `main.rs` never wrote a symbol file. Documented the intent in `docs/future-work.md` so it comes back intentionally if needed. - `ErrorCode::E0202` (invalid cast) and `E0403` (unreachable state): defined, formatted, and marked `#[allow(dead_code)]` but never emitted. W0104 now carries the unreachable-state semantics too. - `Level::Info`: never constructed. - `load_background` / `set_palette` statements and their `BackgroundDecl` / `PaletteDecl` parser support: parsed and silently dropped by IR lowering (`// TODO: implement in asset pipeline`). Removed keywords, AST variants, parser paths, analyzer arms, and tests. `docs/future-work.md` documents the runtime palette/nametable design for when it comes back. Doc cleanup: - `docs/architecture.md` was describing files that don't exist (`analyzer/types.rs`, `optimizer/const_fold.rs`, `codegen/regalloc.rs`, `rom/header.rs`, `debug/symbols.rs`, …). Rewrote it to match the real flat `mod.rs` + `tests.rs` layout and the real pipeline order. - `docs/future-work.md` was a hybrid of open work and "recently completed" entries that duplicated the active stubs at the top of the file. Collapsed to just the gaps that are actually still open. - `README.md` claimed Mesen symbol export and 210 tests; updated both. - `docs/language-guide.md` and `spec.md` described `palette` decls, `set_palette` / `load_background`, `debug.overlay`, and error codes that were never emitted. Trimmed. - Stale comments on `Statement::Play`/`StartMusic`/`StopMusic` claimed the audio subsystem was "a no-op at codegen time". Tests: - Regression tests for every fix above (`lower_shift_left_with_literal _count_uses_that_count`, `lower_shift_right_with_variable_count _uses_runtime_variant`, `lower_divide_emits_div_op_not_load_imm _zero`, `lower_modulo_emits_mod_op_not_load_imm_zero`, `strength_reduce_div_by_power_of_two`, `strength_reduce_mod_by _power_of_two`, `strength_reduce_shift_var_with_constant_amount`). - Renamed the `program_with_sprites_and_palette` integration test (which was exercising the now-removed `load_background`/`set_palette`) to `program_with_inline_sprite_chr`. `examples/sprites_and_palettes.ne` lost its `palette`/`set_palette` usage. Nothing in the emulator test presses A, so the headless jsnes render shouldn't move, but the golden may need regeneration via `UPDATE_GOLDENS=1` if it does. https://claude.ai/code/session_012fKB251HvEUQwG3tizFyqt
2026-04-13 02:47:37 +00:00
// Regression tests: shift / div / mod used to miscompile silently.
// `x << n` with a literal `n` always emitted ShiftLeft(..., 1) and
// `x / n` / `x % n` always emitted LoadImm(..., 0). These tests
// anchor the fixes from the code-review cleanup pass.
#[test]
fn lower_shift_left_with_literal_count_uses_that_count() {
let ir = lower_ok(
r#"
game "Test" { mapper: NROM }
var x: u8 = 1
on frame { x = x << 3 }
start Main
"#,
);
let frame_fn = ir
.functions
.iter()
.find(|f| f.name.contains("frame"))
.expect("frame handler should exist");
let has_shift3 = frame_fn
.blocks
.iter()
.flat_map(|b| &b.ops)
.any(|op| matches!(op, IrOp::ShiftLeft(_, _, 3)));
assert!(
has_shift3,
"expected ShiftLeft with count=3, got ops: {:?}",
frame_fn
.blocks
.iter()
.flat_map(|b| &b.ops)
.collect::<Vec<_>>()
);
}
#[test]
fn lower_shift_right_with_variable_count_uses_runtime_variant() {
let ir = lower_ok(
r#"
game "Test" { mapper: NROM }
var x: u8 = 128
var n: u8 = 2
on frame { x = x >> n }
start Main
"#,
);
let frame_fn = ir
.functions
.iter()
.find(|f| f.name.contains("frame"))
.expect("frame handler should exist");
let has_shift_var = frame_fn
.blocks
.iter()
.flat_map(|b| &b.ops)
.any(|op| matches!(op, IrOp::ShiftRightVar(..)));
assert!(
has_shift_var,
"expected ShiftRightVar for runtime shift amount, got ops: {:?}",
frame_fn
.blocks
.iter()
.flat_map(|b| &b.ops)
.collect::<Vec<_>>()
);
}
#[test]
fn lower_divide_emits_div_op_not_load_imm_zero() {
let ir = lower_ok(
r#"
game "Test" { mapper: NROM }
var x: u8 = 100
var d: u8 = 7
var q: u8 = 0
on frame { q = x / d }
start Main
"#,
);
let frame_fn = ir
.functions
.iter()
.find(|f| f.name.contains("frame"))
.expect("frame handler should exist");
let has_div = frame_fn
.blocks
.iter()
.flat_map(|b| &b.ops)
.any(|op| matches!(op, IrOp::Div(..)));
assert!(
has_div,
"expected IrOp::Div for `q = x / d`, got ops: {:?}",
frame_fn
.blocks
.iter()
.flat_map(|b| &b.ops)
.collect::<Vec<_>>()
);
}
palette/background: first-class declarations with reset-time load and runtime swaps Re-adds `palette Name { colors: [...] }` and `background Name { tiles: [...], attributes: [...] }` as first-class declarations, plus `set_palette Name` and `load_background Name` statements for runtime swaps. Unlike the previous iteration that quietly no-op'd, this one is fully wired through the pipeline and its behavior is pinned by both unit tests and an emulator golden. Pipeline: - Lexer: re-adds `palette`, `background`, `set_palette`, `load_background` keywords and tokenizes them. - AST: `PaletteDecl` (name + 1..=32 colour bytes) and `BackgroundDecl` (name + 0..=960 tile bytes + 0..=64 attribute bytes) live in `Program`. `Statement::SetPalette` and `Statement::LoadBackground` name-reference these declarations. - Parser: `palette Name { colors: [...] }` / `background Name { tiles: [...], attributes: [...] }` blocks and their statement forms parse via the existing byte-array helper. - Analyzer: validates colour indices ($00-$3F), palette length (<=32), nametable length (<=960), attribute length (<=64), and duplicate decl names. `set_palette` / `load_background` targets must reference a declared name (E0502 otherwise). When a program declares palette or background, the analyzer bumps the user zero-page allocator's starting address from `$10` to `$18` to reserve `$11-$17` for the runtime update handshake — programs that don't use the feature keep the old layout so their emulator goldens stay byte-exact. - Assets: `PaletteData` and `BackgroundData` resolve declarations into zero-padded fixed-size blobs (32 / 960 / 64 bytes) and expose `label()` / `tiles_label()` / `attrs_label()` for codegen to reference. - IR: new `IrOp::SetPalette(String)` and `IrOp::LoadBackground(String)`; lowering forwards the names verbatim. - Codegen: `gen_set_palette` writes the palette label pointer into ZP `$12/$13` and ORs bit 0 into the update flags at `$11`; `gen_load_background` does the same for tile/attribute pointers at `$14/$15/$16/$17` with bit 1. Both emit a `__ppu_update_used` marker so the linker splices in the NMI apply helper only when the feature is actually used. - Runtime: `gen_initial_palette_load` and `gen_initial_background_load` write the first declared palette/background at reset time (before rendering is enabled, where PPU writes are safe). `gen_nmi(has_ppu_updates)` takes a new flag; when true it splices `gen_ppu_update_apply` at the top of the NMI body, which checks the `$11` flags byte and copies pending palette / nametable data to `$3F00` / `$2000` inside vblank. All helpers use only ZP $02/$03 as scratch at reset time and never clobber ZP slots live across NMI. - Linker: new `link_banked_with_ppu` takes slice of `PaletteData` / `BackgroundData`; splices each blob as a labelled data block in PRG ROM, picks the first-declared as the reset-time load target, enables background rendering automatically when a background is declared, and threads `has_ppu_updates` into `gen_nmi`. Old `link_banked` remains as a thin wrapper for callers without palette/background data so existing tests don't shift. Tests: - Lexer: tokenization of the 4 new keywords (single added test case). - Parser: 5 new tests for `palette` / `background` decls with and without attributes, plus `set_palette` / `load_background` statements. - Analyzer: 9 new tests covering acceptance of declared palettes/backgrounds, E0502 for unknown names, E0201 for out-of-range NES colors and oversized blobs, E0501 for duplicate names, and the zero-page-layout guard (palette/bg decls bump ZP start; no decls keeps it at $10). - Resolver: 3 new tests for zero-padding, truncation of oversized decls, and label derivation. - IR: 2 new lowering tests for `set_palette` and `load_background`. - Integration: 5 new tests — blob contents spliced verbatim into PRG, `STA $12` / `STA $14` emitted by set_palette / load_background codegen, and a regression guard that programs without palette/background still land user vars at $10. - Emulator: new `examples/palette_and_background.ne` driven by a frame counter that toggles between `CoolBlues` / `WarmReds` and `TitleScreen` / `StageOne` every 90 frames. Golden PNG and audio hash checked in under `tests/emulator/goldens/` and verified via `node run_examples.mjs` — rendered image shows the blue `CoolBlues` palette with the nametable populated from `TitleScreen`. Docs: - `README.md` adds the feature to the headline list and the example table. - `docs/language-guide.md` restores the palette/background sections with the full 32-byte layout table and `set_palette` / `load_background` statement references. - `docs/future-work.md` replaces the "removed as dead code" entry with the remaining gaps (PNG-sourced palette and nametable assets, cross-vblank large background updates, memory-map reporting). - `spec.md` restores the grammar productions and usage examples. - `examples/README.md` lists the new demo. All 497 unit + integration tests pass. Clippy clean. All 21 emulator goldens match after the update pass. https://claude.ai/code/session_012fKB251HvEUQwG3tizFyqt
2026-04-13 11:11:33 +00:00
#[test]
fn lower_set_palette_emits_ir_op() {
let ir = lower_ok(
r#"
game "Test" { mapper: NROM }
palette Cool { colors: [0x0F, 0x01, 0x11, 0x21] }
palette Warm { colors: [0x0F, 0x06, 0x16, 0x26] }
on frame { set_palette Warm }
start Main
"#,
);
let has_set_palette = ir
.functions
.iter()
.flat_map(|f| &f.blocks)
.flat_map(|b| &b.ops)
.any(|op| matches!(op, IrOp::SetPalette(name) if name == "Warm"));
assert!(
has_set_palette,
"expected IrOp::SetPalette(Warm) in lowered IR"
);
}
#[test]
fn lower_load_background_emits_ir_op() {
let ir = lower_ok(
r#"
game "Test" { mapper: NROM }
background Stage { tiles: [0, 1, 2] }
on frame { load_background Stage }
start Main
"#,
);
let has_load_bg = ir
.functions
.iter()
.flat_map(|f| &f.blocks)
.flat_map(|b| &b.ops)
.any(|op| matches!(op, IrOp::LoadBackground(name) if name == "Stage"));
assert!(
has_load_bg,
"expected IrOp::LoadBackground(Stage) in lowered IR"
);
}
cleanup: fix silent miscompiles and delete dead code exposed by code review Two correctness bugs were silently producing wrong ROMs: - `x << n` / `x >> n` always shifted by 1, regardless of `n`, because the IR lowering for `BinOp::ShiftLeft`/`ShiftRight` hardcoded the count. Now eval_const the RHS into a compile-time count; fall back to a new `IrOp::ShiftLeftVar` / `ShiftRightVar` (runtime loop) when the amount isn't constant. Strength reduction folds the variable form back to a fixed count once the optimizer knows the value. - `x / n` / `x % n` always returned 0, because the lowering emitted `LoadImm(t, 0)` for `BinOp::Div`/`Mod` with a comment saying the runtime call was "TODO for now". Added real `IrOp::Div` and `IrOp::Mod`, wired them through use-counting and DCE, gave codegen `__divide`-based implementations, and taught strength reduction to rewrite power-of-two divisors into shifts and modulo-by-2ⁿ into AND masks. Constant folding now handles `Mul`/`Div`/`Mod`/shifts too, which were previously left for the codegen to emit inefficient software calls. Dead code removed (no backward-compat shims kept): - `src/debug/` entirely. `DebugSymbols`, `SourceMap`, and the Mesen/.sym emitters had no callers outside their own tests; `main.rs` never wrote a symbol file. Documented the intent in `docs/future-work.md` so it comes back intentionally if needed. - `ErrorCode::E0202` (invalid cast) and `E0403` (unreachable state): defined, formatted, and marked `#[allow(dead_code)]` but never emitted. W0104 now carries the unreachable-state semantics too. - `Level::Info`: never constructed. - `load_background` / `set_palette` statements and their `BackgroundDecl` / `PaletteDecl` parser support: parsed and silently dropped by IR lowering (`// TODO: implement in asset pipeline`). Removed keywords, AST variants, parser paths, analyzer arms, and tests. `docs/future-work.md` documents the runtime palette/nametable design for when it comes back. Doc cleanup: - `docs/architecture.md` was describing files that don't exist (`analyzer/types.rs`, `optimizer/const_fold.rs`, `codegen/regalloc.rs`, `rom/header.rs`, `debug/symbols.rs`, …). Rewrote it to match the real flat `mod.rs` + `tests.rs` layout and the real pipeline order. - `docs/future-work.md` was a hybrid of open work and "recently completed" entries that duplicated the active stubs at the top of the file. Collapsed to just the gaps that are actually still open. - `README.md` claimed Mesen symbol export and 210 tests; updated both. - `docs/language-guide.md` and `spec.md` described `palette` decls, `set_palette` / `load_background`, `debug.overlay`, and error codes that were never emitted. Trimmed. - Stale comments on `Statement::Play`/`StartMusic`/`StopMusic` claimed the audio subsystem was "a no-op at codegen time". Tests: - Regression tests for every fix above (`lower_shift_left_with_literal _count_uses_that_count`, `lower_shift_right_with_variable_count _uses_runtime_variant`, `lower_divide_emits_div_op_not_load_imm _zero`, `lower_modulo_emits_mod_op_not_load_imm_zero`, `strength_reduce_div_by_power_of_two`, `strength_reduce_mod_by _power_of_two`, `strength_reduce_shift_var_with_constant_amount`). - Renamed the `program_with_sprites_and_palette` integration test (which was exercising the now-removed `load_background`/`set_palette`) to `program_with_inline_sprite_chr`. `examples/sprites_and_palettes.ne` lost its `palette`/`set_palette` usage. Nothing in the emulator test presses A, so the headless jsnes render shouldn't move, but the golden may need regeneration via `UPDATE_GOLDENS=1` if it does. https://claude.ai/code/session_012fKB251HvEUQwG3tizFyqt
2026-04-13 02:47:37 +00:00
#[test]
fn lower_modulo_emits_mod_op_not_load_imm_zero() {
let ir = lower_ok(
r#"
game "Test" { mapper: NROM }
var x: u8 = 17
var d: u8 = 5
var r: u8 = 0
on frame { r = x % d }
start Main
"#,
);
let frame_fn = ir
.functions
.iter()
.find(|f| f.name.contains("frame"))
.expect("frame handler should exist");
let has_mod = frame_fn
.blocks
.iter()
.flat_map(|b| &b.ops)
.any(|op| matches!(op, IrOp::Mod(..)));
assert!(
has_mod,
"expected IrOp::Mod for `r = x % d`, got ops: {:?}",
frame_fn
.blocks
.iter()
.flat_map(|b| &b.ops)
.collect::<Vec<_>>()
);
}