Reverse engineered from RK_Keyboard_Software_Setup_V4.6 20250617.exe (DeviceDriver.exe)
and live hardware probing on Linux.
| Field | Value |
|---|---|
| USB VID | 0x258A (SINOWEALTH) |
| Chip | SINOWEALTH SH68F90A (8051-based MCU) |
| Keyboard Model | Royal Kludge M87 (87-key TKL, LCD) |
| LCD Resolution | 320×172 pixels (RGB, configurable GIF) |
| RK Software PID | 0x01AF (internal config folder) |
The keyboard has no hardware RTC — time is maintained in software and must be synced from the host on every connect.
The keyboard supports two connection modes, each with a different USB PID and HID protocol:
| Mode | USB PID | Product String | Protocol |
|---|---|---|---|
| 2.4 GHz dongle | 0x0150 |
"SINOWEALTH Gaming KB" | CDev3632 (output reports) |
| USB cable | 0x01A2 |
"SINO WEALTH Gaming Keyboard" | CDevG5KB (feature reports) |
Both modes use the same SysParam payload (cmdId 0x0B) for time/volume sync — only the transport framing differs.
Both connection modes expose two HID interfaces:
| Interface | Purpose | Usage Page |
|---|---|---|
| 0 | Boot keyboard (6KRO) | Generic Desktop |
| 1 | Vendor config channel | Vendor-defined |
Interface 1 (/inputN where N=1 in HID_PHYS) is used for all
configuration, time sync, and profile management.
Interface 1 supports:
- Output Report 0x13: 20 bytes (Report ID + 19 data)
Uses the CDev3632 protocol — output reports with per-packet CRC and read-back response.
Interface 1 supports:
- Feature Report 0x09: 520 bytes (Report ID + 519 data)
- Plus additional reports (0x05, 0x06) for other config
Uses the CDevG5KB protocol — feature reports via
ioctl(HIDIOCSFEATURE) / HidD_SetFeature. The report ID is 0x09
(not 0x0A as on some other RK models — the report ID is read from the
device object at runtime, not hardcoded).
All communication uses Report ID 0x13 output reports, 20 bytes total:
Offset Size Field Description
────── ──── ──────────── ──────────────────────────────────────
0 1 reportId Always 0x13
1 1 cmdId Command identifier (see §4)
2 1 numPackages Total packets in this transfer (1 for single-packet)
3 1 packageIndex 0-based index of current packet
4 1 meta (board << 4) | dataLen
5-18 14 payload Command-specific data (zero-padded)
19 1 crc Checksum: sum(bytes[0..18]) & 0xFF
Bits 7-4: board — board identifier (usually 0)
Bits 3-0: dataLen — number of valid payload bytes in this packet (max 14 = 0x0E)
Simple 8-bit sum of bytes 0 through 18 (inclusive), masked to one byte:
crc = sum(packet[0:19]) & 0xFFFor commands transferring more than 14 bytes (e.g., GetProfile = 128 bytes):
numPackages= ceil(totalBytes / 14)packageIndexincrements 0, 1, 2, ...- Each packet carries up to 14 bytes of payload
- Example: 128-byte profile → 10 packets (9 × 14 + 1 × 2)
Responses use the same 20-byte format. After sending a command, the host reads
a response with the same cmdId. The keyboard echoes the cmdId and includes
response data in the payload. The host validates:
cmdIdmatches the request- CRC is correct
packageIndexmatches expected sequence
Important: the dongle (CDev3632) and wired (CDevG5KB) transports use different
cmdId numbers for the same operation. Both tables below were derived from
DeviceDriver.exe decompilation in May 2026 (Ghidra project rk-m87-smk).
| cmdId | Name | Dir | Description |
|---|---|---|---|
| 0x01 | SetMatrix | W | Per-key matrix / layer data (multi-packet) |
| 0x02 | SetGame | W | Per-key RGB color group, nColorPerKey * 3 bytes |
| 0x03 | SetMacro | W | Macro data, 512-byte chunks (multi-packet) |
| 0x04 | SetLED | W | 128-byte LED config |
| 0x06 | ResetDevice | W | Empty payload (was previously documented as 0x11) |
| 0x07 | GetDongleStatus | R | 1 byte: 1 = keyboard reachable, 0 = asleep/away (see §10) |
| 0x09 | SetLedRgbTab | W | RGB LED table |
| 0x0B | SetScreenParam | W | SysParam: time, volume, CPU, memory (see §5) |
| 0x0C–0x0F | ⚠ dangerous | W | Reset/freeze the LCD clock — DO NOT scan |
| 0x12 | SendWeb | W | Web/cloud data |
| 0x44 | GetLED | R | Read 128-byte LED config |
| 0x4A | GetVersion | R | Read 2-byte firmware version (lo, hi) |
| 0x88 | SetRealData | W | Runtime status (CDev3632 only; gated by m_state[0x238]==0) |
| cmdId | Name | Dir | Description |
|---|---|---|---|
| 0x03 | SetMatrix | W | Note: dongle uses 0x01 for the same op |
| 0x04 | SetLED | W | Same as dongle |
| 0x05 | SetMacro | W | Note: dongle uses 0x03 |
| 0x06 | SetGame | W | Note: dongle uses 0x02 |
| 0x0A | SetLedRgbTab | W | Note: dongle uses 0x09 |
| 0x0B | SetScreenParam | W | Same as dongle |
| 0x0C | AccessData_Page | W | LCD image data upload, frame-indexed, per-page CRC |
| 0x0D | SetGIFParam | W | LCD GIF playback config (5-byte payload — see §8) |
| 0x12 | SendWeb | W | Same as dongle |
| 0x82 | GetPassword | R | Read auth bytes |
| 0x84 | GetLED | R | Note: dongle uses 0x44 |
Previous versions of this document labeled cmdIds 0x01/0x02/0x09 as "Echo".
That was wrong — they are real config commands (SetMatrix/SetGame/SetLedRgbTab).
The daemon's echo_ping() was switched to get_dongle_status_ping() (cmdId
0x07) in May 2026 after a live dump confirmed the previous 3-byte payload
[0x0E, 0xDE, 0xAD] was being persisted into the LED color-table at flash
offset 0xA800 every session.
Verified from DeviceDriver.exe byte-level RE (May 2026):
| cmdId | Signed | Name | Returns |
|---|---|---|---|
| 0x82 | -0x7E | GetPassword | 6 bytes — pairing identifier ("psd") |
| 0x83 | -0x7D | GetMatrix | per-key matrix data |
| 0x84 | -0x7C | GetLED | 128 bytes — LED config |
| 0x85 | -0x7B | GetRgb | LED RGB table data |
| 0x87 | -0x79 | GetPower | 2 bytes — battery + charging status |
| 0x88 | -0x78 | GetRealData | live RGB streaming readback |
| 0x8A | -0x76 | (unknown) | TBD |
This is the primary command for syncing time, volume, CPU, and memory data to the keyboard LCD. The Windows software sends this:
- Once on connect (if
SendTimeOnOpenKB=1in KB.ini) - Periodically via
SysParam_Thread(interval =SendSysParamms in KB.ini)
The 14-byte payload encodes system state. Which fields the keyboard reads
is controlled by SysParamMask in KB.ini:
Offset Field Mask Bit Description
────── ──────────── ──────── ──────────────────────────────────
0 volume bit 2 Speaker volume, 0-100 (integer %)
1 cpuUsage bit 1 CPU usage, 0-100 (integer %)
2 memoryLoad bit 1 RAM usage, 0-100 (integer %)
3 yearLow bit 0 Year, low byte (e.g. 0xEA for 2026)
4 yearHigh bit 0 Year, high byte (e.g. 0x07 for 2026)
5 month bit 0 Month, 1-12
6 day bit 0 Day, 1-31
7 hour bit 0 Hour, 0-23
8 minute bit 0 Minute, 0-59
9 second bit 0 Second, 0-59
10 dayOfWeek bit 0 0=Sunday, 1=Monday, ..., 6=Saturday
11-13 (padding) — Always 0x00
- All values are raw integers — NOT BCD encoded
- Year is 16-bit little-endian (e.g., 2026 =
0x07EA→ bytesEA 07) - DayOfWeek follows Windows SYSTEMTIME convention (0 = Sunday)
- Volume comes from Windows IAudioEndpointVolume::GetMasterVolumeLevelScalar (float 0.0–1.0 × 100, rounded to integer)
- CPU usage comes from PDH
\Processor Information(_Total)\% Processor Utility - Memory load comes from GlobalMemoryStatusEx().dwMemoryLoad
SysParamMask=5 → binary 101 → bits 0 and 2:
- Bit 0 (time): ✓ keyboard reads time fields
- Bit 1 (CPU/mem): ✗ keyboard ignores CPU/memory
- Bit 2 (volume): ✓ keyboard reads volume
Setting time to 2026-03-10 14:30:45 (Tuesday), volume 65%:
Offset: 00 01 02 03 04 05 06 07 08 09 0A 0B 0C 0D 0E 0F 10 11 12 13
Data: 13 0B 01 00 0E 41 00 00 EA 07 03 0A 0E 1E 2D 02 00 00 00 CRC
Where:
13 = Report ID
0B = cmdId (SetScreenParam)
01 = 1 packet total
00 = packet index 0
0E = meta (board=0, len=14)
41 = volume 65%
00 00 = CPU/memory (unused)
EA 07 = year 2026 (0x07EA LE)
03 = March
0A = 10th
0E = 14 hours
1E = 30 minutes
2D = 45 seconds
02 = Tuesday (0=Sun)
00 00 00 = padding
CRC = sum(bytes[0..18]) & 0xFF
Located at app/Dev/01AF/KB.ini inside the installer. Key fields:
[OPT]
Fw=1 # Firmware type (1 = newer)
Psd=6,0,0,0,0,23 # Password config (type=6)
ShowScreen=1 # Has LCD screen
RGBScreen=1 # Screen supports RGB
MaxScreenFrame=60 # Max GIF animation frames
siScreen=320,172 # Screen resolution
DataUnitSize=512 # Screen data transfer unit
SendTimeOnOpenKB=1 # Send time on device connect
SendSysParam=1000 # SysParam send interval (ms)
SendOnlyDataChanged=1 # Skip if data unchanged
SysParamMask=5 # Bits: 0=time, 1=cpu/mem, 2=volume
PackageTime=12 # Inter-packet delay (ms)
KbLayout=1 # Standard 104-key layout reference
KbImgUse=0x0174 # Shares key image with PID 0x0174
LayoutKeyNum=104 # Number of keys
LayerNum=4 # Number of programmable layers| Mask | Binary | Fields Sent |
|---|---|---|
| 1 | 001 | Time only |
| 2 | 010 | CPU + Memory only |
| 3 | 011 | Time + CPU + Memory |
| 4 | 100 | Volume only |
| 5 | 101 | Time + Volume |
| 7 | 111 | All fields |
The Windows software has three device classes with virtual dispatch:
CDev3632 — Base class, uses Report 0x13 output reports (WriteFile)
├── CDevG5KB — Newer keyboards, uses Feature Reports (HidD_SetFeature)
│ Can delegate to CDev3632 for dongle-connected devices
└── CDevRKKB — RK-specific keyboards, stubs out SysParam (no-op)
| Offset | CDev3632 | CDevG5KB | CDevRKKB |
|---|---|---|---|
| 0x00 | Init | Init | Init |
| 0x04 | FindHIDDevice | FindHIDDevice | FindHIDDevice |
| 0x0C | GetPSD | GetPSD | GetPSD |
| 0x28 | IsWireless | IsWireless | (stub) |
| 0x34 | GetPassword | SendScreen | SendScreen |
| 0x38 | ResetDevice | ResetDevice | (stub) |
| 0x3C | ApplySetting | ApplySetting | ApplySetting |
| 0x48 | SetScreenParam | SetScreenParam | (no-op) |
| 0x50 | (stub) | SetGIFParam | (stub) |
The M87 via dongle uses CDev3632::SetScreenParam (vtable 0x48) which calls
CDev3632::AccessData with cmdId=0x0B.
| Address | Function | Role |
|---|---|---|
| 0x00410230 | CDevG5KB_SetScreenParam |
wired path for SysParam (cmd 0x0B) |
| 0x0040DE90 | CDev3632_SetScreenParam |
dongle path for SysParam (cmd 0x0B) |
| 0x004128A0 | CDevG5KB_SetGIFParam |
LCD GIF param (cmd 0x0D) |
| 0x0040E570 | CDev3632_SetRealData |
live RGB streaming (cmd 0x88) |
| 0x00410230 | CDevG5KB_GetLED |
read 128 B LED config (cmd 0x84) |
| 0x0040FD10 | CDevG5KB_GetPower |
read battery + charging (cmd 0x87) |
| 0x004103E0 | CDevG5KB_GetPassword |
read 6 B psd (cmd 0x82) |
| 0x00410045 | CDevG5KB_GetRealData |
read live RGB (cmd 0x88) |
| 0x00410360 | SendWeb_dispatch |
cmd 0x12 SendWeb |
| 0x004101B0 | SetLedRgbTab_dispatch |
wired cmd 0x0A / dongle cmd 0x09 |
| 0x0040F9C0 | SetMatrix_dispatch |
wired cmd 0x03 / dongle cmd 0x01 |
| 0x0040DD60 | CDev3632_SetMacro |
dongle cmd 0x03 SetMacro |
| 0x0040D6A0 | CDev3632_FindDevice_OpenHandle |
enumerate dongle by VID/PID |
| 0x0040EE90 | CDevG5KB_InitialDriver |
wired init |
| 0x0040F350 | CDevG5KB_FindHIDDevice |
wired enumerate |
| 0x0040F030 | FindScreenDevice_ByPsd |
locate screen MCU via psd match (cmd 0x82) |
| 0x004168B0 | GetFirmwareVersion_3632 |
read FW version |
| 0x00411BA0 | CDevG5KB_ApplySetting |
apply matrix/macro/LED/sysparam in sequence |
| 0x0040E690 | CDev3632_ApplySetting |
dongle ApplySetting |
| 0x00411700 | FillCfg_LowDaly |
build 128 B SetLED payload (low debounce/tap) |
| 0x00411900 | FillCfg_HighDaly |
build 128 B SetLED payload (high range) |
| 0x00448100 | LoadProfile_rkf |
load .rkf profile |
| 0x00448530 | SaveProfile_rkf |
save .rkf profile |
| 0x004566E0 | SysParam_Thread |
periodic 14 B SysParam sender |
| 0x0045B090 | GetSystemVolume_IAudioEndpoint |
Windows volume via COM |
| 0x0045AFD0 | GetCPUUsage_PDH |
PDH-based CPU usage |
| 0x004533D0 | LoadCfgIni |
parse cfg.ini OPT keys |
Core packet builder. Parameters:
param_1 (this/ECX): data buffer pointer
param_2: device object (has m_hDev at +4)
param_3: cmdId
param_4: board (for meta byte, << 4)
param_5: data size in bytes
param_6: retry flag (0xf = retry enabled)
param_7: inter-packet delay (ms)
Splits data into 14-byte chunks, builds packets, writes via FUN_0040e0f0
(which calls WriteFile), reads response via FUN_0040d370.
Simpler variant for single-packet commands. Builds packet directly:
packet[0] = 0x13; // Report ID
packet[1] = cmdId; // from parameter
packet[2] = 1; // numPackages = 1
packet[3] = 0; // packageIndex = 0
// data at [4..17]
packet[19] = CRC; // computed over [0..18]- Software detects USB HID device via SetupAPI enumeration
- Reads
KB.inifor the detected PID - Opens HID device handle
- Calls
GetPassword(cmdId 0x05) — 6-byte auth handshake - Reads firmware version
- If
SendTimeOnOpenKB=1:- Calls
GetLocalTime()(Windows API) - Builds SysParam buffer with time + volume
- Sends via cmdId 0x0B
- Calls
- If
SendSysParam > 0:- Spawns
SysParam_Thread - Thread sends SysParam every
SendSysParamms - Checks
SendOnlyDataChangedflag to skip unchanged data
- Spawns
loop:
wait(SendSysParam ms)
cpu = GetProcessorUtility() // via PDH
vol = GetMasterVolumeScalar() // via IAudioEndpointVolume × 100
mem = GlobalMemoryStatusEx() // .dwMemoryLoad
time = GetLocalTime()
buffer[0] = vol (if mask & 4)
buffer[1] = cpu (if mask & 2)
buffer[2] = mem (if mask & 2)
buffer[3] = year_lo (if mask & 1)
buffer[4] = year_hi
buffer[5] = month
buffer[6] = day
buffer[7] = hour
buffer[8] = minute
buffer[9] = second
buffer[10] = dayOfWeek
if (!SendOnlyDataChanged || data_changed):
device->SetScreenParam(buffer, 14) // vtable[0x48]
Before sending SysParam, the thread checks NoSyncTimeForWireless (KB.ini).
If set and the device reports wireless (vtable[0x28] returns non-zero),
the SysParam send is skipped. For the M87, NoSyncTimeForWireless is not
set in KB.ini, so time sync works over both USB and 2.4 GHz dongle.
Used by the M87 over USB cable (PID 0x01A2) and some other RK keyboards.
Uses HidD_SetFeature / HidD_GetFeature (Windows) or
ioctl(HIDIOCSFEATURE) / ioctl(HIDIOCGFEATURE) (Linux) with 520-byte
(0x208) buffers:
Offset Size Field Description
────── ──── ──────────── ──────────────────────────────────────
0 1 reportId Device-specific (0x09 for M87 USB)
1 1 cmdId Command identifier (same as CDev3632)
2 1 board Board identifier (usually 0)
3 1 (reserved) Always 0
4 1 numPackages Total packets in transfer
5 1 packageIndex 0-based index of current packet
6 1 dataLenLow Payload size, low byte
7 1 dataLenHigh Payload size, high byte
8-519 512 payload Command-specific data (zero-padded)
| Aspect | CDev3632 (dongle) | CDevG5KB (USB cable) |
|---|---|---|
| Transport | Output reports (write/read) | Feature reports (ioctl) |
| Report ID | 0x13 | 0x09 (device-dependent) |
| Buffer size | 20 bytes | 520 bytes |
| Max payload | 14 bytes/packet | 512 bytes/packet |
| CRC | Byte 19 checksum | None (handled by USB/HID) |
| Response | Read after write | SetScreenParam is fire-and-forget |
For SysParam (cmdId 0x0B), the 14-byte payload is placed at offset 8 in the 520-byte buffer. Example for 2026-03-10 14:30:45, volume 65%:
Offset: 00 01 02 03 04 05 06 07 08 ...
Data: 09 0B 00 00 01 00 0E 00 41 00 00 EA 07 03 0A 0E 1E 2D 02 00 ...
Where:
09 = Report ID (M87 USB)
0B = cmdId (SetScreenParam)
00 = board
00 = reserved
01 = 1 packet total
00 = packet index 0
0E 00 = payload length 14 (little-endian)
41... = same 14-byte SysParam payload as CDev3632
import fcntl
def _IOC(dir, type, nr, size):
return (dir << 30) | (size << 16) | (type << 8) | nr
HIDIOCSFEATURE = lambda size: _IOC(3, ord('H'), 0x06, size) # set
HIDIOCGFEATURE = lambda size: _IOC(3, ord('H'), 0x07, size) # get
buf = bytearray(520)
buf[0] = 0x09 # report ID
buf[1] = 0x0B # cmdId
# ... fill header and payload ...
fcntl.ioctl(fd, HIDIOCSFEATURE(520), buf)The LCD GIF/image upload uses two cmdIds in sequence over the wired CDevG5KB feature-report protocol. The dongle path has no LCD image support — dongle vtable[0x50] (SetGIFParam slot) is a stub. Image uploads must go over USB cable.
The CDevG5KB device object has two HID handles:
m_hDev1(+0x04) — main keyboard interface (reportId read from HID descriptor)m_hDev2(+0x08) — screen daughterboard interface (reportId =0x09hardcoded)
The screen daughterboard appears to Linux as a separate hidraw device
on the same composite USB device. For image upload, m_hDev2 is preferred.
The main daemon currently only opens m_hDev1 — to upload images we'd need
to enumerate and open both.
5-byte payload, sent once per upload session to declare what GIF is coming:
Offset Field Description
────── ──────────────── ──────────────────────────────────────
0 idxBits (isScreen2 << 6) | (gifIndex & 0x3F)
1-2 nFrameNum (LE) Total frame count in this GIF (16-bit)
3-4 nGIFDelay (LE) Per-frame delay in ms (16-bit)
After SetGIFParam, the app awaits a confirmation response. If m_hDev2
is also present, the same SetGIFParam is re-sent to the screen handle
with reportId 0x09.
Bulk image data, one page (≤512 bytes) per packet, frame-indexed, with a per-page CRC that the daughterboard validates and echoes back:
Offset Field Description
────── ──────────────── ──────────────────────────────────────
0 reportId 0x09 if sending to screen handle (m_hDev2)
1 cmdId 0x0C
2 board 0
3 frameIdx Which frame of the GIF this page belongs to
4 pageIdx Cumulative page counter across all frames
5 CRC (-1 - sum(payload[0..len-1])) & 0xFF
6-7 dataLen (LE) Payload length, max 0x200 = 512
8-519 payload Frame pixel data (up to 512 bytes)
CRC enabled by global DAT_00611844 in the Windows app — when 0, the
field is left as 0 and the daughterboard skips the check.
After each page, the daughterboard ACKs via an async HID read with the following meaningful bytes in the response:
- byte
+3: validity flag, must be non-zero, else "respond unmatch" - byte
+0(in resp): echoespageIdx - byte
+1(in resp): echoes CRC
If the CRC does not match, the app logs:
CDevG5KB::AccessData_Page frame=%d, page=%d, CRC(out)=%x, CRC(in)=%x
and retries that page. After all pages are acknowledged, the GIF buffer
on the daughterboard is loaded; the previously sent SetGIFParam
parameters drive playback.
1. User picks image in CBurnScreenDlg (DeviceDriver.exe vtable[0x4C])
2. App computes frame indices (FUN_00466330 interpolates if user wants
more frames than the source has)
3. SetGIFParam(gifIndex, nFrameNum, nGIFDelay) → cmdId 0x0D
4. For each frame:
For each page (frame_bytes / 512):
AccessData_Page(frameIdx, pageIdx, payload) → cmdId 0x0C
wait ACK + verify CRC
retry on mismatch
5. Daughterboard renders the GIF on the LCD per (nFrameNum, nGIFDelay)
This is the path that ultimately drives the chunk-2 → daughterboard
[0x5A, cmd, len, sub, CRC] 5-byte abstract-command protocol — chunk-1
receives 0x0C-cmdId pages over HID, then forwards them to chunk-2 over
SPI which in turn translates to the daughterboard's internal protocol.
The script scans /sys/class/hidraw/*/device/uevent for entries matching
VID 258A with a known PID (0150 or 01A2), then selects the
input1 interface (vendor config channel). The PID determines which
protocol to use.
In dongle mode, the keyboard may be powered off while the dongle is
plugged in. The daemon sends cmdId 0x07 (GetDongleStatus) with an empty
payload and accepts any non-error response as proof of life. This is a
genuine read with no side effects.
The dongle answers on its own, so a reply only proves the dongle is present. The payload byte carries the wireless link state:
13 07 01 00 01 01 00 00 ... 1d keyboard awake, acknowledges SysParam
13 07 01 00 01 00 00 00 ... 1c keyboard asleep, SysParam gets no reply
^^ payload byte
Measured live on an M87 dongle (PID 0x0150, August 2026): the byte sat at
01 while syncs succeeded, flipped to 00 at the moment the keyboard
fell asleep, and a SysParam sent in that state timed out. Earlier
revisions of this document claimed 0 = wireless connected, which is the
opposite of the observed behaviour.
The daemon uses this byte to tell "keyboard asleep" apart from "device
gone": an unacknowledged SysParam keeps the hidraw handle open and
switches to polling 0x07 every 10 s, and the pending sync is delivered
as soon as the byte returns to 01. Only a dongle that stops answering
0x07 altogether counts as a disconnect and triggers a reconnect.
The earlier daemon implementation sent cmdId 0x09 with a 3-byte payload
[0x0E, 0xDE, 0xAD]. That is actually SetLedRgbTab, so each session
wrote those three bytes into the keyboard's LED color-table at flash
offset 0xA800 (confirmed by live dump May 2026). The current code uses
GetDongleStatus and avoids any persistent state change.
In USB cable mode the probe is unnecessary — if the hidraw device exists, the keyboard is connected and powered on.
The hidraw device needs read/write access. Options:
- udev rule:
SUBSYSTEM=="hidraw", ATTRS{idVendor}=="258a", MODE="0666" - Run as root
- Add user to appropriate group
# PipeWire/WirePlumber
wpctl get-volume @DEFAULT_AUDIO_SINK@
# Output: "Volume: 0.45" or "Volume: 0.45 [MUTED]"
# PulseAudio
pactl get-sink-volume @DEFAULT_SINK@The M87 ecosystem contains three SH68F90A 8051 microcontrollers. Verified
2026-05-16 by live dump of the dongle via sinowealth-kb-tool, which
confirmed the dongle is an 8051 SH68F90A (not the Cortex-M0 / "SH68F1000J"
suggested by its silkscreen).
┌─────────────────┐ SPI ┌─────────────────┐
│ Keyboard MCU │────────────▶│ Screen Controller│──▶ LCD daughterboard
│ (chunk 1, │◀────────────│ (chunk 2, │ 320×172
│ SH68F90A 8051)│ │ SH68F90A 8051)│
│ │ └─────────────────┘
│ Keymatrix, │ 2.4 GHz ┌─────────────────┐
│ Encoder, │◀───────────▶│ Dongle MCU │──▶ USB host
│ USB HID │ │ (chunk 0, │
└─────────────────┘ │ SH68F90A 8051)│
└─────────────────┘
The 2.4 GHz radio is integrated in each SH68F90A via the CCP
(capture/compare) module + dedicated TX/RX SFRs (TXSTAT, TXDAT, TXCON,
TXFLG, TXCNTL/H, RXSTAT/DAT/CON/FLG, WCON); no external transceiver.
The LCD daughterboard is a separate physical chip (not yet dumped) that
chunk 2 drives over SPI using a [0x5A, cmd, len, sub, CRC] 5-byte protocol.
Two packet types over SPI:
-
SysParam data:
[0x50, 0x81, sub_cmd, data_len, vol, cpu, mem, ...]Forwards raw volume/time from HID to screen controller. -
Display page commands:
[0x50, cmd_type, page_id, element, ..., CRC]Triggers specific display elements (volume bar, clock, etc.).
PC ──HID cmdId 0x0B──▶ Keyboard MCU ──SPI──▶ Screen Controller ──▶ LCD bar
(stores vol) (stores own copy)
Encoder tick ──────────▶ Keyboard MCU ──SPI──▶ Screen Controller ──▶ LCD bar
(0x81/0x82 cmd) (adjusts own ±2%)
- PC → keyboard: Volume from SysParam (raw 0-100) forwarded over SPI
- Encoder → screen: Direction commands 0x81 (down) / 0x82 (up), screen controller adjusts its internal counter by ±2% per tick (hardcoded)
- The screen controller maintains its own volume counter independently from the SysParam value — this causes mismatch during scrolling
| Address | Name (assigned) | Purpose |
|---|---|---|
| CODE:006e | DisplayUpdateDispatch | Checks flag bits 0F1A/0F1B/0F1C, dispatches |
| CODE:028f | DisplayElementRenderer | Renders volume bar, clock, etc. via SPI |
| CODE:3905 | HidCmdDispatch | cmdId jump table (0x03–0x0D) at CODE:39B8 |
| CODE:7276-7390 | EncoderHandler | Rotary encoder state machine (P1.4, P3.2) |
| CODE:1c0b | TimerISR | Display update timing, flag management |
| CODE:93b8 | SysParamTimer | Clears 0E12 processing flag after ~50ms |
| CODE:a29b | SpiPacketBuilder | Builds [0x50, type, len, data..., CRC] |
| CODE:b86b | SysParamSpiForwarder | Forwards SysParam to screen controller |
| Address | Name | Description |
|---|---|---|
| 0x08F5 | sysParamVol | Volume byte from HID SysParam (0-100) |
| 0x0BAB | encoderState | Encoder state machine current state |
| 0x0CA6 | displayElement | Active display element (0=none, 1=volbar, 16=clock) |
| 0x0CA7 | elementParam | Sub-element parameter / direction indicator |
| 0x0E12 | sysParamBusy | Processing flag (1=block display, 0=allow) |
| 0x0F1A | flagsA | Bit 0=SysParam pending |
| 0x0F1B | flagsB | Bit 5=element dispatch, Bit 6=volume bar trigger |
| 0x0F65 | displayMode | Active display mode (affects encoder behavior) |
Royal Kludge ships Update.exe (a Sinowealth 8805 USB update tool, build
20231113 V1.22 SPEC0.6) to flash new firmware. The protocol is HID-feature-
report based and uses two interfaces simultaneously.
Update.exe enumerates two HID interfaces by feature-report size:
| Interface | Feature size | Purpose |
|---|---|---|
m_hControl |
6 bytes | Small control commands |
m_hBulk |
2050 bytes (or 1032 for BLE) | Bulk data write/read |
The UPF file (.upf) packs 1–3 firmware chunks. The chunk_mode byte in
the header selects which chunks are present:
| mode | chunks | applied to |
|---|---|---|
| 0 | BLE + HID | dongle MCU + keyboard MCU |
| 1 | BLE only | dongle MCU |
| 2 | HID only | keyboard MCU (chunk 1) |
| 3 | BLE + HID + MFU | all three (chunk 0 + 1 + 2) |
- HID mode → chunk 1 (keyboard SH68F90A)
- MFU mode → chunk 2 (screen SH68F90A)
- BLE mode → chunk 0 (dongle SH68F90A; named "BLE" historically but the dongle uses 2.4 GHz proprietary radio, not Bluetooth Low Energy)
- HID and MFU use the identical wire protocol; only the target chunk differs.
Byte 1 is the cmd opcode — an ASCII mnemonic for the action:
| Cmd | Char | Operation | Bytes 2–5 |
|---|---|---|---|
0x45 |
'E' | Enter ISP mode (effectively: Erase ALL user firmware — pages 0x00..0x3B; writes [02, 00, 00] at addr 0 + [F0, 00, 01] at addr 1 as safety. Verified via byte-level RE of isp_set_report_handler @ dongle:0xF48C 2026-05-16) |
0x45 0x45 0x45 0x45 (filler magic) |
0x52 |
'R' | Setup read window | addr_lo addr_hi len_lo len_hi |
0x57 |
'W' | Setup write window / begin transfer | addr_lo addr_hi len_lo len_hi |
0x65 |
'e' | Erase page at addr | addr_lo addr_hi 0 0 (200 ms sleep follows) |
0x72 |
'r' | Read 4-byte data | (no args; issued after 'R' setup) |
0x77 |
'w' | Write 4-byte data inline | b0 b1 b2 b3 |
0x55 |
'U' | Upgrade finalize | 0x55 0x55 0x55 0x55 (filler magic) |
0x5A |
'Z' | Reset / reboot device | 0 0 0 0 |
HidD_GetFeature on the control HID returns 6 bytes; byte 1 is the status:
0x00— success0x55('U') — busy, retry0xCB(=-0x35) — handshake success0xFF— error / fatal
byte 0: 0x06 (report ID)
byte 1: 0x77 or 0x72 (write or read)
bytes 2..: 2048 bytes of payload
The application firmware must transition into ISP/bootloader mode before
any of the control commands above are accepted. The chunk-1 firmware
recognises a Feature Report 5 SET_REPORT carrying [0x05, 0x75] as the
trigger. process_isp_state_machine @ chunk1:0x8907 requires
EXTMEM:0x1100 == 0x05 and EXTMEM:0x1101 == 0x75 ('u'), then calls
enter_isp_full @ 0xB0ED. From Linux:
ioctl(fd, HIDIOCSFEATURE(N), [0x05, 0x75, ...]).
Once in the bootloader, isp_set_report_handler @ dongle_bootloader:0xF48C
processes the ASCII commands listed above. Sending 'E' ([0x05, 0x45*5])
erases the entire user firmware (pages 0x00..0x3B, 60 pages) and is
the first step of an upgrade — not a benign "enter ISP" handshake.
Update.exe uses a different entry sequence for the dongle/BLE upgrade
mode (EnterBootloader_via_HID @ Update.exe:0x00409760):
1. HidD_SetFeature: [0x05, 0xC9, 0x60, ...]
2. Poll HidD_GetFeature for [0x60, status, ...] response
3. status byte:
0x00 → success, enter upgrade thread
0x55 → busy, retry
0xFF → fatal error
This matches the firmware path in chunk2_handle_set_report (state 9
1101 == 0xC9→dispatch_p_or_backtick). The 0xC9/0x60 handshake is the canonical "BLE upgrade mode" entry.
1. ENTER BOOTLOADER: SET_REPORT [0x05, 0x75, ...] (or 0xC9 0x60 for BLE);
wait for ACK
2. ERASE ALL: control [0x05, 0x45, 0x45, 0x45, 0x45, 0x45]; sleep 2000 ms
3. BEGIN WRITE: control [0x05, 0x57, 0x00, 0x00, len_lo, len_hi]
4. WRITE LOOP: for page i = 0..(chunk_size/2048):
bulk [0x06, 0x77, page_data[2048]] ; first page has data[0]=0
5. END WRITE: control [0x05, 0x57, 0x00, 0x00, len_lo, len_hi] (re-sent)
6. BEGIN VERIFY: control [0x05, 0x52, 0x00, 0x00, len_lo, len_hi]
7. VERIFY LOOP: for page i = 0..(chunk_size/2048):
HidD_GetFeature(bulk_handle, buf, 2050)
compare buf[2..2049] vs page_data[i]
8. (HID only) PRESERVE 0xFF80 IDENTITY: read 4 bytes, erase page, write back
with PID/VID overlay.
9. FINALIZE: control [0x05, 0x55, 0x55, 0x55, 0x55, 0x55]
10. RESET: control [0x05, 0x5A, 0x00, 0x00, 0x00, 0x00]
Uses 1032-byte feature reports with cmd 0xC9/0x05/0x02 for version
exchange. The chunk-write command is 0xCB:
data channel (Report 0x06, 1032 bytes):
[06, CB, sub, page_num, ~page_num, data(1024B), CRC_lo, CRC_hi]
subcmd 0x02 = full 1024-byte chunk
subcmd 0x01 = ≤128-byte final chunk
CRC = CRC-16-CCITT (poly 0x1021, init 0x0000) over the firmware data
Status polling uses Report 0x05 (6 bytes); byte 0 = 0xCB (or 'p'=0x70
for finalize), byte 1 = 0x00 success / 0x55 busy / 0xFF error.
The same TXSTAT/TXDAT/TXCON/TXFLG/TXCNTH/CCAP1H/CCAP2H SFRs that drive
the 2.4 GHz radio in the user firmware are repurposed by the bootloader
as flash erase/program controllers. isp_erase_page @ 0xFB9E and
isp_write_byte_to_flash @ 0xFB6B route through these registers. This
is why a single chip can both transmit radio packets and self-flash
without dedicated programming pins.
offset size field BE/LE notes
────── ──── ────────────── ───── ──────────────────────
0 1 marker1 0x5A ('Z')
1 1 marker2 0xA5
2 7 magic "BLESINO"
9 1 chunk_mode 0/1/2/3 (see table)
10 7 version1 display
17 2 PID BE keyboard USB PID
19 2 VID BE keyboard USB VID
21 2 BLE_PID BE dongle USB PID
23 2 BLE_VID BE dongle USB VID
25 1 flag unknown
26 6 version2 display
32 6 blockData
38 6 version3 display
44 4 BLE_chunk_size BE chunk 0 length
48 4 HID_chunk_size BE chunk 1 length
52 4 ver_fw1 mixed
56 4 ver_fw2 mixed
60 6 version4 display
66 4 MFU_chunk_size BE chunk 2 length (only if mode==3)
...
0x80 N ciphertext concatenated TEA-encrypted chunks
EOF-4 4 key 4-byte TEA key
Encryption: modified TEA with delta 0x9E3769B9 (note: not the standard
0x9E3779B9), 32 rounds, 8-byte blocks treated as big-endian uint32 pairs.
Key is 4 bytes at the end of the file; each byte is zero-extended to a uint32
to form the 16-byte expanded key. M87 key: 04 a3 ce 69.
A working decryptor is at scripts/decrypt_upf.py.
To replicate the update flow from Linux:
- Enumerate both HID interfaces (control = 6-byte feature, bulk = 2050-byte feature).
- Run the sequence above via
ioctl(HIDIOCSFEATURE)/HIDIOCGFEATURE. - After the
0x5Areset packet, the keyboard reboots into the new firmware.
The same ISP server that responds to these commands is also reachable via
the LJMP 0xFF00 boot-key recovery shim — so a software-only brick recovery
should be feasible (no external programmer needed, provided the bootloader
isn't corrupted).
The bootloader entry at 0xFF00 (isp_bootloader_entry_5a_a5) requires
the magic byte pair [0x5A, 0xA5] in the register pair — the same magic
as the UPF file header marker.
Chunk 2 (screen MCU) has its own USB SET_REPORT handler
(chunk2_handle_set_report @ 0x1249, dispatched from
chunk2_usb_packet_state_machine @ 0x0D1D) that mirrors chunk 1's. The
USB SETUP packet dispatcher table at chunk2:CODE:0x15C7 is byte-for-byte
identical to chunk 1's at chunk1:CODE:0xA2C2.
For SET_REPORT(Feature, Report 5, iface 0) with payload starting
[0x05, 0xC9, byte2, byte3], the firmware calls
dispatch_p_or_backtick(byte3, byte2):
byte2 == 'p' (0x70)→EXTMEM:0x42 = 4→ mode-4 LCD refreshbyte2 == '\' (0x60)→EXTMEM:0x42 = 0x80→ encoder UP packet[0x5A, 0x80, 5, 0, CRC]` sent to the daughterboard
This is a direct LCD-control path that bypasses chunk 1 entirely
and is useful for debugging the daughterboard. The same [0x05, 0x75]
sequence on chunk 2 triggers halt_baddata_1f9c (chunk-2's
"transition to bootloader" — disables IRQ then halts to let the
bootloader take over).
DeviceDriver.exe saves keyboard profiles to disk as .rkf files via
SaveProfile_rkf @ 0x00448530 / LoadProfile_rkf @ 0x00448100. The format
is fixed-size, 24,332 bytes (0x5F0C):
offset size field notes
────── ──── ────────────── ────────────────────────────────
0x00 0x40 header model name, magic, etc.
0x40 2 PID keyboard USB PID (e.g. 0x01AF)
0x44 4 magic 0xFCFB999A (little-endian)
0x48 8 reserved
0x50 N key info data 4 layers × 132 keys × 16 bytes
0x21D8 N macro buffers 10 macro slots × 834 bytes
The PID at offset 0x40 enforces per-model compatibility — loading a
profile saved on a different keyboard model triggers a "wrong profile
data" warning. Each key info entry is 16 bytes encoding the key type,
HID code, and any macro/layer reference.
-
Sending cmdId 0x0C–0x0F with zeroed payloads resets the keyboard clock to 2000-00-00 00:00:00. These appear to be legacy time-set commands.
-
Scanning all 256 cmdIds (0x00–0xFF) can reset the clock and freeze it. Power-cycle the keyboard to recover.
-
Invalid BCD in time bytes causes garbled display (e.g., non-renderable characters in the year field).
-
CRC is validated — packets with wrong CRC are silently ignored.
-
The keyboard has no hardware RTC — time is lost on power loss and must be re-synced from the host.
-
Profile data (GetProfile 0x44) does NOT contain time — only byte 47 stores TimeFormat (12h/24h toggle).
-
The RK web app (drive.rkgaming.com) has NO time sync code at all. Time sync is exclusive to the Windows desktop software.
Each send_sysparam call over USB cable (ioctl(HIDIOCSFEATURE)) takes
~25ms for the USB control transfer on EP0. However, the keyboard firmware
then spends an additional ~275ms processing the SysParam packet
(setting 0E12 flag, SPI transfer to screen controller, display dispatch,
timer-based flag clearing). During this ~300ms total, the keyboard's 8051
main loop is busy and cannot poll the rotary encoder or send HID
consumer control reports (volume up/down) to the OS.
Impact: Sending SysParam on every volume event throttles the encoder to ~2-3 ticks/second instead of the native ~40 ticks/second. The user experiences extremely sluggish scrolling.
Mitigation: The --daemon mode debounces volume changes — it waits
500ms after the last scroll event before sending a single SysParam. This
keeps EP0 free during scrolling and preserves native scroll speed. The
trade-off is that the LCD bar shows the screen controller's internal
±2% tracking during scrolling, then jumps to the correct OS value ~500ms
after scrolling stops.
The screen controller (chunk2) maintains its own volume counter that increments/decrements by ±2% per encoder tick. This is independent of the SysParam volume value sent from the PC. When the OS changes volume by ±5% per tick, the screen controller's bar and the actual OS volume diverge during scrolling.
The 2% step is hardcoded in the screen controller firmware (chunk2) and is not configurable via KB.ini or any HID command. The only way to fix this would be to modify the screen controller firmware.
When a SysParam packet arrives (cmdId 0x0B), the keyboard firmware sets
EXTMEM 0x0E12 = 1, which blocks the timer ISR (CODE:1c0b) from
triggering volume bar display updates. The FUN_CODE_93b8 timer clears
0E12 after ~50ms. During this window, encoder-triggered bar updates are
suppressed, so even if the correct volume is sent, the LCD may not
refresh until the bar auto-hides and the user scrolls again.
- Firmware patch: Modify chunk2 to use ±5% step (or read step from SysParam). Requires reflashing the screen controller MCU.
- cmdId 0x0D trick: After sending 0x0B, send cmdId 0x0D which sets
0CA6=1(volume bar element), potentially forcing a display refresh. Untested — may cause other side effects. - Async sends: Use a background thread for
ioctl(HIDIOCSFEATURE)so the event loop isn't blocked. However, the firmware processing delay (~275ms) still blocks the encoder regardless of threading.
The MCU firmware is stored as .upf files in the extracted installer
(rk_extracted/app/Dev/<PID>/). The firmware updater is Update.exe.
Offset Size Endian Field Description
────── ──── ────── ────────────── ──────────────────────────────────────
0 2 — magic Always 0x5A 0xA5
2 7 — vendor "BLESINO" (Sinowealth identifier)
9 1 — fwType Firmware type (0–3, see below)
10 7 ASCII model e.g. "KB0000\0"
17 2 BE vid USB Vendor ID (e.g. 0x258A)
19 2 BE pid USB Product ID (e.g. 0x01AF)
21 4 — (unknown) TBD
25 1 — (unknown) TBD
26 6 ASCII version1 Version string component
32 6 ASCII version2 Version string component
38 4 BE size1 Chunk 0 size in bytes
42 4 BE size2 Chunk 1 size in bytes
46 8 — (fields) Format/checksum fields
54 6 ASCII version3 Version string component
60 4 BE size3 Chunk 2 size in bytes (type 3 only)
64 4 — (fields) Format/checksum fields
68-85 — — (padding) Unused
86 1 — (flag) Unknown flag at offset 0x56
87-127 — — (padding) Zero-filled
| fwType | Chunks | Typical Use |
|---|---|---|
| 0 | 2 | Two firmware images (size1 + size2) |
| 1 | 1 | Single image (size1 only) |
| 2 | 1 | Single image (size2 only) |
| 3 | 3 | Three images (size1 + size2 + size3) |
Offset 0x80: Chunk 0 (size1 bytes)
Offset 0x80 + size1: Chunk 1 (size2 bytes)
Offset 0x80 + size1 + size2: Chunk 2 (size3 bytes, type 3 only)
After all chunks: TEA key (4 bytes)
File size = 0x80 + sum(chunk sizes) + 4
The M87 .upf file contains three chunks:
| Chunk | Size | Architecture | Content |
|---|---|---|---|
| 0 | 70,640 B | ARM Cortex-M | BLE/wireless dongle MCU firmware |
| 1 | 61,440 B | 8051 | Keyboard MCU main firmware (SH68F90A) |
| 2 | 61,440 B | 8051 | Screen controller MCU firmware |
- Chunk 0 starts with an ARM vector table (initial SP, reset handler)
- Chunks 1 & 2 start with
LJMP(opcode0x02) — the 8051 reset vector
All firmware chunks are encrypted with a modified TEA (Tiny Encryption Algorithm):
| Parameter | Value |
|---|---|
| Algorithm | TEA (Feistel cipher, 64-bit blocks) |
| Rounds | 32 |
| Delta | 0x9E3769B9 (non-standard; standard is 0x9E3779B9) |
| Key size | 4 bytes (NOT the standard 128-bit key) |
| Key source | Last 4 bytes of the .upf file, after all chunks |
| Block endian | Big-endian uint32 pairs |
Key weakness: Each of the 4 key bytes is zero-extended to a 32-bit TEA key word, giving only 32 bits of effective key strength instead of 128.
Decryption pseudocode:
DELTA = 0x9E3769B9
MASK = 0xFFFFFFFF
def tea_decrypt(v0, v1, k0, k1, k2, k3):
s = (DELTA * 32) & MASK # = 0xC6ED3720
for _ in range(32):
v1 = (v1 - (((v0 >> 5) + k3) ^ (s + v0) ^ ((v0 << 4) + k2))) & MASK
v0 = (v0 - (((v1 >> 5) + k1) ^ (s + v1) ^ ((v1 << 4) + k0))) & MASK
s = (s - DELTA) & MASK
return v0, v1Data is processed in 8-byte (two uint32) blocks, read/written as big-endian.
python3 decrypt_upf.py [path/to/firmware.upf]Outputs decrypted chunks as *_chunk0.bin, *_chunk1.bin, *_chunk2.bin.
The encryption algorithm was traced in Update.exe (Ghidra):
| Address | Function | Purpose |
|---|---|---|
| 0x0040a940 | UPF parser | Reads header, extracts chunks & key |
| 0x004044b0 | Decrypt wrapper | Byte-swaps, calls TEA per 8B block |
| 0x004045a0 | TEA decrypt core | 32-round modified TEA decryption |
| File | Purpose |
|---|---|
scripts/rk-m87-sync.py |
Legacy Python sync script |
scripts/decrypt_upf.py |
UPF firmware decryptor (modified TEA) |