Skip to content

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.

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)

80 0D 00 00 00 00 80 80 80 80 <idx> <cyc> 10    <- controller at rest
 0  1  2  3  4  5  6  7  8  9   10    11   12
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.

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.