@henols/vice-mcp 0.1.11 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/stock-dispatch.ts CHANGED
@@ -27,6 +27,7 @@
27
27
  import { resolve, join } from "node:path";
28
28
 
29
29
  import type { ViceBackend } from "./backend-detect.mts";
30
+ import type { ToolInfo } from "./vice.ts";
30
31
  import { type HeldLease } from "./vice-broker-client.ts";
31
32
  import { stockConnect, stockDisconnect, stockReconnect, type StockConnectSession, type StockConnectDeps } from "./stock-connect.ts";
32
33
  import {
@@ -61,6 +62,15 @@ import { handleExecutionPause, handleExecutionRun, handleExecutionStep, handleEx
61
62
  import { handleMachineReset, handleAutostart, handleDiskAttach, handleSnapshotSave, handleSnapshotLoad } from "./stock-machine.ts";
62
63
  import { handleKeyboardType, handleKeyboardPetscii, handleJoystickSet } from "./stock-input.ts";
63
64
  import { handleDisassemble } from "./stock-disassemble.ts";
65
+ import { handleMemorySearch, handleMemoryCompare } from "./stock-memory-search.ts";
66
+ import { handleSymbolsLoad, handleSymbolsLookup } from "./stock-symbols.ts";
67
+ import { handleViciiGetState } from "./stock-vicii.ts";
68
+ import { handleCiaGetState } from "./stock-cia.ts";
69
+ import { handleSpriteGet, handleSpriteInspect } from "./stock-sprites.ts";
70
+ import { handleCyclesStopwatch, forgetTimingForOtherTargets } from "./stock-timing.ts";
71
+ import { handleRunUntil } from "./stock-run-until.ts";
72
+ import { handleDiagnoseStock } from "./stock-diagnose.ts";
73
+ import { handleRecycleStock } from "./stock-recycle.ts";
64
74
 
65
75
  // Re-exported so Phase 2's existing import surface (and its 921-line test
66
76
  // file) keeps working unchanged -- these four names used to be DEFINED
@@ -92,6 +102,53 @@ export function manifestPathForBackend(backend: ViceBackend, hereDir: string, en
92
102
  return backend === "stock" ? join(hereDir, "tools-manifest.stock.json") : join(hereDir, "tools-manifest.json");
93
103
  }
94
104
 
105
+ /**
106
+ * resolveAdvertisedToolDefinition() -- WR-07's fix. `tools` in vice-proxy.ts
107
+ * is a name-keyed record: whichever assignment to a given key runs LAST
108
+ * wins, and vice-proxy.ts's own registration order used to assign
109
+ * RECYCLE_TOOL/DIAGNOSE_TOOL's literal (fork-worded) definitions
110
+ * UNCONDITIONALLY, on both backends, straight after the backend-aware
111
+ * manifest loop had already populated the same keys correctly. So on the
112
+ * stock backend, `tools/list` served the fork's five-verdict vocabulary --
113
+ * including `stale_read_path`, which stock cannot produce (D-03) -- and
114
+ * omitted `monitor_held_elsewhere`, which stock can, even though the
115
+ * corrected stock manifest entry sat right there in
116
+ * tools-manifest.stock.json, unread. This function is the ONE place that
117
+ * decision is now made, so the manifest loop's own per-tool selection and
118
+ * the two synthetic tools' registration agree.
119
+ *
120
+ * Behaviour:
121
+ * - `backend === "fork"`: always returns `syntheticDef` unchanged, no
122
+ * matter what `manifestTools` contains. The fork's advertised surface
123
+ * is frozen at v0.1.x and this function must never be able to alter it.
124
+ * - `backend === "stock"`: returns the `manifestTools` entry whose `name`
125
+ * equals `syntheticDef.name`, if one exists. Falls back to
126
+ * `syntheticDef` when no match exists -- `readManifestTools()`'s own
127
+ * malformed/unreadable-manifest fallbacks answer `[]`, and in that case
128
+ * the proxy must still advertise a WORKING tool rather than none at all
129
+ * (T-07-16-02).
130
+ * - NEVER merges fields from the two definitions. Picking one whole
131
+ * definition keeps `description`, `inputSchema` and `outputSchema`
132
+ * internally consistent; a field-by-field merge could pair a fork
133
+ * description with a stock `outputSchema`, or the reverse.
134
+ *
135
+ * Declared as a `function`, not a `const` arrow, per this module tree's own
136
+ * standing rule: stock-dispatch.ts <-> stock-diagnose.ts <-> stock-recycle.ts
137
+ * form a runtime import cycle, and the phase already reproduced a live
138
+ * `ReferenceError` from a `const` handler export sitting in that cycle.
139
+ */
140
+ export function resolveAdvertisedToolDefinition(
141
+ syntheticDef: ToolInfo,
142
+ backend: ViceBackend,
143
+ manifestTools: ToolInfo[],
144
+ ): ToolInfo {
145
+ if (backend === "fork") {
146
+ return syntheticDef;
147
+ }
148
+ const manifestEntry = manifestTools.find((t) => t.name === syntheticDef.name);
149
+ return manifestEntry ?? syntheticDef;
150
+ }
151
+
95
152
  // ---------------------------------------------------------------------------
96
153
  // ensureStockSession() -- the lease-to-session seam (Task 2).
97
154
  // ---------------------------------------------------------------------------
@@ -332,6 +389,13 @@ export async function ensureStockSession(deps: StockDispatchDeps): Promise<Ensur
332
389
  // all. The reuse and reconnect branches return before this line, so a
333
390
  // reconnect to the SAME machine never evicts anything.
334
391
  forgetConditionsForOtherTargets(session.targetId);
392
+ // WR-14 (07-REVIEW.md): stock-timing.ts's two targetId-keyed caches (the
393
+ // video-standard cache and the stopwatch baseline store) are evicted from the
394
+ // SAME line, for the same reasons, so the registries can never drift apart on
395
+ // when they forget. Both are strong Maps deliberately -- they must survive a
396
+ // stockReconnect() to the same machine -- so without this they grow one entry
397
+ // per distinct instance for the life of the process.
398
+ forgetTimingForOtherTargets(session.targetId);
335
399
  return { ok: true, session };
336
400
  }
337
401
 
@@ -549,7 +613,7 @@ const handlePing: StockSessionHandler = async (_args, session, deps) => {
549
613
  * - `vice_snapshot_list` (D-16 -- deleted from both manifests)
550
614
  * - `vice_disk_detach` (D-13 -- Phase 7, via the text monitor)
551
615
  * - `vice_joystick_tap` (needs a resume plus Phase 7's timing route)
552
- * - `vice_disk_read_sector` (Phase 5)
616
+ * - `vice_disk_read_sector` (CUT from scope 2026-08-17 -- no skill calls it; see ROADMAP.md "Cut from scope (v0.2.0, 2026-08-17)" and docs/stock-vice-parity.md item 6)
553
617
  * - `vice_sid_get_state` and the low-level keyboard family (hard losses)
554
618
  * - `vice_machine_config_get` / `vice_machine_config_set` (Phase 6)
555
619
  * `dispatchStock()`'s miss branch already refuses any of these by name,
@@ -606,6 +670,43 @@ const STOCK_DISPATCH_TABLE: Record<string, StockHandler> = {
606
670
 
607
671
  // derived (DERIV-07, DISASM-01)
608
672
  vice_disassemble: withDerivedTool("vice_disassemble", { needsSession: true }, handleDisassemble),
673
+
674
+ // derived (DERIV-01)
675
+ vice_memory_search: withDerivedTool("vice_memory_search", { needsSession: true }, handleMemorySearch),
676
+ vice_memory_compare: withDerivedTool("vice_memory_compare", { needsSession: true }, handleMemoryCompare),
677
+
678
+ // derived (DERIV-04) -- needsSession:false: pure client-side state, never touches the wire
679
+ vice_symbols_load: withDerivedTool("vice_symbols_load", { needsSession: false }, handleSymbolsLoad),
680
+ vice_symbols_lookup: withDerivedTool("vice_symbols_lookup", { needsSession: false }, handleSymbolsLookup),
681
+
682
+ // derived (DERIV-05)
683
+ vice_vicii_get_state: withDerivedTool("vice_vicii_get_state", { needsSession: true }, handleViciiGetState),
684
+ vice_cia_get_state: withDerivedTool("vice_cia_get_state", { needsSession: true }, handleCiaGetState),
685
+
686
+ // derived (DERIV-06)
687
+ vice_sprite_get: withDerivedTool("vice_sprite_get", { needsSession: true }, handleSpriteGet),
688
+ vice_sprite_inspect: withDerivedTool("vice_sprite_inspect", { needsSession: true }, handleSpriteInspect),
689
+
690
+ // derived (TIME-01)
691
+ vice_cycles_stopwatch: withDerivedTool("vice_cycles_stopwatch", { needsSession: true }, handleCyclesStopwatch),
692
+
693
+ // derived (TIME-02)
694
+ vice_run_until: withDerivedTool("vice_run_until", { needsSession: true }, handleRunUntil),
695
+
696
+ // derived (TIME-04) -- the two proxy-local synthetic tools (RECYCLE_TOOL/
697
+ // DIAGNOSE_TOOL in vice-proxy.ts), backend-routed to dispatchStock() by
698
+ // buildBackendAwareTool() rather than served from the fork's HTTP
699
+ // transport. Deliberate asymmetry, documented at this call site (see also
700
+ // DerivedPureHandler's amended doc comment in stock-derived.ts):
701
+ // vice_diagnose uses needsSession:false because its own handler acquires
702
+ // the session itself (inside its own try/catch) so it can convert a
703
+ // thrown MonitorOwnershipError into the monitor_held_elsewhere VERDICT
704
+ // rather than let withDerivedTool()'s preamble turn it into refusal text
705
+ // -- the exact generic error string that verdict exists to replace.
706
+ // vice_recycle keeps needsSession:true: it needs a live session to gather
707
+ // evidence and has no verdict vocabulary of its own to preserve.
708
+ vice_diagnose: withDerivedTool("vice_diagnose", { needsSession: false }, handleDiagnoseStock),
709
+ vice_recycle: withDerivedTool("vice_recycle", { needsSession: true }, handleRecycleStock),
609
710
  };
610
711
 
611
712
  /** Looks up the table entry for `name` -- `undefined` on a miss, never a
@@ -624,9 +725,9 @@ export function stockHandlerFor(name: string): StockHandler | undefined {
624
725
  * naming the fork as the backend that does -- WITHOUT reading `deps` at all
625
726
  * (no lease is ever requested for a tool that does not exist on this
626
727
  * backend). There is no third branch, and in particular NO fall-through to
627
- * forwardToVice() anywhere in this file or anything it calls -- that is
628
- * D-09's whole point, grep-gated to zero occurrences of that name in this
629
- * file's own code lines.
728
+ * the fork's HTTP-forwarding path anywhere in this file or anything it calls
729
+ * -- that is D-09's whole point, grep-gated to zero occurrences of that
730
+ * function's name in this file's own code lines.
630
731
  */
631
732
  export async function dispatchStock(name: string, args: Record<string, unknown>, deps: StockDispatchDeps): Promise<StockToolResult> {
632
733
  const handler = stockHandlerFor(name);
package/stock-handler.ts CHANGED
@@ -21,6 +21,9 @@
21
21
  // - Never build a `{ content: [...], isError: false }` literal outside
22
22
  // stockAnswer() -- that is exactly how an answer ships without
23
23
  // `runState`, which D-06 requires on EVERY stock tool answer.
24
+ // - Never construct a session-free derived answer as a bare literal
25
+ // either -- a `needsSession: false` handler calls derivedAnswer(), a
26
+ // sessioned handler calls stockAnswer(), and there is no third shape.
24
27
  // - Never write a third error converter. convertHandshakeError() (moved
25
28
  // here, unchanged, from stock-dispatch.ts) is the ONE conversion for a
26
29
  // failed ensureStockSession()/stockConnect(); convertWireError() (new
@@ -173,3 +176,29 @@ export function stockAnswer(client: ViceMonitorClient, payload: Record<string, u
173
176
  const runState = runStateFor(client);
174
177
  return { content: [{ type: "text", text: JSON.stringify({ ...payload, runState }) }], isError: false };
175
178
  }
179
+
180
+ // ---------------------------------------------------------------------------
181
+ // derivedAnswer() -- new here (Phase 5, 05-02, D-05-06). The ONE place a
182
+ // SESSION-FREE (`withDerivedTool(..., { needsSession: false }, ...)`) derived
183
+ // tool's successful answer is constructed. `runState: "unknown"` is the
184
+ // honest value here, not a placeholder: a session-free handler never opens a
185
+ // monitor connection, so the emulator's run state was genuinely never
186
+ // observed -- "unknown" is already documented (docs/stock-vice-parity.md
187
+ // §A.7) as "the honest post-connect value and is not a failure". This
188
+ // function exists so the standing D-06 gate in stock-dispatch.test.ts
189
+ // ("every stock entry's outputSchema declares a required runState enum of
190
+ // [running, stopped, unknown]") needs no exemption list for the two DERIV-04
191
+ // symbol tools (`vice_symbols_load`/`vice_symbols_lookup`) -- currently the
192
+ // only `needsSession: false` tools in the milestone, and this function's
193
+ // only consumer (stock-symbols.ts).
194
+ //
195
+ // Unlike stockAnswer(), this function takes NO client argument at all --
196
+ // there is no session to read a run state from, which is the whole point.
197
+ // `runState` is stamped LAST, so a `runState` key already present in
198
+ // `payload` is overwritten -- matching stockAnswer()'s own "a handler may
199
+ // never supply its own" rule.
200
+ // ---------------------------------------------------------------------------
201
+
202
+ export function derivedAnswer(payload: Record<string, unknown>): StockOkResult {
203
+ return { content: [{ type: "text", text: JSON.stringify({ ...payload, runState: "unknown" }) }], isError: false };
204
+ }
@@ -0,0 +1,441 @@
1
+ #!/usr/bin/env node
2
+ // stock-memory-search.ts
3
+ //
4
+ // vice_memory_search / vice_memory_compare -- the DERIV-01 pair. Both are
5
+ // DERIVED tools: the binary monitor's confirmed command set
6
+ // (docs/phase0-binmon-findings.md §5) has no MEMORY_SEARCH or MEMORY_COMPARE
7
+ // opcode at all, so both answers are computed CLIENT-SIDE from one bounded
8
+ // MEM_GET read per range -- the same shape stock-disassemble.ts already
9
+ // uses. Registered through withDerivedTool("...", { needsSession: true },
10
+ // ...) in stock-dispatch.ts, never withStockSession() (D-01/D-03).
11
+ //
12
+ // WHAT NOT TO DO:
13
+ // - Never import hostpath.ts or vice-proxy.ts, and never call the
14
+ // fork-forwarding function's rewriteArguments() -- hostpath-consumers.test.ts
15
+ // gates this file's absence from the closed host-path consumer set
16
+ // (D-02). Neither tool takes a path argument at all.
17
+ // - Never issue an unrequested resume (Phase 3 D-05) -- these handlers
18
+ // send MEM_GET and nothing else. `runState` on the answer (via
19
+ // stockAnswer()) reports the halt honestly.
20
+ // - Never turn the MEM_GET body's side-effect flag on -- searching or
21
+ // comparing across $D000-$DFFF must never clear a pending VIC-II IRQ
22
+ // flag or otherwise mutate emulator state as a side effect of reading
23
+ // it. `sidefx` is hardcoded `false` at every call site below with no
24
+ // argument to override it.
25
+ // - Never build the answer outside stockAnswer() (D-06) -- that is
26
+ // exactly how an answer ships without `runState`.
27
+ // - Never re-derive address/byte-count parsing locally (D-04) --
28
+ // stock-address.ts's parseAddress()/parseByteCount() are the only seam.
29
+ // - Never implement `vice_memory_compare`'s `mode:'snapshot'` as a
30
+ // destructive snapshot restore or a `.vsf` parser (D-05-01) -- it is
31
+ // refused by name, before any MEM_GET is sent.
32
+ // - WR-06 (2026-08-17): never pass a LITERAL bank id to memGetBody() here
33
+ // again. All three MEM_GETs used to hardcode `bank: 0x0000`. Unlike the
34
+ // chip-state readers (which must refuse rather than default -- see
35
+ // stock-memory.ts's CR-01 note), the CPU view IS a defensible default for
36
+ // a general memory search; the defect was that it was INVISIBLE and
37
+ // UNCHANGEABLE. An agent could not search RAM under ROM or under I/O, and
38
+ // a search across $D000-$DFFF returned registers or the RAM underneath
39
+ // depending on the halted program's `$01` with the answer looking
40
+ // identical either way. Both tools now take the same optional `bank`
41
+ // argument vice_memory_read has (a stock-only OPTIONAL extra, which D-03
42
+ // permits and vice_memory_read's own `sideEffects` already precedents),
43
+ // resolve it through stock-memory.ts's ONE resolveBank() seam, and REPORT
44
+ // the view they actually read on the answer.
45
+ import { CommandType, memGetBody } from "./stock-protocol.ts";
46
+ import { parseAddress, parseByteCount } from "./stock-address.ts";
47
+ import { resolveBank } from "./stock-memory.ts";
48
+ import { convertWireError, isErrorText, stockAnswer, type StockSessionHandler, type StockErrorResult } from "./stock-handler.ts";
49
+
50
+ /** True iff `value` is a well-formed, generic JSON object -- not null, not
51
+ * an array. Matches this module tree's own isPlainObject() convention
52
+ * (stock-memory.ts, stock-disassemble.ts et al.) -- a small private copy,
53
+ * not a shared import. */
54
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
55
+ return typeof value === "object" && value !== null && !Array.isArray(value);
56
+ }
57
+
58
+ /** The fork's own documented `max_results`/`max_differences` bounds, quoted
59
+ * verbatim from its description: default 100, refused above 10000. */
60
+ const DEFAULT_MAX_RESULTS = 100;
61
+ const MAX_MAX_RESULTS = 10000;
62
+
63
+ /** A hard ceiling on client-side scan cost, independent of the
64
+ * pattern-longer-than-range-searched refusal below. */
65
+ const MAX_PATTERN_BYTES = 0x1000;
66
+
67
+ /**
68
+ * Validates `input` as a non-empty array of integers 0..255, mirroring
69
+ * vice_memory_write's own `data` array validation loop verbatim in
70
+ * structure (stock-memory.ts). Used for both `pattern` and `mask`. Returns
71
+ * the validated `number[]` on success, or a ready-to-return
72
+ * `StockErrorResult` on failure -- callers `return`-propagate the error
73
+ * branch directly.
74
+ */
75
+ function parseByteArray(toolName: string, what: string, input: unknown): number[] | StockErrorResult {
76
+ if (!Array.isArray(input)) {
77
+ return isErrorText(`${toolName}: ${what} must be a non-empty array of integers 0..255, got ${typeof input}`);
78
+ }
79
+ if (input.length === 0) {
80
+ return isErrorText(`${toolName}: ${what} must not be empty`);
81
+ }
82
+ const values: number[] = [];
83
+ for (let index = 0; index < input.length; index += 1) {
84
+ const value: unknown = input[index];
85
+ if (typeof value !== "number" || !Number.isInteger(value) || value < 0 || value > 0xff) {
86
+ return isErrorText(`${toolName}: ${what}[${index}] must be an integer 0..255, got ${JSON.stringify(value)}`);
87
+ }
88
+ values.push(value);
89
+ }
90
+ return values;
91
+ }
92
+
93
+ /** Narrows a `parseByteArray()` result to its error branch. */
94
+ function isByteArrayError(result: number[] | StockErrorResult): result is StockErrorResult {
95
+ return !Array.isArray(result);
96
+ }
97
+
98
+ /**
99
+ * WR-06: the plain-language description of the memory VIEW an answer's bytes
100
+ * were read through, reported on every answer so a caller can audit it
101
+ * instead of inferring it. Rendered from the SAME resolution the MEM_GET body
102
+ * carried -- never from the raw argument, which may have been omitted.
103
+ */
104
+ function bankViewFor(resolution: { id: number; name?: string }): string {
105
+ if (resolution.name !== undefined) {
106
+ return (
107
+ `read through the emulator's own "${resolution.name}" bank (wire id ${resolution.id}), as requested -- ` +
108
+ `not the CPU view, so $00/$01 banking does not affect these bytes`
109
+ );
110
+ }
111
+ return (
112
+ `read through wire bank ${resolution.id}, the CPU view (no bank argument was given) -- it follows $00/$01 ` +
113
+ `banking, so a range crossing $A000-$BFFF, $D000-$DFFF or $E000-$FFFF returns whatever the halted program ` +
114
+ `has banked in there right now. Pass bank:"ram" (or "io", "rom") to read a specific view instead; ` +
115
+ `vice_memory_banks lists the names this emulator reports.`
116
+ );
117
+ }
118
+
119
+ // ---------------------------------------------------------------------------
120
+ // vice_memory_search
121
+ // ---------------------------------------------------------------------------
122
+
123
+ export const handleMemorySearch: StockSessionHandler = async (args, session, _deps) => {
124
+ if (!isPlainObject(args)) {
125
+ return isErrorText("vice_memory_search: arguments must be an object");
126
+ }
127
+
128
+ // --------------------------------------------------------- start/end (both required)
129
+
130
+ let start: number, end: number;
131
+ try {
132
+ start = parseAddress(args.start, { what: "start" });
133
+ end = parseAddress(args.end, { what: "end" });
134
+ } catch (err) {
135
+ return isErrorText(`vice_memory_search: ${err instanceof Error ? err.message : String(err)}`);
136
+ }
137
+ if (end < start) {
138
+ return isErrorText(`vice_memory_search: end (0x${end.toString(16)}) must be >= start (0x${start.toString(16)})`);
139
+ }
140
+
141
+ // --------------------------------------------------------- pattern (required)
142
+
143
+ const patternResult = parseByteArray("vice_memory_search", "pattern", args.pattern);
144
+ if (isByteArrayError(patternResult)) {
145
+ return patternResult;
146
+ }
147
+ const pattern = patternResult;
148
+
149
+ if (pattern.length > MAX_PATTERN_BYTES) {
150
+ return isErrorText(`vice_memory_search: pattern is ${pattern.length} byte(s), which exceeds the maximum of ${MAX_PATTERN_BYTES}`);
151
+ }
152
+ const searched = end - start + 1;
153
+ if (pattern.length > searched) {
154
+ return isErrorText(
155
+ `vice_memory_search: pattern is ${pattern.length} byte(s), longer than the ${searched} byte(s) searched (0x${start.toString(16)}-0x${end.toString(16)}) -- it can never match`,
156
+ );
157
+ }
158
+
159
+ // --------------------------------------------------------- mask (optional)
160
+
161
+ let mask: number[] | undefined;
162
+ if (args.mask !== undefined) {
163
+ const maskResult = parseByteArray("vice_memory_search", "mask", args.mask);
164
+ if (isByteArrayError(maskResult)) {
165
+ return maskResult;
166
+ }
167
+ mask = maskResult;
168
+ if (mask.length !== pattern.length) {
169
+ return isErrorText(
170
+ `vice_memory_search: mask is ${mask.length} byte(s) but pattern is ${pattern.length} byte(s) -- mask is never padded or truncated to fit, the lengths must match exactly`,
171
+ );
172
+ }
173
+ }
174
+
175
+ // --------------------------------------------------------- max_results (optional, default 100, max 10000)
176
+
177
+ let maxResults = DEFAULT_MAX_RESULTS;
178
+ if (args.max_results !== undefined) {
179
+ try {
180
+ maxResults = parseByteCount(args.max_results, { max: MAX_MAX_RESULTS, what: "max_results" });
181
+ } catch (err) {
182
+ return isErrorText(`vice_memory_search: ${err instanceof Error ? err.message : String(err)}`);
183
+ }
184
+ }
185
+
186
+ // --------------------------------------------------------- bank (optional, WR-06: defaults to the CPU view, but visibly and changeably)
187
+
188
+ const bankResolution = await resolveBank("vice_memory_search", args.bank, session);
189
+ if (!bankResolution.ok) {
190
+ return bankResolution.result;
191
+ }
192
+
193
+ // --------------------------------------------------------- bounded memory read (Phase 3 D-05: halts, never resumes)
194
+
195
+ const body = memGetBody({ sidefx: false, start, end, memspace: 0x00, bank: bankResolution.id });
196
+
197
+ let response;
198
+ try {
199
+ response = await session.client.send(CommandType.MemoryGet, body);
200
+ } catch (err) {
201
+ return convertWireError("vice_memory_search", err);
202
+ }
203
+
204
+ if (response.type !== "memory_get") {
205
+ return isErrorText(
206
+ `vice_memory_search: the binary monitor replied with an unexpected response type ("${response.type}"), expected "memory_get"`,
207
+ );
208
+ }
209
+ if (response.bytes.length !== searched) {
210
+ return isErrorText(
211
+ `vice_memory_search: expected ${searched} byte(s), got ${response.bytes.length} -- a short read is a wrong answer, not a partial success`,
212
+ );
213
+ }
214
+
215
+ // --------------------------------------------------------- client-side scan (overlapping matches, bounded)
216
+
217
+ const bytes = response.bytes;
218
+ const matches: number[] = [];
219
+ let truncated = false;
220
+ for (let offset = 0; offset <= bytes.length - pattern.length; offset += 1) {
221
+ let isMatch = true;
222
+ for (let index = 0; index < pattern.length; index += 1) {
223
+ const actual = bytes[offset + index]!;
224
+ if (mask !== undefined) {
225
+ const maskByte = mask[index]!;
226
+ if ((actual & maskByte) !== (pattern[index]! & maskByte)) {
227
+ isMatch = false;
228
+ break;
229
+ }
230
+ } else if (actual !== pattern[index]) {
231
+ isMatch = false;
232
+ break;
233
+ }
234
+ }
235
+ if (isMatch) {
236
+ matches.push(start + offset);
237
+ if (matches.length === maxResults) {
238
+ truncated = true;
239
+ break;
240
+ }
241
+ }
242
+ }
243
+
244
+ const payload: Record<string, unknown> = {
245
+ start,
246
+ end,
247
+ searched,
248
+ pattern,
249
+ ...(mask !== undefined ? { mask } : {}),
250
+ maxResults,
251
+ matches,
252
+ count: matches.length,
253
+ truncated,
254
+ // WR-06: the same shape vice_memory_read reports -- {id,name} when a name
255
+ // was resolved, the bare wire id otherwise -- plus a plain-language
256
+ // `bankView` so the answer says which view produced these bytes.
257
+ bank: bankResolution.name !== undefined ? { id: bankResolution.id, name: bankResolution.name } : bankResolution.id,
258
+ bankView: bankViewFor(bankResolution),
259
+ };
260
+
261
+ return stockAnswer(session.client, payload);
262
+ };
263
+
264
+ // ---------------------------------------------------------------------------
265
+ // vice_memory_compare -- mode 'ranges' only. mode 'snapshot' is refused by
266
+ // name (D-05-01): there is no memory-only snapshot producer tool on either
267
+ // backend (vice_snapshot_save writes a whole-machine .vsf), so serving it
268
+ // would mean either destructively restoring the machine to read memory out
269
+ // of it, or parsing an unverified binary snapshot format. Neither is
270
+ // implemented; the refusal fires before any MEM_GET is sent.
271
+ // ---------------------------------------------------------------------------
272
+
273
+ export const handleMemoryCompare: StockSessionHandler = async (args, session, _deps) => {
274
+ if (!isPlainObject(args)) {
275
+ return isErrorText("vice_memory_compare: arguments must be an object");
276
+ }
277
+
278
+ // --------------------------------------------------------- mode (required)
279
+
280
+ if (typeof args.mode !== "string") {
281
+ return isErrorText(`vice_memory_compare: mode must be a string, got ${typeof args.mode}`);
282
+ }
283
+
284
+ if (args.mode === "snapshot") {
285
+ return isErrorText(
286
+ "vice_memory_compare: mode:'snapshot' is not implemented on the stock backend -- there is no memory-only " +
287
+ "snapshot producer tool on either backend (vice_snapshot_save writes a whole-machine .vsf), so serving it " +
288
+ "would mean either destructively restoring the machine to read memory out of it, or parsing an unverified " +
289
+ "binary snapshot format. Use mode:'ranges' to compare two live ranges captured at different points in " +
290
+ "time, or use the c64-ram-capture skill's own full-image diff.",
291
+ );
292
+ }
293
+
294
+ if (args.mode !== "ranges") {
295
+ return isErrorText(`vice_memory_compare: mode must be "ranges" or "snapshot", got ${JSON.stringify(args.mode)}`);
296
+ }
297
+
298
+ // --------------------------------------------------------- range1_start/range1_end/range2_start (all required in mode:'ranges')
299
+
300
+ if (args.range1_start === undefined) {
301
+ return isErrorText("vice_memory_compare: range1_start is required when mode is 'ranges'");
302
+ }
303
+ if (args.range1_end === undefined) {
304
+ return isErrorText("vice_memory_compare: range1_end is required when mode is 'ranges'");
305
+ }
306
+ if (args.range2_start === undefined) {
307
+ return isErrorText("vice_memory_compare: range2_start is required when mode is 'ranges'");
308
+ }
309
+
310
+ let range1Start: number, range1End: number, range2Start: number;
311
+ try {
312
+ range1Start = parseAddress(args.range1_start, { what: "range1_start" });
313
+ range1End = parseAddress(args.range1_end, { what: "range1_end" });
314
+ range2Start = parseAddress(args.range2_start, { what: "range2_start" });
315
+ } catch (err) {
316
+ return isErrorText(`vice_memory_compare: ${err instanceof Error ? err.message : String(err)}`);
317
+ }
318
+
319
+ if (range1End < range1Start) {
320
+ return isErrorText(
321
+ `vice_memory_compare: range1_end (0x${range1End.toString(16)}) must be >= range1_start (0x${range1Start.toString(16)})`,
322
+ );
323
+ }
324
+
325
+ // --------------------------------------------------------- range2_end is DERIVED from range1's length -- never accepted as an argument
326
+
327
+ const length = range1End - range1Start + 1;
328
+ const range2End = range2Start + length - 1;
329
+ if (range2End > 0xffff) {
330
+ return isErrorText(
331
+ `vice_memory_compare: range2_start (0x${range2Start.toString(16)}) + range1's length (${length}) would put range2_end at ` +
332
+ `0x${range2End.toString(16)}, which exceeds the 16-bit address space -- range 2 takes range 1's length, there is no range2_end argument`,
333
+ );
334
+ }
335
+
336
+ // --------------------------------------------------------- max_differences (optional, default 100, max 10000)
337
+
338
+ let maxDifferences = DEFAULT_MAX_RESULTS;
339
+ if (args.max_differences !== undefined) {
340
+ try {
341
+ maxDifferences = parseByteCount(args.max_differences, { max: MAX_MAX_RESULTS, what: "max_differences" });
342
+ } catch (err) {
343
+ return isErrorText(`vice_memory_compare: ${err instanceof Error ? err.message : String(err)}`);
344
+ }
345
+ }
346
+
347
+ // snapshot_name, start and end are declared on the manifest for D-03 input
348
+ // compatibility only (see plan_decision_D-05-01) -- they belong solely to
349
+ // the refused mode:'snapshot' path and are deliberately ignored here.
350
+
351
+ // --------------------------------------------------------- bank (optional, WR-06)
352
+ //
353
+ // ONE bank for BOTH ranges: the tool compares two ranges in one halted
354
+ // machine, so comparing them through two different views would be comparing
355
+ // two different questions. A caller who wants that reads twice.
356
+
357
+ const bankResolution = await resolveBank("vice_memory_compare", args.bank, session);
358
+ if (!bankResolution.ok) {
359
+ return bankResolution.result;
360
+ }
361
+
362
+ // --------------------------------------------------------- two sequential MEM_GET reads, both sidefx:false
363
+ //
364
+ // The machine is already halted by the first read (Phase 3 D-05), so the
365
+ // two reads are consistent with each other for a stopped machine; neither
366
+ // issues a resume between them.
367
+
368
+ const body1 = memGetBody({ sidefx: false, start: range1Start, end: range1End, memspace: 0x00, bank: bankResolution.id });
369
+ let response1;
370
+ try {
371
+ response1 = await session.client.send(CommandType.MemoryGet, body1);
372
+ } catch (err) {
373
+ return convertWireError("vice_memory_compare", err);
374
+ }
375
+ if (response1.type !== "memory_get") {
376
+ return isErrorText(
377
+ `vice_memory_compare: the binary monitor replied with an unexpected response type ("${response1.type}") for range 1, expected "memory_get"`,
378
+ );
379
+ }
380
+ if (response1.bytes.length !== length) {
381
+ return isErrorText(
382
+ `vice_memory_compare: expected ${length} byte(s) for range 1, got ${response1.bytes.length} -- a short read is a wrong answer, not a partial success`,
383
+ );
384
+ }
385
+
386
+ const body2 = memGetBody({ sidefx: false, start: range2Start, end: range2End, memspace: 0x00, bank: bankResolution.id });
387
+ let response2;
388
+ try {
389
+ response2 = await session.client.send(CommandType.MemoryGet, body2);
390
+ } catch (err) {
391
+ return convertWireError("vice_memory_compare", err);
392
+ }
393
+ if (response2.type !== "memory_get") {
394
+ return isErrorText(
395
+ `vice_memory_compare: the binary monitor replied with an unexpected response type ("${response2.type}") for range 2, expected "memory_get"`,
396
+ );
397
+ }
398
+ if (response2.bytes.length !== length) {
399
+ return isErrorText(
400
+ `vice_memory_compare: expected ${length} byte(s) for range 2, got ${response2.bytes.length} -- a short read is a wrong answer, not a partial success`,
401
+ );
402
+ }
403
+
404
+ // --------------------------------------------------------- diff, bounded at max_differences
405
+
406
+ const bytes1 = response1.bytes;
407
+ const bytes2 = response2.bytes;
408
+ const differences: { offset: number; address1: number; address2: number; value1: number; value2: number }[] = [];
409
+ let truncated = false;
410
+ for (let offset = 0; offset < length; offset += 1) {
411
+ const value1 = bytes1[offset]!;
412
+ const value2 = bytes2[offset]!;
413
+ if (value1 !== value2) {
414
+ differences.push({ offset, address1: range1Start + offset, address2: range2Start + offset, value1, value2 });
415
+ if (differences.length === maxDifferences) {
416
+ truncated = true;
417
+ break;
418
+ }
419
+ }
420
+ }
421
+
422
+ const payload: Record<string, unknown> = {
423
+ mode: "ranges",
424
+ range1Start,
425
+ range1End,
426
+ range2Start,
427
+ range2End,
428
+ length,
429
+ maxDifferences,
430
+ differences,
431
+ count: differences.length,
432
+ truncated,
433
+ identical: differences.length === 0 && !truncated,
434
+ // WR-06: one bank for both ranges, reported as vice_memory_read reports
435
+ // it, plus the plain-language view description.
436
+ bank: bankResolution.name !== undefined ? { id: bankResolution.id, name: bankResolution.name } : bankResolution.id,
437
+ bankView: bankViewFor(bankResolution),
438
+ };
439
+
440
+ return stockAnswer(session.client, payload);
441
+ };