@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.
Files changed (108) hide show
  1. package/README.md +4 -3
  2. package/THIRD-PARTY-NOTICES.md +423 -25
  3. package/{r2000-acme-ident.ts → anno-acme-ident.ts} +13 -13
  4. package/anno-bank.ts +171 -0
  5. package/anno-cli.ts +3040 -0
  6. package/{r2000-confidence.ts → anno-confidence.ts} +22 -22
  7. package/anno-coverage.ts +2465 -0
  8. package/anno-derive.ts +590 -0
  9. package/anno-details.ts +169 -0
  10. package/anno-enum-gen.ts +919 -0
  11. package/anno-export-asm.ts +2396 -0
  12. package/anno-graphics.ts +338 -0
  13. package/anno-hazard-report.ts +1367 -0
  14. package/anno-import.ts +495 -0
  15. package/anno-index.ts +150 -0
  16. package/anno-join.ts +480 -0
  17. package/{r2000-memmap-render.ts → anno-memmap-render.ts} +236 -95
  18. package/anno-provenance-ledger.ts +472 -0
  19. package/{r2000-regbits-gen.ts → anno-regbits-gen.ts} +20 -15
  20. package/{r2000-regbits.json → anno-regbits.json} +2 -2
  21. package/anno-register.ts +399 -0
  22. package/anno-store-export.ts +661 -0
  23. package/anno-store.ts +4002 -0
  24. package/anno-symbols.ts +266 -0
  25. package/anno-tools.ts +3264 -0
  26. package/anno-types.ts +1909 -0
  27. package/backend-detect.mts +124 -312
  28. package/block-class.ts +201 -0
  29. package/build.ts +4 -2
  30. package/capture-predicate.ts +597 -0
  31. package/channel-lock.ts +349 -0
  32. package/disasm-decoder.ts +14 -14
  33. package/disasm-opcodes.ts +4 -4
  34. package/disasm-renderer.ts +2 -2
  35. package/evid-ingest.ts +217 -0
  36. package/evid-reconcile.ts +316 -0
  37. package/host-tool-client.ts +430 -0
  38. package/hostpath.ts +1 -1
  39. package/incident-record.ts +23 -12
  40. package/install-resources.ts +30 -14
  41. package/memmap-lookup.ts +285 -0
  42. package/package.json +48 -23
  43. package/prg-image.ts +118 -0
  44. package/repo-root.ts +107 -8
  45. package/resources/backend-detect.mjs +98 -236
  46. package/resources/broker-control.mjs +189 -16
  47. package/resources/broker-epoch.mjs +1 -1
  48. package/resources/broker-kill.mjs +8 -2
  49. package/resources/broker-launch.mjs +373 -214
  50. package/resources/broker-state.mjs +64 -18
  51. package/resources/container-guard.mjs +1 -1
  52. package/resources/ghidra-project.mjs +790 -0
  53. package/resources/host-tool.mjs +2561 -0
  54. package/resources/vice-broker.mjs +330 -184
  55. package/resources/vice-launcher.sh +130 -12
  56. package/stock-address.ts +6 -6
  57. package/stock-cia.ts +2 -2
  58. package/stock-condition.ts +8 -8
  59. package/stock-connect.ts +10 -6
  60. package/stock-derived.ts +29 -37
  61. package/stock-diagnose.ts +200 -36
  62. package/stock-dispatch.ts +200 -68
  63. package/stock-execution.ts +5 -3
  64. package/stock-handler.ts +1 -1
  65. package/stock-input.ts +9 -9
  66. package/stock-machine.ts +17 -6
  67. package/stock-paths.ts +18 -14
  68. package/stock-petscii.ts +1 -1
  69. package/stock-protocol.ts +17 -12
  70. package/stock-recycle.ts +83 -2
  71. package/stock-registers.ts +54 -29
  72. package/stock-reproducible-run.ts +811 -0
  73. package/stock-run-until.ts +100 -1
  74. package/stock-sprites.ts +3 -3
  75. package/stock-symbols.ts +13 -13
  76. package/stock-timing.ts +2 -2
  77. package/stock-vicii.ts +1 -1
  78. package/stop-oracle.ts +167 -0
  79. package/text-capability-probe.ts +660 -0
  80. package/text-connect.ts +157 -0
  81. package/text-protocol.ts +810 -0
  82. package/text-tools.ts +778 -0
  83. package/textmon-backtrace.ts +385 -0
  84. package/textmon-cpuhistory.ts +335 -0
  85. package/textmon-memmap.ts +494 -0
  86. package/textmon-profile.ts +458 -0
  87. package/textmon-registers.ts +748 -0
  88. package/tools-manifest.stock.json +864 -3
  89. package/version.ts +1 -1
  90. package/vice-broker-client.ts +189 -42
  91. package/vice-errors.ts +268 -0
  92. package/vice-proxy.ts +392 -2175
  93. package/vsf-slice.ts +640 -0
  94. package/capability-registry.ts +0 -388
  95. package/r2000-cli.ts +0 -1103
  96. package/r2000-d64.ts +0 -310
  97. package/r2000-enum-gen.ts +0 -574
  98. package/r2000-launch.ts +0 -357
  99. package/r2000-mcp-client.ts +0 -596
  100. package/r2000-project.ts +0 -190
  101. package/r2000-symbols.ts +0 -388
  102. package/r2000-tools.ts +0 -914
  103. package/r2000-verify.ts +0 -184
  104. package/refresh-manifest.ts +0 -124
  105. package/tools-manifest.json +0 -1223
  106. package/vice-probe.ts +0 -278
  107. package/vice-sync.ts +0 -336
  108. package/vice.ts +0 -772
@@ -0,0 +1,748 @@
1
+ // textmon-registers.ts
2
+ //
3
+ // THE ONE owning module for VICE's `io` text-monitor output (PARSE-02,
4
+ // PARSE-03). Nothing else in this tree reads or interprets an `io` reply --
5
+ // a handler dials the wire, this module is the only place that turns the
6
+ // framed text into structured data.
7
+ //
8
+ // WHY THIS FILE EXISTS RATHER THAN LIVING INSIDE THE TOOL HANDLER: the same
9
+ // reasoning `textmon-memmap.ts`, `textmon-profile.ts` and `disasm-decoder.ts`
10
+ // state for themselves applies here unchanged -- keeping the parser
11
+ // import-free of transport code means any future non-tool consumer can
12
+ // depend on this one file without dragging in a socket, and PARSE-03's own
13
+ // never-throw discipline (D-42-3, decided once in plan 42-01 for all five
14
+ // text parsers this phase adds) means this module can never be the thing
15
+ // that silently launders a wire error into a parse result, because it
16
+ // never touches the wire at all.
17
+ //
18
+ // FOUR OUTCOMES, NOT TWO, FOR THE DECODED-PROSE SECTION (originally "three
19
+ // outcomes" per this plan's own D-42-3 refinement; a fourth outcome was
20
+ // added by plan 42-10 to close CR-01). `prof flat`, `chis`, `bt` and
21
+ // `memmapshow` are closed formats: every field is either recognised or it is
22
+ // drift. `io`'s middle section is not -- it is free-form decoded prose whose
23
+ // line set differs per chip, and a parser that refused every line it had
24
+ // not seen before would refuse a legitimate reply from a chip the committed
25
+ // captures never sampled. So THIS module has four outcomes for that section
26
+ // only: recognised fields decode to typed values; a recognised label whose
27
+ // VALUE cannot be parsed refuses by name (that is drift, and it is loud, via
28
+ // the `unparseable-value` refusal code); a line matching no recognised shape
29
+ // at all is preserved verbatim in `unrecognisedLines`, which the result
30
+ // carries and the tool reports as a count -- never mapped onto a typed
31
+ // field; and a required field that was NEVER OBSERVED AT ALL -- because its
32
+ // one recognised line was dropped, renamed or reordered out of recognition
33
+ // -- refuses by name via `incomplete-decoded-state`, naming every absent
34
+ // field, rather than being cast onto `IoDecodedState` with `undefined`
35
+ // silently sitting in a field typed `number`, `boolean` or `string`. The
36
+ // hex dump, the memspace marker and the sprite table are CLOSED sets and
37
+ // refuse like every other format's fields.
38
+ //
39
+ // WHAT NOT TO DO:
40
+ // - Never import anything -- not `node:` anything, not `text-protocol.ts`,
41
+ // not `textmon-fixtures.ts`, not even a type-only import of a sibling
42
+ // module. Purity is asserted mechanically by this file's own test.
43
+ // - Never throw on a malformed or drifted input (D-42-3). Return the
44
+ // discriminated `IoRegistersParseResult` instead.
45
+ // - Never slice the sprite table at guessed/literal column widths. Some
46
+ // real rows (`X-Pos:`) have NO separating space at all between adjacent
47
+ // values -- a whitespace split would collapse them into one token and
48
+ // silently shift every sprite's value one column left. Column offsets
49
+ // are derived from the `Sprites:` header line's OWN token positions,
50
+ // every time, never a literal width constant.
51
+ // - Never treat VICE's own two graceful-degradation replies ("No details
52
+ // available." / "No I/O regs available") as a successful decode of an
53
+ // empty chip. A caller that received `{ok:true, value:{sections:[]}}`
54
+ // for one of these could not tell "the chip reported it has nothing to
55
+ // show" from "this parse found nothing" -- the second reads like a
56
+ // defect in this project. Both strings are source-traced from
57
+ // `monitor.c:1980-2000` (not live-observed -- see this module's test
58
+ // file for why) and are recognised as their own named refusal codes,
59
+ // each carrying the observed string verbatim.
60
+ // - Never let an unrecognised decoded-prose line populate a typed field.
61
+ // Push it verbatim to `unrecognisedLines` and move on -- see the
62
+ // "THREE OUTCOMES" note above.
63
+ // - Never accept a memspace marker other than the main CPU's ("C"). A
64
+ // drive-contaminated default memspace (CLAUDE.md's own documented
65
+ // constraint) makes every following field describe the wrong CPU; the
66
+ // refusal names the `vice_device_console` remedy tool by name, matching
67
+ // plan 42-02's own established convention for the same hazard.
68
+ // - Never restore an unchecked cast onto `IoDecodedState`, and never
69
+ // supply a default, a zero, an empty string or an inferred value for a
70
+ // field the decoded-prose block never observed (this was CR-01).
71
+ // `REQUIRED_IO_DECODED_KEYS`'s completeness check in `decodeProseLines()`
72
+ // exists precisely to stop that -- an absent observation is refused by
73
+ // name via `incomplete-decoded-state`, never manufactured.
74
+ // - Never restore an unconditional sprite-table requirement (this was
75
+ // WR-02), and never narrow `vice_io_registers`'s advertised 0-65535
76
+ // address range to make a non-VIC-II chip disappear. A CIA1, CIA2 or
77
+ // SID reply is a legitimate, in-schema answer; it is refused by its own
78
+ // name via `unsupported-chip`, never dressed up as a malformed VIC-II
79
+ // section and never made unreachable by shrinking the published
80
+ // contract.
81
+
82
+ /** One 64-byte VIC-II register dump, decoded from four `>C:aaaa ...` rows.
83
+ * `baseAddress` is the first row's own address field -- never assumed. */
84
+ export interface IoRegisterDump {
85
+ readonly baseAddress: number;
86
+ readonly bytes: readonly number[];
87
+ }
88
+
89
+ /** The three VIC-II display-mode bits, decoded from `Mode: ... (ECM/BMM/MCM=a/b/c)`. */
90
+ export interface IoDisplayMode {
91
+ readonly name: string;
92
+ readonly ecm: boolean;
93
+ readonly bmm: boolean;
94
+ readonly mcm: boolean;
95
+ }
96
+
97
+ /** The typed decode of `io`'s free-form middle prose section -- see this
98
+ * module's header comment for the three-outcome rule this section alone
99
+ * follows. Every field here comes from one of the six recognised label
100
+ * prefixes this module knows; nothing here is ever populated from an
101
+ * unrecognised line (see `IoRegisters.unrecognisedLines`). */
102
+ export interface IoDecodedState {
103
+ readonly rasterCycle: number;
104
+ readonly rasterLine: number;
105
+ readonly rasterIrqLine: number;
106
+ readonly mode: IoDisplayMode;
107
+ readonly borderColor: number;
108
+ readonly backgroundColor: number;
109
+ readonly scrollX: number;
110
+ readonly scrollY: number;
111
+ readonly rasterCounter: number;
112
+ readonly idle: boolean;
113
+ readonly screenColumns: number;
114
+ readonly screenRows: number;
115
+ readonly vc: number;
116
+ readonly vcbase: number;
117
+ readonly vmli: number;
118
+ readonly phi1: number;
119
+ readonly videoBase: number;
120
+ readonly charsetBase: number;
121
+ readonly charsetSource: string;
122
+ }
123
+
124
+ /** The closed, ten-member row-label set the sprite table carries -- an
125
+ * unknown row in it is drift, not free prose (see this module's header
126
+ * comment's "THREE OUTCOMES" note, which does NOT apply to this table). */
127
+ export type SpriteRowLabel =
128
+ | "Enabled"
129
+ | "DMA/dis"
130
+ | "Pointer"
131
+ | "MC"
132
+ | "MCBASE"
133
+ | "X-Pos"
134
+ | "Y-Pos"
135
+ | "X/Y-Exp"
136
+ | "Pri./MC"
137
+ | "Color";
138
+
139
+ /** One sprite-table row: a closed-set label plus exactly eight raw,
140
+ * trimmed cell values -- sliced at the header's own declared offsets, not
141
+ * at a guessed width (see this module's header comment). */
142
+ export interface SpriteTableRow {
143
+ readonly label: SpriteRowLabel;
144
+ readonly values: readonly string[];
145
+ }
146
+
147
+ /** The full sprite table. `columns` is the count asserted from the
148
+ * `Sprites:` header line itself -- always 8 for a well-formed table, and
149
+ * the parser refuses (`sprite-column-count`) rather than proceeding when
150
+ * it is anything else. */
151
+ export interface SpriteTable {
152
+ readonly columns: number;
153
+ readonly rows: readonly SpriteTableRow[];
154
+ }
155
+
156
+ /** One decoded chip section: the header names the chip, the dump is its
157
+ * 64-byte register range, `decoded` is the typed middle-prose state, and
158
+ * `sprites` is the fixed-shape sprite table. */
159
+ export interface IoChipSection {
160
+ readonly chip: string;
161
+ readonly dump: IoRegisterDump;
162
+ readonly decoded: IoDecodedState;
163
+ readonly sprites: SpriteTable;
164
+ }
165
+
166
+ /** The full decoded `io` reply. `sections` is an array even though the
167
+ * tool in plan 42-07 always dials a single-address form -- the command
168
+ * itself accepts a bare invocation that dumps every chip, and a parser
169
+ * that hard-assumed one section would refuse a legitimate reply. The array
170
+ * shape survives that reasoning unchanged, but only VIC-II sections
171
+ * decode (plan 42-11, WR-02): a CIA1, CIA2 or SID section is refused by
172
+ * the chip's own name rather than being reported as a VIC-II section
173
+ * missing its sprite table.
174
+ * `unrecognisedLines` carries every decoded-prose line this module did not
175
+ * recognise, verbatim, across every section -- drift stays visible in the
176
+ * answer instead of being dropped from it, and a caller reporting a
177
+ * non-zero count is reporting a real finding. */
178
+ export interface IoRegisters {
179
+ readonly sections: readonly IoChipSection[];
180
+ readonly unrecognisedLines: readonly string[];
181
+ }
182
+
183
+ /** The closed refusal-code union (D-42-3): an empty response, a malformed
184
+ * dump row, an unrecognised memspace marker, a recognised decoded-prose
185
+ * label whose value could not be parsed, an unrecognised sprite-row label,
186
+ * a sprite header declaring a column count other than eight, the two
187
+ * graceful-degradation outcomes (see this module's header comment), a
188
+ * decoded-prose block that did not carry every one of `IoDecodedState`'s
189
+ * required fields -- a required label was dropped, renamed or reordered
190
+ * out of recognition (plan 42-10, CR-01) -- and a chip section this parser
191
+ * does not decode, whose register dump nonetheless read cleanly (plan
192
+ * 42-11, WR-02): the sprite-table and decoded-prose requirements are
193
+ * VIC-II-only, so a CIA1, CIA2 or SID reply is refused by the chip's own
194
+ * name rather than being reported as a malformed VIC-II section. */
195
+ export type IoRegistersRefusalCode =
196
+ | "empty-response"
197
+ | "malformed-dump"
198
+ | "unrecognised-memspace"
199
+ | "unparseable-value"
200
+ | "unrecognised-sprite-row"
201
+ | "sprite-column-count"
202
+ | "no-details-available"
203
+ | "no-io-regs-available"
204
+ | "incomplete-decoded-state"
205
+ | "unsupported-chip";
206
+
207
+ /** A parser refusal (D-42-3): returned, never thrown. `line` and
208
+ * `lineNumber` name the offending content whenever one exists. */
209
+ export interface TextParseRefusal {
210
+ readonly code: IoRegistersRefusalCode;
211
+ readonly message: string;
212
+ readonly line: string;
213
+ readonly lineNumber: number;
214
+ }
215
+
216
+ /** D-42-3's discriminated shape: a parser never throws, it returns one of
217
+ * these two arms. */
218
+ export type IoRegistersParseResult = { ok: true; value: IoRegisters } | { ok: false; refusal: TextParseRefusal };
219
+
220
+ // ---------------------------------------------------------------------------
221
+ // Framing constants -- this module's OWN copies, never imported from
222
+ // text-protocol.ts (purity rule above). `io` carries no leading entry-echo
223
+ // prompt (unlike `memmapshow`) per fixtures/textmon/README.md's own
224
+ // "Framing" section -- only a TRAILING exit-prompt is ever stripped. Note
225
+ // while reading the captures: this command's trailing prompt carries a
226
+ // DIFFERENT address than the other four commands' (`(C:$d040)` here, vs.
227
+ // `(C:$e5d1)` for `prof flat`/`bt` on the same run) -- the parser keys on
228
+ // the prompt's SHAPE, never its value.
229
+ // ---------------------------------------------------------------------------
230
+
231
+ const TRAILING_PROMPT_RE = /\(C:\$[0-9A-Fa-f]{4}\)\s*$/;
232
+
233
+ /** VICE's own two graceful-degradation strings for this command
234
+ * (`monitor.c:1980-2000`), traced from source rather than observed live --
235
+ * this project did not build a VICE binary with the relevant chip support
236
+ * disabled this session (see this module's test file for the full
237
+ * citation). Exact literal match, case- and punctuation-sensitive. */
238
+ const NO_DETAILS_AVAILABLE_TEXT = "No details available.";
239
+ const NO_IO_REGS_AVAILABLE_TEXT = "No I/O regs available";
240
+
241
+ /** A chip-section header: a bare identifier then a single trailing colon,
242
+ * nothing else on the line (e.g. `VIC-II:`). */
243
+ const CHIP_HEADER_RE = /^([A-Za-z][A-Za-z0-9_./-]*):$/;
244
+
245
+ /** The loose shape that identifies a line as dump-row-SHAPED (`>` + a
246
+ * memspace letter + `:` + a 4-hex-digit address + two spaces) -- used to
247
+ * decide whether to keep consuming dump rows at all, distinct from the
248
+ * STRICT full-row regex below that validates one once this much has
249
+ * already matched. A line matching this loose shape but failing the strict
250
+ * regex is a STRUCTURAL malformation (`malformed-dump`), never silently
251
+ * skipped as "not a dump row". */
252
+ const DUMP_ROW_PREFIX_RE = /^>([A-Za-z]):([0-9a-fA-F]{4}) /;
253
+
254
+ /** One dump row's full, strict layout: memspace letter, 4-hex address, two
255
+ * spaces, four groups of four 2-hex-digit values (each group
256
+ * space-separated internally, two spaces between groups), three spaces,
257
+ * then the 16-character rendered character column. */
258
+ const DUMP_ROW_RE =
259
+ /^>([A-Za-z]):([0-9a-fA-F]{4}) ((?:[0-9a-fA-F]{2} ){3}[0-9a-fA-F]{2}) ((?:[0-9a-fA-F]{2} ){3}[0-9a-fA-F]{2}) ((?:[0-9a-fA-F]{2} ){3}[0-9a-fA-F]{2}) ((?:[0-9a-fA-F]{2} ){3}[0-9a-fA-F]{2}) (.{16})$/;
260
+
261
+ /** The main CPU's memspace marker letter -- the only one this module
262
+ * accepts anywhere a marker appears (CLAUDE.md's documented
263
+ * `default_memspace` contamination constraint). */
264
+ const MAIN_CPU_MEMSPACE = "C";
265
+
266
+ /** The only chip whose decoded display state and sprite table this parser
267
+ * models. The chip gate in `parseIoRegisters()` is what keeps every other
268
+ * chip section from being reported as a malformed VIC-II reply (plan
269
+ * 42-11, WR-02). */
270
+ const VIC_II_CHIP_NAME = "VIC-II";
271
+
272
+ const SPRITE_HEADER_PREFIX = "Sprites: ";
273
+
274
+ const SPRITE_ROW_LABELS: readonly SpriteRowLabel[] = [
275
+ "Enabled",
276
+ "DMA/dis",
277
+ "Pointer",
278
+ "MC",
279
+ "MCBASE",
280
+ "X-Pos",
281
+ "Y-Pos",
282
+ "X/Y-Exp",
283
+ "Pri./MC",
284
+ "Color",
285
+ ];
286
+
287
+ function isSpriteRowLabel(value: string): value is SpriteRowLabel {
288
+ return (SPRITE_ROW_LABELS as readonly string[]).includes(value);
289
+ }
290
+
291
+ function makeRefusal(code: IoRegistersRefusalCode, message: string, line: string, lineNumber: number): TextParseRefusal {
292
+ return { code, message, line, lineNumber };
293
+ }
294
+
295
+ /** The six recognised decoded-prose label prefixes, each paired with the
296
+ * full-line detail regex that extracts its typed values once the prefix
297
+ * has matched. A line whose PREFIX matches one of these but whose DETAIL
298
+ * regex does not is `unparseable-value`, naming this recogniser's `label`.
299
+ * A line matching no prefix at all is unrecognised (see the module header
300
+ * comment's "THREE OUTCOMES" note). */
301
+ const PROSE_RECOGNISERS: ReadonlyArray<{
302
+ label: string;
303
+ prefix: string;
304
+ detailRe: RegExp;
305
+ apply: (m: RegExpExecArray, state: Partial<Record<keyof IoDecodedState, unknown>>) => void;
306
+ }> = [
307
+ {
308
+ label: "Raster cycle/line",
309
+ prefix: "Raster cycle/line:",
310
+ detailRe: /^Raster cycle\/line: (\d+)\/(\d+) IRQ: (\d+)$/,
311
+ apply: (m, s) => {
312
+ s.rasterCycle = Number(m[1]);
313
+ s.rasterLine = Number(m[2]);
314
+ s.rasterIrqLine = Number(m[3]);
315
+ },
316
+ },
317
+ {
318
+ label: "Mode",
319
+ prefix: "Mode:",
320
+ detailRe: /^Mode: (.+) \(ECM\/BMM\/MCM=(\d)\/(\d)\/(\d)\)$/,
321
+ apply: (m, s) => {
322
+ s.mode = { name: m[1], ecm: m[2] === "1", bmm: m[3] === "1", mcm: m[4] === "1" } satisfies IoDisplayMode;
323
+ },
324
+ },
325
+ {
326
+ label: "Colors",
327
+ prefix: "Colors:",
328
+ detailRe: /^Colors: Border: ([0-9a-fA-F]) BG: ([0-9a-fA-F])$/,
329
+ apply: (m, s) => {
330
+ s.borderColor = parseInt(m[1]!, 16);
331
+ s.backgroundColor = parseInt(m[2]!, 16);
332
+ },
333
+ },
334
+ {
335
+ label: "Scroll X/Y",
336
+ prefix: "Scroll X/Y:",
337
+ detailRe: /^Scroll X\/Y: (\d+)\/(\d+), RC (\d+), Idle: (\d+), (\d+)x(\d+)$/,
338
+ apply: (m, s) => {
339
+ s.scrollX = Number(m[1]);
340
+ s.scrollY = Number(m[2]);
341
+ s.rasterCounter = Number(m[3]);
342
+ s.idle = m[4] === "1";
343
+ s.screenColumns = Number(m[5]);
344
+ s.screenRows = Number(m[6]);
345
+ },
346
+ },
347
+ {
348
+ label: "VC",
349
+ prefix: "VC $",
350
+ detailRe: /^VC \$([0-9a-fA-F]+), VCBASE \$([0-9a-fA-F]+), VMLI\s+(\d+), Phi1 \$([0-9a-fA-F]+)$/,
351
+ apply: (m, s) => {
352
+ s.vc = parseInt(m[1]!, 16);
353
+ s.vcbase = parseInt(m[2]!, 16);
354
+ s.vmli = Number(m[3]);
355
+ s.phi1 = parseInt(m[4]!, 16);
356
+ },
357
+ },
358
+ {
359
+ label: "Video",
360
+ prefix: "Video $",
361
+ detailRe: /^Video \$([0-9a-fA-F]+), Charset \$([0-9a-fA-F]+) \((.+)\)$/,
362
+ apply: (m, s) => {
363
+ s.videoBase = parseInt(m[1]!, 16);
364
+ s.charsetBase = parseInt(m[2]!, 16);
365
+ s.charsetSource = m[3];
366
+ },
367
+ },
368
+ ];
369
+
370
+ /** The nineteen keys `IoDecodedState` declares as required, in the EXACT
371
+ * order the interface declares them. This is the guard for the one cast in
372
+ * this module -- `decodeProseLines()`'s `state as IoDecodedState` -- and the
373
+ * refusal message below lists absent keys in this same order, so the same
374
+ * drifted input always produces a byte-identical message. TypeScript cannot
375
+ * derive an array of keys from an interface at runtime, so this list's
376
+ * equality with `IoDecodedState`'s own declared field set is asserted by
377
+ * this module's own test (a census read off this file's real source), not
378
+ * by review alone -- a field added to the interface later without a
379
+ * matching addition here would otherwise silently re-open CR-01. */
380
+ export const REQUIRED_IO_DECODED_KEYS: readonly (keyof IoDecodedState)[] = [
381
+ "rasterCycle",
382
+ "rasterLine",
383
+ "rasterIrqLine",
384
+ "mode",
385
+ "borderColor",
386
+ "backgroundColor",
387
+ "scrollX",
388
+ "scrollY",
389
+ "rasterCounter",
390
+ "idle",
391
+ "screenColumns",
392
+ "screenRows",
393
+ "vc",
394
+ "vcbase",
395
+ "vmli",
396
+ "phi1",
397
+ "videoBase",
398
+ "charsetBase",
399
+ "charsetSource",
400
+ ];
401
+
402
+ type DecodeProseResult =
403
+ | { ok: true; state: IoDecodedState }
404
+ | { ok: false; refusal: TextParseRefusal };
405
+
406
+ /** Decodes the free-form decoded-prose block per this module's
407
+ * three-outcome rule: a recognised prefix with a matching detail shape
408
+ * updates `state`; a recognised prefix whose detail shape fails to match
409
+ * refuses `unparseable-value`, naming the recogniser's label; a line
410
+ * matching no recognised prefix at all is pushed verbatim onto
411
+ * `unrecognisedLines` and never populates a typed field. Bounded by
412
+ * construction: one pass over `proseLines`, each iteration tries at most
413
+ * six fixed recognisers, no recursion. */
414
+ function decodeProseLines(proseLines: readonly string[], startLineNumber: number, unrecognisedLines: string[]): DecodeProseResult {
415
+ const state: Partial<Record<keyof IoDecodedState, unknown>> = {};
416
+ for (let i = 0; i < proseLines.length; i++) {
417
+ const original = proseLines[i]!;
418
+ const trimmed = original.trim();
419
+ const lineNumber = startLineNumber + i;
420
+ const recogniser = PROSE_RECOGNISERS.find((r) => trimmed.startsWith(r.prefix));
421
+ if (!recogniser) {
422
+ unrecognisedLines.push(original);
423
+ continue;
424
+ }
425
+ const match = recogniser.detailRe.exec(trimmed);
426
+ if (!match) {
427
+ return {
428
+ ok: false,
429
+ refusal: makeRefusal(
430
+ "unparseable-value",
431
+ `io: line ${lineNumber} carries the recognised "${recogniser.label}" label but its value could not be parsed: ${JSON.stringify(original)}`,
432
+ original,
433
+ lineNumber,
434
+ ),
435
+ };
436
+ }
437
+ recogniser.apply(match, state);
438
+ }
439
+ const absentKeys = REQUIRED_IO_DECODED_KEYS.filter((key) => !(key in state));
440
+ if (absentKeys.length > 0) {
441
+ const lastLineNumber = startLineNumber + proseLines.length - 1;
442
+ return {
443
+ ok: false,
444
+ refusal: makeRefusal(
445
+ "incomplete-decoded-state",
446
+ `io: the decoded-prose block spanning lines ${startLineNumber}-${lastLineNumber} did not carry every ` +
447
+ `required field -- absent: ${absentKeys.join(", ")} -- never returned as a complete decode`,
448
+ "",
449
+ startLineNumber,
450
+ ),
451
+ };
452
+ }
453
+ return { ok: true, state: state as IoDecodedState };
454
+ }
455
+
456
+ interface SpriteHeaderResult {
457
+ ok: true;
458
+ boundaries: number[];
459
+ }
460
+ interface SpriteHeaderFailure {
461
+ ok: false;
462
+ count: number;
463
+ }
464
+
465
+ /** Derives the sprite table's column boundaries from the `Sprites:` header
466
+ * line's OWN token positions -- never a guessed literal width (see this
467
+ * module's header comment). Each column label's start position, minus one
468
+ * character, becomes that column's left boundary; the header line's own
469
+ * length is the final (right) boundary. This one-character shift is what
470
+ * makes the SAME boundary set correctly slice every row even though real
471
+ * rows' own label fields vary in length (7-11 characters) -- see this
472
+ * module's test file for the measured proof. */
473
+ function deriveSpriteBoundaries(headerLine: string): SpriteHeaderResult | SpriteHeaderFailure {
474
+ const allTokens = [...headerLine.matchAll(/\S+/g)];
475
+ const columnTokens = allTokens.slice(1); // drop the "Sprites:" label token itself
476
+ if (columnTokens.length !== 8) {
477
+ return { ok: false, count: columnTokens.length };
478
+ }
479
+ const boundaries = columnTokens.map((m) => m.index! - 1);
480
+ boundaries.push(headerLine.length);
481
+ return { ok: true, boundaries };
482
+ }
483
+
484
+ type SpriteRowResult = { ok: true; row: SpriteTableRow } | { ok: false; label: string };
485
+
486
+ function parseSpriteRow(line: string, boundaries: readonly number[]): SpriteRowResult {
487
+ const rawLabel = line.slice(0, boundaries[0]).trim();
488
+ const label = rawLabel.replace(/:$/, "");
489
+ if (!isSpriteRowLabel(label)) {
490
+ return { ok: false, label: rawLabel };
491
+ }
492
+ const values: string[] = [];
493
+ for (let c = 0; c < 8; c++) {
494
+ values.push(line.slice(boundaries[c]!, boundaries[c + 1]).trim());
495
+ }
496
+ return { ok: true, row: { label, values } };
497
+ }
498
+
499
+ /**
500
+ * Parses `io`'s framed text-monitor reply into a structured
501
+ * {@link IoRegisters}. Input is the string `TextMonitorClient.command()`
502
+ * resolves to, or a fixture's `text` -- both must parse identically. Never
503
+ * throws (D-42-3): every failure mode returns `{ ok: false, refusal }`
504
+ * naming exactly what was wrong and where.
505
+ *
506
+ * Bounded by construction: one pass over the payload's lines, advancing a
507
+ * cursor that always moves forward by at least one line per iteration, no
508
+ * recursion anywhere in this function.
509
+ */
510
+ export function parseIoRegisters(text: string): IoRegistersParseResult {
511
+ if (typeof text !== "string" || text.trim() === "") {
512
+ return {
513
+ ok: false,
514
+ refusal: makeRefusal(
515
+ "empty-response",
516
+ "io: the response was empty or whitespace-only -- never decoded as a zero-section reply, because an empty " +
517
+ "response and a real capture that recorded no chips anywhere are two different facts",
518
+ "",
519
+ 0,
520
+ ),
521
+ };
522
+ }
523
+
524
+ const body = text.replace(TRAILING_PROMPT_RE, "");
525
+ const trimmedBody = body.trim();
526
+ if (trimmedBody === "") {
527
+ return {
528
+ ok: false,
529
+ refusal: makeRefusal(
530
+ "empty-response",
531
+ "io: the response contained only prompt text and no content -- never decoded as a zero-section reply",
532
+ "",
533
+ 0,
534
+ ),
535
+ };
536
+ }
537
+
538
+ if (trimmedBody === NO_DETAILS_AVAILABLE_TEXT) {
539
+ return {
540
+ ok: false,
541
+ refusal: makeRefusal(
542
+ "no-details-available",
543
+ `io: the chip reported "${NO_DETAILS_AVAILABLE_TEXT}" (source-traced, monitor.c:1980-2000) -- a named ` +
544
+ "outcome, never decoded as a successful zero-section reply",
545
+ trimmedBody,
546
+ 1,
547
+ ),
548
+ };
549
+ }
550
+ if (trimmedBody === NO_IO_REGS_AVAILABLE_TEXT) {
551
+ return {
552
+ ok: false,
553
+ refusal: makeRefusal(
554
+ "no-io-regs-available",
555
+ `io: the bank reported "${NO_IO_REGS_AVAILABLE_TEXT}" (source-traced, monitor.c:1980-2000) -- a named ` +
556
+ "outcome, never decoded as a successful zero-section reply",
557
+ trimmedBody,
558
+ 1,
559
+ ),
560
+ };
561
+ }
562
+
563
+ const lines = body.split("\n");
564
+ // A trailing "\n" before the (already-stripped) prompt splits into one
565
+ // trailing empty element -- drop exactly that split artifact, never any
566
+ // other blank line, so a genuinely blank line inside the payload still
567
+ // participates in section framing below rather than being silently
568
+ // swallowed here.
569
+ if (lines.length > 0 && lines[lines.length - 1] === "") {
570
+ lines.pop();
571
+ }
572
+
573
+ const sections: IoChipSection[] = [];
574
+ const unrecognisedLines: string[] = [];
575
+
576
+ let i = 0;
577
+ while (i < lines.length) {
578
+ const headerLine = lines[i]!;
579
+ const lineNumber = i + 1;
580
+ const chipMatch = CHIP_HEADER_RE.exec(headerLine);
581
+ if (!chipMatch) {
582
+ unrecognisedLines.push(headerLine);
583
+ i++;
584
+ continue;
585
+ }
586
+ const chip = chipMatch[1]!;
587
+ i++;
588
+
589
+ // --- dump rows -----------------------------------------------------
590
+ const byteRows: number[][] = [];
591
+ let baseAddress: number | undefined;
592
+ while (i < lines.length && DUMP_ROW_PREFIX_RE.test(lines[i] ?? "")) {
593
+ const line = lines[i]!;
594
+ const rowLineNumber = i + 1;
595
+ const memspaceMatch = /^>([A-Za-z]):/.exec(line)!;
596
+ const memspaceLetter = memspaceMatch[1]!;
597
+ if (memspaceLetter !== MAIN_CPU_MEMSPACE) {
598
+ return {
599
+ ok: false,
600
+ refusal: makeRefusal(
601
+ "unrecognised-memspace",
602
+ `io: line ${rowLineNumber} carries memspace marker "${memspaceLetter}", not the main CPU's -- run ` +
603
+ `vice_device_console to reset the default device before retrying: ${JSON.stringify(line)}`,
604
+ line,
605
+ rowLineNumber,
606
+ ),
607
+ };
608
+ }
609
+ const match = DUMP_ROW_RE.exec(line);
610
+ if (!match) {
611
+ return {
612
+ ok: false,
613
+ refusal: makeRefusal(
614
+ "malformed-dump",
615
+ `io: line ${rowLineNumber} does not match the expected 16-byte hex dump layout: ${JSON.stringify(line)}`,
616
+ line,
617
+ rowLineNumber,
618
+ ),
619
+ };
620
+ }
621
+ if (baseAddress === undefined) baseAddress = parseInt(match[2]!, 16);
622
+ const groups = [match[3]!, match[4]!, match[5]!, match[6]!];
623
+ const rowBytes = groups.flatMap((g) => g.split(" ").map((b) => parseInt(b, 16)));
624
+ byteRows.push(rowBytes);
625
+ i++;
626
+ }
627
+ if (byteRows.length === 0 || baseAddress === undefined) {
628
+ return {
629
+ ok: false,
630
+ refusal: makeRefusal(
631
+ "malformed-dump",
632
+ `io: chip section "${chip}" (line ${lineNumber}) has no register dump rows following its header`,
633
+ headerLine,
634
+ lineNumber,
635
+ ),
636
+ };
637
+ }
638
+
639
+ // --- chip gate (plan 42-11, WR-02) ------------------------------------
640
+ // The sprite table and decoded-prose sections below are VIC-II-only --
641
+ // a CIA1, CIA2 or SID reply (every one of them inside vice_io_registers'
642
+ // own advertised 0-65535 address range) is refused HERE, by the chip's
643
+ // own name, rather than being reported below as a VIC-II section
644
+ // missing its Sprites: header. This sits after the dump-row validation
645
+ // above (so a drive-contaminated memspace still refuses
646
+ // unrecognised-memspace first) and before every check below that would
647
+ // otherwise misread a legitimately different chip as a layout defect.
648
+ if (chip !== VIC_II_CHIP_NAME) {
649
+ const byteCount = byteRows.reduce((sum, row) => sum + row.length, 0);
650
+ const baseAddressHex = `$${baseAddress.toString(16).padStart(4, "0")}`;
651
+ return {
652
+ ok: false,
653
+ refusal: makeRefusal(
654
+ "unsupported-chip",
655
+ `io: chip "${chip}" is not supported -- its register dump read cleanly (${byteCount} bytes at ` +
656
+ `${baseAddressHex}), but only "${VIC_II_CHIP_NAME}" sections carry the decoded display state and ` +
657
+ `sprite table this parser models -- dial an address covered by the ${VIC_II_CHIP_NAME} chip for a ` +
658
+ "decoded answer",
659
+ headerLine,
660
+ lineNumber,
661
+ ),
662
+ };
663
+ }
664
+
665
+ // --- blank separator -------------------------------------------------
666
+ if (lines[i] !== "") {
667
+ return {
668
+ ok: false,
669
+ refusal: makeRefusal(
670
+ "malformed-dump",
671
+ `io: expected a blank line after chip "${chip}"'s register dump, found ${JSON.stringify(lines[i] ?? "<end of input>")}`,
672
+ lines[i] ?? "",
673
+ i + 1,
674
+ ),
675
+ };
676
+ }
677
+ i++;
678
+
679
+ // --- decoded prose -----------------------------------------------------
680
+ const proseStartLineNumber = i + 1;
681
+ const proseLines: string[] = [];
682
+ while (i < lines.length && lines[i] !== "") {
683
+ proseLines.push(lines[i]!);
684
+ i++;
685
+ }
686
+ const proseResult = decodeProseLines(proseLines, proseStartLineNumber, unrecognisedLines);
687
+ if (!proseResult.ok) return proseResult;
688
+ if (i < lines.length && lines[i] === "") i++; // consume the blank separator before the sprite table
689
+
690
+ // --- sprite table -----------------------------------------------------
691
+ const spriteHeaderLine = lines[i] ?? "";
692
+ const spriteHeaderLineNumber = i + 1;
693
+ if (!spriteHeaderLine.startsWith(SPRITE_HEADER_PREFIX)) {
694
+ return {
695
+ ok: false,
696
+ refusal: makeRefusal(
697
+ "sprite-column-count",
698
+ `io: chip "${chip}" is missing its expected "Sprites:" header line at line ${spriteHeaderLineNumber}: ${JSON.stringify(spriteHeaderLine)}`,
699
+ spriteHeaderLine,
700
+ spriteHeaderLineNumber,
701
+ ),
702
+ };
703
+ }
704
+ const boundariesResult = deriveSpriteBoundaries(spriteHeaderLine);
705
+ if (!boundariesResult.ok) {
706
+ return {
707
+ ok: false,
708
+ refusal: makeRefusal(
709
+ "sprite-column-count",
710
+ `io: the "Sprites:" header at line ${spriteHeaderLineNumber} declares ${boundariesResult.count} columns, not 8: ${JSON.stringify(spriteHeaderLine)}`,
711
+ spriteHeaderLine,
712
+ spriteHeaderLineNumber,
713
+ ),
714
+ };
715
+ }
716
+ i++;
717
+
718
+ const spriteRows: SpriteTableRow[] = [];
719
+ while (i < lines.length && lines[i] !== "") {
720
+ const rowLine = lines[i]!;
721
+ const rowLineNumber = i + 1;
722
+ const rowResult = parseSpriteRow(rowLine, boundariesResult.boundaries);
723
+ if (!rowResult.ok) {
724
+ return {
725
+ ok: false,
726
+ refusal: makeRefusal(
727
+ "unrecognised-sprite-row",
728
+ `io: line ${rowLineNumber} has a sprite-table row label outside the closed ten-label set: ${JSON.stringify(rowResult.label)}`,
729
+ rowLine,
730
+ rowLineNumber,
731
+ ),
732
+ };
733
+ }
734
+ spriteRows.push(rowResult.row);
735
+ i++;
736
+ }
737
+ if (i < lines.length && lines[i] === "") i++; // consume the blank separator before the next section, if any
738
+
739
+ sections.push({
740
+ chip,
741
+ dump: { baseAddress, bytes: byteRows.flat() },
742
+ decoded: proseResult.state,
743
+ sprites: { columns: 8, rows: spriteRows },
744
+ });
745
+ }
746
+
747
+ return { ok: true, value: { sections, unrecognisedLines } };
748
+ }