Hardware API¶
The host talks to the Pico over USB-CDC at 115200 baud using a framed serial protocol.
All payloads are JSON with "v":1: including the chunked, base64-encoded ROM upload.
An optional "id" is echoed back in the matching response. This is designed for scripted
bring-up and CI.
Framing¶
Every transaction follows the same byte sequence. The receiver always sends ACK (or NACK on error) after EOT, whether the host or the Pico is sending:
Sender Receiver
ENQ (0x05) ────────►
STX (0x02) ────────►
◄──────── ACK (0x06) ← receiver ready for payload
payload bytes ────────►
EOT (0x04) ────────►
◄──────── ACK (0x06) or NACK (0x15) ← accepted/rejected
Byte |
Value |
Meaning |
|---|---|---|
ENQ |
|
Start frame |
STX |
|
Start payload |
ACK |
|
Ready / accepted |
EOT |
|
End payload |
NACK |
|
Rejected (bad frame, unknown command, or payload too large) |
Important
Do not open a plain serial monitor on the port while using the Hardware API; unstructured
output corrupts framing. Only one process may hold the port at a time. The monitor
command also prints unframed lines (NDJSON in the v1 output schema), and that state
persists on the Pico until you disable it or start a read capture
(which auto-disables it).
Commands¶
All commands are JSON sent in a framed payload (host → Pico), and every request includes
"v":1. The Pico responds with a framed JSON payload (Pico → host).
Command |
Request |
Response |
|---|---|---|
reset |
|
|
upload_rom |
|
per-phase acks; |
read |
|
ack, then poll |
read_event |
|
|
clock |
|
|
request_addr |
|
|
peek |
|
bytes from |
monitor |
|
enables/disables the JSON bus monitor (off by default) |
status |
|
full hardware snapshot (clock, reset, ROM, monitor, last bus sample, raw pins) |
drive |
|
force D0–D7 to a byte, or release the bus |
reset¶
Assert or release the 6502 RESET line (GP27). Use "assert":true to hold the CPU in
reset, "assert":false to let it run.
upload_rom¶
A JSON-only chunked transfer (up to 32768 raw bytes per chunk, base64-encoded; a full ROM fits in one chunk):
{"v":1,"cmd":"upload_rom","action":"begin","size":32768}{"v":1,"cmd":"upload_rom","action":"chunk","offset":0,"data":"<base64>"}, repeat until all 32 KB have been sent.{"v":1,"cmd":"upload_rom","action":"commit"}→{"v":1,"ok":true,"reset_vector":"8000",...}
RESET is asserted for the duration of the upload so the CPU cannot fetch half-written ROM
data. Commit keeps RESET asserted so the host can arm capture from $8000 before release.
read¶
Captures bus activity as JSON. Streams batched events until the CPU fetches STP
(0xDB on a read cycle) or max_cycles is reached. Each batch contains up to batch_size
cycles:
{"v":1,"type":"event","event":"cycles","cycles":[{"seq":1,"addr":"8000","data":"18","rw":0}]}
rw is 0 = read, 1 = write. On this build it is inferred from A15 (ROM
$8000–$FFFF → read, RAM $0000–$7FFF → write) because Pico 2 GP23 cannot sense CPU
RWB on the header. STP stop still uses a ROM read of $DB.
Final event:
{"v":1,"type":"event","event":"done","ok":true,"reason":"stp","cycles":14,"addr":"800D"}
To use this in automated tests, end your ROM with a STP (0xDB) instruction (not
BRK, that opcode is 0x00). Starting a read automatically disables the JSON monitor
on the Pico and releases any active drive diagnostic.
By default read also releases RESET immediately after arming capture (release_reset
defaults to true). This ensures the first captured cycle is the reset-vector fetch after
the upload_rom commit workflow leaves the CPU held in reset. Set release_reset:false if
you want to arm capture while keeping the CPU halted.
batch_size defaults to 1 if omitted, and is clamped to a firmware maximum (64). Hosts
that do not send batch_size receive the legacy single-cycle event; hosts that send it
receive the batched cycles array. The Romulan client defaults to 32.
At the default 1 kHz clock (~1 ms per PHI2 cycle), cycle events are polled quickly over
USB. The Romulan host client uses a serial/frame timeout (default 30 s, configurable)
while draining read_event responses. A full capture from reset through STP is typically
under a second for short demo programs.
request_addr¶
Returns the last address sampled on the bus (updated every PHI2 rising edge).
peek¶
One command, two modes, selected by which field is present (addr and offset
together are rejected with bad_request; neither is also bad_request).
ROM-image mode (offset, optional count) reads back bytes from the currently
loaded rom_image[]. Useful for verifying that an upload landed at the expected
offsets before releasing RESET.
{"v":1,"cmd":"peek","offset":28672,"count":16}
Response:
{"v":1,"ok":true,"cmd":"peek","offset":28672,"count":16,"data":"A9...."}
count is capped at 64 bytes and clipped to the 32 KB ROM bounds.
Live mode (addr as a hex string, with or without 0x) reads one byte from
the live bus: the firmware holds the CPU in reset, patches LDA $addr / STP at
$8000 (and points the reset vector there), runs the CPU briefly at 1 kHz, samples
the bus cycle whose address matches addr, then restores the ROM bytes and clock
speed. The CPU is left held in reset — do not rely on CPU state surviving a live
peek.
{"v":1,"cmd":"peek","addr":"4000"}
Response:
{"v":1,"ok":true,"cmd":"peek","addr":"4000","data":"14"}
Errors: busy (a capture or upload is in progress), no_cycle (no matching bus
cycle within 1 s — CPU not running or bus fault). Live mode reads live RAM only if
RAM OE# = NOT(RWB) is wired; with OE# tied high it sees the open bus. Peeking
the stub/vector bytes ($8000–$8003, $FFFC–$FFFD) returns the patched values
the CPU saw mid-stub — use ROM-image mode for the stored bytes.
status¶
Returns a full hardware snapshot: clock frequency, reset state, ROM active flag,
whether the JSON monitor is enabled, the last bus sample (last_addr, last_data,
last_rw), the active drive diagnostic state, and the raw pin levels (resb, rwb,
a15, phi2) in a single JSON response.
{
"v":1,"ok":true,"cmd":"status",
"phi2_hz":1000.0,"rom_active":true,"reset_asserted":false,
"last_addr":"8000","last_data":"18","last_rw":0,
"read_active":false,"monitor_enabled":false,"upload_active":false,
"drive_enabled":false,
"resb":1,"rwb":1,"a15":1,"phi2":1
}
monitor¶
Toggles the JSON bus monitor (disabled by default). When enabled, every CPU bus cycle is printed as an unframed NDJSON line in the v1 output schema — the same envelope the romulan CLI emits:
{"v":1,"type":"event","event":"monitor","data":{"seq":1,"addr":"8000","data":"EA","rw":0,"hz":1000.0}}
Monitor lines are not framed and are suppressed while a framed command exchange
is in progress (between the host’s ENQ and the end of the Pico’s response); lines
resume once the exchange completes. Still, they share the wire with the framed
protocol, so disable the monitor before scripted upload/read (romulan’s
upload_rom() and read_until_stp() do this automatically):
api.monitor(enable=False)
clock¶
Sets the PHI2 clock frequency independently of starting a capture. Accepts "hz" as a
float in the range 0.1–1000.0. The response echoes the actual frequency after rounding
to the nearest microsecond half-period.
{"v":1,"cmd":"clock","hz":100}
Response:
{"v":1,"ok":true,"cmd":"clock","hz":100}
drive¶
Diagnostic command that forces the Pico to drive a byte onto D0–D7, or releases the bus and returns to normal ROM emulation. The CPU should be held in reset before forcing the data bus, otherwise the Pico and CPU contend.
Force the bus:
{"v":1,"cmd":"drive","value":"EA"}
Release the bus:
{"v":1,"cmd":"drive","enable":false}
Response:
{"v":1,"ok":true,"cmd":"drive","enabled":true,"value":"EA"}
drive is automatically disabled by upload_rom begin, read, and upload_rom abort.
Use read (JSON cycle stream) for automated tests; reserve monitor for manual
breadboard observation.