@henols/vice-mcp 0.1.11 → 0.2.0

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/stock-cia.ts ADDED
@@ -0,0 +1,598 @@
1
+ #!/usr/bin/env node
2
+ // stock-cia.ts
3
+ //
4
+ // vice_cia_get_state -- a DERIVED tool (DERIV-05): its answer is computed
5
+ // CLIENT-SIDE by decoding the bytes one MEM_GET per CIA returns, never
6
+ // answered by a binary-monitor opcode -- the monitor has no CIA command at
7
+ // all. Registered through withDerivedTool("vice_cia_get_state",
8
+ // { needsSession: true }, handleCiaGetState) in stock-dispatch.ts, never
9
+ // withStockSession(). This is stock-vicii.ts's sibling, not a second
10
+ // template -- both follow the exact same "one sidefx:false MEM_GET, decode
11
+ // client-side, wrap unreadable fields" shape.
12
+ //
13
+ // WHY THIS FILE EXISTS: DERIV-05's other half. The CIA is the sharpest case
14
+ // of "the register map is not the chip" in this whole project: THREE of its
15
+ // address ranges mean DIFFERENT things on read than on write --
16
+ // $xx04-$xx07 (timer current count vs. latch), $xx0D (interrupt status vs.
17
+ // enable mask), $xx08-$xx0B (current TOD vs. alarm, while CRB bit 7 is set).
18
+ // A naive decoder that reports only the read side under an ambiguous name
19
+ // leaves the caller believing it has the write side. plan_decision_D-05-11
20
+ // (05-04-PLAN.md) is the binding form of the fix: the readable side is named
21
+ // for what it actually is (`timerA.current`, `interruptStatus`, `tod`), and
22
+ // the write side is a SEPARATE, explicit `{ available: false, reason }`
23
+ // field naming the sharing address.
24
+ //
25
+ // Bit-field names below were transcribed ONCE from
26
+ // `.claude/skills/c64-memory-mapping/memmap.json`'s entries for $DC00,
27
+ // $DC01, $DC02, $DC03, $DC08-$DC0F, $DD00, $DD01 and $DD0D, and
28
+ // cross-checked at write time -- the same "committed literal, cross-checked
29
+ // once, no automated drift check" posture Phase 4's D-06 already accepted
30
+ // for the disassembler's opcode table.
31
+ //
32
+ // THREE CLARIFICATIONS THAT ARE OTHERWISE EASY TO GET WRONG:
33
+ // - Port A/B bits are ACTIVE-LOW for joysticks and the keyboard matrix: a
34
+ // CLEAR bit means pressed. Every joystick field below is computed as
35
+ // `((raw >> bit) & 1) === 0` -- do not "fix" this polarity later.
36
+ // - This is NOT a keyboard-matrix read. $DC00/$DC01 expose only the
37
+ // current column selection and row result; the full matrix is
38
+ // `vice_keyboard_matrix`, which is provably unrecoverable on stock
39
+ // (`docs/stock-vice-parity.md` SS A item 2) and is Phase 8's business.
40
+ // - WR-02 (2026-08-17): the port A/B joystick bits share their PINS with
41
+ // the keyboard matrix's column-select ($DC00) and row-read ($DC01), and
42
+ // a stock read halts the machine at an arbitrary PC -- often inside the
43
+ // KERNAL's IRQ keyboard scan -- so a driven-low column bit decodes as a
44
+ // phantom direction press. The DDR bytes already in this same 16-byte
45
+ // buffer are what makes that detectable: `joystick2`/`joystick1` carry
46
+ // `confounded`, `confoundedDirections` and (when confounded) a
47
+ // `confoundedReason`. The five booleans are ANNOTATED, never removed or
48
+ // altered.
49
+ // - WR-03 (2026-08-17, re-review): that flag must be PER READ ACTUAL and
50
+ // PER BIT, never per DDR configured. The first version was
51
+ // `chip === 1 && DDRA !== 0x00`, and the KERNAL leaves `DDRA = $FF` on
52
+ // every booted machine, so it was true for ~100% of realistic reads and
53
+ // carried no information at all -- an agent could not use it to tell a
54
+ // clean sample from a phantom one, and the documented escape hatch
55
+ // (`DDRA = $00`) described a state that essentially never occurs. It
56
+ // also consulted DDRA for `joystick1`, which lives on port B. A
57
+ // direction is confounded IFF it reads LOW *and* either its own pin is
58
+ // an output driving low or the OTHER port is driving a matrix line low.
59
+ // A bit reading HIGH is never confounded, so `$DC00 = 0x7F` on a booted
60
+ // machine reports a clean "nothing pressed". Do not widen this back to a
61
+ // whole-port or DDR-only predicate.
62
+ //
63
+ // WHAT NOT TO DO:
64
+ // - Never import hostpath.ts or vice-proxy.ts -- this tool takes no path
65
+ // argument at all; the host-facing surface is empty by construction
66
+ // (hostpath-consumers.test.ts's closed five-member consumer list must
67
+ // stay exactly five).
68
+ // - Never turn the MEM_GET body's side-effect flag on. `sidefx` is
69
+ // hardcoded `false` below with NO argument to override it, because
70
+ // $DC0D/$DD0D clear their interrupt-status bits ON READ in hardware --
71
+ // reading them wrongly would destroy pending interrupt flags the
72
+ // running program has not yet serviced. This is a sharper case than
73
+ // VIC-II's $D01E/$D01F: those clear stale collision data, this clears
74
+ // an interrupt the program was about to service.
75
+ // - Never issue an unrequested resume (Phase 3 D-05) -- this handler
76
+ // sends MEM_GET and nothing else. `runState` on the answer (via
77
+ // stockAnswer()) reports the halt honestly.
78
+ // - Never build the answer outside stockAnswer() (D-06).
79
+ // - Never re-derive address/byte-count parsing locally (D-04) -- this
80
+ // module has no address argument to parse (`cia` is validated as an
81
+ // explicit 1-or-2 literal, not a byte count or address), but every
82
+ // other family module's discipline still applies: no local regex.
83
+ // - Never report an unavailable field as `0` or omit it. The five
84
+ // write-side/internal fields below are rendered from
85
+ // CIA_UNAVAILABLE_FIELDS, never as five hand-written literals.
86
+ // - CR-01 (2026-08-17): never read $DC00/$DD00 through bank `0x0000` --
87
+ // that is the CPU view and follows `$00`/`$01` banking, so it returns
88
+ // the RAM underneath $DC00-$DFFF whenever the running program has I/O
89
+ // banked out ($01 = $34/$35). Always resolve the emulator's own `io`
90
+ // bank through resolveRequiredBank() first and refuse when it is
91
+ // absent.
92
+ import { CommandType, memGetBody } from "./stock-protocol.ts";
93
+ import { convertWireError, isErrorText, stockAnswer, type StockSessionHandler } from "./stock-handler.ts";
94
+ import { resolveRequiredBank } from "./stock-memory.ts";
95
+
96
+ /** True iff `value` is a well-formed, generic JSON object -- not null, not
97
+ * an array. Matches this module tree's own isPlainObject() convention
98
+ * (stock-memory.ts, stock-disassemble.ts et al.). */
99
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
100
+ return typeof value === "object" && value !== null && !Array.isArray(value);
101
+ }
102
+
103
+ export const CIA1_BASE = 0xdc00;
104
+ export const CIA2_BASE = 0xdd00;
105
+ export const CIA_LENGTH = 0x10;
106
+
107
+ /**
108
+ * The five fields no `MEM_GET` on stock can ever recover, in this fixed
109
+ * order (a regression test pins this exact order and these exact names).
110
+ * Each reason names the SHARING address and what the read side actually
111
+ * returns -- see this plan's `plan_decision_D-05-11` for why this shape
112
+ * (readable half named for what it is, unreadable half a distinct explicit
113
+ * field) rather than silently reporting the read side under an ambiguous
114
+ * name.
115
+ */
116
+ export const CIA_UNAVAILABLE_FIELDS: ReadonlyArray<readonly [string, string]> = Object.freeze([
117
+ [
118
+ "timerALatch",
119
+ "the timer A start value written behind $xx04/$xx05 -- reading those two addresses returns the timer's CURRENT counter (available as timerA.current), and the binary monitor has no CIA command that exposes the latch itself.",
120
+ ],
121
+ [
122
+ "timerBLatch",
123
+ "the timer B start value written behind $xx06/$xx07 -- reading those two addresses returns the timer's CURRENT counter (available as timerB.current), and the binary monitor has no CIA command that exposes the latch itself.",
124
+ ],
125
+ [
126
+ "interruptEnableMask",
127
+ "the interrupt-enable mask written to $xx0D -- a READ of $xx0D returns the interrupt STATUS flags (available as interruptStatus), not the mask, so the two halves of that one address are genuinely different data.",
128
+ ],
129
+ [
130
+ "todAlarmTime",
131
+ "the TOD alarm written behind $xx08-$xx0B while CRB bit 7 is set -- reading those addresses always returns the current time of day (available as tod), never the alarm, regardless of CRB's state.",
132
+ ],
133
+ [
134
+ "todLatchState",
135
+ "the internal TOD read-latch / halt flip-flop that freezes the TOD registers on a $xx08 read until $xx0B is read back -- not exposed anywhere in the memory-mapped register map at all.",
136
+ ],
137
+ ]);
138
+
139
+ /** Converts one BCD-encoded byte (e.g. `$42`) to its decimal value (`42`).
140
+ * Used for TOD tenths/seconds/minutes/hours -- EVERY TOD field goes through
141
+ * it (CR-01, 2026-08-17: `tenths` used to bypass it and report a masked raw
142
+ * nibble, so 0x0f decoded to an impossible `15`). Originally written to stop a raw-byte
143
+ * pass-through from reporting `0x42` as `66`. WR-03 (2026-08-17): that is
144
+ * not the only invention this helper must refuse -- a byte whose nibble
145
+ * exceeds 9 is not valid BCD at all, and the naive `tens*10+units` formula
146
+ * happily turns `0x9f` into a fabricated `105`. Returns `null`, never a
147
+ * fabricated decimal, when either nibble is out of BCD range; the caller
148
+ * omits the field and names it in `tod.invalidBcd` rather than reporting an
149
+ * impossible value. */
150
+ function fromBcd(raw: number): number | null {
151
+ const tens = (raw >> 4) & 0x0f;
152
+ const units = raw & 0x0f;
153
+ if (tens > 9 || units > 9) {
154
+ return null;
155
+ }
156
+ return tens * 10 + units;
157
+ }
158
+
159
+ function bit(byte: number, n: number): number {
160
+ return (byte >> n) & 1;
161
+ }
162
+
163
+ /** Active-low read: a CLEAR bit means the line is asserted (joystick
164
+ * pressed). See this module's header comment -- never "fix" this to `=== 1`. */
165
+ function activeLow(byte: number, n: number): boolean {
166
+ return bit(byte, n) === 0;
167
+ }
168
+
169
+ function boolBit(byte: number, n: number): boolean {
170
+ return bit(byte, n) === 1;
171
+ }
172
+
173
+ /** The five joystick direction bits, bit 0 first -- the fixed order every
174
+ * `confoundedDirections` list is rendered in. */
175
+ const JOYSTICK_DIRECTIONS: ReadonlyArray<string> = Object.freeze(["up", "down", "left", "right", "fire"]);
176
+
177
+ /** Bits 0-4 of a port read that are LOW. Only a low bit can be a phantom: a
178
+ * bit reading HIGH has nothing pulling it down, so it is an unambiguous
179
+ * "this direction is not pressed" regardless of how the DDR is configured
180
+ * (WR-03, 2026-08-17). */
181
+ function lowDirectionBits(raw: number): number {
182
+ return ~raw & 0x1f;
183
+ }
184
+
185
+ /** The pins this port is actively driving LOW: configured as an output (DDR
186
+ * bit set) with a 0 in the port register. All eight bits, because the
187
+ * keyboard's column-select/row-read lines are not confined to bits 0-4. */
188
+ function drivenLowMask(raw: number, ddr: number): number {
189
+ return ddr & ~raw & 0xff;
190
+ }
191
+
192
+ function directionNames(mask: number): string[] {
193
+ return JOYSTICK_DIRECTIONS.filter((_, n) => ((mask >> n) & 1) === 1);
194
+ }
195
+
196
+ const COUNT_SOURCE_MEANING: Readonly<Record<number, string>> = {
197
+ 0: "system cycles",
198
+ 1: "cnt pin positive edges",
199
+ 2: "timer a underflows",
200
+ 3: "timer a underflows with cnt high",
201
+ };
202
+
203
+ /**
204
+ * Pure per-chip decoder. Takes exactly 16 bytes (the chip's full register
205
+ * block, $xx00-$xx0F) and returns every readable field named for what it
206
+ * actually is, plus the five unavailable write-side/internal fields wrapped
207
+ * as `{ available: false, reason }`. Throws a plain `Error` on any length
208
+ * other than 16 -- never silently pads or truncates.
209
+ */
210
+ export function decodeCia(chip: 1 | 2, bytes: Uint8Array): Record<string, unknown> {
211
+ if (bytes.length !== CIA_LENGTH) {
212
+ throw new Error(`decodeCia: expected exactly ${CIA_LENGTH} bytes, got ${bytes.length}`);
213
+ }
214
+
215
+ const base = chip === 1 ? CIA1_BASE : CIA2_BASE;
216
+ // "DC" or "DD" -- the two hex digits shared by every register address in
217
+ // this chip's block, substituted into WR-02/WR-03's note strings below.
218
+ const basePrefix = base.toString(16).toUpperCase().slice(0, 2);
219
+ const registersHex = Array.from(bytes)
220
+ .map((b) => b.toString(16).padStart(2, "0"))
221
+ .join("");
222
+
223
+ function directionOutputs(raw: number): boolean[] {
224
+ const outputs: boolean[] = [];
225
+ for (let n = 0; n < 8; n += 1) {
226
+ outputs.push(boolBit(raw, n));
227
+ }
228
+ return outputs;
229
+ }
230
+
231
+ // Chip-level prose (WR-02/WR-03) -- present and empty when there is
232
+ // nothing to say, never absent, matching stock-sprites.ts's own
233
+ // notes:string[] convention.
234
+ const notes: string[] = [];
235
+
236
+ // WR-02: computed BEFORE the port A/B decode below (moved up from its
237
+ // original position after portB) so the CIA1 joystick branches can
238
+ // consult the DDR bytes already in this same 16-byte buffer.
239
+ //
240
+ // WR-03 (2026-08-17, re-review): the original predicate was
241
+ // `chip === 1 && DDRA !== 0x00`, which is TRUE on every booted C64 -- the
242
+ // KERNAL leaves DDRA = $FF permanently -- so it fired for ~100% of
243
+ // realistic reads and could not discriminate a clean sample from a phantom
244
+ // one. It also consulted DDRA for `joystick1`, which lives on port B. The
245
+ // predicate is now PER READ ACTUAL and PER BIT:
246
+ //
247
+ // a direction bit is confounded IFF it reads LOW *and* something other
248
+ // than the joystick could be pulling it down.
249
+ //
250
+ // A bit reading HIGH is never confounded (nothing is pulling it down), so
251
+ // an unambiguous "nothing pressed" read like $DC00 = 0x7F is reported
252
+ // clean. A low bit has two possible non-joystick causes, and both are
253
+ // computable from bytes already in this buffer:
254
+ // 1. its OWN pin is an output currently driving low (own DDR bit set,
255
+ // own port bit 0) -- the driven latch reads back on the pin;
256
+ // 2. the OTHER port is driving any pin low, so a pressed KEY in the
257
+ // selected column/row shorts this pin to that driven line. Which
258
+ // port B row a driven port A column can pull down is not recoverable
259
+ // from the register map, so every low bit on the other port is
260
+ // suspect while that is happening -- and the matrix itself is
261
+ // provably unrecoverable on stock (see this module's header).
262
+ const portARaw = bytes[0x00]!;
263
+ const portBRaw = bytes[0x01]!;
264
+ const portADirectionRaw = bytes[0x02]!;
265
+ const portBDirectionRaw = bytes[0x03]!;
266
+ const ddraHex = portADirectionRaw.toString(16).padStart(2, "0");
267
+ const ddrbHex = portBDirectionRaw.toString(16).padStart(2, "0");
268
+
269
+ const portADrivenLow = drivenLowMask(portARaw, portADirectionRaw);
270
+ const portBDrivenLow = drivenLowMask(portBRaw, portBDirectionRaw);
271
+
272
+ /** Suspect direction bits for one joystick: low bits whose own pin is
273
+ * driven low, plus (if the opposite port is driving anything low) every low
274
+ * bit, since a pressed key could be shorting it to that line. */
275
+ function confoundedBitsFor(raw: number, ownDdr: number, otherDrivenLow: number): number {
276
+ const low = lowDirectionBits(raw);
277
+ return low & (ownDdr | (otherDrivenLow !== 0 ? 0x1f : 0x00));
278
+ }
279
+
280
+ const joystick2Bits = chip === 1 ? confoundedBitsFor(portARaw, portADirectionRaw, portBDrivenLow) : 0;
281
+ const joystick1Bits = chip === 1 ? confoundedBitsFor(portBRaw, portBDirectionRaw, portADrivenLow) : 0;
282
+
283
+ /** The per-joystick reason, naming the suspect directions and WHICH of the
284
+ * two causes applies -- never a single shared sentence, since the two ports
285
+ * have different DDRs and different roles in the matrix. */
286
+ function joystickReason(
287
+ joystick: 1 | 2,
288
+ portLabel: "A" | "B",
289
+ bits: number,
290
+ raw: number,
291
+ ownDdr: number,
292
+ ownDdrHex: string,
293
+ otherDrivenLow: number,
294
+ ): string {
295
+ const ownDriven = lowDirectionBits(raw) & ownDdr;
296
+ const causes: string[] = [];
297
+ if (ownDriven !== 0) {
298
+ const ownNames = directionNames(ownDriven);
299
+ causes.push(
300
+ `${ownNames.join("/")} ${ownNames.length === 1 ? "sits" : "sit"} on port ${portLabel} pin(s) that ` +
301
+ `DDR${portLabel} ($DC0${portLabel === "A" ? "2" : "3"} = 0x${ownDdrHex}) configures as OUTPUTS currently driving LOW, ` +
302
+ `so the low bit may be that driven latch`,
303
+ );
304
+ }
305
+ if (otherDrivenLow !== 0) {
306
+ causes.push(
307
+ `port ${portLabel === "A" ? "B" : "A"} is driving 0x${otherDrivenLow.toString(16).padStart(2, "0")} low, so a pressed KEY in the ` +
308
+ `selected matrix line could be shorting a low pin here to that line`,
309
+ );
310
+ }
311
+ return (
312
+ `$DC00 is the keyboard-matrix COLUMN SELECT and $DC01 is the ROW READ, on the same pins as joystick 2 ` +
313
+ `($DC00) and joystick 1 ($DC01), and a stock read halts the machine at an arbitrary PC -- often inside ` +
314
+ `the KERNAL's IRQ keyboard scan. Suspect direction(s) for joystick ${joystick}: ${directionNames(bits).join("/")} -- ` +
315
+ `${causes.join("; and ")}. Directions reading HIGH are unaffected: nothing is pulling them down. Re-sample ` +
316
+ `with the machine stopped outside the scan, or compare two samples.`
317
+ );
318
+ }
319
+
320
+ const joystick2Reason = joystickReason(2, "A", joystick2Bits, portARaw, portADirectionRaw, ddraHex, portBDrivenLow);
321
+ const joystick1Reason = joystickReason(1, "B", joystick1Bits, portBRaw, portBDirectionRaw, ddrbHex, portADrivenLow);
322
+
323
+ if (joystick2Bits !== 0 || joystick1Bits !== 0) {
324
+ const suspects: string[] = [];
325
+ if (joystick2Bits !== 0) suspects.push(`joystick 2: ${directionNames(joystick2Bits).join("/")}`);
326
+ if (joystick1Bits !== 0) suspects.push(`joystick 1: ${directionNames(joystick1Bits).join("/")}`);
327
+ notes.push(
328
+ `$DC00/$DC01 (joystick 2/joystick 1) share pins with the keyboard-matrix column-select/row-read, and ` +
329
+ `DDRA (0x${ddraHex})/DDRB (0x${ddrbHex}) plus the bytes actually read leave these direction(s) suspect -- ` +
330
+ `${suspects.join("; ")}. See portA.joystick2's/portB.joystick1's confoundedReason. Directions not listed ` +
331
+ `read HIGH or sit on undriven pins and are genuine.`,
332
+ );
333
+ }
334
+
335
+ const portA: Record<string, unknown> = { raw: portARaw };
336
+ if (chip === 1) {
337
+ portA.joystick2 = {
338
+ up: activeLow(portARaw, 0),
339
+ down: activeLow(portARaw, 1),
340
+ left: activeLow(portARaw, 2),
341
+ right: activeLow(portARaw, 3),
342
+ fire: activeLow(portARaw, 4),
343
+ confounded: joystick2Bits !== 0,
344
+ // Always present (empty when nothing is suspect), matching this
345
+ // module's own notes:[] convention -- an absent list would read as
346
+ // "not computed" rather than "nothing suspect".
347
+ confoundedDirections: directionNames(joystick2Bits),
348
+ ...(joystick2Bits !== 0 ? { confoundedReason: joystick2Reason } : {}),
349
+ };
350
+ } else {
351
+ // $DD00 bits 0-1 are the VIC bank number, INVERTED: %00=bank3, %01=bank2,
352
+ // %10=bank1, %11=bank0 -- the same `3 - (raw & 3)` form
353
+ // dump-artifacts.mjs's own verified vicBank() uses.
354
+ const vicBank = 3 - (portARaw & 3);
355
+ portA.vicBank = vicBank;
356
+ portA.vicBankBase = vicBank * 16384;
357
+ portA.rs232Txd = boolBit(portARaw, 2);
358
+ portA.serialAtnOut = boolBit(portARaw, 3);
359
+ portA.serialClockOut = boolBit(portARaw, 4);
360
+ portA.serialDataOut = boolBit(portARaw, 5);
361
+ portA.serialClockIn = boolBit(portARaw, 6);
362
+ portA.serialDataIn = boolBit(portARaw, 7);
363
+ }
364
+
365
+ const portB: Record<string, unknown> = { raw: portBRaw };
366
+ if (chip === 1) {
367
+ portB.joystick1 = {
368
+ up: activeLow(portBRaw, 0),
369
+ down: activeLow(portBRaw, 1),
370
+ left: activeLow(portBRaw, 2),
371
+ right: activeLow(portBRaw, 3),
372
+ fire: activeLow(portBRaw, 4),
373
+ confounded: joystick1Bits !== 0,
374
+ confoundedDirections: directionNames(joystick1Bits),
375
+ ...(joystick1Bits !== 0 ? { confoundedReason: joystick1Reason } : {}),
376
+ };
377
+ } else {
378
+ portB.rs232Rxd = boolBit(portBRaw, 0);
379
+ portB.ri = boolBit(portBRaw, 3);
380
+ portB.dcd = boolBit(portBRaw, 4);
381
+ portB.userPortH = boolBit(portBRaw, 5);
382
+ portB.cts = boolBit(portBRaw, 6);
383
+ portB.dsr = boolBit(portBRaw, 7);
384
+ }
385
+
386
+ const portADirection = { raw: portADirectionRaw, outputs: directionOutputs(portADirectionRaw) };
387
+ const portBDirection = { raw: portBDirectionRaw, outputs: directionOutputs(portBDirectionRaw) };
388
+
389
+ const timerA = { current: bytes[0x04]! | (bytes[0x05]! << 8) };
390
+ const timerB = { current: bytes[0x06]! | (bytes[0x07]! << 8) };
391
+
392
+ // WR-03: fromBcd() returns `null`, never a fabricated decimal, when a byte
393
+ // is not valid BCD. The `tod` object OMITS the corresponding key and lists
394
+ // its name in `invalidBcd` (D-05-20) -- `rawHex` is always present so the
395
+ // caller can always re-derive the truth for a field this decoder declines
396
+ // to interpret.
397
+ const todTenthsRaw = bytes[0x08]!;
398
+ const todSecondsRaw = bytes[0x09]!;
399
+ const todMinutesRaw = bytes[0x0a]!;
400
+ const todHoursRaw = bytes[0x0b]!;
401
+ // CR-01 (2026-08-17, re-review): tenths was the one TOD field the original
402
+ // WR-03 fix skipped -- it masked the low nibble and reported it raw, so a
403
+ // register holding 0x0f produced `tenths: 15`, an impossible decimal with
404
+ // no marker at all while its three siblings were correctly refusing. The
405
+ // high nibble of $xx08 is unused and reads 0, so masking first and then
406
+ // routing through fromBcd() validates exactly the one nibble that carries
407
+ // data.
408
+ const todTenths = fromBcd(todTenthsRaw & 0x0f);
409
+ const todSeconds = fromBcd(todSecondsRaw);
410
+ const todMinutes = fromBcd(todMinutesRaw);
411
+ const todHours = fromBcd(todHoursRaw & 0x1f);
412
+
413
+ const invalidBcd: string[] = [];
414
+ const tod: Record<string, unknown> = {};
415
+ if (todTenths !== null) {
416
+ tod.tenths = todTenths;
417
+ } else {
418
+ invalidBcd.push("tenths");
419
+ notes.push(
420
+ `$${basePrefix}08 (TOD tenths) reads 0x${todTenthsRaw.toString(16).padStart(2, "0")}, whose low nibble is not valid BCD -- no decimal value is reported; tod.rawHex carries the raw byte.`,
421
+ );
422
+ }
423
+ if (todSeconds !== null) {
424
+ tod.seconds = todSeconds;
425
+ } else {
426
+ invalidBcd.push("seconds");
427
+ notes.push(
428
+ `$${basePrefix}09 (TOD seconds) reads 0x${todSecondsRaw.toString(16).padStart(2, "0")}, which is not valid BCD -- no decimal value is reported; tod.rawHex carries the raw byte.`,
429
+ );
430
+ }
431
+ if (todMinutes !== null) {
432
+ tod.minutes = todMinutes;
433
+ } else {
434
+ invalidBcd.push("minutes");
435
+ notes.push(
436
+ `$${basePrefix}0A (TOD minutes) reads 0x${todMinutesRaw.toString(16).padStart(2, "0")}, which is not valid BCD -- no decimal value is reported; tod.rawHex carries the raw byte.`,
437
+ );
438
+ }
439
+ if (todHours !== null) {
440
+ tod.hours = todHours;
441
+ } else {
442
+ invalidBcd.push("hours");
443
+ notes.push(
444
+ `$${basePrefix}0B (TOD hours) reads 0x${todHoursRaw.toString(16).padStart(2, "0")}, which is not valid BCD -- no decimal value is reported; tod.rawHex carries the raw byte.`,
445
+ );
446
+ }
447
+ tod.pm = bit(todHoursRaw, 7) === 1;
448
+ tod.rawHex = [todTenthsRaw, todSecondsRaw, todMinutesRaw, todHoursRaw].map((b) => b.toString(16).padStart(2, "0")).join("");
449
+ tod.invalidBcd = invalidBcd;
450
+
451
+ const serialShiftRegister = bytes[0x0c]!;
452
+
453
+ const icrRaw = bytes[0x0d]!;
454
+ const interruptStatus = {
455
+ raw: icrRaw,
456
+ timerAUnderflow: boolBit(icrRaw, 0),
457
+ timerBUnderflow: boolBit(icrRaw, 1),
458
+ todAlarm: boolBit(icrRaw, 2),
459
+ serialShiftComplete: boolBit(icrRaw, 3),
460
+ flagPin: boolBit(icrRaw, 4),
461
+ interruptGenerated: boolBit(icrRaw, 7),
462
+ // Bit 7 means IRQ on CIA1 and NMI on CIA2 -- the answer says which,
463
+ // rather than leaving the caller to remember which chip is wired where.
464
+ interruptKind: chip === 1 ? "irq" : "nmi",
465
+ };
466
+
467
+ const craRaw = bytes[0x0e]!;
468
+ const timerAControl = {
469
+ raw: craRaw,
470
+ started: boolBit(craRaw, 0),
471
+ underflowOnPortB: boolBit(craRaw, 1),
472
+ underflowPulseMode: boolBit(craRaw, 2),
473
+ oneShot: boolBit(craRaw, 3),
474
+ forceLoad: boolBit(craRaw, 4),
475
+ countsCntPin: boolBit(craRaw, 5),
476
+ serialOutput: boolBit(craRaw, 6),
477
+ todFrequency50Hz: boolBit(craRaw, 7),
478
+ };
479
+
480
+ const crbRaw = bytes[0x0f]!;
481
+ const countSource = (crbRaw >> 5) & 0b11;
482
+ const timerBControl = {
483
+ raw: crbRaw,
484
+ started: boolBit(crbRaw, 0),
485
+ underflowOnPortB: boolBit(crbRaw, 1),
486
+ underflowPulseMode: boolBit(crbRaw, 2),
487
+ oneShot: boolBit(crbRaw, 3),
488
+ forceLoad: boolBit(crbRaw, 4),
489
+ countSource,
490
+ countSourceMeaning: COUNT_SOURCE_MEANING[countSource]!,
491
+ todWriteSetsAlarm: boolBit(crbRaw, 7),
492
+ };
493
+
494
+ const unavailable: Record<string, { available: false; reason: string }> = {};
495
+ for (const [name, reason] of CIA_UNAVAILABLE_FIELDS) {
496
+ unavailable[name] = { available: false, reason };
497
+ }
498
+
499
+ return {
500
+ chip,
501
+ base,
502
+ registersHex,
503
+ notes,
504
+ portA,
505
+ portB,
506
+ portADirection,
507
+ portBDirection,
508
+ timerA,
509
+ timerB,
510
+ tod,
511
+ serialShiftRegister,
512
+ interruptStatus,
513
+ timerAControl,
514
+ timerBControl,
515
+ unavailable,
516
+ };
517
+ }
518
+
519
+ function parseCiaArg(value: unknown): 1 | 2 {
520
+ if (value === 1 || value === "1") {
521
+ return 1;
522
+ }
523
+ if (value === 2 || value === "2") {
524
+ return 2;
525
+ }
526
+ throw new Error(`cia must be 1 or 2 (or their "1"/"2" string forms), got ${JSON.stringify(value)}`);
527
+ }
528
+
529
+ export const handleCiaGetState: StockSessionHandler = async (args, session, _deps) => {
530
+ if (!isPlainObject(args)) {
531
+ return isErrorText("vice_cia_get_state: arguments must be an object");
532
+ }
533
+
534
+ const unexpected = Object.keys(args).filter((key) => key !== "cia");
535
+ if (unexpected.length > 0) {
536
+ return isErrorText(`vice_cia_get_state: unexpected argument(s): ${unexpected.join(", ")} -- the only accepted argument is "cia"`);
537
+ }
538
+
539
+ let chips: (1 | 2)[];
540
+ let requested: string;
541
+ if (args.cia === undefined) {
542
+ chips = [1, 2];
543
+ requested = "both";
544
+ } else {
545
+ let parsed: 1 | 2;
546
+ try {
547
+ parsed = parseCiaArg(args.cia);
548
+ } catch (err) {
549
+ return isErrorText(`vice_cia_get_state: ${err instanceof Error ? err.message : String(err)}`);
550
+ }
551
+ chips = [parsed];
552
+ // Rendered as a string ("1"/"2"/"both"), never the bare number, so
553
+ // outputSchema can declare `requested` with a single `type: "string"`
554
+ // rather than a union checkAgainstSchema() cannot express (D-05-07,
555
+ // 05-07 Task 2).
556
+ requested = String(parsed);
557
+ }
558
+
559
+ const bankResolution = await resolveRequiredBank("vice_cia_get_state", "io", session);
560
+ if (!bankResolution.ok) {
561
+ return bankResolution.result;
562
+ }
563
+
564
+ const cias: Record<string, unknown>[] = [];
565
+ for (const chip of chips) {
566
+ const base = chip === 1 ? CIA1_BASE : CIA2_BASE;
567
+ const body = memGetBody({ sidefx: false, start: base, end: base + CIA_LENGTH - 1, memspace: 0x00, bank: bankResolution.id });
568
+
569
+ let response;
570
+ try {
571
+ response = await session.client.send(CommandType.MemoryGet, body);
572
+ } catch (err) {
573
+ return convertWireError("vice_cia_get_state", err);
574
+ }
575
+
576
+ if (response.type !== "memory_get") {
577
+ return isErrorText(
578
+ `vice_cia_get_state: CIA${chip}: the binary monitor replied with an unexpected response type ("${response.type}"), expected "memory_get"`,
579
+ );
580
+ }
581
+
582
+ if (response.bytes.length !== CIA_LENGTH) {
583
+ return isErrorText(
584
+ `vice_cia_get_state: CIA${chip}: expected ${CIA_LENGTH} byte(s), got ${response.bytes.length} -- ` +
585
+ `a short read is a wrong answer, not a partial success`,
586
+ );
587
+ }
588
+
589
+ cias.push(decodeCia(chip, response.bytes));
590
+ }
591
+
592
+ return stockAnswer(session.client, {
593
+ requested,
594
+ bank: { id: bankResolution.id, name: bankResolution.name },
595
+ cias,
596
+ count: cias.length,
597
+ });
598
+ };