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

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 +138 -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 +106 -0
  39. package/src/host/server.ts +376 -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 +238 -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 +106 -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 +243 -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,1316 @@
1
+ // The per-emulator session: one VICE at a time, one serialized operation
2
+ // queue, the machine's run state, and what happens when VICE dies. The
3
+ // session starts headless and can move the machine into a VICE with a window
4
+ // and back; breakpoints, watchpoints and the rest go along.
5
+
6
+ import { WireFailure, type VideoStandard } from "../../protocol/messages.ts";
7
+ import { MAX_BASELINES, MAX_SNAPSHOTS, MAX_COMPARE_DIFFERENCES, RASTER, type Breakpoint, type BreakpointParams, type Condition, type ExecutionParams, type HistoryEntry, type RunTarget, type RunUntilResult, type ScreenComparison, type CiaSelection, type CiaState, type ObserveParams, type ObserveResult, type SidState, type SpriteState, type ViciiState, type JoystickState, type Watchpoint, type WatchpointParams, type ExecutionResult, type MachineStatus, type RunState, type ViceOperation, type ViceOperations, type WindowResult } from "../../protocol/vice.ts";
8
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
9
+ import { tmpdir } from "node:os";
10
+ import { join } from "node:path";
11
+ import { setTimeout as sleep } from "node:timers/promises";
12
+
13
+ import { formatC64Address } from "../../c64.ts";
14
+ import type { ProcessSupervisor } from "../../native/processes.ts";
15
+ import { trace } from "../../trace.ts";
16
+ import type { ViceSessionFactory, ViceSessionHandle } from "../server.ts";
17
+ import {
18
+ backtraceFromStack,
19
+ conditionExpression,
20
+ inRasterWindow,
21
+ JAM_OPCODES,
22
+ rasterLinesExpression,
23
+ rasterWindowExpression,
24
+ RUNNING_COMMAND_TIMEOUT_MS,
25
+ ViceAdapter,
26
+ } from "./adapter.ts";
27
+ import { decodeProgramCounter, MonitorConnectionError, MonitorError, MonitorTimeoutError, ResponseType, type MonitorResponse } from "./binary-monitor.ts";
28
+ import { decodeCia, decodeSid, decodeSprite, decodeVicii } from "./chips.ts";
29
+ import { compareFrames, differenceImage, encodePng, type IndexedFrame } from "./screen.ts";
30
+ import { launchVice, startErrorLines, type ViceMode, type ViceProcess } from "./process.ts";
31
+ import { TextMonitorError, TextMonitorTimeoutError } from "./text-monitor.ts";
32
+
33
+ const CRASH_SETTLE_MS = 500;
34
+ /** How long until-return may run before the session stops it and reports the limit. */
35
+ const UNTIL_RETURN_LIMIT_MS = 30_000;
36
+ /** How long a reset may take to reach the reset vector. */
37
+ const RESET_STOP_LIMIT_MS = 5_000;
38
+
39
+ const STATE_LOST =
40
+ "The emulator stopped unexpectedly and its machine state is lost. Restart the c64-re-tools MCP server to get a fresh C64.";
41
+
42
+ const VICE_HELD =
43
+ "The emulator does not take commands now: it is paused in its window (Pause, Alt+P) or a dialog in its window waits for an answer. " +
44
+ "Resume it or close the dialog in the VICE window, then try again. The machine is kept as it was.";
45
+
46
+ const VICE_HELD_HEADLESS =
47
+ "The emulator does not take commands now. The session sends it nothing until it answers again, and then puts the machine back as it was. " +
48
+ "Try again in a few seconds. If the emulator does not answer again, restart the c64-re-tools MCP server.";
49
+
50
+ function stepNotEnded(action: string, count: number, limitMs: number): string {
51
+ return (
52
+ `The emulator does not take commands now: the ${action} of ${count} instruction${count === 1 ? "" : "s"} did not end in ${limitMs / 1000} seconds. ` +
53
+ "The CPU did not get to the end of it (for example, a subroutine that does not return). " +
54
+ "The emulator takes no commands until the step ends. Try again later. If the step does not end, restart the c64-re-tools MCP server."
55
+ );
56
+ }
57
+
58
+ /** A step or next that got no answer in time; `why` names it for the held VICE. */
59
+ class StepTimeout extends Error {
60
+ override name = "StepTimeout";
61
+ readonly why: string;
62
+ constructor(why: string, cause: unknown) {
63
+ super(cause instanceof Error ? cause.message : String(cause));
64
+ this.why = why;
65
+ }
66
+ }
67
+
68
+ const WINDOW_CLOSING =
69
+ "The VICE window closed. The session starts a headless emulator again from the machine state of when the window opened. Try again in a few seconds.";
70
+
71
+ const WINDOW_CLOSED =
72
+ "The VICE window closed before c64_window close (the user closed it, or that emulator stopped). " +
73
+ "The machine is headless again, in the state it had when the window opened; what changed in the window is lost. " +
74
+ "Breakpoints and watchpoints are kept. Check the machine state before you continue.";
75
+
76
+ /** Emulator state that a move to another VICE cannot carry. */
77
+ const NOT_CARRIED = ["cpu history", "memory map", "profile", "keyboard input not yet typed"];
78
+
79
+ /** Starts another VICE for this session; `log` also gets that launch's lines. */
80
+ export type ViceLauncher = (mode: ViceMode, log: (line: string) => void) => Promise<ViceProcess>;
81
+
82
+ /** The machine as saved for a move to another VICE. */
83
+ interface Handover {
84
+ file: string;
85
+ running: boolean;
86
+ warp: boolean;
87
+ /** Cycles on the session stopwatch (c64_timing) at the save. */
88
+ elapsed: bigint;
89
+ }
90
+
91
+ /** What a user point needs to be set again in another VICE. */
92
+ type PointRecord = { checkpoint: number; enabled: boolean; space: "c64" | "drive8"; address: number; expression?: string };
93
+
94
+ /** A command that got no answer in time on a connection that is still open. */
95
+ function isTimeout(error: unknown): boolean {
96
+ return error instanceof MonitorTimeoutError || error instanceof TextMonitorTimeoutError || error instanceof StepTimeout;
97
+ }
98
+
99
+ /** A stop and the VICE checkpoints that caused it. */
100
+ interface Stop {
101
+ pc: number;
102
+ hits: number[];
103
+ }
104
+
105
+ /** Why a frame-clocked run ended. "frames" means the frame limit was reached. */
106
+ type RunEnd = "target" | "breakpoint" | "watchpoint" | "jam" | "frames";
107
+
108
+ /** How long half a frame may take before the session gives up waiting. */
109
+ const HALF_FRAME_LIMIT_MS = 10_000;
110
+ /** A drive stop is completed within a few computer instructions. */
111
+ const DRIVE_STOP_TRIES = 4;
112
+
113
+ export interface SessionOptions {
114
+ /** Upper bound for until-return; tests shorten it. */
115
+ untilReturnLimitMs?: number;
116
+ /** Upper bound for a step or next; tests shorten it. */
117
+ stepLimitMs?: number;
118
+ /** Starts another VICE, for c64_window. Without it the session cannot open a window. */
119
+ launch?: ViceLauncher;
120
+ /** Makes the host remove a path if it dies; returns the release. */
121
+ ownPath?: (path: string) => () => void;
122
+ }
123
+
124
+ export class ViceSession implements ViceSessionHandle {
125
+ /** The current VICE. A window move replaces it. */
126
+ #vice: ViceProcess;
127
+ /** The mode of the current VICE. */
128
+ #mode: ViceMode = "headless";
129
+ readonly #videoStandard: VideoStandard;
130
+ readonly #log: (line: string) => void;
131
+ readonly #untilReturnLimitMs: number;
132
+ readonly #stepLimitMs: number;
133
+ readonly #launch: ViceLauncher | undefined;
134
+ /** Session files that outlive one VICE: snapshots, staged media, handovers. */
135
+ readonly #workDir: string;
136
+ readonly #releaseWorkDir: () => void;
137
+ /** The machine as it was when the window opened; the way back if the window closes by itself. */
138
+ #handover: Handover | undefined;
139
+ /** Set from the moment the window VICE ends by itself until the session is headless again. */
140
+ #windowClosing = false;
141
+ /** A failure the next operation reports once, then the session goes on. */
142
+ #notice: WireFailure | undefined;
143
+ #adapter: ViceAdapter | undefined;
144
+ #state: RunState = "running";
145
+ /** Each returns true once satisfied and is then dropped. */
146
+ #stopWaiters: Array<(stop: Stop) => boolean> = [];
147
+ /** VICE numbers of checkpoints reported hit whose stopped event has not come yet. */
148
+ #checkpointHits: number[] = [];
149
+ /**
150
+ * Set by a stop that a drive 8 checkpoint caused. VICE then enters the
151
+ * monitor from inside the drive CPU, while the computer's CPU is between two
152
+ * cycles of an instruction: a register write to it is lost or comes late,
153
+ * and the next frame count ends one frame short. The session
154
+ * completes such a stop (#completeDriveStop) before its next command, and
155
+ * before a pause or an observation that first sees it reports the computer's pc.
156
+ */
157
+ #driveStop = false;
158
+ /** Counts stops caused by checkpoints or a CPU JAM rather than by a monitor command. */
159
+ #haltCount = 0;
160
+ #queue: Promise<unknown> = Promise.resolve();
161
+ #closed = false;
162
+ #lost = false;
163
+ /** Set while VICE takes no commands (its own pause, a dialog); settles when it is restored (#recover). */
164
+ #held: Promise<void> | undefined;
165
+ /** Why VICE takes no commands, for every operation refused while it is held. */
166
+ #heldWhy = VICE_HELD;
167
+ /** Session-local snapshots by name, as files in the session directory. */
168
+ readonly #snapshots = new Map<string, string>();
169
+ #snapshotFiles = 0;
170
+ /** Session-local screen baselines, lost with the session. */
171
+ readonly #baselines = new Map<string, { frame: IndexedFrame; palette: Array<[number, number, number]> }>();
172
+ /** The CPU clock when the session stopwatch last started (c64_timing). */
173
+ #timingStart = 0n;
174
+ /** Counts staged media files, for unique names. */
175
+ #staged = 0;
176
+ /** The last staged file of each kind that VICE took; VICE can still read it. */
177
+ readonly #stagedFiles = new Map<"autostart" | "disk", string>();
178
+ /** Session-local ids: one counter for breakpoints and watchpoints, from 1. */
179
+ #nextPointId = 1;
180
+ /**
181
+ * User breakpoints and watchpoints by session id, with their VICE checkpoint
182
+ * numbers and condition expressions: a window move sets them again.
183
+ */
184
+ readonly #breakpoints = new Map<number, Breakpoint & { checkpoint: number; expression?: string }>();
185
+ readonly #watchpoints = new Map<number, Watchpoint & { checkpoint: number; expression?: string }>();
186
+ /** The held joystick state per control port. */
187
+ readonly #joysticks = new Map<1 | 2, JoystickState>([
188
+ [1, { port: 1, direction: "center", fire: false }],
189
+ [2, { port: 2, direction: "center", fire: false }],
190
+ ]);
191
+
192
+ private constructor(vice: ViceProcess, videoStandard: VideoStandard, log: (line: string) => void, options: SessionOptions) {
193
+ this.#vice = vice;
194
+ this.#videoStandard = videoStandard;
195
+ this.#log = log;
196
+ this.#untilReturnLimitMs = options.untilReturnLimitMs ?? UNTIL_RETURN_LIMIT_MS;
197
+ this.#stepLimitMs = options.stepLimitMs ?? RUNNING_COMMAND_TIMEOUT_MS;
198
+ this.#launch = options.launch;
199
+ this.#workDir = mkdtempSync(join(tmpdir(), "c64-re-tools-session-"));
200
+ this.#releaseWorkDir = options.ownPath?.(this.#workDir) ?? (() => {});
201
+ this.#watch(vice);
202
+ }
203
+
204
+ /** Follows a VICE's events and its end. Only the current VICE counts. */
205
+ #watch(vice: ViceProcess): void {
206
+ vice.monitor.onEvent((event) => {
207
+ if (this.#vice === vice) this.#onEvent(event);
208
+ });
209
+ void vice.exited.then((status) =>
210
+ this.#ended(vice, `VICE exited unexpectedly (code ${status.code}, signal ${status.signal})\n${vice.outputTail()}`),
211
+ );
212
+ for (const closed of [vice.monitor.closed, vice.text.closed]) {
213
+ void closed.then((reason) => {
214
+ if (reason !== undefined) this.#ended(vice, `VICE monitor connection lost: ${reason.message}`);
215
+ });
216
+ }
217
+ }
218
+
219
+ /**
220
+ * A VICE ended or dropped its monitor without the session asking. A window
221
+ * VICE goes back headless from the state of when the window opened;
222
+ * any other loss is final.
223
+ */
224
+ #ended(vice: ViceProcess, why: string): void {
225
+ if (this.#closed || this.#vice !== vice || this.#lost || this.#windowClosing) return;
226
+ this.#log(why);
227
+ if (this.#mode === "window" && this.#handover !== undefined && this.#launch !== undefined) {
228
+ this.#windowClosing = true;
229
+ const handover = this.#handover;
230
+ const launch = this.#launch;
231
+ const run = this.#queue.then(() => this.#backToHeadless(vice, handover, launch));
232
+ this.#queue = run.catch(() => {});
233
+ return;
234
+ }
235
+ this.#lost = true;
236
+ }
237
+
238
+ /** Restarts headless from the handover saved when the window opened. */
239
+ async #backToHeadless(dead: ViceProcess, handover: Handover, launch: ViceLauncher): Promise<void> {
240
+ try {
241
+ await dead.stop();
242
+ if (this.#closed) return;
243
+ await this.#install(launch, "headless", handover);
244
+ this.#handover = undefined;
245
+ this.#notice = new WireFailure("machine-state-lost", WINDOW_CLOSED);
246
+ if (handover.running) await this.#resume();
247
+ this.#log("the session is headless again, in the state of when the window opened");
248
+ } catch (error) {
249
+ this.#lost = true;
250
+ this.#log(`going back headless failed: ${error instanceof Error ? error.message : String(error)}`);
251
+ } finally {
252
+ this.#windowClosing = false;
253
+ }
254
+ }
255
+
256
+ /** Wraps a launched, running VICE. Stops the VICE when setup fails. */
257
+ static async start(
258
+ vice: ViceProcess,
259
+ videoStandard: VideoStandard,
260
+ log: (line: string) => void = () => {},
261
+ options: SessionOptions = {},
262
+ ): Promise<ViceSession> {
263
+ const session = new ViceSession(vice, videoStandard, log, options);
264
+ try {
265
+ await session.#enqueue(() =>
266
+ session.#observe(async () => {
267
+ session.#adapter = await ViceAdapter.create(vice.monitor, vice.text);
268
+ session.#timingStart = await session.#adapter.clock();
269
+ // c64_profile reports what ran since the session started.
270
+ await session.#adapter.startProfiler();
271
+ // The control-port lines start all pressed; release them before the C64 reads them.
272
+ for (const joystick of session.#joysticks.values()) await session.#adapter.setJoystick(joystick);
273
+ }),
274
+ );
275
+ } catch (error) {
276
+ await session.close();
277
+ throw error;
278
+ }
279
+ return session;
280
+ }
281
+
282
+ handle<O extends ViceOperation>(
283
+ op: O,
284
+ params: ViceOperations[O]["params"],
285
+ attachments: Buffer[] = [],
286
+ ): Promise<ViceOperations[O]["result"]> {
287
+ // A window request for the mode the session is in sends nothing to VICE, so a held VICE does not stop it.
288
+ return this.#enqueue(() => this.#run(op, params, attachments), { whileHeld: op === "window" }) as Promise<ViceOperations[O]["result"]>;
289
+ }
290
+
291
+ async close(): Promise<void> {
292
+ this.#closed = true;
293
+ try {
294
+ await this.#vice.stop();
295
+ } finally {
296
+ rmSync(this.#workDir, { recursive: true, force: true });
297
+ this.#releaseWorkDir();
298
+ }
299
+ }
300
+
301
+ get #machine(): ViceAdapter {
302
+ if (this.#adapter === undefined) throw new Error("session is not set up");
303
+ return this.#adapter;
304
+ }
305
+
306
+ async #run(op: ViceOperation, params: unknown, attachments: Buffer[]): Promise<unknown> {
307
+ switch (op) {
308
+ case "status":
309
+ return this.#status();
310
+ case "memoryRead": {
311
+ const read = params as ViceOperations["memoryRead"]["params"];
312
+ const data = await this.#observe(() => this.#machine.readMemory(read));
313
+ return { address: read.address, data };
314
+ }
315
+ case "registersGet": {
316
+ const { space } = params as ViceOperations["registersGet"]["params"];
317
+ return this.#observe(() => this.#machine.readRegisters(space));
318
+ }
319
+ case "memoryWrite": {
320
+ const write = params as ViceOperations["memoryWrite"]["params"];
321
+ this.#requireStopped();
322
+ return { address: write.address, bytesWritten: await this.#machine.writeMemory(write) };
323
+ }
324
+ case "memorySearch": {
325
+ const search = params as ViceOperations["memorySearch"]["params"];
326
+ const matches = await this.#observe(() => this.#machine.search({ ...search, limit: search.maxResults }));
327
+ return { matches };
328
+ }
329
+ case "memoryCompare":
330
+ return this.#memoryCompare(params as ViceOperations["memoryCompare"]["params"]);
331
+ case "disassemble": {
332
+ const request = params as ViceOperations["disassemble"]["params"];
333
+ return { instructions: await this.#observe(() => this.#machine.disassemble(request)) };
334
+ }
335
+ case "registersSet": {
336
+ const { space, values } = params as ViceOperations["registersSet"]["params"];
337
+ this.#requireStopped();
338
+ return this.#machine.writeRegisters(space, values);
339
+ }
340
+ case "execution":
341
+ return this.#execution(params as ExecutionParams);
342
+ case "runUntil":
343
+ return this.#runUntil(params as ViceOperations["runUntil"]["params"]);
344
+ case "programLoad":
345
+ return this.#programLoad(params as ViceOperations["programLoad"]["params"], onlyAttachment(attachments));
346
+ case "autostart": {
347
+ const { type, index, run } = params as ViceOperations["autostart"]["params"];
348
+ const file = this.#stage(onlyAttachment(attachments), type);
349
+ await this.#runningCommand(() => this.#machine.autostart(file, index, run));
350
+ this.#replaceStaged("autostart", file);
351
+ return { state: "running" };
352
+ }
353
+ case "diskAttach": {
354
+ const { type } = params as ViceOperations["diskAttach"]["params"];
355
+ const file = this.#stage(onlyAttachment(attachments), type);
356
+ await this.#observe(() => this.#machine.attachDisk(file));
357
+ this.#replaceStaged("disk", file);
358
+ return { attached: true };
359
+ }
360
+ case "reset":
361
+ return this.#reset(params as ViceOperations["reset"]["params"]);
362
+ case "keyboard": {
363
+ const bytes = Buffer.from((params as ViceOperations["keyboard"]["params"]).data, "hex");
364
+ await this.#observe(() => this.#machine.feedKeyboard(bytes));
365
+ return { queuedBytes: bytes.length };
366
+ }
367
+ case "breakpoint":
368
+ return this.#breakpoint(params as BreakpointParams);
369
+ case "watchpoint":
370
+ return this.#watchpoint(params as WatchpointParams);
371
+ case "screenCapture": {
372
+ const { baseline } = params as ViceOperations["screenCapture"]["params"];
373
+ if (baseline !== undefined && !this.#baselines.has(baseline) && this.#baselines.size >= MAX_BASELINES) {
374
+ throw new WireFailure("limit-exceeded", `A session keeps at most ${MAX_BASELINES} baselines. Discard one first.`);
375
+ }
376
+ const shot = await this.#observe(() => this.#machine.captureFrame(this.#videoStandard));
377
+ if (baseline !== undefined) this.#baselines.set(baseline, shot);
378
+ const result: ViceOperations["screenCapture"]["result"] = {
379
+ width: shot.frame.width,
380
+ height: shot.frame.height,
381
+ png: encodePng(shot.frame, shot.palette).toString("base64"),
382
+ };
383
+ if (baseline !== undefined) result.baseline = baseline;
384
+ return result;
385
+ }
386
+ case "screenCompare":
387
+ return this.#screenCompare(params as ViceOperations["screenCompare"]["params"]);
388
+ case "screenBaselines":
389
+ return { baselines: [...this.#baselines.keys()] };
390
+ case "screenDiscard": {
391
+ const { baseline } = params as ViceOperations["screenDiscard"]["params"];
392
+ if (!this.#baselines.delete(baseline)) throw new WireFailure("not-found", `There is no baseline named ${baseline}.`);
393
+ return { discarded: true };
394
+ }
395
+ case "snapshot":
396
+ return this.#snapshot(params as ViceOperations["snapshot"]["params"]);
397
+ case "cpuHistory":
398
+ return this.#cpuHistory(params as ViceOperations["cpuHistory"]["params"]);
399
+ case "backtrace": {
400
+ const { depth, space } = params as ViceOperations["backtrace"]["params"];
401
+ return this.#observe(async () => {
402
+ const { sp } = await this.#machine.readRegisters(space);
403
+ return { frames: backtraceFromStack(sp, await this.#machine.readAll(space), depth) };
404
+ });
405
+ }
406
+ case "timing": {
407
+ const { action } = params as ViceOperations["timing"]["params"];
408
+ const now = await this.#observe(() => this.#machine.clock());
409
+ if (action === "start") {
410
+ this.#timingStart = now;
411
+ return { started: true };
412
+ }
413
+ return { cycles: (now - this.#timingStart).toString() };
414
+ }
415
+ case "profile": {
416
+ const { limit } = params as ViceOperations["profile"]["params"];
417
+ return { entries: await this.#observe(() => this.#machine.profile(limit)) };
418
+ }
419
+ case "memmap": {
420
+ const request = params as ViceOperations["memmap"]["params"];
421
+ if (request.action === "clear") {
422
+ await this.#observe(() => this.#machine.clearMemmap());
423
+ return { cleared: true };
424
+ }
425
+ return { ranges: await this.#observe(() => this.#machine.memmap(request.start, request.end, request.maxRanges)) };
426
+ }
427
+ case "vicii":
428
+ return this.#observe(() => this.#vicii());
429
+ case "sprites": {
430
+ const { indexes } = params as ViceOperations["sprites"]["params"];
431
+ return this.#observe(async () => ({ sprites: await this.#sprites(indexes) }));
432
+ }
433
+ case "cia": {
434
+ const { which } = params as ViceOperations["cia"]["params"];
435
+ return this.#observe(async () => ({ chips: await this.#cias(which) }));
436
+ }
437
+ case "sid":
438
+ return this.#observe(() => this.#sid());
439
+ case "observe":
440
+ return this.#observeAll(params as ObserveParams);
441
+ case "joystick": {
442
+ const joystick = params as JoystickState;
443
+ await this.#observe(() => this.#machine.setJoystick(joystick));
444
+ this.#joysticks.set(joystick.port, joystick);
445
+ return joystick;
446
+ }
447
+ case "warp": {
448
+ const { enabled } = params as ViceOperations["warp"]["params"];
449
+ await this.#observe(() => this.#machine.setWarp(enabled));
450
+ return { enabled };
451
+ }
452
+ case "window":
453
+ return this.#window((params as ViceOperations["window"]["params"]).action === "open" ? "window" : "headless");
454
+ }
455
+ throw new WireFailure("invalid-input", `unknown operation: ${String(op)}`);
456
+ }
457
+
458
+ async #status(): Promise<MachineStatus> {
459
+ const warp = await this.#observe(() => this.#machine.warp());
460
+ // Read after #observe: it resumes a running machine, unless a breakpoint or a JAM stopped it meanwhile.
461
+ const status: MachineStatus = { state: this.#state, videoStandard: this.#videoStandard, warp, window: this.#mode === "window" };
462
+ if (status.state === "stopped") status.pc = await this.#pc();
463
+ return status;
464
+ }
465
+
466
+ async #execution(params: ExecutionParams): Promise<ExecutionResult> {
467
+ if (params.action === "resume") {
468
+ if (this.#state === "stopped") await this.#resume();
469
+ return { state: "running" };
470
+ }
471
+ if (params.action === "pause") {
472
+ await this.#stop();
473
+ // A drive checkpoint can stop the machine just before the pause does.
474
+ return { state: "stopped", pc: (await this.#completeDriveStop()) ?? (await this.#pc()) };
475
+ }
476
+ if (params.space === "drive8") {
477
+ // Stock VICE runs the 1541 CPU in batches that catch up with the computer's clock;
478
+ // neither monitor can stop it after exactly one of its instructions.
479
+ throw new WireFailure(
480
+ "unsupported-in-space",
481
+ `${params.action} works only in space c64: the emulator cannot stop the disk drive CPU after a single instruction. ` +
482
+ "Use a breakpoint or run-until with an address in space drive8: it stops when the drive executes that address, " +
483
+ "and the drive can run a few instructions more before it stops.",
484
+ );
485
+ }
486
+ if (params.action === "advance-frames") {
487
+ if (params.count === undefined) throw new WireFailure("invalid-input", "advance-frames needs count, the number of frames");
488
+ const outcome = await this.#runFrames(params.count);
489
+ return { state: "stopped", pc: outcome.pc, advancedFrames: outcome.frames };
490
+ }
491
+ await this.#stop();
492
+ if (params.action === "until-return") {
493
+ const stopped = this.#nextStop(this.#untilReturnLimitMs);
494
+ await this.#resumingCommand(() => this.#machine.untilReturn());
495
+ const stop = await stopped;
496
+ if (stop === undefined) {
497
+ await this.#stop();
498
+ throw new WireFailure(
499
+ "limit-exceeded",
500
+ `The routine did not return within ${this.#untilReturnLimitMs / 1000} seconds. The machine is stopped at ${formatC64Address(await this.#pc())}.`,
501
+ );
502
+ }
503
+ return { state: "stopped", pc: stop.pc };
504
+ }
505
+ const count = params.count ?? 1;
506
+ try {
507
+ await this.#machine.step(count, params.action === "next", this.#stepLimitMs);
508
+ } catch (error) {
509
+ if (error instanceof MonitorTimeoutError) throw new StepTimeout(stepNotEnded(params.action, count, this.#stepLimitMs), error);
510
+ throw error;
511
+ }
512
+ // VICE answers a step only after the machine stopped again.
513
+ this.#state = "stopped";
514
+ return { state: "stopped", pc: await this.#pc(), executed: count };
515
+ }
516
+
517
+ async #reset(params: { mode: "soft" | "hard"; run: boolean }): Promise<{ state: RunState; pc?: number }> {
518
+ await this.#stop();
519
+ if (params.run) {
520
+ await this.#runningCommand(() => this.#machine.reset(params.mode));
521
+ return { state: "running" };
522
+ }
523
+ // A breakpoint on the reset vector stops the reset at its first instruction.
524
+ // VICE does not check a breakpoint at the PC it resumes from, and a reset
525
+ // sent from the monitor resumes at the vector; so the reset is sent while
526
+ // running, and only a stop caused by this breakpoint counts.
527
+ const vector = await this.#machine.resetVector();
528
+ const checkpoint = await this.#machine.addCheckpoint({ start: vector, end: vector, operation: 0x04, space: "c64" });
529
+ await this.#resume();
530
+ const stopped = this.#nextStop(RESET_STOP_LIMIT_MS, checkpoint);
531
+ await this.#resumingCommand(() => this.#machine.reset(params.mode));
532
+ const reached = (await stopped) !== undefined;
533
+ if (!reached) await this.#stop();
534
+ await this.#machine.deleteCheckpoint(checkpoint);
535
+ if (!reached) throw new WireFailure("operation-failed", "The C64 did not reach its reset vector after the reset.");
536
+ return { state: "stopped", pc: vector };
537
+ }
538
+
539
+ /**
540
+ * Runs the machine for up to `limit` frames, counted from where it stands:
541
+ * frame k ends at the first instruction at or after the start raster
542
+ * position in the k-th frame after it. One internal exec checkpoint over
543
+ * all of memory alternates between an arming window half a frame away and
544
+ * the end-of-frame window, so the count needs no wall clock. A `target`
545
+ * checkpoint, a user breakpoint or watchpoint, or a JAM ends the run early.
546
+ * The machine is stopped afterwards.
547
+ */
548
+ async #runFrames(limit: number, target?: { checkpoint: number; deferred: boolean }): Promise<{ end: RunEnd; frames: number; pc: number }> {
549
+ await this.#stop();
550
+ const lines = RASTER[this.#videoStandard].lines;
551
+ const start = await this.#machine.rasterPosition();
552
+ const arming = rasterLinesExpression((start.line + Math.floor(lines / 2)) % lines, lines);
553
+ const ending = rasterWindowExpression(start.line, start.cycle, lines);
554
+ const clock = await this.#machine.addCheckpoint({ start: 0x0000, end: 0xffff, operation: 0x04, space: "c64" });
555
+ try {
556
+ await this.#machine.setCondition(clock, arming);
557
+ let armingPhase = true;
558
+ let frames = 0;
559
+ for (;;) {
560
+ const next = this.#nextStop(HALF_FRAME_LIMIT_MS);
561
+ await this.#resume();
562
+ const stop = await next;
563
+ if (stop === undefined) {
564
+ await this.#stop();
565
+ throw new WireFailure("operation-failed", "The emulator did not reach the next frame in time.");
566
+ }
567
+ const end = this.#stopCause(stop, clock, target?.checkpoint);
568
+ if (end === "clock") {
569
+ if (armingPhase) {
570
+ await this.#machine.setCondition(clock, ending);
571
+ if (target?.deferred) {
572
+ await this.#machine.toggleCheckpoint(target.checkpoint, true);
573
+ target.deferred = false;
574
+ }
575
+ } else {
576
+ frames++;
577
+ if (frames >= limit) return { end: "frames", frames, pc: stop.pc };
578
+ await this.#machine.setCondition(clock, arming);
579
+ }
580
+ armingPhase = !armingPhase;
581
+ continue;
582
+ }
583
+ if (end === "unknown") {
584
+ if (await this.#jammedAt(stop.pc)) return { end: "jam", frames, pc: stop.pc };
585
+ throw new WireFailure("operation-failed", "The emulator stopped the machine for a reason outside this run.");
586
+ }
587
+ // A drive stop is completed before the result names the computer's pc.
588
+ return { end, frames, pc: (await this.#completeDriveStop()) ?? stop.pc };
589
+ }
590
+ } finally {
591
+ await this.#machine.deleteCheckpoint(clock);
592
+ }
593
+ }
594
+
595
+ /**
596
+ * Completes a stop that a drive 8 checkpoint caused: the computer runs to
597
+ * its next instruction boundary, where a computer checkpoint stops it, so
598
+ * VICE enters the monitor from the computer's CPU. The drive runs those few
599
+ * cycles too. Returns the computer's pc, or undefined when there was no
600
+ * drive stop.
601
+ */
602
+ async #completeDriveStop(): Promise<number | undefined> {
603
+ if (!this.#driveStop) return undefined;
604
+ this.#driveStop = false;
605
+ const boundary = await this.#machine.addCheckpoint({ start: 0x0000, end: 0xffff, operation: 0x04, space: "c64" });
606
+ try {
607
+ for (let attempt = 0; attempt < DRIVE_STOP_TRIES; attempt++) {
608
+ const next = this.#nextStop(HALF_FRAME_LIMIT_MS);
609
+ await this.#resume();
610
+ const stop = await next;
611
+ if (stop === undefined) break;
612
+ // The drive can hit its checkpoint again before the computer gets to the next instruction.
613
+ if (stop.hits.includes(boundary)) {
614
+ this.#driveStop = false;
615
+ trace().event("vice.drive-stop", { pc: stop.pc, resumes: attempt + 1 });
616
+ return stop.pc;
617
+ }
618
+ }
619
+ await this.#stop();
620
+ throw new WireFailure("operation-failed", "The computer did not get to its next instruction after a stop in the disk drive.");
621
+ } finally {
622
+ await this.#machine.deleteCheckpoint(boundary);
623
+ }
624
+ }
625
+
626
+ /** Names what caused a stop during a frame-clocked run. */
627
+ #stopCause(stop: Stop, clock: number, target: number | undefined): Exclude<RunEnd, "frames" | "jam"> | "clock" | "unknown" {
628
+ if (target !== undefined && stop.hits.includes(target)) return "target";
629
+ const isPoint = (registry: Map<number, { checkpoint: number }>) =>
630
+ [...registry.values()].some((point) => stop.hits.includes(point.checkpoint));
631
+ if (isPoint(this.#breakpoints)) return "breakpoint";
632
+ if (isPoint(this.#watchpoints)) return "watchpoint";
633
+ if (stop.hits.includes(clock)) return "clock";
634
+ return "unknown";
635
+ }
636
+
637
+ async #jammedAt(pc: number): Promise<boolean> {
638
+ const opcode = await this.#machine.readMemory({ address: pc, size: 1, space: "c64", view: "cpu" });
639
+ return JAM_OPCODES.has(Number.parseInt(opcode, 16));
640
+ }
641
+
642
+ /** Runs until a target is reached or `timeoutFrames` frames pass. */
643
+ async #runUntil(params: { target: RunTarget; timeoutFrames: number }): Promise<RunUntilResult> {
644
+ const target = params.target;
645
+ let expression: string | undefined;
646
+ let range: { start: number; end: number; operation: number; space: "c64" | "drive8" };
647
+ let deferred = false;
648
+ // The target is checked before the machine stops: a refused target leaves the run state as it was.
649
+ switch (target.kind) {
650
+ case "address":
651
+ range = { start: target.address, end: target.address, operation: 0x04, space: target.space };
652
+ if (target.condition !== undefined) expression = this.#expression(target.condition, target.space);
653
+ break;
654
+ case "memory": {
655
+ // A store checkpoint whose condition reads memory after the write.
656
+ range = { start: target.address, end: target.address, operation: 0x02, space: target.space };
657
+ const { kind: _kind, ...condition } = target;
658
+ expression = this.#expression({ kind: "memory", ...condition }, target.space);
659
+ break;
660
+ }
661
+ case "raster": {
662
+ const raster = RASTER[this.#videoStandard];
663
+ this.#expression(target, "c64");
664
+ const cycle = target.cycle ?? 0;
665
+ range = { start: 0x0000, end: 0xffff, operation: 0x04, space: "c64" };
666
+ expression = rasterWindowExpression(target.line, cycle, raster.lines);
667
+ break;
668
+ }
669
+ }
670
+ await this.#stop();
671
+ if (target.kind === "raster") {
672
+ // Already inside the target window: the next pass through it counts, not this one.
673
+ deferred = inRasterWindow(await this.#machine.rasterPosition(), target.line, target.cycle ?? 0, RASTER[this.#videoStandard].lines);
674
+ }
675
+ const checkpoint = await this.#machine.addCheckpoint({ ...range, enabled: !deferred });
676
+ try {
677
+ if (expression !== undefined) await this.#machine.setCondition(checkpoint, expression);
678
+ const outcome = await this.#runFrames(params.timeoutFrames, { checkpoint, deferred });
679
+ const stopReason = outcome.end === "frames" ? "timeout" : outcome.end;
680
+ // A stop event carries the computer's PC; a drive target reports the drive's.
681
+ const pc = target.kind !== "raster" && target.space === "drive8" ? (await this.#machine.readRegisters("drive8")).pc : outcome.pc;
682
+ return { reached: stopReason === "target", stopReason, state: "stopped", pc };
683
+ } finally {
684
+ await this.#machine.deleteCheckpoint(checkpoint);
685
+ }
686
+ }
687
+
688
+ /** Checks a condition against this session's video standard and builds its VICE expression. */
689
+ #expression(condition: Condition, space: "c64" | "drive8"): string {
690
+ if (condition.kind === "raster") {
691
+ const raster = RASTER[this.#videoStandard];
692
+ if (condition.line >= raster.lines) {
693
+ throw new WireFailure("invalid-input", `A ${this.#videoStandard.toUpperCase()} frame has raster lines 0 to ${raster.lines - 1}.`);
694
+ }
695
+ if (condition.cycle !== undefined && condition.cycle >= raster.cycles) {
696
+ throw new WireFailure("invalid-input", `A ${this.#videoStandard.toUpperCase()} raster line has cycles 0 to ${raster.cycles - 1}.`);
697
+ }
698
+ }
699
+ return conditionExpression(condition, space);
700
+ }
701
+
702
+ /** Adds a checkpoint with an optional condition; removes it again if the condition is refused. */
703
+ async #addPoint(options: {
704
+ start: number;
705
+ end: number;
706
+ operation: number;
707
+ space: "c64" | "drive8";
708
+ condition?: Condition;
709
+ }): Promise<{ checkpoint: number; expression?: string }> {
710
+ const expression = options.condition === undefined ? undefined : this.#expression(options.condition, options.space);
711
+ const checkpoint = await this.#observe(() => setPoint(this.#machine, { ...options, enabled: true, expression }));
712
+ return expression === undefined ? { checkpoint } : { checkpoint, expression };
713
+ }
714
+
715
+ /** remove/enable/disable on a registry of points; `kind` names it in messages. */
716
+ async #changePoint<T extends { checkpoint: number; enabled: boolean }>(
717
+ registry: Map<number, T>,
718
+ kind: string,
719
+ params: { action: "remove" | "enable" | "disable"; id: number },
720
+ ): Promise<T> {
721
+ const point = registry.get(params.id);
722
+ if (point === undefined) throw new WireFailure("not-found", `There is no ${kind} with id ${params.id}.`);
723
+ if (params.action === "remove") {
724
+ await this.#observe(() => this.#machine.deleteCheckpoint(point.checkpoint));
725
+ registry.delete(params.id);
726
+ } else {
727
+ const enabled = params.action === "enable";
728
+ await this.#observe(() => this.#machine.toggleCheckpoint(point.checkpoint, enabled));
729
+ point.enabled = enabled;
730
+ }
731
+ return point;
732
+ }
733
+
734
+ async #breakpoint(params: BreakpointParams): Promise<Breakpoint | { breakpoints: Breakpoint[] }> {
735
+ const publicView = ({ checkpoint: _checkpoint, expression: _expression, ...point }: Breakpoint & { checkpoint: number; expression?: string }): Breakpoint =>
736
+ point;
737
+ if (params.action === "list") return { breakpoints: [...this.#breakpoints.values()].map(publicView) };
738
+ if (params.action !== "add") return publicView(await this.#changePoint(this.#breakpoints, "breakpoint", params));
739
+ const added = await this.#addPoint({
740
+ ...pointRange({ address: params.address }),
741
+ space: params.space,
742
+ ...(params.condition === undefined ? {} : { condition: params.condition }),
743
+ });
744
+ const point = {
745
+ id: this.#nextPointId++,
746
+ address: params.address,
747
+ space: params.space,
748
+ enabled: true,
749
+ ...(params.condition === undefined ? {} : { condition: params.condition }),
750
+ ...added,
751
+ };
752
+ this.#breakpoints.set(point.id, point);
753
+ return publicView(point);
754
+ }
755
+
756
+ async #watchpoint(params: WatchpointParams): Promise<Watchpoint | { watchpoints: Watchpoint[] }> {
757
+ const publicView = ({ checkpoint: _checkpoint, expression: _expression, ...point }: Watchpoint & { checkpoint: number; expression?: string }): Watchpoint =>
758
+ point;
759
+ if (params.action === "list") return { watchpoints: [...this.#watchpoints.values()].map(publicView) };
760
+ if (params.action !== "add") return publicView(await this.#changePoint(this.#watchpoints, "watchpoint", params));
761
+ const added = await this.#addPoint({
762
+ ...pointRange({ address: params.address, size: params.size, access: params.access }),
763
+ space: params.space,
764
+ ...(params.condition === undefined ? {} : { condition: params.condition }),
765
+ });
766
+ const point = {
767
+ id: this.#nextPointId++,
768
+ address: params.address,
769
+ size: params.size,
770
+ access: params.access,
771
+ space: params.space,
772
+ enabled: true,
773
+ ...(params.condition === undefined ? {} : { condition: params.condition }),
774
+ ...added,
775
+ };
776
+ this.#watchpoints.set(point.id, point);
777
+ return publicView(point);
778
+ }
779
+
780
+ /**
781
+ * Writes transferred bytes into the session directory. VICE reads an
782
+ * attached image from its file, so an image stays until another one
783
+ * replaces it (#replaceStaged).
784
+ */
785
+ #stage(bytes: Buffer, type: string): string {
786
+ this.#staged++;
787
+ const file = join(this.#workDir, `media-${this.#staged}${type === "" ? "" : `.${type}`}`);
788
+ writeFileSync(file, bytes);
789
+ return file;
790
+ }
791
+
792
+ /**
793
+ * Records the staged file that VICE took for `kind`, and removes the file
794
+ * it replaces: VICE does not read that one again. A file that VICE did not
795
+ * take stays until the session closes, because a held VICE can still read it.
796
+ */
797
+ #replaceStaged(kind: "autostart" | "disk", file: string): void {
798
+ const previous = this.#stagedFiles.get(kind);
799
+ this.#stagedFiles.set(kind, file);
800
+ if (previous !== undefined) rmSync(previous, { force: true });
801
+ }
802
+
803
+ /** Loads a PRG without reset or start; finishes stopped. */
804
+ async #programLoad(params: { address?: number }, bytes: Buffer): Promise<{ state: RunState; loadAddress: number; size: number }> {
805
+ if (bytes.length < 3) throw new WireFailure("invalid-input", "A PRG file needs a two-byte load address and at least one byte of data.");
806
+ const loadAddress = params.address ?? bytes.readUInt16LE(0);
807
+ const size = bytes.length - 2;
808
+ if (loadAddress + size > 0x10000) {
809
+ throw new WireFailure("invalid-input", "The program would run past $ffff at that load address.");
810
+ }
811
+ const file = this.#stage(bytes, "prg");
812
+ await this.#stop();
813
+ const loaded = await this.#machine.loadProgram(file, params.address);
814
+ // VICE read the file during the load.
815
+ rmSync(file, { force: true });
816
+ return { state: "stopped", ...loaded };
817
+ }
818
+
819
+ /**
820
+ * The last instructions a CPU executed, oldest first. For the C64 each entry
821
+ * gets the raster position it started at: the cycle distance from the current
822
+ * stop, whose position is known, mapped back through the frame.
823
+ */
824
+ async #cpuHistory(params: { limit: number; space: "c64" | "drive8" }): Promise<{ entries: HistoryEntry[] }> {
825
+ return this.#observe(async () => {
826
+ const raw = await this.#machine.history(params.limit, params.space);
827
+ if (params.space === "drive8") return { entries: raw.map(({ clock: _clock, ...entry }) => entry) };
828
+ const now = await this.#machine.clock();
829
+ const position = await this.#machine.rasterPosition();
830
+ const { lines, cycles } = RASTER[this.#videoStandard];
831
+ const frame = BigInt(lines * cycles);
832
+ const current = BigInt(position.line * cycles + position.cycle);
833
+ const entries = raw.map(({ clock, ...entry }) => {
834
+ const at = Number((((current - (now - clock)) % frame) + frame) % frame);
835
+ return { ...entry, rasterLine: Math.floor(at / cycles), rasterCycle: at % cycles };
836
+ });
837
+ return { entries };
838
+ });
839
+ }
840
+
841
+ // Chip reads. Each runs inside an #observe, so several can share one stop.
842
+
843
+ async #vicii(): Promise<ViciiState> {
844
+ return decodeVicii(await this.#machine.readIo(0xd000, 0x2f), await this.#machine.readIo(0xdd00, 0x10));
845
+ }
846
+
847
+ /** Empty `indexes` means all eight sprites. */
848
+ async #sprites(indexes: number[]): Promise<SpriteState[]> {
849
+ const vic = await this.#machine.readIo(0xd000, 0x2f);
850
+ const cia2 = await this.#machine.readIo(0xdd00, 0x10);
851
+ // Sprite pointers sit in the RAM the VIC-II reads, at the end of screen memory.
852
+ const screen = decodeVicii(vic, cia2).screenAddress;
853
+ const pointers = Buffer.from(await this.#machine.readMemory({ address: screen + 0x3f8, size: 8, space: "c64", view: "ram" }), "hex");
854
+ const wanted = indexes.length === 0 ? [0, 1, 2, 3, 4, 5, 6, 7] : indexes;
855
+ return wanted.map((index) => decodeSprite(index, vic, cia2, pointers));
856
+ }
857
+
858
+ async #cias(which: CiaSelection): Promise<CiaState[]> {
859
+ const ids: Array<1 | 2> = which === "both" ? [1, 2] : [Number(which) as 1 | 2];
860
+ const chips = [];
861
+ for (const id of ids) chips.push(decodeCia(id, await this.#machine.readIo(id === 1 ? 0xdc00 : 0xdd00, 0x10)));
862
+ return chips;
863
+ }
864
+
865
+ async #sid(): Promise<SidState> {
866
+ return decodeSid(await this.#machine.readIo(0xd400, 0x19));
867
+ }
868
+
869
+ /** Several observations from one halted moment; the run state is restored afterwards. */
870
+ async #observeAll(params: ObserveParams): Promise<ObserveResult> {
871
+ return this.#observe(async () => {
872
+ const result: ObserveResult = {};
873
+ if (params.registers !== undefined) result.registers = await this.#machine.readRegisters(params.registers);
874
+ if (params.memory !== undefined) {
875
+ result.memory = [];
876
+ for (const range of params.memory) result.memory.push({ address: range.address, data: await this.#machine.readMemory(range) });
877
+ }
878
+ if (params.vicii) result.vicii = await this.#vicii();
879
+ if (params.sprites !== undefined) result.sprites = await this.#sprites(params.sprites);
880
+ if (params.cia !== undefined) result.cia = await this.#cias(params.cia);
881
+ if (params.sid) result.sid = await this.#sid();
882
+ if (params.timing) {
883
+ const position = await this.#machine.rasterPosition();
884
+ result.timing = { rasterLine: position.line, rasterCycle: position.cycle };
885
+ }
886
+ if (params.screen) {
887
+ const shot = await this.#machine.captureFrame(this.#videoStandard);
888
+ result.screen = { width: shot.frame.width, height: shot.frame.height, png: encodePng(shot.frame, shot.palette).toString("base64") };
889
+ }
890
+ return result;
891
+ });
892
+ }
893
+
894
+ /** Saves, restores, lists and discards machine snapshots. */
895
+ async #snapshot(params: ViceOperations["snapshot"]["params"]): Promise<ViceOperations["snapshot"]["result"]> {
896
+ if (params.action === "list") return { snapshots: [...this.#snapshots.keys()] };
897
+ const existing = this.#snapshots.get(params.name);
898
+ if (params.action === "save") {
899
+ if (existing === undefined && this.#snapshots.size >= MAX_SNAPSHOTS) {
900
+ throw new WireFailure("limit-exceeded", `A session keeps at most ${MAX_SNAPSHOTS} snapshots. Discard one first.`);
901
+ }
902
+ const file = existing ?? join(this.#workDir, `snapshot-${++this.#snapshotFiles}.vsf`);
903
+ await this.#observe(() => this.#machine.saveSnapshot(file));
904
+ this.#snapshots.set(params.name, file);
905
+ return { saved: true, name: params.name };
906
+ }
907
+ if (existing === undefined) throw new WireFailure("not-found", `There is no snapshot named ${params.name}.`);
908
+ if (params.action === "discard") {
909
+ rmSync(existing, { force: true });
910
+ this.#snapshots.delete(params.name);
911
+ return { discarded: true };
912
+ }
913
+ await this.#stop();
914
+ const elapsed = (await this.#machine.clock()) - this.#timingStart;
915
+ await this.#machine.restoreSnapshot(existing);
916
+ this.#state = "stopped";
917
+ // The cycle clock jumped with the restore; the stopwatch keeps its reading, as a window move does.
918
+ this.#timingStart = (await this.#machine.clock()) - elapsed;
919
+ return { restored: true, state: "stopped" };
920
+ }
921
+
922
+ /**
923
+ * Moves the machine into a VICE of the other mode: a snapshot of this
924
+ * one, restored in a new one, with the user points, joysticks, warp and the
925
+ * stopwatch set again. The snapshot carries the disk in drive 8: VICE saves
926
+ * its data and restores it into the new drive. The image file
927
+ * is never attached on top of a restore: VICE would write the restored disk
928
+ * into that file. The old VICE stays as it was until the new one is ready.
929
+ */
930
+ async #window(mode: ViceMode): Promise<WindowResult> {
931
+ // Already in that mode: done, and VICE is not asked anything.
932
+ if (this.#mode === mode) return { window: mode === "window", state: this.#state, notCarried: [] };
933
+ if (this.#held !== undefined) throw new WireFailure("machine-unavailable", this.#heldWhy);
934
+ if (this.#launch === undefined) throw new WireFailure("operation-failed", "This session cannot start another emulator.");
935
+ const running = this.#state === "running";
936
+ await this.#stop();
937
+ const handover: Handover = {
938
+ file: join(this.#workDir, mode === "window" ? "window-open.vsf" : "window-close.vsf"),
939
+ running,
940
+ warp: await this.#machine.warp(),
941
+ elapsed: (await this.#machine.clock()) - this.#timingStart,
942
+ };
943
+ await this.#machine.saveSnapshot(handover.file);
944
+ const old = this.#vice;
945
+ try {
946
+ await this.#install(this.#launch, mode, handover);
947
+ } catch (error) {
948
+ if (running && !this.#closed) await this.#resume().catch(() => {});
949
+ throw error;
950
+ }
951
+ await old.stop().catch((error: unknown) => this.#log(`stopping the earlier VICE failed: ${error instanceof Error ? error.message : String(error)}`));
952
+ if (mode === "window") {
953
+ this.#handover = handover;
954
+ } else {
955
+ this.#handover = undefined;
956
+ for (const name of ["window-open.vsf", "window-close.vsf"]) rmSync(join(this.#workDir, name), { force: true });
957
+ }
958
+ if (running) await this.#resume();
959
+ return { window: mode === "window", state: this.#state, notCarried: NOT_CARRIED };
960
+ }
961
+
962
+ /**
963
+ * Starts a VICE in `mode` with `launch`, restores `handover` into it and sets the session
964
+ * state again, then makes it the current VICE. On failure the new VICE is
965
+ * stopped, the current one is untouched, and the error says why.
966
+ */
967
+ async #install(launch: ViceLauncher, mode: ViceMode, handover: Handover): Promise<void> {
968
+ if (this.#closed) throw new WireFailure("machine-unavailable", "The emulator session is closed.");
969
+ const output: string[] = [];
970
+ let vice: ViceProcess;
971
+ try {
972
+ vice = await launch(mode, (line) => output.push(line));
973
+ } catch (error) {
974
+ const reasons = startErrorLines(output);
975
+ const what = mode === "window" ? "VICE with a window" : "a headless VICE";
976
+ throw new WireFailure(
977
+ error instanceof WireFailure && error.code === "installation-incomplete" ? "installation-incomplete" : "machine-unavailable",
978
+ `The host could not start ${what}.${reasons.length === 0 ? "" : `\n ${reasons.join("\n ")}`}\n` +
979
+ `The machine stays ${this.#mode === "window" ? "in the window" : "headless"}, as it was.`,
980
+ );
981
+ }
982
+ this.#watch(vice);
983
+ try {
984
+ if (this.#closed) throw new WireFailure("machine-unavailable", "The emulator session is closed.");
985
+ const adapter = await ViceAdapter.create(vice.monitor, vice.text);
986
+ await adapter.restoreSnapshot(handover.file);
987
+ const points: Array<[PointRecord, number]> = [];
988
+ for (const point of [...this.#breakpoints.values(), ...this.#watchpoints.values()]) {
989
+ points.push([point, await setPoint(adapter, { ...pointRange(point), space: point.space, enabled: point.enabled, expression: point.expression })]);
990
+ }
991
+ for (const joystick of this.#joysticks.values()) await adapter.setJoystick(joystick);
992
+ await adapter.setWarp(handover.warp);
993
+ await adapter.startProfiler();
994
+ const clock = await adapter.clock();
995
+ if (this.#closed) throw new WireFailure("machine-unavailable", "The emulator session is closed.");
996
+ this.#vice = vice;
997
+ this.#adapter = adapter;
998
+ this.#mode = mode;
999
+ for (const [point, checkpoint] of points) point.checkpoint = checkpoint;
1000
+ this.#timingStart = clock - handover.elapsed;
1001
+ this.#checkpointHits = [];
1002
+ this.#state = "stopped";
1003
+ } catch (error) {
1004
+ await vice.stop().catch(() => {});
1005
+ if (error instanceof WireFailure) throw error;
1006
+ this.#log(`moving the machine to a new VICE failed: ${error instanceof Error ? error.message : String(error)}\n${vice.outputTail()}`);
1007
+ throw new WireFailure(
1008
+ "operation-failed",
1009
+ `The new emulator did not take the machine state (${error instanceof Error ? error.message : String(error)}). ` +
1010
+ `The machine stays ${this.#mode === "window" ? "in the window" : "headless"}, as it was.`,
1011
+ );
1012
+ }
1013
+ }
1014
+
1015
+ /** Compares the current frame with a baseline. */
1016
+ async #screenCompare(params: ViceOperations["screenCompare"]["params"]): Promise<ScreenComparison> {
1017
+ const baseline = this.#baselines.get(params.baseline);
1018
+ if (baseline === undefined) throw new WireFailure("not-found", `There is no baseline named ${params.baseline}.`);
1019
+ const current = await this.#observe(() => this.#machine.captureFrame(this.#videoStandard));
1020
+ const difference = compareFrames(baseline.frame, current.frame, params.mask);
1021
+ const mismatchRatio = difference.comparedPixels === 0 ? 0 : difference.mismatchingPixels / difference.comparedPixels;
1022
+ const result: ScreenComparison = {
1023
+ match: mismatchRatio <= params.maxMismatchRatio,
1024
+ mismatchingPixels: difference.mismatchingPixels,
1025
+ mismatchRatio,
1026
+ };
1027
+ if (difference.bounds !== undefined) result.bounds = difference.bounds;
1028
+ if (params.includeDiff) {
1029
+ const image = differenceImage(current.frame, difference, current.palette);
1030
+ result.diffPng = encodePng(image.frame, image.palette).toString("base64");
1031
+ }
1032
+ return result;
1033
+ }
1034
+
1035
+ /** Compares two memory ranges from one coherent stop, in any spaces and views. */
1036
+ async #memoryCompare(params: ViceOperations["memoryCompare"]["params"]): Promise<ViceOperations["memoryCompare"]["result"]> {
1037
+ const [left, right] = await this.#observe(async (): Promise<[Buffer, Buffer]> => [
1038
+ Buffer.from(await this.#machine.readMemory({ ...params.left, size: params.size }), "hex"),
1039
+ Buffer.from(await this.#machine.readMemory({ ...params.right, size: params.size }), "hex"),
1040
+ ]);
1041
+ const firstDifferences: Array<{ offset: number; left: number; right: number }> = [];
1042
+ let differentBytes = 0;
1043
+ for (let offset = 0; offset < params.size; offset++) {
1044
+ if (left[offset] === right[offset]) continue;
1045
+ differentBytes++;
1046
+ if (firstDifferences.length < MAX_COMPARE_DIFFERENCES) firstDifferences.push({ offset, left: left[offset]!, right: right[offset]! });
1047
+ }
1048
+ return { equal: differentBytes === 0, differentBytes, firstDifferences };
1049
+ }
1050
+
1051
+ /** Direct CPU-state writes need a stopped CPU; they never pause it themselves. */
1052
+ #requireStopped(): void {
1053
+ if (this.#state === "running") {
1054
+ throw new WireFailure("machine-running", "The CPU is running. Stop it with c64_execution action pause first.");
1055
+ }
1056
+ }
1057
+
1058
+ /** The C64 program counter; the machine must be stopped. */
1059
+ async #pc(): Promise<number> {
1060
+ return (await this.#machine.readRegisters("c64")).pc;
1061
+ }
1062
+
1063
+ async #stop(): Promise<void> {
1064
+ if (this.#state === "running") await this.#machine.stop();
1065
+ this.#state = "stopped";
1066
+ }
1067
+
1068
+ async #resume(): Promise<void> {
1069
+ await this.#resumingCommand(() => this.#machine.resume());
1070
+ }
1071
+
1072
+ /**
1073
+ * Sends a command that makes VICE run the machine. The state is set first:
1074
+ * VICE's answer and the events after it (resumed, then maybe a stop) can
1075
+ * arrive together, and those events must have the last word.
1076
+ */
1077
+ async #resumingCommand(send: () => Promise<void>): Promise<void> {
1078
+ this.#state = "running";
1079
+ try {
1080
+ await send();
1081
+ } catch (error) {
1082
+ this.#state = "stopped";
1083
+ throw error;
1084
+ }
1085
+ }
1086
+
1087
+ /**
1088
+ * Sends a command after which VICE leaves the monitor by itself (autostart,
1089
+ * reset). VICE sometimes stays in the monitor instead (after an autostart,
1090
+ * and under load also after a reset), and when it was in the
1091
+ * monitor already no event shows that. So the machine is made to run: a
1092
+ * ping stops it or finds it stopped, and an exit resumes it, unless a
1093
+ * checkpoint or a JAM stopped it in between.
1094
+ */
1095
+ async #runningCommand(send: () => Promise<void>): Promise<void> {
1096
+ const halts = this.#haltCount;
1097
+ await this.#resumingCommand(send);
1098
+ await this.#machine.stop();
1099
+ if (this.#haltCount === halts) await this.#resume();
1100
+ }
1101
+
1102
+ /**
1103
+ * Resolves with the next stop, or undefined after `timeoutMs`.
1104
+ * With `checkpoint`, only a stop caused by that VICE checkpoint counts.
1105
+ */
1106
+ #nextStop(timeoutMs: number, checkpoint?: number): Promise<Stop | undefined> {
1107
+ return new Promise((resolve) => {
1108
+ const waiter = (stop: Stop) => {
1109
+ if (checkpoint !== undefined && !stop.hits.includes(checkpoint)) return false;
1110
+ clearTimeout(timer);
1111
+ resolve(stop);
1112
+ return true;
1113
+ };
1114
+ const timer = setTimeout(() => {
1115
+ this.#stopWaiters = this.#stopWaiters.filter((candidate) => candidate !== waiter);
1116
+ resolve(undefined);
1117
+ }, timeoutMs);
1118
+ this.#stopWaiters.push(waiter);
1119
+ });
1120
+ }
1121
+
1122
+ /**
1123
+ * Runs monitor work that must not change the run state. Any monitor
1124
+ * command stops the machine, so a machine that was running is resumed.
1125
+ */
1126
+ async #observe<T>(work: () => Promise<T>): Promise<T> {
1127
+ const wasRunning = this.#state === "running";
1128
+ const halts = this.#haltCount;
1129
+ // A breakpoint or JAM that stopped the machine while this work ran must
1130
+ // stay stopped: the machine was not running when VICE took the command.
1131
+ // The tracked state is no guide here: a text command's stop is reported on
1132
+ // the binary connection and may not have been read yet.
1133
+ const shouldResume = () => wasRunning && this.#haltCount === halts;
1134
+ // VICE answers binary commands in order, so a ping's answer means every
1135
+ // earlier binary event (a checkpoint hit, a stop) has been read.
1136
+ const settle = async () => {
1137
+ if (wasRunning) await this.#machine.stop();
1138
+ };
1139
+ let result: T;
1140
+ try {
1141
+ result = await work();
1142
+ } catch (error) {
1143
+ // A refused read must not leave a running machine stopped either. After
1144
+ // a timeout nothing more is sent: VICE is held, and #recover restores it.
1145
+ if (wasRunning && !this.#lost && !isTimeout(error)) {
1146
+ await settle()
1147
+ .then(() => (shouldResume() ? this.#resume() : undefined))
1148
+ .catch(() => {});
1149
+ }
1150
+ throw error;
1151
+ }
1152
+ await settle();
1153
+ // A drive checkpoint that stopped the machine meanwhile keeps it stopped:
1154
+ // complete that stop, so the computer's registers read afterwards are at an instruction boundary.
1155
+ if (wasRunning) await this.#completeDriveStop();
1156
+ if (shouldResume()) await this.#resume();
1157
+ return result;
1158
+ }
1159
+
1160
+ /** Serializes all VICE work for this session. `whileHeld` work checks a held VICE itself. */
1161
+ #enqueue<T>(work: () => Promise<T>, options: { whileHeld?: boolean } = {}): Promise<T> {
1162
+ const run = this.#queue.then(async () => {
1163
+ if (this.#closed) throw new WireFailure("machine-unavailable", "The emulator session is closed.");
1164
+ if (this.#lost) throw new WireFailure("machine-state-lost", STATE_LOST);
1165
+ if (this.#windowClosing) throw new WireFailure("machine-unavailable", WINDOW_CLOSING);
1166
+ const notice = this.#notice;
1167
+ this.#notice = undefined;
1168
+ if (notice !== undefined) throw notice;
1169
+ // Nothing is sent to a held VICE: each command would wait there and run later.
1170
+ if (this.#held !== undefined && options.whileHeld !== true) throw new WireFailure("machine-unavailable", this.#heldWhy);
1171
+ const runningBefore = this.#state === "running";
1172
+ const halts = this.#haltCount;
1173
+ try {
1174
+ if (this.#held === undefined) await this.#completeDriveStop();
1175
+ return await work();
1176
+ } catch (error) {
1177
+ throw await this.#translate(error, runningBefore, halts);
1178
+ }
1179
+ });
1180
+ this.#queue = run.catch(() => {});
1181
+ return run;
1182
+ }
1183
+
1184
+ async #translate(error: unknown, runningBefore: boolean, halts: number): Promise<unknown> {
1185
+ if (error instanceof WireFailure) return error;
1186
+ if (this.#closed) return new WireFailure("machine-unavailable", "The emulator session is closed.");
1187
+ if (error instanceof MonitorConnectionError || error instanceof TextMonitorError || error instanceof StepTimeout) {
1188
+ this.#log(error.message);
1189
+ // A broken connection usually means VICE died; give its exit a moment to land.
1190
+ if (!this.#lost && !this.#windowClosing) await Promise.race([this.#vice.exited, sleep(CRASH_SETTLE_MS)]);
1191
+ if (this.#lost) return new WireFailure("machine-state-lost", STATE_LOST);
1192
+ if (this.#windowClosing) return new WireFailure("machine-unavailable", WINDOW_CLOSING);
1193
+ if (isTimeout(error)) {
1194
+ // VICE's own log can tell why it did not answer.
1195
+ const tail = this.#vice.outputTail().split("\n").slice(-10).join("\n ");
1196
+ if (tail.trim() !== "") this.#log(`VICE log after the monitor timeout:\n ${tail}`);
1197
+ if (this.#held === undefined) {
1198
+ this.#heldWhy = error instanceof StepTimeout ? error.why : this.#mode === "window" ? VICE_HELD : VICE_HELD_HEADLESS;
1199
+ }
1200
+ this.#recover(runningBefore, halts);
1201
+ return new WireFailure("machine-unavailable", this.#heldWhy);
1202
+ }
1203
+ return new WireFailure("machine-unavailable", "The emulator did not answer in time.");
1204
+ }
1205
+ if (error instanceof MonitorError) {
1206
+ this.#log(error.message);
1207
+ if (error.errorCode === 0x02) return new WireFailure("unsupported-in-space", "The emulator does not support that memory space.");
1208
+ return new WireFailure("operation-failed", "The emulator refused the operation.");
1209
+ }
1210
+ return error;
1211
+ }
1212
+
1213
+ /**
1214
+ * VICE runs but took no command in time: its own pause or a dialog holds
1215
+ * it. The commands already sent wait in VICE and run once it goes on, and
1216
+ * the last of them leaves the machine stopped. So: wait, with no time limit,
1217
+ * until VICE answers a ping (behind every binary command) and a text command
1218
+ * (behind every text command); then put the machine back as it was before
1219
+ * the operation that timed out. A breakpoint that hit meanwhile keeps it
1220
+ * stopped. Operations fail at once until then and send nothing.
1221
+ */
1222
+ #recover(runningBefore: boolean, halts: number): void {
1223
+ if (this.#held !== undefined) return;
1224
+ this.#log("VICE takes no commands (paused in its window, a dialog is open, or a step does not end); waiting until it goes on");
1225
+ const why = (error: unknown) => (error instanceof Error ? error.message : String(error));
1226
+ this.#held = (async () => {
1227
+ try {
1228
+ try {
1229
+ await this.#machine.answered();
1230
+ await this.#machine.drainText();
1231
+ } catch (error) {
1232
+ if (!this.#closed) this.#log(`VICE did not come back: ${why(error)}`);
1233
+ }
1234
+ if (this.#closed || this.#lost) return;
1235
+ // The ping that answered stopped the machine; put back the run state of before the operation.
1236
+ this.#state = "stopped";
1237
+ if (runningBefore && this.#haltCount === halts) await this.#resume();
1238
+ this.#log(`VICE takes commands again; the machine is ${this.#state} as before`);
1239
+ } catch (error) {
1240
+ if (!this.#closed) this.#log(`restoring the run state failed: ${why(error)}`);
1241
+ } finally {
1242
+ this.#held = undefined;
1243
+ }
1244
+ })();
1245
+ }
1246
+
1247
+ #onEvent(event: MonitorResponse): void {
1248
+ // VICE reports each hit checkpoint (byte 4 set) just before the stop it causes.
1249
+ if (event.type === ResponseType.checkpointInfo && event.body[4] !== 0) this.#checkpointHits.push(event.body.readUInt32LE(0));
1250
+ if (event.type === ResponseType.stopped || event.type === ResponseType.jam) {
1251
+ const stop: Stop = { pc: decodeProgramCounter(event.body), hits: this.#checkpointHits };
1252
+ if (stop.hits.length > 0 || event.type === ResponseType.jam) this.#haltCount++;
1253
+ if (stop.hits.some((hit) => this.#adapter?.isDriveCheckpoint(hit))) this.#driveStop = true;
1254
+ this.#checkpointHits = [];
1255
+ this.#state = "stopped";
1256
+ this.#stopWaiters = this.#stopWaiters.filter((waiter) => !waiter(stop));
1257
+ } else if (event.type === ResponseType.resumed) {
1258
+ this.#state = "running";
1259
+ }
1260
+ }
1261
+ }
1262
+
1263
+ /** The one attachment of an operation that takes a file (see attachmentCount); refuses any other count. */
1264
+ function onlyAttachment(attachments: Buffer[]): Buffer {
1265
+ const [bytes] = attachments;
1266
+ if (bytes === undefined || attachments.length !== 1) throw new WireFailure("invalid-input", "The operation needs exactly one file attachment.");
1267
+ return bytes;
1268
+ }
1269
+
1270
+ /** The VICE checkpoint range and operation of a breakpoint (no size) or a watchpoint. */
1271
+ function pointRange(point: { address: number; size?: number; access?: Watchpoint["access"] }): { start: number; end: number; operation: number } {
1272
+ if (point.size === undefined) return { start: point.address, end: point.address, operation: 0x04 }; // exec
1273
+ const operation = point.access === "read" ? 0x01 : point.access === "write" ? 0x02 : 0x03;
1274
+ return { start: point.address, end: point.address + point.size - 1, operation };
1275
+ }
1276
+
1277
+ /** Sets a user checkpoint with its condition; removes it again if the condition is refused. */
1278
+ async function setPoint(
1279
+ machine: ViceAdapter,
1280
+ options: { start: number; end: number; operation: number; space: "c64" | "drive8"; enabled: boolean; expression: string | undefined },
1281
+ ): Promise<number> {
1282
+ const checkpoint = await machine.addCheckpoint(options);
1283
+ if (options.expression !== undefined) {
1284
+ try {
1285
+ await machine.setCondition(checkpoint, options.expression);
1286
+ } catch (error) {
1287
+ await machine.deleteCheckpoint(checkpoint).catch(() => {});
1288
+ throw error;
1289
+ }
1290
+ }
1291
+ return checkpoint;
1292
+ }
1293
+
1294
+ /** The production factory: launch a headless VICE per session through the host's supervisor. */
1295
+ export function viceSessionFactory(options: {
1296
+ supervisor: ProcessSupervisor;
1297
+ env?: NodeJS.ProcessEnv;
1298
+ log?: (line: string) => void;
1299
+ }): ViceSessionFactory {
1300
+ const log = options.log ?? (() => {});
1301
+ return async ({ videoStandard }) => {
1302
+ const launch: ViceLauncher = (mode, launchLog) =>
1303
+ launchVice({
1304
+ videoStandard,
1305
+ mode,
1306
+ supervisor: options.supervisor,
1307
+ log: (line) => {
1308
+ log(line);
1309
+ launchLog(line);
1310
+ },
1311
+ ...(options.env === undefined ? {} : { env: options.env }),
1312
+ });
1313
+ const vice = await launch("headless", () => {});
1314
+ return ViceSession.start(vice, videoStandard, log, { launch, ownPath: (path) => options.supervisor.ownPath(path) });
1315
+ };
1316
+ }