@henols/vice-mcp 0.2.2 → 0.2.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 (84) hide show
  1. package/README.md +2 -2
  2. package/THIRD-PARTY-NOTICES.md +422 -1
  3. package/anno-bank.ts +171 -0
  4. package/anno-cli.ts +1736 -163
  5. package/anno-confidence.ts +2 -2
  6. package/anno-derive.ts +6 -6
  7. package/anno-details.ts +4 -4
  8. package/anno-enum-gen.ts +416 -30
  9. package/anno-export-asm.ts +1211 -126
  10. package/anno-graphics.ts +338 -0
  11. package/anno-hazard-report.ts +1367 -0
  12. package/anno-import.ts +495 -0
  13. package/anno-index.ts +8 -8
  14. package/anno-join.ts +480 -0
  15. package/anno-memmap-render.ts +22 -21
  16. package/anno-provenance-ledger.ts +472 -0
  17. package/anno-regbits-gen.ts +13 -13
  18. package/anno-register.ts +159 -0
  19. package/anno-store-export.ts +661 -0
  20. package/anno-store.ts +635 -124
  21. package/anno-symbols.ts +7 -7
  22. package/anno-tools.ts +1169 -16
  23. package/anno-types.ts +313 -40
  24. package/backend-detect.mts +124 -312
  25. package/build.ts +3 -1
  26. package/capture-predicate.ts +597 -0
  27. package/channel-lock.ts +349 -0
  28. package/evid-ingest.ts +217 -0
  29. package/evid-reconcile.ts +316 -0
  30. package/host-tool-client.ts +430 -0
  31. package/incident-record.ts +23 -12
  32. package/install-resources.ts +29 -13
  33. package/memmap-lookup.ts +285 -0
  34. package/package.json +27 -8
  35. package/prg-image.ts +1 -2
  36. package/repo-root.ts +87 -3
  37. package/resources/backend-detect.mjs +98 -236
  38. package/resources/broker-control.mjs +220 -54
  39. package/resources/broker-epoch.mjs +7 -8
  40. package/resources/broker-kill.mjs +36 -31
  41. package/resources/broker-launch.mjs +511 -374
  42. package/resources/broker-state.mjs +69 -24
  43. package/resources/container-guard.mjs +1 -1
  44. package/resources/ghidra-project.mjs +790 -0
  45. package/resources/host-tool.mjs +2533 -0
  46. package/resources/vice-broker.mjs +434 -290
  47. package/resources/vice-launcher.sh +127 -9
  48. package/stock-address.ts +1 -1
  49. package/stock-condition.ts +1 -1
  50. package/stock-connect.ts +9 -5
  51. package/stock-derived.ts +29 -37
  52. package/stock-diagnose.ts +200 -36
  53. package/stock-dispatch.ts +179 -77
  54. package/stock-handler.ts +1 -1
  55. package/stock-paths.ts +18 -14
  56. package/stock-petscii.ts +1 -1
  57. package/stock-protocol.ts +1 -1
  58. package/stock-recycle.ts +83 -2
  59. package/stock-reproducible-run.ts +811 -0
  60. package/stock-run-until.ts +100 -1
  61. package/stock-symbols.ts +4 -4
  62. package/stock-timing.ts +1 -1
  63. package/stop-oracle.ts +167 -0
  64. package/text-capability-probe.ts +660 -0
  65. package/text-connect.ts +157 -0
  66. package/text-protocol.ts +810 -0
  67. package/text-tools.ts +778 -0
  68. package/textmon-backtrace.ts +385 -0
  69. package/textmon-cpuhistory.ts +335 -0
  70. package/textmon-memmap.ts +494 -0
  71. package/textmon-profile.ts +458 -0
  72. package/textmon-registers.ts +748 -0
  73. package/tools-manifest.stock.json +864 -3
  74. package/vice-broker-client.ts +253 -108
  75. package/vice-errors.ts +268 -0
  76. package/vice-proxy.ts +339 -2144
  77. package/vsf-slice.ts +640 -0
  78. package/anno-d64.ts +0 -310
  79. package/capability-registry.ts +0 -390
  80. package/refresh-manifest.ts +0 -124
  81. package/tools-manifest.json +0 -1223
  82. package/vice-probe.ts +0 -278
  83. package/vice-sync.ts +0 -336
  84. package/vice.ts +0 -772
@@ -0,0 +1,597 @@
1
+ // capture-predicate.ts -- the ONE authoritative place in this repo holding the
2
+ // RUN-EQUIVALENCE predicate for two flat 64K captures: the enumerated
3
+ // transient allow-list and its committed size cap, the `$0000`/`$0001`
4
+ // 6510-port normalisation, the byte-by-byte comparison that decides
5
+ // equivalence, and the argv identity digest a reproducible run is keyed by.
6
+ //
7
+ // CONSUMER STATUS: EXERCISED BY EVIDENCE, NOT YET WIRED INTO A SHIPPED TOOL.
8
+ // Corrected 2026-09-03 after phase 33's verification caught the previous
9
+ // version of this paragraph asserting, wrongly, that nothing outside the tests
10
+ // imports this module and that `normalisePorts()` had never run against a real
11
+ // capture. Both claims were false when written. What is actually true:
12
+ //
13
+ // * FOUR committed phase-33 evidence probes import this module by path --
14
+ // `evidence/capture-pair.mjs:120`, `reset-removed-probe.mjs:103`,
15
+ // `frame-anchor-probe.mjs:86` and `determinism-probe.mjs:78`.
16
+ // * `normalisePorts()` HAS run against real captures -- three of them, at
17
+ // `evidence/capture-pair.mjs:680`.
18
+ // * `compareCaptures()` is called at `evidence/capture-pair.mjs:776`, and
19
+ // THAT CALL IS the `C0_CAPTURE_PAIR: pass` gate input. This module produced
20
+ // the milestone's headline equivalence result; it is not unvalidated code.
21
+ //
22
+ // What remains true, and is the only sense in which this is "not yet wired":
23
+ // no SHIPPED MCP tool and no production caller reaches it -- the route today
24
+ // is an evidence script importing it directly. So read "the ONE authoritative
25
+ // place" above as A DESIGN CONSTRAINT ON FUTURE CALLERS -- when the predicate
26
+ // is called, it is called here -- rather than as a claim about the production
27
+ // surface.
28
+ //
29
+ // This is deliberate and is not a defect to be closed by inventing a caller:
30
+ // phase 33 built the capture substrate ahead of phases 34-38 consuming it. The
31
+ // status is recorded HERE, in the header a later reader will actually reach,
32
+ // because "authoritative", "in use" and "validated" are easy to conflate and
33
+ // the difference decides whether an edit here is safe. When a production
34
+ // consumer lands, this paragraph is what should be updated or removed.
35
+ //
36
+ // This module performs NO filesystem and NO network I/O: every function takes
37
+ // bytes, an already-parsed JSON value, or a string array, and returns values.
38
+ // Callers obtain and persist the bytes themselves. That is the same claim
39
+ // `prg-image.ts` and `vsf-slice.ts`'s library region make about themselves, and
40
+ // `capture-predicate.test.ts` asserts it from this module's own source rather
41
+ // than trusting this paragraph.
42
+ //
43
+ // WHY THIS FILE EXISTS, AND WHAT IT IS NOT: the existing
44
+ // `src/skills/c64-ram-capture/scripts/compare.mjs` is a VOCABULARY ANALOG
45
+ // ONLY. This module is a REPLACEMENT IN KIND for its rules and never an
46
+ // extension of them. Its report vocabulary is kept deliberately -- the
47
+ // `divergence` class, the `pass` verdict field, the `addr`/`a`/`b`/`bits` row
48
+ // shape and the `hex4`/`hex2`/`bin8`/`popcount` helpers -- because a reader
49
+ // comparing two transcripts should not have to learn a second vocabulary for
50
+ // the same facts. Those helpers are COPIED IN KIND rather than imported: that
51
+ // file lives in a different package with a different runtime, and no dependency
52
+ // edge exists or should exist between them.
53
+ //
54
+ // Its two RULES are dropped outright:
55
+ //
56
+ // * `compare.mjs` excludes four address RANGES covering 4866 addresses
57
+ // (`$0000-$0001`, `$0100-$01FF`, `$0200-$03FF`, `$D000-$DFFF`). `CAP-02`
58
+ // requires an ENUMERATED list of addresses, never a range. No range is a
59
+ // volatile span here, at any address, under any name.
60
+ // * `compare.mjs` classifies a difference of exactly one bit as "drift" and
61
+ // lets it PASS anywhere. There is NO bit-count tolerance here, at any
62
+ // address, in any form.
63
+ //
64
+ // THE CONSEQUENCE OF GETTING EITHER WRONG, STATED HERE SO IT CANNOT BE
65
+ // REDISCOVERED THE EXPENSIVE WAY: `D-25`'s fail-ability control plants exactly
66
+ // ONE BIT at an address outside the allow-list and requires the comparison to
67
+ // FAIL. A predicate carrying either inherited rule PASSES that plant -- so the
68
+ // control goes green, the gate reads "the predicate is proven able to fail",
69
+ // and nothing whatever has been proven. A vacuous control is worse than no
70
+ // control, because it is believed.
71
+ //
72
+ // `$D000-$DFFF` IS NOT VOLATILE ON THIS ROUTE. `compare.mjs`'s 4096-address
73
+ // exclusion is a property of the memory-READ route, where reading that range
74
+ // samples live I/O registers and two reads can never agree. The `.vsf`
75
+ // `C64MEM` array this predicate compares is `mem_ram[]` -- RAM *under* I/O,
76
+ // not the register read view -- so the exclusion does not apply on the snapshot
77
+ // route and must not be carried over. Carrying it over would hide 4096
78
+ // addresses of real divergence for a reason that is true somewhere else.
79
+ //
80
+ // WHAT NOT TO DO:
81
+ // - Never make an address RANGE, span, region or page a volatile set here,
82
+ // and never accept range-shaped notation in an allow-list artifact.
83
+ // `parseAllowList()` refuses it BY NAME, and that refusal is a deliverable
84
+ // rather than input hygiene.
85
+ // - Never add a bit-count tolerance, a "drift" class, a fuzz factor or a
86
+ // percentage at any address. A one-bit difference outside the allow-list
87
+ // FAILS. See the vacuity paragraph above for what it costs.
88
+ // - Never raise `TRANSIENT_ALLOW_LIST_CAP` because a derivation overflowed
89
+ // it. An overflow VOIDS the derivation (`D-22`): it means the stop is not
90
+ // frame-exact, which is a fact the gate must hear rather than a threshold
91
+ // to move. Raising the cap after seeing the number converts a measurement
92
+ // into an excuse.
93
+ // - Never spend two allow-list slots on `$0000`/`$0001`. They are
94
+ // normalised, in code, by `normalisePorts()`, and this is the ONE
95
+ // normalisation site (`D-24`). Do not re-derive it in a caller, and do not
96
+ // fold it into the slicer -- the slicer returns the port bytes precisely so
97
+ // this module can apply them exactly once.
98
+ // - Never give any function here a filesystem PATH parameter, and never
99
+ // import either of this repo's host/container path-translation seams. Both
100
+ // absences are asserted structurally by `capture-predicate.test.ts`, not
101
+ // merely stated here.
102
+ // - Never import `stop-oracle.ts` from here, on any route, static or
103
+ // dynamic. The captured 64K is the DEPENDENT VARIABLE the stop-identity
104
+ // oracle certifies; a predicate that could reach the oracle -- or an oracle
105
+ // that could reach the predicate -- would let a capture participate in
106
+ // certifying its own stop. `capture-seam.test.ts` bars it by SHAPE in both
107
+ // directions, which is `CAP-03`.
108
+ import { createHash } from "node:crypto";
109
+
110
+ /** A flat capture is exactly this long, always. Named rather than inlined
111
+ * because every refusal below quotes it back to the caller. */
112
+ export const IMAGE_BYTES = 65536;
113
+
114
+ /** The committed maximum number of addresses a transient allow-list may
115
+ * enumerate (`D-22`).
116
+ *
117
+ * THE REASONING, RECORDED SO A LATER READER CAN WEIGH IT RATHER THAN TRUST IT.
118
+ * The only measurement in hand when this number was committed is **3 transients
119
+ * out of 1024 addresses** at the KERNAL `READY` prompt -- and that figure is
120
+ * itself an UPPER BOUND, taken under frame-divergent conditions, so the
121
+ * frame-exact number is at or below it. A real cracked release adds its own
122
+ * frame counters, RNG state, sprite positions and music-player pointers: tens
123
+ * of addresses, not hundreds. 64 therefore sits an order of magnitude above the
124
+ * only measured value, and at a quarter of the 256-address page the requirement
125
+ * itself names as over-wide.
126
+ *
127
+ * WHAT EXCEEDING IT MEANS. Not "the list is a bit long". Exceeding the cap
128
+ * **VOIDS the derivation**: a run producing more than 64 differing addresses is
129
+ * telling you the stop is not frame-exact, and that is a fact the gate must
130
+ * hear. It is not a threshold to raise, and `parseAllowList()` THROWS rather
131
+ * than warning-and-continuing for exactly that reason -- a warning is a fact
132
+ * that can be scrolled past. */
133
+ export const TRANSIENT_ALLOW_LIST_CAP = 64;
134
+
135
+ /** Every refusal this module raises. Carries the offending value so a caller's
136
+ * failure output can quote it rather than paraphrase it. */
137
+ export class CaptureComparisonError extends Error {
138
+ readonly value: unknown;
139
+ constructor(message: string, value?: unknown) {
140
+ super(message);
141
+ this.name = "CaptureComparisonError";
142
+ this.value = value;
143
+ }
144
+ }
145
+
146
+ /** Four-digit hex, for an address. One of `compare.mjs`'s four formatting
147
+ * helpers, copied in kind (see this file's header for why copied, not
148
+ * imported). */
149
+ export const hex4 = (n: number): string => "$" + n.toString(16).toUpperCase().padStart(4, "0");
150
+ /** Two-digit hex, for a byte value. */
151
+ export const hex2 = (n: number): string => "$" + n.toString(16).toUpperCase().padStart(2, "0");
152
+ /** Eight-digit binary, for a byte value, in the assembler's `%` notation. */
153
+ export const bin8 = (n: number): string => "%" + n.toString(2).padStart(8, "0");
154
+
155
+ /** How many bits are set. Reported per differing address for the reader's
156
+ * benefit ONLY -- no verdict anywhere in this module reads it, which is exactly
157
+ * the difference between this predicate and the one it replaces. */
158
+ export function popcount(n: number): number {
159
+ let c = 0;
160
+ let v = n;
161
+ while (v) {
162
+ v &= v - 1;
163
+ c++;
164
+ }
165
+ return c;
166
+ }
167
+
168
+ /** One enumerated transient: an address, the run pairs it was observed to
169
+ * differ in, and an optional one-line attribution where the cause is known
170
+ * (`D-23`). */
171
+ export interface TransientEntry {
172
+ /** An integer, `0 <= address <= 65535`. Never a range, never a span. */
173
+ address: number;
174
+ /** Which pairwise run comparisons this address differed in, e.g.
175
+ * `["run1-run2", "run1-run3"]`. Free-form labels: the derivation script
176
+ * names the runs, and this module only requires that they are strings. */
177
+ pairs: string[];
178
+ /** One line, where the cause is known. Absent is legal and common. */
179
+ attribution?: string;
180
+ }
181
+
182
+ /** A parsed, validated per-release allow-list artifact. `addresses` is derived
183
+ * once, here, ascending -- so no caller re-derives it, and no caller can
184
+ * accidentally derive it differently. */
185
+ export interface TransientAllowList {
186
+ /** The release this list was derived from. Lists are never inherited between
187
+ * releases (`D-23`), so the identifier travels with the artifact. */
188
+ release: string;
189
+ entries: TransientEntry[];
190
+ /** Every `entries[].address`, ascending, de-duplicated by construction
191
+ * because duplicates are refused. */
192
+ addresses: number[];
193
+ }
194
+
195
+ /** One differing address, in `compare.mjs`'s row vocabulary. */
196
+ export interface CaptureDifference {
197
+ addr: number;
198
+ a: number;
199
+ b: number;
200
+ /** Reported, never read by a verdict. */
201
+ bits: number;
202
+ }
203
+
204
+ /** The verdict, plus everything a transcript needs to show its work. */
205
+ export interface CaptureComparison {
206
+ verdict: "equivalent" | "not-equivalent";
207
+ /** Divergent addresses -- differing and NOT allow-listed. Ascending.
208
+ * Non-empty iff the verdict is `not-equivalent`. */
209
+ differing: number[];
210
+ /** Differing addresses that ARE allow-listed, and are therefore excluded
211
+ * from the verdict. Ascending. */
212
+ allowed: number[];
213
+ /** The committed cap, echoed so a transcript records which cap the verdict
214
+ * was taken under rather than leaving a reader to look it up. */
215
+ cap: number;
216
+ allowListSize: number;
217
+ /** `compare.mjs`'s vocabulary: the divergent rows, in full. */
218
+ divergence: CaptureDifference[];
219
+ /** The allow-listed rows, in full. */
220
+ allowedDifferences: CaptureDifference[];
221
+ /** `compare.mjs`'s vocabulary: `true` iff `verdict === "equivalent"`. */
222
+ pass: boolean;
223
+ }
224
+
225
+ /** The two CPU-visible 6510 port read-back values, under exactly the field
226
+ * names `sliceC64Mem()`'s `C64MemSlice` record returns them under. Taking the
227
+ * slice record's own names means a caller passes the slice straight through
228
+ * rather than re-reading the snapshot layout, which is what keeps that layout
229
+ * knowledge in one file. */
230
+ export interface PortReads {
231
+ /** `pport.dir_read` -- the CPU-visible value of `$0000`. */
232
+ dirRead: number;
233
+ /** `pport.data_read` -- the CPU-visible value of `$0001`. */
234
+ dataRead: number;
235
+ }
236
+
237
+ /** True for a value usable as a 16-bit address. */
238
+ function isAddress(v: unknown): v is number {
239
+ return typeof v === "number" && Number.isInteger(v) && v >= 0 && v <= 0xffff;
240
+ }
241
+
242
+ /** True for a value usable as a byte. */
243
+ function isByte(v: unknown): v is number {
244
+ return typeof v === "number" && Number.isInteger(v) && v >= 0 && v <= 0xff;
245
+ }
246
+
247
+ /** The range-shaped keys an allow-list entry must never carry. Refused BY NAME
248
+ * rather than ignored: an entry written as a span is not a typo, it is an
249
+ * author reaching for the rule `CAP-02` exists to remove, and silently
250
+ * dropping the extra keys would accept the entry while discarding what the
251
+ * author actually meant by it. */
252
+ const RANGE_SHAPED_KEYS = ["start", "end", "from", "to", "range", "span", "lo", "hi", "first", "last"];
253
+
254
+ /** Parse and validate a committed per-release allow-list artifact.
255
+ *
256
+ * Takes an ALREADY-PARSED JSON value -- never a path, and never a JSON string:
257
+ * reading the file is the caller's job (this module does no I/O), and a
258
+ * function accepting a string would have to guess whether it had been handed
259
+ * JSON text or a filename.
260
+ *
261
+ * Every refusal below closes a WIDENING route, because an allow-list is the one
262
+ * input to this module that can turn a failing comparison into a passing one
263
+ * (threat `T-33-26`). None of them is optional, and none of them warns. */
264
+ export function parseAllowList(json: unknown): TransientAllowList {
265
+ if (typeof json !== "object" || json === null || Array.isArray(json)) {
266
+ throw new CaptureComparisonError(
267
+ "parseAllowList: an allow-list artifact must be a JSON object carrying a release identifier and an entries array",
268
+ json,
269
+ );
270
+ }
271
+ const raw = json as Record<string, unknown>;
272
+
273
+ if (typeof raw.release !== "string" || raw.release.trim() === "") {
274
+ throw new CaptureComparisonError(
275
+ "parseAllowList: the artifact must name the release it was derived from -- an allow-list is re-derived per release and never inherited, so the identifier travels with it",
276
+ raw.release,
277
+ );
278
+ }
279
+ if (!Array.isArray(raw.entries)) {
280
+ throw new CaptureComparisonError(
281
+ "parseAllowList: the artifact must carry an entries array -- an enumerated list of addresses",
282
+ raw.entries,
283
+ );
284
+ }
285
+
286
+ // THE CAP IS CHECKED FIRST, AND IT THROWS. Exceeding it voids the whole
287
+ // derivation, so there is nothing to be gained by validating the individual
288
+ // entries of a list that cannot be used: the count IS the finding.
289
+ if (raw.entries.length > TRANSIENT_ALLOW_LIST_CAP) {
290
+ throw new CaptureComparisonError(
291
+ `parseAllowList: the allow-list enumerates ${raw.entries.length} addresses, over the committed cap of ` +
292
+ `${TRANSIENT_ALLOW_LIST_CAP} -- exceeding the cap VOIDS the derivation (it means the stop is not ` +
293
+ `frame-exact) and is never repaired by raising the cap`,
294
+ raw.entries.length,
295
+ );
296
+ }
297
+
298
+ const entries: TransientEntry[] = [];
299
+ const seen = new Set<number>();
300
+
301
+ for (let i = 0; i < raw.entries.length; i++) {
302
+ const item: unknown = raw.entries[i];
303
+ if (typeof item !== "object" || item === null || Array.isArray(item)) {
304
+ throw new CaptureComparisonError(
305
+ `parseAllowList: entry ${i} must be an object carrying an address and the run pairs it differed in`,
306
+ item,
307
+ );
308
+ }
309
+ const entry = item as Record<string, unknown>;
310
+
311
+ for (const key of RANGE_SHAPED_KEYS) {
312
+ if (key in entry) {
313
+ throw new CaptureComparisonError(
314
+ `parseAllowList: entry ${i} carries the range-shaped key "${key}" -- the transient allow-list is ` +
315
+ `ENUMERATED and is never a range, so write each address as its own entry`,
316
+ key,
317
+ );
318
+ }
319
+ }
320
+ if (Array.isArray(entry.address)) {
321
+ throw new CaptureComparisonError(
322
+ `parseAllowList: entry ${i} writes its address as a two-element array, which is a range-shaped span ` +
323
+ `-- the transient allow-list is ENUMERATED and is never a range, so write each address as its own entry`,
324
+ entry.address,
325
+ );
326
+ }
327
+ if (!isAddress(entry.address)) {
328
+ throw new CaptureComparisonError(
329
+ `parseAllowList: entry ${i} has address ${JSON.stringify(entry.address)}, which is not an integer in 0..65535`,
330
+ entry.address,
331
+ );
332
+ }
333
+ if (seen.has(entry.address)) {
334
+ throw new CaptureComparisonError(
335
+ `parseAllowList: entry ${i} repeats address ${hex4(entry.address)}, which an earlier entry already ` +
336
+ `enumerates -- a duplicate silently consumes a second slot of the cap`,
337
+ entry.address,
338
+ );
339
+ }
340
+ if (!Array.isArray(entry.pairs) || entry.pairs.some((p) => typeof p !== "string")) {
341
+ throw new CaptureComparisonError(
342
+ `parseAllowList: entry ${i} (address ${hex4(entry.address)}) must record which run pairs it differed ` +
343
+ `in, as an array of strings`,
344
+ entry.pairs,
345
+ );
346
+ }
347
+ if (entry.attribution !== undefined && typeof entry.attribution !== "string") {
348
+ throw new CaptureComparisonError(
349
+ `parseAllowList: entry ${i} (address ${hex4(entry.address)}) has a non-string attribution`,
350
+ entry.attribution,
351
+ );
352
+ }
353
+
354
+ seen.add(entry.address);
355
+ const parsed: TransientEntry = {
356
+ address: entry.address,
357
+ pairs: entry.pairs as string[],
358
+ };
359
+ if (entry.attribution !== undefined) parsed.attribution = entry.attribution;
360
+ entries.push(parsed);
361
+ }
362
+
363
+ return {
364
+ release: raw.release,
365
+ entries,
366
+ addresses: [...seen].sort((x, y) => x - y),
367
+ };
368
+ }
369
+
370
+ /** Substitute the CPU-visible 6510 port read-back values over RAM `$0000` and
371
+ * `$0001`, returning a COPY. This is the ONE normalisation site (`D-24`).
372
+ *
373
+ * NOTE THE ADDRESS ORDER, WHICH IS THE HALF THAT GETS WRITTEN BACKWARDS:
374
+ * `$0000` is the DIRECTION register and takes `dirRead`; `$0001` is DATA and
375
+ * takes `dataRead`. And note WHERE the values come from: the 3-byte SUFFIX
376
+ * after the RAM array, never the 4-byte prefix before it. Measured on one
377
+ * snapshot with three readings agreeing -- `prefix=[231,47,0,0]` against
378
+ * `suffix3=[39,55,47]`, with the live registers on that same snapshot reading
379
+ * `$00=47 $01=55` -- so a prefix-over-RAM copy writes 231 where the CPU sees
380
+ * 47: wrong by 176, silently, at exactly the two addresses the normalisation
381
+ * exists to fix.
382
+ *
383
+ * WHY THESE TWO BYTES NEED NORMALISING AT ALL, RATHER THAN ALLOW-LISTING:
384
+ * every store to `$00`/`$01` overwrites `mem_ram[0]`/`mem_ram[1]` with the
385
+ * VIC's phi1 bus value at that instant. Those two RAM bytes are therefore a
386
+ * RASTER-POSITION ARTEFACT rather than program state -- which is both why they
387
+ * are the one legitimate divergence pair, and why spending two of the cap's 64
388
+ * slots on them would hide a real difference behind a known one. */
389
+ export function normalisePorts(image: Uint8Array, ports: PortReads): Uint8Array {
390
+ if (!(image instanceof Uint8Array)) {
391
+ throw new CaptureComparisonError("normalisePorts: expected a Uint8Array of capture bytes", image);
392
+ }
393
+ if (image.length !== IMAGE_BYTES) {
394
+ throw new CaptureComparisonError(
395
+ `normalisePorts: the image is ${image.length} byte(s), expected exactly ${IMAGE_BYTES} -- refusing to ` +
396
+ `normalise something that is not a full 64K capture`,
397
+ image.length,
398
+ );
399
+ }
400
+ if (ports === null || typeof ports !== "object" || !isByte(ports.dirRead) || !isByte(ports.dataRead)) {
401
+ throw new CaptureComparisonError(
402
+ "normalisePorts: dirRead and dataRead must both be byte values in 0..255",
403
+ ports,
404
+ );
405
+ }
406
+
407
+ // An explicit `new Uint8Array` + `set`, and NEVER `image.slice(...)`.
408
+ // `readFileSync` returns a `Buffer`, whose `slice` overrides the TypedArray
409
+ // method as an alias for `subarray` -- so `image.slice()` returns a VIEW into
410
+ // the caller's buffer for exactly the input type every real caller passes,
411
+ // and the two writes below would land in the caller's snapshot bytes.
412
+ // Measured in this repo already: `vsf-slice.ts`'s own copy-independence test
413
+ // failed on its first implementation for precisely this reason.
414
+ const out = new Uint8Array(IMAGE_BYTES);
415
+ out.set(image);
416
+ out[0x0000] = ports.dirRead;
417
+ out[0x0001] = ports.dataRead;
418
+ return out;
419
+ }
420
+
421
+ /** Normalise either accepted allow-list shape to an address set, in one place.
422
+ *
423
+ * A parsed `TransientAllowList` is the real artifact shape. A bare array of
424
+ * addresses is accepted for the degenerate cases -- an empty list, or a single
425
+ * address in a control -- so a caller with no artifact does not have to
426
+ * fabricate a release identifier in order to ask a question. Both shapes go
427
+ * through the same cap check and the same address validation, so the array form
428
+ * is not a widening route. */
429
+ function allowSet(allowList: TransientAllowList | readonly number[]): { set: Set<number>; size: number } {
430
+ const addresses: unknown = Array.isArray(allowList)
431
+ ? allowList
432
+ : (allowList as TransientAllowList | null)?.addresses;
433
+ if (!Array.isArray(addresses)) {
434
+ throw new CaptureComparisonError(
435
+ "compareCaptures: the allow-list must be a parsed TransientAllowList or an array of addresses",
436
+ allowList,
437
+ );
438
+ }
439
+ if (addresses.length > TRANSIENT_ALLOW_LIST_CAP) {
440
+ throw new CaptureComparisonError(
441
+ `compareCaptures: the allow-list enumerates ${addresses.length} addresses, over the committed cap of ` +
442
+ `${TRANSIENT_ALLOW_LIST_CAP} -- exceeding the cap VOIDS the derivation and is never repaired by ` +
443
+ `raising the cap`,
444
+ addresses.length,
445
+ );
446
+ }
447
+ const set = new Set<number>();
448
+ for (const addr of addresses as unknown[]) {
449
+ if (!isAddress(addr)) {
450
+ throw new CaptureComparisonError(
451
+ `compareCaptures: the allow-list contains ${JSON.stringify(addr)}, which is not an integer in 0..65535`,
452
+ addr,
453
+ );
454
+ }
455
+ set.add(addr);
456
+ }
457
+ return { set, size: set.size };
458
+ }
459
+
460
+ /** Refuse anything that is not exactly one full 64K capture, naming the length
461
+ * it actually had. A zero-length or wrong-length image is REFUSED and never
462
+ * reported as trivially equivalent -- two empty buffers agree at every address
463
+ * they have, and that is the failure mode this refusal exists to remove
464
+ * (threat `T-33-01`). */
465
+ function requireFullImage(image: Uint8Array, side: string): void {
466
+ if (!(image instanceof Uint8Array)) {
467
+ throw new CaptureComparisonError(`compareCaptures: image ${side} is not a Uint8Array of capture bytes`, image);
468
+ }
469
+ if (image.length !== IMAGE_BYTES) {
470
+ throw new CaptureComparisonError(
471
+ `compareCaptures: image ${side} is ${image.length} byte(s), expected exactly ${IMAGE_BYTES} -- refusing ` +
472
+ `rather than comparing a partial capture, which would agree everywhere it has no bytes`,
473
+ image.length,
474
+ );
475
+ }
476
+ }
477
+
478
+ /** Decide whether two flat 64K captures are EQUIVALENT under an enumerated
479
+ * transient allow-list.
480
+ *
481
+ * The rule, in full, with nothing else in it: a differing address ON the
482
+ * allow-list is counted, reported as `allowed`, and excluded from the verdict.
483
+ * ANY other differing address is a `divergence` and FAILS -- whatever its bit
484
+ * count, wherever it sits, and however close it lies to an allow-listed
485
+ * address. The allow-list is a SET OF ADDRESSES and never a neighbourhood: an
486
+ * address one either side of an allow-listed one is divergent.
487
+ *
488
+ * Symmetric in `a` and `b` by construction: the verdict and both reported
489
+ * address lists depend only on WHICH addresses differ, never on which image was
490
+ * passed first. Both lists are ascending by address. */
491
+ export function compareCaptures(
492
+ a: Uint8Array,
493
+ b: Uint8Array,
494
+ allowList: TransientAllowList | readonly number[],
495
+ ): CaptureComparison {
496
+ requireFullImage(a, "A");
497
+ requireFullImage(b, "B");
498
+ const { set, size } = allowSet(allowList);
499
+
500
+ const divergence: CaptureDifference[] = [];
501
+ const allowedDifferences: CaptureDifference[] = [];
502
+
503
+ // Ascending by construction: one forward walk over the address space, so
504
+ // neither list is ever sorted afterwards and neither can come back out of
505
+ // order after a later edit.
506
+ for (let addr = 0; addr < IMAGE_BYTES; addr++) {
507
+ const x = a[addr];
508
+ const y = b[addr];
509
+ if (x === y) continue;
510
+ const row: CaptureDifference = { addr, a: x, b: y, bits: popcount(x ^ y) };
511
+ if (set.has(addr)) allowedDifferences.push(row);
512
+ else divergence.push(row);
513
+ }
514
+
515
+ const pass = divergence.length === 0;
516
+ return {
517
+ verdict: pass ? "equivalent" : "not-equivalent",
518
+ differing: divergence.map((r) => r.addr),
519
+ allowed: allowedDifferences.map((r) => r.addr),
520
+ cap: TRANSIENT_ALLOW_LIST_CAP,
521
+ allowListSize: size,
522
+ divergence,
523
+ allowedDifferences,
524
+ pass,
525
+ };
526
+ }
527
+
528
+ /** One row, in `compare.mjs`'s exact reporting shape. */
529
+ const formatRow = (r: CaptureDifference): string =>
530
+ ` ${hex4(r.addr)} ${hex2(r.a)} ${bin8(r.a)} -> ${hex2(r.b)} ${bin8(r.b)} ${r.bits} bit${r.bits === 1 ? "" : "s"}`;
531
+
532
+ /** Render a comparison for a transcript, in `compare.mjs`'s report vocabulary.
533
+ * Returns a string rather than printing: this module writes nothing anywhere,
534
+ * and a caller appending to an evidence file needs the text, not stdout.
535
+ *
536
+ * `limit` caps the rows printed per section. `0` means UNLIMITED, which is the
537
+ * default. A NEGATIVE limit is refused rather than folded into that meaning
538
+ * (33 review IN-03): `limit > 0 ? … : rows` treated `-1` exactly like `0`, so
539
+ * a caller that computed a limit and got a negative number silently received
540
+ * up to 65 536 formatted rows in a transcript instead of the few it asked for.
541
+ * The parameter had no documented semantics at all beyond its default, which
542
+ * is why "negative means unlimited" was reachable by accident. */
543
+ export function formatComparison(comparison: CaptureComparison, limit = 0): string {
544
+ if (!Number.isInteger(limit) || limit < 0) {
545
+ throw new CaptureComparisonError(
546
+ `formatComparison: limit must be a non-negative integer (0 means unlimited), got ${JSON.stringify(limit)}`,
547
+ limit,
548
+ );
549
+ }
550
+ const lines: string[] = [];
551
+ const list = (title: string, rows: CaptureDifference[]): void => {
552
+ lines.push(`${title}: ${rows.length}`);
553
+ const shown = limit > 0 ? rows.slice(0, limit) : rows;
554
+ for (const r of shown) lines.push(formatRow(r));
555
+ if (shown.length < rows.length) lines.push(` ... ${rows.length - shown.length} more`);
556
+ };
557
+ list(
558
+ `allowed (enumerated transients, excluded from the verdict; ${comparison.allowListSize} of a cap of ${comparison.cap})`,
559
+ comparison.allowedDifferences,
560
+ );
561
+ list("DIVERGENCE -- outside the allow-list, fails the comparison at any bit count", comparison.divergence);
562
+ lines.push(`VERDICT: ${comparison.verdict}`);
563
+ return lines.join("\n");
564
+ }
565
+
566
+ /** The one byte an argv element cannot contain, and therefore the only safe
567
+ * join separator for a digest over an exact argv array. */
568
+ const ARGV_SEPARATOR = "\u0000";
569
+
570
+ /** sha256 over an exact argv array, NUL-joined, lowercase hex.
571
+ *
572
+ * The NUL join is the whole point, and a space join would be a bug: `["a b"]`
573
+ * and `["a", "b"]` are different argvs that a space join collapses onto the
574
+ * same digest, while 0x00 is the one byte that cannot appear inside a POSIX
575
+ * argument. Order-sensitive by construction, which is what makes the digest
576
+ * usable as a run identity key (`REPRO-04`).
577
+ *
578
+ * An empty array is REFUSED by name rather than digesting the empty string: the
579
+ * sha256 of "" is a real, stable and entirely meaningless value, and a run keyed
580
+ * by it would look identified. */
581
+ export function argvDigest(argv: readonly string[]): string {
582
+ if (!Array.isArray(argv)) {
583
+ throw new CaptureComparisonError("argvDigest: expected an array of argv strings", argv);
584
+ }
585
+ if (argv.length === 0) {
586
+ throw new CaptureComparisonError(
587
+ "argvDigest: refusing to digest an empty argv array -- the digest of nothing is a stable value that would look like a run identity",
588
+ argv,
589
+ );
590
+ }
591
+ for (let i = 0; i < argv.length; i++) {
592
+ if (typeof argv[i] !== "string") {
593
+ throw new CaptureComparisonError(`argvDigest: argv[${i}] is not a string`, argv[i]);
594
+ }
595
+ }
596
+ return createHash("sha256").update(argv.join(ARGV_SEPARATOR), "utf8").digest("hex");
597
+ }