diff options
| author | gbc dev <gbc@localhost> | 2026-09-22 19:54:55 +0200 |
|---|---|---|
| committer | gbc dev <gbc@localhost> | 2026-09-22 19:54:55 +0200 |
| commit | ff3b97be10ad5f8cf87a43dadd71daf5e284a82f (patch) | |
| tree | 420b81c74d20e5f9e3dbf69ebe35978df2e644ff | |
| parent | web: network lab - several Game Boys on a virtual switch, in the page (diff) | |
| download | sl0pboy-ff3b97be10ad5f8cf87a43dadd71daf5e284a82f.tar.gz sl0pboy-ff3b97be10ad5f8cf87a43dadd71daf5e284a82f.tar.xz sl0pboy-ff3b97be10ad5f8cf87a43dadd71daf5e284a82f.zip | |
web: selecting a Game Boy puts the caret in its console
Typing commands is the main thing you do in the lab, so selecting a machine
now focuses its input instead of making you click twice. Clicking anywhere on
a card selects it; the input takes the caret.
The catch is that the console swallows the keyboard - 'z' and 'x' are letters
before they are A and B - so there are two ways back to the joypad: esc, or
clicking the Game Boy's screen (which is where you'd reach for it anyway).
Both are spelled out in the header and the input's placeholder.
Switching machines also releases any button still held on the one you left,
which previously could stick down across a focus change.
Also lands the page swap: the network lab is the landing page and the
single-Game-Boy player moved to /play.html, with /net.html 301'd in nginx.
Verified in headless Chrome: focus follows add and click, the screen and esc
both blur back, SELECT still opens the on-screen keyboard afterwards
(20 -> 491 lit pixels), and typing 'uptime' into a card still answers.
| -rw-r--r-- | README.md | 8 | ||||
| -rw-r--r-- | web/index.html | 478 | ||||
| -rw-r--r-- | web/net.html | 300 | ||||
| -rw-r--r-- | web/play.html | 276 |
4 files changed, 541 insertions, 521 deletions
@@ -420,8 +420,10 @@ building anything: ```sh make wasm # -> web/sl0pboy.wasm (~44KB) +cp ~/dev/gbos/gbos.gb web/roms/ # the demo ROM (gitignored; deploy.sh ships it) cd web && python3 -m http.server 8000 -# open http://localhost:8000 and drop in a .gb/.gbc +# / the network lab: several Game Boys on a virtual switch +# /play.html one Game Boy, drop in your own .gb/.gbc ``` Built with `zig cc` (no emscripten): it carries its own wasm backend, linker @@ -454,9 +456,9 @@ skips ahead instead of running hundreds of frames in one burst. No audio — the APU is still stubbed (see below). -### The network lab (`web/net.html`) +### The network lab (`web/index.html`) -`/net.html` runs *several* Game Boys in one tab, wired to a virtual switch: +The landing page runs *several* Game Boys in one tab, wired to a virtual switch: each one boots gbos, gets a DHCP lease, and they ping each other. Plug one in, plug in another, unplug either; addresses go back to the pool and get reused. diff --git a/web/index.html b/web/index.html index e3b0de3..d182b57 100644 --- a/web/index.html +++ b/web/index.html @@ -3,274 +3,316 @@ <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width,initial-scale=1"> -<title>sl0pboy — a Game Boy Color emulator in your browser</title> +<title>sl0pboy network lab — Game Boys on a virtual switch</title> <style> - :root { --bg:#12121a; --fg:#d7d7e0; --dim:#6f6f85; --accent:#9bbc0f; } - * { box-sizing: border-box; } + :root { --bg:#12121a; --panel:#181823; --line:#2b2b3d; --fg:#d7d7e0; --dim:#6f6f85; --accent:#9bbc0f; --wire:#4f8ef7; } + * { box-sizing:border-box; } body { - margin:0; min-height:100vh; background:var(--bg); color:var(--fg); - font:14px/1.5 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; - display:flex; flex-direction:column; align-items:center; justify-content:center; - gap:14px; padding:20px; + margin:0; min-height:100vh; background:var(--bg); color:var(--fg); padding:18px; + font:13px/1.5 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; } - h1 { font-size:15px; font-weight:600; margin:0; letter-spacing:.14em; text-transform:uppercase; } + h1 { font-size:14px; letter-spacing:.14em; text-transform:uppercase; margin:0 0 2px; } h1 span { color:var(--accent); } - #screen { - /* the PPU's 160x144 must stay crisp at any zoom, never smoothed */ - image-rendering: pixelated; image-rendering: crisp-edges; - /* Height-aware: 160/144 means width = height * 1.111, so capping width at - 71vh keeps the whole LCD on screen in a short/landscape window. */ - background:#000; border-radius:6px; width:min(640px,92vw,71vh); aspect-ratio:160/144; - box-shadow:0 0 0 10px #22222e, 0 0 0 11px #2e2e3d, 0 18px 50px #0008; - touch-action:none; + .sub { color:var(--dim); margin-bottom:14px; font-size:12px; } + .sub a { color:var(--wire); } + + .switch { + display:flex; align-items:center; gap:16px; flex-wrap:wrap; + background:var(--panel); border:1px solid var(--line); border-radius:8px; + padding:12px 14px; margin-bottom:16px; + } + .switch b { color:var(--fg); } + .stat { color:var(--dim); } + .stat b { color:var(--accent); font-weight:600; } + button { + font:inherit; background:#22222e; color:var(--fg); border:1px solid #3a3a52; + border-radius:6px; padding:6px 12px; cursor:pointer; + } + button:hover:not(:disabled) { border-color:var(--accent); } + button:disabled { opacity:.45; cursor:not-allowed; } + + #grid { display:flex; flex-wrap:wrap; gap:14px; align-items:flex-start; } + .gb { + background:var(--panel); border:1px solid var(--line); border-radius:8px; + padding:10px; width:344px; } - #drop { - border:1px dashed #3a3a4d; border-radius:8px; padding:16px 22px; text-align:center; - color:var(--dim); cursor:pointer; transition:border-color .15s, color .15s; + .gb.focused { border-color:var(--accent); } + .gb header { display:flex; align-items:center; gap:8px; margin-bottom:8px; } + .gb header b { font-size:12px; } + .ip { color:var(--accent); font-size:12px; } + .ip.pending { color:var(--dim); } + .grow { flex:1; } + .x { padding:2px 8px; line-height:1.2; } + canvas { + image-rendering:pixelated; width:320px; height:288px; background:#000; + border-radius:4px; display:block; cursor:pointer; } - #drop.over, #drop:hover { border-color:var(--accent); color:var(--fg); } - #drop input { display:none; } - #status { color:var(--dim); min-height:1.5em; text-align:center; font-size:12.5px; } - #status b { color:var(--fg); font-weight:600; } - kbd { - background:#22222e; border:1px solid #33334a; border-bottom-width:2px; - border-radius:4px; padding:1px 5px; font-size:11px; color:var(--fg); + .con { + height:86px; overflow-y:auto; background:#0d0d14; border:1px solid var(--line); + border-radius:4px; margin-top:8px; padding:5px 7px; font-size:11px; + color:#9aa; white-space:pre-wrap; word-break:break-all; } - .keys { color:var(--dim); font-size:12px; text-align:center; max-width:640px; } - .hide { display:none; } + .cmd { display:flex; gap:6px; margin-top:7px; } + .cmd input { + flex:1; font:inherit; font-size:12px; background:#0d0d14; color:var(--fg); + border:1px solid var(--line); border-radius:4px; padding:5px 7px; + } + .cmd input:focus { outline:none; border-color:var(--accent); } + #log { + margin-top:16px; background:#0d0d14; border:1px solid var(--line); border-radius:8px; + padding:8px 10px; height:120px; overflow-y:auto; font-size:11px; color:var(--dim); + white-space:pre-wrap; + } + .empty { color:var(--dim); padding:26px; border:1px dashed var(--line); border-radius:8px; } + kbd { background:#22222e; border:1px solid #33334a; border-radius:3px; padding:0 4px; font-size:11px; } </style> </head> <body> - <h1>sl0p<span>boy</span></h1> - <canvas id="screen" width="160" height="144"></canvas> - <label id="drop"> - <input type="file" id="file" accept=".gb,.gbc,.bin"> - <b>drop a ROM here</b> or click to pick one — <code>.gb</code> / <code>.gbc</code> - </label> - <div id="status">loading core…</div> - <div class="keys"> - <kbd>←↑↓→</kbd>/<kbd>WASD</kbd> d-pad · <kbd>Z</kbd> A · <kbd>X</kbd> B · - <kbd>Enter</kbd> start · <kbd>Space</kbd> select · <kbd>R</kbd> reset - </div> - <div class="keys"> - want several of them on a network? → - <a href="net.html" style="color:#4f8ef7">the network lab</a> — Game Boys on a - virtual switch, with DHCP and pings between them. + <h1>sl0p<span>boy</span> network lab</h1> + <div class="sub"> + Game Boys running <b>gbos</b> on a virtual switch, all inside this tab — DHCP leases, + IP routing and pings between them, no server involved. Click one to select it — + the caret lands in its console; <kbd>esc</kbd> or clicking its screen hands the + keyboard back to the joypad. + <a href="play.html">just one Game Boy (bring your own ROM) →</a> </div> - <div class="keys" id="demo-note"> - booting <b>gbos</b> — a Unix-flavored microkernel for the Game Boy Color. - hit <kbd>Space</kbd> (SELECT) for the on-screen keyboard, then - <kbd>←↑↓→</kbd> + <kbd>Z</kbd> to type at the <code>/#</code> prompt — try - <code>ls</code>, <code>ps</code>, <code>uptime</code>. - <span style="opacity:.7">(its networking rides the link cable, which a browser - tab doesn't have — so <code>dhcp</code> just sits there discovering.)</span> + + <div class="switch"> + <div><b>switch</b> <span class="stat">gw 10.0.0.1/24</span></div> + <div class="stat">ports <b id="s-ports">0</b></div> + <div class="stat">leases <b id="s-leases">0</b></div> + <div class="stat">packets <b id="s-packets">0</b></div> + <div class="stat">emu <b id="s-fps">—</b></div> + <div class="grow"></div> + <button id="add" disabled>+ plug in a Game Boy</button> + <button id="ping" disabled>ping across</button> </div> + <div id="grid"></div> + <div id="log"></div> + <script type="module"> +import { GameBoy, BTN } from "./gbcore.js"; +import { Hub, ipStr, GW } from "./gbhub.js"; + const $ = (id) => document.getElementById(id); -const statusEl = $("status"), canvas = $("screen"); -const ctx = canvas.getContext("2d", { alpha: false }); -const say = (html) => { statusEl.innerHTML = html; }; +const MAX_GB = 6; // one thread runs ~12 at full speed; leave headroom +const ROM_URL = "roms/gbos.gb"; -// ---- WASI shim ------------------------------------------------------------ -// The core is built against wasi-libc but only ever touches stdio, so these -// four fd_* calls are the entire host interface. stdout/stderr (the cart info -// banner) go to the devtools console; there are no real files to open. -const EBADF = 8; -let memory; -const dec = new TextDecoder(); -const wasi = { - fd_write(fd, iovs, iovsLen, nwritten) { - const dv = new DataView(memory.buffer); - let total = 0, parts = []; - for (let i = 0; i < iovsLen; i++) { - const p = dv.getUint32(iovs + i * 8, true), l = dv.getUint32(iovs + i * 8 + 4, true); - parts.push(new Uint8Array(memory.buffer, p, l).slice()); - total += l; +const logEl = $("log"); +const line = (s) => { + const t = new Date().toTimeString().slice(0, 8); + logEl.textContent += `${t} ${s}\n`; + logEl.scrollTop = logEl.scrollHeight; +}; + +const hub = new Hub({ + onEvent(e) { + if (e.type === "lease") { + const b = boys.get(e.port.id); + if (b) { b.ipEl.textContent = e.port.ipText; b.ipEl.classList.remove("pending"); } + line(`DHCP GB${e.port.id} leased ${e.port.ipText}`); + $("ping").disabled = [...boys.values()].filter((x) => x.port.leased).length < 2; + } else if (e.type === "console") { + const b = boys.get(e.port.id); + if (b) { + b.con.textContent += e.text + "\n"; + b.con.scrollTop = b.con.scrollHeight; + } + } else if (e.kind === "no-route") { + line(`drop GB${e.port.id} -> ${e.dst} (no route; this switch has no uplink)`); } - const s = dec.decode(new Uint8Array(parts.flatMap((a) => [...a]))); - if (s.trim()) console.log("[sl0pboy]", s.trimEnd()); - dv.setUint32(nwritten, total, true); - return 0; }, - fd_read: () => EBADF, fd_seek: () => EBADF, fd_close: () => EBADF, -}; +}); -// instantiateStreaming needs the server to send application/wasm; fall back to -// the buffer path so a lazily-configured static host still works. -const imports = { wasi_snapshot_preview1: wasi }; -const res = await fetch("sl0pboy.wasm"); -if (!res.ok) { say(`<b>couldn't fetch sl0pboy.wasm</b> (${res.status})`); throw new Error("wasm fetch"); } -let instance; -try { - ({ instance } = await WebAssembly.instantiateStreaming(res.clone(), imports)); -} catch { - ({ instance } = await WebAssembly.instantiate(await res.arrayBuffer(), imports)); +let core = null, rom = null, nextId = 0, focused = null; +const boys = new Map(); // id -> { gb, port, canvas, ctx, img, ipEl, con } + +// ---- one Game Boy --------------------------------------------------------- +async function addGameBoy() { + if (!core || !rom) return; // nothing to instantiate until both landed + if (boys.size >= MAX_GB) return; + const id = nextId++; + + const card = document.createElement("div"); + card.className = "gb"; + card.innerHTML = ` + <header> + <b>GB${id}</b> + <span class="ip pending">leasing…</span> + <span class="grow"></span> + <button class="x" title="unplug">×</button> + </header> + <canvas width="160" height="144"></canvas> + <div class="con"></div> + <div class="cmd"><input placeholder="type a command, e.g. ping 10.0.0.3"></div>`; + $("grid").appendChild(card); + + const canvas = card.querySelector("canvas"); + const gb = await GameBoy.create(core, rom, { onLog: (s) => line(`GB${id} ${s}`) }); + const port = hub.attach(id, (bytes) => gb.pushSerial(bytes)); + + const b = { + id, gb, port, card, canvas, + ctx: canvas.getContext("2d", { alpha: false }), + img: new ImageData(160, 144), + ipEl: card.querySelector(".ip"), + con: card.querySelector(".con"), + }; + boys.set(id, b); + + // Typing into the link port *is* the shell's stdin (that's how gbtype works + // natively), so this is a real console - no on-screen keyboard needed. + const input = card.querySelector("input"); + b.input = input; + input.addEventListener("keydown", (e) => { + // Keystrokes here are text, not buttons - 'z' and 'x' are letters before + // they are A and B - so the joypad listener must not also see them. + e.stopPropagation(); + if (e.key === "Escape") { input.blur(); return; } // hand the keyboard back + if (e.key !== "Enter") return; + const cmd = input.value; + input.value = ""; + gb.pushSerial(new TextEncoder().encode(cmd + "\n")); + }); + card.querySelector(".x").addEventListener("click", () => removeGameBoy(id)); + // Anywhere on the card selects it (and takes the caret); the screen itself + // is the way to ask for the joypad instead. + card.addEventListener("mousedown", () => { if (focused !== id) focus(id); }); + canvas.addEventListener("click", () => { focus(id); input.blur(); }); + focus(id); + refresh(); + line(`plug GB${id} attached to the switch`); } -const gb = instance.exports; -memory = gb.memory; -gb._initialize(); // reactor model: libc init first -const intoWasm = (bytes) => { - const ptr = gb.gbw_alloc(bytes.length); - new Uint8Array(memory.buffer, ptr, bytes.length).set(bytes); - return ptr; -}; -const cstr = (ptr) => { - const m = new Uint8Array(memory.buffer); - let e = ptr; while (m[e]) e++; - return dec.decode(m.subarray(ptr, e)); -}; +function removeGameBoy(id) { + const b = boys.get(id); + if (!b) return; + hub.detach(id); + boys.delete(id); + b.card.remove(); + if (focused === id) focused = boys.size ? [...boys.keys()][0] : null; + line(`unplug GB${id} left the switch (${b.port.ipText} returned to the pool)`); + refresh(); +} + +function focus(id) { + if (focused !== id) { + // Don't leave a button stuck down on the machine we're leaving. + buttons = 0; + for (const b of boys.values()) b.gb.setButtons(0); + } + focused = id; + for (const [i, b] of boys) b.card.classList.toggle("focused", i === id); + // Focusing a Game Boy puts the caret in its console, since typing commands is + // what you mostly want here. Escape, or clicking its screen, gives the + // keyboard back to the joypad. + boys.get(id)?.input?.focus({ preventScroll: true }); +} + +function refresh() { + $("s-ports").textContent = boys.size; + $("s-leases").textContent = [...boys.values()].filter((b) => b.port.leased).length; + $("add").disabled = !core || !rom || boys.size >= MAX_GB; + $("ping").disabled = [...boys.values()].filter((b) => b.port.leased).length < 2; + if (!boys.size) { + $("grid").innerHTML = `<div class="empty">The switch is empty. Plug in a Game Boy — + then another, and they can ping each other.</div>`; + } else { + const e = $("grid").querySelector(".empty"); + if (e) e.remove(); + } +} -// ---- input ---------------------------------------------------------------- -// Same bits as gb.h. A browser reports key *release*, so unlike the terminal -// build there's no hold-for-N-frames guesswork: this is the true live state. -const BTN = { A:0x01, B:0x02, SELECT:0x04, START:0x08, RIGHT:0x10, LEFT:0x20, UP:0x40, DOWN:0x80 }; +// ---- joypad for the focused Game Boy -------------------------------------- const KEYMAP = { - ArrowUp:BTN.UP, ArrowDown:BTN.DOWN, ArrowLeft:BTN.LEFT, ArrowRight:BTN.RIGHT, - KeyW:BTN.UP, KeyS:BTN.DOWN, KeyA:BTN.LEFT, KeyD:BTN.RIGHT, - KeyZ:BTN.A, KeyJ:BTN.A, KeyX:BTN.B, KeyK:BTN.B, - Enter:BTN.START, Space:BTN.SELECT, + ArrowUp: BTN.UP, ArrowDown: BTN.DOWN, ArrowLeft: BTN.LEFT, ArrowRight: BTN.RIGHT, + KeyZ: BTN.A, KeyX: BTN.B, Enter: BTN.START, Space: BTN.SELECT, }; let buttons = 0; addEventListener("keydown", (e) => { - if (e.code === "KeyR" && running) { gb.gbw_reset(); return; } - const b = KEYMAP[e.code]; - if (b === undefined) return; + const bit = KEYMAP[e.code]; + if (bit === undefined || focused === null) return; e.preventDefault(); - buttons |= b; gb.gbw_set_buttons(buttons); + buttons |= bit; + boys.get(focused)?.gb.setButtons(buttons); }); addEventListener("keyup", (e) => { - const b = KEYMAP[e.code]; - if (b === undefined) return; - e.preventDefault(); - buttons &= ~b; gb.gbw_set_buttons(buttons); + const bit = KEYMAP[e.code]; + if (bit === undefined || focused === null) return; + buttons &= ~bit; + boys.get(focused)?.gb.setButtons(buttons); +}); +addEventListener("blur", () => { + buttons = 0; + for (const b of boys.values()) b.gb.setButtons(0); }); -// Dropping focus mid-press would otherwise latch a button down forever. -addEventListener("blur", () => { buttons = 0; gb.gbw_set_buttons(0); }); - -// ---- battery saves -------------------------------------------------------- -// No filesystem, so cartridge RAM lives in localStorage keyed by game title. -let saveKey = null, saveTimer = 0; -const readSram = () => - new Uint8Array(memory.buffer, gb.gbw_sram_ptr(), gb.gbw_sram_size()).slice(); -// NB: chunked. String.fromCharCode(...buf) on a 128KB save (gbos) blows the -// argument limit and throws RangeError, taking the autosave with it. -const b64encode = (buf) => { - let s = ""; - for (let i = 0; i < buf.length; i += 0x8000) - s += String.fromCharCode.apply(null, buf.subarray(i, i + 0x8000)); - return btoa(s); -}; -const persist = () => { - if (!saveKey || !gb.gbw_has_battery() || !gb.gbw_sram_size()) return; - try { - localStorage.setItem(saveKey, b64encode(readSram())); - } catch (e) { - console.warn("[sl0pboy] save failed (quota?)", e); - } -}; -const restore = () => { - if (!saveKey || !gb.gbw_has_battery()) return false; - const b64 = localStorage.getItem(saveKey); - if (!b64) return false; - const raw = atob(b64), buf = new Uint8Array(raw.length); - for (let i = 0; i < raw.length; i++) buf[i] = raw.charCodeAt(i); - const p = intoWasm(buf), n = gb.gbw_sram_load(p, buf.length); - gb.gbw_free(p); - return n === buf.length; -}; -addEventListener("beforeunload", persist); - -// ---- the frame loop ------------------------------------------------------- -// The GB runs at 59.7275Hz and the display is whatever it is, so an -// accumulator decides how many frames to emulate per repaint. Catch-up is -// capped: after a background tab or a long stall, skip ahead rather than -// running hundreds of frames in one blocking burst. -const FRAME_MS = 1000 / 59.7275, MAX_CATCHUP = 4; -const img = ctx.createImageData(160, 144); -let running = false, acc = 0, last = 0, fps = 0, fpsT = 0, fpsN = 0; -function draw() { - // The view must be rebuilt each frame: any wasm allocation can grow the - // memory and detach every existing ArrayBuffer view. - img.data.set(new Uint8Array(memory.buffer, gb.gbw_framebuffer_rgba(), 160 * 144 * 4)); - ctx.putImageData(img, 0, 0); -} +// ---- the loop ------------------------------------------------------------- +// Every Game Boy advances the same number of frames, then each one's link +// output is handed to the switch. Catch-up is capped so a backgrounded tab +// doesn't come back and run a thousand frames in one blocking burst. +const FRAME_MS = 1000 / 59.7275, MAX_CATCHUP = 3; +let acc = 0, last = 0, fpsT = 0, fpsN = 0; function loop(t) { requestAnimationFrame(loop); - if (!running) return; + if (!core || !boys.size) { last = t; return; } if (!last) last = t; acc += t - last; last = t; let n = Math.floor(acc / FRAME_MS); if (n <= 0) return; acc -= n * FRAME_MS; if (n > MAX_CATCHUP) n = MAX_CATCHUP; - for (let i = 0; i < n; i++) gb.gbw_run_frame(); - draw(); + + for (let f = 0; f < n; f++) { + for (const b of boys.values()) { + b.gb.runFrame(); + const out = b.gb.drainSerial(); + if (out) hub.feed(b.id, out); + } + } + for (const b of boys.values()) { + b.img.data.set(b.gb.framebufferRGBA()); + b.ctx.putImageData(b.img, 0, 0); + } fpsN += n; if (t - fpsT > 500) { - fps = Math.round((fpsN * 1000) / (t - fpsT)); fpsT = t; fpsN = 0; - say(`<b>${cstr(gb.gbw_title()) || "(untitled)"}</b> · ${gb.gbw_is_cgb() ? "CGB" : "DMG"} · ${fps} fps` + - (gb.gbw_has_battery() ? " · battery save on" : "")); + $("s-fps").textContent = `${Math.round((fpsN * 1000) / (t - fpsT))} fps × ${boys.size}`; + $("s-packets").textContent = hub.packets; + $("s-leases").textContent = [...boys.values()].filter((b) => b.port.leased).length; + fpsT = t; fpsN = 0; } - if (gb.gbw_has_battery() && t - saveTimer > 5000) { saveTimer = t; persist(); } } requestAnimationFrame(loop); document.addEventListener("visibilitychange", () => { if (!document.hidden) last = 0; }); -// ---- loading a ROM -------------------------------------------------------- -async function loadROM(file) { - const bytes = new Uint8Array(await file.arrayBuffer()); - if (running) persist(); // flush the outgoing game - running = false; - const ptr = intoWasm(bytes); - const rc = gb.gbw_load(ptr, bytes.length); - gb.gbw_free(ptr); - if (rc !== 0) { say(`<b>failed to load ${file.name}</b> — not a Game Boy ROM?`); return; } - saveKey = "sl0pboy.sav." + (cstr(gb.gbw_title()) || file.name); - const had = restore(); - buttons = 0; gb.gbw_set_buttons(0); - acc = 0; last = 0; fpsT = 0; fpsN = 0; - running = true; - say(`<b>${cstr(gb.gbw_title()) || file.name}</b> — running${had ? " (save restored)" : ""}`); - canvas.focus(); -} - -// A ROM shipped with the page, booted on arrival so the site does something -// on its own. Dropping a file just replaces it. -const DEMO = "roms/gbos.gb"; -async function loadDemo() { - try { - const r = await fetch(DEMO); - if (!r.ok) throw new Error(`HTTP ${r.status}`); - const buf = await r.arrayBuffer(); - await loadROM(new File([buf], DEMO.split("/").pop())); - } catch (e) { - console.warn("[sl0pboy] demo ROM unavailable:", e); - $("demo-note").classList.add("hide"); - say("core ready — drop in a ROM to start"); - } -} - -// Once you bring your own game, the gbos blurb is just noise. -const userROM = (f) => { $("demo-note").classList.add("hide"); loadROM(f); }; - -$("file").addEventListener("change", (e) => e.target.files[0] && userROM(e.target.files[0])); -const drop = $("drop"); -addEventListener("dragover", (e) => { e.preventDefault(); drop.classList.add("over"); }); -addEventListener("dragleave", () => drop.classList.remove("over")); -addEventListener("drop", (e) => { - e.preventDefault(); drop.classList.remove("over"); - const f = e.dataTransfer.files[0]; - if (f) userROM(f); +// ---- wire up -------------------------------------------------------------- +$("add").addEventListener("click", addGameBoy); +$("ping").addEventListener("click", () => { + const leased = [...boys.values()].filter((b) => b.port.leased); + if (leased.length < 2) return; + const [a, b] = leased; + a.gb.pushSerial(new TextEncoder().encode(`ping ${b.port.ipText}\n`)); + line(`GB${a.id} -> ping ${b.port.ipText}`); }); -say("core ready — fetching the demo ROM…"); -draw(); -loadDemo(); +try { + const [mod, romRes] = await Promise.all([ + (await import("./gbcore.js")).loadCore("sl0pboy.wasm"), + fetch(ROM_URL), + ]); + if (!romRes.ok) throw new Error(`${ROM_URL}: HTTP ${romRes.status}`); + core = mod; + rom = new Uint8Array(await romRes.arrayBuffer()); + line(`core ready — gbos ${(rom.length / 1024) | 0}KB, switch up on 10.0.0.1`); + refresh(); + await addGameBoy(); // start with one plugged in +} catch (e) { + line(`failed to start: ${e.message}`); + $("grid").innerHTML = `<div class="empty">Couldn't start: ${e.message}</div>`; +} </script> </body> </html> diff --git a/web/net.html b/web/net.html deleted file mode 100644 index 171ab5a..0000000 --- a/web/net.html +++ /dev/null @@ -1,300 +0,0 @@ -<!DOCTYPE html> -<html lang="en"> -<head> -<meta charset="utf-8"> -<meta name="viewport" content="width=device-width,initial-scale=1"> -<title>sl0pboy network lab — Game Boys on a virtual switch</title> -<style> - :root { --bg:#12121a; --panel:#181823; --line:#2b2b3d; --fg:#d7d7e0; --dim:#6f6f85; --accent:#9bbc0f; --wire:#4f8ef7; } - * { box-sizing:border-box; } - body { - margin:0; min-height:100vh; background:var(--bg); color:var(--fg); padding:18px; - font:13px/1.5 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; - } - h1 { font-size:14px; letter-spacing:.14em; text-transform:uppercase; margin:0 0 2px; } - h1 span { color:var(--accent); } - .sub { color:var(--dim); margin-bottom:14px; font-size:12px; } - .sub a { color:var(--wire); } - - .switch { - display:flex; align-items:center; gap:16px; flex-wrap:wrap; - background:var(--panel); border:1px solid var(--line); border-radius:8px; - padding:12px 14px; margin-bottom:16px; - } - .switch b { color:var(--fg); } - .stat { color:var(--dim); } - .stat b { color:var(--accent); font-weight:600; } - button { - font:inherit; background:#22222e; color:var(--fg); border:1px solid #3a3a52; - border-radius:6px; padding:6px 12px; cursor:pointer; - } - button:hover:not(:disabled) { border-color:var(--accent); } - button:disabled { opacity:.45; cursor:not-allowed; } - - #grid { display:flex; flex-wrap:wrap; gap:14px; align-items:flex-start; } - .gb { - background:var(--panel); border:1px solid var(--line); border-radius:8px; - padding:10px; width:344px; - } - .gb.focused { border-color:var(--accent); } - .gb header { display:flex; align-items:center; gap:8px; margin-bottom:8px; } - .gb header b { font-size:12px; } - .ip { color:var(--accent); font-size:12px; } - .ip.pending { color:var(--dim); } - .grow { flex:1; } - .x { padding:2px 8px; line-height:1.2; } - canvas { - image-rendering:pixelated; width:320px; height:288px; background:#000; - border-radius:4px; display:block; cursor:pointer; - } - .con { - height:86px; overflow-y:auto; background:#0d0d14; border:1px solid var(--line); - border-radius:4px; margin-top:8px; padding:5px 7px; font-size:11px; - color:#9aa; white-space:pre-wrap; word-break:break-all; - } - .cmd { display:flex; gap:6px; margin-top:7px; } - .cmd input { - flex:1; font:inherit; font-size:12px; background:#0d0d14; color:var(--fg); - border:1px solid var(--line); border-radius:4px; padding:5px 7px; - } - .cmd input:focus { outline:none; border-color:var(--accent); } - #log { - margin-top:16px; background:#0d0d14; border:1px solid var(--line); border-radius:8px; - padding:8px 10px; height:120px; overflow-y:auto; font-size:11px; color:var(--dim); - white-space:pre-wrap; - } - .empty { color:var(--dim); padding:26px; border:1px dashed var(--line); border-radius:8px; } - kbd { background:#22222e; border:1px solid #33334a; border-radius:3px; padding:0 4px; font-size:11px; } -</style> -</head> -<body> - <h1>sl0p<span>boy</span> network lab</h1> - <div class="sub"> - Game Boys running <b>gbos</b> on a virtual switch, all inside this tab — DHCP leases, - IP routing and pings between them, no server involved. - <a href="/">← single Game Boy</a> - </div> - - <div class="switch"> - <div><b>switch</b> <span class="stat">gw 10.0.0.1/24</span></div> - <div class="stat">ports <b id="s-ports">0</b></div> - <div class="stat">leases <b id="s-leases">0</b></div> - <div class="stat">packets <b id="s-packets">0</b></div> - <div class="stat">emu <b id="s-fps">—</b></div> - <div class="grow"></div> - <button id="add" disabled>+ plug in a Game Boy</button> - <button id="ping" disabled>ping across</button> - </div> - - <div id="grid"></div> - <div id="log"></div> - -<script type="module"> -import { GameBoy, BTN } from "./gbcore.js"; -import { Hub, ipStr, GW } from "./gbhub.js"; - -const $ = (id) => document.getElementById(id); -const MAX_GB = 6; // one thread runs ~12 at full speed; leave headroom -const ROM_URL = "roms/gbos.gb"; - -const logEl = $("log"); -const line = (s) => { - const t = new Date().toTimeString().slice(0, 8); - logEl.textContent += `${t} ${s}\n`; - logEl.scrollTop = logEl.scrollHeight; -}; - -const hub = new Hub({ - onEvent(e) { - if (e.type === "lease") { - const b = boys.get(e.port.id); - if (b) { b.ipEl.textContent = e.port.ipText; b.ipEl.classList.remove("pending"); } - line(`DHCP GB${e.port.id} leased ${e.port.ipText}`); - $("ping").disabled = [...boys.values()].filter((x) => x.port.leased).length < 2; - } else if (e.type === "console") { - const b = boys.get(e.port.id); - if (b) { - b.con.textContent += e.text + "\n"; - b.con.scrollTop = b.con.scrollHeight; - } - } else if (e.kind === "no-route") { - line(`drop GB${e.port.id} -> ${e.dst} (no route; this switch has no uplink)`); - } - }, -}); - -let core = null, rom = null, nextId = 0, focused = null; -const boys = new Map(); // id -> { gb, port, canvas, ctx, img, ipEl, con } - -// ---- one Game Boy --------------------------------------------------------- -async function addGameBoy() { - if (!core || !rom) return; // nothing to instantiate until both landed - if (boys.size >= MAX_GB) return; - const id = nextId++; - - const card = document.createElement("div"); - card.className = "gb"; - card.innerHTML = ` - <header> - <b>GB${id}</b> - <span class="ip pending">leasing…</span> - <span class="grow"></span> - <button class="x" title="unplug">×</button> - </header> - <canvas width="160" height="144"></canvas> - <div class="con"></div> - <div class="cmd"><input placeholder="type a command, e.g. ping 10.0.0.3"></div>`; - $("grid").appendChild(card); - - const canvas = card.querySelector("canvas"); - const gb = await GameBoy.create(core, rom, { onLog: (s) => line(`GB${id} ${s}`) }); - const port = hub.attach(id, (bytes) => gb.pushSerial(bytes)); - - const b = { - id, gb, port, card, canvas, - ctx: canvas.getContext("2d", { alpha: false }), - img: new ImageData(160, 144), - ipEl: card.querySelector(".ip"), - con: card.querySelector(".con"), - }; - boys.set(id, b); - - // Typing into the link port *is* the shell's stdin (that's how gbtype works - // natively), so this is a real console - no on-screen keyboard needed. - const input = card.querySelector("input"); - input.addEventListener("keydown", (e) => { - e.stopPropagation(); // don't drive the joypad - if (e.key !== "Enter") return; - const cmd = input.value; - input.value = ""; - gb.pushSerial(new TextEncoder().encode(cmd + "\n")); - }); - card.querySelector(".x").addEventListener("click", () => removeGameBoy(id)); - canvas.addEventListener("click", () => focus(id)); - focus(id); - refresh(); - line(`plug GB${id} attached to the switch`); -} - -function removeGameBoy(id) { - const b = boys.get(id); - if (!b) return; - hub.detach(id); - boys.delete(id); - b.card.remove(); - if (focused === id) focused = boys.size ? [...boys.keys()][0] : null; - line(`unplug GB${id} left the switch (${b.port.ipText} returned to the pool)`); - refresh(); -} - -function focus(id) { - focused = id; - for (const [i, b] of boys) b.card.classList.toggle("focused", i === id); -} - -function refresh() { - $("s-ports").textContent = boys.size; - $("s-leases").textContent = [...boys.values()].filter((b) => b.port.leased).length; - $("add").disabled = !core || !rom || boys.size >= MAX_GB; - $("ping").disabled = [...boys.values()].filter((b) => b.port.leased).length < 2; - if (!boys.size) { - $("grid").innerHTML = `<div class="empty">The switch is empty. Plug in a Game Boy — - then another, and they can ping each other.</div>`; - } else { - const e = $("grid").querySelector(".empty"); - if (e) e.remove(); - } -} - -// ---- joypad for the focused Game Boy -------------------------------------- -const KEYMAP = { - ArrowUp: BTN.UP, ArrowDown: BTN.DOWN, ArrowLeft: BTN.LEFT, ArrowRight: BTN.RIGHT, - KeyZ: BTN.A, KeyX: BTN.B, Enter: BTN.START, Space: BTN.SELECT, -}; -let buttons = 0; -addEventListener("keydown", (e) => { - const bit = KEYMAP[e.code]; - if (bit === undefined || focused === null) return; - e.preventDefault(); - buttons |= bit; - boys.get(focused)?.gb.setButtons(buttons); -}); -addEventListener("keyup", (e) => { - const bit = KEYMAP[e.code]; - if (bit === undefined || focused === null) return; - buttons &= ~bit; - boys.get(focused)?.gb.setButtons(buttons); -}); -addEventListener("blur", () => { - buttons = 0; - for (const b of boys.values()) b.gb.setButtons(0); -}); - -// ---- the loop ------------------------------------------------------------- -// Every Game Boy advances the same number of frames, then each one's link -// output is handed to the switch. Catch-up is capped so a backgrounded tab -// doesn't come back and run a thousand frames in one blocking burst. -const FRAME_MS = 1000 / 59.7275, MAX_CATCHUP = 3; -let acc = 0, last = 0, fpsT = 0, fpsN = 0; - -function loop(t) { - requestAnimationFrame(loop); - if (!core || !boys.size) { last = t; return; } - if (!last) last = t; - acc += t - last; last = t; - let n = Math.floor(acc / FRAME_MS); - if (n <= 0) return; - acc -= n * FRAME_MS; - if (n > MAX_CATCHUP) n = MAX_CATCHUP; - - for (let f = 0; f < n; f++) { - for (const b of boys.values()) { - b.gb.runFrame(); - const out = b.gb.drainSerial(); - if (out) hub.feed(b.id, out); - } - } - for (const b of boys.values()) { - b.img.data.set(b.gb.framebufferRGBA()); - b.ctx.putImageData(b.img, 0, 0); - } - - fpsN += n; - if (t - fpsT > 500) { - $("s-fps").textContent = `${Math.round((fpsN * 1000) / (t - fpsT))} fps × ${boys.size}`; - $("s-packets").textContent = hub.packets; - $("s-leases").textContent = [...boys.values()].filter((b) => b.port.leased).length; - fpsT = t; fpsN = 0; - } -} -requestAnimationFrame(loop); -document.addEventListener("visibilitychange", () => { if (!document.hidden) last = 0; }); - -// ---- wire up -------------------------------------------------------------- -$("add").addEventListener("click", addGameBoy); -$("ping").addEventListener("click", () => { - const leased = [...boys.values()].filter((b) => b.port.leased); - if (leased.length < 2) return; - const [a, b] = leased; - a.gb.pushSerial(new TextEncoder().encode(`ping ${b.port.ipText}\n`)); - line(`GB${a.id} -> ping ${b.port.ipText}`); -}); - -try { - const [mod, romRes] = await Promise.all([ - (await import("./gbcore.js")).loadCore("sl0pboy.wasm"), - fetch(ROM_URL), - ]); - if (!romRes.ok) throw new Error(`${ROM_URL}: HTTP ${romRes.status}`); - core = mod; - rom = new Uint8Array(await romRes.arrayBuffer()); - line(`core ready — gbos ${(rom.length / 1024) | 0}KB, switch up on 10.0.0.1`); - refresh(); - await addGameBoy(); // start with one plugged in -} catch (e) { - line(`failed to start: ${e.message}`); - $("grid").innerHTML = `<div class="empty">Couldn't start: ${e.message}</div>`; -} -</script> -</body> -</html> diff --git a/web/play.html b/web/play.html new file mode 100644 index 0000000..bd19fe7 --- /dev/null +++ b/web/play.html @@ -0,0 +1,276 @@ +<!DOCTYPE html> +<html lang="en"> +<head> +<meta charset="utf-8"> +<meta name="viewport" content="width=device-width,initial-scale=1"> +<title>sl0pboy — a Game Boy Color emulator in your browser</title> +<style> + :root { --bg:#12121a; --fg:#d7d7e0; --dim:#6f6f85; --accent:#9bbc0f; } + * { box-sizing: border-box; } + body { + margin:0; min-height:100vh; background:var(--bg); color:var(--fg); + font:14px/1.5 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; + display:flex; flex-direction:column; align-items:center; justify-content:center; + gap:14px; padding:20px; + } + h1 { font-size:15px; font-weight:600; margin:0; letter-spacing:.14em; text-transform:uppercase; } + h1 span { color:var(--accent); } + #screen { + /* the PPU's 160x144 must stay crisp at any zoom, never smoothed */ + image-rendering: pixelated; image-rendering: crisp-edges; + /* Height-aware: 160/144 means width = height * 1.111, so capping width at + 71vh keeps the whole LCD on screen in a short/landscape window. */ + background:#000; border-radius:6px; width:min(640px,92vw,71vh); aspect-ratio:160/144; + box-shadow:0 0 0 10px #22222e, 0 0 0 11px #2e2e3d, 0 18px 50px #0008; + touch-action:none; + } + #drop { + border:1px dashed #3a3a4d; border-radius:8px; padding:16px 22px; text-align:center; + color:var(--dim); cursor:pointer; transition:border-color .15s, color .15s; + } + #drop.over, #drop:hover { border-color:var(--accent); color:var(--fg); } + #drop input { display:none; } + #status { color:var(--dim); min-height:1.5em; text-align:center; font-size:12.5px; } + #status b { color:var(--fg); font-weight:600; } + kbd { + background:#22222e; border:1px solid #33334a; border-bottom-width:2px; + border-radius:4px; padding:1px 5px; font-size:11px; color:var(--fg); + } + .keys { color:var(--dim); font-size:12px; text-align:center; max-width:640px; } + .hide { display:none; } +</style> +</head> +<body> + <h1>sl0p<span>boy</span></h1> + <canvas id="screen" width="160" height="144"></canvas> + <label id="drop"> + <input type="file" id="file" accept=".gb,.gbc,.bin"> + <b>drop a ROM here</b> or click to pick one — <code>.gb</code> / <code>.gbc</code> + </label> + <div id="status">loading core…</div> + <div class="keys"> + <kbd>←↑↓→</kbd>/<kbd>WASD</kbd> d-pad · <kbd>Z</kbd> A · <kbd>X</kbd> B · + <kbd>Enter</kbd> start · <kbd>Space</kbd> select · <kbd>R</kbd> reset + </div> + <div class="keys"> + want several of them on a network? → + <a href="/" style="color:#4f8ef7">the network lab</a> — Game Boys on a + virtual switch, with DHCP and pings between them. + </div> + <div class="keys" id="demo-note"> + booting <b>gbos</b> — a Unix-flavored microkernel for the Game Boy Color. + hit <kbd>Space</kbd> (SELECT) for the on-screen keyboard, then + <kbd>←↑↓→</kbd> + <kbd>Z</kbd> to type at the <code>/#</code> prompt — try + <code>ls</code>, <code>ps</code>, <code>uptime</code>. + <span style="opacity:.7">(its networking rides the link cable, which a browser + tab doesn't have — so <code>dhcp</code> just sits there discovering.)</span> + </div> + +<script type="module"> +const $ = (id) => document.getElementById(id); +const statusEl = $("status"), canvas = $("screen"); +const ctx = canvas.getContext("2d", { alpha: false }); +const say = (html) => { statusEl.innerHTML = html; }; + +// ---- WASI shim ------------------------------------------------------------ +// The core is built against wasi-libc but only ever touches stdio, so these +// four fd_* calls are the entire host interface. stdout/stderr (the cart info +// banner) go to the devtools console; there are no real files to open. +const EBADF = 8; +let memory; +const dec = new TextDecoder(); +const wasi = { + fd_write(fd, iovs, iovsLen, nwritten) { + const dv = new DataView(memory.buffer); + let total = 0, parts = []; + for (let i = 0; i < iovsLen; i++) { + const p = dv.getUint32(iovs + i * 8, true), l = dv.getUint32(iovs + i * 8 + 4, true); + parts.push(new Uint8Array(memory.buffer, p, l).slice()); + total += l; + } + const s = dec.decode(new Uint8Array(parts.flatMap((a) => [...a]))); + if (s.trim()) console.log("[sl0pboy]", s.trimEnd()); + dv.setUint32(nwritten, total, true); + return 0; + }, + fd_read: () => EBADF, fd_seek: () => EBADF, fd_close: () => EBADF, +}; + +// instantiateStreaming needs the server to send application/wasm; fall back to +// the buffer path so a lazily-configured static host still works. +const imports = { wasi_snapshot_preview1: wasi }; +const res = await fetch("sl0pboy.wasm"); +if (!res.ok) { say(`<b>couldn't fetch sl0pboy.wasm</b> (${res.status})`); throw new Error("wasm fetch"); } +let instance; +try { + ({ instance } = await WebAssembly.instantiateStreaming(res.clone(), imports)); +} catch { + ({ instance } = await WebAssembly.instantiate(await res.arrayBuffer(), imports)); +} +const gb = instance.exports; +memory = gb.memory; +gb._initialize(); // reactor model: libc init first + +const intoWasm = (bytes) => { + const ptr = gb.gbw_alloc(bytes.length); + new Uint8Array(memory.buffer, ptr, bytes.length).set(bytes); + return ptr; +}; +const cstr = (ptr) => { + const m = new Uint8Array(memory.buffer); + let e = ptr; while (m[e]) e++; + return dec.decode(m.subarray(ptr, e)); +}; + +// ---- input ---------------------------------------------------------------- +// Same bits as gb.h. A browser reports key *release*, so unlike the terminal +// build there's no hold-for-N-frames guesswork: this is the true live state. +const BTN = { A:0x01, B:0x02, SELECT:0x04, START:0x08, RIGHT:0x10, LEFT:0x20, UP:0x40, DOWN:0x80 }; +const KEYMAP = { + ArrowUp:BTN.UP, ArrowDown:BTN.DOWN, ArrowLeft:BTN.LEFT, ArrowRight:BTN.RIGHT, + KeyW:BTN.UP, KeyS:BTN.DOWN, KeyA:BTN.LEFT, KeyD:BTN.RIGHT, + KeyZ:BTN.A, KeyJ:BTN.A, KeyX:BTN.B, KeyK:BTN.B, + Enter:BTN.START, Space:BTN.SELECT, +}; +let buttons = 0; +addEventListener("keydown", (e) => { + if (e.code === "KeyR" && running) { gb.gbw_reset(); return; } + const b = KEYMAP[e.code]; + if (b === undefined) return; + e.preventDefault(); + buttons |= b; gb.gbw_set_buttons(buttons); +}); +addEventListener("keyup", (e) => { + const b = KEYMAP[e.code]; + if (b === undefined) return; + e.preventDefault(); + buttons &= ~b; gb.gbw_set_buttons(buttons); +}); +// Dropping focus mid-press would otherwise latch a button down forever. +addEventListener("blur", () => { buttons = 0; gb.gbw_set_buttons(0); }); + +// ---- battery saves -------------------------------------------------------- +// No filesystem, so cartridge RAM lives in localStorage keyed by game title. +let saveKey = null, saveTimer = 0; +const readSram = () => + new Uint8Array(memory.buffer, gb.gbw_sram_ptr(), gb.gbw_sram_size()).slice(); +// NB: chunked. String.fromCharCode(...buf) on a 128KB save (gbos) blows the +// argument limit and throws RangeError, taking the autosave with it. +const b64encode = (buf) => { + let s = ""; + for (let i = 0; i < buf.length; i += 0x8000) + s += String.fromCharCode.apply(null, buf.subarray(i, i + 0x8000)); + return btoa(s); +}; +const persist = () => { + if (!saveKey || !gb.gbw_has_battery() || !gb.gbw_sram_size()) return; + try { + localStorage.setItem(saveKey, b64encode(readSram())); + } catch (e) { + console.warn("[sl0pboy] save failed (quota?)", e); + } +}; +const restore = () => { + if (!saveKey || !gb.gbw_has_battery()) return false; + const b64 = localStorage.getItem(saveKey); + if (!b64) return false; + const raw = atob(b64), buf = new Uint8Array(raw.length); + for (let i = 0; i < raw.length; i++) buf[i] = raw.charCodeAt(i); + const p = intoWasm(buf), n = gb.gbw_sram_load(p, buf.length); + gb.gbw_free(p); + return n === buf.length; +}; +addEventListener("beforeunload", persist); + +// ---- the frame loop ------------------------------------------------------- +// The GB runs at 59.7275Hz and the display is whatever it is, so an +// accumulator decides how many frames to emulate per repaint. Catch-up is +// capped: after a background tab or a long stall, skip ahead rather than +// running hundreds of frames in one blocking burst. +const FRAME_MS = 1000 / 59.7275, MAX_CATCHUP = 4; +const img = ctx.createImageData(160, 144); +let running = false, acc = 0, last = 0, fps = 0, fpsT = 0, fpsN = 0; + +function draw() { + // The view must be rebuilt each frame: any wasm allocation can grow the + // memory and detach every existing ArrayBuffer view. + img.data.set(new Uint8Array(memory.buffer, gb.gbw_framebuffer_rgba(), 160 * 144 * 4)); + ctx.putImageData(img, 0, 0); +} + +function loop(t) { + requestAnimationFrame(loop); + if (!running) return; + if (!last) last = t; + acc += t - last; last = t; + let n = Math.floor(acc / FRAME_MS); + if (n <= 0) return; + acc -= n * FRAME_MS; + if (n > MAX_CATCHUP) n = MAX_CATCHUP; + for (let i = 0; i < n; i++) gb.gbw_run_frame(); + draw(); + + fpsN += n; + if (t - fpsT > 500) { + fps = Math.round((fpsN * 1000) / (t - fpsT)); fpsT = t; fpsN = 0; + say(`<b>${cstr(gb.gbw_title()) || "(untitled)"}</b> · ${gb.gbw_is_cgb() ? "CGB" : "DMG"} · ${fps} fps` + + (gb.gbw_has_battery() ? " · battery save on" : "")); + } + if (gb.gbw_has_battery() && t - saveTimer > 5000) { saveTimer = t; persist(); } +} +requestAnimationFrame(loop); +document.addEventListener("visibilitychange", () => { if (!document.hidden) last = 0; }); + +// ---- loading a ROM -------------------------------------------------------- +async function loadROM(file) { + const bytes = new Uint8Array(await file.arrayBuffer()); + if (running) persist(); // flush the outgoing game + running = false; + const ptr = intoWasm(bytes); + const rc = gb.gbw_load(ptr, bytes.length); + gb.gbw_free(ptr); + if (rc !== 0) { say(`<b>failed to load ${file.name}</b> — not a Game Boy ROM?`); return; } + saveKey = "sl0pboy.sav." + (cstr(gb.gbw_title()) || file.name); + const had = restore(); + buttons = 0; gb.gbw_set_buttons(0); + acc = 0; last = 0; fpsT = 0; fpsN = 0; + running = true; + say(`<b>${cstr(gb.gbw_title()) || file.name}</b> — running${had ? " (save restored)" : ""}`); + canvas.focus(); +} + +// A ROM shipped with the page, booted on arrival so the site does something +// on its own. Dropping a file just replaces it. +const DEMO = "roms/gbos.gb"; +async function loadDemo() { + try { + const r = await fetch(DEMO); + if (!r.ok) throw new Error(`HTTP ${r.status}`); + const buf = await r.arrayBuffer(); + await loadROM(new File([buf], DEMO.split("/").pop())); + } catch (e) { + console.warn("[sl0pboy] demo ROM unavailable:", e); + $("demo-note").classList.add("hide"); + say("core ready — drop in a ROM to start"); + } +} + +// Once you bring your own game, the gbos blurb is just noise. +const userROM = (f) => { $("demo-note").classList.add("hide"); loadROM(f); }; + +$("file").addEventListener("change", (e) => e.target.files[0] && userROM(e.target.files[0])); +const drop = $("drop"); +addEventListener("dragover", (e) => { e.preventDefault(); drop.classList.add("over"); }); +addEventListener("dragleave", () => drop.classList.remove("over")); +addEventListener("drop", (e) => { + e.preventDefault(); drop.classList.remove("over"); + const f = e.dataTransfer.files[0]; + if (f) userROM(f); +}); + +say("core ready — fetching the demo ROM…"); +draw(); +loadDemo(); +</script> +</body> +</html> |
