M2: Add function/array parsing, IR data structures and lowering
Parser extensions:
- Function declarations with params and return types (fun, inline fun)
- Array type syntax (u8[16]), array literal expressions ([1, 2, 3])
- fast/slow variable placement hints
- Functions stored in Program AST
IR module (new):
- IrProgram, IrFunction, IrBasicBlock, IrOp, IrTerminator types
- AST-to-IR lowering pass with:
- Expression lowering to virtual temps
- Control flow (if/else, while, loop) to basic blocks with branches
- Break/continue via loop context stack
- Short-circuit logical and/or
- Button reads, draw sprites, wait_frame
- Constants inlined as LoadImm
- State handlers lowered as separate IR functions
Tests: 6 new parser tests, 11 new IR lowering tests (153 total)
https://claude.ai/code/session_01W6eQFStA66EuMKHUFo2rx3
2026-04-11 23:25:26 +00:00
|
|
|
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
|
|
|
|
|
"#,
|
|
|
|
|
);
|
2026-04-11 23:32:12 +00:00
|
|
|
let frame_fn = ir
|
|
|
|
|
.functions
|
|
|
|
|
.iter()
|
|
|
|
|
.find(|f| f.name.contains("frame"))
|
|
|
|
|
.unwrap();
|
M2: Add function/array parsing, IR data structures and lowering
Parser extensions:
- Function declarations with params and return types (fun, inline fun)
- Array type syntax (u8[16]), array literal expressions ([1, 2, 3])
- fast/slow variable placement hints
- Functions stored in Program AST
IR module (new):
- IrProgram, IrFunction, IrBasicBlock, IrOp, IrTerminator types
- AST-to-IR lowering pass with:
- Expression lowering to virtual temps
- Control flow (if/else, while, loop) to basic blocks with branches
- Break/continue via loop context stack
- Short-circuit logical and/or
- Button reads, draw sprites, wait_frame
- Constants inlined as LoadImm
- State handlers lowered as separate IR functions
Tests: 6 new parser tests, 11 new IR lowering tests (153 total)
https://claude.ai/code/session_01W6eQFStA66EuMKHUFo2rx3
2026-04-11 23:25:26 +00:00
|
|
|
// 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
|
|
|
|
|
"#,
|
|
|
|
|
);
|
2026-04-11 23:32:12 +00:00
|
|
|
let frame_fn = ir
|
|
|
|
|
.functions
|
|
|
|
|
.iter()
|
|
|
|
|
.find(|f| f.name.contains("frame"))
|
|
|
|
|
.unwrap();
|
M2: Add function/array parsing, IR data structures and lowering
Parser extensions:
- Function declarations with params and return types (fun, inline fun)
- Array type syntax (u8[16]), array literal expressions ([1, 2, 3])
- fast/slow variable placement hints
- Functions stored in Program AST
IR module (new):
- IrProgram, IrFunction, IrBasicBlock, IrOp, IrTerminator types
- AST-to-IR lowering pass with:
- Expression lowering to virtual temps
- Control flow (if/else, while, loop) to basic blocks with branches
- Break/continue via loop context stack
- Short-circuit logical and/or
- Button reads, draw sprites, wait_frame
- Constants inlined as LoadImm
- State handlers lowered as separate IR functions
Tests: 6 new parser tests, 11 new IR lowering tests (153 total)
https://claude.ai/code/session_01W6eQFStA66EuMKHUFo2rx3
2026-04-11 23:25:26 +00:00
|
|
|
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
|
|
|
|
|
"#,
|
|
|
|
|
);
|
2026-04-11 23:32:12 +00:00
|
|
|
let frame_fn = ir
|
|
|
|
|
.functions
|
|
|
|
|
.iter()
|
|
|
|
|
.find(|f| f.name.contains("frame"))
|
|
|
|
|
.unwrap();
|
M2: Add function/array parsing, IR data structures and lowering
Parser extensions:
- Function declarations with params and return types (fun, inline fun)
- Array type syntax (u8[16]), array literal expressions ([1, 2, 3])
- fast/slow variable placement hints
- Functions stored in Program AST
IR module (new):
- IrProgram, IrFunction, IrBasicBlock, IrOp, IrTerminator types
- AST-to-IR lowering pass with:
- Expression lowering to virtual temps
- Control flow (if/else, while, loop) to basic blocks with branches
- Break/continue via loop context stack
- Short-circuit logical and/or
- Button reads, draw sprites, wait_frame
- Constants inlined as LoadImm
- State handlers lowered as separate IR functions
Tests: 6 new parser tests, 11 new IR lowering tests (153 total)
https://claude.ai/code/session_01W6eQFStA66EuMKHUFo2rx3
2026-04-11 23:25:26 +00:00
|
|
|
let has_branch = frame_fn
|
|
|
|
|
.blocks
|
|
|
|
|
.iter()
|
|
|
|
|
.any(|b| matches!(&b.terminator, IrTerminator::Branch(..)));
|
2026-04-11 23:32:12 +00:00
|
|
|
assert!(
|
|
|
|
|
has_branch,
|
|
|
|
|
"if statement should produce a Branch terminator"
|
|
|
|
|
);
|
M2: Add function/array parsing, IR data structures and lowering
Parser extensions:
- Function declarations with params and return types (fun, inline fun)
- Array type syntax (u8[16]), array literal expressions ([1, 2, 3])
- fast/slow variable placement hints
- Functions stored in Program AST
IR module (new):
- IrProgram, IrFunction, IrBasicBlock, IrOp, IrTerminator types
- AST-to-IR lowering pass with:
- Expression lowering to virtual temps
- Control flow (if/else, while, loop) to basic blocks with branches
- Break/continue via loop context stack
- Short-circuit logical and/or
- Button reads, draw sprites, wait_frame
- Constants inlined as LoadImm
- State handlers lowered as separate IR functions
Tests: 6 new parser tests, 11 new IR lowering tests (153 total)
https://claude.ai/code/session_01W6eQFStA66EuMKHUFo2rx3
2026-04-11 23:25:26 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[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
|
|
|
|
|
"#,
|
|
|
|
|
);
|
2026-04-11 23:32:12 +00:00
|
|
|
let frame_fn = ir
|
|
|
|
|
.functions
|
|
|
|
|
.iter()
|
|
|
|
|
.find(|f| f.name.contains("frame"))
|
|
|
|
|
.unwrap();
|
M2: Add function/array parsing, IR data structures and lowering
Parser extensions:
- Function declarations with params and return types (fun, inline fun)
- Array type syntax (u8[16]), array literal expressions ([1, 2, 3])
- fast/slow variable placement hints
- Functions stored in Program AST
IR module (new):
- IrProgram, IrFunction, IrBasicBlock, IrOp, IrTerminator types
- AST-to-IR lowering pass with:
- Expression lowering to virtual temps
- Control flow (if/else, while, loop) to basic blocks with branches
- Break/continue via loop context stack
- Short-circuit logical and/or
- Button reads, draw sprites, wait_frame
- Constants inlined as LoadImm
- State handlers lowered as separate IR functions
Tests: 6 new parser tests, 11 new IR lowering tests (153 total)
https://claude.ai/code/session_01W6eQFStA66EuMKHUFo2rx3
2026-04-11 23:25:26 +00:00
|
|
|
// 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
|
|
|
|
|
"#,
|
|
|
|
|
);
|
2026-04-11 23:32:12 +00:00
|
|
|
let frame_fn = ir
|
|
|
|
|
.functions
|
|
|
|
|
.iter()
|
|
|
|
|
.find(|f| f.name.contains("frame"))
|
|
|
|
|
.unwrap();
|
M2: Add function/array parsing, IR data structures and lowering
Parser extensions:
- Function declarations with params and return types (fun, inline fun)
- Array type syntax (u8[16]), array literal expressions ([1, 2, 3])
- fast/slow variable placement hints
- Functions stored in Program AST
IR module (new):
- IrProgram, IrFunction, IrBasicBlock, IrOp, IrTerminator types
- AST-to-IR lowering pass with:
- Expression lowering to virtual temps
- Control flow (if/else, while, loop) to basic blocks with branches
- Break/continue via loop context stack
- Short-circuit logical and/or
- Button reads, draw sprites, wait_frame
- Constants inlined as LoadImm
- State handlers lowered as separate IR functions
Tests: 6 new parser tests, 11 new IR lowering tests (153 total)
https://claude.ai/code/session_01W6eQFStA66EuMKHUFo2rx3
2026-04-11 23:25:26 +00:00
|
|
|
let has_input = frame_fn
|
|
|
|
|
.blocks
|
|
|
|
|
.iter()
|
|
|
|
|
.flat_map(|b| &b.ops)
|
2026-04-12 10:40:42 +00:00
|
|
|
.any(|op| matches!(op, IrOp::ReadInput(_, _)));
|
M2: Add function/array parsing, IR data structures and lowering
Parser extensions:
- Function declarations with params and return types (fun, inline fun)
- Array type syntax (u8[16]), array literal expressions ([1, 2, 3])
- fast/slow variable placement hints
- Functions stored in Program AST
IR module (new):
- IrProgram, IrFunction, IrBasicBlock, IrOp, IrTerminator types
- AST-to-IR lowering pass with:
- Expression lowering to virtual temps
- Control flow (if/else, while, loop) to basic blocks with branches
- Break/continue via loop context stack
- Short-circuit logical and/or
- Button reads, draw sprites, wait_frame
- Constants inlined as LoadImm
- State handlers lowered as separate IR functions
Tests: 6 new parser tests, 11 new IR lowering tests (153 total)
https://claude.ai/code/session_01W6eQFStA66EuMKHUFo2rx3
2026-04-11 23:25:26 +00:00
|
|
|
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
|
|
|
|
|
"#,
|
|
|
|
|
);
|
2026-04-11 23:32:12 +00:00
|
|
|
let frame_fn = ir
|
|
|
|
|
.functions
|
|
|
|
|
.iter()
|
|
|
|
|
.find(|f| f.name.contains("frame"))
|
|
|
|
|
.unwrap();
|
M2: Add function/array parsing, IR data structures and lowering
Parser extensions:
- Function declarations with params and return types (fun, inline fun)
- Array type syntax (u8[16]), array literal expressions ([1, 2, 3])
- fast/slow variable placement hints
- Functions stored in Program AST
IR module (new):
- IrProgram, IrFunction, IrBasicBlock, IrOp, IrTerminator types
- AST-to-IR lowering pass with:
- Expression lowering to virtual temps
- Control flow (if/else, while, loop) to basic blocks with branches
- Break/continue via loop context stack
- Short-circuit logical and/or
- Button reads, draw sprites, wait_frame
- Constants inlined as LoadImm
- State handlers lowered as separate IR functions
Tests: 6 new parser tests, 11 new IR lowering tests (153 total)
https://claude.ai/code/session_01W6eQFStA66EuMKHUFo2rx3
2026-04-11 23:25:26 +00:00
|
|
|
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
|
|
|
|
|
"#,
|
|
|
|
|
);
|
2026-04-11 23:32:12 +00:00
|
|
|
let frame_fn = ir
|
|
|
|
|
.functions
|
|
|
|
|
.iter()
|
|
|
|
|
.find(|f| f.name.contains("frame"))
|
|
|
|
|
.unwrap();
|
M2: Add function/array parsing, IR data structures and lowering
Parser extensions:
- Function declarations with params and return types (fun, inline fun)
- Array type syntax (u8[16]), array literal expressions ([1, 2, 3])
- fast/slow variable placement hints
- Functions stored in Program AST
IR module (new):
- IrProgram, IrFunction, IrBasicBlock, IrOp, IrTerminator types
- AST-to-IR lowering pass with:
- Expression lowering to virtual temps
- Control flow (if/else, while, loop) to basic blocks with branches
- Break/continue via loop context stack
- Short-circuit logical and/or
- Button reads, draw sprites, wait_frame
- Constants inlined as LoadImm
- State handlers lowered as separate IR functions
Tests: 6 new parser tests, 11 new IR lowering tests (153 total)
https://claude.ai/code/session_01W6eQFStA66EuMKHUFo2rx3
2026-04-11 23:25:26 +00:00
|
|
|
// 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");
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-12 16:48:29 +00:00
|
|
|
#[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));
|
|
|
|
|
}
|
|
|
|
|
|
M2: Add function/array parsing, IR data structures and lowering
Parser extensions:
- Function declarations with params and return types (fun, inline fun)
- Array type syntax (u8[16]), array literal expressions ([1, 2, 3])
- fast/slow variable placement hints
- Functions stored in Program AST
IR module (new):
- IrProgram, IrFunction, IrBasicBlock, IrOp, IrTerminator types
- AST-to-IR lowering pass with:
- Expression lowering to virtual temps
- Control flow (if/else, while, loop) to basic blocks with branches
- Break/continue via loop context stack
- Short-circuit logical and/or
- Button reads, draw sprites, wait_frame
- Constants inlined as LoadImm
- State handlers lowered as separate IR functions
Tests: 6 new parser tests, 11 new IR lowering tests (153 total)
https://claude.ai/code/session_01W6eQFStA66EuMKHUFo2rx3
2026-04-11 23:25:26 +00:00
|
|
|
#[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
|
|
|
|
|
"#,
|
|
|
|
|
);
|
2026-04-11 23:32:12 +00:00
|
|
|
let frame_fn = ir
|
|
|
|
|
.functions
|
|
|
|
|
.iter()
|
|
|
|
|
.find(|f| f.name.contains("frame"))
|
|
|
|
|
.unwrap();
|
M2: Add function/array parsing, IR data structures and lowering
Parser extensions:
- Function declarations with params and return types (fun, inline fun)
- Array type syntax (u8[16]), array literal expressions ([1, 2, 3])
- fast/slow variable placement hints
- Functions stored in Program AST
IR module (new):
- IrProgram, IrFunction, IrBasicBlock, IrOp, IrTerminator types
- AST-to-IR lowering pass with:
- Expression lowering to virtual temps
- Control flow (if/else, while, loop) to basic blocks with branches
- Break/continue via loop context stack
- Short-circuit logical and/or
- Button reads, draw sprites, wait_frame
- Constants inlined as LoadImm
- State handlers lowered as separate IR functions
Tests: 6 new parser tests, 11 new IR lowering tests (153 total)
https://claude.ai/code/session_01W6eQFStA66EuMKHUFo2rx3
2026-04-11 23:25:26 +00:00
|
|
|
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
|
|
|
|
2026-04-15 01:57:45 +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());
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-15 01:57:45 +00:00
|
|
|
#[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<_>>()
|
|
|
|
|
);
|
|
|
|
|
}
|