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

ir: substitute inline-asm {param} at splice time for constant args

`inline fun` worked for plain-NEScript bodies but blew up on any
function that used inline asm with `{name}` substitution. The
codegen's `substitute_asm_vars` runs against the analyzer's
per-function scope; after splicing, the scope is the *caller*,
where the inlined fun's parameters don't exist. The result was
`{dst}` left as a literal token in the spliced asm body, and
the asm parser failing with `bad number `{dst}``.

Fix: at inline-expansion time (`try_inline_call_stmt`), when the
splicer detects a `Statement::InlineAsm` in the body and the
call site passed a compile-time constant for a parameter,
pre-substitute `{param}` with `#$<value>` so the spliced body
parses as an immediate-mode operand. The substitution is done
via a new `inline_const_args_stack` parallel to the existing
`inline_subs_stack`, populated from the args' `eval_const`
results.

When *any* arg is non-constant the splicer refuses to inline an
asm-containing function and falls back to a regular `Call` op —
preserving correctness, just at the cost of the JSR/RTS apparatus
the user was hoping to avoid. The fallback is exercised by the
new `inline_fun_with_asm_falls_back_for_runtime_arg` test.

`eval_const` is also extended to consult the same const-args
stack, so a nested inline like `inline_outer(K)` →
`inner(outer_param)` correctly recognises `outer_param` as the
constant `K` and recurses the substitution. Without this, the
cascade stopped at the first level.

Two new tests in `src/ir/tests.rs` lock in the behaviour:
  - `inline_fun_with_asm_param_substitutes_immediate` — verifies
    `{param}` becomes `#$<value>` in the spliced body and no
    Call op is left.
  - `inline_fun_with_asm_falls_back_for_runtime_arg` — verifies
    the fallback path emits a Call op.

The SHA-256 example doesn't itself opt into the new feature for
its primitives (full inlining would balloon ROM by 5-10 KB —
the call sites add up fast at 1500+ source-level uses); that's
left as a future opportunity. Hash output unchanged; emulator
harness 34/34; reproducibility diff clean.

https://claude.ai/code/session_01FRmSBruVWCufm3LsUVMs8v
This commit is contained in:
Claude 2026-04-16 17:28:40 +00:00
parent f2623cb62b
commit 4afd196d1e
No known key found for this signature in database
2 changed files with 218 additions and 3 deletions

View file

@ -921,6 +921,99 @@ fn inline_fun_with_conditional_return_compiles_as_regular_call() {
);
}
#[test]
fn inline_fun_with_asm_param_substitutes_immediate() {
// An `inline fun` whose body contains an `asm { ... }` block
// referencing parameters via `{name}` substitution. When the
// call site passes compile-time constants, the splicer
// pre-substitutes each `{param}` with its `#$<value>`
// immediate so the spliced body parses correctly. The end
// result should contain no Call op for the inlined fun, and
// the frame handler's instruction stream (after lowering) is
// expected to contain an `LDA #$2A` (= 42) — the substituted
// immediate.
let ir = lower_ok(
r#"
game "Test" { mapper: NROM }
var sink: u8 = 0
inline fun stash(value: u8) {
asm {
LDA {value}
STA {sink}
}
}
on frame { stash(42) }
start Main
"#,
);
let frame_fn = ir
.functions
.iter()
.find(|f| f.name.contains("frame"))
.expect("frame handler should exist");
let any_call = frame_fn
.blocks
.iter()
.flat_map(|b| &b.ops)
.any(|op| matches!(op, IrOp::Call(_, name, _) if name == "stash"));
assert!(!any_call, "stash should be inlined, no Call op left");
let asm_body = frame_fn
.blocks
.iter()
.flat_map(|b| &b.ops)
.find_map(|op| match op {
IrOp::InlineAsm(body) => Some(body.clone()),
_ => None,
})
.expect("inlined asm block should be present");
assert!(
asm_body.contains("#$2A"),
"asm body should contain the substituted immediate `#$2A`; got: {asm_body}"
);
assert!(
!asm_body.contains("{value}"),
"`{{value}}` should have been pre-substituted; got: {asm_body}"
);
}
#[test]
fn inline_fun_with_asm_falls_back_for_runtime_arg() {
// When the call site passes a runtime value (a variable, not
// a literal), there's no way to synthesize an immediate at
// expansion time. The splicer should refuse to inline and
// emit a regular Call instead — preserving correctness over
// performance.
let ir = lower_ok(
r#"
game "Test" { mapper: NROM }
var sink: u8 = 0
var src: u8 = 7
inline fun stash(value: u8) {
asm {
LDA {value}
STA {sink}
}
}
on frame { stash(src) }
start Main
"#,
);
let frame_fn = ir
.functions
.iter()
.find(|f| f.name.contains("frame"))
.expect("frame handler should exist");
let any_call = frame_fn
.blocks
.iter()
.flat_map(|b| &b.ops)
.any(|op| matches!(op, IrOp::Call(_, name, _) if name == "stash"));
assert!(
any_call,
"stash should fall back to a Call op when arg is runtime"
);
}
#[test]
fn inline_fun_nested_inlines_substitute_correctly() {
// Two inline functions where the outer calls the inner