@henols/vice-mcp 0.1.9 → 0.1.11

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.
@@ -0,0 +1,252 @@
1
+ #!/usr/bin/env node
2
+ // stock-disassemble.ts
3
+ //
4
+ // vice_disassemble -- a DERIVED tool (DERIV-07, DISASM-01): its answer is
5
+ // computed CLIENT-SIDE from bytes MEM_GET returned (disasm-decoder.ts's
6
+ // decode() + disasm-renderer.ts's render()), never answered by one
7
+ // binary-monitor opcode the way a direct tool's answer is. Registered
8
+ // through withDerivedTool() in stock-dispatch.ts, never withStockSession()
9
+ // (D-01/D-03) -- this is the first and largest consumer of the derived-tool
10
+ // seam 04-02 built.
11
+ //
12
+ // WHY THIS FILE EXISTS: DISASM-01 is criterion 2's own sentence -- "a user
13
+ // can disassemble a memory range on the stock backend" -- and the binary
14
+ // monitor has no disassemble opcode at all. `address`/`count`/`show_symbols`
15
+ // keep the fork's own names, types and defaults (Phase 3 D-03); `end` is a
16
+ // stock-only optional extra, mutually exclusive with `count` (D-12, never
17
+ // silently resolved -- a caller that gets a silently-different range than it
18
+ // asked for reads the wrong code).
19
+ //
20
+ // WHAT NOT TO DO:
21
+ // - Never import hostpath.ts or vice-proxy.ts, and never call the
22
+ // fork-forwarding function's rewriteArguments() -- hostpath-consumers.test.ts
23
+ // gates this file's absence from the closed host-path consumer set
24
+ // (D-02). This tool takes no path argument at all; the surface is empty
25
+ // by construction.
26
+ // - Never issue an unrequested resume (Phase 3 D-05) -- this handler sends
27
+ // MEM_GET and nothing else. `runState` on the answer (via stockAnswer())
28
+ // reports the halt honestly.
29
+ // - Never turn the MEM_GET body's side-effect flag on -- disassembling
30
+ // $D000-$DFFF must never clear a pending VIC-II IRQ flag or otherwise
31
+ // mutate emulator state as a side effect of reading it. `sidefx` is
32
+ // hardcoded `false` below with no argument to override it.
33
+ // - Never build the answer outside stockAnswer() (D-06) -- that is exactly
34
+ // how an answer ships without `runState`.
35
+ // - Never re-derive address/byte-count parsing locally (D-04) --
36
+ // stock-address.ts's parseAddress()/parseByteCount() are the only seam.
37
+ import { CommandType, memGetBody } from "./stock-protocol.ts";
38
+ import { parseAddress, parseByteCount, symbolNameFor, hasSymbolStore } from "./stock-address.ts";
39
+ import { convertWireError, isErrorText, stockAnswer, type StockSessionHandler } from "./stock-handler.ts";
40
+ import { decode, type Instruction } from "./disasm-decoder.ts";
41
+ import { render } from "./disasm-renderer.ts";
42
+
43
+ /** True iff `value` is a well-formed, generic JSON object -- not null, not
44
+ * an array. Matches this module tree's own isPlainObject() convention
45
+ * (stock-memory.ts, disasm-decoder.ts et al.). */
46
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
47
+ return typeof value === "object" && value !== null && !Array.isArray(value);
48
+ }
49
+
50
+ /** D-13's answer bound: at most this many instructions are ever returned in
51
+ * one answer, regardless of which form (`count` or `end`) requested the
52
+ * range. `count` itself is already capped at this same value (the fork's own
53
+ * documented max), but the `end` form has no natural cap of its own -- an
54
+ * unbounded answer is the DoS surface (T-04-05-03). */
55
+ const MAX_INSTRUCTIONS = 100;
56
+
57
+ /** Renders an 8-bit value as ACME hex syntax, e.g. `$0f`. Duplicated from
58
+ * disasm-renderer.ts's own private helper of the same shape -- that module
59
+ * exports no per-operand text primitive, and this file's `instructions[]`
60
+ * answer field is a distinct concern from `listing` (D-13's structured
61
+ * per-instruction fields are plain numeric text, never symbol-substituted;
62
+ * `listing` is the one place a substituted symbol name appears). */
63
+ function hex2(value: number): string {
64
+ return `$${(value & 0xff).toString(16).padStart(2, "0")}`;
65
+ }
66
+
67
+ /** Renders a 16-bit value as ACME hex syntax, e.g. `$d020`. */
68
+ function hex4(value: number): string {
69
+ return `$${(value & 0xffff).toString(16).padStart(4, "0")}`;
70
+ }
71
+
72
+ /**
73
+ * Renders just the operand text for one decoded, non-truncated instruction
74
+ * -- the per-instruction `operand` field (D-13), always numeric (never
75
+ * symbol-substituted; that substitution belongs to `listing` alone).
76
+ * Returns `""` for a mode with no operand at all. Must never be called on a
77
+ * truncated instruction (its `operand`/`resolvedTarget` keys are absent by
78
+ * construction, DISASM-05) -- callers guard on `notes.includes("truncated")`
79
+ * first and use `""` directly instead.
80
+ */
81
+ function operandTextFor(instr: Instruction): string {
82
+ switch (instr.mode) {
83
+ case "implicit":
84
+ case "accumulator":
85
+ return "";
86
+ case "immediate":
87
+ return `#${hex2(instr.operand!.value)}`;
88
+ case "zeropage":
89
+ return hex2(instr.operand!.value);
90
+ case "zeropage_x":
91
+ return `${hex2(instr.operand!.value)},x`;
92
+ case "zeropage_y":
93
+ return `${hex2(instr.operand!.value)},y`;
94
+ case "indirect_x":
95
+ return `(${hex2(instr.operand!.value)},x)`;
96
+ case "indirect_y":
97
+ return `(${hex2(instr.operand!.value)}),y`;
98
+ case "indirect":
99
+ return `(${hex4(instr.operand!.value)})`;
100
+ case "relative":
101
+ return hex4(instr.resolvedTarget!);
102
+ case "absolute":
103
+ return hex4(instr.operand!.value);
104
+ case "absolute_x":
105
+ return `${hex4(instr.operand!.value)},x`;
106
+ case "absolute_y":
107
+ return `${hex4(instr.operand!.value)},y`;
108
+ }
109
+ }
110
+
111
+ export const handleDisassemble: StockSessionHandler = async (args, session, _deps) => {
112
+ if (!isPlainObject(args)) {
113
+ return isErrorText("vice_disassemble: arguments must be an object");
114
+ }
115
+
116
+ // --------------------------------------------------------- address (required)
117
+
118
+ let address: number;
119
+ try {
120
+ address = parseAddress(args.address, { what: "address" });
121
+ } catch (err) {
122
+ return isErrorText(`vice_disassemble: ${err instanceof Error ? err.message : String(err)}`);
123
+ }
124
+
125
+ // --------------------------------------------------------- D-12: mutual exclusion, refused outright
126
+ //
127
+ // Gated on args.count !== undefined -- explicitly supplied -- never on the
128
+ // resolved default, so an `end`-only call is never refused because of
129
+ // count's own default of 10.
130
+ if (args.count !== undefined && args.end !== undefined) {
131
+ return isErrorText(
132
+ `vice_disassemble: count and end are mutually exclusive -- supply one or the other, not both ` +
133
+ `(got count=${JSON.stringify(args.count)}, end=${JSON.stringify(args.end)})`,
134
+ );
135
+ }
136
+
137
+ // --------------------------------------------------------- count (optional, default 10, max 100)
138
+
139
+ let count: number | undefined;
140
+ if (args.count !== undefined) {
141
+ try {
142
+ count = parseByteCount(args.count, { max: MAX_INSTRUCTIONS, what: "count" });
143
+ } catch (err) {
144
+ return isErrorText(`vice_disassemble: ${err instanceof Error ? err.message : String(err)}`);
145
+ }
146
+ }
147
+ const effectiveCount = count ?? 10;
148
+
149
+ // --------------------------------------------------------- end (optional, stock-only extra)
150
+
151
+ let end: number | undefined;
152
+ if (args.end !== undefined) {
153
+ try {
154
+ end = parseAddress(args.end, { what: "end" });
155
+ } catch (err) {
156
+ return isErrorText(`vice_disassemble: ${err instanceof Error ? err.message : String(err)}`);
157
+ }
158
+ if (end < address) {
159
+ return isErrorText(
160
+ `vice_disassemble: end (0x${end.toString(16)}) must be >= address (0x${address.toString(16)})`,
161
+ );
162
+ }
163
+ }
164
+
165
+ // --------------------------------------------------------- show_symbols (optional, default true)
166
+
167
+ let showSymbols = true;
168
+ if (args.show_symbols !== undefined) {
169
+ if (typeof args.show_symbols !== "boolean") {
170
+ return isErrorText(`vice_disassemble: show_symbols must be a boolean, got ${typeof args.show_symbols}`);
171
+ }
172
+ showSymbols = args.show_symbols;
173
+ }
174
+
175
+ // --------------------------------------------------------- bounded memory read (Phase 3 D-05: halts, never resumes)
176
+ //
177
+ // `end` form: over-read by two bytes so the last instruction that STARTS
178
+ // at or before `end` has its full length available; `count` form:
179
+ // over-read by up to two extra bytes per instruction (three is the
180
+ // maximum instruction length) so `count` instructions can always be
181
+ // decoded. Both clamped at $ffff -- a genuine memspace boundary, not a
182
+ // client bug.
183
+ const readEnd = end !== undefined ? Math.min(end + 2, 0xffff) : Math.min(address + effectiveCount * 3 - 1, 0xffff);
184
+
185
+ const body = memGetBody({ sidefx: false, start: address, end: readEnd, memspace: 0x00, bank: 0x0000 });
186
+
187
+ let response;
188
+ try {
189
+ response = await session.client.send(CommandType.MemoryGet, body);
190
+ } catch (err) {
191
+ return convertWireError("vice_disassemble", err);
192
+ }
193
+
194
+ if (response.type !== "memory_get") {
195
+ return isErrorText(
196
+ `vice_disassemble: the binary monitor replied with an unexpected response type ("${response.type}"), expected "memory_get"`,
197
+ );
198
+ }
199
+
200
+ const expectedLength = readEnd - address + 1;
201
+ if (response.bytes.length !== expectedLength) {
202
+ return isErrorText(
203
+ `vice_disassemble: expected ${expectedLength} byte(s), got ${response.bytes.length} -- a short read is a wrong answer, not a partial success`,
204
+ );
205
+ }
206
+
207
+ // --------------------------------------------------------- decode and render
208
+
209
+ const decoded = decode(response.bytes, address, end !== undefined ? { end } : { count: effectiveCount });
210
+
211
+ let limitReached = false;
212
+ let nextAddress: number | undefined;
213
+ let kept = decoded;
214
+ if (decoded.length > MAX_INSTRUCTIONS) {
215
+ kept = decoded.slice(0, MAX_INSTRUCTIONS);
216
+ limitReached = true;
217
+ nextAddress = decoded[MAX_INSTRUCTIONS]!.address;
218
+ }
219
+
220
+ // D-14: show_symbols with no store installed is a successful no-op that
221
+ // SAYS SO -- never an error.
222
+ const symbolsApplied = showSymbols && hasSymbolStore();
223
+ const listing = render(kept, { showSymbols: symbolsApplied, symbolFor: symbolNameFor, origin: address });
224
+
225
+ const instructions = kept.map((instr) => {
226
+ const truncated = instr.notes.includes("truncated");
227
+ return {
228
+ address: instr.address,
229
+ bytes: instr.bytes,
230
+ mnemonic: truncated ? "" : instr.mnemonic,
231
+ operand: truncated ? "" : operandTextFor(instr),
232
+ ...(instr.resolvedTarget !== undefined ? { resolvedTarget: instr.resolvedTarget } : {}),
233
+ notes: instr.notes,
234
+ };
235
+ });
236
+
237
+ const payload: Record<string, unknown> = {
238
+ address,
239
+ ...(end !== undefined ? { end } : {}),
240
+ count: kept.length,
241
+ instructions,
242
+ listing,
243
+ symbolsApplied,
244
+ ...(showSymbols && !symbolsApplied
245
+ ? { symbolNote: "no symbol table is loaded -- addresses are rendered numerically. Load one to see symbol names." }
246
+ : {}),
247
+ limitReached,
248
+ ...(limitReached ? { nextAddress } : {}),
249
+ };
250
+
251
+ return stockAnswer(session.client, payload);
252
+ };