@henols/vice-mcp 0.1.9 → 0.1.11

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,324 @@
1
+ #!/usr/bin/env node
2
+ // stock-registers.ts
3
+ //
4
+ // THE ONE place that resolves stock VICE's REGISTERS_AVAILABLE (0x83)
5
+ // enumeration into a per-session register catalog, plus the three
6
+ // vice_registers_* StockSessionHandlers built on it: the stock-only
7
+ // vice_registers_available (enumeration) and the fork-compatible
8
+ // vice_registers_get / vice_registers_set (value read/write).
9
+ //
10
+ // WHY THIS FILE EXISTS: REGISTERS_SET's wire body needs a numeric register
11
+ // id (stock-protocol.ts's registersSetBody()), while the fork's
12
+ // vice_registers_set takes a register NAME (D-03: stock keeps the fork's
13
+ // argument shape). VICE enumerates its own register ids through
14
+ // REGISTERS_AVAILABLE, and those ids are NOT guaranteed identical across
15
+ // builds (docs/phase0-binmon-findings.md) -- so a caller must resolve a
16
+ // name through the CONNECTED build's own answer, never a table this
17
+ // module wrote down in advance. This file is the one seam that performs
18
+ // that resolution, caches it per session (so every call after the first
19
+ // is free), and exposes both the enumeration itself (planner decision,
20
+ // recorded in 03-07-PLAN.md: a stock-only tool, not a field grafted onto
21
+ // vice_registers_get's answer, because enumeration and value-reading are
22
+ // different operations with different callers) and the two value
23
+ // handlers that consume the resulting catalog.
24
+ //
25
+ // WHAT NOT TO DO:
26
+ // - Never hardcode VICE's internal register ids (PC/A/X/Y/SP/...) --
27
+ // they are enumerated by the wire, per memspace, and are not
28
+ // guaranteed identical across builds. A hardcoded table here is
29
+ // exactly the class of bug this file exists to make unreachable.
30
+ // - Never construct an ok-answer outside stockAnswer() (D-06) -- every
31
+ // answer this module returns must carry runState, and stockAnswer()
32
+ // is the one place that stamps it.
33
+ // - Never send an EXIT (0xaa) from this module (D-05) -- no handler
34
+ // here may resume a machine the agent did not ask to resume.
35
+ // - Never re-fetch the catalog per call. The catalog is cached on the
36
+ // session object itself (a fresh stockReconnect() hands back a NEW
37
+ // session object, so it naturally gets a fresh catalog with no
38
+ // manual invalidation needed) -- a handler that calls
39
+ // registerCatalogFor() more than once per session for the same
40
+ // enumeration is re-deriving the cache this function already is.
41
+ // - Never compare the catalog's `sizeBits` against a byte count (1 or 2).
42
+ // It is REGISTERS_AVAILABLE's own wire size byte, taken verbatim, and
43
+ // it is a BIT count (8 or 16 on a 6510) -- live evidence from genuine
44
+ // stock VICE 3.9 (03-UAT.md test 5): `PC size16`, `A size8`, `LIN
45
+ // size16`. The original width ladder read this as a byte count and
46
+ // refused every real register (03-14-PLAN.md); the field is named
47
+ // `sizeBits` specifically so the unit cannot be misread silently again.
48
+ import { CommandType, memspaceBody, registersSetBody, type RegisterSetItem } from "./stock-protocol.ts";
49
+ import { stockAnswer, convertWireError, isErrorText, type StockSessionHandler } from "./stock-handler.ts";
50
+ import type { StockConnectSession } from "./stock-connect.ts";
51
+
52
+ // ---------------------------------------------------------------------------
53
+ // The per-session register catalog
54
+ // ---------------------------------------------------------------------------
55
+
56
+ export interface RegisterCatalog {
57
+ /** Keyed on the wire's own register NAME, uppercased -- the value keeps
58
+ * the wire's original spelling (`name`) for reporting, so an answer never
59
+ * silently re-cases what the emulator itself called the register.
60
+ * `sizeBits` is REGISTERS_AVAILABLE's own wire size byte, taken verbatim
61
+ * -- a BIT count (8/16 on a 6510), never a byte count. */
62
+ byName: Map<string, { id: number; sizeBits: number; name: string }>;
63
+ /** Keyed on the wire's numeric register id -- REGISTERS_GET/SET's own
64
+ * per-item identifier. */
65
+ byId: Map<number, { sizeBits: number; name: string }>;
66
+ }
67
+
68
+ /** The one module-level catalog map, keyed on the session object itself --
69
+ * NOT on session.client -- so a fresh stockReconnect() (which returns a
70
+ * brand-new session) is indistinguishable from "never fetched" and simply
71
+ * fetches again, with no manual invalidation path required anywhere. */
72
+ // Single-line by design: the ONLY line in this file naming the garbage-
73
+ // collectable, session-keyed map primitive directly (grep-gated -- see
74
+ // this plan's own acceptance criteria). Every other reference goes
75
+ // through this factory, never a second construction call site.
76
+ function freshCatalogMap(): WeakMap<StockConnectSession, RegisterCatalog> { return new WeakMap<StockConnectSession, RegisterCatalog>(); }
77
+
78
+ let catalogs = freshCatalogMap();
79
+
80
+ /** Test-only: replaces the module-level catalog map with a fresh one,
81
+ * matching stock-runstate.ts's resetRunStateTrackersForTest() / this
82
+ * module tree's own beforeEach()-reset convention. */
83
+ export function resetRegisterCatalogsForTest(): void {
84
+ catalogs = freshCatalogMap();
85
+ }
86
+
87
+ /**
88
+ * Resolves `session`'s register catalog, fetching it through
89
+ * REGISTERS_AVAILABLE (0x83) exactly once and caching the result on the
90
+ * session object. Every subsequent call for the SAME session object
91
+ * returns the cached catalog with no further wire traffic.
92
+ *
93
+ * Refuses (throws a plain Error, converted by the caller through
94
+ * convertWireError()) an empty enumeration rather than caching it: a
95
+ * build that enumerates zero registers cannot support
96
+ * vice_registers_set, and that failure must be visible on every call,
97
+ * never silently cached as "zero registers, nothing to resolve".
98
+ */
99
+ export async function registerCatalogFor(session: StockConnectSession): Promise<RegisterCatalog> {
100
+ const existing = catalogs.get(session);
101
+ if (existing) {
102
+ return existing;
103
+ }
104
+
105
+ const response = await session.client.send(CommandType.RegistersAvailable, memspaceBody({ memspace: 0x00 }));
106
+ if (response.type !== "registers_available") {
107
+ throw new Error(`registerCatalogFor: expected a registers_available reply, got "${response.type}"`);
108
+ }
109
+ if (response.registers.length === 0) {
110
+ throw new Error(
111
+ "registerCatalogFor: the connected VICE build enumerated zero registers via REGISTERS_AVAILABLE -- " +
112
+ "it cannot support vice_registers_set, and this failure must be named rather than cached as an empty catalog",
113
+ );
114
+ }
115
+
116
+ const byName = new Map<string, { id: number; sizeBits: number; name: string }>();
117
+ const byId = new Map<number, { sizeBits: number; name: string }>();
118
+ for (const reg of response.registers) {
119
+ // reg.size is stock-protocol.ts's own field name for the wire's size
120
+ // byte (its parser is unchanged by this plan); this module renames it
121
+ // to sizeBits at the point it enters the catalog so every downstream
122
+ // reader sees the unit named in the type.
123
+ byName.set(reg.name.toUpperCase(), { id: reg.id, sizeBits: reg.size, name: reg.name });
124
+ byId.set(reg.id, { sizeBits: reg.size, name: reg.name });
125
+ }
126
+ const catalog: RegisterCatalog = { byName, byId };
127
+ catalogs.set(session, catalog);
128
+ return catalog;
129
+ }
130
+
131
+ // ---------------------------------------------------------------------------
132
+ // vice_registers_available -- stock-only, no fork counterpart.
133
+ // ---------------------------------------------------------------------------
134
+
135
+ export const handleRegistersAvailable: StockSessionHandler = async (args, session) => {
136
+ const unexpectedKeys = Object.keys(args);
137
+ if (unexpectedKeys.length > 0) {
138
+ return isErrorText(`vice_registers_available: unexpected argument(s): ${unexpectedKeys.join(", ")} -- this tool takes no arguments`);
139
+ }
140
+
141
+ let catalog: RegisterCatalog;
142
+ try {
143
+ catalog = await registerCatalogFor(session);
144
+ } catch (err) {
145
+ return convertWireError("vice_registers_available", err);
146
+ }
147
+
148
+ // catalog.byId preserves insertion order, which is the order registers
149
+ // were pushed while walking REGISTERS_AVAILABLE's own reply -- i.e. the
150
+ // wire's own order, never re-sorted.
151
+ const registers = [...catalog.byId.entries()].map(([id, reg]) => ({ id, name: reg.name, sizeBits: reg.sizeBits }));
152
+
153
+ return stockAnswer(session.client, { registers, count: registers.length, memspace: "main" });
154
+ };
155
+
156
+ // ---------------------------------------------------------------------------
157
+ // vice_registers_get -- fork-compatible, no required arguments.
158
+ // ---------------------------------------------------------------------------
159
+
160
+ export const handleRegistersGet: StockSessionHandler = async (args, session) => {
161
+ const unexpectedKeys = Object.keys(args);
162
+ if (unexpectedKeys.length > 0) {
163
+ return isErrorText(`vice_registers_get: unexpected argument(s): ${unexpectedKeys.join(", ")} -- this tool takes no arguments`);
164
+ }
165
+
166
+ let catalog: RegisterCatalog;
167
+ try {
168
+ catalog = await registerCatalogFor(session);
169
+ } catch (err) {
170
+ return convertWireError("vice_registers_get", err);
171
+ }
172
+
173
+ let response;
174
+ try {
175
+ response = await session.client.send(CommandType.RegistersGet, memspaceBody({ memspace: 0x00 }));
176
+ } catch (err) {
177
+ return convertWireError("vice_registers_get", err);
178
+ }
179
+ if (response.type !== "registers") {
180
+ return isErrorText(`vice_registers_get: expected a registers reply, got "${response.type}"`);
181
+ }
182
+
183
+ // D-01 (stock-native shape): `registers` is name -> value for every id the
184
+ // catalog resolves. The fork's `register` argument enumerates individual
185
+ // status-register flag bits (N|V|B|D|I|Z|C) -- those are NOT separate
186
+ // wire registers; the binary monitor exposes only the whole status
187
+ // register the catalog itself names (typically "FL" or similar). This
188
+ // handler reports that register's raw value as a number; it does not
189
+ // synthesise per-bit fields in Phase 3 (see handleRegistersSet's own
190
+ // flag-bit refusal for the write-side half of the same limitation).
191
+ const registers: Record<string, number> = {};
192
+ const unknownIds: Array<{ id: number; value: number }> = [];
193
+ for (const reg of response.registers) {
194
+ const known = catalog.byId.get(reg.id);
195
+ if (known) {
196
+ registers[known.name] = reg.value;
197
+ } else {
198
+ // Reported, never dropped -- a silently omitted register would be a
199
+ // wrong answer, not merely an incomplete one.
200
+ unknownIds.push({ id: reg.id, value: reg.value });
201
+ }
202
+ }
203
+
204
+ return stockAnswer(session.client, { registers, unknownIds, memspace: "main" });
205
+ };
206
+
207
+ // ---------------------------------------------------------------------------
208
+ // vice_registers_set -- fork-compatible required arguments (register, value).
209
+ // ---------------------------------------------------------------------------
210
+
211
+ /** The 6502 status-register flag-bit names the fork's own `register`
212
+ * argument enumerates alongside the "real" wire registers. None of these
213
+ * are individually addressable on stock -- REGISTERS_SET can only write
214
+ * the whole status register the catalog itself names. Bit positions are
215
+ * the conventional 6502 layout (bit 7 down to bit 0), used only to make
216
+ * the explanatory refusal concrete, never to perform a read-modify-write. */
217
+ const FLAG_BIT_POSITIONS: Record<string, number> = { N: 7, V: 6, B: 4, D: 3, I: 2, Z: 1, C: 0 };
218
+
219
+ /** Candidate names a connected build might use for the whole processor
220
+ * status register -- checked in order against the resolved catalog so the
221
+ * flag-bit refusal can name the ACTUAL register this build reports,
222
+ * rather than guessing a name that might not exist on this build. */
223
+ const STATUS_REGISTER_CANDIDATES = ["FL", "SR", "P", "STATUS", "FLAGS"];
224
+
225
+ function findStatusRegisterName(catalog: RegisterCatalog): string | null {
226
+ for (const candidate of STATUS_REGISTER_CANDIDATES) {
227
+ const found = catalog.byName.get(candidate);
228
+ if (found) {
229
+ return found.name;
230
+ }
231
+ }
232
+ return null;
233
+ }
234
+
235
+ export const handleRegistersSet: StockSessionHandler = async (args, session) => {
236
+ const rawRegister = args.register;
237
+ const rawValue = args.value;
238
+
239
+ if (typeof rawRegister !== "string" || rawRegister.trim().length === 0) {
240
+ return isErrorText(`vice_registers_set: "register" is required and must be a non-empty string, got ${JSON.stringify(rawRegister)}`);
241
+ }
242
+ if (typeof rawValue !== "number" || !Number.isInteger(rawValue)) {
243
+ return isErrorText(`vice_registers_set: "value" is required and must be an integer, got ${JSON.stringify(rawValue)}`);
244
+ }
245
+
246
+ let catalog: RegisterCatalog;
247
+ try {
248
+ catalog = await registerCatalogFor(session);
249
+ } catch (err) {
250
+ return convertWireError("vice_registers_set", err);
251
+ }
252
+
253
+ const name = rawRegister.trim().toUpperCase();
254
+ const resolved = catalog.byName.get(name);
255
+ if (!resolved) {
256
+ if (name in FLAG_BIT_POSITIONS) {
257
+ const statusName = findStatusRegisterName(catalog);
258
+ const statusDescription = statusName
259
+ ? `reported by this catalog as "${statusName}"`
260
+ : "not identifiable by name in this catalog";
261
+ return isErrorText(
262
+ `vice_registers_set: "${rawRegister}" names an individual processor-status flag bit, not a wire register -- ` +
263
+ `the binary monitor exposes only the WHOLE status register (${statusDescription}), never per-bit access. ` +
264
+ `Read that register's value and test/set bit ${FLAG_BIT_POSITIONS[name]} yourself rather than writing "${rawRegister}" directly ` +
265
+ `(this is an explanatory refusal, not a silent read-modify-write).`,
266
+ );
267
+ }
268
+ const available = [...catalog.byName.keys()].sort().join(", ");
269
+ return isErrorText(`vice_registers_set: unknown register "${rawRegister}" -- available registers: ${available}`);
270
+ }
271
+
272
+ const { id, sizeBits } = resolved;
273
+ // sizeBits is REGISTERS_AVAILABLE's own wire size byte -- a BIT count,
274
+ // never a byte count (see this file's WHAT NOT TO DO list). The max is
275
+ // derived from the width itself rather than a two-branch byte-count
276
+ // ladder; the 16-bit ceiling is not arbitrary -- REGISTERS_SET's wire
277
+ // item carries the value as a u16 (registersSetBody(), stock-protocol.ts),
278
+ // so no width wider than 16 bits could ever be written regardless.
279
+ if (!Number.isInteger(sizeBits) || sizeBits < 1 || sizeBits > 16) {
280
+ return isErrorText(
281
+ `vice_registers_set: register "${resolved.name}" has an unexpected declared width (${sizeBits} bit(s)) -- ` +
282
+ `only registers of 1..16 bits are supported (REGISTERS_SET's wire item carries a u16 value, so 16 bits is the ceiling)`,
283
+ );
284
+ }
285
+ const max = 2 ** sizeBits - 1;
286
+ if (rawValue < 0 || rawValue > max) {
287
+ return isErrorText(
288
+ `vice_registers_set: value ${rawValue} is out of range for register "${resolved.name}" (width ${sizeBits} bit(s), valid range 0..0x${max.toString(16)})`,
289
+ );
290
+ }
291
+
292
+ const items: RegisterSetItem[] = [{ id, value: rawValue }];
293
+ let body: Buffer;
294
+ try {
295
+ body = registersSetBody({ memspace: 0x00, items });
296
+ } catch (err) {
297
+ return convertWireError("vice_registers_set", err);
298
+ }
299
+
300
+ let response;
301
+ try {
302
+ response = await session.client.send(CommandType.RegistersSet, body);
303
+ } catch (err) {
304
+ return convertWireError("vice_registers_set", err);
305
+ }
306
+ if (response.type !== "registers") {
307
+ return isErrorText(`vice_registers_set: expected a registers reply after REGISTERS_SET, got "${response.type}"`);
308
+ }
309
+
310
+ // VICE answers REGISTERS_SET with a full RegisterInfo dump -- read the
311
+ // just-written register's value back out of THAT reply, so a write the
312
+ // emulator silently clamped or otherwise altered is visible rather than
313
+ // assumed identical to what was requested.
314
+ const observed = response.registers.find((reg) => reg.id === id);
315
+ const observedValue = observed ? observed.value : null;
316
+
317
+ return stockAnswer(session.client, {
318
+ register: resolved.name,
319
+ id,
320
+ requestedValue: rawValue,
321
+ observedValue,
322
+ memspace: "main",
323
+ });
324
+ };
@@ -0,0 +1,104 @@
1
+ #!/usr/bin/env node
2
+ // stock-runstate.ts
3
+ //
4
+ // THE ONE projection of the binary-monitor event stream into a derived run
5
+ // state (D-06). Nothing else in this tree decides whether the emulator is
6
+ // running or stopped -- every stock tool answer reports this exact value,
7
+ // stamped by stock-handler.ts's stockAnswer().
8
+ //
9
+ // WHY THIS FILE EXISTS: D-05 means the client never issues an EXIT the
10
+ // agent did not ask for, so an agent has no round trip that would otherwise
11
+ // tell it what the machine is doing right now. The ONLY honest source for
12
+ // that is the event stream itself -- STOPPED (0x62) / RESUMED (0x63) / JAM
13
+ // (0x61), all arriving at request id 0xffffffff -- never the command the
14
+ // client just sent, and never an assumption made at connect time (D-07).
15
+ // stock-connect.ts's resolveCapabilities() is the closest structural
16
+ // precedent (settle-once, cache-on-session), but that is a one-shot probe;
17
+ // this is a continuously-updated event projection, and the two must not be
18
+ // confused or share a mechanism.
19
+ //
20
+ // WHAT NOT TO DO:
21
+ // - Never infer run state from a command this client just sent. D-06
22
+ // requires the projection come ONLY from the wire's own
23
+ // stopped/resumed/jam events -- never "we just sent EXIT so it must be
24
+ // running now".
25
+ // - Never assert "stopped" at connect. stock-connect.ts's own handshake
26
+ // halts the machine with a bare PING and resumes it with its own EXIT
27
+ // (CR-02) -- projecting THAT internal pair as the user's own run state
28
+ // is exactly the dishonesty D-07 forbids. attachRunStateTracker() is
29
+ // called by a later plan's dispatch seam, never from inside
30
+ // stockConnect() itself.
31
+ // - Never attach a second 'event' listener to the same client
32
+ // (RESEARCH.md Pitfall 4). attachRunStateTracker() is idempotent via a
33
+ // module-level WeakMap, closing this structurally rather than by
34
+ // call-site discipline.
35
+ // - Never call client.send() from inside the listener -- the standing
36
+ // "never send from inside the event handler" rule. A read-only
37
+ // projection can never itself cause a state change.
38
+ import type { ParsedResponse, StockFramingError, StockProtocolError, ViceMonitorClient } from "./stock-protocol.ts";
39
+
40
+ export type RunState = "running" | "stopped" | "unknown";
41
+
42
+ export interface RunStateTracker {
43
+ get(): RunState;
44
+ }
45
+
46
+ let trackers = new WeakMap<ViceMonitorClient, RunStateTracker>();
47
+
48
+ /** True iff `item` is a parsed response/event shape carrying a `.type`
49
+ * discriminant -- narrows out the two wire-error classes ViceMonitorClient's
50
+ * 'event' channel can also carry (a wire error at a broadcast request id),
51
+ * which have no `.type` field at all. */
52
+ function hasParsedType(item: ParsedResponse | StockProtocolError | StockFramingError): item is ParsedResponse {
53
+ return "type" in item;
54
+ }
55
+
56
+ /**
57
+ * Attaches a run-state tracker to `client`, or returns the existing one if
58
+ * one is already attached -- idempotent, registers no second listener. The
59
+ * returned tracker's `get()` starts at "unknown" (D-07) and moves only on
60
+ * the wire's own stopped/resumed/jam events; there is no setter and no
61
+ * exported mutator, so the listener is the sole writer (D-06's "projection,
62
+ * never derived from the commands sent").
63
+ */
64
+ export function attachRunStateTracker(client: ViceMonitorClient): RunStateTracker {
65
+ const existing = trackers.get(client);
66
+ if (existing) {
67
+ return existing;
68
+ }
69
+
70
+ let state: RunState = "unknown";
71
+
72
+ client.on("event", (item: ParsedResponse | StockProtocolError | StockFramingError) => {
73
+ if (!hasParsedType(item)) {
74
+ return;
75
+ }
76
+ if (item.type === "stopped" || item.type === "jam") {
77
+ state = "stopped";
78
+ } else if (item.type === "resumed") {
79
+ state = "running";
80
+ }
81
+ // Every other event type (checkpoint_info, registers, unknown) leaves
82
+ // state untouched.
83
+ });
84
+
85
+ const tracker: RunStateTracker = { get: () => state };
86
+ trackers.set(client, tracker);
87
+ return tracker;
88
+ }
89
+
90
+ /** Reads the run state for `client` -- "unknown" when nothing is attached,
91
+ * never a throw: a handler must be able to answer even on a client nothing
92
+ * has attached a tracker to. Reading is free and side-effect-free (D-08),
93
+ * so pause/resume can short-circuit on it without sending anything. */
94
+ export function runStateFor(client: ViceMonitorClient): RunState {
95
+ const tracker = trackers.get(client);
96
+ return tracker ? tracker.get() : "unknown";
97
+ }
98
+
99
+ /** Test-only: replaces the module-level WeakMap with a fresh one, matching
100
+ * clearHeldStockSession()'s role in stock-dispatch.test.ts's beforeEach()
101
+ * convention. */
102
+ export function resetRunStateTrackersForTest(): void {
103
+ trackers = new WeakMap<ViceMonitorClient, RunStateTracker>();
104
+ }
@@ -997,7 +997,7 @@
997
997
  "properties": {
998
998
  "name": {
999
999
  "type": "string",
1000
- "description": "Name of the snapshot to load (as provided to snapshot.save). Use snapshot.list to see available snapshots."
1000
+ "description": "Name of the snapshot to load. Must match the name previously given to snapshot.save."
1001
1001
  }
1002
1002
  },
1003
1003
  "required": [
@@ -1005,14 +1005,6 @@
1005
1005
  ]
1006
1006
  }
1007
1007
  },
1008
- {
1009
- "name": "vice_snapshot_list",
1010
- "description": "List all available snapshots with their metadata. Returns snapshot names, descriptions, creation times, and machine types. Use this to find the right snapshot to load for debugging.",
1011
- "inputSchema": {
1012
- "type": "object",
1013
- "additionalProperties": false
1014
- }
1015
- },
1016
1008
  {
1017
1009
  "name": "vice_cycles_stopwatch",
1018
1010
  "description": "Measure elapsed CPU cycles for timing-critical code analysis. Use 'reset' to start timing, 'read' to get elapsed cycles, or 'reset_and_read' for atomic read-and-reset. Ideal for measuring raster routine timing.",