1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
|
#!/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 <rom> [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})`);
}
|