1
0
Fork 0
mirror of https://github.com/imjasonh/nescript synced 2026-07-08 08:55:38 +00:00
nescript/tests/emulator/run_examples.mjs
Claude 54acb9ee38
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

187 lines
6.6 KiB
JavaScript

// 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;
// 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"],
};
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) }));
}
function floorFor(name) {
const entry = EXAMPLE_FLOORS[name];
if (entry) return entry;
return [DEFAULT_MIN_NON_BLACK, "generic single-sprite floor"];
}
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;
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})`;
}
results.push({
name: rom.name,
status,
bootError,
rendered,
animated,
meetsFloor,
minNonBlack,
floorNote,
failReason,
frames: frameHashes,
consoleErrors,
screenshot: booted ? path.relative(repoRoot, screenshotPath) : null,
});
console.log(
`${status.padEnd(4)} ${rom.name.padEnd(28)} ` +
(rendered
? `nonBlack=${lastStats.nonBlack}/${lastStats.totalPixels} (floor=${minNonBlack}) uniqueHashes=${uniqueHashes} animated=${animated}`
: `boot=${booted} bootError=${bootError ?? "none"}`),
);
if (failReason && !rendered) {
console.log(` reason: ${failReason}`);
} else if (failReason) {
console.log(` reason: ${failReason}`);
}
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);
});