Skip to content

System Model & Data Flow

PolyKybd has a lot of moving parts: a host application (PolyKybdHost), two keyboard halves that run the same firmware image in different roles, a custom HID protocol, and 8 MB of external flash carved into regions. This page is a single mental model of how it all fits together — the topology, the command surface, where resources live, and the handful of runtime processes worth understanding before you change any of them.

Three actors, two links. PolyKybdHost only ever speaks to the master (the USB half). The master owns the HID device and is the only one that relays to the slave over the split serial link. Both halves run the identical firmware — including a second core (core1) used to offload RLE decompression — and differ only in role, decided at runtime from which half has USB.

flowchart LR
  subgraph HOST["PolyKybdHost — Python host software"]
    PC["<b>PolyCore</b><br/>• tracks the active window<br/>• renders per-app key overlays<br/>• control socket + polyctl CLI"]
  end
  subgraph MASTER["Master half — USB role"]
    MC0["<b>core0</b> — QMK main loop<br/>matrix scan · USB · HID dispatch<br/>split master · OLED render"]
    MC1["<b>core1</b><br/>RLE decompression"]
    MRAM["overlays[810][360] ≈ 285 KB<br/>+ g_all_fonts"]
    MC0 <-->|FIFO| MC1
    MC0 --> MRAM
  end
  subgraph SLAVE["Slave half — same image, slave role"]
    SC0["<b>core0</b> — QMK main loop<br/>matrix scan · OLED render<br/>split slave"]
    SC1["<b>core1</b><br/>RLE decompression"]
    SRAM["overlays[810][360] ≈ 285 KB<br/>+ g_all_fonts"]
    SC0 <-->|FIFO| SC1
    SC0 --> SRAM
  end
  PC -->|"USB HID · 64-byte reports"| MC0
  MC0 -->|"full-duplex serial · CRC32 · retries"| SC0

Every report is [report-id][command][payload…], 64 bytes. Replies are prefixed P\xNN. (ACK) or P\xNN! (NACK). Commands live on the keyboard-specific ID_POLYKYBD = 80 channel. Newer commands are gated by PROTOCOL_VERSION, but the host does not require an exact match — it connects to any firmware reporting protocol 2 or newer (the floor at which it can still enumerate languages) and then enables each feature individually:

Keyboard vs host protocol What happens
Below the floor Refused — “please update the keyboard firmware”
Older than the host Connects; features the firmware lacks are greyed out
Same as the host Fully supported
Newer than the host Connects in a restricted safe mode, and offers to update the host app

So a keyboard and a host app from different releases still work together, with only the newer features unavailable — rather than refusing to connect at all.

Cmd Name Notes
10 / 0x0A SEND_OVERLAY Plain 60-byte segment (6 per keycap).
16 / 17 START / SEND_COMPRESSED_OVERLAY RLE-compressed keycap in 1–2 packets → core1 decompress. The common case.
18 / 19 START / SEND_ROI_OVERLAY Partial refresh of a keycap region.
21 / 0x15 SEND_OVERLAY_MAPPING pool→display map: which slot each keycap draws.
11 / 12 OVERLAY_FLAGS on / off Enable/disable overlay rendering.
26 / 0x1A SAVE_MRU Persist the keyboard’s most-recently-used emoji / language picks.
Cmd Name Notes
6 GET_ID id string + per-bundle font-pack version block (after the NUL). The connect handshake.
7 GET_LANG Current language code.
27 / 0x1B GET_LANG_LIST_PACKED Language list as 2-byte ISO index pairs.
9 CHANGE_LANG Select active language.
22 GET_DEFAULT_LAYER Read the persisted default layer.
Cmd Name Notes
13 SET_BRIGHTNESS Contrast; a flags byte selects volatile / host-auto.
14 KEYPRESS Host-injected key event.
15 IDLE_STATE Start / stop the idle anti-burn-in animation.
20 SET_UNICODE_MODE Linux / Mac / Windows / WinCompose / BSD input mode.
24 DISPLAY_OFF Blank the keycaps.
28 / 0x1C IDLE_STYLE Idle anti-burn-in style: pulse / jitter / screensaver.
29 / 0x1D SET_OS Active host OS → modifier legends.
30 / 0x1E GLYPH_SCRIPT Override legends with a fantasy/retro script (open-ended index).
23 / 25 BOOTLOADER / HANDEDNESS Enter bootloader; set which half is which.

Resource flash transport — BEGIN / CHUNK / COMMIT

Section titled “Resource flash transport — BEGIN / CHUNK / COMMIT”
Cmd Name Notes
0x50 FONTPACK_BEGIN Erase the target slot; a byte selects the target (font bundle, firmware, engine data).
0x51 FONTPACK_CHUNK 56-byte sequential chunks; deferred sector erase; bridged to the slave.
0x52 FONTPACK_COMMIT Verify CRC32, mark the slot present.

The same three-command flow, with a different target, drives firmware update, font-pack bundles, and the engine packs — see Resource flashing below.

Custom hardware: 8 MB external QSPI, hard-partitioned. The authoritative map is base/fw_staging.h.

Range Size Contents
0x000000–0x200000 2 MB Running firmware (linker flash1 XIP window)
0x200000–0x400000 2 MB Firmware-update staging (4 KB header + staged image)
0x400000–0x600000 2 MB Font pack — independently-versioned bundles, each in its own slot
0x600000–0x7C0000 1.75 MB DOOM WAD (pinned to the engine’s XIP address)
0x7C0000–0x7FE000 248 KB DOOM engine pack
0x7FE000–0x800000 8 KB Emulated EEPROM (wear-levelling backing store)

The last 8 KB is not the keyboard’s to spend: QMK’s RP2040 wear-levelling driver places the emulated EEPROM at the very top of flash, so everything you configure — keymap, brightness, language, idle style, glyph script — lives there, and the DOOM engine-pack slot deliberately stops short of it.

The firmware partition is the budget that matters for adding languages and fonts: split72:default uses roughly a third of its 2 MB. FW_STAGING_OFFSET is kept equal to the linker’s flash1 length, so a build that exceeds 2 MB fails to link rather than silently growing into the staging area.

Font-pack bundles are .plyf files carrying ABI version 2 — column-native (OLED page) glyph bitmaps (see Column-native glyph bitmap format). A keyboard rejects a bundle whose ABI does not match its firmware and falls back to its resident fonts, so the host, firmware, and shipped bundles are versioned together.

overlay_pool[600][360] ≈ 211 KiB — the live keycap images, addressed indirectly so variants sharing artwork share a slot
display_to_pool[1440] display position → pool slot (90 slots × 16 modifier variants)
display position slot + 90 × modifier_variant; since v12 the variant is the modifier bitmask (Ctrl/Shift/Alt/GUI), so all 16 combinations are addressable
g_all_fonts resident fonts ++ the flashed font-pack bundles, assembled at boot

Each half boots independently and loads its own state from EEPROM. The USB half becomes the master; it renders all keycaps and brings up the split link to the slave. Until the link establishes, the master retries; if it never comes up it runs solo.

sequenceDiagram
  autonumber
  participant M as Master (USB half)
  participant L as Split link
  participant S as Slave half
  M->>M: boot · load EEPROM · render 72 keycaps (splash)
  S->>S: boot · load EEPROM · render splash
  M->>L: establish split transport
  L->>S: handshake
  S-->>M: connected
  M->>S: one-shot layer resync (correct a stale default layer)
  Note over M,S: link up — state + overlays now flow master → slave

When the active window changes, PolyKybdHost sends the new keycap images. The master decompresses them (on core1) into its overlay RAM, then relays each keycap to the slave over the split link so both halves show the same thing.

sequenceDiagram
  autonumber
  participant H as PolyKybdHost
  participant M0 as Master core0
  participant M1 as Master core1
  participant S as Slave
  H->>M0: compressed keycap images (cmd 16/17) ×N
  M0->>M1: FIFO — decompress
  M1-->>M0: → overlays[] (RAM)
  M0->>M0: overlay mapping (cmd 21) · enable (cmd 11)
  M0->>S: relay overlay rows + mapping (split link, CRC32)
  S-->>M0: ACK per transaction
  Note over M0,S: both halves now hold the new images

Frequently-used apps are also cached (the MRU path), so a return to a recent app replays from cache rather than re-sending every image.

To avoid burning the OLED legends in, the keycaps fade and then run the configured idle style — pulsing, migrating their legends, or a full screensaver — and turn off entirely after a longer timeout. Any key activity (or proximity, if the light sensor is fitted) wakes them.

stateDiagram-v2
  [*] --> Awake
  Awake --> Fade: idle timeout
  Fade --> Idle: fade complete
  state Idle {
    [*] --> Pulse: style = pulse
    [*] --> Jitter: style = jitter
    [*] --> Eden: style = eden
  }
  Idle --> Off: turn-off timeout
  Awake --> Suspend: USB suspend
  Fade --> Suspend: USB suspend
  Idle --> Suspend: USB suspend
  Off --> Suspend: USB suspend
  Idle --> Awake: key / proximity
  Off --> Awake: key / proximity
  Suspend --> Awake: USB resume

Only the style value crosses the split link; each half runs its own idle animation on its own keys from that and the shared pulse value. The Eden screensaver is drawn by split72-only code, so selecting it on a split42 leaves the legends dimmed and static — see Idle & Burn-in Protection.

Firmware images, font-pack bundles, and the engine packs all ride the same BEGIN / CHUNK / COMMIT transport. The master streams chunks to its own staging flash and bridges them to the slave, so both halves end up with byte-identical data. A firmware apply reboots both halves in lockstep.

sequenceDiagram
  autonumber
  participant H as PolyKybdHost
  participant M as Master
  participant S as Slave
  H->>M: BEGIN (target, size, CRC)
  M->>S: bridge BEGIN
  Note over M,S: both erase the target slot (deferred, sector-by-sector)
  loop chunks
    H->>M: CHUNK (56 B)
    M->>S: bridge CHUNK
  end
  H->>M: COMMIT
  M->>S: bridge COMMIT
  S-->>M: CRC OK
  Note over M,S: font pack → reload in place · firmware → apply + reboot both halves

RLE decompression of overlays is offloaded to core1 so the QMK main loop on core0 stays responsive. core0 posts a job over the inter-core FIFO and reads back a completion count; core1 loops on the FIFO, decompresses, and updates the count.

flowchart LR
  C0["core0 — QMK main loop"] -->|"CORE1_CMD_* (FIFO)"| C1["core1 — decompress loop"]
  C1 -->|"writes"| OV["overlays[] (RAM)"]
  C1 -->|"decomp_count"| C0

This is why a compressed-overlay burst does not stall matrix scanning on core0 — the CPU-heavy part happens on the second core.