Scripting
A script is a JSON file of steps that drives a machine with no window and nobody watching. Hand one to any Emu198x binary with --script and it boots the hardware, works down the file, writes out whatever it was told to write, and prints a report of what happened. Run the same file tomorrow and the machine does the same thing, frame for frame.
That last part is the reason to bother. A screenshot from a session someone drove by hand is an anecdote — nobody else can check it. A screenshot from a script comes with the file that produced it, and anyone holding the same firmware gets the same picture. Every Spectrum boot capture on the system matrix — one for the machine and one for each of its variants — was made this way.
The mental model
- A script is a list, not a program. Steps run in order, top to bottom. There are no conditions, no loops and no variables. What the file says is what the machine does.
- The steps are shared; the machines are not. Every binary accepts the same vocabulary, and a Spectrum is still a Z80 and a ULA underneath it. Where a machine has no hardware behind a step, that step is refused rather than faked.
- Time is counted in frames.
{"action":"run_frames","frames":300}is 300 native frames of that machine, so a run takes the same machine time on a laptop and on a build agent. - Reading beats sleeping. Guessing a frame count and hoping is how a script starts failing on someone else's hardware.
wait_for_boot,wait_for_query_containsandrun_until_pcstop when the machine reaches a state, and report the frame they stopped on.
A script or MCP?
Both drive the same session through the same verbs. What separates them is who decides the next step.
Write a script when you know the whole run before it starts: boot a machine for a screenshot, load a tape and capture what it paints, run a test image and save the result. The file is one artefact you can commit, diff and hand to someone else.
Reach for MCP when the next step depends on what the last one returned — chasing a bug, walking a program's memory, anything where you would want to look before choosing. A script cannot look.
What works today
All 30 machines take --script PATH, and --script implies headless: no window opens. The step vocabulary is 47 actions and it is the same 47 on every binary, down to the spelling. What varies between machines is which of them a machine can carry out.
| Need | Steps |
|---|---|
| Advance execution | run_frames, run_ticks, step, run_until_pc, run_until_any_pc, run_until_line, run_until_mem_change |
| Wait for a state | wait_for_boot, wait_for_query_contains, wait_for_query_bool |
| Inspect | query_paths, query, query_cpu, query_ay, memory_read, disasm, port_read |
| Change memory and ports | poke_byte, poke_word, port_write |
| Watch for writes | watch_memory_start, watch_memory_log, watch_memory_clear, watch_ay_start, watch_ay_log, watch_ay_clear |
| Input | press_key, press_keys, type_string, input |
| Media and state | load_media, media_transport, autoload_tape, load_basic_program, load_snapshot, save_snapshot, set_machine, reset |
| Capture | save_screenshot, start_audio_recording, stop_audio_recording, save_audio_capture, clear_audio_capture, start_video_recording, stop_video_recording |
| Source-level debugging | load_debug_info, debug_symbol |
Writing a script
The file is a JSON array. Each step is an object with an action and whatever that action needs. Here is one that boots a machine, reads its text screen back, and saves a PNG — put it in boot.json:
[
{"action":"wait_for_boot","max_frames":300},
{"action":"query","path":"screen.text.lines"},
{"action":"save_screenshot","path":"boot.png"}
]Run it against a Spectrum:
./emu198x-spectrum --script boot.jsonThe machine needs its firmware first, found the same way a window finds it — the getting started page covers where each one looks. Nothing prints while it runs; when it finishes, boot.png is on disk and one JSON report is on standard output. Trimmed to the part worth reading:
{"kind":"wait_for_boot","frames":87,"reached":24321024,"reason":"found copyright banner on row 23","row":23}
{"kind":"query","result":{"path":"screen.text.lines","value":[ ... ,"© 1982 Sinclair Research Ltd "]}}Every step that has something to report answers with what the machine did rather than a promise that it did something. wait_for_boot stopped on frame 87 and says why it believed the machine had booted.
That same file runs on a different machine without a word changed, because nothing in it names Spectrum hardware:
./emu198x-c64 --script boot.jsonA C64 boots to READY. instead of a copyright line, so the text that comes back differs — but the steps, the report and the PNG all arrive the same way.
Typing at the machine
Input is where a script starts to look like someone at the keyboard. This one waits for the Spectrum to boot, works out two plus two on it, reads the answer off the screen, and photographs the result:
[
{"action":"wait_for_boot","max_frames":300},
{"action":"press_key","key":"P"},
{"action":"type_string","text":"2"},
{"action":"press_keys","keys":["SymbolShift","K"]},
{"action":"type_string","text":"2\n"},
{"action":"query","path":"screen.text.lines"},
{"action":"save_screenshot","path":"sum.png"}
]Each of those steps is the real keyboard matrix, not a shortcut around it. Pressing P at a 48K BASIC prompt gives the whole PRINT keyword, which is why the script never spells it out. press_keys holds Symbol Shift and K together to reach the + sign, because that is where the plus lives on a rubber-key Spectrum. The trailing \n is Enter.
{"kind":"press_key","key":"P","hold_frames":3,"reached":25439232}
{"kind":"press_keys","keys":["SymbolShift","K"],"hold_frames":3,"reached":30471168}
{"kind":"query","result":{"path":"screen.text.lines","value":["4 ", ... ,"0 OK, 0:1 "]}}A key is held for three frames and released, the way a finger does, and the screen says 4. Nothing in that run needed a person, and running it again produces the same four.
Finding out what a machine accepts
Three things are worth knowing before writing a longer script, and all three are questions the binary will answer.
Its headless flags. Plain --help prints the flags for the window and stops there. The automation flags live behind a second help screen:
./emu198x-spectrum --headless --helpThose flags differ sharply between machines, and several are shorthand for a step. On the Spectrum, --tape game.tap is the same thing as a load_media step on slot tape-1, and the help says so.
Two machines have no second help screen: the ZX80 and the ZX81 reject --headless outright. Their scripting works — --script takes the same vocabulary there as everywhere else — so a script written for another machine still runs. Their flags are readable only from the source.
Its query paths. A query_paths step lists every path the machine publishes, and takes an optional prefix to keep the answer short. Ask before hard-coding: a Spectrum publishes 65 paths and a C64 well over twice that, and the names past cpu., run., session. and capture. are the hardware's own.
Its step vocabulary. No file lists it, so the shortest route is to be wrong on purpose. Feed the binary a step that cannot exist and the rejection names all 47:
echo '[{"action":"tell_me_the_steps"}]' > probe.json
./emu198x-spectrum --script probe.jsonGetting real software to load
Inserting a tape or a disk is one step. Persuading the machine to read it is the machine's own ritual, and it differs on every one of them. These were each worked out by loading commercial software and watching what happened, so they are what worked rather than what ought to.
| Machine | The ritual | The part that catches people |
|---|---|---|
| ZX Spectrum | autoload_tape on tape-1 | The only machine with no --screenshot flag, so its captures go through a save_screenshot step. |
| Commodore 64 | --disk and --autoload-disk, then type_string "RUN\n" | Wait for drive8.motor_on to go true and then false. A fixed frame count is unusable — a real 1541 load runs anywhere from 5,000 to 15,000 frames depending on the title. |
| Amiga | --disk; it boots itself | Budget about 1,500 frames before anything appears. A blank frame at 300 is a machine still booting, not a broken one. |
| BBC Micro | *TAPE, then CHAIN"" | --sideways 12=basic.rom is required. The runtime installs only the MOS, so without it there is no BASIC and therefore no CHAIN to type. |
| Acorn Electron | CHAIN"" | Type the quotes as press_keys ["shift", "2"]. type_string drops a " without saying so, and reports it as typed. |
| Acorn Atom | *LOAD"NAME", then load_media, then a bare return | The COS prints PLAY TAPE and waits for a keypress — that return is the whole trick. The name has to be read out of the UEF; an empty one is refused. |
| Dragon 32 | CLOADM then EXEC, or CLOAD then RUN | Which pair depends on whether the tape holds machine code or BASIC. The wrong one gives ?FM ERROR. |
| ZX81 | LOAD "", then media_transport, then R to run | Two traps. Keys need hold_frames: 20 — the default is dropped by the keyboard scan. And --ram-bytes 16384, or every 16K title fails looking exactly like a bad tape. |
| Amstrad CPC | RUN", then return at the prompt | Same quote problem as the Electron. Tapes are .cdt only; a .wav is refused. |
Two things worth knowing before you spend an afternoon on a machine that is behaving strangely. media_transport works on the Spectrum and is refused by the BBC, Electron and Dragon, so on those three the deck free-runs and a script cannot stop or rewind it. And a tape that appears to stall may only be slow: re-run at a much higher frame budget before concluding anything, because a load in progress and a machine that has hung look identical from outside.
What you need that we cannot give you
Firmware and software, the same as any other way of running these machines. Emu198x ships no ROMs, no BIOS images, and no disks, tapes or cartridges; the downloads page sets out the position on that. A machine that needs firmware fails before the first step runs, and names the file it went looking for.
That travels with the script. A script that loads media names a path on your disk, so handing the file to someone else hands them the steps and not the tape. Scripts that lean only on firmware — a boot capture, a BASIC session — are the ones that survive the trip.
What does not work yet
- A script cannot react. There is no branch, no loop and no way to feed one step's answer into the next. That rules out any run whose shape depends on the outcome: you cannot script "keep playing until the last life is gone", because the file has no way to find out. The working approach is to run wide — more frames than the outcome should need — and confirm from what came back, reading a counter on the screen rather than trusting the frame count. A run that has to decide as it goes belongs on MCP.
- A failed step takes the report with it. Any step that errors ends the run: the message goes to standard error, the exit status is non-zero, and nothing is printed for the steps that already succeeded. A script that dies on step nine tells you nothing about steps one to eight.
- The vocabulary is even; the support is not. Every binary accepts all 47 step names, and a machine that cannot carry one out says so at the moment it is asked —
query_ayon a C64 comes back as a step needing a handler that machine does not have. No call lists which steps a machine implements, so the answer is found by asking. - The flags around the script are per-machine. The C64 takes
--frames,--screenshotand--wait-for-booton the command line; the Spectrum takes none of the three and rejects them outright. Reading one machine's headless help teaches you that machine and no other. The steps inside the file are the portable part. - The report envelope is per-machine too. The
observationsarray is common ground, and the fields beside it are not: a Spectrum closes with its tape state, a C64 with boot and trace fields, a NES with whether a cartridge is loaded. Readobservationsand treat the rest as that machine's own. - No script on standard input.
--scripttakes a path and only a path; passing-looks for a file called-. A generated script has to be written to disk before it can be run. - No published schema. There is no JSON Schema to validate a script against before running it, and no editor completion. Mistakes surface as a rejection at load time, which is early, but it is still the binary doing the proofreading.