@henols/vice-mcp 0.1.11 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/stock-memory.ts CHANGED
@@ -25,6 +25,20 @@
25
25
  // exactly how an answer ships without `runState`.
26
26
  // - Never send an EXIT to "restore" the machine after a read (D-05) -- a
27
27
  // read leaves the machine halted, and the answer says so via runState.
28
+ // - CR-01 (2026-08-17): a chip-state or VIC-fetch read must NEVER pass a
29
+ // literal bank id and must NEVER default to `0x0000` -- bank 0 is the
30
+ // CPU view and follows `$00`/`$01` banking, so with I/O banked out it
31
+ // silently returns the RAM underneath $D000-$DFFF as if it were chip
32
+ // registers. Such a caller MUST use resolveRequiredBank() below and
33
+ // refuse when the emulator's own catalog has no `io` bank -- never
34
+ // guess, never fall back to bank 0.
35
+ // - WR-01 (2026-08-17): never REPORT or LIST banks out of the catalog's
36
+ // `byId` map. Stock VICE reports several names for one wire id (3.9:
37
+ // both `default` and `cpu` are id 0), so an id-keyed map is lossy by
38
+ // construction -- enumerating it made vice_memory_banks answer 5 banks
39
+ // where the emulator enumerated 6, and made resolveRequiredBank()'s
40
+ // refusal tell an agent a working bank name did not exist. Anything
41
+ // agent-facing reads `entries`, the verbatim wire list.
28
42
  import { CommandType, memGetBody, memSetBody } from "./stock-protocol.ts";
29
43
  import { parseAddress, parseByteCount } from "./stock-address.ts";
30
44
  import { convertWireError, isErrorText, stockAnswer, type StockSessionHandler, type StockToolResult } from "./stock-handler.ts";
@@ -51,7 +65,18 @@ function isPlainObject(value: unknown): value is Record<string, unknown> {
51
65
 
52
66
  export interface BankCatalog {
53
67
  byName: Map<string, number>;
68
+ /** ONE name per id, for the reverse lookup only. Real stock VICE reports
69
+ * MORE THAN ONE name for the same wire id (3.9 reports both `default` and
70
+ * `cpu` for id 0), so this map is LOSSY BY CONSTRUCTION -- never enumerate
71
+ * it to report "the banks the emulator has" (WR-01, 2026-08-17: doing
72
+ * exactly that made vice_memory_banks answer 5 banks where the emulator
73
+ * enumerated 6, and made a refusal claim a working bank name did not
74
+ * exist). Use `entries` for anything that reports or lists. */
54
75
  byId: Map<number, string>;
76
+ /** Every (id, name) pair the emulator reported, in wire order -- aliases
77
+ * included. This is the faithful record of the enumeration and the only
78
+ * thing that may be reported to a caller. */
79
+ entries: { id: number; name: string }[];
55
80
  }
56
81
 
57
82
  /** The one place this file's per-session cache storage is defined -- an
@@ -93,22 +118,34 @@ export async function bankCatalogFor(session: StockConnectSession): Promise<Bank
93
118
 
94
119
  const byName = new Map<string, number>();
95
120
  const byId = new Map<number, string>();
121
+ const entries: { id: number; name: string }[] = [];
96
122
  for (const bank of response.banks) {
97
123
  byName.set(bank.name.toLowerCase(), bank.id);
98
- byId.set(bank.id, bank.name);
124
+ // WR-01: FIRST name per id wins here, so the reverse lookup is stable
125
+ // rather than "whichever alias the emulator listed last". Aliases are
126
+ // never lost -- they all live in `entries`.
127
+ if (!byId.has(bank.id)) {
128
+ byId.set(bank.id, bank.name);
129
+ }
130
+ entries.push({ id: bank.id, name: bank.name });
99
131
  }
100
132
 
101
- const catalog: BankCatalog = { byName, byId };
133
+ const catalog: BankCatalog = { byName, byId, entries };
102
134
  bankCatalogs.set(session, catalog);
103
135
  return catalog;
104
136
  }
105
137
 
106
- /** Shared bank-argument resolution for both memory handlers below: omitted
107
- * `bank` resolves to wire id 0x0000, a non-string `bank` refuses, and an
108
- * unknown name refuses listing the names the catalog actually returned --
109
- * never a hardcoded table. Factored once so handleMemoryRead/Write do not
110
- * each re-derive the same three branches. */
111
- async function resolveBank(
138
+ /** Shared bank-argument resolution for every handler whose `bank` argument is
139
+ * OPTIONAL: omitted `bank` resolves to wire id 0x0000, a non-string `bank`
140
+ * refuses, and an unknown name refuses listing the names the catalog actually
141
+ * returned -- never a hardcoded table. Factored once so
142
+ * handleMemoryRead/Write, and stock-memory-search.ts's
143
+ * vice_memory_search/vice_memory_compare (WR-06), do not each re-derive the
144
+ * same three branches. An omitted bank is the CPU view, which is a defensible
145
+ * DEFAULT for a caller who asked to read memory as the CPU sees it -- but it
146
+ * is never correct for a chip-state or VIC-fetch read: those call
147
+ * resolveRequiredBank() below instead, where an absent catalog entry refuses. */
148
+ export async function resolveBank(
112
149
  toolName: string,
113
150
  bankArg: unknown,
114
151
  session: StockConnectSession,
@@ -119,7 +156,30 @@ async function resolveBank(
119
156
  if (typeof bankArg !== "string") {
120
157
  return { ok: false, result: isErrorText(`${toolName}: bank must be a string, got ${typeof bankArg}`) };
121
158
  }
159
+ return await resolveRequiredBank(toolName, bankArg, session);
160
+ }
122
161
 
162
+ /**
163
+ * The one exported seam that turns a REQUIRED bank NAME into the emulator's
164
+ * own wire bank id, or refuses (CR-01, 2026-08-17, plan_decision_D-05-14).
165
+ * Unlike resolveBank() above -- whose contract is "an omitted bank means
166
+ * wire id 0x0000", correct for vice_memory_read/write where the caller
167
+ * asked for the CPU view -- this function's `bankName` is MANDATORY, and an
168
+ * absent catalog entry is a REFUSAL, never a fallback. Consumers that need
169
+ * a specific chip's register view (vice_vicii_get_state,
170
+ * vice_cia_get_state) call this directly; they must never default to bank
171
+ * 0x0000, which follows $00/$01 banking and returns the RAM underneath
172
+ * $D000-$DFFF whenever the running program has banked I/O out.
173
+ *
174
+ * Bank ids are never hardcoded here -- they come only from the emulator's
175
+ * own BANKS_AVAILABLE catalog (bankCatalogFor()'s per-session cache), since
176
+ * ids are build- and machine-specific.
177
+ */
178
+ export async function resolveRequiredBank(
179
+ toolName: string,
180
+ bankName: string,
181
+ session: StockConnectSession,
182
+ ): Promise<{ ok: true; id: number; name: string } | { ok: false; result: StockToolResult }> {
123
183
  let catalog: BankCatalog;
124
184
  try {
125
185
  catalog = await bankCatalogFor(session);
@@ -127,12 +187,28 @@ async function resolveBank(
127
187
  return { ok: false, result: convertWireError(toolName, err) };
128
188
  }
129
189
 
130
- const resolved = catalog.byName.get(bankArg.toLowerCase());
190
+ const requested = bankName.toLowerCase();
191
+ const resolved = catalog.byName.get(requested);
131
192
  if (resolved === undefined) {
132
- const names = [...catalog.byId.values()].join(", ") || "(none reported)";
133
- return { ok: false, result: isErrorText(`${toolName}: unknown bank "${bankArg}" -- available banks: ${names}`) };
193
+ // WR-01: listed from `entries`, NOT from byId -- byId collapses aliases
194
+ // sharing a wire id, so listing it told an agent that a bank name which
195
+ // resolves perfectly well (VICE 3.9's `default`, id 0) does not exist.
196
+ const names = catalog.entries.map((bank) => bank.name).join(", ") || "(none reported)";
197
+ return {
198
+ ok: false,
199
+ result: isErrorText(
200
+ `${toolName}: unknown bank "${bankName}" -- refusing rather than reading the banking-dependent CPU view. ` +
201
+ `The CPU view (bank 0) returns the RAM underneath $D000-$DFFF whenever the running program has banked ` +
202
+ `I/O out via $01 -- available banks: ${names}`,
203
+ ),
204
+ };
134
205
  }
135
- return { ok: true, id: resolved, name: bankArg };
206
+ // WR-01: echo the wire spelling of the name the CALLER asked for, not
207
+ // "whatever name byId happens to hold for this id" -- asking for `default`
208
+ // and being answered `cpu` looks like the resolver silently substituted a
209
+ // different bank.
210
+ const match = catalog.entries.find((bank) => bank.id === resolved && bank.name.toLowerCase() === requested);
211
+ return { ok: true, id: resolved, name: match?.name ?? bankName };
136
212
  }
137
213
 
138
214
  // ---------------------------------------------------------------------------
@@ -155,7 +231,10 @@ export const handleMemoryBanks: StockSessionHandler = async (args, session, _dep
155
231
  return convertWireError("vice_memory_banks", err);
156
232
  }
157
233
 
158
- const banks = [...catalog.byId.entries()].map(([id, name]) => ({ id, name }));
234
+ // WR-01: report the emulator's OWN enumeration verbatim, in wire order,
235
+ // aliases included -- never `byId`, which keeps one name per id and so
236
+ // answered 5 banks on a machine that enumerated 6.
237
+ const banks = catalog.entries.map(({ id, name }) => ({ id, name }));
159
238
  return stockAnswer(session.client, { banks, count: banks.length });
160
239
  };
161
240
 
package/stock-protocol.ts CHANGED
@@ -914,6 +914,60 @@ function requireAsciiFilename(callerName: string, filename: string): Buffer {
914
914
  return filenameBuf;
915
915
  }
916
916
 
917
+ /** Shared name guard for resourceGetBody(): refuses a non-string, a length
918
+ * of 0 or over 255 (name_length is a uint8), and any byte outside printable
919
+ * ASCII (0x20-0x7e), naming the offending value/index and the valid bound --
920
+ * the same discipline as requireAsciiFilename() above. Returns the encoded
921
+ * ASCII Buffer so the caller never re-encodes. */
922
+ function requireResourceName(name: unknown): Buffer {
923
+ if (typeof name !== "string") {
924
+ throw new StockEncodingError(`resourceGetBody: name must be a string, got ${typeof name}`, { field: "name" });
925
+ }
926
+ if (name.length === 0) {
927
+ throw new StockEncodingError("resourceGetBody: name must not be empty", { field: "name" });
928
+ }
929
+ for (let index = 0; index < name.length; index += 1) {
930
+ const codePoint = name.charCodeAt(index);
931
+ if (codePoint < 0x20 || codePoint > 0x7e) {
932
+ throw new StockEncodingError(
933
+ `resourceGetBody: name contains a non-printable-ASCII character at index ${index} (code point ${codePoint}) -- printable ASCII is 0x20-0x7e`,
934
+ { field: "name" },
935
+ );
936
+ }
937
+ }
938
+ const nameBuf = Buffer.from(name, "ascii");
939
+ if (nameBuf.length > 255) {
940
+ throw new StockEncodingError(`resourceGetBody: name exceeds 255 bytes (${nameBuf.length}) -- name_length is a uint8`, { field: "name" });
941
+ }
942
+ return nameBuf;
943
+ }
944
+
945
+ export interface ResourceGetBodyOptions {
946
+ name: string;
947
+ }
948
+
949
+ /**
950
+ * RESOURCE_GET (0x51) request body -- `name_length(1) name(ASCII, NOT
951
+ * NUL-terminated)`. [CITED monitor_binary.c:918-935]
952
+ *
953
+ * READ-SIDE ONLY: this encoder exists so this phase's sole production
954
+ * caller can read `MachineVideoStandard` to pick the right cycles-per-line
955
+ * and lines-per-frame constants. There is no `RESOURCE_SET` (0x52) encoder
956
+ * in this tree and this plan does not add one -- the SET side of
957
+ * `MachineVideoStandard`, `VICIIModel` and `MachinePowerFrequency` reaches
958
+ * `machine_trigger_reset(POWER_CYCLE)` one call deep (`c64/c64.c:1367`) and
959
+ * destroys all emulation state (CLAUDE.md's Safety constraint). Do not add
960
+ * a `resourceSetBody()` or a `case ResponseType.ResourceSet` beside this one
961
+ * without re-deriving that deny-list boundary first.
962
+ */
963
+ export function resourceGetBody({ name }: ResourceGetBodyOptions): Buffer {
964
+ const nameBuf = requireResourceName(name);
965
+ const body = Buffer.alloc(1 + nameBuf.length);
966
+ body[0] = nameBuf.length;
967
+ nameBuf.copy(body, 1);
968
+ return body;
969
+ }
970
+
917
971
  // CHECKPOINT_LIST (0x14), PING (0x81), BANKS_AVAILABLE (0x82),
918
972
  // EXECUTE_UNTIL_RETURN (0x73) and EXIT (0xaa) take EMPTY bodies --
919
973
  // deliberately no encoder for any of the five: ViceMonitorClient.send()
@@ -1046,6 +1100,73 @@ export interface ParsedUndumpResponse extends ParsedBaseResponse {
1046
1100
  programCounter: number;
1047
1101
  }
1048
1102
 
1103
+ /**
1104
+ * One CPUHISTORY_GET (0x86) entry. `cycle` is the monotonic absolute clock
1105
+ * value Phase 7's Route A stopwatch reads -- a `bigint` via
1106
+ * `readBigUInt64LE`, never narrowed to `Number`, since a uint64 clock does
1107
+ * not fit a JS number safely and the stopwatch's whole value is exactness.
1108
+ * The per-entry register block (the `regCount`-prefixed items between the
1109
+ * `item_size` byte and `cycle`, written by `write_registers()`) is
1110
+ * deliberately NOT decoded here: VICE hard-fills `LIN`/`CYC` inside
1111
+ * CPU-history entries with the sentinel `0xffff`
1112
+ * (`monitor_binary.c:1585-1590`), so those per-entry raster positions carry
1113
+ * no real value and this phase never interprets them.
1114
+ *
1115
+ * `instructionLength` is NOT the real length of the decoded 6502
1116
+ * instruction -- VICE hardcodes `uint8_t instruction_length = 4;`
1117
+ * (`monitor_binary.c:1468`) regardless of what `opcode` actually is. Nothing
1118
+ * may treat this field as an instruction size.
1119
+ *
1120
+ * Layout re-derived 2026-08-18 from `monitor_binary_process_cpuhistory()`
1121
+ * (`monitor_binary.c:1452-1620`) and verified against the real committed
1122
+ * captures `fixtures/binmon/cpuhistory-get.bin` and `cpuhistory-get-multi.bin`
1123
+ * (plan 07-12). The PREVIOUS layout this comment cited was disproven live
1124
+ * against a genuine VICE 3.10 build -- see
1125
+ * `.planning/phases/07-cycle-timing-and-wedge-triage/deferred-items.md`,
1126
+ * "Route A (CPUHISTORY_GET) live decode mismatch" -- do not re-trust it.
1127
+ */
1128
+ export interface ParsedCpuHistoryEntry {
1129
+ cycle: bigint;
1130
+ opcode: number;
1131
+ instructionLength: number;
1132
+ p1: number;
1133
+ p2: number;
1134
+ }
1135
+
1136
+ /**
1137
+ * CPUHISTORY_GET (0x86) parsed shape. CORRECTED 2026-08-18 (plan 07-12): the
1138
+ * real `cpuhistory-get-multi.bin` capture (`count:4`) decodes to
1139
+ * STRICTLY ASCENDING `cycle` values across `entries[0..3]` -- i.e.
1140
+ * `entries[0]` is the OLDEST of the returned window and `entries[count-1]`
1141
+ * is the NEWEST, the opposite of this comment's previous unverified
1142
+ * "entries[0] is the newest" claim. Route A's live stopwatch
1143
+ * (`stock-timing.ts`'s `readCycleBaseline()`) is UNAFFECTED by this
1144
+ * correction: it always requests `CPUHISTORY_GET(count:1)`, never a larger
1145
+ * count, so `entries[0]` is the only entry either way. Layout re-derived
1146
+ * from `monitor_binary_process_cpuhistory()` (`monitor_binary.c:1452-1620`)
1147
+ * and verified against the real captures `fixtures/binmon/cpuhistory-get.bin`
1148
+ * (single entry) and `cpuhistory-get-multi.bin` (multi-entry, the stride AND
1149
+ * order proof) -- see plan 07-12 and
1150
+ * `.planning/phases/07-cycle-timing-and-wedge-triage/deferred-items.md` for
1151
+ * the disproven earlier layout this replaces.
1152
+ */
1153
+ export interface ParsedCpuHistoryResponse extends ParsedBaseResponse {
1154
+ type: "cpu_history";
1155
+ count: number;
1156
+ entries: ParsedCpuHistoryEntry[];
1157
+ }
1158
+
1159
+ /**
1160
+ * RESOURCE_GET (0x51) parsed shape. A discriminated union on `valueType` --
1161
+ * `e_MON_RESOURCE_TYPE_STRING` (wire byte 0) decodes to `value: string`,
1162
+ * `e_MON_RESOURCE_TYPE_INT` (wire byte 1) decodes to `value: number`.
1163
+ * [CITED monitor_binary.c:938-965]
1164
+ */
1165
+ export type ParsedResourceGetResponse = ParsedBaseResponse & { type: "resource_get" } & (
1166
+ | { valueType: "integer"; value: number }
1167
+ | { valueType: "string"; value: string }
1168
+ );
1169
+
1049
1170
  /** Fallback shape for any responseType this module has no specific case for
1050
1171
  * (including VERIF-02 case 6's response type byte 0x00, which is never a
1051
1172
  * real response/event type on the wire) -- the parser must produce this
@@ -1069,6 +1190,8 @@ export type ParsedResponse =
1069
1190
  | ParsedResumedEvent
1070
1191
  | ParsedJamEvent
1071
1192
  | ParsedUndumpResponse
1193
+ | ParsedCpuHistoryResponse
1194
+ | ParsedResourceGetResponse
1072
1195
  | ParsedUnknownResponse;
1073
1196
 
1074
1197
  // ---------------------------------------------------------------------------
@@ -1319,6 +1442,209 @@ export function parseResponse({ apiVersion, responseType, errorCode, requestId,
1319
1442
  case ResponseType.Undump:
1320
1443
  need(body, 2, responseType, requestId);
1321
1444
  return { type: "undump", requestId, errorCode, programCounter: body.readUInt16LE(0) };
1445
+ case ResponseType.CpuHistoryGet: {
1446
+ // CPUHISTORY_GET (0x86) body layout, re-derived 2026-08-18 from
1447
+ // monitor_binary_process_cpuhistory() (monitor_binary.c:1452-1620) and
1448
+ // verified against the real captures fixtures/binmon/cpuhistory-get.bin
1449
+ // (single entry) and cpuhistory-get-multi.bin (multi-entry, the stride
1450
+ // proof) -- see plan 07-12 and
1451
+ // .planning/phases/07-cycle-timing-and-wedge-triage/deferred-items.md's
1452
+ // "Route A (CPUHISTORY_GET) live decode mismatch" for the disproven
1453
+ // earlier layout this replaces (WR-13): that layout was NEVER
1454
+ // confirmed against a real reply and does not match what a genuine
1455
+ // VICE >= 3.10 build actually sends.
1456
+ //
1457
+ // count(u32LE) at offset 0. Then per entry: item_size(1) -- the byte
1458
+ // count of EVERYTHING AFTER this size byte
1459
+ // (response_size = 4 + count * (item_size + 1), so the entry stride is
1460
+ // item_size + 1) -- NOT the register-block length alone, the single
1461
+ // misreading that produced the previous layout. Inside one entry: a
1462
+ // register block written by write_registers() -- regCount(u16LE),
1463
+ // then regCount items of size(1)+id(1)+value(u16LE), each item's own
1464
+ // stride being its declared size + 1 (same item_size-is-the-wire's-own
1465
+ // -stride discipline as the RegisterInfo case above); then
1466
+ // cycle(u64LE); then instruction_length(1); then exactly
1467
+ // instruction_length bytes of instruction data (opcode, p1, p2, and a
1468
+ // trailing placeholder byte for a third parameter that exists on some
1469
+ // machines). instruction_length is a hardcoded constant 4 in VICE
1470
+ // (monitor_binary.c:1468) regardless of the decoded instruction's real
1471
+ // size -- see ParsedCpuHistoryEntry's doc comment.
1472
+ //
1473
+ // Every field is bounds-checked against BOTH the declared entry end
1474
+ // (entryEnd = offset + 1 + itemSize) and the body end via need() --
1475
+ // an item_size that does not leave room for its own
1476
+ // regCount/register-items/cycle/instruction_length is a
1477
+ // StockFramingError naming the observed item_size and what did not
1478
+ // fit, never a truncated silent success and never a RangeError
1479
+ // (T-07-12-03). 07-REVIEW.md WR-05 closed two holes in that claim: the
1480
+ // regCount read was bounds-checked against the BODY only, so a
1481
+ // multi-entry frame whose first entry declared item_size < 2 read its
1482
+ // regCount out of the NEXT entry and reported a fabricated number; and
1483
+ // the instruction-bytes guard used a hardcoded 3 rather than the
1484
+ // entry's own declared instruction_length, so an entry declaring 200
1485
+ // with 3 bytes present parsed successfully. Both are now
1486
+ // entry-relative. Do not reintroduce a literal in either guard. count is rejected above 65535 up front: VICE's own
1487
+ // count is a uint16_t (monitor_binary.c:1469, 1491's
1488
+ // little_endian_to_uint32() read), so a larger declared count is a
1489
+ // desynced or hostile frame and iterating it would be a
1490
+ // denial-of-service loop (T-07-12-01). The cursor always advances by
1491
+ // exactly 1 + itemSize per entry -- the stride from the source, never
1492
+ // a recomputed sum of the fields just read.
1493
+ need(body, 4, responseType, requestId);
1494
+ const count = body.readUInt32LE(0);
1495
+ if (count > 0xffff) {
1496
+ throw new StockFramingError(
1497
+ `CPUHISTORY_GET declared count ${count}, exceeding VICE's own uint16_t ceiling of 65535 (monitor_binary.c:1469) -- desynced or hostile frame`,
1498
+ { observed: count, expected: 0xffff, responseType, requestId },
1499
+ );
1500
+ }
1501
+ let offset = 4;
1502
+ const entries: ParsedCpuHistoryEntry[] = [];
1503
+ for (let index = 0; index < count; index += 1) {
1504
+ need(body, offset + 1, responseType, requestId);
1505
+ const itemSize = body[offset]!;
1506
+ const entryEnd = offset + 1 + itemSize;
1507
+ need(body, entryEnd, responseType, requestId);
1508
+
1509
+ let cursor = offset + 1;
1510
+ // WR-05 (1): bound regCount by the DECLARED ENTRY, not just the body.
1511
+ // The body-relative need() alone let an entry with item_size < 2 in a
1512
+ // multi-entry frame read its regCount out of the NEXT entry's bytes;
1513
+ // the resulting diagnostic then named a fabricated register count
1514
+ // (observed live: "does not leave room for register item 0 of 4608",
1515
+ // where 4608 was assembled from bytes outside entry 0). Check the
1516
+ // entry first, so the message can only ever name real numbers.
1517
+ if (cursor + 2 > entryEnd) {
1518
+ throw new StockFramingError(
1519
+ `CPUHISTORY_GET entry ${index}'s item_size ${itemSize} does not leave room for its own 2-byte regCount field`,
1520
+ { observed: itemSize, expected: 2, responseType, requestId },
1521
+ );
1522
+ }
1523
+ need(body, cursor + 2, responseType, requestId);
1524
+ const regCount = body.readUInt16LE(cursor);
1525
+ cursor += 2;
1526
+ for (let regIndex = 0; regIndex < regCount; regIndex += 1) {
1527
+ if (cursor + 2 > entryEnd) {
1528
+ throw new StockFramingError(
1529
+ `CPUHISTORY_GET entry ${index}'s item_size ${itemSize} does not leave room for register item ${regIndex} of ${regCount}`,
1530
+ { observed: itemSize, responseType, requestId },
1531
+ );
1532
+ }
1533
+ const regItemSize = body[cursor]!;
1534
+ cursor += 1 + regItemSize;
1535
+ }
1536
+ if (cursor + 8 > entryEnd) {
1537
+ throw new StockFramingError(
1538
+ `CPUHISTORY_GET entry ${index}'s item_size ${itemSize} does not leave room for the 8-byte cycle field after its ${regCount}-item register block`,
1539
+ { observed: itemSize, responseType, requestId },
1540
+ );
1541
+ }
1542
+ need(body, cursor + 8, responseType, requestId);
1543
+ const cycle = body.readBigUInt64LE(cursor);
1544
+ cursor += 8;
1545
+
1546
+ if (cursor + 1 > entryEnd) {
1547
+ throw new StockFramingError(
1548
+ `CPUHISTORY_GET entry ${index}'s item_size ${itemSize} does not leave room for the instruction_length byte`,
1549
+ { observed: itemSize, responseType, requestId },
1550
+ );
1551
+ }
1552
+ need(body, cursor + 1, responseType, requestId);
1553
+ const instructionLength = body[cursor]!;
1554
+ cursor += 1;
1555
+ if (instructionLength < 3) {
1556
+ throw new StockFramingError(
1557
+ `CPUHISTORY_GET entry ${index} declares instruction_length ${instructionLength}, too short to hold opcode/p1/p2`,
1558
+ { observed: instructionLength, expected: 3, responseType, requestId },
1559
+ );
1560
+ }
1561
+ // WR-05 (2): validate against what the entry DECLARES, not a
1562
+ // hardcoded 3. The error message here has always claimed to check
1563
+ // "room for its declared instruction_length", but the guard used a
1564
+ // literal 3 -- so an entry declaring instruction_length 200 with only
1565
+ // 3 instruction bytes present PARSED SUCCESSFULLY and handed
1566
+ // `instructionLength: 200` to consumers (observed against the real
1567
+ // parser). Bound it by instructionLength, then read the 3 fields this
1568
+ // struct defines (opcode/p1/p2); any bytes beyond those 3 within the
1569
+ // declared length are the trailing third-parameter placeholder VICE
1570
+ // reserves and are deliberately not surfaced.
1571
+ if (cursor + instructionLength > entryEnd) {
1572
+ throw new StockFramingError(
1573
+ `CPUHISTORY_GET entry ${index}'s item_size ${itemSize} does not leave room for its declared instruction_length ${instructionLength} ` +
1574
+ `(${entryEnd - cursor} byte(s) remain in the entry)`,
1575
+ { observed: itemSize, expected: instructionLength, responseType, requestId },
1576
+ );
1577
+ }
1578
+ need(body, cursor + instructionLength, responseType, requestId);
1579
+ const opcode = body[cursor]!;
1580
+ const p1 = body[cursor + 1]!;
1581
+ const p2 = body[cursor + 2]!;
1582
+
1583
+ entries.push({ cycle, instructionLength, opcode, p1, p2 });
1584
+ offset = entryEnd; // advance by 1 + itemSize -- the stride from the source
1585
+ }
1586
+ return { type: "cpu_history", requestId, errorCode, count, entries };
1587
+ }
1588
+ case ResponseType.ResourceGet: {
1589
+ // RESOURCE_GET (0x51) response layout: type(1) size(1) payload(size
1590
+ // bytes). type: 0 = e_MON_RESOURCE_TYPE_STRING, 1 =
1591
+ // e_MON_RESOURCE_TYPE_INT. For the integer case size is always 4 and
1592
+ // the payload is a uint32LE; for the string case size is the payload's
1593
+ // own byte length. [CITED monitor_binary.c:938-965]
1594
+ //
1595
+ // CR-02 (code review, plan 07-12 Task 3): the integer branch used to
1596
+ // read a FIXED 4 bytes at offset 2 behind only `need(body, 2 + size,
1597
+ // ...)` -- a body of `[0x01, 0x00]` (size=0) satisfies that guard and
1598
+ // then `readUInt32LE(2)` throws a bare RangeError straight out of
1599
+ // parseResponse(), violating this function's own normative rule
1600
+ // (never add a case that reads at a fixed or wire-derived offset
1601
+ // without a preceding need() for the bytes it ACTUALLY reads). A
1602
+ // 4-byte payload is part of e_MON_RESOURCE_TYPE_INT's own contract,
1603
+ // not a value to read past -- so the size mismatch is checked FIRST,
1604
+ // independent of how many bytes the (possibly also-lying) body
1605
+ // physically has, naming the observed size rather than surfacing as
1606
+ // a generic "body too short" message. Only once size is confirmed to
1607
+ // be exactly 4 does need(body, 2 + 4, ...) bounds-check the actual
1608
+ // payload bytes before reading them.
1609
+ need(body, 2, responseType, requestId);
1610
+ const valueTypeByte = body[0]!;
1611
+ const size = body[1]!;
1612
+ if (valueTypeByte === 1) {
1613
+ if (size !== 4) {
1614
+ throw new StockFramingError(
1615
+ `RESOURCE_GET integer response declares size ${size}, expected 4 (a uint32LE payload)`,
1616
+ { observed: size, expected: 4, responseType, requestId },
1617
+ );
1618
+ }
1619
+ need(body, 2 + 4, responseType, requestId);
1620
+ return { type: "resource_get", requestId, errorCode, valueType: "integer", value: body.readUInt32LE(2) };
1621
+ }
1622
+ // WR-06 (07-REVIEW.md): the documented contract is EXACTLY two value
1623
+ // types -- 0 = string, 1 = int ([CITED monitor_binary.c:938-965]
1624
+ // above). The CR-02 rewrite hardened `=== 1` and let everything else
1625
+ // fall through to the string branch, so a wire byte of 2, 7 or 0xff was
1626
+ // reported as `valueType: "string"` with `size` bytes of arbitrary
1627
+ // memory rendered as ASCII -- a MISLABELLED value where every other
1628
+ // out-of-contract input in this function produces a framing error, and
1629
+ // a direct sibling of the CR-02 rule stated at the top of this file. It
1630
+ // degraded safely only because the single consumer
1631
+ // (stock-timing.ts's resolveVideoStandard) rejects any non-integer
1632
+ // reply; that is a property of today's consumer, not of this parser.
1633
+ if (valueTypeByte !== 0) {
1634
+ throw new StockFramingError(
1635
+ `RESOURCE_GET reply declared value type ${valueTypeByte}, expected 0 (string) or 1 (integer)`,
1636
+ { observed: valueTypeByte, expected: 0, responseType, requestId },
1637
+ );
1638
+ }
1639
+ need(body, 2 + size, responseType, requestId);
1640
+ return {
1641
+ type: "resource_get",
1642
+ requestId,
1643
+ errorCode,
1644
+ valueType: "string",
1645
+ value: body.subarray(2, 2 + size).toString("ascii"),
1646
+ };
1647
+ }
1322
1648
  default:
1323
1649
  return { type: "unknown", requestId, errorCode, responseType };
1324
1650
  }
@@ -1540,6 +1866,8 @@ const RESPONSE_TYPE_OF_PARSED_KIND: Partial<Record<ParsedResponse["type"], Respo
1540
1866
  resumed: ResponseType.Resumed,
1541
1867
  jam: ResponseType.Jam,
1542
1868
  undump: ResponseType.Undump,
1869
+ cpu_history: ResponseType.CpuHistoryGet,
1870
+ resource_get: ResponseType.ResourceGet,
1543
1871
  };
1544
1872
 
1545
1873
  /** The wire ResponseType byte a parsed response was decoded from, for