mirror of
https://github.com/imjasonh/nescript
synced 2026-07-09 01:16:12 +00:00
codegen: reuse analyzer's local allocations so inline asm {param} works
Fixes compiler-bugs.md #1 — the inline-asm `{name}` resolver looks parameters up in the analyzer's `VarAllocation` table (because that's the only address map it has), but `IrCodeGen::new` was minting a parallel `$0300+` range for every function-local and ignoring what the analyzer had picked. The spill prologue wrote the param to the codegen's private address, the inline asm read from the analyzer's zero-page address, and nothing ever bridged the two — `LDA {param}` would silently load whatever the RAM clear left at the stale slot (always `0`). Fix: drop the `local_ram_next` loop and just look each local up in `allocations` by the analyzer's qualified name (`__local__{scope}__{local}`). The scope string that `gen_function` already computed for `substitute_asm_vars` is now shared with the new address-seeding loop via a `scope_prefix_for_fn(&str)` helper, so the two call sites can't drift. The analyzer's layout already satisfies the "no overlapping live locals" invariant the codegen was relying on — it scopes every local under `__local__<scope>__<name>` so two functions with a parameter named `x` land in different slots. Updated `gen_function_prologue_spills_params_to_local_ram`: the regression test for the War-era param clobbering bug was asserting the spill's destination specifically had to be an absolute address at `$0300+`. That's no longer the mechanism — the spill lands in whatever slot the analyzer assigned, which is zero page when there's room. The test now asserts the destination is *any* address outside `$04-$07`, which is the actual invariant. Reverted the `LDX $04` / `LDY $05` workaround in `examples/sha256/sha_core.ne` — every primitive there now uses `{dst}` / `{src}` / `{w_ofs}` / `{h_ofs}` / `{k_ofs}` substitution as originally intended. The "Parameter convention" comment that documented the workaround is gone. Regenerated `tests/emulator/goldens/inline_asm_demo.png`: that example's `times_four(input)` was previously returning `input` verbatim because the inline asm's `LDA {result}` / `ASL A` / `ASL A` / `STA {result}` operated on a zero-page byte that was disconnected from the NEScript-level `result` variable. With the fix, `times_four` correctly returns `input * 4`, so the smiley-tracker's frame-180 position shifts by the expected `(frame_count * 4) mod 256` delta. The other 33 ROMs remain byte-identical. Verified: - `cargo clippy --all-targets -- -D warnings` clean on both rustc 1.94.1 and 1.95.0. - `cargo test --all-targets`: 616 + 3 + 75 tests pass. - `cargo fmt --check` clean. - Full emulator harness: 34/34 ROMs match goldens. - SHA-256 of "NES" still computes to `AE9145DB5CABC41FE34B54E34AF8881F462362EA20FD8F861B26532FFBB84E0D`. - `--memory-map` output now reflects what the generated code actually reads and writes (previously the codegen's $0300+ override was invisible to the dump). https://claude.ai/code/session_01FRmSBruVWCufm3LsUVMs8v
This commit is contained in:
parent
f128170abf
commit
76d0fd0d28
6 changed files with 179 additions and 191 deletions
|
|
@ -230,51 +230,34 @@ impl<'a> IrCodeGen<'a> {
|
|||
}
|
||||
}
|
||||
// Map every function-local — parameters AND body-declared
|
||||
// vars — into a dedicated RAM slot at `$0300+`. Parameters
|
||||
// are still passed via the zero-page transport slots
|
||||
// vars — into the slot the analyzer already reserved for it.
|
||||
// Parameters arrive via the zero-page transport slots
|
||||
// `$04-$07` as the calling convention, but `gen_function`
|
||||
// emits a prologue at function entry that copies those
|
||||
// transport slots into these per-function RAM slots. That
|
||||
// way, when a function makes a nested call, the nested
|
||||
// call clobbers `$04-$07` (writing its own arguments into
|
||||
// them) without disturbing the caller's saved parameters.
|
||||
// transport slots into the analyzer's per-function slot so
|
||||
// nested calls don't step on the caller's parameters.
|
||||
//
|
||||
// Before this change, parameters lived in `$04-$07` for the
|
||||
// duration of the function body, so any call nested inside
|
||||
// a function's body silently corrupted the caller's
|
||||
// parameters (fixed on the War bug-cleanup branch; see
|
||||
// `git log` for the original reproduction and root cause).
|
||||
// The per-function RAM slots + prologue spill fix that
|
||||
// class of bug at the cost of 4 LDA/STA pairs per function
|
||||
// entry.
|
||||
// NEScript forbids recursion (E0402) and caps call depth
|
||||
// (E0401), so the analyzer's single-slot-per-local layout
|
||||
// can't alias even though two functions may be active on
|
||||
// the 6502 stack at once.
|
||||
//
|
||||
// Locals are laid out linearly across every function:
|
||||
// NEScript forbids recursion (E0402) and enforces a
|
||||
// bounded call depth (E0401), so lifetime overlap between
|
||||
// functions is fine and we don't need to pack them.
|
||||
let mut local_ram_next: u16 = 0x0300;
|
||||
// Advance past any RAM global so locals don't clobber them.
|
||||
// Each global occupies `[address, address + size)` — for an
|
||||
// array global at $0308 with size=4 that's $0308..$030C. We
|
||||
// must advance past the END, not the base, otherwise
|
||||
// subsequent locals overlap with the tail of the array.
|
||||
// Globals are looked up by name against the analyzer's
|
||||
// `allocations` (which has per-global sizes) rather than the
|
||||
// `var_addrs` map, which only stores base addresses.
|
||||
let max_ram_global_end = allocations
|
||||
.iter()
|
||||
.filter(|a| a.address >= 0x0100)
|
||||
.map(|a| a.address + a.size.max(1))
|
||||
.max()
|
||||
.unwrap_or(0);
|
||||
if max_ram_global_end > local_ram_next {
|
||||
local_ram_next = max_ram_global_end;
|
||||
}
|
||||
// Using the analyzer's addresses here (instead of minting a
|
||||
// fresh linear `$0300+` range) is critical for inline-asm
|
||||
// `{name}` substitution: `substitute_asm_vars` resolves
|
||||
// `{param}` against `allocations` (= the analyzer's table),
|
||||
// so the codegen has to agree with the analyzer on each
|
||||
// local's address or `LDA {param}` inside `asm { ... }`
|
||||
// would read a slot nothing ever writes to. See
|
||||
// `compiler-bugs.md` entry #1 for the full diagnosis.
|
||||
for func in &ir.functions {
|
||||
let scope = scope_prefix_for_fn(&func.name);
|
||||
for local in &func.locals {
|
||||
var_addrs.insert(local.var_id, local_ram_next);
|
||||
var_sizes.insert(local.var_id, local.size);
|
||||
local_ram_next += local.size.max(1);
|
||||
let qualified = format!("__local__{scope}__{}", local.name);
|
||||
if let Some(alloc) = allocations.iter().find(|a| a.name == qualified) {
|
||||
var_addrs.insert(local.var_id, alloc.address);
|
||||
var_sizes.insert(local.var_id, alloc.size);
|
||||
}
|
||||
}
|
||||
}
|
||||
let function_names = ir.functions.iter().map(|f| f.name.clone()).collect();
|
||||
|
|
@ -852,24 +835,7 @@ impl<'a> IrCodeGen<'a> {
|
|||
// 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.current_fn_scope_prefix = scope_prefix_for_fn(&func.name);
|
||||
|
||||
self.emit_label(&format!("__ir_fn_{}", func.name));
|
||||
|
||||
|
|
@ -2151,6 +2117,33 @@ enum Cmp16Kind {
|
|||
GtEq,
|
||||
}
|
||||
|
||||
/// Map an IR function name to the analyzer's scope prefix for its
|
||||
/// locals. The analyzer registers every function-local under
|
||||
/// `__local__{prefix}__{name}` — state handlers use
|
||||
/// `{state}__{frame|enter|exit}` or `{state}__scanline_{line}`,
|
||||
/// regular functions use the bare function name. Both
|
||||
/// `IrCodeGen::new` (when seeding `var_addrs`) and `gen_function`
|
||||
/// (when setting `current_fn_scope_prefix` for inline-asm
|
||||
/// substitution) have to agree on the string used here, or
|
||||
/// `{param}` references would resolve to a different address than
|
||||
/// the one generated code reads and writes.
|
||||
fn scope_prefix_for_fn(name: &str) -> String {
|
||||
if let Some(state) = name.strip_suffix("_frame") {
|
||||
format!("{state}__frame")
|
||||
} else if let Some(state) = name.strip_suffix("_enter") {
|
||||
format!("{state}__enter")
|
||||
} else if let Some(state) = name.strip_suffix("_exit") {
|
||||
format!("{state}__exit")
|
||||
} else if let Some((state, line)) = name.rsplit_once("_scanline_") {
|
||||
// Scanline handlers encode the line number in the
|
||||
// function name; the analyzer's prefix joins them with
|
||||
// a double underscore.
|
||||
format!("{state}__scanline_{line}")
|
||||
} else {
|
||||
name.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Replace `{name}` tokens in an inline-asm body with the resolved
|
||||
/// hex address from the given resolver. Unknown names and malformed
|
||||
/// placeholders are passed through unchanged (the asm parser will
|
||||
|
|
@ -3916,9 +3909,21 @@ fn gen_function_prologue_spills_params_to_local_ram() {
|
|||
// own arguments, silently corrupting the caller's params.
|
||||
//
|
||||
// Compile a function that takes `x: u8`, calls `helper(x)`,
|
||||
// then uses `x` again. Verify the callee reads `x` from a
|
||||
// RAM slot (absolute addressing at $0300+) rather than
|
||||
// directly from `$04`.
|
||||
// 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.
|
||||
//
|
||||
// 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".
|
||||
use crate::parser;
|
||||
let src = r#"
|
||||
game "Test" { mapper: NROM }
|
||||
|
|
@ -3943,14 +3948,17 @@ fn gen_function_prologue_spills_params_to_local_ram() {
|
|||
let mut codegen = IrCodeGen::new(&analysis.var_allocations, &ir);
|
||||
let insts = codegen.generate(&ir);
|
||||
|
||||
// Find the __ir_fn_caller label. Immediately after it, look
|
||||
// for the spill pattern: `LDA $04 / STA <absolute $0300+>`.
|
||||
// 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.
|
||||
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_abs = 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" {
|
||||
|
|
@ -3959,20 +3967,25 @@ fn gen_function_prologue_spills_params_to_local_ram() {
|
|||
}
|
||||
if inst.opcode == LDA && inst.mode == AM::ZeroPage(0x04) {
|
||||
saw_lda_zp4 = true;
|
||||
continue;
|
||||
}
|
||||
if saw_lda_zp4 && inst.opcode == STA {
|
||||
if let AM::Absolute(a) = inst.mode {
|
||||
if a >= 0x0300 {
|
||||
saw_sta_abs = true;
|
||||
break;
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
saw_lda_zp4 && saw_sta_abs,
|
||||
"caller function should open with `LDA $04 / STA <absolute>` \
|
||||
as the param-spill prologue — the param-clobbering fix is \
|
||||
not in effect"
|
||||
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"
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue