@henols/vice-mcp 0.1.12 → 0.2.1

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.
@@ -0,0 +1,233 @@
1
+ #!/usr/bin/env node
2
+ // r2000-confidence.ts -- the ONE authoritative place in this repo for D-25's
3
+ // confidence-grade convention: a machine-readable bracket-token prefix inside
4
+ // an r2000 line comment (e.g. `[confirmed-code] observed executing at $0810`).
5
+ //
6
+ // WHY THIS MODULE EXISTS: r2000's own `BlockType` (twelve variants -- Code,
7
+ // Byte, Word, Address, PETSCII, Screencode, four split-table variants,
8
+ // ExternalFile, Undefined; `types.rs:314-331`) carries CLASSIFICATION but no
9
+ // CONFIDENCE axis. `Code` cannot distinguish "PC observed executing" from
10
+ // "reachable via a JSR, never run" -- that distinction is
11
+ // `memory-map.template.md`'s most deliberate feature, and its own text
12
+ // forbids promoting a row by editing its grade (re-verify and restate the
13
+ // evidence instead). Measured (D-25): r2000 line comments persist through
14
+ // save/reload (`user_line_comments`), and both `r2000_get_comments` and
15
+ // `r2000_search_disassembly` (which searches comments by default) can filter
16
+ // on a leading token -- so "show me everything still [unknown]" is a real
17
+ // query today, with NO new storage.
18
+ //
19
+ // WHAT THIS IS THE ONE AUTHORITATIVE PLACE FOR:
20
+ // - the five-grade vocabulary (`CONFIDENCE_GRADES`), copied verbatim from
21
+ // `.claude/skills/c64-program-recon/templates/memory-map.template.md`'s
22
+ // own confidence table -- nowhere else in this repo may hand-write one
23
+ // of these five phrases or bracket tokens as a second copy;
24
+ // - the parser (`parseConfidencePrefix`) that decides whether a comment
25
+ // carries a grade, an ungraded plain comment, or a TYPO'D near-miss that
26
+ // must fail loudly;
27
+ // - the composer (`formatConfidenceComment`) that writes a graded comment,
28
+ // so no caller invents its own spelling of a bracket token;
29
+ // - the query builder (`searchQueryForGrade`) that gives the "still
30
+ // [grade]" search exactly one spelling.
31
+ //
32
+ // THE POINT OF THIS MODULE, STATED PLAINLY: a comment beginning with a
33
+ // bracket token that is NOT exactly one of the five valid tokens must THROW,
34
+ // naming the offending token and listing the five valid ones. A comment with
35
+ // no leading bracket at all is a legal, ungraded comment (`grade: null`) --
36
+ // that is not an error. What must NEVER happen is a TYPO silently degrading
37
+ // into an ungraded comment, because that is exactly how an `[unknown]` row
38
+ // could disappear from the "still unknown" query without anyone noticing.
39
+ //
40
+ // WHAT NOT TO DO, named concretely:
41
+ // - Never accept a near-miss token (wrong case, an underscore instead of a
42
+ // hyphen, a plural, extra whitespace inside the brackets, a genuine
43
+ // typo). Every one of those must throw `R2000ConfidenceGradeError`, not
44
+ // silently degrade to `grade: null`.
45
+ // - Never add a second, address-keyed sidecar store for grades (T-11-
46
+ // SECOND-STORE). Grades live ONLY as this bracket-token prefix inside
47
+ // r2000's own line comments -- a second store keyed by address is
48
+ // exactly the drift class criterion 1 exists to close, and it would not
49
+ // be queryable through the same `r2000_get_comments` /
50
+ // `r2000_search_disassembly` tools this module's whole design depends
51
+ // on.
52
+ // - Never promote a row by editing its grade in place. The template's own
53
+ // text says so, and this module has no "upgrade" or "promote" function
54
+ // by design -- a caller who wants to change a grade calls
55
+ // `r2000_set_comment` again with a freshly composed
56
+ // `formatConfidenceComment()` string, leaving a new comment (or
57
+ // replacing the old one explicitly), never a silent in-place mutation
58
+ // this module would hide.
59
+ // - Never widen `CONFIDENCE_GRADES` without updating
60
+ // `memory-map.template.md`'s own table first -- the template is the
61
+ // source of the vocabulary, this module is its one authoritative
62
+ // runtime copy, and the non-vacuity test below fails if the two drift.
63
+
64
+ export interface ConfidenceGrade {
65
+ /** The bracket token's inner text, e.g. `"confirmed-code"` -- no brackets. */
66
+ readonly token: string;
67
+ /** The full bracket token as it appears in a comment, e.g. `"[confirmed-code]"`. */
68
+ readonly bracket: string;
69
+ /** The human phrase from `memory-map.template.md`'s own confidence table,
70
+ * e.g. `"confirmed code"` (no hyphen -- this is prose, not an identifier). */
71
+ readonly phrase: string;
72
+ /** What the grade means, copied verbatim from the template's "Means" column. */
73
+ readonly meaning: string;
74
+ }
75
+
76
+ /**
77
+ * The five grades from `memory-map.template.md`'s confidence table, in the
78
+ * template's own order. This is the ONE place the vocabulary is written
79
+ * down -- see the module header's "what NOT to do" list.
80
+ */
81
+ export const CONFIDENCE_GRADES: readonly ConfidenceGrade[] = [
82
+ {
83
+ token: "confirmed-code",
84
+ bracket: "[confirmed-code]",
85
+ phrase: "confirmed code",
86
+ meaning: "Executed during tracing, PC observed inside it",
87
+ },
88
+ {
89
+ token: "probable-code",
90
+ bracket: "[probable-code]",
91
+ phrase: "probable code",
92
+ meaning: "Reachable through a JSR/JMP/vector, not yet observed executing",
93
+ },
94
+ {
95
+ token: "confirmed-data",
96
+ bracket: "[confirmed-data]",
97
+ phrase: "confirmed data",
98
+ meaning: "Never hit as an instruction stream across full gameplay coverage",
99
+ },
100
+ {
101
+ token: "probable-data",
102
+ bracket: "[probable-data]",
103
+ phrase: "probable data",
104
+ meaning:
105
+ "Indexed-load target, or matches a data shape (sprite blocks, PETSCII, address tables)",
106
+ },
107
+ {
108
+ token: "unknown",
109
+ bracket: "[unknown]",
110
+ phrase: "unknown",
111
+ meaning: "No reliable interpretation yet",
112
+ },
113
+ ] as const;
114
+
115
+ /** Every valid bracket token, e.g. `["[confirmed-code]", ..., "[unknown]"]`. */
116
+ const VALID_BRACKETS: readonly string[] = CONFIDENCE_GRADES.map((g) => g.bracket);
117
+
118
+ /** Every valid inner token, e.g. `["confirmed-code", ..., "unknown"]`. */
119
+ const VALID_TOKENS: readonly string[] = CONFIDENCE_GRADES.map((g) => g.token);
120
+
121
+ const GRADE_BY_TOKEN: ReadonlyMap<string, ConfidenceGrade> = new Map(
122
+ CONFIDENCE_GRADES.map((g) => [g.token, g]),
123
+ );
124
+
125
+ export interface R2000ConfidenceGradeErrorOptions {
126
+ /** The raw text found between the leading `[` and `]`, verbatim -- may
127
+ * carry the wrong case, stray whitespace, an underscore, or a plural, so a
128
+ * caller can see exactly what was rejected. */
129
+ offendingToken: string;
130
+ }
131
+
132
+ /**
133
+ * Thrown by `parseConfidencePrefix()` when a comment begins with a bracket
134
+ * token that is not exactly one of `CONFIDENCE_GRADES`'s five. Named,
135
+ * carries the offending token as a field, and its message lists all five
136
+ * valid tokens -- mirroring `r2000-launch.ts`'s `R2000ViceFlagError` shape
137
+ * (a named error over a malformed token, rather than a silent strip).
138
+ */
139
+ export class R2000ConfidenceGradeError extends Error {
140
+ offendingToken: string;
141
+
142
+ constructor(message: string, { offendingToken }: R2000ConfidenceGradeErrorOptions) {
143
+ super(message);
144
+ this.name = "R2000ConfidenceGradeError";
145
+ this.offendingToken = offendingToken;
146
+ }
147
+ }
148
+
149
+ export interface ParsedConfidencePrefix {
150
+ /** The matched grade, or `null` for a legal, ungraded plain comment. */
151
+ grade: ConfidenceGrade | null;
152
+ /** The comment text with the leading bracket token (and one following
153
+ * run of whitespace, if any) stripped. Equal to the input when `grade` is
154
+ * `null`. */
155
+ rest: string;
156
+ }
157
+
158
+ /**
159
+ * Extracts a leading `[...]` bracket token from `comment` and resolves it
160
+ * against `CONFIDENCE_GRADES`.
161
+ *
162
+ * - No leading bracket at all (the comment does not start with `[`, or has
163
+ * no closing `]`): returns `{ grade: null, rest: comment }`. A comment
164
+ * with no attempted grade token is legal and ungraded -- this is not an
165
+ * error.
166
+ * - A leading bracket token that matches exactly one of the five valid
167
+ * tokens: returns `{ grade, rest }` with `rest` being the remainder after
168
+ * the bracket and one run of following whitespace.
169
+ * - A leading bracket token that does NOT match exactly one of the five
170
+ * (wrong case, an underscore, a plural, stray whitespace inside the
171
+ * brackets, or a plain typo): THROWS `R2000ConfidenceGradeError`, naming
172
+ * the offending token and listing the five valid ones. This is the whole
173
+ * point of the module -- see the header comment.
174
+ */
175
+ export function parseConfidencePrefix(comment: string): ParsedConfidencePrefix {
176
+ const match = /^\[([^[\]]*)\](\s*)/.exec(comment);
177
+ if (!match) {
178
+ return { grade: null, rest: comment };
179
+ }
180
+
181
+ const innerToken = match[1]!;
182
+ const grade = GRADE_BY_TOKEN.get(innerToken);
183
+ if (!grade) {
184
+ throw new R2000ConfidenceGradeError(
185
+ `"[${innerToken}]" is not a valid confidence grade -- the five valid tokens are ` +
186
+ `${VALID_BRACKETS.join(", ")}. A near-miss (wrong case, an underscore instead of a hyphen, a ` +
187
+ "plural, stray whitespace inside the brackets, or a plain typo) is refused rather than " +
188
+ "silently treated as an ungraded comment, because that is exactly how an [unknown] row " +
189
+ "could disappear from the \"still unknown\" query without anyone noticing.",
190
+ { offendingToken: innerToken },
191
+ );
192
+ }
193
+
194
+ const consumed = match[0]!.length;
195
+ return { grade, rest: comment.slice(consumed) };
196
+ }
197
+
198
+ /**
199
+ * Composes a graded comment: the grade's bracket token, one space, then
200
+ * `evidence`. The ONE place a graded comment is assembled, so no caller
201
+ * invents its own spelling of a bracket token.
202
+ */
203
+ export function formatConfidenceComment(grade: string, evidence: string): string {
204
+ const found = GRADE_BY_TOKEN.get(grade);
205
+ if (!found) {
206
+ throw new R2000ConfidenceGradeError(
207
+ `"${grade}" is not a valid confidence grade token -- the five valid tokens are ` +
208
+ `${VALID_TOKENS.join(", ")}.`,
209
+ { offendingToken: grade },
210
+ );
211
+ }
212
+ return `${found.bracket} ${evidence}`;
213
+ }
214
+
215
+ /**
216
+ * Returns the literal search string that appears verbatim in every comment
217
+ * carrying `grade` -- the bracket token itself, e.g. `"[unknown]"`. Passing
218
+ * this to `r2000_search_disassembly`'s `query` (with `use_regex` left
219
+ * false/omitted) lists every address still carrying that grade. One
220
+ * spelling, so "show me everything still [unknown]" never has two competing
221
+ * queries drifting apart.
222
+ */
223
+ export function searchQueryForGrade(grade: string): string {
224
+ const found = GRADE_BY_TOKEN.get(grade);
225
+ if (!found) {
226
+ throw new R2000ConfidenceGradeError(
227
+ `"${grade}" is not a valid confidence grade token -- the five valid tokens are ` +
228
+ `${VALID_TOKENS.join(", ")}.`,
229
+ { offendingToken: grade },
230
+ );
231
+ }
232
+ return found.bracket;
233
+ }
package/r2000-d64.ts ADDED
@@ -0,0 +1,310 @@
1
+ #!/usr/bin/env node
2
+ // Pure, offline `.d64` directory listing and named-entry byte extraction --
3
+ // the container-side half of D-02's ".d64 is a first-class bootstrap input"
4
+ // requirement.
5
+ //
6
+ // WHY THIS FILE EXISTS HERE, AND NOT AS AN EXTENSION OF
7
+ // `.claude/skills/c64-ram-capture/scripts/d64-parse.mjs`: the researcher's
8
+ // own recommendation (RESEARCH.md Open Question #2) was to extend
9
+ // `d64-parse.mjs` in place, since it already walks the directory chain. That
10
+ // is not reachable in practice: this MCP server ships as `@henols/vice-mcp`,
11
+ // whose `files[]` in `package.json` lists only `.claude/mcp/vice/` contents,
12
+ // while `.claude/skills/**` ships in the *other* package
13
+ // (`@henols/c64-re-tools`). An import from this seam into a skill script
14
+ // cannot resolve on either npm-installer route (neither copies the sibling
15
+ // package's source tree onto disk next to it), and
16
+ // `scripts/check-npm-packages.mjs`'s transitive-closure walk over `files[]`
17
+ // would fail the pack the moment a reachable module sat outside the listed
18
+ // set. So this is a SECOND, independent copy of the sector-chain-walk
19
+ // algorithm, container-side, scoped to exactly what the r2000 bootstrap
20
+ // needs -- not a shared library and not an import of the skill-side module.
21
+ //
22
+ // `d64-parse.mjs` REMAINS the skill-side owner of the algorithm and is left
23
+ // entirely untouched by this phase; this module's job is not to grow beyond
24
+ // the bootstrap's needs, and neither copy should silently drift into a
25
+ // general-purpose disk-image library. If the two ever need to diverge in
26
+ // behaviour, that is a deliberate, documented decision, not an accident of
27
+ // two files existing.
28
+ //
29
+ // WHAT NOT TO DO:
30
+ // - Never auto-pick a directory entry when the caller does not name one
31
+ // (D-02). A silent auto-pick would happily hand a cracktro or loader
32
+ // stub's bytes to the analyser instead of the actual game -- precisely
33
+ // the failure `c64-provenance-diff` exists to prevent elsewhere in this
34
+ // project. Zero matches and multiple matches both throw here; neither
35
+ // returns a guess.
36
+ // - Never walk a sector chain (the directory's own, or an entry's) without
37
+ // the visited-set cycle guard below. A corrupt or adversarial
38
+ // next-track/next-sector pointer must be caught, not looped on.
39
+ // - Never call the process's exit hook or any console-printing function
40
+ // from this module. The fail-loud "no name given -> print the directory
41
+ // listing -> exit non-zero" CLI contract belongs to the caller (plan
42
+ // 10-04's CLI seam),
43
+ // not here -- this module is a pure, offline byte transform with no
44
+ // process-level side effects.
45
+ //
46
+ // Inherited, documented limits (from `d64-parse.mjs`'s own header, carried
47
+ // forward unchanged): plain 174848-byte, 35-track 1541 images only. No
48
+ // error-info-byte variant (175531 bytes) and no 40-track variant are
49
+ // supported -- `assertPlainImage()` below enforces the 174848-byte length
50
+ // and throws naming the actual length otherwise.
51
+
52
+ /** The four sector-count zones of a standard 35-track 1541 image. */
53
+ export function sectorsPerTrack(track: number): number {
54
+ if (!Number.isInteger(track) || track < 1 || track > 35) {
55
+ throw new Error(`sectorsPerTrack: track ${track} out of range 1-35`);
56
+ }
57
+ if (track <= 17) return 21;
58
+ if (track <= 24) return 19;
59
+ if (track <= 30) return 18;
60
+ return 17;
61
+ }
62
+
63
+ /** Byte offset of the start of {track, sector} in a flat 35-track image. */
64
+ export function tsToOffset(track: number, sector: number): number {
65
+ if (!Number.isInteger(track) || track < 1 || track > 35) {
66
+ throw new Error(`tsToOffset: track ${track} out of range 1-35`);
67
+ }
68
+ const spt = sectorsPerTrack(track);
69
+ if (!Number.isInteger(sector) || sector < 0 || sector >= spt) {
70
+ throw new Error(
71
+ `tsToOffset: sector ${sector} out of range for track ${track} (0-${spt - 1}, this track has ${spt} sectors)`,
72
+ );
73
+ }
74
+ let offset = 0;
75
+ for (let t = 1; t < track; t++) offset += sectorsPerTrack(t) * 256;
76
+ return offset + sector * 256;
77
+ }
78
+
79
+ function isInImage(track: number, sector: number): boolean {
80
+ if (!Number.isInteger(track) || track < 1 || track > 35) return false;
81
+ if (!Number.isInteger(sector) || sector < 0) return false;
82
+ return sector < sectorsPerTrack(track);
83
+ }
84
+
85
+ /**
86
+ * WR-05: bounds every sector read against the actual buffer length, not just
87
+ * disk geometry. `isInImage()` above only validates {track, sector} against
88
+ * the standard 35-track zone table -- it has no access to the image bytes
89
+ * and cannot catch a truncated or non-plain image whose geometry is
90
+ * otherwise valid. Without this bound check, a bare subarray read clamps
91
+ * silently to whatever bytes exist, and a caller reading past that point
92
+ * (e.g. `sec[0]`/`sec[1]` for chain pointers, or the payload slice) reads
93
+ * `undefined` and derived garbage with no diagnostic. Used at BOTH sector
94
+ * read sites (the directory walk and the file-chain walk) so the bound
95
+ * check is part of the walk itself, not a separate opt-in the caller can
96
+ * forget -- see `assertPlainImage()` below, which stays a distinct,
97
+ * unrelated whole-image-length check for its one existing caller.
98
+ */
99
+ function sectorSlice(image: Uint8Array, track: number, sector: number, what: string): Uint8Array {
100
+ const off = tsToOffset(track, sector);
101
+ if (off + 256 > image.length) {
102
+ throw new Error(
103
+ `${what}: sector ${track}/${sector} needs bytes ${off}..${off + 255} but the image is only ` +
104
+ `${image.length} bytes -- truncated or non-plain image`,
105
+ );
106
+ }
107
+ return image.subarray(off, off + 256);
108
+ }
109
+
110
+ /**
111
+ * Directory/disk-name bytes are PETSCII, padded with $A0 OR $00 (WR-06: some
112
+ * disk-writing tools, and any image where a slot was partially rewritten,
113
+ * pad with NUL instead). As in `d64-parse.mjs`, every byte this project's
114
+ * disks actually use in a name (A-Z, digits, space, parens) sits at the same
115
+ * code point in PETSCII as in ASCII/Latin-1, so only padding needs
116
+ * stripping -- there is no general PETSCII<->ASCII table here, deliberately,
117
+ * since one is not needed for what these disks contain.
118
+ *
119
+ * This function's padding definition MUST agree with the inline
120
+ * `isEmptySlot` check in `listEntries()` below, which already treats both
121
+ * `0xa0` and `0x00` as filler: a name this function prints (via
122
+ * `listEntries()`) must be a name `extractEntry()`'s `--entry` argument can
123
+ * select, or the printed listing is a dead end (WR-06's reproduced
124
+ * incident).
125
+ */
126
+ function petsciiName(bytes: Uint8Array): string {
127
+ let end = bytes.length;
128
+ while (end > 0 && (bytes[end - 1] === 0xa0 || bytes[end - 1] === 0x00)) end--;
129
+ return Buffer.from(bytes.subarray(0, end)).toString("latin1");
130
+ }
131
+
132
+ const FILE_TYPES: Record<number, string> = { 0: "DEL", 1: "SEQ", 2: "PRG", 3: "USR", 4: "REL" };
133
+
134
+ export interface D64Entry {
135
+ name: string;
136
+ type: string;
137
+ track: number;
138
+ sector: number;
139
+ sizeBlocks: number;
140
+ }
141
+
142
+ /**
143
+ * Walk the directory chain from track 18 sector 1, exactly as
144
+ * `d64-parse.mjs`'s `parseDirectory()` does, returning the flat listing the
145
+ * caller can print. Never picks a "best" or "likely" entry -- that decision
146
+ * belongs to the caller and to `extractEntry()`'s exact-name match below.
147
+ */
148
+ export function listEntries(image: Uint8Array): D64Entry[] {
149
+ const entries: D64Entry[] = [];
150
+ const visited = new Set<string>();
151
+ let track = 18;
152
+ let sector = 1;
153
+
154
+ for (;;) {
155
+ const key = `${track}/${sector}`;
156
+ if (visited.has(key)) {
157
+ throw new Error(
158
+ `listEntries: directory chain revisited ${key} -- stopped to avoid an infinite loop (self-referential or cyclic next-sector pointer)`,
159
+ );
160
+ }
161
+ visited.add(key);
162
+ if (!isInImage(track, sector)) {
163
+ throw new Error(`listEntries: directory chain pointer ${key} is outside the image -- stopped`);
164
+ }
165
+
166
+ const sec = sectorSlice(image, track, sector, "listEntries");
167
+ const nextTrack = sec[0];
168
+ const nextSector = sec[1];
169
+
170
+ for (let i = 0; i < 8; i++) {
171
+ const e = sec.subarray(i * 32, i * 32 + 32);
172
+ const typeByte = e[2];
173
+ const firstTrack = e[3];
174
+ const firstSector = e[4];
175
+ const nameBytes = e.subarray(5, 21);
176
+ const blocks = e[30] | (e[31] << 8);
177
+
178
+ // An all-zero type byte with a blank/padded name is an unused slot,
179
+ // not a file -- never listed as an entry.
180
+ const isEmptySlot =
181
+ typeByte === 0 && firstTrack === 0 && firstSector === 0 &&
182
+ [...nameBytes].every((b) => b === 0xa0 || b === 0x00);
183
+ if (isEmptySlot) continue;
184
+
185
+ entries.push({
186
+ name: petsciiName(nameBytes),
187
+ type: FILE_TYPES[typeByte & 0x0f] ?? `unknown(0x${(typeByte & 0x0f).toString(16)})`,
188
+ track: firstTrack,
189
+ sector: firstSector,
190
+ sizeBlocks: blocks,
191
+ });
192
+ }
193
+
194
+ if (nextTrack === 0) break; // end of chain, by DOS convention
195
+ track = nextTrack;
196
+ sector = nextSector;
197
+ }
198
+
199
+ return entries;
200
+ }
201
+
202
+ /**
203
+ * Resolve `entryName` against `listEntries(image)` by exact,
204
+ * case-insensitive match. Zero matches and multiple matches both throw --
205
+ * D-02's whole point is that this function never guesses. On success,
206
+ * follows that entry's OWN sector chain (starting at its own
207
+ * first_track/first_sector, NOT the fixed directory-chain start) and
208
+ * concatenates each sector's 254 payload bytes, honouring the 1541 DOS
209
+ * end-of-chain convention: when a sector's next-track byte is 0, its
210
+ * next-sector byte instead holds the zero-based offset of the LAST used
211
+ * byte in that final 256-byte sector (so the last sector contributes
212
+ * `usedByte - 1` payload bytes, not 254 -- the payload runs from byte 2 up
213
+ * to and INCLUDING byte `usedByte`).
214
+ *
215
+ * The returned bytes are the file's RAW content INCLUDING its leading 2-byte
216
+ * PRG load address, unmodified -- that is deliberate, since the whole point
217
+ * of this module is to hand bytes straight to `parsePrg()` in
218
+ * `r2000-project.ts`, which expects that same 2-byte header.
219
+ */
220
+ export function extractEntry(image: Uint8Array, entryName: string): Uint8Array {
221
+ const entries = listEntries(image);
222
+ const needle = entryName.toLowerCase();
223
+ const matches = entries.filter((e) => e.name.toLowerCase() === needle);
224
+
225
+ if (matches.length === 0) {
226
+ const available = entries.map((e) => e.name).join(", ") || "(no entries)";
227
+ throw new Error(
228
+ `extractEntry: no entry named "${entryName}" found. Available entries: ${available}`,
229
+ );
230
+ }
231
+ if (matches.length > 1) {
232
+ throw new Error(
233
+ `extractEntry: entry name "${entryName}" is ambiguous -- ${matches.length} entries share this name (at ` +
234
+ `${matches.map((m) => `${m.track}/${m.sector}`).join(", ")}). Refusing to pick one; rename or disambiguate on disk.`,
235
+ );
236
+ }
237
+
238
+ const entry = matches[0];
239
+ const chunks: Uint8Array[] = [];
240
+ const visited = new Set<string>();
241
+ let track = entry.track;
242
+ let sector = entry.sector;
243
+
244
+ for (;;) {
245
+ const key = `${track}/${sector}`;
246
+ if (visited.has(key)) {
247
+ throw new Error(
248
+ `extractEntry: sector chain for "${entryName}" revisited ${key} -- stopped to avoid an infinite loop (self-referential or cyclic next-sector pointer)`,
249
+ );
250
+ }
251
+ visited.add(key);
252
+ if (!isInImage(track, sector)) {
253
+ throw new Error(`extractEntry: sector chain for "${entryName}" points to ${key}, which is outside the image`);
254
+ }
255
+
256
+ const sec = sectorSlice(image, track, sector, `extractEntry: "${entryName}"`);
257
+ const nextTrack = sec[0];
258
+ const nextSector = sec[1];
259
+
260
+ if (nextTrack === 0) {
261
+ // Last sector: byte 1 is the zero-based offset of the last used byte
262
+ // in this 256-byte sector (not a next-sector pointer). Payload runs
263
+ // from byte 2 up to and including that offset, so its length is
264
+ // `usedByte - 1` -- e.g. usedByte === 255 means the whole sector past
265
+ // the 2-byte header is payload (254 bytes), matching a non-final
266
+ // sector; a smaller usedByte means fewer payload bytes than that.
267
+ // WR-05: a corrupt/truncated `usedByte` of 0 or 1 previously silently
268
+ // clamped to a zero-length payload via a floor-at-2 clamp, which
269
+ // surfaced downstream only as the confusing
270
+ // `parsePrg: input is 0 byte(s)`. Throw here instead, naming the
271
+ // sector and the observed value.
272
+ const usedByte = nextSector;
273
+ if (usedByte < 2) {
274
+ throw new Error(
275
+ `extractEntry: "${entryName}" final sector ${track}/${sector} reports usedByte ${usedByte}, ` +
276
+ "which is less than the minimum valid value of 2 -- corrupt or non-plain image",
277
+ );
278
+ }
279
+ chunks.push(sec.subarray(2, usedByte + 1));
280
+ break;
281
+ }
282
+
283
+ chunks.push(sec.subarray(2, 256));
284
+ track = nextTrack;
285
+ sector = nextSector;
286
+ }
287
+
288
+ const total = chunks.reduce((n, c) => n + c.length, 0);
289
+ const out = new Uint8Array(total);
290
+ let pos = 0;
291
+ for (const c of chunks) {
292
+ out.set(c, pos);
293
+ pos += c.length;
294
+ }
295
+ return out;
296
+ }
297
+
298
+ /**
299
+ * Enforce the inherited, documented limits: plain 174848-byte, 35-track
300
+ * images only. 40-track and error-info-byte (175531-byte) variants are
301
+ * deliberately out of scope for this phase, exactly as `d64-parse.mjs`
302
+ * documents for the skill-side reader.
303
+ */
304
+ export function assertPlainImage(image: Uint8Array): void {
305
+ if (image.length !== 174848) {
306
+ throw new Error(
307
+ `assertPlainImage: expected a plain 174848-byte, 35-track .d64 image with no error-info bytes, got ${image.length} bytes`,
308
+ );
309
+ }
310
+ }