@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.
Files changed (35) hide show
  1. package/README.md +61 -0
  2. package/bin/cli.mjs +226 -0
  3. package/package.json +53 -0
  4. package/skills/acme-build/SKILL.md +224 -0
  5. package/skills/acme-build/scripts/acme.mjs +263 -0
  6. package/skills/acme-build/template.a +39 -0
  7. package/skills/c64-memory-mapping/SKILL.md +199 -0
  8. package/skills/c64-memory-mapping/memmap.json +8800 -0
  9. package/skills/c64-memory-mapping/scripts/driver.mjs +553 -0
  10. package/skills/c64-program-recon/SKILL.md +172 -0
  11. package/skills/c64-program-recon/references/control-flow.md +174 -0
  12. package/skills/c64-program-recon/references/graphics.md +73 -0
  13. package/skills/c64-program-recon/references/observation-hazards.md +118 -0
  14. package/skills/c64-program-recon/references/reconstruction.md +128 -0
  15. package/skills/c64-program-recon/references/sound-and-input.md +68 -0
  16. package/skills/c64-program-recon/references/tool-selection.md +55 -0
  17. package/skills/c64-program-recon/scripts/derive.mjs +364 -0
  18. package/skills/c64-program-recon/templates/memory-map.template.md +62 -0
  19. package/skills/c64-provenance-diff/SKILL.md +257 -0
  20. package/skills/c64-provenance-diff/scripts/diff-images.mjs +981 -0
  21. package/skills/c64-provenance-diff/scripts/diff-images.test.mjs +665 -0
  22. package/skills/c64-provenance-diff/scripts/recovery-schema.mjs +383 -0
  23. package/skills/c64-ram-capture/SKILL.md +306 -0
  24. package/skills/c64-ram-capture/scripts/compare.mjs +258 -0
  25. package/skills/c64-ram-capture/scripts/d64-parse.mjs +243 -0
  26. package/skills/c64-ram-capture/scripts/d64-parse.test.mjs +243 -0
  27. package/skills/c64-ram-capture/scripts/dump-artifacts.mjs +317 -0
  28. package/skills/c64-ram-capture/scripts/dump-artifacts.test.mjs +133 -0
  29. package/skills/c64-ram-capture/scripts/project-paths.mjs +81 -0
  30. package/skills/c64-ram-capture/scripts/releases.mjs +109 -0
  31. package/skills/c64-ram-capture/scripts/test-corpus.mjs +75 -0
  32. package/skills/c64-ram-capture/scripts/watch-loads.mjs +575 -0
  33. package/skills/c64-ram-capture/scripts/watch-loads.test.mjs +339 -0
  34. package/skills/c64-ram-capture/templates/capture-record.template.md +59 -0
  35. 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
+ });