Skip to content

F1: Honour HT-MCS in send_packet (gated by DEVOURER_TX_HT_MCS)#88

Merged
josephnef merged 1 commit into
masterfrom
ht-mcs-send-packet
Jun 7, 2026
Merged

F1: Honour HT-MCS in send_packet (gated by DEVOURER_TX_HT_MCS)#88
josephnef merged 1 commit into
masterfrom
ht-mcs-send-packet

Conversation

@josephnef
Copy link
Copy Markdown
Collaborator

Summary

RtlJaguarDevice::send_packet's IEEE80211_RADIOTAP_MCS branch parsed BW and SGI from the radiotap MCS field but never set fixed_rate. An HT-MCS-only radiotap header fell through to the function-top MGN_1M default, so the chip transmitted 1 Mbps CCK regardless of the MCS index. PR #80's README documented this as the "HT MCS 0 doesn't reach the air" decisive correction that locked the precoder PoC to legacy 6 Mbps OFDM.

The VHT branch already does the right thing (line 119: fixed_rate = MGN_VHT1SS_MCS0 + ((nss-1)*10 + mcs)); the HT case just needed the equivalent: fixed_rate = MGN_MCS0 + radiotap_byte_2 when HAVE_MCS is set. MGN_MCS0..MGN_MCS31 = 0x80..0x9F map cleanly through MRateToHwRate to DESC_RATEMCS0..MCS31. No translation layer needed.

Why gated

Flipping this unconditionally would silently change the regression matrix's rate sweeps (currently expecting 1 Mbps CCK on HT-MCS frames) and would break PR #80's PoC plan that explicitly locks to legacy 6M OFDM as the precoder's on-air carrier. Gated behind DEVOURER_TX_HT_MCS=1. Defaults are unchanged.

Patch

case IEEE80211_RADIOTAP_MCS: {
  u8 mcs_known = iterator.this_arg[0];   // new
  u8 mcs_flags = iterator.this_arg[1];
  /* existing BW + SGI handling unchanged */
+ static const bool ht_mcs_enabled =
+     std::getenv("DEVOURER_TX_HT_MCS") != nullptr;
+ if (ht_mcs_enabled && (mcs_known & IEEE80211_RADIOTAP_MCS_HAVE_MCS)) {
+   uint8_t mcs_index = iterator.this_arg[2];
+   if (mcs_index <= 31) {
+     fixed_rate = MGN_MCS0 + mcs_index;
+   }
+ }
} break;

Unlocked

  • 6.5 Mbps → 150 Mbps OFDM (1 stream, MCS 0..7) and SGI/40 MHz variants
  • 5–10× the airtime budget per frame for the JSCC/FEC client
  • Foundation for the F3 stream-carrier rate switch (planned follow-up)

Test plan

  • cmake --build build -j clean
  • Default behaviour (DEVOURER_TX_HT_MCS unset): code path is if (false && ...) → no observable change. Existing tests pass.
  • Hardware (depends on F3 for the CLI to drive it): set DEVOURER_TX_HT_MCS=1, transmit a probe request with an HT-MCS radiotap header, assert the RX-side data_rate is in the HT range (0x0c..0x2b) rather than 0x00 (1M CCK).

First in a series of five small C++ features for the precoder stream link. Followed by:

  • F4 — surface RX seq + tsfl on <devourer-stream>
  • F3 — selectable stream-carrier rate/BW (uses F1's HT-MCS unlock)
  • F5 — C2H TX-RPT parser + REG_FIFOPAGE_INFO queue-depth poll
  • F2 — BB-dbgport per-subcarrier IQ spike (research)

🤖 Generated with Claude Code

The IEEE80211_RADIOTAP_MCS branch in RtlJaguarDevice::send_packet
parsed the bandwidth and SGI fields out of the radiotap MCS field but
never set `fixed_rate`. The result was that an HT-MCS-only radiotap
header (no legacy RATE field) fell through to the function-top default
of MGN_1M, so the chip transmitted 1 Mbps CCK regardless of the MCS
index — PR #80's README documented this as the "HT MCS 0 doesn't reach
the air" decisive correction that locked the precoder PoC to legacy
6 Mbps OFDM.

The VHT branch already does the right thing (line 119:
`fixed_rate = MGN_VHT1SS_MCS0 + ((nss-1)*10 + mcs)`); the HT case just
needed the equivalent mapping
(`fixed_rate = MGN_MCS0 + radiotap_byte_2` when HAVE_MCS is set).
MGN_MCS0..MGN_MCS31 = 0x80..0x9F map cleanly through MRateToHwRate to
DESC_RATEMCS0..MCS31. No translation layer needed.

Gated behind DEVOURER_TX_HT_MCS=1 — flipping this unconditionally
would silently change the regression matrix's rate sweeps (currently
expecting 1 Mbps CCK on HT-MCS frames) and would break PR #80's
PoC plan that explicitly locks to legacy 6M OFDM as the precoder's
on-air carrier. Defaults are unchanged.

Verification

* Unit / regression: the existing test suite is byte-identical without
  the env var set (no code path change).
* Hardware (next-step): set DEVOURER_TX_HT_MCS=1, transmit a probe
  request with an HT-MCS radiotap header, expect the RX-side
  data_rate field to surface in the HT range (0x0c..0x2b for
  MCS0..MCS31) rather than 0x00 (1M CCK). The radiotap-builder PR
  (F3 follow-up) will provide the CLI to drive this end-to-end.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@josephnef josephnef merged commit 84214e0 into master Jun 7, 2026
5 checks passed
@josephnef josephnef deleted the ht-mcs-send-packet branch June 7, 2026 19:04
josephnef added a commit that referenced this pull request Jun 7, 2026
## Summary

Both fields are already on the RX descriptor: `seq_num` is parsed at
`FrameParser.cpp:98`, `tsfl` was one commented-out line at line 129. The
FEC layer (#86 / #87) and any latency-measurement consumer want both
visible; this PR surfaces what the chip already gives us.

## Changes

- **`src/FrameParser.h`** — add `uint32_t tsfl` to `rx_pkt_attrib`
alongside the existing `seq_num`.
- **`src/FrameParser.cpp`** — uncomment the TSFL parser and drop the
bogus `(byte)` cast (the macro reads all 32 bits of `pdesc+20` as a u32,
not a byte — verified against `rtl8812a_recv.h`):
  ```diff
  - /* pattrib.tsfl=(byte)GET_RX_STATUS_DESC_TSFL_8812(pdesc); */
  + pattrib.tsfl = GET_RX_STATUS_DESC_TSFL_8812(pdesc);
  ```
- **`demo/main.cpp`** — extend the `<devourer-stream>` printf with
`seq=%u tsfl=%u`. Optional fields; PR #84's regex pattern in
`stream_rx.py` / `tun_p2p.py` / `corruption_analysis.py` already
tolerates them via the same pass-through approach used for rssi/evm/snr.

## What this enables (out of scope for this PR — just data surfacing)

- FEC RX side can dedup by chip-side seq before feeding the codec, so
air-level retransmissions stop double-counting at the codec.
- One-way latency measurement by diffing TSF against the host clock at
TX time — a building block for the F5 TX-RPT goodput numbers and any
adaptive `--fec-overhead` loop.

## Test plan

- [x] `cmake --build build -j` clean
- [x] `<devourer-stream>` lines on master now carry `seq` + `tsfl`
fields; existing Python consumers tolerate the additions via their
existing regex pass-through (no Python-side change required).
- [ ] Reviewer to run an existing tun_p2p bench and confirm the new
fields appear without disturbing throughput / loss numbers.

Second in the five-feature C++ series. Followed by:
- F3 — selectable stream-carrier rate/BW (uses F1's HT-MCS unlock + this
PR's seq/tsfl plumbing for dup detection)
- F5 — C2H TX-RPT parser + REG_FIFOPAGE_INFO queue-depth poll
- F2 — BB-dbgport per-subcarrier IQ spike (research)

Predecessor: F1 (#88).

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

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
josephnef added a commit that referenced this pull request Jun 7, 2026
## Summary

Replaces the hard-coded 13-byte 6-Mbps legacy-OFDM radiotap header in
`StreamDuplexDemo` and `StreamTxDemo` with a
`DEVOURER_STREAM_RATE`-controlled builder that emits Legacy / HT-MCS /
VHT carriers under one env var.

## Env var grammar

```
DEVOURER_STREAM_RATE=6M|9M|12M|18M|24M|36M|48M|54M           # Legacy
DEVOURER_STREAM_RATE=MCS0..MCS31                              # HT
DEVOURER_STREAM_RATE=VHT1SS_MCS0..VHT4SS_MCS9                 # VHT

DEVOURER_STREAM_BW=20|40|80|160      # bw (Legacy ignores; HT honours 20/40; VHT honours 20/40/80/160)
DEVOURER_STREAM_SGI=1                # short GI
DEVOURER_STREAM_LDPC=1               # FEC = LDPC
DEVOURER_STREAM_STBC=1               # STBC
```

Default (env var unset) = 6M legacy, bit-identical to the historic
`kRadiotapLegacy6M[13]`.

## Wire-format facts the chip relies on

`send_packet` (`RtlJaguarDevice.cpp:42`):

| radiotap length | vht | rate_id | builder mode |
|---|---|---|---|
| 13 | false | 8 | Legacy or HT-MCS |
| > 13 | true | 9 | VHT |

The radiotap iterator parses **all** fields regardless of `vht` — so a
13-byte HT-MCS radiotap (`it_present = MCS \| TX_FLAGS`, no RATE) is
correctly parsed via the `IEEE80211_RADIOTAP_MCS` case and stays on the
HT path. Cross-checked against `txdemo/main.cpp`'s own 13-byte HT
beacon_frame[] layout (line 300).

VHT uses the same 22-byte layout as `txdemo/main.cpp`'s
`DEVOURER_TX_VHT=1` path (lines 374-409): `it_present = VHT \|
TX_FLAGS`, `known = STBC \| GI \| BW`, MCS/NSS in user-0 nibbles.

## F1 dependency for HT mode

HT-MCS in `send_packet` is gated by `DEVOURER_TX_HT_MCS=1` (F1, PR #88,
merged). `parse_stream_rate_env()` emits a stderr warning if an HT rate
is parsed without that gate so users don't silently fall back to 1M CCK:

```
<stream-radiotap>warning: DEVOURER_STREAM_RATE=MCS5 requires DEVOURER_TX_HT_MCS=1 to actually fly at the requested rate (otherwise send_packet falls back to 1M CCK)
```

VHT has no such gate — `send_packet`'s VHT branch always honours the VHT
info field.

## Hardware verification (8/8 PASS)

TX = RTL8812AU (0bda:8812), RX = TP-Link RTL8821AU (2357:0120,
`DEVOURER_VID=0x2357 DEVOURER_PID=0x0120`). ~200 stream frames per case,
RX-side `<devourer-stream>rate=N` index parsed.

| Case | Env | Channel | Expected rate index | Frames RX | Observed |
|---|---|---:|---:|---:|---:|
| baseline | (none) | 6 | 4 (DESC_RATE6M) | 183 | **4** ✓ |
| Legacy 6M | `STREAM_RATE=6M` | 6 | 4 | 197 | **4** ✓ |
| Legacy 24M | `STREAM_RATE=24M` | 6 | 8 (DESC_RATE24M) | 192 | **8** ✓
|
| Legacy 54M | `STREAM_RATE=54M` | 6 | 11 (DESC_RATE54M) | 193 | **11**
✓ |
| HT MCS5 | `STREAM_RATE=MCS5 TX_HT_MCS=1` | 6 | 17 (DESC_RATEMCS5) |
175 | **17** ✓ |
| VHT 1SS MCS3 BW20 | `STREAM_RATE=VHT1SS_MCS3 STREAM_BW=20` | 6 | 47
(DESC_RATEVHTSS1MCS3) | 185 | **47** ✓ |
| VHT 1SS MCS3 BW20 | `STREAM_RATE=VHT1SS_MCS3 STREAM_BW=20` | 36 | 47 |
192 | **47** ✓ |
| VHT 1SS MCS7 BW80 | `STREAM_RATE=VHT1SS_MCS7 STREAM_BW=80` | 36 | 51
(DESC_RATEVHTSS1MCS7) | 192 | **51** ✓ |

Every case: 100 % of received frames carry the expected rate index.
Default behaviour (no env var) is byte-identical to master.

## What ships

- `src/RadiotapBuilder.{h,cpp}` — `StreamRateCfg` struct +
`build_stream_radiotap(cfg) -> std::vector<uint8_t>` +
`parse_stream_rate_env() -> StreamRateCfg`. Three internal builders
(legacy 13B, HT 13B, VHT 22B) keyed off `cfg.mode`.
- `StreamDuplexDemo` and `StreamTxDemo` switched from each demo's
private `kRadiotapLegacy6M[13]` constant to a `kStreamRadiotap` vector
built once at static-init from the env var.
- `PrecoderDemo` intentionally untouched — its PoC plan explicitly locks
the carrier to 6M.
- `CMakeLists.txt` updated to compile the helper into `WiFiDriver`.

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

---------

Co-authored-by: Claude Opus 4.7 <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