@henols/vice-mcp 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 +4 -3
- package/THIRD-PARTY-NOTICES.md +423 -25
- package/{r2000-acme-ident.ts → anno-acme-ident.ts} +13 -13
- package/anno-bank.ts +171 -0
- package/anno-cli.ts +3040 -0
- package/{r2000-confidence.ts → anno-confidence.ts} +22 -22
- package/anno-coverage.ts +2465 -0
- package/anno-derive.ts +590 -0
- package/anno-details.ts +169 -0
- package/anno-enum-gen.ts +919 -0
- package/anno-export-asm.ts +2396 -0
- package/anno-graphics.ts +338 -0
- package/anno-hazard-report.ts +1367 -0
- package/anno-import.ts +495 -0
- package/anno-index.ts +150 -0
- package/anno-join.ts +480 -0
- package/{r2000-memmap-render.ts → anno-memmap-render.ts} +236 -95
- package/anno-provenance-ledger.ts +472 -0
- package/{r2000-regbits-gen.ts → anno-regbits-gen.ts} +20 -15
- package/{r2000-regbits.json → anno-regbits.json} +2 -2
- package/anno-register.ts +399 -0
- package/anno-store-export.ts +661 -0
- package/anno-store.ts +4002 -0
- package/anno-symbols.ts +266 -0
- package/anno-tools.ts +3264 -0
- package/anno-types.ts +1909 -0
- package/backend-detect.mts +124 -312
- package/block-class.ts +201 -0
- package/build.ts +4 -2
- package/capture-predicate.ts +597 -0
- package/channel-lock.ts +349 -0
- package/disasm-decoder.ts +14 -14
- package/disasm-opcodes.ts +4 -4
- package/disasm-renderer.ts +2 -2
- package/evid-ingest.ts +217 -0
- package/evid-reconcile.ts +316 -0
- package/host-tool-client.ts +430 -0
- package/hostpath.ts +1 -1
- package/incident-record.ts +23 -12
- package/install-resources.ts +30 -14
- package/memmap-lookup.ts +285 -0
- package/package.json +48 -23
- package/prg-image.ts +118 -0
- package/repo-root.ts +107 -8
- package/resources/backend-detect.mjs +98 -236
- package/resources/broker-control.mjs +189 -16
- package/resources/broker-epoch.mjs +1 -1
- package/resources/broker-kill.mjs +8 -2
- package/resources/broker-launch.mjs +373 -214
- package/resources/broker-state.mjs +64 -18
- package/resources/container-guard.mjs +1 -1
- package/resources/ghidra-project.mjs +790 -0
- package/resources/host-tool.mjs +2561 -0
- package/resources/vice-broker.mjs +330 -184
- package/resources/vice-launcher.sh +130 -12
- package/stock-address.ts +6 -6
- package/stock-cia.ts +2 -2
- package/stock-condition.ts +8 -8
- package/stock-connect.ts +10 -6
- package/stock-derived.ts +29 -37
- package/stock-diagnose.ts +200 -36
- package/stock-dispatch.ts +200 -68
- package/stock-execution.ts +5 -3
- package/stock-handler.ts +1 -1
- package/stock-input.ts +9 -9
- package/stock-machine.ts +17 -6
- package/stock-paths.ts +18 -14
- package/stock-petscii.ts +1 -1
- package/stock-protocol.ts +17 -12
- package/stock-recycle.ts +83 -2
- package/stock-registers.ts +54 -29
- package/stock-reproducible-run.ts +811 -0
- package/stock-run-until.ts +100 -1
- package/stock-sprites.ts +3 -3
- package/stock-symbols.ts +13 -13
- package/stock-timing.ts +2 -2
- package/stock-vicii.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/version.ts +1 -1
- package/vice-broker-client.ts +189 -42
- package/vice-errors.ts +268 -0
- package/vice-proxy.ts +392 -2175
- package/vsf-slice.ts +640 -0
- package/capability-registry.ts +0 -388
- package/r2000-cli.ts +0 -1103
- package/r2000-d64.ts +0 -310
- package/r2000-enum-gen.ts +0 -574
- package/r2000-launch.ts +0 -357
- package/r2000-mcp-client.ts +0 -596
- package/r2000-project.ts +0 -190
- package/r2000-symbols.ts +0 -388
- package/r2000-tools.ts +0 -914
- package/r2000-verify.ts +0 -184
- 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
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
|
+
}
|