@henols/vice-mcp 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/README.md +2 -1
  2. package/THIRD-PARTY-NOTICES.md +1 -1
  3. package/anno-acme-ident.ts +97 -0
  4. package/anno-cli.ts +1465 -0
  5. package/anno-confidence.ts +233 -0
  6. package/anno-coverage.ts +2465 -0
  7. package/anno-d64.ts +310 -0
  8. package/anno-derive.ts +590 -0
  9. package/anno-details.ts +169 -0
  10. package/anno-enum-gen.ts +533 -0
  11. package/anno-export-asm.ts +1310 -0
  12. package/anno-index.ts +150 -0
  13. package/anno-memmap-render.ts +672 -0
  14. package/anno-regbits-gen.ts +421 -0
  15. package/anno-regbits.json +1370 -0
  16. package/anno-register.ts +240 -0
  17. package/anno-store.ts +3486 -0
  18. package/anno-symbols.ts +266 -0
  19. package/anno-tools.ts +2111 -0
  20. package/anno-types.ts +1636 -0
  21. package/block-class.ts +201 -0
  22. package/build.ts +1 -1
  23. package/capability-registry.ts +3 -1
  24. package/disasm-decoder.ts +14 -14
  25. package/disasm-opcodes.ts +4 -4
  26. package/disasm-renderer.ts +2 -2
  27. package/hostpath.ts +1 -1
  28. package/install-resources.ts +1 -1
  29. package/package.json +23 -3
  30. package/prg-image.ts +119 -0
  31. package/repo-root.ts +20 -5
  32. package/resources/broker-launch.mjs +8 -4
  33. package/resources/vice-launcher.sh +3 -3
  34. package/stock-address.ts +5 -5
  35. package/stock-cia.ts +2 -2
  36. package/stock-condition.ts +7 -7
  37. package/stock-connect.ts +1 -1
  38. package/stock-dispatch.ts +35 -5
  39. package/stock-execution.ts +5 -3
  40. package/stock-input.ts +9 -9
  41. package/stock-machine.ts +17 -6
  42. package/stock-protocol.ts +16 -11
  43. package/stock-registers.ts +54 -29
  44. package/stock-sprites.ts +3 -3
  45. package/stock-symbols.ts +33 -9
  46. package/stock-timing.ts +1 -1
  47. package/stock-vicii.ts +1 -1
  48. package/version.ts +1 -1
  49. package/vice-proxy.ts +168 -0
@@ -0,0 +1,533 @@
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 and the coverage report's wording contract
5
+ // (D-20/D-22/D-23, ANNO-13).
6
+ //
7
+ // WHAT LEFT, WHAT STAYED, AND WHERE THE ROUTE RETURNS (plan 29-10, D-01,
8
+ // 2026-08-30). Read this paragraph before looking for a function that is not
9
+ // here.
10
+ //
11
+ // WHAT LEFT: the ROUTE, and only the route. Four things went, because all
12
+ // four spoke to the retired external analyser's own tool surface and every
13
+ // module they spoke through was deleted in the same commit:
14
+ // - the two disassembly searches that FETCHED the `lda` and `sta` rows;
15
+ // - `parseSearchRows()`, which unwrapped that surface's own result shape;
16
+ // - `createOrUpdateEnum()` and `applyUsage()`, which INSTALLED an enum
17
+ // and bound it to an address through that surface;
18
+ // - `generateEnums()`, the pass that strung those together.
19
+ //
20
+ // WHAT STAYED: the HEURISTICS, all of them, as live code rather than as
21
+ // prose about code that used to exist. This is the part the classification
22
+ // registry exists to protect, so it was extracted from the route rather
23
+ // than deleted with it:
24
+ // - `variantNameFor()` and the bit-name table it decodes against -- the
25
+ // whole D-22 naming vocabulary, untouched, still pinned by its
26
+ // injectivity tests across all 256 values.
27
+ // - `pairSearchRows()` -- the D-23 adjacent-pair rule (a store pairs with
28
+ // an immediate load exactly 2 bytes earlier, adjacent-only, no
29
+ // dataflow, a miss costs nothing), lifted out of the deleted fetch
30
+ // loop verbatim and now a PURE function of two already-fetched row
31
+ // arrays. Whoever rebuilds the fetch supplies the rows; the rule does
32
+ // not change.
33
+ // - `planEnumsForPairing()` -- D-20's own rule: one variant per DISTINCT
34
+ // value the program actually writes, never a full
35
+ // 256-values-per-register table, with the first-seen `lda` address
36
+ // kept as each value's representative binding site.
37
+ // - `buildEnumGenerationReport()` -- D-23's "no silent caps" wording
38
+ // contract, which states a possible truncation in WORDS rather than
39
+ // leaving it to be inferred from a row count.
40
+ // - `sanitizeVariantMap()` and the identifier gate it runs, unchanged.
41
+ //
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.
53
+ //
54
+ // MEASURED MECHANISM FACTS, PAST TENSE -- kept because they are WHY the
55
+ // heuristics have the shape they have, not because anything still calls the
56
+ // producer they were measured against (a real pinned-version 0.9.20 child on
57
+ // this host, by direct live call, never paraphrased from a document):
58
+ // - An enum definition's variants were a flat `BTreeMap<u16, String>` -- a
59
+ // plain value-to-name map, with NO bit-OR composition anywhere. That is
60
+ // why `variantNameFor()` must produce one TOTAL name per value rather
61
+ // than a composable set of flags.
62
+ // - Applying an enum usage bound it to the INSTRUCTION ADDRESS holding the
63
+ // immediate operand (the `lda`, never the `sta`) -- confirmed both by
64
+ // direct call and by `handler.rs:1236-1264`'s own description text. That
65
+ // is why `PairOccurrence` carries `ldaAddr` and not the store address.
66
+ // - Applying an enum emitted its WHOLE variant list into the exported ACME
67
+ // header; an unmatched value fell back to bare `#$xx` while the dead
68
+ // definitions were still emitted. This is exactly why D-20 generates one
69
+ // variant per value the program actually writes.
70
+ // - Creating an enum FAILED with "Enum '<name>' already exists"
71
+ // (`app_state.rs:443-457`'s `validate_new_enum_name`) if the name was
72
+ // already taken -- there was no upsert. That is why `EnumInstallAction`
73
+ // has two values and why ANNO-13's re-runnability needed a documented
74
+ // create-then-update precedence rather than a single call. A rebuilt
75
+ // installer that cannot express "updated" has lost that requirement.
76
+ // - The disassembly search matched its `query` regex against the
77
+ // `mnemonic` and `operand` fields INDEPENDENTLY (`state/search.rs:
78
+ // 309-313`) -- they were NEVER concatenated into one searchable string.
79
+ // A combined `"^sta \$(...)"`-shaped query therefore matched neither
80
+ // field alone. The consequence that outlives it: the register and
81
+ // immediate-mode narrowing belongs CLIENT-SIDE, against this project's
82
+ // own curated register set derived from `anno-regbits.json`'s own keys,
83
+ // which is what `pairSearchRows()` still does and what D-23 requires.
84
+ // - That search's `max_results` server-side default was 50
85
+ // (`handler.rs:1074-1077`), which is where D-23's "no silent caps" rule
86
+ // came from: never accept a producer's own default ceiling, and report a
87
+ // possible truncation in words.
88
+ // - The live query view rendered an applied enum reference as
89
+ // `EnumName.VARIANT` (a dot) while the ACME export rendered
90
+ // `EnumName_VARIANT` (an underscore). VERSION-SCOPED to 0.9.20
91
+ // (RESEARCH.md Assumption A2). A rebuilt route must RE-MEASURE the
92
+ // equivalent discrepancy against its own export rather than inherit this
93
+ // one -- the obligation belongs to the rebuild, not to a numbered phase.
94
+ //
95
+ // WHAT NOT TO DO, named concretely:
96
+ // - Never write a machine-global enum. The machine-wide config-dir save
97
+ // route named in D-21 is never referenced anywhere in this file, and
98
+ // `anno-enum-gen.test.ts`'s own zero-count grep asserts that
99
+ // mechanically. That guard is DORMANT while this module has no install
100
+ // route at all and goes live again the instant ANY install route is added
101
+ // -- the condition is a route existing, not a phase arriving -- which is
102
+ // exactly when it is needed, so it stays.
103
+ // - Never call an install path with an unsanitized identifier.
104
+ // `assertLegalAcmeIdentifier()` (defined in `anno-acme-ident.ts`,
105
+ // re-exported here) runs on every variant name inside
106
+ // `sanitizeVariantMap()`. Any rebuilt installer calls
107
+ // `sanitizeVariantMap()` BEFORE it does any I/O, for the same reason the
108
+ // deleted one did: sanitization is entirely client-side, so a rejected
109
+ // name provably never reaches a child.
110
+ // - Never re-derive the register set from a second hardcoded list. It comes
111
+ // from `anno-regbits.json`'s own keys, via `loadRegBits()`, always.
112
+ import { readFileSync } from "node:fs";
113
+ import { dirname, join } from "node:path";
114
+ import { fileURLToPath } from "node:url";
115
+
116
+ import type { RegBitsField, RegBitsTable } from "./anno-regbits-gen.ts";
117
+ import { MAX_ACME_IDENTIFIER_LENGTH, assertLegalAcmeIdentifier } from "./anno-acme-ident.ts";
118
+
119
+ const HERE = dirname(fileURLToPath(import.meta.url));
120
+ const REGBITS_PATH = join(HERE, "anno-regbits.json");
121
+
122
+ /** The ceiling a caller states instead of trusting a producer's own default
123
+ * (which was 50, `handler.rs:1074-1077`). D-23's "no silent caps" rule: the
124
+ * returned row count is compared against THIS value and a possible truncation
125
+ * is reported in words. A rebuilt fetch passes it explicitly for the same
126
+ * reason. */
127
+ export const DEFAULT_MAX_RESULTS = 10_000;
128
+
129
+ // MAX_ACME_IDENTIFIER_LENGTH / assertLegalAcmeIdentifier() live in
130
+ // anno-acme-ident.ts (plan 260821-a86, T-11-NAME-INJECT) -- that module is
131
+ // the ONE authoritative place for the ACME identifier policy, consumed by
132
+ // THIS file's sanitizeVariantMap() below plus anno-symbols.ts's own pre-spawn
133
+ // label-name gate. Re-exported here (imported above) so this file's existing
134
+ // consumers and tests keep their current import path.
135
+ export { MAX_ACME_IDENTIFIER_LENGTH, assertLegalAcmeIdentifier };
136
+
137
+ // ---------------------------------------------------------------------------
138
+ // The bit-name table (Task 1) -- loaded once, from the committed generated
139
+ // artifact, never re-derived from memmap.json at runtime.
140
+ // ---------------------------------------------------------------------------
141
+
142
+ let cachedTable: RegBitsTable | undefined;
143
+
144
+ function loadRegBits(): RegBitsTable {
145
+ if (cachedTable) return cachedTable;
146
+ const doc = JSON.parse(readFileSync(REGBITS_PATH, "utf8")) as Record<string, unknown>;
147
+ const { _generated, ...table } = doc;
148
+ cachedTable = table as unknown as RegBitsTable;
149
+ return cachedTable;
150
+ }
151
+
152
+ /** Test-only reset, so a test can install a synthetic table without this
153
+ * module's cache surviving across cases. Not exported for production use. */
154
+ export function __resetRegBitsCacheForTests(table?: RegBitsTable): void {
155
+ cachedTable = table;
156
+ }
157
+
158
+ export function registerKeyFor(address: number): string {
159
+ return `$${address.toString(16).toUpperCase().padStart(4, "0")}`;
160
+ }
161
+
162
+ /**
163
+ * Decodes `value` against `register`'s fields (from the loaded bit-name
164
+ * table), in ascending bit order, emitting one token per field:
165
+ * - a "numeric" field ALWAYS emits `NAME` concatenated with the decoded
166
+ * number (e.g. `YSCROLL` + `3` = `YSCROLL3`) -- total by construction,
167
+ * nothing to look up;
168
+ * - a "flag"/"enum" field emits its own `tokens[decoded]` string. This
169
+ * table's own fields (Task 1) give EVERY flag/enum field an EXPLICIT
170
+ * token for every value it can take -- including an explicit EMPTY
171
+ * STRING for a state that is silent by design (e.g. `$D011`'s ECM/RST8,
172
+ * silent when clear) -- so "no token defined" is a genuine data error,
173
+ * never an expected shape. When it happens anyway, this function
174
+ * REFUSES (throws), naming the register/field/value, rather than
175
+ * silently dropping the field: a dropped token could make two distinct
176
+ * register values decode to the identical name, which is exactly the
177
+ * property `anno-enum-gen.test.ts`'s 256-value check exists to catch.
178
+ * - an empty-string token contributes NOTHING to the joined name (it is
179
+ * filtered out before the final `_`-join) -- this is what makes the
180
+ * silent-by-design case above actually silent in the output.
181
+ *
182
+ * The measured target this function is pinned against:
183
+ * `variantNameFor(0xd011, 0x1b) === "YSCROLL3_ROW25_SCREENON_TEXT"`.
184
+ */
185
+ export function variantNameFor(register: number, value: number): string {
186
+ const table = loadRegBits();
187
+ const key = registerKeyFor(register);
188
+ const entry = table[key];
189
+ if (!entry) {
190
+ throw new Error(
191
+ `variantNameFor: no bit-name table entry for register ${key} -- anno-regbits.json has no fields ` +
192
+ "for this address (add an OVERRIDES entry in anno-regbits-gen.ts, or exclude it from generation).",
193
+ );
194
+ }
195
+
196
+ 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
+ }
211
+ if (token !== "") tokens.push(token);
212
+ }
213
+ if (tokens.length === 0) {
214
+ // Every field decoded to an explicitly-silent token (e.g. all eight
215
+ // sprite-plane flags clear at once) -- the only way this can happen is a
216
+ // register whose EVERY field is a flag/enum with a silent-by-design
217
+ // state, at the one value where every field lands on that state. An
218
+ // empty string is not a legal ACME identifier, so this is not "no
219
+ // change needed", it is the single degenerate case this table's design
220
+ // creates -- named explicitly (`V<value>`) rather than left empty. Since
221
+ // a numeric field always emits a non-empty token, this fallback can only
222
+ // ever fire for AT MOST one value per register (the all-fields-silent
223
+ // one), so it can never collide with a genuine multi-token name.
224
+ return `V${value}`;
225
+ }
226
+ return tokens.join("_");
227
+ }
228
+
229
+ // ---------------------------------------------------------------------------
230
+ // The two-pass search + adjacent-pair (D-23).
231
+ // ---------------------------------------------------------------------------
232
+
233
+ export interface DisasmSearchRow {
234
+ address: string;
235
+ address_decimal: number;
236
+ label: string;
237
+ mnemonic: string;
238
+ operand: string;
239
+ comment: string;
240
+ }
241
+
242
+ /** Parses an ACME-style immediate operand string (`"#$1b"`, `"#42"`,
243
+ * `"#%00011011"`) into its numeric value. Throws on anything else, naming
244
+ * the offending operand text -- never silently returns 0 for an
245
+ * unparsable operand, which would misname a variant. */
246
+ export function parseImmediateOperand(operand: string): number {
247
+ if (!operand.startsWith("#")) {
248
+ throw new Error(`parseImmediateOperand: "${operand}" is not an immediate operand (does not start with "#")`);
249
+ }
250
+ const body = operand.slice(1);
251
+ let value: number;
252
+ if (body.startsWith("$")) {
253
+ value = Number.parseInt(body.slice(1), 16);
254
+ } else if (body.startsWith("%")) {
255
+ value = Number.parseInt(body.slice(1), 2);
256
+ } else {
257
+ value = Number.parseInt(body, 10);
258
+ }
259
+ if (!Number.isInteger(value) || Number.isNaN(value)) {
260
+ throw new Error(`parseImmediateOperand: could not parse "${operand}" as a numeric immediate value`);
261
+ }
262
+ return value;
263
+ }
264
+
265
+ /** Normalises a store's operand text (`"$d011"`) into the same `$xxxx`
266
+ * (uppercase, no padding assumptions beyond what the server itself emits)
267
+ * shape used as this module's own register-lookup key, so the two never
268
+ * silently fail to match on case alone. */
269
+ function normalizeOperandAsKey(operand: string): string | null {
270
+ if (!operand.startsWith("$")) return null;
271
+ const hex = operand.slice(1);
272
+ if (!/^[0-9a-fA-F]+$/.test(hex)) return null;
273
+ return `$${hex.toUpperCase().padStart(4, "0")}`;
274
+ }
275
+
276
+ export interface PairOccurrence {
277
+ regKey: string;
278
+ value: number;
279
+ ldaAddr: number;
280
+ }
281
+
282
+ export interface PairingResult {
283
+ occurrences: PairOccurrence[];
284
+ totalRegisterStores: number;
285
+ pairedStores: number;
286
+ unpairedStores: number;
287
+ pass1Truncated: boolean;
288
+ pass2Truncated: boolean;
289
+ }
290
+
291
+ /**
292
+ * THE D-23 ADJACENT-PAIR RULE -- pure, and the reason this module survived
293
+ * the cut (plan 29-10). It was extracted verbatim from the deleted two-pass
294
+ * fetch, which is now the CALLER's job: hand it the `lda` rows and the `sta`
295
+ * rows and it pairs each store to a register the bit-name table knows with an
296
+ * immediate load exactly 2 bytes earlier.
297
+ *
298
+ * Adjacent-only, no dataflow: `lda #imm` is always 2 bytes in immediate mode,
299
+ * so the following store begins at `ldaAddr + 2` regardless of the store's
300
+ * own addressing mode. A store with no immediate load at exactly that address
301
+ * is simply not paired -- D-23's "a miss costs nothing" posture, which is
302
+ * what keeps this rule cheap enough to be worth having at all.
303
+ *
304
+ * The register narrowing is CLIENT-SIDE, against `anno-regbits.json`'s own
305
+ * keys, never a second hardcoded list and never a producer-side query
306
+ * (see the measured search-field fact in this module's header for why that
307
+ * is not merely a preference).
308
+ *
309
+ * `maxResults` is the ceiling the caller asked its fetch for. A pass whose
310
+ * row count EQUALS that ceiling is reported as possibly truncated, per D-23's
311
+ * "no silent caps" -- pass the same value the fetch used, or the truncation
312
+ * signal is meaningless.
313
+ */
314
+ export function pairSearchRows(
315
+ ldaRows: readonly DisasmSearchRow[],
316
+ staRows: readonly DisasmSearchRow[],
317
+ maxResults: number = DEFAULT_MAX_RESULTS,
318
+ ): PairingResult {
319
+ const table = loadRegBits();
320
+ const knownRegisters = new Set(Object.keys(table));
321
+
322
+ const pass1Truncated = ldaRows.length === maxResults;
323
+ const pass2Truncated = staRows.length === maxResults;
324
+
325
+ const immByAddr = new Map<number, number>();
326
+ for (const row of ldaRows) {
327
+ if (!row.operand.startsWith("#")) continue; // not an immediate load
328
+ try {
329
+ immByAddr.set(row.address_decimal, parseImmediateOperand(row.operand));
330
+ } catch {
331
+ // An unparsable immediate operand is skipped (never paired), not fatal
332
+ // to the whole pass -- D-23's "a miss costs nothing" posture.
333
+ }
334
+ }
335
+
336
+ const knownStores = staRows.filter((row) => {
337
+ const key = normalizeOperandAsKey(row.operand);
338
+ return key !== null && knownRegisters.has(key);
339
+ });
340
+
341
+ const occurrences: PairOccurrence[] = [];
342
+ for (const store of knownStores) {
343
+ const regKey = normalizeOperandAsKey(store.operand)!;
344
+ const ldaAddr = store.address_decimal - 2;
345
+ const imm = immByAddr.get(ldaAddr);
346
+ if (imm === undefined) continue; // D-23: adjacent-only -- a miss costs nothing
347
+ occurrences.push({ regKey, value: imm, ldaAddr });
348
+ }
349
+
350
+ return {
351
+ occurrences,
352
+ totalRegisterStores: knownStores.length,
353
+ pairedStores: occurrences.length,
354
+ unpairedStores: knownStores.length - occurrences.length,
355
+ pass1Truncated,
356
+ pass2Truncated,
357
+ };
358
+ }
359
+
360
+ // ---------------------------------------------------------------------------
361
+ // The enum PLAN and its wording contract (D-20/D-21/D-23). The installation
362
+ // route that consumed these was deleted by plan 29-10; what a rebuilt one
363
+ // needs is all still here.
364
+ // ---------------------------------------------------------------------------
365
+
366
+ /** Formats a numeric value the way the retired producer's own
367
+ * `EnumDefinition::parse_variants` accepted it (`$`-prefixed lowercase hex),
368
+ * matching the measured example in this phase's own RESEARCH.md exactly.
369
+ * Kept because it is the shape a variant KEY takes, and whoever rebuilds the
370
+ * route needs to know what it was to decide whether to keep it. */
371
+ function formatVariantKey(value: number): string {
372
+ return `$${value.toString(16)}`;
373
+ }
374
+
375
+ /**
376
+ * Builds the `{ "$1b": "YSCROLL3_..." }`-shaped variants object, calling
377
+ * `assertLegalAcmeIdentifier` on every variant name FIRST.
378
+ *
379
+ * That ordering is the whole property, not an implementation detail: because
380
+ * sanitization happens entirely client-side and before any I/O, a rejected
381
+ * name provably never reaches a child process. The deleted installer proved
382
+ * exactly that with a spy binary; any rebuilt installer inherits the property
383
+ * by calling this function before it does any I/O of its own.
384
+ */
385
+ export function sanitizeVariantMap(regKey: string, variants: ReadonlyMap<number, string>): Record<string, string> {
386
+ const out: Record<string, string> = {};
387
+ for (const [value, name] of variants) {
388
+ assertLegalAcmeIdentifier(name, `variant name for ${regKey} value 0x${value.toString(16)}`);
389
+ out[formatVariantKey(value)] = name;
390
+ }
391
+ return out;
392
+ }
393
+
394
+ /**
395
+ * The two outcomes a rebuilt installer must still be able to report.
396
+ *
397
+ * KEPT ACROSS THE CUT (plan 29-10) even though nothing in this repo installs
398
+ * an enum today. Creating an enum whose name already existed FAILED outright
399
+ * on the retired producer -- there was no upsert -- so ANNO-13's
400
+ * "re-runnable" requirement was met by a documented precedence: try CREATE
401
+ * first, and only on an already-exists failure fall back to UPDATE, which
402
+ * replaces the variant map wholesale. That precedence, and this two-valued
403
+ * result, are the requirement's whole observable content. A rebuilt installer
404
+ * that can only ever report "created" has quietly dropped ANNO-13.
405
+ */
406
+ export type EnumInstallAction = "created" | "updated";
407
+
408
+ export interface EnumInstallSummary {
409
+ regKey: string;
410
+ enumName: string;
411
+ variantCount: number;
412
+ action: EnumInstallAction;
413
+ usagesApplied: number;
414
+ }
415
+
416
+ /** One register's worth of the plan: the enum name, the sanitized variant
417
+ * map, and every paired occurrence whose `lda` address a usage must be bound
418
+ * to (never the store address -- see the measured binding fact in this
419
+ * module's header). */
420
+ export interface PlannedEnum {
421
+ regKey: string;
422
+ enumName: string;
423
+ /** value -> variant name, one entry per DISTINCT value observed (D-20). */
424
+ variants: Map<number, string>;
425
+ occurrences: PairOccurrence[];
426
+ }
427
+
428
+ /**
429
+ * D-20's OWN RULE, pure and route-free: group the paired occurrences by
430
+ * register, keep ONE variant per DISTINCT value the program actually writes,
431
+ * and name each with `variantNameFor()`.
432
+ *
433
+ * Never a full 256-values-per-register table. That is not an efficiency
434
+ * choice: applying an enum emitted its WHOLE variant list into the exported
435
+ * ACME header, so a table of 256 dead definitions is 256 lines of noise in
436
+ * the output for every register touched. The measured fact is in this
437
+ * module's header; this function is where the consequence lives.
438
+ *
439
+ * Extracted from the deleted `generateEnums()` pass by plan 29-10 with its
440
+ * grouping and naming unchanged -- only the install calls that followed it
441
+ * went.
442
+ */
443
+ export function planEnumsForPairing(pairing: PairingResult): PlannedEnum[] {
444
+ // regKey -> value -> representative ldaAddr (first seen)
445
+ const byRegister = new Map<string, Map<number, number>>();
446
+ const occurrencesByRegister = new Map<string, PairOccurrence[]>();
447
+ for (const occ of pairing.occurrences) {
448
+ if (!byRegister.has(occ.regKey)) byRegister.set(occ.regKey, new Map());
449
+ if (!occurrencesByRegister.has(occ.regKey)) occurrencesByRegister.set(occ.regKey, []);
450
+ byRegister.get(occ.regKey)!.set(occ.value, occ.ldaAddr);
451
+ occurrencesByRegister.get(occ.regKey)!.push(occ);
452
+ }
453
+
454
+ const planned: PlannedEnum[] = [];
455
+ for (const [regKey, valuesToLdaAddr] of byRegister) {
456
+ const address = Number.parseInt(regKey.slice(1), 16);
457
+ const variants = new Map<number, string>();
458
+ for (const value of valuesToLdaAddr.keys()) {
459
+ variants.set(value, variantNameFor(address, value));
460
+ }
461
+ planned.push({
462
+ regKey,
463
+ enumName: regKey.slice(1), // "$D011" -> "D011"
464
+ variants,
465
+ occurrences: occurrencesByRegister.get(regKey) ?? [],
466
+ });
467
+ }
468
+ return planned;
469
+ }
470
+
471
+ export interface EnumGenerationReport {
472
+ totalRegisterStores: number;
473
+ pairedStores: number;
474
+ unpairedStores: number;
475
+ pass1Truncated: boolean;
476
+ pass2Truncated: boolean;
477
+ enums: EnumInstallSummary[];
478
+ /** Human-readable summary lines, always including the word "truncat..." if
479
+ * either pass hit its own `max_results` ceiling (D-23: "no silent caps" --
480
+ * a possible truncation is stated in words, never left to be inferred). */
481
+ summaryLines: string[];
482
+ }
483
+
484
+ /**
485
+ * D-23's WORDING CONTRACT, pure and route-free: the coverage report that
486
+ * names the totals, the pairing counts and -- in WORDS, never left to be
487
+ * inferred from a row count that happens to equal a ceiling -- any pass that
488
+ * may have been truncated.
489
+ *
490
+ * "No silent caps" is the whole point. A caller who reads
491
+ * `pairedStores: 4000` off a run whose fetch ceiling was 4000 has no way to
492
+ * know whether that is the answer or the ceiling; a line containing the word
493
+ * "TRUNCATION" is the difference between a measurement and a guess.
494
+ *
495
+ * Extracted from the deleted `generateEnums()` pass by plan 29-10 with its
496
+ * strings byte-identical, so a rebuilt pass reports in the same words rather
497
+ * than paraphrasing them.
498
+ */
499
+ export function buildEnumGenerationReport(
500
+ pairing: PairingResult,
501
+ enums: readonly EnumInstallSummary[],
502
+ maxResults: number = DEFAULT_MAX_RESULTS,
503
+ ): EnumGenerationReport {
504
+ const summaryLines: string[] = [
505
+ `total register stores seen: ${pairing.totalRegisterStores}`,
506
+ `paired (adjacent lda #imm found): ${pairing.pairedStores}`,
507
+ `unpaired (no adjacent immediate load): ${pairing.unpairedStores}`,
508
+ ];
509
+ if (pairing.pass1Truncated) {
510
+ summaryLines.push(
511
+ `TRUNCATION WARNING: pass 1 (lda search) returned exactly max_results=${maxResults} rows -- coverage may be incomplete`,
512
+ );
513
+ }
514
+ if (pairing.pass2Truncated) {
515
+ summaryLines.push(
516
+ `TRUNCATION WARNING: pass 2 (sta search) returned exactly max_results=${maxResults} rows -- coverage may be incomplete`,
517
+ );
518
+ }
519
+ for (const e of enums) {
520
+ summaryLines.push(`enum ${e.enumName}: ${e.action}, ${e.variantCount} variant(s), ${e.usagesApplied} usage(s) applied`);
521
+ }
522
+
523
+ return {
524
+ totalRegisterStores: pairing.totalRegisterStores,
525
+ pairedStores: pairing.pairedStores,
526
+ unpairedStores: pairing.unpairedStores,
527
+ pass1Truncated: pairing.pass1Truncated,
528
+ pass2Truncated: pairing.pass2Truncated,
529
+ enums: [...enums],
530
+ summaryLines,
531
+ };
532
+ }
533
+