@henols/c64-re-tools 1.0.0 → 2.0.0-rc.10

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 (159) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +154 -37
  3. package/distribution/package.ts +21 -0
  4. package/distribution/plugin.ts +122 -0
  5. package/package.json +34 -40
  6. package/skills/c64-assembler/SKILL.md +76 -0
  7. package/skills/c64-assembler/scripts/assemble.ts +110 -0
  8. package/skills/c64-assembler/scripts/compare.ts +84 -0
  9. package/skills/c64-basic/SKILL.md +66 -0
  10. package/skills/c64-basic/scripts/basic.ts +46 -0
  11. package/skills/c64-disk/SKILL.md +93 -0
  12. package/skills/c64-disk/scripts/containers.ts +124 -0
  13. package/skills/c64-disk/scripts/disk.ts +120 -0
  14. package/skills/c64-emulator/SKILL.md +134 -0
  15. package/skills/c64-knowledge/SKILL.md +166 -0
  16. package/skills/c64-knowledge/scripts/knowledge.ts +492 -0
  17. package/skills/c64-memory-map/SKILL.md +79 -0
  18. package/skills/c64-memory-map/scripts/memmap.ts +92 -0
  19. package/skills/c64-provenance/SKILL.md +110 -0
  20. package/skills/c64-provenance/scripts/compare.ts +152 -0
  21. package/skills/c64-provenance/scripts/provenance.ts +334 -0
  22. package/skills/c64-provenance/scripts/runtime.ts +191 -0
  23. package/skills/c64-provenance/scripts/separate.ts +140 -0
  24. package/skills/c64-reverse-engineering/SKILL.md +144 -0
  25. package/skills/c64-source/SKILL.md +126 -0
  26. package/skills/c64-source/scripts/generate.ts +490 -0
  27. package/skills/c64-source/scripts/loader.ts +571 -0
  28. package/skills/c64-source/scripts/opcodes.ts +61 -0
  29. package/skills/c64-source/scripts/source.ts +595 -0
  30. package/skills/c64-static-analysis/SKILL.md +119 -0
  31. package/skills/c64-static-analysis/scripts/analyze.ts +275 -0
  32. package/skills/c64-static-analysis/scripts/coverage.ts +78 -0
  33. package/skills/c64-static-analysis/scripts/findings.ts +75 -0
  34. package/skills/c64-static-analysis/scripts/idioms.ts +149 -0
  35. package/skills/c64-static-analysis/scripts/instructions.ts +105 -0
  36. package/skills/c64-testing/SKILL.md +103 -0
  37. package/skills/c64-testing/references/scenario-format.md +116 -0
  38. package/skills/c64-testing/scripts/checklist.ts +51 -0
  39. package/skills/c64-testing/scripts/run.ts +189 -0
  40. package/skills/c64-testing/scripts/scenario.ts +493 -0
  41. package/skills/c64-testing/scripts/test.ts +65 -0
  42. package/skills/c64-unpacker/SKILL.md +135 -0
  43. package/skills/c64-unpacker/scripts/evidence.ts +207 -0
  44. package/skills/c64-unpacker/scripts/loads.ts +209 -0
  45. package/skills/c64-unpacker/scripts/machine.ts +80 -0
  46. package/skills/c64-unpacker/scripts/stages.ts +130 -0
  47. package/skills/c64-unpacker/scripts/unpack.ts +683 -0
  48. package/src/c64.ts +411 -0
  49. package/src/cli/ap-sdk.ts +44 -0
  50. package/src/cli/cleanup.ts +194 -0
  51. package/src/cli/main.ts +125 -0
  52. package/src/cli/status.ts +61 -0
  53. package/src/host/main.ts +134 -0
  54. package/src/host/server.ts +392 -0
  55. package/src/host/tools/c1541.ts +284 -0
  56. package/src/host/tools/index.ts +35 -0
  57. package/src/host/tools/petcat.ts +77 -0
  58. package/src/host/tools/status.ts +47 -0
  59. package/src/host/tools/version.ts +41 -0
  60. package/src/host/vice/adapter.ts +804 -0
  61. package/src/host/vice/binary-monitor.ts +389 -0
  62. package/src/host/vice/chips.ts +118 -0
  63. package/src/host/vice/connect.ts +34 -0
  64. package/src/host/vice/process.ts +413 -0
  65. package/src/host/vice/screen.ts +184 -0
  66. package/src/host/vice/session.ts +1426 -0
  67. package/src/host/vice/text-monitor.ts +223 -0
  68. package/src/host-client/autostart.ts +55 -0
  69. package/src/host-client/connect.ts +241 -0
  70. package/src/host-client/coverage.ts +63 -0
  71. package/src/host-client/steps.ts +170 -0
  72. package/src/host-client/tools.ts +89 -0
  73. package/src/host-client/transfer.ts +126 -0
  74. package/src/host-client/vice-session.ts +316 -0
  75. package/src/knowledge/database.ts +132 -0
  76. package/src/knowledge/history.ts +163 -0
  77. package/src/knowledge/import.ts +391 -0
  78. package/src/knowledge/read.ts +342 -0
  79. package/src/knowledge/schema.ts +229 -0
  80. package/src/knowledge/write.ts +663 -0
  81. package/src/mcp/main.ts +134 -0
  82. package/src/mcp/output.ts +32 -0
  83. package/src/mcp/server.ts +316 -0
  84. package/src/mcp/tools/debug.ts +327 -0
  85. package/src/mcp/tools/execution.ts +117 -0
  86. package/src/mcp/tools/input.ts +72 -0
  87. package/src/mcp/tools/machine.ts +100 -0
  88. package/src/mcp/tools/media.ts +129 -0
  89. package/src/mcp/tools/memory.ts +250 -0
  90. package/src/mcp/tools/video.ts +271 -0
  91. package/src/native/acme.ts +116 -0
  92. package/src/native/discover.ts +179 -0
  93. package/src/native/dxa.ts +166 -0
  94. package/src/native/ghidra/analyze.ts +86 -0
  95. package/src/native/ghidra/index.ts +173 -0
  96. package/src/native/ghidra/language/c64rt_6510.cspec +34 -0
  97. package/src/native/ghidra/language/c64rt_6510.ldefs +15 -0
  98. package/src/native/ghidra/language/c64rt_6510.pspec +15 -0
  99. package/src/native/ghidra/language/c64rt_6510.slaspec +596 -0
  100. package/src/native/ghidra/scripts/C64Export.java +274 -0
  101. package/src/native/ghidra/scripts/C64Prepare.java +60 -0
  102. package/src/native/local.ts +34 -0
  103. package/src/native/processes.ts +350 -0
  104. package/src/native/run.ts +172 -0
  105. package/src/native/staging.ts +83 -0
  106. package/src/native/status.ts +113 -0
  107. package/src/native/types.ts +257 -0
  108. package/src/native/watchdog.ts +78 -0
  109. package/src/package.ts +21 -0
  110. package/src/platform.ts +441 -0
  111. package/src/project.ts +39 -0
  112. package/src/protocol/framing.ts +132 -0
  113. package/src/protocol/messages.ts +259 -0
  114. package/src/protocol/tools.ts +266 -0
  115. package/src/protocol/vice.ts +1348 -0
  116. package/src/script.ts +60 -0
  117. package/src/time.ts +15 -0
  118. package/src/trace.ts +194 -0
  119. package/THIRD-PARTY-NOTICES.md +0 -26
  120. package/bin/cli.mjs +0 -283
  121. package/skills/acme-build/SKILL.md +0 -260
  122. package/skills/acme-build/scripts/acme.mjs +0 -341
  123. package/skills/acme-build/template.a +0 -53
  124. package/skills/c64-disk-access/SKILL.md +0 -159
  125. package/skills/c64-disk-access/scripts/c1541.mjs +0 -569
  126. package/skills/c64-memory-mapping/SKILL.md +0 -631
  127. package/skills/c64-memory-mapping/memmap.json +0 -8800
  128. package/skills/c64-memory-mapping/scripts/driver.mjs +0 -553
  129. package/skills/c64-petcat/SKILL.md +0 -87
  130. package/skills/c64-petcat/scripts/petcat.mjs +0 -221
  131. package/skills/c64-program-recon/SKILL.md +0 -706
  132. package/skills/c64-program-recon/references/control-flow.md +0 -180
  133. package/skills/c64-program-recon/references/graphics.md +0 -79
  134. package/skills/c64-program-recon/references/observation-hazards.md +0 -143
  135. package/skills/c64-program-recon/references/reconstruction.md +0 -133
  136. package/skills/c64-program-recon/references/sound-and-input.md +0 -74
  137. package/skills/c64-program-recon/references/tool-selection.md +0 -78
  138. package/skills/c64-program-recon/scripts/derive.mjs +0 -364
  139. package/skills/c64-program-recon/scripts/packer-finding.mjs +0 -709
  140. package/skills/c64-program-recon/templates/memory-map.template.md +0 -101
  141. package/skills/c64-provenance-diff/SKILL.md +0 -292
  142. package/skills/c64-provenance-diff/scripts/diff-images.mjs +0 -984
  143. package/skills/c64-provenance-diff/scripts/recovery-schema.mjs +0 -395
  144. package/skills/c64-ram-capture/RELEASES.json.example +0 -17
  145. package/skills/c64-ram-capture/SKILL.md +0 -474
  146. package/skills/c64-ram-capture/scripts/compare-cross-binary.mjs +0 -525
  147. package/skills/c64-ram-capture/scripts/compare.mjs +0 -258
  148. package/skills/c64-ram-capture/scripts/derive-transients.mjs +0 -575
  149. package/skills/c64-ram-capture/scripts/dump-artifacts.mjs +0 -317
  150. package/skills/c64-ram-capture/scripts/mcp-module.mjs +0 -174
  151. package/skills/c64-ram-capture/scripts/project-paths.mjs +0 -81
  152. package/skills/c64-ram-capture/scripts/releases.mjs +0 -109
  153. package/skills/c64-ram-capture/scripts/vsf-slice.mjs +0 -147
  154. package/skills/c64-ram-capture/scripts/watch-loads.mjs +0 -581
  155. package/skills/c64-ram-capture/templates/capture-record.template.md +0 -99
  156. package/skills/c64-ram-capture/transients/README.md +0 -137
  157. package/skills/routine-queue-walker/SKILL.md +0 -394
  158. package/skills/routine-queue-walker/scripts/completeness-report.mjs +0 -465
  159. package/skills/vice-wedge-triage/SKILL.md +0 -269
@@ -0,0 +1,110 @@
1
+ ---
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. 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
+ ---
5
+
6
+ # c64-provenance
7
+
8
+ ## Purpose
9
+
10
+ Compare releases of a C64 program. Find the bytes where they differ and which releases agree. Use this to find which bytes are probably the original application and which are changes of one release.
11
+
12
+ This skill does not change project knowledge.
13
+
14
+ ## Inputs
15
+
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
+ - Optional: an address range to compare.
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.
21
+
22
+ ## Tools / execution path
23
+
24
+ The script compares the files locally. Only `--run` uses the host runtime: each release runs in its own emulator.
25
+
26
+ ```
27
+ node <skill>/scripts/provenance.ts releases/a.prg releases/b.prg releases/c.prg
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
31
+ ```
32
+
33
+ | Option | Meaning |
34
+ |---|---|
35
+ | `--shift 2:-256` | Move release 2 (the first release is 1) by -256 bytes. Use it again for more releases. |
36
+ | `--from '$0900'` | Compare from this address. |
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. |
40
+
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 `--shift`, the addresses of `segments`, `common` and `differences` are the release addresses plus the `shift` of the release. The `start` and `end` of `runtime` are the addresses in the release itself, without the shift.
44
+
45
+ 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.
46
+
47
+ ## Workflow
48
+
49
+ 1. Run the script on the releases.
50
+ 2. Read `identicalShare`. If it is low and `suggestedShifts` is present, the releases are at different addresses. Run the script again with a `--shift` of `suggestedShifts`. `shiftsNotSuggested` tells why a found shift does not fit in `$0000-$ffff`.
51
+
52
+ `suggestedShifts` has at most 3 shifts for each release. The shift with the most `matchingSequences` is first. Data that did not move and code that moved give different shifts. Try each shift. `current: true` marks the shift of this run.
53
+ 3. Read `differences`. Each one has an address range, the bytes of each release, and `groups`: the releases that have the same bytes there.
54
+ 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:
55
+ - `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`. The operand bytes of an executed instruction count as `execute`, for example the value of `LDA #$03`.
56
+ - `changed`: `true` when the bytes at the end of the run are not the bytes of the release.
57
+ - A range that each release writes before it reads is probably runtime state, not a difference of the releases.
58
+ - A range with `none` in each release is not used in this run. Run more frames or other steps before you decide.
59
+ - `run.skipped`: the releases have no differences, so no release ran.
60
+ 5. Examine each important difference:
61
+ - Disassemble the bytes of each release with the c64-emulator skill, or analyze them with the c64-static-analysis skill.
62
+ - Find what the change does, for example a trainer, a removed copy protection, or a changed text.
63
+ 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.
64
+ 7. Record the conclusion as a comment or a region with the c64-knowledge skill.
65
+
66
+ ## One release
67
+
68
+ Use `separate` when you have only one release. It gives evidence, not a decision:
69
+
70
+ - `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. Only an image from c64-unpacker `capture` has stages. For an image from `loads`, `noStages` tells this.
71
+ - `access`: from the joined `--coverage` files: `executed`, `read`, `written`, `read-written` or `untouched`. `untouched` bytes did not take part in the runs.
72
+ - `codeRegions.total`: the number of code regions in the knowledge.
73
+ - `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.
74
+ - `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. A `generated` range can have gaps of two bytes or less: these are operand bytes.
75
+
76
+ Each list has `count` and `listed`. `listed` has the first ranges, at most `--max-listed`. `--out` writes all of them. Run more coverage runs for parts that are `untouched` before you decide.
77
+
78
+ ## Knowledge
79
+
80
+ Automatic write:
81
+ - none.
82
+
83
+ Record a confirmed conclusion as a normal comment, region or symbol with the c64-knowledge skill. Do not record the full comparison.
84
+
85
+ ## Result
86
+
87
+ 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. `releases` gives the segments and the `shift` of each release. With `--run`, each difference also has `runtime`, and `run` tells if each release started, or why not.
88
+
89
+ The runtime evidence is evidence only. The script does not decide which bytes are original.
90
+
91
+ 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.
92
+
93
+ ## Failure and conflicts
94
+
95
+ - Do not call releases independent when you do not know their relation. Two cracks can come from the same source.
96
+ - Do not call a byte original only because one release has it. A majority of releases is also not proof.
97
+ - `common` is empty: the releases do not cover the same addresses. Use `--shift`, or compare a range that all releases cover.
98
+ - `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.
99
+ - `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 emulator script.
100
+ - `moreDifferences: true`: the script shows only the first differences. Use `--from` and `--to` to see the others.
101
+ - `not-found`: the project has no file at that path.
102
+ - `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.
103
+
104
+ ## Handoffs
105
+
106
+ - Get releases from disk images → c64-disk
107
+ - Unpack a packed release → c64-unpacker
108
+ - Run or examine the different code → c64-emulator
109
+ - Analyze the different code → c64-static-analysis
110
+ - Record a conclusion → c64-knowledge
@@ -0,0 +1,152 @@
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
+ }
12
+
13
+ export interface Release {
14
+ name: string;
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;
19
+ }
20
+
21
+ export interface DifferingRange {
22
+ start: number;
23
+ end: number;
24
+ /** The bytes of each release in this range, by release name. */
25
+ values: Record<string, number[]>;
26
+ /** Releases with the same bytes here, largest group first. */
27
+ groups: string[][];
28
+ }
29
+
30
+ export interface Comparison {
31
+ /** The addresses where every release has bytes. */
32
+ common: Range[];
33
+ identicalBytes: number;
34
+ differingBytes: number;
35
+ differences: DifferingRange[];
36
+ /** True when more differing ranges exist than the limit allowed. */
37
+ moreDifferences: boolean;
38
+ }
39
+
40
+ /** Joins differing addresses that are at most this far apart into one range. */
41
+ const JOIN_GAP = 4;
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
+
57
+ export function compareReleases(releases: Release[], options: { from?: number; to?: number; maxRanges?: number } = {}): Comparison {
58
+ const common = commonRanges(releases, options.from, options.to);
59
+ let identicalBytes = 0;
60
+ const differing: number[] = [];
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
+ }
67
+ }
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[] = [];
71
+ for (const address of differing) {
72
+ const last = ranges.at(-1);
73
+ if (last !== undefined && address - last.end <= JOIN_GAP && inSameCommon(last.start, address)) last.end = address;
74
+ else ranges.push({ start: address, end: address });
75
+ }
76
+ const limit = options.maxRanges ?? 64;
77
+ const differences = ranges.slice(0, limit).map((range) => {
78
+ const values: Record<string, number[]> = {};
79
+ const groups = new Map<string, string[]>();
80
+ for (const release of releases) {
81
+ const bytes = Array.from({ length: range.end - range.start + 1 }, (_, offset) => release.byteAt(range.start + offset));
82
+ values[release.name] = bytes;
83
+ const key = bytes.join(",");
84
+ groups.set(key, [...(groups.get(key) ?? []), release.name]);
85
+ }
86
+ return { ...range, values, groups: [...groups.values()].sort((a, b) => b.length - a.length) };
87
+ });
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));
94
+ }
95
+
96
+ /** Most shifts that suggestShifts gives: data that did not move can outvote code that did (b2/04). */
97
+ export const MAX_SUGGESTED_SHIFTS = 3;
98
+
99
+ /**
100
+ * The shifts that put `other` over `base` best: the most common address
101
+ * differences between equal 8-byte sequences, most votes first. A shift with
102
+ * less than a tenth of the best one's votes is chance. Empty when nothing matches.
103
+ */
104
+ export function suggestShifts(base: Release, other: Release): Array<{ shift: number; matches: number }> {
105
+ const WINDOW = 8;
106
+ const key = (release: Release, addresses: number[], at: number) =>
107
+ String.fromCharCode(...addresses.slice(at, at + WINDOW).map((address) => release.byteAt(address)));
108
+ const baseAddresses = addressesOf(base);
109
+ const otherAddresses = addressesOf(other);
110
+ const positions = new Map<string, number[]>();
111
+ for (let at = 0; at + WINDOW <= baseAddresses.length; at++) {
112
+ const found = positions.get(key(base, baseAddresses, at));
113
+ // Very common sequences (fill bytes) say nothing about the alignment.
114
+ if (found === undefined) positions.set(key(base, baseAddresses, at), [baseAddresses[at]!]);
115
+ else if (found.length < 8) found.push(baseAddresses[at]!);
116
+ }
117
+ const votes = new Map<number, number>();
118
+ for (let at = 0; at + WINDOW <= otherAddresses.length; at++) {
119
+ for (const address of positions.get(key(other, otherAddresses, at)) ?? []) {
120
+ const shift = address - otherAddresses[at]!;
121
+ votes.set(shift, (votes.get(shift) ?? 0) + 1);
122
+ }
123
+ }
124
+ const ranked = [...votes].map(([shift, matches]) => ({ shift, matches })).sort((a, b) => b.matches - a.matches || Math.abs(a.shift) - Math.abs(b.shift));
125
+ return ranked.filter((found) => found.matches * 10 >= ranked[0]!.matches).slice(0, MAX_SUGGESTED_SHIFTS);
126
+ }
127
+
128
+ export type ShiftSuggestion = { release: number; shift: number; matchingSequences: number } | { release: number; matchingSequences: number; reason: string };
129
+
130
+ /**
131
+ * The --shift to suggest for release `index` (0 = the first) from `found`,
132
+ * the best shift of that release over the first one (F-25). The shift is the
133
+ * full value for --shift: `applied` holds the shifts already given. A shift
134
+ * that moves a segment out of $0000-$ffff is not suggested: with two releases,
135
+ * the opposite shift of the first release is suggested when it fits, else the
136
+ * result gives the reason.
137
+ */
138
+ export function shiftSuggestion(releases: Release[], applied: number[], index: number, found: { shift: number; matches: number }): ShiftSuggestion {
139
+ const outside = (release: Release, by: number) => release.segments.find((segment) => segment.start + by < 0 || segment.end + by > 0xffff);
140
+ const hex = (value: number) => `$${value.toString(16).padStart(4, "0")}`;
141
+ const why = (number: number, by: number, segment: Range) =>
142
+ `release ${number} moved by ${by} puts ${hex(segment.start)}-${hex(segment.end)} ${segment.start + by < 0 ? "below $0000" : "past $ffff"}`;
143
+ const own = outside(releases[index]!, found.shift);
144
+ if (own === undefined) return { release: index + 1, shift: applied[index]! + found.shift, matchingSequences: found.matches };
145
+ const reasons = [why(index + 1, found.shift, own)];
146
+ if (releases.length === 2) {
147
+ const base = outside(releases[0]!, -found.shift);
148
+ if (base === undefined) return { release: 1, shift: applied[0]! - found.shift, matchingSequences: found.matches };
149
+ reasons.push(why(1, -found.shift, base));
150
+ }
151
+ return { release: index + 1, matchingSequences: found.matches, reason: `No shift fits: ${reasons.join(", and ")}.` };
152
+ }
@@ -0,0 +1,334 @@
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. 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).
7
+
8
+ import { posix } from "node:path";
9
+ import { parseArgs } from "node:util";
10
+
11
+ import { formatC64Address, parseC64Address } from "#src/c64.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";
20
+ import { runScript, UsageError } from "#src/script.ts";
21
+ import { compareReleases, shiftSuggestion, suggestShifts, type Range, type Release } from "./compare.ts";
22
+ import { watchRelease, type ImageState, type Start } from "./runtime.ts";
23
+ import { separate } from "./separate.ts";
24
+
25
+ const USAGE = `provenance.ts <release> <release> [<release> ...] [options]
26
+ provenance.ts separate <release> [--coverage <file> ...] [--out <file>] [--max-listed <n>]
27
+
28
+ --shift <n>:<bytes> move release n (1 = the first) by a number of bytes, for example 2:-256
29
+ --from <address> compare from this address
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
34
+
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 each list (default 50) and its count.
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.`;
44
+
45
+ function address(text: string, option: string): number {
46
+ try {
47
+ return parseC64Address(text);
48
+ } catch {
49
+ throw new UsageError(`${option} must be $ followed by four hex digits, not ${text}`);
50
+ }
51
+ }
52
+
53
+ const hexBytes = (bytes: number[]) => Buffer.from(bytes).toString("hex").replace(/(..)(?!$)/g, "$1 ");
54
+
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> {
148
+ const { values, positionals } = parseArgs({
149
+ args: argv,
150
+ strict: true,
151
+ allowPositionals: true,
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
+ },
163
+ });
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");
172
+ if (positionals.length < 2) throw new UsageError("give two or more releases");
173
+ const shifts = new Map<number, number>();
174
+ for (const text of values.shift ?? []) {
175
+ const match = /^(\d+):([+-]?\d+)$/.exec(text);
176
+ if (match === null || Number(match[1]) < 1 || Number(match[1]) > positionals.length) throw new UsageError(`--shift must be <release number>:<bytes>, not ${text}`);
177
+ shifts.set(Number(match[1]), Number(match[2]));
178
+ }
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);
200
+ const comparison = compareReleases(releases, {
201
+ ...(values.from === undefined ? {} : { from: address(values.from, "--from") }),
202
+ ...(values.to === undefined ? {} : { to: address(values.to, "--to") }),
203
+ });
204
+ const compared = comparison.identicalBytes + comparison.differingBytes;
205
+ const identicalShare = compared === 0 ? 0 : comparison.identicalBytes / compared;
206
+ // A poor match at the load addresses can mean that the releases are placed differently.
207
+ // Each release gets its best shifts: data that stayed can outvote code that moved, so one is not enough (b2/04).
208
+ const applied = loaded.map((item) => item.shift);
209
+ const suggestions =
210
+ identicalShare < 0.5
211
+ ? releases.slice(1).flatMap((release, index) =>
212
+ suggestShifts(releases[0]!, release).map((found) => {
213
+ const suggestion = shiftSuggestion(releases, applied, index + 1, found);
214
+ // The shift that the run already has: its votes tell how good the present alignment is.
215
+ return "shift" in suggestion && suggestion.shift === applied[suggestion.release - 1] ? { ...suggestion, current: true } : suggestion;
216
+ }),
217
+ )
218
+ : [];
219
+ const suggested = suggestions.filter((suggestion) => "shift" in suggestion);
220
+ const notSuggested = suggestions.filter((suggestion) => "reason" in suggestion);
221
+
222
+ // The runtime evidence: one emulator for each release, the differences at the release's own addresses.
223
+ const runtime = new Map<string, Array<Range & { first: string; changed: boolean }>>();
224
+ const runs: Record<string, unknown> = {};
225
+ if (frames !== undefined && comparison.differences.length > 0) {
226
+ for (const item of loaded) {
227
+ const name = item.release.name;
228
+ if (item.start.kind === "none") {
229
+ runs[name] = { started: false, reason: item.start.reason };
230
+ continue;
231
+ }
232
+ const ranges = comparison.differences.map((difference) => ({ start: difference.start - item.shift, end: difference.end - item.shift }));
233
+ const client = await ViceSessionClient.open({ videoStandard: "pal" });
234
+ try {
235
+ const watched = await watchRelease(client, item.start, ranges, (at) => item.release.byteAt(at + item.shift), { frames, steps });
236
+ if (!watched.started) runs[name] = { started: false, reason: watched.reason };
237
+ else {
238
+ runs[name] = { started: true };
239
+ runtime.set(name, watched.accesses.map((access, index) => ({ ...ranges[index]!, ...access })));
240
+ }
241
+ } finally {
242
+ await client.close();
243
+ }
244
+ }
245
+ }
246
+
247
+ return {
248
+ releases: loaded.map((item) => ({ name: item.release.name, shift: item.shift, segments: item.release.segments.map((segment) => ({ start: formatC64Address(segment.start), end: formatC64Address(segment.end) })) })),
249
+ common: comparison.common.map((range) => ({ start: formatC64Address(range.start), end: formatC64Address(range.end) })),
250
+ identicalBytes: comparison.identicalBytes,
251
+ differingBytes: comparison.differingBytes,
252
+ identicalShare: Number(identicalShare.toFixed(3)),
253
+ differences: comparison.differences.map((difference, index) => ({
254
+ start: formatC64Address(difference.start),
255
+ end: formatC64Address(difference.end),
256
+ values: Object.fromEntries(Object.entries(difference.values).map(([name, bytes]) => [name, hexBytes(bytes.slice(0, 32)) + (bytes.length > 32 ? " ..." : "")])),
257
+ groups: difference.groups,
258
+ // runtime gives the release's own addresses: with a shift, they are not the addresses of the difference (F-24).
259
+ ...(runtime.size === 0
260
+ ? {}
261
+ : {
262
+ runtime: Object.fromEntries(
263
+ [...runtime].map(([name, accesses]) => {
264
+ const { start, end, first, changed } = accesses[index]!;
265
+ return [name, { start: formatC64Address(start), end: formatC64Address(end), first, changed }];
266
+ }),
267
+ ),
268
+ }),
269
+ })),
270
+ ...(comparison.moreDifferences ? { moreDifferences: true } : {}),
271
+ ...(suggested.length > 0 ? { suggestedShifts: suggested } : {}),
272
+ ...(notSuggested.length > 0 ? { shiftsNotSuggested: notSuggested } : {}),
273
+ ...(frames === undefined
274
+ ? {}
275
+ : { run: { frames, steps: steps.length, releases: runs, ...(comparison.differences.length === 0 ? { skipped: "The releases have no differences, so no release ran." } : {}) } }),
276
+ };
277
+ }
278
+
279
+ /** separate: evidence from one release (D51), with all ranges in --out and the first ones in the result. */
280
+ function separateCommand(path: string, coveragePaths: string[], out: string | undefined, maxListed: string | undefined): unknown {
281
+ const max = maxListed ?? "50";
282
+ if (!/^\d{1,5}$/.test(max)) throw new UsageError(`--max-listed must be a whole number, not ${max}`);
283
+ const loaded = readRelease(path, 0);
284
+ const coverage = readCoverageFiles(coveragePaths, (message) => {
285
+ throw new UsageError(message);
286
+ });
287
+ const db = openForRead();
288
+ let regions: ReturnType<typeof listRegions>;
289
+ let references: ReturnType<typeof listReferences>;
290
+ try {
291
+ regions = listRegions(db).filter((region) => CODE_REGION_TYPES.includes(region.type));
292
+ references = listReferences(db);
293
+ } finally {
294
+ db?.close();
295
+ }
296
+ const found = separate({
297
+ segments: loaded.release.segments,
298
+ memory: loaded.memory,
299
+ ...(loaded.entry === undefined ? {} : { entry: loaded.entry }),
300
+ ...(loaded.stages === undefined ? {} : { stages: loaded.stages }),
301
+ ...(coverage === undefined ? {} : { coverage }),
302
+ codeRegions: regions,
303
+ references,
304
+ });
305
+ const show = (range: Range) => ({ start: formatC64Address(range.start), end: formatC64Address(range.end) });
306
+ const ranges = found.ranges.map((range) => ({ ...show(range), ...(range.stage === undefined ? {} : { stage: range.stage }), ...(range.access === undefined ? {} : { access: range.access }) }));
307
+ const codeRegions = found.codeRegions.map((region) => ({ ...show(region), type: region.type, ...(region.reachable === undefined ? {} : { reachable: region.reachable }), ...(region.executed === undefined ? {} : { executed: region.executed }) }));
308
+ const outside = { read: found.outside.read.map(show), executed: found.outside.executed.map(show), generated: found.outside.generated.map(show) };
309
+ const listed = <T>(items: T[]) => ({ count: items.length, listed: items.slice(0, Number(max)) });
310
+ if (out !== undefined) writeProjectFile(out, Buffer.from(`${JSON.stringify({ release: path, ranges, codeRegions, outside }, null, 1)}\n`), { what: "--out" });
311
+ const bytes = (filter: (range: (typeof found.ranges)[number]) => boolean) => found.ranges.filter(filter).reduce((sum, range) => sum + range.end - range.start + 1, 0);
312
+ const lastStage = loaded.stages === undefined ? undefined : loaded.stages.length - 1;
313
+ return {
314
+ release: path,
315
+ ...(loaded.entry === undefined ? {} : { entry: formatC64Address(loaded.entry) }),
316
+ ...(lastStage === undefined
317
+ ? { noStages: "image.json has no stage log. Only a capture of c64-unpacker writes one. A capture of loads holds one part of a program and has none." }
318
+ : {
319
+ stages: lastStage + 1,
320
+ bytesByStage: Object.fromEntries([...[...Array(lastStage + 1).keys()].map((stage): [string, number] => [String(stage), bytes((range) => range.stage === stage)]), ["none", bytes((range) => range.stage === undefined)]]),
321
+ }),
322
+ ...(coverage === undefined ? {} : { bytesByAccess: Object.fromEntries((["executed", "read", "written", "read-written", "untouched"] as const).map((access) => [access, bytes((range) => range.access === access)])) }),
323
+ ranges: listed(ranges),
324
+ // total counts every code region; unreachable is a list like the others (b2/08).
325
+ codeRegions: {
326
+ total: codeRegions.length,
327
+ ...(loaded.entry === undefined ? {} : { unreachable: listed(codeRegions.filter((region) => region.reachable === false)) }),
328
+ },
329
+ ...(coverage === undefined ? {} : { outside: { read: listed(outside.read), executed: listed(outside.executed), generated: listed(outside.generated) } }),
330
+ ...(out === undefined ? {} : { out }),
331
+ };
332
+ }
333
+
334
+ await runScript(() => run(process.argv.slice(2)), { usage: USAGE, refusals: [KnowledgeError] });