picoZ80 Technical Guide
picoZ80 Technical Guide
This guide documents the picoZ80 hardware architecture, RP2350 PIO bus interface, memory model, JSON configuration reference, virtual device framework, and debugging procedures. It is intended for developers who want to understand the internals, write new drivers, port the firmware to a new host machine, or debug firmware-level issues.
For end-user setup and web interface usage, see the
picoZ80 User Manual. For the project overview and build instructions see the
picoZ80 project page.
Hardware Architecture
The picoZ80 integrates five subsystems on a single compact PCB designed to fit within the footprint of a DIP-40 package. All logic operates at 3.3V; the Z80 bus interface handles level translation and current drive for the 5V host bus.
System Block Diagram
┌─────────────────────────────────────────────────────────────────────────┐
│ picoZ80 PCB │
│ │
│ ┌────────────────────────────┐ ┌──────────────────────────────┐ │
│ │ RP2350B │ │ ESP32-S3 │ │
│ │ (Cortex-M33, dual core) │ │ │ │
│ │ │ │ ┌──────┐ ┌──────────────┐ │ │
│ │ Core 0: USB, file I/O, │◄────►│ │ SD │ │ Web Server │ │ │
│ │ ESP32 relay │ FSPI │ │ Card │ │ (Bootstrap) │ │ │
│ │ Core 1: Z80 bus hot loop │ UART │ └──────┘ └──────────────┘ │ │
│ │ │ │ │ │
│ │ PIO 0,1,2: bus interface │ │ WiFi ─── 802.11 b/g/n AP │ │
│ │ │ │ or Client mode │ │
│ │ 16MB SPI Flash │ └──────────────────────────────┘ │
│ │ 8MB PSRAM (SPI) │ │
│ └────────────────────────────┘ │
│ │ │
│ ┌────────┴────────┐ │
│ │ Z80 Bus Interface│ │
│ │ (40-pin DIP out) │ │
│ └────────┬────────┘ │
│ │ 5V bus (A0–A15, D0–D7, MREQ, IORQ, RD, WR...) │
└────────────────┼────────────────────────────────────────────────────────┘
│
┌───────┴───────┐
│ Host Z80 │
│ DIP-40 socket│
│ (legacy │
│ computer) │
└───────────────┘
Key Components
| Component |
Device |
Role |
| Primary MCU | RP2350B (QFN-80) | Dual Cortex-M33, 150MHz (up to 300MHz OC), 512KB SRAM, 12 PIO state machines, 48 GPIO pins |
| Flash | W25Q128 (16MB SPI) | Bootloader, dual firmware slots, config partitions |
| PSRAM | 8MB SPI PSRAM | 64 × 64KB RAM/ROM banks for Z80 address space |
| Co-processor | ESP32-S3-PICO-1 | WiFi, SD card, web server, OTA |
| USB hub | CH334F | USB hub, firmware update bridging |
| Power supply | TLV62590BV | 5V → 3.3V synchronous buck converter |
RP2350B GPIO Assignment
The RP2350B QFN-80 package provides 48 GPIO pins. The picoZ80 uses virtually every pin. The assignment is fixed in the board design and reflected in the PIO programs:
| GPIO Range |
Signals |
Direction |
| GPIO 0–15 |
A0–A15 (Z80 Address Bus) |
Output (driven by PIO) |
| GPIO 16–23 |
D0–D7 (Z80 Data Bus) |
Bidirectional (PIO tri-state) |
| GPIO 24 |
MREQ |
Output |
| GPIO 25 |
IORQ |
Output |
| GPIO 26 |
RD |
Output |
| GPIO 27 |
WR |
Output |
| GPIO 28 |
M1 |
Output |
| GPIO 29 |
RFSH |
Output |
| GPIO 30 |
BUSREQ |
Input |
| GPIO 31 |
BUSACK |
Output |
| GPIO 32 |
HALT |
Output |
| GPIO 33 |
INT |
Input |
| GPIO 34 |
NMI |
Input |
| GPIO 35 |
WAIT |
Output |
| GPIO 36 |
CLK |
Input (host clock) |
| GPIO 37 |
RESET |
Input |
| GPIO 38–41 |
ESP32 FSPI (CS, CLK, MOSI, MISO) |
SPI |
| GPIO 42–43 |
ESP32 UART (TX, RX) |
UART |
| GPIO 44–45 |
PSRAM SPI |
SPI |
| GPIO 46–47 |
USB (D+, D–) |
USB |
Firmware Architecture
The RP2350 firmware is built with the Raspberry Pi Pico SDK 2.x targeting the RP2350-arm-s platform. The firmware is divided into two independent executables: the Bootloader and the Application.
Flash Memory Layout
| Partition |
Address Range |
Size |
Contents |
| Bootloader |
0x10000000 – 0x1001FFFF |
128KB |
USB bridge, firmware update, partition selector |
| App Slot 1 |
0x10020000 – 0x1051FFFF |
5MB |
Z80 firmware — active application (slot 1) |
| App Slot 2 |
0x10520000 – 0x10A1FFFF |
5MB |
Z80 firmware — active application (slot 2) |
| App Config 1 |
0x10A20000 – 0x10C9FFFF |
2.5MB |
ROM images + minified config JSON (slot 1) |
| App Config 2 |
0x10CA0000 – 0x10F1FFFF |
2.5MB |
ROM images + minified config JSON (slot 2) |
| General Config |
0x10F20000 – 0x10FFEFFF |
892KB |
Core settings, scratch space |
| Partition Table |
0x10FFF000 – 0x11000000 |
4KB |
Active slot number, checksums, metadata |
Dual-Core Responsibilities
The two Cortex-M33 cores are assigned completely separate responsibilities and communicate via an inter-core message queue (queue_t). This separation ensures that non-real-time work on Core 0 never introduces jitter into the Z80 bus transactions on Core 1.
| Core |
Responsibilities |
| Core 0 |
USB CDC-serial bridge; firmware update coordination; file I/O (relayed to ESP32 over UART); ESP32 command dispatch (disk image changes, config reloads, version queries); partition management; inter-core message dispatch. |
| Core 1 |
Z80 bus emulation hot loop — runs exclusively. Services PIO FIFOs, resolves each bus transaction against the memory map, and dispatches to: physical host hardware (PHYSICAL), PSRAM (RAM/ROM), or virtual device handler (FUNC). Inner loop is placed in SRAM. |
PIO Bus Interface
The Z80 bus interface is implemented entirely in RP2350 PIO assembly (
z80.pio). The RP2350 provides three PIO blocks (PIO 0, PIO 1, PIO 2) each with four state machines — twelve state machines in total, of which the Z80 firmware uses all twelve.
PIO programs execute independently of the Cortex-M33 cores. The bus interface continues to respond deterministically even when Core 1 is occupied with PSRAM accesses or virtual device function calls. State machines communicate via PIO IRQ flags rather than polling, eliminating inter-machine latency.
PIO Program Table
| PIO |
State Machine |
Program |
Function |
| 0 |
SM 0 |
z80_addr |
Outputs the 16-bit address (A0–A15) onto the bus and signals cycle start to SM 2. |
| 0 |
SM 1 |
z80_data |
Drives or samples D0–D7 with tri-state control; released during BUSRQ. |
| 0 |
SM 2 |
z80_cycle |
Top-level bus cycle sequencer — orchestrates fetch, read, write, I/O, and DRAM refresh cycles. |
| 0 |
SM 3 |
z80_fetch |
Opcode-fetch cycle (M1 + MREQ + RD). |
| 1 |
SM 0 |
z80_mem_read |
Memory read cycle (MREQ + RD). |
| 1 |
SM 1 |
z80_mem_write |
Memory write cycle (MREQ + WR). |
| 1 |
SM 2 |
z80_io_read |
I/O read cycle (IORQ + RD). |
| 1 |
SM 3 |
z80_io_write |
I/O write cycle (IORQ + WR). |
| 2 |
SM 0 |
z80_busrq |
Manages BUSREQ/BUSACK; releases /IORQ, /MREQ, /RFSH, /M1, /HALT, /WR, /RD. |
| 2 |
SM 1 |
z80_nmi |
Detects NMI assertion and signals Core 1. |
| 2 |
SM 2 |
z80_clk_sync |
Synchronises PIO state machines to the host Z80 CLK signal. |
| 2 |
SM 3 |
z80_int_ack |
Handles interrupt-acknowledge cycles (M1 + IORQ). |
PIO IRQ Signal Conventions
Inter-state-machine communication uses PIO IRQ flags. Core 1 monitors these flags in the hot loop to take action on each bus event:
| IRQ |
Event |
| IRQ 0 |
Address valid / cycle start — a new bus cycle has begun and A0–A15 are stable. |
| IRQ 1 |
Data phase — data bus direction has been resolved; D0–D7 should be driven or sampled. |
| IRQ 2 |
T1 detected — the rising edge of T1 on the current cycle. Used to synchronise internal operations to the host clock. |
| IRQ 3 |
RESET event — the host RESET line has been asserted. Core 1 should reinitialise emulation state. |
| IRQ 4 |
NMI detected — host NMI line asserted. |
| IRQ 6 |
BUSRQ active — host has asserted BUSREQ; PIO is releasing the bus. |
Wait State Generation
The
z80_wait PIO program in PIO 2 SM 0 inserts configurable T-cycle wait states on the host bus by asserting
/WAIT. The number of additional wait states is controlled per memory or I/O block by the
tcycwait parameter in
config.json.
Wait states are necessary when the RP2350 needs additional time to complete a PSRAM access or a virtual device function call before presenting data to the host bus. The
tcycsync parameter enables T1 synchronisation (
z80_sync in PIO 2 SM 1), which locks the PSRAM access window to the T1 rising edge of each bus cycle, preventing timing drift in applications that depend on the host clock for precise timing (cassette, serial bit-banging).
PIO Architecture — How Bus Cycles Are Recreated
The RP2350's Programmable I/O (PIO) subsystem is the key technology that allows the picoZ80 to recreate cycle-accurate Z80 bus timing. Understanding how the PIO state machines work together is essential for anyone modifying the bus interface or debugging timing issues.
RP2350 PIO Fundamentals
Each PIO block contains four independent state machines (SMs) that execute small programs from a shared 32-instruction memory. State machines run independently of the Cortex-M33 cores at the system clock frequency (up to 300 MHz). Key PIO resources used by the picoZ80:
- TX FIFO — a 4-entry queue from the CPU to the state machine. The C code on Core 1 pushes data (addresses, control words, injected instructions) into the FIFO; the PIO program pulls from it using
out or pull.
- RX FIFO — a 4-entry queue from the state machine to the CPU. The PIO pushes data bus samples into the FIFO using
in; Core 1 reads them after each bus cycle completes.
- IRQ flags — 8 flags (IRQ 0–7) shared across all state machines within a PIO block. Flags can also be seen across PIO blocks (IRQ 0–3 in one block map to IRQ 4–7 in adjacent blocks). State machines use
irq set / irq wait / irq clear to synchronise with each other and with the C code.
- Scratch registers X and Y — two 32-bit registers per SM used for loop counters and temporary values.
out exec — a special instruction that pulls a value from the TX FIFO and executes it as a PIO instruction. This is the mechanism by which the C code dynamically controls bus cycle sequences (see below).
set pins / out pins — drive GPIO pins directly. set uses an immediate 5-bit value; out shifts data from the output shift register (OSR) to the pins.
in pins — samples GPIO pins into the input shift register (ISR), then auto-pushes to the RX FIFO.
wait gpio — stalls the SM until a specific GPIO pin reaches a specified level. Used extensively to synchronise with the host Z80 clock signal.
- Side-set — allows one or two GPIO pins to be driven as a side-effect of any instruction, without using an instruction cycle. The picoZ80 uses 2-bit side-set to control
/RD and /WR simultaneously with other operations.
- JMP PIN — conditional jump based on a designated GPIO pin level. Used to test
/WAIT, BUSREQ, /NMI, and /RESET.
The out exec Mechanism — Dynamic Instruction Injection
The most distinctive feature of the picoZ80 PIO design is the use of
out exec, 16 in the
z80_cycle orchestrator state machine. This instruction pulls a 16-bit value from the TX FIFO and executes it immediately as a PIO instruction — the value is not data, it
is the next instruction the state machine will run.
This mechanism allows the C code on Core 1 to control the bus cycle sequence in real time. Rather than loading a fixed PIO program for each cycle type, Core 1 pushes a sequence of pre-encoded PIO instructions into the TX FIFO, and the cycle SM executes them one by one:
// z80_cycle SM (PIO 0 SM 2) — the orchestrator
//
// .program z80_cycle
// .side_set 2 opt
// public start_cycle:
// wait 0 irq 6 ; Pause if BUSACK is active (bus relinquished).
// irq set 0 ; Signal "ready for new cycle".
// wait 0 irq 0 ; Wait until C code clears IRQ 0 (address loaded).
// wait 1 gpio Z80_PIN_CLK ; Sync to T1 rising edge of host clock.
// cycle_exec:
// out exec, 16 ; ← Pull next instruction from TX FIFO and execute it.
// jmp cycle_exec ; Loop: keep executing injected instructions.
//
// The C code pushes a sequence of encoded PIO instructions into the FIFO.
// Each instruction controls one step of the bus cycle (assert /MREQ, wait for
// clock edge, read data bus, etc.). The sequence ends with a JMP back to
// start_cycle, which restarts the orchestrator for the next bus transaction.
The C code pre-computes these instruction sequences at startup for each cycle type (fetch, memory read, memory write, I/O read, I/O write, refresh, interrupt acknowledge). During execution, Core 1 selects the appropriate pre-built sequence and pushes it into the FIFO. This approach has two critical advantages:
- Program space efficiency — each PIO block has only 32 instruction slots. By injecting instructions dynamically, the cycle SM needs only 7 instructions of program memory to orchestrate all cycle types. The actual cycle-type programs (fetch, read, write, etc.) exist as C arrays of encoded instructions, not as resident PIO programs.
- Flexibility — the C code can modify the injected instruction sequence at runtime to handle special cases (e.g. inserting extra wait states, skipping the refresh phase, or generating a non-standard cycle for debugging).
State Machine Coordination
The 12 state machines work as a coordinated pipeline. The following diagram shows the flow of a typical memory read cycle:
Core 1 (C code) PIO State Machines
───────────── ──────────────────
1. Resolve address z80_cycle: IRQ 0 set
from memory map (waiting for work)
│
2. Push addr → TX FIFO ──────────────────→ z80_addr: receives addr
Clear IRQ 0 outputs A0–A15 on pins
│
3. Push cycle instructions ──────────────→ z80_cycle: out exec, 16
(e.g. mem_read sequence) executes: set /MREQ low
into cycle SM TX FIFO executes: set /RD low
│ executes: wait CLK edges
4. Wait for RX FIFO ←──────────────────── z80_data: samples D0–D7
(data byte from bus) pushes to RX FIFO
│
5. Read data from z80_cycle: JMP start_cycle
RX FIFO (ready for next cycle)
│
6. Dispatch to PSRAM
or driver handler
The IRQ-based handshake ensures that the address bus is stable before control signals are asserted, and that data is sampled at the correct point in the bus cycle. The state machines never poll — they use
wait 0 irq N to sleep until the relevant event occurs, consuming zero CPU cycles while waiting.
Z80 Fetch Cycle (M1 Cycle) — Step by Step
The opcode fetch is the most complex Z80 bus cycle — it combines a memory read with a refresh cycle. The z80_fetch program executes over 4 T-cycles of the host clock:
Host CLK: ──┐ ┌──┐ ┌──┐ ┌──┐ ┌──
│ │ │ │ │ │ │ │
└──┘ └──┘ └──┘ └──┘
T1 T2 T3 T4
A0–A15: ══╤═══ PC address ══════╤═══ Refresh addr ══╗
│ │ ║
/M1: ──┘ └────────────────────╜── (low during T1–T2, high T3–T4)
/MREQ: ────┘ ┌────┘ ┌──── (low T1↓–T3↑, then T3↓–T4↓ for refresh)
/RD: ────┘ ┌─────────────────────── (low T1↓–T3↑)
/RFSH: ────────────────────┘ ┌── (low T3↑–T4↓)
D0–D7: ═══════════════╤═══╗ (sampled at T3↑)
│ ║
opcode read
The PIO program implements this as follows:
- T1 rising edge —
z80_addr SM outputs the PC value onto A0–A15. z80_fetch asserts /M1 low via set pins.
- T1 falling edge —
/MREQ and /RD are asserted low (via set pins and side-set). The address is now valid and the memory system can begin responding.
- T2 — the SM enters a wait-state loop: it waits for CLK rising then falling edge, then checks the
/WAIT pin via jmp pin. If /WAIT is low, the SM loops (adding Tw cycles). If high, it proceeds to T3.
- T3 rising edge —
in pins, 8 samples D0–D7 (the opcode byte) and pushes it into the RX FIFO. IRQ 1 is set to signal z80_data/z80_addr that the refresh address should now be output. /M1, /MREQ, and /RD are deasserted; /RFSH is asserted low.
- T3 falling edge —
/MREQ is asserted again (for the refresh row strobe).
- T4 — refresh continues. At the end of T4,
/MREQ and /RFSH are deasserted. The cycle SM returns to start_cycle ready for the next bus transaction.
Core 1 reads the opcode from the RX FIFO and uses it to decode the instruction, determine how many subsequent memory or I/O cycles are needed, and push the appropriate instruction sequences.
Memory Read and Write Cycles
Memory read and write cycles are simpler than the fetch — they span 3 T-cycles with no refresh phase.
Memory Read:
Host CLK: ──┐ ┌──┐ ┌──┐ ┌──
│ │ │ │ │ │
└──┘ └──┘ └──┘
T1 T2 T3
A0–A15: ══╤═══ address ═══════╗
/MREQ: ────┘ ┌──── (low T1↓–T3↓)
/RD: ────┘ ┌──── (low T1↓–T3↓)
D0–D7: ═══════════════╤═══╗ (sampled at T3↓)
Memory Write:
Host CLK: ──┐ ┌──┐ ┌──┐ ┌──
│ │ │ │ │ │
└──┘ └──┘ └──┘
T1 T2 T3
A0–A15: ══╤═══ address ═══════╗
D0–D7: ══════╤═══ data ═════╗ (driven from T2 onwards)
/MREQ: ────┘ ┌──── (low T1↓–T3↓)
/WR: ──────────┘ ┌──── (low T2↓–T3↓)
For reads, the
z80_mem_read SM asserts
/MREQ and
/RD at T1 falling edge, waits through T2 (checking
/WAIT for wait states), then samples the data bus at T3 falling edge using
in pins, 8. For writes,
z80_mem_write asserts
/MREQ at T1 falling edge, then asserts
/WR at T2 falling edge after the data bus is driven by
z80_data. Both deassert all control signals at the end of T3.
I/O Read and Write Cycles
Z80 I/O cycles use /IORQ instead of /MREQ and always include an automatic wait state (Tw) between T2 and T3. This is a Z80 architectural feature — the extra cycle gives slower I/O devices time to respond:
I/O Read:
Host CLK: ──┐ ┌──┐ ┌──┐ ┌──┐ ┌──
│ │ │ │ │ │ │ │
└──┘ └──┘ └──┘ └──┘
T1 T2 Tw T3
A0–A15: ══╤═══ port address ══════════╗
/IORQ: ──────┘ ┌──── (low T2↑–T3↓)
/RD: ──────┘ ┌──── (low T2↑–T3↓)
D0–D7: ═══════════════════════╤═══╗ (sampled at T3↓)
The
z80_io_read SM asserts
/IORQ and
/RD at T2 rising edge (not T1 as with memory cycles — this is the Z80 specification). The automatic Tw wait state is implemented by the same
jmp pin /
wait loop pattern used for memory cycles. I/O writes follow the same pattern with
/WR replacing
/RD.
BUSREQ / BUSACK Handling
The
z80_busrq SM (PIO 2 SM 0) monitors the host
/BUSREQ input pin. When
/BUSREQ goes active (low), the SM:
- Sets IRQ 6 to signal the cycle SM that a bus request is pending.
- Waits for the current bus cycle to complete (
wait 1 irq 0).
- Pulls a 32-bit control word from the TX FIFO that specifies the pin directions and values for the bus-release state — this tristates the address and data buses and asserts
/BUSACK low.
- Spins on
jmp pin until /BUSREQ goes inactive (high).
- Pulls a second 32-bit word to restore normal pin directions and deassert
/BUSACK.
- Clears IRQ 6, allowing the cycle SM to resume.
The cycle SM checks IRQ 6 at the start of every cycle via
wait 0 irq 6 — if the flag is set, the SM stalls until the bus request is complete. This ensures bus release happens cleanly between cycles, never mid-cycle.
Clock Synchronisation
All cycle-type SMs synchronise to the host Z80 clock using
wait 1 gpio Z80_PIN_CLK (wait for rising edge) and
wait 0 gpio Z80_PIN_CLK (wait for falling edge). This means:
- The PIO programs are clock-frequency independent — they work at any host clock speed from DC to the maximum rate the RP2350 can track (limited by the PIO system clock and GPIO sampling rate).
- The RP2350's 300 MHz PIO clock provides approximately 85 PIO cycles per Z80 T-state at 3.5 MHz, giving more than enough time to execute PIO instructions, push/pull FIFOs, and check IRQ flags between clock edges.
- The
z80_sync SM (PIO 2 SM 1) provides a T1 synchronisation IRQ that the C code uses to align PSRAM accesses with the host clock, preventing timing drift in clock-sensitive host software.
- The
z80_clk_sync SM (PIO 2 SM 2) regenerates the host clock on a separate GPIO, providing a clean clock output for external monitoring or logic analyser triggering.
Interrupt Acknowledge Cycle
The
z80_int_ack SM (PIO 2 SM 3) implements the Z80 interrupt acknowledge sequence. When the C code detects an interrupt condition, it loads the int_ack program. This cycle is similar to a fetch but with key differences:
/M1 is asserted at T1 (like a fetch), but /IORQ is asserted instead of /MREQ at the wait state (Tw1).
- Two automatic wait states (Tw1, Tw2) are inserted to give the interrupting device time to place a vector on the data bus.
- The vector byte is read from D0–D7 and pushed to the RX FIFO.
- A refresh cycle follows, identical to the fetch refresh phase.
Memory Model
Memory accesses are resolved through three tiers of increasing latency. The three-tier design ensures that the common case (PSRAM-backed RAM/ROM) is fast while allowing maximum flexibility for virtual devices and physical host pass-through.
Tier 1 — RP2350 SRAM Dispatch Table
A 128-entry array of 32-bit
membankPtr values, resident in the RP2350's 512KB on-chip SRAM, provides an O(1) block-type lookup for every bus transaction. One entry covers each 512-byte block of the 64KB Z80 address space (128 × 512 = 65,536 bytes). Each entry encodes:
- The block type (PHYSICAL, RAM, ROM, FUNC, etc.).
- For PSRAM-backed blocks: the PSRAM bank number and offset.
- For FUNC blocks: an index into the virtual device function pointer table.
This is the fastest path — Core 1 reads the dispatch table entry for the current address in a single SRAM access (zero wait states at 300MHz) before deciding what to do next.
Tier 2 — External PSRAM
The 8MB PSRAM is organised as:
- 64 banks × 64KB — RAM or ROM image data for the Z80 address space.
- 64KB
memPtr — per-byte redirect pointer array for PTR-type blocks.
- 64KB
memioPtr — function pointer array for memory-mapped FUNC devices.
- 64KB
ioPtr — function pointer array for I/O port FUNC devices.
PSRAM is accessed via the RP2350's dedicated SPI peripheral with DMA. Access latency is deterministic and managed by the wait-state generator to avoid bus violations.
Tier 3 — 16MB SPI Flash
ROM images are loaded from Flash (or the SD card, via the ESP32) into PSRAM at boot. At runtime the Flash is not accessed for bus transactions — all ROM data is served from PSRAM. The Flash is used for:
- Bootloader and application firmware.
- Minified
config.json (cached from SD card on each boot).
- ROM images in App Config partitions (used when no SD card is present).
Memory Block Types
| Type |
Description |
PHYSICAL |
Pass-through — the RP2350 releases the bus and the physical host memory responds. Used for the host’s native ROM and RAM. |
PHYSICAL_VRAM |
As PHYSICAL but with additional wait states for host video RAM timing. Suitable for MZ-700/MZ-80A VRAM regions. |
PHYSICAL_HW |
Pass-through for host hardware registers (I/O-mapped devices in memory space). |
RAM |
Read/write — backed by a PSRAM bank. The RP2350 services reads and writes from/to PSRAM. |
ROM |
Read-only — backed by a PSRAM bank. Write cycles are silently ignored (the host sees normal bus timing but no data is stored). |
VRAM |
PSRAM-backed video RAM. Write cycles are mirrored to both PSRAM and the physical host VRAM simultaneously. |
FUNC |
Virtual device — each access triggers a C function call via the memioPtr or ioPtr function pointer table, enabling arbitrary I/O emulation. |
PTR |
Per-byte redirect — each byte of the 512-byte block can independently point to any other block type or PSRAM location. |
Configuration Reference
All picoZ80 behaviour is controlled by
config.json on the SD card. The RP2350 reads and minifies this file at boot, storing the result in Flash. Subsequent boots use the Flash copy if no SD card is present.
The top-level JSON structure is:
{
"esp32": {
"core": { ... },
"wifi": { ... }
},
"rp2350": {
"core": { ... },
"z80": [ { "memory": [...], "io": [...], "drivers": [...] } ]
}
}
esp32.core
| Key |
Type |
Description |
device |
string |
CPU personality — "Z80" for picoZ80, "6502" for pico6502, "6512" for pico6512. |
mode |
integer |
Default WiFi boot mode: 0 = client (station), 1 = Access Point. |
esp32.wifi
| Key |
Type |
Description |
override |
0/1 |
Master switch: 1 = apply all settings below; 0 = use persisted NVS settings. |
wifimode |
string |
"ap" = Access Point mode; "client" = Station/client mode. |
ssid |
string |
WiFi network name to create (AP) or join (client). |
password |
string |
WiFi passphrase. |
ip |
string |
Fixed IP address (e.g. "192.168.1.192"). |
netmask |
string |
Subnet mask (e.g. "255.255.255.0"). |
gateway |
string |
Default gateway (e.g. "192.168.1.1"). |
dhcp |
0/1 |
Client mode: 1 = DHCP; 0 = use fixed IP settings. |
webfs |
string |
Web filesystem root directory on SD card (default "webfs"). |
persist |
0/1 |
1 = write resolved settings to NVS for persistence across reboots. |
rp2350.core
| Key |
Type |
Description |
cpufreq |
integer |
RP2350 system clock in Hz (e.g. 300000000). Maximum stable frequency depends on PSRAM frequency and core voltage. |
psramfreq |
integer |
PSRAM SPI clock in Hz (e.g. 133000000). |
voltage |
float |
RP2350 core voltage in volts (e.g. 1.10). Higher clock speeds require higher voltage. |
addrDrive |
integer (0–3) |
Address bus GPIO drive strength: 0=2mA, 1=4mA, 2=8mA, 3=12mA. Default 0. |
addrSlew |
integer (0–1) |
Address bus GPIO slew rate: 0=slow (default), 1=fast. |
dataDrive |
integer (0–3) |
Data bus GPIO drive strength. Same encoding as addrDrive. Default 0. |
dataSlew |
integer (0–1) |
Data bus GPIO slew rate. Same encoding as addrSlew. Default 0. |
ctrlDrive |
integer (0–3) |
Control signal GPIO drive strength. Same encoding as addrDrive. Default 0. |
ctrlSlew |
integer (0–1) |
Control signal GPIO slew rate. Same encoding as addrSlew. Default 0. |
addrSchmitt / dataSchmitt / ctrlSchmitt |
0/1 |
Enable the input Schmitt trigger for the address / data / control bus group. 1 = enabled (default), 0 = disabled. Applies to pins configured as inputs. |
addrPull / dataPull / ctrlPull |
integer (0–2) |
Pull resistor for the address / data / control bus group: 0 = none, 1 = pull-down, 2 = pull-up. |
refresh |
integer |
Z80 DRAM refresh generation: 0 = off (no refresh cycles), 1 = a refresh cycle on every opcode fetch, N = one refresh cycle per N fetches. Reduces bus overhead on hosts whose DRAM does not require per-fetch refresh. |
pinOverrides |
array |
Per-pin GPIO overrides. Each element: { "pin": N, "drive": D, "slew": S, "schmitt": 0/1, "pull": P }. Overrides the bus-level defaults for individual GPIO pins when specific hardware requires different electrical characteristics. |
z80[].memory — Memory Map Entries
The memory array defines the Z80 memory map. Entries must be ordered by address. Regions must be aligned to and sized as multiples of 512 bytes. Gaps between entries are treated as PHYSICAL pass-through.
| Key |
Type |
Description |
enable |
0/1 |
Whether this entry is active. Disabled entries are ignored at boot. |
addr |
hex string |
Start address in the Z80 address space (e.g. "0x0000"). Must be 512-byte aligned. |
size |
hex string |
Region size in bytes (e.g. "0x2000" for 8KB). Must be a multiple of 512. |
type |
string |
Block type — see Memory Block Types. |
bank |
integer |
PSRAM bank number (0–63) for RAM/ROM/VRAM/FUNC types. |
tcycwait |
integer |
Additional T-cycle wait states to insert on each access to this region. |
tcycsync |
0/1 |
Enable T1 synchronisation for this region. Required for timing-sensitive regions. |
task |
string |
Optional task identifier for FUNC-type blocks (driver binding string). |
file |
string |
SD-card path to a ROM image to preload into the PSRAM bank at boot (e.g. "/ROM/mz700.rom"). |
fileofs |
integer |
Byte offset into the ROM image file to start reading from. |
z80[].io — I/O Port Map Entries
The io array maps Z80 I/O port ranges to block types. Only PHYSICAL and FUNC types are meaningful for I/O entries.
| Key |
Type |
Description |
enable |
0/1 |
Whether this I/O entry is active. |
addr |
hex string |
Start I/O port address (e.g. "0xE0"). |
size |
hex string |
Number of consecutive ports (e.g. "0x04" for ports E0–E3). |
type |
string |
PHYSICAL = pass to host; FUNC = call C handler function. |
task |
string |
Driver binding string for FUNC-type entries. |
z80[].drivers — Driver Instances
The drivers array instantiates virtual device drivers and binds them to memory or I/O regions. Each driver has a type (the C driver module), a name (instance identifier), and one or more interface objects that define the ROM images, address maps, I/O maps, and parameters for that driver instance.
"drivers": [
{
"enable": 1,
"name": "MZ700",
"type": "PHYSICAL",
"if": [
{
"enable": 1,
"name": "main",
"type": "PHYSICAL",
"rom": [
{
"enable": 1,
"file": "/MZ700/mz700.rom",
"loadaddr": [
{
"enable": 1,
"position": 0,
"addr": "0x0000",
"bank": 0,
"size": "0x1000",
"tcycwait": 0,
"tcycsync": 0
}
]
}
],
"addrmap": [
{ "enable":1, "srcaddr":"0x0000", "size":"0x1000",
"dstaddr":"0x0000" }
],
"iomap": [
{ "enable":1, "srcaddr":"0xE0", "size":"0x08",
"dstaddr":"0xE0", "16bit":0 }
],
"param": [
{ "enable":1, "file":"/config/mz700.cfg" }
]
}
]
},
{
"enable": 1,
"name": "MZ-1E05",
"type": "PHYSICAL",
"if": [
{
"enable": 1,
"name": "fdc0",
"type": "PHYSICAL",
"rom": [],
"addrmap": [],
"iomap": [
{ "enable":1, "srcaddr":"0xD8", "size":"0x04",
"dstaddr":"0xD8", "16bit":0 }
],
"param": [
{ "enable":1, "file":"/DSK/MZ700/disk0.dsk" }
]
}
]
}
]
Each driver entry has a top-level "name" (the C driver module to instantiate), "type" (PHYSICAL or VIRTUAL), and an "if" (interface) array containing one or more interface objects. Each interface object describes a sub-driver or peripheral card and carries these fields:
| Key |
Type |
Description |
enable |
0/1 |
Whether this interface is active (0 = skipped during init). |
name |
string |
Instance identifier — must match a name in the persona’s interfaceFuncMap[]. |
type |
string |
PHYSICAL or VIRTUAL. |
rom |
array |
ROM images to load into PSRAM (see below). |
addrmap |
array |
Memory address remapping entries (srcaddr/dstaddr/size). |
iomap |
array |
I/O port remapping entries (srcaddr/dstaddr/size/16bit). |
param |
array |
Driver-specific parameters — typically { "enable":1, "file":"/path/to/image" } for disk images, { "name":"key", "value":"val" } for named parameters, or { "ip":"a.b.c.d:port", "enable":1 } for the Celestite network file server address. |
Relocatable interface base ports. So the machine-agnostic cards can be used on custom / experimenter boards (see OpenZ80), several interface drivers read their base I/O port from the dstaddr of their iomap entry (with srcaddr set to the card’s authentic port), defaulting to their original port when no iomap entry is present. The relocatable cards and default bases are MZ-1R12 (0xF8/3), MZ-1R18 (0xEA/2), MZ-1R23 (0xB8/2), MZ-1R37 (0xAC/2), PIO-3034 (0x00/4), MZ-8BIO3 / MZ-1E24 (0xB0/4), MZ-1E05 (0xD8/7) and Celestite (0x60/16). The JSON keys are lowercase (srcaddr / dstaddr) and their values are parsed as numbers. The Base I/O Port field on the GUI Configuration page writes the correct iomap entry automatically.
rom[].loadaddr[] — ROM Load Addresses
Each ROM file entry contains a loadaddr array specifying where the ROM data is loaded into PSRAM. Multiple loadaddr entries allow a single ROM file to be split across non-contiguous address ranges or PSRAM banks.
| Key |
Type |
Description |
enable |
0/1 |
Whether this load address is active. |
position |
integer |
Byte offset within the ROM file to start reading from. |
addr |
hex string |
Target Z80 address in the memory map (e.g. "0x0000"). |
bank |
integer |
PSRAM bank number to load into. |
size |
hex string |
Number of bytes to load (e.g. "0x1000" for 4KB). |
tcycwait |
integer |
Extra T-cycle wait states for this region. |
tcycsync |
integer |
T-cycle synchronisation value for this region. |
Persona–Interface Compatibility
Not every interface driver is available for every persona. The table below shows the current Sharp MZ / X1 series interface compatibility. The Amstrad PCW-9512 persona is a self-contained driver with an integrated uPD765 FDC (no separate interface drivers). The Tatung Einstein TC-01 persona is a self-contained driver with an integrated WD1770 FDC and EinsteinFDC sub-interface. The
Open column shows the machine-agnostic cards accepted by the
OpenZ80 experimenter persona.
The table shows which interfaces can be used with each top-level machine persona via the "if" array. The interface "name" value in the JSON must match one of the supported entries for the chosen persona.
| Interface |
MZ-700 |
MZ-1500 |
MZ-80K |
MZ-800 |
MZ-80A |
MZ-2000 |
MZ-2200 |
MZ-80B |
MZ-2500 |
Open |
| RFS |
Yes |
Yes |
Yes |
Yes |
Yes |
Yes |
— |
— |
— |
— |
| TZFS |
Yes |
— |
— |
— |
— |
— |
— |
— |
— |
— |
| MZ-1E05 |
Yes |
Yes |
— |
Yes |
— |
— |
— |
— |
— |
Yes |
| MZ80FIO |
— |
— |
Yes |
Yes |
— |
— |
— |
— |
— |
— |
| MZ80AFI |
— |
— |
Yes |
Yes |
Yes |
— |
— |
— |
— |
— |
| MZ-8BFI / E0054PA |
— |
— |
— |
Yes |
— |
Yes |
Yes |
Yes |
Yes |
— |
| MZ-1E14 |
Yes |
Yes |
Yes |
Yes |
— |
— |
— |
Yes |
Yes |
— |
| MZ-1E19 |
Yes |
Yes |
Yes |
Yes |
— |
Yes |
Yes |
Yes |
Yes |
— |
| MZ-1R12 |
Yes |
Yes |
Yes |
Yes |
Yes |
Yes |
Yes |
Yes |
Yes |
Yes |
| MZ-1R18 |
Yes |
Yes |
Yes |
Yes |
Yes |
Yes |
Yes |
Yes |
Yes |
Yes |
| MZ-1R23 |
— |
Yes |
— |
Yes |
— |
— |
— |
Yes |
Yes |
Yes |
| MZ-1R37 |
— |
Yes |
Yes |
Yes |
— |
— |
— |
Yes |
Yes |
Yes |
| PIO-3034 |
— |
Yes |
Yes |
Yes |
— |
— |
— |
Yes |
Yes |
Yes |
| Celestite |
— |
Yes |
— |
Yes |
— |
— |
— |
Yes |
Yes |
Yes |
| MZ-8BIO3 |
Yes |
Yes |
— |
Yes |
— |
— |
— |
Yes |
— |
Yes |
| MZ-1E24 |
Yes |
Yes |
— |
Yes |
— |
— |
— |
Yes |
— |
Yes |
| MZ-1E30 |
— |
— |
— |
— |
— |
— |
— |
Yes |
Yes |
— |
Built-in Drivers
The firmware supports a targeted build system with five model targets: BaseZ80 (all drivers), SharpZ80 (Sharp only), AmstradZ80 (Amstrad only), TatungZ80 (Tatung only), and OpenZ80 (the machine-agnostic experimenter persona). The compile-time defines INCLUDE_SHARP_DRIVERS, INCLUDE_AMSTRAD_DRIVERS, INCLUDE_TATUNG_DRIVERS, and INCLUDE_OPEN_DRIVERS control which driver modules are compiled in. The tables below list all available drivers.
Sharp MZ Series (INCLUDE_SHARP_DRIVERS)
When the firmware is built with
INCLUDE_SHARP_DRIVERS, the following driver modules are compiled in and can be instantiated via the
drivers array:
| Driver |
Type String |
Description |
MZ700.c |
MZ700 |
Sharp MZ-700 bank switching, video, keyboard I/O |
MZ80A.c |
MZ80A |
Sharp MZ-80A — monitor ROM (SA-1510), VRAM, Intel 8253 PIT emulation, 8255 PPI, MEMSW/MEMSWR memory swap (including CP/M bank switching), physical+virtual mixed mode support, and support for RFS, MZ80AFI, MZ-1E14, MZ-1E19, MZ-1R12, MZ-1R18 sub-interfaces |
MZ2000.c |
MZ2000 |
Sharp MZ-2000 — BST/NST memory mode switching, character + graphics VRAM overlay, 8253 PIT, 8255 PPI, Z80 PIO, MB8866 FDC. Supports both physical mode (drop-in Z80 replacement with automatic boot/normal mode detection) and virtual mode (full PSRAM-based emulation with IPL ROM mirroring) |
MZ2200.c |
MZ2200 |
Sharp MZ-2200 — BST/NST memory mode switching, VRAM overlay, 8253 PIT, 8255 PPI, Z80 PIO, MB8866 FDC, colour CRT |
MZ80B.c |
MZ80B |
Sharp MZ-80B — 2K IPL ROM, BST/NST, monochrome display, dual GRPH pages, 8253 PIT, 8255 PPI, Z80 PIO |
MZ2500.c |
MZ2500 |
Sharp MZ-2500 (SuperMZ) — 8-page MMU (64 blocks), MZ-2000/MZ-80B compat modes, YM2203, G-CRTC, MB8876 FDC, palette, interrupt controller. Virtual mode supports interrupt-driven software via custom fetchByte/RETI handlers (physical M1 bus cycles for gate array clocking), D88 native disk format with sparse/contiguous auto-detection, one-shot interrupt suppression during initialisation |
MZ1500.c |
MZ1500 |
Sharp MZ-1500 — MZ-700 superset with inbuilt Quick Disk drive, PCG, stereo PSG sound (SN76489AN), Z80 PIO printer interface, 8253 PIT, DIP switch MZ-700/MZ-1500 mode selection. Sub-interfaces: RFS, MZ-1E05, MZ-1E14, MZ-1E19, MZ-1R12, MZ-1R18, MZ-1R23, MZ-1R37, PIO-3034, Celestite |
MZ80K.c |
MZ80K |
Sharp MZ-80K — SP-1002 monitor ROM (0x0000–0x0FFF), 2KB VRAM (0xD000–0xD7FF), 8255 PPI / 8253 PIT / LS367 keyboard (0xE000–0xE7FF), native MZ-80FD boot ROM (0xF000–0xF3FF), MEMSW/MEMSWR memory swap for CP/M. Physical + virtual modes (physical-mode CP/M uses per-driver Z80 access remap; virtual mode required for stock CP/M). Two floppy paths: native MZ80FIO (T3444M) — original MZ-80FD interface, boots/reads all MZ-80K disks; and MZ80AFI (MZ-80A FDC) — used for CP/M, boots MZ-80K CP/M and reads MZ-80K CP/M disks within CP/M (C:/D:). Only one floppy interface is active at once; MZ80FIO wins if both are present. Sub-interfaces: RFS, MZ80FIO, MZ80AFI, MZ-1E14, MZ-1E19, MZ-1R12, MZ-1R18, MZ-1R37, PIO-3034 |
MZ800.c |
MZ800 |
Sharp MZ-800 — dual-mode driver (MZ-700 compatibility + native MZ-800). Snoops the GDG Display-Mode register (port 0xCE) to switch modes on the fly: native mode adds 320×200 / 640×200 graphics (VRAM planes 0x8000–0xBFFF), 4/16-colour palette, port-mapped GDG I/O (0xCC–0xCF, palette 0xF0), IM2 vectored interrupts via the Z80-PIO daisy-chain; MZ-700 mode uses memory-mapped 8255/8253 (0xE000–0xE7FF) and text VRAM (0xD000). SN76489 PSG (0xF2), WD1773 FDC (0xD8–0xDF), QuickDisk (0xF4–0xF7), memory banking ports 0xE0–0xE6. Virtual mode replays a physical RETI on the real bus to service the PIO in-service latch. |
MZ80AFI.c |
MZ80AFI |
Sharp MZ-80A floppy interface — emulates the MZ-80A AFI floppy disk controller |
MZ80FIO.c |
MZ80FIO |
Sharp MZ-80FD/MZ-80FIO floppy interface (MZ-80K) — FDIF boot ROM at 0xF000–0xF3FF, T3444M ports 0xF8–0xFB, up to 4 drives, runtime disk change |
T3444M.c |
(used by MZ80FIO) |
Toshiba T3444M/T3444A FDC — MZ-80K native floppy. CPC extended DSK, 35 tracks, 2 heads, 16 sectors/track, 128-byte FM sectors; robust Track-Info DSK parser; 4 simultaneous drive images |
WD1773.c |
WD1773 |
WD1773 FDC — 80-track, 2-head, 8-sector DSK/RAW/D88 images |
QDDrive.c |
QDDRIVE |
Sharp QuickDisk drive — full Z80 SIO/2 emulation with spiral track data, motor control, and async SD card I/O |
RFS.c |
RFS |
ROM Filing System — MZF loading, CP/M, BASIC from SD card |
TZFS.c |
TZFS |
TranZPUter Filing System — working multi-bank monitor + CP/M filing system, selectable on the MZ-700 persona. tranZPUter memory modes via port 0x60, virtual K64F service processor via OUT (0x68), CP/M sector I/O over the ESP32. ROM roms/tzfs.bin from TZFS/asm/tzfs.asm |
MZ-1E05.c |
MZ1E05 |
Sharp MZ-1E05 floppy disk interface unit (WD1773-based) |
MZ8BFI.c |
MZ8BFI / E0054PA |
MZ-2000 floppy disk interface — MB8866 FDC without driver ROM (code in IPL). D88 format support. |
MZ-1E14.c |
MZ1E14 |
MZ-1E14 QuickDisk controller with BIOS ROM (MZ-700/MZ-800) |
MZ-1E19.c |
MZ1E19 |
MZ-1E19 QuickDisk controller without BIOS ROM |
MZ-1R12.c |
MZ1R12 |
32KB battery-backed RAM board (persisted to SD card) |
MZ-1R18.c |
MZ1R18 |
64KB RAM expansion board |
MZ-1R23.c |
MZ1R23 |
MZ-1R23 128KB Kanji ROM (16×16 JIS patterns) and MZ-1R24 256KB Dictionary ROM. ROM files loaded from SD card. I/O ports B8h–B9h with auto-increment read |
MZ-1R37.c |
MZ1R37 |
MZ-1R37 640KB EMM (Expanded Memory Manager) — 20-bit address space with I/O port address latching |
PIO-3034.c |
PIO3034 |
IO DATA PIO-3034 320KB EMM — 19-bit address counter with auto-increment data port |
Celestite.c |
Celestite |
Celestite composite board — Wiznet W5100 Ethernet controller (register emulation), interrupt controller, UFM, integrated MZ-1R12 32KB CMOS RAM (expandable to 64KB), optional MZ-1R37 640KB EMM. I/O ports 60h–6Fh. The ip parameter in the JSON param array configures the netfs.py file server address (e.g. "192.168.1.210:6800"). Phase 2: real TCP/IP networking via ESP32 bridge — W5100 socket commands forwarded to ESP32 for actual BSD socket operations. Inter-core IPC for NET_CFG, NET_SOCK, NET_SEND, NET_RECV, NET_PING |
MZ8BIO3.c |
MZ-8BIO3 |
RS-232C serial card (BI connector), emulated Z80 SIO at ports 0xB0–0xB3 (configurable base). Channels A/B bridged to USB CDC serial ports 2 and 3. No ROM. |
MZ1E24.c |
MZ-1E24 |
RS-232C serial card (Sharp ST connector); same as MZ-8BIO3 with different connector wiring. |
Z80SIO.c |
(used by MZ-8BIO3 / MZ-1E24) |
Register-accurate Zilog Z80 SIO/2: WR0–WR7, RR0–RR2, Z80 mode-2 vectored interrupts, 4-level in-service daisy-chain, status-affects-vector. Lock-free SPSC rings bridge the Z80 core (core 1) and the USB CDC service (core 0). |
SASI.c + MZ1E30.c |
MZ-1E30 |
MZ-1E30 SASI hard disk controller — emulates the Sharp MZ-1E30 SASI (Shugart Associates System Interface) hard disk controller for MZ-2500/MZ-80B. Up to 4 disk targets (~21.4 MB each, 256-byte blocks), 32KB IPL ROM (I/O-accessed via ports 0xA8–0xA9), on-demand sector I/O from SD card disk images. SASI commands: TEST_UNIT_READY, REQUEST_SENSE, READ(6), WRITE(6), SEEK(6), INQUIRY. I/O ports 0xA4–0xA5 (SASI data/control) |
PIT8253.c |
PIT8253 |
Standalone Intel 8253 PIT emulation — all six counter modes, BCD/binary, latch, LSB/MSB read/load |
PPI8255.c |
PPI8255 |
Standalone Intel 8255 PPI emulation — Mode 0 I/O, bit set/reset, output callbacks, input injection |
Amstrad PCW Series (INCLUDE_AMSTRAD_DRIVERS)
When the firmware is built with
INCLUDE_AMSTRAD_DRIVERS, the following driver modules are compiled in:
| Driver |
Type String |
Description |
PCW9512.c |
PCW9512 |
Amstrad PCW-9512 — Z80A @ 4MHz, 512KB RAM with 4-bank 16KB page switching (ports F0–F3), gate array (ASIC) for video/system clock/FDC routing/motor control (port F8), 8041 daisy wheel printer controller (ports FC–FD), bootstrap sequence emulation. Virtual and physical modes supported |
uPD765.c |
— |
NEC uPD765 FDC emulation — CPC DSK format support (ports 00–01). Gate array commands: end bootstrap, reboot, FDC INT routing (NMI/INT/ignore), terminal count, motor on/off. Physical disk imaging via injected Z80 code. Reusable FDC module (analogous to WD1773.c for Sharp) |
Tatung Einstein Series (INCLUDE_TATUNG_DRIVERS)
When the firmware is built with
INCLUDE_TATUNG_DRIVERS, the following driver modules are compiled in:
| Driver |
Type String |
Description |
EinsteinTC01.c |
EinsteinTC01 |
Tatung Einstein TC-01 — Z80A @ 4MHz, 64KB RAM + 8KB switchable ROM (X-TAL MOS), ROM/RAM toggle via port 0x24, TMS9129 VDP with inter-access timing enforcement (~2us gap), AY-3-8910 PSG (ports 0x02-0x03), Z80 CTC (ports 0x28-0x2B), Z80 PIO (ports 0x30-0x33), keyboard interface (port 0x20). Virtual and physical modes supported |
EinsteinFDC.c |
— |
Einstein FDC sub-interface — 2 drives, DSK/D88 format support. Physical disk imaging: read physical floppy to DSK, write DSK to physical floppy |
WD1770.c |
— |
WD1770 FDC emulation — Extended CPC DSK, D88, and standard DSK format support. 40 tracks, 1 head, 10 sectors, 512 bytes (200KB disks). Reusable FDC module for WD1770-based machines (separate from WD1773.c used by Sharp) |
TZFS — Monitor + CP/M Model (MZ-700 persona)
TZFS.c implements a working multi-bank low-level monitor and filing system — a set of enhancements over the original MONITOR 1Z-013A (SD access, ROM banking, an assembler / disassembler, and tools) — modelled on the tranZPUter SW's TZFS and its K64F virtual I/O processor. It is offered as a
selectable interface on the MZ-700 persona only (registered in
MZ700.c alongside RFS, and in practice mutually exclusive with it), not as a top-level persona. CP/M runs underneath the monitor.
Memory-mode emulation (port 0x60)
Writing a tranZPUter memory mode to I/O port
0x60 re-points the bank pointers. The modes are
TZMM_ORIG,
TZMM_BOOT,
TZMM_TZFS,
TZMM_TZFS2,
TZMM_TZFS3,
TZMM_TZFS4,
TZMM_CPM,
TZMM_CPM2 and
TZMM_COMPAT. The CP/M
CPM2 layout uses byte-granular block-0 paging —
0x0000–
0x003F map to the vectors block and
0x0040–
0x01FF to the start of the TPA.
Virtual K64F service processor (port 0x68)
The Z80 requests services by executing
OUT (0x68), which queues a
MSG_TZFS_SVCREQ message to Core 0; Core 0 runs
TZFS_processServiceRequest. Filing-system services are READDIR / NEXTDIR (cached 16-entry directory blocks), READFILE / NEXTREADFILE, LOADFILE (MZF-header and bank-object aware), CHANGEDIR and CLOSE. CP/M services are LOADBDOS (warm-boot CCP + BDOS reload), ADDSDDRIVE, READSDDRIVE and WRITESDDRIVE, plus CPU-frequency services. Because picoZ80 has no direct SD access, CP/M 512-byte sectors are read and written through the
ESP32 (
ESP_readSector /
ESP_writeSector) against whole-image files; per-drive image paths come from the interface JSON
param[].file entries, with fallback template
CPM/SDC16M/RAW/CPMDSK<nn>.RAW. The TZFS ROM is
roms/tzfs.bin on the SD card, assembled from
TZFS/asm/tzfs.asm (its CP/M BIOS from
TZFS/asm/cbios.asm /
cpm22.asm). It ships in
config_MZ-700_MZ-700.json with
"enable": 0 and is switched on in the JSON or from the web GUI Configuration page.
OpenZ80 — Experimenter Persona (INCLUDE_OPEN_DRIVERS)
The
OpenZ80 target (
TARGET_MODEL_OPEN →
INCLUDE_OPEN_DRIVERS) compiles a deliberately bare "vanilla" Z80 persona for experimenters fitting the picoZ80 into a board of their own design or a machine with no dedicated driver. In
PHYSICAL mode the full 64K memory and I/O space passes through to the real board (interface cards overlay their I/O ports); in
VIRTUAL mode it presents a flat 64K RAM into which driver-level ROM images are loaded sequentially from 0x0000. It carries no machine hardware and reuses the Sharp interface-card modules verbatim (they hold no persona-specific state), exposing only the machine-agnostic cards below — each of which supports
base I/O port relocation.
| Driver |
Type String |
Description |
Open.c |
Open |
Vanilla / experimenter Z80 persona — no machine hardware. Physical passthrough or flat 64K virtual RAM with driver-level ROM loading from 0x0000 |
MZ-1R12.c |
MZ1R12 |
MZ-1R12 32K RAM-file card (default base 0xF8, relocatable) |
MZ-1R18.c |
MZ1R18 |
MZ-1R18 64K RAM board (default base 0xEA, relocatable) |
MZ-1R23.c |
MZ1R23 |
MZ-1R23 Kanji / MZ-1R24 dictionary ROM board (default base 0xB8, relocatable) |
MZ-1R37.c |
MZ1R37 |
MZ-1R37 640K EMM (default base 0xAC, relocatable) |
PIO-3034.c |
PIO3034 |
PIO-3034 parallel EMM / parallel I/O (default base 0x00, relocatable) |
MZ8BIO3.c / MZ1E24.c |
MZ8BIO3 / MZ1E24 |
Dual RS-232C serial cards (Z80 SIO, default base 0xB0, relocatable; channels A/B on USB CDC 2/3) |
MZ-1E05.c |
MZ1E05 |
WD1773 floppy interface (default base 0xD8, relocatable; needs an FDC boot ROM) |
Celestite.c |
Celestite |
Celestite ESP32 LAN board (default base 0x60, relocatable) |
Build with
build_tzpuPico.sh open. To adapt the picoZ80 to a new machine, take one of the persona drivers under
src/drivers/{Sharp,Amstrad,Tatung,Other}/ as a base and pair it with the matching monitor / IPL / CP/M BIOS / floppy boot ROM source in the RFS and TZFS projects'
asm/ directories (e.g.
RFS/asm/sa1510.asm,
RFS/asm/cbios.asm,
TZFS/asm/mz2000_ipl.asm,
RFS/asm/mz80afi.asm). See the
Developer's Guide for the step-by-step procedure.
Virtual CMT (Cassette) Unit
The Virtual CMT is a
waveform-level virtual cassette deck for the Sharp MZ series. It emulates the cassette
waveform rather than intercepting the loader, so from the Sharp's side it is indistinguishable from a real deck: monitor LOAD, BASIC, custom and turbo loaders, and SAVE all work unchanged. SD-card loading remains the
fast path; the Virtual CMT is the
authentic path — take an
.mzf and load it "like a real cassette", or record a running program back out to a new
.mzf.
The driver is a single engine —
src/drivers/Sharp/CMT.c and
src/include/drivers/Sharp/CMT.h (
CMT_Init /
CMT_Reset /
CMT_PollCB /
CMT_TaskProcessor) — registered in each machine's
interfaceFuncMap[] as
{"CMT", false, CMT_Init, CMT_Reset, CMT_PollCB, CMT_TaskProcessor} and offered as a selectable interface wherever the machine has a cassette port. All timing runs against the
wall clock (
time_us_64() — the same one-shot timing domain used by the RFS CMT), never against emulated Z80 T-states. The full design is documented in
docs/VIRTUAL_CMT_DESIGN.md in the picoZ80 repository.
Two Hardware Families, One Engine
The MZ cassette hardware falls into two families, both driven by the same codec engine. The family is inferred from the active persona.
| Family |
Machines |
Bus / Ports |
Baud |
Half-wave (0 / 1) |
SIMPLE |
MZ-80K / MZ-80A / MZ-700 / MZ-800 |
8255 motor on/off, linear tape; memory-mapped 8255 at E000–E003 (MZ-800 instead uses I/O ports D0–D3) |
1200 baud |
0 ≈ 504 µs, 1 ≈ 958 µs (MZ-800 trims to ≈ 379 / 964 µs) |
CONTROLLED |
MZ-80B / MZ-2000 / MZ-2200 / MZ-2500 |
Computer-controlled transport (PLAY / STOP / FF / REW / EJECT + APSS program search); hooks ioPtr[] on E0–E3 |
2000 baud |
≈ 333 / 667 µs (≈ 1800-baud variant) |
Shared codec. Both families share the same byte, block and file framing. A
byte is a long start bit followed by 8 data bits, MSB first. A
block is a leader, a tape-mark, the data bytes, and a 16-bit checksum (a count of the 1-bits). A
file is a header (info) record followed by a data record, each written twice, so a single physical read has a second copy to fall back on.
The Virtual Tape — an Ordered Queue
The virtual tape is a GUI-configured ordered queue of up to 16 MZF files, editable live from the web GUI (add / remove / reorder). At the end of each program the queue auto-advances — back-to-back tape behaviour — so the next LOAD finds the next program. On the controlled family, FF / REW skip whole programs and APSS seeks to the next or previous program boundary and auto-stops. The queue is supplied to the driver through the interface param array as an ordered list of { "enable", "file" } entries. A new web GUI panel — esp32/webserver/js/cmt.js — manages the queue.
Real ↔ Virtual — Passthrough, Superimpose, Suppress
The live Real ↔ Virtual toggle is no longer CMT-only — it now covers the
CMT, Floppy (FDD) and Quick Disk (QD) interfaces. In the web GUI the deck toggle is a menu item:
CMT in the CMT menu;
Floppy and
QD as menu items available on all pages, each shown only when the corresponding interface is enabled. Selecting it flips a live driver flag via the ESP32 → RP2350 reverse-command queue, with no reboot. For a CMT the two modes differ in how the driver treats the physically connected deck:
- VIRTUAL — the driver superimposes the virtual cassette bits on the read side and suppresses motor and transport writes to the physical deck. The Sharp loads from the virtual tape and the real deck stays idle.
- REAL — pure passthrough. The CMT snoops read-only and the motor drives the real deck; the physical cassette behaves exactly as if the picoZ80 were not present.
Floppy and QD follow the same VIRTUAL/REAL contract:
Virtual = the firmware emulates the drive from SD-backed images (a virtual tape for the CMT);
Real = pure passthrough to the physically attached drive, with the firmware snooping read-only. A physically-configured interface now
starts in Real mode, with the menu and toggle available so it can be flipped to Virtual live (no reboot). Floppy and QD each also gained an
EJECT button (web-GUI Floppy / QD menu): an empty mount path unmounts the drive so the FDC reports no disk. Deck modes are reported via
INF — the CMT already reported its mode, joined now by the new
fddMode and
qdMode fields.
Implementation: a
DRIVE_MODE_CHANGE task with
FDDM /
QDDM reverse commands over the ESP32 → RP2350 reverse-command queue — the same mechanism as the CMT toggle — installs or uninstalls the per-driver bus hooks on each mode switch. In Real mode the real QD SIO passthrough is
paced: a 10 µs inter-access recovery floor on ports F4–F7, because the Z80 SIO needs several clock cycles of recovery between accesses.
Because the toggle is live, you can load from a virtual tape and then SAVE to a physical deck, or load from a physical deck and record to a virtual tape, without rebooting.
When the motor is running in record mode, the driver samples the write-data line and decodes it back into a file. Each half-wave interval is classified short or long, short/long pairs become bits, bits become bytes, and the 16-bit checksum is validated. From the recovered records an MZF is synthesised and saved to the SD card as:
CMT/taperecord_<name>_<YYYYMMDD_HHMMSS>.mzf
where
<name> is the Sharp header filename transcoded from Sharp display code to ASCII and then FAT-sanitised (illegal characters become
_). When the ESP32 clock has not been set, the timestamp falls back to a sequence number (
taperecord_<name>_0001.mzf) to avoid the FAT "1980" date collision. The real Sharp name is stored verbatim inside the MZF header, so the program keeps its original name when it is reloaded.
Playback Engine and Driver Internals
Playback uses a
streaming edge cursor that computes the next read-data transition on the fly against the wall clock. It is O(1) amortised and holds no giant pre-expanded edge buffer, so an entire tape image costs almost no RAM regardless of length. The two families hook the bus differently:
- Simple family — the driver re-types the cassette 8255 block (512 bytes, E000–E1FF = block 112) to
MEMBANK_TYPE_RAM and installs a whole-block handler. Non-cassette registers in that block — the keyboard (E000 / E001), the 8253 (E004–E007), the joystick (E008) and their mirrors — pass straight through via Z80CPU_readPhysicalMem / Z80CPU_writePhysicalMem, and only the Port C cassette bits are superimposed on reads and snooped on writes. The tuned PHYSICAL_HW hot path is left untouched.
- Controlled family — the driver hooks
ioPtr[] on ports E0–E3 and drives the computer-controlled transport there.
SD reads and writes are handed to Core 0 over the inter-core
requestQueue /
responseQueue so the Core 1 hot loop never blocks on storage. As noted above, the tape queue arrives through the interface
param array and the family is inferred from the persona.
MZ-800 Floppy Boot from TZFS
TZFS can now boot MZ-80A, MZ-700 and MZ-800 floppy disks — including MZ-800 CP/M — directly from within the monitor, and return to TZFS on the hardware RESET switch after a floppy-booted OS has taken over. This spans changes in both the TZFS ROM and the picoZ80 firmware. The TZFS version is now v1.8.3.
TZFS Boot Loader — GETBOOTDSK and 8253 Taming
GETBOOTDSK now accepts any Sharp machine id (01 / 02 / 03) alongside the
"IPLPRO" signature. It pre-pages block 7 DRAM into
0000–0FFF for operating systems that load below
1000H (for example MZ-800 CP/M), and hands the loaded program
BC = 0200H — exactly as the native 9Z-504M IPL does — so that a second-stage loader can drive its own directory read.
Before executing
JP (HL), TZFS
tames the 8253 clock: it reprograms counter 2 to about 1 Hz and masks 8255 Port C bit 2, mirroring the IPL's
SORES. This prevents a free-running counter from flooding the Z80 with
RST 38H interrupts and aborting a loader that re-enables interrupts early — the failure that produced "P-CP/M80 → No system file".
MZ-1E05 A10-Toggle Fetch Handler
The picoZ80 MZ-1E05 driver adds MZ1E05_IO_A10Toggle, an FDC hardware-acceleration fetch handler that ORs DRQ with A10 at addresses 0xF3FE / 0xF7FE. This lets the TZFS DSKREAD routine stream a boot sector on the MZ-800 exactly as it already does on the MZ-80A.
TZMM_DSKLOAD / TZMM_DSKRUN Memory Modes
Two picoZ80 TZFS memory modes,
TZMM_DSKLOAD and
TZMM_DSKRUN, load and then run a floppy-booted OS in native block-0 DRAM exactly as the machine's own IPL does, so the booted OS sees the memory map it expects rather than the TZFS bank layout.
A companion mode,
TZMM_QDLOAD (a TZFS4 map with low RAM in block 0), serves the Quick Disk boot path so that a program loaded from the QD lands where the exec hand-off then runs it.
cgWindow PCG-Font Tracking (MZ-800)
The picoZ80 MZ800 driver now tracks the cgWindow so that an MZ-800-mode OS which loads a PCG font (via IN 0xE0) reaches the real CG-ROM / CG-RAM while running under TZFS. This fixes garbled text under disk BASIC, while native games such as Flappy — which do not touch the PCG window — are unaffected.
Reset-to-TZFS Restore
When the hardware RESET switch is pressed after a floppy-booted OS, the firmware re-loads the pristine block-0 monitor and TZFS UROM — which the OS clobbered while running in TZMM_DSKRUN, since block-0 ROM and RAM alias the same PSRAM — and then cold-boots TZFS. The reset switch therefore returns to TZFS instead of dropping to the bare 1Z-013A monitor.
- Flappy PSRAM bank mismatch — under TZFS,
0x0000–0x0FFF is now mapped from bank 7 so that a native program which LDIRs code into low RAM and then switches video mode sees the same memory on both paths (a general fix, not specific to Flappy).
- Hardware-reset GDG recovery —
initVideoText is re-run once /RESET releases, so the GDG rescans and the monitor's HBLK sync completes.
- Physical-reset settle delay — a 10 ms delay lets the on-board GDG / 8255 finish their hardware reset before the firmware re-initialises the video.
Virtual Device Framework
The FUNC block type enables arbitrary I/O emulation by calling C handler functions on each bus access. Any 512-byte block of memory or range of I/O ports can be backed by a function.
Handler Function Signatures
Memory FUNC handlers are stored in the memioPtr table in PSRAM. I/O FUNC handlers are stored in the ioPtr table. The function signatures are:
/* Memory read handler */
uint8_t mem_read_handler(uint16_t addr, void *ctx);
/* Memory write handler */
void mem_write_handler(uint16_t addr, uint8_t data, void *ctx);
/* I/O read handler */
uint8_t io_read_handler(uint8_t port, void *ctx);
/* I/O write handler */
void io_write_handler(uint8_t port, uint8_t data, void *ctx);
Handler functions are called directly from Core 1's hot loop. They must complete before the current bus cycle's wait states expire — keep handlers short and avoid any blocking operations (file I/O, UART, etc.). If a handler needs to trigger a longer operation (e.g. load a disk sector), it should post a message to Core 0 via the inter-core queue and return immediately with a status byte, deferring the actual I/O to Core 0.
Writing a New Driver
To add support for a new peripheral or host machine:
- Create a new
.c / .h file in the src/drivers/ directory.
- Implement read and write handler functions matching the signatures above.
- Register the handler function pointers in the
memioPtr or ioPtr tables during driver initialisation.
- Add the driver to the CMakeLists.txt build target.
- Add a type string entry so that the JSON configuration parser can instantiate the driver by name.
- Document the driver's
param keys in your driver's header file.
The driver initialisation function is called once at boot, after
config.json is parsed. The driver receives a pointer to its interface configuration block and should set up any internal state and register its handlers at this point.
Virtual Peripheral Devices
A virtual peripheral device is a complete support chip — a Z80 DMA, a Z80 CTC, an 8255 and so on — added to a machine by declaring it in config.json. The firmware models the real integrated circuit, register for register, and the chip appears in the Z80 address space exactly as a physical part soldered to a board would. Devices are then wired to each other through a netlist, so an output pin of one chip can drive an input pin of another, just as a track on a printed circuit board does.
This is a different mechanism from the
Virtual Device Framework described above. A
FUNC block is a
handler — C code the firmware calls on each bus access, written by a developer and compiled into the firmware. A virtual peripheral device is a
declaration — an instance of a chip the firmware already models, created from configuration data with no code at all. It is also distinct from an
interface card, which is a machine-specific composite (ROM images, media, address mapping) declared under
drivers[].if[]. The three are peers, not layers: an interface card may well contain the same chip a device declares.
The purpose is to let anyone build their own Z80 system on a picoZ80 without writing firmware. A machine persona supplies the CPU, memory and video; devices supply the peripherals; the netlist supplies the wiring between them.
The Device Catalogue
Seven devices are available. The name column is the exact string used in config.json. The Service core column matters for timing: devices serviced on Core 0 are advanced by a free-running timer independent of the Z80, while Core 1 devices are driven by bus activity.
name |
Chip modelled |
Pins |
Decode size |
Service core |
Z80DMA |
Zilog Z8410 Direct Memory Access Controller |
10 |
1 |
Core 1 |
Z80CTC |
Zilog Z8430 Counter / Timer Circuit |
10 |
4 |
Core 0 |
Z80PIO |
Zilog Z8420 Parallel Input / Output Controller |
23 |
4 |
Core 1 |
PIT8253 |
Intel 8253 / 8254 Programmable Interval Timer |
6 |
4 |
Core 0 |
PPI8255 |
Intel 8255 Programmable Peripheral Interface |
24 |
4 |
Core 1 |
WD1773 |
Western Digital WD1773 Floppy Disk Controller |
5 |
8 |
Core 1 |
SignalPort |
not a real chip — a test and bridge port |
16 |
1 |
Core 1 |
In addition to the declared devices, a permanent pseudo-device named cpu always exists. It exposes the processor's own control pins so a device can be wired to the machine itself.
Declaring a Device
Devices are declared in a device array inside a z80[] partition object, alongside the existing memory, io and drivers arrays. Each partition carries its own devices and its own netlist, so the two may be fitted quite differently. Note carefully that name selects which chip, while type selects which address space — this catches people out.
| Key |
Type |
Required |
Default |
Description |
name |
string |
yes |
— |
Chip type from the catalogue above. Case-insensitive. An unrecognised name is skipped with a log message. |
addr |
number |
yes |
— |
Base address of the decode. |
id |
string |
no |
dev<i>N</i> |
Instance label, used to refer to this chip when wiring. Maximum 11 characters; longer names are truncated. |
type |
string |
no |
"IO" |
Address space — "MEMORY" places the chip in memory space, anything else places it in I/O space. |
size |
number |
no |
1 |
Number of consecutive addresses decoded. |
enable |
number |
no |
enabled |
0 disables the device. Use the number 0, not false. |
busack |
number |
no |
1 |
Assert the host bus acknowledge while this device owns the bus. Z80 DMA only. |
maxburst |
number |
no |
4096 |
Maximum bytes moved before the processor is allowed back in. Z80 DMA only; 0 removes the guard. |
Devices are applied after the persona's memory and io regions, so a device decode is authoritative — it wins over any blanket region covering the same address. The decode must lie wholly within the 64K space or the device is skipped.
"device": [ {
"name": "Z80DMA",
"id": "dma0",
"enable": 1,
"type": "IO",
"addr": 0x0F,
"size": 1,
"busack": 1,
"maxburst": 4096
},
{
"name": "Z80CTC",
"id": "ctc0",
"enable": 1,
"type": "IO",
"addr": 0x10,
"size": 4
} ]
Wiring Devices Together — the Interlink
The interlink array is the netlist. Each entry describes one wire: a single destination pin, and one or more sources that drive it. A pin is referred to as <id>.<PIN>, for example dma0.RDY or cpu.INT. Both parts are case-insensitive.
| Key |
Type |
Required |
Description |
dst |
string |
yes |
The single input pin this wire drives. Each input may be driven by exactly one wire. |
src |
array |
yes |
One to eight source terms. A term is a pin reference, or the literal string "0" or "1". A leading ! inverts that term. |
op |
string |
no |
How multiple sources combine — BUF, NOT, AND, OR, NAND, NOR, XOR, XNOR. Defaults to BUF for one source and OR for two or more. |
enable |
number |
no |
0 disables this wire. |
name |
string |
no |
A label for your own benefit. The firmware ignores it. |
Important: a net carries the electrical level of the pin, never an abstract "asserted" state. 1 is high and 0 is low, exactly as a voltmeter would read it. Active-low pins such as /INT and /BUSREQ idle high and are asserted by going low. This is what makes the netlist behave like a schematic: a NOR of two active-high timer outputs does on the picoZ80 precisely what it does on paper.
The Three Ways to Drive a Pin
Directly, one to one — omit op and give a single source. This is a plain wire from one chip's output to another chip's input.
{ "name": "floppy data request drives the DMA",
"dst": "dma0.RDY",
"src": [ "fdc0.DRQ" ] }
Hard strapped to a fixed level — give the literal "0" or "1" as the only source. This is the equivalent of tying a pin to ground or to the supply rail, and it is what you do with every input a design does not use. An unwired input is not an error, but it will be reported at boot and will read its resting level, so strapping it explicitly documents your intent.
{ "dst": "dma0.CE", "src": [ "0" ] } /* tied low - permanently selected */
{ "dst": "dma0.WAIT", "src": [ "1" ] } /* tied high - never asserted */
Through a logic condition — give two to eight sources and an op. The terms are combined left to right, and any term may be individually inverted with a leading !. This is the equivalent of soldering a small gate between the chips.
/* The DMA runs only while neither timer output is high. */
{ "name": "timer gates the DMA",
"dst": "dma0.RDY",
"op": "NOR",
"src": [ "ctc0.ZCTO0", "ctc0.ZCTO1" ] }
/* Port A bit 1, inverted, drives port B bit 0. */
{ "dst": "pio0.B0", "src": [ "!pio0.A1" ] }
Interrupt lines are a special case worth understanding. On real hardware every /INT pin is an open-drain output: any chip may pull the shared line low, and it rises only when all of them release it. The netlist reproduces this with an AND of every interrupt output. You must write "op":"AND" explicitly — the default for multiple sources is OR, which would invert the meaning and fire an interrupt only when every chip asserted at once.
{ "name": "interrupt chain",
"dst": "cpu.INT",
"op": "AND",
"src": [ "dma0.INT", "ctc0.INT", "pio0.INT" ] }
Interrupt Priority — the Daisy Chain
Priority between interrupting chips is decided by how you wire them, not by a number in the configuration — exactly as on a real Z80 system. Each chip has an IEI (interrupt enable in) input and an IEO (interrupt enable out) output. Wiring one chip's IEO to the next chip's IEI places the second below the first in priority. The first chip in the chain has its IEI strapped to 1.
A chip drives its IEO high only when its own IEI is high and it is neither interrupting nor being serviced. That single rule produces the whole behaviour: a chip being serviced holds everything below it off until it is finished, and a chip whose IEI has gone low knows a higher-priority device has the processor's attention and stays quiet.
/* ctc0 is highest priority, then dma0, then pio0. */
{ "dst": "ctc0.IEI", "src": [ "1" ] },
{ "dst": "dma0.IEI", "src": [ "ctc0.IEO" ] },
{ "dst": "pio0.IEI", "src": [ "dma0.IEO" ] }
Interrupt mode 2 is fully supported. On acknowledgement the highest-priority device that is requesting and is not blocked supplies its programmed vector, marks itself in service and clears its pending flag; a RETI clears the in-service state again and releases the chips below it.
Physically fitted cards always take priority over declared devices. A real peripheral on the host bus has its own IEI and IEO pins which the picoZ80 can neither see nor arbitrate with — there are no spare processor pins for them — so the firmware offers the acknowledge to any existing card handler first and claims it only when no card responds. This is what allows devices to be added to a machine already fitted with, for example, an MZ-8BIO3 or MZ-1E24 serial card without disturbing it.
Device Pinouts
The tables below list every pin each device exposes to the netlist. Direction is from the chip's point of view. Idle is the level an input reads when nothing drives it. Pins marked declared exist and may be wired, but are not yet acted upon by the firmware — they are listed so that a configuration written today remains correct when they become live.
cpu — the processor pseudo-device
| Pin |
Direction |
Idle |
Function |
INT |
in |
1 |
Maskable interrupt request. The only functional destination on the CPU. Driving it low raises an interrupt. |
BUSACK |
out |
1 |
Bus acknowledge. Declared — not yet driven, so it reads permanently high. |
BUSRQ, NMI, RESET, WAIT |
in |
1 |
Declared — wiring these has no effect yet. |
M1, CLK, HALT |
out |
— |
Declared — not yet driven. |
Z80DMA — Direct Memory Access (Z8410)
| Pin |
Direction |
Idle |
Function |
RDY |
in |
1 |
Transfer request from the peripheral. The only functional input. Its active sense is set by the chip’s WR5 register, not by configuration. A transition to the active state starts a transfer. |
BUSREQ |
out |
1 |
Bus request, active low, open drain. Driven low while the DMA holds the bus. |
INT |
out |
1 |
Interrupt request, active low, open drain. |
IEI |
in |
1 |
Interrupt priority in. Wire from the previous chip’s IEO, or strap to 1 if this is first in the chain. |
IEO |
out |
1 |
Interrupt priority out. Wire to the next chip’s IEI. |
BAI, CE, WAIT, M1 |
in |
1 |
Declared — the model does not consult them. |
BAO |
out |
1 |
Declared — not yet driven. |
Z80CTC — Counter / Timer (Z8430)
| Pin |
Direction |
Idle |
Function |
CLKTRG0 … CLKTRG3 |
in |
0 |
Clock or trigger input for channels 0 to 3. The significant edge is chosen by the channel control word. |
ZCTO0 … ZCTO2 |
out |
0 |
Zero-count / time-out for channels 0 to 2, emitted as a brief pulse. Channel 3 has no output pin, exactly as on the real device. |
INT |
out |
1 |
Interrupt request, active low, open drain. |
IEI |
in |
1 |
Interrupt priority in — see the daisy chain. |
IEO |
out |
1 |
Interrupt priority out. |
Z80PIO — Parallel Input / Output (Z8420)
| Pin |
Direction |
Idle |
Function |
A0 … A7 |
bidirectional |
1 |
Port A data bits. Direction follows the mode programmed by the Z80 at run time. |
B0 … B7 |
bidirectional |
1 |
Port B data bits. |
ASTB, BSTB |
in |
1 |
Port strobe inputs, active low. The rising edge is the significant one. |
ARDY, BRDY |
out |
0 |
Port handshake ready outputs, active high. |
INT |
out |
1 |
Interrupt request, active low, open drain. |
IEI |
in |
1 |
Interrupt priority in — see the daisy chain. |
IEO |
out |
1 |
Interrupt priority out. |
The four decoded addresses are, in order: port A data, port A control, port B data, port B control. Reset selects mode 1 (input) on both ports.
PIT8253 — Interval Timer (8253 / 8254)
| Pin |
Direction |
Idle |
Function |
GATE0 … GATE2 |
in |
1 |
Counter gate inputs. Modes 0, 2, 3 and 4 respond to the level; modes 1 and 5 trigger on the rising edge. |
OUT0 … OUT2 |
out |
1 |
Counter outputs. |
The four decoded addresses are counters 0, 1 and 2 followed by the control word. The 8253 has no interrupt pin. One extra configuration key applies to this device: "clockhz" sets the counter input frequency in Hertz, defaulting to 1000000. Sharp MZ machines drive their timer at 895000.
PPI8255 — Parallel Interface (8255)
| Pin |
Direction |
Idle |
Function |
PA0 … PA7 |
bidirectional |
1 |
Port A data bits. |
PB0 … PB7 |
bidirectional |
1 |
Port B data bits. |
PC0 … PC7 |
bidirectional |
1 |
Port C data bits. The upper and lower halves have independent directions. |
The four decoded addresses are ports A, B and C followed by the control word. The mode 1 and mode 2 handshake lines are not exposed to the netlist.
Known limitation
Driving any single input pin of an 8255 port currently clears the other input bits of that same port. Until this is corrected, use the 8255's input pins one bit at a time, or use a Z80 PIO instead — the PIO handles each bit independently and is unaffected.
WD1773 — Floppy Disk Controller
| Pin |
Direction |
Idle |
Function |
DDEN |
in |
1 |
Double density select, active low. A low level selects MFM. |
WPRT |
in |
1 |
Write protect, active low. |
INTRQ |
out |
0 |
Interrupt request. Active high on this device, unlike the Zilog chips. |
DRQ |
out |
0 |
Data request. Intended to drive a DMA’s RDY pin. |
MOTOR |
out |
0 |
Motor on. |
The eight decoded addresses follow the Sharp MZ-1E05 layout: status and command, track, sector, data, drive select and motor, side select, density, and a second status read. Three extra configuration keys apply — "machine" selects the image geometry ("MZ-80B", "MZ-2500", or MZ-700 if omitted), "drives" sets the number of drives from 1 to 4, and "file" names the disk image or images to mount. Only one WD1773 may be declared.
SignalPort — test and bridge port
| Pin |
Direction |
Idle |
Function |
IN0 … IN7 |
in |
0 |
A Z80 read of the port returns these eight levels as bits 0 to 7. |
OUT0 … OUT7 |
out |
0 |
A Z80 write drives these eight levels. |
This is not a model of any real chip. It is the bench instrument of the netlist: it puts eight wires onto a single I/O address so that a program running on the Z80 can drive signals into the design and sample what comes back. It is the most direct way to test a wiring idea before committing to it.
Worked Example — Adding a Z80 DMA
The following is a complete, working configuration. It places a Z80 DMA at I/O port 0x0F, a Z80 CTC at 0x10, and wires the two together so that the timer gates the transfer. Every unused DMA input is strapped to a fixed level, and the two interrupt outputs are wire-ANDed onto the processor's interrupt pin.
"device": [ { "name": "Z80DMA", "id": "dma0", "enable": 1,
"type": "IO", "addr": 0x0F, "size": 1,
"busack": 1, "maxburst": 4096 },
{ "name": "Z80CTC", "id": "ctc0", "enable": 1,
"type": "IO", "addr": 0x10, "size": 4 } ],
"interlink": [
/* Unused DMA inputs, strapped so their state is documented. */
{ "name": "dma bus acknowledge", "dst": "dma0.BAI", "src": [ "cpu.BUSACK" ] },
{ "name": "dma chip enable", "dst": "dma0.CE", "src": [ "0" ] },
{ "dst": "dma0.WAIT", "src": [ "1" ] },
{ "dst": "dma0.IEI", "src": [ "1" ] },
{ "dst": "dma0.M1", "src": [ "1" ] },
/* Unused CTC trigger inputs. */
{ "dst": "ctc0.CLKTRG2", "src": [ "0" ] },
{ "dst": "ctc0.CLKTRG3", "src": [ "0" ] },
{ "dst": "ctc0.IEI", "src": [ "1" ] },
/* Two timer outputs gate the transfer request through a NOR gate. */
{ "name": "timer gates the dma",
"dst": "dma0.RDY", "op": "NOR",
"src": [ "ctc0.ZCTO0", "ctc0.ZCTO1" ] },
/* Both interrupt outputs are open drain and wire-AND onto the CPU. */
{ "name": "interrupt chain",
"dst": "cpu.INT", "op": "AND",
"src": [ "dma0.INT", "ctc0.INT" ] } ]
Everything else about the DMA — whether it moves bytes or searches for one, which direction it moves them, the block length, the addresses, byte or burst or continuous mode — is programmed by the Z80 through the chip's own registers at run time, exactly as it would be on real hardware. The configuration decides only where the chip sits and what it is wired to.
A memory-to-memory transfer deserves a note, because it is the first thing most people try. Such a transfer has no peripheral to request it, so there is no RDY line to drive. The Z80 DMA solves this on real hardware with the Force Ready command (B3), which supplies the ready condition internally, and the picoZ80 model does the same. Remember that ENABLE DMA (87) must be the last byte written, because almost every other control byte disables the chip. Force Ready is cancelled by bus release and by end of block, so a transfer that must restart itself has to take its ready signal from a wired source such as a timer output rather than from the Force Ready command.
Limits and Current Restrictions
The framework is bounded by fixed table sizes. Exceeding any of them is reported in the boot log, but the configuration will otherwise appear to load.
| Limit |
Value |
Notes |
| Devices |
8 |
Further entries are ignored. |
| Signals in total |
96 |
Across all devices, plus the nine CPU pins. |
| Interlink wires |
48 |
Further entries are ignored. |
| Sources per wire |
8 |
Further terms are ignored. |
| Settle iterations |
8 |
Enough for cross-coupled latches; a genuine oscillation is reported. |
| Device identifier |
11 characters |
Longer identifiers are truncated. |
The nine CPU pins are registered last, so a design that exhausts the signal table loses cpu.INT first and every interrupt wire silently fails to resolve. If interrupts stop working in a large design, count your pins.
Important Limitations
- Counter devices only advance under the BaseZ80 model. The free-running Core 0 tick that clocks the Z80 CTC and the 8253 is started by the BaseZ80 firmware only. Under the Sharp, Amstrad, Tatung and OpenZ80 builds these two devices are declared and addressable but their counters do not run.
- A device is not reset by a processor reset. Devices are initialised once when the configuration is applied.
- A virtual DMA cannot be triggered by a real peripheral. The picoZ80 reaches the Z80 socket only, and every general-purpose pin of the RP2350 is already allocated, so there is no way to bring a physical card's data-request line into the netlist. A virtual DMA takes its request from a constant, from Force Ready, or from another virtual device — the floppy controller's
DRQ output being the obvious example. This is a property of the hardware, not a defect.
- The
mirror, refresh and trace keys are accepted but currently have no effect.
For the step-by-step procedure using the web interface, see the
picoZ80 User Manual. For the internal structure, the debug-shell tooling and how to add a new device model, see the
Developer's Guide.
ICE (Debug Shell)
The picoZ80 includes a built-in ICE (In-Circuit Emulator) debug shell on USB CDC Channel 1 (the second serial port enumerated when the board is connected via USB). The shell runs on Core 0 and communicates with the Core 1 emulation loop via shared flags in the Z80CPU context structure. The debug shell is only available in the DBGSH firmware variant, which is compiled with the INCLUDE_DBGSH define.
USB CDC Serial Channels
The picoZ80 enumerates several USB CDC serial ports when connected to a host. CDC 0 and CDC 1 serve the physical UARTs / ESP32 bridge and the ICE debug shell (CDC 1, DBGSH variants). CDC 2 and CDC 3 are the two channels (A and B) of a virtual RS-232C serial card — the MZ-8BIO3 or MZ-1E24 driver — when one is configured in config.json. These two ports have no physical UART behind them: they are ring-buffer bridges to the emulated Z80 SIO, so data written to CDC 2/3 on the host appears on channel A/B of the Z80 SIO seen by the guest, and vice versa. If no serial card is configured, CDC 2 and CDC 3 are not enumerated.
Architecture
- Input/Output: USB CDC Channel 1 at 115200 baud. The shell prompt is
dbg> . Command history (16 entries) and character echo are supported.
- Breakpoints: Up to 8 simultaneous breakpoints stored in
cpu->dbgBpAddr[]. Core 1 checks the breakpoint array before each opcode fetch; on a hit, it sets cpu->hold = true and signals Core 0 via dbgBpHit.
- Single-step: The
step command sets cpu->dbgStepCount. Core 1 decrements this counter after each instruction, holding automatically when it reaches zero. Before/after register state and the disassembled instruction are displayed for each step.
- Execution trace: A 512-entry ring buffer (
cpu->dbgTrace[]) records PC, opcode, and flags register for each executed instruction when tracing is enabled. Each 32-bit entry packs [31:16]=PC, [15:8]=opcode, [7:0]=F register.
- Memory access: Physical memory access (
dm p, wm p) drives real Z80 bus cycles via the PIO state machines. Virtual access (dm v, wm v) reads/writes PSRAM directly. Auto mode (wm without qualifier) follows the memory map. RP2350 access (dm r) reads the host microcontroller's address space with range validation.
- Hold/Release: The
hold command sets cpu->hold = true. Core 1 acknowledges via cpu->holdAck, ensuring the CPU is quiescent before the shell accesses shared state.
- Break: The
break command holds Core 1 and then reports the current PC, the instruction about to execute (disassembled), and the full register set — the quickest way to see where a running program is, before stepping or inspecting. go/cont resumes.
- Quick keys & abbreviations: To reduce typing during debugging, the most common commands have single-letter quick keys —
c=cont, s=step, g=go, b=break, r=regs, h=hold — which are resolved before prefix matching so they are never treated as ambiguous. Any other command may be entered as its shortest unique prefix (e.g. ste=step, sta=status, dis=disassemble); an ambiguous prefix lists the candidates, and a fully-typed name always wins.
Command Reference
| Command |
Syntax |
Description |
help |
help |
List all commands |
regs |
regs |
Dump all Z80 registers, flags, and cycle count |
dm |
dm <p|f|v|r> <addr> [len] |
Dump memory (physical / fetch / virtual / RP2350) |
search |
search [p|v] <start> <end> <hex..>|"text" |
Search memory for a byte pattern or ASCII text string. p = physical bus, v = virtual PSRAM, omit for mapped. Matches displayed with 8 bytes context. Auto-holds CPU for physical/mapped access. Pattern up to 32 bytes |
cmp |
cmp [f] <phys> <virt> <len> |
Compare physical bus memory with virtual PSRAM |
dis |
dis [p|v] [addr] [count] |
Disassemble Z80 code |
asm |
asm [addr] |
Interactive Z80 assembler |
memmap |
memmap [block] |
Show memory bank pointer table |
memptr |
memptr [addr] |
Show PSRAM memPtr table |
iomap |
iomap [port] |
Show I/O port handler table |
status |
status |
System status (CPU freq, PSRAM, uptime) |
ver |
ver |
Firmware version and partition info |
drivers |
drivers |
List active drivers and interfaces |
hold |
hold |
Pause CPU emulation (silently) |
release |
release |
Resume CPU emulation |
break |
break |
Halt the running Z80 and report where it stopped — PC, the instruction (bytes + disassembly) about to execute, and a full register dump. Unlike hold (which pauses silently), break shows state so you can see exactly where execution is (e.g. when stuck in a loop). Re-issuing break re-shows the state. Resume with go/cont or single-step with step. Times out (~3 s) if the Z80 is held in reset or its clock is gated |
go |
go |
Continue (release hold, breakpoints active) |
cont |
cont |
Alias for go. Continue execution (release hold, breakpoints active) |
step |
step [n] |
Single-step n instructions |
next |
next [n] |
Single-step n instructions, stepping over subroutine calls (alias n) |
bp |
bp <addr> |
Set breakpoint (max 8) |
bc |
bc <n|*> |
Clear breakpoint n or all |
bl |
bl |
List breakpoints |
wm |
wm [p|v] <addr> <byte>... |
Write to memory (physical/virtual/auto) |
fill |
fill [p|v] <addr> <len> [w|d] <val> |
Fill memory with a constant value |
copy |
copy <pv|fp|vp> <src> <len> <dst> |
Copy memory between physical and virtual |
memtest |
memtest <addr> <len> [pattern] |
Test physical memory (write+read, write+fetch, interleaved) |
in |
in <port> |
Read Z80 I/O port |
out |
out <port> <byte> |
Write Z80 I/O port |
trace |
trace <on|off|dump [n]|clear|rt|byte ...> |
Execution trace control; rt enables real-time trace output; byte enables byte-level tracing |
verify |
verify <on|off> |
Toggle full opcode fetch verification |
fwait |
fwait <0-4> |
Force extra M1 (opcode fetch) wait states; 0 = off (default) |
iowait |
iowait <0-8> |
Force extra I/O cycle wait states; 0 = off (default) |
corrupt |
corrupt [clear] |
Display or clear detected fetch corruptions |
fdctrace |
fdctrace <on|off|dump> |
Enable/disable FDC I/O trace; dump shows last 64 operations |
qdtrace |
qdtrace <on|off|dump> |
Enable/disable Quick Disk I/O trace; dump shows last 64 operations |
qdprobe |
qdprobe [seconds] / qdprobe eng [pace_us] |
Hand-drive the REAL Quick Disk SIO (ports F4/F6/F7) and map its signals — motor on, hunts, and sync events (A = ChA RR0: b3=HDST, b4=hunt, b0=Rx; B = ChB RR0: b3=HOME) — for up to seconds (default 20, max 60), holding the Z80 CPU until go resumes it. eng [pace_us] replays the TZFS QD engine’s exact init/hunt/read sequence at a settable inter-access pace, for validating real-hardware timing |
piodbg |
piodbg [clear] |
Display RP2350 PIO hardware diagnostics (FDEBUG, FSTAT, FIFO, PCs, GPIO); clear resets sticky flags |
load |
load <p|v> <file> <addr> [len] [ofs] |
Load a file from the ESP32 SD card into Z80 memory. p = physical bus, v = virtual PSRAM bank 0. file relative to /sdcard/. If len is omitted the entire file is loaded (up to 64KB); if specified, max 1MB. Optional ofs for file offset. Auto-holds CPU for physical writes. Uses PSRAM bank 63 as scratch buffer |
save |
save <p|pf|v> <file> <addr> <len> |
Save Z80 memory to a file on the ESP32 SD card. p = physical bus, pf = physical fetch (M1), v = virtual PSRAM bank 0. file relative to /sdcard/. Max 64KB. Auto-holds CPU for physical reads. Periodic DRAM refresh during physical reads |
dir |
dir [path] |
List files on the ESP32 SD card. Optional path is relative to /sdcard/. Shows filenames and sizes |
echo |
echo [on|off] |
Toggle terminal echo |
reset |
reset |
Force Z80 reset |
set |
set <reg|flags|memmap|memptr|iomap> <idx> <val> |
Modify Z80 register, flags, memory map, memPtr, or I/O map entry at runtime |
hist |
hist [n] |
Display command history (preserved across sessions via ESP32 NVS) |
savehst |
savehst |
Force-save command history to ESP32 NVS |
ipl |
ipl |
Perform an IPL reset (BST mode) by toggling 8255 PPI Port C bit 3. Resets to boot mode without a full Z80 reset |
mmutrace |
mmutrace |
Dump machine-specific trace information (MMU state, I/O register snapshots). Output varies by persona via registered trace handler |
intcount |
intcount |
Show interrupt acknowledge count and current interrupt state |
psync |
psync [start end] |
Synchronise physical memory to PSRAM. Optional address range; defaults to full address space |
dskimage |
dskimage read <filename> [cylinders] [heads] / dskimage write <filename> |
Image a physical floppy disk to a DSK file on the SD card (read), or write a DSK file from the SD card to a physical floppy (write). dskimage <filename> defaults to read (backward compatible). Auto-detects geometry if omitted |
busdiag |
busdiag |
Display bus diagnostics (PIO state, signal levels, bus contention) |
fdcimage |
fdcimage |
Display FDC imaging status and progress |
fdcdiag |
fdcdiag |
Display FDC diagnostic information (controller state, register dump) |
gadiag |
gadiag |
Display gate array diagnostic information (command state, interrupt routing) |
Quick keys and abbreviations. Commands need not be typed in full. The six most-used commands have single-letter quick keys — c (cont), s (step), g (go), b (break), r (regs) and h (hold) — and every other command can be entered as its shortest unique prefix (for example ste for step, sta for status, dis for disassemble). A fully-typed command name always takes priority, and an ambiguous prefix prints the list of matching commands.
ESP32 Co-processor
The ESP32-S3-PICO-1 module acts as a co-processor handling all network and storage functions. It communicates with the RP2350 via two interfaces:
- FSPI (50MHz, 4-wire SPI) — Binary IPC Protocol v1.1 — high-speed bulk data transfer (ROM images, disk sector reads/writes, config file download). The protocol uses a fixed 64-byte binary frame header with CRC32 integrity checking (replacing the earlier XOR checksum). DMA channels are pre-allocated at initialisation and never released, eliminating per-transfer claim/unclaim overhead and race conditions. Burst sector transfers allow up to 16 × 512-byte sectors (8KB) in a single SPI transaction, significantly improving floppy and QuickDisk image load times. The RX DMA channel is elevated to HIGH PRIORITY to prevent FIFO overflow caused by Core 1 PSRAM QMI bus contention.
- UART (460.8kbaud) — command/response protocol for control messages, status queries, and short data exchanges.
Networking Modes
The ESP32 firmware supports three networking modes, selected at build time via pre-built sdkconfig files:
| Mode |
Config File |
WiFi |
USB NCM |
Console |
FCC/RED Required |
| WiFi Only |
sdkconfig.mode_wifi_only |
Yes |
No |
USB Serial/JTAG |
Yes |
| WiFi + NCM |
sdkconfig.mode_wifi_and_ncm |
Yes |
Yes |
TinyUSB CDC-ACM |
Yes |
| NCM Only |
sdkconfig.mode_ncm_only |
No |
Yes |
TinyUSB CDC-ACM |
No |
USB NCM (Network Control Model) presents a CDC-NCM Ethernet adapter on the ESP32-S3 USB OTG port (GPIO 19/20). A composite USB device exposes both a CDC-ACM serial port (for debug logging) and the NCM network interface. The built-in DHCP server assigns the host an IP address from the
192.168.7.0/24 subnet, with the picoZ80 accessible at
192.168.7.1. Lease time is 120 minutes.
In WiFi+NCM mode, the HTTP server binds to
INADDR_ANY:80 and serves both interfaces simultaneously. WiFi connects asynchronously so the USB NCM interface is available immediately at power-on.
When only NCM is enabled, the WiFi radio is completely disabled, the WiFi Manager page is removed from the web interface, and the Dashboard status panel title changes from "WiFi Configuration" to "Network Configuration" (showing USB NCM status instead of SSID/WiFi details). The ESP32-S3 antenna matching network does not need to be populated on the PCB.
SD Card Interface
The ESP32 manages the SD card via its SPI interface. The SD card is mounted as FAT32 and all file access from the RP2350 is mediated by the ESP32 — the RP2350 sends file I/O commands over the FSPI/UART link and the ESP32 performs the actual FAT32 read/write operations.
The SD card is also directly accessible to the ESP32 web server, which serves files from the
webfs/ directory and allows the File Manager to browse and modify the card contents via HTTP.
Large-directory read fix. An intermittent TZFS
"SD Read error" on large directories is fixed. The old code performed O(N²) directory rescans that overran the FSPI handshake window when the SD cache was cold; these are replaced by an ESP32-side
MZFDIR persistent directory cursor plus an RP2350-side dir-cache retry, so large directories enumerate reliably.
Web Server
The ESP32 runs an HTTP server on port 80 (no TLS — local network use only). All web assets (HTML, CSS, JavaScript) are served from the
webfs/ directory on the SD card, allowing the web interface to be updated without reflashing the ESP32 firmware. The web server handles:
- Serving static web assets from the SD card
webfs/ directory.
- REST API endpoints for JSON data (system status, config read/write, file operations).
- OTA firmware upload endpoints for both the RP2350 and ESP32.
- WebSocket connection for real-time Dashboard status updates.
RP2350 ↔ ESP32 Command Protocol
The RP2350 (Core 0) communicates with the ESP32 using a simple command/response protocol over the UART link. Commands are single-byte opcodes with optional payload bytes. The ESP32 acknowledges each command with a status byte followed by any response data.
Common command categories:
- File I/O — open, read, write, close, directory listing, file stat.
- Config — request config.json content, write updated config, reload request.
- Disk — mount/unmount disk image, read/write sector (relayed from WD1773 and QDDrive emulation). The currently mounted floppy and QuickDisk image filenames are tracked by the ESP32 and displayed in the web interface Actions menu.
- System — version query, reboot request, NVS read/write.
The FSPI interface is used for bulk transfers where the payload is too large for the UART (ROM image uploads, disk sector data), while the UART handles all control commands.
Network IPC Commands
The Celestite Phase 2 networking implementation adds five inter-core IPC commands that the RP2350 uses to request network operations from the ESP32. These commands are forwarded over the FSPI/UART link and the ESP32 performs the corresponding BSD socket operations. Non-blocking connects use select() with a timeout, and per-socket pending flags track in-flight operations.
| Command |
Opcode |
Description |
IPCF_CMD_NET_CFG |
0x10 |
Get ESP32 network configuration — returns IP address, gateway, subnet mask, and MAC address |
IPCF_CMD_NET_SOCK |
0x11 |
Socket lifecycle operation — open, connect, listen, close, or disconnect a socket |
IPCF_CMD_NET_SEND |
0x12 |
Send data to an open socket |
IPCF_CMD_NET_RECV |
0x13 |
Receive data from an open socket |
IPCF_CMD_NET_PING |
0x14 |
ICMP echo request (ping) |
The W5100 socket commands supported through this IPC layer are: OPEN, CONNECT, LISTEN, SEND, RECV, CLOSE, and DISCON. The ESP32 translates these into standard BSD socket API calls (
socket(),
connect(),
listen(),
send(),
recv(),
close(),
shutdown()), enabling the Celestite board to communicate with network services such as the
netfs.py file server.
Watchdog and Boot Diagnostics
The RP2350 firmware uses a hardware watchdog timer to detect and recover from boot-time hangs and main-loop stalls. The watchdog is enabled early in the boot sequence with a 30-second timeout and is kicked (watchdog_update()) at each major milestone. If any boot stage or main-loop iteration takes longer than the timeout, the watchdog resets the RP2350 automatically.
Boot Progress Tracking
Boot progress is tracked using the RP2350's watchdog scratch registers, which survive watchdog resets (but not power-on resets). This allows the firmware to determine, after a watchdog reset, exactly which boot stage was reached before the hang.
| Scratch Register |
Name |
Contents |
scratch[0–3] |
Boot history |
Last four reset attempts — each entry encodes (attempt_count << 24) | (stage << 16) | (resetCause & 0xFFFF). Entries shift on each watchdog reset: [0]←[1]←[2]←[3]←current. |
scratch[4] |
SPI diagnostics |
Packed diagnostic counters for the FSPI link: breadcrumbs, message type, T1/T3 timeout counters, bad frame count, and OK count. |
scratch[5] |
Magic marker |
Set to 0xB00710BE to indicate that the scratch registers contain valid boot progress data. |
scratch[6] |
Current stage |
The most recent boot stage code (see table below). |
scratch[7] |
Reset cause |
The reset cause code from the hardware reset controller. |
Boot stage codes progress from
0x01 (start) through to
0x10 (main loop entered). Sub-stages within the main loop (
0x11–0x17) and inter-core command processing (
0x20–0x27) provide fine-grained tracking:
| Code |
Stage |
Description |
0x01 |
BOOTP_START |
Entry point reached |
0x02 |
BOOTP_CLK_SET |
System clock configured |
0x03 |
BOOTP_PSRAM_INIT |
PSRAM initialisation started |
0x04 |
BOOTP_PSRAM_OK |
PSRAM initialised successfully |
0x05 |
BOOTP_STDIO_INIT |
USB stdio initialised |
0x06 |
BOOTP_PIO_INIT |
PIO state machines loaded |
0x07 |
BOOTP_Z80_INIT |
Z80 CPU context initialised |
0x08 |
BOOTP_USB_INIT |
USB bridge initialised |
0x0A |
BOOTP_ESP_HS_SYNC |
ESP32 SPI handshake sync |
0x0B |
BOOTP_CORE1_LAUNCH |
Core 1 launched |
0x0D |
BOOTP_FSPI_INIT |
FSPI binary IPC initialised |
0x0E |
BOOTP_ESP_INIT |
ESP32 communication ready |
0x10 |
BOOTP_MAIN_LOOP |
Main loop entered |
0x11–0x17 |
Main loop sub-stages |
USB poll, inter-core, SPI NOP/CMD, tasks |
0x20–0x27 |
Inter-core commands |
Floppy load, QD load, RAMFILE load, file I/O |
PSRAM Persistent Log (plogf)
The last 4KB of the 8MB PSRAM (address 0x117FF000) is reserved for a persistent debug log that survives watchdog resets. The plogf() macro writes printf-style messages to this buffer during boot, before USB becomes available for normal debugf() output. On the next successful boot, the dump_plog() function outputs any captured messages to the debug console and then clears the buffer. The log uses a simple structure: a 4-byte magic marker (0x504C4F47 = "PLOG"), a 4-byte length counter, and a 3840-byte circular text buffer.
Fault Diagnostics
The firmware installs Cortex-M33 fault handlers for hard faults, memory management faults, bus faults, and usage faults. When a fault occurs, the handler saves a complete diagnostic snapshot to the last 256 bytes of PSRAM (address 0x117FFF00) with a magic marker (0xFA017000), the fault type, all relevant registers (PC, LR, SP, R0–R3, R12, PSR), the Configurable Fault Status Register (CFSR), Hard Fault Status Register (HFSR), Bus Fault Address Register (BFAR), Memory Management Fault Address Register (MMFAR), and the core ID. The handler then enters an infinite loop, allowing the watchdog to trigger a reset. On the next boot, the firmware checks for a valid fault diagnostic and outputs the captured information via debugf(), enabling post-mortem analysis without requiring a live debugger session.
Flash Configuration Clear
The RP2350 OTA update mechanism supports two additional operations beyond firmware upload:
- Clear App Config (
FW_CFGCLEAR_ID = 0xB1D7E5FA) — erases the App Config partition (ROM images and minified JSON) associated with the target firmware slot. This forces the firmware to re-read config.json from the SD card on next boot, which is necessary when the configuration schema has changed between firmware versions.
- Clear Flash Header (
FW_HDRCLEAR_ID = 0xC2E8F6AB) — resets the flash partition header to factory defaults. The bootloader configuration (partition 0) is preserved, but all application partition metadata is rebuilt from scratch. Use this when the partition table has become corrupted or when downgrading to an earlier firmware version that expects a different partition layout.
Both operations are triggered from the RP2350 OTA web page checkboxes and are executed by the bootloader during the firmware update process.
SWD Debugging — RP2350
The RP2350 supports full source-level debugging over ARM Serial Wire Debug (SWD). Connect a CMSIS-DAP compatible probe (Raspberry Pi Debug Probe, Black Magic Probe, or similar) to Pins 1 (SWCLK), 2 (SWDIO), and 5 (GND) of the debug header.
OpenOCD Setup
The picoZ80 requires a small modification to the standard OpenOCD RP2350 target script to enable SMP debugging with separate GDB ports per core:
sudo cp /usr/local/share/openocd/scripts/target/rp2350.cfg \
/usr/local/share/openocd/scripts/target/rp2350_tzpu.cfg
Edit rp2350_tzpu.cfg — find the target smp line inside the if {[string compare $_USE_CORE SMP] == 0} block and remove the leading #:
# Before:
#target smp $_TARGETNAME_0 $_TARGETNAME_1
# After:
target smp $_TARGETNAME_0 $_TARGETNAME_1
This single change causes OpenOCD to register Core 0 on GDB port 3333 and Core 1 on GDB port 3334, allowing independent per-core GDB sessions. Launch OpenOCD before starting GDB:
openocd -f interface/cmsis-dap.cfg -f target/rp2350_tzpu.cfg -c "adapter speed 5000"
GDB Configuration
Add the following to ~/.gdbinit (with absolute paths matching your project location) to permit auto-loading of per-directory .gdbinit files:
set history save on
set history filename ~/.gdb_history
set history size 65536
add-auto-load-safe-path /path/to/project/build/bin/model/BaseZ80/.gdbinit
add-auto-load-safe-path /path/to/project/build/bin/model/Bootloader/.gdbinit
Debugging the Bootloader
# Terminal 1 — Core 0 (port 3333)
cd build/bin/model/Bootloader
cp ../../../../.gdbinit.bootloader.3333 .gdbinit
gdb-multiarch Bootloader.elf
# Terminal 2 — Core 1 (port 3334)
cd build/bin/model/Bootloader
cp ../../../../.gdbinit.bootloader.3334 .gdbinit
gdb-multiarch Bootloader.elf
Debugging the Main Firmware
# Terminal 1 — Core 0 (port 3333)
cd build/bin/model/BaseZ80
cp ../../../../.gdbinit.3333 .gdbinit
gdb-multiarch BaseZ80_0x10020000.elf
# Terminal 2 — Core 1 (port 3334)
cd build/bin/model/BaseZ80
cp ../../../../.gdbinit.3334 .gdbinit
gdb-multiarch BaseZ80_0x10020000.elf
# Memory dump (from GDB prompt) — hex + ASCII:
(gdb) xac 0x20000000 64
The xac <address> <count> GDB command is defined in the .gdbinit.3333 / .gdbinit.3334 files. It dumps memory as combined hex and ASCII output and is useful for inspecting PSRAM bank contents and memory-mapped device state.
ESP32 USB Debugging
The ESP32-S3 co-processor has a built-in USB-JTAG interface — no external debug probe is required. Connect a USB cable from the host PC directly to the ESP32 USB port on the picoZ80 board.
# Start OpenOCD for ESP32-S3
openocd -f board/esp32s3-builtin.cfg
# In a second terminal — launch Xtensa GDB
xtensa-esp32s3-elf-gdb esp32/build/main.elf
(gdb) target extended-remote :3333
Ensure the ELF was built from the same source revision as the firmware running on the device, so that symbols and addresses align correctly.
Build System
The picoZ80 firmware uses CMake with the Raspberry Pi Pico SDK 2.x. The build system produces the
Bootloader and the
Application firmware. The application is built in four variants — two partitions (Partition 1 at 0x10020000, Partition 2 at 0x10520000) each in standard and DBGSH configurations. The DBGSH variants add
INCLUDE_DBGSH to the compile flags, enabling the full debug shell on USB CDC Channel 1. The ESP32 firmware is built separately using ESP-IDF v5.4, managed via Docker.
The quickest way to get a working environment is the automated
setup_picoZ80 script (macOS/Linux and Windows), which installs the SDK and all dependencies and creates ready-to-use build scripts — see the
README and
Developer's Guide. The CMake targets, flags and commands below document the underlying build that the script automates, for those who prefer to configure it manually.
CMake Build Targets
| Target |
Output |
Flash Address |
Notes |
Bootloader |
Bootloader.elf, Bootloader.uf2 |
0x10000000 |
|
BaseZ80_0x10020000 |
BaseZ80_0x10020000.elf, .bin |
0x10020000 (Slot 1) |
Standard (no debug shell) |
BaseZ80_0x10520000 |
BaseZ80_0x10520000.elf, .bin |
0x10520000 (Slot 2) |
Standard (no debug shell) |
BaseZ80_DBGSH_0x10020000 |
BaseZ80_DBGSH_0x10020000.elf, .bin |
0x10020000 (Slot 1) |
DBGSH — includes ICE debug shell |
BaseZ80_DBGSH_0x10520000 |
BaseZ80_DBGSH_0x10520000.elf, .bin |
0x10520000 (Slot 2) |
DBGSH — includes ICE debug shell |
The
SharpZ80,
AmstradZ80,
TatungZ80 and
OpenZ80 models follow the same slot / DBGSH pattern, e.g.
OpenZ80_0x10020000,
OpenZ80_0x10020000_DBGSH,
OpenZ80_0x10520000 and
OpenZ80_0x10520000_DBGSH (each emits
.elf,
.bin,
.hex and
.map; the application slots use plain
.bin, not UF2). Build a single model with the
build_tzpuPico.sh filter, e.g.
build_tzpuPico.sh open.
Key CMake Build Flags
| Flag |
Effect |
INCLUDE_SHARP_DRIVERS |
Compiles in all Sharp MZ peripheral drivers (MZ700, MZ80K, MZ800, MZ80A, MZ80B, MZ2000, MZ2200, MZ2500, MZ1500, WD1773, T3444M, QDDrive, RFS, TZFS, MZ-1E05, MZ80AFI, MZ80FIO, MZ8BFI, MZ8BIO3, MZ1E24, Z80SIO, MZ-1E14, MZ-1E19, MZ-1R12, MZ-1R18, MZ-1R23, MZ-1R37, PIO-3034, Celestite, MZ-1E30). |
INCLUDE_AMSTRAD_DRIVERS |
Compiles in Amstrad PCW peripheral drivers (PCW9512, uPD765). |
INCLUDE_TATUNG_DRIVERS |
Compiles in Tatung Einstein peripheral drivers (EinsteinTC01, EinsteinFDC, WD1770). |
INCLUDE_OPEN_DRIVERS |
Compiles in the OpenZ80 experimenter persona (Open.c) and the machine-agnostic interface cards (MZ-1R12/1R18/1R23/1R37, PIO-3034, MZ8BIO3, MZ1E24, Z80SIO, MZ-1E05, WD1773, Celestite). |
TARGET_MODEL_TATUNG |
Sets Tatung Einstein as the exclusive target model (used in TatungZ80 build). |
TARGET_MODEL_OPEN |
Sets the OpenZ80 experimenter persona as the exclusive target model (used in the OpenZ80 build; enables INCLUDE_OPEN_DRIVERS). |
INCLUDE_DBGSH |
Compiles in the ICE debug shell on USB CDC Channel 1. Present in DBGSH build variants only. |
CMAKE_BUILD_TYPE=Debug |
Enables debug symbols and disables optimisation. Required for source-level GDB debugging. |
CMAKE_BUILD_TYPE=Release |
Full optimisation (-O3). Used for production firmware. |
Build Commands
# First time: clone and build the SDK
./get_and_build_sdk.sh
# Standard release build (RP2350 only)
./build_tzpuPico.sh
# Debug build
./build_tzpuPico.sh DEBUG
# Full build: RP2350 + ESP32 (ESP32 built via Docker)
./build_tzpuPico.sh ALL
# ESP32 only, using the Docker idf54 alias
cd projects/tzpuPico/esp32
idf54 build
The build_tzpuPico.sh script automatically increments the version number on a successful build and copies versioned output files to fw/uf2/ (bootloader UF2) and fw/bin/ (application binary for OTA). The Bootloader UF2 is used for initial USB mass-storage flashing only. Application slot binaries use plain binary format (not UF2) because they reside at non-standard flash addresses.
ESP32 Networking Mode Selection
To switch networking mode, copy the appropriate pre-built configuration file to sdkconfig before building:
cd esp32/
cp sdkconfig.mode_ncm_only sdkconfig # NCM only (FCC/RED safe)
# or: cp sdkconfig.mode_wifi_only sdkconfig
# or: cp sdkconfig.mode_wifi_and_ncm sdkconfig
idf.py build
idf.py flash
Reference Sites
Wireless Regulatory Notice
This device incorporates an ESP32-S3-PICO-1 wireless module that is capable of transmitting in the 2.4 GHz ISM band, making it an intentional radiator under radio-frequency regulations worldwide (including FCC Part 15 Subpart C in the United States, and the Radio Equipment Directive 2014/53/EU in the European Union) when WiFi firmware is installed.
As-shipped Configuration
As shipped, the picoZ80 board is flashed with the
NCM-only firmware (
sdkconfig.mode_ncm_only). In this configuration the WiFi radio is completely disabled and the ESP32-S3 antenna matching network components are not populated on the PCB. Because no RF transmission occurs, the device is
not an intentional radiator and does not require FCC, CE/RED, or equivalent certification. It may be sold, distributed, or gifted without regulatory authorisation.
Adding WiFi
End users may populate the antenna matching network and flash WiFi-enabled firmware (
sdkconfig.mode_wifi_only or
sdkconfig.mode_wifi_and_ncm) for personal, experimental, or educational use. Once WiFi is enabled, the device becomes an intentional radiator and the following rules apply:
Although the ESP32-S3-PICO-1 module itself carries pre-existing regulatory certifications (FCC, CE, and others), those module-level certifications
do not automatically extend to a finished product that incorporates the module. The pre-certified module exemption permits
individual hobbyists to build a limited number of devices for
personal, experimental, or educational use without obtaining separate equipment authorisation.
Important Limitations
- Devices with WiFi firmware must not be sold, offered for sale, gifted, or otherwise distributed to third parties unless the finished product has been independently tested and granted its own equipment authorisation (e.g. FCC ID, CE marking with a Notified Body assessment) in the relevant jurisdiction.
- Building this project with WiFi enabled for personal use in limited quantities is generally permitted under hobbyist and experimental-use provisions (e.g. FCC § 15.23), provided the device does not cause harmful interference.
- Commercial sale with WiFi enabled requires full product-level FCC/RED (or equivalent) certification.
- Regulatory requirements vary by country. Builders outside the United States should consult their national radio-frequency authority for applicable rules.
Builder’s Responsibility
It is the builder’s sole responsibility to ensure that any device constructed from these designs complies with all applicable radio-frequency regulations in their jurisdiction. The author provides these designs for personal, educational, and hobbyist use and makes no representation that a device built from them satisfies the regulatory requirements for commercial distribution.