@henols/c64-re-tools 0.1.4
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 +61 -0
- package/bin/cli.mjs +226 -0
- package/package.json +53 -0
- package/skills/acme-build/SKILL.md +224 -0
- package/skills/acme-build/scripts/acme.mjs +263 -0
- package/skills/acme-build/template.a +39 -0
- package/skills/c64-memory-mapping/SKILL.md +199 -0
- package/skills/c64-memory-mapping/memmap.json +8800 -0
- package/skills/c64-memory-mapping/scripts/driver.mjs +553 -0
- package/skills/c64-program-recon/SKILL.md +172 -0
- package/skills/c64-program-recon/references/control-flow.md +174 -0
- package/skills/c64-program-recon/references/graphics.md +73 -0
- package/skills/c64-program-recon/references/observation-hazards.md +118 -0
- package/skills/c64-program-recon/references/reconstruction.md +128 -0
- package/skills/c64-program-recon/references/sound-and-input.md +68 -0
- package/skills/c64-program-recon/references/tool-selection.md +55 -0
- package/skills/c64-program-recon/scripts/derive.mjs +364 -0
- package/skills/c64-program-recon/templates/memory-map.template.md +62 -0
- package/skills/c64-provenance-diff/SKILL.md +257 -0
- package/skills/c64-provenance-diff/scripts/diff-images.mjs +981 -0
- package/skills/c64-provenance-diff/scripts/diff-images.test.mjs +665 -0
- package/skills/c64-provenance-diff/scripts/recovery-schema.mjs +383 -0
- package/skills/c64-ram-capture/SKILL.md +306 -0
- package/skills/c64-ram-capture/scripts/compare.mjs +258 -0
- package/skills/c64-ram-capture/scripts/d64-parse.mjs +243 -0
- package/skills/c64-ram-capture/scripts/d64-parse.test.mjs +243 -0
- package/skills/c64-ram-capture/scripts/dump-artifacts.mjs +317 -0
- package/skills/c64-ram-capture/scripts/dump-artifacts.test.mjs +133 -0
- package/skills/c64-ram-capture/scripts/project-paths.mjs +81 -0
- package/skills/c64-ram-capture/scripts/releases.mjs +109 -0
- package/skills/c64-ram-capture/scripts/test-corpus.mjs +75 -0
- package/skills/c64-ram-capture/scripts/watch-loads.mjs +575 -0
- package/skills/c64-ram-capture/scripts/watch-loads.test.mjs +339 -0
- package/skills/c64-ram-capture/templates/capture-record.template.md +59 -0
- package/skills/vice-wedge-triage/SKILL.md +149 -0
|
@@ -0,0 +1,243 @@
|
|
|
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
|
+
});
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// The artifact renderer (01-04 Task 1, Part B). This module exists because
|
|
3
|
+
// of a structural fact worth stating up front: the executing agent can
|
|
4
|
+
// write text, not binary, so the only shape a committable 65536-byte image
|
|
5
|
+
// can take under the one permitted route to the emulator is *the agent
|
|
6
|
+
// serialises what it fetched via mcp__plugin_c64-re-tools_vice__* tool calls, and a pure
|
|
7
|
+
// function renders it*. Every function below takes already-fetched data as
|
|
8
|
+
// an argument -- nothing here contacts the emulator.
|
|
9
|
+
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
10
|
+
import { createHash } from "node:crypto";
|
|
11
|
+
import { fileURLToPath } from "node:url";
|
|
12
|
+
import { dirname, join, resolve, relative } from "node:path";
|
|
13
|
+
|
|
14
|
+
import { releaseDir } from "./releases.mjs";
|
|
15
|
+
import { projectRoot } from "./project-paths.mjs";
|
|
16
|
+
import { addrNum, hex4 } from "./watch-loads.mjs";
|
|
17
|
+
|
|
18
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
19
|
+
const REPO_ROOT = projectRoot();
|
|
20
|
+
|
|
21
|
+
const die = (m) => { console.error(`error: ${m}`); process.exit(1); };
|
|
22
|
+
|
|
23
|
+
function rel(p) {
|
|
24
|
+
return relative(REPO_ROOT, p);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// ---------------------------------------------------------------- assembleImage
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Assemble the ordered `{ address, hex }` chunk records the agent wrote
|
|
31
|
+
* after its `mcp__plugin_c64-re-tools_vice__vice_memory_read` calls into a 65536-byte buffer.
|
|
32
|
+
* Asserts contiguity from $0000 with no gap and no overlap and a total of
|
|
33
|
+
* exactly 65536 bytes, naming the offending address in every failure.
|
|
34
|
+
*/
|
|
35
|
+
export function assembleImage(chunks) {
|
|
36
|
+
const sorted = [...chunks].sort((a, b) => addrNum(a.address) - addrNum(b.address));
|
|
37
|
+
const bufs = [];
|
|
38
|
+
let expected = 0;
|
|
39
|
+
for (const c of sorted) {
|
|
40
|
+
const addr = addrNum(c.address);
|
|
41
|
+
const buf = Buffer.from(c.hex, "hex");
|
|
42
|
+
if (addr > expected) {
|
|
43
|
+
throw new Error(`assembleImage: gap before address ${hex4(expected)} -- next chunk starts at ${hex4(addr)}`);
|
|
44
|
+
}
|
|
45
|
+
if (addr < expected) {
|
|
46
|
+
throw new Error(`assembleImage: overlap at address ${hex4(addr)} -- a previous chunk already covered up to ${hex4(expected - 1)}`);
|
|
47
|
+
}
|
|
48
|
+
bufs.push(buf);
|
|
49
|
+
expected += buf.length;
|
|
50
|
+
}
|
|
51
|
+
const total = Buffer.concat(bufs);
|
|
52
|
+
if (total.length !== 65536) {
|
|
53
|
+
throw new Error(`assembleImage: assembled ${total.length} bytes ending at ${hex4(expected)}, expected exactly 65536`);
|
|
54
|
+
}
|
|
55
|
+
return total;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** SHA-256 of a buffer, hex-encoded. `node:crypto` only -- no package added (D-18). */
|
|
59
|
+
export function sha256Buffer(buf) {
|
|
60
|
+
return createHash("sha256").update(buf).digest("hex");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ----------------------------------------------------------------- vicBank
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* VIC-II bank number (0-3) from CIA2 port A ($DD00)'s low two bits. The
|
|
67
|
+
* stored value is the INVERSE of the bank number (c64-memory-mapping skill
|
|
68
|
+
* memmap: raw value %00 = "Bank #3" $C000-$FFFF ... %11 = "Bank #0"
|
|
69
|
+
* $0000-$3FFF), so bank = 3 - (raw & 3). Verified against
|
|
70
|
+
* a committed chip-state sidecar's own recorded
|
|
71
|
+
* dd00_raw=193 (0xC1, low bits %01) -> vic_bank=2, which this formula
|
|
72
|
+
* reproduces exactly.
|
|
73
|
+
*/
|
|
74
|
+
export function vicBank(dd00Raw) {
|
|
75
|
+
return 3 - (dd00Raw & 3);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// --------------------------------------------------------------- screenBase
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Screen memory base address, derived from $D018's bits 4-7 (screen pointer,
|
|
82
|
+
* in 1024-byte units relative to the VIC bank) added to the VIC bank's own
|
|
83
|
+
* base (bank * 16384). Verified against the same committed sidecar:
|
|
84
|
+
* dd00_raw=193, d018_raw=49 (0x31) -> screen_base=35840, reproduced exactly.
|
|
85
|
+
*/
|
|
86
|
+
export function screenBase(d018Raw, dd00Raw) {
|
|
87
|
+
const bank = vicBank(dd00Raw);
|
|
88
|
+
const bankBase = bank * 16384;
|
|
89
|
+
const screenOffset = ((d018Raw >> 4) & 0xf) * 1024;
|
|
90
|
+
return bankBase + screenOffset;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Character memory base: $D018 bits 1-3, in 2048-byte units relative to the VIC bank. */
|
|
94
|
+
function charsetBase(d018Raw, dd00Raw) {
|
|
95
|
+
const bank = vicBank(dd00Raw);
|
|
96
|
+
const bankBase = bank * 16384;
|
|
97
|
+
const charsetOffset = ((d018Raw >> 1) & 0x7) * 2048;
|
|
98
|
+
return bankBase + charsetOffset;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// -------------------------------------------------------------- buildChipState
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Build the D-04 chip-state sidecar in the exact shape the committed
|
|
105
|
+
* primary sidecars already use (same top-level keys, same `derived` field
|
|
106
|
+
* set), from the register/state readings the agent recorded. `raw` carries
|
|
107
|
+
* whatever the agent fetched via vice_registers_get / vice_sprite_get /
|
|
108
|
+
* vice_memory_read, keyed to match: `registers`, `sprites`, `cpu` pass
|
|
109
|
+
* through verbatim; `dd00_raw`, `d018_raw`, `port01_raw` and
|
|
110
|
+
* `sprite_pointers` (the bytes read from the sprite-pointer table at
|
|
111
|
+
* screen_base+$3F8..$3FF) feed the derivation.
|
|
112
|
+
*/
|
|
113
|
+
export function buildChipState(raw) {
|
|
114
|
+
const dd00 = raw.dd00_raw;
|
|
115
|
+
const d018 = raw.d018_raw;
|
|
116
|
+
const bank = vicBank(dd00);
|
|
117
|
+
const bankBase = bank * 16384;
|
|
118
|
+
const screenBaseAddr = screenBase(d018, dd00);
|
|
119
|
+
const charsetBaseAddr = charsetBase(d018, dd00);
|
|
120
|
+
const port01raw = raw.port01_raw;
|
|
121
|
+
const spritePointers = raw.sprite_pointers ?? [];
|
|
122
|
+
const spriteDataAddresses = spritePointers.map((p) => bankBase + p * 64);
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
schema_version: 1,
|
|
126
|
+
release: raw.release,
|
|
127
|
+
label: raw.label,
|
|
128
|
+
snapshot_name: raw.snapshot_name ?? null,
|
|
129
|
+
registers: raw.registers,
|
|
130
|
+
sprites: raw.sprites,
|
|
131
|
+
cpu: raw.cpu,
|
|
132
|
+
derived: {
|
|
133
|
+
port01: {
|
|
134
|
+
raw: port01raw,
|
|
135
|
+
loram: !!(port01raw & 1),
|
|
136
|
+
hiram: !!(port01raw & 2),
|
|
137
|
+
charen: !!(port01raw & 4),
|
|
138
|
+
},
|
|
139
|
+
dd00_raw: dd00,
|
|
140
|
+
dd00_direct_read: raw.dd00_direct_read ?? dd00,
|
|
141
|
+
vic_bank: bank,
|
|
142
|
+
d018_raw: d018,
|
|
143
|
+
screen_base: screenBaseAddr,
|
|
144
|
+
charset_base: charsetBaseAddr,
|
|
145
|
+
sprite_pointers: spritePointers,
|
|
146
|
+
sprite_data_addresses: spriteDataAddresses,
|
|
147
|
+
},
|
|
148
|
+
captured_at: raw.captured_at ?? new Date().toISOString(),
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ---------------------------------------------------------- buildRangeManifest
|
|
153
|
+
|
|
154
|
+
const IO_START = 0xd000;
|
|
155
|
+
const IO_END = 0xdfff;
|
|
156
|
+
|
|
157
|
+
function powerOnRunLength(image, start) {
|
|
158
|
+
const b = image[start];
|
|
159
|
+
if (b !== 0x00 && b !== 0xff) return 0;
|
|
160
|
+
let end = start;
|
|
161
|
+
while (end < image.length && image[end] === b) end++;
|
|
162
|
+
return end - start;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Emit the D-02 range manifest in the committed shape: ranges whose union
|
|
167
|
+
* covers $0000-$FFFF with no gap and no overlap, a contiguous power-on-
|
|
168
|
+
* pattern run of at least 16 bytes marked kind `unused`, the I/O window
|
|
169
|
+
* ($D000-$DFFF) marked `io`, everything else `unclassified`, and
|
|
170
|
+
* `classification_state` set to the same transient `ranges-only` state the
|
|
171
|
+
* committed primary manifests carry.
|
|
172
|
+
*/
|
|
173
|
+
export function buildRangeManifest(image, meta = {}) {
|
|
174
|
+
if (image.length !== 65536) {
|
|
175
|
+
throw new Error(`buildRangeManifest: image must be exactly 65536 bytes, got ${image.length}`);
|
|
176
|
+
}
|
|
177
|
+
const ranges = [];
|
|
178
|
+
let i = 0;
|
|
179
|
+
while (i < 65536) {
|
|
180
|
+
if (i >= IO_START && i <= IO_END) {
|
|
181
|
+
ranges.push({ start: i, end: IO_END, kind: "io", source: "capture", note: "VIC-II/SID/CIA/color-RAM I/O window" });
|
|
182
|
+
i = IO_END + 1;
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
const runLen = powerOnRunLength(image, i);
|
|
186
|
+
if (runLen >= 16) {
|
|
187
|
+
const end = i + runLen - 1;
|
|
188
|
+
ranges.push({ start: i, end, kind: "unused", source: "capture", note: "contiguous $00/$FF power-on-pattern run of at least 16 bytes" });
|
|
189
|
+
i = end + 1;
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
let j = i;
|
|
193
|
+
while (j < 65536 && !(j >= IO_START && j <= IO_END) && powerOnRunLength(image, j) < 16) {
|
|
194
|
+
j++;
|
|
195
|
+
}
|
|
196
|
+
ranges.push({ start: i, end: j - 1, kind: "unclassified", source: "capture", note: "awaiting the loader/cracktro/game three-bucket partition" });
|
|
197
|
+
i = j;
|
|
198
|
+
}
|
|
199
|
+
return {
|
|
200
|
+
schema_version: 1,
|
|
201
|
+
release: meta.release,
|
|
202
|
+
label: meta.label,
|
|
203
|
+
snapshot_name: meta.snapshot_name ?? null,
|
|
204
|
+
image_bytes: image.length,
|
|
205
|
+
offset_equals_address: true,
|
|
206
|
+
classification_state: "ranges-only",
|
|
207
|
+
ranges,
|
|
208
|
+
note: meta.note ?? "",
|
|
209
|
+
generated_at: meta.generated_at ?? new Date().toISOString(),
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// -------------------------------------------------------------- writeDumpSet
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Render and write the four-file dump set from a committed chunk file's
|
|
217
|
+
* contents plus the chip-state raw readings. Returns the written paths and
|
|
218
|
+
* the image digest, so a caller can register a `dumps[]` entry.
|
|
219
|
+
*/
|
|
220
|
+
export function writeDumpSet({ releaseId, label, chunks, chipStateRaw, meta = {}, captureExtra = {} }) {
|
|
221
|
+
const image = assembleImage(chunks);
|
|
222
|
+
const digest = sha256Buffer(image);
|
|
223
|
+
const dumpsDir = join(releaseDir(releaseId), "dumps");
|
|
224
|
+
mkdirSync(dumpsDir, { recursive: true });
|
|
225
|
+
|
|
226
|
+
const binPath = join(dumpsDir, `${releaseId}-${label}.bin`);
|
|
227
|
+
writeFileSync(binPath, image);
|
|
228
|
+
|
|
229
|
+
const stateOut = buildChipState({ ...chipStateRaw, release: releaseId, label });
|
|
230
|
+
const statePath = join(dumpsDir, `${releaseId}-${label}.state.json`);
|
|
231
|
+
writeFileSync(statePath, JSON.stringify(stateOut, null, 2) + "\n");
|
|
232
|
+
|
|
233
|
+
const manifestOut = buildRangeManifest(image, { release: releaseId, label, ...meta });
|
|
234
|
+
const mapPath = join(dumpsDir, `${releaseId}-${label}.map.json`);
|
|
235
|
+
writeFileSync(mapPath, JSON.stringify(manifestOut, null, 2) + "\n");
|
|
236
|
+
|
|
237
|
+
const captureOut = {
|
|
238
|
+
release: releaseId,
|
|
239
|
+
label,
|
|
240
|
+
sha256: digest,
|
|
241
|
+
bytes: image.length,
|
|
242
|
+
...captureExtra,
|
|
243
|
+
};
|
|
244
|
+
const capturePath = join(dumpsDir, `${releaseId}-${label}.capture.json`);
|
|
245
|
+
writeFileSync(capturePath, JSON.stringify(captureOut, null, 2) + "\n");
|
|
246
|
+
|
|
247
|
+
return {
|
|
248
|
+
bin: rel(binPath),
|
|
249
|
+
state: rel(statePath),
|
|
250
|
+
map: rel(mapPath),
|
|
251
|
+
capture: rel(capturePath),
|
|
252
|
+
sha256: digest,
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// -------------------------------------------------------------------- CLI
|
|
257
|
+
|
|
258
|
+
function optValue(rest, name) {
|
|
259
|
+
const i = rest.indexOf(`--${name}`);
|
|
260
|
+
return i === -1 ? undefined : rest[i + 1];
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function readJsonArg(rest, name) {
|
|
264
|
+
const p = optValue(rest, name);
|
|
265
|
+
if (!p) return undefined;
|
|
266
|
+
return JSON.parse(readFileSync(resolve(p), "utf8"));
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const VERBS = {
|
|
270
|
+
assemble(rest) {
|
|
271
|
+
const chunks = readJsonArg(rest, "chunks");
|
|
272
|
+
if (!chunks) die("usage: assemble --chunks <chunks.json> [--json]");
|
|
273
|
+
const image = assembleImage(chunks);
|
|
274
|
+
const digest = sha256Buffer(image);
|
|
275
|
+
const result = { bytes: image.length, sha256: digest };
|
|
276
|
+
console.log(rest.includes("--json") ? JSON.stringify(result, null, 2) : `${result.bytes} bytes, sha256 ${result.sha256}`);
|
|
277
|
+
},
|
|
278
|
+
|
|
279
|
+
"chip-state"(rest) {
|
|
280
|
+
const raw = readJsonArg(rest, "raw");
|
|
281
|
+
if (!raw) die("usage: chip-state --raw <raw.json> [--json]");
|
|
282
|
+
const result = buildChipState(raw);
|
|
283
|
+
console.log(JSON.stringify(result, null, 2));
|
|
284
|
+
},
|
|
285
|
+
|
|
286
|
+
manifest(rest) {
|
|
287
|
+
const chunks = readJsonArg(rest, "chunks");
|
|
288
|
+
const metaArg = readJsonArg(rest, "meta") ?? {};
|
|
289
|
+
if (!chunks) die("usage: manifest --chunks <chunks.json> [--meta <meta.json>] [--json]");
|
|
290
|
+
const image = assembleImage(chunks);
|
|
291
|
+
const result = buildRangeManifest(image, metaArg);
|
|
292
|
+
console.log(JSON.stringify(result, null, 2));
|
|
293
|
+
},
|
|
294
|
+
|
|
295
|
+
"write-set"(rest) {
|
|
296
|
+
const releaseId = optValue(rest, "release");
|
|
297
|
+
const label = optValue(rest, "label");
|
|
298
|
+
const chunks = readJsonArg(rest, "chunks");
|
|
299
|
+
const chipStateRaw = readJsonArg(rest, "raw");
|
|
300
|
+
const metaArg = readJsonArg(rest, "meta") ?? {};
|
|
301
|
+
if (!releaseId || !label || !chunks || !chipStateRaw) {
|
|
302
|
+
die("usage: write-set --release <id> --label <label> --chunks <chunks.json> --raw <raw.json> [--meta <meta.json>] [--json]");
|
|
303
|
+
}
|
|
304
|
+
const result = writeDumpSet({ releaseId, label, chunks, chipStateRaw, meta: metaArg });
|
|
305
|
+
console.log(JSON.stringify(result, null, 2));
|
|
306
|
+
},
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
310
|
+
const [cmd, ...rest] = process.argv.slice(2);
|
|
311
|
+
if (!cmd || !VERBS[cmd]) {
|
|
312
|
+
console.log(`usage: node ${fileURLToPath(import.meta.url)} <assemble|chip-state|manifest|write-set> [--json]`);
|
|
313
|
+
process.exitCode = cmd ? 1 : 0;
|
|
314
|
+
} else {
|
|
315
|
+
VERBS[cmd](rest);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
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
|
+
});
|