@henols/vice-mcp 0.2.0 → 0.2.2
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 +2 -1
- package/THIRD-PARTY-NOTICES.md +1 -1
- package/anno-acme-ident.ts +97 -0
- package/anno-cli.ts +1465 -0
- package/anno-confidence.ts +233 -0
- package/anno-coverage.ts +2465 -0
- package/anno-d64.ts +310 -0
- package/anno-derive.ts +590 -0
- package/anno-details.ts +169 -0
- package/anno-enum-gen.ts +533 -0
- package/anno-export-asm.ts +1310 -0
- package/anno-index.ts +150 -0
- package/anno-memmap-render.ts +672 -0
- package/anno-regbits-gen.ts +421 -0
- package/anno-regbits.json +1370 -0
- package/anno-register.ts +240 -0
- package/anno-store.ts +3486 -0
- package/anno-symbols.ts +266 -0
- package/anno-tools.ts +2111 -0
- package/anno-types.ts +1636 -0
- package/block-class.ts +201 -0
- package/build.ts +1 -1
- package/capability-registry.ts +3 -1
- package/disasm-decoder.ts +14 -14
- package/disasm-opcodes.ts +4 -4
- package/disasm-renderer.ts +2 -2
- package/hostpath.ts +1 -1
- package/install-resources.ts +1 -1
- package/package.json +23 -3
- package/prg-image.ts +119 -0
- package/repo-root.ts +20 -5
- package/resources/broker-launch.mjs +8 -4
- package/resources/vice-launcher.sh +3 -3
- package/stock-address.ts +5 -5
- package/stock-cia.ts +2 -2
- package/stock-condition.ts +7 -7
- package/stock-connect.ts +1 -1
- package/stock-dispatch.ts +35 -5
- package/stock-execution.ts +5 -3
- package/stock-input.ts +9 -9
- package/stock-machine.ts +17 -6
- package/stock-protocol.ts +16 -11
- package/stock-registers.ts +54 -29
- package/stock-sprites.ts +3 -3
- package/stock-symbols.ts +33 -9
- package/stock-timing.ts +1 -1
- package/stock-vicii.ts +1 -1
- package/version.ts +1 -1
- package/vice-proxy.ts +168 -0
|
@@ -0,0 +1,672 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// anno-memmap-render.ts -- the ONE authoritative place in this repo that
|
|
3
|
+
// renders the human-readable Markdown memory map from this project's own
|
|
4
|
+
// annotation store (D-24) plus a validated run-scoped provenance sidecar
|
|
5
|
+
// (D-27's reconciliation, recorded in 11-10-PLAN.md's objective).
|
|
6
|
+
//
|
|
7
|
+
// WHY THIS MODULE EXISTS (D-24): the store is canonical; the Markdown memory
|
|
8
|
+
// map becomes a rendered VIEW. Criterion 1 says findings must be queryable
|
|
9
|
+
// "instead of re-deriving from Markdown prose" -- that is only true by
|
|
10
|
+
// construction if the prose is GENERATED from the queryable thing. Nothing
|
|
11
|
+
// downstream of this module may hand-author an address row: every row in
|
|
12
|
+
// the Range/Contents/Confidence/Evidence table comes from the store's own
|
|
13
|
+
// `listRanges()`/`listLabels()`/`listComments()` readers, never from a
|
|
14
|
+
// human editing the output file directly.
|
|
15
|
+
//
|
|
16
|
+
// THE D-24/D-27 RECONCILIATION THIS FILE IMPLEMENTS: run-scoped facts (the
|
|
17
|
+
// capture's SHA-256, `$01`, `$DD00`, the derived graphics chain, the video
|
|
18
|
+
// standard, the live vector pair, observed raster positions) are facts about
|
|
19
|
+
// a RUN, not about an address -- the store is address-keyed and has no shape
|
|
20
|
+
// for them. They arrive here as an INPUT to the renderer (a JSON sidecar,
|
|
21
|
+
// `parseProvenanceHeader()`'s own schema), never as a hand-edited region of
|
|
22
|
+
// this module's OUTPUT. A missing or malformed required sidecar key is a
|
|
23
|
+
// named error listing every problem at once; this module never substitutes a
|
|
24
|
+
// `<placeholder>` for one.
|
|
25
|
+
//
|
|
26
|
+
// WHY THE LAYOUT IS EMBEDDED IN TYPESCRIPT RATHER THAN READ FROM A TEMPLATE
|
|
27
|
+
// FILE AT RUNTIME (the second decision this plan records): Phase 10's D-06
|
|
28
|
+
// established that `.claude/mcp/vice/*.ts` exists as files on disk only
|
|
29
|
+
// under the Claude Code plugin route -- both npm-installer routes launch via
|
|
30
|
+
// `npx`. A renderer that resolved a template path into the skills tree at
|
|
31
|
+
// runtime would silently fail to resolve for an npm-installed user. The
|
|
32
|
+
// recon skill's own template becomes prose pointing at this generator
|
|
33
|
+
// instead (a later plan's job); this module hardcodes the target shape.
|
|
34
|
+
//
|
|
35
|
+
// WHAT THIS IS THE ONE AUTHORITATIVE PLACE FOR:
|
|
36
|
+
// - the provenance sidecar schema (`ProvenanceHeader`,
|
|
37
|
+
// `parseProvenanceHeader()`) -- nowhere else in this repo may hand-parse
|
|
38
|
+
// or hand-validate that JSON shape;
|
|
39
|
+
// - rendering the memory map (`renderMemoryMap()`) -- nowhere else may
|
|
40
|
+
// assemble the Range/Contents/Confidence/Evidence table or the banner;
|
|
41
|
+
// - drift detection (`checkRenderedMemoryMap()`) -- the one place a
|
|
42
|
+
// rendered file on disk is compared against what the store (plus the
|
|
43
|
+
// sidecar) would produce right now;
|
|
44
|
+
// - Markdown-cell escaping (`escapeMarkdownCell()`, WR-04, closed) --
|
|
45
|
+
// every store-derived text interpolation in the generated document
|
|
46
|
+
// (comment evidence, symbol names) is escaped through this one
|
|
47
|
+
// function, never a second ad hoc `.replace()` at a call site.
|
|
48
|
+
//
|
|
49
|
+
// WHAT NOT TO DO, named concretely:
|
|
50
|
+
// - Never hand-edit the rendered output. The banner exists precisely so a
|
|
51
|
+
// human editor is caught by `checkRenderedMemoryMap()` -- see the
|
|
52
|
+
// `render_digest` comment below for exactly what it covers.
|
|
53
|
+
// - Never read the layout from the skills tree at runtime (Phase 10 D-06).
|
|
54
|
+
// This module's own non-vacuity test asserts a zero-count grep for the
|
|
55
|
+
// recon skill's template filename -- if you are tempted to add a
|
|
56
|
+
// `readFileSync()` call reaching into `.claude/skills/`, don't; the
|
|
57
|
+
// layout lives here, in TypeScript, by design.
|
|
58
|
+
// - Never substitute a placeholder for a missing or malformed sidecar key.
|
|
59
|
+
// `parseProvenanceHeader()` throws, naming every problem at once, rather
|
|
60
|
+
// than rendering a document that LOOKS complete but silently carries a
|
|
61
|
+
// `<hash>`-shaped lie.
|
|
62
|
+
// - Never write an address row from anywhere but the store. If a future
|
|
63
|
+
// caller wants to add a derived-but-not-address-keyed fact (a new
|
|
64
|
+
// run-scoped field), it joins `ProvenanceHeader`'s schema, not a second
|
|
65
|
+
// ad hoc parameter to `renderMemoryMap()`.
|
|
66
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
67
|
+
import { createHash } from "node:crypto";
|
|
68
|
+
|
|
69
|
+
import { CONFIDENCE_GRADES, parseConfidencePrefix } from "./anno-confidence.ts";
|
|
70
|
+
import type { ConfidenceGrade } from "./anno-confidence.ts";
|
|
71
|
+
import { openStore, closeStore, listRanges, listLabels, listComments } from "./anno-store.ts";
|
|
72
|
+
import { COMMENT_TYPES, workspaceRelativePath } from "./anno-types.ts";
|
|
73
|
+
import type { CommentRow, LabelRow, RangeRow } from "./anno-types.ts";
|
|
74
|
+
import { blockClassAt } from "./block-class.ts";
|
|
75
|
+
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
// WHAT THE VERSION-2 DIGEST HASHED -- the provenance of a lineage this
|
|
78
|
+
// renderer no longer reads. Version 2 hashed three wire result shapes
|
|
79
|
+
// measured LIVE against a real external-analyser-core-0.9.20
|
|
80
|
+
// `--mcp-server-stdio` child, never transcribed from a document:
|
|
81
|
+
// `anno_get_blocks` returned `{start_address, end_address, type}`,
|
|
82
|
+
// `anno_get_symbols` returned `{address, name, kind, type}`, and
|
|
83
|
+
// `anno_get_comments` returned `{address, comment, type}`.
|
|
84
|
+
//
|
|
85
|
+
// THIS PARAGRAPH IS THE RECORD, not a pointer at one. The three `interface`
|
|
86
|
+
// declarations it used to sit above went with the queries, so the spellings
|
|
87
|
+
// are carried here inline rather than left as a comment above a hole. It
|
|
88
|
+
// survives because `RENDERER_VERSION`'s "2" -> "3" bump is a statement about
|
|
89
|
+
// TWO KNOWN input shapes -- version 3 canonicalises this store's own
|
|
90
|
+
// `RangeRow`/`LabelRow`/`CommentRow` -- and that statement is only true
|
|
91
|
+
// while the older one is on the record. Delete this and the bump names one
|
|
92
|
+
// known input shape and one assumed one.
|
|
93
|
+
// ---------------------------------------------------------------------------
|
|
94
|
+
|
|
95
|
+
function errMsg(err: unknown): string {
|
|
96
|
+
return err instanceof Error ? err.message : String(err);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The ONE piece of a `JSON.parse` failure that is safe to report: the byte
|
|
101
|
+
* offset at which parsing stopped, as ` (at byte offset N)`, or `""` when the
|
|
102
|
+
* runtime did not name one.
|
|
103
|
+
*
|
|
104
|
+
* WHY THIS IS A DIGIT EXTRACTOR AND NOT A MESSAGE PASS-THROUGH (CR-03). V8's
|
|
105
|
+
* JSON `SyntaxError` embeds a SNIPPET OF THE INPUT in its own message --
|
|
106
|
+
* `Unexpected token 'Q', "QQZZORACLE"... is not valid JSON` -- so any code
|
|
107
|
+
* that forwards `err.message` from a JSON parse over caller-supplied bytes is
|
|
108
|
+
* a content-disclosure oracle. The capture group here is `(\d+)` and nothing
|
|
109
|
+
* else, so no byte of the parsed file can reach the returned string however
|
|
110
|
+
* the runtime words its message. Widening this regex to capture anything but
|
|
111
|
+
* digits reopens CR-03.
|
|
112
|
+
*
|
|
113
|
+
* Returns `""` rather than guessing when no position is present (`Unexpected
|
|
114
|
+
* end of JSON input` carries none) -- an absent offset is reported by absence,
|
|
115
|
+
* never by a fabricated zero.
|
|
116
|
+
*/
|
|
117
|
+
function jsonParsePosition(err: unknown): string {
|
|
118
|
+
const match = /\bat position (\d+)\b/.exec(errMsg(err));
|
|
119
|
+
return match ? ` (at byte offset ${match[1]})` : "";
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** The store's own spelling for a comment placed on its own line before the
|
|
123
|
+
* instruction, read out of `COMMENT_TYPES` -- the ONE home of that
|
|
124
|
+
* vocabulary -- rather than re-typed as a literal here. The pre-store
|
|
125
|
+
* renderer passed `type: "line"` to its comment query; this is that filter,
|
|
126
|
+
* moved to the read boundary. */
|
|
127
|
+
const [LINE_COMMENT] = COMMENT_TYPES;
|
|
128
|
+
|
|
129
|
+
// ---------------------------------------------------------------------------
|
|
130
|
+
// The provenance sidecar schema.
|
|
131
|
+
// ---------------------------------------------------------------------------
|
|
132
|
+
|
|
133
|
+
export interface ProvenanceHeader {
|
|
134
|
+
capturePath: string;
|
|
135
|
+
/** 64 lowercase or uppercase hex characters -- the capture's SHA-256. */
|
|
136
|
+
captureSha256: string;
|
|
137
|
+
/** The `$01` port value, e.g. `"$35"`. */
|
|
138
|
+
port01: string;
|
|
139
|
+
/** The `$DD00` value, e.g. `"$06"`. */
|
|
140
|
+
dd00: string;
|
|
141
|
+
/** The VIC bank derived from `$DD00` bits 0-1 (inverted), e.g. `"0 ($0000-$3FFF)"`. */
|
|
142
|
+
vicBank: string;
|
|
143
|
+
/** Screen RAM derived from `$D018` bits 4-7, e.g. `"$0400"`. */
|
|
144
|
+
screenRam: string;
|
|
145
|
+
/** Charset/bitmap derived from `$D018` bits 1-3, e.g. `"$1000 (ROM shadow)"`. */
|
|
146
|
+
charsetOrBitmap: string;
|
|
147
|
+
/** The graphics mode derived from `$D011` bits 5-6 and `$D016` bit 4, e.g. `"text, multicolor off"`. */
|
|
148
|
+
mode: string;
|
|
149
|
+
videoStandard: "PAL" | "NTSC";
|
|
150
|
+
/** The live vector pair in effect, e.g. `"$0314/$0315"` or `"$FFFE/$FFFF"`. */
|
|
151
|
+
liveVectorPair: string;
|
|
152
|
+
/** The address (or label) the live vector pair points at. */
|
|
153
|
+
vectorHandler: string;
|
|
154
|
+
/** One entry per observed `$D012` write on the way out of a handler. Optional. */
|
|
155
|
+
rasterPositions?: string[];
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const REQUIRED_STRING_KEYS: readonly (keyof ProvenanceHeader)[] = [
|
|
159
|
+
"capturePath",
|
|
160
|
+
"captureSha256",
|
|
161
|
+
"port01",
|
|
162
|
+
"dd00",
|
|
163
|
+
"vicBank",
|
|
164
|
+
"screenRam",
|
|
165
|
+
"charsetOrBitmap",
|
|
166
|
+
"mode",
|
|
167
|
+
"videoStandard",
|
|
168
|
+
"liveVectorPair",
|
|
169
|
+
"vectorHandler",
|
|
170
|
+
];
|
|
171
|
+
|
|
172
|
+
/** A template placeholder is anything shaped like `<...>` -- the recon
|
|
173
|
+
* template's own placeholders (`<hash>`, `<PAL/NTSC>`, `<n>`, `<value>`,
|
|
174
|
+
* `<handler>`, ...) are exactly this shape, and the most likely thing to be
|
|
175
|
+
* copied into a sidecar by accident. */
|
|
176
|
+
const PLACEHOLDER_PATTERN = /^<.*>$/;
|
|
177
|
+
|
|
178
|
+
export class AnnoProvenanceHeaderError extends Error {
|
|
179
|
+
/** Every problem found, one entry per offending key -- a caller filling a
|
|
180
|
+
* sidecar wants the whole list, not one problem at a time. */
|
|
181
|
+
problems: readonly string[];
|
|
182
|
+
|
|
183
|
+
constructor(message: string, problems: readonly string[]) {
|
|
184
|
+
super(message);
|
|
185
|
+
this.name = "AnnoProvenanceHeaderError";
|
|
186
|
+
this.problems = problems;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Parses and validates a provenance sidecar. Collects EVERY problem (a
|
|
192
|
+
* missing key, a non-string value, a template placeholder, a malformed
|
|
193
|
+
* `captureSha256`, an invalid `videoStandard`, a malformed
|
|
194
|
+
* `rasterPositions`) into one list and throws `AnnoProvenanceHeaderError`
|
|
195
|
+
* naming all of them at once -- never one at a time.
|
|
196
|
+
*/
|
|
197
|
+
export function parseProvenanceHeader(json: unknown): ProvenanceHeader {
|
|
198
|
+
const problems: string[] = [];
|
|
199
|
+
|
|
200
|
+
if (typeof json !== "object" || json === null || Array.isArray(json)) {
|
|
201
|
+
throw new AnnoProvenanceHeaderError(
|
|
202
|
+
`provenance sidecar must be a JSON object, got ${Array.isArray(json) ? "an array" : typeof json}`,
|
|
203
|
+
["<root>: must be a JSON object"],
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
const obj = json as Record<string, unknown>;
|
|
207
|
+
|
|
208
|
+
for (const key of REQUIRED_STRING_KEYS) {
|
|
209
|
+
const value = obj[key];
|
|
210
|
+
if (typeof value !== "string" || value.trim() === "") {
|
|
211
|
+
problems.push(`${key}: missing or not a non-empty string`);
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
if (PLACEHOLDER_PATTERN.test(value.trim())) {
|
|
215
|
+
problems.push(`${key}: still carries a template placeholder (${value}) -- fill in the real value`);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const sha = obj.captureSha256;
|
|
220
|
+
if (typeof sha === "string" && sha.trim() !== "" && !PLACEHOLDER_PATTERN.test(sha.trim())) {
|
|
221
|
+
if (!/^[0-9a-fA-F]{64}$/.test(sha.trim())) {
|
|
222
|
+
problems.push(`captureSha256: must be exactly 64 hex characters, got "${sha}" (length ${sha.trim().length})`);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const vs = obj.videoStandard;
|
|
227
|
+
if (typeof vs === "string" && vs.trim() !== "" && !PLACEHOLDER_PATTERN.test(vs.trim())) {
|
|
228
|
+
if (vs !== "PAL" && vs !== "NTSC") {
|
|
229
|
+
problems.push(`videoStandard: must be exactly "PAL" or "NTSC", got "${vs}"`);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
let rasterPositions: string[] | undefined;
|
|
234
|
+
if (obj.rasterPositions !== undefined) {
|
|
235
|
+
const rp = obj.rasterPositions;
|
|
236
|
+
if (!Array.isArray(rp) || rp.some((v) => typeof v !== "string")) {
|
|
237
|
+
problems.push("rasterPositions: when present must be an array of strings");
|
|
238
|
+
} else {
|
|
239
|
+
rasterPositions = rp as string[];
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (problems.length > 0) {
|
|
244
|
+
throw new AnnoProvenanceHeaderError(
|
|
245
|
+
`provenance sidecar has ${problems.length} problem(s):\n` + problems.map((p) => ` - ${p}`).join("\n"),
|
|
246
|
+
problems,
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
return {
|
|
251
|
+
capturePath: obj.capturePath as string,
|
|
252
|
+
captureSha256: (obj.captureSha256 as string).trim(),
|
|
253
|
+
port01: obj.port01 as string,
|
|
254
|
+
dd00: obj.dd00 as string,
|
|
255
|
+
vicBank: obj.vicBank as string,
|
|
256
|
+
screenRam: obj.screenRam as string,
|
|
257
|
+
charsetOrBitmap: obj.charsetOrBitmap as string,
|
|
258
|
+
mode: obj.mode as string,
|
|
259
|
+
videoStandard: obj.videoStandard as "PAL" | "NTSC",
|
|
260
|
+
liveVectorPair: obj.liveVectorPair as string,
|
|
261
|
+
vectorHandler: obj.vectorHandler as string,
|
|
262
|
+
rasterPositions,
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// ---------------------------------------------------------------------------
|
|
267
|
+
// The render digest -- documented exactly, because a digest whose inputs are
|
|
268
|
+
// unclear is a digest nobody trusts. It covers, in order: a canonical JSON
|
|
269
|
+
// serialisation of the SORTED `listRanges()`/`listLabels()`/
|
|
270
|
+
// `listComments()` store rows (so a store-side change, e.g. a comment's
|
|
271
|
+
// confidence grade, changes the digest even with the rendered file
|
|
272
|
+
// untouched), the raw provenance sidecar BYTES (not the parsed object, so
|
|
273
|
+
// even whitespace-only sidecar edits are covered), and this renderer's own
|
|
274
|
+
// version constant (so a future format change is distinguishable from a
|
|
275
|
+
// hand edit).
|
|
276
|
+
// ---------------------------------------------------------------------------
|
|
277
|
+
|
|
278
|
+
/** Bumped whenever this renderer's OUTPUT SHAPE **or its digest's canonical
|
|
279
|
+
* INPUT** changes, so a re-render under a new renderer version is
|
|
280
|
+
* distinguishable from drift under the same one.
|
|
281
|
+
*
|
|
282
|
+
* Version 2 (260821-a86) escaped Markdown table cells via
|
|
283
|
+
* `escapeMarkdownCell()` -- WR-04, an output-shape change.
|
|
284
|
+
*
|
|
285
|
+
* Version 3 (D-17) is an INPUT change: `computeRenderDigest()` canonicalises
|
|
286
|
+
* this store's own `RangeRow`/`LabelRow`/`CommentRow` instead of the three
|
|
287
|
+
* wire shapes recorded above, so the same underlying annotations hash
|
|
288
|
+
* differently either side of it. Leaving the version at "2" across that
|
|
289
|
+
* boundary would let two incompatible renderings compare as ordinary drift. */
|
|
290
|
+
export const RENDERER_VERSION = "3";
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Escapes `text` for safe interpolation into a Markdown table cell or list
|
|
294
|
+
* item: every `|` becomes `\|`, and every `\r\n`/`\n`/bare `\r` collapses to
|
|
295
|
+
* `<br>` (a single-line-safe line break inside a table cell). This control
|
|
296
|
+
* ESCAPES and never REJECTS -- unlike the label-name policy
|
|
297
|
+
* (`anno-acme-ident.ts`'s `assertLegalAcmeIdentifier()`, T-11-NAME-INJECT's
|
|
298
|
+
* other leg), because comment `evidence` legitimately contains `|` and
|
|
299
|
+
* embedded newlines (`anno_set_comment`'s own schema documents multi-line
|
|
300
|
+
* support) -- refusing here would refuse valid data, not an attack. Closes
|
|
301
|
+
* WR-04 / T-11-NAME-INJECT's render leg: an unescaped `|` or newline in
|
|
302
|
+
* store text used to be able to inject an extra table cell or split a row
|
|
303
|
+
* across lines in the generated Markdown. A plain string or an empty string
|
|
304
|
+
* is returned unchanged. */
|
|
305
|
+
export function escapeMarkdownCell(text: string): string {
|
|
306
|
+
return text.replace(/\|/g, "\\|").replace(/\r\n|\r|\n/g, "<br>");
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function computeRenderDigest(
|
|
310
|
+
blocks: readonly RangeRow[],
|
|
311
|
+
symbols: readonly LabelRow[],
|
|
312
|
+
comments: readonly CommentRow[],
|
|
313
|
+
sidecarBytes: string,
|
|
314
|
+
): string {
|
|
315
|
+
const canonical = JSON.stringify({ blocks, symbols, comments }) + "" + sidecarBytes + "" + RENDERER_VERSION;
|
|
316
|
+
return createHash("sha256").update(canonical, "utf8").digest("hex");
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function hex4(addr: number): string {
|
|
320
|
+
return `$${addr.toString(16).toUpperCase().padStart(4, "0")}`;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
interface GradedComment {
|
|
324
|
+
address: number;
|
|
325
|
+
grade: ConfidenceGrade | null;
|
|
326
|
+
evidence: string;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// ---------------------------------------------------------------------------
|
|
330
|
+
// renderMemoryMap()
|
|
331
|
+
// ---------------------------------------------------------------------------
|
|
332
|
+
|
|
333
|
+
export interface RenderMemoryMapOptions {
|
|
334
|
+
/** The annotation store to render. The CALLER confines it through
|
|
335
|
+
* `storePathWithinWorkspace()` and `openStore()` below confines it again
|
|
336
|
+
* against the same `workspaceRoot`, so both answers agree by construction
|
|
337
|
+
* rather than by a second rule (T-29-51). */
|
|
338
|
+
storePath: string;
|
|
339
|
+
/** The provenance sidecar to render from. The CALLER confines it through
|
|
340
|
+
* `storePathWithinWorkspace()` before entering this module -- today that
|
|
341
|
+
* caller is `anno-cli.ts`'s `cmdRenderMemmap()`. THIS MODULE PERFORMS NO
|
|
342
|
+
* CONFINEMENT OF ITS OWN, and must never be handed a path that has not
|
|
343
|
+
* been through that seam.
|
|
344
|
+
*
|
|
345
|
+
* THIS FIELD WAS DOCUMENTED BY SILENCE, and the silence is what the review
|
|
346
|
+
* names as the mechanism. `storePath` one line above carried four lines
|
|
347
|
+
* stating who confines it; this field, an equally caller-supplied path
|
|
348
|
+
* reaching an equally real `readFileSync`, carried nothing -- so a reader
|
|
349
|
+
* comparing the two would reasonably conclude the difference was
|
|
350
|
+
* deliberate. It was not: the CLI read this argument raw, making it an
|
|
351
|
+
* arbitrary-file read oracle (`29-REVIEW.md` CR-03). An absent comment
|
|
352
|
+
* beside a present one is a claim, and this one was false. */
|
|
353
|
+
provenancePath: string;
|
|
354
|
+
/** The workspace root both confinement checks are taken against. REQUIRED
|
|
355
|
+
* rather than defaulted: `openStore()`'s default behaviour is to CREATE
|
|
356
|
+
* the file, so an unconfined store path is a store file created wherever
|
|
357
|
+
* the caller's argument pointed. */
|
|
358
|
+
workspaceRoot: string;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
export interface RenderMemoryMapResult {
|
|
362
|
+
markdown: string;
|
|
363
|
+
renderDigest: string;
|
|
364
|
+
/** Number of Range/Contents/Confidence/Evidence rows emitted. */
|
|
365
|
+
rowCount: number;
|
|
366
|
+
/** Number of comments carrying the `[unknown]` grade -- the Open questions count. */
|
|
367
|
+
unknownCount: number;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Renders the memory map from an annotation store plus a validated
|
|
372
|
+
* provenance sidecar. Reads the store DIRECTLY -- `listRanges()`,
|
|
373
|
+
* `listLabels()` and `listComments()` on ONE handle, opened once per render
|
|
374
|
+
* and closed in a `finally` -- with no child process anywhere on this path.
|
|
375
|
+
*
|
|
376
|
+
* The store's block-kind spelling is interpreted in exactly one place,
|
|
377
|
+
* `block-class.ts`'s `blockClassAt()`; nothing below compares a
|
|
378
|
+
* `dataType` string itself.
|
|
379
|
+
*
|
|
380
|
+
* A malformed confidence prefix inside a store comment (a typo that survived
|
|
381
|
+
* whatever wrote it) THROWS through `parseConfidencePrefix()` -- this
|
|
382
|
+
* renderer never silently drops or blanks a grade it cannot parse; the typo
|
|
383
|
+
* must be fixed in the store, not hidden in the rendered view.
|
|
384
|
+
*/
|
|
385
|
+
export async function renderMemoryMap(opts: RenderMemoryMapOptions): Promise<RenderMemoryMapResult> {
|
|
386
|
+
const { storePath, provenancePath, workspaceRoot } = opts;
|
|
387
|
+
|
|
388
|
+
let sidecarBytes: string;
|
|
389
|
+
try {
|
|
390
|
+
sidecarBytes = readFileSync(provenancePath, "utf8");
|
|
391
|
+
} catch (err) {
|
|
392
|
+
throw new Error(`renderMemoryMap: could not read provenance sidecar at "${provenancePath}": ${errMsg(err)}`);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
let sidecarJson: unknown;
|
|
396
|
+
try {
|
|
397
|
+
sidecarJson = JSON.parse(sidecarBytes);
|
|
398
|
+
} catch (err) {
|
|
399
|
+
// NEVER INTERPOLATE THE UNDERLYING PARSE ERROR HERE (CR-03). Node's
|
|
400
|
+
// SyntaxError quotes a snippet of the input it choked on -- e.g.
|
|
401
|
+
// `Unexpected token 'Q', "QQZZORACLE"... is not valid JSON` -- so passing
|
|
402
|
+
// it through turns a read refusal into a CONTENT-DISCLOSURE ORACLE. That
|
|
403
|
+
// matters here specifically because this argument arrives from an
|
|
404
|
+
// agent-composed Bash invocation: the shipped playbooks tell an LLM to
|
|
405
|
+
// compose this path, so the error text is read by whatever composed it.
|
|
406
|
+
//
|
|
407
|
+
// What survives is everything a caller legitimately needs to fix the
|
|
408
|
+
// problem: WHICH file, and THAT it is not JSON. The byte OFFSET is
|
|
409
|
+
// included where Node exposes one, because a position is a fact about
|
|
410
|
+
// where parsing stopped and not about what the file contains.
|
|
411
|
+
throw new Error(
|
|
412
|
+
`renderMemoryMap: provenance sidecar at "${provenancePath}" is not valid JSON${jsonParsePosition(err)}. ` +
|
|
413
|
+
"The underlying parser message is deliberately NOT included -- it quotes the file's own bytes (CR-03).",
|
|
414
|
+
);
|
|
415
|
+
}
|
|
416
|
+
const provenance = parseProvenanceHeader(sidecarJson);
|
|
417
|
+
|
|
418
|
+
// ONE handle for the whole render, closed in a `finally`. `mustExist` is
|
|
419
|
+
// what makes "the annotations are gone" and "there are no annotations"
|
|
420
|
+
// refuse differently (T-29-52): without it a mistyped path would CREATE an
|
|
421
|
+
// empty store and render as an empty memory map indistinguishable from a
|
|
422
|
+
// real one.
|
|
423
|
+
const handle = openStore(storePath, { workspaceRoot, mustExist: true });
|
|
424
|
+
let ranges: RangeRow[];
|
|
425
|
+
let labels: LabelRow[];
|
|
426
|
+
let lineComments: CommentRow[];
|
|
427
|
+
try {
|
|
428
|
+
ranges = listRanges(handle);
|
|
429
|
+
labels = listLabels(handle);
|
|
430
|
+
lineComments = listComments(handle).filter((c) => c.commentType === LINE_COMMENT);
|
|
431
|
+
} finally {
|
|
432
|
+
closeStore(handle);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
const sortedBlocks = [...ranges].sort((a, b) => a.start - b.start);
|
|
436
|
+
const sortedSymbols = [...labels].sort((a, b) => a.address - b.address);
|
|
437
|
+
const sortedComments = [...lineComments].sort((a, b) => a.address - b.address);
|
|
438
|
+
|
|
439
|
+
// The block listing in `block-class.ts`'s own entry shape. The `dataType`
|
|
440
|
+
// column is copied VERBATIM and never compared here -- that module is the
|
|
441
|
+
// one place in this tree allowed to interpret it.
|
|
442
|
+
const blockEntries = sortedBlocks.map((row) => ({
|
|
443
|
+
start_address: row.start,
|
|
444
|
+
end_address: row.endInclusive,
|
|
445
|
+
type: row.dataType as string,
|
|
446
|
+
}));
|
|
447
|
+
|
|
448
|
+
const gradedComments: GradedComment[] = sortedComments.map((c) => {
|
|
449
|
+
const parsed = parseConfidencePrefix(c.text);
|
|
450
|
+
return { address: c.address, grade: parsed.grade, evidence: parsed.rest };
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
function findGradeInRange(startAddr: number, endAddr: number): GradedComment | undefined {
|
|
454
|
+
return gradedComments.find((c) => c.address >= startAddr && c.address <= endAddr);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
const renderDigest = computeRenderDigest(sortedBlocks, sortedSymbols, sortedComments, sidecarBytes);
|
|
458
|
+
|
|
459
|
+
// The two recorded locations are WORKSPACE-RELATIVE, and that is the
|
|
460
|
+
// load-bearing detail rather than a formatting preference: every byte below
|
|
461
|
+
// is re-rendered and compared BYTE FOR BYTE by `checkRenderedMemoryMap()`,
|
|
462
|
+
// so an absolute path here would make the drift verdict a function of where
|
|
463
|
+
// the checkout sits (CR-01). `workspaceRelativePath()` is the one definition
|
|
464
|
+
// of that spelling; it computes a location and refuses one that escapes the
|
|
465
|
+
// root. It is NOT a confinement check -- this module still performs no
|
|
466
|
+
// confinement of its own, exactly as `RenderMemoryMapOptions` documents.
|
|
467
|
+
const storeLocation = workspaceRelativePath(storePath, workspaceRoot);
|
|
468
|
+
const sidecarLocation = workspaceRelativePath(provenancePath, workspaceRoot);
|
|
469
|
+
|
|
470
|
+
const lines: string[] = [];
|
|
471
|
+
|
|
472
|
+
lines.push("<!--");
|
|
473
|
+
lines.push(" GENERATED by `vice-mcp anno render-memmap` -- do not hand-edit; re-run the generator.");
|
|
474
|
+
lines.push(` store: ${storeLocation}`);
|
|
475
|
+
lines.push(` sidecar: ${sidecarLocation}`);
|
|
476
|
+
lines.push(` render_digest: ${renderDigest}`);
|
|
477
|
+
lines.push(
|
|
478
|
+
" The digest covers the sorted listRanges/listLabels/listComments results, the raw provenance",
|
|
479
|
+
);
|
|
480
|
+
lines.push(
|
|
481
|
+
" sidecar bytes, and this renderer's version constant. `render-memmap --check` reports drift when, and",
|
|
482
|
+
);
|
|
483
|
+
lines.push(
|
|
484
|
+
" only when, one of these changed: this file was hand-edited; a store row changed (a range, a label, a",
|
|
485
|
+
);
|
|
486
|
+
lines.push(
|
|
487
|
+
" comment, or a comment's confidence grade); the provenance sidecar's bytes changed; the store or the",
|
|
488
|
+
);
|
|
489
|
+
lines.push(
|
|
490
|
+
" sidecar moved to a different location RELATIVE TO THE WORKSPACE ROOT; or the renderer changed.",
|
|
491
|
+
);
|
|
492
|
+
lines.push(
|
|
493
|
+
" Relocating the checkout is NOT drift -- the same tree at a different absolute path renders these same",
|
|
494
|
+
);
|
|
495
|
+
lines.push(
|
|
496
|
+
" bytes, because the two locations above are workspace-relative. That matters because this file is meant",
|
|
497
|
+
);
|
|
498
|
+
lines.push(" to be committed and read on a machine that did not produce it.");
|
|
499
|
+
lines.push("-->");
|
|
500
|
+
lines.push("");
|
|
501
|
+
lines.push(`# Memory map — ${provenance.capturePath}`);
|
|
502
|
+
lines.push("");
|
|
503
|
+
lines.push(`Capture: \`${provenance.capturePath}\` · SHA-256 \`${provenance.captureSha256}\``);
|
|
504
|
+
lines.push(
|
|
505
|
+
`\`$01\` = \`${provenance.port01}\` · VIC bank \`${provenance.vicBank}\` (\`$DD00\` = \`${provenance.dd00}\`) · video standard \`${provenance.videoStandard}\``,
|
|
506
|
+
);
|
|
507
|
+
lines.push(`Live vector pair: \`${provenance.liveVectorPair}\` → \`${provenance.vectorHandler}\``);
|
|
508
|
+
lines.push("");
|
|
509
|
+
lines.push(
|
|
510
|
+
"Every row carries a confidence. Do not promote a row by editing its grade -- re-verify and restate",
|
|
511
|
+
);
|
|
512
|
+
lines.push("the evidence, so the record of when something stopped being a guess survives.");
|
|
513
|
+
lines.push("");
|
|
514
|
+
lines.push("| Range | Contents | Confidence | Evidence |");
|
|
515
|
+
lines.push("|---|---|---|---|");
|
|
516
|
+
for (const row of sortedBlocks) {
|
|
517
|
+
const match = findGradeInRange(row.start, row.endInclusive);
|
|
518
|
+
const range = `\`${hex4(row.start)}-${hex4(row.endInclusive)}\``;
|
|
519
|
+
const grade = match?.grade ? match.grade.phrase.toUpperCase() : "";
|
|
520
|
+
const evidence = match ? escapeMarkdownCell(match.evidence) : "";
|
|
521
|
+
lines.push(`| ${range} | ${row.dataType} | ${grade} | ${evidence} |`);
|
|
522
|
+
}
|
|
523
|
+
lines.push("");
|
|
524
|
+
lines.push("Confidence vocabulary — the project's HIGH / MEDIUM / LOW scale, applied to classification:");
|
|
525
|
+
lines.push("");
|
|
526
|
+
lines.push("| Grade | Means |");
|
|
527
|
+
lines.push("|---|---|");
|
|
528
|
+
for (const grade of CONFIDENCE_GRADES) {
|
|
529
|
+
lines.push(`| **${grade.phrase}** | ${grade.meaning} |`);
|
|
530
|
+
}
|
|
531
|
+
lines.push("");
|
|
532
|
+
lines.push("## Graphics chain");
|
|
533
|
+
lines.push("");
|
|
534
|
+
lines.push("| What | Address | Derived from |");
|
|
535
|
+
lines.push("|---|---|---|");
|
|
536
|
+
lines.push(`| VIC bank | ${provenance.vicBank} | \`$DD00\` bits 0-1, inverted |`);
|
|
537
|
+
lines.push(`| Screen RAM (VM) | ${provenance.screenRam} | \`$D018\` bits 4-7 |`);
|
|
538
|
+
lines.push(`| Charset / bitmap (CB) | ${provenance.charsetOrBitmap} | \`$D018\` bits 1-3 |`);
|
|
539
|
+
lines.push(`| Mode | ${provenance.mode} | \`$D011\` bits 5-6, \`$D016\` bit 4 |`);
|
|
540
|
+
lines.push("");
|
|
541
|
+
lines.push("## Interrupts");
|
|
542
|
+
lines.push("");
|
|
543
|
+
lines.push("| | Address | Notes |");
|
|
544
|
+
lines.push("|---|---|---|");
|
|
545
|
+
lines.push(`| Live IRQ handler | ${provenance.vectorHandler} | via ${provenance.liveVectorPair} |`);
|
|
546
|
+
if (provenance.rasterPositions && provenance.rasterPositions.length > 0) {
|
|
547
|
+
lines.push(
|
|
548
|
+
`| Raster positions | ${provenance.rasterPositions.join(", ")} | one per \`$D012\` write on the way out of a handler |`,
|
|
549
|
+
);
|
|
550
|
+
}
|
|
551
|
+
lines.push("");
|
|
552
|
+
lines.push("## Routines");
|
|
553
|
+
lines.push("");
|
|
554
|
+
lines.push("| Address | Provisional name | Confirmed by | Confidence |");
|
|
555
|
+
lines.push("|---|---|---|---|");
|
|
556
|
+
for (const sym of sortedSymbols) {
|
|
557
|
+
if (blockClassAt(blockEntries, sym.address) !== "code") continue;
|
|
558
|
+
const match = gradedComments.find((c) => c.address === sym.address);
|
|
559
|
+
const grade = match?.grade ? match.grade.phrase.toUpperCase() : "";
|
|
560
|
+
const confirmedBy = match ? escapeMarkdownCell(match.evidence) : "";
|
|
561
|
+
lines.push(`| ${hex4(sym.address)} | ${escapeMarkdownCell(sym.name)} | ${confirmedBy} | ${grade} |`);
|
|
562
|
+
}
|
|
563
|
+
lines.push("");
|
|
564
|
+
lines.push("## Open questions");
|
|
565
|
+
lines.push("");
|
|
566
|
+
const unknowns = gradedComments.filter((c) => c.grade?.token === "unknown");
|
|
567
|
+
if (unknowns.length === 0) {
|
|
568
|
+
lines.push("- (none)");
|
|
569
|
+
} else {
|
|
570
|
+
for (const u of unknowns) {
|
|
571
|
+
lines.push(`- ${hex4(u.address)}: ${escapeMarkdownCell(u.evidence)}`);
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
lines.push("");
|
|
575
|
+
|
|
576
|
+
const markdown = lines.join("\n");
|
|
577
|
+
return { markdown, renderDigest, rowCount: sortedBlocks.length, unknownCount: unknowns.length };
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
// ---------------------------------------------------------------------------
|
|
581
|
+
// checkRenderedMemoryMap()
|
|
582
|
+
// ---------------------------------------------------------------------------
|
|
583
|
+
|
|
584
|
+
export interface CheckRenderedMemoryMapOptions {
|
|
585
|
+
/** See `RenderMemoryMapOptions.storePath`. */
|
|
586
|
+
storePath: string;
|
|
587
|
+
/** See `RenderMemoryMapOptions.provenancePath` -- same argument, one layer
|
|
588
|
+
* up. The CALLER (`anno-cli.ts`'s `cmdRenderMemmap()`) confines it through
|
|
589
|
+
* `storePathWithinWorkspace()`; this module performs no confinement of its
|
|
590
|
+
* own (CR-03). */
|
|
591
|
+
provenancePath: string;
|
|
592
|
+
/** The rendered file to compare against, read RAW by `readFileSync` below.
|
|
593
|
+
* The CALLER confines it through `storePathWithinWorkspace()` -- the SAME
|
|
594
|
+
* resolution that produces the write path on the non-`--check` branch, so
|
|
595
|
+
* the drift check and the write are one confined value rather than two
|
|
596
|
+
* rules. This module performs no confinement of its own (CR-02). */
|
|
597
|
+
renderedPath: string;
|
|
598
|
+
/** See `RenderMemoryMapOptions.workspaceRoot`. */
|
|
599
|
+
workspaceRoot: string;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
export type CheckRenderedMemoryMapResult =
|
|
603
|
+
| { status: "in-sync" }
|
|
604
|
+
| { status: "drifted"; line: number; expected: string; actual: string }
|
|
605
|
+
| { status: "missing"; path: string };
|
|
606
|
+
|
|
607
|
+
/**
|
|
608
|
+
* Re-renders the memory map from the CURRENT store and sidecar state and
|
|
609
|
+
* compares it against the file on disk at `renderedPath`, line by line.
|
|
610
|
+
* Never auto-fixes. Returns:
|
|
611
|
+
* - `{status:"missing"}` when `renderedPath` does not exist;
|
|
612
|
+
* - `{status:"in-sync"}` when the freshly rendered text is byte-identical
|
|
613
|
+
* to the file on disk;
|
|
614
|
+
* - `{status:"drifted", line, expected, actual}` naming the first
|
|
615
|
+
* differing line otherwise.
|
|
616
|
+
*
|
|
617
|
+
* WHAT REACHES `drifted`, enumerated from what the compared bytes are a
|
|
618
|
+
* function of rather than from a remembered summary -- the fresh render is a
|
|
619
|
+
* function of the store rows, the sidecar bytes, `RENDERER_VERSION` and the
|
|
620
|
+
* two WORKSPACE-RELATIVE locations, and nothing else:
|
|
621
|
+
* - a hand edit to the rendered file;
|
|
622
|
+
* - a store-side change (a range, a label, a comment, or a comment's
|
|
623
|
+
* confidence grade);
|
|
624
|
+
* - a change to the provenance sidecar's bytes;
|
|
625
|
+
* - a move of the store or the sidecar to a different location RELATIVE TO
|
|
626
|
+
* the workspace root;
|
|
627
|
+
* - a renderer change (output shape, or a `RENDERER_VERSION` bump).
|
|
628
|
+
*
|
|
629
|
+
* AND THE NEGATIVE, which is the defect this list was corrected for (CR-01,
|
|
630
|
+
* `29-VERIFICATION.md` gap 1): relocating the checkout -- the same tree at a
|
|
631
|
+
* different absolute path -- does NOT drift. The banner records
|
|
632
|
+
* workspace-relative locations, so no compared byte is a function of where the
|
|
633
|
+
* checkout sits. Before that fix this returned `drifted` for a byte-identical
|
|
634
|
+
* store, sidecar and rendered file while `renderMemoryMap()` printed the SAME
|
|
635
|
+
* `render_digest` in both trees, so the gate contradicted its own artifact.
|
|
636
|
+
* That matters here specifically because the rendered file is a committed
|
|
637
|
+
* artifact and this repository runs its phases in worktrees, which makes a
|
|
638
|
+
* differing checkout path the normal case rather than an edge.
|
|
639
|
+
*/
|
|
640
|
+
export async function checkRenderedMemoryMap(
|
|
641
|
+
opts: CheckRenderedMemoryMapOptions,
|
|
642
|
+
): Promise<CheckRenderedMemoryMapResult> {
|
|
643
|
+
const { storePath, provenancePath, renderedPath, workspaceRoot } = opts;
|
|
644
|
+
|
|
645
|
+
if (!existsSync(renderedPath)) {
|
|
646
|
+
return { status: "missing", path: renderedPath };
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
const onDisk = readFileSync(renderedPath, "utf8");
|
|
650
|
+
const { markdown } = await renderMemoryMap({ storePath, provenancePath, workspaceRoot });
|
|
651
|
+
|
|
652
|
+
if (onDisk === markdown) {
|
|
653
|
+
return { status: "in-sync" };
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
const diskLines = onDisk.split("\n");
|
|
657
|
+
const freshLines = markdown.split("\n");
|
|
658
|
+
const max = Math.max(diskLines.length, freshLines.length);
|
|
659
|
+
for (let i = 0; i < max; i++) {
|
|
660
|
+
if (diskLines[i] !== freshLines[i]) {
|
|
661
|
+
return {
|
|
662
|
+
status: "drifted",
|
|
663
|
+
line: i + 1,
|
|
664
|
+
expected: freshLines[i] ?? "(end of file)",
|
|
665
|
+
actual: diskLines[i] ?? "(end of file)",
|
|
666
|
+
};
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
// Unreachable in practice (the strings already compared unequal above),
|
|
670
|
+
// kept only as a defensive fallback.
|
|
671
|
+
return { status: "drifted", line: max + 1, expected: "(no further lines)", actual: "(no further lines)" };
|
|
672
|
+
}
|