@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.
- package/README.md +15 -0
- package/THIRD-PARTY-NOTICES.md +113 -0
- package/backend-detect.mts +595 -0
- package/build.ts +1 -0
- package/disasm-decoder.ts +248 -0
- package/disasm-opcodes.ts +464 -0
- package/disasm-renderer.ts +306 -0
- package/package.json +27 -2
- package/refresh-manifest.ts +23 -2
- package/resources/backend-detect.mjs +396 -0
- package/resources/broker-control.mjs +110 -8
- package/resources/broker-kill.mjs +114 -103
- package/resources/broker-launch.mjs +334 -19
- package/resources/broker-state.mjs +11 -0
- package/resources/vice-broker.mjs +185 -14
- package/stock-address.ts +219 -0
- package/stock-checkpoints.ts +794 -0
- package/stock-condition.ts +636 -0
- package/stock-connect.ts +427 -0
- package/stock-derived.ts +122 -0
- package/stock-disassemble.ts +252 -0
- package/stock-dispatch.ts +640 -0
- package/stock-execution.ts +327 -0
- package/stock-handler.ts +175 -0
- package/stock-input.ts +274 -0
- package/stock-machine.ts +357 -0
- package/stock-memory.ts +323 -0
- package/stock-paths.ts +191 -0
- package/stock-petscii.ts +143 -0
- package/stock-protocol.ts +2057 -0
- package/stock-registers.ts +324 -0
- package/stock-runstate.ts +104 -0
- package/tools-manifest.json +1 -9
- package/tools-manifest.stock.json +841 -0
- package/vice-broker-client.ts +233 -7
- package/vice-proxy.ts +202 -17
|
@@ -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
|
+
}
|
package/tools-manifest.json
CHANGED
|
@@ -997,7 +997,7 @@
|
|
|
997
997
|
"properties": {
|
|
998
998
|
"name": {
|
|
999
999
|
"type": "string",
|
|
1000
|
-
"description": "Name of the snapshot to load
|
|
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.",
|