@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/backend-detect.mts +58 -8
- package/capability-registry.ts +388 -0
- package/disasm-decoder.ts +28 -4
- 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/backend-detect.mts
CHANGED
|
@@ -196,8 +196,33 @@ export interface BackendCacheRecord {
|
|
|
196
196
|
* quad; only a live VICE_INFO reply over an established connection can. */
|
|
197
197
|
versionQuad?: string;
|
|
198
198
|
cpuHistoryAvailable?: boolean;
|
|
199
|
+
/** CR-01 (07-REVIEW.md re-review): the CLIENT-side schema version that
|
|
200
|
+
* produced `cpuHistoryAvailable`. The version quad answers "is this the
|
|
201
|
+
* same VICE build?"; it cannot answer "was the code that decided this
|
|
202
|
+
* capability the code running now?". Without this field a capability
|
|
203
|
+
* answer derived by a buggy parser was un-invalidatable by any code
|
|
204
|
+
* change -- only a VICE upgrade could clear it. A record whose value
|
|
205
|
+
* differs from CAPABILITY_SCHEMA_VERSION (including ABSENT, which is
|
|
206
|
+
* every record written before this field existed) reads back as `stale`.
|
|
207
|
+
* Bump CAPABILITY_SCHEMA_VERSION in the same commit as any change to how
|
|
208
|
+
* a capability is probed or decoded. */
|
|
209
|
+
capabilitySchema?: number;
|
|
199
210
|
}
|
|
200
211
|
|
|
212
|
+
/** The client-side capability-decision schema version stamped into every
|
|
213
|
+
* capability record this module writes (see BackendCacheRecord's own field
|
|
214
|
+
* comment). BUMP THIS whenever the client's capability probing or the
|
|
215
|
+
* decoding it depends on changes, so records decided by the older code are
|
|
216
|
+
* re-probed instead of trusted.
|
|
217
|
+
*
|
|
218
|
+
* History:
|
|
219
|
+
* 1 -- plan 02-08's original probe (implicit; never written to disk).
|
|
220
|
+
* 2 -- CR-01 fix: decode failures are no longer persisted at all, and the
|
|
221
|
+
* CPUHISTORY_GET body layout was corrected (07-12). Every record
|
|
222
|
+
* written before this constant existed lacks the field and is
|
|
223
|
+
* therefore stale, which is exactly the intent. */
|
|
224
|
+
export const CAPABILITY_SCHEMA_VERSION = 2;
|
|
225
|
+
|
|
201
226
|
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
202
227
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
203
228
|
}
|
|
@@ -243,6 +268,7 @@ function readCacheRecord(supervisorDir: string): BackendCacheRecord | null {
|
|
|
243
268
|
};
|
|
244
269
|
if (typeof parsed.versionQuad === "string") record.versionQuad = parsed.versionQuad;
|
|
245
270
|
if (typeof parsed.cpuHistoryAvailable === "boolean") record.cpuHistoryAvailable = parsed.cpuHistoryAvailable;
|
|
271
|
+
if (typeof parsed.capabilitySchema === "number") record.capabilitySchema = parsed.capabilitySchema;
|
|
246
272
|
return record;
|
|
247
273
|
}
|
|
248
274
|
|
|
@@ -511,11 +537,23 @@ export function resolvedBackend(deps: ResolvedBackendDeps = {}): ResolvedBackend
|
|
|
511
537
|
export interface CapabilityRecordResult {
|
|
512
538
|
versionQuad?: string;
|
|
513
539
|
cpuHistoryAvailable?: boolean;
|
|
514
|
-
/**
|
|
515
|
-
*
|
|
516
|
-
*
|
|
517
|
-
|
|
518
|
-
|
|
540
|
+
/** The client-side schema version stamped on the record that was read
|
|
541
|
+
* (absent for any record written before CAPABILITY_SCHEMA_VERSION
|
|
542
|
+
* existed). Surfaced so a caller can log WHY a record was stale. */
|
|
543
|
+
capabilitySchema?: number;
|
|
544
|
+
/** True when the caller has just seen a DIFFERENT VICE build than
|
|
545
|
+
* whatever wrote this record (`observedVersionQuad` was given AND differs
|
|
546
|
+
* from the stored value -- the binary was swapped since the last
|
|
547
|
+
* capability determination), OR when the record's `capabilitySchema` is
|
|
548
|
+
* not the CAPABILITY_SCHEMA_VERSION this client decides capabilities
|
|
549
|
+
* with. Either way the stored answer cannot be trusted and must be
|
|
550
|
+
* re-determined rather than reused.
|
|
551
|
+
*
|
|
552
|
+
* CR-01 (07-REVIEW.md re-review) added the schema half: keying staleness
|
|
553
|
+
* on the version quad alone meant a capability answer decided by a buggy
|
|
554
|
+
* client parser survived every subsequent parser fix, and could only be
|
|
555
|
+
* cleared by upgrading VICE or hand-deleting a file under
|
|
556
|
+
* .vice-supervisor/ that nothing tells the user about. */
|
|
519
557
|
stale: boolean;
|
|
520
558
|
}
|
|
521
559
|
|
|
@@ -547,12 +585,20 @@ export function readCapabilityRecord(binPath: string, deps: CapabilityDeps = {})
|
|
|
547
585
|
if (!existing || existing.resolvedPath !== resolvedPath) return null;
|
|
548
586
|
if (existing.versionQuad === undefined && existing.cpuHistoryAvailable === undefined) return null;
|
|
549
587
|
|
|
550
|
-
const
|
|
588
|
+
const versionMismatch =
|
|
551
589
|
deps.observedVersionQuad !== undefined &&
|
|
552
590
|
existing.versionQuad !== undefined &&
|
|
553
591
|
existing.versionQuad !== deps.observedVersionQuad;
|
|
554
|
-
|
|
555
|
-
|
|
592
|
+
// CR-01: a record decided by a different client-side capability schema is
|
|
593
|
+
// as untrustworthy as one decided against a different binary. Absent
|
|
594
|
+
// counts as a mismatch -- that is every record written before the field
|
|
595
|
+
// existed, i.e. every record a possibly-broken parser could have written.
|
|
596
|
+
const schemaMismatch = existing.capabilitySchema !== CAPABILITY_SCHEMA_VERSION;
|
|
597
|
+
const stale = versionMismatch || schemaMismatch;
|
|
598
|
+
|
|
599
|
+
const result: CapabilityRecordResult = { versionQuad: existing.versionQuad, cpuHistoryAvailable: existing.cpuHistoryAvailable, stale };
|
|
600
|
+
if (existing.capabilitySchema !== undefined) result.capabilitySchema = existing.capabilitySchema;
|
|
601
|
+
return result;
|
|
556
602
|
}
|
|
557
603
|
|
|
558
604
|
/** Attaches `{ versionQuad, cpuHistoryAvailable }` to the EXISTING backend
|
|
@@ -591,5 +637,9 @@ export function writeCapabilityRecord(
|
|
|
591
637
|
probedAt: existing.probedAt,
|
|
592
638
|
versionQuad: capability.versionQuad,
|
|
593
639
|
cpuHistoryAvailable: capability.cpuHistoryAvailable,
|
|
640
|
+
// CR-01: stamp WHICH client decided this, so a later parser change
|
|
641
|
+
// invalidates it. Never accept this from the caller -- it describes this
|
|
642
|
+
// module's own code, not anything the caller observed.
|
|
643
|
+
capabilitySchema: CAPABILITY_SCHEMA_VERSION,
|
|
594
644
|
});
|
|
595
645
|
}
|
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
// capability-registry.ts
|
|
2
|
+
//
|
|
3
|
+
// WHY THIS FILE EXISTS (BACK-05): today, a tool name the ACTIVE backend does
|
|
4
|
+
// not advertise falls straight through vice-proxy.ts's CallToolRequestSchema
|
|
5
|
+
// override to its generic `Unknown tool: ${name}` fallback -- the exact same
|
|
6
|
+
// message a genuine typo gets. That is indistinguishable and unhelpful: a
|
|
7
|
+
// caller cannot tell "you misspelled this" from "this tool exists, but only
|
|
8
|
+
// on the other backend, for this specific reason." This module is the ONE
|
|
9
|
+
// authoritative place holding that per-backend capability data -- names,
|
|
10
|
+
// reason categories, reason text, and which backend actually provides each
|
|
11
|
+
// one -- so a future call site (the runtime refusal wired in plan 08-02, the
|
|
12
|
+
// generated support table in plan 08-03, the skill-text lint in plan 08-04)
|
|
13
|
+
// reads exactly one source rather than re-deriving or hand-copying it.
|
|
14
|
+
//
|
|
15
|
+
// WHAT NOT TO DO: do not hand-maintain a second copy of this data anywhere
|
|
16
|
+
// else in the repo (D-E; see CLAUDE.md's "re-deriving a cross-cutting seam
|
|
17
|
+
// locally" anti-pattern). If a consumer needs this data in a different
|
|
18
|
+
// shape, import CAPABILITY_REGISTRY and reshape it there -- never re-type
|
|
19
|
+
// the 26 entries or their reasons from memory.
|
|
20
|
+
//
|
|
21
|
+
// SECURITY POSTURE: this module is a READ-ONLY MESSAGE-TEXT LOOKUP and is
|
|
22
|
+
// NEVER an authorization boundary. DENY_LIST (vice.ts) remains the only
|
|
23
|
+
// refusal in this tree that is a security control; that check runs first, at
|
|
24
|
+
// every call site, and this module never overrides or duplicates it. Every
|
|
25
|
+
// string held here is already public: it names only information already
|
|
26
|
+
// published in docs/stock-vice-parity.md and this public repository -- no
|
|
27
|
+
// credential, no secret, no host, no port, no path, and no tool name that is
|
|
28
|
+
// not already present in one of the two shipped manifests
|
|
29
|
+
// (tools-manifest.json, tools-manifest.stock.json).
|
|
30
|
+
//
|
|
31
|
+
// This is the exact shape vice.ts's DENY_LIST / denyListRefusalMessage()
|
|
32
|
+
// already established: one exported readonly array, one exported
|
|
33
|
+
// message-rendering function, keyed by hazard/reason shape rather than one
|
|
34
|
+
// wording reused for every entry -- because telling a caller the wrong
|
|
35
|
+
// reason shape for what is otherwise the same permanent refusal invites a
|
|
36
|
+
// pointless retry (that function's own doc comment makes the identical
|
|
37
|
+
// point).
|
|
38
|
+
//
|
|
39
|
+
// EXCLUDED, DELIBERATELY (see docs/stock-vice-parity.md and
|
|
40
|
+
// 08-RESEARCH.md's "Capability Delta Registry"):
|
|
41
|
+
// - "vice_diagnose" and "vice_recycle" are NOT capability gaps: they are
|
|
42
|
+
// synthetic, proxy-local tools registered on BOTH backends by
|
|
43
|
+
// vice-proxy.ts's buildBackendAwareTool()/resolveAdvertisedToolDefinition()
|
|
44
|
+
// synthetic-registration call sites, never listed in either raw manifest
|
|
45
|
+
// JSON file. A naive set-difference over the two manifests misclassifies
|
|
46
|
+
// them as a divergence; they are not one, and including them here would
|
|
47
|
+
// be a factual error, not merely an omission.
|
|
48
|
+
// - "initialize", "notifications_initialized", "tools_call", "tools_list"
|
|
49
|
+
// are already refused by vice.ts's DENY_LIST, which runs strictly BEFORE
|
|
50
|
+
// any capability-registry lookup and owns a different hazard shape
|
|
51
|
+
// entirely (confused-deputy bypass, not a missing capability). They must
|
|
52
|
+
// keep being owned there, not duplicated here.
|
|
53
|
+
//
|
|
54
|
+
// This module imports nothing at runtime -- the only import is a type-only
|
|
55
|
+
// import of ViceBackend, which is erased by Node's type-stripping -- so it
|
|
56
|
+
// has no transport, no filesystem, and no process dependency of its own.
|
|
57
|
+
import type { ViceBackend } from "./backend-detect.mts";
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Three reason categories, matching the distinctions the project's own docs
|
|
61
|
+
* already draw (docs/stock-vice-parity.md SS A/B):
|
|
62
|
+
* - "hardware": no 1:1 opcode can ever exist because of a hardware or
|
|
63
|
+
* firmware property (a write-only register, per-read recomputation, no
|
|
64
|
+
* monitor command for a physical line). Permanent; nothing to build.
|
|
65
|
+
* - "descoped": theoretically buildable client-side (an opcode or
|
|
66
|
+
* equivalent already exists) but cut from v0.2.0 scope because no
|
|
67
|
+
* shipped skill calls it.
|
|
68
|
+
* - "stock-only-gain": the reverse direction -- stock has a native opcode
|
|
69
|
+
* the fork's custom HTTP API never exposed an equivalent RPC for.
|
|
70
|
+
*/
|
|
71
|
+
export type CapabilityCategory = "hardware" | "descoped" | "stock-only-gain";
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* One row of capability data. `providedBy` names the backend that DOES have
|
|
75
|
+
* the capability (never the backend refusing it). `reason` is one sentence
|
|
76
|
+
* of user-facing prose ending in a full stop -- no planning identifier
|
|
77
|
+
* (BACK-05, SKILL-01, DERIV-*, SHOT-*, GAIN-*, "Phase N") belongs in this
|
|
78
|
+
* field; those are internal routing annotations, not something a caller
|
|
79
|
+
* calling a tool by name should ever see. `alternative`, when present, names
|
|
80
|
+
* a concrete route that exists on the OTHER backend today -- omit it rather
|
|
81
|
+
* than inventing one where none exists.
|
|
82
|
+
*/
|
|
83
|
+
export interface CapabilityEntry {
|
|
84
|
+
name: string;
|
|
85
|
+
category: CapabilityCategory;
|
|
86
|
+
providedBy: ViceBackend;
|
|
87
|
+
reason: string;
|
|
88
|
+
alternative?: string;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const KEYBOARD_ALTERNATIVE =
|
|
92
|
+
"vice_keyboard_type / vice_keyboard_petscii inject text through the KERNAL keyboard buffer, " +
|
|
93
|
+
"and vice_joystick_set covers most in-game input -- but a program polling $DC00/$DC01 directly " +
|
|
94
|
+
"will not see buffer injection.";
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The 26-entry capability delta: every tool one backend advertises that the
|
|
98
|
+
* other genuinely does not, after excluding registration artifacts
|
|
99
|
+
* (vice_diagnose/vice_recycle) and DENY_LIST's own four meta-tools -- see
|
|
100
|
+
* the header comment above and 08-RESEARCH.md's "Capability Delta Registry"
|
|
101
|
+
* for the full accounting this array is derived from.
|
|
102
|
+
*/
|
|
103
|
+
export const CAPABILITY_REGISTRY: readonly CapabilityEntry[] = [
|
|
104
|
+
// --- hardware (6), providedBy: fork -------------------------------------
|
|
105
|
+
{
|
|
106
|
+
name: "vice_sid_get_state",
|
|
107
|
+
category: "hardware",
|
|
108
|
+
providedBy: "fork",
|
|
109
|
+
reason:
|
|
110
|
+
"SID's $D400-$D418 registers are write-only in hardware, and the binary monitor exposes " +
|
|
111
|
+
"no SID read command.",
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
name: "vice_keyboard_matrix",
|
|
115
|
+
category: "hardware",
|
|
116
|
+
providedBy: "fork",
|
|
117
|
+
reason:
|
|
118
|
+
"The binary monitor's KEYBOARD_FEED (0x72) only injects PETSCII buffer text; the emulator " +
|
|
119
|
+
"recomputes CIA port B from its own keyboard array on every read, so there is no wire " +
|
|
120
|
+
"command that can drive the raw matrix.",
|
|
121
|
+
alternative: KEYBOARD_ALTERNATIVE,
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
name: "vice_keyboard_restore",
|
|
125
|
+
category: "hardware",
|
|
126
|
+
providedBy: "fork",
|
|
127
|
+
reason:
|
|
128
|
+
"RESTORE pulses the NMI line directly; it is not part of the keyboard matrix, and " +
|
|
129
|
+
"KEYBOARD_FEED has no way to produce it.",
|
|
130
|
+
alternative: KEYBOARD_ALTERNATIVE,
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
name: "vice_keyboard_chord",
|
|
134
|
+
category: "hardware",
|
|
135
|
+
providedBy: "fork",
|
|
136
|
+
reason:
|
|
137
|
+
"KEYBOARD_FEED injects a whole string at a time; it has no primitive for holding multiple " +
|
|
138
|
+
"keys down together for a span of frames.",
|
|
139
|
+
alternative: KEYBOARD_ALTERNATIVE,
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
name: "vice_keyboard_key_press",
|
|
143
|
+
category: "hardware",
|
|
144
|
+
providedBy: "fork",
|
|
145
|
+
reason:
|
|
146
|
+
"KEYBOARD_FEED has no hold/release primitive -- it injects a complete string, not an " +
|
|
147
|
+
"individual key-down event.",
|
|
148
|
+
alternative: KEYBOARD_ALTERNATIVE,
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
name: "vice_keyboard_key_release",
|
|
152
|
+
category: "hardware",
|
|
153
|
+
providedBy: "fork",
|
|
154
|
+
reason:
|
|
155
|
+
"KEYBOARD_FEED has no hold/release primitive -- it injects a complete string, not an " +
|
|
156
|
+
"individual key-up event.",
|
|
157
|
+
alternative: KEYBOARD_ALTERNATIVE,
|
|
158
|
+
},
|
|
159
|
+
|
|
160
|
+
// --- descoped (18), providedBy: fork -------------------------------------
|
|
161
|
+
{
|
|
162
|
+
name: "vice_disk_detach",
|
|
163
|
+
category: "descoped",
|
|
164
|
+
providedBy: "fork",
|
|
165
|
+
reason:
|
|
166
|
+
"No detach opcode exists on the stock binary monitor; attaching a different disk image " +
|
|
167
|
+
"covers the same workflow.",
|
|
168
|
+
},
|
|
169
|
+
{
|
|
170
|
+
name: "vice_disk_read_sector",
|
|
171
|
+
category: "descoped",
|
|
172
|
+
providedBy: "fork",
|
|
173
|
+
reason:
|
|
174
|
+
"Reading a sector would require parsing the .d64 file client-side rather than calling a " +
|
|
175
|
+
"live-drive opcode, and no shipped skill calls it.",
|
|
176
|
+
},
|
|
177
|
+
{
|
|
178
|
+
name: "vice_display_screenshot",
|
|
179
|
+
category: "descoped",
|
|
180
|
+
providedBy: "fork",
|
|
181
|
+
reason:
|
|
182
|
+
"The client-side PNG encoder for the INDEXED8 framebuffer DISPLAY_GET returns was descoped " +
|
|
183
|
+
"because no shipped skill calls it.",
|
|
184
|
+
},
|
|
185
|
+
{
|
|
186
|
+
name: "vice_display_get_dimensions",
|
|
187
|
+
category: "descoped",
|
|
188
|
+
providedBy: "fork",
|
|
189
|
+
reason: "Descoped alongside vice_display_screenshot -- no shipped skill calls it.",
|
|
190
|
+
},
|
|
191
|
+
{
|
|
192
|
+
name: "vice_backtrace",
|
|
193
|
+
category: "descoped",
|
|
194
|
+
providedBy: "fork",
|
|
195
|
+
reason: "No shipped skill calls it.",
|
|
196
|
+
},
|
|
197
|
+
{
|
|
198
|
+
name: "vice_checkpoint_group_add",
|
|
199
|
+
category: "descoped",
|
|
200
|
+
providedBy: "fork",
|
|
201
|
+
reason: "No shipped skill calls any checkpoint-group tool.",
|
|
202
|
+
},
|
|
203
|
+
{
|
|
204
|
+
name: "vice_checkpoint_group_create",
|
|
205
|
+
category: "descoped",
|
|
206
|
+
providedBy: "fork",
|
|
207
|
+
reason: "No shipped skill calls any checkpoint-group tool.",
|
|
208
|
+
},
|
|
209
|
+
{
|
|
210
|
+
name: "vice_checkpoint_group_list",
|
|
211
|
+
category: "descoped",
|
|
212
|
+
providedBy: "fork",
|
|
213
|
+
reason: "No shipped skill calls any checkpoint-group tool.",
|
|
214
|
+
},
|
|
215
|
+
{
|
|
216
|
+
name: "vice_checkpoint_group_toggle",
|
|
217
|
+
category: "descoped",
|
|
218
|
+
providedBy: "fork",
|
|
219
|
+
reason: "No shipped skill calls any checkpoint-group tool.",
|
|
220
|
+
},
|
|
221
|
+
{
|
|
222
|
+
name: "vice_checkpoint_set_ignore_count",
|
|
223
|
+
category: "descoped",
|
|
224
|
+
providedBy: "fork",
|
|
225
|
+
reason:
|
|
226
|
+
"No native wire ignore-count exists; the only implementation would require resuming the " +
|
|
227
|
+
"machine on every ignored hit, which the no-unrequested-resume policy forbids. " +
|
|
228
|
+
"CHECKPOINT_INFO's reply still reports an existing ignore count read-only.",
|
|
229
|
+
},
|
|
230
|
+
{
|
|
231
|
+
name: "vice_cia_set_state",
|
|
232
|
+
category: "descoped",
|
|
233
|
+
providedBy: "fork",
|
|
234
|
+
reason:
|
|
235
|
+
"The write half of a tool whose read half already ships on stock; no shipped skill calls " +
|
|
236
|
+
"the write half.",
|
|
237
|
+
},
|
|
238
|
+
{
|
|
239
|
+
name: "vice_vicii_set_state",
|
|
240
|
+
category: "descoped",
|
|
241
|
+
providedBy: "fork",
|
|
242
|
+
reason:
|
|
243
|
+
"The write half of a tool whose read half already ships on stock; no shipped skill calls " +
|
|
244
|
+
"the write half.",
|
|
245
|
+
},
|
|
246
|
+
{
|
|
247
|
+
name: "vice_sprite_set",
|
|
248
|
+
category: "descoped",
|
|
249
|
+
providedBy: "fork",
|
|
250
|
+
reason:
|
|
251
|
+
"The write half of a tool whose read half already ships on stock; no shipped skill calls " +
|
|
252
|
+
"the write half.",
|
|
253
|
+
},
|
|
254
|
+
{
|
|
255
|
+
name: "vice_memory_fill",
|
|
256
|
+
category: "descoped",
|
|
257
|
+
providedBy: "fork",
|
|
258
|
+
reason: "No shipped skill calls it.",
|
|
259
|
+
},
|
|
260
|
+
{
|
|
261
|
+
name: "vice_sid_set_state",
|
|
262
|
+
category: "descoped",
|
|
263
|
+
providedBy: "fork",
|
|
264
|
+
reason:
|
|
265
|
+
"SID writes work fine over MEM_SET at $D400-$D418 -- this is not a hardware loss, only " +
|
|
266
|
+
"reads are write-only in hardware. It is simply not implemented because no shipped skill " +
|
|
267
|
+
"calls it.",
|
|
268
|
+
},
|
|
269
|
+
{
|
|
270
|
+
name: "vice_machine_config_get",
|
|
271
|
+
category: "descoped",
|
|
272
|
+
providedBy: "fork",
|
|
273
|
+
reason:
|
|
274
|
+
"Full resource get/set access was descoped; the fork's tool is a hand-curated whitelist " +
|
|
275
|
+
"subset that never shipped on stock.",
|
|
276
|
+
},
|
|
277
|
+
{
|
|
278
|
+
name: "vice_machine_config_set",
|
|
279
|
+
category: "descoped",
|
|
280
|
+
providedBy: "fork",
|
|
281
|
+
reason:
|
|
282
|
+
"Full resource get/set access was descoped; the fork's tool is a hand-curated whitelist " +
|
|
283
|
+
"subset that never shipped on stock.",
|
|
284
|
+
},
|
|
285
|
+
{
|
|
286
|
+
name: "vice_joystick_tap",
|
|
287
|
+
category: "descoped",
|
|
288
|
+
providedBy: "fork",
|
|
289
|
+
reason:
|
|
290
|
+
"Requires running the machine for a measured hold-then-release interval, which depends on " +
|
|
291
|
+
"timing infrastructure; not yet built, and no shipped skill calls it.",
|
|
292
|
+
},
|
|
293
|
+
|
|
294
|
+
// --- stock-only-gain (2), providedBy: stock ------------------------------
|
|
295
|
+
{
|
|
296
|
+
name: "vice_execution_until_return",
|
|
297
|
+
category: "stock-only-gain",
|
|
298
|
+
providedBy: "stock",
|
|
299
|
+
reason: "The fork's custom HTTP API has no equivalent RPC; this is the native EXECUTE_UNTIL_RETURN opcode.",
|
|
300
|
+
},
|
|
301
|
+
{
|
|
302
|
+
name: "vice_registers_available",
|
|
303
|
+
category: "stock-only-gain",
|
|
304
|
+
providedBy: "stock",
|
|
305
|
+
reason: "The fork has no equivalent enumeration call; this is the native REGISTERS_AVAILABLE opcode.",
|
|
306
|
+
},
|
|
307
|
+
];
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Plain name-keyed lookup over CAPABILITY_REGISTRY. `name` is untrusted
|
|
311
|
+
* `request.params.name` from the wire: it is used ONLY as an equality
|
|
312
|
+
* comparison value below. Never interpolate it into a path, a command, or
|
|
313
|
+
* anything executed.
|
|
314
|
+
*/
|
|
315
|
+
export function capabilityEntryFor(name: string): CapabilityEntry | undefined {
|
|
316
|
+
return CAPABILITY_REGISTRY.find((entry) => entry.name === name);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Renders the BACK-05 refusal for `name` on `activeBackend`, or `undefined`
|
|
321
|
+
* when there is nothing to refuse -- mirroring denyListRefusalMessage()'s
|
|
322
|
+
* keyed-by-hazard-shape contract (vice.ts).
|
|
323
|
+
*
|
|
324
|
+
* Returns `undefined` when:
|
|
325
|
+
* - no registry entry exists for `name` (a genuinely unknown tool name --
|
|
326
|
+
* a typo -- must still fall through to the generic "Unknown tool"
|
|
327
|
+
* message at the call site, not this function's wording); or
|
|
328
|
+
* - `entry.providedBy === activeBackend` (defensive: the active backend
|
|
329
|
+
* already advertises this tool, so a miss here is a genuine
|
|
330
|
+
* unknown-tool case, not a capability gap, and must not be
|
|
331
|
+
* misclassified as one).
|
|
332
|
+
*
|
|
333
|
+
* Otherwise renders one of three wordings, selected by `entry.category`:
|
|
334
|
+
* - "hardware": names the tool, states it is unrecoverable on
|
|
335
|
+
* `activeBackend`, gives `entry.reason`, then names `entry.providedBy`
|
|
336
|
+
* with the actionable `Set VICE_BACKEND=...`, then `entry.alternative`
|
|
337
|
+
* when present. No "wait for a later phase" framing -- none is coming
|
|
338
|
+
* for a hardware loss, but a stock route may still exist and must be
|
|
339
|
+
* named here, since this is where the caller actually reads it.
|
|
340
|
+
* - "descoped": names the tool, states it is not implemented on
|
|
341
|
+
* `activeBackend`, gives `entry.reason`, then `entry.providedBy` and
|
|
342
|
+
* `Set VICE_BACKEND=...`, then `entry.alternative` when present. The
|
|
343
|
+
* literal token "unrecoverable" must NEVER appear in this wording: a
|
|
344
|
+
* reader told "not supported" for both a hardware loss and a merely
|
|
345
|
+
* unbuilt tool cannot tell which one is worth filing an issue about.
|
|
346
|
+
* - "stock-only-gain": names the tool, states it is not implemented on the
|
|
347
|
+
* fork backend specifically, gives `entry.reason`, then
|
|
348
|
+
* `Set VICE_BACKEND=stock`.
|
|
349
|
+
*/
|
|
350
|
+
export function capabilityRefusalMessage(
|
|
351
|
+
name: string,
|
|
352
|
+
activeBackend: ViceBackend,
|
|
353
|
+
): string | undefined {
|
|
354
|
+
const entry = capabilityEntryFor(name);
|
|
355
|
+
if (!entry) return undefined;
|
|
356
|
+
if (entry.providedBy === activeBackend) return undefined;
|
|
357
|
+
|
|
358
|
+
// `alternative` is rendered in EVERY branch that has one. It used to be
|
|
359
|
+
// read only inside the "descoped" branch, which made the field dead at
|
|
360
|
+
// runtime: all five entries that carry an alternative are category
|
|
361
|
+
// "hardware", and no "descoped" entry has one. The generated support
|
|
362
|
+
// table, the skill playbooks and README all printed the stock route while
|
|
363
|
+
// the runtime refusal -- the one surface BACK-05 exists for -- dropped it.
|
|
364
|
+
// Do not re-scope this back into a single branch.
|
|
365
|
+
const alt = entry.alternative ? ` ${entry.alternative}` : "";
|
|
366
|
+
|
|
367
|
+
if (entry.category === "hardware") {
|
|
368
|
+
return (
|
|
369
|
+
`${entry.name} is unrecoverable on the ${activeBackend} backend: ${entry.reason} ` +
|
|
370
|
+
`Use the ${entry.providedBy} backend instead (Set VICE_BACKEND=${entry.providedBy}).${alt}`
|
|
371
|
+
);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
if (entry.category === "descoped") {
|
|
375
|
+
return (
|
|
376
|
+
`${entry.name} is not implemented on the ${activeBackend} backend: ${entry.reason} ` +
|
|
377
|
+
`Use the ${entry.providedBy} backend instead (Set VICE_BACKEND=${entry.providedBy}).${alt}`
|
|
378
|
+
);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// category === "stock-only-gain": only reachable with activeBackend ===
|
|
382
|
+
// "fork" and entry.providedBy === "stock", since the same-backend guard
|
|
383
|
+
// above already excluded the activeBackend === "stock" case.
|
|
384
|
+
return (
|
|
385
|
+
`${entry.name} is not implemented on the fork backend: ${entry.reason} ` +
|
|
386
|
+
`Use the stock backend instead (Set VICE_BACKEND=stock).${alt}`
|
|
387
|
+
);
|
|
388
|
+
}
|
package/disasm-decoder.ts
CHANGED
|
@@ -5,7 +5,10 @@
|
|
|
5
5
|
// CPU-history decode (GAIN-01) import THIS file directly, never a tool
|
|
6
6
|
// module, so a protocol import here would force those consumers to pull in
|
|
7
7
|
// transport code they do not need. This module has no emulator, no
|
|
8
|
-
// protocol, no network -- its only input is a byte array.
|
|
8
|
+
// protocol, no network -- its only input is a byte array. Note: DERIV-02 and
|
|
9
|
+
// GAIN-01 were both cut from v0.2.0 scope on 2026-08-17 -- see the
|
|
10
|
+
// startAddress bound below, which is now defense-in-depth on a currently
|
|
11
|
+
// unreachable path rather than a guard against a live in-process caller.
|
|
9
12
|
//
|
|
10
13
|
// ---------------------------------------------------------------------------
|
|
11
14
|
// WHY THIS FILE EXISTS RATHER THAN LIVING INSIDE THE TOOL HANDLER
|
|
@@ -30,6 +33,13 @@
|
|
|
30
33
|
// - Never add recursion or an unbounded loop -- the bound is what makes an
|
|
31
34
|
// attacker-controlled memory image safe to decode (04-RESEARCH.md
|
|
32
35
|
// Security Domain, T-04-03-01).
|
|
36
|
+
// - Never fold the `<= 0xffff` upper bound into `isNonNegativeSafeInteger()`
|
|
37
|
+
// or apply it to `opts.count`/`opts.end` -- those two stay unbounded by
|
|
38
|
+
// design (04-REVIEW.md IN-03: the loop is always bounded by
|
|
39
|
+
// `bytes.length`, so an absurd value degrades to "no effective limit",
|
|
40
|
+
// never a crash or hang). Bounding them would be a behaviour change
|
|
41
|
+
// dressed up as a consistency fix. The upper bound belongs only in the
|
|
42
|
+
// separate `isValidStartAddress()` guard below.
|
|
33
43
|
|
|
34
44
|
import { OPCODES, type AddressingMode } from "./disasm-opcodes.ts";
|
|
35
45
|
|
|
@@ -94,6 +104,18 @@ function isNonNegativeSafeInteger(value: unknown): value is number {
|
|
|
94
104
|
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
|
|
95
105
|
}
|
|
96
106
|
|
|
107
|
+
/** True iff `value` is a valid `startAddress` -- a safe, non-negative integer
|
|
108
|
+
* that additionally fits in the C64's 16-bit address space (`<= 0xffff`).
|
|
109
|
+
* Deliberately separate from `isNonNegativeSafeInteger()` rather than an
|
|
110
|
+
* upper bound folded into it: `opts.count`/`opts.end` must stay unbounded
|
|
111
|
+
* (see the `WHAT NOT TO DO` block above and 04-REVIEW.md IN-03), so only
|
|
112
|
+
* `startAddress` gets this stricter narrowing. Mirrors `stock-address.ts`'s
|
|
113
|
+
* `inAddressRange()` without importing it -- D-05 keeps this module's only
|
|
114
|
+
* import to `./disasm-opcodes.ts`. */
|
|
115
|
+
function isValidStartAddress(value: unknown): value is number {
|
|
116
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 && value <= 0xffff;
|
|
117
|
+
}
|
|
118
|
+
|
|
97
119
|
/** Interprets `b` as an 8-bit two's-complement signed byte, per DISASM-04's
|
|
98
120
|
* branch resolution rule: `signed8(b) = b < 0x80 ? b : b - 0x100`. */
|
|
99
121
|
function signed8(b: number): number {
|
|
@@ -118,12 +140,14 @@ function buildNotes(flags: { truncated: boolean; pageWrap: boolean; illegal: boo
|
|
|
118
140
|
* `startAddress`. Bounded by construction (T-04-03-01): a single `while`
|
|
119
141
|
* loop over a byte cursor, every iteration consumes at least one byte, no
|
|
120
142
|
* recursion anywhere in this file. Never throws -- malformed input (a
|
|
121
|
-
* non-`Uint8Array` `bytes`, a negative or non-integer `startAddress
|
|
122
|
-
*
|
|
143
|
+
* non-`Uint8Array` `bytes`, a negative or non-integer `startAddress`, or a
|
|
144
|
+
* `startAddress` above `0xffff`, the top of the 16-bit address space)
|
|
145
|
+
* returns `[]` rather than wrapping the address into range and returning a
|
|
146
|
+
* plausible-looking but wrong listing.
|
|
123
147
|
*/
|
|
124
148
|
export function decode(bytes: Uint8Array, startAddress: number, opts: DecodeOptions = {}): Instruction[] {
|
|
125
149
|
if (!(bytes instanceof Uint8Array)) return [];
|
|
126
|
-
if (!
|
|
150
|
+
if (!isValidStartAddress(startAddress)) return [];
|
|
127
151
|
|
|
128
152
|
const options = isPlainObject(opts) ? opts : {};
|
|
129
153
|
const count = isNonNegativeSafeInteger(options.count) ? options.count : undefined;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@henols/vice-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "VICE emulator MCP server for C64 reverse-engineering: a stdio MCP server that proxies vice tools to a host VICE MCP server.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
"vice-sync.ts",
|
|
14
14
|
"vice-probe.ts",
|
|
15
15
|
"vice-broker-client.ts",
|
|
16
|
+
"version.ts",
|
|
16
17
|
"stock-protocol.ts",
|
|
17
18
|
"stock-connect.ts",
|
|
18
19
|
"containerpath.ts",
|
|
@@ -24,6 +25,7 @@
|
|
|
24
25
|
"build.ts",
|
|
25
26
|
"container-guard.mts",
|
|
26
27
|
"backend-detect.mts",
|
|
28
|
+
"capability-registry.ts",
|
|
27
29
|
"stock-dispatch.ts",
|
|
28
30
|
"stock-derived.ts",
|
|
29
31
|
"stock-handler.ts",
|
|
@@ -42,6 +44,15 @@
|
|
|
42
44
|
"disasm-opcodes.ts",
|
|
43
45
|
"disasm-decoder.ts",
|
|
44
46
|
"disasm-renderer.ts",
|
|
47
|
+
"stock-memory-search.ts",
|
|
48
|
+
"stock-symbols.ts",
|
|
49
|
+
"stock-vicii.ts",
|
|
50
|
+
"stock-cia.ts",
|
|
51
|
+
"stock-sprites.ts",
|
|
52
|
+
"stock-timing.ts",
|
|
53
|
+
"stock-run-until.ts",
|
|
54
|
+
"stock-diagnose.ts",
|
|
55
|
+
"stock-recycle.ts",
|
|
45
56
|
"resources",
|
|
46
57
|
"tools-manifest.json",
|
|
47
58
|
"tools-manifest.stock.json",
|
|
@@ -122,6 +122,19 @@ export function probeBackend(binPath, deps = {}) {
|
|
|
122
122
|
}
|
|
123
123
|
return classifyHelpOutput(text);
|
|
124
124
|
}
|
|
125
|
+
/** The client-side capability-decision schema version stamped into every
|
|
126
|
+
* capability record this module writes (see BackendCacheRecord's own field
|
|
127
|
+
* comment). BUMP THIS whenever the client's capability probing or the
|
|
128
|
+
* decoding it depends on changes, so records decided by the older code are
|
|
129
|
+
* re-probed instead of trusted.
|
|
130
|
+
*
|
|
131
|
+
* History:
|
|
132
|
+
* 1 -- plan 02-08's original probe (implicit; never written to disk).
|
|
133
|
+
* 2 -- CR-01 fix: decode failures are no longer persisted at all, and the
|
|
134
|
+
* CPUHISTORY_GET body layout was corrected (07-12). Every record
|
|
135
|
+
* written before this constant existed lacks the field and is
|
|
136
|
+
* therefore stale, which is exactly the intent. */
|
|
137
|
+
export const CAPABILITY_SCHEMA_VERSION = 2;
|
|
125
138
|
function isPlainObject(value) {
|
|
126
139
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
127
140
|
}
|
|
@@ -168,6 +181,8 @@ function readCacheRecord(supervisorDir) {
|
|
|
168
181
|
record.versionQuad = parsed.versionQuad;
|
|
169
182
|
if (typeof parsed.cpuHistoryAvailable === "boolean")
|
|
170
183
|
record.cpuHistoryAvailable = parsed.cpuHistoryAvailable;
|
|
184
|
+
if (typeof parsed.capabilitySchema === "number")
|
|
185
|
+
record.capabilitySchema = parsed.capabilitySchema;
|
|
171
186
|
return record;
|
|
172
187
|
}
|
|
173
188
|
/** Tmp-sibling -> chmod 0600 -> content -> rename, the SAME atomic-write
|
|
@@ -354,10 +369,19 @@ export function readCapabilityRecord(binPath, deps = {}) {
|
|
|
354
369
|
return null;
|
|
355
370
|
if (existing.versionQuad === undefined && existing.cpuHistoryAvailable === undefined)
|
|
356
371
|
return null;
|
|
357
|
-
const
|
|
372
|
+
const versionMismatch = deps.observedVersionQuad !== undefined &&
|
|
358
373
|
existing.versionQuad !== undefined &&
|
|
359
374
|
existing.versionQuad !== deps.observedVersionQuad;
|
|
360
|
-
|
|
375
|
+
// CR-01: a record decided by a different client-side capability schema is
|
|
376
|
+
// as untrustworthy as one decided against a different binary. Absent
|
|
377
|
+
// counts as a mismatch -- that is every record written before the field
|
|
378
|
+
// existed, i.e. every record a possibly-broken parser could have written.
|
|
379
|
+
const schemaMismatch = existing.capabilitySchema !== CAPABILITY_SCHEMA_VERSION;
|
|
380
|
+
const stale = versionMismatch || schemaMismatch;
|
|
381
|
+
const result = { versionQuad: existing.versionQuad, cpuHistoryAvailable: existing.cpuHistoryAvailable, stale };
|
|
382
|
+
if (existing.capabilitySchema !== undefined)
|
|
383
|
+
result.capabilitySchema = existing.capabilitySchema;
|
|
384
|
+
return result;
|
|
361
385
|
}
|
|
362
386
|
/** Attaches `{ versionQuad, cpuHistoryAvailable }` to the EXISTING backend
|
|
363
387
|
* verdict already on record for `binPath`'s resolved identity -- a no-op,
|
|
@@ -392,5 +416,9 @@ export function writeCapabilityRecord(binPath, capability, deps = {}) {
|
|
|
392
416
|
probedAt: existing.probedAt,
|
|
393
417
|
versionQuad: capability.versionQuad,
|
|
394
418
|
cpuHistoryAvailable: capability.cpuHistoryAvailable,
|
|
419
|
+
// CR-01: stamp WHICH client decided this, so a later parser change
|
|
420
|
+
// invalidates it. Never accept this from the caller -- it describes this
|
|
421
|
+
// module's own code, not anything the caller observed.
|
|
422
|
+
capabilitySchema: CAPABILITY_SCHEMA_VERSION,
|
|
395
423
|
});
|
|
396
424
|
}
|