mirror of
https://github.com/imjasonh/nescript
synced 2026-07-21 22:39:25 +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`).
|
- **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`.
|
- **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 |
|
| E0503 | Undefined function |
|
||||||
| E0504 | Missing start declaration |
|
| E0504 | Missing start declaration |
|
||||||
| E0505 | Multiple start declarations|
|
| E0505 | Multiple start declarations|
|
||||||
| E0506 | Function has too many parameters (max 4) |
|
| E0506 | Function has too many parameters (max 8) |
|
||||||
|
|
||||||
### Warnings (W01xx)
|
### Warnings (W01xx)
|
||||||
|
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -80,6 +80,22 @@ fun double_u8(x: u8) -> u8 {
|
||||||
return x + x
|
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 ─────────────────────────────────────────────
|
// ── Main state ─────────────────────────────────────────────
|
||||||
|
|
||||||
state Main {
|
state Main {
|
||||||
|
|
@ -128,6 +144,12 @@ state Main {
|
||||||
var r: u8 = double_u8(21)
|
var r: u8 = double_u8(21)
|
||||||
if r != 42 { all_ok = 0 }
|
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
|
// Drive the backdrop flip. `set_palette` schedules an
|
||||||
// update during the next vblank, so the effect lands on
|
// update during the next vblank, so the effect lands on
|
||||||
// the following frame — well before the frame-180 golden
|
// the following frame — well before the frame-180 golden
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -6,14 +6,24 @@
|
||||||
//
|
//
|
||||||
// frame -> compute -> scale -> clamp -> fold -> taper
|
// frame -> compute -> scale -> clamp -> fold -> taper
|
||||||
//
|
//
|
||||||
// Each function takes its argument through the zero-page
|
// Each function takes its argument through the calling
|
||||||
// parameter slots ($04-$07), computes a small transform, and
|
// convention and returns a value in A. The chained result is
|
||||||
// returns a value in A. The chained result is what drives the
|
// what drives the player sprite's X position on screen each
|
||||||
// player sprite's X position on screen each frame.
|
// 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:
|
// What this exercises end-to-end:
|
||||||
// - Five levels of nested `JSR` without stack corruption
|
// - 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
|
// - Return value propagation through A
|
||||||
// - `fun ... -> u8 { return ... }` — the full typed-function
|
// - `fun ... -> u8 { return ... }` — the full typed-function
|
||||||
// shape, including an early `return` inside an `if`
|
// 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;
|
return;
|
||||||
}
|
}
|
||||||
// The v0.1 calling convention passes parameters via four
|
// The v0.1 calling convention passes parameters via four
|
||||||
// dedicated zero-page slots ($04-$07). Anything past the
|
// Parameters are passed through zero-page transport slots
|
||||||
// fourth parameter is silently dropped by the codegen
|
// for *leaf* functions only; non-leaf functions use a
|
||||||
// (see `codegen/ir_codegen.rs` around the param-slot
|
// direct-write calling convention where the caller stages
|
||||||
// mapping loop) — which produces a runtime miscompile
|
// each argument straight into the callee's analyzer-
|
||||||
// with no compile-time warning. Catch the over-arity case
|
// allocated parameter RAM slot, bypassing the transport
|
||||||
// here so users get a clear error instead.
|
// slots entirely. That lifts the per-function parameter cap
|
||||||
if fun.params.len() > 4 {
|
// 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(
|
self.diagnostics.push(
|
||||||
Diagnostic::error(
|
Diagnostic::error(
|
||||||
ErrorCode::E0506,
|
ErrorCode::E0506,
|
||||||
format!(
|
format!(
|
||||||
"function '{}' has {} parameters; the maximum is 4 in this version",
|
"function '{}' has {} parameters; the maximum is 8",
|
||||||
fun.name,
|
fun.name,
|
||||||
fun.params.len()
|
fun.params.len()
|
||||||
),
|
),
|
||||||
fun.span,
|
fun.span,
|
||||||
)
|
)
|
||||||
.with_help(
|
.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;
|
return;
|
||||||
|
|
|
||||||
|
|
@ -2143,24 +2143,28 @@ fn analyze_debug_frame_overrun_count_with_args_errors() {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn analyze_rejects_function_with_more_than_4_params() {
|
fn analyze_rejects_function_with_more_than_8_params() {
|
||||||
// The v0.1 calling convention only allocates 4 zero-page
|
// The calling convention allocates four zero-page transport
|
||||||
// parameter slots ($04-$07). A function with 5 params would
|
// slots ($04-$07) that leaves pass through, and a per-function
|
||||||
// silently corrupt the 5th param at runtime, so we reject it
|
// RAM spill region that non-leaves direct-write into. Non-leaf
|
||||||
// at compile time with E0506.
|
// 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(
|
let errors = analyze_errors(
|
||||||
r#"
|
r#"
|
||||||
game "T" { mapper: NROM }
|
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
|
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
|
start Main
|
||||||
"#,
|
"#,
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
errors.contains(&ErrorCode::E0506),
|
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
|
//! (`0x80-0xFF`) for IR temp storage per function. Functions reset the
|
||||||
//! temp counter at entry.
|
//! temp counter at entry.
|
||||||
|
|
||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::HashMap;
|
||||||
|
|
||||||
use crate::analyzer::VarAllocation;
|
use crate::analyzer::VarAllocation;
|
||||||
use crate::asm::{AddressingMode as AM, Instruction, Opcode::*};
|
use crate::asm::{AddressingMode as AM, Instruction, Opcode::*};
|
||||||
|
|
@ -261,13 +261,6 @@ pub struct IrCodeGen<'a> {
|
||||||
/// dispatcher loop, top-level functions).
|
/// dispatcher loop, top-level functions).
|
||||||
current_bank: Option<String>,
|
current_bank: Option<String>,
|
||||||
allocations: &'a [VarAllocation],
|
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}`
|
/// Per-function-scoped overrides for inline-asm `{name}`
|
||||||
/// substitution. Leaf functions point their parameters at the
|
/// substitution. Leaf functions point their parameters at the
|
||||||
/// transport slots `$04..$07` instead of the analyzer's
|
/// 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
|
/// body resolves to the live transport slot rather than the
|
||||||
/// stale per-function spill destination.
|
/// stale per-function spill destination.
|
||||||
leaf_param_overrides: HashMap<String, u16>,
|
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> {
|
impl<'a> IrCodeGen<'a> {
|
||||||
|
|
@ -343,26 +350,41 @@ impl<'a> IrCodeGen<'a> {
|
||||||
// `{name}` substitution agrees). `gen_function` skips the
|
// `{name}` substitution agrees). `gen_function` skips the
|
||||||
// spill prologue entirely for these — saving 12 cycles
|
// spill prologue entirely for these — saving 12 cycles
|
||||||
// and 12 bytes of code per leaf-function call site.
|
// 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 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 {
|
for func in &ir.functions {
|
||||||
if !function_is_leaf(func) {
|
if function_is_leaf(func) {
|
||||||
continue;
|
|
||||||
}
|
|
||||||
leaf_functions.insert(func.name.clone());
|
|
||||||
let scope = scope_prefix_for_fn(&func.name);
|
let scope = scope_prefix_for_fn(&func.name);
|
||||||
for (i, local) in func
|
for (i, local) in func.locals.iter().take(func.param_count).enumerate() {
|
||||||
.locals
|
|
||||||
.iter()
|
|
||||||
.take(func.param_count)
|
|
||||||
.take(4)
|
|
||||||
.enumerate()
|
|
||||||
{
|
|
||||||
let addr = 0x04u16 + i as u16;
|
let addr = 0x04u16 + i as u16;
|
||||||
var_addrs.insert(local.var_id, addr);
|
var_addrs.insert(local.var_id, addr);
|
||||||
let qualified = format!("__local__{scope}__{}", local.name);
|
let qualified = format!("__local__{scope}__{}", local.name);
|
||||||
leaf_param_overrides.insert(qualified, addr);
|
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();
|
let function_names = ir.functions.iter().map(|f| f.name.clone()).collect();
|
||||||
// Build the function-name → bank-name map from the IR. Most
|
// Build the function-name → bank-name map from the IR. Most
|
||||||
|
|
@ -413,8 +435,8 @@ impl<'a> IrCodeGen<'a> {
|
||||||
function_banks,
|
function_banks,
|
||||||
current_bank: None,
|
current_bank: None,
|
||||||
allocations,
|
allocations,
|
||||||
leaf_functions,
|
|
||||||
leaf_param_overrides,
|
leaf_param_overrides,
|
||||||
|
non_leaf_param_addrs,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -982,44 +1004,25 @@ impl<'a> IrCodeGen<'a> {
|
||||||
|
|
||||||
self.emit_label(&format!("__ir_fn_{}", func.name));
|
self.emit_label(&format!("__ir_fn_{}", func.name));
|
||||||
|
|
||||||
// Prologue: spill the parameter-transport slots $04-$07
|
// No parameter-spill prologue. Two call-conventions handle
|
||||||
// into the function's per-local RAM slots. The caller
|
// arguments entirely at the *call site*:
|
||||||
// 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.
|
|
||||||
//
|
//
|
||||||
// Parameters are the first `param_count` entries of
|
// - Leaf functions: caller writes to the fixed transport
|
||||||
// `func.locals`. The analyzer refuses functions with
|
// slots `$04..$07`, body reads them directly for its
|
||||||
// more than 4 parameters (E0506), so the `.take(4)`
|
// entire lifetime. No prologue copy needed because
|
||||||
// below is a defensive guard — it should never be hit
|
// leaves never `JSR` (by definition), so the transport
|
||||||
// in a well-formed program.
|
// slots never get clobbered from inside the body.
|
||||||
//
|
//
|
||||||
// Leaf functions skip this entirely: their var_addrs
|
// - Non-leaf functions: caller direct-writes each arg
|
||||||
// were already rewired to $04-$07 in `IrCodeGen::new`,
|
// into the callee's per-parameter RAM slot (see
|
||||||
// and since they never JSR from inside their body, the
|
// `non_leaf_param_addrs`). No prologue copy needed
|
||||||
// transport slots stay live for the whole function. This
|
// because the arg is already exactly where the body
|
||||||
// saves 12 cycles per call to every leaf primitive — the
|
// expects to read it.
|
||||||
// SHA-256 example's `cp_wk`, `xor_wk`, `add_wk`, etc. are
|
//
|
||||||
// all leaves.
|
// The old "transport-then-spill" prologue added 4 LDA/STA
|
||||||
if !self.leaf_functions.contains(&func.name) {
|
// pairs (~28 cycles, 16 bytes) at every non-leaf entry.
|
||||||
for (i, local) in func
|
// Skipping it saves the cycles *and* removes the hard
|
||||||
.locals
|
// 4-param ceiling that the four transport slots imposed.
|
||||||
.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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// At the start of a frame handler that actually draws
|
// At the start of a frame handler that actually draws
|
||||||
// sprites, clear the OAM shadow buffer so stale sprites from
|
// sprites, clear the OAM shadow buffer so stale sprites from
|
||||||
|
|
@ -1396,21 +1399,56 @@ impl<'a> IrCodeGen<'a> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
IrOp::Call(dest, name, args) => {
|
IrOp::Call(dest, name, args) => {
|
||||||
// Pass up to 4 arguments via zero-page slots $04-$07.
|
// Two calling conventions, selected by callee shape:
|
||||||
// E0506 rejects function declarations with more than
|
//
|
||||||
// four parameters, so a call with >4 args is a
|
// - **Leaf callees** (no nested `JSR` in body AND
|
||||||
// compiler-internal inconsistency — panic rather than
|
// ≤4 params): stage each argument into the fixed
|
||||||
// silently drop the extras (PR-#31-shaped miscompile).
|
// 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!(
|
assert!(
|
||||||
args.len() <= 4,
|
args.len() <= 8,
|
||||||
"internal compiler error: Call to {name:?} with \
|
"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()
|
args.len()
|
||||||
);
|
);
|
||||||
|
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() {
|
for (i, arg) in args.iter().enumerate() {
|
||||||
self.load_temp(*arg);
|
self.load_temp(*arg);
|
||||||
self.emit(STA, AM::ZeroPage(0x04 + i as u8));
|
self.emit(STA, AM::ZeroPage(0x04 + i as u8));
|
||||||
}
|
}
|
||||||
|
}
|
||||||
// Pick the right JSR target. Four cases:
|
// Pick the right JSR target. Four cases:
|
||||||
// 1. Caller and callee are both in the fixed bank
|
// 1. Caller and callee are both in the fixed bank
|
||||||
// (most common): JSR `__ir_fn_<name>` directly.
|
// (most common): JSR `__ir_fn_<name>` directly.
|
||||||
|
|
@ -2587,9 +2625,18 @@ fn scope_prefix_for_fn(name: &str) -> String {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// True if `func` doesn't `JSR` any other routine from inside its
|
/// True if `func` is eligible for the "leaf" calling convention:
|
||||||
/// body. Leaf functions skip the parameter-spill prologue and read
|
/// its body emits no `JSR`, and its parameter count fits in the
|
||||||
/// their args straight out of the transport slots `$04..$07`.
|
/// 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
|
/// The match below is exhaustive on `IrOp` so adding a new variant
|
||||||
/// that secretly emits a `JSR` (e.g. a future `Mul16` calling a
|
/// 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
|
/// ops are: `Call`, `Mul`, `Div`, `Mod`, `Transition`, plus
|
||||||
/// `InlineAsm` bodies that mention `JSR` as a token.
|
/// `InlineAsm` bodies that mention `JSR` as a token.
|
||||||
fn function_is_leaf(func: &IrFunction) -> bool {
|
fn function_is_leaf(func: &IrFunction) -> bool {
|
||||||
|
if func.param_count > 4 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
for block in &func.blocks {
|
for block in &func.blocks {
|
||||||
for op in &block.ops {
|
for op in &block.ops {
|
||||||
// Returning `false` from any arm marks the function as
|
// Returning `false` from any arm marks the function as
|
||||||
|
|
@ -4473,30 +4523,27 @@ mod more_tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn gen_function_prologue_spills_params_to_local_ram() {
|
fn non_leaf_call_direct_writes_args_to_callee_param_slots() {
|
||||||
// Regression test for the param-slot clobbering bug fixed
|
// Non-leaf functions receive their parameters via direct
|
||||||
// on the War bug-cleanup branch (see `git log`): without the
|
// caller writes to the callee's analyzer-allocated RAM slot,
|
||||||
// param-spill prologue, a function's parameters live only
|
// bypassing the `$04-$07` transport entirely. That's both
|
||||||
// in the transport slots $04-$07. The first nested call
|
// faster (~24 cycles saved per call by eliminating the old
|
||||||
// inside the body would overwrite those slots with its
|
// spill prologue) and what lifts the 4-param ceiling — the
|
||||||
// own arguments, silently corrupting the caller's params.
|
// slots live wherever the analyzer put them.
|
||||||
//
|
//
|
||||||
// Compile a function that takes `x: u8`, calls `helper(x)`,
|
// Compile a function that takes `x: u8`, calls `helper(x)`,
|
||||||
// then uses `x` again. Verify that immediately after the
|
// and verify two invariants:
|
||||||
// `__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.
|
|
||||||
//
|
//
|
||||||
// Earlier revisions of this test asserted `<slot>` had to
|
// 1. The caller of `caller` stages its argument at the
|
||||||
// be an absolute address at `$0300+`, reflecting a codegen
|
// RAM slot the analyzer allocated for `x` (NOT at
|
||||||
// that minted a fresh per-function RAM range. After
|
// `$04-$07`), because `caller` is non-leaf.
|
||||||
// `compiler-bugs.md` #1 — the inline-asm `{param}`
|
// 2. The callee `caller`'s entry does NOT emit an
|
||||||
// resolution fix — the codegen reuses the analyzer's
|
// `LDA $04 / STA <slot>` prologue — args are already
|
||||||
// allocation, which can land in zero page when there's
|
// where the body expects to read them.
|
||||||
// room. The invariant that matters is "separate from the
|
//
|
||||||
// transport slots", not "must be main RAM".
|
// 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;
|
use crate::parser;
|
||||||
let src = r#"
|
let src = r#"
|
||||||
game "Test" { mapper: NROM }
|
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 mut codegen = IrCodeGen::new(&analysis.var_allocations, &ir);
|
||||||
let insts = codegen.generate(&ir);
|
let insts = codegen.generate(&ir);
|
||||||
|
|
||||||
// Walk the instructions emitted for `caller` (up until the
|
// (2) The body of `caller` must not open with a prologue
|
||||||
// next function label) looking for the spill `LDA $04` /
|
// `LDA $04` — the first thing inside `__ir_fn_caller`
|
||||||
// `STA <slot>` pair. `<slot>` is accepted as either
|
// should be related to the nested JSR to `helper`, not
|
||||||
// `ZeroPage(addr)` or `Absolute(addr)`, as long as `addr`
|
// a spill copy.
|
||||||
// is outside the `$04-$07` transport range.
|
|
||||||
let caller_idx = insts
|
let caller_idx = insts
|
||||||
.iter()
|
.iter()
|
||||||
.position(|i| i.mode == AM::Label("__ir_fn_caller".into()))
|
.position(|i| i.mode == AM::Label("__ir_fn_caller".into()))
|
||||||
.expect("caller function should be emitted");
|
.expect("caller function should be emitted");
|
||||||
let mut saw_lda_zp4 = false;
|
for inst in insts[caller_idx + 1..].iter().take(4) {
|
||||||
let mut saw_sta_separate = false;
|
assert!(
|
||||||
for inst in &insts[caller_idx + 1..] {
|
!(inst.opcode == LDA && inst.mode == AM::ZeroPage(0x04)),
|
||||||
if let AM::Label(l) = &inst.mode {
|
"non-leaf `caller` should not open with an `LDA $04` \
|
||||||
if l.starts_with("__ir_fn_") && l != "__ir_fn_caller" {
|
prologue under the direct-write ABI — args are written \
|
||||||
break;
|
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!(
|
||||||
|
!(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;
|
||||||
}
|
}
|
||||||
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 {
|
let addr: u16 = match inst.mode {
|
||||||
AM::ZeroPage(a) => u16::from(a),
|
AM::ZeroPage(a) => u16::from(a),
|
||||||
AM::Absolute(a) => a,
|
AM::Absolute(a) => a,
|
||||||
_ => continue,
|
_ => return false,
|
||||||
};
|
};
|
||||||
if !(0x04..=0x07).contains(&addr) {
|
addr == x_slot
|
||||||
saw_sta_separate = true;
|
});
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
assert!(
|
assert!(
|
||||||
saw_lda_zp4 && saw_sta_separate,
|
saw_stage,
|
||||||
"caller function should open with `LDA $04` followed by \
|
"caller site should stage the `42` argument directly at \
|
||||||
a `STA <slot>` that spills the param out of the \
|
`caller.x`'s slot (${x_slot:04X}), not at `$04`. \
|
||||||
transport slots — the param-clobbering fix is not in \
|
Emitted instructions follow: {:#?}",
|
||||||
effect"
|
&insts[frame_idx + 1..].iter().take(40).collect::<Vec<_>>()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,7 @@ pub enum ErrorCode {
|
||||||
E0503, // undefined function
|
E0503, // undefined function
|
||||||
E0504, // missing start declaration
|
E0504, // missing start declaration
|
||||||
E0505, // multiple start declarations
|
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)
|
// E06xx: Unsupported-feature errors (parsed but not lowered)
|
||||||
E0601, // state-local variable with array initializer is not supported
|
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");
|
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]
|
#[test]
|
||||||
fn transition_dispatches_leaving_states_on_exit_handler() {
|
fn transition_dispatches_leaving_states_on_exit_handler() {
|
||||||
// `on exit` handlers used to be silently never called — the
|
// `on exit` handlers used to be silently never called — the
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue