@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.
- package/README.md +2 -2
- package/THIRD-PARTY-NOTICES.md +422 -1
- package/anno-bank.ts +171 -0
- package/anno-cli.ts +1736 -163
- package/anno-confidence.ts +2 -2
- package/anno-derive.ts +6 -6
- package/anno-details.ts +4 -4
- package/anno-enum-gen.ts +416 -30
- package/anno-export-asm.ts +1211 -126
- package/anno-graphics.ts +338 -0
- package/anno-hazard-report.ts +1367 -0
- package/anno-import.ts +495 -0
- package/anno-index.ts +8 -8
- package/anno-join.ts +480 -0
- package/anno-memmap-render.ts +22 -21
- package/anno-provenance-ledger.ts +472 -0
- package/anno-regbits-gen.ts +13 -13
- package/anno-register.ts +159 -0
- package/anno-store-export.ts +661 -0
- package/anno-store.ts +635 -124
- package/anno-symbols.ts +7 -7
- package/anno-tools.ts +1169 -16
- package/anno-types.ts +313 -40
- package/backend-detect.mts +124 -312
- package/build.ts +3 -1
- package/capture-predicate.ts +597 -0
- package/channel-lock.ts +349 -0
- package/evid-ingest.ts +217 -0
- package/evid-reconcile.ts +316 -0
- package/host-tool-client.ts +430 -0
- package/incident-record.ts +23 -12
- package/install-resources.ts +29 -13
- package/memmap-lookup.ts +285 -0
- package/package.json +27 -8
- package/prg-image.ts +1 -2
- package/repo-root.ts +87 -3
- package/resources/backend-detect.mjs +98 -236
- package/resources/broker-control.mjs +220 -54
- package/resources/broker-epoch.mjs +7 -8
- package/resources/broker-kill.mjs +36 -31
- package/resources/broker-launch.mjs +511 -374
- package/resources/broker-state.mjs +69 -24
- package/resources/container-guard.mjs +1 -1
- package/resources/ghidra-project.mjs +790 -0
- package/resources/host-tool.mjs +2533 -0
- package/resources/vice-broker.mjs +434 -290
- package/resources/vice-launcher.sh +127 -9
- package/stock-address.ts +1 -1
- package/stock-condition.ts +1 -1
- package/stock-connect.ts +9 -5
- package/stock-derived.ts +29 -37
- package/stock-diagnose.ts +200 -36
- package/stock-dispatch.ts +179 -77
- package/stock-handler.ts +1 -1
- package/stock-paths.ts +18 -14
- package/stock-petscii.ts +1 -1
- package/stock-protocol.ts +1 -1
- package/stock-recycle.ts +83 -2
- package/stock-reproducible-run.ts +811 -0
- package/stock-run-until.ts +100 -1
- package/stock-symbols.ts +4 -4
- package/stock-timing.ts +1 -1
- package/stop-oracle.ts +167 -0
- package/text-capability-probe.ts +660 -0
- package/text-connect.ts +157 -0
- package/text-protocol.ts +810 -0
- package/text-tools.ts +778 -0
- package/textmon-backtrace.ts +385 -0
- package/textmon-cpuhistory.ts +335 -0
- package/textmon-memmap.ts +494 -0
- package/textmon-profile.ts +458 -0
- package/textmon-registers.ts +748 -0
- package/tools-manifest.stock.json +864 -3
- package/vice-broker-client.ts +253 -108
- package/vice-errors.ts +268 -0
- package/vice-proxy.ts +339 -2144
- package/vsf-slice.ts +640 -0
- package/anno-d64.ts +0 -310
- package/capability-registry.ts +0 -390
- package/refresh-manifest.ts +0 -124
- package/tools-manifest.json +0 -1223
- package/vice-probe.ts +0 -278
- package/vice-sync.ts +0 -336
- 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
|
+
}
|