@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
@@ -11,14 +11,14 @@
11
11
  import { createHash } from "node:crypto";
12
12
  import { readFileSync, writeFileSync } from "node:fs";
13
13
 
14
+ import { isRefusal, Refusal } from "#src/errors.ts";
14
15
  import { formatC64Address, parseC64Address } from "#src/c64.ts";
15
- import { checkActionStep, isInteger, isObject, type ActionStep, type Location } from "#src/host-client/steps.ts";
16
- import { readProjectFile } from "#src/host-client/transfer.ts";
17
- import { openForRead } from "#src/knowledge/database.ts";
18
- import { listSymbols } from "#src/knowledge/read.ts";
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";
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";
21
- import { loadSymbols, ScenarioError, type Outcome } from "./scenario.ts";
21
+ import { namesOf, type Outcome } from "./scenario.ts";
22
22
 
23
23
  export const RECORDING_FORMAT = 1;
24
24
  /**
@@ -34,9 +34,7 @@ export const DEFAULT_INPUTS = ["$dc00", "$dc01", "$d419", "$d41a", "$d41b", "$d4
34
34
  export function inputsArgument(inputs: string[]): string {
35
35
  return inputs.map((input) => input.replace(/\$/g, "")).join(",");
36
36
  }
37
- /** Frames from power-on to READY, before the program goes into memory. */
38
- export const BOOT_FRAMES = 150;
39
- /** Frames that a typed key is held, and then released. */
37
+ /** Frames that a pressed key is held, and then released. */
40
38
  const KEY_FRAMES = 3;
41
39
  const MAX_STATE_ITEMS = 16;
42
40
  const MAX_STATE_BYTES = 4096;
@@ -44,7 +42,7 @@ const MAX_STATE_BYTES = 4096;
44
42
  const REBUILD_TIME_FACTOR = 2;
45
43
 
46
44
  export interface StateItem {
47
- at: Location;
45
+ address: Location;
48
46
  size: number;
49
47
  }
50
48
 
@@ -73,10 +71,16 @@ export interface Comparison {
73
71
  pin?: StateItem[];
74
72
  }
75
73
 
76
- /** A step that is not input: it runs at the same point of the program on each replay. */
74
+ /** RESTORE: it starts an NMI and is no read, so the recording keeps it as an event. */
75
+ export interface RestoreAction {
76
+ restore: true;
77
+ }
78
+
79
+ /** A step that is not input, or a RESTORE of the play: it runs at the same point of the program on each replay. */
77
80
  export interface RecordedEvent {
78
- step: number;
79
- action: ActionStep;
81
+ /** The step of the record file; none for a RESTORE of the play. */
82
+ step?: number;
83
+ action: ActionStep | RestoreAction;
80
84
  /**
81
85
  * The point: the n-th read at the busiest program site, or a tick when no
82
86
  * program site has read yet. An event that follows another event directly
@@ -111,13 +115,16 @@ export interface Recording {
111
115
 
112
116
  export const RECORDING_FILES = { recording: "recording.json", reads: "reads.txt", coverage: "coverage.bin" } as const;
113
117
 
114
- const STEP_KINDS = ["write", "registers", "joystick", "type", "frames", "runUntil"];
115
- /** Steps that are input or time: a replay does not do them again. */
116
- const INPUT_KINDS = new Set(["joystick", "type", "frames"]);
118
+ /** Steps that are input or time: the replay gives the program the reads that they caused, and does not do them again. */
119
+ const INPUT_KINDS = new Set(["joystick", "press", "frames"]);
120
+ /** The KERNAL keyboard queue: $0277, its length in $c6, at most 10 keys. */
121
+ const QUEUE = 0x0277;
122
+ const QUEUE_LENGTH = 0x00c6;
123
+ const QUEUE_SIZE = 10;
117
124
 
118
125
  const hex = (address: number) => address.toString(16).padStart(4, "0");
119
126
 
120
- /** The keys of the replay core for the characters that a type step can have. */
127
+ /** The keys of the replay core for the characters that a press step can have. */
121
128
  const KEY_CODES = new Map<string, number>([
122
129
  ...[..."ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 +-.:@,*;=/!\"#$%&'()[]<>?"].map((character) => [character, character.charCodeAt(0)] as [string, number]),
123
130
  ["\n", 0x0d],
@@ -127,7 +134,7 @@ const KEY_CODES = new Map<string, number>([
127
134
  export function keyCodes(text: string): number[] {
128
135
  return [...text.replace(/\r\n?/g, "\n")].map((character) => {
129
136
  const code = KEY_CODES.get(character.toUpperCase());
130
- if (code === undefined) throw new ScenarioError(`type cannot press ${JSON.stringify(character)} on the keyboard of the replay core`);
137
+ if (code === undefined) throw new Refusal("invalid-input", `press cannot press ${JSON.stringify(character)} on the keyboard of the replay core`);
131
138
  return code;
132
139
  });
133
140
  }
@@ -142,18 +149,18 @@ export function joystickMask(direction: Extract<ActionStep, { joystick: unknown
142
149
  export function parseStateItem(text: string): StateItem {
143
150
  const match = /^([^:]+)(?::(\d+))?$/.exec(text);
144
151
  const size = Number(match?.[2] ?? 1);
145
- if (match === null || !isInteger(size, 1, MAX_STATE_BYTES)) throw new ScenarioError(`--state ${text}: give a name or a $ address, with :size from 1 to ${MAX_STATE_BYTES}`);
146
- return { at: match[1]!, size };
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}`);
153
+ return { address: match[1]!, size };
147
154
  }
148
155
 
149
156
  export function checkState(value: unknown, fail: (message: string) => never): StateItem[] {
150
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`);
151
158
  return (value as unknown[]).map((item, index) => {
152
- const spec = typeof item === "string" ? { at: item } : item;
153
- if (!isObject(spec) || typeof spec.at !== "string" || Object.keys(spec).some((key) => key !== "at" && key !== "size")) fail(`state item ${index + 1} must be a name, a $ address, or { at, size }`);
159
+ 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 }`);
154
161
  const size = (spec as { size?: unknown }).size ?? 1;
155
162
  if (!isInteger(size, 1, MAX_STATE_BYTES)) fail(`state item ${index + 1}: size must be from 1 to ${MAX_STATE_BYTES}`);
156
- return { at: (spec as { at: string }).at, size: size as number };
163
+ return { address: (spec as { address: string }).address, size: size as number };
157
164
  });
158
165
  }
159
166
 
@@ -166,15 +173,16 @@ function checkInputs(value: unknown, fail: (message: string) => never): string[]
166
173
  return (value as string[]).map((input) => input.toLowerCase());
167
174
  }
168
175
 
169
- /** Reads and checks a record file. Throws ScenarioError naming the first problem. */
176
+ /** Reads and checks a record file. Refuses it with the first problem. */
170
177
  export function readRecordFile(path: string): RecordFile {
171
178
  const fail = (message: string): never => {
172
- throw new ScenarioError(`record file ${path}: ${message}`);
179
+ throw new Refusal("invalid-input", `record file ${path}: ${message}`);
173
180
  };
174
181
  let value: unknown;
175
182
  try {
176
183
  value = JSON.parse(readProjectFile(path).bytes.toString("utf8"));
177
184
  } catch (error) {
185
+ if (isRefusal(error)) throw error;
178
186
  return fail((error as Error).message);
179
187
  }
180
188
  if (!isObject(value)) return fail("it must be a JSON object");
@@ -191,16 +199,11 @@ export function readRecordFile(path: string): RecordFile {
191
199
  const compare = value.checkpoint === undefined ? undefined : { checkpoint: value.checkpoint as string, state: checkState(value.state, fail), ...(pin === undefined ? {} : { pin }) };
192
200
  const inputs = checkInputs(value.inputs, fail);
193
201
  if (!Array.isArray(value.steps) || value.steps.length === 0) fail("steps must be a list with at least one step");
194
- (value.steps as unknown[]).forEach((step, index) => {
202
+ const steps = (value.steps as unknown[]).map((raw, index) => {
195
203
  const at = `step ${index + 1}`;
196
- if (!isObject(step)) return fail(`${at} must be an object`);
197
- const kinds = Object.keys(step).filter((key) => STEP_KINDS.includes(key));
198
- if (kinds.length !== 1) return fail(`${at} must have exactly one of ${STEP_KINDS.join(", ")}`);
199
- const kind = kinds[0]!;
200
- for (const key of Object.keys(step)) if (key !== kind && !(kind === "runUntil" && key === "timeoutFrames")) fail(`${at} has the key ${key}, which a ${kind} step does not have`);
201
- checkActionStep(kind, step, at, fail);
202
- if (kind === "runUntil" && "raster" in (step.runUntil as object)) fail(`${at}: the replay core has no runUntil raster; use at or memory`);
203
- if (kind === "type") keyCodes(step.type as string);
204
+ const step = checkActionStep(stepKind(raw, ACTION_STEP_KINDS, at, fail), raw as Record<string, unknown>, at, fail, { press: true });
205
+ checkCoreStep(step, at, fail);
206
+ return step;
204
207
  });
205
208
  return {
206
209
  name: value.name as string,
@@ -209,10 +212,26 @@ export function readRecordFile(path: string): RecordFile {
209
212
  load: value.load === true,
210
213
  ...(compare === undefined ? {} : { compare }),
211
214
  inputs,
212
- steps: value.steps as ActionStep[],
215
+ steps,
213
216
  };
214
217
  }
215
218
 
219
+ /** 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
+ function checkCoreStep(step: ActionStep, at: string, fail: (message: string) => never): void {
221
+ 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);
223
+ if ("type" in step) {
224
+ const keys = keyboardBytes(step.type);
225
+ 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`);
226
+ }
227
+ if ("runUntil" in step) {
228
+ const { target } = step.runUntil;
229
+ if (target.kind === "raster") fail(`${at}: the replay core has no raster target; use an address or a memory target`);
230
+ else if (target.space !== "c64") fail(`${at}: the replay core has the C64 alone, not ${target.space}`);
231
+ else if (target.kind === "address" && target.condition !== undefined) fail(`${at}: the replay core has no condition at an address target`);
232
+ }
233
+ }
234
+
216
235
  /** Reads and checks recording.json of a recording folder. */
217
236
  export function readRecording(folder: string): Recording {
218
237
  const path = `${folder}/${RECORDING_FILES.recording}`;
@@ -220,12 +239,13 @@ export function readRecording(folder: string): Recording {
220
239
  try {
221
240
  value = JSON.parse(readProjectFile(path).bytes.toString("utf8"));
222
241
  } catch (error) {
223
- throw new ScenarioError(`recording ${path}: ${(error as Error).message}`);
242
+ if (isRefusal(error)) throw error;
243
+ throw new Refusal("invalid-input", `recording ${path}: ${(error as Error).message}`);
224
244
  }
225
- if (!isObject(value) || value.format !== RECORDING_FORMAT) throw new ScenarioError(`recording ${path}: it is not a recording of format ${RECORDING_FORMAT}`);
226
- for (const key of ["name", "program", "sha256", "symbols", "hash"]) if (typeof value[key] !== "string") throw new ScenarioError(`recording ${path}: ${key} is missing`);
227
- for (const key of ["inputs", "events", "sites"]) if (!Array.isArray(value[key])) throw new ScenarioError(`recording ${path}: ${key} is missing`);
228
- if (!isInteger(value.ticks, 1, Number.MAX_SAFE_INTEGER)) throw new ScenarioError(`recording ${path}: ticks is missing`);
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`);
229
249
  return value as unknown as Recording;
230
250
  }
231
251
 
@@ -243,40 +263,12 @@ function sideAddresses(symbols: Map<string, number>, side: string, move: { code:
243
263
  const resolve = (location: Location, kind: "code" | "data") => {
244
264
  if (location.startsWith("$")) return move[kind](parseC64Address(location));
245
265
  const address = symbols.get(location);
246
- if (address === undefined) throw new ScenarioError(`the ${side} side has no symbol ${location}`);
266
+ if (address === undefined) throw new Refusal("invalid-input", `the ${side} side has no symbol ${location}`);
247
267
  return address;
248
268
  };
249
269
  return { code: (location) => resolve(location, "code"), data: (location) => resolve(location, "data") };
250
270
  }
251
271
 
252
- /**
253
- * The symbols of a symbol source that move with the program: with "code",
254
- * knowledge labels and routines, else all knowledge symbols; the address
255
- * symbols of c64-assembler output; or every entry of a name → address map.
256
- * Constants do not move with the code, so they are left out.
257
- */
258
- export function codeLabels(symbols: string, kind: "code" | "all" = "code"): Map<string, number> {
259
- if (symbols === "none") return new Map();
260
- if (symbols === "knowledge") {
261
- const db = openForRead();
262
- try {
263
- return new Map(
264
- listSymbols(db)
265
- .filter((row) => kind === "all" || row.kind === "label" || row.kind === "routine")
266
- .map((row) => [row.name, row.address]),
267
- );
268
- } finally {
269
- db?.close();
270
- }
271
- }
272
- const value = JSON.parse(readProjectFile(symbols).bytes.toString("utf8")) as unknown;
273
- const assembled = (value as { symbols?: Array<{ name: string; kind?: string; address?: string }> }).symbols;
274
- if (Array.isArray(assembled)) {
275
- return new Map(assembled.filter((symbol) => symbol.address !== undefined).map((symbol) => [symbol.name, parseC64Address(symbol.address!)]));
276
- }
277
- return new Map(Object.entries(value as Record<string, string>).map(([name, address]) => [name, parseC64Address(address)]));
278
- }
279
-
280
272
  /** The furthest a site can be from the label before it and still move with that label. */
281
273
  const MAX_LABEL_DISTANCE = 4096;
282
274
 
@@ -311,11 +303,11 @@ export function siteMap(sites: RecordedSite[], from: Map<string, number>, to: Ma
311
303
 
312
304
  /** Power-on and READY, then the program in memory as a LOAD leaves it, and RUN in the keyboard queue. */
313
305
  async function startProgram(core: C64RunSession, prg: Uint8Array, load: boolean): Promise<CoreStatus> {
314
- if (prg.length < 3) throw new ScenarioError("the program is not a PRG file");
315
- await core.status(`run ${BOOT_FRAMES * PAL_FRAME_CYCLES}`);
306
+ if (prg.length < 3) throw new Refusal("invalid-input", "the program is not a PRG file");
307
+ await core.status(`run ${READY_FRAMES * PAL_FRAME_CYCLES}`);
316
308
  const start = prg[0]! | (prg[1]! << 8);
317
309
  const end = start + prg.length - 2;
318
- if (!load && start !== 0x0801) throw new ScenarioError(`the program loads at ${formatC64Address(start)}, not at $0801 with a BASIC start line: give load true and a registers step that starts it`);
310
+ if (!load && start !== 0x0801) throw new Refusal("invalid-input", `the program loads at ${formatC64Address(start)}, not at $0801 with a BASIC start line: give load true and a registers step that starts it`);
319
311
  await core.write(start, prg.subarray(2));
320
312
  // The pointers that LOAD sets: the end of the program and of the variables.
321
313
  for (const pointer of [0x2d, 0x2f, 0x31, 0xae]) await core.write(pointer, Uint8Array.of(end & 0xff, end >> 8));
@@ -354,39 +346,63 @@ async function runToKey(core: C64RunSession, key: RecordedEvent["key"], until: n
354
346
  }
355
347
 
356
348
  /** Does a step that is not input. Gives a reason when a runUntil does not reach its target. */
357
- async function doAction(core: C64RunSession, step: ActionStep, at: SideAddresses): Promise<string | undefined> {
358
- if ("write" in step) {
359
- await core.write(at.data(step.write.at), Uint8Array.from(step.write.bytes));
349
+ /** The status flags in the order of the P register, bit 7 to bit 0; bit 5 is always 1. */
350
+ const FLAG_BITS: Record<string, number> = { n: 0x80, v: 0x40, b: 0x10, d: 0x08, i: 0x04, z: 0x02, c: 0x01 };
351
+
352
+ /** A memory comparison of a memory target. */
353
+ const COMPARE: Record<string, (value: number, wanted: number) => boolean> = {
354
+ eq: (value, wanted) => value === wanted,
355
+ ne: (value, wanted) => value !== wanted,
356
+ lt: (value, wanted) => value < wanted,
357
+ lte: (value, wanted) => value <= wanted,
358
+ gt: (value, wanted) => value > wanted,
359
+ gte: (value, wanted) => value >= wanted,
360
+ };
361
+
362
+ async function doAction(core: C64RunSession, step: ActionStep | RestoreAction, at: SideAddresses): Promise<string | undefined> {
363
+ if ("restore" in step) {
364
+ // The NMI line is low while RESTORE is down: hold it as a press step holds a key.
365
+ const tick = (await core.status("status")).tick;
366
+ await core.command("key ff down");
367
+ await core.status("run " + (tick + KEY_FRAMES * PAL_FRAME_CYCLES));
368
+ await core.command("key ff up");
369
+ } else if ("write" in step) {
370
+ await core.write(at.data(step.write.address), Buffer.from(step.write.data, "hex"));
371
+ } else if ("type" in step) {
372
+ // The KERNAL keyboard queue: the keys after the keys that wait there now.
373
+ const keys = keyboardBytes(step.type) as Uint8Array;
374
+ const waiting = (await core.peek(QUEUE_LENGTH, 1))[0]!;
375
+ if (waiting + keys.length > QUEUE_SIZE) return `the KERNAL keyboard queue holds ${waiting} keys, and ${keys.length} more do not fit in its ${QUEUE_SIZE}`;
376
+ await core.write(QUEUE + waiting, keys);
377
+ await core.write(QUEUE_LENGTH, Uint8Array.of(waiting + keys.length));
360
378
  } else if ("registers" in step) {
361
379
  const now = await core.status("run-sync");
362
- const value = (name: "a" | "x" | "y" | "sp", current: number) => {
363
- const given = step.registers[name];
364
- return given === undefined ? current : typeof given === "number" ? given : at.data(given);
365
- };
366
- const values = [value("a", now.a), value("x", now.x), value("y", now.y), value("sp", now.s), now.p].map((n) => n.toString(16));
380
+ const { pc, flags } = step.registers;
381
+ let p = now.p;
382
+ for (const [name, set] of Object.entries(flags ?? {})) if (set !== undefined) p = set ? p | FLAG_BITS[name]! : p & ~FLAG_BITS[name]!;
383
+ const values = [step.registers.a ?? now.a, step.registers.x ?? now.x, step.registers.y ?? now.y, step.registers.sp ?? now.s, p].map((n) => n.toString(16));
367
384
  await core.status(`regs ${values.join(" ")}`);
368
- const pc = step.registers.pc;
369
- if (pc !== undefined) await core.status(`goto ${hex(typeof pc === "number" ? pc : at.code(pc))}`);
385
+ if (pc !== undefined) await core.status(`goto ${hex(at.code(pc))}`);
370
386
  } else if ("runUntil" in step) {
387
+ const { target, timeoutFrames } = step.runUntil;
371
388
  const start = (await core.status("status")).tick;
372
- const until = start + step.timeoutFrames * PAL_FRAME_CYCLES;
373
- if ("memory" in step.runUntil) {
374
- const { at: where, equals } = step.runUntil.memory;
389
+ const until = start + timeoutFrames * PAL_FRAME_CYCLES;
390
+ if (target.kind === "memory") {
375
391
  for (let tick = start; ; tick += PAL_FRAME_CYCLES) {
376
- if ((await core.peek(at.data(where), 1))[0] === equals) return undefined;
377
- if (tick >= until) return `${where} did not become ${equals} within ${step.timeoutFrames} frames`;
392
+ if (COMPARE[target.operator]!((await core.peek(at.data(target.address), 1))[0]!, target.value)) return undefined;
393
+ if (tick >= until) return `${target.address} did not become ${target.operator} ${target.value} within ${timeoutFrames} frames`;
378
394
  await core.status(`run ${tick + PAL_FRAME_CYCLES}`);
379
395
  }
380
396
  }
381
- const target = (step.runUntil as { at: Location }).at;
382
- const reached = await core.status(`run-pc ${hex(at.code(target))} ${until}`);
383
- if (reached.word !== "reached") return `the program did not reach ${target} within ${step.timeoutFrames} frames`;
397
+ if (target.kind !== "address") return "the replay core has no raster target";
398
+ const reached = await core.status(`run-pc ${hex(at.code(target.address))} ${until}`);
399
+ if (reached.word !== "reached") return `the program did not reach ${target.address} within ${timeoutFrames} frames`;
384
400
  }
385
401
  return undefined;
386
402
  }
387
403
 
388
404
  function rangesArgument(state: StateItem[], at: SideAddresses): string {
389
- return state.map((item) => `${hex(at.data(item.at))}:${item.size}`).join(",");
405
+ return state.map((item) => `${hex(at.data(item.address))}:${item.size}`).join(",");
390
406
  }
391
407
 
392
408
  export interface RecordOutcome {
@@ -397,15 +413,24 @@ export interface RecordOutcome {
397
413
  reason?: string;
398
414
  }
399
415
 
400
- /** Plays the steps of a record file in the replay core and records the session. */
401
- export async function record(core: C64RunSession, file: RecordFile, workspace: Workspace, input?: (core: C64RunSession) => Promise<void>): Promise<RecordOutcome> {
416
+ /**
417
+ * Plays the steps of a record file in the replay core and records the session.
418
+ * `input` runs after the steps, with `restore` for a RESTORE press of the play;
419
+ * a reason that it gives means no recording.
420
+ */
421
+ export async function record(
422
+ core: C64RunSession,
423
+ file: RecordFile,
424
+ workspace: Workspace,
425
+ input?: (core: C64RunSession, restore: () => Promise<string | undefined>) => Promise<string | undefined>,
426
+ ): Promise<RecordOutcome> {
402
427
  const prg = readProjectFile(file.program).bytes;
403
428
  const same = (address: number) => address;
404
- const at = sideAddresses(loadSymbols({ program: file.program, symbols: file.symbols }), "original", { code: same, data: same });
429
+ const at = sideAddresses(namesOf(file.symbols), "original", { code: same, data: same });
405
430
  // A checkpoint and state of the record file must resolve now, so that a replay can use them.
406
431
  if (file.compare !== undefined) {
407
432
  at.code(file.compare.checkpoint);
408
- for (const item of file.compare.state) at.data(item.at);
433
+ for (const item of file.compare.state) at.data(item.address);
409
434
  }
410
435
  const reads = workspace.path(RECORDING_FILES.reads);
411
436
  const coverage = workspace.path(RECORDING_FILES.coverage);
@@ -420,36 +445,37 @@ export async function record(core: C64RunSession, file: RecordFile, workspace: W
420
445
  const masks = [0, 0];
421
446
  let reason: string | undefined;
422
447
  let afterEvent = false;
448
+ // An event runs after the next read at the busiest program site, here and on each
449
+ // replay. An event right after another event runs where that one ended.
450
+ const runEvent = async (action: ActionStep | RestoreAction, step?: number): Promise<string | undefined> => {
451
+ const tick = (await core.status("status")).tick;
452
+ const busiest = afterEvent ? undefined : busiestSite(await core.command("sites"));
453
+ const key: RecordedEvent["key"] = afterEvent
454
+ ? { previous: true }
455
+ : busiest === undefined
456
+ ? { tick: tick + 1 }
457
+ : { site: formatC64Address(busiest.site), addr: formatC64Address(busiest.addr), count: busiest.count + 1 };
458
+ if ((await runToKey(core, key, tick + 600 * PAL_FRAME_CYCLES, new Map())) !== "reached") return "the program did not read its input again within 600 frames";
459
+ events.push({ ...(step === undefined ? {} : { step }), action, key });
460
+ return doAction(core, action, at);
461
+ };
423
462
  for (const [index, step] of file.steps.entries()) {
424
463
  let tick = (await core.status("status")).tick;
425
- const event = !INPUT_KINDS.has(Object.keys(step).find((key) => STEP_KINDS.includes(key))!);
464
+ const event = !INPUT_KINDS.has(Object.keys(step)[0]!);
426
465
  if ("frames" in step) {
427
466
  await core.status(`run ${tick + step.frames * PAL_FRAME_CYCLES}`);
428
467
  } else if ("joystick" in step) {
429
468
  masks[step.joystick.port - 1] = joystickMask(step.joystick.direction, step.joystick.fire ?? false);
430
469
  await core.command(`joy ${masks[0]!.toString(16)} ${masks[1]!.toString(16)}`);
431
- } else if ("type" in step) {
432
- for (const code of keyCodes(step.type)) {
470
+ } else if ("press" in step) {
471
+ for (const code of keyCodes(step.press)) {
433
472
  await core.command(`key ${code.toString(16)} down`);
434
473
  await core.status(`run ${(tick += KEY_FRAMES * PAL_FRAME_CYCLES)}`);
435
474
  await core.command(`key ${code.toString(16)} up`);
436
475
  await core.status(`run ${(tick += KEY_FRAMES * PAL_FRAME_CYCLES)}`);
437
476
  }
438
477
  } else {
439
- // Not input: it runs after the next read at the busiest program site, here and on each
440
- // replay. An event right after another event runs where that one ended.
441
- const busiest = afterEvent ? undefined : busiestSite(await core.command("sites"));
442
- const key: RecordedEvent["key"] = afterEvent
443
- ? { previous: true }
444
- : busiest === undefined
445
- ? { tick: tick + 1 }
446
- : { site: formatC64Address(busiest.site), addr: formatC64Address(busiest.addr), count: busiest.count + 1 };
447
- if ((await runToKey(core, key, tick + 600 * PAL_FRAME_CYCLES, new Map())) !== "reached") {
448
- reason = `step ${index + 1}: the program did not read its input again within 600 frames`;
449
- break;
450
- }
451
- events.push({ step: index + 1, action: step, key });
452
- reason = await doAction(core, step, at);
478
+ reason = await runEvent(step, index + 1);
453
479
  if (reason !== undefined) {
454
480
  reason = `step ${index + 1}: ${reason}`;
455
481
  break;
@@ -457,7 +483,13 @@ export async function record(core: C64RunSession, file: RecordFile, workspace: W
457
483
  }
458
484
  afterEvent = event;
459
485
  }
460
- if (input !== undefined && reason === undefined) await input(core);
486
+ // The play runs frames before each RESTORE: a RESTORE never runs where an event of the steps ended.
487
+ const restore = async () => {
488
+ afterEvent = false;
489
+ const failed = await runEvent({ restore: true });
490
+ return failed === undefined ? undefined : "RESTORE: " + failed;
491
+ };
492
+ if (input !== undefined && reason === undefined) reason = await input(core, restore);
461
493
  const end = await core.status("status");
462
494
  const sites = (await core.command("sites")).split(" ").slice(2).map((entry): RecordedSite => {
463
495
  const [where, reads, kind] = entry.split(":");
@@ -526,9 +558,9 @@ export async function replaySide(
526
558
  const same = (address: number) => address;
527
559
  const move = original
528
560
  ? { code: same, data: same }
529
- : { code: mover(codeLabels(names), codeLabels(subject.symbols)), data: mover(codeLabels(names, "all"), codeLabels(subject.symbols, "all")) };
530
- const at = sideAddresses(loadSymbols({ program: subject.program, symbols: subject.symbols }), subject.side, move);
531
- const moved = original ? [] : siteMap(recording.sites, codeLabels(names), codeLabels(subject.symbols));
561
+ : { code: mover(namesOf(names, "code"), namesOf(subject.symbols, "code")), data: mover(namesOf(names), namesOf(subject.symbols)) };
562
+ const at = sideAddresses(namesOf(subject.symbols), subject.side, move);
563
+ const moved = original ? [] : siteMap(recording.sites, namesOf(names, "code"), namesOf(subject.symbols, "code"));
532
564
  const map = new Map(moved);
533
565
  const reads = workspace.path(`${subject.side}-${RECORDING_FILES.reads}`);
534
566
  writeFileSync(reads, readProjectFile(`${folder}/${RECORDING_FILES.reads}`).bytes);
@@ -555,15 +587,16 @@ export async function replaySide(
555
587
  const limit = original ? recording.ticks : recording.ticks * REBUILD_TIME_FACTOR;
556
588
  let problem: string | undefined;
557
589
  for (const event of recording.events) {
590
+ const what = event.step === undefined ? "the RESTORE of the play" : "step " + event.step;
558
591
  const point = await runToKey(core, event.key, limit, map);
559
592
  if (point === "halted") break;
560
593
  if (point === "not-reached") {
561
- problem = `the program did not come to the point of step ${event.step}`;
594
+ problem = "the program did not come to the point of " + what;
562
595
  break;
563
596
  }
564
597
  problem = await doAction(core, event.action, at);
565
598
  if (problem !== undefined) {
566
- problem = `step ${event.step}: ${problem}`;
599
+ problem = what + ": " + problem;
567
600
  break;
568
601
  }
569
602
  }
@@ -585,7 +618,7 @@ export interface CheckpointDifference {
585
618
  /** 1 = the first checkpoint. */
586
619
  checkpoint: number;
587
620
  tick: { original: number; rebuild: number };
588
- items: Array<{ at: Location; original: string; rebuild: string }>;
621
+ items: Array<{ address: Location; original: string; rebuild: string }>;
589
622
  }
590
623
 
591
624
  /** The pinned values of each sample line: the fields after the state items. */
@@ -604,7 +637,7 @@ export function firstDifference(expected: string[], got: string[], state: StateI
604
637
  const a = allA.slice(0, state.length);
605
638
  const b = allB.slice(0, state.length);
606
639
  if (a.join(" ") === b.join(" ")) continue;
607
- const items = state.flatMap((item, at) => (a[at] === b[at] ? [] : [{ at: item.at, original: a[at] ?? "", rebuild: b[at] ?? "" }]));
640
+ const items = state.flatMap((item, at) => (a[at] === b[at] ? [] : [{ address: item.address, original: a[at] ?? "", rebuild: b[at] ?? "" }]));
608
641
  return { checkpoint: index + 1, tick: { original: Number(tickA), rebuild: Number(tickB) }, items };
609
642
  }
610
643
  return undefined;
@@ -625,5 +658,5 @@ export interface ReplayResult {
625
658
  movedSites?: number;
626
659
  /** Input reads of the rebuild that the recording had no value for: they got the idle value. */
627
660
  idleReads?: number;
628
- files?: string[];
661
+ files?: Written[];
629
662
  }