Skip to content

Coprocessor Firmware

The Pico-side firmware for the realtime I/O coprocessor: architecture, the YM2149 PSG emulation algorithm, the MAX98357A I2S output, the SSD1322 OLED driver, versioning, and the build/flash workflow. Read this if you are modifying the Pico firmware, debugging an audio glitch, debugging an OLED render artifact, or bringing up a fresh Pico 2 board.


The Pico 2 firmware lives at kn86-pico/ in this repo (deliverable F2 of ADR-0017). It is a Pico-SDK + CMake project with three concurrent subsystems running on the dual-core M33:

+--------------------------------------------------------------+
| kn86-pico/ |
| |
| Core 0: UART command handler (main loop) |
| +--> Frame parser (state machine per coproc-protocol §2.4)|
| +--> Dispatch table (coproc-protocol §3 frame types) |
| +--> Heartbeat tracker (§5.2; degraded after 3 missed) |
| +--> Session state (HELLO/VERSION handshake; §5.3) |
| |
| Core 1: Subsystem workers |
| +--> PSG emulator (YM2149 register set + sample gen) |
| +--> I2S DMA pump (MAX98357A output, 44.1 kHz) |
| +--> SSD1322 OLED driver (SPI, framebuffer, ticker) |
| |
| PIO state machines (programmable I/O blocks): |
| +--> SM 0: I2S BCK/LRCK/DOUT to MAX98357A |
| +--> SM 1: SPI to SSD1322 (or hardware SPI; TBD F2) |
| +--> SM 2-11: free; reserved for future protocols |
+--------------------------------------------------------------+

Core 0 owns the UART link end-to-end. Frames arrive byte-at-a-time via interrupt; a small ring buffer feeds the parser; valid frames dispatch to Core-1 workers via lock-free single-producer/single-consumer queues per subsystem. Core 0 never blocks on a worker — fire-and-forget commands (PSG_*, OLED_*) are queued and acknowledged only via async EVENT(BUFFER_OVERFLOW) if the queue is saturated.

Core 1 owns the workers. The I2S DMA pump fires every ~22 µs (per 44.1 kHz sample) and is timing-critical; the OLED driver runs on a soft timer at 30 Hz for ticker scroll updates (CIPHER-LINE Row 2/3 typewriter cadence, ADR-0015). Both worker subsystems pull commands from their queues at queue-poll cadence and apply them.

The Pico-SDK multicore_* API handles the inter-core handoff. There is no RTOS — the firmware is a bare-metal Pico-SDK build with cooperative loops on each core.

power-on
-> RP2350 stage-1 boot ROM
-> jumps to kn86-pico flash image
-> Core 0: init UART0 @ 1 Mbps, init dispatch table, send unsolicited HELLO
if no Pi HELLO arrives within 1 s of boot (§5.3 fallback)
-> Core 1: init PSG state to all zeros, init I2S DMA pump in idle (no samples),
init SSD1322 driver (panel init sequence), display "KN-86 BOOT"
splash on OLED
-> Both cores enter their main loops; firmware is operational

Boot completes in <100 ms. The Pi typically boots Linux in 5–10 s, so the Pico is always ready by the time the Pi’s kn86-coprocessor.service (boot-and-systemd.md) sends its HELLO.

The YM2149 is the General Instruments AY-3-8910-family PSG used in the Atari ST, Amiga, MSX, and ZX Spectrum 128. The Pico firmware emulates it in software at sample-accurate cadence and pumps the samples to the MAX98357A over I2S.

Spec values are canonical in CLAUDE.md (Audio row): 3 tone channels + 1 noise channel, envelope generator, 14 registers, 44.1 kHz mono PCM output. This doc covers the algorithm at a high level; for the exact register layout see the upstream YM2149 datasheet referenced in ADR-0017 and the kn86-pico/src/psg/ source comments.

State machine (one tick per output sample)

Section titled “State machine (one tick per output sample)”
1. For each tone channel (A, B, C):
- period = (regs[2k+1] << 8) | regs[2k] # 12-bit tone period
- tone_counter[k] -= 1
- if tone_counter[k] == 0:
tone_phase[k] ^= 1 # square wave flip
tone_counter[k] = period
- tone_output[k] = tone_phase[k]
2. Noise channel:
- noise_period = regs[6] & 0x1F # 5-bit noise period
- noise_counter -= 1
- if noise_counter == 0:
lfsr_step() # 17-bit LFSR per AY-3-8910 spec
noise_counter = noise_period
- noise_output = lfsr & 1
3. Mixer (regs[7]):
- For each channel: mixed[k] = (tone_output[k] | tone_disable[k])
& (noise_output | noise_disable[k])
4. Envelope generator (regs[11..13]):
- Run the 16-step envelope state machine per the regs[13] shape bits
- env_amplitude in [0, 15]
5. Per-channel amplitude (regs[8..10]):
- if regs[k] & 0x10: amp[k] = env_amplitude
- else: amp[k] = regs[k] & 0x0F
6. Sample mix:
- sample = sum_k(mixed[k] * volume_lut[amp[k]])
- sample is signed 16-bit, mono
7. Push sample to I2S DMA buffer.

The volume_lut is the standard YM2149 logarithmic 16-step volume table. State table size is small (~64 bytes); per-sample compute fits comfortably in the M33 budget at 44.1 kHz.

Pi sends PSG_REG_WRITE(reg, value) (frame type 0x20, coprocessor-protocol.md §4 — exact payload TBD against the spec body). The Pico’s UART handler queues the write to Core 1; Core 1 applies the write to its register file at the next sample-boundary so a register update never lands mid-sample. PSG_BULK_WRITE (0x22) batches multiple register updates atomically — useful for a phase-coherent retrigger.

PSG_RESET (0x21) zeros the register file (effectively silence) and is the path used by power-idle.md to throttle the synth on idle.

The MAX98357A is a class-D mono I2S DAC + amplifier. It accepts standard I2S timing (BCLK + LRCLK + DIN) and outputs directly to the speaker. No I²C config required — the chip strap-pins set the gain and channel.

Pico GPIO (TBD F2 bring-up) -BCLK-> MAX98357A BCLK
Pico GPIO (TBD F2 bring-up) -LRCK-> MAX98357A LRC
Pico GPIO (TBD F2 bring-up) -DOUT-> MAX98357A DIN
|
+--> 28mm 8Ω 2W speaker

The Pico’s PIO state machine is the I2S generator: 32-bit frames at 1.4112 MHz BCLK (32× 44.1 kHz). DMA double-buffers the sample stream so the PSG worker writes into one buffer while the PIO drains the other; the swap is interrupt-driven on DMA completion.

Latency from a PSG_REG_WRITE arriving on UART to the corresponding tone change being audible is ~5–20 ms (dominated by the I2S buffer depth). The buffer depth is configurable; the budget per coprocessor-protocol.md §7 (audio latency) is <30 ms end-to-end, leaving comfortable headroom.

The SSD1322 controller drives the 256×64 yellow OLED that carries CIPHER-LINE (ADR-0015). It speaks 4-wire SPI: SCLK, MOSI (the SSD1322 input), DC (data/command select), and CS (chip select). RST is a separate GPIO held high in normal operation, pulled low briefly to reset.

Pico SPI (TBD F2 bring-up) -SCLK-> SSD1322 SCLK
Pico SPI (TBD F2 bring-up) -MOSI-> SSD1322 SDIN
Pico GPIO (TBD F2 bring-up) -CS -> SSD1322 CS#
Pico GPIO (TBD F2 bring-up) -DC -> SSD1322 DC
Pico GPIO (TBD F2 bring-up) -RST -> SSD1322 RST#

On Pico boot the OLED driver runs the SSD1322 power-on init sequence (the chip’s datasheet is the authoritative source; the Pico firmware’s kn86-pico/src/oled/init.c carries the exact byte sequence):

  1. Pull RST low for 10 ms, release, wait 100 ms.
  2. Send 0xFD 0x12 (unlock command) so subsequent commands are accepted.
  3. Send 0xAE (display off) before reconfiguring.
  4. Set front-clock divider, MUX ratio, display offset, segment remap (the panel’s physical pixel layout has the SEG/COM lines mapped through a remap table — exact values per the datasheet’s panel-orientation guidance).
  5. Set GPIO/IREF/precharge/VCOMH per panel datasheet recommendations.
  6. Set contrast (greyscale level — start at mid-range; per-mission brightness tuning is a power-idle.md concern).
  7. Send 0xAF (display on).

After init, the OLED driver maintains a 256×64 1-bit framebuffer in Pico SRAM (~2 KB) and pushes dirty rows to the panel on demand.

The framebuffer is logically partitioned into four 256×16-pixel rows per ADR-0015 §2 + CLAUDE.md (Auxiliary Display row):

Logical rowPixel rowsOwner / content
Row 10–15Status strip — battery / timer / mode / TERM hint
Row 216–31CIPHER scrollback — current fragment
Row 332–47CIPHER scrollback — previous echo (fading)
Row 448–63Contextual — seed capture / sub-timer / mission meta

UART command frames map to the layout:

  • OLED_SET_ROW(row, "string") (0x30) — replaces the contents of row (1–4) with string. Press Start 2P 8×8 source bitmap, rendered 1× horizontal / 2× vertical to fill the 16-pixel row height.
  • OLED_SCROLL_ROW(row, direction, px_per_sec) (0x31) — animates a row’s existing content scrolling. The Pico runs the scroll on its own 30 Hz timer; the Pi sends the command once and forgets, immune to Pi rendering load (one of the load-bearing reasons for ADR-0017).
  • OLED_FILL(rect, mode) (0x32) — fills a sub-rect with a grayscale level; used for dim policy in power-idle.md.
  • OLED_CLEAR() (0x33) — blanks the entire panel.

Dirty-rect tracking limits SPI traffic — only changed rows get pushed. A full-panel refresh is ~2 KB at the SPI clock the Pico drives, well under 1 ms.

Firmware semver lives in kn86-pico/include/kn86_pico_version.h:

#define KN86_PICO_FW_MAJOR 0
#define KN86_PICO_FW_MINOR 2
#define KN86_PICO_FW_PATCH 0
#define KN86_PICO_PROTO_MAJOR 0 /* matches coproc-protocol §4.3 */
#define KN86_PICO_PROTO_MINOR 2

These are reported back to the Pi in the VERSION_RESPONSE frame (coprocessor-protocol.md §4.3) and also embedded in the boot banner that the firmware writes to the OLED at boot — KN-86 PICO v0.2.0 on row 2 of the splash. The git commit hash (lower 32 bits) is included in VERSION_RESPONSE.build_id for bug reports.

The Pi’s coprocessor daemon refuses to enter the operational state if proto_major doesn’t match its expected value (per coproc-protocol §4.3 “Version mismatch behaviour” and ADR-0017 §5). This is a hard fail — the operator sees COPROCESSOR PROTOCOL MISMATCH (v0 vs v1) on Row 24 of the primary display.

# Initial setup (one-time):
git clone https://github.com/raspberrypi/pico-sdk
cd pico-sdk && git submodule update --init
export PICO_SDK_PATH=$PWD
# Build the firmware:
cd kn86-pico
mkdir build && cd build
cmake .. -DPICO_BOARD=pico2
make
# Output: kn86-pico.uf2

Flash paths:

  1. Direct USB BOOTSEL (dev fast path). Hold BOOTSEL on the Pico, plug it into a host USB port, the Pico enumerates as USB MSC, drag kn86-pico.uf2 onto the mounted volume. The Pico re-enumerates with the new firmware. Used during F2 bring-up and tight iteration on the bench.

  2. Pi-mediated BOOTSEL (production path, ADR-0017 §6). The Pi’s coprocessor daemon pulses GPIO22 (BOOTSEL) low, asserts GPIO23 (RESET), releases RESET, waits 100 ms for the Pico to enumerate as USB MSC over the internal hub. The Pi mounts that MSC volume and copies /lib/firmware/kn86-pico.uf2 onto it; releases BOOTSEL; resets again. Used by the firmware-update flow so that a .kn86fw system update can ship a new Pi+Pico pair atomically. The Pico image lives in the Pi rootfs at /lib/firmware/kn86-pico.uf2; ADR-0011 ships an amendment that bundles the Pico image into the .kn86fw payload (see ADR-0017 follow-on F6).

  3. picotool load (alternative dev path). With the Pico in BOOTSEL on the host:

    picotool load -x kn86-pico.uf2

    -x resets and runs the new firmware. Useful when integrating with make flash.

Shipped KN-86 units come pre-flashed at assembly time — the Pico has the matching kn86-pico.uf2 baked in. Field updates flow through path (2) above. Recovery for a totally bricked Pico (e.g., a corrupt flash image) is TBD final — ADR-0017 §6 mentions a debug header for last-resort recovery; the exact pin assignment and physical access path is TBD pending bring-up.

There is no JTAG/SWD on a stock Pico 2 module, so the recovery story for a bricked Pico-via-module is a USB-BOOTSEL gesture; for a bare-RP2350-on-custom-sub-board production layout (ADR-0017 KU#3) the SWD pads on the chip become the recovery interface. That decision is also TBD pending the production-tooling pass.