@henols/vice-mcp 0.2.1 → 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.
- package/README.md +2 -1
- package/THIRD-PARTY-NOTICES.md +1 -24
- package/{r2000-acme-ident.ts → anno-acme-ident.ts} +13 -13
- package/anno-cli.ts +1465 -0
- package/{r2000-confidence.ts → anno-confidence.ts} +22 -22
- package/anno-coverage.ts +2465 -0
- package/{r2000-d64.ts → anno-d64.ts} +5 -5
- package/anno-derive.ts +590 -0
- package/anno-details.ts +169 -0
- package/anno-enum-gen.ts +533 -0
- package/anno-export-asm.ts +1310 -0
- package/anno-index.ts +150 -0
- package/{r2000-memmap-render.ts → anno-memmap-render.ts} +236 -95
- package/{r2000-regbits-gen.ts → anno-regbits-gen.ts} +20 -15
- package/{r2000-regbits.json → anno-regbits.json} +2 -2
- package/anno-register.ts +240 -0
- package/anno-store.ts +3486 -0
- package/anno-symbols.ts +266 -0
- package/anno-tools.ts +2111 -0
- package/anno-types.ts +1636 -0
- package/block-class.ts +201 -0
- package/build.ts +1 -1
- package/capability-registry.ts +3 -1
- package/disasm-decoder.ts +14 -14
- package/disasm-opcodes.ts +4 -4
- package/disasm-renderer.ts +2 -2
- package/hostpath.ts +1 -1
- package/install-resources.ts +1 -1
- package/package.json +23 -17
- package/prg-image.ts +119 -0
- package/repo-root.ts +20 -5
- package/resources/broker-launch.mjs +8 -4
- package/resources/vice-launcher.sh +3 -3
- package/stock-address.ts +5 -5
- package/stock-cia.ts +2 -2
- package/stock-condition.ts +7 -7
- package/stock-connect.ts +1 -1
- package/stock-dispatch.ts +35 -5
- package/stock-execution.ts +5 -3
- package/stock-input.ts +9 -9
- package/stock-machine.ts +17 -6
- package/stock-protocol.ts +16 -11
- package/stock-registers.ts +54 -29
- package/stock-sprites.ts +3 -3
- package/stock-symbols.ts +9 -9
- package/stock-timing.ts +1 -1
- package/stock-vicii.ts +1 -1
- package/version.ts +1 -1
- package/vice-proxy.ts +68 -46
- package/r2000-cli.ts +0 -1103
- package/r2000-enum-gen.ts +0 -574
- package/r2000-launch.ts +0 -357
- package/r2000-mcp-client.ts +0 -596
- package/r2000-project.ts +0 -190
- package/r2000-symbols.ts +0 -388
- package/r2000-tools.ts +0 -914
- package/r2000-verify.ts +0 -184
package/anno-enum-gen.ts
ADDED
|
@@ -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
|
+
|