aboutsummaryrefslogtreecommitdiffstats
path: root/web/index.html
blob: 1df98f27245c7b7437720f8c3c992d9d6fb50990 (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
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
<!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); }
  /* A machine still being set up: pulsing edge, so it reads as "working"
     rather than "stuck on a banner". */
  .gb.booting { border-color:var(--wire); animation:pulse 1.4s ease-in-out infinite; }
  @keyframes pulse { 0%,100% { box-shadow:0 0 0 0 #4f8ef700; } 50% { box-shadow:0 0 0 3px #4f8ef733; } }
  #boot {
    display:flex; align-items:center; gap:10px; margin:-6px 0 14px;
    background:#141c2e; border:1px solid #2f3f63; border-radius:8px; padding:9px 13px;
    color:var(--fg); font-size:12.5px;
  }
  #boot.done { border-color:#2e4a2e; background:#141f14; }
  .spin {
    width:12px; height:12px; border-radius:50%; flex:none;
    border:2px solid #2f3f63; border-top-color:var(--wire);
    animation:spin .8s linear infinite;
  }
  #boot.done .spin { border:2px solid var(--accent); animation:none; }
  @keyframes spin { to { transform:rotate(360deg); } }
  #boot .steps { color:var(--dim); }
  .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); }
  .role {
    font-size:11px; color:var(--wire); border:1px solid #2f3f63; background:#182036;
    border-radius:3px; padding:0 6px;
  }
  .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. 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 style="margin-top:6px">
      it's a real machine, so poke at it: <code>help</code> · <code>ls /bin</code> ·
      <code>ls -l</code> · <code>df</code> · <code>ps</code> · <code>uptime</code> ·
      <code>ping 10.0.0.3</code> — or run a server on one and connect from another:
      <code>ircd &amp;</code> then <code>irc 10.0.0.2 nick</code>, <code>httpd &amp;</code>
      then <code>wget 10.0.0.2</code>.
    </div>
  </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>
    <button id="demo" disabled>restart demo</button>
    <button id="clear" disabled>empty the switch</button>
  </div>

  <div id="boot" class="hide">
    <span class="spin"></span>
    <span id="boot-text">starting the demo…</span>
    <span class="steps" id="boot-steps"></span>
  </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 = 8;          // CPU-bound: ~12 fit in one thread with zero 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}`);
      refresh();          // not $("ping").disabled directly: refresh() also
                          // knows about the demo lock

    } else if (e.type === "console") {
      const b = boys.get(e.port.id);
      // The LCD is a 40-column terminal, so full-width things (irc's status
      // bar) arrive space-padded. This mirror isn't column-aligned, so squeeze
      // the padding out and drop lines that were only padding.
      const text = e.text.replace(/\s{2,}/g, " ").trim();
      if (b && text) {
        b.con.textContent += 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(role = null) {
  if (!core || !rom) return null;   // nothing to instantiate until both landed
  if (boys.size >= MAX_GB) return null;
  const id = nextId++;

  const card = document.createElement("div");
  card.className = "gb";
  card.innerHTML = `
    <header>
      <b>GB${id}</b>
      ${role ? `<span class="role">${role}</span>` : ""}
      <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, role,
    boost: 1,          // frames per tick; >1 fast-forwards this machine's boot
    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${role ? ` (${role})` : ""}`);
  return b;
}

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;
  // While the demo is assembling, every control is off: adding a machine
  // mid-scenario steals the address the next step is about to be told to use.
  $("add").disabled = !core || !rom || demoRunning || boys.size >= MAX_GB;
  $("clear").disabled = !boys.size || demoRunning;
  $("demo").disabled = !core || !rom || demoRunning;
  $("ping").disabled = demoRunning ||
    [...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()) {
      // boost > 1 fast-forwards a machine that's still booting, so the demo
      // assembles in a few seconds instead of a minute. Only one is ever
      // boosted at a time (the scenario builds sequentially), which keeps the
      // total inside the ~12x-realtime budget one thread has.
      for (let k = 0; k < b.boost; k++) {
        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}`);
});

// ---- the default demo ------------------------------------------------------
// An empty switch is a blank page, so the site builds a small network on its
// own: two Game Boys running servers, two more using them. Every step is a
// command typed into a real shell over the link port - nothing is faked or
// pre-baked, which is why you can then take it over and keep typing.
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const BOOT_BOOST = 6;            // fast-forward while a machine is still booting
let demoRunning = false;

const say = (b, cmd) => {
  b.gb.pushSerial(new TextEncoder().encode(cmd + "\n"));
  line(`GB${b.id}  $ ${cmd}`);
};

// Resolve when this machine has a lease (or we give up waiting).
async function waitLease(b, timeoutMs = 60000) {
  const t0 = performance.now();
  while (!b.port.leased) {
    if (performance.now() - t0 > timeoutMs) return false;
    await sleep(200);
  }
  return true;
}

// The boot banner: what the demo is doing right now, and how far along.
const DEMO_STEPS = 4;
let stepNo = 0;
function boot(text, { done = false, step = false } = {}) {
  const el = $("boot");
  el.classList.remove("hide");
  el.classList.toggle("done", done);
  if (step) stepNo++;
  $("boot-text").textContent = text;
  $("boot-steps").textContent = done ? "" : `step ${stepNo}/${DEMO_STEPS}`;
}

// Bring up one machine: boot it fast, then run its commands at normal speed.
async function join(role, label, cmds) {
  boot(`plugging in a Game Boy to be the ${role}…`, { step: true });
  const b = await addGameBoy(role);
  if (!b) return null;
  b.card.classList.add("booting");
  b.boost = BOOT_BOOST;
  boot(`GB${b.id}: booting gbos and waiting for a DHCP lease…`);
  const ok = await waitLease(b);
  if (!ok) {
    b.boost = 1;
    b.card.classList.remove("booting");
    line(`GB${b.id}  no lease; skipping its part of the demo`);
    return b;
  }
  boot(`GB${b.id} is ${b.port.ipText} — ${label}…`);
  // The lease lands while boot's backgrounded dhcp job is still being reaped,
  // so give the shell a moment before typing at it - still fast-forwarded,
  // then drop back to real time for the part people actually watch.
  await sleep(1500);
  b.boost = 1;
  await sleep(400);
  for (const cmd of cmds(b)) { say(b, cmd); await sleep(1800); }
  b.card.classList.remove("booting");
  return b;
}

async function runDemo() {
  if (demoRunning) return;
  demoRunning = true;
  $("demo").disabled = true;
  stepNo = 0;
  refresh();                      // locks the controls for the duration
  try {
    const ircd = await join("ircd", "starting the IRC server", () => ["ircd &"]);
    const httpd = await join("httpd", "starting the web server", () => ["httpd &"]);
    if (ircd) {
      await join("irc client", `joining #gb on ${ircd.port.ipText}`, () => [
        `irc ${ircd.port.ipText} gbchat`, "/join #gb", "hello from the other Game Boy",
      ]);
    }
    if (httpd) {
      await join("web client", `fetching from ${httpd.port.ipText}`,
        () => [`wget ${httpd.port.ipText}`, `ping ${httpd.port.ipText}`]);
    }
    boot("demo ready — type into any of them, or plug in more", { done: true });
    line("demo ready — type into any of them, or plug in more");
  } catch (e) {
    boot(`demo stopped: ${e.message}`, { done: true });
    line(`demo failed: ${e.message}`);
  } finally {
    demoRunning = false;
    for (const b of boys.values()) { b.boost = 1; b.card.classList.remove("booting"); }
    refresh();
  }
}

function clearSwitch() {
  for (const id of [...boys.keys()]) removeGameBoy(id);
  line("switch emptied");
}

$("demo").addEventListener("click", async () => { clearSwitch(); await runDemo(); });
$("clear").addEventListener("click", clearSwitch);

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 runDemo();
} catch (e) {
  line(`failed to start: ${e.message}`);
  $("grid").innerHTML = `<div class="empty">Couldn't start: ${e.message}</div>`;
}
</script>
</body>
</html>