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

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.

MachinePathsSome of what it publishes
Amiga1321agnus., denise., paula., copper., blitter., dma., disk.
Commodore 64164vic., cia1., cia2., drive8., iec., memory.
NES83ppu., apu., mapper., sprites., cartridge.
Dragon67sam., pia0., pia1., video., tape., disk.
ZX Spectrum65screen., basic., tape., keyboard., boot.
Atari 800XL64antic., gtia., pokey., pia., basic.
MSX142vdp., ay., ppi., bios.
Atari 260039tia., input., cartridge.
Game Boy26cpu., 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.

NeedCallWhere
What does this machine publish?query_pathsEvery machine
Read one pathqueryEvery machine
Registers as a formatted dumpquery_cpuEvery machine
Raw bytes off the busmemory_readEvery machine
Instructions at an addressdisasmEvery machine
Stop when a path reaches a valuewait_for_query_bool, wait_for_query_containsEvery machine
Every I/O port access over a windowio_traceNine port-mapped machines, over MCP
An instruction-by-instruction tracecpu_trace_arm, cpu_trace_logAmiga, over MCP
Log writes to a memory rangewatch_memory_start, watch_memory_logZX Spectrum and Amiga
Log writes to the sound chipwatch_ay_start, watch_ay_logSix 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.json

One 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