@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.
Files changed (49) hide show
  1. package/README.md +2 -1
  2. package/THIRD-PARTY-NOTICES.md +1 -1
  3. package/anno-acme-ident.ts +97 -0
  4. package/anno-cli.ts +1465 -0
  5. package/anno-confidence.ts +233 -0
  6. package/anno-coverage.ts +2465 -0
  7. package/anno-d64.ts +310 -0
  8. package/anno-derive.ts +590 -0
  9. package/anno-details.ts +169 -0
  10. package/anno-enum-gen.ts +533 -0
  11. package/anno-export-asm.ts +1310 -0
  12. package/anno-index.ts +150 -0
  13. package/anno-memmap-render.ts +672 -0
  14. package/anno-regbits-gen.ts +421 -0
  15. package/anno-regbits.json +1370 -0
  16. package/anno-register.ts +240 -0
  17. package/anno-store.ts +3486 -0
  18. package/anno-symbols.ts +266 -0
  19. package/anno-tools.ts +2111 -0
  20. package/anno-types.ts +1636 -0
  21. package/block-class.ts +201 -0
  22. package/build.ts +1 -1
  23. package/capability-registry.ts +3 -1
  24. package/disasm-decoder.ts +14 -14
  25. package/disasm-opcodes.ts +4 -4
  26. package/disasm-renderer.ts +2 -2
  27. package/hostpath.ts +1 -1
  28. package/install-resources.ts +1 -1
  29. package/package.json +23 -3
  30. package/prg-image.ts +119 -0
  31. package/repo-root.ts +20 -5
  32. package/resources/broker-launch.mjs +8 -4
  33. package/resources/vice-launcher.sh +3 -3
  34. package/stock-address.ts +5 -5
  35. package/stock-cia.ts +2 -2
  36. package/stock-condition.ts +7 -7
  37. package/stock-connect.ts +1 -1
  38. package/stock-dispatch.ts +35 -5
  39. package/stock-execution.ts +5 -3
  40. package/stock-input.ts +9 -9
  41. package/stock-machine.ts +17 -6
  42. package/stock-protocol.ts +16 -11
  43. package/stock-registers.ts +54 -29
  44. package/stock-sprites.ts +3 -3
  45. package/stock-symbols.ts +33 -9
  46. package/stock-timing.ts +1 -1
  47. package/stock-vicii.ts +1 -1
  48. package/version.ts +1 -1
  49. 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
+ }