1
0
Fork 0
mirror of https://github.com/imjasonh/nescript synced 2026-07-08 17:06:04 +00:00
nescript/compiler-bugs.md
Claude 5976b74b2f
compiler-bugs.md: document inline-asm {param} resolution bug
Full writeup of the codegen bug found while building the SHA-256
example: function parameters (and function-local `var`s)
referenced inside an `asm { ... }` block resolve to the
analyzer's zero-page allocation, but the codegen's prologue
spills the parameter transport slots ($04-$07) to a completely
different per-function RAM slot. Nothing copies between the two,
so `LDA {param}` always reads a stale zero-page byte.

The entry includes a minimal reproducer, a walk-through of the
two disagreeing address maps (analyzer's `var_allocations` vs.
codegen's `var_addrs` override in `Emitter::new`), the exact
workaround the SHA-256 primitives use (`LDX $04` / `LDY $05`
directly instead of `{dst}` / `{src}`), notes on the
inline_asm_demo golden silently encoding the bug
(`times_four(x)` returns `x`, not `x*4`), and a guess at the
fix (delete the codegen's override so both maps agree on the
analyzer's zero-page allocation).

https://claude.ai/code/session_01FRmSBruVWCufm3LsUVMs8v
2026-04-16 15:04:15 +00:00

10 KiB
Raw Blame History

// compiler-bugs.md — a running log of compiler issues surfaced // while implementing the Pong example (examples/pong.ne et al). // // Format, one entry per bug: // // ## #N — one-line title // // Status: OPEN / WORKED-AROUND / FIXED // Phase: lexer / parser / analyzer / ir / optimizer / codegen / linker / runtime / asset // Surfaced in: examples/pong/.ne (brief context) // // ### Reproducer // ne // ... minimal .ne snippet that triggers the bad behaviour ... // // // ### Expected vs actual // What the user-visible behaviour should be; what the compiler actually does. // // ### Workaround (if applied) // The current shape of the code in examples/pong/ that avoids the bug, // and exactly what should be reverted once the fix lands. Every workaround // in examples/pong/ MUST be tagged with // BUG: compiler-bugs.md #N so // grep -r "BUG: compiler-bugs.md" finds every reverible workaround in one pass. // // ### Guess at the fix // Which source file(s) and what kind of change is likely needed. Doesn't // have to be right — it's a hint for the compiler-bug cleanup milestone. // // ---

(no bugs logged yet — pong development just started)


#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}) Phase: codegen (prologue spill vs. inline-asm resolver disagree on local addresses) Surfaced in: examples/sha256/sha_core.ne — the 20-odd 32-bit byte primitives (cp_wk, xor_wk, add_wk, rotr1_wk, add_wk_to_h, add_k_to_wk, …) all pass dst / src / w_ofs / h_ofs / k_ofs as parameters and want to use them inside LDX {dst} / LDY {src} / LDA {wk},X.

Reproducer

game "Param Bug" { mapper: NROM }

var sink: u8 = 0

fun echo(value: u8) {
    asm {
        LDA {value}
        STA {sink}
    }
}

on frame {
    echo(0x42)
    if sink == 0x42 {
        draw Smiley at: (120, 120)     // should draw — doesn't
    }
}

start Main

sink is 0x00 every frame no matter what echo is called with. {value} resolves to a zero-page slot that nothing in the generated program ever writes to.

Expected vs actual

Expected — the asm { LDA {value} } inside echo should load the caller's argument. sink should become 0x42 after echo(0x42) runs.

Actual — the function prologue reads $04 (the parameter transport slot) and spills it to one absolute address; the inline {value} substitution resolves value to a different zero-page address; nothing ever writes the spilled value to that zero-page slot, so LDA {value} always loads whatever the RAM clear left there (0x00).

A minimal --asm-dump shows the disagreement directly. For a fun cp_wk(dst: u8, src: u8) { asm { LDX {dst}; ... } }:

__ir_fn_cp_wk:
    LDA ZeroPage(4)
    STA Absolute(1464)       ; $05B8 — codegen's address for `dst`
    LDA ZeroPage(5)
    STA Absolute(1465)       ; $05B9 — codegen's address for `src`
__ir_blk_fn_cp_wk_entry_1:
    LDX ZeroPage(39)         ; $27   — analyzer's address for `dst`
    LDY ZeroPage(40)         ; $28   — analyzer's address for `src`
    LDA AbsoluteY(1360)      ; wk,Y
    STA AbsoluteX(1360)
    ...

$05B8 / $05B9 are the codegen's spill destinations for the function's locals. $27 / $28 are the analyzer's allocations for the same two parameter names. Nothing copies $05B8$27, so the LDX ZeroPage(39) above always reads 0.

--memory-map confirms the analyzer thinks the parameters live in zero page:

$0027    [USER]    __local__cp_wk__dst (u8)
$0028    [USER]    __local__cp_wk__src (u8)

while --asm-dump shows the codegen's prologue writing them to $05B8 / $05B9.

Root cause

Two independently-populated address maps disagree on where every function-local lives:

  • src/analyzer/mod.rs::register_const (for const decls) and the equivalent path for function parameters call allocate_ram(size, span), which allocates from zero page and pushes a VarAllocation { name: "__local__cp_wk__dst", address: 0x0027, size: 1 } onto self.var_allocations. This is the table substitute_asm_vars consults to resolve {name} inside asm { ... } blocks.

  • src/codegen/ir_codegen.rs::Emitter::new (around line 255) overwrites every local's address in its own var_addrs map:

    let mut local_ram_next: u16 = 0x0300;
    // ... (skip past globals) ...
    for func in &ir.functions {
        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);
        }
    }
    

    local_ram_next grows linearly from 0x0300 upward, past every other local in every other function. NEScript code generated afterwards — assignments, reads, arithmetic, the function's parameter spill prologue at gen_function — all consult var_addrs and therefore use the $05B8-ish codegen address.

    The comment on that block explains that the override is deliberate (so nested calls don't trash the caller's params when they overwrite $04-$07), but it stops tracking the analyzer's allocation entirely, so anyone else who still uses the analyzer's allocations (= the inline-asm resolver) sees a stale address.

  • src/codegen/ir_codegen.rs::substitute_asm_vars (line 1371):

    self.allocations
        .iter()
        .find(|a| a.name == qualified)
        .map(|a| a.address)
    

    self.allocations is the &[VarAllocation] from the analyzer. That's the stale table — it still says dst is at $27.

Blast radius

Silently wrong for every fun (regular or state-handler helper) that references a parameter or a function-local var inside an inline asm { ... } block. Globals and state-scoped (non- function) locals are unaffected because the analyzer and codegen agree on their addresses through allocations. The bug hides itself well because the asm reads a zero-page slot that's always 0 (the RAM clear zeros it, and nothing else writes there) — most programs just produce a wrong result rather than crashing.

examples/inline_asm_demo.ne is also affected but its output looks plausibly animated anyway:

fun times_four(input: u8) -> u8 {
    var result: u8 = input
    asm {
        LDA {result}    ; reads stale $14 (= 0), not $0301
        ASL A
        ASL A
        STA {result}    ; writes 0 << 2 = 0 to $14
    }
    return result       ; returns the $0301 copy of `input`, unchanged
}

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/)

Every primitive in sha_core.ne reads its parameters straight out of the transport slots $04 / $05 with the raw literal:

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:

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

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.

(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.

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).

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.