@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/src/png.mjs ADDED
@@ -0,0 +1,158 @@
1
+ // A minimal PNG encoder — just enough to turn a raw RGBA buffer into a
2
+ // `.png` file, with no dependency beyond Node's own zlib. Used by the
3
+ // `--screenshot` targets that have no emulator-native PNG writer of their
4
+ // own to call into (see screenshot.mjs's web target): once a target can
5
+ // ask its own emulator to save a PNG (VICE's -exitscreenshot, Xemu's
6
+ // -screenshot, FCEUX's gui.savescreenshotas), that's always preferred —
7
+ // this exists only for the one target with nothing to ask.
8
+ import { deflateSync, inflateSync } from 'node:zlib';
9
+
10
+ // The standard CRC-32 (IEEE 802.3) table PNG's spec requires for every
11
+ // chunk's trailing checksum.
12
+ const CRC_TABLE = (() => {
13
+ const table = new Uint32Array(256);
14
+ for (let n = 0; n < 256; n += 1) {
15
+ let c = n;
16
+ for (let k = 0; k < 8; k += 1) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
17
+ table[n] = c >>> 0;
18
+ }
19
+ return table;
20
+ })();
21
+
22
+ function crc32(buf) {
23
+ let c = 0xffffffff;
24
+ for (let i = 0; i < buf.length; i += 1) c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8);
25
+ return (c ^ 0xffffffff) >>> 0;
26
+ }
27
+
28
+ function chunk(type, data) {
29
+ const typeBuf = Buffer.from(type, 'ascii');
30
+ const lenBuf = Buffer.alloc(4);
31
+ lenBuf.writeUInt32BE(data.length, 0);
32
+ const crcBuf = Buffer.alloc(4);
33
+ crcBuf.writeUInt32BE(crc32(Buffer.concat([typeBuf, data])), 0);
34
+ return Buffer.concat([lenBuf, typeBuf, data, crcBuf]);
35
+ }
36
+
37
+ const SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
38
+
39
+ /**
40
+ * Encode an RGBA pixel buffer (width*height*4 bytes, row-major, no padding)
41
+ * as a PNG file's bytes — 8-bit-per-channel, no filtering (filter type 0 on
42
+ * every scanline; the image sizes this project draws are small enough that
43
+ * skipping adaptive filtering costs a few hundred bytes, not correctness).
44
+ *
45
+ * @param {number} width
46
+ * @param {number} height
47
+ * @param {Uint8Array | Buffer} rgba
48
+ * @returns {Buffer}
49
+ */
50
+ export function encodePNG(width, height, rgba) {
51
+ if (rgba.length !== width * height * 4) {
52
+ throw new Error(`png: expected ${width * height * 4} bytes for ${width}x${height} RGBA, got ${rgba.length}`);
53
+ }
54
+ const ihdr = Buffer.alloc(13);
55
+ ihdr.writeUInt32BE(width, 0);
56
+ ihdr.writeUInt32BE(height, 4);
57
+ ihdr[8] = 8; // bit depth
58
+ ihdr[9] = 6; // color type 6 = RGBA
59
+ ihdr[10] = 0; // compression method
60
+ ihdr[11] = 0; // filter method
61
+ ihdr[12] = 0; // interlace method
62
+
63
+ const stride = width * 4;
64
+ const raw = Buffer.alloc((stride + 1) * height);
65
+ for (let y = 0; y < height; y += 1) {
66
+ raw[y * (stride + 1)] = 0; // filter type 0 (None) for every row
67
+ Buffer.from(rgba.buffer ?? rgba, rgba.byteOffset ?? 0, rgba.length)
68
+ .copy(raw, y * (stride + 1) + 1, y * stride, y * stride + stride);
69
+ }
70
+ const idat = deflateSync(raw);
71
+
72
+ return Buffer.concat([
73
+ SIGNATURE,
74
+ chunk('IHDR', ihdr),
75
+ chunk('IDAT', idat),
76
+ chunk('IEND', Buffer.alloc(0)),
77
+ ]);
78
+ }
79
+
80
+ // ---- and just enough of a decoder to read one pixel back ----------------
81
+ //
82
+ // The machine packages' hardware probes are verified by running them under
83
+ // a real emulator and screenshotting the result: the probe encodes its
84
+ // answer as a border colour, and the test reads one pixel of the border
85
+ // rather than trying to recognise text (see packages/c64/test/reu.test.mjs
86
+ // and packages/cx16/test/banks.test.mjs). This reads that pixel. It
87
+ // handles what the emulators actually write — 8 bits a channel, RGB, RGBA
88
+ // or palette, not interlaced — and throws on anything else rather than
89
+ // guessing.
90
+
91
+ /**
92
+ * The colour at (x, y) of a PNG file, as `[r, g, b]`.
93
+ *
94
+ * @param {Buffer} buf the file's bytes
95
+ * @param {number} x
96
+ * @param {number} y
97
+ * @returns {[number, number, number]}
98
+ */
99
+ export function pixelAt(buf, x, y) {
100
+ const signature = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
101
+ if (!buf.subarray(0, 8).equals(signature)) throw new Error('not a PNG');
102
+ let offset = 8;
103
+ let width = 0; let height = 0; let depth = 0; let colorType = 0; let interlace = 0;
104
+ let palette = null;
105
+ const idat = [];
106
+ while (offset + 8 <= buf.length) {
107
+ const length = buf.readUInt32BE(offset);
108
+ const type = buf.toString('ascii', offset + 4, offset + 8);
109
+ const data = buf.subarray(offset + 8, offset + 8 + length);
110
+ if (type === 'IHDR') {
111
+ width = data.readUInt32BE(0); height = data.readUInt32BE(4);
112
+ depth = data[8]; colorType = data[9]; interlace = data[12];
113
+ } else if (type === 'PLTE') palette = data;
114
+ else if (type === 'IDAT') idat.push(data);
115
+ else if (type === 'IEND') break;
116
+ offset += 12 + length;
117
+ }
118
+ if (depth !== 8) throw new Error(`PNG bit depth ${depth} is not 8`);
119
+ if (interlace !== 0) throw new Error('interlaced PNG');
120
+ if (x >= width || y >= height) throw new Error(`(${x}, ${y}) is outside a ${width}x${height} image`);
121
+ const channels = { 0: 1, 2: 3, 3: 1, 4: 2, 6: 4 }[colorType];
122
+ if (!channels) throw new Error(`PNG colour type ${colorType}`);
123
+ const raw = inflateSync(Buffer.concat(idat));
124
+ const stride = width * channels;
125
+ // Every row's filter is relative to the row above, so rows up to y have
126
+ // to be undone in order; nothing below y is touched.
127
+ let previous = Buffer.alloc(stride);
128
+ let line = previous;
129
+ for (let row = 0; row <= y; row += 1) {
130
+ const filter = raw[row * (stride + 1)];
131
+ line = Buffer.from(raw.subarray(row * (stride + 1) + 1, (row + 1) * (stride + 1)));
132
+ for (let i = 0; i < stride; i += 1) {
133
+ const a = i >= channels ? line[i - channels] : 0;
134
+ const b = previous[i];
135
+ const c = i >= channels ? previous[i - channels] : 0;
136
+ let predictor = 0;
137
+ if (filter === 1) predictor = a;
138
+ else if (filter === 2) predictor = b;
139
+ else if (filter === 3) predictor = (a + b) >> 1;
140
+ else if (filter === 4) {
141
+ const p = a + b - c;
142
+ const pa = Math.abs(p - a); const pb = Math.abs(p - b); const pc = Math.abs(p - c);
143
+ predictor = pa <= pb && pa <= pc ? a : (pb <= pc ? b : c);
144
+ }
145
+ line[i] = (line[i] + predictor) & 0xff;
146
+ }
147
+ previous = line;
148
+ }
149
+ if (colorType === 3) {
150
+ const index = line[x];
151
+ return [palette[index * 3], palette[index * 3 + 1], palette[index * 3 + 2]];
152
+ }
153
+ if (channels <= 2) {
154
+ const grey = line[x * channels];
155
+ return [grey, grey, grey];
156
+ }
157
+ return [line[x * channels], line[x * channels + 1], line[x * channels + 2]];
158
+ }
package/src/run.mjs ADDED
@@ -0,0 +1,311 @@
1
+ // `8bs run <target>` — build, then actually run the program.
2
+ //
3
+ // 8bs run vic20 builds the .prg and opens it in the VICE VIC-20
4
+ // emulator, machine model NTSC (60fps) — the default
5
+ // 8bs run vic20 --pal the same, machine model PAL (50fps)
6
+ // 8bs run c64 the same idea, in the C64 emulator (NTSC default)
7
+ // 8bs run c64 --pal
8
+ // 8bs run pet builds the .prg and opens it in VICE's PET emulator
9
+ // (xpet) as the model the hardware names — a 3032 by
10
+ // default: no CRTC, 40 columns, 32K, VICE's hardcoded
11
+ // ~60.1Hz. --profile 8032 builds for and launches the
12
+ // 80-column business machine, 3008/3016/4016/4032 the
13
+ // other RAM sizes and series (the `model` option in
14
+ // packages/pet's catalog). There is no --pal for the
15
+ // PET: its refresh is the model's (the CRTC models
16
+ // run their 50Hz editor ROMs; the 60Hz ones make VICE
17
+ // refuse autostart), and the program measures the
18
+ // actual frame period at runtime (FRAME_SYNC.pet).
19
+ // 8bs run c128 VICE's C128 emulator (x128), NTSC default, --pal
20
+ // 8bs run atari8 builds for the default 800XL and opens it in
21
+ // atari800; --profile picks another machine in the
22
+ // family (130xe, xegs, ...), --pal/--ntsc the TV
23
+ // standard (NTSC default)
24
+ // 8bs run vic20 --profile 16k builds for a 16K-expanded VIC-20 and
25
+ // passes xvic the matching `-memory` flag, so the
26
+ // emulated machine's RAM matches what the program
27
+ // was linked for
28
+ // 8bs run c64 --hardware ram=reu512,port1=mouse1351
29
+ // builds for the stock C64 (neither changes the
30
+ // memory map) and fits x64sc a 512K REU and a 1351
31
+ // in port 1 — every option, and what each does, is
32
+ // the machine package's catalog (`8bs targets`)
33
+ // 8bs run nes builds the .nes and opens it in FCEUX
34
+ // 8bs run cx16 builds the .prg and opens it in x16emu
35
+ // 8bs run mega65 builds the .prg and opens it in Xemu's MEGA65
36
+ // core (xmega65) with -prg, which autoloads and RUNs
37
+ // it in MEGA65 mode ($2001 load address; a c64-target
38
+ // .prg would go to C64 mode) — verified on screen,
39
+ // see packages/mega65/AGENTS.md
40
+ // 8bs run web builds the .wasm and opens it in the browser
41
+ // runtime (web-runtime.mjs): the program runs in a
42
+ // worker, its waitFrame() paced by the page's frame
43
+ // clock, the page painting its screen memory — the
44
+ // same for every program, whether it loops on
45
+ // waitFrame(), returns, or spins
46
+ // 8bs run web --no-open the same, without spawning a browser window —
47
+ // for pasting the printed URL into an editor's own
48
+ // browser (e.g. VS Code/Cursor's "Simple Browser:
49
+ // Show" command), which no CLI can open unattended:
50
+ // that command only exists inside the editor, with
51
+ // no terminal-invokable equivalent
52
+ import { homedir, tmpdir } from 'node:os';
53
+ import { join, resolve } from 'node:path';
54
+ import { readFile, writeFile } from 'node:fs/promises';
55
+
56
+ import { compile } from './build.mjs';
57
+ import { HARDWARE_USAGE, hardwareArgs, loadArgs } from './hardware.mjs';
58
+
59
+ // The VICE family (vic20/c64/pet/c128): one emulator suite, one invocation
60
+ // shape — -autostart injects the built file straight into RAM. Exported so
61
+ // screenshot.mjs's --screenshot path (8bs run <target> --screenshot <file>)
62
+ // can drive the same emulators/flags rather than keeping a second copy that
63
+ // could drift from this one.
64
+ export const VICE_EMULATOR = { vic20: 'xvic', c64: 'x64sc', pet: 'xpet', c128: 'x128' };
65
+
66
+ // Flags the emulator needs to run our .prg files. The emulated machine must
67
+ // match the memory layout the program was linked for: 8BitScript's vic20
68
+ // target is the UNEXPANDED VIC-20 ($1001), which is also xvic's stock
69
+ // configuration, so no memory flag is needed — but if the backend's
70
+ // __memory_expansion pin ever changes, this table must change with it, or
71
+ // autostart injects the program at the wrong address and silently never runs
72
+ // it. RAM injection (-autostartprgmode 1) skips the emulated disk load. The
73
+ // PET's __ram_size pin (32K) is likewise xpet's own stock default, so it
74
+ // needs no matching flag either.
75
+ // x128 alone among these drives two physical displays — the VIC-IIe (40-
76
+ // column, what this target's screen/border/background all actually reach)
77
+ // and the 80-column VDC, which this target never touches. Without
78
+ // -hidevdcwindow, x128 opens a second window for it anyway, showing
79
+ // whatever the VDC's power-on RAM happens to contain (typically a plain
80
+ // black screen) alongside the real output — not a second copy of the
81
+ // program, just an unused second monitor the hardware genuinely has.
82
+ export const VICE_EMULATOR_ARGS = {
83
+ vic20: ['-autostartprgmode', '1'],
84
+ c64: ['-autostartprgmode', '1'],
85
+ pet: ['-autostartprgmode', '1'],
86
+ c128: ['-autostartprgmode', '1', '-hidevdcwindow'],
87
+ };
88
+ // -ntsc/-pal only flip VICE's sync factor (raster timing): the screen-origin
89
+ // registers the KERNAL sets up at boot stay wired to whichever machine model
90
+ // is loaded, so -ntsc alone can pair NTSC timing with PAL geometry and the
91
+ // picture renders off-center. -model switches the whole machine (ROM set,
92
+ // VIC-II/VIC geometry, and timing together), which is why vic20/c64/c128
93
+ // name a model rather than a sync-factor flag. Verified against `x128 -help`
94
+ // ("Set C128 model (c128/c128dcr, pal/ntsc)").
95
+ //
96
+ // The PET is not in this table: its refresh is not a sync factor but the
97
+ // model's, and the model is a hardware option — see PET_REGION_NOTE below.
98
+ export const VICE_MODEL_ARGS = {
99
+ vic20: { ntsc: ['-model', 'vic20ntsc'], pal: ['-model', 'vic20pal'] },
100
+ c64: { ntsc: ['-model', 'ntsc'], pal: ['-model', 'c64'] },
101
+ c128: { ntsc: ['-model', 'ntsc'], pal: ['-model', 'pal'] },
102
+ };
103
+
104
+ // The PET's model is a hardware option (packages/pet's catalog: `-model`
105
+ // to xpet, `__ram_size` to the linker, the columns as a fact). No
106
+ // `-ntsc`/`-pal` goes with it. VICE's own pet.h: 50Hz vs 60Hz on a PET is
107
+ // which editor ROM programs the CRTC, not a sync factor. The 60Hz editor
108
+ // ROMs VICE ships (`edit-4-*-60Hz*`) do switch the CRTC to ~60Hz, but
109
+ // they also make VICE refuse autostart ("Autostart is not available on
110
+ // this setup") — observed with both the 40-column and 80-column 60Hz
111
+ // editors, with and without `-default` — so the CRTC models (4016, 4032,
112
+ // 8032) run their stock 50Hz editors here, measured at 49.92-50.02Hz, and
113
+ // the no-CRTC 3xxx models run at VICE's hardcoded ~60.1Hz. The program
114
+ // measures whichever it gets at start-up (FRAME_SYNC.pet), so the build is
115
+ // the same either way; `8bs run pet --pal` prints a note and changes
116
+ // nothing. Verified with `xpet -verbose -limitcycles` and by whether
117
+ // `-autostart` of examples/borders stays running.
118
+ export const PET_REGION_NOTE = '8bs run: the PET has no --pal/--ntsc — its refresh rate is the model\'s. '
119
+ + 'Pick a model with --profile (3032 is ~60Hz; 4016, 4032 and 8032 are 50Hz).\n';
120
+
121
+ // How each emulator is handed the built file when the hardware does not
122
+ // say otherwise (a catalog value's `load` — the Atari XEGS cartridge —
123
+ // overrides this; see hardware.mjs's loadArgs).
124
+ export const DEFAULT_LOAD = {
125
+ xvic: (out) => ['-autostart', out],
126
+ x64sc: (out) => ['-autostart', out],
127
+ xpet: (out) => ['-autostart', out],
128
+ x128: (out) => ['-autostart', out],
129
+ atari800: (out) => ['-run', out],
130
+ fceux: (out) => [out],
131
+ x16emu: (out) => ['-prg', out, '-run'],
132
+ xmega65: (out) => ['-prg', out],
133
+ };
134
+
135
+ // atari800's SDL2 OpenGL shader (atari800-shader.frag) defaults
136
+ // CRT_BEAM_SHAPE=10, which spreads each emulated pixel with a Gaussian
137
+ // falloff — visible as dark vertical stripes across the whole frame,
138
+ // border included, on top of the ordinary horizontal scanline overlay.
139
+ // There is no CLI flag for that uniform (only SCANLINES_PERCENTAGE has
140
+ // `-scanlines`), so `8bs run` copies the user's ~/.atari800.cfg, zeros
141
+ // the CRT knobs, and points `-config` at a *per-process* copy with
142
+ // `-no-autosave-config` so the user's own file is left alone. The path
143
+ // includes the pid because `pnpm test` runs atari8 screenshot tests in
144
+ // parallel (banks vs layers, and other packages' emulators at the same
145
+ // time): a shared `8bs-atari800.cfg` is two writers and two atari800s
146
+ // reading one file. ROM paths stay whatever the user already configured;
147
+ // without those the emulator boots to black.
148
+ export async function atari800CleanDisplayConfig() {
149
+ let cfg;
150
+ try {
151
+ cfg = await readFile(join(homedir(), '.atari800.cfg'), 'utf8');
152
+ } catch {
153
+ return null;
154
+ }
155
+ const setKey = (text, key, value) => {
156
+ const re = new RegExp(`^${key}=.*$`, 'm');
157
+ if (re.test(text)) return text.replace(re, `${key}=${value}`);
158
+ return `${text.trimEnd()}\n${key}=${value}\n`;
159
+ };
160
+ cfg = setKey(cfg, 'CRT_BEAM_SHAPE', '0');
161
+ cfg = setKey(cfg, 'CRT_PHOSPHOR_GLOW', '0');
162
+ cfg = setKey(cfg, 'SCANLINES_PERCENTAGE', '0');
163
+ cfg = setKey(cfg, 'INTERPOLATE_SCANLINES', '0');
164
+ const outPath = join(tmpdir(), `8bs-atari800-${process.pid}.cfg`);
165
+ await writeFile(outPath, cfg);
166
+ return outPath;
167
+ }
168
+
169
+ /** @returns {Promise<number>} exit code */
170
+ export async function run(args) {
171
+ const pal = args.includes('--pal');
172
+ const open = !args.includes('--no-open');
173
+ const hw = hardwareArgs(args);
174
+ if (!hw.ok) {
175
+ process.stderr.write(`8bs run: ${hw.error}\n`);
176
+ return 2;
177
+ }
178
+ const screenshotIndex = args.indexOf('--screenshot');
179
+ const screenshotPath = screenshotIndex >= 0 ? resolve(args[screenshotIndex + 1]) : undefined;
180
+ const framesIndex = args.indexOf('--frames');
181
+ const framesArg = framesIndex >= 0 ? args[framesIndex + 1] : undefined;
182
+ const frames = framesArg !== undefined ? Number.parseInt(framesArg, 10) : undefined;
183
+ if (framesArg !== undefined && !Number.isFinite(frames)) {
184
+ process.stderr.write(`8bs run: --frames expects a number, got '${framesArg}'\n`);
185
+ return 2;
186
+ }
187
+ const consumed = new Set([
188
+ ...hw.consumed,
189
+ ...[screenshotIndex, framesIndex].flatMap((i) => (i >= 0 ? [i, i + 1] : [])),
190
+ ]);
191
+ const positionals = args.filter((a, i) => !consumed.has(i) && !a.startsWith('-'));
192
+ const target = positionals[0];
193
+ if (!target) {
194
+ process.stderr.write(
195
+ 'Usage: 8bs run <vic20|c64|pet|c128|atari8|nes|cx16|mega65|web>\n'
196
+ + ' [--pal]\n'
197
+ + HARDWARE_USAGE
198
+ + ' [--no-open] [entry.8bs]\n'
199
+ + ' [--screenshot <file.png>] [--frames <n>]\n'
200
+ + ' capture one screenshot through the target\'s own\n'
201
+ + ' emulator API instead of opening an interactive\n'
202
+ + ' window — see docs/setup/verify.md#screenshots for\n'
203
+ + ' what --frames counts on each target\n',
204
+ );
205
+ return 2;
206
+ }
207
+ const entry = positionals[1];
208
+
209
+ // Said once, before either route — the PET has no region (PET_REGION_NOTE).
210
+ if (target === 'pet' && pal) process.stderr.write(PET_REGION_NOTE);
211
+
212
+ const { ok, outFile, frameRate, hardware } = await compile(target, entry, { pal, profile: hw.profile, hardware: hw.overrides });
213
+ if (!ok) return 1;
214
+
215
+ if (screenshotPath) {
216
+ const { captureScreenshot } = await import('./screenshot.mjs');
217
+ try {
218
+ await captureScreenshot(target, outFile, screenshotPath, {
219
+ pal, hardware, frames, frameRate,
220
+ });
221
+ } catch (err) {
222
+ process.stderr.write(`${err.message}\n`);
223
+ return 1;
224
+ }
225
+ process.stdout.write(`wrote ${screenshotPath}\n`);
226
+ return 0;
227
+ }
228
+
229
+ if (target === 'web') {
230
+ // Every program runs the same way on the web: in the browser runtime's
231
+ // worker (web-runtime.mjs), whether it loops on waitFrame(), returns, or
232
+ // spins. Headless execution is `--screenshot`'s job, bounded by --frames.
233
+ const { runInBrowser } = await import('./web-runtime.mjs');
234
+ const bytes = await readFile(outFile);
235
+ return runInBrowser(bytes, { open, frameRate, root: resolve('dist', 'web') });
236
+ }
237
+
238
+ const region = pal ? 'pal' : 'ntsc';
239
+
240
+ // The emulator's own flags for the machine, then whatever the hardware
241
+ // fits (the catalog's `run` list for this emulator), then the file.
242
+ let emulator;
243
+ let emulatorArgs;
244
+ if (target in VICE_EMULATOR) {
245
+ emulator = VICE_EMULATOR[target];
246
+ emulatorArgs = [
247
+ ...(VICE_EMULATOR_ARGS[target] ?? []),
248
+ ...(VICE_MODEL_ARGS[target]?.[region] ?? []),
249
+ ...(hardware.run[emulator] ?? []),
250
+ // Skip the "really quit?" confirmation dialog — closing the emulator
251
+ // window during dev/test cycles should not need a click every time.
252
+ '+confirmonexit',
253
+ ...loadArgs(hardware, emulator, outFile, DEFAULT_LOAD[emulator](outFile)),
254
+ ];
255
+ } else if (target === 'atari8') {
256
+ emulator = 'atari800';
257
+ // atari800's TV-area visible size (DOC/USAGE -horiz-area/-vert-area):
258
+ // 336 wide, 224 tall on NTSC and 240 tall on PAL. The emulator opens
259
+ // at 1x of that — a postage stamp on any modern display — and unlike
260
+ // VICE it has no larger default of its own. 3x is a window worth
261
+ // looking at on a 1080p screen and still an exact integer scale, which
262
+ // is what atari800's own default INTEGRAL stretch wants: a non-multiple
263
+ // just letterboxes the same small image inside a bigger window.
264
+ const tvHeight = pal ? 240 : 224;
265
+ const displayCfg = await atari800CleanDisplayConfig();
266
+ emulatorArgs = [
267
+ ...(displayCfg ? ['-config', displayCfg, '-no-autosave-config'] : []),
268
+ ...(hardware.run.atari800 ?? []),
269
+ pal ? '-pal' : '-ntsc',
270
+ '-horiz-area', 'tv',
271
+ '-vert-area', 'tv',
272
+ '-stretch', 'integral',
273
+ '-scanlines', '0',
274
+ '-win-width', String(336 * 3),
275
+ '-win-height', String(tvHeight * 3),
276
+ ...loadArgs(hardware, emulator, outFile, DEFAULT_LOAD.atari800(outFile)),
277
+ ];
278
+ } else if (target === 'nes') {
279
+ emulator = 'fceux';
280
+ emulatorArgs = [...(hardware.run.fceux ?? []), ...loadArgs(hardware, emulator, outFile, DEFAULT_LOAD.fceux(outFile))];
281
+ } else if (target === 'cx16') {
282
+ // Confirmed against the X16Community/x16-emulator README.
283
+ emulator = 'x16emu';
284
+ emulatorArgs = [...(hardware.run.x16emu ?? []), ...loadArgs(hardware, emulator, outFile, DEFAULT_LOAD.x16emu(outFile))];
285
+ } else if (target === 'mega65') {
286
+ // -videostd pins the video standard to match the region the .prg was
287
+ // built for (0=PAL, 1=NTSC); left unset, Xemu's Hyppo default is PAL
288
+ // regardless of which region this target compiled for, so an NTSC
289
+ // build gets PAL's ~100 extra scanlines of VIC-IV border/overscan — the
290
+ // exact off-geometry mismatch VICE_MODEL_ARGS above documents for
291
+ // -ntsc/-pal not implying a model.
292
+ emulator = 'xmega65';
293
+ emulatorArgs = [...(hardware.run.xmega65 ?? []), ...loadArgs(hardware, emulator, outFile, DEFAULT_LOAD.xmega65(outFile)), '-videostd', pal ? '0' : '1'];
294
+ } else {
295
+ // build() already validated the target against the same TARGETS set
296
+ // this function branches over, so this is unreachable.
297
+ process.stderr.write(`8bs run: no emulator wired up for target '${target}'\n`);
298
+ return 1;
299
+ }
300
+
301
+ process.stdout.write(`starting ${emulator}; close the emulator window to finish.\n`);
302
+ const { spawn } = await import('node:child_process');
303
+ return new Promise((resolvePromise) => {
304
+ const child = spawn(emulator, emulatorArgs, { stdio: 'inherit' });
305
+ child.on('error', () => {
306
+ process.stderr.write(`8bs run: cannot start ${emulator}. Run '8bs doctor' — docs/setup/index.md\n`);
307
+ resolvePromise(1);
308
+ });
309
+ child.on('close', (code) => resolvePromise(code === 0 ? 0 : 0));
310
+ });
311
+ }