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:
parent
ee3dddb19e
commit
0602fd9590
17 changed files with 364 additions and 156 deletions
|
|
@ -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.
|
|
@ -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.
|
|
@ -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.
BIN
examples/war.nes
BIN
examples/war.nes
Binary file not shown.
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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:?}"
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 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 <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<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
6c171f3d 132084
|
||||
6afbe82b 132084
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
60d2ce9d 132084
|
||||
b054facb 132084
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue