Source code for romulan.upload_rom
#!/usr/bin/env python3
"""Plain-text ROM upload for the Pico-as-ROM 65C02 firmware.
Uploads a 32 KB ROM image over USB serial using the firmware's ``loadbin``
handshake: the host sends ``loadbin``, the Pico responds with
``OK send 32768 bytes``, the host streams 32,768 raw bytes, and the Pico
acknowledges with ``loaded 32768 bytes``.
Also handles CPU reset sequencing and ROM-emulator toggling so the CPU
restarts cleanly on the new image.
"""
from __future__ import annotations
import glob
import sys
import time
from pathlib import Path
try:
import serial
import serial.tools.list_ports
except ImportError:
sys.stderr.write(
"ERROR: pyserial is required. Install with:\n"
" pip install --user pyserial\n"
)
sys.exit(1)
ROM_SIZE = 0x8000 # 32 KB — must match firmware
# Raspberry Pi Pico USB VID
RPI_VID = 0x2E8A
[docs]
def find_pico_port() -> str:
"""Auto-detect a Raspberry Pi Pico serial port.
Tries USB vendor ID ``0x2E8A`` (Raspberry Pi) first, then falls back to
common port name patterns on Linux, macOS, and Windows.
Returns:
The device path of the detected Pico (e.g. ``/dev/ttyACM0``).
Raises:
RuntimeError: If zero or more than one matching port is found.
"""
# First try pyserial's list_ports with VID filter
ports = list(serial.tools.list_ports.comports())
pico_ports = [
p.device
for p in ports
if p.vid == RPI_VID or (p.manufacturer and "Raspberry Pi" in p.manufacturer)
]
if len(pico_ports) == 1:
return pico_ports[0]
if len(pico_ports) > 1:
raise RuntimeError(
f"Multiple Raspberry Pi Pico devices found: {pico_ports}. "
"Please specify one with --port."
)
# Fallback: guess by port name patterns
patterns = [
"/dev/ttyACM*",
"/dev/ttyUSB*",
"/dev/cu.usbmodem*",
"/dev/tty.usbmodem*",
]
candidates = []
for pattern in patterns:
candidates.extend(glob.glob(pattern))
# On Windows, COM ports don't glob; list_ports should have caught them.
# If we still have nothing, check for COM ports via list_ports.
if not candidates and sys.platform == "win32":
candidates = [p.device for p in ports]
if len(candidates) == 1:
return candidates[0]
if len(candidates) > 1:
raise RuntimeError(
f"Multiple serial ports found: {candidates}. "
"Please specify one with --port."
)
raise RuntimeError(
"No Raspberry Pi Pico serial port found.\n"
"Please ensure your Pico is connected and running the firmware, "
"or specify the port explicitly with --port."
)
[docs]
def read_until(ser: serial.Serial, needle: str, timeout: float = 3.0) -> str:
"""Read from a serial port until a substring appears or time runs out.
Non-empty lines received are echoed to stdout prefixed with ``<<``.
Args:
ser: An open :class:`serial.Serial` connection.
needle: Substring to search for in the accumulated receive buffer.
timeout: Maximum wait time in seconds. Defaults to ``3.0``.
Returns:
The full receive buffer once ``needle`` is found.
Raises:
TimeoutError: If ``needle`` is not seen before the deadline.
"""
deadline = time.time() + timeout
buf = ""
while time.time() < deadline:
chunk = ser.read(256).decode(errors="replace")
if chunk:
buf += chunk
for line in chunk.splitlines():
line = line.strip()
if line:
print(f" << {line}")
if needle in buf:
return buf
raise TimeoutError(f"never saw {needle!r} in {buf!r}")
[docs]
def upload(port: str, path: Path) -> None:
"""Upload a 32 KB ROM image via the plain-text ``loadbin`` protocol.
Side effects:
Opens the serial port at 115200 baud, asserts CPU reset (``r0``),
disables ROM emulation (``roms``), streams the binary, re-enables ROM
and clock (``rom``, ``c100``), releases reset (``r1``), captures ~5 s
of live output, then closes the port.
Args:
port: Serial device path for the Pico.
path: Path to the ROM binary; must be exactly :data:`ROM_SIZE` bytes.
Raises:
SystemExit: If ``path`` is not exactly 32 KB (via ``sys.exit``).
TimeoutError: If the firmware does not respond during handshake.
"""
data = path.read_bytes()
if len(data) != ROM_SIZE:
sys.exit(
f"ERROR: {path} is {len(data)} bytes, expected exactly "
f"{ROM_SIZE} ({ROM_SIZE // 1024} KB)"
)
print(f"Opening {port} ...")
ser = serial.Serial(port, 115200, timeout=0.2)
time.sleep(0.3)
ser.reset_input_buffer()
# Make sure the CPU isn't actively reading from the ROM region while we
# rewrite it — the firmware also disables `rom` internally, but flipping
# it explicitly + asserting reset is cleaner and stops the CPU.
print(">> assert RESET")
ser.write(b"r0\n")
time.sleep(0.05)
print(">> rom off")
ser.write(b"roms\n")
time.sleep(0.05)
ser.reset_input_buffer()
print(">> loadbin")
ser.write(b"loadbin\n")
read_until(ser, "OK send", timeout=3.0)
print(f">> sending {len(data)} bytes ...")
t0 = time.time()
ser.write(data)
ser.flush()
read_until(ser, "loaded", timeout=10.0)
dt = time.time() - t0
print(f" ({dt:.2f} s, {len(data) / dt / 1024:.1f} KB/s)")
print(">> rom on")
ser.write(b"rom\n")
time.sleep(0.05)
print(">> watch 4000")
ser.write(b"watch 4000\n")
time.sleep(0.05)
print(">> c100 (start 100 kHz clock — safe with flaky 3.3 V RAM)")
ser.write(b"c100\n")
time.sleep(0.05)
print(">> release RESET")
ser.write(b"r1\n")
print("\n--- live output for 5 s (Ctrl-C to stop early) ---")
try:
deadline = time.time() + 5.0
while time.time() < deadline:
chunk = ser.read(256).decode(errors="replace")
if chunk:
sys.stdout.write(chunk)
sys.stdout.flush()
except KeyboardInterrupt:
pass
print("\n--- end of capture ---")
ser.close()
print("Done. CPU is still running your new ROM image.")
print("Re-attach with: screen /dev/ttyACM0 115200")
[docs]
def main() -> None:
"""CLI entry point for standalone ``upload-rom.py`` usage.
Accepts optional ``[PORT] [BIN]`` arguments; auto-detects the port and
defaults to ``bin/rom.bin`` when omitted.
Side effects:
Delegates to :func:`upload`, which opens the serial port and drives
the Pico firmware.
"""
args = sys.argv[1:]
port = args[0] if len(args) >= 1 else find_pico_port()
binp = Path(args[1]) if len(args) >= 2 else Path(__file__).parent / "bin" / "rom.bin"
upload(port, binp)
if __name__ == "__main__":
main()