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 viaudevmass-storage events on the Pi side. The Pico no longer touches the cart bus.
1. What the bridge is responsible for
Section titled “1. What the bridge is responsible for”The coprocessor bridge is a Pi-side userspace daemon (or daemon module) that:
- Owns the
/dev/serial0file descriptor at 1 Mbps 8N1 (UART0, GPIO14/15 per ADR-0017 §4). - Builds and transmits v0.2 wire frames for all PSG and OLED commands.
- Receives and dispatches Pico-originated event frames (
BUFFER_OVERFLOW,INTERNAL_ERROR) and correlation responses (HELLOecho,VERSION_RESPONSE). - Implements the §5.3 bootstrap sequence at startup and after a detected Pico reboot.
- Implements the §5.2 heartbeat (5-second ping, 3-strike degraded transition).
- Maintains error counters and surfaces degraded state to nOSh.
The bridge does not handle:
- Cartridge detection or cartridge bus I/O — those are
udevmass-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.
2. Wire frame format
Section titled “2. Wire frame format”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_*) useseq = 0. Handshake/query frames useseq ∈ [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 byte | Name | seq | Payload |
|---|---|---|---|
0x01 | HELLO | 1–255 (handshake) or 0 (heartbeat) | 6 B: role, flags, nonce |
0x03 | VERSION_QUERY | 1–255 | empty |
0x20 | PSG_REG_WRITE | 0 | 2 B: reg, value |
0x21 | PSG_RESET | 0 | empty |
0x22 | PSG_BULK_WRITE | 0 | 14 B: all 14 YM2149 registers |
0x30 | OLED_SET_ROW | 0 | 3+N B: row, col_start, text_len, text |
0x31 | OLED_SCROLL_ROW | 0 | 3 B: row, direction, cells |
0x32 | OLED_FILL | 0 | 2 B: row, glyph |
0x33 | OLED_CLEAR | 0 | 1 B: row mask (0xFF = all rows) |
Types 0x10–0x16 (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.
3. Command queue design
Section titled “3. Command queue design”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_OVERFLOWevent after a 100 ms quiescent window. - The Pi must rate-limit to 80% of nominal throughput after receiving a
BUFFER_OVERFLOWevent, and hold that rate until 5 seconds with no further overflows. - At 1 Mbps 8N1, a
PSG_REG_WRITEframe (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.
4. Backchannel event subscriber
Section titled “4. Backchannel event subscriber”The Pico sends unsolicited frames in two cases:
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.EVENT(0xE0) — subsystem events from the Pico:
| Event code | Name | What to do |
|---|---|---|
0x03 | BUFFER_OVERFLOW | Log; apply 80% rate limit per §4.15; clear after 5 s with no further overflows. |
0x04 | INTERNAL_ERROR | Log 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:
- Parse with
coproc_recv()— validates length, CRC, rejects vacated types. - Route on
frame.type:HELLO: trigger bootstrap re-handshake.VERSION_RESPONSE: correlate with the outstandingVERSION_QUERYviaseq.EVENT: demux onevent_code; forward to nOSh via the event bus.ERROR: log; correlate with the outstanding request viaseqif possible; raise error flag.
- Log unrecognized frames with the raw bytes for diagnostics.
5. Error recovery
Section titled “5. Error recovery”5.1. Frame resync (UART garbage)
Section titled “5.1. Frame resync (UART garbage)”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.
5.2. Command-timeout handling
Section titled “5.2. Command-timeout handling”Only handshake and query frames expect a response. Timeouts per protocol spec §5.1:
| Frame | Timeout | Action on timeout |
|---|---|---|
HELLO (handshake) | 200 ms | Retry up to 3 times with fresh nonce; on 3rd timeout, hard fail. |
VERSION_QUERY | 200 ms | Retry up to 2 times; on failure, hard fail. |
HELLO (heartbeat) | 200 ms | Increment 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.
5.3. Degraded state
Section titled “5.3. Degraded state”After 3 consecutive missed heartbeats (~15 s):
- Raise
KN86_LINK_DEGRADEDflag to nOSh. - Display
COPROCESSOR LINK DEGRADEDon 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.
5.4. Full-restart escalation
Section titled “5.4. Full-restart escalation”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.
6. Boot-time handshake
Section titled “6. Boot-time handshake”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.
7. Emulator stub equivalence
Section titled “7. Emulator stub equivalence”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_UARTstub incoproc_send()logs the frame bytes to stdout. This mode exists to validate frame contents during integration testing before/dev/serial0wiring 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.