@henols/c64-re-tools 0.2.2 → 0.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/skills/acme-build/SKILL.md +39 -23
- package/skills/acme-build/scripts/acme.mjs +159 -64
- package/skills/acme-build/template.a +1 -1
- package/skills/c64-disk-access/SKILL.md +156 -0
- package/skills/c64-disk-access/scripts/c1541.mjs +569 -0
- package/skills/c64-memory-mapping/SKILL.md +30 -23
- package/skills/c64-memory-mapping/scripts/driver.mjs +1 -1
- package/skills/c64-petcat/SKILL.md +87 -0
- package/skills/c64-petcat/scripts/petcat.mjs +221 -0
- package/skills/c64-program-recon/SKILL.md +93 -39
- package/skills/c64-program-recon/references/control-flow.md +12 -15
- package/skills/c64-program-recon/references/graphics.md +1 -1
- package/skills/c64-program-recon/references/observation-hazards.md +18 -16
- package/skills/c64-program-recon/references/reconstruction.md +1 -2
- package/skills/c64-program-recon/references/sound-and-input.md +6 -8
- package/skills/c64-program-recon/references/tool-selection.md +36 -17
- package/skills/c64-program-recon/scripts/packer-finding.mjs +165 -87
- package/skills/c64-program-recon/templates/memory-map.template.md +2 -2
- package/skills/c64-provenance-diff/SKILL.md +40 -5
- package/skills/c64-provenance-diff/scripts/diff-images.mjs +8 -8
- package/skills/c64-provenance-diff/scripts/recovery-schema.mjs +7 -5
- package/skills/c64-ram-capture/SKILL.md +112 -44
- package/skills/c64-ram-capture/scripts/compare.mjs +2 -2
- package/skills/c64-ram-capture/scripts/derive-transients.mjs +575 -0
- package/skills/c64-ram-capture/scripts/dump-artifacts.mjs +3 -3
- package/skills/c64-ram-capture/scripts/mcp-module.mjs +174 -0
- package/skills/c64-ram-capture/scripts/releases.mjs +1 -1
- package/skills/c64-ram-capture/scripts/vsf-slice.mjs +147 -0
- package/skills/c64-ram-capture/scripts/watch-loads.mjs +15 -15
- package/skills/c64-ram-capture/templates/capture-record.template.md +44 -4
- package/skills/c64-ram-capture/transients/README.md +136 -0
- package/skills/routine-queue-walker/SKILL.md +114 -22
- package/skills/routine-queue-walker/scripts/completeness-report.mjs +463 -0
- package/skills/vice-wedge-triage/SKILL.md +96 -89
- package/skills/c64-ram-capture/scripts/d64-parse.mjs +0 -243
|
@@ -0,0 +1,569 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// c1541 -> disk-image reader driver. Read-only: directory, block allocation
|
|
3
|
+
// map, a named file's sector chain, and a named file's raw bytes. No write,
|
|
4
|
+
// format, delete or any other mutating verb is reachable from this script --
|
|
5
|
+
// deliberately.
|
|
6
|
+
//
|
|
7
|
+
// Reached ONLY through the host-tool execution seam -- the project owner's
|
|
8
|
+
// rule of 2026-08-28 is that this script runs container-side, `c1541` lives
|
|
9
|
+
// host-side, and there is no container PATH to find it on. This file never spawns c1541 itself; it constructs a TYPED
|
|
10
|
+
// request per capability (`c1541.bam`/`c1541.dir`/`c1541.entry`/
|
|
11
|
+
// `c1541.chain`/`c1541.read`) and reads the produced files back off the
|
|
12
|
+
// shared workspace tree, mirroring src/skills/acme-build/scripts/acme.mjs's
|
|
13
|
+
// own invokeSeam() shape verbatim.
|
|
14
|
+
//
|
|
15
|
+
// WHAT NOT TO DO:
|
|
16
|
+
// - Never spawn the disk-image utility (`c1541`) directly from this
|
|
17
|
+
// script, even as a "just this once" fallback. A direct call works on
|
|
18
|
+
// the developer's own host and silently fails inside a container -- the
|
|
19
|
+
// exact failure this seam exists to remove.
|
|
20
|
+
// - Never fall back to reading the image bytes locally (re-parsing the
|
|
21
|
+
// `.d64` in this script) when the seam refuses. A seam refusal is
|
|
22
|
+
// reported as `{ ok: false, message }`; it is never retried by
|
|
23
|
+
// re-implementing the read here.
|
|
24
|
+
import { readFileSync } from "node:fs";
|
|
25
|
+
import { dirname, relative, isAbsolute, resolve, sep } from "node:path";
|
|
26
|
+
import { fileURLToPath } from "node:url";
|
|
27
|
+
import { spawn } from "node:child_process";
|
|
28
|
+
|
|
29
|
+
import { resolveMcpModule, refusalMessage } from "../../c64-ram-capture/scripts/mcp-module.mjs";
|
|
30
|
+
|
|
31
|
+
const SELF = fileURLToPath(import.meta.url);
|
|
32
|
+
const HERE = dirname(SELF);
|
|
33
|
+
|
|
34
|
+
/** The MCP-side module this script reaches -- never imported statically
|
|
35
|
+
* (cross-package: this file ships in `@henols/c64-re-tools`, the seam client
|
|
36
|
+
* ships in `@henols/vice-mcp`), only located via the ladder and invoked with
|
|
37
|
+
* `process.execPath`, the interpreter already running this script, on an
|
|
38
|
+
* in-tree module -- not an external host binary. */
|
|
39
|
+
const HOST_TOOL_CLIENT_FILE = "host-tool-client.ts";
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Invokes the host-tool execution seam for `tool`/`args`, rooted at
|
|
43
|
+
* `repoRoot` for THIS invocation's workspace-relative path resolution.
|
|
44
|
+
* Never rejects: a resolution failure, a spawn failure, or unparseable
|
|
45
|
+
* output all resolve to `{ ok: false, message }` -- the same shape a tool's
|
|
46
|
+
* own refusal uses, so a caller never needs a try/catch. Copied verbatim
|
|
47
|
+
* from acme.mjs's own invokeSeam() -- see this file's own header for why a
|
|
48
|
+
* shared import is not possible across the two npm packages.
|
|
49
|
+
*/
|
|
50
|
+
function invokeSeam(tool, args, repoRoot) {
|
|
51
|
+
return new Promise((resolvePromise) => {
|
|
52
|
+
const resolved = resolveMcpModule(HOST_TOOL_CLIENT_FILE);
|
|
53
|
+
if (!resolved.ok) {
|
|
54
|
+
resolvePromise({ ok: false, message: refusalMessage(HOST_TOOL_CLIENT_FILE, resolved.rungs) });
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const cliArgs = [resolved.path, "run", "--tool", tool, "--args", JSON.stringify(args), "--repo-root", repoRoot];
|
|
59
|
+
let child;
|
|
60
|
+
try {
|
|
61
|
+
child = spawn(process.execPath, cliArgs, { stdio: ["ignore", "pipe", "pipe"] });
|
|
62
|
+
} catch (e) {
|
|
63
|
+
resolvePromise({ ok: false, message: e instanceof Error ? e.message : String(e) });
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
let stdout = "";
|
|
68
|
+
let stderr = "";
|
|
69
|
+
child.stdout.on("data", (chunk) => { stdout += chunk.toString("utf8"); });
|
|
70
|
+
child.stderr.on("data", (chunk) => { stderr += chunk.toString("utf8"); });
|
|
71
|
+
child.on("error", (err) => resolvePromise({ ok: false, message: err.message }));
|
|
72
|
+
child.on("close", () => {
|
|
73
|
+
const lines = stdout.split("\n").filter((line) => line.trim() !== "");
|
|
74
|
+
const last = lines[lines.length - 1];
|
|
75
|
+
if (last === undefined) {
|
|
76
|
+
resolvePromise({ ok: false, message: `host-tool-client.ts produced no output${stderr ? ` (stderr: ${stderr})` : ""}` });
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
try {
|
|
80
|
+
resolvePromise(JSON.parse(last));
|
|
81
|
+
} catch {
|
|
82
|
+
resolvePromise({ ok: false, message: `host-tool-client.ts produced non-JSON output: ${last}` });
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** The smallest common ancestor directory of two absolute paths -- computed,
|
|
89
|
+
* never a fixed guess, so the request's `--repo-root` for THIS invocation is
|
|
90
|
+
* always exactly big enough to contain both the image and the output
|
|
91
|
+
* directory, and no bigger. Copied verbatim from acme.mjs's own
|
|
92
|
+
* commonAncestorDir() -- see this file's own header for why a shared import
|
|
93
|
+
* is not possible. */
|
|
94
|
+
function commonAncestorDir(a, b) {
|
|
95
|
+
const partsA = resolve(a).split(sep);
|
|
96
|
+
const partsB = resolve(b).split(sep);
|
|
97
|
+
const common = [];
|
|
98
|
+
for (let i = 0; i < Math.min(partsA.length, partsB.length); i++) {
|
|
99
|
+
if (partsA[i] === partsB[i]) common.push(partsA[i]);
|
|
100
|
+
else break;
|
|
101
|
+
}
|
|
102
|
+
const joined = common.join(sep);
|
|
103
|
+
return joined === "" ? sep : joined;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** `path.relative()`, except the "same directory" case yields `"."` rather
|
|
107
|
+
* than `""` -- the seam's `resolveWorkspacePath()` refuses an empty string,
|
|
108
|
+
* but accepts `"."` as a no-op relative reference to its own root. */
|
|
109
|
+
function toRel(root, abs) {
|
|
110
|
+
const r = relative(root, abs);
|
|
111
|
+
return r === "" ? "." : r;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// How to refer to this script in hints, from wherever we were run.
|
|
115
|
+
function selfPath() {
|
|
116
|
+
const r = relative(process.cwd(), SELF);
|
|
117
|
+
return !r || r.startsWith("..") || isAbsolute(r) ? SELF : r;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const die = (m) => { console.error(`error: ${m}`); process.exit(1); };
|
|
121
|
+
|
|
122
|
+
// ------------------------------------------------------------- capabilities
|
|
123
|
+
|
|
124
|
+
/** The one place a subcommand name maps to its `host_tool` id. */
|
|
125
|
+
const VERB_TO_TOOL = {
|
|
126
|
+
bam: "c1541.bam",
|
|
127
|
+
dir: "c1541.dir",
|
|
128
|
+
entry: "c1541.entry",
|
|
129
|
+
chain: "c1541.chain",
|
|
130
|
+
read: "c1541.read",
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
// ---------------------------------------------------------------- audit
|
|
134
|
+
//
|
|
135
|
+
// Ports the fakery detector the skill-side pure-parse module carried at its
|
|
136
|
+
// own `parseDirectory()` (that module -- `src/skills/c64-ram-capture/`'s own
|
|
137
|
+
// disk-image reader -- has since been deleted) onto this skill's own
|
|
138
|
+
// seam-reached capabilities, now that `c1541` is the only `.d64` route.
|
|
139
|
+
// Composes THREE
|
|
140
|
+
// existing capabilities -- one `dir` call for names/block counts, one `bam`
|
|
141
|
+
// call for the per-sector allocation map, and one `entry` call PER NAME for
|
|
142
|
+
// that file's own claimed first track/sector and its directory sector's
|
|
143
|
+
// "next directory T/S" pointer -- rather than adding a seventh tool id.
|
|
144
|
+
//
|
|
145
|
+
// Same three named-reason signatures as the replaced parser, PLUS a
|
|
146
|
+
// genuinely SHARPER third signature: the per-SECTOR allocation map
|
|
147
|
+
// `c1541.bam` returns lets this check test the file's EXACT claimed first
|
|
148
|
+
// sector, not merely whether its whole track is free.
|
|
149
|
+
// 1. block count is 0
|
|
150
|
+
// 2. first track/sector is outside the image's own geometry
|
|
151
|
+
// 3. the first SECTOR the allocation map reports free -- the file cannot
|
|
152
|
+
// really start there
|
|
153
|
+
//
|
|
154
|
+
// Plus the chain guard the replaced parser carried and `c1541` itself does
|
|
155
|
+
// not: a visited set over every "next directory
|
|
156
|
+
// T/S" pointer observed, seeded with the directory's own starting sector
|
|
157
|
+
// (18/1, the standard 1541 layout every other convention in this project
|
|
158
|
+
// already assumes), and a second visited set over every entry's own claimed
|
|
159
|
+
// first track/sector -- a repeat in EITHER produces a named `chain_error`.
|
|
160
|
+
//
|
|
161
|
+
// Composition detail worth recording: this audit's outer loop walks a
|
|
162
|
+
// FIXED, already-known list of names (`dir`'s own listing), never a raw
|
|
163
|
+
// track/sector-following walk the way the replaced parser did -- so unlike
|
|
164
|
+
// that parser, this walk cannot loop forever by construction, regardless of
|
|
165
|
+
// the chain guard. The chain guard here is therefore a DETECTION signal
|
|
166
|
+
// (does the disk's own metadata contain a cycle) rather than a hang
|
|
167
|
+
// preventer, and this audit deliberately keeps auditing every remaining
|
|
168
|
+
// name after the first chain_error is recorded, rather than aborting --
|
|
169
|
+
// otherwise a corrupt disk whose FIRST file happens to reveal the cycle
|
|
170
|
+
// would hide every later file's own independent flags, which is exactly the
|
|
171
|
+
// wrong trade for a detector whose job is finding every fabricated entry.
|
|
172
|
+
|
|
173
|
+
/** Strips ANSI colour escape codes -- `c1541`'s own captured output colours
|
|
174
|
+
* "OPENCBM"/"Error" and the seam's refusal message quotes that text
|
|
175
|
+
* verbatim, so an opaque entry-lookup failure reason would otherwise carry
|
|
176
|
+
* raw escape bytes into a human-read report. */
|
|
177
|
+
function stripAnsi(text) {
|
|
178
|
+
// eslint-disable-next-line no-control-regex
|
|
179
|
+
return text.replace(/\x1b\[[0-9;]*m/g, "");
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** The four sector-count zones of a standard 35-track 1541 image -- same
|
|
183
|
+
* table the deleted skill-side pure-parse module used, copied rather than
|
|
184
|
+
* imported since that module no longer exists. */
|
|
185
|
+
export function sectorsPerTrack(track) {
|
|
186
|
+
if (!Number.isInteger(track) || track < 1 || track > 35) return null;
|
|
187
|
+
if (track <= 17) return 21;
|
|
188
|
+
if (track <= 24) return 19;
|
|
189
|
+
if (track <= 30) return 18;
|
|
190
|
+
return 17;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** The directory chain's own starting sector on a standard 1541 image --
|
|
194
|
+
* the deleted skill-side pure-parse module's own default
|
|
195
|
+
* (`{ startTrack = 18, startSector = 1 }`). Seeded into the
|
|
196
|
+
* next-directory visited set BEFORE any entry is
|
|
197
|
+
* read, so a next-directory pointer that refers back to this sector -- a
|
|
198
|
+
* genuine self-reference, the shape this plan's own corrupt fixture plants
|
|
199
|
+
* -- is caught on the very first entry that reports it. */
|
|
200
|
+
const DIRECTORY_START_TRACK = 18;
|
|
201
|
+
const DIRECTORY_START_SECTOR = 1;
|
|
202
|
+
|
|
203
|
+
/** Parses `c1541.dir`'s captured listing into `{ name, blocks }` pairs,
|
|
204
|
+
* skipping the disk-header line (no recognised file-type word) and the
|
|
205
|
+
* trailing "<N> blocks free." line. MEASURED against the committed
|
|
206
|
+
* synthetic.d64/synthetic-corrupt.d64 fixtures. */
|
|
207
|
+
export function parseDirListing(stdout) {
|
|
208
|
+
const entries = [];
|
|
209
|
+
for (const line of stdout.split(/\r?\n/)) {
|
|
210
|
+
const m = line.match(/^\s*(\d+)\s+"([^"]*)"\s+\*?(prg|seq|usr|rel|del)\b/i);
|
|
211
|
+
if (!m) continue;
|
|
212
|
+
entries.push({ name: m[2].replace(/\s+$/, ""), blocks: Number(m[1]) });
|
|
213
|
+
}
|
|
214
|
+
return entries;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Parses `c1541.bam`'s captured allocation grid into `Map<track,
|
|
218
|
+
* Set<allocatedSectorIndex>>`. Each row is a 1-2 digit track number
|
|
219
|
+
* followed by at least two spaces, then a run of `*`/`.` characters (with
|
|
220
|
+
* internal spacing purely for readability, stripped here) -- MEASURED
|
|
221
|
+
* against the committed fixtures; the two header rows (column-index
|
|
222
|
+
* scaffolding) never match this shape, since their own digits are not
|
|
223
|
+
* followed by whitespace. */
|
|
224
|
+
export function parseBamAllocation(stdout) {
|
|
225
|
+
const map = new Map();
|
|
226
|
+
for (const line of stdout.split(/\r?\n/)) {
|
|
227
|
+
const m = line.match(/^\s*(\d{1,2})\s{2,}([*.\s]+)$/);
|
|
228
|
+
if (!m) continue;
|
|
229
|
+
const track = Number(m[1]);
|
|
230
|
+
const cells = m[2].replace(/\s+/g, "");
|
|
231
|
+
const allocated = new Set();
|
|
232
|
+
for (let i = 0; i < cells.length; i++) if (cells[i] === "*") allocated.add(i);
|
|
233
|
+
map.set(track, allocated);
|
|
234
|
+
}
|
|
235
|
+
return map;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Parses a SUCCESSFUL `c1541.entry` response's captured listing for the
|
|
239
|
+
* file's own claimed first track/sector and blocks, plus its directory
|
|
240
|
+
* sector's "Next directory T/S" pointer (a property of the SECTOR the entry
|
|
241
|
+
* lives in, not of the file itself -- every entry sharing that sector
|
|
242
|
+
* reports the same value). Returns `null` if the declared `T/S:` line is
|
|
243
|
+
* absent (should not happen for an `ok:true` response, since the seam's own
|
|
244
|
+
* classifier already required it -- defensive only). */
|
|
245
|
+
export function parseEntryFields(stdout) {
|
|
246
|
+
const tsMatch = stdout.match(/T\/S:\s*(\d+)\/(\d+),\s*(\d+)\s*blocks/);
|
|
247
|
+
if (!tsMatch) return null;
|
|
248
|
+
const nextMatch = stdout.match(/Next directory T\/S:\s*(\d+)\/(\d+)/);
|
|
249
|
+
return {
|
|
250
|
+
firstTrack: Number(tsMatch[1]),
|
|
251
|
+
firstSector: Number(tsMatch[2]),
|
|
252
|
+
blocks: Number(tsMatch[3]),
|
|
253
|
+
nextDirTrack: nextMatch ? Number(nextMatch[1]) : null,
|
|
254
|
+
nextDirSector: nextMatch ? Number(nextMatch[2]) : null,
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** Salvages a claimed first track/sector out of a REFUSED `c1541.entry`
|
|
259
|
+
* response's own message, for the specific case MEASURED this plan (an
|
|
260
|
+
* out-of-geometry first track/sector makes `c1541` itself fail to read the
|
|
261
|
+
* file before it ever prints a `T/S:` line, so the seam's classifier
|
|
262
|
+
* refuses with no usable stdout at all -- see fixtures/c1541/README.md).
|
|
263
|
+
* The classifier's own refusal message carries a tail of the real captured
|
|
264
|
+
* text, which for this failure mode names the exact track/sector `c1541`
|
|
265
|
+
* tried and could not read. Returns `null` when the message carries no such
|
|
266
|
+
* substring -- the caller then falls back to an opaque "entry lookup
|
|
267
|
+
* failed" reason rather than fabricating a track/sector. */
|
|
268
|
+
export function salvageFirstTsFromRefusal(message) {
|
|
269
|
+
const m = message.match(/Error reading T:(\d+)\s*S:(\d+)/);
|
|
270
|
+
if (!m) return null;
|
|
271
|
+
return { firstTrack: Number(m[1]), firstSector: Number(m[2]) };
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* The pure detector core -- takes already-composed per-name records (each
|
|
276
|
+
* either a resolved `{ name, blocks, firstTrack, firstSector, nextDirTrack,
|
|
277
|
+
* nextDirSector }`, or `{ name, blocks, entryFailed: true, reason }` for a
|
|
278
|
+
* name whose `c1541.entry` call itself could not be resolved) plus the BAM
|
|
279
|
+
* allocation map, and applies the three named-reason signatures and the
|
|
280
|
+
* chain guard. Never calls the seam itself -- fully unit-testable with
|
|
281
|
+
* synthetic records, no `c1541` binary required.
|
|
282
|
+
*/
|
|
283
|
+
export function auditEntries(records, { bamAllocated }) {
|
|
284
|
+
const visitedFirstTS = new Set([`${DIRECTORY_START_TRACK}/${DIRECTORY_START_SECTOR}`]);
|
|
285
|
+
const visitedNextDir = new Set([`${DIRECTORY_START_TRACK}/${DIRECTORY_START_SECTOR}`]);
|
|
286
|
+
let chainError = null;
|
|
287
|
+
const entries = [];
|
|
288
|
+
|
|
289
|
+
for (const r of records) {
|
|
290
|
+
const reasons = [];
|
|
291
|
+
let firstTrack = null;
|
|
292
|
+
let firstSector = null;
|
|
293
|
+
|
|
294
|
+
if (r.entryFailed) {
|
|
295
|
+
if (r.salvagedFirstTrack !== undefined && r.salvagedFirstTrack !== null) {
|
|
296
|
+
// A concise reason -- the "first track/sector ... outside the image"
|
|
297
|
+
// reason below already names the salvaged value, so this only notes
|
|
298
|
+
// WHY the entry lookup itself never printed a T/S: line, without
|
|
299
|
+
// repeating the full raw transcript (ANSI colour codes and all).
|
|
300
|
+
reasons.push("c1541 could not read this entry's own directory record at all -- see the geometry reason below");
|
|
301
|
+
firstTrack = r.salvagedFirstTrack;
|
|
302
|
+
firstSector = r.salvagedFirstSector;
|
|
303
|
+
} else {
|
|
304
|
+
reasons.push(`c1541 could not resolve this entry's directory record: ${stripAnsi(r.reason)}`);
|
|
305
|
+
}
|
|
306
|
+
} else {
|
|
307
|
+
firstTrack = r.firstTrack;
|
|
308
|
+
firstSector = r.firstSector;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
if (r.blocks === 0) reasons.push("block count is 0");
|
|
312
|
+
|
|
313
|
+
if (firstTrack !== null) {
|
|
314
|
+
const spt = sectorsPerTrack(firstTrack);
|
|
315
|
+
const inGeometry = spt !== null && firstSector !== null && firstSector >= 0 && firstSector < spt;
|
|
316
|
+
if (!inGeometry) {
|
|
317
|
+
reasons.push(`first track/sector ${firstTrack}/${firstSector} is outside the image`);
|
|
318
|
+
} else {
|
|
319
|
+
const allocated = bamAllocated.get(firstTrack);
|
|
320
|
+
if (allocated && !allocated.has(firstSector)) {
|
|
321
|
+
reasons.push(
|
|
322
|
+
`first sector ${firstTrack}/${firstSector} is reported free by the allocation map -- the file cannot really start there`,
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
const key = `${firstTrack}/${firstSector}`;
|
|
326
|
+
if (visitedFirstTS.has(key) && chainError === null) {
|
|
327
|
+
chainError = `two entries claim the same first track/sector ${key} -- a fabricated or corrupted directory record`;
|
|
328
|
+
}
|
|
329
|
+
visitedFirstTS.add(key);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
if (!r.entryFailed && r.nextDirTrack !== null && r.nextDirTrack !== 0) {
|
|
334
|
+
const nextKey = `${r.nextDirTrack}/${r.nextDirSector}`;
|
|
335
|
+
if (visitedNextDir.has(nextKey) && chainError === null) {
|
|
336
|
+
chainError = `directory chain revisited ${nextKey} -- stopped to avoid an infinite loop (self-referential or cyclic next-sector pointer)`;
|
|
337
|
+
}
|
|
338
|
+
visitedNextDir.add(nextKey);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
entries.push({
|
|
342
|
+
name: r.name,
|
|
343
|
+
blocks: r.blocks,
|
|
344
|
+
first_track: firstTrack,
|
|
345
|
+
first_sector: firstSector,
|
|
346
|
+
suspicious: reasons.length > 0,
|
|
347
|
+
suspicious_reasons: reasons,
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
return { entries, chain_error: chainError };
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** Reads back the produced listing file for an `ok:true` seam response's
|
|
355
|
+
* first result -- mirrors `augmentEntryResponse()`'s own read-back
|
|
356
|
+
* convention above (display-purposed parsing over an already-decided
|
|
357
|
+
* response, never a second oracle). Returns `""` if the file cannot be
|
|
358
|
+
* read. */
|
|
359
|
+
function readSeamOutputText(response) {
|
|
360
|
+
const path = response.results?.[0]?.path;
|
|
361
|
+
if (!path) return "";
|
|
362
|
+
try {
|
|
363
|
+
return readFileSync(path, "utf8");
|
|
364
|
+
} catch {
|
|
365
|
+
return "";
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/** Composes `c1541.dir` + `c1541.bam` + one `c1541.entry` call per name into
|
|
370
|
+
* the pure `auditEntries()` detector above. When ANY composed seam call
|
|
371
|
+
* fails outright (`ok:false`), the whole audit fails with that call's own
|
|
372
|
+
* refusal reported verbatim -- there is no byte-level fallback:
|
|
373
|
+
* never read the image bytes locally when the seam is unreachable, because
|
|
374
|
+
* a fallback that works on the developer's own host and silently fails
|
|
375
|
+
* inside a container is the exact failure this seam exists to remove.
|
|
376
|
+
*/
|
|
377
|
+
async function runAudit(argv) {
|
|
378
|
+
const o = parseOpts(argv);
|
|
379
|
+
if (!o.image) die(`usage: audit --image <path.d64> [--out-dir <dir>] [--json]`);
|
|
380
|
+
|
|
381
|
+
const imageAbs = resolve(o.image);
|
|
382
|
+
const outDirAbs = o.outDir ? resolve(o.outDir) : dirname(imageAbs);
|
|
383
|
+
const repoRoot = commonAncestorDir(dirname(imageAbs), outDirAbs);
|
|
384
|
+
const baseArgs = { image: toRel(repoRoot, imageAbs) };
|
|
385
|
+
if (o.outDir) baseArgs.outDir = toRel(repoRoot, outDirAbs);
|
|
386
|
+
|
|
387
|
+
const dirResp = await invokeSeam("c1541.dir", baseArgs, repoRoot);
|
|
388
|
+
if (!dirResp.ok) {
|
|
389
|
+
report(dirResp, o);
|
|
390
|
+
process.exit(1);
|
|
391
|
+
}
|
|
392
|
+
const names = parseDirListing(readSeamOutputText(dirResp));
|
|
393
|
+
|
|
394
|
+
const bamResp = await invokeSeam("c1541.bam", baseArgs, repoRoot);
|
|
395
|
+
if (!bamResp.ok) {
|
|
396
|
+
report(bamResp, o);
|
|
397
|
+
process.exit(1);
|
|
398
|
+
}
|
|
399
|
+
const bamAllocated = parseBamAllocation(readSeamOutputText(bamResp));
|
|
400
|
+
|
|
401
|
+
const records = [];
|
|
402
|
+
for (const { name, blocks } of names) {
|
|
403
|
+
const entryResp = await invokeSeam("c1541.entry", { ...baseArgs, name }, repoRoot);
|
|
404
|
+
if (entryResp.ok) {
|
|
405
|
+
const fields = parseEntryFields(readSeamOutputText(entryResp));
|
|
406
|
+
records.push(fields ? { name, blocks, ...fields } : { name, blocks, entryFailed: true, reason: "entry response carried no T/S: line" });
|
|
407
|
+
} else {
|
|
408
|
+
const salvaged = salvageFirstTsFromRefusal(entryResp.message);
|
|
409
|
+
records.push({
|
|
410
|
+
name,
|
|
411
|
+
blocks,
|
|
412
|
+
entryFailed: true,
|
|
413
|
+
reason: entryResp.message,
|
|
414
|
+
salvagedFirstTrack: salvaged?.firstTrack ?? null,
|
|
415
|
+
salvagedFirstSector: salvaged?.firstSector ?? null,
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
const result = auditEntries(records, { bamAllocated });
|
|
421
|
+
if (o.json) {
|
|
422
|
+
console.log(JSON.stringify(result));
|
|
423
|
+
} else {
|
|
424
|
+
for (const e of result.entries) {
|
|
425
|
+
const flag = e.suspicious ? ` SUSPICIOUS: ${e.suspicious_reasons.join("; ")}` : "";
|
|
426
|
+
console.log(`"${e.name}" first=${e.first_track}/${e.first_sector} blocks=${e.blocks}${flag}`);
|
|
427
|
+
}
|
|
428
|
+
if (result.chain_error) console.log(`chain error: ${result.chain_error}`);
|
|
429
|
+
}
|
|
430
|
+
process.exit(0);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/** `entry`/`chain`/`read` all require a CBM name -- the seam itself refuses
|
|
434
|
+
* a request missing `name` for these, but failing fast here gives a plain
|
|
435
|
+
* usage message rather than a round-trip to the seam for a mistake this
|
|
436
|
+
* script can already see. */
|
|
437
|
+
const VERBS_REQUIRING_NAME = new Set(["entry", "chain", "read"]);
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* Runs one c1541 capability against `--image` (required), `--name`
|
|
441
|
+
* (required for entry/chain/read, ignored for bam/dir), and `--out-dir`
|
|
442
|
+
* (optional, defaults to the seam's own dirname(image) default exactly as
|
|
443
|
+
* acme.build's own outDir default does). Prints the seam's response
|
|
444
|
+
* verbatim as one line of JSON when `--json` is given -- the response IS
|
|
445
|
+
* the reportable shape (`{ ok, tool, exitStatus, results, stderrTail }` /
|
|
446
|
+
* `{ ok: false, message }`), so no reshaping happens here except for
|
|
447
|
+
* `entry`, whose own listing file this script additionally parses for the
|
|
448
|
+
* first track/sector (see augmentEntryResponse() below).
|
|
449
|
+
*/
|
|
450
|
+
async function runCapability(verb, argv) {
|
|
451
|
+
const tool = VERB_TO_TOOL[verb];
|
|
452
|
+
if (!tool) die(`unknown c1541 capability: ${verb}`);
|
|
453
|
+
|
|
454
|
+
const o = parseOpts(argv);
|
|
455
|
+
if (!o.image) die(`usage: ${verb} --image <path.d64> [--name <cbm-name>] [--out-dir <dir>] [--json]`);
|
|
456
|
+
if (VERBS_REQUIRING_NAME.has(verb) && !o.name) {
|
|
457
|
+
die(`usage: ${verb} --image <path.d64> --name <cbm-name> [--out-dir <dir>] [--json]`);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
const imageAbs = resolve(o.image);
|
|
461
|
+
const outDirAbs = o.outDir ? resolve(o.outDir) : dirname(imageAbs);
|
|
462
|
+
|
|
463
|
+
// Workspace-relative request construction (mirrors acme.mjs's own build()):
|
|
464
|
+
// the root for THIS invocation is the smallest ancestor containing both
|
|
465
|
+
// the image and the output directory.
|
|
466
|
+
const repoRoot = commonAncestorDir(dirname(imageAbs), outDirAbs);
|
|
467
|
+
const args = { image: toRel(repoRoot, imageAbs) };
|
|
468
|
+
if (o.outDir) args.outDir = toRel(repoRoot, outDirAbs);
|
|
469
|
+
if (o.name !== undefined) args.name = o.name;
|
|
470
|
+
|
|
471
|
+
let response = await invokeSeam(tool, args, repoRoot);
|
|
472
|
+
if (verb === "entry") response = augmentEntryResponse(response);
|
|
473
|
+
report(response, o);
|
|
474
|
+
process.exit(response.ok ? 0 : 1);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/** Reads `c1541.entry`'s own listing file back and parses its `T/S:
|
|
478
|
+
* <t>/<s>, <n> blocks` line (MEASURED against the committed fixture,
|
|
479
|
+
* fixtures/c1541/README.md) into numeric `firstTrack`/`firstSector` fields
|
|
480
|
+
* on the response -- never a second copy of the classifier's own oracle;
|
|
481
|
+
* this parses purely for DISPLAY, after the seam has already decided
|
|
482
|
+
* ok/not-ok. A response the seam reported as a failure, or a listing this
|
|
483
|
+
* parse cannot make sense of, is returned UNCHANGED -- never a thrown
|
|
484
|
+
* error and never a fabricated track/sector pair. */
|
|
485
|
+
function augmentEntryResponse(response) {
|
|
486
|
+
if (!response.ok) return response;
|
|
487
|
+
const listingPath = response.results?.[0]?.path;
|
|
488
|
+
if (!listingPath) return response;
|
|
489
|
+
let text;
|
|
490
|
+
try {
|
|
491
|
+
text = readFileSync(listingPath, "utf8");
|
|
492
|
+
} catch {
|
|
493
|
+
return response;
|
|
494
|
+
}
|
|
495
|
+
const m = text.match(/T\/S:\s*(\d+)\/(\d+),\s*(\d+)\s*blocks/);
|
|
496
|
+
if (!m) return response;
|
|
497
|
+
return { ...response, firstTrack: Number(m[1]), firstSector: Number(m[2]) };
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
function report(response, { json }) {
|
|
501
|
+
if (json) {
|
|
502
|
+
console.log(JSON.stringify(response));
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
if (!response.ok) {
|
|
506
|
+
console.error(`c1541 call FAILED: ${response.message}`);
|
|
507
|
+
return;
|
|
508
|
+
}
|
|
509
|
+
for (const r of response.results ?? []) {
|
|
510
|
+
console.log(`${r.path} (${r.byteLength} bytes, sha256 ${r.sha256})`);
|
|
511
|
+
}
|
|
512
|
+
if (response.firstTrack !== undefined) {
|
|
513
|
+
console.log(`first track/sector: ${response.firstTrack}/${response.firstSector}`);
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// ------------------------------------------------------------------ options
|
|
518
|
+
|
|
519
|
+
function parseOpts(argv) {
|
|
520
|
+
const o = { json: false };
|
|
521
|
+
for (let i = 0; i < argv.length; i++) {
|
|
522
|
+
const a = argv[i];
|
|
523
|
+
if (a === "--json") o.json = true;
|
|
524
|
+
else if (a === "--image") o.image = argv[++i];
|
|
525
|
+
else if (a === "--name") o.name = argv[++i];
|
|
526
|
+
else if (a === "--out-dir") o.outDir = argv[++i];
|
|
527
|
+
}
|
|
528
|
+
return o;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// --------------------------------------------------------------------- main
|
|
532
|
+
//
|
|
533
|
+
// The CLI dispatch below MUST be guarded to run only when this file is the actual
|
|
534
|
+
// entry point, not merely imported -- c1541.test.mjs (this plan) imports
|
|
535
|
+
// auditEntries()/parseDirListing()/etc. as a pure library, and an unguarded
|
|
536
|
+
// dispatch would run this section with the TEST RUNNER's own process.argv
|
|
537
|
+
// (no recognised command) on every import, printing the usage banner and
|
|
538
|
+
// calling process.exit(0) before a single test() call ever registers.
|
|
539
|
+
// Mirrors the deleted skill-side pure-parse module's own entry-point guard
|
|
540
|
+
// (`resolve(process.argv[1]) === fileURLToPath(import.meta.url)`) rather
|
|
541
|
+
// than inventing a second shape.
|
|
542
|
+
|
|
543
|
+
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
544
|
+
const [cmd, ...rest] = process.argv.slice(2);
|
|
545
|
+
const VERBS = {
|
|
546
|
+
bam: (argv) => runCapability("bam", argv),
|
|
547
|
+
dir: (argv) => runCapability("dir", argv),
|
|
548
|
+
entry: (argv) => runCapability("entry", argv),
|
|
549
|
+
chain: (argv) => runCapability("chain", argv),
|
|
550
|
+
read: (argv) => runCapability("read", argv),
|
|
551
|
+
audit: (argv) => runAudit(argv),
|
|
552
|
+
};
|
|
553
|
+
if (!cmd || !VERBS[cmd]) {
|
|
554
|
+
console.log(`usage: node ${selfPath()} <command> [options]
|
|
555
|
+
|
|
556
|
+
bam --image <path.d64> [--out-dir <dir>] [--json] block allocation map
|
|
557
|
+
dir --image <path.d64> [--out-dir <dir>] [--json] directory listing
|
|
558
|
+
entry --image <path.d64> --name <cbm-name> [--out-dir <dir>] [--json] one directory entry's raw fields
|
|
559
|
+
chain --image <path.d64> --name <cbm-name> [--out-dir <dir>] [--json] a named file's sector chain
|
|
560
|
+
read --image <path.d64> --name <cbm-name> [--out-dir <dir>] [--json] extract a named file's bytes
|
|
561
|
+
audit --image <path.d64> [--out-dir <dir>] [--json] find fabricated/corrupted directory entries
|
|
562
|
+
|
|
563
|
+
Read-only -- no -format/-write/-bwrite/-delete verb is reachable from this script.
|
|
564
|
+
|
|
565
|
+
options: --image PATH --name CBM-NAME --out-dir DIR --json`);
|
|
566
|
+
process.exit(cmd ? 1 : 0);
|
|
567
|
+
}
|
|
568
|
+
await VERBS[cmd](rest);
|
|
569
|
+
}
|