@henols/vice-mcp 0.1.9 → 0.1.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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
+ }