@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.
- package/README.md +2 -2
- package/THIRD-PARTY-NOTICES.md +422 -1
- package/anno-bank.ts +171 -0
- package/anno-cli.ts +1736 -163
- package/anno-confidence.ts +2 -2
- package/anno-derive.ts +6 -6
- package/anno-details.ts +4 -4
- package/anno-enum-gen.ts +416 -30
- package/anno-export-asm.ts +1211 -126
- package/anno-graphics.ts +338 -0
- package/anno-hazard-report.ts +1367 -0
- package/anno-import.ts +495 -0
- package/anno-index.ts +8 -8
- package/anno-join.ts +480 -0
- package/anno-memmap-render.ts +22 -21
- package/anno-provenance-ledger.ts +472 -0
- package/anno-regbits-gen.ts +13 -13
- package/anno-register.ts +159 -0
- package/anno-store-export.ts +661 -0
- package/anno-store.ts +635 -124
- package/anno-symbols.ts +7 -7
- package/anno-tools.ts +1169 -16
- package/anno-types.ts +313 -40
- package/backend-detect.mts +124 -312
- package/build.ts +3 -1
- package/capture-predicate.ts +597 -0
- package/channel-lock.ts +349 -0
- package/evid-ingest.ts +217 -0
- package/evid-reconcile.ts +316 -0
- package/host-tool-client.ts +430 -0
- package/incident-record.ts +23 -12
- package/install-resources.ts +29 -13
- package/memmap-lookup.ts +285 -0
- package/package.json +27 -8
- package/prg-image.ts +1 -2
- package/repo-root.ts +87 -3
- package/resources/backend-detect.mjs +98 -236
- package/resources/broker-control.mjs +220 -54
- package/resources/broker-epoch.mjs +7 -8
- package/resources/broker-kill.mjs +36 -31
- package/resources/broker-launch.mjs +511 -374
- package/resources/broker-state.mjs +69 -24
- package/resources/container-guard.mjs +1 -1
- package/resources/ghidra-project.mjs +790 -0
- package/resources/host-tool.mjs +2533 -0
- package/resources/vice-broker.mjs +434 -290
- package/resources/vice-launcher.sh +127 -9
- package/stock-address.ts +1 -1
- package/stock-condition.ts +1 -1
- package/stock-connect.ts +9 -5
- package/stock-derived.ts +29 -37
- package/stock-diagnose.ts +200 -36
- package/stock-dispatch.ts +179 -77
- package/stock-handler.ts +1 -1
- package/stock-paths.ts +18 -14
- package/stock-petscii.ts +1 -1
- package/stock-protocol.ts +1 -1
- package/stock-recycle.ts +83 -2
- package/stock-reproducible-run.ts +811 -0
- package/stock-run-until.ts +100 -1
- package/stock-symbols.ts +4 -4
- package/stock-timing.ts +1 -1
- package/stop-oracle.ts +167 -0
- package/text-capability-probe.ts +660 -0
- package/text-connect.ts +157 -0
- package/text-protocol.ts +810 -0
- package/text-tools.ts +778 -0
- package/textmon-backtrace.ts +385 -0
- package/textmon-cpuhistory.ts +335 -0
- package/textmon-memmap.ts +494 -0
- package/textmon-profile.ts +458 -0
- package/textmon-registers.ts +748 -0
- package/tools-manifest.stock.json +864 -3
- package/vice-broker-client.ts +253 -108
- package/vice-errors.ts +268 -0
- package/vice-proxy.ts +339 -2144
- package/vsf-slice.ts +640 -0
- package/anno-d64.ts +0 -310
- package/capability-registry.ts +0 -390
- package/refresh-manifest.ts +0 -124
- package/tools-manifest.json +0 -1223
- package/vice-probe.ts +0 -278
- package/vice-sync.ts +0 -336
- 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
|
+
}
|