HID Protocol Reference
PolyKybdHost communicates with the firmware over a custom raw HID report protocol. The same protocol is used by the HIL test rig to test the firmware in CI.
Report format
Section titled “Report format”Every message is one 64-byte report on the keyboard’s raw HID interface (usage page
0xFF61, usage 0x62). The host’s HID library puts a report-ID byte 0x00 in front of
each write; the offsets on this page are the 64 bytes the firmware sees, after that byte.
| Offset | Request | Reply |
|---|---|---|
[0] |
0x50 (P) |
0x50 (P) |
[1] |
Command ID | Command ID, echoed |
[2] |
First payload byte | Status: . (0x2E) accepted, ! (0x21) refused |
[3..63] |
Payload | Reply body |
- Unused bytes are
0in both directions. - PolyKybd’s own multi-byte fields are little-endian. The QMK keymap commands (below) keep QMK’s big-endian order.
- A report whose
[0]is not0x50goes to the QMK keymap handler instead. (0x09in[0]is also accepted asP, for historical reasons.) - An unknown command is answered with the request echoed back and
[2]set to!. - The bulk overlay commands get no reply at all; see the note under Resource-flash transport.
The main dispatcher is in the firmware’s hid_com.c (raw_hid_receive()); firmware
update (0x40–0x45) is in hid_fw_up.c and the font-pack transport (0x50–0x53) in
hid_fontpack.c. Exact byte layouts for every command are under
Message formats. The PROTOCOL_VERSION, reported inside the
GET_ID identity string, tells the host which commands this keyboard understands, so each
feature can be enabled or greyed out individually.
Command areas
Section titled “Command areas”The commands are grouped below by purpose. IDs are shown in hex, with the decimal form where the source commonly uses it.
Identity & language
Section titled “Identity & language”| Command | ID | Notes |
|---|---|---|
| GET_ID | 0x06 |
Identity string incl. PROTOCOL_VERSION and firmware version, plus (v6+) a per-bundle font-pack version block after the string’s NUL. The connect handshake and liveness check. |
| GET_LANG | 0x07 |
Query the active keyboard language. |
| CHANGE_LANG | 0x09 |
Select the active language. |
| GET_LANG_LIST_PACKED | 0x1b (27) |
v2+. Language list as a count byte plus one 2-byte (ISO 639-1 index, ISO 3166-1 alpha-2 index) pair per language. The only language-list command on current firmware. |
| GET_LANG_LIST (ASCII) | 0x08 |
Retired. Returned 4 ASCII chars per language; now NACKs (P\x08!). |
| GET_DEFAULT_LAYER | 0x16 (22) |
Read/select the persisted default layer. |
Overlays — the bulk data path
Section titled “Overlays — the bulk data path”| Command | ID | Notes |
|---|---|---|
| SEND_OVERLAY | 0x0A |
Plain overlay: each 360-byte keycap in 6 × 60-byte segments. (v11 packs modifier + segment into one header byte so a 60-byte segment fits the report exactly.) |
| START / SEND_COMPRESSED_OVERLAY | 0x10 / 0x11 |
RLE-compressed keycap in 1–2 packets; decompressed in firmware (optionally on RP2040 core1). The common case. |
| START / SEND_ROI_OVERLAY | 0x12 / 0x13 |
Partial refresh of a region of a keycap’s display. |
| SEND_PRC_OVERLAY | 0x29 (41) |
v19+. PRC-coded keycap images, several records per report. A typical icon codes to about a third of the bytes of the best older encoding. |
| FILL_POOL_FROM_ICON | 0x2A (42) |
v20+. Fills pool slots from the icon library in the keyboard’s flash: (pool slot, icon id) pairs instead of bitmaps. The one bulk overlay command that replies. |
| SEND_OVERLAY_MAPPING | 0x15 (21) |
Maps overlay pool slots to displays / usage bits, at a fixed 10 bits per index. No reply since v3 (no per-chunk ACK). The only mapping command a pre-v12 keyboard understands. |
| SEND_OVERLAY_MAPPING_W | 0x21 (33) |
v12+. The same mapping stream, but a width byte says how many bits each index uses (8–11), so the host packs each group of pairs as tightly as it fits. Also sends no reply. Since v21 two bits of the width byte can also trigger the prepare and enable steps of an app switch, and since v23 a third draws the report’s icons dimmed; see mapping flags. Since v25 a pool slot sent plus 600 draws that key without its legend. |
| OVERLAY_FLAGS on / off | 0x0B / 0x0C |
Enable/disable overlay rendering; enable forces a sync of the overlay state to the slave. From v21 the prepare and enable steps of an app switch can be sent in the mapping reports as flags instead. The host still sends the prepare as its own report before any image upload, and sends both as their own reports when a switch has no mapping report. off is unchanged. |
| SAVE_MRU | 0x1A (26) |
Persist the keyboard’s most-recently-used emoji / language picks. Unrelated to the host’s overlay cache. |
Configuration & display
Section titled “Configuration & display”| Command | ID | Notes |
|---|---|---|
| SET_BRIGHTNESS | 0x0D (13) |
Keycap OLED contrast; a flags byte selects volatile / host-auto (v5+). |
| KEYPRESS | 0x0E (14) |
Host-injected key event. Refused (!) unless debug mode is on. |
| IDLE_STATE | 0x0F (15) |
Start / stop the idle animation. |
| SET_UNICODE_MODE | 0x14 (20) |
Select the OS unicode-input mode. Since v17 a flag byte can apply it for this session only, leaving the stored mode alone. |
| DISPLAY_OFF | 0x18 (24) |
Blank the keycaps. |
| IDLE_STYLE | 0x1C (28) |
v4+. Get/set the idle style (pulse, jitter, IDDQD, Eden). |
| SET_OS | 0x1D (29) |
v7+. Get/set the host OS, which picks the modifier legends and the shortcut hints. The host sends it in auto mode; an OS fixed on the keyboard’s settings layer takes priority. |
| GLYPH_SCRIPT | 0x1E (30) |
v9+. Get/set the glyph-script override (open-ended index since v10). |
| REPLAY_ANIM | 0x1F (31) |
v11+. Play the startup animation once, whatever the idle style. Used by polyctl replay-anim. |
| PROFILER | 0x20 (32) |
Read or reset the main-loop profiler. Only profiler builds answer it; every other build NACKs. |
| GLYPH_SIZE | 0x22 (34) |
v13+. Get/set the legend size — how large a key’s main legend is drawn. A closed range (0 small / 1 medium / 2 large); anything else NACKs. |
| GET_LAYER_NAMES | 0x23 (35) |
v14+. Read-only. The keyboard reports how many layers it lets you remap and what each is called — the labels the layout editor puts on its layer tabs. The reply starts with its own total length, then the count, then one NUL-terminated name per layer. |
| MACRO_INFO | 0x24 (36) |
v15+. How many macros the keyboard holds, how long a label may be, and how much of the shared body buffer is used. Read-only. |
| MACRO_BODY | 0x25 (37) |
v15+. Read or write a window of the shared macro body buffer — offset, count, bytes. The bodies are NUL-separated, so the host reads the whole buffer, edits one and writes it back. |
| MACRO_LABEL | 0x26 (38) |
v15+. Get/set one macro’s label — the short text the keycap shows along its bottom edge. |
| CRASH_RECORD | 0x27 (39) |
v16+. Read the keyboard’s last crash record — 0 this half, 1 the link-side half (read over the split link), 2 clears it. The reply is a flags byte (present / fresh) and the 48-byte record: crash kind, core, the faulting pc/lr/sp, what the firmware was doing, uptime and firmware version. |
| IDLE_TIMEOUT | 0x28 (40) |
v18+. Get/set how long the keyboard waits before it starts fading into the idle style — a preset index (0 = 15 sec … 5 = 5 min), or 0xFF to query. A closed range like GLYPH_SIZE: anything else NACKs. The reply carries both the preset and its duration in seconds, so a host older than the keyboard can still label a preset it does not know. |
| REBOOT | 0x2B (43) |
v22+. Restart both halves and save nothing. The keyboard answers before it resets. It exists for the host’s boot-loop test, which reboots hundreds of times. |
| BOOTLOADER / HANDEDNESS | 0x17 / 0x19 |
Enter bootloader; set which half is which. |
Layer names — the reply format
Section titled “Layer names — the reply format”GET_LAYER_NAMES answers with a payload that describes its own length, so a reader
knows when it has the whole thing without waiting for a timeout:
| Offset | Bytes | Meaning |
|---|---|---|
0 |
1 | total — the whole payload length, this byte included |
1 |
1 | count — how many layers are named |
2… |
rest | count NUL-terminated ASCII names, at most 8 characters each |
With today’s eight remappable layers that is 54 bytes, which fits a single 64-byte
report (three of which are the P, command and status header). A longer set simply
spans more reports; the reader concatenates each report’s payload and stops once it
holds total bytes.
Two details follow from carrying the total rather than padding each name to a fixed width. The zero fill at the end of the final report is never examined, because the total already says where the names stop — so it can’t be mistaken for a separator. And a layer with no name is expressible: it is simply an empty entry, which stays distinguishable from that fill.
Resource-flash transport
Section titled “Resource-flash transport”Two command sets share one BEGIN / CHUNK / COMMIT flow and one staging code path in the firmware:
| Command | ID | Notes |
|---|---|---|
| FW_UP_BEGIN | 0x40 |
Firmware image: size and CRC; both halves start erasing staging. |
| FW_UP_CHUNK | 0x41 |
56 bytes at an offset; relayed to the slave. |
| FW_UP_COMMIT | 0x42 |
Check CRC and signature on both halves. |
| FW_UP_GET_VERSION | 0x43 |
Running version, size and CRC. |
| FW_UP_APPLY | 0x44 |
Install the staged image on both halves and reboot. |
| FW_UP_SIGNATURE | 0x45 |
Ed25519 signature, two 32-byte parts, before COMMIT. |
| FONTPACK_BEGIN | 0x50 |
Font-pack bundle, DOOM WAD or DOOM engine pack: size, CRC and target slot. |
| FONTPACK_CHUNK | 0x51 |
56 bytes at an offset; relayed to the slave. The same data goes as 236-byte frames on the fast link when the host has it. |
| FONTPACK_COMMIT | 0x52 |
Check CRC on both halves and reload the fonts. No reboot. |
| FONTPACK_STATUS | 0x53 |
Whether a pack is present, its ABI, content version and font count. |
The whole update flow is described in Firmware Update over HID.
A firmware update may additionally carry an Ed25519 image signature (command 0x45, sent
before COMMIT) that the keyboard verifies against a key built into its firmware. It is additive
— older hosts do not send it, so the PROTOCOL_VERSION did not change — and it is now
enforced. See Firmware Signing and
Firmware Update over HID.
Enforcement gives the firmware COMMIT four status bytes rather than two:
| Byte | Meaning |
|---|---|
. |
Accepted — the staged image verified. |
? |
Awaiting a physical ACCEPT/REJECT on the keycaps (unsigned image). Re-poll COMMIT until it changes; the staged image and its CRC are untouched between polls. |
S |
Refused — not validly signed (rejected at the prompt, timed out, or the signature failed to verify). |
! |
Staged-CRC mismatch. |
? and S are why the refusal reason is no longer guessed from a NACK alone. A COMMIT carrying
'x' in [2] cancels a pending prompt — safe to expose over HID because it can only ever
deny, whereas accepting stays a keypress on the board.
Message formats
Section titled “Message formats”Offsets are into the 64-byte report (see Report format). [0] is always
0x50 and [1] the command ID, so the tables start at [2]. A reply’s [2] is the
status byte (. / !) unless the table says otherwise. u16 and u32 are
little-endian. “—” means no payload.
Identity and language
Section titled “Identity and language”| ID | Request [2..] |
Reply [3..] |
|---|---|---|
0x06 GET_ID |
— | Status [2] is * instead of . on the first GET_ID after a boot. ASCII <board> <fw version> P<protocol> HW<hw version> , NUL; then V, n, n × u16 font-pack bundle versions; then G, u16 state generation. A block that does not fit is left out whole. |
0x07 GET_LANG |
— | [3..6] language code, 4 ASCII (deDE) |
0x09 CHANGE_LANG |
[2..5] language code, 4 ASCII |
— (! if unknown) |
0x16 GET_DEFAULT_LAYER |
— | [3] default layer |
0x1B GET_LANG_LIST_PACKED |
— | Several replies, each [3..63] up to 61 bytes. Joined: count, then count × (ISO 639-1 index, ISO 3166-1 index), indices into the frozen table iso_lang_country.py. |
Settings
Section titled “Settings”| ID | Request [2..] |
Reply [3..] |
|---|---|---|
0x0B OVERLAY_FLAGS on |
[2] flags to set: bit 0 show overlays, 1 map all, 2 mirror to both halves, 5 reset buffers, 6 reset usage, 7 reset mapping |
— |
0x0C OVERLAY_FLAGS off |
[2] flags to clear, same bits |
— |
0x0D SET_BRIGHTNESS |
[2] level 0..50; [3] flags: bit 0 volatile, bit 1 auto on, bit 2 auto off |
— (! if level > 50) |
0x0E KEYPRESS |
[2..3] keycode, big-endian; [4] 0 press, else release |
— (! unless debug mode is on, see below) |
0x0F IDLE_STATE |
[2] 0 wake, else start idle |
— |
0x14 SET_UNICODE_MODE |
[2] 0 Linux, 1 macOS, 2 Windows, 3 WinCompose, 4 BSD; [3] non-zero = this session only (v17) |
— |
0x17 BOOTLOADER |
— | — then the keyboard enters the bootloader |
0x18 DISPLAY_OFF |
— | — |
0x19 SET_HANDEDNESS |
[2] 0 = the USB half is left, else right; [3] (v24) Rev.2 mark of the USB half: 0 keep, 1 remove, 2 set |
— then both halves reboot (! if [3] > 2) |
0x1A SAVE_MRU |
— | — (writes unsaved settings to EEPROM) |
0x1C IDLE_STYLE |
[2] 0xFF query, or 0 pulse, 1 jitter, 2 DOOM demo, 3 Eden |
[3] style |
0x1D SET_OS |
[2] 0xFF query, 0xFE auto, or OS: 0 unknown, 1 Windows, 2 macOS, 3 Linux, 4 Android, 6 GNOME, 7 KDE; [3] bit 0 = pin (manual) |
[3] active OS, [4] 1 if auto |
0x1E GLYPH_SCRIPT |
[2] 0xFF query, or script 0..0xFE (any value accepted) |
[3] script |
0x1F REPLAY_ANIM |
— | — |
0x20 PROFILER |
[2] 0 reset, 1 read, 2 log; [3] page (read) |
[3] page, [4..] snapshot. Profiler builds only; others answer !. See Main-Loop Profiler. |
0x22 GLYPH_SIZE |
[2] 0xFF query, or 0 small, 1 medium, 2 large |
[3] size (! if out of range) |
0x23 GET_LAYER_NAMES |
— | See Layer names |
0x28 IDLE_TIMEOUT |
[2] 0xFF query, or preset 0..5 (15 s, 30 s, 45 s, 1 min, 2 min, 5 min) |
[3] preset, [4..5] u16 seconds (! if out of range) |
KEYPRESS makes the keyboard type into whatever app has focus, so any program that can
talk raw HID could use it to inject keystrokes. The firmware therefore refuses it unless
debug mode is on. Debug mode is off at every boot and is switched on with the DB_TOGG
key on the settings layer, which needs a physical key press on the keyboard.
Macros and crash record
Section titled “Macros and crash record”| ID | Request [2..] |
Reply [3..] |
|---|---|---|
0x24 MACRO_INFO |
— | [3] macro count, [4] max label length, [5..6] u16 buffer capacity, [7..8] u16 bytes used, [9] keycap styles drawn |
0x25 MACRO_BODY |
[2] 0 read, 1 write; [3..4] u16 offset; [5] count (≤ 58); [6..] bytes (write) |
Read: [3] count, [6..] bytes. Write: status only. |
0x26 MACRO_LABEL |
[2] macro id; [3] 0xFF query, else caption length; [4] style; [5..8] u32 icon code point; [9..] caption, ASCII |
Same layout from [3]: length, style, icon, caption |
0x27 CRASH_RECORD |
[2] 0 this half, 1 other half, 2 clear |
[3] flags: bit 0 present, bit 1 fresh; [4..51] record (below) |
The 48-byte crash record, from [4]:
| Offset | Type | Field |
|---|---|---|
+0 |
u32 |
magic |
+4 |
u8 × 4 |
kind (1 HardFault, 2 unhandled interrupt, 3 watchdog, 4 halt), core, consecutive count, reset reason |
+8 |
u32 × 6 |
pc, lr, sp, xpsr, icsr, uptime in ms |
+32 |
u16 × 2 |
phase, phase argument |
+36 |
8 chars | firmware version, NUL-padded |
+44 |
u32 |
CRC-32 of the 44 bytes before it |
Overlay uploads
Section titled “Overlay uploads”No reply, except 0x2A. A keycap image is 72 × 40 pixels, 360 bytes: row-major,
9 bytes per row, most significant bit = leftmost pixel, 1 = lit. keycode is the HID
usage of the key (KC_A … KC_RGUI); mod is the modifier variant 0..15 from
Overlay addressing.
| ID | Report layout |
|---|---|
0x0A SEND_OVERLAY |
[2] keycode; [3] bits 0–3 mod, bits 4–7 segment 0..5; [4..63] bytes 60 × segment to 60 × segment + 59 of the image |
0x10 START_COMPRESSED |
[2] keycode; [3] mod; [4..63] first 60 bytes of the RLE stream |
0x11 SEND_COMPRESSED |
[2..63] next 62 bytes of the RLE stream |
0x12 START_ROI |
[2] keycode; [3..6] ROI header (below); [7..63] first 57 bytes of ROI data |
0x13 SEND_ROI |
[2..63] next 62 bytes of ROI data |
0x15 SEND_OVERLAY_MAPPING |
[2..63] 10-bit values, packed least significant bit first |
0x21 SEND_OVERLAY_MAPPING_W |
[2] bits 0–4 width 8..16, bit 5 show after, bit 6 prepare before, bit 7 dim (mapping flags); [3..63] values of that width, packed least significant bit first; since v25 a to of 600–1199 is slot to − 600 drawn without the legend |
0x29 SEND_PRC_OVERLAY |
[2..63] PRC records |
0x2A FILL_POOL_FROM_ICON |
[2] width 8..16, with no flag bits (a set bit 5 or 6 makes the width out of range and the report is refused); [3..63] (pool slot, icon id) pairs, packed least significant bit first. Reply: . if every pair applied, else ! and [3] = index of the first pair the host must upload as a bitmap. |
The mapping values come in pairs: an overlay index, then the pool slot it shows. A report that has fewer pairs than it holds is padded by repeating the last pair.
ROI header, [3..6] of 0x12. top/left are the first row and column, bottom/right
the row and column one past the end:
| Byte | Bits 7–4 | Bits 3–2 | Bits 1–0 |
|---|---|---|---|
[3] |
top bits 5–2 |
mod bits 3–2 |
mod bits 1–0 |
[4] |
bottom bits 5–2 |
bottom bits 1–0 |
top bits 1–0 |
[5] |
left (bits 6–0) |
||
[6] |
bit 7: data is RLE-coded; bits 6–0: right |
ROI data is the rectangle’s pixels, row-major, packed with no padding between rows, most significant bit first.
Firmware update and font packs
Section titled “Firmware update and font packs”| ID | Request [2..] |
Reply |
|---|---|---|
0x40 FW_UP_BEGIN |
[2..5] u32 image size; [6..9] u32 CRC-32 (zlib) |
[2] . ready, ~ still erasing (send again), ! invalid size |
0x41 FW_UP_CHUNK |
[2..5] u32 offset; [6..61] 56 bytes |
., or ! and [3..6] u32 offset to resume from |
0x45 FW_UP_SIGNATURE |
[2] part 0 or 1; [3..34] 32 signature bytes |
status |
0x42 FW_UP_COMMIT |
[2] x cancels a pending prompt, else 0 |
[2] . accepted, ? waiting for the keypress (send again), S not validly signed, ! CRC mismatch |
0x43 FW_UP_GET_VERSION |
— | [3..18] version, ASCII, NUL-padded; [19..22] u32 size; [23..26] u32 CRC |
0x44 FW_UP_APPLY |
— | . then both halves install and reboot; ! nothing valid staged |
0x50 FONTPACK_BEGIN |
[2..5] u32 size; [6..9] u32 CRC-32; [10] target: bundle slot 0..8, 0x7F DOOM WAD, 0x7E DOOM engine pack |
[2] . / ~ / !, as 0x40 |
0x51 FONTPACK_CHUNK |
As 0x41 |
As 0x41 |
0x52 FONTPACK_COMMIT |
— | [2] . both halves done, R rejected, L the other half did not confirm (send again); [3..4] u16 content version (font bundles) |
0x53 FONTPACK_STATUS |
— | [3] 1 if a pack is present, [4] ABI version, [5..6] u16 content version, [7] font count |
QMK keymap commands
Section titled “QMK keymap commands”These follow QMK’s dynamic-keymap protocol: there is no P, [0] is the command, and
multi-byte values are big-endian. The reply is the request echoed back, with read
data filled in. Commands not listed get no reply.
[0] |
Command | Request | Reply |
|---|---|---|---|
0x05 |
set keycode | [1] layer, [2] row, [3] column, [4..5] keycode |
echo |
0x06 |
reset keymap | — | echo |
0x11 |
get layer count | — | [1] count of remappable layers |
0x12 |
get buffer | [1..2] byte offset, [3] size (≤ 60) |
[4..] keymap bytes |
0x13 |
set buffer | [1..2] byte offset, [3] size (≤ 60), [4..] bytes |
echo |
The keymap buffer is layer × rows × columns keycodes, 2 bytes each, big-endian, as in
upstream QMK.
RLE overlay encoding
Section titled “RLE overlay encoding”The compressed uploads (0x10/0x11, and 0x12/0x13 with the RLE bit) use a bit-run
encoding: each byte describes one run of identical pixels.
- Input: the image read as a bit stream, most significant bit of each byte first. For a whole keycap that is all 2880 pixels; for an ROI, the rectangle’s pixels.
- One byte per run. Bit 7 is the pixel value of the run, bits 0–6 its length,
0..127. A run longer than 127 is split: 300 lit pixels areFF FF AE. - The stream starts with a run of unlit pixels. An image whose first pixel is lit
begins with
00, an empty run. - Empty runs decode to nothing. That is why the zero fill after the last data byte of
the final report needs no length field: every
00is a run of no pixels.
Example, 16 pixels 0000 0111 1111 1000: 5 unlit, 8 lit, 3 unlit, coded as 05 88 03.
The firmware decoder is rle_decompress() in the
polymod_rle module. It takes the bit position to
continue from, so each report is decoded on arrival without buffering the whole stream,
and it stops at the end of the 360-byte image whatever the stream says. For a whole
keycap it runs on core1 where available. For an ROI, the firmware
decodes one run byte at a time (at most 127 pixels) and places the pixels into the
rectangle row by row.
The host encoder is polyhost/util/rle_util.py. RLE saves space on large uniform areas and
uses more space on detailed or dithered images, where runs are 1–2 pixels and each costs a byte
that plain data would spread over 8 pixels. The host therefore sizes each image in every
encoding (plain, RLE, ROI, ROI with RLE, and PRC from
v19) and sends the one that needs the fewest reports.
PRC: Predictive Range Coding
Section titled “PRC: Predictive Range Coding”Since v19 the host has a fifth encoding for keycap images: PRC, short for Predictive Range Coding. Host and firmware must both be on v19 or later; with an older keyboard the host keeps using the four older encodings.
PRC is a context-model binary arithmetic coder, the scheme of JBIG, the fax-image standard ITU-T T.82:
- Context model. Before a pixel is decoded, 10 already-decoded pixels in a fixed causal template (3 pixels two rows up, 5 one row up, 2 to the left in the same row) form a 10-bit context, 0–1023. A fixed 1 KB table maps each context to P(pixel is clear). Keycap icons are mostly clear area and clean stroke edges, so for most pixels the table’s prediction is near 0% or 100%.
- Range coding. A binary range coder turns those probabilities into bits. A pixel’s code length is its information content, −log₂ p, where p is the probability the table gave its actual value: a correctly predicted pixel costs a fraction of a bit, a mispredicted one several bits. The table was trained once on the shipped overlay templates and is compiled into the firmware.
Where the template comes from. Three decisions shape it, and only one of them was trained:
- Which 10 pixels: taken from JBIG unchanged, not measured. PRC uses the pixel pattern of JBIG’s three-line template, defined in ITU-T T.82. JBIG lets the encoder move one of the five pixels in the row above, the adaptive pixel; PRC keeps it at its default place, two columns right. No other set of positions was compared on the keycap icons.
- The numbering 1–10: our own convention, not JBIG’s. It sets which neighbour is the context’s most significant bit: the five nearest first, then the five farther ones. Any order gives the same compression, because reordering the bits only relabels the 1,024 contexts. Each context still collects the same pixels during training, so every probability and every code length stays the same. The order is fixed only because host and firmware must agree on it.
- The table: trained. For each of the 1,024 contexts, the training counted how often the pixel is clear across the shipped overlay templates. This is the only part that comes from the data.
Only the bounding box (ROI) of the set pixels is coded. A typical icon’s coded payload is about
28 bytes, against about 87 for the best of the older encodings. Each record adds a 6-byte header,
and a report carries 62 bytes after its P and command byte. So most icons need one report, and
two records share one when together they fit in 62 bytes. Across the 118 shipped templates, the image reports of an uncached app
switch fall to 40% (4348 → 1753); a JetBrains IDE goes from 205 to 81.
The host decides per image: an image goes out as PRC only if its record fits one report, which is never more reports than an older encoding would take. Larger images use the older encodings.
Worked example: a 10 × 10 square
Section titled “Worked example: a 10 × 10 square”Take a solid 10 × 10 square: a 100-pixel ROI with every pixel set. Uncoded, it takes 100 bits (13 bytes). The coder visits the pixels in raster order. Context pixels outside the ROI count as clear.
| Pixel (row, column) | Context | P(set) from the table | Code length |
|---|---|---|---|
| (0, 0), first pixel | all 10 outside the ROI, so all clear | 1.6% | 6.0 bits |
| (0, 1) | left neighbour set | 86% | 0.21 bits |
| (1, 0) | row above set, left neighbours clear | 80% | 0.31 bits |
| (1, 5) | row above and left neighbours set | 99.6% | 0.006 bits |
| (5, 5) | all 10 set | 95% | 0.075 bits |
| (9, 9), last pixel | set, but the upper-right context pixels lie outside the ROI | 80% | 0.32 bits |
The figure shows the code length of all 100 pixels. The colour scale runs from dark blue (predicted, near zero bits) to red (mispredicted):
Only the first pixel is mispredicted. Every later pixel matches the prediction, so the 100 pixels together carry 19.2 bits of information. The coder’s initial and flush bytes bring the payload to 6 bytes, and the record to 12.
The table was trained on real icons, which are mostly 1–2 px strokes. So a pixel whose whole context is set (95%) is predicted less confidently than one continuing a stroke edge (99.6%): large filled areas are rare in the training set.
Worked example: a real icon
Section titled “Worked example: a real icon”VS Code’s Ctrl+P (Quick Open) shows the Fluent Document Search icon. Its set pixels fit a
27 × 31 px ROI (orange) in the right half of the 72 × 40 px keycap, rows 7–37 and columns 37–63:

| Encoding | Bytes | Reports |
|---|---|---|
Plain (0x0A) |
360 | 5 (all-clear segments are skipped) |
RLE, whole keycap (0x10/0x11) |
154 | 3 |
ROI, uncompressed (0x12/0x13) |
105 | 2 |
ROI, RLE (0x12/0x13) |
139 | 3 |
PRC (0x29) |
26 + 6-byte header | 1, with 30 bytes left |
The best older encoding needs 2 reports. The PRC record is 32 bytes, which leaves 30 of the report’s 62 bytes. The next record joins it if its whole record, header included, fits in those 30 bytes; otherwise it starts a new report.
The next figure shows where the 26 bytes go. It colours each of the ROI’s 837 pixels by its code length, on the same scale as the square; set pixels have a white outline:
- A is set where the table gave P(set) = 1.6%: the page’s bottom edge resumes after the gap for the magnifier’s handle, and no context pixel indicates it. 6 bits, the same as the square’s first pixel.
- B is clear where the table gave P(set) = 94.9%: the context matches a continuing stroke, but the handle ends there. 4.3 bits.
- C lies inside the page’s right edge. The context is unambiguous, P(set) = 99.2%, and the pixel costs 0.01 bits.
Most of the ROI behaves like C. 610 of the 837 pixels (73%) cost under 0.1 bit each, 18 bits in total. 43 pixels (5%), the stroke starts, ends and corners, carry 61% of the 185.6 bits. That is why PRC saves the most on line icons: long strokes, few stroke ends.
One row, pixel by pixel
Section titled “One row, pixel by pixel”The coder handles the ROI one row at a time, left to right. Row 2 of the ROI (keycap row 9) is the page’s top edge below the folded corner. Rows 0 and 1 above it are already decoded:
Column 4 shows how a context is formed. Its 10 neighbours, in template order, read
1 1 1 0 1 0 1 1 0 1. Neighbour 1 is the most significant bit, so the context is
1110101101 in binary, 941. Table v1 holds 2 at index 941: P(clear) = 2/256, so
P(set) = 99.2%. The pixel is set and costs −log₂ 0.992 = 0.011 bits.
The whole row, with runs of identical context and value merged into one line:
| Column | Pixel | Context bits 1–10 | Context | Table byte | P(set) | Code length | Running total |
|---|---|---|---|---|---|---|---|
| 0 | clear | 0000000000 |
0 | 252 | 1.6% | 0.023 | 0.02 |
| 1 | clear | 0000000001 |
1 | 243 | 5.1% | 0.075 | 0.10 |
| 2 | clear | 0010000001 |
129 | 97 | 62.1% | 1.400 | 1.50 |
| 3 | set | 0110000101 |
389 | 31 | 87.9% | 0.186 | 1.68 |
| 4 | set | 1110101101 |
941 | 2 | 99.2% | 0.011 | 1.70 |
| 5 | set | 1111111111 |
1023 | 13 | 94.9% | 0.075 | 1.77 |
| 6 | clear | 1111111111 |
1023 | 13 | 94.9% | 4.300 | 6.07 |
| 7 | clear | 1111011111 |
991 | 229 | 10.5% | 0.161 | 6.23 |
| 8–14 | clear | 1110011111 |
927 | 234 | 8.6% | 0.130 each | 7.14 |
| 15 | set | 1110011111 |
927 | 234 | 8.6% | 3.541 | 10.68 |
| 16 | set | 1110111010 |
954 | 1 | 99.6% | 0.006 | 10.68 |
| 17 | set | 1101110010 |
882 | 17 | 93.4% | 0.099 | 10.78 |
| 18 | set | 1001100010 |
610 | 73 | 71.5% | 0.484 | 11.27 |
| 19 | clear | 0001100010 |
98 | 185 | 27.7% | 0.469 | 11.74 |
| 20 | clear | 0001000000 |
64 | 254 | 0.8% | 0.011 | 11.75 |
| 21–26 | clear | 0000000000 |
0 | 252 | 1.6% | 0.023 each | 11.88 |
Read from left to right:
- Columns 0–1 are clear. At most one context pixel is set (neighbour 10 of column 1), so P(set) is 1.6–5.1% and each pixel costs under 0.1 bit.
- Column 2 is clear, but neighbours 3 and 10 are set: the page’s top edge starts above and to the right. The table gives P(set) = 62.1%, and the pixel costs 1.4 bits.
- Columns 3–5 are the page’s left edge, predicted at 87.9–99.2%: 0.01–0.19 bits each.
- Column 6 has all 10 neighbours set and P(set) = 94.9%, but it is clear: the page’s interior starts here. 4.3 bits, more than a third of the row.
- Columns 7–14 are the page’s interior below the set top edge. The table gives P(set) = 8.6–10.5% here, against 1.6% in open space, so these clear pixels cost 0.13–0.16 bits each.
- Column 15 has the same context as columns 8–14, but it is set: the fold’s diagonal starts. 3.5 bits.
- Columns 16–18 continue the diagonal, predicted at 71.5–99.6%.
- Columns 19–26 lie right of the fold and are clear: 0.01–0.47 bits each.
The 27 pixels carry 11.9 bits of information; uncoded they take 27. The three mispredicted pixels, columns 2, 6 and 15, carry 9.2 of those 11.9 bits. The range coder emits no bits per pixel: it narrows one interval by each pixel’s probability, so the payload follows the sum over the whole ROI. For this icon that is 185.6 bits, which the coder writes as 26 bytes, including its start and flush overhead.
Report layout. After P and 0x29, the report carries records one after another. A keycode byte
of 0 (the zero padding) ends the list.
| Field | Bits | Content |
|---|---|---|
| keycode | 8 | Addresses the image like the other upload commands |
| modifier | 4 | Modifier variant 0–15 |
| top, left | 6, 7 | Top-left corner of the ROI |
| height − 1, width − 1 | 6, 7 | ROI size |
| length | 6 | Payload bytes that follow |
| reserved | 4 | Must be 0 |
| payload | length × 8 | The range-coded ROI |
Icon library fills
Section titled “Icon library fills”Since v20 the icons that the app templates share are stored in the keyboard’s flash, on both
halves, as one more font-pack bundle: icons.plyi, bundle 8, a 256 KB slot. When the host
switches app, it no longer uploads such an icon. It sends a pair instead: which pool slot to
fill, and which icon to draw there. Each half draws the icon from its own flash.
The host decides by exact pixels. It fills a slot only when the image it would upload is, byte for byte, a glyph in the bundle it shipped, and only when the keyboard reports that same bundle version. So the keycap shows exactly what an upload would have shown. Anything else (an app’s own drawings, text labels, the ESC program mark) is uploaded as before, with PRC.
What it saves. Counted through the host’s own send path, for a cold switch into each app (nothing in the keyboard’s image pool yet), on top of PRC:
| App | Reports with PRC | With the icon library |
|---|---|---|
| JetBrains IDEs | 84 | 32 |
| Calculator | 64 | 9 |
| paint.net | 59 | 11 |
| Photoshop | 46 | 15 |
| All 60 template sets | 1,868 | 744 |
The report uses the same packing as SEND_OVERLAY_MAPPING_W: byte 2 is the value width
(8–16 bits), then 61 bytes of (pool slot, icon id) pairs, padded by repeating the last pair.
At 10 bits that is 24 pairs per report.
The reply is P 0x2A . when every pair was applied on both halves. Otherwise it is
P 0x2A !, and byte 3 names the first pair the keyboard did not apply. The host uploads
that image and every one after it as a normal upload. A half without the icon bundle refuses the whole
report, which reads as pair 0, so a keyboard that missed the bundle still shows every icon.
The bundle has the font-pack layout with its own magic, PlyI, so the font loader can never
load an icon as a character. Each icon is stored cropped to its set pixels, with its position on
the 72 × 40 keycap. The icon ids are frozen: a new icon takes the next id, and an existing id
never changes, because only the id is sent. The host flashes the bundle like
any other, over BEGIN / CHUNK / COMMIT.
Mapping flags
Section titled “Mapping flags”Since v21 (firmware 1.3.0, host 1.7.0) an app switch no longer needs its own reports to
prepare and switch on the overlays. Two bits of SEND_OVERLAY_MAPPING_W’s width byte do it:
| Bit | Value | Effect |
|---|---|---|
| 6 | 0x40 |
Prepare, before the report’s pairs are applied: clear the old mapping and usage bits, the same as OVERLAY_FLAGS on with the reset bits. |
| 5 | 0x20 |
Enable, after the pairs are applied: show the overlays, the same as OVERLAY_FLAGS on with the display bit. |
| 7 | 0x80 |
Dim (v23+): draw every icon this report maps dimmed. See dimmed icons. |
The keyboard masks the width with 0x1F before it decodes the pairs, so the value width is
unchanged (8–16 bits). A report whose masked width is out of range is refused before either flag
is applied, so a malformed report changes nothing.
The host puts the prepare flag on the first mapping report of a switch and the enable flag on the last. When the switch has to upload images first, the host still sends the prepare as its own report, just before the first image: an image written into a slot the old mapping still shows would otherwise appear on the old key for a moment.
What it saves. Counted through the host’s own send path over all 60 template sets:
| Switch | Before v21 | v21 |
|---|---|---|
| Warm (every image already in the keyboard’s pool) | 249 reports | 129 |
| Cold (nothing in the pool yet) | 744 reports | 684 |
A warm switch, the common case when you move between apps you use all the time, is now its mapping reports only.
Dimmed icons
Section titled “Dimmed icons”Since v23 (firmware 1.8.3, host 1.16.2) bit 7 of the width byte (0x80) marks every pair in
the report as dim. The keyboard stores one dim bit per display position, beside the usage
bits, and the prepare step clears it. A dimmed icon is drawn with only its even rows and even
columns lit, a quarter of its pixels. The area kept clear for the legend is still computed from the full icon, so
the legend sits where it always does, and the legend itself is never dimmed.
The keyboard dims at draw time, so the image in the pool is the same whether it is shown dimmed or not. A browser icon dimmed under one website and drawn full in the plain browser is one pool entry, and switching between the two uploads nothing.
The host puts dimmed and plain pairs in separate reports. It dims the keys that still show a
browser’s shortcut under a website’s overlay,
except the program icon on Esc. Below v23 the host never sets the bit: a v21 or v22 keyboard
masks the width with 0x1F and would draw a flagged report at full strength anyway.
Keys without a legend
Section titled “Keys without a legend”Since v25 (firmware 1.9.13, host 1.17.52) a mapping pair can mark a key whose icon replaces
the whole keycap. The pair’s to value is the pool slot plus 600 (the pool size), so a to of
600–1199 means slot to − 600, drawn without the key legend. Before it draws such an icon, the
keyboard clears the whole 72×40 cell instead of the 3 px around the icon, so no part of the legend
shows. Every pair sets or clears the mark, so a key that moves to a plain pair gets its legend back.
The marked pairs travel in the same reports as the plain ones. A slot below 424 still fits a 10-bit value; a higher slot needs 11 bits, which the host already chooses per pair. The host marks the keys an overlay file lists (see keys without a legend in the overlay how-to), such as the full-cell mark on Esc of the Age of Empires II sets.
A keyboard before v25 refuses a to of 600 or more as outside the pool and leaves the key blank,
so the host adds the 600 only from v25. With an older keyboard the host sends the plain slot: the
legend is still drawn, and the icon is drawn over it with the usual 3 px clear margin around the icon.
Overlay addressing
Section titled “Overlay addressing”A keycap image is addressed by 90 keycap slots × 16 modifier variants:
overlay_index = keycode_slot + 90 * modifier_variantSince v12 the modifier variant is simply the modifier bitmask — bit 0 Ctrl, bit 1 Shift, bit 2 Alt, bit 3 GUI — so all 16 combinations are addressable and the numbering is self-describing (variant 0 = bare, 5 = Ctrl+Alt, 10 = GUI+Shift, 15 = all four). Before v12 there were 9 variants and GUI could not be combined with anything.
That index is an address, not storage. It selects an entry in a lookup table that points at one of 600 slots in the image pool (360 bytes each, ≈ 211 KiB). Variants that share the same artwork share a slot, so adding variants costs address space rather than image memory — which is why v12 grew the addressable set from 810 to 1440 without needing more room for pixels.
Protocol versions
Section titled “Protocol versions”The firmware advertises a PROTOCOL_VERSION (in the GET_ID string) that the host reads to decide
which commands to use. The versions do not have to match. The host connects to any keyboard
from v2 onwards and then enables each feature separately:
- Same version — everything works.
- Keyboard older than the host — it still connects, and only the features that keyboard is too old for are greyed out, with a “some features need a firmware update” hint.
- Keyboard newer than the host — the host asks what you want to do, defaulting to a safe connect that uses only the stable command set until you update the host app.
- Older than v2 — refused, with “Firmware too old… please update the keyboard firmware”. The host cannot even list the keyboard’s languages below that.
Selected milestones:
- v2 — the packed language list (
0x1b): a count byte plus one 2-byte ISO index pair per language, replacing the 4-ASCII-char legacy list. The legacy list (0x08) is retired and NACKs. - v3 — the overlay-mapping command (
0x15) changed to send no reply (no per-chunk ACK), matching the other bulk overlay commands and removing a frequent source of stale replies. - v4 — IDLE_STYLE (
0x1c): choose the idle anti-burn-in style (pulse / jitter). - v5 — a brightness flags byte on
SET_BRIGHTNESS(0x0d): volatile vs host-auto. - v6 — a per-bundle font-pack version block appended to the GET_ID reply, so the host flashes only the bundles a keyboard is missing or behind on.
- v7 — SET_OS (
0x1d): the host tells the keyboard which OS it runs on, so modifier legends and shortcut hints match it. - v9 — GLYPH_SCRIPT (
0x1e): override the letter/digit legends with an alternative script. - v10 — the glyph script became an open-ended index (an unknown script renders the normal legend), so new scripts need no protocol version change.
- v11 — the plain overlay upload (
0x0a) packs modifier + segment into one header byte, so a full 60-byte segment fits a 64-byte report exactly. The same version adds REPLAY_ANIM (0x1f), which plays the startup animation once. - v12 — GUI combines with the other modifiers for app overlays:
Cmd+Shift,Cmd+Altand the rest each get their own artwork instead of all falling back to the plain GUI icon, taking the addressable modifier variants from 9 to 16. The upload commands are unchanged, but the wider index space no longer fits the fixed 10-bit fields of0x15, so SEND_OVERLAY_MAPPING_W (0x21) was added alongside it. - v13 — GLYPH_SIZE (
0x22): the legend size — draw a key’s main legend at one of three sizes, leaving the Shift/AltGr previews alone. Unlike the glyph script, the range is closed: the keyboard refuses a size it doesn’t know, because an accepted-but-unrenderable size would look like a setting that does nothing. - v14 — GET_LAYER_NAMES (
0x23): the keyboard names its own remappable layers, so the layout editor labels its tabs from the connected keyboard rather than from a list built into the host. It reports the same layer count as the existing count command, so the editor can never draw a tab it has no name for. - v15 — macros (
0x24–0x26): store text or a short key sequence on the keyboard and type it back with one key, with a short label the keycap shows so a row of macro keys showspush,work mail,sign offrather than M0, M1, M2. All three commands are enabled by a single protocol check, because a host that could read the macro list but not the bodies would show an editor for data it cannot read. See Macros. - v16 — CRASH_RECORD (
0x27): the firmware records a hardware fault, an unexpected exception or a watchdog restart, restarts, and reports it on its next boot — on the console (which the host turns into an alert) and over this command. See when the keyboard firmware crashes. - v17 — a volatile flag on
SET_UNICODE_MODE(0x14): apply a unicode mode for this session only, without storing it. It exists for the Windows logon screen, where the host cannot yet tell WinCompose is not installed from WinCompose has not started yet — so it applies its first reading volatile and only stores the mode once it can tell the two apart. Before this, every logon on a WinCompose machine storedWindowsand then wroteWinComposeback over it. - v18 — IDLE_TIMEOUT (
0x28): how long the keyboard waits before it starts fading into the idle style becomes a setting — six presets from 15 seconds to 5 minutes — where it used to be fixed at 2 minutes when the firmware was built. 2 minutes stays the default, so a keyboard with default settings behaves as before. Like the legend size and unlike the glyph script, the range is closed: a keyboard refuses a preset it doesn’t know, because an accepted one would be stored and then changed to a different duration without any message. - v19 — SEND_PRC_OVERLAY (
0x29): PRC (Predictive Range Coding), a fifth keycap-image encoding that codes a typical icon to a payload of about 28 bytes. The host uses it per image, only where it saves a report; the older encodings stay. - v20 — FILL_POOL_FROM_ICON (
0x2A): the icon library. Icons the app templates share are stored in the keyboard’s flash, and the host sends(pool slot, icon id)pairs instead of their bitmaps. A cold switch into a JetBrains IDE drops from 84 reports to 32. The GET_ID version block now reports the lower of the two halves’ versions per bundle. - v21 — mapping flags on SEND_OVERLAY_MAPPING_W (
0x21): the prepare and enable steps of an app switch are sent in the mapping reports instead of separateOVERLAY_FLAGSreports. A switch between apps whose images the keyboard already holds needs about half the reports. - v22 — REBOOT (
0x2B): restart both halves without saving anything, so the host’s boot-loop test can reboot the keyboard hundreds of times to catch a rare startup crash. Changing the handedness also reboots, but it writes the settings storage on every call. - v23 — a dim flag on SEND_OVERLAY_MAPPING_W (
0x21): the keyboard draws the icons of a flagged report dimmed. The host uses it for a browser’s shortcuts under a website’s overlay. - v24 (firmware 1.9.12, host 1.17.49) — a second byte on SET_HANDEDNESS (
0x19) that sets or removes the Rev.2 mark in the USB half’s handedness stamp. A marked half becomes the main half only when the computer recognises it over USB, because a Rev.2 board passes USB power to both halves.0keeps the stored mark, so an older host that sends one byte cannot remove it. - v25 (firmware 1.9.13, host 1.17.52) — keys without a legend on SEND_OVERLAY_MAPPING_W (
0x21): a pool slot sent plus 600 draws that key’s icon on a cleared keycap. No new command and no new byte; an older keyboard refuses the value, so the host sends it only from v25.