# CLAUDE.md This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. It holds **cross-cutting** facts only. Deep subtree facts live in nested `CLAUDE.md` files, auto-loaded when working there: `src/{jaguar1,jaguar2,jaguar3,kestrel,rtl8733b}/` for per-generation registers, descriptors and per-chip mechanisms; `src/hopset/` for keyed FHSS and the adaptive hopset; `src/chanmig/` for channel migration. Add new facts to the narrowest file that covers them. Two standing rules for this file: never duplicate what a header already doc-comments (point at `src/DeviceConfig.h`, `src/TxPower.h`, `src/AdapterCaps.h` instead), and never quote a favourable measurement without its adversarial counterpart in the same breath. ## What this is Userspace re-implementation of Realtek's USB Wi-Fi drivers (11n RTL873x, 11ac RTL88xx and 11ax RTL8852 families) — speaks to the chip directly via libusb instead of a kernel module. Static library `devourer` (CMake target) + example executables under `examples/` (`rxdemo` and `txdemo` are the canonical RX/TX demos). Used by the OpenIPC project for long-range video links. Five hardware backends, each behind its own self-contained HAL, dispatched at construction from the `SYS_CFG2` chip-id (Kestrel: PID-first): - **Jaguar1** (`src/jaguar1/`): RTL8812AU (2T2R reference), RTL8811AU (1T1R cut, rides the 8812 path), RTL8814AU (4T4R RF / 3-SS baseband — a failed FW-boot poll of its 3081 MCU means dead TX while RX still works), RTL8821AU (1T1R + BT). 5/10 MHz narrowband on the 8812AU/8811AU/8814AU (bench-characterized TX+RX); the 8821A is excluded and falls back to 20 MHz. See `docs/narrowband.md`. - **Jaguar2** (`src/jaguar2/`): RTL8822BU / RTL8812BU (chip-id `0x0a`) and RTL8811CU / RTL8821CU (chip 8821C, chip-id `0x09`). A HalMAC + phydm hybrid. RX + TX on 2.4/5 GHz at 20/40/80 MHz plus 5/10 MHz narrowband on both variants; 5 MHz at 5 GHz is CFO-limited. - **Jaguar3** (`src/jaguar3/`): rtl8822c (RTL8812CU/8822CU, chip-id `0x13`) and rtl8822e (RTL8812EU/8822EU, chip-id `0x17`). 5/10 MHz narrowband, 80 MHz (incl. a 40-in-80 frame), halrf calibration. Sustained 5 GHz TX needs the coex runtime thread (started in `InitWrite`) — without it the combo chip's coex firmware silences the antenna. 8822e constraints: `docs/8822e-quirks.md`. - **Kestrel** (`src/kestrel/`): the Wi-Fi 6 / 802.11ax generation — RTL8852BU/8832BU (variant C8852B) and RTL8852CU/8832CU (C8852C), one HAL serving both dies on all platforms (MSVC included). Two planes: hand-ported mac_ax C++ + Realtek's halbb/halrf C compiled verbatim (`hal/hal{bb,rf}/g6/`). Dispatched PID-first (`kestrel/KestrelUsbIds.h`) — the 0x00FC byte is not a chip-id on AX silicon. Blacklist rtw89 when testing: a kernel driver pre-initializing the chip masks the cold-boot path. On-air-validated: monitor RX (both dies, 2.4/5 GHz), TX injection (legacy/HT/VHT/HE + HE ER SU/DCM — `docs/he-extended-range.md`), 5/10/20/40/80 MHz on both dies + 160 MHz on the 8852C only; 6 GHz TX tops out at 80 MHz (the 6G+160 TX-enable path is un-ported). TX power is a fixed BB dBm (`DEVOURER_TX_PWR`, whole dBm here). The 8852A-family (RTL8832AU) is deliberately excluded. Quirks: `docs/8852c-quirks.md`. - **RTL8733B** (`src/rtl8733b/`): the 802.11n generation — RTL8731BU/RTL8733BU (chip-id `0x16`), a HALMAC 87xx part, not a Jaguar variant: its own power, firmware, MAC, descriptor and PHY paths. 1T1R, and the advertised surface is only what an independent witness decoded — legacy OFDM + HT MCS0-7, BCC, 20/40 MHz on 2.4/5 GHz, plus long-preamble CCK on 2.4 GHz at 20 MHz, plus 10 MHz narrowband (5 MHz refused — `src/rtl8733b/CLAUDE.md`). Everything the backend has not ported (TSF/beacons, A-MPDU, CCX/`tx.report`, the flat-index and per-rate TX-power knobs) falls through to `IRadio`/`IRtlRadio`'s not-ported defaults rather than being faked, so read the base class before assuming a cross-generation feature below applies here. `FastRetune` IS ported (intra-band, TSSI kept live — `src/rtl8733b/CLAUDE.md`). SGI, LDPC, STBC, VHT and HE are refused outright. Scope, one-unit validation record and the deferred matrix: `docs/rtl8733b.md`. Naming traps: **RTL8821AU is Jaguar1** (not Jaguar2, despite the Jaguar2 RTL8821C's similar name); RTL8822**B**U (Jaguar2) ≠ RTL8822**C**U (Jaguar3); the TP-Link TX50UH is RTL8832**C**U (8852C-family) despite lab lore calling it 8832AU, while the TX20U **Nano** is RTL8852BU; RTL8733B**U** is USB and supported, RTL8733B**S** is the SDIO sibling and has no transport here. Full chip / bench-throughput table: README **Supported hardware**. **PCIe** (`DEVOURER_PCIE=ON`, Linux-only, default OFF): the RTL8821CE — the PCIe sibling of the 8821CU — rides the same Jaguar2 HAL through a vfio-pci transport (`src/PcieTransport.{h,cpp}`: BAR2 MMIO registers over the same 0x0000..0xFFFF space the USB vendor-control path addresses, 88xx buffer-descriptor DMA rings for TX/RX). USB and PCIe are independent transports behind `devourer::ITransport` (`src/Transport.h`); the bus-neutral `RtlAdapter` the HALs hold forwards to whichever it was built with. The few genuinely bus-specific bring-up steps gate on `is_usb()` (PCIe power-seq rows, PQ map, no USB RX-agg, no DLFW 512-pad) or ride `hci_setup()` (pre-power TRX ring programming, no-op on USB). Factory: `WiFiDriver::CreateRadioPcie(PcieTransport::Open(bdf, logger))` — the caller owns vfio like it owns libusb. Demos: `DEVOURER_PCIE_BDF=0000:01:00.0` on rxdemo and txdemo; `pcieprobe ` validates the layers bottom-up. Bind/restore: `tests/pcie_vfio_bind.sh` — driver_override, **not** new_id, because of the in-tree rtw88 auto-probe race. Validation: `sudo python3 tests/pcie_rx_smoke.py` against a vfio-bound 8821CE. ## Build ```sh cmake -S . -B build cmake --build build -j ``` libusb-1.0 via `pkg-config` (Linux/macOS) or vcpkg (`VCPKG_ROOT`, Windows). The vendor kernel drivers under `reference/` are git submodules (fork rationale + layout in `reference/README.md`); `git submodule update --init --recursive` before running `tools/extract_*.py` or the hardware-testing kernel cells. Per-chip options, all default ON: `DEVOURER_JAGUAR1`, `DEVOURER_8814` (requires JAGUAR1), `DEVOURER_JAGUAR2_8822B`, `DEVOURER_JAGUAR2_8821C`, `DEVOURER_JAGUAR3_8822C`, `DEVOURER_JAGUAR3_8822E`, `DEVOURER_8733B`, `DEVOURER_KESTREL_8852B`, `DEVOURER_KESTREL_8852C`. `DEVOURER_PCIE` (default OFF, Linux-only, requires JAGUAR2_8821C) adds the vfio-pci transport + `pcieprobe`; OFF builds are byte-identical to before it existed. Turning groups off drops their firmware blobs + PHY tables (an 8812AU-only `rxdemo` is ~1.6 MB vs ~6.3 MB all-on — the verbatim-vendored Kestrel halbb/halrf plane dominates the difference). Configure fails on no-chip-selected or 8814-without-JAGUAR1. Each group exports a PUBLIC `DEVOURER_HAVE_*` define; sites referencing a dropped group sit behind `#if defined(DEVOURER_HAVE_*)`, and the factory returns `nullptr` (logs) for a chip whose support isn't built. `DEVOURER_SANITIZE=address|address+undefined|thread` (default off) builds the library, demos and selftests instrumented; the vendored halbb/halrf C is exempted from UBSan (Realtek's sources are full of unaligned loads and signed shifts — noise we would never act on). MSVC has AddressSanitizer only and rejects the other values rather than silently degrading. The demos are worth running under it on hardware, not just `ctest`: the lifetime bugs it finds (teardown, in-flight URBs) need a real device — `tests/tx_teardown_asan.sh` (max-duty TX killed mid-flight, incl. a real VBUS-cut wedge) and `tests/teardown_gen_sanity.sh` (every plugged generation init + teardown). CI (`.github/workflows/cmake-multi-platform.yml`): GCC/Clang/MSVC × Ubuntu/macOS/Windows matrix, a `build-mingw` job, a `build-configs` matrix over each per-chip subset, `build-sanitizers` (ASan+UBSan `ctest`), and `reject-bad-configs` for the invalid option combos. `ctest` runs in every job (headless selftests — math guards + the `stream_stdin_binary` framing round-trip). Hardware testing is out-of-band. ## Hardware testing `tests/regress.py` — 2×2 TX/RX matrix (devourer vs. kernel driver) over two plugged adapters. Run after building. ```sh sudo python3 tests/regress.py # local kernel cells sudo python3 tests/regress.py \ --vm-name devourer-testrig --vm-ssh @ # pinned-kernel VM ``` VM mode (provision once with `tests/setup_vm.sh`) is required for chips whose vendor driver doesn't build on bleeding-edge kernels — notably the RTL8814AU. Default channel 6; pass `--channel 36` / `--channel 100` for 5 GHz — a single-band matrix is not comprehensive. Caveat: with the 8814 as TX, kernel-TX cells read 0 on every channel (`aircrack-ng/88XXau` doesn't emit host-pushed *beacon* injection) — judge 8814 TX by the devourer-TX cells. Layered modes: `--full-matrix` (every ordered DUT pair × 4 driver combos), `--encoding-matrix --tx-pid --rx-pid` (radiotap encoding combos; the kernel-TX rows are not authoritative for LDPC/STBC — that driver strips those bits — so truth-table runs add `--modes devourer:devourer`; per-cell `rx.txhit` events carry the decoded `rate`/`ldpc`/`stbc` as proof of what flew), `--sniffer-iface IFACE` (3rd-adapter capture, intended for AR9271). `--keep-logs` puts per-cell logs at `/tmp/devourer-regress-last/`. Full semantics: `tests/README.md`. Startup-time benchmarking: `tests/bench_init.py` (per-stage `init.timing` events from `src/InitTimer.h`; methodology + numbers in `docs/performance-tuning.md`). On-air TX throughput: measure **Mbps via SDR duty × PHY rate** (`tests/bench_onair.py`), never monitor-sniffer frame counts — a sensitive receiver decodes weak frames and masks a real drop. Keep a known-good control adapter, re-check it each session, and take one clean SDR read per session (a second back-to-back `sdr_duty` read can fail to reacquire and report ~0). Suspect a DUT itself (deaf with a green init, chronic FW-boot fails): `build/doctor` grades adapter health — EFUSE read-stability ×N, fw-boot, RX smoke → HEALTHY/SUSPECT/FAILING in the exit code (EFUSE stability is `IRtlRadio`-only; the other legs are `IRadio`); `tests/adapter_doctor_cold.sh` wraps it in per-rep VBUS cold + a vouched flood for a definitive verdict (`docs/adapter-doctor.md`). Two cold-init traps it encodes: the in-tree rtw88 modules auto-probe (and fw-download into) every Realtek dongle at each enumeration — `modprobe -r` does NOT survive re-enumeration, temp-blacklist instead; and `authorized`-toggle "cold" leaves chip state (real VBUS cold via `REGRESS_VBUS_MAP` / uhubctl — hub ports with per-port power switching only, not xhci root ports). ## Logging Two planes (`docs/logging.md` is the schema source of truth): **machine events** = JSON Lines on stdout, one object per line, first field always `{"ev":"",...}` — so `grep -F '"ev":"rx.txhit"'` works without a JSON parser, and `tests/devourer_events.py` (`iter_events`/`parse_event`) is the python helper every test script uses. **Human diagnostics** = stderr, `devourer [I] msg` (level letter T/D/I/W/E). Every line is written with one fwrite + flush, so piped consumers never stall on buffering and threads never interleave mid-line. `2>/dev/null` gives a pure event stream. Demo knobs: `DEVOURER_LOG_LEVEL=trace..silent` (stderr verbosity, default debug), `DEVOURER_EVENTS=stdout|stderr|off`, `DEVOURER_EVENT_FLUSH=0` (max-rate benches). Compile-time floor: `-DDEVOURER_LOG_MAX_LEVEL=WARN` compiles trace/debug out entirely (args included at `DVR_TRACE`/`DVR_DEBUG` sites) for production builds; unset = NDEBUG-derived. Exceptions kept as diffable text on the diagnostic plane: canary / bb / efuse / txpwr register dumps (kernel cross-validation format). ## Configuration **The library reads no environment.** Construction-time knobs live in `devourer::DeviceConfig` (`src/DeviceConfig.h` — rx / tx / bf / tuning / debug / usb sections, every field doc-tagged with its env-var spelling and value grammar), passed as `CreateRadio`'s defaulted fourth argument. Mid-session knobs are runtime setters on `IRadio` (`SetTxMode`, `SetTxPowerOffsetQdb`, `SetTxPowerIndexOverride`, `SetRxPathMask`, `SetCcaMode`, `FastRetune`, ...). **Adapter capabilities**: `IRadio::GetAdapterCaps()` (`src/AdapterCaps.h`) aggregates chip identity, chain counts, the composed `GetTxCaps` + `GetTxPowerCaps`, channel widths, per-band tunable + characterized frequency spans, and feature flags — resolved at construction, thread-safe, callable pre-`Init`; the demos emit it as the `adapter.caps` JSONL event. Every flag is doc-commented at its declaration, including the bench-derived (not vendor-advertised) `ldpc_rx_*` and `vht_2g4_ok` truth tables and the three per-packet-TX-power hardware shapes — read the header, not a copy of it. Two facts that live only here: LDPC TX is per-packet radiotap-driven on all generations (`TxCaps.ldpc_ok`) with bench-measured coding gain ≈ +3 dB at the 10%-delivery crossing, MCS7/20 MHz (`tests/ldpc_waterfall.sh`) — prefer `/LDPC` on any link whose RX side can decode it. And `GetActiveRxPaths()` is the live companion to the static caps: a best-effort per-chain-RSSI estimate of which antennas actually carry signal (needs an RX loop + traffic). The 5 GHz synthesizer tunes past the UNII channels (extended range ~5080–6165 MHz, chan up to 253, `freq = 5000 + 5*chan`); out-of-band channels tune but their TX power / per-channel constants are extrapolated from the nearest characterized channel (one-shot `W` diagnostic). No regulatory enforcement — the caller owns compliance. **Env vars are the demos' interface**: `examples/common/env_config.{h,cpp}` is the authoritative mapping of every library-level `DEVOURER_*` var onto `DeviceConfig`, so the test scripts drive everything through env. For the per-var reference read the `env:` tags in `DeviceConfig.h` — do not look for a second copy here. Demo-local vars (device selection, timing, telemetry cadence) have no `DeviceConfig` field and are parsed in each demo's own code; those are the ones listed below. - `DEVOURER_PID=0xNNNN` / `DEVOURER_VID=0xNNNN` — restrict the device-open loop (default VID `0x0bda`, all Realtek PIDs). `DEVOURER_USB_BUS=N` + `DEVOURER_USB_PORT=a.b.c` select by USB topology when two adapters share VID:PID **and** serial. - `DEVOURER_CHANNEL=N` — monitor channel. - `DEVOURER_TX_RATE=[/][/SGI][/LDPC][/STBC][/ER|/ER106][/DCM]` — TX mode for rate-less frames (`MCS7/40/SGI`, `VHT2SS_MCS3/80/LDPC`, `1M`...). Unset = 6M legacy. CCK rates are 2.4 GHz-only; `1M` buys ~9 dB link budget over `6M`. Rate resolution never reads the band, so `VHT*` rates air on 2.4 GHz too — the non-standard NitroQAM/TurboQAM extension, confirmed on the dies whose `AdapterCaps.vht_2g4_ok` is set, peer-decode required and the 256-QAM MCS8/9 points still unmeasured (`docs/vht-on-2g4.md`). `/ER`, `/ER106`, `/DCM` are HE-only (Kestrel): the HE ER SU extended-range PPDU + dual-carrier modulation (`docs/he-extended-range.md`). The library itself is radiotap-driven — a frame carrying its own rate radiotap overrides the mode per-packet (ER SU = radiotap-HE FORMAT=EXT_SU). Programmatic: `SetTxMode` / `ClearTxMode`. - `DEVOURER_SKIP_RESET=1` — skip `libusb_reset_device` before claim (only helps when firmware state is intact). Kestrel adapters skip the reset unconditionally — a USB reset on running firmware can land the chip in the dead ZeroCD DISK id (`src/kestrel/CLAUDE.md`). - `DEVOURER_TX_GAP_US=N` — txdemo inter-frame gap (default 2000, ~500 fps; `0` = max duty for heating experiments). - `DEVOURER_TX_FRAMES=N` — txdemo finite frame bound (default `0` = unbounded). Exits through the ordinary `Stop()` path once N frames have been submitted, so first-light and regression captures get a clean teardown instead of a killed timed flood. - `DEVOURER_USB_DEBUG=1` — libusb DEBUG log level (~7 MB / 15 s, has filled `/tmp` mid-capture; adds 0.5–0.8 s to init). - `DEVOURER_THERMAL_POLL_MS=N` — emit `thermal` events from the RF 0x42 meter, on every generation (the poller rides `IRadio::GetThermalStatus`). `raw` is 0..63 thermal units (~1.5–2 °C each, **not** absolute °C); `delta` = raw − EFUSE baseline. **Telemetry only**: the poller emits and warns (`DEVOURER_THERMAL_WARN_DELTA`, default 15) and never stops RX; no HAL gates its send path on the meter. A rising delta is *suggestive*, not a validated degradation predictor — `docs/warm-tx-degradation.md` has delivery scattered 63–83% with no relation to the meter, and inside one uninterrupted session the meter stays pinned while delivery drifts. - `DEVOURER_LINKHEALTH=1` (rxdemo, needs `DEVOURER_RX_ENERGY_MS=N`) — classify the RX sensor tuple via `src/LinkHealth.h`. **EVM, not SNR, is the saturation tell**: strong RSSI + poor EVM means back power OFF, which is the opposite of the weak-link response (`docs/bench-testing-near-field.md`). - `DEVOURER_LA_CAPTURE=/M/dma0/port:0x880` (rxdemo) — one-shot LA-mode IQ capture to a `DVLA` file, offline per-tone H(k) via `tools/la_csi.py`. Not on the 8812A/8821A (no LA block). Packing, per-chip windows, trigger semantics and wedge risks: `docs/la-capture.md`. Behavioural traps the per-field docs can't carry: - `DEVOURER_TX_WITH_RX=thread` (concurrent TX+RX on one claimed handle: `InitWrite` once, then `StartRxLoop` on a thread) must be set **before** `InitWrite` on Jaguar3. This is the single-radio beamforming self-sounding station: pair with `DEVOURER_BF_ARM_SOUNDER` / `DEVOURER_TX_NDPA` / `DEVOURER_BF_DETECT_REPORT` (`docs/beamforming-self-sounding.md`). Non-`thread` values select a `fork()` RX child that only works on Termux; on regular Linux the forked bring-ups race and die. - `DEVOURER_RX_PATHS` (Jaguar1 RX-chain mask) routes through `SetRxPathMask` and is **sticky** across `SetMonitorChannel`. Toggle spec `0xAA:0xBB[:0xCC]@` cycles masks on a timer for mobility/MRC measurements (`docs/measuring-spatial-diversity.md`, `tests/mrc_mobility.py`). `DEVOURER_RX_ALLPATHS=1` emits per-chain RSSI/SNR/EVM as a separate `rx.path` event on the four Jaguar/Kestrel generations (Kestrel parses its halbb physts path pages; C/D nonzero only on the 8814AU — the other dies are ≤2 RX chains; the 1T1R RTL8733B does not emit it). Windowed per-antenna means ride `GetActiveRxPaths()` / the `adapter.rxpaths` event (rssi/snr/evm per chain). - `DEVOURER_RX_KEEP_CORRUPTED=1` is the entry point for the fused-FEC salvage layer (`docs/fused-fec.md`), and stays opt-in for a reason: a body with a corrupt tail is the worst-case input for an IP-stack consumer that didn't ask for it. - `DEVOURER_RX_CSI_MASK` / `DEVOURER_RX_NBI` apply at RX-loop start and **revert on a channel switch**. Measured inert against a *jammed* slice — that loss is pre-FCS sync/AGC, upstream of the equalizer. They target in-band spurs on otherwise decodable frames (`docs/pseudo-preamble-puncturing.md`). - `DEVOURER_DIS_CCA=1` (runtime `SetCcaMode` — the interface method is pure virtual so no backend can silently no-op it) disables the MAC carrier-sense gate — **both** primary CCA (`0x520[14]`) and EDCCA (`[15]`). The default is carrier-sense + EDCCA **enabled** on Jaguar1/2/3; on Jaguar1 the enable is real work — its BB table parks the EDCCA thresholds (`0x8a4`) at never-trigger, so bring-up programs the vendor adaptivity operating point (IGI-coupled; the phydm watchdog re-tracks it when running). The primary-CCA bit is the one that matters: monitor injection is not CCA-free, it defers ~40–60% to a co-channel 802.11 transmitter, and clearing `[14]` recovers ~1.5–2.2× (on-air 8822EU/8812CU, `tests/dis_cca_tx_onair.sh`); the energy bit `[15]` alone is null against a decodable preamble. **On by default on the streamtx FPV downlink** (the link owns the channel — CSMA backoff only stutters it); `DEVOURER_DIS_CCA=0` forces standard carrier-sense back. On Kestrel the 8852C runs the same enabled default (measured: full-rate TX, 2.4x flood deferral); the 8852B TX bring-up still clears the gates and WARNS pending its measurement arm (`tests/kestrel_cca_default_check.sh`). On the RTL8733B the disable is **not ported** — the HALMAC 87xx carrier-sense gate has not been located and measured there, so `SetCcaMode(true)` throws (loudly, but without tearing the session down) while `SetCcaMode(false)`, the state its MAC bring-up already leaves programmed, succeeds as a no-op; setting the config knob warns once at bring-up (RX-only sessions included) and airs with carrier-sense. Does NOT apply the vendor BB CCA-off writes (they deafen the RX). RX-decode side is a separate null (`tests/dis_cca_onair.sh`). **Runtime TX power** — the adaptive-link power lever, three knobs on the four Jaguar/Kestrel generations: `SetTxPowerOffsetQdb` (relative, shape-preserving), `SetTxPowerIndexOverride` (flat absolute), `SetTxPowerRateDiffs` (replace the calibrated per-rate shape). The RTL8733B ports **only the first**, and on a different mechanism: its closed-loop TSSI target table, not a TXAGC index, so its caps report the dBm model (`index_max = 0`) over the int8 delta field's `[-128, +127] qdB`, centred on a safe 16 dBm first-light target. Neither end is re-clamped at something softer; where the chip stops responding — about -96 qdB down, about +32 up, where the PA compresses and only EVM shows it — is measured and documented rather than enforced. The flat index is refused there because it was measured unable to carry HT at all. The contract — how they compose, the MCS7-anchor semantics, family step sizes, the write-only-family `hw_readback=false` shadow, and Kestrel's software send-time fold — is documented at the declarations in `src/TxPower.h`; the per-chip mechanics are in each `src//CLAUDE.md`. All three apply live and stick across `SetMonitorChannel` and `FastRetune`. None is regulatory-clamped: the operator owns compliance. `txpower` (`examples/txpower/`) is the reference consumer — `--rate-diffs cck,legacy,m0..m7|clear`, `--offset-start`/`--offset-stop`, `--flat`; `txdemo` maps the third knob as `DEVOURER_TX_RATE_DIFFS`. What each validation script actually proves matters on a write-only family: `tests/txpwr_offset_regcheck.sh` and `tests/txpwr_rate_diffs_regcheck.sh` check **register/shadow state management**, not radiated power — `tests/txpwr_rate_diffs_onair.sh` is the one that measures the antenna, per rate. **Per-packet TX power** — a radiotap `DBM_TX_POWER` dB-delta per frame, zero USB cost once armed. The three hardware shapes (Jaguar2 + 8814A descriptor LUT, Jaguar3 programmable BB banks, Kestrel fixed-dBm BB rewrite) are documented with the `per_pkt_txpwr_*` caps in `src/AdapterCaps.h`, and in full in each `src//CLAUDE.md`. The 8812AU/8821AU have no descriptor field at all — their compensating fast lever is `FastSetTxPowerOffsetQdb` (BB-swing). Sweep harnesses: `tests/txpkt_pwr_ofset_onair.sh` (TX_PID/TX_VID select the DUT; Kestrel DUTs need `TX_PWR=14`-style dBm bases), `tests/txpkt_fastswing_onair.sh`, `tests/txpkt_hop_persist.sh`. Per-packet unequal error protection: `svctx` classifies stdin HEVC NALs by temporal layer and injects each at its ladder's rate (`DEVOURER_SVC_LADDER="CRIT=;T0=;..."`); the application-FEC half (RS outer code + corrupt-frame salvage) lives in `tools/precoder/` (`docs/fused-fec.md`). ## Frequency hopping `IRadio::FastRetune(channel)` — lean intra-band, same-bandwidth retune on all five generations (RF channel switch only, write-only from a compose cache); falls back to full `SetMonitorChannel` on a band change. FHSS-grade on the Jaguar/Kestrel dies: ~0.5–2.5 ms per hop depending on chip. On the RTL8733B the hop keeps TSSI tracking live (per-channel rate-offset dwords rewritten in place) and costs ~55 ms call / ~10 ms p50 radio-live on the USB-HS validation unit — vs its ~330-440 ms full path — with a measured 1-in-20 40 ms radio-live tail; one unit, no SDR (`src/rtl8733b/CLAUDE.md`). On the Jaguar2 dies (8822B, 8821C) and Jaguar3 (8822C, 8822E), `DEVOURER_FASTRETUNE_FW=1` hands the hop to the chip firmware instead (H2C 0x1D, fire-and-confirm-later): ~1.4 ms dead air on the 8822B (a tie on-air on the 8822C/8822E, but ~3× cheaper host-side), and `=2` extends it to **cross-band** hops (~2–2.6 ms vs the ~90 ms full path) — protocol + bench: `docs/experiments/kernel-channel-switch-offload.md`. `send_packet` honours a radiotap `CHANNEL` field, so hopping is per-packet and radiotap-driven like rate. Demos hop via `DEVOURER_HOP_CHANNELS` (SweepSpec grammar: `1,6,11`, `36-48/4`, `5170-5250/5` MHz) + `DEVOURER_HOP_DWELL_FRAMES` / `_ROUNDS` / `_FAST` / `_RADIOTAP` / `_BW` / `_OFFSET`; `DEVOURER_HOP_PROF=1` emits per-stage `debug.hop_prof` timing. Validation: `tests/run_hop_validation.sh`, `tests/hop_parity_check.sh` (register parity full-vs-fast). Implementation + per-generation ports: `docs/frequency-hopping.md`. Above that primitive sits the **keyed FHSS / adaptive hopset** subsystem (`src/hopset/`, header-only and pure): keyed slot schedules and lockstep RX (`DEVOURER_HOP_SLOT_MS` + `DEVOURER_HOP_SEED`), a receiver-driven channel exclusion policy, and TX-side quiet-window sensing with endpoint fusion. Knob reference, policy thresholds, measured sensing constants and the on-air harnesses: `src/hopset/CLAUDE.md`. Article + results: `docs/fhss.md`, `docs/jammer-resilience.md`. `IRadio::FastSetBandwidth(bw)` is the bandwidth analogue — a lean same-channel toggle between 20 MHz and 5/10 MHz narrowband (baseband re-clock only; ~0.18 ms on the 8812AU vs ~90 ms for the full `SetMonitorChannel`); falls back to the full path for a 40/80 MHz endpoint. Validation: `tests/fast_bw_parity.sh`. See `docs/narrowband.md`. RX counterpart: `DEVOURER_RX_SWEEP` dwells FastRetune-cheap bins emitting per-bin energy + frame stats; `tests/sounding_sweep.sh` + `tests/sounding_map.py` recover a coarse per-bin H(f) — down to 5 MHz bins on Jaguar3 (`docs/rx-spectrum-sensing.md`). ## Adaptive channel migration Slow, evidence-driven whole-link channel moves — the deliberate complement to per-slot FHSS. Pure caller-side logic under `src/chanmig/`: a passive scout on a second adapter (`chanscout`), a two-leg scoring engine, an authenticated ground-proposes / drone-commits protocol (`examples/chanmig --role ground|drone`), and a deterministic automation gate (`DEVOURER_MIG_MODE=off|advisory|manual|automatic`, default `advisory`). Control frames are their own 802.11 frames — video PSDUs are never touched, and there is no regulatory DB: the caller owns compliance. Layer detail, validation matrix and the near-field bench note: `src/chanmig/CLAUDE.md`. ## Hardware time, beacons, AP mode `ReadTsf()` reads the 64-bit MAC TSF and every received frame carries the MAC-latched `tsfl` RX timestamp — on the four Jaguar/Kestrel generations, µs-grade (`docs/time-distribution.md`, measured vs NTP/PTP: `docs/timing-accuracy.md`). `StartBeacon` loads a beacon into the MAC's reserved page and the chip auto-transmits at each TBTT with the live TSF stamped at the TX instant — the sub-µs downlink. The chip beacons **autonomously**, so `StopBeacon()` is what stops it mid-session; a session that ends via `Stop()` or destruction powers the chip down and takes the beacon with it (Jaguar1/Jaguar3 — Jaguar2 has no teardown power-down yet, so there it keeps airing until the adapter is re-enumerated). `UpdateBeaconPayload` swaps the airing content in place (frame-atomic on air, TBTT-quantized latency); `PinBeaconTbtt` steers the TBTT to an absolute TSF instant without corrupting the clock (Jaguar1: offset 0 only — its TBTT is hardware-locked to the TSF grid); `AdjustBeaconTimingFine` is the µs-fine manual lever. Demos: `timesync` (`DEVOURER_TSYNC_ROLE=master|slave|ue` + `DEVOURER_TSYNC_*` knobs, incl. the PCIe master + I226-PTP discipline loop) and `tdma` (TSF-slotted narrowband↔wide bursts on one channel). The beacon is also the seed of AP mode — a real Linux station associates, open or WPA2-PSK (`docs/ap-mode.md`); the multi-cell architecture it enables is `docs/multi-ap-cellular.md`, and the measured scheduler contracts (submit→air guard time, dynamic beacon grants, ACK/TxReport, per-UE RX attribution) are `docs/scheduled-mac.md`. None of this section is ported on the RTL8733B — `ReadTsf` returns 0 and `StartBeacon` returns false there. ## Aggregation, hardware ACK, TX reports `SetAmpduMode` enables 802.11 A-MPDU on injected frames: ~+30% *goodput* at the PHY ceiling by amortizing per-frame overhead — an occupancy metric can't show it, count delivered payload. That number is high-MCS broadcast; the unicast-ARQ shape measured **−8%** delivered at MCS3, and per-frame CCX accounting does not survive AGG_EN (docs/aggregation.md) — under A-MPDU the receipts tier is the delivery truth. `SetAckResponder(mac)` arms the hardware ACK/BlockAck responder; with a unicast TA on the soliciting frame this closes a hardware-ARQ loop (autonomous MAC retransmission until ACK). The ACK's horizon is chip-FIFO **admission** (bench: 8812EU responder, `tests/arq_e2e_delivery.sh` per-frame ledgers): receiver-side congestion upstream of admission declines the ACK, so the loop sees and retries it — but a host stage that drops on a full queue while keeping URBs armed turns the same loss into ACKed-but-undelivered that the TX peer logs as delivered and never retries. The spsc-fat ring's `DEVOURER_RX_POOL_EXHAUST` policy is that choice (`src/DeviceConfig.h`): `backpressure` (default) parks exhausted URBs so the chip declines further ACKs and the loss stays ARQ-visible (`pool_stalls` in `rx.ring`; bench: 0 ACKed-but-undelivered under stalls that lose 5.5k+ frames on the `drop` policy, which remains opt-in and counted as `pool_dropped`). Per-frame TX outcomes surface as `tx.report` events (CCX via C2H) — the TX-side link sensor; C2H rides the RX path, so J1/J2 TX-only sessions see none (run `DEVOURER_TX_WITH_RX=thread`; J3's coex thread drains C2H regardless). `GetRxQuality()` is the device-wide windowed RX sensor; `cell::UeRxAttribution` is its per-transmitter (per-UE) counterpart. Docs: `docs/aggregation.md`, measured per-generation matrix `docs/scheduled-mac.md`. ## Architecture **The caller owns libusb.** `WiFiDriver::CreateRadio` is intentionally thin — `libusb_init`, device open, kernel-driver detach, and `libusb_claim_interface(handle, 0)` must happen **before** handing the handle to the factory. `examples/rx/main.cpp` is the canonical boilerplate; `devourer::claim_interface_then_reset` (src/UsbOpen.h) is the recommended open path (advisory per-adapter lock before reset). Owning libusb means owning the **teardown order**: destroy the `IRadio` first, then release the interface, close the handle, and only then `libusb_exit`. The device is what quiesces TX (`IRadio::Stop`, and the destructor as a backstop: Jaguar1's async bulk-OUT URBs must be cancelled and reaped while the context still exists), so tearing libusb down first is a crash, not a leak — and only under enough TX load to keep URBs outstanding at exit. `examples/common/DeviceSession.h` is the demos' RAII holder for exactly that order; the transport logs a diagnostic naming this if it is destroyed with TX still in flight. **Chip identity is resolved at construction** from the `SYS_CFG2` chip-id + USB PID. `CreateRadio` returns an `IRadio` (`Init` = bring-up + RX loop; `InitWrite` = TX bring-up; `StartRxLoop` = blocking RX worker on an already-up chip, enabling TX+RX on one handle; `send_packet`) and constructs `RtlJaguarDevice` / `RtlJaguar2Device` / `RtlJaguar3Device` / `RtlKestrelDevice` / `Rtl8733bDevice` per backend. `Rtl8812aDevice` is a deprecated alias of `RtlJaguarDevice`. The five Realtek backends derive from `IRtlRadio` (`src/IRtlRadio.h`), the Realtek-only extension of `IRadio` — the header carries the member list and the downcast contract. Optional device methods are **virtual with not-ported defaults**, not pure virtual — a backend that hasn't ported a feature inherits `false`/`0`/a full-path fallback rather than a fake. Check the override list in the backend's header before believing a cross-generation claim. Generation-agnostic core in `src/` (always compiled; depends on no HAL): - `IRadio` (`src/IRadio.h`) — the vendor-neutral radio contract every backend implements; `WiFiDriver::CreateRadio` returns one. - `WiFiDriver` — the factory (`CreateRadio`). - `DeviceConfig.h` — construction-time configuration struct; every component copies the sub-struct it consumes at construction. - `RtlAdapter` — the bus-neutral register/frame accessor; a copyable value type shared by every component, forwarding to the `ITransport` it was built with (`UsbTransport` = libusb vendor control + bulk; `PcieTransport` = BAR2 MMIO + DMA rings). `RtlUsbAdapter` is a deprecated alias. - `Radiotap.c` — radiotap iterator. TX buffers passed to `send_packet` **must** begin with a radiotap header; rate/MCS/VHT/STBC/LDPC/SGI/bandwidth are read from it. - `RateDefinitions.h`, `RxPacket.h`, `TxDescBits.h` — symbols all generations' parsers build on, kept neutral so no generation's header pulls in another's. - `PhyTableLoader` — runtime walker for Realtek's phydm-format register tables (`check_positive` + opcode state machine, without pulling in phydm itself). Shared by Jaguar1 + Jaguar2; Jaguar3 has its own `PhyTableLoaderJaguar3`. - `cell/` — caller-side per-cell helpers built on the device API (`UeRxAttribution`: per-transmitter windowed RX statistics keyed by 802.11 TA); the device RX loops are untouched. Per-backend HALs are self-contained under `src/jaguar1/`, `src/jaguar2/`, `src/jaguar3/`, `src/kestrel/`, `src/rtl8733b/`; each subtree's `CLAUDE.md` maps its files, strategy seams and chip-specific mechanisms. `hal/` holds vendor headers and tables. The per-chip PHY/limit tables and most firmware blobs are **generated** by `tools/extract_*.py` (check `tools/` for which — the set has grown) — edit the generators, never the output files. The newer extractors (`extract_8733b_*.py`) pin per-source and per-array SHA-256 hashes and carry a `--check` mode that reproduces the checked-in output byte-for-byte; prefer that shape when adding one. ## Hardware gotchas - **ZeroCD trap**: some Realtek dongles enumerate first as USB mass-storage (`0bda:1a2b`) exposing a Windows installer, then re-enumerate as the NIC. If `libusb_open_device_with_vid_pid` returns NULL, check `lsusb` — may need `usb_modeswitch`. - **rmmod/sysfs-unbind actively de-inits the chip** (RF off, MAC DMA off). After detaching a kernel driver, expect a cold re-init; `DEVOURER_SKIP_RESET` only helps when firmware state is intact. - **The chip retains state across soft re-init** — cold-bisect hardware problems with a VBUS power-cycle, not a re-run. - **Qualify the ground station before believing a delivery number.** Delivery is a property of a *link*; a receiver whose modulation cliff sits at the rate under test measures itself, not the transmitter, and swings between "fine" and "zero" on a few dB of ambient while the robust rates stay pinned — which looks exactly like a transmitter that degrades and recovers. Same TX, same channel, minutes apart: a TP-Link Archer T3U (8822BU, **internal** antennas) ran MCS3 97.8 / MCS4 98.3 / MCS5 79.1 / MCS6 49.0 / **MCS7 2.9**, while an RTL8814AU (two **external** antennas) was flat at ~80% across the same ladder. Both healthy — one just has less margin. `tests/ground_station_qualify.sh` sweeps the ladder and refuses the pairing (exit 1) when the test rate is off the flat part. Prefer external-antenna adapters as ground stations for high-MCS work, and cross-check with a second, independent receiver. - **A single delivery probe is worth ±3 points, so small effects are not findings.** Ten identical back-to-back MCS7/20 probes on an 8812AU (nothing changed between them) gave sd 1.8 and a 5.7-point spread; the MCS1 control over the same run held 98.0 ± 0.2. Anything under ~4 points needs repetition before it means anything — several plausible-looking "decay curves" have turned out to sit inside that band. `tests/probe_repeatability.sh` measures the floor for a given pair; run it before believing a delivery difference. - **Do not attribute a rate ceiling to chip temperature without varying temperature independently.** Delivery does correlate with the `thermal` meter within a probe sequence, and that correlation is a trap: a sweep varying *only* the power-off duration (identical reset every arm) found delivery scattered 63–83% with no relation to off-time or temperature, and inside a single uninterrupted session the meter stays pinned while delivery drifts. Read the `thermal` event (`DEVOURER_THERMAL_POLL_MS`) beside a rate-ceiling number by all means, but a power cycle changes chip state *and* temperature together, so it can never separate them (`docs/warm-tx-degradation.md`). - **MediaTek Android hosts cap bulk-IN reads at 16 KB**: some MTK xhci/usbfs stacks (Dimensity 810, Helio G99, MT6765) never complete a larger bulk-IN transfer — `LIBUSB_ERROR_TIMEOUT` forever, zero RX with a green init (OpenIPC/PixelPilot#6). The 11ac RX rings therefore post 16 KB URBs paired with ≤16 KB device-side aggregation caps (`DeviceConfig::Rx::urb_bytes`, `DEVOURER_RX_URB_BYTES`); raise both together or MTK hosts go silent, and never let an aggregate exceed the URB size or the parse walk breaks. Kestrel is exempt (8852C RXAGG LEN_TH ~20 KB needs its 32 KB ring) and unvalidated on MTK hosts. The RTL8733B is the worked example of the failure: its vendor-default 20 KiB aggregate was observed being split by xHCI across 16 KiB completions, tail and body landing separately. It now caps the device at 12 KiB and floors its URB at the same constant, tied together by a `static_assert` — raising one alone reintroduces the straddle. ## TX path ```cpp auto logger = std::make_shared(); WiFiDriver driver(logger); auto dev = driver.CreateRadio(handle); // handle is already claimed dev->InitWrite(SelectedChannel{ .Channel = 36, .ChannelOffset = 0, .ChannelWidth = CHANNEL_WIDTH_20 }); dev->send_packet(buffer, len); // buffer[0..] = radiotap header, then 802.11 ``` The canonical test beacon (`examples/tx/main.cpp`) uses SA `57:42:75:05:d6:00` — the same constant is hardcoded into `examples/rx/main.cpp` as the `rx.txhit` event matcher and into `tests/regress.py` (`CANONICAL_SA`). Change all three together if it ever moves. **TX transfer mode is deliberately per-generation** (`docs/performance-tuning.md`): Jaguar1 submits asynchronously (`RtlAdapter::send_packet` → `tx_async`, caller-thread completion reaping — its USB2 round-trip is too long for a blocking send to saturate the link); Jaguar2/Jaguar3 send synchronously (`bulk_send_sync_ep` → `tx_sync` — their USB3 round-trip saturates on one blocking thread, and sync gives the HalMAC bring-up a clean per-send NAK backoff); the RTL8733B is synchronous too, so every submission has a bounded result and no buffer outlives the `send_packet` call. Don't unify the modes — either direction regresses throughput or bring-up safety. **Nothing reads a register per frame on the send path.** Measured on one RTL8733B unit during bring-up: a single thermal read (3 RF writes + a 15 µs settle + an RF read, each several USB control transfers) cost 2.51 ms of a 2.71 ms per-frame budget on USB high speed — 93%, for a meter that tracks PA bias and is not a validated degradation predictor. One bench, one part, and a USB3 host would divide it; the shape of the result is the transferable part. Sensor reads belong on the caller's own cadence via the runtime getters, never inside `send_packet`.