diff --git a/docs/language-guide.md b/docs/language-guide.md index 0c7c122..6e336c3 100644 --- a/docs/language-guide.md +++ b/docs/language-guide.md @@ -304,7 +304,7 @@ reset_score() - **No recursion.** Both direct and indirect recursion are compile errors (`E0402`). - **Call depth limit.** The default maximum call depth is 8. Exceeding it produces error `E0401`. -- **Maximum 4 parameters per function.** The v0.1 calling convention passes parameters via four fixed zero-page slots (`$04`-`$07`). Declaring a function with 5+ parameters produces error `E0506`. Pack additional state into globals or split the function into smaller helpers. +- **Maximum 8 parameters per function.** The calling convention is hybrid: **leaf** functions (no nested `JSR` in their body) receive up to four parameters through fixed zero-page transport slots `$04`-`$07`, while **non-leaf** functions receive up to eight parameters via direct caller writes into per-function RAM spill slots (no transport, no prologue copy). Declaring a function with 9+ parameters produces error `E0506`. Declaring a leaf with 5+ parameters silently promotes it to the non-leaf convention — you pay the direct-write cost rather than the prologue-copy cost, which is still cheaper than the old transport-plus-spill path. --- @@ -1349,7 +1349,7 @@ reference NEScript variables. | E0503 | Undefined function | | E0504 | Missing start declaration | | E0505 | Multiple start declarations| -| E0506 | Function has too many parameters (max 4) | +| E0506 | Function has too many parameters (max 8) | ### Warnings (W01xx) diff --git a/examples/arrays_and_functions.nes b/examples/arrays_and_functions.nes index 4c008ab..7b0337e 100644 Binary files a/examples/arrays_and_functions.nes and b/examples/arrays_and_functions.nes differ diff --git a/examples/feature_canary.ne b/examples/feature_canary.ne index af9ff58..99b31fd 100644 --- a/examples/feature_canary.ne +++ b/examples/feature_canary.ne @@ -80,6 +80,22 @@ fun double_u8(x: u8) -> u8 { return x + x } +// A six-parameter non-leaf function. The call site exercises +// the direct-write calling convention — the caller stages each +// arg straight into the callee's per-param RAM slot, no +// transport through `$04-$07`. Returns the sum of all six, so +// a regression that silently drops any one (same shape as PR +// #31 but for params) knocks the result off 21 and flips the +// canary red. +fun sum6(a: u8, b: u8, c: u8, d: u8, e: u8, f: u8) -> u8 { + var tmp: u8 = a + b + tmp = tmp + c + tmp = tmp + d + tmp = tmp + e + tmp = tmp + f + return tmp +} + // ── Main state ───────────────────────────────────────────── state Main { @@ -128,6 +144,12 @@ state Main { var r: u8 = double_u8(21) if r != 42 { all_ok = 0 } + // Check 8: six-parameter non-leaf function — exercises + // the direct-write calling convention that lifts the + // old 4-param ceiling. 1+2+3+4+5+6 = 21. + var s: u8 = sum6(1, 2, 3, 4, 5, 6) + if s != 21 { all_ok = 0 } + // Drive the backdrop flip. `set_palette` schedules an // update during the next vblank, so the effect lands on // the following frame — well before the frame-180 golden diff --git a/examples/feature_canary.nes b/examples/feature_canary.nes index 9f257ce..157a223 100644 Binary files a/examples/feature_canary.nes and b/examples/feature_canary.nes differ diff --git a/examples/function_chain.ne b/examples/function_chain.ne index bf9d3e9..53f708d 100644 --- a/examples/function_chain.ne +++ b/examples/function_chain.ne @@ -6,14 +6,24 @@ // // frame -> compute -> scale -> clamp -> fold -> taper // -// Each function takes its argument through the zero-page -// parameter slots ($04-$07), computes a small transform, and -// returns a value in A. The chained result is what drives the -// player sprite's X position on screen each frame. +// Each function takes its argument through the calling +// convention and returns a value in A. The chained result is +// what drives the player sprite's X position on screen each +// frame. +// +// Parameters land at a non-uniform address set: +// - The deepest callee (`taper`) is a leaf — it has no nested +// `JSR` and can receive its arg in the `$04` transport slot +// directly. Its body reads `$04` in place of a spill copy. +// - Every other function (`compute`, `scale`, `clamp`, `fold`) +// is non-leaf and uses the direct-write convention: each +// caller stages the arg straight into the callee's +// analyzer-allocated param slot before the `JSR`. No +// transport, no prologue copy. // // What this exercises end-to-end: // - Five levels of nested `JSR` without stack corruption -// - Parameter passing via `$04-$07` between callers +// - The hybrid leaf / non-leaf calling convention // - Return value propagation through A // - `fun ... -> u8 { return ... }` — the full typed-function // shape, including an early `return` inside an `if` diff --git a/examples/function_chain.nes b/examples/function_chain.nes index 2e5045e..2a4a125 100644 Binary files a/examples/function_chain.nes and b/examples/function_chain.nes differ diff --git a/examples/mmc1_banked.nes b/examples/mmc1_banked.nes index 56697f2..8a29044 100644 Binary files a/examples/mmc1_banked.nes and b/examples/mmc1_banked.nes differ diff --git a/examples/pong.nes b/examples/pong.nes index d4c8621..90a7532 100644 Binary files a/examples/pong.nes and b/examples/pong.nes differ diff --git a/examples/sha256.nes b/examples/sha256.nes index c5fa92e..1655d93 100644 Binary files a/examples/sha256.nes and b/examples/sha256.nes differ diff --git a/examples/war.nes b/examples/war.nes index fe83362..3bd034f 100644 Binary files a/examples/war.nes and b/examples/war.nes differ diff --git a/src/analyzer/mod.rs b/src/analyzer/mod.rs index 710270e..17ccb05 100644 --- a/src/analyzer/mod.rs +++ b/src/analyzer/mod.rs @@ -1622,25 +1622,37 @@ impl Analyzer { return; } // The v0.1 calling convention passes parameters via four - // dedicated zero-page slots ($04-$07). Anything past the - // fourth parameter is silently dropped by the codegen - // (see `codegen/ir_codegen.rs` around the param-slot - // mapping loop) — which produces a runtime miscompile - // with no compile-time warning. Catch the over-arity case - // here so users get a clear error instead. - if fun.params.len() > 4 { + // Parameters are passed through zero-page transport slots + // for *leaf* functions only; non-leaf functions use a + // direct-write calling convention where the caller stages + // each argument straight into the callee's analyzer- + // allocated parameter RAM slot, bypassing the transport + // slots entirely. That lifts the per-function parameter cap + // from 4 (the number of ZP transport slots at $04-$07) to 8 + // for non-leaves. Leaves still cap at 4 because their bodies + // read `$04-$07` directly and there's nowhere to put extras. + // + // The analyzer can't know which functions will be leaves + // without running the leaf-analysis that only exists in the + // codegen, so we apply the looser 8-param cap here and let + // the codegen's `function_is_leaf` check demote any + // 5-to-8-param function to non-leaf automatically. The net + // user-visible rule: 1–4 params works everywhere; 5–8 params + // works but forbids the leaf fast path. + if fun.params.len() > 8 { self.diagnostics.push( Diagnostic::error( ErrorCode::E0506, format!( - "function '{}' has {} parameters; the maximum is 4 in this version", + "function '{}' has {} parameters; the maximum is 8", fun.name, fun.params.len() ), fun.span, ) .with_help( - "the v0.1 ABI passes parameters via four fixed zero-page slots ($04-$07); pass extras through globals or split the function".to_string(), + "pass related data through a struct global, or split the function into two" + .to_string(), ), ); return; diff --git a/src/analyzer/tests.rs b/src/analyzer/tests.rs index 131c5a2..98f5a6a 100644 --- a/src/analyzer/tests.rs +++ b/src/analyzer/tests.rs @@ -2143,24 +2143,28 @@ fn analyze_debug_frame_overrun_count_with_args_errors() { } #[test] -fn analyze_rejects_function_with_more_than_4_params() { - // The v0.1 calling convention only allocates 4 zero-page - // parameter slots ($04-$07). A function with 5 params would - // silently corrupt the 5th param at runtime, so we reject it - // at compile time with E0506. +fn analyze_rejects_function_with_more_than_8_params() { + // The calling convention allocates four zero-page transport + // slots ($04-$07) that leaves pass through, and a per-function + // RAM spill region that non-leaves direct-write into. Non-leaf + // functions scale past four params cleanly via the spill + // region, and we cap at eight both to keep the calling + // convention simple and because any call site needing more + // than eight arguments is almost certainly better served by a + // struct global. let errors = analyze_errors( r#" game "T" { mapper: NROM } - fun too_many(a: u8, b: u8, c: u8, d: u8, e: u8) { + fun too_many(a: u8, b: u8, c: u8, d: u8, e: u8, f: u8, g: u8, h: u8, i: u8) { a = 0 } - on frame { too_many(1, 2, 3, 4, 5) } + on frame { too_many(1, 2, 3, 4, 5, 6, 7, 8, 9) } start Main "#, ); assert!( errors.contains(&ErrorCode::E0506), - "expected E0506 for function with >4 params, got: {errors:?}" + "expected E0506 for function with >8 params, got: {errors:?}" ); } diff --git a/src/codegen/ir_codegen.rs b/src/codegen/ir_codegen.rs index 3ad399b..1b4f481 100644 --- a/src/codegen/ir_codegen.rs +++ b/src/codegen/ir_codegen.rs @@ -22,7 +22,7 @@ //! (`0x80-0xFF`) for IR temp storage per function. Functions reset the //! temp counter at entry. -use std::collections::{HashMap, HashSet}; +use std::collections::HashMap; use crate::analyzer::VarAllocation; use crate::asm::{AddressingMode as AM, Instruction, Opcode::*}; @@ -261,13 +261,6 @@ pub struct IrCodeGen<'a> { /// dispatcher loop, top-level functions). current_bank: Option, allocations: &'a [VarAllocation], - /// Set of function names whose body never `JSR`s any other - /// routine. Leaf functions skip the parameter-spill prologue - /// (`LDA $04 / STA `) entirely — they can read their - /// arguments straight out of the transport slots `$04..$07` - /// for the lifetime of the body, since nothing else clobbers - /// those slots from inside. Detected by [`function_is_leaf`]. - leaf_functions: HashSet, /// Per-function-scoped overrides for inline-asm `{name}` /// substitution. Leaf functions point their parameters at the /// transport slots `$04..$07` instead of the analyzer's @@ -275,6 +268,20 @@ pub struct IrCodeGen<'a> { /// body resolves to the live transport slot rather than the /// stale per-function spill destination. leaf_param_overrides: HashMap, + /// For every non-leaf function, the ordered list of addresses + /// its parameters occupy. The direct-write calling convention + /// has the *caller* of a non-leaf function stage each argument + /// straight into these slots before the `JSR`, bypassing the + /// `$04-$07` transport entirely. This saves the 12-to-16-cycle + /// spill prologue and, more importantly, lifts the 4-param cap + /// that the transport slots imposed. Populated alongside + /// `leaf_functions` in [`IrCodeGen::new`]. + /// + /// Leaves are deliberately absent: their callers still use the + /// `$04-$07` transport (which the body reads directly, since + /// leaves have no nested `JSR` to clobber them), and a lookup + /// miss here means "this callee is a leaf, use the transport". + non_leaf_param_addrs: HashMap>, } impl<'a> IrCodeGen<'a> { @@ -343,25 +350,40 @@ impl<'a> IrCodeGen<'a> { // `{name}` substitution agrees). `gen_function` skips the // spill prologue entirely for these — saving 12 cycles // and 12 bytes of code per leaf-function call site. - let mut leaf_functions: HashSet = HashSet::new(); let mut leaf_param_overrides: HashMap = HashMap::new(); + let mut non_leaf_param_addrs: HashMap> = HashMap::new(); for func in &ir.functions { - if !function_is_leaf(func) { - continue; - } - leaf_functions.insert(func.name.clone()); - let scope = scope_prefix_for_fn(&func.name); - for (i, local) in func - .locals - .iter() - .take(func.param_count) - .take(4) - .enumerate() - { - let addr = 0x04u16 + i as u16; - var_addrs.insert(local.var_id, addr); - let qualified = format!("__local__{scope}__{}", local.name); - leaf_param_overrides.insert(qualified, addr); + if function_is_leaf(func) { + let scope = scope_prefix_for_fn(&func.name); + for (i, local) in func.locals.iter().take(func.param_count).enumerate() { + let addr = 0x04u16 + i as u16; + var_addrs.insert(local.var_id, addr); + let qualified = format!("__local__{scope}__{}", local.name); + leaf_param_overrides.insert(qualified, addr); + } + } else if func.param_count > 0 { + // Non-leaf: callers direct-write each argument into + // these param slots. Resolve each param's allocated + // address from `var_addrs` (which was populated from + // the analyzer's per-function spill allocations a + // few loops up). A miss here means the analyzer + // didn't allocate a slot — which would be a + // compiler bug of the PR-#31 shape. + let addrs: Vec = func + .locals + .iter() + .take(func.param_count) + .map(|local| { + *var_addrs.get(&local.var_id).unwrap_or_else(|| { + panic!( + "internal compiler error: parameter {:?} of \ + function {:?} has no allocated address", + local.name, func.name + ) + }) + }) + .collect(); + non_leaf_param_addrs.insert(func.name.clone(), addrs); } } let function_names = ir.functions.iter().map(|f| f.name.clone()).collect(); @@ -413,8 +435,8 @@ impl<'a> IrCodeGen<'a> { function_banks, current_bank: None, allocations, - leaf_functions, leaf_param_overrides, + non_leaf_param_addrs, } } @@ -982,44 +1004,25 @@ impl<'a> IrCodeGen<'a> { self.emit_label(&format!("__ir_fn_{}", func.name)); - // Prologue: spill the parameter-transport slots $04-$07 - // into the function's per-local RAM slots. The caller - // stages arguments into $04-$07 before the JSR; this - // copy makes sure that when the function body later - // makes a nested call (which clobbers $04-$07 again to - // pass its own arguments), the caller's parameters are - // still available in their dedicated RAM slots. + // No parameter-spill prologue. Two call-conventions handle + // arguments entirely at the *call site*: // - // Parameters are the first `param_count` entries of - // `func.locals`. The analyzer refuses functions with - // more than 4 parameters (E0506), so the `.take(4)` - // below is a defensive guard — it should never be hit - // in a well-formed program. + // - Leaf functions: caller writes to the fixed transport + // slots `$04..$07`, body reads them directly for its + // entire lifetime. No prologue copy needed because + // leaves never `JSR` (by definition), so the transport + // slots never get clobbered from inside the body. // - // Leaf functions skip this entirely: their var_addrs - // were already rewired to $04-$07 in `IrCodeGen::new`, - // and since they never JSR from inside their body, the - // transport slots stay live for the whole function. This - // saves 12 cycles per call to every leaf primitive — the - // SHA-256 example's `cp_wk`, `xor_wk`, `add_wk`, etc. are - // all leaves. - if !self.leaf_functions.contains(&func.name) { - for (i, local) in func - .locals - .iter() - .take(func.param_count) - .take(4) - .enumerate() - { - let addr = self.var_addr(local.var_id); - self.emit(LDA, AM::ZeroPage(0x04 + i as u8)); - if addr < 0x100 { - self.emit(STA, AM::ZeroPage(addr as u8)); - } else { - self.emit(STA, AM::Absolute(addr)); - } - } - } + // - Non-leaf functions: caller direct-writes each arg + // into the callee's per-parameter RAM slot (see + // `non_leaf_param_addrs`). No prologue copy needed + // because the arg is already exactly where the body + // expects to read it. + // + // The old "transport-then-spill" prologue added 4 LDA/STA + // pairs (~28 cycles, 16 bytes) at every non-leaf entry. + // Skipping it saves the cycles *and* removes the hard + // 4-param ceiling that the four transport slots imposed. // At the start of a frame handler that actually draws // sprites, clear the OAM shadow buffer so stale sprites from @@ -1396,20 +1399,55 @@ impl<'a> IrCodeGen<'a> { } } IrOp::Call(dest, name, args) => { - // Pass up to 4 arguments via zero-page slots $04-$07. - // E0506 rejects function declarations with more than - // four parameters, so a call with >4 args is a - // compiler-internal inconsistency — panic rather than - // silently drop the extras (PR-#31-shaped miscompile). + // Two calling conventions, selected by callee shape: + // + // - **Leaf callees** (no nested `JSR` in body AND + // ≤4 params): stage each argument into the fixed + // zero-page transport slots `$04..$07`. The + // callee reads them straight out of `$04..$07` + // for its entire body — leaves never clobber + // those slots, so no prologue copy is needed. + // Fastest path; 3-cycle ZP stores and 3-cycle + // ZP loads inside the body. + // + // - **Non-leaf callees** (≥1 nested `JSR` OR 5–8 + // params): direct-write each argument straight + // into the callee's analyzer-allocated parameter + // RAM slot. No transport step, no prologue copy. + // Saves ~24 cycles and ~16 bytes per call vs the + // old "transport + spill" ABI, and — crucially — + // scales past 4 params because the slots live + // wherever the analyzer put them, not in a fixed + // ZP window. E0506 caps declarations at 8 so the + // assert below stays a belt-and-braces guard. assert!( - args.len() <= 4, + args.len() <= 8, "internal compiler error: Call to {name:?} with \ - {} args (max 4); E0506 should have caught this", + {} args (max 8); E0506 should have caught this", args.len() ); - for (i, arg) in args.iter().enumerate() { - self.load_temp(*arg); - self.emit(STA, AM::ZeroPage(0x04 + i as u8)); + if let Some(param_addrs) = self.non_leaf_param_addrs.get(name).cloned() { + debug_assert_eq!( + args.len(), + param_addrs.len(), + "arity mismatch at non-leaf call to {name}: \ + {} args vs {} params", + args.len(), + param_addrs.len() + ); + for (arg, addr) in args.iter().zip(param_addrs.iter()) { + self.load_temp(*arg); + if *addr < 0x100 { + self.emit(STA, AM::ZeroPage(*addr as u8)); + } else { + self.emit(STA, AM::Absolute(*addr)); + } + } + } else { + for (i, arg) in args.iter().enumerate() { + self.load_temp(*arg); + self.emit(STA, AM::ZeroPage(0x04 + i as u8)); + } } // Pick the right JSR target. Four cases: // 1. Caller and callee are both in the fixed bank @@ -2587,9 +2625,18 @@ fn scope_prefix_for_fn(name: &str) -> String { } } -/// True if `func` doesn't `JSR` any other routine from inside its -/// body. Leaf functions skip the parameter-spill prologue and read -/// their args straight out of the transport slots `$04..$07`. +/// True if `func` is eligible for the "leaf" calling convention: +/// its body emits no `JSR`, and its parameter count fits in the +/// four zero-page transport slots (`$04-$07`). Leaf-eligible +/// functions read their args straight out of the transport slots +/// and skip the spill prologue entirely. +/// +/// The `param_count <= 4` check matters for the direct-write +/// calling convention: non-leaf (5-to-8-param) functions have the +/// caller stage each argument directly into the callee's +/// per-parameter RAM slot, which is where leaves *can't* live +/// because their params would then need a prologue copy out of +/// `$04-$07` — defeating the whole point of the leaf fast path. /// /// The match below is exhaustive on `IrOp` so adding a new variant /// that secretly emits a `JSR` (e.g. a future `Mul16` calling a @@ -2598,6 +2645,9 @@ fn scope_prefix_for_fn(name: &str) -> String { /// ops are: `Call`, `Mul`, `Div`, `Mod`, `Transition`, plus /// `InlineAsm` bodies that mention `JSR` as a token. fn function_is_leaf(func: &IrFunction) -> bool { + if func.param_count > 4 { + return false; + } for block in &func.blocks { for op in &block.ops { // Returning `false` from any arm marks the function as @@ -4473,30 +4523,27 @@ mod more_tests { } #[test] -fn gen_function_prologue_spills_params_to_local_ram() { - // Regression test for the param-slot clobbering bug fixed - // on the War bug-cleanup branch (see `git log`): without the - // param-spill prologue, a function's parameters live only - // in the transport slots $04-$07. The first nested call - // inside the body would overwrite those slots with its - // own arguments, silently corrupting the caller's params. +fn non_leaf_call_direct_writes_args_to_callee_param_slots() { + // Non-leaf functions receive their parameters via direct + // caller writes to the callee's analyzer-allocated RAM slot, + // bypassing the `$04-$07` transport entirely. That's both + // faster (~24 cycles saved per call by eliminating the old + // spill prologue) and what lifts the 4-param ceiling — the + // slots live wherever the analyzer put them. // // Compile a function that takes `x: u8`, calls `helper(x)`, - // then uses `x` again. Verify that immediately after the - // `__ir_fn_caller` label, the codegen emits a spill - // `LDA $04 / STA ` where `` is the analyzer's - // dedicated address for the param — crucially, not $04 - // itself (which nested calls would clobber) and not - // $05/$06/$07 either. + // and verify two invariants: // - // Earlier revisions of this test asserted `` had to - // be an absolute address at `$0300+`, reflecting a codegen - // that minted a fresh per-function RAM range. After - // `compiler-bugs.md` #1 — the inline-asm `{param}` - // resolution fix — the codegen reuses the analyzer's - // allocation, which can land in zero page when there's - // room. The invariant that matters is "separate from the - // transport slots", not "must be main RAM". + // 1. The caller of `caller` stages its argument at the + // RAM slot the analyzer allocated for `x` (NOT at + // `$04-$07`), because `caller` is non-leaf. + // 2. The callee `caller`'s entry does NOT emit an + // `LDA $04 / STA ` prologue — args are already + // where the body expects to read them. + // + // Together these assertions would have failed the old ABI + // as well, but in the opposite direction — they're a + // regression guard for the new convention. use crate::parser; let src = r#" game "Test" { mapper: NROM } @@ -4521,45 +4568,67 @@ fn gen_function_prologue_spills_params_to_local_ram() { let mut codegen = IrCodeGen::new(&analysis.var_allocations, &ir); let insts = codegen.generate(&ir); - // Walk the instructions emitted for `caller` (up until the - // next function label) looking for the spill `LDA $04` / - // `STA ` pair. `` is accepted as either - // `ZeroPage(addr)` or `Absolute(addr)`, as long as `addr` - // is outside the `$04-$07` transport range. + // (2) The body of `caller` must not open with a prologue + // `LDA $04` — the first thing inside `__ir_fn_caller` + // should be related to the nested JSR to `helper`, not + // a spill copy. let caller_idx = insts .iter() .position(|i| i.mode == AM::Label("__ir_fn_caller".into())) .expect("caller function should be emitted"); - let mut saw_lda_zp4 = false; - let mut saw_sta_separate = false; - for inst in &insts[caller_idx + 1..] { - if let AM::Label(l) = &inst.mode { - if l.starts_with("__ir_fn_") && l != "__ir_fn_caller" { - break; - } - } - if inst.opcode == LDA && inst.mode == AM::ZeroPage(0x04) { - saw_lda_zp4 = true; - continue; - } - if saw_lda_zp4 && inst.opcode == STA { - let addr: u16 = match inst.mode { - AM::ZeroPage(a) => u16::from(a), - AM::Absolute(a) => a, - _ => continue, - }; - if !(0x04..=0x07).contains(&addr) { - saw_sta_separate = true; - break; - } - } + for inst in insts[caller_idx + 1..].iter().take(4) { + assert!( + !(inst.opcode == LDA && inst.mode == AM::ZeroPage(0x04)), + "non-leaf `caller` should not open with an `LDA $04` \ + prologue under the direct-write ABI — args are written \ + to caller's slots by the caller, not copied here" + ); } + + // (1) Walk the frame handler (which invokes `caller`) and + // locate the `STA` that stages the argument. Because + // `caller` is non-leaf, the staging STA must go to + // `caller.x`'s allocated slot — which the analyzer + // records under `"__local__caller__x"`. The target + // must be outside `$04-$07`. + let x_slot = analysis + .var_allocations + .iter() + .find(|a| a.name == "__local__caller__x") + .expect("analyzer should allocate a slot for caller's param `x`") + .address; assert!( - saw_lda_zp4 && saw_sta_separate, - "caller function should open with `LDA $04` followed by \ - a `STA ` that spills the param out of the \ - transport slots — the param-clobbering fix is not in \ - effect" + !(0x04..=0x07).contains(&x_slot), + "non-leaf param slot must live outside the `$04-$07` \ + transport range, got ${x_slot:04X}" + ); + + // The staging STA may land in either Main_frame or in + // caller's body (if the test source becomes nested). Scan + // from the start of Main_frame and require the slot to be + // hit at least once before any __ir_fn_ label other than + // Main_frame / caller. + let frame_idx = insts + .iter() + .position(|i| i.mode == AM::Label("__ir_fn_Main_frame".into())) + .expect("Main_frame handler should be emitted"); + let saw_stage = insts[frame_idx + 1..].iter().any(|inst| { + if inst.opcode != STA { + return false; + } + let addr: u16 = match inst.mode { + AM::ZeroPage(a) => u16::from(a), + AM::Absolute(a) => a, + _ => return false, + }; + addr == x_slot + }); + assert!( + saw_stage, + "caller site should stage the `42` argument directly at \ + `caller.x`'s slot (${x_slot:04X}), not at `$04`. \ + Emitted instructions follow: {:#?}", + &insts[frame_idx + 1..].iter().take(40).collect::>() ); } diff --git a/src/errors/diagnostic.rs b/src/errors/diagnostic.rs index 8ab6a98..fd7833d 100644 --- a/src/errors/diagnostic.rs +++ b/src/errors/diagnostic.rs @@ -33,7 +33,7 @@ pub enum ErrorCode { E0503, // undefined function E0504, // missing start declaration E0505, // multiple start declarations - E0506, // function has too many parameters (max 4 in v0.1) + E0506, // function has too many parameters (max 8) // E06xx: Unsupported-feature errors (parsed but not lowered) E0601, // state-local variable with array initializer is not supported diff --git a/tests/emulator/goldens/pong.audio.hash b/tests/emulator/goldens/pong.audio.hash index 469676e..c252042 100644 --- a/tests/emulator/goldens/pong.audio.hash +++ b/tests/emulator/goldens/pong.audio.hash @@ -1 +1 @@ -6c171f3d 132084 +6afbe82b 132084 diff --git a/tests/emulator/goldens/war.audio.hash b/tests/emulator/goldens/war.audio.hash index c832f7e..55b2161 100644 --- a/tests/emulator/goldens/war.audio.hash +++ b/tests/emulator/goldens/war.audio.hash @@ -1 +1 @@ -60d2ce9d 132084 +b054facb 132084 diff --git a/tests/integration_test.rs b/tests/integration_test.rs index a4afc78..792ff20 100644 --- a/tests/integration_test.rs +++ b/tests/integration_test.rs @@ -335,6 +335,97 @@ fn program_with_u16_struct_field() { rom::validate_ines(&rom_data).expect("should be valid iNES"); } +#[test] +fn eight_param_non_leaf_function_stages_every_arg_at_its_allocated_slot() { + // Non-leaf functions use direct-write calling convention: the + // caller stages each argument at the callee's analyzer- + // allocated parameter slot, bypassing the four-slot `$04-$07` + // transport. That lifts the 4-param ceiling to 8 (E0506) and + // saves the old prologue's ~28 cycles per call. + // + // Verify end-to-end by compiling a function that takes eight + // distinct u8 params (so any cross-wiring would show up) and + // writes each to a distinct global. Then scan the PRG for an + // `LDA #N / STA ` pair per arg — eight different + // immediates, eight different destination slots. The eight + // immediates `0x11..0x88` are chosen to be visually + // distinctive and unlikely to appear as incidental runtime + // constants. + let source = r#" + game "EightParams" { mapper: NROM } + var g0: u8 = 0 + var g1: u8 = 0 + var g2: u8 = 0 + var g3: u8 = 0 + var g4: u8 = 0 + var g5: u8 = 0 + var g6: u8 = 0 + var g7: u8 = 0 + fun spread(a: u8, b: u8, c: u8, d: u8, e: u8, f: u8, g: u8, h: u8) { + g0 = a + g1 = b + g2 = c + g3 = d + g4 = e + g5 = f + g6 = g + g7 = h + } + on frame { + spread(0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88) + } + start Main + "#; + let rom_data = compile(source); + let prg = &rom_data[16..16 + 16384]; + + // For each of the eight immediates, require at least one + // `LDA #imm / STA ` pair anywhere in PRG. The STA + // target can be zero-page ($85) or absolute ($8D); we don't + // pin down which because the analyzer picks the cheapest + // slot available. + for imm in [0x11u8, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88] { + let found = prg + .windows(4) + .any(|w| w[0] == 0xA9 && w[1] == imm && (w[2] == 0x85 || w[2] == 0x8D)); + assert!( + found, + "expected an LDA #${imm:02X} / STA pair for argument \ + staging — if this fails, the 8-param non-leaf call is \ + dropping args again" + ); + } + + // Belt-and-braces: in the OLD ABI every arg first got + // staged to $04-$07 (four ZP addresses). The new ABI stages + // *nothing* to those addresses for this call (spread has + // more than four params, so it's forced non-leaf, so direct- + // write). If someone re-introduces the old transport path, + // we'd see STA $04/$05/$06/$07 pairs. Assert absence. + for transport_slot in 0x04..=0x07u8 { + let any_store = prg + .windows(2) + .any(|w| w[0] == 0x85 && w[1] == transport_slot); + // The runtime itself may write to $04-$07 (they're not + // reserved outside of this calling convention), so we + // can't assert zero globally. Just require that the + // number of STA-to-transport stores is strictly smaller + // than the 8 args — if the old transport path were + // active we'd see eight extra such stores. + let _ = any_store; // intentional: see count check below + } + let transport_sta_count = prg + .windows(2) + .filter(|w| w[0] == 0x85 && (0x04..=0x07).contains(&w[1])) + .count(); + assert!( + transport_sta_count < 8, + "the 8-param call should NOT stage any arg through the \ + `$04-$07` transport slots under the direct-write ABI; saw \ + {transport_sta_count} `STA $04-$07` instructions total in PRG" + ); +} + #[test] fn transition_dispatches_leaving_states_on_exit_handler() { // `on exit` handlers used to be silently never called — the