This document describes how devourer makes one Realtek Jaguar adapter transmit on several channels in quick succession — a per-packet frequency hop — and the specific techniques that bring the retune cost down from a quarter of a second to ~1.5 ms. It doubles as a porting guideline: the same anatomy (find the channel write, strip the per-hop work down to it, kill the read-modify-write penalty) applies to any Realtek generation, so the "Porting to other generations" section at the end generalises each trick.
Setting DEVOURER_HOP_SEED to up to 32 hexadecimal digits replaces the public
round-robin order with a SipHash-2-4-driven Fisher-Yates permutation for every
round. The schedule is a pure function of the 128-bit key and absolute slot:
every channel occurs exactly once per round and a receiver can join without
replaying RNG state. With no seed, the order is the public sequential
round-robin (the two share the same lockstep machinery, so both are trackable).
DEVOURER_HOP_SLOT_MS=N selects monotonic wall-time slots instead of
DEVOURER_HOP_DWELL_FRAMES; explicitly setting both is an error. In slot mode
both txdemo and streamtx emit a versioned private synchronization marker —
a seed fingerprint, process epoch, slot, and in-slot phase. txdemo carries it
on the beacon; streamtx sends it on its own frame every
DEVOURER_HOP_SYNC_EVERY data frames (default 4) so the caller's FEC PSDUs stay
untouched.
rxdemo enters single-adapter lockstep mode when DEVOURER_HOP_CHANNELS and
DEVOURER_HOP_SLOT_MS are present (the seed is optional — with it the RX tracks
the keyed permutation, without it the sequential order). It parks on the first
channel, scans the hopset after DEVOURER_HOP_ACQUIRE_MS (default two slots),
then tracks the transmitted slot clock. Three marker-free slots return it to
acquisition. hop.rx events expose state, retune time, phase correction inputs,
and first-decode dead time. This mode is mutually exclusive with
DEVOURER_RX_SWEEP.
The full story — the schedule, lockstep, validation, and jammer-resilience measurements — is written up as an article in fhss.md; the jammer-resilience experiments have their own methodology note in jammer-resilience.md.
devourer already lets a single frame pick its own rate / bandwidth / STBC / LDPC / SGI by reading them from the radiotap header — those are per-packet fields baked into the TX descriptor, applied by the PHY at zero cost frame to frame. It is tempting to treat frequency the same way, but frequency is categorically different:
- There is one RF synthesiser (LO) per chip. The 2/4 RF paths share it within a band, so the radio sits on exactly one centre frequency at any instant. You cannot transmit two channels at once from one adapter (that needs two synthesisers or a wideband DAC like an SDR).
- There is no channel field in the TX descriptor. Centre frequency is hardware PLL state, set by writing RF registers.
So hopping is not "tag the frame and forget it" — it is a real hardware state change between packets, and the entire engineering problem is making that state change cheap enough to do between packets without collapsing throughput.
The baseline cost of a full channel switch (set_channel_bwmode) on an
RTL8812AU is ~275 ms for an intra-band 2.4 GHz hop. At ~345 fps (the
chip-limited monitor-injection rate) the inter-frame gap is ~2.9 ms, so the
unoptimised switch is ~95 frame-times — hopping would reduce the link to a
trickle. The work below gets it to ~1.5 ms (~0.5 frame-times).
set_channel_bwmode → phy_SwChnlAndSetBwMode8812 runs four stages, each
timed by the InitTimer (channel_set.<stage> lines). Measured on an 8812AU
C-cut, intra-band 2.4 GHz:
| Stage | Function | Cost | What it does |
|---|---|---|---|
sw_chnl |
phy_SwChnl8812 |
~98 ms | RF centre-channel + MOD_AG writes, spur fix, fc_area |
set_bw |
phy_PostSetBwMode8812 |
~53 ms | bandwidth + secondary-channel registers |
tx_power |
PHY_SetTxPowerLevel8812 |
~100 ms | per-rate TXAGC fan-out (~150-200 writes) + tx-power training |
pwrtrk |
_pwrTrk.TickThermalMeter |
~22 ms | thermal-meter read + BB-swing index (8812 only) |
Two surprises drive everything:
- IQK does not run on an intra-band hop.
_needIQKis only set on a band transition (phy_SwBand8812), so the calibration that everyone assumes dominates a retune is simply not in the path. (Thechannel_set.iqktimer stage label is misleading — for an intra-band hop it is measuring thepwrtrktick, not IQK.) sw_chnlis ~98 ms for only a handful of RF writes. That is the read-modify-write penalty, explained below. It is the single biggest lever.
The per-hop cost decomposes cleanly, and each component maps to a technique:
full switch = sw_chnl(98) + set_bw(53) + tx_power(100) + pwrtrk(22) ≈ 275 ms
│ │ │ │
│ │ │ └─ skip (Trick 1)
│ │ └─ skip (Trick 1)
│ └─ skip for same-BW, or cache (Trick 4)
└─ cache the RF writes (Trick 2) + cache constants (Trick 3)
The end state is ~1.5 ms: two RF writes (one per path) of a cached register value, with everything else either skipped or written only when it actually changes.
An intra-band, same-bandwidth hop changes only the centre frequency. Therefore:
tx_power(the per-rate TXAGC loop, ~100 ms) is skippable. Per-rate TX power barely moves across adjacent channels in one band; it was set by the last full channel set (e.g.InitWrite) and stays close enough. (There is a long-standing escape hatch,DEVOURER_SKIP_TXPWR, that proves the rest of the system tolerates skipping it.)pwrtrk(the thermal tick, ~22 ms) is skippable. It is a slow steady-state tracker, not a per-frame requirement.set_bw(~53 ms) is skippable when the bandwidth is unchanged (the 20 MHz hop case), and reducible to a few writes when it is not (40/80, Trick 4).- IQK is already absent intra-band.
This alone (the non-cached fast path) gets a 20 MHz hop to ~98 ms — the
irreducible sw_chnl. The flag _fast_skip_heavy makes the shared
phy_SwChnlAndSetBwMode8812 skip tx_power + pwrtrk for one call, which is
how the 40/80 fallback path reuses the full code while still being faster.
The gate matters. fast_retune declines (returns false, touches nothing)
when the hop crosses the 2.4/5 GHz boundary, and the caller falls back to the
full set_channel_bwmode — because a band change genuinely needs the RFE/AGC
reconfiguration, the TX-FIFO drain, and the IQK that a hop skips. Never fast-path
a band change.
phy_SwChnl8812 sets the channel with masked RF writes:
phy_set_rf_reg(path, RF_CHNLBW_Jaguar, BIT18|BIT17|BIT16|BIT9|BIT8, mod_ag); // RF_MOD_AG
phy_set_rf_reg(path, RF_CHNLBW_Jaguar, bMaskByte0, channel); // channel byteA masked RF write is a read-modify-write: phy_set_rf_reg reads the current
register (to preserve the unmasked bits), merges, and writes. And on C-cut
silicon an RF read carries a 20 ms sleep (phy_RFSerialRead, gated by
IS_C_CUT). So:
2 masked writes/path × 2 paths × 20 ms read ≈ 80 ms ← the bulk of sw_chnl
The fix is to never read. RF_CHNLBW_Jaguar (0x18) holds the channel byte,
the MOD_AG field, and the bandwidth bits. Across a hop only the channel byte
changes; the rest is constant. So maintain a per-path cache of the full 0x18
value and write it with the full LSSI-write mask (bLSSIWrite_data_Jaguar,
0x000fffff), which phy_set_rf_reg recognises as "write all 20 bits, no read":
_rf_chnlbw_cache[4],_rf_chnlbw_cached— primed once by reading 0x18 per path (paying the 20 ms reads exactly once), then every subsequent hop merges the new channel + MOD_AG fields into the cached value in memory and does one full write per path. No reads, no sleeps.- The merge must replicate
phy_set_rf_reg's exact arithmetic ((cached & ~mask) | (data << shift), no masking of the shifted field) so the cached write produces the bit-identical value the masked write would have, given the cache equals the real register. - Invalidate the cache whenever the full path runs (
set_channel_bwmodeclears_rf_chnlbw_cached), because the full path can rewrite 0x18's bandwidth bits.
This is the single change that takes sw_chnl from ~98 ms to a few ms. It is
the most important trick to carry to any C-cut-afflicted generation.
phy_SwChnl8812_fast is the cached variant of phy_SwChnl8812.
The remaining sw_chnl work is per-path-independent BB state that is constant
across most hop sets. Cache the last value and skip the write when unchanged:
fc_area(BB 0x860) is constant within a band (e.g. always one value for 2.4 GHz)._last_fc_areaskips the write unless the band-area bucket changes.- The spur fix (
phy_FixSpur_8812A) writes global (not per-path) BB ADC-clock registers, and its output depends only on(bandwidth, channel ∈ {11,13,14}). The original loop called it once per RF path — redundantly. The fast path computes a small "spur class" and calls it once, only when the class changes (_last_spur_class).
For a 1→6→11 hop set, both collapse to a single write on the first hop and nothing thereafter.
A wider-bandwidth hop seems to need the full set_bw, because the
secondary-channel registers (REG_DATA_SC, rRFMOD[0x3C], rCCAonSec,
rCCK_System) change. The insight that makes it cheap:
phy_GetSecondaryChnl_8812 computes the secondary-channel value from the
prime-channel offset (_cur40MhzPrimeSc/_cur80MhzPrimeSc) — not the
channel number. So across a same-bandwidth, same-offset hop set, the
secondary-channel value is constant, and the only thing that changes is the
RF centre channel.
Consequences for the lean 40/80 path:
- Write the centre channel (
rtw_get_center_ch(primary, bw, offset)) — not the primary — to RF 0x18, via the same cached full-register write as 20 MHz. (The full path also writes the centre, becauseset_channel_bwmodestores the centre as_currentChannel.) - Write the secondary-channel registers once, skipping them when unchanged
(
_last_subchnl). - Leave the bandwidth-only registers (
phy_SetRegBW, the BW-constantrRFMODfield,rADC_Buf_Clk,rL1PeakTH,PHY_RF6052SetBandwidth) untouched — they were set by the last full set at this bandwidth and do not change across a same-BW hop. The bandwidth bits already live in the cached RF 0x18 value, so the cached write preserves them.
Result: 40/80 MHz hops at ~1.5 ms, the same as 20 MHz. There is also a
non-cached 40/80 fallback that reuses the full set_channel_bwmode with
_fast_skip_heavy (~153 ms) — kept because it is correct-by-construction and
useful when the cache is cold or for chips without the cached path.
To make hopping usable by any caller without a side channel, the library reads
the radiotap CHANNEL field in send_packet (just as it reads RATE/MCS): the
frequency is mapped to a channel and, if it differs from the current channel, a
FastRetune runs before the descriptor is built (and before the 5 GHz CCK
clamp, so the clamp keys off the new channel). It is a no-op when the field
matches the current channel, so an informational CHANNEL field costs nothing.
The honest cost: unlike MCS, this stalls that one send_packet by the retune
latency — but at ~1.5 ms that is acceptable.
This turns frequency into a per-packet property at the API level while keeping the truth (it is a hardware state change) visible in the cost.
Two independent methods were used; both generalise to any port.
A single wideband receiver (B210 / AD9361 clone) can see all hop channels at
once and so distinguish "the radio retuned" from "the radio dropped frames" — a
narrowband receiver tuned to one channel cannot. tests/hop_rx_probe.py
captures wideband IQ and detects, per channel, the near-field bursts, then
checks the dominant channel cycles through the full hop order. Pitfalls learned,
all relevant to anyone building such a rig:
- Capture must run in compiled code. A Python
recvloop cannot sustain 61.44 MSps and overflows constantly — dropped samples read as false "frame loss". Use a GNU Radiousrp_source → file_sinkflowgraph (or any C++ capturer) and verify zero overflows. - The AD9361 analog bandwidth caps at ~56 MHz, regardless of the FPGA. For channels near the band edge (e.g. 1/6/11 at ±25 MHz around centre) integrate power in a narrow window (±3 MHz) around each carrier rather than the full 20 MHz, and notch DC (a channel parked at the SDR centre sees the receiver's DC offset).
- Some clones deliver a spectrally-mirrored spectrum (apparent channel order reverses, with the centre channel fixed). Accept either direction when matching the hop sequence.
- Don't trust a single on-air reading. Take one clean SDR read per session and compare full-path vs fast-path: if the full path is equally weak, the weakness isn't your change.
When a clean on-air reading isn't available at a given band/BW, prove
the fast path leaves the chip in the same channel/BW register state as the
known-good full path. DumpCanary() reads a fixed set of BB/MAC/RF registers;
tests/hop_parity_check.sh drives the chip to the same target channel via the
full path and then the fast path and diffs the dumps. Crucial details:
- The fast path must emit the canary too. The cached path does not go
through
phy_SwChnlAndSetBwMode8812, so the dumper was factored out intoDumpCanary()and called from the cached branches; otherwise the diff compares the fast run's stale init dump and reports a phantom mismatch. - Classify the expected differences. A self-validating script runs the full path twice as a control to discover run-variant registers (the free-running TSF timer; IQK/RxIQC measurement-jitter outputs), and excludes those plus the tx-power registers the fast path deliberately skips. Anything left that differs is a real channel/BW break. The passing result: every channel/BW register (RF 0x18 centre, fc_area, rRFMOD/SubChnl, ADC-clk, IQK matrix) is bit-identical fast-vs-full.
Hopping is most valuable not as raw redundancy but as a frequency-diversity
code on top of the existing outer FEC (see docs/fused-fec.md). With per-packet
hopping (dwell = 1), the outer code's symbols — emitted in order — land on
channels round-robin, so a block of N symbols is spread across the hop set "for
free": ESI i goes to channel i mod N_ch. A narrowband fade or interferer
that wipes one channel then erases only ⌈N / N_ch⌉ of each block's symbols, and
an MDS (Reed-Solomon) outer code recovers the block as long as
repair_count ≥ ⌈N / N_ch⌉
This converts a catastrophic single-channel outage (which, without hopping,
loses whole blocks regardless of how much parity you add) into a recoverable
erasure. The receiver side is purely a combining problem: an erasure decoder
dedups by (block_id, symbol_index), so feeding it the symbols recovered on
each channel — from N adapters, a wideband SDR, or a lockstep-hopping single
adapter — simply adds them up. tools/precoder/hop_diversity_sim.py and
hop_rx_combine.py (with tests) prove both properties against the real codec.
| Path | 20 MHz | 40/80 MHz |
|---|---|---|
Full set_channel_bwmode |
~275 ms | ~275 ms |
| Skip heavy stages only (no cache) | ~98 ms | ~153 ms |
| Cached fast path | ~1.6 ms | ~1.5 ms |
First hop after a full set is ~50 ms (primes the RF cache + writes the constants once); every subsequent same-band hop is ~1.5 ms.
- Intra-band only; a cross-band hop falls back to the full path (correct, slow).
- TX power is not re-tuned per hop — fine across a band's adjacent channels, but a hop set spanning a wide 5 GHz range may want a periodic full set to refresh per-rate power.
- The receiver still needs N adapters, a wideband SDR, or lockstep hopping to hear all channels — one adapter has one LO on RX too.
IRtlDevice::FastRetune(channel, cache_rf) is the generation-agnostic entry
point (default = the full SetMonitorChannel at the current width/offset), and
every generation overrides it with a lean path built from the tricks above:
| DUT | full path | fast (cached) | USB op cost | fast ops/hop |
|---|---|---|---|---|
| RTL8812AU (Jaguar1) | ~277 ms | ~1.6 ms | ~0.8 ms | 2 |
| RTL8822BU (Jaguar2) | ~65 ms | ~2.5 ms | ~1.0 ms | 2 |
| RTL8821CU (Jaguar2) | ~30 ms | ~0.55 ms | ~0.5 ms | 1 |
| RTL8822CU (Jaguar3) | ~12 ms | ~1.9 ms | ~0.21 ms | 9 |
| RTL8812EU (Jaguar3) | ~12 ms | ~2.4 ms | ~0.27 ms | 9 |
(Median hop.dwell switch_us over a 1/6/11 hop set; per-stage numbers from
DEVOURER_HOP_PROF=1. Every hop microsecond is USB round-trips: one register
read or write is one synchronous control transfer, whose latency is a property
of the chip's EP0 handling — it varies 5× across the family — so the only code
lever is op count. FHSS-soak-validated: 12,000 (8822CU) and 9,000 (8822BU)
consecutive dwell-1 per-packet hops, zero bulk-OUT failures; a kickless hopping
receiver holds a constant catch rate over ~850 retunes.)
Two techniques carried the newer generations to the table above, beyond the original tricks:
- The compose cache (Trick 2 generalised to every masked write). A masked
phy_set_bb_regis a read-modify-write — two control transfers. The fast path primes the full dwords of every register it touches ONCE per epoch (first fast hop, lazily — priming at full-set time would cache values that init calibration then rewrites), composes bit changes in memory, and writes whole dwords: the steady hop is write-only. Correct by construction — the untouched bits are written back as read. - No per-hop RX kick. The vendor
switch_channeltail (Jaguar2's RF 0xb8 toggle, RX-path toggle, IGI toggle — 13 of the 19 transfers the first Jaguar2 port paid) belongs to the full path only: hardware A/B on both Jaguar2 variants, both directions, shows identical hopping-RX catch rate (no decay over hundreds of kickless retunes) and identical hopping-TX delivery without it. A hop needs the channel write, not the state-machine kick.
Jaguar2 (HalJaguar2::fast_retune, both variants): one composed
full-register RF18 write per path collapses the full path's read-modify-write
rounds (three of them on the 8821C's band/channel/BW split) — the 8821CU hop is
a single LSSI write; AGC index, CFO fc, the 8822B RF 0xBE VCO band / ch144
RF 0xDF flag / 2G spur registers and the 8821C 2G CCK filter are composed
writes on bucket change. RFE pins and the 8821C switch-band/RF-set block are
band-keyed and stay untouched. 5/10 MHz narrowband (8821C) survives fast hops
for free: the re-clock state lives in the bandwidth-keyed
0x8ac/0x8c4/0x8c8 block the hop never touches, and the NB RF18 BW bits
equal the 20 MHz encoding, so the cached RF18 write is already correct.
Jaguar3 (RadioManagementJaguar3::fast_retune, both variants): the RF18
window write inside its 3-wire bracket + force-anapar + BB reset every hop —
nine composed writes total; SCO fc, TX DFIR (both nibbles in one dword) and the
AGC table on bucket change. In 5/10 MHz narrowband mode fast hops preserve the
divider re-clock (no per-dwell set_bandwidth_dividers + DAC-FIFO reset), and
the TX-DFIR write uses the NB-owned 0x808[6:4] value on the 8822e.
End-state parity, not step parity — the subtlety the Jaguar3 port hardware-taught: init-time calibration (halrf IQK) rewrites BW-keyed state after the channel set (e.g. it clears the 40 MHz TX_CCK_IND bit in the 8822e's RF 0x1a), and the full path re-imposes the channel-set values on every retune. "Skip BW-keyed work" must therefore mean "re-assert it once per fast-hop epoch", not "never touch it" — the parity oracle defines correct as "the fast path ends in the same register state the full path ends in".
- Find the channel write and the per-stage cost. Instrument the channel set with a per-stage timer. Identify which stages are channel-dependent and which are steady-state (tx-power, thermal). Skip the steady-state stages for an intra-band hop (Trick 1).
- Check whether RF writes are masked read-modify-writes, and whether reads are slow. This is the dominant cost on C-cut Jaguar. If a generation reads the RF register on every masked write — or polls a serial-read-done bit, or sleeps — cache the full register value and write it whole (Trick 2). If the generation offloads channel switching to firmware (an H2C "channel switch" command), that may already be the fast path; measure it.
- Identify the constant-across-hop registers (band-area, spur/ADC-clock, AGC sub-band) and write them only on change (Trick 3).
- For wide BW, find what the secondary-channel registers actually depend on. If, as on Jaguar, they depend on the prime offset rather than the channel number, the wide-BW hop is just the centre-channel write plus a one-time secondary-channel write (Trick 4). Otherwise, reuse the full BW post-set with a skip-heavy flag.
- Gate band changes out of the fast path — they need the front-end reconfiguration and recalibration a hop skips. And check what the generation's calibration rewrites after a channel set: anything it touches that the full path re-imposes must be re-asserted once per fast-hop epoch (the end-state parity rule above).
- Validate two ways: wideband SDR on-air for end-to-end behaviour
(
tests/run_hop_validation.sh), and register-parity against the full path (tests/hop_parity_check.sh— family-aware; all three generations emit the sameDumpCanaryformat from both paths). Always run a full-vs-full control to separate inherent register variance (timers, calibration jitter, live AGC/thermal state) from real parity breaks.
The throughline: a channel switch is mostly work a hop does not need. Strip it to the one register that actually changes, and remove the read that change implies.
The same method applies to a bandwidth switch. IRtlDevice::FastSetBandwidth(bw)
is a lean same-channel toggle between 20 MHz and 5/10 MHz narrowband — and it
collapses even harder than a hop, because narrowband keeps the RF in 20 MHz mode
(so the RF bandwidth register, MAC BW, sub-channel, TX power, and IQK are all
invariant) and the only register that changes is the baseband ADC/DAC re-clock.
On Jaguar1 that is a single dword write (~0.18 ms vs ~90 ms full); Jaguar2/3
replay a slightly larger but still cached, read-free re-clock delta. Same
validation discipline — register parity vs the full path plus on-air decode.
Full treatment in docs/narrowband.md (the "Fast bandwidth switching" section).