@8bitscript/cli 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/bin/8bs.mjs +132 -0
- package/package.json +39 -0
- package/src/build.mjs +309 -0
- package/src/check.mjs +67 -0
- package/src/config.mjs +46 -0
- package/src/doctor.mjs +976 -0
- package/src/font8x8.mjs +27 -0
- package/src/hardware.mjs +444 -0
- package/src/mac-window-capture.mjs +150 -0
- package/src/png.mjs +158 -0
- package/src/run.mjs +311 -0
- package/src/screenshot.mjs +485 -0
- package/src/setup/cx16.mjs +510 -0
- package/src/setup/deps.mjs +103 -0
- package/src/setup/exec.mjs +110 -0
- package/src/setup/host.mjs +58 -0
- package/src/setup/install.mjs +47 -0
- package/src/setup/launcher.mjs +112 -0
- package/src/setup/mega65-rom.mjs +165 -0
- package/src/setup/mega65.mjs +515 -0
- package/src/setup/paths.mjs +96 -0
- package/src/setup/prompt.mjs +28 -0
- package/src/setup/report.mjs +13 -0
- package/src/setup/rom.mjs +188 -0
- package/src/setup/source.mjs +60 -0
- package/src/setup/xemu.mjs +107 -0
- package/src/setup/zip.mjs +75 -0
- package/src/setup.mjs +56 -0
- package/src/targets.mjs +158 -0
- package/src/wasm-host.mjs +75 -0
- package/src/web-runtime.mjs +557 -0
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
// MEGA65 ROM handling: validating the legally-obtained C65 base ROM and the
|
|
2
|
+
// official 920413 patch's own byte format, and deciding how the canonical
|
|
3
|
+
// ROM should relate to Xemu's per-user data directory — all pure/testable,
|
|
4
|
+
// no filesystem or network access. The one filesystem-touching helper at the
|
|
5
|
+
// bottom (`inspectXemuRomLink`) is a thin real-fs wrapper around the pure
|
|
6
|
+
// `classifyXemuRomLink` below it, kept here so doctor.mjs and setup/mega65.mjs
|
|
7
|
+
// share one implementation of "is this symlink already correct".
|
|
8
|
+
//
|
|
9
|
+
// 8BitScript never bundles or downloads the ROM itself — see docs/setup/
|
|
10
|
+
// mega65.md. What lives here only checks a ROM the *user* already has.
|
|
11
|
+
import { createHash } from 'node:crypto';
|
|
12
|
+
import {
|
|
13
|
+
lstat as fsLstat, readlink as fsReadlink, readFile as fsReadFile,
|
|
14
|
+
mkdir as fsMkdir, symlink as fsSymlink, unlink as fsUnlink,
|
|
15
|
+
} from 'node:fs/promises';
|
|
16
|
+
import { dirname, resolve } from 'node:path';
|
|
17
|
+
|
|
18
|
+
// The free C65 910828 ROM from Cloanto's "C64 Forever Free Express Edition",
|
|
19
|
+
// which the official MEGA65 920413 patch is diffed against. Confirmed size
|
|
20
|
+
// and hash from a working install (see the setup brief this module
|
|
21
|
+
// implements) — this is the gate that stops a wrong/corrupt base ROM from
|
|
22
|
+
// silently producing a bad MEGA65.ROM.
|
|
23
|
+
export const C65_BASE_ROM = {
|
|
24
|
+
filename: 'c-65-19910828.rom',
|
|
25
|
+
size: 131072,
|
|
26
|
+
sha256: '0c4a00b45b65ca553b8a9f38cae83fe5f7dca7e809c24c0051ae40956640509d',
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
// The official MEGA65 920413 ROM release. `patchUrl` carries a suffix
|
|
30
|
+
// (`_Sn7YEw`) that looks like a content hash or random token MEGA65's file
|
|
31
|
+
// host generated for this specific upload — nothing says it is stable across
|
|
32
|
+
// future releases or re-uploads of this same release, so treat it as a
|
|
33
|
+
// best-effort default, not a permanent API. `romSha256` is the value that
|
|
34
|
+
// actually matters: it's checked against the *generated* MEGA65.ROM, which
|
|
35
|
+
// stays correct even if this URL goes stale (setup's --rom-patch flag is the
|
|
36
|
+
// escape hatch for that case — see setup/mega65.mjs).
|
|
37
|
+
export const MEGA65_ROM_920413 = {
|
|
38
|
+
release: '920413',
|
|
39
|
+
patchUrl: 'https://files.mega65.org/files/other/920413_Sn7YEw.zip',
|
|
40
|
+
rdfEntryName: '920413.rdf',
|
|
41
|
+
romSize: 131072,
|
|
42
|
+
romSha256: 'af3c447f791a2fdc48cb21e1bd3fab015e32641228d9d30d21259b9e878c6fa0',
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export function sha256Hex(buffer) {
|
|
46
|
+
return createHash('sha256').update(buffer).digest('hex');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Check a ROM (or ROM-shaped) buffer against a known {size, sha256}. */
|
|
50
|
+
export function validateRomBuffer(buffer, expected) {
|
|
51
|
+
const size = buffer.length;
|
|
52
|
+
const sha256 = sha256Hex(buffer);
|
|
53
|
+
return {
|
|
54
|
+
size,
|
|
55
|
+
sha256,
|
|
56
|
+
sizeOk: size === expected.size,
|
|
57
|
+
hashOk: sha256 === expected.sha256,
|
|
58
|
+
ok: size === expected.size && sha256 === expected.sha256,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ---- RDF (romdiff patch) header ---------------------------------------
|
|
63
|
+
//
|
|
64
|
+
// Confirmed directly against the real 920413.rdf from
|
|
65
|
+
// https://files.mega65.org/files/other/920413_Sn7YEw.zip, and against
|
|
66
|
+
// romdiff's own source (MEGA65/mega65-tools, src/tools/romdiff.c): a fixed
|
|
67
|
+
// 256-byte header before the diff payload —
|
|
68
|
+
// offset 0x00, 32 bytes: magic, "MEGA65ROMPATCH01.00", NUL-padded
|
|
69
|
+
// offset 0x20, 64 bytes: reference (base) ROM filename, NUL-padded
|
|
70
|
+
// offset 0x60, 160 bytes: output ROM filename, NUL-padded
|
|
71
|
+
// romdiff itself only checks the first 16 bytes ("MEGA65ROMPATCH01") and
|
|
72
|
+
// reads the reference filename as a NUL-terminated C string at offset 32 —
|
|
73
|
+
// this parses the same way rather than assuming this release's exact
|
|
74
|
+
// filenames, per the brief.
|
|
75
|
+
const RDF_MAGIC = 'MEGA65ROMPATCH01';
|
|
76
|
+
const RDF_HEADER_SIZE = 256;
|
|
77
|
+
const RDF_FIELDS = [
|
|
78
|
+
['magic', 0, 32],
|
|
79
|
+
['referenceFilename', 32, 64],
|
|
80
|
+
['outputFilename', 96, 160],
|
|
81
|
+
];
|
|
82
|
+
|
|
83
|
+
function readNulTerminated(buffer, start, maxLen) {
|
|
84
|
+
const field = buffer.subarray(start, start + maxLen);
|
|
85
|
+
const nul = field.indexOf(0);
|
|
86
|
+
return (nul === -1 ? field : field.subarray(0, nul)).toString('ascii');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Parse an .rdf patch file's header. Returns null if `buffer` is too short
|
|
91
|
+
* or does not carry the expected magic — the caller should treat that as
|
|
92
|
+
* "not a MEGA65 ROM diff file", the same thing romdiff itself refuses.
|
|
93
|
+
*/
|
|
94
|
+
export function parseRdfHeader(buffer) {
|
|
95
|
+
if (buffer.length < RDF_HEADER_SIZE) return null;
|
|
96
|
+
const magic = readNulTerminated(buffer, 0, 32);
|
|
97
|
+
if (!magic.startsWith(RDF_MAGIC)) return null;
|
|
98
|
+
const fields = {};
|
|
99
|
+
for (const [name, start, len] of RDF_FIELDS) {
|
|
100
|
+
fields[name] = readNulTerminated(buffer, start, len);
|
|
101
|
+
}
|
|
102
|
+
return fields;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ---- canonical ROM vs. Xemu's per-user ROM -----------------------------
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* How does the path Xemu reads its ROM from (~/.xemu-lgb/MEGA65.ROM) relate
|
|
109
|
+
* to the canonical install (/opt/mega65/MEGA65.ROM)? Pure classifier over
|
|
110
|
+
* pre-gathered facts, so it never has to be exercised against a real
|
|
111
|
+
* filesystem to test the branch matrix the "never overwrite an unrelated
|
|
112
|
+
* ROM without confirmation" requirement hinges on.
|
|
113
|
+
*
|
|
114
|
+
* 'absent' — nothing at the link path yet; safe to create the symlink
|
|
115
|
+
* 'linked' — already a symlink to the canonical path; nothing to do
|
|
116
|
+
* 'migratable' — a regular file that *is* a valid copy of the expected
|
|
117
|
+
* ROM; safe to offer replacing it with the symlink
|
|
118
|
+
* 'foreign' — a symlink elsewhere, or a regular file that doesn't
|
|
119
|
+
* match the expected ROM; never touch without asking
|
|
120
|
+
*/
|
|
121
|
+
export function classifyXemuRomLink({ exists, isSymlink, resolvedTarget, canonicalPath, isValidRom }) {
|
|
122
|
+
if (!exists) return 'absent';
|
|
123
|
+
if (isSymlink) return resolvedTarget === canonicalPath ? 'linked' : 'foreign';
|
|
124
|
+
return isValidRom ? 'migratable' : 'foreign';
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Real-filesystem version of the above: gathers the facts `classifyXemuRomLink`
|
|
129
|
+
* needs from `linkPath` and `canonicalPath`, then classifies them. Kept
|
|
130
|
+
* separate from the pure classifier so doctor.mjs and setup/mega65.mjs can
|
|
131
|
+
* both call this one thing instead of duplicating the fs plumbing, while
|
|
132
|
+
* still being able to unit-test the decision itself without a filesystem.
|
|
133
|
+
* `fs` overrides (`lstatFn`/`readlinkFn`/`readFileFn`) let both callers fake
|
|
134
|
+
* the filesystem entirely for `8bs setup mega65`'s own unit tests — real
|
|
135
|
+
* `node:fs/promises` functions by default.
|
|
136
|
+
*/
|
|
137
|
+
export async function inspectXemuRomLink(linkPath, canonicalPath, expected = MEGA65_ROM_920413, {
|
|
138
|
+
lstatFn = fsLstat, readlinkFn = fsReadlink, readFileFn = fsReadFile,
|
|
139
|
+
} = {}) {
|
|
140
|
+
let exists = false;
|
|
141
|
+
let isSymlink = false;
|
|
142
|
+
let resolvedTarget = null;
|
|
143
|
+
let isValidRom = false;
|
|
144
|
+
try {
|
|
145
|
+
const lstat = await lstatFn(linkPath);
|
|
146
|
+
exists = true;
|
|
147
|
+
isSymlink = lstat.isSymbolicLink();
|
|
148
|
+
if (isSymlink) {
|
|
149
|
+
const target = await readlinkFn(linkPath);
|
|
150
|
+
// A relative symlink target resolves against the *directory containing
|
|
151
|
+
// the link*, not the link path itself.
|
|
152
|
+
resolvedTarget = target.startsWith('/') ? target : resolve(dirname(linkPath), target);
|
|
153
|
+
} else if (lstat.isFile()) {
|
|
154
|
+
const { ok } = validateRomBuffer(await readFileFn(linkPath), { size: expected.romSize, sha256: expected.romSha256 });
|
|
155
|
+
isValidRom = ok;
|
|
156
|
+
}
|
|
157
|
+
} catch {
|
|
158
|
+
exists = false;
|
|
159
|
+
}
|
|
160
|
+
const state = classifyXemuRomLink({ exists, isSymlink, resolvedTarget, canonicalPath, isValidRom });
|
|
161
|
+
return { state, exists, isSymlink, resolvedTarget, isValidRom };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Create or repair the Xemu-local ROM link, based on what `inspectXemuRomLink`
|
|
166
|
+
* found. Never touches anything but `linkPath` itself, and never overwrites
|
|
167
|
+
* a 'foreign' file (an unrelated symlink, or a regular file that isn't a
|
|
168
|
+
* valid copy of the expected ROM) — that always comes back as
|
|
169
|
+
* 'skipped-foreign' with no write performed, regardless of `allowMigrate`.
|
|
170
|
+
* A 'migratable' regular file (a valid ROM already sitting at the link path,
|
|
171
|
+
* predating the canonical install) is only replaced with the symlink when
|
|
172
|
+
* the caller passes `allowMigrate: true` — i.e. after asking the user.
|
|
173
|
+
*/
|
|
174
|
+
export async function ensureXemuRomLink(linkPath, canonicalPath, {
|
|
175
|
+
allowMigrate = false, expected = MEGA65_ROM_920413,
|
|
176
|
+
mkdir = fsMkdir, symlink = fsSymlink, unlink = fsUnlink,
|
|
177
|
+
lstatFn, readlinkFn, readFileFn,
|
|
178
|
+
inspect = (lp, cp, exp) => inspectXemuRomLink(lp, cp, exp, { lstatFn, readlinkFn, readFileFn }),
|
|
179
|
+
} = {}) {
|
|
180
|
+
const inspection = await inspect(linkPath, canonicalPath, expected);
|
|
181
|
+
if (inspection.state === 'linked') return { action: 'none', ...inspection };
|
|
182
|
+
if (inspection.state === 'foreign') return { action: 'skipped-foreign', ...inspection };
|
|
183
|
+
if (inspection.state === 'migratable' && !allowMigrate) return { action: 'skipped-migratable', ...inspection };
|
|
184
|
+
await mkdir(dirname(linkPath), { recursive: true });
|
|
185
|
+
if (inspection.state === 'migratable') await unlink(linkPath);
|
|
186
|
+
await symlink(canonicalPath, linkPath);
|
|
187
|
+
return { action: inspection.state === 'migratable' ? 'migrated' : 'linked', ...inspection };
|
|
188
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// Source checkout and build steps shared by every source-built target
|
|
2
|
+
// (`8bs setup mega65` builds Xemu, `8bs setup cx16` builds x16-emulator
|
|
3
|
+
// and x16-rom). Every process/filesystem boundary is injected — an `exec`
|
|
4
|
+
// matching exec.mjs's execInherit, plus `exists`/`mkdirFn` — so the
|
|
5
|
+
// per-target modules stay thin and unit tests never clone or compile.
|
|
6
|
+
import { access, mkdir } from 'node:fs/promises';
|
|
7
|
+
import { dirname, join } from 'node:path';
|
|
8
|
+
|
|
9
|
+
import { execInherit } from './exec.mjs';
|
|
10
|
+
|
|
11
|
+
export async function pathExists(p) {
|
|
12
|
+
try {
|
|
13
|
+
await access(p);
|
|
14
|
+
return true;
|
|
15
|
+
} catch {
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Clone `repo` into `sourceDir` the first time; fast-forward it on every
|
|
22
|
+
* later run. Never re-clones an existing checkout — a build directory that
|
|
23
|
+
* already exists there is exactly what makes a re-run cheap (an incremental
|
|
24
|
+
* `make` instead of a from-scratch build). Runs as the normal user; there is
|
|
25
|
+
* no sudo anywhere in this module.
|
|
26
|
+
*/
|
|
27
|
+
export async function syncRepository({
|
|
28
|
+
sourceDir, repo, exec = execInherit, exists = pathExists, mkdirFn = mkdir,
|
|
29
|
+
}) {
|
|
30
|
+
if (await exists(join(sourceDir, '.git'))) {
|
|
31
|
+
const pull = await exec('git', ['-C', sourceDir, 'pull', '--ff-only']);
|
|
32
|
+
if (pull.code !== 0) return { ok: false, step: 'git pull', code: pull.code };
|
|
33
|
+
return { ok: true, action: 'updated' };
|
|
34
|
+
}
|
|
35
|
+
await mkdirFn(dirname(sourceDir), { recursive: true });
|
|
36
|
+
const clone = await exec('git', ['clone', repo, sourceDir]);
|
|
37
|
+
if (clone.code !== 0) return { ok: false, step: 'git clone', code: clone.code };
|
|
38
|
+
return { ok: true, action: 'cloned' };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Run one build command in `cwd` and confirm every path in `artifacts` came
|
|
43
|
+
* out of it. Warnings on stderr are never a failure — every source build
|
|
44
|
+
* this project does (Xemu under GCC, x16-emulator under AppleClang with its
|
|
45
|
+
* "reducing alignment of section" linker warnings, x16-rom's pages of
|
|
46
|
+
* ca65/ld65 notices) emits some on a perfectly good build. Only a non-zero
|
|
47
|
+
* exit, or a missing artifact afterward, is.
|
|
48
|
+
*/
|
|
49
|
+
export async function runBuild({
|
|
50
|
+
cwd, command = 'make', args = [], artifacts = [], exec = execInherit, exists = pathExists,
|
|
51
|
+
}) {
|
|
52
|
+
const build = await exec(command, args, { cwd });
|
|
53
|
+
if (build.code !== 0) return { ok: false, step: command, code: build.code };
|
|
54
|
+
for (const artifact of artifacts) {
|
|
55
|
+
if (!(await exists(artifact))) {
|
|
56
|
+
return { ok: false, step: command, code: build.code, missingArtifact: artifact };
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return { ok: true };
|
|
60
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// Building and installing xmega65 (Xemu's MEGA65 core) from source. The AUR
|
|
2
|
+
// `xmega65-git` package is deliberately not used anywhere in this
|
|
3
|
+
// project — see doctor.mjs's INSTALLERS.xmega65 comment — so this is the
|
|
4
|
+
// only path `8bs setup mega65` has to a working emulator.
|
|
5
|
+
import { lstat, mkdir, readlink, symlink } from 'node:fs/promises';
|
|
6
|
+
import { dirname, join, resolve } from 'node:path';
|
|
7
|
+
|
|
8
|
+
import { execInherit } from './exec.mjs';
|
|
9
|
+
import { syncRepository, runBuild, pathExists } from './source.mjs';
|
|
10
|
+
import {
|
|
11
|
+
xemuSourceDir, XEMU_INSTALL_DIR, XMEGA65_INSTALL_PATH, XMEGA65_SYMLINK_PATH,
|
|
12
|
+
xemuUserDataDir, xemuMega65RealDataDir,
|
|
13
|
+
} from './paths.mjs';
|
|
14
|
+
|
|
15
|
+
const XEMU_REPO = 'https://github.com/lgblgblgb/xemu.git';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Clone-or-update lgblgblgb/xemu, then `make` only `targets/mega65` — this
|
|
19
|
+
* project has no use for Xemu's other machine cores. Harmless compiler
|
|
20
|
+
* warnings (confirmed against GCC 16 / sdl2-compat 2.32.70 / GTK3 on current
|
|
21
|
+
* Arch, and against Apple clang 21 / SDL 2.32.72 on Apple Silicon macOS —
|
|
22
|
+
* "experimental memory data pointers", "no _mm_malloc() on ARM", unused
|
|
23
|
+
* variables) are not a failure; only a non-zero `make` exit or a missing
|
|
24
|
+
* resulting binary is.
|
|
25
|
+
*/
|
|
26
|
+
export async function buildXemuMega65({
|
|
27
|
+
sourceDir = xemuSourceDir(), repo = XEMU_REPO, exec = execInherit, exists = pathExists, mkdirFn = mkdir,
|
|
28
|
+
} = {}) {
|
|
29
|
+
const sync = await syncRepository({ sourceDir, repo, exec, exists, mkdirFn });
|
|
30
|
+
if (!sync.ok) return sync;
|
|
31
|
+
const binaryPath = join(sourceDir, 'build', 'bin', 'xmega65.native');
|
|
32
|
+
const build = await runBuild({ cwd: join(sourceDir, 'targets', 'mega65'), artifacts: [binaryPath], exec, exists });
|
|
33
|
+
if (!build.ok) return { ...build, binaryPath };
|
|
34
|
+
return { ok: true, binaryPath };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Install the built binary system-wide: `/opt/xemu/xmega65` (never named
|
|
39
|
+
* `xmega65.native` — the brief is explicit). Putting it on `PATH` is a
|
|
40
|
+
* separate step — see xmega65LauncherSpec() and setup/launcher.mjs — so a
|
|
41
|
+
* foreign `/usr/local/bin/xmega65` is never silently overwritten here.
|
|
42
|
+
*/
|
|
43
|
+
export async function installXemu(binaryPath, { sudoExec, installDir = XEMU_INSTALL_DIR, installPath = XMEGA65_INSTALL_PATH } = {}) {
|
|
44
|
+
const mkdirResult = await sudoExec('mkdir', ['-p', installDir]);
|
|
45
|
+
if (mkdirResult.code !== 0) return { ok: false, step: 'mkdir', code: mkdirResult.code };
|
|
46
|
+
const installResult = await sudoExec('install', ['-m755', binaryPath, installPath]);
|
|
47
|
+
if (installResult.code !== 0) return { ok: false, step: 'install', code: installResult.code };
|
|
48
|
+
return { ok: true, installPath };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Unlike Commander X16's x16emu, a plain `/usr/local/bin/xmega65 ->
|
|
53
|
+
* /opt/xemu/xmega65` symlink works correctly on macOS too — confirmed on a
|
|
54
|
+
* real Apple Silicon install: Xemu resolves its own data directory from
|
|
55
|
+
* `$HOME`, never from the path it was invoked through, so there's no
|
|
56
|
+
* platform split here and no wrapper script.
|
|
57
|
+
*/
|
|
58
|
+
export function xmega65LauncherSpec() {
|
|
59
|
+
return { path: XMEGA65_SYMLINK_PATH, target: XMEGA65_INSTALL_PATH, execLine: null, kind: 'symlink' };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* What's at Xemu's `~/.xemu-lgb` compatibility path right now: `'missing'`
|
|
64
|
+
* (nothing there yet), `'symlink'` (resolved to its real target — Xemu's
|
|
65
|
+
* own first-run behaviour, or a prior run of this function), or `'other'`
|
|
66
|
+
* (a real directory, or anything else Xemu itself put there — left alone
|
|
67
|
+
* either way).
|
|
68
|
+
*/
|
|
69
|
+
export async function inspectXemuDataDir(linkPath, { lstatFn = lstat, readlinkFn = readlink } = {}) {
|
|
70
|
+
let stats;
|
|
71
|
+
try {
|
|
72
|
+
stats = await lstatFn(linkPath);
|
|
73
|
+
} catch {
|
|
74
|
+
return { state: 'missing' };
|
|
75
|
+
}
|
|
76
|
+
if (stats.isSymbolicLink()) {
|
|
77
|
+
const target = await readlinkFn(linkPath);
|
|
78
|
+
const resolved = target.startsWith('/') ? target : resolve(dirname(linkPath), target);
|
|
79
|
+
return { state: 'symlink', target: resolved };
|
|
80
|
+
}
|
|
81
|
+
return { state: 'other' };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Xemu creates `~/.xemu-lgb` itself, as a compatibility symlink into its
|
|
86
|
+
* real per-platform data directory (`~/Library/Application Support/
|
|
87
|
+
* xemu-lgb/mega65` on macOS, `~/.local/share/xemu-lgb/mega65` on Linux —
|
|
88
|
+
* both confirmed on real first launches), the first time it runs. Setup
|
|
89
|
+
* never launches the emulator just to get that layout, so if nothing is
|
|
90
|
+
* there yet it creates the identical layout itself: the real directory,
|
|
91
|
+
* then the symlink — so the ROM link step right after this has somewhere
|
|
92
|
+
* correct to land. Anything already at `linkPath` — any symlink, or a real
|
|
93
|
+
* directory — is Xemu's own (or a prior run of this same function) and is
|
|
94
|
+
* left completely untouched, per the brief's "never overwrite an existing
|
|
95
|
+
* ~/.xemu-lgb that points somewhere else" — extended here to "never
|
|
96
|
+
* overwrite it at all" once it exists.
|
|
97
|
+
*/
|
|
98
|
+
export async function ensureXemuDataDir({
|
|
99
|
+
platform = process.platform, linkPath = xemuUserDataDir(), realDir = xemuMega65RealDataDir(platform),
|
|
100
|
+
mkdirFn = mkdir, symlinkFn = symlink, inspect = inspectXemuDataDir,
|
|
101
|
+
} = {}) {
|
|
102
|
+
const inspection = await inspect(linkPath);
|
|
103
|
+
if (inspection.state !== 'missing') return { ok: true, action: 'unchanged', ...inspection };
|
|
104
|
+
await mkdirFn(realDir, { recursive: true });
|
|
105
|
+
await symlinkFn(realDir, linkPath);
|
|
106
|
+
return { ok: true, action: 'created', realDir };
|
|
107
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// A minimal ZIP reader, just enough to pull one named entry out of the
|
|
2
|
+
// official MEGA65 ROM-patch archive (or a C64 Forever MSI's own zip-shaped
|
|
3
|
+
// internals, if ever needed) without adding a zip library dependency this
|
|
4
|
+
// project doesn't otherwise need. Supports the two compression methods a
|
|
5
|
+
// real-world zip actually uses: 0 (stored) and 8 (deflate, via Node's own
|
|
6
|
+
// zlib). Confirmed directly against the real 920413_Sn7YEw.zip from
|
|
7
|
+
// files.mega65.org, whose three entries are all stored (method 0) — deflate
|
|
8
|
+
// support is here so a future re-upload that does compress them still works.
|
|
9
|
+
import { inflateRawSync } from 'node:zlib';
|
|
10
|
+
|
|
11
|
+
const EOCD_SIGNATURE = 0x06054b50;
|
|
12
|
+
const CENTRAL_DIR_SIGNATURE = 0x02014b50;
|
|
13
|
+
const LOCAL_HEADER_SIGNATURE = 0x04034b50;
|
|
14
|
+
|
|
15
|
+
/** Find the End Of Central Directory record by scanning backward from the
|
|
16
|
+
* end of the file — it can be followed by a variable-length comment, so its
|
|
17
|
+
* offset isn't fixed. */
|
|
18
|
+
function findEndOfCentralDirectory(buffer) {
|
|
19
|
+
const maxCommentLength = 65535;
|
|
20
|
+
const searchStart = Math.max(0, buffer.length - 22 - maxCommentLength);
|
|
21
|
+
for (let offset = buffer.length - 22; offset >= searchStart; offset -= 1) {
|
|
22
|
+
if (buffer.readUInt32LE(offset) === EOCD_SIGNATURE) return offset;
|
|
23
|
+
}
|
|
24
|
+
return -1;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** List every entry in a ZIP archive: { name, method, compressedSize,
|
|
28
|
+
* uncompressedSize, localHeaderOffset }. Throws if `buffer` isn't a ZIP
|
|
29
|
+
* this reader understands. */
|
|
30
|
+
export function readZipEntries(buffer) {
|
|
31
|
+
const eocd = findEndOfCentralDirectory(buffer);
|
|
32
|
+
if (eocd === -1) throw new Error('not a ZIP file (no end-of-central-directory record found)');
|
|
33
|
+
|
|
34
|
+
const entryCount = buffer.readUInt16LE(eocd + 10);
|
|
35
|
+
const centralDirOffset = buffer.readUInt32LE(eocd + 16);
|
|
36
|
+
|
|
37
|
+
const entries = [];
|
|
38
|
+
let offset = centralDirOffset;
|
|
39
|
+
for (let i = 0; i < entryCount; i += 1) {
|
|
40
|
+
if (buffer.readUInt32LE(offset) !== CENTRAL_DIR_SIGNATURE) {
|
|
41
|
+
throw new Error(`ZIP central directory entry ${i} has a bad signature`);
|
|
42
|
+
}
|
|
43
|
+
const method = buffer.readUInt16LE(offset + 10);
|
|
44
|
+
const compressedSize = buffer.readUInt32LE(offset + 20);
|
|
45
|
+
const uncompressedSize = buffer.readUInt32LE(offset + 24);
|
|
46
|
+
const nameLength = buffer.readUInt16LE(offset + 28);
|
|
47
|
+
const extraLength = buffer.readUInt16LE(offset + 30);
|
|
48
|
+
const commentLength = buffer.readUInt16LE(offset + 32);
|
|
49
|
+
const localHeaderOffset = buffer.readUInt32LE(offset + 42);
|
|
50
|
+
const name = buffer.toString('utf8', offset + 46, offset + 46 + nameLength);
|
|
51
|
+
entries.push({ name, method, compressedSize, uncompressedSize, localHeaderOffset });
|
|
52
|
+
offset += 46 + nameLength + extraLength + commentLength;
|
|
53
|
+
}
|
|
54
|
+
return entries;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Decompressed bytes for one entry from `readZipEntries`. */
|
|
58
|
+
export function extractZipEntry(buffer, entry) {
|
|
59
|
+
const localNameLength = buffer.readUInt16LE(entry.localHeaderOffset + 26);
|
|
60
|
+
const localExtraLength = buffer.readUInt16LE(entry.localHeaderOffset + 28);
|
|
61
|
+
const dataOffset = entry.localHeaderOffset + 30 + localNameLength + localExtraLength;
|
|
62
|
+
const compressed = buffer.subarray(dataOffset, dataOffset + entry.compressedSize);
|
|
63
|
+
if (entry.method === 0) return Buffer.from(compressed);
|
|
64
|
+
if (entry.method === 8) return inflateRawSync(compressed);
|
|
65
|
+
throw new Error(`ZIP entry '${entry.name}' uses unsupported compression method ${entry.method}`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Find an entry by exact path, or by basename if no exact match — the
|
|
69
|
+
* official archive nests entries under a release-number directory
|
|
70
|
+
* (`920413/920413.rdf`) that this project should not assume is permanent. */
|
|
71
|
+
export function findZipEntry(entries, name) {
|
|
72
|
+
const exact = entries.find((e) => e.name === name);
|
|
73
|
+
if (exact) return exact;
|
|
74
|
+
return entries.find((e) => e.name.split('/').pop() === name) ?? null;
|
|
75
|
+
}
|
package/src/setup.mjs
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// `8bs setup <target>` — install/configure what a target needs beyond what
|
|
2
|
+
// `8bs doctor` can offer as a single package-manager command: mega65 (Xemu
|
|
3
|
+
// built from source, plus the legally-obtained ROM pipeline
|
|
4
|
+
// docs/setup/mega65.md documents) and cx16 (x16emu and a matching ROM built
|
|
5
|
+
// from upstream source, docs/setup/cx16.md). This file stays a thin
|
|
6
|
+
// dispatcher; the shared machinery lives in setup/{deps,host,source,
|
|
7
|
+
// install,launcher}.mjs so the next source-built target follows the same
|
|
8
|
+
// shape.
|
|
9
|
+
const TARGETS = {
|
|
10
|
+
mega65: () => import('./setup/mega65.mjs').then((m) => m.setupMega65),
|
|
11
|
+
cx16: () => import('./setup/cx16.mjs').then((m) => m.setupCx16),
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const USAGE = 'Usage: 8bs setup <mega65|cx16> [--rom <path>] [--c64-forever <msi>] [--rom-patch <zip>] [--repair] [--update]\n';
|
|
15
|
+
|
|
16
|
+
function parseArgs(args) {
|
|
17
|
+
const options = {};
|
|
18
|
+
const positionals = [];
|
|
19
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
20
|
+
const arg = args[i];
|
|
21
|
+
if (arg === '--rom') {
|
|
22
|
+
options.romPath = args[i + 1];
|
|
23
|
+
i += 1;
|
|
24
|
+
} else if (arg === '--c64-forever') {
|
|
25
|
+
options.c64ForeverPath = args[i + 1];
|
|
26
|
+
i += 1;
|
|
27
|
+
} else if (arg === '--rom-patch') {
|
|
28
|
+
options.romPatchPath = args[i + 1];
|
|
29
|
+
i += 1;
|
|
30
|
+
} else if (arg === '--repair') {
|
|
31
|
+
options.repair = true;
|
|
32
|
+
} else if (arg === '--update') {
|
|
33
|
+
options.update = true;
|
|
34
|
+
} else if (!arg.startsWith('-')) {
|
|
35
|
+
positionals.push(arg);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return { target: positionals[0], options };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** @returns {Promise<number>} process exit code */
|
|
42
|
+
export async function setup(args) {
|
|
43
|
+
const { target, options } = parseArgs(args);
|
|
44
|
+
if (!target) {
|
|
45
|
+
process.stderr.write(USAGE);
|
|
46
|
+
return 2;
|
|
47
|
+
}
|
|
48
|
+
const load = TARGETS[target];
|
|
49
|
+
if (!load) {
|
|
50
|
+
process.stderr.write(`8bs setup: no setup available for '${target}'. Run '8bs doctor' for what's missing — docs/setup/.\n`);
|
|
51
|
+
return 2;
|
|
52
|
+
}
|
|
53
|
+
const run = await load();
|
|
54
|
+
const result = await run(options);
|
|
55
|
+
return result.ok ? 0 : 1;
|
|
56
|
+
}
|
package/src/targets.mjs
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
// `8bs targets [--json]` — every machine the toolchain builds for, and the
|
|
2
|
+
// hardware each can be fitted with: the catalog every machine package
|
|
3
|
+
// declares (packages/cli/src/hardware.mjs), plus the profiles the project
|
|
4
|
+
// in the current directory composes in its 8bs.config.ts, and the whole
|
|
5
|
+
// machines that config has been set up for in its `systems` block. The
|
|
6
|
+
// editor reads the JSON form to build its System and Hardware controls, so
|
|
7
|
+
// a new machine or option in a package — or a new system in a project — is
|
|
8
|
+
// a new row there with nothing to update by hand.
|
|
9
|
+
import { FACTS, MACHINES } from '@8bitscript/compiler';
|
|
10
|
+
|
|
11
|
+
import { loadConfig } from './config.mjs';
|
|
12
|
+
import {
|
|
13
|
+
REGION_MACHINES, loadCatalog, projectHardware, projectProfiles, projectRequires, projectSystems,
|
|
14
|
+
stockFacts,
|
|
15
|
+
} from './hardware.mjs';
|
|
16
|
+
import { VICE_EMULATOR } from './run.mjs';
|
|
17
|
+
|
|
18
|
+
const EMULATOR = {
|
|
19
|
+
...VICE_EMULATOR, atari8: 'atari800', nes: 'fceux', cx16: 'x16emu', mega65: 'xmega65', web: 'the browser',
|
|
20
|
+
};
|
|
21
|
+
const TITLE = {
|
|
22
|
+
vic20: 'Commodore VIC-20', c64: 'Commodore 64', pet: 'Commodore PET', c128: 'Commodore 128',
|
|
23
|
+
atari8: 'Atari 8-bit', nes: 'Nintendo Entertainment System', cx16: 'Commander X16', mega65: 'MEGA65', web: 'Web',
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* One entry per target: what the editor's dropdowns and hardware panel
|
|
28
|
+
* are built from.
|
|
29
|
+
*
|
|
30
|
+
* @param {object|null} config the project's 8bs.config.ts, if any
|
|
31
|
+
*/
|
|
32
|
+
export function describeTargets(config) {
|
|
33
|
+
return MACHINES.map((id) => {
|
|
34
|
+
const catalog = loadCatalog(id);
|
|
35
|
+
const options = Object.fromEntries(Object.entries(catalog.options).map(([optionId, option]) => [optionId, {
|
|
36
|
+
label: option.label,
|
|
37
|
+
default: option.default,
|
|
38
|
+
// The package subpath whose probe finds this hardware on the machine
|
|
39
|
+
// at run time, when one probe finds every value of the option; a
|
|
40
|
+
// value may name its own instead (see below). Null when the choice
|
|
41
|
+
// is the build's (a PET model).
|
|
42
|
+
detect: option.detect ?? null,
|
|
43
|
+
values: Object.fromEntries(Object.entries(option.values).map(([value, entry]) => [value, {
|
|
44
|
+
label: entry.label,
|
|
45
|
+
affectsBuild: Boolean(entry.build),
|
|
46
|
+
// What finds *this* value at run time: its own probe, or the
|
|
47
|
+
// option's when one probe covers them all.
|
|
48
|
+
detect: entry.detect ?? option.detect ?? null,
|
|
49
|
+
tag: Object.hasOwn(entry, 'tag') ? entry.tag : (value === option.default ? null : value),
|
|
50
|
+
facts: entry.facts ?? {},
|
|
51
|
+
}])),
|
|
52
|
+
}]));
|
|
53
|
+
return {
|
|
54
|
+
id,
|
|
55
|
+
title: TITLE[id],
|
|
56
|
+
emulator: EMULATOR[id],
|
|
57
|
+
region: REGION_MACHINES.has(id),
|
|
58
|
+
options,
|
|
59
|
+
presets: catalog.presets,
|
|
60
|
+
profiles: projectProfiles(config, id),
|
|
61
|
+
// The project's own default values for this machine, under any profile.
|
|
62
|
+
hardware: projectHardware(config, id),
|
|
63
|
+
// The stock machine's sheet; a chosen value's `facts` change it, in
|
|
64
|
+
// option order, the way resolveHardware merges them.
|
|
65
|
+
facts: stockFacts(id),
|
|
66
|
+
};
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The fact keys, typed and described, so the editor's sheet labels itself from one place. */
|
|
71
|
+
export function describeFacts() {
|
|
72
|
+
return [...FACTS].map(([key, fact]) => ({ key, ...fact }));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** One line per system a config declares, for the table form. */
|
|
76
|
+
function printSystems(systems) {
|
|
77
|
+
if (systems.length === 0) return;
|
|
78
|
+
process.stdout.write('\nThis project is set up for:\n');
|
|
79
|
+
for (const system of systems) {
|
|
80
|
+
const parts = [
|
|
81
|
+
system.profile && `--profile ${system.profile}`,
|
|
82
|
+
Object.entries(system.hardware).length > 0
|
|
83
|
+
&& `--hardware ${Object.entries(system.hardware).map(([k, v]) => `${k}=${v}`).join(',')}`,
|
|
84
|
+
system.region === 'pal' && '--pal',
|
|
85
|
+
].filter(Boolean);
|
|
86
|
+
process.stdout.write(` ${system.name.padEnd(24)} 8bs run ${system.target}${parts.length > 0 ? ` ${parts.join(' ')}` : ''}\n`);
|
|
87
|
+
for (const { key, need, have } of system.unmet ?? []) {
|
|
88
|
+
process.stdout.write(` ${' '.repeat(24)} short: ${key} needs ${need === true ? 'it' : need}, has ${have === true ? 'it' : have}\n`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** What the program asks of any machine, for the table form. */
|
|
94
|
+
function printRequires(requires) {
|
|
95
|
+
const entries = Object.entries(requires);
|
|
96
|
+
if (entries.length === 0) return;
|
|
97
|
+
process.stdout.write('\nThis program needs, of any machine:\n');
|
|
98
|
+
for (const [key, need] of entries) {
|
|
99
|
+
process.stdout.write(` ${key.padEnd(24)} ${need === true ? 'yes' : `at least ${need}`}\n`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** @returns {Promise<number>} exit code */
|
|
104
|
+
export async function targets(args) {
|
|
105
|
+
const config = await loadConfig(process.cwd(), '8bs targets');
|
|
106
|
+
const required = projectRequires(config);
|
|
107
|
+
const systems = projectSystems(config);
|
|
108
|
+
const described = describeTargets(config);
|
|
109
|
+
if (args.includes('--json')) {
|
|
110
|
+
// A `systems` block the config gets wrong costs the reader its
|
|
111
|
+
// systems and nothing else: the machines and their catalogs are the
|
|
112
|
+
// toolchain's and are still true. The editor reads this, and a whole
|
|
113
|
+
// hardware panel disappearing because of a typo three lines away
|
|
114
|
+
// would say nothing about what is wrong.
|
|
115
|
+
process.stdout.write(`${JSON.stringify({
|
|
116
|
+
targets: described,
|
|
117
|
+
systems: systems.ok ? systems.systems : [],
|
|
118
|
+
systemsError: systems.ok ? null : systems.error,
|
|
119
|
+
requires: required.ok ? required.requires : {},
|
|
120
|
+
requiresError: required.ok ? null : required.error,
|
|
121
|
+
facts: describeFacts(),
|
|
122
|
+
}, null, 2)}\n`);
|
|
123
|
+
return 0;
|
|
124
|
+
}
|
|
125
|
+
for (const result of [required, systems]) {
|
|
126
|
+
if (!result.ok) {
|
|
127
|
+
process.stderr.write(`8bs targets: ${result.error}\n`);
|
|
128
|
+
return 1;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
for (const t of described) {
|
|
132
|
+
const presets = Object.keys(t.presets);
|
|
133
|
+
const profiles = Object.keys(t.profiles);
|
|
134
|
+
process.stdout.write(`${t.id.padEnd(8)} ${t.title} — ${t.emulator}${t.region ? ', --pal available' : ''}\n`);
|
|
135
|
+
for (const [optionId, option] of Object.entries(t.options)) {
|
|
136
|
+
const values = Object.entries(option.values)
|
|
137
|
+
.map(([value, entry]) => `${value}${value === option.default ? '*' : ''}${entry.affectsBuild ? ' (build)' : ''}`)
|
|
138
|
+
.join(', ');
|
|
139
|
+
process.stdout.write(` --hardware ${optionId}= ${option.label}: ${values}\n`);
|
|
140
|
+
const probes = new Map();
|
|
141
|
+
for (const [value, entry] of Object.entries(option.values)) {
|
|
142
|
+
if (!entry.detect || value === option.default) continue;
|
|
143
|
+
probes.set(entry.detect, [...(probes.get(entry.detect) ?? []), value]);
|
|
144
|
+
}
|
|
145
|
+
for (const [probe, found] of probes) {
|
|
146
|
+
process.stdout.write(` ${found.join(', ')} found at run time by ${probe}\n`);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
if (presets.length > 0) process.stdout.write(` --profile presets: ${presets.join(', ')}\n`);
|
|
150
|
+
if (profiles.length > 0) process.stdout.write(` --profile this project: ${profiles.join(', ')}\n`);
|
|
151
|
+
const own = Object.entries(t.hardware).map(([k, v]) => `${k}=${v}`);
|
|
152
|
+
if (own.length > 0) process.stdout.write(` this project's default: ${own.join(' ')}\n`);
|
|
153
|
+
}
|
|
154
|
+
printRequires(required.requires);
|
|
155
|
+
printSystems(systems.systems);
|
|
156
|
+
process.stdout.write('\n* the default; (build) changes the program, not only the emulator\n');
|
|
157
|
+
return 0;
|
|
158
|
+
}
|