@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
package/stock-input.ts
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// stock-input.ts
|
|
3
|
+
//
|
|
4
|
+
// THE keyboard and joystick handlers for Family D: vice_keyboard_type,
|
|
5
|
+
// vice_keyboard_petscii, and vice_joystick_set. Ships the input half of the
|
|
6
|
+
// tool families this milestone builds on stock VICE's binary monitor.
|
|
7
|
+
//
|
|
8
|
+
// WHY THIS FILE EXISTS: KEYBOARD_FEED (0x72) and JOYPORT_SET (0xa2) are
|
|
9
|
+
// thin wire opcodes -- the argument validation, ASCII->PETSCII conversion
|
|
10
|
+
// routing, and the composed-value bookkeeping that make them safe and
|
|
11
|
+
// legible to an agent all have to live somewhere. This is that somewhere:
|
|
12
|
+
// every handler here follows the shared StockSessionHandler contract
|
|
13
|
+
// (stock-handler.ts), builds its wire body through stock-protocol.ts's
|
|
14
|
+
// encoders only, and answers through stockAnswer() so D-06's runState stamp
|
|
15
|
+
// is never missed.
|
|
16
|
+
//
|
|
17
|
+
// WHAT NOT TO DO:
|
|
18
|
+
// - Never convert text to PETSCII inline. stock-petscii.ts's
|
|
19
|
+
// asciiToPetscii() is the ONE conversion path -- a second hand-rolled
|
|
20
|
+
// version here is exactly the failure mode that module's own header
|
|
21
|
+
// comment warns about.
|
|
22
|
+
// - Never send an EXIT so the queued keyboard buffer gets consumed.
|
|
23
|
+
// D-05 is absolute: this client never issues a resume the agent did not
|
|
24
|
+
// explicitly ask for. The answer says the machine is halted; the agent
|
|
25
|
+
// resumes explicitly, on its own schedule.
|
|
26
|
+
// - Never add vice_joystick_tap. A tap needs the machine to RUN for a
|
|
27
|
+
// measured interval -- an unrequested EXIT (forbidden by D-05) plus a
|
|
28
|
+
// frame/cycle measurement that does not exist on stock until Phase 7's
|
|
29
|
+
// timing route lands (docs/stock-vice-parity.md section A item 7).
|
|
30
|
+
// vice_joystick_set (hold/release/centre) satisfies DIRECT-07's
|
|
31
|
+
// joystick half in the meantime.
|
|
32
|
+
// - Never construct an ok-answer outside stockAnswer(). Every successful
|
|
33
|
+
// result below is built through it, never a bare
|
|
34
|
+
// `{ content: [...], isError: false }` literal.
|
|
35
|
+
import { CommandType, joyportSetBody, keyboardFeedBody } from "./stock-protocol.ts";
|
|
36
|
+
import { asciiToPetscii, StockPetsciiError } from "./stock-petscii.ts";
|
|
37
|
+
import { convertWireError, isErrorText, stockAnswer, type StockSessionHandler } from "./stock-handler.ts";
|
|
38
|
+
|
|
39
|
+
/** True iff `value` is a well-formed, generic JSON object -- not null, not
|
|
40
|
+
* an array. Matches vice.ts's own isPlainObject() predicate exactly -- the
|
|
41
|
+
* same narrowing discipline this module tree uses everywhere a parsed JSON
|
|
42
|
+
* value's fields are touched. Declared privately per this codebase's own
|
|
43
|
+
* convention (re-declared per consuming module, never imported). */
|
|
44
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
45
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
// vice_keyboard_type / vice_keyboard_petscii
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
/** Halted-machine note every keyboard answer carries -- docs/stock-vice-parity.md
|
|
53
|
+
* section A item 7's recorded divergence: input lands in the buffer of a
|
|
54
|
+
* machine now halted (D-05: any command halts it), and nothing consumes the
|
|
55
|
+
* buffer until the agent explicitly resumes. */
|
|
56
|
+
const KEYBOARD_HALTED_NOTE =
|
|
57
|
+
"Bytes are queued in the KERNAL keyboard buffer -- nothing consumes them until the machine runs. " +
|
|
58
|
+
"This client never issues an unrequested resume (D-05); resume explicitly to have the buffer read.";
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* vice_keyboard_type -- types `text` (converted to PETSCII via
|
|
62
|
+
* stock-petscii.ts's asciiToPetscii()) into the keyboard buffer.
|
|
63
|
+
* Arguments: `text` (required string), `petscii_upper` (optional boolean,
|
|
64
|
+
* default true) -- the fork's exact argument names, including the
|
|
65
|
+
* snake_case `petscii_upper`.
|
|
66
|
+
*/
|
|
67
|
+
export const handleKeyboardType: StockSessionHandler = async (args, session) => {
|
|
68
|
+
if (!isPlainObject(args)) {
|
|
69
|
+
return isErrorText("vice_keyboard_type: arguments must be an object");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const { text, petscii_upper: petsciiUpperArg } = args;
|
|
73
|
+
if (typeof text !== "string") {
|
|
74
|
+
return isErrorText("vice_keyboard_type: text is required and must be a string");
|
|
75
|
+
}
|
|
76
|
+
if (petsciiUpperArg !== undefined && typeof petsciiUpperArg !== "boolean") {
|
|
77
|
+
return isErrorText("vice_keyboard_type: petscii_upper must be a boolean");
|
|
78
|
+
}
|
|
79
|
+
const petsciiUpper = petsciiUpperArg === undefined ? true : petsciiUpperArg;
|
|
80
|
+
|
|
81
|
+
let petscii: Buffer;
|
|
82
|
+
try {
|
|
83
|
+
petscii = asciiToPetscii(text, { upper: petsciiUpper });
|
|
84
|
+
} catch (err) {
|
|
85
|
+
// A StockPetsciiError's own message is returned VERBATIM -- never
|
|
86
|
+
// re-worded, and never a fallback to sending the raw, unconverted
|
|
87
|
+
// bytes instead.
|
|
88
|
+
if (err instanceof StockPetsciiError) {
|
|
89
|
+
return isErrorText(err.message);
|
|
90
|
+
}
|
|
91
|
+
throw err;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const body = keyboardFeedBody({ petscii });
|
|
95
|
+
try {
|
|
96
|
+
await session.client.send(CommandType.KeyboardFeed, body);
|
|
97
|
+
} catch (err) {
|
|
98
|
+
return convertWireError("vice_keyboard_type", err);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return stockAnswer(session.client, {
|
|
102
|
+
text,
|
|
103
|
+
petsciiUpper,
|
|
104
|
+
byteCount: petscii.length,
|
|
105
|
+
petsciiHex: petscii.toString("hex"),
|
|
106
|
+
note: KEYBOARD_HALTED_NOTE,
|
|
107
|
+
});
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* vice_keyboard_petscii -- feeds explicit, already-PETSCII bytes into the
|
|
112
|
+
* keyboard buffer with no conversion. This is the deliberate escape hatch
|
|
113
|
+
* handleKeyboardType()'s control-code refusal points callers at: a caller
|
|
114
|
+
* that genuinely wants a PETSCII control code (e.g. 0x93, clear screen)
|
|
115
|
+
* states it here, one byte at a time, rather than through an ASCII string.
|
|
116
|
+
* Argument: `data` (required array of integers, 1-255 elements, each
|
|
117
|
+
* 0x00-0xff).
|
|
118
|
+
*/
|
|
119
|
+
export const handleKeyboardPetscii: StockSessionHandler = async (args, session) => {
|
|
120
|
+
if (!isPlainObject(args)) {
|
|
121
|
+
return isErrorText("vice_keyboard_petscii: arguments must be an object");
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const { data } = args;
|
|
125
|
+
if (!Array.isArray(data)) {
|
|
126
|
+
return isErrorText("vice_keyboard_petscii: data is required and must be an array");
|
|
127
|
+
}
|
|
128
|
+
if (data.length === 0) {
|
|
129
|
+
return isErrorText("vice_keyboard_petscii: data must not be empty");
|
|
130
|
+
}
|
|
131
|
+
if (data.length > 255) {
|
|
132
|
+
return isErrorText(`vice_keyboard_petscii: data exceeds 255 bytes (${data.length}) -- KEYBOARD_FEED's textLen field is a uint8`);
|
|
133
|
+
}
|
|
134
|
+
for (let index = 0; index < data.length; index++) {
|
|
135
|
+
const value: unknown = data[index];
|
|
136
|
+
if (typeof value !== "number" || !Number.isInteger(value) || value < 0x00 || value > 0xff) {
|
|
137
|
+
return isErrorText(`vice_keyboard_petscii: data[${index}] must be an integer in 0..0xff, got ${JSON.stringify(value)}`);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const petscii = Buffer.from(data as number[]);
|
|
142
|
+
const body = keyboardFeedBody({ petscii });
|
|
143
|
+
try {
|
|
144
|
+
await session.client.send(CommandType.KeyboardFeed, body);
|
|
145
|
+
} catch (err) {
|
|
146
|
+
return convertWireError("vice_keyboard_petscii", err);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return stockAnswer(session.client, {
|
|
150
|
+
byteCount: petscii.length,
|
|
151
|
+
petsciiHex: petscii.toString("hex"),
|
|
152
|
+
note: KEYBOARD_HALTED_NOTE,
|
|
153
|
+
});
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
// ---------------------------------------------------------------------------
|
|
157
|
+
// vice_joystick_set -- and the deliberate absence of vice_joystick_tap
|
|
158
|
+
// ---------------------------------------------------------------------------
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* JOYPORT_SET's `value` bit layout. **[ASSUMED]** -- RESEARCH.md
|
|
162
|
+
* Assumptions Log row A3: derived from general VICE joystick-driver
|
|
163
|
+
* knowledge, never confirmed against the manual or a probe run against a
|
|
164
|
+
* real binary. Probe debt filed at
|
|
165
|
+
* .planning/todos/pending/2026-08-14-probe-phase3-assumed-wire-details.md.
|
|
166
|
+
* Do not remove the [ASSUMED] label until that todo's acceptance check
|
|
167
|
+
* closes it -- do not write a comment claiming this mapping is verified.
|
|
168
|
+
*
|
|
169
|
+
* Exported as a single named constant so a future probe session has
|
|
170
|
+
* exactly one place to correct it -- joyportSetBody() itself deliberately
|
|
171
|
+
* takes an already-composed raw `value` for the same reason.
|
|
172
|
+
*/
|
|
173
|
+
export const JOYPORT_BITS = { up: 0x01, down: 0x02, left: 0x04, right: 0x08, fire: 0x10 } as const;
|
|
174
|
+
|
|
175
|
+
const VALID_DIRECTIONS = ["up", "down", "left", "right", "center"] as const;
|
|
176
|
+
type JoystickDirection = (typeof VALID_DIRECTIONS)[number];
|
|
177
|
+
|
|
178
|
+
function isValidDirection(value: string): value is JoystickDirection {
|
|
179
|
+
return (VALID_DIRECTIONS as readonly string[]).includes(value);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* vice_joystick_set -- composes a JOYPORT_SET value from `direction`
|
|
184
|
+
* (string or array of strings) and `fire`, and sends it. Arguments: `port`
|
|
185
|
+
* (optional number, default 1), `direction` (optional string or array of
|
|
186
|
+
* strings, default "center"), `fire` (optional boolean, default false) --
|
|
187
|
+
* the fork's exact names and documented string-or-array shape.
|
|
188
|
+
*
|
|
189
|
+
* vice_joystick_tap is deliberately absent from this module (and the whole
|
|
190
|
+
* stock manifest) -- a tap needs the machine to run for a measured
|
|
191
|
+
* interval, which is an unrequested EXIT (forbidden by D-05) plus a
|
|
192
|
+
* frame/cycle measurement stock does not have until Phase 7's timing route
|
|
193
|
+
* lands (docs/stock-vice-parity.md section A item 7). Do not approximate it
|
|
194
|
+
* with a sleep; do not implement it here.
|
|
195
|
+
*/
|
|
196
|
+
export const handleJoystickSet: StockSessionHandler = async (args, session) => {
|
|
197
|
+
if (!isPlainObject(args)) {
|
|
198
|
+
return isErrorText("vice_joystick_set: arguments must be an object");
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const portArg = args.port === undefined ? 1 : args.port;
|
|
202
|
+
if (typeof portArg !== "number" || !Number.isInteger(portArg) || (portArg !== 1 && portArg !== 2)) {
|
|
203
|
+
return isErrorText(`vice_joystick_set: port must be 1 or 2, got ${JSON.stringify(args.port)}`);
|
|
204
|
+
}
|
|
205
|
+
const port = portArg;
|
|
206
|
+
|
|
207
|
+
const fireArg = args.fire === undefined ? false : args.fire;
|
|
208
|
+
if (typeof fireArg !== "boolean") {
|
|
209
|
+
return isErrorText("vice_joystick_set: fire must be a boolean");
|
|
210
|
+
}
|
|
211
|
+
const fire = fireArg;
|
|
212
|
+
|
|
213
|
+
const rawDirection = args.direction === undefined ? "center" : args.direction;
|
|
214
|
+
const directionInputs: unknown[] = Array.isArray(rawDirection) ? rawDirection : [rawDirection];
|
|
215
|
+
|
|
216
|
+
const directions: JoystickDirection[] = [];
|
|
217
|
+
for (let index = 0; index < directionInputs.length; index++) {
|
|
218
|
+
const rawValue = directionInputs[index];
|
|
219
|
+
const normalized = typeof rawValue === "string" ? rawValue.trim().toLowerCase() : undefined;
|
|
220
|
+
if (normalized === undefined || !isValidDirection(normalized)) {
|
|
221
|
+
return isErrorText(
|
|
222
|
+
`vice_joystick_set: direction[${index}] must be one of up, down, left, right, center -- got ${JSON.stringify(rawValue)}`,
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
directions.push(normalized);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const hasUp = directions.includes("up");
|
|
229
|
+
const hasDown = directions.includes("down");
|
|
230
|
+
const hasLeft = directions.includes("left");
|
|
231
|
+
const hasRight = directions.includes("right");
|
|
232
|
+
const hasCenter = directions.includes("center");
|
|
233
|
+
|
|
234
|
+
// A composed value asserting two opposite switches at once is a state no
|
|
235
|
+
// real joystick can produce -- refused before any send, per this module's
|
|
236
|
+
// own T-3-02 threat mitigation.
|
|
237
|
+
if (hasUp && hasDown) {
|
|
238
|
+
return isErrorText("vice_joystick_set: direction cannot contain both 'up' and 'down' -- no real joystick can assert two opposite switches at once");
|
|
239
|
+
}
|
|
240
|
+
if (hasLeft && hasRight) {
|
|
241
|
+
return isErrorText("vice_joystick_set: direction cannot contain both 'left' and 'right' -- no real joystick can assert two opposite switches at once");
|
|
242
|
+
}
|
|
243
|
+
if (hasCenter && directions.length > 1) {
|
|
244
|
+
return isErrorText("vice_joystick_set: 'center' cannot be combined with any other direction");
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
let value = 0;
|
|
248
|
+
const valueBits: string[] = [];
|
|
249
|
+
for (const direction of ["up", "down", "left", "right"] as const) {
|
|
250
|
+
if (directions.includes(direction)) {
|
|
251
|
+
value |= JOYPORT_BITS[direction];
|
|
252
|
+
valueBits.push(direction);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
if (fire) {
|
|
256
|
+
value |= JOYPORT_BITS.fire;
|
|
257
|
+
valueBits.push("fire");
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const body = joyportSetBody({ port, value });
|
|
261
|
+
try {
|
|
262
|
+
await session.client.send(CommandType.JoyportSet, body);
|
|
263
|
+
} catch (err) {
|
|
264
|
+
return convertWireError("vice_joystick_set", err);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
return stockAnswer(session.client, {
|
|
268
|
+
port,
|
|
269
|
+
directions,
|
|
270
|
+
fire,
|
|
271
|
+
value,
|
|
272
|
+
valueBits,
|
|
273
|
+
});
|
|
274
|
+
};
|
package/stock-machine.ts
ADDED
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// stock-machine.ts
|
|
3
|
+
//
|
|
4
|
+
// Family D: the machine-control half of Phase 3's stock tool surface --
|
|
5
|
+
// `vice_machine_reset`, `vice_autostart`, `vice_disk_attach`,
|
|
6
|
+
// `vice_snapshot_save` and `vice_snapshot_load`. Five tools that either
|
|
7
|
+
// restart the machine (RESET, AUTOSTART) or hand VICE a filename THE HOST
|
|
8
|
+
// opens (AUTOSTART, DUMP, UNDUMP) -- every filename-carrying send in this
|
|
9
|
+
// file routes through stock-paths.ts's one translation wrapper (imported
|
|
10
|
+
// below), never a local path heuristic.
|
|
11
|
+
//
|
|
12
|
+
// WHAT NOT TO DO:
|
|
13
|
+
// - Never gate or deny vice_machine_reset's hard mode. CLAUDE.md's
|
|
14
|
+
// power-cycle warning is about RESOURCE_SET (0x52) writes to
|
|
15
|
+
// MachineVideoStandard/VICIIModel/MachinePowerFrequency -- Phase 6
|
|
16
|
+
// territory, a DIFFERENT opcode entirely. RESET (0xcc) is a distinct
|
|
17
|
+
// command, and an agent-requested hard reset via RESET is exactly what
|
|
18
|
+
// DIRECT-06 asks for. It needs no deny-list (RESEARCH.md Pitfall 1).
|
|
19
|
+
// - Never look for a per-unit disk-attach route mid-implementation.
|
|
20
|
+
// AUTOSTART (0xdd) has NO drive-unit field on the wire at all -- this is
|
|
21
|
+
// a protocol gap, not a code bug you can fix by looking harder
|
|
22
|
+
// (RESEARCH.md Pitfall 2).
|
|
23
|
+
// - Never add a disk-detach handler here. D-13 ships that tool in Phase 7
|
|
24
|
+
// through the text monitor -- grep-gated to zero occurrences of its name
|
|
25
|
+
// in this file's own acceptance criteria.
|
|
26
|
+
// - Never build a host path outside stock-paths.ts. Every filename this
|
|
27
|
+
// file sends through the wire goes through that same one wrapper --
|
|
28
|
+
// grep-gated to zero direct hostPath()/hostPathCandidates() calls here.
|
|
29
|
+
// - Never construct an ok-answer outside stockAnswer(). D-06 requires
|
|
30
|
+
// every stock tool answer to carry runState, and stockAnswer() is the
|
|
31
|
+
// one place that is stamped.
|
|
32
|
+
import { resolve, dirname } from "node:path";
|
|
33
|
+
import { mkdirSync, existsSync, readdirSync, writeFileSync, readFileSync } from "node:fs";
|
|
34
|
+
|
|
35
|
+
import { CommandType, ResetMode, resetBody, autostartBody, dumpBody, undumpBody } from "./stock-protocol.ts";
|
|
36
|
+
import { stockAnswer, convertWireError, isErrorText, type StockSessionHandler } from "./stock-handler.ts";
|
|
37
|
+
import { withEmulatorSidePath, snapshotPathFor, snapshotMetaPathFor, sanitizeSnapshotName } from "./stock-paths.ts";
|
|
38
|
+
|
|
39
|
+
/** True iff `value` is a well-formed, generic JSON object -- not null, not
|
|
40
|
+
* an array. Matches this module tree's own isPlainObject() convention
|
|
41
|
+
* (vice.ts:314, stock-condition.ts:228) -- redeclared privately here, not
|
|
42
|
+
* imported, per the established per-module convention. */
|
|
43
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
44
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
// handleMachineReset -- RESET (0xcc), with an OPTIONAL follow-up EXIT (0xaa).
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* `mode` defaults to "soft"; `run_after` defaults to **false** on stock --
|
|
53
|
+
* the divergence docs/stock-vice-parity.md records. RESET has no run-after
|
|
54
|
+
* field on the wire at all, so honouring `run_after: true` means sending a
|
|
55
|
+
* follow-up EXIT -- fine when the agent explicitly asked for it (D-05
|
|
56
|
+
* licenses this: the agent's own argument IS the request, not an
|
|
57
|
+
* auto-resume), but a *default* of true would resume a machine nobody asked
|
|
58
|
+
* to resume, which D-05's absolute no-unrequested-resume policy forbids.
|
|
59
|
+
* This is one of only two EXIT call sites in this phase -- the other is
|
|
60
|
+
* vice_execution_run.
|
|
61
|
+
*/
|
|
62
|
+
export const handleMachineReset: StockSessionHandler = async (args, session) => {
|
|
63
|
+
const a = isPlainObject(args) ? args : {};
|
|
64
|
+
|
|
65
|
+
const modeArg = a.mode;
|
|
66
|
+
if (modeArg !== undefined && modeArg !== "soft" && modeArg !== "hard") {
|
|
67
|
+
return isErrorText(`vice_machine_reset: mode must be "soft" or "hard", got ${JSON.stringify(modeArg)}`);
|
|
68
|
+
}
|
|
69
|
+
const mode: "soft" | "hard" = modeArg === "hard" ? "hard" : "soft";
|
|
70
|
+
|
|
71
|
+
const runAfterArg = a.run_after;
|
|
72
|
+
if (runAfterArg !== undefined && typeof runAfterArg !== "boolean") {
|
|
73
|
+
return isErrorText(`vice_machine_reset: run_after must be a boolean, got ${typeof runAfterArg}`);
|
|
74
|
+
}
|
|
75
|
+
const runAfter = runAfterArg === true; // default false on stock (D-03/D-05)
|
|
76
|
+
|
|
77
|
+
try {
|
|
78
|
+
await session.client.send(CommandType.Reset, resetBody({ mode: mode === "hard" ? ResetMode.Hard : ResetMode.Soft }));
|
|
79
|
+
|
|
80
|
+
let resumed = false;
|
|
81
|
+
if (runAfter) {
|
|
82
|
+
// Licensed by D-05: run_after: true is the AGENT's own explicit
|
|
83
|
+
// request, not an auto-resume -- one of only two EXIT sites in this
|
|
84
|
+
// phase (the other is vice_execution_run).
|
|
85
|
+
await session.client.send(CommandType.Exit);
|
|
86
|
+
resumed = true;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return stockAnswer(session.client, { mode, runAfter, resumed });
|
|
90
|
+
} catch (err) {
|
|
91
|
+
return convertWireError("vice_machine_reset", err);
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
// ---------------------------------------------------------------------------
|
|
96
|
+
// handleAutostart -- AUTOSTART (0xdd), run flag honoured, path translated.
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* `program` is refused when supplied (D-03): AUTOSTART supports only a
|
|
101
|
+
* numeric `fileIndex` and has no load-by-name field, so an argument stock
|
|
102
|
+
* cannot honour is refused rather than silently dropped.
|
|
103
|
+
*/
|
|
104
|
+
export const handleAutostart: StockSessionHandler = async (args, session) => {
|
|
105
|
+
const a = isPlainObject(args) ? args : {};
|
|
106
|
+
|
|
107
|
+
const path = a.path;
|
|
108
|
+
if (typeof path !== "string" || path.length === 0) {
|
|
109
|
+
return isErrorText("vice_autostart: path is required and must be a non-empty string");
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (a.program !== undefined) {
|
|
113
|
+
return isErrorText(
|
|
114
|
+
"vice_autostart: program is not supported on the stock backend -- AUTOSTART (0xdd) supports only a numeric " +
|
|
115
|
+
"fileIndex field and has no load-by-name field. Use index to select a program by position instead.",
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const runArg = a.run;
|
|
120
|
+
if (runArg !== undefined && typeof runArg !== "boolean") {
|
|
121
|
+
return isErrorText(`vice_autostart: run must be a boolean, got ${typeof runArg}`);
|
|
122
|
+
}
|
|
123
|
+
const run = runArg === undefined ? true : runArg; // matches the fork's own default
|
|
124
|
+
|
|
125
|
+
const indexArg = a.index;
|
|
126
|
+
if (indexArg !== undefined && (typeof indexArg !== "number" || !Number.isInteger(indexArg) || indexArg < 0 || indexArg > 0xffff)) {
|
|
127
|
+
return isErrorText(`vice_autostart: index must be an integer in 0..0xffff, got ${JSON.stringify(indexArg)}`);
|
|
128
|
+
}
|
|
129
|
+
const index = indexArg === undefined ? 0 : indexArg;
|
|
130
|
+
|
|
131
|
+
const containerPath = resolve(path);
|
|
132
|
+
try {
|
|
133
|
+
const { sentPath } = await withEmulatorSidePath("vice_autostart", containerPath, (hostPath) =>
|
|
134
|
+
session.client.send(CommandType.AutoStart, autostartBody({ runAfter: run, fileIndex: index, filename: hostPath })),
|
|
135
|
+
);
|
|
136
|
+
return stockAnswer(session.client, { path: containerPath, sentPath, run, index });
|
|
137
|
+
} catch (err) {
|
|
138
|
+
return convertWireError("vice_autostart", err);
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
// ---------------------------------------------------------------------------
|
|
143
|
+
// handleDiskAttach -- AUTOSTART (0xdd) again, the D-14 approximation.
|
|
144
|
+
// ---------------------------------------------------------------------------
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Kept the fork's exact `unit`+`path` argument shape (D-03). Units 9-11 are
|
|
148
|
+
* refused, never silently retargeted to unit 8 -- AUTOSTART is the only wire
|
|
149
|
+
* route to attaching an image and its request body has NO drive-unit field
|
|
150
|
+
* at all, so an agent told "attached to unit 9" when the image landed on
|
|
151
|
+
* unit 8 would debug the wrong drive. See docs/stock-vice-parity.md's D-14
|
|
152
|
+
* entry.
|
|
153
|
+
*/
|
|
154
|
+
export const handleDiskAttach: StockSessionHandler = async (args, session) => {
|
|
155
|
+
const a = isPlainObject(args) ? args : {};
|
|
156
|
+
|
|
157
|
+
const unit = a.unit;
|
|
158
|
+
if (typeof unit !== "number" || !Number.isInteger(unit) || unit < 8 || unit > 11) {
|
|
159
|
+
return isErrorText(`vice_disk_attach: unit must be an integer in 8..11, got ${JSON.stringify(a.unit)}`);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const path = a.path;
|
|
163
|
+
if (typeof path !== "string" || path.length === 0) {
|
|
164
|
+
return isErrorText("vice_disk_attach: path is required and must be a non-empty string");
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (unit !== 8) {
|
|
168
|
+
return isErrorText(
|
|
169
|
+
`vice_disk_attach: unit ${unit} cannot be targeted on the stock backend -- AUTOSTART (0xdd) is the only wire ` +
|
|
170
|
+
"route to attaching a disk image on the stock binary monitor and its request body has no drive-unit field at " +
|
|
171
|
+
"all, so units 9-11 cannot be targeted. Only unit 8 is reachable; the call was refused rather than silently " +
|
|
172
|
+
"retargeted to unit 8 so you do not debug the wrong drive. See docs/stock-vice-parity.md's D-14 entry.",
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const containerPath = resolve(path);
|
|
177
|
+
try {
|
|
178
|
+
const { sentPath } = await withEmulatorSidePath("vice_disk_attach", containerPath, (hostPath) =>
|
|
179
|
+
session.client.send(CommandType.AutoStart, autostartBody({ runAfter: false, fileIndex: 0, filename: hostPath })),
|
|
180
|
+
);
|
|
181
|
+
return stockAnswer(session.client, {
|
|
182
|
+
unit: 8,
|
|
183
|
+
path: containerPath,
|
|
184
|
+
sentPath,
|
|
185
|
+
approximation: "AUTOSTART with the run flag clear (D-14)",
|
|
186
|
+
});
|
|
187
|
+
} catch (err) {
|
|
188
|
+
return convertWireError("vice_disk_attach", err);
|
|
189
|
+
}
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
// ---------------------------------------------------------------------------
|
|
193
|
+
// handleSnapshotSave / handleSnapshotLoad -- DUMP (0x41) / UNDUMP (0x42).
|
|
194
|
+
// ---------------------------------------------------------------------------
|
|
195
|
+
|
|
196
|
+
const MAX_DESCRIPTION_LENGTH = 512;
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* `name` is sanitised through stock-paths.ts's sanitizeSnapshotName() into a
|
|
200
|
+
* workspace-internal path -- never treated as a path fragment. The client-
|
|
201
|
+
* side metadata sidecar (docs/stock-vice-parity.md item 6: "DUMP writes
|
|
202
|
+
* state; JSON metadata is our own bookkeeping") is written ONLY after a
|
|
203
|
+
* successful DUMP, so a failed save never leaves a sidecar claiming a
|
|
204
|
+
* snapshot that does not exist; a sidecar WRITE failure is reported in the
|
|
205
|
+
* answer as `metadataWritten: false` with a reason, never thrown -- the
|
|
206
|
+
* snapshot itself succeeded and the agent must be told exactly that (T-3-10).
|
|
207
|
+
*/
|
|
208
|
+
export const handleSnapshotSave: StockSessionHandler = async (args, session) => {
|
|
209
|
+
const a = isPlainObject(args) ? args : {};
|
|
210
|
+
|
|
211
|
+
let name: string;
|
|
212
|
+
try {
|
|
213
|
+
name = sanitizeSnapshotName(a.name);
|
|
214
|
+
} catch (err) {
|
|
215
|
+
return isErrorText(`vice_snapshot_save: ${err instanceof Error ? err.message : String(err)}`);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const descriptionArg = a.description;
|
|
219
|
+
if (descriptionArg !== undefined && typeof descriptionArg !== "string") {
|
|
220
|
+
return isErrorText(`vice_snapshot_save: description must be a string, got ${typeof descriptionArg}`);
|
|
221
|
+
}
|
|
222
|
+
if (typeof descriptionArg === "string" && descriptionArg.length > MAX_DESCRIPTION_LENGTH) {
|
|
223
|
+
return isErrorText(`vice_snapshot_save: description exceeds ${MAX_DESCRIPTION_LENGTH} characters (${descriptionArg.length})`);
|
|
224
|
+
}
|
|
225
|
+
const description: string | null = typeof descriptionArg === "string" ? descriptionArg : null;
|
|
226
|
+
|
|
227
|
+
const includeRomsArg = a.include_roms;
|
|
228
|
+
if (includeRomsArg !== undefined && typeof includeRomsArg !== "boolean") {
|
|
229
|
+
return isErrorText(`vice_snapshot_save: include_roms must be a boolean, got ${typeof includeRomsArg}`);
|
|
230
|
+
}
|
|
231
|
+
const includeRoms = includeRomsArg === true;
|
|
232
|
+
|
|
233
|
+
const includeDisksArg = a.include_disks;
|
|
234
|
+
if (includeDisksArg !== undefined && typeof includeDisksArg !== "boolean") {
|
|
235
|
+
return isErrorText(`vice_snapshot_save: include_disks must be a boolean, got ${typeof includeDisksArg}`);
|
|
236
|
+
}
|
|
237
|
+
const includeDisks = includeDisksArg === true;
|
|
238
|
+
|
|
239
|
+
const containerPath = snapshotPathFor(name);
|
|
240
|
+
// VICE opens the file for writing and will not create the directory --
|
|
241
|
+
// the same mkdirSync-before-translate ordering vice-sync.ts's screenshot()
|
|
242
|
+
// already uses.
|
|
243
|
+
mkdirSync(dirname(containerPath), { recursive: true });
|
|
244
|
+
|
|
245
|
+
let sentPath: string;
|
|
246
|
+
try {
|
|
247
|
+
const result = await withEmulatorSidePath("vice_snapshot_save", containerPath, (hostPath) =>
|
|
248
|
+
session.client.send(CommandType.Dump, dumpBody({ saveRoms: includeRoms, saveDisks: includeDisks, filename: hostPath })),
|
|
249
|
+
);
|
|
250
|
+
sentPath = result.sentPath;
|
|
251
|
+
} catch (err) {
|
|
252
|
+
return convertWireError("vice_snapshot_save", err);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// DUMP succeeded -- write the metadata sidecar. A write failure here is
|
|
256
|
+
// reported, never thrown: the snapshot itself is good.
|
|
257
|
+
const metadataPath = snapshotMetaPathFor(name);
|
|
258
|
+
let metadataWritten = true;
|
|
259
|
+
let metadataFailureReason: string | null = null;
|
|
260
|
+
try {
|
|
261
|
+
const metadata = {
|
|
262
|
+
name,
|
|
263
|
+
description,
|
|
264
|
+
createdAt: new Date().toISOString(),
|
|
265
|
+
includeRoms,
|
|
266
|
+
includeDisks,
|
|
267
|
+
viceVersion: session.versionQuad,
|
|
268
|
+
backend: "stock" as const,
|
|
269
|
+
snapshotPath: containerPath,
|
|
270
|
+
};
|
|
271
|
+
writeFileSync(metadataPath, JSON.stringify(metadata, null, 2));
|
|
272
|
+
} catch (err) {
|
|
273
|
+
metadataWritten = false;
|
|
274
|
+
metadataFailureReason = err instanceof Error ? err.message : String(err);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
return stockAnswer(session.client, {
|
|
278
|
+
name,
|
|
279
|
+
path: containerPath,
|
|
280
|
+
sentPath,
|
|
281
|
+
includeRoms,
|
|
282
|
+
includeDisks,
|
|
283
|
+
metadataWritten,
|
|
284
|
+
...(metadataFailureReason !== null ? { metadataFailureReason } : {}),
|
|
285
|
+
metadataPath,
|
|
286
|
+
});
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Refuses with an explanatory message, listing the `.vsf` basenames present
|
|
291
|
+
* in the snapshot directory, when the named snapshot file does not exist --
|
|
292
|
+
* the useful half of what the deleted `vice_snapshot_list` used to provide
|
|
293
|
+
* (D-16 deleted the tool because it had no consumer), delivered at the point
|
|
294
|
+
* of failure rather than as its own tool.
|
|
295
|
+
*
|
|
296
|
+
* Loading a snapshot REPLACES THE ENTIRE MACHINE STATE, so this handler's
|
|
297
|
+
* `runState` reflects whatever the event stream reports after UNDUMP and
|
|
298
|
+
* nothing is asserted about it here.
|
|
299
|
+
*/
|
|
300
|
+
export const handleSnapshotLoad: StockSessionHandler = async (args, session) => {
|
|
301
|
+
const a = isPlainObject(args) ? args : {};
|
|
302
|
+
|
|
303
|
+
let name: string;
|
|
304
|
+
try {
|
|
305
|
+
name = sanitizeSnapshotName(a.name);
|
|
306
|
+
} catch (err) {
|
|
307
|
+
return isErrorText(`vice_snapshot_load: ${err instanceof Error ? err.message : String(err)}`);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const containerPath = snapshotPathFor(name);
|
|
311
|
+
if (!existsSync(containerPath)) {
|
|
312
|
+
const dir = dirname(containerPath);
|
|
313
|
+
let available: string[] = [];
|
|
314
|
+
try {
|
|
315
|
+
available = existsSync(dir) ? readdirSync(dir).filter((f) => f.endsWith(".vsf")) : [];
|
|
316
|
+
} catch {
|
|
317
|
+
available = [];
|
|
318
|
+
}
|
|
319
|
+
return isErrorText(
|
|
320
|
+
`vice_snapshot_load: no snapshot named "${name}" exists at ${containerPath}. ` +
|
|
321
|
+
(available.length > 0 ? `Available snapshots: ${available.join(", ")}` : "No snapshots exist yet."),
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
let sentPath: string;
|
|
326
|
+
let programCounter: number | null = null;
|
|
327
|
+
try {
|
|
328
|
+
const result = await withEmulatorSidePath("vice_snapshot_load", containerPath, (hostPath) =>
|
|
329
|
+
session.client.send(CommandType.Undump, undumpBody({ filename: hostPath })),
|
|
330
|
+
);
|
|
331
|
+
sentPath = result.sentPath;
|
|
332
|
+
const reply = result.result;
|
|
333
|
+
if (reply && typeof reply === "object" && "type" in reply && (reply as { type: unknown }).type === "undump") {
|
|
334
|
+
programCounter = (reply as { programCounter: number }).programCounter;
|
|
335
|
+
}
|
|
336
|
+
} catch (err) {
|
|
337
|
+
return convertWireError("vice_snapshot_load", err);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const metadataPath = snapshotMetaPathFor(name);
|
|
341
|
+
let metadata: { description?: string | null; createdAt?: string } | null = null;
|
|
342
|
+
try {
|
|
343
|
+
if (existsSync(metadataPath)) {
|
|
344
|
+
const parsed: unknown = JSON.parse(readFileSync(metadataPath, "utf8"));
|
|
345
|
+
if (isPlainObject(parsed)) {
|
|
346
|
+
metadata = {
|
|
347
|
+
description: typeof parsed.description === "string" ? parsed.description : null,
|
|
348
|
+
createdAt: typeof parsed.createdAt === "string" ? parsed.createdAt : undefined,
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
} catch {
|
|
353
|
+
metadata = null; // a missing or unparsable sidecar is reported as null, never an error
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
return stockAnswer(session.client, { name, path: containerPath, sentPath, programCounter, metadata });
|
|
357
|
+
};
|