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
|
|
@ -80,8 +80,31 @@ impl Assembler {
|
|||
fn emit_instruction(&mut self, inst: &Instruction) {
|
||||
match &inst.mode {
|
||||
AddressingMode::Label(name) => {
|
||||
// This should be a label definition, not an instruction
|
||||
self.labels.insert(name.clone(), self.current_address());
|
||||
// A `NOP` with a `Label` mode is the label-definition
|
||||
// pseudo-instruction: it records the current address and
|
||||
// emits no bytes. Any other opcode paired with `Label` is
|
||||
// an actual jump-style instruction targeting that label
|
||||
// (e.g. `JMP __ir_main_loop`, `JSR __ir_fn_frame`), which
|
||||
// needs an opcode byte plus a 2-byte absolute-address
|
||||
// fixup resolved in the second pass.
|
||||
if inst.opcode == Opcode::NOP {
|
||||
self.labels.insert(name.clone(), self.current_address());
|
||||
} else if let Some(op) = opcodes::encode(inst.opcode, &AddressingMode::Absolute(0))
|
||||
{
|
||||
self.output.push(op);
|
||||
self.fixups.push(Fixup {
|
||||
offset: self.output.len(),
|
||||
label: name.clone(),
|
||||
kind: FixupKind::Absolute,
|
||||
});
|
||||
self.output.push(0); // placeholder low byte
|
||||
self.output.push(0); // placeholder high byte
|
||||
} else {
|
||||
panic!(
|
||||
"opcode {:?} cannot target a label (no absolute encoding)",
|
||||
inst.opcode
|
||||
);
|
||||
}
|
||||
}
|
||||
AddressingMode::LabelRelative(name) => {
|
||||
// Branch to label — emit opcode + placeholder
|
||||
|
|
|
|||
|
|
@ -362,6 +362,19 @@ impl CodeGen {
|
|||
}
|
||||
}
|
||||
Statement::Call(name, args, _) => {
|
||||
// Built-in `poke(addr, value)` writes `value` to the
|
||||
// compile-time-constant `addr`. The IR codegen handles
|
||||
// this intrinsic in ir/lowering.rs; the legacy AST
|
||||
// codegen has to recognize it here too, otherwise it
|
||||
// falls through to the generic JSR path and tries to
|
||||
// call a non-existent `__fn_poke` routine.
|
||||
if name == "poke" && args.len() == 2 {
|
||||
if let Some(addr) = const_u16(&args[0]) {
|
||||
self.gen_expr(&args[1]);
|
||||
self.emit(STA, AM::Absolute(addr));
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Pass arguments via zero-page param slots ($04-$07)
|
||||
for (i, arg) in args.iter().enumerate() {
|
||||
self.gen_expr(arg);
|
||||
|
|
@ -868,9 +881,20 @@ impl CodeGen {
|
|||
self.emit_load(addr);
|
||||
}
|
||||
}
|
||||
Expr::Call(_, _, _) => {
|
||||
// Function calls as expressions need JSR — handled elsewhere
|
||||
// For now, result is 0
|
||||
Expr::Call(name, args, _) => {
|
||||
// Built-in `peek(addr)` reads a byte from the
|
||||
// compile-time-constant `addr`. Matches the IR codegen's
|
||||
// `Peek` handling so the legacy AST path doesn't emit a
|
||||
// bogus JSR to a non-existent `__fn_peek` routine.
|
||||
if name == "peek" && args.len() == 1 {
|
||||
if let Some(addr) = const_u16(&args[0]) {
|
||||
self.emit(LDA, AM::Absolute(addr));
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Other function calls as expressions would need JSR
|
||||
// with a return-value slot; the legacy codegen doesn't
|
||||
// implement that yet, so fall back to loading 0.
|
||||
self.emit(LDA, AM::Immediate(0));
|
||||
}
|
||||
Expr::ArrayLiteral(_, _) => {
|
||||
|
|
@ -1122,3 +1146,14 @@ fn button_mask(button: &str) -> u8 {
|
|||
_ => 0x00,
|
||||
}
|
||||
}
|
||||
|
||||
/// Return the compile-time-constant u16 value of an expression, or
|
||||
/// `None` if it's not a plain integer literal. Used by the intrinsic
|
||||
/// call handling to recognize `poke(0x2007, …)` and `peek(0x2002)`
|
||||
/// at codegen time.
|
||||
fn const_u16(expr: &Expr) -> Option<u16> {
|
||||
match expr {
|
||||
Expr::IntLiteral(v, _) => Some(*v),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -190,6 +190,10 @@ impl LoweringContext {
|
|||
}
|
||||
|
||||
// Lower globals. Initializers can be any constant expression.
|
||||
// Struct-literal initializers are expanded into per-field
|
||||
// globals so each field gets its own `init_value`; the parent
|
||||
// struct itself is still registered (size=0) so any later IR
|
||||
// op referencing it by name still resolves.
|
||||
for var in &program.globals {
|
||||
let var_id = self.get_or_create_var(&var.name);
|
||||
let init = var.init.as_ref().and_then(|e| self.eval_const(e));
|
||||
|
|
@ -199,6 +203,19 @@ impl LoweringContext {
|
|||
size: type_size(&var.var_type),
|
||||
init_value: init,
|
||||
});
|
||||
if let Some(Expr::StructLiteral(_, fields, _)) = &var.init {
|
||||
for (fname, fexpr) in fields {
|
||||
let full = format!("{}.{fname}", var.name);
|
||||
let fvid = self.get_or_create_var(&full);
|
||||
let fval = self.eval_const(fexpr);
|
||||
self.globals.push(IrGlobal {
|
||||
var_id: fvid,
|
||||
name: full,
|
||||
size: 1,
|
||||
init_value: fval,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Lower user functions
|
||||
|
|
@ -278,12 +295,20 @@ impl LoweringContext {
|
|||
fn lower_handler(&mut self, name: &str, block: &Block, state: &StateDecl) {
|
||||
self.next_temp = 0;
|
||||
self.current_blocks = Vec::new();
|
||||
let mut locals = Vec::new();
|
||||
|
||||
// Register state-local variables
|
||||
// Seed `current_locals` with the state's declared locals so any
|
||||
// `VarDecl` inside the handler body — tracked by
|
||||
// `lower_statement` via `current_locals` — is appended alongside
|
||||
// them. Without this, handler-local variables (e.g. a `var i`
|
||||
// inside a `while`) would get orphaned: their `VarId` would be
|
||||
// created by `get_or_create_var`, but the `IrFunction`'s
|
||||
// `locals` list (which the IR codegen uses to allocate RAM
|
||||
// addresses) would never see them. The result would be a
|
||||
// silent `LoadVar`/`StoreVar` emit-nothing bug that leaves the
|
||||
// temp slots uninitialized at runtime.
|
||||
self.current_locals = Vec::new();
|
||||
for var in &state.locals {
|
||||
let var_id = self.get_or_create_var(&var.name);
|
||||
locals.push(IrLocal {
|
||||
self.current_locals.push(IrLocal {
|
||||
var_id,
|
||||
name: var.name.clone(),
|
||||
size: type_size(&var.var_type),
|
||||
|
|
@ -298,7 +323,7 @@ impl LoweringContext {
|
|||
self.functions.push(IrFunction {
|
||||
name: name.to_string(),
|
||||
blocks: std::mem::take(&mut self.current_blocks),
|
||||
locals,
|
||||
locals: std::mem::take(&mut self.current_locals),
|
||||
param_count: 0,
|
||||
has_return: false,
|
||||
source_span: state.span,
|
||||
|
|
|
|||
2
tests/emulator/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
node_modules/
|
||||
report.json
|
||||
87
tests/emulator/harness.html
Normal 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
14
tests/emulator/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
139
tests/emulator/run_examples.mjs
Normal 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);
|
||||
});
|
||||
BIN
tests/emulator/screenshots/arrays_and_functions.png
Normal file
|
After Width: | Height: | Size: 828 B |
BIN
tests/emulator/screenshots/bouncing_ball.png
Normal file
|
After Width: | Height: | Size: 817 B |
BIN
tests/emulator/screenshots/coin_cavern.png
Normal file
|
After Width: | Height: | Size: 816 B |
BIN
tests/emulator/screenshots/hello_sprite.png
Normal file
|
After Width: | Height: | Size: 817 B |
BIN
tests/emulator/screenshots/inline_asm_demo.png
Normal file
|
After Width: | Height: | Size: 811 B |
BIN
tests/emulator/screenshots/mmc1_banked.png
Normal file
|
After Width: | Height: | Size: 822 B |
BIN
tests/emulator/screenshots/sprites_and_palettes.png
Normal file
|
After Width: | Height: | Size: 821 B |
BIN
tests/emulator/screenshots/state_machine.png
Normal file
|
After Width: | Height: | Size: 816 B |
BIN
tests/emulator/screenshots/structs_enums_for.png
Normal file
|
After Width: | Height: | Size: 816 B |