@henols/vice-mcp 0.2.0 → 0.2.1

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.
@@ -0,0 +1,574 @@
1
+ #!/usr/bin/env node
2
+ // r2000-enum-gen.ts -- the ONE authoritative place in this repo for value ->
3
+ // variant naming, the adjacent-pair pass, identifier sanitization, enum
4
+ // installation and the coverage report (D-20/D-22/D-23, R2000-13, criterion
5
+ // 3 -- the phase's most distinctive deliverable: neither this project nor
6
+ // regenerator2000 can produce it alone).
7
+ //
8
+ // MEASURED MECHANISM FACTS (all confirmed by direct live calls against a
9
+ // real regenerator2000 0.9.20 child on this host, not merely paraphrased
10
+ // from RESEARCH.md):
11
+ // - `EnumDefinition.variants` is a flat `BTreeMap<u16, String>` -- a plain
12
+ // value-to-name map. There is NO bit-OR composition anywhere in 0.9.20.
13
+ // - `r2000_apply_enum_usage` binds to the INSTRUCTION ADDRESS holding the
14
+ // immediate operand (the `lda`, never the `sta`) -- confirmed both by
15
+ // direct call and by `handler.rs:1236-1264`'s own description text.
16
+ // - Applying an enum emits its WHOLE variant list into the exported ACME
17
+ // header; an unmatched value falls back to bare `#$xx` while the dead
18
+ // definitions are still emitted. This is exactly why D-20 generates one
19
+ // variant per value the program actually writes, never a full
20
+ // 256-values-per-register table.
21
+ // - `r2000_create_project_enum` FAILS with "Enum '<name>' already exists"
22
+ // (`app_state.rs:443-457`'s `validate_new_enum_name`) if the name is
23
+ // already taken -- there is no upsert. This module's own precedence
24
+ // (documented at `createOrUpdateEnum()` below): try create first, and
25
+ // ONLY on an "already exists" failure fall back to
26
+ // `r2000_update_project_enum`, which replaces the variant map wholesale
27
+ // (R2000-13's own "re-runnable" requirement).
28
+ // - `r2000_search_disassembly` matches its `query` regex against the
29
+ // `mnemonic` and `operand` fields INDEPENDENTLY (`state/search.rs:
30
+ // 309-313`, `text_matches(&line.mnemonic, ...)` OR
31
+ // `text_matches(&line.operand, ...)`) -- they are NEVER concatenated
32
+ // into one searchable string. This corrects RESEARCH.md's own Pattern 2
33
+ // code example, which assumed a combined `"^sta \$(...)"`-shaped query
34
+ // would match a "mnemonic + operand" string; measured live, it does not
35
+ // (a query is applied to `mnemonic` OR `operand`, so a combined pattern
36
+ // never matches either field alone). This module instead queries the
37
+ // MNEMONIC exactly (`"^lda$"` / `"^sta$"`, case-insensitive per the
38
+ // server's own `(?i)` prefix) and does the register/immediate-mode
39
+ // narrowing CLIENT-SIDE against this project's own curated register set
40
+ // -- still derived from `r2000-regbits.json`'s own keys, never a second
41
+ // hardcoded list, exactly as D-23 requires; only the MECHANISM by which
42
+ // that narrowing happens changed from "one combined regex" to "two exact
43
+ // mnemonic queries plus a client-side operand filter".
44
+ // - `r2000_search_disassembly`'s `max_results` server-side default is 50
45
+ // (`handler.rs:1074-1077`) -- always pass an explicit value on this
46
+ // surface (D-23's "no silent caps": the returned count is compared
47
+ // against the requested ceiling and a truncation signal is reported in
48
+ // words, never left to be inferred).
49
+ //
50
+ // WHAT NOT TO DO, named concretely:
51
+ // - Never assert criterion 3 against `r2000_search_disassembly`'s own
52
+ // rendered operand text. Measured discrepancy (RESEARCH.md, confirmed
53
+ // unchanged this session): the live query view renders an applied enum
54
+ // reference as `EnumName.VARIANT` (a dot), while the ACME export
55
+ // (`--export_asm`) renders `EnumName_VARIANT` (an underscore) -- exactly
56
+ // what criterion 3's own wording quotes. This finding is VERSION-SCOPED
57
+ // to regenerator2000 0.9.20 (RESEARCH.md Assumption A2) -- re-verify
58
+ // against `--export_asm` at execution time rather than trusting it as
59
+ // permanent; Task 3's acceptance test records what it observes on this
60
+ // run rather than assuming the historical finding still holds.
61
+ // - Never omit `max_results` on a `r2000_search_disassembly` call in this
62
+ // module. Every call site below passes it explicitly.
63
+ // - Never call `r2000_create_project_enum` (or `_update_`) with an
64
+ // unsanitized identifier. `assertLegalAcmeIdentifier()` (now defined in
65
+ // `r2000-acme-ident.ts`, re-exported here) runs on the enum name AND
66
+ // every variant name inside `sanitizeVariantMap()`, which is called
67
+ // BEFORE `createOrUpdateEnum()` ever reaches `runR2000Tool()` -- proven
68
+ // zero-spawn in `r2000-enum-gen.test.ts` via a spy binary.
69
+ // - Never write a machine-global enum. Every call in this module goes
70
+ // through `runR2000Tool()` (`r2000-tools.ts`, plan 11-05), which only
71
+ // knows `r2000_create_project_enum`/`r2000_update_project_enum` -- the
72
+ // machine-wide config-dir save route named in D-21 is never referenced
73
+ // anywhere in this file (asserted mechanically by
74
+ // `r2000-enum-gen.test.ts`'s own zero-count grep).
75
+ // - Never call `r2000-mcp-client.ts` directly. Every child interaction in
76
+ // this module goes through `r2000-tools.ts`'s `runR2000Tool()`, so the
77
+ // curated allow-list gate and the per-call auto-save both apply for
78
+ // free, exactly as plan 11-05's own "Next Phase Readiness" note
79
+ // instructs.
80
+ import { readFileSync } from "node:fs";
81
+ import { dirname, join } from "node:path";
82
+ import { fileURLToPath } from "node:url";
83
+
84
+ import { runR2000Tool } from "./r2000-tools.ts";
85
+ import type { RegBitsField, RegBitsTable } from "./r2000-regbits-gen.ts";
86
+ import { MAX_ACME_IDENTIFIER_LENGTH, assertLegalAcmeIdentifier } from "./r2000-acme-ident.ts";
87
+
88
+ const HERE = dirname(fileURLToPath(import.meta.url));
89
+ const REGBITS_PATH = join(HERE, "r2000-regbits.json");
90
+
91
+ /** The server-side default (`handler.rs:1074-1077`) this surface's own
92
+ * `runR2000Tool()` wrapper REQUIRES an explicit override for -- named here
93
+ * so every call site in this module states its ceiling instead of trusting
94
+ * the child's own default. */
95
+ export const DEFAULT_MAX_RESULTS = 10_000;
96
+
97
+ // MAX_ACME_IDENTIFIER_LENGTH / assertLegalAcmeIdentifier() now live in
98
+ // r2000-acme-ident.ts (plan 260821-a86, T-11-NAME-INJECT) -- that module is
99
+ // the ONE authoritative place for the ACME identifier policy, consumed by
100
+ // THIS file's createOrUpdateEnum()/sanitizeVariantMap() below plus two more
101
+ // entry routes (r2000-tools.ts's r2000_set_label_name, r2000-symbols.ts's
102
+ // importLabels()) that could not import it from here without forming a
103
+ // cycle (this file statically imports runR2000Tool FROM r2000-tools.ts).
104
+ // Re-exported here (imported above) so this file's own existing
105
+ // consumers/tests keep their current import path.
106
+ export { MAX_ACME_IDENTIFIER_LENGTH, assertLegalAcmeIdentifier };
107
+
108
+ // ---------------------------------------------------------------------------
109
+ // The bit-name table (Task 1) -- loaded once, from the committed generated
110
+ // artifact, never re-derived from memmap.json at runtime.
111
+ // ---------------------------------------------------------------------------
112
+
113
+ let cachedTable: RegBitsTable | undefined;
114
+
115
+ function loadRegBits(): RegBitsTable {
116
+ if (cachedTable) return cachedTable;
117
+ const doc = JSON.parse(readFileSync(REGBITS_PATH, "utf8")) as Record<string, unknown>;
118
+ const { _generated, ...table } = doc;
119
+ cachedTable = table as unknown as RegBitsTable;
120
+ return cachedTable;
121
+ }
122
+
123
+ /** Test-only reset, so a test can install a synthetic table without this
124
+ * module's cache surviving across cases. Not exported for production use. */
125
+ export function __resetRegBitsCacheForTests(table?: RegBitsTable): void {
126
+ cachedTable = table;
127
+ }
128
+
129
+ export function registerKeyFor(address: number): string {
130
+ return `$${address.toString(16).toUpperCase().padStart(4, "0")}`;
131
+ }
132
+
133
+ /**
134
+ * Decodes `value` against `register`'s fields (from the loaded bit-name
135
+ * table), in ascending bit order, emitting one token per field:
136
+ * - a "numeric" field ALWAYS emits `NAME` concatenated with the decoded
137
+ * number (e.g. `YSCROLL` + `3` = `YSCROLL3`) -- total by construction,
138
+ * nothing to look up;
139
+ * - a "flag"/"enum" field emits its own `tokens[decoded]` string. This
140
+ * table's own fields (Task 1) give EVERY flag/enum field an EXPLICIT
141
+ * token for every value it can take -- including an explicit EMPTY
142
+ * STRING for a state that is silent by design (e.g. `$D011`'s ECM/RST8,
143
+ * silent when clear) -- so "no token defined" is a genuine data error,
144
+ * never an expected shape. When it happens anyway, this function
145
+ * REFUSES (throws), naming the register/field/value, rather than
146
+ * silently dropping the field: a dropped token could make two distinct
147
+ * register values decode to the identical name, which is exactly the
148
+ * property `r2000-enum-gen.test.ts`'s 256-value check exists to catch.
149
+ * - an empty-string token contributes NOTHING to the joined name (it is
150
+ * filtered out before the final `_`-join) -- this is what makes the
151
+ * silent-by-design case above actually silent in the output.
152
+ *
153
+ * The measured target this function is pinned against:
154
+ * `variantNameFor(0xd011, 0x1b) === "YSCROLL3_ROW25_SCREENON_TEXT"`.
155
+ */
156
+ export function variantNameFor(register: number, value: number): string {
157
+ const table = loadRegBits();
158
+ const key = registerKeyFor(register);
159
+ const entry = table[key];
160
+ if (!entry) {
161
+ throw new Error(
162
+ `variantNameFor: no bit-name table entry for register ${key} -- r2000-regbits.json has no fields ` +
163
+ "for this address (add an OVERRIDES entry in r2000-regbits-gen.ts, or exclude it from generation).",
164
+ );
165
+ }
166
+
167
+ const tokens: string[] = [];
168
+ for (const field of entry.fields as RegBitsField[]) {
169
+ const decoded = (value & field.mask) >>> field.shift;
170
+ if (field.kind === "numeric") {
171
+ tokens.push(`${field.name}${decoded}`);
172
+ continue;
173
+ }
174
+ const token = field.tokens?.[decoded];
175
+ if (token === undefined) {
176
+ throw new Error(
177
+ `variantNameFor: register ${key} field "${field.name}" (kind ${field.kind}) has no token for decoded ` +
178
+ `value ${decoded} (full register value 0x${value.toString(16)}) -- refusing rather than silently ` +
179
+ "dropping a field, which could make two distinct register values decode to the same name.",
180
+ );
181
+ }
182
+ if (token !== "") tokens.push(token);
183
+ }
184
+ if (tokens.length === 0) {
185
+ // Every field decoded to an explicitly-silent token (e.g. all eight
186
+ // sprite-plane flags clear at once) -- the only way this can happen is a
187
+ // register whose EVERY field is a flag/enum with a silent-by-design
188
+ // state, at the one value where every field lands on that state. An
189
+ // empty string is not a legal ACME identifier, so this is not "no
190
+ // change needed", it is the single degenerate case this table's design
191
+ // creates -- named explicitly (`V<value>`) rather than left empty. Since
192
+ // a numeric field always emits a non-empty token, this fallback can only
193
+ // ever fire for AT MOST one value per register (the all-fields-silent
194
+ // one), so it can never collide with a genuine multi-token name.
195
+ return `V${value}`;
196
+ }
197
+ return tokens.join("_");
198
+ }
199
+
200
+ // ---------------------------------------------------------------------------
201
+ // The two-pass search + adjacent-pair (D-23).
202
+ // ---------------------------------------------------------------------------
203
+
204
+ export interface DisasmSearchRow {
205
+ address: string;
206
+ address_decimal: number;
207
+ label: string;
208
+ mnemonic: string;
209
+ operand: string;
210
+ comment: string;
211
+ }
212
+
213
+ /** Parses one `r2000_search_disassembly` `ToolCallResult` into its rows,
214
+ * throwing (naming `what`) on a reported `isError` or an unparsable body --
215
+ * never silently treating either as "no rows". */
216
+ async function parseSearchRows(
217
+ resultPromise: ReturnType<typeof runR2000Tool>,
218
+ what: string,
219
+ ): Promise<DisasmSearchRow[]> {
220
+ const result = await resultPromise;
221
+ const text = result.content.map((c) => c.text).join("");
222
+ if (result.isError) {
223
+ throw new Error(`${what} failed: ${text}`);
224
+ }
225
+ let rows: unknown;
226
+ try {
227
+ rows = JSON.parse(text);
228
+ } catch (err) {
229
+ throw new Error(`${what}: could not parse response as JSON (${err instanceof Error ? err.message : String(err)}): ${text}`);
230
+ }
231
+ if (!Array.isArray(rows)) {
232
+ throw new Error(`${what}: expected an array of rows, got ${typeof rows}`);
233
+ }
234
+ return rows as DisasmSearchRow[];
235
+ }
236
+
237
+ /** Parses an ACME-style immediate operand string (`"#$1b"`, `"#42"`,
238
+ * `"#%00011011"`) into its numeric value. Throws on anything else, naming
239
+ * the offending operand text -- never silently returns 0 for an
240
+ * unparsable operand, which would misname a variant. */
241
+ export function parseImmediateOperand(operand: string): number {
242
+ if (!operand.startsWith("#")) {
243
+ throw new Error(`parseImmediateOperand: "${operand}" is not an immediate operand (does not start with "#")`);
244
+ }
245
+ const body = operand.slice(1);
246
+ let value: number;
247
+ if (body.startsWith("$")) {
248
+ value = Number.parseInt(body.slice(1), 16);
249
+ } else if (body.startsWith("%")) {
250
+ value = Number.parseInt(body.slice(1), 2);
251
+ } else {
252
+ value = Number.parseInt(body, 10);
253
+ }
254
+ if (!Number.isInteger(value) || Number.isNaN(value)) {
255
+ throw new Error(`parseImmediateOperand: could not parse "${operand}" as a numeric immediate value`);
256
+ }
257
+ return value;
258
+ }
259
+
260
+ /** Normalises a store's operand text (`"$d011"`) into the same `$xxxx`
261
+ * (uppercase, no padding assumptions beyond what the server itself emits)
262
+ * shape used as this module's own register-lookup key, so the two never
263
+ * silently fail to match on case alone. */
264
+ function normalizeOperandAsKey(operand: string): string | null {
265
+ if (!operand.startsWith("$")) return null;
266
+ const hex = operand.slice(1);
267
+ if (!/^[0-9a-fA-F]+$/.test(hex)) return null;
268
+ return `$${hex.toUpperCase().padStart(4, "0")}`;
269
+ }
270
+
271
+ export interface PairOccurrence {
272
+ regKey: string;
273
+ value: number;
274
+ ldaAddr: number;
275
+ }
276
+
277
+ export interface PairingResult {
278
+ occurrences: PairOccurrence[];
279
+ totalRegisterStores: number;
280
+ pairedStores: number;
281
+ unpairedStores: number;
282
+ pass1Truncated: boolean;
283
+ pass2Truncated: boolean;
284
+ }
285
+
286
+ /**
287
+ * Runs the two-pass search (all `lda` instructions, all `sta` instructions --
288
+ * queried by EXACT mnemonic match, per this module's own measured correction
289
+ * to RESEARCH.md's combined-regex assumption, see header comment) and pairs
290
+ * each store to a register this module's bit-name table knows with an
291
+ * immediate load exactly 2 bytes earlier (D-23: adjacent-only, no
292
+ * dataflow -- `lda #imm` is always 2 bytes in immediate mode, so the
293
+ * following store begins at `ldaAddr + 2` regardless of the store's own
294
+ * addressing mode).
295
+ */
296
+ export async function pairImmediateLoadsToStores(
297
+ projectPath: string,
298
+ maxResults: number = DEFAULT_MAX_RESULTS,
299
+ ): Promise<PairingResult> {
300
+ const table = loadRegBits();
301
+ const knownRegisters = new Set(Object.keys(table));
302
+
303
+ const ldaRows = await parseSearchRows(
304
+ runR2000Tool("r2000_search_disassembly", {
305
+ project: projectPath,
306
+ query: "^lda$",
307
+ use_regex: true,
308
+ max_results: maxResults,
309
+ search_labels: false,
310
+ search_comments: false,
311
+ search_instructions: true,
312
+ }),
313
+ "r2000_search_disassembly (pass 1: lda)",
314
+ );
315
+ const pass1Truncated = ldaRows.length === maxResults;
316
+
317
+ const staRows = await parseSearchRows(
318
+ runR2000Tool("r2000_search_disassembly", {
319
+ project: projectPath,
320
+ query: "^sta$",
321
+ use_regex: true,
322
+ max_results: maxResults,
323
+ search_labels: false,
324
+ search_comments: false,
325
+ search_instructions: true,
326
+ }),
327
+ "r2000_search_disassembly (pass 2: sta)",
328
+ );
329
+ const pass2Truncated = staRows.length === maxResults;
330
+
331
+ const immByAddr = new Map<number, number>();
332
+ for (const row of ldaRows) {
333
+ if (!row.operand.startsWith("#")) continue; // not an immediate load
334
+ try {
335
+ immByAddr.set(row.address_decimal, parseImmediateOperand(row.operand));
336
+ } catch {
337
+ // An unparsable immediate operand is skipped (never paired), not fatal
338
+ // to the whole pass -- D-23's "a miss costs nothing" posture.
339
+ }
340
+ }
341
+
342
+ const knownStores = staRows.filter((row) => {
343
+ const key = normalizeOperandAsKey(row.operand);
344
+ return key !== null && knownRegisters.has(key);
345
+ });
346
+
347
+ const occurrences: PairOccurrence[] = [];
348
+ for (const store of knownStores) {
349
+ const regKey = normalizeOperandAsKey(store.operand)!;
350
+ const ldaAddr = store.address_decimal - 2;
351
+ const imm = immByAddr.get(ldaAddr);
352
+ if (imm === undefined) continue; // D-23: adjacent-only -- a miss costs nothing
353
+ occurrences.push({ regKey, value: imm, ldaAddr });
354
+ }
355
+
356
+ return {
357
+ occurrences,
358
+ totalRegisterStores: knownStores.length,
359
+ pairedStores: occurrences.length,
360
+ unpairedStores: knownStores.length - occurrences.length,
361
+ pass1Truncated,
362
+ pass2Truncated,
363
+ };
364
+ }
365
+
366
+ // ---------------------------------------------------------------------------
367
+ // Enum installation (D-20/D-21/D-33-adjacent: only through runR2000Tool()).
368
+ // ---------------------------------------------------------------------------
369
+
370
+ /** Formats a numeric value the way `r2000_create_project_enum`'s own
371
+ * `EnumDefinition::parse_variants` accepts (`$`-prefixed lowercase hex),
372
+ * matching the measured example in this phase's own RESEARCH.md exactly. */
373
+ function formatVariantKey(value: number): string {
374
+ return `$${value.toString(16)}`;
375
+ }
376
+
377
+ /**
378
+ * Builds the `{ "$1b": "YSCROLL3_..." }`-shaped variants object for
379
+ * `r2000_create_project_enum`/`_update_`, calling `assertLegalAcmeIdentifier`
380
+ * on every variant name FIRST -- this is the whole reason
381
+ * `createOrUpdateEnum()` below can prove zero child spawns for a rejected
382
+ * name: sanitization happens entirely client-side, before any I/O.
383
+ */
384
+ export function sanitizeVariantMap(regKey: string, variants: ReadonlyMap<number, string>): Record<string, string> {
385
+ const out: Record<string, string> = {};
386
+ for (const [value, name] of variants) {
387
+ assertLegalAcmeIdentifier(name, `variant name for ${regKey} value 0x${value.toString(16)}`);
388
+ out[formatVariantKey(value)] = name;
389
+ }
390
+ return out;
391
+ }
392
+
393
+ export type EnumInstallAction = "created" | "updated";
394
+
395
+ /**
396
+ * Creates (or, on an "already exists" failure, updates) the project-level
397
+ * enum named after `regKey` (e.g. `$D011` -> enum name `D011`) with
398
+ * `variants`. Precedence, decided and documented here: CREATE is tried
399
+ * first; only when regenerator2000 itself reports the name already exists
400
+ * (`validate_new_enum_name`'s own message, `app_state.rs:455`) does this
401
+ * function fall back to UPDATE, which replaces the variant map wholesale --
402
+ * this is what makes a re-run of `generateEnums()` idempotent (R2000-13's
403
+ * "re-runnable" requirement) rather than failing on every run after the
404
+ * first.
405
+ *
406
+ * `assertLegalAcmeIdentifier` runs on the enum name and (via
407
+ * `sanitizeVariantMap`) every variant name BEFORE either child call --
408
+ * proven zero-spawn in `r2000-enum-gen.test.ts` via a spy binary.
409
+ */
410
+ export async function createOrUpdateEnum(
411
+ projectPath: string,
412
+ regKey: string,
413
+ variants: ReadonlyMap<number, string>,
414
+ ): Promise<EnumInstallAction> {
415
+ const enumName = regKey.slice(1); // "$D011" -> "D011"
416
+ assertLegalAcmeIdentifier(enumName, `enum name for ${regKey}`);
417
+ const variantsObj = sanitizeVariantMap(regKey, variants);
418
+
419
+ const createResult = await runR2000Tool("r2000_create_project_enum", {
420
+ project: projectPath,
421
+ name: enumName,
422
+ variants: variantsObj,
423
+ });
424
+ if (!createResult.isError) return "created";
425
+
426
+ const createText = createResult.content.map((c) => c.text).join(" ");
427
+ if (!/already exists/i.test(createText)) {
428
+ throw new Error(`r2000_create_project_enum failed for "${enumName}": ${createText}`);
429
+ }
430
+
431
+ const updateResult = await runR2000Tool("r2000_update_project_enum", {
432
+ project: projectPath,
433
+ name: enumName,
434
+ variants: variantsObj,
435
+ });
436
+ if (updateResult.isError) {
437
+ throw new Error(
438
+ `r2000_update_project_enum failed for "${enumName}" (after create reported already-exists): ` +
439
+ `${updateResult.content.map((c) => c.text).join(" ")}`,
440
+ );
441
+ }
442
+ return "updated";
443
+ }
444
+
445
+ async function applyUsage(projectPath: string, address: number, enumName: string): Promise<void> {
446
+ const result = await runR2000Tool("r2000_apply_enum_usage", {
447
+ project: projectPath,
448
+ address,
449
+ name: enumName,
450
+ });
451
+ if (result.isError) {
452
+ throw new Error(
453
+ `r2000_apply_enum_usage failed at address ${address} for enum "${enumName}": ` +
454
+ `${result.content.map((c) => c.text).join(" ")}`,
455
+ );
456
+ }
457
+ }
458
+
459
+ // ---------------------------------------------------------------------------
460
+ // generateEnums() -- the whole pass, and its coverage report.
461
+ // ---------------------------------------------------------------------------
462
+
463
+ export interface EnumInstallSummary {
464
+ regKey: string;
465
+ enumName: string;
466
+ variantCount: number;
467
+ action: EnumInstallAction;
468
+ usagesApplied: number;
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
+ export interface GenerateEnumsOptions {
485
+ projectPath: string;
486
+ maxResults?: number;
487
+ }
488
+
489
+ /**
490
+ * The whole D-20/D-22/D-23 pass: two exact-mnemonic searches (explicit
491
+ * `max_results`, never the server's own 50-row default), adjacent-only
492
+ * pairing, one variant per DISTINCT value observed per register (D-20),
493
+ * create-or-update per register (this module's own documented precedence),
494
+ * apply-usage at every paired `lda` address (never the store address --
495
+ * measured, `handler.rs:1236-1264`), and a coverage report naming totals,
496
+ * pairing counts and any possible truncation explicitly.
497
+ *
498
+ * Persistence: every mutating call here goes through `runR2000Tool()`
499
+ * (`r2000-tools.ts`), whose own per-call auto-save (D-17) already persists
500
+ * each create/update/apply the instant its own session closes -- see that
501
+ * module's header for why a SECOND, standalone `r2000_save_project` call
502
+ * after a sequence of already-auto-saving calls is not issued here: it would
503
+ * only ever observe an unchanged hash (nothing pending) and report a
504
+ * spurious `R2000SaveNotPersistedError`, which `r2000-tools.ts`'s own header
505
+ * documents as the expected (not buggy) shape of that specific call
506
+ * sequence. Persistence itself is independently proven at the ACME EXPORT
507
+ * layer (Task 3), not re-asserted here.
508
+ */
509
+ export async function generateEnums({ projectPath, maxResults = DEFAULT_MAX_RESULTS }: GenerateEnumsOptions): Promise<EnumGenerationReport> {
510
+ const pairing = await pairImmediateLoadsToStores(projectPath, maxResults);
511
+
512
+ const byRegister = new Map<string, Map<number, number>>(); // regKey -> value -> representative ldaAddr (first seen)
513
+ const occurrencesByRegister = new Map<string, PairOccurrence[]>();
514
+ for (const occ of pairing.occurrences) {
515
+ if (!byRegister.has(occ.regKey)) byRegister.set(occ.regKey, new Map());
516
+ if (!occurrencesByRegister.has(occ.regKey)) occurrencesByRegister.set(occ.regKey, []);
517
+ byRegister.get(occ.regKey)!.set(occ.value, occ.ldaAddr);
518
+ occurrencesByRegister.get(occ.regKey)!.push(occ);
519
+ }
520
+
521
+ const enums: EnumInstallSummary[] = [];
522
+ for (const [regKey, valuesToLdaAddr] of byRegister) {
523
+ const address = Number.parseInt(regKey.slice(1), 16);
524
+ const variants = new Map<number, string>();
525
+ for (const value of valuesToLdaAddr.keys()) {
526
+ variants.set(value, variantNameFor(address, value));
527
+ }
528
+
529
+ const action = await createOrUpdateEnum(projectPath, regKey, variants);
530
+
531
+ const occurrences = occurrencesByRegister.get(regKey) ?? [];
532
+ for (const occ of occurrences) {
533
+ const enumName = regKey.slice(1);
534
+ await applyUsage(projectPath, occ.ldaAddr, enumName);
535
+ }
536
+
537
+ enums.push({
538
+ regKey,
539
+ enumName: regKey.slice(1),
540
+ variantCount: variants.size,
541
+ action,
542
+ usagesApplied: occurrences.length,
543
+ });
544
+ }
545
+
546
+ const summaryLines: string[] = [
547
+ `total register stores seen: ${pairing.totalRegisterStores}`,
548
+ `paired (adjacent lda #imm found): ${pairing.pairedStores}`,
549
+ `unpaired (no adjacent immediate load): ${pairing.unpairedStores}`,
550
+ ];
551
+ if (pairing.pass1Truncated) {
552
+ summaryLines.push(
553
+ `TRUNCATION WARNING: pass 1 (lda search) returned exactly max_results=${maxResults} rows -- coverage may be incomplete`,
554
+ );
555
+ }
556
+ if (pairing.pass2Truncated) {
557
+ summaryLines.push(
558
+ `TRUNCATION WARNING: pass 2 (sta search) returned exactly max_results=${maxResults} rows -- coverage may be incomplete`,
559
+ );
560
+ }
561
+ for (const e of enums) {
562
+ summaryLines.push(`enum ${e.enumName}: ${e.action}, ${e.variantCount} variant(s), ${e.usagesApplied} usage(s) applied`);
563
+ }
564
+
565
+ return {
566
+ totalRegisterStores: pairing.totalRegisterStores,
567
+ pairedStores: pairing.pairedStores,
568
+ unpairedStores: pairing.unpairedStores,
569
+ pass1Truncated: pairing.pass1Truncated,
570
+ pass2Truncated: pairing.pass2Truncated,
571
+ enums,
572
+ summaryLines,
573
+ };
574
+ }