@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/backend-detect.mts +58 -8
- package/capability-registry.ts +388 -0
- package/disasm-decoder.ts +28 -4
- package/package.json +12 -1
- 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 +431 -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 +49 -6
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
|
+
};
|