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
76 changes: 50 additions & 26 deletions tests/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -160,37 +160,61 @@ sudo python3 tests/regress.py --encoding-matrix \
--vm-name devourer-testrig --vm-ssh <user>@<VM-IP>
```

Encoding combos iterated (with MCS 1, 20 MHz): `BCC`, `LDPC`, `STBC=1`,
`LDPC+STBC=1`. Add more in `ENCODING_COMBOS` at the top of `regress.py`
if you need other mixes (e.g. MCS 7, 40 MHz, or VHT).
Encoding combos iterated by default — 6 cells per driver mode × 4 driver
modes = 24 cells total per run:

The underlying knobs are also usable standalone for one-off targeted
TX:

- Devourer TX: `DEVOURER_TX_LDPC=1`, `DEVOURER_TX_STBC=1`,
`DEVOURER_TX_MCS=N`, `DEVOURER_TX_BW=20|40` env vars read by
`WiFiDriverTxDemo` to patch the radiotap MCS field at startup.
- Kernel-side scapy TX: `--ldpc`, `--stbc N`, `--mcs N`, `--bandwidth
20|40` flags on `tests/inject_beacon.py`.

#### Known caveat: kernel-TX encoding flags may not reach the air
| Combo | Radiotap bit | Notes |
|---|---|---|
| `HT-BCC` | 19 (MCS info) | MCS 1, BCC FEC, 20 MHz |
| `HT-LDPC` | 19 | MCS 1, FEC=LDPC |
| `HT-STBC=1` | 19 | MCS 1, 1 STBC stream |
| `HT-LDPC+STBC` | 19 | MCS 1, FEC=LDPC + 1 STBC stream |
| `VHT-BCC` | 21 (VHT info) | VHT MCS 0, NSS 1, BCC, 20 MHz |
| `VHT-LDPC` | 21 | VHT MCS 0, NSS 1, FEC=LDPC |

VHT (802.11ac) coverage exists because some chips' LDPC decoder limitation
is on the VHT path only — the silicon's HT-LDPC and VHT-LDPC are separate
blocks. RTL8821AU is the motivating case (HT-LDPC tests pass, VHT-LDPC
reportedly fails at RX). Add more in `ENCODING_COMBOS` at the top of
`regress.py` if you need MCS 7, 40/80 MHz, or higher NSS.

The underlying knobs are also usable standalone for one-off targeted TX:

- **Devourer TX:** `DEVOURER_TX_MCS=N`, `DEVOURER_TX_LDPC=1`,
`DEVOURER_TX_STBC=N`, `DEVOURER_TX_BW=20|40|80|160` env vars read by
`WiFiDriverTxDemo`. Default mode is HT; `DEVOURER_TX_VHT=1` switches to
a VHT radiotap header (22 bytes) and exposes `DEVOURER_TX_VHT_MCS=N` +
`DEVOURER_TX_VHT_NSS=N`. `_LDPC` / `_STBC` / `_BW` apply to whichever
mode is active.
- **Kernel-side scapy TX:** `--mcs N` / `--ldpc` / `--stbc N` / `--bandwidth
20|40|80|160` on `tests/inject_beacon.py`, plus `--vht` / `--vht-mcs N`
/ `--vht-nss N` for VHT mode.

#### Caveat: kernel-TX encoding flags may not always reach the air

mac80211 + the `aircrack-ng/88XXau` driver don't necessarily honour the
radiotap MCS flags on TX — the chip's own rate-selection logic may
override them. So the `k/k` and `k/d` rows of the table reflect what
the *kernel* driver chose to transmit, which may collapse all four
encoding columns onto the chip's default. Validated 2026-05-25 with
`--encoding-matrix --tx-pid 0x8813 --rx-pid 0x0120`: the `k/d` row was
flat across all four columns (`~400 hits`), consistent with the kernel
stripping the LDPC/STBC bits before they reached the air rather than
the 8821AU RX accepting LDPC frames.

The `d/k` and `d/d` rows are not affected — `WiFiDriverTxDemo` writes
the radiotap header directly into the chip's bulk-OUT buffer, so the
`DEVOURER_TX_*` env vars are the ground truth for what flies. To
validate kernel-TX encoding made it on-air, run a third adapter as
sniffer (e.g. AR9271 via tcpdump on the same channel) and inspect the
MCS flags in the captured radiotap.
the *kernel* driver chose to transmit, which may collapse encoding
columns onto the chip's default.

To prove what actually flew, run `tests/sniff_air.py` on a third
adapter (AR9271 is the canonical sniffer — it speaks vanilla radiotap
without driver-side filtering) on the same channel, in parallel with
the matrix. Output reports each captured frame's decoded radiotap
MCS / VHT info, including LDPC bit + STBC streams. If a `--ldpc`
injection comes back tagged as BCC, the kernel stripped it before the
air.

```bash
sudo python3 tests/sniff_air.py --iface wlan0mon --channel 100 \
--duration 60
```

The `d/k` and `d/d` rows are not affected by the kernel-TX caveat —
`WiFiDriverTxDemo` writes the radiotap header directly into the
chip's bulk-OUT buffer, so the `DEVOURER_TX_*` env vars are ground
truth for what flies on devourer TX.

## Supported DUTs

Expand Down
77 changes: 69 additions & 8 deletions tests/inject_beacon.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,12 @@
# https://www.radiotap.org/.
_RT_TX_FLAGS = 1 << 15
_RT_MCS = 1 << 19
_RT_VHT = 1 << 21


def _build_radiotap_mcs(*, mcs: int, ldpc: bool, stbc: int, bandwidth: int,
tx_flags: int = 0x0008) -> bytes:
"""Hand-rolled radiotap header with TX Flags + MCS info.
"""Hand-rolled radiotap header with TX Flags + HT MCS info.

Scapy's RadioTap layer doesn't expose first-class LDPC / STBC fields, and
its `MCS` post-field plumbing is brittle across versions. Easier to emit
Expand All @@ -55,8 +56,41 @@ def _build_radiotap_mcs(*, mcs: int, ldpc: bool, stbc: int, bandwidth: int,
)


def _build_radiotap_vht(*, vht_mcs: int, nss: int, ldpc: bool, stbc: bool,
bandwidth: int, tx_flags: int = 0x0008) -> bytes:
"""Hand-rolled radiotap header with TX Flags + VHT info (radiotap bit 21).

VHT is 802.11ac's encoding spec; required for testing chips like RTL8821AU
whose LDPC behaviour at HT (bit 19) doesn't reflect their VHT path. Layout
(22 bytes total): 8-byte header (version/pad/it_len/it_present), 2-byte TX
Flags, then 12 bytes of VHT info — u16 known, u8 flags, u8 bandwidth,
u8[4] mcs_nss (user 0..3 each = mcs<<4 | nss), u8 coding (LDPC nibble per
user), u8 group_id, u16 partial_aid. Single-user only (users 1-3 zeroed).
"""
bw_code = {20: 0, 40: 1, 80: 4, 160: 11}.get(bandwidth, 0)
# known: bit 0 STBC, bit 2 GI, bit 6 bandwidth known.
known = (1 << 0) | (1 << 2) | (1 << 6)
flags = 0
if stbc:
flags |= 0x01 # bit 0: STBC enabled
mcs_nss = bytes([((vht_mcs & 0xF) << 4) | (nss & 0xF), 0, 0, 0])
coding = 0x01 if ldpc else 0x00 # user-0 nibble: 0=BCC, 1=LDPC
vht_info = (
struct.pack('<HBB', known, flags, bw_code)
+ mcs_nss
+ bytes([coding, 0]) # coding, group_id
+ struct.pack('<H', 0) # partial_aid
)
assert len(vht_info) == 12, len(vht_info)
return (
struct.pack('<BBHIH', 0, 0, 22, _RT_TX_FLAGS | _RT_VHT, tx_flags)
+ vht_info
)


def build_beacon(rate_mbps_x2: int = 0, *, mcs=None, ldpc: bool = False,
stbc: int = 0, bandwidth: int = 20):
stbc: int = 0, bandwidth: int = 20, vht: bool = False,
vht_mcs: int = 0, nss: int = 1):
"""Mgmt / probe-request frame matching txdemo's beacon_frame[]. The body
payload doesn't matter for hit-count testing — only SA is matched.

Expand All @@ -65,9 +99,13 @@ def build_beacon(rate_mbps_x2: int = 0, *, mcs=None, ldpc: bool = False,
chip pick its own default (varies by chipset and is the source of the
8812-vs-8814 asymmetry we're investigating).

If any of `mcs` / `ldpc` / `stbc` is set, switches to a hand-built
radiotap header with MCS info (default MCS 1 if `mcs` is None). Used by
--encoding-matrix to exercise LDPC and STBC paths."""
Encoding selection:
- default: legacy radiotap with optional `Rate`, chip picks encoding.
- `mcs` / `ldpc` / `stbc` (without `vht`): HT radiotap (bit 19).
- `vht=True`: VHT radiotap (bit 21); use `vht_mcs` / `nss` / `ldpc` /
`stbc` / `bandwidth` to control. Default VHT MCS 0 / NSS 1 / 20 MHz.
Required for testing chips whose LDPC RX limitation is on the VHT
path (e.g. RTL8821AU's reported LDPC-RX-no per Eachine Sphere Link)."""
dot11_bytes = bytes(
Dot11(
type=0, # mgmt
Expand All @@ -78,6 +116,12 @@ def build_beacon(rate_mbps_x2: int = 0, *, mcs=None, ldpc: bool = False,
)
/ b"\x00\x00\x00\x00\x00\x00\x00\x00" # ssid IE (empty)
)
if vht:
rt_bytes = _build_radiotap_vht(
vht_mcs=vht_mcs, nss=nss, ldpc=ldpc, stbc=bool(stbc),
bandwidth=bandwidth,
)
return Raw(rt_bytes + dot11_bytes)
if mcs is not None or ldpc or stbc:
rt_bytes = _build_radiotap_mcs(
mcs=mcs if mcs is not None else 1,
Expand Down Expand Up @@ -124,14 +168,31 @@ def main():
help="STBC stream count, 0..3 (default 0 = no STBC).",
)
ap.add_argument(
"--bandwidth", type=int, default=20, choices=(20, 40),
help="HT bandwidth in MHz (20 default; 40 sets MCS flags bw bit).",
"--bandwidth", type=int, default=20, choices=(20, 40, 80, 160),
help="bandwidth in MHz. HT honours 20/40; VHT honours 20/40/80/160.",
)
ap.add_argument(
"--vht", action="store_true",
help="emit VHT (802.11ac) radiotap (bit 21) instead of HT (bit 19). "
"Combine with --ldpc / --stbc / --vht-mcs / --vht-nss / "
"--bandwidth. Needed to test chips whose LDPC RX limitation is "
"specifically on the VHT path (e.g. RTL8821AU).",
)
ap.add_argument(
"--vht-mcs", type=int, default=0,
help="VHT MCS index, 0..9 typical (default 0). Only used with --vht.",
)
ap.add_argument(
"--vht-nss", type=int, default=1,
help="VHT spatial streams (NSS), 1..4 (default 1). Only used with "
"--vht.",
)
args = ap.parse_args()

pkt = build_beacon(
args.rate, mcs=args.mcs, ldpc=args.ldpc, stbc=args.stbc,
bandwidth=args.bandwidth,
bandwidth=args.bandwidth, vht=args.vht, vht_mcs=args.vht_mcs,
nss=args.vht_nss,
)
end = time.monotonic() + args.duration
sent = 0
Expand Down
27 changes: 23 additions & 4 deletions tests/regress.py
Original file line number Diff line number Diff line change
Expand Up @@ -520,6 +520,12 @@ def _devourer_env(dut: Dut, channel: int,
env["DEVOURER_TX_STBC"] = str(tx_encoding["stbc"])
if tx_encoding.get("bandwidth") is not None:
env["DEVOURER_TX_BW"] = str(tx_encoding["bandwidth"])
if tx_encoding.get("vht"):
env["DEVOURER_TX_VHT"] = "1"
if tx_encoding.get("vht_mcs") is not None:
env["DEVOURER_TX_VHT_MCS"] = str(tx_encoding["vht_mcs"])
if tx_encoding.get("nss") is not None:
env["DEVOURER_TX_VHT_NSS"] = str(tx_encoding["nss"])
return env


Expand Down Expand Up @@ -585,6 +591,12 @@ def _spawn_kernel_tx(
extra += ["--stbc", str(encoding["stbc"])]
if encoding.get("bandwidth") is not None:
extra += ["--bandwidth", str(encoding["bandwidth"])]
if encoding.get("vht"):
extra.append("--vht")
if encoding.get("vht_mcs") is not None:
extra += ["--vht-mcs", str(encoding["vht_mcs"])]
if encoding.get("nss") is not None:
extra += ["--vht-nss", str(encoding["nss"])]
if kh.is_remote:
# Ship the injector to the VM (overwrites each run — fine for the
# tiny script).
Expand Down Expand Up @@ -989,10 +1001,17 @@ def emit_full_markdown(
# ---------------------------------------------------------------------------

ENCODING_COMBOS = [
("BCC", {"mcs": 1}),
("LDPC", {"mcs": 1, "ldpc": True}),
("STBC=1", {"mcs": 1, "stbc": 1}),
("LDPC+STBC=1", {"mcs": 1, "ldpc": True, "stbc": 1}),
# HT (radiotap bit 19): MCS 1, 20 MHz.
("HT-BCC", {"mcs": 1}),
("HT-LDPC", {"mcs": 1, "ldpc": True}),
("HT-STBC=1", {"mcs": 1, "stbc": 1}),
("HT-LDPC+STBC", {"mcs": 1, "ldpc": True, "stbc": 1}),
# VHT (radiotap bit 21): VHT MCS 0, NSS 1, 20 MHz. 8821AU's LDPC-RX-no
# limitation (per Eachine Sphere Link reports) is on the VHT path —
# HT-LDPC didn't surface it because the chip's HT and VHT decoders are
# separate code paths in silicon.
("VHT-BCC", {"vht": True, "vht_mcs": 0, "nss": 1}),
("VHT-LDPC", {"vht": True, "vht_mcs": 0, "nss": 1, "ldpc": True}),
]


Expand Down
Loading
Loading