Romulan¶
Host client for the Piclone 65C02 system — builds ROM images from annotated hex dumps or 6502 assembly and talks to the Pico over the framed v1 JSON Hardware API (USB serial).
Full documentation: romulan.big-iron.dev
Features¶
Parse annotated hex dumps — address, byte, and optional comment per line
Assemble 6502/65C02 source — mnemonics, labels,
.org,.byte/.word; input format auto-detectedBuild 32 KB ROM images — auto-fill with NOPs (
$EA), validate reset/IRQ vectorsFramed Hardware API (v1) — JSON over ENQ/STX/ACK/EOT for scripted control and ROM upload
Verbose protocol traces —
-v/--verboseon--uploadand onhardwaresubcommands (NDJSON SEND/RECV)Bus capture — stream CPU cycles until
STPor a cycle limitCross-platform port detection — auto-detect the Pico on Linux, macOS, and Windows
65C02 opcode validation — catch undefined opcodes before they reach hardware
Installation¶
Requires Python 3.11+ and uv:
git clone https://github.com/big-iron-cde/romulan.git
cd romulan
uv sync
Quick Usage¶
Build a ROM from an annotated hex file:
uv run romulan demo.txt --build
Or from 6502 assembly (demo.s is the assembly counterpart of demo.txt; the input format is auto-detected):
uv run romulan demo.s --build
Build and upload in one step (framed Hardware API):
uv run romulan demo.txt --build --upload
uv run romulan demo.s --build --upload
uv run romulan demo.s --build --upload -v # NDJSON protocol trace during upload
Upload an existing binary (optional -v / --timeout):
uv run romulan --upload
uv run romulan --upload -v --timeout 45
-v / --verbose works on the standard --upload path as well as on romulan hardware … commands.
Hardware API commands¶
The --port flag is optional when exactly one Pico is connected. Add --verbose (-v) for NDJSON protocol traffic on any hardware command (and on --upload above).
uv run romulan hardware upload bin/rom.bin
uv run romulan hardware capture --max-cycles 500
# Use a longer timeout for slow operations (e.g. large uploads or long captures)
uv run romulan hardware capture --max-cycles 500 --timeout 45
# Hold CPU in reset
uv run romulan hardware reset --assert
uv run romulan hardware reset --release
uv run romulan hardware monitor --disable
uv run romulan hardware request-addr
# Read back bytes from the loaded ROM image (CPU $F000 == offset 0x7000)
uv run romulan hardware peek --offset 0x7000 --count 16
# Live-peek a CPU bus/RAM byte (briefly resets the CPU)
uv run romulan hardware peek --addr 0x4000
# Set the 65C02 clock speed
uv run romulan hardware clock --hz 100
# Query firmware state
uv run romulan hardware status
Python client¶
from romulan.hardware_api import HardwareAPI
with HardwareAPI("/dev/ttyACM0") as api:
print(api.status())
api.reset(assert_reset=True)
api.upload_rom(open("bin/rom.bin", "rb").read())
# Verify the byte at CPU $F000 before releasing reset
print(api.peek(offset=0x7000, count=16).data.hex())
api.set_clock(hz=100.0)
api.reset(assert_reset=False)
capture = api.read_until_stp(max_cycles=500)
print(capture.reason, len(capture.cycles))
Input formats¶
--build accepts two source formats, auto-detected from the file contents (the extension doesn’t matter). Either way, the result is a 32 KB ROM image and the vectors are required — the builder rejects images without them.
Annotated hex format¶
Each line is address, byte, and an optional @ comment:
0x0000 0x18 @ CLC
0x0001 0xA9 @ LDA 0x05
0x0002 0x05
0x0003 0x8D @ STA $4000
0x0004 0x00
0x0005 0x40
...
0x7FFC 0x00 @ Reset vector (low)
0x7FFD 0x80 @ Reset vector (high)
0x7FFE 0x00 @ IRQ/BRK vector (low)
0x7FFF 0x80 @ IRQ/BRK vector (high)
File addresses (
0x0000–0x7FFF) map to CPU addresses$8000–$FFFFComments are optional — everything after
@is ignoredVectors at
0x7FFC–0x7FFFare required — the builder will reject images without them
6502 assembly¶
A built-in two-pass assembler covers the official NMOS 6502 mnemonics plus the W65C02 additions used by the course (STP, WAI, BRA, PHX/PHY/PLX/PLY, STZ, TRB/TSB, accumulator INC/DEC, (zp) indirect). Comments start with ;:
.org $8000 ; CPU address (omit to start at $8000)
reset: CLC ; labels end with ':'
LDA #$05 ; immediate
STA $4000 ; absolute ($10 would be zero page)
BNE reset ; relative branches
STP
.org $FFFC
.word reset ; reset vector (little-endian)
.word reset ; IRQ/BRK vector
Numbers:
$FF,0xFF, or decimalDirectives:
.org,.byte v1, v2, …,.word v1, v2, …Addressing modes: implied, accumulator, immediate, zero page, absolute,
,X/,Yindexed,JMP ($1234),($10,X),($10),Y,($10), relativeA numeric operand under
$100uses the zero-page form when the mnemonic has one (soJMP $0000still encodes as4C 00 00); label operands are always absoluteAll emitted bytes must land in the ROM region
$8000–$FFFF; operand values are unrestricted
CLI Reference¶
Standard CLI¶
Flag |
Description |
Default |
|---|---|---|
|
Path to the input file: annotated hex dump or 6502 assembly (required with |
— |
|
Build a |
— |
|
Upload the ROM image to the Pico (framed Hardware API) |
— |
|
Output ROM binary path |
|
|
Serial port for the Pico (auto-detected if omitted) |
Auto-detect |
|
Idle timeout (seconds) with no framing progress on upload |
|
|
Hardware API NDJSON traces during |
— |
At least one of --build or --upload is required, but --upload can only be used after a successful --build or if a valid ROM binary already exists at the output path.
Hardware API¶
Romulan speaks the Piclone firmware’s v1 JSON protocol over USB-CDC at 115200 baud. Each transaction uses byte-level framing (ENQ → STX → ACK → payload → EOT → ACK/NACK); all payloads include "v": 1.
Subcommand |
Arguments |
Description |
|---|---|---|
|
|
Upload a ROM binary via the framed protocol |
|
|
Capture CPU bus cycles until STP or max cycles reached |
|
|
Toggle JSON monitor output |
|
|
Hold or release the CPU reset line |
|
|
Request the current CPU address |
|
|
Read ROM-image bytes ( |
Flag |
Description |
Default |
|---|---|---|
|
Print every JSON message sent and received over the serial protocol |
— |
|
Idle timeout in seconds with no framing/capture progress |
|
Full firmware-side protocol reference: Piclone Hardware API docs.
Captured cycles include rw: 0 = read, 1 = write. Piclone firmware on Pico 2 infers this from A15 (ROM region = read, RAM region = write), not from a wired RWB sense pin—so STA/store cycles report rw=1 and opcode fetches report rw=0.
hardware peek --addr is a live bus/RAM read (the firmware runs a short LDA/STP stub and samples the matching cycle). It is not a ROM-image offset read — use --offset for that. The CPU is held in reset around the peek. Requires piclone wiring RAM OE# = NOT(RWB) and a current piclone flash (older ROM-image-only firmware answers live requests with a ROM-mode response, which romulan rejects with a clear error).
Verbose example¶
$ uv run romulan hardware request-addr --verbose
{"v":1,"type":"event","event":"port_detected","data":{"port":"/dev/ttyACM0","auto_detected":true}}
{"v":1,"type":"event","event":"call","data":{"method":"request_addr"}}
{"v":1,"type":"event","event":"send","data":{"payload":{"v":1,"cmd":"request_addr","id":"abc123"}}}
{"v":1,"type":"event","event":"ack"}
{"v":1,"type":"event","event":"ack"}
{"v":1,"type":"event","event":"recv","data":{"payload":{"v":1,"ok":true,"addr":"8000"}}}
{"v":1,"type":"event","event":"ret","data":{"method":"request_addr","result":32768}}
{"v":1,"type":"result","cmd":"request_addr","data":{"addr":"8000"}}
Architecture¶
Memory Map¶
The 32 KB ROM image maps directly to the 65C02 address space:
File Offset |
CPU Address |
Purpose |
|---|---|---|
|
|
Start of ROM |
|
|
Reset vector (low byte) |
|
|
Reset vector (high byte) |
|
|
IRQ/BRK vector (low byte) |
|
|
IRQ/BRK vector (high byte) |
Documentation¶
The complete documentation — getting started, CLI reference, Hardware API client guide, and Python API reference — is published at https://big-iron-cde.github.io/romulan/.
Build and view locally:
make docs-serve # build + serve at http://127.0.0.1:8000
make docs # build only → docs/_build/html
Or manually:
uv sync --group docs
uv run sphinx-build -W docs docs/_build/html
uv run python -m http.server 8000 --directory docs/_build/html
Testing¶
uv run pytest
License¶
Released under the MIT License.