1
0
Fork 0
mirror of https://github.com/imjasonh/nescript synced 2026-07-08 08:55:38 +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:
Claude 2026-04-16 16:03:10 +00:00
parent f128170abf
commit 76d0fd0d28
No known key found for this signature in database
6 changed files with 179 additions and 191 deletions

View file

@ -35,10 +35,13 @@
## #1 — inline `asm { {param} }` resolves to an address nothing writes to
**Status**: WORKED-AROUND (every SHA-256 primitive in
`examples/sha256/sha_core.ne` reads parameters straight out of the
caller's `$04`/`$05` transport slots instead of using `{dst}` /
`{src}`)
**Status**: FIXED in `src/codegen/ir_codegen.rs::IrCodeGen::new`
(the codegen now reads each function-local's address out of the
analyzer's `VarAllocation` table instead of minting its own
parallel `$0300+` range). The workaround in
`examples/sha256/sha_core.ne` — reading parameters directly from
the `$04`/`$05` transport slots — has been reverted in the same
commit.
**Phase**: codegen (prologue spill vs. inline-asm resolver
disagree on local addresses)
**Surfaced in**: `examples/sha256/sha_core.ne` — the 20-odd
@ -206,96 +209,75 @@ So `times_four(x)` actually returns `x`, not `x * 4`. The
committed golden for that example reflects the bug rather than
the intended `×4` behaviour.
### Workaround (applied in `examples/sha256/`)
### How it was fixed
Every primitive in `sha_core.ne` reads its parameters straight
out of the transport slots `$04` / `$05` with the raw literal:
```ne
fun cp_wk(dst: u8, src: u8) {
asm {
LDX $04 ; == dst on entry
LDY $05 ; == src on entry
LDA {wk},Y
STA {wk},X
; ... 3 more 4-byte iterations ...
}
}
```
This works because:
1. The analyzer's function prologue at the AST level doesn't do
anything with the inline-asm block's contents — it's a raw
text token.
2. The codegen's spill prologue copies `$04`/`$05` → the codegen
local but **leaves the originals alone**. So the transport
slots still hold the argument when the first instruction of
the asm block executes.
3. None of the primitives `JSR` from inside the `asm { ... }`
block, so nothing else re-enters the function's body (or any
other function) while the inline block is running, which
would re-populate `$04`/`$05` with different arguments.
The file has a big comment (`── Parameter convention ──`)
explaining exactly this. Every primitive in that file starts
with `LDX $04` (and if it has two params, `LDY $05`) instead of
`LDX {dst}` / `LDY {src}`.
### Once the compiler is fixed
Revert every `LDX $04` / `LDY $05` in `examples/sha256/sha_core.ne`
back to `LDX {dst}` / `LDY {src}` / `LDX {h_ofs}` / …, and delete
the "Parameter convention" comment. Also consider whether
`examples/inline_asm_demo.ne` should be updated so `times_four`
actually produces the documented `×4`, and regenerate
`tests/emulator/goldens/inline_asm_demo.png` in the same commit —
the current golden encodes the buggy behaviour.
### Guess at the fix
Two equivalent options, each about 10 lines of code:
**(a) Make the codegen use the analyzer's allocation for
locals.** Drop the `local_ram_next` loop at the top of
`Emitter::new` and, instead of minting new addresses, look up
each local's analyzer key and copy its address into
`var_addrs`:
Option (a) from the original writeup: `IrCodeGen::new` now looks
each function-local's address up in the analyzer's
`VarAllocation` table instead of minting a parallel `$0300+`
range. The codegen and the inline-asm resolver consequently
agree on every local's address, so `{dst}` / `{src}` / … inside
`asm { ... }` blocks resolve to the same slot the NEScript-level
code reads and writes.
```rust
// Was:
let mut local_ram_next: u16 = 0x0300;
// ...
for func in &ir.functions {
for local in &func.locals {
let qualified = /* __local__<scope>__<local.name> */;
if let Some(a) = allocations.iter().find(|a| a.name == qualified) {
var_addrs.insert(local.var_id, a.address);
var_sizes.insert(local.var_id, a.size);
var_addrs.insert(local.var_id, local_ram_next);
var_sizes.insert(local.var_id, local.size);
local_ram_next += local.size.max(1);
}
}
// Is now:
for func in &ir.functions {
let scope = scope_prefix_for_fn(&func.name);
for local in &func.locals {
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);
}
}
}
```
The analyzer already picks slots that are stable across
functions (the `__local__fn__name` prefix avoids collisions and
it allocates from zero page first, which is faster anyway), so
the codegen's "grow linearly from $0300" policy isn't actually
buying anything — and the comment in `ir_codegen.rs` explaining
why it's safe to stack locals was already relying on the same
"no recursion, bounded call depth" guarantees the analyzer
enforces. The analyzer's allocations already satisfy them.
The same commit:
**(b) Make `substitute_asm_vars` use the codegen's
`var_addrs`.** Pass `self.var_addrs` (plus the VarId map) into
the resolver instead of `self.allocations`. Same effect — both
maps agree after this — and arguably more local to the bug. The
analyzer's allocations stay as they are.
- factors the "function name → analyzer scope prefix" mapping
(`_frame` / `_enter` / `_exit` / `_scanline_N` / bare name)
into a `scope_prefix_for_fn(&str) -> String` helper and
reuses it in `gen_function` so the two sites can't drift;
- updates `gen_function_prologue_spills_params_to_local_ram`
(the regression test originally guarding the War-era param
clobbering bug) to assert the spill's destination is *any*
address outside `$04-$07`, not specifically `$0300+`. The
invariant that matters is "separate from the transport slots",
which holds for the analyzer's zero-page allocations too;
- reverts the `LDX $04` / `LDY $05` workaround across every
primitive in `examples/sha256/sha_core.ne` back to the
intended `LDX {dst}` / `LDY {src}` substitution form, and
drops the "Parameter convention" note from the top of the
file;
- regenerates `tests/emulator/goldens/inline_asm_demo.png`:
that example's `times_four` previously returned its input
verbatim (the inline asm operated on an unrelated zero-page
byte that was always `0`), so the golden's smiley position
drifted by exactly the expected `x * 4 mod 256` delta at
frame 180.
Preferred: (a) — it deletes code instead of rerouting it, and
it makes the memory map dumped by `--memory-map` truthful again
(the codegen's override was invisible to `--memory-map`, which
is why the discrepancy above looks puzzling without this writeup).
Verified after the fix:
Once either change is in, re-run the full emulator harness. The
`inline_asm_demo` and `sha256` goldens will need fresh captures
because both change observable output.
- `cargo test --all-targets` — 616 + 3 + 75 tests pass on
both rustc 1.94.1 and 1.95.0.
- `cargo clippy --all-targets -- -D warnings` clean on both.
- Full emulator harness — 34/34 ROMs match their goldens
(only `inline_asm_demo.png` changed, and the new capture
reflects the corrected `×4` behaviour).
- The SHA-256 example still computes `AE9145DB…4E0D` for the
auto-demo input `"NES"`, matching `shasum` byte-for-byte,
with the inline-asm-pretty `{dst}` / `{src}` primitives.
---

Binary file not shown.

Binary file not shown.

View file

@ -21,28 +21,21 @@
//
// K[i] and H_INIT[i] live in RAM as `var` arrays loaded from
// the init_array initialiser at reset time (see constants.ne).
//
// ── Parameter convention ────────────────────────────────────
//
// NEScript passes the first two function parameters via
// zero-page slots $04 and $05 before the JSR. The compiler's
// standard prologue immediately spills those slots into a
// per-function local in high RAM so nested calls don't step on
// them — but the inline-asm `{name}` resolver looks parameters
// up in the analyzer's allocation table, which doesn't see the
// codegen's spill. Rather than double-copy through a global,
// every primitive below reads its parameters straight out of
// the transport slots with `LDX $04` / `LDY $05`. Our
// primitives never JSR from inside the `asm` block, so the
// transport slots are still live when we read them.
// ── 32-bit byte primitives ──────────────────────────────────
//
// Every primitive reads its destination and source offsets
// via `{dst}` / `{src}` / `{w_ofs}` / … substitutions, which
// resolve to the analyzer's per-function local slots. The
// codegen's function prologue spills the `$04`/`$05` transport
// slots into those same addresses on entry, so the values are
// already live by the time the asm block runs.
// wk[dst..dst+4] = wk[src..src+4]
fun cp_wk(dst: u8, src: u8) {
asm {
LDX $04
LDY $05
LDX {dst}
LDY {src}
LDA {wk},Y
STA {wk},X
INX
@ -63,8 +56,8 @@ fun cp_wk(dst: u8, src: u8) {
// wk[dst..dst+4] ^= wk[src..src+4]
fun xor_wk(dst: u8, src: u8) {
asm {
LDX $04
LDY $05
LDX {dst}
LDY {src}
LDA {wk},X
EOR {wk},Y
STA {wk},X
@ -89,8 +82,8 @@ fun xor_wk(dst: u8, src: u8) {
// wk[dst..dst+4] &= wk[src..src+4]
fun and_wk(dst: u8, src: u8) {
asm {
LDX $04
LDY $05
LDX {dst}
LDY {src}
LDA {wk},X
AND {wk},Y
STA {wk},X
@ -115,8 +108,8 @@ fun and_wk(dst: u8, src: u8) {
// wk[dst..dst+4] += wk[src..src+4] (chained ADC for carry)
fun add_wk(dst: u8, src: u8) {
asm {
LDX $04
LDY $05
LDX {dst}
LDY {src}
CLC
LDA {wk},X
ADC {wk},Y
@ -142,7 +135,7 @@ fun add_wk(dst: u8, src: u8) {
// wk[dst..dst+4] = ~wk[dst..dst+4] (bitwise NOT, in place)
fun not_wk(dst: u8) {
asm {
LDX $04
LDX {dst}
LDA {wk},X
EOR #$FF
STA {wk},X
@ -170,7 +163,7 @@ fun not_wk(dst: u8) {
// previous byte's bit 0 into the next byte's bit 7.
fun rotr1_wk(dst: u8) {
asm {
LDX $04
LDX {dst}
LDA {wk},X
LSR A
INX
@ -191,7 +184,7 @@ fun rotr1_wk(dst: u8) {
// new[2] = old[3], new[3] = old[0]
fun byte_rotr_wk(dst: u8) {
asm {
LDX $04
LDX {dst}
LDY {wk},X
INX
LDA {wk},X
@ -233,7 +226,7 @@ fun rotr_wk(dst: u8, n: u8) {
// becomes 0).
fun shr1_wk(dst: u8) {
asm {
LDX $04
LDX {dst}
INX
INX
INX
@ -251,7 +244,7 @@ fun shr1_wk(dst: u8) {
// byte becomes 0.
fun byte_shr_wk(dst: u8) {
asm {
LDX $04
LDX {dst}
INX
LDA {wk},X
DEX
@ -290,8 +283,8 @@ fun shr_wk(dst: u8, n: u8) {
// wk[dst..dst+4] = w[w_ofs..w_ofs+4]
fun cp_w_to_wk(dst: u8, w_ofs: u8) {
asm {
LDX $04
LDY $05
LDX {dst}
LDY {w_ofs}
LDA {w},Y
STA {wk},X
INX
@ -312,8 +305,8 @@ fun cp_w_to_wk(dst: u8, w_ofs: u8) {
// wk[dst..dst+4] += w[w_ofs..w_ofs+4]
fun add_w_to_wk(dst: u8, w_ofs: u8) {
asm {
LDX $04
LDY $05
LDX {dst}
LDY {w_ofs}
CLC
LDA {wk},X
ADC {w},Y
@ -339,8 +332,8 @@ fun add_w_to_wk(dst: u8, w_ofs: u8) {
// w[w_ofs..w_ofs+4] = wk[src..src+4]
fun cp_wk_to_w(w_ofs: u8, src: u8) {
asm {
LDX $05
LDY $04
LDX {src}
LDY {w_ofs}
LDA {wk},X
STA {w},Y
INX
@ -361,8 +354,8 @@ fun cp_wk_to_w(w_ofs: u8, src: u8) {
// h_state[h_ofs..h_ofs+4] += wk[src..src+4]
fun add_wk_to_h(h_ofs: u8, src: u8) {
asm {
LDX $04
LDY $05
LDX {h_ofs}
LDY {src}
CLC
LDA {h_state},X
ADC {wk},Y
@ -388,8 +381,8 @@ fun add_wk_to_h(h_ofs: u8, src: u8) {
// wk[dst..dst+4] += _K_BYTES[k_ofs..k_ofs+4]
fun add_k_to_wk(dst: u8, k_ofs: u8) {
asm {
LDX $04
LDY $05
LDX {dst}
LDY {k_ofs}
CLC
LDA {wk},X
ADC {_K_BYTES},Y

View file

@ -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"
);
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 846 B

After

Width:  |  Height:  |  Size: 846 B

Before After
Before After