@henols/c64-re-tools 2.0.0-rc.2 → 2.0.0-rc.4

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 (75) hide show
  1. package/README.md +19 -59
  2. package/package.json +1 -1
  3. package/skills/c64-assembler/SKILL.md +5 -2
  4. package/skills/c64-assembler/scripts/assemble.ts +8 -1
  5. package/skills/c64-assembler/scripts/compare.ts +84 -0
  6. package/skills/c64-basic/SKILL.md +1 -2
  7. package/skills/c64-disk/SKILL.md +20 -6
  8. package/skills/c64-disk/scripts/containers.ts +124 -0
  9. package/skills/c64-disk/scripts/disk.ts +59 -5
  10. package/skills/c64-emulator/SKILL.md +11 -4
  11. package/skills/c64-knowledge/SKILL.md +53 -3
  12. package/skills/c64-knowledge/scripts/knowledge.ts +258 -29
  13. package/skills/c64-memory-map/SKILL.md +4 -1
  14. package/skills/c64-memory-map/scripts/memmap.ts +14 -3
  15. package/skills/c64-provenance/SKILL.md +37 -9
  16. package/skills/c64-provenance/scripts/compare.ts +60 -29
  17. package/skills/c64-provenance/scripts/provenance.ts +248 -19
  18. package/skills/c64-provenance/scripts/runtime.ts +179 -0
  19. package/skills/c64-provenance/scripts/separate.ts +132 -0
  20. package/skills/c64-reverse-engineering/SKILL.md +65 -11
  21. package/skills/c64-source/SKILL.md +119 -0
  22. package/skills/c64-source/scripts/generate.ts +402 -0
  23. package/skills/c64-source/scripts/loader.ts +320 -0
  24. package/skills/c64-source/scripts/opcodes.ts +61 -0
  25. package/skills/c64-source/scripts/source.ts +558 -0
  26. package/skills/c64-static-analysis/SKILL.md +27 -4
  27. package/skills/c64-static-analysis/scripts/analyze.ts +102 -13
  28. package/skills/c64-static-analysis/scripts/coverage.ts +78 -0
  29. package/skills/c64-static-analysis/scripts/findings.ts +6 -4
  30. package/skills/c64-static-analysis/scripts/idioms.ts +144 -0
  31. package/skills/c64-static-analysis/scripts/instructions.ts +105 -0
  32. package/skills/c64-testing/SKILL.md +27 -3
  33. package/skills/c64-testing/references/scenario-format.md +43 -3
  34. package/skills/c64-testing/scripts/run.ts +189 -0
  35. package/skills/c64-testing/scripts/scenario.ts +153 -135
  36. package/skills/c64-testing/scripts/test.ts +16 -6
  37. package/skills/c64-unpacker/SKILL.md +65 -14
  38. package/skills/c64-unpacker/scripts/evidence.ts +86 -21
  39. package/skills/c64-unpacker/scripts/loads.ts +174 -0
  40. package/skills/c64-unpacker/scripts/machine.ts +68 -0
  41. package/skills/c64-unpacker/scripts/unpack.ts +302 -48
  42. package/src/c64.ts +91 -0
  43. package/src/cli/status.ts +3 -1
  44. package/src/host/main.ts +10 -25
  45. package/src/host/server.ts +8 -40
  46. package/src/host/vice/adapter.ts +25 -10
  47. package/src/host/vice/session.ts +64 -20
  48. package/src/host-client/autostart.ts +45 -0
  49. package/src/host-client/connect.ts +4 -5
  50. package/src/host-client/coverage.ts +59 -0
  51. package/src/host-client/steps.ts +170 -0
  52. package/src/host-client/transfer.ts +29 -3
  53. package/src/host-client/vice-session.ts +42 -13
  54. package/src/knowledge/history.ts +40 -13
  55. package/src/knowledge/import.ts +72 -56
  56. package/src/knowledge/read.ts +203 -52
  57. package/src/knowledge/schema.ts +94 -12
  58. package/src/knowledge/write.ts +432 -123
  59. package/src/mcp/main.ts +1 -4
  60. package/src/mcp/output.ts +32 -0
  61. package/src/mcp/server.ts +4 -2
  62. package/src/mcp/tools/debug.ts +110 -24
  63. package/src/mcp/tools/input.ts +1 -0
  64. package/src/mcp/tools/media.ts +22 -6
  65. package/src/mcp/tools/memory.ts +57 -10
  66. package/src/mcp/tools/video.ts +9 -3
  67. package/src/native/discover.ts +5 -2
  68. package/src/native/ghidra/index.ts +19 -3
  69. package/src/native/ghidra/scripts/C64Export.java +33 -1
  70. package/src/native/status.ts +15 -3
  71. package/src/native/types.ts +30 -1
  72. package/{skills/c64-memory-map/scripts → src}/platform.ts +18 -2
  73. package/src/protocol/messages.ts +17 -10
  74. package/src/protocol/vice.ts +72 -18
  75. package/src/script.ts +3 -2
@@ -3,19 +3,11 @@
3
3
  // Observations name semantic symbols that resolve per side, comparisons are
4
4
  // explicit, and the result is PASS, FAIL or INCONCLUSIVE.
5
5
 
6
- import { formatC64Address, parseC64Address, textToPetscii } from "#src/c64.ts";
7
- import {
8
- JOYSTICK_DIRECTIONS,
9
- MAX_BASELINES,
10
- MAX_EXECUTION_COUNT,
11
- MAX_KEYBOARD_BYTES,
12
- MAX_MASK_RECTANGLES,
13
- MAX_MEMORY_READ,
14
- MAX_MEMORY_WRITE,
15
- MAX_TIMEOUT_FRAMES,
16
- WireFailure,
17
- } from "#src/host-client/tools.ts";
18
- import { readProjectFile } from "#src/host-client/transfer.ts";
6
+ import { formatC64Address, parseC64Address } from "#src/c64.ts";
7
+ import { MAX_BASELINES, MAX_MASK_RECTANGLES, MAX_MEMORY_READ, WireFailure } from "#src/host-client/tools.ts";
8
+ import { LOAD_LIMIT_FRAMES, RUN_LIMIT_FRAMES, runProgram } from "#src/host-client/autostart.ts";
9
+ import { ACTION_STEP_KEYS, checkActionStep, checkLocation, isInteger, isObject, runActionStep, type ActionStep, type Location } from "#src/host-client/steps.ts";
10
+ import { readProjectFile, writeProjectFile } from "#src/host-client/transfer.ts";
19
11
  import type { ViceSessionClient } from "#src/host-client/vice-session.ts";
20
12
  import { openForRead } from "#src/knowledge/database.ts";
21
13
  import { listSymbols } from "#src/knowledge/read.ts";
@@ -26,7 +18,7 @@ export type Side = "original" | "rebuild";
26
18
  export const SIDES: Side[] = ["original", "rebuild"];
27
19
 
28
20
  /** A "$xxxx" address or a symbol name that each side resolves on its own. */
29
- export type Location = string;
21
+ export type { Location };
30
22
 
31
23
  export interface MemoryObservation {
32
24
  at: Location;
@@ -34,6 +26,8 @@ export interface MemoryObservation {
34
26
  size?: number;
35
27
  /** The largest allowed difference per byte; 0 when not given. */
36
28
  tolerance?: number;
29
+ /** Bytes in the range that the comparison leaves out, each with the reason (D46). */
30
+ exclude?: Array<{ at: Location; reason: string }>;
37
31
  }
38
32
 
39
33
  export interface ScreenPolicy {
@@ -43,18 +37,18 @@ export interface ScreenPolicy {
43
37
  mask?: Array<{ x: number; y: number; width: number; height: number }>;
44
38
  }
45
39
 
46
- export type Step =
40
+ type StepBody =
47
41
  | { reset: "hard" | "soft" }
48
42
  | { load: true }
49
43
  | { autostart: true }
50
- | { write: { at: Location; bytes: number[] } }
51
- | { registers: Partial<Record<"pc" | "a" | "x" | "y" | "sp", number | Location>> }
52
- | { joystick: { port: 1 | 2; direction: JoystickDirection; fire?: boolean } }
53
- | { type: string }
54
- | { frames: number }
55
- | { runUntil: { at: Location } | { memory: { at: Location; equals: number } }; timeoutFrames: number }
44
+ | { snapshot: string }
45
+ | { normalize: string }
46
+ | ActionStep
56
47
  | { observe: string; memory?: Array<Location | MemoryObservation>; registers?: ObservedRegister[]; screen?: ScreenPolicy; vicii?: boolean; sprites?: number[] };
57
48
 
49
+ /** A step; with side, only that side does it (D46). An observe step has no side: it compares both. */
50
+ export type Step = StepBody & { side?: Side };
51
+
58
52
  export interface Subject {
59
53
  program: string;
60
54
  /** "knowledge" for knowledge.db, a project JSON file (c64-assembler output or a name → "$xxxx" map), or "none". */
@@ -85,26 +79,23 @@ export interface ScenarioResult {
85
79
  reason?: string;
86
80
  checkpoints: string[];
87
81
  differences: Difference[];
82
+ /** The bytes that the comparison left out, with their reasons (D46). */
83
+ excluded?: Array<{ checkpoint: string; at: Location; reason: string }>;
84
+ /** The files that a FAIL wrote with --out. */
85
+ files?: string[];
88
86
  }
89
87
 
90
- const STEP_KINDS = ["reset", "load", "autostart", "write", "registers", "joystick", "type", "frames", "runUntil", "observe"];
88
+ const STEP_KINDS = ["reset", "load", "autostart", "snapshot", "normalize", "write", "registers", "joystick", "type", "frames", "runUntil", "observe"];
91
89
 
92
90
  /** The keys each kind of step can have. */
93
91
  const STEP_KEYS: Record<string, readonly string[]> = {
94
- runUntil: ["runUntil", "timeoutFrames"],
92
+ ...ACTION_STEP_KEYS,
95
93
  observe: ["observe", "memory", "registers", "screen", "vicii", "sprites"],
96
94
  };
97
95
 
98
96
  /** The registers an observe step can compare. */
99
97
  export const OBSERVED_REGISTERS = ["pc", "a", "x", "y", "sp", "flags"] as const;
100
98
  type ObservedRegister = (typeof OBSERVED_REGISTERS)[number];
101
- /** The registers a registers step can set. */
102
- const SET_REGISTERS = ["pc", "a", "x", "y", "sp"];
103
-
104
- type JoystickDirection = (typeof JOYSTICK_DIRECTIONS)[number];
105
-
106
- const isObject = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null && !Array.isArray(value);
107
- const isInteger = (value: unknown, min: number, max: number): value is number => Number.isInteger(value) && (value as number) >= min && (value as number) <= max;
108
99
 
109
100
  /** Reads and checks a scenario file. Throws ScenarioError naming the first problem. */
110
101
  export function readScenario(path: string): Scenario {
@@ -135,8 +126,9 @@ export function readScenario(path: string): Scenario {
135
126
  const kinds = "observe" in step ? ["observe"] : Object.keys(step).filter((key) => STEP_KINDS.includes(key));
136
127
  if (kinds.length !== 1) return fail(`${at} must have exactly one of ${STEP_KINDS.join(", ")}`);
137
128
  const kind = kinds[0]!;
138
- const allowed = STEP_KEYS[kind] ?? [kind];
129
+ const allowed = [...(STEP_KEYS[kind] ?? [kind]), ...(kind === "observe" ? [] : ["side"])];
139
130
  for (const key of Object.keys(step)) if (!allowed.includes(key)) fail(`${at} has the key ${key}, which a ${kind} step does not have`);
131
+ if (step.side !== undefined && !(SIDES as unknown[]).includes(step.side)) fail(`${at}: side must be original or rebuild`);
140
132
  checkStep(kind, step, at, fail);
141
133
  if (kind === "observe") {
142
134
  if (labels.has(step.observe as string)) fail(`${at} needs a new checkpoint name`);
@@ -149,18 +141,6 @@ export function readScenario(path: string): Scenario {
149
141
  return scenario;
150
142
  }
151
143
 
152
- /** Checks a "$xxxx" address or a symbol name. */
153
- function checkLocation(value: unknown, what: string, fail: (message: string) => never): void {
154
- if (typeof value !== "string" || value === "") fail(`${what} must be a $ address or a symbol name`);
155
- if ((value as string).startsWith("$")) {
156
- try {
157
- parseC64Address(value as string);
158
- } catch {
159
- fail(`${what} must be $ followed by four hex digits, not ${value as string}`);
160
- }
161
- }
162
- }
163
-
164
144
  /** Checks the value of one step against its kind. */
165
145
  function checkStep(kind: string, step: Record<string, unknown>, at: string, fail: (message: string) => never): void {
166
146
  switch (kind) {
@@ -171,60 +151,19 @@ function checkStep(kind: string, step: Record<string, unknown>, at: string, fail
171
151
  case "autostart":
172
152
  if (step[kind] !== true) fail(`${at}: ${kind} must be true`);
173
153
  return;
174
- case "write": {
175
- const write = step.write;
176
- if (!isObject(write) || Object.keys(write).some((key) => key !== "at" && key !== "bytes")) return fail(`${at}: write needs at and bytes, and nothing else`);
177
- checkLocation(write.at, `${at}: write.at`, fail);
178
- if (!Array.isArray(write.bytes) || write.bytes.length === 0 || write.bytes.length > MAX_MEMORY_WRITE || !write.bytes.every((byte) => isInteger(byte, 0, 0xff))) {
179
- fail(`${at}: write.bytes must be a list of 1 to ${MAX_MEMORY_WRITE} numbers from 0 to 255`);
180
- }
181
- return;
182
- }
183
- case "registers": {
184
- const registers = step.registers;
185
- if (!isObject(registers) || Object.keys(registers).length === 0) return fail(`${at}: registers must name at least one of ${SET_REGISTERS.join(", ")}`);
186
- for (const [name, value] of Object.entries(registers)) {
187
- if (!SET_REGISTERS.includes(name)) fail(`${at}: registers can set only ${SET_REGISTERS.join(", ")}, not ${name}`);
188
- if (typeof value === "string") checkLocation(value, `${at}: registers.${name}`, fail);
189
- else if (!isInteger(value, 0, name === "pc" ? 0xffff : 0xff)) fail(`${at}: registers.${name} must be a symbol name, a $ address or a number from 0 to ${name === "pc" ? 65535 : 255}`);
190
- }
191
- return;
192
- }
193
- case "joystick": {
194
- const joystick = step.joystick;
195
- if (!isObject(joystick) || Object.keys(joystick).some((key) => !["port", "direction", "fire"].includes(key))) return fail(`${at}: joystick needs port and direction, and fire is optional`);
196
- if (joystick.port !== 1 && joystick.port !== 2) fail(`${at}: joystick.port must be 1 or 2`);
197
- if (!(JOYSTICK_DIRECTIONS as readonly unknown[]).includes(joystick.direction)) fail(`${at}: joystick.direction must be one of ${JOYSTICK_DIRECTIONS.join(", ")}`);
198
- if (joystick.fire !== undefined && typeof joystick.fire !== "boolean") fail(`${at}: joystick.fire must be true or false`);
154
+ case "snapshot":
155
+ if (typeof step.snapshot !== "string" || step.snapshot === "") fail(`${at}: snapshot needs a .vsf file in the project`);
199
156
  return;
200
- }
201
- case "type": {
202
- if (typeof step.type !== "string" || step.type === "") return fail(`${at}: type needs text`);
203
- let bytes: Uint8Array;
204
- try {
205
- bytes = textToPetscii(step.type);
206
- } catch (error) {
207
- return fail(`${at}: ${(error as Error).message}`);
208
- }
209
- if (bytes.length > MAX_KEYBOARD_BYTES) fail(`${at}: type can have at most ${MAX_KEYBOARD_BYTES} keys`);
157
+ case "normalize":
158
+ if (typeof step.normalize !== "string" || step.normalize === "") fail(`${at}: normalize needs an entry state file in the project`);
210
159
  return;
211
- }
160
+ case "write":
161
+ case "registers":
162
+ case "joystick":
163
+ case "type":
212
164
  case "frames":
213
- if (!isInteger(step.frames, 1, MAX_EXECUTION_COUNT)) fail(`${at} needs a frame count from 1 to ${MAX_EXECUTION_COUNT}`);
214
- return;
215
- case "runUntil": {
216
- const target = step.runUntil;
217
- if (!isObject(target) || Object.keys(target).length !== 1 || !("at" in target || "memory" in target)) return fail(`${at}: runUntil needs at or memory`);
218
- if ("at" in target) checkLocation(target.at, `${at}: runUntil.at`, fail);
219
- else {
220
- const memory = target.memory;
221
- if (!isObject(memory) || Object.keys(memory).some((key) => key !== "at" && key !== "equals")) return fail(`${at}: runUntil.memory needs at and equals, and nothing else`);
222
- checkLocation(memory.at, `${at}: runUntil.memory.at`, fail);
223
- if (!isInteger(memory.equals, 0, 0xff)) fail(`${at}: runUntil.memory.equals must be a number from 0 to 255`);
224
- }
225
- if (!isInteger(step.timeoutFrames, 1, MAX_TIMEOUT_FRAMES)) fail(`${at} needs timeoutFrames from 1 to ${MAX_TIMEOUT_FRAMES}`);
226
- return;
227
- }
165
+ case "runUntil":
166
+ return checkActionStep(kind, step, at, fail);
228
167
  default:
229
168
  checkObserve(step, at, fail);
230
169
  }
@@ -239,7 +178,15 @@ function checkObserve(step: Record<string, unknown>, at: string, fail: (message:
239
178
  checkLocation(item, `${at}: memory`, fail);
240
179
  continue;
241
180
  }
242
- if (!isObject(item) || Object.keys(item).some((key) => !["at", "size", "tolerance"].includes(key))) return fail(`${at}: a memory object has at, size and tolerance, and nothing else`);
181
+ if (!isObject(item) || Object.keys(item).some((key) => !["at", "size", "tolerance", "exclude"].includes(key))) return fail(`${at}: a memory object has at, size, tolerance and exclude, and nothing else`);
182
+ if (item.exclude !== undefined) {
183
+ if (!Array.isArray(item.exclude) || item.exclude.length === 0) fail(`${at}: memory.exclude must be a list of objects with at and reason`);
184
+ for (const excluded of item.exclude as unknown[]) {
185
+ if (!isObject(excluded) || Object.keys(excluded).some((key) => key !== "at" && key !== "reason")) return fail(`${at}: an exclude has at and reason, and nothing else`);
186
+ checkLocation(excluded.at, `${at}: memory.exclude.at`, fail);
187
+ if (typeof excluded.reason !== "string" || excluded.reason.trim() === "") fail(`${at}: an exclude needs a reason: why this byte may differ`);
188
+ }
189
+ }
243
190
  checkLocation(item.at, `${at}: memory.at`, fail);
244
191
  if (item.size !== undefined && !isInteger(item.size, 1, MAX_MEMORY_READ)) fail(`${at}: memory.size must be a number from 1 to ${MAX_MEMORY_READ}`);
245
192
  if (item.tolerance !== undefined && !isInteger(item.tolerance, 0, 0xff)) fail(`${at}: memory.tolerance must be a number from 0 to 255`);
@@ -334,11 +281,34 @@ export function resolve(location: Location, symbols: Map<string, number>, side:
334
281
  }
335
282
 
336
283
  interface Observation {
337
- memory: Array<{ label: string; bytes: number[]; tolerance: number }>;
284
+ memory: Array<{ label: string; address: number; bytes: number[]; tolerance: number; excluded: Array<{ offset: number; at: Location; reason: string }> }>;
338
285
  registers: Partial<Record<ObservedRegister, unknown>>;
339
286
  screen?: { match: boolean; mismatchingPixels: number; mismatchRatio: number; bounds?: unknown };
340
287
  vicii?: unknown;
341
288
  sprites?: unknown;
289
+ /** PNG images for the files of a FAIL (D46): the side's screen, and on the rebuild the difference image. */
290
+ png?: string;
291
+ diffPng?: string;
292
+ }
293
+
294
+ /** Writes pages 0-3 and $00/$01 from an entry state file (D43, D46): both sides then start from the same RAM. */
295
+ async function normalize(client: ViceSessionClient, path: string): Promise<void> {
296
+ let state: { pages?: Record<string, string>; cpuPort?: Record<string, number> };
297
+ try {
298
+ state = JSON.parse(readProjectFile(path).bytes.toString("utf8")) as typeof state;
299
+ } catch (error) {
300
+ if (error instanceof WireFailure) throw error;
301
+ throw new ScenarioError(`${path} is not an entry state file: ${(error as Error).message}`);
302
+ }
303
+ const pages = ["$0000", "$0100", "$0200", "$0300"].map((page) => state.pages?.[page]);
304
+ const port = [state.cpuPort?.$00, state.cpuPort?.$01];
305
+ if (pages.some((page) => typeof page !== "string" || !/^[0-9a-f]{512}$/.test(page)) || port.some((value) => !isInteger(value, 0, 0xff))) {
306
+ throw new ScenarioError(`${path} is not an entry state file: it needs pages $0000 to $0300 and cpuPort $00 and $01`);
307
+ }
308
+ if ((await client.status()).state === "running") await client.execution({ action: "pause", space: "c64" });
309
+ // Pages 0 to 3 in RAM from $0002, then the CPU port: the port bits switch what the CPU sees.
310
+ await client.memoryWrite({ address: 0x0002, data: pages.join("").slice(4), space: "c64", view: "ram" });
311
+ await client.memoryWrite({ address: 0x0000, data: Buffer.from(port as number[]).toString("hex"), space: "c64", view: "cpu" });
342
312
  }
343
313
 
344
314
  /**
@@ -350,27 +320,39 @@ export function baselineName(checkpoint: number, label: string): string {
350
320
  return `test-${checkpoint}-${label}`.replace(/[^A-Za-z0-9._-]/g, "_").slice(0, 64);
351
321
  }
352
322
 
353
- async function runSide(client: ViceSessionClient, scenario: Scenario, side: Side, symbols: Map<string, number>): Promise<Map<string, Observation>> {
323
+ /** Autostart that returns only when the program runs (runProgram), or INCONCLUSIVE with the reason. */
324
+ async function startProgram(client: ViceSessionClient, program: string, side: Side): Promise<void> {
325
+ const failed = await runProgram(client, program, readProjectFile(program).bytes);
326
+ if (failed === "not-loaded") throw new Inconclusive(`the ${side} program was not in memory after ${LOAD_LIMIT_FRAMES} frames of autostart`);
327
+ if (failed === "not-started") throw new Inconclusive(`the ${side} program did not start within ${RUN_LIMIT_FRAMES} frames of RUN; autostart runs only a program with a BASIC start line`);
328
+ }
329
+
330
+ async function runSide(client: ViceSessionClient, scenario: Scenario, side: Side, symbols: Map<string, number>, files: boolean): Promise<Map<string, Observation>> {
354
331
  const subject = scenario[side];
355
332
  const observations = new Map<string, Observation>();
356
333
  const at = (location: Location) => resolve(location, symbols, side);
357
- const stop = async () => {
358
- if ((await client.status()).state === "running") await client.execution({ action: "pause", space: "c64" });
359
- };
360
334
  // Each side starts from a hard reset, stopped at the reset vector, with the control ports released.
361
335
  await client.reset({ mode: "hard", run: false });
362
336
  await client.joystick({ port: 1, direction: "center", fire: false });
363
337
  await client.joystick({ port: 2, direction: "center", fire: false });
364
338
  let checkpoint = 0;
365
339
  for (const step of scenario.steps) {
340
+ if (step.side !== undefined && step.side !== side) continue;
366
341
  // Observe first: an observe step may also carry "registers" or "memory".
367
342
  if ("observe" in step) {
368
343
  checkpoint++;
369
344
  const observation: Observation = { memory: [], registers: {} };
370
345
  for (const item of step.memory ?? []) {
371
- const spec = typeof item === "string" ? { at: item } : item;
372
- const read = await client.memoryRead({ address: at(spec.at), size: spec.size ?? 1, space: "c64", view: "cpu" });
373
- observation.memory.push({ label: spec.size !== undefined && spec.size > 1 ? `${spec.at} (${spec.size} bytes)` : spec.at, bytes: [...Buffer.from(read.data, "hex")], tolerance: spec.tolerance ?? 0 });
346
+ const spec: MemoryObservation = typeof item === "string" ? { at: item } : item;
347
+ const address = at(spec.at);
348
+ const size = spec.size ?? 1;
349
+ const read = await client.memoryRead({ address, size, space: "c64", view: "cpu" });
350
+ const excluded = (spec.exclude ?? []).map((exclude) => {
351
+ const offset = at(exclude.at) - address;
352
+ if (offset < 0 || offset >= size) throw new ScenarioError(`the exclude ${exclude.at} lies outside the observed ${spec.at} (${size} bytes)`);
353
+ return { offset, at: exclude.at, reason: exclude.reason };
354
+ });
355
+ observation.memory.push({ label: size > 1 ? `${spec.at} (${size} bytes)` : spec.at, address, bytes: [...Buffer.from(read.data, "hex")], tolerance: spec.tolerance ?? 0, excluded });
374
356
  }
375
357
  if (step.registers !== undefined) {
376
358
  const registers = await client.registersGet("c64");
@@ -385,10 +367,15 @@ async function runSide(client: ViceSessionClient, scenario: Scenario, side: Side
385
367
  if (step.screen !== undefined) {
386
368
  const baseline = baselineName(checkpoint, step.observe);
387
369
  if (side === "original") {
388
- await client.screenCapture(baseline);
370
+ const shot = await client.screenCapture(baseline);
371
+ if (files) observation.png = shot.png;
389
372
  } else {
390
- const compared = await client.screenCompare({ baseline, maxMismatchRatio: step.screen.maxMismatchRatio ?? 0, mask: step.screen.mask ?? [], includeDiff: false });
373
+ const compared = await client.screenCompare({ baseline, maxMismatchRatio: step.screen.maxMismatchRatio ?? 0, mask: step.screen.mask ?? [], includeDiff: files });
391
374
  observation.screen = { match: compared.match, mismatchingPixels: compared.mismatchingPixels, mismatchRatio: compared.mismatchRatio, ...(compared.bounds === undefined ? {} : { bounds: compared.bounds }) };
375
+ if (files) {
376
+ observation.png = (await client.screenCapture()).png;
377
+ if (compared.diffPng !== undefined) observation.diffPng = compared.diffPng;
378
+ }
392
379
  await client.screenDiscard(baseline);
393
380
  }
394
381
  }
@@ -399,30 +386,16 @@ async function runSide(client: ViceSessionClient, scenario: Scenario, side: Side
399
386
  } else if ("load" in step) {
400
387
  await client.programLoad({ path: subject.program });
401
388
  } else if ("autostart" in step) {
402
- await client.autostart({ path: subject.program, index: 0, run: true });
403
- } else if ("write" in step) {
404
- await stop();
405
- await client.memoryWrite({ address: at(step.write.at), data: Buffer.from(step.write.bytes).toString("hex"), space: "c64", view: "cpu" });
406
- } else if ("registers" in step) {
407
- await stop();
408
- const values: Record<string, number> = {};
409
- for (const [name, value] of Object.entries(step.registers)) values[name] = typeof value === "number" ? value : at(value as Location);
410
- await client.registersSet("c64", values);
411
- } else if ("joystick" in step) {
412
- await client.joystick({ port: step.joystick.port, direction: step.joystick.direction, fire: step.joystick.fire ?? false });
413
- } else if ("type" in step) {
414
- await client.keyboard(textToPetscii(step.type));
415
- } else if ("frames" in step) {
416
- await stop();
417
- await client.execution({ action: "advance-frames", count: step.frames, space: "c64" });
418
- } else if ("runUntil" in step) {
419
- const target =
420
- "at" in step.runUntil
421
- ? { kind: "address" as const, address: at(step.runUntil.at), space: "c64" as const }
422
- : { kind: "memory" as const, address: at(step.runUntil.memory.at), operator: "eq" as const, value: step.runUntil.memory.equals, space: "c64" as const, view: "cpu" as const };
423
- const reached = await client.runUntil({ target, timeoutFrames: step.timeoutFrames });
424
- if (!reached.reached) {
425
- throw new Inconclusive(`the ${side} side did not reach the checkpoint before ${step.timeoutFrames} frames (it stopped by ${reached.stopReason} at ${formatC64Address(reached.pc)})`);
389
+ await startProgram(client, subject.program, side);
390
+ } else if ("snapshot" in step) {
391
+ await client.snapshotRestore(readProjectFile(step.snapshot).bytes);
392
+ } else if ("normalize" in step) {
393
+ await normalize(client, step.normalize);
394
+ } else {
395
+ const { side: _side, ...action } = step;
396
+ const notReached = await runActionStep(client, action as ActionStep, at);
397
+ if (notReached !== undefined && "runUntil" in step) {
398
+ throw new Inconclusive(`the ${side} side did not reach the checkpoint before ${step.timeoutFrames} frames (it stopped by ${notReached.stopReason} at ${formatC64Address(notReached.pc)})`);
426
399
  }
427
400
  }
428
401
  }
@@ -436,7 +409,8 @@ export function compare(original: Map<string, Observation>, rebuild: Map<string,
436
409
  const right = rebuild.get(checkpoint)!;
437
410
  left.memory.forEach((item, index) => {
438
411
  const other = right.memory[index]!;
439
- if (item.bytes.some((byte, at) => Math.abs(byte - other.bytes[at]!) > item.tolerance)) {
412
+ const skipped = new Set(item.excluded.map((excluded) => excluded.offset));
413
+ if (item.bytes.some((byte, at) => !skipped.has(at) && Math.abs(byte - other.bytes[at]!) > item.tolerance)) {
440
414
  const show = (bytes: number[]) => (bytes.length === 1 ? bytes[0] : Buffer.from(bytes).toString("hex"));
441
415
  differences.push({ checkpoint, what: `memory ${item.label}`, original: show(item.bytes), rebuild: show(other.bytes) });
442
416
  }
@@ -453,13 +427,46 @@ export function compare(original: Map<string, Observation>, rebuild: Map<string,
453
427
  return differences;
454
428
  }
455
429
 
456
- /** Runs the scenario on the original, then on the rebuild, in one VICE session. */
457
- export async function runScenario(client: ViceSessionClient, scenario: Scenario): Promise<ScenarioResult> {
430
+ /**
431
+ * Writes the files of a FAIL into `out` (D46): for a screen difference both
432
+ * screens and the difference image, and memory.json with each differing byte.
433
+ */
434
+ function writeFailFiles(out: string, original: Map<string, Observation>, rebuild: Map<string, Observation>, differences: Difference[]): string[] {
435
+ const files: string[] = [];
436
+ const write = (name: string, bytes: Uint8Array) => {
437
+ writeProjectFile(`${out}/${name}`, bytes, { what: "--out" });
438
+ files.push(name);
439
+ };
440
+ const memory: Array<{ checkpoint: string; what: string; bytes: Array<{ address: string; original: number; rebuild: number }> }> = [];
441
+ for (const difference of differences) {
442
+ const left = original.get(difference.checkpoint)!;
443
+ const right = rebuild.get(difference.checkpoint)!;
444
+ const name = difference.checkpoint.replace(/[^A-Za-z0-9._-]/g, "_");
445
+ if (difference.what === "screen") {
446
+ if (left.png !== undefined) write(`${name}-original.png`, Buffer.from(left.png, "base64"));
447
+ if (right.png !== undefined) write(`${name}-rebuild.png`, Buffer.from(right.png, "base64"));
448
+ if (right.diffPng !== undefined) write(`${name}-diff.png`, Buffer.from(right.diffPng, "base64"));
449
+ }
450
+ if (difference.what.startsWith("memory ")) {
451
+ const index = left.memory.findIndex((item) => `memory ${item.label}` === difference.what);
452
+ const item = left.memory[index]!;
453
+ const other = right.memory[index]!;
454
+ const skipped = new Set(item.excluded.map((excluded) => excluded.offset));
455
+ const bytes = item.bytes.flatMap((byte, offset) => (!skipped.has(offset) && Math.abs(byte - other.bytes[offset]!) > item.tolerance ? [{ address: formatC64Address(item.address + offset), original: byte, rebuild: other.bytes[offset]! }] : []));
456
+ memory.push({ checkpoint: difference.checkpoint, what: difference.what, bytes });
457
+ }
458
+ }
459
+ if (memory.length > 0) write("memory.json", Buffer.from(`${JSON.stringify(memory, null, 2)}\n`));
460
+ return files;
461
+ }
462
+
463
+ /** Runs the scenario on the original, then on the rebuild, in one VICE session. With out, a FAIL leaves files there. */
464
+ export async function runScenario(client: ViceSessionClient, scenario: Scenario, options: { out?: string } = {}): Promise<ScenarioResult> {
458
465
  const checkpoints = scenario.steps.flatMap((step) => ("observe" in step ? [step.observe] : []));
459
466
  const observed = new Map<Side, Map<string, Observation>>();
460
467
  for (const side of SIDES) {
461
468
  try {
462
- observed.set(side, await runSide(client, scenario, side, loadSymbols(scenario[side])));
469
+ observed.set(side, await runSide(client, scenario, side, loadSymbols(scenario[side]), options.out !== undefined));
463
470
  } catch (error) {
464
471
  if (error instanceof Inconclusive) return { result: "INCONCLUSIVE", scenario: scenario.name, reason: error.message, checkpoints, differences: [] };
465
472
  if (error instanceof WireFailure) {
@@ -468,6 +475,17 @@ export async function runScenario(client: ViceSessionClient, scenario: Scenario)
468
475
  throw error;
469
476
  }
470
477
  }
471
- const differences = compare(observed.get("original")!, observed.get("rebuild")!);
472
- return { result: differences.length === 0 ? "PASS" : "FAIL", scenario: scenario.name, checkpoints, differences };
478
+ const original = observed.get("original")!;
479
+ const rebuild = observed.get("rebuild")!;
480
+ const differences = compare(original, rebuild);
481
+ const excluded = [...original].flatMap(([checkpoint, observation]) => observation.memory.flatMap((item) => item.excluded.map((excluded) => ({ checkpoint, at: excluded.at, reason: excluded.reason }))));
482
+ const files = differences.length > 0 && options.out !== undefined ? writeFailFiles(options.out, original, rebuild, differences) : [];
483
+ return {
484
+ result: differences.length === 0 ? "PASS" : "FAIL",
485
+ scenario: scenario.name,
486
+ checkpoints,
487
+ differences,
488
+ ...(excluded.length === 0 ? {} : { excluded }),
489
+ ...(files.length === 0 ? {} : { files }),
490
+ };
473
491
  }
@@ -6,22 +6,32 @@ import { parseArgs } from "node:util";
6
6
 
7
7
  import { ViceSessionClient } from "#src/host-client/vice-session.ts";
8
8
  import { WireFailure } from "#src/host-client/tools.ts";
9
+ import { resolveProjectPath } from "#src/project.ts";
9
10
  import { runScript, UsageError } from "#src/script.ts";
10
11
  import { readScenario, runScenario, ScenarioError, type ScenarioResult } from "./scenario.ts";
11
12
 
12
- const USAGE = `test.ts <scenario.json>
13
+ const USAGE = `test.ts <scenario.json> [--out <dir>]
13
14
 
14
15
  <scenario.json> is a scenario file relative to the project directory. See
15
- references/scenario-format.md. The result is one JSON object.`;
16
+ references/scenario-format.md. With --out, a FAIL writes both screens, the
17
+ difference image and memory.json with each differing byte into <dir>. The
18
+ result is one JSON object.`;
16
19
 
17
20
  const EXIT = { PASS: 0, FAIL: 1, INCONCLUSIVE: 3 } as const;
18
21
 
19
22
  async function run(argv: string[]): Promise<ScenarioResult> {
20
- const { values, positionals } = parseArgs({ args: argv, strict: true, allowPositionals: true, options: { help: { type: "boolean" } } });
23
+ const { values, positionals } = parseArgs({ args: argv, strict: true, allowPositionals: true, options: { out: { type: "string" }, help: { type: "boolean" } } });
21
24
  if (values.help) throw new UsageError("");
22
25
  if (positionals.length !== 1) throw new UsageError("give one scenario file");
26
+ if (values.out !== undefined) {
27
+ try {
28
+ resolveProjectPath(values.out);
29
+ } catch {
30
+ throw new UsageError(`--out must be a path relative to the project directory that stays inside it, not ${values.out}`);
31
+ }
32
+ }
23
33
  try {
24
- return await runScenarioFile(positionals[0]!);
34
+ return await runScenarioFile(positionals[0]!, values.out);
25
35
  } catch (error) {
26
36
  // A scenario or symbol file that is not usable is a bad call.
27
37
  if (error instanceof ScenarioError) throw new UsageError(error.message);
@@ -29,7 +39,7 @@ async function run(argv: string[]): Promise<ScenarioResult> {
29
39
  }
30
40
  }
31
41
 
32
- async function runScenarioFile(path: string): Promise<ScenarioResult> {
42
+ async function runScenarioFile(path: string, out: string | undefined): Promise<ScenarioResult> {
33
43
  const scenario = readScenario(path);
34
44
  let client: ViceSessionClient;
35
45
  try {
@@ -39,7 +49,7 @@ async function runScenarioFile(path: string): Promise<ScenarioResult> {
39
49
  return { result: "INCONCLUSIVE", scenario: scenario.name, reason: `no emulator: ${error.message}`, checkpoints: [], differences: [] };
40
50
  }
41
51
  try {
42
- return await runScenario(client, scenario);
52
+ return await runScenario(client, scenario, out === undefined ? {} : { out });
43
53
  } finally {
44
54
  await client.close();
45
55
  }
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: c64-unpacker
3
- description: Use this skill to find out if a packer or a cruncher compressed a C64 program, and to get the unpacked program for analysis. It shows evidence of packing without a guess of the packer name. It runs the program in an emulator until the unpacked code starts, then writes the memory into the project. Do not use it to analyze the unpacked code.
3
+ description: Use this skill to find out if a packer or a cruncher compressed a C64 program, and to get the unpacked program for analysis. It shows evidence of packing without a guess of the packer name. It runs the program in an emulator until the unpacked code starts, then writes the memory into the project. It also captures each part of a multi-load program and the drive RAM. Do not use it to analyze the unpacked code.
4
4
  ---
5
5
 
6
6
  # c64-unpacker
@@ -14,17 +14,19 @@ This skill does not change project knowledge.
14
14
  ## Inputs
15
15
 
16
16
  - The program: a PRG file, relative to the project directory.
17
- - For a capture: the address where the unpacked program starts, and where to write the result.
17
+ - For a capture: the address where the unpacked program starts, and the directory for the result.
18
18
 
19
19
  ## Tools / execution path
20
20
 
21
21
  ```
22
22
  node <skill>/scripts/unpack.ts inspect original/game.prg
23
23
  node <skill>/scripts/unpack.ts trace original/game.prg
24
- node <skill>/scripts/unpack.ts capture original/game.prg --until '$c000' --out unpacked/game.prg --range '$c000' '$cfff'
24
+ node <skill>/scripts/unpack.ts capture original/game.prg --until '$c000' --out unpacked
25
+ node <skill>/scripts/unpack.ts stages original/game.prg --steps input.json
26
+ node <skill>/scripts/unpack.ts loads original/game.d64 --steps parts.json --out parts --drive
25
27
  ```
26
28
 
27
- `inspect` examines the file locally. `trace` and `capture` start their own emulator through the host runtime and start the program. `trace` runs the program and finds the memory that the program wrote and then executed. `capture` stops the program when the CPU gets to the `--until` address.
29
+ `inspect` examines the file locally. `trace` and `capture` start their own emulator through the host runtime and start the program. `trace` runs the program and finds the memory that the program wrote and then executed. `capture` stops the program when the CPU gets to the `--until` address. `stages` runs the program and finds each stage: a decruncher, an intro, a copier, and then the program. For each stage it gives the entry, the frames, the written memory (`wrote`) and the entry of the next stage (`next`). In `stages`, a `frames` step counts as run time, and a `runUntil` step is not possible.
28
30
 
29
31
  | Option | Meaning |
30
32
  |---|---|
@@ -34,25 +36,71 @@ node <skill>/scripts/unpack.ts capture original/game.prg --until '$c000' --out u
34
36
  | `--timeout-frames 3000` | How many frames the program can run before the capture stops (default 3000). |
35
37
  | `--frames 3000` | `trace` only: how many frames the program runs (default 3000). |
36
38
  | `--entry '$c000'` | `trace` only: start the program at this address. Without it, `trace` types RUN. A program without a BASIC start line needs it. |
39
+ | `--steps input.json` | Input for the program, right after it starts: a JSON list of steps. Use it when an intro waits for a key or a menu waits for a choice. |
40
+ | `--drive` | `loads` only: each capture also writes the 2 KiB RAM of the 1541 drive as `drive8.prg`. |
41
+ | `--min-block 64` | `loads` only: the smallest block of changed memory that becomes a segment (default 64). The result counts smaller changes as `smallChanges`. |
42
+ | `--min-stage 64` | `stages` only: the smallest block of written memory that starts a new stage when the CPU executes it (default 64). |
37
43
 
38
44
  The capture reads RAM, also the RAM under the ROMs and the I/O area. The script prints one JSON object.
39
45
 
46
+ A steps file has the steps `write`, `registers`, `joystick`, `type`, `frames` and `runUntil` of the c64-testing skill. Addresses are `$` and four hex digits. For example, wait 50 frames and press the space bar:
47
+
48
+ ```json
49
+ [{ "frames": 50 }, { "type": " " }]
50
+ ```
51
+
52
+ A `type` step puts keys in the keyboard queue of the KERNAL. A program that reads the keyboard hardware does not see them. A step cannot press one specific key of the keyboard matrix. A wait for any key that reads all keyboard columns can end without a key.
53
+
40
54
  ## Workflow
41
55
 
42
56
  1. Run `inspect`. Read `basicStart` and `packing`:
43
57
  - `likely`: most of the bytes look compressed. Packed data has this property.
44
58
  - `unlikely`: the bytes look like code and data. A simple cruncher can also give this result.
45
- - `unclear`: the file is short, or the blocks do not give clear evidence.
59
+ - `unclear`: the file is short, or the blocks do not give clear evidence. Or the code at the SYS address decodes or moves bytes outside the program.
60
+
61
+ Also read the evidence that the script finds in the file. The script never names a packer from it:
62
+ - `basicText`: the BASIC lines as text. A packer or a crack often puts its name there.
63
+ - `strings`: readable text near the start of the code.
64
+ - `start`: the code at the SYS address. `jumpsOutside` and `writesOutside` give an address outside the loaded program, where the code jumps or writes.
65
+ - `decodeLoops`: EOR instructions in loops that store with an index. This is usually decryption or decoding.
46
66
  2. Run `trace`. It gives the evidence from the run. Read `packing`:
47
67
  - `likely`: the program wrote code and then executed it. It unpacked, decrypted or moved code. `runningRange` is the code that runs now. Its `start` is usually the entry point of the unpacked program.
48
68
  - `unclear`: the program executed a small quantity of code that it wrote. This can be code that changes itself.
49
69
  - `unlikely`: the program executed no code that it wrote. If the program waits for a key or a joystick, it can move code after that input. Then use the c64-emulator skill.
50
70
  3. Find the address where the unpacked program starts:
51
- - Use `runningRange.start` from `trace`, or `suggestedUntil`. The CPU gets to `suggestedUntil` after the unpacking.
71
+ - Run `stages`. The `entry` of the last stage is usually the start of the program.
72
+ - Or use `runningRange.start` from `trace`, or `suggestedUntil`. The CPU gets to `suggestedUntil` after the unpacking.
52
73
  - Or use the c64-emulator skill: set a breakpoint after the unpack loop, or look at the last JMP of the unpacker.
53
- 4. Run `capture` with that address. Use `--range` for the area that holds the unpacked program, if you know it.
54
- 5. Make sure that the result is the application: the code at the start address must not be the unpacker again.
55
- 6. Give the result to the c64-static-analysis skill. For a flat 64 KiB file, use its `--flat64k` option.
74
+ 4. Run `capture` with that address and a directory in `--out`. Give the same `--steps` as before. The directory gets:
75
+ - `image.bin`: all 64 KiB of RAM at the entry.
76
+ - `image.json`: the entry, the segments, the vectors and the stages. A segment is a block that a stage wrote. Blocks with the code of an earlier stage are not segments. `writtenBy` tells which stage wrote the segment.
77
+ - One PRG for each segment.
78
+ - `entry_state.json`: the registers, `$00` and `$01`, pages 0 to 3, the VIC, SID and CIA registers, and the vectors. `differsFromReset` lists the bytes that are not as a clean C64 has them at READY. A rebuild must make these bytes the same, because the program can read them.
79
+ 5. Make sure that the result is the application: the code at the entry must not be the unpacker again. A segment can be data that a loader left, for example intro graphics. Keep it until other evidence shows that the program does not use it.
80
+ 6. Give `image.bin` to the c64-static-analysis skill with its `--flat64k` option, or give it the segment PRGs.
81
+ 7. Use `--range` only when you know the area of the program. Then the capture writes that one segment.
82
+
83
+ ## Multi-load programs
84
+
85
+ A multi-load program loads more parts from the disk while it runs, for example one part for each level. Use `loads` to capture each part. The program can be a PRG, or a disk, tape or cartridge image that VICE starts.
86
+
87
+ 1. Write a steps file. Put a `capture` step after each point where a part is in memory, for example:
88
+
89
+ ```json
90
+ [
91
+ { "runUntil": { "at": "$080d" }, "timeoutFrames": 6000 },
92
+ { "capture": "main" },
93
+ { "type": " " },
94
+ { "runUntil": { "at": "$c000" }, "timeoutFrames": 6000 },
95
+ { "capture": "level1" }
96
+ ]
97
+ ```
98
+
99
+ 2. Run `loads` with the steps file and a directory in `--out`. Add `--drive` when the program has its own drive code, for example a fast loader.
100
+ 3. Each capture writes `<out>/<name>/` with `image.bin`, `image.json`, `entry_state.json` and a PRG for each segment. A segment is a block of RAM from `$0400` that the program changed after the capture before. The first capture also has the program itself.
101
+ 4. With `--drive`, `drive8.prg` is the drive RAM at `$0000`-`$07ff`. Analyze it with c64-static-analysis and `--space drive8`, and record its knowledge in space `drive8`.
102
+
103
+ VICE records no memory access map for the drive CPU. Thus no tool gives coverage of drive code.
56
104
 
57
105
  ## Knowledge
58
106
 
@@ -63,18 +111,21 @@ Do not record the unpacker in the project knowledge, unless the user asks to exa
63
111
 
64
112
  ## Result
65
113
 
66
- The packing evidence, or the unpacked program in the project with the address where it starts.
114
+ The packing evidence, the stages, or the unpacked program with its image, segments and entry state in a directory of the project.
115
+
116
+ For `loads`: `captures`, with the name, the PC, the segments and the drive file of each capture.
67
117
 
68
- The exit status is 0 for a result. The exit status is 1 for `traced: false`, for `captured: false` and for an error. The exit status is 2 for a wrong argument.
118
+ The exit status is 0 for a result. The exit status is 1 for `traced: false`, for `captured: false`, for `loaded: false` and for an error. The exit status is 2 for a wrong argument.
69
119
 
70
120
  ## Failure and conflicts
71
121
 
72
122
  - `traced: false`: the emulator did not load the program into memory in the time limit. The reason gives the load address. Make sure that the file is a PRG that the C64 can load.
73
- - `captured: false`: the program did not get to the address in the time limit. The reason tells where it stopped. Find a better address with the c64-emulator skill, or give more frames.
123
+ - `captured: false`: the program did not get to the address in the time limit. The reason tells where it stopped. Find a better address with the c64-emulator skill, or give more frames. If it stopped in a key wait, give `--steps` with a key.
124
+ - A step did not reach its target: the reason names the step. Change the step or give it more frames.
74
125
  - Do not name a packer from weak evidence. The script never names one.
75
- - Some programs unpack in more than one stage. Make sure that the captured code is the application and not a second unpacker.
126
+ - Some programs unpack in more than one stage. Run `stages` to find each stage. Capture at the entry of the last stage. Make sure that it is the application and not a second unpacker.
76
127
  - `not-found` or `invalid-input`: a path or an argument is wrong. Read the message.
77
- - `operation-failed` with a message about the host runtime: the host runtime does not run. Tell the user to start the host runtime with `npx -y --package=@henols/c64-re-tools@latest c64-re-tools-host`.
128
+ - `installation-incomplete`, or a message that the host runtime does not run: stop the task. Give the message to the user and wait for the user. The message gives the remedy. Do not make a replacement for the tool or the host runtime, for example your own disk parser, disassembler or emulator script.
78
129
 
79
130
  ## Handoffs
80
131