@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.
@@ -0,0 +1,712 @@
1
+ #!/usr/bin/env node
2
+ // stock-sprites.ts
3
+ //
4
+ // vice_sprite_get / vice_sprite_inspect -- DERIVED tools (DERIV-06): the
5
+ // binary monitor has no sprite command at all, so both answers are
6
+ // pointer-chain arithmetic plus bit rendering computed CLIENT-SIDE over
7
+ // MEM_GET reads. Registered through withDerivedTool(..., { needsSession:
8
+ // true }, ...) in stock-dispatch.ts by 05-07 (wave 3) -- THIS PLAN DOES NOT
9
+ // REGISTER EITHER TOOL. No write to stock-dispatch.ts, stock-derived.ts,
10
+ // tools-manifest.stock.json or package.json happens here.
11
+ //
12
+ // PROVENANCE (required reading before touching the four geometry
13
+ // functions below): vicBank(), vicBankBase(), screenBase() and
14
+ // spriteDataAddress() are PORTED, NOT RE-DERIVED, from
15
+ // .claude/skills/c64-ram-capture/scripts/dump-artifacts.mjs's own
16
+ // vicBank()/screenBase()/spriteDataAddresses map, which carries a
17
+ // committed, verified fixture: dd00_raw=193 (0xC1), d018_raw=49 (0x31) ->
18
+ // screen_base=35840. stock-sprites.test.ts re-asserts the SAME fixture as
19
+ // its own cross-check -- do not change any of the four expressions without
20
+ // also updating that committed fixture's provenance. The skill's
21
+ // JavaScript is copied here, never imported at runtime -- .claude/skills/
22
+ // is a different package, absent from .claude/mcp/vice's files[], so a
23
+ // runtime cross-package import would be missing from the published
24
+ // tarball.
25
+ //
26
+ // WHAT NOT TO DO:
27
+ // - Never import hostpath.ts or vice-proxy.ts -- hostpath-consumers.test.ts
28
+ // gates this file's absence from the closed host-path consumer set
29
+ // (D-02). This tool takes no path argument at all.
30
+ // - Never set sidefx: true on any read. The VIC-II block read includes
31
+ // $D01E/$D01F, which CLEAR ON READ in hardware -- every read here is
32
+ // sidefx: false, with no argument anywhere to override it.
33
+ // - Never issue an unrequested resume (Phase 3 D-05) -- MEM_GET only.
34
+ // `runState` on the answer (via stockAnswer()) reports the halt
35
+ // honestly.
36
+ // - Never validate the sprite index with stock-address.ts's
37
+ // parseByteCount() -- that helper refuses 0, which is a valid (and the
38
+ // most commonly inspected) sprite index. The index is validated here
39
+ // as an explicit integer 0..7 instead (see parseSpriteIndex()).
40
+ // - Never build the answer outside stockAnswer() (D-06) -- that is
41
+ // exactly how an answer ships without `runState`.
42
+ // - CR-02 (2026-08-17): never read VIC-fetched memory (the sprite pointer
43
+ // table, sprite data) or I/O registers ($D000-$D02E, $DD00) through a
44
+ // literal bank id, and never default either to bank 0x0000 -- bank 0 is
45
+ // the CPU view and follows $00/$01 banking, so with I/O banked out it
46
+ // silently returns the RAM underneath $D000-$DFFF as if it were chip
47
+ // registers, and a screen at $CC00 with pointers into $D000+ is a
48
+ // normal layout (not an exotic one) that this exact bug turns into
49
+ // misread register bytes rendered as sprite pixels. Registers ($D000
50
+ // block, $DD00) resolve the emulator's own `io` bank; VIC-fetched memory
51
+ // (pointer table, sprite data) resolves its own `ram` bank -- both via
52
+ // resolveRequiredBank() (stock-memory.ts), never re-derived locally. See
53
+ // readSpriteContext() below; state this split in one sentence so the
54
+ // next reader does not "simplify" it to one bank.
55
+ import { CommandType, memGetBody } from "./stock-protocol.ts";
56
+ import { convertWireError, isErrorText, stockAnswer, type StockSessionHandler, type StockToolResult } from "./stock-handler.ts";
57
+ import { resolveRequiredBank } from "./stock-memory.ts";
58
+ import type { StockConnectSession } from "./stock-connect.ts";
59
+
60
+ /** True iff `value` is a well-formed, generic JSON object -- not null, not
61
+ * an array. Matches this module tree's own isPlainObject() convention
62
+ * (stock-memory.ts, stock-disassemble.ts et al. each keep a private copy
63
+ * rather than sharing one import). */
64
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
65
+ return typeof value === "object" && value !== null && !Array.isArray(value);
66
+ }
67
+
68
+ // ---------------------------------------------------------------------------
69
+ // Module constants
70
+ // ---------------------------------------------------------------------------
71
+
72
+ const VICII_BASE = 0xd000;
73
+ const VICII_END = 0xd02e;
74
+ const VICII_LENGTH = 0x2f;
75
+
76
+ const CIA2_PORT_A = 0xdd00;
77
+
78
+ const SPRITE_POINTER_TABLE_OFFSET = 0x3f8;
79
+ const SPRITE_COUNT = 8;
80
+ const SPRITE_DATA_BYTES = 63;
81
+ const SPRITE_ROWS = 21;
82
+ const SPRITE_HIRES_COLUMNS = 24;
83
+ const SPRITE_MULTICOLOUR_COLUMNS = 12;
84
+
85
+ /**
86
+ * The legend is a property of the RENDER, not of the tool -- renderSpriteAscii()'s
87
+ * two branches (below) emit genuinely different alphabets, so a single shared
88
+ * legend constant lies about whichever mode it is attached to. Hi-res emits
89
+ * one character per BIT ('.'/'#'); multicolour emits one character per BIT
90
+ * PAIR ('.'/'@'/'#'/'%'), through MULTICOLOUR_LEGEND. handleSpriteInspect
91
+ * selects between these two constants on the same `multicolour` flag that
92
+ * selects the renderer -- never a single constant applied regardless of mode
93
+ * (that was CR-02's live-reproduced legend defect: a hi-res render told an
94
+ * agent that '@' and '%' exist in a grid that only ever emits '.' and '#').
95
+ */
96
+ export const SPRITE_ASCII_LEGEND_HIRES = "'.' = transparent (bit clear), '#' = sprite colour (bit set)";
97
+
98
+ /** The fork's own manifest description, quoted verbatim in shape (four
99
+ * mappings separated by ", ") so a caller sees the exact bit-pair legend
100
+ * without decoding pixels themselves. Unchanged text -- it was always
101
+ * correct for multicolour sprites; the defect was attaching it to hi-res
102
+ * renders too. */
103
+ export const SPRITE_ASCII_LEGEND_MULTICOLOUR =
104
+ "'.' = transparent (00), '#' = sprite colour (10), '@' = multicolour 1 (01), '%' = multicolour 2 (11)";
105
+
106
+ /** vice_sprite_inspect's `format` values actually served on stock (D-05-03). */
107
+ export const SERVED_INSPECT_FORMATS = ["ascii", "binary"];
108
+
109
+ /** vice_sprite_inspect's `format` values refused by name (D-05-03) -- built
110
+ * for a value nothing calls, mirroring the SHOT-01..SHOT-05 cut. */
111
+ export const REFUSED_INSPECT_FORMATS = ["png_base64"];
112
+
113
+ // ---------------------------------------------------------------------------
114
+ // Geometry helpers -- PORTED VERBATIM from dump-artifacts.mjs. Do not change
115
+ // any of these four expressions; they are fixture-verified (see the
116
+ // provenance paragraph above) and re-deriving them from the hardware
117
+ // description a second time is exactly the anti-pattern this plan exists to
118
+ // avoid.
119
+ // ---------------------------------------------------------------------------
120
+
121
+ /** VIC-II bank number (0-3), from $DD00 bits 0-1. The stored value is the
122
+ * INVERSE of the bank number. */
123
+ export function vicBank(dd00Raw: number): number {
124
+ return 3 - (dd00Raw & 3);
125
+ }
126
+
127
+ /** Absolute base address of the selected VIC-II bank (16 KB windows). */
128
+ export function vicBankBase(dd00Raw: number): number {
129
+ return vicBank(dd00Raw) * 16384;
130
+ }
131
+
132
+ /** Screen memory base address: $D018 bits 4-7 (screen pointer, in
133
+ * 1024-byte units relative to the VIC bank) added to the bank's own base. */
134
+ export function screenBase(d018Raw: number, dd00Raw: number): number {
135
+ return vicBankBase(dd00Raw) + ((d018Raw >> 4) & 0x0f) * 1024;
136
+ }
137
+
138
+ /** Absolute address of one sprite's 63-byte data block, from its pointer
139
+ * table byte (each unit is 64 bytes, relative to the VIC bank's own base). */
140
+ export function spriteDataAddress(dd00Raw: number, pointerByte: number): number {
141
+ return vicBankBase(dd00Raw) + pointerByte * 64;
142
+ }
143
+
144
+ /**
145
+ * Returns a note when `address` (already resolved to an absolute address)
146
+ * falls in one of two independent hazard windows -- returns null otherwise.
147
+ * Both conditions describe a case where the bytes MEM_GET returns (through
148
+ * whichever bank this file resolved for that read) may not be what the
149
+ * VIC-II chip is actually fetching at that address:
150
+ *
151
+ * 1. VIC banks 0 and 2, address in $1000-$1FFF relative to the bank base --
152
+ * the character-ROM shadow window. The chip fetches character ROM there;
153
+ * MEM_GET (even through the resolved `ram` bank) always returns the RAM
154
+ * underneath it.
155
+ * 2. VIC bank 3, absolute address in $D000-$DFFF -- bank 3's I/O window. A
156
+ * screen or sprite-data pointer resolved into this range (a standard
157
+ * trick to reclaim 4 KB under bank 3) is exactly the CR-02 defect: the
158
+ * CPU's own view (bank 0x0000) would have returned CIA/VIC/colour-RAM
159
+ * register bytes here, while the VIC-II chip itself fetches RAM. This
160
+ * note documents that this answer read it through the resolved `ram`
161
+ * bank -- the chip's own view -- naming that bank so a caller can see
162
+ * which read produced the bytes.
163
+ */
164
+ function spriteWindowNote(address: number, bank: number, ramBankName: string): string | null {
165
+ if (bank === 0 || bank === 2) {
166
+ const relative = address & 0x3fff;
167
+ if (relative >= 0x1000 && relative <= 0x1fff) {
168
+ return (
169
+ `address 0x${address.toString(16)} falls in VIC bank ${bank}'s character-ROM window ` +
170
+ `($1000-$1FFF relative to the bank base) -- the VIC-II chip sees character ROM there, ` +
171
+ `while MEM_GET returns the RAM underneath it, so the bytes reported may not be what the chip is fetching`
172
+ );
173
+ }
174
+ return null;
175
+ }
176
+ if (bank === 3 && address >= 0xd000 && address <= 0xdfff) {
177
+ return (
178
+ `address 0x${address.toString(16)} falls in VIC bank 3's I/O window ($D000-$DFFF absolute) -- ` +
179
+ `the VIC-II chip fetches RAM there, while the CPU's own view (bank 0x0000) returns CIA/VIC/colour-RAM ` +
180
+ `registers instead; this answer read it through the emulator's resolved "${ramBankName}" bank, the chip's own view`
181
+ );
182
+ }
183
+ return null;
184
+ }
185
+
186
+ /**
187
+ * Validates a sprite index as an explicit integer 0..7, accepting a number
188
+ * or its decimal-string form. Deliberately NOT stock-address.ts's
189
+ * parseByteCount() -- that helper refuses 0 outright, and sprite 0 is a
190
+ * valid (and the most commonly inspected) sprite index; refusing it would
191
+ * be a correctness bug wearing a validation costume.
192
+ */
193
+ function parseSpriteIndex(value: unknown, toolName: string): number {
194
+ let parsed: number;
195
+ if (typeof value === "number") {
196
+ parsed = value;
197
+ } else if (typeof value === "string" && /^[0-9]+$/.test(value.trim())) {
198
+ parsed = parseInt(value.trim(), 10);
199
+ } else {
200
+ throw new Error(`${toolName}: sprite index must be an integer 0..7, got ${JSON.stringify(value)}`);
201
+ }
202
+ if (!Number.isInteger(parsed) || parsed < 0 || parsed > 7) {
203
+ throw new Error(`${toolName}: sprite index must be an integer 0..7, got ${JSON.stringify(value)}`);
204
+ }
205
+ return parsed;
206
+ }
207
+
208
+ // ---------------------------------------------------------------------------
209
+ // Shared read helper -- BOTH handleSpriteGet and handleSpriteInspect call
210
+ // this ONE private function for their three common reads (VIC-II block,
211
+ // $DD00, sprite pointer table), so a fourth read can never be added with a
212
+ // different sidefx flag by only one of the two call sites.
213
+ // ---------------------------------------------------------------------------
214
+
215
+ interface SpriteContext {
216
+ ok: true;
217
+ /** The 47-byte $D000-$D02E block, indexable by `address - VICII_BASE`. */
218
+ viciiBytes: Uint8Array;
219
+ dd00: number;
220
+ d018: number;
221
+ bank: number;
222
+ bankBase: number;
223
+ screenBaseAddr: number;
224
+ pointerTableAddress: number;
225
+ /** The 8 pointer-table bytes, one per sprite. */
226
+ pointerBytes: Uint8Array;
227
+ /** The emulator's own resolved `io` bank -- used for every register read
228
+ * ($D000-$D02E, $DD00). */
229
+ ioBank: { id: number; name: string };
230
+ /** The emulator's own resolved `ram` bank -- used for every VIC-fetched
231
+ * read (the sprite pointer table, and each sprite's data block). */
232
+ ramBank: { id: number; name: string };
233
+ /** Non-null spriteWindowNote() results gathered so far (the screen base
234
+ * check only -- per-sprite data-address notes are added by each handler
235
+ * once it knows which sprite(s) it needs, prefixed `sprite N: ` so a note
236
+ * can never be mis-attributed to a sprite the answer did not report:
237
+ * WR-02, 2026-08-17). */
238
+ notes: string[];
239
+ }
240
+
241
+ interface SpriteContextError {
242
+ ok: false;
243
+ result: StockToolResult;
244
+ }
245
+
246
+ type SpriteContextResult = SpriteContext | SpriteContextError;
247
+
248
+ /**
249
+ * Performs the three reads common to both sprite tools: the VIC-II block
250
+ * ($D000-$D02E), $DD00 (VIC bank, one byte, a SEPARATE read because $DD00 is
251
+ * far from $D000-$D02E and a single request spanning both would pull ~3.3 KB
252
+ * of unrelated I/O and RAM across the wire for one byte), and the resolved
253
+ * sprite pointer table (8 bytes at screenBase + $3F8). Every read is
254
+ * sidefx: false. Refuses (before sending) if the resolved pointer-table
255
+ * range would exceed the 16-bit address space.
256
+ *
257
+ * CR-02: the VIC-II block and $DD00 are I/O REGISTERS -- resolved through
258
+ * the emulator's own `io` bank, exactly like stock-vicii.ts/stock-cia.ts.
259
+ * The sprite pointer table is what the VIC-II chip itself FETCHES -- the
260
+ * chip never sees I/O or cartridge ROM there, so it is resolved through the
261
+ * emulator's own `ram` bank instead. Both banks are resolved BEFORE the
262
+ * first send, and this function refuses (with zero MEM_GET sends) if either
263
+ * name is absent from the emulator's own catalog.
264
+ */
265
+ async function readSpriteContext(toolName: string, session: StockConnectSession): Promise<SpriteContextResult> {
266
+ const ioResolution = await resolveRequiredBank(toolName, "io", session);
267
+ if (!ioResolution.ok) {
268
+ return { ok: false, result: ioResolution.result };
269
+ }
270
+ const ramResolution = await resolveRequiredBank(toolName, "ram", session);
271
+ if (!ramResolution.ok) {
272
+ return { ok: false, result: ramResolution.result };
273
+ }
274
+ const ioBank = { id: ioResolution.id, name: ioResolution.name };
275
+ const ramBank = { id: ramResolution.id, name: ramResolution.name };
276
+
277
+ const viciiBody = memGetBody({ sidefx: false, start: VICII_BASE, end: VICII_END, memspace: 0x00, bank: ioBank.id });
278
+ let viciiResponse;
279
+ try {
280
+ viciiResponse = await session.client.send(CommandType.MemoryGet, viciiBody);
281
+ } catch (err) {
282
+ return { ok: false, result: convertWireError(toolName, err) };
283
+ }
284
+ if (viciiResponse.type !== "memory_get") {
285
+ return {
286
+ ok: false,
287
+ result: isErrorText(
288
+ `${toolName}: the binary monitor replied with an unexpected response type ("${viciiResponse.type}"), expected "memory_get"`,
289
+ ),
290
+ };
291
+ }
292
+ if (viciiResponse.bytes.length !== VICII_LENGTH) {
293
+ return {
294
+ ok: false,
295
+ result: isErrorText(
296
+ `${toolName}: expected ${VICII_LENGTH} byte(s) for the VIC-II block, got ${viciiResponse.bytes.length} -- a short read is a wrong answer, not a partial success`,
297
+ ),
298
+ };
299
+ }
300
+
301
+ // $DD00 is far from $D000-$D02E -- a second, small read rather than one
302
+ // wide request spanning both, which would pull ~3.3 KB of unrelated I/O
303
+ // and RAM across the wire for one byte.
304
+ const dd00Body = memGetBody({ sidefx: false, start: CIA2_PORT_A, end: CIA2_PORT_A, memspace: 0x00, bank: ioBank.id });
305
+ let dd00Response;
306
+ try {
307
+ dd00Response = await session.client.send(CommandType.MemoryGet, dd00Body);
308
+ } catch (err) {
309
+ return { ok: false, result: convertWireError(toolName, err) };
310
+ }
311
+ if (dd00Response.type !== "memory_get") {
312
+ return {
313
+ ok: false,
314
+ result: isErrorText(
315
+ `${toolName}: the binary monitor replied with an unexpected response type ("${dd00Response.type}"), expected "memory_get"`,
316
+ ),
317
+ };
318
+ }
319
+ if (dd00Response.bytes.length !== 1) {
320
+ return {
321
+ ok: false,
322
+ result: isErrorText(
323
+ `${toolName}: expected 1 byte(s) for $DD00, got ${dd00Response.bytes.length} -- a short read is a wrong answer, not a partial success`,
324
+ ),
325
+ };
326
+ }
327
+
328
+ const dd00 = dd00Response.bytes[0]!;
329
+ const d018 = viciiResponse.bytes[0xd018 - VICII_BASE]!;
330
+ const bank = vicBank(dd00);
331
+ const bankBase = vicBankBase(dd00);
332
+ const screenBaseAddr = screenBase(d018, dd00);
333
+
334
+ const pointerTableAddress = screenBaseAddr + SPRITE_POINTER_TABLE_OFFSET;
335
+ const pointerTableEnd = pointerTableAddress + 7;
336
+ if (pointerTableEnd > 0xffff) {
337
+ return {
338
+ ok: false,
339
+ result: isErrorText(
340
+ `${toolName}: the resolved sprite pointer table (screenBase 0x${screenBaseAddr.toString(16)} + 0x3f8) ` +
341
+ `would end at 0x${pointerTableEnd.toString(16)}, past the 16-bit address space -- refusing before sending`,
342
+ ),
343
+ };
344
+ }
345
+
346
+ const pointerBody = memGetBody({ sidefx: false, start: pointerTableAddress, end: pointerTableEnd, memspace: 0x00, bank: ramBank.id });
347
+ let pointerResponse;
348
+ try {
349
+ pointerResponse = await session.client.send(CommandType.MemoryGet, pointerBody);
350
+ } catch (err) {
351
+ return { ok: false, result: convertWireError(toolName, err) };
352
+ }
353
+ if (pointerResponse.type !== "memory_get") {
354
+ return {
355
+ ok: false,
356
+ result: isErrorText(
357
+ `${toolName}: the binary monitor replied with an unexpected response type ("${pointerResponse.type}"), expected "memory_get"`,
358
+ ),
359
+ };
360
+ }
361
+ if (pointerResponse.bytes.length !== 8) {
362
+ return {
363
+ ok: false,
364
+ result: isErrorText(
365
+ `${toolName}: expected 8 byte(s) for the sprite pointer table, got ${pointerResponse.bytes.length} -- a short read is a wrong answer, not a partial success`,
366
+ ),
367
+ };
368
+ }
369
+
370
+ const notes: string[] = [];
371
+ const screenNote = spriteWindowNote(screenBaseAddr, bank, ramBank.name);
372
+ if (screenNote !== null && !notes.includes(screenNote)) {
373
+ notes.push(screenNote);
374
+ }
375
+
376
+ return {
377
+ ok: true,
378
+ viciiBytes: viciiResponse.bytes,
379
+ dd00,
380
+ d018,
381
+ bank,
382
+ bankBase,
383
+ screenBaseAddr,
384
+ pointerTableAddress,
385
+ pointerBytes: pointerResponse.bytes,
386
+ ioBank,
387
+ ramBank,
388
+ notes,
389
+ };
390
+ }
391
+
392
+ // ---------------------------------------------------------------------------
393
+ // vice_sprite_get
394
+ // ---------------------------------------------------------------------------
395
+
396
+ export const handleSpriteGet: StockSessionHandler = async (args, session, _deps) => {
397
+ const toolName = "vice_sprite_get";
398
+
399
+ if (!isPlainObject(args)) {
400
+ return isErrorText(`${toolName}: arguments must be an object`);
401
+ }
402
+
403
+ const unexpected = Object.keys(args).filter((key) => key !== "sprite");
404
+ if (unexpected.length > 0) {
405
+ return isErrorText(`${toolName}: unexpected argument(s): ${unexpected.join(", ")} -- the only accepted argument is "sprite"`);
406
+ }
407
+
408
+ let spriteIndex: number | undefined;
409
+ if (args.sprite !== undefined) {
410
+ try {
411
+ spriteIndex = parseSpriteIndex(args.sprite, toolName);
412
+ } catch (err) {
413
+ return isErrorText(err instanceof Error ? err.message : String(err));
414
+ }
415
+ }
416
+
417
+ const context = await readSpriteContext(toolName, session);
418
+ if (!context.ok) {
419
+ return context.result;
420
+ }
421
+
422
+ const bytes = context.viciiBytes;
423
+ const d015 = bytes[0xd015 - VICII_BASE]!;
424
+ const d010 = bytes[0xd010 - VICII_BASE]!;
425
+ const d017 = bytes[0xd017 - VICII_BASE]!;
426
+ const d01b = bytes[0xd01b - VICII_BASE]!;
427
+ const d01c = bytes[0xd01c - VICII_BASE]!;
428
+ const d01d = bytes[0xd01d - VICII_BASE]!;
429
+ const d025 = bytes[0xd025 - VICII_BASE]!;
430
+ const d026 = bytes[0xd026 - VICII_BASE]!;
431
+
432
+ const notes = [...context.notes];
433
+
434
+ const allSprites: Record<string, unknown>[] = [];
435
+ for (let index = 0; index < SPRITE_COUNT; index += 1) {
436
+ const pointer = context.pointerBytes[index]!;
437
+ const dataAddress = spriteDataAddress(context.dd00, pointer);
438
+ allSprites.push({
439
+ index,
440
+ enabled: ((d015 >> index) & 1) === 1,
441
+ x: bytes[index * 2]! | (((d010 >> index) & 1) << 8),
442
+ y: bytes[1 + index * 2]!,
443
+ colour: bytes[0x27 + index]! & 0x0f,
444
+ multicolour: ((d01c >> index) & 1) === 1,
445
+ expandX: ((d01d >> index) & 1) === 1,
446
+ expandY: ((d017 >> index) & 1) === 1,
447
+ priorityBehindBackground: ((d01b >> index) & 1) === 1,
448
+ pointer,
449
+ dataAddress,
450
+ });
451
+ }
452
+
453
+ const sprites = spriteIndex !== undefined ? [allSprites[spriteIndex]!] : allSprites;
454
+
455
+ // WR-02 (2026-08-17): per-sprite hazard notes are computed AFTER the answer
456
+ // is narrowed, for the sprites this answer actually RETURNS, and each note
457
+ // NAMES its sprite. The loop above used to compute them for all eight and
458
+ // push into one unattributed array, so asking about sprite 3 could return a
459
+ // hazard warning that belonged to sprite 5 -- and a caller had no way to
460
+ // tell that from a real hazard on the sprite it asked about. This is the
461
+ // contract SpriteContext.notes already documented; handleSpriteInspect has
462
+ // always honoured it.
463
+ for (const sprite of sprites) {
464
+ const dataNote = spriteWindowNote(sprite.dataAddress as number, context.bank, context.ramBank.name);
465
+ if (dataNote === null) {
466
+ continue;
467
+ }
468
+ const attributed = `sprite ${sprite.index as number}: ${dataNote}`;
469
+ if (!notes.includes(attributed)) {
470
+ notes.push(attributed);
471
+ }
472
+ }
473
+
474
+ const payload: Record<string, unknown> = {
475
+ ...(spriteIndex !== undefined ? { sprite: spriteIndex } : {}),
476
+ vicBank: context.bank,
477
+ vicBankBase: context.bankBase,
478
+ cia2PortARaw: context.dd00,
479
+ memorySetupRaw: context.d018,
480
+ screenBase: context.screenBaseAddr,
481
+ pointerTableAddress: context.pointerTableAddress,
482
+ spriteMulticolour1: d025 & 0x0f,
483
+ spriteMulticolour2: d026 & 0x0f,
484
+ sprites,
485
+ count: sprites.length,
486
+ registerBank: context.ioBank,
487
+ dataBank: context.ramBank,
488
+ notes,
489
+ };
490
+
491
+ return stockAnswer(session.client, payload);
492
+ };
493
+
494
+ // ---------------------------------------------------------------------------
495
+ // Renderers -- mode-independent bit dump (renderSpriteBinary) and the two
496
+ // native-resolution ASCII modes (renderSpriteAscii), per D-05-04: 24 columns
497
+ // for hi-res, 12 for multicolour, always 21 rows. No normalisation, no
498
+ // expansion scaling -- these render the sprite's 63-byte DATA BLOCK, which
499
+ // is fixed-size regardless of how the VIC-II stretches it on screen via the
500
+ // X/Y expansion bits.
501
+ // ---------------------------------------------------------------------------
502
+
503
+ /** Bit-pair value (0..3) -> ASCII legend character. NOT the natural numeric
504
+ * order -- the fork's own legend assigns %10 (2) to the sprite colour ('#')
505
+ * and %01 (1) to multicolour 1 ('@'), so a "simplification" that maps
506
+ * 0,1,2,3 to '.','@','#','%' in a naively-derived order would coincidentally
507
+ * match here, but do not re-derive this table from "the numeric value" --
508
+ * it is the fork's fixed legend, quoted, not computed. */
509
+ const MULTICOLOUR_LEGEND: Record<number, string> = { 0: ".", 1: "@", 2: "#", 3: "%" };
510
+
511
+ /** 21 strings of 24 "0"/"1" characters, MSB first within each byte, three
512
+ * bytes per row. Mode-independent -- this is the raw bit dump. */
513
+ export function renderSpriteBinary(bytes: Uint8Array): string[] {
514
+ if (bytes.length !== SPRITE_DATA_BYTES) {
515
+ throw new Error(`renderSpriteBinary: expected ${SPRITE_DATA_BYTES} bytes, got ${bytes.length}`);
516
+ }
517
+ const rows: string[] = [];
518
+ for (let row = 0; row < SPRITE_ROWS; row += 1) {
519
+ let line = "";
520
+ for (let col = 0; col < 3; col += 1) {
521
+ const byte = bytes[row * 3 + col]!;
522
+ for (let bit = 7; bit >= 0; bit -= 1) {
523
+ line += (byte >> bit) & 1 ? "1" : "0";
524
+ }
525
+ }
526
+ rows.push(line);
527
+ }
528
+ return rows;
529
+ }
530
+
531
+ /**
532
+ * 21 strings, native resolution per mode. Hi-res (multicolour === false): 24
533
+ * characters per row, one per bit, MSB first; a set bit renders '#', a clear
534
+ * bit renders '.'. Multicolour (multicolour === true): 12 characters per
535
+ * row, one per bit PAIR, taken MSB-first in pairs across the three bytes,
536
+ * mapped through MULTICOLOUR_LEGEND.
537
+ */
538
+ export function renderSpriteAscii(bytes: Uint8Array, multicolour: boolean): string[] {
539
+ if (bytes.length !== SPRITE_DATA_BYTES) {
540
+ throw new Error(`renderSpriteAscii: expected ${SPRITE_DATA_BYTES} bytes, got ${bytes.length}`);
541
+ }
542
+ const rows: string[] = [];
543
+ for (let row = 0; row < SPRITE_ROWS; row += 1) {
544
+ let line = "";
545
+ if (!multicolour) {
546
+ for (let col = 0; col < 3; col += 1) {
547
+ const byte = bytes[row * 3 + col]!;
548
+ for (let bit = 7; bit >= 0; bit -= 1) {
549
+ line += (byte >> bit) & 1 ? "#" : ".";
550
+ }
551
+ }
552
+ } else {
553
+ for (let col = 0; col < 3; col += 1) {
554
+ const byte = bytes[row * 3 + col]!;
555
+ for (let pair = 3; pair >= 0; pair -= 1) {
556
+ const value = (byte >> (pair * 2)) & 0b11;
557
+ line += MULTICOLOUR_LEGEND[value];
558
+ }
559
+ }
560
+ }
561
+ rows.push(line);
562
+ }
563
+ return rows;
564
+ }
565
+
566
+ // ---------------------------------------------------------------------------
567
+ // vice_sprite_inspect
568
+ // ---------------------------------------------------------------------------
569
+
570
+ export const handleSpriteInspect: StockSessionHandler = async (args, session, _deps) => {
571
+ const toolName = "vice_sprite_inspect";
572
+
573
+ if (!isPlainObject(args)) {
574
+ return isErrorText(`${toolName}: arguments must be an object`);
575
+ }
576
+
577
+ const unexpected = Object.keys(args).filter((key) => key !== "sprite_number" && key !== "format");
578
+ if (unexpected.length > 0) {
579
+ return isErrorText(
580
+ `${toolName}: unexpected argument(s): ${unexpected.join(", ")} -- the only accepted arguments are "sprite_number" and "format"`,
581
+ );
582
+ }
583
+
584
+ if (args.sprite_number === undefined) {
585
+ return isErrorText(`${toolName}: sprite_number is required`);
586
+ }
587
+ let spriteIndex: number;
588
+ try {
589
+ spriteIndex = parseSpriteIndex(args.sprite_number, toolName);
590
+ } catch (err) {
591
+ return isErrorText(err instanceof Error ? err.message : String(err));
592
+ }
593
+
594
+ // All `format` refusals happen before any wire send.
595
+ let format = "ascii";
596
+ if (args.format !== undefined) {
597
+ if (typeof args.format !== "string") {
598
+ return isErrorText(`${toolName}: format must be a string, got ${typeof args.format}`);
599
+ }
600
+ if (REFUSED_INSPECT_FORMATS.includes(args.format)) {
601
+ return isErrorText(
602
+ `${toolName}: format "png_base64" was cut from this milestone with SHOT-01..SHOT-05 -- no skill calls it. ` +
603
+ `Served formats are: ${SERVED_INSPECT_FORMATS.join(", ")}.`,
604
+ );
605
+ }
606
+ if (!SERVED_INSPECT_FORMATS.includes(args.format)) {
607
+ return isErrorText(
608
+ `${toolName}: format must be one of ${SERVED_INSPECT_FORMATS.join(", ")}, got ${JSON.stringify(args.format)}`,
609
+ );
610
+ }
611
+ format = args.format;
612
+ }
613
+
614
+ const context = await readSpriteContext(toolName, session);
615
+ if (!context.ok) {
616
+ return context.result;
617
+ }
618
+
619
+ const bytes = context.viciiBytes;
620
+ const d015 = bytes[0xd015 - VICII_BASE]!;
621
+ const d010 = bytes[0xd010 - VICII_BASE]!;
622
+ const d017 = bytes[0xd017 - VICII_BASE]!;
623
+ const d01b = bytes[0xd01b - VICII_BASE]!;
624
+ const d01c = bytes[0xd01c - VICII_BASE]!;
625
+ const d01d = bytes[0xd01d - VICII_BASE]!;
626
+ const d025 = bytes[0xd025 - VICII_BASE]!;
627
+ const d026 = bytes[0xd026 - VICII_BASE]!;
628
+
629
+ const pointer = context.pointerBytes[spriteIndex]!;
630
+ const dataAddress = spriteDataAddress(context.dd00, pointer);
631
+ const dataEnd = dataAddress + SPRITE_DATA_BYTES - 1;
632
+ if (dataEnd > 0xffff) {
633
+ return isErrorText(
634
+ `${toolName}: sprite ${spriteIndex}'s resolved data address (pointer 0x${pointer.toString(16)} -> ` +
635
+ `0x${dataAddress.toString(16)}) would end at 0x${dataEnd.toString(16)}, past the 16-bit address space -- refusing before sending`,
636
+ );
637
+ }
638
+
639
+ const dataBody = memGetBody({ sidefx: false, start: dataAddress, end: dataEnd, memspace: 0x00, bank: context.ramBank.id });
640
+ let dataResponse;
641
+ try {
642
+ dataResponse = await session.client.send(CommandType.MemoryGet, dataBody);
643
+ } catch (err) {
644
+ return convertWireError(toolName, err);
645
+ }
646
+ if (dataResponse.type !== "memory_get") {
647
+ return isErrorText(
648
+ `${toolName}: the binary monitor replied with an unexpected response type ("${dataResponse.type}"), expected "memory_get"`,
649
+ );
650
+ }
651
+ if (dataResponse.bytes.length !== SPRITE_DATA_BYTES) {
652
+ return isErrorText(
653
+ `${toolName}: expected ${SPRITE_DATA_BYTES} byte(s) for the sprite data block, got ${dataResponse.bytes.length} -- a short read is a wrong answer, not a partial success`,
654
+ );
655
+ }
656
+
657
+ const multicolour = ((d01c >> spriteIndex) & 1) === 1;
658
+ const expandX = ((d01d >> spriteIndex) & 1) === 1;
659
+ const expandY = ((d017 >> spriteIndex) & 1) === 1;
660
+
661
+ const notes = [...context.notes];
662
+ const dataNote = spriteWindowNote(dataAddress, context.bank, context.ramBank.name);
663
+ if (dataNote !== null) {
664
+ // WR-02: same attributed form handleSpriteGet emits, so the two tools'
665
+ // notes for one sprite are the same string rather than two spellings.
666
+ const attributed = `sprite ${spriteIndex}: ${dataNote}`;
667
+ if (!notes.includes(attributed)) {
668
+ notes.push(attributed);
669
+ }
670
+ }
671
+ if (expandX || expandY) {
672
+ notes.push(
673
+ "the rendered grid is the sprite's 24x21 data block and is NOT scaled by the X/Y expansion bits -- " +
674
+ "the VIC-II stretches the sprite on screen, but MEM_GET returns the unscaled 63-byte block",
675
+ );
676
+ }
677
+
678
+ const rows = format === "binary" ? renderSpriteBinary(dataResponse.bytes) : renderSpriteAscii(dataResponse.bytes, multicolour);
679
+ const width = multicolour ? SPRITE_MULTICOLOUR_COLUMNS : SPRITE_HIRES_COLUMNS;
680
+
681
+ const payload: Record<string, unknown> = {
682
+ sprite: spriteIndex,
683
+ format,
684
+ multicolour,
685
+ enabled: ((d015 >> spriteIndex) & 1) === 1,
686
+ x: bytes[spriteIndex * 2]! | (((d010 >> spriteIndex) & 1) << 8),
687
+ y: bytes[1 + spriteIndex * 2]!,
688
+ colour: bytes[0x27 + spriteIndex]! & 0x0f,
689
+ expandX,
690
+ expandY,
691
+ priorityBehindBackground: ((d01b >> spriteIndex) & 1) === 1,
692
+ spriteMulticolour1: d025 & 0x0f,
693
+ spriteMulticolour2: d026 & 0x0f,
694
+ vicBank: context.bank,
695
+ registerBank: context.ioBank,
696
+ dataBank: context.ramBank,
697
+ pointer,
698
+ dataAddress,
699
+ width,
700
+ height: SPRITE_ROWS,
701
+ bytes: Array.from(dataResponse.bytes),
702
+ rows,
703
+ // CR-02: the legend must match THIS render's own alphabet -- attaching
704
+ // the multicolour legend to a hi-res render told an agent that '@' and
705
+ // '%' exist in a grid that never emits them, and that '#' meant a
706
+ // two-bit code when it is really a single set bit here.
707
+ ...(format === "ascii" ? { ascii: rows.join("\n"), legend: multicolour ? SPRITE_ASCII_LEGEND_MULTICOLOUR : SPRITE_ASCII_LEGEND_HIRES } : {}),
708
+ notes,
709
+ };
710
+
711
+ return stockAnswer(session.client, payload);
712
+ };