@henols/c64-re-tools 2.1.0 → 2.1.2

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 (73) hide show
  1. package/package.json +1 -1
  2. package/skills/c64-assembler/SKILL.md +7 -3
  3. package/skills/c64-assembler/scripts/assembler.ts +29 -11
  4. package/skills/c64-assembler/scripts/lib/compare.ts +4 -3
  5. package/skills/c64-basic/SKILL.md +1 -0
  6. package/skills/c64-disk/SKILL.md +3 -2
  7. package/skills/c64-disk/scripts/disk.ts +26 -12
  8. package/skills/c64-disk/scripts/lib/containers.ts +10 -11
  9. package/skills/c64-docs/SKILL.md +2 -1
  10. package/skills/c64-docs/scripts/docs.ts +1 -1
  11. package/skills/c64-docs/scripts/lib/links.ts +5 -3
  12. package/skills/c64-docs/scripts/lib/reference.ts +2 -2
  13. package/skills/c64-emulator/SKILL.md +9 -2
  14. package/skills/c64-knowledge/SKILL.md +9 -5
  15. package/skills/c64-knowledge/scripts/knowledge.ts +91 -53
  16. package/skills/c64-knowledge/scripts/lib/listing.ts +8 -30
  17. package/skills/c64-memory-map/SKILL.md +2 -1
  18. package/skills/c64-memory-map/scripts/memory-map.ts +4 -3
  19. package/skills/c64-provenance/SKILL.md +6 -4
  20. package/skills/c64-provenance/scripts/lib/library.ts +18 -6
  21. package/skills/c64-provenance/scripts/lib/separate.ts +45 -6
  22. package/skills/c64-provenance/scripts/provenance.ts +14 -10
  23. package/skills/c64-source/SKILL.md +3 -0
  24. package/skills/c64-source/scripts/lib/source.ts +9 -31
  25. package/skills/c64-source/scripts/source.ts +14 -10
  26. package/skills/c64-static-analysis/SKILL.md +2 -0
  27. package/skills/c64-static-analysis/scripts/lib/idioms.ts +6 -2
  28. package/skills/c64-static-analysis/scripts/static-analysis.ts +18 -8
  29. package/skills/c64-testing/SKILL.md +14 -3
  30. package/skills/c64-testing/references/recording-format.md +2 -0
  31. package/skills/c64-testing/references/scenario-format.md +6 -0
  32. package/skills/c64-testing/scripts/lib/diff-files.ts +76 -14
  33. package/skills/c64-testing/scripts/lib/diff.ts +3 -3
  34. package/skills/c64-testing/scripts/lib/recording.ts +50 -15
  35. package/skills/c64-testing/scripts/lib/replay.ts +12 -1
  36. package/skills/c64-testing/scripts/lib/runs.ts +84 -4
  37. package/skills/c64-testing/scripts/lib/scenario.ts +43 -6
  38. package/skills/c64-testing/scripts/testing.ts +20 -7
  39. package/skills/c64-unpacker/SKILL.md +17 -10
  40. package/skills/c64-unpacker/scripts/lib/evidence.ts +4 -5
  41. package/skills/c64-unpacker/scripts/lib/loads.ts +14 -7
  42. package/skills/c64-unpacker/scripts/lib/machine.ts +30 -3
  43. package/skills/c64-unpacker/scripts/lib/stages.ts +15 -7
  44. package/skills/c64-unpacker/scripts/unpacker.ts +93 -42
  45. package/src/host/vice/adapter.ts +4 -1
  46. package/src/host/vice/session.ts +148 -13
  47. package/src/host-client/image.ts +40 -8
  48. package/src/host-client/steps.ts +145 -9
  49. package/src/host-client/tools.ts +1 -1
  50. package/src/host-client/transfer.ts +33 -18
  51. package/src/knowledge/expression.ts +1 -1
  52. package/src/knowledge/history.ts +74 -4
  53. package/src/knowledge/import.ts +79 -7
  54. package/src/knowledge/names.ts +2 -1
  55. package/src/knowledge/program.ts +32 -18
  56. package/src/knowledge/read.ts +46 -0
  57. package/src/knowledge/schema.ts +4 -2
  58. package/src/knowledge/text.ts +2 -0
  59. package/src/knowledge/write.ts +58 -16
  60. package/src/mcp/inputs.ts +6 -3
  61. package/src/mcp/output.ts +5 -0
  62. package/src/mcp/server.ts +20 -4
  63. package/src/mcp/tools/debug.ts +36 -9
  64. package/src/mcp/tools/execution.ts +18 -5
  65. package/src/mcp/tools/machine.ts +3 -1
  66. package/src/mcp/tools/media.ts +14 -2
  67. package/src/mcp/tools/memory.ts +11 -6
  68. package/src/mcp/tools/video.ts +5 -3
  69. package/src/media.ts +42 -0
  70. package/src/opcodes.ts +51 -18
  71. package/src/project.ts +111 -3
  72. package/src/protocol/vice.ts +8 -2
  73. package/src/script.ts +55 -3
@@ -5,6 +5,8 @@
5
5
  // run, or two observations of runs (also two of one run), compare as they are.
6
6
 
7
7
  import { formatC64Address, parseC64Address } from "#src/c64.ts";
8
+ import { Refusal } from "#src/errors.ts";
9
+ import { isObject } from "#src/host-client/steps.ts";
8
10
  import { WireFailure } from "#src/host-client/tools.ts";
9
11
  import { readProjectFile, writeReported } from "#src/host-client/transfer.ts";
10
12
  import { readMemoryInParts, ViceSessionClient } from "#src/host-client/vice-session.ts";
@@ -41,33 +43,92 @@ function observation(path: string): MachineState {
41
43
  observed[at] = readProjectFile(`${folder}${(value as { file: string }).file}`).bytes.toString("hex");
42
44
  }
43
45
  }
44
- const values: Record<string, number> = {};
45
- for (const [register, value] of Object.entries(seen.registers ?? {})) {
46
+ return { observed, values: cpuValues(seen.registers ?? {}) };
47
+ }
48
+
49
+ /** The CPU registers as values: each register by its name, the pc from a number or "$xxxx", and each flag as cpu.flags.<flag>. */
50
+ function cpuValues(registers: Record<string, unknown>): Record<string, number | boolean> {
51
+ const values: Record<string, number | boolean> = {};
52
+ for (const [register, value] of Object.entries(registers)) {
46
53
  if (typeof value === "number") values[`cpu.${register}`] = value;
47
54
  else if (typeof value === "string" && value.startsWith("$")) values[`cpu.${register}`] = parseC64Address(value);
55
+ else if (register === "flags" && isObject(value)) for (const [flag, set] of Object.entries(value)) if (typeof set === "boolean") values[`cpu.flags.${flag}`] = set;
48
56
  }
49
- return { observed, values };
57
+ return values;
50
58
  }
51
59
 
52
- /** The state of a snapshot: restored in the session, then read with the readers of the runs. */
53
- async function snapshotState(client: ViceSessionClient, path: string): Promise<MachineState> {
54
- await client.snapshotRestore(readProjectFile(path).bytes);
60
+ /** The size of the header of a module in a VICE snapshot file: the name (16 bytes), the version (2 bytes) and the size (4 bytes). */
61
+ const MODULE_HEADER = 22;
62
+ const VSF_MAGIC = "VICE Snapshot File\x1a";
63
+ const VSF_VERSION = "VICE Version\x1a";
64
+
65
+ /**
66
+ * The 64 KiB of C64 RAM that a VICE snapshot file holds: the C64MEM module,
67
+ * after the CPU port and the cartridge lines (4 bytes). Undefined when the
68
+ * file has no such module.
69
+ */
70
+ export function snapshotRam(file: Uint8Array): Uint8Array | undefined {
71
+ const bytes = Buffer.from(file);
72
+ if (bytes.subarray(0, VSF_MAGIC.length).toString("latin1") !== VSF_MAGIC) return undefined;
73
+ // The magic, the version (2 bytes) and the machine name (16 bytes); a newer VICE adds its own version (8 bytes).
74
+ let at = VSF_MAGIC.length + 2 + 16;
75
+ if (bytes.subarray(at, at + VSF_VERSION.length).toString("latin1") === VSF_VERSION) at += VSF_VERSION.length + 8;
76
+ while (at + MODULE_HEADER <= bytes.length) {
77
+ const name = bytes.subarray(at, at + 16).toString("latin1").replace(/\0+$/, "");
78
+ const size = bytes.readUInt32LE(at + 18);
79
+ if (size < MODULE_HEADER) return undefined;
80
+ if (name === "C64MEM") return at + MODULE_HEADER + 4 + 0x10000 <= bytes.length ? bytes.subarray(at + MODULE_HEADER + 4, at + MODULE_HEADER + 4 + 0x10000) : undefined;
81
+ at += size;
82
+ }
83
+ return undefined;
84
+ }
85
+
86
+ /**
87
+ * The state of a snapshot: restored in the session, then read with the
88
+ * readers of the runs. The RAM must be the RAM of the file: else the
89
+ * snapshot did not restore, and the state would be wrong.
90
+ */
91
+ async function snapshotState(client: ViceSessionClient, path: string, ntsc: boolean): Promise<MachineState> {
92
+ const file = readProjectFile(path).bytes;
93
+ const saved = snapshotRam(file);
94
+ if (saved === undefined) throw new Refusal("invalid-input", `${path} is not a snapshot of a C64: it has no C64 memory. Give a file that c64_snapshot save or a save step of a run wrote.`);
95
+ const restored = await client.snapshotRestore(file);
55
96
  const memory = await readMemoryInParts(client, 0x0000, 0x10000, "ram");
97
+ // $00 and $01 are the CPU port: the file keeps them apart from the RAM.
98
+ if (("restored" in restored && !restored.restored) || !memory.subarray(2).equals(Buffer.from(saved.subarray(2)))) {
99
+ throw new Refusal(
100
+ "invalid-input",
101
+ `${path} did not restore: the RAM after the restore is not the RAM of the file. A snapshot restores only in a machine of the video standard that saved it. ${ntsc ? "Leave out --ntsc for a PAL snapshot" : "Give --ntsc for an NTSC snapshot"}, or use the emulator version that saved it.`,
102
+ );
103
+ }
56
104
  const colorRam = Buffer.from((await client.ioRead(0xd800, 0x400)).data, "hex").map((value) => value & 0x0f);
57
105
  const vic = Buffer.from((await client.ioRead(0xd000, 0x2f)).data, "hex");
58
- const values: Record<string, number> = {};
106
+ const values: Record<string, number | boolean> = {};
59
107
  vic.forEach((value, index) => (values[`vic.${formatC64Address(0xd000 + index)}`] = value));
60
108
  for (const chip of (await client.cia("both")).chips) {
61
109
  for (const field of ["portA", "portB", "ddrA", "ddrB", "timerA", "timerB", "latchA", "latchB", "controlA", "controlB", "interruptMask"] as const) values[`cia${chip.id}.${field}`] = chip[field];
62
110
  }
63
111
  const sid = await client.sid();
64
112
  for (const voice of sid.voices) for (const [field, value] of Object.entries(voice)) if (field !== "index" && typeof value === "number") values[`sid.voice${voice.index}.${field}`] = value;
65
- const registers = await client.registersGet("c64");
66
- for (const [field, value] of Object.entries(registers)) if (typeof value === "number") values[`cpu.${field}`] = value;
113
+ Object.assign(values, cpuValues({ ...(await client.registersGet("c64")) }));
67
114
  return { memory: { base: 0, bytes: memory }, colorRam, values };
68
115
  }
69
116
 
70
117
  const show = (range: ChangedRange) => ({ start: formatC64Address(range.start), end: formatC64Address(range.end), ...(range.a === undefined ? {} : { a: range.a, b: range.b }) });
118
+ /** A changed value as a person reads it: the pc as "$xxxx". */
119
+ const showValue = (item: { name: string; a: number | boolean; b: number | boolean }) => (item.name === "cpu.pc" && typeof item.a === "number" && typeof item.b === "number" ? { name: item.name, a: formatC64Address(item.a), b: formatC64Address(item.b) } : item);
120
+
121
+ /**
122
+ * How many items two observations have in common, by name: the items that the
123
+ * diff compared. With none, the reason names what each side has, so that a
124
+ * count of 0 does not read as "equal".
125
+ */
126
+ export function comparedItems(what: string, a: readonly string[], b: readonly string[], first: string, second: string): { compared: number; reason?: string } {
127
+ const compared = a.filter((name) => b.includes(name)).length;
128
+ if (compared > 0) return { compared };
129
+ const has = (names: readonly string[]) => (names.length === 0 ? "none" : names.join(", "));
130
+ return { compared, reason: `No ${what} was compared: the observations have none in common. ${first} has ${has(a)}; ${second} has ${has(b)}.` };
131
+ }
71
132
 
72
133
  /** The differences of two states of one kind: snapshots, memory files or observations of runs. */
73
134
  export async function diffFiles(first: string, second: string, options: { gap: number; max: number; ntsc: boolean; at?: number; out?: string }): Promise<Record<string, unknown>> {
@@ -90,8 +151,8 @@ export async function diffFiles(first: string, second: string, options: { gap: n
90
151
  } else {
91
152
  const client = await ViceSessionClient.open({ videoStandard: options.ntsc ? "ntsc" : "pal" });
92
153
  try {
93
- a = await snapshotState(client, first);
94
- b = await snapshotState(client, second);
154
+ a = await snapshotState(client, first, options.ntsc);
155
+ b = await snapshotState(client, second, options.ntsc);
95
156
  } finally {
96
157
  await client.close();
97
158
  }
@@ -101,7 +162,8 @@ export async function diffFiles(first: string, second: string, options: { gap: n
101
162
  const part = (item: { changedBytes: number; ranges: ChangedRange[] } | undefined) => (item === undefined ? undefined : { changedBytes: item.changedBytes, ...listed(item.ranges, max, show, rest) });
102
163
  let written: ReturnType<typeof writeReported> | undefined;
103
164
  if (options.out !== undefined) {
104
- const all = { a: first, b: second, ...(diff.memory === undefined ? {} : { memory: diff.memory.ranges.map(show) }), ...(diff.colorRam === undefined ? {} : { colorRam: diff.colorRam.ranges.map(show) }), values: diff.values ?? [], observed: diff.observed ?? [] };
165
+ // The names of the result: registers holds the changed values.
166
+ const all = { a: first, b: second, ...(diff.memory === undefined ? {} : { memory: diff.memory.ranges.map(show) }), ...(diff.colorRam === undefined ? {} : { colorRam: diff.colorRam.ranges.map(show) }), registers: (diff.values ?? []).map(showValue), observed: diff.observed ?? [] };
105
167
  written = writeReported(options.out, `${JSON.stringify(all, null, 2)}\n`, { what: "--out" });
106
168
  }
107
169
  return {
@@ -109,8 +171,8 @@ export async function diffFiles(first: string, second: string, options: { gap: n
109
171
  b: second,
110
172
  ...(diff.memory === undefined ? {} : { memory: part(diff.memory) }),
111
173
  ...(diff.colorRam === undefined ? {} : { colorRam: part(diff.colorRam) }),
112
- ...(diff.values === undefined ? {} : { registers: listed(diff.values, max, undefined, rest) }),
113
- ...(diff.observed === undefined ? {} : { observed: listed(diff.observed, max, undefined, rest) }),
174
+ ...(diff.values === undefined ? {} : { registers: { ...(kind === "observation" ? comparedItems("register", Object.keys(a.values ?? {}), Object.keys(b.values ?? {}), first, second) : {}), ...listed(diff.values, max, showValue, rest) } }),
175
+ ...(diff.observed === undefined ? {} : { observed: { ...comparedItems("memory item", Object.keys(a.observed ?? {}), Object.keys(b.observed ?? {}), first, second), ...listed(diff.observed, max, undefined, rest) } }),
114
176
  ...(written === undefined ? {} : written),
115
177
  };
116
178
  }
@@ -10,8 +10,8 @@ export interface MachineState {
10
10
  memory?: { base: number; bytes: Uint8Array };
11
11
  /** Colour RAM $D800-$DBFF, the low 4 bits of each byte. */
12
12
  colorRam?: Uint8Array;
13
- /** Named single values: VIC-II, CIA and SID registers and the CPU registers, as "vic.$d011", "cia1.timerA", "cpu.pc". */
14
- values?: Record<string, number>;
13
+ /** Named single values: VIC-II, CIA and SID registers and the CPU registers, as "vic.$d011", "cia1.timerA", "cpu.pc", and each CPU flag as "cpu.flags.c". */
14
+ values?: Record<string, number | boolean>;
15
15
  /** The memory items of an observation of a run: the address or name, and its bytes as hex. */
16
16
  observed?: Record<string, string>;
17
17
  }
@@ -27,7 +27,7 @@ export interface ChangedRange {
27
27
  export interface StateDiff {
28
28
  memory?: { changedBytes: number; ranges: ChangedRange[] };
29
29
  colorRam?: { changedBytes: number; ranges: ChangedRange[] };
30
- values?: Array<{ name: string; a: number; b: number }>;
30
+ values?: Array<{ name: string; a: number | boolean; b: number | boolean }>;
31
31
  observed?: Array<{ address: string; a: string; b: string }>;
32
32
  }
33
33
 
@@ -14,7 +14,7 @@ import { readFileSync, writeFileSync } from "node:fs";
14
14
  import { isRefusal, Refusal } from "#src/errors.ts";
15
15
  import { formatC64Address, parseC64Address } from "#src/c64.ts";
16
16
  import { READY_FRAMES } from "#src/host-client/entry-state.ts";
17
- import { ACTION_STEP_KINDS, checkActionStep, isInteger, isObject, keyboardBytes, stepKind, type ActionStep, type Location } from "#src/host-client/steps.ts";
17
+ import { ACTION_STEP_KINDS, checkActionStep, isInteger, isObject, keyboardBytes, renamedField, stepKind, type ActionStep, type Location } from "#src/host-client/steps.ts";
18
18
  import { readProjectFile, type Written } from "#src/host-client/transfer.ts";
19
19
  import { PAL_FRAME_CYCLES, type C64RunSession, type CoreStatus } from "#src/native/c64run.ts";
20
20
  import type { Workspace } from "#src/native/staging.ts";
@@ -46,7 +46,7 @@ export interface StateItem {
46
46
  size: number;
47
47
  }
48
48
 
49
- /** The input of record.ts. */
49
+ /** The input of testing.ts record. */
50
50
  export interface RecordFile {
51
51
  name: string;
52
52
  program: string;
@@ -145,21 +145,28 @@ export function joystickMask(direction: Extract<ActionStep, { joystick: unknown
145
145
  return direction.split("-").reduce((mask, part) => mask | (bits[part] ?? 0), fire ? 0x10 : 0);
146
146
  }
147
147
 
148
- /** A state item of the command line: a name or a $ address, with :size for more than one byte. */
149
- export function parseStateItem(text: string): StateItem {
148
+ /** A state item of the command line: a name or a $ address, with :size for more than one byte. `option` names the option of the item in a refusal. */
149
+ export function parseStateItem(text: string, option = "--state"): StateItem {
150
150
  const match = /^([^:]+)(?::(\d+))?$/.exec(text);
151
151
  const size = Number(match?.[2] ?? 1);
152
- if (match === null || !isInteger(size, 1, MAX_STATE_BYTES)) throw new Refusal("invalid-input", `--state ${text}: give a name or a $ address, with :size from 1 to ${MAX_STATE_BYTES}`);
152
+ if (match === null || !isInteger(size, 1, MAX_STATE_BYTES)) throw new Refusal("invalid-input", `${option} ${text}: give a name or a $ address, with :size from 1 to ${MAX_STATE_BYTES}`);
153
153
  return { address: match[1]!, size };
154
154
  }
155
155
 
156
- export function checkState(value: unknown, fail: (message: string) => never): StateItem[] {
157
- if (!Array.isArray(value) || value.length === 0 || value.length > MAX_STATE_ITEMS) fail(`state must be a list of 1 to ${MAX_STATE_ITEMS} memory items`);
156
+ /** Checks the memory items of state or pin (`what`): names, $ addresses, or { address, size }. */
157
+ export function checkState(value: unknown, fail: (message: string) => never, what = "state"): StateItem[] {
158
+ if (!Array.isArray(value) || value.length === 0 || value.length > MAX_STATE_ITEMS) fail(`${what} must be a list of 1 to ${MAX_STATE_ITEMS} memory items`);
158
159
  return (value as unknown[]).map((item, index) => {
159
160
  const spec = typeof item === "string" ? { address: item } : item;
160
- if (!isObject(spec) || typeof spec.address !== "string" || Object.keys(spec).some((key) => key !== "address" && key !== "size")) fail(`state item ${index + 1} must be a name, a $ address, or { address, size }`);
161
+ if (isObject(spec)) {
162
+ for (const key of Object.keys(spec)) {
163
+ const renamed = renamedField(key, ["address", "size"]);
164
+ if (key !== "address" && key !== "size") fail(`${what} item ${index + 1} has the field ${key}. ${renamed ?? "A memory item has address and size, and nothing else"}`);
165
+ }
166
+ }
167
+ if (!isObject(spec) || typeof spec.address !== "string") fail(`${what} item ${index + 1} must be a name, a $ address, or { address, size }`);
161
168
  const size = (spec as { size?: unknown }).size ?? 1;
162
- if (!isInteger(size, 1, MAX_STATE_BYTES)) fail(`state item ${index + 1}: size must be from 1 to ${MAX_STATE_BYTES}`);
169
+ if (!isInteger(size, 1, MAX_STATE_BYTES)) fail(`${what} item ${index + 1}: size must be from 1 to ${MAX_STATE_BYTES}`);
163
170
  return { address: (spec as { address: string }).address, size: size as number };
164
171
  });
165
172
  }
@@ -195,7 +202,7 @@ export function readRecordFile(path: string): RecordFile {
195
202
  if ((value.checkpoint === undefined) !== (value.state === undefined)) fail("give checkpoint and state together, or neither: a replay can give them");
196
203
  if (value.checkpoint !== undefined && (typeof value.checkpoint !== "string" || value.checkpoint === "")) fail("checkpoint must be the name or $ address of a routine");
197
204
  if (value.pin !== undefined && value.checkpoint === undefined) fail("pin needs checkpoint and state");
198
- const pin = value.pin === undefined ? undefined : checkState(value.pin, fail);
205
+ const pin = value.pin === undefined ? undefined : checkState(value.pin, fail, "pin");
199
206
  const compare = value.checkpoint === undefined ? undefined : { checkpoint: value.checkpoint as string, state: checkState(value.state, fail), ...(pin === undefined ? {} : { pin }) };
200
207
  const inputs = checkInputs(value.inputs, fail);
201
208
  if (!Array.isArray(value.steps) || value.steps.length === 0) fail("steps must be a list with at least one step");
@@ -219,7 +226,14 @@ export function readRecordFile(path: string): RecordFile {
219
226
  /** What the replay core cannot do of a step that is right for VICE: it has the C64 alone, no raster wait and no conditions. */
220
227
  function checkCoreStep(step: ActionStep, at: string, fail: (message: string) => never): void {
221
228
  if ("write" in step && step.write.space !== "c64") fail(`${at}: the replay core has the C64 alone, not ${step.write.space}`);
222
- if ("press" in step) keyCodes(step.press);
229
+ if ("press" in step) {
230
+ try {
231
+ keyCodes(step.press);
232
+ } catch (error) {
233
+ if (isRefusal(error)) fail(`${at}: ${error.message}`);
234
+ throw error;
235
+ }
236
+ }
223
237
  if ("type" in step) {
224
238
  const keys = keyboardBytes(step.type);
225
239
  if (typeof keys !== "string" && keys.length > QUEUE_SIZE) fail(`${at}: the KERNAL keyboard queue holds ${QUEUE_SIZE} keys; give more type steps, or press for keys of the keyboard matrix`);
@@ -242,10 +256,31 @@ export function readRecording(folder: string): Recording {
242
256
  if (isRefusal(error)) throw error;
243
257
  throw new Refusal("invalid-input", `recording ${path}: ${(error as Error).message}`);
244
258
  }
245
- if (!isObject(value) || value.format !== RECORDING_FORMAT) throw new Refusal("invalid-input", `recording ${path}: it is not a recording of format ${RECORDING_FORMAT}`);
246
- for (const key of ["name", "program", "sha256", "symbols", "hash"]) if (typeof value[key] !== "string") throw new Refusal("invalid-input", `recording ${path}: ${key} is missing`);
247
- for (const key of ["inputs", "events", "sites"]) if (!Array.isArray(value[key])) throw new Refusal("invalid-input", `recording ${path}: ${key} is missing`);
248
- if (!isInteger(value.ticks, 1, Number.MAX_SAFE_INTEGER)) throw new Refusal("invalid-input", `recording ${path}: ticks is missing`);
259
+ const fail = (message: string): never => {
260
+ throw new Refusal("invalid-input", `recording ${path}: ${message}`);
261
+ };
262
+ if (!isObject(value) || value.format !== RECORDING_FORMAT) return fail(`it is not a recording of format ${RECORDING_FORMAT}`);
263
+ for (const key of ["name", "program", "sha256", "symbols", "hash"]) if (typeof value[key] !== "string") fail(`${key} is missing`);
264
+ for (const key of ["inputs", "events", "sites"]) if (!Array.isArray(value[key])) fail(`${key} is missing`);
265
+ if (!isInteger(value.ticks, 1, Number.MAX_SAFE_INTEGER)) fail("ticks is missing");
266
+ // A recording of an older version can have the step forms of the 2.0 files: refused with the form to write, never replayed wrong.
267
+ const remedy = "Record the session again with record, or write the 2.1 form in recording.json";
268
+ if (value.compare !== undefined) {
269
+ const compare = isObject(value.compare) ? value.compare : fail("compare must be an object with checkpoint, state and pin");
270
+ const where = (message: string): never => fail(`compare: ${message}. ${remedy}`);
271
+ if (typeof compare.checkpoint !== "string" || compare.checkpoint === "") where("checkpoint must be the name or $ address of a routine");
272
+ checkState(compare.state, where);
273
+ if (compare.pin !== undefined) checkState(compare.pin, where, "pin");
274
+ }
275
+ (value.events as unknown[]).forEach((event, index) => {
276
+ const at = `event ${index + 1}`;
277
+ const where = (message: string): never => fail(`${message}. ${remedy}`);
278
+ if (!isObject(event) || !isObject(event.action) || !isObject(event.key)) return where(`${at} must be an object with action and key`);
279
+ const named = typeof event.step === "number" ? `${at} (step ${event.step})` : at;
280
+ if (event.action.restore === true && Object.keys(event.action).length === 1) return;
281
+ // The action with the defaults filled in, as the record file gave it.
282
+ event.action = checkActionStep(stepKind(event.action, ACTION_STEP_KINDS, named, where), event.action, named, where, { press: true });
283
+ });
249
284
  return value as unknown as Recording;
250
285
  }
251
286
 
@@ -6,6 +6,8 @@
6
6
 
7
7
  import { createHash } from "node:crypto";
8
8
 
9
+ import { isRefusal, Refusal } from "#src/errors.ts";
10
+ import { readPrg } from "#src/host-client/image.ts";
9
11
  import { readProjectFile, writeReported, type Written } from "#src/host-client/transfer.ts";
10
12
  import { C64RunSession } from "#src/native/c64run.ts";
11
13
  import { localToolContext } from "#src/native/local.ts";
@@ -35,7 +37,7 @@ export async function replay(values: ReplayOptions): Promise<ReplayResult> {
35
37
  const folder = values.folder.replace(/\/+$/, "").replace(/\/recording\.json$/, "");
36
38
  const recording = readRecording(folder);
37
39
  const given: Comparison | undefined =
38
- values.checkpoint === undefined ? undefined : { checkpoint: values.checkpoint, state: values.state.map(parseStateItem), ...(values.pin.length === 0 ? {} : { pin: values.pin.map(parseStateItem) }) };
40
+ values.checkpoint === undefined ? undefined : { checkpoint: values.checkpoint, state: values.state.map((item) => parseStateItem(item, "--state")), ...(values.pin.length === 0 ? {} : { pin: values.pin.map((item) => parseStateItem(item, "--pin")) }) };
39
41
  const compare = given ?? recording.compare;
40
42
  if (values.rebuild !== undefined && compare === undefined) throw new UsageError("the recording has no checkpoint and state: give --checkpoint and --state");
41
43
  const result = (outcome: ReplayResult["result"], side: ReplayResult["side"], fields: Partial<ReplayResult> = {}): ReplayResult => ({
@@ -47,6 +49,15 @@ export async function replay(values: ReplayOptions): Promise<ReplayResult> {
47
49
  compared: 0,
48
50
  ...fields,
49
51
  });
52
+ // The files of the rebuild are checked before the replay core starts.
53
+ for (const [option, check] of [["--rebuild", () => values.rebuild !== undefined && readPrg(values.rebuild)], ["--symbols", () => values.symbols !== undefined && namesOf(values.symbols)]] as const) {
54
+ try {
55
+ check();
56
+ } catch (error) {
57
+ if (isRefusal(error)) throw new Refusal(error.code, `${option}: ${error.message}`);
58
+ throw error;
59
+ }
60
+ }
50
61
  const sha256 = createHash("sha256").update(readProjectFile(recording.program).bytes).digest("hex");
51
62
  if (sha256 !== recording.sha256) return result("INCONCLUSIVE", "original", { reason: `${recording.program} is not the program of the recording (its SHA-256 changed); record the session again` });
52
63
 
@@ -5,8 +5,10 @@
5
5
 
6
6
  import { formatC64Address, formatSprite, formatVicii, parseC64Address } from "#src/c64.ts";
7
7
  import { runProgram } from "#src/host-client/autostart.ts";
8
+ import { isRefusal, Refusal } from "#src/errors.ts";
8
9
  import { readCoverage } from "#src/host-client/coverage.ts";
9
- import { ACTION_STEP_KINDS, checkActionStep, isInteger, isObject, runActionStep, type ActionStep, type ActionStepKind } from "#src/host-client/steps.ts";
10
+ import { ACTION_STEP_KINDS, checkActionStep, checkStepNames, isInteger, isObject, refuseOldStep, renamedField, runActionStep, VICE_STEP_KINDS, type ActionStep, type ActionStepKind } from "#src/host-client/steps.ts";
11
+ import { readPrg } from "#src/host-client/image.ts";
10
12
  import { MAX_MEMORY_READ, MAX_TRACE_FRAMES, MAX_TRACE_HITS, MAX_TRACE_POINTS, TRACE_KINDS, WireFailure, type TraceHit, type TraceKind } from "#src/host-client/tools.ts";
11
13
  import { readProjectFile, writeReported, type Written } from "#src/host-client/transfer.ts";
12
14
  import { readMemoryInParts, ViceSessionClient } from "#src/host-client/vice-session.ts";
@@ -35,6 +37,40 @@ const TRACE_KEYS = ["trace", "points", "frames", "maxHits"];
35
37
  const OBSERVE_KEYS = ["observe", "memory", "registers", "vicii", "sprites"];
36
38
  /** A name of a file that a step writes: letters, digits, dots, underscores or hyphens. */
37
39
  const NAME = /^[A-Za-z0-9._-]{1,64}$/;
40
+ /** The file of the observations, which every run writes. */
41
+ const RESULTS = "results.json";
42
+
43
+ /** The file name of an observed memory item that goes into its own file: more than one read gives. */
44
+ const memoryFile = (observe: string, address: string) => `${observe}-${address.replace(/^\$/, "")}.bin`;
45
+
46
+ /**
47
+ * The files that each step of a run writes into --out, by step. Two steps
48
+ * that write one name (also in another case: some file systems do not tell
49
+ * them apart) are refused: the second would overwrite the first.
50
+ */
51
+ function checkFileNames(steps: RunStep[], fail: (message: string) => never): void {
52
+ const writers = new Map<string, string>([[RESULTS, `the run itself (${RESULTS})`]]);
53
+ steps.forEach((step, index) => {
54
+ const named: [string, string[]] =
55
+ "save" in step
56
+ ? ["save", [`${step.save}.vsf`]]
57
+ : "screen" in step
58
+ ? ["screen", [`${step.screen}.png`]]
59
+ : "coverage" in step
60
+ ? ["coverage", [`${step.coverage}.bin`]]
61
+ : "trace" in step
62
+ ? ["trace", [`${step.trace}.json`]]
63
+ : "observe" in step
64
+ ? ["observe", step.memory === "all" ? [`${step.observe}.bin`] : (step.memory ?? []).flatMap((item) => (typeof item !== "string" && (item.size ?? 1) > MAX_MEMORY_READ ? [memoryFile(step.observe, item.address)] : []))]
65
+ : ["", []];
66
+ const [kind, files] = named;
67
+ for (const file of files) {
68
+ const other = writers.get(file.toLowerCase());
69
+ if (other !== undefined) fail(`step ${index + 1} (${kind}) writes ${file}, and ${other} writes that file too. Give one of them another name`);
70
+ writers.set(file.toLowerCase(), `step ${index + 1} (${kind})`);
71
+ }
72
+ });
73
+ }
38
74
 
39
75
  function readRun(path: string): Run {
40
76
  let value: unknown;
@@ -60,8 +96,10 @@ function readRun(path: string): Run {
60
96
  const at = `step ${index + 1}`;
61
97
  if (!isObject(step)) return fail(`${at} must be an object`);
62
98
  const kinds = "observe" in step ? ["observe"] : "trace" in step ? ["trace"] : Object.keys(step).filter((key) => (ACTION_STEP_KINDS as readonly string[]).includes(key) || (OWN_KINDS as readonly string[]).includes(key));
63
- if (kinds.length !== 1) return fail(`${at} must have exactly one of ${[...ACTION_STEP_KINDS, ...OWN_KINDS].join(", ")}`);
99
+ if (kinds.length > 1) return fail(`${at} has ${kinds.length} step kinds: ${kinds.join(", ")}. Give each one its own step`);
100
+ if (kinds.length !== 1) return fail(`${at} must have exactly one of ${[...VICE_STEP_KINDS, ...OWN_KINDS].join(", ")}`);
64
101
  const kind = kinds[0]!;
102
+ refuseOldStep(kind, step, at, fail);
65
103
  const allowed = kind === "observe" ? OBSERVE_KEYS : kind === "trace" ? TRACE_KEYS : [kind];
66
104
  for (const key of Object.keys(step)) if (!allowed.includes(key)) fail(`${at} has the key ${key}, which a ${kind} step does not have`);
67
105
  if ((ACTION_STEP_KINDS as readonly string[]).includes(kind)) {
@@ -81,7 +119,8 @@ function readRun(path: string): Run {
81
119
  const points = step.points;
82
120
  if (!Array.isArray(points) || points.length === 0 || points.length > MAX_TRACE_POINTS) return fail(`${at}: points is a list of 1 to ${MAX_TRACE_POINTS} objects with kind and start`);
83
121
  for (const point of points as unknown[]) {
84
- if (!isObject(point) || Object.keys(point).some((key) => !["kind", "start", "end"].includes(key))) return fail(`${at}: a point has kind, start and end, and nothing else`);
122
+ if (!isObject(point)) return fail(`${at}: a point is an object with kind, start and end`);
123
+ for (const key of Object.keys(point)) if (!["kind", "start", "end"].includes(key)) fail(`${at}: a point has the field ${key}. ${renamedField(key, ["kind", "start", "end"]) ?? "A point has kind, start and end, and nothing else"}`);
85
124
  if (!(TRACE_KINDS as readonly unknown[]).includes(point.kind)) fail(`${at}: a point kind is exec, read or write`);
86
125
  if (typeof point.start !== "string" || (point.end !== undefined && typeof point.end !== "string")) fail(`${at}: a point needs start, and end when it is a range, as names or $ addresses`);
87
126
  }
@@ -92,6 +131,16 @@ function readRun(path: string): Run {
92
131
  if (kind !== "observe") return;
93
132
  if (labels.has(name as string)) fail(`${at} needs a new observation name`);
94
133
  labels.add(name as string);
134
+ const itemFields = ["address", "size", "view"];
135
+ if (Array.isArray(step.memory)) {
136
+ for (const item of step.memory as unknown[]) {
137
+ if (!isObject(item)) continue;
138
+ for (const key of Object.keys(item)) {
139
+ const renamed = renamedField(key, itemFields);
140
+ if (renamed !== undefined && !itemFields.includes(key)) fail(`${at}: a memory object has the field ${key}. ${renamed}`);
141
+ }
142
+ }
143
+ }
95
144
  const memoryItem = (item: unknown) =>
96
145
  typeof item === "string" ||
97
146
  (isObject(item) &&
@@ -106,9 +155,35 @@ function readRun(path: string): Run {
106
155
  if (step.vicii !== undefined && typeof step.vicii !== "boolean") fail(`${at}: vicii must be true or false`);
107
156
  if (step.sprites !== undefined && (!Array.isArray(step.sprites) || !step.sprites.every((sprite) => isInteger(sprite, 0, 7)))) fail(`${at}: sprites is a list of numbers from 0 to 7`);
108
157
  });
158
+ checkFileNames(value.steps as RunStep[], fail);
109
159
  return value as unknown as Run;
110
160
  }
111
161
 
162
+ /** The remedy for a start program that is no PRG. */
163
+ const PROGRAM_REMEDY = "A run starts a PRG. Get the PRG out of the disk, tape or cartridge with disk.ts read (or disk.ts crt) of the c64-disk skill";
164
+
165
+ /** Checks each file that the run reads before the emulator starts: the start program or snapshot, and each snapshot step. A refusal names the run file and the step. */
166
+ function checkRunFiles(path: string, run: Run): void {
167
+ const named = (where: string, check: () => unknown) => {
168
+ try {
169
+ check();
170
+ } catch (error) {
171
+ if (isRefusal(error)) throw new Refusal(error.code, `run ${path}: ${where}: ${error.message}`);
172
+ throw error;
173
+ }
174
+ };
175
+ if ("program" in run.start) {
176
+ const { program } = run.start;
177
+ named("start.program", () => readPrg(program, PROGRAM_REMEDY));
178
+ } else {
179
+ const { snapshot } = run.start;
180
+ named("start.snapshot", () => readProjectFile(snapshot));
181
+ }
182
+ run.steps.forEach((step, index) => {
183
+ if ("snapshot" in step) named(`step ${index + 1}: snapshot`, () => readProjectFile(step.snapshot));
184
+ });
185
+ }
186
+
112
187
  /** A trace hit with $ addresses, as a person reads it. */
113
188
  function showHit(hit: TraceHit) {
114
189
  return { ...hit, pc: formatC64Address(hit.pc), ...(hit.address === undefined ? {} : { address: formatC64Address(hit.address) }) };
@@ -117,6 +192,7 @@ function showHit(hit: TraceHit) {
117
192
  /** Runs the run file and writes its files into `out`. failed: a step did not run. */
118
193
  export async function runFile(path: string, out: string): Promise<{ output: Record<string, unknown>; failed: boolean }> {
119
194
  const run = readRun(path);
195
+ checkRunFiles(path, run);
120
196
  const symbols = namesOf(run.symbols ?? "none");
121
197
  const at = (location: string): number => {
122
198
  if (location.startsWith("$")) return parseC64Address(location);
@@ -124,6 +200,10 @@ export async function runFile(path: string, out: string): Promise<{ output: Reco
124
200
  if (address === undefined) throw new UsageError(`the symbols have no ${location}`);
125
201
  return address;
126
202
  };
203
+ // The names of the action steps resolve before the emulator starts.
204
+ run.steps.forEach((step, index) => {
205
+ if ((ACTION_STEP_KINDS as readonly string[]).some((kind) => kind in step) && !("observe" in step) && !("trace" in step)) checkStepNames([step as ActionStep], at, `run ${path}`, index + 1);
206
+ });
127
207
  const files: string[] = [];
128
208
  const written: Written[] = [];
129
209
  const write = (name: string, bytes: Uint8Array) => {
@@ -185,7 +265,7 @@ export async function runFile(path: string, out: string): Promise<{ output: Reco
185
265
  // More than one read gives goes to a file: results.json stays small.
186
266
  if (size <= MAX_MEMORY_READ) memory[spec.address] = bytes.toString("hex");
187
267
  else {
188
- const file = `${step.observe}-${spec.address.replace(/^\$/, "")}.bin`;
268
+ const file = memoryFile(step.observe, spec.address);
189
269
  write(file, bytes);
190
270
  memory[spec.address] = { file };
191
271
  }
@@ -7,8 +7,9 @@ import { isRefusal, Refusal } from "#src/errors.ts";
7
7
  import { c64AddressOf, formatC64Address, formatSprite, formatVicii, parseC64Address } from "#src/c64.ts";
8
8
  import { MAX_BASELINES, MAX_MASK_RECTANGLES, maskCoversFrame, WireFailure } from "#src/host-client/tools.ts";
9
9
  import { LOAD_LIMIT_FRAMES, RUN_LIMIT_FRAMES, runProgram } from "#src/host-client/autostart.ts";
10
- import { ACTION_STEP_KINDS, checkActionStep, checkLocation, isInteger, isObject, runActionStep, type ActionStep, type ActionStepKind, type Location } from "#src/host-client/steps.ts";
10
+ import { ACTION_STEP_KINDS, checkActionStep, checkLocation, isInteger, isObject, refuseOldStep, renamedField, runActionStep, VICE_STEP_KINDS, type ActionStep, type ActionStepKind, type Location } from "#src/host-client/steps.ts";
11
11
  import { applyEntryState, parseEntryState } from "#src/host-client/entry-state.ts";
12
+ import { readPrg } from "#src/host-client/image.ts";
12
13
  import { readProjectFile, writeReported, type Written } from "#src/host-client/transfer.ts";
13
14
  import { readMemoryInParts, type ViceSessionClient } from "#src/host-client/vice-session.ts";
14
15
  import { openForRead } from "#src/knowledge/database.ts";
@@ -90,6 +91,8 @@ export interface ScenarioResult {
90
91
  }
91
92
 
92
93
  const STEP_KINDS = ["reset", "load", "autostart", "snapshot", "normalize", ...ACTION_STEP_KINDS, "observe"];
94
+ /** The step kinds that a scenario runs: press is for the replay core only. */
95
+ const SHOWN_KINDS = ["reset", "load", "autostart", "snapshot", "normalize", ...VICE_STEP_KINDS, "observe"];
93
96
 
94
97
  /** The keys each kind of step can have. */
95
98
  const STEP_KEYS: Record<string, readonly string[]> = {
@@ -128,8 +131,10 @@ export function readScenario(path: string): Scenario {
128
131
  if (!isObject(step)) return fail(`${at} must be an object`);
129
132
  // An observe step names what it observes with "memory" and "registers" too.
130
133
  const kinds = "observe" in step ? ["observe"] : Object.keys(step).filter((key) => STEP_KINDS.includes(key));
131
- if (kinds.length !== 1) return fail(`${at} must have exactly one of ${STEP_KINDS.join(", ")}`);
134
+ if (kinds.length > 1) return fail(`${at} has ${kinds.length} step kinds: ${kinds.join(", ")}. Give each one its own step`);
135
+ if (kinds.length !== 1) return fail(`${at} must have exactly one of ${SHOWN_KINDS.join(", ")}`);
132
136
  const kind = kinds[0]!;
137
+ refuseOldStep(kind, step, at, fail);
133
138
  const allowed = [...(STEP_KEYS[kind] ?? [kind]), ...(kind === "observe" ? [] : ["side"])];
134
139
  for (const key of Object.keys(step)) if (!allowed.includes(key)) fail(`${at} has the key ${key}, which a ${kind} step does not have`);
135
140
  if (step.side !== undefined && !(SIDES as unknown[]).includes(step.side)) fail(`${at}: side must be original or rebuild`);
@@ -180,12 +185,15 @@ function checkObserve(step: Record<string, unknown>, at: string, fail: (message:
180
185
  checkLocation(item, `${at}: memory`, fail);
181
186
  continue;
182
187
  }
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`);
188
+ const itemFields = ["address", "size", "view", "tolerance", "exclude"];
189
+ if (!isObject(item)) return fail(`${at}: memory must be a list of names, $ addresses or objects with address`);
190
+ for (const key of Object.keys(item)) if (!itemFields.includes(key)) fail(`${at}: a memory object has the field ${key}. ${renamedField(key, itemFields) ?? `It has ${itemFields.join(", ")}, and nothing else`}`);
184
191
  if (item.view !== undefined && item.view !== "cpu" && item.view !== "ram") fail(`${at}: memory.view must be cpu or ram`);
185
192
  if (item.exclude !== undefined) {
186
193
  if (!Array.isArray(item.exclude) || item.exclude.length === 0) fail(`${at}: memory.exclude must be a list of objects with address and reason`);
187
194
  for (const excluded of item.exclude as unknown[]) {
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`);
195
+ if (!isObject(excluded)) return fail(`${at}: memory.exclude must be a list of objects with address and reason`);
196
+ for (const key of Object.keys(excluded)) if (key !== "address" && key !== "reason") fail(`${at}: an exclude has the field ${key}. ${renamedField(key, ["address", "reason"]) ?? "It has address and reason, and nothing else"}`);
189
197
  checkLocation(excluded.address, `${at}: memory.exclude.address`, fail);
190
198
  if (typeof excluded.reason !== "string" || excluded.reason.trim() === "") fail(`${at}: an exclude needs a reason: why this byte may differ`);
191
199
  }
@@ -276,6 +284,33 @@ export function namesOf(source: string, kinds: "code" | "all" = "all"): Map<stri
276
284
  return names;
277
285
  }
278
286
 
287
+ /** The remedy for a scenario program that is no PRG. */
288
+ const PROGRAM_REMEDY = "A scenario program is a PRG. Get the PRG out of the disk, tape or cartridge with disk.ts read (or disk.ts crt) of the c64-disk skill";
289
+
290
+ /**
291
+ * Checks each file that the scenario names before an emulator starts: the
292
+ * program and the symbols of each side, and each snapshot file. A missing
293
+ * file is not-found; a program that is no PRG and a symbols file with no
294
+ * names are invalid-input. Gives the names of each side.
295
+ */
296
+ export function scenarioNames(scenario: Scenario): Map<Side, Map<string, number>> {
297
+ const names = new Map<Side, Map<string, number>>();
298
+ const named = <T>(field: string, read: () => T): T => {
299
+ try {
300
+ return read();
301
+ } catch (error) {
302
+ if (isRefusal(error)) throw new Refusal(error.code, `${field}: ${error.message}`);
303
+ throw error;
304
+ }
305
+ };
306
+ for (const side of SIDES) {
307
+ named(`${side}.program`, () => readPrg(scenario[side].program, PROGRAM_REMEDY));
308
+ names.set(side, named(`${side}.symbols`, () => namesOf(scenario[side].symbols)));
309
+ }
310
+ for (const step of scenario.steps) if ("snapshot" in step) readProjectFile(step.snapshot);
311
+ return names;
312
+ }
313
+
279
314
  /** A failure that makes the comparison impossible: the result is INCONCLUSIVE. */
280
315
  class Inconclusive extends Error {}
281
316
 
@@ -466,12 +501,14 @@ function writeFailFiles(out: string, original: Map<string, Observation>, rebuild
466
501
  }
467
502
 
468
503
  /** Runs the scenario on the original, then on the rebuild, in one VICE session. With out, a FAIL leaves files there. */
469
- export async function runScenario(client: ViceSessionClient, scenario: Scenario, options: { out?: string } = {}): Promise<ScenarioResult> {
504
+ export async function runScenario(client: ViceSessionClient, scenario: Scenario, options: { out?: string; names?: Map<Side, Map<string, number>> } = {}): Promise<ScenarioResult> {
470
505
  const checkpoints = scenario.steps.flatMap((step) => ("observe" in step ? [step.observe] : []));
471
506
  const observed = new Map<Side, Map<string, Observation>>();
507
+ // The names of both sides before either side runs: a bad symbols file is an error, not a result.
508
+ const names = options.names ?? new Map(SIDES.map((side) => [side, namesOf(scenario[side].symbols)]));
472
509
  for (const side of SIDES) {
473
510
  try {
474
- observed.set(side, await runSide(client, scenario, side, namesOf(scenario[side].symbols), options.out !== undefined));
511
+ observed.set(side, await runSide(client, scenario, side, names.get(side)!, options.out !== undefined));
475
512
  } catch (error) {
476
513
  if (error instanceof Inconclusive) return { result: "INCONCLUSIVE", scenario: scenario.name, reason: error.message, checkpoints, differences: [] };
477
514
  if (error instanceof WireFailure) {