aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorgbc dev <gbc@localhost>2026-09-22 15:57:06 +0200
committergbc dev <gbc@localhost>2026-09-22 15:57:06 +0200
commit6fb4fbbce99c08bd535d1b7e0f356864fb0c38a0 (patch)
tree7f0b249f4cfe640d80a7848d9012dc695d42d5c3 /src
parentcore: make the machine loadable without a filesystem (diff)
downloadsl0pboy-6fb4fbbce99c08bd535d1b7e0f356864fb0c38a0.tar.gz
sl0pboy-6fb4fbbce99c08bd535d1b7e0f356864fb0c38a0.tar.xz
sl0pboy-6fb4fbbce99c08bd535d1b7e0f356864fb0c38a0.zip
wasm: run the emulator in a browser
`make wasm` builds the core to WebAssembly (~44KB stripped) and web/ is a front-end for it: drag-drop a ROM, pixelated canvas at any zoom, keyboard mapped like the native build, battery saves in localStorage. Built with zig cc, which carries its own wasm backend, linker and wasi-libc - so no emscripten dependency, just zig on PATH. Only the portable core goes in; render/kitty/control/main are terminal- and POSIX-bound and stay out. src/wasm.c is the browser's whole host layer: no terminal, no sockets, no files, and no frame pacing (the page's requestAnimationFrame owns that). The machine is static, the RGB888 -> RGBA conversion for putImageData happens in wasm, and nothing allocates per frame. The entire host interface is four WASI fd_* imports, pulled in by the cart info banner's fprintf; the page shims them to console.log in ~20 lines. Cartridge RAM *is* the save file here, so it's exported for the page to persist per cart title (every 5s and on unload). Because both builds now advance on the same frame boundary, the port is checked byte-for-byte against the native binary rather than by eyeballing a canvas - tools/wasm_check.mjs runs N frames in node and diffs the framebuffer against a --shot PPM: cgb-acid2 @60, dmg-acid2 @60, cpu_instrs @600, mem_timing-2 @700, pky.gbc @400 with an imported .sav, cgb-acid2 @120 through the full CGB BIOS boot animation -> all pixel-identical Also smoke-tested in headless chromium over CDP: core instantiates, ROM loads through the file input, canvas animates (7 distinct frames over 5.6s), keyboard reaches the joypad, 60fps, and a 32KB save lands in localStorage. No audio: the APU is still stubbed.
Diffstat (limited to 'src')
-rw-r--r--src/wasm.c126
1 files changed, 126 insertions, 0 deletions
diff --git a/src/wasm.c b/src/wasm.c
new file mode 100644
index 0000000..e1051a1
--- /dev/null
+++ b/src/wasm.c
@@ -0,0 +1,126 @@
+// ---- wasm entry point: the browser's "main" -------------------------------
+//
+// The native build has render.c / kitty.c / control.c / main.c wrapped around
+// the core; a browser has none of that. This file is the whole host layer for
+// the wasm target: no terminal, no sockets, no files, no frame pacing (the
+// page's requestAnimationFrame owns that). It just exposes the core as a
+// handful of C functions the page can call, plus two buffers it can read.
+//
+// The contract, in the order a page uses it:
+// _initialize() once, libc + ctors (wasi reactor model)
+// p = gbw_alloc(len) get a staging buffer for the ROM bytes
+// gbw_load(p, len) parse cart + reset (0 = ok)
+// gbw_free(p) staging buffer is no longer needed
+// gbw_set_buttons(mask) BTN_* bits, 1 = pressed
+// gbw_run_frame() advance exactly one frame
+// gbw_framebuffer_rgba() 160*144*4 bytes, ready for putImageData
+//
+// Everything hands back a pointer into the wasm linear memory; the page reads
+// it through a typed array view. Nothing here allocates per frame.
+
+#include "gb.h"
+#include "cart.h"
+#include <stdlib.h>
+#include <string.h>
+
+#define EXPORT(name) __attribute__((export_name(#name))) name
+
+// One machine, statically allocated: a GB is ~120KB of arrays, and keeping it
+// out of the heap means the ROM upload is the only thing that ever mallocs.
+static GB gb;
+static bool loaded = false;
+
+// The PPU draws RGB888, but canvas ImageData wants RGBA8888. Converting here
+// costs one pass over 23k pixels in wasm instead of a per-pixel loop in JS.
+static u8 fb_rgba[SCREEN_H * SCREEN_W * 4];
+
+// ---- memory handoff -------------------------------------------------------
+// The page can't write into wasm memory without somewhere to put things.
+void *EXPORT(gbw_alloc)(int n) { return malloc((size_t)n); }
+void EXPORT(gbw_free)(void *p) { free(p); }
+
+// ---- lifecycle ------------------------------------------------------------
+
+// Parse a ROM image and reset the machine. Returns 0 on success.
+// Safe to call again to swap games; the old cart is released first.
+int EXPORT(gbw_load)(const u8 *rom, int len) {
+ if (loaded) {
+ cart_free(&gb.cart); // no-op battery save in this build
+ loaded = false;
+ }
+ Cart c;
+ if (cart_load_mem(&c, rom, (size_t)len) != 0) return -1;
+ gb_init(&gb); // zeroes the machine, keeps any boot ROM
+ gb.cart = c; // install the cart, then boot into it
+ gb_reset(&gb);
+ loaded = true;
+ return 0;
+}
+
+// Install a boot ROM (256B DMG / 2304B CGB) so the next load boots through the
+// power-on animation. Must be called before gbw_load. Returns 0 on success.
+int EXPORT(gbw_load_bootrom)(const u8 *data, int len) {
+ return gb_load_bootrom_mem(&gb, data, len);
+}
+
+void EXPORT(gbw_reset)(void) {
+ if (loaded) gb_reset(&gb);
+}
+
+int EXPORT(gbw_is_loaded)(void) { return loaded ? 1 : 0; }
+
+// ---- running --------------------------------------------------------------
+
+// Advance exactly one frame (to the next VBlank). The page calls this from
+// requestAnimationFrame; the emulator itself does no timing.
+void EXPORT(gbw_run_frame)(void) {
+ if (loaded) gb_run_frame(&gb);
+}
+
+// Pressed-button bitmask (BTN_A | BTN_LEFT | ...), 1 = pressed. Unlike the
+// terminal, a browser reports key-release, so this is just the live state.
+void EXPORT(gbw_set_buttons)(int mask) { gb.buttons = (u8)mask; }
+
+// ---- reading the screen ---------------------------------------------------
+
+// Convert the PPU framebuffer to RGBA and return it. 160*144*4 bytes.
+const u8 *EXPORT(gbw_framebuffer_rgba)(void) {
+ const u8 *src = &gb.ppu.fb[0][0][0];
+ u8 *dst = fb_rgba;
+ for (int i = 0; i < SCREEN_W * SCREEN_H; i++) {
+ *dst++ = *src++;
+ *dst++ = *src++;
+ *dst++ = *src++;
+ *dst++ = 0xFF;
+ }
+ return fb_rgba;
+}
+
+// Raw RGB888 straight out of the PPU (160*144*3) - what --shot writes, so the
+// golden-frame test can compare it byte for byte against a native PPM.
+const u8 *EXPORT(gbw_framebuffer_rgb)(void) { return &gb.ppu.fb[0][0][0]; }
+
+int EXPORT(gbw_screen_w)(void) { return SCREEN_W; }
+int EXPORT(gbw_screen_h)(void) { return SCREEN_H; }
+
+// ---- cart info & battery saves --------------------------------------------
+
+const char *EXPORT(gbw_title)(void) { return gb.cart.title; }
+int EXPORT(gbw_is_cgb)(void) { return gb.cart.cgb ? 1 : 0; }
+int EXPORT(gbw_has_battery)(void) { return gb.cart.has_battery ? 1 : 0; }
+
+// Cartridge RAM, for the page to persist (IndexedDB/localStorage) and restore.
+// There is no .sav file in a browser, so this *is* the save.
+u8 *EXPORT(gbw_sram_ptr)(void) { return gb.cart.ram; }
+int EXPORT(gbw_sram_size)(void) { return (int)gb.cart.ram_size; }
+
+// Overwrite cartridge RAM from a previously saved blob. Returns bytes copied;
+// a size mismatch copies nothing (wrong game, or a different MBC layout).
+int EXPORT(gbw_sram_load)(const u8 *data, int len) {
+ if (!gb.cart.ram || (size_t)len != gb.cart.ram_size) return 0;
+ memcpy(gb.cart.ram, data, (size_t)len);
+ return len;
+}
+
+// Total T-cycles since power-on; handy for pacing checks and debugging.
+double EXPORT(gbw_cycles)(void) { return (double)gb.cycles; }