ppsspp_screenshot
Capture PSP framebuffer as a PNG-encoded screenshot for visual inspection or sequence documentation. Choose 'render' source for safe native capture, or 'output' for post-processed image.
Instructions
PURPOSE: Capture the current PSP framebuffer as a PNG-encoded screenshot. USAGE: For visual inspection or sequence documentation. Default 'render' source reads the active GPU render target — safer, native 480x272, what the PSP CPU asked the GPU to draw. Opt-in 'output' source reads PPSSPP's final composited output (post scaling/shaders) but can crash PPSSPP on games whose output framebuffer state confuses GPU_GetOutputFramebuffer (a real upstream bug — an assert that should be a graceful failure). Prefer 'render' unless you specifically need the post-processed image. BEHAVIOR: Transparently pauses the CPU (cpu.stepping), captures, then resumes — both PPSSPP buffer events require stepping. If the emulator was already paused, leaves it paused. Returns an error if no game is loaded. The 'output' source CAN crash PPSSPP on certain games; if it does, MCP auto-reconnects to the relaunched PPSSPP cleanly. RETURNS: Text confirmation + inline PNG image block.
Input Schema
| Name | Required | Description | Default |
|---|---|---|---|
| source | No | Which GPU buffer to capture. 'render' (default) reads the current render target via gpu.buffer.renderColor — native PSP 480x272, safer on homebrew/edge-case games. 'output' reads the final composited framebuffer via gpu.buffer.screenshot — post-processed (matches what's on screen) but can crash PPSSPP on games where GPU_GetOutputFramebuffer trips its null-buf assertion. |
Implementation Reference
- src/tools.ts:317-335 (schema)Schema/definition for the ppsspp_screenshot tool, registered in the TOOLS array with name, description, and inputSchema (accepts optional 'source' parameter: 'render' or 'output').
{ name: "ppsspp_screenshot", description: "PURPOSE: Capture the current PSP framebuffer as a PNG-encoded screenshot. " + "USAGE: For visual inspection or sequence documentation. Default 'render' source reads the active GPU render target — safer, native 480x272, what the PSP CPU asked the GPU to draw. Opt-in 'output' source reads PPSSPP's final composited output (post scaling/shaders) but can crash PPSSPP on games whose output framebuffer state confuses GPU_GetOutputFramebuffer (a real upstream bug — an _assert_ that should be a graceful failure). Prefer 'render' unless you specifically need the post-processed image. " + "BEHAVIOR: Transparently pauses the CPU (cpu.stepping), captures, then resumes — both PPSSPP buffer events require stepping. If the emulator was already paused, leaves it paused. Returns an error if no game is loaded. The 'output' source CAN crash PPSSPP on certain games; if it does, MCP auto-reconnects to the relaunched PPSSPP cleanly. " + "RETURNS: Text confirmation + inline PNG image block.", inputSchema: { type: "object", properties: { source: { type: "string", enum: ["render", "output"], description: "Which GPU buffer to capture. 'render' (default) reads the current render target via gpu.buffer.renderColor — native PSP 480x272, safer on homebrew/edge-case games. 'output' reads the final composited framebuffer via gpu.buffer.screenshot — post-processed (matches what's on screen) but can crash PPSSPP on games where GPU_GetOutputFramebuffer trips its null-buf assertion.", }, }, additionalProperties: false, }, }, - src/tools.ts:512-565 (handler)Handler for ppsspp_screenshot: pauses CPU if needed, captures PNG via gpu.buffer.renderColor or gpu.buffer.screenshot, returns text+image content, then resumes emulation if it was running.
case "ppsspp_screenshot": { // PPSSPP's gpu.buffer.* events all require CORE_STEPPING_CPU (or GPU // stepping) state — they fail with "Neither CPU or GPU is stepping" // otherwise. We transparently pause→capture→resume so callers can // screenshot any time without managing pause state. If the emulator // was already paused, we leave it paused. // // source='render' (default) uses gpu.buffer.renderColor → reads the // active GPU render target. Safer: GPU_GetCurrentFramebuffer hits a // different code path than the crash-prone GPU_GetOutputFramebuffer. // // source='output' uses gpu.buffer.screenshot → reads the final // composited output (what's on screen, post scaling/shaders). Can // CRASH PPSSPP on some games: upstream has an `_assert_(buf != nullptr)` // after GPU_GetOutputFramebuffer that fires when the function returns // true with a null buffer (observed on some homebrew). We can't catch // a process abort from outside, but v0.1.2's auto-reconnect means MCP // recovers when PPSSPP is relaunched. const source = (p.source as string | undefined) ?? "render"; const event = source === "output" ? "gpu.buffer.screenshot" : "gpu.buffer.renderColor"; const statusBefore = await pp.call<{ stepping?: boolean; paused?: boolean }>("cpu.status"); const wasStepping = !!statusBefore.stepping; if (!wasStepping) { await pp.fireAndForget("cpu.stepping"); await pp.waitForState((s) => s.stepping === true); } try { // type: "base64" returns the raw base64 payload; the default "uri" // returns a "data:image/png;base64,..." prefix which we'd have to strip. const r = await pp.call<{ base64?: string; uri?: string }>(event, { type: "base64" }); let b64 = r.base64; if (!b64 && r.uri) { // Belt-and-suspenders: if PPSSPP returned a URI anyway, strip the prefix. const m = /^data:image\/png;base64,(.*)$/.exec(r.uri); if (m) b64 = m[1]; } if (!b64) { throw new Error(`PPSSPP did not return screenshot data from ${event} (no game loaded, or framebuffer not readable?)`); } return { content: [ { type: "text" as const, text: `Screenshot captured (source: ${source}, event: ${event}).` }, { type: "image" as const, data: b64, mimeType: "image/png" }, ], }; } finally { if (!wasStepping) { try { await pp.fireAndForget("cpu.resume"); await pp.waitForState((s) => s.stepping === false, { timeoutMs: 2000 }); } catch { /* best-effort */ } } } } - src/tools.ts:405-613 (registration)Registration of all tools including ppsspp_screenshot via registerTools(), which sets up ListToolsRequestSchema and CallToolRequestSchema handlers. The switch/case dispatches to the screenshot handler at line 512.
export function registerTools(server: Server, pp: PpssppClient): void { server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS })); server.setRequestHandler(CallToolRequestSchema, async (req) => { const { name, arguments: args = {} } = req.params; const p = args as Record<string, unknown>; const a = () => p.address as number; switch (name) { case "ppsspp_ping": { const r = await pp.call<{ version?: string; name?: string }>("version"); return ok(`pong (${r.name ?? "PPSSPP"} ${r.version ?? "(unknown version)"})`); } case "ppsspp_get_info": { const status = await pp.call<{ game?: { id?: string; title?: string; version?: string } | null; paused?: boolean; stepping?: boolean }>("game.status"); const lines: string[] = []; if (status.game) { lines.push(`Title: ${status.game.title ?? "(unavailable)"}`); lines.push(`Disc ID: ${status.game.id ?? "(unavailable)"}`); lines.push(`Version: ${status.game.version ?? "(unavailable)"}`); } else { lines.push("No game loaded."); } const state = status.stepping ? "stepping (paused)" : status.paused ? "paused" : "running"; lines.push(`State: ${state}`); return ok(lines.join("\n")); } case "ppsspp_read8": { const r = await pp.call<{ value: number }>("memory.read_u8", { address: a() }); return ok(`${addrHex(a())}: ${fmtHex(r.value)}`); } case "ppsspp_read16": { const r = await pp.call<{ value: number }>("memory.read_u16", { address: a() }); return ok(`${addrHex(a())}: ${fmtHex(r.value)}`); } case "ppsspp_read32": { const r = await pp.call<{ value: number }>("memory.read_u32", { address: a() }); return ok(`${addrHex(a())}: ${fmtHex(r.value)}`); } case "ppsspp_read_range": { const r = await pp.call<{ base64: string }>("memory.read", { address: a(), size: p.size }); const bytes = Buffer.from(r.base64 ?? "", "base64"); const hex = Array.from(bytes).map((b) => b.toString(16).padStart(2, "0").toUpperCase()).join(" "); return ok(`${addrHex(a())} [${bytes.length} bytes]:\n${hex}`); } case "ppsspp_read_string": { const r = await pp.call<{ value: string }>("memory.readString", { address: a(), type: "utf-8" }); return ok(`${addrHex(a())}: ${JSON.stringify(r.value ?? "")}`); } case "ppsspp_write8": { await pp.call("memory.write_u8", { address: a(), value: p.value }); return ok(`Wrote ${fmtHex(p.value)} → ${addrHex(a())}`); } case "ppsspp_write16": { await pp.call("memory.write_u16", { address: a(), value: p.value }); return ok(`Wrote ${fmtHex(p.value)} → ${addrHex(a())}`); } case "ppsspp_write32": { await pp.call("memory.write_u32", { address: a(), value: p.value }); return ok(`Wrote ${fmtHex(p.value)} → ${addrHex(a())}`); } case "ppsspp_write_range": { const bytes = Buffer.from(p.bytes as number[]); const base64 = bytes.toString("base64"); await pp.call("memory.write", { address: a(), base64 }); return ok(`Wrote ${bytes.length} bytes → ${addrHex(a())}`); } case "ppsspp_press_buttons": { await pp.call("input.buttons.send", { buttons: p.buttons }); const pressed = Object.entries(p.buttons as Record<string, boolean>) .filter(([, v]) => v).map(([k]) => k); return ok(`Set buttons: ${pressed.length ? pressed.join("+") : "(all released)"}`); } case "ppsspp_press_button": { await pp.call("input.buttons.press", { button: p.button, duration: p.duration ?? 1 }); return ok(`Pressed ${p.button} for ${p.duration ?? 1} frames (auto-released)`); } case "ppsspp_send_analog": { await pp.call("input.analog.send", { stick: p.stick, x: p.x, y: p.y }); return ok(`Set analog stick ${p.stick} to (${p.x}, ${p.y})`); } case "ppsspp_pause": { // cpu.stepping is fire-and-forget per PPSSPP source ("No immediate // response. Once CPU is stepping, a 'cpu.stepping' event will be // sent."). Send it, then poll cpu.status until stepping=true. await pp.fireAndForget("cpu.stepping"); await pp.waitForState((s) => s.stepping === true); return ok("Emulation paused"); } case "ppsspp_resume": { await pp.fireAndForget("cpu.resume"); await pp.waitForState((s) => s.stepping === false); return ok("Emulation resumed"); } case "ppsspp_step": { const r = await pp.call<{ pc?: number }>("cpu.stepInto"); return ok(`Stepped one instruction. PC: ${r.pc !== undefined ? addrHex(r.pc) : "(unknown)"}`); } case "ppsspp_reset": { await pp.call("game.reset"); return ok("Game reset"); } case "ppsspp_screenshot": { // PPSSPP's gpu.buffer.* events all require CORE_STEPPING_CPU (or GPU // stepping) state — they fail with "Neither CPU or GPU is stepping" // otherwise. We transparently pause→capture→resume so callers can // screenshot any time without managing pause state. If the emulator // was already paused, we leave it paused. // // source='render' (default) uses gpu.buffer.renderColor → reads the // active GPU render target. Safer: GPU_GetCurrentFramebuffer hits a // different code path than the crash-prone GPU_GetOutputFramebuffer. // // source='output' uses gpu.buffer.screenshot → reads the final // composited output (what's on screen, post scaling/shaders). Can // CRASH PPSSPP on some games: upstream has an `_assert_(buf != nullptr)` // after GPU_GetOutputFramebuffer that fires when the function returns // true with a null buffer (observed on some homebrew). We can't catch // a process abort from outside, but v0.1.2's auto-reconnect means MCP // recovers when PPSSPP is relaunched. const source = (p.source as string | undefined) ?? "render"; const event = source === "output" ? "gpu.buffer.screenshot" : "gpu.buffer.renderColor"; const statusBefore = await pp.call<{ stepping?: boolean; paused?: boolean }>("cpu.status"); const wasStepping = !!statusBefore.stepping; if (!wasStepping) { await pp.fireAndForget("cpu.stepping"); await pp.waitForState((s) => s.stepping === true); } try { // type: "base64" returns the raw base64 payload; the default "uri" // returns a "data:image/png;base64,..." prefix which we'd have to strip. const r = await pp.call<{ base64?: string; uri?: string }>(event, { type: "base64" }); let b64 = r.base64; if (!b64 && r.uri) { // Belt-and-suspenders: if PPSSPP returned a URI anyway, strip the prefix. const m = /^data:image\/png;base64,(.*)$/.exec(r.uri); if (m) b64 = m[1]; } if (!b64) { throw new Error(`PPSSPP did not return screenshot data from ${event} (no game loaded, or framebuffer not readable?)`); } return { content: [ { type: "text" as const, text: `Screenshot captured (source: ${source}, event: ${event}).` }, { type: "image" as const, data: b64, mimeType: "image/png" }, ], }; } finally { if (!wasStepping) { try { await pp.fireAndForget("cpu.resume"); await pp.waitForState((s) => s.stepping === false, { timeoutMs: 2000 }); } catch { /* best-effort */ } } } } case "ppsspp_get_registers": { // PPSSPP's cpu.getAllRegs returns categories with PARALLEL arrays: // { categories: [{ name, registerNames: [...], uintValues: [...], floatValues: [...] }] } // Not an array of {name, value} objects as I first assumed. const r = await pp.call<{ categories?: Array<{ name: string; registerNames?: string[]; uintValues?: number[]; floatValues?: string[]; }>; }>("cpu.getAllRegs"); const lines: string[] = []; for (const cat of r.categories ?? []) { lines.push(`── ${cat.name} ──`); const names = cat.registerNames ?? []; const vals = cat.uintValues ?? []; for (let i = 0; i < Math.max(names.length, vals.length); i++) { const nm = names[i] ?? `r${i}`; const v = vals[i]; lines.push(` ${nm.padEnd(8)} = ${v !== undefined ? addrHex(v) : "(unavailable)"}`); } } return ok(lines.join("\n") || "(no registers returned)"); } case "ppsspp_breakpoint_add": { await pp.call("cpu.breakpoint.add", { address: a() }); return ok(`Breakpoint added at ${addrHex(a())}`); } case "ppsspp_breakpoint_remove": { await pp.call("cpu.breakpoint.remove", { address: a() }); return ok(`Breakpoint removed at ${addrHex(a())}`); } case "ppsspp_breakpoint_list": { const r = await pp.call<{ breakpoints?: Array<{ address: number; enabled?: boolean; condition?: string }> }>("cpu.breakpoint.list"); const bps = r.breakpoints ?? []; if (bps.length === 0) return ok("No breakpoints set."); const lines = bps.map((b) => ` ${addrHex(b.address)} ${b.enabled === false ? "(disabled)" : ""}${b.condition ? ` if ${b.condition}` : ""}`); return ok(`${bps.length} breakpoint${bps.length === 1 ? "" : "s"}:\n${lines.join("\n")}`); } default: throw new Error(`Unknown tool: ${name}`); } }); }