@henols/c64-re-tools 2.1.0-rc.1 → 2.1.0

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 (103) hide show
  1. package/package.json +1 -1
  2. package/skills/c64-assembler/SKILL.md +24 -9
  3. package/skills/c64-assembler/scripts/assembler.ts +95 -0
  4. package/skills/c64-basic/SKILL.md +17 -9
  5. package/skills/c64-basic/scripts/basic.ts +26 -36
  6. package/skills/c64-disk/SKILL.md +17 -7
  7. package/skills/c64-disk/scripts/disk.ts +130 -104
  8. package/skills/c64-disk/scripts/lib/containers.ts +10 -12
  9. package/skills/c64-docs/SKILL.md +47 -28
  10. package/skills/c64-docs/references/style.md +6 -6
  11. package/skills/c64-docs/scripts/docs.ts +323 -0
  12. package/skills/c64-docs/scripts/lib/source-map.ts +1 -1
  13. package/skills/c64-emulator/SKILL.md +11 -7
  14. package/skills/c64-knowledge/SKILL.md +34 -21
  15. package/skills/c64-knowledge/scripts/knowledge.ts +597 -718
  16. package/skills/c64-knowledge/scripts/lib/listing.ts +5 -7
  17. package/skills/c64-memory-map/SKILL.md +18 -10
  18. package/skills/c64-memory-map/scripts/memory-map.ts +81 -0
  19. package/skills/c64-provenance/SKILL.md +33 -24
  20. package/skills/c64-provenance/scripts/lib/compare.ts +8 -12
  21. package/skills/c64-provenance/scripts/lib/leftovers.ts +19 -20
  22. package/skills/c64-provenance/scripts/lib/library.ts +15 -25
  23. package/skills/c64-provenance/scripts/lib/runtime.ts +12 -37
  24. package/skills/c64-provenance/scripts/lib/separate.ts +9 -10
  25. package/skills/c64-provenance/scripts/provenance.ts +177 -279
  26. package/skills/c64-reverse-engineering/SKILL.md +13 -12
  27. package/skills/c64-source/SKILL.md +28 -16
  28. package/skills/c64-source/scripts/lib/loader.ts +52 -77
  29. package/skills/c64-source/scripts/lib/source.ts +33 -42
  30. package/skills/c64-source/scripts/{generate.ts → source.ts} +68 -109
  31. package/skills/c64-static-analysis/SKILL.md +24 -13
  32. package/skills/c64-static-analysis/scripts/lib/coverage.ts +3 -4
  33. package/skills/c64-static-analysis/scripts/lib/findings.ts +2 -2
  34. package/skills/c64-static-analysis/scripts/lib/instructions.ts +1 -1
  35. package/skills/c64-static-analysis/scripts/lib/macros.ts +6 -8
  36. package/skills/c64-static-analysis/scripts/{analyze.ts → static-analysis.ts} +58 -118
  37. package/skills/c64-testing/SKILL.md +48 -32
  38. package/skills/c64-testing/references/recording-format.md +48 -19
  39. package/skills/c64-testing/references/scenario-format.md +24 -19
  40. package/skills/c64-testing/scripts/{diff.ts → lib/diff-files.ts} +18 -69
  41. package/skills/c64-testing/scripts/lib/diff.ts +5 -3
  42. package/skills/c64-testing/scripts/lib/play.ts +244 -54
  43. package/skills/c64-testing/scripts/lib/recording.ts +153 -120
  44. package/skills/c64-testing/scripts/{replay.ts → lib/replay.ts} +28 -86
  45. package/skills/c64-testing/scripts/{run.ts → lib/runs.ts} +36 -59
  46. package/skills/c64-testing/scripts/lib/scenario.ts +73 -81
  47. package/skills/c64-testing/scripts/testing.ts +189 -0
  48. package/skills/c64-unpacker/SKILL.md +42 -25
  49. package/skills/c64-unpacker/scripts/lib/evidence.ts +3 -4
  50. package/skills/c64-unpacker/scripts/lib/loads.ts +12 -10
  51. package/skills/c64-unpacker/scripts/lib/machine.ts +0 -2
  52. package/skills/c64-unpacker/scripts/lib/stages.ts +14 -19
  53. package/skills/c64-unpacker/scripts/{unpack.ts → unpacker.ts} +109 -211
  54. package/src/c64.ts +35 -20
  55. package/src/cli/main.ts +2 -2
  56. package/src/errors.ts +38 -0
  57. package/src/graphics.ts +3 -1
  58. package/src/host/server.ts +3 -2
  59. package/src/host/vice/session.ts +4 -4
  60. package/src/host-client/entry-state.ts +106 -0
  61. package/src/host-client/image.ts +21 -10
  62. package/src/host-client/steps.ts +285 -108
  63. package/src/host-client/transfer.ts +21 -15
  64. package/src/host-client/vice-session.ts +1 -1
  65. package/src/knowledge/database.ts +3 -23
  66. package/src/knowledge/expression.ts +8 -11
  67. package/src/knowledge/history.ts +2 -2
  68. package/src/knowledge/import.ts +13 -14
  69. package/src/knowledge/names.ts +50 -0
  70. package/src/knowledge/program.ts +11 -10
  71. package/src/knowledge/read.ts +16 -21
  72. package/src/knowledge/schema.ts +9 -3
  73. package/src/knowledge/text.ts +5 -5
  74. package/src/knowledge/write.ts +93 -137
  75. package/src/mcp/inputs.ts +105 -0
  76. package/src/mcp/output.ts +2 -13
  77. package/src/mcp/server.ts +12 -60
  78. package/src/mcp/tools/debug.ts +17 -12
  79. package/src/mcp/tools/execution.ts +5 -32
  80. package/src/mcp/tools/input.ts +7 -32
  81. package/src/mcp/tools/media.ts +6 -5
  82. package/src/mcp/tools/memory.ts +11 -38
  83. package/src/mcp/tools/video.ts +6 -5
  84. package/src/native/c64run.ts +1 -1
  85. package/src/native/dxa.ts +7 -6
  86. package/src/native/ghidra/analyze.ts +2 -1
  87. package/src/native/ghidra/index.ts +2 -1
  88. package/src/native/processes.ts +4 -3
  89. package/src/native/status.ts +2 -2
  90. package/src/native/types.ts +3 -7
  91. package/src/opcodes.ts +13 -13
  92. package/src/platform.ts +9 -9
  93. package/src/project.ts +16 -26
  94. package/src/protocol/messages.ts +3 -21
  95. package/src/protocol/vice.ts +7 -7
  96. package/src/script.ts +491 -49
  97. package/skills/c64-assembler/scripts/assemble.ts +0 -138
  98. package/skills/c64-docs/scripts/generate.ts +0 -109
  99. package/skills/c64-docs/scripts/link.ts +0 -239
  100. package/skills/c64-memory-map/scripts/memmap.ts +0 -92
  101. package/skills/c64-testing/scripts/checklist.ts +0 -51
  102. package/skills/c64-testing/scripts/record.ts +0 -96
  103. package/skills/c64-testing/scripts/test.ts +0 -65
@@ -3,18 +3,19 @@
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, formatSprite, formatVicii, parseC64Address } from "#src/c64.ts";
6
+ import { isRefusal, Refusal } from "#src/errors.ts";
7
+ import { c64AddressOf, formatC64Address, formatSprite, formatVicii, parseC64Address } from "#src/c64.ts";
7
8
  import { MAX_BASELINES, MAX_MASK_RECTANGLES, maskCoversFrame, WireFailure } from "#src/host-client/tools.ts";
8
9
  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";
10
+ import { ACTION_STEP_KINDS, checkActionStep, checkLocation, isInteger, isObject, runActionStep, type ActionStep, type ActionStepKind, type Location } from "#src/host-client/steps.ts";
11
+ import { applyEntryState, parseEntryState } from "#src/host-client/entry-state.ts";
12
+ import { readProjectFile, writeReported, type Written } from "#src/host-client/transfer.ts";
11
13
  import { readMemoryInParts, type ViceSessionClient } from "#src/host-client/vice-session.ts";
12
14
  import { openForRead } from "#src/knowledge/database.ts";
13
- import { listAnalyzerNames, listSymbols } from "#src/knowledge/read.ts";
15
+ import { nameTable } from "#src/knowledge/names.ts";
16
+ import { DEFAULT_SPACE } from "#src/knowledge/schema.ts";
14
17
  import { programImage } from "#src/knowledge/program.ts";
15
18
 
16
- export class ScenarioError extends Error {}
17
-
18
19
  export type Side = "original" | "rebuild";
19
20
  export const SIDES: Side[] = ["original", "rebuild"];
20
21
 
@@ -22,15 +23,15 @@ export const SIDES: Side[] = ["original", "rebuild"];
22
23
  export type { Location };
23
24
 
24
25
  export interface MemoryObservation {
25
- at: Location;
26
- /** Bytes from `at`; 1 when not given, at most 64 KiB. */
26
+ address: Location;
27
+ /** Bytes from `address`; 1 when not given, at most 64 KiB. */
27
28
  size?: number;
28
29
  /** cpu (the default) reads as the CPU sees memory; ram reads the RAM under the ROMs and the I/O area. */
29
30
  view?: "cpu" | "ram";
30
31
  /** The largest allowed difference per byte; 0 when not given. */
31
32
  tolerance?: number;
32
33
  /** Bytes in the range that the comparison leaves out, each with the reason. */
33
- exclude?: Array<{ at: Location; reason: string }>;
34
+ exclude?: Array<{ address: Location; reason: string }>;
34
35
  }
35
36
 
36
37
  export interface ScreenPolicy {
@@ -46,7 +47,7 @@ type StepBody =
46
47
  | { autostart: true }
47
48
  | { snapshot: string }
48
49
  | { normalize: true }
49
- | ActionStep
50
+ | Exclude<ActionStep, { press: string }>
50
51
  | { observe: string; memory?: Array<Location | MemoryObservation>; registers?: ObservedRegister[]; screen?: ScreenPolicy; vicii?: boolean; sprites?: number[] };
51
52
 
52
53
  /** A step; with side, only that side does it. An observe step has no side: it compares both. */
@@ -83,16 +84,15 @@ export interface ScenarioResult {
83
84
  checkpoints: string[];
84
85
  differences: Difference[];
85
86
  /** The bytes that the comparison left out, with their reasons. */
86
- excluded?: Array<{ checkpoint: string; at: Location; reason: string }>;
87
+ excluded?: Array<{ checkpoint: string; address: Location; reason: string }>;
87
88
  /** The files that a FAIL wrote with --out. */
88
- files?: string[];
89
+ files?: Written[];
89
90
  }
90
91
 
91
- const STEP_KINDS = ["reset", "load", "autostart", "snapshot", "normalize", "write", "registers", "joystick", "type", "frames", "runUntil", "observe"];
92
+ const STEP_KINDS = ["reset", "load", "autostart", "snapshot", "normalize", ...ACTION_STEP_KINDS, "observe"];
92
93
 
93
94
  /** The keys each kind of step can have. */
94
95
  const STEP_KEYS: Record<string, readonly string[]> = {
95
- ...ACTION_STEP_KEYS,
96
96
  observe: ["observe", "memory", "registers", "screen", "vicii", "sprites"],
97
97
  };
98
98
 
@@ -100,16 +100,17 @@ const STEP_KEYS: Record<string, readonly string[]> = {
100
100
  export const OBSERVED_REGISTERS = ["pc", "a", "x", "y", "sp", "flags"] as const;
101
101
  type ObservedRegister = (typeof OBSERVED_REGISTERS)[number];
102
102
 
103
- /** Reads and checks a scenario file. Throws ScenarioError naming the first problem. */
103
+ /** Reads and checks a scenario file. Refuses it with the first problem. */
104
104
  export function readScenario(path: string): Scenario {
105
105
  let value: unknown;
106
106
  try {
107
107
  value = JSON.parse(readProjectFile(path).bytes.toString("utf8"));
108
108
  } catch (error) {
109
- throw new ScenarioError(`cannot read the scenario ${path}: ${(error as Error).message}`);
109
+ if (isRefusal(error)) throw error;
110
+ throw new Refusal("invalid-input", `cannot read the scenario ${path}: ${(error as Error).message}`);
110
111
  }
111
112
  const fail = (message: string): never => {
112
- throw new ScenarioError(`scenario ${path}: ${message}`);
113
+ throw new Refusal("invalid-input", `scenario ${path}: ${message}`);
113
114
  };
114
115
  if (!isObject(value)) return fail("is not a JSON object");
115
116
  const scenario = value as unknown as Scenario;
@@ -132,6 +133,11 @@ export function readScenario(path: string): Scenario {
132
133
  const allowed = [...(STEP_KEYS[kind] ?? [kind]), ...(kind === "observe" ? [] : ["side"])];
133
134
  for (const key of Object.keys(step)) if (!allowed.includes(key)) fail(`${at} has the key ${key}, which a ${kind} step does not have`);
134
135
  if (step.side !== undefined && !(SIDES as unknown[]).includes(step.side)) fail(`${at}: side must be original or rebuild`);
136
+ if ((ACTION_STEP_KINDS as readonly string[]).includes(kind)) {
137
+ // The step with its defaults filled in, as the shared schema of the action steps gives it.
138
+ scenario.steps[index] = { ...checkActionStep(kind as ActionStepKind, { [kind]: step[kind] }, at, fail), ...(step.side === undefined ? {} : { side: step.side as Side }) } as Step;
139
+ return;
140
+ }
135
141
  checkStep(kind, step, at, fail, scenario.videoStandard ?? "pal");
136
142
  if (kind === "observe") {
137
143
  if (labels.has(step.observe as string)) fail(`${at} needs a new checkpoint name`);
@@ -160,13 +166,6 @@ function checkStep(kind: string, step: Record<string, unknown>, at: string, fail
160
166
  case "normalize":
161
167
  if (step.normalize !== true) fail(`${at}: normalize is true: it writes the entry state of the knowledge`);
162
168
  return;
163
- case "write":
164
- case "registers":
165
- case "joystick":
166
- case "type":
167
- case "frames":
168
- case "runUntil":
169
- return checkActionStep(kind, step, at, fail);
170
169
  default:
171
170
  checkObserve(step, at, fail, videoStandard);
172
171
  }
@@ -175,23 +174,23 @@ function checkStep(kind: string, step: Record<string, unknown>, at: string, fail
175
174
  function checkObserve(step: Record<string, unknown>, at: string, fail: (message: string) => never, videoStandard: "pal" | "ntsc"): void {
176
175
  if (typeof step.observe !== "string" || step.observe === "") fail(`${at} needs a new checkpoint name`);
177
176
  if (step.memory !== undefined) {
178
- if (!Array.isArray(step.memory) || step.memory.length === 0) fail(`${at}: memory must be a list of names, $ addresses or objects with at`);
177
+ if (!Array.isArray(step.memory) || step.memory.length === 0) fail(`${at}: memory must be a list of names, $ addresses or objects with address`);
179
178
  for (const item of step.memory as unknown[]) {
180
179
  if (typeof item === "string") {
181
180
  checkLocation(item, `${at}: memory`, fail);
182
181
  continue;
183
182
  }
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`);
183
+ if (!isObject(item) || Object.keys(item).some((key) => !["address", "size", "view", "tolerance", "exclude"].includes(key))) return fail(`${at}: a memory object has address, size, view, tolerance and exclude, and nothing else`);
185
184
  if (item.view !== undefined && item.view !== "cpu" && item.view !== "ram") fail(`${at}: memory.view must be cpu or ram`);
186
185
  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`);
186
+ if (!Array.isArray(item.exclude) || item.exclude.length === 0) fail(`${at}: memory.exclude must be a list of objects with address and reason`);
188
187
  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);
188
+ if (!isObject(excluded) || Object.keys(excluded).some((key) => key !== "address" && key !== "reason")) return fail(`${at}: an exclude has address and reason, and nothing else`);
189
+ checkLocation(excluded.address, `${at}: memory.exclude.address`, fail);
191
190
  if (typeof excluded.reason !== "string" || excluded.reason.trim() === "") fail(`${at}: an exclude needs a reason: why this byte may differ`);
192
191
  }
193
192
  }
194
- checkLocation(item.at, `${at}: memory.at`, fail);
193
+ checkLocation(item.address, `${at}: memory.address`, fail);
195
194
  if (item.size !== undefined && !isInteger(item.size, 1, 0x10000)) fail(`${at}: memory.size must be a number from 1 to 65536`);
196
195
  if (item.tolerance !== undefined && !isInteger(item.tolerance, 0, 0xff)) fail(`${at}: memory.tolerance must be a number from 0 to 255`);
197
196
  }
@@ -233,46 +232,48 @@ function checkObserve(step: Record<string, unknown>, at: string, fail: (message:
233
232
  }
234
233
  }
235
234
 
236
- /** Name → address for one side. */
237
- export function loadSymbols(subject: Subject): Map<string, number> {
238
- const symbols = new Map<string, number>();
239
- if (subject.symbols === "none") return symbols;
240
- if (subject.symbols === "knowledge") {
235
+ /**
236
+ * Name → address from a source of names: none; the knowledge (its symbols and
237
+ * the analyzer names that only one address has); the c64-assembler output; or
238
+ * a JSON map of name to $ address. With kinds "code", only the names that move
239
+ * with the code: labels and routines of the knowledge, and the labels of the
240
+ * assembler output. With "all", also the data names and the assembler
241
+ * constants of four hex digits: RAM variables such as "counter = $c100" lie
242
+ * outside the program, so the assembler reports them as constants.
243
+ */
244
+ export function namesOf(source: string, kinds: "code" | "all" = "all"): Map<string, number> {
245
+ if (source === "none") return new Map();
246
+ if (source === "knowledge") {
241
247
  const db = openForRead();
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.
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]!);
247
- db?.close();
248
- return symbols;
248
+ try {
249
+ return nameTable(db, DEFAULT_SPACE, kinds === "code" ? ["label", "routine"] : undefined);
250
+ } finally {
251
+ db?.close();
252
+ }
249
253
  }
250
254
  let value: unknown;
251
255
  try {
252
- value = JSON.parse(readProjectFile(subject.symbols).bytes.toString("utf8"));
256
+ value = JSON.parse(readProjectFile(source).bytes.toString("utf8"));
253
257
  } catch (error) {
254
- throw new ScenarioError(`cannot read the symbols ${subject.symbols}: ${(error as Error).message}`);
258
+ if (isRefusal(error)) throw error;
259
+ throw new Refusal("invalid-input", `cannot read the symbols ${source}: ${(error as Error).message}`);
255
260
  }
256
- // c64-assembler output, or a plain name → "$xxxx" map. A constant in the
257
- // assembler output counts too: RAM variables such as "counter = $c100" lie
258
- // outside the program, so the assembler reports them as constants.
259
261
  const assembled = (value as { symbols?: Array<{ name: string; address?: string; value?: string }> }).symbols;
260
262
  const entries: Array<[string, unknown]> = Array.isArray(assembled)
261
263
  ? assembled.flatMap((symbol): Array<[string, unknown]> => {
262
264
  if (symbol.address !== undefined) return [[symbol.name, symbol.address]];
263
265
  // 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]];
266
+ if (kinds === "all" && typeof symbol.value === "string" && /^\$[0-9a-f]{4}$/.test(symbol.value)) return [[symbol.name, symbol.value]];
265
267
  return [];
266
268
  })
267
269
  : Object.entries(value as Record<string, unknown>);
270
+ const names = new Map<string, number>();
268
271
  for (const [name, address] of entries) {
269
- try {
270
- symbols.set(name, parseC64Address(String(address)));
271
- } catch {
272
- throw new ScenarioError(`the symbol ${name} in ${subject.symbols} has no $xxxx address`);
273
- }
272
+ const at = typeof address === "string" ? c64AddressOf(address) : undefined;
273
+ if (at === undefined) throw new Refusal("invalid-input", `the symbol ${name} in ${source} has no $ address`);
274
+ names.set(name, at);
274
275
  }
275
- return symbols;
276
+ return names;
276
277
  }
277
278
 
278
279
  /** A failure that makes the comparison impossible: the result is INCONCLUSIVE. */
@@ -283,7 +284,7 @@ export function resolve(location: Location, symbols: Map<string, number>, side:
283
284
  try {
284
285
  return parseC64Address(location);
285
286
  } catch {
286
- throw new ScenarioError(`${location} is not $ followed by four hex digits`);
287
+ throw new Refusal("invalid-input", `${location} is not $ and 1 to 4 hex digits`);
287
288
  }
288
289
  }
289
290
  const address = symbols.get(location);
@@ -292,7 +293,7 @@ export function resolve(location: Location, symbols: Map<string, number>, side:
292
293
  }
293
294
 
294
295
  interface Observation {
295
- memory: Array<{ label: string; address: number; bytes: number[]; tolerance: number; excluded: Array<{ offset: number; at: Location; reason: string }> }>;
296
+ memory: Array<{ label: string; address: number; bytes: number[]; tolerance: number; excluded: Array<{ offset: number; address: Location; reason: string }> }>;
296
297
  registers: Partial<Record<ObservedRegister, unknown>>;
297
298
  screen?: { match: boolean; mismatchingPixels: number; comparedPixels: number; mismatchRatio: number; bounds?: unknown };
298
299
  vicii?: unknown;
@@ -305,22 +306,14 @@ interface Observation {
305
306
  /** Writes pages 0-3 and $00/$01 from the entry state of the knowledge: both sides then start from the same RAM. */
306
307
  async function normalize(client: ViceSessionClient): Promise<void> {
307
308
  const db = openForRead();
308
- let state: { pages?: Record<string, string>; cpuPort?: Record<string, number> } | undefined;
309
+ let state: unknown;
309
310
  try {
310
- state = programImage(db)?.state as typeof state;
311
+ state = programImage(db)?.state;
311
312
  } finally {
312
313
  db?.close();
313
314
  }
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" });
315
+ if (state === undefined) throw new Refusal("invalid-input", "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");
316
+ await applyEntryState(client, parseEntryState(state, "the entry state of the knowledge"), { registers: false });
324
317
  }
325
318
 
326
319
  /**
@@ -355,16 +348,16 @@ async function runSide(client: ViceSessionClient, scenario: Scenario, side: Side
355
348
  checkpoint++;
356
349
  const observation: Observation = { memory: [], registers: {} };
357
350
  for (const item of step.memory ?? []) {
358
- const spec: MemoryObservation = typeof item === "string" ? { at: item } : item;
359
- const address = at(spec.at);
351
+ const spec: MemoryObservation = typeof item === "string" ? { address: item } : item;
352
+ const address = at(spec.address);
360
353
  const size = spec.size ?? 1;
361
354
  const read = { data: (await readMemoryInParts(client, address, size, spec.view ?? "cpu")).toString("hex") };
362
355
  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 };
356
+ const offset = at(exclude.address) - address;
357
+ if (offset < 0 || offset >= size) throw new Refusal("invalid-input", `the exclude ${exclude.address} lies outside the observed ${spec.address} (${size} bytes)`);
358
+ return { offset, address: exclude.address, reason: exclude.reason };
366
359
  });
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 });
360
+ observation.memory.push({ label: size > 1 ? `${spec.address} (${size} bytes)` : spec.address, address, bytes: [...Buffer.from(read.data, "hex")], tolerance: spec.tolerance ?? 0, excluded });
368
361
  }
369
362
  if (step.registers !== undefined) {
370
363
  const registers = await client.registersGet("c64");
@@ -407,7 +400,7 @@ async function runSide(client: ViceSessionClient, scenario: Scenario, side: Side
407
400
  const { side: _side, ...action } = step;
408
401
  const notReached = await runActionStep(client, action as ActionStep, at);
409
402
  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)})`);
403
+ throw new Inconclusive(`the ${side} side did not reach the checkpoint before ${step.runUntil.timeoutFrames} frames (it stopped by ${notReached.stopReason} at ${formatC64Address(notReached.pc)})`);
411
404
  }
412
405
  }
413
406
  }
@@ -443,11 +436,10 @@ export function compare(original: Map<string, Observation>, rebuild: Map<string,
443
436
  * Writes the files of a FAIL into `out`: for a screen difference both
444
437
  * screens and the difference image, and memory.json with each differing byte.
445
438
  */
446
- function writeFailFiles(out: string, original: Map<string, Observation>, rebuild: Map<string, Observation>, differences: Difference[]): string[] {
447
- const files: string[] = [];
439
+ function writeFailFiles(out: string, original: Map<string, Observation>, rebuild: Map<string, Observation>, differences: Difference[]): Written[] {
440
+ const files: Written[] = [];
448
441
  const write = (name: string, bytes: Uint8Array) => {
449
- writeProjectFile(`${out}/${name}`, bytes, { what: "--out" });
450
- files.push(name);
442
+ files.push(writeReported(`${out}/${name}`, bytes, { what: "--out" }));
451
443
  };
452
444
  const memory: Array<{ checkpoint: string; what: string; bytes: Array<{ address: string; original: string; rebuild: string }> }> = [];
453
445
  const hex = (byte: number) => byte.toString(16).padStart(2, "0");
@@ -479,7 +471,7 @@ export async function runScenario(client: ViceSessionClient, scenario: Scenario,
479
471
  const observed = new Map<Side, Map<string, Observation>>();
480
472
  for (const side of SIDES) {
481
473
  try {
482
- observed.set(side, await runSide(client, scenario, side, loadSymbols(scenario[side]), options.out !== undefined));
474
+ observed.set(side, await runSide(client, scenario, side, namesOf(scenario[side].symbols), options.out !== undefined));
483
475
  } catch (error) {
484
476
  if (error instanceof Inconclusive) return { result: "INCONCLUSIVE", scenario: scenario.name, reason: error.message, checkpoints, differences: [] };
485
477
  if (error instanceof WireFailure) {
@@ -491,7 +483,7 @@ export async function runScenario(client: ViceSessionClient, scenario: Scenario,
491
483
  const original = observed.get("original")!;
492
484
  const rebuild = observed.get("rebuild")!;
493
485
  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 }))));
486
+ const excluded = [...original].flatMap(([checkpoint, observation]) => observation.memory.flatMap((item) => item.excluded.map((excluded) => ({ checkpoint, address: excluded.address, reason: excluded.reason }))));
495
487
  const files = differences.length > 0 && options.out !== undefined ? writeFailFiles(options.out, original, rebuild, differences) : [];
496
488
  return {
497
489
  result: differences.length === 0 ? "PASS" : "FAIL",
@@ -0,0 +1,189 @@
1
+ // The c64-testing script. test runs a scenario on the original and the
2
+ // rebuild in one emulator; run runs steps on one side and writes what they
3
+ // ask for; record and replay play a session in the replay core (c64run) and
4
+ // compare the rebuild with it; diff shows what changed between two states;
5
+ // checklist gives the Markdown list for a human tester.
6
+
7
+ import { COVERAGE } from "#src/c64.ts";
8
+ import { WireFailure } from "#src/host-client/tools.ts";
9
+ import { writeReported } from "#src/host-client/transfer.ts";
10
+ import { ViceSessionClient } from "#src/host-client/vice-session.ts";
11
+ import { C64RunSession, PAL_FRAME_CYCLES } from "#src/native/c64run.ts";
12
+ import { localToolContext } from "#src/native/local.ts";
13
+ import { Workspace } from "#src/native/staging.ts";
14
+ import { knowledgeNames } from "#src/knowledge/names.ts";
15
+ import { OPTIONS, runScript, UsageError, type Call, type CommandSpec } from "#src/script.ts";
16
+ import { diffFiles } from "./lib/diff-files.ts";
17
+ import { play } from "./lib/play.ts";
18
+ import { readRecordFile, record, RECORDING_FILES } from "./lib/recording.ts";
19
+ import { replay } from "./lib/replay.ts";
20
+ import { runFile } from "./lib/runs.ts";
21
+ import { readScenario, runScenario, type Scenario, type ScenarioResult } from "./lib/scenario.ts";
22
+
23
+ /** The default port of the --play page: next to the Host Runtime's 6464. */
24
+ const DEFAULT_HTTP_PORT = 6465;
25
+
26
+ /** Behavior that only a person can judge. */
27
+ const ALWAYS = [
28
+ "The controls respond as fast as in the original.",
29
+ "The animation is smooth and has no glitches.",
30
+ "The music and the sound effects sound the same as in the original.",
31
+ "The game flow and the level changes work from start to end.",
32
+ "Nothing unexpected happens outside the tested scenarios.",
33
+ ];
34
+
35
+ async function test(call: Call): Promise<ScenarioResult> {
36
+ const scenario = readScenario(call.subject(0));
37
+ const out = call.string("out");
38
+ let client: ViceSessionClient;
39
+ try {
40
+ client = await ViceSessionClient.open({ videoStandard: scenario.videoStandard ?? "pal" });
41
+ } catch (error) {
42
+ if (!(error instanceof WireFailure)) throw error;
43
+ return { result: "INCONCLUSIVE", scenario: scenario.name, reason: `no emulator: ${error.message}`, checkpoints: [], differences: [] };
44
+ }
45
+ try {
46
+ return await runScenario(client, scenario, out === undefined ? {} : { out });
47
+ } finally {
48
+ await client.close();
49
+ }
50
+ }
51
+
52
+ async function recordCommand(call: Call): Promise<Record<string, unknown>> {
53
+ const playing = call.flag("play");
54
+ if (!playing && (call.count("joystick") !== undefined || call.count("http-port") !== undefined)) throw new UsageError("--joystick and --http-port are for --play");
55
+ const joystick = (call.count("joystick") ?? 2) as 1 | 2;
56
+ const httpPort = call.count("http-port") ?? DEFAULT_HTTP_PORT;
57
+ const out = call.string("out")!;
58
+ const file = readRecordFile(call.subject(0));
59
+ const context = localToolContext();
60
+ const core = await C64RunSession.open(context);
61
+ const workspace = Workspace.create(context.supervisor, "c64-re-tools-record-");
62
+ try {
63
+ const person = playing
64
+ ? (session: C64RunSession, restore: () => Promise<string | undefined>) => {
65
+ const ready = (url: string) =>
66
+ process.stderr.write("Open " + url + ". The start screen is frozen. Enter or Start begins the play, F12 or End saves the recording, F11 or Cancel ends without one. F9 changes the joystick port.\n");
67
+ return play(session, { httpPort, joystick, ready, restore });
68
+ }
69
+ : undefined;
70
+ const outcome = await record(core, file, workspace, person);
71
+ const summary = {
72
+ name: file.name,
73
+ frames: Math.round(outcome.recording.ticks / PAL_FRAME_CYCLES),
74
+ inputSites: outcome.recording.sites.length,
75
+ events: outcome.recording.events.length,
76
+ // The instructions in the coverage map: 0 when the program never read its input from RAM.
77
+ executed: outcome.coverage.reduce((count, byte) => count + ((byte & COVERAGE.ramExecute) === 0 ? 0 : 1), 0),
78
+ };
79
+ if (outcome.reason !== undefined) return { result: "FAIL", recorded: false, reason: outcome.reason, ...summary };
80
+ const files = [
81
+ writeReported(`${out}/${RECORDING_FILES.recording}`, `${JSON.stringify(outcome.recording, null, 2)}\n`, { what: "--out" }),
82
+ writeReported(`${out}/${RECORDING_FILES.reads}`, outcome.reads, { what: "--out" }),
83
+ writeReported(`${out}/${RECORDING_FILES.coverage}`, outcome.coverage, { what: "--out" }),
84
+ ];
85
+ return { result: "PASS", recorded: true, out, ...summary, files };
86
+ } finally {
87
+ await core.close();
88
+ workspace.remove();
89
+ }
90
+ }
91
+
92
+ function checklist(call: Call): { text: string } {
93
+ const lines = ["# Playtest checklist", ""];
94
+ for (const path of call.subjects) {
95
+ const scenario: Scenario = readScenario(path);
96
+ lines.push(`## ${scenario.name}`, "");
97
+ for (const step of scenario.steps) if ("observe" in step) lines.push(`- [ ] At "${step.observe}", the rebuild looks and behaves as the original.`);
98
+ lines.push("");
99
+ }
100
+ lines.push("## Application", "");
101
+ for (const item of call.strings("item")) lines.push(`- [ ] ${item}`);
102
+ for (const item of ALWAYS) lines.push(`- [ ] ${item}`);
103
+ return { text: `${lines.join("\n")}\n` };
104
+ }
105
+
106
+ const STATE_ITEM = "a name or a $ address, with :size for more than one byte, for example room:2";
107
+
108
+ const COMMANDS: CommandSpec[] = [
109
+ {
110
+ name: "test",
111
+ summary: "Runs a scenario on the original and then on the rebuild in one emulator. result is PASS, FAIL with the differences, or INCONCLUSIVE with the reason.",
112
+ subjects: [{ name: "<scenario.json>", help: "a scenario file, relative to the project folder. See references/scenario-format.md" }],
113
+ options: { out: { ...OPTIONS.out, value: "<dir>", help: "with a FAIL, write both screens, the difference image and memory.json with each differing byte into this folder" } },
114
+ run: test,
115
+ },
116
+ {
117
+ name: "run",
118
+ summary: "Runs the steps of a run file in its own emulator and writes what they ask for into --out: screens, coverage maps, snapshot files, traces, and results.json with the observations. result is FAIL when a step did not run.",
119
+ subjects: [{ name: "<run.json>", help: "a run file, relative to the project folder. See references/scenario-format.md, section Runs" }],
120
+ options: { out: { ...OPTIONS.out, value: "<dir>", required: true, help: "the folder for the files of the steps and results.json" } },
121
+ run: async (call) => {
122
+ const { output, failed } = await runFile(call.subject(0), call.string("out")!);
123
+ return { result: failed ? "FAIL" : "PASS", ...output };
124
+ },
125
+ },
126
+ {
127
+ name: "record",
128
+ summary: "Plays the steps of a record file on the original in the replay core (c64run), and writes the session as a recording folder: recording.json, reads.txt and coverage.bin. result is FAIL when the session did not complete.",
129
+ subjects: [{ name: "<record.json>", help: "a record file, relative to the project folder. See references/recording-format.md" }],
130
+ options: {
131
+ out: { ...OPTIONS.out, value: "<dir>", required: true, help: "the recording folder, for example tests/recordings/<name>" },
132
+ play: { kind: "flag", help: "a person plays after the steps: the script prints the address of a page on stderr. The page shows the start screen frozen until Start (Enter). End (F12) saves the recording, Cancel (F11) ends without one" },
133
+ joystick: { kind: "count", value: "<1|2>", min: 1, max: 2, help: "with --play: the joystick port that the program reads, at the start (default 2). F9 on the page changes it" },
134
+ "http-port": { kind: "count", value: "<n>", min: 0, max: 65535, help: `with --play: the port of the page (default ${DEFAULT_HTTP_PORT})` },
135
+ },
136
+ run: recordCommand,
137
+ },
138
+ {
139
+ name: "replay",
140
+ summary: "Replays a recording on the original, which must end with the recorded state, and with --rebuild on the rebuild, which must have the state of the original at each checkpoint. result is PASS, FAIL or INCONCLUSIVE.",
141
+ subjects: [{ name: "<recording>", help: "a folder that record wrote, relative to the project folder" }],
142
+ options: {
143
+ checkpoint: { kind: "text", value: "<routine>", help: "the name or $ address of a routine: each entry is a checkpoint. Without it, the comparison of the record file" },
144
+ state: { kind: "text", value: "<item>", multiple: true, help: `the state to compare at each checkpoint: ${STATE_ITEM}` },
145
+ pin: { kind: "text", value: "<item>", multiple: true, help: `random state in RAM, in the form of --state: at each checkpoint the rebuild gets the original's values there` },
146
+ rebuild: { kind: "path", value: "<prg>", help: "the rebuild to compare with the original" },
147
+ symbols: { ...OPTIONS.symbols, help: "the names of the rebuild: the c64-assembler output (default: the symbols of the recording)" },
148
+ out: { ...OPTIONS.out, value: "<dir>", help: "with a FAIL, write the RAM of both sides at the first different checkpoint into this folder" },
149
+ },
150
+ notes: "A recording without names takes the names of the knowledge for the original. A $ address is an address of the original: on the rebuild it moves with the name before it.",
151
+ run: (call) => {
152
+ const [checkpoint, rebuild, symbols, out] = [call.string("checkpoint"), call.string("rebuild"), call.string("symbols"), call.string("out")];
153
+ return replay({ folder: call.subject(0), state: call.strings("state"), pin: call.strings("pin"), ...(checkpoint === undefined ? {} : { checkpoint }), ...(rebuild === undefined ? {} : { rebuild }), ...(symbols === undefined ? {} : { symbols }), ...(out === undefined ? {} : { out }) });
154
+ },
155
+ },
156
+ {
157
+ name: "diff",
158
+ summary: "What changed between two machine states of one kind: two snapshot files (snapshot.vsf), two memory files of runs (memory.bin), or two observations of runs (results.json:<name>).",
159
+ subjects: [
160
+ { name: "<a>", help: "the first state, relative to the project folder" },
161
+ { name: "<b>", help: "the second state" },
162
+ ],
163
+ options: {
164
+ gap: { kind: "count", value: "<n>", min: 0, max: 0xffff, default: 0, help: "join changed ranges across at most n equal bytes (default 0)" },
165
+ "max-listed": OPTIONS["max-listed"],
166
+ ntsc: { kind: "flag", help: "the snapshots are of an NTSC machine (default PAL)" },
167
+ address: { kind: "address", value: "<address>", help: "the address of the first byte of a memory file that is shorter than 64 KiB" },
168
+ out: { ...OPTIONS.out, value: "<file>", help: "also write all ranges to this file" },
169
+ },
170
+ run: (call) => {
171
+ const [address, out] = [call.address("address"), call.string("out")];
172
+ return diffFiles(call.subject(0), call.subject(1), { gap: call.count("gap")!, max: call.count("max-listed")!, ntsc: call.flag("ntsc"), ...(address === undefined ? {} : { at: address }), ...(out === undefined ? {} : { out }) });
173
+ },
174
+ },
175
+ {
176
+ name: "checklist",
177
+ summary: "The playtest checklist for a human tester as Markdown in text: the checkpoints of the scenarios, and the behavior that automated scenarios cannot check.",
178
+ subjects: [{ name: "<scenario.json>", help: "a scenario file", list: true }],
179
+ options: { item: { kind: "text", value: "<text>", multiple: true, help: "an application-specific line" } },
180
+ run: checklist,
181
+ },
182
+ ];
183
+
184
+ await runScript({
185
+ name: "testing",
186
+ summary: "Tests a rebuild against the original: scenarios in VICE, runs, recorded play sessions in the replay core, state differences, and a checklist for a human tester.",
187
+ names: knowledgeNames,
188
+ commands: COMMANDS,
189
+ });