@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,306 @@
1
+ // disasm-renderer.ts
2
+ //
3
+ // The pure `render(instructions, opts) -> string` / `renderLine(instruction,
4
+ // opts) -> string` pair that turns decoded 6510 instructions (04-03's
5
+ // `Instruction[]`) into ACME-ready `!cpu 6510` source. D-05's standalone
6
+ // module: no protocol, no symbol-store, no emulator, no `node:` builtin.
7
+ // `stock-disassemble.ts` (04-05) wires the real symbol resolver in via
8
+ // `RenderOptions.symbolFor`; this file never imports `stock-address.ts`
9
+ // itself, so it stays importable by anything that only has an
10
+ // `Instruction[]` in hand (Phase 5's backtrace, Phase 6's CPU-history decode
11
+ // -- neither has a live symbol store).
12
+ //
13
+ // ---------------------------------------------------------------------------
14
+ // WHY THIS FILE EXISTS
15
+ // ---------------------------------------------------------------------------
16
+ // 04-06 feeds this module's `render()` output to a real `acme` process and
17
+ // asserts the reassembled bytes match the original stream exactly (the
18
+ // byte-exact round-trip criterion 4 requires). 04-05's `stock_disassemble`
19
+ // tool answer returns the same string in its `listing` field. Both consumers
20
+ // depend on the two invariants this file exists to enforce:
21
+ // - D-09: every opcode ACME's `!cpu 6510` cannot express goes out as
22
+ // `!byte` with all its bytes, never as a mnemonic ACME would reject.
23
+ // - D-11: the rendered operand's width always equals the decoded
24
+ // instruction's width, forced explicitly (`mnemonic+2`) wherever ACME
25
+ // would otherwise re-encode a small absolute address to zero page.
26
+ //
27
+ // ---------------------------------------------------------------------------
28
+ // THE `+2` SPELLING IS AN ASSUMPTION, NOT A VERIFIED FACT (see the plan)
29
+ // ---------------------------------------------------------------------------
30
+ // ACME's documented size-forcing postfix syntax is `mnemonic+1` / `+2` /
31
+ // `+3`. ACME is not installed in this execution environment, so nothing here
32
+ // proves that spelling against a real assembler. **04-06's real-ACME
33
+ // round-trip is the proof** -- if ACME rejects `+2`, 04-06 corrects this
34
+ // file (it lists `disasm-renderer.ts` in its own `files_modified` for
35
+ // exactly this reason). Do not invent a different mechanism (padding with
36
+ // extra `!byte`, etc.) to dodge this uncertainty; `+2` is the one spelling
37
+ // this module emits everywhere the width invariant requires forcing.
38
+ //
39
+ // ---------------------------------------------------------------------------
40
+ // WHAT NOT TO DO
41
+ // ---------------------------------------------------------------------------
42
+ // - Never import `stock-*.ts`/`vice*.ts`/any `node:` builtin -- in
43
+ // particular never `stock-address.ts`. The symbol lookup arrives only as
44
+ // `opts.symbolFor`, an injected function (D-05); 04-05 wires the real
45
+ // resolver in at the tool layer, not here.
46
+ // - Never emit a mnemonic for an instruction whose `acmeExpressible` is
47
+ // `false`. That is the exclusion-list approach D-09 rejected -- it ships
48
+ // output that provably does not reassemble. Render every one of its
49
+ // bytes as `!byte` instead, with the mnemonic moved into a comment.
50
+ // - Never substitute a symbol into an immediate operand (the `#<`/`#>`
51
+ // high/low-byte ambiguity, D-11) or into any zeropage-family operand (a
52
+ // symbol whose value resolves `>= $0100` would silently widen the
53
+ // instruction).
54
+ // - Never emit a substituted symbol name without also emitting its own
55
+ // `name = $XXXX` definition line in the listing header -- criterion 4's
56
+ // "reassembles with zero external declarations" depends on it.
57
+
58
+ import type { DisasmNote, Instruction } from "./disasm-decoder.ts";
59
+ import type { AddressingMode } from "./disasm-opcodes.ts";
60
+
61
+ /**
62
+ * Options controlling `render()`/`renderLine()`. `symbolFor` is DISASM-06's
63
+ * injected resolver -- 04-05 wires `stock-address.ts`'s real symbol store
64
+ * behind it; this module never imports that store itself (D-05).
65
+ */
66
+ export interface RenderOptions {
67
+ showSymbols?: boolean;
68
+ symbolFor?: (address: number) => string | undefined;
69
+ origin?: number;
70
+ }
71
+
72
+ /** Resolved, fully-defaulted options this module's internals actually work
73
+ * against -- computed once per `render()`/`renderLine()` call. */
74
+ interface ResolvedOptions {
75
+ showSymbols: boolean;
76
+ symbolFor?: (address: number) => string | undefined;
77
+ origin?: number;
78
+ }
79
+
80
+ /** True iff `value` is a well-formed, generic JSON object -- not null, not
81
+ * an array. Matches this module tree's own isPlainObject() convention
82
+ * (stock-schema-check.ts, disasm-decoder.ts et al. -- the one-line predicate
83
+ * is repeated per file, never centrally imported). */
84
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
85
+ return typeof value === "object" && value !== null && !Array.isArray(value);
86
+ }
87
+
88
+ /** True iff `value` is a representable, non-negative whole number -- the
89
+ * same narrowing `disasm-decoder.ts` uses for its own numeric options. */
90
+ function isNonNegativeSafeInteger(value: unknown): value is number {
91
+ return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
92
+ }
93
+
94
+ function resolveOptions(opts: RenderOptions | undefined): ResolvedOptions {
95
+ const o = isPlainObject(opts) ? opts : {};
96
+ return {
97
+ showSymbols: o.showSymbols === true,
98
+ symbolFor: typeof o.symbolFor === "function" ? (o.symbolFor as (address: number) => string | undefined) : undefined,
99
+ origin: isNonNegativeSafeInteger(o.origin) ? o.origin : undefined,
100
+ };
101
+ }
102
+
103
+ /** Renders an 8-bit value as ACME hex syntax, e.g. `$0f`. */
104
+ function hex2(value: number): string {
105
+ return `$${(value & 0xff).toString(16).padStart(2, "0")}`;
106
+ }
107
+
108
+ /** Renders a 16-bit value as ACME hex syntax, e.g. `$d020`. */
109
+ function hex4(value: number): string {
110
+ return `$${(value & 0xffff).toString(16).padStart(4, "0")}`;
111
+ }
112
+
113
+ /** D-10's fixed note-text vocabulary. Every note renders through this table
114
+ * -- never a note's own bare string literal -- so the wording stays in one
115
+ * place. */
116
+ const NOTE_TEXT: Readonly<Record<DisasmNote, string>> = {
117
+ "nmos-page-wrap": "NMOS page-wrap: the high byte is fetched from $xx00, not the next page",
118
+ truncated: "truncated -- partial instruction at the end of the requested range",
119
+ "acme-unassemblable": "not expressible in ACME !cpu 6510",
120
+ "illegal-opcode": "illegal opcode",
121
+ };
122
+
123
+ /** Joins every note on an instruction into one `;`-comment body, in the
124
+ * decoder's own fixed note order, `" | "`-separated (D-10). Returns `""`
125
+ * when there are no notes -- callers must check for that before adding a
126
+ * leading `; `. */
127
+ function formatNotesComment(notes: DisasmNote[]): string {
128
+ if (notes.length === 0) return "";
129
+ return notes.map((note) => NOTE_TEXT[note]).join(" | ");
130
+ }
131
+
132
+ /**
133
+ * DISASM-06's substitution gate. Returns a resolved symbol only when
134
+ * `opts.showSymbols` is `true`, `opts.symbolFor` is supplied, and it returns
135
+ * a name for `address`. Callers decide, by operand role, whether they are
136
+ * even allowed to call this at all -- immediate and zeropage-family operand
137
+ * renderers never call it (D-11's own table).
138
+ */
139
+ function resolveSymbol(address: number, opts: ResolvedOptions): { name: string; address: number } | undefined {
140
+ if (!opts.showSymbols || !opts.symbolFor) return undefined;
141
+ const name = opts.symbolFor(address);
142
+ return name !== undefined ? { name, address } : undefined;
143
+ }
144
+
145
+ /** The suffix ACME syntax needs for an absolute-family addressing mode.
146
+ * Typed against `disasm-opcodes.ts`'s own `AddressingMode` -- the opcode
147
+ * table's shared vocabulary, not a locally re-derived one. */
148
+ function absoluteSuffix(mode: AddressingMode): "" | ",x" | ",y" {
149
+ if (mode === "absolute_x") return ",x";
150
+ if (mode === "absolute_y") return ",y";
151
+ return "";
152
+ }
153
+
154
+ /**
155
+ * Renders the "mnemonic + operand" text for an expressible instruction --
156
+ * the part that, for a D-09 `!byte` substitution, moves into the trailing
157
+ * comment instead of being emitted as real ACME source. Never called for a
158
+ * truncated instruction (operand unknown by construction).
159
+ */
160
+ function renderMnemonicOperand(instr: Instruction, opts: ResolvedOptions): { text: string; symbol?: { name: string; address: number } } {
161
+ const m = instr.mnemonic;
162
+
163
+ switch (instr.mode) {
164
+ case "implicit":
165
+ case "accumulator":
166
+ return { text: m };
167
+
168
+ case "immediate":
169
+ // D-11: never substituted -- the `#<`/`#>` high/low-byte ambiguity.
170
+ return { text: `${m} #${hex2(instr.operand!.value)}` };
171
+
172
+ case "zeropage":
173
+ // D-11: never substituted -- a symbol >= $0100 would widen this.
174
+ return { text: `${m} ${hex2(instr.operand!.value)}` };
175
+
176
+ case "zeropage_x":
177
+ return { text: `${m} ${hex2(instr.operand!.value)},x` };
178
+
179
+ case "zeropage_y":
180
+ return { text: `${m} ${hex2(instr.operand!.value)},y` };
181
+
182
+ case "indirect_x":
183
+ return { text: `${m} (${hex2(instr.operand!.value)},x)` };
184
+
185
+ case "indirect_y":
186
+ return { text: `${m} (${hex2(instr.operand!.value)}),y` };
187
+
188
+ case "indirect": {
189
+ // `jmp ($xxxx)` has exactly one encoding -- substitution is safe, no
190
+ // width force is ever needed.
191
+ const value = instr.operand!.value;
192
+ const symbol = resolveSymbol(value, opts);
193
+ const addrText = symbol?.name ?? hex4(value);
194
+ return { text: `${m} (${addrText})`, ...(symbol !== undefined ? { symbol } : {}) };
195
+ }
196
+
197
+ case "relative": {
198
+ // A branch is always 2 bytes; ACME computes the offset from the
199
+ // label, so substitution can never change the encoding.
200
+ const target = instr.resolvedTarget!;
201
+ const symbol = resolveSymbol(target, opts);
202
+ const addrText = symbol?.name ?? hex4(target);
203
+ return { text: `${m} ${addrText}`, ...(symbol !== undefined ? { symbol } : {}) };
204
+ }
205
+
206
+ case "absolute":
207
+ case "absolute_x":
208
+ case "absolute_y": {
209
+ // D-11's width invariant: a value below $0100 renders with ACME's
210
+ // `+2` size-forcing postfix, whether or not a symbol is substituted --
211
+ // otherwise ACME would re-encode it to zero page and shrink the
212
+ // instruction.
213
+ const value = instr.operand!.value;
214
+ const symbol = resolveSymbol(value, opts);
215
+ const addrText = symbol?.name ?? hex4(value);
216
+ const forceSize = value < 0x100;
217
+ const mnemonic = forceSize ? `${m}+2` : m;
218
+ const suffix = absoluteSuffix(instr.mode);
219
+ return { text: `${mnemonic} ${addrText}${suffix}`, ...(symbol !== undefined ? { symbol } : {}) };
220
+ }
221
+ }
222
+ }
223
+
224
+ /** ACME source indent for instruction/directive lines -- purely cosmetic,
225
+ * comments cannot affect assembly. */
226
+ const INDENT = " ";
227
+
228
+ /**
229
+ * Renders one `Instruction` to its listing line(s), applying D-09's `!byte`
230
+ * substitution for anything ACME cannot express (or that decoded as
231
+ * truncated) and D-10's note-comment vocabulary. Returns the substituted
232
+ * symbol, if any, so `render()` can collect it into the header's symbol
233
+ * definitions.
234
+ */
235
+ function renderInstructionLine(instr: Instruction, opts: ResolvedOptions): { text: string; symbol?: { name: string; address: number } } {
236
+ const notesText = formatNotesComment(instr.notes);
237
+
238
+ if (instr.notes.includes("truncated")) {
239
+ // DISASM-05: the operand is unknown -- never render a mnemonic. Emit
240
+ // every byte that exists so the following instruction (if any) is never
241
+ // reached; there is none, since a truncated instruction is always last.
242
+ const bytesHex = instr.bytes.map(hex2).join(", ");
243
+ return { text: `${INDENT}!byte ${bytesHex} ; ${notesText}` };
244
+ }
245
+
246
+ if (!instr.acmeExpressible) {
247
+ // D-09: every byte goes out as `!byte`, keeping the following
248
+ // instruction at the correct address. The mnemonic and operand a human
249
+ // reader needs move into the trailing comment instead.
250
+ const { text: mnemonicOperand, symbol } = renderMnemonicOperand(instr, opts);
251
+ const bytesHex = instr.bytes.map(hex2).join(", ");
252
+ const comment = notesText ? `${mnemonicOperand} [${notesText}]` : mnemonicOperand;
253
+ return { text: `${INDENT}!byte ${bytesHex} ; ${comment}`, ...(symbol !== undefined ? { symbol } : {}) };
254
+ }
255
+
256
+ const { text: mnemonicOperand, symbol } = renderMnemonicOperand(instr, opts);
257
+ const text = notesText ? `${INDENT}${mnemonicOperand} ; ${notesText}` : `${INDENT}${mnemonicOperand}`;
258
+ return { text, ...(symbol !== undefined ? { symbol } : {}) };
259
+ }
260
+
261
+ /**
262
+ * Renders a single instruction to one listing line -- no `!cpu 6510`
263
+ * header, no origin, no symbol definitions. `render()` is what produces a
264
+ * self-contained listing; this is the per-instruction primitive it and
265
+ * standalone callers (e.g. Phase 5's backtrace, when it wants one rendered
266
+ * line without a whole listing) share.
267
+ */
268
+ export function renderLine(instruction: Instruction, opts?: RenderOptions): string {
269
+ const resolved = resolveOptions(opts);
270
+ return renderInstructionLine(instruction, resolved).text;
271
+ }
272
+
273
+ /**
274
+ * Renders a full, self-contained ACME `!cpu 6510` listing: the header
275
+ * (`!cpu 6510`, one `name = $XXXX` definition per substituted symbol sorted
276
+ * by address, then `* = $XXXX`), followed by one line per instruction. This
277
+ * is what 04-06 feeds to a real `acme` process and what 04-05's tool answer
278
+ * returns as `listing`.
279
+ */
280
+ export function render(instructions: Instruction[], opts?: RenderOptions): string {
281
+ const resolved = resolveOptions(opts);
282
+ const list = Array.isArray(instructions) ? instructions : [];
283
+
284
+ const symbols = new Map<string, number>();
285
+ const instructionLines: string[] = [];
286
+
287
+ for (const instr of list) {
288
+ const { text, symbol } = renderInstructionLine(instr, resolved);
289
+ instructionLines.push(text);
290
+ if (symbol !== undefined) symbols.set(symbol.name, symbol.address);
291
+ }
292
+
293
+ const lines: string[] = ["!cpu 6510"];
294
+
295
+ const sortedSymbols = [...symbols.entries()].sort((a, b) => a[1] - b[1]);
296
+ for (const [name, address] of sortedSymbols) {
297
+ lines.push(`${name} = ${hex4(address)}`);
298
+ }
299
+
300
+ const origin = resolved.origin ?? list[0]?.address ?? 0;
301
+ lines.push(`* = ${hex4(origin)}`);
302
+
303
+ lines.push(...instructionLines);
304
+
305
+ return lines.join("\n");
306
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@henols/vice-mcp",
3
- "version": "0.1.9",
3
+ "version": "0.1.11",
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,8 @@
13
13
  "vice-sync.ts",
14
14
  "vice-probe.ts",
15
15
  "vice-broker-client.ts",
16
+ "stock-protocol.ts",
17
+ "stock-connect.ts",
16
18
  "containerpath.ts",
17
19
  "hostpath.ts",
18
20
  "repo-root.ts",
@@ -21,9 +23,30 @@
21
23
  "refresh-manifest.ts",
22
24
  "build.ts",
23
25
  "container-guard.mts",
26
+ "backend-detect.mts",
27
+ "stock-dispatch.ts",
28
+ "stock-derived.ts",
29
+ "stock-handler.ts",
30
+ "stock-runstate.ts",
31
+ "stock-address.ts",
32
+ "stock-paths.ts",
33
+ "stock-machine.ts",
34
+ "stock-petscii.ts",
35
+ "stock-input.ts",
36
+ "stock-memory.ts",
37
+ "stock-registers.ts",
38
+ "stock-checkpoints.ts",
39
+ "stock-execution.ts",
40
+ "stock-condition.ts",
41
+ "stock-disassemble.ts",
42
+ "disasm-opcodes.ts",
43
+ "disasm-decoder.ts",
44
+ "disasm-renderer.ts",
24
45
  "resources",
25
46
  "tools-manifest.json",
26
- "README.md"
47
+ "tools-manifest.stock.json",
48
+ "README.md",
49
+ "THIRD-PARTY-NOTICES.md"
27
50
  ],
28
51
  "engines": {
29
52
  "node": ">=22.18.0"
@@ -56,6 +79,8 @@
56
79
  ],
57
80
  "scripts": {
58
81
  "test": "node --test '*.test.*'",
82
+ "test:automated": "node test-gate.mjs",
83
+ "test:manual": "node test-gate.mjs --manual",
59
84
  "typecheck": "tsc --noEmit -p tsconfig.json",
60
85
  "build": "node build.ts",
61
86
  "smoke": "node smoke.mjs"
@@ -11,11 +11,17 @@
11
11
  import { serverInfo, activeInstance, type ServerInfoPayload, type ToolInfo } from "./vice.ts";
12
12
  import { writeFileSync, chmodSync, renameSync } from "node:fs";
13
13
  import { fileURLToPath } from "node:url";
14
- import { dirname, join, resolve } from "node:path";
14
+ import { basename, dirname, join, resolve } from "node:path";
15
15
 
16
16
  const HERE = dirname(fileURLToPath(import.meta.url));
17
17
  const DEFAULT_MANIFEST_PATH = join(HERE, "tools-manifest.json");
18
18
 
19
+ // The hand-authored stock surface's own filename (stock-dispatch.ts's
20
+ // tools-manifest.stock.json, plan 02-09) -- named here ONLY so
21
+ // writeManifestAtomic() below can refuse to ever target it, never as
22
+ // something this file writes.
23
+ const STOCK_MANIFEST_BASENAME = "tools-manifest.stock.json";
24
+
19
25
  function manifestPath(): string {
20
26
  return process.env.VICE_TOOLS_MANIFEST
21
27
  ? resolve(process.env.VICE_TOOLS_MANIFEST)
@@ -39,8 +45,23 @@ export interface ToolsManifest {
39
45
  * a crash, not only against a rejected handshake -- the rejected-handshake
40
46
  * half of that guarantee already held (the early return below, unchanged
41
47
  * from the original), but a crash between "open the file" and "write the
42
- * content" could previously still truncate a good manifest to nothing. */
48
+ * content" could previously still truncate a good manifest to nothing.
49
+ *
50
+ * T-02-31 (02-09): this function regenerates the manifest from a LIVE fork
51
+ * host's tools/list, so its output path must NEVER be
52
+ * tools-manifest.stock.json -- that file is the hand-authored, separately
53
+ * committed stock surface (D-07/D-09), and this refresh path overwriting it
54
+ * with the fork's full tool list would silently destroy the trimming that
55
+ * surface exists to enforce. This assertion makes that impossible by
56
+ * accident; a future `--stock` flag pointing this generator at the stock
57
+ * file has to edit this line deliberately, not merely change a default. */
43
58
  function writeManifestAtomic(path: string, manifest: ToolsManifest): void {
59
+ if (basename(path) === STOCK_MANIFEST_BASENAME) {
60
+ throw new Error(
61
+ `refresh-manifest: refusing to write ${STOCK_MANIFEST_BASENAME} -- this generator regenerates the fork's ` +
62
+ `manifest from a live host and must never overwrite the hand-authored stock surface (D-07/D-09)`
63
+ );
64
+ }
44
65
  const tmpPath = `${path}.tmp-${process.pid}-${Date.now()}`;
45
66
  writeFileSync(tmpPath, "");
46
67
  chmodSync(tmpPath, 0o600);