@henols/c64-re-tools 1.1.0 → 2.0.0-rc.2

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.
Files changed (138) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +190 -37
  3. package/distribution/package.ts +36 -0
  4. package/distribution/plugin.ts +120 -0
  5. package/package.json +34 -40
  6. package/skills/c64-assembler/SKILL.md +73 -0
  7. package/skills/c64-assembler/scripts/assemble.ts +103 -0
  8. package/skills/c64-basic/SKILL.md +67 -0
  9. package/skills/c64-basic/scripts/basic.ts +46 -0
  10. package/skills/c64-disk/SKILL.md +79 -0
  11. package/skills/c64-disk/scripts/disk.ts +65 -0
  12. package/skills/c64-emulator/SKILL.md +124 -0
  13. package/skills/c64-knowledge/SKILL.md +102 -0
  14. package/skills/c64-knowledge/scripts/knowledge.ts +257 -0
  15. package/skills/c64-memory-map/SKILL.md +76 -0
  16. package/skills/c64-memory-map/scripts/memmap.ts +81 -0
  17. package/skills/c64-memory-map/scripts/platform.ts +424 -0
  18. package/skills/c64-provenance/SKILL.md +76 -0
  19. package/skills/c64-provenance/scripts/compare.ts +92 -0
  20. package/skills/c64-provenance/scripts/provenance.ts +83 -0
  21. package/skills/c64-reverse-engineering/SKILL.md +90 -0
  22. package/skills/c64-static-analysis/SKILL.md +96 -0
  23. package/skills/c64-static-analysis/scripts/analyze.ts +175 -0
  24. package/skills/c64-static-analysis/scripts/findings.ts +73 -0
  25. package/skills/c64-testing/SKILL.md +79 -0
  26. package/skills/c64-testing/references/scenario-format.md +76 -0
  27. package/skills/c64-testing/scripts/checklist.ts +51 -0
  28. package/skills/c64-testing/scripts/scenario.ts +473 -0
  29. package/skills/c64-testing/scripts/test.ts +55 -0
  30. package/skills/c64-unpacker/SKILL.md +82 -0
  31. package/skills/c64-unpacker/scripts/evidence.ts +135 -0
  32. package/skills/c64-unpacker/scripts/unpack.ts +216 -0
  33. package/src/c64.ts +281 -0
  34. package/src/cli/ap-sdk.ts +44 -0
  35. package/src/cli/cleanup.ts +194 -0
  36. package/src/cli/main.ts +122 -0
  37. package/src/cli/status.ts +34 -0
  38. package/src/host/main.ts +121 -0
  39. package/src/host/server.ts +409 -0
  40. package/src/host/tools/c1541.ts +255 -0
  41. package/src/host/tools/index.ts +31 -0
  42. package/src/host/tools/petcat.ts +70 -0
  43. package/src/host/tools/status.ts +30 -0
  44. package/src/host/tools/version.ts +41 -0
  45. package/src/host/vice/adapter.ts +761 -0
  46. package/src/host/vice/binary-monitor.ts +389 -0
  47. package/src/host/vice/chips.ts +108 -0
  48. package/src/host/vice/connect.ts +34 -0
  49. package/src/host/vice/process.ts +402 -0
  50. package/src/host/vice/screen.ts +183 -0
  51. package/src/host/vice/session.ts +1316 -0
  52. package/src/host/vice/text-monitor.ts +223 -0
  53. package/src/host-client/connect.ts +241 -0
  54. package/src/host-client/tools.ts +89 -0
  55. package/src/host-client/transfer.ts +100 -0
  56. package/src/host-client/vice-session.ts +254 -0
  57. package/src/knowledge/database.ts +132 -0
  58. package/src/knowledge/history.ts +128 -0
  59. package/src/knowledge/import.ts +344 -0
  60. package/src/knowledge/read.ts +191 -0
  61. package/src/knowledge/schema.ts +140 -0
  62. package/src/knowledge/write.ts +348 -0
  63. package/src/mcp/main.ts +109 -0
  64. package/src/mcp/server.ts +307 -0
  65. package/src/mcp/tools/debug.ts +237 -0
  66. package/src/mcp/tools/execution.ts +116 -0
  67. package/src/mcp/tools/input.ts +69 -0
  68. package/src/mcp/tools/machine.ts +97 -0
  69. package/src/mcp/tools/media.ts +106 -0
  70. package/src/mcp/tools/memory.ts +203 -0
  71. package/src/mcp/tools/video.ts +260 -0
  72. package/src/native/acme.ts +116 -0
  73. package/src/native/discover.ts +141 -0
  74. package/src/native/dxa.ts +166 -0
  75. package/src/native/ghidra/analyze.ts +86 -0
  76. package/src/native/ghidra/index.ts +157 -0
  77. package/src/native/ghidra/language/c64rt_6510.cspec +34 -0
  78. package/src/native/ghidra/language/c64rt_6510.ldefs +15 -0
  79. package/src/native/ghidra/language/c64rt_6510.pspec +15 -0
  80. package/src/native/ghidra/language/c64rt_6510.slaspec +596 -0
  81. package/src/native/ghidra/scripts/C64Export.java +242 -0
  82. package/src/native/ghidra/scripts/C64Prepare.java +60 -0
  83. package/src/native/local.ts +34 -0
  84. package/src/native/processes.ts +350 -0
  85. package/src/native/run.ts +172 -0
  86. package/src/native/staging.ts +83 -0
  87. package/src/native/status.ts +101 -0
  88. package/src/native/types.ts +228 -0
  89. package/src/native/watchdog.ts +78 -0
  90. package/src/project.ts +39 -0
  91. package/src/protocol/framing.ts +132 -0
  92. package/src/protocol/messages.ts +252 -0
  93. package/src/protocol/tools.ts +247 -0
  94. package/src/protocol/vice.ts +1235 -0
  95. package/src/script.ts +59 -0
  96. package/src/time.ts +15 -0
  97. package/src/trace.ts +162 -0
  98. package/THIRD-PARTY-NOTICES.md +0 -26
  99. package/bin/cli.mjs +0 -283
  100. package/skills/acme-build/SKILL.md +0 -260
  101. package/skills/acme-build/scripts/acme.mjs +0 -341
  102. package/skills/acme-build/template.a +0 -53
  103. package/skills/c64-disk-access/SKILL.md +0 -159
  104. package/skills/c64-disk-access/scripts/c1541.mjs +0 -569
  105. package/skills/c64-memory-mapping/SKILL.md +0 -631
  106. package/skills/c64-memory-mapping/memmap.json +0 -8800
  107. package/skills/c64-memory-mapping/scripts/driver.mjs +0 -553
  108. package/skills/c64-petcat/SKILL.md +0 -87
  109. package/skills/c64-petcat/scripts/petcat.mjs +0 -221
  110. package/skills/c64-program-recon/SKILL.md +0 -706
  111. package/skills/c64-program-recon/references/control-flow.md +0 -180
  112. package/skills/c64-program-recon/references/graphics.md +0 -79
  113. package/skills/c64-program-recon/references/observation-hazards.md +0 -143
  114. package/skills/c64-program-recon/references/reconstruction.md +0 -133
  115. package/skills/c64-program-recon/references/sound-and-input.md +0 -74
  116. package/skills/c64-program-recon/references/tool-selection.md +0 -78
  117. package/skills/c64-program-recon/scripts/derive.mjs +0 -364
  118. package/skills/c64-program-recon/scripts/packer-finding.mjs +0 -709
  119. package/skills/c64-program-recon/templates/memory-map.template.md +0 -101
  120. package/skills/c64-provenance-diff/SKILL.md +0 -292
  121. package/skills/c64-provenance-diff/scripts/diff-images.mjs +0 -984
  122. package/skills/c64-provenance-diff/scripts/recovery-schema.mjs +0 -395
  123. package/skills/c64-ram-capture/RELEASES.json.example +0 -17
  124. package/skills/c64-ram-capture/SKILL.md +0 -474
  125. package/skills/c64-ram-capture/scripts/compare-cross-binary.mjs +0 -525
  126. package/skills/c64-ram-capture/scripts/compare.mjs +0 -258
  127. package/skills/c64-ram-capture/scripts/derive-transients.mjs +0 -575
  128. package/skills/c64-ram-capture/scripts/dump-artifacts.mjs +0 -317
  129. package/skills/c64-ram-capture/scripts/mcp-module.mjs +0 -174
  130. package/skills/c64-ram-capture/scripts/project-paths.mjs +0 -81
  131. package/skills/c64-ram-capture/scripts/releases.mjs +0 -109
  132. package/skills/c64-ram-capture/scripts/vsf-slice.mjs +0 -147
  133. package/skills/c64-ram-capture/scripts/watch-loads.mjs +0 -581
  134. package/skills/c64-ram-capture/templates/capture-record.template.md +0 -99
  135. package/skills/c64-ram-capture/transients/README.md +0 -137
  136. package/skills/routine-queue-walker/SKILL.md +0 -394
  137. package/skills/routine-queue-walker/scripts/completeness-report.mjs +0 -465
  138. package/skills/vice-wedge-triage/SKILL.md +0 -269
@@ -0,0 +1,402 @@
1
+ // VICE launch, readiness and termination. The launch contract of x64sc:
2
+ // - `-default` must come before `-binarymonitor`, or the monitor never binds;
3
+ // - the monitor may accept a connection during startup and then drop it, so
4
+ // readiness is a connect plus a successful ping, retried until a deadline;
5
+ // - any monitor command stops the machine, so readiness ends with `exit`;
6
+ // - `-default` selects drive type 1542, whose ROM stock installs often lack,
7
+ // so drive 8 is a plain 1541;
8
+ // - the remote text monitor runs beside the binary one for the few
9
+ // operations only it offers; both act on the same machine;
10
+ // - autostart of a PRG needs `-autostartprgmode 1` (inject into RAM);
11
+ // - the joyport command reaches the C64 only through the "Joyport I/O
12
+ // simulation" device (37), whose lines start all pressed until set;
13
+ // - with the default JAM action a jammed CPU hangs silently; -jamaction 2
14
+ // enters the monitor instead;
15
+ // - the monitor's profiler prints numbers in the host locale, so VICE runs
16
+ // with LC_NUMERIC=C;
17
+ // - piped output is block-buffered and lost when VICE exits early (for
18
+ // example without its ROM files), so VICE also writes a log file;
19
+ // - `-console` runs VICE with no window and needs no display; the
20
+ // monitors, the CPU and the frame buffer work as with a window. It is a
21
+ // start option only, so headless and windowed are two VICE processes.
22
+ // A headless VICE plays no sound: `-sounddev dummy` keeps the SID emulated.
23
+
24
+ import { mkdtempSync, readFileSync, rmSync } from "node:fs";
25
+ import { createServer } from "node:net";
26
+ import { tmpdir } from "node:os";
27
+ import { join } from "node:path";
28
+ import { setTimeout as sleep } from "node:timers/promises";
29
+
30
+ import { WireFailure, type VideoStandard } from "../../protocol/messages.ts";
31
+ import { elapsedMs, trace } from "../../trace.ts";
32
+ import { findTool, isOlderThanMinimum, MIN_VICE, VICE } from "../../native/discover.ts";
33
+ import type { ExitStatus, ProcessSupervisor, SupervisedProcess } from "../../native/processes.ts";
34
+ import { BinaryMonitor, Command, decodeMemory, memoryGetBody } from "./binary-monitor.ts";
35
+ import { TextMonitor } from "./text-monitor.ts";
36
+
37
+ export const DEFAULT_READY_TIMEOUT_MS = 30_000;
38
+ const PING_TIMEOUT_MS = 5_000;
39
+ const RETRY_MS = 50;
40
+ const OUTPUT_TAIL_LINES = 40;
41
+ const STOP_REPORT_MS = 10_000;
42
+
43
+ /** Finds x64sc: C64RT_VICE, else PATH. Refuses by name with the remedy; never installs anything. */
44
+ export function findVice(env: NodeJS.ProcessEnv = process.env): string {
45
+ return findTool(VICE, env);
46
+ }
47
+
48
+ /** Asks the OS for a free loopback port. Another process can still take it before VICE binds. */
49
+ export function freePort(): Promise<number> {
50
+ return new Promise((resolve, reject) => {
51
+ const server = createServer();
52
+ server.once("error", reject);
53
+ server.listen(0, "127.0.0.1", () => {
54
+ const address = server.address();
55
+ server.close(() => {
56
+ if (address === null || typeof address === "string") reject(new Error("no TCP address"));
57
+ else resolve(address.port);
58
+ });
59
+ });
60
+ });
61
+ }
62
+
63
+ /** A VICE with no window (the default) or one with a window the user can use. */
64
+ export type ViceMode = "headless" | "window";
65
+
66
+ export function viceArguments(options: {
67
+ binary: string;
68
+ port: number;
69
+ textPort: number;
70
+ configFile: string;
71
+ logFile: string;
72
+ videoStandard: VideoStandard;
73
+ mode: ViceMode;
74
+ }): string[] {
75
+ const headless = options.mode === "headless" ? ["-console", "-sounddev", "dummy"] : [];
76
+ return [
77
+ options.binary,
78
+ "-default",
79
+ "-logfile",
80
+ options.logFile,
81
+ "-binarymonitor",
82
+ "-binarymonitoraddress",
83
+ `ip4://127.0.0.1:${options.port}`,
84
+ "-remotemonitor",
85
+ "-remotemonitoraddress",
86
+ `ip4://127.0.0.1:${options.textPort}`,
87
+ "-config",
88
+ options.configFile,
89
+ "-model",
90
+ options.videoStandard === "ntsc" ? "ntsc" : "c64",
91
+ "-drive8type",
92
+ "1541",
93
+ // Autostart injects a PRG into RAM; the default mode copies it to a disk image first.
94
+ "-autostartprgmode",
95
+ "1",
96
+ // The same RAM after every power-up: a hard reset is a power cycle, and VICE's default can flip
97
+ // random bits, so two runs from one start state could differ.
98
+ "-raminitstartvalue",
99
+ "0",
100
+ "-raminitvalueinvert",
101
+ "64",
102
+ "-raminitpatterninvert",
103
+ "0",
104
+ "-raminitstartrandom",
105
+ "0",
106
+ "-raminitrepeatrandom",
107
+ "0",
108
+ "-raminitrandomchance",
109
+ "0",
110
+ // A CPU JAM enters the monitor, so it stops the machine visibly instead of hanging it.
111
+ "-jamaction",
112
+ "2",
113
+ // The binary monitor's joyport command drives only the "Joyport I/O simulation" device.
114
+ "-controlport1device",
115
+ "37",
116
+ "-controlport2device",
117
+ "37",
118
+ ...headless,
119
+ ];
120
+ }
121
+
122
+ export interface LaunchOptions {
123
+ videoStandard: VideoStandard;
124
+ /** Defaults to headless. */
125
+ mode?: ViceMode;
126
+ supervisor: ProcessSupervisor;
127
+ /** Environment to find VICE in and to run it with. Defaults to process.env. */
128
+ env?: NodeJS.ProcessEnv;
129
+ readyTimeoutMs?: number;
130
+ log?: (line: string) => void;
131
+ }
132
+
133
+ /** A running, ready VICE with its binary monitor connected. */
134
+ export interface ViceProcess {
135
+ /** Host-internal; never leaves the Host Runtime. */
136
+ readonly pid: number;
137
+ readonly monitor: BinaryMonitor;
138
+ readonly text: TextMonitor;
139
+ /** Settles when VICE exits, for any reason. */
140
+ readonly exited: Promise<ExitStatus>;
141
+ /** The last lines VICE printed, for host logs only. */
142
+ outputTail(): string;
143
+ /** Closes the monitor, stops the process group and removes the scratch directory. Idempotent. */
144
+ stop(): Promise<void>;
145
+ }
146
+
147
+ /** The last lines of VICE's log file, or of its output while there is no log file. */
148
+ function tailCollector(child: SupervisedProcess, logFile: string): () => string {
149
+ const lines: string[] = [];
150
+ let partial = "";
151
+ const take = (chunk: Buffer) => {
152
+ const parts = (partial + chunk.toString("utf8")).split("\n");
153
+ partial = parts.pop() ?? "";
154
+ lines.push(...parts);
155
+ if (lines.length > OUTPUT_TAIL_LINES) lines.splice(0, lines.length - OUTPUT_TAIL_LINES);
156
+ };
157
+ child.child.stdout?.on("data", take);
158
+ child.child.stderr?.on("data", take);
159
+ return () => {
160
+ try {
161
+ return readFileSync(logFile, "utf8").split("\n").filter((line) => line !== "").slice(-OUTPUT_TAIL_LINES).join("\n");
162
+ } catch {
163
+ return [...lines, partial].filter((line) => line !== "").join("\n");
164
+ }
165
+ };
166
+ }
167
+
168
+ /**
169
+ * Launches one VICE in its own scratch directory outside the project, waits
170
+ * until its binary monitor answers, then lets the machine run.
171
+ * Throws WireFailure: installation-incomplete when VICE is missing,
172
+ * machine-unavailable when it does not start.
173
+ */
174
+ export async function launchVice(options: LaunchOptions): Promise<ViceProcess> {
175
+ // A failed launch is tried again with a fresh VICE: VICE exits, another
176
+ // process takes a monitor port between the free-port check and VICE binding
177
+ // it, or (on macOS, with a window) VICE starts but never binds its monitor,
178
+ // while the next VICE starts at once. A missing installation is final.
179
+ let lastError: unknown;
180
+ for (let attempt = 1; attempt <= LAUNCH_ATTEMPTS; attempt++) {
181
+ try {
182
+ return await launchOnce(options);
183
+ } catch (error) {
184
+ lastError = error;
185
+ if (error instanceof WireFailure && error.code === "installation-incomplete") throw error;
186
+ options.log?.(`VICE launch attempt ${attempt} failed: ${error instanceof Error ? error.message : String(error)}`);
187
+ }
188
+ }
189
+ if (lastError instanceof WireFailure) throw lastError;
190
+ throw new WireFailure("machine-unavailable", "The emulator could not be started on the host.");
191
+ }
192
+
193
+ const LAUNCH_ATTEMPTS = 3;
194
+
195
+ /** VICE's own error lines from the log lines of failed launches, without repeats. */
196
+ export function startErrorLines(output: string[]): string[] {
197
+ return [...new Set(output.flatMap((entry) => entry.split("\n")).filter((line) => /\berror\b/i.test(line) && !line.startsWith("VICE ")))];
198
+ }
199
+
200
+ /**
201
+ * Starts VICE once, as a session does, and stops it again. Finding
202
+ * x64sc is not enough: without its ROM files VICE exits at once. Throws
203
+ * WireFailure installation-incomplete with VICE's own error lines.
204
+ */
205
+ export async function checkViceStarts(options: Omit<LaunchOptions, "videoStandard" | "log">): Promise<void> {
206
+ const binary = findVice(options.env ?? process.env);
207
+ const output: string[] = [];
208
+ let vice: ViceProcess;
209
+ try {
210
+ vice = await launchVice({ ...options, videoStandard: "pal", log: (line) => output.push(line) });
211
+ } catch (error) {
212
+ if (error instanceof WireFailure && error.code === "installation-incomplete") throw error;
213
+ const reasons = startErrorLines(output);
214
+ throw new WireFailure(
215
+ "installation-incomplete",
216
+ `VICE (${binary}) is installed but does not start${reasons.length === 0 ? "." : `:\n ${reasons.join("\n ")}`}\n` +
217
+ "Make sure that VICE has its ROM files (the C64 KERNAL, BASIC and character ROMs) and that x64sc starts when you run it by hand, " +
218
+ "then restart the Host Runtime.",
219
+ );
220
+ }
221
+ // VICE_INFO: the length of the main version (4), then major, minor, build and revision.
222
+ let info: Buffer;
223
+ try {
224
+ info = (await vice.monitor.request(Command.viceInfo)).body;
225
+ } catch (error) {
226
+ throw new WireFailure(
227
+ "installation-incomplete",
228
+ `VICE (${binary}) starts, but it did not tell its version (${error instanceof Error ? error.message : String(error)}). ` +
229
+ `c64-re-tools needs VICE ${MIN_VICE.major}.${MIN_VICE.minor} or later. ${VICE.remedy}`,
230
+ );
231
+ } finally {
232
+ await vice.stop();
233
+ }
234
+ const version = { major: info[1] ?? 0, minor: info[2] ?? 0 };
235
+ if (isOlderThanMinimum(version)) {
236
+ throw new WireFailure(
237
+ "installation-incomplete",
238
+ `VICE (${binary}) is version ${version.major}.${version.minor}, and c64-re-tools needs VICE ${MIN_VICE.major}.${MIN_VICE.minor} or later. ${VICE.remedy}`,
239
+ );
240
+ }
241
+ }
242
+
243
+ async function launchOnce(options: LaunchOptions): Promise<ViceProcess> {
244
+ const env = options.env ?? process.env;
245
+ const log = options.log ?? (() => {});
246
+ const binary = findVice(env);
247
+ const port = await freePort();
248
+ const textPort = await freePort();
249
+ const scratch = mkdtempSync(join(tmpdir(), "c64-re-tools-vice-"));
250
+ // Owned by the supervisor: its exit guard and the watchdog remove it if this process dies.
251
+ const releaseScratch = options.supervisor.ownPath(scratch);
252
+ const mode = options.mode ?? "headless";
253
+ const argv = viceArguments({ binary, port, textPort, configFile: join(scratch, "vicerc"), logFile: join(scratch, "vice.log"), videoStandard: options.videoStandard, mode });
254
+ const child = options.supervisor.spawn(argv, {
255
+ cwd: scratch,
256
+ // Keep VICE's config, cache and state away from the user's own VICE setup, and
257
+ // make its monitor print numbers the same way on every host.
258
+ env: { ...env, XDG_CONFIG_HOME: scratch, XDG_CACHE_HOME: scratch, XDG_STATE_HOME: scratch, LC_NUMERIC: "C" },
259
+ stdio: ["ignore", "pipe", "pipe"],
260
+ });
261
+ const launched = performance.now();
262
+ trace().event("vice.launch", { pid: child.pid, mode, videoStandard: options.videoStandard, ports: [port, textPort], scratch, argv });
263
+ const outputTail = tailCollector(child, join(scratch, "vice.log"));
264
+ let exited = false;
265
+ void child.exited.then((status) => {
266
+ exited = true;
267
+ trace().event("vice.exit", { pid: child.pid, ...status, ms: elapsedMs(launched) });
268
+ });
269
+
270
+ let monitor: BinaryMonitor | undefined;
271
+ let text: TextMonitor | undefined;
272
+ let stopping: Promise<void> | undefined;
273
+ const stop = () => {
274
+ stopping ??= (async () => {
275
+ // A stop that takes long says what it waits for, in the host log.
276
+ let step = "closing the binary monitor";
277
+ const started = Date.now();
278
+ const slow = setInterval(() => log(`VICE (pid ${child.pid}) is not stopped after ${Math.round((Date.now() - started) / 1000)} s: ${step}`), STOP_REPORT_MS);
279
+ slow.unref();
280
+ // Every step runs, also after one fails; the first failure is reported at the end.
281
+ let failure: unknown;
282
+ const run = async (name: string, work: () => Promise<void> | void) => {
283
+ step = name;
284
+ try {
285
+ await work();
286
+ } catch (error) {
287
+ failure ??= error;
288
+ }
289
+ };
290
+ try {
291
+ await run("closing the binary monitor", () => monitor?.close());
292
+ await run("closing the text monitor", () => text?.close());
293
+ await run("ending the process", () => child.stop());
294
+ // The supervisor keeps the directory on its list, to remove it at exit, until the removal works.
295
+ await run("removing its scratch directory", () => {
296
+ // VICE's own log goes with the directory; the trace keeps a copy.
297
+ trace().keep(join(scratch, "vice.log"), `vice-${child.pid}.log`);
298
+ rmSync(scratch, { recursive: true, force: true });
299
+ releaseScratch();
300
+ });
301
+ } finally {
302
+ clearInterval(slow);
303
+ }
304
+ if (failure !== undefined) throw failure;
305
+ })();
306
+ return stopping;
307
+ };
308
+
309
+ try {
310
+ const deadline = Date.now() + (options.readyTimeoutMs ?? DEFAULT_READY_TIMEOUT_MS);
311
+ monitor = await waitForMonitor(port, () => exited, deadline);
312
+ text = await waitForText(textPort, () => exited, deadline);
313
+ await checkSameMachine(monitor, text);
314
+ // Readiness stopped the machine; let it run as a freshly started C64 does.
315
+ await monitor.request(Command.exit);
316
+ } catch (error) {
317
+ log(`VICE failed to start: ${error instanceof Error ? error.message : String(error)}\n${outputTail()}`);
318
+ trace().event("vice.launch-failed", { pid: child.pid, ms: elapsedMs(launched), error });
319
+ await stop();
320
+ if (error instanceof WireFailure) throw error;
321
+ throw new WireFailure("machine-unavailable", "The emulator could not be started on the host.");
322
+ }
323
+ log(`VICE started (pid ${child.pid}, monitor ports ${port} and ${textPort})`);
324
+ trace().event("vice.ready", { pid: child.pid, ms: elapsedMs(launched) });
325
+ return { pid: child.pid, monitor, text, exited: child.exited, outputTail, stop };
326
+ }
327
+
328
+ /**
329
+ * Connects the text monitor and proves it answers. Like the binary one, VICE
330
+ * can accept a connection during startup that it then never serves, so a
331
+ * connection that does not answer a probe is dropped and made again.
332
+ */
333
+ async function waitForText(port: number, hasExited: () => boolean, deadline: number): Promise<TextMonitor> {
334
+ while (Date.now() < deadline) {
335
+ if (hasExited()) throw new WireFailure("machine-unavailable", "The emulator exited while it was starting.");
336
+ let text: TextMonitor;
337
+ try {
338
+ text = await TextMonitor.connect(port);
339
+ } catch {
340
+ await sleep(RETRY_MS);
341
+ continue;
342
+ }
343
+ try {
344
+ await text.command("dev c:", Math.min(TEXT_PROBE_MS, Math.max(1, deadline - Date.now())));
345
+ return text;
346
+ } catch {
347
+ await text.close();
348
+ await sleep(RETRY_MS);
349
+ }
350
+ }
351
+ throw new WireFailure("machine-unavailable", "The emulator's monitor did not become ready in time.");
352
+ }
353
+
354
+ const TEXT_PROBE_MS = 2_000;
355
+
356
+ /**
357
+ * Proves both monitor connections reach the same, freshly started VICE: a
358
+ * byte written through the binary monitor must read back through the text
359
+ * monitor. Zero-page $02 is unused by the KERNAL; its value is restored.
360
+ */
361
+ async function checkSameMachine(monitor: BinaryMonitor, text: TextMonitor): Promise<void> {
362
+ const read = memoryGetBody({ start: 0x0002, end: 0x0002, memspace: 0, bank: 0 });
363
+ const original = decodeMemory((await monitor.request(Command.memoryGet, read)).body)[0]!;
364
+ const marker = (original ^ 0xa5) & 0xff;
365
+ const write = (value: number) => {
366
+ const body = memoryGetBody({ start: 0x0002, end: 0x0002, memspace: 0, bank: 0 });
367
+ return monitor.request(Command.memorySet, Buffer.concat([body, Buffer.from([value])]));
368
+ };
369
+ await write(marker);
370
+ try {
371
+ const answer = await text.command("m 0002 0002", TEXT_PROBE_MS);
372
+ const seen = /^>C:0002\s+([0-9a-f]{2})/im.exec(answer);
373
+ if (seen === null || Number.parseInt(seen[1]!, 16) !== marker) {
374
+ throw new WireFailure("machine-unavailable", "The emulator's two monitor connections reached different machines.");
375
+ }
376
+ } finally {
377
+ await write(original);
378
+ }
379
+ }
380
+
381
+ /** Connects the binary monitor and proves it answers a ping, until `deadline`. */
382
+ async function waitForMonitor(port: number, hasExited: () => boolean, deadline: number): Promise<BinaryMonitor> {
383
+ while (Date.now() < deadline) {
384
+ if (hasExited()) throw new WireFailure("machine-unavailable", "The emulator exited while it was starting.");
385
+ let monitor: BinaryMonitor;
386
+ try {
387
+ monitor = await BinaryMonitor.connect(port);
388
+ } catch {
389
+ await sleep(RETRY_MS);
390
+ continue;
391
+ }
392
+ try {
393
+ await monitor.request(Command.ping, undefined, Math.min(PING_TIMEOUT_MS, Math.max(1, deadline - Date.now())));
394
+ return monitor;
395
+ } catch {
396
+ // VICE can drop a connection it accepted during startup; connect again.
397
+ await monitor.close();
398
+ await sleep(RETRY_MS);
399
+ }
400
+ }
401
+ throw new WireFailure("machine-unavailable", "The emulator did not become ready in time.");
402
+ }
@@ -0,0 +1,183 @@
1
+ // Canonical emulator-frame capture: VICE's own rendered VIC-II frame, never a
2
+ // desktop screenshot; the frame comparison and difference image for baselines.
3
+
4
+ import { crc32, deflateSync } from "node:zlib";
5
+
6
+ import type { VideoStandard } from "../../protocol/messages.ts";
7
+
8
+ export interface IndexedFrame {
9
+ width: number;
10
+ height: number;
11
+ /** One palette index per pixel, row by row. */
12
+ pixels: Uint8Array;
13
+ }
14
+
15
+ export interface RawDisplay {
16
+ /** Full buffer size, blanking included. */
17
+ bufferWidth: number;
18
+ bufferHeight: number;
19
+ /** Where the 320×200 display window starts, as VICE reports it. */
20
+ innerX: number;
21
+ innerY: number;
22
+ pixels: Uint8Array;
23
+ }
24
+
25
+ /** Decodes a binary-monitor display-get body (8 bits per pixel). The pixels may be short; see visibleFrame. */
26
+ export function decodeDisplay(body: Buffer): RawDisplay {
27
+ const headerLength = body.readUInt32LE(0);
28
+ const bits = body[16];
29
+ if (bits !== 8) throw new Error(`VICE sent ${bits} bits per pixel, expected 8`);
30
+ // The buffer length follows the header fields; the pixels follow it.
31
+ const length = body.readUInt32LE(4 + headerLength);
32
+ const start = 4 + headerLength + 4;
33
+ return {
34
+ bufferWidth: body.readUInt16LE(4),
35
+ bufferHeight: body.readUInt16LE(6),
36
+ innerX: body.readUInt16LE(8),
37
+ innerY: body.readUInt16LE(10),
38
+ pixels: body.subarray(start, start + length),
39
+ };
40
+ }
41
+
42
+ /**
43
+ * The visible frame with normal borders, relative to VICE's display-window
44
+ * offsets: PAL 384×272, NTSC 384×247.
45
+ */
46
+ const VISIBLE: Record<VideoStandard, { dx: number; dy: number; width: number; height: number }> = {
47
+ pal: { dx: -32, dy: -36, width: 384, height: 272 },
48
+ ntsc: { dx: -32, dy: -23, width: 384, height: 247 },
49
+ };
50
+
51
+ /** Crops the visible bordered frame out of VICE's full raster buffer. */
52
+ export function visibleFrame(display: RawDisplay, standard: VideoStandard): IndexedFrame {
53
+ const area = VISIBLE[standard];
54
+ const left = display.innerX + area.dx;
55
+ const top = display.innerY + area.dy;
56
+ if (left < 0 || top < 0 || left + area.width > display.bufferWidth || top + area.height > display.bufferHeight) {
57
+ throw new Error(`the ${display.bufferWidth}x${display.bufferHeight} display buffer does not hold the visible ${standard} frame`);
58
+ }
59
+ // VICE sends 4 bytes fewer than its length field says; they are
60
+ // the last ones, in the blanking area, so only the cropped area must be present.
61
+ if (display.pixels.length < (top + area.height - 1) * display.bufferWidth + left + area.width) {
62
+ throw new Error("the display buffer is short");
63
+ }
64
+ const pixels = new Uint8Array(area.width * area.height);
65
+ for (let row = 0; row < area.height; row++) {
66
+ const from = (top + row) * display.bufferWidth + left;
67
+ pixels.set(display.pixels.subarray(from, from + area.width), row * area.width);
68
+ }
69
+ return { width: area.width, height: area.height, pixels };
70
+ }
71
+
72
+ /** Decodes a binary-monitor palette-get body into RGB triples. */
73
+ export function decodePalette(body: Buffer): Array<[number, number, number]> {
74
+ const count = body.readUInt16LE(0);
75
+ const colours: Array<[number, number, number]> = [];
76
+ let offset = 2;
77
+ for (let index = 0; index < count; index++) {
78
+ const size = body[offset]!;
79
+ colours.push([body[offset + 1]!, body[offset + 2]!, body[offset + 3]!]);
80
+ offset += size + 1;
81
+ }
82
+ return colours;
83
+ }
84
+
85
+ function chunk(type: string, data: Uint8Array): Buffer {
86
+ const length = Buffer.alloc(4);
87
+ length.writeUInt32BE(data.length, 0);
88
+ const body = Buffer.concat([Buffer.from(type, "latin1"), data]);
89
+ const crc = Buffer.alloc(4);
90
+ crc.writeUInt32BE(crc32(body), 0);
91
+ return Buffer.concat([length, body, crc]);
92
+ }
93
+
94
+ /** Encodes an indexed frame as an 8-bit palette PNG. */
95
+ export function encodePng(frame: IndexedFrame, palette: ReadonlyArray<readonly [number, number, number]>): Buffer {
96
+ for (const index of frame.pixels) {
97
+ if (index >= palette.length) throw new Error(`pixel colour ${index} is not in the ${palette.length}-colour palette`);
98
+ }
99
+ const header = Buffer.alloc(13);
100
+ header.writeUInt32BE(frame.width, 0);
101
+ header.writeUInt32BE(frame.height, 4);
102
+ header[8] = 8; // bit depth
103
+ header[9] = 3; // indexed colour
104
+ const rows = Buffer.alloc((frame.width + 1) * frame.height);
105
+ for (let row = 0; row < frame.height; row++) {
106
+ // Filter byte 0 (none), then the row.
107
+ rows.set(frame.pixels.subarray(row * frame.width, (row + 1) * frame.width), row * (frame.width + 1) + 1);
108
+ }
109
+ return Buffer.concat([
110
+ Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
111
+ chunk("IHDR", header),
112
+ chunk("PLTE", Buffer.from(palette.flat())),
113
+ chunk("IDAT", deflateSync(rows)),
114
+ chunk("IEND", new Uint8Array(0)),
115
+ ]);
116
+ }
117
+
118
+ export interface FrameDifference {
119
+ mismatchingPixels: number;
120
+ /** Pixels compared: the frame minus the masked ones. */
121
+ comparedPixels: number;
122
+ /** The smallest rectangle holding every mismatch; absent when nothing differs. */
123
+ bounds?: { x: number; y: number; width: number; height: number };
124
+ /** One flag per pixel: 1 mismatch, 2 masked, 0 equal. */
125
+ map: Uint8Array;
126
+ }
127
+
128
+ /** Compares two frames of the same size pixel by pixel, outside the mask rectangles. */
129
+ export function compareFrames(
130
+ baseline: IndexedFrame,
131
+ current: IndexedFrame,
132
+ mask: ReadonlyArray<{ x: number; y: number; width: number; height: number }>,
133
+ ): FrameDifference {
134
+ if (baseline.width !== current.width || baseline.height !== current.height) {
135
+ throw new Error(`frame sizes differ: ${baseline.width}x${baseline.height} and ${current.width}x${current.height}`);
136
+ }
137
+ const { width, height } = current;
138
+ const map = new Uint8Array(width * height);
139
+ for (const rectangle of mask) {
140
+ for (let y = rectangle.y; y < Math.min(height, rectangle.y + rectangle.height); y++) {
141
+ map.fill(2, y * width + rectangle.x, y * width + Math.min(width, rectangle.x + rectangle.width));
142
+ }
143
+ }
144
+ let mismatchingPixels = 0;
145
+ let comparedPixels = 0;
146
+ let left = width;
147
+ let top = height;
148
+ let right = -1;
149
+ let bottom = -1;
150
+ for (let y = 0; y < height; y++) {
151
+ for (let x = 0; x < width; x++) {
152
+ const at = y * width + x;
153
+ if (map[at] === 2) continue;
154
+ comparedPixels++;
155
+ if (baseline.pixels[at] === current.pixels[at]) continue;
156
+ map[at] = 1;
157
+ mismatchingPixels++;
158
+ left = Math.min(left, x);
159
+ top = Math.min(top, y);
160
+ right = Math.max(right, x);
161
+ bottom = Math.max(bottom, y);
162
+ }
163
+ }
164
+ const difference: FrameDifference = { mismatchingPixels, comparedPixels, map };
165
+ if (mismatchingPixels > 0) difference.bounds = { x: left, y: top, width: right - left + 1, height: bottom - top + 1 };
166
+ return difference;
167
+ }
168
+
169
+ /**
170
+ * The difference image: the current frame with equal and masked pixels at a
171
+ * quarter of their brightness and every mismatch in bright magenta.
172
+ */
173
+ export function differenceImage(
174
+ current: IndexedFrame,
175
+ difference: FrameDifference,
176
+ palette: ReadonlyArray<readonly [number, number, number]>,
177
+ ): { frame: IndexedFrame; palette: Array<[number, number, number]> } {
178
+ const dimmed = palette.map(([r, g, b]) => [r >> 2, g >> 2, b >> 2] as [number, number, number]);
179
+ const highlight = dimmed.length;
180
+ const pixels = new Uint8Array(current.pixels.length);
181
+ for (let at = 0; at < pixels.length; at++) pixels[at] = difference.map[at] === 1 ? highlight : current.pixels[at]!;
182
+ return { frame: { width: current.width, height: current.height, pixels }, palette: [...dimmed, [255, 0, 255]] };
183
+ }