@henols/vice-mcp 0.1.12 → 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,190 @@
1
+ #!/usr/bin/env node
2
+ // r2000-project.ts -- the ONE authoritative place in this repo that builds a
3
+ // `.regen2000proj` file. This module performs no filesystem or network I/O:
4
+ // callers read the source bytes and write the returned JSON text themselves;
5
+ // this module only transforms bytes into the wire shape regenerator2000
6
+ // loads. Nothing else in this repo may hand-build a project file -- if a
7
+ // future caller needs a `.regen2000proj`, it imports `synthesizeProject`
8
+ // from here rather than re-deriving the shape.
9
+ //
10
+ // WHY THIS EXISTS (D-01): Phase 9 already proved a working bootstrap route --
11
+ // drive the TUI's own Save-As flow over a synthetic pty and a scripted
12
+ // keystroke sequence. That route works, but it costs `tmux` as a declared
13
+ // prerequisite this project does not otherwise need, AND it still requires a
14
+ // post-save JSON edit to force `settings.use_illegal_opcodes` (the TUI's own
15
+ // Save-As does not expose that setting). Synthesising the project file
16
+ // directly in Node removes all four costs at once: no pty, no modal TUI, no
17
+ // keystroke encoding, no terminal-size assumption, and no post-edit -- the
18
+ // forced setting is written correctly the first time, by construction.
19
+ //
20
+ // WHAT NOT TO DO (two concrete past mistakes, both from Phase 9's own
21
+ // evidence and CONTEXT.md's D-01/D-04):
22
+ // 1. Do NOT write `raw_data_base64` as a plain (uncompressed) base64
23
+ // payload. regenerator2000 loads the project file expecting the value
24
+ // to be gzip-then-base64 encoded; an uncompressed payload was tried
25
+ // during Phase 9's probe and failed to load with
26
+ // `Error loading file: invalid gzip header`. Always run the bytes
27
+ // through `node:zlib`'s `gzipSync` first.
28
+ // 2. Do NOT add a version pin or a `--version` allow-list anywhere near
29
+ // this module. D-04 explicitly rejects that shape: it blocks a user on
30
+ // a newer, perfectly working regenerator2000 build, and it still
31
+ // cannot detect a schema break that lands *within* a permitted
32
+ // version range. The compatibility strategy here is minimality --
33
+ // write only the fields `ProjectState` requires (no `#[serde(default)]`
34
+ // on the Rust side) plus the two deliberately-forced settings, and let
35
+ // every other field's own default carry the rest. The self-check that
36
+ // this is compatible is running a real regenerator2000 against the
37
+ // synthesised file once (see `r2000-project.test.ts`'s gated
38
+ // integration half), never a version table.
39
+ //
40
+ // Ground truth for the shape below was independently re-verified against the
41
+ // installed regenerator2000-core-0.9.20 crate source (not merely paraphrased
42
+ // from CONTEXT.md) -- see
43
+ // `.planning/phases/10-adoption-boundaries-automated-bootstrap-and-the-removal/evidence/10-environment-recheck.txt`
44
+ // for the file:line citations. Summary: `ProjectState`'s only three fields
45
+ // without a `#[serde(default...)]` are `origin`, `raw_data` (serde-renamed
46
+ // to `raw_data_base64`), and `blocks`; `origin` is `Addr`, a
47
+ // `#[serde(transparent)]` newtype over `u16`, so it serialises as a plain
48
+ // JSON number; and `System`, `DocumentSettings.system`'s type, is likewise
49
+ // `#[serde(transparent)]` over `String`, so `settings.system` is a plain
50
+ // JSON string, with the C64 constant equal to the exact literal
51
+ // `Commodore 64`.
52
+ //
53
+ // `.vsf` is deliberately NOT an input to this module (D-03). Phase 9 found
54
+ // `.vsf`'s machine-type field only reads correctly by coincidence --
55
+ // `"C64SC"` matches none of regenerator2000's literal `System` arms and
56
+ // falls through to that tool's own C64 default.
57
+ //
58
+ // FLOW-02 (D-11.1-01): this comment used to end by naming a specific
59
+ // numbered phase as the eventual owner of closing that gap. That phase
60
+ // shipped and never touched `.vsf` bootstrap, so the pointer was false the
61
+ // moment that phase closed -- a phase number is a planning artifact, not a
62
+ // durable remediation path. The idea is recorded as backlog, not assigned
63
+ // to any phase: see
64
+ // `.planning/todos/pending/2026-08-20-vsf-as-a-bootstrap-input.md`. (This is
65
+ // a comment, not a user-facing string literal, so it is fixed here by hand
66
+ // rather than by `docs-dangling-refs.test.ts`'s guard -- see that guard's
67
+ // header for why it is deliberately scoped to string literals only.)
68
+
69
+ import { gzipSync, gunzipSync } from "node:zlib";
70
+
71
+ /** The exact literal regenerator2000's `System::C64` constant serialises as
72
+ * (`types.rs`, `#[serde(transparent)] pub struct System(String)`). This is
73
+ * the only system string this module ever writes unless a caller passes an
74
+ * explicit override. */
75
+ export const R2000_SYSTEM_C64 = "Commodore 64";
76
+
77
+ export interface SynthesizeOptions {
78
+ /** Load address (`ProjectState.origin`). `Addr` is `#[serde(transparent)]`
79
+ * over `u16`, so this is written as a plain JSON number -- never a hex
80
+ * string, never an object. */
81
+ origin: number;
82
+ /** `DocumentSettings.system`, a plain JSON string. Defaults to
83
+ * `R2000_SYSTEM_C64`. This module never infers, detects or defaults the
84
+ * machine type from the payload bytes -- an explicit caller-supplied (or
85
+ * defaulted-to-C64) value is the whole point (D-05): it is what makes
86
+ * Phase 9's `.vsf` machine-type coincidence unreachable rather than
87
+ * merely mitigated, because there is no inference path here to get
88
+ * wrong. */
89
+ system?: string;
90
+ }
91
+
92
+ /**
93
+ * Builds the JSON text of a `.regen2000proj` file from raw program bytes.
94
+ *
95
+ * The object written is EXACTLY four top-level keys: `origin`,
96
+ * `raw_data_base64`, `blocks`, and `settings` (itself exactly two keys:
97
+ * `use_illegal_opcodes` and `system`). Every other `ProjectState` member
98
+ * (`version`, `labels`, `user_side_comments`, `cursor_address`, and so on)
99
+ * carries its own `#[serde(default...)]` on the Rust side, so omitting them
100
+ * is the forward-compatibility strategy (D-04), not an oversight -- a field
101
+ * this module does not write is a field a future regenerator2000 release
102
+ * can freely add, rename the default of, or restructure without ever
103
+ * breaking this synthesiser. `version` is deliberately not written; it
104
+ * defaults to the crate's current `PROJECT_FORMAT_VERSION` on load.
105
+ *
106
+ * `settings.use_illegal_opcodes` and `settings.system` are deliberately NOT
107
+ * configurable to be turned off or omitted -- there is no flag, no option,
108
+ * no code path that skips writing either:
109
+ * - `use_illegal_opcodes` defaults to `false` on the Rust side
110
+ * (`settings.rs`). Illegal-opcode-*correct* decoding is the entire
111
+ * reason this project's now-removed `toacme` had caveats in the first
112
+ * place; making this setting optional here would silently reintroduce
113
+ * that exact defect as a configuration choice rather than closing it
114
+ * (D-05).
115
+ * - An explicit `system` on every synthesised project (rather than
116
+ * omitting the key and letting the Rust-side default apply) is what
117
+ * makes Phase 9's `.vsf` machine-type limit unreachable through this
118
+ * route rather than merely mitigated -- there is no coincidental
119
+ * fallback to fall into, because a value is always supplied.
120
+ */
121
+ export function synthesizeProject(bytes: Uint8Array, opts: SynthesizeOptions): string {
122
+ const { origin, system = R2000_SYSTEM_C64 } = opts;
123
+
124
+ if (!Number.isInteger(origin) || origin < 0 || origin > 0xffff) {
125
+ throw new Error(
126
+ `synthesizeProject: origin ${origin} is out of range -- expected an integer 0..0xffff (0..65535)`,
127
+ );
128
+ }
129
+ if (bytes.length === 0) {
130
+ throw new Error("synthesizeProject: payload is empty -- a .regen2000proj must carry at least one byte");
131
+ }
132
+
133
+ const raw_data_base64 = gzipSync(bytes).toString("base64");
134
+
135
+ const project = {
136
+ origin,
137
+ raw_data_base64,
138
+ blocks: [] as unknown[],
139
+ settings: {
140
+ // Forced true, never configurable -- see the function doc comment
141
+ // above and D-05. Do not add a parameter that overrides this.
142
+ use_illegal_opcodes: true,
143
+ // Always written explicitly, never omitted -- see the function doc
144
+ // comment above and D-05/Phase 9's .vsf finding.
145
+ system,
146
+ },
147
+ };
148
+
149
+ return JSON.stringify(project);
150
+ }
151
+
152
+ /**
153
+ * Parses a `.prg` file: a little-endian 2-byte load address followed by the
154
+ * payload bytes. This is the C64 program-file convention every C64 loader
155
+ * (and this project's own `acme-build` output) already follows.
156
+ */
157
+ export function parsePrg(bytes: Uint8Array): { origin: number; body: Uint8Array } {
158
+ if (bytes.length < 3) {
159
+ throw new Error(
160
+ `parsePrg: input is ${bytes.length} byte(s) -- a .prg needs at least 3 bytes (2-byte load address plus at least 1 payload byte)`,
161
+ );
162
+ }
163
+ const origin = bytes[0]! | (bytes[1]! << 8);
164
+ const body = bytes.subarray(2);
165
+ return { origin, body };
166
+ }
167
+
168
+ /**
169
+ * Returns the load address (`0`) for a flat 64K RAM capture, and throws for
170
+ * anything else. Flat 64K is in scope because `R2000-06` names it directly
171
+ * and it is exactly the shape `c64-ram-capture` already produces (D-03) --
172
+ * this function does not attempt to support any other flat-image size.
173
+ */
174
+ export function flatImageOrigin(bytes: Uint8Array): number {
175
+ if (bytes.length !== 65536) {
176
+ throw new Error(
177
+ `flatImageOrigin: input is ${bytes.length} byte(s) -- a flat 64K capture must be exactly 65536 bytes`,
178
+ );
179
+ }
180
+ return 0;
181
+ }
182
+
183
+ /**
184
+ * The inverse of the `raw_data_base64` encoding step: base64-decode then
185
+ * gunzip. Exported so tests can prove the payload round-trips exactly,
186
+ * rather than asserting against an opaque blob.
187
+ */
188
+ export function decodeRawData(base64: string): Uint8Array {
189
+ return gunzipSync(Buffer.from(base64, "base64"));
190
+ }
@@ -0,0 +1,416 @@
1
+ #!/usr/bin/env node
2
+ // r2000-regbits-gen.ts -- the ONE authoritative place in this repo that turns
3
+ // c64-memory-mapping's memmap.json into the curated address->bit-name table
4
+ // r2000-enum-gen.ts decodes register values against (D-22, R2000-13).
5
+ //
6
+ // WHY THIS EXISTS (D-22): neither register the phase's own pinned criterion-3
7
+ // target needs ($D011) nor the registers a real game writes to constantly
8
+ // ($D015/$D017/$D01A-$D01D) can be named from memmap.json's own `bits` prose
9
+ // alone -- some of that prose is OCR-damaged ("O = Blank" uses a letter O for
10
+ // the digit 0; "Read NMls" uses a lowercase L for an uppercase I), and five
11
+ // addresses have NO `bits` entry in memmap.json at all (its `io` parser never
12
+ // produced one for them; widening memmap.json itself is separate work
13
+ // belonging to `c64-memory-mapping`, not this phase). `OVERRIDES` below is
14
+ // the curated fix for both problems, carrying a WHY comment on every entry
15
+ // so a future reader never has to guess why a bit was hand-named instead of
16
+ // mechanically derived.
17
+ //
18
+ // WHAT THIS IS THE ONE AUTHORITATIVE PLACE FOR: reading memmap.json's 29
19
+ // structured `bits` entries, normalising each `bits[].bit` range string into
20
+ // `{mask, shift}`, deriving (or overriding) a legal ACME identifier for every
21
+ // field, and emitting the committed, banner-marked `r2000-regbits.json`
22
+ // artifact `r2000-enum-gen.ts` decodes against. No other module may read
23
+ // memmap.json for this purpose or hand-maintain a second bit-name table.
24
+ //
25
+ // KEY-SHAPE DECISION: table keys are `$XXXX` (uppercase, 4-hex-digit,
26
+ // dollar-prefixed) strings, not decimal numbers -- this matches how every
27
+ // register in this project's own assembly/documentation conventions is
28
+ // named (`$D011`, not `53265`), and because every key is the same fixed
29
+ // width, `Object.keys(table).sort()` on the STRING keys already produces the
30
+ // same order as sorting the underlying addresses numerically, so no separate
31
+ // numeric-sort step is needed to keep the emitted JSON diff-stable.
32
+ //
33
+ // WHAT NOT TO DO, named concretely:
34
+ // - Never hand-edit r2000-regbits.json. It is a generated-but-committed
35
+ // artifact (ENGINEERING_RULES.md Sec 11), the same shape
36
+ // `resources-sync.test.ts` already established for compiled `.mjs`
37
+ // build output -- re-run `node r2000-regbits-gen.ts` and let the drift
38
+ // guard in r2000-regbits.test.ts confirm the result matches.
39
+ // - Never silently skip or placeholder an unmappable bit description.
40
+ // `buildRegBits()` THROWS, naming the address, the bit range and the
41
+ // offending description, when mechanical derivation fails AND no
42
+ // OVERRIDES entry covers it -- the difference between a curated table
43
+ // and an implicit one is exactly this refusal.
44
+ // - Never emit a timestamp into the banner. The drift guard's whole
45
+ // comparison (`buildRegBits()` re-run in memory vs. the committed file)
46
+ // is only TOTAL because nothing in the emitted document changes between
47
+ // two runs against the same memmap.json bytes.
48
+ import { readFileSync, writeFileSync } from "node:fs";
49
+ import { createHash } from "node:crypto";
50
+ import { dirname, join } from "node:path";
51
+ import { fileURLToPath, pathToFileURL } from "node:url";
52
+
53
+ const HERE = dirname(fileURLToPath(import.meta.url));
54
+
55
+ /** The sole read of c64-memory-mapping's own memmap.json -- this generator is
56
+ * its only consumer for this purpose (per this plan's key_links entry). */
57
+ const MEMMAP_PATH = join(HERE, "..", "..", "skills", "c64-memory-mapping", "memmap.json");
58
+
59
+ /** Where the generated, committed artifact lives -- always a sibling of this
60
+ * generator, never a caller-supplied path. */
61
+ const OUTPUT_PATH = join(HERE, "r2000-regbits.json");
62
+
63
+ export type FieldKind = "flag" | "numeric" | "enum";
64
+
65
+ export interface RegBitsField {
66
+ mask: number;
67
+ shift: number;
68
+ name: string;
69
+ kind: FieldKind;
70
+ /** Present only for "flag"/"enum" fields whose decoded value maps to a
71
+ * specific token string. A field silent-by-design in one state (e.g. a
72
+ * "the bit only matters when set" flag like ECM/RST8 below) still carries
73
+ * an EXPLICIT entry for that state -- an empty string, never an absent
74
+ * key -- so decoding never has to guess whether an omission was
75
+ * deliberate. */
76
+ tokens?: Record<number, string>;
77
+ }
78
+
79
+ export interface RegBitsEntry {
80
+ label: string;
81
+ fields: RegBitsField[];
82
+ }
83
+
84
+ export type RegBitsTable = Record<string, RegBitsEntry>;
85
+
86
+ const ACME_IDENT_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
87
+
88
+ interface MemmapBit {
89
+ bit: string;
90
+ desc: string;
91
+ }
92
+
93
+ interface MemmapEntry {
94
+ start: number;
95
+ end: number;
96
+ label?: string | null;
97
+ desc?: string;
98
+ bits?: MemmapBit[];
99
+ }
100
+
101
+ interface MemmapFile {
102
+ sources: unknown[];
103
+ entries: MemmapEntry[];
104
+ }
105
+
106
+ /**
107
+ * Parses a memmap `bits[].bit` string into `{mask, shift}`. Handles a single
108
+ * index ("7"), a descending range ("2-0") and an ascending range ("6-7") --
109
+ * both range forms appear in memmap.json's real data, and the result is
110
+ * identical either way since a bit RANGE has no inherent direction; only the
111
+ * high/low bounds matter for the mask this produces.
112
+ */
113
+ export function parseBitRange(bitStr: string): { mask: number; shift: number } {
114
+ const parts = bitStr.split("-").map((s) => Number.parseInt(s.trim(), 10));
115
+ if (parts.length === 1) {
116
+ const bit = parts[0]!;
117
+ return { mask: 1 << bit, shift: bit };
118
+ }
119
+ const [a, b] = parts as [number, number];
120
+ const hi = Math.max(a, b);
121
+ const lo = Math.min(a, b);
122
+ const width = hi - lo + 1;
123
+ return { mask: ((1 << width) - 1) << lo, shift: lo };
124
+ }
125
+
126
+ /**
127
+ * Mechanically derives an ACME-legal identifier from a memmap bit
128
+ * description: drops parenthetical asides, uppercases, replaces every
129
+ * non-alphanumeric run with a single underscore, and trims leading/trailing
130
+ * underscores. Returns `null` (never throws) when the result is empty or
131
+ * still not a legal identifier -- the caller decides whether to consult
132
+ * `OVERRIDES` or throw; this function's job is only the mechanical half of
133
+ * that decision.
134
+ */
135
+ export function deriveIdentifier(desc: string): string | null {
136
+ let s = desc.replace(/\([^)]*\)/g, " ");
137
+ s = s.toUpperCase();
138
+ s = s.replace(/[^A-Z0-9]+/g, "_");
139
+ s = s.replace(/^_+|_+$/g, "");
140
+ if (s === "") return null;
141
+ if (!ACME_IDENT_RE.test(s)) return null;
142
+ return s;
143
+ }
144
+
145
+ export interface RegbitsFieldOverride {
146
+ /** Must match a memmap `bits[].bit` string EXACTLY (e.g. "2-0", "4"). */
147
+ bit: string;
148
+ name: string;
149
+ kind: FieldKind;
150
+ tokens?: Record<number, string>;
151
+ }
152
+
153
+ export interface RegbitsRegisterOverride {
154
+ address: number;
155
+ /** Overrides memmap's own `label` for this address (OCR-damage fix). */
156
+ label?: string;
157
+ /** Per-bit overrides, matched against this address's own memmap `bits`
158
+ * entries by their `bit` string. */
159
+ fields?: readonly RegbitsFieldOverride[];
160
+ /** A COMPLETE field list for an address memmap.json's `io` parser produced
161
+ * no `bits` entry for at all (D-22's known gap) -- used only when no
162
+ * memmap entry exists for this address, never to replace one that does. */
163
+ synthetic?: readonly RegBitsField[];
164
+ }
165
+
166
+ /** Builds the 8 independent "bit N = sprite N" flag fields the five VIC
167
+ * sprite-plane registers ($D015/$D017/$D01B/$D01C/$D01D) all share the same
168
+ * shape for (D-22's named gap) -- each bit is silent (empty token) when
169
+ * clear and names the specific sprite when set, so a typical enum (most
170
+ * sprites off, one or two on) renders as a short, readable name instead of
171
+ * naming all eight sprites' negative state every time.
172
+ */
173
+ function spriteBitFields(suffix: string): RegBitsField[] {
174
+ const fields: RegBitsField[] = [];
175
+ for (let n = 0; n < 8; n++) {
176
+ fields.push({
177
+ mask: 1 << n,
178
+ shift: n,
179
+ name: `SPR${n}${suffix}`,
180
+ kind: "flag",
181
+ tokens: { 0: "", 1: `SPR${n}${suffix}` },
182
+ });
183
+ }
184
+ return fields;
185
+ }
186
+
187
+ // ---------------------------------------------------------------------------
188
+ // OVERRIDES -- every entry carries its own WHY comment immediately above it
189
+ // (r2000-regbits.test.ts's own non-vacuity check counts these two things
190
+ // against each other, so removing a comment without removing its entry, or
191
+ // vice versa, fails a test rather than silently drifting).
192
+ // ---------------------------------------------------------------------------
193
+
194
+ export const OVERRIDES: readonly RegbitsRegisterOverride[] = [
195
+ {
196
+ address: 53265, // $D011 -- VIC Control Register 1, this phase's pinned criterion-3 target
197
+ fields: [
198
+ // WHY: memmap's own prose ("Smooth Scroll to Y Dot-Position (0-7)") reads as a sentence, not
199
+ // a compact field name; mechanical derivation would bake the whole sentence into the
200
+ // identifier instead of the short numeric field the pinned criterion-3 target needs
201
+ // (YSCROLL3, not SMOOTH_SCROLL_TO_Y_DOT_POSITION3).
202
+ { bit: "2-0", name: "YSCROLL", kind: "numeric" },
203
+ // WHY: OCR damage -- "O = Blank" uses a letter O for the digit 0, and the raw prose reads as
204
+ // a NEGATIVE ("0 = Blank" the screen, i.e. bit=0 blanks it) rather than the positive
205
+ // "is the screen on" framing this table wants. This override fixes both problems in one
206
+ // place: the OCR-damaged letter, and the polarity, landing on the pinned SCREENON name.
207
+ { bit: "4", name: "SCREENON", kind: "flag", tokens: { 0: "SCREENOFF", 1: "SCREENON" } },
208
+ // WHY: memmap's prose ("Select 24/25 Row Text Display: 1 = 25 Rows") mixes the field name
209
+ // with its own value table; this override extracts the clean ROW24/ROW25 pair the pinned
210
+ // criterion-3 target needs instead of a mechanical transcription of the whole sentence.
211
+ { bit: "3", name: "ROWS", kind: "flag", tokens: { 0: "ROW24", 1: "ROW25" } },
212
+ // WHY: same shape as ROWS above -- "Bit Map Mode. 1 = Enable" is this register's other
213
+ // genuinely-binary choice (text vs. bitmap), and both states are equally meaningful, so both
214
+ // get a name rather than one state being silent.
215
+ { bit: "5", name: "MODE", kind: "flag", tokens: { 0: "TEXT", 1: "BITMAP" } },
216
+ // WHY: Extended Color Mode is off in the overwhelming majority of real programs' writes to
217
+ // this register; naming only the "on" state (silent when clear, via an explicit empty-string
218
+ // token for 0 -- never an absent key) keeps a typical generated variant name short rather
219
+ // than always appending "_ECMOFF" to every single value.
220
+ { bit: "6", name: "ECM", kind: "flag", tokens: { 0: "", 1: "ECM" } },
221
+ // WHY: same reasoning as ECM -- bit 8 of the raster compare value is only interesting when
222
+ // set (a compare line past 255), so the clear state is silent by design, not a dropped token.
223
+ { bit: "7", name: "RST8", kind: "flag", tokens: { 0: "", 1: "RST8" } },
224
+ ],
225
+ },
226
+ {
227
+ // WHY: OCR damage -- memmap's own label reads "Read NMls" (a lowercase L in place of an
228
+ // uppercase I). This is the register's LABEL, not a field name, so it is fixed here rather
229
+ // than via a per-bit override.
230
+ address: 56589, // $DD0D -- CIA2 Interrupt Control Register
231
+ label: "CIA Interrupt Control Register (Read NMIs/Write Mask)",
232
+ },
233
+ {
234
+ // WHY: memmap.json's `io` parser produced no `bits` entry at all for this address (D-22's
235
+ // named gap) even though a real game writes to it constantly -- sprite enable, one flag bit
236
+ // per sprite, is trivially regular and does not need memmap's own prose to describe correctly.
237
+ address: 53269, // $D015 -- Sprite Enable
238
+ label: "Sprite Enable",
239
+ synthetic: spriteBitFields("EN"),
240
+ },
241
+ {
242
+ // WHY: same D-22 gap as $D015 -- Sprite Y-Expand, one flag bit per sprite.
243
+ address: 53271, // $D017 -- Sprite Y-Expand
244
+ label: "Sprite Y-Expand",
245
+ synthetic: spriteBitFields("YEXP"),
246
+ },
247
+ {
248
+ // WHY: memmap.json's `io` parser produced no `bits` entry for the VIC IRQ mask register at
249
+ // all -- distinct from the VIC Interrupt FLAG register at $D019, which memmap.json does cover.
250
+ // Named per-bit since each bit enables a DIFFERENT interrupt source, not a repeated pattern.
251
+ address: 53274, // $D01A -- VIC Interrupt Enable (mask) Register
252
+ label: "VIC Interrupt Enable Register",
253
+ synthetic: [
254
+ { mask: 0x01, shift: 0, name: "RSTIRQEN", kind: "numeric" },
255
+ { mask: 0x02, shift: 1, name: "SPRBGIRQEN", kind: "numeric" },
256
+ { mask: 0x04, shift: 2, name: "SPRSPRIRQEN", kind: "numeric" },
257
+ { mask: 0x08, shift: 3, name: "LPIRQEN", kind: "numeric" },
258
+ { mask: 0xf0, shift: 4, name: "IRQENUNUSED", kind: "numeric" },
259
+ ],
260
+ },
261
+ {
262
+ // WHY: same D-22 gap as $D015 -- Sprite Priority (behind/in-front of background), one flag bit
263
+ // per sprite.
264
+ address: 53275, // $D01B -- Sprite Data Priority
265
+ label: "Sprite Data Priority",
266
+ synthetic: spriteBitFields("BG"),
267
+ },
268
+ {
269
+ // WHY: same D-22 gap as $D015 -- Sprite Multicolor, one flag bit per sprite.
270
+ address: 53276, // $D01C -- Sprite Multicolor
271
+ label: "Sprite Multicolor",
272
+ synthetic: spriteBitFields("MC"),
273
+ },
274
+ {
275
+ // WHY: same D-22 gap as $D015 -- Sprite X-Expand, one flag bit per sprite.
276
+ address: 53277, // $D01D -- Sprite X-Expand
277
+ label: "Sprite X-Expand",
278
+ synthetic: spriteBitFields("XEXP"),
279
+ },
280
+ ];
281
+
282
+ function findOverride(address: number): RegbitsRegisterOverride | undefined {
283
+ return OVERRIDES.find((o) => o.address === address);
284
+ }
285
+
286
+ /**
287
+ * Builds the field list for one memmap entry, consulting `override` for
288
+ * per-bit name/kind/tokens, falling back to mechanical derivation, and
289
+ * THROWING when neither succeeds (see this module's header). Also
290
+ * deduplicates overlapping bit ranges within a single entry -- two memmap
291
+ * "io" registers (`$DC00`/`$DC01`, the joystick/keyboard data ports) list
292
+ * multiple ALTERNATE readings of the very same bits (e.g. "7-0: keyboard
293
+ * column" and, separately, "4: joystick fire" -- the same physical bits
294
+ * read two different ways depending on what is plugged in), which would
295
+ * otherwise double-count those bits into two overlapping fields. First
296
+ * claim wins (array order); a later entry whose mask intersects an
297
+ * already-claimed one is skipped, never merged or thrown on.
298
+ */
299
+ function buildFieldsForEntry(entry: MemmapEntry, override: RegbitsRegisterOverride | undefined): RegBitsField[] {
300
+ const claimed: RegBitsField[] = [];
301
+ for (const b of entry.bits ?? []) {
302
+ const { mask, shift } = parseBitRange(b.bit);
303
+ if (claimed.some((f) => (f.mask & mask) !== 0)) continue;
304
+
305
+ const fo = override?.fields?.find((f) => f.bit === b.bit);
306
+ if (fo) {
307
+ const field: RegBitsField = { mask, shift, name: fo.name, kind: fo.kind };
308
+ if (fo.tokens) field.tokens = fo.tokens;
309
+ claimed.push(field);
310
+ continue;
311
+ }
312
+
313
+ const name = deriveIdentifier(b.desc);
314
+ if (name === null) {
315
+ throw new Error(
316
+ `buildRegBits: address $${entry.start.toString(16).toUpperCase().padStart(4, "0")} bit "${b.bit}" ` +
317
+ `desc "${b.desc}" did not mechanically derive a legal ACME identifier, and no OVERRIDES entry ` +
318
+ `covers it -- add a fieldOverride for { address: ${entry.start}, bit: "${b.bit}" }.`,
319
+ );
320
+ }
321
+ claimed.push({ mask, shift, name, kind: "numeric" });
322
+ }
323
+ claimed.sort((a, b) => a.shift - b.shift);
324
+ return claimed;
325
+ }
326
+
327
+ function formatAddressKey(address: number): string {
328
+ return `$${address.toString(16).toUpperCase().padStart(4, "0")}`;
329
+ }
330
+
331
+ /**
332
+ * Reads memmap.json, takes every entry with a `bits` array (29 as of this
333
+ * writing), normalises each field, and returns the curated table keyed by
334
+ * `$XXXX` address string, sorted so the result -- and the JSON this module
335
+ * emits from it -- is diff-stable. Synthetic (memmap-absent) registers named
336
+ * in `OVERRIDES` are added afterward, only when memmap produced no entry for
337
+ * that address.
338
+ */
339
+ export function buildRegBits(): RegBitsTable {
340
+ const memmap = JSON.parse(readFileSync(MEMMAP_PATH, "utf8")) as MemmapFile;
341
+ const table: RegBitsTable = {};
342
+
343
+ for (const entry of memmap.entries) {
344
+ if (!Array.isArray(entry.bits) || entry.bits.length === 0) continue;
345
+ const address = entry.start;
346
+ const override = findOverride(address);
347
+ const key = formatAddressKey(address);
348
+ const label = override?.label ?? entry.label ?? entry.desc ?? key;
349
+ const fields = buildFieldsForEntry(entry, override);
350
+ table[key] = { label, fields };
351
+ }
352
+
353
+ for (const override of OVERRIDES) {
354
+ if (!override.synthetic) continue;
355
+ const key = formatAddressKey(override.address);
356
+ if (table[key]) continue; // memmap already produced this address -- synthetic is a fallback only
357
+ table[key] = {
358
+ label: override.label ?? key,
359
+ fields: [...override.synthetic].sort((a, b) => a.shift - b.shift),
360
+ };
361
+ }
362
+
363
+ const sorted: RegBitsTable = {};
364
+ for (const key of Object.keys(table).sort()) {
365
+ sorted[key] = table[key]!;
366
+ }
367
+ return sorted;
368
+ }
369
+
370
+ /** SHA-256 hex digest of memmap.json's current bytes -- the banner's own
371
+ * drift-pin (T-11-GEN-DRIFT). */
372
+ export function memmapSha256(): string {
373
+ return createHash("sha256").update(readFileSync(MEMMAP_PATH)).digest("hex");
374
+ }
375
+
376
+ export interface RegBitsBanner {
377
+ generator: string;
378
+ memmapSha256: string;
379
+ warning: string;
380
+ }
381
+
382
+ export interface RegBitsDocument {
383
+ _generated: RegBitsBanner;
384
+ [key: string]: unknown;
385
+ }
386
+
387
+ /**
388
+ * Wraps `buildRegBits()`'s table with the first-key banner (generator
389
+ * filename, memmap.json's own digest, a do-not-hand-edit warning) --
390
+ * deliberately no timestamp, so two runs against the same memmap.json bytes
391
+ * produce byte-identical output and the drift guard's comparison stays
392
+ * total.
393
+ */
394
+ export function buildRegBitsDocument(): RegBitsDocument {
395
+ const table = buildRegBits();
396
+ const doc: RegBitsDocument = {
397
+ _generated: {
398
+ generator: "r2000-regbits-gen.ts",
399
+ memmapSha256: memmapSha256(),
400
+ warning: "GENERATED FILE -- do not hand-edit. Regenerate via `node r2000-regbits-gen.ts` from .claude/mcp/vice.",
401
+ },
402
+ };
403
+ for (const key of Object.keys(table).sort()) {
404
+ doc[key] = table[key];
405
+ }
406
+ return doc;
407
+ }
408
+
409
+ // Run-as-script: regenerate the committed artifact. Guarded so importing this
410
+ // module (e.g. from r2000-regbits.test.ts) never has a write side effect.
411
+ const isMain = process.argv[1] !== undefined && pathToFileURL(process.argv[1]).href === import.meta.url;
412
+ if (isMain) {
413
+ const doc = buildRegBitsDocument();
414
+ writeFileSync(OUTPUT_PATH, `${JSON.stringify(doc, null, 2)}\n`);
415
+ console.log(`r2000-regbits-gen: wrote ${OUTPUT_PATH} (${Object.keys(doc).length - 1} registers)`);
416
+ }