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

analyzer+codegen: lift the 4-param ceiling via a direct-write calling convention

Follow-up to the silent-drop audit. The old ABI passed every
parameter through four fixed zero-page transport slots `$04-$07`,
imposing a hard 4-param cap (E0506) that didn't compose with
structs/arrays/u16s and fell back to "pack args into a global"
workarounds whenever a function needed five things. The transport
scheme also cost every non-leaf call a 4-LDA/STA spill prologue
(~28 cycles, 16 bytes) to copy args out of ZP before the next
nested `JSR` could clobber them.

Replace it with a hybrid convention keyed on leaf-ness:

- **Leaf callees** (no nested `JSR` in body, ≤4 params):
  unchanged. Caller stages args into `$04-$07`; body reads those
  slots directly for its entire lifetime. No prologue copy.
  Fastest path, 3-cycle ZP stores + 3-cycle ZP loads, preserves
  the SHA-256 leaf-primitive optimisation that motivated the
  original fast path.

- **Non-leaf callees** (body contains a nested `JSR`, OR ≥5
  params): direct-write. Caller stages each argument straight
  into the callee's analyzer-allocated parameter RAM slot,
  bypassing the transport slots entirely. No prologue copy on
  the callee side. Saves ~24 cycles and ~16 bytes per call vs
  the old transport-then-spill path, and — crucially — scales
  past 4 params because the per-param slots live wherever the
  analyzer put them rather than in a fixed ZP window.

The analyzer's ceiling moves from 4 to 8. Functions with 5–8
params are silently promoted to the non-leaf convention (even if
their body has no nested `JSR`), which pays the direct-write cost
rather than the prologue-copy cost — still cheaper than the old
ABI. Declarations with 9+ params still emit E0506.

### Implementation

- `function_is_leaf` now also requires `param_count <= 4`.
- `IrCodeGen::new` populates `non_leaf_param_addrs: HashMap<String,
  Vec<u16>>` — for every non-leaf function, the ordered list of
  addresses its parameters occupy. Callers use this to route each
  arg directly to the right slot.
- `IrOp::Call` branches on presence in the map: non-leaf → direct-
  write, leaf (or absent — 0-arg case) → ZP transport.
- `gen_function` no longer emits a prologue. Leaves didn't have
  one; non-leaves had a 4-LDA/STA copy that is now unnecessary
  because args arrive pre-written to the slot.
- The previous `leaf_functions: HashSet<String>` field is
  removed; leaf-ness is now inferred from absence-in-
  `non_leaf_param_addrs` at the call site.

### Tests and regressions

- `eight_param_non_leaf_function_stages_every_arg_at_its_allocated_slot`
  compiles an 8-param function, scans PRG for a distinct
  `LDA #\$NN / STA <addr>` per arg (immediates `0x11..0x88`), and
  asserts that STAs to the `$04-$07` range are strictly fewer
  than 8 — proof the old transport path is gone for this call.
- `non_leaf_call_direct_writes_args_to_callee_param_slots`
  replaces the old `gen_function_prologue_spills_params_to_local_ram`
  test with a dual assertion: (a) no `LDA \$04` prologue at the
  callee entry, and (b) the caller-side STA lands at the
  analyzer-allocated param slot, not at `\$04-\$07`.
- `analyze_rejects_function_with_more_than_4_params` renamed and
  rewritten for the new 8-param cap.
- `feature_canary.ne` gains a 6-param `sum6` call (1+2+3+4+5+6 =
  21) as check 8. The canary stays green (all eight checks
  pass), so the committed golden is unchanged.

### Blast radius

- Six example ROMs change bytes (arrays_and_functions, function_chain,
  mmc1_banked, pong, sha256, war) because their non-leaf call sites
  pick up the shorter staging sequence.
- Pong and war audio hashes refresh (pure layout-timing shift; no
  behavioural change in the 180-frame no-input window). docs/pong.gif
  and docs/war.gif stay byte-identical.
- `examples/function_chain.ne`'s header comment updated to
  document the leaf vs non-leaf split it exercises.
- `docs/language-guide.md` parameter-count section and E0506 entry
  updated to reflect the new rule.

All 720 Rust tests pass; all 35 emulator goldens pass.

https://claude.ai/code/session_01AoQ678uVeqpyayvWHpfDhC
This commit is contained in:
Claude 2026-04-18 02:34:56 +00:00
parent ee3dddb19e
commit 0602fd9590
No known key found for this signature in database
17 changed files with 364 additions and 156 deletions

View file

@ -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)

Binary file not shown.

View file

@ -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

Binary file not shown.

View file

@ -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`

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -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: 14 params works everywhere; 58 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;

View file

@ -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:?}"
);
}

View file

@ -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<String>,
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 <local>`) 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<String>,
/// 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<String, u16>,
/// 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<String, Vec<u16>>,
}
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<String> = HashSet::new();
let mut leaf_param_overrides: HashMap<String, u16> = HashMap::new();
let mut non_leaf_param_addrs: HashMap<String, Vec<u16>> = 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<u16> = 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 58
// 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 <slot>` where `<slot>` 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 `<slot>` 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 <slot>` 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 <slot>` pair. `<slot>` 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 <slot>` 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::<Vec<_>>()
);
}

View file

@ -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

View file

@ -1 +1 @@
6c171f3d 132084
6afbe82b 132084

View file

@ -1 +1 @@
60d2ce9d 132084
b054facb 132084

View file

@ -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 <slot>` 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 <addr>` 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 <addr> 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