@henols/c64-re-tools 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 +1 -1
- package/THIRD-PARTY-NOTICES.md +26 -0
- package/bin/cli.mjs +18 -7
- package/package.json +6 -4
- package/skills/acme-build/SKILL.md +83 -33
- 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 +419 -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 +497 -92
- 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 +11 -6
- package/skills/c64-program-recon/references/sound-and-input.md +6 -8
- package/skills/c64-program-recon/references/tool-selection.md +37 -18
- package/skills/c64-program-recon/scripts/packer-finding.mjs +709 -0
- package/skills/c64-program-recon/templates/memory-map.template.md +27 -13
- package/skills/c64-provenance-diff/SKILL.md +43 -8
- package/skills/c64-provenance-diff/scripts/diff-images.mjs +9 -6
- package/skills/c64-provenance-diff/scripts/recovery-schema.mjs +20 -8
- package/skills/c64-ram-capture/RELEASES.json.example +17 -0
- package/skills/c64-ram-capture/SKILL.md +147 -46
- 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/project-paths.mjs +1 -1
- 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 +19 -13
- 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 +365 -0
- package/skills/routine-queue-walker/scripts/completeness-report.mjs +463 -0
- package/skills/vice-wedge-triage/SKILL.md +104 -97
- package/skills/c64-provenance-diff/scripts/diff-images.test.mjs +0 -665
- package/skills/c64-ram-capture/scripts/d64-parse.mjs +0 -243
- package/skills/c64-ram-capture/scripts/d64-parse.test.mjs +0 -243
- package/skills/c64-ram-capture/scripts/dump-artifacts.test.mjs +0 -133
- package/skills/c64-ram-capture/scripts/test-corpus.mjs +0 -75
- package/skills/c64-ram-capture/scripts/watch-loads.test.mjs +0 -339
|
@@ -1,243 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
// Direct byte-level .d64 parser -- the permanent, sanctioned replacement for
|
|
3
|
-
// the forbidden vice_disk_list tool (T-01-03). Never calls any vice_* tool
|
|
4
|
-
// or touches the emulator at all: this is pure Node over the disk-image
|
|
5
|
-
// bytes, which is why it works whether or not VICE happens to be up.
|
|
6
|
-
//
|
|
7
|
-
// Standard 35-track 1541 layout: BAM at track 18 sector 0, directory chain
|
|
8
|
-
// from track 18 sector 1, four sector-count zones (21/19/18/17 sectors per
|
|
9
|
-
// track). Plain 174848-byte, 35-track, no-error-info images are assumed
|
|
10
|
-
// are plain 174848-byte, 35-track, no-error-info images -- no extended
|
|
11
|
-
// (40-track) or error-byte variants to handle.
|
|
12
|
-
import { readFileSync } from "node:fs";
|
|
13
|
-
import { fileURLToPath } from "node:url";
|
|
14
|
-
import { resolve } from "node:path";
|
|
15
|
-
|
|
16
|
-
const die = (m) => { console.error(`error: ${m}`); process.exit(1); };
|
|
17
|
-
|
|
18
|
-
export function readImage(path) {
|
|
19
|
-
return readFileSync(path);
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
/** The four sector-count zones of a standard 35-track 1541 image. */
|
|
23
|
-
export function sectorsPerTrack(track) {
|
|
24
|
-
if (!Number.isInteger(track) || track < 1 || track > 35) {
|
|
25
|
-
throw new Error(`sectorsPerTrack: track ${track} out of range 1-35`);
|
|
26
|
-
}
|
|
27
|
-
if (track <= 17) return 21;
|
|
28
|
-
if (track <= 24) return 19;
|
|
29
|
-
if (track <= 30) return 18;
|
|
30
|
-
return 17;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
/** Byte offset of the start of {track, sector} in a flat 35-track image. */
|
|
34
|
-
export function tsToOffset(track, sector) {
|
|
35
|
-
if (!Number.isInteger(track) || track < 1 || track > 35) {
|
|
36
|
-
throw new Error(`tsToOffset: track ${track} out of range 1-35`);
|
|
37
|
-
}
|
|
38
|
-
const spt = sectorsPerTrack(track);
|
|
39
|
-
if (!Number.isInteger(sector) || sector < 0 || sector >= spt) {
|
|
40
|
-
throw new Error(`tsToOffset: sector ${sector} out of range for track ${track} (0-${spt - 1}, this track has ${spt} sectors)`);
|
|
41
|
-
}
|
|
42
|
-
let offset = 0;
|
|
43
|
-
for (let t = 1; t < track; t++) offset += sectorsPerTrack(t) * 256;
|
|
44
|
-
return offset + sector * 256;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
function isInImage(track, sector) {
|
|
48
|
-
if (!Number.isInteger(track) || track < 1 || track > 35) return false;
|
|
49
|
-
if (!Number.isInteger(sector) || sector < 0) return false;
|
|
50
|
-
return sector < sectorsPerTrack(track);
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
/**
|
|
54
|
-
* Directory/disk-name bytes are PETSCII, padded with $A0. Every byte this
|
|
55
|
-
* project's two disks actually use in a name (A-Z, digits, space, parens)
|
|
56
|
-
* sits at the same code point in PETSCII as in ASCII/Latin-1, so only the
|
|
57
|
-
* $A0 padding needs stripping -- there is no general PETSCII<->ASCII table
|
|
58
|
-
* here, on purpose, since one is not needed for what these disks contain.
|
|
59
|
-
*/
|
|
60
|
-
function petsciiName(bytes) {
|
|
61
|
-
let end = bytes.length;
|
|
62
|
-
while (end > 0 && bytes[end - 1] === 0xa0) end--;
|
|
63
|
-
return Buffer.from(bytes.subarray(0, end)).toString("latin1");
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
const FILE_TYPES = { 0: "DEL", 1: "SEQ", 2: "PRG", 3: "USR", 4: "REL" };
|
|
67
|
-
|
|
68
|
-
/** Read track 18 sector 0: disk name/id, DOS type, and per-track free counts. */
|
|
69
|
-
export function parseBam(buffer) {
|
|
70
|
-
const off = tsToOffset(18, 0);
|
|
71
|
-
const bam = buffer.subarray(off, off + 256);
|
|
72
|
-
|
|
73
|
-
const perTrack = [];
|
|
74
|
-
for (let t = 1; t <= 35; t++) {
|
|
75
|
-
const eoff = 4 + (t - 1) * 4;
|
|
76
|
-
perTrack.push({
|
|
77
|
-
track: t,
|
|
78
|
-
free: bam[eoff],
|
|
79
|
-
sectors_per_track: sectorsPerTrack(t),
|
|
80
|
-
bitmap: [bam[eoff + 1], bam[eoff + 2], bam[eoff + 3]],
|
|
81
|
-
});
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
const occupiedTracks = perTrack.filter((t) => t.free < t.sectors_per_track).map((t) => t.track);
|
|
85
|
-
const occupiedRanges = [];
|
|
86
|
-
for (const t of occupiedTracks) {
|
|
87
|
-
const last = occupiedRanges[occupiedRanges.length - 1];
|
|
88
|
-
if (last && last.end === t - 1) last.end = t;
|
|
89
|
-
else occupiedRanges.push({ start: t, end: t });
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
return {
|
|
93
|
-
first_dir_track: bam[0],
|
|
94
|
-
first_dir_sector: bam[1],
|
|
95
|
-
dos_version: bam[2],
|
|
96
|
-
disk_name: petsciiName(bam.subarray(0x90, 0x90 + 16)),
|
|
97
|
-
disk_id: Buffer.from(bam.subarray(0xa2, 0xa2 + 2)).toString("latin1"),
|
|
98
|
-
dos_type: Buffer.from(bam.subarray(0xa5, 0xa5 + 2)).toString("latin1"),
|
|
99
|
-
per_track: perTrack,
|
|
100
|
-
occupied_tracks: occupiedTracks,
|
|
101
|
-
occupied_ranges: occupiedRanges,
|
|
102
|
-
};
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
function isTrackFullyFree(bam, track) {
|
|
106
|
-
const entry = bam.per_track.find((t) => t.track === track);
|
|
107
|
-
return !!entry && entry.free === entry.sectors_per_track;
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
/**
|
|
111
|
-
* Walk the directory chain from track 18 sector 1 (by default). Guards
|
|
112
|
-
* against a malicious or corrupt next-sector pointer with a visited set: a
|
|
113
|
-
* sector, once processed, can never be re-entered, so even a
|
|
114
|
-
* self-referential or cyclic pointer stops the walk (reported in
|
|
115
|
-
* `chain_error`) rather than looping forever.
|
|
116
|
-
*
|
|
117
|
-
* Each entry is flagged `suspicious` -- with the specific reason(s) named,
|
|
118
|
-
* never a bare boolean -- when its block count is 0, when its first
|
|
119
|
-
* track/sector falls outside the image, or when its first track/sector
|
|
120
|
-
* points into a track the BAM reports as entirely free (0 sectors
|
|
121
|
-
* allocated): exactly the signature of a faked directory entry that claims
|
|
122
|
-
* a file that was never actually written to disk.
|
|
123
|
-
*/
|
|
124
|
-
export function parseDirectory(buffer, { startTrack = 18, startSector = 1 } = {}) {
|
|
125
|
-
const bam = parseBam(buffer);
|
|
126
|
-
const entries = [];
|
|
127
|
-
const visited = new Set();
|
|
128
|
-
let track = startTrack;
|
|
129
|
-
let sector = startSector;
|
|
130
|
-
let chainError = null;
|
|
131
|
-
|
|
132
|
-
for (;;) {
|
|
133
|
-
const key = `${track}/${sector}`;
|
|
134
|
-
if (visited.has(key)) {
|
|
135
|
-
chainError = `directory chain revisited ${key} -- stopped to avoid an infinite loop (self-referential or cyclic next-sector pointer)`;
|
|
136
|
-
break;
|
|
137
|
-
}
|
|
138
|
-
visited.add(key);
|
|
139
|
-
if (!isInImage(track, sector)) {
|
|
140
|
-
chainError = `directory chain pointer ${key} is outside the image -- stopped`;
|
|
141
|
-
break;
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
const off = tsToOffset(track, sector);
|
|
145
|
-
const sec = buffer.subarray(off, off + 256);
|
|
146
|
-
const nextTrack = sec[0];
|
|
147
|
-
const nextSector = sec[1];
|
|
148
|
-
|
|
149
|
-
for (let i = 0; i < 8; i++) {
|
|
150
|
-
const e = sec.subarray(i * 32, i * 32 + 32);
|
|
151
|
-
const typeByte = e[2];
|
|
152
|
-
const firstTrack = e[3];
|
|
153
|
-
const firstSector = e[4];
|
|
154
|
-
const nameBytes = e.subarray(5, 21);
|
|
155
|
-
const blocks = e[30] | (e[31] << 8);
|
|
156
|
-
|
|
157
|
-
// An all-zero type byte with a blank/padded name is an unused slot,
|
|
158
|
-
// not a file -- never listed as an entry.
|
|
159
|
-
const isEmptySlot =
|
|
160
|
-
typeByte === 0 && firstTrack === 0 && firstSector === 0 &&
|
|
161
|
-
[...nameBytes].every((b) => b === 0xa0 || b === 0x00);
|
|
162
|
-
if (isEmptySlot) continue;
|
|
163
|
-
|
|
164
|
-
const reasons = [];
|
|
165
|
-
if (blocks === 0) reasons.push("block count is 0");
|
|
166
|
-
if (!isInImage(firstTrack, firstSector)) {
|
|
167
|
-
reasons.push(`first track/sector ${firstTrack}/${firstSector} is outside the image`);
|
|
168
|
-
} else if (isTrackFullyFree(bam, firstTrack)) {
|
|
169
|
-
reasons.push(`first track ${firstTrack} is reported entirely free by the BAM (0 sectors allocated) -- the file cannot really start there`);
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
entries.push({
|
|
173
|
-
dir_track: track,
|
|
174
|
-
dir_sector: sector,
|
|
175
|
-
entry_index: i,
|
|
176
|
-
type: FILE_TYPES[typeByte & 0x0f] ?? `unknown(0x${(typeByte & 0x0f).toString(16)})`,
|
|
177
|
-
closed: !!(typeByte & 0x80),
|
|
178
|
-
locked: !!(typeByte & 0x40),
|
|
179
|
-
name: petsciiName(nameBytes),
|
|
180
|
-
first_track: firstTrack,
|
|
181
|
-
first_sector: firstSector,
|
|
182
|
-
blocks,
|
|
183
|
-
suspicious: reasons.length > 0,
|
|
184
|
-
suspicious_reasons: reasons,
|
|
185
|
-
});
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
if (nextTrack === 0) break; // end of chain, by DOS convention
|
|
189
|
-
track = nextTrack;
|
|
190
|
-
sector = nextSector;
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
return { entries, chain_error: chainError };
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
// -------------------------------------------------------------------- CLI
|
|
197
|
-
|
|
198
|
-
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
199
|
-
const [cmd, ...rest] = process.argv.slice(2);
|
|
200
|
-
const opt = (name, fallback) => {
|
|
201
|
-
const i = rest.indexOf(`--${name}`);
|
|
202
|
-
return i === -1 ? fallback : rest[i + 1];
|
|
203
|
-
};
|
|
204
|
-
const jsonFlag = rest.includes("--json");
|
|
205
|
-
|
|
206
|
-
function run() {
|
|
207
|
-
if (cmd !== "directory" && cmd !== "bam") {
|
|
208
|
-
console.log(`usage: node ${fileURLToPath(import.meta.url)} <directory|bam> --image <path.d64> [--json]`);
|
|
209
|
-
process.exitCode = cmd ? 1 : 0;
|
|
210
|
-
return;
|
|
211
|
-
}
|
|
212
|
-
const imagePath = opt("image");
|
|
213
|
-
if (!imagePath) die(`usage: ${cmd} --image <path.d64> [--json]`);
|
|
214
|
-
const buffer = readImage(resolve(imagePath));
|
|
215
|
-
|
|
216
|
-
if (cmd === "directory") {
|
|
217
|
-
const result = parseDirectory(buffer);
|
|
218
|
-
if (jsonFlag) {
|
|
219
|
-
console.log(JSON.stringify(result, null, 2));
|
|
220
|
-
} else {
|
|
221
|
-
for (const e of result.entries) {
|
|
222
|
-
const flag = e.suspicious ? ` SUSPICIOUS: ${e.suspicious_reasons.join("; ")}` : "";
|
|
223
|
-
console.log(`${e.type} "${e.name}" first=${e.first_track}/${e.first_sector} blocks=${e.blocks}${flag}`);
|
|
224
|
-
}
|
|
225
|
-
if (result.chain_error) console.log(`chain error: ${result.chain_error}`);
|
|
226
|
-
}
|
|
227
|
-
return;
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
const bam = parseBam(buffer);
|
|
231
|
-
if (jsonFlag) {
|
|
232
|
-
console.log(JSON.stringify(bam, null, 2));
|
|
233
|
-
} else {
|
|
234
|
-
console.log(`disk name: "${bam.disk_name}" id: ${bam.disk_id} dos type: ${bam.dos_type}`);
|
|
235
|
-
console.log(`first dir sector: ${bam.first_dir_track}/${bam.first_dir_sector}`);
|
|
236
|
-
console.log(
|
|
237
|
-
`occupied track ranges: ${bam.occupied_ranges.map((r) => (r.start === r.end ? `${r.start}` : `${r.start}-${r.end}`)).join(", ")}`
|
|
238
|
-
);
|
|
239
|
-
}
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
run();
|
|
243
|
-
}
|
|
@@ -1,243 +0,0 @@
|
|
|
1
|
-
// Coverage for the direct .d64 byte parser: the track/sector offset
|
|
2
|
-
// arithmetic, the directory-chain walk (including its loop guard), and the
|
|
3
|
-
// suspicious-entry detector -- against synthetic images built in-test (so the
|
|
4
|
-
// detector is proven to FIRE on a genuine defect, not merely proven silent),
|
|
5
|
-
// plus an optional pass over whatever real .d64 corpus the host project ships.
|
|
6
|
-
// Portable: with no corpus present the real-image checks skip, never fail.
|
|
7
|
-
import { test } from "node:test";
|
|
8
|
-
import assert from "node:assert/strict";
|
|
9
|
-
import { readFileSync, readdirSync, existsSync } from "node:fs";
|
|
10
|
-
import { join, basename } from "node:path";
|
|
11
|
-
|
|
12
|
-
import { sectorsPerTrack, tsToOffset, parseBam, parseDirectory, readImage } from "./d64-parse.mjs";
|
|
13
|
-
import { projectRoot } from "./project-paths.mjs";
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
// ------------------------------------------------------------- tsToOffset
|
|
17
|
-
|
|
18
|
-
test("tsToOffset(1, 0) is byte 0", () => {
|
|
19
|
-
assert.equal(tsToOffset(1, 0), 0);
|
|
20
|
-
});
|
|
21
|
-
|
|
22
|
-
test("tsToOffset(18, 0) is the BAM's offset -- sum of tracks 1-17's 21 sectors each", () => {
|
|
23
|
-
assert.equal(tsToOffset(18, 0), 17 * 21 * 256);
|
|
24
|
-
});
|
|
25
|
-
|
|
26
|
-
test("tsToOffset throws for a track below 1", () => {
|
|
27
|
-
assert.throws(() => tsToOffset(0, 0), /track 0 out of range/);
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
test("tsToOffset throws for a track above 35", () => {
|
|
31
|
-
assert.throws(() => tsToOffset(36, 0), /track 36 out of range/);
|
|
32
|
-
});
|
|
33
|
-
|
|
34
|
-
test("tsToOffset throws for a sector beyond the count for that track's zone", () => {
|
|
35
|
-
// Track 1 is in the 21-sectors-per-track zone (sectors 0-20).
|
|
36
|
-
assert.throws(() => tsToOffset(1, 21), /sector 21 out of range/);
|
|
37
|
-
// Track 30 is in the 18-sectors-per-track zone (sectors 0-17).
|
|
38
|
-
assert.throws(() => tsToOffset(30, 18), /sector 18 out of range/);
|
|
39
|
-
// Track 35 is in the 17-sectors-per-track zone (sectors 0-16).
|
|
40
|
-
assert.throws(() => tsToOffset(35, 17), /sector 17 out of range/);
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
test("sectorsPerTrack covers all four standard 1541 zones", () => {
|
|
44
|
-
assert.equal(sectorsPerTrack(1), 21);
|
|
45
|
-
assert.equal(sectorsPerTrack(17), 21);
|
|
46
|
-
assert.equal(sectorsPerTrack(18), 19);
|
|
47
|
-
assert.equal(sectorsPerTrack(24), 19);
|
|
48
|
-
assert.equal(sectorsPerTrack(25), 18);
|
|
49
|
-
assert.equal(sectorsPerTrack(30), 18);
|
|
50
|
-
assert.equal(sectorsPerTrack(31), 17);
|
|
51
|
-
assert.equal(sectorsPerTrack(35), 17);
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
// -------------------------------------------------------- synthetic image
|
|
55
|
-
|
|
56
|
-
/** A blank, well-formed 35-track image: every BAM track marked fully free. */
|
|
57
|
-
function blankImage() {
|
|
58
|
-
const buf = Buffer.alloc(174848, 0);
|
|
59
|
-
const bamOff = tsToOffset(18, 0);
|
|
60
|
-
buf[bamOff] = 18; // first_dir_track
|
|
61
|
-
buf[bamOff + 1] = 1; // first_dir_sector
|
|
62
|
-
buf[bamOff + 2] = 0x41; // dos version 'A'
|
|
63
|
-
for (let t = 1; t <= 35; t++) {
|
|
64
|
-
const eoff = bamOff + 4 + (t - 1) * 4;
|
|
65
|
-
buf[eoff] = sectorsPerTrack(t); // fully free
|
|
66
|
-
buf[eoff + 1] = 0xff;
|
|
67
|
-
buf[eoff + 2] = 0xff;
|
|
68
|
-
buf[eoff + 3] = 0xff; // top 3 bits unused for <=21-sector zones, harmless
|
|
69
|
-
}
|
|
70
|
-
const nameOff = bamOff + 0x90;
|
|
71
|
-
const nameBuf = Buffer.alloc(16, 0xa0);
|
|
72
|
-
Buffer.from("SYNTHETIC", "latin1").copy(nameBuf);
|
|
73
|
-
nameBuf.copy(buf, nameOff);
|
|
74
|
-
return buf;
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
function writeDirEntry(buf, dirTrack, dirSector, index, { typeByte, firstTrack, firstSector, name, blocks }) {
|
|
78
|
-
const off = tsToOffset(dirTrack, dirSector) + index * 32;
|
|
79
|
-
buf[off + 2] = typeByte;
|
|
80
|
-
buf[off + 3] = firstTrack;
|
|
81
|
-
buf[off + 4] = firstSector;
|
|
82
|
-
const nameBuf = Buffer.alloc(16, 0xa0);
|
|
83
|
-
Buffer.from(name, "latin1").copy(nameBuf);
|
|
84
|
-
nameBuf.copy(buf, off + 5);
|
|
85
|
-
buf[off + 30] = blocks & 0xff;
|
|
86
|
-
buf[off + 31] = (blocks >> 8) & 0xff;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
function markTrackOccupied(buf, track, usedSectors) {
|
|
90
|
-
const bamOff = tsToOffset(18, 0);
|
|
91
|
-
const eoff = bamOff + 4 + (track - 1) * 4;
|
|
92
|
-
buf[eoff] = sectorsPerTrack(track) - usedSectors;
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
test("parseDirectory: a genuine defect (0 blocks, first T/S into a BAM-free track) IS flagged suspicious", () => {
|
|
96
|
-
const buf = blankImage();
|
|
97
|
-
// Entry 0: a real, valid PRG that actually occupies track 5.
|
|
98
|
-
markTrackOccupied(buf, 5, 5);
|
|
99
|
-
writeDirEntry(buf, 18, 1, 0, { typeByte: 0x82, firstTrack: 5, firstSector: 0, name: "REAL FILE", blocks: 5 });
|
|
100
|
-
// Chain the real file's own 5 sectors so the terminator is well-formed
|
|
101
|
-
// (not load-bearing for this test, but keeps the fixture honest).
|
|
102
|
-
for (let s = 0; s < 5; s++) {
|
|
103
|
-
const off = tsToOffset(5, s);
|
|
104
|
-
if (s < 4) { buf[off] = 5; buf[off + 1] = s + 1; } else { buf[off] = 0; buf[off + 1] = 0; }
|
|
105
|
-
}
|
|
106
|
-
// Entry 1: the faked entry -- claims track 6, which the BAM still reports
|
|
107
|
-
// entirely free, and a 0 block count.
|
|
108
|
-
writeDirEntry(buf, 18, 1, 1, { typeByte: 0x82, firstTrack: 6, firstSector: 0, name: "FAKE ENTRY", blocks: 0 });
|
|
109
|
-
|
|
110
|
-
const { entries, chain_error } = parseDirectory(buf);
|
|
111
|
-
assert.equal(chain_error, null);
|
|
112
|
-
assert.equal(entries.length, 2);
|
|
113
|
-
const real = entries.find((e) => e.name.startsWith("REAL FILE"));
|
|
114
|
-
const fake = entries.find((e) => e.name.startsWith("FAKE ENTRY"));
|
|
115
|
-
assert.equal(real.suspicious, false, "a genuinely-allocated file must not be flagged");
|
|
116
|
-
assert.equal(fake.suspicious, true, "0 blocks pointing into a BAM-free track must be flagged");
|
|
117
|
-
assert.ok(fake.suspicious_reasons.some((r) => r.includes("block count is 0")));
|
|
118
|
-
assert.ok(fake.suspicious_reasons.some((r) => r.includes("entirely free")));
|
|
119
|
-
});
|
|
120
|
-
|
|
121
|
-
test("parseDirectory: first track/sector outside the image is flagged with its own reason", () => {
|
|
122
|
-
const buf = blankImage();
|
|
123
|
-
writeDirEntry(buf, 18, 1, 0, { typeByte: 0x82, firstTrack: 40, firstSector: 0, name: "OUT OF RANGE", blocks: 12 });
|
|
124
|
-
const { entries } = parseDirectory(buf);
|
|
125
|
-
assert.equal(entries.length, 1);
|
|
126
|
-
assert.equal(entries[0].suspicious, true);
|
|
127
|
-
assert.ok(entries[0].suspicious_reasons[0].includes("outside the image"));
|
|
128
|
-
});
|
|
129
|
-
|
|
130
|
-
test("parseDirectory: an unused directory slot (blank name, type 0) is not listed as an entry", () => {
|
|
131
|
-
const buf = blankImage(); // no entries written at all
|
|
132
|
-
const { entries } = parseDirectory(buf);
|
|
133
|
-
assert.equal(entries.length, 0);
|
|
134
|
-
});
|
|
135
|
-
|
|
136
|
-
test("parseDirectory: terminates on a self-referential next-sector pointer without looping", () => {
|
|
137
|
-
const buf = blankImage();
|
|
138
|
-
const off = tsToOffset(18, 1);
|
|
139
|
-
buf[off] = 18; // next track: itself
|
|
140
|
-
buf[off + 1] = 1; // next sector: itself
|
|
141
|
-
const { entries, chain_error } = parseDirectory(buf);
|
|
142
|
-
assert.equal(entries.length, 0);
|
|
143
|
-
assert.match(chain_error, /revisited 18\/1/);
|
|
144
|
-
});
|
|
145
|
-
|
|
146
|
-
test("parseDirectory: terminates on a next-sector pointer that cycles back two hops later", () => {
|
|
147
|
-
const buf = blankImage();
|
|
148
|
-
const s1 = tsToOffset(18, 1);
|
|
149
|
-
buf[s1] = 18; buf[s1 + 1] = 2; // 18/1 -> 18/2
|
|
150
|
-
const s2 = tsToOffset(18, 2);
|
|
151
|
-
buf[s2] = 18; buf[s2 + 1] = 1; // 18/2 -> 18/1 (cycle)
|
|
152
|
-
const { chain_error } = parseDirectory(buf);
|
|
153
|
-
assert.match(chain_error, /revisited 18\/1/);
|
|
154
|
-
});
|
|
155
|
-
|
|
156
|
-
test("parseBam: reports free-sector counts and derives occupied ranges", () => {
|
|
157
|
-
const buf = blankImage();
|
|
158
|
-
markTrackOccupied(buf, 5, 21);
|
|
159
|
-
markTrackOccupied(buf, 6, 10);
|
|
160
|
-
const bam = parseBam(buf);
|
|
161
|
-
assert.equal(bam.disk_name, "SYNTHETIC");
|
|
162
|
-
assert.deepEqual(bam.occupied_tracks, [5, 6]);
|
|
163
|
-
assert.deepEqual(bam.occupied_ranges, [{ start: 5, end: 6 }]);
|
|
164
|
-
const t5 = bam.per_track.find((t) => t.track === 5);
|
|
165
|
-
assert.equal(t5.free, 0);
|
|
166
|
-
const t7 = bam.per_track.find((t) => t.track === 7);
|
|
167
|
-
assert.equal(t7.free, 21, "an untouched track stays fully free");
|
|
168
|
-
});
|
|
169
|
-
|
|
170
|
-
// ------------------------------------------------------- optional real corpus
|
|
171
|
-
//
|
|
172
|
-
// Everything above runs on synthetic images and passes in any project. What
|
|
173
|
-
// follows exercises the parser against whatever REAL `.d64` images the host
|
|
174
|
-
// project happens to ship, discovered rather than named, and SKIPS when there
|
|
175
|
-
// are none -- so this file never fails just because it was installed somewhere
|
|
176
|
-
// without a disk corpus.
|
|
177
|
-
//
|
|
178
|
-
// These assertions are deliberately properties of the parser, not facts about
|
|
179
|
-
// any particular disk: a specific image's disk name, entry name or block count
|
|
180
|
-
// is that project's evidence and belongs in that project's own records, not
|
|
181
|
-
// hardcoded in a portable test.
|
|
182
|
-
|
|
183
|
-
const CORPUS = (() => {
|
|
184
|
-
const dir = process.env.C64RE_DISKS_DIR ?? join(projectRoot(), "disks");
|
|
185
|
-
if (!existsSync(dir)) return [];
|
|
186
|
-
return readdirSync(dir)
|
|
187
|
-
.filter((f) => f.toLowerCase().endsWith(".d64"))
|
|
188
|
-
.sort()
|
|
189
|
-
.map((f) => join(dir, f));
|
|
190
|
-
})();
|
|
191
|
-
|
|
192
|
-
const noCorpus =
|
|
193
|
-
CORPUS.length === 0
|
|
194
|
-
? "no .d64 images found -- set C64RE_DISKS_DIR to run the real-corpus checks"
|
|
195
|
-
: false;
|
|
196
|
-
|
|
197
|
-
test("real corpus: every image is a standard 174848-byte 35-track image", { skip: noCorpus }, () => {
|
|
198
|
-
for (const path of CORPUS) {
|
|
199
|
-
const buf = readImage(path);
|
|
200
|
-
assert.equal(buf.length, 174848, `${basename(path)} is ${buf.length} bytes, not a plain 35-track image`);
|
|
201
|
-
}
|
|
202
|
-
});
|
|
203
|
-
|
|
204
|
-
test("real corpus: every image's BAM points at a first directory sector and yields occupied ranges", { skip: noCorpus }, () => {
|
|
205
|
-
for (const path of CORPUS) {
|
|
206
|
-
const bam = parseBam(readImage(path));
|
|
207
|
-
assert.equal(bam.first_dir_track, 18, `${basename(path)}: first dir track should be 18 on a 1541 image`);
|
|
208
|
-
assert.ok(bam.first_dir_sector >= 0, `${basename(path)}: first dir sector missing`);
|
|
209
|
-
assert.ok(
|
|
210
|
-
bam.occupied_ranges.length > 0,
|
|
211
|
-
`${basename(path)}: no occupied track ranges derived -- a real image should allocate something`,
|
|
212
|
-
);
|
|
213
|
-
}
|
|
214
|
-
});
|
|
215
|
-
|
|
216
|
-
test("real corpus: every directory chain walks to a clean end, with no loop guard tripped", { skip: noCorpus }, () => {
|
|
217
|
-
for (const path of CORPUS) {
|
|
218
|
-
const { entries, chain_error } = parseDirectory(readImage(path));
|
|
219
|
-
assert.equal(chain_error, null, `${basename(path)}: directory chain failed -- ${chain_error}`);
|
|
220
|
-
assert.ok(entries.length > 0, `${basename(path)}: no directory entries found`);
|
|
221
|
-
}
|
|
222
|
-
});
|
|
223
|
-
|
|
224
|
-
test("real corpus: a suspicious entry always names its reasons, and a clean one never does", { skip: noCorpus }, () => {
|
|
225
|
-
for (const path of CORPUS) {
|
|
226
|
-
const { entries } = parseDirectory(readImage(path));
|
|
227
|
-
for (const e of entries) {
|
|
228
|
-
assert.equal(typeof e.suspicious, "boolean", `${basename(path)}: "${e.name}" has no suspicious flag`);
|
|
229
|
-
if (e.suspicious) {
|
|
230
|
-
assert.ok(
|
|
231
|
-
e.suspicious_reasons.length > 0,
|
|
232
|
-
`${basename(path)}: "${e.name}" is flagged suspicious with no reason given -- a bare boolean is not a finding`,
|
|
233
|
-
);
|
|
234
|
-
} else {
|
|
235
|
-
assert.deepEqual(
|
|
236
|
-
e.suspicious_reasons,
|
|
237
|
-
[],
|
|
238
|
-
`${basename(path)}: "${e.name}" is not suspicious but carries reasons`,
|
|
239
|
-
);
|
|
240
|
-
}
|
|
241
|
-
}
|
|
242
|
-
}
|
|
243
|
-
});
|
|
@@ -1,133 +0,0 @@
|
|
|
1
|
-
// Coverage for the artifact renderer (01-04 Task 1, Part B): chunk
|
|
2
|
-
// contiguity and the 65536-byte assertion, the gap and overlap refusals,
|
|
3
|
-
// the VIC-bank/screen-base/charset-base derivations against the committed
|
|
4
|
-
// sidecars' own recorded values, and the power-on-pattern run detection
|
|
5
|
-
// that produces `unused` ranges. Runs entirely with no emulator present.
|
|
6
|
-
import { test } from "node:test";
|
|
7
|
-
import assert from "node:assert/strict";
|
|
8
|
-
import { readFileSync } from "node:fs";
|
|
9
|
-
import { fileURLToPath } from "node:url";
|
|
10
|
-
import { dirname, join, resolve } from "node:path";
|
|
11
|
-
|
|
12
|
-
import { assembleImage, sha256Buffer, buildChipState, vicBank, screenBase, buildRangeManifest } from "./dump-artifacts.mjs";
|
|
13
|
-
import { allDumpArtifacts, skipUnless } from "./test-corpus.mjs";
|
|
14
|
-
|
|
15
|
-
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
16
|
-
const REPO_ROOT = resolve(HERE, "..", "..", "..", ".."); // scripts -> skill -> skills -> .claude -> repo root
|
|
17
|
-
|
|
18
|
-
function chunkOf(address, byte, length) {
|
|
19
|
-
// `byte` is a 2-hex-char octet (e.g. "00"); repeating it `length` times
|
|
20
|
-
// yields `length` bytes of hex.
|
|
21
|
-
return { address, hex: byte.repeat(length) };
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
test("assembleImage returns a 65536-byte buffer from ordered contiguous chunk records", () => {
|
|
25
|
-
const chunks = [chunkOf(0, "00", 32768), chunkOf(32768, "ff", 32768)];
|
|
26
|
-
const image = assembleImage(chunks);
|
|
27
|
-
assert.equal(image.length, 65536);
|
|
28
|
-
assert.equal(image[0], 0x00);
|
|
29
|
-
assert.equal(image[32768], 0xff);
|
|
30
|
-
});
|
|
31
|
-
|
|
32
|
-
test("assembleImage accepts chunks out of order and still assembles correctly", () => {
|
|
33
|
-
const chunks = [chunkOf(32768, "ff", 32768), chunkOf(0, "00", 32768)];
|
|
34
|
-
const image = assembleImage(chunks);
|
|
35
|
-
assert.equal(image.length, 65536);
|
|
36
|
-
});
|
|
37
|
-
|
|
38
|
-
test("assembleImage throws naming the address for a one-byte gap", () => {
|
|
39
|
-
const chunks = [chunkOf(0, "00", 32768), chunkOf(32769, "ff", 32767)];
|
|
40
|
-
assert.throws(() => assembleImage(chunks), /gap before address \$8000/);
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
test("assembleImage throws naming the address for a one-byte overlap", () => {
|
|
44
|
-
const chunks = [chunkOf(0, "00", 32769), chunkOf(32768, "ff", 32768)];
|
|
45
|
-
assert.throws(() => assembleImage(chunks), /overlap at address \$8000/);
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
test("assembleImage throws when the total is not exactly 65536 bytes", () => {
|
|
49
|
-
const chunks = [chunkOf(0, "00", 100)];
|
|
50
|
-
assert.throws(() => assembleImage(chunks), /expected exactly 65536/);
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
test("sha256Buffer hashes with node:crypto and is deterministic", () => {
|
|
54
|
-
const buf = Buffer.from("hello", "utf8");
|
|
55
|
-
const a = sha256Buffer(buf);
|
|
56
|
-
const b = sha256Buffer(buf);
|
|
57
|
-
assert.equal(a, b);
|
|
58
|
-
assert.equal(a.length, 64);
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
test("vicBank derives the bank from the two low bits of $DD00, inverted per the memmap table", () => {
|
|
62
|
-
assert.equal(vicBank(0b00), 3);
|
|
63
|
-
assert.equal(vicBank(0b01), 2);
|
|
64
|
-
assert.equal(vicBank(0b10), 1);
|
|
65
|
-
assert.equal(vicBank(0b11), 0);
|
|
66
|
-
assert.equal(vicBank(193), 2); // real committed dd00_raw
|
|
67
|
-
});
|
|
68
|
-
|
|
69
|
-
test("screenBase derives the screen pointer from $D018 bits 4-7 relative to the VIC bank base", () => {
|
|
70
|
-
assert.equal(screenBase(49, 193), 35840); // real committed values
|
|
71
|
-
});
|
|
72
|
-
|
|
73
|
-
// Corpus-driven: re-derive vic_bank / screen_base / charset_base from EVERY
|
|
74
|
-
// committed chip-state sidecar the registry names, and require each to match what
|
|
75
|
-
// that sidecar recorded. Running it over every release is what proves the formula
|
|
76
|
-
// is generic rather than tuned to one machine state -- and with no corpus it
|
|
77
|
-
// skips instead of failing.
|
|
78
|
-
const CHIP_STATES = allDumpArtifacts("chip_state");
|
|
79
|
-
|
|
80
|
-
test("buildChipState reproduces every committed sidecar's recorded vic_bank, screen_base and charset_base",
|
|
81
|
-
{ skip: skipUnless(CHIP_STATES, "committed chip-state sidecars") }, () => {
|
|
82
|
-
for (const { release, label, path } of CHIP_STATES) {
|
|
83
|
-
const committed = JSON.parse(readFileSync(path, "utf8"));
|
|
84
|
-
if (!committed.derived) continue;
|
|
85
|
-
const raw = {
|
|
86
|
-
dd00_raw: committed.derived.dd00_raw,
|
|
87
|
-
d018_raw: committed.derived.d018_raw,
|
|
88
|
-
port01_raw: committed.derived.port01.raw,
|
|
89
|
-
sprite_pointers: committed.derived.sprite_pointers,
|
|
90
|
-
};
|
|
91
|
-
const result = buildChipState(raw);
|
|
92
|
-
const where = `${release}/${label}`;
|
|
93
|
-
assert.equal(result.derived.vic_bank, committed.derived.vic_bank, `${where}: vic_bank`);
|
|
94
|
-
assert.equal(result.derived.screen_base, committed.derived.screen_base, `${where}: screen_base`);
|
|
95
|
-
assert.equal(result.derived.charset_base, committed.derived.charset_base, `${where}: charset_base`);
|
|
96
|
-
}
|
|
97
|
-
});
|
|
98
|
-
|
|
99
|
-
test("buildRangeManifest marks a contiguous power-on-pattern run of at least 16 bytes as kind unused", () => {
|
|
100
|
-
const image = Buffer.alloc(65536, 0xaa);
|
|
101
|
-
// A genuine 20-byte run of $00 in the middle, well clear of the I/O window.
|
|
102
|
-
image.fill(0x00, 4096, 4116);
|
|
103
|
-
const manifest = buildRangeManifest(image, { release: "fake", label: "run1" });
|
|
104
|
-
const hit = manifest.ranges.find((r) => r.start === 4096);
|
|
105
|
-
assert.ok(hit, "expected a range starting at the pattern run");
|
|
106
|
-
assert.equal(hit.kind, "unused");
|
|
107
|
-
assert.equal(hit.end, 4115);
|
|
108
|
-
});
|
|
109
|
-
|
|
110
|
-
test("buildRangeManifest marks the I/O window $D000-$DFFF as kind io and sets classification_state to ranges-only", () => {
|
|
111
|
-
const image = Buffer.alloc(65536, 0xaa);
|
|
112
|
-
const manifest = buildRangeManifest(image, { release: "fake", label: "run1" });
|
|
113
|
-
assert.equal(manifest.classification_state, "ranges-only");
|
|
114
|
-
const io = manifest.ranges.find((r) => r.start === 0xd000);
|
|
115
|
-
assert.ok(io);
|
|
116
|
-
assert.equal(io.kind, "io");
|
|
117
|
-
assert.equal(io.end, 0xdfff);
|
|
118
|
-
});
|
|
119
|
-
|
|
120
|
-
test("buildRangeManifest throws when the image is not exactly 65536 bytes", () => {
|
|
121
|
-
assert.throws(() => buildRangeManifest(Buffer.alloc(100), {}), /exactly 65536 bytes/);
|
|
122
|
-
});
|
|
123
|
-
|
|
124
|
-
test("buildRangeManifest's ranges union covers $0000-$FFFF with no gap and no overlap on a synthetic image", () => {
|
|
125
|
-
const image = Buffer.alloc(65536, 0x11);
|
|
126
|
-
const manifest = buildRangeManifest(image, { release: "fake", label: "run1" });
|
|
127
|
-
let expected = 0;
|
|
128
|
-
for (const r of manifest.ranges) {
|
|
129
|
-
assert.equal(r.start, expected);
|
|
130
|
-
expected = r.end + 1;
|
|
131
|
-
}
|
|
132
|
-
assert.equal(expected, 65536);
|
|
133
|
-
});
|