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"); } #[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" ); } #[test] fn lower_metasprite_draw_expands_to_one_op_per_tile() { // `draw Hero at: (10, 20)` where Hero is a 4-tile metasprite // should lower to four `DrawSprite` ops, each with the // metasprite's underlying sprite name and one tile from the // declaration's `frame:` array (offset by the sprite's base // tile index — the runtime smiley occupies tile 0, so a // single-sprite program starts user tiles at 1). let ir = lower_ok( r#" game "T" { mapper: NROM } sprite Tile { pixels: [ "@@@@@@@@", "@@@@@@@@", "@@@@@@@@", "@@@@@@@@", "@@@@@@@@", "@@@@@@@@", "@@@@@@@@", "@@@@@@@@" ] } metasprite Hero { sprite: Tile dx: [0, 8, 0, 8] dy: [0, 0, 8, 8] frame: [0, 0, 0, 0] } on frame { draw Hero at: (10, 20) wait_frame } start Main "#, ); let frame_fn = ir .functions .iter() .find(|f| f.name.contains("frame")) .unwrap(); let ops: Vec<&IrOp> = frame_fn.blocks.iter().flat_map(|b| &b.ops).collect(); let draws: Vec<_> = ops .iter() .filter_map(|op| match op { IrOp::DrawSprite { sprite_name, .. } => Some(sprite_name.clone()), _ => None, }) .collect(); assert_eq!( draws.len(), 4, "metasprite with 4 tiles should expand to 4 DrawSprite ops" ); for name in &draws { assert_eq!( name, "Tile", "expanded ops should target the underlying sprite" ); } // Each tile in the metasprite uses `frame: 0` relative to // the underlying sprite. The single-sprite program has `Tile` // at base index 1 (smiley occupies 0), so the resolved // absolute frame index for every expanded DrawSprite is 1 — // we should see at least one `LoadImm(_, 1)` matching that. let load_imm_1_count = ops .iter() .filter(|op| matches!(op, IrOp::LoadImm(_, 1))) .count(); assert!( load_imm_1_count >= 4, "metasprite expansion should LoadImm(_, 1) at least once per tile (sprite Tile sits at base index 1, frame: [0,0,0,0]); got {load_imm_1_count}" ); } #[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" ); } #[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 ); } #[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 ); } // 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::>() ); } #[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::>() ); } #[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::>() ); } #[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" ); } #[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::>() ); } #[test] fn wide_hi_does_not_leak_between_functions() { // Regression test for the `wide_hi` leak bug fixed on the // War bug-cleanup branch (see `git log`): the IR lowerer's // `wide_hi` map used to persist across function boundaries // even though `next_temp` resets to 0 per function. A // function whose body had no u16 ops would inherit stale // `(temp_id -> high_byte)` entries from earlier functions // and emit `CmpEq16` (or other 16-bit ops) where the // destination temp aliased one of the source temps. // // The shape that reproduces it: function A bumps a u16 // global (creating wide entries); function B does u8 == // const compares against a u8 global. Pre-fix, function B's // last few comparisons would lower to `CmpEq16`. Post-fix, // they all stay narrow. let ir = lower_ok( r#" game "Test" { mapper: NROM } var clock: u16 = 0 var phase: u8 = 0 var hits: u8 = 0 fun bump_a() { hits += 1 } fun bump_b() { hits += 2 } fun bump_c() { hits += 3 } fun bump_d() { hits += 4 } on frame { clock += 1 if phase == 0 { bump_a() } if phase == 1 { bump_b() } if phase == 2 { bump_c() } if phase == 3 { bump_d() } wait_frame } start Main "#, ); let frame_fn = ir .functions .iter() .find(|f| f.name.contains("frame")) .expect("frame handler should exist"); let mut wide_eq_dest_aliases = 0; for op in frame_fn.blocks.iter().flat_map(|b| &b.ops) { if let IrOp::CmpEq16 { dest, b_hi, a_hi, .. } = op { // The dest of a 16-bit compare must never alias one // of its operand high bytes — that's the symptom of // the `wide_hi` leak bug. if dest == b_hi || dest == a_hi { wide_eq_dest_aliases += 1; } } } assert_eq!( wide_eq_dest_aliases, 0, "wide CmpEq16 destination aliased a source operand — wide_hi leaked between functions" ); } #[test] fn inline_fun_expression_body_emits_no_call_at_use_site() { // Regression test for the real-inlining feature added on // the War bug-cleanup branch (see `git log`): `inline fun` // with a single-return-expression body should be spliced // at every call site instead of emitting a Call op. The // lowered frame handler should contain zero Call ops // targeting the inline function. let ir = lower_ok( r#" game "Test" { mapper: NROM } inline fun shift_right_4(c: u8) -> u8 { return c >> 4 } var out: u8 = 0 on frame { out = shift_right_4(0x90) } start Main "#, ); let frame_fn = ir .functions .iter() .find(|f| f.name.contains("frame")) .expect("frame handler should exist"); let any_call_to_inline = frame_fn .blocks .iter() .flat_map(|b| &b.ops) .any(|op| matches!(op, IrOp::Call(_, name, _) if name == "shift_right_4")); assert!( !any_call_to_inline, "frame handler should not contain a Call to the inlined function; ops: {:?}", frame_fn .blocks .iter() .flat_map(|b| &b.ops) .collect::>() ); } #[test] fn inline_fun_void_body_statements_are_spliced() { // Void `inline fun` with a multi-statement body (no // control flow) should be spliced at every statement- // context call site. `set_phase(P_FLY_A)` should lower // to two StoreVar ops (phase = P_FLY_A, phase_timer = 0) // rather than a Call op. let ir = lower_ok( r#" game "Test" { mapper: NROM } const P_WAIT: u8 = 0 const P_FLY: u8 = 1 var phase: u8 = 0 var phase_timer: u8 = 0 inline fun set_phase(p: u8) { phase = p phase_timer = 0 } on frame { set_phase(P_FLY) } start Main "#, ); let frame_fn = ir .functions .iter() .find(|f| f.name.contains("frame")) .expect("frame handler should exist"); let any_call_to_inline = frame_fn .blocks .iter() .flat_map(|b| &b.ops) .any(|op| matches!(op, IrOp::Call(_, name, _) if name == "set_phase")); assert!( !any_call_to_inline, "frame handler should not contain a Call to set_phase; ops: {:?}", frame_fn .blocks .iter() .flat_map(|b| &b.ops) .collect::>() ); } #[test] fn inline_fun_with_conditional_return_compiles_as_regular_call() { // A conditional early-return body (wrap52-style) is too // complex for the simple inliner. It should gracefully // fall back to a regular Call op — this is the intended // behaviour, not a bug. The important thing is that the // fallback is correct, not that it's inlined. let ir = lower_ok( r#" game "Test" { mapper: NROM } inline fun wrap52(v: u8) -> u8 { if v >= 52 { return v - 52 } return v } var out: u8 = 0 on frame { out = wrap52(60) } start Main "#, ); let frame_fn = ir .functions .iter() .find(|f| f.name.contains("frame")) .expect("frame handler should exist"); let calls_wrap52 = frame_fn .blocks .iter() .flat_map(|b| &b.ops) .any(|op| matches!(op, IrOp::Call(_, name, _) if name == "wrap52")); assert!( calls_wrap52, "wrap52 has conditional early return — it should fall back to a Call op" ); } #[test] fn inline_fun_with_asm_param_substitutes_immediate() { // An `inline fun` whose body contains an `asm { ... }` block // referencing parameters via `{name}` substitution. When the // call site passes compile-time constants, the splicer // pre-substitutes each `{param}` with its `#$` // immediate so the spliced body parses correctly. The end // result should contain no Call op for the inlined fun, and // the frame handler's instruction stream (after lowering) is // expected to contain an `LDA #$2A` (= 42) — the substituted // immediate. let ir = lower_ok( r#" game "Test" { mapper: NROM } var sink: u8 = 0 inline fun stash(value: u8) { asm { LDA {value} STA {sink} } } on frame { stash(42) } start Main "#, ); let frame_fn = ir .functions .iter() .find(|f| f.name.contains("frame")) .expect("frame handler should exist"); let any_call = frame_fn .blocks .iter() .flat_map(|b| &b.ops) .any(|op| matches!(op, IrOp::Call(_, name, _) if name == "stash")); assert!(!any_call, "stash should be inlined, no Call op left"); let asm_body = frame_fn .blocks .iter() .flat_map(|b| &b.ops) .find_map(|op| match op { IrOp::InlineAsm(body) => Some(body.clone()), _ => None, }) .expect("inlined asm block should be present"); assert!( asm_body.contains("#$2A"), "asm body should contain the substituted immediate `#$2A`; got: {asm_body}" ); assert!( !asm_body.contains("{value}"), "`{{value}}` should have been pre-substituted; got: {asm_body}" ); } #[test] fn inline_fun_with_asm_falls_back_for_runtime_arg() { // When the call site passes a runtime value (a variable, not // a literal), there's no way to synthesize an immediate at // expansion time. The splicer should refuse to inline and // emit a regular Call instead — preserving correctness over // performance. let ir = lower_ok( r#" game "Test" { mapper: NROM } var sink: u8 = 0 var src: u8 = 7 inline fun stash(value: u8) { asm { LDA {value} STA {sink} } } on frame { stash(src) } start Main "#, ); let frame_fn = ir .functions .iter() .find(|f| f.name.contains("frame")) .expect("frame handler should exist"); let any_call = frame_fn .blocks .iter() .flat_map(|b| &b.ops) .any(|op| matches!(op, IrOp::Call(_, name, _) if name == "stash")); assert!( any_call, "stash should fall back to a Call op when arg is runtime" ); } #[test] fn inline_fun_with_asm_param_cascades_through_nested_inline() { // Outer inline fun calls inner inline fun (with asm) using // its *own* parameter as the inner's argument. The outer's // parameter is bound to a compile-time constant at the // top-level call site, and that constness has to flow // through `eval_const` so the inner — which has an asm body // — sees its arg as constant too. // // Without the `inline_const_args_stack` lookup in // `eval_const`, the inner would treat the outer's param as // runtime and refuse to inline, falling back to a Call. let ir = lower_ok( r#" game "Test" { mapper: NROM } var sink: u8 = 0 inline fun inner(value: u8) { asm { LDA {value} STA {sink} } } inline fun outer(p: u8) { inner(p) } on frame { outer(123) } start Main "#, ); let frame_fn = ir .functions .iter() .find(|f| f.name.contains("frame")) .expect("frame handler should exist"); let any_call = frame_fn .blocks .iter() .flat_map(|b| &b.ops) .any(|op| matches!(op, IrOp::Call(_, name, _) if name == "outer" || name == "inner")); assert!( !any_call, "both inline funs should expand; no Call ops should remain" ); let asm_body = frame_fn .blocks .iter() .flat_map(|b| &b.ops) .find_map(|op| match op { IrOp::InlineAsm(body) => Some(body.clone()), _ => None, }) .expect("inlined asm block should be present"); // 123 = $7B assert!( asm_body.contains("#$7B"), "asm body should contain `#$7B` from the cascaded constant; got: {asm_body}" ); } #[test] fn inline_fun_nested_inlines_substitute_correctly() { // Two inline functions where the outer calls the inner // using its own parameter. Both should inline; the // result should have no Call ops in the frame handler // targeting either function. let ir = lower_ok( r#" game "Test" { mapper: NROM } inline fun double(x: u8) -> u8 { return x + x } inline fun quad(x: u8) -> u8 { return double(double(x)) } var out: u8 = 0 on frame { out = quad(5) } start Main "#, ); let frame_fn = ir .functions .iter() .find(|f| f.name.contains("frame")) .expect("frame handler should exist"); let any_inline_call = frame_fn .blocks .iter() .flat_map(|b| &b.ops) .any(|op| matches!(op, IrOp::Call(_, name, _) if name == "double" || name == "quad")); assert!( !any_inline_call, "nested inline calls should both be expanded; frame ops: {:?}", frame_fn .blocks .iter() .flat_map(|b| &b.ops) .collect::>() ); }