@henols/c64-re-tools 0.2.1 → 0.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/THIRD-PARTY-NOTICES.md +26 -0
- package/bin/cli.mjs +18 -7
- package/package.json +6 -4
- package/skills/acme-build/SKILL.md +83 -33
- package/skills/acme-build/scripts/acme.mjs +159 -64
- package/skills/acme-build/template.a +1 -1
- package/skills/c64-disk-access/SKILL.md +156 -0
- package/skills/c64-disk-access/scripts/c1541.mjs +569 -0
- package/skills/c64-memory-mapping/SKILL.md +419 -23
- package/skills/c64-memory-mapping/scripts/driver.mjs +1 -1
- package/skills/c64-petcat/SKILL.md +87 -0
- package/skills/c64-petcat/scripts/petcat.mjs +221 -0
- package/skills/c64-program-recon/SKILL.md +497 -92
- package/skills/c64-program-recon/references/control-flow.md +12 -15
- package/skills/c64-program-recon/references/graphics.md +1 -1
- package/skills/c64-program-recon/references/observation-hazards.md +18 -16
- package/skills/c64-program-recon/references/reconstruction.md +11 -6
- package/skills/c64-program-recon/references/sound-and-input.md +6 -8
- package/skills/c64-program-recon/references/tool-selection.md +37 -18
- package/skills/c64-program-recon/scripts/packer-finding.mjs +709 -0
- package/skills/c64-program-recon/templates/memory-map.template.md +27 -13
- package/skills/c64-provenance-diff/SKILL.md +43 -8
- package/skills/c64-provenance-diff/scripts/diff-images.mjs +9 -6
- package/skills/c64-provenance-diff/scripts/recovery-schema.mjs +20 -8
- package/skills/c64-ram-capture/RELEASES.json.example +17 -0
- package/skills/c64-ram-capture/SKILL.md +147 -46
- package/skills/c64-ram-capture/scripts/compare.mjs +2 -2
- package/skills/c64-ram-capture/scripts/derive-transients.mjs +575 -0
- package/skills/c64-ram-capture/scripts/dump-artifacts.mjs +3 -3
- package/skills/c64-ram-capture/scripts/mcp-module.mjs +174 -0
- package/skills/c64-ram-capture/scripts/project-paths.mjs +1 -1
- package/skills/c64-ram-capture/scripts/releases.mjs +1 -1
- package/skills/c64-ram-capture/scripts/vsf-slice.mjs +147 -0
- package/skills/c64-ram-capture/scripts/watch-loads.mjs +19 -13
- package/skills/c64-ram-capture/templates/capture-record.template.md +44 -4
- package/skills/c64-ram-capture/transients/README.md +136 -0
- package/skills/routine-queue-walker/SKILL.md +365 -0
- package/skills/routine-queue-walker/scripts/completeness-report.mjs +463 -0
- package/skills/vice-wedge-triage/SKILL.md +104 -97
- package/skills/c64-provenance-diff/scripts/diff-images.test.mjs +0 -665
- package/skills/c64-ram-capture/scripts/d64-parse.mjs +0 -243
- package/skills/c64-ram-capture/scripts/d64-parse.test.mjs +0 -243
- package/skills/c64-ram-capture/scripts/dump-artifacts.test.mjs +0 -133
- package/skills/c64-ram-capture/scripts/test-corpus.mjs +0 -75
- package/skills/c64-ram-capture/scripts/watch-loads.test.mjs +0 -339
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// mcp-module.mjs -- the ONE resolution ladder a skill script uses to reach a
|
|
3
|
+
// module that lives in the OTHER package (`@henols/vice-mcp`'s `src/mcp/vice/`
|
|
4
|
+
// tree, as opposed to this file's own `@henols/c64-re-tools` skills tree).
|
|
5
|
+
//
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// WHY THIS FILE EXISTS
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
// Before this file existed, `vsf-slice.mjs`
|
|
10
|
+
// carried its own copy of this exact three-rung ladder (`ladder()` /
|
|
11
|
+
// `resolveTarget()`), and TWO MORE skill scripts (`acme.mjs`,
|
|
12
|
+
// `packer-finding.mjs`) were about to migrate onto the host-tool execution
|
|
13
|
+
// seam and were each going to need the identical lookup to reach
|
|
14
|
+
// `host-tool-client.ts`. Three production consumers were about to carry
|
|
15
|
+
// three copies of one ladder -- so it is extracted here instead, and
|
|
16
|
+
// `vsf-slice.mjs` is rewritten to import it rather than keep its own.
|
|
17
|
+
//
|
|
18
|
+
// The underlying constraint this ladder exists to answer is
|
|
19
|
+
// `vsf-slice.mjs`'s own recorded finding: the MCP server ships as
|
|
20
|
+
// `@henols/vice-mcp`, whose `files[]` lists only `src/mcp/vice/` contents,
|
|
21
|
+
// while `src/skills/**` ships in a DIFFERENT package (`@henols/c64-re-tools`).
|
|
22
|
+
// A plain `import` from a skill script into the MCP tree resolves on NEITHER
|
|
23
|
+
// npm-installer route, so a skill script that needs an MCP-side module must
|
|
24
|
+
// LOCATE it on disk and invoke it as a subprocess (`process.execPath
|
|
25
|
+
// <resolved-path> ...argv`) -- never a static `import`.
|
|
26
|
+
//
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
// WHAT THIS IS THE ONE AUTHORITATIVE PLACE FOR
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
// - the three-rung resolution order (`$VICE_MCP_DIR`, the in-repo relative
|
|
31
|
+
// path, then `require.resolve()` against the published package) for
|
|
32
|
+
// locating ANY named file inside `src/mcp/vice/` from a skill's own
|
|
33
|
+
// `scripts/` directory;
|
|
34
|
+
// - refusing BY NAME, listing every rung tried, when none resolves
|
|
35
|
+
// (`refusalMessage()`);
|
|
36
|
+
// - computing the in-repo hop count from `import.meta.url` rather than a
|
|
37
|
+
// fixed `".."` count -- `hostpath.ts` and `containerpath.ts` both record
|
|
38
|
+
// dropping a hard-coded four-level hop when they moved directories once
|
|
39
|
+
// already, and this project has ALSO shipped a stale hardcoded snapshot
|
|
40
|
+
// offset before (see `vsf-slice.mjs`'s own header) -- a fixed `".."`
|
|
41
|
+
// count is a mistake this project keeps making, not a one-off.
|
|
42
|
+
//
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
// WHAT NOT TO DO
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
// - Never carry a second copy of these three rungs, their ordering, or
|
|
47
|
+
// their refusal text anywhere else in this repository. A future
|
|
48
|
+
// skill-side consumer of an MCP-side module imports `resolveMcpModule`
|
|
49
|
+
// from HERE.
|
|
50
|
+
// - Never let a resolution failure become a local re-implementation of
|
|
51
|
+
// whatever was being resolved -- refuse by name instead. A silent
|
|
52
|
+
// fallback that reimplements the target module's own logic is exactly
|
|
53
|
+
// the divergence this ladder exists to prevent (see `vsf-slice.mjs`'s own
|
|
54
|
+
// header on the stale-offset incident this project has already had).
|
|
55
|
+
// - `process.execPath` spawned on a resolved `.ts`/`.mjs` file inside this
|
|
56
|
+
// project's OWN tree reaches NO external host binary -- it is the
|
|
57
|
+
// interpreter already running the calling script, pointed at an in-tree
|
|
58
|
+
// module. This is NOT what the host-tool execution seam (`host-tool.mts`
|
|
59
|
+
// / `host-tool-client.ts`) exists for, and must never be confused with
|
|
60
|
+
// it: the seam is for binaries that live on the HOST, outside any
|
|
61
|
+
// container; this ladder is for locating a file inside this project's own
|
|
62
|
+
// two npm packages.
|
|
63
|
+
import { existsSync } from "node:fs";
|
|
64
|
+
import { createRequire } from "node:module";
|
|
65
|
+
import { fileURLToPath } from "node:url";
|
|
66
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
67
|
+
|
|
68
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
69
|
+
|
|
70
|
+
/** The package the MCP-side module ships in, for the npm-installer rung. */
|
|
71
|
+
export const TARGET_PACKAGE = "@henols/vice-mcp";
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Walks UP from `startDir` until a directory literally named `name` is
|
|
75
|
+
* found, returning its absolute path -- or `null` if the filesystem root is
|
|
76
|
+
* reached first without a match. This is what lets the in-repo hop count be
|
|
77
|
+
* COMPUTED from `import.meta.url` instead of a fixed `".."` count: whichever
|
|
78
|
+
* skill's `scripts/` directory this module is imported from, walking up to
|
|
79
|
+
* the `skills/` ancestor and back down to `mcp/vice/` is the same operation.
|
|
80
|
+
*/
|
|
81
|
+
function findAncestorDir(startDir, name) {
|
|
82
|
+
let dir = startDir;
|
|
83
|
+
for (;;) {
|
|
84
|
+
if (basename(dir) === name) return dir;
|
|
85
|
+
const parent = dirname(dir);
|
|
86
|
+
if (parent === dir) return null; // reached the filesystem root, no match
|
|
87
|
+
dir = parent;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Computed once, at module load: HERE is .../src/skills/<skill>/scripts/, so
|
|
92
|
+
// walking up to the "skills" ancestor and taking ITS parent ("src") gives the
|
|
93
|
+
// root from which "mcp/vice" is a sibling of "skills" -- with no fixed hop
|
|
94
|
+
// count written down anywhere in this file.
|
|
95
|
+
const SKILLS_DIR = findAncestorDir(HERE, "skills");
|
|
96
|
+
const IN_REPO_MCP_VICE_DIR = SKILLS_DIR ? join(dirname(SKILLS_DIR), "mcp", "vice") : null;
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The resolution ladder for `fileName`, in order, as `{ rung, path, note }`
|
|
100
|
+
* records -- returned IN FULL (never short-circuited), so a refusal message
|
|
101
|
+
* can name every path tried, which is the whole point of refusing rather
|
|
102
|
+
* than guessing:
|
|
103
|
+
*
|
|
104
|
+
* 1. `$VICE_MCP_DIR` -- an explicit override, checked first so a caller can
|
|
105
|
+
* always point this ladder at a known-good tree.
|
|
106
|
+
* 2. The in-repo relative path (see `findAncestorDir()` above). The plugin
|
|
107
|
+
* distribution keeps both trees in one checkout, so this is the rung
|
|
108
|
+
* that resolves during development and in CI.
|
|
109
|
+
* 3. `require.resolve()` against the published package, for the
|
|
110
|
+
* npm-installer route where the two packages are installed separately.
|
|
111
|
+
*/
|
|
112
|
+
function ladder(fileName) {
|
|
113
|
+
const rungs = [];
|
|
114
|
+
|
|
115
|
+
const override = process.env.VICE_MCP_DIR;
|
|
116
|
+
rungs.push({
|
|
117
|
+
rung: "VICE_MCP_DIR",
|
|
118
|
+
path: override ? join(resolve(override), fileName) : null,
|
|
119
|
+
note: override ? null : "VICE_MCP_DIR is not set",
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
rungs.push({
|
|
123
|
+
rung: "in-repo relative path",
|
|
124
|
+
path: IN_REPO_MCP_VICE_DIR ? join(IN_REPO_MCP_VICE_DIR, fileName) : null,
|
|
125
|
+
note: IN_REPO_MCP_VICE_DIR ? null : `could not locate an ancestor "skills" directory from ${HERE}`,
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
let resolved = null;
|
|
129
|
+
let note = null;
|
|
130
|
+
try {
|
|
131
|
+
resolved = createRequire(import.meta.url).resolve(`${TARGET_PACKAGE}/${fileName}`);
|
|
132
|
+
} catch (err) {
|
|
133
|
+
note = `${TARGET_PACKAGE} is not resolvable from here (${err.code ?? err.message})`;
|
|
134
|
+
}
|
|
135
|
+
rungs.push({ rung: `${TARGET_PACKAGE} package`, path: resolved, note });
|
|
136
|
+
|
|
137
|
+
return rungs;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Builds the refusal text naming every rung tried plus the instruction to
|
|
142
|
+
* set `VICE_MCP_DIR` -- the ONE place this message is worded, so callers
|
|
143
|
+
* that construct their own additional context around it (e.g.
|
|
144
|
+
* `vsf-slice.mjs`'s own domain-specific rationale) do not restate it.
|
|
145
|
+
*/
|
|
146
|
+
export function refusalMessage(fileName, rungs) {
|
|
147
|
+
const tried = rungs
|
|
148
|
+
.map((r) => ` ${r.rung}: ${r.path ?? "(no path)"}${r.note ? ` -- ${r.note}` : ""}`)
|
|
149
|
+
.join("\n");
|
|
150
|
+
return (
|
|
151
|
+
`could not resolve ${fileName}.\n` +
|
|
152
|
+
`Tried, in order:\n${tried}\n` +
|
|
153
|
+
`Set VICE_MCP_DIR to the directory holding ${fileName}.`
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Resolves `fileName` (e.g. `"vsf-slice.ts"`, `"host-tool-client.ts"`) against
|
|
159
|
+
* the three-rung ladder above, with NO per-file special-casing -- every
|
|
160
|
+
* caller gets the same three rungs in the same order.
|
|
161
|
+
*
|
|
162
|
+
* Returns `{ ok: true, rung, path }` for the first rung whose path exists on
|
|
163
|
+
* disk, or `{ ok: false, rungs, message }` when none resolves -- `rungs`
|
|
164
|
+
* carries the full attempt list (so a caller can build its own message from
|
|
165
|
+
* the raw records instead of `refusalMessage()`, if it wants extra context),
|
|
166
|
+
* and `message` is `refusalMessage(fileName, rungs)` already computed. Never
|
|
167
|
+
* throws.
|
|
168
|
+
*/
|
|
169
|
+
export function resolveMcpModule(fileName) {
|
|
170
|
+
const rungs = ladder(fileName);
|
|
171
|
+
const hit = rungs.find((r) => r.path && existsSync(r.path));
|
|
172
|
+
if (hit) return { ok: true, rung: hit.rung, path: hit.path };
|
|
173
|
+
return { ok: false, rungs, message: refusalMessage(fileName, rungs) };
|
|
174
|
+
}
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
//
|
|
7
7
|
// 1. The project root is found by walking UP for a `.git` marker. Counting
|
|
8
8
|
// hops from `import.meta.url` breaks the moment the toolkit is installed
|
|
9
|
-
// somewhere other than
|
|
9
|
+
// somewhere other than `src/skills/<skill>/scripts/`, and it breaks
|
|
10
10
|
// silently -- paths resolve to a plausible wrong place rather than erroring.
|
|
11
11
|
// 2. Every data location is overridable by environment variable, so a project
|
|
12
12
|
// that does not use this repo's `recovery/` + `disks/` layout can point the
|
|
@@ -44,7 +44,7 @@ export function release(id) {
|
|
|
44
44
|
}
|
|
45
45
|
|
|
46
46
|
/**
|
|
47
|
-
* The registry's own N-readiness documentation
|
|
47
|
+
* The registry's own N-readiness documentation:
|
|
48
48
|
* a top-level `schema_notes` string, sibling to `schema_version` and
|
|
49
49
|
* `releases`, stating the mechanical claim that adding a release is one
|
|
50
50
|
* `releases[]` entry plus one invocation of `tools/recover.mjs`. Kept as a
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Slice a flat 64K RAM image out of a VICE `.vsf` snapshot, with no
|
|
3
|
+
// transcription step anywhere.
|
|
4
|
+
//
|
|
5
|
+
// A WRAPPER, DELIBERATELY EMPTY OF LAYOUT KNOWLEDGE. Not one snapshot byte
|
|
6
|
+
// offset, module name or body length appears in this file. Every such fact
|
|
7
|
+
// lives in `src/mcp/vice/vsf-slice.ts`, which this script resolves and
|
|
8
|
+
// invokes. If you came here looking for the layout, you are in the wrong
|
|
9
|
+
// file, and that is the design.
|
|
10
|
+
//
|
|
11
|
+
// WHY IT INVOKES THE OTHER PACKAGE'S MODULE INSTEAD OF CARRYING A COPY.
|
|
12
|
+
// THE CONSTRAINT, MEASURED rather than assumed: the MCP server ships as
|
|
13
|
+
// `@henols/vice-mcp`, whose `files[]` lists only `src/mcp/vice/` contents,
|
|
14
|
+
// while `src/skills/**` ships in the other package (`@henols/c64-re-tools`).
|
|
15
|
+
// A plain `import` from a skill script into the MCP tree resolves on neither
|
|
16
|
+
// npm-installer route, and `scripts/check-npm-packages.mjs`'s transitive
|
|
17
|
+
// closure walk over `files[]` would fail the pack the moment a reachable
|
|
18
|
+
// module sat outside the listed set.
|
|
19
|
+
//
|
|
20
|
+
// That leaves two answers, and this file takes the second:
|
|
21
|
+
//
|
|
22
|
+
// (a) a self-contained skill-side script carrying its own copy of the
|
|
23
|
+
// layout -- the retired skill-side/MCP-side disk-image-reader pair's
|
|
24
|
+
// own precedent (two independent copies of the sector-chain walk,
|
|
25
|
+
// both deleted once the c1541 host-tool seam took over their one job).
|
|
26
|
+
// (b) a CLI entry point on the MCP-side module, which the skill invokes.
|
|
27
|
+
//
|
|
28
|
+
// (b), because the two cases are not alike. That retired pair's own
|
|
29
|
+
// duplicate was of a STABLE, PUBLISHED disk format that has not changed in
|
|
30
|
+
// forty years. The
|
|
31
|
+
// `.vsf` layout is version-sensitive: a second magic block moved the first
|
|
32
|
+
// module offset, the memory module's body length differs between two module
|
|
33
|
+
// minors that are both in the wild, and this project already had ONE stale
|
|
34
|
+
// copy of those numbers -- a prototype carrying a first-module offset that
|
|
35
|
+
// predates that second magic block, which survived only through a
|
|
36
|
+
// byte-by-byte rescan fallback that can lock onto a false module name inside
|
|
37
|
+
// 64 KB of RAM data. The real values, and their derivations, are in
|
|
38
|
+
// `vsf-slice.ts` where they belong. A second copy of the one
|
|
39
|
+
// authoritative reading of a version-sensitive format is precisely the
|
|
40
|
+
// divergence hazard the single-seam convention exists to remove. Recorded
|
|
41
|
+
// here as a decision so the next reader sees a choice rather than an
|
|
42
|
+
// oversight.
|
|
43
|
+
//
|
|
44
|
+
// IT SPAWNS `node` ON AN IN-TREE MODULE, AND NOTHING ELSE. `process.execPath`
|
|
45
|
+
// is the interpreter already running this script, and the target is a
|
|
46
|
+
// JavaScript/TypeScript file inside one of this project's own two packages.
|
|
47
|
+
// No external host binary is reached, so the host-tool execution seam is not
|
|
48
|
+
// involved here and must not be pre-empted -- that seam is for binaries that
|
|
49
|
+
// live on the host outside any container, which this is not.
|
|
50
|
+
//
|
|
51
|
+
// WHAT NOT TO DO:
|
|
52
|
+
// - Never add a fallback that slices the snapshot here. If no rung of the
|
|
53
|
+
// resolution ladder resolves, this script exits non-zero naming every
|
|
54
|
+
// path it tried. A local re-implementation would turn a resolution
|
|
55
|
+
// failure into a WRONG IMAGE, which is the one outcome the whole slicing
|
|
56
|
+
// route exists to avoid.
|
|
57
|
+
// - Never rewrite, prefix or summarise the target module's stderr. It is
|
|
58
|
+
// inherited straight through, so a refusal reaches you in the words of
|
|
59
|
+
// the code that refused.
|
|
60
|
+
// - Never copy a snapshot byte offset, a module body length or a module
|
|
61
|
+
// name into this file, not even in a comment as documentation, and not
|
|
62
|
+
// even into the usage text. The next person to update the layout will
|
|
63
|
+
// update `vsf-slice.ts` and will not know to look here. Its colocated
|
|
64
|
+
// test asserts the absence mechanically, so this is a red gate rather
|
|
65
|
+
// than a promise. (The `$D000-$DFFF` range in the usage below is a C64
|
|
66
|
+
// address range and a fact about the memory-read route, not a snapshot
|
|
67
|
+
// layout constant.)
|
|
68
|
+
import { spawnSync } from "node:child_process";
|
|
69
|
+
|
|
70
|
+
// The resolution ladder used to live HERE,
|
|
71
|
+
// as this file's own `ladder()`/`resolveTarget()` pair. It is now extracted
|
|
72
|
+
// to `mcp-module.mjs` (`resolveMcpModule()`/`refusalMessage()`), because two
|
|
73
|
+
// MORE skill scripts (`acme.mjs`, `packer-finding.mjs`) needed the identical
|
|
74
|
+
// lookup to reach the host-tool execution seam's `host-tool-client.ts`, and
|
|
75
|
+
// three copies of one ladder is exactly the divergence hazard this file's own
|
|
76
|
+
// header (below) already warns about for the `.vsf` layout itself. Import
|
|
77
|
+
// the ladder rather than re-adding a copy here.
|
|
78
|
+
import { resolveMcpModule, refusalMessage, TARGET_PACKAGE } from "./mcp-module.mjs";
|
|
79
|
+
|
|
80
|
+
/** The MCP-side module's file name, in one place. */
|
|
81
|
+
const TARGET_FILE = "vsf-slice.ts";
|
|
82
|
+
|
|
83
|
+
/** Forwards argv to the MCP-side entry point with stdio inherited, so its
|
|
84
|
+
* stdout and stderr reach the caller unmodified and its exit status is this
|
|
85
|
+
* script's exit status. */
|
|
86
|
+
function forward(argv) {
|
|
87
|
+
const resolved = resolveMcpModule(TARGET_FILE);
|
|
88
|
+
|
|
89
|
+
if (!resolved.ok) {
|
|
90
|
+
console.error(
|
|
91
|
+
`vsf-slice.mjs: ${refusalMessage(TARGET_FILE, resolved.rungs)}\n` +
|
|
92
|
+
`${TARGET_FILE} is where the .vsf layout lives. Refusing rather than slicing the snapshot ` +
|
|
93
|
+
`here: a second copy of a version-sensitive byte layout is how a wrong image gets produced ` +
|
|
94
|
+
`with no error.`,
|
|
95
|
+
);
|
|
96
|
+
return 1;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const run = spawnSync(process.execPath, [resolved.path, ...argv], { stdio: "inherit" });
|
|
100
|
+
if (run.error) {
|
|
101
|
+
console.error(`vsf-slice.mjs: could not run ${resolved.path}: ${run.error.message}`);
|
|
102
|
+
return 1;
|
|
103
|
+
}
|
|
104
|
+
if (run.signal) {
|
|
105
|
+
console.error(`vsf-slice.mjs: ${resolved.path} was killed by ${run.signal}`);
|
|
106
|
+
return 1;
|
|
107
|
+
}
|
|
108
|
+
return run.status ?? 1;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Both verbs forward identically. The table exists so an unknown verb is
|
|
112
|
+
// answered here, with this script's usage, rather than by a subprocess whose
|
|
113
|
+
// own usage names a file the caller did not run.
|
|
114
|
+
//
|
|
115
|
+
// Prototype-less, via Object.create(null). A plain object
|
|
116
|
+
// literal inherits Object.prototype, so `commands["constructor"]` and
|
|
117
|
+
// `commands["toString"]` are truthy FUNCTIONS: an unknown verb that happens to
|
|
118
|
+
// be a prototype member passed the known-verb test and was then CALLED, so the
|
|
119
|
+
// documented usage was never printed and the failure surfaced as a confusing
|
|
120
|
+
// message from process.exit() about its argument type instead. Same idiom this
|
|
121
|
+
// file already uses for its flag bag, applied one level up.
|
|
122
|
+
const commands = Object.assign(Object.create(null), { slice: forward, digest: forward });
|
|
123
|
+
|
|
124
|
+
const [cmd, ...rest] = process.argv.slice(2);
|
|
125
|
+
if (!cmd || !commands[cmd]) {
|
|
126
|
+
console.error(`usage: node vsf-slice.mjs <command>
|
|
127
|
+
|
|
128
|
+
slice <snapshot.vsf> --out <image.bin> [--json] write the flat 64K image
|
|
129
|
+
digest <snapshot.vsf> sha256 of the sliced image, writing no file
|
|
130
|
+
|
|
131
|
+
Produces a flat 64K image by slicing the snapshot's memory module body. There
|
|
132
|
+
is no transcription step on this route, so the \`$D000-$DFFF\` volatility rule
|
|
133
|
+
that governs the memory-read route does NOT apply to an image produced this
|
|
134
|
+
way: the snapshot array is RAM under I/O, not the register read view.
|
|
135
|
+
|
|
136
|
+
The layout lives in ${TARGET_FILE} on the MCP side; this script resolves it via
|
|
137
|
+
VICE_MCP_DIR, the in-repo path, or ${TARGET_PACKAGE}, and refuses by name when
|
|
138
|
+
no rung resolves. A malformed snapshot is refused, never truncated.`);
|
|
139
|
+
process.exit(cmd ? 1 : 0);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
try {
|
|
143
|
+
process.exit(commands[cmd]([cmd, ...rest]));
|
|
144
|
+
} catch (e) {
|
|
145
|
+
console.error(`error: ${e.message}`);
|
|
146
|
+
process.exit(1);
|
|
147
|
+
}
|
|
@@ -100,7 +100,7 @@ function loadManifestForRelease(rel) {
|
|
|
100
100
|
* Resolve the two-tier sentinel set for one release from registry data and
|
|
101
101
|
* that release's run1 range manifest -- never hardcoded. `stopping` tier is
|
|
102
102
|
* one sentinel per `loader_ranges` entry (the loader-reentry sentinels that
|
|
103
|
-
* must never fire again after the dump point
|
|
103
|
+
* must never fire again after the dump point). `counting` tier is
|
|
104
104
|
* one sentinel per never-populated range in the run1 manifest, plus one
|
|
105
105
|
* register sentinel on CIA2 port A ($DD00), which carries both the VIC
|
|
106
106
|
* bank-select bits and the bit-banged serial-bus lines a KERNAL-bypassing
|
|
@@ -136,7 +136,7 @@ export function WATCH_SET(releaseId, { registry, manifest } = {}) {
|
|
|
136
136
|
type: "exec",
|
|
137
137
|
start,
|
|
138
138
|
end,
|
|
139
|
-
reason: lr.note ?? "loader-reentry range: must never fire again after the dump point
|
|
139
|
+
reason: lr.note ?? "loader-reentry range: must never fire again after the dump point",
|
|
140
140
|
evidence: lr.evidence ?? "",
|
|
141
141
|
});
|
|
142
142
|
}
|
|
@@ -285,7 +285,7 @@ export function classifyHit(hit) {
|
|
|
285
285
|
* are human-audit artifacts and are never hashed here or anywhere else in
|
|
286
286
|
* this project: an encoder can emit different bytes for pixel-identical
|
|
287
287
|
* images, and this project deliberately installs no image-decoding library
|
|
288
|
-
*
|
|
288
|
+
* to decode-then-hash instead.
|
|
289
289
|
*/
|
|
290
290
|
export function screenSignature(screenMatrixHex, spriteEnable) {
|
|
291
291
|
const buf = Buffer.from(screenMatrixHex, "hex");
|
|
@@ -403,24 +403,24 @@ function renderReleaseSection(id, log) {
|
|
|
403
403
|
for (const h of loadCandidates) {
|
|
404
404
|
s += `- Hit at ${h.address} (cycle ${h.cycle}): supplementary dump \`${h.supplementary_dump ?? "unrecorded"}\`, ` +
|
|
405
405
|
`registry ref \`${h.load_event_ref ?? "unrecorded"}\`. Reproducibility bar: a single capture, decided in ` +
|
|
406
|
-
`
|
|
407
|
-
`
|
|
408
|
-
`three-run bar before
|
|
406
|
+
`the claim is about an observed moment rather than a stable state -- if loaded content is later ` +
|
|
407
|
+
`absorbed into the canonical image, this region must be re-captured at the primary dumps' ` +
|
|
408
|
+
`three-run bar before it can be treated as a round-trip diff target.\n`;
|
|
409
409
|
}
|
|
410
410
|
s += "\n";
|
|
411
411
|
}
|
|
412
412
|
|
|
413
|
-
s += `### Hand-off to
|
|
414
|
-
s += "The registry's `watch_set` entries for this release are the re-armable specification:
|
|
415
|
-
"
|
|
416
|
-
"
|
|
417
|
-
"`reportHits` and `classifyHit` functions. This is a hand-off of data and procedure, not an executable --
|
|
418
|
-
"
|
|
413
|
+
s += `### Hand-off to the exhaustive trace\n\n`;
|
|
414
|
+
s += "The registry's `watch_set` entries for this release are the re-armable specification: whoever runs the " +
|
|
415
|
+
"exhaustive all-chambers trace re-arms the same set by issuing the same `mcp__plugin_c64-re-tools_vice__vice_checkpoint_add` " +
|
|
416
|
+
"calls, and interprets what it observes with this module's pure `attributeAddress`, " +
|
|
417
|
+
"`reportHits` and `classifyHit` functions. This is a hand-off of data and procedure, not an executable -- describe " +
|
|
418
|
+
"agent-performed arming with acceptance criteria over a committed record " +
|
|
419
419
|
"rather than over an exit code. A late hit there reopens this document.\n\n";
|
|
420
420
|
|
|
421
421
|
s += `### Input sequence notes\n\n`;
|
|
422
422
|
s += (log.input_notes ?? "(no input notes recorded)") + "\n\n";
|
|
423
|
-
s += "
|
|
423
|
+
s += "This is plain notes, not a `verify/scripts/` artifact -- the replay tooling owns the real " +
|
|
424
424
|
"input-script format; these notes are a seed for it, not a pre-empting specification.\n\n";
|
|
425
425
|
|
|
426
426
|
s += `### Teardown proof\n\n`;
|
|
@@ -445,6 +445,12 @@ function renderReleaseSection(id, log) {
|
|
|
445
445
|
* string templating over already-fetched data -- nothing here reads a file
|
|
446
446
|
* or contacts anything; the CLI `render` verb below is what reads the
|
|
447
447
|
* hit-log files and writes the result.
|
|
448
|
+
*
|
|
449
|
+
* The two script paths named in the output below are deliberately the
|
|
450
|
+
* consumer's installed location, not this repository's source tree -- this
|
|
451
|
+
* string is written into a `recovery/LOADING.md` a consumer keeps. A blanket
|
|
452
|
+
* path sweep rewrote them to the source tree once already; pinned
|
|
453
|
+
* by the test below plus the class-level registry in skill-consumer-paths.test.ts.
|
|
448
454
|
*/
|
|
449
455
|
export function renderLoading(entries) {
|
|
450
456
|
let out = "# `recovery/LOADING.md` -- the on-demand-load detection record\n\n";
|
|
@@ -4,6 +4,13 @@ One record per capture. Every field is recorded **in the same step as the
|
|
|
4
4
|
capture**, before the machine is resumed — a value read later describes a
|
|
5
5
|
different machine.
|
|
6
6
|
|
|
7
|
+
Every `node scripts/…` command cited below is relative to **this skill's own
|
|
8
|
+
directory** (`src/skills/c64-ram-capture/`), not to the repository root. From
|
|
9
|
+
the repo root, use the `$S`-derived variables `SKILL.md`'s quick-reference
|
|
10
|
+
block defines — `node $V slice …` rather than `node scripts/vsf-slice.mjs
|
|
11
|
+
slice …`. Stated once here because the shorthand is used in five rows and
|
|
12
|
+
`scripts/` resolves from neither cwd by accident.
|
|
13
|
+
|
|
7
14
|
## Identity
|
|
8
15
|
|
|
9
16
|
| Field | Value | How obtained |
|
|
@@ -11,10 +18,30 @@ different machine.
|
|
|
11
18
|
| image path | `recovery/<release>/dumps/<name>.bin` | — |
|
|
12
19
|
| size | `65536` bytes | must be exact; anything else is not a full image |
|
|
13
20
|
| sha256 | `<64 hex chars>` | `node scripts/compare.mjs digest <name>.bin` |
|
|
21
|
+
| binary sha256 | `<64 hex chars>` | `sha256sum <release>.d64` (or `.prg`), or `node scripts/compare.mjs digest <release>.d64`. This identifies the release by its **bytes**, never by its filename |
|
|
22
|
+
| argv digest | `<64 hex chars>` | `argvDigest()` in `src/mcp/vice/capture-predicate.ts` — sha256 over the **exact spawn argv array joined by a single NUL byte**. **Order-sensitive by construction**, which is the whole reason it is a field of its own. It refuses an empty argv array rather than digesting the empty string, so a blank here is a blank and never a plausible-looking digest |
|
|
23
|
+
| seed | `4242` | the `-seed` value the instance was launched with — `STOCK_DETERMINISM_SEED` in `src/mcp/vice/broker-launch.mts`. Cite the constant, not a loose number |
|
|
24
|
+
| capture route | `memory-read` \| `snapshot` | `memory-read` = the sixteen `vice_memory_read` calls, transcribed. `snapshot` = `node scripts/vsf-slice.mjs slice <run>.vsf --out <run>.bin`. **This row decides which volatility rule applies to this record** — see the closing note |
|
|
14
25
|
| checkpoint / trigger address | `$____` | the address armed for this capture |
|
|
15
26
|
| release | the registry id this capture belongs to | — |
|
|
16
27
|
| run | `<N>` of `<total>` | three runs is this project's minimum for a verified capture |
|
|
17
28
|
|
|
29
|
+
### The reproducibility key is the triple, not the seed
|
|
30
|
+
|
|
31
|
+
**`(binary sha256, argv digest, seed)`.** All three rows, together. Two captures
|
|
32
|
+
share a reproducibility key only when all three fields are exactly equal.
|
|
33
|
+
|
|
34
|
+
**MEASURED: the same seed with a reordered argv yielded a 76-byte-different
|
|
35
|
+
image.** So the seed alone is not the key, and a record keyed on it claims a
|
|
36
|
+
reproducibility it does not have. Two captures whose argv digests differ are
|
|
37
|
+
**different keys and must not be compared as a pair**, even at the same seed —
|
|
38
|
+
including when the difference is a single reordered element, because the digest
|
|
39
|
+
is order-sensitive and a reordered argv is a different launch.
|
|
40
|
+
|
|
41
|
+
**A record with any of the three fields blank is not a reproducible capture.**
|
|
42
|
+
The void protocol below applies to it: it is not a capture with a gap in its
|
|
43
|
+
paperwork, it is a capture whose identity cannot be established.
|
|
44
|
+
|
|
18
45
|
## Machine state at the capture instant
|
|
19
46
|
|
|
20
47
|
Read these *before* resuming, in the same paused window as the memory reads.
|
|
@@ -30,6 +57,8 @@ Read these *before* resuming, in the same paused window as the memory reads.
|
|
|
30
57
|
## Verdict
|
|
31
58
|
|
|
32
59
|
- [ ] Size is exactly 65536 bytes.
|
|
60
|
+
- [ ] All three reproducibility-key fields — `binary sha256`, `argv digest`, `seed` — are filled. A blank in any one of them fails this box.
|
|
61
|
+
- [ ] The `capture route` row is filled, so a reader knows which volatility rule this record is under.
|
|
33
62
|
- [ ] No epoch-drift error appeared at any point during the capture.
|
|
34
63
|
- [ ] `vice_checkpoint_list` reported zero checkpoints before resuming.
|
|
35
64
|
- [ ] Machine resumed exactly once, at the end.
|
|
@@ -53,7 +82,18 @@ and keep the voided artifacts on disk.
|
|
|
53
82
|
Record the drift floor address count, and state it as a floor rather than a
|
|
54
83
|
complete set — more captures of the same checkpoint can only widen it.
|
|
55
84
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
85
|
+
### `$D000-$DFFF`: the rule depends on the `capture route` row
|
|
86
|
+
|
|
87
|
+
This exclusion is a property of **how the image was transcribed**, not of the
|
|
88
|
+
C64. Read the `capture route` row and apply the matching half. Getting this
|
|
89
|
+
backwards costs a good capture in one direction and hides 4096 addresses of real
|
|
90
|
+
divergence in the other.
|
|
91
|
+
|
|
92
|
+
| `capture route` | Rule for `$D000-$DFFF` |
|
|
93
|
+
|---|---|
|
|
94
|
+
| `memory-read` | **Any divergence inside `$D000-$DFFF` is not a divergence.** On this route the range is read through `vice_memory_read`, which samples live I/O: the VIC's registers repeat every `$40` across `$D000-$D3FF` and the SID's across `$D400-$D7FF`, so two reads can never agree there. Classify it as volatile, say so in the record, and do not void a good capture over it. |
|
|
95
|
+
| `snapshot` | **The exclusion does not apply, and a divergence there is a real divergence.** A `.vsf`-sliced image is the `C64MEM` array — `mem_ram[]`, which is RAM *under* I/O and not the register read view — so those 4096 addresses are ordinary RAM in a sliced image. There is no transcription step on this route to sample anything. Do not carry the memory-read exclusion across. |
|
|
96
|
+
|
|
97
|
+
Both halves are live: records taken on the memory-read route still exist and
|
|
98
|
+
still need the first row. What is not permitted is applying either half without
|
|
99
|
+
reading which route produced the image.
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# Per-release transient allow-lists
|
|
2
|
+
|
|
3
|
+
One committed JSON artifact per release, written by
|
|
4
|
+
`../scripts/derive-transients.mjs derive`. Nothing in this directory is
|
|
5
|
+
inherited, hand-edited or carried across releases.
|
|
6
|
+
|
|
7
|
+
**What carries forward is the method below and the script that implements it.
|
|
8
|
+
Never an address set.** A real release's transients are its own frame counters,
|
|
9
|
+
RNG state, sprite positions and music-player pointers. An allow-list borrowed
|
|
10
|
+
from another release cannot be distinguished afterwards from one honestly
|
|
11
|
+
derived, so a contaminated ledger has no cheap repair — it has to be re-derived
|
|
12
|
+
from fresh captures.
|
|
13
|
+
|
|
14
|
+
## The committed derivation method
|
|
15
|
+
|
|
16
|
+
Verbatim, because this is the part that is meant to survive:
|
|
17
|
+
|
|
18
|
+
- **N >= 3 runs** of the **same release** under the **same protocol** at the
|
|
19
|
+
**same stop**. Three runs is already this project's documented minimum for a
|
|
20
|
+
verified capture. Fewer is refused, naming the count and the minimum.
|
|
21
|
+
- The allow-list is the **union of addresses differing across the pairwise
|
|
22
|
+
comparisons** — every pairing, which is N(N-1)/2 for N images, not just the
|
|
23
|
+
adjacent ones.
|
|
24
|
+
- Each entry records **the address**, **which run pairs it differed in**, the
|
|
25
|
+
distinct byte values seen, and **a one-line attribution where known** (left
|
|
26
|
+
empty otherwise, so an unattributed transient is visibly unattributed rather
|
|
27
|
+
than absent).
|
|
28
|
+
- It is **re-derived per release**, and **no address set is ever inherited
|
|
29
|
+
between releases**. Re-deriving over an existing artifact is refused without
|
|
30
|
+
an explicit `--force`.
|
|
31
|
+
- The list is **enumerated** — one entry per address, never a range, a span, a
|
|
32
|
+
page or a region. `parseAllowList()` in
|
|
33
|
+
`src/mcp/vice/capture-predicate.ts` refuses range-shaped keys by name.
|
|
34
|
+
- There is **no bit-count tolerance** at any address. A one-bit difference
|
|
35
|
+
outside the list fails.
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
S=src/skills/c64-ram-capture/scripts # from the repo root
|
|
39
|
+
T=src/skills/c64-ram-capture/transients
|
|
40
|
+
|
|
41
|
+
node $S/derive-transients.mjs derive --release <id> \
|
|
42
|
+
--out $T/<id>.json run1.bin run2.bin run3.bin
|
|
43
|
+
|
|
44
|
+
node $S/derive-transients.mjs check --allow-list $T/<id>.json runA.bin runB.bin
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## The artifact shape
|
|
48
|
+
|
|
49
|
+
```json
|
|
50
|
+
{
|
|
51
|
+
"schema_version": "1.0",
|
|
52
|
+
"release": "<the registry id the runs came from>",
|
|
53
|
+
"derived_from": ["run1.bin", "run2.bin", "run3.bin"],
|
|
54
|
+
"pair_count": 3,
|
|
55
|
+
"cap": 64,
|
|
56
|
+
"method": "union of addresses differing across every pairwise comparison ...",
|
|
57
|
+
"entries": [
|
|
58
|
+
{
|
|
59
|
+
"address": 164,
|
|
60
|
+
"pairs": ["run1.bin vs run2.bin", "run2.bin vs run3.bin"],
|
|
61
|
+
"values": ["$11", "$22"],
|
|
62
|
+
"attribution": ""
|
|
63
|
+
}
|
|
64
|
+
]
|
|
65
|
+
}
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
`entries` is ascending by address, so the artifact is diffable and an entry's
|
|
69
|
+
position never depends on which pairing happened to observe it first. `address`
|
|
70
|
+
is an integer; `values` are the distinct bytes seen at it, in hex, across every
|
|
71
|
+
pairing it differed in. This is exactly the shape `parseAllowList()` accepts —
|
|
72
|
+
there is no translation step between the derivation and the predicate, and the
|
|
73
|
+
derivation's colocated test asserts the round-trip rather than assuming it.
|
|
74
|
+
|
|
75
|
+
## The cap is 64, and exceeding it VOIDS the derivation
|
|
76
|
+
|
|
77
|
+
`TRANSIENT_ALLOW_LIST_CAP = 64` in `src/mcp/vice/capture-predicate.ts` is the
|
|
78
|
+
one definition; the script's default cap is asserted equal to it by test.
|
|
79
|
+
`--cap` **only ever narrows** — a value above the committed cap is refused by
|
|
80
|
+
name.
|
|
81
|
+
|
|
82
|
+
**Over the cap is not "the list is a bit long".** It means **the stop is not
|
|
83
|
+
frame-exact**, and that is a fact the gate must hear rather than a threshold to
|
|
84
|
+
move. So the script exits non-zero, writes **no artifact at all** — not a
|
|
85
|
+
partial one and not a truncated one — and says so. A list truncated to fit the
|
|
86
|
+
cap would make every later comparison pass on bytes nobody vetted, which is the
|
|
87
|
+
one failure this rule exists to remove. Raising the cap after seeing a
|
|
88
|
+
derivation overflow converts a measurement into an excuse.
|
|
89
|
+
|
|
90
|
+
### What the cap actually distinguishes — four measured reference points
|
|
91
|
+
|
|
92
|
+
The cap does not separate "a few transients" from "a lot of transients". It
|
|
93
|
+
separates **a frame-exact stop** from **a stop that is not**. The numbers, all
|
|
94
|
+
measured on this host:
|
|
95
|
+
|
|
96
|
+
| Differing addresses | The stop that produced it | Verdict under the cap |
|
|
97
|
+
|---|---|---|
|
|
98
|
+
| **0** | frame-exact stop at the KERNAL `READY` prompt | derivation writes |
|
|
99
|
+
| **66** | autostarted stop, frame anchor, pre-protocol jitter 4000 ms (48 for the reported jitter-0/2500 pair) | **VOID** — 2 over |
|
|
100
|
+
| **300** | wall-clock autostarted stop on a real cracked release | **VOID** — 4.7× the cap |
|
|
101
|
+
| **1242** | wall-clock `READY`-prompt stop with the determinism block fully applied | **VOID** — 19× the cap |
|
|
102
|
+
|
|
103
|
+
Read the second row carefully: **overflow is an observed outcome, not a
|
|
104
|
+
hypothetical.** A frame-anchored autostarted stop has already been measured
|
|
105
|
+
over the cap at one jitter and under it at another. The answer to that is a
|
|
106
|
+
better stop, recorded with the jitter it was taken at — not a bigger cap.
|
|
107
|
+
|
|
108
|
+
The 1242 row is also the reason the seed is not the whole story: that stop had
|
|
109
|
+
the determinism block applied and still differed at 1242 bytes, because it was
|
|
110
|
+
wall-clock-anchored. The residual is what the reset protocol and the frame
|
|
111
|
+
anchor close, not what the seed does.
|
|
112
|
+
|
|
113
|
+
## `$0000` / `$0001` do not belong in a derived list by hand
|
|
114
|
+
|
|
115
|
+
The 6510 port overlay is normalised **in code**, once, by `normalisePorts()` on
|
|
116
|
+
the snapshot route. Spending two of the cap's 64 slots on those two addresses
|
|
117
|
+
would hide a real difference behind a known one. They can legitimately appear in
|
|
118
|
+
a derivation taken from un-normalised images — `derive` compares the bytes it is
|
|
119
|
+
given — so if you see them in an artifact, that is what it is telling you about
|
|
120
|
+
the images, not a rule to add.
|
|
121
|
+
|
|
122
|
+
## What this directory refuses, and why now
|
|
123
|
+
|
|
124
|
+
`.gitignore` here refuses every image byte form — flat captures, disk and tape
|
|
125
|
+
images, cartridges, `.vsf` snapshots, and the archives an image arrives inside —
|
|
126
|
+
and it was committed **before the first derivation existed**. A directory that
|
|
127
|
+
starts refusing images after the first capture lands has already had one
|
|
128
|
+
commit's worth of opportunity to leak one. JSON is deliberately not refused:
|
|
129
|
+
the derived allow-lists are the artifacts this directory is for.
|
|
130
|
+
|
|
131
|
+
**If you are reading this from an installed copy of the skill, that `.gitignore`
|
|
132
|
+
did not come with it.** npm excludes a nested `.gitignore` from a published
|
|
133
|
+
tarball, so the installed `transients/` directory has this README and nothing
|
|
134
|
+
else. Add the same refusals to your own project's ignore rules before you take
|
|
135
|
+
the first capture — the whole point of the timing is that it happens before,
|
|
136
|
+
not after.
|