1
0
Fork 0
mirror of https://github.com/imjasonh/nescript synced 2026-07-10 01:37:45 +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

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