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