@henols/c64-re-tools 0.1.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 (35) hide show
  1. package/README.md +61 -0
  2. package/bin/cli.mjs +226 -0
  3. package/package.json +53 -0
  4. package/skills/acme-build/SKILL.md +224 -0
  5. package/skills/acme-build/scripts/acme.mjs +263 -0
  6. package/skills/acme-build/template.a +39 -0
  7. package/skills/c64-memory-mapping/SKILL.md +199 -0
  8. package/skills/c64-memory-mapping/memmap.json +8800 -0
  9. package/skills/c64-memory-mapping/scripts/driver.mjs +553 -0
  10. package/skills/c64-program-recon/SKILL.md +172 -0
  11. package/skills/c64-program-recon/references/control-flow.md +174 -0
  12. package/skills/c64-program-recon/references/graphics.md +73 -0
  13. package/skills/c64-program-recon/references/observation-hazards.md +118 -0
  14. package/skills/c64-program-recon/references/reconstruction.md +128 -0
  15. package/skills/c64-program-recon/references/sound-and-input.md +68 -0
  16. package/skills/c64-program-recon/references/tool-selection.md +55 -0
  17. package/skills/c64-program-recon/scripts/derive.mjs +364 -0
  18. package/skills/c64-program-recon/templates/memory-map.template.md +62 -0
  19. package/skills/c64-provenance-diff/SKILL.md +257 -0
  20. package/skills/c64-provenance-diff/scripts/diff-images.mjs +981 -0
  21. package/skills/c64-provenance-diff/scripts/diff-images.test.mjs +665 -0
  22. package/skills/c64-provenance-diff/scripts/recovery-schema.mjs +383 -0
  23. package/skills/c64-ram-capture/SKILL.md +306 -0
  24. package/skills/c64-ram-capture/scripts/compare.mjs +258 -0
  25. package/skills/c64-ram-capture/scripts/d64-parse.mjs +243 -0
  26. package/skills/c64-ram-capture/scripts/d64-parse.test.mjs +243 -0
  27. package/skills/c64-ram-capture/scripts/dump-artifacts.mjs +317 -0
  28. package/skills/c64-ram-capture/scripts/dump-artifacts.test.mjs +133 -0
  29. package/skills/c64-ram-capture/scripts/project-paths.mjs +81 -0
  30. package/skills/c64-ram-capture/scripts/releases.mjs +109 -0
  31. package/skills/c64-ram-capture/scripts/test-corpus.mjs +75 -0
  32. package/skills/c64-ram-capture/scripts/watch-loads.mjs +575 -0
  33. package/skills/c64-ram-capture/scripts/watch-loads.test.mjs +339 -0
  34. package/skills/c64-ram-capture/templates/capture-record.template.md +59 -0
  35. package/skills/vice-wedge-triage/SKILL.md +149 -0
@@ -0,0 +1,257 @@
1
+ ---
2
+ name: c64-provenance-diff
3
+ description: Decide whether a byte in a cracked C64 release is original game code or something a cracker changed, by diffing two or more independently-cracked releases at an anchor-proven offset. Use when asked to diff two releases or disk images, work out which bytes the cracker patched, tell loader or cracktro code from game code, prove a byte is original, establish provenance or confidence for a memory range, regenerate the provenance ledger, or run anchor-search, count-patches or diff-images. Also use when asked whether a crack added a trainer or cheat, whether a patch changes gameplay rather than loading, whether a rebuild would inherit a cracker's gameplay alteration, or whether two releases are genuinely independent rather than sharing an ancestor.
4
+ ---
5
+
6
+ # Deciding what a cracker changed
7
+
8
+ **A byte that differs between two releases is not a cracker patch.** It is a byte
9
+ that differs. This pipeline exists because the gap between those two statements is
10
+ where confident nonsense gets manufactured — `scripts/diff-images.mjs`'s own header
11
+ calls it "the step most able to produce confident nonsense". Every stage below
12
+ either proves its own precondition or refuses to emit.
13
+
14
+ **Run the four verbs in order.** `diff` is meaningless without a proven offset, and
15
+ `ledger` will not write a verdict the earlier stages did not earn.
16
+
17
+ ```bash
18
+ D=.claude/skills/c64-provenance-diff/scripts/diff-images.mjs # from the repo root
19
+
20
+ node $D anchor-search # 1. prove the per-release offset [WRITES]
21
+ node $D diff # 2. N-way byte diff at that offset
22
+ node $D count-patches # 3. CRACKER-PATCH addresses in game code
23
+ node $D ledger # 4. regenerate recovery/PROVENANCE.md [WRITES]
24
+
25
+ node $D diff --json # machine-readable, with per-range reasons
26
+ node $D diff --gap-tolerance 16 # coalescing width (default shown)
27
+ node $D anchor-search --reference <id> # pick the reference release
28
+ node .claude/skills/c64-provenance-diff/scripts/releases.mjs list # the release ids in play
29
+ ```
30
+
31
+ Pure Node over committed files — the `.bin` dumps, their `.map.json` manifests, and
32
+ `recovery/RELEASES.json`. It contacts nothing.
33
+
34
+ ## The order
35
+
36
+ | # | Verb | Proves | Refuses to |
37
+ |---|---|---|---|
38
+ | 1 | `anchor-search` | A single global offset per release, from long distinctive byte runs located with `Buffer.indexOf` | Accept a **majority** vote — every usable anchor must agree, or there is no offset |
39
+ | 2 | `diff` | Which ranges differ, coalesced on verdict continuity | Diff at an assumed offset |
40
+ | 3 | `count-patches` | How many addresses are `CRACKER-PATCH` **and** `game`-kind | Count a patch outside game code |
41
+ | 4 | `ledger` | The generated tier of `recovery/PROVENANCE.md` | Emit rather than launder an assumption |
42
+
43
+ ## Two verbs write to tracked files
44
+
45
+ `anchor-search` updates `recovery/RELEASES.json`; `ledger` rewrites
46
+ `recovery/PROVENANCE.md` and touches `RELEASES.json` too. So `git status` is
47
+ **expected** to be dirty after a run.
48
+
49
+ What matters is *what* changed. A clean re-run produces a **timestamp-only** diff —
50
+ `proven_at` and `generated_at`. Anything else is a real change:
51
+
52
+ ```bash
53
+ git diff -- recovery/RELEASES.json recovery/PROVENANCE.md
54
+ ```
55
+
56
+ If the only `-`/`+` pairs are those two fields, revert the churn and move on. If
57
+ `offset`, `anchor_count`, `anchors_agreeing` or `generated_tier_sha256` moved, stop
58
+ and find out why before committing — that is the pipeline telling you the evidence
59
+ changed.
60
+
61
+ ## Worked example — the real corpus
62
+
63
+ Two independently-cracked releases of one title, both captured at the same
64
+ post-loader entry trigger. **The release ids below are shown as `release-a` and
65
+ `release-b`; every number is real output from a live run against a two-release
66
+ corpus, with only the ids renamed** — the tool has no opinion about what a
67
+ release is called.
68
+
69
+ ```
70
+ $ node $D anchor-search
71
+ release-a -> release-b: ok=true offset=0 (all 7 usable anchor(s) agree on offset 0)
72
+
73
+ $ node $D diff
74
+ diff: 204 range(s), gap_tolerance=16, coalesced=260
75
+
76
+ $ node $D count-patches
77
+ release-a: 0
78
+ release-b: 0
79
+ ```
80
+
81
+ **204 differing ranges and zero cracker patches.** The verdict tally from
82
+ `diff --json` is `{"UNKNOWN": 102, "ORIGINAL": 102}` — nothing reached
83
+ `CRACKER-PATCH` at all. That is the pipeline working, not failing.
84
+
85
+ Read it as: 102 ranges are identical across two independently-cracked releases, so
86
+ they are `ORIGINAL` with real evidence behind the word. The other 102 differ, match
87
+ no cracker signature, and are therefore `UNKNOWN` — and each carries a `reason`
88
+ naming the alternatives it ruled out:
89
+
90
+ > differs across 2 release(s) (release-a, release-b) with no recognised cracker
91
+ > signature … not a revision difference … not a `.d64` read error … not a packer
92
+ > artifact … not relocation (the anchor-proven offset for this pair is recorded
93
+ > above and used here).
94
+
95
+ `UNKNOWN` with a rule-out list is the honest answer. Do not upgrade it to
96
+ `CRACKER-PATCH` because a byte differs. **Confidence: HIGH** — run live against the
97
+ committed corpus; `ledger` reproduced the committed
98
+ `generated_tier_sha256 dc7eb080…` byte-identically, so the classification is
99
+ deterministic.
100
+
101
+ **One qualifier on the 102, and it is the example's premise rather than its output:**
102
+ "independently-cracked" is asserted at the top of this example, not proven by it. The
103
+ determinism is HIGH; the `ORIGINAL` verdicts inherit whatever confidence that
104
+ independence claim carries. See § *The independence precondition* below before
105
+ quoting an `ORIGINAL` count as settled.
106
+
107
+ ## The five kinds and the three verdicts
108
+
109
+ `bucketManifest` promotes a manifest from `ranges-only` to `bucketed`, assigning
110
+ `game` / `loader` / `cracktro` / `io` / `unused`. Verdicts are `ORIGINAL`,
111
+ `CRACKER-PATCH`, `UNKNOWN`, carrying `HIGH` or `MEDIUM-HIGH` confidence.
112
+
113
+ The two seeds are where this goes wrong, and both failure modes are on record:
114
+
115
+ - **`loader` is seeded from `RELEASES.json`'s earned `loader_ranges`** — live
116
+ disassembly evidence — **never from `NOTES.md` prose.** Reading a loader range
117
+ out of prose is the documented root cause of `$08F5`, a permanent joystick-poll
118
+ instruction, once being classified as loader code.
119
+ - **`cracktro` is seeded from a crack-credit *vocabulary* scan**, not a bare
120
+ printable-ASCII scan. A bare scan was tried and produced a real false positive
121
+ against a real corpus: **the game's own title-screen text** is printable ASCII
122
+ too, and it differed between the two releases. A bare scan called that cracker
123
+ credit. It is not — a differing string is not a cracker string, and it is
124
+ correctly left `UNKNOWN`.
125
+
126
+ `io` (`$D000-$DFFF`) and `unused` (contiguous `$00`/`$FF` power-on runs) are
127
+ assigned at capture time and kept verbatim. Everything the trace reaches is `game`.
128
+
129
+ Per D-05 the `.bin` files are **never** edited or zeroed. Classification lives in
130
+ the manifests; the bytes stay verbatim evidence.
131
+
132
+ ## A `CRACKER-PATCH` in `game` code is a trainer until proven otherwise
133
+
134
+ `count-patches` counts exactly one intersection — verdict `CRACKER-PATCH`, kind
135
+ `game`. That intersection has a name the pipeline never says out loud: a **trainer**.
136
+ A cracker changing bytes *inside game code* is altering gameplay, and unlimited
137
+ lives, disabled collision or a frozen timer is the usual reason.
138
+
139
+ This matters because the three verdicts answer **who wrote a range**, not **what it
140
+ does**. A relocated loader stub and a life-decrement patched to a `NOP` both come
141
+ back `CRACKER-PATCH`. So give every patch a **function verdict** alongside its origin
142
+ verdict:
143
+
144
+ | Function | Means | Why it matters |
145
+ |---|---|---|
146
+ | `loader` | raw-sector loading, decrunch, relocation, drive code | an obstacle to get past, not a subject |
147
+ | `cracktro` | intro, scroller, music, the crack's own presentation | not the object of study |
148
+ | `gameplay` | reads or writes game state — **a trainer** | any rebuild copying these bytes inherits it |
149
+ | `unknown` | not yet attributed | scrutinise before trusting |
150
+
151
+ **A rebuild reconstructed from these bytes inherits a `gameplay` patch silently**, and
152
+ behaviour-only verification will not catch it: the baselines come from the same
153
+ cracked image, so the rebuild and its reference agree *while both differ from the
154
+ game as it shipped*.
155
+
156
+ ### The independence precondition — this skill's own premise
157
+
158
+ `ORIGINAL` means "identical across two **independently**-cracked releases". Delete the
159
+ word *independently* and the verdict is worthless: two releases sharing an ancestor
160
+ are identical everywhere the ancestor was, **including everywhere the ancestor's
161
+ cracker patched**. Establish the independence. Do not infer it from two releases
162
+ carrying different group names, different loaders, or different cracktros — those are
163
+ the cheapest things for a re-cracker to replace.
164
+
165
+ Until it is established, the diff is directional, and the direction is the trap:
166
+
167
+ - A diff **hit** is informative — something was patched.
168
+ - A diff **miss** is not, and it is the miss that reads as reassurance.
169
+
170
+ So `count-patches` reporting `0` is not evidence that no trainer exists. It is
171
+ evidence that no trainer exists **in one release and not the other**. With unproven
172
+ ancestry those are different claims, and only the second one was tested.
173
+
174
+ ### The detector that does not depend on the diff
175
+
176
+ A signature hunt over the canonical image, read against the coverage map rather than
177
+ against another release. None of it needs a second release at all, which is precisely
178
+ why it survives the shared-ancestor case:
179
+
180
+ 1. **Writes to a consequence counter from an unexpected site.** Once the memory map
181
+ names what the game decrements on failure — lives, timer, health — every writer
182
+ that is not the game's own is a candidate. Search **every addressing form that can
183
+ reach the address**, indexed included. An absolute-mode-only search is the standard
184
+ way this hunt returns a false negative.
185
+ 2. **Armed but never reached.** Code jumped to from a patched region that never
186
+ executes across full gameplay coverage is either dead crack scaffolding or a
187
+ trainer waiting on a trigger. Both need a verdict; neither should be reproduced
188
+ without one.
189
+ 3. **Trigger scanners.** Reads of the keyboard or joystick registers in code that is
190
+ not the game's own input handler, and comparisons against key codes inside a range
191
+ already marked `CRACKER-PATCH`.
192
+ 4. **`NOP` sleds and inverted branches.** The cheapest trainer is a patched-out check
193
+ — `EA EA EA` where a `JSR` or a decrement was, or a `BEQ`↔`BNE` flip on a collision
194
+ or life test. These show as a few bytes inside otherwise-original code: the pattern
195
+ most easily dismissed as noise, and the one that matters most.
196
+
197
+ **A negative is a result, and must state its own limits.** "No trainer found, by these
198
+ four signatures, at this coverage level" is an answer. "The diff was clean" is not.
199
+
200
+ ## Before you trust a verdict
201
+
202
+ - **Coverage is incomplete, and the ledger says so out loud.**
203
+ a load-coverage record is not a finished coverage claim until every game state
204
+ has actually been visited. An on-demand-loaded
205
+ region — bytes that only appear after reaching a room or state nobody visited —
206
+ is by construction **absent from the primary dumps this diffs**. Every verdict is
207
+ scoped to "the addresses visible at the post-loader game-entry point", not to the
208
+ whole running game. This is exactly why the ledger is regenerable: a more
209
+ complete `LOADING.md` reopens it.
210
+ - **Never resolve a range's `kind` from its `start` address.** Coalescing groups on
211
+ *verdict* continuity, not *kind* continuity, so one range can span several kind
212
+ zones. `splitRangeByManifestKind` exists for this, and the bug was found live:
213
+ a wide `ORIGINAL` range was found running straight through a `loader` sub-range
214
+ nested inside it. Resolving from `start` silently mislabels every address after
215
+ the first boundary.
216
+ - **`--gap-tolerance` is off-by-one sensitive by design.** A gap of identical bytes
217
+ *strictly shorter* than N coalesces; a run of *exactly* N stays its own row.
218
+ - **More agreeing independent releases is the only thing that raises confidence.**
219
+ Two releases can establish `ORIGINAL`; they cannot establish intent. And *agreeing*
220
+ only counts once *independent* is proven — releases sharing an ancestor agree on
221
+ the ancestor's patches too, so unproven ancestry makes every `ORIGINAL` verdict
222
+ conditional rather than earned.
223
+
224
+ ## Which skill does what
225
+
226
+ This one answers "is this byte original?". It does not capture images or read
227
+ addresses.
228
+
229
+ | Need | Go to |
230
+ |---|---|
231
+ | A verified 64K image, or proving two captures equivalent | `c64-ram-capture` |
232
+ | Which address to read next, and what the answer rules out | `c64-program-recon` |
233
+ | What a specific address or bit means | `c64-memory-mapping` — `node … lookup '$D018'` |
234
+ | Assembling, or a first-pass dead listing | `acme-build` |
235
+ | **Whether a byte is original, cracker-changed, or unknown** | here |
236
+
237
+ Findings that make RE faster go in `.planning/RE-FINDINGS.md` **at the moment you
238
+ find them**, graded with `Evidence:` and `Confidence:`. Promote by re-logging with
239
+ the new evidence, never by editing a grade in place. File-changing work enters
240
+ through a GSD command (`/gsd-quick`).
241
+
242
+ ## Troubleshooting
243
+
244
+ | Symptom | Fix |
245
+ |---|---|
246
+ | `anchor-search` reports `ok=false` | Anchors disagreed, so there is no single offset. Do **not** pick the majority — the images are not the same fully-loaded state, or one capture is bad. Re-capture rather than force it. |
247
+ | `git status` dirty after a run | Expected — two verbs write. Diff the two files; if only `proven_at`/`generated_at` moved, `git checkout --` them. |
248
+ | `generated_tier_sha256` changed | The classification changed, not just a timestamp. Find the cause before committing; the digest is the determinism check. |
249
+ | `count-patches` reports 0 | Usually correct. It counts `CRACKER-PATCH` **and** `game`-kind addresses; with two releases and no signature match, nothing qualifies. Check the `diff --json` tally before treating it as a bug. But do not read it as "no trainer" — see the next two rows. |
250
+ | Asked whether the crack added a trainer | `count-patches` is the diff-side answer: `CRACKER-PATCH` ∧ `game` **is** the trainer count. It is necessary and not sufficient — it cannot see a trainer both releases carry. Run the signature hunt too. |
251
+ | Asked to confirm a release has no trainer | You cannot confirm that from a diff alone, and saying so is the answer. A clean diff only rules out a trainer in *one* release and not the other; with unproven ancestry that is a weaker claim than it sounds. Report the signature-hunt result with its coverage limits. |
252
+ | Two releases agree everywhere suspicious | Suspect shared ancestry before concluding `ORIGINAL`. Different group names, loaders and cracktros are the cheapest things for a re-cracker to swap and prove nothing about independence. |
253
+ | Everything is `UNKNOWN` | Also usually correct. `UNKNOWN` means "differs, no recognised signature, alternatives ruled out". Read the range's `reason` field. |
254
+ | A range's `kind` looks wrong past its start | You resolved `kind` from `start`. Use `splitRangeByManifestKind`; coalescing does not respect kind boundaries. |
255
+ | A loader range disagrees with `NOTES.md` | `RELEASES.json`'s `loader_ranges` wins — it is earned from disassembly. Prose is how `$08F5` got misclassified. |
256
+ | Title-screen text shows up as cracktro | You used a bare printable-run scan. The vocabulary scan exists because `$4771-$4779` is the game's own text. |
257
+ | `unknown release "x" -- known releases: …` | `node .claude/skills/c64-provenance-diff/scripts/releases.mjs list` for the valid ids. |