Skip to content

tests: VHT encoding combos + sniff_air.py radiotap verifier#40

Merged
josephnef merged 1 commit into
masterfrom
feat/regress-vht-encoding
May 25, 2026
Merged

tests: VHT encoding combos + sniff_air.py radiotap verifier#40
josephnef merged 1 commit into
masterfrom
feat/regress-vht-encoding

Conversation

@josephnef
Copy link
Copy Markdown
Collaborator

Follow-up to #39 closing two of the three follow-ups noted in that PR's test plan.

What this is

Two pieces:

  1. VHT combos in --encoding-matrix. ENCODING_COMBOS grows from 4 to 6 entries: adds VHT-BCC and VHT-LDPC (radiotap bit 21, 22-byte VHT info field, MCS 0 / NSS 1 / 20 MHz). Motivating case: RTL8821AU's LDPC-RX-no limitation (per @RomanLut, Eachine Sphere Link) is on the VHT path. The chip's HT and VHT decoders are separate silicon blocks, so tests: --encoding-matrix iterates radiotap LDPC/STBC per cell #39's HT-LDPC test passing didn't say anything about VHT-LDPC.

  2. tests/sniff_air.py. Standalone radiotap-decode helper. Capture on a monitor-mode iface (intended use: AR9271 — vanilla radiotap, no driver-side flag filtering), filter on the canonical injection SA, decode each captured frame's MCS / VHT field. Answers the question "did mac80211 actually emit what `inject_beacon.py` requested, or strip the encoding flags before the air?" — which is what tests: --encoding-matrix iterates radiotap LDPC/STBC per cell #39's flat-LDPC-row result was waiting on.

Implementation

File Change
`tests/inject_beacon.py` `_build_radiotap_vht` helper, `--vht / --vht-mcs / --vht-nss` flags, `--bandwidth` extended to 80/160
`txdemo/main.cpp` When `DEVOURER_TX_VHT=1`, swap the 13-byte HT radiotap prefix for a 22-byte VHT radiotap built from `DEVOURER_TX_VHT_MCS / _VHT_NSS / _LDPC / _STBC / _BW` env vars. TX buffer refactored to `std::vector<uint8_t>` to hold either prefix length. Byte-identical to the Python builder.
`tests/regress.py` `ENCODING_COMBOS` gets VHT-BCC + VHT-LDPC; `_devourer_env` + `_spawn_kernel_tx` pass `vht / vht_mcs / nss` through
`tests/sniff_air.py` NEW, ~270 lines. `sudo python3 tests/sniff_air.py --iface --channel N --duration N`. Sets monitor mode, runs tcpdump → pcap, manually parses radiotap (handles MCS bit 19 + VHT bit 21 + correct alignment for skipped fields), reports encoding distribution
`tests/README.md` Documents the VHT combos table + sniffer + updates the kernel-TX-strip caveat

Validation on the lab rig (8814 TX → 8821 RX, ch 100, VM mode)

`--encoding-matrix` table (24 cells, all ran):

Mode HT-BCC HT-LDPC HT-STBC=1 HT-LDPC+STBC VHT-BCC VHT-LDPC
k/k 466 ✓ 455 ✓ 462 ✓ 443 ✓ 435 ✓ 453 ✓
d/k 0 ✗ 0 ✗ 0 ✗ 0 ✗ 0 ✗ 0 ✗
k/d 400 ✓ 400 ✓ 400 ✓ 400 ✓ 400 ✓ 400 ✓
d/d 0 ✗ 0 ✗ 0 ✗ 0 ✗ 0 ✗ 0 ✗

VHT-LDPC `k/d` still flat at ~400 hits, same as VHT-BCC and HT-LDPC. 8821AU RX accepted every cell. Two reads, neither distinguishable in this PR:

  1. Kernel-TX path strips radiotap LDPC bit regardless of HT vs VHT mode → `sniff_air.py` on an AR9271 would prove or disprove this.
  2. 8821AU doesn't actually have the LDPC-RX-no limitation, or it only triggers under conditions we don't reproduce (specific MCS / NSS / BW / aggregation pattern).

`d/k` and `d/d` rows are 0 because 8814 TX is broken on master (separate known issue, not in scope here).

Sniffer parser unit-tested against every combo `inject_beacon.py` emits — round-trips with byte-identical decoded fields.

What this PR doesn't touch

  • AR9271 sniffer cell integration into `regress.py`'s per-cell flow. `sniff_air.py` is standalone here; wiring it into the matrix orchestration is a smaller follow-up.
  • A-MPDU / Beamforming / Higher VHT NSS / 802.11ax (HE) — extend `ENCODING_COMBOS` as needed.
  • Fixing the 8821AU LDPC-RX-no question itself — the infrastructure to answer it (`sniff_air.py` + VHT combos) is now in place, but the actual proof requires plugging in an AR9271 and running the sniffer alongside the matrix.

Test plan

  • `--help` lists `--vht / --vht-mcs / --vht-nss` on `inject_beacon.py`
  • C++ and Python radiotap builders produce byte-identical output for all (HT, VHT) × (LDPC, STBC) combos
  • `sniff_air.py` parser round-trips every combo `inject_beacon.build_beacon` emits
  • Full `--encoding-matrix` on master + sanity `--full-matrix` post-merge confirm `encoding=None` default behaviour preserved
  • CI builds (all 5 OS/compiler matrix combos) — passing on prior pushes; will rerun on this branch
  • AR9271 plugged in + `sniff_air.py` exercised alongside an `--encoding-matrix` run to confirm what's actually on-air (waiting on hardware)

🤖 Generated with Claude Code

Extends `tests/regress.py --encoding-matrix` (PR #39) with two follow-ups
that together close the loop on chip-specific RX encoding limitations.

## What this is

1. **VHT combos in `ENCODING_COMBOS`.** Adds `VHT-BCC` and `VHT-LDPC`
   (radiotap bit 21, 22-byte VHT info field — single-user MCS 0 / NSS 1
   / 20 MHz) alongside the existing HT combos (radiotap bit 19). The
   motivating case is RTL8821AU: its reported LDPC-RX-no limitation
   (per Eachine Sphere Link via @RomanLut) is on the VHT path, not HT.
   The chip's HT and VHT decoders are separate code paths in silicon,
   so an HT-LDPC test passing doesn't tell us anything about VHT-LDPC.

   Matrix grows from 16 → 24 cells per run (6 combos × 4 driver modes).

2. **`tests/sniff_air.py`.** Standalone helper that captures on a
   monitor-mode iface (intended use: AR9271, per kaeru ref `AR9271 as
   peer-sniffer for devourer TX validation`) and decodes each captured
   frame's radiotap to report what encoding actually flew. Filters on
   the canonical injection SA. Answers the "did mac80211 actually emit
   what inject_beacon.py requested, or strip the flags before the air"
   question that the encoding-matrix itself can't.

   Internal radiotap parser handles MCS info (bit 19) and VHT info
   (bit 21) — same fields the inject/txdemo sides emit. Round-trip
   tested: every combo emitted by inject_beacon.py parses back to the
   same (kind, mcs, nss, ldpc, stbc, bw) tuple in sniff_air.py.

## Implementation

- `tests/inject_beacon.py`: `_build_radiotap_vht` helper (hand-built
  22-byte VHT radiotap), `--vht / --vht-mcs / --vht-nss / --bandwidth
  20|40|80|160` flags. Existing `--ldpc / --stbc / --mcs` work for both
  HT and VHT modes — they map to the appropriate field for whichever
  mode is active.

- `txdemo/main.cpp`: when `DEVOURER_TX_VHT=1`, replaces the 13-byte HT
  radiotap prefix with a 22-byte VHT radiotap header built dynamically
  from `DEVOURER_TX_VHT_MCS / _VHT_NSS / _LDPC / _STBC / _BW` env
  vars. Refactored TX buffer to `std::vector<uint8_t>` so it can hold
  either prefix length. Cross-checked against `inject_beacon.py`'s
  builder for byte-identical output.

- `tests/regress.py`: `ENCODING_COMBOS` extended with VHT-BCC /
  VHT-LDPC. `_devourer_env` + `_spawn_kernel_tx` pass `vht / vht_mcs /
  nss` through. No new flags.

- `tests/sniff_air.py`: new file (~270 lines). Standalone, runs via
  `sudo python3 tests/sniff_air.py --iface <mon> --channel N
  --duration N`. Sets iface to monitor mode, captures via tcpdump,
  parses pcap manually (no scapy.rdpcap dependency — scapy's radiotap
  parser is hit-or-miss for LDPC/STBC bits), groups frames by encoding,
  prints distribution.

- `tests/README.md`: documents both pieces. Replaces the "HT-only,
  validated didn't reproduce LDPC asymmetry" caveat with a more
  general "kernel-TX encoding flags may not always reach the air,
  use sniff_air.py to prove what flew" paragraph.

## Validation

Built and ran end-to-end on the lab rig 2026-05-25, channel 100,
RTL8814AU TX → RTL8821AU RX, VM mode (devourer-testrig).

`--encoding-matrix` table (24 cells, all ran cleanly):

| Mode | HT-BCC | HT-LDPC | HT-STBC=1 | HT-LDPC+STBC | VHT-BCC | VHT-LDPC |
|---|---|---|---|---|---|---|
| k/k | 466 ✓ | 455 ✓ | 462 ✓ | 443 ✓ | 435 ✓ | 453 ✓ |
| d/k | 0 ✗ | 0 ✗ | 0 ✗ | 0 ✗ | 0 ✗ | 0 ✗ |
| k/d | 400 ✓ | 400 ✓ | 400 ✓ | 400 ✓ | 400 ✓ | 400 ✓ |
| d/d | 0 ✗ | 0 ✗ | 0 ✗ | 0 ✗ | 0 ✗ | 0 ✗ |

VHT-LDPC `k/d` still flat at ~400 (same as VHT-BCC and HT-LDPC). 8821AU
RX accepted every cell. Two reads:

1. Kernel-TX path strips radiotap LDPC bit regardless of HT vs VHT
   mode → would explain why the chip never has to refuse an LDPC
   frame. `tests/sniff_air.py` on an AR9271 would prove or disprove
   this.
2. 8821AU does not actually have the LDPC-RX-no limitation, or it
   only triggers under different conditions (specific MCS / NSS /
   bandwidth / aggregation pattern).

Without an AR9271 in the rig today, can't pick between (1) and (2)
in this commit. The infrastructure to answer it is now in place.

`d/k` and `d/d` rows blank because 8814 TX is broken on master
(known issue, separate from this PR). They are the ground-truth path
for devourer-side encoding — `WiFiDriverTxDemo` writes radiotap
directly into the chip bulk-OUT buffer, no kernel filtering.

Sniffer parser unit-tested against every combo `inject_beacon.py` can
emit (HT-BCC / HT-LDPC / HT-STBC=1 / VHT-BCC / VHT-LDPC /
VHT-LDPC+STBC) — round-trips with byte-identical results.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@josephnef josephnef merged commit 0724319 into master May 25, 2026
5 checks passed
@josephnef josephnef deleted the feat/regress-vht-encoding branch May 25, 2026 15:22
josephnef added a commit that referenced this pull request May 25, 2026
…#41)

Closes the last of #39's three follow-ups: wires the radiotap verifier
from #40 (`tests/sniff_air.py`) into `tests/regress.py` so it runs
alongside every cell. Optional and opt-in — `--sniffer-iface` defaults
to off, all prior modes unchanged.

## What this is

```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> \
    --sniffer-iface wlan0mon
```

Per-cell output gains a `↪ sniffer: N frames — <encoding>=N, ...` line
under the existing hit-count line. Reports what actually flew on-air for
each cell — closes the open question from #40 ("did the kernel-TX path
actually emit LDPC, or strip the flag?").

Intended for an AR9271: vanilla radiotap, no driver-side filtering on
what the cell injects. The chipset is widely used as a sniffer for
exactly this reason.

## Implementation

| Component | Behaviour |
|---|---|
| `_spawn_sniffer(iface, channel, pcap_path)` | Sets iface to monitor
mode, runs `tcpdump -w pcap -U -nn 'ether src CANONICAL_SA'`. Always
host-local; sniffer never moved into the VM via USB passthrough. |
| `_summarise_sniffer_pcap(pcap_path)` | Imports sniff_air at runtime
(sits next to regress.py), reuses `_read_pcap_frames` +
`_parse_radiotap` + `_frame_sa` + `CANONICAL_SA` to bucket captured
frames. Returns a one-line summary for the cell's `notes` field. |
| `run_cell(sniffer_iface=...)` | Spawns sniffer between RX and TX
stages so the full TX window gets captured. Sniffer failures are
observational — never fail the cell on sniffer issues. |
| `run_matrix` / `run_full_matrix` / `run_encoding_matrix` | Pass
`sniffer_iface` through to `run_cell`. When active + `r.notes` is set,
print an extra `↪ <notes>` line per cell. |
| `--sniffer-iface IFACE` | New CLI flag + `DEVOURER_SNIFFER_IFACE` env
equivalent. |

## Validation

The dormant path (`sniffer_iface=None`) preserves the exact prior
behaviour of every matrix mode — only structural change in `run_cell` is
initializing `sniffer_proc=None` and a no-op cleanup if it stays None.

Active-path validation requires an AR9271 plugged in, which isn't in the
rig today. CI matrix builds will confirm the code paths compile /
import. Functional end-to-end pending hardware.

The sniffer parser itself was unit-tested in #40: every combo
`inject_beacon.build_beacon` emits round-trips back through
`sniff_air._parse_radiotap` with byte-identical decoded fields.

## What this PR doesn't touch

- The markdown table emit functions are unchanged — sniffer notes go to
per-cell stdout, not into the table. Could be added as a separate column
in a future PR if interesting.
- No new combos; `ENCODING_COMBOS` unchanged from #40.
- AR9271-specific bring-up (driver loading, monitor capability
detection). The flag takes a generic iface name; the user is expected to
have a working monitor iface before pointing the matrix at it.

## Test plan

- [x] `--help` lists `--sniffer-iface`
- [x] Code parses, imports successfully
- [x] CI matrix builds — pending on this push
- [ ] `--sniffer-iface IFACE` with an AR9271 plugged in: verify per-cell
`↪ sniffer:` lines appear and decode reasonably
- [ ] Confirm `--sniffer-iface` running alongside `--encoding-matrix`
shows different encoding distributions for `--ldpc` vs default cells
(the actual goal — proves whether mac80211 / 88XXau emits the LDPC bit
on-air)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant