@henols/vice-mcp 0.2.2 → 0.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/README.md +2 -2
  2. package/THIRD-PARTY-NOTICES.md +422 -1
  3. package/anno-bank.ts +171 -0
  4. package/anno-cli.ts +1736 -163
  5. package/anno-confidence.ts +2 -2
  6. package/anno-derive.ts +6 -6
  7. package/anno-details.ts +4 -4
  8. package/anno-enum-gen.ts +416 -30
  9. package/anno-export-asm.ts +1211 -126
  10. package/anno-graphics.ts +338 -0
  11. package/anno-hazard-report.ts +1367 -0
  12. package/anno-import.ts +495 -0
  13. package/anno-index.ts +8 -8
  14. package/anno-join.ts +480 -0
  15. package/anno-memmap-render.ts +22 -21
  16. package/anno-provenance-ledger.ts +472 -0
  17. package/anno-regbits-gen.ts +13 -13
  18. package/anno-register.ts +159 -0
  19. package/anno-store-export.ts +661 -0
  20. package/anno-store.ts +635 -124
  21. package/anno-symbols.ts +7 -7
  22. package/anno-tools.ts +1169 -16
  23. package/anno-types.ts +313 -40
  24. package/backend-detect.mts +124 -312
  25. package/build.ts +3 -1
  26. package/capture-predicate.ts +597 -0
  27. package/channel-lock.ts +349 -0
  28. package/evid-ingest.ts +217 -0
  29. package/evid-reconcile.ts +316 -0
  30. package/host-tool-client.ts +430 -0
  31. package/incident-record.ts +23 -12
  32. package/install-resources.ts +29 -13
  33. package/memmap-lookup.ts +285 -0
  34. package/package.json +27 -8
  35. package/prg-image.ts +1 -2
  36. package/repo-root.ts +87 -3
  37. package/resources/backend-detect.mjs +98 -236
  38. package/resources/broker-control.mjs +220 -54
  39. package/resources/broker-epoch.mjs +7 -8
  40. package/resources/broker-kill.mjs +36 -31
  41. package/resources/broker-launch.mjs +511 -374
  42. package/resources/broker-state.mjs +69 -24
  43. package/resources/container-guard.mjs +1 -1
  44. package/resources/ghidra-project.mjs +790 -0
  45. package/resources/host-tool.mjs +2533 -0
  46. package/resources/vice-broker.mjs +434 -290
  47. package/resources/vice-launcher.sh +127 -9
  48. package/stock-address.ts +1 -1
  49. package/stock-condition.ts +1 -1
  50. package/stock-connect.ts +9 -5
  51. package/stock-derived.ts +29 -37
  52. package/stock-diagnose.ts +200 -36
  53. package/stock-dispatch.ts +179 -77
  54. package/stock-handler.ts +1 -1
  55. package/stock-paths.ts +18 -14
  56. package/stock-petscii.ts +1 -1
  57. package/stock-protocol.ts +1 -1
  58. package/stock-recycle.ts +83 -2
  59. package/stock-reproducible-run.ts +811 -0
  60. package/stock-run-until.ts +100 -1
  61. package/stock-symbols.ts +4 -4
  62. package/stock-timing.ts +1 -1
  63. package/stop-oracle.ts +167 -0
  64. package/text-capability-probe.ts +660 -0
  65. package/text-connect.ts +157 -0
  66. package/text-protocol.ts +810 -0
  67. package/text-tools.ts +778 -0
  68. package/textmon-backtrace.ts +385 -0
  69. package/textmon-cpuhistory.ts +335 -0
  70. package/textmon-memmap.ts +494 -0
  71. package/textmon-profile.ts +458 -0
  72. package/textmon-registers.ts +748 -0
  73. package/tools-manifest.stock.json +864 -3
  74. package/vice-broker-client.ts +253 -108
  75. package/vice-errors.ts +268 -0
  76. package/vice-proxy.ts +339 -2144
  77. package/vsf-slice.ts +640 -0
  78. package/anno-d64.ts +0 -310
  79. package/capability-registry.ts +0 -390
  80. package/refresh-manifest.ts +0 -124
  81. package/tools-manifest.json +0 -1223
  82. package/vice-probe.ts +0 -278
  83. package/vice-sync.ts +0 -336
  84. package/vice.ts +0 -772
package/anno-join.ts ADDED
@@ -0,0 +1,480 @@
1
+ #!/usr/bin/env node
2
+ // anno-join.ts
3
+ //
4
+ // The mechanical join between stored
5
+ // cross-references and `memmap.json`, with no agent call, no queue walk and
6
+ // no skill invocation anywhere in the loop.
7
+ //
8
+ // THIS MODULE RECEIVES AN ALREADY-OPEN STORE HANDLE, exactly like
9
+ // `anno-import.ts` -- there is no second store session anywhere in this
10
+ // file. It never names `node:sqlite`, never calls `openStore()`/`closeStore()`
11
+ // itself, and never imports `hostpath.ts`/`containerpath.ts`: the image range
12
+ // it needs arrives as plain numbers (`imageOrigin`, `imageByteLength`) that
13
+ // the caller has already derived from a loaded image, mirroring
14
+ // `dxa-blocks.ts`'s own header posture of reading only already-fetched facts
15
+ // a caller passes in.
16
+ //
17
+ // WHAT THIS IS THE ONE AUTHORITATIVE PLACE FOR: reading `listXrefs()`'s
18
+ // distinct target addresses, classifying each one (inside the loaded image,
19
+ // no `memmap.json` entry, or annotated) and writing the resulting comment
20
+ // through `setComment()`. This module's own criterion -- no agent, no queue
21
+ // walk, no skill invocation -- is checked STRUCTURALLY over this module's own
22
+ // source in `anno-join.test.ts`, not merely asserted here in
23
+ // prose.
24
+ //
25
+ // WHAT NOT TO DO:
26
+ // - Never import anything under `src/skills/`. This function's entire
27
+ // point is that annotating an address costs one mechanical call, not an
28
+ // agent turn.
29
+ // - Never spawn a child process from this module.
30
+ // - Never carry a bank value forward past a point where two paths
31
+ // disagree, and never default an unresolved bank state to the power-on
32
+ // value (T-37-26). A reaching-values set of size other than one, or one
33
+ // that decodes to more than one region, DECLINES -- see the bank-state
34
+ // block below.
35
+ //
36
+ // `runMemmapJoin()` gains an
37
+ // OPTIONAL `constWrites` argument. When `undefined` (every pre-37-06 call
38
+ // site, and every existing test in this file), the bank-state machinery
39
+ // below is a complete no-op and every address resolves EXACTLY as it did
40
+ // before this plan -- the candidate-constraint argument must not change any
41
+ // unconstrained selection's answer. Only when a caller explicitly supplies
42
+ // an array (even an empty one) does the reaching-values/decline logic
43
+ // activate for addresses inside `BANK_CONDITIONAL_RANGES`. The
44
+ // reaching-values computation is deliberately conservative -- it is NOT a
45
+ // dataflow analysis. It uses only what the export gives: the recovered
46
+ // constant stores to the processor port (each with its own address), and
47
+ // the cross-reference graph the importer already stored via `putXref()`.
48
+ // The set of recovered values reaching a given address is the set of
49
+ // `constWrites` whose OWN store address can reach that address, walked
50
+ // forward over that same graph. Where the graph does not connect a store to
51
+ // the address at all, that value is simply not in the reaching set --
52
+ // the decline-on-empty-or-disagreement rule (below) is what turns
53
+ // "nothing reaches this point" into a stated absence rather than a silent
54
+ // default.
55
+
56
+ import { listXrefs, setComment, setDataType } from "./anno-store.ts";
57
+ import type { AnnoStoreHandle } from "./anno-store.ts";
58
+ import {
59
+ decodeBankState,
60
+ isBankConditionalAddress,
61
+ regionAdmitsEntry,
62
+ resolveBankedRegion,
63
+ } from "./anno-bank.ts";
64
+ import type { BankedRegion } from "./anno-bank.ts";
65
+ import type { ConstWriteFact } from "./anno-import.ts";
66
+ import type { ContradictedComment, SplitTableReinterpretation, XrefRow } from "./anno-types.ts";
67
+ import { loadMemmap, memmapDigest, PROVENANCE_TOKEN_PREFIX, selectMemmapEntry } from "./memmap-lookup.ts";
68
+ import type { MemmapEntry, MemmapSelection } from "./memmap-lookup.ts";
69
+ // The graphics write-back. deriveGraphicsRanges()
70
+ // is structurally typed against ConstWriteFact -- GraphicsConstWriteFact's own
71
+ // shape is identical ({storeAddress, targetAddress, value}) -- so THIS module's
72
+ // own constWrites argument, already threaded for the bank-state block, is
73
+ // handed straight through with no translation layer.
74
+ import { deriveGraphicsRanges } from "./anno-graphics.ts";
75
+ import type { GraphicsMap } from "./anno-graphics.ts";
76
+
77
+ /** The axis-qualified provenance marker an annotated bank-conditional
78
+ * comment carries, ALWAYS before `PROVENANCE_TOKEN_PREFIX`'s own digest
79
+ * token (which stays last). Names the axis ("processor-port")
80
+ * explicitly, so a later phase adding the VIC banking axis is additive
81
+ * rather than ambiguous about which axis a given token names. */
82
+ const BANK_PROVENANCE_PREFIX = "[processor-port:";
83
+
84
+ /**
85
+ * The reaching-values computation's own result shape: exactly one
86
+ * recovered value reaches the address; several do (an array -- length 0
87
+ * means none reach at all, length 2+ means genuine disagreement-or-agreement
88
+ * to resolve); or the computation cannot decide at all. Nothing in this
89
+ * project's current data model (a plain directed graph of already-resolved
90
+ * `XrefRow`s) can currently produce `"unknown"` -- there is no signal here
91
+ * for a dropped or unresolved reference (a stated limit of this computation) -- but
92
+ * the shape is kept complete for a future importer that DOES emit such a
93
+ * signal, rather than silently folding that case into "empty".
94
+ */
95
+ type ReachingValues = { kind: "one"; value: number } | { kind: "several"; values: number[] } | { kind: "unknown" };
96
+
97
+ /** Builds a forward adjacency map from `xrefs`, ONCE per join run, reused for
98
+ * every address's own reachability walk below. */
99
+ function buildAdjacency(xrefs: readonly XrefRow[]): Map<number, number[]> {
100
+ const adjacency = new Map<number, number[]>();
101
+ for (const xref of xrefs) {
102
+ const list = adjacency.get(xref.fromAddress);
103
+ if (list) list.push(xref.toAddress);
104
+ else adjacency.set(xref.fromAddress, [xref.toAddress]);
105
+ }
106
+ return adjacency;
107
+ }
108
+
109
+ /** Plain forward reachability (DFS, visited-set guarded against cycles):
110
+ * can `from` reach `target` by following zero or more `adjacency` edges? */
111
+ function canReach(from: number, target: number, adjacency: Map<number, number[]>): boolean {
112
+ if (from === target) return true;
113
+ const visited = new Set<number>([from]);
114
+ const stack = [from];
115
+ while (stack.length > 0) {
116
+ const current = stack.pop()!;
117
+ for (const next of adjacency.get(current) ?? []) {
118
+ if (next === target) return true;
119
+ if (!visited.has(next)) {
120
+ visited.add(next);
121
+ stack.push(next);
122
+ }
123
+ }
124
+ }
125
+ return false;
126
+ }
127
+
128
+ /** The set of recovered processor-port values whose OWN store
129
+ * address can reach `targetAddress`, over `adjacency`. Never a dataflow
130
+ * analysis -- purely "does the stored cross-reference graph connect this
131
+ * store to this address". */
132
+ function computeReachingValues(
133
+ targetAddress: number,
134
+ constWrites: readonly ConstWriteFact[],
135
+ adjacency: Map<number, number[]>,
136
+ ): ReachingValues {
137
+ const reaching: number[] = [];
138
+ for (const write of constWrites) {
139
+ if (canReach(write.storeAddress, targetAddress, adjacency)) reaching.push(write.value);
140
+ }
141
+ if (reaching.length === 1) return { kind: "one", value: reaching[0]! };
142
+ return { kind: "several", values: reaching };
143
+ }
144
+
145
+ /** Raised when `runMemmapJoin()`'s own inputs cannot support a join at all --
146
+ * currently only the zero-byte-image refusal below. Follows `anno-import.ts`'s
147
+ * `AnnoImportError` construction idiom (a bare `Error` subclass, not
148
+ * `AnnoStoreError`, since this module never touches the store's own
149
+ * persistence). */
150
+ export class AnnoJoinError extends Error {
151
+ constructor(message: string) {
152
+ super(message);
153
+ this.name = "AnnoJoinError";
154
+ }
155
+ }
156
+
157
+ /** What one `runMemmapJoin()` call reports. `addressesConsidered` is always
158
+ * the sum of the next four fields. The three `graphics*` fields are always
159
+ * present and `0` when `constWrites` is omitted or when
160
+ * the selected map derives zero ranges -- never absent, so a caller reads
161
+ * them unconditionally instead of guarding on them, mirroring
162
+ * `SetDataTypeResult`'s own "always present, often empty" convention for
163
+ * `contradictedComments`/`reinterpretedSplitTables`. */
164
+ export interface JoinCounts {
165
+ addressesConsidered: number;
166
+ annotated: number;
167
+ skippedInImage: number;
168
+ skippedNoMapEntry: number;
169
+ declined: number;
170
+ commentsChanged: number;
171
+ graphicsRangesWritten: number;
172
+ graphicsContradictedComments: number;
173
+ graphicsReinterpretedSplitTables: number;
174
+ }
175
+
176
+ /**
177
+ * What the graphics write-back reports, in full --
178
+ * `JoinCounts`'s own `graphics*` fields are the COUNTS of these same
179
+ * `contradictedComments`/`reinterpretedSplitTables` arrays; this record
180
+ * carries the disclosures themselves so neither is dropped (must_haves.truths:
181
+ * "the join's returned counts include the contradicted-comment and
182
+ * fragmented-split-table disclosures the range write reported; neither is
183
+ * dropped"). `mapIndex` records WHICH of `deriveGraphicsRanges()`'s several
184
+ * maps was written -- this project's own rule (several valid combinations are
185
+ * several maps, never one merged map) means writing more than one would
186
+ * write mutually-contradicting ranges into the SAME store, so exactly one is
187
+ * ever written and this field is the record of which. */
188
+ export interface GraphicsWriteBack {
189
+ mapIndex: number;
190
+ rangesWritten: number;
191
+ contradictedComments: readonly ContradictedComment[];
192
+ reinterpretedSplitTables: readonly SplitTableReinterpretation[];
193
+ }
194
+
195
+ /** One address's own outcome. A skip or a decline always carries a non-empty
196
+ * `reason` naming WHY in the join's own vocabulary; an annotation carries the
197
+ * `label` it wrote. */
198
+ export interface JoinDecision {
199
+ address: number;
200
+ outcome: "annotated" | "skipped-in-image" | "skipped-no-entry" | "declined";
201
+ reason?: string;
202
+ label?: string;
203
+ }
204
+
205
+ export interface RunMemmapJoinArgs {
206
+ imageOrigin: number;
207
+ imageByteLength: number;
208
+ /** The recovered `$01` const-write facts this run
209
+ * has evidence for, typically `parseConstWrites()`'s own output over one
210
+ * imported export. `undefined` (every pre-37-06 call site) means "this run
211
+ * carries no bank-state evidence at all" -- the bank-state machinery is a
212
+ * complete no-op and every address resolves EXACTLY as before this plan.
213
+ * An explicit array (even `[]`) activates it for addresses inside
214
+ * `BANK_CONDITIONAL_RANGES`. */
215
+ constWrites?: readonly ConstWriteFact[];
216
+ /** Which of `deriveGraphicsRanges()`'s
217
+ * several maps to write back, when `constWrites` derives more than one
218
+ * distinct register-value combination. Defaults to `0`. Consulted ONLY
219
+ * when `constWrites` is supplied AT ALL (the SAME gate that activates the
220
+ * bank-state block above) -- omitting `constWrites` entirely skips the
221
+ * graphics write-back completely, exactly like the bank-state block. Out
222
+ * of range for the derived map count refuses BY NAME (`AnnoJoinError`)
223
+ * rather than silently clamping or picking a default. */
224
+ graphicsMapIndex?: number;
225
+ }
226
+
227
+ /**
228
+ * Joins every DISTINCT `toAddress` `listXrefs()` holds against `memmap.json`,
229
+ * in ascending address order. An address inside the caller's own loaded
230
+ * image range is a program address, not a hardware/memory-map feature, and
231
+ * is skipped WITHOUT a `memmap.json` lookup -- the membership test runs
232
+ * BEFORE `selectEntry()` is called at all, so the guard is a
233
+ * control-flow fact rather than a result-filtering one: this file's own
234
+ * contract says an
235
+ * in-image address is "never looked up in memmap.json", a claim about what
236
+ * runs, not merely about what the caller sees back. An address with no
237
+ * containing `memmap.json` entry is skipped for that reason instead;
238
+ * everything else is annotated with the selected entry's label via
239
+ * `setComment()`. Running this twice over an unchanged store re-classifies
240
+ * every address identically and reports `commentsChanged: 0` on the second
241
+ * run, because `setComment()` itself reports `changed: false` for a
242
+ * byte-identical repeat.
243
+ *
244
+ * `selectEntry` defaults to the real `selectMemmapEntry` and exists as an
245
+ * injection point for exactly one reason: it is the seam that makes "never
246
+ * looked up" checkable at all. Without it, the only available assertion is
247
+ * on the RESULT, and a result-filtering implementation (compute the
248
+ * selection, then discard it for an in-image address) would pass that
249
+ * assertion while still violating the requirement. A test passes a counting
250
+ * spy in its place and asserts zero calls for an all-in-image store.
251
+ */
252
+ export function runMemmapJoin(
253
+ handle: AnnoStoreHandle,
254
+ args: RunMemmapJoinArgs,
255
+ entries: readonly MemmapEntry[] = loadMemmap(),
256
+ selectEntry: (address: number, entries: readonly MemmapEntry[]) => MemmapSelection | undefined = selectMemmapEntry,
257
+ ): { counts: JoinCounts; decisions: JoinDecision[]; graphics?: GraphicsWriteBack } {
258
+ if (args.imageByteLength === 0) {
259
+ throw new AnnoJoinError(
260
+ "runMemmapJoin refused: the image's own body length is 0 -- an image with no bytes has no range, and treating " +
261
+ "it as a zero-width range at its own origin would silently make one address (the origin) read as in-image.",
262
+ );
263
+ }
264
+
265
+ // Computed ONCE per join run and reused for every annotated row,
266
+ // never recomputed per row -- two comments written in the same run are
267
+ // therefore GUARANTEED to carry byte-identical tokens, not merely likely
268
+ // to (the file cannot change mid-run, but a per-row recompute would still
269
+ // be wasted work re-reading and re-hashing the same bytes for nothing).
270
+ const digest = memmapDigest();
271
+
272
+ const xrefs = listXrefs(handle);
273
+ const targets = [...new Set(xrefs.map((xref) => xref.toAddress))].sort((a, b) => a - b);
274
+
275
+ // Built ONCE per run, over the SAME xref graph the unconstrained
276
+ // path already reads via `listXrefs()` above -- reused for every address's
277
+ // own reachability walk below. `undefined` `args.constWrites` means the
278
+ // bank-state block is never entered at all, so this adjacency map is built
279
+ // but simply never consulted -- negligible cost, and keeps the "no
280
+ // constWrites -> no-op" contract a single conditional rather than two
281
+ // divergent code paths.
282
+ const bankAdjacency = buildAdjacency(xrefs);
283
+
284
+ // The inclusive image range, computed ONCE from the LoadedImage's own body
285
+ // bytes -- never from `totalBytes` (the file's own byte count,
286
+ // which on the .prg route includes the two-byte load-address header and
287
+ // would shift this whole range by two bytes) and never a caller-supplied
288
+ // number pair that could silently widen or narrow the program's own
289
+ // extent.
290
+ const imageStart = args.imageOrigin;
291
+ const imageEnd = args.imageOrigin + args.imageByteLength - 1;
292
+
293
+ const decisions: JoinDecision[] = [];
294
+ let annotated = 0;
295
+ let skippedInImage = 0;
296
+ let skippedNoMapEntry = 0;
297
+ let declined = 0;
298
+ let commentsChanged = 0;
299
+
300
+ for (const address of targets) {
301
+ // THE GUARD: one early-return, before selectEntry() is ever called. A
302
+ // single textual deletion of this block removes it cleanly -- that
303
+ // deletion is this file's own observed-red control.
304
+ if (address >= imageStart && address <= imageEnd) {
305
+ skippedInImage += 1;
306
+ decisions.push({
307
+ address,
308
+ outcome: "skipped-in-image",
309
+ reason:
310
+ `address $${address.toString(16)} lies inside the loaded image's own range ($${imageStart.toString(16)}-` +
311
+ `$${imageEnd.toString(16)}) and is therefore a program address, never looked up in memmap.json`,
312
+ });
313
+ continue;
314
+ }
315
+
316
+ // THE BANK-STATE BLOCK. Only
317
+ // entered when the caller supplied `constWrites` AT ALL (`undefined`
318
+ // skips this whole block, falling through to the unconstrained path
319
+ // below exactly as pre-37-06) AND the address is inside one of the
320
+ // three bank-conditional ranges -- outside them, bank state is
321
+ // irrelevant and the candidate set stays unconstrained regardless of
322
+ // `constWrites`.
323
+ if (args.constWrites !== undefined && isBankConditionalAddress(address)) {
324
+ const reaching = computeReachingValues(address, args.constWrites, bankAdjacency);
325
+
326
+ if (reaching.kind === "unknown") {
327
+ declined += 1;
328
+ decisions.push({
329
+ address,
330
+ outcome: "declined",
331
+ reason: `bank state at $${address.toString(16)} could not be determined -- the reaching-values computation could not decide`,
332
+ });
333
+ continue;
334
+ }
335
+
336
+ // Resolves ONE region (or refuses) for a single reaching value, applies
337
+ // it as a candidate constraint BEFORE selection runs (never a
338
+ // post-filter), and pushes the matching decision. Shared by
339
+ // both the single-value and the several-values-same-region branches
340
+ // below, so the annotate path is written exactly once.
341
+ const annotateUnderRegion = (region: Exclude<BankedRegion, "not_applicable">, bankNote: string): void => {
342
+ const constrained = entries.filter((entry) => regionAdmitsEntry(entry, region));
343
+ const selection = selectEntry(address, constrained);
344
+ if (selection === undefined) {
345
+ skippedNoMapEntry += 1;
346
+ decisions.push({
347
+ address,
348
+ outcome: "skipped-no-entry",
349
+ reason: `no memmap.json entry admits the resolved ${region} region for $${address.toString(16)}`,
350
+ });
351
+ return;
352
+ }
353
+ const commentText = `${selection.entry.label} ${BANK_PROVENANCE_PREFIX}${bankNote}] ${PROVENANCE_TOKEN_PREFIX}${digest}`;
354
+ const write = setComment(handle, { address, commentType: "line", text: commentText });
355
+ annotated += 1;
356
+ if (write.changed) commentsChanged += 1;
357
+ decisions.push({
358
+ address,
359
+ outcome: "annotated",
360
+ label: selection.entry.label,
361
+ ...(reaching.kind === "several" ? { reason: `reached under differing processor-port values (${bankNote}) that all resolve to the same region (${region})` } : {}),
362
+ });
363
+ };
364
+
365
+ if (reaching.kind === "one") {
366
+ const region = resolveBankedRegion(address, decodeBankState(reaching.value));
367
+ annotateUnderRegion(region as Exclude<BankedRegion, "not_applicable">, `$${reaching.value.toString(16)}`);
368
+ continue;
369
+ }
370
+
371
+ // reaching.kind === "several"
372
+ if (reaching.values.length === 0) {
373
+ declined += 1;
374
+ decisions.push({
375
+ address,
376
+ outcome: "declined",
377
+ reason: `no recovered processor-port value reaches $${address.toString(16)} -- declining rather than defaulting to the power-on state`,
378
+ });
379
+ continue;
380
+ }
381
+
382
+ const uniqueValues = [...new Set(reaching.values)].sort((a, b) => a - b);
383
+ const regionsByValue = uniqueValues.map((value) => resolveBankedRegion(address, decodeBankState(value)));
384
+ const uniqueRegions = new Set(regionsByValue);
385
+ if (uniqueRegions.size > 1) {
386
+ declined += 1;
387
+ const named = uniqueValues.map((value, i) => `$${value.toString(16)}(${regionsByValue[i]})`).join(", ");
388
+ decisions.push({
389
+ address,
390
+ outcome: "declined",
391
+ reason: `$${address.toString(16)} is reached under disagreeing processor-port values: ${named}`,
392
+ });
393
+ continue;
394
+ }
395
+
396
+ const region = regionsByValue[0]! as Exclude<BankedRegion, "not_applicable">;
397
+ const valuesNote = uniqueValues.map((value) => `$${value.toString(16)}`).join(",");
398
+ annotateUnderRegion(region, valuesNote);
399
+ continue;
400
+ }
401
+
402
+ const selection = selectEntry(address, entries);
403
+ if (selection === undefined) {
404
+ skippedNoMapEntry += 1;
405
+ decisions.push({
406
+ address,
407
+ outcome: "skipped-no-entry",
408
+ reason: "no memmap.json entry contains this address",
409
+ });
410
+ continue;
411
+ }
412
+
413
+ // The full comment text: the selected entry's label, one space, the
414
+ // provenance prefix, then the full 64-character digest -- always LAST,
415
+ // never truncated. setComment() -> assertCommentText() refuses
416
+ // (never truncates) a text that overflows MAX_COMMENT_BYTES; that
417
+ // refusal is left to propagate here rather than being pre-checked and
418
+ // silently worked around, because a truncated provenance token would be
419
+ // a wrong answer that reports success.
420
+ const commentText = `${selection.entry.label} ${PROVENANCE_TOKEN_PREFIX}${digest}`;
421
+ const write = setComment(handle, { address, commentType: "line", text: commentText });
422
+ annotated += 1;
423
+ if (write.changed) commentsChanged += 1;
424
+ decisions.push({ address, outcome: "annotated", label: selection.entry.label });
425
+ }
426
+
427
+ // THE GRAPHICS WRITE-BACK. Runs AFTER the main
428
+ // per-address loop above, as its own step -- graphics ranges are derived
429
+ // from register VALUES, never from the cross-reference targets the loop
430
+ // above walks, so there is no reason to interleave the two. Gated on the
431
+ // SAME `constWrites !== undefined` condition the bank-state block uses:
432
+ // omitting `constWrites` entirely is a complete no-op here too, exactly
433
+ // like the bank-state block above.
434
+ let graphics: GraphicsWriteBack | undefined;
435
+ if (args.constWrites !== undefined) {
436
+ const maps = deriveGraphicsRanges(args.constWrites);
437
+ const mapIndex = args.graphicsMapIndex ?? 0;
438
+ const selectedMap: GraphicsMap | undefined = maps[mapIndex];
439
+ if (selectedMap === undefined) {
440
+ throw new AnnoJoinError(
441
+ `runMemmapJoin refused: graphicsMapIndex ${mapIndex} is out of range -- deriveGraphicsRanges() produced ` +
442
+ `${maps.length} map(s) for this run's own constWrites`,
443
+ );
444
+ }
445
+
446
+ // Write ONLY the selected map's own ranges -- never every map
447
+ // deriveGraphicsRanges() returned. Several distinct register-value
448
+ // combinations describe MUTUALLY CONTRADICTING layouts (the reason
449
+ // several maps exist at all); writing more than one into the
450
+ // same store would write ranges that disagree with each other by
451
+ // construction.
452
+ let rangesWritten = 0;
453
+ const contradictedComments: ContradictedComment[] = [];
454
+ const reinterpretedSplitTables: SplitTableReinterpretation[] = [];
455
+ for (const range of selectedMap.ranges) {
456
+ const write = setDataType(handle, { start: range.start, endInclusive: range.endInclusive, dataType: range.dataType });
457
+ rangesWritten += 1;
458
+ // Disclosures a successful range write can carry -- surfaced, never
459
+ // dropped (must_haves.truths): a comment whose recorded confidence now
460
+ // contradicts the type this write just assigned, or a split table this
461
+ // write fragmented.
462
+ contradictedComments.push(...write.contradictedComments);
463
+ reinterpretedSplitTables.push(...write.reinterpretedSplitTables);
464
+ }
465
+ graphics = { mapIndex, rangesWritten, contradictedComments, reinterpretedSplitTables };
466
+ }
467
+
468
+ const counts: JoinCounts = {
469
+ addressesConsidered: targets.length,
470
+ annotated,
471
+ skippedInImage,
472
+ skippedNoMapEntry,
473
+ declined,
474
+ commentsChanged,
475
+ graphicsRangesWritten: graphics?.rangesWritten ?? 0,
476
+ graphicsContradictedComments: graphics?.contradictedComments.length ?? 0,
477
+ graphicsReinterpretedSplitTables: graphics?.reinterpretedSplitTables.length ?? 0,
478
+ };
479
+ return graphics === undefined ? { counts, decisions } : { counts, decisions, graphics };
480
+ }
@@ -1,10 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
  // anno-memmap-render.ts -- the ONE authoritative place in this repo that
3
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).
4
+ // annotation store plus a validated run-scoped provenance sidecar, using
5
+ // the reconciliation between those two sources this file implements.
6
6
  //
7
- // WHY THIS MODULE EXISTS (D-24): the store is canonical; the Markdown memory
7
+ // WHY THIS MODULE EXISTS: the store is canonical; the Markdown memory
8
8
  // map becomes a rendered VIEW. Criterion 1 says findings must be queryable
9
9
  // "instead of re-deriving from Markdown prose" -- that is only true by
10
10
  // construction if the prose is GENERATED from the queryable thing. Nothing
@@ -13,7 +13,8 @@
13
13
  // `listRanges()`/`listLabels()`/`listComments()` readers, never from a
14
14
  // human editing the output file directly.
15
15
  //
16
- // THE D-24/D-27 RECONCILIATION THIS FILE IMPLEMENTS: run-scoped facts (the
16
+ // THE RECONCILIATION THIS FILE IMPLEMENTS, BETWEEN THE STORE AND THE
17
+ // RUN-SCOPED SIDECAR: run-scoped facts (the
17
18
  // capture's SHA-256, `$01`, `$DD00`, the derived graphics chain, the video
18
19
  // standard, the live vector pair, observed raster positions) are facts about
19
20
  // a RUN, not about an address -- the store is address-keyed and has no shape
@@ -24,8 +25,8 @@
24
25
  // `<placeholder>` for one.
25
26
  //
26
27
  // 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
28
+ // FILE AT RUNTIME (the second decision this plan records): an earlier
29
+ // finding established that `.claude/mcp/vice/*.ts` exists as files on disk only
29
30
  // under the Claude Code plugin route -- both npm-installer routes launch via
30
31
  // `npx`. A renderer that resolved a template path into the skills tree at
31
32
  // runtime would silently fail to resolve for an npm-installed user. The
@@ -41,7 +42,7 @@
41
42
  // - drift detection (`checkRenderedMemoryMap()`) -- the one place a
42
43
  // rendered file on disk is compared against what the store (plus the
43
44
  // sidecar) would produce right now;
44
- // - Markdown-cell escaping (`escapeMarkdownCell()`, WR-04, closed) --
45
+ // - Markdown-cell escaping (`escapeMarkdownCell()`) --
45
46
  // every store-derived text interpolation in the generated document
46
47
  // (comment evidence, symbol names) is escaped through this one
47
48
  // function, never a second ad hoc `.replace()` at a call site.
@@ -50,7 +51,7 @@
50
51
  // - Never hand-edit the rendered output. The banner exists precisely so a
51
52
  // human editor is caught by `checkRenderedMemoryMap()` -- see the
52
53
  // `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
+ // - Never read the layout from the skills tree at runtime (see the module header for why).
54
55
  // This module's own non-vacuity test asserts a zero-count grep for the
55
56
  // recon skill's template filename -- if you are tempted to add a
56
57
  // `readFileSync()` call reaching into `.claude/skills/`, don't; the
@@ -101,14 +102,14 @@ function errMsg(err: unknown): string {
101
102
  * offset at which parsing stopped, as ` (at byte offset N)`, or `""` when the
102
103
  * runtime did not name one.
103
104
  *
104
- * WHY THIS IS A DIGIT EXTRACTOR AND NOT A MESSAGE PASS-THROUGH (CR-03). V8's
105
+ * WHY THIS IS A DIGIT EXTRACTOR AND NOT A MESSAGE PASS-THROUGH. V8's
105
106
  * JSON `SyntaxError` embeds a SNIPPET OF THE INPUT in its own message --
106
107
  * `Unexpected token 'Q', "QQZZORACLE"... is not valid JSON` -- so any code
107
108
  * that forwards `err.message` from a JSON parse over caller-supplied bytes is
108
109
  * a content-disclosure oracle. The capture group here is `(\d+)` and nothing
109
110
  * else, so no byte of the parsed file can reach the returned string however
110
111
  * the runtime words its message. Widening this regex to capture anything but
111
- * digits reopens CR-03.
112
+ * digits reopens the same content-disclosure risk.
112
113
  *
113
114
  * Returns `""` rather than guessing when no position is present (`Unexpected
114
115
  * end of JSON input` carries none) -- an absent offset is reported by absence,
@@ -280,9 +281,9 @@ export function parseProvenanceHeader(json: unknown): ProvenanceHeader {
280
281
  * distinguishable from drift under the same one.
281
282
  *
282
283
  * Version 2 (260821-a86) escaped Markdown table cells via
283
- * `escapeMarkdownCell()` -- WR-04, an output-shape change.
284
+ * `escapeMarkdownCell()`, an output-shape change.
284
285
  *
285
- * Version 3 (D-17) is an INPUT change: `computeRenderDigest()` canonicalises
286
+ * Version 3 is an INPUT change: `computeRenderDigest()` canonicalises
286
287
  * this store's own `RangeRow`/`LabelRow`/`CommentRow` instead of the three
287
288
  * wire shapes recorded above, so the same underlying annotations hash
288
289
  * differently either side of it. Leaving the version at "2" across that
@@ -298,7 +299,7 @@ export const RENDERER_VERSION = "3";
298
299
  * other leg), because comment `evidence` legitimately contains `|` and
299
300
  * embedded newlines (`anno_set_comment`'s own schema documents multi-line
300
301
  * 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
+ * this control's own render leg of T-11-NAME-INJECT: an unescaped `|` or newline in
302
303
  * store text used to be able to inject an extra table cell or split a row
303
304
  * across lines in the generated Markdown. A plain string or an empty string
304
305
  * is returned unchanged. */
@@ -348,7 +349,7 @@ export interface RenderMemoryMapOptions {
348
349
  * reaching an equally real `readFileSync`, carried nothing -- so a reader
349
350
  * comparing the two would reasonably conclude the difference was
350
351
  * 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
+ * arbitrary-file read oracle. An absent comment
352
353
  * beside a present one is a claim, and this one was false. */
353
354
  provenancePath: string;
354
355
  /** The workspace root both confinement checks are taken against. REQUIRED
@@ -396,7 +397,7 @@ export async function renderMemoryMap(opts: RenderMemoryMapOptions): Promise<Ren
396
397
  try {
397
398
  sidecarJson = JSON.parse(sidecarBytes);
398
399
  } catch (err) {
399
- // NEVER INTERPOLATE THE UNDERLYING PARSE ERROR HERE (CR-03). Node's
400
+ // NEVER INTERPOLATE THE UNDERLYING PARSE ERROR HERE. Node's
400
401
  // SyntaxError quotes a snippet of the input it choked on -- e.g.
401
402
  // `Unexpected token 'Q', "QQZZORACLE"... is not valid JSON` -- so passing
402
403
  // it through turns a read refusal into a CONTENT-DISCLOSURE ORACLE. That
@@ -410,7 +411,7 @@ export async function renderMemoryMap(opts: RenderMemoryMapOptions): Promise<Ren
410
411
  // where parsing stopped and not about what the file contains.
411
412
  throw new Error(
412
413
  `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
+ "The underlying parser message is deliberately NOT included -- it quotes the file's own bytes.",
414
415
  );
415
416
  }
416
417
  const provenance = parseProvenanceHeader(sidecarJson);
@@ -460,7 +461,7 @@ export async function renderMemoryMap(opts: RenderMemoryMapOptions): Promise<Ren
460
461
  // load-bearing detail rather than a formatting preference: every byte below
461
462
  // is re-rendered and compared BYTE FOR BYTE by `checkRenderedMemoryMap()`,
462
463
  // 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
+ // the checkout sits. `workspaceRelativePath()` is the one definition
464
465
  // of that spelling; it computes a location and refuses one that escapes the
465
466
  // root. It is NOT a confinement check -- this module still performs no
466
467
  // confinement of its own, exactly as `RenderMemoryMapOptions` documents.
@@ -587,13 +588,13 @@ export interface CheckRenderedMemoryMapOptions {
587
588
  /** See `RenderMemoryMapOptions.provenancePath` -- same argument, one layer
588
589
  * up. The CALLER (`anno-cli.ts`'s `cmdRenderMemmap()`) confines it through
589
590
  * `storePathWithinWorkspace()`; this module performs no confinement of its
590
- * own (CR-03). */
591
+ * own. */
591
592
  provenancePath: string;
592
593
  /** The rendered file to compare against, read RAW by `readFileSync` below.
593
594
  * The CALLER confines it through `storePathWithinWorkspace()` -- the SAME
594
595
  * resolution that produces the write path on the non-`--check` branch, so
595
596
  * 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
+ * rules. This module performs no confinement of its own. */
597
598
  renderedPath: string;
598
599
  /** See `RenderMemoryMapOptions.workspaceRoot`. */
599
600
  workspaceRoot: string;
@@ -626,8 +627,8 @@ export type CheckRenderedMemoryMapResult =
626
627
  * the workspace root;
627
628
  * - a renderer change (output shape, or a `RENDERER_VERSION` bump).
628
629
  *
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
630
+ * AND THE NEGATIVE, which is the defect this list was corrected for: relocating
631
+ * the checkout -- the same tree at a
631
632
  * different absolute path -- does NOT drift. The banner records
632
633
  * workspace-relative locations, so no compared byte is a function of where the
633
634
  * checkout sits. Before that fix this returned `drifted` for a byte-identical