@henols/vice-mcp 0.2.0 → 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.
- package/THIRD-PARTY-NOTICES.md +23 -0
- package/package.json +15 -1
- package/r2000-acme-ident.ts +97 -0
- package/r2000-cli.ts +1103 -0
- package/r2000-confidence.ts +233 -0
- package/r2000-d64.ts +310 -0
- package/r2000-enum-gen.ts +574 -0
- package/r2000-launch.ts +357 -0
- package/r2000-mcp-client.ts +596 -0
- package/r2000-memmap-render.ts +531 -0
- package/r2000-project.ts +190 -0
- package/r2000-regbits-gen.ts +416 -0
- package/r2000-regbits.json +1370 -0
- package/r2000-symbols.ts +388 -0
- package/r2000-tools.ts +914 -0
- package/r2000-verify.ts +184 -0
- package/stock-symbols.ts +33 -9
- package/vice-proxy.ts +146 -0
|
@@ -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
|
+
}
|