All writing

~/writing/royuan-keyboard-protocol

Embedded systems
6 min read

Reverse engineering the Attack Shark keyboard protocol

Attack Shark, Epomaker and Akko sell hundreds of keyboards built on the same ROYUAN hardware, configurable only through Windows software. Here is the wire protocol: two colliding command sets, per-key colour that lands in flash, and a firmware image read out of a rescued binary.

The plan was to port my keyboard to QMK. An Attack Shark X86: aluminum, a knob, tri-mode wireless. Attack Shark even markets a configurator at qmk.top, so the firmware must be QMK, right? It is not. qmk.top is a vendor web tool squatting on the name, the board runs stock proprietary firmware, and the real config software is Windows-only. I’m on Linux. So the port was out, and the goal became simpler: configure the board I own from the machine I use.

That meant learning what the software actually says to the keyboard.

Getting a foot in

The vendor tool is an Electron app, and nothing in its device path is native. Every byte it sends to the keyboard goes out over WebHID, from JavaScript. Three patches to the bundle force the pure-WebHID path, and it runs on Linux with a debug flag that logs every report. That log, plus the prettified bundle, is the whole map.

The transport is plain: 64-byte HID feature reports, report ID 0, on vendor usage page 0xFFFF, usage 2. A request goes down, the board settles for about 10 ms, a reply comes back with the opcode echoed in byte 0. Checksum is 0xFF - (sum & 0xFF).

Two command sets on one wire

The “Attack Shark Driver” is a reskin of a universal ROYUAN app. The same manufacturer builds boards sold as Epomaker, Akko, Hator, ikbc, NOPPOO and MEETION, and the one driver serves all of them, swapping brand logos off a JSON file over a registry of more than five hundred devices. Most enumerate on USB vendor ID 0x3151.

But there are two command sets in the family, and they reuse opcode numbers for different registers:

commandfamily Afamily B
write keymap0x090x0A
write options bitfield0x060x09
write debounce0x110x06
write sleep timers0x120x11

Send family A’s “write keymap” to a family B board and you have written the options bitfield, which holds the bit that locks the whole keyboard. Nothing rejects it. The board applies the opcode to whatever it means locally and returns a clean reply. So the family has to be resolved per device from a registry, never guessed from the product ID, which the vendors recycle.

Reading the keymap

For family A, the Windows tool is enough to work from: capture a report, replay it, diff the board. A layer is 128 slots of 4 bytes, 512 per layer, and each slot value is tagged by its first byte.

byte 0meaning
0x00HID usage
0x03consumer usage (volume, media)
0x09macro, [mode, index]
0x0Aspecial: [1,0,0] Fn, [12,0,0] power save

The knob is just three more slots: on the X86, slot 90 is turn-right, 91 is turn-left, 84 is press. Bind a macro by writing [0x09, mode, index, 0] into a slot, where mode is repeat, toggle, or hold.

When there is no software to capture

The hall-effect boards use family B, and I wanted the write paths confirmed against something more solid than vendor JavaScript before pointing my own writes at flash. The firmware answers that.

These boards get stuck in bootloader mode when an update fails, because the vendor’s update backend answers “Record not found” for every device ID, so the community keeps an archive of rescued firmware images. One is the X65HE. It reads as encrypted, entropy 7.99, but the vendor’s JS names the format itself with a call to inflateRaw. It is raw-deflate. Inflated, it is a 115 KB ARM Cortex-M image at base 0x08000000.

radare2, thumb mode
$ r2 -m 0x08000000 -a arm -b 16 2268_v309.app.bin
# command dispatch at 0x08010980
# SET_KEYMATRIX handler at 0x08006f10
# GET_KEYMATRIX handler at 0x08010d08

The dispatch table gives the family B keymap layout, the bulk-write flow, and the bit on the final page that commits the write to flash, all from the handlers rather than inferred from captures. It also names the opcodes to avoid: one erases the flash chip, two pairs are bootloader entry points.

Lighting

Lighting is one report, SET_LEDPARAM 0x07:

[op, mode, speed, brightness, (option<<4)|flags, R, G, B, checksum]
07 04 03 04 87 ff 00 00 <ck>   # wave, fixed red

A few things the wire does that the UI hides: speed inverts, UI 0..4 goes out as 5..1. White 0xFFFFFF transmits as 0xFAFAFA, and any colour below 0x080808 renders as unlit, so “off” is an option bit, not black. There are 18 effects; a handful need host-side data.

Per-key colour lands in flash

The one write that needs care is per-key colour. It uploads 384 bytes (128 keys, RGB, matrix order) as seven 56-byte pages, and unlike everything else it goes into the keyboard’s flash. Push the pages too fast and the config endpoint stops answering: every report after it fails with a protocol error until the board is re-enumerated on USB.

Measured on the X86:

cadenceresult
7 reports every 500 msendpoint dead after ~13 reports
7 reports every 3 s42 reports, fine
2 uploads per 5 s, mode switch 600 ms afterstalls on the second
2 uploads per 10 s, 2 s settle, no redundant switchsurvives

The last row is the shipping rate limit, and it belongs in the backend, not the UI, because a limit the frontend enforces is one a bug can skip.

The firmware answers for hardware it lacks

One habit runs through the whole protocol: a reply is not evidence.

The firmware is shared across the family, so a board answers status queries for parts it was never fitted with. Ask my X86 about edge lighting and it returns a full, plausible state:

GET_SLEDPARAM on a board with no edge LEDs
88 01 03 04 08 ff ff ff   # mode 1, speed 3, brightness 4, white

The X86 has no edge LEDs. An unsupported command doesn’t error either, it returns the previous reply, so a probe looks like success unless you compare against the last one. And some reads are fiction: per-key colour reads back stale data that never reflects a write, while macros read back faithfully. Whether a read means anything is a per-command fact, established by writing, reading back, and diffing.

What it turned into

The notes became a protocol document, and the document became a configurator: keymaps, macros, lighting, profiles, backups, for 523 boards. It runs on Linux, Windows and macOS, or in a Chromium browser over WebHID, where the page reaches only the config collection and never the keys you type.

sharkfin's lighting page in the Olivia colorway: a drawn keyboard layout with per-key RGB, effect and speed controls, and the profile switcher.

The full protocol is on GitHub; the configurator is sharkfin.