@8bitscript/cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,75 @@
1
+ // Running a web-target program outside a browser.
2
+ //
3
+ // A .wasm built by packages/backend-web exports exactly one function — the
4
+ // program (the entry module's one export) — plus its globals and memory, and
5
+ // imports one thing at most: `env.waitFrame`, when the program calls
6
+ // waitFrame(). The browser host (web-runtime.mjs) blocks that import on the
7
+ // page's frame clock. A headless host has no frame clock, so it counts
8
+ // instead: a bounded waitFrame() lets a program that loops forever run for
9
+ // exactly N frames and then unwinds it, the only way out of a `while (true)`
10
+ // that a caller controls. Used by `8bs run web --screenshot` and the tests.
11
+ export class FrameLimitReached extends Error {
12
+ constructor(frames) {
13
+ super(`the program ran for ${frames} frame(s) and was stopped`);
14
+ this.frames = frames;
15
+ }
16
+ }
17
+
18
+ /**
19
+ * A waitFrame() implementation that returns `limit` times and then throws
20
+ * FrameLimitReached — which propagates out through the wasm frames to the
21
+ * caller of the entry, ending a program that would otherwise never return.
22
+ */
23
+ export function boundedWaitFrame(limit) {
24
+ let frames = 0;
25
+ return () => {
26
+ frames += 1;
27
+ if (frames > limit) throw new FrameLimitReached(limit);
28
+ };
29
+ }
30
+
31
+ /**
32
+ * Instantiate a program's .wasm with a host-supplied waitFrame().
33
+ *
34
+ * The import object always offers `env.waitFrame`; a program that never calls
35
+ * waitFrame() simply doesn't import it, and an unused import is ignored. The
36
+ * entry is found as the one exported function, not by name.
37
+ *
38
+ * @param {Buffer|Uint8Array} bytes
39
+ * @param {{ waitFrame?: () => void }} [options]
40
+ * @returns {Promise<{
41
+ * instance: WebAssembly.Instance, memory: WebAssembly.Memory,
42
+ * entry: () => void, entryName: string, usesWaitFrame: boolean,
43
+ * }>}
44
+ */
45
+ export async function instantiateProgram(bytes, { waitFrame = () => {} } = {}) {
46
+ const module = await WebAssembly.compile(bytes);
47
+ const usesWaitFrame = WebAssembly.Module.imports(module)
48
+ .some((i) => i.module === 'env' && i.name === 'waitFrame');
49
+ const instance = await WebAssembly.instantiate(module, { env: { waitFrame } });
50
+ const functions = Object.entries(instance.exports).filter(([, v]) => typeof v === 'function');
51
+ if (functions.length !== 1) {
52
+ throw new Error(`8bs: a program exports exactly one function, this .wasm exports ${functions.length}`);
53
+ }
54
+ const [entryName, entry] = functions[0];
55
+ return { instance, memory: instance.exports.memory, entry, entryName, usesWaitFrame };
56
+ }
57
+
58
+ /**
59
+ * Run a program for at most `frames` logical frames: the entry is called and
60
+ * either returns on its own or is unwound by the frame bound. Anything else
61
+ * the program throws is a real error and propagates.
62
+ *
63
+ * @param {Buffer|Uint8Array} bytes
64
+ * @param {{ frames: number }} options
65
+ * @returns the instantiated program, after running
66
+ */
67
+ export async function runProgram(bytes, { frames }) {
68
+ const program = await instantiateProgram(bytes, { waitFrame: boundedWaitFrame(frames) });
69
+ try {
70
+ program.entry();
71
+ } catch (error) {
72
+ if (!(error instanceof FrameLimitReached)) throw error;
73
+ }
74
+ return program;
75
+ }
@@ -0,0 +1,557 @@
1
+ // The web target's real runtime: a browser tab, a canvas, and a worker.
2
+ //
3
+ // The program — the .wasm's one exported function — runs in a Web Worker,
4
+ // exactly as it would run on a real machine: it owns its thread, loops
5
+ // forever if it wants to, and calls waitFrame() to wait for the next frame.
6
+ // The page is the video chip. It never calls into the program; it paints the
7
+ // program's screen memory (shared with the worker) every display refresh,
8
+ // writes a one-byte input snapshot into that memory for @8bitscript/web/input
9
+ // to read, and releases one logical frame at a time on a fixed timestep at the
10
+ // project's configured `frameRate` (8bs.config.ts, default 60) — the same
11
+ // rate on every target, whatever the display actually refreshes at (60Hz,
12
+ // 120Hz, 144Hz, 50Hz). waitFrame() in the worker is a wasm import that blocks
13
+ // on `Atomics.wait` until the page releases a frame: one build runs correctly
14
+ // anywhere, and there is no web equivalent of --pal because nothing here is
15
+ // tied to a machine's real refresh rate to begin with.
16
+ //
17
+ // A program that never calls waitFrame() runs the same way. One that returns
18
+ // simply ends (the page says so); one that spins burns its own worker, not
19
+ // the tab — the page keeps painting whatever was last written, like a real
20
+ // machine with a program stuck in a loop.
21
+ import { createReadStream } from 'node:fs';
22
+ import { mkdir, writeFile } from 'node:fs/promises';
23
+ import { createServer } from 'node:http';
24
+ import { extname, join } from 'node:path';
25
+ import { spawn } from 'node:child_process';
26
+
27
+ // The C64's palette (0-15), reused so a colour number means the same thing
28
+ // in every 8BitScript example, on whichever machine it runs on. See the
29
+ // header comment on @8bitscript/web/screen's setColors() for why the web target
30
+ // borrows this rather than defining its own. Exported (with the layout
31
+ // constants below) so screenshot.mjs's --screenshot path can rasterize the
32
+ // exact same virtual screen this browser canvas draws, without a second,
33
+ // hand-copied version of these numbers to keep in sync by hand.
34
+ export const COLORS = [
35
+ '#000000', '#ffffff', '#883932', '#67b6bd',
36
+ '#8b3f96', '#55a049', '#40318d', '#bfce72',
37
+ '#8b5429', '#574200', '#b86962', '#505050',
38
+ '#787878', '#94e089', '#7869c4', '#9f9f9f',
39
+ ];
40
+
41
+ // The character grid is the C64's own 40×25 of 8×8 cells — 320×200, the
42
+ // same shape @8bitscript/web's virtual screen uses. The coloured border sits
43
+ // around that grid, the way the VIC-II/VIC paint it, rather than eating into
44
+ // it: characters then live entirely in the background, not clipped into the
45
+ // border. This is the canvas's *resolution*, not its on-page size: the
46
+ // page stretches it to fill the window (see resize() in the page script
47
+ // below) while image-rendering: pixelated keeps every scaled-up pixel a
48
+ // hard square instead of a blurred one.
49
+ export const GRID_COLS = 40;
50
+ export const GRID_ROWS = 25;
51
+ export const CHAR_W = 8;
52
+ export const CHAR_H = 8;
53
+ export const BORDER_PX = 24;
54
+ const INNER_W = GRID_COLS * CHAR_W;
55
+ const INNER_H = GRID_ROWS * CHAR_H;
56
+ const SCREEN_W = INNER_W + BORDER_PX * 2;
57
+ const SCREEN_H = INNER_H + BORDER_PX * 2;
58
+
59
+ // Where the virtual screen's character codes and per-cell colours live in
60
+ // the wasm's linear memory — @8bitscript/web's WebRegisters layout, mirrored
61
+ // here by hand (there's no shared module the .8bs side and this JS host
62
+ // could both import). Byte 0 is border, byte 1 is background.
63
+ export const CHAR_BASE = 2;
64
+ export const COLOR_BASE = 1002;
65
+ // Directions / confirm / cancel: the page writes this byte, @8bitscript/web/input
66
+ // reads it. Same Edge bits as every other machine's input layer. First byte
67
+ // after the 1000 colour cells at COLOR_BASE.
68
+ export const INPUT_OFFSET = 2002;
69
+
70
+ export const InputEdge = {
71
+ LEFT: 1,
72
+ RIGHT: 2,
73
+ UP: 4,
74
+ DOWN: 8,
75
+ CONFIRM: 16,
76
+ CANCEL: 32,
77
+ };
78
+
79
+ const KEY_TO_EDGE = {
80
+ ArrowLeft: InputEdge.LEFT,
81
+ ArrowRight: InputEdge.RIGHT,
82
+ ArrowUp: InputEdge.UP,
83
+ ArrowDown: InputEdge.DOWN,
84
+ Enter: InputEdge.CONFIRM,
85
+ Escape: InputEdge.CANCEL,
86
+ };
87
+
88
+ /** Bit in the INPUT_OFFSET snapshot for a DOM `KeyboardEvent.key`, or 0. */
89
+ export function inputBitForKey(key) {
90
+ return KEY_TO_EDGE[key] ?? 0;
91
+ }
92
+
93
+ // A swipe shorter than this (in CSS pixels on the canvas) is a tap, which
94
+ // the page writes as confirm — the same edge as Enter. A longer gesture
95
+ // takes the dominant axis. Exported so the mapping is tested, not inferred
96
+ // from the inlined page script.
97
+ export const SWIPE_THRESHOLD = 28;
98
+
99
+ /** Direction or confirm bit for a pointer gesture, or 0 if it did not move enough to count. */
100
+ export function swipeEdge(dx, dy) {
101
+ const ax = Math.abs(dx);
102
+ const ay = Math.abs(dy);
103
+ if (ax < SWIPE_THRESHOLD && ay < SWIPE_THRESHOLD) return 0;
104
+ if (ax > ay) return dx < 0 ? InputEdge.LEFT : InputEdge.RIGHT;
105
+ return dy < 0 ? InputEdge.UP : InputEdge.DOWN;
106
+ }
107
+
108
+ export const ISOLATION_HEADERS = {
109
+ 'Cross-Origin-Opener-Policy': 'same-origin',
110
+ 'Cross-Origin-Embedder-Policy': 'require-corp',
111
+ };
112
+
113
+ const HEADERS_FILE = `/*
114
+ Cross-Origin-Opener-Policy: same-origin
115
+ Cross-Origin-Embedder-Policy: require-corp
116
+ `;
117
+
118
+ // The two words the page and the worker share, in a SharedArrayBuffer beside
119
+ // the program's memory: how many logical frames the page has released, and
120
+ // how many the program has taken. Their difference is how far behind the
121
+ // program is.
122
+ const ISSUED = 0;
123
+ const CONSUMED = 1;
124
+
125
+ // The worker: the machine the program runs on. It instantiates the program
126
+ // with the one import a program can have — waitFrame(), blocking on the
127
+ // page's frame clock — hands the page its memory to paint, and calls the
128
+ // program's one exported function.
129
+ function renderWorker() {
130
+ return `
131
+ const ISSUED = ${ISSUED};
132
+ const CONSUMED = ${CONSUMED};
133
+
134
+ self.onmessage = async ({ data: { ctrl } }) => {
135
+ // Block until the page has released a frame this program hasn't taken yet.
136
+ // Returns at once when one is already owed — two logical frames per real
137
+ // one on a slow display — otherwise sleeps until the page notifies. The
138
+ // same 0/1/2-frames-per-wait behaviour the 6502 backend's accumulator has.
139
+ const waitFrame = () => {
140
+ const next = Atomics.load(ctrl, CONSUMED) + 1;
141
+ for (let issued; (issued = Atomics.load(ctrl, ISSUED)) < next;) {
142
+ Atomics.wait(ctrl, ISSUED, issued);
143
+ }
144
+ Atomics.store(ctrl, CONSUMED, next);
145
+ };
146
+
147
+ const response = await fetch(new URL('program.wasm', self.location.href));
148
+ const module = await WebAssembly.compile(await response.arrayBuffer());
149
+ const shared = WebAssembly.Module.imports(module).some((i) => i.module === 'env' && i.name === 'waitFrame');
150
+ const instance = await WebAssembly.instantiate(module, { env: { waitFrame } });
151
+ const entry = Object.values(instance.exports).find((v) => typeof v === 'function');
152
+
153
+ // A waitFrame() program's memory is shared, so the page can paint it while
154
+ // the program runs. One that never waits has ordinary memory: it runs to
155
+ // completion first, and the page gets a copy of the result to paint.
156
+ if (shared) self.postMessage({ memory: instance.exports.memory.buffer });
157
+ try {
158
+ entry();
159
+ if (!shared) self.postMessage({ memory: instance.exports.memory.buffer });
160
+ self.postMessage({ done: true });
161
+ } catch (error) {
162
+ if (!shared) self.postMessage({ memory: instance.exports.memory.buffer });
163
+ self.postMessage({ error: String(error) });
164
+ }
165
+ };
166
+ `;
167
+ }
168
+
169
+ function renderHtml(frameRate) {
170
+ return `<!doctype html>
171
+ <html>
172
+ <head>
173
+ <meta charset="utf-8">
174
+ <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
175
+ <title>8BitScript</title>
176
+ <style>
177
+ html, body { margin: 0; height: 100%; overflow: hidden; background: #000; touch-action: none; }
178
+ body { display: flex; align-items: center; justify-content: center; }
179
+ canvas { image-rendering: pixelated; display: block; touch-action: none; }
180
+ #hint {
181
+ position: fixed;
182
+ left: 50%;
183
+ bottom: 18px;
184
+ transform: translateX(-50%);
185
+ font: 12px/1.4 ui-monospace, Menlo, monospace;
186
+ color: rgba(255, 255, 255, 0.55);
187
+ background: rgba(0, 0, 0, 0.35);
188
+ padding: 5px 10px;
189
+ border-radius: 6px;
190
+ pointer-events: none;
191
+ transition: opacity 0.6s ease;
192
+ }
193
+ #hint.hidden { opacity: 0; }
194
+ #fps {
195
+ position: fixed;
196
+ top: 10px;
197
+ right: 14px;
198
+ font: 11px/1.4 ui-monospace, Menlo, monospace;
199
+ color: rgba(255, 255, 255, 0.4);
200
+ pointer-events: none;
201
+ }
202
+ </style>
203
+ </head>
204
+ <body>
205
+ <canvas id="screen" width="${SCREEN_W}" height="${SCREEN_H}"></canvas>
206
+ <div id="hint">arrows or swipe to move · double-click or F for fullscreen</div>
207
+ <div id="fps">FPS --</div>
208
+ <script>
209
+ const COLORS = ${JSON.stringify(COLORS)};
210
+ const LOGICAL_STEP_MS = 1000 / ${frameRate};
211
+ const BORDER_PX = ${BORDER_PX};
212
+ const GRID_COLS = ${GRID_COLS};
213
+ const GRID_ROWS = ${GRID_ROWS};
214
+ const CHAR_W = ${CHAR_W};
215
+ const CHAR_H = ${CHAR_H};
216
+ const INNER_W = ${INNER_W};
217
+ const INNER_H = ${INNER_H};
218
+ const SCREEN_W = ${SCREEN_W};
219
+ const SCREEN_H = ${SCREEN_H};
220
+ const ISSUED = ${ISSUED};
221
+ const CONSUMED = ${CONSUMED};
222
+
223
+ const canvas = document.getElementById('screen');
224
+ const ctx = canvas.getContext('2d');
225
+ const hint = document.getElementById('hint');
226
+ const fpsEl = document.getElementById('fps');
227
+
228
+ // @8bitscript/web's WebRegisters (CHAR_BASE/COLOR_BASE, exported above): a
229
+ // virtual 40-column, 1000-cell character screen starting at byte offset 2,
230
+ // its colour bytes starting at offset 1002. The cells hold ASCII — the
231
+ // portable character codes every machine's text.putChar takes:
232
+ // space, '0'-'9', 'A'-'Z' and a little punctuation, upper case only, 32-95.
233
+ // The Commodore packages turn those into screen codes for a character ROM;
234
+ // this host has no ROM, so it draws them as the text they already are.
235
+ const CHAR_BASE = ${CHAR_BASE};
236
+ const COLOR_BASE = ${COLOR_BASE};
237
+ const INPUT_OFFSET = ${INPUT_OFFSET};
238
+ const KEY_TO_EDGE = ${JSON.stringify(KEY_TO_EDGE)};
239
+ const SWIPE_THRESHOLD = ${SWIPE_THRESHOLD};
240
+ const InputEdgeConfirm = ${InputEdge.CONFIRM};
241
+ function swipeEdge(dx, dy) {
242
+ const ax = Math.abs(dx);
243
+ const ay = Math.abs(dy);
244
+ if (ax < SWIPE_THRESHOLD && ay < SWIPE_THRESHOLD) return 0;
245
+ if (ax > ay) return dx < 0 ? ${InputEdge.LEFT} : ${InputEdge.RIGHT};
246
+ return dy < 0 ? ${InputEdge.UP} : ${InputEdge.DOWN};
247
+ }
248
+
249
+ function decodeScreenCode(code) {
250
+ if (code >= 32 && code <= 95) return String.fromCharCode(code);
251
+ return null; // 0 (never written) and everything outside the portable set
252
+ }
253
+
254
+ // The canvas's on-page size, not its pixel grid: as large as fits the
255
+ // window while keeping the screen's own aspect ratio, so it reads as one
256
+ // screen filling the tab rather than a fixed-size box floating in it.
257
+ function resize() {
258
+ const scale = Math.min(window.innerWidth / SCREEN_W, window.innerHeight / SCREEN_H);
259
+ canvas.style.width = Math.floor(SCREEN_W * scale) + 'px';
260
+ canvas.style.height = Math.floor(SCREEN_H * scale) + 'px';
261
+ }
262
+ window.addEventListener('resize', resize);
263
+ document.addEventListener('fullscreenchange', resize);
264
+ resize();
265
+
266
+ function toggleFullscreen() {
267
+ if (document.fullscreenElement) document.exitFullscreen();
268
+ else document.body.requestFullscreen().catch(() => {});
269
+ }
270
+ canvas.addEventListener('dblclick', toggleFullscreen);
271
+ let hintTimer = setTimeout(() => hint.classList.add('hidden'), 3000);
272
+ function say(text) {
273
+ clearTimeout(hintTimer);
274
+ hint.textContent = text;
275
+ hint.classList.remove('hidden');
276
+ }
277
+
278
+ // A real character ROM's 8×8 bits sit entirely inside the cell. System
279
+ // fonts do not: with textBaseline 'top', glyphs still paint a fraction of
280
+ // a pixel above y, which (scaled up, pixelated) is the top of "TICK"
281
+ // clipping into the border. Shift by that overflow so row 0 stays on
282
+ // the background. Clip to the inner rectangle as well — on the VIC-20/C64
283
+ // characters cannot draw in the border, and a font that overflows a cell
284
+ // should not either.
285
+ ctx.font = CHAR_H + 'px ui-monospace, Menlo, monospace';
286
+ ctx.textBaseline = 'top';
287
+ ctx.textAlign = 'left';
288
+ const glyphY = Math.ceil(ctx.measureText('M').actualBoundingBoxAscent || 0);
289
+
290
+ function paint(mem) {
291
+ // Byte 0 is border, byte 1 is background — the same two offsets
292
+ // @8bitscript/web's screen.setColors() writes, agreed on in that
293
+ // package's WebRegisters.
294
+ ctx.fillStyle = COLORS[mem[0] & 15];
295
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
296
+ ctx.fillStyle = COLORS[mem[1] & 15];
297
+ ctx.fillRect(BORDER_PX, BORDER_PX, INNER_W, INNER_H);
298
+ const background = COLORS[mem[1] & 15];
299
+
300
+ // Whatever the program poked into the virtual character screen — this
301
+ // host doesn't know or care what any of it means, the same way a real
302
+ // VIC-20/C64 doesn't know what a program's screen memory says. Blank
303
+ // cells (never written, or written as a literal space) draw nothing,
304
+ // unless reverse video (colour bit 7) is set: then the cell fills with
305
+ // the foreground colour and the glyph is punched out in the background.
306
+ ctx.save();
307
+ ctx.beginPath();
308
+ ctx.rect(BORDER_PX, BORDER_PX, INNER_W, INNER_H);
309
+ ctx.clip();
310
+ for (let cell = 0; cell < GRID_COLS * GRID_ROWS; cell += 1) {
311
+ const colorByte = mem[COLOR_BASE + cell];
312
+ const reverse = (colorByte & 128) !== 0;
313
+ const glyph = decodeScreenCode(mem[CHAR_BASE + cell]);
314
+ if (glyph === null && !reverse) continue;
315
+ const col = cell % GRID_COLS;
316
+ const row = (cell - col) / GRID_COLS;
317
+ const x = BORDER_PX + col * CHAR_W;
318
+ const y = BORDER_PX + row * CHAR_H;
319
+ const fg = COLORS[colorByte & 15];
320
+ if (reverse) {
321
+ ctx.fillStyle = fg;
322
+ ctx.fillRect(x, y, CHAR_W, CHAR_H);
323
+ if (glyph !== null && glyph !== ' ') {
324
+ ctx.fillStyle = background;
325
+ ctx.fillText(glyph, x, y + glyphY);
326
+ }
327
+ } else if (glyph !== null) {
328
+ ctx.fillStyle = fg;
329
+ ctx.fillText(glyph, x, y + glyphY);
330
+ }
331
+ }
332
+ ctx.restore();
333
+ }
334
+
335
+ // The frame clock. Real elapsed time accumulates and is released to the
336
+ // program in fixed logical steps — on a display refreshing at exactly
337
+ // frameRate that is one frame per callback, on a faster one fewer, on a
338
+ // slower one sometimes two. The program is *behind* by however many
339
+ // released frames it hasn't taken yet; when that reaches two, a step is
340
+ // dropped rather than banked — the same rule the 6502 backend's accumulator
341
+ // has, where at most about two frames can ever be owed and the rest are
342
+ // lost. Without it, a program that lagged for a while would race through a
343
+ // backlog at full speed afterwards, which no real machine does.
344
+ const ctrl = new Int32Array(new SharedArrayBuffer(8));
345
+ let mem = null;
346
+ let keysHeld = 0;
347
+ function writeInput() {
348
+ if (mem) mem[INPUT_OFFSET] = keysHeld;
349
+ }
350
+ function setInputBit(bit, down) {
351
+ if (!bit) return;
352
+ if (down) keysHeld |= bit;
353
+ else keysHeld &= ~bit;
354
+ writeInput();
355
+ }
356
+ window.addEventListener('keydown', (e) => {
357
+ const bit = KEY_TO_EDGE[e.key] || 0;
358
+ if (bit) {
359
+ e.preventDefault();
360
+ setInputBit(bit, true);
361
+ }
362
+ if (e.key === 'f' || e.key === 'F') toggleFullscreen();
363
+ });
364
+ window.addEventListener('keyup', (e) => {
365
+ const bit = KEY_TO_EDGE[e.key] || 0;
366
+ if (bit) {
367
+ e.preventDefault();
368
+ setInputBit(bit, false);
369
+ }
370
+ });
371
+ window.addEventListener('blur', () => {
372
+ keysHeld = 0;
373
+ writeInput();
374
+ });
375
+ let pulseTimer = 0;
376
+ function pulseBit(bit) {
377
+ if (!bit) return;
378
+ setInputBit(bit, true);
379
+ clearTimeout(pulseTimer);
380
+ pulseTimer = setTimeout(() => setInputBit(bit, false), 80);
381
+ }
382
+ let pointerStart = null;
383
+ function onPointerDown(e) {
384
+ if (e.pointerType === 'mouse' && e.button !== 0) return;
385
+ pointerStart = { x: e.clientX, y: e.clientY };
386
+ e.preventDefault();
387
+ }
388
+ function onPointerUp(e) {
389
+ if (!pointerStart) return;
390
+ const dx = e.clientX - pointerStart.x;
391
+ const dy = e.clientY - pointerStart.y;
392
+ pointerStart = null;
393
+ e.preventDefault();
394
+ const swipe = swipeEdge(dx, dy);
395
+ pulseBit(swipe || InputEdgeConfirm);
396
+ }
397
+ canvas.addEventListener('pointerdown', onPointerDown);
398
+ canvas.addEventListener('pointerup', onPointerUp);
399
+ canvas.addEventListener('pointercancel', () => { pointerStart = null; });
400
+ canvas.addEventListener('contextmenu', (e) => e.preventDefault());
401
+ let acc = 0;
402
+ let last = null;
403
+ let fpsWindowStart = null;
404
+ let fpsConsumedAtWindowStart = 0;
405
+ function tick(now) {
406
+ if (last === null) last = now;
407
+ acc += now - last;
408
+ last = now;
409
+ // A backgrounded/stalled tab shouldn't spin through a huge backlog of
410
+ // logical frames the instant it regains focus.
411
+ if (acc > LOGICAL_STEP_MS * 10) acc = LOGICAL_STEP_MS * 10;
412
+ while (acc >= LOGICAL_STEP_MS) {
413
+ acc -= LOGICAL_STEP_MS;
414
+ if (Atomics.load(ctrl, ISSUED) - Atomics.load(ctrl, CONSUMED) < 2) {
415
+ Atomics.add(ctrl, ISSUED, 1);
416
+ Atomics.notify(ctrl, ISSUED);
417
+ }
418
+ }
419
+ // How many frames the program actually took in the last real second —
420
+ // the number that answers "is it really running at frameRate," whatever
421
+ // Hz the display refreshes at. Sampled once a second so the digits don't
422
+ // flicker. This host's own diagnostic, drawn outside the canvas: not
423
+ // something the program can see or control.
424
+ if (fpsWindowStart === null) fpsWindowStart = now;
425
+ if (now - fpsWindowStart >= 1000) {
426
+ const consumed = Atomics.load(ctrl, CONSUMED);
427
+ fpsEl.textContent = 'FPS ' + (consumed - fpsConsumedAtWindowStart);
428
+ fpsConsumedAtWindowStart = consumed;
429
+ fpsWindowStart = now;
430
+ }
431
+ if (mem) paint(mem);
432
+ requestAnimationFrame(tick);
433
+ }
434
+
435
+ const worker = new Worker('worker.js');
436
+ worker.onmessage = ({ data }) => {
437
+ if (data.memory) {
438
+ mem = new Uint8Array(data.memory);
439
+ writeInput();
440
+ }
441
+ if (data.done) say('the program finished');
442
+ if (data.error) say('the program failed: ' + data.error);
443
+ };
444
+ worker.onerror = (e) => say('the program failed: ' + e.message);
445
+ worker.postMessage({ ctrl });
446
+ requestAnimationFrame(tick);
447
+ </script>
448
+ </body>
449
+ </html>
450
+ `;
451
+ }
452
+
453
+ function openBrowser(url) {
454
+ if (process.platform === 'darwin') return spawn('open', [url], { stdio: 'ignore' });
455
+ if (process.platform === 'win32') return spawn('cmd', ['/c', 'start', '""', url], { stdio: 'ignore' });
456
+ return spawn('xdg-open', [url], { stdio: 'ignore' });
457
+ }
458
+
459
+ const MIME = {
460
+ '.html': 'text/html; charset=utf-8',
461
+ '.js': 'text/javascript; charset=utf-8',
462
+ '.wasm': 'application/wasm',
463
+ '.txt': 'text/plain; charset=utf-8',
464
+ };
465
+
466
+ /**
467
+ * Write the hostable web bundle: index.html, worker.js, program.wasm, and
468
+ * a Cloudflare `_headers` file with the COOP/COEP isolation headers
469
+ * SharedArrayBuffer needs. `8bs build --target web` writes this to
470
+ * dist/web/; wrangler (or any static host) serves the same directory.
471
+ *
472
+ * @param {string} dir
473
+ * @param {Buffer} wasmBytes
474
+ * @param {{ frameRate?: number }} [options]
475
+ */
476
+ export async function writeWebBundle(dir, wasmBytes, { frameRate = 60 } = {}) {
477
+ await mkdir(dir, { recursive: true });
478
+ await writeFile(join(dir, 'index.html'), renderHtml(frameRate));
479
+ await writeFile(join(dir, 'worker.js'), renderWorker());
480
+ await writeFile(join(dir, 'program.wasm'), wasmBytes);
481
+ await writeFile(join(dir, '_headers'), HEADERS_FILE);
482
+ }
483
+
484
+ /**
485
+ * Serve a program's .wasm with the canvas page and its worker, open it in the
486
+ * system browser, and keep running until the user interrupts (Ctrl+C) —
487
+ * there is no window-close signal to wait on the way VICE gives run.mjs one.
488
+ *
489
+ * When `root` is set, the directory is served as-is (the dist/web/ bundle
490
+ * `8bs build --target web` writes). Otherwise the same files are generated
491
+ * in memory so `8bs run web` still works without a prior build.
492
+ *
493
+ * @param {Buffer} wasmBytes
494
+ * @param {{ open?: boolean, frameRate?: number, root?: string }} [options]
495
+ * @returns {Promise<number>} exit code
496
+ */
497
+ export async function runInBrowser(wasmBytes, { open = true, frameRate = 60, root } = {}) {
498
+ const html = root ? null : renderHtml(frameRate);
499
+ const worker = root ? null : renderWorker();
500
+ const server = createServer((req, res) => {
501
+ const url = new URL(req.url ?? '/', 'http://127.0.0.1');
502
+ let pathname = url.pathname === '/' ? '/index.html' : url.pathname;
503
+ if (root) {
504
+ if (pathname.includes('..')) {
505
+ res.writeHead(404, ISOLATION_HEADERS);
506
+ res.end();
507
+ return;
508
+ }
509
+ const file = join(root, pathname.slice(1));
510
+ const type = MIME[extname(file)] ?? 'application/octet-stream';
511
+ const stream = createReadStream(file);
512
+ stream.on('error', () => {
513
+ res.writeHead(404, ISOLATION_HEADERS);
514
+ res.end();
515
+ });
516
+ stream.on('open', () => {
517
+ res.writeHead(200, { 'Content-Type': type, ...ISOLATION_HEADERS });
518
+ stream.pipe(res);
519
+ });
520
+ return;
521
+ }
522
+ if (pathname === '/index.html') {
523
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', ...ISOLATION_HEADERS });
524
+ res.end(html);
525
+ return;
526
+ }
527
+ if (pathname === '/worker.js') {
528
+ res.writeHead(200, { 'Content-Type': 'text/javascript; charset=utf-8', ...ISOLATION_HEADERS });
529
+ res.end(worker);
530
+ return;
531
+ }
532
+ if (pathname === '/program.wasm') {
533
+ res.writeHead(200, { 'Content-Type': 'application/wasm', ...ISOLATION_HEADERS });
534
+ res.end(wasmBytes);
535
+ return;
536
+ }
537
+ res.writeHead(404, ISOLATION_HEADERS);
538
+ res.end();
539
+ });
540
+
541
+ await new Promise((resolveListen) => server.listen(0, '127.0.0.1', resolveListen));
542
+ const { port } = server.address();
543
+ const url = `http://127.0.0.1:${port}/`;
544
+ process.stdout.write(`serving ${url}\n`);
545
+ process.stdout.write(
546
+ 'in VS Code or Cursor: Cmd/Ctrl+Shift+P -> "Simple Browser: Show" -> paste that URL, ' +
547
+ 'to view it inside the editor.\n',
548
+ );
549
+ if (open) openBrowser(url);
550
+ process.stdout.write('press Ctrl+C to stop. (in the page: swipe or arrows to move; F for fullscreen)\n');
551
+
552
+ return new Promise((resolvePromise) => {
553
+ process.on('SIGINT', () => {
554
+ server.close(() => resolvePromise(0));
555
+ });
556
+ });
557
+ }