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 MCURP2350B (QFN-80)Dual Cortex-M33, 150MHz (up to 300MHz OC), 512KB SRAM, 12 PIO state machines, 48 GPIO pins
FlashW25Q128 (16MB SPI)Bootloader, dual firmware slots, config partitions
PSRAM8MB SPI PSRAM64 × 64KB RAM/ROM banks for Z80 address space
Co-processorESP32-S3-PICO-1WiFi, SD card, web server, OTA
USB hubCH334FUSB hub, firmware update bridging
Power supplyTLV62590BV5V → 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:
  1. T1 rising edgez80_addr SM outputs the PC value onto A0–A15. z80_fetch asserts /M1 low via set pins.
  2. 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.
  3. 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.
  4. T3 rising edgein 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.
  5. T3 falling edge/MREQ is asserted again (for the refresh row strobe).
  6. 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:
  1. Sets IRQ 6 to signal the cycle SM that a bus request is pending.
  2. Waits for the current bus cycle to complete (wait 1 irq 0).
  3. 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.
  4. Spins on jmp pin until /BUSREQ goes inactive (high).
  5. Pulls a second 32-bit word to restore normal pin directions and deassert /BUSACK.
  6. 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 — 0x00000x003F map to the vectors block and 0x00400x01FF 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_OPENINCLUDE_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.

Recording (SAVE) — the Waveform Decoder

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 recoveryinitVideoText 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:
  1. Create a new .c / .h file in the src/drivers/ directory.
  2. Implement read and write handler functions matching the signatures above.
  3. Register the handler function pointers in the memioPtr or ioPtr tables during driver initialisation.
  4. Add the driver to the CMakeLists.txt build target.
  5. Add a type string entry so that the JSON configuration parser can instantiate the driver by name.
  6. 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
            } ]

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
CLKTRG0CLKTRG3 in 0 Clock or trigger input for channels 0 to 3. The significant edge is chosen by the channel control word.
ZCTO0ZCTO2 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
A0A7 bidirectional 1 Port A data bits. Direction follows the mode programmed by the Z80 at run time.
B0B7 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
GATE0GATE2 in 1 Counter gate inputs. Modes 0, 2, 3 and 4 respond to the level; modes 1 and 5 trigger on the rising edge.
OUT0OUT2 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
PA0PA7 bidirectional 1 Port A data bits.
PB0PB7 bidirectional 1 Port B data bits.
PC0PC7 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
IN0IN7 in 0 A Z80 read of the port returns these eight levels as bits 0 to 7.
OUT0OUT7 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 keysc (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

Resource Link
picoZ80 project page /picoz80/
picoZ80 User Manual /picoz80-usermanual/
pico6502 project page /pico6502/
RP2350 Datasheet datasheets.raspberrypi.com
RP2350 PIO Reference datasheets.raspberrypi.com — Appendix B
Pico SDK Documentation raspberrypi.github.io/pico-sdk-doxygen
ESP32-S3 Technical Reference docs.espressif.com
ESP-IDF Programming Guide docs.espressif.com/esp-idf
Zilog Z80 CPU User Manual zilog.com
OpenOCD Documentation openocd.org
X (Twitter) project preview engineerswork1

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.