@henols/c64-re-tools 2.0.0-rc.6 → 2.0.0-rc.7

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 (48) hide show
  1. package/package.json +1 -1
  2. package/skills/c64-assembler/SKILL.md +1 -1
  3. package/skills/c64-basic/SKILL.md +1 -1
  4. package/skills/c64-disk/SKILL.md +3 -3
  5. package/skills/c64-disk/scripts/disk.ts +2 -1
  6. package/skills/c64-emulator/SKILL.md +3 -1
  7. package/skills/c64-knowledge/SKILL.md +1 -1
  8. package/skills/c64-provenance/SKILL.md +7 -5
  9. package/skills/c64-provenance/scripts/compare.ts +26 -0
  10. package/skills/c64-provenance/scripts/provenance.ts +24 -10
  11. package/skills/c64-provenance/scripts/separate.ts +12 -4
  12. package/skills/c64-reverse-engineering/SKILL.md +2 -2
  13. package/skills/c64-source/SKILL.md +15 -9
  14. package/skills/c64-source/scripts/generate.ts +36 -10
  15. package/skills/c64-source/scripts/loader.ts +88 -6
  16. package/skills/c64-source/scripts/source.ts +40 -9
  17. package/skills/c64-static-analysis/SKILL.md +4 -4
  18. package/skills/c64-static-analysis/scripts/analyze.ts +13 -6
  19. package/skills/c64-static-analysis/scripts/coverage.ts +4 -4
  20. package/skills/c64-static-analysis/scripts/idioms.ts +14 -9
  21. package/skills/c64-testing/SKILL.md +1 -1
  22. package/skills/c64-testing/scripts/scenario.ts +2 -2
  23. package/skills/c64-unpacker/SKILL.md +9 -7
  24. package/skills/c64-unpacker/scripts/loads.ts +12 -1
  25. package/skills/c64-unpacker/scripts/stages.ts +66 -3
  26. package/skills/c64-unpacker/scripts/unpack.ts +96 -30
  27. package/src/c64.ts +14 -5
  28. package/src/cli/status.ts +26 -3
  29. package/src/host/tools/c1541.ts +34 -5
  30. package/src/host/tools/petcat.ts +5 -2
  31. package/src/host/tools/status.ts +6 -3
  32. package/src/host/vice/adapter.ts +23 -9
  33. package/src/host/vice/process.ts +7 -2
  34. package/src/host/vice/session.ts +47 -10
  35. package/src/host-client/connect.ts +10 -10
  36. package/src/host-client/coverage.ts +19 -10
  37. package/src/host-client/tools.ts +5 -5
  38. package/src/host-client/vice-session.ts +1 -1
  39. package/src/knowledge/schema.ts +7 -0
  40. package/src/knowledge/write.ts +5 -0
  41. package/src/mcp/tools/debug.ts +3 -2
  42. package/src/mcp/tools/machine.ts +4 -1
  43. package/src/mcp/tools/media.ts +5 -1
  44. package/src/mcp/tools/video.ts +2 -0
  45. package/src/native/discover.ts +2 -1
  46. package/src/platform.ts +3 -2
  47. package/src/protocol/tools.ts +19 -4
  48. package/src/protocol/vice.ts +11 -2
@@ -1,6 +1,8 @@
1
1
  // The loader of a generated source (D48): a PRG that copies each segment and
2
- // the entry state's pages $0002-$03ff into place, sets $00/$01, the stack
3
- // pointer, A, X, Y and the flags, and jumps to the entry. The copies run
2
+ // the entry state's pages $0002-$03ff into place, sets the display registers
3
+ // of the VIC-II and the VIC bank, waits for the entry's raster line (D60),
4
+ // sets $00/$01, the stack pointer, A, X, Y and the flags, and jumps to the
5
+ // entry. The copies run
4
6
  // from a free page with SEI and all RAM visible, in an order where no copy
5
7
  // overwrites the packed bytes of a copy that has not run yet. The plan is
6
8
  // simulated here before the emulator runs it.
@@ -20,6 +22,31 @@ export interface EntryState {
20
22
  port: [number, number];
21
23
  /** $0000-$03ff at the entry. */
22
24
  pages: Uint8Array;
25
+ /** The VIC-II at the entry (D60): $d000-$d02e, and the VIC bank (bits 0-1 of $dd00). */
26
+ vic?: { registers: Uint8Array; bank: number };
27
+ }
28
+
29
+ /**
30
+ * The VIC-II registers that the loader sets (D60), each with the bits that a
31
+ * read gives back: not the raster, the light pen, the interrupt registers or
32
+ * the collisions. The raster line comes from a wait instead.
33
+ */
34
+ export const VIC_DISPLAY_REGISTERS: ReadonlyArray<{ register: number; mask: number }> = [
35
+ ...Array.from({ length: 0x11 }, (_, register) => ({ register, mask: 0xff })),
36
+ { register: 0x11, mask: 0x7f },
37
+ { register: 0x15, mask: 0xff },
38
+ { register: 0x16, mask: 0x3f },
39
+ { register: 0x17, mask: 0xff },
40
+ { register: 0x18, mask: 0xfe },
41
+ { register: 0x1b, mask: 0xff },
42
+ { register: 0x1c, mask: 0xff },
43
+ { register: 0x1d, mask: 0xff },
44
+ ...Array.from({ length: 0x0f }, (_, index) => ({ register: 0x20 + index, mask: 0x0f })),
45
+ ];
46
+
47
+ /** The raster line of the entry, from $d011 bit 7 and $d012 as the capture read them. */
48
+ export function rasterLine(registers: Uint8Array): number {
49
+ return ((registers[0x11]! & 0x80) << 1) | registers[0x12]!;
23
50
  }
24
51
 
25
52
  const FLAG_BITS: Record<string, number> = { n: 0x80, v: 0x40, b: 0x10, d: 0x08, i: 0x04, z: 0x02, c: 0x01 };
@@ -57,6 +84,7 @@ export function readEntryState(text: string, path: string): EntryState {
57
84
  flags,
58
85
  port: [byte(value.cpuPort?.$00, "cpuPort.$00"), byte(value.cpuPort?.$01, "cpuPort.$01")],
59
86
  pages,
87
+ ...readVic(value.io, fail),
60
88
  };
61
89
  }
62
90
 
@@ -97,9 +125,14 @@ const STUB = 0x080d;
97
125
  const STUB_SIZE = 21;
98
126
  /** The copier code and its three variables, without the table. */
99
127
  const COPIER_CODE_SIZE = 167;
128
+ /** The VIC-II code of the copier (D60), without its two tables of one byte per register. */
129
+ const VIC_CODE_SIZE = 44;
130
+ const VIC_SIZE = VIC_CODE_SIZE + 2 * VIC_DISPLAY_REGISTERS.length;
131
+ /** The copier and the stub's move of it: two pages. */
132
+ const COPIER_LIMIT = 0x200;
100
133
  const COPY_ENTRY = 7;
101
- /** X indexes the table, so the table fits in 256 bytes. */
102
- export const MAX_COPIES = Math.floor((256 - 2) / COPY_ENTRY);
134
+ /** X indexes the table, so the table fits in 256 bytes, and the copier with the VIC-II code fits in two pages. */
135
+ export const MAX_COPIES = Math.min(Math.floor((256 - 2) / COPY_ENTRY), Math.floor((COPIER_LIMIT - COPIER_CODE_SIZE - VIC_SIZE - 2) / COPY_ENTRY));
103
136
  /** A loader that loads into $d000 or higher would write the I/O area. */
104
137
  const LOAD_LIMIT = 0xcfff;
105
138
  const COPIER_PAGES = [...Array.from({ length: 15 }, (_, index) => 0xc000 + index * 0x100), ...Array.from({ length: 0x9b }, (_, index) => 0x9e00 - index * 0x100)];
@@ -147,6 +180,16 @@ export function simulate(memory: Int16Array, copies: Copy[], expected: (address:
147
180
  return undefined;
148
181
  }
149
182
 
183
+ /** The VIC-II and its bank from the io part of an entry state, when it has them. */
184
+ function readVic(io: Record<string, any> | undefined, fail: (what: string) => never): { vic: NonNullable<EntryState["vic"]> } | Record<string, never> {
185
+ if (io?.vic === undefined) return {};
186
+ const vic = io.vic.bytes;
187
+ const cia2 = io.cia2?.bytes;
188
+ if (typeof vic !== "string" || !/^[0-9a-f]{94}$/i.test(vic)) return fail("io.vic.bytes is not 47 bytes of hex");
189
+ if (typeof cia2 !== "string" || !/^[0-9a-f]{2}/i.test(cia2)) return fail("io.cia2.bytes is missing");
190
+ return { vic: { registers: Buffer.from(vic, "hex"), bank: Number.parseInt(cia2.slice(0, 2), 16) & 0x03 } };
191
+ }
192
+
150
193
  export interface LoaderInput {
151
194
  /** The segments with the files that hold them, in address order. */
152
195
  segments: Array<Range & { files: string[] }>;
@@ -181,7 +224,7 @@ export function planLoader(input: LoaderInput): LoaderPlan {
181
224
  }
182
225
  if (plain.length > MAX_COPIES) throw new WireFailure("invalid-input", `The loader needs ${plain.length} copies; it can do at most ${MAX_COPIES}. Join segments in the unpacker capture (--range) or write the loader by hand.`);
183
226
  const copierImage = at;
184
- const copierSize = COPIER_CODE_SIZE + COPY_ENTRY * plain.length + 2;
227
+ const copierSize = COPIER_CODE_SIZE + (state.vic === undefined ? 0 : VIC_SIZE) + COPY_ENTRY * plain.length + 2;
185
228
  const end = copierImage + copierSize - 1;
186
229
  if (end > LOAD_LIMIT) throw new WireFailure("invalid-input", `The loader would end at ${hex4(end)}, after ${hex4(LOAD_LIMIT)}: loading it would write the I/O area. The image is too large for one loader file.`);
187
230
 
@@ -286,7 +329,9 @@ export function planLoader(input: LoaderInput): LoaderPlan {
286
329
  " adc #7",
287
330
  " tax",
288
331
  " jmp .next",
289
- `.done lda #${hex2(port0)}`,
332
+ ".done",
333
+ ...vicLines(state),
334
+ ` lda #${hex2(port0)}`,
290
335
  " sta $00",
291
336
  ` lda #${hex2(port1)}`,
292
337
  " sta $01",
@@ -303,6 +348,12 @@ export function planLoader(input: LoaderInput): LoaderPlan {
303
348
  ".count !word 0",
304
349
  ".index !byte 0",
305
350
  ".back !byte 0",
351
+ ...(state.vic === undefined
352
+ ? []
353
+ : [
354
+ `.vicreg !byte ${VIC_DISPLAY_REGISTERS.map((item) => hex2(item.register)).join(",")}`,
355
+ `.vicval !byte ${VIC_DISPLAY_REGISTERS.map((item) => hex2(state.vic!.registers[item.register]!)).join(",")}`,
356
+ ]),
306
357
  ".table",
307
358
  );
308
359
  for (const copy of copies) {
@@ -322,3 +373,34 @@ export function planLoader(input: LoaderInput): LoaderPlan {
322
373
  text: `${lines.join("\n")}\n`,
323
374
  };
324
375
  }
376
+
377
+ /**
378
+ * The copier's VIC-II part (D60): with the I/O area visible it sets the
379
+ * display registers and the VIC bank of the entry state, then waits for the
380
+ * entry's raster line, so the program starts in the same phase of the frame.
381
+ */
382
+ function vicLines(state: EntryState): string[] {
383
+ if (state.vic === undefined) return [];
384
+ const line = rasterLine(state.vic.registers);
385
+ return [
386
+ " lda #$35",
387
+ " sta $01",
388
+ ` ldx #${hex2(VIC_DISPLAY_REGISTERS.length - 1)}`,
389
+ ".vic ldy .vicreg,x",
390
+ " lda .vicval,x",
391
+ " sta $d000,y",
392
+ " dex",
393
+ " bpl .vic",
394
+ " lda $dd00",
395
+ " and #$fc",
396
+ ` ora #${hex2(state.vic.bank)}`,
397
+ " sta $dd00",
398
+ `.raster lda $d012`,
399
+ ` cmp #${hex2(line & 0xff)}`,
400
+ " bne .raster",
401
+ " lda $d011",
402
+ " and #$80",
403
+ ` cmp #${hex2((line >> 1) & 0x80)}`,
404
+ " bne .raster",
405
+ ];
406
+ }
@@ -7,7 +7,7 @@
7
7
 
8
8
  import { COVERAGE, formatC64Address } from "#src/c64.ts";
9
9
  import { WireFailure } from "#src/host-client/tools.ts";
10
- import type { CommentPlacement, RegionType } from "#src/knowledge/schema.ts";
10
+ import { isGeneratedFile, type CommentPlacement, type RegionType } from "#src/knowledge/schema.ts";
11
11
  import { hasZeroPageForm, MNEMONICS, MODE_LENGTH, OPCODES, takesAddress, type Mode } from "./opcodes.ts";
12
12
 
13
13
  export interface Range {
@@ -31,13 +31,10 @@ export interface SourceInput {
31
31
  referenceTargets: readonly number[];
32
32
  /** Joined coverage files (D40): an instruction that no run executed is marked (D49). */
33
33
  coverage?: Uint8Array;
34
- /** Platform names (D52): the name of an address that knowledge does not name. */
35
- platform?: ReadonlyMap<number, string>;
34
+ /** Platform names (D52): the name of an address that knowledge does not name, and the last address of its entry. */
35
+ platform?: ReadonlyArray<{ address: number; end: number; name: string }>;
36
36
  }
37
37
 
38
- /** Files that the generator writes itself; a module cannot have these names. */
39
- export const GENERATED_FILES = ["main.asm", "loader.asm", "constants.asm"] as const;
40
-
41
38
  const CODE_TYPES: readonly RegionType[] = ["code", "jumps", "dual-use"];
42
39
  const BYTES_PER_LINE = 8;
43
40
  const WORDS_PER_LINE = 4;
@@ -106,6 +103,24 @@ function operandValue(address: number, opcode: number, byteAt: (address: number)
106
103
  return low;
107
104
  }
108
105
 
106
+ /**
107
+ * Each entry point inside a data region of the knowledge, with that region.
108
+ * A data region wins over an entry (D44, D48): Ghidra does not decode there,
109
+ * and the source has data there. A code region in the knowledge changes that (F-27).
110
+ */
111
+ export function entriesInData<R extends { start: number; end: number; type: RegionType }>(entries: readonly number[], regions: readonly R[]): Array<{ entry: number; region: R }> {
112
+ return entries.flatMap((entry) => {
113
+ const region = regions.find((row) => entry >= row.start && entry <= row.end && !CODE_TYPES.includes(row.type));
114
+ return region === undefined ? [] : [{ entry, region }];
115
+ });
116
+ }
117
+
118
+ /** Why an image without its entry fails: the image does not hold the program (R2-02). Undefined when the entry is in a segment, or the image has no entry. */
119
+ export function entryOutsideImage(entry: number | undefined, segments: readonly Range[]): string | undefined {
120
+ if (entry === undefined || segments.some((segment) => entry >= segment.start && entry <= segment.end)) return undefined;
121
+ return `The entry ${hex4(entry)} is in no segment of the image: the image does not hold the program. Capture it again with c64-unpacker loads or capture.`;
122
+ }
123
+
109
124
  /** Splits the segments into pieces at module starts and lays out instructions and data. */
110
125
  export function layout(input: SourceInput): Layout {
111
126
  const inImage = (address: number) => input.segments.some((segment) => address >= segment.start && address <= segment.end);
@@ -116,7 +131,7 @@ export function layout(input: SourceInput): Layout {
116
131
  };
117
132
 
118
133
  for (const module of input.modules) {
119
- if ((GENERATED_FILES as readonly string[]).includes(module.file)) throw new WireFailure("invalid-input", `The module at ${hex4(module.start)} has the file ${module.file}, which the generator writes itself. Give the module another file.`);
134
+ if (isGeneratedFile(module.file)) throw new WireFailure("invalid-input", `The module at ${hex4(module.start)} has the file ${module.file}, which the generator writes itself. Give the module another file.`);
120
135
  }
121
136
  const modules = [...input.modules].sort((a, b) => a.start - b.start);
122
137
  const unusedModules = modules.filter((module) => !inImage(module.start)).map(({ start, file }) => ({ start, file }));
@@ -184,10 +199,18 @@ export function layout(input: SourceInput): Layout {
184
199
  const symbols = new Map(input.symbols.map((symbol) => [symbol.address, symbol.name]));
185
200
  // A platform name that knowledge gives to another address, or that reads as an instruction, is not used.
186
201
  const knowledgeNames = new Set(input.symbols.map((symbol) => symbol.name));
187
- const platform = new Map([...(input.platform ?? [])].filter(([, name]) => !knowledgeNames.has(name) && !MNEMONICS.has(name.toLowerCase())));
202
+ const platform = new Map((input.platform ?? []).filter(({ name }) => !knowledgeNames.has(name) && !MNEMONICS.has(name.toLowerCase())).map(({ address, name }) => [address, name]));
203
+ /** Addresses outside the image without a name of their own, inside a named platform entry of more bytes: the start of that entry (F-20). */
204
+ const insideEntry = new Map<number, number>();
188
205
  const use = (address: number) => {
189
206
  if (inImage(address)) wanted.add(address);
190
207
  else if (symbols.has(address) || platform.has(address)) outside.add(address);
208
+ else {
209
+ const entry = input.platform?.find((row) => address > row.address && address <= row.end && !inImage(row.address) && (symbols.has(row.address) || platform.has(row.address)));
210
+ if (entry === undefined) return;
211
+ outside.add(entry.address);
212
+ insideEntry.set(address, entry.address);
213
+ }
191
214
  };
192
215
  for (const piece of pieces) {
193
216
  for (const item of piece.items) {
@@ -226,6 +249,8 @@ export function layout(input: SourceInput): Layout {
226
249
  names.set(address, name);
227
250
  generatedLabels++;
228
251
  }
252
+ // The high byte of a vector is NMINV+1. A zero-page entry keeps zero-page instructions: its constant has two digits.
253
+ for (const [address, start] of insideEntry) names.set(address, `${names.get(start)!}+${address - start}`);
229
254
  return {
230
255
  pieces,
231
256
  names,
@@ -490,6 +515,8 @@ export function render(layout: Layout, asBytes: ReadonlySet<string>, header: str
490
515
  const out: string[] = [`; ${piece.title}`, `; ${hex4(piece.start)}-${hex4(piece.end)}`, ""];
491
516
  /** True while the instructions before have not executed: they form one range. */
492
517
  let running = false;
518
+ /** The line of the mark of the open range of instructions that did not execute. */
519
+ let markLine = 0;
493
520
  const head = (address: number) => {
494
521
  for (const text of lineComments.get(address) ?? []) out.push(`; ${text}`.trimEnd());
495
522
  const label = layout.names.get(address);
@@ -503,8 +530,12 @@ export function render(layout: Layout, asBytes: ReadonlySet<string>, header: str
503
530
  if (running) notExecuted.ranges.at(-1)!.end = item.address + item.length - 1;
504
531
  else {
505
532
  notExecuted.ranges.push({ start: item.address, end: item.address + item.length - 1 });
506
- out.push("; not executed");
533
+ markLine = out.length;
534
+ out.push("");
507
535
  }
536
+ // The mark gives the range, so a reader finds it in the full list (D49).
537
+ const range = notExecuted.ranges.at(-1)!;
538
+ out[markLine] = `; not executed ${hex4(range.start)}-${hex4(range.end)}`;
508
539
  notExecuted.instructions++;
509
540
  }
510
541
  running = !executed;
@@ -31,7 +31,7 @@ node <skill>/scripts/analyze.ts extracted/game.prg --entry '$080d' --decompile '
31
31
  | `--listing analysis/game.lst` | DXA only: write the disassembly listing to this project file. |
32
32
  | `--decompile '$0818'` | Ghidra only: decompile the routine at this address. Use it again for more routines, at most 32. |
33
33
  | `--flat64k` | The file is 64 KiB of memory from `$0000`, not a PRG. |
34
- | `--max-listed 20` | List at most this number of labels or functions. The default is 50. Use 0 to list none. |
34
+ | `--max-listed 20` | List at most this number of labels, functions or candidates. The default is 50. Use 0 to list none. |
35
35
  | `--instructions analysis/instructions.json` | Ghidra only: write every instruction to this project file as JSON. Each has `address`, `bytes`, `mnemonic`, `mode` and `operand` in 6502 syntax. A branch, jump or call also has its `target`. With `--coverage`, `executed` tells if it ran. |
36
36
  | `--space drive8` | The knowledge space of the image: `drive8` for code from the 1541 drive, or `bank0` to `bank255` for a cartridge bank. The default is `c64`. The seeds come from that space, and the findings go into it. |
37
37
  | `--coverage coverage/attract.bin` | A coverage file of 64 KiB from `c64_memmap` (action `read` with `out`). Use it again for more runs. The executed code becomes entry points. |
@@ -63,9 +63,9 @@ The analyzers take at most 1024 entry points. Knowledge routines and `--entry` c
63
63
  - `changes` counts the symbols, regions and references that the analysis `added`, `changed`, `retired` or kept `unchanged`. `suppressed` counts the findings that the analysis did not write, because a removal blocks that place.
64
64
  - DXA: `regions` lists the code and data regions. `labels` gives the number of names that DXA found (`count`) and lists the first names (`listed`).
65
65
  - Ghidra: `functions` gives the number of routines that Ghidra found (`count`) and lists the first routines (`listed`). `decompilations` gives C-like code for the requested routines.
66
- - If a list is not complete, `rest` gives the c64-knowledge command that lists all of them.
66
+ - If a list is not complete, `rest` gives the c64-knowledge command that lists all of them. For `candidates`, `rest` tells you to give a larger `--max-listed`.
67
67
  - `conflicts` lists the places where the analyzer and the current knowledge disagree.
68
- - `idioms` tells what the script found by pattern: RTS dispatch tables (`rtsTables`), JMP tables (`jumpTables`) and BIT skips (`bitSkips`). The script writes them as knowledge with the origin `heuristic`, and seeds their targets into the same analysis. `candidates` gives DOP padding and opcodes that the program wrote and then executed. The script does not write those. Look at them and decide.
68
+ - `idioms` tells what the script found by pattern: RTS dispatch tables (`rtsTables`), JMP tables (`jumpTables`) and BIT skips (`bitSkips`). The script writes them as knowledge with the origin `heuristic`, and seeds their targets into the same analysis. `candidates` gives DOP padding (`dop`) and opcodes that the program wrote and then executed (`selfModified`). For `selfModified`, the write and the execution must be in one coverage file. Each list has a `count`. The script does not write the candidates. Look at them and decide.
69
69
  - With `--coverage`, a code finding replaces the DXA or Ghidra data where a run executed an instruction start. `coverageConflicts` lists the analyzer data regions where the coverage still shows executed code. Classify them as code with the c64-knowledge skill, or as `dual-use` when the bytes are also data.
70
70
  6. Give each important routine a name with the c64-knowledge skill. Then run the analysis again. The analyzers use the new names.
71
71
 
@@ -108,7 +108,7 @@ The exit status is 0 for a result, also with conflicts. The exit status is 1 for
108
108
  - `invalid-input` with a message about an entry point: no entry point is inside the program for Ghidra. Give one with `--entry`.
109
109
  - `stale-revision`: the knowledge changed during the analysis. The script recorded nothing. Run the analysis again.
110
110
  - `operation-failed` with a message about an incomplete result or listing: the analyzer did not give a complete result. The script recorded nothing. Tell the user.
111
- - `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.
111
+ - `installation-incomplete`, or `machine-unavailable` with 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.
112
112
  - `notDecompiled`: Ghidra did not decompile these routines. Each entry has the reason. If the reason says that the decompiler did not start, tell the user the remedy in the reason. On macOS, Ghidra has no native decompiler until the user builds it: `./gradlew buildNatives` in the `support/gradle` directory of Ghidra.
113
113
 
114
114
  ## Handoffs
@@ -9,6 +9,7 @@ import { dirname } from "node:path";
9
9
  import { parseArgs } from "node:util";
10
10
 
11
11
  import { COVERAGE, formatC64Address, parseC64Address, prgRange } from "#src/c64.ts";
12
+ import { joinCoverage } from "#src/host-client/coverage.ts";
12
13
  import { WireFailure } from "#src/host-client/tools.ts";
13
14
  import { readProjectFile, writeProjectFile } from "#src/host-client/transfer.ts";
14
15
  import { analyze as analyzeWithDxa } from "#src/native/dxa.ts";
@@ -21,7 +22,7 @@ import { currentRevision } from "#src/knowledge/read.ts";
21
22
  import { DEFAULT_SPACE, SPACE } from "#src/knowledge/schema.ts";
22
23
  import { resolveProjectPath } from "#src/project.ts";
23
24
  import { runScript, UsageError } from "#src/script.ts";
24
- import { codeEntries, coverageConflicts, coverageEntries, imageReader, readCoverageFiles } from "./coverage.ts";
25
+ import { codeEntries, coverageConflicts, coverageEntries, imageReader, readCoverageMaps } from "./coverage.ts";
25
26
  import { dxaFindings, ghidraFindings, seedsFromKnowledge } from "./findings.ts";
26
27
  import { findIdioms } from "./idioms.ts";
27
28
  import { instructionList } from "./instructions.ts";
@@ -38,8 +39,9 @@ const USAGE = `analyze.ts <image> [options]
38
39
  mode, operand, target, executed) to this project file as JSON
39
40
  --coverage <file> a coverage map of 64 KiB from c64_memmap (repeatable): its
40
41
  executed code is seeded, and analyzer data over it is reported
41
- --max-listed <n> list at most n labels or functions (default 50, 0 for none);
42
- the counts are always given, and knowledge has all of them
42
+ --max-listed <n> list at most n labels, functions or candidates (default 50,
43
+ 0 for none); the counts are always given, and knowledge has
44
+ all labels and functions
43
45
  --space <space> the knowledge space of the image: c64 (the default), drive8
44
46
  for 1541 drive code, or bank0 to bank255 for a cartridge bank
45
47
 
@@ -158,7 +160,8 @@ async function run(argv: string[]): Promise<unknown> {
158
160
  if (space !== DEFAULT_SPACE && values.coverage !== undefined) throw new UsageError("--coverage works only in space c64");
159
161
  const imageKind = values.flat64k ? "flat64k" : "prg";
160
162
  const bytes = readProjectFile(image).bytes;
161
- const coverage = readCoverageFiles(values.coverage ?? []);
163
+ const runs = readCoverageMaps(values.coverage ?? []);
164
+ const coverage = runs.length === 0 ? undefined : joinCoverage(runs);
162
165
  const byteAt = imageReader(bytes, imageKind);
163
166
  // With coverage, a code finding over an executed instruction start replaces the data of the other analyzer (D58), and the files are the evidence.
164
167
  const executed = coverage === undefined ? {} : { executed: (address: number) => (coverage[address]! & COVERAGE.ramExecute) !== 0 };
@@ -170,7 +173,7 @@ async function run(argv: string[]): Promise<unknown> {
170
173
  } catch (error) {
171
174
  throw new UsageError(`${image}: ${(error as Error).message}`);
172
175
  }
173
- const idioms = findIdioms(byteAt, { start: loaded.start, end: loaded.end }, coverage);
176
+ const idioms = findIdioms(byteAt, { start: loaded.start, end: loaded.end }, runs);
174
177
 
175
178
  const reader = openForRead();
176
179
  const revision = currentRevision(reader);
@@ -192,7 +195,11 @@ async function run(argv: string[]): Promise<unknown> {
192
195
  revision: imported.revision,
193
196
  changes: { regions: imported.regions, references: imported.references },
194
197
  conflicts: imported.conflicts.map((conflict) => showConflict(conflict, "heuristic")),
195
- candidates: { dop: idioms.candidates.dop.map(formatC64Address), selfModified: idioms.candidates.selfModified.map(formatC64Address) },
198
+ // Candidates are not knowledge: a larger --max-listed gives more of them.
199
+ candidates: {
200
+ dop: listed(idioms.candidates.dop, max, formatC64Address, "a larger --max-listed"),
201
+ selfModified: listed(idioms.candidates.selfModified, max, formatC64Address, "a larger --max-listed"),
202
+ },
196
203
  },
197
204
  };
198
205
  };
@@ -5,7 +5,7 @@
5
5
  import type { DatabaseSync } from "node:sqlite";
6
6
 
7
7
  import { COVERAGE, instructionLength } from "#src/c64.ts";
8
- import { FULL_COVERAGE, readCoverageFiles as readFiles } from "#src/host-client/coverage.ts";
8
+ import { FULL_COVERAGE, readCoverageMaps as readMaps } from "#src/host-client/coverage.ts";
9
9
  import { listRegions, type RegionRow } from "#src/knowledge/read.ts";
10
10
  import { CODE_REGION_TYPES, DEFAULT_SPACE, type Space } from "#src/knowledge/schema.ts";
11
11
  import { UsageError } from "#src/script.ts";
@@ -15,9 +15,9 @@ const FULL_MAP = FULL_COVERAGE;
15
15
  const FLOW_ENDS = new Set([0x00, 0x40, 0x4c, 0x60, 0x6c]);
16
16
  const ANALYZER_ORIGINS = new Set(["dxa", "ghidra", "heuristic"]);
17
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) => {
18
+ /** Reads coverage files, one map per file (a run). */
19
+ export function readCoverageMaps(paths: string[]): Uint8Array[] {
20
+ return readMaps(paths, (message) => {
21
21
  throw new UsageError(message);
22
22
  });
23
23
  }
@@ -1,10 +1,13 @@
1
1
  // 6502 idioms that hide code from a disassembler (D44): RTS dispatch tables,
2
2
  // JMP tables and BIT skips are found by their exact instruction patterns and
3
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.
4
+ // opcodes are only reported. A self-modified opcode is one that one run wrote
5
+ // and executed: a copier's writes in one run and their execution in another
6
+ // do not count (F-16). A table's length is not known statically: entries
7
+ // count while each target is a plausible instruction in the program.
6
8
 
7
9
  import { COVERAGE } from "#src/c64.ts";
10
+ import { joinCoverage } from "#src/host-client/coverage.ts";
8
11
  import type { NormalizedFindings } from "#src/knowledge/import.ts";
9
12
  import type { ImageByte } from "./coverage.ts";
10
13
 
@@ -22,7 +25,7 @@ export interface Idioms {
22
25
  /** Table ranges for the same run: they are data. */
23
26
  dataRanges: Array<{ start: number; end: number }>;
24
27
  counts: { rtsTables: number; jumpTables: number; bitSkips: number };
25
- /** Seen in the coverage, not written: the LLM decides. */
28
+ /** Seen in the coverage, not written: the LLM decides. All of them: the result lists the first ones. */
26
29
  candidates: { dop: number[]; selfModified: number[] };
27
30
  }
28
31
 
@@ -38,7 +41,9 @@ const word = (byteAt: ImageByte, address: number) => {
38
41
  return low === undefined || high === undefined ? undefined : low | (high << 8);
39
42
  };
40
43
 
41
- export function findIdioms(byteAt: ImageByte, range: { start: number; end: number }, coverage?: Uint8Array): Idioms {
44
+ /** `runs` are the coverage maps, one for each file. */
45
+ export function findIdioms(byteAt: ImageByte, range: { start: number; end: number }, runs: readonly Uint8Array[] = []): Idioms {
46
+ const coverage = runs.length === 0 ? undefined : joinCoverage(runs);
42
47
  const regions: NormalizedFindings["regions"] = [];
43
48
  const references: NormalizedFindings["references"] = [];
44
49
  const entryPoints = new Set<number>();
@@ -113,14 +118,14 @@ export function findIdioms(byteAt: ImageByte, range: { start: number; end: numbe
113
118
  }
114
119
  }
115
120
 
116
- // Candidates from the coverage: DOP padding that ran, and opcodes that the program wrote and then ran.
121
+ // Candidates from the coverage: DOP padding that ran, and opcodes that one run wrote and executed.
117
122
  const candidates = { dop: [] as number[], selfModified: [] as number[] };
118
123
  if (coverage !== undefined) {
124
+ const both = COVERAGE.ramWrite | COVERAGE.ramExecute;
119
125
  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);
126
+ if ((coverage[address]! & COVERAGE.ramExecute) === 0) continue;
127
+ if (DOPS.has(byteAt(address)!)) candidates.dop.push(address);
128
+ if (runs.some((run) => (run[address]! & both) === both)) candidates.selfModified.push(address);
124
129
  }
125
130
  }
126
131
 
@@ -86,7 +86,7 @@ PASS, FAIL or INCONCLUSIVE, the checkpoints and the smallest differences that ex
86
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.
87
87
  - INCONCLUSIVE, "has no symbol": one side has no symbol with that name. Add the name to the knowledge or to the rebuild source.
88
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.
89
+ - `installation-incomplete`, or `machine-unavailable` with 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.
90
90
  - A FAIL with only a `screen` difference: add `vicii` or `sprites` to the observation to find which chip state is different.
91
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
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.
@@ -283,7 +283,7 @@ export function resolve(location: Location, symbols: Map<string, number>, side:
283
283
  interface Observation {
284
284
  memory: Array<{ label: string; address: number; bytes: number[]; tolerance: number; excluded: Array<{ offset: number; at: Location; reason: string }> }>;
285
285
  registers: Partial<Record<ObservedRegister, unknown>>;
286
- screen?: { match: boolean; mismatchingPixels: number; mismatchRatio: number; bounds?: unknown };
286
+ screen?: { match: boolean; mismatchingPixels: number; comparedPixels: number; mismatchRatio: number; bounds?: unknown };
287
287
  vicii?: unknown;
288
288
  sprites?: unknown;
289
289
  /** PNG images for the files of a FAIL (D46): the side's screen, and on the rebuild the difference image. */
@@ -371,7 +371,7 @@ async function runSide(client: ViceSessionClient, scenario: Scenario, side: Side
371
371
  if (files) observation.png = shot.png;
372
372
  } else {
373
373
  const compared = await client.screenCompare({ baseline, maxMismatchRatio: step.screen.maxMismatchRatio ?? 0, mask: step.screen.mask ?? [], includeDiff: files });
374
- observation.screen = { match: compared.match, mismatchingPixels: compared.mismatchingPixels, mismatchRatio: compared.mismatchRatio, ...(compared.bounds === undefined ? {} : { bounds: compared.bounds }) };
374
+ observation.screen = { match: compared.match, mismatchingPixels: compared.mismatchingPixels, comparedPixels: compared.comparedPixels, mismatchRatio: compared.mismatchRatio, ...(compared.bounds === undefined ? {} : { bounds: compared.bounds }) };
375
375
  if (files) {
376
376
  observation.png = (await client.screenCapture()).png;
377
377
  if (compared.diffPng !== undefined) observation.diffPng = compared.diffPng;
@@ -26,11 +26,11 @@ node <skill>/scripts/unpack.ts stages original/game.prg --steps input.json
26
26
  node <skill>/scripts/unpack.ts loads original/game.d64 --steps parts.json --out parts --drive
27
27
  ```
28
28
 
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.
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` and `capture`, a `frames` step and a `runUntil` step with only `at` count as run time. `stages` refuses a `runUntil` step with `raster` or `memory`.
30
30
 
31
31
  | Option | Meaning |
32
32
  |---|---|
33
- | `--until '$c000'` | The address where the unpacked program starts. |
33
+ | `--until '$c000'` | The address where the unpacked program starts. The capture stops there after the steps. To capture a later arrival at the address, add a `runUntil` step to an address after the first arrival. |
34
34
  | `--out unpacked/game.prg` | Where to write the result, relative to the project directory. |
35
35
  | `--range '$c000' '$cfff'` | Write this range as a PRG. Without it, the script writes all 64 KiB of RAM as a flat file. |
36
36
  | `--timeout-frames 3000` | How many frames the program can run before the capture stops (default 3000). |
@@ -64,16 +64,17 @@ A `type` step puts keys in the keyboard queue of the KERNAL. A program that read
64
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
65
  - `decodeLoops`: EOR instructions in loops that store with an index. This is usually decryption or decoding.
66
66
  2. Run `trace`. It gives the evidence from the run. Read `packing`:
67
- - `likely`: the program wrote code and then executed it. It unpacked, decrypted or moved code. `runningRange` is the written code that runs at the end of the trace. When the trace ends in ROM code, for example in an interrupt, it is the written code that runs next. Its `start` is usually the entry point of the unpacked program. When no written code runs in 50 more frames, the result has no `runningRange` and no `suggestedUntil`.
67
+ - `likely`: the program wrote code and then executed it. It unpacked, decrypted or moved code. `runningRange` is the written code that runs at the end of the trace. When the trace ends in ROM code, for example in an interrupt, it is the written code that runs next. Its `start` is code that runs after the unpacking. It is not always the entry point of the program. When no written code runs in 50 more frames, the result has no `runningRange` and no `suggestedUntil`.
68
68
  - `unclear`: the program executed a small quantity of code that it wrote. This can be code that changes itself.
69
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.
70
70
  3. Find the address where the unpacked program starts:
71
- - Run `stages`. The `entry` of the last stage is usually the start of the program.
71
+ - Run `stages`. The `entry` of the last stage is usually the start of the program. A routine that a stage writes and calls is part of that stage when it returns to the stage.
72
72
  - Or use `runningRange.start` from `trace`, or `suggestedUntil`. The CPU gets to `suggestedUntil` after the unpacking.
73
73
  - Or use the c64-emulator skill: set a breakpoint after the unpack loop, or look at the last JMP of the unpacker.
74
+ - Disassemble the code at the address with the c64-emulator skill. Make sure that it is the start of the program. The start of a program often sets the stack pointer, the interrupts and the vectors.
74
75
  4. Run `capture` with that address and a directory in `--out`. Give the same `--steps` as before. The directory gets:
75
76
  - `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
+ - `image.json`: the entry, the segments, the vectors and the stages. A segment is a block that a stage wrote. A block is not a segment when it holds the entry of a later stage before the program, and no stage wrote over that entry. The block with the capture entry is always a segment. Segments do not overlap. `writtenBy` tells the last stage that wrote into the segment.
77
78
  - One PRG for each segment.
78
79
  - `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
80
  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.
@@ -113,7 +114,7 @@ Do not record the unpacker in the project knowledge, unless the user asks to exa
113
114
 
114
115
  The packing evidence, the stages, or the unpacked program with its image, segments and entry state in a directory of the project.
115
116
 
116
- For `loads`: `captures`, with the name, the PC, the segments and the drive file of each capture.
117
+ For `loads`: `captures`, with the name, the PC, the segments and the drive file of each capture. `keyWait: true` tells that the CPU was in the KERNAL wait for a key.
117
118
 
118
119
  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.
119
120
 
@@ -122,10 +123,11 @@ The exit status is 0 for a result. The exit status is 1 for `traced: false`, for
122
123
  - `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.
123
124
  - `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
125
  - A step did not reach its target: the reason names the step. Change the step or give it more frames.
126
+ - `loaded: false` and no capture has a segment: the program did not load a part, or it did not start. `keyWait: true` on a capture tells that the C64 waited at READY. or at an INPUT statement. For a tape image, read `emulatorFlaw`. A known VICE flaw can stop a tape load. Tell the user to install the latest VICE.
125
127
  - Do not name a packer from weak evidence. The script never names one.
126
128
  - 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.
127
129
  - `not-found` or `invalid-input`: a path or an argument is wrong. Read the message.
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.
130
+ - `installation-incomplete`, or `machine-unavailable` with 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.
129
131
 
130
132
  ## Handoffs
131
133
 
@@ -24,6 +24,8 @@ const DRIVE_RAM = 0x0800;
24
24
  const FIRST_SEGMENT_ADDRESS = 0x0400;
25
25
  /** The KERNAL LOAD entry in the jump table. */
26
26
  const KERNAL_LOAD = 0xffd5;
27
+ /** The KERNAL loop that waits for a key at READY. or at an INPUT statement. */
28
+ const KERNAL_KEY_WAIT = { start: 0xe5cd, end: 0xe5d5 };
27
29
 
28
30
  /** Reads and checks the steps file of loads: action steps with $ addresses, and capture steps with new names. */
29
31
  export function readLoadSteps(path: string): LoadStep[] {
@@ -100,6 +102,8 @@ export async function loads(program: string, options: { out: string; steps: Load
100
102
  let loadedProgram: Buffer | undefined;
101
103
  /** False while the map holds the writes of the KERNAL RAM test. */
102
104
  let writesKnown = true;
105
+ /** A known flaw of the host's VICE version that a tape load meets (D56). */
106
+ let emulatorFlaw: string | undefined;
103
107
  if (program.toLowerCase().endsWith(".prg")) {
104
108
  const { bytes } = readProjectFile(program);
105
109
  if (!(await autostartLoaded(client, program, bytes))) return { output: { loaded: false, reason: `The program was not in memory after ${LOAD_LIMIT_FRAMES} frames.` }, failed: true };
@@ -114,7 +118,7 @@ export async function loads(program: string, options: { out: string; steps: Load
114
118
  // A disk, tape or cartridge: VICE loads and starts it. The RAM test of the KERNAL puts back what it writes.
115
119
  previous = await readRam(client, 0x0000, 0x10000);
116
120
  await client.memmap({ action: "clear" });
117
- await client.autostart({ path: program, index: 0, run: true });
121
+ ({ emulatorFlaw } = await client.autostart({ path: program, index: 0, run: true }));
118
122
  writesKnown = false;
119
123
  if (!program.toLowerCase().endsWith(".crt")) {
120
124
  // The map starts again where the KERNAL LOAD starts, after the RAM test: then it holds only what the program wrote.
@@ -178,10 +182,12 @@ export async function loads(program: string, options: { out: string; steps: Load
178
182
  }),
179
183
  );
180
184
  write("entry_state.json", json(entryState(registers, state, vectors)));
185
+ const keyWait = registers.pc >= KERNAL_KEY_WAIT.start && registers.pc <= KERNAL_KEY_WAIT.end;
181
186
  captures.push({
182
187
  capture: step.capture,
183
188
  out: directory,
184
189
  pc: formatC64Address(registers.pc),
190
+ ...(keyWait ? { keyWait: true } : {}),
185
191
  segments: segments.map(showRange),
186
192
  ...(small === 0 ? {} : { smallChanges: small }),
187
193
  ...(drive === undefined ? {} : { drive: drive.file }),
@@ -190,6 +196,11 @@ export async function loads(program: string, options: { out: string; steps: Load
190
196
  await client.memmap({ action: "clear" });
191
197
  writesKnown = true;
192
198
  }
199
+ if (captures.length > 0 && captures.every((capture) => (capture.segments as unknown[]).length === 0)) {
200
+ const waits = captures.some((capture) => capture.keyWait === true) ? " The C64 waited for a key at READY. or at an INPUT statement, so the load ended or did not start." : "";
201
+ const reason = `No capture has a segment: the program changed no block of RAM from ${formatC64Address(FIRST_SEGMENT_ADDRESS)} on.${waits}`;
202
+ return { output: { loaded: false, reason, captures, ...(emulatorFlaw === undefined ? {} : { emulatorFlaw }) }, failed: true };
203
+ }
193
204
  return { output: { loaded: true, captures }, failed: false };
194
205
  } finally {
195
206
  await client.close();