@henols/c64-re-tools 2.0.0-rc.3 → 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 (73) hide show
  1. package/README.md +15 -3
  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/server.ts +6 -5
  45. package/src/host/vice/adapter.ts +25 -10
  46. package/src/host/vice/session.ts +64 -20
  47. package/src/host-client/autostart.ts +45 -0
  48. package/src/host-client/connect.ts +4 -2
  49. package/src/host-client/coverage.ts +59 -0
  50. package/src/host-client/steps.ts +170 -0
  51. package/src/host-client/transfer.ts +29 -3
  52. package/src/host-client/vice-session.ts +42 -13
  53. package/src/knowledge/history.ts +40 -13
  54. package/src/knowledge/import.ts +72 -56
  55. package/src/knowledge/read.ts +203 -52
  56. package/src/knowledge/schema.ts +94 -12
  57. package/src/knowledge/write.ts +432 -123
  58. package/src/mcp/output.ts +32 -0
  59. package/src/mcp/server.ts +4 -2
  60. package/src/mcp/tools/debug.ts +110 -24
  61. package/src/mcp/tools/input.ts +1 -0
  62. package/src/mcp/tools/media.ts +22 -6
  63. package/src/mcp/tools/memory.ts +57 -10
  64. package/src/mcp/tools/video.ts +9 -3
  65. package/src/native/discover.ts +5 -2
  66. package/src/native/ghidra/index.ts +19 -3
  67. package/src/native/ghidra/scripts/C64Export.java +33 -1
  68. package/src/native/status.ts +15 -3
  69. package/src/native/types.ts +30 -1
  70. package/{skills/c64-memory-map/scripts → src}/platform.ts +18 -2
  71. package/src/protocol/messages.ts +17 -1
  72. package/src/protocol/vice.ts +72 -18
  73. package/src/script.ts +3 -2
@@ -7,18 +7,23 @@ import { mkdirSync, writeFileSync } from "node:fs";
7
7
  import { dirname } from "node:path";
8
8
  import { parseArgs } from "node:util";
9
9
 
10
- import { formatC64Address, parseC64Address } from "#src/c64.ts";
10
+ import { formatC64Address, parseC64Address, prgRange } from "#src/c64.ts";
11
11
  import { WireFailure } from "#src/host-client/tools.ts";
12
- import { readProjectFile } from "#src/host-client/transfer.ts";
12
+ import { readProjectFile, writeProjectFile } from "#src/host-client/transfer.ts";
13
13
  import { analyze as analyzeWithDxa } from "#src/native/dxa.ts";
14
14
  import { analyze as analyzeWithGhidra } from "#src/native/ghidra/analyze.ts";
15
15
  import { localToolContext } from "#src/native/local.ts";
16
+ import { GHIDRA_LIMITS } from "#src/native/types.ts";
16
17
  import { KnowledgeError, openForRead, withWrite } from "#src/knowledge/database.ts";
17
18
  import { importFindings, runProvenance, type Analyzer, type ImportConflict, type ImportContext, type ImportResult, type NormalizedFindings } from "#src/knowledge/import.ts";
18
19
  import { currentRevision } from "#src/knowledge/read.ts";
20
+ import { DEFAULT_SPACE, SPACE } from "#src/knowledge/schema.ts";
19
21
  import { resolveProjectPath } from "#src/project.ts";
20
22
  import { runScript, UsageError } from "#src/script.ts";
23
+ import { codeEntries, coverageConflicts, coverageEntries, imageReader, readCoverageFiles } from "./coverage.ts";
21
24
  import { dxaFindings, ghidraFindings, seedsFromKnowledge } from "./findings.ts";
25
+ import { findIdioms } from "./idioms.ts";
26
+ import { instructionList } from "./instructions.ts";
22
27
 
23
28
  const USAGE = `analyze.ts <image> [options]
24
29
 
@@ -28,12 +33,25 @@ const USAGE = `analyze.ts <image> [options]
28
33
  --entry <address> an entry point, for example '$080d' (repeatable)
29
34
  --decompile <address> ghidra: a routine to decompile, at most 32 (repeatable)
30
35
  --listing <file> dxa: write the disassembly listing to this project file
36
+ --instructions <file> ghidra: write every instruction (address, bytes, mnemonic,
37
+ mode, operand, target, executed) to this project file as JSON
38
+ --coverage <file> a coverage map of 64 KiB from c64_memmap (repeatable): its
39
+ executed code is seeded, and analyzer data over it is reported
40
+ --max-listed <n> list at most n labels or functions (default 50, 0 for none);
41
+ the counts are always given, and knowledge has all of them
42
+ --space <space> the knowledge space of the image: c64 (the default), drive8
43
+ for 1541 drive code, or bank0 to bank255 for a cartridge bank
31
44
 
32
45
  Routine symbols in knowledge are entry points too. Ghidra needs at least one
33
46
  entry point; dxa finds the SYS of a BASIC start by itself. The result is one
34
47
  JSON object. Addresses are $ and four hex digits.`;
35
48
 
36
- const MAX_LISTED_FUNCTIONS = 200;
49
+ const DEFAULT_MAX_LISTED = 50;
50
+
51
+ /** The first `max` items, the count, and where to read the rest when the list is cut. */
52
+ function listed<T, U>(items: T[], max: number, show: (item: T) => U, all: string) {
53
+ return { count: items.length, listed: items.slice(0, max).map(show), ...(items.length > max ? { rest: all } : {}) };
54
+ }
37
55
 
38
56
  /** A decompiler that did not start gets the remedy: Ghidra ships no native decompiler for macOS. */
39
57
  function withRemedy(reason: string): string {
@@ -103,6 +121,10 @@ async function run(argv: string[]): Promise<unknown> {
103
121
  entry: { type: "string", multiple: true },
104
122
  decompile: { type: "string", multiple: true },
105
123
  listing: { type: "string" },
124
+ "max-listed": { type: "string" },
125
+ coverage: { type: "string", multiple: true },
126
+ instructions: { type: "string" },
127
+ space: { type: "string" },
106
128
  help: { type: "boolean" },
107
129
  },
108
130
  });
@@ -112,10 +134,14 @@ async function run(argv: string[]): Promise<unknown> {
112
134
  const entries = addresses(values.entry, "--entry");
113
135
  const decompile = addresses(values.decompile, "--decompile");
114
136
  if (decompile.length > 32) throw new UsageError("--decompile takes at most 32 routines");
137
+ const maxListed = values["max-listed"] ?? String(DEFAULT_MAX_LISTED);
138
+ if (!/^\d{1,5}$/.test(maxListed)) throw new UsageError(`--max-listed must be a whole number, not ${maxListed}`);
139
+ const max = Number(maxListed);
115
140
  const analyzer = values.analyzer ?? "ghidra";
116
141
  if (analyzer !== "dxa" && analyzer !== "ghidra") throw new UsageError(`--analyzer must be dxa or ghidra, not ${analyzer}`);
117
142
  if (analyzer === "dxa" && decompile.length > 0) throw new UsageError("--decompile works only with --analyzer ghidra");
118
143
  if (analyzer === "ghidra" && values.listing !== undefined) throw new UsageError("--listing works only with --analyzer dxa");
144
+ if (analyzer === "dxa" && values.instructions !== undefined) throw new UsageError("--instructions works only with --analyzer ghidra");
119
145
  let listingPath: string | undefined;
120
146
  if (values.listing !== undefined) {
121
147
  try {
@@ -125,18 +151,68 @@ async function run(argv: string[]): Promise<unknown> {
125
151
  }
126
152
  }
127
153
 
154
+ const space = values.space ?? DEFAULT_SPACE;
155
+ if (!SPACE.test(space)) throw new UsageError("--space must be c64, drive8, or bank0 to bank255");
156
+ // A coverage map is of the C64 CPU: VICE keeps none for the drive (D53).
157
+ if (space !== DEFAULT_SPACE && values.coverage !== undefined) throw new UsageError("--coverage works only in space c64");
158
+ const imageKind = values.flat64k ? "flat64k" : "prg";
159
+ const bytes = readProjectFile(image).bytes;
160
+ const coverage = readCoverageFiles(values.coverage ?? []);
161
+ const byteAt = imageReader(bytes, imageKind);
162
+
163
+ let loaded: { start: number; end: number };
164
+ try {
165
+ loaded = imageKind === "flat64k" ? { start: 0x0000, end: bytes.length - 1 } : prgRange(bytes);
166
+ } catch (error) {
167
+ throw new UsageError(`${image}: ${(error as Error).message}`);
168
+ }
169
+ const idioms = findIdioms(byteAt, { start: loaded.start, end: loaded.end }, coverage);
170
+
128
171
  const reader = openForRead();
129
172
  const revision = currentRevision(reader);
130
- const seeds = seedsFromKnowledge(reader, entries);
173
+ const seeds = seedsFromKnowledge(reader, entries, space);
174
+ // Knowledge routines and --entry first, then code regions and JMP tables, then idioms, then coverage, up to the seed bound (D44).
175
+ const fromCoverage = coverage === undefined ? [] : coverageEntries(coverage, byteAt);
176
+ const ordered = [...new Set([...seeds.entryPoints, ...codeEntries(reader, byteAt, space), ...idioms.entryPoints, ...fromCoverage])];
177
+ seeds.entryPoints = ordered.slice(0, GHIDRA_LIMITS.entryPoints).sort((a, b) => a - b);
178
+ // Idiom tables are data for this run too; semantic data ranges come first.
179
+ seeds.dataRanges = [...seeds.dataRanges, ...idioms.dataRanges.filter((range) => !seeds.dataRanges.some((seed) => range.start <= seed.end && range.end >= seed.start))].slice(0, GHIDRA_LIMITS.dataRanges);
180
+ const coverageUsed = fromCoverage.filter((entry) => seeds.entryPoints.includes(entry)).length;
131
181
  reader?.close();
132
-
133
- const imageKind = values.flat64k ? "flat64k" : "prg";
134
- const bytes = readProjectFile(image).bytes;
182
+ /** Imports the idioms as their own revision, after the analyzer's, and gives their part of the result. */
183
+ const idiomsResult = () => {
184
+ const imported = withWrite((db) => importFindings(db, { ...idioms.findings, space }, { description: `Idioms in ${image}` }));
185
+ return {
186
+ idioms: {
187
+ ...idioms.counts,
188
+ revision: imported.revision,
189
+ changes: { regions: imported.regions, references: imported.references },
190
+ conflicts: imported.conflicts.map((conflict) => showConflict(conflict, "heuristic")),
191
+ candidates: { dop: idioms.candidates.dop.map(formatC64Address), selfModified: idioms.candidates.selfModified.map(formatC64Address) },
192
+ },
193
+ };
194
+ };
195
+ /** The coverage part of a result: its seeds, and the analyzer data that it shows to be code. */
196
+ const coverageResult = () => {
197
+ if (coverage === undefined) return {};
198
+ const db = openForRead();
199
+ const conflicts = coverageConflicts(db, coverage);
200
+ db?.close();
201
+ return {
202
+ coverageSeeds: { entryPoints: fromCoverage.length, used: coverageUsed },
203
+ coverageConflicts: conflicts.map(({ region, executed }) => ({
204
+ range: { start: formatC64Address(region.start), end: formatC64Address(region.end) },
205
+ knowledge: { type: region.type, origin: region.origin },
206
+ executed,
207
+ problem: "coverage shows code",
208
+ })),
209
+ };
210
+ };
135
211
  if (analyzer === "dxa") {
136
212
  const { result, attachments } = await analyzeWithDxa({ imageKind, ...seeds }, bytes, localToolContext());
137
213
  const listing = attachments[0]!.toString("utf8");
138
214
  // The listing is written only after the import: a refused import leaves no new file.
139
- const imported = record(dxaFindings(result), revision, `DXA analysis of ${image}`, runProvenance(bytes, result.toolVersion));
215
+ const imported = record({ ...dxaFindings(result), space }, revision, `DXA analysis of ${image}`, runProvenance(bytes, result.toolVersion));
140
216
  if (listingPath !== undefined) {
141
217
  try {
142
218
  mkdirSync(dirname(listingPath), { recursive: true });
@@ -146,25 +222,38 @@ async function run(argv: string[]): Promise<unknown> {
146
222
  throw new WireFailure("operation-failed", `The script did not write the listing to ${values.listing}: ${(error as Error).message}. ${recorded}`);
147
223
  }
148
224
  }
225
+ const found = idiomsResult();
149
226
  return {
150
227
  analyzer: "dxa",
228
+ ...(space === DEFAULT_SPACE ? {} : { space }),
229
+ ...found,
230
+ ...coverageResult(),
151
231
  coverage: result.coverage.map((range) => ({ start: formatC64Address(range.start), end: formatC64Address(range.end) })),
152
232
  ...imported,
153
233
  regions: result.regions.map((region) => ({ start: formatC64Address(region.start), end: formatC64Address(region.end), classification: region.classification })),
154
- labels: result.labels.slice(0, MAX_LISTED_FUNCTIONS).map((label) => ({ address: formatC64Address(label.address), name: label.name })),
155
- ...(result.labels.length > MAX_LISTED_FUNCTIONS ? { moreLabels: result.labels.length - MAX_LISTED_FUNCTIONS } : {}),
234
+ labels: listed(result.labels, max, (label) => ({ address: formatC64Address(label.address), name: label.name }), "c64-knowledge: knowledge.ts symbols"),
156
235
  ...(listingPath === undefined ? {} : { listing: values.listing }),
157
236
  };
158
237
  }
159
238
 
160
239
  const { result } = await analyzeWithGhidra({ imageKind, ...seeds, decompile }, bytes, localToolContext());
161
- const imported = record(ghidraFindings(result), revision, `Ghidra analysis of ${image}`, runProvenance(bytes, result.toolVersion));
240
+ const imported = record({ ...ghidraFindings(result), space }, revision, `Ghidra analysis of ${image}`, runProvenance(bytes, result.toolVersion));
241
+ const found = idiomsResult();
242
+ let instructions: { out: string; count: number } | undefined;
243
+ if (values.instructions !== undefined) {
244
+ const list = instructionList(result.instructions, coverage);
245
+ writeProjectFile(values.instructions, Buffer.from(`${JSON.stringify(list, null, 1)}\n`), { what: "--instructions" });
246
+ instructions = { out: values.instructions, count: list.length };
247
+ }
162
248
  return {
163
249
  analyzer: "ghidra",
250
+ ...(space === DEFAULT_SPACE ? {} : { space }),
251
+ ...(instructions === undefined ? {} : { instructions }),
252
+ ...found,
253
+ ...coverageResult(),
164
254
  coverage: result.coverage.map((range) => ({ start: formatC64Address(range.start), end: formatC64Address(range.end) })),
165
255
  ...imported,
166
- functions: result.functions.slice(0, MAX_LISTED_FUNCTIONS).map((fn) => ({ entry: formatC64Address(fn.entry), name: fn.name })),
167
- ...(result.functions.length > MAX_LISTED_FUNCTIONS ? { moreFunctions: result.functions.length - MAX_LISTED_FUNCTIONS } : {}),
256
+ functions: listed(result.functions, max, (fn) => ({ entry: formatC64Address(fn.entry), name: fn.name }), "c64-knowledge: knowledge.ts symbols --kind routine"),
168
257
  decompilations: result.decompilations.map((item) => ({ entry: formatC64Address(item.entry), text: item.text, ...(item.truncated ? { truncated: true } : {}) })),
169
258
  ...((result.notDecompiled ?? []).length === 0
170
259
  ? {}
@@ -0,0 +1,78 @@
1
+ // Runtime evidence for static analysis (D44): coverage files as entry points,
2
+ // user/LLM code regions and JMP tables as entry points, and the check that
3
+ // shows analyzer data over executed code.
4
+
5
+ import type { DatabaseSync } from "node:sqlite";
6
+
7
+ import { COVERAGE, instructionLength } from "#src/c64.ts";
8
+ import { FULL_COVERAGE, readCoverageFiles as readFiles } from "#src/host-client/coverage.ts";
9
+ import { listRegions, type RegionRow } from "#src/knowledge/read.ts";
10
+ import { CODE_REGION_TYPES, DEFAULT_SPACE, type Space } from "#src/knowledge/schema.ts";
11
+ import { UsageError } from "#src/script.ts";
12
+
13
+ const FULL_MAP = FULL_COVERAGE;
14
+ /** Instructions after which the next instruction is not reached by falling through. */
15
+ const FLOW_ENDS = new Set([0x00, 0x40, 0x4c, 0x60, 0x6c]);
16
+ const ANALYZER_ORIGINS = new Set(["dxa", "ghidra", "heuristic"]);
17
+
18
+ /** Reads coverage files and joins them: an address has every flag that any run gave it. */
19
+ export function readCoverageFiles(paths: string[]): Uint8Array | undefined {
20
+ return readFiles(paths, (message) => {
21
+ throw new UsageError(message);
22
+ });
23
+ }
24
+
25
+ /** The byte at an address of the analyzed image, or undefined outside it. */
26
+ export type ImageByte = (address: number) => number | undefined;
27
+
28
+ export function imageReader(bytes: Uint8Array, kind: "prg" | "flat64k"): ImageByte {
29
+ if (kind === "flat64k") return (address) => bytes[address];
30
+ const load = bytes[0]! | (bytes[1]! << 8);
31
+ return (address) => (address >= load && address - load + 2 < bytes.length ? bytes[address - load + 2] : undefined);
32
+ }
33
+
34
+ /**
35
+ * The executed instruction starts in RAM that begin a flow: the first after
36
+ * a gap, or the first after an instruction that does not fall through. The
37
+ * analyzers follow the rest of a flow themselves.
38
+ */
39
+ export function coverageEntries(map: Uint8Array, image: ImageByte): number[] {
40
+ const entries: number[] = [];
41
+ let falls: number | undefined;
42
+ for (let address = 0; address < FULL_MAP; address++) {
43
+ if ((map[address]! & COVERAGE.ramExecute) === 0) continue;
44
+ const opcode = image(address);
45
+ if (opcode === undefined) continue;
46
+ if (falls !== address) entries.push(address);
47
+ falls = FLOW_ENDS.has(opcode) ? undefined : address + instructionLength(opcode);
48
+ }
49
+ return entries;
50
+ }
51
+
52
+ /** Entry points from semantic knowledge beyond routines: the start of each code region, and each entry of a JMP table with its target. */
53
+ export function codeEntries(db: DatabaseSync | undefined, image: ImageByte, space: Space = DEFAULT_SPACE): number[] {
54
+ const entries: number[] = [];
55
+ for (const region of listRegions(db, undefined, undefined, undefined, space).filter((row) => row.origin === "user" || row.origin === "llm")) {
56
+ if (region.type === "code") entries.push(region.start);
57
+ if (region.type !== "jumps") continue;
58
+ for (let at = region.start; at + 2 <= region.end; at += 3) {
59
+ entries.push(at);
60
+ const low = image(at + 1);
61
+ const high = image(at + 2);
62
+ if (image(at) === 0x4c && low !== undefined && high !== undefined) entries.push(low | (high << 8));
63
+ }
64
+ }
65
+ return entries;
66
+ }
67
+
68
+ /** Analyzer data regions that hold executed instruction starts: the coverage shows code there. */
69
+ export function coverageConflicts(db: DatabaseSync | undefined, map: Uint8Array): Array<{ region: RegionRow; executed: number }> {
70
+ const conflicts: Array<{ region: RegionRow; executed: number }> = [];
71
+ for (const region of listRegions(db)) {
72
+ if (!ANALYZER_ORIGINS.has(region.origin) || CODE_REGION_TYPES.includes(region.type) || region.type === "dual-use") continue;
73
+ let executed = 0;
74
+ for (let address = region.start; address <= region.end; address++) if ((map[address]! & COVERAGE.ramExecute) !== 0) executed++;
75
+ if (executed > 0) conflicts.push({ region, executed });
76
+ }
77
+ return conflicts;
78
+ }
@@ -5,6 +5,7 @@ import type { DatabaseSync } from "node:sqlite";
5
5
 
6
6
  import type { NormalizedFindings } from "#src/knowledge/import.ts";
7
7
  import { listRegions, listSymbols } from "#src/knowledge/read.ts";
8
+ import { CODE_REGION_TYPES, DEFAULT_SPACE, type Space } from "#src/knowledge/schema.ts";
8
9
  import type { DxaResult, GhidraParams, GhidraResult } from "#src/native/types.ts";
9
10
 
10
11
  export type Seeds = Pick<GhidraParams, "entryPoints" | "dataRanges" | "labels">;
@@ -18,13 +19,14 @@ const semantic = (row: { origin: string }) => row.origin === "user" || row.origi
18
19
  * earlier guess would force the same answer again, and a re-analysis could
19
20
  * not retire it.
20
21
  */
21
- export function seedsFromKnowledge(db: DatabaseSync | undefined, extraEntryPoints: number[] = []): Seeds {
22
- const symbols = listSymbols(db).filter(semantic);
22
+ export function seedsFromKnowledge(db: DatabaseSync | undefined, extraEntryPoints: number[] = [], space: Space = DEFAULT_SPACE): Seeds {
23
+ const symbols = listSymbols(db, undefined, undefined, undefined, space).filter(semantic);
23
24
  const entryPoints = [...new Set([...extraEntryPoints, ...symbols.filter((symbol) => symbol.kind === "routine").map((symbol) => symbol.address)])].sort((a, b) => a - b);
24
25
  return {
25
26
  entryPoints,
26
- dataRanges: listRegions(db)
27
- .filter((region) => semantic(region) && region.type !== "code")
27
+ dataRanges: listRegions(db, undefined, undefined, undefined, space)
28
+ // A JMP table is code and dual-use bytes are both: neither is a data range (D42).
29
+ .filter((region) => semantic(region) && !CODE_REGION_TYPES.includes(region.type) && region.type !== "dual-use")
28
30
  .map((region) => ({ start: region.start, end: region.end })),
29
31
  labels: symbols.map((symbol) => ({ address: symbol.address, name: symbol.name })),
30
32
  };
@@ -0,0 +1,144 @@
1
+ // 6502 idioms that hide code from a disassembler (D44): RTS dispatch tables,
2
+ // JMP tables and BIT skips are found by their exact instruction patterns and
3
+ // become heuristic findings and entry points; DOP padding and self-modified
4
+ // opcodes are only reported. A table's length is not known statically:
5
+ // entries count while each target is a plausible instruction in the program.
6
+
7
+ import { COVERAGE } from "#src/c64.ts";
8
+ import type { NormalizedFindings } from "#src/knowledge/import.ts";
9
+ import type { ImageByte } from "./coverage.ts";
10
+
11
+ const MAX_TABLE = 64;
12
+ const MIN_JUMPS = 3;
13
+ const LDA_INDEXED = new Set([0xbd, 0xb9]);
14
+ const JAMS = new Set([0x02, 0x12, 0x22, 0x32, 0x42, 0x52, 0x62, 0x72, 0x92, 0xb2, 0xd2, 0xf2]);
15
+ /** Undocumented NOPs with an operand byte (DOP): padding between instructions hides the real ones. */
16
+ const DOPS = new Set([0x04, 0x14, 0x34, 0x44, 0x54, 0x64, 0x74, 0x80, 0x82, 0x89, 0xc2, 0xd4, 0xe2, 0xf4]);
17
+
18
+ export interface Idioms {
19
+ findings: NormalizedFindings;
20
+ /** Entry points for the same analysis run: analyzer facts are never seeds. */
21
+ entryPoints: number[];
22
+ /** Table ranges for the same run: they are data. */
23
+ dataRanges: Array<{ start: number; end: number }>;
24
+ counts: { rtsTables: number; jumpTables: number; bitSkips: number };
25
+ /** Seen in the coverage, not written: the LLM decides. */
26
+ candidates: { dop: number[]; selfModified: number[] };
27
+ }
28
+
29
+ /** True when an instruction can start at the address: inside the program, and no JAM. */
30
+ function plausible(byteAt: ImageByte, address: number, range: { start: number; end: number }): boolean {
31
+ const opcode = byteAt(address);
32
+ return address >= range.start && address <= range.end && opcode !== undefined && !JAMS.has(opcode);
33
+ }
34
+
35
+ const word = (byteAt: ImageByte, address: number) => {
36
+ const low = byteAt(address);
37
+ const high = byteAt(address + 1);
38
+ return low === undefined || high === undefined ? undefined : low | (high << 8);
39
+ };
40
+
41
+ export function findIdioms(byteAt: ImageByte, range: { start: number; end: number }, coverage?: Uint8Array): Idioms {
42
+ const regions: NormalizedFindings["regions"] = [];
43
+ const references: NormalizedFindings["references"] = [];
44
+ const entryPoints = new Set<number>();
45
+ const counts = { rtsTables: 0, jumpTables: 0, bitSkips: 0 };
46
+ const taken = (start: number, end: number) => regions.some((region) => start <= region.end && end >= region.start);
47
+ const addRegion = (start: number, end: number, type: NormalizedFindings["regions"][number]["type"]) => {
48
+ if (start < range.start || end > range.end || taken(start, end)) return false;
49
+ regions.push({ start, end, type });
50
+ return true;
51
+ };
52
+
53
+ for (let at = range.start; at + 8 <= range.end; at++) {
54
+ // RTS dispatch: LDA high,X / PHA / LDA low,X / PHA / RTS.
55
+ if (LDA_INDEXED.has(byteAt(at)!) && byteAt(at + 3) === 0x48 && LDA_INDEXED.has(byteAt(at + 4)!) && byteAt(at + 7) === 0x48 && byteAt(at + 8) === 0x60) {
56
+ const high = word(byteAt, at + 1)!;
57
+ const low = word(byteAt, at + 5)!;
58
+ const interleaved = high === low + 1;
59
+ const step = interleaved ? 2 : 1;
60
+ const targets: number[] = [];
61
+ for (let index = 0; index < MAX_TABLE; index++) {
62
+ const lowByte = byteAt(low + index * step);
63
+ const highByte = byteAt(high + index * step);
64
+ if (lowByte === undefined || highByte === undefined) break;
65
+ const target = ((highByte << 8) | lowByte) + 1;
66
+ if (!plausible(byteAt, target, range)) break;
67
+ targets.push(target);
68
+ }
69
+ if (targets.length === 0) continue;
70
+ const n = targets.length;
71
+ const written = interleaved
72
+ ? addRegion(low, low + 2 * n - 1, "rts-addresses")
73
+ : low + n === high
74
+ ? addRegion(low, high + n - 1, "rts-addresses-split")
75
+ : addRegion(low, low + n - 1, "bytes") && addRegion(high, high + n - 1, "bytes");
76
+ if (!written) continue;
77
+ counts.rtsTables++;
78
+ for (const target of targets) {
79
+ references.push({ from: at + 8, to: target, kind: "jump" });
80
+ entryPoints.add(target);
81
+ }
82
+ }
83
+ }
84
+
85
+ // JMP tables: at least three JMP abs in a row.
86
+ for (let at = range.start; at + 3 * MIN_JUMPS - 1 <= range.end; at++) {
87
+ let n = 0;
88
+ while (at + 3 * n + 2 <= range.end && byteAt(at + 3 * n) === 0x4c) n++;
89
+ if (n < MIN_JUMPS || !addRegion(at, at + 3 * n - 1, "jumps")) continue;
90
+ counts.jumpTables++;
91
+ for (let index = 0; index < n; index++) {
92
+ entryPoints.add(at + 3 * index);
93
+ const target = word(byteAt, at + 3 * index + 1)!;
94
+ if (plausible(byteAt, target, range)) entryPoints.add(target);
95
+ }
96
+ at += 3 * n - 1;
97
+ }
98
+
99
+ // BIT skips: BIT abs ($2c) whose operand bytes are an instruction that a JMP, a JSR or the coverage enters.
100
+ const entered = new Set<number>();
101
+ for (let at = range.start; at + 2 <= range.end; at++) {
102
+ if (byteAt(at) === 0x4c || byteAt(at) === 0x20) entered.add(word(byteAt, at + 1)!);
103
+ }
104
+ if (coverage !== undefined) for (let address = range.start; address <= range.end; address++) if ((coverage[address]! & COVERAGE.ramExecute) !== 0) entered.add(address);
105
+ for (let at = range.start; at + 2 <= range.end; at++) {
106
+ if (byteAt(at) !== 0x2c || !entered.has(at + 1) || !plausible(byteAt, at + 1, range)) continue;
107
+ if (!addRegion(at + 1, at + 2, "dual-use")) continue;
108
+ counts.bitSkips++;
109
+ entryPoints.add(at);
110
+ entryPoints.add(at + 1);
111
+ for (let from = range.start; from + 2 <= range.end; from++) {
112
+ if ((byteAt(from) === 0x4c || byteAt(from) === 0x20) && word(byteAt, from + 1) === at + 1) references.push({ from, to: at + 1, kind: byteAt(from) === 0x20 ? "call" : "jump" });
113
+ }
114
+ }
115
+
116
+ // Candidates from the coverage: DOP padding that ran, and opcodes that the program wrote and then ran.
117
+ const candidates = { dop: [] as number[], selfModified: [] as number[] };
118
+ if (coverage !== undefined) {
119
+ for (let address = range.start; address <= range.end; address++) {
120
+ const flags = coverage[address]!;
121
+ if ((flags & COVERAGE.ramExecute) === 0) continue;
122
+ if (DOPS.has(byteAt(address)!) && candidates.dop.length < MAX_TABLE) candidates.dop.push(address);
123
+ if ((flags & COVERAGE.ramWrite) !== 0 && candidates.selfModified.length < MAX_TABLE) candidates.selfModified.push(address);
124
+ }
125
+ }
126
+
127
+ regions.sort((a, b) => a.start - b.start);
128
+ const dataRanges = regions.filter((region) => region.type !== "jumps" && region.type !== "dual-use").map(({ start, end }) => ({ start, end }));
129
+ return {
130
+ findings: {
131
+ analyzer: "heuristic",
132
+ coverage: [range],
133
+ authoritative: { symbols: true, regions: true, references: true },
134
+ symbols: [],
135
+ regions,
136
+ // Each reference once: the importer keys them by from, to and kind.
137
+ references: [...new Map(references.map((reference) => [`${reference.from}|${reference.to}|${reference.kind}`, reference])).values()],
138
+ },
139
+ entryPoints: [...entryPoints].sort((a, b) => a - b),
140
+ dataRanges,
141
+ counts,
142
+ candidates,
143
+ };
144
+ }
@@ -0,0 +1,105 @@
1
+ // The structured instruction list (D44): Ghidra's decoded instructions with
2
+ // the addressing mode, the operand in 6502 syntax, the flow target and,
3
+ // with a coverage file, whether the instruction ran.
4
+
5
+ import { COVERAGE, formatC64Address } from "#src/c64.ts";
6
+ import type { GhidraInstruction } from "#src/native/types.ts";
7
+
8
+ export type Mode =
9
+ | "implied"
10
+ | "accumulator"
11
+ | "immediate"
12
+ | "zeropage"
13
+ | "zeropage,x"
14
+ | "zeropage,y"
15
+ | "absolute"
16
+ | "absolute,x"
17
+ | "absolute,y"
18
+ | "(indirect)"
19
+ | "(indirect,x)"
20
+ | "(indirect),y"
21
+ | "relative";
22
+
23
+ const BRANCHES = new Set([0x10, 0x30, 0x50, 0x70, 0x90, 0xb0, 0xd0, 0xf0]);
24
+ const JMP_INDIRECT = 0x6c;
25
+
26
+ /**
27
+ * The addressing mode from Ghidra's operand text and the instruction length.
28
+ * Ghidra prints zero page and absolute alike, and JMP (indirect) as an
29
+ * address, so the length and the opcode decide those.
30
+ */
31
+ export function modeOf(opcode: number, length: number, operands: string): Mode {
32
+ const text = operands.toUpperCase();
33
+ if (text === "") return "implied";
34
+ if (text === "A") return "accumulator";
35
+ if (text.startsWith("#")) return "immediate";
36
+ if (text.startsWith("(") && text.endsWith(",X)")) return "(indirect,x)";
37
+ if (text.startsWith("(") && text.endsWith("),Y")) return "(indirect),y";
38
+ if (text.endsWith(",X")) return length === 2 ? "zeropage,x" : "absolute,x";
39
+ if (text.endsWith(",Y")) return length === 2 ? "zeropage,y" : "absolute,y";
40
+ if (BRANCHES.has(opcode)) return "relative";
41
+ if (opcode === JMP_INDIRECT) return "(indirect)";
42
+ return length === 2 ? "zeropage" : "absolute";
43
+ }
44
+
45
+ /** The operand in 6502 syntax, from the instruction bytes; empty for implied. */
46
+ export function operandText(mode: Mode, bytes: Uint8Array, target: number | undefined): string {
47
+ const byte = `$${(bytes[1] ?? 0).toString(16).padStart(2, "0")}`;
48
+ const word = formatC64Address((bytes[1] ?? 0) | ((bytes[2] ?? 0) << 8));
49
+ switch (mode) {
50
+ case "implied":
51
+ return "";
52
+ case "accumulator":
53
+ return "a";
54
+ case "immediate":
55
+ return `#${byte}`;
56
+ case "zeropage":
57
+ return byte;
58
+ case "zeropage,x":
59
+ return `${byte},x`;
60
+ case "zeropage,y":
61
+ return `${byte},y`;
62
+ case "absolute":
63
+ return word;
64
+ case "absolute,x":
65
+ return `${word},x`;
66
+ case "absolute,y":
67
+ return `${word},y`;
68
+ case "(indirect)":
69
+ return `(${word})`;
70
+ case "(indirect,x)":
71
+ return `(${byte},x)`;
72
+ case "(indirect),y":
73
+ return `(${byte}),y`;
74
+ case "relative":
75
+ return target === undefined ? byte : formatC64Address(target);
76
+ }
77
+ }
78
+
79
+ export interface ListedInstruction {
80
+ address: string;
81
+ bytes: string;
82
+ mnemonic: string;
83
+ mode: Mode;
84
+ operand: string;
85
+ target?: string;
86
+ executed?: boolean;
87
+ }
88
+
89
+ /** Ghidra's instructions as the list that --instructions writes. */
90
+ export function instructionList(instructions: GhidraInstruction[], coverage?: Uint8Array): ListedInstruction[] {
91
+ return instructions.map((instruction) => {
92
+ const bytes = Buffer.from(instruction.bytes, "hex");
93
+ const mode = modeOf(bytes[0]!, bytes.length, instruction.operands);
94
+ const [target] = instruction.flows;
95
+ return {
96
+ address: formatC64Address(instruction.address),
97
+ bytes: (instruction.bytes.match(/../g) ?? []).join(" "),
98
+ mnemonic: instruction.mnemonic,
99
+ mode,
100
+ operand: operandText(mode, bytes, target),
101
+ ...(target === undefined ? {} : { target: formatC64Address(target) }),
102
+ ...(coverage === undefined ? {} : { executed: (coverage[instruction.address]! & COVERAGE.ramExecute) !== 0 }),
103
+ };
104
+ });
105
+ }
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: c64-testing
3
- description: Use this skill to test if a rebuilt or changed C64 program behaves as the original. It runs one scenario on the original and then on the rebuild in an emulator. It compares memory, registers, chip state and the screen with explicit rules, and gives PASS, FAIL or INCONCLUSIVE. It also makes a checklist for a human playtest. Do not use it to find the cause of a difference.
3
+ description: Use this skill to test if a rebuilt or changed C64 program behaves as the original. It runs one scenario on the original and then on the rebuild in an emulator. It compares memory, registers, chip state and the screen with explicit rules, and gives PASS, FAIL or INCONCLUSIVE. It also makes a checklist for a human playtest. Use its run command to explore a program with many steps, for example to visit each room or level. A run keeps screens, coverage maps and snapshots as files. Do not use it to find the cause of a difference.
4
4
  ---
5
5
 
6
6
  # c64-testing
@@ -22,14 +22,34 @@ A PASS proves only the behavior that the scenario does and observes. It does not
22
22
  The script starts one emulator through the host runtime. It runs the scenario on the original and then on the rebuild. Before each side, it does a hard reset of the emulator. The CPU then stops at the reset vector.
23
23
 
24
24
  ```
25
- node <skill>/scripts/test.ts tests/moves-left.json
25
+ node <skill>/scripts/test.ts tests/moves-left.json --out results/moves-left
26
26
  node <skill>/scripts/checklist.ts tests/moves-left.json --item 'FIRE starts the game.'
27
27
  ```
28
28
 
29
- The test script prints one JSON object. The exit status is 0 for PASS, 1 for FAIL, 3 for INCONCLUSIVE and 2 for a bad call.
29
+ The test script prints one JSON object. The exit status is 0 for PASS, 1 for FAIL, 3 for INCONCLUSIVE and 2 for a bad call. With `--out`, a FAIL writes files into that directory: both screens and the difference image for each screen difference, and `memory.json` with each byte that differs. `files` in the result names them.
30
30
 
31
31
  The checklist script prints a Markdown checklist for a human tester.
32
32
 
33
+ ### Runs
34
+
35
+ 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.
36
+
37
+ ```
38
+ node <skill>/scripts/run.ts runs/rooms.json --out runs/rooms
39
+ ```
40
+
41
+ The run file gives where to start (a program, or a snapshot file), the steps, and optional symbols. To go on from the live emulator, save its state with `c64_snapshot` action `save` and `out`, and start the run from that file. Besides the steps of a scenario, a run has these steps:
42
+
43
+ | Step | Effect |
44
+ |---|---|
45
+ | `{ "snapshot": "snapshots/room0.vsf" }` | Restore a snapshot file. |
46
+ | `{ "save": "room3" }` | Write the state now to `room3.vsf` in the output directory. |
47
+ | `{ "screen": "room3" }` | Write the screen to `room3.png`. |
48
+ | `{ "coverage": "room3" }` | Write the coverage map since the start or the last coverage step to `room3.bin`, then clear the map. |
49
+ | `{ "observe": "room3", "memory": ["$c100"], "registers": true }` | Record memory, registers, `vicii` or `sprites` in `results.json`. |
50
+
51
+ See [references/scenario-format.md](references/scenario-format.md), section Runs. The script prints one JSON object. The exit status is 0 when all steps ran, 1 when a step did not reach its target, and 2 for a bad call. A long run can need more time than one shell call. Then run it in the background.
52
+
33
53
  ## Workflow
34
54
 
35
55
  1. Write the scenario file. Start the steps with a frame count, for example `{ "frames": 150 }`. Each side starts at the reset vector, and the C64 needs these frames to start.
@@ -66,8 +86,12 @@ PASS, FAIL or INCONCLUSIVE, the checkpoints and the smallest differences that ex
66
86
  - 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.
67
87
  - INCONCLUSIVE, "has no symbol": one side has no symbol with that name. Add the name to the knowledge or to the rebuild source.
68
88
  - INCONCLUSIVE, "no emulator" or "could not run": the host runtime or the emulator failed. Tell the user the message.
89
+ - `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.
69
90
  - A FAIL with only a `screen` difference: add `vicii` or `sprites` to the observation to find which chip state is different.
70
91
  - A program that uses random numbers: set the random seed with a `write` step on both sides. If that is not possible, observe only values that do not depend on the random numbers.
92
+ - The two sides start differently, for example the original after a cracked intro. Give the steps that only one side needs a `side`. Then make the memory the same with a `normalize` step from the entry state of the c64-unpacker skill.
93
+ - Frame counts drift by one frame between the sides: the sides got to the same address at different raster positions. Do not sync on an IRQ handler. Sync on a value that the IRQ changes, with `runUntil` `memory`, or on an address and a raster line together.
94
+ - A byte that cannot be the same on both sides, for example a read-only collision register: use `exclude` with the reason. Do not use `exclude` to hide a real difference.
71
95
 
72
96
  Do not add a mask or a tolerance only to make a test pass. A tolerance must have a reason in the behavior of the program.
73
97