@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,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
|
+
}
|