1
0
Fork 0
mirror of https://github.com/imjasonh/nescript synced 2026-07-17 06:02:01 +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
This commit is contained in:
Claude 2026-04-12 18:46:58 +00:00
parent 593a333eec
commit 81f3fd7de0
No known key found for this signature in database
17 changed files with 1479 additions and 10 deletions

2
tests/emulator/.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
node_modules/
report.json

View file

@ -0,0 +1,87 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>NEScript jsnes harness</title>
<style>
body { margin: 0; background: #222; color: #eee; font-family: monospace; }
canvas { image-rendering: pixelated; background: #000; display: block; }
#info { padding: 8px; }
</style>
</head>
<body>
<canvas id="screen" width="256" height="240"></canvas>
<div id="info">waiting…</div>
<script src="./node_modules/jsnes/dist/jsnes.js"></script>
<script>
(function () {
const { NES } = window.jsnes;
const canvas = document.getElementById("screen");
const ctx = canvas.getContext("2d");
const image = ctx.createImageData(256, 240);
// Pre-fill alpha channel so we don't have to do it every frame.
const buf = new ArrayBuffer(image.data.length);
const buf8 = new Uint8ClampedArray(buf);
const buf32 = new Uint32Array(buf);
for (let i = 0; i < buf32.length; i++) buf32[i] = 0xff000000;
const nes = new NES({
onFrame(frameBuffer) {
for (let i = 0; i < 256 * 240; i++) {
// jsnes pixel is 0x00BBGGRR in little-endian = 0xFFBBGGRR when alpha set
buf32[i] = 0xff000000 | frameBuffer[i];
}
image.data.set(buf8);
ctx.putImageData(image, 0, 0);
},
// Silence audio; we are only checking video.
onAudioSample() {},
sampleRate: 44100,
});
// Expose an API for puppeteer to drive.
window.nesHarness = {
loadRomBase64(b64) {
// atob produces a binary string, which is exactly what jsnes wants.
const bin = atob(b64);
nes.loadROM(bin);
},
frame() {
nes.frame();
},
runFrames(n) {
for (let i = 0; i < n; i++) nes.frame();
},
buttonDown(player, button) {
nes.buttonDown(player, button);
},
buttonUp(player, button) {
nes.buttonUp(player, button);
},
// Return a hash and non-zero-pixel count for the current canvas contents.
frameStats() {
const data = ctx.getImageData(0, 0, 256, 240).data;
let nonBlack = 0;
let hash = 2166136261 >>> 0; // FNV-1a
for (let i = 0; i < data.length; i += 4) {
const r = data[i], g = data[i + 1], b = data[i + 2];
if (r !== 0 || g !== 0 || b !== 0) nonBlack++;
hash ^= r; hash = Math.imul(hash, 16777619);
hash ^= g; hash = Math.imul(hash, 16777619);
hash ^= b; hash = Math.imul(hash, 16777619);
}
return { nonBlack, hash: (hash >>> 0).toString(16), totalPixels: 256 * 240 };
},
};
// Controller button enum constants (from jsnes Controller).
window.nesButtons = {
A: 0, B: 1, SELECT: 2, START: 3,
UP: 4, DOWN: 5, LEFT: 6, RIGHT: 7,
};
document.getElementById("info").textContent = "ready";
})();
</script>
</body>
</html>

1144
tests/emulator/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,14 @@
{
"name": "nescript-emulator-tests",
"version": "1.0.0",
"description": "Local jsnes + puppeteer smoke test for compiled NEScript example ROMs.",
"private": true,
"type": "module",
"scripts": {
"test": "node run_examples.mjs"
},
"dependencies": {
"jsnes": "^2.1.0",
"puppeteer": "^24.40.0"
}
}

View file

@ -0,0 +1,139 @@
// 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;
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) }));
}
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 firstStats = frameHashes[0];
const uniqueHashes = new Set(frameHashes.map((h) => h.hash)).size;
const rendered = booted && lastStats && lastStats.nonBlack > 0;
const animated = uniqueHashes > 1;
const status = rendered ? "OK" : "FAIL";
if (!rendered) failures++;
results.push({
name: rom.name,
status,
bootError,
rendered,
animated,
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} uniqueHashes=${uniqueHashes} animated=${animated}`
: `boot=${booted} bootError=${bootError ?? "none"}`),
);
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);
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 828 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 817 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 816 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 817 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 811 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 822 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 821 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 816 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 816 B