@henols/c64-re-tools 1.0.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,490 @@
1
+ // The c64-source script (D48): generates ACME source from an image, current
2
+ // knowledge and Ghidra's instructions, which it runs itself, and checks with
3
+ // ACME that the source assembles to the image byte by byte. With --loader it
4
+ // also writes a loader that recreates the entry state, simulates it, and
5
+ // checks it in the emulator. Exit status: 0 when every check passes, 1 when
6
+ // one fails, 2 for a bad call.
7
+
8
+ import { existsSync, readdirSync, readFileSync, rmSync } from "node:fs";
9
+ import { join, posix } from "node:path";
10
+ import { parseArgs } from "node:util";
11
+
12
+ import { formatC64Address, parseC64Address, prgRange, textToPetscii } from "#src/c64.ts";
13
+ import { autostartLoaded, RUN_LIMIT_FRAMES } from "#src/host-client/autostart.ts";
14
+ import { readCoverageFiles } from "#src/host-client/coverage.ts";
15
+ import { MAX_MEMORY_READ, 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 { KnowledgeError, openForRead } from "#src/knowledge/database.ts";
19
+ import { currentRevision, listComments, listModules, listReferences, listRegions, listSymbols } from "#src/knowledge/read.ts";
20
+ import { CODE_REGION_TYPES, DEFAULT_SPACE, SPACE, type Space } from "#src/knowledge/schema.ts";
21
+ import { assemble } from "#src/native/acme.ts";
22
+ import { analyze } from "#src/native/ghidra/analyze.ts";
23
+ import { localToolContext } from "#src/native/local.ts";
24
+ import { GHIDRA_LIMITS, type AssembledSymbol, type Diagnostic } from "#src/native/types.ts";
25
+ import { platformNames } from "#src/platform.ts";
26
+ import { resolveProjectPath } from "#src/project.ts";
27
+ import { runScript, UsageError } from "#src/script.ts";
28
+ import { LOADER_JUMP, planLoader, rasterLine, readEntryState, simulate, VIC_DISPLAY_REGISTERS, type EntryState, type LoaderPlan } from "./loader.ts";
29
+ import { OPCODES } from "./opcodes.ts";
30
+ import { entriesInData, entryOutsideImage, instructionText, layout, literalOperand, probeForms, render, type Layout, type Range } from "./source.ts";
31
+
32
+ const USAGE = `generate.ts <image> --out <dir> [options]
33
+
34
+ <image> an image.json of the c64-unpacker skill, or a PRG, relative to
35
+ the project directory
36
+ --out <dir> the directory for the source files, relative to the project
37
+ --entry <address> an entry point for Ghidra, for example '$080d' (repeatable)
38
+ --coverage <file> a coverage map of 64 KiB from c64_memmap (repeatable): code
39
+ that no run executed is marked and listed; <dir>/not_executed.json
40
+ has all of its ranges
41
+ --space <space> the knowledge space of the image: c64 (the default), drive8, or
42
+ bank0 to bank255; --coverage and --loader are for c64 only
43
+ --loader also write loader.asm and loader.prg: the loader copies the
44
+ segments into place, sets the entry state from entry_state.json
45
+ next to image.json, and jumps to the entry; it is checked in
46
+ the emulator
47
+
48
+ The result is one JSON object. Addresses are $ and four hex digits.`;
49
+
50
+ const MAX_LISTED = 20;
51
+ const PROBE_BASE = 0x1000;
52
+ const PROBE_SLOT = 4;
53
+ /** The copies of a full 64 KiB take about 140 frames. */
54
+ const LOADER_FRAMES = 1500;
55
+
56
+ interface Image {
57
+ path: string;
58
+ segments: Range[];
59
+ byteAt: (address: number) => number;
60
+ /** The bytes that Ghidra analyzes, and how. */
61
+ analyzed: { bytes: Buffer; kind: "prg" | "flat64k" };
62
+ entry?: number;
63
+ /** entry_state.json next to image.json, when it exists. */
64
+ statePath?: string;
65
+ }
66
+
67
+ function readImage(path: string): Image {
68
+ const { bytes } = readProjectFile(path);
69
+ if (!path.endsWith(".json")) {
70
+ let range: ReturnType<typeof prgRange>;
71
+ try {
72
+ range = prgRange(bytes);
73
+ } catch (error) {
74
+ throw new UsageError(`${path}: ${(error as Error).message}`);
75
+ }
76
+ return { path, segments: [{ start: range.start, end: range.end }], byteAt: (address) => range.body[address - range.start]!, analyzed: { bytes, kind: "prg" } };
77
+ }
78
+ const fail = (what: string): never => {
79
+ throw new UsageError(`${path} is not an image.json of the c64-unpacker skill: ${what}`);
80
+ };
81
+ let manifest: Record<string, unknown>;
82
+ try {
83
+ manifest = JSON.parse(bytes.toString("utf8")) as Record<string, unknown>;
84
+ } catch (error) {
85
+ return fail((error as Error).message);
86
+ }
87
+ const address = (value: unknown, what: string) => (typeof value === "string" && /^\$[0-9a-f]{4}$/i.test(value) ? Number.parseInt(value.slice(1), 16) : fail(`${what} is not a $ address`));
88
+ if (typeof manifest.image !== "string") fail("it has no image");
89
+ if (!Array.isArray(manifest.segments) || manifest.segments.length === 0) fail("it has no segments");
90
+ const segments = (manifest.segments as Array<Record<string, unknown>>)
91
+ .map((segment, index) => ({ start: address(segment.start, `segment ${index + 1} start`), end: address(segment.end, `segment ${index + 1} end`) }))
92
+ .sort((a, b) => a.start - b.start);
93
+ segments.forEach((segment, index) => {
94
+ if (segment.end < segment.start) fail(`the segment at ${formatC64Address(segment.start)} ends before it starts`);
95
+ if (index > 0 && segment.start <= segments[index - 1]!.end) fail(`the segments at ${formatC64Address(segments[index - 1]!.start)} and ${formatC64Address(segment.start)} overlap`);
96
+ });
97
+ const directory = posix.dirname(path);
98
+ const memory = readProjectFile(posix.join(directory, manifest.image as string)).bytes;
99
+ if (memory.length !== 0x10000) fail(`its image has ${memory.length} bytes, not 65536`);
100
+ const image: Image = { path, segments, byteAt: (at) => memory[at]!, analyzed: { bytes: memory, kind: "flat64k" }, statePath: posix.join(directory, "entry_state.json") };
101
+ if (manifest.entry !== undefined) image.entry = address(manifest.entry, "entry");
102
+ return image;
103
+ }
104
+
105
+ /** The knowledge of one space that the source uses, read once. */
106
+ function readKnowledge(space: Space) {
107
+ const db = openForRead();
108
+ try {
109
+ return {
110
+ revision: currentRevision(db),
111
+ symbols: listSymbols(db, undefined, undefined, undefined, space),
112
+ regions: listRegions(db, undefined, undefined, undefined, space),
113
+ comments: listComments(db, undefined, undefined, space),
114
+ modules: listModules(db, undefined, undefined, space),
115
+ references: listReferences(db, undefined, undefined, undefined, space),
116
+ };
117
+ } finally {
118
+ db?.close();
119
+ }
120
+ }
121
+
122
+ /** Ghidra's instruction starts and lengths inside the image, seeded from the entry and from knowledge of any origin. */
123
+ async function decode(image: Image, knowledge: ReturnType<typeof readKnowledge>, entries: number[]): Promise<Map<number, number>> {
124
+ const inImage = (address: number) => image.segments.some((segment) => address >= segment.start && address <= segment.end);
125
+ const seeds = new Set<number>([...(image.entry === undefined ? [] : [image.entry]), ...entries]);
126
+ for (const symbol of knowledge.symbols) if (symbol.kind === "routine") seeds.add(symbol.address);
127
+ for (const region of knowledge.regions) {
128
+ if (CODE_REGION_TYPES.includes(region.type)) seeds.add(region.start);
129
+ if (region.type === "jumps") for (let at = region.start; at + 2 <= region.end; at += 3) seeds.add(at);
130
+ }
131
+ const entryPoints = [...seeds].filter(inImage).sort((a, b) => a - b);
132
+ if (entryPoints.length === 0) {
133
+ // An entry in no segment is a capture fault: the image is data only, and the result says entryOutside (R3-01).
134
+ if (image.entry !== undefined && !inImage(image.entry)) return new Map();
135
+ throw new UsageError("Ghidra needs an entry point: give --entry, or record a routine or a code region in knowledge with the c64-knowledge skill");
136
+ }
137
+ const dataRanges = knowledge.regions
138
+ .filter((region) => !CODE_REGION_TYPES.includes(region.type) && region.type !== "dual-use")
139
+ .flatMap((region) => image.segments.filter((segment) => region.start <= segment.end && segment.start <= region.end).map((segment) => ({ start: Math.max(region.start, segment.start), end: Math.min(region.end, segment.end) })));
140
+ const { result } = await analyze(
141
+ { imageKind: image.analyzed.kind, entryPoints: entryPoints.slice(0, GHIDRA_LIMITS.entryPoints), dataRanges: dataRanges.slice(0, GHIDRA_LIMITS.dataRanges), labels: [], decompile: [] },
142
+ image.analyzed.bytes,
143
+ localToolContext(),
144
+ );
145
+ const instructions = new Map<number, number>();
146
+ for (const instruction of result.instructions) {
147
+ const bytes = Buffer.from(instruction.bytes, "hex");
148
+ const inside = Array.from({ length: bytes.length }, (_, offset) => instruction.address + offset).every((address, offset) => inImage(address) && image.byteAt(address) === bytes[offset]);
149
+ if (inside) instructions.set(instruction.address, bytes.length);
150
+ }
151
+ return instructions;
152
+ }
153
+
154
+ /** Assembles files given as text with ACME; the program or the diagnostics. */
155
+ async function assembleFiles(files: Array<{ name: string; text: string }>, entry: string): Promise<{ program?: Buffer; start?: number; symbols?: AssembledSymbol[]; diagnostics: Diagnostic[] }> {
156
+ const contents = files.map((file) => Buffer.from(file.text, "utf8"));
157
+ const { result, attachments } = await assemble(
158
+ { files: files.map((file, index) => ({ path: file.name, size: contents[index]!.length })), entrySource: entry, includeDirs: [], defines: {} },
159
+ contents,
160
+ localToolContext(),
161
+ );
162
+ if (!result.assembled) return { diagnostics: result.diagnostics };
163
+ return { program: attachments![0]!, start: result.loadRange!.start, ...(result.symbols === undefined ? {} : { symbols: result.symbols }), diagnostics: result.diagnostics };
164
+ }
165
+
166
+ /**
167
+ * Assembles one instruction of each opcode form that the source uses, each
168
+ * in its own slot. A form that ACME refuses, or writes with other bytes, is
169
+ * written as bytes in the source.
170
+ */
171
+ async function probe(laid: Layout): Promise<Set<string>> {
172
+ const asBytes = new Set<string>();
173
+ for (let round = 0; round < 3; round++) {
174
+ const forms = probeForms(laid).filter((item) => !asBytes.has(item.form.key));
175
+ if (forms.length === 0) return asBytes;
176
+ const lines = ["!cpu 6510"];
177
+ const lineOf = new Map<number, string>();
178
+ forms.forEach((item, index) => {
179
+ const slot = PROBE_BASE + PROBE_SLOT * index;
180
+ const { mode } = OPCODES[item.form.opcode]!;
181
+ const operand =
182
+ mode === "imp"
183
+ ? ""
184
+ : mode === "rel"
185
+ ? formatC64Address((slot + 2 + (item.bytes[1]! < 0x80 ? item.bytes[1]! : item.bytes[1]! - 0x100)) & 0xffff)
186
+ : literalOperand(item.form.opcode, item.bytes.length === 3 ? item.bytes[1]! | (item.bytes[2]! << 8) : item.bytes[1]!);
187
+ lines.push(`* = ${formatC64Address(slot)}`);
188
+ lines.push(` ${instructionText(item.form.opcode, item.form.suffix, operand)}`);
189
+ lineOf.set(lines.length, item.form.key);
190
+ });
191
+ const built = await assembleFiles([{ name: "probe.asm", text: `${lines.join("\n")}\n` }], "probe.asm");
192
+ if (built.program === undefined) {
193
+ const refused = built.diagnostics.filter((diagnostic) => diagnostic.severity === "error" && diagnostic.line !== undefined && lineOf.has(diagnostic.line));
194
+ if (refused.length === 0) throw new WireFailure("operation-failed", `ACME refused the opcode probe: ${built.diagnostics.map((diagnostic) => diagnostic.message).join("; ")}`);
195
+ for (const diagnostic of refused) asBytes.add(lineOf.get(diagnostic.line!)!);
196
+ continue;
197
+ }
198
+ forms.forEach((item, index) => {
199
+ const offset = 2 + PROBE_BASE + PROBE_SLOT * index - built.start!;
200
+ const same = item.bytes.every((byte, at) => built.program![offset + at] === byte);
201
+ if (!same) asBytes.add(item.form.key);
202
+ });
203
+ return asBytes;
204
+ }
205
+ throw new WireFailure("operation-failed", "ACME refused the opcode probe three times.");
206
+ }
207
+
208
+ interface SegmentCheck {
209
+ start: string;
210
+ end: string;
211
+ result: "identical" | "different" | "not covered";
212
+ differences?: number;
213
+ firstDifference?: { address: string; built: number; image: number };
214
+ }
215
+
216
+ /** Compares the assembled program with the image, segment by segment. */
217
+ function check(program: Buffer, start: number, image: Image): { identical: boolean; segments: SegmentCheck[] } {
218
+ const end = start + program.length - 3;
219
+ const segments = image.segments.map((segment): SegmentCheck => {
220
+ const shown = { start: formatC64Address(segment.start), end: formatC64Address(segment.end) };
221
+ if (segment.start < start || segment.end > end) return { ...shown, result: "not covered" };
222
+ let differences = 0;
223
+ let first: SegmentCheck["firstDifference"];
224
+ for (let address = segment.start; address <= segment.end; address++) {
225
+ const built = program[2 + address - start]!;
226
+ if (built === image.byteAt(address)) continue;
227
+ differences++;
228
+ first ??= { address: formatC64Address(address), built, image: image.byteAt(address) };
229
+ }
230
+ return differences === 0 ? { ...shown, result: "identical" } : { ...shown, result: "different", differences, firstDifference: first! };
231
+ });
232
+ return { identical: segments.every((segment) => segment.result === "identical"), segments };
233
+ }
234
+
235
+ /** Reads RAM in parts. */
236
+ async function readRam(client: ViceSessionClient, start: number, size: number): Promise<Buffer> {
237
+ const parts: Buffer[] = [];
238
+ for (let at = start; at < start + size; at += MAX_MEMORY_READ) {
239
+ parts.push(Buffer.from((await client.memoryRead({ address: at, size: Math.min(MAX_MEMORY_READ, start + size - at), space: "c64", view: "ram" })).data, "hex"));
240
+ }
241
+ return Buffer.concat(parts);
242
+ }
243
+
244
+ /** Runs the loader in the emulator to the entry and compares memory, $00/$01 and the registers with the entry state. */
245
+ /**
246
+ * Runs the loader in the emulator to its jump to the entry and compares the
247
+ * machine with the entry state there. At the jump, every byte and register is
248
+ * set and no interrupt has run since the PLP: the 6502 takes one only after
249
+ * the JMP, as the original program can take one right at its entry (F-32).
250
+ */
251
+ async function runLoader(path: string, program: Buffer, plan: LoaderPlan, image: Image, state: EntryState, jump: number): Promise<{ verified: boolean; differences: string[] }> {
252
+ const client = await ViceSessionClient.open({ videoStandard: "pal" });
253
+ try {
254
+ await client.reset({ mode: "hard", run: false });
255
+ if (!(await autostartLoaded(client, path, program))) return { verified: false, differences: ["The loader was not in memory after autostart."] };
256
+ await client.keyboard(textToPetscii("RUN\n"));
257
+ const copier = await client.runUntil({ target: { kind: "address", address: plan.copier, space: "c64" }, timeoutFrames: RUN_LIMIT_FRAMES });
258
+ if (!copier.reached) return { verified: false, differences: [`The loader did not get to its copier at ${formatC64Address(plan.copier)}; it stopped at ${formatC64Address(copier.pc)}.`] };
259
+ const atJump = await client.runUntil({ target: { kind: "address", address: jump, space: "c64" }, timeoutFrames: LOADER_FRAMES });
260
+ if (!atJump.reached) return { verified: false, differences: [`The loader did not get to its jump to the entry at ${formatC64Address(jump)}; it stopped at ${formatC64Address(atJump.pc)}.`] };
261
+ const memory = await readRam(client, 0x0000, 0x10000);
262
+ if (memory[jump] !== 0x4c || (memory[jump + 1]! | (memory[jump + 2]! << 8)) !== state.pc) {
263
+ return { verified: false, differences: [`The instruction at ${formatC64Address(jump)} is not JMP ${formatC64Address(state.pc)}.`] };
264
+ }
265
+ const port = Buffer.from((await client.memoryRead({ address: 0x0000, size: 2, space: "c64", view: "cpu" })).data, "hex");
266
+ const registers = await client.registersGet("c64");
267
+ const differences: string[] = [];
268
+ const byte = (value: number) => `$${value.toString(16).padStart(2, "0")}`;
269
+ const differ = (what: string, got: number, wanted: number) => got !== wanted && differences.push(`${what} is ${byte(got)}, not ${byte(wanted)}`);
270
+ for (const segment of image.segments) {
271
+ for (let address = Math.max(segment.start, 2); address <= segment.end; address++) differ(formatC64Address(address), memory[address]!, image.byteAt(address));
272
+ }
273
+ for (let address = 2; address < 0x400; address++) if (address !== plan.stackByte) differ(formatC64Address(address), memory[address]!, state.pages[address]!);
274
+ differ("$00", port[0]!, state.port[0]);
275
+ differ("$01", port[1]!, state.port[1]);
276
+ differ("a", registers.a, state.a);
277
+ differ("x", registers.x, state.x);
278
+ differ("y", registers.y, state.y);
279
+ differ("sp", registers.sp, state.sp);
280
+ const flags = (["n", "v", "d", "i", "z", "c"] as const).reduce((value, name, index) => value | (registers.flags[name] ? [0x80, 0x40, 0x08, 0x04, 0x02, 0x01][index]! : 0), 0);
281
+ differ("flags", flags, state.flags & 0xcf);
282
+ if (state.vic !== undefined) {
283
+ // The display registers and the raster line (D60): the loader waited for the line, so the jump comes in it or the next one.
284
+ const vic = Buffer.from((await client.ioRead(0xd000, 0x2f)).data, "hex");
285
+ for (const { register, mask } of VIC_DISPLAY_REGISTERS) differ(formatC64Address(0xd000 + register), vic[register]! & mask, state.vic.registers[register]! & mask);
286
+ differ("the VIC bank", Buffer.from((await client.ioRead(0xdd00, 1)).data, "hex")[0]! & 0x03, state.vic.bank);
287
+ const line = rasterLine(vic);
288
+ const wanted = rasterLine(state.vic.registers);
289
+ if (line !== wanted && line !== wanted + 1) differences.push(`the raster line is ${line}, not ${wanted}`);
290
+ }
291
+ if (plan.cia !== undefined) {
292
+ // The CIA timers (D63): at the jump a running timer is 3 cycles, the JMP, before its value at the entry.
293
+ const chips = (await client.cia("both")).chips;
294
+ const word = (value: number) => `$${value.toString(16).padStart(4, "0")}`;
295
+ const differWord = (what: string, got: number, wanted: number) => got !== wanted && differences.push(`${what} is ${word(got)}, not ${word(wanted)}`);
296
+ for (const timer of plan.cia.timers) {
297
+ const chip = chips[timer.cia - 1]!;
298
+ const name = `CIA ${timer.cia} timer ${timer.timer}`;
299
+ const [counter, latch, control] = timer.timer === "A" ? [chip.timerA, chip.latchA, chip.controlA] : [chip.timerB, chip.latchB, chip.controlB];
300
+ differ(`${name} control`, control, timer.control);
301
+ differWord(`${name} latch`, latch, timer.latch);
302
+ if (timer.counterAtJump !== undefined) differWord(`${name} counter`, counter, timer.counterAtJump);
303
+ }
304
+ plan.cia.masks.forEach((mask, index) => differ(`CIA ${index + 1} interrupt mask`, chips[index]!.interruptMask, mask));
305
+ }
306
+ return { verified: differences.length === 0, differences: differences.slice(0, MAX_LISTED) };
307
+ } finally {
308
+ await client.close();
309
+ }
310
+ }
311
+
312
+ /** The first words of every file that the script writes. */
313
+ const GENERATED_MARK = "; Generated by c64-source";
314
+ /** The file with every range of instructions that no run executed (D49). */
315
+ const NOT_EXECUTED_FILE = "not_executed.json";
316
+
317
+ /** Removes the .asm files of an earlier run that this run does not write, and an earlier loader.prg and not_executed.json; other files stay. */
318
+ function removeOldFiles(out: string, keep: ReadonlySet<string>): void {
319
+ const directory = resolveProjectPath(out);
320
+ if (!existsSync(directory)) return;
321
+ for (const entry of readdirSync(directory, { withFileTypes: true })) {
322
+ if (!entry.isFile()) continue;
323
+ const name = entry.name;
324
+ const path = join(directory, name);
325
+ if (name === "loader.prg" || name === NOT_EXECUTED_FILE) rmSync(path, { force: true });
326
+ else if (name.endsWith(".asm") && !keep.has(name) && readFileSync(path, "utf8").startsWith(GENERATED_MARK)) rmSync(path, { force: true });
327
+ }
328
+ }
329
+
330
+ async function run(argv: string[]): Promise<{ output: Record<string, unknown>; failed: boolean }> {
331
+ const { values, positionals } = parseArgs({
332
+ args: argv,
333
+ strict: true,
334
+ allowPositionals: true,
335
+ options: { out: { type: "string" }, entry: { type: "string", multiple: true }, coverage: { type: "string", multiple: true }, space: { type: "string" }, loader: { type: "boolean" }, help: { type: "boolean" } },
336
+ });
337
+ if (values.help) throw new UsageError("");
338
+ if (positionals.length !== 1) throw new UsageError("give one image");
339
+ if (values.out === undefined) throw new UsageError("generate needs --out <dir>");
340
+ try {
341
+ resolveProjectPath(values.out);
342
+ } catch {
343
+ throw new UsageError(`--out must be a path relative to the project directory that stays inside it, not ${values.out}`);
344
+ }
345
+ const entries = (values.entry ?? []).map((value) => {
346
+ try {
347
+ return parseC64Address(value);
348
+ } catch {
349
+ throw new UsageError(`--entry must be $ followed by four hex digits, not ${value}`);
350
+ }
351
+ });
352
+ const space = values.space ?? DEFAULT_SPACE;
353
+ if (!SPACE.test(space)) throw new UsageError("--space must be c64, drive8, or bank0 to bank255");
354
+ if (space !== DEFAULT_SPACE && (values.coverage !== undefined || values.loader)) throw new UsageError("--coverage and --loader work only in space c64");
355
+ const image = readImage(positionals[0]!);
356
+ const coverage = readCoverageFiles(values.coverage ?? [], (message) => {
357
+ throw new UsageError(message);
358
+ });
359
+ if (values.loader && (image.statePath === undefined || image.entry === undefined)) throw new UsageError("--loader needs an image.json with an entry and an entry_state.json next to it, from c64-unpacker capture");
360
+ // Read the entry state before the long steps, so a missing file stops the run at once.
361
+ const state = values.loader ? readEntryState(readProjectFile(image.statePath!).bytes.toString("utf8"), image.statePath!) : undefined;
362
+ const knowledge = readKnowledge(space);
363
+ const instructions = await decode(image, knowledge, entries);
364
+ const laid = layout({
365
+ segments: image.segments,
366
+ byteAt: image.byteAt,
367
+ ...(image.entry === undefined ? {} : { entry: image.entry }),
368
+ instructions,
369
+ symbols: knowledge.symbols,
370
+ regions: knowledge.regions,
371
+ comments: knowledge.comments,
372
+ modules: knowledge.modules,
373
+ referenceTargets: knowledge.references.map((reference) => reference.to),
374
+ ...(coverage === undefined ? {} : { coverage }),
375
+ // The platform names are C64 names: drive code and banks get none.
376
+ ...(space === DEFAULT_SPACE ? { platform: platformNames() } : {}),
377
+ });
378
+ const asBytes = await probe(laid);
379
+ const header = [`Generated by c64-source from ${image.path}${space === DEFAULT_SPACE ? "" : ` in space ${space}`} and knowledge revision ${knowledge.revision}.`, "Change names, comments, regions and modules in knowledge, then generate again."];
380
+ const rendered = render(laid, asBytes, header);
381
+ const files = [...rendered.files];
382
+ let loader: LoaderPlan | undefined;
383
+ // An image without its entry has no program: the source is still written and checked, but the run fails (R2-02).
384
+ const missingEntry = entryOutsideImage(image.entry, image.segments);
385
+ // A loader that cannot be planned does not stop the source: the source is written and checked, and the result says why.
386
+ let loaderRefused: string | undefined = state === undefined ? undefined : missingEntry;
387
+ if (state !== undefined && missingEntry === undefined) {
388
+ const segments = image.segments.map((segment) => ({ ...segment, files: laid.pieces.filter((piece) => piece.start >= segment.start && piece.start <= segment.end).map((piece) => piece.file) }));
389
+ try {
390
+ loader = planLoader({ segments, byteAt: image.byteAt, state, entry: image.entry!, header: [`Loader generated by c64-source from ${image.path}.`, "It copies the segments into place, sets the entry state and jumps to the entry."] });
391
+ files.push({ name: "loader.asm", text: loader.text });
392
+ } catch (error) {
393
+ if (!(error instanceof WireFailure)) throw error;
394
+ loaderRefused = error.message;
395
+ }
396
+ }
397
+ // Every generated file starts with the mark, so a later run knows its own files and removes the ones it does not write again (F-25).
398
+ for (const [index, file] of files.entries()) if (!file.text.startsWith(GENERATED_MARK)) files[index] = { ...file, text: `${GENERATED_MARK}. Do not edit: change the knowledge and generate again.\n${file.text}` };
399
+ removeOldFiles(values.out, new Set(files.map((file) => file.name)));
400
+ for (const file of files) writeProjectFile(`${values.out}/${file.name}`, Buffer.from(file.text, "utf8"), { what: "--out" });
401
+ const showRange = (range: Range) => ({ start: formatC64Address(range.start), end: formatC64Address(range.end) });
402
+ const inData = entriesInData(entries, knowledge.regions);
403
+ if (rendered.notExecuted !== undefined) {
404
+ const all = { image: image.path, revision: knowledge.revision, instructions: rendered.notExecuted.instructions, ranges: rendered.notExecuted.ranges.map(showRange) };
405
+ writeProjectFile(`${values.out}/${NOT_EXECUTED_FILE}`, Buffer.from(`${JSON.stringify(all, null, 2)}\n`, "utf8"), { what: "--out" });
406
+ }
407
+
408
+ const output: Record<string, unknown> = {
409
+ out: values.out,
410
+ files: files.map((file) => file.name),
411
+ revision: knowledge.revision,
412
+ instructions: rendered.instructions,
413
+ dataBytes: rendered.dataBytes,
414
+ ...(rendered.undecodedBytes === 0 ? {} : { undecodedBytes: rendered.undecodedBytes }),
415
+ ...(missingEntry === undefined ? {} : { entryOutside: { entry: formatC64Address(image.entry!), reason: missingEntry } }),
416
+ labels: { named: laid.namedLabels, generated: laid.generatedLabels },
417
+ // An --entry in a data region decodes nothing: the result says so, and a code region in the knowledge changes it (F-27).
418
+ ...(inData.length === 0
419
+ ? {}
420
+ : { entriesInData: inData.map(({ entry, region }) => ({ entry: formatC64Address(entry), region: { ...showRange(region), type: region.type }, origin: region.origin })) }),
421
+ ...(rendered.asBytes.length === 0
422
+ ? {}
423
+ : { asBytes: { count: rendered.asBytes.length, listed: rendered.asBytes.slice(0, MAX_LISTED).map((item) => ({ address: formatC64Address(item.address), instruction: item.text })) } }),
424
+ ...(rendered.notExecuted === undefined
425
+ ? {}
426
+ : {
427
+ notExecuted: {
428
+ instructions: rendered.notExecuted.instructions,
429
+ ranges: rendered.notExecuted.ranges.length,
430
+ listed: rendered.notExecuted.ranges.slice(0, MAX_LISTED).map(showRange),
431
+ all: `${values.out}/${NOT_EXECUTED_FILE}`,
432
+ },
433
+ }),
434
+ ...(laid.unusedModules.length === 0 ? {} : { unusedModules: laid.unusedModules.map((module) => ({ start: formatC64Address(module.start), file: module.file })) }),
435
+ };
436
+ // The loader includes the source, so it is built only from a source that is identical to the image.
437
+ const notBuilt = (reason: string) => (state === undefined ? {} : { loader: { verified: false, reason: loaderRefused ?? reason } });
438
+ const built = await assembleFiles(files, "main.asm");
439
+ if (built.program === undefined) {
440
+ return { output: { ...output, check: { identical: false, diagnostics: built.diagnostics.slice(0, MAX_LISTED) }, ...notBuilt("The source does not assemble, and the loader includes it.") }, failed: true };
441
+ }
442
+ const checked = check(built.program, built.start!, image);
443
+ output.check = checked;
444
+ if (!checked.identical) return { output: { ...output, ...notBuilt("The source does not assemble to the image, and the loader includes it.") }, failed: true };
445
+ if (loaderRefused !== undefined) return { output: { ...output, loader: { verified: false, reason: loaderRefused } }, failed: true };
446
+ if (loader === undefined || state === undefined) return { output, failed: missingEntry !== undefined };
447
+
448
+ const loaderBuilt = await assembleFiles(files, "loader.asm");
449
+ if (loaderBuilt.program === undefined) return { output: { ...output, loader: { verified: false, reason: "loader.asm does not assemble.", diagnostics: loaderBuilt.diagnostics.slice(0, MAX_LISTED) } }, failed: true };
450
+ const program = loaderBuilt.program;
451
+ // The simulation: the loader in memory as it loads, then every copy in its order, byte by byte.
452
+ const memory = new Int16Array(0x10000).fill(-1);
453
+ program.subarray(2).forEach((byte, offset) => (memory[loaderBuilt.start! + offset] = byte));
454
+ const expected = (address: number) => (image.segments.some((segment) => address >= segment.start && address <= segment.end) ? image.byteAt(address) : state.pages[address]!);
455
+ const wrongPacking = loader.blocks.find((block) => {
456
+ for (let address = block.range.start; address <= block.range.end; address++) if (memory[block.packedAt + address - block.range.start] !== block.bytes(address)) return true;
457
+ return false;
458
+ });
459
+ if (loaderBuilt.start !== 0x0801 || loaderBuilt.start + program.length - 3 !== loader.end || wrongPacking !== undefined) {
460
+ throw new WireFailure("operation-failed", `The assembled loader does not have the planned layout${wrongPacking === undefined ? "" : ` (${wrongPacking.what})`}. This is a defect of the c64-source script.`);
461
+ }
462
+ const jumpSymbol = loaderBuilt.symbols?.find((symbol) => symbol.name === LOADER_JUMP);
463
+ if (jumpSymbol === undefined) throw new WireFailure("operation-failed", `The assembled loader has no ${LOADER_JUMP} label. This is a defect of the c64-source script.`);
464
+ const jump = jumpSymbol.kind === "address" ? jumpSymbol.address : jumpSymbol.value;
465
+ const wrong = simulate(memory, loader.copies, expected);
466
+ if (wrong !== undefined) throw new WireFailure("operation-failed", `The simulated loader writes a wrong byte at ${formatC64Address(wrong)}. This is a defect of the c64-source script.`);
467
+ const loaderPath = `${values.out}/loader.prg`;
468
+ writeProjectFile(loaderPath, program, { what: "--out" });
469
+ (output.files as string[]).push("loader.prg");
470
+ const ran = await runLoader(loaderPath, program, loader, image, state, jump);
471
+ output.loader = {
472
+ program: loaderPath,
473
+ copies: loader.copies.map((copy) => ({ what: copy.what, to: `${formatC64Address(copy.target.start)}-${formatC64Address(copy.target.end)}` })),
474
+ copier: formatC64Address(loader.copier),
475
+ stackByte: formatC64Address(loader.stackByte),
476
+ ...ran,
477
+ // What the loader cannot set as it was at the entry (D63).
478
+ ...(loader.cia === undefined || loader.cia.limits.length === 0 ? {} : { limits: loader.cia.limits }),
479
+ };
480
+ return { output, failed: !ran.verified };
481
+ }
482
+
483
+ await runScript(
484
+ async () => {
485
+ const { output, failed } = await run(process.argv.slice(2));
486
+ if (failed) process.exitCode = 1;
487
+ return output;
488
+ },
489
+ { usage: USAGE, refusals: [KnowledgeError] },
490
+ );