From 18ade4649b288ee1b119e3d73269c561f567fa57 Mon Sep 17 00:00:00 2001 From: Joseph <162703152+josephnef@users.noreply.github.com> Date: Sun, 5 Jul 2026 23:28:57 +0300 Subject: [PATCH 1/5] DEVOURER_HOP_PROF: per-stage fast-retune timing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One machine-parseable line per fast hop with per-stage microseconds — the instrument behind the hop-latency work (every hop microsecond is USB control transfers; the profile is what localises them). Zero overhead when unset. Co-Authored-By: Claude Opus 4.8 --- src/HopProf.h | 68 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 src/HopProf.h diff --git a/src/HopProf.h b/src/HopProf.h new file mode 100644 index 0000000..42c25f6 --- /dev/null +++ b/src/HopProf.h @@ -0,0 +1,68 @@ +#ifndef HOP_PROF_H +#define HOP_PROF_H + +#include +#include +#include + +/* DEVOURER_HOP_PROF=1 — per-stage timing inside the fast_retune paths, for + * driving the hop-latency work (FHSS wants every hop microsecond accounted + * for). Each fast hop emits one machine-parseable line: + * + * gen= ch= _us= ... total_us= + * + * Stages are generation-specific labels passed at mark() time. Zero overhead + * when the env is unset beyond one cached getenv + a branch. */ + +namespace devourer { + +class HopProf { +public: + static bool enabled() { + static const bool on = std::getenv("DEVOURER_HOP_PROF") != nullptr; + return on; + } + + HopProf(const char *gen, unsigned channel) : _gen(gen), _ch(channel) { + if (!enabled()) + return; + _t0 = _last = std::chrono::steady_clock::now(); + _len = std::snprintf(_buf, sizeof(_buf), "gen=%s ch=%u", + _gen, _ch); + } + + /* Record the time since the previous mark under `stage`. */ + void mark(const char *stage) { + if (!enabled()) + return; + const auto now = std::chrono::steady_clock::now(); + const long long us = + std::chrono::duration_cast(now - _last) + .count(); + _last = now; + if (_len > 0 && _len < static_cast(sizeof(_buf))) + _len += std::snprintf(_buf + _len, sizeof(_buf) - _len, " %s_us=%lld", + stage, us); + } + + ~HopProf() { + if (!enabled() || _len <= 0) + return; + const long long total = + std::chrono::duration_cast( + std::chrono::steady_clock::now() - _t0) + .count(); + std::fprintf(stderr, "%s total_us=%lld\n", _buf, total); + } + +private: + const char *_gen; + unsigned _ch; + std::chrono::steady_clock::time_point _t0{}, _last{}; + char _buf[512]; + int _len = 0; +}; + +} /* namespace devourer */ + +#endif /* HOP_PROF_H */ From 3617659b510e166e2da2b991b3a13aab5d5a3a14 Mon Sep 17 00:00:00 2001 From: Joseph <162703152+josephnef@users.noreply.github.com> Date: Sun, 5 Jul 2026 23:28:57 +0300 Subject: [PATCH 2/5] Jaguar3 fast hop: write-only compose cache (~3.6 -> ~1.9 ms) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A masked phy_set_bb_reg is a read-modify-write — two USB control transfers — and the hop's cost is purely transfer count x EP0 latency (DEVOURER_HOP_PROF-measured ~0.21 ms/op on the 8822CU). Prime the full dwords of everything the hop touches once per epoch (lazily, on the first fast hop — init calibration rewrites registers after the full set), compose bit changes in memory, and write whole dwords: the steady hop is nine write-only transfers (3-wire bracket, RF18 window A/B, anapar A/B, BB-reset triple), with SCO/DFIR as composed single writes on bucket change (DFIR's two nibbles in one dword). Top bits ride along from the prime, so untouched fields are written back as read — correct by construction. The compose caches invalidate wherever those registers are written outside the channel paths (TXAGC-gate writers, the NB divider recipe). Measured (1/6/11 hop set): 8822CU 3.6 -> 1.9 ms, 8812EU 4.3 -> 2.4 ms median. Register parity PASS on both variants (20/40 MHz + NB shapes); B210 hop order PASS (~100 cycles); 12,000 consecutive dwell-1 per-packet hops with zero bulk-OUT failures. Co-Authored-By: Claude Opus 4.8 --- src/jaguar3/RadioManagementJaguar3.cpp | 100 +++++++++++++++++-------- src/jaguar3/RadioManagementJaguar3.h | 28 +++++-- 2 files changed, 90 insertions(+), 38 deletions(-) diff --git a/src/jaguar3/RadioManagementJaguar3.cpp b/src/jaguar3/RadioManagementJaguar3.cpp index 6f60113..cbe40d8 100644 --- a/src/jaguar3/RadioManagementJaguar3.cpp +++ b/src/jaguar3/RadioManagementJaguar3.cpp @@ -4,6 +4,7 @@ #include #include +#include "HopProf.h" /* DEVOURER_HOP_PROF fast-retune stage timing */ #include "RateDefinitions.h" /* MGN_* rate enum */ #if defined(DEVOURER_HAVE_JAGUAR3_8822E) #include "Hal8822e_PhyTables.h" /* array_mp_8822e_phy_reg_pg */ @@ -43,11 +44,7 @@ void RadioManagementJaguar3::set_channel_bwmode(uint8_t channel, /* Any full channel set rewrites what fast_retune caches (RF18 BW/band bits, * SCO, TX DFIR, AGC tables, RXBB) — invalidate; the caches only ever mirror * fast-path writes (the J1 RadioManagementModule pattern). */ - _rf18_cached = false; - _last_sco = 0xffffffff; - _last_dfir = 0xffffffff; - _last_agc_key = -1; - _rxbb_asserted = false; + invalidate_fast_caches(); _last_channel = channel; @@ -424,6 +421,14 @@ void RadioManagementJaguar3::bb_reset_toggle() { _device.rtw_write32(0x0, r0 | (1u << 16)); } +void RadioManagementJaguar3::invalidate_fast_caches() { + _last_sco = 0xffffffff; + _last_dfir = 0xffffffff; + _last_agc_key = -1; + _rxbb_asserted = false; + _cw_primed = false; +} + bool RadioManagementJaguar3::fast_retune(uint8_t channel, uint8_t channel_offset, ChannelWidth_t bwmode, bool cache_rf) { @@ -435,26 +440,37 @@ bool RadioManagementJaguar3::fast_retune(uint8_t channel, if (channel == _last_channel) return true; /* no-op hop */ + devourer::HopProf prof("j3", channel); uint8_t central, pri; central_and_pri(channel, channel_offset, bwmode, central, pri); - /* RF18: merge the new central into the cached full value (8822c — one - * BB-window read primes the cache; cache_rf=false re-reads per hop for A/B - * measurement) or compose from scratch (8822e — plain write, no read ever). */ + /* Compose-cache prime: every masked write costs a read+write round-trip, so + * read the full dwords of everything the hop touches ONCE per epoch, then + * compose bit changes in memory and write whole dwords — zero per-hop reads + * (Jaguar1's cached-LSSI trick generalised). cache_rf=false re-primes every + * hop (the A/B knob now measures the whole read penalty). */ const bool is_c = (_variant == ChipVariant::C8822C); - uint32_t rf18; - if (is_c) { - if (!cache_rf || !_rf18_cached) { - _rf18_cache = _device.rtw_read32( - static_cast(0x3c00 + (0x18 << 2))) & - 0xfffffu; - _rf18_cached = true; - } - rf18 = rf18_for(central, bwmode, _rf18_cache); - _rf18_cache = rf18; /* refresh so the next hop merges into what we wrote */ - } else { - rf18 = rf18_for(central, bwmode, 0); + if (!cache_rf || !_cw_primed) { + _cw_1c90 = _device.rtw_read32(0x1c90); + _cw_1830 = _device.rtw_read32(0x1830); + _cw_4130 = _device.rtw_read32(0x4130); + _cw_r0 = _device.rtw_read32(0x0); + _cw_c30 = _device.rtw_read32(0xc30); + _cw_808 = _device.rtw_read32(0x808); + _cw_rfwin_a = + _device.rtw_read32(static_cast(0x3c00 + (0x18 << 2))); + _cw_rfwin_b = + _device.rtw_read32(static_cast(0x4c00 + (0x18 << 2))); + _cw_primed = true; } + /* RF18: merge the new central into the cached low 20 bits (8822c — the + * primed read carries the current BW/band bits) or compose from scratch + * (8822e); the window dword's top 12 bits ride along from the prime. */ + const uint32_t rf18 = is_c ? rf18_for(central, bwmode, _cw_rfwin_a) + : rf18_for(central, bwmode, 0); + const uint32_t win_a = (_cw_rfwin_a & 0xfff00000u) | rf18; + const uint32_t win_b = (_cw_rfwin_b & 0xfff00000u) | rf18; + prof.mark("prime"); /* The per-hop core of config_phydm_switch_channel: 3-wire bracket, RF18 on * both paths, force-update-anapar (pushes the RF/analog shadow to the @@ -466,17 +482,23 @@ bool RadioManagementJaguar3::fast_retune(uint8_t channel, * in RF 0x1a) and a fast hop must end in the full path's state; per-variant * order matches the full path (8822c: RXBB then RF18; 8822e: RF18 then * RF 0x1a). */ - _device.phy_set_bb_reg(0x1c90, 1u << 8, 0x0); /* rstb_3wire(false) */ + _device.rtw_write32(0x1c90, _cw_1c90 & ~(1u << 8)); /* rstb_3wire(false) */ if (is_c && !_rxbb_asserted) apply_rxbb(bwmode); - rf_window_write(0x3c00, 0x18, rf18); - rf_window_write(0x4c00, 0x18, rf18); + _device.rtw_write32(static_cast(0x3c00 + (0x18 << 2)), win_a); + _device.rtw_write32(static_cast(0x4c00 + (0x18 << 2)), win_b); if (!is_c && !_rxbb_asserted) apply_rxbb(bwmode); _rxbb_asserted = true; - _device.phy_set_bb_reg(0x1c90, 1u << 8, 0x1); /* rstb_3wire(true) */ - _device.phy_set_bb_reg(0x1830, 1u << 29, 0x1); /* force update anapar (A) */ - _device.phy_set_bb_reg(0x4130, 1u << 29, 0x1); /* force update anapar (B) */ + _device.rtw_write32(0x1c90, _cw_1c90 | (1u << 8)); /* rstb_3wire(true) */ + _device.rtw_write32(0x1830, _cw_1830 | (1u << 29)); /* force anapar (A) */ + _device.rtw_write32(0x4130, _cw_4130 | (1u << 29)); /* force anapar (B) */ + _cw_1c90 |= (1u << 8); + _cw_1830 |= (1u << 29); + _cw_4130 |= (1u << 29); + _cw_rfwin_a = win_a; + _cw_rfwin_b = win_b; + prof.mark("bracket"); /* Channel-keyed constants, written only when their bucket moves (invalidated * by every full set, so the first fast hop writes them once). */ @@ -494,7 +516,8 @@ bool RadioManagementJaguar3::fast_retune(uint8_t channel, const uint32_t sco = sco_for(central); if (sco != _last_sco) { - _device.phy_set_bb_reg(0xc30, 0xfff, sco); + _cw_c30 = (_cw_c30 & ~0xfffu) | sco; + _device.rtw_write32(0xc30, _cw_c30); _last_sco = sco; } @@ -513,14 +536,22 @@ bool RadioManagementJaguar3::fast_retune(uint8_t channel, : 0x3u; const uint32_t dfir = (dfir_msb << 8) | dfir_lsb; if (dfir != _last_dfir) { - _device.phy_set_bb_reg(0x808, 0x700000, dfir_msb); - _device.phy_set_bb_reg(0x808, 0x70, dfir_lsb); + /* Both nibbles composed into one dword write. */ + _cw_808 = (_cw_808 & ~0x700070u) | (dfir_msb << 20) | (dfir_lsb << 4); + _device.rtw_write32(0x808, _cw_808); _last_dfir = dfir; } + prof.mark("consts"); + /* BB reset every hop (the kernel runs it after every switch_channel; the RX - * engine must relatch on the new channel). */ - bb_reset_toggle(); + * engine must relatch on the new channel) — three composed writes from the + * primed dword. */ + _device.rtw_write32(0x0, _cw_r0 | (1u << 16)); + _device.rtw_write32(0x0, _cw_r0 & ~(1u << 16)); + _device.rtw_write32(0x0, _cw_r0 | (1u << 16)); + _cw_r0 |= (1u << 16); + prof.mark("bbrst"); _last_channel = channel; _logger->debug("Jaguar3: fast retune -> ch {} (central {}, RF18=0x{:05x})", @@ -553,6 +584,9 @@ void RadioManagementJaguar3::set_mac_bw_txsc(ChannelWidth_t bw, uint8_t pri) { } void RadioManagementJaguar3::set_tx_power_ref(uint8_t idx, bool zero_diffs) { + /* Writes the 0x1c90 TXAGC gate below — the fast path's composed 0x1c90 + * cache would go stale. */ + invalidate_fast_caches(); /* Override the 8822C TX-power reference on both RF paths (port of * rtw8822c_set_write_tx_power_ref + zeroing the per-rate diffs). idx is a * 7-bit power index (0..0x7f); higher = more power. Each txagc write must be @@ -622,6 +656,7 @@ static bool pg_addr_to_rates(uint32_t addr, std::array &rates) { void RadioManagementJaguar3::apply_power_by_rate_8822e( uint8_t channel, uint8_t ref_a, uint8_t ref_b, bool skip_path_b_ofdm_ref) { + invalidate_fast_caches(); /* writes the 0x1c90 TXAGC gate below */ #if defined(DEVOURER_HAVE_JAGUAR3_8822E) /* Port of the phy_reg_pg (power-by-rate) apply that devourer's table walk * skips. The 8822e TXAGC is ref + per-rate diff: the OFDM/HT/VHT reference @@ -698,6 +733,9 @@ void RadioManagementJaguar3::apply_power_by_rate_8822e( } void RadioManagementJaguar3::set_bandwidth_dividers(ChannelWidth_t bwmode) { + /* Writes 0x808 (8822e shaping) + the BB-reset word — stale-proof the fast + * path's composed caches. */ + invalidate_fast_caches(); /* Narrowband baseband underclock — the Jaguar3-only payoff. Applies ONLY the * clock-divider / small-BW / RX-DFIR delta from config_phydm_switch_bandwidth * _8822c / _8822e, on top of an already-tuned channel (this re-clocks the diff --git a/src/jaguar3/RadioManagementJaguar3.h b/src/jaguar3/RadioManagementJaguar3.h index c88047d..4f6fdd5 100644 --- a/src/jaguar3/RadioManagementJaguar3.h +++ b/src/jaguar3/RadioManagementJaguar3.h @@ -40,11 +40,12 @@ class RadioManagementJaguar3 { * narrowband re-clock owns 0x808 on the 8822e), so fast hops preserve the * narrowband clocking without re-running the divider recipe. * - * cache_rf=true reuses the cached full RF18 value (primed by one BB-window - * read on the first fast hop; the 8822e composes RF18 from scratch and - * needs no cache/read at all). Returns false — chip untouched — when the - * hop crosses the 2.4/5 GHz boundary or the radio was never tuned; the - * caller falls back to the full set_channel_bwmode. */ + * cache_rf=true runs from the compose cache (all touched dwords primed by + * one read each on the first fast hop; thereafter the hop is write-only); + * cache_rf=false re-primes every hop (the A/B knob measuring the read + * penalty). Returns false — chip untouched — when the hop crosses the + * 2.4/5 GHz boundary or the radio was never tuned; the caller falls back to + * the full set_channel_bwmode. */ bool fast_retune(uint8_t channel, uint8_t channel_offset, ChannelWidth_t bwmode, bool cache_rf); @@ -146,11 +147,24 @@ class RadioManagementJaguar3 { * set_channel_bwmode runs (it rewrites all of them); refreshed by the fast * path's own writes, so they only ever mirror fast-path state — the J1 * RadioManagementModule pattern. */ - uint32_t _rf18_cache = 0; /* 8822c full RF18 (BB-window read, 20-bit) */ - bool _rf18_cached = false; uint32_t _last_sco = 0xffffffff; uint32_t _last_dfir = 0xffffffff; /* packed (0x808[22:20]<<8)|[6:4] */ int _last_agc_key = -1; /* band/sub-band bucket + bw20 flag */ + + /* Compose cache — the FHSS hop-latency trick generalised from Jaguar1's + * cached LSSI write: every masked phy_set_bb_reg is a read+write (two USB + * control transfers, ~0.2-1 ms each depending on the chip's EP0 latency), so + * the fast path primes the full dwords of the registers it touches ONCE per + * epoch and thereafter composes the bit changes in memory and writes whole + * dwords — zero per-hop reads, correct by construction (untouched bits are + * written back as read). Invalidated with the rest of the fast-path caches, + * plus wherever these registers are written outside the channel paths + * (set_tx_power_ref / apply_power_by_rate write the 0x1c90 TXAGC gate). */ + bool _cw_primed = false; + uint32_t _cw_1c90 = 0, _cw_1830 = 0, _cw_4130 = 0, _cw_r0 = 0; + uint32_t _cw_c30 = 0, _cw_808 = 0; + uint32_t _cw_rfwin_a = 0, _cw_rfwin_b = 0; /* 0x3c60/0x4c60 full dwords */ + void invalidate_fast_caches(); /* BW-keyed RXBB state, re-asserted on the first fast hop of each epoch: * the init-time halrf calibration rewrites it after the channel set * (hardware-observed on the 8812EU — IQK clears the 40 MHz TX_CCK_IND bit From c85de771ae66e36a9a05432deed348eff64ca6f6 Mon Sep 17 00:00:00 2001 From: Joseph <162703152+josephnef@users.noreply.github.com> Date: Sun, 5 Jul 2026 23:29:16 +0300 Subject: [PATCH 3/5] Jaguar2 fast hop: compose cache + drop the per-hop RX kick (18 -> 2.5 ms) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first Jaguar2 fast path spent 17 of its 19 USB transfers on the vendor switch_channel tail (RF 0xb8 toggle, RX-path toggle, IGI toggle — each masked write a read+write round-trip, at the T3U's ~1 ms EP0 latency). Hardware A/B on both variants, both directions, shows a hop does not need that kick: a hopping receiver catches a parked beacon at identical per-dwell medians with and without it (no decay over ~850 kickless retunes, zero dead dwells), and hopping-TX delivery is unchanged. The kick stays in the full path only. The rest goes write-only via the same compose cache as Jaguar1/Jaguar3: RF18 primed once and composed per hop (the 8821CU hop is a single LSSI write), AGC/fc/RF-0xBE as composed writes on bucket change. Measured (1/6/11 hop set): 8822BU 18.2 -> 2.5 ms, 8821CU 5.6 -> 0.55 ms median. Register parity PASS both variants (2.4/5 GHz); B210 hop order PASS (~100 cycles); 9,000 consecutive dwell-1 per-packet hops with zero bulk-OUT failures. Co-Authored-By: Claude Opus 4.8 --- src/jaguar2/HalJaguar2.cpp | 65 ++++++++++++++++++++-------------- src/jaguar2/HalJaguar2.h | 34 +++++++++++------- src/jaguar2/RtlJaguar2Device.h | 13 ++++--- 3 files changed, 66 insertions(+), 46 deletions(-) diff --git a/src/jaguar2/HalJaguar2.cpp b/src/jaguar2/HalJaguar2.cpp index ce13b63..b70095b 100644 --- a/src/jaguar2/HalJaguar2.cpp +++ b/src/jaguar2/HalJaguar2.cpp @@ -12,6 +12,7 @@ #if defined(DEVOURER_HAVE_JAGUAR2_8821C) #include "Hal8821c_TxpwrLmt.h" /* generated: hal8821c_txpwr_lmt() WW-min limits */ #endif +#include "HopProf.h" /* DEVOURER_HOP_PROF fast-retune stage timing */ #include "PhyTableLoader.h" namespace jaguar2 { @@ -465,7 +466,7 @@ void HalJaguar2::set_channel_bw(uint8_t channel, uint8_t bw, uint8_t rfe_type, /* Any full channel set rewrites what fast_retune caches (RF18 band/BW bits, * AGC/fc buckets, RF 0xBE, spur/CCK-filter regs) — invalidate; the caches * only ever mirror fast-path writes. */ - _rf18_cached = false; + _cw_primed = false; _last_agc_bucket = -1; _last_fc = 0xffffffff; _last_rf_be = -1; @@ -686,19 +687,26 @@ bool HalJaguar2::fast_retune(uint8_t channel, uint8_t bw, if (channel == _last_tuned_ch) return true; /* no-op hop */ + devourer::HopProf prof("j2", channel); const uint8_t cch = central_ch(channel, bw, primary_ch_idx); const bool g2 = cch <= 14; const bool c8821 = (_variant == ChipVariant::C8821C); const bool r2t2r = _ver.rf_2t2r != 0; - /* RF18: one cached full-register write replaces the full path's - * read-modify-write round(s) (three of them on the 8821C). One direct-window - * read primes the cache; the band bits ([16]/[8], band-keyed) and the BW bits - * ([11:10], bandwidth-keyed) ride along in the cached value. */ - if (!cache_rf || !_rf18_cached) { + /* Compose-cache prime — one read per touched register per epoch; the hop + * itself is then write-only. RF18's band bits ([16]/[8], band-keyed) and BW + * bits ([11:10], bandwidth-keyed) ride along in the cached value; the AGC / + * fc / RF 0xBE dwords carry their untouched neighbour bits the same way. + * cache_rf=false re-primes every hop (the A/B read-penalty knob). */ + if (!cache_rf || !_cw_primed) { _rf18_cache = rf_read(0, 0x18); - _rf18_cached = true; + _cw_agc = _device.rtw_read32(c8821 ? 0x0c1c : 0x0958); + _cw_fc = _device.rtw_read32(0x0860); + if (!c8821) + _cw_rfbe = rf_read(0, 0xbe); + _cw_primed = true; } + prof.mark("prime"); uint32_t rf18 = _rf18_cache & ~((1u << 18) | (1u << 17) | 0xffu); rf18 |= cch; @@ -718,11 +726,14 @@ bool HalJaguar2::fast_retune(uint8_t channel, uint8_t bw, else if (cch >= 149) agc_bucket = 3; if (agc_bucket >= 0 && agc_bucket != _last_agc_bucket) { - if (c8821) - _device.phy_set_bb_reg(0x0c1c, 0x00000F00, - static_cast(agc_bucket)); - else - _device.phy_set_bb_reg(0x0958, 0x1f, static_cast(agc_bucket)); + if (c8821) { + _cw_agc = (_cw_agc & ~0x00000F00u) | + (static_cast(agc_bucket) << 8); + _device.rtw_write32(0x0c1c, _cw_agc); + } else { + _cw_agc = (_cw_agc & ~0x1Fu) | static_cast(agc_bucket); + _device.rtw_write32(0x0958, _cw_agc); + } _last_agc_bucket = agc_bucket; } @@ -738,7 +749,8 @@ bool HalJaguar2::fast_retune(uint8_t channel, uint8_t bw, else if (cch >= 118 && cch <= 177) fc = 0x412; if (fc != 0xffffffff && fc != _last_fc) { - _device.phy_set_bb_reg(0x0860, 0x1ffe0000, fc); + _cw_fc = (_cw_fc & ~0x1ffe0000u) | (fc << 17); + _device.rtw_write32(0x0860, _cw_fc); _last_fc = fc; } @@ -747,7 +759,9 @@ bool HalJaguar2::fast_retune(uint8_t channel, uint8_t bw, * VCO), the ch144 RF 0xDF[18] flag, and the 2G spur registers. */ const uint8_t rf_be = rf_be_for_8822b(cch); if (rf_be != 0xff && rf_be != _last_rf_be) { - rf_set(0, 0xbe, (1u << 17) | (1u << 16) | (1u << 15), rf_be); + _cw_rfbe = (_cw_rfbe & ~0x38000u) | + (static_cast(rf_be) << 15); + rf_write(0, 0xbe, _cw_rfbe); _last_rf_be = rf_be; } const int df18 = (cch == 144) ? 1 : 0; @@ -803,23 +817,20 @@ bool HalJaguar2::fast_retune(uint8_t channel, uint8_t bw, } } + prof.mark("consts"); rf_write(0, 0x18, rf18); if (!c8821 && r2t2r) rf_write(1, 0x18, rf18); _rf18_cache = rf18; /* refresh so the next hop merges into what we wrote */ - - /* Per-hop RX kick — the vendor switch_channel tail: 8822B RF read-error - * toggle + RX-path toggle (leave the RX dead-zone), then the IGI toggle on - * both variants. */ - if (!c8821) { - rf_set(0, 0xb8, (1u << 19), 0); - rf_set(0, 0xb8, (1u << 19), 1); - const uint8_t rx_ant = r2t2r ? 0x3 : 0x1; - _device.phy_set_bb_reg(0x0808, 0xff, 0x0); - _device.phy_set_bb_reg(0x0808, 0xff, - static_cast(rx_ant | (rx_ant << 4))); - } - igi_toggle(); + prof.mark("rf18"); + + /* No per-hop RX kick. The vendor switch_channel tail (8822B RF 0xb8 + + * RX-path toggles, IGI toggle) stays in the full path, but a hop does not + * need it — hardware-measured on both variants, both directions: a hopping + * receiver catches a parked beacon at the same per-dwell rate with and + * without the kick (8822BU/8821CU: identical medians, no decay over ~70 + * kickless retunes), and hopping-TX delivery is unchanged. The kick was + * >80% of the hop's USB round-trips. */ _last_tuned_ch = channel; _logger->debug("Jaguar2: fast retune -> ch {} (central {}, RF18=0x{:05x})", diff --git a/src/jaguar2/HalJaguar2.h b/src/jaguar2/HalJaguar2.h index aaea5b5..293d2de 100644 --- a/src/jaguar2/HalJaguar2.h +++ b/src/jaguar2/HalJaguar2.h @@ -76,17 +76,21 @@ class HalJaguar2 { /* Lean intra-band, same-bandwidth hop retune — the Jaguar2 FastRetune core * (see docs/frequency-hopping.md), variant-dispatched like set_channel_bw. - * Only the per-hop essentials run: one cached full-register RF18 write (a - * single direct-window read primes the cache, collapsing the full path's - * read-modify-write rounds), the channel-keyed constants (AGC table index, - * CFO-tracking fc, 8822B RF 0xBE VCO band / ch144 RF 0xDF flag / 2G spur - * regs, 8821C 2G CCK filter) written only when their bucket moves, and the - * per-hop RX kick (8822B RF 0xb8 toggle + RX-path toggle; both variants the - * IGI toggle). Everything bandwidth-keyed (0x8ac/0x8c4 block, RX DFIR, CCA - * thresholds) and band-keyed (RFE pins, 8821C switch-band/RF-set block) - * stays untouched — set by the last full set at this BW/band. Returns false - * (chip untouched) on a band change or when the radio was never tuned; the - * caller falls back to the full set_channel_bw. */ + * Only the per-hop essentials run: one cached full-register RF18 write per + * path (a compose cache primed on the first fast hop collapses the full + * path's read-modify-write rounds — the steady hop is write-only), plus the + * channel-keyed constants (AGC table index, CFO-tracking fc, 8822B RF 0xBE + * VCO band / ch144 RF 0xDF flag / 2G spur regs, 8821C 2G CCK filter) as + * composed writes only when their bucket moves. The vendor switch_channel + * tail (RF 0xb8 / RX-path / IGI toggles) stays in the full path only — a + * hop does not need it (hardware-measured on both variants, both + * directions: identical hopping-RX catch rate and hopping-TX delivery with + * and without, no decay over repeated kickless retunes). Everything + * bandwidth-keyed (0x8ac/0x8c4 block, RX DFIR, CCA thresholds) and + * band-keyed (RFE pins, 8821C switch-band/RF-set block) stays untouched — + * set by the last full set at this BW/band. Returns false (chip untouched) + * on a band change or when the radio was never tuned; the caller falls back + * to the full set_channel_bw. */ bool fast_retune(uint8_t channel, uint8_t bw, uint8_t primary_ch_idx, bool cache_rf); @@ -197,7 +201,13 @@ class HalJaguar2 { * fast path (0 = never tuned / unknown band). */ uint8_t _last_tuned_ch = 0; uint32_t _rf18_cache = 0; - bool _rf18_cached = false; + /* Compose cache (the Jaguar1 cached-LSSI trick generalised): the dwords the + * fast path touches are primed by one read each on the first fast hop of an + * epoch; thereafter bit changes are composed in memory and written as whole + * dwords — zero per-hop reads (every masked write is otherwise a read+write + * USB round-trip). _cw_rfbe is the 8822B RF 0xBE 20-bit word. */ + bool _cw_primed = false; + uint32_t _cw_agc = 0, _cw_fc = 0, _cw_rfbe = 0; int _last_agc_bucket = -1; uint32_t _last_fc = 0xffffffff; int _last_rf_be = -1; diff --git a/src/jaguar2/RtlJaguar2Device.h b/src/jaguar2/RtlJaguar2Device.h index ea359c1..1486d9d 100644 --- a/src/jaguar2/RtlJaguar2Device.h +++ b/src/jaguar2/RtlJaguar2Device.h @@ -48,13 +48,12 @@ class RtlJaguar2Device : public IRtlDevice { void StopRxLoop() override { _rx_stop = true; } void SetMonitorChannel(SelectedChannel channel) override; /* Lean frequency-hop retune (the Jaguar2 port of the Jaguar1 FastRetune — - * see docs/frequency-hopping.md): cached RF18 write + on-change channel - * constants + the RX kick, via HalJaguar2::fast_retune; falls back to the - * full set_channel_bw on a band change. Same concurrency model as - * SetMonitorChannel: no lock — the DIG thread (RX sessions only) may - * interleave IGI writes with the retune's IGI toggle, which is benign - * (single-register writes; DIG re-converges), and TX-only sessions have no - * concurrent register writer. cache_rf=false re-reads RF18 per hop (A/B + * see docs/frequency-hopping.md): composed cached writes for RF18 + the + * on-change channel constants, via HalJaguar2::fast_retune; falls back to + * the full set_channel_bw on a band change. Same concurrency model as + * SetMonitorChannel: no lock — the DIG thread (RX sessions only) writes its + * own registers (IGI/FA), and TX-only sessions have no concurrent register + * writer. cache_rf=false re-primes the compose cache per hop (A/B * measurement). */ void FastRetune(uint8_t channel, bool cache_rf) override; void InitWrite(SelectedChannel channel) override; From b1639d01a2395e96cdc6ff0389a1f9de7449e4bc Mon Sep 17 00:00:00 2001 From: Joseph <162703152+josephnef@users.noreply.github.com> Date: Sun, 5 Jul 2026 23:29:16 +0300 Subject: [PATCH 4/5] hop parity: exclude the 8822C's floating RF 0x1a MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RF 0x1a is not written by any channel path on the 8822C (it is the 8822E's RXBB register) and its bit17 floats — observed flipping between two full-path control runs. Static exclusion for the C PIDs only; on the E it is real RXBB config and stays checked. Co-Authored-By: Claude Opus 4.8 --- tests/hop_parity_check.sh | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/hop_parity_check.sh b/tests/hop_parity_check.sh index acb6b3f..ea2f7b8 100755 --- a/tests/hop_parity_check.sh +++ b/tests/hop_parity_check.sh @@ -110,6 +110,14 @@ else TXPWR='' IQK='' LIVE='' + # 8822C only: RF 0x1a is not written by any channel path on the C (it is + # the 8822E's RXBB register) and its bit17 floats — observed flipping + # between two full-path runs. Excluded statically since the full-vs-full + # control catches it only probabilistically. On the E it is REAL RXBB + # config and stays checked. + case "$TX_PID" in + 0xc812|0xc82c|0xc82e) LIVE='0x1a' ;; + esac fi echo; echo "== fast-vs-full diff (< full > fast) ==" From c9a2d989686c1b8aa3cbe6e9516c1c7e4041a06a Mon Sep 17 00:00:00 2001 From: Joseph <162703152+josephnef@users.noreply.github.com> Date: Sun, 5 Jul 2026 23:29:16 +0300 Subject: [PATCH 5/5] =?UTF-8?q?docs:=20hop-latency=20results=20=E2=80=94?= =?UTF-8?q?=20compose=20cache,=20kick=20finding,=20new=20timings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ports table gains the per-DUT USB-op cost and op count (the whole cost model: transfer count x EP0 latency, which varies 5x across the family), the two techniques that carried the newer generations to 0.55-2.5 ms (the compose cache generalising the cached-write trick to every masked register write; the hardware-measured finding that a hop needs no per-hop RX kick), and the FHSS soak results. Co-Authored-By: Claude Opus 4.8 --- CLAUDE.md | 12 ++++--- docs/frequency-hopping.md | 68 +++++++++++++++++++++++++-------------- 2 files changed, 51 insertions(+), 29 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index cdbb71c..259df71 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -305,10 +305,14 @@ that exercise the salvage path. `IRtlDevice::FastRetune(channel)` is a lean intra-band, same-bandwidth channel retune every generation implements (default = the full `SetMonitorChannel`): it does the RF channel switch only, skipping the steady-state work a hop doesn't -need, writes the channel RF register from a cached value, and re-writes the -channel-keyed constants only when their bucket changes. Measured medians: -8812AU ~251→1.6 ms, 8822BU ~62→18 ms, 8821CU ~29→5.6 ms, 8822CU/8812EU -~11→3-4 ms. It falls back to the full path on a band change; on Jaguar3 it +need, and runs write-only from a compose cache (full dwords primed once per +epoch, bit changes composed in memory — a masked register write is otherwise a +read+write USB round-trip, and hop cost is purely transfer count × the chip's +EP0 latency). Measured medians: 8812AU ~277→1.6 ms, 8822BU ~65→2.5 ms, 8821CU +~30→0.55 ms, 8822CU/8812EU ~12→2-2.4 ms — FHSS-grade on every generation +(soak: 12k consecutive dwell-1 per-packet hops, zero TX stalls; kickless +hopping RX holds a constant catch rate). `DEVOURER_HOP_PROF=1` emits per-stage +hop timing. It falls back to the full path on a band change; on Jaguar3 it serializes on the coex thread's register mutex (the sanctioned in-session hop) and preserves the 5/10 MHz narrowband dividers across hops. `send_packet` on all three generations also honours a radiotap `CHANNEL` field, so hopping is diff --git a/docs/frequency-hopping.md b/docs/frequency-hopping.md index 18b753d..aae02d0 100644 --- a/docs/frequency-hopping.md +++ b/docs/frequency-hopping.md @@ -298,33 +298,51 @@ constants once); every subsequent same-band hop is ~1.5 ms. 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) | fast (no cache) | -|-----|-----------|---------------|-----------------| -| RTL8812AU (Jaguar1) | ~251 ms | **~1.6 ms** | ~99 ms | -| RTL8822BU (Jaguar2) | ~62 ms | **~18 ms** | ~20 ms | -| RTL8821CU (Jaguar2) | ~29 ms | **~5.6 ms** | ~5.6 ms | -| RTL8822CU (Jaguar3) | ~11 ms | **~3.6 ms** | ~3 ms | -| RTL8812EU (Jaguar3) | ~11 ms | **~4.3 ms** | ~3 ms | - -(Median `switch_us` over a 1/6/11 hop set. The newer generations' -full paths are already far cheaper than Jaguar1's — no 20 ms C-cut read sleeps — -so their win comes from transfer-count reduction, and `cache_rf=false` costs -only the one extra RF18 read.) - -**Jaguar2** (`HalJaguar2::fast_retune`, both variants): one cached full-register -RF18 write collapses the full path's read-modify-write rounds (three of them on -the 8821C's band/channel/BW split); AGC index, CFO fc, the 8822B RF 0xBE VCO -band / ch144 RF 0xDF flag / 2G spur registers and the 8821C 2G CCK filter are -written on bucket change; the per-hop RX kick (8822B RF 0xb8 + RX-path toggles, -IGI toggle on both variants) runs every hop. RFE pins and the 8821C -switch-band/RF-set block are band-keyed and stay untouched. +| 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 `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_reg` is 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_channel` tail (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. **Jaguar3** (`RadioManagementJaguar3::fast_retune`, both variants): the RF18 -write inside its 3-wire bracket + force-anapar + BB reset every hop; SCO fc, -TX DFIR and the AGC table on bucket change; the 8822c RF18 read-modify-write -becomes a lazily-primed cached write (primed on the first fast hop, after -init-time IQK has settled RF18 — priming at full-set time would cache a value -calibration then rewrites). In 5/10 MHz narrowband mode fast hops preserve the +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.