@8bitscript/cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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
+ }
@@ -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';