@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,493 @@
1
+ // Functional-equivalence scenarios: a small JSON scenario of ordered
2
+ // steps runs on the original and then on the rebuild in one VICE session.
3
+ // Observations name semantic symbols that resolve per side, comparisons are
4
+ // explicit, and the result is PASS, FAIL or INCONCLUSIVE.
5
+
6
+ import { formatC64Address, parseC64Address } from "#src/c64.ts";
7
+ import { MAX_BASELINES, MAX_MASK_RECTANGLES, MAX_MEMORY_READ, maskCoversFrame, WireFailure } from "#src/host-client/tools.ts";
8
+ import { LOAD_LIMIT_FRAMES, RUN_LIMIT_FRAMES, runProgram } from "#src/host-client/autostart.ts";
9
+ import { ACTION_STEP_KEYS, checkActionStep, checkLocation, isInteger, isObject, runActionStep, type ActionStep, type Location } from "#src/host-client/steps.ts";
10
+ import { readProjectFile, writeProjectFile } from "#src/host-client/transfer.ts";
11
+ import type { ViceSessionClient } from "#src/host-client/vice-session.ts";
12
+ import { openForRead } from "#src/knowledge/database.ts";
13
+ import { listSymbols } from "#src/knowledge/read.ts";
14
+
15
+ export class ScenarioError extends Error {}
16
+
17
+ export type Side = "original" | "rebuild";
18
+ export const SIDES: Side[] = ["original", "rebuild"];
19
+
20
+ /** A "$xxxx" address or a symbol name that each side resolves on its own. */
21
+ export type { Location };
22
+
23
+ export interface MemoryObservation {
24
+ at: Location;
25
+ /** Bytes from `at`; 1 when not given. */
26
+ size?: number;
27
+ /** The largest allowed difference per byte; 0 when not given. */
28
+ tolerance?: number;
29
+ /** Bytes in the range that the comparison leaves out, each with the reason (D46). */
30
+ exclude?: Array<{ at: Location; reason: string }>;
31
+ }
32
+
33
+ export interface ScreenPolicy {
34
+ /** The largest allowed fraction of different pixels; 0 (exact) when not given. */
35
+ maxMismatchRatio?: number;
36
+ /** Rectangles that the comparison ignores, in screen pixels. */
37
+ mask?: Array<{ x: number; y: number; width: number; height: number }>;
38
+ }
39
+
40
+ type StepBody =
41
+ | { reset: "hard" | "soft" }
42
+ | { load: true }
43
+ | { autostart: true }
44
+ | { snapshot: string }
45
+ | { normalize: string }
46
+ | ActionStep
47
+ | { observe: string; memory?: Array<Location | MemoryObservation>; registers?: ObservedRegister[]; screen?: ScreenPolicy; vicii?: boolean; sprites?: number[] };
48
+
49
+ /** A step; with side, only that side does it (D46). An observe step has no side: it compares both. */
50
+ export type Step = StepBody & { side?: Side };
51
+
52
+ export interface Subject {
53
+ program: string;
54
+ /** "knowledge" for knowledge.db, a project JSON file (c64-assembler output or a name → "$xxxx" map), or "none". */
55
+ symbols: string;
56
+ }
57
+
58
+ export interface Scenario {
59
+ name: string;
60
+ videoStandard?: "pal" | "ntsc";
61
+ original: Subject;
62
+ rebuild: Subject;
63
+ steps: Step[];
64
+ }
65
+
66
+ export type Outcome = "PASS" | "FAIL" | "INCONCLUSIVE";
67
+
68
+ export interface Difference {
69
+ checkpoint: string;
70
+ what: string;
71
+ original: unknown;
72
+ rebuild: unknown;
73
+ }
74
+
75
+ export interface ScenarioResult {
76
+ result: Outcome;
77
+ scenario: string;
78
+ /** Why the result is INCONCLUSIVE. */
79
+ reason?: string;
80
+ checkpoints: string[];
81
+ differences: Difference[];
82
+ /** The bytes that the comparison left out, with their reasons (D46). */
83
+ excluded?: Array<{ checkpoint: string; at: Location; reason: string }>;
84
+ /** The files that a FAIL wrote with --out. */
85
+ files?: string[];
86
+ }
87
+
88
+ const STEP_KINDS = ["reset", "load", "autostart", "snapshot", "normalize", "write", "registers", "joystick", "type", "frames", "runUntil", "observe"];
89
+
90
+ /** The keys each kind of step can have. */
91
+ const STEP_KEYS: Record<string, readonly string[]> = {
92
+ ...ACTION_STEP_KEYS,
93
+ observe: ["observe", "memory", "registers", "screen", "vicii", "sprites"],
94
+ };
95
+
96
+ /** The registers an observe step can compare. */
97
+ export const OBSERVED_REGISTERS = ["pc", "a", "x", "y", "sp", "flags"] as const;
98
+ type ObservedRegister = (typeof OBSERVED_REGISTERS)[number];
99
+
100
+ /** Reads and checks a scenario file. Throws ScenarioError naming the first problem. */
101
+ export function readScenario(path: string): Scenario {
102
+ let value: unknown;
103
+ try {
104
+ value = JSON.parse(readProjectFile(path).bytes.toString("utf8"));
105
+ } catch (error) {
106
+ throw new ScenarioError(`cannot read the scenario ${path}: ${(error as Error).message}`);
107
+ }
108
+ const fail = (message: string): never => {
109
+ throw new ScenarioError(`scenario ${path}: ${message}`);
110
+ };
111
+ if (!isObject(value)) return fail("is not a JSON object");
112
+ const scenario = value as unknown as Scenario;
113
+ if (typeof scenario.name !== "string" || scenario.name === "") fail("needs a name");
114
+ if (scenario.videoStandard !== undefined && scenario.videoStandard !== "pal" && scenario.videoStandard !== "ntsc") fail("videoStandard must be pal or ntsc");
115
+ for (const side of SIDES) {
116
+ const subject = scenario[side];
117
+ if (typeof subject?.program !== "string" || typeof subject.symbols !== "string") fail(`${side} needs a program and symbols`);
118
+ }
119
+ if (!Array.isArray(scenario.steps) || scenario.steps.length === 0) fail("needs steps");
120
+ const labels = new Set<string>();
121
+ let screens = 0;
122
+ scenario.steps.forEach((step: unknown, index) => {
123
+ const at = `step ${index + 1}`;
124
+ if (!isObject(step)) return fail(`${at} must be an object`);
125
+ // An observe step names what it observes with "memory" and "registers" too.
126
+ const kinds = "observe" in step ? ["observe"] : Object.keys(step).filter((key) => STEP_KINDS.includes(key));
127
+ if (kinds.length !== 1) return fail(`${at} must have exactly one of ${STEP_KINDS.join(", ")}`);
128
+ const kind = kinds[0]!;
129
+ const allowed = [...(STEP_KEYS[kind] ?? [kind]), ...(kind === "observe" ? [] : ["side"])];
130
+ for (const key of Object.keys(step)) if (!allowed.includes(key)) fail(`${at} has the key ${key}, which a ${kind} step does not have`);
131
+ if (step.side !== undefined && !(SIDES as unknown[]).includes(step.side)) fail(`${at}: side must be original or rebuild`);
132
+ checkStep(kind, step, at, fail, scenario.videoStandard ?? "pal");
133
+ if (kind === "observe") {
134
+ if (labels.has(step.observe as string)) fail(`${at} needs a new checkpoint name`);
135
+ labels.add(step.observe as string);
136
+ if (step.screen !== undefined) screens++;
137
+ }
138
+ });
139
+ if (labels.size === 0) fail("observes nothing; add an observe step");
140
+ if (screens > MAX_BASELINES) fail(`observes the screen at ${screens} checkpoints; the limit is ${MAX_BASELINES}`);
141
+ return scenario;
142
+ }
143
+
144
+ /** Checks the value of one step against its kind. */
145
+ function checkStep(kind: string, step: Record<string, unknown>, at: string, fail: (message: string) => never, videoStandard: "pal" | "ntsc"): void {
146
+ switch (kind) {
147
+ case "reset":
148
+ if (step.reset !== "hard" && step.reset !== "soft") fail(`${at}: reset must be hard or soft`);
149
+ return;
150
+ case "load":
151
+ case "autostart":
152
+ if (step[kind] !== true) fail(`${at}: ${kind} must be true`);
153
+ return;
154
+ case "snapshot":
155
+ if (typeof step.snapshot !== "string" || step.snapshot === "") fail(`${at}: snapshot needs a .vsf file in the project`);
156
+ return;
157
+ case "normalize":
158
+ if (typeof step.normalize !== "string" || step.normalize === "") fail(`${at}: normalize needs an entry state file in the project`);
159
+ return;
160
+ case "write":
161
+ case "registers":
162
+ case "joystick":
163
+ case "type":
164
+ case "frames":
165
+ case "runUntil":
166
+ return checkActionStep(kind, step, at, fail);
167
+ default:
168
+ checkObserve(step, at, fail, videoStandard);
169
+ }
170
+ }
171
+
172
+ function checkObserve(step: Record<string, unknown>, at: string, fail: (message: string) => never, videoStandard: "pal" | "ntsc"): void {
173
+ if (typeof step.observe !== "string" || step.observe === "") fail(`${at} needs a new checkpoint name`);
174
+ if (step.memory !== undefined) {
175
+ if (!Array.isArray(step.memory) || step.memory.length === 0) fail(`${at}: memory must be a list of names, $ addresses or objects with at`);
176
+ for (const item of step.memory as unknown[]) {
177
+ if (typeof item === "string") {
178
+ checkLocation(item, `${at}: memory`, fail);
179
+ continue;
180
+ }
181
+ if (!isObject(item) || Object.keys(item).some((key) => !["at", "size", "tolerance", "exclude"].includes(key))) return fail(`${at}: a memory object has at, size, tolerance and exclude, and nothing else`);
182
+ if (item.exclude !== undefined) {
183
+ if (!Array.isArray(item.exclude) || item.exclude.length === 0) fail(`${at}: memory.exclude must be a list of objects with at and reason`);
184
+ for (const excluded of item.exclude as unknown[]) {
185
+ if (!isObject(excluded) || Object.keys(excluded).some((key) => key !== "at" && key !== "reason")) return fail(`${at}: an exclude has at and reason, and nothing else`);
186
+ checkLocation(excluded.at, `${at}: memory.exclude.at`, fail);
187
+ if (typeof excluded.reason !== "string" || excluded.reason.trim() === "") fail(`${at}: an exclude needs a reason: why this byte may differ`);
188
+ }
189
+ }
190
+ checkLocation(item.at, `${at}: memory.at`, fail);
191
+ if (item.size !== undefined && !isInteger(item.size, 1, MAX_MEMORY_READ)) fail(`${at}: memory.size must be a number from 1 to ${MAX_MEMORY_READ}`);
192
+ if (item.tolerance !== undefined && !isInteger(item.tolerance, 0, 0xff)) fail(`${at}: memory.tolerance must be a number from 0 to 255`);
193
+ }
194
+ }
195
+ if (step.registers !== undefined) {
196
+ const names = step.registers;
197
+ if (!Array.isArray(names) || names.length === 0) return fail(`${at}: registers must be a list of ${OBSERVED_REGISTERS.join(", ")}`);
198
+ for (const name of names) if (!(OBSERVED_REGISTERS as readonly unknown[]).includes(name)) fail(`${at}: registers can have only ${OBSERVED_REGISTERS.join(", ")}, not ${JSON.stringify(name)}`);
199
+ if (new Set(names).size !== names.length) fail(`${at}: registers names a register two times`);
200
+ }
201
+ if (step.screen !== undefined) {
202
+ const screen = step.screen;
203
+ if (!isObject(screen) || Object.keys(screen).some((key) => key !== "maxMismatchRatio" && key !== "mask")) return fail(`${at}: screen can have maxMismatchRatio and mask, and nothing else`);
204
+ if (screen.maxMismatchRatio !== undefined && !(typeof screen.maxMismatchRatio === "number" && screen.maxMismatchRatio >= 0 && screen.maxMismatchRatio <= 1)) {
205
+ fail(`${at}: screen.maxMismatchRatio must be a number from 0 to 1`);
206
+ }
207
+ if (screen.mask !== undefined) {
208
+ if (!Array.isArray(screen.mask) || screen.mask.length > MAX_MASK_RECTANGLES) return fail(`${at}: screen.mask must be a list of at most ${MAX_MASK_RECTANGLES} rectangles`);
209
+ for (const rectangle of screen.mask as unknown[]) {
210
+ const ok =
211
+ isObject(rectangle) &&
212
+ Object.keys(rectangle).every((key) => ["x", "y", "width", "height"].includes(key)) &&
213
+ isInteger(rectangle.x, 0, 4095) &&
214
+ isInteger(rectangle.y, 0, 4095) &&
215
+ isInteger(rectangle.width, 1, 4096) &&
216
+ isInteger(rectangle.height, 1, 4096);
217
+ if (!ok) fail(`${at}: a screen.mask rectangle needs whole numbers x and y from 0, and width and height from 1`);
218
+ }
219
+ // The emulator refuses such a compare too, but only after the original side ran (b2/03).
220
+ if (maskCoversFrame(screen.mask as Array<{ x: number; y: number; width: number; height: number }>, videoStandard)) fail(`${at}: screen.mask covers the whole screen, so nothing is compared`);
221
+ }
222
+ }
223
+ if (step.vicii !== undefined && typeof step.vicii !== "boolean") fail(`${at}: vicii must be true or false`);
224
+ if (step.sprites !== undefined) {
225
+ const sprites = step.sprites;
226
+ if (!Array.isArray(sprites) || sprites.length === 0 || !sprites.every((sprite) => isInteger(sprite, 0, 7)) || new Set(sprites).size !== sprites.length) {
227
+ fail(`${at}: sprites must be a list of different sprite numbers from 0 to 7`);
228
+ }
229
+ }
230
+ }
231
+
232
+ /** Name → address for one side. */
233
+ export function loadSymbols(subject: Subject): Map<string, number> {
234
+ const symbols = new Map<string, number>();
235
+ if (subject.symbols === "none") return symbols;
236
+ if (subject.symbols === "knowledge") {
237
+ const db = openForRead();
238
+ for (const symbol of listSymbols(db)) symbols.set(symbol.name, symbol.address);
239
+ db?.close();
240
+ return symbols;
241
+ }
242
+ let value: unknown;
243
+ try {
244
+ value = JSON.parse(readProjectFile(subject.symbols).bytes.toString("utf8"));
245
+ } catch (error) {
246
+ throw new ScenarioError(`cannot read the symbols ${subject.symbols}: ${(error as Error).message}`);
247
+ }
248
+ // c64-assembler output, or a plain name → "$xxxx" map. A constant in the
249
+ // assembler output counts too: RAM variables such as "counter = $c100" lie
250
+ // outside the program, so the assembler reports them as constants.
251
+ const assembled = (value as { symbols?: Array<{ name: string; address?: string; value?: number }> }).symbols;
252
+ const entries: Array<[string, unknown]> = Array.isArray(assembled)
253
+ ? assembled.flatMap((symbol): Array<[string, unknown]> => {
254
+ if (symbol.address !== undefined) return [[symbol.name, symbol.address]];
255
+ if (Number.isInteger(symbol.value) && symbol.value! >= 0 && symbol.value! <= 0xffff) return [[symbol.name, formatC64Address(symbol.value!)]];
256
+ return [];
257
+ })
258
+ : Object.entries(value as Record<string, unknown>);
259
+ for (const [name, address] of entries) {
260
+ try {
261
+ symbols.set(name, parseC64Address(String(address)));
262
+ } catch {
263
+ throw new ScenarioError(`the symbol ${name} in ${subject.symbols} has no $xxxx address`);
264
+ }
265
+ }
266
+ return symbols;
267
+ }
268
+
269
+ /** A failure that makes the comparison impossible: the result is INCONCLUSIVE. */
270
+ class Inconclusive extends Error {}
271
+
272
+ export function resolve(location: Location, symbols: Map<string, number>, side: Side): number {
273
+ if (location.startsWith("$")) {
274
+ try {
275
+ return parseC64Address(location);
276
+ } catch {
277
+ throw new ScenarioError(`${location} is not $ followed by four hex digits`);
278
+ }
279
+ }
280
+ const address = symbols.get(location);
281
+ if (address === undefined) throw new Inconclusive(`the ${side} side has no symbol ${location}`);
282
+ return address;
283
+ }
284
+
285
+ interface Observation {
286
+ memory: Array<{ label: string; address: number; bytes: number[]; tolerance: number; excluded: Array<{ offset: number; at: Location; reason: string }> }>;
287
+ registers: Partial<Record<ObservedRegister, unknown>>;
288
+ screen?: { match: boolean; mismatchingPixels: number; comparedPixels: number; mismatchRatio: number; bounds?: unknown };
289
+ vicii?: unknown;
290
+ sprites?: unknown;
291
+ /** PNG images for the files of a FAIL (D46): the side's screen, and on the rebuild the difference image. */
292
+ png?: string;
293
+ diffPng?: string;
294
+ }
295
+
296
+ /** Writes pages 0-3 and $00/$01 from an entry state file (D43, D46): both sides then start from the same RAM. */
297
+ async function normalize(client: ViceSessionClient, path: string): Promise<void> {
298
+ let state: { pages?: Record<string, string>; cpuPort?: Record<string, number> };
299
+ try {
300
+ state = JSON.parse(readProjectFile(path).bytes.toString("utf8")) as typeof state;
301
+ } catch (error) {
302
+ if (error instanceof WireFailure) throw error;
303
+ throw new ScenarioError(`${path} is not an entry state file: ${(error as Error).message}`);
304
+ }
305
+ const pages = ["$0000", "$0100", "$0200", "$0300"].map((page) => state.pages?.[page]);
306
+ const port = [state.cpuPort?.$00, state.cpuPort?.$01];
307
+ if (pages.some((page) => typeof page !== "string" || !/^[0-9a-f]{512}$/.test(page)) || port.some((value) => !isInteger(value, 0, 0xff))) {
308
+ throw new ScenarioError(`${path} is not an entry state file: it needs pages $0000 to $0300 and cpuPort $00 and $01`);
309
+ }
310
+ if ((await client.status()).state === "running") await client.execution({ action: "pause", space: "c64" });
311
+ // Pages 0 to 3 in RAM from $0002, then the CPU port: the port bits switch what the CPU sees.
312
+ await client.memoryWrite({ address: 0x0002, data: pages.join("").slice(4), space: "c64", view: "ram" });
313
+ await client.memoryWrite({ address: 0x0000, data: Buffer.from(port as number[]).toString("hex"), space: "c64", view: "cpu" });
314
+ }
315
+
316
+ /**
317
+ * A session baseline name for a checkpoint: letters, digits, dots, underscores
318
+ * or hyphens. The checkpoint's number comes first, so two checkpoints never
319
+ * get the same name.
320
+ */
321
+ export function baselineName(checkpoint: number, label: string): string {
322
+ return `test-${checkpoint}-${label}`.replace(/[^A-Za-z0-9._-]/g, "_").slice(0, 64);
323
+ }
324
+
325
+ /** Autostart that returns only when the program runs (runProgram), or INCONCLUSIVE with the reason. */
326
+ async function startProgram(client: ViceSessionClient, program: string, side: Side): Promise<void> {
327
+ const failed = await runProgram(client, program, readProjectFile(program).bytes);
328
+ if (failed === "not-loaded") throw new Inconclusive(`the ${side} program was not in memory after ${LOAD_LIMIT_FRAMES} frames of autostart`);
329
+ if (failed === "not-started") throw new Inconclusive(`the ${side} program did not start within ${RUN_LIMIT_FRAMES} frames of RUN; autostart runs only a program with a BASIC start line`);
330
+ }
331
+
332
+ async function runSide(client: ViceSessionClient, scenario: Scenario, side: Side, symbols: Map<string, number>, files: boolean): Promise<Map<string, Observation>> {
333
+ const subject = scenario[side];
334
+ const observations = new Map<string, Observation>();
335
+ const at = (location: Location) => resolve(location, symbols, side);
336
+ // Each side starts from a hard reset, stopped at the reset vector, with the control ports released.
337
+ await client.reset({ mode: "hard", run: false });
338
+ await client.joystick({ port: 1, direction: "center", fire: false });
339
+ await client.joystick({ port: 2, direction: "center", fire: false });
340
+ let checkpoint = 0;
341
+ for (const step of scenario.steps) {
342
+ if (step.side !== undefined && step.side !== side) continue;
343
+ // Observe first: an observe step may also carry "registers" or "memory".
344
+ if ("observe" in step) {
345
+ checkpoint++;
346
+ const observation: Observation = { memory: [], registers: {} };
347
+ for (const item of step.memory ?? []) {
348
+ const spec: MemoryObservation = typeof item === "string" ? { at: item } : item;
349
+ const address = at(spec.at);
350
+ const size = spec.size ?? 1;
351
+ const read = await client.memoryRead({ address, size, space: "c64", view: "cpu" });
352
+ const excluded = (spec.exclude ?? []).map((exclude) => {
353
+ const offset = at(exclude.at) - address;
354
+ if (offset < 0 || offset >= size) throw new ScenarioError(`the exclude ${exclude.at} lies outside the observed ${spec.at} (${size} bytes)`);
355
+ return { offset, at: exclude.at, reason: exclude.reason };
356
+ });
357
+ observation.memory.push({ label: size > 1 ? `${spec.at} (${size} bytes)` : spec.at, address, bytes: [...Buffer.from(read.data, "hex")], tolerance: spec.tolerance ?? 0, excluded });
358
+ }
359
+ if (step.registers !== undefined) {
360
+ const registers = await client.registersGet("c64");
361
+ for (const name of step.registers) observation.registers[name] = registers[name];
362
+ }
363
+ if (step.vicii) {
364
+ // The raster line changes from one moment to the next; the comparison leaves it out.
365
+ const { rasterLine: _rasterLine, ...state } = await client.vicii();
366
+ observation.vicii = state;
367
+ }
368
+ if (step.sprites !== undefined) observation.sprites = (await client.sprites(step.sprites)).sprites;
369
+ if (step.screen !== undefined) {
370
+ const baseline = baselineName(checkpoint, step.observe);
371
+ if (side === "original") {
372
+ const shot = await client.screenCapture(baseline);
373
+ if (files) observation.png = shot.png;
374
+ } else {
375
+ const compared = await client.screenCompare({ baseline, maxMismatchRatio: step.screen.maxMismatchRatio ?? 0, mask: step.screen.mask ?? [], includeDiff: files });
376
+ observation.screen = { match: compared.match, mismatchingPixels: compared.mismatchingPixels, comparedPixels: compared.comparedPixels, mismatchRatio: compared.mismatchRatio, ...(compared.bounds === undefined ? {} : { bounds: compared.bounds }) };
377
+ if (files) {
378
+ observation.png = (await client.screenCapture()).png;
379
+ if (compared.diffPng !== undefined) observation.diffPng = compared.diffPng;
380
+ }
381
+ await client.screenDiscard(baseline);
382
+ }
383
+ }
384
+ observations.set(step.observe, observation);
385
+ } else if ("reset" in step) {
386
+ // Stopped at the reset vector, so frame counts from here are exact.
387
+ await client.reset({ mode: step.reset, run: false });
388
+ } else if ("load" in step) {
389
+ await client.programLoad({ path: subject.program });
390
+ } else if ("autostart" in step) {
391
+ await startProgram(client, subject.program, side);
392
+ } else if ("snapshot" in step) {
393
+ await client.snapshotRestore(readProjectFile(step.snapshot).bytes);
394
+ } else if ("normalize" in step) {
395
+ await normalize(client, step.normalize);
396
+ } else {
397
+ const { side: _side, ...action } = step;
398
+ const notReached = await runActionStep(client, action as ActionStep, at);
399
+ if (notReached !== undefined && "runUntil" in step) {
400
+ throw new Inconclusive(`the ${side} side did not reach the checkpoint before ${step.timeoutFrames} frames (it stopped by ${notReached.stopReason} at ${formatC64Address(notReached.pc)})`);
401
+ }
402
+ }
403
+ }
404
+ return observations;
405
+ }
406
+
407
+ /** The differences between the two sides' observations at each checkpoint, under the scenario's rules. */
408
+ export function compare(original: Map<string, Observation>, rebuild: Map<string, Observation>): Difference[] {
409
+ const differences: Difference[] = [];
410
+ for (const [checkpoint, left] of original) {
411
+ const right = rebuild.get(checkpoint)!;
412
+ left.memory.forEach((item, index) => {
413
+ const other = right.memory[index]!;
414
+ const skipped = new Set(item.excluded.map((excluded) => excluded.offset));
415
+ if (item.bytes.some((byte, at) => !skipped.has(at) && Math.abs(byte - other.bytes[at]!) > item.tolerance)) {
416
+ const show = (bytes: number[]) => (bytes.length === 1 ? bytes[0] : Buffer.from(bytes).toString("hex"));
417
+ differences.push({ checkpoint, what: `memory ${item.label}`, original: show(item.bytes), rebuild: show(other.bytes) });
418
+ }
419
+ });
420
+ for (const [name, value] of Object.entries(left.registers)) {
421
+ const other = right.registers[name as ObservedRegister];
422
+ if (JSON.stringify(other) !== JSON.stringify(value)) differences.push({ checkpoint, what: `register ${name}`, original: value, rebuild: other });
423
+ }
424
+ const screenDiffers = right.screen !== undefined && !right.screen.match;
425
+ if (screenDiffers) differences.push({ checkpoint, what: "screen", original: "baseline", rebuild: right.screen });
426
+ if (left.vicii !== undefined && JSON.stringify(left.vicii) !== JSON.stringify(right.vicii)) differences.push({ checkpoint, what: "VIC-II state", original: left.vicii, rebuild: right.vicii });
427
+ if (left.sprites !== undefined && JSON.stringify(left.sprites) !== JSON.stringify(right.sprites)) differences.push({ checkpoint, what: "sprites", original: left.sprites, rebuild: right.sprites });
428
+ }
429
+ return differences;
430
+ }
431
+
432
+ /**
433
+ * Writes the files of a FAIL into `out` (D46): for a screen difference both
434
+ * screens and the difference image, and memory.json with each differing byte.
435
+ */
436
+ function writeFailFiles(out: string, original: Map<string, Observation>, rebuild: Map<string, Observation>, differences: Difference[]): string[] {
437
+ const files: string[] = [];
438
+ const write = (name: string, bytes: Uint8Array) => {
439
+ writeProjectFile(`${out}/${name}`, bytes, { what: "--out" });
440
+ files.push(name);
441
+ };
442
+ const memory: Array<{ checkpoint: string; what: string; bytes: Array<{ address: string; original: number; rebuild: number }> }> = [];
443
+ for (const difference of differences) {
444
+ const left = original.get(difference.checkpoint)!;
445
+ const right = rebuild.get(difference.checkpoint)!;
446
+ const name = difference.checkpoint.replace(/[^A-Za-z0-9._-]/g, "_");
447
+ if (difference.what === "screen") {
448
+ if (left.png !== undefined) write(`${name}-original.png`, Buffer.from(left.png, "base64"));
449
+ if (right.png !== undefined) write(`${name}-rebuild.png`, Buffer.from(right.png, "base64"));
450
+ if (right.diffPng !== undefined) write(`${name}-diff.png`, Buffer.from(right.diffPng, "base64"));
451
+ }
452
+ if (difference.what.startsWith("memory ")) {
453
+ const index = left.memory.findIndex((item) => `memory ${item.label}` === difference.what);
454
+ const item = left.memory[index]!;
455
+ const other = right.memory[index]!;
456
+ const skipped = new Set(item.excluded.map((excluded) => excluded.offset));
457
+ const bytes = item.bytes.flatMap((byte, offset) => (!skipped.has(offset) && Math.abs(byte - other.bytes[offset]!) > item.tolerance ? [{ address: formatC64Address(item.address + offset), original: byte, rebuild: other.bytes[offset]! }] : []));
458
+ memory.push({ checkpoint: difference.checkpoint, what: difference.what, bytes });
459
+ }
460
+ }
461
+ if (memory.length > 0) write("memory.json", Buffer.from(`${JSON.stringify(memory, null, 2)}\n`));
462
+ return files;
463
+ }
464
+
465
+ /** Runs the scenario on the original, then on the rebuild, in one VICE session. With out, a FAIL leaves files there. */
466
+ export async function runScenario(client: ViceSessionClient, scenario: Scenario, options: { out?: string } = {}): Promise<ScenarioResult> {
467
+ const checkpoints = scenario.steps.flatMap((step) => ("observe" in step ? [step.observe] : []));
468
+ const observed = new Map<Side, Map<string, Observation>>();
469
+ for (const side of SIDES) {
470
+ try {
471
+ observed.set(side, await runSide(client, scenario, side, loadSymbols(scenario[side]), options.out !== undefined));
472
+ } catch (error) {
473
+ if (error instanceof Inconclusive) return { result: "INCONCLUSIVE", scenario: scenario.name, reason: error.message, checkpoints, differences: [] };
474
+ if (error instanceof WireFailure) {
475
+ return { result: "INCONCLUSIVE", scenario: scenario.name, reason: `the ${side} side could not run: ${error.message}`, checkpoints, differences: [] };
476
+ }
477
+ throw error;
478
+ }
479
+ }
480
+ const original = observed.get("original")!;
481
+ const rebuild = observed.get("rebuild")!;
482
+ const differences = compare(original, rebuild);
483
+ const excluded = [...original].flatMap(([checkpoint, observation]) => observation.memory.flatMap((item) => item.excluded.map((excluded) => ({ checkpoint, at: excluded.at, reason: excluded.reason }))));
484
+ const files = differences.length > 0 && options.out !== undefined ? writeFailFiles(options.out, original, rebuild, differences) : [];
485
+ return {
486
+ result: differences.length === 0 ? "PASS" : "FAIL",
487
+ scenario: scenario.name,
488
+ checkpoints,
489
+ differences,
490
+ ...(excluded.length === 0 ? {} : { excluded }),
491
+ ...(files.length === 0 ? {} : { files }),
492
+ };
493
+ }
@@ -0,0 +1,65 @@
1
+ // The c64-testing script: runs a scenario on the original and then on the
2
+ // rebuild in one emulator, and prints PASS, FAIL or INCONCLUSIVE with the
3
+ // differences. Exit status: 0 PASS, 1 FAIL, 3 INCONCLUSIVE, 2 a bad call.
4
+
5
+ import { parseArgs } from "node:util";
6
+
7
+ import { ViceSessionClient } from "#src/host-client/vice-session.ts";
8
+ import { WireFailure } from "#src/host-client/tools.ts";
9
+ import { resolveProjectPath } from "#src/project.ts";
10
+ import { runScript, UsageError } from "#src/script.ts";
11
+ import { readScenario, runScenario, ScenarioError, type ScenarioResult } from "./scenario.ts";
12
+
13
+ const USAGE = `test.ts <scenario.json> [--out <dir>]
14
+
15
+ <scenario.json> is a scenario file relative to the project directory. See
16
+ references/scenario-format.md. With --out, a FAIL writes both screens, the
17
+ difference image and memory.json with each differing byte into <dir>. The
18
+ result is one JSON object.`;
19
+
20
+ const EXIT = { PASS: 0, FAIL: 1, INCONCLUSIVE: 3 } as const;
21
+
22
+ async function run(argv: string[]): Promise<ScenarioResult> {
23
+ const { values, positionals } = parseArgs({ args: argv, strict: true, allowPositionals: true, options: { out: { type: "string" }, help: { type: "boolean" } } });
24
+ if (values.help) throw new UsageError("");
25
+ if (positionals.length !== 1) throw new UsageError("give one scenario file");
26
+ if (values.out !== undefined) {
27
+ try {
28
+ resolveProjectPath(values.out);
29
+ } catch {
30
+ throw new UsageError(`--out must be a path relative to the project directory that stays inside it, not ${values.out}`);
31
+ }
32
+ }
33
+ try {
34
+ return await runScenarioFile(positionals[0]!, values.out);
35
+ } catch (error) {
36
+ // A scenario or symbol file that is not usable is a bad call.
37
+ if (error instanceof ScenarioError) throw new UsageError(error.message);
38
+ throw error;
39
+ }
40
+ }
41
+
42
+ async function runScenarioFile(path: string, out: string | undefined): Promise<ScenarioResult> {
43
+ const scenario = readScenario(path);
44
+ let client: ViceSessionClient;
45
+ try {
46
+ client = await ViceSessionClient.open({ videoStandard: scenario.videoStandard ?? "pal" });
47
+ } catch (error) {
48
+ if (!(error instanceof WireFailure)) throw error;
49
+ return { result: "INCONCLUSIVE", scenario: scenario.name, reason: `no emulator: ${error.message}`, checkpoints: [], differences: [] };
50
+ }
51
+ try {
52
+ return await runScenario(client, scenario, out === undefined ? {} : { out });
53
+ } finally {
54
+ await client.close();
55
+ }
56
+ }
57
+
58
+ await runScript(
59
+ async () => {
60
+ const result = await run(process.argv.slice(2));
61
+ process.exitCode = EXIT[result.result];
62
+ return result;
63
+ },
64
+ { usage: USAGE },
65
+ );