Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ add_library(WiFiDriver
src/RateDefinitions.h
src/RxPacket.h
src/TxDescBits.h
src/BeamformingSounder.h
src/TxMode.cpp
src/TxMode.h
src/IRtlDevice.h
Expand Down
57 changes: 57 additions & 0 deletions demo/main.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,63 @@ static void packetProcessor(const Packet &packet) {
packet.RxAtrib.snr[0], packet.RxAtrib.snr[1]);
fflush(stdout);
}
/* BF self-sounding report detector. A VHT/HT Compressed Beamforming report
* is a management Action frame — subtype 0xD0 (Action) or 0xE0 (Action
* No-Ack; CSI reports are sent No-Ack) — whose body begins with a category
* + action of VHT cat 0x15 act 0x00, or HT cat 0x07 act 0x00. When an armed
* beamformee replies to our NDPA+NDP the report's SA is the beamformee MAC,
* the decode-level confirmation the unassociated responder produced CSI.
*
* DEVOURER_BF_DETECT_REPORT modes:
* 1 <devourer-bf-report> summary (Nc/Nr/BW/Ng, SA) per report
* 2 <devourer-bf-any> FC/category of EVERY frame (subtype survey)
* 3 mode 1 + capped CSI-payload hexdump (first frames)
* 4 mode 1 + <devourer-bf-report-raw> full frame hex (for the decoder,
* tools/bf_report_decode.py) */
if (const char *mode_s = std::getenv("DEVOURER_BF_DETECT_REPORT")) {
if (packet.Data.size() >= 27) {
const char mode = mode_s[0];
const uint8_t *d = packet.Data.data();
const uint8_t cat = d[24], act = d[25];
const bool vht = (cat == 0x15 && act == 0x00);
const bool ht = (cat == 0x07 && act == 0x00);
if (mode == '2') {
static int any = 0;
if (++any <= 4000)
printf("<devourer-bf-any>fc=%02x%02x cat=%02x act=%02x crc=%u "
"len=%zu\n", d[0], d[1], cat, act,
packet.RxAtrib.crc_err ? 1u : 0u, packet.Data.size());
}
const uint8_t sub = d[0] & 0xF0;
if ((sub == 0xD0 || sub == 0xE0) && (vht || ht)) {
static int rpt = 0;
++rpt;
const uint8_t *mc = d + 26; /* VHT MIMO control field */
unsigned nc = (mc[0] & 0x07) + 1, nr = ((mc[0] >> 3) & 0x07) + 1;
unsigned chw = (mc[0] >> 6) & 0x03, ng = mc[1] & 0x03;
printf("<devourer-bf-report>%s n=%d sa=%02x:%02x:%02x:%02x:%02x:%02x "
"Nc=%u Nr=%u BW=%u Ng=%u len=%zu\n",
vht ? "VHT" : "HT", rpt, d[10], d[11], d[12], d[13], d[14],
d[15], nc, nr, chw, ng, packet.Data.size());
if (mode == '3' && rpt <= 6) {
size_t off = 26 + 3; /* hdr(24)+cat+act+mimoctrl(3) */
size_t end = packet.Data.size() >= 4 ? packet.Data.size() - 4 : off;
printf(" csi[%zu]:", end - off);
for (size_t i = off; i < end && i < off + 40; ++i)
printf(" %02x", d[i]);
printf("\n");
}
if (mode == '4' && rpt <= 200) {
printf("<devourer-bf-report-raw>");
for (size_t i = 0; i < packet.Data.size(); ++i)
printf("%02x", d[i]);
printf("\n");
}
fflush(stdout);
}
}
}

/* TX-validation hook: detect frames whose SA matches the txdemo's hardcoded
* injected beacon (57:42:75:05:d6:00). When running this RX demo against
* one adapter while WiFiDriverTxDemo runs against another on the same
Expand Down
94 changes: 94 additions & 0 deletions docs/beamforming-self-sounding.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
# Beamforming self-sounding — per-subcarrier CSI from two adapters

802.11ac beamforming sounding leaks per-subcarrier channel state: a beamformee
estimates the channel matrix H(k) per subcarrier from a sounding NDP, compresses
the steering matrix V(k) into Givens angles, and **transmits it back over the
air** as a VHT Compressed Beamforming report. On Jaguar-1 silicon that pipeline
is hardware-terminated — a chip cannot read back its own estimate — but the
report is addressed to the *beamformer*, so a second adapter (or a monitor RX)
captures it. With devourer driving both ends of the link, we sound our own
channel on demand and recover per-subcarrier CSI the chip otherwise hides.

## The exchange

```
beamformer (adapter A) beamformee (adapter B)
── NDPA (control frame) ───────────────▶ matches its own addr as RA
── NDP (HW-generated) ────────────────▶ estimates H(k) from VHT-LTFs,
SVD → V(k), compresses to angles
◀── VHT Compressed Beamforming report ── (Action No-Ack, over the air)
```

Two hardware facts made this work, both validated on real silicon:

- The NDP is **hardware-generated**: marking the injected NDPA in the TX
descriptor is not enough on its own — the MAC sounding engine must be armed
first, or no NDP follows.
- The beamformee responds **without any association**: arming the responder
registers with the beamformer's MAC (address match only, P_AID = 0) is
sufficient; no CAM/macid entry is needed.

## Register recipe

All register values are in `src/jaguar1/BeamformingSounder.h`, transcribed from
the vendor `hal_txbf_jaguar_enter()`
(`reference/rtl8812au/hal/phydm/txbf/haltxbfjaguar.c`). Entry 0, P_AID 0.

- **Beamformer (`arm_sounder`)**: `REG_SND_PTCL_CTRL` enable, NDP standby
timeout, `REG_TXBF_CTRL` NDPA-transmit enables, `REG_BFMEE_SEL` entry select.
- **Beamformee (`arm_beamformee`)**: `REG_SND_PTCL_CTRL` enable, standby
timeout, `REG_BFMER0_INFO` = beamformer MAC (matched against the NDPA TA),
`REG_CSI_RPT_PARAM_BW20/40/80` + BB CSI-content = report matrix dimensions.
- **NDPA descriptor (`mark_ndpa_descriptor`)**: TX-desc NDPA bit, no HW
sequence, unicast, use-header NAV, no rate fallback.

## Driving it (demo env vars)

```sh
# beamformee: arm to reply to sounding from the beamformer MAC (no association)
DEVOURER_VID=0x2357 DEVOURER_PID=0x0120 DEVOURER_CHANNEL=100 \
DEVOURER_BF_ARM_BFEE=<beamformer-mac> WiFiDriverDemo

# beamformer: inject an NDPA to the beamformee, arm the sounding engine
DEVOURER_PID=0x8812 DEVOURER_CHANNEL=100 DEVOURER_TX_RATE=VHT2SS_MCS0 \
DEVOURER_TX_NDPA_RA=<beamformee-mac> DEVOURER_TX_NDPA=1 \
DEVOURER_BF_ARM_SOUNDER=1 WiFiDriverTxDemo

# capture + decode the reports (any monitor RX; the beamformer is addressed but
# monitor mode is promiscuous). Mode 4 dumps full frames for the decoder.
DEVOURER_PID=0x8813 DEVOURER_CHANNEL=100 DEVOURER_BF_DETECT_REPORT=4 \
WiFiDriverDemo | tools/bf_report_decode.py
```

Gotchas that cost time during bring-up:

- CSI reports are **Action No-Ack** (FC subtype `0xE0`), not Action (`0xD0`).
A detector matching only `0xD0` sees zero frames.
- The report frames are VHT PPDUs; a weak sniffer link drops them on FCS.

## The report format (measured, 2×1 case)

For a 2-antenna beamformer sounding a 1-antenna beamformee, each report is a
99-byte frame carrying, after the 24-byte MAC header:

| field | bytes | value seen |
|---|---|---|
| Category / VHT Action | 24–25 | `0x15` / `0x00` |
| VHT MIMO Control | 26–28 | Nc=1, Nr=2, BW=20 MHz, Ng=1, codebook=1, SU |
| Avg SNR (per column) | 29 | int8, dB = 22 + 0.25·v |
| Compressed angles | 30–94 | 65 B = 520 bits |
| FCS | 95–98 | |

The angle payload is **52 subcarriers × 10 bits** = 1 φ (6 bits) + 1 ψ (4 bits)
per tone — a *compact* codebook, narrower than the textbook VHT SU sizes
(7/5 or 9/7). The φ = ψ+2 relationship matches the standard Givens structure.
`tools/bf_report_decode.py` reconstructs the per-subcarrier 2×1 steering
vector V(k) = [cos ψ_k, e^{jφ_k} sin ψ_k], i.e. the relative per-tone channel
|h_B(k)/h_A(k)| = tan ψ_k and arg = φ_k between the beamformer's two antennas.

Decode validation: cross-frame variance of ψ(k) over 200 over-air reports is
~0.0014 rad² (~2°) — the reports are repeatable; the LSB-first bit order is
confirmed by an MSB-first control blowing the variance up ~70×. On a short LOS
bench link the channel is flat across 20 MHz (coherence bandwidth exceeds the
channel), so per-tone structure is quantisation-limited; a wider bandwidth or a
multipath geometry is needed to exercise real frequency selectivity.
137 changes: 137 additions & 0 deletions src/BeamformingSounder.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
/* BeamformingSounder — Realtek 802.11ac explicit-sounding helpers for the
* two-adapter "self-sounding" CSI path, shared across chip generations.
*
* With one adapter as beamformer and another as beamformee, an NDPA +
* hardware-NDP exchange makes the beamformee emit a VHT Compressed Beamforming
* report over the air, which a monitor RX captures — giving per-subcarrier
* channel state the chip otherwise terminates in hardware. See
* docs/beamforming-self-sounding.md.
*
* WHY THIS IS GENERATION-NEUTRAL: the MAC sounding registers are byte-identical
* across the whole Realtek AC family — kSndPtclCtrl (0x718),
* kBfmer0Info (0x6E4), REG_CSI_RPT_PARAM_BW20 (0x6F4), kBfmeeSel
* (0x714), kTxbfCtrl (0x42C) are the same on 8192E/8703B/8723D/8812A/
* 8814A/8822B/8822C/8822E (verified against every vendor include tree). The
* beamformer arm (arm_sounder) is therefore fully shared. Only the beamformee
* PHY details differ per generation, and those are captured in BfeeConfig:
* - the sounding-protocol control byte (0xCB Jaguar-1 / 0xDB Jaguar-2/3);
* - the BB CSI-content register 0x9B4 is CSI content on Jaguar-1 but the
* NARROWBAND CLOCK DIVIDER on Jaguar-3 (RadioManagementJaguar3.h) — it
* MUST NOT be written there, so csi_content_reg is 0 on Jaguar-2/3;
* - Jaguar-2/3 additionally gate the responder behind RX-filter bits
* (accept NDPA + action-no-ack) and an own-AID register (0x1680).
*
* Register values transcribed from the vendor beamformee-entry functions:
* Jaguar-1 hal_txbf_jaguar_enter() (haltxbfjaguar.c)
* Jaguar-2/3 hal_txbf_8822b_enter() (haltxbf8822b.c, shared 8822B/C/E)
* Entry 0, P_AID = 0 (unassociated sounding — no CAM/macid entry).
*/
#ifndef BEAMFORMING_SOUNDER_H
#define BEAMFORMING_SOUNDER_H

#include <cstdint>

#include "RtlUsbAdapter.h"

namespace devourer {
namespace bf {

/* Shared MAC sounding registers (identical across the Realtek AC family).
* k-prefixed to avoid colliding with the REG_* preprocessor macros in the
* vendor hal_com_reg.h (macros ignore namespaces). */
enum : uint16_t {
kTxbfCtrl = 0x042C,
kRxFltMap0 = 0x06A0, /* +1 bit6 = accept action-no-ack */
kRxFltMap1 = 0x06A2, /* [5:4] = accept NDPA + BF report poll */
kBfmer0Info = 0x06E4,
kCsiRptParam20 = 0x06F4,
kCsiRptParam40 = 0x06F8,
kCsiRptParam80 = 0x06FC,
kBfmeeSel = 0x0714,
kSndPtclCtrl = 0x0718,
kSndNdpStandby = 0x071B,
kBbCsiContent = 0x09B4, /* Jaguar-1 only (clock divider on Jaguar-3) */
kOurAid = 0x1680, /* Jaguar-2/3 only: our AID [11:0] */
};

enum : uint8_t {
kTxbfNdpaEnables = 0x10 | 0x40 | 0x80, /* 0x42F BIT4|6|7: NDPA TX enable */
kRxFlt0ActNoAck = 0x40, /* 0x6A1 bit6 */
kRxFlt1NdpaPoll = 0x30, /* 0x6A2 [5:4] */
};

/* Per-generation beamformee knobs. */
struct BfeeConfig {
uint8_t snd_ptcl_ctrl; /* 0xCB Jaguar-1 / 0xDB Jaguar-2/3 */
uint8_t ndp_standby; /* 0x50 */
uint32_t csi_rpt_param; /* report matrix dims (1T1R VHT SU = 0x0109) */
bool csi_rpt_param_16; /* 16-bit @0x6F4 (Jg2/3) vs 32-bit x3 (Jg1) */
uint16_t csi_content_reg; /* 0x9B4 on Jaguar-1; 0 = skip on Jaguar-2/3 */
uint32_t csi_content_val;
bool set_our_aid; /* write 0 to 0x1680 (Jaguar-2/3) */
bool set_rxflt; /* enable NDPA/action-no-ack RX (Jaguar-2/3) */
};

/* Jaguar-1 (8812/8814/8821): BB CSI-content at 0x9B4, 32-bit params, no
* RX-filter poke (monitor mode already accepts the frames). */
constexpr BfeeConfig kBfeeJaguar1{
/*snd_ptcl_ctrl*/ 0xCB, /*ndp_standby*/ 0x50,
/*csi_rpt_param*/ 0x01080108, /*csi_rpt_param_16*/ false,
/*csi_content_reg*/ kBbCsiContent, /*csi_content_val*/ 0x01081008,
/*set_our_aid*/ false, /*set_rxflt*/ false};

/* Jaguar-2/3 (8822B/C/E): 0xDB, 16-bit param at 0x6F4, own-AID + RX-filter
* gates, and NO 0x9B4 write (that address is the narrowband clock divider). */
constexpr BfeeConfig kBfeeJaguar23{
/*snd_ptcl_ctrl*/ 0xDB, /*ndp_standby*/ 0x50,
/*csi_rpt_param*/ 0x0109, /*csi_rpt_param_16*/ true,
/*csi_content_reg*/ 0, /*csi_content_val*/ 0,
/*set_our_aid*/ true, /*set_rxflt*/ true};

/* Beamformer side (fully shared): arm the MAC sounding engine so a
* TX-descriptor-marked NDPA is followed by a hardware-generated NDP.
* Beamformee entry 0, P_AID = 0 (unassociated). */
inline void arm_sounder(RtlUsbAdapter& dev) {
dev.rtw_write8(kSndPtclCtrl, 0xCB);
dev.rtw_write8(kSndNdpStandby, 0x50);
dev.rtw_write16(kTxbfCtrl, 0x0000); /* P_AID = 0 */
uint8_t v = dev.rtw_read8(kTxbfCtrl + 3);
dev.rtw_write8(kTxbfCtrl + 3, v | kTxbfNdpaEnables);
uint8_t s = dev.rtw_read8(kBfmeeSel + 3);
dev.rtw_write8(kBfmeeSel + 3, (s & 0x03) | 0x60);
dev.rtw_write16(kBfmeeSel, 0x0200); /* entry-0 select */
}

/* Beamformee side: arm the hardware CSI responder to reply to NDPA+NDP from
* `beamformer_mac` (matched against the NDPA TA), no association required.
* `cfg` selects the per-generation register recipe. */
inline void arm_beamformee(RtlUsbAdapter& dev, const uint8_t beamformer_mac[6],
const BfeeConfig& cfg) {
dev.rtw_write8(kSndPtclCtrl, cfg.snd_ptcl_ctrl);
dev.rtw_write8(kSndNdpStandby, cfg.ndp_standby);
for (uint16_t i = 0; i < 6; ++i)
dev.rtw_write8(kBfmer0Info + i, beamformer_mac[i]);
if (cfg.set_our_aid)
dev.rtw_write16(kOurAid, 0x0000); /* our AID = 0 */
if (cfg.set_rxflt) {
uint8_t r0 = dev.rtw_read8(kRxFltMap0 + 1);
dev.rtw_write8(kRxFltMap0 + 1, r0 | kRxFlt0ActNoAck);
uint8_t r1 = dev.rtw_read8(kRxFltMap1);
dev.rtw_write8(kRxFltMap1, r1 | kRxFlt1NdpaPoll);
}
if (cfg.csi_rpt_param_16) {
dev.rtw_write16(kCsiRptParam20,
static_cast<uint16_t>(cfg.csi_rpt_param));
} else {
dev.rtw_write32(kCsiRptParam20, cfg.csi_rpt_param);
dev.rtw_write32(kCsiRptParam40, cfg.csi_rpt_param);
dev.rtw_write32(kCsiRptParam80, cfg.csi_rpt_param);
}
if (cfg.csi_content_reg)
dev.rtw_write32(cfg.csi_content_reg, cfg.csi_content_val);
}

} // namespace bf
} // namespace devourer

#endif // BEAMFORMING_SOUNDER_H
44 changes: 44 additions & 0 deletions src/jaguar1/RtlJaguarDevice.cpp
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
#include "RtlJaguarDevice.h"
#include "BeamformingSounder.h"
#include "EepromManager.h"
#include "RadioManagementModule.h"
#include "SignalStop.h"
Expand Down Expand Up @@ -30,6 +31,15 @@ void RtlJaguarDevice::InitWrite(SelectedChannel channel) {
StartWithMonitorMode(channel);
SetMonitorChannel(channel);
_logger->info("In Monitor Mode");

/* DEVOURER_BF_ARM_SOUNDER=1 — beamforming self-sounding probe (beamformer
* side): arm the MAC's hardware sounding engine so a TX-descriptor-marked
* NDPA (DEVOURER_TX_NDPA=1) is followed by a hardware-generated NDP. See
* BeamformingSounder.h for the vendor register recipe. */
if (std::getenv("DEVOURER_BF_ARM_SOUNDER")) {
devourer::bf::arm_sounder(_device);
_logger->info("BF sounder armed (beamformer side)");
}
}

/* Map a radiotap CHANNEL frequency (MHz) to a Wi-Fi channel number. Returns 0
Expand Down Expand Up @@ -344,6 +354,22 @@ bool RtlJaguarDevice::send_packet(const uint8_t *packet, size_t length) {

SET_TX_DESC_DATA_STBC_8812(usb_frame, stbc & 3);

/* DEVOURER_TX_NDPA=1 — beamforming self-sounding probe: mark the injected
* frame as an NDPA (TX-desc Dword3 [23:22]=1) so the MAC sounding engine
* follows it with a hardware-generated NDP (pairs with
* DEVOURER_BF_ARM_SOUNDER + the txdemo NDPA builder DEVOURER_TX_NDPA_RA).
* The descriptor layout is Jaguar-1-specific, so this stays in the HAL
* (the shared BeamformingSounder.h only holds the generation-neutral MAC
* register recipe). NDPA is a unicast control frame: no HW sequence stamp,
* not broadcast, use-header NAV, no rate fallback. */
if (std::getenv("DEVOURER_TX_NDPA")) {
SET_TX_DESC_NDPA_8812(usb_frame, 1);
SET_TX_DESC_HWSEQ_EN_8812(usb_frame, 0);
SET_TX_DESC_BMC_8812(usb_frame, 0);
SET_TX_DESC_NAV_USE_HDR_8812(usb_frame, 1);
SET_TX_DESC_DISABLE_FB_8812(usb_frame, 1);
}

rtl8812a_cal_txdesc_chksum(usb_frame);
_logger->debug("tx desc formed");
#ifdef DEBUG
Expand Down Expand Up @@ -405,6 +431,24 @@ void RtlJaguarDevice::Init(Action_ParsedRadioPacket packetProcessor,
StartWithMonitorMode(channel);
SetMonitorChannel(channel);

/* DEVOURER_BF_ARM_BFEE=aa:bb:cc:dd:ee:ff — beamforming self-sounding probe
* (beamformee side): arm the hardware CSI responder so an NDPA+NDP from the
* given beamformer MAC triggers a hardware-built VHT Compressed Beamforming
* report, with NO association. The chip's own MAC (0x610, from EFUSE) is the
* NDPA RA match. See BeamformingSounder.h for the register recipe. */
if (const char *bfer = std::getenv("DEVOURER_BF_ARM_BFEE")) {
unsigned m[6];
if (std::sscanf(bfer, "%x:%x:%x:%x:%x:%x", &m[0], &m[1], &m[2], &m[3],
&m[4], &m[5]) == 6) {
uint8_t mac[6];
for (int i = 0; i < 6; ++i) mac[i] = static_cast<uint8_t>(m[i]);
devourer::bf::arm_beamformee(_device, mac, devourer::bf::kBfeeJaguar1);
_logger->info("BF beamformee armed for beamformer {}", bfer);
} else {
_logger->error("DEVOURER_BF_ARM_BFEE — bad MAC '{}'", bfer);
}
}

/* DEVOURER_RX_PATHS=0xNN restricts which RX chains the chip enables/combines,
* by masking the RX-path-enable register (0x808 byte 0: bits 0/4 = path A
* CCK/OFDM, 1/5 = B, 2/6 = C, 3/7 = D). Default is all paths (0xFF, from the
Expand Down
Loading
Loading