1
0
Fork 0
mirror of https://github.com/imjasonh/nescript synced 2026-07-17 14:16:11 +00:00
nescript/tests/emulator/run_examples.mjs

195 lines
7.1 KiB
JavaScript
Raw Normal View History

Add jsnes emulator harness and fix four codegen bugs it surfaced Running the compiled example ROMs through a headless puppeteer + local jsnes harness exposed four latent bugs that the header-structure-only integration tests couldn't catch: - src/asm/mod.rs: the first pass treated ANY instruction with `AddressingMode::Label` as a label definition, silently dropping every `JMP`/`JSR` to a label. Now only `NOP + Label` is a label def; other opcodes emit the opcode byte plus a 2-byte absolute fixup resolved in pass two. Without this, every example crashed with "invalid opcode at $1xxx" once the CPU fell through into the math runtime and hit an unbalanced `RTS`. - src/ir/lowering.rs (lower_handler): handler-local `VarDecl`s (e.g. `var i: u8 = 0` inside a `while`) were pushed onto `current_locals` but the handler built its own throwaway `locals` list, so those var ids never got RAM addresses and every `LoadVar`/`StoreVar` for them silently emitted nothing. Seed `current_locals` with the state's declared locals and reuse it so `lower_statement`'s appends flow through to the `IrFunction`. Fixes the black screen in `arrays_and_functions`. - src/ir/lowering.rs (global init): struct-literal initializers on globals (`var player: Player = Player { x: 120, ... }`) fell through to `eval_const`, which returned `None` for a non-literal, so no init code was emitted. Now the per-field synthetic globals each get their own `init_value`. Fixes the black screen in `structs_enums_for`. - src/codegen/mod.rs: the legacy AST codegen was emitting `JSR __fn_poke` / treating `peek` as `LDA #0` for the hardware intrinsics. It only "worked" before because the broken assembler swallowed the bogus JSR. Handle `poke`/`peek` as direct STA/LDA to a compile-time-constant absolute address, matching the IR codegen's intrinsic path. The harness lives in `tests/emulator/`: a tiny HTML page that wraps the `jsnes` npm package, driven by a puppeteer script that loads each ROM, runs ~180 frames, snapshots the canvas, and records a smoke-test verdict (booted without a CPU crash, non-zero pixels rendered, frames differ over time). `npm install && node run_examples.mjs` from `tests/emulator/` runs the full sweep. 9/9 example ROMs now load, render, and animate where expected. All 324 unit + 35 integration tests still pass. https://claude.ai/code/session_014Z5y3Q9krLcAxYpZQJhZ5V
2026-04-12 18:46:58 +00:00
// Drive the local jsnes harness from puppeteer to sanity-check every compiled
// example ROM. For each ROM we load it, run a couple of seconds of frames,
// capture a screenshot, and record basic "did it render" stats. This is a
// load+render smoke test, not a gameplay test.
import { promises as fs } from "node:fs";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import puppeteer from "puppeteer";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(__dirname, "..", "..");
const examplesDir = path.join(repoRoot, "examples");
const screenshotsDir = path.join(__dirname, "screenshots");
const harnessUrl = pathToFileURL(path.join(__dirname, "harness.html")).toString();
const FRAMES_TO_RUN = 180; // ~3 seconds at 60 fps, enough to get past a title/boot
const SCREENSHOT_FRAME = 180;
Bug B: runtime OAM cursor so `draw` inside loops actually works `IrCodeGen::next_oam_slot` incremented at *compile time*: one `draw` statement = one fixed OAM slot, baked into absolute-mode stores at codegen. A `draw` inside a `while`/`for`/`loop` body was lowered once and then always wrote to the same four OAM bytes every iteration, so only the last iteration was ever visible. The writeup in the earlier PR called this "bug B". Fix: reserve ZP `$09` as `ZP_OAM_CURSOR`, reset it to 0 at the top of every frame handler (right after the existing OAM clear loop), and lower each `DrawSprite` IR op to: LDY $09 ; load cursor LDA <y_temp> STA $0200,Y ; sprite Y LDA #tile STA $0201,Y ; tile LDA #0 STA $0202,Y ; attr LDA <x_temp> STA $0203,Y ; sprite X INC $09 x4 ; bump cursor by 4 Cost is ~+6 bytes per `draw` over the old static form. At 64 slots the u8 cursor wraps naturally, giving classic NES "too many sprites" flicker instead of a silent compile-time drop. `next_oam_slot` and its resets are gone from the IR codegen entirely. Secondary fix: `for i in 0..N` counters are now registered as handler locals. `lower_statement` created a `VarId` for the counter via `get_or_create_var` but never pushed it onto `current_locals`, so the IR codegen's `var_addrs` lookup returned `None` for every `StoreVar(i)` / `LoadVar(i)` and silently emitted nothing. The counter stayed at 0 forever, the loop spun indefinitely, and every iteration wrote the first array element into OAM — turning all 64 sprites into the same smiley. Same class as the handler-local `var` decl bug from the earlier PR, just for for-loop variables. Smoke-test deltas (all 14/14 still pass): - arrays_and_functions: 104 -> 260 (player + 4 enemies) - bitwise_ops: 104 -> 416 (player + flag sprites + pips) - loop_break_continue: 208 -> 208 (already fixed by the earlier pass) - structs_enums_for: 104 -> 260 (player + 4 enemies) Regression tests: - `ir_codegen::more_tests::ir_codegen_draw_sprite` — checks a single `draw` emits `LDY cursor`, four `STA $020N,Y`, and four `INC cursor`. - `ir_codegen::more_tests::ir_codegen_multi_oam_uses_sequential_slots` — rewritten for the new form: each draw gets its own `LDY cursor` + 4 `INC cursor`. - `ir_codegen::more_tests::ir_codegen_draw_in_loop_...` — proves a `draw` inside a `while` compiles to ONE cursor-based draw (not N unrolled statics and not zero), and asserts no stray `STA $0204`/`$0208`/... absolute stores — those would indicate bug B has regressed. - `ir::tests::for_loop_counter_is_registered_as_handler_local` — verifies `for i in 0..N` pushes `i` onto `current_locals` so the IR codegen allocates it. Smoke-test tightening: `tests/emulator/run_examples.mjs` now has per-example `minNonBlack` floors. `arrays_and_functions`, `structs_enums_for`, `loop_break_continue`, and `bitwise_ops` all require multi-sprite rendering — if the OAM cursor bug comes back, the smoke test fails loudly instead of passing on the default `nonBlack > 0` check. The legacy AST codegen in `src/codegen/mod.rs` still uses the compile-time `next_oam_slot` approach. It's only reachable via `--use-ast`, none of the examples use it, and its integration tests only check iNES structure — left alone on purpose. https://claude.ai/code/session_014Z5y3Q9krLcAxYpZQJhZ5V
2026-04-12 20:20:20 +00:00
// Per-example non-black pixel floors, used to catch silent
// render regressions. A bare smiley sprite contributes ~52
// non-black pixels; the default floor below assumes one visible
// sprite. Examples that draw more sprites override the floor
// with a tighter value so bugs like "only one of the four
// enemies actually shows up" fail CI instead of silently
// slipping past the base `nonBlack > 0` check.
//
// Each entry is `[minNonBlack, note]`. The note is printed when
// the floor fails so it's easy to tell what the example was
// supposed to show.
const DEFAULT_MIN_NON_BLACK = 40; // one small sprite, conservative
const EXAMPLE_FLOORS = {
arrays_and_functions: [200, "player + 4 enemies drawn by a while loop"],
bitwise_ops: [150, "player + multiple flag/pip sprites across if branches and a while loop"],
loop_break_continue: [150, "player + 3 active hazards (one slot is inactive)"],
structs_enums_for: [200, "player + 4 enemies drawn by a `for` loop"],
sprites_and_palettes: [60, "custom CHR tiles visible"],
scanline_split: [80, "banner + player"],
examples: 4 new programs covering MMC3 + other e2e gaps Four new examples bring total coverage to 18/18 ROMs through the jsnes smoke test: - mmc3_per_state_split.ne — two states, each with their own `on scanline(N)` handler at a different line (80 vs 160). Pressing START transitions between them. Verifies the per-state MMC3 IRQ dispatch: the `__ir_mmc3_reload` helper CMPs `current_state` on every NMI and writes the right latch value to `$C000`/`$C001`, and `__irq_user` runs the current state's handler when the counter fires. This is the first example that exercises the per-state reload logic at runtime, not just at compile-time. - two_player.ne — exercises `p2.button.*` reads alongside the default (P1) `button.*`. Two independently-moveable sprites sharing a single frame handler and the runtime OAM cursor. The runtime's NMI controller poll already reads both `$4016` and `$4017`, but until this example no runtime test actually looked at `$08` (the P2 input byte). - function_chain.ne — five-deep user-function call chain (`frame -> compute -> scale -> clamp -> fold -> taper`) with parameter passing through ZP `$04-$07` and return values through A. Early returns inside nested `if`s, handler-local result var, mixed shift + additive transforms. Catches any regression in: JSR stack discipline, param slot layout, RTS stack unwinding, return-value flow, or the analyzer's call-graph / max-depth computation. - comparisons.ne — one `if` per comparison operator (`==`, `!=`, `<`, `<=`, `>`, `>=`) gated on a u8 ramping through 0..255. Each `if` drives a pip sprite at a fixed column. Exercises every `CmpKind::*` case in the IR codegen's `gen_cmp`, catching regressions in branch-opcode selection (BEQ/BNE/BCC/BCS) and inverted-branch peephole folding. Smoke test deltas (all 18/18 pass, with per-example floors): comparisons 208 (floor 150) function_chain 104 (floor 100) mmc3_per_state_split 104 (floor 80) two_player 104 (floor 100) `tests/emulator/run_examples.mjs` gets new `EXAMPLE_FLOORS` entries for each, with notes describing the expected content so a regression prints a helpful reason. cargo test (313 unit + 37 integration), cargo fmt --check, cargo clippy --release -- -D warnings all clean. https://claude.ai/code/session_014Z5y3Q9krLcAxYpZQJhZ5V
2026-04-12 20:48:31 +00:00
mmc3_per_state_split: [80, "marker + player in the split-screen state"],
two_player: [100, "two player sprites drawn independently"],
function_chain: [100, "player swept by chained function return + a static marker"],
// `comparisons` has at least `value != MIDPOINT` true for 255 of
// 256 frames, plus either `<`/`<=` or `>`/`>=`, plus the player.
// That's 4+ sprites on most frames.
comparisons: [150, "player + pips for each true comparison against MIDPOINT"],
Bug B: runtime OAM cursor so `draw` inside loops actually works `IrCodeGen::next_oam_slot` incremented at *compile time*: one `draw` statement = one fixed OAM slot, baked into absolute-mode stores at codegen. A `draw` inside a `while`/`for`/`loop` body was lowered once and then always wrote to the same four OAM bytes every iteration, so only the last iteration was ever visible. The writeup in the earlier PR called this "bug B". Fix: reserve ZP `$09` as `ZP_OAM_CURSOR`, reset it to 0 at the top of every frame handler (right after the existing OAM clear loop), and lower each `DrawSprite` IR op to: LDY $09 ; load cursor LDA <y_temp> STA $0200,Y ; sprite Y LDA #tile STA $0201,Y ; tile LDA #0 STA $0202,Y ; attr LDA <x_temp> STA $0203,Y ; sprite X INC $09 x4 ; bump cursor by 4 Cost is ~+6 bytes per `draw` over the old static form. At 64 slots the u8 cursor wraps naturally, giving classic NES "too many sprites" flicker instead of a silent compile-time drop. `next_oam_slot` and its resets are gone from the IR codegen entirely. Secondary fix: `for i in 0..N` counters are now registered as handler locals. `lower_statement` created a `VarId` for the counter via `get_or_create_var` but never pushed it onto `current_locals`, so the IR codegen's `var_addrs` lookup returned `None` for every `StoreVar(i)` / `LoadVar(i)` and silently emitted nothing. The counter stayed at 0 forever, the loop spun indefinitely, and every iteration wrote the first array element into OAM — turning all 64 sprites into the same smiley. Same class as the handler-local `var` decl bug from the earlier PR, just for for-loop variables. Smoke-test deltas (all 14/14 still pass): - arrays_and_functions: 104 -> 260 (player + 4 enemies) - bitwise_ops: 104 -> 416 (player + flag sprites + pips) - loop_break_continue: 208 -> 208 (already fixed by the earlier pass) - structs_enums_for: 104 -> 260 (player + 4 enemies) Regression tests: - `ir_codegen::more_tests::ir_codegen_draw_sprite` — checks a single `draw` emits `LDY cursor`, four `STA $020N,Y`, and four `INC cursor`. - `ir_codegen::more_tests::ir_codegen_multi_oam_uses_sequential_slots` — rewritten for the new form: each draw gets its own `LDY cursor` + 4 `INC cursor`. - `ir_codegen::more_tests::ir_codegen_draw_in_loop_...` — proves a `draw` inside a `while` compiles to ONE cursor-based draw (not N unrolled statics and not zero), and asserts no stray `STA $0204`/`$0208`/... absolute stores — those would indicate bug B has regressed. - `ir::tests::for_loop_counter_is_registered_as_handler_local` — verifies `for i in 0..N` pushes `i` onto `current_locals` so the IR codegen allocates it. Smoke-test tightening: `tests/emulator/run_examples.mjs` now has per-example `minNonBlack` floors. `arrays_and_functions`, `structs_enums_for`, `loop_break_continue`, and `bitwise_ops` all require multi-sprite rendering — if the OAM cursor bug comes back, the smoke test fails loudly instead of passing on the default `nonBlack > 0` check. The legacy AST codegen in `src/codegen/mod.rs` still uses the compile-time `next_oam_slot` approach. It's only reachable via `--use-ast`, none of the examples use it, and its integration tests only check iNES structure — left alone on purpose. https://claude.ai/code/session_014Z5y3Q9krLcAxYpZQJhZ5V
2026-04-12 20:20:20 +00:00
};
Add jsnes emulator harness and fix four codegen bugs it surfaced Running the compiled example ROMs through a headless puppeteer + local jsnes harness exposed four latent bugs that the header-structure-only integration tests couldn't catch: - src/asm/mod.rs: the first pass treated ANY instruction with `AddressingMode::Label` as a label definition, silently dropping every `JMP`/`JSR` to a label. Now only `NOP + Label` is a label def; other opcodes emit the opcode byte plus a 2-byte absolute fixup resolved in pass two. Without this, every example crashed with "invalid opcode at $1xxx" once the CPU fell through into the math runtime and hit an unbalanced `RTS`. - src/ir/lowering.rs (lower_handler): handler-local `VarDecl`s (e.g. `var i: u8 = 0` inside a `while`) were pushed onto `current_locals` but the handler built its own throwaway `locals` list, so those var ids never got RAM addresses and every `LoadVar`/`StoreVar` for them silently emitted nothing. Seed `current_locals` with the state's declared locals and reuse it so `lower_statement`'s appends flow through to the `IrFunction`. Fixes the black screen in `arrays_and_functions`. - src/ir/lowering.rs (global init): struct-literal initializers on globals (`var player: Player = Player { x: 120, ... }`) fell through to `eval_const`, which returned `None` for a non-literal, so no init code was emitted. Now the per-field synthetic globals each get their own `init_value`. Fixes the black screen in `structs_enums_for`. - src/codegen/mod.rs: the legacy AST codegen was emitting `JSR __fn_poke` / treating `peek` as `LDA #0` for the hardware intrinsics. It only "worked" before because the broken assembler swallowed the bogus JSR. Handle `poke`/`peek` as direct STA/LDA to a compile-time-constant absolute address, matching the IR codegen's intrinsic path. The harness lives in `tests/emulator/`: a tiny HTML page that wraps the `jsnes` npm package, driven by a puppeteer script that loads each ROM, runs ~180 frames, snapshots the canvas, and records a smoke-test verdict (booted without a CPU crash, non-zero pixels rendered, frames differ over time). `npm install && node run_examples.mjs` from `tests/emulator/` runs the full sweep. 9/9 example ROMs now load, render, and animate where expected. All 324 unit + 35 integration tests still pass. https://claude.ai/code/session_014Z5y3Q9krLcAxYpZQJhZ5V
2026-04-12 18:46:58 +00:00
async function listRoms() {
const entries = await fs.readdir(examplesDir);
return entries
.filter((f) => f.endsWith(".nes"))
.sort()
.map((f) => ({ name: f.replace(/\.nes$/, ""), file: path.join(examplesDir, f) }));
}
Bug B: runtime OAM cursor so `draw` inside loops actually works `IrCodeGen::next_oam_slot` incremented at *compile time*: one `draw` statement = one fixed OAM slot, baked into absolute-mode stores at codegen. A `draw` inside a `while`/`for`/`loop` body was lowered once and then always wrote to the same four OAM bytes every iteration, so only the last iteration was ever visible. The writeup in the earlier PR called this "bug B". Fix: reserve ZP `$09` as `ZP_OAM_CURSOR`, reset it to 0 at the top of every frame handler (right after the existing OAM clear loop), and lower each `DrawSprite` IR op to: LDY $09 ; load cursor LDA <y_temp> STA $0200,Y ; sprite Y LDA #tile STA $0201,Y ; tile LDA #0 STA $0202,Y ; attr LDA <x_temp> STA $0203,Y ; sprite X INC $09 x4 ; bump cursor by 4 Cost is ~+6 bytes per `draw` over the old static form. At 64 slots the u8 cursor wraps naturally, giving classic NES "too many sprites" flicker instead of a silent compile-time drop. `next_oam_slot` and its resets are gone from the IR codegen entirely. Secondary fix: `for i in 0..N` counters are now registered as handler locals. `lower_statement` created a `VarId` for the counter via `get_or_create_var` but never pushed it onto `current_locals`, so the IR codegen's `var_addrs` lookup returned `None` for every `StoreVar(i)` / `LoadVar(i)` and silently emitted nothing. The counter stayed at 0 forever, the loop spun indefinitely, and every iteration wrote the first array element into OAM — turning all 64 sprites into the same smiley. Same class as the handler-local `var` decl bug from the earlier PR, just for for-loop variables. Smoke-test deltas (all 14/14 still pass): - arrays_and_functions: 104 -> 260 (player + 4 enemies) - bitwise_ops: 104 -> 416 (player + flag sprites + pips) - loop_break_continue: 208 -> 208 (already fixed by the earlier pass) - structs_enums_for: 104 -> 260 (player + 4 enemies) Regression tests: - `ir_codegen::more_tests::ir_codegen_draw_sprite` — checks a single `draw` emits `LDY cursor`, four `STA $020N,Y`, and four `INC cursor`. - `ir_codegen::more_tests::ir_codegen_multi_oam_uses_sequential_slots` — rewritten for the new form: each draw gets its own `LDY cursor` + 4 `INC cursor`. - `ir_codegen::more_tests::ir_codegen_draw_in_loop_...` — proves a `draw` inside a `while` compiles to ONE cursor-based draw (not N unrolled statics and not zero), and asserts no stray `STA $0204`/`$0208`/... absolute stores — those would indicate bug B has regressed. - `ir::tests::for_loop_counter_is_registered_as_handler_local` — verifies `for i in 0..N` pushes `i` onto `current_locals` so the IR codegen allocates it. Smoke-test tightening: `tests/emulator/run_examples.mjs` now has per-example `minNonBlack` floors. `arrays_and_functions`, `structs_enums_for`, `loop_break_continue`, and `bitwise_ops` all require multi-sprite rendering — if the OAM cursor bug comes back, the smoke test fails loudly instead of passing on the default `nonBlack > 0` check. The legacy AST codegen in `src/codegen/mod.rs` still uses the compile-time `next_oam_slot` approach. It's only reachable via `--use-ast`, none of the examples use it, and its integration tests only check iNES structure — left alone on purpose. https://claude.ai/code/session_014Z5y3Q9krLcAxYpZQJhZ5V
2026-04-12 20:20:20 +00:00
function floorFor(name) {
const entry = EXAMPLE_FLOORS[name];
if (entry) return entry;
return [DEFAULT_MIN_NON_BLACK, "generic single-sprite floor"];
}
Add jsnes emulator harness and fix four codegen bugs it surfaced Running the compiled example ROMs through a headless puppeteer + local jsnes harness exposed four latent bugs that the header-structure-only integration tests couldn't catch: - src/asm/mod.rs: the first pass treated ANY instruction with `AddressingMode::Label` as a label definition, silently dropping every `JMP`/`JSR` to a label. Now only `NOP + Label` is a label def; other opcodes emit the opcode byte plus a 2-byte absolute fixup resolved in pass two. Without this, every example crashed with "invalid opcode at $1xxx" once the CPU fell through into the math runtime and hit an unbalanced `RTS`. - src/ir/lowering.rs (lower_handler): handler-local `VarDecl`s (e.g. `var i: u8 = 0` inside a `while`) were pushed onto `current_locals` but the handler built its own throwaway `locals` list, so those var ids never got RAM addresses and every `LoadVar`/`StoreVar` for them silently emitted nothing. Seed `current_locals` with the state's declared locals and reuse it so `lower_statement`'s appends flow through to the `IrFunction`. Fixes the black screen in `arrays_and_functions`. - src/ir/lowering.rs (global init): struct-literal initializers on globals (`var player: Player = Player { x: 120, ... }`) fell through to `eval_const`, which returned `None` for a non-literal, so no init code was emitted. Now the per-field synthetic globals each get their own `init_value`. Fixes the black screen in `structs_enums_for`. - src/codegen/mod.rs: the legacy AST codegen was emitting `JSR __fn_poke` / treating `peek` as `LDA #0` for the hardware intrinsics. It only "worked" before because the broken assembler swallowed the bogus JSR. Handle `poke`/`peek` as direct STA/LDA to a compile-time-constant absolute address, matching the IR codegen's intrinsic path. The harness lives in `tests/emulator/`: a tiny HTML page that wraps the `jsnes` npm package, driven by a puppeteer script that loads each ROM, runs ~180 frames, snapshots the canvas, and records a smoke-test verdict (booted without a CPU crash, non-zero pixels rendered, frames differ over time). `npm install && node run_examples.mjs` from `tests/emulator/` runs the full sweep. 9/9 example ROMs now load, render, and animate where expected. All 324 unit + 35 integration tests still pass. https://claude.ai/code/session_014Z5y3Q9krLcAxYpZQJhZ5V
2026-04-12 18:46:58 +00:00
async function main() {
await fs.mkdir(screenshotsDir, { recursive: true });
const roms = await listRoms();
if (roms.length === 0) {
console.error("no .nes files found in examples/ — build them first");
process.exit(1);
}
const browser = await puppeteer.launch({
headless: "new",
args: ["--no-sandbox", "--disable-setuid-sandbox", "--allow-file-access-from-files"],
});
const results = [];
let failures = 0;
try {
for (const rom of roms) {
const page = await browser.newPage();
const consoleErrors = [];
page.on("pageerror", (err) => consoleErrors.push(String(err)));
page.on("console", (msg) => {
if (msg.type() === "error") consoleErrors.push(msg.text());
});
await page.goto(harnessUrl, { waitUntil: "load" });
// Wait until the harness reports ready.
await page.waitForFunction("window.nesHarness && document.getElementById('info').textContent === 'ready'");
const romBytes = await fs.readFile(rom.file);
const romB64 = romBytes.toString("base64");
let booted = true;
let bootError = null;
try {
await page.evaluate((b64) => window.nesHarness.loadRomBase64(b64), romB64);
} catch (err) {
booted = false;
bootError = String(err);
}
// Collect hashes across frames so we can detect a frozen / all-black boot.
const frameHashes = [];
if (booted) {
try {
for (let i = 0; i < FRAMES_TO_RUN; i++) {
await page.evaluate(() => window.nesHarness.frame());
if (i === 29 || i === 89 || i === 149 || i === SCREENSHOT_FRAME - 1) {
const stats = await page.evaluate(() => window.nesHarness.frameStats());
frameHashes.push({ frame: i + 1, ...stats });
}
}
} catch (err) {
booted = false;
bootError = String(err);
}
}
const screenshotPath = path.join(screenshotsDir, `${rom.name}.png`);
if (booted) {
const canvas = await page.$("#screen");
await canvas.screenshot({ path: screenshotPath });
}
const lastStats = frameHashes[frameHashes.length - 1];
const uniqueHashes = new Set(frameHashes.map((h) => h.hash)).size;
const rendered = booted && lastStats && lastStats.nonBlack > 0;
const animated = uniqueHashes > 1;
Bug B: runtime OAM cursor so `draw` inside loops actually works `IrCodeGen::next_oam_slot` incremented at *compile time*: one `draw` statement = one fixed OAM slot, baked into absolute-mode stores at codegen. A `draw` inside a `while`/`for`/`loop` body was lowered once and then always wrote to the same four OAM bytes every iteration, so only the last iteration was ever visible. The writeup in the earlier PR called this "bug B". Fix: reserve ZP `$09` as `ZP_OAM_CURSOR`, reset it to 0 at the top of every frame handler (right after the existing OAM clear loop), and lower each `DrawSprite` IR op to: LDY $09 ; load cursor LDA <y_temp> STA $0200,Y ; sprite Y LDA #tile STA $0201,Y ; tile LDA #0 STA $0202,Y ; attr LDA <x_temp> STA $0203,Y ; sprite X INC $09 x4 ; bump cursor by 4 Cost is ~+6 bytes per `draw` over the old static form. At 64 slots the u8 cursor wraps naturally, giving classic NES "too many sprites" flicker instead of a silent compile-time drop. `next_oam_slot` and its resets are gone from the IR codegen entirely. Secondary fix: `for i in 0..N` counters are now registered as handler locals. `lower_statement` created a `VarId` for the counter via `get_or_create_var` but never pushed it onto `current_locals`, so the IR codegen's `var_addrs` lookup returned `None` for every `StoreVar(i)` / `LoadVar(i)` and silently emitted nothing. The counter stayed at 0 forever, the loop spun indefinitely, and every iteration wrote the first array element into OAM — turning all 64 sprites into the same smiley. Same class as the handler-local `var` decl bug from the earlier PR, just for for-loop variables. Smoke-test deltas (all 14/14 still pass): - arrays_and_functions: 104 -> 260 (player + 4 enemies) - bitwise_ops: 104 -> 416 (player + flag sprites + pips) - loop_break_continue: 208 -> 208 (already fixed by the earlier pass) - structs_enums_for: 104 -> 260 (player + 4 enemies) Regression tests: - `ir_codegen::more_tests::ir_codegen_draw_sprite` — checks a single `draw` emits `LDY cursor`, four `STA $020N,Y`, and four `INC cursor`. - `ir_codegen::more_tests::ir_codegen_multi_oam_uses_sequential_slots` — rewritten for the new form: each draw gets its own `LDY cursor` + 4 `INC cursor`. - `ir_codegen::more_tests::ir_codegen_draw_in_loop_...` — proves a `draw` inside a `while` compiles to ONE cursor-based draw (not N unrolled statics and not zero), and asserts no stray `STA $0204`/`$0208`/... absolute stores — those would indicate bug B has regressed. - `ir::tests::for_loop_counter_is_registered_as_handler_local` — verifies `for i in 0..N` pushes `i` onto `current_locals` so the IR codegen allocates it. Smoke-test tightening: `tests/emulator/run_examples.mjs` now has per-example `minNonBlack` floors. `arrays_and_functions`, `structs_enums_for`, `loop_break_continue`, and `bitwise_ops` all require multi-sprite rendering — if the OAM cursor bug comes back, the smoke test fails loudly instead of passing on the default `nonBlack > 0` check. The legacy AST codegen in `src/codegen/mod.rs` still uses the compile-time `next_oam_slot` approach. It's only reachable via `--use-ast`, none of the examples use it, and its integration tests only check iNES structure — left alone on purpose. https://claude.ai/code/session_014Z5y3Q9krLcAxYpZQJhZ5V
2026-04-12 20:20:20 +00:00
const [minNonBlack, floorNote] = floorFor(rom.name);
const meetsFloor = rendered && lastStats.nonBlack >= minNonBlack;
const pass = rendered && meetsFloor;
const status = pass ? "OK" : "FAIL";
if (!pass) failures++;
let failReason = null;
if (!booted) {
failReason = `boot error: ${bootError ?? "unknown"}`;
} else if (!rendered) {
failReason = "rendered a fully black screen (nonBlack=0)";
} else if (!meetsFloor) {
failReason = `nonBlack=${lastStats.nonBlack} below floor=${minNonBlack} (${floorNote})`;
}
Add jsnes emulator harness and fix four codegen bugs it surfaced Running the compiled example ROMs through a headless puppeteer + local jsnes harness exposed four latent bugs that the header-structure-only integration tests couldn't catch: - src/asm/mod.rs: the first pass treated ANY instruction with `AddressingMode::Label` as a label definition, silently dropping every `JMP`/`JSR` to a label. Now only `NOP + Label` is a label def; other opcodes emit the opcode byte plus a 2-byte absolute fixup resolved in pass two. Without this, every example crashed with "invalid opcode at $1xxx" once the CPU fell through into the math runtime and hit an unbalanced `RTS`. - src/ir/lowering.rs (lower_handler): handler-local `VarDecl`s (e.g. `var i: u8 = 0` inside a `while`) were pushed onto `current_locals` but the handler built its own throwaway `locals` list, so those var ids never got RAM addresses and every `LoadVar`/`StoreVar` for them silently emitted nothing. Seed `current_locals` with the state's declared locals and reuse it so `lower_statement`'s appends flow through to the `IrFunction`. Fixes the black screen in `arrays_and_functions`. - src/ir/lowering.rs (global init): struct-literal initializers on globals (`var player: Player = Player { x: 120, ... }`) fell through to `eval_const`, which returned `None` for a non-literal, so no init code was emitted. Now the per-field synthetic globals each get their own `init_value`. Fixes the black screen in `structs_enums_for`. - src/codegen/mod.rs: the legacy AST codegen was emitting `JSR __fn_poke` / treating `peek` as `LDA #0` for the hardware intrinsics. It only "worked" before because the broken assembler swallowed the bogus JSR. Handle `poke`/`peek` as direct STA/LDA to a compile-time-constant absolute address, matching the IR codegen's intrinsic path. The harness lives in `tests/emulator/`: a tiny HTML page that wraps the `jsnes` npm package, driven by a puppeteer script that loads each ROM, runs ~180 frames, snapshots the canvas, and records a smoke-test verdict (booted without a CPU crash, non-zero pixels rendered, frames differ over time). `npm install && node run_examples.mjs` from `tests/emulator/` runs the full sweep. 9/9 example ROMs now load, render, and animate where expected. All 324 unit + 35 integration tests still pass. https://claude.ai/code/session_014Z5y3Q9krLcAxYpZQJhZ5V
2026-04-12 18:46:58 +00:00
results.push({
name: rom.name,
status,
bootError,
rendered,
animated,
Bug B: runtime OAM cursor so `draw` inside loops actually works `IrCodeGen::next_oam_slot` incremented at *compile time*: one `draw` statement = one fixed OAM slot, baked into absolute-mode stores at codegen. A `draw` inside a `while`/`for`/`loop` body was lowered once and then always wrote to the same four OAM bytes every iteration, so only the last iteration was ever visible. The writeup in the earlier PR called this "bug B". Fix: reserve ZP `$09` as `ZP_OAM_CURSOR`, reset it to 0 at the top of every frame handler (right after the existing OAM clear loop), and lower each `DrawSprite` IR op to: LDY $09 ; load cursor LDA <y_temp> STA $0200,Y ; sprite Y LDA #tile STA $0201,Y ; tile LDA #0 STA $0202,Y ; attr LDA <x_temp> STA $0203,Y ; sprite X INC $09 x4 ; bump cursor by 4 Cost is ~+6 bytes per `draw` over the old static form. At 64 slots the u8 cursor wraps naturally, giving classic NES "too many sprites" flicker instead of a silent compile-time drop. `next_oam_slot` and its resets are gone from the IR codegen entirely. Secondary fix: `for i in 0..N` counters are now registered as handler locals. `lower_statement` created a `VarId` for the counter via `get_or_create_var` but never pushed it onto `current_locals`, so the IR codegen's `var_addrs` lookup returned `None` for every `StoreVar(i)` / `LoadVar(i)` and silently emitted nothing. The counter stayed at 0 forever, the loop spun indefinitely, and every iteration wrote the first array element into OAM — turning all 64 sprites into the same smiley. Same class as the handler-local `var` decl bug from the earlier PR, just for for-loop variables. Smoke-test deltas (all 14/14 still pass): - arrays_and_functions: 104 -> 260 (player + 4 enemies) - bitwise_ops: 104 -> 416 (player + flag sprites + pips) - loop_break_continue: 208 -> 208 (already fixed by the earlier pass) - structs_enums_for: 104 -> 260 (player + 4 enemies) Regression tests: - `ir_codegen::more_tests::ir_codegen_draw_sprite` — checks a single `draw` emits `LDY cursor`, four `STA $020N,Y`, and four `INC cursor`. - `ir_codegen::more_tests::ir_codegen_multi_oam_uses_sequential_slots` — rewritten for the new form: each draw gets its own `LDY cursor` + 4 `INC cursor`. - `ir_codegen::more_tests::ir_codegen_draw_in_loop_...` — proves a `draw` inside a `while` compiles to ONE cursor-based draw (not N unrolled statics and not zero), and asserts no stray `STA $0204`/`$0208`/... absolute stores — those would indicate bug B has regressed. - `ir::tests::for_loop_counter_is_registered_as_handler_local` — verifies `for i in 0..N` pushes `i` onto `current_locals` so the IR codegen allocates it. Smoke-test tightening: `tests/emulator/run_examples.mjs` now has per-example `minNonBlack` floors. `arrays_and_functions`, `structs_enums_for`, `loop_break_continue`, and `bitwise_ops` all require multi-sprite rendering — if the OAM cursor bug comes back, the smoke test fails loudly instead of passing on the default `nonBlack > 0` check. The legacy AST codegen in `src/codegen/mod.rs` still uses the compile-time `next_oam_slot` approach. It's only reachable via `--use-ast`, none of the examples use it, and its integration tests only check iNES structure — left alone on purpose. https://claude.ai/code/session_014Z5y3Q9krLcAxYpZQJhZ5V
2026-04-12 20:20:20 +00:00
meetsFloor,
minNonBlack,
floorNote,
failReason,
Add jsnes emulator harness and fix four codegen bugs it surfaced Running the compiled example ROMs through a headless puppeteer + local jsnes harness exposed four latent bugs that the header-structure-only integration tests couldn't catch: - src/asm/mod.rs: the first pass treated ANY instruction with `AddressingMode::Label` as a label definition, silently dropping every `JMP`/`JSR` to a label. Now only `NOP + Label` is a label def; other opcodes emit the opcode byte plus a 2-byte absolute fixup resolved in pass two. Without this, every example crashed with "invalid opcode at $1xxx" once the CPU fell through into the math runtime and hit an unbalanced `RTS`. - src/ir/lowering.rs (lower_handler): handler-local `VarDecl`s (e.g. `var i: u8 = 0` inside a `while`) were pushed onto `current_locals` but the handler built its own throwaway `locals` list, so those var ids never got RAM addresses and every `LoadVar`/`StoreVar` for them silently emitted nothing. Seed `current_locals` with the state's declared locals and reuse it so `lower_statement`'s appends flow through to the `IrFunction`. Fixes the black screen in `arrays_and_functions`. - src/ir/lowering.rs (global init): struct-literal initializers on globals (`var player: Player = Player { x: 120, ... }`) fell through to `eval_const`, which returned `None` for a non-literal, so no init code was emitted. Now the per-field synthetic globals each get their own `init_value`. Fixes the black screen in `structs_enums_for`. - src/codegen/mod.rs: the legacy AST codegen was emitting `JSR __fn_poke` / treating `peek` as `LDA #0` for the hardware intrinsics. It only "worked" before because the broken assembler swallowed the bogus JSR. Handle `poke`/`peek` as direct STA/LDA to a compile-time-constant absolute address, matching the IR codegen's intrinsic path. The harness lives in `tests/emulator/`: a tiny HTML page that wraps the `jsnes` npm package, driven by a puppeteer script that loads each ROM, runs ~180 frames, snapshots the canvas, and records a smoke-test verdict (booted without a CPU crash, non-zero pixels rendered, frames differ over time). `npm install && node run_examples.mjs` from `tests/emulator/` runs the full sweep. 9/9 example ROMs now load, render, and animate where expected. All 324 unit + 35 integration tests still pass. https://claude.ai/code/session_014Z5y3Q9krLcAxYpZQJhZ5V
2026-04-12 18:46:58 +00:00
frames: frameHashes,
consoleErrors,
screenshot: booted ? path.relative(repoRoot, screenshotPath) : null,
});
console.log(
`${status.padEnd(4)} ${rom.name.padEnd(28)} ` +
(rendered
Bug B: runtime OAM cursor so `draw` inside loops actually works `IrCodeGen::next_oam_slot` incremented at *compile time*: one `draw` statement = one fixed OAM slot, baked into absolute-mode stores at codegen. A `draw` inside a `while`/`for`/`loop` body was lowered once and then always wrote to the same four OAM bytes every iteration, so only the last iteration was ever visible. The writeup in the earlier PR called this "bug B". Fix: reserve ZP `$09` as `ZP_OAM_CURSOR`, reset it to 0 at the top of every frame handler (right after the existing OAM clear loop), and lower each `DrawSprite` IR op to: LDY $09 ; load cursor LDA <y_temp> STA $0200,Y ; sprite Y LDA #tile STA $0201,Y ; tile LDA #0 STA $0202,Y ; attr LDA <x_temp> STA $0203,Y ; sprite X INC $09 x4 ; bump cursor by 4 Cost is ~+6 bytes per `draw` over the old static form. At 64 slots the u8 cursor wraps naturally, giving classic NES "too many sprites" flicker instead of a silent compile-time drop. `next_oam_slot` and its resets are gone from the IR codegen entirely. Secondary fix: `for i in 0..N` counters are now registered as handler locals. `lower_statement` created a `VarId` for the counter via `get_or_create_var` but never pushed it onto `current_locals`, so the IR codegen's `var_addrs` lookup returned `None` for every `StoreVar(i)` / `LoadVar(i)` and silently emitted nothing. The counter stayed at 0 forever, the loop spun indefinitely, and every iteration wrote the first array element into OAM — turning all 64 sprites into the same smiley. Same class as the handler-local `var` decl bug from the earlier PR, just for for-loop variables. Smoke-test deltas (all 14/14 still pass): - arrays_and_functions: 104 -> 260 (player + 4 enemies) - bitwise_ops: 104 -> 416 (player + flag sprites + pips) - loop_break_continue: 208 -> 208 (already fixed by the earlier pass) - structs_enums_for: 104 -> 260 (player + 4 enemies) Regression tests: - `ir_codegen::more_tests::ir_codegen_draw_sprite` — checks a single `draw` emits `LDY cursor`, four `STA $020N,Y`, and four `INC cursor`. - `ir_codegen::more_tests::ir_codegen_multi_oam_uses_sequential_slots` — rewritten for the new form: each draw gets its own `LDY cursor` + 4 `INC cursor`. - `ir_codegen::more_tests::ir_codegen_draw_in_loop_...` — proves a `draw` inside a `while` compiles to ONE cursor-based draw (not N unrolled statics and not zero), and asserts no stray `STA $0204`/`$0208`/... absolute stores — those would indicate bug B has regressed. - `ir::tests::for_loop_counter_is_registered_as_handler_local` — verifies `for i in 0..N` pushes `i` onto `current_locals` so the IR codegen allocates it. Smoke-test tightening: `tests/emulator/run_examples.mjs` now has per-example `minNonBlack` floors. `arrays_and_functions`, `structs_enums_for`, `loop_break_continue`, and `bitwise_ops` all require multi-sprite rendering — if the OAM cursor bug comes back, the smoke test fails loudly instead of passing on the default `nonBlack > 0` check. The legacy AST codegen in `src/codegen/mod.rs` still uses the compile-time `next_oam_slot` approach. It's only reachable via `--use-ast`, none of the examples use it, and its integration tests only check iNES structure — left alone on purpose. https://claude.ai/code/session_014Z5y3Q9krLcAxYpZQJhZ5V
2026-04-12 20:20:20 +00:00
? `nonBlack=${lastStats.nonBlack}/${lastStats.totalPixels} (floor=${minNonBlack}) uniqueHashes=${uniqueHashes} animated=${animated}`
Add jsnes emulator harness and fix four codegen bugs it surfaced Running the compiled example ROMs through a headless puppeteer + local jsnes harness exposed four latent bugs that the header-structure-only integration tests couldn't catch: - src/asm/mod.rs: the first pass treated ANY instruction with `AddressingMode::Label` as a label definition, silently dropping every `JMP`/`JSR` to a label. Now only `NOP + Label` is a label def; other opcodes emit the opcode byte plus a 2-byte absolute fixup resolved in pass two. Without this, every example crashed with "invalid opcode at $1xxx" once the CPU fell through into the math runtime and hit an unbalanced `RTS`. - src/ir/lowering.rs (lower_handler): handler-local `VarDecl`s (e.g. `var i: u8 = 0` inside a `while`) were pushed onto `current_locals` but the handler built its own throwaway `locals` list, so those var ids never got RAM addresses and every `LoadVar`/`StoreVar` for them silently emitted nothing. Seed `current_locals` with the state's declared locals and reuse it so `lower_statement`'s appends flow through to the `IrFunction`. Fixes the black screen in `arrays_and_functions`. - src/ir/lowering.rs (global init): struct-literal initializers on globals (`var player: Player = Player { x: 120, ... }`) fell through to `eval_const`, which returned `None` for a non-literal, so no init code was emitted. Now the per-field synthetic globals each get their own `init_value`. Fixes the black screen in `structs_enums_for`. - src/codegen/mod.rs: the legacy AST codegen was emitting `JSR __fn_poke` / treating `peek` as `LDA #0` for the hardware intrinsics. It only "worked" before because the broken assembler swallowed the bogus JSR. Handle `poke`/`peek` as direct STA/LDA to a compile-time-constant absolute address, matching the IR codegen's intrinsic path. The harness lives in `tests/emulator/`: a tiny HTML page that wraps the `jsnes` npm package, driven by a puppeteer script that loads each ROM, runs ~180 frames, snapshots the canvas, and records a smoke-test verdict (booted without a CPU crash, non-zero pixels rendered, frames differ over time). `npm install && node run_examples.mjs` from `tests/emulator/` runs the full sweep. 9/9 example ROMs now load, render, and animate where expected. All 324 unit + 35 integration tests still pass. https://claude.ai/code/session_014Z5y3Q9krLcAxYpZQJhZ5V
2026-04-12 18:46:58 +00:00
: `boot=${booted} bootError=${bootError ?? "none"}`),
);
Bug B: runtime OAM cursor so `draw` inside loops actually works `IrCodeGen::next_oam_slot` incremented at *compile time*: one `draw` statement = one fixed OAM slot, baked into absolute-mode stores at codegen. A `draw` inside a `while`/`for`/`loop` body was lowered once and then always wrote to the same four OAM bytes every iteration, so only the last iteration was ever visible. The writeup in the earlier PR called this "bug B". Fix: reserve ZP `$09` as `ZP_OAM_CURSOR`, reset it to 0 at the top of every frame handler (right after the existing OAM clear loop), and lower each `DrawSprite` IR op to: LDY $09 ; load cursor LDA <y_temp> STA $0200,Y ; sprite Y LDA #tile STA $0201,Y ; tile LDA #0 STA $0202,Y ; attr LDA <x_temp> STA $0203,Y ; sprite X INC $09 x4 ; bump cursor by 4 Cost is ~+6 bytes per `draw` over the old static form. At 64 slots the u8 cursor wraps naturally, giving classic NES "too many sprites" flicker instead of a silent compile-time drop. `next_oam_slot` and its resets are gone from the IR codegen entirely. Secondary fix: `for i in 0..N` counters are now registered as handler locals. `lower_statement` created a `VarId` for the counter via `get_or_create_var` but never pushed it onto `current_locals`, so the IR codegen's `var_addrs` lookup returned `None` for every `StoreVar(i)` / `LoadVar(i)` and silently emitted nothing. The counter stayed at 0 forever, the loop spun indefinitely, and every iteration wrote the first array element into OAM — turning all 64 sprites into the same smiley. Same class as the handler-local `var` decl bug from the earlier PR, just for for-loop variables. Smoke-test deltas (all 14/14 still pass): - arrays_and_functions: 104 -> 260 (player + 4 enemies) - bitwise_ops: 104 -> 416 (player + flag sprites + pips) - loop_break_continue: 208 -> 208 (already fixed by the earlier pass) - structs_enums_for: 104 -> 260 (player + 4 enemies) Regression tests: - `ir_codegen::more_tests::ir_codegen_draw_sprite` — checks a single `draw` emits `LDY cursor`, four `STA $020N,Y`, and four `INC cursor`. - `ir_codegen::more_tests::ir_codegen_multi_oam_uses_sequential_slots` — rewritten for the new form: each draw gets its own `LDY cursor` + 4 `INC cursor`. - `ir_codegen::more_tests::ir_codegen_draw_in_loop_...` — proves a `draw` inside a `while` compiles to ONE cursor-based draw (not N unrolled statics and not zero), and asserts no stray `STA $0204`/`$0208`/... absolute stores — those would indicate bug B has regressed. - `ir::tests::for_loop_counter_is_registered_as_handler_local` — verifies `for i in 0..N` pushes `i` onto `current_locals` so the IR codegen allocates it. Smoke-test tightening: `tests/emulator/run_examples.mjs` now has per-example `minNonBlack` floors. `arrays_and_functions`, `structs_enums_for`, `loop_break_continue`, and `bitwise_ops` all require multi-sprite rendering — if the OAM cursor bug comes back, the smoke test fails loudly instead of passing on the default `nonBlack > 0` check. The legacy AST codegen in `src/codegen/mod.rs` still uses the compile-time `next_oam_slot` approach. It's only reachable via `--use-ast`, none of the examples use it, and its integration tests only check iNES structure — left alone on purpose. https://claude.ai/code/session_014Z5y3Q9krLcAxYpZQJhZ5V
2026-04-12 20:20:20 +00:00
if (failReason && !rendered) {
console.log(` reason: ${failReason}`);
} else if (failReason) {
console.log(` reason: ${failReason}`);
}
Add jsnes emulator harness and fix four codegen bugs it surfaced Running the compiled example ROMs through a headless puppeteer + local jsnes harness exposed four latent bugs that the header-structure-only integration tests couldn't catch: - src/asm/mod.rs: the first pass treated ANY instruction with `AddressingMode::Label` as a label definition, silently dropping every `JMP`/`JSR` to a label. Now only `NOP + Label` is a label def; other opcodes emit the opcode byte plus a 2-byte absolute fixup resolved in pass two. Without this, every example crashed with "invalid opcode at $1xxx" once the CPU fell through into the math runtime and hit an unbalanced `RTS`. - src/ir/lowering.rs (lower_handler): handler-local `VarDecl`s (e.g. `var i: u8 = 0` inside a `while`) were pushed onto `current_locals` but the handler built its own throwaway `locals` list, so those var ids never got RAM addresses and every `LoadVar`/`StoreVar` for them silently emitted nothing. Seed `current_locals` with the state's declared locals and reuse it so `lower_statement`'s appends flow through to the `IrFunction`. Fixes the black screen in `arrays_and_functions`. - src/ir/lowering.rs (global init): struct-literal initializers on globals (`var player: Player = Player { x: 120, ... }`) fell through to `eval_const`, which returned `None` for a non-literal, so no init code was emitted. Now the per-field synthetic globals each get their own `init_value`. Fixes the black screen in `structs_enums_for`. - src/codegen/mod.rs: the legacy AST codegen was emitting `JSR __fn_poke` / treating `peek` as `LDA #0` for the hardware intrinsics. It only "worked" before because the broken assembler swallowed the bogus JSR. Handle `poke`/`peek` as direct STA/LDA to a compile-time-constant absolute address, matching the IR codegen's intrinsic path. The harness lives in `tests/emulator/`: a tiny HTML page that wraps the `jsnes` npm package, driven by a puppeteer script that loads each ROM, runs ~180 frames, snapshots the canvas, and records a smoke-test verdict (booted without a CPU crash, non-zero pixels rendered, frames differ over time). `npm install && node run_examples.mjs` from `tests/emulator/` runs the full sweep. 9/9 example ROMs now load, render, and animate where expected. All 324 unit + 35 integration tests still pass. https://claude.ai/code/session_014Z5y3Q9krLcAxYpZQJhZ5V
2026-04-12 18:46:58 +00:00
if (consoleErrors.length > 0) {
for (const e of consoleErrors) console.log(" console:", e);
}
await page.close();
}
} finally {
await browser.close();
}
const reportPath = path.join(__dirname, "report.json");
await fs.writeFile(reportPath, JSON.stringify({ generatedAt: new Date().toISOString(), results }, null, 2));
console.log(`\nreport written to ${path.relative(repoRoot, reportPath)}`);
console.log(`${results.length - failures}/${results.length} ROMs rendered successfully`);
if (failures > 0) process.exit(1);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});