@henols/vice-mcp 0.2.1 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. package/README.md +4 -3
  2. package/THIRD-PARTY-NOTICES.md +423 -25
  3. package/{r2000-acme-ident.ts → anno-acme-ident.ts} +13 -13
  4. package/anno-bank.ts +171 -0
  5. package/anno-cli.ts +3040 -0
  6. package/{r2000-confidence.ts → anno-confidence.ts} +22 -22
  7. package/anno-coverage.ts +2465 -0
  8. package/anno-derive.ts +590 -0
  9. package/anno-details.ts +169 -0
  10. package/anno-enum-gen.ts +919 -0
  11. package/anno-export-asm.ts +2396 -0
  12. package/anno-graphics.ts +338 -0
  13. package/anno-hazard-report.ts +1367 -0
  14. package/anno-import.ts +495 -0
  15. package/anno-index.ts +150 -0
  16. package/anno-join.ts +480 -0
  17. package/{r2000-memmap-render.ts → anno-memmap-render.ts} +236 -95
  18. package/anno-provenance-ledger.ts +472 -0
  19. package/{r2000-regbits-gen.ts → anno-regbits-gen.ts} +20 -15
  20. package/{r2000-regbits.json → anno-regbits.json} +2 -2
  21. package/anno-register.ts +399 -0
  22. package/anno-store-export.ts +661 -0
  23. package/anno-store.ts +4002 -0
  24. package/anno-symbols.ts +266 -0
  25. package/anno-tools.ts +3264 -0
  26. package/anno-types.ts +1909 -0
  27. package/backend-detect.mts +124 -312
  28. package/block-class.ts +201 -0
  29. package/build.ts +4 -2
  30. package/capture-predicate.ts +597 -0
  31. package/channel-lock.ts +349 -0
  32. package/disasm-decoder.ts +14 -14
  33. package/disasm-opcodes.ts +4 -4
  34. package/disasm-renderer.ts +2 -2
  35. package/evid-ingest.ts +217 -0
  36. package/evid-reconcile.ts +316 -0
  37. package/host-tool-client.ts +430 -0
  38. package/hostpath.ts +1 -1
  39. package/incident-record.ts +23 -12
  40. package/install-resources.ts +30 -14
  41. package/memmap-lookup.ts +285 -0
  42. package/package.json +48 -23
  43. package/prg-image.ts +118 -0
  44. package/repo-root.ts +107 -8
  45. package/resources/backend-detect.mjs +98 -236
  46. package/resources/broker-control.mjs +189 -16
  47. package/resources/broker-epoch.mjs +1 -1
  48. package/resources/broker-kill.mjs +8 -2
  49. package/resources/broker-launch.mjs +373 -214
  50. package/resources/broker-state.mjs +64 -18
  51. package/resources/container-guard.mjs +1 -1
  52. package/resources/ghidra-project.mjs +790 -0
  53. package/resources/host-tool.mjs +2561 -0
  54. package/resources/vice-broker.mjs +330 -184
  55. package/resources/vice-launcher.sh +130 -12
  56. package/stock-address.ts +6 -6
  57. package/stock-cia.ts +2 -2
  58. package/stock-condition.ts +8 -8
  59. package/stock-connect.ts +10 -6
  60. package/stock-derived.ts +29 -37
  61. package/stock-diagnose.ts +200 -36
  62. package/stock-dispatch.ts +200 -68
  63. package/stock-execution.ts +5 -3
  64. package/stock-handler.ts +1 -1
  65. package/stock-input.ts +9 -9
  66. package/stock-machine.ts +17 -6
  67. package/stock-paths.ts +18 -14
  68. package/stock-petscii.ts +1 -1
  69. package/stock-protocol.ts +17 -12
  70. package/stock-recycle.ts +83 -2
  71. package/stock-registers.ts +54 -29
  72. package/stock-reproducible-run.ts +811 -0
  73. package/stock-run-until.ts +100 -1
  74. package/stock-sprites.ts +3 -3
  75. package/stock-symbols.ts +13 -13
  76. package/stock-timing.ts +2 -2
  77. package/stock-vicii.ts +1 -1
  78. package/stop-oracle.ts +167 -0
  79. package/text-capability-probe.ts +660 -0
  80. package/text-connect.ts +157 -0
  81. package/text-protocol.ts +810 -0
  82. package/text-tools.ts +778 -0
  83. package/textmon-backtrace.ts +385 -0
  84. package/textmon-cpuhistory.ts +335 -0
  85. package/textmon-memmap.ts +494 -0
  86. package/textmon-profile.ts +458 -0
  87. package/textmon-registers.ts +748 -0
  88. package/tools-manifest.stock.json +864 -3
  89. package/version.ts +1 -1
  90. package/vice-broker-client.ts +189 -42
  91. package/vice-errors.ts +268 -0
  92. package/vice-proxy.ts +392 -2175
  93. package/vsf-slice.ts +640 -0
  94. package/capability-registry.ts +0 -388
  95. package/r2000-cli.ts +0 -1103
  96. package/r2000-d64.ts +0 -310
  97. package/r2000-enum-gen.ts +0 -574
  98. package/r2000-launch.ts +0 -357
  99. package/r2000-mcp-client.ts +0 -596
  100. package/r2000-project.ts +0 -190
  101. package/r2000-symbols.ts +0 -388
  102. package/r2000-tools.ts +0 -914
  103. package/r2000-verify.ts +0 -184
  104. package/refresh-manifest.ts +0 -124
  105. package/tools-manifest.json +0 -1223
  106. package/vice-probe.ts +0 -278
  107. package/vice-sync.ts +0 -336
  108. package/vice.ts +0 -772
@@ -0,0 +1,919 @@
1
+ #!/usr/bin/env node
2
+ // anno-enum-gen.ts -- the ONE authoritative place in this repo for value ->
3
+ // variant naming, the adjacent-pair rule, identifier sanitization, the
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).
7
+ //
8
+ // WHAT LEFT, WHAT STAYED, AND WHERE THE ROUTE RETURNS (plan 29-10, D-01,
9
+ // 2026-08-30). Read this paragraph before looking for a function that is not
10
+ // here.
11
+ //
12
+ // WHAT LEFT: the ROUTE, and only the route. Four things went, because all
13
+ // four spoke to the retired external analyser's own tool surface and every
14
+ // module they spoke through was deleted in the same commit:
15
+ // - the two disassembly searches that FETCHED the `lda` and `sta` rows;
16
+ // - `parseSearchRows()`, which unwrapped that surface's own result shape;
17
+ // - `createOrUpdateEnum()` and `applyUsage()`, which INSTALLED an enum
18
+ // and bound it to an address through that surface;
19
+ // - `generateEnums()`, the pass that strung those together.
20
+ //
21
+ // WHAT STAYED: the HEURISTICS, all of them, as live code rather than as
22
+ // prose about code that used to exist. This is the part the classification
23
+ // registry exists to protect, so it was extracted from the route rather
24
+ // than deleted with it:
25
+ // - `variantNameFor()` and the bit-name table it decodes against -- the
26
+ // whole D-22 naming vocabulary, untouched, still pinned by its
27
+ // injectivity tests across all 256 values.
28
+ // - `pairSearchRows()` -- the D-23 adjacent-pair rule (a store pairs with
29
+ // an immediate load exactly 2 bytes earlier, adjacent-only, no
30
+ // dataflow, a miss costs nothing), lifted out of the deleted fetch
31
+ // loop verbatim and now a PURE function of two already-fetched row
32
+ // arrays. Whoever rebuilds the fetch supplies the rows; the rule does
33
+ // not change.
34
+ // - `planEnumsForPairing()` -- D-20's own rule: one variant per DISTINCT
35
+ // value the program actually writes, never a full
36
+ // 256-values-per-register table, with the first-seen `lda` address
37
+ // kept as each value's representative binding site.
38
+ // - `buildEnumGenerationReport()` -- D-23's "no silent caps" wording
39
+ // contract, which states a possible truncation in WORDS rather than
40
+ // leaving it to be inferred from a row count.
41
+ // - `sanitizeVariantMap()` and the identifier gate it runs, unchanged.
42
+ //
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.
71
+ //
72
+ // MEASURED MECHANISM FACTS, PAST TENSE -- kept because they are WHY the
73
+ // heuristics have the shape they have, not because anything still calls the
74
+ // producer they were measured against (a real pinned-version 0.9.20 child on
75
+ // this host, by direct live call, never paraphrased from a document):
76
+ // - An enum definition's variants were a flat `BTreeMap<u16, String>` -- a
77
+ // plain value-to-name map, with NO bit-OR composition anywhere. That is
78
+ // why `variantNameFor()` must produce one TOTAL name per value rather
79
+ // than a composable set of flags.
80
+ // - Applying an enum usage bound it to the INSTRUCTION ADDRESS holding the
81
+ // immediate operand (the `lda`, never the `sta`) -- confirmed both by
82
+ // direct call and by `handler.rs:1236-1264`'s own description text. That
83
+ // is why `PairOccurrence` carries `ldaAddr` and not the store address.
84
+ // - Applying an enum emitted its WHOLE variant list into the exported ACME
85
+ // header; an unmatched value fell back to bare `#$xx` while the dead
86
+ // definitions were still emitted. This is exactly why D-20 generates one
87
+ // variant per value the program actually writes.
88
+ // - Creating an enum FAILED with "Enum '<name>' already exists"
89
+ // (`app_state.rs:443-457`'s `validate_new_enum_name`) if the name was
90
+ // already taken -- there was no upsert. That is why `EnumInstallAction`
91
+ // has two values and why ANNO-13's re-runnability needed a documented
92
+ // create-then-update precedence rather than a single call. A rebuilt
93
+ // installer that cannot express "updated" has lost that requirement.
94
+ // - The disassembly search matched its `query` regex against the
95
+ // `mnemonic` and `operand` fields INDEPENDENTLY (`state/search.rs:
96
+ // 309-313`) -- they were NEVER concatenated into one searchable string.
97
+ // A combined `"^sta \$(...)"`-shaped query therefore matched neither
98
+ // field alone. The consequence that outlives it: the register and
99
+ // immediate-mode narrowing belongs CLIENT-SIDE, against this project's
100
+ // own curated register set derived from `anno-regbits.json`'s own keys,
101
+ // which is what `pairSearchRows()` still does and what D-23 requires.
102
+ // - That search's `max_results` server-side default was 50
103
+ // (`handler.rs:1074-1077`), which is where D-23's "no silent caps" rule
104
+ // came from: never accept a producer's own default ceiling, and report a
105
+ // possible truncation in words.
106
+ // - The live query view rendered an applied enum reference as
107
+ // `EnumName.VARIANT` (a dot) while the ACME export rendered
108
+ // `EnumName_VARIANT` (an underscore). VERSION-SCOPED to 0.9.20
109
+ // (RESEARCH.md Assumption A2). A rebuilt route must RE-MEASURE the
110
+ // equivalent discrepancy against its own export rather than inherit this
111
+ // one -- the obligation belongs to the rebuild, not to a numbered phase.
112
+ //
113
+ // WHAT NOT TO DO, named concretely:
114
+ // - Never write a machine-global enum. The machine-wide config-dir save
115
+ // route named in D-21 is never referenced anywhere in this file, and
116
+ // `anno-enum-gen.test.ts`'s own zero-count grep asserts that
117
+ // mechanically. That guard is DORMANT while this module has no install
118
+ // route at all and goes live again the instant ANY install route is added
119
+ // -- the condition is a route existing, not a phase arriving -- which is
120
+ // exactly when it is needed, so it stays.
121
+ // - Never call an install path with an unsanitized identifier.
122
+ // `assertLegalAcmeIdentifier()` (defined in `anno-acme-ident.ts`,
123
+ // re-exported here) runs on every variant name inside
124
+ // `sanitizeVariantMap()`. Any rebuilt installer calls
125
+ // `sanitizeVariantMap()` BEFORE it does any I/O, for the same reason the
126
+ // deleted one did: sanitization is entirely client-side, so a rejected
127
+ // name provably never reaches a child.
128
+ // - Never re-derive the register set from a second hardcoded list. It comes
129
+ // from `anno-regbits.json`'s own keys, via `loadRegBits()`, always.
130
+ import { readFileSync } from "node:fs";
131
+ import { dirname, join } from "node:path";
132
+ import { fileURLToPath } from "node:url";
133
+
134
+ import type { RegBitsField, RegBitsTable } from "./anno-regbits-gen.ts";
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";
141
+
142
+ const HERE = dirname(fileURLToPath(import.meta.url));
143
+ const REGBITS_PATH = join(HERE, "anno-regbits.json");
144
+
145
+ /** The ceiling a caller states instead of trusting a producer's own default
146
+ * (which was 50, `handler.rs:1074-1077`). D-23's "no silent caps" rule: the
147
+ * returned row count is compared against THIS value and a possible truncation
148
+ * is reported in words. A rebuilt fetch passes it explicitly for the same
149
+ * reason. */
150
+ export const DEFAULT_MAX_RESULTS = 10_000;
151
+
152
+ // MAX_ACME_IDENTIFIER_LENGTH / assertLegalAcmeIdentifier() live in
153
+ // anno-acme-ident.ts (plan 260821-a86, T-11-NAME-INJECT) -- that module is
154
+ // the ONE authoritative place for the ACME identifier policy, consumed by
155
+ // THIS file's sanitizeVariantMap() below plus anno-symbols.ts's own pre-spawn
156
+ // label-name gate. Re-exported here (imported above) so this file's existing
157
+ // consumers and tests keep their current import path.
158
+ export { MAX_ACME_IDENTIFIER_LENGTH, assertLegalAcmeIdentifier };
159
+
160
+ // ---------------------------------------------------------------------------
161
+ // The bit-name table (Task 1) -- loaded once, from the committed generated
162
+ // artifact, never re-derived from memmap.json at runtime.
163
+ // ---------------------------------------------------------------------------
164
+
165
+ let cachedTable: RegBitsTable | undefined;
166
+
167
+ function loadRegBits(): RegBitsTable {
168
+ if (cachedTable) return cachedTable;
169
+ const doc = JSON.parse(readFileSync(REGBITS_PATH, "utf8")) as Record<string, unknown>;
170
+ const { _generated, ...table } = doc;
171
+ cachedTable = table as unknown as RegBitsTable;
172
+ return cachedTable;
173
+ }
174
+
175
+ /** Test-only reset, so a test can install a synthetic table without this
176
+ * module's cache surviving across cases. Not exported for production use. */
177
+ export function __resetRegBitsCacheForTests(table?: RegBitsTable): void {
178
+ cachedTable = table;
179
+ }
180
+
181
+ export function registerKeyFor(address: number): string {
182
+ return `$${address.toString(16).toUpperCase().padStart(4, "0")}`;
183
+ }
184
+
185
+ /**
186
+ * Decodes `value` against `register`'s fields (from the loaded bit-name
187
+ * table), in ascending bit order, emitting one token per field:
188
+ * - a "numeric" field ALWAYS emits `NAME` concatenated with the decoded
189
+ * number (e.g. `YSCROLL` + `3` = `YSCROLL3`) -- total by construction,
190
+ * nothing to look up;
191
+ * - a "flag"/"enum" field emits its own `tokens[decoded]` string. This
192
+ * table's own fields (Task 1) give EVERY flag/enum field an EXPLICIT
193
+ * token for every value it can take -- including an explicit EMPTY
194
+ * STRING for a state that is silent by design (e.g. `$D011`'s ECM/RST8,
195
+ * silent when clear) -- so "no token defined" is a genuine data error,
196
+ * never an expected shape. When it happens anyway, this function
197
+ * REFUSES (throws), naming the register/field/value, rather than
198
+ * silently dropping the field: a dropped token could make two distinct
199
+ * register values decode to the identical name, which is exactly the
200
+ * property `anno-enum-gen.test.ts`'s 256-value check exists to catch.
201
+ * - an empty-string token contributes NOTHING to the joined name (it is
202
+ * filtered out before the final `_`-join) -- this is what makes the
203
+ * silent-by-design case above actually silent in the output.
204
+ *
205
+ * The measured target this function is pinned against:
206
+ * `variantNameFor(0xd011, 0x1b) === "YSCROLL3_ROW25_SCREENON_TEXT"`.
207
+ */
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[] {
213
+ const table = loadRegBits();
214
+ const entry = table[key];
215
+ if (!entry) {
216
+ throw new Error(
217
+ `${callerName}: no bit-name table entry for register ${key} -- anno-regbits.json has no fields ` +
218
+ "for this address (add an OVERRIDES entry in anno-regbits-gen.ts, or exclude it from generation).",
219
+ );
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");
283
+
284
+ const tokens: string[] = [];
285
+ for (const field of fields) {
286
+ const { token } = decodeField(key, field, value);
287
+ if (token !== "") tokens.push(token);
288
+ }
289
+ if (tokens.length === 0) {
290
+ // Every field decoded to an explicitly-silent token (e.g. all eight
291
+ // sprite-plane flags clear at once) -- the only way this can happen is a
292
+ // register whose EVERY field is a flag/enum with a silent-by-design
293
+ // state, at the one value where every field lands on that state. An
294
+ // empty string is not a legal ACME identifier, so this is not "no
295
+ // change needed", it is the single degenerate case this table's design
296
+ // creates -- named explicitly (`V<value>`) rather than left empty. Since
297
+ // a numeric field always emits a non-empty token, this fallback can only
298
+ // ever fire for AT MOST one value per register (the all-fields-silent
299
+ // one), so it can never collide with a genuine multi-token name.
300
+ return `V${value}`;
301
+ }
302
+ return tokens.join("_");
303
+ }
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
+
449
+ // ---------------------------------------------------------------------------
450
+ // The two-pass search + adjacent-pair (D-23).
451
+ // ---------------------------------------------------------------------------
452
+
453
+ export interface DisasmSearchRow {
454
+ address: string;
455
+ address_decimal: number;
456
+ label: string;
457
+ mnemonic: string;
458
+ operand: string;
459
+ comment: string;
460
+ }
461
+
462
+ /** Parses an ACME-style immediate operand string (`"#$1b"`, `"#42"`,
463
+ * `"#%00011011"`) into its numeric value. Throws on anything else, naming
464
+ * the offending operand text -- never silently returns 0 for an
465
+ * unparsable operand, which would misname a variant. */
466
+ export function parseImmediateOperand(operand: string): number {
467
+ if (!operand.startsWith("#")) {
468
+ throw new Error(`parseImmediateOperand: "${operand}" is not an immediate operand (does not start with "#")`);
469
+ }
470
+ const body = operand.slice(1);
471
+ let value: number;
472
+ if (body.startsWith("$")) {
473
+ value = Number.parseInt(body.slice(1), 16);
474
+ } else if (body.startsWith("%")) {
475
+ value = Number.parseInt(body.slice(1), 2);
476
+ } else {
477
+ value = Number.parseInt(body, 10);
478
+ }
479
+ if (!Number.isInteger(value) || Number.isNaN(value)) {
480
+ throw new Error(`parseImmediateOperand: could not parse "${operand}" as a numeric immediate value`);
481
+ }
482
+ return value;
483
+ }
484
+
485
+ /** Normalises a store's operand text (`"$d011"`) into the same `$xxxx`
486
+ * (uppercase, no padding assumptions beyond what the server itself emits)
487
+ * shape used as this module's own register-lookup key, so the two never
488
+ * silently fail to match on case alone. */
489
+ function normalizeOperandAsKey(operand: string): string | null {
490
+ if (!operand.startsWith("$")) return null;
491
+ const hex = operand.slice(1);
492
+ if (!/^[0-9a-fA-F]+$/.test(hex)) return null;
493
+ return `$${hex.toUpperCase().padStart(4, "0")}`;
494
+ }
495
+
496
+ export interface PairOccurrence {
497
+ regKey: string;
498
+ value: number;
499
+ ldaAddr: number;
500
+ }
501
+
502
+ export interface PairingResult {
503
+ occurrences: PairOccurrence[];
504
+ totalRegisterStores: number;
505
+ pairedStores: number;
506
+ unpairedStores: number;
507
+ pass1Truncated: boolean;
508
+ pass2Truncated: boolean;
509
+ }
510
+
511
+ /**
512
+ * THE D-23 ADJACENT-PAIR RULE -- pure, and the reason this module survived
513
+ * the cut (plan 29-10). It was extracted verbatim from the deleted two-pass
514
+ * fetch, which is now the CALLER's job: hand it the `lda` rows and the `sta`
515
+ * rows and it pairs each store to a register the bit-name table knows with an
516
+ * immediate load exactly 2 bytes earlier.
517
+ *
518
+ * Adjacent-only, no dataflow: `lda #imm` is always 2 bytes in immediate mode,
519
+ * so the following store begins at `ldaAddr + 2` regardless of the store's
520
+ * own addressing mode. A store with no immediate load at exactly that address
521
+ * is simply not paired -- D-23's "a miss costs nothing" posture, which is
522
+ * what keeps this rule cheap enough to be worth having at all.
523
+ *
524
+ * The register narrowing is CLIENT-SIDE, against `anno-regbits.json`'s own
525
+ * keys, never a second hardcoded list and never a producer-side query
526
+ * (see the measured search-field fact in this module's header for why that
527
+ * is not merely a preference).
528
+ *
529
+ * `maxResults` is the ceiling the caller asked its fetch for. A pass whose
530
+ * row count EQUALS that ceiling is reported as possibly truncated, per D-23's
531
+ * "no silent caps" -- pass the same value the fetch used, or the truncation
532
+ * signal is meaningless.
533
+ */
534
+ export function pairSearchRows(
535
+ ldaRows: readonly DisasmSearchRow[],
536
+ staRows: readonly DisasmSearchRow[],
537
+ maxResults: number = DEFAULT_MAX_RESULTS,
538
+ ): PairingResult {
539
+ const table = loadRegBits();
540
+ const knownRegisters = new Set(Object.keys(table));
541
+
542
+ const pass1Truncated = ldaRows.length === maxResults;
543
+ const pass2Truncated = staRows.length === maxResults;
544
+
545
+ const immByAddr = new Map<number, number>();
546
+ for (const row of ldaRows) {
547
+ if (!row.operand.startsWith("#")) continue; // not an immediate load
548
+ try {
549
+ immByAddr.set(row.address_decimal, parseImmediateOperand(row.operand));
550
+ } catch {
551
+ // An unparsable immediate operand is skipped (never paired), not fatal
552
+ // to the whole pass -- D-23's "a miss costs nothing" posture.
553
+ }
554
+ }
555
+
556
+ const knownStores = staRows.filter((row) => {
557
+ const key = normalizeOperandAsKey(row.operand);
558
+ return key !== null && knownRegisters.has(key);
559
+ });
560
+
561
+ const occurrences: PairOccurrence[] = [];
562
+ for (const store of knownStores) {
563
+ const regKey = normalizeOperandAsKey(store.operand)!;
564
+ const ldaAddr = store.address_decimal - 2;
565
+ const imm = immByAddr.get(ldaAddr);
566
+ if (imm === undefined) continue; // D-23: adjacent-only -- a miss costs nothing
567
+ occurrences.push({ regKey, value: imm, ldaAddr });
568
+ }
569
+
570
+ return {
571
+ occurrences,
572
+ totalRegisterStores: knownStores.length,
573
+ pairedStores: occurrences.length,
574
+ unpairedStores: knownStores.length - occurrences.length,
575
+ pass1Truncated,
576
+ pass2Truncated,
577
+ };
578
+ }
579
+
580
+ // ---------------------------------------------------------------------------
581
+ // The enum PLAN and its wording contract (D-20/D-21/D-23). The installation
582
+ // route that consumed these was deleted by plan 29-10; what a rebuilt one
583
+ // needs is all still here.
584
+ // ---------------------------------------------------------------------------
585
+
586
+ /** Formats a numeric value the way the retired producer's own
587
+ * `EnumDefinition::parse_variants` accepted it (`$`-prefixed lowercase hex),
588
+ * matching the measured example in this phase's own RESEARCH.md exactly.
589
+ * Kept because it is the shape a variant KEY takes, and whoever rebuilds the
590
+ * route needs to know what it was to decide whether to keep it. */
591
+ function formatVariantKey(value: number): string {
592
+ return `$${value.toString(16)}`;
593
+ }
594
+
595
+ /**
596
+ * Builds the `{ "$1b": "YSCROLL3_..." }`-shaped variants object, calling
597
+ * `assertLegalAcmeIdentifier` on every variant name FIRST.
598
+ *
599
+ * That ordering is the whole property, not an implementation detail: because
600
+ * sanitization happens entirely client-side and before any I/O, a rejected
601
+ * name provably never reaches a child process. The deleted installer proved
602
+ * exactly that with a spy binary; any rebuilt installer inherits the property
603
+ * by calling this function before it does any I/O of its own.
604
+ */
605
+ export function sanitizeVariantMap(regKey: string, variants: ReadonlyMap<number, string>): Record<string, string> {
606
+ const out: Record<string, string> = {};
607
+ for (const [value, name] of variants) {
608
+ assertLegalAcmeIdentifier(name, `variant name for ${regKey} value 0x${value.toString(16)}`);
609
+ out[formatVariantKey(value)] = name;
610
+ }
611
+ return out;
612
+ }
613
+
614
+ /**
615
+ * The two outcomes a rebuilt installer must still be able to report.
616
+ *
617
+ * KEPT ACROSS THE CUT (plan 29-10) even though nothing in this repo installs
618
+ * an enum today. Creating an enum whose name already existed FAILED outright
619
+ * on the retired producer -- there was no upsert -- so ANNO-13's
620
+ * "re-runnable" requirement was met by a documented precedence: try CREATE
621
+ * first, and only on an already-exists failure fall back to UPDATE, which
622
+ * replaces the variant map wholesale. That precedence, and this two-valued
623
+ * result, are the requirement's whole observable content. A rebuilt installer
624
+ * that can only ever report "created" has quietly dropped ANNO-13.
625
+ */
626
+ export type EnumInstallAction = "created" | "updated";
627
+
628
+ export interface EnumInstallSummary {
629
+ regKey: string;
630
+ enumName: string;
631
+ variantCount: number;
632
+ action: EnumInstallAction;
633
+ usagesApplied: number;
634
+ }
635
+
636
+ /** One register's worth of the plan: the enum name, the sanitized variant
637
+ * map, and every paired occurrence whose `lda` address a usage must be bound
638
+ * to (never the store address -- see the measured binding fact in this
639
+ * module's header). */
640
+ export interface PlannedEnum {
641
+ regKey: string;
642
+ enumName: string;
643
+ /** value -> variant name, one entry per DISTINCT value observed (D-20). */
644
+ variants: Map<number, string>;
645
+ occurrences: PairOccurrence[];
646
+ }
647
+
648
+ /**
649
+ * D-20's OWN RULE, pure and route-free: group the paired occurrences by
650
+ * register, keep ONE variant per DISTINCT value the program actually writes,
651
+ * and name each with `variantNameFor()`.
652
+ *
653
+ * Never a full 256-values-per-register table. That is not an efficiency
654
+ * choice: applying an enum emitted its WHOLE variant list into the exported
655
+ * ACME header, so a table of 256 dead definitions is 256 lines of noise in
656
+ * the output for every register touched. The measured fact is in this
657
+ * module's header; this function is where the consequence lives.
658
+ *
659
+ * Extracted from the deleted `generateEnums()` pass by plan 29-10 with its
660
+ * grouping and naming unchanged -- only the install calls that followed it
661
+ * went.
662
+ */
663
+ export function planEnumsForPairing(pairing: PairingResult): PlannedEnum[] {
664
+ // regKey -> value -> representative ldaAddr (first seen)
665
+ const byRegister = new Map<string, Map<number, number>>();
666
+ const occurrencesByRegister = new Map<string, PairOccurrence[]>();
667
+ for (const occ of pairing.occurrences) {
668
+ if (!byRegister.has(occ.regKey)) byRegister.set(occ.regKey, new Map());
669
+ if (!occurrencesByRegister.has(occ.regKey)) occurrencesByRegister.set(occ.regKey, []);
670
+ byRegister.get(occ.regKey)!.set(occ.value, occ.ldaAddr);
671
+ occurrencesByRegister.get(occ.regKey)!.push(occ);
672
+ }
673
+
674
+ const planned: PlannedEnum[] = [];
675
+ for (const [regKey, valuesToLdaAddr] of byRegister) {
676
+ const address = Number.parseInt(regKey.slice(1), 16);
677
+ const variants = new Map<number, string>();
678
+ for (const value of valuesToLdaAddr.keys()) {
679
+ variants.set(value, variantNameFor(address, value));
680
+ }
681
+ planned.push({
682
+ regKey,
683
+ enumName: regKey.slice(1), // "$D011" -> "D011"
684
+ variants,
685
+ occurrences: occurrencesByRegister.get(regKey) ?? [],
686
+ });
687
+ }
688
+ return planned;
689
+ }
690
+
691
+ export interface EnumGenerationReport {
692
+ totalRegisterStores: number;
693
+ pairedStores: number;
694
+ unpairedStores: number;
695
+ pass1Truncated: boolean;
696
+ pass2Truncated: boolean;
697
+ enums: EnumInstallSummary[];
698
+ /** Human-readable summary lines, always including the word "truncat..." if
699
+ * either pass hit its own `max_results` ceiling (D-23: "no silent caps" --
700
+ * a possible truncation is stated in words, never left to be inferred). */
701
+ summaryLines: string[];
702
+ }
703
+
704
+ /**
705
+ * D-23's WORDING CONTRACT, pure and route-free: the coverage report that
706
+ * names the totals, the pairing counts and -- in WORDS, never left to be
707
+ * inferred from a row count that happens to equal a ceiling -- any pass that
708
+ * may have been truncated.
709
+ *
710
+ * "No silent caps" is the whole point. A caller who reads
711
+ * `pairedStores: 4000` off a run whose fetch ceiling was 4000 has no way to
712
+ * know whether that is the answer or the ceiling; a line containing the word
713
+ * "TRUNCATION" is the difference between a measurement and a guess.
714
+ *
715
+ * Extracted from the deleted `generateEnums()` pass by plan 29-10 with its
716
+ * strings byte-identical, so a rebuilt pass reports in the same words rather
717
+ * than paraphrasing them.
718
+ */
719
+ export function buildEnumGenerationReport(
720
+ pairing: PairingResult,
721
+ enums: readonly EnumInstallSummary[],
722
+ maxResults: number = DEFAULT_MAX_RESULTS,
723
+ ): EnumGenerationReport {
724
+ const summaryLines: string[] = [
725
+ `total register stores seen: ${pairing.totalRegisterStores}`,
726
+ `paired (adjacent lda #imm found): ${pairing.pairedStores}`,
727
+ `unpaired (no adjacent immediate load): ${pairing.unpairedStores}`,
728
+ ];
729
+ if (pairing.pass1Truncated) {
730
+ summaryLines.push(
731
+ `TRUNCATION WARNING: pass 1 (lda search) returned exactly max_results=${maxResults} rows -- coverage may be incomplete`,
732
+ );
733
+ }
734
+ if (pairing.pass2Truncated) {
735
+ summaryLines.push(
736
+ `TRUNCATION WARNING: pass 2 (sta search) returned exactly max_results=${maxResults} rows -- coverage may be incomplete`,
737
+ );
738
+ }
739
+ for (const e of enums) {
740
+ summaryLines.push(`enum ${e.enumName}: ${e.action}, ${e.variantCount} variant(s), ${e.usagesApplied} usage(s) applied`);
741
+ }
742
+
743
+ return {
744
+ totalRegisterStores: pairing.totalRegisterStores,
745
+ pairedStores: pairing.pairedStores,
746
+ unpairedStores: pairing.unpairedStores,
747
+ pass1Truncated: pairing.pass1Truncated,
748
+ pass2Truncated: pairing.pass2Truncated,
749
+ enums: [...enums],
750
+ summaryLines,
751
+ };
752
+ }
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
+