bizhawk_write32
Write a 32-bit little-endian value to emulator memory for cheats and pokes. Overwrites four bytes at the specified address with no undo; save state first if rollback needed.
Instructions
PURPOSE: Write an unsigned 32-bit little-endian value to emulator memory at the given address. USAGE: Use for 32-bit cheats and pokes (timestamps, large counters, pointers on 32-bit systems). For 8/16-bit values use bizhawk_write8/write16; for big-endian layouts byteswap and use bizhawk_write_range. BEHAVIOR: DESTRUCTIVE: overwrites four bytes starting at address with no undo (snapshot via bizhawk_save_state first if you need rollback). Direct memory write — bypasses MBC/mapper/DMA, see bizhawk_write8 notes. Returns an error if the domain is unknown, address+4 exceeds the domain, value < 0 or > 4294967295, or the core lacks memory.write_u32_le. RETURNS: Single line 'Wrote VAL_DEC (0xVAL_HEX) → ADDR_HEX (DOMAIN)'.
Input Schema
| Name | Required | Description | Default |
|---|---|---|---|
| address | Yes | Byte offset within the chosen memory domain. Per-domain offsets are 0-based and INDEPENDENT of system bus addresses (e.g. SNES WRAM uses 0x09C6, NOT 0x7E09C6). Reads 4 consecutive bytes starting here. Returns an error if address < 0 or address + 4 exceeds the domain's size. | |
| value | Yes | 32-bit value to write. Must be 0-4294967295 (0x00000000-0xFFFFFFFF). LSB lands at `address`, MSB at `address+3`. Values outside this range return an error. | |
| domain | No | Optional case-sensitive memory domain name. Omit to use BizHawk's currently selected domain (see bizhawk_get_info → current_memory_domain). Discover available names with bizhawk_list_memory_domains; they vary per system (WRAM on SNES, RAM on NES, RDRAM on N64, 68K RAM on Genesis, MainRAM on PSX, EWRAM/IWRAM on GBA). Returns an error if the name doesn't match any domain on the loaded core. |
Implementation Reference
- src/tools.ts:555-557 (handler)Handler for bizhawk_write32: calls bh.call('write32', ...) with address, value, and optional domain, then returns a success message with formatted hex output.
case "bizhawk_write32": { await bh.call("write32", { address: a(), value: p.value, ...dom() }); return ok(`Wrote ${fmtHex(p.value)} → ${addrHex(a())}${p.domain ? ` (${p.domain})` : ""}`); - src/tools.ts:189-206 (schema)Tool definition and inputSchema for bizhawk_write32: declares name, description, and JSON schema with required params address (integer, min 0) and value (integer, 0-4294967295), plus optional domain string.
{ name: "bizhawk_write32", description: "PURPOSE: Write an unsigned 32-bit little-endian value to emulator memory at the given address. " + "USAGE: Use for 32-bit cheats and pokes (timestamps, large counters, pointers on 32-bit systems). For 8/16-bit values use bizhawk_write8/write16; for big-endian layouts byteswap and use bizhawk_write_range. " + "BEHAVIOR: DESTRUCTIVE: overwrites four bytes starting at `address` with no undo (snapshot via bizhawk_save_state first if you need rollback). Direct memory write — bypasses MBC/mapper/DMA, see bizhawk_write8 notes. Returns an error if the domain is unknown, address+4 exceeds the domain, value < 0 or > 4294967295, or the core lacks memory.write_u32_le. " + "RETURNS: Single line 'Wrote VAL_DEC (0xVAL_HEX) → ADDR_HEX (DOMAIN)'.", inputSchema: { type: "object", required: ["address", "value"], properties: { address: { type: "integer", minimum: 0, description: ADDRESS_PARAM_DESC(4) }, value: { type: "integer", minimum: 0, maximum: 4294967295, description: "32-bit value to write. Must be 0-4294967295 (0x00000000-0xFFFFFFFF). LSB lands at `address`, MSB at `address+3`. Values outside this range return an error." }, domain: { type: "string", description: DOMAIN_PARAM_DESC }, }, additionalProperties: false, }, }, - src/tools.ts:486-660 (registration)registerTools function registers all tools via ListToolsRequestSchema (returns TOOLS array) and CallToolRequestSchema (routes to the bizhawk_write32 case at line 555).
export function registerTools(server: Server, bh: BizhawkServer): 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; const dom = () => p.domain ? { domain: p.domain } : {}; switch (name) { case "bizhawk_ping": { const r = await bh.call<string>("ping"); return ok(r); } case "bizhawk_get_info": { const r = await bh.call<{ rom_name?: string; rom_hash?: string; framecount?: number; memory_domains?: string[]; current_memory_domain?: string; capabilities?: Record<string, boolean>; }>("get_info"); const lines = [ `ROM: ${r.rom_name ?? "(unavailable)"}`, `ROM hash: ${r.rom_hash ?? "(unavailable)"}`, `Framecount: ${r.framecount ?? "(unavailable)"}`, ]; if (r.memory_domains?.length) { lines.push(""); lines.push(`Memory domains: ${r.memory_domains.join(", ")}`); if (r.current_memory_domain) { lines.push(`Active domain (used when 'domain' is omitted): ${r.current_memory_domain}`); } } if (r.capabilities) { const missing = Object.entries(r.capabilities).filter(([, v]) => !v).map(([k]) => k); if (missing.length) { lines.push(""); lines.push(`Missing capabilities on this BizHawk build: ${missing.join(", ")}`); } } return ok(lines.join("\n")); } case "bizhawk_list_memory_domains": { const r = await bh.call<string[]>("list_memory_domains"); return ok("Memory domains:\n " + r.join("\n ")); } case "bizhawk_read8": return ok(`${addrHex(a())}: ${fmtHex(await bh.call<number>("read8", { address: a(), ...dom() }))}`); case "bizhawk_read16": return ok(`${addrHex(a())}: ${fmtHex(await bh.call<number>("read16", { address: a(), ...dom() }))}`); case "bizhawk_read32": return ok(`${addrHex(a())}: ${fmtHex(await bh.call<number>("read32", { address: a(), ...dom() }))}`); case "bizhawk_read_range": { const bytes = await bh.call<number[]>("read_range", { address: a(), length: p.length, ...dom() }); const hex = bytes.map((b) => b.toString(16).padStart(2, "0").toUpperCase()).join(" "); return ok(`${addrHex(a())} [${bytes.length} bytes${p.domain ? `, ${p.domain}` : ""}]:\n${hex}`); } case "bizhawk_write8": { await bh.call("write8", { address: a(), value: p.value, ...dom() }); return ok(`Wrote ${fmtHex(p.value)} → ${addrHex(a())}${p.domain ? ` (${p.domain})` : ""}`); } case "bizhawk_write16": { await bh.call("write16", { address: a(), value: p.value, ...dom() }); return ok(`Wrote ${fmtHex(p.value)} → ${addrHex(a())}${p.domain ? ` (${p.domain})` : ""}`); } case "bizhawk_write32": { await bh.call("write32", { address: a(), value: p.value, ...dom() }); return ok(`Wrote ${fmtHex(p.value)} → ${addrHex(a())}${p.domain ? ` (${p.domain})` : ""}`); } case "bizhawk_write_range": { const r = await bh.call<{ written: number }>("write_range", { address: a(), bytes: p.bytes, ...dom() }); return ok(`Wrote ${r.written} bytes → ${addrHex(a())}${p.domain ? ` (${p.domain})` : ""}`); } case "bizhawk_press_buttons": { await bh.call("press_buttons", { buttons: p.buttons, player: p.player ?? 1 }); const pressed = Object.entries(p.buttons as Record<string, boolean>) .filter(([, v]) => v).map(([k]) => k); return ok(`Set joypad ${p.player ?? 1}: ${pressed.length ? pressed.join("+") : "(all released)"}`); } case "bizhawk_play_input_sequence": { const params: Record<string, unknown> = { frames: p.frames }; if (p.screenshot_every !== undefined) params.screenshot_every = p.screenshot_every; if (p.screenshot_dir !== undefined) params.screenshot_dir = p.screenshot_dir; if (p.screenshot_prefix !== undefined) params.screenshot_prefix = p.screenshot_prefix; if (p.observe_memory !== undefined) params.observe_memory = p.observe_memory; if (p.stop_on_memory_change !== undefined) params.stop_on_memory_change = p.stop_on_memory_change; const r = await bh.call<{ played: number; final_framecount?: number; stopped_early?: boolean; stop_reason?: string; observations?: { frame_offset: number; path?: string; memory?: Record<string, number>; }[]; }>("play_input_sequence", params); const obs = r.observations ?? []; const lines = [ `Played ${r.played} frames. Final framecount: ${r.final_framecount ?? "(unavailable)"}.`, ]; if (r.stopped_early) { lines.push(`Stopped early — reason: ${r.stop_reason ?? "(unspecified)"}.`); } lines.push(`Captured ${obs.length} observation${obs.length === 1 ? "" : "s"}.`); // Per-observation lines so the agent can correlate inline images with state for (let i = 0; i < obs.length; i++) { const o = obs[i]; const memStr = o.memory ? ` memory={${Object.entries(o.memory).map(([k, v]) => `${k}=${v}`).join(", ")}}` : ""; const imgStr = o.path ? ` (image ${i + 1})` : ""; lines.push(` obs[${i}] frame_offset=${o.frame_offset}${memStr}${imgStr}`); } // Build the multi-content response: text summary + per-observation // inline image blocks. We read each PNG from disk (Lua wrote it), // base64-encode for MCP transport. const content: ({ type: "text"; text: string } | { type: "image"; data: string; mimeType: string })[] = [ { type: "text", text: lines.join("\n") }, ]; const fs = await import("node:fs"); for (const o of obs) { if (!o.path) continue; try { const bytes = fs.readFileSync(o.path); content.push({ type: "image", data: bytes.toString("base64"), mimeType: "image/png", }); } catch (err) { content.push({ type: "text", text: `(failed to read observation at frame ${o.frame_offset} from ${o.path}: ${(err as Error).message})`, }); } } return { content }; } case "bizhawk_pause": await bh.call("pause"); return ok("Emulation paused"); case "bizhawk_unpause": await bh.call("unpause"); return ok("Emulation resumed"); case "bizhawk_reset": await bh.call("reset"); return ok("Core reset"); case "bizhawk_frame_advance": { const f = await bh.call<number>("frame_advance", { count: p.count ?? 1 }); return ok(`Advanced ${p.count ?? 1} frame(s). Framecount: ${f}`); } case "bizhawk_screenshot": { const path = await bh.call<string>("screenshot", { path: p.path }); return ok(`Screenshot saved: ${path}`); } case "bizhawk_save_state": { const r = await bh.call<{ path: string }>("save_state", { path: p.path }); return ok(`Saved state to ${r.path}`); } case "bizhawk_load_state": { const r = await bh.call<{ path: string }>("load_state", { path: p.path }); return ok(`Loaded state from ${r.path}`); } default: throw new Error(`Unknown tool: ${name}`); } }); } - src/bizhawk.ts:34-265 (helper)BizhawkServer class with the call() method that enqueues commands (including 'write32') and sends them as JSON over TCP to the BizHawk Lua bridge.
export class BizhawkServer { private server: net.Server | null = null; private client: net.Socket | null = null; private buf = ""; private queue: PendingCmd[] = []; /** Commands sent to BizHawk awaiting a RESULT reply. Keyed by command id. */ private inflight = new Map<number, PendingCmd>(); /** True between sending a command and receiving its reply. Lua only handles * one command per round-trip, so we wait for a RESULT before sending the * next command. */ private awaitingResult = false; private nextId = 1; private readonly host: string; private readonly port: number; private readonly timeoutMs: number; constructor(opts: BizhawkOptions = {}) { this.host = opts.host ?? "127.0.0.1"; this.port = opts.port ?? 8766; this.timeoutMs = opts.timeoutMs ?? 10000; } describeTarget(): string { return `tcp listening on ${this.host}:${this.port}`; } isConnected(): boolean { return this.client !== null && !this.client.destroyed; } /** * True once we've received at least one message (READY or RESULT) from the * bridge. A bare TCP connection isn't enough — BizHawk's CLI flag opens the * socket at process startup, but `bridge.lua` only starts polling after the * user loads it via Tools > Lua Console. Tools should wait for this before * issuing commands. */ isBridgeReady(): boolean { return this.bridgeReady; } private bridgeReady = false; /** Start listening. Resolves once the listen socket is bound. */ async start(): Promise<void> { if (this.server) return; return new Promise<void>((resolve, reject) => { const srv = net.createServer((sock) => this.attachClient(sock)); srv.once("error", (err) => reject(err)); srv.listen(this.port, this.host, () => { this.server = srv; resolve(); }); }); } stop(): void { this.client?.destroy(); this.client = null; this.server?.close(); this.server = null; } private attachClient(sock: net.Socket): void { if (this.client && !this.client.destroyed) { // Already have a client. Refuse the new one — only one BizHawk at a time. sock.write("ERROR another BizHawk client already connected\n"); sock.destroy(); return; } process.stderr.write("[mcp-bizhawk] BizHawk client connected (waiting for bridge.lua to start polling)\n"); this.client = sock; this.buf = ""; this.awaitingResult = false; this.bridgeReady = false; // CRITICAL: disable Nagle. Bridge's socketServerResponse has a 5ms timeout; // our 5-byte NONE replies get coalesced by Nagle for up to 200ms otherwise, // missing every receive window. With Nagle off, writes flush immediately. sock.setNoDelay(true); sock.setEncoding("utf8"); sock.on("data", (chunk: string) => this.onData(chunk)); sock.on("close", () => { process.stderr.write("[mcp-bizhawk] BizHawk client disconnected\n"); this.client = null; // Leave inflight commands hanging — they'll time out cleanly. }); sock.on("error", (err) => { process.stderr.write(`[mcp-bizhawk] socket error: ${err.message}\n`); }); } private onData(chunk: string): void { if (process.env.MCP_BIZHAWK_DEBUG) { process.stderr.write(`[trace] RX raw (${chunk.length}B): ${JSON.stringify(chunk)}\n`); } this.buf += chunk; while (true) { const nl = this.buf.indexOf("\n"); if (nl === -1) break; let line = this.buf.slice(0, nl).trim(); this.buf = this.buf.slice(nl + 1); if (line.length === 0) continue; // BizHawk's SocketServer.SendString prepends every outgoing message // with "<byte-count> " as a framing header. Strip it. const m = line.match(/^(\d+) (.+)$/); if (m) line = m[2]; if (process.env.MCP_BIZHAWK_DEBUG) { process.stderr.write(`[trace] RX line: ${JSON.stringify(line)}\n`); } this.handleMessage(line); } } private handleMessage(line: string): void { if (!this.bridgeReady) { this.bridgeReady = true; process.stderr.write("[mcp-bizhawk] bridge.lua is polling — bridge ready\n"); } if (line === "READY") { // Lua is asking for work; no result to deliver this round. this.maybeDispatchNext(); return; } if (line.startsWith("RESULT ")) { const json = line.slice(7); let parsed: { id?: number; result?: unknown; error?: { code: number; message: string } }; try { parsed = JSON.parse(json); } catch (err) { process.stderr.write(`[mcp-bizhawk] bad RESULT json: ${(err as Error).message}\n`); this.awaitingResult = false; this.maybeDispatchNext(); return; } const id = parsed.id ?? -1; const pending = this.inflight.get(id); if (pending) { this.inflight.delete(id); if (parsed.error) { pending.reject(new Error(`BizHawk RPC error [${parsed.error.code}]: ${parsed.error.message}`)); } else { pending.resolve(parsed.result); } } this.awaitingResult = false; this.maybeDispatchNext(); return; } process.stderr.write(`[mcp-bizhawk] unrecognised line from BizHawk: ${line}\n`); this.maybeDispatchNext(); } /** * Write a single message to the bridge. BizHawk's socket server, since * 2.6.2, requires INCOMING messages to be length-prefixed the same way it * frames its OUTGOING ones: `"{length:D} {message}"`. Without the prefix, * BizHawk's parser silently discards the line and `socketServerResponse()` * returns empty — which is exactly the failure mode we hit before finding * this in `Lua/_docs_luacats/comm.d.lua`. */ private sendFramed(payload: string): void { if (!this.client || this.client.destroyed) return; // length = byte count of the payload itself (not including the framing // prefix or the trailing newline that delimits the line on the wire). const byteLen = Buffer.byteLength(payload, "utf8"); const wire = `${byteLen} ${payload}\n`; if (process.env.MCP_BIZHAWK_DEBUG) { process.stderr.write(`[trace] TX: ${JSON.stringify(wire)}\n`); } this.client.write(wire); } /** * Called when Lua sends READY. We MUST reply to every send because * BizHawk's Lua bridge polls `socketServerResponse()` once per frame and * expects something there — and we want to keep the round-trip turning * even when there's no work. Reply with the next queued command if * available, otherwise NONE. */ private maybeDispatchNext(): void { if (!this.client || this.client.destroyed) return; if (this.awaitingResult) { // a command is in flight; tell bridge to keep waiting this.sendFramed("NONE"); return; } const next = this.queue.shift(); if (!next) { this.sendFramed("NONE"); return; } this.inflight.set(next.id, next); this.awaitingResult = true; const msg = JSON.stringify({ id: next.id, method: next.method, params: next.params }); this.sendFramed(msg); } /** Enqueue a command and return a promise that resolves when BizHawk replies. */ async call<T = unknown>(method: string, params: Record<string, unknown> = {}): Promise<T> { return new Promise<T>((resolve, reject) => { const id = this.nextId++; const pending: PendingCmd = { id, method, params, resolve: (r) => resolve(r as T), reject, }; const timer = setTimeout(() => { // Drop from queue if still waiting; from inflight if already sent. this.queue = this.queue.filter((p) => p.id !== id); this.inflight.delete(id); if (this.inflight.size === 0) this.awaitingResult = false; reject(new Error( `BizHawk call "${method}" timed out (${this.timeoutMs}ms) — ` + `is the bridge.lua script still polling?`, )); }, this.timeoutMs); // Wrap so the timer always clears const origResolve = pending.resolve, origReject = pending.reject; pending.resolve = (r) => { clearTimeout(timer); origResolve(r); }; pending.reject = (e) => { clearTimeout(timer); origReject(e); }; this.queue.push(pending); }); } }