@henols/c64-re-tools 2.1.0-rc.2 → 2.1.1

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 (58) hide show
  1. package/package.json +1 -1
  2. package/skills/c64-assembler/SKILL.md +1 -0
  3. package/skills/c64-assembler/scripts/assembler.ts +10 -0
  4. package/skills/c64-disk/SKILL.md +2 -2
  5. package/skills/c64-disk/scripts/disk.ts +24 -10
  6. package/skills/c64-disk/scripts/lib/containers.ts +9 -9
  7. package/skills/c64-docs/scripts/lib/links.ts +3 -3
  8. package/skills/c64-docs/scripts/lib/reference.ts +2 -2
  9. package/skills/c64-emulator/SKILL.md +8 -2
  10. package/skills/c64-knowledge/SKILL.md +3 -1
  11. package/skills/c64-knowledge/scripts/knowledge.ts +59 -43
  12. package/skills/c64-memory-map/SKILL.md +1 -1
  13. package/skills/c64-memory-map/scripts/memory-map.ts +3 -2
  14. package/skills/c64-provenance/SKILL.md +5 -4
  15. package/skills/c64-provenance/scripts/lib/library.ts +18 -6
  16. package/skills/c64-provenance/scripts/lib/separate.ts +45 -6
  17. package/skills/c64-provenance/scripts/provenance.ts +5 -4
  18. package/skills/c64-source/SKILL.md +2 -0
  19. package/skills/c64-source/scripts/lib/source.ts +2 -2
  20. package/skills/c64-source/scripts/source.ts +10 -6
  21. package/skills/c64-static-analysis/scripts/lib/idioms.ts +6 -2
  22. package/skills/c64-static-analysis/scripts/static-analysis.ts +4 -3
  23. package/skills/c64-testing/SKILL.md +11 -1
  24. package/skills/c64-testing/references/recording-format.md +2 -0
  25. package/skills/c64-testing/references/scenario-format.md +6 -0
  26. package/skills/c64-testing/scripts/lib/diff-files.ts +63 -13
  27. package/skills/c64-testing/scripts/lib/diff.ts +3 -3
  28. package/skills/c64-testing/scripts/lib/recording.ts +47 -12
  29. package/skills/c64-testing/scripts/lib/replay.ts +11 -0
  30. package/skills/c64-testing/scripts/lib/runs.ts +64 -4
  31. package/skills/c64-testing/scripts/lib/scenario.ts +43 -6
  32. package/skills/c64-testing/scripts/testing.ts +15 -2
  33. package/skills/c64-unpacker/SKILL.md +9 -5
  34. package/skills/c64-unpacker/scripts/lib/evidence.ts +4 -5
  35. package/skills/c64-unpacker/scripts/lib/loads.ts +3 -2
  36. package/skills/c64-unpacker/scripts/unpacker.ts +75 -25
  37. package/src/host/vice/session.ts +99 -10
  38. package/src/host-client/image.ts +32 -0
  39. package/src/host-client/steps.ts +106 -6
  40. package/src/host-client/tools.ts +1 -1
  41. package/src/host-client/transfer.ts +35 -8
  42. package/src/knowledge/expression.ts +1 -1
  43. package/src/knowledge/history.ts +19 -2
  44. package/src/knowledge/names.ts +2 -1
  45. package/src/knowledge/program.ts +1 -16
  46. package/src/knowledge/read.ts +43 -0
  47. package/src/knowledge/schema.ts +3 -2
  48. package/src/knowledge/text.ts +1 -0
  49. package/src/knowledge/write.ts +40 -6
  50. package/src/mcp/output.ts +5 -0
  51. package/src/mcp/server.ts +19 -4
  52. package/src/mcp/tools/debug.ts +7 -6
  53. package/src/mcp/tools/execution.ts +18 -5
  54. package/src/mcp/tools/media.ts +14 -2
  55. package/src/mcp/tools/memory.ts +7 -3
  56. package/src/mcp/tools/video.ts +5 -3
  57. package/src/project.ts +1 -1
  58. package/src/protocol/vice.ts +4 -1
@@ -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;
@@ -153,13 +153,20 @@ export function parseStateItem(text: string): StateItem {
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";
@@ -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
 
@@ -6,7 +6,8 @@
6
6
  import { formatC64Address, formatSprite, formatVicii, parseC64Address } from "#src/c64.ts";
7
7
  import { runProgram } from "#src/host-client/autostart.ts";
8
8
  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";
9
+ import { ACTION_STEP_KINDS, checkActionStep, isInteger, isObject, refuseOldStep, renamedField, runActionStep, VICE_STEP_KINDS, type ActionStep, type ActionStepKind } from "#src/host-client/steps.ts";
10
+ import { readPrg } from "#src/host-client/image.ts";
10
11
  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
12
  import { readProjectFile, writeReported, type Written } from "#src/host-client/transfer.ts";
12
13
  import { readMemoryInParts, ViceSessionClient } from "#src/host-client/vice-session.ts";
@@ -35,6 +36,40 @@ const TRACE_KEYS = ["trace", "points", "frames", "maxHits"];
35
36
  const OBSERVE_KEYS = ["observe", "memory", "registers", "vicii", "sprites"];
36
37
  /** A name of a file that a step writes: letters, digits, dots, underscores or hyphens. */
37
38
  const NAME = /^[A-Za-z0-9._-]{1,64}$/;
39
+ /** The file of the observations, which every run writes. */
40
+ const RESULTS = "results.json";
41
+
42
+ /** The file name of an observed memory item that goes into its own file: more than one read gives. */
43
+ const memoryFile = (observe: string, address: string) => `${observe}-${address.replace(/^\$/, "")}.bin`;
44
+
45
+ /**
46
+ * The files that each step of a run writes into --out, by step. Two steps
47
+ * that write one name (also in another case: some file systems do not tell
48
+ * them apart) are refused: the second would overwrite the first.
49
+ */
50
+ function checkFileNames(steps: RunStep[], fail: (message: string) => never): void {
51
+ const writers = new Map<string, string>([[RESULTS, `the run itself (${RESULTS})`]]);
52
+ steps.forEach((step, index) => {
53
+ const named: [string, string[]] =
54
+ "save" in step
55
+ ? ["save", [`${step.save}.vsf`]]
56
+ : "screen" in step
57
+ ? ["screen", [`${step.screen}.png`]]
58
+ : "coverage" in step
59
+ ? ["coverage", [`${step.coverage}.bin`]]
60
+ : "trace" in step
61
+ ? ["trace", [`${step.trace}.json`]]
62
+ : "observe" in step
63
+ ? ["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)] : []))]
64
+ : ["", []];
65
+ const [kind, files] = named;
66
+ for (const file of files) {
67
+ const other = writers.get(file.toLowerCase());
68
+ if (other !== undefined) fail(`step ${index + 1} (${kind}) writes ${file}, and ${other} writes that file too. Give one of them another name`);
69
+ writers.set(file.toLowerCase(), `step ${index + 1} (${kind})`);
70
+ }
71
+ });
72
+ }
38
73
 
39
74
  function readRun(path: string): Run {
40
75
  let value: unknown;
@@ -60,8 +95,10 @@ function readRun(path: string): Run {
60
95
  const at = `step ${index + 1}`;
61
96
  if (!isObject(step)) return fail(`${at} must be an object`);
62
97
  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(", ")}`);
98
+ if (kinds.length > 1) return fail(`${at} has ${kinds.length} step kinds: ${kinds.join(", ")}. Give each one its own step`);
99
+ if (kinds.length !== 1) return fail(`${at} must have exactly one of ${[...VICE_STEP_KINDS, ...OWN_KINDS].join(", ")}`);
64
100
  const kind = kinds[0]!;
101
+ refuseOldStep(kind, step, at, fail);
65
102
  const allowed = kind === "observe" ? OBSERVE_KEYS : kind === "trace" ? TRACE_KEYS : [kind];
66
103
  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
104
  if ((ACTION_STEP_KINDS as readonly string[]).includes(kind)) {
@@ -81,7 +118,8 @@ function readRun(path: string): Run {
81
118
  const points = step.points;
82
119
  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
120
  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`);
121
+ if (!isObject(point)) return fail(`${at}: a point is an object with kind, start and end`);
122
+ 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
123
  if (!(TRACE_KINDS as readonly unknown[]).includes(point.kind)) fail(`${at}: a point kind is exec, read or write`);
86
124
  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
125
  }
@@ -92,6 +130,16 @@ function readRun(path: string): Run {
92
130
  if (kind !== "observe") return;
93
131
  if (labels.has(name as string)) fail(`${at} needs a new observation name`);
94
132
  labels.add(name as string);
133
+ const itemFields = ["address", "size", "view"];
134
+ if (Array.isArray(step.memory)) {
135
+ for (const item of step.memory as unknown[]) {
136
+ if (!isObject(item)) continue;
137
+ for (const key of Object.keys(item)) {
138
+ const renamed = renamedField(key, itemFields);
139
+ if (renamed !== undefined && !itemFields.includes(key)) fail(`${at}: a memory object has the field ${key}. ${renamed}`);
140
+ }
141
+ }
142
+ }
95
143
  const memoryItem = (item: unknown) =>
96
144
  typeof item === "string" ||
97
145
  (isObject(item) &&
@@ -106,9 +154,20 @@ function readRun(path: string): Run {
106
154
  if (step.vicii !== undefined && typeof step.vicii !== "boolean") fail(`${at}: vicii must be true or false`);
107
155
  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
156
  });
157
+ checkFileNames(value.steps as RunStep[], fail);
109
158
  return value as unknown as Run;
110
159
  }
111
160
 
161
+ /** The remedy for a start program that is no PRG. */
162
+ 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";
163
+
164
+ /** Checks each file that the run reads before the emulator starts: the start program or snapshot, and each snapshot step. */
165
+ function checkRunFiles(run: Run): void {
166
+ if ("program" in run.start) readPrg(run.start.program, PROGRAM_REMEDY);
167
+ else readProjectFile(run.start.snapshot);
168
+ for (const step of run.steps) if ("snapshot" in step) readProjectFile(step.snapshot);
169
+ }
170
+
112
171
  /** A trace hit with $ addresses, as a person reads it. */
113
172
  function showHit(hit: TraceHit) {
114
173
  return { ...hit, pc: formatC64Address(hit.pc), ...(hit.address === undefined ? {} : { address: formatC64Address(hit.address) }) };
@@ -117,6 +176,7 @@ function showHit(hit: TraceHit) {
117
176
  /** Runs the run file and writes its files into `out`. failed: a step did not run. */
118
177
  export async function runFile(path: string, out: string): Promise<{ output: Record<string, unknown>; failed: boolean }> {
119
178
  const run = readRun(path);
179
+ checkRunFiles(run);
120
180
  const symbols = namesOf(run.symbols ?? "none");
121
181
  const at = (location: string): number => {
122
182
  if (location.startsWith("$")) return parseC64Address(location);
@@ -185,7 +245,7 @@ export async function runFile(path: string, out: string): Promise<{ output: Reco
185
245
  // More than one read gives goes to a file: results.json stays small.
186
246
  if (size <= MAX_MEMORY_READ) memory[spec.address] = bytes.toString("hex");
187
247
  else {
188
- const file = `${step.observe}-${spec.address.replace(/^\$/, "")}.bin`;
248
+ const file = memoryFile(step.observe, spec.address);
189
249
  write(file, bytes);
190
250
  memory[spec.address] = { file };
191
251
  }
@@ -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) {
@@ -5,7 +5,9 @@
5
5
  // checklist gives the Markdown list for a human tester.
6
6
 
7
7
  import { COVERAGE } from "#src/c64.ts";
8
+ import { isRefusal, Refusal } from "#src/errors.ts";
8
9
  import { WireFailure } from "#src/host-client/tools.ts";
10
+ import { readPrg } from "#src/host-client/image.ts";
9
11
  import { writeReported } from "#src/host-client/transfer.ts";
10
12
  import { ViceSessionClient } from "#src/host-client/vice-session.ts";
11
13
  import { C64RunSession, PAL_FRAME_CYCLES } from "#src/native/c64run.ts";
@@ -18,7 +20,7 @@ import { play } from "./lib/play.ts";
18
20
  import { readRecordFile, record, RECORDING_FILES } from "./lib/recording.ts";
19
21
  import { replay } from "./lib/replay.ts";
20
22
  import { runFile } from "./lib/runs.ts";
21
- import { readScenario, runScenario, type Scenario, type ScenarioResult } from "./lib/scenario.ts";
23
+ import { namesOf, readScenario, runScenario, scenarioNames, type Scenario, type ScenarioResult } from "./lib/scenario.ts";
22
24
 
23
25
  /** The default port of the --play page: next to the Host Runtime's 6464. */
24
26
  const DEFAULT_HTTP_PORT = 6465;
@@ -35,6 +37,8 @@ const ALWAYS = [
35
37
  async function test(call: Call): Promise<ScenarioResult> {
36
38
  const scenario = readScenario(call.subject(0));
37
39
  const out = call.string("out");
40
+ // Every file of the scenario is checked before the emulator starts.
41
+ const names = scenarioNames(scenario);
38
42
  let client: ViceSessionClient;
39
43
  try {
40
44
  client = await ViceSessionClient.open({ videoStandard: scenario.videoStandard ?? "pal" });
@@ -43,7 +47,7 @@ async function test(call: Call): Promise<ScenarioResult> {
43
47
  return { result: "INCONCLUSIVE", scenario: scenario.name, reason: `no emulator: ${error.message}`, checkpoints: [], differences: [] };
44
48
  }
45
49
  try {
46
- return await runScenario(client, scenario, out === undefined ? {} : { out });
50
+ return await runScenario(client, scenario, { names, ...(out === undefined ? {} : { out }) });
47
51
  } finally {
48
52
  await client.close();
49
53
  }
@@ -56,6 +60,15 @@ async function recordCommand(call: Call): Promise<Record<string, unknown>> {
56
60
  const httpPort = call.count("http-port") ?? DEFAULT_HTTP_PORT;
57
61
  const out = call.string("out")!;
58
62
  const file = readRecordFile(call.subject(0));
63
+ // The program and the symbols are checked before the replay core starts.
64
+ for (const [field, check] of [["program", () => readPrg(file.program)], ["symbols", () => namesOf(file.symbols)]] as const) {
65
+ try {
66
+ check();
67
+ } catch (error) {
68
+ if (isRefusal(error)) throw new Refusal(error.code, `record file ${call.subject(0)}: ${field}: ${error.message}`);
69
+ throw error;
70
+ }
71
+ }
59
72
  const context = localToolContext();
60
73
  const core = await C64RunSession.open(context);
61
74
  const workspace = Workspace.create(context.supervisor, "c64-re-tools-record-");
@@ -13,7 +13,7 @@ This skill does not change project knowledge.
13
13
 
14
14
  ## Inputs
15
15
 
16
- - The program: a PRG file, relative to the project folder.
16
+ - The program: a PRG file, relative to the project folder. `inspect`, `trace`, `stages` and `capture` refuse a disk, tape or cartridge image. For such an image, use `loads`, or get the PRG with `disk.ts read` of the c64-disk skill.
17
17
  - For a capture: the address where the unpacked program starts, and the directory for the result.
18
18
 
19
19
  ## Tools / execution path
@@ -32,17 +32,18 @@ node <skill>/scripts/unpacker.ts loads original/game.d64 --steps .c64-re-tools/l
32
32
  |---|---|
33
33
  | `--until '$c000'` | `capture`: the address where the unpacked program starts. The capture stops there after the steps. To capture a later arrival at the address, add a `runUntil` step to an address after the first arrival. |
34
34
  | `--out .c64-re-tools/local/capture` | `capture` and `loads`: the folder for the result, relative to the project folder. |
35
- | `--range '$c000-$cfff'` | `capture` only: write this range as the one segment. Without it, the segments are the blocks that the stages wrote. |
36
- | `--exclude '$a000-$a9ff'` | `capture` only: remove this range from the segments. You can give it more than once. `image.json` lists the ranges in `excluded`. The entry must not be in the range. |
35
+ | `--range '$c000-$cfff'` | `capture` only: write this range as the one segment. Without it, the segments are the blocks that the stages wrote. The range must hold the `--until` address. |
36
+ | `--exclude '$a000-$a9ff'` | `capture` only: remove this range from the segments. You can give it more than once. `image.json` lists the ranges in `excluded`. The range must not hold the `--until` address. |
37
37
  | `--timeout-frames 3000` | `capture` only: how many frames the program can run before the capture stops (default 3000). |
38
38
  | `--frames 3000` | `trace` and `stages`: how many frames the program runs (default 3000). |
39
39
  | `--entry '$c000'` | `trace`, `stages` and `capture`: start the program at this address. Without it, the script types RUN. A file that is not a BASIC program at `$0801` needs it. |
40
40
  | `--steps input.json` | Input for the program, right after it starts: a JSON list of steps. Use it when an intro waits for a key or a menu waits for a choice. |
41
+ | `--max-listed 50` | `trace` only: the most ranges that `writtenThenExecuted` lists (default 50). |
41
42
  | `--drive` | `loads` only: each capture also writes the 2 KiB RAM of the 1541 drive as `drive8.prg`. |
42
43
  | `--min-block 64` | `loads` only: the smallest block of changed memory that becomes a segment (default 64). The result counts smaller changes as `smallChanges`. |
43
44
  | `--min-stage 64` | `stages` and `capture`: the smallest block of written memory that starts a new stage when the CPU executes it (default 64). |
44
45
 
45
- The capture reads RAM, also the RAM under the ROMs and the I/O area. The script refuses an option that the command does not use.
46
+ The capture reads RAM, also the RAM under the ROMs and the I/O area. The script refuses an option that the command does not use. It refuses a wrong `--range` or `--exclude` before the emulator starts.
46
47
 
47
48
  A steps file is a JSON list of steps. Each step has the input of the MCP tool of the same action. An address in a step can be a name. For example, wait 50 frames and type a space:
48
49
 
@@ -59,6 +60,8 @@ A steps file is a JSON list of steps. Each step has the input of the MCP tool of
59
60
 
60
61
  A program that reads the keyboard matrix does not see the keys of a `type` step. These scripts refuse a `press` step. A wait for any key that reads all keyboard columns can end without a key.
61
62
 
63
+ The scripts refuse a step in the form of version 2.0, for example `{ "runUntil": { "at": "main_loop" }, "timeoutFrames": 300 }`. The message names the old field and gives the step to write.
64
+
62
65
  ### Calling the script
63
66
 
64
67
  - Call a script as `node <skill>/scripts/<script>.ts <command> [subjects] [options]`. The command comes first.
@@ -92,7 +95,7 @@ A program that reads the keyboard matrix does not see the keys of a `type` step.
92
95
  - `strings`: readable text near the start of the code.
93
96
  - `start`: the code at the SYS address. `jumpsOutside` and `writesOutside` give an address outside the loaded program, where the code jumps or writes.
94
97
  - `decodeLoops`: EOR instructions in loops that store with an index. This is usually decryption or decoding.
95
- 2. Run `trace`. It gives the evidence from the run. Read `packing`:
98
+ 2. Run `trace`. It gives the evidence from the run. `writtenThenExecuted` lists the ranges that the program wrote and then executed. Read `packing`:
96
99
  - `likely`: the program wrote code and then executed it. It unpacked, decrypted or moved code. `runningRange` is the written code that runs at the end of the trace. When the trace ends in ROM code, for example in an interrupt, it is the written code that runs next. Its `start` is code that runs after the unpacking. It is not always the entry point of the program. When no written code runs in 50 more frames, the result has no `runningRange` and no `suggestedUntil`.
97
100
  - `unclear`: the program executed a small quantity of code that it wrote. This can be code that changes itself.
98
101
  - `unlikely`: the program executed no code that it wrote. If the program waits for a key or a joystick, it can move code after that input. Then use the c64-emulator skill.
@@ -152,6 +155,7 @@ For `loads`: `captures`, with the name, the PC, the segments, the files and the
152
155
  ## Failure and conflicts
153
156
 
154
157
  - `traced: false` or `followed: false`: the emulator did not load the program into memory in the time limit. The reason gives the load address. Make sure that the file is a PRG that the C64 can load.
158
+ - `traced: false` or `followed: false` with `stopReason`: the CPU stopped before the frames ran, for example at a JAM instruction. `pc` gives the address. Make sure that `--entry` is the start of the code that the file holds at that time, and that the steps are correct.
155
159
  - `captured: false`: the program did not get to the address in the time limit. The reason tells where it stopped. Find a better address with the c64-emulator skill, or give more frames. If it stopped in a key wait, give `--steps` with a `type` step.
156
160
  - A step did not reach its target: the reason names the step. Change the step or give it more frames.
157
161
  - `loaded: false` and no capture has a segment: the program did not load a part, or it did not start. `keyWait: true` on a capture tells that the C64 waited at READY. or at an INPUT statement. For a tape image, read `emulatorFlaw`. A known VICE flaw can stop a tape load. Tell the user to install the latest VICE.
@@ -1,7 +1,7 @@
1
1
  // Local evidence of packing for the c64-unpacker script: where BASIC hands
2
2
  // over and how compressed the bytes look. It never names a packer.
3
3
 
4
- import { basicLineText, findHandoffs, formatC64Address, instructionLength, parseTokenized } from "#src/c64.ts";
4
+ import { basicLineText, findHandoffs, formatC64Address, instructionLength, parseTokenized, prgRange } from "#src/c64.ts";
5
5
  import { Refusal } from "#src/errors.ts";
6
6
 
7
7
  /** A 256-byte block above this many bits per byte looks compressed. */
@@ -111,9 +111,8 @@ function startSignals(program: Uint8Array, load: number, end: number, sys: numbe
111
111
  }
112
112
 
113
113
  export function inspect(program: Uint8Array): PackingEvidence {
114
- if (program.length < 3) throw new Refusal("invalid-input", "the file is too short for a PRG");
115
- const load = program[0]! | (program[1]! << 8);
116
- const end = load + program.length - 3;
114
+ // A PRG ends at $ffff or before: a longer file (a disk image) is refused.
115
+ const { start: load, end } = prgRange(program);
117
116
  const basic = basicStart(program);
118
117
  const stub = basic?.sys;
119
118
  // Without BASIC, all bytes count; with it, only the bytes after the BASIC text.
@@ -132,7 +131,7 @@ export function inspect(program: Uint8Array): PackingEvidence {
132
131
  const packing = byEntropy === "unlikely" && startMoves ? "unclear" : byEntropy;
133
132
  const sorted = [...blocks].sort((a, b) => a - b);
134
133
  return {
135
- loadRange: { start: formatC64Address(load), end: formatC64Address(Math.min(end, 0xffff)) },
134
+ loadRange: { start: formatC64Address(load), end: formatC64Address(end) },
136
135
  bytes: program.length - 2,
137
136
  basicStart: stub === undefined ? null : stub.address === undefined ? { line: stub.line, sys: null, expression: stub.expression! } : { line: stub.line, sys: formatC64Address(stub.address) },
138
137
  entropy: {
@@ -7,7 +7,7 @@
7
7
  import { COVERAGE, formatC64Address } from "#src/c64.ts";
8
8
  import { autostartLoaded, LOAD_LIMIT_FRAMES, RUN_LIMIT_FRAMES, typeRun } from "#src/host-client/autostart.ts";
9
9
  import { readCoverage } from "#src/host-client/coverage.ts";
10
- import { ACTION_STEP_KINDS, checkActionStep, isObject, runActionStep, type ActionStep, type ActionStepKind } from "#src/host-client/steps.ts";
10
+ import { ACTION_STEP_KINDS, checkActionStep, isObject, runActionStep, VICE_STEP_KINDS, type ActionStep, type ActionStepKind } from "#src/host-client/steps.ts";
11
11
  import { knowledgeAt } from "#src/knowledge/names.ts";
12
12
  import { WireFailure } from "#src/host-client/tools.ts";
13
13
  import { readProjectFile, writeReported, type Written } from "#src/host-client/transfer.ts";
@@ -54,7 +54,8 @@ export function readLoadSteps(path: string): LoadStep[] {
54
54
  return;
55
55
  }
56
56
  const kinds = Object.keys(step).filter((key) => (ACTION_STEP_KINDS as readonly string[]).includes(key));
57
- if (kinds.length !== 1) return fail(`${at} must have exactly one of ${[...ACTION_STEP_KINDS, "capture"].join(", ")}`);
57
+ if (kinds.length > 1) return fail(`${at} has ${kinds.length} step kinds: ${kinds.join(", ")}. Give each one its own step`);
58
+ if (kinds.length !== 1) return fail(`${at} must have exactly one of ${[...VICE_STEP_KINDS, "capture"].join(", ")}`);
58
59
  // The step with its defaults filled in, as the shared schema of the action steps gives it.
59
60
  (value as unknown[])[index] = checkActionStep(kinds[0] as ActionStepKind, step, at, fail);
60
61
  });