@henols/c64-re-tools 2.0.1 → 2.1.0-rc.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 (104) hide show
  1. package/README.md +8 -4
  2. package/package.json +1 -1
  3. package/skills/c64-assembler/SKILL.md +24 -9
  4. package/skills/c64-assembler/scripts/assembler.ts +95 -0
  5. package/skills/c64-basic/SKILL.md +17 -9
  6. package/skills/c64-basic/scripts/basic.ts +26 -36
  7. package/skills/c64-disk/SKILL.md +17 -7
  8. package/skills/c64-disk/scripts/disk.ts +130 -104
  9. package/skills/c64-disk/scripts/lib/containers.ts +10 -12
  10. package/skills/c64-docs/SKILL.md +47 -28
  11. package/skills/c64-docs/references/style.md +6 -6
  12. package/skills/c64-docs/scripts/docs.ts +323 -0
  13. package/skills/c64-docs/scripts/lib/source-map.ts +1 -1
  14. package/skills/c64-emulator/SKILL.md +11 -7
  15. package/skills/c64-knowledge/SKILL.md +34 -21
  16. package/skills/c64-knowledge/scripts/knowledge.ts +597 -718
  17. package/skills/c64-knowledge/scripts/lib/listing.ts +5 -7
  18. package/skills/c64-memory-map/SKILL.md +18 -10
  19. package/skills/c64-memory-map/scripts/memory-map.ts +81 -0
  20. package/skills/c64-provenance/SKILL.md +82 -26
  21. package/skills/c64-provenance/scripts/lib/compare.ts +8 -12
  22. package/skills/c64-provenance/scripts/lib/leftovers.ts +442 -0
  23. package/skills/c64-provenance/scripts/lib/library.ts +15 -25
  24. package/skills/c64-provenance/scripts/lib/runtime.ts +12 -37
  25. package/skills/c64-provenance/scripts/lib/separate.ts +9 -10
  26. package/skills/c64-provenance/scripts/provenance.ts +222 -252
  27. package/skills/c64-reverse-engineering/SKILL.md +18 -15
  28. package/skills/c64-source/SKILL.md +28 -16
  29. package/skills/c64-source/scripts/lib/loader.ts +52 -77
  30. package/skills/c64-source/scripts/lib/source.ts +33 -42
  31. package/skills/c64-source/scripts/{generate.ts → source.ts} +68 -109
  32. package/skills/c64-static-analysis/SKILL.md +24 -13
  33. package/skills/c64-static-analysis/scripts/lib/coverage.ts +3 -4
  34. package/skills/c64-static-analysis/scripts/lib/findings.ts +2 -2
  35. package/skills/c64-static-analysis/scripts/lib/instructions.ts +1 -1
  36. package/skills/c64-static-analysis/scripts/lib/macros.ts +6 -8
  37. package/skills/c64-static-analysis/scripts/{analyze.ts → static-analysis.ts} +58 -118
  38. package/skills/c64-testing/SKILL.md +94 -19
  39. package/skills/c64-testing/references/recording-format.md +125 -0
  40. package/skills/c64-testing/references/scenario-format.md +24 -19
  41. package/skills/c64-testing/scripts/{diff.ts → lib/diff-files.ts} +18 -69
  42. package/skills/c64-testing/scripts/lib/diff.ts +5 -3
  43. package/skills/c64-testing/scripts/lib/play.ts +334 -0
  44. package/skills/c64-testing/scripts/lib/recording.ts +662 -0
  45. package/skills/c64-testing/scripts/lib/replay.ts +105 -0
  46. package/skills/c64-testing/scripts/{run.ts → lib/runs.ts} +36 -59
  47. package/skills/c64-testing/scripts/lib/scenario.ts +73 -81
  48. package/skills/c64-testing/scripts/testing.ts +189 -0
  49. package/skills/c64-unpacker/SKILL.md +42 -25
  50. package/skills/c64-unpacker/scripts/lib/evidence.ts +3 -4
  51. package/skills/c64-unpacker/scripts/lib/loads.ts +12 -10
  52. package/skills/c64-unpacker/scripts/lib/machine.ts +0 -2
  53. package/skills/c64-unpacker/scripts/lib/stages.ts +14 -19
  54. package/skills/c64-unpacker/scripts/{unpack.ts → unpacker.ts} +109 -211
  55. package/src/c64.ts +35 -20
  56. package/src/cli/main.ts +2 -2
  57. package/src/errors.ts +38 -0
  58. package/src/graphics.ts +3 -1
  59. package/src/host/server.ts +3 -2
  60. package/src/host/vice/session.ts +4 -4
  61. package/src/host-client/entry-state.ts +106 -0
  62. package/src/host-client/image.ts +21 -10
  63. package/src/host-client/steps.ts +285 -108
  64. package/src/host-client/transfer.ts +21 -15
  65. package/src/host-client/vice-session.ts +1 -1
  66. package/src/knowledge/database.ts +3 -23
  67. package/src/knowledge/expression.ts +8 -11
  68. package/src/knowledge/history.ts +2 -2
  69. package/src/knowledge/import.ts +13 -14
  70. package/src/knowledge/names.ts +50 -0
  71. package/src/knowledge/program.ts +11 -10
  72. package/src/knowledge/read.ts +16 -21
  73. package/src/knowledge/schema.ts +9 -3
  74. package/src/knowledge/text.ts +5 -5
  75. package/src/knowledge/write.ts +93 -137
  76. package/src/mcp/inputs.ts +105 -0
  77. package/src/mcp/output.ts +2 -13
  78. package/src/mcp/server.ts +12 -60
  79. package/src/mcp/tools/debug.ts +17 -12
  80. package/src/mcp/tools/execution.ts +5 -32
  81. package/src/mcp/tools/input.ts +7 -32
  82. package/src/mcp/tools/media.ts +6 -5
  83. package/src/mcp/tools/memory.ts +11 -38
  84. package/src/mcp/tools/video.ts +6 -5
  85. package/src/native/c64run.ts +162 -0
  86. package/src/native/discover.ts +8 -0
  87. package/src/native/dxa.ts +7 -6
  88. package/src/native/ghidra/analyze.ts +2 -1
  89. package/src/native/ghidra/index.ts +2 -1
  90. package/src/native/processes.ts +4 -3
  91. package/src/native/status.ts +25 -4
  92. package/src/native/types.ts +3 -7
  93. package/src/opcodes.ts +13 -13
  94. package/src/platform.ts +9 -9
  95. package/src/project.ts +16 -26
  96. package/src/protocol/messages.ts +3 -21
  97. package/src/protocol/vice.ts +7 -7
  98. package/src/script.ts +491 -49
  99. package/skills/c64-assembler/scripts/assemble.ts +0 -138
  100. package/skills/c64-docs/scripts/generate.ts +0 -109
  101. package/skills/c64-docs/scripts/link.ts +0 -239
  102. package/skills/c64-memory-map/scripts/memmap.ts +0 -92
  103. package/skills/c64-testing/scripts/checklist.ts +0 -51
  104. package/skills/c64-testing/scripts/test.ts +0 -65
@@ -0,0 +1,662 @@
1
+ // Recordings: a session of a program in the replay core (c64run), kept as the
2
+ // values that its input reads got (the joystick ports, the keyboard matrix,
3
+ // the SID noise) and the state hash at its end. A replay gives the same values
4
+ // to the same reads, keyed by the site (the reading instruction) and the read
5
+ // index. On the original it must end with the same hash. On a rebuild it
6
+ // compares the state at each entry of one routine (the checkpoints) with the
7
+ // original's. Timing does not key anything, so a rebuild with other timing
8
+ // replays the same session. A recording needs no names: the checkpoint and the
9
+ // state can be chosen at replay time, with the names that the knowledge has then.
10
+
11
+ import { createHash } from "node:crypto";
12
+ import { readFileSync, writeFileSync } from "node:fs";
13
+
14
+ import { isRefusal, Refusal } from "#src/errors.ts";
15
+ import { formatC64Address, parseC64Address } from "#src/c64.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
+ import { PAL_FRAME_CYCLES, type C64RunSession, type CoreStatus } from "#src/native/c64run.ts";
20
+ import type { Workspace } from "#src/native/staging.ts";
21
+ import { namesOf, type Outcome } from "./scenario.ts";
22
+
23
+ export const RECORDING_FORMAT = 1;
24
+ /**
25
+ * CIA 1 ports A and B (joystick 2, joystick 1 and the keyboard matrix); the
26
+ * four SID registers that a program can read: the paddles ($D419, $D41A) and
27
+ * voice 3 (the noise output $D41B and its envelope $D41C), which programs use as
28
+ * random seeds; and I/O 1 and I/O 2 ($DE00-$DFFF), where a C64 without a
29
+ * cartridge gives the last byte on the bus.
30
+ */
31
+ export const DEFAULT_INPUTS = ["$dc00", "$dc01", "$d419", "$d41a", "$d41b", "$d41c", "$de00-$dfff"];
32
+
33
+ /** The inputs argument of the replay core: hex addresses and ranges without $. */
34
+ export function inputsArgument(inputs: string[]): string {
35
+ return inputs.map((input) => input.replace(/\$/g, "")).join(",");
36
+ }
37
+ /** Frames that a pressed key is held, and then released. */
38
+ const KEY_FRAMES = 3;
39
+ const MAX_STATE_ITEMS = 16;
40
+ const MAX_STATE_BYTES = 4096;
41
+ /** A replay of a rebuild may take this many times the cycles of the recording before it stops. */
42
+ const REBUILD_TIME_FACTOR = 2;
43
+
44
+ export interface StateItem {
45
+ address: Location;
46
+ size: number;
47
+ }
48
+
49
+ /** The input of record.ts. */
50
+ export interface RecordFile {
51
+ name: string;
52
+ program: string;
53
+ /** "knowledge", "none", or a project JSON file (c64-assembler output or a name → "$xxxx" map). */
54
+ symbols: string;
55
+ /** true: load the program with the CPU at READY and no RUN; a registers step starts it. */
56
+ load?: boolean;
57
+ /** The comparison that a replay uses when it gets none: the checkpoint routine and the state. */
58
+ compare?: Comparison;
59
+ inputs: string[];
60
+ steps: ActionStep[];
61
+ }
62
+
63
+ /**
64
+ * What a replay compares: the state at each entry of a routine, for example the
65
+ * main loop of a game. `pin` is random state in RAM (a seed, a pointer into a
66
+ * random table): at each checkpoint the rebuild gets the original's values there.
67
+ */
68
+ export interface Comparison {
69
+ checkpoint: Location;
70
+ state: StateItem[];
71
+ pin?: StateItem[];
72
+ }
73
+
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. */
80
+ export interface RecordedEvent {
81
+ /** The step of the record file; none for a RESTORE of the play. */
82
+ step?: number;
83
+ action: ActionStep | RestoreAction;
84
+ /**
85
+ * The point: the n-th read at the busiest program site, or a tick when no
86
+ * program site has read yet. An event that follows another event directly
87
+ * has { previous: true }: it runs where that one ended.
88
+ */
89
+ key: { site: Location; addr: Location; count: number } | { tick: number } | { previous: true };
90
+ }
91
+
92
+ export interface RecordedSite {
93
+ site: Location;
94
+ addr: Location;
95
+ reads: number;
96
+ rom: boolean;
97
+ }
98
+
99
+ /** recording.json */
100
+ export interface Recording {
101
+ format: typeof RECORDING_FORMAT;
102
+ name: string;
103
+ program: string;
104
+ sha256: string;
105
+ symbols: string;
106
+ load: boolean;
107
+ compare?: Comparison;
108
+ inputs: string[];
109
+ events: RecordedEvent[];
110
+ sites: RecordedSite[];
111
+ /** The cycles of the session, and the state hash at its end (RAM from $0002 and colour RAM). */
112
+ ticks: number;
113
+ hash: string;
114
+ }
115
+
116
+ export const RECORDING_FILES = { recording: "recording.json", reads: "reads.txt", coverage: "coverage.bin" } as const;
117
+
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;
124
+
125
+ const hex = (address: number) => address.toString(16).padStart(4, "0");
126
+
127
+ /** The keys of the replay core for the characters that a press step can have. */
128
+ const KEY_CODES = new Map<string, number>([
129
+ ...[..."ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 +-.:@,*;=/!\"#$%&'()[]<>?"].map((character) => [character, character.charCodeAt(0)] as [string, number]),
130
+ ["\n", 0x0d],
131
+ ]);
132
+
133
+ /** The key codes of a text; a-z type as A-Z, as the C64 shows them after power-on. */
134
+ export function keyCodes(text: string): number[] {
135
+ return [...text.replace(/\r\n?/g, "\n")].map((character) => {
136
+ const code = KEY_CODES.get(character.toUpperCase());
137
+ if (code === undefined) throw new Refusal("invalid-input", `press cannot press ${JSON.stringify(character)} on the keyboard of the replay core`);
138
+ return code;
139
+ });
140
+ }
141
+
142
+ /** The joystick mask of the replay core: 1 up, 2 down, 4 left, 8 right, $10 fire. */
143
+ export function joystickMask(direction: Extract<ActionStep, { joystick: unknown }>["joystick"]["direction"], fire: boolean): number {
144
+ const bits: Record<string, number> = { up: 1, down: 2, left: 4, right: 8 };
145
+ return direction.split("-").reduce((mask, part) => mask | (bits[part] ?? 0), fire ? 0x10 : 0);
146
+ }
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 {
150
+ const match = /^([^:]+)(?::(\d+))?$/.exec(text);
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}`);
153
+ return { address: match[1]!, size };
154
+ }
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`);
158
+ return (value as unknown[]).map((item, index) => {
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 }`);
161
+ 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}`);
163
+ return { address: (spec as { address: string }).address, size: size as number };
164
+ });
165
+ }
166
+
167
+ function checkInputs(value: unknown, fail: (message: string) => never): string[] {
168
+ if (value === undefined) return DEFAULT_INPUTS;
169
+ if (!Array.isArray(value) || value.length === 0) fail("inputs must be a list of I/O addresses");
170
+ for (const input of value as unknown[]) {
171
+ if (typeof input !== "string" || !/^\$d[0-9a-f]{3}(-\$d[0-9a-f]{3})?$/i.test(input)) fail(`inputs must be $ addresses or ranges ($de00-$dfff) in $D000-$DFFF, not ${String(input)}`);
172
+ }
173
+ return (value as string[]).map((input) => input.toLowerCase());
174
+ }
175
+
176
+ /** Reads and checks a record file. Refuses it with the first problem. */
177
+ export function readRecordFile(path: string): RecordFile {
178
+ const fail = (message: string): never => {
179
+ throw new Refusal("invalid-input", `record file ${path}: ${message}`);
180
+ };
181
+ let value: unknown;
182
+ try {
183
+ value = JSON.parse(readProjectFile(path).bytes.toString("utf8"));
184
+ } catch (error) {
185
+ if (isRefusal(error)) throw error;
186
+ return fail((error as Error).message);
187
+ }
188
+ if (!isObject(value)) return fail("it must be a JSON object");
189
+ const known = ["name", "program", "symbols", "load", "checkpoint", "state", "pin", "inputs", "steps"];
190
+ for (const key of Object.keys(value)) if (!known.includes(key)) fail(`it has the key ${key}; a record file has ${known.join(", ")}`);
191
+ if (typeof value.name !== "string" || value.name === "") fail("name must be a short text");
192
+ if (typeof value.program !== "string" || value.program === "") fail("program must be a PRG file in the project");
193
+ if (value.symbols !== undefined && typeof value.symbols !== "string") fail('symbols must be "knowledge", "none" or a JSON file');
194
+ if (value.load !== undefined && typeof value.load !== "boolean") fail("load must be true or false");
195
+ if ((value.checkpoint === undefined) !== (value.state === undefined)) fail("give checkpoint and state together, or neither: a replay can give them");
196
+ if (value.checkpoint !== undefined && (typeof value.checkpoint !== "string" || value.checkpoint === "")) fail("checkpoint must be the name or $ address of a routine");
197
+ if (value.pin !== undefined && value.checkpoint === undefined) fail("pin needs checkpoint and state");
198
+ const pin = value.pin === undefined ? undefined : checkState(value.pin, fail);
199
+ const compare = value.checkpoint === undefined ? undefined : { checkpoint: value.checkpoint as string, state: checkState(value.state, fail), ...(pin === undefined ? {} : { pin }) };
200
+ const inputs = checkInputs(value.inputs, fail);
201
+ if (!Array.isArray(value.steps) || value.steps.length === 0) fail("steps must be a list with at least one step");
202
+ const steps = (value.steps as unknown[]).map((raw, index) => {
203
+ const at = `step ${index + 1}`;
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;
207
+ });
208
+ return {
209
+ name: value.name as string,
210
+ program: value.program as string,
211
+ symbols: (value.symbols as string | undefined) ?? "knowledge",
212
+ load: value.load === true,
213
+ ...(compare === undefined ? {} : { compare }),
214
+ inputs,
215
+ steps,
216
+ };
217
+ }
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
+
235
+ /** Reads and checks recording.json of a recording folder. */
236
+ export function readRecording(folder: string): Recording {
237
+ const path = `${folder}/${RECORDING_FILES.recording}`;
238
+ let value: unknown;
239
+ try {
240
+ value = JSON.parse(readProjectFile(path).bytes.toString("utf8"));
241
+ } catch (error) {
242
+ if (isRefusal(error)) throw error;
243
+ throw new Refusal("invalid-input", `recording ${path}: ${(error as Error).message}`);
244
+ }
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`);
249
+ return value as unknown as Recording;
250
+ }
251
+
252
+ /**
253
+ * The addresses of one side. A name comes from the symbols of the side. A $
254
+ * address is an address of the original: on the rebuild it moves with the
255
+ * name before it (code with a code label, data with any symbol).
256
+ */
257
+ export interface SideAddresses {
258
+ code: (location: Location) => number;
259
+ data: (location: Location) => number;
260
+ }
261
+
262
+ function sideAddresses(symbols: Map<string, number>, side: string, move: { code: (address: number) => number; data: (address: number) => number }): SideAddresses {
263
+ const resolve = (location: Location, kind: "code" | "data") => {
264
+ if (location.startsWith("$")) return move[kind](parseC64Address(location));
265
+ const address = symbols.get(location);
266
+ if (address === undefined) throw new Refusal("invalid-input", `the ${side} side has no symbol ${location}`);
267
+ return address;
268
+ };
269
+ return { code: (location) => resolve(location, "code"), data: (location) => resolve(location, "data") };
270
+ }
271
+
272
+ /** The furthest a site can be from the label before it and still move with that label. */
273
+ const MAX_LABEL_DISTANCE = 4096;
274
+
275
+ /**
276
+ * Moves an address of the original to the rebuild: the name before it in the
277
+ * original, plus the same offset from that name in the rebuild. An address
278
+ * without a name before it (within MAX_LABEL_DISTANCE) stays where it is.
279
+ */
280
+ export function mover(from: Map<string, number>, to: Map<string, number>): (address: number) => number {
281
+ const names = [...from].filter(([name]) => to.has(name)).sort((a, b) => a[1] - b[1]);
282
+ return (address) => {
283
+ let before: [string, number] | undefined;
284
+ for (const entry of names) {
285
+ if (entry[1] > address) break;
286
+ before = entry;
287
+ }
288
+ if (before === undefined || address - before[1] > MAX_LABEL_DISTANCE) return address;
289
+ return (to.get(before[0])! + (address - before[1])) & 0xffff;
290
+ };
291
+ }
292
+
293
+ /** Where each recorded site is in a rebuild (see mover). ROM sites stay where they are. Gives only the sites that move. */
294
+ export function siteMap(sites: RecordedSite[], from: Map<string, number>, to: Map<string, number>): Array<[number, number]> {
295
+ const move = mover(from, to);
296
+ const moved: Array<[number, number]> = [];
297
+ for (const pc of new Set(sites.filter((site) => !site.rom).map((site) => parseC64Address(site.site)))) {
298
+ const target = move(pc);
299
+ if (target !== pc) moved.push([pc, target]);
300
+ }
301
+ return moved;
302
+ }
303
+
304
+ /** Power-on and READY, then the program in memory as a LOAD leaves it, and RUN in the keyboard queue. */
305
+ async function startProgram(core: C64RunSession, prg: Uint8Array, load: boolean): Promise<CoreStatus> {
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}`);
308
+ const start = prg[0]! | (prg[1]! << 8);
309
+ const end = start + prg.length - 2;
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`);
311
+ await core.write(start, prg.subarray(2));
312
+ // The pointers that LOAD sets: the end of the program and of the variables.
313
+ for (const pointer of [0x2d, 0x2f, 0x31, 0xae]) await core.write(pointer, Uint8Array.of(end & 0xff, end >> 8));
314
+ if (!load) {
315
+ await core.write(0x0277, Uint8Array.from([0x52, 0x55, 0x4e, 0x0d]));
316
+ await core.write(0x00c6, Uint8Array.of(4));
317
+ }
318
+ return core.status("status");
319
+ }
320
+
321
+ /** The busiest site that is not ROM code: the point at which an event happens again on a replay. */
322
+ function busiestSite(answer: string): { site: number; addr: number; count: number } | undefined {
323
+ let best: { site: number; addr: number; count: number } | undefined;
324
+ for (const entry of answer.split(" ").slice(2)) {
325
+ const match = /^([0-9a-f]{4})\/([0-9a-f]{4}):(\d+):(ram|rom)/.exec(entry);
326
+ if (match === null || match[4] === "rom") continue;
327
+ const count = Number(match[3]);
328
+ if (count > 0 && (best === undefined || count > best.count)) best = { site: Number.parseInt(match[1]!, 16), addr: Number.parseInt(match[2]!, 16), count };
329
+ }
330
+ return best;
331
+ }
332
+
333
+ /**
334
+ * Runs to the point of an event. `map` moves a site to the rebuild. "halted"
335
+ * when a sample stop came first, "not-reached" when the point did not come before `until`.
336
+ */
337
+ async function runToKey(core: C64RunSession, key: RecordedEvent["key"], until: number, map: Map<number, number>): Promise<"reached" | "not-reached" | "halted"> {
338
+ if ("previous" in key) return (await core.status("status")).word === "halted" ? "halted" : "reached";
339
+ if ("tick" in key) {
340
+ const status = await core.status(`run ${key.tick}`);
341
+ return status.word === "halted" ? "halted" : "reached";
342
+ }
343
+ const site = parseC64Address(key.site);
344
+ const status = await core.status(`run-reads ${hex(map.get(site) ?? site)} ${hex(parseC64Address(key.addr))} ${key.count} ${until}`);
345
+ return status.word === "halted" ? "halted" : status.word === "reached" ? "reached" : "not-reached";
346
+ }
347
+
348
+ /** Does a step that is not input. Gives a reason when a runUntil does not reach its target. */
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));
378
+ } else if ("registers" in step) {
379
+ const now = await core.status("run-sync");
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));
384
+ await core.status(`regs ${values.join(" ")}`);
385
+ if (pc !== undefined) await core.status(`goto ${hex(at.code(pc))}`);
386
+ } else if ("runUntil" in step) {
387
+ const { target, timeoutFrames } = step.runUntil;
388
+ const start = (await core.status("status")).tick;
389
+ const until = start + timeoutFrames * PAL_FRAME_CYCLES;
390
+ if (target.kind === "memory") {
391
+ for (let tick = start; ; tick += PAL_FRAME_CYCLES) {
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`;
394
+ await core.status(`run ${tick + PAL_FRAME_CYCLES}`);
395
+ }
396
+ }
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`;
400
+ }
401
+ return undefined;
402
+ }
403
+
404
+ function rangesArgument(state: StateItem[], at: SideAddresses): string {
405
+ return state.map((item) => `${hex(at.data(item.address))}:${item.size}`).join(",");
406
+ }
407
+
408
+ export interface RecordOutcome {
409
+ recording: Recording;
410
+ reads: Uint8Array;
411
+ coverage: Uint8Array;
412
+ /** Why the session did not complete (a runUntil that did not reach its target). */
413
+ reason?: string;
414
+ }
415
+
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> {
427
+ const prg = readProjectFile(file.program).bytes;
428
+ const same = (address: number) => address;
429
+ const at = sideAddresses(namesOf(file.symbols), "original", { code: same, data: same });
430
+ // A checkpoint and state of the record file must resolve now, so that a replay can use them.
431
+ if (file.compare !== undefined) {
432
+ at.code(file.compare.checkpoint);
433
+ for (const item of file.compare.state) at.data(item.address);
434
+ }
435
+ const reads = workspace.path(RECORDING_FILES.reads);
436
+ const coverage = workspace.path(RECORDING_FILES.coverage);
437
+ await core.init();
438
+ await core.command(`inputs ${inputsArgument(file.inputs)}`);
439
+ // The coverage starts at the first input read of code in RAM: then the program has done its
440
+ // own start (a loader, a relocation), so the map holds the work of the program.
441
+ await core.command("coverage on-input");
442
+ await core.command(`record ${reads}`);
443
+ await startProgram(core, prg, file.load ?? false);
444
+ const events: RecordedEvent[] = [];
445
+ const masks = [0, 0];
446
+ let reason: string | undefined;
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
+ };
462
+ for (const [index, step] of file.steps.entries()) {
463
+ let tick = (await core.status("status")).tick;
464
+ const event = !INPUT_KINDS.has(Object.keys(step)[0]!);
465
+ if ("frames" in step) {
466
+ await core.status(`run ${tick + step.frames * PAL_FRAME_CYCLES}`);
467
+ } else if ("joystick" in step) {
468
+ masks[step.joystick.port - 1] = joystickMask(step.joystick.direction, step.joystick.fire ?? false);
469
+ await core.command(`joy ${masks[0]!.toString(16)} ${masks[1]!.toString(16)}`);
470
+ } else if ("press" in step) {
471
+ for (const code of keyCodes(step.press)) {
472
+ await core.command(`key ${code.toString(16)} down`);
473
+ await core.status(`run ${(tick += KEY_FRAMES * PAL_FRAME_CYCLES)}`);
474
+ await core.command(`key ${code.toString(16)} up`);
475
+ await core.status(`run ${(tick += KEY_FRAMES * PAL_FRAME_CYCLES)}`);
476
+ }
477
+ } else {
478
+ reason = await runEvent(step, index + 1);
479
+ if (reason !== undefined) {
480
+ reason = `step ${index + 1}: ${reason}`;
481
+ break;
482
+ }
483
+ }
484
+ afterEvent = event;
485
+ }
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);
493
+ const end = await core.status("status");
494
+ const sites = (await core.command("sites")).split(" ").slice(2).map((entry): RecordedSite => {
495
+ const [where, reads, kind] = entry.split(":");
496
+ const [site, addr] = where!.split("/");
497
+ return { site: `$${site}`, addr: `$${addr}`, reads: Number(reads), rom: kind === "rom" };
498
+ });
499
+ await core.command("record off");
500
+ await core.command(`coverage ${coverage}`);
501
+ const recording: Recording = {
502
+ format: RECORDING_FORMAT,
503
+ name: file.name,
504
+ program: file.program,
505
+ sha256: createHash("sha256").update(prg).digest("hex"),
506
+ symbols: file.symbols,
507
+ load: file.load ?? false,
508
+ ...(file.compare === undefined ? {} : { compare: file.compare }),
509
+ inputs: file.inputs,
510
+ events,
511
+ sites,
512
+ ticks: end.tick,
513
+ hash: end.hash,
514
+ };
515
+ return { recording, reads: readFileSync(reads), coverage: readFileSync(coverage), ...(reason === undefined ? {} : { reason }) };
516
+ }
517
+
518
+ /** One side of a replay. */
519
+ export interface ReplaySubject {
520
+ side: "original" | "rebuild";
521
+ program: string;
522
+ symbols: string;
523
+ }
524
+
525
+ export interface SideRun {
526
+ /** The checkpoint lines: the tick, then the hex of each state item. */
527
+ samples: string[];
528
+ /** The machine at the end of the run. */
529
+ end: CoreStatus;
530
+ /** Why the side stopped before its last checkpoint, if it did. */
531
+ problem?: string;
532
+ /**
533
+ * Input reads that the recording has no value for, which got the fixed idle value:
534
+ * at sites that it does not have, and after its end at a site.
535
+ */
536
+ unknown: number;
537
+ misses: number;
538
+ }
539
+
540
+ /**
541
+ * Replays a recording on one side. The original runs to the end of the
542
+ * session; a rebuild runs until `stop` checkpoints (or REBUILD_TIME_FACTOR
543
+ * times the cycles). With `dump`, it stops at that checkpoint (1 = the first)
544
+ * and gives the RAM there.
545
+ */
546
+ export async function replaySide(
547
+ core: C64RunSession,
548
+ recording: Recording,
549
+ folder: string,
550
+ subject: ReplaySubject,
551
+ workspace: Workspace,
552
+ options: { compare?: Comparison; stop?: number; dump?: number; pins?: string[] } = {},
553
+ ): Promise<SideRun & { ram?: Uint8Array }> {
554
+ const prg = readProjectFile(subject.program).bytes;
555
+ const original = subject.side === "original";
556
+ // The names of the original: a recording without names takes those of the knowledge.
557
+ const names = recording.symbols === "none" ? "knowledge" : recording.symbols;
558
+ const same = (address: number) => address;
559
+ const move = original
560
+ ? { code: same, data: same }
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"));
564
+ const map = new Map(moved);
565
+ const reads = workspace.path(`${subject.side}-${RECORDING_FILES.reads}`);
566
+ writeFileSync(reads, readProjectFile(`${folder}/${RECORDING_FILES.reads}`).bytes);
567
+ const samples = workspace.path(`${subject.side}-checkpoints.txt`);
568
+ const mapFile = workspace.path(`${subject.side}-sites.map`);
569
+ writeFileSync(mapFile, moved.map(([from, to]) => `${hex(from)} ${hex(to)}\n`).join(""));
570
+ await core.init();
571
+ await core.command(`inputs ${inputsArgument(recording.inputs)}`);
572
+ await core.command(`site-map ${mapFile}`);
573
+ await core.command(`replay ${reads}`);
574
+ if (options.compare !== undefined) {
575
+ const stop = options.dump ?? options.stop ?? 0;
576
+ const pinned = options.compare.pin ?? [];
577
+ if (options.pins !== undefined && pinned.length > 0) {
578
+ // The rebuild: each checkpoint first gets the original's values of the random state.
579
+ const pinFile = workspace.path(`${subject.side}-pins.txt`);
580
+ writeFileSync(pinFile, options.pins.map((line) => `${line}\n`).join(""));
581
+ await core.command(`pin ${pinFile} ${rangesArgument(pinned, at)}`);
582
+ }
583
+ // The samples hold the state, then the pinned items.
584
+ await core.command(`sample ${hex(at.code(options.compare.checkpoint))} ${samples} ${rangesArgument([...options.compare.state, ...pinned], at)}${stop > 0 ? ` ${stop}` : ""}`);
585
+ }
586
+ await startProgram(core, prg, recording.load);
587
+ const limit = original ? recording.ticks : recording.ticks * REBUILD_TIME_FACTOR;
588
+ let problem: string | undefined;
589
+ for (const event of recording.events) {
590
+ const what = event.step === undefined ? "the RESTORE of the play" : "step " + event.step;
591
+ const point = await runToKey(core, event.key, limit, map);
592
+ if (point === "halted") break;
593
+ if (point === "not-reached") {
594
+ problem = "the program did not come to the point of " + what;
595
+ break;
596
+ }
597
+ problem = await doAction(core, event.action, at);
598
+ if (problem !== undefined) {
599
+ problem = what + ": " + problem;
600
+ break;
601
+ }
602
+ }
603
+ const end = problem === undefined ? await core.status(`run ${limit}`) : await core.status("status");
604
+ const sites = await core.command("sites");
605
+ const unknown = Number(/unknown=(\d+)/.exec(sites)?.[1] ?? 0);
606
+ const misses = sites.split(" ").slice(2).reduce((sum, entry) => sum + Number(entry.split(":")[4] ?? 0), 0);
607
+ const ram = options.dump === undefined ? undefined : await core.peek(0, 0x10000);
608
+ let lines: string[] = [];
609
+ if (options.compare !== undefined) {
610
+ await core.command("pin off");
611
+ await core.command("sample off");
612
+ lines = readFileSync(samples, "utf8").split("\n").filter((line) => line !== "");
613
+ }
614
+ return { samples: lines, end, unknown, misses, ...(problem === undefined ? {} : { problem }), ...(ram === undefined ? {} : { ram }) };
615
+ }
616
+
617
+ export interface CheckpointDifference {
618
+ /** 1 = the first checkpoint. */
619
+ checkpoint: number;
620
+ tick: { original: number; rebuild: number };
621
+ items: Array<{ address: Location; original: string; rebuild: string }>;
622
+ }
623
+
624
+ /** The pinned values of each sample line: the fields after the state items. */
625
+ export function pinLines(samples: string[], state: StateItem[]): string[] {
626
+ return samples.map((line) => line.split(" ").slice(1 + state.length).join(" "));
627
+ }
628
+
629
+ /**
630
+ * The first checkpoint where two runs differ in the state, or undefined when
631
+ * the first `count` are the same. Fields after the state items (pins) do not count.
632
+ */
633
+ export function firstDifference(expected: string[], got: string[], state: StateItem[], count = expected.length): CheckpointDifference | undefined {
634
+ for (let index = 0; index < Math.min(count, expected.length, got.length); index++) {
635
+ const [tickA, ...allA] = expected[index]!.split(" ");
636
+ const [tickB, ...allB] = got[index]!.split(" ");
637
+ const a = allA.slice(0, state.length);
638
+ const b = allB.slice(0, state.length);
639
+ if (a.join(" ") === b.join(" ")) continue;
640
+ const items = state.flatMap((item, at) => (a[at] === b[at] ? [] : [{ address: item.address, original: a[at] ?? "", rebuild: b[at] ?? "" }]));
641
+ return { checkpoint: index + 1, tick: { original: Number(tickA), rebuild: Number(tickB) }, items };
642
+ }
643
+ return undefined;
644
+ }
645
+
646
+ export interface ReplayResult {
647
+ result: Outcome;
648
+ recording: string;
649
+ /** The side that the result is about: the original (the self-check) or the rebuild. */
650
+ side: "original" | "rebuild";
651
+ /** The routine of the checkpoints, and the checkpoints of the original. */
652
+ checkpoint?: Location;
653
+ checkpoints: number;
654
+ /** The checkpoints of the rebuild that were equal. */
655
+ compared: number;
656
+ reason?: string;
657
+ firstDifference?: CheckpointDifference;
658
+ movedSites?: number;
659
+ /** Input reads of the rebuild that the recording had no value for: they got the idle value. */
660
+ idleReads?: number;
661
+ files?: Written[];
662
+ }