Add semantic layer tools for interpreted C64 debugging

New tools:
- readScreen: Screen RAM as ASCII text with non-empty line summary
- readColorRam: Color RAM with color names and usage statistics
- readVicState: Full VIC-II state with interpreted values
  - Graphics mode detection
  - Memory bank/address calculation
  - Sprite enable summary
- readSprites: All 8 sprites with position, visibility, colors
  - Visibility checks with explanations
  - Data pointer address resolution
  - enabledOnly filtering option

Utilities (src/utils/c64.ts):
- PETSCII screen code to ASCII conversion
- C64 color palette names
- VIC bank and memory address calculation
- Graphics mode detection
- Sprite visibility range checks

AX patterns applied:
- Summary modes (readColorRam summary option)
- Filtering (readSprites enabledOnly option)
- Rich hints explaining issues (sprite visibility)
- Proactive issue detection

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Simen Svale 2025-12-30 01:56:03 +01:00
parent 84b10689d0
commit 37f53616ff
3 changed files with 608 additions and 0 deletions

View file

@ -4,6 +4,14 @@ 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";
import {
getColorInfo,
screenToText,
getVicBank,
getVideoAddresses,
getGraphicsMode,
isSpriteVisible,
} from "./utils/index.js";
const server = new McpServer({
name: "vice-mcp",
@ -647,6 +655,425 @@ Related tools: setBreakpoint, deleteBreakpoint`,
}
);
// =============================================================================
// SEMANTIC LAYER TOOLS - Interpreted output for autonomous debugging
// =============================================================================
// Tool: readScreen - Get screen contents as text
server.registerTool(
"readScreen",
{
description: `Read the C64 screen memory and return it as interpreted text.
Converts PETSCII screen codes to readable ASCII. Returns 25 lines of 40 characters.
Use this instead of readMemory($0400) when you want to see what's displayed on screen.
Note: This reads from the current screen RAM location (may not be $0400 if the program moved it).
In bitmap modes, the data won't represent text.
Options:
- includeRaw: Also return raw screen codes (default: false)
Related tools: readColorRam, readVicState, readMemory`,
inputSchema: z.object({
includeRaw: z
.boolean()
.optional()
.describe("Include raw screen codes array (default: false)"),
}),
},
async (args) => {
try {
// First get VIC bank from CIA2
const cia2Data = await client.readMemory(0xdd00, 0xdd00);
const bankInfo = getVicBank(cia2Data[0]);
// Get screen address from $D018
const d018Data = await client.readMemory(0xd018, 0xd018);
const videoAddrs = getVideoAddresses(d018Data[0], bankInfo.baseAddress);
// Read screen RAM (1000 bytes)
const screenData = await client.readMemory(
videoAddrs.screenAddress,
videoAddrs.screenAddress + 999
);
// Convert to text
const textLines = screenToText(screenData);
// Find non-empty lines for summary
const nonEmptyLines = textLines
.map((line, idx) => ({ line: idx, content: line }))
.filter((l) => l.content.trim().length > 0);
const response: Record<string, unknown> = {
screenAddress: {
value: videoAddrs.screenAddress,
hex: `$${videoAddrs.screenAddress.toString(16).padStart(4, "0")}`,
},
vicBank: bankInfo.bank,
lines: textLines,
summary: {
nonEmptyLines: nonEmptyLines.length,
preview:
nonEmptyLines.length > 0
? nonEmptyLines.slice(0, 3).map((l) => `Line ${l.line}: "${l.content}"`)
: ["Screen appears empty"],
},
};
if (args.includeRaw) {
response.raw = Array.from(screenData);
}
// Check graphics mode and add hint
const d011Data = await client.readMemory(0xd011, 0xd011);
const d016Data = await client.readMemory(0xd016, 0xd016);
const graphicsMode = getGraphicsMode(d011Data[0], d016Data[0]);
response.graphicsMode = graphicsMode.mode;
if (graphicsMode.bitmap) {
response.hint =
"Warning: VIC-II is in bitmap mode - screen RAM contains bitmap data, not text.";
} else if (nonEmptyLines.length === 0) {
response.hint = "Screen appears empty or contains only spaces.";
} else {
response.hint = `Screen has ${nonEmptyLines.length} non-empty line(s). First: "${nonEmptyLines[0]?.content || ""}"`;
}
return formatResponse(response);
} catch (error) {
return formatError(error as ViceError);
}
}
);
// Tool: readColorRam - Get color RAM state
server.registerTool(
"readColorRam",
{
description: `Read color RAM ($D800-$DBE7) and return color values with names.
Color RAM determines the foreground color of each character on screen.
Returns:
- 25x40 grid of color values (0-15) with names
- Summary of colors used
Related tools: readScreen, readVicState`,
inputSchema: z.object({
summary: z
.boolean()
.optional()
.describe("Return only color usage summary, not full grid (default: false)"),
}),
},
async (args) => {
try {
// Color RAM is always at $D800
const colorData = await client.readMemory(0xd800, 0xd800 + 999);
// Count color usage
const colorCounts = new Map<number, number>();
for (const byte of colorData) {
const color = byte & 0x0f;
colorCounts.set(color, (colorCounts.get(color) || 0) + 1);
}
// Sort by frequency
const colorUsage = Array.from(colorCounts.entries())
.sort((a, b) => b[1] - a[1])
.map(([color, count]) => ({
color: getColorInfo(color),
count,
percentage: Math.round((count / 1000) * 100),
}));
const response: Record<string, unknown> = {
address: { value: 0xd800, hex: "$D800" },
summary: {
uniqueColors: colorUsage.length,
dominantColor: colorUsage[0]?.color || null,
usage: colorUsage,
},
};
if (!args.summary) {
// Convert to 25 lines of 40 color values
const colorLines: Array<Array<{ value: number; name: string }>> = [];
for (let row = 0; row < 25; row++) {
const line: Array<{ value: number; name: string }> = [];
for (let col = 0; col < 40; col++) {
const offset = row * 40 + col;
line.push(getColorInfo(colorData[offset]));
}
colorLines.push(line);
}
response.grid = colorLines;
}
response.hint =
colorUsage.length === 1
? `Entire screen uses ${colorUsage[0].color.name} (${colorUsage[0].color.value})`
: `${colorUsage.length} colors used. Dominant: ${colorUsage[0]?.color.name} (${colorUsage[0]?.percentage}%)`;
return formatResponse(response);
} catch (error) {
return formatError(error as ViceError);
}
}
);
// Tool: readVicState - Full VIC-II chip state
server.registerTool(
"readVicState",
{
description: `Read the full VIC-II state with interpreted values.
Returns all VIC-II registers with semantic meaning:
- Border and background colors (with names)
- Graphics mode (text, bitmap, multicolor, etc.)
- Screen and character memory locations
- Scroll values
- Raster position
- Sprite enable bits
This is the high-level view of the video chip. Use for understanding display configuration.
Related tools: readScreen, readSprites, readMemory (for $D000-$D02E)`,
},
async () => {
try {
// Read all VIC registers $D000-$D02E (47 bytes)
const vicData = await client.readMemory(0xd000, 0xd02e);
// Read CIA2 for bank info
const cia2Data = await client.readMemory(0xdd00, 0xdd00);
const bankInfo = getVicBank(cia2Data[0]);
const d011 = vicData[0x11];
const d016 = vicData[0x16];
const d018 = vicData[0x18];
const graphicsMode = getGraphicsMode(d011, d016);
const videoAddrs = getVideoAddresses(d018, bankInfo.baseAddress);
// Raster position (9-bit)
const rasterLine = vicData[0x12] | ((d011 & 0x80) << 1);
// Sprite enable
const spriteEnable = vicData[0x15];
const enabledSprites = [];
for (let i = 0; i < 8; i++) {
if (spriteEnable & (1 << i)) enabledSprites.push(i);
}
// Display enable
const displayEnabled = !!(d011 & 0x10);
const response = {
// Colors
borderColor: getColorInfo(vicData[0x20]),
backgroundColor: [
getColorInfo(vicData[0x21]),
getColorInfo(vicData[0x22]),
getColorInfo(vicData[0x23]),
getColorInfo(vicData[0x24]),
],
// Graphics mode
graphicsMode: graphicsMode.mode,
displayEnabled,
bitmap: graphicsMode.bitmap,
multicolor: graphicsMode.multicolor,
extendedColor: graphicsMode.extendedColor,
// Screen geometry
rows: d011 & 0x08 ? 25 : 24,
columns: d016 & 0x08 ? 40 : 38,
scrollX: d016 & 0x07,
scrollY: d011 & 0x07,
// Memory setup
vicBank: {
bank: bankInfo.bank,
baseAddress: {
value: bankInfo.baseAddress,
hex: `$${bankInfo.baseAddress.toString(16).padStart(4, "0")}`,
},
},
screenAddress: {
value: videoAddrs.screenAddress,
hex: `$${videoAddrs.screenAddress.toString(16).padStart(4, "0")}`,
},
charAddress: {
value: videoAddrs.charAddress,
hex: `$${videoAddrs.charAddress.toString(16).padStart(4, "0")}`,
},
// Raster
rasterLine,
// Sprites summary
spriteEnable: {
value: spriteEnable,
binary: spriteEnable.toString(2).padStart(8, "0"),
enabledSprites,
count: enabledSprites.length,
},
// Sprite multicolor registers
spriteMulticolor0: getColorInfo(vicData[0x25]),
spriteMulticolor1: getColorInfo(vicData[0x26]),
hint: !displayEnabled
? "Display is blanked (DEN=0) - screen shows border color only"
: enabledSprites.length > 0
? `${graphicsMode.mode} mode, ${enabledSprites.length} sprite(s) enabled. Use readSprites() for sprite details.`
: `${graphicsMode.mode} mode, no sprites enabled.`,
};
return formatResponse(response);
} catch (error) {
return formatError(error as ViceError);
}
}
);
// Tool: readSprites - Get detailed sprite state
server.registerTool(
"readSprites",
{
description: `Read state of all 8 hardware sprites with interpreted values.
Returns for each sprite:
- Position (X, Y) with visibility check
- Color (with name)
- Enable status
- Multicolor mode
- X/Y expansion (double size)
- Priority (in front of / behind background)
- Data pointer address
Use this to debug sprite issues like:
- "Why is my sprite invisible?" check enabled, position, pointer
- "Wrong colors?" check multicolor mode and color registers
- "Wrong size?" check expand flags
Options:
- enabledOnly: Only return enabled sprites (default: false)
Related tools: readVicState, readMemory (for sprite data)`,
inputSchema: z.object({
enabledOnly: z
.boolean()
.optional()
.describe("Only return enabled sprites (default: false)"),
}),
},
async (args) => {
try {
// Read VIC registers
const vicData = await client.readMemory(0xd000, 0xd02e);
// Read CIA2 for bank info (needed for sprite pointer calculation)
const cia2Data = await client.readMemory(0xdd00, 0xdd00);
const bankInfo = getVicBank(cia2Data[0]);
// Get screen address for sprite pointers
const d018 = vicData[0x18];
const videoAddrs = getVideoAddresses(d018, bankInfo.baseAddress);
// Sprite pointers are at screen + $3F8
const spritePointerBase = videoAddrs.screenAddress + 0x3f8;
const spritePointers = await client.readMemory(
spritePointerBase,
spritePointerBase + 7
);
const spriteEnable = vicData[0x15];
const spriteXMsb = vicData[0x10];
const spriteYExpand = vicData[0x17];
const spriteXExpand = vicData[0x1d];
const spriteMulticolor = vicData[0x1c];
const spritePriority = vicData[0x1b];
const sprites = [];
for (let i = 0; i < 8; i++) {
const enabled = !!(spriteEnable & (1 << i));
// Skip disabled sprites if enabledOnly
if (args.enabledOnly && !enabled) continue;
// X position (9-bit)
const xLow = vicData[i * 2];
const xHigh = (spriteXMsb & (1 << i)) ? 256 : 0;
const x = xLow + xHigh;
// Y position (8-bit)
const y = vicData[i * 2 + 1];
const visibility = isSpriteVisible(x, y, enabled);
// Sprite data address
const pointer = spritePointers[i];
const dataAddress = bankInfo.baseAddress + pointer * 64;
sprites.push({
index: i,
enabled,
position: {
x,
y,
visible: visibility.visible,
visibilityReason: visibility.reason,
},
color: getColorInfo(vicData[0x27 + i]),
multicolor: !!(spriteMulticolor & (1 << i)),
expandX: !!(spriteXExpand & (1 << i)),
expandY: !!(spriteYExpand & (1 << i)),
priority: (spritePriority & (1 << i)) ? "behind" : "front",
pointer: {
value: pointer,
hex: `$${pointer.toString(16).padStart(2, "0")}`,
},
dataAddress: {
value: dataAddress,
hex: `$${dataAddress.toString(16).padStart(4, "0")}`,
},
});
}
const enabledCount = sprites.filter((s) => s.enabled).length;
const visibleCount = sprites.filter((s) => s.position.visible).length;
const issues = sprites
.filter((s) => s.enabled && !s.position.visible)
.map((s) => `Sprite ${s.index}: ${s.position.visibilityReason}`);
return formatResponse({
count: sprites.length,
enabledCount,
visibleCount,
sprites,
spriteMulticolor0: getColorInfo(vicData[0x25]),
spriteMulticolor1: getColorInfo(vicData[0x26]),
issues: issues.length > 0 ? issues : undefined,
hint:
issues.length > 0
? `${issues.length} enabled sprite(s) not visible: ${issues[0]}`
: enabledCount === 0
? "No sprites enabled"
: `${enabledCount} sprite(s) enabled, ${visibleCount} visible`,
});
} catch (error) {
return formatError(error as ViceError);
}
}
);
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);

180
src/utils/c64.ts Normal file
View file

@ -0,0 +1,180 @@
// C64 Constants and Utilities
// Color palette names
export const C64_COLORS = [
"black",
"white",
"red",
"cyan",
"purple",
"green",
"blue",
"yellow",
"orange",
"brown",
"light red",
"dark gray",
"gray",
"light green",
"light blue",
"light gray",
] as const;
export type C64Color = (typeof C64_COLORS)[number];
export function getColorName(value: number): C64Color {
return C64_COLORS[value & 0x0f];
}
export function getColorInfo(value: number): { value: number; name: C64Color } {
return {
value: value & 0x0f,
name: getColorName(value),
};
}
// PETSCII to ASCII conversion (screen codes, not PETSCII character codes)
// Screen codes are different from PETSCII - this handles the screen memory values
export function screenCodeToAscii(code: number): string {
// Screen codes 0-31: @, A-Z, [, £, ], ↑, ←
if (code <= 31) {
if (code === 0) return "@";
if (code <= 26) return String.fromCharCode(64 + code); // A-Z
if (code === 27) return "[";
if (code === 28) return "£";
if (code === 29) return "]";
if (code === 30) return "^"; // up arrow
if (code === 31) return "<"; // left arrow
}
// 32-63: space, !, ", #, $, %, &, ', (, ), *, +, ,, -, ., /, 0-9, :, ;, <, =, >, ?
if (code >= 32 && code <= 63) {
return String.fromCharCode(code);
}
// 64-95: horizontal line, graphics chars - use placeholders
if (code >= 64 && code <= 95) {
return "#"; // graphics placeholder
}
// 96-127: more graphics
if (code >= 96 && code <= 127) {
return "#"; // graphics placeholder
}
// 128-255: reverse video versions of 0-127
if (code >= 128) {
// Just show the base character (non-reversed)
return screenCodeToAscii(code - 128);
}
return "?";
}
// Convert screen RAM to text lines
export function screenToText(screenData: Buffer | number[]): string[] {
const data = Buffer.isBuffer(screenData) ? screenData : Buffer.from(screenData);
const lines: string[] = [];
for (let row = 0; row < 25; row++) {
const offset = row * 40;
let line = "";
for (let col = 0; col < 40; col++) {
if (offset + col < data.length) {
line += screenCodeToAscii(data[offset + col]);
}
}
// Trim trailing spaces but keep the line
lines.push(line.trimEnd());
}
return lines;
}
// VIC-II memory bank calculation
export function getVicBank(cia2PortA: number): { bank: number; baseAddress: number } {
// CIA2 port A bits 0-1 (inverted) select the bank
const bankBits = (~cia2PortA) & 0x03;
return {
bank: bankBits,
baseAddress: bankBits * 0x4000,
};
}
// Screen and character base from $D018
export function getVideoAddresses(
d018: number,
bankBase: number
): { screenAddress: number; charAddress: number } {
const screenOffset = ((d018 >> 4) & 0x0f) * 0x0400;
const charOffset = ((d018 >> 1) & 0x07) * 0x0800;
return {
screenAddress: bankBase + screenOffset,
charAddress: bankBase + charOffset,
};
}
// Graphics mode from D011 and D016
export function getGraphicsMode(d011: number, d016: number): {
mode: string;
bitmap: boolean;
multicolor: boolean;
extendedColor: boolean;
} {
const ecm = !!(d011 & 0x40);
const bmm = !!(d011 & 0x20);
const mcm = !!(d016 & 0x10);
let mode = "standard text";
if (ecm && !bmm && !mcm) mode = "extended background color";
else if (!ecm && !bmm && mcm) mode = "multicolor text";
else if (!ecm && bmm && !mcm) mode = "standard bitmap";
else if (!ecm && bmm && mcm) mode = "multicolor bitmap";
else if (ecm) mode = "invalid (ECM + other modes)";
return {
mode,
bitmap: bmm,
multicolor: mcm,
extendedColor: ecm,
};
}
// Sprite position visible range helpers
export const SPRITE_VISIBLE_X_MIN = 24;
export const SPRITE_VISIBLE_X_MAX = 343;
export const SPRITE_VISIBLE_Y_MIN = 50;
export const SPRITE_VISIBLE_Y_MAX = 249;
export function isSpriteVisible(x: number, y: number, enabled: boolean): {
visible: boolean;
reason?: string;
} {
if (!enabled) {
return { visible: false, reason: "Sprite is disabled ($D015)" };
}
if (x < SPRITE_VISIBLE_X_MIN || x > SPRITE_VISIBLE_X_MAX) {
return { visible: false, reason: `X position ${x} is outside visible range (${SPRITE_VISIBLE_X_MIN}-${SPRITE_VISIBLE_X_MAX})` };
}
if (y < SPRITE_VISIBLE_Y_MIN || y > SPRITE_VISIBLE_Y_MAX) {
return { visible: false, reason: `Y position ${y} is outside visible range (${SPRITE_VISIBLE_Y_MIN}-${SPRITE_VISIBLE_Y_MAX})` };
}
return { visible: true };
}
// Memory region descriptions
export function describeAddress(address: number): string {
if (address < 0x0100) return "Zero page";
if (address < 0x0200) return "Stack";
if (address >= 0x0400 && address < 0x0800) return "Default screen RAM";
if (address >= 0x0800 && address < 0x1000) return "Default char ROM shadow";
if (address >= 0xa000 && address < 0xc000) return "BASIC ROM / RAM";
if (address >= 0xd000 && address < 0xd400) return "VIC-II registers";
if (address >= 0xd400 && address < 0xd800) return "SID registers";
if (address >= 0xd800 && address < 0xdc00) return "Color RAM";
if (address >= 0xdc00 && address < 0xdd00) return "CIA1 registers";
if (address >= 0xdd00 && address < 0xde00) return "CIA2 registers";
if (address >= 0xe000) return "KERNAL ROM / RAM";
return "";
}

1
src/utils/index.ts Normal file
View file

@ -0,0 +1 @@
export * from "./c64.js";