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
62 changes: 62 additions & 0 deletions tests/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,68 @@ per-cell stdout/stderr logs end up at `/tmp/devourer-regress-last/`.

Environment variable equivalents: `DEVOURER_VM_NAME`, `DEVOURER_VM_SSH`.

## Specialized modes

The default invocation runs the 4-cell matrix on one ordered (TX, RX)
pair. Two additional modes extend coverage along different axes.

### `--full-matrix`: cross-chipset interop

Iterates every ordered (TX, RX) pair of plugged DUTs across all four
driver-side combinations and emits four NxN tables. For N adapters,
N×(N-1)×4 cells; ~16 min for N=3 in VM mode. Useful for catching
cross-chipset regressions in PRs that touch shared HAL code.

```bash
sudo python3 tests/regress.py --full-matrix --channel 100 \
--vm-name devourer-testrig --vm-ssh <user>@<VM-IP>
```

### `--encoding-matrix`: chip-specific radiotap encoding asymmetries

For one ordered TX→RX pair, iterates (driver mode × radiotap encoding
flags). 16 cells per run (~10 min in VM mode). Designed to surface
chip-specific RX asymmetries that the default and full matrices miss
because they only exercise the chip's default encoding.

```bash
sudo python3 tests/regress.py --encoding-matrix \
--tx-pid 0x8813 --rx-pid 0x0120 --channel 100 \
--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).

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

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.

## Supported DUTs

Listed in `SUPPORTED_DUTS` at the top of `regress.py`. Extend the dict
Expand Down
76 changes: 68 additions & 8 deletions tests/inject_beacon.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,28 +16,60 @@
"""

import argparse
import struct
import time

from scapy.all import RadioTap, Dot11, sendp
from scapy.all import RadioTap, Dot11, Raw, sendp

# Source MAC matches the canonical beacon SA in txdemo/main.cpp and the
# `<devourer-tx-hit>` matcher in demo/main.cpp. Don't change without
# updating both sides.
CANONICAL_SA = "57:42:75:05:d6:00"

# Radiotap field bits used here. Full list in
# https://www.radiotap.org/.
_RT_TX_FLAGS = 1 << 15
_RT_MCS = 1 << 19

def build_beacon(rate_mbps_x2: int = 0):

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.

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
the bytes ourselves and prepend them as Raw. Layout: u8 version, u8 pad,
u16 it_len (LE), u32 it_present (LE), u16 TX Flags, u8 MCS known, u8 MCS
flags, u8 MCS index. Total 13 bytes — matches txdemo/main.cpp's header.
"""
known = 0x37 # bandwidth | mcs_idx | gi | fec_type | stbc known
flags = 0
if bandwidth == 40:
flags |= 0x01 # bw bits 0..1: 00=20, 01=40, 10=20L, 11=20U
if ldpc:
flags |= 0x10 # bit 4: FEC type 0=BCC 1=LDPC
flags |= (stbc & 0x3) << 5 # bits 5..6: STBC stream count 0..3
return (
struct.pack('<BBHIH', 0, 0, 13, _RT_TX_FLAGS | _RT_MCS, tx_flags)
+ bytes([known, flags, mcs & 0x7F])
)


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

`rate_mbps_x2` is in 500kbps units (the radiotap convention): 12 → 6Mbps
OFDM, 2 → 1Mbps CCK, etc. 0 leaves the rate unspecified, which lets the
chip pick its own default (varies by chipset and is the source of the
8812-vs-8814 asymmetry we're investigating)."""
rt = RadioTap(Rate=rate_mbps_x2) if rate_mbps_x2 else RadioTap()
return (
rt
/ Dot11(
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."""
dot11_bytes = bytes(
Dot11(
type=0, # mgmt
subtype=4, # probe request
addr1="ff:ff:ff:ff:ff:ff", # DA broadcast
Expand All @@ -46,6 +78,14 @@ def build_beacon(rate_mbps_x2: int = 0):
)
/ b"\x00\x00\x00\x00\x00\x00\x00\x00" # ssid IE (empty)
)
if mcs is not None or ldpc or stbc:
rt_bytes = _build_radiotap_mcs(
mcs=mcs if mcs is not None else 1,
ldpc=ldpc, stbc=stbc, bandwidth=bandwidth,
)
return Raw(rt_bytes + dot11_bytes)
rt = RadioTap(Rate=rate_mbps_x2) if rate_mbps_x2 else RadioTap()
return rt / Raw(dot11_bytes)


def main():
Expand All @@ -70,9 +110,29 @@ def main():
help="TX rate in 500kbps units (e.g. 12 = 6Mbps OFDM, 2 = 1Mbps CCK). "
"0 (default) leaves it unspecified — chip picks its own default.",
)
ap.add_argument(
"--mcs", type=int, default=None,
help="HT MCS index (sets the MCS info radiotap field). Implied 1 if "
"any of --ldpc / --stbc is set without an explicit --mcs.",
)
ap.add_argument(
"--ldpc", action="store_true",
help="Set MCS LDPC bit (FEC type = LDPC instead of BCC).",
)
ap.add_argument(
"--stbc", type=int, default=0,
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).",
)
args = ap.parse_args()

pkt = build_beacon(args.rate)
pkt = build_beacon(
args.rate, mcs=args.mcs, ldpc=args.ldpc, stbc=args.stbc,
bandwidth=args.bandwidth,
)
end = time.monotonic() + args.duration
sent = 0
while time.monotonic() < end:
Expand Down
Loading
Loading