A control tool for the Ultimate 64 over its REST API. Two faces over one shared core:
- a CLI for interactive/scripted use — read & disassemble memory, write bytes, query info, control the machine;
- an MCP server (the
mcpsubcommand) exposing the same operations to an MCP host such as Claude, over a Streamable HTTP transport.
Disassembly is powered by go6asm.
The core is a public, structured library (ops returns typed data; render
formats it for terminals) so every face binds to the same logic — including a
WebAssembly build for the browser:
┌─ u64ctl read/dis/write ... (CLI)
ops ──────┤ u64ctl mcp → MCP server → Claude
(typed core) │ cmd/u64wasm → globalThis.u64ctl (browser)
+ render │
│ └─ render → display text (CLI + MCP)
│
└─ u64 → HTTP /v1/... → Ultimate 64
make build # -> bin/u64ctl
make install # go install (puts u64ctl on your PATH)
make test vetThe device is selected with -host (or U64_HOST) and an optional -password
(or U64_PASSWORD). Hexadecimal addresses need a $ or 0x prefix ($C000,
0xC000); an unprefixed number is decimal. Prefer 0xC000 on the command line —
a shell would expand the unquoted $C000.
export U64_HOST=192.168.1.64
u64ctl info # device + API info
u64ctl read 0xC000 64 # hex/ASCII dump of 64 bytes
u64ctl dis 0xC000 64 # 6502 disassembly
u64ctl write 0xC000 "A9 00 8D 20 D0"
u64ctl asm hello.s -o hello.prg # assemble locally (no device needed)
u64ctl run hello.s # assemble → upload → run on the device
u64ctl run demo.prg # upload & run a prebuilt .prg as-is
u64ctl run game.crt # upload & start a .crt cartridge image
u64ctl run kickman.bin # wrap a raw cart ROM as a .crt, then start it
u64ctl run diamond.t64 # extract a program from a .t64 tape & run it
u64ctl type 'RUN{enter}' # inject keystrokes ({enter} = RETURN)
u64ctl stop # send RUN/STOP to break a BASIC program
u64ctl screen # read the 40x25 text screen as text
u64ctl reset # also: reboot | pause | resume | menu | poweroff-host/-password work on every command too: u64ctl read -host 192.168.1.64 0xC000.
Add -v to any device command to log the exact HTTP requests hit on the U64:
$ u64ctl read -v 0xC000 16
u64ctl: GET http://192.168.1.64/v1/machine:readmem?address=0xc000&length=16 -> 200
asm/run use go6asm to assemble ca65-compatible
6502 source into a C64 .prg, then upload it via the device's run_prg runner. asm is
fully offline; run needs a device. Source can set its own .org; otherwise the load
address defaults to $0801 (override with -org). run -load loads without starting.
run picks how to handle a file by extension (and content), all over the device's
runner endpoints:
| Input | What run does |
|---|---|
.s |
assemble with go6asm, then run_prg |
.prg |
upload as-is (load address from the 2-byte header), then run_prg |
.crt |
start as-is via run_crt (also taken if the bytes carry the C64 CARTRIDGE signature) |
.bin / .rom |
raw cartridge ROM — wrap in a .crt and run_crt. Mapping auto-detects from size + reset vector (8K $E000 reset → Ultimax, else $8000); override with -cart-mode ultimax8k|std8k|std16k |
.t64 |
tape image — extract a program (-entry n for multi-program tapes; an out-of-range n prints the directory) and run_prg it |
A .t64 is an emulator tape archive, not a real tape recording: a small directory of
one or more .prg-style programs (each with its own load address). It belongs to the
.prg family — run pulls the chosen program out and starts it like any other .prg. A
raw cartridge ROM (.bin) carries no mapping metadata, so run reconstructs the .crt
container (header + CHIP packet with the right load address and EXROM/GAME lines) the
device needs to drive the cartridge banking — the faithful way to run a cart.
examples/ holds ready-to-build C64 source:
| File | What it does |
|---|---|
border-flash.s |
Hijacks the KERNAL IRQ vector to cycle the border color in the background while BASIC keeps running. A complete, deployable demo of the install → IRQ-handler → chain-to-KERNAL pattern. |
sprite-bounce.s |
Builds on the same IRQ trick with a sprite: two independent tasks (horizontal bounce, vertical bob + size pulse) each gated to its own frame rate. Shows sprite setup, a bitmap copy into VIC RAM, and a lookup-table animation. |
sprite-anim.s |
A true two-frame shape animation: two bitmaps in two VIC blocks, with the IRQ flipping sprite 0's data pointer ($07F8) between them so the face blinks while it bounces. |
u64ctl asm examples/border-flash.s -o /tmp/border.prg # build it
u64ctl run -host 192.168.1.64 examples/border-flash.s # build + run on the U64Stop the flasher from the C64 with POKE 788,49 : POKE 789,234 (restores the stock vector).
u64ctl mcp -host 192.168.1.64 # daemon, listens on :8080/mcpIt prints the connect command on startup. Register it with Claude Code:
claude mcp add --transport http ultimate64 http://localhost:8080/mcpMCP flags: -addr (default :8080), -path (default /mcp), -v (verbose).
Tools exposed: u64_info, u64_read_memory, u64_write_memory, u64_disassemble,
u64_run_source (assemble & run 6502 source), u64_type_text (inject keystrokes),
u64_stop (RUN/STOP), u64_read_screen (text screen → text), u64_machine_control.
⚠️ The MCP server has no auth and binds all interfaces by default. Keep it on a trusted LAN, or bind localhost only (-addr 127.0.0.1:8080) and reach it via SSH tunnel.
All logs go to stderr. The daemon logs the connecting client on initialize and every
tool call with arguments, outcome, and timing; on Ctrl+C it prints the claude mcp remove
cleanup command. Add -v to also log every JSON-RPC method.
u64ctl 22:10:55 initialize: claude-code 1.2.3 (protocol 2025-06-18)
u64ctl 22:10:55 tool u64_read_memory({"address":"$C000","length":16}) -> ok in 12ms (89 bytes)
The core compiles to a browser WASM module — a third face over the same ops/
render packages. Go's net/http maps to the browser fetch API, so a page
can drive the device directly with no extra server.
make wasm # -> bin/u64.wasm (+ bin/wasm_exec.js loader)A host page loads wasm_exec.js, instantiates u64.wasm, and finds the API on
globalThis.u64ctl. Every method takes (host, password, …) and returns a
Promise resolving to a parsed object:
const screen = await u64ctl.readScreen("192.168.1.64", "");
console.log(screen.text);
await u64ctl.typeText("192.168.1.64", "", "RUN\n");Methods: readMemory, writeMemory, disassemble, info, machine,
typeText, stop, readScreen, runSource. CORS applies at runtime — the
page must be same-origin with the device (or the device must send CORS headers).
main.go subcommand dispatcher: CLI verbs + `mcp`
ops/ public typed core: device verbs + address/hex parsing
render/ public display formatting (CLI + MCP)
u64/ public thin Ultimate 64 REST client (/v1 routes)
cmd/u64wasm/ WebAssembly face: ops/render exposed to JavaScript
internal/mcpserver/ hand-rolled MCP JSON-RPC over Streamable HTTP
internal/tools/ MCP tool definitions (thin adapters over ops + render)
examples/ ready-to-build C64 assembly sources
During local development go6asm is resolved via a replace directive in go.mod
pointing at ../go6asm (and the local go.work). Once stable, drop the replace and
pin the published tag (go6asm v0.1.1 is the current require).
u64_screenshot— capture a VIC video frame → PNG via/v1/streams/<stream>:start(needs wired Ethernet)- screen-base autodetect for
read_screen(VIC bank via CIA2$DD00+$D018) - annotated disassembly via go6asm symbols/comments (KERNAL/BASIC labels)
- more runners/endpoints:
sidplay/modplay,drives:*,files:*,configs - a browser UI on top of the WASM library