Skip to content

Coprocessor Bridge

The Pi-side software component that owns /dev/serial0 and manages the UART link to the Pico 2 I/O coprocessor. Single source for the bridge’s architecture, command-queue design, backchannel event handling, error recovery, and boot-time handshake.

Related:

  • adr/ADR-0017 — commits the Pico 2 as realtime I/O coprocessor and defines the UART link parameters, responsibilities, and the delivery items (F1–F5) this bridge is part of.
  • docs/software/api-reference/grammars/coprocessor-protocol.md — the wire-level spec: every frame layout, every error code, every timeout, the bootstrap sequence. The bridge must conform to every canonical clause.
  • kn86-emulator/src/coproc.c, coproc.h — the current emulator implementation. The in-process mode is the working reference; the UART mode stub shows the integration point where the device bridge will wire in.
  • audio-pipeline.md — PSG commands flow through this bridge on the prototype.
  • display-pipeline.md — OLED commands flow through this bridge on the prototype.
  • adr/ADR-0019 — cartridge events do NOT flow through this bridge; they arrive via udev mass-storage events on the Pi side. The Pico no longer touches the cart bus.

The coprocessor bridge is a Pi-side userspace daemon (or daemon module) that:

  1. Owns the /dev/serial0 file descriptor at 1 Mbps 8N1 (UART0, GPIO14/15 per ADR-0017 §4).
  2. Builds and transmits v0.2 wire frames for all PSG and OLED commands.
  3. Receives and dispatches Pico-originated event frames (BUFFER_OVERFLOW, INTERNAL_ERROR) and correlation responses (HELLO echo, VERSION_RESPONSE).
  4. Implements the §5.3 bootstrap sequence at startup and after a detected Pico reboot.
  5. Implements the §5.2 heartbeat (5-second ping, 3-strike degraded transition).
  6. Maintains error counters and surfaces degraded state to nOSh.

The bridge does not handle:

  • Cartridge detection or cartridge bus I/O — those are udev mass-storage events on the Pi USB stack (ADR-0019). The Pico does not observe cart insertion or removal.
  • Audio synthesis — the Pico owns YM2149 synthesis and I2S output. The bridge relays register-write commands, not PCM samples.
  • OLED rendering — the Pico owns the SSD1322 SPI driver. The bridge relays row-content commands, not pixel buffers.

The emulator satisfies the same interface by running in COPROC_MODE_INPROCESS — no UART involved, all calls dispatch directly to psg.c and oled.c in-process. The vtable seam in coproc.h is the abstraction boundary: call sites in sound.c and oled.c never observe which mode is active.


All frames follow the v0.2 envelope defined in the coprocessor protocol spec §2:

+--------+--------+------+-----+----------+----------+----------+
| len_lo | len_hi | type | seq | payload | crc16_lo | crc16_hi |
+--------+--------+------+-----+----------+----------+----------+
  • len (2 B, LE): total frame length including all fields. Min 6 (zero payload), max 1024.
  • type (1 B): frame type from the canonical set (see protocol spec §3).
  • seq (1 B): sequence number. Fire-and-forget commands (PSG_*, OLED_*) use seq = 0. Handshake/query frames use seq ∈ [1, 255].
  • payload: type-specific bytes (see protocol spec §4).
  • crc16 (2 B, LE): CRC-16/CCITT-FALSE over [type, seq, payload]. Reference test vector: ASCII "123456789"0x29B1.

The current implementation in coproc.c provides coproc_build_frame() (frame construction) and coproc_recv() (frame parsing + CRC validation). Both are shared between emulator and future device builds — the device build will use these same functions with a real write()/read() call where the emulator uses in-process dispatch.

Canonical frame types the bridge sends (Pi→Pico):

Type byteNameseqPayload
0x01HELLO1–255 (handshake) or 0 (heartbeat)6 B: role, flags, nonce
0x03VERSION_QUERY1–255empty
0x20PSG_REG_WRITE02 B: reg, value
0x21PSG_RESET0empty
0x22PSG_BULK_WRITE014 B: all 14 YM2149 registers
0x30OLED_SET_ROW03+N B: row, col_start, text_len, text
0x31OLED_SCROLL_ROW03 B: row, direction, cells
0x32OLED_FILL02 B: row, glyph
0x33OLED_CLEAR01 B: row mask (0xFF = all rows)

Types 0x100x16 (former cart-bus types) are vacated per ADR-0019 and MUST be rejected — coproc_recv() returns COPROC_ERR_UNKNOWN_TYPE for these and coproc_send() refuses to build frames with these types.


PSG and OLED commands are fire-and-forget (seq = 0); there is no per-command acknowledgment from the Pico. The bridge maintains a bounded command queue for each subsystem to absorb bursts.

Queue design constraints (from protocol spec §4.15 and §7):

  • The Pico maintains a 32-frame internal queue per subsystem (PSG, OLED). When its queue is full, the Pico drops the oldest entry and emits a BUFFER_OVERFLOW event after a 100 ms quiescent window.
  • The Pi must rate-limit to 80% of nominal throughput after receiving a BUFFER_OVERFLOW event, and hold that rate until 5 seconds with no further overflows.
  • At 1 Mbps 8N1, a PSG_REG_WRITE frame (8 bytes) costs 80 µs of wire time. The Pico’s 32-frame queue absorbs about 2.6 ms of burst.

Pi-side queue behavior:

  • Maintain a single-writer bounded FIFO per subsystem (PSG, OLED).
  • Under normal conditions, drain the queue to UART as fast as the link allows.
  • On NACK or BUFFER_OVERFLOW: log, increment the overflow counter, apply back-pressure (reduce drain rate to 80% of nominal), and surface a degraded-state flag to nOSh.
  • If the queue is full and a new command arrives before draining: the oldest queued command in that subsystem is dropped (prioritize recency for PSG register state). Log the drop for diagnostics.

The protocol spec does not mandate persistent disk logging for dropped commands — that is an implementation choice. If the bridge logs drops to a ring buffer in SRAM rather than disk, it avoids I/O latency in the hot path.


The Pico sends unsolicited frames in two cases:

  1. HELLO(role=Pico, flags=HANDSHAKE) — the Pico has just booted (crash-and-reboot or cold boot). Treat as a session reset: run §5.3 bootstrap re-handshake.
  2. EVENT (0xE0) — subsystem events from the Pico:
Event codeNameWhat to do
0x03BUFFER_OVERFLOWLog; apply 80% rate limit per §4.15; clear after 5 s with no further overflows.
0x04INTERNAL_ERRORLog diag string; if error_class indicates hard subsystem outage (ERR_INTERNAL_PICO), surface degraded state to nOSh.

Cart-related event codes 0x01 (CART_INSERTED) and 0x02 (CART_REMOVED) are obsolete per ADR-0019 — the Pico does not observe the cart bus, so these events will never be emitted by conforming v0.2 Pico firmware. The bridge must not rely on these events for any logic.

The backchannel reader runs on a dedicated receive path (separate thread or select/poll loop). On receipt of any Pico-originated frame:

  1. Parse with coproc_recv() — validates length, CRC, rejects vacated types.
  2. Route on frame.type:
    • HELLO: trigger bootstrap re-handshake.
    • VERSION_RESPONSE: correlate with the outstanding VERSION_QUERY via seq.
    • EVENT: demux on event_code; forward to nOSh via the event bus.
    • ERROR: log; correlate with the outstanding request via seq if possible; raise error flag.
  3. Log unrecognized frames with the raw bytes for diagnostics.

If the parser encounters bytes that fail the len < 6 or len > 1024 check, or a CRC mismatch, the parser returns to IDLE and waits for the next frame start. The protocol spec §2.4 defines the resync rule: any 10 ms gap with no incoming byte flushes the RX buffer and returns to IDLE.

For a hard-stuck Pico (no frames at all for 30 s), issue a UART BREAK condition. Both endpoints flush to IDLE on BREAK; the Pi then re-runs §5.3 bootstrap.

Only handshake and query frames expect a response. Timeouts per protocol spec §5.1:

FrameTimeoutAction on timeout
HELLO (handshake)200 msRetry up to 3 times with fresh nonce; on 3rd timeout, hard fail.
VERSION_QUERY200 msRetry up to 2 times; on failure, hard fail.
HELLO (heartbeat)200 msIncrement missed_heartbeats; after 3 consecutive misses, enter degraded.

Hard fail: log the failure, display COPROCESSOR LINK FAILED on Row 24, refuse to start the nOSh runtime.

After 3 consecutive missed heartbeats (~15 s):

  • Raise KN86_LINK_DEGRADED flag to nOSh.
  • Display COPROCESSOR LINK DEGRADED on Row 24.
  • Silence audio (PSG state unreachable; send no more PSG_* frames).
  • Suspend OLED writes (no more OLED_* frames).
  • Continue heartbeat attempts every 5 s.

On a successful heartbeat response while degraded:

  • Reset missed_heartbeats = 0, clear degraded flag.
  • Re-issue VERSION_QUERY (verify Pico did not reboot with a different firmware version).
  • Replay the deck-state-critical OLED content: send OLED_CLEAR(0xFF) then re-populate all four rows from the nOSh OLED shadow buffer.
  • Resume normal PSG and OLED command flow.

If the Pico remains unresponsive after the heartbeat degraded path fails to recover within a configurable timeout (suggestion: 120 s total), escalate to a full-restart: display COPROCESSOR UNRESPONSIVE — REBOOT REQUIRED on Row 24, log a structured error record to the device’s persistent log, and optionally trigger a supervised device reboot via systemd.


The bootstrap sequence runs at daemon start and after any detected Pico reboot. It follows protocol spec §5.3 exactly:

1. Pi → HELLO(role=Pi, flags=HANDSHAKE, nonce=N1) seq=1
Wait ≤ 200 ms for Pico echo.
2. Pico → HELLO(role=Pico, flags=HANDSHAKE, nonce=N1) seq=1
Validate echoed nonce.
3. Pi → VERSION_QUERY seq=2
Wait ≤ 200 ms.
4. Pico → VERSION_RESPONSE seq=2
Validate proto_major matches Pi's expected value.
On mismatch: hard fail — display COPROCESSOR PROTOCOL MISMATCH on Row 24.
5. Pi → PSG_RESET seq=0 (defensive cleanup)
6. Pi → OLED_CLEAR(0xFF) seq=0 (defensive cleanup)
[Step 7 of original spec — CART_DETECT — is obsolete per ADR-0019.
Cart slot state comes from udev mass-storage events, not the Pico.]
7. Link is OPERATIONAL. Begin periodic heartbeats every 5 s per §5.2.

The CRC-16/CCITT-FALSE reference test vector ("123456789"0x29B1) must pass before the bridge raises the link to operational — this validates that both endpoints use the same polynomial and initialization vector.


The emulator satisfies the same coprocessor API surface through COPROC_MODE_INPROCESS in coproc.c. The vtable entries (emu_psg_write, emu_oled_set_row, etc.) call psg.c and oled.c directly, with the same frame-building and dispatch logic as the device path. This means:

  • Cart code that calls (psg-tone 0 440 8) on the emulator and the prototype follows identical code paths up to the vtable dispatch point.
  • The frame-building code (coproc_build_frame, kn86_crc16_ccitt_false) runs on both paths — emulator catches any framing bugs before they reach real UART hardware.
  • The COPROC_MODE_UART stub in coproc_send() logs the frame bytes to stdout. This mode exists to validate frame contents during integration testing before /dev/serial0 wiring is complete.

The device build’s UART integration (opening /dev/serial0, writing frames, reading responses) is the only delta from the emulator path. ADR-0017 §F4 deliverables and the TODO(GWP-293) comment in coproc.c mark the exact integration point.

Cart code never observes which mode is active — the bridge is the seam.