@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.
- package/backend-detect.mts +58 -8
- package/capability-registry.ts +388 -0
- package/package.json +12 -1
- package/resources/backend-detect.mjs +30 -2
- package/resources/broker-launch.mjs +166 -34
- package/resources/vice-broker.mjs +25 -4
- package/stock-cia.ts +598 -0
- package/stock-connect.ts +137 -20
- package/stock-derived.ts +67 -14
- package/stock-diagnose.ts +994 -0
- package/stock-dispatch.ts +105 -4
- package/stock-handler.ts +29 -0
- package/stock-memory-search.ts +441 -0
- package/stock-memory.ts +92 -13
- package/stock-protocol.ts +328 -0
- package/stock-recycle.ts +500 -0
- package/stock-run-until.ts +400 -0
- package/stock-runstate.ts +46 -2
- package/stock-sprites.ts +712 -0
- package/stock-symbols.ts +431 -0
- package/stock-timing.ts +562 -0
- package/stock-vicii.ts +318 -0
- package/tools-manifest.stock.json +3031 -223
- package/version.ts +279 -0
- package/vice-proxy.ts +49 -6
package/stock-timing.ts
ADDED
|
@@ -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
|
+
};
|