@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/font8x8.mjs
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// The 64 glyphs (ASCII 32 "space" through 95 "_") from Daniel Hepper's
|
|
2
|
+
// font8x8_basic (https://github.com/dhepper/font8x8, public domain, itself
|
|
3
|
+
// based on Marcel Sondaar's public-domain VGA font) — the same portable
|
|
4
|
+
// character set @8bitscript/web/text's putChar accepts (space, 0-9, A-Z,
|
|
5
|
+
// a little punctuation, upper case only; see web-runtime.mjs's
|
|
6
|
+
// decodeScreenCode). Trimmed from the original's full 128-entry table to
|
|
7
|
+
// just the range this project's screen codes ever use.
|
|
8
|
+
//
|
|
9
|
+
// Each glyph is 8 bytes, one per row; bit x of a row byte is column x
|
|
10
|
+
// (bit 0 = leftmost pixel) — Hepper's own reference renderer reads it the
|
|
11
|
+
// same way.
|
|
12
|
+
const GLYPHS_32_95_HEX = '0000000000000000183c3c1818001800363600000000000036367f367f3636000c3e031e301f0c00006333180c6663001c361c6e3b336e000606030000000000180c0606060c1800060c1818180c060000663cff3c660000000c0c3f0c0c000000000000000c0c060000003f0000000000000000000c0c006030180c060301003e63737b6f673e000c0e0c0c0c0c3f001e33301c06333f001e33301c30331e00383c36337f3078003f031f3030331e001c06031f33331e003f3330180c0c0c001e33331e33331e001e33333e30180e00000c0c00000c0c00000c0c00000c0c06180c0603060c180000003f00003f0000060c1830180c06001e3330180c000c003e637b7b7b031e000c1e33333f3333003f66663e66663f003c66030303663c001f36666666361f007f46161e16467f007f46161e16060f003c66030373667c003333333f333333001e0c0c0c0c0c1e007830303033331e006766361e366667000f06060646667f0063777f7f6b63630063676f7b736363001c36636363361c003f66663e06060f001e3333333b1e38003f66663e366667001e33070e38331e003f2d0c0c0c0c1e003333333333333f0033333333331e0c006363636b7f7763006363361c1c3663003333331e0c0c1e007f6331184c667f001e06060606061e0003060c18306040001e18181818181e00081c36630000000000000000000000ff';
|
|
13
|
+
|
|
14
|
+
const GLYPHS = Buffer.from(GLYPHS_32_95_HEX, 'hex');
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The 8 row-bytes for an ASCII code in [32, 95], or `null` outside that
|
|
18
|
+
* range — the same "blank cell" outcome web-runtime.mjs's
|
|
19
|
+
* decodeScreenCode gives for a screen byte it doesn't recognize.
|
|
20
|
+
* @param {number} code
|
|
21
|
+
* @returns {Buffer | null}
|
|
22
|
+
*/
|
|
23
|
+
export function glyphRows(code) {
|
|
24
|
+
if (code < 32 || code > 95) return null;
|
|
25
|
+
const offset = (code - 32) * 8;
|
|
26
|
+
return GLYPHS.subarray(offset, offset + 8);
|
|
27
|
+
}
|
package/src/hardware.mjs
ADDED
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
// The hardware a build is made for — a machine plus the options fitted to
|
|
2
|
+
// it — resolved from the machine package's catalog.
|
|
3
|
+
//
|
|
4
|
+
// Every machine package declares what can be fitted in its package.json,
|
|
5
|
+
// under `"8bitscript".hardware`: a set of *options* (a VIC-20's RAM
|
|
6
|
+
// expansion, a C64's control ports and SID, a PET's model, an Atari's
|
|
7
|
+
// machine and mouse), each with the values it can take, and *presets*,
|
|
8
|
+
// the community names for whole configurations (`8032`, `130xe`,
|
|
9
|
+
// `reu512`) that `--profile` accepts. Each value says what fitting it
|
|
10
|
+
// changes, in at most four ways:
|
|
11
|
+
//
|
|
12
|
+
// tag the word a `.<machine>.<tag>.8bs` file twin is named with, for
|
|
13
|
+
// code that differs on that hardware (default: the value's own
|
|
14
|
+
// name; the option's default value carries no tag unless it says)
|
|
15
|
+
// build what the linker needs — `defsym` symbols for the SDK's link
|
|
16
|
+
// script, or a different `driver` and `output` extension
|
|
17
|
+
// run the flags each emulator takes to fit the same thing, keyed by
|
|
18
|
+
// the emulator's name; `load` overrides how the built file is
|
|
19
|
+
// handed to it, with `{out}` standing for the file
|
|
20
|
+
// facts what a program can then rely on, as dotted keys (`input.mouse`,
|
|
21
|
+
// `video.columns`) — carried through the build for the fact
|
|
22
|
+
// sheet and the editor's hardware panel
|
|
23
|
+
// and `detect` names the package subpath whose probe finds that hardware
|
|
24
|
+
// on the machine at run time (`@8bitscript/c64/reu`). It sits on the
|
|
25
|
+
// option when one probe finds every value of it — one build then serves
|
|
26
|
+
// them all — or on a single value when only that one can be found: an
|
|
27
|
+
// Atari 130XE's extra RAM can be, an 800XL's absence of it needs no
|
|
28
|
+
// probe, and a `xegs` cartridge is a different binary either way. Without
|
|
29
|
+
// `detect` a value is chosen at build time, each its own build (a PET
|
|
30
|
+
// model, a VIC-20 expansion that moves the screen). Fitting the hardware
|
|
31
|
+
// for a build is the opt-in: it compiles the probe's caller in and sets
|
|
32
|
+
// the fact to "may use", and the probe confirms it on the machine.
|
|
33
|
+
//
|
|
34
|
+
// A project's 8bs.config.ts may add named profiles of its own under
|
|
35
|
+
// `targets.<machine>.profiles`, each a set of option values; `--profile`
|
|
36
|
+
// names one of those or a catalog preset — a project's name shadows a
|
|
37
|
+
// preset's — and `--hardware ram=8k,port1=mouse1351` sets options on top
|
|
38
|
+
// of whichever was chosen. The result is one object the build, the
|
|
39
|
+
// emulator launch, the screenshot path, and `8bs targets` all read.
|
|
40
|
+
// One thing to know when reading a catalog: an option value whose name is
|
|
41
|
+
// all digits (`1541`, a PET's `8032`) is a canonical array index to
|
|
42
|
+
// JavaScript, so `Object.keys` hands those back first, in numeric order,
|
|
43
|
+
// ahead of every name with a letter in it. Nothing here depends on the
|
|
44
|
+
// order of *values* — facts merge per option, and `buildValues` only ever
|
|
45
|
+
// holds values that carry a `build` — but a list printed from one is not
|
|
46
|
+
// in the order the package.json writes it, and that is why.
|
|
47
|
+
import { createRequire } from 'node:module';
|
|
48
|
+
|
|
49
|
+
import { MACHINES, requiresProblems, unmetRequirements } from '@8bitscript/compiler';
|
|
50
|
+
|
|
51
|
+
const require = createRequire(import.meta.url);
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The catalog a machine package declares, or an empty one for a machine
|
|
55
|
+
* with nothing to fit (web).
|
|
56
|
+
*
|
|
57
|
+
* @param {string} machine
|
|
58
|
+
* @returns {{ machine: string, options: object, presets: object, facts: object, run: object }}
|
|
59
|
+
*/
|
|
60
|
+
export function loadCatalog(machine) {
|
|
61
|
+
if (!MACHINES.includes(machine)) throw new Error(`no such machine '${machine}'`);
|
|
62
|
+
const pkg = require(`@8bitscript/${machine}/package.json`);
|
|
63
|
+
const hardware = pkg['8bitscript']?.hardware ?? {};
|
|
64
|
+
return {
|
|
65
|
+
machine,
|
|
66
|
+
options: hardware.options ?? {},
|
|
67
|
+
presets: hardware.presets ?? {},
|
|
68
|
+
facts: hardware.facts ?? {},
|
|
69
|
+
run: hardware.run ?? {},
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The stock machine's fact sheet: what `8bs build <machine>` with no
|
|
75
|
+
* profile and no `--hardware` hands the compiler. Tests link against it.
|
|
76
|
+
*
|
|
77
|
+
* @param {string} machine
|
|
78
|
+
* @returns {object} facts, keyed as the compiler's FACTS table is
|
|
79
|
+
*/
|
|
80
|
+
export function stockFacts(machine) {
|
|
81
|
+
return resolveHardware(loadCatalog(machine), {}).hardware.facts;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* `ram=8k,port1=mouse1351` → `{ ram: '8k', port1: 'mouse1351' }`. Several
|
|
86
|
+
* `--hardware` arguments may be given; join their texts with commas.
|
|
87
|
+
*
|
|
88
|
+
* @param {string} text
|
|
89
|
+
* @returns {{ ok: true, overrides: object } | { ok: false, error: string }}
|
|
90
|
+
*/
|
|
91
|
+
export function parseHardwareArg(text) {
|
|
92
|
+
const overrides = {};
|
|
93
|
+
for (const part of text.split(',').map((s) => s.trim()).filter(Boolean)) {
|
|
94
|
+
const m = /^([A-Za-z0-9_-]+)=([A-Za-z0-9_.-]+)$/.exec(part);
|
|
95
|
+
if (!m) return { ok: false, error: `--hardware expects option=value pairs separated by commas, got '${part}'` };
|
|
96
|
+
overrides[m[1]] = m[2];
|
|
97
|
+
}
|
|
98
|
+
return { ok: true, overrides };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The project's own profiles for a machine, from an 8bs.config.ts whose
|
|
103
|
+
* `targets` is the object form: `{ c64: { profiles: { loaded: { ram:
|
|
104
|
+
* 'reu512' } } } }`. The array form has none.
|
|
105
|
+
*
|
|
106
|
+
* @param {object|null} config
|
|
107
|
+
* @param {string} machine
|
|
108
|
+
* @returns {object} profile name → option values
|
|
109
|
+
*/
|
|
110
|
+
export function projectProfiles(config, machine) {
|
|
111
|
+
const targets = config?.targets;
|
|
112
|
+
if (!targets || Array.isArray(targets)) return {};
|
|
113
|
+
return targets[machine]?.profiles ?? {};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The hardware a project fits one target with by default — its own stock
|
|
118
|
+
* for that machine, applied under any named profile and any `--hardware`:
|
|
119
|
+
* `targets: { pet: { hardware: { model: '8032' } } }` makes every PET
|
|
120
|
+
* build of this project an 80-column one unless a build says otherwise.
|
|
121
|
+
*
|
|
122
|
+
* @param {object|null} config
|
|
123
|
+
* @param {string} machine
|
|
124
|
+
* @returns {object} option → value
|
|
125
|
+
*/
|
|
126
|
+
export function projectHardware(config, machine) {
|
|
127
|
+
const targets = config?.targets;
|
|
128
|
+
if (!targets || Array.isArray(targets)) return {};
|
|
129
|
+
return targets[machine]?.hardware ?? {};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* The targets a project's config lists, in either form, or null for
|
|
134
|
+
* "every target" when it lists none.
|
|
135
|
+
*
|
|
136
|
+
* @param {object|null} config
|
|
137
|
+
* @returns {string[]|null}
|
|
138
|
+
*/
|
|
139
|
+
export function listedTargets(config) {
|
|
140
|
+
const targets = config?.targets;
|
|
141
|
+
if (!targets) return null;
|
|
142
|
+
return Array.isArray(targets) ? targets : Object.keys(targets);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* The floor a program sets: `requires` in its 8bs.config.ts, a fact key to
|
|
147
|
+
* the least of it the program needs.
|
|
148
|
+
*
|
|
149
|
+
* requires: { 'memory.ram': 8192, 'storage.save': true }
|
|
150
|
+
*
|
|
151
|
+
* The machines are not alike, and this is where a program says which of
|
|
152
|
+
* the differences it cannot live with. A count is a floor and a flag must
|
|
153
|
+
* be true, checked against the sheet the build resolves to — so "needs 8K"
|
|
154
|
+
* is a sentence about the program, answered before the compiler runs,
|
|
155
|
+
* instead of a linker overflow at the end of one.
|
|
156
|
+
*
|
|
157
|
+
* @param {object|null} config
|
|
158
|
+
* @returns {{ ok: true, requires: object } | { ok: false, error: string }}
|
|
159
|
+
*/
|
|
160
|
+
export function projectRequires(config) {
|
|
161
|
+
const requires = config?.requires;
|
|
162
|
+
if (requires === undefined) return { ok: true, requires: {} };
|
|
163
|
+
if (requires === null || typeof requires !== 'object' || Array.isArray(requires)) {
|
|
164
|
+
return { ok: false, error: "8bs.config.ts's `requires` must be an object of fact → the least of it the program needs" };
|
|
165
|
+
}
|
|
166
|
+
const problems = requiresProblems(requires);
|
|
167
|
+
if (problems.length > 0) {
|
|
168
|
+
return { ok: false, error: `8bs.config.ts's \`requires\`: ${problems.join('; ')}` };
|
|
169
|
+
}
|
|
170
|
+
return { ok: true, requires };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* What this machine could be fitted with that would meet a requirement the
|
|
175
|
+
* build does not — the half of the message that makes it actionable, since
|
|
176
|
+
* "needs 8192 bytes" on a stock VIC-20 is only useful beside "ram=8k gives
|
|
177
|
+
* 28671".
|
|
178
|
+
*
|
|
179
|
+
* Every value of every option is resolved on top of the choice already
|
|
180
|
+
* made, so what comes back is one change away, not a different machine.
|
|
181
|
+
*
|
|
182
|
+
* @param {object} catalog
|
|
183
|
+
* @param {string} key the fact that fell short
|
|
184
|
+
* @param {number|boolean} need
|
|
185
|
+
* @param {object} [choice] the same shape resolveHardware takes
|
|
186
|
+
* @returns {string[]} `option=value gives N`, in catalog order
|
|
187
|
+
*/
|
|
188
|
+
export function whatSatisfies(catalog, key, need, choice = {}) {
|
|
189
|
+
const found = [];
|
|
190
|
+
for (const [id, option] of Object.entries(catalog.options ?? {})) {
|
|
191
|
+
for (const value of Object.keys(option.values ?? {})) {
|
|
192
|
+
const resolved = resolveHardware(catalog, {
|
|
193
|
+
...choice,
|
|
194
|
+
overrides: { ...choice.overrides, [id]: value },
|
|
195
|
+
});
|
|
196
|
+
if (!resolved.ok) continue;
|
|
197
|
+
const have = resolved.hardware.facts[key];
|
|
198
|
+
if (unmetRequirements({ [key]: need }, resolved.hardware.facts).length === 0) {
|
|
199
|
+
found.push(`${id}=${value} gives ${have === true ? 'it' : have}`);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return found;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** The machines whose emulator takes a region; `--pal` means nothing elsewhere. */
|
|
207
|
+
export const REGION_MACHINES = new Set(['vic20', 'c64', 'c128', 'mega65', 'atari8']);
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* The machines a project has been *set up for*: its `systems` block, each
|
|
211
|
+
* entry one of its targets with the hardware already fitted.
|
|
212
|
+
*
|
|
213
|
+
* This is the layer above `targets`. A program is normally written for
|
|
214
|
+
* every machine — `targets` stays the whole list — but some hardware is
|
|
215
|
+
* always a choice rather than a fact of the machine: a mouse or a stick in
|
|
216
|
+
* a port, how much RAM is in the expansion, which drive is attached. A
|
|
217
|
+
* `systems` entry names one such arrangement, so a person picks *"C64 with
|
|
218
|
+
* a mouse"* instead of assembling `--profile`/`--hardware` from the
|
|
219
|
+
* catalog each time, and the editor lists it ready to run:
|
|
220
|
+
*
|
|
221
|
+
* systems: {
|
|
222
|
+
* 'C64 with a mouse': { target: 'c64', hardware: { port1: 'mouse1351' } },
|
|
223
|
+
* 'Expanded VIC-20': { target: 'vic20', profile: '8k' },
|
|
224
|
+
* 'X16': { target: 'cx16' },
|
|
225
|
+
* }
|
|
226
|
+
*
|
|
227
|
+
* `profile` names a catalog preset or one of that machine's own profiles
|
|
228
|
+
* and `hardware` sets options on top, exactly as `--profile` and
|
|
229
|
+
* `--hardware` do — an entry is the command line, written down. `region`
|
|
230
|
+
* is `'ntsc'` or `'pal'` for the machines that have one.
|
|
231
|
+
*
|
|
232
|
+
* Every entry is checked here rather than where it is used: a name in the
|
|
233
|
+
* config that quietly fails to appear in the editor is worse than an
|
|
234
|
+
* error, so a bad entry fails the whole block.
|
|
235
|
+
*
|
|
236
|
+
* @param {object|null} config
|
|
237
|
+
* @returns {{ ok: true, systems: SystemSetup[] } | { ok: false, error: string }}
|
|
238
|
+
*
|
|
239
|
+
* @typedef {{
|
|
240
|
+
* name: string, target: string, profile: string|null,
|
|
241
|
+
* hardware: object, region: 'ntsc'|'pal'|null, label: string,
|
|
242
|
+
* }} SystemSetup
|
|
243
|
+
*/
|
|
244
|
+
export function projectSystems(config) {
|
|
245
|
+
const declared = config?.systems;
|
|
246
|
+
if (declared === undefined) return { ok: true, systems: [] };
|
|
247
|
+
if (declared === null || typeof declared !== 'object' || Array.isArray(declared)) {
|
|
248
|
+
return { ok: false, error: "8bs.config.ts's `systems` must be an object of name → { target, ... }" };
|
|
249
|
+
}
|
|
250
|
+
const listed = listedTargets(config);
|
|
251
|
+
// The floor, but only once it is a floor: an unchecked `requires` would
|
|
252
|
+
// have every system marked short over a key the CLI is about to refuse
|
|
253
|
+
// — and `requires: ['memory.ram']` would ask the sheet for a fact named
|
|
254
|
+
// '0'. A config with a bad block gets its error, and its systems get no
|
|
255
|
+
// verdict rather than a wrong one.
|
|
256
|
+
const required = projectRequires(config);
|
|
257
|
+
const floor = required.ok ? required.requires : {};
|
|
258
|
+
const systems = [];
|
|
259
|
+
for (const [name, entry] of Object.entries(declared)) {
|
|
260
|
+
const where = `8bs.config.ts: system '${name}'`;
|
|
261
|
+
if (!entry || typeof entry !== 'object') {
|
|
262
|
+
return { ok: false, error: `${where} must be an object with a target` };
|
|
263
|
+
}
|
|
264
|
+
// A system is offered beside the bare machines, in one list. A name
|
|
265
|
+
// that is already a machine's would be two entries answering to the
|
|
266
|
+
// same word, and the wrong one would win.
|
|
267
|
+
if (MACHINES.includes(name)) {
|
|
268
|
+
return { ok: false, error: `${where}: '${name}' is a machine's own name; call the system something else` };
|
|
269
|
+
}
|
|
270
|
+
const { target, profile, hardware = {}, region = null } = entry;
|
|
271
|
+
if (!MACHINES.includes(target)) {
|
|
272
|
+
return { ok: false, error: `${where}: '${target}' is not a machine. Machines: ${MACHINES.join(', ')}` };
|
|
273
|
+
}
|
|
274
|
+
if (listed && !listed.includes(target)) {
|
|
275
|
+
return { ok: false, error: `${where}: this project does not target ${target}. Targets: ${listed.join(', ')}` };
|
|
276
|
+
}
|
|
277
|
+
if (region !== null && region !== 'ntsc' && region !== 'pal') {
|
|
278
|
+
return { ok: false, error: `${where}: region must be 'ntsc' or 'pal', got ${JSON.stringify(region)}` };
|
|
279
|
+
}
|
|
280
|
+
if (region !== null && !REGION_MACHINES.has(target)) {
|
|
281
|
+
return { ok: false, error: `${where}: the ${target} has no region to pick; leave it out` };
|
|
282
|
+
}
|
|
283
|
+
// The entry has to resolve the way the build will resolve it, or the
|
|
284
|
+
// editor offers a machine that cannot be run. `profile: null` is
|
|
285
|
+
// written out by anything that fills the shape in mechanically, and
|
|
286
|
+
// means the same as leaving it out — not a profile called "null".
|
|
287
|
+
const resolved = resolveHardware(loadCatalog(target), {
|
|
288
|
+
profile: profile ?? undefined,
|
|
289
|
+
overrides: hardware,
|
|
290
|
+
profiles: projectProfiles(config, target),
|
|
291
|
+
defaults: projectHardware(config, target),
|
|
292
|
+
});
|
|
293
|
+
if (!resolved.ok) return { ok: false, error: `${where}: ${resolved.error}` };
|
|
294
|
+
systems.push({
|
|
295
|
+
name,
|
|
296
|
+
target,
|
|
297
|
+
profile: profile ?? null,
|
|
298
|
+
hardware: Object.fromEntries(Object.entries(hardware).map(([k, v]) => [k, String(v)])),
|
|
299
|
+
region,
|
|
300
|
+
label: resolved.hardware.label,
|
|
301
|
+
// What this arrangement falls short of, if the program set a floor.
|
|
302
|
+
// A system that cannot run the program is still listed — it is in
|
|
303
|
+
// the config, and silently dropping it would be the debugging trap
|
|
304
|
+
// this function exists to avoid — but it is listed as such.
|
|
305
|
+
unmet: unmetRequirements(floor, resolved.hardware.facts),
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
return { ok: true, systems };
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Resolve the hardware for one build.
|
|
313
|
+
*
|
|
314
|
+
* @param {{ machine: string, options: object, presets: object, facts: object, run?: object }} catalog
|
|
315
|
+
* @param {{ profile?: string, overrides?: object, profiles?: object, defaults?: object }} [choice]
|
|
316
|
+
* `defaults` are the project's own values for this machine (from
|
|
317
|
+
* projectHardware()), applied over the catalog's; `profile` names a
|
|
318
|
+
* project profile (`profiles`, from projectProfiles()) or, failing that,
|
|
319
|
+
* a catalog preset, applied over those; `overrides` are option values
|
|
320
|
+
* set on top of everything (`--hardware`).
|
|
321
|
+
* @returns {{ ok: true, hardware: Hardware } | { ok: false, error: string }}
|
|
322
|
+
*
|
|
323
|
+
* @typedef {{
|
|
324
|
+
* machine: string, profile: string|null, options: object, tags: string[],
|
|
325
|
+
* buildValues: string[], build: { defsym: object, driver?: string, output?: string },
|
|
326
|
+
* run: object, load: object, facts: object, label: string,
|
|
327
|
+
* }} Hardware
|
|
328
|
+
* `options` is every option's chosen value; `tags` the file-twin tags
|
|
329
|
+
* those values carry; `buildValues` the non-default values that change
|
|
330
|
+
* the build, in catalog order — what an output filename carries; `build`
|
|
331
|
+
* the merged linker effects; `run`/`load` per-emulator flag lists;
|
|
332
|
+
* `facts` the merged facts; `label` a short human spelling.
|
|
333
|
+
*/
|
|
334
|
+
export function resolveHardware(catalog, { profile, overrides = {}, profiles = {}, defaults = {} } = {}) {
|
|
335
|
+
const { machine, options: catalogOptions, presets } = catalog;
|
|
336
|
+
const named = (name) => {
|
|
337
|
+
if (name === undefined) return { ok: true, values: {} };
|
|
338
|
+
if (Object.hasOwn(profiles, name)) return { ok: true, values: profiles[name], from: 'this project' };
|
|
339
|
+
if (Object.hasOwn(presets, name)) return { ok: true, values: presets[name], from: 'the catalog' };
|
|
340
|
+
const known = [...Object.keys(profiles), ...Object.keys(presets)];
|
|
341
|
+
return {
|
|
342
|
+
ok: false,
|
|
343
|
+
error: known.length > 0
|
|
344
|
+
? `unknown ${machine} profile '${name}'. Profiles: ${known.join(', ')} (a project's profile shadows a catalog preset of the same name)`
|
|
345
|
+
: `the ${machine} has no profiles to choose from; use --hardware option=value instead`,
|
|
346
|
+
};
|
|
347
|
+
};
|
|
348
|
+
const base = named(profile);
|
|
349
|
+
if (!base.ok) return base;
|
|
350
|
+
|
|
351
|
+
const options = {};
|
|
352
|
+
for (const [id, option] of Object.entries(catalogOptions)) options[id] = option.default;
|
|
353
|
+
for (const [source, values] of [['this project\'s hardware', defaults], ['profile', base.values], ['--hardware', overrides]]) {
|
|
354
|
+
for (const [id, value] of Object.entries(values)) {
|
|
355
|
+
const option = catalogOptions[id];
|
|
356
|
+
if (!option) {
|
|
357
|
+
return { ok: false, error: `the ${machine} has no '${id}' option (from ${source}). Options: ${Object.keys(catalogOptions).join(', ') || 'none'}` };
|
|
358
|
+
}
|
|
359
|
+
if (!Object.hasOwn(option.values, String(value))) {
|
|
360
|
+
return { ok: false, error: `'${value}' is not a value the ${machine}'s '${id}' option takes (from ${source}). Values: ${Object.keys(option.values).join(', ')}` };
|
|
361
|
+
}
|
|
362
|
+
options[id] = String(value);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const tags = [];
|
|
367
|
+
const buildValues = [];
|
|
368
|
+
const build = { defsym: {} };
|
|
369
|
+
const run = {};
|
|
370
|
+
const load = {};
|
|
371
|
+
const facts = { ...catalog.facts };
|
|
372
|
+
const labels = [];
|
|
373
|
+
for (const [id, option] of Object.entries(catalogOptions)) {
|
|
374
|
+
const value = options[id];
|
|
375
|
+
const entry = option.values[value];
|
|
376
|
+
const isDefault = value === option.default;
|
|
377
|
+
if (Object.hasOwn(entry, 'tag') ? entry.tag !== null : !isDefault) tags.push(entry.tag ?? value);
|
|
378
|
+
if (entry.build) {
|
|
379
|
+
if (!isDefault) buildValues.push(value);
|
|
380
|
+
Object.assign(build.defsym, entry.build.defsym ?? {});
|
|
381
|
+
if (entry.build.driver) build.driver = entry.build.driver;
|
|
382
|
+
if (entry.build.output) build.output = entry.build.output;
|
|
383
|
+
}
|
|
384
|
+
for (const [emulator, args] of Object.entries(entry.run ?? {})) run[emulator] = [...(run[emulator] ?? []), ...args];
|
|
385
|
+
for (const [emulator, args] of Object.entries(entry.load ?? {})) load[emulator] = args;
|
|
386
|
+
Object.assign(facts, entry.facts ?? {});
|
|
387
|
+
if (!isDefault) labels.push(`${id}=${value}`);
|
|
388
|
+
}
|
|
389
|
+
// Stock-machine emulator flags (the X16's mouse grab, etc.), after the
|
|
390
|
+
// option values so a value can still prepend its own flags.
|
|
391
|
+
for (const [emulator, args] of Object.entries(catalog.run ?? {})) {
|
|
392
|
+
run[emulator] = [...(run[emulator] ?? []), ...args];
|
|
393
|
+
}
|
|
394
|
+
return {
|
|
395
|
+
ok: true,
|
|
396
|
+
hardware: {
|
|
397
|
+
machine, profile: profile ?? null, options, tags, buildValues, build, run, load, facts,
|
|
398
|
+
label: labels.length > 0 ? labels.join(' ') : 'stock',
|
|
399
|
+
},
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* The `--profile <name>` and `--hardware option=value,...` arguments of a
|
|
405
|
+
* `8bs build`/`8bs run` line — `--hardware` may repeat — and which
|
|
406
|
+
* argument positions they took, so the caller can leave them out of its
|
|
407
|
+
* positionals.
|
|
408
|
+
*
|
|
409
|
+
* @param {string[]} args
|
|
410
|
+
* @returns {{ ok: true, profile?: string, overrides: object, consumed: Set<number> } | { ok: false, error: string }}
|
|
411
|
+
*/
|
|
412
|
+
export function hardwareArgs(args) {
|
|
413
|
+
const consumed = new Set();
|
|
414
|
+
let profile;
|
|
415
|
+
const overrides = {};
|
|
416
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
417
|
+
if (args[i] === '--profile') {
|
|
418
|
+
if (args[i + 1] === undefined) return { ok: false, error: '--profile expects a name' };
|
|
419
|
+
profile = args[i + 1];
|
|
420
|
+
consumed.add(i).add(i + 1);
|
|
421
|
+
} else if (args[i] === '--hardware') {
|
|
422
|
+
if (args[i + 1] === undefined) return { ok: false, error: '--hardware expects option=value pairs' };
|
|
423
|
+
const parsed = parseHardwareArg(args[i + 1]);
|
|
424
|
+
if (!parsed.ok) return parsed;
|
|
425
|
+
Object.assign(overrides, parsed.overrides);
|
|
426
|
+
consumed.add(i).add(i + 1);
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
return { ok: true, profile, overrides, consumed };
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/** The usage lines both commands print for the hardware arguments. */
|
|
433
|
+
export const HARDWARE_USAGE = ' [--profile <name>] a catalog preset or a project profile — `8bs targets` lists them\n'
|
|
434
|
+
+ ' [--hardware option=value,...] single options on top (e.g. --hardware ram=8k,port1=mouse1351)\n';
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* `hardware.load[emulator]` with `{out}` filled in, or the emulator's own
|
|
438
|
+
* default way of taking the file.
|
|
439
|
+
*/
|
|
440
|
+
export function loadArgs(hardware, emulator, outFile, fallback) {
|
|
441
|
+
const template = hardware?.load?.[emulator];
|
|
442
|
+
if (!template) return fallback;
|
|
443
|
+
return template.map((arg) => arg.replaceAll('{out}', outFile));
|
|
444
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
// atari800 has no built-in "save a screenshot and exit" flag the way VICE
|
|
2
|
+
// (-exitscreenshot), Xemu (-screenshot), and FCEUX (a Lua script calling
|
|
3
|
+
// gui.savescreenshotas) do — its own screenshot feature only triggers off
|
|
4
|
+
// a host keypress (see docs/setup/verify.md's screenshot table). macOS
|
|
5
|
+
// (Screen Recording permission, not Accessibility) can capture that one
|
|
6
|
+
// window's real pixels directly, without sending it any synthetic
|
|
7
|
+
// keystrokes at all: this is the "set up the OS" half of a screenshot
|
|
8
|
+
// path, used only where no clean emulator API exists, not a general
|
|
9
|
+
// substitute for one.
|
|
10
|
+
//
|
|
11
|
+
// Finding *which* window belongs to a given process, asking macOS to
|
|
12
|
+
// capture only that one, and checking whether Screen Recording permission
|
|
13
|
+
// is even granted all need CoreGraphics APIs with no shell-command
|
|
14
|
+
// equivalent, so this compiles a tiny Swift helper the first time it's
|
|
15
|
+
// needed and caches the binary (screenshotCacheDir()) rather than
|
|
16
|
+
// recompiling on every screenshot. The cache key includes a hash of
|
|
17
|
+
// HELPER_SOURCE below, so editing this file invalidates the old binary
|
|
18
|
+
// instead of silently keeping using it forever.
|
|
19
|
+
import { createHash } from 'node:crypto';
|
|
20
|
+
import { spawn } from 'node:child_process';
|
|
21
|
+
import { existsSync } from 'node:fs';
|
|
22
|
+
import { mkdir, writeFile } from 'node:fs/promises';
|
|
23
|
+
import { tmpdir } from 'node:os';
|
|
24
|
+
import { join } from 'node:path';
|
|
25
|
+
|
|
26
|
+
import { screenshotCacheDir } from './setup/paths.mjs';
|
|
27
|
+
|
|
28
|
+
const HELPER_SOURCE = `
|
|
29
|
+
import CoreGraphics
|
|
30
|
+
import Foundation
|
|
31
|
+
|
|
32
|
+
func findWindowId(_ ownerPid: Int32?, _ needle: String) -> Int? {
|
|
33
|
+
guard let list = CGWindowListCopyWindowInfo([.optionOnScreenOnly], kCGNullWindowID) as? [[String: AnyObject]] else {
|
|
34
|
+
return nil
|
|
35
|
+
}
|
|
36
|
+
var best: (id: Int, area: Int)? = nil
|
|
37
|
+
for win in list {
|
|
38
|
+
guard let owner = win[kCGWindowOwnerName as String] as? String else { continue }
|
|
39
|
+
if let ownerPid = ownerPid {
|
|
40
|
+
guard let pid = win[kCGWindowOwnerPID as String] as? Int32, pid == ownerPid else { continue }
|
|
41
|
+
} else {
|
|
42
|
+
guard owner.lowercased().contains(needle.lowercased()) else { continue }
|
|
43
|
+
}
|
|
44
|
+
guard let id = win[kCGWindowNumber as String] as? Int,
|
|
45
|
+
let bounds = win[kCGWindowBounds as String] as? [String: CGFloat] else { continue }
|
|
46
|
+
let area = Int((bounds["Width"] ?? 0) * (bounds["Height"] ?? 0))
|
|
47
|
+
if best == nil || area > best!.area { best = (id, area) }
|
|
48
|
+
}
|
|
49
|
+
return best?.id
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
let args = CommandLine.arguments
|
|
53
|
+
guard args.count > 1 else { exit(2) }
|
|
54
|
+
|
|
55
|
+
switch args[1] {
|
|
56
|
+
case "check-permission":
|
|
57
|
+
// Preflight only — never prompts the user. CGRequestScreenCaptureAccess
|
|
58
|
+
// would pop the system dialog, which a non-interactive \`8bs doctor\`
|
|
59
|
+
// run has no business doing on its own.
|
|
60
|
+
print(CGPreflightScreenCaptureAccess() ? "granted" : "denied")
|
|
61
|
+
case "find-window-by-pid":
|
|
62
|
+
guard args.count > 2, let pid = Int32(args[2]) else { exit(2) }
|
|
63
|
+
if let id = findWindowId(pid, "") { print(id) } else { exit(1) }
|
|
64
|
+
case "find-window-by-name":
|
|
65
|
+
guard args.count > 2 else { exit(2) }
|
|
66
|
+
if let id = findWindowId(nil, args[2]) { print(id) } else { exit(1) }
|
|
67
|
+
default:
|
|
68
|
+
exit(2)
|
|
69
|
+
}
|
|
70
|
+
`;
|
|
71
|
+
|
|
72
|
+
function sourceHash() {
|
|
73
|
+
return createHash('sha1').update(HELPER_SOURCE).digest('hex').slice(0, 12);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function run(command, args) {
|
|
77
|
+
return new Promise((resolvePromise, rejectPromise) => {
|
|
78
|
+
const child = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
79
|
+
let stdout = '';
|
|
80
|
+
let stderr = '';
|
|
81
|
+
child.stdout.on('data', (d) => { stdout += d; });
|
|
82
|
+
child.stderr.on('data', (d) => { stderr += d; });
|
|
83
|
+
child.on('error', rejectPromise);
|
|
84
|
+
child.on('close', (code) => resolvePromise({ code, stdout, stderr }));
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function ensureHelperBinary() {
|
|
89
|
+
const cacheDir = screenshotCacheDir();
|
|
90
|
+
const binPath = join(cacheDir, `window-capture-${sourceHash()}`);
|
|
91
|
+
if (existsSync(binPath)) return binPath;
|
|
92
|
+
|
|
93
|
+
await mkdir(cacheDir, { recursive: true });
|
|
94
|
+
const srcPath = join(tmpdir(), `8bs-window-capture-${process.pid}.swift`);
|
|
95
|
+
await writeFile(srcPath, HELPER_SOURCE);
|
|
96
|
+
const { code, stderr } = await run('swiftc', [srcPath, '-o', binPath]);
|
|
97
|
+
if (code !== 0) {
|
|
98
|
+
throw new Error(`8bs: could not compile the window-capture helper (is Xcode's command line tools installed?):\n${stderr}`);
|
|
99
|
+
}
|
|
100
|
+
return binPath;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* The macOS window ID of the largest on-screen window belonging to
|
|
105
|
+
* `pid`, or `null` if none is on screen. PID-matched, not name-matched, so
|
|
106
|
+
* two atari800 windows (an interactive `8bs run atari8` left open alongside
|
|
107
|
+
* a `--screenshot` capture) can't be confused for each other.
|
|
108
|
+
* @param {number} pid
|
|
109
|
+
* @returns {Promise<number | null>}
|
|
110
|
+
*/
|
|
111
|
+
export async function findWindowIdForPid(pid) {
|
|
112
|
+
const binPath = await ensureHelperBinary();
|
|
113
|
+
const { code, stdout } = await run(binPath, ['find-window-by-pid', String(pid)]);
|
|
114
|
+
if (code !== 0) return null;
|
|
115
|
+
const id = Number.parseInt(stdout.trim(), 10);
|
|
116
|
+
return Number.isFinite(id) ? id : null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Capture exactly one window's real pixels to a PNG — no synthetic
|
|
121
|
+
* keyboard/mouse input, just macOS's own windowed screen capture. Requires
|
|
122
|
+
* Screen Recording permission for whichever process runs `8bs` (System
|
|
123
|
+
* Settings -> Privacy & Security -> Screen Recording) — see
|
|
124
|
+
* hasScreenRecordingPermission() to check that ahead of time.
|
|
125
|
+
* @param {number} windowId
|
|
126
|
+
* @param {string} outFile
|
|
127
|
+
*/
|
|
128
|
+
export async function captureWindow(windowId, outFile) {
|
|
129
|
+
const { code, stderr } = await run('screencapture', ['-x', '-o', '-l', String(windowId), outFile]);
|
|
130
|
+
if (code !== 0) {
|
|
131
|
+
throw new Error(`8bs: screencapture failed (${code}): ${stderr}`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Whether this process already has Screen Recording permission — checked
|
|
137
|
+
* with CGPreflightScreenCaptureAccess(), which never prompts. Used by
|
|
138
|
+
* `8bs doctor` to report the gap (with a fix) rather than let a
|
|
139
|
+
* `--screenshot atari8` fail confusingly later with an empty/black capture.
|
|
140
|
+
* @returns {Promise<boolean>}
|
|
141
|
+
*/
|
|
142
|
+
export async function hasScreenRecordingPermission() {
|
|
143
|
+
const binPath = await ensureHelperBinary();
|
|
144
|
+
const { code, stdout } = await run(binPath, ['check-permission']);
|
|
145
|
+
return code === 0 && stdout.trim() === 'granted';
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** The System Settings pane to send someone to grant it, and the shell
|
|
149
|
+
* command that opens it directly. */
|
|
150
|
+
export const SCREEN_RECORDING_SETTINGS_URL = 'x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture';
|