@henols/vice-mcp 0.1.12 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/backend-detect.mts +58 -8
- package/capability-registry.ts +388 -0
- package/package.json +12 -1
- package/resources/backend-detect.mjs +30 -2
- package/resources/broker-launch.mjs +166 -34
- package/resources/vice-broker.mjs +25 -4
- package/stock-cia.ts +598 -0
- package/stock-connect.ts +137 -20
- package/stock-derived.ts +67 -14
- package/stock-diagnose.ts +994 -0
- package/stock-dispatch.ts +105 -4
- package/stock-handler.ts +29 -0
- package/stock-memory-search.ts +441 -0
- package/stock-memory.ts +92 -13
- package/stock-protocol.ts +328 -0
- package/stock-recycle.ts +500 -0
- package/stock-run-until.ts +400 -0
- package/stock-runstate.ts +46 -2
- package/stock-sprites.ts +712 -0
- package/stock-symbols.ts +431 -0
- package/stock-timing.ts +562 -0
- package/stock-vicii.ts +318 -0
- package/tools-manifest.stock.json +3031 -223
- package/version.ts +279 -0
- package/vice-proxy.ts +49 -6
package/stock-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` (
|
|
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
|
-
*
|
|
628
|
-
* D-09's whole point, grep-gated to zero occurrences of that
|
|
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
|
+
};
|