From 010069a49b8502b61aef1f7273092a5d7c43ba82 Mon Sep 17 00:00:00 2001 From: Joseph <162703152+josephnef@users.noreply.github.com> Date: Fri, 3 Jul 2026 20:31:20 +0300 Subject: [PATCH 1/6] Pseudo preamble puncturing: RX tone-mask knobs + jammed-slice study MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wi-Fi 7 preamble puncturing (use a wide channel, skip a dirty 20 MHz slice) has no true rendition on Jaguar silicon — VHT preambles must be contiguous and no generation exposes TX tone nulling. This ports the closest thing the chips do have and measures what it is worth: - src/ToneMask.h: phydm CSI-mask (per-tone receive-equalizer de-weight) + NBI notch, one shared header for all three generations (11ac bit-arrays 0x874/0x880-0x89C; Jaguar3 indexed table behind 0x1D94 with per-tone weight, enable 0xC0C[3]). New env knobs DEVOURER_RX_CSI_MASK=[-][/wgt] (masks an arbitrary tone range — the vendor wrapper only notches one interferer frequency) and DEVOURER_RX_NBI=, applied in each generation's StartRxLoop. - Register landing validated on 8821AU / 8814AU / 8822BU / 8812CU / 8822EU (tests/tone_mask_regcheck.sh); the mask provably gates demodulation (tests/tone_mask_rx_sanity.sh: 29->6 hits masking the tuned channel, 29->30 off-frequency). Tone math is ctest-guarded (tone_mask_math). - Jammed-slice experiment (tests/pseudo_puncture_onair.sh, B210 AWGN on one slice of a VHT80 link): the CSI mask is inert against a jammed slice — with DEVOURER_RX_KEEP_CORRUPTED=1 the jammed cell delivers zero FCS-failed frames, so the loss is pre-FCS (sync/AGC), upstream of the equalizer. Dropping TX to the clean 40 MHz half recovers 78% of clean delivery; a victim-link probe scan locates the jammed slice (tests/pseudo_puncture_detect.sh). Study written up in docs/pseudo-preamble-puncturing.md. - tests/sdr_interferer.py: pre-generated fixed-seed noise ring — the per-buffer numpy draw could not feed 20 MS/s, so the jam radiated only in underflow bursts and the gain knob did nothing. - demo: DEVOURER_BW now accepts 80 (VHT80 monitor RX). Co-Authored-By: Claude Opus 4.8 --- CLAUDE.md | 14 + CMakeLists.txt | 11 + demo/main.cpp | 14 +- docs/pseudo-preamble-puncturing.md | 140 +++++++++ src/ToneMask.h | 460 +++++++++++++++++++++++++++++ src/jaguar1/RtlJaguarDevice.cpp | 15 + src/jaguar2/RtlJaguar2Device.cpp | 8 + src/jaguar3/RtlJaguar3Device.cpp | 12 + tests/pseudo_puncture_detect.sh | 182 ++++++++++++ tests/pseudo_puncture_onair.sh | 132 +++++++++ tests/sdr_interferer.py | 16 +- tests/tone_mask_regcheck.sh | 114 +++++++ tests/tone_mask_rx_sanity.sh | 96 ++++++ tests/tone_mask_selftest.cpp | 108 +++++++ 14 files changed, 1313 insertions(+), 9 deletions(-) create mode 100644 docs/pseudo-preamble-puncturing.md create mode 100644 src/ToneMask.h create mode 100755 tests/pseudo_puncture_detect.sh create mode 100755 tests/pseudo_puncture_onair.sh create mode 100755 tests/tone_mask_regcheck.sh create mode 100755 tests/tone_mask_rx_sanity.sh create mode 100644 tests/tone_mask_selftest.cpp diff --git a/CLAUDE.md b/CLAUDE.md index ef38812..5f3b515 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -195,6 +195,20 @@ Both `WiFiDriverDemo` and `WiFiDriverTxDemo` honour: mobile/fading combining measurement (alternate a fixed chain vs all-4 fast relative to RX motion so both sample the same fading). Analyse with `tests/mrc_mobility.py`. +- `DEVOURER_RX_CSI_MASK=[-][/wgt]` — (all generations, RX) + de-weight a frequency range (MHz) in the receive equalizer's per-tone CSI + mask — the RX half of pseudo preamble puncturing (`src/ToneMask.h`, + `docs/pseudo-preamble-puncturing.md`). Masks every 312.5 kHz subcarrier in + the range (e.g. `5230-5250` = the top 20 MHz slice of the ch36 80 MHz + block); `/wgt` (0..7, default 7) is the Jaguar3 per-tone weight. Applied + when the RX loop starts, after the channel set; a channel switch reverts it + (same contract as `DEVOURER_RX_PATHS`). Register-landing checked per chip by + `tests/tone_mask_regcheck.sh`. NB measured: inert against a *jammed* slice + (that loss is pre-FCS sync/AGC, upstream of the equalizer) — it targets + in-band spurs riding on decodable frames. +- `DEVOURER_RX_NBI=` — (all generations, RX) arm the narrowband- + interference notch filter at one in-channel frequency (vendor-parity, + LUT-quantized — a single narrow notch, not a slice mask). - `DEVOURER_TX_WITH_RX=thread` — (`WiFiDriverTxDemo`) run the RX worker loop on a `std::thread` alongside the TX loop, on the **same claimed adapter**: one bring-up (`InitWrite`), then `StartRxLoop(packetProcessor)` — the programmatic diff --git a/CMakeLists.txt b/CMakeLists.txt index a474c86..bd609f7 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -245,3 +245,14 @@ add_test( -DSELFTEST_EXE=$ -P ${CMAKE_CURRENT_SOURCE_DIR}/tests/stream_stdin_test.cmake ) + +# Headless guard for the ToneMask tone-index math (src/ToneMask.h) — the fc +# derivation + subcarrier enumeration behind DEVOURER_RX_CSI_MASK / +# DEVOURER_RX_NBI. Pure math only; the register encodings are hardware-checked +# via the apply-time readback logging. +add_executable(ToneMaskSelftest + tests/tone_mask_selftest.cpp +) +target_link_libraries(ToneMaskSelftest PRIVATE WiFiDriver) + +add_test(NAME tone_mask_math COMMAND ToneMaskSelftest) diff --git a/demo/main.cpp b/demo/main.cpp index a7bcd7e..82d8b4f 100644 --- a/demo/main.cpp +++ b/demo/main.cpp @@ -558,19 +558,21 @@ int main() { channel = std::atoi(ch_env); logger->info("DEVOURER_CHANNEL set — tuning to channel {}", channel); } - /* RX bandwidth: 20 MHz by default. DEVOURER_BW=40 selects a 40 MHz monitor - * channel (for receiving HT40 frames); DEVOURER_CHOFFSET picks the secondary - * half (1 = secondary above the primary, 2 = secondary below). + /* RX bandwidth: 20 MHz by default. DEVOURER_BW=40|80 selects a wide monitor + * channel (for receiving HT40 / VHT80 frames); DEVOURER_CHOFFSET picks the + * secondary half (1 = secondary above the primary, 2 = secondary below). * DEVOURER_NB_BW=5|10 re-clocks the baseband to narrowband (Jaguar3 only). */ ChannelWidth_t width = CHANNEL_WIDTH_20; uint8_t ch_offset = 0; if (const char *bw_env = std::getenv("DEVOURER_BW")) { - if (std::atoi(bw_env) == 40) { - width = CHANNEL_WIDTH_40; + int bw = std::atoi(bw_env); + if (bw == 40 || bw == 80) { + width = (bw == 40) ? CHANNEL_WIDTH_40 : CHANNEL_WIDTH_80; ch_offset = 1; // default: secondary channel above the primary if (const char *off_env = std::getenv("DEVOURER_CHOFFSET")) ch_offset = static_cast(std::atoi(off_env)); - logger->info("DEVOURER_BW=40 — 40 MHz RX, channel-offset {}", ch_offset); + logger->info("DEVOURER_BW={} — {} MHz RX, channel-offset {}", bw, bw, + ch_offset); } } if (const char *nb = std::getenv("DEVOURER_NB_BW")) { diff --git a/docs/pseudo-preamble-puncturing.md b/docs/pseudo-preamble-puncturing.md new file mode 100644 index 0000000..6d24a8b --- /dev/null +++ b/docs/pseudo-preamble-puncturing.md @@ -0,0 +1,140 @@ +# Pseudo preamble puncturing — using a wide channel with a dirty slice + +Wi-Fi 7 preamble puncturing lets a transmitter keep an 80/160 MHz channel +while skipping one dirty 20 MHz slice: the EHT preamble carries a per-PPDU +puncturing bitmap, the TX IFFT zeroes the punctured tones, and the receiver +skips them during equalization. This document is the answer to "how close can +the 802.11ac-era Jaguar silicon get?" — what the chips offer, what devourer +now exposes, and what a jammed-slice experiment measured about each +mechanism's real worth. + +## Why true puncturing is out of reach + +Puncturing is **PHY signaling, not a driver trick**. Three independent walls: + +- **The VHT preamble must be contiguous full-band.** L-STF/L-LTF/L-SIG/ + VHT-SIG are wideband training sequences; there is no 802.11ac encoding for + "this 80 MHz PPDU skips 20 MHz in the middle", so even a hypothetically + tone-nulled transmission could not tell a receiver which tones to skip. +- **No TX tone nulling exists on this silicon.** Neither the vendor drivers + nor the firmware expose per-subcarrier TX amplitude control on any Jaguar + generation. Every tone-level knob the hardware has lives in the RX path. +- **The nearest 11ac concept, non-contiguous 80+80, is absent too** — none of + the supported USB parts implement it (register-level plumbing for it exists + only on later silicon). + +What the silicon *does* offer composes into a three-part approximation: +per-tone **detection** (find the dirty slice), an RX-side **tone mask** +(de-weight it in the equalizer), and TX-side **avoidance** (place a narrower +transmission on the clean sub-channels). All three are now in devourer; the +measurements below say which ones carry their weight. + +## The RX tone mask (`DEVOURER_RX_CSI_MASK`, `DEVOURER_RX_NBI`) + +All three generations carry two receive-path tone-level mechanisms, ported +from vendor phydm into `src/ToneMask.h` and exposed on both demos: + +- **CSI mask** — a per-tone receive-equalizer de-weight table: the BB treats + masked subcarriers' channel estimates as garbage. On 11ac silicon + (Jaguar-1: 8812/8811/8821/8814; Jaguar-2: 8822B) it is a plain bit array — + enable `0x874[0]`, positive tones `0x880-0x88F`, negative `0x890-0x89F`, + one bit per 312.5 kHz subcarrier. On Jaguar-3 (8822C/E) it is an indexed + table behind `0x1D94` (bracketed by `0x1EE8[1:0]`, two 4-bit entries per + byte: enable bit + 3-bit weight), enable `0xC0C[3]`. The vendor wrapper + only ever masks a handful of tones around one interferer frequency; + devourer's `DEVOURER_RX_CSI_MASK=-[/wgt]` (MHz) loops the + low-level setter across an arbitrary range, so one env var masks a whole + 20 MHz slice. On Jaguar-3 note the sharing trap: adjacent tones share a + table byte, so a range must be accumulated host-side before writing — + the vendor's one-tone-at-a-time sequence would clobber neighbours. +- **NBI notch** — a single narrowband notch filter, LUT-quantized + (`0x87C[19:14]` + enable `0x87C[13]` on 11ac, with `0xC20[28]`/`0xE20[28]` + added on 8822B; `0x1944/0x4044[20:12]` per path + `0x818[3]` (inverted + polarity on 8822C/E), `0x818[11]`, `0x1D3C[30:27]` on Jaguar-3). + `DEVOURER_RX_NBI=` — vendor-parity, one interferer. + +Both knobs are applied when the RX loop starts, after the channel set (same +contract as `DEVOURER_RX_PATHS`: the final word for a single-channel capture; +a channel switch reverts them). The tone math (`phydm_find_fc` port + +subcarrier enumeration) is unit-tested headlessly (`ctest -R tone_mask_math`); +the register encodings are asserted on hardware by +`tests/tone_mask_regcheck.sh` — validated on 8821AU, 8814AU, 8822BU, 8812CU +and 8822EU, including an 80 MHz cell (masking 5230-5250 of the ch36 block +sets exactly tones +64..+128). + +The mask is provably *active*, not just a register that reads back: +`tests/tone_mask_rx_sanity.sh` shows an off-frequency mask leaves a live link +untouched (29 → 30 hits) while masking the tuned channel collapses it +(29 → 6). + +## What the jammed-slice experiment measured + +`tests/pseudo_puncture_onair.sh`: 8822BU transmits VHT80 beacons on the ch36 +block (fc 5210) to an 8814AU receiver at 80 MHz, while a USRP B210 jams +exactly one 20 MHz slice (5230-5250) with band-limited AWGN +(`tests/sdr_interferer.py` — the TX gain is the reproducible damage knob). +Hits per 15 s cell: + +| cell | hits | vs clean | +|----------------------------------------|------|----------| +| A — clean VHT80 | 61 | 100% | +| B — jammed VHT80 | 31 | 50% | +| C — jammed VHT80 + CSI mask on slice | 33 | 54% | +| D — jammed, TX drops to clean-half 40 | 48 | 78% | + +**The CSI mask is inert against a jammed slice** (+2 hits ≈ run-to-run +noise; a higher jam gain repeats the same picture), and the mechanism behind +that is the central finding: re-running the jammed cell with +`DEVOURER_RX_KEEP_CORRUPTED=1` delivers **zero FCS-failed frames** — the +missing 50% never reach the FCS stage at all. The jam kills wide frames at +the detection/AGC/sync level, *upstream* of the equalizer the CSI mask +programs, and equally upstream of the sub-block salvage layer +(`docs/fused-fec.md`), which needs a delivered-but-corrupt body to work on. +The mask remains the right tool for what the vendor built it for — a narrow +in-band interferer riding on top of decodable frames — but it cannot emulate +punctured *reception* of a wide PPDU. + +**TX-side avoidance is the lever that works.** Dropping the transmission to +the clean 40 MHz half recovers most of the delivery (78% of clean-80). It is +also cheap and per-packet-capable: bandwidth and rate ride in the radiotap +header, the primary-channel offset in `SelectedChannel.ChannelOffset`, and an +intra-band retune costs ~1.5 ms (`docs/frequency-hopping.md`). Wi-Fi 7 keeps +the punctured channel's full clean tones where this fallback pays the +next-narrower-bandwidth quantization (80 → 40 loses the clean ch44 slice +too) — that gap, [clean][clean][dirty][clean] → 60 MHz usable vs 40 MHz +usable, is the real cost of not having puncturing signaling. + +## Finding the dirty slice + +Two detectors measured (`tests/pseudo_puncture_detect.sh`): + +- **Per-tone SNR self-sounding** (`sound` mode) — the MU-report machinery + from `docs/beamforming-self-sounding.md`, sounding each 20 MHz slice in + turn. Measured caveat: the report describes the channel **at the + beamformee**. On a bench where the sounding pair sits point-blank (NDP SNR + ~50 dB) the jam neither suppresses the report count nor moves the per-tone + SNR beyond its ±5 dB wobble — the detector does not discriminate the + jammed slice. Use it when the sounding peer *is* the far end of the link + whose spectrum you care about. +- **Victim-link probe scan** (`probe` mode) — hop the actual TX/RX pair + across the four 20 MHz slices and compare delivery. Picks the jammed slice + correctly (29 hits on ch48 vs 35-37 elsewhere). Crude, but it senses the + only channel that matters — the victim's own — and needs no extra + hardware. `FastRetune` makes the scan cheap enough to repeat. + +## Verdict + +| mechanism | works? | notes | +|--------------------------------------|--------|------------------------------------------| +| TX tone nulling / true puncturing | no | no silicon hook, no VHT signaling | +| RX CSI mask against a jammed slice | no | loss is pre-FCS (sync/AGC), mask is later | +| RX CSI mask against in-band spurs | yes | the vendor's original use; knob now open | +| Sub-block salvage of jammed frames | no | nothing delivered to salvage | +| TX avoidance (narrower, clean half) | yes | 78% of clean delivery; per-packet capable | +| Detection: self-sounding per-tone SNR| partly | senses the peer's channel, not the victim's | +| Detection: victim-link probe scan | yes | picks the jammed slice | + +So the honest Jaguar rendition of "[clean][clean][skip][clean]" is: +**probe-scan the slices, then transmit around the dirty one at the widest +clean bandwidth, re-scanning on a cadence** — puncturing by primary-channel +selection, at the cost of the bandwidth quantization Wi-Fi 7 avoids. diff --git a/src/ToneMask.h b/src/ToneMask.h new file mode 100644 index 0000000..aaa0c36 --- /dev/null +++ b/src/ToneMask.h @@ -0,0 +1,460 @@ +/* ToneMask — RX-side per-subcarrier masking, shared across chip generations. + * + * Two receive-path mechanisms ported from vendor phydm (phydm_api.c): + * + * CSI mask — a per-tone receive-equalizer de-weight table. Tells the BB "these + * subcarriers carry garbage" so a jammed slice stops dragging the whole-frame + * channel estimate / equalization down. This is the closest an 11ac chip gets + * to Wi-Fi 7 preamble puncturing, and only on the RX side: the vendor wrapper + * (phydm_csi_mask_setting) masks a handful of tones around one interferer + * frequency; here the low-level per-tone setter is looped so an entire 20 MHz + * slice of an 80 MHz channel can be de-weighted (DEVOURER_RX_CSI_MASK). + * + * NBI notch — a narrowband-interference notch filter at one tone position + * (phydm_nbi_setting). Much coarser lever (one notch, LUT-quantized), kept + * for vendor parity as DEVOURER_RX_NBI. + * + * Neither touches the TX path — the silicon exposes no TX tone nulling, and a + * VHT preamble must be contiguous full-band (true puncturing is 11be EHT-SIG + * signaling). See docs/pseudo-preamble-puncturing.md. + * + * Register recipes transcribed from: + * 11ac (Jaguar-1 8812/8811/8821/8814 + Jaguar-2 8822B): + * reference/rtl8812au/hal/phydm/phydm_api.c — phydm_csi_mask_enable + * (0x874[0]), phydm_set_csi_mask (bit arrays 0x880-0x88F positive / + * 0x890-0x89F negative, one bit per 312.5 kHz subcarrier), + * phydm_set_nbi_reg (LUT -> 0x87C[19:14]), phydm_nbi_enable (0x87C[13]; + * 8822B additionally 0xC20[28]/0xE20[28]). + * Jaguar-3 (8822C/8822E): + * reference/rtl88x2cu/hal/phydm/phydm_api.c — the _jgr3 variants: CSI mask + * is an indexed table behind 0x1D94 (0x1EE8[1:0] write bracket, BYTE2 = + * table address = tone_idx/2, [7:0] = two 4-bit tone entries: BIT3 enable | + * 3-bit weight), enable 0xC0C[3]; NBI tone index at 0x1944/0x4044[20:12] + * per path, enable 0x818[3] (inverted on 8822C/E) + 0x818[11] + + * 0x1D3C[30:27] + 0x1940/0x4040[31]. + * + * All tone math is in 312.5 kHz subcarrier units relative to the wide channel's + * center frequency fc (phydm_find_fc port). The pure helpers (find_fc_mhz, + * enumerate_tones, parse_*) have no device dependency so they can be unit- + * checked headlessly. + */ +#ifndef DEVOURER_TONE_MASK_H +#define DEVOURER_TONE_MASK_H + +#include +#include +#include +#include +#include + +#include "RtlUsbAdapter.h" +#include "SelectedChannel.h" +#include "logger.h" + +namespace devourer { +namespace tonemask { + +/* Which register recipe applies. AC1 = 8812/8811/8821 (NBI LUT always + * 256-FFT); AC2 = 8814A / 8822B (NBI LUT 128-FFT at 20/40, 256-FFT at 80; + * 8822B adds the 0xC20/0xE20 NBI enable bits). */ +enum class Family { AC1, AC2_8814, AC2_8822B, JGR3 }; + +/* Vendor secondary-channel position (PHYDM_DONT_CARE/ABOVE/BELOW). */ +enum SecCh : int { kSecDontCare = 0, kSecAbove = 1, kSecBelow = 2 }; + +/* HAL_PRIME_CHNL_OFFSET_* (SelectedChannel.ChannelOffset) -> vendor secondary + * position: primary LOWER means the secondary (and fc) sit ABOVE the primary. */ +inline int sec_from_prime_offset(uint8_t prime_offset) { + if (prime_offset == 1) return kSecAbove; /* HAL_PRIME_CHNL_OFFSET_LOWER */ + if (prime_offset == 2) return kSecBelow; /* HAL_PRIME_CHNL_OFFSET_UPPER */ + return kSecDontCare; +} + +inline uint32_t bw_mhz_from(ChannelWidth_t bw) { + switch (bw) { + case CHANNEL_WIDTH_40: return 40; + case CHANNEL_WIDTH_80: return 80; + default: return 20; /* 20 + the Jaguar-3 5/10 MHz re-clocks tune as 20 */ + } +} + +/* phydm_find_fc port — wide-channel center frequency in MHz from the PRIMARY + * 20 MHz channel + bandwidth (+ secondary position, 2.4 GHz 40 MHz only). + * Returns 0 on an invalid combination. */ +inline uint32_t find_fc_mhz(uint32_t channel, uint32_t bw, int sec_ch) { + /* 2.4G */ + if (channel >= 1 && channel <= 14) { + if (bw == 80) return 0; + uint32_t fc = 2412 + (channel - 1) * 5; + if (bw == 40 && sec_ch == kSecAbove) { + if (channel >= 10) return 0; + fc += 10; + } else if (bw == 40 && sec_ch == kSecBelow) { + if (channel <= 2) return 0; + fc -= 10; + } + return fc; + } + /* 5G */ + if (channel >= 16 && channel <= 253) { + static constexpr uint32_t start_40m[] = {20, 28, 36, 44, 52, 60, 68, 76, + 84, 92, 100, 108, 116, 124, 132, + 140, 149, 157, 165, 173, 181, 189, + 197, 205, 213, 221, 229, 237, 245}; + static constexpr uint32_t start_80m[] = {20, 36, 52, 68, 84, 100, 116, + 132, 149, 165, 181, 197, 213, 229}; + if (bw == 40 || bw == 80) { + const uint32_t *start = (bw == 40) ? start_40m : start_80m; + size_t n = (bw == 40) ? sizeof(start_40m) / sizeof(start_40m[0]) + : sizeof(start_80m) / sizeof(start_80m[0]); + uint32_t off = (bw == 40) ? 2 : 6; /* CH_OFFSET_40M / CH_OFFSET_80M */ + for (size_t i = 0; i + 1 < n; i++) { + if (channel < start[i + 1]) { + channel = start[i] + off; + break; + } + } + } + return 5080 + (channel - 16) * 5; + } + return 0; +} + +/* Signed subcarrier indices (312.5 kHz units, relative to fc) covering + * [f_lo_khz, f_hi_khz], clipped to the RF bandwidth edge (bw_mhz * 1.6 = + * bw/2 / 0.3125 subcarriers each side). */ +inline std::vector enumerate_tones(uint32_t fc_mhz, uint32_t bw_mhz, + uint32_t f_lo_khz, uint32_t f_hi_khz) { + std::vector tones; + if (!fc_mhz || f_hi_khz < f_lo_khz) return tones; + int64_t fc_khz = int64_t(fc_mhz) * 1000; + int k_edge = int(bw_mhz * 8 / 5); /* bw/2 / 0.3125 */ + /* k*312.5 kHz = k*625/2 — ceil/floor in integer math on 2*offset/625. */ + int64_t lo2 = (int64_t(f_lo_khz) - fc_khz) * 2; + int64_t hi2 = (int64_t(f_hi_khz) - fc_khz) * 2; + auto div_ceil = [](int64_t a, int64_t b) { + return (a >= 0) ? (a + b - 1) / b : -((-a) / b); + }; + auto div_floor = [](int64_t a, int64_t b) { + return (a >= 0) ? a / b : -((-a + b - 1) / b); + }; + int k_lo = int(div_ceil(lo2, 625)); + int k_hi = int(div_floor(hi2, 625)); + if (k_lo > k_hi && f_lo_khz == f_hi_khz) { + /* Single-frequency spec — mask the nearest tone (vendor rounding). */ + int64_t k10 = (int64_t(f_lo_khz) - fc_khz) * 32 / 1000; /* x10 units */ + k_lo = k_hi = int((k10 + ((k10 >= 0) ? 5 : -5)) / 10); + } + for (int k = k_lo; k <= k_hi; k++) { + if (k < -k_edge || k > k_edge) continue; + tones.push_back(k); + } + return tones; +} + +/* ---- env spec parsing ---------------------------------------------------- */ + +struct CsiMaskSpec { + bool valid = false; + uint32_t f_lo_khz = 0, f_hi_khz = 0; + uint8_t wgt = 7; /* Jaguar-3 3-bit suppression weight (7 = strongest) */ +}; + +/* "5210-5230" (MHz range) | "5220" (single frequency) with optional "/W" + * Jaguar-3 weight suffix, e.g. "5210-5230/7". */ +inline CsiMaskSpec parse_csi_spec(const char *s) { + CsiMaskSpec spec; + if (!s || !*s) return spec; + char *end = nullptr; + unsigned long lo = std::strtoul(s, &end, 10); + if (end == s) return spec; + unsigned long hi = lo; + if (*end == '-') { + const char *p = end + 1; + hi = std::strtoul(p, &end, 10); + if (end == p) return spec; + } + if (*end == '/') { + const char *p = end + 1; + unsigned long w = std::strtoul(p, &end, 10); + if (end != p && w <= 7) spec.wgt = uint8_t(w); + } + if (*end != '\0' || hi < lo) return spec; + spec.f_lo_khz = uint32_t(lo) * 1000; + spec.f_hi_khz = uint32_t(hi) * 1000; + spec.valid = true; + return spec; +} + +/* ---- 11ac (Jaguar-1 + Jaguar-2) ------------------------------------------ */ + +/* CSI-mask bit arrays: 0x880-0x88F = positive tones 0..127 (one bit per + * subcarrier), 0x890-0x89F = negative (bit index 128 - |k|). Builds the full + * 32-byte shadow host-side and writes 8 dwords, then sets the 0x874[0] enable. + * Returns the number of tones masked. */ +inline size_t csi_mask_apply_11ac(RtlUsbAdapter &dev, uint32_t fc_mhz, + uint32_t bw_mhz, const CsiMaskSpec &spec) { + auto tones = enumerate_tones(fc_mhz, bw_mhz, spec.f_lo_khz, spec.f_hi_khz); + uint8_t shadow[32] = {}; + for (int k : tones) { + uint32_t idx; + if (k >= 0) { /* FREQ_POSITIVE */ + idx = (uint32_t(k) >= 127) ? 127 : uint32_t(k); + } else { + uint32_t a = uint32_t(-k); + idx = 128 + (128 - ((a >= 128) ? 128 : a)); + if (idx >= 256) idx = 255; + } + shadow[idx >> 3] |= uint8_t(1u << (idx & 7)); + } + for (int i = 0; i < 8; i++) { + uint32_t dw = uint32_t(shadow[i * 4]) | (uint32_t(shadow[i * 4 + 1]) << 8) | + (uint32_t(shadow[i * 4 + 2]) << 16) | + (uint32_t(shadow[i * 4 + 3]) << 24); + dev.rtw_write32(uint16_t(0x880 + i * 4), dw); + } + dev.phy_set_bb_reg(0x874, 0x1, tones.empty() ? 0 : 1); + return tones.size(); +} + +inline void csi_mask_clear_11ac(RtlUsbAdapter &dev) { + for (int i = 0; i < 8; i++) + dev.rtw_write32(uint16_t(0x880 + i * 4), 0); + dev.phy_set_bb_reg(0x874, 0x1, 0); +} + +/* NBI notch, 11ac: quantize the tone offset (x10 fixed point, vendor units) + * into the register LUT and write 0x87C[19:14] + the per-family enables. + * f_intf must fall inside the tuned bandwidth. */ +inline bool nbi_apply_11ac(RtlUsbAdapter &dev, Family fam, uint32_t fc_mhz, + uint32_t bw_mhz, uint32_t f_intf_mhz, + bool paths_ge_2t) { + /* tone_idx x10 units */ + static constexpr uint32_t nbi_128[] = {25, 55, 85, 115, 135, 155, 185, 205, + 225, 245, 265, 285, 305, 335, 355, + 375, 395, 415, 435, 455, 485, 505, + 525, 555, 585, 615, 635}; + static constexpr uint32_t nbi_256[] = { + 25, 55, 85, 115, 135, 155, 175, 195, 225, 245, 265, 285, + 305, 325, 345, 365, 385, 405, 425, 445, 465, 485, 505, 525, + 545, 565, 585, 605, 625, 645, 665, 695, 715, 735, 755, 775, + 795, 815, 835, 855, 875, 895, 915, 935, 955, 975, 995, 1015, + 1035, 1055, 1085, 1105, 1125, 1145, 1175, 1195, 1225, 1255, 1275}; + uint32_t bw_up = fc_mhz + bw_mhz / 2, bw_low = fc_mhz - bw_mhz / 2; + if (f_intf_mhz < bw_low || f_intf_mhz > bw_up) return false; + uint32_t dist = (f_intf_mhz > fc_mhz) ? (f_intf_mhz - fc_mhz) + : (fc_mhz - f_intf_mhz); + uint32_t idx10 = dist << 5; /* 10 * (dist / 0.3125) */ + + bool use_256 = (fam == Family::AC1) || (bw_mhz == 80); + const uint32_t *lut = use_256 ? nbi_256 : nbi_128; + size_t n = use_256 ? sizeof(nbi_256) / sizeof(nbi_256[0]) + : sizeof(nbi_128) / sizeof(nbi_128[0]); + uint32_t reg_idx = 0; + for (size_t i = 0; i < n; i++) { + if (idx10 < lut[i]) { + reg_idx = uint32_t(i) + 1; + break; + } + } + dev.phy_set_bb_reg(0x87c, 0xfc000, reg_idx); + dev.phy_set_bb_reg(0x87c, 1u << 13, 1); + if (fam == Family::AC2_8822B) { + dev.phy_set_bb_reg(0xc20, 1u << 28, 1); + if (paths_ge_2t) dev.phy_set_bb_reg(0xe20, 1u << 28, 1); + } + return true; +} + +inline void nbi_disable_11ac(RtlUsbAdapter &dev, Family fam, + bool paths_ge_2t) { + dev.phy_set_bb_reg(0x87c, 1u << 13, 0); + if (fam == Family::AC2_8822B) { + dev.phy_set_bb_reg(0xc20, 1u << 28, 0); + if (paths_ge_2t) dev.phy_set_bb_reg(0xe20, 1u << 28, 0); + } +} + +/* ---- Jaguar-3 (8822C / 8822E) -------------------------------------------- */ + +/* The CSI-mask table lives behind an indexed window: 0x1EE8[1:0]=3 opens it, + * 0x1D94[31:30]=1 selects write mode, BYTE2 = table address (= tone_idx/2), + * [7:0] = two 4-bit entries (low nibble even tone, high nibble odd tone), + * each BIT3-enable | 3-bit weight. tone_num tracks the RF bandwidth via + * 0x9B0[3:2] (RF80 -> 128, else 64). Adjacent tones share a table byte, so the + * per-address byte is accumulated host-side first — the vendor's one-tone + * setter would clobber its neighbour's nibble when looped over a range. */ +inline size_t csi_mask_apply_jgr3(RtlUsbAdapter &dev, uint32_t fc_mhz, + uint32_t bw_mhz, const CsiMaskSpec &spec) { + uint8_t rf_bw = dev.rtw_read8(0x9b0); + uint32_t tone_num = (((rf_bw & 0xc) >> 2) == 0x2) ? 128 : 64; + auto tones = enumerate_tones(fc_mhz, bw_mhz, spec.f_lo_khz, spec.f_hi_khz); + + uint8_t table[128] = {}; /* 256 tone entries / 2 per byte */ + uint8_t nib = uint8_t(0x8 | (spec.wgt & 0x7)); + for (int k : tones) { + uint32_t idx; + if (k >= 0) { + idx = (uint32_t(k) >= tone_num - 1) ? (tone_num - 1) : uint32_t(k); + } else { + uint32_t a = uint32_t(-k); + if (a >= tone_num) a = tone_num; + idx = (tone_num << 1) - a; + } + if (idx >= 256) continue; + table[idx >> 1] |= (idx & 1) ? uint8_t(nib << 4) : nib; + } + + uint32_t idx_lmt = tone_num; /* full table for the current FFT size */ + dev.phy_set_bb_reg(0x1ee8, 0x3, 0x3); + dev.phy_set_bb_reg(0x1d94, 0xc0000000, 0x1); + for (uint32_t a = 0; a < idx_lmt; a++) { + dev.phy_set_bb_reg(0x1d94, 0x00ff0000, a); + dev.phy_set_bb_reg(0x1d94, 0x000000ff, table[a]); + } + dev.phy_set_bb_reg(0x1ee8, 0x3, 0x0); + dev.phy_set_bb_reg(0xc0c, 1u << 3, tones.empty() ? 0 : 1); + return tones.size(); +} + +inline void csi_mask_clear_jgr3(RtlUsbAdapter &dev) { + dev.phy_set_bb_reg(0x1ee8, 0x3, 0x3); + dev.phy_set_bb_reg(0x1d94, 0xc0000000, 0x1); + for (uint32_t a = 0; a < 128; a++) { + dev.phy_set_bb_reg(0x1d94, 0x00ff0000, a); + dev.phy_set_bb_reg(0x1d94, 0x000000ff, 0); + } + dev.phy_set_bb_reg(0x1ee8, 0x3, 0x0); + dev.phy_set_bb_reg(0xc0c, 1u << 3, 0); +} + +/* NBI notch, Jaguar-3: tone index (312.5 kHz units, kHz-exact per the x2cu + * tree) into 0x1944/0x4044[20:12] per path; enable 0x818[3]=0 (inverted on + * 8822C/E) + 0x818[11]=1 + 0x1D3C[30:27]=0xF + 0x1940/0x4040[31]. */ +inline bool nbi_apply_jgr3(RtlUsbAdapter &dev, uint32_t fc_mhz, + uint32_t bw_mhz, uint32_t f_intf_khz, int n_paths) { + uint32_t fc_khz = fc_mhz * 1000; + uint32_t bw_up = fc_khz + bw_mhz * 500, bw_low = fc_khz - bw_mhz * 500; + if (f_intf_khz < bw_low || f_intf_khz > bw_up) return false; + uint32_t dist = (f_intf_khz > fc_khz) ? (f_intf_khz - fc_khz) + : (fc_khz - f_intf_khz); + uint32_t idx = dist / 312; + + uint8_t rf_bw = dev.rtw_read8(0x9b0); + uint32_t tone_num = (((rf_bw & 0xc) >> 2) == 0x2) ? 128 : 64; + if (f_intf_khz >= fc_khz) { /* FREQ_POSITIVE */ + if (idx >= tone_num - 1) idx = tone_num - 1; + } else { + if (idx >= tone_num) idx = tone_num; + idx = (tone_num << 1) - idx; + } + + static constexpr uint16_t tone_reg[] = {0x1944, 0x4044}; + static constexpr uint16_t en_reg[] = {0x1940, 0x4040}; + if (n_paths > 2) n_paths = 2; /* 8822C/E are 2T2R */ + for (int p = 0; p < n_paths; p++) + dev.phy_set_bb_reg(tone_reg[p], 0x001FF000, idx); + + dev.phy_set_bb_reg(0x818, 1u << 3, 0); /* !enable on 8822C/8822E */ + dev.phy_set_bb_reg(0x818, 1u << 11, 1); + dev.phy_set_bb_reg(0x1d3c, 0x78000000, 0xf); + for (int p = 0; p < n_paths; p++) + dev.phy_set_bb_reg(en_reg[p], 1u << 31, 1); + return true; +} + +/* phydm_nbi_reset_jgr3 (8822C/E subset). */ +inline void nbi_disable_jgr3(RtlUsbAdapter &dev) { + dev.phy_set_bb_reg(0x818, 1u << 3, 1); + dev.phy_set_bb_reg(0x1d3c, 0x78000000, 0); + dev.phy_set_bb_reg(0x818, 1u << 3, 0); + dev.phy_set_bb_reg(0x818, 1u << 11, 0); + dev.phy_set_bb_reg(0x1940, 1u << 31, 0); + dev.phy_set_bb_reg(0x4040, 1u << 31, 0); +} + +/* ---- env-driven entry point ---------------------------------------------- + * + * Reads DEVOURER_RX_CSI_MASK / DEVOURER_RX_NBI and applies whichever is set, + * for the current channel state. Call after the channel set (like + * DEVOURER_RX_PATHS, the mask is the final word for a single-channel capture; + * a later channel switch does not re-derive it). n_paths: RX chains to arm for + * the Jaguar-3 NBI / the 8822B second-path enable. */ +inline void apply_from_env(RtlUsbAdapter &dev, Logger_t logger, Family fam, + const SelectedChannel &ch, int n_paths) { + const char *csi_env = std::getenv("DEVOURER_RX_CSI_MASK"); + const char *nbi_env = std::getenv("DEVOURER_RX_NBI"); + if (!csi_env && !nbi_env) return; + + uint32_t bw_mhz = bw_mhz_from(ch.ChannelWidth); + int sec = sec_from_prime_offset(ch.ChannelOffset); + uint32_t fc = find_fc_mhz(ch.Channel, bw_mhz, sec); + if (!fc) { + logger->error("ToneMask: no fc for ch={} bw={} sec={} — knobs ignored", + unsigned(ch.Channel), bw_mhz, sec); + return; + } + + if (csi_env) { + CsiMaskSpec spec = parse_csi_spec(csi_env); + if (!spec.valid) { + logger->error("DEVOURER_RX_CSI_MASK — bad spec '{}' " + "(want [-][/wgt], MHz)", csi_env); + } else { + size_t masked = (fam == Family::JGR3) + ? csi_mask_apply_jgr3(dev, fc, bw_mhz, spec) + : csi_mask_apply_11ac(dev, fc, bw_mhz, spec); + if (fam == Family::JGR3) { + logger->info("DEVOURER_RX_CSI_MASK: {} tones masked (fc={} MHz, " + "wgt={}), 0xc0c=0x{:08x}", + masked, fc, spec.wgt, dev.rtw_read32(0xc0c)); + } else { + logger->info("DEVOURER_RX_CSI_MASK: {} tones masked (fc={} MHz), " + "0x874=0x{:08x} mask[0x880..0x89c]={:08x} {:08x} {:08x} " + "{:08x} {:08x} {:08x} {:08x} {:08x}", + masked, fc, dev.rtw_read32(0x874), dev.rtw_read32(0x880), + dev.rtw_read32(0x884), dev.rtw_read32(0x888), + dev.rtw_read32(0x88c), dev.rtw_read32(0x890), + dev.rtw_read32(0x894), dev.rtw_read32(0x898), + dev.rtw_read32(0x89c)); + } + if (!masked) + logger->warn("DEVOURER_RX_CSI_MASK: spec '{}' covers no tone inside " + "the tuned {} MHz channel — mask disabled", csi_env, + bw_mhz); + } + } + + if (nbi_env) { + char *end = nullptr; + unsigned long f_mhz = std::strtoul(nbi_env, &end, 10); + if (end == nbi_env || *end != '\0') { + logger->error("DEVOURER_RX_NBI — bad frequency '{}' (want MHz)", nbi_env); + } else { + bool ok = (fam == Family::JGR3) + ? nbi_apply_jgr3(dev, fc, bw_mhz, uint32_t(f_mhz) * 1000, + n_paths) + : nbi_apply_11ac(dev, fam, fc, bw_mhz, uint32_t(f_mhz), + n_paths >= 2); + if (ok) { + if (fam == Family::JGR3) + logger->info("DEVOURER_RX_NBI: notch at {} MHz (fc={}), " + "0x818=0x{:08x} 0x1944=0x{:08x}", + f_mhz, fc, dev.rtw_read32(0x818), + dev.rtw_read32(0x1944)); + else + logger->info("DEVOURER_RX_NBI: notch at {} MHz (fc={}), " + "0x87c=0x{:08x}", + f_mhz, fc, dev.rtw_read32(0x87c)); + } else { + logger->warn("DEVOURER_RX_NBI: {} MHz outside the tuned {} MHz " + "channel (fc={}) — notch not armed", f_mhz, bw_mhz, fc); + } + } + } +} + +} /* namespace tonemask */ +} /* namespace devourer */ + +#endif /* DEVOURER_TONE_MASK_H */ diff --git a/src/jaguar1/RtlJaguarDevice.cpp b/src/jaguar1/RtlJaguarDevice.cpp index 5403490..78268e3 100644 --- a/src/jaguar1/RtlJaguarDevice.cpp +++ b/src/jaguar1/RtlJaguarDevice.cpp @@ -3,6 +3,7 @@ #include "EepromManager.h" #include "RadioManagementModule.h" #include "SignalStop.h" +#include "ToneMask.h" #include #include @@ -494,6 +495,20 @@ void RtlJaguarDevice::StartRxLoop(Action_ParsedRadioPacket packetProcessor) { } } + /* DEVOURER_RX_CSI_MASK / DEVOURER_RX_NBI — RX-side per-subcarrier masking + * (receive-equalizer CSI mask / narrowband notch), the poor-man's preamble + * puncturing. Applied here for the same reason as DEVOURER_RX_PATHS: after + * the channel set it is the final word; a later channel switch reverts it, + * so it targets a single-channel RX capture. See ToneMask.h. */ + { + auto fam = (_eepromManager->version_id.ICType == CHIP_8814A) + ? devourer::tonemask::Family::AC2_8814 + : devourer::tonemask::Family::AC1; + devourer::tonemask::apply_from_env( + _device, _logger, fam, _channel, + (_eepromManager->version_id.ICType == CHIP_8814A) ? 4 : 2); + } + _logger->info("Listening air..."); /* Keep several bulk-IN transfers in flight at once (mirrors the kernel's ~4 * always-posted RX URBs — confirmed via usbmon: rtw88 re-submits each URB diff --git a/src/jaguar2/RtlJaguar2Device.cpp b/src/jaguar2/RtlJaguar2Device.cpp index 0c0c114..782c2f8 100644 --- a/src/jaguar2/RtlJaguar2Device.cpp +++ b/src/jaguar2/RtlJaguar2Device.cpp @@ -13,6 +13,7 @@ #include "FrameParserJaguar2.h" #include "Halrf8822b.h" +#include "ToneMask.h" #include "RateDefinitions.h" #include "RxPacket.h" #include "SignalStop.h" /* g_devourer_should_stop */ @@ -148,6 +149,13 @@ void RtlJaguar2Device::StartRxLoop(Action_ParsedRadioPacket packetProcessor) { _logger->info("RtlJaguar2Device: DIG thread started"); } + /* DEVOURER_RX_CSI_MASK / DEVOURER_RX_NBI — RX-side per-subcarrier masking + * (receive-equalizer CSI mask / narrowband notch). Applied after the channel + * set so it is the final word for a single-channel capture. See ToneMask.h. */ + devourer::tonemask::apply_from_env(_device, _logger, + devourer::tonemask::Family::AC2_8822B, + _channel, 2); + _logger->info("RtlJaguar2Device: entering RX loop (ch={})", _channel.Channel); /* RX loop: async bulk-IN URB queue; walk the aggregated 8822B RX descriptors diff --git a/src/jaguar3/RtlJaguar3Device.cpp b/src/jaguar3/RtlJaguar3Device.cpp index 0047635..6a8664c 100644 --- a/src/jaguar3/RtlJaguar3Device.cpp +++ b/src/jaguar3/RtlJaguar3Device.cpp @@ -11,6 +11,7 @@ #include "FrameParserJaguar3.h" #include "RateDefinitions.h" /* MGN_* rate enum (shared across the family) */ #include "SignalStop.h" /* g_devourer_should_stop — set by demo signal handlers */ +#include "ToneMask.h" /* DEVOURER_RX_CSI_MASK / DEVOURER_RX_NBI knobs */ extern "C" { #include "ieee80211_radiotap.h" /* MRateToHwRate + radiotap iterator */ @@ -130,6 +131,17 @@ void RtlJaguar3Device::StartRxLoop(Action_ParsedRadioPacket packetProcessor) { } } + /* DEVOURER_RX_CSI_MASK / DEVOURER_RX_NBI — RX-side per-subcarrier masking + * (receive-equalizer CSI mask / narrowband notch). Applied after the channel + * set so it is the final word for a single-channel capture; serialized + * against the coex housekeeping tick like every other register writer here. + * See ToneMask.h. */ + if (std::getenv("DEVOURER_RX_CSI_MASK") || std::getenv("DEVOURER_RX_NBI")) { + std::lock_guard lk(_reg_mu); + devourer::tonemask::apply_from_env( + _device, _logger, devourer::tonemask::Family::JGR3, _channel, 2); + } + _logger->info("Jaguar3: entering RX loop (kernel-style async URB queue)"); uint64_t frames = 0, reads = 0; /* Process one bulk-IN completion: walk the aggregated 8822C RX descriptors. */ diff --git a/tests/pseudo_puncture_detect.sh b/tests/pseudo_puncture_detect.sh new file mode 100755 index 0000000..0a1b9b0 --- /dev/null +++ b/tests/pseudo_puncture_detect.sh @@ -0,0 +1,182 @@ +#!/usr/bin/env bash +# pseudo_puncture_detect.sh — the DETECTION half of the pseudo-puncturing +# study (docs/pseudo-preamble-puncturing.md): locate the jammed 20 MHz slice +# of the ch36 80 MHz block. B210 AWGN jams the ch48 slice (5230-5250). +# +# Two detector modes (MODE arg): +# +# sound — per-tone SNR via MU self-sounding (docs/beamforming-self-sounding +# .md): sound each 20 MHz slice in turn, decode the beamformee's MU +# report. Roles: 8822EU sounder (0bda:a81a), 8812CU MU beamformee +# (0bda:c812), 8814AU sniffer (0bda:8813 — RX-only role, works even +# when its TX FW-boot fails). MEASURED CAVEAT: this senses the +# channel AT THE BEAMFORMEE; on a bench where the sounding pair sits +# point-blank (NDP SNR ~50 dB) the jam barely dents the report and +# the detector does NOT discriminate the slice. +# +# probe — per-slice link probing with the victim pair itself: TX (8822BU +# T3U) + RX (8814AU) hop 20 MHz across ch36/40/44/48 and compare +# canonical-SA hit rates. The jammed slice is the delivery minimum. +# This senses the victim's own channel — the quantity that matters. +# +# Usage: sudo tests/pseudo_puncture_detect.sh [sound|probe|both] [JAM_GAIN] [DUR] + +set -u +cd "$(dirname "$0")/.." + +MODE=${1:-probe} +JAM_GAIN=${2:-70} +DUR=${3:-12} + +SND_PID=0xa81a # 8822EU sounder +BFEE_PID=0xc812 # 8812CU MU beamformee +SNF_PID=0x8813 # 8814AU sniffer +PROBE_TX_VID=2357; PROBE_TX_PID=0x012d # 8822BU T3U +PROBE_RX_VID=0bda; PROBE_RX_PID=0x8813 # 8814AU + +BFER_MAC="57:42:75:05:d6:00" # canonical SA = NDPA TA +BFEE_MAC="00:e0:4c:88:22:ce" # 8812CU EFUSE MAC (see tests/bf_waterfall.sh) + +JAM_FREQ=5240e6 # center of the ch48 slice (5230-5250) +JAM_RATE=20e6 + +LOGDIR=/tmp/devourer-pseudo-puncture-detect +mkdir -p "$LOGDIR" + +JAM_PID="" +cleanup() { + [ -n "$JAM_PID" ] && kill -INT "$JAM_PID" 2>/dev/null + pkill -INT -x WiFiDriverTxDemo 2>/dev/null + pkill -INT -x WiFiDriverDemo 2>/dev/null + pkill -INT -f sdr_interferer 2>/dev/null + sleep 1 + pkill -KILL -x WiFiDriverTxDemo 2>/dev/null + pkill -KILL -x WiFiDriverDemo 2>/dev/null + pkill -KILL -f sdr_interferer 2>/dev/null +} +trap cleanup EXIT INT TERM + +jam_start() { + python3 tests/sdr_interferer.py --freq "$JAM_FREQ" --rate "$JAM_RATE" \ + --tx-gain "$JAM_GAIN" >"$LOGDIR/jammer.log" 2>&1 & + JAM_PID=$! + sleep 3 + kill -0 "$JAM_PID" 2>/dev/null || { + echo "FATAL: jammer died — $LOGDIR/jammer.log"; exit 2; } +} + +jam_stop() { + [ -n "$JAM_PID" ] && { kill -INT "$JAM_PID" 2>/dev/null; wait "$JAM_PID" 2>/dev/null; } + JAM_PID="" + sleep 1 +} + +# ---- sound mode ----------------------------------------------------------- + +sound_slice() { #