Observability
A running machine holds state: a program counter, a raster line, a border colour, a tape head part-way through a block. Emu198x publishes that state as named paths. Ask a machine which paths it has, read the ones you want, and the clock does not move while you look.
Paths are the shape because the alternative is a lie. A C64 has a VIC-II, a NES has a PPU, an Amiga has three custom chips that share a bus, and no honest common interface flattens those into one screen object. So the names stay the hardware's own and the list is what travels: a tool calls query_paths to find out what this machine publishes, then query to read one. Written that way, it works on a machine that did not exist when it was written.
The mental model
- Two calls, and the first is the discovery.
query_pathslists what a machine publishes;queryreads one path. Both are script steps and both are MCP tools, with the same names and the same arguments either way. - The list is what the machine can publish, not what it can answer now. A NES with no cartridge still lists all 83 of its paths. Reading one comes back as
query path cpu.pc is unavailable: no cartridge is loaded— the path is real, the machine has nothing behind it yet. A name that was never there answers differently:query path nonsense.path is not known. Wrong name and wrong moment stay separate failures. - Reading costs the machine nothing. Query
session.time, read two whole chips, querysession.timeagain and the number has not changed. There is no peek that clears a latch and no read that steals a cycle, so a report taken mid-run describes the run it was taken from. - A path is a label on a real register.
cpu.afon a Z80 andcpu.xon a 6502, because that is what those CPUs have. Four prefixes are on every machine —cpu.,run.,session.andcapture.— and everything past them is that machine's hardware under its own name.
What works today
All 30 machines publish paths, and the smallest set is not small. What varies is how much hardware there is to name.
| Machine | Paths | Some of what it publishes |
|---|---|---|
| Amiga | 1321 | agnus., denise., paula., copper., blitter., dma., disk. |
| Commodore 64 | 164 | vic., cia1., cia2., drive8., iec., memory. |
| NES | 83 | ppu., apu., mapper., sprites., cartridge. |
| Dragon | 67 | sam., pia0., pia1., video., tape., disk. |
| ZX Spectrum | 65 | screen., basic., tape., keyboard., boot. |
| Atari 800XL | 64 | antic., gtia., pokey., pia., basic. |
| MSX1 | 42 | vdp., ay., ppi., bios. |
| Atari 2600 | 39 | tia., input., cartridge. |
| Game Boy | 26 | cpu., cartridge., capture. |
Those two ends are worth sitting with. The Amiga names 240 paths under cpu. alone and another 227 under paula., because a chip that does audio, disk and interrupts has that much to say. The Game Boy publishes 26 and has no machine. prefix at all — it is the one machine of the 30 that does not. Hard-coding a path list is therefore the mistake the discovery call exists to prevent.
The machine describes itself
Before any of the hardware paths there is session.profile., which answers with no media loaded and on every machine. Put this in profile.json:
[
{"action":"query","path":"session.profile.display_name"},
{"action":"query","path":"session.profile.firmware.ids"},
{"action":"query","path":"session.profile.media_slots.ids"}
]./emu198x-spectrum --script profile.json{"kind":"query","result":{"path":"session.profile.display_name","value":"ZX Spectrum 48K (PAL)"}}
{"kind":"query","result":{"path":"session.profile.firmware.ids","value":["sinclair-zx-spectrum-48k-rom"]}}
{"kind":"query","result":{"path":"session.profile.media_slots.ids","value":["tape-1"]}}That last line is the one to remember. A media slot has a name and the names differ per machine — tape-1 here, cartridge-1 and cartridge-2 on an MSX, cartridge-1 on a NES — and this path is where a machine lists its own. The same prefix carries machine_id, family, region, release_year, capabilities and the clock rate as a numerator and denominator, so a tool can label a report without being told which binary produced it.
What else reads state
Paths are the portable half. Beside them sit calls that answer a shape a path is wrong for.
| Need | Call | Where |
|---|---|---|
| What does this machine publish? | query_paths | Every machine |
| Read one path | query | Every machine |
| Registers as a formatted dump | query_cpu | Every machine |
| Raw bytes off the bus | memory_read | Every machine |
| Instructions at an address | disasm | Every machine |
| Stop when a path reaches a value | wait_for_query_bool, wait_for_query_contains | Every machine |
| Every I/O port access over a window | io_trace | Nine port-mapped machines, over MCP |
| An instruction-by-instruction trace | cpu_trace_arm, cpu_trace_log | Amiga, over MCP |
| Log writes to a memory range | watch_memory_start, watch_memory_log | ZX Spectrum and Amiga |
| Log writes to the sound chip | watch_ay_start, watch_ay_log | Six machines |
Looking at a running machine
The shortest run that needs no firmware and no software you have to find. Discover a prefix, run the machine, read three paths. Put this in look.json:
[
{"action":"query_paths","prefix":"ppu."},
{"action":"run_frames","frames":120},
{"action":"query","path":"ppu.scanline"},
{"action":"query","path":"cpu.pc"},
{"action":"query","path":"cpu.flags"}
]From a source checkout, against a synthetic cartridge built in this repository:
cargo run --release -p emu198x-nes -- \
--rom test-data/synthetic-cartridges/nintendo-nes-logo.nes \
--script look.jsonOne JSON object lands on standard output. Its observations, one per line and trimmed to the part worth reading:
{"kind":"query_paths","result":{"prefix":"ppu.","paths":["ppu.ctrl","ppu.dot", ... ,"ppu.scanline","ppu.status"]}}
{"kind":"run_frames","frames":120,"reached":21263620,"stop_reason":"ReachedTarget"}
{"kind":"query","result":{"path":"ppu.scanline","value":0}}
{"kind":"query","result":{"path":"cpu.pc","value":32926}}
{"kind":"query","result":{"path":"cpu.flags","value":{"b":false,"c":true,"d":false,"i":true,"n":false,"u":true,"v":false,"z":false}}}Three things in that report shape everything else. prefix is a literal string match with no wildcards, and a prefix nothing starts with returns an empty list instead of an error — so a tool can probe for vic. and find out it is on the wrong machine without handling a failure. cpu.pc comes back as 32926, in decimal, because a query answers with a number and not with a rendering of one; $809E is your side of the line. And cpu.flags answers with a whole object, because the path names a group of bits rather than a single value.
That last habit goes further on some machines. Where a bare group name appears in the path list — cpu, ppu and apu on the NES, copper and denise among nineteen on the Amiga — reading it returns every leaf beneath it in one answer. It is the difference between twenty calls and one. Not every machine offers them: the C64 publishes leaves only.
The same two calls over MCP
Nothing about the vocabulary changes when a program drives the machine instead of a file. What changes is the envelope:
printf '%s\n' \
'{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}' \
'{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"run_frames","arguments":{"frames":120}}}' \
'{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"query","arguments":{"path":"vic.border_colour"}}}' \
| ./emu198x-c64 --mcp{"jsonrpc":"2.0","id":3,"result":{"content":[{"text":"{\"kind\":\"query\",\"result\":{\"path\":\"vic.border_colour\",\"value\":14}}","type":"text"}]}}The answer is a JSON document inside a JSON string inside a JSON-RPC frame. Parse content[0].text a second time before reading value out of it — a client that treats the text as opaque gets a string that looks right in a log and counts as zero paths in code. Every query_paths reply arrives the same way.
One trap on the way in. A machine that boots from firmware is ready before the first call arrives, so the C64 above needs no setup. A machine that boots from a cartridge is not: --rom and --cart are ignored under --mcp, and every hardware path answers is unavailable: no cartridge is loaded until a load_media call has named a slot, a kind and a file.
Reading memory by path
The C64 is the one machine whose path list carries a template rather than a fixed name. Two of its 164 entries read memory.ram.<hex16> and drive8.mem.<hex16>, and the placeholder is yours to fill:
[
{"action":"wait_for_boot","max_frames":300},
{"action":"query","path":"vic.border_colour"},
{"action":"query","path":"memory.ram.D020"},
{"action":"query","path":"memory.ram.0400"}
]{"kind":"wait_for_boot","frames":109,"reached":2142504,"reason":"found READY. screen codes at offset $00C8 on row 5","row":5}
{"kind":"query","result":{"path":"vic.border_colour","value":14}}
{"kind":"query","result":{"path":"memory.ram.D020","value":255}}
{"kind":"query","result":{"path":"memory.ram.0400","value":32}}Fourteen is light blue, and it is the border a C64 wakes up with. The two memory reads show why the chip path is not a convenience wrapper around the address: $D020 is where a program writes to change that border, and the byte living at $D020 in RAM is 255, because the VIC-II register and the RAM underneath it are different storage that share an address. Ask the chip for chip state. memory.ram.0400 is 32, a space, which is the top-left cell of a freshly cleared screen.
Both spellings of the address work — D020 and 0x0400 resolve alike, and no other machine takes an address this way. The Amiga is the one other machine publishing anything under memory., and its twelve entries are fixed names for the memory map rather than a byte you choose: memory.chip_ram.size_bytes, memory.rom.kind, memory.overlay. The remaining 28 machines publish nothing under that prefix at all. Reading a byte anywhere but the C64 is memory_read, which takes an address and a length.
Watching a window of time
A path read answers for the instant it was taken. The two calls below record what happened across a span instead, and both are MCP tools with no script step of their own.
io_trace runs a given number of frames and reports every I/O port access inside them. On an MSX that is the BIOS talking to its own hardware:
printf '%s\n' \
'{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}' \
'{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"io_trace","arguments":{"frames":2,"limit":4}}}' \
| ./emu198x-msx --mcp{"by_port":[{"port":"$0090","reads":0,"writes":1},{"port":"$0098","reads":0,"writes":121},{"port":"$0099","reads":1,"writes":8}, ... ],
"events":[{"dir":"out","pc":"$02DB","port":"$00AB","value":"$82"}, ... ],
"frames":2,"total_events":215,"truncated":true}A per-port tally first, then a sample of events carrying the PC that made each one, then the honest count: 215 accesses happened and 4 came back, truncated says so. Ports $98 and $99 are the VDP, $A0 to $A2 the sound chip, $A8 to $AB the PPI — the boot ROM setting up a machine, visible without a debugger attached.
io_trace is offered by every binary and works on nine of them: the Amstrad CPC, ColecoVision, Mattel Aquarius, Memotech MTX, MSX1, Sord M5, Tatung Einstein, ZX80 and ZX81. Elsewhere it refuses and says what to use instead.
The Amiga goes further than any other machine, with a trace buffer you arm and read. Arm it, run, and read the tail:
printf '%s\n' \
'{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}' \
'{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"cpu_trace_arm","arguments":{"max_entries":2000}}}' \
'{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"run_frames","arguments":{"frames":2}}}' \
'{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"cpu_trace_log","arguments":{"limit":3}}}' \
| ./emu198x-amiga --mcp{"armed":true,"at_limit":true,"captured":2000,"entries":[{"cck":11996,"opcode":"$6EFC","pc":"$00FC00E0","sr":"$2700"}, ... ],"filtered_total":2000,"max_entries":2000,"returned":3}One entry per completed instruction, stamped with the colour clock it finished on. cpu_trace_arm takes pc_min and pc_max to record only inside a region of interest, and cpu_trace_log filters what it returns by clock range and reads from either end of the buffer. Two frames of Kickstart filled a 2000-entry buffer, which is the number to plan around: bound the PC range or you capture the idle loop.
What you need that we cannot give you
Firmware and software. Emu198x ships no ROMs, no BIOS images, and no disks, tapes or cartridges; the downloads page sets out the position on that. Paths are the exception that proves it: query_paths and session.profile. answer on a machine with nothing loaded, which makes them the one part of the surface you can explore before finding anything. Every hardware path needs a machine that is running.
Knowledge of the machine. A path list is a list of names, with no types, no ranges and no prose. vic.vcbase and ppu.oam_addr mean what the hardware means by them, and the documentation for that is the chip's own — a data sheet, a programmer's guide, a well-annotated ROM listing. The emulator tells you the value and stops there.
A JSON parser you trust. Reports nest: a script writes one object holding an observations array, and MCP wraps each payload in a string inside a frame. Reading either with a pattern match works until a value contains a brace.
What does not work yet
- No whole-machine snapshot you can read.
save_snapshotwrites a file that restores the machine and tells you nothing — 684,309 opaque bytes for a NES. There is no call that hands back CPU, memory, video and audio state together as one structured value, so a full picture is assembled path by path. - No breakpoints, and no conditions. Nothing sets one, lists one, or attaches an expression such as
a == 0to an address. What exists is stepping to a target —run_until_pc,run_until_any_pc,run_until_lineandrun_until_mem_change— which covers most of what a breakpoint is used for and none of what a conditional one is. - Tracing is thin outside the Amiga. One machine of 30 records instructions. Nine record I/O ports, and the refusal elsewhere can be wrong about why: a Spectrum turns
io_tracedown withthis machine does not support I/O port tracing (memory-mapped CPU), which is not what a Z80 with anINinstruction is. Memory-write logging reaches two machines and sound-chip logging six. There is no general event buffer to filter by type, address or tick. disasmreturns 16 instructions and ignorescount. Every machine, every argument: ask for 3 and the reply says"count":16and carries 16. Longer listings come from calling again at the address after the last one. The output gives address, raw bytes and mnemonic, with no cycle counts and no note of which flags an instruction touches.- No labelled memory. Nothing maps
$D020toBORDERor explains what a write there does.load_debug_infoanddebug_symbolresolve symbols from a build you produced, which is a different question from naming a machine's well-known addresses. - One path per call. There is no bulk read, no glob in a path, and no wildcard in a
query_pathsprefix. Sampling forty paths costs forty calls, and no call returns two paths captured at the same instant unless one of them is a group. - Reading and writing are not symmetric. Paths read; they never write. Changing state means
poke_byteorpoke_wordat an address, orport_writewhere a machine has ports, so the name you read a value under is not the name you change it under. - The path list is not a schema. It is names and nothing else — no type, no width, no unit, no description, no marker for which paths need a loaded machine. Whether a value arrives as a number, a string, an array or an object is found by asking for it.
- No visual debugger. There is no memory view, no sprite inspector, no raster-position display and no register panel; the window a binary opens plays the machine and does not inspect it. Everything on this page is a script step or an MCP call, and drawing any of it is yours.
- A failed query ends a script. Asking for an unavailable path stops the run, prints the message on standard error and discards the report for the steps that already succeeded. Order matters:
run.last.stop_reasonis unavailable until something has run, and reading it first costs you the whole file.