@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,339 @@
1
+ // Coverage for the on-demand-load detector's pure logic (01-04 Task 1).
2
+ // Every test here runs with no emulator present -- small synthetic fixtures
3
+ // for the boundary/attribution/ordering behaviour, plus two cases that read
4
+ // the real committed sidecars to prove the pure functions reproduce
5
+ // already-recorded evidence. The import-purity guard test at the bottom is
6
+ // the durable, mechanical statement of the one-permitted-route rule: this
7
+ // whole file runs to completion with the emulator absent, and the guard
8
+ // keeps it that way.
9
+ import { test } from "node:test";
10
+ import assert from "node:assert/strict";
11
+ import { readFileSync } from "node:fs";
12
+ import { fileURLToPath } from "node:url";
13
+ import { dirname, join, resolve } from "node:path";
14
+
15
+ import {
16
+ WATCH_SET,
17
+ attributeAddress,
18
+ reportHits,
19
+ idleGate,
20
+ classifyHit,
21
+ screenSignature,
22
+ addrNum,
23
+ hex4,
24
+ } from "./watch-loads.mjs";
25
+ import { buildChipState, buildRangeManifest } from "./dump-artifacts.mjs";
26
+ import { firstDumpArtifact, skipUnless } from "./test-corpus.mjs";
27
+
28
+ const HERE = dirname(fileURLToPath(import.meta.url));
29
+ const REPO_ROOT = resolve(HERE, "..", "..", "..", ".."); // scripts -> skill -> skills -> .claude -> repo root
30
+
31
+ // ----------------------------------------------------------------- addrNum
32
+
33
+ test("addrNum parses $-hex, 0x-hex, decimal strings and numbers", () => {
34
+ assert.equal(addrNum("$08B1"), 0x08b1);
35
+ assert.equal(addrNum("0x08b1"), 0x08b1);
36
+ assert.equal(addrNum("2225"), 2225);
37
+ assert.equal(addrNum(2225), 2225);
38
+ });
39
+
40
+ test("hex4 formats a 4-digit uppercase $ address", () => {
41
+ assert.equal(hex4(0x08b1), "$08B1");
42
+ assert.equal(hex4(0), "$0000");
43
+ });
44
+
45
+ // -------------------------------------------------------------- WATCH_SET
46
+
47
+ function fakeRegistry(loaderRanges) {
48
+ return {
49
+ releases: [
50
+ {
51
+ id: "fake",
52
+ loader_ranges: loaderRanges,
53
+ dumps: [{ label: "run1", range_manifest: "recovery/fake/dumps/fake-run1.map.json" }],
54
+ },
55
+ ],
56
+ };
57
+ }
58
+
59
+ const FAKE_MANIFEST = {
60
+ ranges: [
61
+ { start: 0, end: 15, kind: "unclassified" },
62
+ { start: 16, end: 31, kind: "unused", note: "power-on pattern" },
63
+ { start: 32, end: 63, kind: "unclassified" },
64
+ ],
65
+ };
66
+
67
+ test("WATCH_SET assigns tier stopping to loader-reentry ranges and tier counting to never-populated ranges and the register sentinel", () => {
68
+ const reg = fakeRegistry([{ start: "$0900", end: "$0901", note: "cracktro poll", evidence: "LDA $DC00" }]);
69
+ const set = WATCH_SET("fake", { registry: reg, manifest: FAKE_MANIFEST });
70
+ const tiers = new Map(set.map((s) => [s.kind, s.tier]));
71
+ assert.equal(tiers.get("loader-reentry"), "stopping");
72
+ assert.equal(tiers.get("never-populated"), "counting");
73
+ assert.equal(tiers.get("register"), "counting");
74
+ const dd00 = set.find((s) => s.name === "reg:$DD00");
75
+ assert.ok(dd00, "register sentinel present");
76
+ });
77
+
78
+ test("WATCH_SET throws an actionable message when the release has no loader_ranges recorded", () => {
79
+ const reg = fakeRegistry([]);
80
+ assert.throws(() => WATCH_SET("fake", { registry: reg, manifest: FAKE_MANIFEST }), /no loader_ranges recorded/);
81
+ });
82
+
83
+ // --------------------------------------------------------- attributeAddress
84
+
85
+ test("attributeAddress resolves the first and last byte of every declared range", () => {
86
+ const sentinels = [
87
+ { name: "a", start: 0x0900, end: 0x0910 },
88
+ { name: "b", start: 0x1000, end: 0x1fff },
89
+ ];
90
+ assert.equal(attributeAddress(0x0900, sentinels).name, "a");
91
+ assert.equal(attributeAddress(0x0910, sentinels).name, "a");
92
+ assert.equal(attributeAddress(0x1000, sentinels).name, "b");
93
+ assert.equal(attributeAddress(0x1fff, sentinels).name, "b");
94
+ });
95
+
96
+ test("attributeAddress keeps abutting ranges separate -- exactly one owner at the shared boundary", () => {
97
+ const sentinels = [
98
+ { name: "a", start: 0x1000, end: 0x1fff },
99
+ { name: "b", start: 0x2000, end: 0x2fff },
100
+ ];
101
+ const atBoundaryEnd = attributeAddress(0x1fff, sentinels);
102
+ const atBoundaryStart = attributeAddress(0x2000, sentinels);
103
+ assert.equal(atBoundaryEnd.name, "a");
104
+ assert.equal(atBoundaryStart.name, "b");
105
+ assert.notEqual(atBoundaryEnd.name, atBoundaryStart.name);
106
+ });
107
+
108
+ test("attributeAddress throws naming both sentinels for an overlapping resolved set", () => {
109
+ const sentinels = [
110
+ { name: "a", start: 0x1000, end: 0x1fff },
111
+ { name: "b", start: 0x1800, end: 0x2000 },
112
+ ];
113
+ assert.throws(() => attributeAddress(0x1900, sentinels), (err) => {
114
+ return /overlapping or duplicate/.test(err.message) && err.message.includes("a") && err.message.includes("b");
115
+ });
116
+ });
117
+
118
+ test("attributeAddress returns an explicit unmatched result for an out-of-range address, never the nearest neighbour", () => {
119
+ const sentinels = [{ name: "a", start: 0x1000, end: 0x1fff }];
120
+ const result = attributeAddress(0x5000, sentinels);
121
+ assert.equal(result.matched, false);
122
+ assert.equal(result.name, null);
123
+ });
124
+
125
+ // ------------------------------------------------------------- reportHits
126
+
127
+ test("reportHits orders by cycle, then address, then sentinel name -- swapping input order is stable", () => {
128
+ const hits = [
129
+ { cycle: 5, address: "$1000", sentinel: "z" },
130
+ { cycle: 5, address: "$1000", sentinel: "a" },
131
+ { cycle: 3, address: "$2000", sentinel: "m" },
132
+ ];
133
+ const a = reportHits(hits);
134
+ const b = reportHits([...hits].reverse());
135
+ assert.deepEqual(a, b);
136
+ assert.equal(a[0].cycle, 3);
137
+ assert.equal(a[1].sentinel, "a");
138
+ assert.equal(a[2].sentinel, "z");
139
+ });
140
+
141
+ test("reportHits over an empty hit log returns an empty result rather than throwing", () => {
142
+ assert.deepEqual(reportHits({ hits: [] }), []);
143
+ assert.deepEqual(reportHits([]), []);
144
+ assert.doesNotThrow(() => reportHits({}));
145
+ });
146
+
147
+ test("reportHits is idempotent -- calling twice on the same log produces identical output", () => {
148
+ const log = { hits: [{ cycle: 1, address: "$1000", sentinel: "a" }, { cycle: 1, address: "$1000", sentinel: "b" }] };
149
+ assert.deepEqual(reportHits(log), reportHits(log));
150
+ });
151
+
152
+ // --------------------------------------------------------------- idleGate
153
+
154
+ test("idleGate accepts a calibration in which every stopping-tier sentinel recorded zero hits", () => {
155
+ const cal = {
156
+ cycles_advanced: 12345,
157
+ sentinels: [
158
+ { name: "loader:a", tier: "stopping", hits: 0 },
159
+ { name: "unused:b", tier: "counting", hits: 3 },
160
+ ],
161
+ };
162
+ assert.equal(idleGate(cal).ok, true);
163
+ });
164
+
165
+ test("idleGate rejects a calibration in which a stopping-tier sentinel recorded a non-zero count, naming it", () => {
166
+ const cal = {
167
+ cycles_advanced: 12345,
168
+ sentinels: [{ name: "loader:a", tier: "stopping", hits: 113 }],
169
+ };
170
+ const result = idleGate(cal);
171
+ assert.equal(result.ok, false);
172
+ assert.equal(result.violations[0].name, "loader:a");
173
+ assert.equal(result.violations[0].hits, 113);
174
+ });
175
+
176
+ test("idleGate rejects a calibration whose cycles_advanced is zero even when every hit count is zero", () => {
177
+ const cal = { cycles_advanced: 0, sentinels: [{ name: "loader:a", tier: "stopping", hits: 0 }] };
178
+ const result = idleGate(cal);
179
+ assert.equal(result.ok, false);
180
+ assert.match(result.reasons.join(" "), /cycles_advanced/);
181
+ });
182
+
183
+ // -------------------------------------------------------------- classifyHit
184
+
185
+ test("classifyHit returns unattributed when pc, backtrace or disassembly is missing", () => {
186
+ assert.equal(classifyHit({}), "unattributed");
187
+ assert.equal(classifyHit({ pc: "$8E9D", backtrace: [], disassembly: "STA ($04),Y" }), "unattributed");
188
+ assert.equal(classifyHit({ pc: "$8E9D", backtrace: ["$0800"], disassembly: "" }), "unattributed");
189
+ });
190
+
191
+ test("classifyHit returns the recorded classification only when pc, backtrace and disassembly are all present", () => {
192
+ const hit = { pc: "$8E9D", backtrace: ["$0800"], disassembly: "STA ($04),Y", classification: "gameplay-write" };
193
+ assert.equal(classifyHit(hit), "gameplay-write");
194
+ const loadHit = { ...hit, classification: "load-candidate" };
195
+ assert.equal(classifyHit(loadHit), "load-candidate");
196
+ });
197
+
198
+ // ---------------------------------------------------------- screenSignature
199
+
200
+ test("screenSignature hashes exactly 1000 bytes and carries the sprite-enable value through", () => {
201
+ const hex = "00".repeat(1000);
202
+ const result = screenSignature(hex, 60);
203
+ assert.equal(result.sprite_enable, 60);
204
+ assert.equal(typeof result.digest, "string");
205
+ assert.equal(result.digest.length, 64);
206
+ });
207
+
208
+ test("screenSignature throws for a screen matrix that is not exactly 1000 bytes", () => {
209
+ assert.throws(() => screenSignature("00".repeat(999), 0), /expected 1000 bytes/);
210
+ });
211
+
212
+ // ------------------------------------------------ real committed sidecars
213
+
214
+ const CHIP_STATE = firstDumpArtifact("chip_state");
215
+
216
+ test("buildChipState reproduces a committed sidecar's recorded derivations from that file's own raw readings",
217
+ { skip: skipUnless(CHIP_STATE, "a committed chip-state sidecar") }, () => {
218
+ const committed = JSON.parse(readFileSync(CHIP_STATE.path, "utf8"));
219
+ const raw = {
220
+ dd00_raw: committed.derived.dd00_raw,
221
+ d018_raw: committed.derived.d018_raw,
222
+ port01_raw: committed.derived.port01.raw,
223
+ sprite_pointers: committed.derived.sprite_pointers,
224
+ registers: committed.registers,
225
+ sprites: committed.sprites,
226
+ cpu: committed.cpu,
227
+ };
228
+ const result = buildChipState(raw);
229
+ assert.equal(result.derived.vic_bank, committed.derived.vic_bank);
230
+ assert.equal(result.derived.screen_base, committed.derived.screen_base);
231
+ assert.equal(result.derived.charset_base, committed.derived.charset_base);
232
+ assert.deepEqual(result.derived.sprite_data_addresses, committed.derived.sprite_data_addresses);
233
+ });
234
+
235
+ const BIN = firstDumpArtifact("bin");
236
+
237
+ test("buildRangeManifest over a committed image produces ranges whose union covers all 65536 addresses",
238
+ { skip: skipUnless(BIN, "a committed 64K image") }, () => {
239
+ const image = readFileSync(BIN.path);
240
+ const manifest = buildRangeManifest(image, { release: BIN.release, label: BIN.label });
241
+ assert.equal(manifest.classification_state, "ranges-only");
242
+ let expected = 0;
243
+ for (const r of manifest.ranges) {
244
+ assert.equal(r.start, expected, `gap or overlap before ${r.start}`);
245
+ expected = r.end + 1;
246
+ }
247
+ assert.equal(expected, 65536);
248
+ });
249
+
250
+ // ------------------------------------------------- import-purity guard (T-01-25)
251
+
252
+ function stripComments(src) {
253
+ return src.replace(/\/\*[\s\S]*?\*\//g, "").replace(/(^|[^:])\/\/.*$/gm, "$1");
254
+ }
255
+
256
+ function importSpecifiers(src) {
257
+ const specs = [];
258
+ const re = /import\s+(?:[^'"]+?\s+from\s+)?["']([^"']+)["']/g;
259
+ let m;
260
+ while ((m = re.exec(src))) specs.push(m[1]);
261
+ return specs;
262
+ }
263
+
264
+ test("every import specifier in watch-loads.mjs and dump-artifacts.mjs is a node: built-in or a sibling file inside tools/ -- the mechanical proof of the one permitted route", () => {
265
+ const files = ["watch-loads.mjs", "dump-artifacts.mjs"];
266
+ let totalSpecifiers = 0;
267
+ for (const f of files) {
268
+ const src = stripComments(readFileSync(join(HERE, f), "utf8"));
269
+ const specs = importSpecifiers(src);
270
+ assert.ok(specs.length > 0, `${f} should have at least one import specifier (this assertion fails if removed, so it cannot pass vacuously)`);
271
+ totalSpecifiers += specs.length;
272
+ for (const spec of specs) {
273
+ const isNodeBuiltin = spec.startsWith("node:");
274
+ const isSiblingPath = spec.startsWith("./") || spec.startsWith("../");
275
+ assert.ok(
276
+ isNodeBuiltin || isSiblingPath,
277
+ `${f} imports "${spec}", which is neither a node: built-in nor a relative path -- this module must never acquire an outside dependency`
278
+ );
279
+ if (isSiblingPath) {
280
+ const resolved = resolve(HERE, spec);
281
+ assert.ok(resolved.startsWith(HERE), `${f}'s import "${spec}" resolves outside tools/ (${resolved})`);
282
+ }
283
+ }
284
+ }
285
+ assert.ok(totalSpecifiers > 0, "at least one specifier must have been checked across both modules");
286
+ });
287
+
288
+ // ----------------------------------------------------------------- renderLoading
289
+
290
+ test("renderLoading flags a blocked run's zero count as unevidenced rather than rendering it as a plain zero", async () => {
291
+ const { renderLoading } = await import("./watch-loads.mjs");
292
+ const blockedLog = {
293
+ machine: "C64SC",
294
+ video_standard: "PAL",
295
+ vice_version: "3.10",
296
+ run_status: "blocked",
297
+ run_status_note: "Boot never progressed past its pre-loader state; two independent cycles_advanced brackets both measured zero.",
298
+ armed: [],
299
+ idle_calibration: { cycles_advanced: 0, sentinels: [] },
300
+ hits: [],
301
+ };
302
+ const md = renderLoading([{ id: "example", log: blockedLog }]);
303
+ assert.match(md, /NOT AN EVIDENCED ZERO/, "a blocked run must not render its zero count as a plain, unqualified result");
304
+ assert.match(md, /never progressed past its pre-loader state/, "the blocked-run reason must be surfaced in the rendered document");
305
+ });
306
+
307
+ test("renderLoading flags a blocked run's NON-zero count as a partial result, not a plain evidenced count", async () => {
308
+ const { renderLoading } = await import("./watch-loads.mjs");
309
+ const partialLog = {
310
+ machine: "C64SC",
311
+ video_standard: "PAL",
312
+ vice_version: "3.10",
313
+ run_status: "blocked",
314
+ run_status_note: "Play-through halted by a genuine silent host VICE stall after 2 of the required milestones.",
315
+ armed: [],
316
+ idle_calibration: { cycles_advanced: 100, sentinels: [] },
317
+ hits: [
318
+ { sentinel: "reg:$DD00", address: "$DD00", cycle: 1, pc: "$07DB", backtrace: [{ return_address: 1 }], disassembly: "STA $DD00", classification: "gameplay-write" },
319
+ ],
320
+ };
321
+ const md = renderLoading([{ id: "example", log: partialLog }]);
322
+ assert.match(md, /PARTIAL RESULT, NOT A COMPLETED COVERAGE CLAIM/, "a blocked run with a non-zero count must not be rendered with the zero-specific warning text");
323
+ assert.doesNotMatch(md, /The count above is `0` only because/, "must not claim the count is 0 when it is not");
324
+ assert.match(md, /halted by a genuine silent host VICE stall/, "the blocked-run reason must still be surfaced");
325
+ });
326
+
327
+ test("renderLoading does NOT add the blocked-run warning for an ordinary (non-blocked) log", async () => {
328
+ const { renderLoading } = await import("./watch-loads.mjs");
329
+ const normalLog = {
330
+ machine: "C64SC",
331
+ video_standard: "PAL",
332
+ vice_version: "3.10",
333
+ armed: [],
334
+ idle_calibration: { cycles_advanced: 100, sentinels: [] },
335
+ hits: [],
336
+ };
337
+ const md = renderLoading([{ id: "example", log: normalLog }]);
338
+ assert.doesNotMatch(md, /NOT AN EVIDENCED ZERO/, "an ordinary log must not be flagged as a blocked run");
339
+ });
@@ -0,0 +1,59 @@
1
+ # Capture record — `<release>-<checkpoint>-run<N>`
2
+
3
+ One record per capture. Every field is recorded **in the same step as the
4
+ capture**, before the machine is resumed — a value read later describes a
5
+ different machine.
6
+
7
+ ## Identity
8
+
9
+ | Field | Value | How obtained |
10
+ |---|---|---|
11
+ | image path | `recovery/<release>/dumps/<name>.bin` | — |
12
+ | size | `65536` bytes | must be exact; anything else is not a full image |
13
+ | sha256 | `<64 hex chars>` | `node scripts/compare.mjs digest <name>.bin` |
14
+ | checkpoint / trigger address | `$____` | the address armed for this capture |
15
+ | release | the registry id this capture belongs to | — |
16
+ | run | `<N>` of `<total>` | three runs is this project's minimum for a verified capture |
17
+
18
+ ## Machine state at the capture instant
19
+
20
+ Read these *before* resuming, in the same paused window as the memory reads.
21
+
22
+ | Field | Value | Source |
23
+ |---|---|---|
24
+ | `$01` (processor port) | `$__` `%________` | `vice_memory_read` — decides which vectors are live |
25
+ | video standard | PAL \| NTSC | `vice_vicii_get_state` |
26
+ | registers (PC, A, X, Y, SP, flags) | | `vice_registers_get` |
27
+ | epoch-drift errors during the capture | `none` | the proxy raises these itself, before and after every forwarded call — no tool reads the epoch on demand |
28
+ | checkpoints armed at exit | `0` | `vice_checkpoint_list` — accept only this enumeration as proof |
29
+
30
+ ## Verdict
31
+
32
+ - [ ] Size is exactly 65536 bytes.
33
+ - [ ] No epoch-drift error appeared at any point during the capture.
34
+ - [ ] `vice_checkpoint_list` reported zero checkpoints before resuming.
35
+ - [ ] Machine resumed exactly once, at the end.
36
+
37
+ If any box is unchecked, void the run: rename each artifact to
38
+ `<name>.VOID-<UTC timestamp>`, write a sibling note giving the reason and — when a
39
+ drift error was the cause — both epoch values quoted from that error's own text,
40
+ and keep the voided artifacts on disk.
41
+
42
+ ## Comparison against sibling runs
43
+
44
+ `node scripts/compare.mjs compare <a>.bin <b>.bin` for each pairing, and
45
+ `node scripts/compare.mjs floor <a>.bin <b>.bin <c>.bin` across the set.
46
+
47
+ | Pairing | volatile | drift (1 bit) | divergence (2+ bits) | verdict |
48
+ |---|---|---|---|---|
49
+ | run1 vs run2 | | | | |
50
+ | run1 vs run3 | | | | |
51
+ | run2 vs run3 | | | | |
52
+
53
+ Record the drift floor address count, and state it as a floor rather than a
54
+ complete set — more captures of the same checkpoint can only widen it.
55
+
56
+ **Any divergence inside `$D000-$DFFF` is not a divergence.** That range is
57
+ register images, not RAM: reading it samples live hardware, so it can never be
58
+ stable across two captures. Classify it as volatile and say so in the record
59
+ rather than voiding a good capture over it.
@@ -0,0 +1,149 @@
1
+ ---
2
+ name: vice-wedge-triage
3
+ description: Decide whether a VICE emulator that has stopped responding is genuinely wedged, stopped itself at your own checkpoint, crashed and respawned, or merely paused — and what is safe to do about each. Use when asked why the emulator is stuck, frozen, hung, wedged, dead or not advancing, when a cycle bracket reads zero, when vice_ping says running but nothing happens, when a checkpoint never fires, when deciding whether to recycle or restart VICE, or when a run has to be voided and its evidence recorded.
4
+ ---
5
+
6
+ # Triage a VICE that stopped moving
7
+
8
+ **Four states look identical from outside, and the intuitive fix destroys a healthy machine in
9
+ one of them.** Work the order below. Do not start with a remedy.
10
+
11
+ | State | Cheap tell | Safe action |
12
+ |---|---|---|
13
+ | **Merely paused** | Any state read pauses the machine and does not resume it | Resume once. Nothing is wrong |
14
+ | **Stopped itself at your checkpoint** | An armed *stopping* checkpoint on the live IRQ path | Delete/disable the checkpoint. **Never recycle** |
15
+ | **Crashed and respawned** | The proxy raises epoch drift on the next forwarded call | Void the run, reboot from scratch. Already handled for you |
16
+ | **Genuinely wedged** | Two consecutive cycle brackets read exactly `0` | `vice_recycle` with a reason, as a last resort |
17
+
18
+ ```
19
+ mcp__plugin_c64-re-tools_vice__vice_diagnose # one call, no arguments, answers which of the five it is
20
+ ```
21
+
22
+ `vice_diagnose` returns a closed five-verdict vocabulary — `restarted`, `checkpoint_trap`,
23
+ `wedged`, `stale_read_path`, `live` — with the evidence that produced it. Read its schema for the
24
+ contract; this skill is the judgement around it.
25
+
26
+ ## The order
27
+
28
+ 1. **Call `vice_diagnose` first.** It runs the checks in the cheap-to-expensive order and stops at
29
+ the first that fires: the epoch comparison costs zero emulator calls, the checkpoint-trap check
30
+ costs three reads and **no resume**, and only then does it measure a cycle bracket.
31
+ 2. **Read the verdict, not the vibe.** Each verdict has exactly one correct response — the table
32
+ below. A verdict is not a suggestion to try things.
33
+ 3. **`diagnose` leaves the machine paused** when it ran a bracket. Resuming is your own next call.
34
+ Do not treat "still paused afterwards" as a symptom.
35
+ 4. **If the verdict is `wedged`, capture evidence before recovering.** `vice_recycle` requires a
36
+ `reason`, and that string is written verbatim into a permanent, repo-tracked incident record
37
+ under `.planning/incidents/` **before anything is killed**. That record is the evidence
38
+ capture — there is no separate ritual to perform, and a lazy `reason` is a lost incident.
39
+ 5. **Recycling changes the restart epoch.** Any run in flight is void. Resume from the last
40
+ recorded milestone snapshot, never from where the wedge happened.
41
+
42
+ ## Verdict → response
43
+
44
+ | Verdict | What it means | Do |
45
+ |---|---|---|
46
+ | `live` | Cycles advanced | Resume and carry on. Suspect your own checkpoint conditions, not the emulator |
47
+ | `checkpoint_trap` | The machine stopped **itself** at an armed checkpoint | `vice_checkpoint_delete` or `vice_checkpoint_toggle` it, or `vice_execution_step` past it, then re-run `diagnose`. **Recycling here destroys a healthy instance** |
48
+ | `restarted` | The epoch changed — a crash-and-respawn already happened | The run is void. `c64-ram-capture` § Void a run gives the artifact procedure. Reboot from `vice_disk_attach` |
49
+ | `stale_read_path` | Some reads move while others do not | Do not trust any measurement taken across the boundary. Treat as void and re-derive |
50
+ | `wedged` | Two brackets, zero cycles, no epoch change | Last resort: `vice_recycle` with a real reason |
51
+
52
+ ## What is not recoverable
53
+
54
+ **A checkpoint trap may be the onset without being the whole story.** In the recorded incident
55
+ (`.planning/todos/pending/2026-08-01-vice-registers-frozen-after-reset-during-01-04-task2.md`)
56
+ checkpoint delete, then a soft reset, then a hard reset, then an explicit single step **all** left
57
+ the machine frozen, in sequence. Deleting the checkpoint is not guaranteed to unfreeze anything.
58
+
59
+ If a bracket still reads zero after the checkpoint is gone, the verdict becomes `wedged` and
60
+ recycle is the fallback after all.
61
+
62
+ **A stalled session cannot be repaired from inside.** The instance is granted on the session's
63
+ first forwarded call and is that session's for its whole life, so a subagent inherits the same
64
+ stalled instance. Before `vice_recycle` existed, the only exit was abandoning the session; that is
65
+ still the exit if recycle itself cannot land.
66
+
67
+ ## Two traps that read as a wedge and are not
68
+
69
+ **`vice_ping`'s `execution` field is not a liveness signal.** A stalled host VICE answers
70
+ `status: "ok", execution: "running"` continuously and indefinitely. So does a machine that stopped
71
+ at a checkpoint — VICE's flag flips before the trap fires. Checkpoint bookkeeping
72
+ (`vice_checkpoint_add`/`list`/`delete`) also keeps returning healthy, self-consistent responses
73
+ throughout a real wedge, so "the tools respond" proves nothing.
74
+
75
+ **A `vice_run_until` on an address that is never reached looks exactly like a wedge.** Its `cycles`
76
+ parameter is documented in its own schema as *"not yet implemented"*, so there is no working
77
+ timeout to bound it. Before concluding anything, check whether you asked the machine to run to an
78
+ address it cannot reach. **Confidence: MEDIUM** — read off the tool schema, not reproduced.
79
+
80
+ ## The manual fallback, when `vice_diagnose` cannot answer
81
+
82
+ `vice_diagnose` needs the host broker running. When it reports that no `broker.json` record
83
+ exists, that is a **host action for a human** — say so and stop. Nothing container-side may reach
84
+ the emulator by another route.
85
+
86
+ When the broker is up but you want the raw measurement, the cycle bracket is the only trustworthy
87
+ liveness test, and it is four calls:
88
+
89
+ 1. `vice_cycles_stopwatch` `{action: "reset"}`
90
+ 2. `vice_execution_run`
91
+ 3. `vice_ping` ×3 — the one call measured non-pausing (986,693 cycles/s while polling vs 991,569
92
+ fully quiet). Never poll with a state read; those pause and do not resume
93
+ 4. `vice_cycles_stopwatch` `{action: "read"}`
94
+
95
+ **Exactly `0`, twice in a row, is a wedge.** Cycles advancing but far below ~991,000/s is a third
96
+ thing — merely slow, a separate documented hazard measured at ~6,000/s when a loop polls without
97
+ re-resuming. Read all state first, poll with `vice_ping`, resume exactly once at the end.
98
+
99
+ **Enumerate your own checkpoints before running any bracket.** `vice_checkpoint_list`, then
100
+ resolve the live IRQ handler (`$0314/$0315`, or `$FFFE/$FFFF` when `$01` has the ROMs banked out).
101
+ An armed stopping checkpoint at or inside the live IRQ path, with the PC pinned at or just past
102
+ it, is the trap signature — and reaching that verdict needs **no `vice_execution_run`**, which
103
+ matters because `vice_execution_run` is this project's leading crash suspect (six outages in one
104
+ session, the last three all on that call).
105
+
106
+ ## Provenance
107
+
108
+ | Claim | Evidence | Confidence |
109
+ |---|---|---|
110
+ | The wedge signature: zero cycles, `ping` says running, PC byte-identical across pause/resume/step | Four live incidents across two disk images and two sessions; three independent zero-cycle brackets in one | HIGH |
111
+ | `vice_ping`'s `execution` field is not liveness | Confirmed twice independently | HIGH |
112
+ | The cycle bracket is the only trustworthy liveness test | Measured both ways — 21,551,860 cycles on a healthy instance, exactly 0 twice on a stalled one | HIGH |
113
+ | Epoch drift is surfaced automatically, and self-heals on the next call | Live, twice; both incidents self-healed within the session | HIGH |
114
+ | Checkpoint delete / reset / step can all fail to recover | One recorded incident, all four attempts in sequence | HIGH, single incident |
115
+ | A checkpoint trap explains all three recorded "silent stalls" | Cross-read, 3/3 correlation, mechanism consistent with every symptom — **not reproduced** | MEDIUM |
116
+ | `vice_diagnose`'s five-verdict path behaves as its schema says | Schema read, and cross-checked against the tracked implementation's own report builders. **Not exercised end to end** | MEDIUM |
117
+ | `vice_run_until` has no working timeout | Its schema says `cycles` is "not yet implemented" | MEDIUM |
118
+
119
+ Full provenance in `.planning/RE-FINDINGS.md`. **Log a new incident there at the moment you hit
120
+ it**, graded with `Evidence:` and `Confidence:`; promote by re-logging, never by editing a grade.
121
+ VICE MCP defects go to `.planning/todos/pending/` rather than being fixed inline. File-changing
122
+ work enters through a GSD command (`/gsd-quick`).
123
+
124
+ ## Which skill does what
125
+
126
+ This one owns "is the machine alive, and what may I do to it". It does not restate what the
127
+ others carry.
128
+
129
+ | Need | Go to |
130
+ |---|---|
131
+ | Every way a live *read* gives a wrong answer | `c64-program-recon` — `references/observation-hazards.md` |
132
+ | Which address to read next, and what the answer rules out | `c64-program-recon` |
133
+ | Voiding a capture, and the artifact rename procedure | `c64-ram-capture` — § Void a run |
134
+ | What a specific address or bit means | `c64-memory-mapping` |
135
+ | **Whether the emulator is wedged, and whether to recycle** | here |
136
+
137
+ ## Troubleshooting
138
+
139
+ | Symptom | Fix |
140
+ |---|---|
141
+ | `vice_diagnose` reports no `broker.json` record exists | The host broker was never started. A human must start it on the host. There is no container-side workaround |
142
+ | The machine is still paused after `vice_diagnose` | Expected — it leaves it paused after a bracket. Resume with `vice_execution_run` |
143
+ | `vice_ping` says `running`, nothing advances | Not liveness. Run a bracket, or call `vice_diagnose` |
144
+ | The checkpoint never fired | Most state reads pause the emulator. Resume exactly once after every read |
145
+ | Zero cycles, and a checkpoint is armed on the IRQ handler | `checkpoint_trap`, not a wedge. Do not recycle |
146
+ | Zero cycles, nothing armed, epoch unchanged | A wedge. `vice_recycle` with a reason that names the evidence |
147
+ | A run "survived a reset" | Distrust it. You cannot read the epoch to confirm — but an unintended respawn inside the bracket would have raised a drift error on the next forwarded call, so absence of that error is the only evidence available |
148
+ | `vice_recycle` refused for a missing reason | It is required, by design — the reason *is* the incident record |
149
+ </content>