@henols/c64-re-tools 2.0.0-rc.2 → 2.0.0-rc.21

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 (126) hide show
  1. package/README.md +28 -62
  2. package/distribution/package.ts +1 -16
  3. package/distribution/plugin.ts +5 -3
  4. package/package.json +1 -1
  5. package/skills/c64-assembler/SKILL.md +25 -6
  6. package/skills/c64-assembler/scripts/assemble.ts +45 -10
  7. package/skills/c64-assembler/scripts/lib/compare.ts +48 -0
  8. package/skills/c64-basic/SKILL.md +15 -7
  9. package/skills/c64-basic/scripts/basic.ts +4 -4
  10. package/skills/c64-disk/SKILL.md +34 -11
  11. package/skills/c64-disk/scripts/disk.ts +65 -10
  12. package/skills/c64-disk/scripts/lib/containers.ts +124 -0
  13. package/skills/c64-docs/SKILL.md +138 -0
  14. package/skills/c64-docs/references/page-types.md +141 -0
  15. package/skills/c64-docs/references/style.md +43 -0
  16. package/skills/c64-docs/scripts/generate.ts +102 -0
  17. package/skills/c64-docs/scripts/lib/links.ts +155 -0
  18. package/skills/c64-docs/scripts/lib/markdown.ts +79 -0
  19. package/skills/c64-docs/scripts/lib/reference.ts +406 -0
  20. package/skills/c64-docs/scripts/lib/source-map.ts +81 -0
  21. package/skills/c64-docs/scripts/link.ts +207 -0
  22. package/skills/c64-emulator/SKILL.md +29 -8
  23. package/skills/c64-knowledge/SKILL.md +201 -15
  24. package/skills/c64-knowledge/scripts/knowledge.ts +927 -78
  25. package/skills/c64-knowledge/scripts/lib/listing.ts +162 -0
  26. package/skills/c64-memory-map/SKILL.md +14 -2
  27. package/skills/c64-memory-map/scripts/memmap.ts +15 -4
  28. package/skills/c64-provenance/SKILL.md +100 -11
  29. package/skills/c64-provenance/scripts/lib/compare.ts +152 -0
  30. package/skills/c64-provenance/scripts/lib/library.ts +584 -0
  31. package/skills/c64-provenance/scripts/lib/runtime.ts +191 -0
  32. package/skills/c64-provenance/scripts/lib/separate.ts +140 -0
  33. package/skills/c64-provenance/scripts/provenance.ts +477 -24
  34. package/skills/c64-reverse-engineering/SKILL.md +90 -11
  35. package/skills/c64-source/SKILL.md +174 -0
  36. package/skills/c64-source/scripts/generate.ts +788 -0
  37. package/skills/c64-source/scripts/lib/loader.ts +630 -0
  38. package/skills/c64-source/scripts/lib/source.ts +1121 -0
  39. package/skills/c64-static-analysis/SKILL.md +53 -13
  40. package/skills/c64-static-analysis/scripts/analyze.ts +260 -31
  41. package/skills/c64-static-analysis/scripts/lib/coverage.ts +72 -0
  42. package/skills/c64-static-analysis/scripts/{findings.ts → lib/findings.ts} +11 -5
  43. package/skills/c64-static-analysis/scripts/lib/idioms.ts +158 -0
  44. package/skills/c64-static-analysis/scripts/lib/instructions.ts +105 -0
  45. package/skills/c64-static-analysis/scripts/lib/macros.ts +233 -0
  46. package/skills/c64-static-analysis/scripts/lib/pitfalls.ts +68 -0
  47. package/skills/c64-static-analysis/scripts/lib/pointers.ts +108 -0
  48. package/skills/c64-testing/SKILL.md +57 -4
  49. package/skills/c64-testing/references/scenario-format.md +50 -6
  50. package/skills/c64-testing/scripts/checklist.ts +3 -3
  51. package/skills/c64-testing/scripts/diff.ts +167 -0
  52. package/skills/c64-testing/scripts/lib/diff.ts +70 -0
  53. package/skills/c64-testing/scripts/{scenario.ts → lib/scenario.ts} +183 -152
  54. package/skills/c64-testing/scripts/run.ts +241 -0
  55. package/skills/c64-testing/scripts/test.ts +20 -10
  56. package/skills/c64-unpacker/SKILL.md +88 -21
  57. package/skills/c64-unpacker/scripts/lib/evidence.ts +207 -0
  58. package/skills/c64-unpacker/scripts/lib/loads.ts +209 -0
  59. package/skills/c64-unpacker/scripts/lib/machine.ts +80 -0
  60. package/skills/c64-unpacker/scripts/lib/stages.ts +142 -0
  61. package/skills/c64-unpacker/scripts/unpack.ts +545 -61
  62. package/src/c64.ts +240 -12
  63. package/src/cli/cleanup.ts +1 -1
  64. package/src/cli/main.ts +14 -8
  65. package/src/cli/status.ts +105 -11
  66. package/src/graphics.ts +158 -0
  67. package/src/host/main.ts +57 -31
  68. package/src/host/server.ts +30 -47
  69. package/src/host/tools/c1541.ts +34 -5
  70. package/src/host/tools/index.ts +6 -2
  71. package/src/host/tools/petcat.ts +10 -3
  72. package/src/host/tools/status.ts +23 -6
  73. package/src/host/vice/adapter.ts +64 -20
  74. package/src/host/vice/chips.ts +11 -1
  75. package/src/host/vice/process.ts +19 -8
  76. package/src/host/vice/screen.ts +4 -44
  77. package/src/host/vice/session.ts +343 -60
  78. package/src/host-client/autostart.ts +55 -0
  79. package/src/host-client/connect.ts +14 -14
  80. package/src/host-client/coverage.ts +36 -0
  81. package/src/host-client/image.ts +63 -0
  82. package/src/host-client/steps.ts +175 -0
  83. package/src/host-client/tools.ts +7 -6
  84. package/src/host-client/transfer.ts +60 -10
  85. package/src/host-client/vice-session.ts +107 -28
  86. package/src/knowledge/conflicts.ts +161 -0
  87. package/src/knowledge/database.ts +150 -38
  88. package/src/knowledge/expression.ts +209 -0
  89. package/src/knowledge/graph.ts +104 -0
  90. package/src/knowledge/history.ts +105 -27
  91. package/src/knowledge/import.ts +141 -85
  92. package/src/knowledge/join.ts +211 -0
  93. package/src/knowledge/program.ts +258 -0
  94. package/src/knowledge/read.ts +445 -63
  95. package/src/knowledge/schema.ts +277 -65
  96. package/src/knowledge/text.ts +415 -0
  97. package/src/knowledge/write.ts +1145 -150
  98. package/src/mcp/main.ts +44 -19
  99. package/src/mcp/output.ts +39 -0
  100. package/src/mcp/server.ts +15 -4
  101. package/src/mcp/tools/debug.ts +202 -31
  102. package/src/mcp/tools/execution.ts +39 -5
  103. package/src/mcp/tools/input.ts +7 -4
  104. package/src/mcp/tools/machine.ts +14 -2
  105. package/src/mcp/tools/media.ts +44 -16
  106. package/src/mcp/tools/memory.ts +68 -12
  107. package/src/mcp/tools/video.ts +20 -9
  108. package/src/native/discover.ts +44 -6
  109. package/src/native/ghidra/analyze.ts +2 -2
  110. package/src/native/ghidra/index.ts +19 -3
  111. package/src/native/ghidra/language/c64rt_6510.slaspec +4 -1
  112. package/src/native/ghidra/scripts/C64Export.java +33 -1
  113. package/src/native/ghidra/scripts/C64Prepare.java +7 -1
  114. package/src/native/status.ts +15 -3
  115. package/src/native/types.ts +43 -8
  116. package/src/opcodes.ts +133 -0
  117. package/src/package.ts +21 -0
  118. package/{skills/c64-memory-map/scripts → src}/platform.ts +87 -7
  119. package/src/project.ts +166 -3
  120. package/src/protocol/messages.ts +17 -10
  121. package/src/protocol/tools.ts +27 -22
  122. package/src/protocol/vice.ts +255 -33
  123. package/src/script.ts +49 -9
  124. package/src/trace.ts +55 -23
  125. package/skills/c64-provenance/scripts/compare.ts +0 -92
  126. package/skills/c64-unpacker/scripts/evidence.ts +0 -135
@@ -3,22 +3,15 @@
3
3
  // Observations name semantic symbols that resolve per side, comparisons are
4
4
  // explicit, and the result is PASS, FAIL or INCONCLUSIVE.
5
5
 
6
- import { formatC64Address, parseC64Address, textToPetscii } from "#src/c64.ts";
7
- import {
8
- JOYSTICK_DIRECTIONS,
9
- MAX_BASELINES,
10
- MAX_EXECUTION_COUNT,
11
- MAX_KEYBOARD_BYTES,
12
- MAX_MASK_RECTANGLES,
13
- MAX_MEMORY_READ,
14
- MAX_MEMORY_WRITE,
15
- MAX_TIMEOUT_FRAMES,
16
- WireFailure,
17
- } from "#src/host-client/tools.ts";
18
- import { readProjectFile } from "#src/host-client/transfer.ts";
19
- import type { ViceSessionClient } from "#src/host-client/vice-session.ts";
6
+ import { formatC64Address, formatSprite, formatVicii, parseC64Address } from "#src/c64.ts";
7
+ import { MAX_BASELINES, MAX_MASK_RECTANGLES, 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 { readMemoryInParts, type ViceSessionClient } from "#src/host-client/vice-session.ts";
20
12
  import { openForRead } from "#src/knowledge/database.ts";
21
- import { listSymbols } from "#src/knowledge/read.ts";
13
+ import { listAnalyzerNames, listSymbols } from "#src/knowledge/read.ts";
14
+ import { programImage } from "#src/knowledge/program.ts";
22
15
 
23
16
  export class ScenarioError extends Error {}
24
17
 
@@ -26,14 +19,18 @@ export type Side = "original" | "rebuild";
26
19
  export const SIDES: Side[] = ["original", "rebuild"];
27
20
 
28
21
  /** A "$xxxx" address or a symbol name that each side resolves on its own. */
29
- export type Location = string;
22
+ export type { Location };
30
23
 
31
24
  export interface MemoryObservation {
32
25
  at: Location;
33
- /** Bytes from `at`; 1 when not given. */
26
+ /** Bytes from `at`; 1 when not given, at most 64 KiB. */
34
27
  size?: number;
28
+ /** cpu (the default) reads as the CPU sees memory; ram reads the RAM under the ROMs and the I/O area (M23.9). */
29
+ view?: "cpu" | "ram";
35
30
  /** The largest allowed difference per byte; 0 when not given. */
36
31
  tolerance?: number;
32
+ /** Bytes in the range that the comparison leaves out, each with the reason (D46). */
33
+ exclude?: Array<{ at: Location; reason: string }>;
37
34
  }
38
35
 
39
36
  export interface ScreenPolicy {
@@ -43,21 +40,21 @@ export interface ScreenPolicy {
43
40
  mask?: Array<{ x: number; y: number; width: number; height: number }>;
44
41
  }
45
42
 
46
- export type Step =
43
+ type StepBody =
47
44
  | { reset: "hard" | "soft" }
48
45
  | { load: true }
49
46
  | { autostart: true }
50
- | { write: { at: Location; bytes: number[] } }
51
- | { registers: Partial<Record<"pc" | "a" | "x" | "y" | "sp", number | Location>> }
52
- | { joystick: { port: 1 | 2; direction: JoystickDirection; fire?: boolean } }
53
- | { type: string }
54
- | { frames: number }
55
- | { runUntil: { at: Location } | { memory: { at: Location; equals: number } }; timeoutFrames: number }
47
+ | { snapshot: string }
48
+ | { normalize: true }
49
+ | ActionStep
56
50
  | { observe: string; memory?: Array<Location | MemoryObservation>; registers?: ObservedRegister[]; screen?: ScreenPolicy; vicii?: boolean; sprites?: number[] };
57
51
 
52
+ /** A step; with side, only that side does it (D46). An observe step has no side: it compares both. */
53
+ export type Step = StepBody & { side?: Side };
54
+
58
55
  export interface Subject {
59
56
  program: string;
60
- /** "knowledge" for knowledge.db, a project JSON file (c64-assembler output or a name → "$xxxx" map), or "none". */
57
+ /** "knowledge" for the project knowledge, a project JSON file (c64-assembler output or a name → "$xxxx" map), or "none". */
61
58
  symbols: string;
62
59
  }
63
60
 
@@ -85,26 +82,23 @@ export interface ScenarioResult {
85
82
  reason?: string;
86
83
  checkpoints: string[];
87
84
  differences: Difference[];
85
+ /** The bytes that the comparison left out, with their reasons (D46). */
86
+ excluded?: Array<{ checkpoint: string; at: Location; reason: string }>;
87
+ /** The files that a FAIL wrote with --out. */
88
+ files?: string[];
88
89
  }
89
90
 
90
- const STEP_KINDS = ["reset", "load", "autostart", "write", "registers", "joystick", "type", "frames", "runUntil", "observe"];
91
+ const STEP_KINDS = ["reset", "load", "autostart", "snapshot", "normalize", "write", "registers", "joystick", "type", "frames", "runUntil", "observe"];
91
92
 
92
93
  /** The keys each kind of step can have. */
93
94
  const STEP_KEYS: Record<string, readonly string[]> = {
94
- runUntil: ["runUntil", "timeoutFrames"],
95
+ ...ACTION_STEP_KEYS,
95
96
  observe: ["observe", "memory", "registers", "screen", "vicii", "sprites"],
96
97
  };
97
98
 
98
99
  /** The registers an observe step can compare. */
99
100
  export const OBSERVED_REGISTERS = ["pc", "a", "x", "y", "sp", "flags"] as const;
100
101
  type ObservedRegister = (typeof OBSERVED_REGISTERS)[number];
101
- /** The registers a registers step can set. */
102
- const SET_REGISTERS = ["pc", "a", "x", "y", "sp"];
103
-
104
- type JoystickDirection = (typeof JOYSTICK_DIRECTIONS)[number];
105
-
106
- const isObject = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null && !Array.isArray(value);
107
- const isInteger = (value: unknown, min: number, max: number): value is number => Number.isInteger(value) && (value as number) >= min && (value as number) <= max;
108
102
 
109
103
  /** Reads and checks a scenario file. Throws ScenarioError naming the first problem. */
110
104
  export function readScenario(path: string): Scenario {
@@ -135,9 +129,10 @@ export function readScenario(path: string): Scenario {
135
129
  const kinds = "observe" in step ? ["observe"] : Object.keys(step).filter((key) => STEP_KINDS.includes(key));
136
130
  if (kinds.length !== 1) return fail(`${at} must have exactly one of ${STEP_KINDS.join(", ")}`);
137
131
  const kind = kinds[0]!;
138
- const allowed = STEP_KEYS[kind] ?? [kind];
132
+ const allowed = [...(STEP_KEYS[kind] ?? [kind]), ...(kind === "observe" ? [] : ["side"])];
139
133
  for (const key of Object.keys(step)) if (!allowed.includes(key)) fail(`${at} has the key ${key}, which a ${kind} step does not have`);
140
- checkStep(kind, step, at, fail);
134
+ if (step.side !== undefined && !(SIDES as unknown[]).includes(step.side)) fail(`${at}: side must be original or rebuild`);
135
+ checkStep(kind, step, at, fail, scenario.videoStandard ?? "pal");
141
136
  if (kind === "observe") {
142
137
  if (labels.has(step.observe as string)) fail(`${at} needs a new checkpoint name`);
143
138
  labels.add(step.observe as string);
@@ -149,20 +144,8 @@ export function readScenario(path: string): Scenario {
149
144
  return scenario;
150
145
  }
151
146
 
152
- /** Checks a "$xxxx" address or a symbol name. */
153
- function checkLocation(value: unknown, what: string, fail: (message: string) => never): void {
154
- if (typeof value !== "string" || value === "") fail(`${what} must be a $ address or a symbol name`);
155
- if ((value as string).startsWith("$")) {
156
- try {
157
- parseC64Address(value as string);
158
- } catch {
159
- fail(`${what} must be $ followed by four hex digits, not ${value as string}`);
160
- }
161
- }
162
- }
163
-
164
147
  /** Checks the value of one step against its kind. */
165
- function checkStep(kind: string, step: Record<string, unknown>, at: string, fail: (message: string) => never): void {
148
+ function checkStep(kind: string, step: Record<string, unknown>, at: string, fail: (message: string) => never, videoStandard: "pal" | "ntsc"): void {
166
149
  switch (kind) {
167
150
  case "reset":
168
151
  if (step.reset !== "hard" && step.reset !== "soft") fail(`${at}: reset must be hard or soft`);
@@ -171,66 +154,25 @@ function checkStep(kind: string, step: Record<string, unknown>, at: string, fail
171
154
  case "autostart":
172
155
  if (step[kind] !== true) fail(`${at}: ${kind} must be true`);
173
156
  return;
174
- case "write": {
175
- const write = step.write;
176
- if (!isObject(write) || Object.keys(write).some((key) => key !== "at" && key !== "bytes")) return fail(`${at}: write needs at and bytes, and nothing else`);
177
- checkLocation(write.at, `${at}: write.at`, fail);
178
- if (!Array.isArray(write.bytes) || write.bytes.length === 0 || write.bytes.length > MAX_MEMORY_WRITE || !write.bytes.every((byte) => isInteger(byte, 0, 0xff))) {
179
- fail(`${at}: write.bytes must be a list of 1 to ${MAX_MEMORY_WRITE} numbers from 0 to 255`);
180
- }
181
- return;
182
- }
183
- case "registers": {
184
- const registers = step.registers;
185
- if (!isObject(registers) || Object.keys(registers).length === 0) return fail(`${at}: registers must name at least one of ${SET_REGISTERS.join(", ")}`);
186
- for (const [name, value] of Object.entries(registers)) {
187
- if (!SET_REGISTERS.includes(name)) fail(`${at}: registers can set only ${SET_REGISTERS.join(", ")}, not ${name}`);
188
- if (typeof value === "string") checkLocation(value, `${at}: registers.${name}`, fail);
189
- else if (!isInteger(value, 0, name === "pc" ? 0xffff : 0xff)) fail(`${at}: registers.${name} must be a symbol name, a $ address or a number from 0 to ${name === "pc" ? 65535 : 255}`);
190
- }
191
- return;
192
- }
193
- case "joystick": {
194
- const joystick = step.joystick;
195
- if (!isObject(joystick) || Object.keys(joystick).some((key) => !["port", "direction", "fire"].includes(key))) return fail(`${at}: joystick needs port and direction, and fire is optional`);
196
- if (joystick.port !== 1 && joystick.port !== 2) fail(`${at}: joystick.port must be 1 or 2`);
197
- if (!(JOYSTICK_DIRECTIONS as readonly unknown[]).includes(joystick.direction)) fail(`${at}: joystick.direction must be one of ${JOYSTICK_DIRECTIONS.join(", ")}`);
198
- if (joystick.fire !== undefined && typeof joystick.fire !== "boolean") fail(`${at}: joystick.fire must be true or false`);
157
+ case "snapshot":
158
+ if (typeof step.snapshot !== "string" || step.snapshot === "") fail(`${at}: snapshot needs a .vsf file in the project`);
199
159
  return;
200
- }
201
- case "type": {
202
- if (typeof step.type !== "string" || step.type === "") return fail(`${at}: type needs text`);
203
- let bytes: Uint8Array;
204
- try {
205
- bytes = textToPetscii(step.type);
206
- } catch (error) {
207
- return fail(`${at}: ${(error as Error).message}`);
208
- }
209
- if (bytes.length > MAX_KEYBOARD_BYTES) fail(`${at}: type can have at most ${MAX_KEYBOARD_BYTES} keys`);
160
+ case "normalize":
161
+ if (step.normalize !== true) fail(`${at}: normalize is true: it writes the entry state of the knowledge`);
210
162
  return;
211
- }
163
+ case "write":
164
+ case "registers":
165
+ case "joystick":
166
+ case "type":
212
167
  case "frames":
213
- if (!isInteger(step.frames, 1, MAX_EXECUTION_COUNT)) fail(`${at} needs a frame count from 1 to ${MAX_EXECUTION_COUNT}`);
214
- return;
215
- case "runUntil": {
216
- const target = step.runUntil;
217
- if (!isObject(target) || Object.keys(target).length !== 1 || !("at" in target || "memory" in target)) return fail(`${at}: runUntil needs at or memory`);
218
- if ("at" in target) checkLocation(target.at, `${at}: runUntil.at`, fail);
219
- else {
220
- const memory = target.memory;
221
- if (!isObject(memory) || Object.keys(memory).some((key) => key !== "at" && key !== "equals")) return fail(`${at}: runUntil.memory needs at and equals, and nothing else`);
222
- checkLocation(memory.at, `${at}: runUntil.memory.at`, fail);
223
- if (!isInteger(memory.equals, 0, 0xff)) fail(`${at}: runUntil.memory.equals must be a number from 0 to 255`);
224
- }
225
- if (!isInteger(step.timeoutFrames, 1, MAX_TIMEOUT_FRAMES)) fail(`${at} needs timeoutFrames from 1 to ${MAX_TIMEOUT_FRAMES}`);
226
- return;
227
- }
168
+ case "runUntil":
169
+ return checkActionStep(kind, step, at, fail);
228
170
  default:
229
- checkObserve(step, at, fail);
171
+ checkObserve(step, at, fail, videoStandard);
230
172
  }
231
173
  }
232
174
 
233
- function checkObserve(step: Record<string, unknown>, at: string, fail: (message: string) => never): void {
175
+ function checkObserve(step: Record<string, unknown>, at: string, fail: (message: string) => never, videoStandard: "pal" | "ntsc"): void {
234
176
  if (typeof step.observe !== "string" || step.observe === "") fail(`${at} needs a new checkpoint name`);
235
177
  if (step.memory !== undefined) {
236
178
  if (!Array.isArray(step.memory) || step.memory.length === 0) fail(`${at}: memory must be a list of names, $ addresses or objects with at`);
@@ -239,9 +181,18 @@ function checkObserve(step: Record<string, unknown>, at: string, fail: (message:
239
181
  checkLocation(item, `${at}: memory`, fail);
240
182
  continue;
241
183
  }
242
- if (!isObject(item) || Object.keys(item).some((key) => !["at", "size", "tolerance"].includes(key))) return fail(`${at}: a memory object has at, size and tolerance, and nothing else`);
184
+ if (!isObject(item) || Object.keys(item).some((key) => !["at", "size", "view", "tolerance", "exclude"].includes(key))) return fail(`${at}: a memory object has at, size, view, tolerance and exclude, and nothing else`);
185
+ if (item.view !== undefined && item.view !== "cpu" && item.view !== "ram") fail(`${at}: memory.view must be cpu or ram`);
186
+ if (item.exclude !== undefined) {
187
+ if (!Array.isArray(item.exclude) || item.exclude.length === 0) fail(`${at}: memory.exclude must be a list of objects with at and reason`);
188
+ for (const excluded of item.exclude as unknown[]) {
189
+ if (!isObject(excluded) || Object.keys(excluded).some((key) => key !== "at" && key !== "reason")) return fail(`${at}: an exclude has at and reason, and nothing else`);
190
+ checkLocation(excluded.at, `${at}: memory.exclude.at`, fail);
191
+ if (typeof excluded.reason !== "string" || excluded.reason.trim() === "") fail(`${at}: an exclude needs a reason: why this byte may differ`);
192
+ }
193
+ }
243
194
  checkLocation(item.at, `${at}: memory.at`, fail);
244
- 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}`);
195
+ if (item.size !== undefined && !isInteger(item.size, 1, 0x10000)) fail(`${at}: memory.size must be a number from 1 to 65536`);
245
196
  if (item.tolerance !== undefined && !isInteger(item.tolerance, 0, 0xff)) fail(`${at}: memory.tolerance must be a number from 0 to 255`);
246
197
  }
247
198
  }
@@ -269,6 +220,8 @@ function checkObserve(step: Record<string, unknown>, at: string, fail: (message:
269
220
  isInteger(rectangle.height, 1, 4096);
270
221
  if (!ok) fail(`${at}: a screen.mask rectangle needs whole numbers x and y from 0, and width and height from 1`);
271
222
  }
223
+ // The emulator refuses such a compare too, but only after the original side ran (b2/03).
224
+ 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`);
272
225
  }
273
226
  }
274
227
  if (step.vicii !== undefined && typeof step.vicii !== "boolean") fail(`${at}: vicii must be true or false`);
@@ -287,6 +240,10 @@ export function loadSymbols(subject: Subject): Map<string, number> {
287
240
  if (subject.symbols === "knowledge") {
288
241
  const db = openForRead();
289
242
  for (const symbol of listSymbols(db)) symbols.set(symbol.name, symbol.address);
243
+ // An analyzer name gives an address too, when no symbol has the name and only one analyzer gives it (D74).
244
+ const second = new Map<string, number[]>();
245
+ for (const row of listAnalyzerNames(db)) second.set(row.name, [...(second.get(row.name) ?? []), row.address]);
246
+ for (const [name, addresses] of second) if (!symbols.has(name) && new Set(addresses).size === 1) symbols.set(name, addresses[0]!);
290
247
  db?.close();
291
248
  return symbols;
292
249
  }
@@ -299,11 +256,12 @@ export function loadSymbols(subject: Subject): Map<string, number> {
299
256
  // c64-assembler output, or a plain name → "$xxxx" map. A constant in the
300
257
  // assembler output counts too: RAM variables such as "counter = $c100" lie
301
258
  // outside the program, so the assembler reports them as constants.
302
- const assembled = (value as { symbols?: Array<{ name: string; address?: string; value?: number }> }).symbols;
259
+ const assembled = (value as { symbols?: Array<{ name: string; address?: string; value?: string }> }).symbols;
303
260
  const entries: Array<[string, unknown]> = Array.isArray(assembled)
304
261
  ? assembled.flatMap((symbol): Array<[string, unknown]> => {
305
262
  if (symbol.address !== undefined) return [[symbol.name, symbol.address]];
306
- if (Number.isInteger(symbol.value) && symbol.value! >= 0 && symbol.value! <= 0xffff) return [[symbol.name, formatC64Address(symbol.value!)]];
263
+ // A constant above $ffff has more digits and is no address.
264
+ if (typeof symbol.value === "string" && /^\$[0-9a-f]{4}$/.test(symbol.value)) return [[symbol.name, symbol.value]];
307
265
  return [];
308
266
  })
309
267
  : Object.entries(value as Record<string, unknown>);
@@ -334,11 +292,35 @@ export function resolve(location: Location, symbols: Map<string, number>, side:
334
292
  }
335
293
 
336
294
  interface Observation {
337
- memory: Array<{ label: string; bytes: number[]; tolerance: number }>;
295
+ memory: Array<{ label: string; address: number; bytes: number[]; tolerance: number; excluded: Array<{ offset: number; at: Location; reason: string }> }>;
338
296
  registers: Partial<Record<ObservedRegister, unknown>>;
339
- screen?: { match: boolean; mismatchingPixels: number; mismatchRatio: number; bounds?: unknown };
297
+ screen?: { match: boolean; mismatchingPixels: number; comparedPixels: number; mismatchRatio: number; bounds?: unknown };
340
298
  vicii?: unknown;
341
299
  sprites?: unknown;
300
+ /** PNG images for the files of a FAIL (D46): the side's screen, and on the rebuild the difference image. */
301
+ png?: string;
302
+ diffPng?: string;
303
+ }
304
+
305
+ /** Writes pages 0-3 and $00/$01 from the entry state of the knowledge (D43, D46, D83): both sides then start from the same RAM. */
306
+ async function normalize(client: ViceSessionClient): Promise<void> {
307
+ const db = openForRead();
308
+ let state: { pages?: Record<string, string>; cpuPort?: Record<string, number> } | undefined;
309
+ try {
310
+ state = programImage(db)?.state as typeof state;
311
+ } finally {
312
+ db?.close();
313
+ }
314
+ if (state === undefined) throw new ScenarioError("normalize needs the entry state of a c64-unpacker capture in the knowledge: put the capture in with knowledge.ts image <image.json> of the c64-knowledge skill");
315
+ const pages = ["$0000", "$0100", "$0200", "$0300"].map((page) => state.pages?.[page]);
316
+ const port = [state.cpuPort?.$00, state.cpuPort?.$01];
317
+ if (pages.some((page) => typeof page !== "string" || !/^[0-9a-f]{512}$/.test(page)) || port.some((value) => !isInteger(value, 0, 0xff))) {
318
+ throw new ScenarioError("the entry state of the knowledge is not the entry state of a c64-unpacker capture: it needs pages $0000 to $0300 and cpuPort $00 and $01");
319
+ }
320
+ if ((await client.status()).state === "running") await client.execution({ action: "pause", space: "c64" });
321
+ // Pages 0 to 3 in RAM from $0002, then the CPU port: the port bits switch what the CPU sees.
322
+ await client.memoryWrite({ address: 0x0002, data: pages.join("").slice(4), space: "c64", view: "ram" });
323
+ await client.memoryWrite({ address: 0x0000, data: Buffer.from(port as number[]).toString("hex"), space: "c64", view: "cpu" });
342
324
  }
343
325
 
344
326
  /**
@@ -350,45 +332,62 @@ export function baselineName(checkpoint: number, label: string): string {
350
332
  return `test-${checkpoint}-${label}`.replace(/[^A-Za-z0-9._-]/g, "_").slice(0, 64);
351
333
  }
352
334
 
353
- async function runSide(client: ViceSessionClient, scenario: Scenario, side: Side, symbols: Map<string, number>): Promise<Map<string, Observation>> {
335
+ /** Autostart that returns only when the program runs (runProgram), or INCONCLUSIVE with the reason. */
336
+ async function startProgram(client: ViceSessionClient, program: string, side: Side): Promise<void> {
337
+ const failed = await runProgram(client, program, readProjectFile(program).bytes);
338
+ if (failed === "not-loaded") throw new Inconclusive(`the ${side} program was not in memory after ${LOAD_LIMIT_FRAMES} frames of autostart`);
339
+ 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`);
340
+ }
341
+
342
+ async function runSide(client: ViceSessionClient, scenario: Scenario, side: Side, symbols: Map<string, number>, files: boolean): Promise<Map<string, Observation>> {
354
343
  const subject = scenario[side];
355
344
  const observations = new Map<string, Observation>();
356
345
  const at = (location: Location) => resolve(location, symbols, side);
357
- const stop = async () => {
358
- if ((await client.status()).state === "running") await client.execution({ action: "pause", space: "c64" });
359
- };
360
346
  // Each side starts from a hard reset, stopped at the reset vector, with the control ports released.
361
347
  await client.reset({ mode: "hard", run: false });
362
348
  await client.joystick({ port: 1, direction: "center", fire: false });
363
349
  await client.joystick({ port: 2, direction: "center", fire: false });
364
350
  let checkpoint = 0;
365
351
  for (const step of scenario.steps) {
352
+ if (step.side !== undefined && step.side !== side) continue;
366
353
  // Observe first: an observe step may also carry "registers" or "memory".
367
354
  if ("observe" in step) {
368
355
  checkpoint++;
369
356
  const observation: Observation = { memory: [], registers: {} };
370
357
  for (const item of step.memory ?? []) {
371
- const spec = typeof item === "string" ? { at: item } : item;
372
- const read = await client.memoryRead({ address: at(spec.at), size: spec.size ?? 1, space: "c64", view: "cpu" });
373
- observation.memory.push({ label: spec.size !== undefined && spec.size > 1 ? `${spec.at} (${spec.size} bytes)` : spec.at, bytes: [...Buffer.from(read.data, "hex")], tolerance: spec.tolerance ?? 0 });
358
+ const spec: MemoryObservation = typeof item === "string" ? { at: item } : item;
359
+ const address = at(spec.at);
360
+ const size = spec.size ?? 1;
361
+ const read = { data: (await readMemoryInParts(client, address, size, spec.view ?? "cpu")).toString("hex") };
362
+ const excluded = (spec.exclude ?? []).map((exclude) => {
363
+ const offset = at(exclude.at) - address;
364
+ if (offset < 0 || offset >= size) throw new ScenarioError(`the exclude ${exclude.at} lies outside the observed ${spec.at} (${size} bytes)`);
365
+ return { offset, at: exclude.at, reason: exclude.reason };
366
+ });
367
+ observation.memory.push({ label: size > 1 ? `${spec.at} (${size} bytes)` : spec.at, address, bytes: [...Buffer.from(read.data, "hex")], tolerance: spec.tolerance ?? 0, excluded });
374
368
  }
375
369
  if (step.registers !== undefined) {
376
370
  const registers = await client.registersGet("c64");
377
- for (const name of step.registers) observation.registers[name] = registers[name];
371
+ for (const name of step.registers) observation.registers[name] = name === "pc" ? formatC64Address(registers.pc) : registers[name];
378
372
  }
379
373
  if (step.vicii) {
380
374
  // The raster line changes from one moment to the next; the comparison leaves it out.
381
375
  const { rasterLine: _rasterLine, ...state } = await client.vicii();
382
- observation.vicii = state;
376
+ observation.vicii = formatVicii(state);
383
377
  }
384
- if (step.sprites !== undefined) observation.sprites = (await client.sprites(step.sprites)).sprites;
378
+ if (step.sprites !== undefined) observation.sprites = (await client.sprites(step.sprites)).sprites.map(formatSprite);
385
379
  if (step.screen !== undefined) {
386
380
  const baseline = baselineName(checkpoint, step.observe);
387
381
  if (side === "original") {
388
- await client.screenCapture(baseline);
382
+ const shot = await client.screenCapture(baseline);
383
+ if (files) observation.png = shot.png;
389
384
  } else {
390
- const compared = await client.screenCompare({ baseline, maxMismatchRatio: step.screen.maxMismatchRatio ?? 0, mask: step.screen.mask ?? [], includeDiff: false });
391
- observation.screen = { match: compared.match, mismatchingPixels: compared.mismatchingPixels, mismatchRatio: compared.mismatchRatio, ...(compared.bounds === undefined ? {} : { bounds: compared.bounds }) };
385
+ const compared = await client.screenCompare({ baseline, maxMismatchRatio: step.screen.maxMismatchRatio ?? 0, mask: step.screen.mask ?? [], includeDiff: files });
386
+ observation.screen = { match: compared.match, mismatchingPixels: compared.mismatchingPixels, comparedPixels: compared.comparedPixels, mismatchRatio: compared.mismatchRatio, ...(compared.bounds === undefined ? {} : { bounds: compared.bounds }) };
387
+ if (files) {
388
+ observation.png = (await client.screenCapture()).png;
389
+ if (compared.diffPng !== undefined) observation.diffPng = compared.diffPng;
390
+ }
392
391
  await client.screenDiscard(baseline);
393
392
  }
394
393
  }
@@ -399,30 +398,16 @@ async function runSide(client: ViceSessionClient, scenario: Scenario, side: Side
399
398
  } else if ("load" in step) {
400
399
  await client.programLoad({ path: subject.program });
401
400
  } else if ("autostart" in step) {
402
- await client.autostart({ path: subject.program, index: 0, run: true });
403
- } else if ("write" in step) {
404
- await stop();
405
- await client.memoryWrite({ address: at(step.write.at), data: Buffer.from(step.write.bytes).toString("hex"), space: "c64", view: "cpu" });
406
- } else if ("registers" in step) {
407
- await stop();
408
- const values: Record<string, number> = {};
409
- for (const [name, value] of Object.entries(step.registers)) values[name] = typeof value === "number" ? value : at(value as Location);
410
- await client.registersSet("c64", values);
411
- } else if ("joystick" in step) {
412
- await client.joystick({ port: step.joystick.port, direction: step.joystick.direction, fire: step.joystick.fire ?? false });
413
- } else if ("type" in step) {
414
- await client.keyboard(textToPetscii(step.type));
415
- } else if ("frames" in step) {
416
- await stop();
417
- await client.execution({ action: "advance-frames", count: step.frames, space: "c64" });
418
- } else if ("runUntil" in step) {
419
- const target =
420
- "at" in step.runUntil
421
- ? { kind: "address" as const, address: at(step.runUntil.at), space: "c64" as const }
422
- : { kind: "memory" as const, address: at(step.runUntil.memory.at), operator: "eq" as const, value: step.runUntil.memory.equals, space: "c64" as const, view: "cpu" as const };
423
- const reached = await client.runUntil({ target, timeoutFrames: step.timeoutFrames });
424
- if (!reached.reached) {
425
- throw new Inconclusive(`the ${side} side did not reach the checkpoint before ${step.timeoutFrames} frames (it stopped by ${reached.stopReason} at ${formatC64Address(reached.pc)})`);
401
+ await startProgram(client, subject.program, side);
402
+ } else if ("snapshot" in step) {
403
+ await client.snapshotRestore(readProjectFile(step.snapshot).bytes);
404
+ } else if ("normalize" in step) {
405
+ await normalize(client);
406
+ } else {
407
+ const { side: _side, ...action } = step;
408
+ const notReached = await runActionStep(client, action as ActionStep, at);
409
+ if (notReached !== undefined && "runUntil" in step) {
410
+ throw new Inconclusive(`the ${side} side did not reach the checkpoint before ${step.timeoutFrames} frames (it stopped by ${notReached.stopReason} at ${formatC64Address(notReached.pc)})`);
426
411
  }
427
412
  }
428
413
  }
@@ -436,8 +421,9 @@ export function compare(original: Map<string, Observation>, rebuild: Map<string,
436
421
  const right = rebuild.get(checkpoint)!;
437
422
  left.memory.forEach((item, index) => {
438
423
  const other = right.memory[index]!;
439
- if (item.bytes.some((byte, at) => Math.abs(byte - other.bytes[at]!) > item.tolerance)) {
440
- const show = (bytes: number[]) => (bytes.length === 1 ? bytes[0] : Buffer.from(bytes).toString("hex"));
424
+ const skipped = new Set(item.excluded.map((excluded) => excluded.offset));
425
+ if (item.bytes.some((byte, at) => !skipped.has(at) && Math.abs(byte - other.bytes[at]!) > item.tolerance)) {
426
+ const show = (bytes: number[]) => Buffer.from(bytes).toString("hex");
441
427
  differences.push({ checkpoint, what: `memory ${item.label}`, original: show(item.bytes), rebuild: show(other.bytes) });
442
428
  }
443
429
  });
@@ -453,13 +439,47 @@ export function compare(original: Map<string, Observation>, rebuild: Map<string,
453
439
  return differences;
454
440
  }
455
441
 
456
- /** Runs the scenario on the original, then on the rebuild, in one VICE session. */
457
- export async function runScenario(client: ViceSessionClient, scenario: Scenario): Promise<ScenarioResult> {
442
+ /**
443
+ * Writes the files of a FAIL into `out` (D46): for a screen difference both
444
+ * screens and the difference image, and memory.json with each differing byte.
445
+ */
446
+ function writeFailFiles(out: string, original: Map<string, Observation>, rebuild: Map<string, Observation>, differences: Difference[]): string[] {
447
+ const files: string[] = [];
448
+ const write = (name: string, bytes: Uint8Array) => {
449
+ writeProjectFile(`${out}/${name}`, bytes, { what: "--out" });
450
+ files.push(name);
451
+ };
452
+ const memory: Array<{ checkpoint: string; what: string; bytes: Array<{ address: string; original: string; rebuild: string }> }> = [];
453
+ const hex = (byte: number) => byte.toString(16).padStart(2, "0");
454
+ for (const difference of differences) {
455
+ const left = original.get(difference.checkpoint)!;
456
+ const right = rebuild.get(difference.checkpoint)!;
457
+ const name = difference.checkpoint.replace(/[^A-Za-z0-9._-]/g, "_");
458
+ if (difference.what === "screen") {
459
+ if (left.png !== undefined) write(`${name}-original.png`, Buffer.from(left.png, "base64"));
460
+ if (right.png !== undefined) write(`${name}-rebuild.png`, Buffer.from(right.png, "base64"));
461
+ if (right.diffPng !== undefined) write(`${name}-diff.png`, Buffer.from(right.diffPng, "base64"));
462
+ }
463
+ if (difference.what.startsWith("memory ")) {
464
+ const index = left.memory.findIndex((item) => `memory ${item.label}` === difference.what);
465
+ const item = left.memory[index]!;
466
+ const other = right.memory[index]!;
467
+ const skipped = new Set(item.excluded.map((excluded) => excluded.offset));
468
+ const bytes = item.bytes.flatMap((byte, offset) => (!skipped.has(offset) && Math.abs(byte - other.bytes[offset]!) > item.tolerance ? [{ address: formatC64Address(item.address + offset), original: hex(byte), rebuild: hex(other.bytes[offset]!) }] : []));
469
+ memory.push({ checkpoint: difference.checkpoint, what: difference.what, bytes });
470
+ }
471
+ }
472
+ if (memory.length > 0) write("memory.json", Buffer.from(`${JSON.stringify(memory, null, 2)}\n`));
473
+ return files;
474
+ }
475
+
476
+ /** Runs the scenario on the original, then on the rebuild, in one VICE session. With out, a FAIL leaves files there. */
477
+ export async function runScenario(client: ViceSessionClient, scenario: Scenario, options: { out?: string } = {}): Promise<ScenarioResult> {
458
478
  const checkpoints = scenario.steps.flatMap((step) => ("observe" in step ? [step.observe] : []));
459
479
  const observed = new Map<Side, Map<string, Observation>>();
460
480
  for (const side of SIDES) {
461
481
  try {
462
- observed.set(side, await runSide(client, scenario, side, loadSymbols(scenario[side])));
482
+ observed.set(side, await runSide(client, scenario, side, loadSymbols(scenario[side]), options.out !== undefined));
463
483
  } catch (error) {
464
484
  if (error instanceof Inconclusive) return { result: "INCONCLUSIVE", scenario: scenario.name, reason: error.message, checkpoints, differences: [] };
465
485
  if (error instanceof WireFailure) {
@@ -468,6 +488,17 @@ export async function runScenario(client: ViceSessionClient, scenario: Scenario)
468
488
  throw error;
469
489
  }
470
490
  }
471
- const differences = compare(observed.get("original")!, observed.get("rebuild")!);
472
- return { result: differences.length === 0 ? "PASS" : "FAIL", scenario: scenario.name, checkpoints, differences };
491
+ const original = observed.get("original")!;
492
+ const rebuild = observed.get("rebuild")!;
493
+ const differences = compare(original, rebuild);
494
+ const excluded = [...original].flatMap(([checkpoint, observation]) => observation.memory.flatMap((item) => item.excluded.map((excluded) => ({ checkpoint, at: excluded.at, reason: excluded.reason }))));
495
+ const files = differences.length > 0 && options.out !== undefined ? writeFailFiles(options.out, original, rebuild, differences) : [];
496
+ return {
497
+ result: differences.length === 0 ? "PASS" : "FAIL",
498
+ scenario: scenario.name,
499
+ checkpoints,
500
+ differences,
501
+ ...(excluded.length === 0 ? {} : { excluded }),
502
+ ...(files.length === 0 ? {} : { files }),
503
+ };
473
504
  }