@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,485 @@
1
+ // `8bs run <target> --screenshot <file>` — build, then capture one PNG of
2
+ // what the program is doing, through whichever mechanism that target's own
3
+ // emulator offers, instead of opening an interactive window. The point is
4
+ // letting an agent (or a script) see a program's actual output without a
5
+ // human at the keyboard or a general-purpose "grab my screen" tool: every
6
+ // target here calls into something the emulator itself exposes for exactly
7
+ // this — VICE's -exitscreenshot, Xemu's -screenshot, FCEUX's
8
+ // gui.savescreenshotas — except atari8, which has no such flag (see its
9
+ // section below) and falls back to macOS capturing just that one window's
10
+ // real pixels, with Screen Recording permission and no synthetic input.
11
+ //
12
+ // --frames means a different unit on every target, because what's being
13
+ // counted really is different hardware — see each capture function's own
14
+ // comment. In every case, count generously: the number has to cover
15
+ // whatever the machine's own boot sequence (BASIC's power-on banner, the
16
+ // KERNAL's autostart, an NES cartridge's reset handler) costs before the
17
+ // *program's* first real frame, not just the frames you want to see after
18
+ // that. Each target's DEFAULT_FRAMES was chosen by testing against
19
+ // examples/borders until the boot sequence had clearly cleared.
20
+ import { spawn } from 'node:child_process';
21
+ import {
22
+ access, mkdir, mkdtemp, readFile, rm, writeFile,
23
+ } from 'node:fs/promises';
24
+ import { tmpdir } from 'node:os';
25
+ import { join } from 'node:path';
26
+
27
+ import {
28
+ DEFAULT_LOAD, VICE_EMULATOR, VICE_EMULATOR_ARGS, VICE_MODEL_ARGS,
29
+ atari800CleanDisplayConfig,
30
+ } from './run.mjs';
31
+ import { loadArgs, loadCatalog, resolveHardware } from './hardware.mjs';
32
+
33
+ /** The stock hardware, for a caller that did not resolve any. */
34
+ const stockHardware = (target) => resolveHardware(loadCatalog(target)).hardware;
35
+ import { encodePNG } from './png.mjs';
36
+ import { runProgram } from './wasm-host.mjs';
37
+ import { glyphRows } from './font8x8.mjs';
38
+ import {
39
+ BORDER_PX, CHAR_BASE, CHAR_H, CHAR_W, COLOR_BASE, COLORS, GRID_COLS, GRID_ROWS,
40
+ } from './web-runtime.mjs';
41
+
42
+ function run(command, args) {
43
+ return new Promise((resolvePromise, rejectPromise) => {
44
+ let child;
45
+ try {
46
+ child = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'] });
47
+ } catch (err) {
48
+ rejectPromise(err);
49
+ return;
50
+ }
51
+ let stdout = '';
52
+ let stderr = '';
53
+ child.stdout.on('data', (d) => { stdout += d; });
54
+ child.stderr.on('data', (d) => { stderr += d; });
55
+ child.on('error', rejectPromise);
56
+ child.on('close', (code) => resolvePromise({ code, stdout, stderr }));
57
+ });
58
+ }
59
+
60
+ function sleep(ms) {
61
+ return new Promise((resolvePromise) => { setTimeout(resolvePromise, ms); });
62
+ }
63
+
64
+ function pidAlive(pid) {
65
+ try { process.kill(pid, 0); return true; } catch { return false; }
66
+ }
67
+
68
+ // atari800 screenshots are a real window plus screencapture: two of those
69
+ // at once share a display, a config file, and (under `pnpm test`) a CPU
70
+ // that makes the wall-clock `--frames` wait fire before the OS has
71
+ // painted. One capture at a time, with a stale-pid break so a killed test
72
+ // cannot leave the next one waiting forever.
73
+ const ATARI8_LOCK = join(tmpdir(), '8bs-atari8-screenshot.lock');
74
+
75
+ async function withAtari8ScreenshotLock(fn) {
76
+ const started = Date.now();
77
+ while (true) {
78
+ try {
79
+ await mkdir(ATARI8_LOCK);
80
+ await writeFile(join(ATARI8_LOCK, 'pid'), String(process.pid));
81
+ break;
82
+ } catch (err) {
83
+ if (err.code !== 'EEXIST') throw err;
84
+ try {
85
+ const holder = Number.parseInt(await readFile(join(ATARI8_LOCK, 'pid'), 'utf8'), 10);
86
+ if (!Number.isFinite(holder) || !pidAlive(holder)) {
87
+ await rm(ATARI8_LOCK, { recursive: true, force: true });
88
+ continue;
89
+ }
90
+ } catch {
91
+ await rm(ATARI8_LOCK, { recursive: true, force: true }).catch(() => {});
92
+ continue;
93
+ }
94
+ if (Date.now() - started > 180_000) {
95
+ throw new Error('8bs run: timed out waiting for another atari8 screenshot to finish');
96
+ }
97
+ await sleep(100);
98
+ }
99
+ }
100
+ try {
101
+ return await fn();
102
+ } finally {
103
+ await rm(ATARI8_LOCK, { recursive: true, force: true });
104
+ }
105
+ }
106
+
107
+ async function fileExists(path) {
108
+ try {
109
+ await access(path);
110
+ return true;
111
+ } catch {
112
+ return false;
113
+ }
114
+ }
115
+
116
+ /**
117
+ * Ask a child process to quit and wait for it to actually do so — SIGTERM
118
+ * first, SIGKILL after `graceMs` if it's still alive — the same two-stage
119
+ * shutdown packages/cli/test/emulator-smoke.test.mjs already relies on for
120
+ * atari800/xmega65/fceux, all of which either ignore SIGTERM or need a
121
+ * moment to flush state (Xemu's screenshot, FCEUX's log) before exiting.
122
+ * A fixed `sleep` after `kill()` has no way to know that flush finished;
123
+ * waiting on 'close' does.
124
+ */
125
+ function terminateAndWait(child, { graceMs = 2000 } = {}) {
126
+ return new Promise((resolvePromise) => {
127
+ let killTimer;
128
+ child.once('close', () => { clearTimeout(killTimer); resolvePromise(); });
129
+ child.kill('SIGTERM');
130
+ killTimer = setTimeout(() => child.kill('SIGKILL'), graceMs);
131
+ });
132
+ }
133
+
134
+ // ---- VICE (vic20/c64/pet/c128) ---------------------------------------------
135
+ //
136
+ // -limitcycles makes VICE run exactly that many CPU cycles under -warp
137
+ // (as fast as the host can) and then quit on its own — the same technique
138
+ // packages/cli/test/emulator-smoke.test.mjs and doctor.mjs's VIC-20 boot
139
+ // check already use to prove a build is alive, extended here to name a real
140
+ // output file instead of a throwaway temp one. c128 needs
141
+ // -exitscreenshotvicii, not -exitscreenshot: x128 drives two displays (the
142
+ // VIC-IIe this target actually draws to, and an unused 80-column VDC —
143
+ // see VICE_EMULATOR_ARGS's -hidevdcwindow comment in run.mjs), and plain
144
+ // -exitscreenshot grabs the VDC's blank power-on RAM, not the screen this
145
+ // target draws to.
146
+ //
147
+ // Real NTSC/PAL CPU clocks (Hz), taken from the same crystal/divisor
148
+ // figures documented on packages/backend-6502's FRAME_SYNC (vic20/c64/c128
149
+ // share the C64's clock derivation) — used only to convert an explicit
150
+ // --frames into a cycle count; DEFAULT_CYCLES below is what a plain
151
+ // `--screenshot` with no --frames uses.
152
+ const VICE_CLOCK_HZ = {
153
+ vic20: { ntsc: 1_022_727, pal: 1_108_405 },
154
+ c64: { ntsc: 1_022_727, pal: 985_248 },
155
+ c128: { ntsc: 1_022_727, pal: 985_248 },
156
+ };
157
+ const VICE_FPS = { ntsc: 60, pal: 50 };
158
+
159
+ // -limitcycles values confirmed in this project's own testing to comfortably
160
+ // clear -autostartprgmode's BASIC/KERNAL boot and land on examples/borders'
161
+ // own steady state (not the boot banner), checked by eye against the
162
+ // resulting PNG on each machine individually. These are not derived from a
163
+ // shared formula across machines and shouldn't be compared to each other —
164
+ // each is just "generously past boot," picked per machine.
165
+ const VICE_DEFAULT_CYCLES = {
166
+ vic20: 14_000_000, c64: 5_000_000, pet: 8_000_000, c128: 8_000_000,
167
+ };
168
+ // The PET's CPU clock is a flat, region-independent 1MHz (FRAME_SYNC.pet
169
+ // in backend-6502). Video refresh is the xpet model's — the catalog's
170
+ // `video.frameRate` fact for the model fitted (the 3xxx ~60Hz, the CRTC
171
+ // models 50Hz); these numbers only convert an explicit --frames into a
172
+ // cycle count. The program still measures the actual period at startup.
173
+ const PET_CLOCK_HZ = 1_000_000;
174
+
175
+ function viceCycles(target, region, frames, hardware) {
176
+ if (frames === undefined) return VICE_DEFAULT_CYCLES[target];
177
+ const clockHz = target === 'pet' ? PET_CLOCK_HZ : VICE_CLOCK_HZ[target][region];
178
+ const fps = target === 'pet' ? (hardware.facts['video.frameRate'] ?? 60) : VICE_FPS[region];
179
+ return Math.round((clockHz * frames) / fps);
180
+ }
181
+
182
+ async function viceScreenshot(target, outFile, screenshotPath, { pal, hardware = stockHardware(target), frames }) {
183
+ const region = pal ? 'pal' : 'ntsc';
184
+ const emulator = VICE_EMULATOR[target];
185
+ const cycles = viceCycles(target, region, frames, hardware);
186
+ const exitFlag = target === 'c128' ? '-exitscreenshotvicii' : '-exitscreenshot';
187
+
188
+ // The same hardware flags the interactive `8bs run` passes (the
189
+ // catalog's `run` list), so a screenshot reflects the REU, the mouse, the
190
+ // model the program was built for instead of silently running without.
191
+ const args = [
192
+ '-default', '-warp', '+sound',
193
+ ...(VICE_EMULATOR_ARGS[target] ?? []),
194
+ ...(VICE_MODEL_ARGS[target]?.[region] ?? []),
195
+ ...(hardware.run[emulator] ?? []),
196
+ '-limitcycles', String(cycles),
197
+ '+confirmonexit',
198
+ ...loadArgs(hardware, emulator, outFile, DEFAULT_LOAD[emulator](outFile)),
199
+ exitFlag, screenshotPath,
200
+ ];
201
+ const { stderr } = await run(emulator, args);
202
+ if (!(await fileExists(screenshotPath))) {
203
+ throw new Error(`8bs run: ${emulator} did not produce a screenshot:\n${stderr.slice(-500)}`);
204
+ }
205
+ }
206
+
207
+ // ---- Atari 8-bit (atari800) -------------------------------------------------
208
+ //
209
+ // atari800 has no exit-and-screenshot flag the way VICE and Xemu do — its
210
+ // -screenshots flag only sets the filename *pattern* for screenshots taken
211
+ // from the running UI (its own AKEY_SCREENSHOT hotkey), and there's no CLI
212
+ // or monitor-console way this project found to trigger that hotkey without
213
+ // a real keypress. So this launches the real windowed emulator, waits for
214
+ // --frames worth of real time (atari800 has no -limitcycles-style flag
215
+ // either), and asks macOS to capture that one window's actual pixels,
216
+ // matched by this child's own PID (not by process name — an interactive
217
+ // `8bs run atari8` window left open elsewhere on the same machine would
218
+ // otherwise be a second, indistinguishable "atari800" window to pick from)
219
+ // — see mac-window-capture.mjs's header comment for exactly what permission
220
+ // that needs and why it isn't the same thing as OS-level keystroke
221
+ // injection. Not available on non-macOS hosts.
222
+ const ATARI8_FPS = 60;
223
+ const ATARI8_DEFAULT_FRAMES = 240; // ~4s: measured enough for the OS boot and a program's own steady state
224
+
225
+ async function atari8Screenshot(outFile, screenshotPath, { pal, hardware = stockHardware('atari8'), frames }) {
226
+ if (process.platform !== 'darwin') {
227
+ throw new Error('8bs run: atari8 --screenshot needs macOS (window capture via Screen Recording permission); no equivalent has been wired up for this platform yet.');
228
+ }
229
+ return withAtari8ScreenshotLock(async () => {
230
+ const { findWindowIdForPid, captureWindow } = await import('./mac-window-capture.mjs');
231
+ const displayCfg = await atari800CleanDisplayConfig();
232
+ const args = [
233
+ ...(displayCfg ? ['-config', displayCfg, '-no-autosave-config'] : []),
234
+ ...(hardware.run.atari800 ?? []),
235
+ pal ? '-pal' : '-ntsc',
236
+ // An XEGS cartridge is not an executable: the catalog's `load` says
237
+ // `-cart ... -cart-type 23` where the default would be `-run`.
238
+ ...loadArgs(hardware, 'atari800', outFile, DEFAULT_LOAD.atari800(outFile)),
239
+ ];
240
+ const child = spawn('atari800', args, { stdio: 'ignore' });
241
+ try {
242
+ await sleep(1000 * ((frames ?? ATARI8_DEFAULT_FRAMES) / ATARI8_FPS));
243
+ let windowId = null;
244
+ for (let attempt = 0; attempt < 10; attempt += 1) {
245
+ windowId = await findWindowIdForPid(child.pid);
246
+ if (windowId !== null) break;
247
+ await sleep(250);
248
+ }
249
+ if (windowId === null) throw new Error('8bs run: could not find the atari800 window to capture.');
250
+ await captureWindow(windowId, screenshotPath);
251
+ } finally {
252
+ child.kill('SIGKILL');
253
+ }
254
+ });
255
+ }
256
+
257
+ // ---- Commander X16 (x16emu) -------------------------------------------------
258
+ //
259
+ // x16emu's own screenshot hotkey (F12, per its README) triggers a UI action
260
+ // this project found no CLI/monitor equivalent for either, but -gif *does*
261
+ // work headlessly: it records the video output to a file for as long as
262
+ // the emulator runs, no window interaction needed. So this records for
263
+ // --frames worth of real time, terminates the emulator (waiting for the
264
+ // GIF file to actually be closed, not a fixed sleep), and asks ffmpeg for
265
+ // the GIF's last frame as a still PNG — ffmpeg is a hard requirement of
266
+ // this path (checked up front, not left to a cryptic spawn ENOENT).
267
+ const CX16_FPS = 60;
268
+ const CX16_DEFAULT_FRAMES = 300; // ~5s: measured enough for -run's BASIC RUN and a program's own steady state
269
+
270
+ async function cx16Screenshot(outFile, screenshotPath, { frames, hardware = stockHardware('cx16') }) {
271
+ const ffmpegCheck = await run('ffmpeg', ['-version']).catch(() => ({ code: 1 }));
272
+ if (ffmpegCheck.code !== 0) {
273
+ throw new Error('8bs run: cx16 --screenshot needs ffmpeg on PATH (to pull a still frame out of x16emu\'s -gif recording).');
274
+ }
275
+ const scratch = await mkdtemp(join(tmpdir(), '8bs-cx16-shot-'));
276
+ const gifPath = join(scratch, 'capture.gif');
277
+ try {
278
+ // The catalog's own flags first (the banked-RAM size, the mouse grab),
279
+ // then how the built file is handed over — the same list the interactive
280
+ // `8bs run` passes, so a screenshot reflects the hardware the program
281
+ // was built for instead of silently running on the emulator's defaults.
282
+ // Keep `-capture`. Without it, x16emu reports the host cursor as off
283
+ // the window and mouse_scan slams the KERNAL pointer to the last cell
284
+ // (examples/pointer printed CELL 04255); the sprite sits off-screen
285
+ // and packages/pointer's centre-arrow count is 0. An earlier x16emu
286
+ // exited 13 combining `-capture` with a gif and no window; r50
287
+ // ("next" 77f2bab3) records the gif with `-capture` and the arrow
288
+ // is in the still (measured: 43 white pixels in the 24×24 centre box,
289
+ // 0 without `-capture`).
290
+ const args = [
291
+ ...(hardware.run.x16emu ?? []),
292
+ ...loadArgs(hardware, 'x16emu', outFile, ['-prg', outFile, '-run']),
293
+ '-gif', gifPath, '-sound', 'none',
294
+ ];
295
+ const child = spawn('x16emu', args, { stdio: 'ignore' });
296
+ await sleep(1000 * ((frames ?? CX16_DEFAULT_FRAMES) / CX16_FPS));
297
+ await terminateAndWait(child);
298
+ const { code, stderr } = await run('ffmpeg', ['-y', '-sseof', '-0.1', '-i', gifPath, '-update', '1', '-frames:v', '1', screenshotPath]);
299
+ if (code !== 0 || !(await fileExists(screenshotPath))) {
300
+ throw new Error(`8bs run: ffmpeg could not extract a still frame from x16emu's recording:\n${stderr.slice(-500)}`);
301
+ }
302
+ } finally {
303
+ await rm(scratch, { recursive: true, force: true });
304
+ }
305
+ }
306
+
307
+ // ---- MEGA65 (Xemu's xmega65) ------------------------------------------------
308
+ //
309
+ // -screenshot <file> is a real Xemu flag: "Save screenshot (PNG) on exit".
310
+ // This project's own testing found it also fires on a plain SIGTERM
311
+ // (Xemu's normal shutdown path runs the same exit handler as a clean quit,
312
+ // unlike fceux/atari800 where SIGTERM just kills the process) — so this
313
+ // waits for --frames worth of real time, then terminates it and waits for
314
+ // the process to actually close (Xemu block-buffers its own log/exit
315
+ // handling, so a fixed sleep after kill() has no way to know the PNG write
316
+ // finished) before confirming the file landed.
317
+ const MEGA65_FPS = 60;
318
+ const MEGA65_DEFAULT_FRAMES = 480; // ~8s: measured enough for Hyppo boot + the READY. autoload inject
319
+
320
+ async function mega65Screenshot(outFile, screenshotPath, { pal, frames, hardware = stockHardware('mega65') }) {
321
+ const args = [
322
+ '-besure', '-screenshot', screenshotPath,
323
+ ...(hardware.run.xmega65 ?? []),
324
+ ...loadArgs(hardware, 'xmega65', outFile, ['-prg', outFile]),
325
+ '-videostd', pal ? '0' : '1',
326
+ ];
327
+ const child = spawn('xmega65', args, { stdio: 'ignore' });
328
+ await sleep(1000 * ((frames ?? MEGA65_DEFAULT_FRAMES) / MEGA65_FPS));
329
+ await terminateAndWait(child, { graceMs: 3000 });
330
+ if (!(await fileExists(screenshotPath))) {
331
+ throw new Error('8bs run: xmega65 did not produce a screenshot.');
332
+ }
333
+ }
334
+
335
+ // ---- NES (FCEUX) -------------------------------------------------------------
336
+ //
337
+ // FCEUX's Lua scripting is its only headless-friendly control surface (no
338
+ // CLI flag runs N frames and exits): --loadlua runs a script alongside the
339
+ // ROM, so this writes a small script (LUA_SCRIPT below) that advances
340
+ // exactly --frames emulated frames, calls gui.savescreenshotas, then exits
341
+ // the emulator itself — frame-exact, unlike atari8/mega65/cx16's wall-clock
342
+ // waits, because Lua's emu.frameadvance() is a real per-frame hook, not a
343
+ // timer.
344
+ const NES_DEFAULT_FRAMES = 120; // ~2s: measured enough for the NES's own reset handler and a program's own steady state
345
+
346
+ function nesLuaScript(frames, screenshotPath) {
347
+ // Lua single-quoted strings: only ' and \ need escaping for a path.
348
+ const escaped = screenshotPath.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
349
+ return [
350
+ 'emu.speedmode("nothrottle")',
351
+ `for i = 1, ${Math.max(1, Math.round(frames))} do emu.frameadvance() end`,
352
+ `gui.savescreenshotas('${escaped}')`,
353
+ 'emu.frameadvance()',
354
+ 'if emu.exit then emu.exit() end',
355
+ ].join('\n');
356
+ }
357
+
358
+ async function nesScreenshot(outFile, screenshotPath, { frames, hardware = stockHardware('nes') }) {
359
+ const scratch = await mkdtemp(join(tmpdir(), '8bs-nes-shot-'));
360
+ const luaPath = join(scratch, 'screenshot.lua');
361
+ try {
362
+ await writeFile(luaPath, nesLuaScript(frames ?? NES_DEFAULT_FRAMES, screenshotPath));
363
+ const { stderr } = await run('fceux', [
364
+ '--no-config', '1', '--loadlua', luaPath,
365
+ ...(hardware.run.fceux ?? []),
366
+ ...loadArgs(hardware, 'fceux', outFile, [outFile]),
367
+ ]);
368
+ if (!(await fileExists(screenshotPath))) {
369
+ throw new Error(`8bs run: fceux did not produce a screenshot:\n${stderr.slice(-500)}`);
370
+ }
371
+ } finally {
372
+ await rm(scratch, { recursive: true, force: true });
373
+ }
374
+ }
375
+
376
+ // ---- Web (Node's own WebAssembly runtime) -----------------------------------
377
+ //
378
+ // The cleanest of the nine: no emulator, no process, no timing guesswork.
379
+ // This runs the real .wasm build for exactly --frames waitFrame() calls (or
380
+ // until it returns), then rasterizes the exact same virtual screen web-runtime.mjs's
381
+ // browser canvas draws — imported from there directly (COLORS, the grid/
382
+ // border layout, CHAR_BASE/COLOR_BASE) so there's exactly one place that
383
+ // describes this layout, not two hand-synced copies — using an 8x8 bitmap
384
+ // font instead of a browser's own text renderer, and writes the result out
385
+ // with png.mjs.
386
+ const WEB_DEFAULT_FRAME_SECONDS = 3;
387
+
388
+ // COLORS is a list of CSS hex strings (a canvas fillStyle); the PNG
389
+ // rasterizer below needs RGB triples instead.
390
+ const RGB_COLORS = COLORS.map((hex) => [
391
+ Number.parseInt(hex.slice(1, 3), 16),
392
+ Number.parseInt(hex.slice(3, 5), 16),
393
+ Number.parseInt(hex.slice(5, 7), 16),
394
+ ]);
395
+
396
+ function setPixel(rgba, width, x, y, [r, g, b]) {
397
+ const i = (y * width + x) * 4;
398
+ rgba[i] = r; rgba[i + 1] = g; rgba[i + 2] = b; rgba[i + 3] = 255;
399
+ }
400
+
401
+ function fillRect(rgba, width, x0, y0, w, h, color) {
402
+ for (let y = y0; y < y0 + h; y += 1) {
403
+ for (let x = x0; x < x0 + w; x += 1) setPixel(rgba, width, x, y, color);
404
+ }
405
+ }
406
+
407
+ async function webScreenshot(outFile, screenshotPath, { frames, frameRate = 60 }) {
408
+ const bytes = await readFile(outFile);
409
+ // The program runs until it returns or has taken --frames waitFrame()s
410
+ // (3 logical seconds' worth by default), whichever comes first; a program
411
+ // that never calls waitFrame() and never returns cannot be bounded and
412
+ // would spin here, exactly as it would on a real machine.
413
+ const { memory } = await runProgram(bytes, { frames: frames ?? frameRate * WEB_DEFAULT_FRAME_SECONDS });
414
+
415
+ const mem = new Uint8Array(memory.buffer);
416
+ const innerW = GRID_COLS * CHAR_W;
417
+ const innerH = GRID_ROWS * CHAR_H;
418
+ const width = innerW + BORDER_PX * 2;
419
+ const height = innerH + BORDER_PX * 2;
420
+ const rgba = new Uint8Array(width * height * 4);
421
+
422
+ fillRect(rgba, width, 0, 0, width, height, RGB_COLORS[mem[0] & 15]);
423
+ fillRect(rgba, width, BORDER_PX, BORDER_PX, innerW, innerH, RGB_COLORS[mem[1] & 15]);
424
+ const background = RGB_COLORS[mem[1] & 15];
425
+
426
+ for (let cell = 0; cell < GRID_COLS * GRID_ROWS; cell += 1) {
427
+ const code = mem[CHAR_BASE + cell];
428
+ const colorByte = mem[COLOR_BASE + cell];
429
+ const reverse = (colorByte & 128) !== 0;
430
+ const rows = glyphRows(code);
431
+ if (rows === null && !reverse) continue;
432
+ const col = cell % GRID_COLS;
433
+ const row = (cell - col) / GRID_COLS;
434
+ const color = RGB_COLORS[colorByte & 15];
435
+ const x0 = BORDER_PX + col * CHAR_W;
436
+ const y0 = BORDER_PX + row * CHAR_H;
437
+ if (reverse) {
438
+ fillRect(rgba, width, x0, y0, CHAR_W, CHAR_H, color);
439
+ }
440
+ if (rows !== null) {
441
+ const ink = reverse ? background : color;
442
+ for (let gy = 0; gy < 8; gy += 1) {
443
+ const bits = rows[gy];
444
+ for (let gx = 0; gx < 8; gx += 1) {
445
+ if ((bits >> gx) & 1) setPixel(rgba, width, x0 + gx, y0 + gy, ink);
446
+ }
447
+ }
448
+ }
449
+ }
450
+
451
+ await writeFile(screenshotPath, encodePNG(width, height, rgba));
452
+ }
453
+
454
+ /**
455
+ * Build `target` and capture one screenshot of the result to `screenshotPath`.
456
+ * Any stale file already at `screenshotPath` is removed first, so a failed
457
+ * capture can never be mistaken for a fresh one by its mere presence.
458
+ * @param {string} target
459
+ * @param {string} outFile The already-built file (from build.mjs's compile()).
460
+ * @param {string} screenshotPath
461
+ * @param {{ pal?: boolean, hardware?: object, frames?: number, frameRate?: number }} [options]
462
+ * `hardware` is the resolved hardware the program was built for (from
463
+ * compile()); left off, the stock machine.
464
+ * `frameRate` (default 60) only matters for the `web` target, whose default
465
+ * `--frames` count (3 logical seconds' worth) scales with it.
466
+ * @returns {Promise<void>}
467
+ */
468
+ export async function captureScreenshot(target, outFile, screenshotPath, options = {}) {
469
+ await rm(screenshotPath, { force: true });
470
+ if (target in VICE_EMULATOR) {
471
+ await viceScreenshot(target, outFile, screenshotPath, options);
472
+ } else if (target === 'atari8') {
473
+ await atari8Screenshot(outFile, screenshotPath, options);
474
+ } else if (target === 'cx16') {
475
+ await cx16Screenshot(outFile, screenshotPath, options);
476
+ } else if (target === 'mega65') {
477
+ await mega65Screenshot(outFile, screenshotPath, options);
478
+ } else if (target === 'nes') {
479
+ await nesScreenshot(outFile, screenshotPath, options);
480
+ } else if (target === 'web') {
481
+ await webScreenshot(outFile, screenshotPath, options);
482
+ } else {
483
+ throw new Error(`8bs run: no screenshot method wired up for target '${target}'`);
484
+ }
485
+ }