@henols/vice-mcp 0.2.2 → 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.
Files changed (77) 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 +1674 -99
  5. package/anno-enum-gen.ts +416 -30
  6. package/anno-export-asm.ts +1175 -89
  7. package/anno-graphics.ts +338 -0
  8. package/anno-hazard-report.ts +1367 -0
  9. package/anno-import.ts +495 -0
  10. package/anno-join.ts +480 -0
  11. package/anno-provenance-ledger.ts +472 -0
  12. package/anno-register.ts +159 -0
  13. package/anno-store-export.ts +661 -0
  14. package/anno-store.ts +518 -2
  15. package/anno-tools.ts +1169 -16
  16. package/anno-types.ts +275 -2
  17. package/backend-detect.mts +124 -312
  18. package/build.ts +3 -1
  19. package/capture-predicate.ts +597 -0
  20. package/channel-lock.ts +349 -0
  21. package/evid-ingest.ts +217 -0
  22. package/evid-reconcile.ts +316 -0
  23. package/host-tool-client.ts +430 -0
  24. package/incident-record.ts +23 -12
  25. package/install-resources.ts +29 -13
  26. package/memmap-lookup.ts +285 -0
  27. package/package.json +27 -8
  28. package/prg-image.ts +1 -2
  29. package/repo-root.ts +87 -3
  30. package/resources/backend-detect.mjs +98 -236
  31. package/resources/broker-control.mjs +189 -16
  32. package/resources/broker-epoch.mjs +1 -1
  33. package/resources/broker-kill.mjs +8 -2
  34. package/resources/broker-launch.mjs +365 -210
  35. package/resources/broker-state.mjs +64 -18
  36. package/resources/container-guard.mjs +1 -1
  37. package/resources/ghidra-project.mjs +790 -0
  38. package/resources/host-tool.mjs +2561 -0
  39. package/resources/vice-broker.mjs +330 -184
  40. package/resources/vice-launcher.sh +127 -9
  41. package/stock-address.ts +1 -1
  42. package/stock-condition.ts +1 -1
  43. package/stock-connect.ts +9 -5
  44. package/stock-derived.ts +29 -37
  45. package/stock-diagnose.ts +200 -36
  46. package/stock-dispatch.ts +179 -77
  47. package/stock-handler.ts +1 -1
  48. package/stock-paths.ts +18 -14
  49. package/stock-petscii.ts +1 -1
  50. package/stock-protocol.ts +1 -1
  51. package/stock-recycle.ts +83 -2
  52. package/stock-reproducible-run.ts +811 -0
  53. package/stock-run-until.ts +100 -1
  54. package/stock-symbols.ts +4 -4
  55. package/stock-timing.ts +1 -1
  56. package/stop-oracle.ts +167 -0
  57. package/text-capability-probe.ts +660 -0
  58. package/text-connect.ts +157 -0
  59. package/text-protocol.ts +810 -0
  60. package/text-tools.ts +778 -0
  61. package/textmon-backtrace.ts +385 -0
  62. package/textmon-cpuhistory.ts +335 -0
  63. package/textmon-memmap.ts +494 -0
  64. package/textmon-profile.ts +458 -0
  65. package/textmon-registers.ts +748 -0
  66. package/tools-manifest.stock.json +864 -3
  67. package/vice-broker-client.ts +189 -42
  68. package/vice-errors.ts +268 -0
  69. package/vice-proxy.ts +339 -2144
  70. package/vsf-slice.ts +640 -0
  71. package/anno-d64.ts +0 -310
  72. package/capability-registry.ts +0 -390
  73. package/refresh-manifest.ts +0 -124
  74. package/tools-manifest.json +0 -1223
  75. package/vice-probe.ts +0 -278
  76. package/vice-sync.ts +0 -336
  77. package/vice.ts +0 -772
package/vsf-slice.ts ADDED
@@ -0,0 +1,640 @@
1
+ #!/usr/bin/env node
2
+ // vsf-slice.ts -- the ONE authoritative place in this repo holding VICE `.vsf`
3
+ // snapshot byte-layout knowledge: where the module table starts, how a module
4
+ // header is shaped, and how the `C64MEM` module's body splits into a 4-byte
5
+ // port/PLA prefix, the 65536-byte RAM array, and the 3-byte port-read suffix
6
+ // after it.
7
+ //
8
+ // This module performs NO filesystem and NO network I/O: every function takes
9
+ // bytes (`Uint8Array`) and returns values. Callers obtain the bytes
10
+ // themselves. That is the same claim `prg-image.ts` makes about itself, and a
11
+ // structural test in `vsf-slice.test.ts` asserts it from this module's own
12
+ // source rather than trusting this paragraph.
13
+ //
14
+ // WHY THIS FILE EXISTS: transcribing 64K out of a running machine as hex
15
+ // already lost data twice -- one 32 KB write truncated mid-payload, and one
16
+ // 8 KB write dropped ten characters, localised to `$7871`. Slicing the
17
+ // snapshot removes the transcription step outright rather than adding a
18
+ // checksum around it. It also removes a 4096-address exclusion: the `C64MEM`
19
+ // array is `mem_ram[]` -- RAM *under* I/O, not the register read view -- so
20
+ // `$D000-$DFFF` is not volatile on this route at all, which is the opposite
21
+ // of the rule that governs the memory-read route.
22
+ //
23
+ // THE ONE FACT THIS MODULE EXISTS TO GET RIGHT: a `.vsf` that is not read
24
+ // strictly does not fail loudly. It returns something 65536 bytes long that
25
+ // is not the machine's memory, and every number measured on top of it is then
26
+ // wrong with no diagnostic. So every refusal below is a deliverable, not
27
+ // error handling bolted on afterwards, and each one has a committed fixture
28
+ // under `fixtures/vsf/` observed to trigger it.
29
+ //
30
+ // THIS MODULE MUST BE LISTED IN `package.json`'s `files[]`. Two mechanical
31
+ // reasons: the skill-side route (`src/skills/c64-ram-capture/scripts/
32
+ // vsf-slice.mjs`) resolves this file inside the published tarball on the
33
+ // npm-installer route, and the structural census over `shippedTsModules()` is
34
+ // derived from `files[]`, so a module absent from that array is outside every
35
+ // structural guard's scanned set entirely.
36
+ //
37
+ // WHAT NOT TO DO:
38
+ // - Never reach `C64MEM` by a fixed byte offset. The already-written
39
+ // prototype at `.planning/phases/23-the-real-release-gate-go-degrade-no-go/
40
+ // evidence/vsf-ram-extract.mjs` carries `first_module_offset = 37`, which
41
+ // is stale: a SECOND magic block (`"VICE Version\x1a"`, 13 bytes, plus 4
42
+ // version bytes plus a 4-byte SVN dword) follows the 16-byte machine
43
+ // name, so the real first module offset is 58. Offset 37 lands inside
44
+ // that second magic block, where the first "module" reads
45
+ // `size = 1291845632`.
46
+ // - Never carry that prototype's recovery path, which is the more dangerous
47
+ // half. It survives the stale offset only by `off++`-rescanning for a
48
+ // 16-byte printable field followed by a plausible u32 length -- and such
49
+ // a scan can lock onto a false module-name string inside 64 KB of RAM
50
+ // data, then return a garbage 65536-byte image. The walk below advances
51
+ // by the module's own size field and throws on the first malformed
52
+ // header. `fixtures/vsf/malformed-header.vsf` is the positive control:
53
+ // an implementation with a rescan fallback steps past it and keeps
54
+ // walking, so that fixture slicing successfully means the fallback is
55
+ // back.
56
+ // - Never assert the `C64MEM` body is `4 + 65536` bytes. That arithmetic
57
+ // (65540) refuses EVERY real snapshot: the measured body is 65555 at
58
+ // module minor 1 and 65543 at minor 0, because VICE writes three port
59
+ // read-back bytes after the RAM array and, at minor 1, two DWORD falloff
60
+ // clocks plus four state bytes after those. The rule is `>= 65543`.
61
+ // - Never take the CPU-visible `$0000`/`$0001` values from the 4-byte
62
+ // PREFIX. The prefix is `(pport.data, pport.dir, EXROM, GAME)` -- data
63
+ // first, address-swapped relative to the machine, where `$0000` is
64
+ // direction and `$0001` is data -- and neither field is the CPU view
65
+ // anyway. `zero_read()` returns `pport.dir_read` for `$0000` and
66
+ // `pport.data_read` for `$0001`, and both of those live in the 3-byte
67
+ // SUFFIX. Measured on one snapshot, three readings agreeing: prefix
68
+ // `[231,47,0,0]` against suffix `[39,55,47]`, with the live registers on
69
+ // that same snapshot giving `$00=47 $01=55`. A prefix-over-RAM copy
70
+ // writes 231 where the CPU sees 47 -- wrong by 176, silently, at exactly
71
+ // the two addresses the normalisation exists to fix.
72
+ // - Never give any exported function a filesystem PATH parameter. They take
73
+ // byte arrays, which is what keeps path traversal out of this module's
74
+ // threat surface entirely rather than merely checked. Snapshot paths come
75
+ // from `stock-paths.ts`'s `snapshotPathFor()`, which is confined inside
76
+ // the workspace by construction. For the same reason this module imports
77
+ // nothing from either of this repo's two host/container path-translation
78
+ // seams; that absence is asserted structurally by
79
+ // `hostpath-consumers.test.ts`, whose consumer set is a closed
80
+ // five-member list.
81
+ // - Never `subarray` on an unvalidated length. A short `subarray` silently
82
+ // returns fewer bytes than asked for, which is the exact shape of the
83
+ // failure this module exists to refuse.
84
+
85
+ /** The 19-byte leading magic. `SNAPSHOT_MAGIC_LEN` is 19 in VICE's own
86
+ * source, and `.length` here is the arithmetic that produces it rather than
87
+ * a second copy of the number. */
88
+ export const SNAPSHOT_MAGIC = "VICE Snapshot File\x1a";
89
+
90
+ /** `SNAPSHOT_MACHINE_NAME_LEN`. The name is NUL-padded to this width
91
+ * (`"C64SC"` on an `x64sc` build). */
92
+ export const SNAPSHOT_MACHINE_NAME_LEN = 16;
93
+
94
+ /** The 13-byte SECOND magic block, which is the whole reason the first module
95
+ * offset is 58 and not 37. */
96
+ export const SNAPSHOT_VERSION_MAGIC = "VICE Version\x1a";
97
+
98
+ /** Width of a module header's NUL-padded name field. Deliberately its OWN
99
+ * constant and not `SNAPSHOT_MACHINE_NAME_LEN`: both are 16 in VICE's source,
100
+ * but they are different fields in different structures, and sharing one
101
+ * constant would make a future divergence in either look like a bug in the
102
+ * other. */
103
+ export const MODULE_NAME_LEN = 16;
104
+
105
+ /** `name(16) major(1) minor(1) size(u32LE)`. Written as the arithmetic so the
106
+ * 22 is checkable. The size field covers the module's OWN header as well as
107
+ * its body. */
108
+ export const MODULE_HEADER_LEN = MODULE_NAME_LEN + 1 + 1 + 4;
109
+
110
+ /** Byte offset of the size field within a module header: the name field plus
111
+ * the major and minor bytes. Evaluates to 18. */
112
+ export const MODULE_SIZE_FIELD_OFFSET = MODULE_NAME_LEN + 2;
113
+
114
+ /** Where the module table starts. Written as the arithmetic so the derivation
115
+ * is checkable at a glance and cannot drift from the constants it is made of:
116
+ * magic + snapshot major/minor + machine name + version magic + 4 VICE
117
+ * version bytes + a 4-byte SVN dword. Evaluates to 58, which is where
118
+ * `"MAINCPU"` was measured to begin on a genuine 3.9 snapshot. */
119
+ export const FIRST_MODULE_OFFSET =
120
+ SNAPSHOT_MAGIC.length +
121
+ 2 +
122
+ SNAPSHOT_MACHINE_NAME_LEN +
123
+ SNAPSHOT_VERSION_MAGIC.length +
124
+ 4 +
125
+ 4;
126
+
127
+ /** Bytes of `(pport.data, pport.dir, EXROM, GAME)` ahead of the RAM array.
128
+ * Deliberately NOT the normalisation source -- see the header. */
129
+ export const RAM_OFFSET = 4;
130
+
131
+ /** `C64_RAM_SIZE`, `0x10000`. */
132
+ export const RAM_SIZE = 65536;
133
+
134
+ /** The shortest `C64MEM` body this module accepts: the port/PLA prefix, the
135
+ * RAM array, and the three port read-back bytes after it. Evaluates to 65543,
136
+ * which is the measured body length at module minor 0. */
137
+ export const MIN_C64MEM_BODY_LEN = RAM_OFFSET + RAM_SIZE + 3;
138
+
139
+ /** The body length at module minor 1: the minor-0 body plus two DWORD port
140
+ * falloff clocks and four port state bytes. Evaluates to 65555, the length
141
+ * measured on a genuine 3.9 snapshot. */
142
+ export const V01_C64MEM_BODY_LEN = MIN_C64MEM_BODY_LEN + 4 + 4 + 4;
143
+
144
+ /** The module name this module slices, matched byte-exactly after trailing
145
+ * NULs are stripped. Byte-exact matters: `"C64MEMHACKS"` is a real, adjacent
146
+ * module in every genuine snapshot, and a `startsWith`/`includes` match would
147
+ * find it. */
148
+ export const C64MEM_MODULE_NAME = "C64MEM";
149
+
150
+ /** Every refusal this module raises. Named rather than a bare `Error` so a
151
+ * caller can tell "this snapshot is not readable" from a programming fault,
152
+ * and so the skill-side CLI can pass the message through untouched instead of
153
+ * inventing its own wording. */
154
+ export class VsfSliceError extends Error {
155
+ constructor(message: string) {
156
+ super(message);
157
+ this.name = "VsfSliceError";
158
+ }
159
+ }
160
+
161
+ /** One entry of the module table. `size` is the module's own size field and
162
+ * INCLUDES `MODULE_HEADER_LEN`; `bodyLength` is what is left after it. Both
163
+ * are returned so a reader never has to remember which of the two a given
164
+ * number is. */
165
+ export interface SnapshotModule {
166
+ /** 16 bytes read as latin1 with trailing NULs stripped. */
167
+ name: string;
168
+ major: number;
169
+ minor: number;
170
+ /** Offset of the module HEADER within the file. */
171
+ offset: number;
172
+ /** The module's own u32LE size field, covering its 22-byte header. */
173
+ size: number;
174
+ /** `offset + MODULE_HEADER_LEN`. */
175
+ bodyOffset: number;
176
+ /** `size - MODULE_HEADER_LEN`. */
177
+ bodyLength: number;
178
+ }
179
+
180
+ /** The result of slicing `C64MEM`: a flat 64K image plus the port bytes a
181
+ * caller needs in order to normalise RAM `$0000`/`$0001` without re-reading
182
+ * the snapshot. */
183
+ export interface C64MemSlice {
184
+ /** Exactly `RAM_SIZE` bytes, copied out -- never a view into the input. */
185
+ ram: Uint8Array;
186
+ /** `pport.data_out`. Recorded for completeness; not a normalisation source. */
187
+ dataOut: number;
188
+ /** `pport.data_read` -- the CPU-visible value of `$0001`. */
189
+ dataRead: number;
190
+ /** `pport.dir_read` -- the CPU-visible value of `$0000`. */
191
+ dirRead: number;
192
+ /** The `C64MEM` MODULE's minor version (VICE's `SNAP_MINOR` for this
193
+ * module), not the snapshot file header's minor. It is what distinguishes a
194
+ * 65543-byte body from a 65555-byte one, so it is returned rather than left
195
+ * for a caller to re-derive from `bodyLength`. */
196
+ snapshotMinor: number;
197
+ /** The observed body length: 65543 at minor 0, 65555 at minor 1. */
198
+ bodyLength: number;
199
+ }
200
+
201
+ /** Reads a little-endian u32 without going through `Buffer`, so every
202
+ * function here accepts a plain `Uint8Array` and the module needs no Node
203
+ * import at all. `>>> 0` keeps a top-bit-set field unsigned, which matters
204
+ * because a malformed size field is exactly the shape that sets it. */
205
+ function readU32LE(bytes: Uint8Array, at: number): number {
206
+ return (
207
+ (bytes[at] | (bytes[at + 1] << 8) | (bytes[at + 2] << 16) | (bytes[at + 3] << 24)) >>> 0
208
+ );
209
+ }
210
+
211
+ /** Decodes a module name: 16 bytes latin1, trailing NULs stripped. Written as
212
+ * a loop rather than a trailing-NUL regex so the stripping is visibly
213
+ * TRAILING-only -- an embedded NUL is kept, because a name with one is
214
+ * malformed and must not be silently normalised into a name that matches. */
215
+ function decodeModuleName(bytes: Uint8Array, at: number): string {
216
+ let end = at + MODULE_NAME_LEN;
217
+ while (end > at && bytes[end - 1] === 0) end--;
218
+ let name = "";
219
+ for (let i = at; i < end; i++) name += String.fromCharCode(bytes[i]);
220
+ return name;
221
+ }
222
+
223
+ /** True iff `bytes` begins with `SNAPSHOT_MAGIC`. */
224
+ function hasSnapshotMagic(bytes: Uint8Array): boolean {
225
+ if (bytes.length < SNAPSHOT_MAGIC.length) return false;
226
+ for (let i = 0; i < SNAPSHOT_MAGIC.length; i++) {
227
+ if (bytes[i] !== SNAPSHOT_MAGIC.charCodeAt(i)) return false;
228
+ }
229
+ return true;
230
+ }
231
+
232
+ /**
233
+ * Walks the snapshot's module table STRICTLY, from `FIRST_MODULE_OFFSET`,
234
+ * advancing by each module's own size field and never by one byte.
235
+ *
236
+ * Throws a `VsfSliceError` rather than recovering, at four points: an input
237
+ * too short to hold a header plus one module header; a missing leading magic;
238
+ * a module whose size field is below `MODULE_HEADER_LEN` or whose extent runs
239
+ * past the end of the file; and -- after the loop -- a walk that did not end
240
+ * EXACTLY at the file length.
241
+ *
242
+ * That last check is the cheapest whole-file integrity test available on this
243
+ * format, and it is the one that would catch a future VICE adding a third
244
+ * magic block loudly instead of silently: the modules would all still parse,
245
+ * and the walk would simply stop somewhere other than the end.
246
+ */
247
+ export function listSnapshotModules(bytes: Uint8Array): SnapshotModule[] {
248
+ const minimum = FIRST_MODULE_OFFSET + MODULE_HEADER_LEN;
249
+ if (bytes.length < minimum) {
250
+ throw new VsfSliceError(
251
+ `listSnapshotModules: input is ${bytes.length} byte(s) -- a .vsf needs at least ${minimum} ` +
252
+ `(a ${FIRST_MODULE_OFFSET}-byte file header plus one ${MODULE_HEADER_LEN}-byte module header) -- ` +
253
+ `refusing rather than returning a short read.`,
254
+ );
255
+ }
256
+ if (!hasSnapshotMagic(bytes)) {
257
+ throw new VsfSliceError(
258
+ `listSnapshotModules: input does not begin with the ${SNAPSHOT_MAGIC.length}-byte VICE ` +
259
+ `snapshot magic -- refusing rather than guessing at the layout.`,
260
+ );
261
+ }
262
+
263
+ const modules: SnapshotModule[] = [];
264
+ let offset = FIRST_MODULE_OFFSET;
265
+
266
+ while (offset + MODULE_HEADER_LEN <= bytes.length) {
267
+ const name = decodeModuleName(bytes, offset);
268
+ const major = bytes[offset + MODULE_NAME_LEN];
269
+ const minor = bytes[offset + MODULE_NAME_LEN + 1];
270
+ const size = readU32LE(bytes, offset + MODULE_SIZE_FIELD_OFFSET);
271
+
272
+ if (size < MODULE_HEADER_LEN || offset + size > bytes.length) {
273
+ throw new VsfSliceError(
274
+ `listSnapshotModules: malformed module header at offset ${offset} ` +
275
+ `(name=${JSON.stringify(name)}, major=${major}, minor=${minor}, size=${size}); a module ` +
276
+ `size must be at least ${MODULE_HEADER_LEN} and must not run past the ${bytes.length}-byte ` +
277
+ `file -- refusing rather than rescanning, because a byte-by-byte rescan can lock onto a false ` +
278
+ `module name inside RAM data and return a garbage image.`,
279
+ );
280
+ }
281
+
282
+ modules.push({
283
+ name,
284
+ major,
285
+ minor,
286
+ offset,
287
+ size,
288
+ bodyOffset: offset + MODULE_HEADER_LEN,
289
+ bodyLength: size - MODULE_HEADER_LEN,
290
+ });
291
+ offset += size;
292
+ }
293
+
294
+ if (offset !== bytes.length) {
295
+ throw new VsfSliceError(
296
+ `listSnapshotModules: the module table walk ended at offset ${offset} but the file is ` +
297
+ `${bytes.length} byte(s) -- the two must be equal. ${modules.length} module(s) parsed ` +
298
+ `cleanly, so the geometry changed rather than the modules being malformed -- refusing rather ` +
299
+ `than trusting a walk that did not account for the whole file.`,
300
+ );
301
+ }
302
+
303
+ return modules;
304
+ }
305
+
306
+ /**
307
+ * Slices the flat 64K RAM image out of the snapshot's `C64MEM` module body,
308
+ * together with the three port read-back bytes that follow it.
309
+ *
310
+ * Zero `C64MEM` modules and two or more both throw -- a duplicated module
311
+ * name is a malformed snapshot, never a first-wins situation. A body below
312
+ * `MIN_C64MEM_BODY_LEN` throws naming both the observed length and the
313
+ * minimum.
314
+ */
315
+ export function sliceC64Mem(bytes: Uint8Array): C64MemSlice {
316
+ const modules = listSnapshotModules(bytes);
317
+ const matches = modules.filter((m) => m.name === C64MEM_MODULE_NAME);
318
+
319
+ if (matches.length === 0) {
320
+ const available = modules.map((m) => m.name).join(", ") || "(no modules)";
321
+ throw new VsfSliceError(
322
+ `sliceC64Mem: no module named "${C64MEM_MODULE_NAME}" found. Available modules: ${available}`,
323
+ );
324
+ }
325
+ if (matches.length > 1) {
326
+ throw new VsfSliceError(
327
+ `sliceC64Mem: module name "${C64MEM_MODULE_NAME}" is duplicated -- ${matches.length} modules ` +
328
+ `carry it (at offsets ${matches.map((m) => m.offset).join(", ")}). A duplicated module name ` +
329
+ `is a malformed snapshot -- refusing rather than taking the first.`,
330
+ );
331
+ }
332
+
333
+ const c64mem = matches[0];
334
+ if (c64mem.bodyLength < MIN_C64MEM_BODY_LEN) {
335
+ throw new VsfSliceError(
336
+ `sliceC64Mem: ${C64MEM_MODULE_NAME} body is ${c64mem.bodyLength} byte(s), need at least ` +
337
+ `${MIN_C64MEM_BODY_LEN} (${RAM_OFFSET}-byte port prefix + ${RAM_SIZE} RAM + 3 port ` +
338
+ `read-back bytes) -- refusing a short read rather than returning a truncated image.`,
339
+ );
340
+ }
341
+
342
+ const ramStart = c64mem.bodyOffset + RAM_OFFSET;
343
+ const view = bytes.subarray(ramStart, ramStart + RAM_SIZE);
344
+ // The length re-check makes "never subarray on an unvalidated length" true
345
+ // by CONSTRUCTION rather than by the body-length check above happening to
346
+ // imply it: a short `subarray` returns fewer bytes than asked for with no
347
+ // error, and a short image is precisely what this module exists to refuse.
348
+ if (view.length !== RAM_SIZE) {
349
+ throw new VsfSliceError(
350
+ `sliceC64Mem: the ${C64MEM_MODULE_NAME} RAM array at offset ${ramStart} is ${view.length} ` +
351
+ `byte(s) inside a ${bytes.length}-byte file, not ${RAM_SIZE} -- refusing rather than ` +
352
+ `returning a short image.`,
353
+ );
354
+ }
355
+ // An explicit `new Uint8Array` + `set` and NOT `bytes.slice(...)`. Measured
356
+ // while writing this module's own copy-independence test: `readFileSync`
357
+ // returns a `Buffer`, and `Buffer.prototype.slice` overrides the TypedArray
358
+ // method as an alias for `subarray` -- so `bytes.slice(...)` returns a VIEW
359
+ // into the snapshot for exactly the input type every real caller passes.
360
+ // A caller then normalising `$0000`/`$0001` would be writing into the
361
+ // snapshot bytes, and the whole snapshot would stay alive behind a 64K
362
+ // image.
363
+ const ram = new Uint8Array(RAM_SIZE);
364
+ ram.set(view);
365
+ const suffix = ramStart + RAM_SIZE;
366
+
367
+ return {
368
+ ram,
369
+ dataOut: bytes[suffix],
370
+ dataRead: bytes[suffix + 1],
371
+ dirRead: bytes[suffix + 2],
372
+ snapshotMinor: c64mem.minor,
373
+ bodyLength: c64mem.bodyLength,
374
+ };
375
+ }
376
+
377
+ // ===========================================================================
378
+ // CLI_REGION_BEGIN
379
+ //
380
+ // Everything ABOVE this marker is the pure library: bytes in, values out, no
381
+ // imports, no I/O. Everything BELOW it is the process entry point, and it is
382
+ // the ONLY part of this file allowed to touch the filesystem.
383
+ //
384
+ // `vsf-slice.test.ts` splits this file's raw source on the marker at the top
385
+ // of this banner -- which therefore appears EXACTLY ONCE in this file, and the
386
+ // test fails loudly rather than scanning the wrong region if a second copy
387
+ // ever shows up -- and asserts the region before it performs no
388
+ // filesystem, subprocess or network I/O and reads no `process.` property. So
389
+ // the purity claim in this file's header stays a checked property rather than
390
+ // becoming a comment that a later edit quietly falsified -- it just applies to
391
+ // the library region, which is the region every importer gets.
392
+ //
393
+ // WHY THE ENTRY POINT LIVES HERE AT ALL, rather than in a sibling CLI module:
394
+ // the skill-side wrapper needs a route to this layout knowledge across a
395
+ // package boundary. THE CONSTRAINT, MEASURED: the MCP server ships as one
396
+ // npm package whose `files[]` covers only `src/mcp/vice/`, the skills ship
397
+ // in the other package, and a plain cross-package import resolves on
398
+ // neither installer route. This project's own precedent for that exact
399
+ // constraint (the MCP-side disk-image reader deleted in Phase 40 plan
400
+ // 40-06, once it moved to the seam that now provides its old capability)
401
+ // answered it with a second, independent copy of a *stable, published*
402
+ // disk format. That answer is wrong for this format: the `.vsf` layout is
403
+ // version-sensitive,
404
+ // this file's header documents one already-stale copy of it, and a second
405
+ // copy of the one authoritative reading of a version-sensitive format is
406
+ // exactly the divergence hazard the single-seam convention exists to remove.
407
+ // So the module carries a CLI entry point and the skill invokes it. The
408
+ // reasoning is repeated in the wrapper's own header, where the next reader of
409
+ // that file will be.
410
+ //
411
+ // WHAT NOT TO DO:
412
+ // - Never let `main()` run on import. The guard at the bottom compares
413
+ // `process.argv[1]` against this module's own URL, with `resolve()` and
414
+ // NOT `realpathSync()`, so the check itself is pure path arithmetic and
415
+ // importing this module still performs no I/O.
416
+ //
417
+ // WHO ACTUALLY IMPORTS THIS (corrected, 33 review WR-07). This line used
418
+ // to say "`capture-predicate.ts` imports it, and so does the structural
419
+ // census". The first half was false: `capture-predicate.ts` imports only
420
+ // `node:crypto`, and names this module in comments alone. A repo-wide
421
+ // grep finds NO non-test module importing it. Today's only non-test
422
+ // consumers are this module's OWN CLI -- invoked as a subprocess by
423
+ // `src/skills/c64-ram-capture/scripts/vsf-slice.mjs`, which is a spawn
424
+ // and not an import -- and `shippedTsModules()`'s structural census,
425
+ // which reads the file rather than importing it either.
426
+ //
427
+ // So the guard has to hold for the CENSUS ALONE, with no real importer
428
+ // exercising it. That is why the correction matters rather than being
429
+ // pedantry: in a codebase where these headers are normative and cited by
430
+ // other files' comments, "a real importer depends on this" is exactly the
431
+ // premise a future editor would use to conclude the guard is already
432
+ // covered and relax it. It is not covered; keep it.
433
+ // - Never rewrite, prefix or soften a refusal message at this boundary. The
434
+ // library's messages already name the offending value and the valid
435
+ // range; the CLI prints `err.message` verbatim, and the skill-side test
436
+ // asserts the module's own wording reaches the caller's stderr.
437
+ // - Never move a byte offset or a length constant down here. Every layout
438
+ // fact belongs above the marker, where the structural guards and the
439
+ // library's own tests can see it.
440
+ // ===========================================================================
441
+
442
+ import { readFileSync, writeFileSync } from "node:fs";
443
+ import { createHash } from "node:crypto";
444
+ import { fileURLToPath } from "node:url";
445
+ import { resolve } from "node:path";
446
+
447
+ const USAGE = `usage: node vsf-slice.ts <verb>
448
+
449
+ slice <snapshot.vsf> --out <image.bin> [--json]
450
+ Slice the C64MEM module's RAM array out of <snapshot.vsf> and write it
451
+ to <image.bin> as exactly 65536 bytes. Prints one summary line carrying
452
+ the C64MEM module minor, the observed body length and the three port
453
+ read-back values (data_out, data_read, dir_read). With --json, prints
454
+ that same summary as one JSON object instead.
455
+
456
+ digest <snapshot.vsf>
457
+ Print the sha256 and the length of the sliced image, writing no file.
458
+
459
+ A malformed snapshot is REFUSED: this module's own message goes to stderr and
460
+ the exit status is non-zero. It is never truncated into a plausible short
461
+ image, and no offset is ever guessed.
462
+ `;
463
+
464
+ interface CliArgs {
465
+ positional: string[];
466
+ out?: string;
467
+ json: boolean;
468
+ }
469
+
470
+ function parseCliArgs(argv: string[]): CliArgs {
471
+ const positional: string[] = [];
472
+ let out: string | undefined;
473
+ let json = false;
474
+ for (let i = 0; i < argv.length; i++) {
475
+ const arg = argv[i];
476
+ if (arg === "--out") {
477
+ const value = argv[i + 1];
478
+ // The `startsWith("--")` half is 33 review IN-02. `argv[i + 1]` was
479
+ // taken unconditionally, so `slice a.vsf --out --json` wrote a 64K file
480
+ // literally NAMED `--json` and silently dropped the JSON output the
481
+ // caller asked for. The sibling parser in derive-transients.mjs already
482
+ // refuses exactly this ("needs a value" when the next token starts with
483
+ // `--`); this mirrors it, so the two CLIs answer the same mistake the
484
+ // same way.
485
+ if (value === undefined || value.startsWith("--")) {
486
+ throw new VsfSliceError(
487
+ `vsf-slice: --out needs a path${value === undefined ? "" : `, but the next token is the flag ${value}`}`,
488
+ );
489
+ }
490
+ out = value;
491
+ i++;
492
+ continue;
493
+ }
494
+ if (arg === "--json") {
495
+ json = true;
496
+ continue;
497
+ }
498
+ if (arg.startsWith("--")) {
499
+ throw new VsfSliceError(`vsf-slice: unknown flag ${arg} -- this CLI has --out and --json`);
500
+ }
501
+ positional.push(arg);
502
+ }
503
+ return { positional, out, json };
504
+ }
505
+
506
+ function sha256(bytes: Uint8Array): string {
507
+ return createHash("sha256").update(bytes).digest("hex");
508
+ }
509
+
510
+ /** The one place that reads a snapshot path off the command line. The library
511
+ * above deliberately has no path parameter, so this is where path resolution
512
+ * lives -- and it stays a thin read with no path arithmetic of its own, so
513
+ * nothing here needs either host/container translation seam. */
514
+ function readSnapshot(path: string): Uint8Array {
515
+ return readFileSync(path);
516
+ }
517
+
518
+ function cmdSlice(argv: string[]): number {
519
+ const { positional, out, json } = parseCliArgs(argv);
520
+ if (positional.length !== 1) {
521
+ console.error(
522
+ `vsf-slice: slice needs exactly one <snapshot.vsf>, got ${positional.length}`,
523
+ );
524
+ return 1;
525
+ }
526
+ if (out === undefined) {
527
+ console.error("vsf-slice: slice needs --out <image.bin> -- refusing to slice with nowhere to put the image");
528
+ return 1;
529
+ }
530
+
531
+ const snapshot = positional[0];
532
+ const slice = sliceC64Mem(readSnapshot(snapshot));
533
+ writeFileSync(out, slice.ram);
534
+
535
+ if (json) {
536
+ console.log(
537
+ JSON.stringify({
538
+ snapshot,
539
+ out,
540
+ imageBytes: slice.ram.length,
541
+ sha256: sha256(slice.ram),
542
+ snapshotMinor: slice.snapshotMinor,
543
+ bodyLength: slice.bodyLength,
544
+ dataOut: slice.dataOut,
545
+ dataRead: slice.dataRead,
546
+ dirRead: slice.dirRead,
547
+ }),
548
+ );
549
+ } else {
550
+ console.log(
551
+ `vsf-slice: wrote ${out} (${slice.ram.length} bytes) from C64MEM minor ` +
552
+ `${slice.snapshotMinor}, body ${slice.bodyLength} bytes; data_out=${slice.dataOut} ` +
553
+ `data_read=${slice.dataRead} dir_read=${slice.dirRead}`,
554
+ );
555
+ }
556
+ return 0;
557
+ }
558
+
559
+ function cmdDigest(argv: string[]): number {
560
+ const { positional, json } = parseCliArgs(argv);
561
+ if (positional.length !== 1) {
562
+ console.error(
563
+ `vsf-slice: digest needs exactly one <snapshot.vsf>, got ${positional.length}`,
564
+ );
565
+ return 1;
566
+ }
567
+
568
+ const snapshot = positional[0];
569
+ const slice = sliceC64Mem(readSnapshot(snapshot));
570
+
571
+ if (json) {
572
+ console.log(
573
+ JSON.stringify({
574
+ snapshot,
575
+ imageBytes: slice.ram.length,
576
+ sha256: sha256(slice.ram),
577
+ snapshotMinor: slice.snapshotMinor,
578
+ bodyLength: slice.bodyLength,
579
+ dataOut: slice.dataOut,
580
+ dataRead: slice.dataRead,
581
+ dirRead: slice.dirRead,
582
+ }),
583
+ );
584
+ } else {
585
+ console.log(`${sha256(slice.ram)} ${slice.ram.length} bytes ${snapshot}`);
586
+ }
587
+ return 0;
588
+ }
589
+
590
+ /** Entry point for the `vsf-slice` CLI. Returns an exit code and never calls
591
+ * `process.exit()` itself -- the guard below does that, exactly once, which
592
+ * keeps this function callable in a test without terminating the runner.
593
+ * Deliberately NOT exported: the module's exported surface is the library
594
+ * above, and every exported function there takes bytes rather than a path. */
595
+ function main(argv: string[]): number {
596
+ const [verb, ...rest] = argv;
597
+ if (verb === undefined) {
598
+ console.error("vsf-slice: no verb given");
599
+ console.log(USAGE);
600
+ return 1;
601
+ }
602
+ if (verb === "--help" || verb === "-h") {
603
+ console.log(USAGE);
604
+ return 0;
605
+ }
606
+
607
+ try {
608
+ switch (verb) {
609
+ case "slice":
610
+ return cmdSlice(rest);
611
+ case "digest":
612
+ return cmdDigest(rest);
613
+ default:
614
+ console.error(
615
+ `vsf-slice: unknown verb "${verb}" -- this CLI has exactly two: slice and digest`,
616
+ );
617
+ console.log(USAGE);
618
+ return 1;
619
+ }
620
+ } catch (err) {
621
+ // VERBATIM, and prefixed with nothing. The library's refusals already
622
+ // name the offending value and the valid range, and the skill-side test
623
+ // matches on that wording reaching the caller's stderr.
624
+ console.error(err instanceof Error ? err.message : String(err));
625
+ return 1;
626
+ }
627
+ }
628
+
629
+ /** True iff this module IS the process entry point. `resolve()` and not
630
+ * `realpathSync()`: path arithmetic only, so importing this module performs no
631
+ * I/O. */
632
+ function isProcessEntryPoint(): boolean {
633
+ const entry = process.argv[1];
634
+ if (entry === undefined) return false;
635
+ return resolve(entry) === fileURLToPath(import.meta.url);
636
+ }
637
+
638
+ if (isProcessEntryPoint()) {
639
+ process.exit(main(process.argv.slice(2)));
640
+ }