@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.
- package/README.md +4 -3
- package/THIRD-PARTY-NOTICES.md +423 -25
- package/{r2000-acme-ident.ts → anno-acme-ident.ts} +13 -13
- package/anno-bank.ts +171 -0
- package/anno-cli.ts +3040 -0
- package/{r2000-confidence.ts → anno-confidence.ts} +22 -22
- package/anno-coverage.ts +2465 -0
- package/anno-derive.ts +590 -0
- package/anno-details.ts +169 -0
- package/anno-enum-gen.ts +919 -0
- package/anno-export-asm.ts +2396 -0
- package/anno-graphics.ts +338 -0
- package/anno-hazard-report.ts +1367 -0
- package/anno-import.ts +495 -0
- package/anno-index.ts +150 -0
- package/anno-join.ts +480 -0
- package/{r2000-memmap-render.ts → anno-memmap-render.ts} +236 -95
- package/anno-provenance-ledger.ts +472 -0
- package/{r2000-regbits-gen.ts → anno-regbits-gen.ts} +20 -15
- package/{r2000-regbits.json → anno-regbits.json} +2 -2
- package/anno-register.ts +399 -0
- package/anno-store-export.ts +661 -0
- package/anno-store.ts +4002 -0
- package/anno-symbols.ts +266 -0
- package/anno-tools.ts +3264 -0
- package/anno-types.ts +1909 -0
- package/backend-detect.mts +124 -312
- package/block-class.ts +201 -0
- package/build.ts +4 -2
- package/capture-predicate.ts +597 -0
- package/channel-lock.ts +349 -0
- package/disasm-decoder.ts +14 -14
- package/disasm-opcodes.ts +4 -4
- package/disasm-renderer.ts +2 -2
- package/evid-ingest.ts +217 -0
- package/evid-reconcile.ts +316 -0
- package/host-tool-client.ts +430 -0
- package/hostpath.ts +1 -1
- package/incident-record.ts +23 -12
- package/install-resources.ts +30 -14
- package/memmap-lookup.ts +285 -0
- package/package.json +48 -23
- package/prg-image.ts +118 -0
- package/repo-root.ts +107 -8
- package/resources/backend-detect.mjs +98 -236
- package/resources/broker-control.mjs +189 -16
- package/resources/broker-epoch.mjs +1 -1
- package/resources/broker-kill.mjs +8 -2
- package/resources/broker-launch.mjs +373 -214
- package/resources/broker-state.mjs +64 -18
- package/resources/container-guard.mjs +1 -1
- package/resources/ghidra-project.mjs +790 -0
- package/resources/host-tool.mjs +2561 -0
- package/resources/vice-broker.mjs +330 -184
- package/resources/vice-launcher.sh +130 -12
- package/stock-address.ts +6 -6
- package/stock-cia.ts +2 -2
- package/stock-condition.ts +8 -8
- package/stock-connect.ts +10 -6
- package/stock-derived.ts +29 -37
- package/stock-diagnose.ts +200 -36
- package/stock-dispatch.ts +200 -68
- package/stock-execution.ts +5 -3
- package/stock-handler.ts +1 -1
- package/stock-input.ts +9 -9
- package/stock-machine.ts +17 -6
- package/stock-paths.ts +18 -14
- package/stock-petscii.ts +1 -1
- package/stock-protocol.ts +17 -12
- package/stock-recycle.ts +83 -2
- package/stock-registers.ts +54 -29
- package/stock-reproducible-run.ts +811 -0
- package/stock-run-until.ts +100 -1
- package/stock-sprites.ts +3 -3
- package/stock-symbols.ts +13 -13
- package/stock-timing.ts +2 -2
- package/stock-vicii.ts +1 -1
- package/stop-oracle.ts +167 -0
- package/text-capability-probe.ts +660 -0
- package/text-connect.ts +157 -0
- package/text-protocol.ts +810 -0
- package/text-tools.ts +778 -0
- package/textmon-backtrace.ts +385 -0
- package/textmon-cpuhistory.ts +335 -0
- package/textmon-memmap.ts +494 -0
- package/textmon-profile.ts +458 -0
- package/textmon-registers.ts +748 -0
- package/tools-manifest.stock.json +864 -3
- package/version.ts +1 -1
- package/vice-broker-client.ts +189 -42
- package/vice-errors.ts +268 -0
- package/vice-proxy.ts +392 -2175
- package/vsf-slice.ts +640 -0
- package/capability-registry.ts +0 -388
- package/r2000-cli.ts +0 -1103
- package/r2000-d64.ts +0 -310
- 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/refresh-manifest.ts +0 -124
- package/tools-manifest.json +0 -1223
- package/vice-probe.ts +0 -278
- package/vice-sync.ts +0 -336
- package/vice.ts +0 -772
package/anno-enum-gen.ts
ADDED
|
@@ -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
|
+
|