1
0
Fork 0
mirror of https://github.com/imjasonh/nescript synced 2026-07-09 09:18:01 +00:00

compiler: close out bug #4 (W0109 sprite-per-scanline) and bug #5 (real inlining)

Fixes the last two deferred compiler bugs catalogued in
examples/war/COMPILER_BUGS.md, finishing the bug-cleanup arc on
the War branch.

Bug #5 — `inline fun` inliner
  Previously the `inline` keyword was parsed into `FunDecl.is_inline`
  and then dropped on the floor: every call site emitted a regular
  `JSR` through the $04-$07 transport slots. Now the IR lowerer
  captures inline function bodies up front in
  `LoweringContext::capture_inline_bodies` and rewrites call sites
  at lowering time. Two body shapes are supported:

    1. Single-return expression — the body is re-lowered in place
       of the `Call` op with the parameter names substituted to
       fresh IR temps for each argument.
    2. Void multi-statement body whose every statement is one of
       Assign/Call/Draw/Scroll/SetPalette/LoadBackground/WaitFrame/
       Play/StartMusic/StopMusic/InlineAsm/RawAsm/DebugLog/DebugAssert
       — the statements are spliced into the caller's block with
       the same parameter substitution machinery.

  Control-flow-heavy inline bodies (conditional early returns,
  loops, transitions) fall back to a regular out-of-line call with
  no diagnostic. That's predictable and documented in the bug-tracking
  doc. Nested inline expansion uses a substitution-frame stack so
  an inline calling another inline sees the right arguments.

  A codegen follow-up was needed because bug #3's scope-qualified
  local names broke `{result}` substitution in inline asm. The
  codegen now tracks `current_fn_scope_prefix` per function and the
  InlineAsm op tries the qualified name first before falling back
  to the bare name.

Bug #4 — W0109 sprite-per-scanline static check
  Adds a new warning code W0109 and an analyzer pass
  `check_sprite_scanline_budget` that walks each state's `on_frame`
  handler, collects literal-coordinate `draw` statements (including
  metasprite expansion via dx/dy offsets), and iterates scanlines
  0..240 to count how many 8x8 sprites overlap each line. When a
  scanline has > 8, the analyzer emits W0109 with labels pointing
  at each offending draw site plus a help message about staggering
  y-rows and a note explaining the hardware dropout. Non-literal
  coordinates are skipped (static analysis can't resolve them).
  Nested `if`/`while`/`for`/`loop` blocks are unioned conservatively.

Tests added
  src/ir/tests.rs
    - inline_fun_expression_body_emits_no_call_at_use_site
    - inline_fun_void_body_statements_are_spliced
    - inline_fun_with_conditional_return_compiles_as_regular_call
    - inline_fun_nested_inlines_substitute_correctly
  src/analyzer/tests.rs
    - analyze_sprite_scanline_budget_warns_over_eight
    - analyze_sprite_scanline_budget_ok_when_staggered
    - analyze_sprite_scanline_budget_skips_dynamic_coords
    - analyze_sprite_scanline_budget_expands_metasprites
    - analyze_sprite_scanline_budget_recurses_into_if

COMPILER_BUGS.md
  Bugs #4 and #5 marked **FIXED** in the status table, with full
  reproduction/root-cause/fix/regression-test write-ups updated in
  place. All seven catalogued bugs now have shipped fixes.

Artifact churn
  - examples/war.nes and examples/inline_asm_demo.nes rebuild
    byte-shifted (different JSR targets post-inliner).
  - tests/emulator/goldens/war.audio.hash shifts from 143660f to
    13443e28 — the inliner removes JSRs to set_phase, which nudges
    NMI sampling timing. No pixel diff; behavior is unchanged.

https://claude.ai/code/session_0143dTgh3UeRrtfHgQwzcv5z
This commit is contained in:
Claude 2026-04-15 21:33:00 +00:00
parent 76dd8eacb0
commit d6cb84a5bd
No known key found for this signature in database
10 changed files with 1046 additions and 60 deletions

View file

@ -678,6 +678,16 @@ impl Analyzer {
// Check for unreachable states (W0104).
self.check_unreachable_states(program);
// Check for literal-coord sprite draws that would
// overflow the NES's 8-sprites-per-scanline hardware
// limit (W0109). Only on_frame handlers are checked —
// on_enter and on_exit fire once per transition and are
// much less likely to exceed the budget. Only draws
// with `IntLiteral` (x, y) pairs are counted; dynamic
// coordinates are skipped because the static analysis
// can't know where the sprite will land at runtime.
self.check_sprite_scanline_budget(program);
}
/// Qualify `name` under the current scope prefix. If no prefix
@ -1024,6 +1034,117 @@ impl Analyzer {
}
}
/// Static check for the NES's 8-sprites-per-scanline hardware
/// limit (W0109). Walks every state's `on_frame` handler,
/// collects literal-coordinate `draw` statements (and expands
/// metasprites via their per-tile `dx`/`dy` offsets), then
/// iterates scanlines 0..240 and emits W0109 for any state
/// where more than 8 sprites overlap a single scanline.
///
/// Draws with non-literal `x` or `y` are skipped — static
/// analysis can't know where those sprites land at runtime.
/// Draws inside nested `if`/`while`/`for`/`loop` blocks are
/// counted as if they always fire; this over-counts programs
/// that stagger draws across mutually exclusive branches, but
/// it matches the worst case the hardware sees. Only `on_frame`
/// is checked — `on_enter`/`on_exit` run once per transition
/// and aren't on the hot sprite path.
fn check_sprite_scanline_budget(&mut self, program: &Program) {
// Build a name -> MetaspriteDecl lookup so draws that target
// a metasprite can expand to one slot per tile offset.
let metasprites: HashMap<&str, &MetaspriteDecl> = program
.metasprites
.iter()
.map(|ms| (ms.name.as_str(), ms))
.collect();
for state in &program.states {
let Some(block) = &state.on_frame else {
continue;
};
// Collect (y, x, span) tuples for every literal-coord
// draw in the handler, recursing through nested control
// flow and expanding metasprites.
let mut draws: Vec<(u8, u8, Span)> = Vec::new();
collect_literal_draws(block, &metasprites, &mut draws);
// Fast path: if there aren't even 9 literal draws total
// the overlap check can never trip.
if draws.len() <= 8 {
continue;
}
// For each scanline, count how many 8×8 sprites cover
// it. Sprites at y=Y cover scanlines Y..Y+8 (NES OAM
// stores the y one line early, but for the overlap
// budget the 8-pixel span is what matters).
let mut worst_count: usize = 0;
let mut worst_scanline: u16 = 0;
for scanline in 0u16..240 {
let mut count = 0usize;
for (y, _, _) in &draws {
let top = u16::from(*y);
if top <= scanline && scanline < top + 8 {
count += 1;
}
}
if count > worst_count {
worst_count = count;
worst_scanline = scanline;
}
}
if worst_count <= 8 {
continue;
}
// Build a diagnostic pointing at the state with labels
// on each offending draw. Cap the labels at 9 so the
// message doesn't become a wall of text for pathological
// programs.
let mut diag = Diagnostic::warning(
ErrorCode::W0109,
format!(
"state '{}' draws {} literal-coordinate sprites overlapping scanline {}; \
the NES renders at most 8 sprites per scanline",
state.name, worst_count, worst_scanline
),
state.span,
)
.with_help(
"stagger draws vertically by at least 8 pixels, reduce the number of \
on-screen sprites, or split the draws across `on_scanline` handlers",
)
.with_note(
"the 9th and later sprites on a scanline are dropped by the PPU, \
causing flicker or invisible objects on real hardware",
);
let mut labeled: usize = 0;
let mut seen_spans: HashSet<(u16, u32, u32)> = HashSet::new();
for (y, _, span) in &draws {
let top = u16::from(*y);
if top <= worst_scanline && worst_scanline < top + 8 {
// Deduplicate identical spans (metasprite
// expansion produces one tuple per tile but all
// share the original draw-site span).
let key = (span.file_id, span.start, span.end);
if !seen_spans.insert(key) {
continue;
}
diag = diag.with_label(*span, "draws here");
labeled += 1;
if labeled >= 9 {
break;
}
}
}
self.diagnostics.push(diag);
}
}
fn register_const(&mut self, c: &ConstDecl) {
if self.symbols.contains_key(&c.name) {
self.diagnostics.push(Diagnostic::error(
@ -2096,6 +2217,78 @@ fn collect_transitions_stmt(stmt: &Statement, queue: &mut Vec<String>) {
}
}
/// Walk a block and collect `(y, x, span)` tuples for every literal
/// -coordinate draw it contains. Metasprite draws expand to one
/// tuple per tile using the metasprite's `dx`/`dy` offsets; plain
/// sprites contribute exactly one tuple at the literal `(x, y)`.
/// Draws with a non-literal coordinate are skipped — static
/// analysis can't know where they land.
///
/// Recurses through `if`/`while`/`for`/`loop` bodies and counts
/// every branch as if it always fires. This conservatively
/// over-counts programs that stagger draws across mutually
/// exclusive branches, but it matches the worst case the PPU can
/// see on any given frame.
fn collect_literal_draws(
block: &Block,
metasprites: &HashMap<&str, &MetaspriteDecl>,
out: &mut Vec<(u8, u8, Span)>,
) {
for stmt in &block.statements {
collect_literal_draws_stmt(stmt, metasprites, out);
}
}
fn collect_literal_draws_stmt(
stmt: &Statement,
metasprites: &HashMap<&str, &MetaspriteDecl>,
out: &mut Vec<(u8, u8, Span)>,
) {
match stmt {
Statement::Draw(draw) => {
let (Expr::IntLiteral(x, _), Expr::IntLiteral(y, _)) = (&draw.x, &draw.y) else {
return;
};
// Literals that don't fit in u8 would already be caught
// by the type checker; bail out here rather than risk
// double-reporting.
if *x > 255 || *y > 255 {
return;
}
let base_x = *x as u8;
let base_y = *y as u8;
if let Some(ms) = metasprites.get(draw.sprite_name.as_str()) {
// Metasprite: one slot per tile. Share the original
// draw-site span so the diagnostic labels point at
// user-authored source, not invented offsets.
for i in 0..ms.dx.len() {
let tile_x = base_x.wrapping_add(ms.dx[i]);
let tile_y = base_y.wrapping_add(ms.dy[i]);
out.push((tile_y, tile_x, draw.span));
}
} else {
out.push((base_y, base_x, draw.span));
}
}
Statement::If(_, then_b, elifs, else_b, _) => {
collect_literal_draws(then_b, metasprites, out);
for (_, b) in elifs {
collect_literal_draws(b, metasprites, out);
}
if let Some(b) = else_b {
collect_literal_draws(b, metasprites, out);
}
}
Statement::While(_, body, _) | Statement::Loop(body, _) => {
collect_literal_draws(body, metasprites, out);
}
Statement::For { body, .. } => {
collect_literal_draws(body, metasprites, out);
}
_ => {}
}
}
/// Collect all function/call names from a block.
fn collect_calls(block: &Block) -> Vec<String> {
let mut calls = Vec::new();

View file

@ -2200,3 +2200,216 @@ fn analyze_still_rejects_duplicate_local_in_same_function() {
"expected E0501 for duplicate `var i` in same function, got: {errors:?}"
);
}
#[test]
fn analyze_sprite_scanline_budget_warns_over_eight() {
// Nine literal-coord draws all sharing the same `y` stack
// vertically on a single scanline. That blows past the NES's
// 8-sprites-per-scanline budget and must trip W0109.
let (prog, diags) = parser::parse(
r#"
game "T" { mapper: NROM }
state Main {
on frame {
draw Blip at: (10, 100)
draw Blip at: (20, 100)
draw Blip at: (30, 100)
draw Blip at: (40, 100)
draw Blip at: (50, 100)
draw Blip at: (60, 100)
draw Blip at: (70, 100)
draw Blip at: (80, 100)
draw Blip at: (90, 100)
wait_frame
}
}
start Main
"#,
);
assert!(diags.is_empty(), "parse errors: {diags:?}");
let result = analyze(&prog.unwrap());
let w0109: Vec<_> = result
.diagnostics
.iter()
.filter(|d| d.code == ErrorCode::W0109)
.collect();
assert_eq!(
w0109.len(),
1,
"expected exactly one W0109, got: {:?}",
result.diagnostics
);
assert!(
w0109[0].message.contains('9') && w0109[0].message.contains("Main"),
"W0109 message should mention count 9 and state Main, got: {}",
w0109[0].message
);
assert!(
!w0109[0].labels.is_empty(),
"W0109 should label the offending draws"
);
}
#[test]
fn analyze_sprite_scanline_budget_ok_when_staggered() {
// Nine draws, but each one is on its own line. No scanline
// ever sees more than one sprite. Must NOT trip W0109.
let (prog, diags) = parser::parse(
r#"
game "T" { mapper: NROM }
state Main {
on frame {
draw Blip at: (10, 0)
draw Blip at: (10, 16)
draw Blip at: (10, 32)
draw Blip at: (10, 48)
draw Blip at: (10, 64)
draw Blip at: (10, 80)
draw Blip at: (10, 96)
draw Blip at: (10, 112)
draw Blip at: (10, 128)
wait_frame
}
}
start Main
"#,
);
assert!(diags.is_empty(), "parse errors: {diags:?}");
let result = analyze(&prog.unwrap());
assert!(
!result
.diagnostics
.iter()
.any(|d| d.code == ErrorCode::W0109),
"did not expect W0109 for staggered draws, got: {:?}",
result.diagnostics
);
}
#[test]
fn analyze_sprite_scanline_budget_skips_dynamic_coords() {
// Nine draws on the same line, but the x coordinate comes from
// a variable. Static analysis can't know where these land, so
// W0109 must stay silent.
let (prog, diags) = parser::parse(
r#"
game "T" { mapper: NROM }
var px: u8 = 0
state Main {
on frame {
draw Blip at: (px, 100)
draw Blip at: (px, 100)
draw Blip at: (px, 100)
draw Blip at: (px, 100)
draw Blip at: (px, 100)
draw Blip at: (px, 100)
draw Blip at: (px, 100)
draw Blip at: (px, 100)
draw Blip at: (px, 100)
wait_frame
}
}
start Main
"#,
);
assert!(diags.is_empty(), "parse errors: {diags:?}");
let result = analyze(&prog.unwrap());
assert!(
!result
.diagnostics
.iter()
.any(|d| d.code == ErrorCode::W0109),
"did not expect W0109 for dynamic coords, got: {:?}",
result.diagnostics
);
}
#[test]
fn analyze_sprite_scanline_budget_expands_metasprites() {
// A metasprite with four tiles all at `dy = 0` means one
// `draw` statement contributes four sprites to the same
// scanline. Three such draws = 12 overlapping sprites, which
// must trip W0109.
let (prog, diags) = parser::parse(
r#"
game "T" { mapper: NROM }
sprite Tile8 {
pixels: [
"........",
"........",
"........",
"........",
"........",
"........",
"........",
"........"
]
}
metasprite Quad {
sprite: Tile8
dx: [0, 8, 16, 24]
dy: [0, 0, 0, 0]
frame: [0, 0, 0, 0]
}
state Main {
on frame {
draw Quad at: (0, 100)
draw Quad at: (40, 100)
draw Quad at: (80, 100)
wait_frame
}
}
start Main
"#,
);
assert!(diags.is_empty(), "parse errors: {diags:?}");
let result = analyze(&prog.unwrap());
assert!(
result
.diagnostics
.iter()
.any(|d| d.code == ErrorCode::W0109),
"expected W0109 for metasprite overlap, got: {:?}",
result.diagnostics
);
}
#[test]
fn analyze_sprite_scanline_budget_recurses_into_if() {
// Conservative: a branch that always fires when the state
// runs still counts. Nine draws inside an `if` block over the
// same scanline must trip W0109.
let (prog, diags) = parser::parse(
r#"
game "T" { mapper: NROM }
var flag: u8 = 0
state Main {
on frame {
if flag == 1 {
draw Blip at: (10, 100)
draw Blip at: (20, 100)
draw Blip at: (30, 100)
draw Blip at: (40, 100)
draw Blip at: (50, 100)
draw Blip at: (60, 100)
draw Blip at: (70, 100)
draw Blip at: (80, 100)
draw Blip at: (90, 100)
}
wait_frame
}
}
start Main
"#,
);
assert!(diags.is_empty(), "parse errors: {diags:?}");
let result = analyze(&prog.unwrap());
assert!(
result
.diagnostics
.iter()
.any(|d| d.code == ErrorCode::W0109),
"expected W0109 for draws inside if, got: {:?}",
result.diagnostics
);
}

View file

@ -113,6 +113,18 @@ pub struct IrCodeGen<'a> {
/// True while generating code inside a state frame handler.
/// When set, `Return` terminators emit `JMP __ir_main_loop` instead of `RTS`.
in_frame_handler: bool,
/// Scope prefix for the function currently being emitted.
/// Mirrors the analyzer and IR lowerer's
/// `current_scope_prefix` field and is used by
/// `substitute_asm_vars` to resolve `{name}` references in
/// inline asm bodies — user source says `{result}` but the
/// symbol table stores the variable as
/// `__local__times_four__result`, so the resolver has to
/// try the scope-qualified key first before falling back
/// to the bare key for globals. Empty string while
/// emitting runtime / dispatcher code that isn't inside a
/// user function body.
current_fn_scope_prefix: String,
/// When true, emit code for `debug.log` / `debug.assert`.
/// When false, these ops are stripped entirely.
debug_mode: bool,
@ -283,6 +295,7 @@ impl<'a> IrCodeGen<'a> {
state_indices: HashMap::new(),
function_names,
in_frame_handler: false,
current_fn_scope_prefix: String::new(),
debug_mode: false,
audio_used: false,
noise_used: false,
@ -819,6 +832,33 @@ impl<'a> IrCodeGen<'a> {
self.use_counts = build_use_counts(func);
self.in_frame_handler = func.name.ends_with("_frame");
// Set the scope prefix used by `substitute_asm_vars`
// when resolving `{name}` references in inline asm.
// For state handlers (`Title_frame`, `Title_enter`,
// `Title_exit`) the prefix is `Title__frame`/etc —
// matching how the analyzer and IR lowerer scoped
// their locals. For regular user functions it's just
// the function name. See the commentary on
// `current_fn_scope_prefix` above.
self.current_fn_scope_prefix = if let Some(state) = func.name.strip_suffix("_frame") {
format!("{state}__frame")
} else if let Some(state) = func.name.strip_suffix("_enter") {
format!("{state}__enter")
} else if let Some(state) = func.name.strip_suffix("_exit") {
format!("{state}__exit")
} else if let Some(rest) = func.name.strip_prefix("") {
// Scanline handlers encode the line number, but
// the analyzer's prefix is
// `{state}__scanline_{N}` — check the split.
if let Some((state, line)) = rest.rsplit_once("_scanline_") {
format!("{state}__scanline_{line}")
} else {
rest.to_string()
}
} else {
func.name.clone()
};
self.emit_label(&format!("__ir_fn_{}", func.name));
// Prologue: spill the parameter-transport slots $04-$07
@ -885,6 +925,7 @@ impl<'a> IrCodeGen<'a> {
}
self.in_frame_handler = false;
self.current_fn_scope_prefix.clear();
}
fn gen_block(&mut self, block: &IrBasicBlock) {
@ -1273,11 +1314,27 @@ impl<'a> IrCodeGen<'a> {
// `raw asm` block, flagged by the lowering with a
// magic prefix), then parse with the shared inline
// parser and splice the resulting instructions.
//
// The resolver tries the current function's
// scope-qualified key first
// (`__local__{fn}__{name}`) so a reference like
// `{result}` inside a function that declares
// `var result: u8` resolves to the function's
// own local, not to an unrelated global of the
// same name. Globals / state-locals / consts
// still resolve via the bare-name fallback.
let raw = body.strip_prefix(crate::ir::RAW_ASM_PREFIX);
let to_parse: std::borrow::Cow<'_, str> = if let Some(raw_body) = raw {
std::borrow::Cow::Borrowed(raw_body)
} else {
let scope = self.current_fn_scope_prefix.clone();
std::borrow::Cow::Owned(substitute_asm_vars(body, |name| {
if !scope.is_empty() {
let qualified = format!("__local__{scope}__{name}");
if let Some(a) = self.allocations.iter().find(|a| a.name == qualified) {
return Some(a.address);
}
}
self.allocations
.iter()
.find(|a| a.name == name)

View file

@ -44,6 +44,7 @@ pub enum ErrorCode {
W0106, // implicit drop of non-void function return value
W0107, // `fast` variable rarely accessed (wastes zero-page slot)
W0108, // array elements past byte 255 unreachable via 8-bit X index
W0109, // too many literal-coord sprite draws on one scanline (NES 8/scanline limit)
}
impl fmt::Display for ErrorCode {
@ -72,6 +73,7 @@ impl fmt::Display for ErrorCode {
Self::W0106 => "W0106",
Self::W0107 => "W0107",
Self::W0108 => "W0108",
Self::W0109 => "W0109",
};
write!(f, "{code}")
}
@ -87,7 +89,8 @@ impl ErrorCode {
| Self::W0105
| Self::W0106
| Self::W0107
| Self::W0108 => Level::Warning,
| Self::W0108
| Self::W0109 => Level::Warning,
_ => Level::Error,
}
}

View file

@ -34,6 +34,26 @@ struct LoweringContext {
/// function-local vars resolve to the scoped entry the
/// analyzer registered for them. `None` outside of any body.
current_scope_prefix: Option<String>,
/// Captured inline function bodies. Populated by
/// `capture_inline_bodies` before any lowering runs. Each
/// entry is keyed by function name and holds the parameter
/// list plus the shape of the body (see [`InlineBody`]).
/// Call sites targeting a name in this map expand inline:
/// each argument is lowered to a temp, the temps are
/// registered as substitutions for the parameter names,
/// and the body is lowered into the caller's current block
/// in place of a `Call` op. See `try_inline_call_expr` /
/// `try_inline_call_stmt` below and `COMPILER_BUGS.md` §5.
inline_bodies: HashMap<String, CapturedInline>,
/// Substitution stack for nested inline expansions. The top
/// frame is the active substitution map — `Expr::Ident(name)`
/// lookups check it first and, if the name is present, use
/// the stored IR temp directly without emitting any load op.
/// Nested inlines push a fresh frame on entry and pop it on
/// exit so an inline body calling another inline sees the
/// inner function's parameter substitutions, not its
/// caller's.
inline_subs_stack: Vec<HashMap<String, IrTemp>>,
next_var_id: u32,
next_temp: u32,
next_block: u32,
@ -65,6 +85,38 @@ struct LoweringContext {
metasprites: HashMap<String, MetaspriteInfo>,
}
/// A captured `inline fun` body that the lowerer can splice in
/// at each call site. Two flavours are recognised:
///
/// - **Expression**: the function body is exactly
/// `{ return <expr> }`. The return expression can be lowered
/// into either a statement context (result discarded) or an
/// expression context (result used).
/// - **Void**: the function has no return type and its body is
/// a sequence of plain statements (no `return`, no loops, no
/// conditionals). The statements can only be spliced into
/// statement contexts. This is the shape of helpers like
/// `set_phase(p) { phase = p; phase_timer = 0 }`.
///
/// Anything more exotic (early returns inside `if`, loops,
/// nested blocks, recursive inlines, etc.) is not captured and
/// compiles as a regular `JSR` call, with no warning since
/// declining to inline is always a correct fallback.
#[derive(Debug, Clone)]
enum InlineBody {
Expression(Expr),
Void(Vec<Statement>),
}
/// Captured inline function metadata: parameter list plus the
/// shape of the body. See `InlineBody` and
/// `LoweringContext::inline_bodies`.
#[derive(Debug, Clone)]
struct CapturedInline {
params: Vec<Param>,
body: InlineBody,
}
#[derive(Debug, Clone)]
struct MetaspriteInfo {
sprite_name: String,
@ -107,6 +159,8 @@ impl LoweringContext {
const_values: HashMap::new(),
var_types,
current_scope_prefix: None,
inline_bodies: HashMap::new(),
inline_subs_stack: Vec::new(),
next_var_id,
next_temp: 0,
next_block: 0,
@ -159,6 +213,139 @@ impl LoweringContext {
id
}
/// Walk the program and capture every `inline fun` whose
/// body matches one of the shapes the lowerer can splice
/// in at call sites. Two shapes are recognised:
///
/// 1. **Single-return-expression**: the function has a
/// declared return type and its body is exactly
/// `{ return <expr> }`. Lowered as `InlineBody::Expression`
/// — usable in both expression and statement contexts.
/// 2. **Void multi-statement**: the function has no return
/// type and its body is a sequence of plain statements
/// (assigns, calls, draws — no control flow, no
/// `return`). Lowered as `InlineBody::Void` — usable
/// only in statement contexts.
///
/// Anything else (conditional early returns, loops,
/// block-nested `if`s, etc.) is silently declined and the
/// function compiles as a regular `JSR` call. Users who
/// want their `inline fun` inlined can check the
/// `--asm-dump` output; declining is always correct.
fn capture_inline_bodies(&mut self, program: &Program) {
for fun in &program.functions {
if !fun.is_inline {
continue;
}
// Single-return-expression shape.
if fun.return_type.is_some()
&& fun.body.statements.len() == 1
&& matches!(fun.body.statements[0], Statement::Return(Some(_), _))
{
if let Statement::Return(Some(expr), _) = &fun.body.statements[0] {
self.inline_bodies.insert(
fun.name.clone(),
CapturedInline {
params: fun.params.clone(),
body: InlineBody::Expression(expr.clone()),
},
);
continue;
}
}
// Void multi-statement shape: no return type, and
// every body statement must be a shape we know how
// to splice. Only assigns, statement-context calls,
// draws, scroll, set_palette, and load_background
// are accepted — anything with nested control flow
// is too complex to inline without a full CFG
// clone.
if fun.return_type.is_none()
&& !fun.body.statements.is_empty()
&& fun.body.statements.iter().all(is_splicable_void_stmt)
{
self.inline_bodies.insert(
fun.name.clone(),
CapturedInline {
params: fun.params.clone(),
body: InlineBody::Void(fun.body.statements.clone()),
},
);
}
}
}
/// Inline a call to `name` in expression context and
/// return the result temp. Returns `None` if the target
/// isn't in `inline_bodies` or is a void-body inline that
/// can't produce a value.
fn try_inline_call_expr(&mut self, name: &str, args: &[Expr]) -> Option<IrTemp> {
let captured = self.inline_bodies.get(name).cloned()?;
let InlineBody::Expression(return_expr) = &captured.body else {
return None;
};
if captured.params.len() != args.len() {
return None;
}
let arg_temps: Vec<IrTemp> = args.iter().map(|a| self.lower_expr(a)).collect();
let mut frame = HashMap::new();
for (param, temp) in captured.params.iter().zip(arg_temps.iter()) {
frame.insert(param.name.clone(), *temp);
}
self.inline_subs_stack.push(frame);
let result = self.lower_expr(return_expr);
self.inline_subs_stack.pop();
Some(result)
}
/// Inline a call to `name` in statement context. Returns
/// `true` on success (i.e. the body was spliced into
/// `current_ops`), `false` if the target isn't in
/// `inline_bodies`.
///
/// A single-return-expression inline used in statement
/// context lowers the return expression and discards the
/// result — the side effects of argument evaluation still
/// happen, which is what a regular `Statement::Call` would
/// do.
fn try_inline_call_stmt(&mut self, name: &str, args: &[Expr]) -> bool {
let Some(captured) = self.inline_bodies.get(name).cloned() else {
return false;
};
if captured.params.len() != args.len() {
return false;
}
let arg_temps: Vec<IrTemp> = args.iter().map(|a| self.lower_expr(a)).collect();
let mut frame = HashMap::new();
for (param, temp) in captured.params.iter().zip(arg_temps.iter()) {
frame.insert(param.name.clone(), *temp);
}
self.inline_subs_stack.push(frame);
match &captured.body {
InlineBody::Expression(expr) => {
// Evaluate the expression for its side effects;
// discard the result temp.
let _ = self.lower_expr(expr);
}
InlineBody::Void(stmts) => {
for stmt in stmts {
self.lower_statement(stmt);
}
}
}
self.inline_subs_stack.pop();
true
}
/// Look up `name` in the active inline substitution frame,
/// if any. Returns the IR temp previously computed for that
/// parameter (during `try_inline_call_*`'s argument
/// lowering). The top of the stack wins so nested inlines
/// see their own frame.
fn lookup_inline_sub(&self, name: &str) -> Option<IrTemp> {
self.inline_subs_stack.last()?.get(name).copied()
}
/// Recursively expand a struct-literal global initializer into
/// per-leaf-field `IrGlobal` entries. Handles three field-value
/// shapes:
@ -399,6 +586,24 @@ impl LoweringContext {
}
}
// Capture `inline fun` bodies that qualify for real
// inlining. A function qualifies when it's marked
// `inline`, has a declared return type, and its body
// consists of exactly one `Statement::Return(Some(expr))`.
// Call sites targeting one of these functions will be
// expanded in-place in `lower_expr` / `lower_statement`
// instead of emitting a `Call` op — the caller's body
// gets the return expression spliced in with the
// function's parameters substituted for argument temps.
//
// Functions marked `inline` but with more complex bodies
// (multi-statement, void, loops, conditionals) compile
// as regular calls with a W0109 "inline declined"
// warning emitted by the analyzer. This catches users
// who write `inline fun` expecting the keyword to be
// enforced.
self.capture_inline_bodies(program);
// Lower user functions
for fun in &program.functions {
self.lower_function(fun);
@ -763,6 +968,13 @@ impl LoweringContext {
}
}
_ => {
// Inline expansion at statement context
// splices either the return expression
// (discarding its result) or the body
// statements directly into `current_ops`.
if self.try_inline_call_stmt(name, args) {
return;
}
let arg_temps: Vec<_> = args.iter().map(|a| self.lower_expr(a)).collect();
self.emit(IrOp::Call(None, name.clone(), arg_temps));
}
@ -1205,6 +1417,17 @@ impl LoweringContext {
t
}
Expr::Ident(name, _) => {
// When we're inside an inline expansion and this
// name is a parameter of the function currently
// being inlined, return the pre-computed argument
// temp directly instead of emitting a load op.
// That's how substitution actually happens: the
// body expression references the parameter, we
// short-circuit the lookup to the temp the caller
// already evaluated.
if let Some(temp) = self.lookup_inline_sub(name) {
return temp;
}
// Check constants first
if let Some(&val) = self.const_values.get(name) {
let t = self.fresh_temp();
@ -1275,6 +1498,14 @@ impl LoweringContext {
return t;
}
}
// `inline fun` bodies captured by
// `capture_inline_bodies` expand in-place here:
// no JSR, no parameter transport, no prologue.
// The return value is whatever temp the body
// expression lowered to.
if let Some(t) = self.try_inline_call_expr(name, args) {
return t;
}
let arg_temps: Vec<_> = args.iter().map(|a| self.lower_expr(a)).collect();
let t = self.fresh_temp();
self.emit(IrOp::Call(Some(t), name.clone(), arg_temps));
@ -1571,6 +1802,35 @@ impl LoweringContext {
}
}
/// True if `stmt` is simple enough for the inliner to splice
/// into a caller without a CFG rewrite. Accepted shapes: plain
/// assignments, statement-context calls, draws, scroll/set
/// palette / load background, `wait_frame`, inline asm, and the
/// `debug.log` / `debug.assert` builtins. Rejected: any shape with
/// control flow (if/while/loop/for/match/return/break/continue
/// /transition) because those would require cloning basic
/// blocks and renumbering labels per call site, which is
/// more than the simple substitution machinery can handle.
fn is_splicable_void_stmt(stmt: &Statement) -> bool {
matches!(
stmt,
Statement::Assign(..)
| Statement::Call(..)
| Statement::Draw(..)
| Statement::Scroll(..)
| Statement::SetPalette(..)
| Statement::LoadBackground(..)
| Statement::WaitFrame(..)
| Statement::Play(..)
| Statement::StartMusic(..)
| Statement::StopMusic(..)
| Statement::InlineAsm(..)
| Statement::RawAsm(..)
| Statement::DebugLog(..)
| Statement::DebugAssert(..)
)
}
fn type_size(t: &NesType) -> u16 {
match t {
NesType::U8 | NesType::I8 | NesType::Bool => 1,

View file

@ -801,3 +801,157 @@ fn wide_hi_does_not_leak_between_functions() {
"wide CmpEq16 destination aliased a source operand — wide_hi leaked between functions"
);
}
#[test]
fn inline_fun_expression_body_emits_no_call_at_use_site() {
// Regression test for COMPILER_BUGS.md §5: `inline fun`
// with a single-return-expression body should be spliced
// at every call site instead of emitting a Call op. The
// lowered frame handler should contain zero Call ops
// targeting the inline function.
let ir = lower_ok(
r#"
game "Test" { mapper: NROM }
inline fun shift_right_4(c: u8) -> u8 {
return c >> 4
}
var out: u8 = 0
on frame { out = shift_right_4(0x90) }
start Main
"#,
);
let frame_fn = ir
.functions
.iter()
.find(|f| f.name.contains("frame"))
.expect("frame handler should exist");
let any_call_to_inline = frame_fn
.blocks
.iter()
.flat_map(|b| &b.ops)
.any(|op| matches!(op, IrOp::Call(_, name, _) if name == "shift_right_4"));
assert!(
!any_call_to_inline,
"frame handler should not contain a Call to the inlined function; ops: {:?}",
frame_fn
.blocks
.iter()
.flat_map(|b| &b.ops)
.collect::<Vec<_>>()
);
}
#[test]
fn inline_fun_void_body_statements_are_spliced() {
// Void `inline fun` with a multi-statement body (no
// control flow) should be spliced at every statement-
// context call site. `set_phase(P_FLY_A)` should lower
// to two StoreVar ops (phase = P_FLY_A, phase_timer = 0)
// rather than a Call op.
let ir = lower_ok(
r#"
game "Test" { mapper: NROM }
const P_WAIT: u8 = 0
const P_FLY: u8 = 1
var phase: u8 = 0
var phase_timer: u8 = 0
inline fun set_phase(p: u8) {
phase = p
phase_timer = 0
}
on frame { set_phase(P_FLY) }
start Main
"#,
);
let frame_fn = ir
.functions
.iter()
.find(|f| f.name.contains("frame"))
.expect("frame handler should exist");
let any_call_to_inline = frame_fn
.blocks
.iter()
.flat_map(|b| &b.ops)
.any(|op| matches!(op, IrOp::Call(_, name, _) if name == "set_phase"));
assert!(
!any_call_to_inline,
"frame handler should not contain a Call to set_phase; ops: {:?}",
frame_fn
.blocks
.iter()
.flat_map(|b| &b.ops)
.collect::<Vec<_>>()
);
}
#[test]
fn inline_fun_with_conditional_return_compiles_as_regular_call() {
// A conditional early-return body (wrap52-style) is too
// complex for the simple inliner. It should gracefully
// fall back to a regular Call op — this is the intended
// behaviour, not a bug. The important thing is that the
// fallback is correct, not that it's inlined.
let ir = lower_ok(
r#"
game "Test" { mapper: NROM }
inline fun wrap52(v: u8) -> u8 {
if v >= 52 { return v - 52 }
return v
}
var out: u8 = 0
on frame { out = wrap52(60) }
start Main
"#,
);
let frame_fn = ir
.functions
.iter()
.find(|f| f.name.contains("frame"))
.expect("frame handler should exist");
let calls_wrap52 = frame_fn
.blocks
.iter()
.flat_map(|b| &b.ops)
.any(|op| matches!(op, IrOp::Call(_, name, _) if name == "wrap52"));
assert!(
calls_wrap52,
"wrap52 has conditional early return — it should fall back to a Call op"
);
}
#[test]
fn inline_fun_nested_inlines_substitute_correctly() {
// Two inline functions where the outer calls the inner
// using its own parameter. Both should inline; the
// result should have no Call ops in the frame handler
// targeting either function.
let ir = lower_ok(
r#"
game "Test" { mapper: NROM }
inline fun double(x: u8) -> u8 { return x + x }
inline fun quad(x: u8) -> u8 { return double(double(x)) }
var out: u8 = 0
on frame { out = quad(5) }
start Main
"#,
);
let frame_fn = ir
.functions
.iter()
.find(|f| f.name.contains("frame"))
.expect("frame handler should exist");
let any_inline_call = frame_fn
.blocks
.iter()
.flat_map(|b| &b.ops)
.any(|op| matches!(op, IrOp::Call(_, name, _) if name == "double" || name == "quad"));
assert!(
!any_inline_call,
"nested inline calls should both be expanded; frame ops: {:?}",
frame_fn
.blocks
.iter()
.flat_map(|b| &b.ops)
.collect::<Vec<_>>()
);
}