@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.
- package/LICENSE +21 -0
- package/bin/8bs.mjs +132 -0
- package/package.json +39 -0
- package/src/build.mjs +309 -0
- package/src/check.mjs +67 -0
- package/src/config.mjs +46 -0
- package/src/doctor.mjs +976 -0
- package/src/font8x8.mjs +27 -0
- package/src/hardware.mjs +444 -0
- package/src/mac-window-capture.mjs +150 -0
- package/src/png.mjs +158 -0
- package/src/run.mjs +311 -0
- package/src/screenshot.mjs +485 -0
- package/src/setup/cx16.mjs +510 -0
- package/src/setup/deps.mjs +103 -0
- package/src/setup/exec.mjs +110 -0
- package/src/setup/host.mjs +58 -0
- package/src/setup/install.mjs +47 -0
- package/src/setup/launcher.mjs +112 -0
- package/src/setup/mega65-rom.mjs +165 -0
- package/src/setup/mega65.mjs +515 -0
- package/src/setup/paths.mjs +96 -0
- package/src/setup/prompt.mjs +28 -0
- package/src/setup/report.mjs +13 -0
- package/src/setup/rom.mjs +188 -0
- package/src/setup/source.mjs +60 -0
- package/src/setup/xemu.mjs +107 -0
- package/src/setup/zip.mjs +75 -0
- package/src/setup.mjs +56 -0
- package/src/targets.mjs +158 -0
- package/src/wasm-host.mjs +75 -0
- package/src/web-runtime.mjs +557 -0
|
@@ -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
|
+
}
|