@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.
- package/THIRD-PARTY-NOTICES.md +23 -0
- package/backend-detect.mts +58 -8
- package/capability-registry.ts +388 -0
- package/package.json +26 -1
- package/r2000-acme-ident.ts +97 -0
- package/r2000-cli.ts +1103 -0
- package/r2000-confidence.ts +233 -0
- package/r2000-d64.ts +310 -0
- package/r2000-enum-gen.ts +574 -0
- package/r2000-launch.ts +357 -0
- package/r2000-mcp-client.ts +596 -0
- package/r2000-memmap-render.ts +531 -0
- package/r2000-project.ts +190 -0
- package/r2000-regbits-gen.ts +416 -0
- package/r2000-regbits.json +1370 -0
- package/r2000-symbols.ts +388 -0
- package/r2000-tools.ts +914 -0
- package/r2000-verify.ts +184 -0
- package/resources/backend-detect.mjs +30 -2
- package/resources/broker-launch.mjs +166 -34
- package/resources/vice-broker.mjs +25 -4
- package/stock-cia.ts +598 -0
- package/stock-connect.ts +137 -20
- package/stock-derived.ts +67 -14
- package/stock-diagnose.ts +994 -0
- package/stock-dispatch.ts +105 -4
- package/stock-handler.ts +29 -0
- package/stock-memory-search.ts +441 -0
- package/stock-memory.ts +92 -13
- package/stock-protocol.ts +328 -0
- package/stock-recycle.ts +500 -0
- package/stock-run-until.ts +400 -0
- package/stock-runstate.ts +46 -2
- package/stock-sprites.ts +712 -0
- package/stock-symbols.ts +455 -0
- package/stock-timing.ts +562 -0
- package/stock-vicii.ts +318 -0
- package/tools-manifest.stock.json +3031 -223
- package/version.ts +279 -0
- package/vice-proxy.ts +195 -6
package/r2000-project.ts
ADDED
|
@@ -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
|
+
}
|