From 32eb8f3c17e25c0fe33ea89246fa581c05a1679a Mon Sep 17 00:00:00 2001 From: Simen Svale Date: Tue, 30 Dec 2025 01:50:27 +0100 Subject: [PATCH] Add VICE binary protocol layer and core debugging tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Protocol layer (src/protocol/): - TCP socket client with connection management - Binary packet encoding/decoding per VICE monitor protocol - Async response handling and request ID tracking - Proper error handling with actionable suggestions MCP tools implemented: - status: Connection and emulation state - connect/disconnect: VICE session management - readMemory/writeMemory: Memory access with hex dump formatting - getRegisters: CPU state with decoded flags - step/continue: Execution control - reset: Machine reset (soft/hard) - setBreakpoint/deleteBreakpoint: Breakpoint management AX patterns integrated: - _meta block in all responses (connection state context) - Structured output (value + hex + hint where relevant) - Rich tool descriptions with cross-references - Actionable error messages with suggestions - Memory region hints for common addresses 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- src/index.ts | 631 ++++++++++++++++++++++++++++++++++++----- src/protocol/client.ts | 436 ++++++++++++++++++++++++++++ src/protocol/index.ts | 2 + src/protocol/types.ts | 73 +++++ 4 files changed, 1064 insertions(+), 78 deletions(-) create mode 100644 src/protocol/client.ts create mode 100644 src/protocol/index.ts create mode 100644 src/protocol/types.ts diff --git a/src/index.ts b/src/index.ts index 10501e7..86b9de6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,40 +3,101 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { z } from "zod"; +import { getViceClient, ViceError } from "./protocol/index.js"; const server = new McpServer({ name: "vice-mcp", version: "0.1.0", }); -// Connection state (will be managed by protocol layer) -let connected = false; +const client = getViceClient(); + +// Helper to format tool responses with _meta context +function formatResponse(data: object) { + const state = client.getState(); + return { + content: [ + { + type: "text" as const, + text: JSON.stringify( + { + ...data, + _meta: { + connected: state.connected, + running: state.running, + ...(state.connected && { host: state.host, port: state.port }), + }, + }, + null, + 2 + ), + }, + ], + }; +} + +// Helper to format error responses +function formatError(error: ViceError | Error) { + const state = client.getState(); + const errorData = + "code" in error + ? error + : { + error: true, + code: "UNKNOWN_ERROR", + message: error.message, + }; + + return { + content: [ + { + type: "text" as const, + text: JSON.stringify( + { + ...errorData, + _meta: { + connected: state.connected, + running: state.running, + }, + }, + null, + 2 + ), + }, + ], + }; +} // Tool: status - Get current connection and emulation state server.registerTool( "status", { - description: "Get current connection and emulation state", + description: `Get current VICE connection and emulation state. + +Returns connection status, whether emulation is running or paused, and host/port if connected. + +Use this to: +- Check if you're connected before running other commands +- See if emulation is running or stopped (e.g., at a breakpoint) +- Verify connection details + +Related tools: connect, disconnect`, }, async () => { - return { - content: [ - { - type: "text", - text: JSON.stringify( - { - connected, - running: false, - hint: connected - ? "Connected to VICE" - : "Not connected. Use connect() to establish connection to VICE.", - }, - null, - 2 - ), - }, - ], - }; + const state = client.getState(); + return formatResponse({ + connected: state.connected, + running: state.running, + ...(state.connected && { + host: state.host, + port: state.port, + }), + hint: state.connected + ? state.running + ? "VICE is running. Use step() to pause execution." + : "VICE is paused. Use continue() to resume." + : "Not connected. Use connect() to establish connection to VICE.", + }); } ); @@ -44,36 +105,45 @@ server.registerTool( server.registerTool( "connect", { - description: "Connect to a running VICE instance with binary monitor enabled", + description: `Connect to a running VICE emulator instance via the binary monitor protocol. + +VICE must be started with the binary monitor enabled: + x64sc -binarymonitor -binarymonitoraddress ip4://127.0.0.1:6502 + +Default connection: 127.0.0.1:6502 + +Use this first before any debugging operations. Connection persists until disconnect() is called or VICE closes. + +Related tools: status, disconnect`, inputSchema: z.object({ - host: z.string().optional().describe("VICE host address (default: 127.0.0.1)"), - port: z.number().optional().describe("VICE binary monitor port (default: 6502)"), + host: z + .string() + .optional() + .describe("VICE host address (default: 127.0.0.1)"), + port: z + .number() + .min(1) + .max(65535) + .optional() + .describe("VICE binary monitor port (default: 6502)"), }), }, async (args) => { const host = args.host || "127.0.0.1"; const port = args.port || 6502; - // Placeholder - actual connection logic will be in protocol layer - return { - content: [ - { - type: "text", - text: JSON.stringify( - { - connected: false, - error: true, - code: "NOT_IMPLEMENTED", - message: `Connection to ${host}:${port} not yet implemented`, - suggestion: - "Protocol layer is under development. Check back soon!", - }, - null, - 2 - ), - }, - ], - }; + try { + await client.connect(host, port); + return formatResponse({ + connected: true, + host, + port, + message: `Successfully connected to VICE at ${host}:${port}`, + hint: "Connection established. You can now use readMemory, getRegisters, and other debugging tools.", + }); + } catch (error) { + return formatError(error as ViceError); + } } ); @@ -81,44 +151,449 @@ server.registerTool( server.registerTool( "disconnect", { - description: "Disconnect from the VICE instance", + description: `Disconnect from the VICE emulator instance. + +Cleanly closes the connection. Safe to call even if not connected. + +Related tools: connect, status`, }, async () => { - if (!connected) { - return { - content: [ - { - type: "text", - text: JSON.stringify( - { - error: true, - code: "NOT_CONNECTED", - message: "Not connected to VICE", - }, - null, - 2 - ), - }, - ], - }; - } + const wasConnected = client.getState().connected; - connected = false; - return { - content: [ - { - type: "text", - text: JSON.stringify( - { - disconnected: true, - message: "Disconnected from VICE", - }, - null, - 2 - ), + try { + await client.disconnect(); + return formatResponse({ + disconnected: true, + wasConnected, + message: wasConnected + ? "Disconnected from VICE" + : "Was not connected", + }); + } catch (error) { + return formatError(error as ViceError); + } + } +); + +// Tool: readMemory - Read memory from the C64 +server.registerTool( + "readMemory", + { + description: `Read memory from the C64's address space. + +Returns raw bytes plus hex and ASCII representations. + +C64 memory map highlights: +- $0000-$00FF: Zero page (fast access, common variables) +- $0100-$01FF: Stack +- $0400-$07FF: Default screen RAM (1000 bytes) +- $D000-$D3FF: VIC-II registers (graphics) +- $D400-$D7FF: SID registers (sound) +- $D800-$DBFF: Color RAM + +For screen content, consider using readScreen instead for interpreted output. +For sprite info, use readSprites for semantic data. + +Related tools: writeMemory, readScreen, readSprites, readVicState`, + inputSchema: z.object({ + address: z + .number() + .min(0) + .max(0xffff) + .describe("Start address (0x0000-0xFFFF)"), + length: z + .number() + .min(1) + .max(65536) + .optional() + .describe("Number of bytes to read (default: 256, max: 65536)"), + }), + }, + async (args) => { + const address = args.address; + const length = Math.min(args.length || 256, 65536); + const endAddress = Math.min(address + length - 1, 0xffff); + + try { + const data = await client.readMemory(address, endAddress); + + // Format as hex dump + const hexLines: string[] = []; + const asciiLines: string[] = []; + + for (let i = 0; i < data.length; i += 16) { + const chunk = data.subarray(i, Math.min(i + 16, data.length)); + const hex = Array.from(chunk) + .map((b) => b.toString(16).padStart(2, "0")) + .join(" "); + const ascii = Array.from(chunk) + .map((b) => (b >= 32 && b < 127 ? String.fromCharCode(b) : ".")) + .join(""); + + hexLines.push( + `$${(address + i).toString(16).padStart(4, "0")}: ${hex}` + ); + asciiLines.push(ascii); + } + + return formatResponse({ + address: { + value: address, + hex: `$${address.toString(16).padStart(4, "0")}`, }, - ], - }; + length: data.length, + bytes: Array.from(data), + hex: hexLines.join("\n"), + ascii: asciiLines.join(""), + hint: getMemoryHint(address, endAddress), + }); + } catch (error) { + return formatError(error as ViceError); + } + } +); + +// Helper to provide context about memory regions +function getMemoryHint(start: number, end: number): string { + if (start <= 0xff) return "Zero page - commonly used for variables and pointers"; + if (start >= 0x100 && start <= 0x1ff) return "Stack area"; + if (start >= 0x400 && end <= 0x7ff) return "Default screen RAM area"; + if (start >= 0xd000 && end <= 0xd3ff) return "VIC-II registers - use readVicState for interpreted data"; + if (start >= 0xd400 && end <= 0xd7ff) return "SID registers - use readSidState for interpreted data"; + if (start >= 0xd800 && end <= 0xdbff) return "Color RAM"; + if (start >= 0xa000 && end <= 0xbfff) return "BASIC ROM (or RAM if bank switched)"; + if (start >= 0xe000 && end <= 0xffff) return "KERNAL ROM (or RAM if bank switched)"; + return ""; +} + +// Tool: writeMemory - Write memory to the C64 +server.registerTool( + "writeMemory", + { + description: `Write bytes to the C64's memory. + +Directly modifies memory at the specified address. Changes take effect immediately. + +Common uses: +- Poke values for testing +- Patch code at runtime +- Modify screen/color RAM directly +- Change VIC/SID registers + +Be careful writing to ROM areas ($A000-$BFFF, $E000-$FFFF) - you may need to bank out ROM first. + +Related tools: readMemory, fillMemory`, + inputSchema: z.object({ + address: z + .number() + .min(0) + .max(0xffff) + .describe("Start address (0x0000-0xFFFF)"), + bytes: z + .array(z.number().min(0).max(255)) + .min(1) + .describe("Array of bytes to write (0-255 each)"), + }), + }, + async (args) => { + try { + await client.writeMemory(args.address, args.bytes); + + return formatResponse({ + success: true, + address: { + value: args.address, + hex: `$${args.address.toString(16).padStart(4, "0")}`, + }, + bytesWritten: args.bytes.length, + message: `Wrote ${args.bytes.length} byte(s) to $${args.address.toString(16).padStart(4, "0")}`, + }); + } catch (error) { + return formatError(error as ViceError); + } + } +); + +// Tool: getRegisters - Get CPU registers +server.registerTool( + "getRegisters", + { + description: `Get current 6502/6510 CPU register state. + +Returns all CPU registers with interpreted flags. + +Registers: +- A: Accumulator (arithmetic operations) +- X, Y: Index registers (addressing, loops) +- SP: Stack pointer ($100-$1FF range) +- PC: Program counter (current instruction address) +- Flags: N(egative), V(overflow), B(reak), D(ecimal), I(nterrupt), Z(ero), C(arry) + +Use this to: +- Check CPU state at breakpoints +- Understand program flow +- Debug crashes (check PC, SP) + +Related tools: setRegister, step, continue, status`, + }, + async () => { + try { + const response = await client.getRegisters(); + + // Parse register response + // Format: count(2) + [id(1) + size(1) + value(size)]... + const count = response.body.readUInt16LE(0); + const registers: Record = {}; + let offset = 2; + + const regNames: Record = { + 0: "A", + 1: "X", + 2: "Y", + 3: "PC", + 4: "SP", + 5: "FL", // Flags + }; + + for (let i = 0; i < count && offset < response.body.length; i++) { + const id = response.body[offset]; + const size = response.body[offset + 1]; + offset += 2; + + let value = 0; + if (size === 1) { + value = response.body[offset]; + } else if (size === 2) { + value = response.body.readUInt16LE(offset); + } + offset += size; + + const name = regNames[id] || `R${id}`; + registers[name] = value; + } + + // Parse flags + const flags = registers.FL || 0; + const flagsDecoded = { + negative: !!(flags & 0x80), + overflow: !!(flags & 0x40), + break: !!(flags & 0x10), + decimal: !!(flags & 0x08), + interrupt: !!(flags & 0x04), + zero: !!(flags & 0x02), + carry: !!(flags & 0x01), + raw: flags, + }; + + return formatResponse({ + a: { value: registers.A, hex: `$${(registers.A || 0).toString(16).padStart(2, "0")}` }, + x: { value: registers.X, hex: `$${(registers.X || 0).toString(16).padStart(2, "0")}` }, + y: { value: registers.Y, hex: `$${(registers.Y || 0).toString(16).padStart(2, "0")}` }, + sp: { + value: registers.SP, + hex: `$${(registers.SP || 0).toString(16).padStart(2, "0")}`, + stackTop: `$01${(registers.SP || 0).toString(16).padStart(2, "0")}`, + }, + pc: { + value: registers.PC, + hex: `$${(registers.PC || 0).toString(16).padStart(4, "0")}`, + }, + flags: flagsDecoded, + hint: + registers.SP !== undefined && registers.SP < 0x10 + ? "Warning: Stack pointer very low - possible stack overflow" + : registers.SP !== undefined && registers.SP > 0xf0 + ? "Warning: Stack nearly empty - possible stack underflow" + : "CPU state looks normal", + }); + } catch (error) { + return formatError(error as ViceError); + } + } +); + +// Tool: continue - Resume execution +server.registerTool( + "continue", + { + description: `Resume C64 execution after a breakpoint or pause. + +Starts the emulator running until the next breakpoint, manual stop, or error. + +Related tools: step, status, setBreakpoint`, + }, + async () => { + try { + await client.continue(); + return formatResponse({ + resumed: true, + message: "Execution resumed", + hint: "Use status() to check if execution stopped (e.g., at breakpoint)", + }); + } catch (error) { + return formatError(error as ViceError); + } + } +); + +// Tool: step - Single-step execution +server.registerTool( + "step", + { + description: `Execute one or more instructions, then stop. + +Single-stepping is essential for understanding code flow and debugging. + +Options: +- count: Number of instructions to execute (default: 1) +- stepOver: If true, treat JSR as single instruction (don't step into subroutines) + +After stepping, use getRegisters to see the new CPU state. + +Related tools: getRegisters, continue, setBreakpoint, status`, + inputSchema: z.object({ + count: z.number().min(1).optional().describe("Number of instructions to step (default: 1)"), + stepOver: z.boolean().optional().describe("Step over JSR calls instead of into them (default: false)"), + }), + }, + async (args) => { + try { + await client.step(args.count || 1, args.stepOver || false); + + // Get registers after step + const regResponse = await client.getRegisters(); + const count = regResponse.body.readUInt16LE(0); + let offset = 2; + let pc = 0; + + for (let i = 0; i < count && offset < regResponse.body.length; i++) { + const id = regResponse.body[offset]; + const size = regResponse.body[offset + 1]; + offset += 2; + if (id === 3 && size === 2) { + // PC + pc = regResponse.body.readUInt16LE(offset); + } + offset += size; + } + + return formatResponse({ + stepped: true, + count: args.count || 1, + stepOver: args.stepOver || false, + pc: { + value: pc, + hex: `$${pc.toString(16).padStart(4, "0")}`, + }, + message: `Stepped ${args.count || 1} instruction(s)`, + hint: "Use getRegisters() for full CPU state, or readMemory at PC for next instruction", + }); + } catch (error) { + return formatError(error as ViceError); + } + } +); + +// Tool: reset - Reset the C64 +server.registerTool( + "reset", + { + description: `Reset the C64 machine. + +Options: +- hard: If true, performs hard reset (like power cycle). If false, soft reset (like reset button). + +A soft reset preserves some memory contents, hard reset clears everything. + +Related tools: connect, status`, + inputSchema: z.object({ + hard: z.boolean().optional().describe("Hard reset (true) vs soft reset (false, default)"), + }), + }, + async (args) => { + try { + await client.reset(args.hard || false); + return formatResponse({ + reset: true, + type: args.hard ? "hard" : "soft", + message: `${args.hard ? "Hard" : "Soft"} reset performed`, + hint: "C64 is now at startup. Use status() to check state.", + }); + } catch (error) { + return formatError(error as ViceError); + } + } +); + +// Tool: setBreakpoint - Set execution breakpoint +server.registerTool( + "setBreakpoint", + { + description: `Set an execution breakpoint at a memory address. + +When the PC reaches this address, execution stops. Use to: +- Debug code at specific points +- Catch when routines are called +- Analyze code flow + +Returns a breakpoint ID for later management. + +Related tools: deleteBreakpoint, listBreakpoints, continue, step`, + inputSchema: z.object({ + address: z.number().min(0).max(0xffff).describe("Address to break at (0x0000-0xFFFF)"), + enabled: z.boolean().optional().describe("Whether breakpoint is active (default: true)"), + temporary: z.boolean().optional().describe("Auto-delete after hit (default: false)"), + }), + }, + async (args) => { + try { + const id = await client.setBreakpoint(args.address, { + enabled: args.enabled ?? true, + temporary: args.temporary ?? false, + }); + + return formatResponse({ + success: true, + breakpointId: id, + address: { + value: args.address, + hex: `$${args.address.toString(16).padStart(4, "0")}`, + }, + enabled: args.enabled ?? true, + temporary: args.temporary ?? false, + message: `Breakpoint ${id} set at $${args.address.toString(16).padStart(4, "0")}`, + hint: "Use continue() to run until breakpoint is hit", + }); + } catch (error) { + return formatError(error as ViceError); + } + } +); + +// Tool: deleteBreakpoint - Remove a breakpoint +server.registerTool( + "deleteBreakpoint", + { + description: `Delete a breakpoint by its ID. + +Use listBreakpoints to see current breakpoint IDs. + +Related tools: setBreakpoint, listBreakpoints`, + inputSchema: z.object({ + breakpointId: z.number().describe("Breakpoint ID from setBreakpoint"), + }), + }, + async (args) => { + try { + await client.deleteBreakpoint(args.breakpointId); + return formatResponse({ + success: true, + deletedId: args.breakpointId, + message: `Breakpoint ${args.breakpointId} deleted`, + }); + } catch (error) { + return formatError(error as ViceError); + } } ); diff --git a/src/protocol/client.ts b/src/protocol/client.ts new file mode 100644 index 0000000..9b0232a --- /dev/null +++ b/src/protocol/client.ts @@ -0,0 +1,436 @@ +// VICE Binary Monitor Client +import { Socket } from "net"; +import { + STX, + API_VERSION, + Command, + ResponseType, + ErrorCode, + MemorySpace, + ViceResponse, + ConnectionState, +} from "./types.js"; + +export interface ViceError { + error: true; + code: string; + message: string; + suggestion?: string; +} + +export class ViceClient { + private socket: Socket | null = null; + private requestId = 0; + private responseBuffer = Buffer.alloc(0); + private pendingRequests = new Map< + number, + { + resolve: (response: ViceResponse) => void; + reject: (error: ViceError) => void; + } + >(); + private state: ConnectionState = { + connected: false, + host: "", + port: 0, + running: true, + }; + + // Event handlers for async events (breakpoints, etc.) + public onStopped?: (response: ViceResponse) => void; + public onResumed?: (response: ViceResponse) => void; + + getState(): ConnectionState { + return { ...this.state }; + } + + async connect(host = "127.0.0.1", port = 6502): Promise { + if (this.socket) { + throw this.makeError( + "ALREADY_CONNECTED", + "Already connected to VICE", + "Use disconnect() first if you want to reconnect" + ); + } + + return new Promise((resolve, reject) => { + this.socket = new Socket(); + + const timeout = setTimeout(() => { + this.socket?.destroy(); + this.socket = null; + reject( + this.makeError( + "CONNECTION_TIMEOUT", + `Connection to ${host}:${port} timed out after 5 seconds`, + "Ensure VICE is running with -binarymonitor flag: x64sc -binarymonitor -binarymonitoraddress ip4://127.0.0.1:6502" + ) + ); + }, 5000); + + this.socket.on("connect", () => { + clearTimeout(timeout); + this.state = { connected: true, host, port, running: true }; + resolve(); + }); + + this.socket.on("error", (err) => { + clearTimeout(timeout); + this.socket?.destroy(); + this.socket = null; + this.state.connected = false; + reject( + this.makeError( + "CONNECTION_FAILED", + `Failed to connect to ${host}:${port}: ${err.message}`, + "Ensure VICE is running with -binarymonitor flag: x64sc -binarymonitor -binarymonitoraddress ip4://127.0.0.1:6502" + ) + ); + }); + + this.socket.on("close", () => { + this.state.connected = false; + this.socket = null; + // Reject all pending requests + for (const [, { reject: rejectFn }] of this.pendingRequests) { + rejectFn( + this.makeError( + "CONNECTION_CLOSED", + "Connection to VICE closed unexpectedly", + "VICE may have been closed or crashed. Try reconnecting." + ) + ); + } + this.pendingRequests.clear(); + }); + + this.socket.on("data", (data) => this.handleData(data)); + + this.socket.connect(port, host); + }); + } + + async disconnect(): Promise { + if (!this.socket) { + return; + } + + return new Promise((resolve) => { + this.socket!.once("close", () => { + this.state.connected = false; + resolve(); + }); + this.socket!.end(); + }); + } + + private makeError(code: string, message: string, suggestion?: string): ViceError { + return { error: true, code, message, suggestion }; + } + + private nextRequestId(): number { + this.requestId = (this.requestId + 1) & 0xff; + return this.requestId; + } + + private handleData(data: Buffer): void { + this.responseBuffer = Buffer.concat([this.responseBuffer, data]); + + // Process complete packets + while (this.responseBuffer.length >= 9) { + // Minimum header size + const stx = this.responseBuffer[0]; + if (stx !== STX) { + // Protocol error, skip byte + this.responseBuffer = this.responseBuffer.subarray(1); + continue; + } + + const bodyLength = this.responseBuffer.readUInt32LE(2); + const totalLength = 6 + bodyLength; // Header (6) + body + + if (this.responseBuffer.length < totalLength) { + // Wait for more data + break; + } + + // Parse complete packet + const responseType = this.responseBuffer[6] as ResponseType; + const errorCode = this.responseBuffer[7] as ErrorCode; + const requestId = this.responseBuffer[8]; + const body = this.responseBuffer.subarray(9, totalLength); + + const response: ViceResponse = { + responseType, + errorCode, + requestId, + body, + }; + + // Remove processed packet from buffer + this.responseBuffer = this.responseBuffer.subarray(totalLength); + + // Handle response + this.handleResponse(response); + } + } + + private handleResponse(response: ViceResponse): void { + // Check for async events + if (response.responseType === ResponseType.Stopped || response.responseType === ResponseType.CheckpointHit) { + this.state.running = false; + this.onStopped?.(response); + return; + } + + if (response.responseType === ResponseType.Resumed) { + this.state.running = true; + this.onResumed?.(response); + return; + } + + // Match to pending request + const pending = this.pendingRequests.get(response.requestId); + if (pending) { + this.pendingRequests.delete(response.requestId); + if (response.errorCode !== ErrorCode.Ok) { + pending.reject( + this.makeError( + `VICE_ERROR_${response.errorCode}`, + `VICE returned error code ${response.errorCode}`, + this.getErrorSuggestion(response.errorCode) + ) + ); + } else { + pending.resolve(response); + } + } + } + + private getErrorSuggestion(code: ErrorCode): string { + switch (code) { + case ErrorCode.ObjectMissing: + return "The requested object (checkpoint, etc.) does not exist"; + case ErrorCode.InvalidMemspace: + return "Invalid memory space specified. Use 0 for main CPU memory."; + case ErrorCode.InvalidCmdLength: + return "Command packet has invalid length - this is likely a protocol bug"; + case ErrorCode.InvalidParameter: + return "Invalid parameter value - check address ranges (0x0000-0xFFFF for C64)"; + default: + return "Check VICE console for more details"; + } + } + + private async sendCommand(command: Command, body: Buffer = Buffer.alloc(0)): Promise { + if (!this.socket || !this.state.connected) { + throw this.makeError( + "NOT_CONNECTED", + "Not connected to VICE", + "Use connect() first to establish connection" + ); + } + + const requestId = this.nextRequestId(); + + // Build packet: STX (1) + API (1) + Length (4) + RequestID (1) + Command (1) + Body + const header = Buffer.alloc(8); + header[0] = STX; + header[1] = API_VERSION; + header.writeUInt32LE(body.length + 2, 2); // Body length includes request ID and command + header[6] = requestId; + header[7] = command; + + const packet = Buffer.concat([header, body]); + + return new Promise((resolve, reject) => { + this.pendingRequests.set(requestId, { resolve, reject }); + + this.socket!.write(packet, (err) => { + if (err) { + this.pendingRequests.delete(requestId); + reject( + this.makeError( + "SEND_FAILED", + `Failed to send command: ${err.message}`, + "Connection may have been lost. Try reconnecting." + ) + ); + } + }); + + // Timeout for response + setTimeout(() => { + if (this.pendingRequests.has(requestId)) { + this.pendingRequests.delete(requestId); + reject( + this.makeError( + "RESPONSE_TIMEOUT", + "Timeout waiting for VICE response", + "VICE may be busy or unresponsive. Try again or reconnect." + ) + ); + } + }, 10000); + }); + } + + // High-level commands + + async readMemory( + startAddress: number, + endAddress: number, + memspace: MemorySpace = MemorySpace.MainCPU + ): Promise { + // Validate addresses + if (startAddress < 0 || startAddress > 0xffff) { + throw this.makeError( + "INVALID_ADDRESS", + `Start address 0x${startAddress.toString(16)} is outside C64 memory range`, + "C64 addresses are 16-bit (0x0000-0xFFFF)" + ); + } + if (endAddress < 0 || endAddress > 0xffff) { + throw this.makeError( + "INVALID_ADDRESS", + `End address 0x${endAddress.toString(16)} is outside C64 memory range`, + "C64 addresses are 16-bit (0x0000-0xFFFF)" + ); + } + if (startAddress > endAddress) { + throw this.makeError( + "INVALID_RANGE", + `Start address (0x${startAddress.toString(16)}) is greater than end address (0x${endAddress.toString(16)})`, + "Swap the addresses or check your range" + ); + } + + // Build request: side_effects(1) + start(2) + memspace(1) + end(2) + const body = Buffer.alloc(6); + body[0] = 0; // No side effects + body.writeUInt16LE(startAddress, 1); + body[3] = memspace; + body.writeUInt16LE(endAddress, 4); + + const response = await this.sendCommand(Command.MemoryGet, body); + + // Response body: length(2) + data(N) + const dataLength = response.body.readUInt16LE(0); + return response.body.subarray(2, 2 + dataLength); + } + + async writeMemory( + address: number, + data: Buffer | number[], + memspace: MemorySpace = MemorySpace.MainCPU + ): Promise { + const dataBuffer = Buffer.isBuffer(data) ? data : Buffer.from(data); + + if (address < 0 || address > 0xffff) { + throw this.makeError( + "INVALID_ADDRESS", + `Address 0x${address.toString(16)} is outside C64 memory range`, + "C64 addresses are 16-bit (0x0000-0xFFFF)" + ); + } + + if (dataBuffer.length === 0) { + throw this.makeError( + "INVALID_DATA", + "Cannot write empty data", + "Provide at least one byte to write" + ); + } + + if (address + dataBuffer.length > 0x10000) { + throw this.makeError( + "INVALID_RANGE", + `Write would extend past end of memory (0x${address.toString(16)} + ${dataBuffer.length} bytes)`, + "Reduce data length or use a lower start address" + ); + } + + // Build request: side_effects(1) + start(2) + memspace(1) + length-1(1) + data(N) + const body = Buffer.alloc(5 + dataBuffer.length); + body[0] = 0; // No side effects + body.writeUInt16LE(address, 1); + body[3] = memspace; + body[4] = dataBuffer.length - 1; + dataBuffer.copy(body, 5); + + await this.sendCommand(Command.MemorySet, body); + } + + async getRegisters(memspace: MemorySpace = MemorySpace.MainCPU): Promise { + const body = Buffer.alloc(1); + body[0] = memspace; + return this.sendCommand(Command.RegistersGet, body); + } + + async continue(): Promise { + await this.sendCommand(Command.Continue); + this.state.running = true; + } + + async step(count = 1, stepOver = false): Promise { + const body = Buffer.alloc(3); + body[0] = stepOver ? 1 : 0; + body.writeUInt16LE(count, 1); + const response = await this.sendCommand(Command.Step, body); + this.state.running = false; + return response; + } + + async reset(hard = false): Promise { + const body = Buffer.alloc(1); + body[0] = hard ? 1 : 0; + await this.sendCommand(Command.Reset, body); + } + + async setBreakpoint( + address: number, + options: { + enabled?: boolean; + stop?: boolean; + temporary?: boolean; + } = {} + ): Promise { + const { enabled = true, stop = true, temporary = false } = options; + + if (address < 0 || address > 0xffff) { + throw this.makeError( + "INVALID_ADDRESS", + `Address 0x${address.toString(16)} is outside C64 memory range`, + "C64 addresses are 16-bit (0x0000-0xFFFF)" + ); + } + + // Build request: start(2) + end(2) + stop(1) + enabled(1) + op(1) + temp(1) + const body = Buffer.alloc(8); + body.writeUInt16LE(address, 0); + body.writeUInt16LE(address, 2); + body[4] = stop ? 1 : 0; + body[5] = enabled ? 1 : 0; + body[6] = 0x01; // Exec + body[7] = temporary ? 1 : 0; + + const response = await this.sendCommand(Command.CheckpointSet, body); + return response.body.readUInt32LE(0); + } + + async deleteBreakpoint(checkpointId: number): Promise { + const body = Buffer.alloc(4); + body.writeUInt32LE(checkpointId, 0); + await this.sendCommand(Command.CheckpointDelete, body); + } +} + +// Singleton instance +let clientInstance: ViceClient | null = null; + +export function getViceClient(): ViceClient { + if (!clientInstance) { + clientInstance = new ViceClient(); + } + return clientInstance; +} diff --git a/src/protocol/index.ts b/src/protocol/index.ts new file mode 100644 index 0000000..0fdca2f --- /dev/null +++ b/src/protocol/index.ts @@ -0,0 +1,2 @@ +export * from "./types.js"; +export * from "./client.js"; diff --git a/src/protocol/types.ts b/src/protocol/types.ts new file mode 100644 index 0000000..42c9a26 --- /dev/null +++ b/src/protocol/types.ts @@ -0,0 +1,73 @@ +// VICE Binary Monitor Protocol Types + +// API Constants +export const STX = 0x02; +export const API_VERSION = 0x02; + +// Command codes +export enum Command { + MemoryGet = 0x01, + MemorySet = 0x02, + CheckpointSet = 0x11, + CheckpointDelete = 0x13, + RegistersGet = 0x22, + Continue = 0x31, + Step = 0x32, + Reset = 0x43, + Exit = 0x71, +} + +// Response types +export enum ResponseType { + Invalid = 0x00, + Ok = 0x01, + Object = 0x02, + Stopped = 0x11, + Resumed = 0x12, + MemoryGet = 0x31, + RegisterInfo = 0x62, + CheckpointHit = 0x63, +} + +// Memory spaces +export enum MemorySpace { + MainCPU = 0, + Drive8 = 1, + Drive9 = 2, + Drive10 = 3, + Drive11 = 4, +} + +// Checkpoint (breakpoint) operation types +export enum CheckpointOp { + Exec = 0x01, + Load = 0x02, + Store = 0x04, +} + +// Error codes +export enum ErrorCode { + Ok = 0x00, + ObjectMissing = 0x01, + InvalidMemspace = 0x02, + InvalidCmdLength = 0x80, + InvalidParameterLength = 0x81, + InvalidAPI = 0x82, + InvalidCmdType = 0x83, + InvalidTarget = 0x84, + InvalidParameter = 0x85, +} + +export interface ViceResponse { + responseType: ResponseType; + errorCode: ErrorCode; + requestId: number; + body: Buffer; +} + +export interface ConnectionState { + connected: boolean; + host: string; + port: number; + running: boolean; +}