@henols/vice-mcp 0.2.2 → 0.2.4

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.
Files changed (84) hide show
  1. package/README.md +2 -2
  2. package/THIRD-PARTY-NOTICES.md +422 -1
  3. package/anno-bank.ts +171 -0
  4. package/anno-cli.ts +1736 -163
  5. package/anno-confidence.ts +2 -2
  6. package/anno-derive.ts +6 -6
  7. package/anno-details.ts +4 -4
  8. package/anno-enum-gen.ts +416 -30
  9. package/anno-export-asm.ts +1211 -126
  10. package/anno-graphics.ts +338 -0
  11. package/anno-hazard-report.ts +1367 -0
  12. package/anno-import.ts +495 -0
  13. package/anno-index.ts +8 -8
  14. package/anno-join.ts +480 -0
  15. package/anno-memmap-render.ts +22 -21
  16. package/anno-provenance-ledger.ts +472 -0
  17. package/anno-regbits-gen.ts +13 -13
  18. package/anno-register.ts +159 -0
  19. package/anno-store-export.ts +661 -0
  20. package/anno-store.ts +635 -124
  21. package/anno-symbols.ts +7 -7
  22. package/anno-tools.ts +1169 -16
  23. package/anno-types.ts +313 -40
  24. package/backend-detect.mts +124 -312
  25. package/build.ts +3 -1
  26. package/capture-predicate.ts +597 -0
  27. package/channel-lock.ts +349 -0
  28. package/evid-ingest.ts +217 -0
  29. package/evid-reconcile.ts +316 -0
  30. package/host-tool-client.ts +430 -0
  31. package/incident-record.ts +23 -12
  32. package/install-resources.ts +29 -13
  33. package/memmap-lookup.ts +285 -0
  34. package/package.json +27 -8
  35. package/prg-image.ts +1 -2
  36. package/repo-root.ts +87 -3
  37. package/resources/backend-detect.mjs +98 -236
  38. package/resources/broker-control.mjs +220 -54
  39. package/resources/broker-epoch.mjs +7 -8
  40. package/resources/broker-kill.mjs +36 -31
  41. package/resources/broker-launch.mjs +511 -374
  42. package/resources/broker-state.mjs +69 -24
  43. package/resources/container-guard.mjs +1 -1
  44. package/resources/ghidra-project.mjs +790 -0
  45. package/resources/host-tool.mjs +2533 -0
  46. package/resources/vice-broker.mjs +434 -290
  47. package/resources/vice-launcher.sh +127 -9
  48. package/stock-address.ts +1 -1
  49. package/stock-condition.ts +1 -1
  50. package/stock-connect.ts +9 -5
  51. package/stock-derived.ts +29 -37
  52. package/stock-diagnose.ts +200 -36
  53. package/stock-dispatch.ts +179 -77
  54. package/stock-handler.ts +1 -1
  55. package/stock-paths.ts +18 -14
  56. package/stock-petscii.ts +1 -1
  57. package/stock-protocol.ts +1 -1
  58. package/stock-recycle.ts +83 -2
  59. package/stock-reproducible-run.ts +811 -0
  60. package/stock-run-until.ts +100 -1
  61. package/stock-symbols.ts +4 -4
  62. package/stock-timing.ts +1 -1
  63. package/stop-oracle.ts +167 -0
  64. package/text-capability-probe.ts +660 -0
  65. package/text-connect.ts +157 -0
  66. package/text-protocol.ts +810 -0
  67. package/text-tools.ts +778 -0
  68. package/textmon-backtrace.ts +385 -0
  69. package/textmon-cpuhistory.ts +335 -0
  70. package/textmon-memmap.ts +494 -0
  71. package/textmon-profile.ts +458 -0
  72. package/textmon-registers.ts +748 -0
  73. package/tools-manifest.stock.json +864 -3
  74. package/vice-broker-client.ts +253 -108
  75. package/vice-errors.ts +268 -0
  76. package/vice-proxy.ts +339 -2144
  77. package/vsf-slice.ts +640 -0
  78. package/anno-d64.ts +0 -310
  79. package/capability-registry.ts +0 -390
  80. package/refresh-manifest.ts +0 -124
  81. package/tools-manifest.json +0 -1223
  82. package/vice-probe.ts +0 -278
  83. package/vice-sync.ts +0 -336
  84. package/vice.ts +0 -772
@@ -0,0 +1,811 @@
1
+ #!/usr/bin/env node
2
+ // stock-reproducible-run.ts
3
+ //
4
+ // THE ONE authoritative place in this repo holding the REPRODUCIBLE-RUN
5
+ // PROTOCOL (`REPRO-02`): the ordered sequence that takes an already-open stock
6
+ // binary-monitor session to a stop whose four-term identity
7
+ // `(PC, hit_count, (LIN, CYC))` is reproducible across pre-protocol jitter.
8
+ //
9
+ // WHY THIS FILE EXISTS. Measured over `-binarymonitor` on this host, this
10
+ // sequence -- connect (which halts the machine) -> arm the frame anchor and the
11
+ // target WHILE HALTED -> `RESET` hard -> exactly one `EXIT` -> wait
12
+ // event-driven for the target's own `CHECKPOINT_INFO` -> `REGISTERS_GET` --
13
+ // produced, at pre-protocol jitter of 0 / 1500 / 4000 ms, ONE identical 64K
14
+ // sha256 and ONE identical `(PC=$ea31, hit_count=1, LIN=257, CYC=57)`.
15
+ //
16
+ // The `RESET` step is what makes that true. The same sequence with the reset
17
+ // removed is the CONTROL this phase records red, and that is the whole reason
18
+ // the reset lives INSIDE this procedure rather than beside it: `REPRO-02` says
19
+ // the protocol must be reached through an optional argument rather than as a
20
+ // second route a caller can forget, so there is one procedure, one call site
21
+ // (`stock-run-until.ts`'s `handleRunUntil`), and no sub-flags. A caller cannot
22
+ // obtain "the protocol without the reset" through any published surface.
23
+ //
24
+ // WHY THIS SEQUENCE AND NOT THE AUTOSTART ONE. Plan `33-03` measured a SECOND
25
+ // ordering for an AUTOSTARTed disk release and settled it as `S3`: arm the
26
+ // frame anchor while halted, then `AUTOSTART` (0xdd) -- which is ITSELF the
27
+ // power cycle (`autostart.c:1437`), so no `RESET` appears anywhere in it -- then
28
+ // `CHECKPOINT_LIST` to assert the anchor survived, then one `EXIT` per observed
29
+ // anchor hit to the target count. That ordering is NOT implemented here, and
30
+ // deliberately so:
31
+ //
32
+ // * `33-03` recorded `AUTOSTART_FRAME_EXACT: not-achieved`. Frame-exactness
33
+ // holds through anchor hit 50 (pre-load) and is LOST from hit 75: the
34
+ // power cycle resets the CPU, VIC-II and CIAs but NOT the absolute emulated
35
+ // clock, and the 1541's rotational phase is a function of that clock.
36
+ // * So the autostarted ordering is UNRESOLVED for a post-load stop, and this
37
+ // module implements the READY-prompt sequence measured green instead --
38
+ // never an ordering the evidence does not support.
39
+ // * The full ordered 11-step `S3` table, its argv, its measured
40
+ // divergence counts, and the withdrawal of the earlier "frame anchoring
41
+ // always fits inside the cap of 64" claim (66 differing addresses at
42
+ // jitter 4000) live in
43
+ // `.planning/phases/33-the-reproducible-run-protocol-and-the-capture-substrate-go-d/evidence/33-autostart-sequencing.md`.
44
+ // Read that file before adding an autostart path here.
45
+ //
46
+ // WHAT THIS PROCEDURE REPORTS, AND WHAT IT DOES NOT ASSERT. It REPORTS the stop
47
+ // identity it achieved. It does NOT assert frame-exactness -- `33-03`'s
48
+ // § "What 33-09 must NOT conclude from this file" is explicit that the right
49
+ // sequence is not sufficient for frame-exactness past the start of a disk load,
50
+ // and that the honest shape is to report rather than to claim.
51
+ //
52
+ // WHY `default_memspace` CONTAMINATION CANNOT REACH THIS PROCEDURE. A drive
53
+ // checkpoint hit sets `default_memspace` (`monitor.c:3393-3396`) and NO
54
+ // binary-monitor command resets it; after that, `ADVANCE_INSTRUCTIONS` and
55
+ // `EXECUTE_UNTIL_RETURN` step the DRIVE CPU and `@bank:` conditions fail
56
+ // outright. There is no direct remedy over the wire. This procedure is immune
57
+ // by CONSTRUCTION, not by luck: every command it sends carries an EXPLICIT
58
+ // memspace byte (`0x00` = main, routed through the encoders' own wire-byte
59
+ // mapping) or no memspace at all, and it never sends `ADVANCE_INSTRUCTIONS`,
60
+ // `EXECUTE_UNTIL_RETURN`, or a `@bank:`-bearing condition. Adding any of those
61
+ // three here would make a contaminated `default_memspace` silently retarget
62
+ // this procedure at the drive CPU and report a confident stop on the wrong
63
+ // processor -- see WHAT NOT TO DO.
64
+ //
65
+ // WHAT NOT TO DO:
66
+ // - Never wrap the three cleanup paths (hit / timeout / restarted) in one
67
+ // undifferentiated `finally { delete }` -- this design space's documented
68
+ // first-draft mistake, inherited verbatim in substance from
69
+ // `stock-run-until.ts`. Each path takes its OWN action; only the TIMEOUT
70
+ // path deletes the TARGET (the target is temporary, and VICE auto-deletes a
71
+ // temporary checkpoint the instant it fires, `mon_breakpoint.c:605-607`).
72
+ // The ANCHOR is non-temporary, so it is deleted on every path that OWNS it
73
+ // -- hit and timeout -- and on neither the arming-failure nor the
74
+ // machine-restarted path, where there is nothing left to own.
75
+ // - Never call `registerTraceCheckpoint()` here. That guard
76
+ // (`stock-checkpoints.ts`) exists for `stop:false` TRACE checkpoints, and
77
+ // EVERY checkpoint this file arms stops.
78
+ // - Never send a second resume for one wait. Exactly ONE `EXIT` per call,
79
+ // which is `vice-sync.ts`'s own "exactly one resume per wait" invariant
80
+ // ported here in its stock-native (event-driven, not polling) form. The
81
+ // consequence is deliberate and is REPORTED, not papered over: the anchor
82
+ // is armed `stop: true`, so an anchor hit ARRIVING BEFORE the target's
83
+ // halts the machine, no further instructions execute, and the wait bounds
84
+ // out as a TIMEOUT carrying `anchorStoppedFirst: true` and a reason naming
85
+ // it. That is a refusal. Do NOT "fix" it by issuing a second `EXIT` --
86
+ // the multi-resume anchor-COUNTING loop (`33-03`'s `S3` steps 8-10) is an
87
+ // evidence script's job, driving this module's pieces directly, and is not
88
+ // a published tool surface.
89
+ // - Never invent a second wire-error converter. An arming/read failure goes
90
+ // through `convertWireError()` (the established per-handler convention);
91
+ // a failure surfacing from the resume/wait step is left to PROPAGATE
92
+ // uncaught, so the ONE existing converter seam (`withStockSession`'s own
93
+ // `convertHandshakeError`/`convertWireError`) produces the answer.
94
+ // - Never publish a sub-flag that removes a step from this procedure.
95
+ // `D-13`: there is no `skip_reset`, no `no_anchor` and no `reset_only`, so
96
+ // a protocol-without-the-reset is not a shippable option. `RUN_UNTIL_KEYS`
97
+ // in `stock-run-until.ts` is pinned by a single `assert.deepEqual` so a
98
+ // future one reds a test rather than earning a review comment.
99
+ // - Never anchor a stop on a WALL-CLOCK wait. Measured twice: 1242 differing
100
+ // bytes at the READY prompt, and 300 with a differing `LIN` on a real
101
+ // autostarted release. A `setTimeout` is a deadline here, never an anchor.
102
+ // - Never hardcode a wire register id for `PC`, `LIN` or `CYC`. Ids are not
103
+ // stable across builds; `registerCatalogFor()` (`stock-registers.ts`) is
104
+ // the only route from a register NAME to its id, and a build enumerating
105
+ // none of the three is REFUSED by name before anything is armed.
106
+ // - Never add `ADVANCE_INSTRUCTIONS`, `EXECUTE_UNTIL_RETURN` or a `@bank:`
107
+ // condition here -- see the `default_memspace` paragraph above.
108
+ // - Never reuse `readCycleBaseline()` (`stock-timing.ts`) for the triple.
109
+ // On a VICE >= 3.10 build it takes Route A -- `CPUHISTORY_GET` plus a
110
+ // SEPARATE `REGISTERS_GET` for `PC` only -- and never reads `LIN`/`CYC` at
111
+ // all, so it cannot supply the frame term this oracle needs. This module
112
+ // reads all three names out of ONE `REGISTERS_GET` reply unconditionally.
113
+ // That is a different read, not a re-derived seam.
114
+ import {
115
+ CommandType,
116
+ CheckpointOperation,
117
+ checkpointSetBody,
118
+ cpNumBody,
119
+ memspaceBody,
120
+ resetBody,
121
+ ResetMode,
122
+ ErrorCode,
123
+ StockProtocolError,
124
+ type ParsedCheckpointInfoResponse,
125
+ type ResolvedResponse,
126
+ type ViceMonitorClient,
127
+ } from "./stock-protocol.ts";
128
+ import { registerCatalogFor } from "./stock-registers.ts";
129
+ import { stockAnswer, isErrorText, convertWireError, type StockToolResult } from "./stock-handler.ts";
130
+ import { runStateFor } from "./stock-runstate.ts";
131
+ import { compareStopIdentity, ORACLE_TERMS, StopOracleError, type StopIdentity } from "./stop-oracle.ts";
132
+ import type { StockConnectSession } from "./stock-connect.ts";
133
+ import { MachineRestartedError } from "./vice-errors.ts";
134
+
135
+ /** The argument name(s) `reproducible: true` cannot run without.
136
+ *
137
+ * `D-14`: the once-per-frame site is RELEASE-SPECIFIC. A cracked release almost
138
+ * always takes over the IRQ, so no KERNAL default (`$EA31` included) is safe to
139
+ * guess -- `33-03` used `$EA31` only because it MEASURED that this particular
140
+ * release leaves it alone, and recorded that as a fact about the release rather
141
+ * than promoting it to a default.
142
+ *
143
+ * Exported so `stock-run-until.ts`'s refusal reads the required name from ONE
144
+ * definition instead of repeating a literal in a second place. */
145
+ export const REPRODUCIBLE_RUN_REQUIRED_SIBLINGS = ["frame_anchor"] as const;
146
+
147
+ /** The register names this procedure must resolve BY NAME before it arms
148
+ * anything. `PC` is the target's stop address; `LIN`/`CYC` are the two halves
149
+ * of the frame term. All three come out of ONE `REGISTERS_GET` reply. */
150
+ const REQUIRED_REGISTER_NAMES = ["PC", "LIN", "CYC"] as const;
151
+
152
+ /** `CHECKPOINT_INFO`'s `hit_count` field offset within the response BODY.
153
+ *
154
+ * THE ONE NUMBER A READER WILL WANT TO SKIP AND MUST NOT: `hit_count` sits at
155
+ * body offset **13** as u32LE. Reading offset 12 instead yields **256** where
156
+ * the truth is **1** -- because offset 12 is the `temporary` flag byte, so a
157
+ * non-temporary checkpoint on its first hit gives `0x00 0x01 0x00 0x00 0x00`
158
+ * and a u32LE read one byte early sees `1 * 256`. That is the worst kind of
159
+ * wrong number: it looks plausible. A frame term of 256 reported as a frame
160
+ * term of 1 would certify two entirely different stops as the same stop.
161
+ *
162
+ * This module does NOT read that offset itself -- `stock-protocol.ts`'s
163
+ * `parseResponse()` CHECKPOINT_INFO branch (`stock-protocol.ts:1370`,
164
+ * `hitCount: body.readUInt32LE(13)`) is the ONE place in this tree that turns
165
+ * those bytes into a number, and duplicating the read here would be a second
166
+ * parse of the same field, which is exactly the drift the single-seam rule
167
+ * exists to prevent (`T-33-32`). The constant is declared and exported so the
168
+ * offset has ONE named definition that `stock-reproducible-run.test.ts` pins
169
+ * end to end: it builds a RAW body whose offset 13 gives 1 and whose offset 12
170
+ * gives 256, runs it through the real `parseResponse()`, and asserts both the
171
+ * parsed value and this procedure's reported frame term are 1. A wrong offset
172
+ * therefore reds with a DISTINGUISHABLE number rather than a plausible one. */
173
+ export const CHECKPOINT_INFO_HIT_COUNT_BODY_OFFSET = 13;
174
+
175
+ /** What `runReproducible()` did, for the caller that wants the record rather
176
+ * than the JSON answer. Returned alongside the answer so `handleRunUntil`
177
+ * stays a one-line branch and tests can assert on structure. */
178
+ export interface ReproducibleRunOutcome {
179
+ /** The four-term stop identity, present ONLY on a certified stop. */
180
+ identity?: StopIdentity;
181
+ /** How the wait settled. */
182
+ settled: "hit" | "timeout" | "refused";
183
+ /** The target checkpoint's own hit count -- NOT the frame term. */
184
+ targetHitCount?: number;
185
+ }
186
+
187
+ interface RunReproducibleOptions {
188
+ /** The stop target. Becomes the `pc` term of the stop identity. */
189
+ address: number;
190
+ /** The once-per-frame site whose hit count IS the frame term. */
191
+ frameAnchor: number;
192
+ /** The wait's deadline. A bound, never an anchor. */
193
+ timeoutMs: number;
194
+ /** Passed straight through onto the answer, so the caller's clamp decision
195
+ * is reported by the same handler that made it. */
196
+ timeoutClamped?: boolean;
197
+ }
198
+
199
+ /** True iff `value` is a well-formed, generic JSON object. Matches this module
200
+ * tree's own `isPlainObject()` convention (`vice.ts:310-316`); redeclared
201
+ * privately here, not imported, per the established per-module convention. */
202
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
203
+ return typeof value === "object" && value !== null && !Array.isArray(value);
204
+ }
205
+
206
+ /** Narrows an emitted `event` item to a CHECKPOINT_INFO event -- checked on the
207
+ * PARSED item's own `.type` discriminant, never on response type alone.
208
+ *
209
+ * This matters more here than anywhere else in the tree: FIVE unsolicited
210
+ * message types arrive at request id `0xffffffff` -- `STOPPED` (0x62),
211
+ * `RESUMED` (0x63), `JAM` (0x61), plus `CHECKPOINT_INFO` (0x11) on every
212
+ * checkpoint hit and `REGISTER_INFO` (0x31) on every monitor open -- and the
213
+ * last two SHARE a response type with a legitimate command reply. The demux
214
+ * keys on request id and never resolves a pending request with an event; this
215
+ * predicate is the second half of that discipline, on the listening side. */
216
+ function isCheckpointInfoEvent(item: unknown): item is ParsedCheckpointInfoResponse {
217
+ return isPlainObject(item) && item.type === "checkpoint_info" && isPlainObject(item.checkpoint);
218
+ }
219
+
220
+ type ReproducibleWaitOutcome =
221
+ | { status: "hit"; targetHitCount: number; anchorHitsObserved: number; anchorHitCountObserved?: number }
222
+ | { status: "timeout"; anchorHitsObserved: number; anchorHitCountObserved?: number };
223
+
224
+ /**
225
+ * Installs ONE `event` listener, sends the resume EXACTLY ONCE, and races that
226
+ * against a single timeout deadline and the client's own `close` signal. The
227
+ * listener goes on BEFORE the resume is sent, so a checkpoint firing in the gap
228
+ * between "sent" and "listening" cannot be missed.
229
+ *
230
+ * DISCRIMINATION IS THE POINT (`T-33-31`): status "hit" is reported ONLY on the
231
+ * TARGET's own checkpoint id. An anchor `CHECKPOINT_INFO` never produces a
232
+ * "hit" -- it is counted, so the answer can say the anchor stopped the machine.
233
+ * A wait resolved by the wrong frame would report a confident stop that never
234
+ * happened.
235
+ *
236
+ * `anchorSharesTargetAddress` says whether the two checkpoints were armed at
237
+ * the SAME address, which decides what an anchor frame MEANS:
238
+ * * different addresses -- the stop:true anchor has halted the machine and
239
+ * only one resume is ever sent, so the target can never fire. The anchor
240
+ * frame is TERMINAL, and settles the wait as a timeout at once rather than
241
+ * burning a deadline whose outcome is already known (33 review WR-01).
242
+ * * the same address -- one stop emits BOTH checkpoints' frames, so the
243
+ * target's arrives from that same stop and the wait must CONTINUE
244
+ * whichever order the monitor emitted them in.
245
+ * Either way, "hit" resolves on the target's id alone.
246
+ *
247
+ * Removes every listener and clears the timer in a `finally` on EVERY path, so
248
+ * a long session never accumulates listeners.
249
+ *
250
+ * Any rejection from the resume send itself (a `MachineRestartedError`, or any
251
+ * other error) propagates OUT of this function uncaught -- see the caller's own
252
+ * cleanup path 3 of 3 for which of those cases still has an armed anchor to
253
+ * delete and which does not.
254
+ */
255
+ async function waitForReproducibleStop(
256
+ client: ViceMonitorClient,
257
+ targetCheckpointId: number,
258
+ anchorCheckpointId: number,
259
+ timeoutMs: number,
260
+ anchorSharesTargetAddress: boolean,
261
+ ): Promise<ReproducibleWaitOutcome> {
262
+ let timer: ReturnType<typeof setTimeout> | undefined;
263
+ let onEvent: ((item: unknown) => void) | undefined;
264
+ let onClose: (() => void) | undefined;
265
+ let anchorHitsObserved = 0;
266
+ let anchorHitCountObserved: number | undefined;
267
+
268
+ try {
269
+ return await new Promise<ReproducibleWaitOutcome>((resolve, reject) => {
270
+ onEvent = (item: unknown) => {
271
+ if (!isCheckpointInfoEvent(item)) return;
272
+ if (item.checkpoint.id === anchorCheckpointId) {
273
+ // The anchor fired. Count it and record its own hit count -- this
274
+ // frame is not the stop being certified.
275
+ anchorHitsObserved += 1;
276
+ anchorHitCountObserved = item.checkpoint.hitCount;
277
+ // TERMINAL, BUT ONLY WHEN THE TWO SIT AT DIFFERENT ADDRESSES
278
+ // (33 review WR-01).
279
+ //
280
+ // When they differ: the anchor is armed stop:true, so the machine is
281
+ // now HALTED, and this procedure sends exactly ONE resume per wait by
282
+ // design. No further instruction will execute, so the target's frame
283
+ // can never arrive. The outcome is already decided AND already
284
+ // observed -- so settle now instead of waiting out a deadline whose
285
+ // result is known. That deadline runs up to
286
+ // RUN_UNTIL_MAX_TIMEOUT_MS (600 000 ms) while .mcp.json caps a
287
+ // request at 150 000 ms, so burning it meant the carefully written
288
+ // anchorStoppedFirstNote explaining the refusal never reached the
289
+ // caller on any realistic timeout setting -- the request died first.
290
+ //
291
+ // When they are EQUAL this early settle would be WRONG, which is why
292
+ // it is gated. `frameAnchor === address` is a documented legitimate
293
+ // configuration (see step 4: the two differ in their temporary flag
294
+ // and in what they mean, and are deliberately armed as two). At one
295
+ // address BOTH checkpoints match the same instruction, so a single
296
+ // stop emits TWO CHECKPOINT_INFO frames -- the target's arrives from
297
+ // that same stop, needing no further execution, and the order the
298
+ // monitor emits them in is not ours to depend on. Settling on the
299
+ // anchor there would turn the adjacent configuration's successful
300
+ // stop into a spurious refusal.
301
+ //
302
+ // The discrimination invariant (T-33-31) is untouched either way:
303
+ // status "hit" is still only ever reported on the TARGET's id.
304
+ if (!anchorSharesTargetAddress) {
305
+ resolve({ status: "timeout", anchorHitsObserved, anchorHitCountObserved });
306
+ }
307
+ return;
308
+ }
309
+ if (item.checkpoint.id !== targetCheckpointId) return;
310
+ resolve({
311
+ status: "hit",
312
+ targetHitCount: item.checkpoint.hitCount,
313
+ anchorHitsObserved,
314
+ anchorHitCountObserved,
315
+ });
316
+ };
317
+ onClose = () => {
318
+ resolve({ status: "timeout", anchorHitsObserved, anchorHitCountObserved });
319
+ };
320
+
321
+ client.on("event", onEvent);
322
+ client.on("close", onClose);
323
+ timer = setTimeout(() => resolve({ status: "timeout", anchorHitsObserved, anchorHitCountObserved }), timeoutMs);
324
+
325
+ // THE one resume for this wait. Not in a loop, not retried, not
326
+ // conditional -- see this module's WHAT NOT TO DO.
327
+ client.send(CommandType.Exit).catch(reject);
328
+ });
329
+ } finally {
330
+ if (onEvent) client.off("event", onEvent);
331
+ if (onClose) client.off("close", onClose);
332
+ if (timer !== undefined) clearTimeout(timer);
333
+ }
334
+ }
335
+
336
+ /** Arms one stopping exec checkpoint and returns its id, or the refusal.
337
+ *
338
+ * Both `stop: true` and the explicit `memspace: 0x00` are load-bearing:
339
+ * * `stop: true` -- a NON-stopping checkpoint emits `CHECKPOINT_INFO`
340
+ * SYNCHRONOUSLY, from inside the CPU loop, on every hit
341
+ * (`mon_breakpoint.c:557-562` calls `mon_breakpoint_event()` before it
342
+ * checks `cp->stop`). On a once-per-frame address that stalls the emulator
343
+ * thread (`T-33-10`).
344
+ * * `memspace: 0x00` -- routed through `checkpointSetBody`'s own wire-byte
345
+ * mapping (`0x00` main, `0x01`-`0x04` units 8-11, `0x08` REJECTED), never
346
+ * by writing `body[8] =` here (`T-33-17`). The wire byte is NOT the
347
+ * internal enum, and this module never hand-assembles a header or a body.
348
+ */
349
+ async function armCheckpoint(
350
+ session: StockConnectSession,
351
+ what: string,
352
+ address: number,
353
+ temporary: boolean,
354
+ ): Promise<{ ok: true; id: number } | { ok: false; refusal: StockToolResult }> {
355
+ const body = checkpointSetBody({
356
+ start: address,
357
+ end: address,
358
+ stop: true,
359
+ enabled: true,
360
+ operation: CheckpointOperation.Exec,
361
+ temporary,
362
+ memspace: 0x00,
363
+ });
364
+
365
+ let response: ResolvedResponse;
366
+ try {
367
+ response = await session.client.send(CommandType.CheckpointSet, body);
368
+ } catch (err) {
369
+ return { ok: false, refusal: convertWireError("vice_run_until", err) };
370
+ }
371
+ if (response.type !== "checkpoint_info") {
372
+ return {
373
+ ok: false,
374
+ refusal: isErrorText(`vice_run_until: unexpected reply type "${response.type}" from the ${what} CHECKPOINT_SET`),
375
+ };
376
+ }
377
+ return { ok: true, id: response.checkpoint.id };
378
+ }
379
+
380
+ type CleanupDisposition = "deleted" | "already_gone" | "delete_failed" | "not_owned";
381
+
382
+ /** Deletes one checkpoint exactly once. `ObjectMissing` is tolerated as BENIGN
383
+ * (the hit landed between the deadline firing and this delete); any other wire
384
+ * error is RECORDED on the answer, never thrown, so the caller still gets a
385
+ * bounded result. */
386
+ async function deleteCheckpoint(
387
+ session: StockConnectSession,
388
+ checkpointId: number,
389
+ ): Promise<{ disposition: CleanupDisposition; error?: string }> {
390
+ try {
391
+ await session.client.send(CommandType.CheckpointDelete, cpNumBody(checkpointId));
392
+ return { disposition: "deleted" };
393
+ } catch (err) {
394
+ if (err instanceof StockProtocolError && err.errorCode === ErrorCode.ObjectMissing) {
395
+ return { disposition: "already_gone" };
396
+ }
397
+ return { disposition: "delete_failed", error: convertWireError("vice_run_until", err).content[0]!.text };
398
+ }
399
+ }
400
+
401
+ /**
402
+ * THE reproducible-run protocol. One procedure, one call site
403
+ * (`stock-run-until.ts`), the hard reset inside it, no sub-flags.
404
+ *
405
+ * Ordered sequence, and nothing may be inserted between steps 4 and 6:
406
+ *
407
+ * 1. The session ALREADY halted the machine -- connecting opens the monitor,
408
+ * and `REGISTER_INFO` (0x31) then `STOPPED` (0x62) both arrive at request
409
+ * id `0xffffffff`. Neither is a command reply and neither is treated as
410
+ * one. On stock, ANY inbound byte halts the machine.
411
+ * 2. Resolve `PC`, `LIN` and `CYC` BY NAME through `registerCatalogFor()`
412
+ * (one `REGISTERS_AVAILABLE`, cached per session). Done FIRST, before
413
+ * anything is armed, so a build that enumerates none of the three is
414
+ * refused while the machine is still untouched -- refusing beats arming a
415
+ * run whose frame term could never be read.
416
+ * 3. Arm the FRAME ANCHOR, `temporary: false`. Non-temporary is deliberate:
417
+ * VICE auto-deletes a temporary checkpoint the instant it fires, and the
418
+ * anchor must SURVIVE its own hits to keep counting frames.
419
+ * 4. Arm the TARGET, `temporary: true` -- matching `stock-run-until.ts`'s
420
+ * existing divergence and its cited justification.
421
+ * 5. Issue the monitor-issued HARD RESET (`RESET` 0xcc, `ResetMode.Hard`).
422
+ * This is the load-bearing step. It is NOT the `RESOURCE_SET`
423
+ * power-cycle hazard the project's safety constraint names -- a distinct
424
+ * opcode, needing no deny-list (`resetBody`'s own comment says so
425
+ * normatively; do not "fix" it by adding one).
426
+ * 6. Issue EXACTLY ONE `EXIT` and wait EVENT-DRIVEN for the TARGET's own
427
+ * `CHECKPOINT_INFO`, keyed on that checkpoint's id.
428
+ * 7. Read `PC`, `LIN` and `CYC` from ONE `REGISTERS_GET` reply.
429
+ * 8. Read the ANCHOR's hit count -- `CHECKPOINT_GET` (0x11), parsed by the
430
+ * same one seam, `hit_count` at body offset
431
+ * `CHECKPOINT_INFO_HIT_COUNT_BODY_OFFSET`. THAT is the frame term. The
432
+ * target's own hit count is reported separately and is NOT the frame
433
+ * term; both are named on the answer so a reader never has to infer
434
+ * which is which.
435
+ *
436
+ * Every refusal below states its REASON. Nothing here degrades: there is no
437
+ * path that reports a stop identity with fewer than four terms, and
438
+ * `compareStopIdentity()` is what enforces that -- it REFUSES a partial record
439
+ * naming the term and the side rather than substituting a zero, a null or a
440
+ * sentinel.
441
+ */
442
+ export async function runReproducible(
443
+ session: StockConnectSession,
444
+ { address, frameAnchor, timeoutMs, timeoutClamped = false }: RunReproducibleOptions,
445
+ ): Promise<{ result: StockToolResult; outcome: ReproducibleRunOutcome }> {
446
+ const refuse = (text: string): { result: StockToolResult; outcome: ReproducibleRunOutcome } => ({
447
+ result: isErrorText(text),
448
+ outcome: { settled: "refused" },
449
+ });
450
+
451
+ // --- Step 2: resolve the three register names BEFORE arming anything ------
452
+ let catalog: Awaited<ReturnType<typeof registerCatalogFor>>;
453
+ try {
454
+ catalog = await registerCatalogFor(session);
455
+ } catch (err) {
456
+ return { result: convertWireError("vice_run_until", err), outcome: { settled: "refused" } };
457
+ }
458
+ const resolved = new Map<string, number>();
459
+ const missing: string[] = [];
460
+ for (const name of REQUIRED_REGISTER_NAMES) {
461
+ const entry = catalog.byName.get(name);
462
+ if (entry) resolved.set(name, entry.id);
463
+ else missing.push(name);
464
+ }
465
+ if (missing.length > 0) {
466
+ return refuse(
467
+ `vice_run_until: the reproducible protocol cannot run on this VICE build -- its REGISTERS_AVAILABLE enumeration ` +
468
+ `has no ${missing.join(" and ")} register, and ${missing.length === 1 ? "that name is" : "those names are"} required to read the ` +
469
+ `stop identity's four terms (PC, hit_count, (LIN, CYC)). Register ids are not stable across builds, so no id is ` +
470
+ `guessed here. Refusing before anything is armed beats running to a stop whose frame term could never be read and ` +
471
+ `reporting a two-term identity as if it were the oracle.`,
472
+ );
473
+ }
474
+
475
+ // --- Step 3: the frame anchor, non-temporary -----------------------------
476
+ const anchorArm = await armCheckpoint(session, "frame-anchor", frameAnchor, false);
477
+ if (!anchorArm.ok) return { result: anchorArm.refusal, outcome: { settled: "refused" } };
478
+ const anchorCheckpointId = anchorArm.id;
479
+
480
+ // --- Step 4: the target, temporary ---------------------------------------
481
+ // Armed as a SECOND, DISTINCT checkpoint even when `frameAnchor === address`:
482
+ // the two differ in their temporary flag and in what they mean, and the wait
483
+ // discriminates on their ids. Adjacency is a legitimate configuration -- the
484
+ // measured green case IS `address === frameAnchor === $ea31` -- not a reason
485
+ // to collapse them into one.
486
+ const targetArm = await armCheckpoint(session, "target", address, true);
487
+ if (!targetArm.ok) {
488
+ await deleteCheckpoint(session, anchorCheckpointId);
489
+ return { result: targetArm.refusal, outcome: { settled: "refused" } };
490
+ }
491
+ const targetCheckpointId = targetArm.id;
492
+
493
+ if (targetCheckpointId === anchorCheckpointId) {
494
+ // The monitor handed back the SAME id for two CHECKPOINT_SET calls. The
495
+ // wait discriminates on id, so this state makes the anchor's frames and
496
+ // the target's indistinguishable. Refuse rather than certify a stop that
497
+ // may have been resolved by the wrong frame.
498
+ await deleteCheckpoint(session, targetCheckpointId);
499
+ return refuse(
500
+ `vice_run_until: the binary monitor returned the same checkpoint id (${targetCheckpointId}) for both the frame anchor ` +
501
+ `and the target, so their CHECKPOINT_INFO frames cannot be told apart -- the wait discriminates on checkpoint id. ` +
502
+ `Refusing rather than resolving the wait on a frame that may belong to the anchor and reporting it as the target's stop.`,
503
+ );
504
+ }
505
+
506
+ // --- Step 5: the hard reset. The load-bearing step, inside the procedure --
507
+ try {
508
+ await session.client.send(CommandType.Reset, resetBody({ mode: ResetMode.Hard }));
509
+ } catch (err) {
510
+ await deleteCheckpoint(session, targetCheckpointId);
511
+ await deleteCheckpoint(session, anchorCheckpointId);
512
+ return { result: convertWireError("vice_run_until", err), outcome: { settled: "refused" } };
513
+ }
514
+
515
+ // --- Step 6: exactly one resume, then the event-driven wait ---------------
516
+ //
517
+ // --- Cleanup path 3 of 3: a FAILING resume/wait --------------------------
518
+ //
519
+ // The error always propagates out uncaught -- the standard wording is
520
+ // produced by the ONE existing convertHandshakeError()/convertWireError()
521
+ // seam, never a second converter written here. What is CONDITIONAL is
522
+ // whether there is anything left to clean up.
523
+ //
524
+ // A MachineRestartedError has already taken the instance and every
525
+ // checkpoint on it, so there is nothing to own and nothing to delete -- that
526
+ // was the original justification for taking no action at all.
527
+ //
528
+ // WHY THAT WAS TOO BROAD (33 review WR-02). `client.send(Exit)` can reject
529
+ // for reasons that are NOT a restart: a StockProtocolError carrying a
530
+ // non-zero error code from the EXIT reply, or the client's own per-request
531
+ // timeout -- with the socket and the instance still very much alive. On that
532
+ // path the frame anchor was left ARMED: temporary:false, stop:true, sitting
533
+ // at a once-per-frame address. Every subsequent resume on that session then
534
+ // halts within one frame, which is indistinguishable from a wedge to
535
+ // vice-wedge-triage and poisons the instance for every later tool call.
536
+ //
537
+ // So delete the anchor when the instance is still there. `connected` is the
538
+ // operative test (it is the observable that says whether a delete could even
539
+ // be answered); the error-class test keeps the restarted path exactly as it
540
+ // was. deleteCheckpoint() reports dispositions and never throws, so it
541
+ // cannot mask the error being rethrown.
542
+ let wait: ReproducibleWaitOutcome;
543
+ try {
544
+ wait = await waitForReproducibleStop(
545
+ session.client,
546
+ targetCheckpointId,
547
+ anchorCheckpointId,
548
+ timeoutMs,
549
+ frameAnchor === address,
550
+ );
551
+ } catch (err) {
552
+ if (!(err instanceof MachineRestartedError) && session.client.connected) {
553
+ await deleteCheckpoint(session, anchorCheckpointId);
554
+ }
555
+ throw err;
556
+ }
557
+
558
+ if (wait.status === "timeout") {
559
+ // --- Cleanup path 2 of 3: TIMEOUT. The only path that deletes the -------
560
+ // TARGET (temporary, so it was never auto-deleted -- it never fired). The
561
+ // anchor is non-temporary and is owned here too, so it is deleted as well.
562
+ const targetCleanup = await deleteCheckpoint(session, targetCheckpointId);
563
+ const anchorCleanup = await deleteCheckpoint(session, anchorCheckpointId);
564
+
565
+ // machineHalted is DERIVED, never a hand-passed literal: a state flag
566
+ // drifts from reality the moment a call site changes
567
+ // (stock-diagnose.ts:642-656, normative). "delete_failed" is reachable
568
+ // precisely when the socket is already gone, and claiming a halted machine
569
+ // over a dead connection while telling the caller to resume down it is
570
+ // self-contradictory in one JSON body.
571
+ const deleteWasAnswered = targetCleanup.disposition !== "delete_failed";
572
+ const machineHalted = deleteWasAnswered && session.client.connected ? true : runStateFor(session.client) === "stopped";
573
+
574
+ const anchorStoppedFirst = wait.anchorHitsObserved > 0;
575
+ const payload: Record<string, unknown> = {
576
+ requested: "run_until",
577
+ protocol: "reproducible",
578
+ reproducibleStop: false,
579
+ reproducibleStopNote:
580
+ "reproducibleStop is false: the target's own CHECKPOINT_INFO did not arrive within the deadline, so NO stop identity " +
581
+ "was established. No term of the four-term oracle (PC, hit_count, (LIN, CYC)) is reported on this answer -- a partial " +
582
+ "stop record would certify two stops as identical on the strength of whichever terms happened to be present.",
583
+ timedOut: true,
584
+ address,
585
+ frameAnchor,
586
+ targetCheckpointId,
587
+ anchorCheckpointId,
588
+ anchorHitsObserved: wait.anchorHitsObserved,
589
+ anchorStoppedFirst,
590
+ anchorStoppedFirstNote: anchorStoppedFirst
591
+ ? "the frame anchor fired BEFORE the target. The anchor is armed stop:true (a non-stopping checkpoint would emit " +
592
+ "CHECKPOINT_INFO synchronously from inside the CPU loop on every hit and stall the emulator thread), so the machine " +
593
+ "HALTED on that hit and no further instructions executed. This procedure sends exactly ONE resume per wait, by " +
594
+ "design, so the wait then bounded out rather than issuing a second EXIT. This is a refusal, not a defect: pick a " +
595
+ "frame_anchor the target is reached from within one resumed run, or drive the multi-resume anchor-counting sequence " +
596
+ "directly (see evidence/33-autostart-sequencing.md) rather than through this tool."
597
+ : "the frame anchor never fired within the deadline, so the anchor did not stop the machine -- the target address " +
598
+ "simply did not execute in time. Verify frame_anchor is a site this release actually executes: a cracked release " +
599
+ "almost always takes over the IRQ, so a KERNAL site such as $EA31 may never run at all.",
600
+ timeoutMs,
601
+ cleanup: targetCleanup.disposition,
602
+ anchorCleanup: anchorCleanup.disposition,
603
+ machineHalted,
604
+ machineHaltedNote: machineHalted
605
+ ? "the cleanup CHECKPOINT_DELETE sent after the timeout halted the emulated machine (on stock, any inbound byte does), " +
606
+ "and nothing here resumed it -- this is expected, not a wedge. Call vice_execution_run to resume."
607
+ : "the machine's run state could NOT be established: the cleanup CHECKPOINT_DELETE did not complete (see cleanupError) " +
608
+ "and/or the connection is gone, so nothing here can claim the machine is halted. Call vice_diagnose before acting.",
609
+ resumes: 1,
610
+ resumesNote:
611
+ "exactly one resume (EXIT) was sent for this wait, which is vice-sync.ts's own invariant in its stock-native " +
612
+ "event-driven form. A second resume is never sent, on any path.",
613
+ };
614
+ if (wait.anchorHitCountObserved !== undefined) payload.anchorHitCountObserved = wait.anchorHitCountObserved;
615
+ if (timeoutClamped) payload.timeoutClamped = true;
616
+ if (targetCleanup.error !== undefined) payload.cleanupError = targetCleanup.error;
617
+ if (anchorCleanup.error !== undefined) payload.anchorCleanupError = anchorCleanup.error;
618
+
619
+ return { result: stockAnswer(session.client, payload), outcome: { settled: "timeout" } };
620
+ }
621
+
622
+ // --- Cleanup path 1 of 3: HIT. VICE already deleted the TARGET itself -----
623
+ // (temporary; mon_breakpoint.c:605-607), so no delete is issued for it --
624
+ // that would target an object which no longer exists. The ANCHOR is
625
+ // non-temporary and IS owned here, so it is deleted below, after its hit
626
+ // count has been read.
627
+
628
+ // --- Step 7: the triple, from ONE REGISTERS_GET reply --------------------
629
+ let registers: ResolvedResponse;
630
+ try {
631
+ registers = await session.client.send(CommandType.RegistersGet, memspaceBody({ memspace: 0x00 }));
632
+ } catch (err) {
633
+ await deleteCheckpoint(session, anchorCheckpointId);
634
+ return { result: convertWireError("vice_run_until", err), outcome: { settled: "refused" } };
635
+ }
636
+ if (registers.type !== "registers") {
637
+ await deleteCheckpoint(session, anchorCheckpointId);
638
+ return refuse(`vice_run_until: expected a registers reply from REGISTERS_GET, got "${registers.type}"`);
639
+ }
640
+ const byId = new Map(registers.registers.map((reg) => [reg.id, reg.value] as const));
641
+ const readByName = (name: string): number | undefined => byId.get(resolved.get(name)!);
642
+ const pc = readByName("PC");
643
+ const line = readByName("LIN");
644
+ const cycle = readByName("CYC");
645
+ // The three `=== undefined` tests are written out rather than folded into
646
+ // `absent.length > 0` so the compiler NARROWS pc/line/cycle to `number` past
647
+ // this guard. That is not a formality: it is what makes it impossible to
648
+ // assemble a StopIdentity from an absent term, which is the same
649
+ // refuse-rather-than-substitute rule stop-oracle.ts enforces at runtime.
650
+ if (pc === undefined || line === undefined || cycle === undefined) {
651
+ const absent = [
652
+ pc === undefined ? "PC" : null,
653
+ line === undefined ? "LIN" : null,
654
+ cycle === undefined ? "CYC" : null,
655
+ ].filter((name): name is string => name !== null);
656
+ await deleteCheckpoint(session, anchorCheckpointId);
657
+ return refuse(
658
+ `vice_run_until: REGISTERS_GET's reply carried no value for ${absent.join(" and ")} despite the build's own ` +
659
+ `REGISTERS_AVAILABLE enumeration listing ${absent.length === 1 ? "it" : "them"}. Refusing rather than reporting a ` +
660
+ `stop identity with ${absent.length === 1 ? "a term" : "terms"} missing.`,
661
+ );
662
+ }
663
+
664
+ // --- Step 8: the frame term -- the ANCHOR's own hit count -----------------
665
+ let anchorInfo: ResolvedResponse;
666
+ try {
667
+ anchorInfo = await session.client.send(CommandType.CheckpointGet, cpNumBody(anchorCheckpointId));
668
+ } catch (err) {
669
+ await deleteCheckpoint(session, anchorCheckpointId);
670
+ return { result: convertWireError("vice_run_until", err), outcome: { settled: "refused" } };
671
+ }
672
+ if (anchorInfo.type !== "checkpoint_info") {
673
+ await deleteCheckpoint(session, anchorCheckpointId);
674
+ return refuse(
675
+ `vice_run_until: expected a checkpoint_info reply from the frame anchor's CHECKPOINT_GET, got "${anchorInfo.type}" -- ` +
676
+ `refusing rather than reporting a stop identity whose frame term could not be read.`,
677
+ );
678
+ }
679
+ const anchorHitCount = anchorInfo.checkpoint.hitCount;
680
+
681
+ // --- Step 8a: a frame term that counted no frames is NOT a term -----------
682
+ //
683
+ // WHY THIS REFUSAL EXISTS (33 review CR-02). The target can stop before the
684
+ // frame anchor has executed even once -- entirely reachable, and not a
685
+ // pathological case: the anchor is a release-specific once-per-frame site
686
+ // (this module refuses to GUESS one precisely because a cracked release may
687
+ // relocate it or never reach it), while the target may be a loader address
688
+ // hit during boot, before the anchor's frame ever comes round. In that state
689
+ // `anchorHitCount === 0` and `wait.anchorHitsObserved === 0`.
690
+ //
691
+ // Zero is a legal, finite integer, so NOTHING DOWNSTREAM CATCHES IT. The
692
+ // four-term self-check below passes (`requireTerms()` only rejects absent or
693
+ // non-finite terms, and comparing a record against itself is satisfied by
694
+ // any value), and the answer would go out with `reproducibleStop: true` and
695
+ // a complete-looking identity whose frame term carries no frame
696
+ // information. compareStopIdentity() would then report
697
+ // `identical: true, frameTermAsserted: true` for any two such stops HOWEVER
698
+ // MANY FRAMES APART -- which is exactly the confusion this module's own
699
+ // header says the frame term exists to prevent ("two stops one whole frame
700
+ // apart can carry identical (LIN, CYC); PC and hit_count are what
701
+ // distinguish them"). A vacuous frame term is worse than an absent one,
702
+ // because it is indistinguishable from an asserted one.
703
+ //
704
+ // So refuse, in the register the rest of this module uses. The timeout path
705
+ // already sets this precedent: it emits NO oracle term rather than
706
+ // zero-filling one (asserted at stock-reproducible-run.test.ts's
707
+ // no-zero-filled-terms case). This is the hit path's equivalent.
708
+ if (anchorHitCount === 0) {
709
+ await deleteCheckpoint(session, anchorCheckpointId);
710
+ return refuse(
711
+ `vice_run_until: the target stopped at ${anchorHex(address)} before the frame anchor at ` +
712
+ `${anchorHex(frameAnchor)} had executed even once, so the frame term is 0 and carries no frame information -- ` +
713
+ `two stops any number of frames apart would both report hit_count 0 and would certify as the same stop. ` +
714
+ `Refusing rather than reporting a four-term stop identity whose frame term is vacuous. Pick a frame_anchor ` +
715
+ `this release reaches BEFORE the target address.`,
716
+ );
717
+ }
718
+
719
+ const anchorCleanup = await deleteCheckpoint(session, anchorCheckpointId);
720
+
721
+ // --- The stop identity, and the oracle's own completeness check ----------
722
+ //
723
+ // `hitCount` is the ANCHOR's count (the frame term), and `pc` is the TARGET's
724
+ // stop address. compareStopIdentity() is called against the record itself:
725
+ // that is NOT a tautology, it is the oracle's own four-term VALIDATION --
726
+ // requireTerms() throws a StopOracleError naming the term and the side for
727
+ // any term that is absent or not a finite integer, which is precisely the
728
+ // "refuse rather than silently weaken" property this procedure must have.
729
+ // A three-term answer is unreachable from here by construction.
730
+ const identity: StopIdentity = { pc, hitCount: anchorHitCount, line, cycle };
731
+ try {
732
+ const selfCheck = compareStopIdentity(identity, identity);
733
+ if (!selfCheck.identical || !selfCheck.frameTermAsserted) {
734
+ return refuse(
735
+ `vice_run_until: the assembled stop identity failed its own four-term self-check ` +
736
+ `(differing: ${selfCheck.differingTerms.join(", ") || "none"}, frameTermAsserted: ${selfCheck.frameTermAsserted}) -- refusing.`,
737
+ );
738
+ }
739
+ } catch (err) {
740
+ if (err instanceof StopOracleError) {
741
+ return refuse(
742
+ `vice_run_until: the stop identity is incomplete -- term "${err.term}" could not be established (${err.message}). ` +
743
+ `Refusing rather than reporting a stop certified on fewer than the oracle's four terms.`,
744
+ );
745
+ }
746
+ throw err;
747
+ }
748
+
749
+ const payload: Record<string, unknown> = {
750
+ requested: "run_until",
751
+ protocol: "reproducible",
752
+ reproducibleStop: true,
753
+ reached: true,
754
+ address,
755
+ frameAnchor,
756
+ targetCheckpointId,
757
+ anchorCheckpointId,
758
+ targetHitCount: wait.targetHitCount,
759
+ anchorHitsObserved: wait.anchorHitsObserved,
760
+ hitCountNote:
761
+ `hitCount IS the FRAME TERM: the frame anchor's own hit count at ${anchorHex(frameAnchor)}, read from CHECKPOINT_GET. ` +
762
+ `targetHitCount is the TARGET checkpoint's separate count and is NOT a term of the stop identity -- both are named ` +
763
+ `here so neither has to be inferred from the other.`,
764
+ stopIdentityTerms: [...ORACLE_TERMS],
765
+ stopIdentityNote:
766
+ "the four fields named by stopIdentityTerms ARE the stop identity (PC, hit_count, (LIN, CYC)), and all four are " +
767
+ "asserted -- never two. (LIN, CYC) is a WITHIN-FRAME position, not a monotonic clock (stock's binary monitor has no " +
768
+ "monotonic cycle register below VICE 3.10), so two stops one whole frame apart can carry identical (LIN, CYC); the " +
769
+ "frame term is what tells them apart. This procedure REPORTS the identity it achieved and does NOT assert " +
770
+ "frame-exactness: frame-exactness was measured to hold through anchor hit 50 and to be LOST from hit 75, because a " +
771
+ "power cycle resets the CPU/VIC-II/CIAs but not the absolute emulated clock, and the 1541's rotational phase is a " +
772
+ "function of that clock. See evidence/33-autostart-sequencing.md.",
773
+ timeoutMs,
774
+ cleanup: "auto_deleted_by_vice",
775
+ cleanupNote:
776
+ "the target checkpoint was TEMPORARY, and VICE deletes a temporary checkpoint the instant it fires -- no " +
777
+ "CHECKPOINT_DELETE is issued for it on this path. The frame anchor is non-temporary and IS deleted here; see " +
778
+ "anchorCleanup.",
779
+ anchorCleanup: anchorCleanup.disposition,
780
+ machineHalted: true,
781
+ machineHaltedNote:
782
+ "the target checkpoint that just fired STOPPED the emulated machine (it was armed stop:true) and nothing here resumed " +
783
+ "it -- this is expected, not a wedge. Call vice_execution_run to resume. Emitted unconditionally, never only when true, " +
784
+ "so an absent field can never be read as \"not halted\".",
785
+ resumes: 1,
786
+ resumesNote:
787
+ "exactly one resume (EXIT) was sent for this wait -- vice-sync.ts's invariant in its stock-native event-driven form.",
788
+ };
789
+ // The four terms, emitted BY WALKING ORACLE_TERMS rather than as four
790
+ // hand-written keys. This is the assumption-delta `promote` decision made
791
+ // structural: the bare target address is ONE TERM of the stop identity (the
792
+ // `pc` term), not the whole identity alongside it. A future phase
793
+ // reintroducing an address-only certification has to DELETE a field the test
794
+ // reads, and shrinking ORACLE_TERMS shrinks this answer visibly.
795
+ for (const term of ORACLE_TERMS) {
796
+ payload[term] = identity[term];
797
+ }
798
+ if (timeoutClamped) payload.timeoutClamped = true;
799
+ if (anchorCleanup.error !== undefined) payload.anchorCleanupError = anchorCleanup.error;
800
+
801
+ return {
802
+ result: stockAnswer(session.client, payload),
803
+ outcome: { identity, settled: "hit", targetHitCount: wait.targetHitCount },
804
+ };
805
+ }
806
+
807
+ /** `$xxxx` for a message. Local, tiny, and deliberately not exported -- this is
808
+ * message formatting, not an address seam; `stock-address.ts` owns PARSING. */
809
+ function anchorHex(address: number): string {
810
+ return `$${address.toString(16).padStart(4, "0")}`;
811
+ }