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

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 (159) 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 +166 -0
  16. package/skills/c64-knowledge/scripts/knowledge.ts +492 -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 +126 -0
  26. package/skills/c64-source/scripts/generate.ts +490 -0
  27. package/skills/c64-source/scripts/loader.ts +571 -0
  28. package/skills/c64-source/scripts/opcodes.ts +61 -0
  29. package/skills/c64-source/scripts/source.ts +595 -0
  30. package/skills/c64-static-analysis/SKILL.md +119 -0
  31. package/skills/c64-static-analysis/scripts/analyze.ts +275 -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-testing/SKILL.md +103 -0
  37. package/skills/c64-testing/references/scenario-format.md +116 -0
  38. package/skills/c64-testing/scripts/checklist.ts +51 -0
  39. package/skills/c64-testing/scripts/run.ts +189 -0
  40. package/skills/c64-testing/scripts/scenario.ts +493 -0
  41. package/skills/c64-testing/scripts/test.ts +65 -0
  42. package/skills/c64-unpacker/SKILL.md +135 -0
  43. package/skills/c64-unpacker/scripts/evidence.ts +207 -0
  44. package/skills/c64-unpacker/scripts/loads.ts +209 -0
  45. package/skills/c64-unpacker/scripts/machine.ts +80 -0
  46. package/skills/c64-unpacker/scripts/stages.ts +130 -0
  47. package/skills/c64-unpacker/scripts/unpack.ts +683 -0
  48. package/src/c64.ts +411 -0
  49. package/src/cli/ap-sdk.ts +44 -0
  50. package/src/cli/cleanup.ts +194 -0
  51. package/src/cli/main.ts +125 -0
  52. package/src/cli/status.ts +61 -0
  53. package/src/host/main.ts +134 -0
  54. package/src/host/server.ts +392 -0
  55. package/src/host/tools/c1541.ts +284 -0
  56. package/src/host/tools/index.ts +35 -0
  57. package/src/host/tools/petcat.ts +77 -0
  58. package/src/host/tools/status.ts +47 -0
  59. package/src/host/tools/version.ts +41 -0
  60. package/src/host/vice/adapter.ts +804 -0
  61. package/src/host/vice/binary-monitor.ts +389 -0
  62. package/src/host/vice/chips.ts +118 -0
  63. package/src/host/vice/connect.ts +34 -0
  64. package/src/host/vice/process.ts +413 -0
  65. package/src/host/vice/screen.ts +184 -0
  66. package/src/host/vice/session.ts +1426 -0
  67. package/src/host/vice/text-monitor.ts +223 -0
  68. package/src/host-client/autostart.ts +55 -0
  69. package/src/host-client/connect.ts +241 -0
  70. package/src/host-client/coverage.ts +63 -0
  71. package/src/host-client/steps.ts +170 -0
  72. package/src/host-client/tools.ts +89 -0
  73. package/src/host-client/transfer.ts +126 -0
  74. package/src/host-client/vice-session.ts +316 -0
  75. package/src/knowledge/database.ts +132 -0
  76. package/src/knowledge/history.ts +163 -0
  77. package/src/knowledge/import.ts +391 -0
  78. package/src/knowledge/read.ts +342 -0
  79. package/src/knowledge/schema.ts +229 -0
  80. package/src/knowledge/write.ts +663 -0
  81. package/src/mcp/main.ts +134 -0
  82. package/src/mcp/output.ts +32 -0
  83. package/src/mcp/server.ts +316 -0
  84. package/src/mcp/tools/debug.ts +327 -0
  85. package/src/mcp/tools/execution.ts +117 -0
  86. package/src/mcp/tools/input.ts +72 -0
  87. package/src/mcp/tools/machine.ts +100 -0
  88. package/src/mcp/tools/media.ts +129 -0
  89. package/src/mcp/tools/memory.ts +250 -0
  90. package/src/mcp/tools/video.ts +271 -0
  91. package/src/native/acme.ts +116 -0
  92. package/src/native/discover.ts +179 -0
  93. package/src/native/dxa.ts +166 -0
  94. package/src/native/ghidra/analyze.ts +86 -0
  95. package/src/native/ghidra/index.ts +173 -0
  96. package/src/native/ghidra/language/c64rt_6510.cspec +34 -0
  97. package/src/native/ghidra/language/c64rt_6510.ldefs +15 -0
  98. package/src/native/ghidra/language/c64rt_6510.pspec +15 -0
  99. package/src/native/ghidra/language/c64rt_6510.slaspec +596 -0
  100. package/src/native/ghidra/scripts/C64Export.java +274 -0
  101. package/src/native/ghidra/scripts/C64Prepare.java +60 -0
  102. package/src/native/local.ts +34 -0
  103. package/src/native/processes.ts +350 -0
  104. package/src/native/run.ts +172 -0
  105. package/src/native/staging.ts +83 -0
  106. package/src/native/status.ts +113 -0
  107. package/src/native/types.ts +257 -0
  108. package/src/native/watchdog.ts +78 -0
  109. package/src/package.ts +21 -0
  110. package/src/platform.ts +441 -0
  111. package/src/project.ts +39 -0
  112. package/src/protocol/framing.ts +132 -0
  113. package/src/protocol/messages.ts +259 -0
  114. package/src/protocol/tools.ts +266 -0
  115. package/src/protocol/vice.ts +1348 -0
  116. package/src/script.ts +60 -0
  117. package/src/time.ts +15 -0
  118. package/src/trace.ts +194 -0
  119. package/THIRD-PARTY-NOTICES.md +0 -26
  120. package/bin/cli.mjs +0 -283
  121. package/skills/acme-build/SKILL.md +0 -260
  122. package/skills/acme-build/scripts/acme.mjs +0 -341
  123. package/skills/acme-build/template.a +0 -53
  124. package/skills/c64-disk-access/SKILL.md +0 -159
  125. package/skills/c64-disk-access/scripts/c1541.mjs +0 -569
  126. package/skills/c64-memory-mapping/SKILL.md +0 -631
  127. package/skills/c64-memory-mapping/memmap.json +0 -8800
  128. package/skills/c64-memory-mapping/scripts/driver.mjs +0 -553
  129. package/skills/c64-petcat/SKILL.md +0 -87
  130. package/skills/c64-petcat/scripts/petcat.mjs +0 -221
  131. package/skills/c64-program-recon/SKILL.md +0 -706
  132. package/skills/c64-program-recon/references/control-flow.md +0 -180
  133. package/skills/c64-program-recon/references/graphics.md +0 -79
  134. package/skills/c64-program-recon/references/observation-hazards.md +0 -143
  135. package/skills/c64-program-recon/references/reconstruction.md +0 -133
  136. package/skills/c64-program-recon/references/sound-and-input.md +0 -74
  137. package/skills/c64-program-recon/references/tool-selection.md +0 -78
  138. package/skills/c64-program-recon/scripts/derive.mjs +0 -364
  139. package/skills/c64-program-recon/scripts/packer-finding.mjs +0 -709
  140. package/skills/c64-program-recon/templates/memory-map.template.md +0 -101
  141. package/skills/c64-provenance-diff/SKILL.md +0 -292
  142. package/skills/c64-provenance-diff/scripts/diff-images.mjs +0 -984
  143. package/skills/c64-provenance-diff/scripts/recovery-schema.mjs +0 -395
  144. package/skills/c64-ram-capture/RELEASES.json.example +0 -17
  145. package/skills/c64-ram-capture/SKILL.md +0 -474
  146. package/skills/c64-ram-capture/scripts/compare-cross-binary.mjs +0 -525
  147. package/skills/c64-ram-capture/scripts/compare.mjs +0 -258
  148. package/skills/c64-ram-capture/scripts/derive-transients.mjs +0 -575
  149. package/skills/c64-ram-capture/scripts/dump-artifacts.mjs +0 -317
  150. package/skills/c64-ram-capture/scripts/mcp-module.mjs +0 -174
  151. package/skills/c64-ram-capture/scripts/project-paths.mjs +0 -81
  152. package/skills/c64-ram-capture/scripts/releases.mjs +0 -109
  153. package/skills/c64-ram-capture/scripts/vsf-slice.mjs +0 -147
  154. package/skills/c64-ram-capture/scripts/watch-loads.mjs +0 -581
  155. package/skills/c64-ram-capture/templates/capture-record.template.md +0 -99
  156. package/skills/c64-ram-capture/transients/README.md +0 -137
  157. package/skills/routine-queue-walker/SKILL.md +0 -394
  158. package/skills/routine-queue-walker/scripts/completeness-report.mjs +0 -465
  159. package/skills/vice-wedge-triage/SKILL.md +0 -269
@@ -0,0 +1,683 @@
1
+ // The c64-unpacker script. "inspect" gives local evidence of packing; it never
2
+ // names a packer. "trace" runs the program and reports the memory that it
3
+ // wrote and then executed: the run-time evidence that also finds a simple
4
+ // cruncher whose bytes look like code. "capture" runs the program in an
5
+ // emulator until it reaches a given address (the unpacked entry point) and
6
+ // writes the memory into the project. "loads" captures each part of a
7
+ // multi-load program (D53). No native unpack tool is used.
8
+
9
+ import { parseArgs } from "node:util";
10
+
11
+ import { COVERAGE, EXECUTE, formatC64Address, parseC64Address, READ, WRITE } from "#src/c64.ts";
12
+ import { autostartLoaded, LOAD_LIMIT_FRAMES, RUN_LIMIT_FRAMES, typeRun } from "#src/host-client/autostart.ts";
13
+ import { readCoverage, writtenBlocks } from "#src/host-client/coverage.ts";
14
+ import { checkActionSteps, runActionStep, type ActionStep } from "#src/host-client/steps.ts";
15
+ import { WireFailure } from "#src/host-client/tools.ts";
16
+ import { readProjectFile, writeProjectFile } from "#src/host-client/transfer.ts";
17
+ import { ViceSessionClient } from "#src/host-client/vice-session.ts";
18
+ import { resolveProjectPath } from "#src/project.ts";
19
+ import { runScript, UsageError } from "#src/script.ts";
20
+ import { basicStart, inspect, PackingError, readMemoryMap, underRom, writtenThenExecuted } from "./evidence.ts";
21
+ import { loads, readLoadSteps } from "./loads.ts";
22
+ import { callReturn, captureSegments, executedIn, inRanges, mergeMaps, stageCandidates, stagePoints, type Range } from "./stages.ts";
23
+ import { entryState, IO_AREAS, json, machineState, readRam, READY_FRAMES, vectorsOf } from "./machine.ts";
24
+
25
+ const USAGE = `unpack.ts inspect <program.prg>
26
+ unpack.ts trace <program.prg> [--frames <n>] [--entry <address>] [--steps <file>]
27
+ unpack.ts capture <program.prg> --until <address> --out <dir> [--range <start> <end>] [--timeout-frames <n>] [--entry <address>] [--steps <file>] [--min-stage <bytes>]
28
+ unpack.ts stages <program.prg> [--frames <n>] [--entry <address>] [--steps <file>] [--min-stage <bytes>]
29
+ unpack.ts loads <program> --steps <file> --out <dir> [--drive] [--min-block <bytes>]
30
+
31
+ inspect packing evidence: the BASIC start and the entropy of the bytes
32
+ trace run the program for <n> frames (default 3000) and report the memory
33
+ that it wrote and then executed; --entry starts it there instead of RUN
34
+ capture run the program until the CPU gets to <address> after the steps, and write into the
35
+ directory --out: image.bin (64 KiB of RAM), image.json (the entry,
36
+ the segments that the stages wrote, the vectors, the stages), a PRG
37
+ for each segment, and entry_state.json (registers, $00/$01, pages
38
+ 0-3, VIC, SID and CIA registers, vectors, and what differs from a
39
+ clean reset). --range gives one segment in place of the found ones.
40
+ --entry starts it there instead of RUN.
41
+
42
+ stages run the program for <n> frames (default 3000) and report each stage:
43
+ where it starts, the memory it wrote, and where the next one starts.
44
+ A stage starts when the CPU executes a block of at least
45
+ --min-stage bytes (default 64) that the stage before wrote.
46
+
47
+ loads run a multi-load program (a PRG, or a disk, tape or cartridge image)
48
+ with the steps file; each capture step {"capture": "<name>"} writes
49
+ <dir>/<name>/ with image.bin, image.json, entry_state.json and a PRG
50
+ for each block of at least --min-block bytes (default 64) that the C64
51
+ side changed since the previous capture. --drive also writes the 2 KiB
52
+ drive RAM as drive8.prg, for knowledge space drive8.
53
+
54
+ --steps <file> a JSON list of input steps that run right after the program
55
+ starts: write, registers, joystick, type, frames and runUntil, as in
56
+ c64-testing scenarios, with $ addresses only. Use it to press a key
57
+ in an intro or to get through a menu. stages and capture track the
58
+ stages in frames steps and in runUntil steps with at alone.
59
+
60
+ Addresses are $ and four hex digits. The result is one JSON object.`;
61
+
62
+
63
+ function address(text: string, option: string): number {
64
+ try {
65
+ return parseC64Address(text);
66
+ } catch {
67
+ throw new UsageError(`${option} must be $ followed by four hex digits, not ${text}`);
68
+ }
69
+ }
70
+
71
+ /** Written-then-executed bytes from this many on are unpacking, decrypting or relocating, not self-changing code. */
72
+ const UNPACKED_BYTES = 256;
73
+ const MAX_ADVANCE = 10_000;
74
+ /** The most ranges one memory map read gives. */
75
+ const MAX_RANGES = 1000;
76
+
77
+ /** Reads and checks a steps file: action steps with $ addresses only (D43). */
78
+ function readSteps(path: string): ActionStep[] {
79
+ let value: unknown;
80
+ try {
81
+ value = JSON.parse(readProjectFile(path).bytes.toString("utf8"));
82
+ } catch (error) {
83
+ if (error instanceof WireFailure) throw error;
84
+ throw new UsageError(`--steps ${path} is not JSON: ${(error as Error).message}`);
85
+ }
86
+ return checkActionSteps(value, (message) => {
87
+ throw new UsageError(`--steps ${path}: ${message}`);
88
+ });
89
+ }
90
+
91
+ /** Runs the steps; the reason when a runUntil step did not reach its target. */
92
+ async function runSteps(client: ViceSessionClient, steps: ActionStep[]): Promise<string | undefined> {
93
+ for (const [index, step] of steps.entries()) {
94
+ const notReached = await runActionStep(client, step, parseC64Address);
95
+ if (notReached !== undefined && "runUntil" in step) {
96
+ return `Step ${index + 1} did not reach its target in ${step.timeoutFrames} frames. It stopped by ${notReached.stopReason} at ${formatC64Address(notReached.pc)}.`;
97
+ }
98
+ }
99
+ return undefined;
100
+ }
101
+
102
+ async function trace(program: string, options: { frames: number; entry?: number; steps: ActionStep[] }): Promise<{ output: Record<string, unknown>; failed: boolean }> {
103
+ const { bytes } = readProjectFile(program);
104
+ if (bytes.length < 3) throw new UsageError("the file is too short for a PRG");
105
+ const load = bytes[0]! | (bytes[1]! << 8);
106
+ if (options.entry === undefined && basicStart(bytes) === undefined) throw new UsageError(NO_BASIC);
107
+ const client = await ViceSessionClient.open({ videoStandard: "pal" });
108
+ try {
109
+ // Loaded but not started, so the memory map can start empty: the KERNAL start-up writes all of RAM.
110
+ const notStarted = await startLoaded(client, program, bytes, options.entry);
111
+ if (notStarted !== undefined) return { output: { traced: false, reason: notStarted }, failed: true };
112
+ const stepFailed = await runSteps(client, options.steps);
113
+ if (stepFailed !== undefined) return { output: { traced: false, reason: stepFailed }, failed: true };
114
+ let ran = 0;
115
+ while (ran < options.frames) {
116
+ const step = await client.execution({ action: "advance-frames", count: Math.min(MAX_ADVANCE, options.frames - ran), space: "c64" });
117
+ const advanced = step.advancedFrames ?? 0;
118
+ ran += advanced;
119
+ if (advanced === 0) break;
120
+ }
121
+ const { pc } = await client.registersGet("c64");
122
+ const ranges = await readMemoryMap(async (start) => {
123
+ const map = await client.memmap({ action: "read", start, end: 0xffff, maxRanges: MAX_RANGES });
124
+ if (!("ranges" in map)) throw new WireFailure("operation-failed", "The emulator did not give the memory map.");
125
+ const ranges = map.ranges.map(({ start: from, end, flags }) => ({ start: from, end, read: (flags & READ) !== 0, write: (flags & WRITE) !== 0, execute: (flags & EXECUTE) !== 0 }));
126
+ return map.next === undefined ? { ranges } : { ranges, next: map.next };
127
+ });
128
+ const { written, counted, bytes: count, running: atStop } = writtenThenExecuted(ranges, pc);
129
+ // The run often ends in a KERNAL interrupt: the written code that runs next gives the range and the point after the unpacking.
130
+ let running = atStop;
131
+ let until = pc;
132
+ if (running === undefined && counted.length > 0) {
133
+ const next = await firstPcIn(client, counted);
134
+ if (next !== undefined) {
135
+ running = counted.find((range) => next >= range.start && next <= range.end);
136
+ until = next;
137
+ }
138
+ }
139
+ const packing = count >= UNPACKED_BYTES || running !== undefined ? "likely" : count > 0 ? "unclear" : "unlikely";
140
+ return {
141
+ output: {
142
+ traced: true,
143
+ frames: ran,
144
+ pc: formatC64Address(pc),
145
+ writtenThenExecuted: written.map((range) => ({
146
+ start: formatC64Address(range.start),
147
+ end: formatC64Address(range.end),
148
+ ...(underRom(range.start) ? { underRom: true } : {}),
149
+ })),
150
+ bytes: count,
151
+ packing,
152
+ evidence:
153
+ packing === "likely"
154
+ ? running !== undefined
155
+ ? `The CPU runs code at ${formatC64Address(until)} that the program wrote: it unpacked, decrypted or relocated it.`
156
+ : `The program wrote ${count} bytes and then executed them: it unpacked, decrypted or relocated code.`
157
+ : packing === "unclear"
158
+ ? `The program executed ${count} bytes that it wrote. This can be code that changes itself or a small loader.`
159
+ : "The program executed no code that it wrote while it ran.",
160
+ // A point after the unpacking where the unpacked code runs; the entry point can be at the start of its range.
161
+ ...(running === undefined ? {} : { suggestedUntil: formatC64Address(until), runningRange: { start: formatC64Address(running.start), end: formatC64Address(running.end) } }),
162
+ },
163
+ failed: false,
164
+ };
165
+ } finally {
166
+ await client.close();
167
+ }
168
+ }
169
+
170
+ const NO_BASIC = "the program is no BASIC program at $0801, so RUN cannot start it. Give --entry <address>.";
171
+
172
+ /** The first PC in one of the ranges within SYS_FRAMES frames, found with exec breakpoints over them. */
173
+ async function firstPcIn(client: ViceSessionClient, ranges: Array<{ start: number; end: number }>): Promise<number | undefined> {
174
+ const points = [];
175
+ for (const range of ranges) points.push(await client.breakpoint({ action: "add", address: range.start, size: range.end - range.start + 1, space: "c64" }));
176
+ try {
177
+ const stop = await client.execution({ action: "advance-frames", count: SYS_FRAMES, space: "c64" });
178
+ const pc = stop.pc ?? (await client.registersGet("c64")).pc;
179
+ return ranges.some((range) => pc >= range.start && pc <= range.end) ? pc : undefined;
180
+ } finally {
181
+ for (const point of points) await client.breakpoint({ action: "remove", id: point.id });
182
+ }
183
+ }
184
+
185
+ /** Frames that RUN gets to reach the machine code of a SYS whose argument needs run-time values. */
186
+ const SYS_FRAMES = 50;
187
+
188
+ /**
189
+ * The first PC in the program after its BASIC text, for a SYS whose address
190
+ * BASIC computes: an exec breakpoint over that part of the program stops there.
191
+ */
192
+ /**
193
+ * Starts the program as a c64-testing scenario does (D62): hard reset, load,
194
+ * an empty memory map, then RUN up to the first BASIC line, or the pc at
195
+ * `entry`. Gives why it did not start.
196
+ */
197
+ async function startLoaded(client: ViceSessionClient, program: string, bytes: Uint8Array, entry: number | undefined): Promise<string | undefined> {
198
+ await client.reset({ mode: "hard", run: false });
199
+ if (!(await autostartLoaded(client, program, bytes))) return `The program was not in memory at ${formatC64Address(bytes[0]! | (bytes[1]! << 8))} after ${LOAD_LIMIT_FRAMES} frames.`;
200
+ await client.memmap({ action: "clear" });
201
+ if (entry !== undefined) await client.registersSet("c64", { pc: entry });
202
+ else if (!(await typeRun(client))) return `After RUN, BASIC ran no program line in ${RUN_LIMIT_FRAMES} frames.`;
203
+ return undefined;
204
+ }
205
+
206
+ /** The raster position now: where a step's frames end (D62). */
207
+ async function rasterAt(client: ViceSessionClient): Promise<{ line: number; cycle: number }> {
208
+ const { timing } = await client.observe({ timing: true });
209
+ return { line: timing!.rasterLine, cycle: timing!.rasterCycle };
210
+ }
211
+
212
+ async function firstPcAfterBasic(client: ViceSessionClient, from: number, to: number): Promise<number | undefined> {
213
+ return from > to ? undefined : firstPcIn(client, [{ start: from, end: to }]);
214
+ }
215
+
216
+ /** Frames between two looks at the memory map while stages are followed. */
217
+ const STAGE_CHUNK_FRAMES = 25;
218
+ const MAX_STAGES = 16;
219
+ /** Most stops of one re-run of a chunk: own code and writes over it each stop once. */
220
+ const MAX_STAGE_STOPS = 4096;
221
+ const STAGE_SNAPSHOT = "stage-chunk";
222
+ /** The snapshot at a call into written code, while the run checks if the call returns (D59). */
223
+ const CALL_SNAPSHOT = "stage-call";
224
+
225
+ interface Stage {
226
+ entry: number;
227
+ firstFrame: number;
228
+ frames: number;
229
+ wrote: Array<{ start: number; end: number }>;
230
+ next?: number;
231
+ }
232
+
233
+ interface Followed {
234
+ stages: Stage[];
235
+ frames: number;
236
+ /** True when the CPU got to `until`. */
237
+ reached: boolean;
238
+ /** Why the program did not start, when it did not. */
239
+ failed?: string;
240
+ }
241
+
242
+ /** A runUntil step to an address alone: a breakpoint stops it, so the stages are tracked while it runs. */
243
+ function trackedRunUntil(step: ActionStep): step is { runUntil: { at: string }; timeoutFrames: number } {
244
+ return "runUntil" in step && "at" in step.runUntil && !("raster" in step.runUntil);
245
+ }
246
+
247
+ /**
248
+ * Starts the program and follows its stages (D43). The memory map holds what
249
+ * the current stage wrote; when the CPU executed a block of at least
250
+ * `minStage` written bytes during a chunk of frames, the chunk runs again from
251
+ * its snapshot with exec breakpoints over those blocks, which stop at the
252
+ * first instruction of the next stage. Steps run in the tracked time: a frames
253
+ * step and a runUntil step to an address alone are tracked, the others act
254
+ * between two chunks. With `until`, the run ends when the CPU gets there
255
+ * after the steps.
256
+ */
257
+ async function follow(
258
+ client: ViceSessionClient,
259
+ program: string,
260
+ options: { frames: number; entry?: number; steps: ActionStep[]; minStage: number; until?: number },
261
+ ): Promise<Followed> {
262
+ const { bytes } = readProjectFile(program);
263
+ if (bytes.length < 3) throw new UsageError("the file is too short for a PRG");
264
+ const load = bytes[0]! | (bytes[1]! << 8);
265
+ const basic = basicStart(bytes);
266
+ if (options.entry === undefined && basic === undefined) throw new UsageError(NO_BASIC);
267
+ const notStarted = await startLoaded(client, program, bytes, options.entry);
268
+ if (notStarted !== undefined) return { stages: [], frames: 0, reached: false, failed: notStarted };
269
+ // The frames of each step end where a scenario's do: the first step counts from RUN, a later one from where it starts (D62).
270
+ let anchor: { line: number; cycle: number } | undefined = await rasterAt(client);
271
+ const framed = () => (anchor === undefined ? {} : { anchor });
272
+ const found: Stage[] = [];
273
+ const start = options.entry ?? basic!.sys?.address ?? (await firstPcAfterBasic(client, basic!.basicEnd, load + bytes.length - 3));
274
+ if (start === undefined) return { stages: [], frames: 0, reached: false, failed: `After RUN, the CPU did not execute the program after its BASIC text (${formatC64Address(basic!.basicEnd)} on) in ${SYS_FRAMES} frames. Give --entry <address>.` };
275
+ let entry = start;
276
+ let firstFrame = 0;
277
+ let total = 0;
278
+ let ended = false;
279
+ /** Where the run stops: the target of a runUntil step while it runs, then until (R2-06). */
280
+ let stopAt: number | undefined;
281
+ const atTarget = (pc: number) => stopAt !== undefined && pc === stopAt;
282
+ // The current stage: its memory map since it started, chunk by chunk, its own code, and the written code it calls (D59).
283
+ let stage = { map: new Uint8Array(0x10000), own: new Set<number>(), called: [] as Range[] };
284
+ const startStage = async (next: number, last: Uint8Array) => {
285
+ mergeMaps(stage.map, last);
286
+ found.push({ entry, firstFrame, frames: total - firstFrame, wrote: writtenBlocks(stage.map, options.minStage), next });
287
+ entry = next;
288
+ firstFrame = total;
289
+ stage = { map: new Uint8Array(0x10000), own: new Set<number>(), called: [] };
290
+ await client.memmap({ action: "clear" });
291
+ };
292
+
293
+ /**
294
+ * Runs the chunk again from its snapshot with points over the candidate
295
+ * blocks, and stops at the first opcode that the stage wrote and the CPU
296
+ * then executes. Own code stops the run once; a write watch arms it again.
297
+ */
298
+ const rerun = async (candidates: Range[], frames: number): Promise<{ used: number; map: Uint8Array; next?: number; atTarget: boolean }> => {
299
+ const points = new Map<string, { kind: "exec" | "watch"; id: number }>();
300
+ const arm = async (wanted: { exec: Range[]; watch: Range[] }) => {
301
+ const keys = new Set([...wanted.exec.map((range) => `exec:${range.start}:${range.end}`), ...wanted.watch.map((range) => `watch:${range.start}:${range.end}`)]);
302
+ for (const [key, point] of points) {
303
+ if (keys.has(key)) continue;
304
+ if (point.kind === "exec") await client.breakpoint({ action: "remove", id: point.id });
305
+ else await client.watchpoint({ action: "remove", id: point.id });
306
+ points.delete(key);
307
+ }
308
+ for (const range of wanted.exec) {
309
+ const key = `exec:${range.start}:${range.end}`;
310
+ if (!points.has(key)) points.set(key, { kind: "exec", id: (await client.breakpoint({ action: "add", address: range.start, size: range.end - range.start + 1, space: "c64" })).id });
311
+ }
312
+ for (const range of wanted.watch) {
313
+ const key = `watch:${range.start}:${range.end}`;
314
+ if (!points.has(key)) points.set(key, { kind: "watch", id: (await client.watchpoint({ action: "add", address: range.start, size: range.end - range.start + 1, access: "write", space: "c64" })).id });
315
+ }
316
+ };
317
+ /**
318
+ * At written code that a JSR of the stage called: runs on until the call
319
+ * returns, in the frames left. Gives the frames used when it returns;
320
+ * otherwise the machine goes back to the stop and nothing is given.
321
+ */
322
+ const returns = async (map: Uint8Array, frames: number): Promise<number | undefined> => {
323
+ const { sp } = await client.registersGet("c64");
324
+ const stack = await readRam(client, 0x0100, 0x100);
325
+ const pushed = stack[(sp + 1) & 0xff]! | (stack[(sp + 2) & 0xff]! << 8);
326
+ const [opcode] = await readRam(client, (pushed - 2) & 0xffff, 1);
327
+ const back = callReturn(stack, sp, () => opcode!, (address) => ((stage.map[address]! | map[address]!) & COVERAGE.ramExecute) !== 0);
328
+ if (back === undefined) return undefined;
329
+ await arm({ exec: [], watch: [] });
330
+ await client.snapshot({ action: "save", name: CALL_SNAPSHOT });
331
+ const point = await client.breakpoint({ action: "add", address: back, space: "c64" });
332
+ try {
333
+ const run = await client.execution({ action: "advance-frames", count: frames, space: "c64", ...framed() });
334
+ if ((run.pc ?? (await client.registersGet("c64")).pc) === back) return run.advancedFrames ?? 0;
335
+ await client.snapshot({ action: "restore", name: CALL_SNAPSHOT });
336
+ return undefined;
337
+ } finally {
338
+ await client.breakpoint({ action: "remove", id: point.id });
339
+ await client.snapshot({ action: "discard", name: CALL_SNAPSHOT });
340
+ }
341
+ };
342
+ let left = frames;
343
+ let used = 0;
344
+ let map: Uint8Array = new Uint8Array(0x10000);
345
+ try {
346
+ for (let stops = 0; ; stops++) {
347
+ if (stops > MAX_STAGE_STOPS) {
348
+ throw new WireFailure("operation-failed", `The stage that starts at ${formatC64Address(entry)} stopped ${MAX_STAGE_STOPS} times in one chunk of frames without a new stage. Use --min-stage with a larger block size, or capture with --until.`);
349
+ }
350
+ const written = (address: number) => ((stage.map[address]! | map[address]!) & COVERAGE.ramWrite) !== 0;
351
+ await arm(stagePoints(candidates, stage.own, written, stage.called));
352
+ const run = await client.execution({ action: "advance-frames", count: left, space: "c64", ...framed() });
353
+ const advanced = run.advancedFrames ?? 0;
354
+ used += advanced;
355
+ left -= advanced;
356
+ const pc = run.pc ?? (await client.registersGet("c64")).pc;
357
+ map = await readCoverage(client);
358
+ // A frame can end in written code that the stage called before (D59): that stop starts no stage.
359
+ if (inRanges(candidates, pc) && !inRanges(stage.called, pc)) {
360
+ if (written(pc)) {
361
+ const back = left > 0 && !atTarget(pc) ? await returns(map, left) : undefined;
362
+ if (back === undefined) return { used, map, next: pc, atTarget: atTarget(pc) };
363
+ used += back;
364
+ left -= back;
365
+ map = await readCoverage(client);
366
+ // The stage called the block and got the CPU back: the code that ran there is its own code.
367
+ const block = candidates.find((range) => pc >= range.start && pc <= range.end)!;
368
+ stage.called.push(...executedIn(map, block));
369
+ if (left <= 0) return { used, map, atTarget: false };
370
+ continue;
371
+ }
372
+ stage.own.add(pc);
373
+ }
374
+ if (atTarget(pc)) return { used, map, atTarget: true };
375
+ if (left <= 0) return { used, map, atTarget: false };
376
+ }
377
+ } finally {
378
+ for (const point of points.values()) {
379
+ if (point.kind === "exec") await client.breakpoint({ action: "remove", id: point.id });
380
+ else await client.watchpoint({ action: "remove", id: point.id });
381
+ }
382
+ }
383
+ };
384
+
385
+ /** Tracks the stages for some frames; true when the CPU got to stopAt. */
386
+ const track = async (frames: number): Promise<boolean> => {
387
+ anchor ??= await rasterAt(client);
388
+ try {
389
+ return await trackFrom(frames);
390
+ } finally {
391
+ anchor = undefined;
392
+ }
393
+ };
394
+ const trackFrom = async (frames: number): Promise<boolean> => {
395
+ let left = frames;
396
+ let stopped = false;
397
+ while (left > 0 && !ended && !stopped && found.length < MAX_STAGES) {
398
+ await client.snapshot({ action: "save", name: STAGE_SNAPSHOT });
399
+ await client.memmap({ action: "clear" });
400
+ const chunk = Math.min(STAGE_CHUNK_FRAMES, left);
401
+ const first = await client.execution({ action: "advance-frames", count: chunk, space: "c64", ...framed() });
402
+ const firstPc = first.pc ?? (await client.registersGet("c64")).pc;
403
+ const map = await readCoverage(client);
404
+ const candidates = stageCandidates(stage.map, map, stage.own, options.minStage, stage.called);
405
+ if (candidates.length === 0) {
406
+ const advanced = first.advancedFrames ?? 0;
407
+ total += advanced;
408
+ left -= advanced;
409
+ if (atTarget(firstPc)) {
410
+ // The stop at a target comes before its instruction runs: a target that the stage wrote starts a stage too.
411
+ const combined = Uint8Array.from(stage.map);
412
+ mergeMaps(combined, map);
413
+ const wrote = (combined[firstPc]! & COVERAGE.ramWrite) !== 0 && inRanges(writtenBlocks(combined, options.minStage), firstPc);
414
+ if (wrote) await startStage(firstPc, map);
415
+ else mergeMaps(stage.map, map);
416
+ stopped = true;
417
+ } else {
418
+ mergeMaps(stage.map, map);
419
+ if (advanced === 0) ended = true;
420
+ }
421
+ continue;
422
+ }
423
+ // Again from the start of the chunk, with points over the candidate blocks.
424
+ await client.snapshot({ action: "restore", name: STAGE_SNAPSHOT });
425
+ await client.memmap({ action: "clear" });
426
+ const again = await rerun(candidates, chunk);
427
+ total += again.used;
428
+ left -= again.used;
429
+ if (again.next !== undefined) await startStage(again.next, again.map);
430
+ else mergeMaps(stage.map, again.map);
431
+ if (again.atTarget) stopped = true;
432
+ }
433
+ return stopped;
434
+ };
435
+ /** Tracks the stages until the CPU gets to an address, with a breakpoint there. */
436
+ const trackTo = async (address: number, frames: number): Promise<boolean> => {
437
+ stopAt = address;
438
+ const point = await client.breakpoint({ action: "add", address, space: "c64" });
439
+ try {
440
+ return await track(frames);
441
+ } finally {
442
+ await client.breakpoint({ action: "remove", id: point.id });
443
+ stopAt = undefined;
444
+ }
445
+ };
446
+ // The steps run first and in the tracked time: a frames step and a runUntil step to an address (R2-06). The others act between two chunks.
447
+ for (const [index, step] of options.steps.entries()) {
448
+ if ("frames" in step) await track(step.frames);
449
+ else if (trackedRunUntil(step)) {
450
+ if (!(await trackTo(parseC64Address(step.runUntil.at), step.timeoutFrames))) {
451
+ const { pc } = await client.registersGet("c64");
452
+ return { stages: found, frames: total, reached: false, failed: `Step ${index + 1} did not reach its target in ${step.timeoutFrames} frames. It is at ${formatC64Address(pc)}.` };
453
+ }
454
+ } else await runActionStep(client, step, parseC64Address);
455
+ }
456
+ const left = Math.max(0, options.frames - total);
457
+ let reached = false;
458
+ if (options.until === undefined) await track(left);
459
+ else reached = await trackTo(options.until, left);
460
+ await client.snapshot({ action: "discard", name: STAGE_SNAPSHOT }).catch(() => undefined);
461
+ mergeMaps(stage.map, await readCoverage(client));
462
+ found.push({ entry, firstFrame, frames: total - firstFrame, wrote: writtenBlocks(stage.map, options.minStage) });
463
+ return { stages: found, frames: total, reached };
464
+ }
465
+
466
+ const showRange = (range: { start: number; end: number }) => ({ start: formatC64Address(range.start), end: formatC64Address(range.end) });
467
+ const showStages = (stages: Stage[]) =>
468
+ stages.map((stage, index) => ({
469
+ stage: index,
470
+ entry: formatC64Address(stage.entry),
471
+ frames: stage.frames,
472
+ wrote: stage.wrote.map(showRange),
473
+ ...(stage.next === undefined ? {} : { next: formatC64Address(stage.next) }),
474
+ }));
475
+
476
+ async function stages(program: string, options: { frames: number; entry?: number; steps: ActionStep[]; minStage: number }): Promise<{ output: Record<string, unknown>; failed: boolean }> {
477
+ const client = await ViceSessionClient.open({ videoStandard: "pal" });
478
+ try {
479
+ const followed = await follow(client, program, options);
480
+ if (followed.failed !== undefined) return { output: { followed: false, reason: followed.failed }, failed: true };
481
+ return {
482
+ output: {
483
+ followed: true,
484
+ frames: followed.frames,
485
+ stages: showStages(followed.stages),
486
+ pc: formatC64Address((await client.registersGet("c64")).pc),
487
+ ...(followed.stages.length > MAX_STAGES ? { moreStages: true } : {}),
488
+ },
489
+ failed: false,
490
+ };
491
+ } finally {
492
+ await client.close();
493
+ }
494
+ }
495
+
496
+
497
+ async function capture(
498
+ program: string,
499
+ options: { until: number; out: string; range?: [number, number]; timeoutFrames: number; entry?: number; steps: ActionStep[]; minStage: number },
500
+ ): Promise<{ output: Record<string, unknown>; failed: boolean }> {
501
+ const client = await ViceSessionClient.open({ videoStandard: "pal" });
502
+ try {
503
+ const followed = await follow(client, program, { frames: options.timeoutFrames, steps: options.steps, minStage: options.minStage, until: options.until, ...(options.entry === undefined ? {} : { entry: options.entry }) });
504
+ if (followed.failed !== undefined) return { output: { captured: false, reason: followed.failed }, failed: true };
505
+ const registers = await client.registersGet("c64");
506
+ if (!followed.reached) {
507
+ return {
508
+ output: { captured: false, reason: `The program did not get to ${formatC64Address(options.until)} in ${options.timeoutFrames} frames. It is at ${formatC64Address(registers.pc)}.`, stages: showStages(followed.stages) },
509
+ failed: true,
510
+ };
511
+ }
512
+ // The RAM view: unpacked code is often under the ROMs or the I/O area.
513
+ const memory = await readRam(client, 0x0000, 0x10000);
514
+ const state = await machineState(client);
515
+ // The segments: what the stages wrote, without loader leftovers, with no overlap.
516
+ const segments = options.range !== undefined ? [{ start: options.range[0], end: options.range[1] }] : captureSegments(followed.stages, registers.pc);
517
+ const file = (path: string) => `${options.out}/${path}`;
518
+ const write = (path: string, bytes: Uint8Array) => writeProjectFile(file(path), bytes, { what: "--out" });
519
+ write("image.bin", memory);
520
+ const files = segments.map((segment) => {
521
+ const name = `segment-${segment.start.toString(16).padStart(4, "0")}-${segment.end.toString(16).padStart(4, "0")}.prg`;
522
+ write(name, Buffer.concat([Buffer.from([segment.start & 0xff, segment.start >> 8]), memory.subarray(segment.start, segment.end + 1)]));
523
+ return name;
524
+ });
525
+ const vectors = vectorsOf(state, memory);
526
+ write(
527
+ "image.json",
528
+ Buffer.from(
529
+ `${JSON.stringify(
530
+ {
531
+ entry: formatC64Address(registers.pc),
532
+ segments: segments.map((segment, index) => ({ ...showRange(segment), ...("writtenBy" in segment ? { writtenBy: segment.writtenBy } : {}), file: files[index] })),
533
+ vectors,
534
+ stages: showStages(followed.stages),
535
+ image: "image.bin",
536
+ },
537
+ null,
538
+ 2,
539
+ )}\n`,
540
+ ),
541
+ );
542
+ // A clean machine at the READY prompt shows what the loader and the stages changed.
543
+ await client.reset({ mode: "hard", run: true });
544
+ await client.execution({ action: "advance-frames", count: READY_FRAMES, space: "c64" });
545
+ const clean = await machineState(client);
546
+ const differs: Array<{ address: string; entry: number; reset: number }> = [];
547
+ const compare = (base: number, entry: Buffer, reset: Buffer) => entry.forEach((value, offset) => value !== reset[offset] && differs.push({ address: formatC64Address(base + offset), entry: value, reset: reset[offset]! }));
548
+ compare(0x0000, state.port, clean.port);
549
+ compare(0x0002, state.pages.subarray(2), clean.pages.subarray(2));
550
+ for (const [name, [address]] of Object.entries(IO_AREAS)) compare(address, state.io[name as keyof typeof IO_AREAS], clean.io[name as keyof typeof IO_AREAS]);
551
+ write("entry_state.json", json(entryState(registers, state, vectors, differs)));
552
+ return {
553
+ output: {
554
+ captured: true,
555
+ pc: formatC64Address(registers.pc),
556
+ out: options.out,
557
+ files: ["image.json", "image.bin", "entry_state.json", ...files],
558
+ segments: segments.map(showRange),
559
+ stages: followed.stages.length,
560
+ differsFromReset: differs.length,
561
+ },
562
+ failed: false,
563
+ };
564
+ } finally {
565
+ await client.close();
566
+ }
567
+ }
568
+
569
+ /** The options that each command takes; any other option is refused, not ignored. */
570
+ const OPTIONS_OF: Record<string, string[]> = {
571
+ inspect: [],
572
+ trace: ["frames", "entry", "steps"],
573
+ stages: ["frames", "entry", "steps", "min-stage"],
574
+ capture: ["until", "out", "range", "timeout-frames", "entry", "steps", "min-stage"],
575
+ loads: ["steps", "out", "drive", "min-block"],
576
+ };
577
+
578
+ async function run(argv: string[]): Promise<{ output: unknown; failed: boolean }> {
579
+ const { values, positionals } = parseArgs({
580
+ args: argv,
581
+ strict: true,
582
+ allowPositionals: true,
583
+ options: {
584
+ until: { type: "string" },
585
+ out: { type: "string" },
586
+ range: { type: "string" },
587
+ "timeout-frames": { type: "string" },
588
+ frames: { type: "string" },
589
+ entry: { type: "string" },
590
+ steps: { type: "string" },
591
+ "min-stage": { type: "string" },
592
+ "min-block": { type: "string" },
593
+ drive: { type: "boolean" },
594
+ help: { type: "boolean" },
595
+ },
596
+ });
597
+ if (values.help) throw new UsageError("");
598
+ const [command, program, ...rest] = positionals;
599
+ if (program === undefined) throw new UsageError("give a command and a program");
600
+ const allowed = OPTIONS_OF[command ?? ""];
601
+ if (allowed === undefined) throw new UsageError(`unknown command ${command}`);
602
+ for (const option of Object.keys(values)) {
603
+ if (option !== "help" && !allowed.includes(option)) throw new UsageError(`${command} does not take --${option}`);
604
+ }
605
+ if (command === "inspect") {
606
+ if (rest.length > 0) throw new UsageError("inspect takes one program");
607
+ const { bytes } = readProjectFile(program);
608
+ try {
609
+ return { output: inspect(bytes), failed: false };
610
+ } catch (error) {
611
+ if (error instanceof PackingError) throw new UsageError(error.message);
612
+ throw error;
613
+ }
614
+ }
615
+ if (command === "trace") {
616
+ if (rest.length > 0) throw new UsageError("trace takes one program");
617
+ const frames = values.frames === undefined ? 3000 : Number(values.frames);
618
+ if (!Number.isInteger(frames) || frames < 1 || frames > 30_000) throw new UsageError("--frames must be from 1 to 30000");
619
+ const steps = values.steps === undefined ? [] : readSteps(values.steps);
620
+ return trace(program, { frames, steps, ...(values.entry === undefined ? {} : { entry: address(values.entry, "--entry") }) });
621
+ }
622
+ if (command === "stages") {
623
+ if (rest.length > 0) throw new UsageError("stages takes one program");
624
+ const frames = values.frames === undefined ? 3000 : Number(values.frames);
625
+ if (!Number.isInteger(frames) || frames < 1 || frames > 30_000) throw new UsageError("--frames must be from 1 to 30000");
626
+ const minStage = values["min-stage"] === undefined ? 64 : Number(values["min-stage"]);
627
+ if (!Number.isInteger(minStage) || minStage < 1 || minStage > 0x10000) throw new UsageError("--min-stage must be from 1 to 65536");
628
+ const steps = values.steps === undefined ? [] : readSteps(values.steps);
629
+ // A runUntil step to a raster line or a memory value cannot be tracked in chunks of frames; capture takes it.
630
+ if (steps.some((step) => "runUntil" in step && !trackedRunUntil(step))) throw new UsageError("stages takes a runUntil step only with at alone; use frames steps or runUntil at");
631
+ return stages(program, { frames, steps, minStage, ...(values.entry === undefined ? {} : { entry: address(values.entry, "--entry") }) });
632
+ }
633
+ if (command === "loads") {
634
+ if (rest.length > 0) throw new UsageError("loads takes one program");
635
+ if (values.steps === undefined || values.out === undefined) throw new UsageError("loads needs --steps and --out");
636
+ try {
637
+ resolveProjectPath(values.out);
638
+ } catch {
639
+ throw new UsageError(`--out must be a path relative to the project directory that stays inside it, not ${values.out}`);
640
+ }
641
+ const minBlock = values["min-block"] === undefined ? 64 : Number(values["min-block"]);
642
+ if (!Number.isInteger(minBlock) || minBlock < 1 || minBlock > 0x10000) throw new UsageError("--min-block must be from 1 to 65536");
643
+ return loads(program, { out: values.out, steps: readLoadSteps(values.steps), drive: values.drive === true, minBlock });
644
+ }
645
+ if (values.until === undefined || values.out === undefined) throw new UsageError("capture needs --until and --out");
646
+ // --range takes two values: the end is the next positional argument.
647
+ let range: [number, number] | undefined;
648
+ if (values.range !== undefined) {
649
+ if (rest.length !== 1) throw new UsageError("--range takes a start and an end address");
650
+ range = [address(values.range, "--range"), address(rest[0]!, "--range")];
651
+ if (range[1] < range[0]) throw new UsageError("the --range end must not be before its start");
652
+ } else if (rest.length > 0) {
653
+ throw new UsageError("too many arguments");
654
+ }
655
+ try {
656
+ resolveProjectPath(values.out);
657
+ } catch {
658
+ throw new UsageError(`--out must be a path relative to the project directory that stays inside it, not ${values.out}`);
659
+ }
660
+ const timeoutFrames = values["timeout-frames"] === undefined ? 3000 : Number(values["timeout-frames"]);
661
+ if (!Number.isInteger(timeoutFrames) || timeoutFrames < 1 || timeoutFrames > 30_000) throw new UsageError("--timeout-frames must be from 1 to 30000");
662
+ const steps = values.steps === undefined ? [] : readSteps(values.steps);
663
+ const minStage = values["min-stage"] === undefined ? 64 : Number(values["min-stage"]);
664
+ if (!Number.isInteger(minStage) || minStage < 1 || minStage > 0x10000) throw new UsageError("--min-stage must be from 1 to 65536");
665
+ return capture(program, {
666
+ until: address(values.until, "--until"),
667
+ out: values.out,
668
+ ...(range === undefined ? {} : { range }),
669
+ timeoutFrames,
670
+ steps,
671
+ minStage,
672
+ ...(values.entry === undefined ? {} : { entry: address(values.entry, "--entry") }),
673
+ });
674
+ }
675
+
676
+ await runScript(
677
+ async () => {
678
+ const { output, failed } = await run(process.argv.slice(2));
679
+ if (failed) process.exitCode = 1;
680
+ return output;
681
+ },
682
+ { usage: USAGE },
683
+ );