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

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 (162) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +154 -37
  3. package/distribution/package.ts +21 -0
  4. package/distribution/plugin.ts +122 -0
  5. package/package.json +34 -40
  6. package/skills/c64-assembler/SKILL.md +76 -0
  7. package/skills/c64-assembler/scripts/assemble.ts +110 -0
  8. package/skills/c64-assembler/scripts/compare.ts +84 -0
  9. package/skills/c64-basic/SKILL.md +66 -0
  10. package/skills/c64-basic/scripts/basic.ts +46 -0
  11. package/skills/c64-disk/SKILL.md +93 -0
  12. package/skills/c64-disk/scripts/containers.ts +124 -0
  13. package/skills/c64-disk/scripts/disk.ts +120 -0
  14. package/skills/c64-emulator/SKILL.md +134 -0
  15. package/skills/c64-knowledge/SKILL.md +192 -0
  16. package/skills/c64-knowledge/scripts/knowledge.ts +613 -0
  17. package/skills/c64-memory-map/SKILL.md +79 -0
  18. package/skills/c64-memory-map/scripts/memmap.ts +92 -0
  19. package/skills/c64-provenance/SKILL.md +110 -0
  20. package/skills/c64-provenance/scripts/compare.ts +152 -0
  21. package/skills/c64-provenance/scripts/provenance.ts +334 -0
  22. package/skills/c64-provenance/scripts/runtime.ts +191 -0
  23. package/skills/c64-provenance/scripts/separate.ts +140 -0
  24. package/skills/c64-reverse-engineering/SKILL.md +144 -0
  25. package/skills/c64-source/SKILL.md +133 -0
  26. package/skills/c64-source/scripts/generate.ts +503 -0
  27. package/skills/c64-source/scripts/loader.ts +589 -0
  28. package/skills/c64-source/scripts/opcodes.ts +61 -0
  29. package/skills/c64-source/scripts/source.ts +673 -0
  30. package/skills/c64-static-analysis/SKILL.md +120 -0
  31. package/skills/c64-static-analysis/scripts/analyze.ts +293 -0
  32. package/skills/c64-static-analysis/scripts/coverage.ts +78 -0
  33. package/skills/c64-static-analysis/scripts/findings.ts +75 -0
  34. package/skills/c64-static-analysis/scripts/idioms.ts +149 -0
  35. package/skills/c64-static-analysis/scripts/instructions.ts +105 -0
  36. package/skills/c64-static-analysis/scripts/pointers.ts +108 -0
  37. package/skills/c64-testing/SKILL.md +103 -0
  38. package/skills/c64-testing/references/scenario-format.md +116 -0
  39. package/skills/c64-testing/scripts/checklist.ts +51 -0
  40. package/skills/c64-testing/scripts/run.ts +189 -0
  41. package/skills/c64-testing/scripts/scenario.ts +493 -0
  42. package/skills/c64-testing/scripts/test.ts +65 -0
  43. package/skills/c64-unpacker/SKILL.md +135 -0
  44. package/skills/c64-unpacker/scripts/evidence.ts +207 -0
  45. package/skills/c64-unpacker/scripts/loads.ts +209 -0
  46. package/skills/c64-unpacker/scripts/machine.ts +80 -0
  47. package/skills/c64-unpacker/scripts/stages.ts +130 -0
  48. package/skills/c64-unpacker/scripts/unpack.ts +683 -0
  49. package/src/c64.ts +411 -0
  50. package/src/cli/ap-sdk.ts +44 -0
  51. package/src/cli/cleanup.ts +194 -0
  52. package/src/cli/main.ts +125 -0
  53. package/src/cli/status.ts +61 -0
  54. package/src/host/main.ts +134 -0
  55. package/src/host/server.ts +392 -0
  56. package/src/host/tools/c1541.ts +284 -0
  57. package/src/host/tools/index.ts +35 -0
  58. package/src/host/tools/petcat.ts +77 -0
  59. package/src/host/tools/status.ts +47 -0
  60. package/src/host/tools/version.ts +41 -0
  61. package/src/host/vice/adapter.ts +804 -0
  62. package/src/host/vice/binary-monitor.ts +389 -0
  63. package/src/host/vice/chips.ts +118 -0
  64. package/src/host/vice/connect.ts +34 -0
  65. package/src/host/vice/process.ts +413 -0
  66. package/src/host/vice/screen.ts +184 -0
  67. package/src/host/vice/session.ts +1426 -0
  68. package/src/host/vice/text-monitor.ts +223 -0
  69. package/src/host-client/autostart.ts +55 -0
  70. package/src/host-client/connect.ts +241 -0
  71. package/src/host-client/coverage.ts +63 -0
  72. package/src/host-client/image.ts +63 -0
  73. package/src/host-client/steps.ts +170 -0
  74. package/src/host-client/tools.ts +89 -0
  75. package/src/host-client/transfer.ts +126 -0
  76. package/src/host-client/vice-session.ts +316 -0
  77. package/src/knowledge/database.ts +132 -0
  78. package/src/knowledge/expression.ts +209 -0
  79. package/src/knowledge/history.ts +169 -0
  80. package/src/knowledge/import.ts +392 -0
  81. package/src/knowledge/read.ts +392 -0
  82. package/src/knowledge/schema.ts +250 -0
  83. package/src/knowledge/write.ts +837 -0
  84. package/src/mcp/main.ts +134 -0
  85. package/src/mcp/output.ts +32 -0
  86. package/src/mcp/server.ts +316 -0
  87. package/src/mcp/tools/debug.ts +327 -0
  88. package/src/mcp/tools/execution.ts +117 -0
  89. package/src/mcp/tools/input.ts +72 -0
  90. package/src/mcp/tools/machine.ts +100 -0
  91. package/src/mcp/tools/media.ts +129 -0
  92. package/src/mcp/tools/memory.ts +250 -0
  93. package/src/mcp/tools/video.ts +271 -0
  94. package/src/native/acme.ts +116 -0
  95. package/src/native/discover.ts +179 -0
  96. package/src/native/dxa.ts +166 -0
  97. package/src/native/ghidra/analyze.ts +86 -0
  98. package/src/native/ghidra/index.ts +173 -0
  99. package/src/native/ghidra/language/c64rt_6510.cspec +34 -0
  100. package/src/native/ghidra/language/c64rt_6510.ldefs +15 -0
  101. package/src/native/ghidra/language/c64rt_6510.pspec +15 -0
  102. package/src/native/ghidra/language/c64rt_6510.slaspec +596 -0
  103. package/src/native/ghidra/scripts/C64Export.java +274 -0
  104. package/src/native/ghidra/scripts/C64Prepare.java +60 -0
  105. package/src/native/local.ts +34 -0
  106. package/src/native/processes.ts +350 -0
  107. package/src/native/run.ts +172 -0
  108. package/src/native/staging.ts +83 -0
  109. package/src/native/status.ts +113 -0
  110. package/src/native/types.ts +257 -0
  111. package/src/native/watchdog.ts +78 -0
  112. package/src/package.ts +21 -0
  113. package/src/platform.ts +441 -0
  114. package/src/project.ts +39 -0
  115. package/src/protocol/framing.ts +132 -0
  116. package/src/protocol/messages.ts +259 -0
  117. package/src/protocol/tools.ts +266 -0
  118. package/src/protocol/vice.ts +1348 -0
  119. package/src/script.ts +60 -0
  120. package/src/time.ts +15 -0
  121. package/src/trace.ts +194 -0
  122. package/THIRD-PARTY-NOTICES.md +0 -26
  123. package/bin/cli.mjs +0 -283
  124. package/skills/acme-build/SKILL.md +0 -260
  125. package/skills/acme-build/scripts/acme.mjs +0 -341
  126. package/skills/acme-build/template.a +0 -53
  127. package/skills/c64-disk-access/SKILL.md +0 -159
  128. package/skills/c64-disk-access/scripts/c1541.mjs +0 -569
  129. package/skills/c64-memory-mapping/SKILL.md +0 -631
  130. package/skills/c64-memory-mapping/memmap.json +0 -8800
  131. package/skills/c64-memory-mapping/scripts/driver.mjs +0 -553
  132. package/skills/c64-petcat/SKILL.md +0 -87
  133. package/skills/c64-petcat/scripts/petcat.mjs +0 -221
  134. package/skills/c64-program-recon/SKILL.md +0 -706
  135. package/skills/c64-program-recon/references/control-flow.md +0 -180
  136. package/skills/c64-program-recon/references/graphics.md +0 -79
  137. package/skills/c64-program-recon/references/observation-hazards.md +0 -143
  138. package/skills/c64-program-recon/references/reconstruction.md +0 -133
  139. package/skills/c64-program-recon/references/sound-and-input.md +0 -74
  140. package/skills/c64-program-recon/references/tool-selection.md +0 -78
  141. package/skills/c64-program-recon/scripts/derive.mjs +0 -364
  142. package/skills/c64-program-recon/scripts/packer-finding.mjs +0 -709
  143. package/skills/c64-program-recon/templates/memory-map.template.md +0 -101
  144. package/skills/c64-provenance-diff/SKILL.md +0 -292
  145. package/skills/c64-provenance-diff/scripts/diff-images.mjs +0 -984
  146. package/skills/c64-provenance-diff/scripts/recovery-schema.mjs +0 -395
  147. package/skills/c64-ram-capture/RELEASES.json.example +0 -17
  148. package/skills/c64-ram-capture/SKILL.md +0 -474
  149. package/skills/c64-ram-capture/scripts/compare-cross-binary.mjs +0 -525
  150. package/skills/c64-ram-capture/scripts/compare.mjs +0 -258
  151. package/skills/c64-ram-capture/scripts/derive-transients.mjs +0 -575
  152. package/skills/c64-ram-capture/scripts/dump-artifacts.mjs +0 -317
  153. package/skills/c64-ram-capture/scripts/mcp-module.mjs +0 -174
  154. package/skills/c64-ram-capture/scripts/project-paths.mjs +0 -81
  155. package/skills/c64-ram-capture/scripts/releases.mjs +0 -109
  156. package/skills/c64-ram-capture/scripts/vsf-slice.mjs +0 -147
  157. package/skills/c64-ram-capture/scripts/watch-loads.mjs +0 -581
  158. package/skills/c64-ram-capture/templates/capture-record.template.md +0 -99
  159. package/skills/c64-ram-capture/transients/README.md +0 -137
  160. package/skills/routine-queue-walker/SKILL.md +0 -394
  161. package/skills/routine-queue-walker/scripts/completeness-report.mjs +0 -465
  162. package/skills/vice-wedge-triage/SKILL.md +0 -269
@@ -0,0 +1,392 @@
1
+ import { createServer, type Server, type Socket } from "node:net";
2
+
3
+ import { encodeFrame, MessageReader } from "../protocol/framing.ts";
4
+ import { checkHello, isOneOf, HEARTBEAT_TIMEOUT_MS, parseClientMessage, ProtocolError, type HostMessage, type ReceivedRequest, type VideoStandard, type WireError, WireFailure } from "../protocol/messages.ts";
5
+ import { TOOL_OPERATIONS, validateToolParams, type ToolOperation, type ToolOperations } from "../protocol/tools.ts";
6
+ import { attachmentCount, VICE_OPERATIONS, validateViceParams, type ViceOperation, type ViceOperations } from "../protocol/vice.ts";
7
+ import { elapsedMs, summarize, trace } from "../trace.ts";
8
+
9
+ /** One live VICE session, owned by one connection. */
10
+ export interface ViceSessionHandle {
11
+ /**
12
+ * Runs one operation. The session serializes its own work. Throws
13
+ * WireFailure for domain failures. `attachments` holds the bytes the
14
+ * operation takes (see attachmentCount); bytes the operation sends back go
15
+ * into `reply`.
16
+ */
17
+ handle<O extends ViceOperation>(op: O, params: ViceOperations[O]["params"], attachments?: Buffer[], reply?: Buffer[]): Promise<ViceOperations[O]["result"]>;
18
+ /** Stops the emulator and drops queued work. Safe to call more than once. */
19
+ close(): Promise<void>;
20
+ }
21
+
22
+ /** Starts a VICE session. Throws WireFailure with an actionable code when it cannot. */
23
+ export type ViceSessionFactory = (options: { videoStandard: VideoStandard; log?: (line: string) => void }) => Promise<ViceSessionHandle>;
24
+
25
+ /**
26
+ * Runs one native-tool request. `signal` aborts when the client goes away; the
27
+ * tool must then stop its children and remove its workspace. Throws WireFailure.
28
+ */
29
+ export type ToolDispatcher = <O extends ToolOperation>(
30
+ op: O,
31
+ params: ToolOperations[O]["params"],
32
+ attachments: Buffer[],
33
+ signal: AbortSignal,
34
+ ) => Promise<{ result: ToolOperations[O]["result"]; attachments?: Buffer[] }>;
35
+
36
+ export interface HostServerOptions {
37
+ createViceSession: ViceSessionFactory;
38
+ /** Native-tool requests on "tool" connections; without it they are refused. */
39
+ tools?: ToolDispatcher;
40
+ /** Defaults to 127.0.0.1; 0.0.0.0 listens on every address, so a container reaches it too. */
41
+ host?: string;
42
+ /** 0 picks a free port. */
43
+ port: number;
44
+ /** How long a new connection may stay silent before it must send hello. */
45
+ handshakeTimeoutMs?: number;
46
+ /** How long a ready connection may send nothing (no request, no ping) before it is closed with its session. */
47
+ heartbeatTimeoutMs?: number;
48
+ log?: (line: string) => void;
49
+ }
50
+
51
+ export interface HostServer {
52
+ readonly host: string;
53
+ readonly port: number;
54
+ /** Stops listening, closes every connection and stops every session. */
55
+ close(): Promise<void>;
56
+ }
57
+
58
+ const DEFAULT_HANDSHAKE_TIMEOUT_MS = 10_000;
59
+ /** How long a refused client may keep its side of the connection open after the host has ended its side. */
60
+ const REFUSED_CLOSE_MS = 1_000;
61
+
62
+ /** A caught value for the log, with its stack when it has one. */
63
+ function describe(error: unknown): string {
64
+ return error instanceof Error ? (error.stack ?? error.message) : String(error);
65
+ }
66
+
67
+ function toWireError(error: unknown, fallback: WireError): WireError {
68
+ return error instanceof WireFailure ? error.toWire() : fallback;
69
+ }
70
+
71
+ /** Numbers the connections of this process, for the trace. */
72
+ let connectionCount = 0;
73
+
74
+ /** Owns one client connection: handshake, then requests against at most one VICE session. */
75
+ class Connection {
76
+ readonly closed: Promise<void>;
77
+ readonly #id = ++connectionCount;
78
+ #state: "handshake" | "starting" | "open" | "closed" = "handshake";
79
+ #session: ViceSessionHandle | undefined;
80
+ #starting: Promise<ViceSessionHandle | undefined> | undefined;
81
+ #shuttingDown = false;
82
+ readonly #toolAbort = new AbortController();
83
+ #isTool = false;
84
+ readonly #reader = new MessageReader();
85
+
86
+ readonly #socket: Socket;
87
+ readonly #options: HostServerOptions;
88
+ readonly #log: (line: string) => void;
89
+ readonly #handshakeTimer: NodeJS.Timeout;
90
+ /** Runs from ready on; every received frame restarts it. */
91
+ #heartbeatTimer: NodeJS.Timeout | undefined;
92
+ #refusedTimer: NodeJS.Timeout | undefined;
93
+ #resolveClosed!: () => void;
94
+
95
+ constructor(socket: Socket, options: HostServerOptions, log: (line: string) => void) {
96
+ this.#socket = socket;
97
+ this.#options = options;
98
+ // Every line names its connection, so the console shows which client starts and stops what.
99
+ this.#log = (line) => log(`connection ${this.#id}: ${line}`);
100
+ this.closed = new Promise((resolve) => (this.#resolveClosed = resolve));
101
+ const remote = `${socket.remoteAddress}:${socket.remotePort}`;
102
+ this.#log(`opened from ${remote}`);
103
+ trace().event("connection.open", { connection: this.#id, remote, local: socket.localAddress });
104
+ this.#handshakeTimer = setTimeout(
105
+ () => this.#abort("no hello before the handshake timeout"),
106
+ options.handshakeTimeoutMs ?? DEFAULT_HANDSHAKE_TIMEOUT_MS,
107
+ );
108
+ socket.setNoDelay(true);
109
+ // The kernel also probes an idle connection, for a peer that vanished without a close.
110
+ socket.setKeepAlive(true, 10_000);
111
+ socket.on("data", (chunk) => this.#receive(chunk));
112
+ socket.on("error", (error) => this.#log(`connection error: ${error.message}`));
113
+ socket.on("close", () => void this.#shutdown());
114
+ }
115
+
116
+ /** Closes the connection and its session. */
117
+ close(): Promise<void> {
118
+ this.#socket.destroy();
119
+ return this.closed;
120
+ }
121
+
122
+ /**
123
+ * A client that sends nothing for the heartbeat timeout is gone or hung (a
124
+ * half-open connection never closes by itself): close it, and with it its
125
+ * session and emulator.
126
+ */
127
+ #restartHeartbeat(): void {
128
+ if (this.#state !== "open") return;
129
+ clearTimeout(this.#heartbeatTimer);
130
+ const timeout = this.#options.heartbeatTimeoutMs ?? HEARTBEAT_TIMEOUT_MS;
131
+ this.#heartbeatTimer = setTimeout(() => this.#abort(`no message or ping for ${timeout} ms; the client is gone`), timeout);
132
+ }
133
+
134
+ #send(message: HostMessage, attachments: readonly Uint8Array[] = []): void {
135
+ if (this.#state !== "closed" && !this.#socket.destroyed) this.#socket.write(encodeFrame(message, attachments));
136
+ }
137
+
138
+ /** Ends the host's side after a refusal; a client that does not close its side in time is cut off. */
139
+ #endRefused(): void {
140
+ this.#socket.end();
141
+ this.#refusedTimer = setTimeout(() => this.#abort("the refused client did not close the connection"), REFUSED_CLOSE_MS);
142
+ }
143
+
144
+ #abort(reason: string): void {
145
+ this.#log(`closing connection: ${reason}`);
146
+ this.#socket.destroy();
147
+ }
148
+
149
+ #receive(chunk: Buffer): void {
150
+ this.#restartHeartbeat();
151
+ try {
152
+ for (const { message, attachments } of this.#reader.push(chunk)) {
153
+ if (this.#state === "closed") return;
154
+ this.#dispatch(message, attachments);
155
+ }
156
+ } catch (error) {
157
+ // Any failure closes only this connection; the listener and the other connections go on.
158
+ if (error instanceof ProtocolError) this.#abort(`protocol error: ${error.message}`);
159
+ else this.#abort(`internal error: ${describe(error)}`);
160
+ }
161
+ }
162
+
163
+ /** Closes this connection when work that runs after its message fails in an unexpected way. */
164
+ #contain(work: Promise<void>): void {
165
+ work.catch((error: unknown) => this.#abort(`internal error: ${describe(error)}`));
166
+ }
167
+
168
+ #dispatch(raw: unknown, attachments: Buffer[]): void {
169
+ const message = parseClientMessage(raw);
170
+ if (this.#state === "handshake") {
171
+ if (message.type !== "hello") throw new ProtocolError("first message is not hello");
172
+ clearTimeout(this.#handshakeTimer);
173
+ const mismatch = checkHello(message);
174
+ if (mismatch !== undefined) {
175
+ this.#log(`refusing a client: ${mismatch.message}`);
176
+ trace().event("connection.refused", { connection: this.#id, role: message.role, ...mismatch });
177
+ this.#send({ type: "error", error: mismatch });
178
+ this.#endRefused();
179
+ this.#state = "closed";
180
+ return;
181
+ }
182
+ if (message.role === "tool") {
183
+ this.#isTool = true;
184
+ this.#state = "open";
185
+ this.#send({ type: "ready" });
186
+ this.#log("ready for tool requests");
187
+ trace().event("connection.ready", { connection: this.#id, role: "tool" });
188
+ this.#restartHeartbeat();
189
+ return;
190
+ }
191
+ this.#state = "starting";
192
+ this.#log(`starts a VICE session (${message.videoStandard ?? "pal"})`);
193
+ this.#contain(this.#startSession(message.videoStandard ?? "pal"));
194
+ return;
195
+ }
196
+ if (message.type === "hello") throw new ProtocolError("hello sent twice");
197
+ if (this.#state !== "open") throw new ProtocolError(`${message.type} sent before ready`);
198
+ if (message.type === "ping") {
199
+ this.#send({ type: "pong" });
200
+ return;
201
+ }
202
+ this.#contain(this.#answer(message, attachments));
203
+ }
204
+
205
+ async #startSession(videoStandard: VideoStandard): Promise<void> {
206
+ const started = performance.now();
207
+ this.#starting = this.#createSession(videoStandard);
208
+ const session = await this.#starting;
209
+ // A connection closed while starting is shut down by #shutdown, which awaits #starting.
210
+ if (session === undefined || this.#state === "closed") return;
211
+ this.#session = session;
212
+ this.#state = "open";
213
+ this.#send({ type: "ready" });
214
+ this.#log(`VICE session ready after ${Math.round(performance.now() - started)} ms`);
215
+ trace().event("connection.ready", { connection: this.#id, role: "vice-session", videoStandard, ms: elapsedMs(started) });
216
+ this.#restartHeartbeat();
217
+ }
218
+
219
+ /** The new session, or undefined after the failure is sent to the client. Never rejects, also when the factory throws at once. */
220
+ async #createSession(videoStandard: VideoStandard): Promise<ViceSessionHandle | undefined> {
221
+ try {
222
+ return await this.#options.createViceSession({ videoStandard, log: this.#log });
223
+ } catch (error) {
224
+ this.#log(`VICE session failed to start: ${error instanceof Error ? error.message : String(error)}`);
225
+ trace().event("connection.session-failed", { connection: this.#id, error });
226
+ this.#send({
227
+ type: "error",
228
+ error: toWireError(error, { code: "machine-unavailable", message: "The emulator could not be started." }),
229
+ });
230
+ this.#endRefused();
231
+ return undefined;
232
+ }
233
+ }
234
+
235
+ async #answer(request: ReceivedRequest, attachments: Buffer[]): Promise<void> {
236
+ const started = performance.now();
237
+ const record = (outcome: Record<string, unknown>) => {
238
+ const ms = elapsedMs(started);
239
+ trace().event("request", { connection: this.#id, op: request.op, id: request.id, params: request.params, attachments: attachments.length, ms, ...outcome });
240
+ // A call from a skill script (c1541, petcat, status) is always logged; the many calls of a VICE session only in the trace.
241
+ if (this.#isTool) {
242
+ const what = outcome.ok === true ? "ok" : `${String(outcome.code)}: ${String(outcome.message)}`;
243
+ this.#log(`${request.op} ${JSON.stringify(summarize(request.params) ?? {})}: ${what} (${ms} ms)`);
244
+ }
245
+ };
246
+ try {
247
+ let result: unknown;
248
+ let replyAttachments: Buffer[] = [];
249
+ if (this.#isTool) {
250
+ const { op } = request;
251
+ const tools = this.#options.tools;
252
+ if (!isOneOf(TOOL_OPERATIONS, op) || tools === undefined) throw new WireFailure("invalid-input", `unknown operation: ${op}`);
253
+ const params = validateToolParams(op, request.params, attachments);
254
+ const answer = await tools(op, params, attachments, this.#toolAbort.signal);
255
+ result = answer.result;
256
+ replyAttachments = answer.attachments ?? [];
257
+ } else {
258
+ const { op } = request;
259
+ const session = this.#session;
260
+ if (session === undefined || !isOneOf(VICE_OPERATIONS, op)) throw new WireFailure("invalid-input", `unknown operation: ${op}`);
261
+ const params = validateViceParams(op, request.params);
262
+ if (attachments.length !== attachmentCount(op, params)) {
263
+ throw new WireFailure("invalid-input", `${op} takes ${attachmentCount(op, params)} file attachment(s)`);
264
+ }
265
+ result = await session.handle(op, params, attachments, replyAttachments);
266
+ }
267
+ this.#send({ type: "reply", id: request.id, result }, replyAttachments);
268
+ record({ ok: true, result });
269
+ } catch (error) {
270
+ if (!(error instanceof WireFailure)) {
271
+ this.#log(`operation ${request.op} failed: ${error instanceof Error ? (error.stack ?? error.message) : String(error)}`);
272
+ }
273
+ const wire = toWireError(error, { code: "operation-failed", message: "The operation failed inside the host runtime." });
274
+ this.#send({ type: "reply", id: request.id, error: wire });
275
+ record({ ok: false, ...wire });
276
+ }
277
+ }
278
+
279
+ async #shutdown(): Promise<void> {
280
+ if (this.#shuttingDown) return;
281
+ this.#shuttingDown = true;
282
+ this.#state = "closed";
283
+ trace().event("connection.close", { connection: this.#id });
284
+ clearTimeout(this.#handshakeTimer);
285
+ clearTimeout(this.#heartbeatTimer);
286
+ clearTimeout(this.#refusedTimer);
287
+ // Tool work for a client that is gone stops: its children and workspaces go too.
288
+ this.#toolAbort.abort();
289
+ const session = this.#session ?? (await this.#starting);
290
+ this.#session = undefined;
291
+ if (session !== undefined) this.#log("the client is gone; stopping its VICE session");
292
+ try {
293
+ await session?.close();
294
+ } catch (error) {
295
+ this.#log(`session close failed: ${error instanceof Error ? error.message : String(error)}`);
296
+ }
297
+ this.#log("closed");
298
+ this.#resolveClosed();
299
+ }
300
+ }
301
+
302
+ /** A port as the user gives it: decimal digits only, 0 to 65535. Anything else gives undefined. */
303
+ export function parsePort(text: string): number | undefined {
304
+ if (!/^\d{1,5}$/.test(text)) return undefined;
305
+ const port = Number(text);
306
+ return port <= 0xffff ? port : undefined;
307
+ }
308
+
309
+ /**
310
+ * Resolves with the first SIGINT or SIGTERM. The handlers stay: a later
311
+ * signal is logged and ignored, so a second Ctrl+C cannot cut the shutdown
312
+ * short before every emulator and tool is stopped.
313
+ */
314
+ export function stopSignal(log: (line: string) => void): Promise<NodeJS.Signals> {
315
+ return new Promise((resolve) => {
316
+ let stopping = false;
317
+ const stop = (signal: NodeJS.Signals) => {
318
+ if (stopping) {
319
+ log(`received ${signal} again; the host is already stopping`);
320
+ return;
321
+ }
322
+ stopping = true;
323
+ log(`received ${signal}, stopping`);
324
+ resolve(signal);
325
+ };
326
+ process.on("SIGINT", stop);
327
+ process.on("SIGTERM", stop);
328
+ });
329
+ }
330
+
331
+ /** A listener that could not start, with the address and the port it tried and the system error code. */
332
+ export class ListenError extends Error {
333
+ readonly code: string | undefined;
334
+ readonly address: string;
335
+ readonly port: number;
336
+
337
+ constructor(cause: unknown, address: string, port: number) {
338
+ super(`cannot listen on ${address} port ${port}: ${cause instanceof Error ? cause.message : String(cause)}`, { cause });
339
+ this.name = "ListenError";
340
+ this.code = (cause as NodeJS.ErrnoException | undefined)?.code;
341
+ this.address = address;
342
+ this.port = port;
343
+ }
344
+ }
345
+
346
+ /** Starts the Host Runtime listener. Each connection owns at most one VICE session. */
347
+ export async function startHostServer(options: HostServerOptions): Promise<HostServer> {
348
+ const log = options.log ?? (() => {});
349
+ const host = options.host ?? "127.0.0.1";
350
+ const connections = new Set<Connection>();
351
+ const servers: Server[] = [];
352
+ const listen = (address: string, port: number) => {
353
+ const server: Server = createServer((socket) => {
354
+ const connection = new Connection(socket, options, log);
355
+ connections.add(connection);
356
+ void connection.closed.then(() => connections.delete(connection));
357
+ });
358
+ servers.push(server);
359
+ return new Promise<number>((resolve, reject) => {
360
+ const failed = (error: Error) => reject(new ListenError(error, address, port));
361
+ server.once("error", failed);
362
+ server.listen({ host: address, port, exclusive: true }, () => {
363
+ server.off("error", failed);
364
+ const bound = server.address();
365
+ if (bound === null || typeof bound === "string") reject(new Error("listener has no TCP address"));
366
+ else resolve(bound.port);
367
+ });
368
+ });
369
+ };
370
+
371
+ let port: number;
372
+ try {
373
+ port = await listen(host, options.port);
374
+ } catch (error) {
375
+ await Promise.all(servers.map((server) => new Promise<void>((resolve) => server.close(() => resolve()))));
376
+ throw error;
377
+ }
378
+
379
+ let closing: Promise<void> | undefined;
380
+ return {
381
+ host,
382
+ port,
383
+ close() {
384
+ closing ??= (async () => {
385
+ const stopped = Promise.all(servers.map((server) => new Promise<void>((resolve) => server.close(() => resolve()))));
386
+ await Promise.all([...connections].map((connection) => connection.close()));
387
+ await stopped;
388
+ })();
389
+ return closing;
390
+ },
391
+ };
392
+ }
@@ -0,0 +1,284 @@
1
+ // The c1541 adapter. c1541 does all image access: it recognises the
2
+ // image, reports free blocks and the BAM, follows sector chains and writes raw
3
+ // blocks. File names never go to c1541: its name arguments and its directory
4
+ // listing change letter case, so the adapter reads the raw header and
5
+ // directory blocks and matches names by their exact PETSCII bytes.
6
+ //
7
+ // c1541 exits 0 after most errors, so success is decided from its output and
8
+ // from the output files it was asked to write.
9
+
10
+ import { existsSync, readFileSync } from "node:fs";
11
+ import { join } from "node:path";
12
+
13
+ import { petsciiNameToText, textToPetsciiName } from "../../c64.ts";
14
+ import { WireFailure } from "../../protocol/messages.ts";
15
+ import { CBM_FILE_TYPES, type C1541Params, type C1541Result, type CbmFileType, type DiskFile, type DiskSector } from "../../protocol/tools.ts";
16
+ import { Workspace } from "../../native/staging.ts";
17
+ import { C1541, findTool } from "../../native/discover.ts";
18
+ import { requireMinimumVersion } from "./version.ts";
19
+ import { runToolOrFail, type ToolContext } from "../../native/run.ts";
20
+
21
+ const TIMEOUT_MS = 30_000;
22
+ const BLOCK_BYTES = 256;
23
+ /** Block reads in one c1541 call, so that its command line stays far below the Windows limit of 32767 characters. */
24
+ export const READS_PER_CALL = 256;
25
+
26
+ /** Where a drive format keeps its header block and the header's fields. */
27
+ interface Layout {
28
+ header: DiskSector;
29
+ name: number;
30
+ id: number;
31
+ dos: number;
32
+ }
33
+
34
+ const LAYOUTS: Record<string, Layout> = {
35
+ "1541": { header: { track: 18, sector: 0 }, name: 0x90, id: 0xa2, dos: 0xa5 },
36
+ "1571": { header: { track: 18, sector: 0 }, name: 0x90, id: 0xa2, dos: 0xa5 },
37
+ "1581": { header: { track: 40, sector: 0 }, name: 0x04, id: 0x16, dos: 0x19 },
38
+ };
39
+
40
+ /** Used sectors of the format outside the header and directory chain: the 1571 BAM track 53, the two 1581 BAM blocks. */
41
+ const RESERVED: Record<string, DiskSector[]> = {
42
+ "1541": [],
43
+ "1571": Array.from({ length: 19 }, (_, sector) => ({ track: 53, sector })),
44
+ "1581": [
45
+ { track: 40, sector: 1 },
46
+ { track: 40, sector: 2 },
47
+ ],
48
+ };
49
+
50
+ export interface DiskEntry extends DiskFile {
51
+ /** The name's PETSCII bytes, up to the first shifted space ($a0). */
52
+ nameBytes: Uint8Array;
53
+ startTrack: number;
54
+ startSector: number;
55
+ /** REL only: the first side sector. */
56
+ sideStart?: DiskSector;
57
+ }
58
+
59
+ const damaged = (message: string) => new WireFailure("media-error", `The disk image is damaged: ${message}`);
60
+
61
+ /** The drive format and track count from c1541's info command. */
62
+ export function parseInfo(output: string): { format: string; tracks: number } {
63
+ const format = /^disk format\s*:\s*(\S+)/m.exec(output);
64
+ const tracks = /^track count\s*:\s*(\d+)/m.exec(output);
65
+ if (format === null || tracks === null) {
66
+ throw new WireFailure("media-error", "c1541 does not recognise the disk image. Check that the file is a complete disk image of the given type.");
67
+ }
68
+ return { format: format[1]!, tracks: Number(tracks[1]) };
69
+ }
70
+
71
+ export function parseFreeBlocks(output: string): number {
72
+ const match = /^(\d+) blocks free\.$/m.exec(output);
73
+ if (match === null) throw new WireFailure("operation-failed", "c1541 did not report the free blocks of the disk.");
74
+ return Number(match[1]);
75
+ }
76
+
77
+ /** A sector chain as c1541's chain command prints it: "(17, 1) -> (17,11) -> 207". */
78
+ export function parseChain(output: string, start: DiskSector): DiskSector[] {
79
+ const where = `the sector chain from track ${start.track} sector ${start.sector}`;
80
+ if (/cyclic reference found/.test(output)) throw damaged(`${where} loops.`);
81
+ const line = /^\(\s*\d+,\s*\d+\).*$/m.exec(output)?.[0];
82
+ if (line === undefined) throw damaged(`${where} cannot be read.`);
83
+ const parts = line.split(" -> ");
84
+ const end = parts.pop()!;
85
+ if (!/^\d+$/.test(end.trim())) throw damaged(`${where} leaves the disk.`);
86
+ return parts.map((part) => {
87
+ const match = /^\(\s*(\d+),\s*(\d+)\)$/.exec(part.trim());
88
+ if (match === null) throw new WireFailure("operation-failed", "c1541 printed a sector chain in an unknown form.");
89
+ return { track: Number(match[1]), sector: Number(match[2]) };
90
+ });
91
+ }
92
+
93
+ /** The BAM grid c1541 prints: one row per track, "*" for a used sector, "." for a free one. */
94
+ export function parseBam(output: string, tracks: number): Array<{ track: number; freeSectors: number[]; usedSectors: number[] }> {
95
+ const rows: Array<{ track: number; freeSectors: number[]; usedSectors: number[] }> = [];
96
+ for (const line of output.split(/\r?\n/)) {
97
+ const match = /^\s*(\d+)\s+([.*]{1,8}(?: [.*]{1,8})*)\s*$/.exec(line);
98
+ if (match === null) continue;
99
+ const row = { track: Number(match[1]), freeSectors: [] as number[], usedSectors: [] as number[] };
100
+ [...match[2]!.replace(/ /g, "")].forEach((mark, sector) => (mark === "*" ? row.usedSectors : row.freeSectors).push(sector));
101
+ rows.push(row);
102
+ }
103
+ const complete = rows.length === tracks && rows.every((row, index) => row.track === index + 1);
104
+ if (!complete) throw new WireFailure("operation-failed", `c1541 printed a BAM that does not cover the ${tracks} tracks of the disk.`);
105
+ return rows;
106
+ }
107
+
108
+ /** The bytes of a name field up to the first shifted space ($a0). */
109
+ function nameField(block: Buffer, offset: number, length: number): Uint8Array {
110
+ const field = block.subarray(offset, offset + length);
111
+ const end = field.indexOf(0xa0);
112
+ return Uint8Array.from(end < 0 ? field : field.subarray(0, end));
113
+ }
114
+
115
+ function fileType(typeByte: number): CbmFileType {
116
+ return CBM_FILE_TYPES[typeByte & 0x07] ?? "unknown";
117
+ }
118
+
119
+ /** The disk header and the directory entries, from the raw header block and the directory blocks after it. */
120
+ export function parseDirectory(blocks: Buffer[], layout: Layout): { diskName: string; diskId: string; dosType: string; entries: DiskEntry[] } {
121
+ const [header, ...directory] = blocks;
122
+ if (header === undefined) throw new WireFailure("operation-failed", "c1541 returned no header block.");
123
+ const entries: DiskEntry[] = [];
124
+ for (const block of directory) {
125
+ for (let offset = 0; offset < BLOCK_BYTES; offset += 32) {
126
+ const typeByte = block.readUInt8(offset + 2);
127
+ // Type 0 is an empty or scratched slot; the DOS lists no file there.
128
+ if (typeByte === 0) continue;
129
+ const nameBytes = nameField(block, offset + 5, 16);
130
+ const entry: DiskEntry = {
131
+ name: petsciiNameToText(nameBytes),
132
+ nameBytes,
133
+ type: fileType(typeByte),
134
+ blocks: block.readUInt16LE(offset + 30),
135
+ closed: (typeByte & 0x80) !== 0,
136
+ locked: (typeByte & 0x40) !== 0,
137
+ startTrack: block.readUInt8(offset + 3),
138
+ startSector: block.readUInt8(offset + 4),
139
+ };
140
+ const side = { track: block.readUInt8(offset + 0x15), sector: block.readUInt8(offset + 0x16) };
141
+ if (entry.type === "rel" && side.track !== 0) entry.sideStart = side;
142
+ entries.push(entry);
143
+ }
144
+ }
145
+ return {
146
+ diskName: petsciiNameToText(nameField(header, layout.name, 16)),
147
+ diskId: petsciiNameToText(header.subarray(layout.id, layout.id + 2)),
148
+ dosType: petsciiNameToText(header.subarray(layout.dos, layout.dos + 2)),
149
+ entries,
150
+ };
151
+ }
152
+
153
+ /** A file's data from its blocks in chain order: 254 bytes per block, the last block up to its end index. */
154
+ export function fileData(blocks: Buffer[], chain: DiskSector[]): Buffer {
155
+ const parts: Buffer[] = [];
156
+ blocks.forEach((block, index) => {
157
+ const next = chain[index + 1];
158
+ if (next === undefined) {
159
+ if (block[0] !== 0) throw new WireFailure("operation-failed", "c1541 returned a last file block that links further.");
160
+ parts.push(block.subarray(2, Math.max(2, block.readUInt8(1) + 1)));
161
+ } else {
162
+ if (block[0] !== next.track || block[1] !== next.sector) throw new WireFailure("operation-failed", "c1541 returned file blocks that do not match the sector chain.");
163
+ parts.push(block.subarray(2));
164
+ }
165
+ });
166
+ return Buffer.concat(parts);
167
+ }
168
+
169
+ /** The used sectors of the BAM that are not in `referenced`, in track and sector order. */
170
+ export function unreferencedSectors(tracks: ReadonlyArray<{ track: number; usedSectors: number[] }>, referenced: Iterable<DiskSector>): DiskSector[] {
171
+ const known = new Set<string>();
172
+ for (const at of referenced) known.add(`${at.track}/${at.sector}`);
173
+ return tracks.flatMap(({ track, usedSectors }) => usedSectors.filter((sector) => !known.has(`${track}/${sector}`)).map((sector) => ({ track, sector })));
174
+ }
175
+
176
+ const toFile = (entry: DiskEntry): DiskFile => ({ name: entry.name, type: entry.type, blocks: entry.blocks, closed: entry.closed, locked: entry.locked });
177
+
178
+ const sameBytes = (a: Uint8Array, b: Uint8Array) => a.length === b.length && a.every((byte, index) => byte === b[index]);
179
+
180
+ /** Runs c1541 commands on the staged image and returns all its output. */
181
+ export class C1541Session {
182
+ readonly #executable: string;
183
+ readonly #workspace: Workspace;
184
+ readonly #image: string;
185
+ readonly #context: ToolContext;
186
+ #reads = 0;
187
+
188
+ constructor(executable: string, workspace: Workspace, image: string, context: ToolContext) {
189
+ this.#executable = executable;
190
+ this.#workspace = workspace;
191
+ this.#image = image;
192
+ this.#context = context;
193
+ }
194
+
195
+ async run(commands: string[]): Promise<string> {
196
+ const run = await runToolOrFail(
197
+ "c1541",
198
+ "The disk inspection",
199
+ {
200
+ argv: [this.#executable, "-attach", this.#image, ...commands],
201
+ cwd: this.#workspace.root,
202
+ timeoutMs: TIMEOUT_MS,
203
+ truncated: "c1541 printed more output than the host accepts.",
204
+ },
205
+ this.#context,
206
+ );
207
+ return `${run.stdout}\n${run.stderr}`;
208
+ }
209
+
210
+ async chain(start: DiskSector): Promise<DiskSector[]> {
211
+ return parseChain(await this.run(["-chain", String(start.track), String(start.sector)]), start);
212
+ }
213
+
214
+ /** Raw blocks, in the given order, through c1541's block read: at most READS_PER_CALL blocks in each c1541 call. */
215
+ async blocks(sectors: DiskSector[]): Promise<Buffer[]> {
216
+ const blocks: Buffer[] = [];
217
+ for (let first = 0; first < sectors.length; first += READS_PER_CALL) {
218
+ blocks.push(...(await this.#readBlocks(sectors.slice(first, first + READS_PER_CALL))));
219
+ }
220
+ return blocks;
221
+ }
222
+
223
+ async #readBlocks(sectors: DiskSector[]): Promise<Buffer[]> {
224
+ const directory = `blocks-${this.#reads++}`;
225
+ const full = this.#workspace.directory(directory);
226
+ const reads = sectors.map((at, index) => ({ at, file: `${index}.bin` }));
227
+ const output = await this.run(reads.flatMap(({ at, file }) => ["-bread", join(directory, file), String(at.track), String(at.sector)]));
228
+ return reads.map(({ at, file }) => {
229
+ const path = join(full, file);
230
+ const block = existsSync(path) ? readFileSync(path) : undefined;
231
+ if (block?.length === BLOCK_BYTES) return block;
232
+ if (/cannot read track|out of bounds/.test(output)) throw damaged(`track ${at.track} sector ${at.sector} cannot be read.`);
233
+ throw new WireFailure("operation-failed", `c1541 did not write track ${at.track} sector ${at.sector}.`);
234
+ });
235
+ }
236
+ }
237
+
238
+ export async function inspect(params: C1541Params, image: Buffer, context: ToolContext): Promise<{ result: C1541Result; attachments?: Buffer[] }> {
239
+ const executable = findTool(C1541, context.env);
240
+ await requireMinimumVersion(C1541, executable, context);
241
+ const workspace = Workspace.create(context.supervisor);
242
+ try {
243
+ const file = `image.${params.imageType}`;
244
+ workspace.materialize("input", [{ path: file, size: image.length }], [image]);
245
+ const c1541 = new C1541Session(executable, workspace, `input/${file}`, context);
246
+ const overview = await c1541.run(["-info", "-dir"]);
247
+ const info = parseInfo(overview);
248
+ const layout = LAYOUTS[info.format];
249
+ if (layout === undefined) throw new WireFailure("media-error", `The disk image has the drive format ${info.format}; only 1541, 1571 and 1581 disks are supported.`);
250
+
251
+ const headerChain = await c1541.chain(layout.header);
252
+ const directory = parseDirectory(await c1541.blocks(headerChain), layout);
253
+ if (params.action === "bam") {
254
+ const tracks = parseBam(await c1541.run(["-bam"]), info.tracks);
255
+ // Every sector that the header, the directory or a file holds; a file without blocks has no chain.
256
+ const referenced = [...headerChain, ...(RESERVED[info.format] ?? [])];
257
+ for (const entry of directory.entries) {
258
+ if (entry.startTrack !== 0) referenced.push(...(await c1541.chain({ track: entry.startTrack, sector: entry.startSector })));
259
+ if (entry.sideStart !== undefined) referenced.push(...(await c1541.chain(entry.sideStart)));
260
+ }
261
+ return { result: { action: "bam", tracks, unreferenced: unreferencedSectors(tracks, referenced) } };
262
+ }
263
+ if (params.action === "directory") {
264
+ const { diskName, diskId, dosType, entries } = directory;
265
+ return { result: { action: "directory", diskName, diskId, dosType, freeBlocks: parseFreeBlocks(overview), entries: entries.map(toFile) } };
266
+ }
267
+
268
+ if (params.name === undefined) throw new WireFailure("invalid-input", `action ${params.action} needs a file name`);
269
+ const wanted = textToPetsciiName(params.name);
270
+ const entry = directory.entries.find((candidate) => sameBytes(candidate.nameBytes, wanted));
271
+ if (entry === undefined) return { result: { action: params.action, found: false } };
272
+ if (params.action === "entry") {
273
+ return { result: { action: "entry", found: true, entry: { ...toFile(entry), startTrack: entry.startTrack, startSector: entry.startSector } } };
274
+ }
275
+ // A file without blocks (a DEL entry, for example) has no chain.
276
+ const start = { track: entry.startTrack, sector: entry.startSector };
277
+ const chain = start.track === 0 ? [] : await c1541.chain(start);
278
+ if (params.action === "chain") return { result: { action: "chain", found: true, sectors: chain } };
279
+ const data = fileData(await c1541.blocks(chain), chain);
280
+ return { result: { action: "read", found: true, name: entry.name, bytes: data.length }, attachments: [data] };
281
+ } finally {
282
+ workspace.remove();
283
+ }
284
+ }