@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-sprites.ts
ADDED
|
@@ -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
|
+
};
|