@henols/c64-re-tools 0.2.1 → 0.2.3
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 +1 -1
- package/THIRD-PARTY-NOTICES.md +26 -0
- package/bin/cli.mjs +18 -7
- package/package.json +6 -4
- package/skills/acme-build/SKILL.md +83 -33
- package/skills/acme-build/scripts/acme.mjs +159 -64
- package/skills/acme-build/template.a +1 -1
- package/skills/c64-disk-access/SKILL.md +156 -0
- package/skills/c64-disk-access/scripts/c1541.mjs +569 -0
- package/skills/c64-memory-mapping/SKILL.md +419 -23
- package/skills/c64-memory-mapping/scripts/driver.mjs +1 -1
- package/skills/c64-petcat/SKILL.md +87 -0
- package/skills/c64-petcat/scripts/petcat.mjs +221 -0
- package/skills/c64-program-recon/SKILL.md +497 -92
- package/skills/c64-program-recon/references/control-flow.md +12 -15
- package/skills/c64-program-recon/references/graphics.md +1 -1
- package/skills/c64-program-recon/references/observation-hazards.md +18 -16
- package/skills/c64-program-recon/references/reconstruction.md +11 -6
- package/skills/c64-program-recon/references/sound-and-input.md +6 -8
- package/skills/c64-program-recon/references/tool-selection.md +37 -18
- package/skills/c64-program-recon/scripts/packer-finding.mjs +709 -0
- package/skills/c64-program-recon/templates/memory-map.template.md +27 -13
- package/skills/c64-provenance-diff/SKILL.md +43 -8
- package/skills/c64-provenance-diff/scripts/diff-images.mjs +9 -6
- package/skills/c64-provenance-diff/scripts/recovery-schema.mjs +20 -8
- package/skills/c64-ram-capture/RELEASES.json.example +17 -0
- package/skills/c64-ram-capture/SKILL.md +147 -46
- package/skills/c64-ram-capture/scripts/compare.mjs +2 -2
- package/skills/c64-ram-capture/scripts/derive-transients.mjs +575 -0
- package/skills/c64-ram-capture/scripts/dump-artifacts.mjs +3 -3
- package/skills/c64-ram-capture/scripts/mcp-module.mjs +174 -0
- package/skills/c64-ram-capture/scripts/project-paths.mjs +1 -1
- package/skills/c64-ram-capture/scripts/releases.mjs +1 -1
- package/skills/c64-ram-capture/scripts/vsf-slice.mjs +147 -0
- package/skills/c64-ram-capture/scripts/watch-loads.mjs +19 -13
- package/skills/c64-ram-capture/templates/capture-record.template.md +44 -4
- package/skills/c64-ram-capture/transients/README.md +136 -0
- package/skills/routine-queue-walker/SKILL.md +365 -0
- package/skills/routine-queue-walker/scripts/completeness-report.mjs +463 -0
- package/skills/vice-wedge-triage/SKILL.md +104 -97
- package/skills/c64-provenance-diff/scripts/diff-images.test.mjs +0 -665
- package/skills/c64-ram-capture/scripts/d64-parse.mjs +0 -243
- package/skills/c64-ram-capture/scripts/d64-parse.test.mjs +0 -243
- package/skills/c64-ram-capture/scripts/dump-artifacts.test.mjs +0 -133
- package/skills/c64-ram-capture/scripts/test-corpus.mjs +0 -75
- package/skills/c64-ram-capture/scripts/watch-loads.test.mjs +0 -339
|
@@ -0,0 +1,575 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Derive a per-release transient allow-list from N >= 3 captures of the same
|
|
3
|
+
// stop, and re-check a pair against an already-committed derivation.
|
|
4
|
+
//
|
|
5
|
+
// Pure logic. This module reads images the agent already captured and does
|
|
6
|
+
// arithmetic over them. It contacts nothing: the mcp__plugin_c64-re-tools_vice__* tools are the
|
|
7
|
+
// only route to the emulator, and nothing here opens a connection, reads
|
|
8
|
+
// broker state, or spawns any process at all -- not even the interpreter
|
|
9
|
+
// already running it.
|
|
10
|
+
//
|
|
11
|
+
// THE METHOD IS THE DELIVERABLE, AND THE ADDRESS SET NEVER IS. A real
|
|
12
|
+
// release's transients are its own frame counters, RNG state,
|
|
13
|
+
// sprite positions and music-player pointers. An allow-list inherited from
|
|
14
|
+
// another release cannot be distinguished afterwards from one honestly
|
|
15
|
+
// derived, which is why this script re-derives per release and refuses to
|
|
16
|
+
// overwrite an existing artifact without an explicit `--force`. What carries
|
|
17
|
+
// forward between releases is this file, never its output.
|
|
18
|
+
//
|
|
19
|
+
// THE RULES IT IMPLEMENTS, in full:
|
|
20
|
+
//
|
|
21
|
+
// N >= 3 fewer than three images is refused, naming the count and the
|
|
22
|
+
// minimum. Three runs is already this project's documented
|
|
23
|
+
// minimum for a verified capture.
|
|
24
|
+
// union the allow-list is the union of addresses differing across
|
|
25
|
+
// EVERY pairwise comparison -- N(N-1)/2 pairs for N images.
|
|
26
|
+
// per entry the address, which run pairs it differed in (by basename, so
|
|
27
|
+
// the artifact is readable), the distinct byte values seen, and
|
|
28
|
+
// a one-line attribution left empty for a human to fill.
|
|
29
|
+
// cap 64 exceeding it VOIDS the derivation: non-zero exit, no artifact,
|
|
30
|
+
// and the message states the stop is not frame-exact. It is not
|
|
31
|
+
// a threshold to raise. `--cap` only ever NARROWS -- a value
|
|
32
|
+
// above the committed cap is refused by name, so the flag cannot
|
|
33
|
+
// be used to launder an overflow into a pass.
|
|
34
|
+
//
|
|
35
|
+
// THE TWO RULES IT DOES NOT INHERIT FROM ITS SIBLING, stated explicitly
|
|
36
|
+
// because `compare.mjs` sits in this same directory and reads on the same
|
|
37
|
+
// captures:
|
|
38
|
+
//
|
|
39
|
+
// * NO ADDRESS RANGE IS EVER A VOLATILE SPAN HERE, at any address, under any
|
|
40
|
+
// name. `compare.mjs` excludes four ranges covering 4866 addresses
|
|
41
|
+
// (`$0000-$0001`, `$0100-$01FF`, `$0200-$03FF`, `$D000-$DFFF`). The rule
|
|
42
|
+
// requires an ENUMERATED list and never a range, and the artifact this
|
|
43
|
+
// script writes carries one entry per address for exactly that reason.
|
|
44
|
+
// * THERE IS NO BIT-COUNT TOLERANCE HERE, at any address, in any form.
|
|
45
|
+
// `compare.mjs` classifies a one-bit difference as "drift" and lets it
|
|
46
|
+
// pass anywhere. A one-bit difference outside the allow-list FAILS here.
|
|
47
|
+
// The cost of getting this wrong is recorded in
|
|
48
|
+
// `src/mcp/vice/capture-predicate.ts`'s header: a predicate carrying
|
|
49
|
+
// either inherited rule passes the phase's one-bit fail-ability control,
|
|
50
|
+
// so the control goes green having proven nothing.
|
|
51
|
+
//
|
|
52
|
+
// `$D000-$DFFF` IS NOT VOLATILE HERE EITHER, and that is route-specific rather
|
|
53
|
+
// than arbitrary. `compare.mjs`'s 4096-address exclusion is a property of the
|
|
54
|
+
// memory-READ route, where reading that range samples live I/O registers and
|
|
55
|
+
// two reads can never agree. A `.vsf`-sliced image is the `C64MEM` array --
|
|
56
|
+
// RAM *under* I/O, not the register read view -- so on the snapshot route the
|
|
57
|
+
// exclusion disappears and a difference there is a real difference.
|
|
58
|
+
//
|
|
59
|
+
// WHY IT CARRIES ITS OWN COMPARISON INSTEAD OF CALLING THE MCP-SIDE ONE.
|
|
60
|
+
// `src/mcp/vice/capture-predicate.ts` is the authoritative predicate, and
|
|
61
|
+
// `vsf-slice.mjs` in this directory shows the route a skill script takes to
|
|
62
|
+
// reach the MCP tree: spawn the interpreter on a CLI entry point. That route
|
|
63
|
+
// is unavailable here -- `capture-predicate.ts` is a pure library with no CLI
|
|
64
|
+
// entry point, a static cross-package import resolves on neither npm-installer
|
|
65
|
+
// route (see `vsf-slice.mjs`'s header for the measured constraint), and
|
|
66
|
+
// `scripts/check-npm-packages.mjs`'s transitive closure walk would fail the
|
|
67
|
+
// pack for one. So this is the retired skill-side/MCP-side disk-image-reader
|
|
68
|
+
// pair's own answer rather than the `vsf-slice.mjs`
|
|
69
|
+
// answer: a second independent implementation of a rule that
|
|
70
|
+
// is STABLE and TINY -- set membership over differing addresses, with no
|
|
71
|
+
// ranges, no tolerances and no version-sensitive layout anywhere in it. The
|
|
72
|
+
// agreement between the two is not left to trust: `derive-transients.test.mjs`
|
|
73
|
+
// imports `compareCaptures()` over the same resolution ladder `vsf-slice.mjs`
|
|
74
|
+
// uses and asserts that `check`'s verdict matches it on a synthetic pair,
|
|
75
|
+
// skipping with a NAMED reason if the MCP tree is absent.
|
|
76
|
+
//
|
|
77
|
+
// WHAT NOT TO DO:
|
|
78
|
+
// - Never raise the cap because a derivation overflowed it. An overflow
|
|
79
|
+
// means the stop is not frame-exact. MEASURED, for scale: 0
|
|
80
|
+
// differing addresses at a frame-exact `READY`-prompt stop, 66 at a
|
|
81
|
+
// jitter-4000 autostarted stop, 300 at a wall-clock autostarted stop on a
|
|
82
|
+
// real release, 1242 at a wall-clock `READY` stop with the determinism
|
|
83
|
+
// block applied. Over-cap is a real, observed outcome and not a
|
|
84
|
+
// hypothetical, and the answer to it is a better stop.
|
|
85
|
+
// - Never truncate the union to fit the cap. A truncated allow-list makes
|
|
86
|
+
// every later comparison pass on bytes nobody vetted, which is the silent
|
|
87
|
+
// widening the void exists to prevent. The artifact is written once, in
|
|
88
|
+
// full, only after the cap check clears.
|
|
89
|
+
// - Never add `$0000`/`$0001` to a derived allow-list by hand. They are
|
|
90
|
+
// normalised in code by `normalisePorts()` on the snapshot route,
|
|
91
|
+
// and spending two of the cap's 64 slots on them would hide a real
|
|
92
|
+
// difference behind a known one. They can legitimately appear in a
|
|
93
|
+
// derivation taken from un-normalised images -- see `check`'s note below.
|
|
94
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
95
|
+
import { createHash } from "node:crypto";
|
|
96
|
+
import { basename } from "node:path";
|
|
97
|
+
|
|
98
|
+
const IMAGE_BYTES = 65536;
|
|
99
|
+
|
|
100
|
+
/** The committed maximum number of addresses an allow-list may enumerate
|
|
101
|
+
* This literal MUST equal `TRANSIENT_ALLOW_LIST_CAP` in
|
|
102
|
+
* `src/mcp/vice/capture-predicate.ts`; the colocated test asserts that
|
|
103
|
+
* equality against the IMPORTED constant rather than against a second copy of
|
|
104
|
+
* the number, so the two cannot drift apart silently. */
|
|
105
|
+
const TRANSIENT_ALLOW_LIST_CAP = 64;
|
|
106
|
+
|
|
107
|
+
/** The committed minimum number of runs a derivation takes. */
|
|
108
|
+
const MIN_RUNS = 3;
|
|
109
|
+
|
|
110
|
+
/** The artifact format version. Bump it when the entry shape changes, never
|
|
111
|
+
* when an address set changes -- address sets are per-release data, not
|
|
112
|
+
* schema. */
|
|
113
|
+
const SCHEMA_VERSION = "1.0";
|
|
114
|
+
|
|
115
|
+
const hex4 = (n) => "$" + n.toString(16).toUpperCase().padStart(4, "0");
|
|
116
|
+
const hex2 = (n) => "$" + n.toString(16).toUpperCase().padStart(2, "0");
|
|
117
|
+
|
|
118
|
+
const sha256 = (buf) => createHash("sha256").update(buf).digest("hex");
|
|
119
|
+
|
|
120
|
+
/** The range-shaped keys an entry must never carry, byte-identical to
|
|
121
|
+
* `parseAllowList()`'s own list. Refused BY NAME on the way in AND on the way
|
|
122
|
+
* out: an entry written as a span is an author reaching for the very rule
|
|
123
|
+
* this list exists to enforce. */
|
|
124
|
+
const RANGE_SHAPED_KEYS = [
|
|
125
|
+
"start",
|
|
126
|
+
"end",
|
|
127
|
+
"from",
|
|
128
|
+
"to",
|
|
129
|
+
"range",
|
|
130
|
+
"span",
|
|
131
|
+
"lo",
|
|
132
|
+
"hi",
|
|
133
|
+
"first",
|
|
134
|
+
"last",
|
|
135
|
+
];
|
|
136
|
+
|
|
137
|
+
// ---------------------------------------------------------------- argv
|
|
138
|
+
|
|
139
|
+
/** Which flags each verb accepts, and whether each takes a value.
|
|
140
|
+
*
|
|
141
|
+
* A REAL PARSER, DELIBERATELY, and not `compare.mjs`'s
|
|
142
|
+
* `argv.filter(s => !s.startsWith("--"))` idiom. That filter treats a flag's
|
|
143
|
+
* VALUE as a positional, so `--out probe.json a.bin b.bin c.bin` would derive
|
|
144
|
+
* from four images, one of which is a JSON path. With value-taking flags in
|
|
145
|
+
* the signature that idiom is a defect rather than a shortcut. */
|
|
146
|
+
const FLAG_SPEC = {
|
|
147
|
+
derive: { "--release": "value", "--out": "value", "--cap": "value", "--force": "boolean" },
|
|
148
|
+
check: { "--allow-list": "value", "--limit": "value" },
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
function parseArgv(verb, argv) {
|
|
152
|
+
const spec = FLAG_SPEC[verb];
|
|
153
|
+
const flags = Object.create(null);
|
|
154
|
+
const positionals = [];
|
|
155
|
+
|
|
156
|
+
for (let i = 0; i < argv.length; i++) {
|
|
157
|
+
const tok = argv[i];
|
|
158
|
+
if (!tok.startsWith("--")) {
|
|
159
|
+
positionals.push(tok);
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
const kind = spec[tok];
|
|
163
|
+
if (!kind) {
|
|
164
|
+
throw new Error(
|
|
165
|
+
`${verb}: unknown flag ${tok} -- accepted: ${Object.keys(spec).join(", ")}`,
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
if (kind === "boolean") {
|
|
169
|
+
flags[tok] = true;
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
const value = argv[i + 1];
|
|
173
|
+
if (value === undefined || value.startsWith("--")) {
|
|
174
|
+
throw new Error(`${verb}: ${tok} needs a value`);
|
|
175
|
+
}
|
|
176
|
+
flags[tok] = value;
|
|
177
|
+
i++;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return { flags, positionals };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function requiredFlag(verb, flags, name) {
|
|
184
|
+
const v = flags[name];
|
|
185
|
+
if (typeof v !== "string" || v.trim() === "") {
|
|
186
|
+
throw new Error(`${verb}: ${name} is required and must not be empty`);
|
|
187
|
+
}
|
|
188
|
+
return v;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// ---------------------------------------------------------------- images
|
|
192
|
+
|
|
193
|
+
/** Load one image, refusing anything that is not exactly a full 64K capture,
|
|
194
|
+
* naming the path and the length it actually had. Refused rather than
|
|
195
|
+
* compared: two short buffers agree at every address they have. */
|
|
196
|
+
function loadImage(path) {
|
|
197
|
+
const buf = readFileSync(path);
|
|
198
|
+
if (buf.length !== IMAGE_BYTES) {
|
|
199
|
+
throw new Error(
|
|
200
|
+
`${path}: ${buf.length} bytes, expected exactly ${IMAGE_BYTES} -- not a full 64K image, and ` +
|
|
201
|
+
`refusing rather than deriving from a partial one, which agrees everywhere it has no bytes`,
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
return buf;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Load every image, in the order given, refusing a basename collision.
|
|
208
|
+
*
|
|
209
|
+
* The pair attributions in the artifact are written by basename, so two
|
|
210
|
+
* images sharing one makes an entry's provenance unreadable -- and passing the
|
|
211
|
+
* same path twice would add a pair that differs nowhere and inflate the pair
|
|
212
|
+
* count with a comparison of an image against itself. */
|
|
213
|
+
function loadImages(verb, paths) {
|
|
214
|
+
if (paths.length < MIN_RUNS) {
|
|
215
|
+
throw new Error(
|
|
216
|
+
`${verb}: ${paths.length} image(s) given, minimum ${MIN_RUNS} -- the committed method is ` +
|
|
217
|
+
`N >= ${MIN_RUNS} runs of the same release under the same protocol at the same stop, and three ` +
|
|
218
|
+
`runs is already this project's documented minimum for a verified capture`,
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
const byName = new Map();
|
|
222
|
+
const imgs = [];
|
|
223
|
+
for (const path of paths) {
|
|
224
|
+
const name = basename(path);
|
|
225
|
+
const already = byName.get(name);
|
|
226
|
+
if (already !== undefined) {
|
|
227
|
+
throw new Error(
|
|
228
|
+
`${verb}: two images share the basename "${name}" (${already} and ${path}) -- the pair ` +
|
|
229
|
+
`attributions are written by basename, so a collision makes an entry's provenance unreadable`,
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
byName.set(name, path);
|
|
233
|
+
imgs.push({ path, name, buf: loadImage(path) });
|
|
234
|
+
}
|
|
235
|
+
return imgs;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// ---------------------------------------------------------------- derivation
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* The committed derivation: the union of addresses differing across EVERY
|
|
242
|
+
* pairwise comparison of N images. Returns the union as an ascending entry
|
|
243
|
+
* array plus the pair count, and classifies nothing -- there is no volatile
|
|
244
|
+
* span and no bit-count tolerance anywhere in it.
|
|
245
|
+
*/
|
|
246
|
+
function deriveUnion(imgs) {
|
|
247
|
+
const union = new Map(); // addr -> { pairs: string[], values: Set<number> }
|
|
248
|
+
const pairs = [];
|
|
249
|
+
|
|
250
|
+
for (let i = 0; i < imgs.length; i++) {
|
|
251
|
+
for (let j = i + 1; j < imgs.length; j++) {
|
|
252
|
+
const label = `${imgs[i].name} vs ${imgs[j].name}`;
|
|
253
|
+
pairs.push(label);
|
|
254
|
+
const a = imgs[i].buf;
|
|
255
|
+
const b = imgs[j].buf;
|
|
256
|
+
let n = 0;
|
|
257
|
+
for (let addr = 0; addr < IMAGE_BYTES; addr++) {
|
|
258
|
+
const x = a[addr];
|
|
259
|
+
const y = b[addr];
|
|
260
|
+
if (x === y) continue;
|
|
261
|
+
n++;
|
|
262
|
+
let rec = union.get(addr);
|
|
263
|
+
if (!rec) {
|
|
264
|
+
rec = { pairs: [], values: new Set() };
|
|
265
|
+
union.set(addr, rec);
|
|
266
|
+
}
|
|
267
|
+
rec.pairs.push(label);
|
|
268
|
+
rec.values.add(x);
|
|
269
|
+
rec.values.add(y);
|
|
270
|
+
}
|
|
271
|
+
console.log(`PAIR ${label}: ${n} differing address${n === 1 ? "" : "es"}`);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Ascending by address, so the artifact is diffable and an entry's position
|
|
276
|
+
// never depends on which pair happened to observe it first.
|
|
277
|
+
const addresses = [...union.keys()].sort((x, y) => x - y);
|
|
278
|
+
const entries = addresses.map((address) => {
|
|
279
|
+
const rec = union.get(address);
|
|
280
|
+
return {
|
|
281
|
+
address,
|
|
282
|
+
pairs: rec.pairs,
|
|
283
|
+
values: [...rec.values].sort((p, q) => p - q).map(hex2),
|
|
284
|
+
// Left empty for a human to fill. An empty string is a legal
|
|
285
|
+
// attribution and round-trips through `parseAllowList()`; the field
|
|
286
|
+
// exists so an unattributed transient is visibly unattributed rather
|
|
287
|
+
// than absent.
|
|
288
|
+
attribution: "",
|
|
289
|
+
};
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
return { entries, pairs };
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function capFrom(flags) {
|
|
296
|
+
if (flags["--cap"] === undefined) return TRANSIENT_ALLOW_LIST_CAP;
|
|
297
|
+
const n = Number(flags["--cap"]);
|
|
298
|
+
if (!Number.isInteger(n) || n < 1) {
|
|
299
|
+
throw new Error("derive: --cap needs a positive integer");
|
|
300
|
+
}
|
|
301
|
+
if (n > TRANSIENT_ALLOW_LIST_CAP) {
|
|
302
|
+
throw new Error(
|
|
303
|
+
`derive: --cap ${n} is above the committed cap of ${TRANSIENT_ALLOW_LIST_CAP} -- the cap is a ` +
|
|
304
|
+
`pre-commitment, and raising it after seeing a derivation overflow converts a measurement into ` +
|
|
305
|
+
`an excuse. --cap only ever NARROWS`,
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
return n;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function cmdDerive(argv) {
|
|
312
|
+
const { flags, positionals } = parseArgv("derive", argv);
|
|
313
|
+
const release = requiredFlag("derive", flags, "--release");
|
|
314
|
+
const out = requiredFlag("derive", flags, "--out");
|
|
315
|
+
const cap = capFrom(flags);
|
|
316
|
+
|
|
317
|
+
// Checked BEFORE any image is read, so the no-inheritance refusal costs
|
|
318
|
+
// nothing and cannot be reached halfway through a derivation.
|
|
319
|
+
if (existsSync(out) && flags["--force"] !== true) {
|
|
320
|
+
throw new Error(
|
|
321
|
+
`derive: ${out} already exists. An allow-list is re-derived per release from that release's own ` +
|
|
322
|
+
`runs, and no address set is ever inherited between releases -- an inherited list cannot be ` +
|
|
323
|
+
`distinguished afterwards from an honestly derived one, so a contaminated ledger has to be ` +
|
|
324
|
+
`re-derived from fresh captures. Pass --force to replace this artifact with a fresh derivation ` +
|
|
325
|
+
`of "${release}"`,
|
|
326
|
+
);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const imgs = loadImages("derive", positionals);
|
|
330
|
+
for (const i of imgs) console.log(`${i.name} sha256 ${sha256(i.buf)}`);
|
|
331
|
+
|
|
332
|
+
const { entries, pairs } = deriveUnion(imgs);
|
|
333
|
+
|
|
334
|
+
// The count IS the finding, on both paths, so it is printed on both. What
|
|
335
|
+
// differs is that the void path writes nothing.
|
|
336
|
+
console.log(`TRANSIENT_COUNT: ${entries.length}`);
|
|
337
|
+
console.log(`cap: ${cap} (committed cap ${TRANSIENT_ALLOW_LIST_CAP})`);
|
|
338
|
+
|
|
339
|
+
if (entries.length > cap) {
|
|
340
|
+
console.error(
|
|
341
|
+
`VOID: the derivation over ${imgs.length} runs of release "${release}" yields ${entries.length} ` +
|
|
342
|
+
`differing addresses, above the cap of ${cap}.\n` +
|
|
343
|
+
`The derivation is VOID. What that means is not "the list is a bit long": it means THE STOP IS ` +
|
|
344
|
+
`NOT FRAME-EXACT, and that is a fact the gate must hear rather than a threshold to move. ` +
|
|
345
|
+
`Re-derive from a frame-anchored stop and record this count as measured; do not raise the cap ` +
|
|
346
|
+
`and do not truncate the union to fit it, which would make every later comparison pass on bytes ` +
|
|
347
|
+
`nobody vetted.\n` +
|
|
348
|
+
`No artifact was written to ${out}.`,
|
|
349
|
+
);
|
|
350
|
+
return 1;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const artifact = {
|
|
354
|
+
schema_version: SCHEMA_VERSION,
|
|
355
|
+
release,
|
|
356
|
+
derived_from: imgs.map((i) => i.name),
|
|
357
|
+
pair_count: pairs.length,
|
|
358
|
+
cap,
|
|
359
|
+
method:
|
|
360
|
+
`union of addresses differing across every pairwise comparison of N >= ${MIN_RUNS} runs of one ` +
|
|
361
|
+
`release at one stop; re-derived per release, never inherited`,
|
|
362
|
+
entries,
|
|
363
|
+
};
|
|
364
|
+
|
|
365
|
+
// Written ONCE, in full, only after the cap check cleared. No partial file
|
|
366
|
+
// exists at any point on the void path.
|
|
367
|
+
writeFileSync(out, JSON.stringify(artifact, null, 2) + "\n");
|
|
368
|
+
console.log(`wrote ${out}: release "${release}", ${entries.length} entries, ${pairs.length} pairs`);
|
|
369
|
+
return 0;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// ---------------------------------------------------------------- check
|
|
373
|
+
|
|
374
|
+
/** Parse a committed artifact, with the refusals `parseAllowList()` raises, in
|
|
375
|
+
* the same order -- the cap FIRST, because exceeding it voids the whole
|
|
376
|
+
* derivation and there is nothing to gain from validating the entries of a
|
|
377
|
+
* list that cannot be used. */
|
|
378
|
+
function parseArtifact(json, path) {
|
|
379
|
+
if (typeof json !== "object" || json === null || Array.isArray(json)) {
|
|
380
|
+
throw new Error(`${path}: an allow-list artifact must be a JSON object with a release and entries`);
|
|
381
|
+
}
|
|
382
|
+
if (typeof json.release !== "string" || json.release.trim() === "") {
|
|
383
|
+
throw new Error(
|
|
384
|
+
`${path}: the artifact must name the release it was derived from -- an allow-list is re-derived ` +
|
|
385
|
+
`per release and never inherited, so the identifier travels with it`,
|
|
386
|
+
);
|
|
387
|
+
}
|
|
388
|
+
if (!Array.isArray(json.entries)) {
|
|
389
|
+
throw new Error(`${path}: the artifact must carry an entries array -- an enumerated list of addresses`);
|
|
390
|
+
}
|
|
391
|
+
if (json.entries.length > TRANSIENT_ALLOW_LIST_CAP) {
|
|
392
|
+
throw new Error(
|
|
393
|
+
`${path}: the allow-list enumerates ${json.entries.length} addresses, above the committed cap of ` +
|
|
394
|
+
`${TRANSIENT_ALLOW_LIST_CAP} -- exceeding the cap VOIDS the derivation (it means the stop is not ` +
|
|
395
|
+
`frame-exact) and is never repaired by raising the cap`,
|
|
396
|
+
);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
const addresses = new Set();
|
|
400
|
+
for (let i = 0; i < json.entries.length; i++) {
|
|
401
|
+
const entry = json.entries[i];
|
|
402
|
+
if (typeof entry !== "object" || entry === null || Array.isArray(entry)) {
|
|
403
|
+
throw new Error(`${path}: entry ${i} must be an object carrying an address and its run pairs`);
|
|
404
|
+
}
|
|
405
|
+
for (const key of RANGE_SHAPED_KEYS) {
|
|
406
|
+
if (key in entry) {
|
|
407
|
+
throw new Error(
|
|
408
|
+
`${path}: entry ${i} carries the range-shaped key "${key}" -- the transient allow-list is ` +
|
|
409
|
+
`ENUMERATED and is never a range, so write each address as its own entry`,
|
|
410
|
+
);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
const addr = entry.address;
|
|
414
|
+
if (Array.isArray(addr)) {
|
|
415
|
+
throw new Error(
|
|
416
|
+
`${path}: entry ${i} writes its address as an array, which is a range-shaped span -- the ` +
|
|
417
|
+
`transient allow-list is ENUMERATED and is never a range`,
|
|
418
|
+
);
|
|
419
|
+
}
|
|
420
|
+
if (!Number.isInteger(addr) || addr < 0 || addr > 0xffff) {
|
|
421
|
+
throw new Error(`${path}: entry ${i} has address ${JSON.stringify(addr)}, not an integer in 0..65535`);
|
|
422
|
+
}
|
|
423
|
+
if (addresses.has(addr)) {
|
|
424
|
+
throw new Error(
|
|
425
|
+
`${path}: entry ${i} repeats address ${hex4(addr)} -- a duplicate silently consumes a second ` +
|
|
426
|
+
`slot of the cap`,
|
|
427
|
+
);
|
|
428
|
+
}
|
|
429
|
+
if (!Array.isArray(entry.pairs) || entry.pairs.some((p) => typeof p !== "string")) {
|
|
430
|
+
throw new Error(
|
|
431
|
+
`${path}: entry ${i} (address ${hex4(addr)}) must record which run pairs it differed in, as an ` +
|
|
432
|
+
`array of strings`,
|
|
433
|
+
);
|
|
434
|
+
}
|
|
435
|
+
// The MCP-side parseAllowList() refuses a non-string
|
|
436
|
+
// `attribution` and this parser did not check the field at all, so a
|
|
437
|
+
// hand-edited artifact with `"attribution": 5` passed `check` here and was
|
|
438
|
+
// refused there. The two implementations are a DELIBERATE duplicate
|
|
439
|
+
// asserted to agree on VERDICTS, and parse strictness is exactly where a
|
|
440
|
+
// deliberate duplicate drifts first -- an artifact one accepts and the
|
|
441
|
+
// other rejects is a disagreement about what the ledger even says.
|
|
442
|
+
if (entry.attribution !== undefined && typeof entry.attribution !== "string") {
|
|
443
|
+
throw new Error(
|
|
444
|
+
`${path}: entry ${i} (address ${hex4(addr)}) has a non-string attribution ` +
|
|
445
|
+
`(${JSON.stringify(entry.attribution)}) -- an attribution is a one-line human note or absent`,
|
|
446
|
+
);
|
|
447
|
+
}
|
|
448
|
+
addresses.add(addr);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
return { release: json.release, addresses };
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* The equivalence rule, in full, with nothing else in it: a differing address
|
|
456
|
+
* ON the allow-list is counted, reported and excluded from the verdict. ANY
|
|
457
|
+
* other differing address FAILS -- whatever its bit count, wherever it sits,
|
|
458
|
+
* and however close it lies to an allow-listed address. The allow-list is a
|
|
459
|
+
* SET OF ADDRESSES and never a neighbourhood.
|
|
460
|
+
*
|
|
461
|
+
* Symmetric in `a` and `b`: the verdict depends only on WHICH addresses
|
|
462
|
+
* differ, never on which image was passed first.
|
|
463
|
+
*/
|
|
464
|
+
function compareUnderAllowList(a, b, addresses) {
|
|
465
|
+
const allowed = [];
|
|
466
|
+
const divergence = [];
|
|
467
|
+
for (let addr = 0; addr < IMAGE_BYTES; addr++) {
|
|
468
|
+
const x = a[addr];
|
|
469
|
+
const y = b[addr];
|
|
470
|
+
if (x === y) continue;
|
|
471
|
+
(addresses.has(addr) ? allowed : divergence).push({ addr, a: x, b: y });
|
|
472
|
+
}
|
|
473
|
+
return {
|
|
474
|
+
allowed,
|
|
475
|
+
divergence,
|
|
476
|
+
verdict: divergence.length === 0 ? "equivalent" : "not-equivalent",
|
|
477
|
+
};
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
function printRows(title, rows, limit) {
|
|
481
|
+
console.log(`\n${title}: ${rows.length}`);
|
|
482
|
+
const shown = limit === 0 ? rows : rows.slice(0, limit);
|
|
483
|
+
for (const r of shown) console.log(` ${hex4(r.addr)} ${hex2(r.a)} -> ${hex2(r.b)}`);
|
|
484
|
+
if (shown.length < rows.length) {
|
|
485
|
+
console.log(` ... ${rows.length - shown.length} more (--limit 0 for all)`);
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function cmdCheck(argv) {
|
|
490
|
+
const { flags, positionals } = parseArgv("check", argv);
|
|
491
|
+
const listPath = requiredFlag("check", flags, "--allow-list");
|
|
492
|
+
const limit = flags["--limit"] === undefined ? 40 : Number(flags["--limit"]);
|
|
493
|
+
if (!Number.isInteger(limit) || limit < 0) throw new Error("check: --limit needs a non-negative integer");
|
|
494
|
+
if (positionals.length !== 2) {
|
|
495
|
+
throw new Error(`check: needs exactly two image paths, got ${positionals.length}`);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// The JSON.parse used to sit OUTSIDE parseArtifact(), so a syntax
|
|
499
|
+
// error surfaced through the outer catch as a bare `error: Unexpected token
|
|
500
|
+
// …` naming no file -- unlike every other refusal in this script, each of
|
|
501
|
+
// which names the path. On a route whose whole subject is which artifact
|
|
502
|
+
// says what, "which file failed to parse" is the first thing an operator
|
|
503
|
+
// needs.
|
|
504
|
+
let listJson;
|
|
505
|
+
const listRaw = readFileSync(listPath, "utf8");
|
|
506
|
+
try {
|
|
507
|
+
listJson = JSON.parse(listRaw);
|
|
508
|
+
} catch (e) {
|
|
509
|
+
throw new Error(`${listPath}: not valid JSON -- ${e.message}`);
|
|
510
|
+
}
|
|
511
|
+
const { release, addresses } = parseArtifact(listJson, listPath);
|
|
512
|
+
const [pa, pb] = positionals;
|
|
513
|
+
const a = loadImage(pa);
|
|
514
|
+
const b = loadImage(pb);
|
|
515
|
+
|
|
516
|
+
console.log(`allow-list ${listPath}: release "${release}", ${addresses.size} addresses (cap ${TRANSIENT_ALLOW_LIST_CAP})`);
|
|
517
|
+
console.log(`A ${basename(pa)} sha256 ${sha256(a)}`);
|
|
518
|
+
console.log(`B ${basename(pb)} sha256 ${sha256(b)}`);
|
|
519
|
+
|
|
520
|
+
const r = compareUnderAllowList(a, b, addresses);
|
|
521
|
+
printRows("allowed (enumerated transients, excluded from the verdict)", r.allowed, limit);
|
|
522
|
+
printRows("DIVERGENCE -- outside the allow-list, fails at any bit count", r.divergence, limit);
|
|
523
|
+
|
|
524
|
+
console.log(`\nCHECK_VERDICT: ${r.verdict}`);
|
|
525
|
+
return r.verdict === "equivalent" ? 0 : 1;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// ---------------------------------------------------------------- CLI
|
|
529
|
+
|
|
530
|
+
// Prototype-less, via Object.create(null). A plain object
|
|
531
|
+
// literal inherits Object.prototype, so `commands["constructor"]` and
|
|
532
|
+
// `commands["toString"]` are truthy FUNCTIONS: an unknown verb that happens to
|
|
533
|
+
// be a prototype member passed the known-verb test and was then CALLED, so the
|
|
534
|
+
// documented usage was never printed and the failure surfaced as a confusing
|
|
535
|
+
// message from process.exit() about its argument type instead. Same idiom this
|
|
536
|
+
// file already uses for its flag bag, applied one level up.
|
|
537
|
+
const commands = Object.assign(Object.create(null), { derive: cmdDerive, check: cmdCheck });
|
|
538
|
+
|
|
539
|
+
const [cmd, ...rest] = process.argv.slice(2);
|
|
540
|
+
if (!cmd || !commands[cmd]) {
|
|
541
|
+
console.error(`usage: node derive-transients.mjs <command>
|
|
542
|
+
|
|
543
|
+
derive --release <id> --out <path> [--cap N] [--force] <a.bin> <b.bin> <c.bin> [...]
|
|
544
|
+
Derive the per-release transient allow-list: the union of addresses differing across
|
|
545
|
+
every pairwise comparison of N >= ${MIN_RUNS} captures of one release at one stop.
|
|
546
|
+
Prints TRANSIENT_COUNT: <n>. Over the cap of ${TRANSIENT_ALLOW_LIST_CAP} the derivation
|
|
547
|
+
is VOID -- non-zero exit, no artifact written, because the stop is not frame-exact.
|
|
548
|
+
|
|
549
|
+
check --allow-list <path> [--limit N] <a.bin> <b.bin>
|
|
550
|
+
Re-check one pair against an already-committed derivation, without re-deriving it.
|
|
551
|
+
Prints CHECK_VERDICT: equivalent | not-equivalent. Exit 1 when not equivalent.
|
|
552
|
+
|
|
553
|
+
The allow-list is ENUMERATED, never a range, and there is no bit-count tolerance at any
|
|
554
|
+
address: a one-bit difference outside the list FAILS. \`compare.mjs\`'s volatile spans and
|
|
555
|
+
its drift-passes rule are NOT inherited here -- see this file's header for why, and for why
|
|
556
|
+
\`$D000-$DFFF\` is not volatile on the snapshot route.
|
|
557
|
+
|
|
558
|
+
The method is re-derived per release and NO address set is ever inherited between releases;
|
|
559
|
+
re-deriving over an existing artifact is refused without --force. \`--cap\` only narrows.
|
|
560
|
+
|
|
561
|
+
\`check\` compares the images exactly as given. On the snapshot route the \`$0000\`/\`$0001\`
|
|
562
|
+
6510-port overlay is normalised in code by the MCP-side predicate, so a derivation
|
|
563
|
+
taken from un-normalised images can legitimately carry those two addresses.
|
|
564
|
+
|
|
565
|
+
Images come from the capture procedure in this skill's SKILL.md. This script contacts
|
|
566
|
+
nothing and spawns nothing.`);
|
|
567
|
+
process.exit(cmd ? 1 : 0);
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
try {
|
|
571
|
+
process.exit(commands[cmd](rest));
|
|
572
|
+
} catch (e) {
|
|
573
|
+
console.error(`error: ${e.message}`);
|
|
574
|
+
process.exit(1);
|
|
575
|
+
}
|
|
@@ -55,7 +55,7 @@ export function assembleImage(chunks) {
|
|
|
55
55
|
return total;
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
-
/** SHA-256 of a buffer, hex-encoded. `node:crypto` only -- no package added
|
|
58
|
+
/** SHA-256 of a buffer, hex-encoded. `node:crypto` only -- no package added. */
|
|
59
59
|
export function sha256Buffer(buf) {
|
|
60
60
|
return createHash("sha256").update(buf).digest("hex");
|
|
61
61
|
}
|
|
@@ -101,7 +101,7 @@ function charsetBase(d018Raw, dd00Raw) {
|
|
|
101
101
|
// -------------------------------------------------------------- buildChipState
|
|
102
102
|
|
|
103
103
|
/**
|
|
104
|
-
* Build the
|
|
104
|
+
* Build the chip-state sidecar in the exact shape the committed
|
|
105
105
|
* primary sidecars already use (same top-level keys, same `derived` field
|
|
106
106
|
* set), from the register/state readings the agent recorded. `raw` carries
|
|
107
107
|
* whatever the agent fetched via vice_registers_get / vice_sprite_get /
|
|
@@ -163,7 +163,7 @@ function powerOnRunLength(image, start) {
|
|
|
163
163
|
}
|
|
164
164
|
|
|
165
165
|
/**
|
|
166
|
-
* Emit the
|
|
166
|
+
* Emit the range manifest in the committed shape: ranges whose union
|
|
167
167
|
* covers $0000-$FFFF with no gap and no overlap, a contiguous power-on-
|
|
168
168
|
* pattern run of at least 16 bytes marked kind `unused`, the I/O window
|
|
169
169
|
* ($D000-$DFFF) marked `io`, everything else `unclassified`, and
|