@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,2057 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// This is the ONE authoritative place that frames, parses, and demultiplexes
|
|
3
|
+
// the stock binary VICE binary-monitor wire protocol -- nothing else in this
|
|
4
|
+
// tree decodes binmon bytes. Two seams live here: the pure byte-level
|
|
5
|
+
// framing/parsing functions (parseBuffer/parseResponse/encodeRequestHeader,
|
|
6
|
+
// plan 02-04), and a raw-socket client (ViceMonitorClient) that drives them
|
|
7
|
+
// off a real net.Socket and adds request-id correlation, demux, and
|
|
8
|
+
// socket-lifecycle rejection (plan 02-06).
|
|
9
|
+
//
|
|
10
|
+
// Attribution: this module is derived from henrik/c64-debug-mcp's
|
|
11
|
+
// src/vice-protocol.ts (v1.0.14, MIT, Henrik Olsson 2025). Three defects in
|
|
12
|
+
// that source are fixed on the way in: (a) the zero-length JAM read --
|
|
13
|
+
// vendor lines 357-358 call body.readUInt16LE(0) on a JAM's body
|
|
14
|
+
// unconditionally, even though monitor_binary.c:384-394 sends no PC bytes at
|
|
15
|
+
// all for that event, which throws on a real JAM; (b) the throw-on-bad-STX
|
|
16
|
+
// that never advances the buffer -- vendor lines 228-231 throw out of the
|
|
17
|
+
// framing loop on a single unexpected byte instead of resyncing, which
|
|
18
|
+
// permanently wedges the connection on the very first stray byte; and (c) the
|
|
19
|
+
// api_version byte at header offset 1, which the vendor's parseBuffer()
|
|
20
|
+
// never reads at all, silently accepting a monitor speaking a different wire
|
|
21
|
+
// version. A fourth defect, not one of the three named above but found while
|
|
22
|
+
// porting: the vendor's DisplayGet case computes imageBytes starting at
|
|
23
|
+
// `infoLength + 4`, which is the same offset its own imageLength field
|
|
24
|
+
// occupies -- it should start after that 4-byte field, at
|
|
25
|
+
// `infoLength + 4 + 4`. This repo's own probe-binmon.mjs:parseDisplayGet()
|
|
26
|
+
// already derives this correctly (see its "never hardcoded to 17/21"
|
|
27
|
+
// comment); this module follows that already-tested reference instead of
|
|
28
|
+
// the vendor's off-by-four slice (Rule 1 auto-fix, not one of D-16's three
|
|
29
|
+
// named defects).
|
|
30
|
+
//
|
|
31
|
+
// What NOT to do: never demux on response type before request id -- plan
|
|
32
|
+
// 02-06 builds that correlation layer on top of the parser below, and a
|
|
33
|
+
// response type can be reused between a legitimate command reply and an
|
|
34
|
+
// unsolicited event (CHECKPOINT_INFO/REGISTER_INFO), so only request id can
|
|
35
|
+
// tell them apart. Never import the vendor's contracts.ts or errors.ts --
|
|
36
|
+
// contracts.ts pulls in a validation library this package's dependencies
|
|
37
|
+
// block does not carry and must not gain (D-16); only the pure wire
|
|
38
|
+
// constants below are hand-copied from it. No existing file in this repo
|
|
39
|
+
// vendors third-party source before this one; this header comment
|
|
40
|
+
// establishes the template other vendoring, if any, should follow.
|
|
41
|
+
import { EventEmitter } from "node:events";
|
|
42
|
+
import net from "node:net";
|
|
43
|
+
|
|
44
|
+
import { ViceError } from "./vice.ts";
|
|
45
|
+
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
// Wire constants (hand-copied, not imported -- see header comment above)
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
export const VICE_STX = 0x02;
|
|
51
|
+
export const VICE_API_VERSION = 0x02;
|
|
52
|
+
export const VICE_BROADCAST_REQUEST_ID = 0xffffffff;
|
|
53
|
+
export const RESPONSE_HEADER_LEN = 12;
|
|
54
|
+
export const REQUEST_HEADER_LEN = 11;
|
|
55
|
+
|
|
56
|
+
// Upper bound on a trusted declared body length. The largest legitimate
|
|
57
|
+
// frame is a DISPLAY_GET of the full debug screen (504*312 = 157,248 bytes
|
|
58
|
+
// at 8bpp plus its info block), so 4 MiB is far above anything real while
|
|
59
|
+
// still refusing an arbitrary 32-bit value read out of a desynced stream.
|
|
60
|
+
// Same rationale as probe-binmon.mjs:73-77's MAX_BODY_LEN.
|
|
61
|
+
export const MAX_BODY_LEN = 4 * 1024 * 1024;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* WR-03: the cap on ACCUMULATED, not-yet-parseable bytes -- a DIFFERENT
|
|
65
|
+
* quantity from MAX_BODY_LEN above, which caps a single frame's DECLARED body
|
|
66
|
+
* length. #onData() used the same constant for both, so a frame whose body is
|
|
67
|
+
* at or near MAX_BODY_LEN could never be reassembled from chunks: its own
|
|
68
|
+
* partially-received bytes tripped the cap and the buffer was reset, forever,
|
|
69
|
+
* on every retry. The accumulation cap must therefore be strictly larger than
|
|
70
|
+
* the largest legal frame (header + max body) with room for a following frame's
|
|
71
|
+
* bytes arriving in the same chunk.
|
|
72
|
+
*/
|
|
73
|
+
export const MAX_BUFFERED_LEN = RESPONSE_HEADER_LEN + MAX_BODY_LEN + 64 * 1024;
|
|
74
|
+
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
// Command / response / error "enums" -- one-for-one with
|
|
77
|
+
// docs/phase0-binmon-findings.md §5's normative set, which is a superset of
|
|
78
|
+
// the vendor's own CommandType (missing RESOURCE_GET/SET, CPUHISTORY_GET,
|
|
79
|
+
// and USERPORT_SET).
|
|
80
|
+
//
|
|
81
|
+
// Deviation from the plan's literal wording ("plain TypeScript enums, not
|
|
82
|
+
// `const enum`"): a real TypeScript `enum` -- plain or const -- emits
|
|
83
|
+
// runtime code, and this package has NO build step at all (see CLAUDE.md /
|
|
84
|
+
// this repo's README: Node's native type-stripping runs these .ts files
|
|
85
|
+
// directly). Node's strip-only mode explicitly rejects `enum` with
|
|
86
|
+
// ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX ("TypeScript enum is not supported in
|
|
87
|
+
// strip-only mode") -- confirmed by running this file. No other file in this
|
|
88
|
+
// package uses `enum` for exactly this reason. `as const` objects plus a
|
|
89
|
+
// derived type alias give the same `CommandType.Ping`-style access and the
|
|
90
|
+
// same numeric values with zero runtime codegen -- the standard idiom for
|
|
91
|
+
// this project's constraint (Rule 3 auto-fix; not an architectural change,
|
|
92
|
+
// same names/values as originally specified).
|
|
93
|
+
// ---------------------------------------------------------------------------
|
|
94
|
+
|
|
95
|
+
export const CommandType = {
|
|
96
|
+
MemoryGet: 0x01,
|
|
97
|
+
MemorySet: 0x02,
|
|
98
|
+
CheckpointGet: 0x11,
|
|
99
|
+
CheckpointSet: 0x12,
|
|
100
|
+
CheckpointDelete: 0x13,
|
|
101
|
+
CheckpointList: 0x14,
|
|
102
|
+
CheckpointToggle: 0x15,
|
|
103
|
+
ConditionSet: 0x22,
|
|
104
|
+
RegistersGet: 0x31,
|
|
105
|
+
RegistersSet: 0x32,
|
|
106
|
+
Dump: 0x41,
|
|
107
|
+
Undump: 0x42,
|
|
108
|
+
ResourceGet: 0x51,
|
|
109
|
+
ResourceSet: 0x52,
|
|
110
|
+
AdvanceInstructions: 0x71,
|
|
111
|
+
KeyboardFeed: 0x72,
|
|
112
|
+
ExecuteUntilReturn: 0x73,
|
|
113
|
+
Ping: 0x81,
|
|
114
|
+
BanksAvailable: 0x82,
|
|
115
|
+
RegistersAvailable: 0x83,
|
|
116
|
+
DisplayGet: 0x84,
|
|
117
|
+
ViceInfo: 0x85,
|
|
118
|
+
CpuHistoryGet: 0x86,
|
|
119
|
+
PaletteGet: 0x91,
|
|
120
|
+
JoyportSet: 0xa2,
|
|
121
|
+
UserportSet: 0xb2,
|
|
122
|
+
Exit: 0xaa,
|
|
123
|
+
Quit: 0xbb,
|
|
124
|
+
Reset: 0xcc,
|
|
125
|
+
AutoStart: 0xdd,
|
|
126
|
+
} as const;
|
|
127
|
+
export type CommandType = (typeof CommandType)[keyof typeof CommandType];
|
|
128
|
+
|
|
129
|
+
export const ResponseType = {
|
|
130
|
+
MemoryGet: 0x01,
|
|
131
|
+
MemorySet: 0x02,
|
|
132
|
+
CheckpointInfo: 0x11,
|
|
133
|
+
// Added in plan 02-06 (was missing from 02-04's port): CHECKPOINT_DELETE
|
|
134
|
+
// replies with its own response type, not CHECKPOINT_INFO -- confirmed
|
|
135
|
+
// against monitor_binary.c's monitor_binary_process_checkpoint_delete(),
|
|
136
|
+
// which calls monitor_binary_response(..., e_MON_RESPONSE_CHECKPOINT_DELETE,
|
|
137
|
+
// ...). Without this entry EXPECTED_RESPONSE below would have no correct
|
|
138
|
+
// value to name for CHECKPOINT_DELETE, and every real delete reply would
|
|
139
|
+
// reject as a false mismatch.
|
|
140
|
+
CheckpointDelete: 0x13,
|
|
141
|
+
CheckpointList: 0x14,
|
|
142
|
+
CheckpointToggle: 0x15,
|
|
143
|
+
ConditionSet: 0x22,
|
|
144
|
+
RegisterInfo: 0x31,
|
|
145
|
+
Dump: 0x41,
|
|
146
|
+
Undump: 0x42,
|
|
147
|
+
ResourceGet: 0x51,
|
|
148
|
+
ResourceSet: 0x52,
|
|
149
|
+
Jam: 0x61,
|
|
150
|
+
Stopped: 0x62,
|
|
151
|
+
Resumed: 0x63,
|
|
152
|
+
AdvanceInstructions: 0x71,
|
|
153
|
+
KeyboardFeed: 0x72,
|
|
154
|
+
ExecuteUntilReturn: 0x73,
|
|
155
|
+
Ping: 0x81,
|
|
156
|
+
BanksAvailable: 0x82,
|
|
157
|
+
RegistersAvailable: 0x83,
|
|
158
|
+
DisplayGet: 0x84,
|
|
159
|
+
ViceInfo: 0x85,
|
|
160
|
+
CpuHistoryGet: 0x86,
|
|
161
|
+
PaletteGet: 0x91,
|
|
162
|
+
JoyportSet: 0xa2,
|
|
163
|
+
UserportSet: 0xb2,
|
|
164
|
+
Exit: 0xaa,
|
|
165
|
+
Quit: 0xbb,
|
|
166
|
+
Reset: 0xcc,
|
|
167
|
+
AutoStart: 0xdd,
|
|
168
|
+
} as const;
|
|
169
|
+
export type ResponseType = (typeof ResponseType)[keyof typeof ResponseType];
|
|
170
|
+
|
|
171
|
+
export const ErrorCode = {
|
|
172
|
+
Ok: 0x00,
|
|
173
|
+
ObjectMissing: 0x01,
|
|
174
|
+
InvalidMemspace: 0x02,
|
|
175
|
+
InvalidLength: 0x80,
|
|
176
|
+
InvalidParameter: 0x81,
|
|
177
|
+
InvalidApiVersion: 0x82,
|
|
178
|
+
InvalidType: 0x83,
|
|
179
|
+
CmdFailure: 0x8f,
|
|
180
|
+
} as const;
|
|
181
|
+
export type ErrorCode = (typeof ErrorCode)[keyof typeof ErrorCode];
|
|
182
|
+
|
|
183
|
+
// ---------------------------------------------------------------------------
|
|
184
|
+
// Error hierarchy -- ViceError subclasses, following vice.ts's exact
|
|
185
|
+
// constructor(message, { ...fields }: XOptions = {}) shape. Never the
|
|
186
|
+
// vendor's own error class.
|
|
187
|
+
// ---------------------------------------------------------------------------
|
|
188
|
+
|
|
189
|
+
export interface StockProtocolErrorOptions {
|
|
190
|
+
errorCode?: number;
|
|
191
|
+
responseType?: number;
|
|
192
|
+
requestId?: number;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Raised (returned inside parseBuffer()'s responses array, never thrown out
|
|
196
|
+
* of it) for any non-OK wire error code -- PROTO-05's distinguishable
|
|
197
|
+
* failure. Carries the wire errorCode and the responseType it arrived on so
|
|
198
|
+
* a caller never mistakes this for an empty success. */
|
|
199
|
+
export class StockProtocolError extends ViceError {
|
|
200
|
+
errorCode?: number;
|
|
201
|
+
responseType?: number;
|
|
202
|
+
requestId?: number;
|
|
203
|
+
|
|
204
|
+
constructor(message: string, { errorCode, responseType, requestId }: StockProtocolErrorOptions = {}) {
|
|
205
|
+
super(message, { code: errorCode });
|
|
206
|
+
this.name = "StockProtocolError";
|
|
207
|
+
this.errorCode = errorCode;
|
|
208
|
+
this.responseType = responseType;
|
|
209
|
+
this.requestId = requestId;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export interface StockFramingErrorOptions {
|
|
214
|
+
observed?: number;
|
|
215
|
+
expected?: number;
|
|
216
|
+
responseType?: number;
|
|
217
|
+
requestId?: number;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** api_version mismatch and other decode-level faults, carrying the observed
|
|
221
|
+
* bytes. Like StockProtocolError, this is returned inside parseBuffer()'s
|
|
222
|
+
* responses array rather than thrown out of the parse loop. */
|
|
223
|
+
export class StockFramingError extends ViceError {
|
|
224
|
+
observed?: number;
|
|
225
|
+
expected?: number;
|
|
226
|
+
responseType?: number;
|
|
227
|
+
requestId?: number;
|
|
228
|
+
|
|
229
|
+
constructor(message: string, { observed, expected, responseType, requestId }: StockFramingErrorOptions = {}) {
|
|
230
|
+
super(message);
|
|
231
|
+
this.name = "StockFramingError";
|
|
232
|
+
this.observed = observed;
|
|
233
|
+
this.expected = expected;
|
|
234
|
+
this.responseType = responseType;
|
|
235
|
+
this.requestId = requestId;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export interface StockDesyncErrorOptions {
|
|
240
|
+
bytesSkipped?: number;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Carries bytesSkipped, for a caller that wants to escalate a persistent
|
|
244
|
+
* desync. Emitted by ViceMonitorClient (below) when the accumulated,
|
|
245
|
+
* unparsed buffer grows past MAX_BODY_LEN without a complete frame, or when
|
|
246
|
+
* an unexpected throw would otherwise poison the connection. */
|
|
247
|
+
export class StockDesyncError extends ViceError {
|
|
248
|
+
bytesSkipped?: number;
|
|
249
|
+
|
|
250
|
+
constructor(message: string, { bytesSkipped }: StockDesyncErrorOptions = {}) {
|
|
251
|
+
super(message);
|
|
252
|
+
this.name = "StockDesyncError";
|
|
253
|
+
this.bytesSkipped = bytesSkipped;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export interface StockResponseMismatchErrorOptions {
|
|
258
|
+
expected?: number;
|
|
259
|
+
received?: number;
|
|
260
|
+
requestId?: number;
|
|
261
|
+
command?: number;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Raised by the correlation layer's dispatch (plan 02-06) when a reply's
|
|
266
|
+
* response type is not the one `EXPECTED_RESPONSE` names for the pending
|
|
267
|
+
* command's type. Consulted before `pending.resolve()` -- nothing in the
|
|
268
|
+
* vendor or in plan 02-04's parser validates this, so a wrong-typed frame on
|
|
269
|
+
* a matching request id would otherwise be handed to the caller as that
|
|
270
|
+
* command's answer, unchecked.
|
|
271
|
+
*/
|
|
272
|
+
export class StockResponseMismatchError extends ViceError {
|
|
273
|
+
expected?: number;
|
|
274
|
+
received?: number;
|
|
275
|
+
requestId?: number;
|
|
276
|
+
command?: number;
|
|
277
|
+
|
|
278
|
+
constructor(message: string, { expected, received, requestId, command }: StockResponseMismatchErrorOptions = {}) {
|
|
279
|
+
super(message);
|
|
280
|
+
this.name = "StockResponseMismatchError";
|
|
281
|
+
this.expected = expected;
|
|
282
|
+
this.received = received;
|
|
283
|
+
this.requestId = requestId;
|
|
284
|
+
this.command = command;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
export interface StockConnectionClosedErrorOptions {
|
|
289
|
+
port?: number | null;
|
|
290
|
+
abandoned?: number;
|
|
291
|
+
trigger?: "close" | "error";
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Raised by the socket-lifecycle rejection path (plan 02-06, D-11) for every
|
|
296
|
+
* pending command still outstanding when the underlying socket closes or
|
|
297
|
+
* errors, and for any `send()` call issued after that point. This file
|
|
298
|
+
* answers "this socket died" only -- whether a freshly reconnected socket is
|
|
299
|
+
* the *same machine* is deliberately NOT decided here; plan 02-08's
|
|
300
|
+
* stock-connect.ts owns that and reuses vice.ts's existing
|
|
301
|
+
* MachineRestartedError rather than inventing a second restart type. A
|
|
302
|
+
* second restart-error type introduced in this file would be the regression
|
|
303
|
+
* to prevent.
|
|
304
|
+
*/
|
|
305
|
+
export class StockConnectionClosedError extends ViceError {
|
|
306
|
+
port?: number | null;
|
|
307
|
+
abandoned?: number;
|
|
308
|
+
trigger?: "close" | "error";
|
|
309
|
+
|
|
310
|
+
constructor(message: string, { port, abandoned, trigger }: StockConnectionClosedErrorOptions = {}) {
|
|
311
|
+
super(message);
|
|
312
|
+
this.name = "StockConnectionClosedError";
|
|
313
|
+
this.port = port;
|
|
314
|
+
this.abandoned = abandoned;
|
|
315
|
+
this.trigger = trigger;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
export interface StockRequestTimeoutErrorOptions {
|
|
320
|
+
requestId?: number;
|
|
321
|
+
commandType?: number;
|
|
322
|
+
elapsedMs?: number;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* A strictly separate class from StockConnectionClosedError (D-11): a TCP
|
|
327
|
+
* close is unambiguous and immediate ("this socket died"), while a timeout
|
|
328
|
+
* means "connected but silent" -- a caller must be able to tell them apart
|
|
329
|
+
* by type without parsing message text.
|
|
330
|
+
*/
|
|
331
|
+
export class StockRequestTimeoutError extends ViceError {
|
|
332
|
+
requestId?: number;
|
|
333
|
+
commandType?: number;
|
|
334
|
+
elapsedMs?: number;
|
|
335
|
+
|
|
336
|
+
constructor(message: string, { requestId, commandType, elapsedMs }: StockRequestTimeoutErrorOptions = {}) {
|
|
337
|
+
super(message);
|
|
338
|
+
this.name = "StockRequestTimeoutError";
|
|
339
|
+
this.requestId = requestId;
|
|
340
|
+
this.commandType = commandType;
|
|
341
|
+
this.elapsedMs = elapsedMs;
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
export interface StockEncodingErrorOptions {
|
|
346
|
+
field?: string;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Raised by the Phase 3 request-body encoders below (never by the
|
|
351
|
+
* parsing/demux seam above) for a caller-supplied argument that cannot be
|
|
352
|
+
* safely turned into wire bytes -- an out-of-range address, a memspace byte
|
|
353
|
+
* outside 0x00-0x04, a variable-length field whose declared size would
|
|
354
|
+
* disagree with its actual payload, or similar. Always thrown BEFORE any
|
|
355
|
+
* bytes are written, never after a partially-built Buffer -- see each
|
|
356
|
+
* encoder's own validate-then-build discipline.
|
|
357
|
+
*/
|
|
358
|
+
export class StockEncodingError extends ViceError {
|
|
359
|
+
field?: string;
|
|
360
|
+
|
|
361
|
+
constructor(message: string, { field }: StockEncodingErrorOptions = {}) {
|
|
362
|
+
super(message);
|
|
363
|
+
this.name = "StockEncodingError";
|
|
364
|
+
this.field = field;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// ---------------------------------------------------------------------------
|
|
369
|
+
// Request encoding
|
|
370
|
+
// ---------------------------------------------------------------------------
|
|
371
|
+
|
|
372
|
+
export interface EncodeRequestHeaderOptions {
|
|
373
|
+
commandType: number;
|
|
374
|
+
requestId: number;
|
|
375
|
+
body?: Buffer;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/** Build the normative 11-byte binary-monitor request header
|
|
379
|
+
* (docs/phase0-binmon-findings.md §5) plus body: STX, api_version, uint32 LE
|
|
380
|
+
* body length, uint32 LE request id, command type byte. */
|
|
381
|
+
export function encodeRequestHeader({ commandType, requestId, body = Buffer.alloc(0) }: EncodeRequestHeaderOptions): Buffer {
|
|
382
|
+
const header = Buffer.alloc(REQUEST_HEADER_LEN);
|
|
383
|
+
header[0] = VICE_STX;
|
|
384
|
+
header[1] = VICE_API_VERSION;
|
|
385
|
+
header.writeUInt32LE(body.length >>> 0, 2);
|
|
386
|
+
header.writeUInt32LE(requestId >>> 0, 6);
|
|
387
|
+
header[10] = commandType;
|
|
388
|
+
return Buffer.concat([header, body]);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// ---------------------------------------------------------------------------
|
|
392
|
+
// Request-body encoders (Phase 3)
|
|
393
|
+
// ---------------------------------------------------------------------------
|
|
394
|
+
//
|
|
395
|
+
// The ONLY functions in this tree that turn tool arguments into
|
|
396
|
+
// binary-monitor request body bytes -- a caller (stock-memory.ts,
|
|
397
|
+
// stock-registers.ts, stock-checkpoints.ts, stock-execution.ts,
|
|
398
|
+
// stock-machine.ts, plans 03-06..03-11) must never hand-assemble a body
|
|
399
|
+
// Buffer itself. Five of the sixteen encoders below (memGetBody,
|
|
400
|
+
// memSetBody, checkpointSetBody, cpNumBody, conditionSetBody) are ported
|
|
401
|
+
// near-verbatim from probe-binmon.mjs:268-332 -- an already offline-tested
|
|
402
|
+
// reference implementation (see that file's own --selftest mode) --
|
|
403
|
+
// converted to a TypeScript options-object signature matching
|
|
404
|
+
// encodeRequestHeader()'s own style above. The rest are derived fresh from
|
|
405
|
+
// the official VICE manual (vice-emu.sourceforge.io/vice_13.html §13) and
|
|
406
|
+
// docs/phase0-binmon-findings.md §5; every encoder whose runtime BEHAVIOUR
|
|
407
|
+
// (not wire shape) is unconfirmed against a real binary says so explicitly
|
|
408
|
+
// in its own JSDoc as [ASSUMED], naming the RESEARCH.md Assumptions Log
|
|
409
|
+
// row -- never silently claimed as verified.
|
|
410
|
+
//
|
|
411
|
+
// Every encoder takes a single plain options object (never positional
|
|
412
|
+
// args, matching stock-connect.ts's own convention), validates its
|
|
413
|
+
// arguments and throws StockEncodingError BEFORE writing any bytes, and
|
|
414
|
+
// returns a Buffer ready for ViceMonitorClient.send(commandType, body) --
|
|
415
|
+
// the single exception is cpNumBody(), which mirrors probe-binmon.mjs's own
|
|
416
|
+
// bare-number signature since it has nothing else to validate or name.
|
|
417
|
+
// ---------------------------------------------------------------------------
|
|
418
|
+
|
|
419
|
+
function requireU16(fieldName: string, value: number): void {
|
|
420
|
+
if (!Number.isInteger(value) || value < 0x0000 || value > 0xffff) {
|
|
421
|
+
throw new StockEncodingError(`${fieldName} must be an integer in 0x0000..0xffff, got ${value}`, { field: fieldName });
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function requireU32(fieldName: string, value: number): void {
|
|
426
|
+
if (!Number.isInteger(value) || value < 0 || value > 0xffffffff) {
|
|
427
|
+
throw new StockEncodingError(`${fieldName} must be an integer in 0..0xffffffff, got ${value}`, { field: fieldName });
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* The shared memspace-byte validator every encoder below that takes a
|
|
433
|
+
* memspace routes through. Accepts `undefined` (defaults to `0x00`, main)
|
|
434
|
+
* and `0x00`-`0x04` (units 8-11). The wire memspace byte is NOT VICE's
|
|
435
|
+
* internal enum -- `0x08` (the internal main-memory enum value) is refused
|
|
436
|
+
* by the monitor itself (`monitor_binary.c:401-434`, CLAUDE.md's own
|
|
437
|
+
* "Protocol" constraint) and refused here too, before a single byte is
|
|
438
|
+
* ever sent.
|
|
439
|
+
*/
|
|
440
|
+
export function memspaceByte(memspace?: number): number {
|
|
441
|
+
if (memspace === undefined) {
|
|
442
|
+
return 0x00;
|
|
443
|
+
}
|
|
444
|
+
if (!Number.isInteger(memspace) || memspace < 0x00 || memspace > 0x04) {
|
|
445
|
+
throw new StockEncodingError(
|
|
446
|
+
`memspace byte must be 0x00 (main) or 0x01-0x04 (units 8-11) -- the wire memspace byte is not VICE's internal enum, and 0x08 is rejected by the monitor. Got 0x${memspace.toString(16).padStart(2, "0")}`,
|
|
447
|
+
{ field: "memspace" },
|
|
448
|
+
);
|
|
449
|
+
}
|
|
450
|
+
return memspace;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
export interface MemspaceBodyOptions {
|
|
454
|
+
memspace?: number;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/** The one-byte body shared by REGISTERS_GET (0x31) and REGISTERS_AVAILABLE
|
|
458
|
+
* (0x83). [CITED docs/phase0-binmon-findings.md §5] */
|
|
459
|
+
export function memspaceBody({ memspace }: MemspaceBodyOptions = {}): Buffer {
|
|
460
|
+
return Buffer.from([memspaceByte(memspace)]);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
export interface MemGetBodyOptions {
|
|
464
|
+
/** Default false -- DIRECT-01's side-effect-free-by-default read. Encoded
|
|
465
|
+
* as byte 0x00 when omitted, so a caller must opt IN to side effects. */
|
|
466
|
+
sidefx?: boolean;
|
|
467
|
+
start: number;
|
|
468
|
+
end: number;
|
|
469
|
+
memspace?: number;
|
|
470
|
+
bank?: number;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* MEM_GET (0x01) request body -- ALWAYS EXACTLY 8 BYTES:
|
|
475
|
+
* `sidefx(1) start(u16LE) end(u16LE) memspace(1) bank(u16LE)`.
|
|
476
|
+
* [VERIFIED against probe-binmon.mjs:268-276, this repo's own
|
|
477
|
+
* offline-tested reference; CITED docs/phase0-binmon-findings.md §5]
|
|
478
|
+
*
|
|
479
|
+
* The body is always 8 bytes -- never shorter -- because stock VICE's
|
|
480
|
+
* `monitor_binary.c` handler dereferences every one of these fields before
|
|
481
|
+
* it ever checks the declared body length against what a shorter caller
|
|
482
|
+
* might try to send; a truncated body would be read past its own end on
|
|
483
|
+
* the VICE side, not rejected cleanly.
|
|
484
|
+
*/
|
|
485
|
+
export function memGetBody({ sidefx = false, start, end, memspace, bank = 0x0000 }: MemGetBodyOptions): Buffer {
|
|
486
|
+
requireU16("start", start);
|
|
487
|
+
requireU16("end", end);
|
|
488
|
+
requireU16("bank", bank);
|
|
489
|
+
if (end < start) {
|
|
490
|
+
throw new StockEncodingError(`memGetBody: end (0x${end.toString(16)}) must be >= start (0x${start.toString(16)})`);
|
|
491
|
+
}
|
|
492
|
+
const body = Buffer.alloc(8);
|
|
493
|
+
body[0] = sidefx ? 0x01 : 0x00;
|
|
494
|
+
body.writeUInt16LE(start, 1);
|
|
495
|
+
body.writeUInt16LE(end, 3);
|
|
496
|
+
body[5] = memspaceByte(memspace);
|
|
497
|
+
body.writeUInt16LE(bank, 6);
|
|
498
|
+
return body;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
export interface MemSetBodyOptions {
|
|
502
|
+
start: number;
|
|
503
|
+
end: number;
|
|
504
|
+
memspace?: number;
|
|
505
|
+
bank?: number;
|
|
506
|
+
data: Buffer | Uint8Array;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/**
|
|
510
|
+
* MEM_SET (0x02) request body -- the same 8-byte header as memGetBody(),
|
|
511
|
+
* with `sidefx` forced to `0x00` (MEM_SET has no side-effect flag on the
|
|
512
|
+
* wire), then `data` appended at offset 8.
|
|
513
|
+
* [VERIFIED probe-binmon.mjs:278-287; CITED docs/phase0-binmon-findings.md §5]
|
|
514
|
+
*/
|
|
515
|
+
export function memSetBody({ start, end, memspace, bank = 0x0000, data }: MemSetBodyOptions): Buffer {
|
|
516
|
+
requireU16("start", start);
|
|
517
|
+
requireU16("end", end);
|
|
518
|
+
requireU16("bank", bank);
|
|
519
|
+
if (end < start) {
|
|
520
|
+
throw new StockEncodingError(`memSetBody: end (0x${end.toString(16)}) must be >= start (0x${start.toString(16)})`);
|
|
521
|
+
}
|
|
522
|
+
const expectedLength = end - start + 1;
|
|
523
|
+
if (data.length !== expectedLength) {
|
|
524
|
+
throw new StockEncodingError(
|
|
525
|
+
`memSetBody: data.length (${data.length}) must equal end - start + 1 (${expectedLength}) for start=0x${start.toString(16)}, end=0x${end.toString(16)}`,
|
|
526
|
+
);
|
|
527
|
+
}
|
|
528
|
+
const body = Buffer.alloc(8 + data.length);
|
|
529
|
+
body[0] = 0x00;
|
|
530
|
+
body.writeUInt16LE(start, 1);
|
|
531
|
+
body.writeUInt16LE(end, 3);
|
|
532
|
+
body[5] = memspaceByte(memspace);
|
|
533
|
+
body.writeUInt16LE(bank, 6);
|
|
534
|
+
Buffer.from(data).copy(body, 8);
|
|
535
|
+
return body;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/**
|
|
539
|
+
* Shared 4-byte `checkpointNum(u32LE)` body for CHECKPOINT_GET (0x11) and
|
|
540
|
+
* CHECKPOINT_DELETE (0x13). Not an options object (matching
|
|
541
|
+
* probe-binmon.mjs:314-318's own bare-number signature) since it has
|
|
542
|
+
* nothing else to validate offline.
|
|
543
|
+
* [VERIFIED probe-binmon.mjs:314-318; CITED docs/phase0-binmon-findings.md §5]
|
|
544
|
+
*/
|
|
545
|
+
export function cpNumBody(checkpointNum: number): Buffer {
|
|
546
|
+
requireU32("checkpointNum", checkpointNum);
|
|
547
|
+
const body = Buffer.alloc(4);
|
|
548
|
+
body.writeUInt32LE(checkpointNum, 0);
|
|
549
|
+
return body;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/** The op bitmask CHECKPOINT_SET's `operation` byte expects -- load/store/exec
|
|
553
|
+
* OR together. Exported so no family module hardcodes these three values. */
|
|
554
|
+
export const CheckpointOperation = { Load: 0x01, Store: 0x02, Exec: 0x04 } as const;
|
|
555
|
+
export type CheckpointOperation = (typeof CheckpointOperation)[keyof typeof CheckpointOperation];
|
|
556
|
+
|
|
557
|
+
export interface CheckpointSetBodyOptions {
|
|
558
|
+
start: number;
|
|
559
|
+
end: number;
|
|
560
|
+
stop?: boolean;
|
|
561
|
+
enabled?: boolean;
|
|
562
|
+
operation: number;
|
|
563
|
+
temporary?: boolean;
|
|
564
|
+
memspace?: number;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
/**
|
|
568
|
+
* CHECKPOINT_SET (0x12) request body -- 8 bytes, or 9 when `memspace` is
|
|
569
|
+
* supplied: `start(u16LE) end(u16LE) stop(1) enabled(1) operation(1)
|
|
570
|
+
* temporary(1) [memspace(1)]`.
|
|
571
|
+
* [VERIFIED probe-binmon.mjs:290-309; CITED docs/phase0-binmon-findings.md §5]
|
|
572
|
+
*/
|
|
573
|
+
export function checkpointSetBody({
|
|
574
|
+
start,
|
|
575
|
+
end,
|
|
576
|
+
stop = true,
|
|
577
|
+
enabled = true,
|
|
578
|
+
operation,
|
|
579
|
+
temporary = false,
|
|
580
|
+
memspace,
|
|
581
|
+
}: CheckpointSetBodyOptions): Buffer {
|
|
582
|
+
requireU16("start", start);
|
|
583
|
+
requireU16("end", end);
|
|
584
|
+
if (operation === 0) {
|
|
585
|
+
throw new StockEncodingError(
|
|
586
|
+
"checkpointSetBody: operation must include at least one of CheckpointOperation.Load (0x01), Store (0x02), Exec (0x04) -- a checkpoint that watches nothing",
|
|
587
|
+
);
|
|
588
|
+
}
|
|
589
|
+
const withMemspace = memspace !== undefined;
|
|
590
|
+
const body = Buffer.alloc(withMemspace ? 9 : 8);
|
|
591
|
+
body.writeUInt16LE(start, 0);
|
|
592
|
+
body.writeUInt16LE(end, 2);
|
|
593
|
+
body[4] = stop ? 0x01 : 0x00;
|
|
594
|
+
body[5] = enabled ? 0x01 : 0x00;
|
|
595
|
+
body[6] = operation;
|
|
596
|
+
body[7] = temporary ? 0x01 : 0x00;
|
|
597
|
+
if (withMemspace) {
|
|
598
|
+
body[8] = memspaceByte(memspace);
|
|
599
|
+
}
|
|
600
|
+
return body;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
export interface CheckpointToggleBodyOptions {
|
|
604
|
+
checkpointNum: number;
|
|
605
|
+
enabled: boolean;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
/** CHECKPOINT_TOGGLE (0x15) request body -- 5 bytes,
|
|
609
|
+
* `checkpointNum(u32LE) enabled(1)`. [CITED docs/phase0-binmon-findings.md §5] */
|
|
610
|
+
export function checkpointToggleBody({ checkpointNum, enabled }: CheckpointToggleBodyOptions): Buffer {
|
|
611
|
+
requireU32("checkpointNum", checkpointNum);
|
|
612
|
+
const body = Buffer.alloc(5);
|
|
613
|
+
body.writeUInt32LE(checkpointNum, 0);
|
|
614
|
+
body[4] = enabled ? 0x01 : 0x00;
|
|
615
|
+
return body;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
export interface ConditionSetBodyOptions {
|
|
619
|
+
checkpointNum: number;
|
|
620
|
+
expression: string;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
/**
|
|
624
|
+
* CONDITION_SET (0x22) request body -- `checkpointNum(u32LE) exprLen(1)
|
|
625
|
+
* expr(ASCII, NOT NUL-terminated)`.
|
|
626
|
+
* [VERIFIED probe-binmon.mjs:320-332, including its own >255-byte guard,
|
|
627
|
+
* ported verbatim; CITED docs/phase0-binmon-findings.md §5]
|
|
628
|
+
*
|
|
629
|
+
* This is the ONLY function in this tree that ever turns condition TEXT
|
|
630
|
+
* into wire bytes -- its `expression` argument must always come from
|
|
631
|
+
* stock-condition.ts's `emitCondition()`, never from a raw caller string
|
|
632
|
+
* (D-09/D-10). Throws BEFORE encoding, never truncates: an expression over
|
|
633
|
+
* 255 bytes would silently truncate `exprLen`, desyncing the stream this
|
|
634
|
+
* connection's demux depends on (probe-binmon.mjs's own comment frames
|
|
635
|
+
* this as an ASVS V5 input-validation control). Also refuses an empty
|
|
636
|
+
* expression and any non-ASCII byte, naming the offending character index
|
|
637
|
+
* -- the condition lexer is ASCII-only, and a multi-byte UTF-8 character
|
|
638
|
+
* would produce a byte length that disagrees with the character count the
|
|
639
|
+
* caller reasoned about.
|
|
640
|
+
*/
|
|
641
|
+
export function conditionSetBody({ checkpointNum, expression }: ConditionSetBodyOptions): Buffer {
|
|
642
|
+
requireU32("checkpointNum", checkpointNum);
|
|
643
|
+
if (expression.length === 0) {
|
|
644
|
+
throw new StockEncodingError("conditionSetBody: expression must not be empty");
|
|
645
|
+
}
|
|
646
|
+
for (let index = 0; index < expression.length; index += 1) {
|
|
647
|
+
const codePoint = expression.charCodeAt(index);
|
|
648
|
+
if (codePoint > 0x7f) {
|
|
649
|
+
throw new StockEncodingError(
|
|
650
|
+
`conditionSetBody: expression contains a non-ASCII character at index ${index} (code point ${codePoint}) -- the condition lexer is ASCII-only`,
|
|
651
|
+
);
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
const exprBuf = Buffer.from(expression, "ascii");
|
|
655
|
+
if (exprBuf.length > 255) {
|
|
656
|
+
throw new StockEncodingError(
|
|
657
|
+
`conditionSetBody: expression exceeds 255 bytes (${exprBuf.length}) -- exprLen is a uint8 and a silently truncated frame would desync the stream`,
|
|
658
|
+
);
|
|
659
|
+
}
|
|
660
|
+
const body = Buffer.alloc(5 + exprBuf.length);
|
|
661
|
+
body.writeUInt32LE(checkpointNum, 0);
|
|
662
|
+
body[4] = exprBuf.length;
|
|
663
|
+
exprBuf.copy(body, 5);
|
|
664
|
+
return body;
|
|
665
|
+
}
|
|
666
|
+
// Example, correctly parenthesised, hex literal, uppercase pseudo-registers:
|
|
667
|
+
// conditionSetBody({ checkpointNum, expression: "(RL == $64) && (CY == $14)" })
|
|
668
|
+
|
|
669
|
+
export interface RegisterSetItem {
|
|
670
|
+
id: number;
|
|
671
|
+
value: number;
|
|
672
|
+
/** Present for symmetry with the fork's own per-register descriptors, but
|
|
673
|
+
* NOT written to the wire as a variable stride -- REGISTERS_SET's
|
|
674
|
+
* itemSize byte is always 3 (see below). Reserved for a future item
|
|
675
|
+
* shape this encoder does not yet need to support. */
|
|
676
|
+
size?: number;
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
export interface RegistersSetBodyOptions {
|
|
680
|
+
memspace?: number;
|
|
681
|
+
items: RegisterSetItem[];
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
/**
|
|
685
|
+
* REGISTERS_SET (0x32) request body -- `memspace(1) count(u16LE)` then per
|
|
686
|
+
* item `itemSize(1) regId(1) value(u16LE)`, with `itemSize` always `3`
|
|
687
|
+
* (the byte count following the itemSize byte itself: 1 id byte + 2 value
|
|
688
|
+
* bytes). [CITED docs/phase0-binmon-findings.md §5]
|
|
689
|
+
*
|
|
690
|
+
* This is the structural inverse of this file's `ResponseType.RegisterInfo`
|
|
691
|
+
* parser case (below, in the response-parsing section -- grep `case
|
|
692
|
+
* ResponseType.RegisterInfo`), which walks items with the same `itemSize +
|
|
693
|
+
* 1` stride; keep the two in sync if VICE's item shape is ever probed and
|
|
694
|
+
* found to differ from the manual's description.
|
|
695
|
+
*/
|
|
696
|
+
export function registersSetBody({ memspace, items }: RegistersSetBodyOptions): Buffer {
|
|
697
|
+
if (items.length === 0) {
|
|
698
|
+
throw new StockEncodingError("registersSetBody: items must not be empty");
|
|
699
|
+
}
|
|
700
|
+
const memspaceB = memspaceByte(memspace);
|
|
701
|
+
const itemBuffers: Buffer[] = [];
|
|
702
|
+
for (const item of items) {
|
|
703
|
+
if (!Number.isInteger(item.id) || item.id < 0x00 || item.id > 0xff) {
|
|
704
|
+
throw new StockEncodingError(`registersSetBody: id must be an integer in 0x00..0xff, got ${item.id}`);
|
|
705
|
+
}
|
|
706
|
+
if (!Number.isInteger(item.value) || item.value < 0x0000 || item.value > 0xffff) {
|
|
707
|
+
throw new StockEncodingError(`registersSetBody: value must be an integer in 0x0000..0xffff, got ${item.value}`);
|
|
708
|
+
}
|
|
709
|
+
const itemBuf = Buffer.alloc(4);
|
|
710
|
+
itemBuf[0] = 3; // itemSize -- always 3 (regId + value), see JSDoc above
|
|
711
|
+
itemBuf[1] = item.id;
|
|
712
|
+
itemBuf.writeUInt16LE(item.value, 2);
|
|
713
|
+
itemBuffers.push(itemBuf);
|
|
714
|
+
}
|
|
715
|
+
const header = Buffer.alloc(3);
|
|
716
|
+
header[0] = memspaceB;
|
|
717
|
+
header.writeUInt16LE(items.length, 1);
|
|
718
|
+
return Buffer.concat([header, ...itemBuffers]);
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
// ---------------------------------------------------------------------------
|
|
722
|
+
// Execution and machine-control body encoders (Phase 3, Task 2). Every
|
|
723
|
+
// body layout below is [CITED] against the official VICE manual (§13) but
|
|
724
|
+
// has NOT been exercised against a real binary in this environment -- each
|
|
725
|
+
// JSDoc says so explicitly, and where RESEARCH.md's Assumptions Log flags a
|
|
726
|
+
// behavioural (not wire-shape) assumption, the JSDoc names the row (A2, A3,
|
|
727
|
+
// A5) and points at .planning/todos/pending/ for the probe debt. None of
|
|
728
|
+
// these are claimed as verified.
|
|
729
|
+
// ---------------------------------------------------------------------------
|
|
730
|
+
|
|
731
|
+
export interface AdvanceInstructionsBodyOptions {
|
|
732
|
+
stepOver?: boolean;
|
|
733
|
+
count?: number;
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
/**
|
|
737
|
+
* ADVANCE_INSTRUCTIONS (0x71) request body -- 3 bytes, `stepOver(1)
|
|
738
|
+
* count(u16LE)`. [CITED docs/phase0-binmon-findings.md §5; body SHAPE also
|
|
739
|
+
* exercised, with stepOver=0 only, in probe-binmon.mjs's async-events check]
|
|
740
|
+
*
|
|
741
|
+
* `stepOver = true`'s runtime meaning (skip a `JSR`'s subroutine as one
|
|
742
|
+
* step, matching the fork's own `stepOver` field name) is [ASSUMED] --
|
|
743
|
+
* RESEARCH.md Assumptions Log row A2 -- never probed against a real `JSR`.
|
|
744
|
+
* See `.planning/todos/pending/` for the outstanding probe debt.
|
|
745
|
+
*/
|
|
746
|
+
export function advanceInstructionsBody({ stepOver = false, count = 1 }: AdvanceInstructionsBodyOptions = {}): Buffer {
|
|
747
|
+
if (!Number.isInteger(count) || count < 1 || count > 0xffff) {
|
|
748
|
+
throw new StockEncodingError(`advanceInstructionsBody: count must be an integer in 1..0xffff, got ${count}`);
|
|
749
|
+
}
|
|
750
|
+
const body = Buffer.alloc(3);
|
|
751
|
+
body[0] = stepOver ? 0x01 : 0x00;
|
|
752
|
+
body.writeUInt16LE(count, 1);
|
|
753
|
+
return body;
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
export interface KeyboardFeedBodyOptions {
|
|
757
|
+
/** Already-converted PETSCII bytes ONLY -- ASCII->PETSCII conversion
|
|
758
|
+
* happens in stock-petscii.ts. Passing a JS string here is a type error
|
|
759
|
+
* on purpose, so no call site can accidentally feed UTF-16 code units to
|
|
760
|
+
* the emulator. */
|
|
761
|
+
petscii: Uint8Array | Buffer;
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
/**
|
|
765
|
+
* KEYBOARD_FEED (0x72) request body -- `textLen(1) text(bytes)`.
|
|
766
|
+
* [CITED docs/phase0-binmon-findings.md §5]
|
|
767
|
+
*/
|
|
768
|
+
export function keyboardFeedBody({ petscii }: KeyboardFeedBodyOptions): Buffer {
|
|
769
|
+
if (petscii.length === 0) {
|
|
770
|
+
throw new StockEncodingError("keyboardFeedBody: petscii must not be empty");
|
|
771
|
+
}
|
|
772
|
+
if (petscii.length > 255) {
|
|
773
|
+
throw new StockEncodingError(`keyboardFeedBody: petscii exceeds 255 bytes (${petscii.length}) -- textLen is a uint8`);
|
|
774
|
+
}
|
|
775
|
+
const body = Buffer.alloc(1 + petscii.length);
|
|
776
|
+
body[0] = petscii.length;
|
|
777
|
+
Buffer.from(petscii).copy(body, 1);
|
|
778
|
+
return body;
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
export interface JoyportSetBodyOptions {
|
|
782
|
+
port: number;
|
|
783
|
+
value: number;
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
/**
|
|
787
|
+
* JOYPORT_SET (0xa2) request body -- 4 bytes, `port(u16LE) value(u16LE)`.
|
|
788
|
+
* [CITED docs/phase0-binmon-findings.md §5]
|
|
789
|
+
*
|
|
790
|
+
* The body SHAPE is cited; the BIT MEANING of `value` (which bit is
|
|
791
|
+
* up/down/left/right/fire) is [ASSUMED] -- RESEARCH.md Assumptions Log row
|
|
792
|
+
* A3 -- and is mapped in stock-input.ts, not here. This encoder
|
|
793
|
+
* deliberately takes a raw, already-composed value so the assumed mapping
|
|
794
|
+
* lives in exactly one place a future probe session can correct.
|
|
795
|
+
*/
|
|
796
|
+
export function joyportSetBody({ port, value }: JoyportSetBodyOptions): Buffer {
|
|
797
|
+
requireU16("port", port);
|
|
798
|
+
requireU16("value", value);
|
|
799
|
+
const body = Buffer.alloc(4);
|
|
800
|
+
body.writeUInt16LE(port, 0);
|
|
801
|
+
body.writeUInt16LE(value, 2);
|
|
802
|
+
return body;
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
/** RESET's mode byte. `Soft`/`Hard` are [CITED]; `Drive8`-`Drive11` are
|
|
806
|
+
* [CITED] per the manual's `0x08`-`0x0b` drive-reset range. */
|
|
807
|
+
export const ResetMode = { Soft: 0x00, Hard: 0x01, Drive8: 0x08, Drive9: 0x09, Drive10: 0x0a, Drive11: 0x0b } as const;
|
|
808
|
+
export type ResetMode = (typeof ResetMode)[keyof typeof ResetMode];
|
|
809
|
+
|
|
810
|
+
export interface ResetBodyOptions {
|
|
811
|
+
mode: number;
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
/**
|
|
815
|
+
* RESET (0xcc) request body -- 1 byte, `resetMode`.
|
|
816
|
+
* [CITED docs/phase0-binmon-findings.md §5]
|
|
817
|
+
*
|
|
818
|
+
* NOT the RESOURCE_SET (0x52) power-cycle hazard CLAUDE.md warns about
|
|
819
|
+
* (`MachineVideoStandard`/`VICIIModel`/`MachinePowerFrequency`, Phase 6
|
|
820
|
+
* territory) -- this is a distinct opcode, and an agent-requested hard
|
|
821
|
+
* reset via RESET is exactly what DIRECT-06 asks for. It needs no
|
|
822
|
+
* deny-list. This is RESEARCH.md's Pitfall 1; this comment is what stops a
|
|
823
|
+
* later reviewer from "fixing" it by adding one.
|
|
824
|
+
*/
|
|
825
|
+
export function resetBody({ mode }: ResetBodyOptions): Buffer {
|
|
826
|
+
const validModes: readonly number[] = Object.values(ResetMode);
|
|
827
|
+
if (!validModes.includes(mode)) {
|
|
828
|
+
throw new StockEncodingError(
|
|
829
|
+
`resetBody: mode 0x${mode.toString(16).padStart(2, "0")} is not a recognised ResetMode (Soft 0x00, Hard 0x01, Drive8-Drive11 0x08-0x0b)`,
|
|
830
|
+
);
|
|
831
|
+
}
|
|
832
|
+
return Buffer.from([mode]);
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
export interface AutostartBodyOptions {
|
|
836
|
+
runAfter: boolean;
|
|
837
|
+
fileIndex?: number;
|
|
838
|
+
filename: string;
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
/**
|
|
842
|
+
* AUTOSTART (0xdd) request body -- `runAfter(1) fileIndex(u16LE)
|
|
843
|
+
* filenameLen(1) filename(ASCII)`. [CITED docs/phase0-binmon-findings.md §5]
|
|
844
|
+
*
|
|
845
|
+
* AUTOSTART has NO drive-unit field at all -- a caller cannot target units
|
|
846
|
+
* 9-11 through this opcode (plan 03-10 owns the refusal for those units).
|
|
847
|
+
* `fileIndex`'s behaviour when `runAfter` is false is [ASSUMED] --
|
|
848
|
+
* RESEARCH.md Assumptions Log row A5.
|
|
849
|
+
*/
|
|
850
|
+
export function autostartBody({ runAfter, fileIndex = 0, filename }: AutostartBodyOptions): Buffer {
|
|
851
|
+
requireU16("fileIndex", fileIndex);
|
|
852
|
+
const filenameBuf = requireAsciiFilename("autostartBody", filename);
|
|
853
|
+
const body = Buffer.alloc(1 + 2 + 1 + filenameBuf.length);
|
|
854
|
+
body[0] = runAfter ? 0x01 : 0x00;
|
|
855
|
+
body.writeUInt16LE(fileIndex, 1);
|
|
856
|
+
body[3] = filenameBuf.length;
|
|
857
|
+
filenameBuf.copy(body, 4);
|
|
858
|
+
return body;
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
export interface DumpBodyOptions {
|
|
862
|
+
saveRoms: boolean;
|
|
863
|
+
saveDisks: boolean;
|
|
864
|
+
filename: string;
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
/**
|
|
868
|
+
* DUMP (0x41) request body -- `saveRoms(1) saveDisks(1) filenameLen(1)
|
|
869
|
+
* filename(ASCII)`. [CITED docs/phase0-binmon-findings.md §5]
|
|
870
|
+
*/
|
|
871
|
+
export function dumpBody({ saveRoms, saveDisks, filename }: DumpBodyOptions): Buffer {
|
|
872
|
+
const filenameBuf = requireAsciiFilename("dumpBody", filename);
|
|
873
|
+
const body = Buffer.alloc(1 + 1 + 1 + filenameBuf.length);
|
|
874
|
+
body[0] = saveRoms ? 0x01 : 0x00;
|
|
875
|
+
body[1] = saveDisks ? 0x01 : 0x00;
|
|
876
|
+
body[2] = filenameBuf.length;
|
|
877
|
+
filenameBuf.copy(body, 3);
|
|
878
|
+
return body;
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
export interface UndumpBodyOptions {
|
|
882
|
+
filename: string;
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
/** UNDUMP (0x42) request body -- `filenameLen(1) filename(ASCII)`.
|
|
886
|
+
* [CITED docs/phase0-binmon-findings.md §5] */
|
|
887
|
+
export function undumpBody({ filename }: UndumpBodyOptions): Buffer {
|
|
888
|
+
const filenameBuf = requireAsciiFilename("undumpBody", filename);
|
|
889
|
+
const body = Buffer.alloc(1 + filenameBuf.length);
|
|
890
|
+
body[0] = filenameBuf.length;
|
|
891
|
+
filenameBuf.copy(body, 1);
|
|
892
|
+
return body;
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
/** Shared filename guard for autostartBody/dumpBody/undumpBody: refuses a
|
|
896
|
+
* length of 0 or over 255 (the length field is a uint8) and any non-ASCII
|
|
897
|
+
* byte, naming the offending index -- the same discipline as
|
|
898
|
+
* conditionSetBody()'s expression guard above. Returns the encoded ASCII
|
|
899
|
+
* Buffer so callers never re-encode. */
|
|
900
|
+
function requireAsciiFilename(callerName: string, filename: string): Buffer {
|
|
901
|
+
if (filename.length === 0) {
|
|
902
|
+
throw new StockEncodingError(`${callerName}: filename must not be empty`);
|
|
903
|
+
}
|
|
904
|
+
for (let index = 0; index < filename.length; index += 1) {
|
|
905
|
+
const codePoint = filename.charCodeAt(index);
|
|
906
|
+
if (codePoint > 0x7f) {
|
|
907
|
+
throw new StockEncodingError(`${callerName}: filename contains a non-ASCII character at index ${index} (code point ${codePoint})`);
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
const filenameBuf = Buffer.from(filename, "ascii");
|
|
911
|
+
if (filenameBuf.length > 255) {
|
|
912
|
+
throw new StockEncodingError(`${callerName}: filename exceeds 255 bytes (${filenameBuf.length}) -- filenameLen is a uint8`);
|
|
913
|
+
}
|
|
914
|
+
return filenameBuf;
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
// CHECKPOINT_LIST (0x14), PING (0x81), BANKS_AVAILABLE (0x82),
|
|
918
|
+
// EXECUTE_UNTIL_RETURN (0x73) and EXIT (0xaa) take EMPTY bodies --
|
|
919
|
+
// deliberately no encoder for any of the five: ViceMonitorClient.send()
|
|
920
|
+
// already defaults `body` to Buffer.alloc(0). Do not add a no-op builder
|
|
921
|
+
// for any of these five opcodes.
|
|
922
|
+
|
|
923
|
+
// ---------------------------------------------------------------------------
|
|
924
|
+
// Parsed response shapes
|
|
925
|
+
// ---------------------------------------------------------------------------
|
|
926
|
+
|
|
927
|
+
export interface ParsedBaseResponse {
|
|
928
|
+
requestId: number;
|
|
929
|
+
errorCode: number;
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
export interface ParsedMemoryGetResponse extends ParsedBaseResponse {
|
|
933
|
+
type: "memory_get";
|
|
934
|
+
bytes: Uint8Array;
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
export interface ParsedRegistersResponse extends ParsedBaseResponse {
|
|
938
|
+
type: "registers";
|
|
939
|
+
registers: Array<{ id: number; value: number }>;
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
export interface ParsedRegistersAvailableResponse extends ParsedBaseResponse {
|
|
943
|
+
type: "registers_available";
|
|
944
|
+
registers: Array<{ id: number; size: number; name: string }>;
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
/**
|
|
948
|
+
* BANKS_AVAILABLE (0x82) parsed shape. Added by plan 03-06 -- plan 03-02
|
|
949
|
+
* added CommandType.BanksAvailable/ResponseType.BanksAvailable and the
|
|
950
|
+
* EXPECTED_RESPONSE entry mapping the two, but never added this parser case
|
|
951
|
+
* or its RESPONSE_TYPE_OF_PARSED_KIND entry, so every BANKS_AVAILABLE reply
|
|
952
|
+
* fell through to the "unknown" fallback shape with no name/id pairs
|
|
953
|
+
* extractable at all -- a hard blocker for stock-memory.ts's bank catalog
|
|
954
|
+
* (03-06 Task 2). [CITED: official binary-monitor protocol documentation's
|
|
955
|
+
* BANKS_AVAILABLE response shape -- bank ids are a WORD, unlike
|
|
956
|
+
* REGISTERS_AVAILABLE's single-byte register ids, and there is no per-item
|
|
957
|
+
* "size" field the way registers have one.]
|
|
958
|
+
*/
|
|
959
|
+
export interface ParsedBanksAvailableResponse extends ParsedBaseResponse {
|
|
960
|
+
type: "banks_available";
|
|
961
|
+
banks: Array<{ id: number; name: string }>;
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
export interface ParsedViceInfoResponse extends ParsedBaseResponse {
|
|
965
|
+
type: "vice_info";
|
|
966
|
+
version: number[];
|
|
967
|
+
versionString: string;
|
|
968
|
+
svnVersion: number;
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
/** Checkpoint field layout per the vendor's byte offsets, with `kind`
|
|
972
|
+
* deliberately NOT mapped to a named BreakpointKind -- that mapping lives in
|
|
973
|
+
* the vendor's contracts.ts, which this module must not import (D-16). The
|
|
974
|
+
* raw wire operation byte is carried instead; a later plan that needs the
|
|
975
|
+
* named mapping owns re-deriving it without pulling in that dependency. */
|
|
976
|
+
export interface ParsedCheckpoint {
|
|
977
|
+
id: number;
|
|
978
|
+
currentlyHit: boolean;
|
|
979
|
+
start: number;
|
|
980
|
+
end: number;
|
|
981
|
+
stopWhenHit: boolean;
|
|
982
|
+
enabled: boolean;
|
|
983
|
+
operation: number;
|
|
984
|
+
temporary: boolean;
|
|
985
|
+
hitCount: number;
|
|
986
|
+
ignoreCount: number;
|
|
987
|
+
hasCondition: boolean;
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
export interface ParsedCheckpointInfoResponse extends ParsedBaseResponse {
|
|
991
|
+
type: "checkpoint_info";
|
|
992
|
+
checkpoint: ParsedCheckpoint;
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
export interface ParsedCheckpointListResponse extends ParsedBaseResponse {
|
|
996
|
+
type: "checkpoint_list";
|
|
997
|
+
total: number;
|
|
998
|
+
checkpoints: ParsedCheckpoint[];
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
export interface ParsedDisplayResponse extends ParsedBaseResponse {
|
|
1002
|
+
type: "display";
|
|
1003
|
+
debugWidth: number;
|
|
1004
|
+
debugHeight: number;
|
|
1005
|
+
debugOffsetX: number;
|
|
1006
|
+
debugOffsetY: number;
|
|
1007
|
+
innerWidth: number;
|
|
1008
|
+
innerHeight: number;
|
|
1009
|
+
bitsPerPixel: number;
|
|
1010
|
+
imageBytes: Uint8Array;
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
export interface ParsedPaletteItem {
|
|
1014
|
+
index: number;
|
|
1015
|
+
red: number;
|
|
1016
|
+
green: number;
|
|
1017
|
+
blue: number;
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
export interface ParsedPaletteResponse extends ParsedBaseResponse {
|
|
1021
|
+
type: "palette";
|
|
1022
|
+
items: ParsedPaletteItem[];
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
export interface ParsedStoppedEvent extends ParsedBaseResponse {
|
|
1026
|
+
type: "stopped";
|
|
1027
|
+
programCounter: number;
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
export interface ParsedResumedEvent extends ParsedBaseResponse {
|
|
1031
|
+
type: "resumed";
|
|
1032
|
+
programCounter: number;
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
/** JAM (0x61): defect (a) fix. per monitor_binary.c:384-394, the PC is
|
|
1036
|
+
* computed then a zero-length body is sent -- programCounter is `null` when
|
|
1037
|
+
* the body is short, never fabricated as 0 (which is indistinguishable from
|
|
1038
|
+
* a real PC of $0000). */
|
|
1039
|
+
export interface ParsedJamEvent extends ParsedBaseResponse {
|
|
1040
|
+
type: "jam";
|
|
1041
|
+
programCounter: number | null;
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
export interface ParsedUndumpResponse extends ParsedBaseResponse {
|
|
1045
|
+
type: "undump";
|
|
1046
|
+
programCounter: number;
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
/** Fallback shape for any responseType this module has no specific case for
|
|
1050
|
+
* (including VERIF-02 case 6's response type byte 0x00, which is never a
|
|
1051
|
+
* real response/event type on the wire) -- the parser must produce this
|
|
1052
|
+
* shape rather than throw. */
|
|
1053
|
+
export interface ParsedUnknownResponse extends ParsedBaseResponse {
|
|
1054
|
+
type: "unknown";
|
|
1055
|
+
responseType: number;
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
export type ParsedResponse =
|
|
1059
|
+
| ParsedMemoryGetResponse
|
|
1060
|
+
| ParsedRegistersResponse
|
|
1061
|
+
| ParsedRegistersAvailableResponse
|
|
1062
|
+
| ParsedBanksAvailableResponse
|
|
1063
|
+
| ParsedViceInfoResponse
|
|
1064
|
+
| ParsedCheckpointInfoResponse
|
|
1065
|
+
| ParsedCheckpointListResponse
|
|
1066
|
+
| ParsedDisplayResponse
|
|
1067
|
+
| ParsedPaletteResponse
|
|
1068
|
+
| ParsedStoppedEvent
|
|
1069
|
+
| ParsedResumedEvent
|
|
1070
|
+
| ParsedJamEvent
|
|
1071
|
+
| ParsedUndumpResponse
|
|
1072
|
+
| ParsedUnknownResponse;
|
|
1073
|
+
|
|
1074
|
+
// ---------------------------------------------------------------------------
|
|
1075
|
+
// Response parsing
|
|
1076
|
+
// ---------------------------------------------------------------------------
|
|
1077
|
+
|
|
1078
|
+
export interface ParseResponseOptions {
|
|
1079
|
+
apiVersion: number;
|
|
1080
|
+
responseType: number;
|
|
1081
|
+
errorCode: number;
|
|
1082
|
+
requestId: number;
|
|
1083
|
+
body: Buffer;
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
/**
|
|
1087
|
+
* CR-01 (code review 2026-08-13): every read below is bounds-checked through
|
|
1088
|
+
* need() FIRST. Before this guard existed, a COMPLETE frame whose body was
|
|
1089
|
+
* shorter than its response type requires threw a bare `RangeError` out of
|
|
1090
|
+
* this function -- past parseBuffer()'s deliberately narrow catch (which
|
|
1091
|
+
* absorbs only this function's own two documented throw types), out of a
|
|
1092
|
+
* seam whose own doc comment promises it "never throws". Four shapes were
|
|
1093
|
+
* reproduced against the pre-fix code: a zero-length STOPPED (0x62), a
|
|
1094
|
+
* 1-byte MEM_GET (0x01), a CHECKPOINT_INFO (0x11) with a 10-byte body, and a
|
|
1095
|
+
* DISPLAY_GET (0x84) whose wire-controlled `info_len` read 0xfffffff0. The
|
|
1096
|
+
* last one is reachable from ordinary desync (once the scanner locks onto a
|
|
1097
|
+
* false STX with a plausible length, every body byte is garbage-controlled),
|
|
1098
|
+
* and inside ViceMonitorClient it discarded the WHOLE accumulated buffer --
|
|
1099
|
+
* including complete, valid frames already sitting in it -- and left every
|
|
1100
|
+
* in-flight request to time out.
|
|
1101
|
+
*
|
|
1102
|
+
* What NOT to do: never add a `case` here that reads at a fixed or
|
|
1103
|
+
* wire-derived offset without a preceding need() for the bytes it touches.
|
|
1104
|
+
* A short body is a FRAMING ERROR this function returns through its
|
|
1105
|
+
* documented StockFramingError channel, never a RangeError it lets escape.
|
|
1106
|
+
*/
|
|
1107
|
+
function need(body: Buffer, bytes: number, responseType: number, requestId: number): void {
|
|
1108
|
+
if (body.length < bytes) {
|
|
1109
|
+
throw new StockFramingError(
|
|
1110
|
+
`response type 0x${responseType.toString(16).padStart(2, "0")} body is ${body.length} byte(s), needs at least ${bytes}`,
|
|
1111
|
+
{ observed: body.length, expected: bytes, responseType, requestId },
|
|
1112
|
+
);
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
/**
|
|
1117
|
+
* Decode one already-framed response. May THROW StockFramingError (api
|
|
1118
|
+
* version mismatch, or a body too short for the response type -- see need()
|
|
1119
|
+
* above) or StockProtocolError (non-OK wire error code) -- parseBuffer()
|
|
1120
|
+
* below is the seam that catches both and packages them into its responses
|
|
1121
|
+
* array instead of letting them escape. Never throws for an unrecognized
|
|
1122
|
+
* responseType; that falls through to the "unknown" shape. Provably total
|
|
1123
|
+
* for any Buffer: every offset read is preceded by a need() covering it.
|
|
1124
|
+
*/
|
|
1125
|
+
export function parseResponse({ apiVersion, responseType, errorCode, requestId, body }: ParseResponseOptions): ParsedResponse {
|
|
1126
|
+
if (apiVersion !== VICE_API_VERSION) {
|
|
1127
|
+
throw new StockFramingError(
|
|
1128
|
+
`observed api_version 0x${apiVersion.toString(16).padStart(2, "0")}, expected 0x${VICE_API_VERSION.toString(16).padStart(2, "0")}`,
|
|
1129
|
+
{ observed: apiVersion, expected: VICE_API_VERSION, responseType, requestId },
|
|
1130
|
+
);
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
if (errorCode !== ErrorCode.Ok) {
|
|
1134
|
+
throw new StockProtocolError(
|
|
1135
|
+
`binary monitor returned error code 0x${errorCode.toString(16).padStart(2, "0")} for response type 0x${responseType.toString(16).padStart(2, "0")}`,
|
|
1136
|
+
{ errorCode, responseType, requestId },
|
|
1137
|
+
);
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
switch (responseType) {
|
|
1141
|
+
case ResponseType.MemoryGet: {
|
|
1142
|
+
need(body, 2, responseType, requestId);
|
|
1143
|
+
const length = body.readUInt16LE(0);
|
|
1144
|
+
need(body, 2 + length, responseType, requestId);
|
|
1145
|
+
return { type: "memory_get", requestId, errorCode, bytes: body.subarray(2, 2 + length) };
|
|
1146
|
+
}
|
|
1147
|
+
case ResponseType.RegisterInfo: {
|
|
1148
|
+
// WR-09: the stride comes from the wire's own per-item size byte, exactly
|
|
1149
|
+
// like the RegistersAvailable case below. This case previously computed
|
|
1150
|
+
// `2 + index * 4` -- stepping OVER the item_size byte it had just read
|
|
1151
|
+
// past without using it -- so any register whose declared size is not 2
|
|
1152
|
+
// bytes (or any future item-layout change) silently mis-parsed the WHOLE
|
|
1153
|
+
// array rather than failing. These values feed LIN/CYC cycle
|
|
1154
|
+
// reconstruction in a later phase, where a silently shifted array is a
|
|
1155
|
+
// wrong answer, not an error.
|
|
1156
|
+
need(body, 2, responseType, requestId);
|
|
1157
|
+
const count = body.readUInt16LE(0);
|
|
1158
|
+
let offset = 2;
|
|
1159
|
+
const registers: Array<{ id: number; value: number }> = [];
|
|
1160
|
+
for (let index = 0; index < count; index += 1) {
|
|
1161
|
+
need(body, offset + 4, responseType, requestId);
|
|
1162
|
+
const itemSize = body[offset]!;
|
|
1163
|
+
registers.push({ id: body[offset + 1]!, value: body.readUInt16LE(offset + 2) });
|
|
1164
|
+
offset += itemSize + 1;
|
|
1165
|
+
}
|
|
1166
|
+
return { type: "registers", requestId, errorCode, registers };
|
|
1167
|
+
}
|
|
1168
|
+
case ResponseType.RegistersAvailable: {
|
|
1169
|
+
need(body, 2, responseType, requestId);
|
|
1170
|
+
const count = body.readUInt16LE(0);
|
|
1171
|
+
let offset = 2;
|
|
1172
|
+
const registers: Array<{ id: number; size: number; name: string }> = [];
|
|
1173
|
+
for (let index = 0; index < count; index += 1) {
|
|
1174
|
+
need(body, offset + 4, responseType, requestId);
|
|
1175
|
+
const itemSize = body[offset]!;
|
|
1176
|
+
const id = body[offset + 1]!;
|
|
1177
|
+
const size = body[offset + 2]!;
|
|
1178
|
+
const nameLength = body[offset + 3]!;
|
|
1179
|
+
need(body, offset + 4 + nameLength, responseType, requestId);
|
|
1180
|
+
const name = body.subarray(offset + 4, offset + 4 + nameLength).toString("ascii");
|
|
1181
|
+
registers.push({ id, size, name });
|
|
1182
|
+
offset += itemSize + 1;
|
|
1183
|
+
}
|
|
1184
|
+
return { type: "registers_available", requestId, errorCode, registers };
|
|
1185
|
+
}
|
|
1186
|
+
case ResponseType.BanksAvailable: {
|
|
1187
|
+
// BANKS_AVAILABLE (0x82): count(u16LE), then per item item_size(1)
|
|
1188
|
+
// id(u16LE) nameLength(1) name(nameLength ASCII). Same
|
|
1189
|
+
// item_size-is-the-wire's-own-stride discipline as RegistersAvailable
|
|
1190
|
+
// just above (WR-09) -- a bank id is a WORD (unlike a register id,
|
|
1191
|
+
// which is a single byte), and there is no per-item "size" field the
|
|
1192
|
+
// way REGISTERS_AVAILABLE has one. [CITED: official binary-monitor
|
|
1193
|
+
// protocol documentation's BANKS_AVAILABLE response shape]
|
|
1194
|
+
need(body, 2, responseType, requestId);
|
|
1195
|
+
const count = body.readUInt16LE(0);
|
|
1196
|
+
let offset = 2;
|
|
1197
|
+
const banks: Array<{ id: number; name: string }> = [];
|
|
1198
|
+
for (let index = 0; index < count; index += 1) {
|
|
1199
|
+
need(body, offset + 4, responseType, requestId);
|
|
1200
|
+
const itemSize = body[offset]!;
|
|
1201
|
+
const id = body.readUInt16LE(offset + 1);
|
|
1202
|
+
const nameLength = body[offset + 3]!;
|
|
1203
|
+
need(body, offset + 4 + nameLength, responseType, requestId);
|
|
1204
|
+
const name = body.subarray(offset + 4, offset + 4 + nameLength).toString("ascii");
|
|
1205
|
+
banks.push({ id, name });
|
|
1206
|
+
offset += itemSize + 1;
|
|
1207
|
+
}
|
|
1208
|
+
return { type: "banks_available", requestId, errorCode, banks };
|
|
1209
|
+
}
|
|
1210
|
+
case ResponseType.ViceInfo: {
|
|
1211
|
+
const mainVersionLength = body[0] ?? 0;
|
|
1212
|
+
const version = Array.from(body.subarray(1, 1 + mainVersionLength));
|
|
1213
|
+
const svnLengthOffset = 1 + mainVersionLength;
|
|
1214
|
+
const svnLength = body[svnLengthOffset] ?? 0;
|
|
1215
|
+
const svnBytes = body.subarray(svnLengthOffset + 1, svnLengthOffset + 1 + svnLength);
|
|
1216
|
+
let svnVersion = 0;
|
|
1217
|
+
for (let index = 0; index < svnBytes.length; index += 1) {
|
|
1218
|
+
svnVersion += (svnBytes[index] ?? 0) * 2 ** (index * 8);
|
|
1219
|
+
}
|
|
1220
|
+
return {
|
|
1221
|
+
type: "vice_info",
|
|
1222
|
+
requestId,
|
|
1223
|
+
errorCode,
|
|
1224
|
+
version,
|
|
1225
|
+
versionString: version.join("."),
|
|
1226
|
+
svnVersion,
|
|
1227
|
+
};
|
|
1228
|
+
}
|
|
1229
|
+
case ResponseType.CheckpointInfo: {
|
|
1230
|
+
// 22 = the last field's own extent: hasCondition sits at body[21].
|
|
1231
|
+
need(body, 22, responseType, requestId);
|
|
1232
|
+
const operation = body[11] ?? 0x04;
|
|
1233
|
+
const checkpoint: ParsedCheckpoint = {
|
|
1234
|
+
id: body.readUInt32LE(0),
|
|
1235
|
+
currentlyHit: body[4] === 1,
|
|
1236
|
+
start: body.readUInt16LE(5),
|
|
1237
|
+
end: body.readUInt16LE(7),
|
|
1238
|
+
stopWhenHit: body[9] === 1,
|
|
1239
|
+
enabled: body[10] === 1,
|
|
1240
|
+
operation,
|
|
1241
|
+
temporary: body[12] === 1,
|
|
1242
|
+
hitCount: body.readUInt32LE(13),
|
|
1243
|
+
ignoreCount: body.readUInt32LE(17),
|
|
1244
|
+
hasCondition: body[21] === 1,
|
|
1245
|
+
};
|
|
1246
|
+
return { type: "checkpoint_info", requestId, errorCode, checkpoint };
|
|
1247
|
+
}
|
|
1248
|
+
case ResponseType.CheckpointList: {
|
|
1249
|
+
// total's checkpoints are filled by request-id correlation across the
|
|
1250
|
+
// preceding CHECKPOINT_INFO events sharing this request id -- that
|
|
1251
|
+
// demux is plan 02-06's, not this parser's; always empty here, same as
|
|
1252
|
+
// the vendor.
|
|
1253
|
+
need(body, 4, responseType, requestId);
|
|
1254
|
+
return { type: "checkpoint_list", requestId, errorCode, total: body.readUInt32LE(0), checkpoints: [] };
|
|
1255
|
+
}
|
|
1256
|
+
case ResponseType.DisplayGet: {
|
|
1257
|
+
// Layout: [info_len:u32LE][dw,dh,xo,yo,iw,ih:u16LE each][bpp:1]
|
|
1258
|
+
// [buflen:u32LE][buffer...]. buflenOff and the pixel-buffer start are
|
|
1259
|
+
// DERIVED from infoLength, never hardcoded to 17/21 -- see this file's
|
|
1260
|
+
// header comment on the vendor's off-by-four defect, and
|
|
1261
|
+
// probe-binmon.mjs:parseDisplayGet()'s matching, already-tested
|
|
1262
|
+
// derivation.
|
|
1263
|
+
need(body, 4, responseType, requestId);
|
|
1264
|
+
const infoLength = body.readUInt32LE(0);
|
|
1265
|
+
// The six u16 geometry fields plus the bpp byte occupy body[4..16], so
|
|
1266
|
+
// 17 bytes is the floor for the reads below regardless of what
|
|
1267
|
+
// `info_len` claims. Then `buflen`'s own offset and the pixel buffer's
|
|
1268
|
+
// declared extent are each validated against the REAL body length --
|
|
1269
|
+
// CR-01 case C was a desynced stream whose info_len read 0xfffffff0,
|
|
1270
|
+
// which threw a RangeError straight out of this parser.
|
|
1271
|
+
need(body, 17, responseType, requestId);
|
|
1272
|
+
const buflenOffset = 4 + infoLength;
|
|
1273
|
+
need(body, buflenOffset + 4, responseType, requestId);
|
|
1274
|
+
const imageLength = body.readUInt32LE(buflenOffset);
|
|
1275
|
+
const bufStart = buflenOffset + 4;
|
|
1276
|
+
need(body, bufStart + imageLength, responseType, requestId);
|
|
1277
|
+
return {
|
|
1278
|
+
type: "display",
|
|
1279
|
+
requestId,
|
|
1280
|
+
errorCode,
|
|
1281
|
+
debugWidth: body.readUInt16LE(4),
|
|
1282
|
+
debugHeight: body.readUInt16LE(6),
|
|
1283
|
+
debugOffsetX: body.readUInt16LE(8),
|
|
1284
|
+
debugOffsetY: body.readUInt16LE(10),
|
|
1285
|
+
innerWidth: body.readUInt16LE(12),
|
|
1286
|
+
innerHeight: body.readUInt16LE(14),
|
|
1287
|
+
bitsPerPixel: body[16] ?? 0,
|
|
1288
|
+
imageBytes: body.subarray(bufStart, bufStart + imageLength),
|
|
1289
|
+
};
|
|
1290
|
+
}
|
|
1291
|
+
case ResponseType.PaletteGet: {
|
|
1292
|
+
need(body, 2, responseType, requestId);
|
|
1293
|
+
const count = body.readUInt16LE(0);
|
|
1294
|
+
let offset = 2;
|
|
1295
|
+
const items: ParsedPaletteItem[] = [];
|
|
1296
|
+
for (let index = 0; index < count; index += 1) {
|
|
1297
|
+
need(body, offset + 4, responseType, requestId);
|
|
1298
|
+
const itemSize = body[offset] ?? 0;
|
|
1299
|
+
items.push({
|
|
1300
|
+
index,
|
|
1301
|
+
red: body[offset + 1] ?? 0,
|
|
1302
|
+
green: body[offset + 2] ?? 0,
|
|
1303
|
+
blue: body[offset + 3] ?? 0,
|
|
1304
|
+
});
|
|
1305
|
+
offset += itemSize + 1;
|
|
1306
|
+
}
|
|
1307
|
+
return { type: "palette", requestId, errorCode, items };
|
|
1308
|
+
}
|
|
1309
|
+
case ResponseType.Stopped:
|
|
1310
|
+
need(body, 2, responseType, requestId);
|
|
1311
|
+
return { type: "stopped", requestId, errorCode, programCounter: body.readUInt16LE(0) };
|
|
1312
|
+
case ResponseType.Resumed:
|
|
1313
|
+
need(body, 2, responseType, requestId);
|
|
1314
|
+
return { type: "resumed", requestId, errorCode, programCounter: body.readUInt16LE(0) };
|
|
1315
|
+
case ResponseType.Jam:
|
|
1316
|
+
// Defect (a) fix: never call readUInt16LE on a body that might be
|
|
1317
|
+
// zero-length. programCounter is null, not a fabricated 0.
|
|
1318
|
+
return { type: "jam", requestId, errorCode, programCounter: body.length >= 2 ? body.readUInt16LE(0) : null };
|
|
1319
|
+
case ResponseType.Undump:
|
|
1320
|
+
need(body, 2, responseType, requestId);
|
|
1321
|
+
return { type: "undump", requestId, errorCode, programCounter: body.readUInt16LE(0) };
|
|
1322
|
+
default:
|
|
1323
|
+
return { type: "unknown", requestId, errorCode, responseType };
|
|
1324
|
+
}
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
// ---------------------------------------------------------------------------
|
|
1328
|
+
// Buffer framing
|
|
1329
|
+
// ---------------------------------------------------------------------------
|
|
1330
|
+
|
|
1331
|
+
export interface ParseCounters {
|
|
1332
|
+
desyncBytes: number;
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
export interface ParseBufferResult {
|
|
1336
|
+
responses: Array<ParsedResponse | StockProtocolError | StockFramingError>;
|
|
1337
|
+
remainder: Buffer;
|
|
1338
|
+
desyncBytes: number;
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
/**
|
|
1342
|
+
* Frame and parse every complete response in `buffer`, returning the
|
|
1343
|
+
* unconsumed tail as `remainder` for the next chunk. Defect (b) fix: an STX
|
|
1344
|
+
* mismatch or an implausible declared body length never throws -- the parser
|
|
1345
|
+
* advances exactly one byte, counts it in `counters.desyncBytes` (mutated in
|
|
1346
|
+
* place, so a caller can track a running total across many calls), and keeps
|
|
1347
|
+
* scanning within this same call. A declared body length above MAX_BODY_LEN
|
|
1348
|
+
* is treated identically -- skipped one byte at a time, never allocated
|
|
1349
|
+
* against. This function never throws: StockFramingError/StockProtocolError
|
|
1350
|
+
* raised by parseResponse() are caught here and returned inside `responses`
|
|
1351
|
+
* rather than escaping the loop, and (CR-01) so is any other throw -- a
|
|
1352
|
+
* COMPLETE frame whose body is shorter than its response type requires is a
|
|
1353
|
+
* returned StockFramingError, never a RangeError out of this seam.
|
|
1354
|
+
*/
|
|
1355
|
+
export function parseBuffer(buffer: Buffer, counters: ParseCounters = { desyncBytes: 0 }): ParseBufferResult {
|
|
1356
|
+
const responses: Array<ParsedResponse | StockProtocolError | StockFramingError> = [];
|
|
1357
|
+
let offset = 0;
|
|
1358
|
+
let desyncEpisodeActive = false;
|
|
1359
|
+
|
|
1360
|
+
while (offset + RESPONSE_HEADER_LEN <= buffer.length) {
|
|
1361
|
+
if (buffer[offset] !== VICE_STX) {
|
|
1362
|
+
if (!desyncEpisodeActive) {
|
|
1363
|
+
console.error(
|
|
1364
|
+
`[framing] desync at offset ${offset} (byte 0x${buffer[offset]!.toString(16).padStart(2, "0")} is not STX) -- resyncing one byte at a time`,
|
|
1365
|
+
);
|
|
1366
|
+
desyncEpisodeActive = true;
|
|
1367
|
+
}
|
|
1368
|
+
offset += 1;
|
|
1369
|
+
counters.desyncBytes += 1;
|
|
1370
|
+
continue;
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
const bodyLength = buffer.readUInt32LE(offset + 2);
|
|
1374
|
+
if (bodyLength > MAX_BODY_LEN) {
|
|
1375
|
+
if (!desyncEpisodeActive) {
|
|
1376
|
+
console.error(
|
|
1377
|
+
`[framing] implausible body length ${bodyLength} at offset ${offset} -- treating as desync, resyncing one byte`,
|
|
1378
|
+
);
|
|
1379
|
+
desyncEpisodeActive = true;
|
|
1380
|
+
}
|
|
1381
|
+
offset += 1;
|
|
1382
|
+
counters.desyncBytes += 1;
|
|
1383
|
+
continue;
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
const frameLength = RESPONSE_HEADER_LEN + bodyLength;
|
|
1387
|
+
if (offset + frameLength > buffer.length) {
|
|
1388
|
+
break; // frame incomplete -- wait for more bytes, buffer left intact from here
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1391
|
+
desyncEpisodeActive = false;
|
|
1392
|
+
|
|
1393
|
+
const apiVersion = buffer[offset + 1]!;
|
|
1394
|
+
const responseType = buffer[offset + 6]!;
|
|
1395
|
+
const errorCode = buffer[offset + 7]!;
|
|
1396
|
+
const requestId = buffer.readUInt32LE(offset + 8);
|
|
1397
|
+
const body = buffer.subarray(offset + RESPONSE_HEADER_LEN, offset + frameLength);
|
|
1398
|
+
|
|
1399
|
+
try {
|
|
1400
|
+
responses.push(parseResponse({ apiVersion, responseType, errorCode, requestId, body }));
|
|
1401
|
+
} catch (err) {
|
|
1402
|
+
if (err instanceof StockFramingError || err instanceof StockProtocolError) {
|
|
1403
|
+
responses.push(err);
|
|
1404
|
+
} else {
|
|
1405
|
+
// Not one of parseResponse()'s two documented throw types -- a
|
|
1406
|
+
// genuinely unexpected bug, not a wire-format event this seam is
|
|
1407
|
+
// designed to absorb. CR-01 (code review 2026-08-13): it is
|
|
1408
|
+
// nonetheless NOT re-thrown any more. The prior `throw err` here
|
|
1409
|
+
// broke this function's own documented contract ("This function
|
|
1410
|
+
// never throws"), and the escape was reachable in practice, not
|
|
1411
|
+
// hypothetically: a wire-controlled offset read past the end of a
|
|
1412
|
+
// short body raised a RangeError, which sailed straight through this
|
|
1413
|
+
// arm. parseResponse() is now provably total (see need() above), so
|
|
1414
|
+
// reaching this arm means a genuine defect -- so it is LOUD on
|
|
1415
|
+
// stderr (never silently swallowed, which was the original arm's
|
|
1416
|
+
// whole point) while still being reported through the documented
|
|
1417
|
+
// channel, and the frame is consumed so the same bytes cannot
|
|
1418
|
+
// re-raise it on every subsequent chunk.
|
|
1419
|
+
const message = err instanceof Error ? `${err.name}: ${err.message}` : String(err);
|
|
1420
|
+
console.error(
|
|
1421
|
+
`[framing] BUG: parseResponse() threw an undocumented error for response type 0x${responseType
|
|
1422
|
+
.toString(16)
|
|
1423
|
+
.padStart(2, "0")} (request id ${requestId}, ${bodyLength}-byte body) -- ${message}`,
|
|
1424
|
+
);
|
|
1425
|
+
responses.push(
|
|
1426
|
+
new StockFramingError(
|
|
1427
|
+
`parseResponse() threw an undocumented ${err instanceof Error ? err.name : "error"} for response type 0x${responseType
|
|
1428
|
+
.toString(16)
|
|
1429
|
+
.padStart(2, "0")}: ${message}`,
|
|
1430
|
+
{ observed: bodyLength, responseType, requestId },
|
|
1431
|
+
),
|
|
1432
|
+
);
|
|
1433
|
+
}
|
|
1434
|
+
}
|
|
1435
|
+
|
|
1436
|
+
offset += frameLength;
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
return { responses, remainder: buffer.subarray(offset), desyncBytes: counters.desyncBytes };
|
|
1440
|
+
}
|
|
1441
|
+
|
|
1442
|
+
/**
|
|
1443
|
+
* WR-03: does `buffer` start with a header that could be a real, still-arriving
|
|
1444
|
+
* frame -- STX, plus a declared body length within MAX_BODY_LEN? This is
|
|
1445
|
+
* exactly the shape parseBuffer() leaves as its `remainder` when it breaks on
|
|
1446
|
+
* an incomplete frame, so a `true` here means "waiting for more bytes", never
|
|
1447
|
+
* "desynced". Used ONLY to keep the accumulation cap from mistaking a large
|
|
1448
|
+
* in-progress frame for garbage; it is not a second framing decision (the
|
|
1449
|
+
* parser above remains the only one), and a buffer too short to hold a header
|
|
1450
|
+
* is trivially still in progress.
|
|
1451
|
+
*/
|
|
1452
|
+
function beginsWithPlausibleFrame(buffer: Buffer): boolean {
|
|
1453
|
+
if (buffer.length < RESPONSE_HEADER_LEN) return true;
|
|
1454
|
+
if (buffer[0] !== VICE_STX) return false;
|
|
1455
|
+
return buffer.readUInt32LE(2) <= MAX_BODY_LEN;
|
|
1456
|
+
}
|
|
1457
|
+
|
|
1458
|
+
// ---------------------------------------------------------------------------
|
|
1459
|
+
// Correlation tables (plan 02-06) -- data-driven, not hardcoded branches
|
|
1460
|
+
// (D-16). These are consulted by ViceMonitorClient's #dispatch() below.
|
|
1461
|
+
// ---------------------------------------------------------------------------
|
|
1462
|
+
|
|
1463
|
+
/**
|
|
1464
|
+
* Command -> the set of ParsedResponse.type discriminants that arrive as
|
|
1465
|
+
* interim frames under the same request id before the terminal reply. Today
|
|
1466
|
+
* only CHECKPOINT_LIST (0x14) has this N+1 shape: each interim
|
|
1467
|
+
* CHECKPOINT_INFO (ResponseType 0x11) frame accumulates into the pending
|
|
1468
|
+
* command's `related[]` array before the terminal CHECKPOINT_LIST reply
|
|
1469
|
+
* resolves it. The vendor hardcoded exactly this one case as a single `if`
|
|
1470
|
+
* branch (`c64-debug-mcp/src/vice-protocol.ts:683`); this table exists so
|
|
1471
|
+
* the next N+1-shaped command is a data entry here, not a fifth
|
|
1472
|
+
* copy-pasted branch.
|
|
1473
|
+
*/
|
|
1474
|
+
const RELATED_RESPONSES: Partial<Record<CommandType, ReadonlySet<ParsedResponse["type"]>>> = {
|
|
1475
|
+
[CommandType.CheckpointList]: new Set(["checkpoint_info"]),
|
|
1476
|
+
};
|
|
1477
|
+
|
|
1478
|
+
/**
|
|
1479
|
+
* Command -> the wire ResponseType its terminal reply must carry, per
|
|
1480
|
+
* monitor_binary.c's monitor_binary_response(..., e_MON_RESPONSE_*, ...)
|
|
1481
|
+
* call in each command's handler (confirmed by direct read of that source
|
|
1482
|
+
* this session). Consulted by #dispatch() before `pending.resolve()`: a
|
|
1483
|
+
* mismatch rejects with StockResponseMismatchError rather than handing the
|
|
1484
|
+
* caller another command's payload. Nothing in the vendor or in plan
|
|
1485
|
+
* 02-04's parser validates this today.
|
|
1486
|
+
*/
|
|
1487
|
+
const EXPECTED_RESPONSE: Partial<Record<CommandType, ResponseType>> = {
|
|
1488
|
+
[CommandType.MemoryGet]: ResponseType.MemoryGet,
|
|
1489
|
+
[CommandType.MemorySet]: ResponseType.MemorySet,
|
|
1490
|
+
[CommandType.CheckpointGet]: ResponseType.CheckpointInfo,
|
|
1491
|
+
[CommandType.CheckpointSet]: ResponseType.CheckpointInfo,
|
|
1492
|
+
[CommandType.CheckpointDelete]: ResponseType.CheckpointDelete,
|
|
1493
|
+
[CommandType.CheckpointList]: ResponseType.CheckpointList,
|
|
1494
|
+
[CommandType.CheckpointToggle]: ResponseType.CheckpointToggle,
|
|
1495
|
+
[CommandType.ConditionSet]: ResponseType.ConditionSet,
|
|
1496
|
+
[CommandType.RegistersGet]: ResponseType.RegisterInfo,
|
|
1497
|
+
[CommandType.RegistersSet]: ResponseType.RegisterInfo,
|
|
1498
|
+
[CommandType.Dump]: ResponseType.Dump,
|
|
1499
|
+
[CommandType.Undump]: ResponseType.Undump,
|
|
1500
|
+
[CommandType.ResourceGet]: ResponseType.ResourceGet,
|
|
1501
|
+
[CommandType.ResourceSet]: ResponseType.ResourceSet,
|
|
1502
|
+
[CommandType.AdvanceInstructions]: ResponseType.AdvanceInstructions,
|
|
1503
|
+
[CommandType.KeyboardFeed]: ResponseType.KeyboardFeed,
|
|
1504
|
+
[CommandType.ExecuteUntilReturn]: ResponseType.ExecuteUntilReturn,
|
|
1505
|
+
[CommandType.Ping]: ResponseType.Ping,
|
|
1506
|
+
[CommandType.BanksAvailable]: ResponseType.BanksAvailable,
|
|
1507
|
+
[CommandType.RegistersAvailable]: ResponseType.RegistersAvailable,
|
|
1508
|
+
[CommandType.DisplayGet]: ResponseType.DisplayGet,
|
|
1509
|
+
[CommandType.ViceInfo]: ResponseType.ViceInfo,
|
|
1510
|
+
[CommandType.CpuHistoryGet]: ResponseType.CpuHistoryGet,
|
|
1511
|
+
[CommandType.PaletteGet]: ResponseType.PaletteGet,
|
|
1512
|
+
[CommandType.JoyportSet]: ResponseType.JoyportSet,
|
|
1513
|
+
[CommandType.UserportSet]: ResponseType.UserportSet,
|
|
1514
|
+
[CommandType.Exit]: ResponseType.Exit,
|
|
1515
|
+
[CommandType.Quit]: ResponseType.Quit,
|
|
1516
|
+
[CommandType.Reset]: ResponseType.Reset,
|
|
1517
|
+
[CommandType.AutoStart]: ResponseType.AutoStart,
|
|
1518
|
+
};
|
|
1519
|
+
|
|
1520
|
+
/**
|
|
1521
|
+
* Reverse lookup: a parsed response's discriminant `.type` string back to
|
|
1522
|
+
* the wire ResponseType byte it was parsed from. Needed only by the
|
|
1523
|
+
* EXPECTED_RESPONSE check below -- parseResponse()'s named ParsedResponse
|
|
1524
|
+
* shapes deliberately do not carry a redundant raw responseType field (see
|
|
1525
|
+
* plan 02-04's shapes); the "unknown" fallback shape already carries its
|
|
1526
|
+
* own responseType and is handled without this table (see
|
|
1527
|
+
* responseTypeOfParsed() below).
|
|
1528
|
+
*/
|
|
1529
|
+
const RESPONSE_TYPE_OF_PARSED_KIND: Partial<Record<ParsedResponse["type"], ResponseType>> = {
|
|
1530
|
+
memory_get: ResponseType.MemoryGet,
|
|
1531
|
+
registers: ResponseType.RegisterInfo,
|
|
1532
|
+
registers_available: ResponseType.RegistersAvailable,
|
|
1533
|
+
banks_available: ResponseType.BanksAvailable,
|
|
1534
|
+
vice_info: ResponseType.ViceInfo,
|
|
1535
|
+
checkpoint_info: ResponseType.CheckpointInfo,
|
|
1536
|
+
checkpoint_list: ResponseType.CheckpointList,
|
|
1537
|
+
display: ResponseType.DisplayGet,
|
|
1538
|
+
palette: ResponseType.PaletteGet,
|
|
1539
|
+
stopped: ResponseType.Stopped,
|
|
1540
|
+
resumed: ResponseType.Resumed,
|
|
1541
|
+
jam: ResponseType.Jam,
|
|
1542
|
+
undump: ResponseType.Undump,
|
|
1543
|
+
};
|
|
1544
|
+
|
|
1545
|
+
/** The wire ResponseType byte a parsed response was decoded from, for
|
|
1546
|
+
* EXPECTED_RESPONSE comparison. The "unknown" fallback already carries its
|
|
1547
|
+
* own responseType field; every other shape is looked up in
|
|
1548
|
+
* RESPONSE_TYPE_OF_PARSED_KIND above. */
|
|
1549
|
+
function responseTypeOfParsed(response: ParsedResponse): number | undefined {
|
|
1550
|
+
if (response.type === "unknown") {
|
|
1551
|
+
return response.responseType;
|
|
1552
|
+
}
|
|
1553
|
+
return RESPONSE_TYPE_OF_PARSED_KIND[response.type];
|
|
1554
|
+
}
|
|
1555
|
+
|
|
1556
|
+
/** Bounded ring size for the settled-request-id memory (RESEARCH.md's
|
|
1557
|
+
* duplicate-reply question, planner decision): large enough to catch a
|
|
1558
|
+
* duplicate arriving shortly after its original settled, small enough to
|
|
1559
|
+
* never grow unbounded (T-02-24). */
|
|
1560
|
+
const SETTLED_RING_SIZE = 256;
|
|
1561
|
+
|
|
1562
|
+
/** A resolved response carries `related`: the pending command's accumulated
|
|
1563
|
+
* interim frames (RELATED_RESPONSES above), always present (empty array when
|
|
1564
|
+
* the command has no N+1 shape) so a caller never has to branch on whether
|
|
1565
|
+
* the field exists. */
|
|
1566
|
+
export type ResolvedResponse = ParsedResponse & { related: ParsedResponse[] };
|
|
1567
|
+
|
|
1568
|
+
interface PendingCommand {
|
|
1569
|
+
commandType: CommandType;
|
|
1570
|
+
resolve: (value: ResolvedResponse) => void;
|
|
1571
|
+
reject: (reason: unknown) => void;
|
|
1572
|
+
timer: NodeJS.Timeout;
|
|
1573
|
+
related: ParsedResponse[];
|
|
1574
|
+
}
|
|
1575
|
+
|
|
1576
|
+
// ---------------------------------------------------------------------------
|
|
1577
|
+
// Socket layer + correlation/demux (Tasks 1 and 2, plan 02-06) -- drives
|
|
1578
|
+
// parseBuffer() off a real net.Socket, mints request ids, keyed
|
|
1579
|
+
// request-id-first demux, N+1 related-frame accumulation,
|
|
1580
|
+
// expected-response validation, duplicate-reply detection, and
|
|
1581
|
+
// socket-lifecycle rejection. Plan 02-04 built parseBuffer()/parseResponse()
|
|
1582
|
+
// this class drives; this class adds everything above that.
|
|
1583
|
+
// ---------------------------------------------------------------------------
|
|
1584
|
+
|
|
1585
|
+
export interface ConnectOptions {
|
|
1586
|
+
timeoutMs?: number;
|
|
1587
|
+
}
|
|
1588
|
+
|
|
1589
|
+
export interface ViceMonitorClientOptions {
|
|
1590
|
+
/** Override the request-id minter's starting value. Test-only knob to
|
|
1591
|
+
* exercise the 0xfffffffe -> 1 wraparound (D-17) without minting ~4.3
|
|
1592
|
+
* billion ids first; production callers should never need this (defaults
|
|
1593
|
+
* to 1). */
|
|
1594
|
+
initialRequestId?: number;
|
|
1595
|
+
}
|
|
1596
|
+
|
|
1597
|
+
export interface ViceMonitorClientCounters {
|
|
1598
|
+
desyncBytes: number;
|
|
1599
|
+
/** Incremented by #dispatch() (plan 02-06) on a duplicate reply for an
|
|
1600
|
+
* already-settled request id -- dropped, never emitted as 'event'
|
|
1601
|
+
* (planner decision, RESEARCH.md's duplicate-reply question). */
|
|
1602
|
+
duplicateReplies: number;
|
|
1603
|
+
}
|
|
1604
|
+
|
|
1605
|
+
/**
|
|
1606
|
+
* Raw binary-monitor socket client: connect/disconnect, request-id minting
|
|
1607
|
+
* (`mintRequestId()`), correlated command dispatch (`send()`), and a
|
|
1608
|
+
* 'response'/'event'/'protocol-error'/'desync'/'close'/'transport-error'
|
|
1609
|
+
* event surface. Wraps parseBuffer() in a try/catch that, on any unexpected
|
|
1610
|
+
* throw, drops the buffer to empty and emits 'desync' rather than leaving a
|
|
1611
|
+
* concatenated-but-unadvanced buffer that would repeat the same throw on
|
|
1612
|
+
* every subsequent chunk -- the structural, call-site-level fix for the
|
|
1613
|
+
* vendor's defect (b) failure mode, on top of parseBuffer()'s own
|
|
1614
|
+
* parser-level fix above. Also caps accumulated buffering: unparsed bytes
|
|
1615
|
+
* above MAX_BODY_LEN without a complete frame are a desync (reset + emit),
|
|
1616
|
+
* never an unbounded Buffer.concat growth path.
|
|
1617
|
+
*
|
|
1618
|
+
* D-11: this class answers "this socket died" only. It never decides
|
|
1619
|
+
* whether a freshly reconnected socket is the *same machine* -- that is
|
|
1620
|
+
* plan 02-08's stock-connect.ts, one layer up, reusing vice.ts's existing
|
|
1621
|
+
* MachineRestartedError.
|
|
1622
|
+
*/
|
|
1623
|
+
export class ViceMonitorClient extends EventEmitter {
|
|
1624
|
+
#socket: net.Socket | null = null;
|
|
1625
|
+
#buffer: Buffer = Buffer.alloc(0);
|
|
1626
|
+
#desyncBytes = 0;
|
|
1627
|
+
#duplicateReplies = 0;
|
|
1628
|
+
#nextRequestId = 1;
|
|
1629
|
+
#pending = new Map<number, PendingCommand>();
|
|
1630
|
+
#settledRing: number[] = [];
|
|
1631
|
+
#settledSet = new Set<number>();
|
|
1632
|
+
#port: number | null = null;
|
|
1633
|
+
#closed = false;
|
|
1634
|
+
#onDataBound = (chunk: Buffer) => this.#onData(chunk);
|
|
1635
|
+
#onCloseBound = () => this.#onClose();
|
|
1636
|
+
#onErrorBound = (err: Error) => this.#onError(err);
|
|
1637
|
+
|
|
1638
|
+
constructor({ initialRequestId }: ViceMonitorClientOptions = {}) {
|
|
1639
|
+
super();
|
|
1640
|
+
if (initialRequestId !== undefined) {
|
|
1641
|
+
this.#nextRequestId = initialRequestId;
|
|
1642
|
+
}
|
|
1643
|
+
}
|
|
1644
|
+
|
|
1645
|
+
get connected(): boolean {
|
|
1646
|
+
return this.#socket != null && !this.#socket.destroyed;
|
|
1647
|
+
}
|
|
1648
|
+
|
|
1649
|
+
get counters(): ViceMonitorClientCounters {
|
|
1650
|
+
return { desyncBytes: this.#desyncBytes, duplicateReplies: this.#duplicateReplies };
|
|
1651
|
+
}
|
|
1652
|
+
|
|
1653
|
+
/**
|
|
1654
|
+
* D-17: never mint VICE_BROADCAST_REQUEST_ID (0xffffffff) -- five
|
|
1655
|
+
* unsolicited types arrive at that id and two share a response type with a
|
|
1656
|
+
* legitimate reply, so a minted collision would be indistinguishable from
|
|
1657
|
+
* a real event. Wraps back to 1 at 0xfffffffe, explicitly skipping
|
|
1658
|
+
* 0xffffffff, rather than relying on "49.7 days of continuous traffic
|
|
1659
|
+
* before it could wrap" being the same as never. The `=== ` check below is
|
|
1660
|
+
* a second, defensive skip in case #nextRequestId is ever set to the
|
|
1661
|
+
* broadcast id by some other path.
|
|
1662
|
+
*/
|
|
1663
|
+
mintRequestId(): number {
|
|
1664
|
+
const id = this.#nextRequestId;
|
|
1665
|
+
if (id === VICE_BROADCAST_REQUEST_ID) {
|
|
1666
|
+
this.#nextRequestId = 1;
|
|
1667
|
+
return this.mintRequestId();
|
|
1668
|
+
}
|
|
1669
|
+
this.#nextRequestId = id >= 0xfffffffe ? 1 : id + 1;
|
|
1670
|
+
return id;
|
|
1671
|
+
}
|
|
1672
|
+
|
|
1673
|
+
connect(host: string, port: number, { timeoutMs = 5000 }: ConnectOptions = {}): Promise<void> {
|
|
1674
|
+
// WR-13(b): refuse to connect over a socket that is still live. Before this,
|
|
1675
|
+
// a second connect() simply OVERWROTE #socket, leaking the previous socket
|
|
1676
|
+
// and its three listeners with nothing left to remove them, while
|
|
1677
|
+
// #closed/#pending/#settledRing were reset inconsistently around it. Route
|
|
1678
|
+
// a reconnect through disconnect() first -- which is what stock-connect.ts's
|
|
1679
|
+
// stockReconnect() already does by building a fresh client -- rather than
|
|
1680
|
+
// letting this method silently accumulate sockets against an emulator that
|
|
1681
|
+
// services exactly one binmon client.
|
|
1682
|
+
if (this.#socket != null && !this.#socket.destroyed) {
|
|
1683
|
+
return Promise.reject(
|
|
1684
|
+
new ViceError(
|
|
1685
|
+
`connect to ${host}:${port} refused: this client already holds a live socket to port ${this.#port} -- call disconnect() first (stock VICE services exactly one binmon client)`,
|
|
1686
|
+
),
|
|
1687
|
+
);
|
|
1688
|
+
}
|
|
1689
|
+
|
|
1690
|
+
return new Promise((resolve, reject) => {
|
|
1691
|
+
const socket = net.createConnection({ host, port });
|
|
1692
|
+
|
|
1693
|
+
const onConnect = () => {
|
|
1694
|
+
clearTimeout(timer);
|
|
1695
|
+
socket.removeListener("error", onConnectError);
|
|
1696
|
+
this.#socket = socket;
|
|
1697
|
+
this.#buffer = Buffer.alloc(0);
|
|
1698
|
+
this.#port = port;
|
|
1699
|
+
this.#closed = false;
|
|
1700
|
+
socket.on("data", this.#onDataBound);
|
|
1701
|
+
socket.on("close", this.#onCloseBound);
|
|
1702
|
+
socket.on("error", this.#onErrorBound);
|
|
1703
|
+
resolve();
|
|
1704
|
+
};
|
|
1705
|
+
const onConnectError = (err: Error) => {
|
|
1706
|
+
clearTimeout(timer);
|
|
1707
|
+
reject(err);
|
|
1708
|
+
};
|
|
1709
|
+
const timer = setTimeout(() => {
|
|
1710
|
+
socket.removeListener("connect", onConnect);
|
|
1711
|
+
socket.removeListener("error", onConnectError);
|
|
1712
|
+
// WR-13(a): destroy() can itself deliver an 'error' for this socket
|
|
1713
|
+
// (ECONNRESET on a half-open connect, for instance). With both
|
|
1714
|
+
// listeners already removed that is an UNHANDLED 'error' event in Node
|
|
1715
|
+
// -- which reaches nothing but the proxy's never-throw global handler
|
|
1716
|
+
// and surfaces as an unexplained stderr incident. A no-op listener
|
|
1717
|
+
// attached for the socket's remaining lifetime is the whole fix: this
|
|
1718
|
+
// socket is abandoned, so there is nothing to report, but the event
|
|
1719
|
+
// still needs somewhere to land.
|
|
1720
|
+
socket.on("error", () => {
|
|
1721
|
+
/* abandoned socket -- swallow, see the comment above */
|
|
1722
|
+
});
|
|
1723
|
+
socket.destroy();
|
|
1724
|
+
reject(new ViceError(`connect to ${host}:${port} timed out after ${timeoutMs}ms`));
|
|
1725
|
+
}, timeoutMs);
|
|
1726
|
+
|
|
1727
|
+
socket.once("connect", onConnect);
|
|
1728
|
+
socket.once("error", onConnectError);
|
|
1729
|
+
});
|
|
1730
|
+
}
|
|
1731
|
+
|
|
1732
|
+
/**
|
|
1733
|
+
* Send a command and correlate its terminal reply, per D-11/PROTO-02.
|
|
1734
|
+
* Rejects immediately with StockConnectionClosedError if the client is not
|
|
1735
|
+
* connected or has already seen its socket close/error -- never queues
|
|
1736
|
+
* against a dead socket. Otherwise mints a request id (never
|
|
1737
|
+
* VICE_BROADCAST_REQUEST_ID, D-17), tracks it in the pending map, and
|
|
1738
|
+
* resolves/rejects it from #dispatch() as replies arrive.
|
|
1739
|
+
*/
|
|
1740
|
+
send(commandType: CommandType, body: Buffer = Buffer.alloc(0), { timeoutMs = 5000 }: ConnectOptions = {}): Promise<ResolvedResponse> {
|
|
1741
|
+
if (this.#closed || !this.connected || !this.#socket) {
|
|
1742
|
+
return Promise.reject(
|
|
1743
|
+
new StockConnectionClosedError("cannot send: binary monitor connection is not open", {
|
|
1744
|
+
port: this.#port,
|
|
1745
|
+
abandoned: 0,
|
|
1746
|
+
trigger: this.#closed ? "close" : "error",
|
|
1747
|
+
}),
|
|
1748
|
+
);
|
|
1749
|
+
}
|
|
1750
|
+
|
|
1751
|
+
const requestId = this.mintRequestId();
|
|
1752
|
+
const packet = encodeRequestHeader({ commandType, requestId, body });
|
|
1753
|
+
const socket = this.#socket;
|
|
1754
|
+
|
|
1755
|
+
return new Promise<ResolvedResponse>((resolve, reject) => {
|
|
1756
|
+
const startedAt = Date.now();
|
|
1757
|
+
const timer = setTimeout(() => {
|
|
1758
|
+
// WR-02: #markSettled too, not just the pending-map delete. This id IS
|
|
1759
|
+
// settled from the caller's point of view -- the promise has been
|
|
1760
|
+
// rejected -- and a reply for it can still arrive afterwards ("connected
|
|
1761
|
+
// but silent" is a timeout, not a dead socket). Without the ring entry
|
|
1762
|
+
// #dispatch() finds neither a pending entry nor a settled one and falls
|
|
1763
|
+
// through to emit("event"), routing a COMMAND REPLY onto the event
|
|
1764
|
+
// channel -- exactly what the duplicate-reply branch two lines above it
|
|
1765
|
+
// deliberately refuses to do, because a future consumer would read it
|
|
1766
|
+
// as a second, spurious STOPPED/RESUMED-shaped transition.
|
|
1767
|
+
this.#abandonPending(requestId);
|
|
1768
|
+
reject(
|
|
1769
|
+
new StockRequestTimeoutError(
|
|
1770
|
+
`command 0x${commandType.toString(16).padStart(2, "0")} (request id ${requestId}) timed out waiting for a reply after ${timeoutMs}ms`,
|
|
1771
|
+
{ requestId, commandType, elapsedMs: Date.now() - startedAt },
|
|
1772
|
+
),
|
|
1773
|
+
);
|
|
1774
|
+
}, timeoutMs);
|
|
1775
|
+
|
|
1776
|
+
this.#pending.set(requestId, {
|
|
1777
|
+
commandType,
|
|
1778
|
+
resolve,
|
|
1779
|
+
reject,
|
|
1780
|
+
timer,
|
|
1781
|
+
related: [],
|
|
1782
|
+
});
|
|
1783
|
+
|
|
1784
|
+
socket.write(packet, (err) => {
|
|
1785
|
+
if (err) {
|
|
1786
|
+
clearTimeout(timer);
|
|
1787
|
+
// WR-02: same reasoning as the timeout path above -- the write failed,
|
|
1788
|
+
// but the bytes may still have reached VICE, so a late reply must be
|
|
1789
|
+
// counted as a duplicate rather than emitted as an event.
|
|
1790
|
+
this.#abandonPending(requestId);
|
|
1791
|
+
reject(
|
|
1792
|
+
new StockConnectionClosedError(`socket write failed for request id ${requestId}: ${err.message}`, {
|
|
1793
|
+
port: this.#port,
|
|
1794
|
+
abandoned: 1,
|
|
1795
|
+
trigger: "error",
|
|
1796
|
+
}),
|
|
1797
|
+
);
|
|
1798
|
+
}
|
|
1799
|
+
});
|
|
1800
|
+
});
|
|
1801
|
+
}
|
|
1802
|
+
|
|
1803
|
+
disconnect(): Promise<void> {
|
|
1804
|
+
this.#failAllPending("close");
|
|
1805
|
+
const socket = this.#socket;
|
|
1806
|
+
this.#socket = null;
|
|
1807
|
+
this.#buffer = Buffer.alloc(0);
|
|
1808
|
+
if (!socket) {
|
|
1809
|
+
return Promise.resolve();
|
|
1810
|
+
}
|
|
1811
|
+
socket.removeListener("data", this.#onDataBound);
|
|
1812
|
+
socket.removeListener("close", this.#onCloseBound);
|
|
1813
|
+
socket.removeListener("error", this.#onErrorBound);
|
|
1814
|
+
return new Promise((resolve) => {
|
|
1815
|
+
socket.once("close", () => resolve());
|
|
1816
|
+
socket.destroy();
|
|
1817
|
+
});
|
|
1818
|
+
}
|
|
1819
|
+
|
|
1820
|
+
#onData(chunk: Buffer): void {
|
|
1821
|
+
let combined: Buffer;
|
|
1822
|
+
try {
|
|
1823
|
+
combined = Buffer.concat([this.#buffer, chunk]);
|
|
1824
|
+
const counters: ParseCounters = { desyncBytes: this.#desyncBytes };
|
|
1825
|
+
const { responses, remainder, desyncBytes } = parseBuffer(combined, counters);
|
|
1826
|
+
this.#desyncBytes = desyncBytes;
|
|
1827
|
+
|
|
1828
|
+
// WR-03: the cap is MAX_BUFFERED_LEN (accumulated bytes), NOT
|
|
1829
|
+
// MAX_BODY_LEN (one frame's declared body). Using the latter for both
|
|
1830
|
+
// meant a frame at or near 4 MiB -- a full-screen DISPLAY_GET is already
|
|
1831
|
+
// ~157 KB, and nothing bounds a future one lower -- could never be
|
|
1832
|
+
// reassembled from chunks: its own partially-received bytes tripped the
|
|
1833
|
+
// cap, the buffer was reset, and the retry hit the same wall. The second
|
|
1834
|
+
// condition is the other half: a remainder that BEGINS with a plausible
|
|
1835
|
+
// frame header is an in-progress frame, never a desync, whatever its
|
|
1836
|
+
// size. Only genuinely unparseable accumulation trips this.
|
|
1837
|
+
if (remainder.length > MAX_BUFFERED_LEN && !beginsWithPlausibleFrame(remainder)) {
|
|
1838
|
+
// Accumulated unparsed bytes without a complete frame, past the cap
|
|
1839
|
+
// -- this is the DoS shape RESEARCH.md flags in the vendor client's
|
|
1840
|
+
// unbounded Buffer.concat growth path. Reset rather than keep
|
|
1841
|
+
// growing.
|
|
1842
|
+
const skipped = remainder.length;
|
|
1843
|
+
this.#buffer = Buffer.alloc(0);
|
|
1844
|
+
this.#desyncBytes += skipped;
|
|
1845
|
+
const desyncErr = new StockDesyncError(
|
|
1846
|
+
`accumulated buffer exceeded MAX_BUFFERED_LEN (${MAX_BUFFERED_LEN}) with no parseable frame -- buffer reset`,
|
|
1847
|
+
{ bytesSkipped: skipped },
|
|
1848
|
+
);
|
|
1849
|
+
this.emit("desync", desyncErr);
|
|
1850
|
+
// WR-03: the discarded bytes may have included the only copy of an
|
|
1851
|
+
// in-flight reply, so those requests can never be answered. Rejecting
|
|
1852
|
+
// them now, with the reason, beats letting each one silently burn its
|
|
1853
|
+
// full timeout and then report "connected but silent" -- which is a
|
|
1854
|
+
// materially different diagnosis from "the stream desynced". The socket
|
|
1855
|
+
// itself is still alive, so this deliberately does NOT go through
|
|
1856
|
+
// #failAllPending(), which would also latch #closed and refuse every
|
|
1857
|
+
// future send().
|
|
1858
|
+
this.#rejectAllPending(desyncErr);
|
|
1859
|
+
} else {
|
|
1860
|
+
this.#buffer = remainder;
|
|
1861
|
+
}
|
|
1862
|
+
|
|
1863
|
+
for (const item of responses) {
|
|
1864
|
+
this.#dispatch(item);
|
|
1865
|
+
}
|
|
1866
|
+
} catch (err) {
|
|
1867
|
+
// Defensive backstop: parseBuffer() is designed to never throw, but if
|
|
1868
|
+
// it somehow does, drop the buffer rather than poisoning the
|
|
1869
|
+
// connection with a concatenated-but-unadvanced buffer that would
|
|
1870
|
+
// repeat the same throw on every subsequent chunk.
|
|
1871
|
+
this.#buffer = Buffer.alloc(0);
|
|
1872
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1873
|
+
const desyncErr = new StockDesyncError(`unexpected throw while parsing binmon stream: ${message}`);
|
|
1874
|
+
this.emit("desync", desyncErr);
|
|
1875
|
+
// WR-03: same reasoning as the accumulation-cap reset above -- the bytes
|
|
1876
|
+
// that were dropped may have carried the only copy of an in-flight reply,
|
|
1877
|
+
// so those requests cannot be answered and should not silently burn their
|
|
1878
|
+
// full timeouts. The socket stays usable.
|
|
1879
|
+
this.#rejectAllPending(desyncErr);
|
|
1880
|
+
}
|
|
1881
|
+
}
|
|
1882
|
+
|
|
1883
|
+
/**
|
|
1884
|
+
* Request-id-first demux (PROTO-03, D-16/D-17) -- the entire mechanism
|
|
1885
|
+
* that keeps an unsolicited event from masquerading as a legitimate reply.
|
|
1886
|
+
* Ported verbatim from the vendor's ordering
|
|
1887
|
+
* (c64-debug-mcp/src/vice-protocol.ts:669-681, read this session): emit
|
|
1888
|
+
* 'response'/'protocol-error' first, THEN check
|
|
1889
|
+
* `requestId === VICE_BROADCAST_REQUEST_ID` and route to 'event' --
|
|
1890
|
+
* BEFORE any #pending.get() lookup, and NEVER inspecting the response type
|
|
1891
|
+
* byte. Do not restructure this to check response type first: a
|
|
1892
|
+
* CHECKPOINT_INFO (0x11) or REGISTER_INFO (0x31) arriving mid-flight would
|
|
1893
|
+
* otherwise resolve an in-flight CHECKPOINT_GET/REGISTERS_GET with some
|
|
1894
|
+
* other checkpoint's or register dump's data, silently.
|
|
1895
|
+
*/
|
|
1896
|
+
#dispatch(item: ParsedResponse | StockProtocolError | StockFramingError): void {
|
|
1897
|
+
const isWireError = item instanceof StockProtocolError || item instanceof StockFramingError;
|
|
1898
|
+
|
|
1899
|
+
if (isWireError) {
|
|
1900
|
+
this.emit("protocol-error", item);
|
|
1901
|
+
} else {
|
|
1902
|
+
this.emit("response", item);
|
|
1903
|
+
}
|
|
1904
|
+
|
|
1905
|
+
const requestId = item.requestId;
|
|
1906
|
+
|
|
1907
|
+
if (requestId === undefined || requestId === VICE_BROADCAST_REQUEST_ID) {
|
|
1908
|
+
this.emit("event", item);
|
|
1909
|
+
return;
|
|
1910
|
+
}
|
|
1911
|
+
|
|
1912
|
+
const pending = this.#pending.get(requestId);
|
|
1913
|
+
if (!pending) {
|
|
1914
|
+
if (this.#settledSet.has(requestId)) {
|
|
1915
|
+
// Planner decision (RESEARCH.md's duplicate-reply question): a
|
|
1916
|
+
// duplicate reply on an already-settled id is dropped and counted,
|
|
1917
|
+
// never emitted on 'event' -- routing it there would let a future
|
|
1918
|
+
// consumer treat a duplicate reply as a second, spurious
|
|
1919
|
+
// STOPPED/RESUMED-shaped transition.
|
|
1920
|
+
this.#duplicateReplies += 1;
|
|
1921
|
+
console.error(`[framing] duplicate reply on already-settled request id ${requestId} -- dropped, not emitted as an event`);
|
|
1922
|
+
return;
|
|
1923
|
+
}
|
|
1924
|
+
// Never pending and not a known-settled id: a genuine unsolicited
|
|
1925
|
+
// frame arriving at a non-broadcast id.
|
|
1926
|
+
this.emit("event", item);
|
|
1927
|
+
return;
|
|
1928
|
+
}
|
|
1929
|
+
|
|
1930
|
+
if (isWireError) {
|
|
1931
|
+
this.#finishPending(requestId, pending);
|
|
1932
|
+
pending.reject(item);
|
|
1933
|
+
return;
|
|
1934
|
+
}
|
|
1935
|
+
|
|
1936
|
+
const response = item;
|
|
1937
|
+
const relatedTypes = RELATED_RESPONSES[pending.commandType];
|
|
1938
|
+
if (relatedTypes?.has(response.type)) {
|
|
1939
|
+
// Interim frame under an N+1-shaped command (today: CHECKPOINT_LIST's
|
|
1940
|
+
// CHECKPOINT_INFO frames) -- accumulate, do not resolve yet.
|
|
1941
|
+
pending.related.push(response);
|
|
1942
|
+
return;
|
|
1943
|
+
}
|
|
1944
|
+
|
|
1945
|
+
const expected = EXPECTED_RESPONSE[pending.commandType];
|
|
1946
|
+
const received = responseTypeOfParsed(response);
|
|
1947
|
+
if (expected !== undefined && received !== expected) {
|
|
1948
|
+
this.#finishPending(requestId, pending);
|
|
1949
|
+
pending.reject(
|
|
1950
|
+
new StockResponseMismatchError(
|
|
1951
|
+
`command 0x${pending.commandType.toString(16).padStart(2, "0")} (request id ${requestId}) expected response type 0x${expected.toString(16).padStart(2, "0")} but received 0x${(received ?? -1).toString(16).padStart(2, "0")}`,
|
|
1952
|
+
{ expected, received, requestId, command: pending.commandType },
|
|
1953
|
+
),
|
|
1954
|
+
);
|
|
1955
|
+
return;
|
|
1956
|
+
}
|
|
1957
|
+
|
|
1958
|
+
this.#finishPending(requestId, pending);
|
|
1959
|
+
pending.resolve({ ...response, related: pending.related });
|
|
1960
|
+
}
|
|
1961
|
+
|
|
1962
|
+
/** Clears the timer, deletes the pending entry, and inserts it into the
|
|
1963
|
+
* bounded settled-id ring, all together -- so the pending-map delete and
|
|
1964
|
+
* the ring insert can never disagree (plan 02-06's action text). Call
|
|
1965
|
+
* exactly once per settled request id, immediately before
|
|
1966
|
+
* resolve()/reject(). */
|
|
1967
|
+
#finishPending(requestId: number, pending: PendingCommand): void {
|
|
1968
|
+
clearTimeout(pending.timer);
|
|
1969
|
+
this.#pending.delete(requestId);
|
|
1970
|
+
this.#markSettled(requestId);
|
|
1971
|
+
}
|
|
1972
|
+
|
|
1973
|
+
/**
|
|
1974
|
+
* WR-03: reject every in-flight request WITHOUT declaring the socket dead.
|
|
1975
|
+
* Deliberately not #failAllPending(), which latches `#closed = true` and so
|
|
1976
|
+
* refuses every future send() -- correct for a real close/error, wrong for a
|
|
1977
|
+
* stream desync, where the connection is still usable and the caller's next
|
|
1978
|
+
* command should be attempted. Each abandoned id is marked settled (same
|
|
1979
|
+
* reasoning as WR-02) so a late reply is counted as a duplicate rather than
|
|
1980
|
+
* emitted on the event channel.
|
|
1981
|
+
*/
|
|
1982
|
+
#rejectAllPending(reason: unknown): void {
|
|
1983
|
+
if (this.#pending.size === 0) return;
|
|
1984
|
+
const entries = Array.from(this.#pending.entries());
|
|
1985
|
+
this.#pending.clear();
|
|
1986
|
+
for (const [requestId, pending] of entries) {
|
|
1987
|
+
clearTimeout(pending.timer);
|
|
1988
|
+
this.#markSettled(requestId);
|
|
1989
|
+
pending.reject(reason);
|
|
1990
|
+
}
|
|
1991
|
+
}
|
|
1992
|
+
|
|
1993
|
+
/** WR-02: the abandonment counterpart to #finishPending(). Same pair of state
|
|
1994
|
+
* mutations, minus the timer clear (the caller of this method has already
|
|
1995
|
+
* dealt with the timer -- the timeout path IS the timer, and the write-error
|
|
1996
|
+
* path clears it explicitly). Kept as its own named method so neither
|
|
1997
|
+
* abandonment path can drift back into a bare `#pending.delete()` that
|
|
1998
|
+
* forgets the settled-ring insert. Call exactly once per abandoned request
|
|
1999
|
+
* id, immediately before reject(). */
|
|
2000
|
+
#abandonPending(requestId: number): void {
|
|
2001
|
+
this.#pending.delete(requestId);
|
|
2002
|
+
this.#markSettled(requestId);
|
|
2003
|
+
}
|
|
2004
|
+
|
|
2005
|
+
#markSettled(requestId: number): void {
|
|
2006
|
+
if (this.#settledSet.has(requestId)) {
|
|
2007
|
+
return;
|
|
2008
|
+
}
|
|
2009
|
+
this.#settledRing.push(requestId);
|
|
2010
|
+
this.#settledSet.add(requestId);
|
|
2011
|
+
if (this.#settledRing.length > SETTLED_RING_SIZE) {
|
|
2012
|
+
const evicted = this.#settledRing.shift();
|
|
2013
|
+
if (evicted !== undefined) {
|
|
2014
|
+
this.#settledSet.delete(evicted);
|
|
2015
|
+
}
|
|
2016
|
+
}
|
|
2017
|
+
}
|
|
2018
|
+
|
|
2019
|
+
/**
|
|
2020
|
+
* D-11: reject every pending command with StockConnectionClosedError and
|
|
2021
|
+
* clear the pending map -- called from both #onClose() and #onError(), and
|
|
2022
|
+
* from disconnect(), so no in-flight request is ever left unresolved by
|
|
2023
|
+
* any path that tears down the socket. Idempotent: calling this with an
|
|
2024
|
+
* already-empty pending map (e.g. 'close' firing after 'error' already
|
|
2025
|
+
* rejected everything) is a harmless no-op abandoning 0 requests.
|
|
2026
|
+
*/
|
|
2027
|
+
#failAllPending(trigger: "close" | "error"): void {
|
|
2028
|
+
const abandoned = this.#pending.size;
|
|
2029
|
+
if (abandoned > 0) {
|
|
2030
|
+
const err = new StockConnectionClosedError(
|
|
2031
|
+
`binary monitor connection ${trigger === "error" ? "errored" : "closed"} with ${abandoned} request(s) abandoned`,
|
|
2032
|
+
{ port: this.#port, abandoned, trigger },
|
|
2033
|
+
);
|
|
2034
|
+
for (const pending of this.#pending.values()) {
|
|
2035
|
+
clearTimeout(pending.timer);
|
|
2036
|
+
pending.reject(err);
|
|
2037
|
+
}
|
|
2038
|
+
this.#pending.clear();
|
|
2039
|
+
}
|
|
2040
|
+
this.#closed = true;
|
|
2041
|
+
}
|
|
2042
|
+
|
|
2043
|
+
#onClose(): void {
|
|
2044
|
+
this.#failAllPending("close");
|
|
2045
|
+
this.#socket = null;
|
|
2046
|
+
this.#buffer = Buffer.alloc(0);
|
|
2047
|
+
this.emit("close");
|
|
2048
|
+
}
|
|
2049
|
+
|
|
2050
|
+
#onError(err: Error): void {
|
|
2051
|
+
// D-11: an ECONNRESET or other socket 'error' is treated the same as a
|
|
2052
|
+
// clean close -- every pending command rejects with the died-underneath
|
|
2053
|
+
// error, distinguishable from a timeout by class.
|
|
2054
|
+
this.#failAllPending("error");
|
|
2055
|
+
this.emit("transport-error", err);
|
|
2056
|
+
}
|
|
2057
|
+
}
|