aboutsummaryrefslogtreecommitdiffstats
path: root/web/play.html
blob: bd19fe707b6720dbe3d1e0435fb4113d4327011c (plain) (blame)
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
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
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>