#!/usr/bin/env node // Golden-frame test for the wasm build. // // The point: prove the browser core is the *same emulator* as the native one, // not "looks about right". It runs a ROM for N frames in wasm and compares the // framebuffer byte-for-byte against a PPM written by the native binary: // // ./build/sl0pboy --shot 60 /tmp/n.ppm roms/cgb-acid2.gbc // node tools/wasm_check.mjs roms/cgb-acid2.gbc 60 --check /tmp/n.ppm // // Both sides run the identical loop (gb_run_frame in gb.c), so any mismatch is // a real portability bug - endianness, struct layout, UB the two backends // resolved differently - and not a rendering difference. // // Also doubles as a PPM dumper: ... 60 -o /tmp/w.ppm import { readFileSync, writeFileSync } from "node:fs"; const argv = process.argv.slice(2); if (argv.length < 1) { console.error("usage: wasm_check.mjs [frames] [--check ref.ppm] [-o out.ppm]"); console.error(" [--wasm path] [--sram file] [--bios file]"); process.exit(2); } const romPath = argv[0]; const frames = argv[1] && !argv[1].startsWith("-") ? parseInt(argv[1], 10) : 60; const flag = (n) => { const i = argv.indexOf(n); return i >= 0 ? argv[i + 1] : null; }; const checkPath = flag("--check"); const outPath = flag("-o"); const wasmPath = flag("--wasm") || new URL("../web/sl0pboy.wasm", import.meta.url).pathname; const sramPath = flag("--sram"); const biosPath = flag("--bios"); // ---- the entire WASI surface this module uses ---------------------------- // Four fd_* imports, pulled in by the diagnostic fprintf(stderr) in cart.c. // stdout/stderr go to the console; everything else is a closed file. const WASI_EBADF = 8; let memory; const text = new TextDecoder(); const wasi = { fd_write(fd, iovs, iovsLen, nwritten) { const view = new DataView(memory.buffer); let written = 0, out = []; for (let i = 0; i < iovsLen; i++) { const ptr = view.getUint32(iovs + i * 8, true); const len = view.getUint32(iovs + i * 8 + 4, true); out.push(new Uint8Array(memory.buffer, ptr, len)); written += len; } const blob = Buffer.concat(out.map(Buffer.from)); if (fd === 1 || fd === 2) process.stderr.write(text.decode(blob)); view.setUint32(nwritten, written, true); return 0; }, fd_read: () => WASI_EBADF, fd_seek: () => WASI_EBADF, fd_close: () => WASI_EBADF, }; const bytes = readFileSync(wasmPath); const { instance } = await WebAssembly.instantiate(bytes, { wasi_snapshot_preview1: wasi, }); const ex = instance.exports; memory = ex.memory; ex._initialize(); // reactor model: libc + ctors before anything else // Copy a Buffer into wasm linear memory, returning [ptr, len] to free later. const intoWasm = (buf) => { const ptr = ex.gbw_alloc(buf.length); if (!ptr) throw new Error("gbw_alloc failed"); new Uint8Array(memory.buffer, ptr, buf.length).set(buf); return ptr; }; if (biosPath) { const b = readFileSync(biosPath); const p = intoWasm(b); if (ex.gbw_load_bootrom(p, b.length) !== 0) throw new Error("bootrom rejected"); ex.gbw_free(p); } const rom = readFileSync(romPath); const romPtr = intoWasm(rom); if (ex.gbw_load(romPtr, rom.length) !== 0) throw new Error("gbw_load failed"); ex.gbw_free(romPtr); if (sramPath) { const s = readFileSync(sramPath); const p = intoWasm(s); const n = ex.gbw_sram_load(p, s.length); ex.gbw_free(p); if (n !== s.length) throw new Error(`sram rejected: ${s.length}B given, cart wants ${ex.gbw_sram_size()}B`); } const cstr = (ptr) => { const m = new Uint8Array(memory.buffer); let e = ptr; while (m[e]) e++; return text.decode(m.subarray(ptr, e)); }; console.error(`wasm: '${cstr(ex.gbw_title())}' cgb=${ex.gbw_is_cgb()} ` + `battery=${ex.gbw_has_battery()} sram=${ex.gbw_sram_size()}B`); for (let f = 0; f < frames; f++) ex.gbw_run_frame(); const w = ex.gbw_screen_w(), h = ex.gbw_screen_h(); // Copy out: any later wasm call could grow (and thus detach) the buffer. const fb = Buffer.from(new Uint8Array(memory.buffer, ex.gbw_framebuffer_rgb(), w * h * 3)); console.error(`ran ${frames} frames, ${ex.gbw_cycles()} cycles, fb ${w}x${h}`); if (outPath) { writeFileSync(outPath, Buffer.concat([Buffer.from(`P6\n${w} ${h}\n255\n`), fb])); console.error(`wrote ${outPath}`); } if (checkPath) { const ref = readFileSync(checkPath); // P6 header: magic, width, height, maxval - then binary RGB. const m = /^P6\s+(\d+)\s+(\d+)\s+(\d+)\s/.exec(ref.subarray(0, 64).toString("latin1")); if (!m) { console.error(`FAIL: ${checkPath} is not a P6 PPM`); process.exit(1); } const [rw, rh] = [parseInt(m[1]), parseInt(m[2])]; const pix = ref.subarray(m[0].length); if (rw !== w || rh !== h) { console.error(`FAIL: size ${rw}x${rh} (ref) vs ${w}x${h} (wasm)`); process.exit(1); } if (!pix.subarray(0, fb.length).equals(fb)) { let diff = 0, first = -1; for (let i = 0; i < fb.length; i++) if (pix[i] !== fb[i]) { diff++; if (first < 0) first = i; } const px = Math.floor(first / 3); console.error(`FAIL: ${diff}/${fb.length} bytes differ; first at byte ${first} ` + `(pixel ${px % w},${Math.floor(px / w)}) ref=${pix[first]} wasm=${fb[first]}`); process.exit(1); } console.error(`OK: wasm frame ${frames} matches ${checkPath} exactly (${w}x${h})`); }