24G Protocol (SF30 2.4G)¶
Wire protocol reference for the 8BitDo SF30 2.4G wireless controller and its nRF24L01+-based radio link. Recovered by logic-analyser capture of the SPI bus between the OEM receiver dongle's MCU and its radio chip (a Beken BK2425, an nRF24L01+ register-compatible clone), then confirmed live with a genuine nRF24L01+.
Location: src/native/host/rf24g/sf30_protocol.h, rf24g_host.c
Radio Configuration¶
| Register | Value | Meaning |
|---|---|---|
RF_SETUP |
0x27 |
250 kbps, max PA |
SETUP_AW |
0x03 |
5-byte addresses |
CONFIG |
0x3F |
PWR_UP, PRIM_RX, EN_CRC+CRCO (16-bit CRC); RX_DR unmasked (the OEM dongle masks all IRQs and polls STATUS instead -- this driver uses the IRQ line) |
EN_AA |
0x3F |
auto-ACK, all pipes -- the controller is PTX and needs it |
FEATURE |
0x06 |
dynamic payload + ack payload, EN_DYN_ACK clear -- see warning below |
DYNPD |
0x3F |
dynamic payload length, all pipes (payloads are 13 bytes, not the 32 RX_PW_Pn implies) |
SETUP_RETR |
0x12 |
ARD 500us, 2 retries |
RX_ADDR_P0 / TX_ADDR |
E4 81 00 EA B3 |
OEM link address |
RX_ADDR_P1 |
E4 17 D8 55 AA |
pairing rendezvous address |
ACTIVATE 0x50 0x73 must precede writing FEATURE/DYNPD -- they're locked otherwise.
FEATURE must be 0x06, not 0x07
The OEM dongle uses FEATURE = 0x07 (EN_DYN_ACK set). Its BK2425 is an nRF24 clone that does not honor EN_DYN_ACK the same way a genuine nRF24L01+ does. On real nRF24 silicon, 0x07 causes the radio to refuse to ACK the controller's NO_ACK probe frames, and the controller then never starts hopping. Measured A/B, same controller, cold start each time: 0x07 -> 3 probes, 0 links, 0 of 74s linked; 0x06 -> 1 probe, 1 link, 47 of 51s linked, 1500 packets received. Do not "correct" this to match the dongle.
Frequency Hopping¶
The link hops across 16 channels on a 64-step sequence. Byte 10 of every linked frame carries a counter (0-63) that, once linked, equals the current hop index -- so a receiver resynchronizes from any single received packet instead of searching blind.
static const uint8_t hop_table[64] = {
6, 24, 47, 67, 11, 29, 51, 69,
14, 33, 55, 73, 17, 36, 59, 76,
76, 59, 36, 17, 73, 55, 33, 14,
69, 51, 29, 11, 67, 47, 24, 6,
67, 47, 24, 6, 69, 51, 29, 11,
73, 55, 33, 14, 76, 59, 36, 17,
17, 36, 59, 76, 14, 33, 55, 73,
11, 29, 51, 69, 6, 24, 47, 67,
};
Idle/unpaired, the receiver parks on RF_CH = 0x2F (2447 MHz) -- itself hop-table indices 2, 29, 33 and 62, so a hopping controller is guaranteed to visit it periodically.
idx + 1 is the wrong successor -- three times in every 64 hops
The index does not count up and wrap. It advances by one within a 16-index block, but the blocks run in a fixed non-ascending order: 32..47, 48..63, 16..31, 0..15, 32..47, .... So the transitions 63->16, 15->32 and 31->0 are not +1. Treating it as a plain counter loses the boundary transitions, and because both ends then keep advancing by one from a wrong point, the resulting 16-index offset persists until the free-running channel coincidentally realigns -- it isn't a one-dwell cost. Use the block-aware successor:
static inline uint8_t sf30_next_idx(uint8_t idx)
{
static const uint8_t NEXT_BLK[4] = { 2, 0, 3, 1 }; /* 2 -> 3 -> 1 -> 0 */
idx &= 0x3F;
return ((idx & 0x0F) != 0x0F) ? (uint8_t)(idx + 1)
: (uint8_t)(NEXT_BLK[idx >> 4] << 4);
}
Measured impact: naive +1 sustains 61.6 pkt/s; the block-aware successor sustains 104.3 pkt/s against a controller transmitting ~99 frames/s -- a 1.69x difference, entirely from empty dwells caused by chasing the wrong channel after a boundary.
Link Acquisition¶
| State | Behavior |
|---|---|
| PARK | Sits on 2447 MHz, CE held high, RF_CH rewritten every ~2ms. No periodic sweep timer -- sweeping would take the receiver off the only channel a cold controller uses. |
| SEARCH | One 52-hop sweep -- a second, independent path to acquisition alongside a direct contact on the park channel. Sweeping and hopping traverse the same 16 channels, so a coincidence is bounded. |
| LINKED | Every frame's hop-index byte resynchronizes index and phase, so the link self-heals after any stall without needing to reacquire from scratch. |
A cold controller probes on 2447 MHz (and occasionally other hop channels while scanning) with the frame's hop-index byte pinned at 48 -- that value is a fixed probe marker, not a real hop index.
Never validate a frame on byte12 == 0x10
Every ordinary frame carries 0x10 in byte 12, but the initial contact frame that begins a link carries 0x31. Requiring 0x10 silently discards exactly the frame that starts a link.
Don't decode buttons from an unlinked (probing) frame
Byte 3 bit 0x20 is Start -- also the controller's power button -- and it is set on most probe frames (the controller powers on holding Start). Decoding buttons before the link is established emits a phantom Start press on nearly every probe. Gate button decoding on LINKED state.
Frame Format (13 bytes)¶
| Byte | Meaning |
|---|---|
| 0 | 0x80 constant -- header |
| 1 | 0x0D constant -- length (13) |
| 2 | buttons -- see below |
| 3 | buttons -- see below |
| 4-5 | unused, always 0x00 |
| 6-9 | 0x80 constant -- no analog axes (SF30 2.4G has no sticks) |
| 10 | hop index, 0x00-0x3F, block-ordered (see above); pinned at 0x30 (48) on unlinked probes |
| 11 | <base> \| (counter >> 4) -- low two bits are the frame counter's high bits; upper six bits are address-tied but not decoded (open question upstream) |
| 12 | 0x10 on ordinary frames, 0x31 on the contact frame that begins a link |
Byte 2¶
| Bit | Mask | Button |
|---|---|---|
| 7 | 0x80 |
Up |
| 6 | 0x40 |
Down |
| 5 | 0x20 |
Left |
| 4 | 0x10 |
Right |
| 3 | 0x08 |
A |
| 2 | 0x04 |
B |
| 1 | 0x02 |
X |
| 0 | 0x01 |
Y |
Byte 3¶
| Bit | Mask | Button |
|---|---|---|
| 6 | 0x40 |
Up (fires together with byte 2 bit 7 -- treat as the same button, never surface separately) |
| 5 | 0x20 |
Start (also the power button) |
| 4 | 0x10 |
Select |
| 3 | 0x08 |
R |
| 0 | 0x01 |
L |
Combinations, including all four diagonals, are a plain bitwise OR of the individual bits -- no SOCD handling exists at the protocol level.
Pairing Rendezvous¶
RX_ADDR_P1 (E4 17 D8 55 AA) is a dedicated pairing address, listened to at 2447 MHz only.
- Request burst. A controller entering pairing mode (held Select ~3-4s) transmits
35 04 00 <counter8>repeatedly, about 24ms apart, then goes quiet and listens. - Reply timing. The responder must wait for the request burst to go quiet (~250ms) and send its first reply 346ms after the last request -- answering the first request talks over the rest of the burst while the controller is still deaf.
- Reply payload, 13 bytes:
35 0D 01 <address:5> <~address:5>-- header, length, message-type-response, the offered 5-byte address, then that address bitwise-complemented as a check field. The tail must be recomputed per offered address, never copied from a captured example. - Five reply/listen cycles. 25 replies at 20ms intervals, then 500ms listening on the rendezvous address, repeated 5 times (125 replies total). The listening phases are load-bearing: the controller may re-request once or twice before settling, and the only signal that it accepted is it ceasing to request -- and only in the final cycle.
- Provisional claim. The offered address should not be persisted until a frame actually arrives on it (an 8s acceptance window is a reasonable bound) -- three attempts in the original recovery reported "paired" from ACK counts alone and delivered zero frames.
ACK counts mean nothing during pairing
The run that successfully bound a controller got 0 of 125 replies acknowledged; the OEM dongle itself only gets ~10 of 125. Judge success by the controller ceasing to request, never by ACK count.
Attribution¶
Protocol recovered by logic-analyser capture (SPI tap between the OEM receiver's MCU and its BK2425 radio), independently confirmed live with a genuine nRF24L01+. Full capture notes and reverse-engineering detail: github.com/FatBeard/sf30-2.4g-protocol.