examples: add feature_canary that turns red on any memory silent-drop regression
Phase 5 of the post-PR-#31 audit, and the structural piece that
closes the failure mode the earlier phases couldn't fix alone.
The audit's recurring diagnosis: pixel/audio goldens capture
*whatever* the program does, not what it *should* do. A silent
drop in codegen is still deterministic — the golden locks in
the broken behaviour and every future run agrees with it. That's
how state-locals, uninitialized struct-field writes, `on exit`
handlers, and `slow` placement each sat broken for months-to-a-
year in a green CI.
The canary inverts the relationship: the committed golden is a
solid-green universal backdrop that only appears when every
round-trip check passes. Each check writes a distinctive constant
through one language construct, reads it back, and clears
`all_ok` on mismatch. A final `if all_ok == 0 { set_palette Fail }`
flips the entire screen red for the rest of the run.
Checks cover the silent-drop shapes caught by this audit:
- state-local variable write-read (PR #31)
- uninitialized struct-field write-read (caught by phase 1)
- u8 / u16 globals (u16 exercises both StoreVar + StoreVarHi)
- array-element write at nonzero index
- `slow`-placed global still round-trips
- function call return value
The canary doesn't use `debug.assert` on purpose — debug-only
ops get stripped in release and the emulator harness runs
release builds. The palette swap works in release and is what
the harness pixel-diff sees.
### Why this matters as a long-lived test
The harness already had 34 pixel goldens covering full-program
behaviour, but none of them exist specifically to fail if a
*specific language feature* silently drops. The canary does.
Every silent-drop bug the audit found would have flipped it
red the moment the check was added, which is the "behaviour
assertion that can't be satisfied by silence" the plan called
for.
### Harness footprint
`tests/emulator/goldens/feature_canary.{png,audio.hash}` +
`examples/feature_canary.{ne,nes}`. 35/35 ROMs match their
goldens with the canary added. Listed in both README tables.
https://claude.ai/code/session_01AoQ678uVeqpyayvWHpfDhC
2026-04-18 00:14:40 +00:00
|
|
|
// Feature canary — a round-trip smoke test for memory-affecting
|
|
|
|
|
// language features. Every check writes a distinctive constant
|
|
|
|
|
// through one language construct, then reads it back and compares
|
|
|
|
|
// against the written value. A pass leaves the universal palette
|
|
|
|
|
// green; any failure flips it to red.
|
|
|
|
|
//
|
|
|
|
|
// The goal is a single emulator golden that captures the green-
|
|
|
|
|
// backdrop "all features round-trip correctly" state at frame 180.
|
|
|
|
|
// If any of the following bugs reappear, the canary turns red and
|
|
|
|
|
// `tests/emulator/goldens/feature_canary.png` no longer matches:
|
|
|
|
|
//
|
|
|
|
|
// - PR #31 (state-local variable writes silently dropped)
|
|
|
|
|
// - Uninitialized struct-field writes silently dropped (caught
|
|
|
|
|
// while hardening `var_addrs` in this audit)
|
|
|
|
|
// - `slow` placement ignored (cold var still lands in ZP)
|
|
|
|
|
// - u16 high byte not stored
|
|
|
|
|
// - Array-element write silently dropped
|
|
|
|
|
// - Function return value dropped
|
|
|
|
|
//
|
|
|
|
|
// Every check cascades into `all_ok` (cleared to 0 on first
|
|
|
|
|
// failure), so the final set_palette call picks Pass/Fail from
|
|
|
|
|
// one flag. We deliberately do not use `debug.assert` because
|
|
|
|
|
// `--debug` builds strip nothing; the palette swap works in
|
|
|
|
|
// release and that's what the emulator harness runs.
|
|
|
|
|
//
|
|
|
|
|
// Build: cargo run -- build examples/feature_canary.ne
|
|
|
|
|
|
|
|
|
|
game "Feature Canary" { mapper: NROM }
|
|
|
|
|
|
|
|
|
|
// ── Palettes ────────────────────────────────────────────────
|
|
|
|
|
//
|
|
|
|
|
// Pass = all-green backdrop; Fail = all-red. The canary starts
|
|
|
|
|
// in Pass; if any round-trip check mismatches, `set_palette Fail`
|
|
|
|
|
// flips the entire screen red for the rest of the run.
|
|
|
|
|
palette Pass {
|
|
|
|
|
universal: green
|
|
|
|
|
bg0: [dk_green, lt_green, white]
|
|
|
|
|
bg1: [dk_green, lt_green, white]
|
|
|
|
|
bg2: [dk_green, lt_green, white]
|
|
|
|
|
bg3: [dk_green, lt_green, white]
|
|
|
|
|
sp0: [black, black, black]
|
|
|
|
|
sp1: [black, black, black]
|
|
|
|
|
sp2: [black, black, black]
|
|
|
|
|
sp3: [black, black, black]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
palette Fail {
|
|
|
|
|
universal: red
|
|
|
|
|
bg0: [dk_red, lt_red, white]
|
|
|
|
|
bg1: [dk_red, lt_red, white]
|
|
|
|
|
bg2: [dk_red, lt_red, white]
|
|
|
|
|
bg3: [dk_red, lt_red, white]
|
|
|
|
|
sp0: [black, black, black]
|
|
|
|
|
sp1: [black, black, black]
|
|
|
|
|
sp2: [black, black, black]
|
|
|
|
|
sp3: [black, black, black]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Types and storage ──────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
struct Vec2 { x: u8, y: u8 }
|
|
|
|
|
|
|
|
|
|
// Uninitialized struct global — this is the shape that was
|
|
|
|
|
// silently dropping field writes before the `var_addrs` fix.
|
|
|
|
|
var pos: Vec2
|
|
|
|
|
|
|
|
|
|
// Global u8 / u16 / array — classic globals.
|
|
|
|
|
var scalar: u8 = 0
|
|
|
|
|
var wide: u16 = 0
|
|
|
|
|
var row: u8[4] = [0, 0, 0, 0]
|
|
|
|
|
|
|
|
|
|
// A deliberately-cold u8 placed via `slow` so the analyzer
|
|
|
|
|
// keeps it outside zero-page. If `slow` regresses to advisory,
|
|
|
|
|
// the allocation address moves into ZP but the round-trip still
|
|
|
|
|
// succeeds — so this byte is for memory-map inspection, not the
|
|
|
|
|
// backdrop flip.
|
|
|
|
|
slow var cold_byte: u8 = 0
|
|
|
|
|
|
|
|
|
|
fun double_u8(x: u8) -> u8 {
|
|
|
|
|
return x + x
|
|
|
|
|
}
|
|
|
|
|
|
analyzer+codegen: lift the 4-param ceiling via a direct-write calling convention
Follow-up to the silent-drop audit. The old ABI passed every
parameter through four fixed zero-page transport slots `$04-$07`,
imposing a hard 4-param cap (E0506) that didn't compose with
structs/arrays/u16s and fell back to "pack args into a global"
workarounds whenever a function needed five things. The transport
scheme also cost every non-leaf call a 4-LDA/STA spill prologue
(~28 cycles, 16 bytes) to copy args out of ZP before the next
nested `JSR` could clobber them.
Replace it with a hybrid convention keyed on leaf-ness:
- **Leaf callees** (no nested `JSR` in body, ≤4 params):
unchanged. Caller stages args into `$04-$07`; body reads those
slots directly for its entire lifetime. No prologue copy.
Fastest path, 3-cycle ZP stores + 3-cycle ZP loads, preserves
the SHA-256 leaf-primitive optimisation that motivated the
original fast path.
- **Non-leaf callees** (body contains a nested `JSR`, OR ≥5
params): direct-write. Caller stages each argument straight
into the callee's analyzer-allocated parameter RAM slot,
bypassing the transport slots entirely. No prologue copy on
the callee side. Saves ~24 cycles and ~16 bytes per call vs
the old transport-then-spill path, and — crucially — scales
past 4 params because the per-param slots live wherever the
analyzer put them rather than in a fixed ZP window.
The analyzer's ceiling moves from 4 to 8. Functions with 5–8
params are silently promoted to the non-leaf convention (even if
their body has no nested `JSR`), which pays the direct-write cost
rather than the prologue-copy cost — still cheaper than the old
ABI. Declarations with 9+ params still emit E0506.
### Implementation
- `function_is_leaf` now also requires `param_count <= 4`.
- `IrCodeGen::new` populates `non_leaf_param_addrs: HashMap<String,
Vec<u16>>` — for every non-leaf function, the ordered list of
addresses its parameters occupy. Callers use this to route each
arg directly to the right slot.
- `IrOp::Call` branches on presence in the map: non-leaf → direct-
write, leaf (or absent — 0-arg case) → ZP transport.
- `gen_function` no longer emits a prologue. Leaves didn't have
one; non-leaves had a 4-LDA/STA copy that is now unnecessary
because args arrive pre-written to the slot.
- The previous `leaf_functions: HashSet<String>` field is
removed; leaf-ness is now inferred from absence-in-
`non_leaf_param_addrs` at the call site.
### Tests and regressions
- `eight_param_non_leaf_function_stages_every_arg_at_its_allocated_slot`
compiles an 8-param function, scans PRG for a distinct
`LDA #\$NN / STA <addr>` per arg (immediates `0x11..0x88`), and
asserts that STAs to the `$04-$07` range are strictly fewer
than 8 — proof the old transport path is gone for this call.
- `non_leaf_call_direct_writes_args_to_callee_param_slots`
replaces the old `gen_function_prologue_spills_params_to_local_ram`
test with a dual assertion: (a) no `LDA \$04` prologue at the
callee entry, and (b) the caller-side STA lands at the
analyzer-allocated param slot, not at `\$04-\$07`.
- `analyze_rejects_function_with_more_than_4_params` renamed and
rewritten for the new 8-param cap.
- `feature_canary.ne` gains a 6-param `sum6` call (1+2+3+4+5+6 =
21) as check 8. The canary stays green (all eight checks
pass), so the committed golden is unchanged.
### Blast radius
- Six example ROMs change bytes (arrays_and_functions, function_chain,
mmc1_banked, pong, sha256, war) because their non-leaf call sites
pick up the shorter staging sequence.
- Pong and war audio hashes refresh (pure layout-timing shift; no
behavioural change in the 180-frame no-input window). docs/pong.gif
and docs/war.gif stay byte-identical.
- `examples/function_chain.ne`'s header comment updated to
document the leaf vs non-leaf split it exercises.
- `docs/language-guide.md` parameter-count section and E0506 entry
updated to reflect the new rule.
All 720 Rust tests pass; all 35 emulator goldens pass.
https://claude.ai/code/session_01AoQ678uVeqpyayvWHpfDhC
2026-04-18 02:34:56 +00:00
|
|
|
// A six-parameter non-leaf function. The call site exercises
|
|
|
|
|
// the direct-write calling convention — the caller stages each
|
|
|
|
|
// arg straight into the callee's per-param RAM slot, no
|
|
|
|
|
// transport through `$04-$07`. Returns the sum of all six, so
|
|
|
|
|
// a regression that silently drops any one (same shape as PR
|
|
|
|
|
// #31 but for params) knocks the result off 21 and flips the
|
|
|
|
|
// canary red.
|
|
|
|
|
fun sum6(a: u8, b: u8, c: u8, d: u8, e: u8, f: u8) -> u8 {
|
|
|
|
|
var tmp: u8 = a + b
|
|
|
|
|
tmp = tmp + c
|
|
|
|
|
tmp = tmp + d
|
|
|
|
|
tmp = tmp + e
|
|
|
|
|
tmp = tmp + f
|
|
|
|
|
return tmp
|
|
|
|
|
}
|
|
|
|
|
|
examples: add feature_canary that turns red on any memory silent-drop regression
Phase 5 of the post-PR-#31 audit, and the structural piece that
closes the failure mode the earlier phases couldn't fix alone.
The audit's recurring diagnosis: pixel/audio goldens capture
*whatever* the program does, not what it *should* do. A silent
drop in codegen is still deterministic — the golden locks in
the broken behaviour and every future run agrees with it. That's
how state-locals, uninitialized struct-field writes, `on exit`
handlers, and `slow` placement each sat broken for months-to-a-
year in a green CI.
The canary inverts the relationship: the committed golden is a
solid-green universal backdrop that only appears when every
round-trip check passes. Each check writes a distinctive constant
through one language construct, reads it back, and clears
`all_ok` on mismatch. A final `if all_ok == 0 { set_palette Fail }`
flips the entire screen red for the rest of the run.
Checks cover the silent-drop shapes caught by this audit:
- state-local variable write-read (PR #31)
- uninitialized struct-field write-read (caught by phase 1)
- u8 / u16 globals (u16 exercises both StoreVar + StoreVarHi)
- array-element write at nonzero index
- `slow`-placed global still round-trips
- function call return value
The canary doesn't use `debug.assert` on purpose — debug-only
ops get stripped in release and the emulator harness runs
release builds. The palette swap works in release and is what
the harness pixel-diff sees.
### Why this matters as a long-lived test
The harness already had 34 pixel goldens covering full-program
behaviour, but none of them exist specifically to fail if a
*specific language feature* silently drops. The canary does.
Every silent-drop bug the audit found would have flipped it
red the moment the check was added, which is the "behaviour
assertion that can't be satisfied by silence" the plan called
for.
### Harness footprint
`tests/emulator/goldens/feature_canary.{png,audio.hash}` +
`examples/feature_canary.{ne,nes}`. 35/35 ROMs match their
goldens with the canary added. Listed in both README tables.
https://claude.ai/code/session_01AoQ678uVeqpyayvWHpfDhC
2026-04-18 00:14:40 +00:00
|
|
|
// ── Main state ─────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
state Main {
|
|
|
|
|
// State-local — the PR #31 bug.
|
|
|
|
|
var local_counter: u8 = 0
|
|
|
|
|
// Per-frame "pass" flag. Starts true each frame; any failed
|
|
|
|
|
// round-trip clears it.
|
|
|
|
|
var all_ok: u8 = 1
|
|
|
|
|
|
|
|
|
|
on enter {
|
|
|
|
|
set_palette Pass
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
on frame {
|
|
|
|
|
all_ok = 1
|
|
|
|
|
|
|
|
|
|
// Check 1: state-local write-read.
|
|
|
|
|
local_counter = 42
|
|
|
|
|
if local_counter != 42 { all_ok = 0 }
|
|
|
|
|
|
|
|
|
|
// Check 2: uninitialized struct-field write-read.
|
|
|
|
|
pos.x = 99
|
|
|
|
|
pos.y = 77
|
|
|
|
|
if pos.x != 99 { all_ok = 0 }
|
|
|
|
|
if pos.y != 77 { all_ok = 0 }
|
|
|
|
|
|
|
|
|
|
// Check 3: global u8.
|
|
|
|
|
scalar = 123
|
|
|
|
|
if scalar != 123 { all_ok = 0 }
|
|
|
|
|
|
|
|
|
|
// Check 4: global u16 > 255 (both low and high bytes must
|
|
|
|
|
// land — the u16 path splits into StoreVar + StoreVarHi).
|
|
|
|
|
wide = 1234
|
|
|
|
|
if wide != 1234 { all_ok = 0 }
|
|
|
|
|
|
|
|
|
|
// Check 5: array element write-read at nonzero index.
|
|
|
|
|
row[2] = 55
|
|
|
|
|
if row[2] != 55 { all_ok = 0 }
|
|
|
|
|
|
|
|
|
|
// Check 6: slow-placed global still round-trips.
|
|
|
|
|
cold_byte = 200
|
|
|
|
|
if cold_byte != 200 { all_ok = 0 }
|
|
|
|
|
|
|
|
|
|
// Check 7: function call return value survives the
|
|
|
|
|
// caller's frame of reference.
|
|
|
|
|
var r: u8 = double_u8(21)
|
|
|
|
|
if r != 42 { all_ok = 0 }
|
|
|
|
|
|
analyzer+codegen: lift the 4-param ceiling via a direct-write calling convention
Follow-up to the silent-drop audit. The old ABI passed every
parameter through four fixed zero-page transport slots `$04-$07`,
imposing a hard 4-param cap (E0506) that didn't compose with
structs/arrays/u16s and fell back to "pack args into a global"
workarounds whenever a function needed five things. The transport
scheme also cost every non-leaf call a 4-LDA/STA spill prologue
(~28 cycles, 16 bytes) to copy args out of ZP before the next
nested `JSR` could clobber them.
Replace it with a hybrid convention keyed on leaf-ness:
- **Leaf callees** (no nested `JSR` in body, ≤4 params):
unchanged. Caller stages args into `$04-$07`; body reads those
slots directly for its entire lifetime. No prologue copy.
Fastest path, 3-cycle ZP stores + 3-cycle ZP loads, preserves
the SHA-256 leaf-primitive optimisation that motivated the
original fast path.
- **Non-leaf callees** (body contains a nested `JSR`, OR ≥5
params): direct-write. Caller stages each argument straight
into the callee's analyzer-allocated parameter RAM slot,
bypassing the transport slots entirely. No prologue copy on
the callee side. Saves ~24 cycles and ~16 bytes per call vs
the old transport-then-spill path, and — crucially — scales
past 4 params because the per-param slots live wherever the
analyzer put them rather than in a fixed ZP window.
The analyzer's ceiling moves from 4 to 8. Functions with 5–8
params are silently promoted to the non-leaf convention (even if
their body has no nested `JSR`), which pays the direct-write cost
rather than the prologue-copy cost — still cheaper than the old
ABI. Declarations with 9+ params still emit E0506.
### Implementation
- `function_is_leaf` now also requires `param_count <= 4`.
- `IrCodeGen::new` populates `non_leaf_param_addrs: HashMap<String,
Vec<u16>>` — for every non-leaf function, the ordered list of
addresses its parameters occupy. Callers use this to route each
arg directly to the right slot.
- `IrOp::Call` branches on presence in the map: non-leaf → direct-
write, leaf (or absent — 0-arg case) → ZP transport.
- `gen_function` no longer emits a prologue. Leaves didn't have
one; non-leaves had a 4-LDA/STA copy that is now unnecessary
because args arrive pre-written to the slot.
- The previous `leaf_functions: HashSet<String>` field is
removed; leaf-ness is now inferred from absence-in-
`non_leaf_param_addrs` at the call site.
### Tests and regressions
- `eight_param_non_leaf_function_stages_every_arg_at_its_allocated_slot`
compiles an 8-param function, scans PRG for a distinct
`LDA #\$NN / STA <addr>` per arg (immediates `0x11..0x88`), and
asserts that STAs to the `$04-$07` range are strictly fewer
than 8 — proof the old transport path is gone for this call.
- `non_leaf_call_direct_writes_args_to_callee_param_slots`
replaces the old `gen_function_prologue_spills_params_to_local_ram`
test with a dual assertion: (a) no `LDA \$04` prologue at the
callee entry, and (b) the caller-side STA lands at the
analyzer-allocated param slot, not at `\$04-\$07`.
- `analyze_rejects_function_with_more_than_4_params` renamed and
rewritten for the new 8-param cap.
- `feature_canary.ne` gains a 6-param `sum6` call (1+2+3+4+5+6 =
21) as check 8. The canary stays green (all eight checks
pass), so the committed golden is unchanged.
### Blast radius
- Six example ROMs change bytes (arrays_and_functions, function_chain,
mmc1_banked, pong, sha256, war) because their non-leaf call sites
pick up the shorter staging sequence.
- Pong and war audio hashes refresh (pure layout-timing shift; no
behavioural change in the 180-frame no-input window). docs/pong.gif
and docs/war.gif stay byte-identical.
- `examples/function_chain.ne`'s header comment updated to
document the leaf vs non-leaf split it exercises.
- `docs/language-guide.md` parameter-count section and E0506 entry
updated to reflect the new rule.
All 720 Rust tests pass; all 35 emulator goldens pass.
https://claude.ai/code/session_01AoQ678uVeqpyayvWHpfDhC
2026-04-18 02:34:56 +00:00
|
|
|
// Check 8: six-parameter non-leaf function — exercises
|
|
|
|
|
// the direct-write calling convention that lifts the
|
|
|
|
|
// old 4-param ceiling. 1+2+3+4+5+6 = 21.
|
|
|
|
|
var s: u8 = sum6(1, 2, 3, 4, 5, 6)
|
|
|
|
|
if s != 21 { all_ok = 0 }
|
|
|
|
|
|
examples: add feature_canary that turns red on any memory silent-drop regression
Phase 5 of the post-PR-#31 audit, and the structural piece that
closes the failure mode the earlier phases couldn't fix alone.
The audit's recurring diagnosis: pixel/audio goldens capture
*whatever* the program does, not what it *should* do. A silent
drop in codegen is still deterministic — the golden locks in
the broken behaviour and every future run agrees with it. That's
how state-locals, uninitialized struct-field writes, `on exit`
handlers, and `slow` placement each sat broken for months-to-a-
year in a green CI.
The canary inverts the relationship: the committed golden is a
solid-green universal backdrop that only appears when every
round-trip check passes. Each check writes a distinctive constant
through one language construct, reads it back, and clears
`all_ok` on mismatch. A final `if all_ok == 0 { set_palette Fail }`
flips the entire screen red for the rest of the run.
Checks cover the silent-drop shapes caught by this audit:
- state-local variable write-read (PR #31)
- uninitialized struct-field write-read (caught by phase 1)
- u8 / u16 globals (u16 exercises both StoreVar + StoreVarHi)
- array-element write at nonzero index
- `slow`-placed global still round-trips
- function call return value
The canary doesn't use `debug.assert` on purpose — debug-only
ops get stripped in release and the emulator harness runs
release builds. The palette swap works in release and is what
the harness pixel-diff sees.
### Why this matters as a long-lived test
The harness already had 34 pixel goldens covering full-program
behaviour, but none of them exist specifically to fail if a
*specific language feature* silently drops. The canary does.
Every silent-drop bug the audit found would have flipped it
red the moment the check was added, which is the "behaviour
assertion that can't be satisfied by silence" the plan called
for.
### Harness footprint
`tests/emulator/goldens/feature_canary.{png,audio.hash}` +
`examples/feature_canary.{ne,nes}`. 35/35 ROMs match their
goldens with the canary added. Listed in both README tables.
https://claude.ai/code/session_01AoQ678uVeqpyayvWHpfDhC
2026-04-18 00:14:40 +00:00
|
|
|
// Drive the backdrop flip. `set_palette` schedules an
|
|
|
|
|
// update during the next vblank, so the effect lands on
|
|
|
|
|
// the following frame — well before the frame-180 golden
|
|
|
|
|
// sample.
|
|
|
|
|
if all_ok == 0 {
|
|
|
|
|
set_palette Fail
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
wait_frame
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
start Main
|