diff --git a/tests/README.md b/tests/README.md index ca7bb68..6ad1a88 100644 --- a/tests/README.md +++ b/tests/README.md @@ -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 @ +``` + +### `--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 @ +``` + +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 diff --git a/tests/inject_beacon.py b/tests/inject_beacon.py index e6ae8ac..2f71aba 100755 --- a/tests/inject_beacon.py +++ b/tests/inject_beacon.py @@ -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 # `` 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(' str: # --------------------------------------------------------------------------- -def _devourer_env(dut: Dut, channel: int) -> dict[str, str]: +def _devourer_env(dut: Dut, channel: int, + tx_encoding: Optional[dict] = None) -> dict[str, str]: env = os.environ.copy() env["DEVOURER_VID"] = f"0x{dut.vid}" env["DEVOURER_PID"] = f"0x{dut.pid}" env["DEVOURER_CHANNEL"] = str(channel) env["DEVOURER_USB_QUIET"] = "1" + if tx_encoding: + # DEVOURER_TX_* knobs are read by txdemo/main.cpp to patch the + # radiotap MCS info bytes before the TX loop. Only meaningful when + # spawning WiFiDriverTxDemo; harmless on the RX side. + if tx_encoding.get("mcs") is not None: + env["DEVOURER_TX_MCS"] = str(tx_encoding["mcs"]) + if tx_encoding.get("ldpc"): + env["DEVOURER_TX_LDPC"] = "1" + if tx_encoding.get("stbc"): + env["DEVOURER_TX_STBC"] = str(tx_encoding["stbc"]) + if tx_encoding.get("bandwidth") is not None: + env["DEVOURER_TX_BW"] = str(tx_encoding["bandwidth"]) return env @@ -522,12 +535,13 @@ def _spawn_devourer_rx( def _spawn_devourer_tx( - devourer_root: Path, dut: Dut, channel: int, log_path: Path + devourer_root: Path, dut: Dut, channel: int, log_path: Path, + encoding: Optional[dict] = None, ) -> subprocess.Popen: fh = open(log_path, "w") return subprocess.Popen( [str(devourer_root / "build" / "WiFiDriverTxDemo")], - env=_devourer_env(dut, channel), + env=_devourer_env(dut, channel, tx_encoding=encoding), stdout=fh, stderr=subprocess.STDOUT, start_new_session=True, ) @@ -553,7 +567,7 @@ def _spawn_kernel_rx( def _spawn_kernel_tx( kh: KernelHost, devourer_root: Path, iface: str, channel: int, - duration: float, log_path: Path, + duration: float, log_path: Path, encoding: Optional[dict] = None, ) -> subprocess.Popen: """TX side: scapy injector that emits the canonical beacon. Local mode runs tests/inject_beacon.py directly. VM mode scps it over @@ -561,15 +575,25 @@ def _spawn_kernel_tx( kh.iface_to_monitor(iface, channel) fh = open(log_path, "w") injector = devourer_root / "tests" / "inject_beacon.py" + extra: list[str] = [] + if encoding: + if encoding.get("mcs") is not None: + extra += ["--mcs", str(encoding["mcs"])] + if encoding.get("ldpc"): + extra.append("--ldpc") + if encoding.get("stbc"): + extra += ["--stbc", str(encoding["stbc"])] + if encoding.get("bandwidth") is not None: + extra += ["--bandwidth", str(encoding["bandwidth"])] if kh.is_remote: # Ship the injector to the VM (overwrites each run — fine for the # tiny script). kh.push_file(injector, "/tmp/inject_beacon.py") cmd = ["python3", "/tmp/inject_beacon.py", - "--iface", iface, "--duration", str(duration)] + "--iface", iface, "--duration", str(duration)] + extra else: cmd = [sys.executable, str(injector), - "--iface", iface, "--duration", str(duration)] + "--iface", iface, "--duration", str(duration)] + extra return kh.popen(cmd, stdout=fh, stderr=subprocess.STDOUT) @@ -696,9 +720,14 @@ def run_cell( duration: float, tmpdir: Path, kh: KernelHost, + encoding: Optional[dict] = None, ) -> CellResult: """Run one matrix cell. State contract: always restore DUTs to a clean - baseline (host kernel-bound) on exit via try/finally.""" + baseline (host kernel-bound) on exit via try/finally. + + `encoding` (optional dict, used by --encoding-matrix) passes radiotap + TX encoding flags through to the TX-side spawn: kernel TX → injector + CLI args, devourer TX → DEVOURER_TX_* env vars.""" cell_id = f"tx-{tx_side}_rx-{rx_side}" tx_log = tmpdir / f"{cell_id}.tx.log" rx_log = tmpdir / f"{cell_id}.rx.log" @@ -722,12 +751,15 @@ def run_cell( # Stage 3: TX side. if tx_side == "devourer": - tx_proc = _spawn_devourer_tx(devourer_root, tx_dut, channel, tx_log) + tx_proc = _spawn_devourer_tx( + devourer_root, tx_dut, channel, tx_log, encoding=encoding + ) tx_warmup = 6.0 else: tx_iface = kh.wait_for_wlan_iface(tx_dut) tx_proc = _spawn_kernel_tx( - kh, devourer_root, tx_iface, channel, duration, tx_log + kh, devourer_root, tx_iface, channel, duration, tx_log, + encoding=encoding, ) tx_warmup = 0.5 @@ -947,6 +979,94 @@ def emit_full_markdown( return "\n".join(out) +# --------------------------------------------------------------------------- +# Encoding matrix — one TX/RX pair, iterate (driver-mode × encoding flags). +# Surfaces chip-specific radiotap-encoding asymmetries that --full-matrix +# can't see because every cell there uses the default encoding (MCS 1, BCC, +# no STBC). Motivating example: RTL8821AU TXes LDPC fine but can't RX LDPC +# frames (the chip's decoder lacks the LDPC block), so an `LDPC` k/d cell +# to an 8821AU goes to 0 hits while `BCC` works. +# --------------------------------------------------------------------------- + +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}), +] + + +def run_encoding_matrix( + devourer_root: Path, + tx_dut: Dut, + rx_dut: Dut, + channel: int, + duration: float, + threshold: int, + tmpdir: Path, + kh: KernelHost, +) -> dict[tuple[str, str, str], CellResult]: + """For one ordered (TX, RX) pair, iterate every driver-mode × encoding + combination. Returns dict keyed by (tx_side, rx_side, encoding_label).""" + results: dict[tuple[str, str, str], CellResult] = {} + total = len(FULL_MATRIX_MODES) * len(ENCODING_COMBOS) + idx = 0 + for tx_side, rx_side, _label in FULL_MATRIX_MODES: + for enc_label, enc in ENCODING_COMBOS: + idx += 1 + cell_hdr = ( + f"[{time.strftime('%H:%M:%S')}] [{idx}/{total}] " + f"TX={tx_dut.chipset} ({tx_side}) → " + f"RX={rx_dut.chipset} ({rx_side}) enc=[{enc_label}]" + ) + print(cell_hdr + " ...", flush=True) + try: + r = run_cell( + devourer_root, tx_dut, rx_dut, tx_side, rx_side, + channel, duration, tmpdir, kh, encoding=enc, + ) + except Exception as e: + print(f" ✗ cell crashed: {e}", flush=True) + r = CellResult(hits=0, tx_attempts=0, tx_failures=0, + duration_s=0.0, notes=str(e)) + results[(tx_side, rx_side, enc_label)] = r + print(f" → {r.fmt(threshold)}", flush=True) + return results + + +def emit_encoding_markdown( + tx_dut: Dut, rx_dut: Dut, channel: int, duration: float, + threshold: int, kh: KernelHost, + results: dict[tuple[str, str, str], CellResult], +) -> str: + """Render one table: rows = driver mode (k/k, d/k, k/d, d/d), + columns = encoding combo. A chip with an encoding-specific RX + limitation will show a single column going to 0 in the k/d row.""" + out = [] + out.append(f"# Encoding matrix — channel {channel}, " + f"{time.strftime('%Y-%m-%d %H:%M:%S')}\n") + out.append(f"- TX adapter: `{tx_dut.vidpid}` ({tx_dut.chipset})") + out.append(f"- RX adapter: `{rx_dut.vidpid}` ({rx_dut.chipset})") + out.append(f"- Kernel host: " + f"{'VM ' + kh.vm_name + ' via ' + kh.ssh_target if kh.is_remote else 'local'}") + out.append(f"- Cell duration: {duration:.0f}s " + f"Pass threshold: ≥ {threshold} hits\n") + header = "| Mode (TX/RX driver) |" + "".join( + f" {lbl} |" for lbl, _ in ENCODING_COMBOS + ) + sep = "|---|" + "---|" * len(ENCODING_COMBOS) + out.append(header) + out.append(sep) + for tx_side, rx_side, _ in FULL_MATRIX_MODES: + row = f"| {tx_side[0]}/{rx_side[0]} |" + for enc_label, _ in ENCODING_COMBOS: + r = results.get((tx_side, rx_side, enc_label)) + row += f" {r.fmt(threshold) if r else '?'} |" + out.append(row) + out.append("") + return "\n".join(out) + + def emit_markdown( tx_dut: Dut, rx_dut: Dut, channel: int, duration: float, threshold: int, kh: KernelHost, @@ -1022,6 +1142,13 @@ def main(): "all 4 driver-side combinations. Emits four NxN tables instead " "of one 4-cell table. Ignores --tx-pid / --rx-pid.", ) + ap.add_argument( + "--encoding-matrix", action="store_true", + help="for one ordered TX→RX pair (use --tx-pid / --rx-pid to select, " + "first two auto-detected DUTs otherwise), iterate " + "(driver-mode × radiotap encoding combo). Surfaces chip-specific " + "asymmetries like the RTL8821AU LDPC-RX-no limitation.", + ) ap.add_argument( "--vm-name", default=os.environ.get("DEVOURER_VM_NAME", ""), @@ -1069,6 +1196,52 @@ def pick(pid_arg, default_idx): sys.stderr.write(f"No plugged DUT has PID {pid_arg}\n") sys.exit(2) + if args.encoding_matrix: + tx_dut = pick(args.tx_pid, 0) + rx_dut = pick(args.rx_pid, 1) + if tx_dut.sysfs_id == rx_dut.sysfs_id: + sys.stderr.write("TX and RX must be different physical devices.\n") + sys.exit(2) + print(f"Encoding matrix mode:") + print(f" TX adapter: {tx_dut.vidpid} ({tx_dut.chipset}) at {tx_dut.sysfs_id}") + print(f" RX adapter: {rx_dut.vidpid} ({rx_dut.chipset}) at {rx_dut.sysfs_id}") + print(f"Kernel host: " + f"{'VM ' + kh.vm_name + ' (' + kh.ssh_target + ')' if kh.is_remote else 'local'}") + n_cells = len(FULL_MATRIX_MODES) * len(ENCODING_COMBOS) + print(f"Channel: {args.channel} Duration/cell: {args.duration}s " + f"Pass threshold: ≥{args.pass_threshold} hits") + print(f"Total cells: {n_cells} " + f"({len(FULL_MATRIX_MODES)} driver modes × " + f"{len(ENCODING_COMBOS)} encoding combos)\n") + + kh.release_all_known_duts([tx_dut, rx_dut]) + with tempfile.TemporaryDirectory(prefix="devourer-regress-") as td: + tmpdir = Path(td) + results = run_encoding_matrix( + devourer_root=args.devourer_root, + tx_dut=tx_dut, rx_dut=rx_dut, + channel=args.channel, duration=args.duration, + threshold=args.pass_threshold, + tmpdir=tmpdir, kh=kh, + ) + print() + md = emit_encoding_markdown( + tx_dut, rx_dut, args.channel, args.duration, + args.pass_threshold, kh, results, + ) + print(md, flush=True) + sys.stdout.flush() + if args.keep_logs: + kept = Path(tempfile.gettempdir()) / "devourer-regress-last" + if kept.is_symlink() or kept.exists(): + kept.unlink() + kept.symlink_to(tmpdir) + print(f"(logs kept at {kept} — symlink, valid until next run)", + flush=True) + sys.stdout.flush() + os._exit(0) + return + if args.full_matrix: print(f"Full matrix mode over {len(duts)} adapters:") for d in duts: diff --git a/txdemo/main.cpp b/txdemo/main.cpp index c1e1b5f..d166b72 100644 --- a/txdemo/main.cpp +++ b/txdemo/main.cpp @@ -204,6 +204,33 @@ int main(int argc, char **argv) { 0xcd, 0xce, 0x4e, 0x35, 0xd9, 0x85, 0x9a, 0xcf, 0x4d, 0x48, 0x4c, 0x8f, 0x28, 0x6f, 0x10, 0xb0, 0xa9, 0x5d, 0xbf, 0xcb, 0x6f}; + /* Radiotap MCS info lives at beacon_frame[10..12]: known mask, flags, idx. + * Defaults encode MCS 1 / 20 MHz / long GI / BCC / no STBC. Env knobs let + * tests/regress.py --encoding-matrix exercise LDPC and STBC paths — needed + * to surface chip-specific asymmetries like the RTL8821AU LDPC-RX-no + * limitation (the chip can TX LDPC but its decoder can't RX LDPC frames). */ + if (const char *m = std::getenv("DEVOURER_TX_MCS")) { + beacon_frame[12] = static_cast(std::strtoul(m, nullptr, 0) & 0x7F); + logger->info("DEVOURER_TX_MCS — MCS index set to {}", beacon_frame[12]); + } + uint8_t mcs_flags = beacon_frame[11]; + if (std::getenv("DEVOURER_TX_LDPC")) { + mcs_flags |= 0x10; /* MCS flags bit 4 = FEC type LDPC */ + logger->info("DEVOURER_TX_LDPC — FEC=LDPC"); + } + if (const char *s = std::getenv("DEVOURER_TX_STBC")) { + int n = std::atoi(s) & 0x3; + mcs_flags = static_cast((mcs_flags & ~0x60) | (n << 5)); + logger->info("DEVOURER_TX_STBC — {} STBC stream(s)", n); + } + if (const char *bw = std::getenv("DEVOURER_TX_BW")) { + int b = std::atoi(bw); + uint8_t code = (b == 40) ? 0x01 : 0x00; + mcs_flags = static_cast((mcs_flags & ~0x03) | code); + logger->info("DEVOURER_TX_BW — {} MHz", b); + } + beacon_frame[11] = mcs_flags; + long tx_count = 0; while (true) { rc = rtlDevice->send_packet(beacon_frame, sizeof(beacon_frame));