tests: --encoding-matrix iterates radiotap LDPC/STBC per cell#39
Merged
Conversation
Adds a new mode to tests/regress.py that, for one ordered TX→RX pair,
iterates every (driver mode × radiotap encoding combo) cell and emits a
single table. 16 cells per run (4 driver modes × 4 encoding combos:
BCC, LDPC, STBC=1, LDPC+STBC=1, all at MCS 1 / 20 MHz). Designed to
surface chip-specific RX-encoding asymmetries that the default and
full matrices can't see because they only exercise the chip's default
encoding.
Usage:
sudo python3 tests/regress.py --encoding-matrix \
--tx-pid 0x8813 --rx-pid 0x0120 --channel 100 \
--vm-name devourer-testrig --vm-ssh <user>@<VM-IP>
Implementation:
- txdemo/main.cpp reads DEVOURER_TX_MCS / _LDPC / _STBC / _BW env vars
at startup and patches the radiotap MCS info bytes in beacon_frame[].
txdemo writes its radiotap header directly into the chip bulk-OUT
buffer, so these knobs are ground truth for what flies on devourer TX.
- tests/inject_beacon.py grows --mcs / --ldpc / --stbc / --bandwidth
flags. scapy's RadioTap layer doesn't have first-class LDPC/STBC
support (and its MCS plumbing has churned across versions), so the
helper builds the 13-byte radiotap header manually via struct.pack
and prepends it as Raw — matches txdemo's hand-built bytes.
- tests/regress.py: ENCODING_COMBOS at module level, encoding=None
parameter threaded through run_cell, _spawn_devourer_tx,
_spawn_kernel_tx, and _devourer_env. New run_encoding_matrix +
emit_encoding_markdown functions; new --encoding-matrix flag in
main(). All existing call sites remain encoding=None (current
behaviour preserved).
Validated 2026-05-25 with `--encoding-matrix --tx-pid 0x8813 (8814AU)
--rx-pid 0x0120 (8821AU) --channel 100 --vm-mode`. 16/16 cells ran,
table emitted cleanly. The hypothesised RTL8821AU LDPC-RX-no asymmetry
(Roman Lut, #22 2026-05-25) did not surface in the k/d
row — all four columns flat at ~400 hits. Two plausible reasons,
documented in tests/README.md:
1. aircrack-ng/88XXau in the VM may not honour radiotap MCS flags on
TX — mac80211 + driver rate selection can override before the air,
so the k/k and k/d columns reflect what the chip chose, not what
the helper requested.
2. 8821AU is 802.11ac silicon — Roman's symptom is likely VHT-LDPC,
which sits in a different radiotap field (bit 21, VHT info) than
the HT-LDPC bit (bit 19, MCS info flags) this PR adds. Extending
ENCODING_COMBOS with VHT entries is straightforward future work.
The d/k and d/d rows are unaffected by either caveat — devourer TX
writes its radiotap header directly. They're the ground truth path for
testing devourer-side encoding parity once more chips have working TX.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
6 tasks
josephnef
added a commit
that referenced
this pull request
May 25, 2026
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 #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 #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 <mon> --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 - [x] \`--help\` lists \`--vht / --vht-mcs / --vht-nss\` on \`inject_beacon.py\` - [x] C++ and Python radiotap builders produce byte-identical output for all (HT, VHT) × (LDPC, STBC) combos - [x] \`sniff_air.py\` parser round-trips every combo \`inject_beacon.build_beacon\` emits - [x] Full \`--encoding-matrix\` on master + sanity \`--full-matrix\` post-merge confirm \`encoding=None\` default behaviour preserved - [x] 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](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
5 tasks
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this is
New mode for
tests/regress.py. For one ordered TX→RX pair, iterates every (driver-mode × radiotap encoding combo) cell and emits a single table. 16 cells per run (4 driver modes × 4 encoding combos:BCC,LDPC,STBC=1,LDPC+STBC=1, all at MCS 1 / 20 MHz).Designed to surface chip-specific RX-encoding asymmetries that the existing default and
--full-matrixmodes can't see — they only exercise the chip's default encoding, so a chip whose decoder doesn't support, say, LDPC would silently never have an LDPC frame thrown at it.sudo python3 tests/regress.py --encoding-matrix \ --tx-pid 0x8813 --rx-pid 0x0120 --channel 100 \ --vm-name devourer-testrig --vm-ssh <user>@<VM-IP>Prompted by @RomanLut's note in #22: "BTW, rtl8821au supports LDPC TX, but not LDPC RX. PixelPilot can not receive video stream from Eachine Sphere Link if LDPC is enabled. You may also want to add LDPC and STBC settings to the tests."
Implementation
txdemo/main.cpp— readsDEVOURER_TX_MCS / _LDPC / _STBC / _BWenv vars at startup and patches the radiotap MCS info bytes inbeacon_frame[10..12].WiFiDriverTxDemowrites its radiotap header directly into the chip's bulk-OUT buffer, so these knobs are ground truth for what flies on devourer TX.tests/inject_beacon.py—--mcs / --ldpc / --stbc / --bandwidthflags. Scapy'sRadioTaplayer doesn't expose first-class LDPC/STBC, and itsMCSplumbing has churned across versions, so the helper hand-builds the 13-byte radiotap header viastruct.packand prepends it asRaw— matchestxdemo's exact layout.tests/regress.py—ENCODING_COMBOSat module level,encoding=parameter threaded throughrun_cell/_spawn_devourer_tx/_spawn_kernel_tx/_devourer_env. Newrun_encoding_matrix+emit_encoding_markdown. New--encoding-matrixflag inmain(). All existing call sites remainencoding=None, current behaviour preserved.tests/README.md— new "Specialized modes" section covering both--full-matrix(which was undocumented since tests: --full-matrix runs N-adapter cross-driver interop tables #34) and--encoding-matrix, plus the kernel-TX caveat below.Validation on the lab rig (8814 TX → 8821 RX, channel 100, VM mode)
16/16 cells ran, table emitted cleanly. Plumbing works end-to-end.
Honest note: the LDPC asymmetry didn't surface
The
k/drow is flat across encodings. Two plausible reasons (documented intests/README.md):aircrack-ng/88XXaumay not honour radiotap MCS TX flags. mac80211 + the OOT driver's rate-selection can override what the helper requests, so the kernel-TX-side columns may all carry the chip's default encoding on-air regardless of whatinject_beacon.pyasked for. Verifying this requires a third adapter as sniffer (AR9271) decoding the captured MCS flags.ENCODING_COMBOSwith VHT entries is straightforward future work; left for a follow-up so this PR stays small.The
d/kandd/drows are unaffected by either caveat —txdemowrites the radiotap header directly into the chip's bulk-OUT buffer, no kernel filtering involved. They're the ground truth direction for testing devourer-side encoding parity. They show 0 here because 8814 TX is currently broken on master (separate known issue), not because the encoding plumbing failed.What this PR doesn't touch
--full-matrixbehaviour — both pure-additive.txdemo—WiFiDriver.aitself is unchanged.Test plan
--encoding-matrix --tx-pid 0x8813 --rx-pid 0x0120 --channel 100 --vm-name devourer-testrig --vm-ssh ...runs all 16 cells and emits the table--helplists the new flaginject_beacon.py_build_radiotap_mcsproduces correct bytes for each (ldpc, stbc) combo (manually verified)WiFiDriverTxDemoreads env vars and logs each one (visible in<devourer-tx>log lines)tests/regress.py --channel 100) still works — no encoding params passed,encoding=Nonedefault preserved--full-matrixstill works (not re-validated in this session, but the onlyrun_cellcall-site changes are new optionalencoding=Noneparameters)🤖 Generated with Claude Code