@henols/vice-mcp 0.1.12 → 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.
@@ -0,0 +1,562 @@
1
+ #!/usr/bin/env node
2
+ // stock-timing.ts
3
+ //
4
+ // THE stock-backend implementation of `vice_cycles_stopwatch` (TIME-01), plus
5
+ // the two shared primitives (readCycleBaseline()/resolveVideoStandard()) that
6
+ // a later plan's `stock-diagnose.ts` liveness bracket reuses verbatim.
7
+ //
8
+ // WHY THIS FILE EXISTS: stock VICE's binary monitor has no monotonic cycle
9
+ // register at all (CLAUDE.md's own Protocol constraint) -- the fork's
10
+ // in-process `mon_stopwatch_get_elapsed()` has no wire equivalent. Two
11
+ // routes exist, chosen from Phase 2's BACK-04 capability resolution
12
+ // (`session.capabilities.cpuHistory`, settled once per connect, never
13
+ // re-probed here): Route A (VICE >= 3.10) reads CPUHISTORY_GET's newest
14
+ // entry's monotonic uint64 `cycle` field and is exact for any bracket
15
+ // length; Route B (below 3.10) reconstructs a within-frame position from
16
+ // `LIN`/`CYC` and is exact only within one frame, refusing explicitly the
17
+ // moment a frame boundary is PROVEN crossed (TIME-03) rather than guessing a
18
+ // correction.
19
+ //
20
+ // WHAT NOT TO DO:
21
+ // - Never assign `cycles: 0` or `cycles: null` for an unmeasurable
22
+ // bracket. `measurable: false` with a `reason` and NO `cycles` key at
23
+ // all is the only honest shape -- see the incident this rule exists to
24
+ // prevent: `.claude/skills/c64-program-recon/references/observation-hazards.md`'s
25
+ // record of the fork's stopwatch reading 258,504,308 cycles and being
26
+ // trusted as fact.
27
+ // - Never hardcode a register id for LIN/CYC/PC. `registerCatalogFor()`
28
+ // (stock-registers.ts) is the only route from a register NAME to its
29
+ // wire id -- ids are not stable across builds.
30
+ // - Never guess a `+ k * cyclesPerFrame` correction for an unknown `k`
31
+ // when Route B proves a frame boundary was crossed. `CPUHISTORY_GET`
32
+ // (VICE >= 3.10) is the only route that can measure that bracket; name
33
+ // it in the refusal rather than approximate past it.
34
+ // - Never add a `resourceSetBody()`/`RESOURCE_SET` call here.
35
+ // `MachineVideoStandard`'s SET side reaches
36
+ // `machine_trigger_reset(POWER_CYCLE)` one call deep (CLAUDE.md's Safety
37
+ // constraint) -- this file only ever sends RESOURCE_GET (0x51), read-side
38
+ // only, and only for this one resource name.
39
+ import {
40
+ CommandType,
41
+ memspaceBody,
42
+ resourceGetBody,
43
+ StockConnectionClosedError,
44
+ StockRequestTimeoutError,
45
+ type ParsedCpuHistoryEntry,
46
+ type StockProtocolError,
47
+ } from "./stock-protocol.ts";
48
+ import { MachineRestartedError } from "./vice.ts";
49
+ import { clampCpuHistoryCount, type StockConnectSession } from "./stock-connect.ts";
50
+ import { registerCatalogFor } from "./stock-registers.ts";
51
+ import { stockAnswer, convertWireError, isErrorText, type StockSessionHandler, type StockOkResult } from "./stock-handler.ts";
52
+
53
+ // ---------------------------------------------------------------------------
54
+ // Video standards -- MachineVideoStandard's OWN integer resource values,
55
+ // 1-based (never 0-based). [CITED c64/c64.h:36-58, machine.h:57-60] The
56
+ // familiar "PAL is 19656 cycles/frame" figure is 63 * 312 -- documented here
57
+ // for orientation only; nothing in this file multiplies cyclesPerLine by
58
+ // screenLines and stores the literal product as a constant. Every consumer
59
+ // of this table derives that product itself, from these two fields, so a
60
+ // wrong PAL-only assumption cannot silently survive a video-standard change.
61
+ // ---------------------------------------------------------------------------
62
+
63
+ export interface VideoStandardEntry {
64
+ cyclesPerLine: number;
65
+ screenLines: number;
66
+ name: string;
67
+ }
68
+
69
+ export const VIDEO_STANDARDS: Record<number, VideoStandardEntry> = {
70
+ 1: { cyclesPerLine: 63, screenLines: 312, name: "PAL" }, // MACHINE_SYNC_PAL
71
+ 2: { cyclesPerLine: 65, screenLines: 263, name: "NTSC" }, // MACHINE_SYNC_NTSC
72
+ 3: { cyclesPerLine: 64, screenLines: 262, name: "NTSC-old" }, // MACHINE_SYNC_NTSCOLD
73
+ 4: { cyclesPerLine: 65, screenLines: 312, name: "PAL-N" }, // MACHINE_SYNC_PALN
74
+ };
75
+
76
+ /** MachineVideoStandard's own documented default -- returned when the
77
+ * resource read fails or answers a value outside VIDEO_STANDARDS. [CITED
78
+ * c64/c64-resources.c:438] */
79
+ const PAL_STANDARD_VALUE = 1;
80
+
81
+ export interface VideoStandardResult {
82
+ /** MachineVideoStandard's own integer value -- 1 (the PAL default) when
83
+ * `assumed` is true. */
84
+ value: number;
85
+ cyclesPerLine: number;
86
+ screenLines: number;
87
+ name: string;
88
+ /** True when this result was NOT read from the emulator -- the wire
89
+ * read failed, or it answered something not in VIDEO_STANDARDS. A caller
90
+ * must report this rather than presenting the PAL fallback as an
91
+ * observation (T-07-13). */
92
+ assumed: boolean;
93
+ /** Present only when `assumed` is true -- names why. */
94
+ reason?: string;
95
+ }
96
+
97
+ /** Per-`session.targetId` cache of the resolved video standard -- a
98
+ * SUCCESSFUL read only. A `resolveVideoStandard()` fallback (assumed: true)
99
+ * is deliberately never cached, so a transient wire failure gets a fresh
100
+ * chance to resolve for real on the next call, rather than pinning a
101
+ * degraded answer for the rest of the session.
102
+ *
103
+ * Keyed on `session.targetId` (a plain string), not the session object
104
+ * itself, matching this plan's own instruction -- unlike
105
+ * `bankCatalogFor()`'s/`registerCatalogFor()`'s object-keyed `WeakMap`s. The
106
+ * two caches solve different problems: those cache a per-CONNECTION
107
+ * enumeration that a fresh `stockReconnect()` naturally invalidates by
108
+ * handing back a new session object; this caches a per-TARGET machine
109
+ * property. `MachineVideoStandard`'s only WRITE path
110
+ * (`RESOURCE_SET`) reaches `machine_trigger_reset(POWER_CYCLE)` one call
111
+ * deep (CLAUDE.md), and this file adds no `RESOURCE_SET` encoder at all --
112
+ * so within this codebase's own reach, the value can never change out from
113
+ * under a live target, and a `targetId`-keyed cache cannot go stale for a
114
+ * reason this file itself could cause.
115
+ *
116
+ * WR-14 (07-REVIEW.md) added the EPOCH half. `session.targetId` survives both a
117
+ * `stockReconnect()` and a `vice_recycle` respawn, so a `targetId`-keyed entry
118
+ * outlives the machine it describes -- a respawned instance can be a different
119
+ * build or a different model entirely. Every entry therefore records the
120
+ * `baselineEpoch` of the session that established it, and an entry whose epoch
121
+ * does not match the reading session's is treated as a MISS, not as a value. */
122
+ interface CachedVideoStandard {
123
+ result: VideoStandardResult;
124
+ /** The `session.baselineEpoch` in force when this was read. `null` means
125
+ * identity could not be proven at connect time; a `null` entry is only ever
126
+ * reused by another `null`-epoch session on the same target, which is the
127
+ * most this file can honestly claim. */
128
+ epoch: number | null;
129
+ }
130
+
131
+ let videoStandardCache = new Map<string, CachedVideoStandard>();
132
+
133
+ function palFallback(reason: string): VideoStandardResult {
134
+ const pal = VIDEO_STANDARDS[PAL_STANDARD_VALUE]!;
135
+ return { value: PAL_STANDARD_VALUE, cyclesPerLine: pal.cyclesPerLine, screenLines: pal.screenLines, name: pal.name, assumed: true, reason };
136
+ }
137
+
138
+ /**
139
+ * Resolves the connected target's video standard via RESOURCE_GET (0x51,
140
+ * read-side only -- see this file's header comment), caching a successful
141
+ * read per `session.targetId`. Never throws: a rejecting `send()`, an
142
+ * unexpected reply shape, or a value not in `VIDEO_STANDARDS` all fall back
143
+ * to a PAL result carrying `assumed: true` and a `reason`, so every caller
144
+ * can report the assumption rather than present it as an observation
145
+ * (T-07-13).
146
+ */
147
+ export async function resolveVideoStandard(session: StockConnectSession): Promise<VideoStandardResult> {
148
+ const cached = videoStandardCache.get(session.targetId);
149
+ // WR-14: a cache hit must ALSO match the session's epoch -- a targetId alone
150
+ // cannot distinguish this machine from the one that replaced it.
151
+ if (cached && cached.epoch === session.baselineEpoch) {
152
+ return cached.result;
153
+ }
154
+ if (cached) {
155
+ videoStandardCache.delete(session.targetId);
156
+ }
157
+
158
+ try {
159
+ const response = await session.client.send(CommandType.ResourceGet, resourceGetBody({ name: "MachineVideoStandard" }));
160
+ if (response.type !== "resource_get" || response.valueType !== "integer") {
161
+ const observedType = response.type === "resource_get" ? `valueType "${response.valueType}"` : `reply type "${response.type}"`;
162
+ return palFallback(`resolveVideoStandard: MachineVideoStandard resource replied with an unexpected shape (${observedType}) -- assuming PAL`);
163
+ }
164
+ const entry = VIDEO_STANDARDS[response.value];
165
+ if (!entry) {
166
+ return palFallback(`resolveVideoStandard: MachineVideoStandard resource returned an unrecognized value (${response.value}) -- assuming PAL`);
167
+ }
168
+ const result: VideoStandardResult = { value: response.value, cyclesPerLine: entry.cyclesPerLine, screenLines: entry.screenLines, name: entry.name, assumed: false };
169
+ videoStandardCache.set(session.targetId, { result, epoch: session.baselineEpoch });
170
+ return result;
171
+ } catch (err) {
172
+ // WR-17 (07-REVIEW.md): a TRANSPORT failure is not a value-shaped failure
173
+ // and must NOT be laundered into "assuming PAL".
174
+ //
175
+ // This is the last wire call inside Route B's readCycleBaseline(), which
176
+ // runStockLivenessBracket() calls -- so a socket that dies HERE used to be
177
+ // swallowed, and 07-15's new `connection_lost` / `request_timeout`
178
+ // diagnosis_unavailable reason classes (which both the stock manifest and
179
+ // vice-wedge-triage/SKILL.md now promise) could never be reached from this
180
+ // path. The failure re-surfaced later, if at all, as
181
+ // `evidence_gathering_failed`. The new classification is only ever as
182
+ // honest as the narrowest catch on the path, and this was it.
183
+ //
184
+ // Rethrow the three typed conditions that mean "the connection or the
185
+ // machine, not the value": handleCyclesStopwatch()'s own
186
+ // convertWireError() and handleDiagnoseStock()'s classifier both know what
187
+ // to do with them. Keep the PAL fallback for value-shaped failures only
188
+ // (an unexpected reply shape, an unrecognised standard, a build with no
189
+ // such resource). Do NOT widen this back to a bare catch.
190
+ if (err instanceof MachineRestartedError || err instanceof StockConnectionClosedError || err instanceof StockRequestTimeoutError) {
191
+ throw err;
192
+ }
193
+ const message = err instanceof Error ? err.message : String(err);
194
+ return palFallback(`resolveVideoStandard: reading the MachineVideoStandard resource failed (${message}) -- assuming PAL`);
195
+ }
196
+ }
197
+
198
+ /** Pure frame-position arithmetic: `lin * cyclesPerLine + cyc`, bounded
199
+ * `0..(screenLines * cyclesPerLine - 1)`. [Pattern 3, 07-RESEARCH.md] */
200
+ export function positionWithinFrame(lin: number, cyc: number, cyclesPerLine: number): number {
201
+ return lin * cyclesPerLine + cyc;
202
+ }
203
+
204
+ // ---------------------------------------------------------------------------
205
+ // readCycleBaseline() -- the shared, dual-route primitive.
206
+ // ---------------------------------------------------------------------------
207
+
208
+ export interface CpuHistoryBaseline {
209
+ route: "cpu_history";
210
+ cycle: bigint;
211
+ pc: number;
212
+ }
213
+
214
+ export interface FramePositionBaseline {
215
+ route: "frame_position";
216
+ lin: number;
217
+ cyc: number;
218
+ pc: number;
219
+ standard: VideoStandardResult;
220
+ position: number;
221
+ }
222
+
223
+ export interface UnavailableBaseline {
224
+ route: "unavailable";
225
+ reason: string;
226
+ }
227
+
228
+ export type CycleBaseline = CpuHistoryBaseline | FramePositionBaseline | UnavailableBaseline;
229
+
230
+ /** Route A's one extra REGISTERS_GET for PC -- Route B reads PC out of the
231
+ * SAME REGISTERS_GET reply it already needs for LIN/CYC (see
232
+ * `readCycleBaseline()` below), so this helper exists only for Route A.
233
+ *
234
+ * Exported (07-14) so `stock-run-until.ts` can reuse this one existing
235
+ * PC-read seam to resolve the already_gone cleanup race, rather than
236
+ * re-deriving a second one. No test in this tree asserts these throw
237
+ * messages' exact strings (grep-verified against `readCycleBaseline:` at
238
+ * 07-14 plan time), so they are named for what this function does, not for
239
+ * the caller that historically was its only one. */
240
+ export async function readProgramCounter(session: StockConnectSession): Promise<number> {
241
+ const catalog = await registerCatalogFor(session);
242
+ const pcEntry = catalog.byName.get("PC");
243
+ if (!pcEntry) {
244
+ throw new Error("readProgramCounter: the connected VICE build's REGISTERS_AVAILABLE enumeration has no \"PC\" register");
245
+ }
246
+ const response = await session.client.send(CommandType.RegistersGet, memspaceBody({ memspace: 0x00 }));
247
+ if (response.type !== "registers") {
248
+ throw new Error(`readProgramCounter: expected a registers reply, got "${response.type}"`);
249
+ }
250
+ const found = response.registers.find((reg) => reg.id === pcEntry.id);
251
+ if (!found) {
252
+ throw new Error("readProgramCounter: REGISTERS_GET's reply did not include a value for \"PC\" despite the catalog enumerating it");
253
+ }
254
+ return found.value;
255
+ }
256
+
257
+ /**
258
+ * The shared cycle-baseline primitive `handleCyclesStopwatch()` below and
259
+ * (07-06) `stock-diagnose.ts`'s liveness bracket both consume. Route
260
+ * selection is a SINGLE read of `session.capabilities.cpuHistory` -- Phase
261
+ * 2's BACK-04 already settled this once per connect; there is no second
262
+ * probe here (Pattern 2, 07-RESEARCH.md).
263
+ *
264
+ * Returns a `"route"`-discriminated record, never a fabricated figure:
265
+ * - `"cpu_history"`: Route A. CPUHISTORY_GET's newest entry's exact
266
+ * bigint `cycle` -- the LAST element of `entries[]`, which arrives
267
+ * oldest-first (WR-07) -- plus PC via one extra REGISTERS_GET.
268
+ * - `"frame_position"`: Route B. `LIN`/`CYC`/`PC` all read from ONE
269
+ * REGISTERS_GET reply, plus the resolved video standard and the
270
+ * computed within-frame `position`.
271
+ * - `"unavailable"`: the connected build enumerates neither `LIN` nor
272
+ * `CYC` by name -- named in `reason`, never a substituted zero.
273
+ */
274
+ export async function readCycleBaseline(session: StockConnectSession): Promise<CycleBaseline> {
275
+ if (session.capabilities.cpuHistory === "available") {
276
+ // Route A: CPUHISTORY_GET(count:1) -- NEVER count:0, which real VICE
277
+ // rejects with InvalidParameter (the Wave-0 defect 07-01 fixed).
278
+ const count = clampCpuHistoryCount(1);
279
+ const body = Buffer.alloc(5);
280
+ body[0] = 0x00; // memspace: main
281
+ body.writeUInt32LE(count, 1);
282
+ const response = await session.client.send(CommandType.CpuHistoryGet, body);
283
+ if (response.type !== "cpu_history") {
284
+ throw new Error(`readCycleBaseline: expected a cpu_history reply, got "${response.type}"`);
285
+ }
286
+ if (response.entries.length === 0) {
287
+ throw new Error("readCycleBaseline: CPUHISTORY_GET(count:1) returned zero entries");
288
+ }
289
+ // WR-07 (07-REVIEW.md): entries[] is in WIRE order -- entries[0] is the
290
+ // OLDEST of the returned window, entries[length-1] the NEWEST. 07-12
291
+ // proved this against fixtures/binmon/cpuhistory-get-multi.bin (four
292
+ // entries, strictly ascending cycles) and corrected the parser's own
293
+ // documentation, but this consumer still read entries[0] and named it
294
+ // `newest`. That is correct ONLY while count === 1, and nothing enforces
295
+ // that coupling: the parser returns whatever `count` the server sent, so a
296
+ // future caller -- or a build that returns a full window regardless of the
297
+ // requested count -- would silently sample the OLDEST entry and report a
298
+ // stale baseline with `exactness: "exact"`. Index from the END so this
299
+ // stays correct if the window ever grows, and do not rename this back to a
300
+ // positional read: the misleading identifier is what would make such a
301
+ // change look correct.
302
+ const newest: ParsedCpuHistoryEntry = response.entries[response.entries.length - 1]!;
303
+ const pc = await readProgramCounter(session);
304
+ return { route: "cpu_history", cycle: newest.cycle, pc };
305
+ }
306
+
307
+ // Route B: the build's own catalog must enumerate BOTH LIN and CYC by
308
+ // name -- never a hardcoded register id (this file's own WHAT NOT TO DO).
309
+ const catalog = await registerCatalogFor(session);
310
+ const lin = catalog.byName.get("LIN");
311
+ const cyc = catalog.byName.get("CYC");
312
+ if (!lin || !cyc) {
313
+ const missing = [!lin ? "LIN" : null, !cyc ? "CYC" : null].filter((name): name is string => name !== null).join(" and ");
314
+ return {
315
+ route: "unavailable",
316
+ reason: `readCycleBaseline: the connected VICE build's REGISTERS_AVAILABLE enumeration has no ${missing} register -- frame-position reconstruction is impossible without it`,
317
+ };
318
+ }
319
+
320
+ const response = await session.client.send(CommandType.RegistersGet, memspaceBody({ memspace: 0x00 }));
321
+ if (response.type !== "registers") {
322
+ throw new Error(`readCycleBaseline: expected a registers reply, got "${response.type}"`);
323
+ }
324
+ const byId = new Map(response.registers.map((reg) => [reg.id, reg.value] as const));
325
+ const linValue = byId.get(lin.id);
326
+ const cycValue = byId.get(cyc.id);
327
+ if (linValue === undefined || cycValue === undefined) {
328
+ return {
329
+ route: "unavailable",
330
+ reason: "readCycleBaseline: REGISTERS_GET's reply did not include a value for LIN and/or CYC despite the catalog enumerating them",
331
+ };
332
+ }
333
+ const pcEntry = catalog.byName.get("PC");
334
+ const pcValue = pcEntry ? byId.get(pcEntry.id) : undefined;
335
+ if (pcValue === undefined) {
336
+ throw new Error("readCycleBaseline: REGISTERS_GET's reply did not include a value for \"PC\"");
337
+ }
338
+
339
+ const standard = await resolveVideoStandard(session);
340
+ const position = positionWithinFrame(linValue, cycValue, standard.cyclesPerLine);
341
+ return { route: "frame_position", lin: linValue, cyc: cycValue, pc: pcValue, standard, position };
342
+ }
343
+
344
+ // ---------------------------------------------------------------------------
345
+ // resetTimingStateForTest() -- clears BOTH module-level caches this file
346
+ // owns (the video-standard cache above, and the stopwatch baseline store
347
+ // below), matching resetBankCatalogsForTest()'s / resetCheckpointStateForTest()'s
348
+ // per-file, single-call reset convention.
349
+ // ---------------------------------------------------------------------------
350
+
351
+ /** The stopwatch's own per-target baseline store (Task 2) -- declared here,
352
+ * ahead of handleCyclesStopwatch(), so resetTimingStateForTest() can clear
353
+ * both this file's caches from one place.
354
+ *
355
+ * WR-14 (07-REVIEW.md): each entry records the `baselineEpoch` of the session
356
+ * that recorded it. `session.targetId` survives a `stockReconnect()` AND a
357
+ * `vice_recycle` respawn, so keying on it alone let the stopwatch compare a
358
+ * baseline taken on one machine against a sample taken on its replacement.
359
+ * Only Route A had a `delta < 0n` guard to catch that accidentally; Route B
360
+ * compared two unrelated within-frame positions and answered
361
+ * `measurable: true`. An epoch mismatch is now a first-class refusal on BOTH
362
+ * routes, checked before either route's own arithmetic. */
363
+ interface StoredBaseline {
364
+ baseline: CycleBaseline;
365
+ epoch: number | null;
366
+ }
367
+
368
+ let stopwatchBaselines = new Map<string, StoredBaseline>();
369
+
370
+ export function resetTimingStateForTest(): void {
371
+ videoStandardCache = new Map<string, CachedVideoStandard>();
372
+ stopwatchBaselines = new Map<string, StoredBaseline>();
373
+ }
374
+
375
+ /**
376
+ * WR-14: THE per-target eviction seam for both of this file's `targetId`-keyed
377
+ * caches, mirroring stock-checkpoints.ts's `forgetConditionsForOtherTargets()`
378
+ * exactly -- including being called from the SAME place in
379
+ * stock-dispatch.ts's ensureStockSession(), so the two registries can never
380
+ * drift apart on when they forget.
381
+ *
382
+ * Reaching that call site means a fresh handshake just installed a new held
383
+ * session, so every OTHER target this process has seen is an instance that has
384
+ * already been torn down and can never be consulted again. Without this, both
385
+ * maps (strong `Map`s, deliberately, so they survive a `stockReconnect()` to
386
+ * the same machine) grow one entry per distinct instance for the life of the
387
+ * process -- which a broker that recycles/respawns/re-warms routinely makes
388
+ * unbounded.
389
+ *
390
+ * Deliberately does NOT touch the ACTIVE target's entries: a reconnect to the
391
+ * same machine must keep a usable stopwatch baseline, and the epoch check
392
+ * inside handleCyclesStopwatch() is what catches the case where "the same
393
+ * targetId" is not the same machine.
394
+ */
395
+ export function forgetTimingForOtherTargets(activeTargetId: string): void {
396
+ for (const targetId of videoStandardCache.keys()) {
397
+ if (targetId !== activeTargetId) videoStandardCache.delete(targetId);
398
+ }
399
+ for (const targetId of stopwatchBaselines.keys()) {
400
+ if (targetId !== activeTargetId) stopwatchBaselines.delete(targetId);
401
+ }
402
+ }
403
+
404
+ // ---------------------------------------------------------------------------
405
+ // handleCyclesStopwatch -- vice_cycles_stopwatch (TIME-01/TIME-03).
406
+ //
407
+ // An unmeasurable bracket emits NO `cycles` key at all -- `0` is a wrong
408
+ // answer, not a null answer.
409
+ // ---------------------------------------------------------------------------
410
+
411
+ const VALID_ACTIONS = ["reset", "read", "reset_and_read"] as const;
412
+ type StopwatchAction = (typeof VALID_ACTIONS)[number];
413
+
414
+ function isValidAction(value: unknown): value is StopwatchAction {
415
+ return typeof value === "string" && (VALID_ACTIONS as readonly string[]).includes(value);
416
+ }
417
+
418
+ export const handleCyclesStopwatch: StockSessionHandler = async (args, session) => {
419
+ const unexpectedKeys = Object.keys(args).filter((key) => key !== "action");
420
+ if (unexpectedKeys.length > 0) {
421
+ return isErrorText(`vice_cycles_stopwatch: unexpected argument(s): ${unexpectedKeys.join(", ")} -- this tool takes only "action"`);
422
+ }
423
+ const rawAction = args.action;
424
+ if (!isValidAction(rawAction)) {
425
+ return isErrorText(
426
+ `vice_cycles_stopwatch: "action" is required and must be one of ${VALID_ACTIONS.join(", ")}, got ${JSON.stringify(rawAction)}`,
427
+ );
428
+ }
429
+ const action = rawAction;
430
+
431
+ let sample: CycleBaseline;
432
+ try {
433
+ sample = await readCycleBaseline(session);
434
+ } catch (err) {
435
+ return convertWireError("vice_cycles_stopwatch", err as StockProtocolError | Error);
436
+ }
437
+
438
+ if (action === "reset") {
439
+ stopwatchBaselines.set(session.targetId, { baseline: sample, epoch: session.baselineEpoch });
440
+ return stockAnswer(session.client, {
441
+ requested: "cycles_stopwatch",
442
+ action: "reset",
443
+ route: sample.route,
444
+ measurable: false,
445
+ reason: "a baseline was recorded; call read to measure elapsed cycles",
446
+ });
447
+ }
448
+
449
+ // action is "read" or "reset_and_read" from here on.
450
+ const stored = stopwatchBaselines.get(session.targetId);
451
+
452
+ function finish(payload: Record<string, unknown>): StockOkResult {
453
+ // reset_and_read stores the new sample as the NEXT baseline on EVERY
454
+ // path, including the unmeasurable ones -- a failed measurement must
455
+ // not silently leave a stale baseline behind.
456
+ if (action === "reset_and_read") {
457
+ stopwatchBaselines.set(session.targetId, { baseline: sample, epoch: session.baselineEpoch });
458
+ }
459
+ return stockAnswer(session.client, { requested: "cycles_stopwatch", action, ...payload });
460
+ }
461
+
462
+ if (!stored) {
463
+ return finish({
464
+ route: sample.route,
465
+ measurable: false,
466
+ reason: `no baseline recorded on this instance -- call vice_cycles_stopwatch with action:"reset" before action:"${action}"`,
467
+ });
468
+ }
469
+
470
+ // WR-14: the epoch check, BEFORE either route's arithmetic. `targetId`
471
+ // survives a stockReconnect() and a vice_recycle respawn, so a matching key
472
+ // does not prove the baseline and the sample came from the same machine.
473
+ // Only Route A had a `delta < 0n` guard that caught this by accident; Route B
474
+ // happily subtracted two unrelated within-frame positions and answered
475
+ // `measurable: true`. Refusing here covers both routes for the real reason
476
+ // rather than one route for a coincidental one.
477
+ if (stored.epoch !== session.baselineEpoch) {
478
+ return finish({
479
+ route: sample.route,
480
+ measurable: false,
481
+ reason:
482
+ `the baseline was recorded against restart epoch ${String(stored.epoch)} but this session's epoch is ` +
483
+ `${String(session.baselineEpoch)} -- the emulator was restarted, recycled or respawned in between, so the two samples ` +
484
+ `are from different machines and the elapsed count is meaningless; call action:"reset" again before reading`,
485
+ });
486
+ }
487
+ const baseline = stored.baseline;
488
+
489
+ if (sample.route === "unavailable") {
490
+ return finish({ route: "unavailable", measurable: false, reason: sample.reason });
491
+ }
492
+
493
+ if (baseline.route !== sample.route) {
494
+ return finish({
495
+ route: sample.route,
496
+ measurable: false,
497
+ reason:
498
+ `the baseline was recorded on route "${baseline.route}" but the current sample is route "${sample.route}" -- ` +
499
+ "this is possible only across a reconnect that changed this build's cpu-history capability; reset again before reading",
500
+ });
501
+ }
502
+
503
+ if (sample.route === "cpu_history" && baseline.route === "cpu_history") {
504
+ const delta = sample.cycle - baseline.cycle;
505
+ if (delta < 0n) {
506
+ return finish({
507
+ route: "cpu_history",
508
+ measurable: false,
509
+ reason: `the monotonic CPUHISTORY_GET clock went backwards (baseline cycle ${baseline.cycle}, current cycle ${sample.cycle}) -- the machine restarted or its history was cleared`,
510
+ });
511
+ }
512
+ // WR-13 (07-REVIEW.md): `cycles` is a JS number for the manifest's sake,
513
+ // but a uint64 clock delta does not always fit one. ParsedCpuHistoryEntry's
514
+ // own doc comment says the cycle is "never narrowed to Number, since a
515
+ // uint64 clock does not fit a JS number safely and the stopwatch's whole
516
+ // value is exactness" -- and this is the narrowing site. Above
517
+ // Number.MAX_SAFE_INTEGER, `Number(delta)` silently rounds, so labelling
518
+ // that "exact" is false on its face. `cyclesExact` (the decimal string) is
519
+ // always the authoritative figure; the LABEL is what changes.
520
+ const narrowable = delta <= BigInt(Number.MAX_SAFE_INTEGER);
521
+ const measured: Record<string, unknown> = {
522
+ route: "cpu_history",
523
+ measurable: true,
524
+ cycles: Number(delta),
525
+ cyclesExact: delta.toString(),
526
+ exactness: narrowable ? "exact" : "exact-but-narrowed",
527
+ };
528
+ if (!narrowable) {
529
+ measured.caveat =
530
+ `the elapsed count ${delta} exceeds Number.MAX_SAFE_INTEGER (${Number.MAX_SAFE_INTEGER}), so the "cycles" field has been ` +
531
+ "rounded by the narrowing to a JS number -- read cyclesExact, which is the exact decimal value, for any arithmetic that matters";
532
+ }
533
+ return finish(measured);
534
+ }
535
+
536
+ // Route B: frame_position (the only remaining route once cpu_history and
537
+ // unavailable are handled above, and the mismatch check above proved
538
+ // baseline.route === sample.route).
539
+ const before = baseline as FramePositionBaseline;
540
+ const after = sample as FramePositionBaseline;
541
+ if (after.position < before.position) {
542
+ return finish({
543
+ route: "frame_position",
544
+ measurable: false,
545
+ reason:
546
+ "at least one frame boundary was crossed between reset and read -- LIN/CYC's within-frame position went backwards, and the elapsed " +
547
+ "cycle count cannot be reconstructed from LIN/CYC alone on a VICE build below 3.10; CPUHISTORY_GET (VICE >= 3.10) is the route that " +
548
+ "can measure a bracket that may cross a frame boundary",
549
+ });
550
+ }
551
+ return finish({
552
+ route: "frame_position",
553
+ measurable: true,
554
+ cycles: after.position - before.position,
555
+ exactness: "within-one-frame-unverified",
556
+ caveat:
557
+ "LIN/CYC cannot distinguish \"0 frames elapsed\" from \"exactly N whole frames elapsed\" -- this figure is trustworthy only for a " +
558
+ "bracket known to be bounded well under one frame",
559
+ standard: after.standard.name,
560
+ standardAssumed: after.standard.assumed,
561
+ });
562
+ };