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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/package.json +1 -1
  2. package/skills/c64-assembler/SKILL.md +1 -0
  3. package/skills/c64-assembler/scripts/assembler.ts +10 -0
  4. package/skills/c64-disk/SKILL.md +2 -2
  5. package/skills/c64-disk/scripts/disk.ts +24 -10
  6. package/skills/c64-disk/scripts/lib/containers.ts +9 -9
  7. package/skills/c64-docs/scripts/lib/links.ts +3 -3
  8. package/skills/c64-docs/scripts/lib/reference.ts +2 -2
  9. package/skills/c64-emulator/SKILL.md +8 -2
  10. package/skills/c64-knowledge/SKILL.md +3 -1
  11. package/skills/c64-knowledge/scripts/knowledge.ts +59 -43
  12. package/skills/c64-memory-map/SKILL.md +1 -1
  13. package/skills/c64-memory-map/scripts/memory-map.ts +3 -2
  14. package/skills/c64-provenance/SKILL.md +5 -4
  15. package/skills/c64-provenance/scripts/lib/library.ts +18 -6
  16. package/skills/c64-provenance/scripts/lib/separate.ts +45 -6
  17. package/skills/c64-provenance/scripts/provenance.ts +5 -4
  18. package/skills/c64-source/SKILL.md +2 -0
  19. package/skills/c64-source/scripts/lib/source.ts +2 -2
  20. package/skills/c64-source/scripts/source.ts +10 -6
  21. package/skills/c64-static-analysis/scripts/lib/idioms.ts +6 -2
  22. package/skills/c64-static-analysis/scripts/static-analysis.ts +4 -3
  23. package/skills/c64-testing/SKILL.md +11 -1
  24. package/skills/c64-testing/references/recording-format.md +2 -0
  25. package/skills/c64-testing/references/scenario-format.md +6 -0
  26. package/skills/c64-testing/scripts/lib/diff-files.ts +63 -13
  27. package/skills/c64-testing/scripts/lib/diff.ts +3 -3
  28. package/skills/c64-testing/scripts/lib/recording.ts +47 -12
  29. package/skills/c64-testing/scripts/lib/replay.ts +11 -0
  30. package/skills/c64-testing/scripts/lib/runs.ts +64 -4
  31. package/skills/c64-testing/scripts/lib/scenario.ts +43 -6
  32. package/skills/c64-testing/scripts/testing.ts +15 -2
  33. package/skills/c64-unpacker/SKILL.md +9 -5
  34. package/skills/c64-unpacker/scripts/lib/evidence.ts +4 -5
  35. package/skills/c64-unpacker/scripts/lib/loads.ts +3 -2
  36. package/skills/c64-unpacker/scripts/unpacker.ts +75 -25
  37. package/src/host/vice/session.ts +99 -10
  38. package/src/host-client/image.ts +32 -0
  39. package/src/host-client/steps.ts +106 -6
  40. package/src/host-client/tools.ts +1 -1
  41. package/src/host-client/transfer.ts +35 -8
  42. package/src/knowledge/expression.ts +1 -1
  43. package/src/knowledge/history.ts +19 -2
  44. package/src/knowledge/names.ts +2 -1
  45. package/src/knowledge/program.ts +1 -16
  46. package/src/knowledge/read.ts +43 -0
  47. package/src/knowledge/schema.ts +3 -2
  48. package/src/knowledge/text.ts +1 -0
  49. package/src/knowledge/write.ts +40 -6
  50. package/src/mcp/output.ts +5 -0
  51. package/src/mcp/server.ts +19 -4
  52. package/src/mcp/tools/debug.ts +7 -6
  53. package/src/mcp/tools/execution.ts +18 -5
  54. package/src/mcp/tools/media.ts +14 -2
  55. package/src/mcp/tools/memory.ts +7 -3
  56. package/src/mcp/tools/video.ts +5 -3
  57. package/src/project.ts +1 -1
  58. package/src/protocol/vice.ts +4 -1
@@ -4,10 +4,12 @@
4
4
  // its start, so match finds it in another program at another address and
5
5
  // suggests its names there as an apply batch. Nothing here writes knowledge.
6
6
 
7
+ import { createHash } from "node:crypto";
7
8
  import { mkdirSync, readdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
8
- import { join, resolve } from "node:path";
9
+ import { join, relative, resolve, sep } from "node:path";
9
10
 
10
11
  import { Refusal } from "#src/errors.ts";
12
+ import { writeRefusal, type Written } from "#src/host-client/transfer.ts";
11
13
  import { formatC64Address, instructionLength } from "#src/c64.ts";
12
14
  import { evaluate, identifiers, operandText } from "#src/knowledge/expression.ts";
13
15
  import type { CommentRow, OperandRow, ReferenceRow, RegionRow, SymbolRow } from "#src/knowledge/read.ts";
@@ -317,16 +319,26 @@ export function readLibrary(folder: string): Routine[] {
317
319
  .map((name) => parseRoutine(readFileSync(join(folder, name), "utf8"), name));
318
320
  }
319
321
 
320
- /** Writes a routine file into the library: a temporary file, then a rename. Refuses a name that exists unless `replace`. */
321
- export function saveRoutine(folder: string, routine: Routine, replace: boolean): string {
322
+ /**
323
+ * Writes a routine file into the library: a temporary file, then a rename.
324
+ * Refuses a name that exists unless `replace`. Reports the file as a written
325
+ * file: its path relative to the project folder `root`, its size and its SHA-256.
326
+ */
327
+ export function saveRoutine(folder: string, routine: Routine, replace: boolean, root = process.cwd()): Written {
322
328
  const file = join(folder, `${routine.name}.json`);
329
+ const out = relative(root, file).split(sep).join("/");
323
330
  if (!replace && statSync(file, { throwIfNoEntry: false }) !== undefined) {
324
331
  throw new Refusal("conflict", `The library already has the routine ${routine.name}. Give another --name, or give --replace to save over it.`);
325
332
  }
333
+ const bytes = Buffer.from(routineText(routine), "utf8");
326
334
  const temporary = `${file}.${process.pid}.tmp`;
327
- writeFileSync(temporary, routineText(routine));
328
- renameSync(temporary, file);
329
- return file;
335
+ try {
336
+ writeFileSync(temporary, bytes);
337
+ renameSync(temporary, file);
338
+ } catch (error) {
339
+ throw writeRefusal(error, out, "--library") ?? error;
340
+ }
341
+ return { out, bytes: bytes.length, sha256: createHash("sha256").update(bytes).digest("hex") };
330
342
  }
331
343
 
332
344
  /** An image to search: its segments and its bytes inside them. */
@@ -1,10 +1,11 @@
1
1
  // Evidence from one release: for each range of the image, the unpack
2
2
  // stage that wrote it and the access class from coverage; for each code
3
- // region of knowledge, whether call and jump references reach it from the
4
- // entry; and what the program reads or executes outside the image. Evidence
5
- // only: nothing here decides what is original.
3
+ // region of knowledge, whether call and jump references or the run on from
4
+ // its last instruction reach it from the entry; and what the program reads
5
+ // or executes outside the image. Evidence only: nothing here decides what is
6
+ // original.
6
7
 
7
- import { type AddressRange, COVERAGE, executedRamCode } from "#src/c64.ts";
8
+ import { type AddressRange, COVERAGE, executedRamCode, instructionLength } from "#src/c64.ts";
8
9
 
9
10
  export type Access = "executed" | "read" | "written" | "read-written" | "untouched";
10
11
 
@@ -49,6 +50,27 @@ export interface Separation {
49
50
 
50
51
  const SYSTEM_PAGES_END = 0x03ff;
51
52
 
53
+ /** BRK, JMP, RTI, RTS, JMP (indirect) and the JAM opcodes: the CPU does not run on to the next instruction after them. */
54
+ const NO_FALL_THROUGH = new Set([0x00, 0x4c, 0x40, 0x60, 0x6c, 0x02, 0x12, 0x22, 0x32, 0x42, 0x52, 0x62, 0x72, 0x92, 0xb2, 0xd2, 0xf2]);
55
+
56
+ /**
57
+ * The address after a code region where the CPU runs on, or undefined when
58
+ * its last instruction does not run on (RTS, RTI, JMP, BRK, JAM). The
59
+ * instructions follow one another from the start of the region.
60
+ */
61
+ export function fallThrough(region: AddressRange, memory: Uint8Array): number | undefined {
62
+ let at = region.start;
63
+ let last = at;
64
+ while (at <= region.end) {
65
+ last = at;
66
+ at += instructionLength(memory[at]!);
67
+ }
68
+ return NO_FALL_THROUGH.has(memory[last]!) || at > 0xffff ? undefined : at;
69
+ }
70
+
71
+ /** The most instructions outside the code regions that the run on from a region goes through, for example an instruction in a dual-use region. */
72
+ const MAX_RUN_ON = 16;
73
+
52
74
  /** Joins addresses in order into ranges; `gap` is the most bytes between two addresses in one range. */
53
75
  function merge(addresses: number[], gap = 0, joins: (from: number, to: number) => boolean = () => true): AddressRange[] {
54
76
  const ranges: AddressRange[] = [];
@@ -92,12 +114,29 @@ export function separate(input: SeparationInput): Separation {
92
114
  }
93
115
  }
94
116
 
95
- // Reachability: from the region that holds the entry, through call and jump references, to a fixed point.
117
+ // Reachability: from the region that holds the entry, through call and jump references and the run on
118
+ // from the last instruction of a region into the next address, to a fixed point.
96
119
  const regions = input.codeRegions.filter((region) => input.segments.some((segment) => region.start <= segment.end && segment.start <= region.end));
97
120
  const holding = (address: number) => regions.findIndex((region) => address >= region.start && address <= region.end);
98
121
  const reached = new Set<number>();
99
122
  if (input.entry !== undefined && holding(input.entry) >= 0) reached.add(holding(input.entry));
100
- const flows = input.references.filter((reference) => reference.kind === "call" || reference.kind === "jump");
123
+ // The CPU runs the bytes after the region as instructions: through bytes of no code region (a dual-use
124
+ // instruction, for example) into the next code region, unless one of them does not run on.
125
+ const runOn = (from: number): number | undefined => {
126
+ let at = from;
127
+ for (let count = 0; count <= MAX_RUN_ON && at <= 0xffff && inImage(at); count++) {
128
+ if (holding(at) >= 0) return at;
129
+ if (NO_FALL_THROUGH.has(input.memory[at]!)) return undefined;
130
+ at += instructionLength(input.memory[at]!);
131
+ }
132
+ return undefined;
133
+ };
134
+ const runsOn = regions.flatMap((region) => {
135
+ const next = fallThrough(region, input.memory);
136
+ const to = next === undefined ? undefined : runOn(next);
137
+ return to === undefined ? [] : [{ from: region.end, to }];
138
+ });
139
+ const flows = [...input.references.filter((reference) => reference.kind === "call" || reference.kind === "jump"), ...runsOn];
101
140
  for (let grown = true; grown; ) {
102
141
  grown = false;
103
142
  for (const flow of flows) {
@@ -8,7 +8,7 @@
8
8
  // routines that several programs share in a library folder and find them in the
9
9
  // program (D71).
10
10
 
11
- import { basename, relative } from "node:path";
11
+ import { basename } from "node:path";
12
12
 
13
13
  import { type AddressRange, c64AddressOf, formatC64Address } from "#src/c64.ts";
14
14
  import { Refusal } from "#src/errors.ts";
@@ -108,6 +108,7 @@ async function compare(call: Call): Promise<unknown> {
108
108
  for (const text of call.strings("shift")) {
109
109
  const match = /^(\d+):([+-]?\d+)$/.exec(text);
110
110
  if (match === null || Number(match[1]) < 1 || Number(match[1]) > positionals.length) throw new UsageError(`--shift must be <release number>:<bytes>, not ${text}`);
111
+ if (shifts.has(Number(match[1]))) throw new UsageError(`--shift gives release ${match[1]} more than one shift (${shifts.get(Number(match[1]))} and ${match[2]}). Give one --shift for each release`);
111
112
  shifts.set(Number(match[1]), Number(match[2]));
112
113
  }
113
114
  const frames = call.count("frames");
@@ -364,10 +365,10 @@ function routineCommand(call: Call): unknown {
364
365
  operands: listOperands(db, range, SEMANTIC_ORIGINS),
365
366
  platform: platform(),
366
367
  });
367
- const file = saveRoutine(libraryFolder(library, { create: true, cwd: projectRoot() }), routine, call.flag("replace"));
368
+ const written = saveRoutine(libraryFolder(library, { create: true, cwd: projectRoot() }), routine, call.flag("replace"), projectRoot());
368
369
  return {
369
370
  routine: routine.name,
370
- file: relative(projectRoot(), file).split("\\").join("/"),
371
+ ...written,
371
372
  start: formatC64Address(start),
372
373
  end: formatC64Address(end),
373
374
  length: routine.bytes.length,
@@ -460,7 +461,7 @@ const COMMANDS: CommandSpec[] = [
460
461
  },
461
462
  {
462
463
  name: "separate",
463
- summary: "Evidence from the program in the knowledge: the unpack stage that wrote each range, its access in the coverage, whether call and jump references reach each code region from the entry, and what the program reads or executes outside its segments.",
464
+ summary: "Evidence from the program in the knowledge: the unpack stage that wrote each range, its access in the coverage, whether call and jump references, or the run on from the code before it, reach each code region from the entry, and what the program reads or executes outside its segments.",
464
465
  options: { out: { ...OPTIONS.out, value: "<file>", help: "write all ranges to this file" }, "max-listed": OPTIONS["max-listed"] },
465
466
  run: separateCommand,
466
467
  },
@@ -150,6 +150,7 @@ Do not store:
150
150
  - `operandsAsNumbers`: the operand expressions that the source has as a number with a side comment, each with its `address`, `expression` and `reason`. An expression becomes a number when no instruction with an operand value is at the address, or when a name has no address. An expression that ACME refuses or does not assemble to the program also becomes a number. Then the script assembles the source again, at most six times.
151
151
  - `unusedModules`: modules in the knowledge whose start is in no segment of the program. Often this tells that the program does not hold code that the knowledge describes.
152
152
  - `entryOutside`: the entry is in no segment of the program in the knowledge, with a `reason`. The capture does not hold the program.
153
+ - `dataOnly`: the knowledge has no entry, no routine, no code region and no executed address in the space. Thus the source has only data, and Ghidra does not run. A table or a key page in a cartridge bank is an example.
153
154
  - `notExecuted`: when the knowledge has coverage, the count of instructions that no run executed, the count of their ranges, and the first ranges. `rest` names the file `not_executed.json` in `--out` when the list is not complete. This file has all of the ranges. Each range has its source `file` and the `routine` before it. `files` counts the ranges in each file.
154
155
  - `macroUses`: the count of macro uses that the source has.
155
156
  - `macrosAsInstructions`: the macro uses that the source has as instructions, each with its `start`, `end`, `macro` and `reason`.
@@ -171,6 +172,7 @@ Do not store:
171
172
  - `patchesNotWritten`: read each `reason`. Move the start or the end of the patch to an instruction, or give the switch another name.
172
173
  - `macrosAsInstructions`: read each `reason`. Correct the macro or the args, or remove the use with `unuse`. The source stays identical, so this is not a failure.
173
174
  - `entryOutside`: the run fails, but the script still writes and checks the source. With `--loader`, `loader.verified` is `false` with the same `reason`. Capture the program again with c64-unpacker `loads` or `capture`. Make sure that a segment holds the entry. Then put it into the knowledge again with `image`.
175
+ - `dataOnly`: this is not a failure. If the space has code, give `--entry` or classify the code with the c64-knowledge skill. Then generate again.
174
176
  - `entriesInData`: an analyzer region of data hides the code at the entry. If the entry is code, record a code region at the entry with the c64-knowledge skill, for example `classify '$0500-$05ff' code`. A region of the user or the LLM replaces the analyzer region. Then generate again.
175
177
  - `loader.verified: false` with a `reason`: the script did not make the loader. The script still writes and checks the source. When no copy order or no free pages exist, or the loader needs too many copies, the reason gives the ranges. Capture the program again with fewer segments, or write the loader by hand.
176
178
  - `loader.verified: false` with `differences`: the loader ran, and `differences` gives the bytes and registers that are not equal.
@@ -384,7 +384,7 @@ export function layout(input: SourceInput): Layout {
384
384
  }
385
385
  const missing = [...new Set(use.args.flatMap((arg) => identifiers(arg)))].filter((name) => !byName.has(name));
386
386
  if (missing.length > 0) {
387
- fallBack(`No symbol and no platform name is ${missing.join(", ")}.`);
387
+ fallBack(`${missing.join(", ")} ${missing.length === 1 ? "is" : "are"} not a symbol of the knowledge or a platform name. Name the address with knowledge.ts rename, or give other args with knowledge.ts use.`);
388
388
  continue;
389
389
  }
390
390
  candidates.push({ use, items: run as InstructionItem[] });
@@ -403,7 +403,7 @@ export function layout(input: SourceInput): Layout {
403
403
  }
404
404
  const missing = identifiers(expression).filter((name) => !byName.has(name));
405
405
  if (missing.length > 0) {
406
- operandsAsNumbers.push({ address: operand.address, expression, reason: `No symbol and no platform name is ${missing.join(", ")}.` });
406
+ operandsAsNumbers.push({ address: operand.address, expression, reason: `${missing.join(", ")} ${missing.length === 1 ? "is" : "are"} not a symbol of the knowledge or a platform name. Name the address with knowledge.ts rename, or write the operand again with knowledge.ts operand.` });
407
407
  continue;
408
408
  }
409
409
  // An immediate operand builds the address of a name; any other operand uses the address as the instruction does.
@@ -40,6 +40,9 @@ const MAX_DIAGNOSTICS = 20;
40
40
  const ROUNDS = 6;
41
41
  const PROBE_BASE = 0x1000;
42
42
  const PROBE_SLOT = 4;
43
+ /** Why a source has no instructions when the knowledge has no code. */
44
+ const DATA_ONLY = "The knowledge has no entry, routine, code region or executed address in this space. Thus the source has only data. For instructions, give --entry, or classify the code with knowledge.ts classify, then generate again.";
45
+
43
46
  /** The copies of a full 64 KiB take about 140 frames. */
44
47
  const LOADER_FRAMES = 1500;
45
48
 
@@ -99,9 +102,10 @@ function withAnalyzerNames(symbols: SymbolRow[], second: SymbolRow[]): SymbolRow
99
102
  * Ghidra's instruction starts and lengths inside the image, seeded from the
100
103
  * entry, from knowledge of any origin, and from the coverage: a run's flow
101
104
  * starts, also after a JSR, so code that an analyzer does not reach by itself
102
- * is in the source.
105
+ * is in the source. A program without a seed has no known code: it is data
106
+ * only, and Ghidra does not run.
103
107
  */
104
- async function decode(image: ProgramImage, knowledge: ReturnType<typeof readKnowledge>, entries: number[], coverage: Uint8Array | undefined): Promise<Map<number, number>> {
108
+ async function decode(image: ProgramImage, knowledge: ReturnType<typeof readKnowledge>, entries: number[], coverage: Uint8Array | undefined): Promise<{ instructions: Map<number, number>; dataOnly: boolean }> {
105
109
  const inImage = (address: number) => image.segments.some((segment) => address >= segment.start && address <= segment.end);
106
110
  const seeds = new Set<number>([...(image.entry === undefined ? [] : [image.entry]), ...entries]);
107
111
  for (const address of knowledge.routines) seeds.add(address);
@@ -114,8 +118,7 @@ async function decode(image: ProgramImage, knowledge: ReturnType<typeof readKnow
114
118
  const entryPoints = [...seeds].filter(inImage).sort((a, b) => a - b);
115
119
  if (entryPoints.length === 0) {
116
120
  // An entry in no segment is a capture fault: the image is data only, and the result says entryOutside.
117
- if (image.entry !== undefined && !inImage(image.entry)) return new Map();
118
- throw new UsageError("Ghidra needs an entry point: give --entry, or record a routine or a code region in knowledge with the c64-knowledge skill");
121
+ return { instructions: new Map(), dataOnly: image.entry === undefined || inImage(image.entry) };
119
122
  }
120
123
  const dataRanges = knowledge.regions
121
124
  .filter((region) => !CODE_REGION_TYPES.includes(region.type) && region.type !== "dual-use")
@@ -138,7 +141,7 @@ async function decode(image: ProgramImage, knowledge: ReturnType<typeof readKnow
138
141
  const inside = Array.from({ length: bytes.length }, (_, offset) => instruction.address + offset).every((address, offset) => inImage(address) && image.byteAt(address) === bytes[offset]);
139
142
  if (inside) instructions.set(instruction.address, bytes.length);
140
143
  }
141
- return instructions;
144
+ return { instructions, dataOnly: false };
142
145
  }
143
146
 
144
147
  /** Assembles files given as text with ACME; the program or the diagnostics. */
@@ -547,7 +550,7 @@ async function generate(call: Call): Promise<{ output: Record<string, unknown>;
547
550
  // Read the entry state before the long steps, so a bad state stops the run at once.
548
551
  const state = loaderWanted ? loaderState(image.state, "the entry state of the knowledge") : undefined;
549
552
  const romUse = romsInUse(image, coverage);
550
- const instructions = await decode(image, knowledge, entries, coverage);
553
+ const { instructions, dataOnly } = await decode(image, knowledge, entries, coverage);
551
554
  const laid = layout({
552
555
  segments: image.segments,
553
556
  byteAt: image.byteAt,
@@ -634,6 +637,7 @@ async function generate(call: Call): Promise<{ output: Record<string, unknown>;
634
637
  // The ROM names that the source leaves out, and the evidence.
635
638
  ...(space !== DEFAULT_SPACE || (romUse.basic && romUse.kernal) ? {} : { romNamesLeftOut: { roms: [...(romUse.basic ? [] : ["BASIC"]), ...(romUse.kernal ? [] : ["KERNAL"])], ...(romUse.basic || romUse.kernal ? {} : { systemArea: true }), from: romUse.from } }),
636
639
  ...(missingEntry === undefined ? {} : { entryOutside: { entry: formatC64Address(image.entry!), reason: missingEntry } }),
640
+ ...(dataOnly ? { dataOnly: DATA_ONLY } : {}),
637
641
  labels: { named: laid.namedLabels, generated: laid.generatedLabels },
638
642
  // An --entry in a data region decodes nothing: the result says so, and a code region in the knowledge changes it.
639
643
  ...(inData.length === 0
@@ -31,7 +31,7 @@ export interface Idioms {
31
31
  candidates: { dop: number[]; selfModified: number[] };
32
32
  }
33
33
 
34
- /** True when an instruction can start at the address: inside the program, and no JAM. */
34
+ /** True when an instruction can start at the address: inside the range and the program (`byteAt` gives a byte), and no JAM. */
35
35
  function plausible(byteAt: ImageByte, address: number, range: { start: number; end: number }): boolean {
36
36
  const opcode = byteAt(address);
37
37
  return address >= range.start && address <= range.end && opcode !== undefined && !JAMS.has(opcode);
@@ -43,7 +43,11 @@ const word = (byteAt: ImageByte, address: number) => {
43
43
  return low === undefined || high === undefined ? undefined : low | (high << 8);
44
44
  };
45
45
 
46
- /** `covered` is the coverage of the knowledge: the joined map, and the addresses that one run wrote and executed. */
46
+ /**
47
+ * `byteAt` gives the bytes of the program and undefined outside it, so a
48
+ * table and its targets stay inside the program. `covered` is the coverage of
49
+ * the knowledge: the joined map, and the addresses that one run wrote and executed.
50
+ */
47
51
  export function findIdioms(byteAt: ImageByte, range: { start: number; end: number }, covered?: { map: Uint8Array; selfModified: Uint8Array }): Idioms {
48
52
  const coverage = covered?.map;
49
53
  const regions: NormalizedFindings["regions"] = [];
@@ -184,7 +184,8 @@ async function analyze(call: Call): Promise<unknown> {
184
184
  // With coverage, a code finding over an executed instruction start replaces the data of the other analyzer.
185
185
  const executed = coverage === undefined ? {} : { executed: (address: number) => (coverage[address]! & COVERAGE.ramExecute) !== 0 };
186
186
  const loaded = imageKind === "flat64k" ? { start: 0x0000, end: 0xffff } : prgRange(bytes);
187
- const idioms = findIdioms(byteAt, { start: loaded.start, end: loaded.end }, covered);
187
+ // The idioms read only the program: a table that runs past a segment gets no targets from the 0 bytes of the 64 KiB.
188
+ const idioms = findIdioms(inProgram, { start: loaded.start, end: loaded.end }, covered);
188
189
 
189
190
  const revision = currentRevision(reader);
190
191
  const seeds = seedsFromKnowledge(reader, entries, space);
@@ -281,7 +282,7 @@ async function analyze(call: Call): Promise<unknown> {
281
282
  coverage: result.coverage.map((range) => ({ start: formatC64Address(range.start), end: formatC64Address(range.end) })),
282
283
  ...imported,
283
284
  regions: result.regions.map((region) => ({ start: formatC64Address(region.start), end: formatC64Address(region.end), classification: region.classification })),
284
- labels: listed(result.labels, max, (label) => ({ address: formatC64Address(label.address), name: label.name }), "c64-knowledge: knowledge.ts symbols"),
285
+ labels: listed(result.labels, max, (label) => ({ address: formatC64Address(label.address), name: label.name }), "c64-knowledge: knowledge.ts symbols --analyzer --origin dxa"),
285
286
  ...(written === undefined ? {} : { listing: written }),
286
287
  };
287
288
  }
@@ -311,7 +312,7 @@ async function analyze(call: Call): Promise<unknown> {
311
312
  ...(coverage === undefined ? {} : { coverageGaps: listed(gaps, max, formatC64Address, "a larger --max-listed") }),
312
313
  coverage: result.coverage.map((range) => ({ start: formatC64Address(range.start), end: formatC64Address(range.end) })),
313
314
  ...imported,
314
- functions: listed(result.functions, max, (fn) => ({ entry: formatC64Address(fn.entry), name: fn.name }), "c64-knowledge: knowledge.ts symbols --kind routine"),
315
+ functions: listed(result.functions, max, (fn) => ({ entry: formatC64Address(fn.entry), name: fn.name }), "c64-knowledge: knowledge.ts symbols --analyzer --origin ghidra"),
315
316
  decompilations: result.decompilations.map((item) => ({ entry: formatC64Address(item.entry), text: item.text, ...(item.truncated ? { truncated: true } : {}) })),
316
317
  ...((result.notDecompiled ?? []).length === 0
317
318
  ? {}
@@ -30,6 +30,8 @@ node <skill>/scripts/testing.ts checklist tests/moves-left.json --item 'FIRE sta
30
30
 
31
31
  `testing.ts checklist` gives a Markdown checklist for a human tester in `text`.
32
32
 
33
+ Before the emulator starts, `testing.ts test` examines each file that the scenario names: the program and the symbols of each side, and each snapshot file. A missing file gives `not-found`. A program must be a PRG. The script refuses a disk, tape or cartridge image.
34
+
33
35
  ### Runs
34
36
 
35
37
  A run does steps on one program, with no comparison, and keeps the results as files. Use it for many steps. One run replaces many emulator calls, for example a visit to each room of a game.
@@ -50,6 +52,8 @@ The run file gives where to start (a program, or a snapshot file), the steps, an
50
52
  | `{ "trace": "d011", "points": [{ "kind": "write", "start": "$d011" }], "frames": 100 }` | Record each hit of the points for 100 frames in `d011.json`: the instruction, its registers and the raster line and cycle. A point has the `kind` `exec`, `read` or `write`, a `start` and an optional `end`. `maxHits` limits the hits. |
51
53
  | `{ "observe": "all3", "memory": "all" }` | Write all 64 KiB of RAM to `all3.bin`, also the RAM under the ROMs and the I/O area. |
52
54
 
55
+ Each step that writes a file must have its own file name. The script refuses a run where two steps write the same file, for example `{ "coverage": "x" }` and `{ "observe": "x", "memory": "all" }`, which both write `x.bin`. The script also examines the start program and each snapshot file before the emulator starts.
56
+
53
57
  A memory item can have `"view": "ram"`. Then it reads the RAM under the ROMs and the I/O area, for example code at `$e000` under the KERNAL. The default view `cpu` reads as the CPU sees memory. An item larger than 4096 bytes goes into its own `.bin` file, and `results.json` gives the file name.
54
58
 
55
59
  See [references/scenario-format.md](references/scenario-format.md), section Runs. `result` is PASS when all steps ran, and FAIL when a step did not run. `reason` tells why. A long run can need more time than one shell call. Then run it in the background.
@@ -118,7 +122,11 @@ node <skill>/scripts/testing.ts diff .c64-re-tools/local/runs/rooms/results.json
118
122
  - Two memory files of a run: the changed ranges. For a file of less than 64 KiB, `--address` gives the address of its first byte.
119
123
  - Two observations of runs, also of one run: the changed memory items and registers.
120
124
 
121
- `--gap N` joins ranges across N equal bytes. `--out` writes all ranges to a project file.
125
+ `registers` gives each changed register and chip value. `cpu.pc` is a `$` address. Each CPU flag is `cpu.flags.<flag>`, for example `cpu.flags.c`, with `true` or `false`.
126
+
127
+ The script refuses a snapshot that does not restore in its emulator. An example is a PAL snapshot with `--ntsc`. Give `--ntsc` only for snapshots of an NTSC machine.
128
+
129
+ `--gap N` joins ranges across N equal bytes. `--out` writes all ranges and all changed registers to a project file. The file uses the names of the result.
122
130
 
123
131
  ### Calling the script
124
132
 
@@ -180,6 +188,8 @@ PASS, FAIL or INCONCLUSIVE, the checkpoints and the smallest differences that ex
180
188
  ## Failure and conflicts
181
189
 
182
190
  - `invalid-input`: the scenario file has an error. The message names the step. Change the step and run the test again.
191
+ - `invalid-input` that names the 2.0 form: a step, a memory item or a recording has a form of version 2.0. An example is `"at"` in place of `"address"`. The message gives the form to write. Change the file. Record a recording of version 2.0 again.
192
+ - `not-found` before the run: a file of the scenario or the run file is missing, for example the rebuild program. Build the rebuild, or change the path.
183
193
  - INCONCLUSIVE, "did not reach the checkpoint": one side did not reach the checkpoint in the frame limit. Find the reason with the c64-emulator skill. Do not report a FAIL.
184
194
  - INCONCLUSIVE, "has no symbol": one side has no symbol with that name. Add the name to the knowledge or to the rebuild source.
185
195
  - INCONCLUSIVE, "no emulator" or "could not run": the host runtime or the emulator failed. Tell the user the message.
@@ -101,6 +101,8 @@ The page sends input only between Start and End. The input of the page goes into
101
101
 
102
102
  A site is the address of the instruction that reads. The values are hex. `<from>` and `<count>` are decimal.
103
103
 
104
+ `testing.ts replay` checks `recording.json` before the replay core starts. A recording of version 2.0 can have events or a comparison in the old form, for example `"at"` in place of `"address"`. The script refuses it with `invalid-input`. The message names the event, the old field and the form to write. Record the session again.
105
+
104
106
  ## Replay
105
107
 
106
108
  `testing.ts replay` replays a recording folder:
@@ -123,3 +123,9 @@ Example: visit two rooms from the same start.
123
123
  ## Checks
124
124
 
125
125
  The script checks the scenario before it starts the emulator. A step with an unknown key or a wrong value stops the script with `invalid-input`. The message names the step.
126
+
127
+ The script also examines each file before it starts the emulator: the program and the symbols of each side, and each snapshot file. A missing file stops the script with `not-found`. A program that is not a PRG, for example a disk image, stops the script with `invalid-input`.
128
+
129
+ A step in the form of version 2.0 stops the script with `invalid-input`. The message names the old field and gives the step to write. For example, the script refuses `{ "runUntil": { "at": "main_loop" }, "timeoutFrames": 300 }` and gives `{"runUntil": {"target": {"kind": "address", "address": "main_loop"}, "timeoutFrames": 300}}`. A memory item with `at` gets the same refusal: write `address`.
130
+
131
+ In a run, two steps must not write the same file name. The script refuses such a run before it starts. Names that differ only in upper and lower case are the same name.
@@ -5,6 +5,8 @@
5
5
  // run, or two observations of runs (also two of one run), compare as they are.
6
6
 
7
7
  import { formatC64Address, parseC64Address } from "#src/c64.ts";
8
+ import { Refusal } from "#src/errors.ts";
9
+ import { isObject } from "#src/host-client/steps.ts";
8
10
  import { WireFailure } from "#src/host-client/tools.ts";
9
11
  import { readProjectFile, writeReported } from "#src/host-client/transfer.ts";
10
12
  import { readMemoryInParts, ViceSessionClient } from "#src/host-client/vice-session.ts";
@@ -41,33 +43,80 @@ function observation(path: string): MachineState {
41
43
  observed[at] = readProjectFile(`${folder}${(value as { file: string }).file}`).bytes.toString("hex");
42
44
  }
43
45
  }
44
- const values: Record<string, number> = {};
45
- for (const [register, value] of Object.entries(seen.registers ?? {})) {
46
+ return { observed, values: cpuValues(seen.registers ?? {}) };
47
+ }
48
+
49
+ /** The CPU registers as values: each register by its name, the pc from a number or "$xxxx", and each flag as cpu.flags.<flag>. */
50
+ function cpuValues(registers: Record<string, unknown>): Record<string, number | boolean> {
51
+ const values: Record<string, number | boolean> = {};
52
+ for (const [register, value] of Object.entries(registers)) {
46
53
  if (typeof value === "number") values[`cpu.${register}`] = value;
47
54
  else if (typeof value === "string" && value.startsWith("$")) values[`cpu.${register}`] = parseC64Address(value);
55
+ else if (register === "flags" && isObject(value)) for (const [flag, set] of Object.entries(value)) if (typeof set === "boolean") values[`cpu.flags.${flag}`] = set;
48
56
  }
49
- return { observed, values };
57
+ return values;
50
58
  }
51
59
 
52
- /** The state of a snapshot: restored in the session, then read with the readers of the runs. */
53
- async function snapshotState(client: ViceSessionClient, path: string): Promise<MachineState> {
54
- await client.snapshotRestore(readProjectFile(path).bytes);
60
+ /** The size of the header of a module in a VICE snapshot file: the name (16 bytes), the version (2 bytes) and the size (4 bytes). */
61
+ const MODULE_HEADER = 22;
62
+ const VSF_MAGIC = "VICE Snapshot File\x1a";
63
+ const VSF_VERSION = "VICE Version\x1a";
64
+
65
+ /**
66
+ * The 64 KiB of C64 RAM that a VICE snapshot file holds: the C64MEM module,
67
+ * after the CPU port and the cartridge lines (4 bytes). Undefined when the
68
+ * file has no such module.
69
+ */
70
+ export function snapshotRam(file: Uint8Array): Uint8Array | undefined {
71
+ const bytes = Buffer.from(file);
72
+ if (bytes.subarray(0, VSF_MAGIC.length).toString("latin1") !== VSF_MAGIC) return undefined;
73
+ // The magic, the version (2 bytes) and the machine name (16 bytes); a newer VICE adds its own version (8 bytes).
74
+ let at = VSF_MAGIC.length + 2 + 16;
75
+ if (bytes.subarray(at, at + VSF_VERSION.length).toString("latin1") === VSF_VERSION) at += VSF_VERSION.length + 8;
76
+ while (at + MODULE_HEADER <= bytes.length) {
77
+ const name = bytes.subarray(at, at + 16).toString("latin1").replace(/\0+$/, "");
78
+ const size = bytes.readUInt32LE(at + 18);
79
+ if (size < MODULE_HEADER) return undefined;
80
+ if (name === "C64MEM") return at + MODULE_HEADER + 4 + 0x10000 <= bytes.length ? bytes.subarray(at + MODULE_HEADER + 4, at + MODULE_HEADER + 4 + 0x10000) : undefined;
81
+ at += size;
82
+ }
83
+ return undefined;
84
+ }
85
+
86
+ /**
87
+ * The state of a snapshot: restored in the session, then read with the
88
+ * readers of the runs. The RAM must be the RAM of the file: else the
89
+ * snapshot did not restore, and the state would be wrong.
90
+ */
91
+ async function snapshotState(client: ViceSessionClient, path: string, ntsc: boolean): Promise<MachineState> {
92
+ const file = readProjectFile(path).bytes;
93
+ const saved = snapshotRam(file);
94
+ if (saved === undefined) throw new Refusal("invalid-input", `${path} is not a snapshot of a C64: it has no C64 memory. Give a file that c64_snapshot save or a save step of a run wrote.`);
95
+ const restored = await client.snapshotRestore(file);
55
96
  const memory = await readMemoryInParts(client, 0x0000, 0x10000, "ram");
97
+ // $00 and $01 are the CPU port: the file keeps them apart from the RAM.
98
+ if (("restored" in restored && !restored.restored) || !memory.subarray(2).equals(Buffer.from(saved.subarray(2)))) {
99
+ throw new Refusal(
100
+ "invalid-input",
101
+ `${path} did not restore: the RAM after the restore is not the RAM of the file. A snapshot restores only in a machine of the video standard that saved it. ${ntsc ? "Leave out --ntsc for a PAL snapshot" : "Give --ntsc for an NTSC snapshot"}, or use the emulator version that saved it.`,
102
+ );
103
+ }
56
104
  const colorRam = Buffer.from((await client.ioRead(0xd800, 0x400)).data, "hex").map((value) => value & 0x0f);
57
105
  const vic = Buffer.from((await client.ioRead(0xd000, 0x2f)).data, "hex");
58
- const values: Record<string, number> = {};
106
+ const values: Record<string, number | boolean> = {};
59
107
  vic.forEach((value, index) => (values[`vic.${formatC64Address(0xd000 + index)}`] = value));
60
108
  for (const chip of (await client.cia("both")).chips) {
61
109
  for (const field of ["portA", "portB", "ddrA", "ddrB", "timerA", "timerB", "latchA", "latchB", "controlA", "controlB", "interruptMask"] as const) values[`cia${chip.id}.${field}`] = chip[field];
62
110
  }
63
111
  const sid = await client.sid();
64
112
  for (const voice of sid.voices) for (const [field, value] of Object.entries(voice)) if (field !== "index" && typeof value === "number") values[`sid.voice${voice.index}.${field}`] = value;
65
- const registers = await client.registersGet("c64");
66
- for (const [field, value] of Object.entries(registers)) if (typeof value === "number") values[`cpu.${field}`] = value;
113
+ Object.assign(values, cpuValues({ ...(await client.registersGet("c64")) }));
67
114
  return { memory: { base: 0, bytes: memory }, colorRam, values };
68
115
  }
69
116
 
70
117
  const show = (range: ChangedRange) => ({ start: formatC64Address(range.start), end: formatC64Address(range.end), ...(range.a === undefined ? {} : { a: range.a, b: range.b }) });
118
+ /** A changed value as a person reads it: the pc as "$xxxx". */
119
+ const showValue = (item: { name: string; a: number | boolean; b: number | boolean }) => (item.name === "cpu.pc" && typeof item.a === "number" && typeof item.b === "number" ? { name: item.name, a: formatC64Address(item.a), b: formatC64Address(item.b) } : item);
71
120
 
72
121
  /** The differences of two states of one kind: snapshots, memory files or observations of runs. */
73
122
  export async function diffFiles(first: string, second: string, options: { gap: number; max: number; ntsc: boolean; at?: number; out?: string }): Promise<Record<string, unknown>> {
@@ -90,8 +139,8 @@ export async function diffFiles(first: string, second: string, options: { gap: n
90
139
  } else {
91
140
  const client = await ViceSessionClient.open({ videoStandard: options.ntsc ? "ntsc" : "pal" });
92
141
  try {
93
- a = await snapshotState(client, first);
94
- b = await snapshotState(client, second);
142
+ a = await snapshotState(client, first, options.ntsc);
143
+ b = await snapshotState(client, second, options.ntsc);
95
144
  } finally {
96
145
  await client.close();
97
146
  }
@@ -101,7 +150,8 @@ export async function diffFiles(first: string, second: string, options: { gap: n
101
150
  const part = (item: { changedBytes: number; ranges: ChangedRange[] } | undefined) => (item === undefined ? undefined : { changedBytes: item.changedBytes, ...listed(item.ranges, max, show, rest) });
102
151
  let written: ReturnType<typeof writeReported> | undefined;
103
152
  if (options.out !== undefined) {
104
- const all = { a: first, b: second, ...(diff.memory === undefined ? {} : { memory: diff.memory.ranges.map(show) }), ...(diff.colorRam === undefined ? {} : { colorRam: diff.colorRam.ranges.map(show) }), values: diff.values ?? [], observed: diff.observed ?? [] };
153
+ // The names of the result: registers holds the changed values.
154
+ const all = { a: first, b: second, ...(diff.memory === undefined ? {} : { memory: diff.memory.ranges.map(show) }), ...(diff.colorRam === undefined ? {} : { colorRam: diff.colorRam.ranges.map(show) }), registers: (diff.values ?? []).map(showValue), observed: diff.observed ?? [] };
105
155
  written = writeReported(options.out, `${JSON.stringify(all, null, 2)}\n`, { what: "--out" });
106
156
  }
107
157
  return {
@@ -109,7 +159,7 @@ export async function diffFiles(first: string, second: string, options: { gap: n
109
159
  b: second,
110
160
  ...(diff.memory === undefined ? {} : { memory: part(diff.memory) }),
111
161
  ...(diff.colorRam === undefined ? {} : { colorRam: part(diff.colorRam) }),
112
- ...(diff.values === undefined ? {} : { registers: listed(diff.values, max, undefined, rest) }),
162
+ ...(diff.values === undefined ? {} : { registers: listed(diff.values, max, showValue, rest) }),
113
163
  ...(diff.observed === undefined ? {} : { observed: listed(diff.observed, max, undefined, rest) }),
114
164
  ...(written === undefined ? {} : written),
115
165
  };
@@ -10,8 +10,8 @@ export interface MachineState {
10
10
  memory?: { base: number; bytes: Uint8Array };
11
11
  /** Colour RAM $D800-$DBFF, the low 4 bits of each byte. */
12
12
  colorRam?: Uint8Array;
13
- /** Named single values: VIC-II, CIA and SID registers and the CPU registers, as "vic.$d011", "cia1.timerA", "cpu.pc". */
14
- values?: Record<string, number>;
13
+ /** Named single values: VIC-II, CIA and SID registers and the CPU registers, as "vic.$d011", "cia1.timerA", "cpu.pc", and each CPU flag as "cpu.flags.c". */
14
+ values?: Record<string, number | boolean>;
15
15
  /** The memory items of an observation of a run: the address or name, and its bytes as hex. */
16
16
  observed?: Record<string, string>;
17
17
  }
@@ -27,7 +27,7 @@ export interface ChangedRange {
27
27
  export interface StateDiff {
28
28
  memory?: { changedBytes: number; ranges: ChangedRange[] };
29
29
  colorRam?: { changedBytes: number; ranges: ChangedRange[] };
30
- values?: Array<{ name: string; a: number; b: number }>;
30
+ values?: Array<{ name: string; a: number | boolean; b: number | boolean }>;
31
31
  observed?: Array<{ address: string; a: string; b: string }>;
32
32
  }
33
33