@henols/vice-mcp 0.1.12 → 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-vicii.ts ADDED
@@ -0,0 +1,318 @@
1
+ #!/usr/bin/env node
2
+ // stock-vicii.ts
3
+ //
4
+ // vice_vicii_get_state -- a DERIVED tool (DERIV-05): its answer is computed
5
+ // CLIENT-SIDE from bytes ONE MEM_GET returns, never from a single
6
+ // binary-monitor opcode the way a direct tool's answer is. Registered
7
+ // through withDerivedTool("vice_vicii_get_state", { needsSession: true }, ...)
8
+ // in stock-dispatch.ts (05-07's task), never withStockSession().
9
+ //
10
+ // WHY THIS FILE EXISTS: half of criterion 3 -- "a user can read decoded
11
+ // VIC-II state on the stock backend". The binary monitor has no VIC-II
12
+ // command at all; the readable memory-mapped register block $D000-$D02E is
13
+ // all there is, and CLAUDE.md's constraint is explicit that VIC-II
14
+ // *internal* state (raster-IRQ latch, VC/VCBASE, RC, the bad-line flip-flop,
15
+ // the border flip-flops, the per-sprite DMA sequencer) is not recoverable on
16
+ // stock. Criterion 3 turns entirely on representing that honestly instead of
17
+ // defaulting a missing field to `0` or omitting the key.
18
+ //
19
+ // WHAT NOT TO DO:
20
+ // - Never turn `sidefx` on, and never accept an argument that could turn
21
+ // it on. $D01E (sprite-sprite collision) and $D01F (sprite-background
22
+ // collision) CLEAR ON READ in hardware -- this tool exists specifically
23
+ // to read them, so a side-effecting read would destroy the very state
24
+ // the caller asked for. ONE unconditional `sidefx: false` read covers
25
+ // the whole $D000-$D02E block; there is no per-register branch anywhere
26
+ // in this file.
27
+ // - Do not conflate $D019/$D01A with the collision registers. $D019 is an
28
+ // interrupt STATUS register that does NOT clear on read (it is cleared
29
+ // only by WRITING a 1 to a bit), and $D01A is a plain enable mask with
30
+ // no read side effects at all. Both are decoded exactly like every other
31
+ // byte in the block -- the discipline is one unconditional read, never a
32
+ // per-register side-effect decision.
33
+ // - Never report an internal-only field as `0` or omit its key. The six
34
+ // unrecoverable fields are enumerated once in VICII_UNAVAILABLE_FIELDS
35
+ // and the `unavailable` object on the answer is BUILT from that
36
+ // registry, never as six hand-written literals, so the registry and the
37
+ // answer cannot drift.
38
+ // - Never resolve $D018's screen/charset/bitmap pointers to absolute
39
+ // addresses here. They are bank-relative (the VIC bank lives in CIA2
40
+ // port A, $DD00, outside this chip's register block) -- report them as
41
+ // `screenOffset`/`charsetOffset`/`bitmapOffset` with `relativeTo: "vic
42
+ // bank"| and let vice_cia_get_state (cia: 2) report vicBank and
43
+ // vice_sprite_get resolve the whole pointer chain. Duplicating that
44
+ // arithmetic here would be the "re-deriving a cross-cutting seam
45
+ // locally" anti-pattern for a field the caller can already get from two
46
+ // other tools.
47
+ // - CR-01 (2026-08-17): never read this block through bank `0x0000` --
48
+ // that is the CPU view and follows `$00`/`$01` banking, so it returns
49
+ // the RAM underneath $D000-$DFFF whenever the running program has I/O
50
+ // banked out ($01 = $34/$35, routine in loaders, depackers and IRQ
51
+ // handlers), producing a plausible-and-wrong answer with an empty
52
+ // `unavailable` set. Always resolve the emulator's own `io` bank
53
+ // through resolveRequiredBank() first and refuse when it is absent.
54
+ // - Never send anything but MEM_GET -- no ExitLoop/Exit/Continue, i.e.
55
+ // never an unrequested resume (Phase 3 D-05). `runState` on the answer
56
+ // (via stockAnswer()) reports the halt honestly.
57
+ // - Never build the answer outside stockAnswer() (D-06).
58
+ // - Never import hostpath.ts or vice-proxy.ts -- this tool takes no path
59
+ // argument at all, and hostpath-consumers.test.ts gates this file's
60
+ // absence from the closed host-path consumer set.
61
+ //
62
+ // FIELD-NAME PROVENANCE (Assumption A4's mitigation): every bit-field name
63
+ // below was transcribed once from
64
+ // .claude/skills/c64-memory-mapping/memmap.json's own entries for $D011
65
+ // (Screen control register #1 / VIC Control Register), $D012 (raster line),
66
+ // $D016 (Screen control register #2 / VIC Control Register), $D018 (Memory
67
+ // setup register / VIC Memory Control Register), $D019 (Interrupt status
68
+ // register / VIC Interrupt Flag Register) and $D01A (Interrupt control
69
+ // register / IRQ Mask Register), and cross-checked at write time. There is
70
+ // no automated drift check against that file -- a "committed literal,
71
+ // cross-checked once" posture, matching Phase 4 D-06's stance for the
72
+ // disassembler's opcode table.
73
+ import { CommandType, memGetBody } from "./stock-protocol.ts";
74
+ import { convertWireError, isErrorText, stockAnswer, type StockSessionHandler } from "./stock-handler.ts";
75
+ import { resolveRequiredBank } from "./stock-memory.ts";
76
+
77
+ /** True iff `value` is a well-formed, generic JSON object -- not null, not
78
+ * an array. Matches this module tree's own isPlainObject() convention
79
+ * (stock-memory.ts, stock-disassemble.ts et al.). */
80
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
81
+ return typeof value === "object" && value !== null && !Array.isArray(value);
82
+ }
83
+
84
+ /** Start of the VIC-II's memory-mapped register block. */
85
+ export const VICII_BASE = 0xd000;
86
+ /** End of the VIC-II's memory-mapped register block (inclusive). */
87
+ export const VICII_END = 0xd02e;
88
+ /** Length in bytes of [VICII_BASE, VICII_END] inclusive -- 47. */
89
+ export const VICII_LENGTH = VICII_END - VICII_BASE + 1;
90
+
91
+ /**
92
+ * The six VIC-II fields the binary monitor's memory-mapped register block
93
+ * cannot express, each paired with a reason naming WHY it is unreadable and,
94
+ * where applicable, WHAT is readable instead. `decodeVicii()` builds the
95
+ * answer's `unavailable` object from this registry -- never as hand-written
96
+ * literals -- so the two cannot drift (T-05-03-02's mitigation).
97
+ */
98
+ export const VICII_UNAVAILABLE_FIELDS: readonly (readonly [string, string])[] = Object.freeze([
99
+ [
100
+ "rasterIrqLine",
101
+ "the raster-compare latch written to $D012 plus $D011 bit 7 -- reading those addresses returns the CURRENT raster line, not the compare value, and the binary monitor has no VIC-II command that exposes the latch. The current raster line IS available as this answer's own rasterLine.",
102
+ ],
103
+ [
104
+ "videoCounter",
105
+ "the VIC-II's internal VC/VCBASE video matrix counter -- not exposed anywhere in the memory-mapped register map.",
106
+ ],
107
+ [
108
+ "rowCounter",
109
+ "the VIC-II's internal RC character-row counter -- not exposed anywhere in the memory-mapped register map.",
110
+ ],
111
+ [
112
+ "badLineCondition",
113
+ "the internal bad-line flip-flop that gates VIC-II DMA steals -- not exposed anywhere in the memory-mapped register map.",
114
+ ],
115
+ [
116
+ "borderFlipFlops",
117
+ "the internal vertical and horizontal border flip-flops that gate the border unit -- not exposed anywhere in the memory-mapped register map.",
118
+ ],
119
+ [
120
+ "spriteDmaState",
121
+ "the internal per-sprite DMA / MC / MCBASE sequencer state -- not exposed anywhere in the memory-mapped register map.",
122
+ ],
123
+ ]);
124
+
125
+ /** Returns the eight per-bit booleans of `raw`, bit 0 first, so callers do
126
+ * not repeat the shift/mask idiom eight times per register. */
127
+ function bits8(raw: number): boolean[] {
128
+ const out: boolean[] = [];
129
+ for (let i = 0; i < 8; i += 1) {
130
+ out.push(((raw >> i) & 1) === 1);
131
+ }
132
+ return out;
133
+ }
134
+
135
+ /**
136
+ * Pure decoder over exactly 47 bytes covering $D000-$D02E. Throws a plain
137
+ * `Error` on any other length -- the handler guards the length before
138
+ * calling this, so a mismatch here is a programmer-error path, never a
139
+ * caller-facing one.
140
+ */
141
+ export function decodeVicii(bytes: Uint8Array): Record<string, unknown> {
142
+ if (bytes.length !== VICII_LENGTH) {
143
+ throw new Error(`decodeVicii: expected exactly ${VICII_LENGTH} bytes, got ${bytes.length}`);
144
+ }
145
+
146
+ const registersHex = Buffer.from(bytes).toString("hex");
147
+
148
+ const spriteX: number[] = [];
149
+ const spriteY: number[] = [];
150
+ const msbByte = bytes[0x10]!;
151
+ for (let i = 0; i < 8; i += 1) {
152
+ const low = bytes[0x00 + i * 2]!;
153
+ const msb = (msbByte >> i) & 1;
154
+ spriteX.push(low | (msb << 8));
155
+ spriteY.push(bytes[0x01 + i * 2]!);
156
+ }
157
+
158
+ const spriteEnabled = bits8(bytes[0x15]!);
159
+ const spriteExpandY = bits8(bytes[0x17]!);
160
+ const spritePriorityBehindBackground = bits8(bytes[0x1b]!);
161
+ const spriteMulticolour = bits8(bytes[0x1c]!);
162
+ const spriteExpandX = bits8(bytes[0x1d]!);
163
+ const spriteColour: number[] = [];
164
+ for (let i = 0; i < 8; i += 1) {
165
+ spriteColour.push(bytes[0x27 + i]! & 0x0f);
166
+ }
167
+
168
+ const control1Raw = bytes[0x11]!;
169
+ const control1 = {
170
+ raw: control1Raw,
171
+ yScroll: control1Raw & 0x07,
172
+ rows25: ((control1Raw >> 3) & 1) === 1,
173
+ screenOn: ((control1Raw >> 4) & 1) === 1,
174
+ bitmapMode: ((control1Raw >> 5) & 1) === 1,
175
+ extendedBackgroundMode: ((control1Raw >> 6) & 1) === 1,
176
+ rasterMsb: ((control1Raw >> 7) & 1) === 1,
177
+ };
178
+
179
+ const control2Raw = bytes[0x16]!;
180
+ const control2 = {
181
+ raw: control2Raw,
182
+ xScroll: control2Raw & 0x07,
183
+ columns40: ((control2Raw >> 3) & 1) === 1,
184
+ multicolourMode: ((control2Raw >> 4) & 1) === 1,
185
+ };
186
+
187
+ const rasterLine = bytes[0x12]! | (((control1Raw >> 7) & 1) << 8);
188
+ const lightPenX = bytes[0x13]!;
189
+ const lightPenY = bytes[0x14]!;
190
+
191
+ const memorySetupRaw = bytes[0x18]!;
192
+ const memorySetup = {
193
+ raw: memorySetupRaw,
194
+ screenOffset: ((memorySetupRaw >> 4) & 0x0f) * 1024,
195
+ charsetOffset: ((memorySetupRaw >> 1) & 0x07) * 2048,
196
+ bitmapOffset: ((memorySetupRaw >> 3) & 0x01) * 8192,
197
+ relativeTo: "vic bank",
198
+ };
199
+
200
+ const interruptStatusRaw = bytes[0x19]!;
201
+ const interruptStatus = {
202
+ raw: interruptStatusRaw,
203
+ rasterIrq: (interruptStatusRaw & 1) === 1,
204
+ spriteBackgroundCollisionIrq: ((interruptStatusRaw >> 1) & 1) === 1,
205
+ spriteSpriteCollisionIrq: ((interruptStatusRaw >> 2) & 1) === 1,
206
+ lightPenIrq: ((interruptStatusRaw >> 3) & 1) === 1,
207
+ anyIrqPending: ((interruptStatusRaw >> 7) & 1) === 1,
208
+ };
209
+
210
+ const interruptEnableRaw = bytes[0x1a]!;
211
+ const interruptEnable = {
212
+ raw: interruptEnableRaw,
213
+ rasterIrqEnabled: (interruptEnableRaw & 1) === 1,
214
+ spriteBackgroundCollisionIrqEnabled: ((interruptEnableRaw >> 1) & 1) === 1,
215
+ spriteSpriteCollisionIrqEnabled: ((interruptEnableRaw >> 2) & 1) === 1,
216
+ lightPenIrqEnabled: ((interruptEnableRaw >> 3) & 1) === 1,
217
+ };
218
+
219
+ const spriteSpriteCollisionRaw = bytes[0x1e]!;
220
+ const spriteSpriteCollision = {
221
+ raw: spriteSpriteCollisionRaw,
222
+ sprites: bits8(spriteSpriteCollisionRaw),
223
+ };
224
+
225
+ const spriteBackgroundCollisionRaw = bytes[0x1f]!;
226
+ const spriteBackgroundCollision = {
227
+ raw: spriteBackgroundCollisionRaw,
228
+ sprites: bits8(spriteBackgroundCollisionRaw),
229
+ };
230
+
231
+ const borderColour = bytes[0x20]! & 0x0f;
232
+ const backgroundColour = bytes[0x21]! & 0x0f;
233
+ const extraBackgroundColour1 = bytes[0x22]! & 0x0f;
234
+ const extraBackgroundColour2 = bytes[0x23]! & 0x0f;
235
+ const extraBackgroundColour3 = bytes[0x24]! & 0x0f;
236
+ const spriteMulticolour1 = bytes[0x25]! & 0x0f;
237
+ const spriteMulticolour2 = bytes[0x26]! & 0x0f;
238
+
239
+ const unavailable: Record<string, { available: false; reason: string }> = {};
240
+ for (const [name, reason] of VICII_UNAVAILABLE_FIELDS) {
241
+ unavailable[name] = { available: false, reason };
242
+ }
243
+
244
+ return {
245
+ registersHex,
246
+ spriteX,
247
+ spriteY,
248
+ spriteEnabled,
249
+ spriteExpandY,
250
+ spritePriorityBehindBackground,
251
+ spriteMulticolour,
252
+ spriteExpandX,
253
+ spriteColour,
254
+ control1,
255
+ control2,
256
+ rasterLine,
257
+ lightPenX,
258
+ lightPenY,
259
+ memorySetup,
260
+ interruptStatus,
261
+ interruptEnable,
262
+ spriteSpriteCollision,
263
+ spriteBackgroundCollision,
264
+ borderColour,
265
+ backgroundColour,
266
+ extraBackgroundColour1,
267
+ extraBackgroundColour2,
268
+ extraBackgroundColour3,
269
+ spriteMulticolour1,
270
+ spriteMulticolour2,
271
+ unavailable,
272
+ };
273
+ }
274
+
275
+ export const handleViciiGetState: StockSessionHandler = async (args, session, _deps) => {
276
+ if (!isPlainObject(args)) {
277
+ return isErrorText("vice_vicii_get_state: arguments must be an object");
278
+ }
279
+
280
+ const unexpected = Object.keys(args);
281
+ if (unexpected.length > 0) {
282
+ return isErrorText(`vice_vicii_get_state: unexpected argument(s): ${unexpected.join(", ")} -- this tool takes no arguments`);
283
+ }
284
+
285
+ const bankResolution = await resolveRequiredBank("vice_vicii_get_state", "io", session);
286
+ if (!bankResolution.ok) {
287
+ return bankResolution.result;
288
+ }
289
+
290
+ const body = memGetBody({ sidefx: false, start: VICII_BASE, end: VICII_END, memspace: 0x00, bank: bankResolution.id });
291
+
292
+ let response;
293
+ try {
294
+ response = await session.client.send(CommandType.MemoryGet, body);
295
+ } catch (err) {
296
+ return convertWireError("vice_vicii_get_state", err);
297
+ }
298
+
299
+ if (response.type !== "memory_get") {
300
+ return isErrorText(
301
+ `vice_vicii_get_state: the binary monitor replied with an unexpected response type ("${response.type}"), expected "memory_get"`,
302
+ );
303
+ }
304
+
305
+ if (response.bytes.length !== VICII_LENGTH) {
306
+ return isErrorText(
307
+ `vice_vicii_get_state: expected ${VICII_LENGTH} byte(s), got ${response.bytes.length} -- a short read is a wrong answer, not a partial success`,
308
+ );
309
+ }
310
+
311
+ return stockAnswer(session.client, {
312
+ base: VICII_BASE,
313
+ end: VICII_END,
314
+ length: VICII_LENGTH,
315
+ bank: { id: bankResolution.id, name: bankResolution.name },
316
+ ...decodeVicii(response.bytes),
317
+ });
318
+ };