@henols/vice-mcp 0.2.2 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) 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 +1674 -99
  5. package/anno-enum-gen.ts +416 -30
  6. package/anno-export-asm.ts +1175 -89
  7. package/anno-graphics.ts +338 -0
  8. package/anno-hazard-report.ts +1367 -0
  9. package/anno-import.ts +495 -0
  10. package/anno-join.ts +480 -0
  11. package/anno-provenance-ledger.ts +472 -0
  12. package/anno-register.ts +159 -0
  13. package/anno-store-export.ts +661 -0
  14. package/anno-store.ts +518 -2
  15. package/anno-tools.ts +1169 -16
  16. package/anno-types.ts +275 -2
  17. package/backend-detect.mts +124 -312
  18. package/build.ts +3 -1
  19. package/capture-predicate.ts +597 -0
  20. package/channel-lock.ts +349 -0
  21. package/evid-ingest.ts +217 -0
  22. package/evid-reconcile.ts +316 -0
  23. package/host-tool-client.ts +430 -0
  24. package/incident-record.ts +23 -12
  25. package/install-resources.ts +29 -13
  26. package/memmap-lookup.ts +285 -0
  27. package/package.json +27 -8
  28. package/prg-image.ts +1 -2
  29. package/repo-root.ts +87 -3
  30. package/resources/backend-detect.mjs +98 -236
  31. package/resources/broker-control.mjs +189 -16
  32. package/resources/broker-epoch.mjs +1 -1
  33. package/resources/broker-kill.mjs +8 -2
  34. package/resources/broker-launch.mjs +365 -210
  35. package/resources/broker-state.mjs +64 -18
  36. package/resources/container-guard.mjs +1 -1
  37. package/resources/ghidra-project.mjs +790 -0
  38. package/resources/host-tool.mjs +2561 -0
  39. package/resources/vice-broker.mjs +330 -184
  40. package/resources/vice-launcher.sh +127 -9
  41. package/stock-address.ts +1 -1
  42. package/stock-condition.ts +1 -1
  43. package/stock-connect.ts +9 -5
  44. package/stock-derived.ts +29 -37
  45. package/stock-diagnose.ts +200 -36
  46. package/stock-dispatch.ts +179 -77
  47. package/stock-handler.ts +1 -1
  48. package/stock-paths.ts +18 -14
  49. package/stock-petscii.ts +1 -1
  50. package/stock-protocol.ts +1 -1
  51. package/stock-recycle.ts +83 -2
  52. package/stock-reproducible-run.ts +811 -0
  53. package/stock-run-until.ts +100 -1
  54. package/stock-symbols.ts +4 -4
  55. package/stock-timing.ts +1 -1
  56. package/stop-oracle.ts +167 -0
  57. package/text-capability-probe.ts +660 -0
  58. package/text-connect.ts +157 -0
  59. package/text-protocol.ts +810 -0
  60. package/text-tools.ts +778 -0
  61. package/textmon-backtrace.ts +385 -0
  62. package/textmon-cpuhistory.ts +335 -0
  63. package/textmon-memmap.ts +494 -0
  64. package/textmon-profile.ts +458 -0
  65. package/textmon-registers.ts +748 -0
  66. package/tools-manifest.stock.json +864 -3
  67. package/vice-broker-client.ts +189 -42
  68. package/vice-errors.ts +268 -0
  69. package/vice-proxy.ts +339 -2144
  70. package/vsf-slice.ts +640 -0
  71. package/anno-d64.ts +0 -310
  72. package/capability-registry.ts +0 -390
  73. package/refresh-manifest.ts +0 -124
  74. package/tools-manifest.json +0 -1223
  75. package/vice-probe.ts +0 -278
  76. package/vice-sync.ts +0 -336
  77. package/vice.ts +0 -772
package/anno-enum-gen.ts CHANGED
@@ -1,8 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
  // anno-enum-gen.ts -- the ONE authoritative place in this repo for value ->
3
3
  // variant naming, the adjacent-pair rule, identifier sanitization, the
4
- // per-register enum plan and the coverage report's wording contract
5
- // (D-20/D-22/D-23, ANNO-13).
4
+ // per-register enum plan, the coverage report's wording contract, and (as of
5
+ // phase 45 plan 45-03) the multi-bit register DECOMPOSITION into named,
6
+ // OR-able terms (D-15/D-16/D-17/D-20/D-22/D-23, ANNO-13).
6
7
  //
7
8
  // WHAT LEFT, WHAT STAYED, AND WHERE THE ROUTE RETURNS (plan 29-10, D-01,
8
9
  // 2026-08-30). Read this paragraph before looking for a function that is not
@@ -39,17 +40,34 @@
39
40
  // leaving it to be inferred from a row count.
40
41
  // - `sanitizeVariantMap()` and the identifier gate it runs, unchanged.
41
42
  //
42
- // WHERE THE ROUTE RETURNS: **NO PHASE CURRENTLY OWNS ITS RETURN**, and this
43
- // line used to say otherwise. It forecast a rebuild of the fetch and the
44
- // install over this project's own annotation store, rendering the enums into
45
- // the ACME export. The ACME export route itself did come back on 2026-08-31,
46
- // as the `anno export-asm` CLI verb -- but the work that rebuilt it covered
47
- // that route ONLY: no requirement and no success criterion of it mentioned
48
- // `gen-enums`, and no phase currently owns rebuilding it. The forecast was
49
- // therefore wrong, and it is CORRECTED here
50
- // rather than deleted, because deleting the notice would erase the record
51
- // that the capability went missing. Everything above is the specification
52
- // whoever eventually rebuilds it builds against.
43
+ // WHERE THE ROUTE RETURNS: this line used to say "NO PHASE CURRENTLY OWNS
44
+ // ITS RETURN", and that too is now CORRECTED rather than deleted, for the
45
+ // same reason the paragraph above was: deleting a withdrawal notice erases
46
+ // the record that a capability went missing, and deleting a return notice
47
+ // would erase the record of when and why it came back. The first
48
+ // correction (recorded here, kept for the history): it forecast a rebuild
49
+ // of the fetch and the install over this project's own annotation store,
50
+ // rendering the enums into the ACME export. The ACME export route itself
51
+ // did come back on 2026-08-31, as the `anno export-asm` CLI verb -- but the
52
+ // work that rebuilt it covered that route ONLY: no requirement and no
53
+ // success criterion of it mentioned `gen-enums`, and at that time no phase
54
+ // owned rebuilding it.
55
+ //
56
+ // THE SECOND CORRECTION, dated 2026-09-11 (phase 45 plan 45-03, D-15):
57
+ // Phase 45 owns it now, and has returned it -- the ENUM half of `ANNO-13`
58
+ // only. `fetchRegisterSearchRows()` walks a store's own `code`-typed ranges
59
+ // through the same `disasm-decoder.ts` `decode()` `anno_disassemble` uses,
60
+ // `generateEnumsFromStore()` strings fetch -> `pairSearchRows()` ->
61
+ // `planEnumsForPairing()` -> `sanitizeVariantMap()` -> `installPlannedEnums()`
62
+ // -> `buildEnumGenerationReport()`, and `installPlannedEnums()` installs
63
+ // through the same `createProjectEnum()`/`updateProjectEnum()`/
64
+ // `applyEnumUsage()` write path the by-hand route already used. The symbol
65
+ // round trip (`ANNO-14`/`ANNO-15`, `export-lbl`/`import-lbl`) is a SEPARATE
66
+ // capability this phase does not touch and remains unowned -- see
67
+ // `.planning/PROJECT.md`'s own withdrawal notice, corrected in the same
68
+ // plan. Everything above this paragraph is the specification this rebuild
69
+ // was built against, and it needed no changes to build against: every
70
+ // surviving heuristic is called here unmodified.
53
71
  //
54
72
  // MEASURED MECHANISM FACTS, PAST TENSE -- kept because they are WHY the
55
73
  // heuristics have the shape they have, not because anything still calls the
@@ -115,6 +133,11 @@ import { fileURLToPath } from "node:url";
115
133
 
116
134
  import type { RegBitsField, RegBitsTable } from "./anno-regbits-gen.ts";
117
135
  import { MAX_ACME_IDENTIFIER_LENGTH, assertLegalAcmeIdentifier } from "./anno-acme-ident.ts";
136
+ import { decode } from "./disasm-decoder.ts";
137
+ import type { Instruction } from "./disasm-decoder.ts";
138
+ import { applyEnumUsage, createProjectEnum, listRanges, updateProjectEnum } from "./anno-store.ts";
139
+ import type { AnnoStoreHandle } from "./anno-store.ts";
140
+ import { AnnoLabelError } from "./anno-types.ts";
118
141
 
119
142
  const HERE = dirname(fileURLToPath(import.meta.url));
120
143
  const REGBITS_PATH = join(HERE, "anno-regbits.json");
@@ -182,32 +205,85 @@ export function registerKeyFor(address: number): string {
182
205
  * The measured target this function is pinned against:
183
206
  * `variantNameFor(0xd011, 0x1b) === "YSCROLL3_ROW25_SCREENON_TEXT"`.
184
207
  */
185
- export function variantNameFor(register: number, value: number): string {
208
+ /** Looks up `register`'s bit-name table entry, or throws naming the register
209
+ * and the remedy -- the ONE lookup+refusal both `variantNameFor()` and
210
+ * `decomposeRegisterValue()` share, so the two can never disagree about
211
+ * which registers are decodable at all. */
212
+ function requireRegBitsEntry(key: string, callerName: string): RegBitsField[] {
186
213
  const table = loadRegBits();
187
- const key = registerKeyFor(register);
188
214
  const entry = table[key];
189
215
  if (!entry) {
190
216
  throw new Error(
191
- `variantNameFor: no bit-name table entry for register ${key} -- anno-regbits.json has no fields ` +
217
+ `${callerName}: no bit-name table entry for register ${key} -- anno-regbits.json has no fields ` +
192
218
  "for this address (add an OVERRIDES entry in anno-regbits-gen.ts, or exclude it from generation).",
193
219
  );
194
220
  }
221
+ return entry.fields as RegBitsField[];
222
+ }
223
+
224
+ /**
225
+ * THE ONE MEMBERSHIP-TEST PREDICATE (45-REVIEW CR-01): answers "does
226
+ * `anno-regbits.json` have an entry for this register at all", independent
227
+ * of whether that entry, once found, can fully decompose any particular
228
+ * value. `pairSearchRows()` above already narrows candidate `sta` targets
229
+ * this same way (`knownRegisters.has(key)`, built from this table's own
230
+ * keys) before ever treating one as a register; this export gives the two
231
+ * D-16 render surfaces (`anno-export-asm.ts`, `anno-tools.ts`'s
232
+ * `renderDisassembleListing()`) the identical membership check so a
233
+ * register-SHAPED enum name for a register the table simply does not cover
234
+ * (e.g. `D020`, `D021` -- confirmed absent, `docs/phase45-closure-dxa-family.md`)
235
+ * falls through to the plain single-symbol path instead of being attempted,
236
+ * and failing, through `decomposeRegisterValue()`.
237
+ *
238
+ * `key` is the SAME `$`-prefixed shape `registerKeyFor()` produces and the
239
+ * table's own keys use (e.g. `"$D011"`) -- callers holding only the bare
240
+ * `regKey.slice(1)` enum name (e.g. `"D011"`) must prefix it with `$` before
241
+ * calling this, exactly as `requireRegBitsEntry()`'s own callers already do
242
+ * via `registerKeyFor()`.
243
+ *
244
+ * Deliberately NOT folded into `requireRegBitsEntry()`: that function's job
245
+ * is "fetch or throw naming the remedy" for a caller that already believes
246
+ * the register IS decodable; this function's job is "may I even ask" for a
247
+ * caller that does not yet know. Collapsing them would force every
248
+ * membership check to pay for (and catch) a thrown error it does not want.
249
+ */
250
+ export function hasRegBitsEntry(key: string): boolean {
251
+ return loadRegBits()[key] !== undefined;
252
+ }
253
+
254
+ /**
255
+ * Decodes ONE field of `register`'s value against `field` -- the single
256
+ * decode step `variantNameFor()` and `decomposeRegisterValue()` BOTH walk in
257
+ * the same ascending bit order (Task 1's own rule: "do not write a second
258
+ * decode"). A numeric field ALWAYS returns a non-empty token (total by
259
+ * construction). A flag/enum field's token is looked up in `field.tokens`;
260
+ * an explicitly-silent state (empty string) is returned as `""`, never
261
+ * treated as absent; a genuinely missing token throws, naming the register,
262
+ * the field and the decoded value, exactly as before this extraction.
263
+ */
264
+ function decodeField(key: string, field: RegBitsField, value: number): { decoded: number; token: string } {
265
+ const decoded = (value & field.mask) >>> field.shift;
266
+ if (field.kind === "numeric") {
267
+ return { decoded, token: `${field.name}${decoded}` };
268
+ }
269
+ const token = field.tokens?.[decoded];
270
+ if (token === undefined) {
271
+ throw new Error(
272
+ `variantNameFor: register ${key} field "${field.name}" (kind ${field.kind}) has no token for decoded ` +
273
+ `value ${decoded} (full register value 0x${value.toString(16)}) -- refusing rather than silently ` +
274
+ "dropping a field, which could make two distinct register values decode to the same name.",
275
+ );
276
+ }
277
+ return { decoded, token };
278
+ }
279
+
280
+ export function variantNameFor(register: number, value: number): string {
281
+ const key = registerKeyFor(register);
282
+ const fields = requireRegBitsEntry(key, "variantNameFor");
195
283
 
196
284
  const tokens: string[] = [];
197
- for (const field of entry.fields as RegBitsField[]) {
198
- const decoded = (value & field.mask) >>> field.shift;
199
- if (field.kind === "numeric") {
200
- tokens.push(`${field.name}${decoded}`);
201
- continue;
202
- }
203
- const token = field.tokens?.[decoded];
204
- if (token === undefined) {
205
- throw new Error(
206
- `variantNameFor: register ${key} field "${field.name}" (kind ${field.kind}) has no token for decoded ` +
207
- `value ${decoded} (full register value 0x${value.toString(16)}) -- refusing rather than silently ` +
208
- "dropping a field, which could make two distinct register values decode to the same name.",
209
- );
210
- }
285
+ for (const field of fields) {
286
+ const { token } = decodeField(key, field, value);
211
287
  if (token !== "") tokens.push(token);
212
288
  }
213
289
  if (tokens.length === 0) {
@@ -226,6 +302,150 @@ export function variantNameFor(register: number, value: number): string {
226
302
  return tokens.join("_");
227
303
  }
228
304
 
305
+ // ---------------------------------------------------------------------------
306
+ // The ONE owning multi-bit decoder (Task 1, D-16/D-17). `variantNameFor()`
307
+ // above already decodes a value into per-field tokens and joins them with
308
+ // `_` into ONE total name; this is that SAME token list, unjoined, each term
309
+ // carrying its own masked value -- so the OR-ed decomposition and the
310
+ // whole-value enum member are provably one vocabulary, never two.
311
+ // ---------------------------------------------------------------------------
312
+
313
+ /** One named, OR-able term of a decomposed register write. `name` is the
314
+ * emitted ACME identifier (`<enumName>_<field token>`, the same
315
+ * `regKey.slice(1)` prefix `planEnumsForPairing()` already uses for its own
316
+ * `enumName`). `value` is this term's own masked contribution
317
+ * (`value & field.mask`) -- the bitwise OR of every term's `value` in a
318
+ * `RegisterDecomposition` reconstructs the original byte exactly, or
319
+ * `decomposeRegisterValue()` refuses rather than return the lossy result. */
320
+ export interface RegisterTerm {
321
+ name: string;
322
+ value: number;
323
+ fieldName: string;
324
+ decoded: number;
325
+ }
326
+
327
+ /** The full decomposition of one register write. `comment` is the
328
+ * mechanical decode text (`<REGKEY>: <FIELD>=<decoded>`, comma-separated, in
329
+ * ascending bit order) -- D-17's readability half; `terms` is the OR-able
330
+ * half. `multiField` is true when the register's own table entry has two or
331
+ * more fields, regardless of how many terms a particular value happened to
332
+ * produce (a single-field register, or a value that silenced every
333
+ * flag/enum field but one, is not "multi-bit" in the sense D-17 cares
334
+ * about). */
335
+ export interface RegisterDecomposition {
336
+ terms: RegisterTerm[];
337
+ comment: string;
338
+ multiField: boolean;
339
+ }
340
+
341
+ /**
342
+ * THE ONE OWNING DECODER (D-16): splits `value` into one named term per
343
+ * bit-field of `register`, arithmetically exact. Both render surfaces
344
+ * (`anno-export-asm.ts`'s OR-ed constants, plan 45-05; `anno_disassemble`'s
345
+ * readable comment, plan 45-05) consume THIS function rather than decoding
346
+ * independently -- see this module's own header for why that is the whole
347
+ * point.
348
+ *
349
+ * Rules, each pinned by a test in `anno-enum-gen.test.ts`:
350
+ * - Walks the SAME field loop, in the SAME ascending bit order, and the
351
+ * SAME per-field decode (`decodeField()` above) that `variantNameFor()`
352
+ * walks -- never a second decode.
353
+ * - A field whose token is the explicit empty string AND whose masked
354
+ * contribution is zero is OMITTED: it contributes nothing to the OR and
355
+ * nothing to the name (the silent-by-design case).
356
+ * - A field whose token is the empty string but whose masked contribution
357
+ * is NON-zero is a DATA ERROR in `anno-regbits.json`'s own OVERRIDES
358
+ * table -- refused by name, exactly like the missing-token case
359
+ * `decodeField()` already refuses.
360
+ * - After building the terms, the OR of every term's `value` MUST equal
361
+ * the input `value`. When it does not -- the table's fields do not cover
362
+ * every set bit of `value` -- this REFUSES, naming the register, the
363
+ * value and the uncovered bits in hex, with the remedy. Never emits a
364
+ * residual hex literal into the term list: a magic number in the OR
365
+ * expression is exactly what criterion 5 forbids.
366
+ * - The degenerate all-silent case (every field decodes to a silent
367
+ * token) returns the single `V<value>` term rather than an empty list,
368
+ * mirroring `variantNameFor()`'s own fallback so the two never disagree
369
+ * about what that value is called. This can only happen when `value`
370
+ * itself is `0` for a register whose fields fully cover the byte (every
371
+ * silent field's masked contribution is, by the rule above, zero) --
372
+ * the OR-reconstruction check above still runs FIRST, so a value this
373
+ * branch would otherwise mis-accept as "all silent" but that actually
374
+ * has uncovered bits is refused there instead, never silently treated
375
+ * as degenerate.
376
+ */
377
+ export function decomposeRegisterValue(register: number, value: number): RegisterDecomposition {
378
+ const key = registerKeyFor(register);
379
+ const fields = requireRegBitsEntry(key, "decomposeRegisterValue");
380
+ const enumName = key.slice(1); // "$D011" -> "D011", the SAME prefix planEnumsForPairing() derives.
381
+
382
+ const terms: RegisterTerm[] = [];
383
+ const commentParts: string[] = [];
384
+ let orAccumulator = 0;
385
+
386
+ for (const field of fields) {
387
+ const { decoded, token } = decodeField(key, field, value);
388
+ const masked = value & field.mask;
389
+ commentParts.push(`${field.name}=${decoded}`);
390
+
391
+ if (token === "") {
392
+ if (masked !== 0) {
393
+ throw new Error(
394
+ `decomposeRegisterValue: register ${key} field "${field.name}" decoded a NON-ZERO contribution ` +
395
+ `(0x${masked.toString(16)}) from an explicitly-silent token at decoded value ${decoded} (full register ` +
396
+ `value 0x${value.toString(16)}) -- a silent token must correspond to a zero masked contribution, or the ` +
397
+ "OR-reconstruction below would silently drop a real bit. This is a data error in anno-regbits.json's " +
398
+ "OVERRIDES table (anno-regbits-gen.ts), not a value this function can decompose.",
399
+ );
400
+ }
401
+ continue; // silent-by-design, zero contribution -- omitted from both the OR and the name.
402
+ }
403
+
404
+ terms.push({ name: `${enumName}_${token}`, value: masked, fieldName: field.name, decoded });
405
+ orAccumulator |= masked;
406
+ }
407
+
408
+ if (orAccumulator !== value) {
409
+ const uncovered = value & ~orAccumulator & 0xff;
410
+ throw new Error(
411
+ `decomposeRegisterValue: register ${key} value 0x${value.toString(16)} is not fully covered by its fields -- the ` +
412
+ `OR of the decomposed terms is 0x${orAccumulator.toString(16)}, leaving bits 0x${uncovered.toString(16)} unaccounted ` +
413
+ "for. Refusing to emit a lossy decomposition rather than a residual hex literal (add an OVERRIDES entry in " +
414
+ "anno-regbits-gen.ts and regenerate anno-regbits.json).",
415
+ );
416
+ }
417
+
418
+ if (terms.length === 0) {
419
+ // Every field decoded to an explicitly-silent, zero-contribution token --
420
+ // the invariant check above already proved value === 0 in this branch
421
+ // (orAccumulator is the OR of zeros), so this mirrors variantNameFor()'s
422
+ // own V<value> fallback exactly, never disagreeing about what value 0
423
+ // (or any all-silent value) is called.
424
+ terms.push({ name: `${enumName}_V${value}`, value, fieldName: "", decoded: value });
425
+ }
426
+
427
+ // T-45-10's mitigation, run here rather than left to a downstream caller:
428
+ // MEASURED against the real committed table (register $0001, "MOS 6510
429
+ // Micro-Processor On-Chip I/O Port") that a numeric-leading `enumName`
430
+ // (`registerKeyFor(1).slice(1)` is the all-digit string "0001") produces
431
+ // an illegal ACME identifier for EVERY term of that register, regardless
432
+ // of value -- `sanitizeVariantMap()` never catches this because it only
433
+ // validates a bare variant name, never the enum-name prefix this
434
+ // function's own OR-emission shape adds. Refusing here, before returning
435
+ // anything, is the same client-side-before-I/O property
436
+ // `sanitizeVariantMap()` already holds -- an illegal name provably never
437
+ // reaches a render surface.
438
+ for (const term of terms) {
439
+ assertLegalAcmeIdentifier(term.name, `decomposeRegisterValue term for register ${key} value 0x${value.toString(16)}`);
440
+ }
441
+
442
+ return {
443
+ terms,
444
+ comment: `${key}: ${commentParts.join(", ")}`,
445
+ multiField: fields.length >= 2,
446
+ };
447
+ }
448
+
229
449
  // ---------------------------------------------------------------------------
230
450
  // The two-pass search + adjacent-pair (D-23).
231
451
  // ---------------------------------------------------------------------------
@@ -531,3 +751,169 @@ export function buildEnumGenerationReport(
531
751
  };
532
752
  }
533
753
 
754
+ // ---------------------------------------------------------------------------
755
+ // Task 2 (D-15): THE REBUILT FETCH AND INSTALL, over this project's own
756
+ // disassembler and store. Everything above this line is a surviving
757
+ // heuristic, called here but never edited (`variantNameFor()`,
758
+ // `pairSearchRows()`, `planEnumsForPairing()`, `sanitizeVariantMap()`,
759
+ // `buildEnumGenerationReport()`).
760
+ // ---------------------------------------------------------------------------
761
+
762
+ /** The minimal shape this module's fetch needs from a loaded image: the
763
+ * origin address and the raw body bytes. Deliberately NOT importing
764
+ * `anno-tools.ts`'s own `LoadedImage` (a private, tool-layer interface) --
765
+ * that would pull the tool-dispatch module into this one, and all this fetch
766
+ * needs from it is these two fields. */
767
+ interface EnumSourceImage {
768
+ origin: number;
769
+ body: Uint8Array;
770
+ }
771
+
772
+ const IMMEDIATE_LOAD_MNEMONICS: ReadonlySet<string> = new Set(["lda", "ldx", "ldy"]);
773
+ const ABSOLUTE_STORE_MNEMONICS: ReadonlySet<string> = new Set(["sta", "stx", "sty"]);
774
+
775
+ /** The slice of `image` covering `[start, endInclusive]`, or `null` when the
776
+ * span is not entirely inside the image -- mirrors `anno-tools.ts`'s own
777
+ * `sliceSpan()` bounds discipline (never a short slice, never a fabricated
778
+ * byte for a range this image does not cover) without importing that
779
+ * private function. */
780
+ function sliceImageRange(image: EnumSourceImage, start: number, endInclusive: number): Uint8Array | null {
781
+ const from = start - image.origin;
782
+ const to = endInclusive - image.origin;
783
+ if (from < 0 || to >= image.body.length || from > to) return null;
784
+ return image.body.subarray(from, to + 1);
785
+ }
786
+
787
+ function searchRowAddress(instr: Instruction): string {
788
+ return `$${instr.address.toString(16).toUpperCase().padStart(4, "0")}`;
789
+ }
790
+
791
+ function toSearchRow(instr: Instruction, operand: string): DisasmSearchRow {
792
+ return { address: searchRowAddress(instr), address_decimal: instr.address, label: "", mnemonic: instr.mnemonic, operand, comment: "" };
793
+ }
794
+
795
+ export interface FetchRegisterSearchRowsOptions {
796
+ maxResults?: number;
797
+ }
798
+
799
+ export interface FetchRegisterSearchRowsResult {
800
+ ldaRows: DisasmSearchRow[];
801
+ staRows: DisasmSearchRow[];
802
+ }
803
+
804
+ /**
805
+ * THE REBUILT FETCH (D-15). Walks `handle`'s own `code`-typed ranges,
806
+ * decoding each through the SAME `disasm-decoder.ts` `decode()` function
807
+ * `anno_disassemble` uses -- never a second decoder, never a regex over
808
+ * rendered text. Returns two plain row arrays in the exact `DisasmSearchRow`
809
+ * shape `pairSearchRows()` already consumes: pass 1, immediate loads
810
+ * (`lda`/`ldx`/`ldy`); pass 2, absolute stores (`sta`/`stx`/`sty`) whose
811
+ * target is a register `anno-regbits.json` knows.
812
+ *
813
+ * `maxResults` bounds EACH pass independently AS IT IS FETCHED, not merely
814
+ * reported afterwards -- that is what makes `pairSearchRows()`'s own
815
+ * truncation signal (a returned row count equal to the ceiling) a true
816
+ * measurement rather than a coincidence: capping here is the only way a
817
+ * caller comparing the returned length against the same ceiling can trust
818
+ * what it sees (D-23's "no silent caps").
819
+ */
820
+ export function fetchRegisterSearchRows(
821
+ handle: AnnoStoreHandle,
822
+ image: EnumSourceImage,
823
+ opts: FetchRegisterSearchRowsOptions = {},
824
+ ): FetchRegisterSearchRowsResult {
825
+ const maxResults = opts.maxResults ?? DEFAULT_MAX_RESULTS;
826
+ const knownRegisters = new Set(Object.keys(loadRegBits()));
827
+
828
+ const ldaRows: DisasmSearchRow[] = [];
829
+ const staRows: DisasmSearchRow[] = [];
830
+
831
+ for (const range of listRanges(handle)) {
832
+ if (range.dataType !== "code") continue;
833
+ const bytes = sliceImageRange(image, range.start, range.endInclusive);
834
+ if (bytes === null) continue; // this image does not cover the range -- never fabricate bytes for it
835
+ const instructions = decode(bytes, range.start, { end: range.endInclusive });
836
+ for (const instr of instructions) {
837
+ if (ldaRows.length < maxResults && instr.mode === "immediate" && instr.operand && IMMEDIATE_LOAD_MNEMONICS.has(instr.mnemonic)) {
838
+ ldaRows.push(toSearchRow(instr, `#$${instr.operand.value.toString(16).padStart(2, "0")}`));
839
+ } else if (staRows.length < maxResults && instr.mode === "absolute" && instr.operand && ABSOLUTE_STORE_MNEMONICS.has(instr.mnemonic)) {
840
+ const key = registerKeyFor(instr.operand.value);
841
+ if (knownRegisters.has(key)) {
842
+ staRows.push(toSearchRow(instr, `$${instr.operand.value.toString(16).padStart(4, "0")}`));
843
+ }
844
+ }
845
+ }
846
+ }
847
+
848
+ return { ldaRows, staRows };
849
+ }
850
+
851
+ /**
852
+ * THE REBUILT INSTALL (D-15): create-or-update each planned enum through the
853
+ * shipped `createProjectEnum()`/`updateProjectEnum()` write path -- the SAME
854
+ * functions `anno_create_project_enum`/`anno_update_project_enum` dispatch
855
+ * to, never a second install path -- and bind every occurrence through
856
+ * `applyEnumUsage()`, at the `lda` address (never the store address -- the
857
+ * measured binding fact in this module's header). `sanitizeVariantMap()`
858
+ * runs FIRST, before any I/O, so an illegal identifier provably never
859
+ * reaches the store (the same client-side-first property the deleted
860
+ * installer proved with a spy binary).
861
+ *
862
+ * CREATE-THEN-UPDATE, never a delete: `createProjectEnum()` no-ops on a
863
+ * byte-identical repeat and THROWS `AnnoLabelError` when the same name
864
+ * already holds DIFFERENT content -- caught here and retried through
865
+ * `updateProjectEnum()`, which replaces the variant map wholesale. This is
866
+ * `EnumInstallAction`'s own documented re-runnability precedent (see its
867
+ * comment above); an installer that could only ever report "created" would
868
+ * have quietly dropped ANNO-13's re-runnability requirement.
869
+ */
870
+ export function installPlannedEnums(handle: AnnoStoreHandle, planned: readonly PlannedEnum[]): EnumInstallSummary[] {
871
+ const summaries: EnumInstallSummary[] = [];
872
+ for (const plan of planned) {
873
+ const sanitized = sanitizeVariantMap(plan.regKey, plan.variants);
874
+ const description = `Generated by anno-enum-gen.ts (D-15) from ${plan.occurrences.length} observed write(s) to ${plan.regKey}.`;
875
+
876
+ let action: EnumInstallAction;
877
+ try {
878
+ createProjectEnum(handle, { name: plan.enumName, variants: sanitized, description });
879
+ action = "created";
880
+ } catch (err) {
881
+ if (!(err instanceof AnnoLabelError)) throw err;
882
+ updateProjectEnum(handle, { name: plan.enumName, variants: sanitized, description });
883
+ action = "updated";
884
+ }
885
+
886
+ let usagesApplied = 0;
887
+ for (const occ of plan.occurrences) {
888
+ applyEnumUsage(handle, { address: occ.ldaAddr, name: plan.enumName });
889
+ usagesApplied += 1;
890
+ }
891
+
892
+ summaries.push({ regKey: plan.regKey, enumName: plan.enumName, variantCount: plan.variants.size, action, usagesApplied });
893
+ }
894
+ return summaries;
895
+ }
896
+
897
+ export interface GenerateEnumsFromStoreOptions {
898
+ maxResults?: number;
899
+ }
900
+
901
+ /**
902
+ * THE REBUILT PASS (D-15): fetch -> `pairSearchRows()` -> `planEnumsForPairing()`
903
+ * -> `installPlannedEnums()` (which itself calls `sanitizeVariantMap()`) ->
904
+ * `buildEnumGenerationReport()`. The three middle heuristics are called,
905
+ * never edited, exactly per this module's own header specification.
906
+ */
907
+ export function generateEnumsFromStore(
908
+ handle: AnnoStoreHandle,
909
+ image: EnumSourceImage,
910
+ opts: GenerateEnumsFromStoreOptions = {},
911
+ ): EnumGenerationReport {
912
+ const maxResults = opts.maxResults ?? DEFAULT_MAX_RESULTS;
913
+ const { ldaRows, staRows } = fetchRegisterSearchRows(handle, image, { maxResults });
914
+ const pairing = pairSearchRows(ldaRows, staRows, maxResults);
915
+ const planned = planEnumsForPairing(pairing);
916
+ const installed = installPlannedEnums(handle, planned);
917
+ return buildEnumGenerationReport(pairing, installed, maxResults);
918
+ }
919
+