Skip to content

tests: --encoding-matrix iterates radiotap LDPC/STBC per cell#39

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

tests: --encoding-matrix iterates radiotap LDPC/STBC per cell#39
josephnef merged 1 commit into
masterfrom
feat/regress-encoding-matrix

Conversation

@josephnef
Copy link
Copy Markdown
Collaborator

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-matrix modes 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 — reads DEVOURER_TX_MCS / _LDPC / _STBC / _BW env vars at startup and patches the radiotap MCS info bytes in beacon_frame[10..12]. WiFiDriverTxDemo writes 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 / --bandwidth flags. Scapy's RadioTap layer doesn't expose first-class LDPC/STBC, and its MCS plumbing has churned across versions, so the helper hand-builds the 13-byte radiotap header via struct.pack and prepends it as Raw — matches txdemo's exact layout.
  • tests/regress.pyENCODING_COMBOS at module level, encoding= parameter threaded through run_cell / _spawn_devourer_tx / _spawn_kernel_tx / _devourer_env. New run_encoding_matrix + emit_encoding_markdown. New --encoding-matrix flag in main(). All existing call sites remain encoding=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)

Mode (TX/RX driver) BCC LDPC STBC=1 LDPC+STBC=1
k/k 447 ✓ 449 ✓ 438 ✓ 433 ✓
d/k 0 ✗ (4500 fail) 0 ✗ 0 ✗ 0 ✗
k/d 400 ✓ 400 ✓ 400 ✓ 400 ✓
d/d 0 ✗ 0 ✗ 0 ✗ 0 ✗

16/16 cells ran, table emitted cleanly. Plumbing works end-to-end.

Honest note: the LDPC asymmetry didn't surface

The k/d row is flat across encodings. Two plausible reasons (documented in tests/README.md):

  1. aircrack-ng/88XXau may 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 what inject_beacon.py asked for. Verifying this requires a third adapter as sniffer (AR9271) decoding the captured MCS flags.
  2. 8821AU is 802.11ac silicon. Roman's failing case is likely VHT-LDPC, which sits in radiotap bit 21 (VHT info, 12 bytes), not the HT-LDPC bit (bit 19, MCS flags, 3 bytes) this PR adds. Extending ENCODING_COMBOS with VHT entries is straightforward future work; left for a follow-up so this PR stays small.

The d/k and d/d rows are unaffected by either caveat — txdemo writes 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

  • The default 4-cell mode and --full-matrix behaviour — both pure-additive.
  • VHT (802.11ac) encoding fields — HT only for now.
  • Sniffer-side validation of on-air encoding — would need a third adapter wired in.
  • Any TX/RX/HAL code outside txdemoWiFiDriver.a itself 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
  • --help lists the new flag
  • inject_beacon.py _build_radiotap_mcs produces correct bytes for each (ldpc, stbc) combo (manually verified)
  • WiFiDriverTxDemo reads env vars and logs each one (visible in <devourer-tx> log lines)
  • Default mode (tests/regress.py --channel 100) still works — no encoding params passed, encoding=None default preserved
  • --full-matrix still works (not re-validated in this session, but the only run_cell call-site changes are new optional encoding=None parameters)
  • Add VHT encoding combos as a follow-up
  • Add AR9271 sniffer validation step to confirm on-air MCS flags match what the helper requested

🤖 Generated with Claude Code

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>
@josephnef josephnef merged commit 7027247 into master May 25, 2026
5 checks passed
@josephnef josephnef deleted the feat/regress-encoding-matrix branch May 25, 2026 14:43
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>
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