@henols/c64-re-tools 2.0.1 → 2.1.0-rc.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.
- package/README.md +8 -4
- package/package.json +1 -1
- package/skills/c64-provenance/SKILL.md +50 -3
- package/skills/c64-provenance/scripts/lib/leftovers.ts +443 -0
- package/skills/c64-provenance/scripts/provenance.ts +79 -7
- package/skills/c64-reverse-engineering/SKILL.md +7 -5
- package/skills/c64-testing/SKILL.md +61 -2
- package/skills/c64-testing/references/recording-format.md +96 -0
- package/skills/c64-testing/scripts/lib/play.ts +144 -0
- package/skills/c64-testing/scripts/lib/recording.ts +629 -0
- package/skills/c64-testing/scripts/record.ts +96 -0
- package/skills/c64-testing/scripts/replay.ts +163 -0
- package/src/native/c64run.ts +162 -0
- package/src/native/discover.ts +8 -0
- package/src/native/status.ts +23 -2
|
@@ -0,0 +1,629 @@
|
|
|
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 { 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";
|
|
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 { loadSymbols, ScenarioError, 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 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. */
|
|
40
|
+
const KEY_FRAMES = 3;
|
|
41
|
+
const MAX_STATE_ITEMS = 16;
|
|
42
|
+
const MAX_STATE_BYTES = 4096;
|
|
43
|
+
/** A replay of a rebuild may take this many times the cycles of the recording before it stops. */
|
|
44
|
+
const REBUILD_TIME_FACTOR = 2;
|
|
45
|
+
|
|
46
|
+
export interface StateItem {
|
|
47
|
+
at: Location;
|
|
48
|
+
size: number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The input of record.ts. */
|
|
52
|
+
export interface RecordFile {
|
|
53
|
+
name: string;
|
|
54
|
+
program: string;
|
|
55
|
+
/** "knowledge", "none", or a project JSON file (c64-assembler output or a name → "$xxxx" map). */
|
|
56
|
+
symbols: string;
|
|
57
|
+
/** true: load the program with the CPU at READY and no RUN; a registers step starts it. */
|
|
58
|
+
load?: boolean;
|
|
59
|
+
/** The comparison that a replay uses when it gets none: the checkpoint routine and the state. */
|
|
60
|
+
compare?: Comparison;
|
|
61
|
+
inputs: string[];
|
|
62
|
+
steps: ActionStep[];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* What a replay compares: the state at each entry of a routine, for example the
|
|
67
|
+
* main loop of a game. `pin` is random state in RAM (a seed, a pointer into a
|
|
68
|
+
* random table): at each checkpoint the rebuild gets the original's values there.
|
|
69
|
+
*/
|
|
70
|
+
export interface Comparison {
|
|
71
|
+
checkpoint: Location;
|
|
72
|
+
state: StateItem[];
|
|
73
|
+
pin?: StateItem[];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** A step that is not input: it runs at the same point of the program on each replay. */
|
|
77
|
+
export interface RecordedEvent {
|
|
78
|
+
step: number;
|
|
79
|
+
action: ActionStep;
|
|
80
|
+
/**
|
|
81
|
+
* The point: the n-th read at the busiest program site, or a tick when no
|
|
82
|
+
* program site has read yet. An event that follows another event directly
|
|
83
|
+
* has { previous: true }: it runs where that one ended.
|
|
84
|
+
*/
|
|
85
|
+
key: { site: Location; addr: Location; count: number } | { tick: number } | { previous: true };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface RecordedSite {
|
|
89
|
+
site: Location;
|
|
90
|
+
addr: Location;
|
|
91
|
+
reads: number;
|
|
92
|
+
rom: boolean;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** recording.json */
|
|
96
|
+
export interface Recording {
|
|
97
|
+
format: typeof RECORDING_FORMAT;
|
|
98
|
+
name: string;
|
|
99
|
+
program: string;
|
|
100
|
+
sha256: string;
|
|
101
|
+
symbols: string;
|
|
102
|
+
load: boolean;
|
|
103
|
+
compare?: Comparison;
|
|
104
|
+
inputs: string[];
|
|
105
|
+
events: RecordedEvent[];
|
|
106
|
+
sites: RecordedSite[];
|
|
107
|
+
/** The cycles of the session, and the state hash at its end (RAM from $0002 and colour RAM). */
|
|
108
|
+
ticks: number;
|
|
109
|
+
hash: string;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export const RECORDING_FILES = { recording: "recording.json", reads: "reads.txt", coverage: "coverage.bin" } as const;
|
|
113
|
+
|
|
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"]);
|
|
117
|
+
|
|
118
|
+
const hex = (address: number) => address.toString(16).padStart(4, "0");
|
|
119
|
+
|
|
120
|
+
/** The keys of the replay core for the characters that a type step can have. */
|
|
121
|
+
const KEY_CODES = new Map<string, number>([
|
|
122
|
+
...[..."ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 +-.:@,*;=/!\"#$%&'()[]<>?"].map((character) => [character, character.charCodeAt(0)] as [string, number]),
|
|
123
|
+
["\n", 0x0d],
|
|
124
|
+
]);
|
|
125
|
+
|
|
126
|
+
/** The key codes of a text; a-z type as A-Z, as the C64 shows them after power-on. */
|
|
127
|
+
export function keyCodes(text: string): number[] {
|
|
128
|
+
return [...text.replace(/\r\n?/g, "\n")].map((character) => {
|
|
129
|
+
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`);
|
|
131
|
+
return code;
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** The joystick mask of the replay core: 1 up, 2 down, 4 left, 8 right, $10 fire. */
|
|
136
|
+
export function joystickMask(direction: Extract<ActionStep, { joystick: unknown }>["joystick"]["direction"], fire: boolean): number {
|
|
137
|
+
const bits: Record<string, number> = { up: 1, down: 2, left: 4, right: 8 };
|
|
138
|
+
return direction.split("-").reduce((mask, part) => mask | (bits[part] ?? 0), fire ? 0x10 : 0);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** A state item of the command line: a name or a $ address, with :size for more than one byte. */
|
|
142
|
+
export function parseStateItem(text: string): StateItem {
|
|
143
|
+
const match = /^([^:]+)(?::(\d+))?$/.exec(text);
|
|
144
|
+
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 };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function checkState(value: unknown, fail: (message: string) => never): StateItem[] {
|
|
150
|
+
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
|
+
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 }`);
|
|
154
|
+
const size = (spec as { size?: unknown }).size ?? 1;
|
|
155
|
+
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 };
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function checkInputs(value: unknown, fail: (message: string) => never): string[] {
|
|
161
|
+
if (value === undefined) return DEFAULT_INPUTS;
|
|
162
|
+
if (!Array.isArray(value) || value.length === 0) fail("inputs must be a list of I/O addresses");
|
|
163
|
+
for (const input of value as unknown[]) {
|
|
164
|
+
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)}`);
|
|
165
|
+
}
|
|
166
|
+
return (value as string[]).map((input) => input.toLowerCase());
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Reads and checks a record file. Throws ScenarioError naming the first problem. */
|
|
170
|
+
export function readRecordFile(path: string): RecordFile {
|
|
171
|
+
const fail = (message: string): never => {
|
|
172
|
+
throw new ScenarioError(`record file ${path}: ${message}`);
|
|
173
|
+
};
|
|
174
|
+
let value: unknown;
|
|
175
|
+
try {
|
|
176
|
+
value = JSON.parse(readProjectFile(path).bytes.toString("utf8"));
|
|
177
|
+
} catch (error) {
|
|
178
|
+
return fail((error as Error).message);
|
|
179
|
+
}
|
|
180
|
+
if (!isObject(value)) return fail("it must be a JSON object");
|
|
181
|
+
const known = ["name", "program", "symbols", "load", "checkpoint", "state", "pin", "inputs", "steps"];
|
|
182
|
+
for (const key of Object.keys(value)) if (!known.includes(key)) fail(`it has the key ${key}; a record file has ${known.join(", ")}`);
|
|
183
|
+
if (typeof value.name !== "string" || value.name === "") fail("name must be a short text");
|
|
184
|
+
if (typeof value.program !== "string" || value.program === "") fail("program must be a PRG file in the project");
|
|
185
|
+
if (value.symbols !== undefined && typeof value.symbols !== "string") fail('symbols must be "knowledge", "none" or a JSON file');
|
|
186
|
+
if (value.load !== undefined && typeof value.load !== "boolean") fail("load must be true or false");
|
|
187
|
+
if ((value.checkpoint === undefined) !== (value.state === undefined)) fail("give checkpoint and state together, or neither: a replay can give them");
|
|
188
|
+
if (value.checkpoint !== undefined && (typeof value.checkpoint !== "string" || value.checkpoint === "")) fail("checkpoint must be the name or $ address of a routine");
|
|
189
|
+
if (value.pin !== undefined && value.checkpoint === undefined) fail("pin needs checkpoint and state");
|
|
190
|
+
const pin = value.pin === undefined ? undefined : checkState(value.pin, fail);
|
|
191
|
+
const compare = value.checkpoint === undefined ? undefined : { checkpoint: value.checkpoint as string, state: checkState(value.state, fail), ...(pin === undefined ? {} : { pin }) };
|
|
192
|
+
const inputs = checkInputs(value.inputs, fail);
|
|
193
|
+
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) => {
|
|
195
|
+
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
|
+
});
|
|
205
|
+
return {
|
|
206
|
+
name: value.name as string,
|
|
207
|
+
program: value.program as string,
|
|
208
|
+
symbols: (value.symbols as string | undefined) ?? "knowledge",
|
|
209
|
+
load: value.load === true,
|
|
210
|
+
...(compare === undefined ? {} : { compare }),
|
|
211
|
+
inputs,
|
|
212
|
+
steps: value.steps as ActionStep[],
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Reads and checks recording.json of a recording folder. */
|
|
217
|
+
export function readRecording(folder: string): Recording {
|
|
218
|
+
const path = `${folder}/${RECORDING_FILES.recording}`;
|
|
219
|
+
let value: unknown;
|
|
220
|
+
try {
|
|
221
|
+
value = JSON.parse(readProjectFile(path).bytes.toString("utf8"));
|
|
222
|
+
} catch (error) {
|
|
223
|
+
throw new ScenarioError(`recording ${path}: ${(error as Error).message}`);
|
|
224
|
+
}
|
|
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`);
|
|
229
|
+
return value as unknown as Recording;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* The addresses of one side. A name comes from the symbols of the side. A $
|
|
234
|
+
* address is an address of the original: on the rebuild it moves with the
|
|
235
|
+
* name before it (code with a code label, data with any symbol).
|
|
236
|
+
*/
|
|
237
|
+
export interface SideAddresses {
|
|
238
|
+
code: (location: Location) => number;
|
|
239
|
+
data: (location: Location) => number;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function sideAddresses(symbols: Map<string, number>, side: string, move: { code: (address: number) => number; data: (address: number) => number }): SideAddresses {
|
|
243
|
+
const resolve = (location: Location, kind: "code" | "data") => {
|
|
244
|
+
if (location.startsWith("$")) return move[kind](parseC64Address(location));
|
|
245
|
+
const address = symbols.get(location);
|
|
246
|
+
if (address === undefined) throw new ScenarioError(`the ${side} side has no symbol ${location}`);
|
|
247
|
+
return address;
|
|
248
|
+
};
|
|
249
|
+
return { code: (location) => resolve(location, "code"), data: (location) => resolve(location, "data") };
|
|
250
|
+
}
|
|
251
|
+
|
|
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
|
+
/** The furthest a site can be from the label before it and still move with that label. */
|
|
281
|
+
const MAX_LABEL_DISTANCE = 4096;
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Moves an address of the original to the rebuild: the name before it in the
|
|
285
|
+
* original, plus the same offset from that name in the rebuild. An address
|
|
286
|
+
* without a name before it (within MAX_LABEL_DISTANCE) stays where it is.
|
|
287
|
+
*/
|
|
288
|
+
export function mover(from: Map<string, number>, to: Map<string, number>): (address: number) => number {
|
|
289
|
+
const names = [...from].filter(([name]) => to.has(name)).sort((a, b) => a[1] - b[1]);
|
|
290
|
+
return (address) => {
|
|
291
|
+
let before: [string, number] | undefined;
|
|
292
|
+
for (const entry of names) {
|
|
293
|
+
if (entry[1] > address) break;
|
|
294
|
+
before = entry;
|
|
295
|
+
}
|
|
296
|
+
if (before === undefined || address - before[1] > MAX_LABEL_DISTANCE) return address;
|
|
297
|
+
return (to.get(before[0])! + (address - before[1])) & 0xffff;
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** Where each recorded site is in a rebuild (see mover). ROM sites stay where they are. Gives only the sites that move. */
|
|
302
|
+
export function siteMap(sites: RecordedSite[], from: Map<string, number>, to: Map<string, number>): Array<[number, number]> {
|
|
303
|
+
const move = mover(from, to);
|
|
304
|
+
const moved: Array<[number, number]> = [];
|
|
305
|
+
for (const pc of new Set(sites.filter((site) => !site.rom).map((site) => parseC64Address(site.site)))) {
|
|
306
|
+
const target = move(pc);
|
|
307
|
+
if (target !== pc) moved.push([pc, target]);
|
|
308
|
+
}
|
|
309
|
+
return moved;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** Power-on and READY, then the program in memory as a LOAD leaves it, and RUN in the keyboard queue. */
|
|
313
|
+
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}`);
|
|
316
|
+
const start = prg[0]! | (prg[1]! << 8);
|
|
317
|
+
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`);
|
|
319
|
+
await core.write(start, prg.subarray(2));
|
|
320
|
+
// The pointers that LOAD sets: the end of the program and of the variables.
|
|
321
|
+
for (const pointer of [0x2d, 0x2f, 0x31, 0xae]) await core.write(pointer, Uint8Array.of(end & 0xff, end >> 8));
|
|
322
|
+
if (!load) {
|
|
323
|
+
await core.write(0x0277, Uint8Array.from([0x52, 0x55, 0x4e, 0x0d]));
|
|
324
|
+
await core.write(0x00c6, Uint8Array.of(4));
|
|
325
|
+
}
|
|
326
|
+
return core.status("status");
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** The busiest site that is not ROM code: the point at which an event happens again on a replay. */
|
|
330
|
+
function busiestSite(answer: string): { site: number; addr: number; count: number } | undefined {
|
|
331
|
+
let best: { site: number; addr: number; count: number } | undefined;
|
|
332
|
+
for (const entry of answer.split(" ").slice(2)) {
|
|
333
|
+
const match = /^([0-9a-f]{4})\/([0-9a-f]{4}):(\d+):(ram|rom)/.exec(entry);
|
|
334
|
+
if (match === null || match[4] === "rom") continue;
|
|
335
|
+
const count = Number(match[3]);
|
|
336
|
+
if (count > 0 && (best === undefined || count > best.count)) best = { site: Number.parseInt(match[1]!, 16), addr: Number.parseInt(match[2]!, 16), count };
|
|
337
|
+
}
|
|
338
|
+
return best;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Runs to the point of an event. `map` moves a site to the rebuild. "halted"
|
|
343
|
+
* when a sample stop came first, "not-reached" when the point did not come before `until`.
|
|
344
|
+
*/
|
|
345
|
+
async function runToKey(core: C64RunSession, key: RecordedEvent["key"], until: number, map: Map<number, number>): Promise<"reached" | "not-reached" | "halted"> {
|
|
346
|
+
if ("previous" in key) return (await core.status("status")).word === "halted" ? "halted" : "reached";
|
|
347
|
+
if ("tick" in key) {
|
|
348
|
+
const status = await core.status(`run ${key.tick}`);
|
|
349
|
+
return status.word === "halted" ? "halted" : "reached";
|
|
350
|
+
}
|
|
351
|
+
const site = parseC64Address(key.site);
|
|
352
|
+
const status = await core.status(`run-reads ${hex(map.get(site) ?? site)} ${hex(parseC64Address(key.addr))} ${key.count} ${until}`);
|
|
353
|
+
return status.word === "halted" ? "halted" : status.word === "reached" ? "reached" : "not-reached";
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/** 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));
|
|
360
|
+
} else if ("registers" in step) {
|
|
361
|
+
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));
|
|
367
|
+
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))}`);
|
|
370
|
+
} else if ("runUntil" in step) {
|
|
371
|
+
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;
|
|
375
|
+
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`;
|
|
378
|
+
await core.status(`run ${tick + PAL_FRAME_CYCLES}`);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
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`;
|
|
384
|
+
}
|
|
385
|
+
return undefined;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function rangesArgument(state: StateItem[], at: SideAddresses): string {
|
|
389
|
+
return state.map((item) => `${hex(at.data(item.at))}:${item.size}`).join(",");
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
export interface RecordOutcome {
|
|
393
|
+
recording: Recording;
|
|
394
|
+
reads: Uint8Array;
|
|
395
|
+
coverage: Uint8Array;
|
|
396
|
+
/** Why the session did not complete (a runUntil that did not reach its target). */
|
|
397
|
+
reason?: string;
|
|
398
|
+
}
|
|
399
|
+
|
|
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> {
|
|
402
|
+
const prg = readProjectFile(file.program).bytes;
|
|
403
|
+
const same = (address: number) => address;
|
|
404
|
+
const at = sideAddresses(loadSymbols({ program: file.program, symbols: file.symbols }), "original", { code: same, data: same });
|
|
405
|
+
// A checkpoint and state of the record file must resolve now, so that a replay can use them.
|
|
406
|
+
if (file.compare !== undefined) {
|
|
407
|
+
at.code(file.compare.checkpoint);
|
|
408
|
+
for (const item of file.compare.state) at.data(item.at);
|
|
409
|
+
}
|
|
410
|
+
const reads = workspace.path(RECORDING_FILES.reads);
|
|
411
|
+
const coverage = workspace.path(RECORDING_FILES.coverage);
|
|
412
|
+
await core.init();
|
|
413
|
+
await core.command(`inputs ${inputsArgument(file.inputs)}`);
|
|
414
|
+
// The coverage starts at the first input read of code in RAM: then the program has done its
|
|
415
|
+
// own start (a loader, a relocation), so the map holds the work of the program.
|
|
416
|
+
await core.command("coverage on-input");
|
|
417
|
+
await core.command(`record ${reads}`);
|
|
418
|
+
await startProgram(core, prg, file.load ?? false);
|
|
419
|
+
const events: RecordedEvent[] = [];
|
|
420
|
+
const masks = [0, 0];
|
|
421
|
+
let reason: string | undefined;
|
|
422
|
+
let afterEvent = false;
|
|
423
|
+
for (const [index, step] of file.steps.entries()) {
|
|
424
|
+
let tick = (await core.status("status")).tick;
|
|
425
|
+
const event = !INPUT_KINDS.has(Object.keys(step).find((key) => STEP_KINDS.includes(key))!);
|
|
426
|
+
if ("frames" in step) {
|
|
427
|
+
await core.status(`run ${tick + step.frames * PAL_FRAME_CYCLES}`);
|
|
428
|
+
} else if ("joystick" in step) {
|
|
429
|
+
masks[step.joystick.port - 1] = joystickMask(step.joystick.direction, step.joystick.fire ?? false);
|
|
430
|
+
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)) {
|
|
433
|
+
await core.command(`key ${code.toString(16)} down`);
|
|
434
|
+
await core.status(`run ${(tick += KEY_FRAMES * PAL_FRAME_CYCLES)}`);
|
|
435
|
+
await core.command(`key ${code.toString(16)} up`);
|
|
436
|
+
await core.status(`run ${(tick += KEY_FRAMES * PAL_FRAME_CYCLES)}`);
|
|
437
|
+
}
|
|
438
|
+
} 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);
|
|
453
|
+
if (reason !== undefined) {
|
|
454
|
+
reason = `step ${index + 1}: ${reason}`;
|
|
455
|
+
break;
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
afterEvent = event;
|
|
459
|
+
}
|
|
460
|
+
if (input !== undefined && reason === undefined) await input(core);
|
|
461
|
+
const end = await core.status("status");
|
|
462
|
+
const sites = (await core.command("sites")).split(" ").slice(2).map((entry): RecordedSite => {
|
|
463
|
+
const [where, reads, kind] = entry.split(":");
|
|
464
|
+
const [site, addr] = where!.split("/");
|
|
465
|
+
return { site: `$${site}`, addr: `$${addr}`, reads: Number(reads), rom: kind === "rom" };
|
|
466
|
+
});
|
|
467
|
+
await core.command("record off");
|
|
468
|
+
await core.command(`coverage ${coverage}`);
|
|
469
|
+
const recording: Recording = {
|
|
470
|
+
format: RECORDING_FORMAT,
|
|
471
|
+
name: file.name,
|
|
472
|
+
program: file.program,
|
|
473
|
+
sha256: createHash("sha256").update(prg).digest("hex"),
|
|
474
|
+
symbols: file.symbols,
|
|
475
|
+
load: file.load ?? false,
|
|
476
|
+
...(file.compare === undefined ? {} : { compare: file.compare }),
|
|
477
|
+
inputs: file.inputs,
|
|
478
|
+
events,
|
|
479
|
+
sites,
|
|
480
|
+
ticks: end.tick,
|
|
481
|
+
hash: end.hash,
|
|
482
|
+
};
|
|
483
|
+
return { recording, reads: readFileSync(reads), coverage: readFileSync(coverage), ...(reason === undefined ? {} : { reason }) };
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/** One side of a replay. */
|
|
487
|
+
export interface ReplaySubject {
|
|
488
|
+
side: "original" | "rebuild";
|
|
489
|
+
program: string;
|
|
490
|
+
symbols: string;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
export interface SideRun {
|
|
494
|
+
/** The checkpoint lines: the tick, then the hex of each state item. */
|
|
495
|
+
samples: string[];
|
|
496
|
+
/** The machine at the end of the run. */
|
|
497
|
+
end: CoreStatus;
|
|
498
|
+
/** Why the side stopped before its last checkpoint, if it did. */
|
|
499
|
+
problem?: string;
|
|
500
|
+
/**
|
|
501
|
+
* Input reads that the recording has no value for, which got the fixed idle value:
|
|
502
|
+
* at sites that it does not have, and after its end at a site.
|
|
503
|
+
*/
|
|
504
|
+
unknown: number;
|
|
505
|
+
misses: number;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/**
|
|
509
|
+
* Replays a recording on one side. The original runs to the end of the
|
|
510
|
+
* session; a rebuild runs until `stop` checkpoints (or REBUILD_TIME_FACTOR
|
|
511
|
+
* times the cycles). With `dump`, it stops at that checkpoint (1 = the first)
|
|
512
|
+
* and gives the RAM there.
|
|
513
|
+
*/
|
|
514
|
+
export async function replaySide(
|
|
515
|
+
core: C64RunSession,
|
|
516
|
+
recording: Recording,
|
|
517
|
+
folder: string,
|
|
518
|
+
subject: ReplaySubject,
|
|
519
|
+
workspace: Workspace,
|
|
520
|
+
options: { compare?: Comparison; stop?: number; dump?: number; pins?: string[] } = {},
|
|
521
|
+
): Promise<SideRun & { ram?: Uint8Array }> {
|
|
522
|
+
const prg = readProjectFile(subject.program).bytes;
|
|
523
|
+
const original = subject.side === "original";
|
|
524
|
+
// The names of the original: a recording without names takes those of the knowledge.
|
|
525
|
+
const names = recording.symbols === "none" ? "knowledge" : recording.symbols;
|
|
526
|
+
const same = (address: number) => address;
|
|
527
|
+
const move = original
|
|
528
|
+
? { 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));
|
|
532
|
+
const map = new Map(moved);
|
|
533
|
+
const reads = workspace.path(`${subject.side}-${RECORDING_FILES.reads}`);
|
|
534
|
+
writeFileSync(reads, readProjectFile(`${folder}/${RECORDING_FILES.reads}`).bytes);
|
|
535
|
+
const samples = workspace.path(`${subject.side}-checkpoints.txt`);
|
|
536
|
+
const mapFile = workspace.path(`${subject.side}-sites.map`);
|
|
537
|
+
writeFileSync(mapFile, moved.map(([from, to]) => `${hex(from)} ${hex(to)}\n`).join(""));
|
|
538
|
+
await core.init();
|
|
539
|
+
await core.command(`inputs ${inputsArgument(recording.inputs)}`);
|
|
540
|
+
await core.command(`site-map ${mapFile}`);
|
|
541
|
+
await core.command(`replay ${reads}`);
|
|
542
|
+
if (options.compare !== undefined) {
|
|
543
|
+
const stop = options.dump ?? options.stop ?? 0;
|
|
544
|
+
const pinned = options.compare.pin ?? [];
|
|
545
|
+
if (options.pins !== undefined && pinned.length > 0) {
|
|
546
|
+
// The rebuild: each checkpoint first gets the original's values of the random state.
|
|
547
|
+
const pinFile = workspace.path(`${subject.side}-pins.txt`);
|
|
548
|
+
writeFileSync(pinFile, options.pins.map((line) => `${line}\n`).join(""));
|
|
549
|
+
await core.command(`pin ${pinFile} ${rangesArgument(pinned, at)}`);
|
|
550
|
+
}
|
|
551
|
+
// The samples hold the state, then the pinned items.
|
|
552
|
+
await core.command(`sample ${hex(at.code(options.compare.checkpoint))} ${samples} ${rangesArgument([...options.compare.state, ...pinned], at)}${stop > 0 ? ` ${stop}` : ""}`);
|
|
553
|
+
}
|
|
554
|
+
await startProgram(core, prg, recording.load);
|
|
555
|
+
const limit = original ? recording.ticks : recording.ticks * REBUILD_TIME_FACTOR;
|
|
556
|
+
let problem: string | undefined;
|
|
557
|
+
for (const event of recording.events) {
|
|
558
|
+
const point = await runToKey(core, event.key, limit, map);
|
|
559
|
+
if (point === "halted") break;
|
|
560
|
+
if (point === "not-reached") {
|
|
561
|
+
problem = `the program did not come to the point of step ${event.step}`;
|
|
562
|
+
break;
|
|
563
|
+
}
|
|
564
|
+
problem = await doAction(core, event.action, at);
|
|
565
|
+
if (problem !== undefined) {
|
|
566
|
+
problem = `step ${event.step}: ${problem}`;
|
|
567
|
+
break;
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
const end = problem === undefined ? await core.status(`run ${limit}`) : await core.status("status");
|
|
571
|
+
const sites = await core.command("sites");
|
|
572
|
+
const unknown = Number(/unknown=(\d+)/.exec(sites)?.[1] ?? 0);
|
|
573
|
+
const misses = sites.split(" ").slice(2).reduce((sum, entry) => sum + Number(entry.split(":")[4] ?? 0), 0);
|
|
574
|
+
const ram = options.dump === undefined ? undefined : await core.peek(0, 0x10000);
|
|
575
|
+
let lines: string[] = [];
|
|
576
|
+
if (options.compare !== undefined) {
|
|
577
|
+
await core.command("pin off");
|
|
578
|
+
await core.command("sample off");
|
|
579
|
+
lines = readFileSync(samples, "utf8").split("\n").filter((line) => line !== "");
|
|
580
|
+
}
|
|
581
|
+
return { samples: lines, end, unknown, misses, ...(problem === undefined ? {} : { problem }), ...(ram === undefined ? {} : { ram }) };
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
export interface CheckpointDifference {
|
|
585
|
+
/** 1 = the first checkpoint. */
|
|
586
|
+
checkpoint: number;
|
|
587
|
+
tick: { original: number; rebuild: number };
|
|
588
|
+
items: Array<{ at: Location; original: string; rebuild: string }>;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/** The pinned values of each sample line: the fields after the state items. */
|
|
592
|
+
export function pinLines(samples: string[], state: StateItem[]): string[] {
|
|
593
|
+
return samples.map((line) => line.split(" ").slice(1 + state.length).join(" "));
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
/**
|
|
597
|
+
* The first checkpoint where two runs differ in the state, or undefined when
|
|
598
|
+
* the first `count` are the same. Fields after the state items (pins) do not count.
|
|
599
|
+
*/
|
|
600
|
+
export function firstDifference(expected: string[], got: string[], state: StateItem[], count = expected.length): CheckpointDifference | undefined {
|
|
601
|
+
for (let index = 0; index < Math.min(count, expected.length, got.length); index++) {
|
|
602
|
+
const [tickA, ...allA] = expected[index]!.split(" ");
|
|
603
|
+
const [tickB, ...allB] = got[index]!.split(" ");
|
|
604
|
+
const a = allA.slice(0, state.length);
|
|
605
|
+
const b = allB.slice(0, state.length);
|
|
606
|
+
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] ?? "" }]));
|
|
608
|
+
return { checkpoint: index + 1, tick: { original: Number(tickA), rebuild: Number(tickB) }, items };
|
|
609
|
+
}
|
|
610
|
+
return undefined;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
export interface ReplayResult {
|
|
614
|
+
result: Outcome;
|
|
615
|
+
recording: string;
|
|
616
|
+
/** The side that the result is about: the original (the self-check) or the rebuild. */
|
|
617
|
+
side: "original" | "rebuild";
|
|
618
|
+
/** The routine of the checkpoints, and the checkpoints of the original. */
|
|
619
|
+
checkpoint?: Location;
|
|
620
|
+
checkpoints: number;
|
|
621
|
+
/** The checkpoints of the rebuild that were equal. */
|
|
622
|
+
compared: number;
|
|
623
|
+
reason?: string;
|
|
624
|
+
firstDifference?: CheckpointDifference;
|
|
625
|
+
movedSites?: number;
|
|
626
|
+
/** Input reads of the rebuild that the recording had no value for: they got the idle value. */
|
|
627
|
+
idleReads?: number;
|
|
628
|
+
files?: string[];
|
|
629
|
+
}
|