@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
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: c64-provenance
3
- description: Use this skill to compare two or more releases of a C64 program, for example cracked versions from different groups. It finds the bytes where the releases differ and shows which releases agree there. Use the result to separate the content of the application from changes that a release added. Do not use it to run or analyze the code.
3
+ description: Use this skill to compare two or more releases of a C64 program, for example cracked versions from different groups. It finds the bytes where the releases differ and shows which releases agree there. It can run each release and tell how the program uses each difference. With one release, it gives evidence for each range: the unpack stage, the access, and the code that the entry does not reach. Use the result to separate the content of the application from changes that a release added. Do not use it to analyze the code.
4
4
  ---
5
5
 
6
6
  # c64-provenance
@@ -13,17 +13,21 @@ This skill does not change project knowledge.
13
13
 
14
14
  ## Inputs
15
15
 
16
- - Two or more releases: PRG files, relative to the project directory. Extract them from disk images with the c64-disk skill. Unpack packed releases with the c64-unpacker skill first.
16
+ - Two or more releases, relative to the project directory. A release is a PRG file or an `image.json` from c64-unpacker capture. Extract releases from disk images with the c64-disk skill. Unpack packed releases with the c64-unpacker skill first.
17
17
  - Optional: an address range to compare.
18
18
  - Optional: a shift for a release that loads at a different address.
19
+ - Optional: a number of frames to run each release, and a steps file.
20
+ - For one release: the release, coverage files, and the knowledge of the project.
19
21
 
20
22
  ## Tools / execution path
21
23
 
22
- The script compares the files locally. It does not use the host runtime.
24
+ The script compares the files locally. Only `--run` uses the host runtime: each release runs in its own emulator.
23
25
 
24
26
  ```
25
27
  node <skill>/scripts/provenance.ts releases/a.prg releases/b.prg releases/c.prg
26
28
  node <skill>/scripts/provenance.ts releases/a.prg releases/b.prg --shift 2:-256 --from '$0900' --to '$09ff'
29
+ node <skill>/scripts/provenance.ts releases/a.prg unpacked/b/image.json --run 600 --steps steps/start-game.json
30
+ node <skill>/scripts/provenance.ts separate unpacked/image.json --coverage coverage/attract.bin --coverage coverage/level1.bin --out notes/separate.json
27
31
  ```
28
32
 
29
33
  | Option | Meaning |
@@ -31,19 +35,39 @@ node <skill>/scripts/provenance.ts releases/a.prg releases/b.prg --shift 2:-256
31
35
  | `--shift 2:-256` | Move release 2 (the first release is 1) by -256 bytes. Use it again for more releases. |
32
36
  | `--from '$0900'` | Compare from this address. |
33
37
  | `--to '$09ff'` | Compare up to this address. |
38
+ | `--run 600` | Run each release for this many frames, and give each listed difference its runtime evidence. |
39
+ | `--steps steps/start-game.json` | With `--run`: the action steps of the c64-unpacker steps file to run before the frames, for example a key to start the game. |
34
40
 
35
- The script puts each release at its load address and compares the addresses that all releases cover. It prints one JSON object.
41
+ The script puts each PRG at its load address. An image has bytes only in its segments. The script compares the addresses where all releases have bytes. It prints one JSON object.
42
+
43
+ With `--run`, a PRG starts with autostart and RUN. An image starts from its bytes: the script writes the segments and the entry state from `entry_state.json` into the emulator, and starts at the entry. Watchpoints on each difference stop the run at its first access.
36
44
 
37
45
  ## Workflow
38
46
 
39
47
  1. Run the script on the releases.
40
48
  2. Read `identicalShare`. If it is low and `suggestedShifts` is present, the releases are at different addresses. Run the script again with `--shift`.
41
49
  3. Read `differences`. Each one has an address range, the bytes of each release, and `groups`: the releases that have the same bytes there.
42
- 4. Examine each important difference:
50
+ 4. To know how the program uses the differences, run the script again with `--run` and the frames that get the program to the part you examine. Read `runtime` of each difference:
51
+ - `first`: the first access in that release: `read`, `write`, `execute` or `none`. An instruction that reads and then writes the same byte, for example INC, counts as `read`.
52
+ - `changed`: `true` when the bytes at the end of the run are not the bytes of the release.
53
+ - A range that each release writes before it reads is probably runtime state, not a difference of the releases.
54
+ - A range with `none` in each release is not used in this run. Run more frames or other steps before you decide.
55
+ 5. Examine each important difference:
43
56
  - Disassemble the bytes of each release with the c64-emulator skill, or analyze them with the c64-static-analysis skill.
44
57
  - Find what the change does, for example a trainer, a removed copy protection, or a changed text.
45
- 5. Decide which bytes are probably the original application. Use more than the byte comparison: the code, the text, and the history of the releases.
46
- 6. Record the conclusion as a comment or a region with the c64-knowledge skill.
58
+ 6. Decide which bytes are probably the original application. Use more than the byte comparison: the code, the text, and the history of the releases.
59
+ 7. Record the conclusion as a comment or a region with the c64-knowledge skill.
60
+
61
+ ## One release
62
+
63
+ Use `separate` when you have only one release. It gives evidence, not a decision:
64
+
65
+ - `ranges`: the image in ranges with the same stage and access. `stage` is the last unpack stage that wrote the range. In `image.json`, the last stage is the program. An earlier stage is a depacker, an intro or a loader.
66
+ - `access`: from the joined `--coverage` files: `executed`, `read`, `written`, `read-written` or `untouched`. `untouched` bytes did not take part in the runs.
67
+ - `codeRegions.unreachable`: code regions of the knowledge that no chain of call and jump references reaches from the entry. Run c64-static-analysis first, so that the knowledge has the code regions and the references.
68
+ - `outside`: RAM outside the image that the program `read` or `executed` but did not write, and code it `generated`. A loader or an earlier stage put the bytes that the program did not write.
69
+
70
+ The result lists the first ranges. `--out` writes all of them. Run more coverage runs for parts that are `untouched` before you decide.
47
71
 
48
72
  ## Knowledge
49
73
 
@@ -54,7 +78,9 @@ Record a confirmed conclusion as a normal comment, region or symbol with the c64
54
78
 
55
79
  ## Result
56
80
 
57
- The differing ranges with the bytes of each release and the groups that agree, and the share of identical bytes.
81
+ The differing ranges with the bytes of each release and the groups that agree, and the share of identical bytes. `common` gives the ranges where all releases have bytes. With `--run`, each difference also has `runtime`, and `run` tells if each release started, or why not.
82
+
83
+ The runtime evidence is evidence only. The script does not decide which bytes are original.
58
84
 
59
85
  The exit status is 0 for a result. The exit status is 1 for an error. The exit status is 2 for a wrong argument.
60
86
 
@@ -62,7 +88,9 @@ The exit status is 0 for a result. The exit status is 1 for an error. The exit s
62
88
 
63
89
  - Do not call releases independent when you do not know their relation. Two cracks can come from the same source.
64
90
  - Do not call a byte original only because one release has it. A majority of releases is also not proof.
65
- - `common: null`: the releases do not cover the same addresses. Use `--shift`, or compare a range that all releases cover.
91
+ - `common` is empty: the releases do not cover the same addresses. Use `--shift`, or compare a range that all releases cover.
92
+ - `run.releases` shows `started: false`: a PRG without a BASIC start, or an image without `entry_state.json`. The reason tells which. The other releases still give evidence.
93
+ - `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 emulator script.
66
94
  - `moreDifferences: true`: the script shows only the first differences. Use `--from` and `--to` to see the others.
67
95
  - `not-found`: the project has no file at that path.
68
96
  - `invalid-input`: a file, an address or a shift is wrong. A path that goes out of the project directory is also wrong, also through a link. Read the message.
@@ -1,13 +1,21 @@
1
- // Byte comparison of releases for the c64-provenance script. Each release is
2
- // placed at its load address plus an optional shift; the comparison reports
3
- // where the releases differ and which releases agree there. It never decides
4
- // which bytes are original: that needs evidence beyond one comparison.
1
+ // Byte comparison of releases for the c64-provenance script. Each release
2
+ // has bytes in one or more segments: a PRG at its load address plus an
3
+ // optional shift, or the segments of an unpacker image (D50). The comparison
4
+ // covers the addresses where every release has bytes, and reports where the
5
+ // releases differ and which releases agree there. It never decides which
6
+ // bytes are original: that needs evidence beyond one comparison.
7
+
8
+ export interface Range {
9
+ start: number;
10
+ end: number;
11
+ }
5
12
 
6
13
  export interface Release {
7
14
  name: string;
8
- /** Address of the first byte after the shift. */
9
- start: number;
10
- bytes: Uint8Array;
15
+ /** Where the release has bytes, after the shift, in address order. */
16
+ segments: Range[];
17
+ /** The byte at an address inside a segment. */
18
+ byteAt: (address: number) => number;
11
19
  }
12
20
 
13
21
  export interface DifferingRange {
@@ -20,8 +28,8 @@ export interface DifferingRange {
20
28
  }
21
29
 
22
30
  export interface Comparison {
23
- /** The addresses that every release covers. */
24
- common: { start: number; end: number } | null;
31
+ /** The addresses where every release has bytes. */
32
+ common: Range[];
25
33
  identicalBytes: number;
26
34
  differingBytes: number;
27
35
  differences: DifferingRange[];
@@ -32,22 +40,37 @@ export interface Comparison {
32
40
  /** Joins differing addresses that are at most this far apart into one range. */
33
41
  const JOIN_GAP = 4;
34
42
 
43
+ export const covers = (release: Release, address: number) => release.segments.some((segment) => address >= segment.start && address <= segment.end);
44
+
45
+ /** The ranges where every release has bytes, inside from..to. */
46
+ export function commonRanges(releases: Release[], from = 0x0000, to = 0xffff): Range[] {
47
+ const ranges: Range[] = [];
48
+ for (let address = from; address <= to; address++) {
49
+ if (!releases.every((release) => covers(release, address))) continue;
50
+ const last = ranges.at(-1);
51
+ if (last !== undefined && last.end + 1 === address) last.end = address;
52
+ else ranges.push({ start: address, end: address });
53
+ }
54
+ return ranges;
55
+ }
56
+
35
57
  export function compareReleases(releases: Release[], options: { from?: number; to?: number; maxRanges?: number } = {}): Comparison {
36
- const start = Math.max(options.from ?? 0, ...releases.map((release) => release.start));
37
- const end = Math.min(options.to ?? 0xffff, ...releases.map((release) => release.start + release.bytes.length - 1));
38
- if (start > end) return { common: null, identicalBytes: 0, differingBytes: 0, differences: [], moreDifferences: false };
39
- const at = (release: Release, address: number) => release.bytes[address - release.start]!;
58
+ const common = commonRanges(releases, options.from, options.to);
40
59
  let identicalBytes = 0;
41
60
  const differing: number[] = [];
42
- for (let address = start; address <= end; address++) {
43
- const first = at(releases[0]!, address);
44
- if (releases.every((release) => at(release, address) === first)) identicalBytes++;
45
- else differing.push(address);
61
+ for (const range of common) {
62
+ for (let address = range.start; address <= range.end; address++) {
63
+ const first = releases[0]!.byteAt(address);
64
+ if (releases.every((release) => release.byteAt(address) === first)) identicalBytes++;
65
+ else differing.push(address);
66
+ }
46
67
  }
47
- const ranges: Array<{ start: number; end: number }> = [];
68
+ // A joined range stays inside one common range, so every release has all of its bytes.
69
+ const inSameCommon = (a: number, b: number) => common.some((range) => a >= range.start && b <= range.end);
70
+ const ranges: Range[] = [];
48
71
  for (const address of differing) {
49
72
  const last = ranges.at(-1);
50
- if (last !== undefined && address - last.end <= JOIN_GAP) last.end = address;
73
+ if (last !== undefined && address - last.end <= JOIN_GAP && inSameCommon(last.start, address)) last.end = address;
51
74
  else ranges.push({ start: address, end: address });
52
75
  }
53
76
  const limit = options.maxRanges ?? 64;
@@ -55,14 +78,19 @@ export function compareReleases(releases: Release[], options: { from?: number; t
55
78
  const values: Record<string, number[]> = {};
56
79
  const groups = new Map<string, string[]>();
57
80
  for (const release of releases) {
58
- const bytes = Array.from(release.bytes.subarray(range.start - release.start, range.end - release.start + 1));
81
+ const bytes = Array.from({ length: range.end - range.start + 1 }, (_, offset) => release.byteAt(range.start + offset));
59
82
  values[release.name] = bytes;
60
83
  const key = bytes.join(",");
61
84
  groups.set(key, [...(groups.get(key) ?? []), release.name]);
62
85
  }
63
86
  return { ...range, values, groups: [...groups.values()].sort((a, b) => b.length - a.length) };
64
87
  });
65
- return { common: { start, end }, identicalBytes, differingBytes: differing.length, differences, moreDifferences: ranges.length > limit };
88
+ return { common, identicalBytes, differingBytes: differing.length, differences, moreDifferences: ranges.length > limit };
89
+ }
90
+
91
+ /** The addresses of a release in order, so that a window of bytes can run over its segments. */
92
+ function addressesOf(release: Release): number[] {
93
+ return release.segments.flatMap((segment) => Array.from({ length: segment.end - segment.start + 1 }, (_, offset) => segment.start + offset));
66
94
  }
67
95
 
68
96
  /**
@@ -71,18 +99,21 @@ export function compareReleases(releases: Release[], options: { from?: number; t
71
99
  */
72
100
  export function suggestShift(base: Release, other: Release): { shift: number; matches: number } | undefined {
73
101
  const WINDOW = 8;
74
- const key = (bytes: Uint8Array, at: number) => Buffer.from(bytes.subarray(at, at + WINDOW)).toString("latin1");
102
+ const key = (release: Release, addresses: number[], at: number) =>
103
+ String.fromCharCode(...addresses.slice(at, at + WINDOW).map((address) => release.byteAt(address)));
104
+ const baseAddresses = addressesOf(base);
105
+ const otherAddresses = addressesOf(other);
75
106
  const positions = new Map<string, number[]>();
76
- for (let at = 0; at + WINDOW <= base.bytes.length; at++) {
77
- const positionsOfKey = positions.get(key(base.bytes, at));
107
+ for (let at = 0; at + WINDOW <= baseAddresses.length; at++) {
108
+ const found = positions.get(key(base, baseAddresses, at));
78
109
  // Very common sequences (fill bytes) say nothing about the alignment.
79
- if (positionsOfKey === undefined) positions.set(key(base.bytes, at), [at]);
80
- else if (positionsOfKey.length < 8) positionsOfKey.push(at);
110
+ if (found === undefined) positions.set(key(base, baseAddresses, at), [baseAddresses[at]!]);
111
+ else if (found.length < 8) found.push(baseAddresses[at]!);
81
112
  }
82
113
  const votes = new Map<number, number>();
83
- for (let at = 0; at + WINDOW <= other.bytes.length; at++) {
84
- for (const position of positions.get(key(other.bytes, at)) ?? []) {
85
- const shift = base.start + position - (other.start + at);
114
+ for (let at = 0; at + WINDOW <= otherAddresses.length; at++) {
115
+ for (const address of positions.get(key(other, otherAddresses, at)) ?? []) {
116
+ const shift = address - otherAddresses[at]!;
86
117
  votes.set(shift, (votes.get(shift) ?? 0) + 1);
87
118
  }
88
119
  }
@@ -1,21 +1,46 @@
1
1
  // The c64-provenance script: compares two or more releases byte by byte at
2
- // their addresses and reports where they differ and which releases agree.
2
+ // their addresses and reports where they differ and which releases agree. A
3
+ // release is a PRG or an unpacker image.json (D50). With --run, each release
4
+ // runs in its own emulator, and each listed difference gets its first access
5
+ // and whether the run changed it. The separate command gives evidence from
6
+ // one release (D51).
3
7
 
8
+ import { posix } from "node:path";
4
9
  import { parseArgs } from "node:util";
5
10
 
6
11
  import { formatC64Address, parseC64Address } from "#src/c64.ts";
7
- import { readProjectFile } from "#src/host-client/transfer.ts";
12
+ import { readCoverageFiles } from "#src/host-client/coverage.ts";
13
+ import { checkActionSteps, type ActionStep } from "#src/host-client/steps.ts";
14
+ import { MAX_EXECUTION_COUNT, WireFailure } from "#src/host-client/tools.ts";
15
+ import { readProjectFile, writeProjectFile } from "#src/host-client/transfer.ts";
16
+ import { ViceSessionClient } from "#src/host-client/vice-session.ts";
17
+ import { KnowledgeError, openForRead } from "#src/knowledge/database.ts";
18
+ import { listReferences, listRegions } from "#src/knowledge/read.ts";
19
+ import { CODE_REGION_TYPES } from "#src/knowledge/schema.ts";
8
20
  import { runScript, UsageError } from "#src/script.ts";
9
- import { compareReleases, suggestShift, type Release } from "./compare.ts";
21
+ import { compareReleases, suggestShift, type Range, type Release } from "./compare.ts";
22
+ import { watchRelease, type ImageState, type Start } from "./runtime.ts";
23
+ import { separate } from "./separate.ts";
10
24
 
11
- const USAGE = `provenance.ts <release.prg> <release.prg> [<release.prg> ...] [options]
25
+ const USAGE = `provenance.ts <release> <release> [<release> ...] [options]
26
+ provenance.ts separate <release> [--coverage <file> ...] [--out <file>] [--max-listed <n>]
12
27
 
13
28
  --shift <n>:<bytes> move release n (1 = the first) by a number of bytes, for example 2:-256
14
29
  --from <address> compare from this address
15
30
  --to <address> compare up to this address
31
+ --run <frames> run each release in its own emulator for this many frames, and
32
+ give each listed difference its first access and whether it changed
33
+ --steps <file> with --run: action steps (JSON) to run before the frames
16
34
 
17
- Each release is a PRG file relative to the project directory, placed at its
18
- load address. The result is one JSON object.`;
35
+ separate gives evidence from one release: the unpack stage that wrote each range,
36
+ its access in the --coverage files (64 KiB each, repeatable), whether call and
37
+ jump references in knowledge reach each code region from the entry, and what the
38
+ program reads or executes outside the image. --out writes all ranges to a file;
39
+ the result lists at most --max-listed of them (default 50).
40
+
41
+ A release is a PRG, placed at its load address, or an image.json of the
42
+ c64-unpacker skill, compared inside its segments. Paths are relative to the
43
+ project directory. The result is one JSON object.`;
19
44
 
20
45
  function address(text: string, option: string): number {
21
46
  try {
@@ -27,14 +52,123 @@ function address(text: string, option: string): number {
27
52
 
28
53
  const hexBytes = (bytes: number[]) => Buffer.from(bytes).toString("hex").replace(/(..)(?!$)/g, "$1 ");
29
54
 
30
- function run(argv: string[]): unknown {
55
+ interface LoadedRelease {
56
+ release: Release;
57
+ shift: number;
58
+ start: Start | { kind: "none"; reason: string };
59
+ /** 64 KiB with the release bytes at their own addresses. */
60
+ memory: Uint8Array;
61
+ entry?: number;
62
+ stages?: Array<{ entry: number; wrote: Range[] }>;
63
+ }
64
+
65
+ /** Reads entry_state.json of the c64-unpacker skill for a start by writes. */
66
+ function entryState(path: string): ImageState | string {
67
+ let value: Record<string, any>;
68
+ try {
69
+ value = JSON.parse(readProjectFile(path).bytes.toString("utf8")) as Record<string, any>;
70
+ } catch (error) {
71
+ if (error instanceof WireFailure && error.code === "not-found") return `It has no ${path}; capture it with c64-unpacker.`;
72
+ if (error instanceof WireFailure) throw error;
73
+ return `${path} is not JSON: ${(error as Error).message}`;
74
+ }
75
+ const registers = value.registers ?? {};
76
+ const byte = (item: unknown) => Number.isInteger(item) && (item as number) >= 0 && (item as number) <= 0xff;
77
+ const pages = ["$0000", "$0100", "$0200", "$0300"].map((page) => value.pages?.[page]);
78
+ const ok =
79
+ typeof registers.pc === "string" &&
80
+ /^\$[0-9a-f]{4}$/i.test(registers.pc) &&
81
+ ["a", "x", "y", "sp"].every((name) => byte(registers[name])) &&
82
+ typeof registers.flags === "object" &&
83
+ byte(value.cpuPort?.$00) &&
84
+ byte(value.cpuPort?.$01) &&
85
+ pages.every((page) => typeof page === "string" && /^[0-9a-f]{512}$/i.test(page));
86
+ if (!ok) return `${path} is not an entry_state.json of the c64-unpacker skill.`;
87
+ const flag = (name: string) => registers.flags[name] === true;
88
+ return {
89
+ pc: Number.parseInt(registers.pc.slice(1), 16),
90
+ a: registers.a,
91
+ x: registers.x,
92
+ y: registers.y,
93
+ sp: registers.sp,
94
+ flags: { n: flag("n"), v: flag("v"), d: flag("d"), i: flag("i"), z: flag("z"), c: flag("c") },
95
+ port: [value.cpuPort.$00, value.cpuPort.$01],
96
+ pages: Buffer.from(pages.join(""), "hex"),
97
+ };
98
+ }
99
+
100
+ function readRelease(path: string, shift: number): LoadedRelease {
101
+ const { bytes } = readProjectFile(path);
102
+ const moved = (range: Range) => ({ start: range.start + shift, end: range.end + shift });
103
+ const inside = (range: Range) => range.start >= 0 && range.end <= 0xffff;
104
+ if (!path.endsWith(".json")) {
105
+ if (bytes.length < 3) throw new UsageError(`${path} is too short for a PRG`);
106
+ const load = bytes.readUInt16LE(0);
107
+ const segment = moved({ start: load, end: load + bytes.length - 3 });
108
+ if (!inside(segment)) throw new UsageError(`the shift moves ${path} outside $0000-$ffff`);
109
+ const memory = new Uint8Array(0x10000);
110
+ memory.set(bytes.subarray(2), load);
111
+ return { release: { name: path, segments: [segment], byteAt: (at) => bytes[2 + at - segment.start]! }, shift, start: { kind: "program", path }, memory };
112
+ }
113
+ const fail = (what: string): never => {
114
+ throw new UsageError(`${path} is not an image.json of the c64-unpacker skill: ${what}`);
115
+ };
116
+ let manifest: Record<string, unknown>;
117
+ try {
118
+ manifest = JSON.parse(bytes.toString("utf8")) as Record<string, unknown>;
119
+ } catch (error) {
120
+ return fail((error as Error).message);
121
+ }
122
+ const parse = (value: unknown) => (typeof value === "string" && /^\$[0-9a-f]{4}$/i.test(value) ? Number.parseInt(value.slice(1), 16) : fail("a segment needs start and end as $ addresses"));
123
+ if (typeof manifest.image !== "string" || !Array.isArray(manifest.segments) || manifest.segments.length === 0) fail("it needs image and segments");
124
+ const own = (manifest.segments as Array<Record<string, unknown>>).map((segment) => ({ start: parse(segment.start), end: parse(segment.end) })).sort((a, b) => a.start - b.start);
125
+ const directory = posix.dirname(path);
126
+ const memory = readProjectFile(posix.join(directory, manifest.image as string)).bytes;
127
+ if (memory.length !== 0x10000) fail(`its image has ${memory.length} bytes, not 65536`);
128
+ const segments = own.map(moved);
129
+ if (!segments.every(inside)) throw new UsageError(`the shift moves ${path} outside $0000-$ffff`);
130
+ const state = entryState(posix.join(directory, "entry_state.json"));
131
+ const stages = Array.isArray(manifest.stages)
132
+ ? (manifest.stages as Array<Record<string, unknown>>).map((stage) => ({
133
+ entry: parse(stage.entry),
134
+ wrote: (Array.isArray(stage.wrote) ? (stage.wrote as Array<Record<string, unknown>>) : []).map((range) => ({ start: parse(range.start), end: parse(range.end) })),
135
+ }))
136
+ : undefined;
137
+ return {
138
+ release: { name: path, segments, byteAt: (at) => memory[at - shift]! },
139
+ shift,
140
+ start: typeof state === "string" ? { kind: "none", reason: state } : { kind: "image", segments: own, byteAt: (at) => memory[at]!, state },
141
+ memory,
142
+ ...(manifest.entry === undefined ? {} : { entry: parse(manifest.entry) }),
143
+ ...(stages === undefined ? {} : { stages }),
144
+ };
145
+ }
146
+
147
+ async function run(argv: string[]): Promise<unknown> {
31
148
  const { values, positionals } = parseArgs({
32
149
  args: argv,
33
150
  strict: true,
34
151
  allowPositionals: true,
35
- options: { shift: { type: "string", multiple: true }, from: { type: "string" }, to: { type: "string" }, help: { type: "boolean" } },
152
+ options: {
153
+ shift: { type: "string", multiple: true },
154
+ from: { type: "string" },
155
+ to: { type: "string" },
156
+ run: { type: "string" },
157
+ steps: { type: "string" },
158
+ coverage: { type: "string", multiple: true },
159
+ out: { type: "string" },
160
+ "max-listed": { type: "string" },
161
+ help: { type: "boolean" },
162
+ },
36
163
  });
37
164
  if (values.help) throw new UsageError("");
165
+ if (positionals[0] === "separate") {
166
+ const used = ["shift", "from", "to", "run", "steps"].filter((option) => values[option as keyof typeof values] !== undefined);
167
+ if (used.length > 0) throw new UsageError(`separate does not take --${used.join(", --")}`);
168
+ if (positionals.length !== 2) throw new UsageError("separate needs one release");
169
+ return separateCommand(positionals[1]!, values.coverage ?? [], values.out, values["max-listed"]);
170
+ }
171
+ if (values.coverage !== undefined || values.out !== undefined || values["max-listed"] !== undefined) throw new UsageError("--coverage, --out and --max-listed work only with separate");
38
172
  if (positionals.length < 2) throw new UsageError("give two or more releases");
39
173
  const shifts = new Map<number, number>();
40
174
  for (const text of values.shift ?? []) {
@@ -42,13 +176,27 @@ function run(argv: string[]): unknown {
42
176
  if (match === null || Number(match[1]) < 1 || Number(match[1]) > positionals.length) throw new UsageError(`--shift must be <release number>:<bytes>, not ${text}`);
43
177
  shifts.set(Number(match[1]), Number(match[2]));
44
178
  }
45
- const releases: Release[] = positionals.map((path, index) => {
46
- const { bytes } = readProjectFile(path);
47
- if (bytes.length < 3) throw new UsageError(`${path} is too short for a PRG`);
48
- const start = bytes.readUInt16LE(0) + (shifts.get(index + 1) ?? 0);
49
- if (start < 0 || start + bytes.length - 3 > 0xffff) throw new UsageError(`the shift moves ${path} outside $0000-$ffff`);
50
- return { name: path, start, bytes: bytes.subarray(2) };
51
- });
179
+ if (values.steps !== undefined && values.run === undefined) throw new UsageError("--steps works only with --run");
180
+ let frames: number | undefined;
181
+ if (values.run !== undefined) {
182
+ if (!/^\d{1,6}$/.test(values.run) || Number(values.run) < 1 || Number(values.run) > MAX_EXECUTION_COUNT) throw new UsageError(`--run needs a frame count from 1 to ${MAX_EXECUTION_COUNT}, not ${values.run}`);
183
+ frames = Number(values.run);
184
+ }
185
+ let steps: ActionStep[] = [];
186
+ if (values.steps !== undefined) {
187
+ let parsed: unknown;
188
+ try {
189
+ parsed = JSON.parse(readProjectFile(values.steps).bytes.toString("utf8"));
190
+ } catch (error) {
191
+ if (error instanceof WireFailure) throw error;
192
+ throw new UsageError(`the steps file ${values.steps} is not JSON: ${(error as Error).message}`);
193
+ }
194
+ steps = checkActionSteps(parsed, (message) => {
195
+ throw new UsageError(`steps file ${values.steps}: ${message}`);
196
+ });
197
+ }
198
+ const loaded = positionals.map((path, index) => readRelease(path, shifts.get(index + 1) ?? 0));
199
+ const releases = loaded.map((item) => item.release);
52
200
  const comparison = compareReleases(releases, {
53
201
  ...(values.from === undefined ? {} : { from: address(values.from, "--from") }),
54
202
  ...(values.to === undefined ? {} : { to: address(values.to, "--to") }),
@@ -63,21 +211,102 @@ function run(argv: string[]): unknown {
63
211
  return best === undefined || best.shift === 0 ? [] : [{ release: index + 2, shift: best.shift, matchingSequences: best.matches }];
64
212
  })
65
213
  : [];
214
+
215
+ // The runtime evidence: one emulator for each release, the differences at the release's own addresses.
216
+ const runtime = new Map<string, Array<{ first: string; changed: boolean }>>();
217
+ const runs: Record<string, unknown> = {};
218
+ if (frames !== undefined && comparison.differences.length > 0) {
219
+ for (const item of loaded) {
220
+ const name = item.release.name;
221
+ if (item.start.kind === "none") {
222
+ runs[name] = { started: false, reason: item.start.reason };
223
+ continue;
224
+ }
225
+ const ranges = comparison.differences.map((difference) => ({ start: difference.start - item.shift, end: difference.end - item.shift }));
226
+ const client = await ViceSessionClient.open({ videoStandard: "pal" });
227
+ try {
228
+ const watched = await watchRelease(client, item.start, ranges, (at) => item.release.byteAt(at + item.shift), { frames, steps });
229
+ if (!watched.started) runs[name] = { started: false, reason: watched.reason };
230
+ else {
231
+ runs[name] = { started: true };
232
+ runtime.set(name, watched.accesses);
233
+ }
234
+ } finally {
235
+ await client.close();
236
+ }
237
+ }
238
+ }
239
+
66
240
  return {
67
- releases: releases.map((release) => ({ name: release.name, start: formatC64Address(release.start), end: formatC64Address(release.start + release.bytes.length - 1) })),
68
- common: comparison.common === null ? null : { start: formatC64Address(comparison.common.start), end: formatC64Address(comparison.common.end) },
241
+ releases: loaded.map((item) => ({ name: item.release.name, segments: item.release.segments.map((segment) => ({ start: formatC64Address(segment.start), end: formatC64Address(segment.end) })) })),
242
+ common: comparison.common.map((range) => ({ start: formatC64Address(range.start), end: formatC64Address(range.end) })),
69
243
  identicalBytes: comparison.identicalBytes,
70
244
  differingBytes: comparison.differingBytes,
71
245
  identicalShare: Number(identicalShare.toFixed(3)),
72
- differences: comparison.differences.map((difference) => ({
246
+ differences: comparison.differences.map((difference, index) => ({
73
247
  start: formatC64Address(difference.start),
74
248
  end: formatC64Address(difference.end),
75
249
  values: Object.fromEntries(Object.entries(difference.values).map(([name, bytes]) => [name, hexBytes(bytes.slice(0, 32)) + (bytes.length > 32 ? " ..." : "")])),
76
250
  groups: difference.groups,
251
+ ...(runtime.size === 0 ? {} : { runtime: Object.fromEntries([...runtime].map(([name, accesses]) => [name, accesses[index]!])) }),
77
252
  })),
78
253
  ...(comparison.moreDifferences ? { moreDifferences: true } : {}),
79
254
  ...(suggestions.length > 0 ? { suggestedShifts: suggestions } : {}),
255
+ ...(frames === undefined ? {} : { run: { frames, steps: steps.length, releases: runs } }),
256
+ };
257
+ }
258
+
259
+ /** separate: evidence from one release (D51), with all ranges in --out and the first ones in the result. */
260
+ function separateCommand(path: string, coveragePaths: string[], out: string | undefined, maxListed: string | undefined): unknown {
261
+ const max = maxListed ?? "50";
262
+ if (!/^\d{1,5}$/.test(max)) throw new UsageError(`--max-listed must be a whole number, not ${max}`);
263
+ const loaded = readRelease(path, 0);
264
+ const coverage = readCoverageFiles(coveragePaths, (message) => {
265
+ throw new UsageError(message);
266
+ });
267
+ const db = openForRead();
268
+ let regions: ReturnType<typeof listRegions>;
269
+ let references: ReturnType<typeof listReferences>;
270
+ try {
271
+ regions = listRegions(db).filter((region) => CODE_REGION_TYPES.includes(region.type));
272
+ references = listReferences(db);
273
+ } finally {
274
+ db?.close();
275
+ }
276
+ const found = separate({
277
+ segments: loaded.release.segments,
278
+ memory: loaded.memory,
279
+ ...(loaded.entry === undefined ? {} : { entry: loaded.entry }),
280
+ ...(loaded.stages === undefined ? {} : { stages: loaded.stages }),
281
+ ...(coverage === undefined ? {} : { coverage }),
282
+ codeRegions: regions,
283
+ references,
284
+ });
285
+ const show = (range: Range) => ({ start: formatC64Address(range.start), end: formatC64Address(range.end) });
286
+ const ranges = found.ranges.map((range) => ({ ...show(range), ...(range.stage === undefined ? {} : { stage: range.stage }), ...(range.access === undefined ? {} : { access: range.access }) }));
287
+ const codeRegions = found.codeRegions.map((region) => ({ ...show(region), type: region.type, ...(region.reachable === undefined ? {} : { reachable: region.reachable }), ...(region.executed === undefined ? {} : { executed: region.executed }) }));
288
+ const outside = { read: found.outside.read.map(show), executed: found.outside.executed.map(show), generated: found.outside.generated.map(show) };
289
+ if (out !== undefined) writeProjectFile(out, Buffer.from(`${JSON.stringify({ release: path, ranges, codeRegions, outside }, null, 1)}\n`), { what: "--out" });
290
+ const bytes = (filter: (range: (typeof found.ranges)[number]) => boolean) => found.ranges.filter(filter).reduce((sum, range) => sum + range.end - range.start + 1, 0);
291
+ const lastStage = loaded.stages === undefined ? undefined : loaded.stages.length - 1;
292
+ return {
293
+ release: path,
294
+ ...(loaded.entry === undefined ? {} : { entry: formatC64Address(loaded.entry) }),
295
+ ...(lastStage === undefined
296
+ ? {}
297
+ : {
298
+ stages: lastStage + 1,
299
+ bytesByStage: Object.fromEntries([...[...Array(lastStage + 1).keys()].map((stage): [string, number] => [String(stage), bytes((range) => range.stage === stage)]), ["none", bytes((range) => range.stage === undefined)]]),
300
+ }),
301
+ ...(coverage === undefined ? {} : { bytesByAccess: Object.fromEntries((["executed", "read", "written", "read-written", "untouched"] as const).map((access) => [access, bytes((range) => range.access === access)])) }),
302
+ ranges: { count: ranges.length, listed: ranges.slice(0, Number(max)) },
303
+ codeRegions: {
304
+ count: codeRegions.length,
305
+ ...(loaded.entry === undefined ? {} : { unreachable: codeRegions.filter((region) => region.reachable === false) }),
306
+ },
307
+ ...(coverage === undefined ? {} : { outside }),
308
+ ...(out === undefined ? {} : { out }),
80
309
  };
81
310
  }
82
311
 
83
- await runScript(() => run(process.argv.slice(2)), { usage: USAGE });
312
+ await runScript(() => run(process.argv.slice(2)), { usage: USAGE, refusals: [KnowledgeError] });