From 7d332335afbb763714156a7c2549e94e64dcb120 Mon Sep 17 00:00:00 2001 From: Joseph <162703152+josephnef@users.noreply.github.com> Date: Sun, 7 Jun 2026 22:11:49 +0300 Subject: [PATCH 1/2] F3: Selectable stream-carrier legacy-OFDM rate (DEVOURER_STREAM_RATE) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two stream demos (StreamDuplexDemo, StreamTxDemo) hard-coded a 13-byte 6-Mbps legacy-OFDM radiotap header. With the precoder stream link's FEC layers landed (#86 RaptorQ, #87 RLC), the carrier rate is now a useful robustness-vs-throughput knob. This v1 ships legacy-OFDM rate switching only: DEVOURER_STREAM_RATE=6M|9M|12M|18M|24M|36M|48M|54M (default 6M) DEVOURER_STREAM_RATE=12|18|24|36|48|72|96|108 (500 kbps units) Higher-rate HT-MCS / VHT paths deliberately stay out of this PR — they need a non-13-byte radiotap and would switch send_packet onto its VHT branch via the existing `radiotap_length != 0x0d` heuristic. F1 (#88) unlocks the HT-MCS path; a follow-up can extend RadiotapBuilder to emit HT and VHT headers behind the same env var. - New src/RadiotapBuilder.{h,cpp}: small helper exposing build_legacy_radiotap(rate_500kbps) -> std::array parse_stream_rate_env() -> uint8_t - Wired into StreamDuplexDemo and StreamTxDemo, replacing each binary's private kRadiotapLegacy6M[13] constant. The on-air length stays at 13 bytes so send_packet keeps these on the legacy path. - PrecoderDemo is intentionally NOT touched — its PoC plan locks the carrier to 6M. Defaults are unchanged on every code path, so the existing regression matrix and tools/precoder/precoder_stream_roundtrip.py stay byte-identical without setting the new env var. Co-Authored-By: Claude Opus 4.7 --- CMakeLists.txt | 2 + src/RadiotapBuilder.cpp | 67 ++++++++++++++++++++++++++++++ src/RadiotapBuilder.h | 49 ++++++++++++++++++++++ txdemo/stream_duplex_demo/main.cpp | 23 +++++----- txdemo/stream_tx_demo/main.cpp | 21 ++++++---- 5 files changed, 143 insertions(+), 19 deletions(-) create mode 100644 src/RadiotapBuilder.cpp create mode 100644 src/RadiotapBuilder.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 6eb73b4..50291f8 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -68,6 +68,8 @@ add_library(WiFiDriver src/RadioManagementModule.cpp src/RadioManagementModule.h src/Radiotap.c + src/RadiotapBuilder.cpp + src/RadiotapBuilder.h src/RtlJaguarDevice.cpp src/RtlJaguarDevice.h src/RtlUsbAdapter.cpp diff --git a/src/RadiotapBuilder.cpp b/src/RadiotapBuilder.cpp new file mode 100644 index 0000000..738937f --- /dev/null +++ b/src/RadiotapBuilder.cpp @@ -0,0 +1,67 @@ +#include "RadiotapBuilder.h" + +#include +#include +#include +#include + +namespace devourer { + +std::array build_legacy_radiotap(uint8_t rate_500kbps) { + /* Layout (bytes): + * 0-1: version/pad + * 2-3: length = 0x000d + * 4-7: it_present = 0x00008004 (bit 2 RATE | bit 15 TX_FLAGS) + * 8: RATE (500 kbps units) + * 9: pad (TX_FLAGS' 2-byte alignment) + * 10-11: TX_FLAGS = 0x0008 (no-ack) + * 12: pad + * + * Bit-identical to the historic kRadiotapLegacy6M[13] constant except + * byte 8, which the caller now controls. */ + return std::array{{ + 0x00, 0x00, 0x0d, 0x00, 0x04, 0x80, 0x00, + 0x00, rate_500kbps, 0x00, 0x08, 0x00, 0x00, + }}; +} + +uint8_t parse_stream_rate_env() { + const char* raw = std::getenv("DEVOURER_STREAM_RATE"); + if (raw == nullptr || *raw == '\0') { + return kStreamRateDefault500kbps; + } + /* Normalise: strip leading/trailing whitespace, upper-case. */ + std::string s; + for (const char* p = raw; *p; ++p) { + if (*p == ' ' || *p == '\t' || *p == '\n') continue; + s.push_back(static_cast(std::toupper(static_cast(*p)))); + } + /* Mnemonic forms first; falls through to int parse. */ + if (s == "6M") return 12; + if (s == "9M") return 18; + if (s == "12M") return 24; + if (s == "18M") return 36; + if (s == "24M") return 48; + if (s == "36M") return 72; + if (s == "48M") return 96; + if (s == "54M") return 108; + /* Bare integer interpreted as 500 kbps units. Reject anything that's + * not a representable legacy-OFDM rate so a typo doesn't quietly send + * an unknown rate (the chip would either silently drop the frame or + * pick its fallback default). */ + char* end = nullptr; + long v = std::strtol(s.c_str(), &end, 0); + if (end && *end == '\0' && v >= 0 && v <= 255) { + switch (v) { + case 12: case 18: case 24: case 36: case 48: case 72: case 96: case 108: + return static_cast(v); + default: + break; + } + } + /* Unrecognised — fall back to default to keep stream demos forward- + * compatible when a future env var includes "MCS0" etc. */ + return kStreamRateDefault500kbps; +} + +} // namespace devourer diff --git a/src/RadiotapBuilder.h b/src/RadiotapBuilder.h new file mode 100644 index 0000000..92a07b4 --- /dev/null +++ b/src/RadiotapBuilder.h @@ -0,0 +1,49 @@ +/* Stream-carrier radiotap helper shared between the stream TX binaries. + * + * The two stream demos (StreamDuplexDemo, StreamTxDemo) historically each + * shipped a private `kRadiotapLegacy6M[13]` constant. With the precoder + * stream link's FEC layers now in master, the carrier MCS/BW is a useful + * robustness-vs-throughput knob — but the underlying chip's send_packet + * uses `radiotap_length != 0x0d` (= 13) as the legacy-vs-VHT path + * selector, so anything that swaps the header has to keep the 13-byte + * length to stay on the legacy path. + * + * This v1 helper switches between the eight legacy OFDM rates (6 / 9 / + * 12 / 18 / 24 / 36 / 48 / 54 Mbps) by mutating byte 8 (the RATE field) + * of an otherwise byte-identical 13-byte header. Higher-rate HT-MCS / VHT + * paths are deliberately out of scope of this first PR — they need PR #88 + * (DEVOURER_TX_HT_MCS) to land first and they ship a non-13-byte radiotap + * which switches send_packet into its VHT branch. Follow-up PR. + */ + +#ifndef RADIOTAP_BUILDER_H +#define RADIOTAP_BUILDER_H + +#include +#include + +namespace devourer { + +/* RATE field convention: u8 in 500 kbps units. 12 = 6 Mbps, 18 = 9 Mbps, + * 24 = 12 Mbps, 36 = 18 Mbps, 48 = 24 Mbps, 72 = 36 Mbps, 96 = 48 Mbps, + * 108 = 54 Mbps. Validated against IEEE 802.11 §17.3.4 and the + * MGN_*M enum positions in src/RadioManagementModule.h. */ +constexpr uint8_t kStreamRateDefault500kbps = 12; // 6 Mbps + +/* Build a 13-byte legacy-OFDM radiotap header with the given RATE field + * (in 500 kbps units). All other bytes are bit-identical to the historic + * kRadiotapLegacy6M constant: presence = RATE | TX_FLAGS, TX_FLAGS = 8 + * (no-ack). Length stays at 0x0d so send_packet's vht-detection + * heuristic keeps this on the legacy path. */ +std::array build_legacy_radiotap(uint8_t rate_500kbps); + +/* Look up DEVOURER_STREAM_RATE and return the corresponding 500 kbps + * units value, or kStreamRateDefault500kbps if the env var is unset or + * unrecognised. Accepted forms: "6M", "9M", "12M", "18M", "24M", "36M", + * "48M", "54M" (case-insensitive). A bare integer (e.g. "12") is + * interpreted as already in 500 kbps units. */ +uint8_t parse_stream_rate_env(); + +} // namespace devourer + +#endif // RADIOTAP_BUILDER_H diff --git a/txdemo/stream_duplex_demo/main.cpp b/txdemo/stream_duplex_demo/main.cpp index 8fd9d7b..bfd6032 100644 --- a/txdemo/stream_duplex_demo/main.cpp +++ b/txdemo/stream_duplex_demo/main.cpp @@ -22,6 +22,7 @@ // canonical SA. Other stdout output is suppressed; stderr carries logger and // counters. +#include #include #include #include @@ -53,6 +54,7 @@ #endif #include "FrameParser.h" +#include "RadiotapBuilder.h" #include "RtlUsbAdapter.h" #include "WiFiDriver.h" #include "logger.h" @@ -63,13 +65,14 @@ static constexpr uint16_t kRealtekProductIds[] = { 0x8812, 0x0811, 0xa811, 0xb811, 0x8813, }; -// Same radiotap + probe-request header as StreamTxDemo / PrecoderDemo. The -// canonical SA matcher in the packet processor below is identical to -// demo/main.cpp's, so any tooling that already grep'd -// lines keeps working unchanged. -static const uint8_t kRadiotapLegacy6M[13] = { - 0x00, 0x00, 0x0d, 0x00, 0x04, 0x80, 0x00, - 0x00, 0x0c, 0x00, 0x08, 0x00, 0x00}; +// Same probe-request header as StreamTxDemo / PrecoderDemo; radiotap is now +// built once at startup from DEVOURER_STREAM_RATE (default 6M legacy OFDM). +// Length stays 13 bytes so send_packet's vht-detection heuristic keeps this +// on the legacy path. The canonical SA matcher in the packet processor +// below is identical to demo/main.cpp's, so any tooling that already grep'd +// lines keeps working unchanged. +static const std::array kRadiotapLegacy = + devourer::build_legacy_radiotap(devourer::parse_stream_rate_env()); static const uint8_t kCanonicalSa[6] = {0x57, 0x42, 0x75, 0x05, 0xd6, 0x00}; static std::vector build_dot11_probe_req() { @@ -136,7 +139,7 @@ struct TxArgs { static void tx_thread(TxArgs args) { auto dot11 = build_dot11_probe_req(); std::vector tx_buf; - tx_buf.reserve(sizeof(kRadiotapLegacy6M) + dot11.size() + args.max_psdu); + tx_buf.reserve(kRadiotapLegacy.size() + dot11.size() + args.max_psdu); long tx_count = 0; while (!args.should_stop->load()) { @@ -162,8 +165,8 @@ static void tx_thread(TxArgs args) { break; } tx_buf.clear(); - tx_buf.insert(tx_buf.end(), kRadiotapLegacy6M, - kRadiotapLegacy6M + sizeof(kRadiotapLegacy6M)); + tx_buf.insert(tx_buf.end(), kRadiotapLegacy.begin(), + kRadiotapLegacy.end()); tx_buf.insert(tx_buf.end(), dot11.begin(), dot11.end()); tx_buf.insert(tx_buf.end(), psdu.begin(), psdu.end()); bool ok = args.rtl->send_packet(tx_buf.data(), tx_buf.size()); diff --git a/txdemo/stream_tx_demo/main.cpp b/txdemo/stream_tx_demo/main.cpp index c1582e8..1f01245 100644 --- a/txdemo/stream_tx_demo/main.cpp +++ b/txdemo/stream_tx_demo/main.cpp @@ -27,6 +27,7 @@ // Env: same conventions as the other demos (DEVOURER_VID / DEVOURER_PID / // DEVOURER_CHANNEL / DEVOURER_SKIP_RESET). +#include #include #include #include @@ -58,6 +59,7 @@ #endif #include "FrameParser.h" +#include "RadiotapBuilder.h" #include "RtlUsbAdapter.h" #include "WiFiDriver.h" #include "logger.h" @@ -68,12 +70,13 @@ static constexpr uint16_t kRealtekProductIds[] = { 0x8812, 0x0811, 0xa811, 0xb811, 0x8813, }; -// Identical legacy-6M radiotap + 802.11 probe-request header to PrecoderDemo. -// Same canonical SA, same matcher in demo/main.cpp's RX path. Keep these -// three in lockstep — see CLAUDE.md. -static const uint8_t kRadiotapLegacy6M[13] = { - 0x00, 0x00, 0x0d, 0x00, 0x04, 0x80, 0x00, - 0x00, 0x0c, 0x00, 0x08, 0x00, 0x00}; +// Identical 802.11 probe-request header to PrecoderDemo; radiotap is now +// built once at startup from DEVOURER_STREAM_RATE (default 6M legacy OFDM). +// Length stays 13 bytes so send_packet's vht-detection heuristic keeps this +// on the legacy path. Same canonical SA, same matcher in demo/main.cpp's +// RX path — keep these three in lockstep, see CLAUDE.md. +static const std::array kRadiotapLegacy = + devourer::build_legacy_radiotap(devourer::parse_stream_rate_env()); static const uint8_t kCanonicalSa[6] = {0x57, 0x42, 0x75, 0x05, 0xd6, 0x00}; static std::vector build_dot11_probe_req() { @@ -215,7 +218,7 @@ int main(int argc, char **argv) { auto dot11 = build_dot11_probe_req(); std::vector tx_buf; - tx_buf.reserve(sizeof(kRadiotapLegacy6M) + dot11.size() + max_psdu); + tx_buf.reserve(kRadiotapLegacy.size() + dot11.size() + max_psdu); logger->info( "stream TX ready (legacy 6M OFDM, ch {}); reading length-prefixed PSDUs " @@ -243,8 +246,8 @@ int main(int argc, char **argv) { } tx_buf.clear(); - tx_buf.insert(tx_buf.end(), kRadiotapLegacy6M, - kRadiotapLegacy6M + sizeof(kRadiotapLegacy6M)); + tx_buf.insert(tx_buf.end(), kRadiotapLegacy.begin(), + kRadiotapLegacy.end()); tx_buf.insert(tx_buf.end(), dot11.begin(), dot11.end()); tx_buf.insert(tx_buf.end(), psdu.begin(), psdu.end()); bool ok = rtlDevice->send_packet(tx_buf.data(), tx_buf.size()); From 10871058d8fc0dae463915e53e745da46bf4ea24 Mon Sep 17 00:00:00 2001 From: Joseph <162703152+josephnef@users.noreply.github.com> Date: Sun, 7 Jun 2026 22:17:58 +0300 Subject: [PATCH 2/2] F3: Extend stream-carrier rate to HT-MCS and VHT modes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the legacy-only v1 API with a single StreamRateCfg / build_stream_radiotap pair that emits three radiotap variants under the same DEVOURER_STREAM_RATE env var: Legacy: 6M | 9M | 12M | 18M | 24M | 36M | 48M | 54M (13-byte radiotap) HT: MCS0 .. MCS31 (13-byte radiotap) VHT: VHT1SS_MCS0 .. VHT4SS_MCS9 (22-byte radiotap) Plus DEVOURER_STREAM_BW (20|40|80|160), _SGI, _LDPC, _STBC. Wire-format facts the chip-side send_packet (RtlJaguarDevice.cpp:42) relies on: - radiotap_length == 13 -> vht=false, rate_id=8 (legacy or HT path) - radiotap_length > 13 -> vht=true, rate_id=9 (VHT path) - the iterator parses *all* fields regardless of vht — so a 13-byte HT-MCS radiotap (presence = MCS|TX_FLAGS, no RATE) is correctly parsed via the IEEE80211_RADIOTAP_MCS case and stays on rate_id=8. Verified against txdemo/main.cpp's own 13-byte HT beacon_frame[]. HT-MCS requires DEVOURER_TX_HT_MCS=1 (F1 gate in send_packet) to actually fly at the requested rate; parse_stream_rate_env() emits a stderr warning when an HT rate is parsed without that gate so users don't silently fall back to 1M CCK. VHT has no such gate — send_packet's VHT branch unconditionally honours the VHT info field. Builders mirror exact field layouts already in use: - HT 13-byte MCS layout cross-checked against txdemo/main.cpp's beacon_frame[] (line 300) — same it_present, same MCS field positions - VHT 22-byte layout cross-checked against txdemo/main.cpp's DEVOURER_TX_VHT path (lines 374-409) — same known mask, same flag bits, same coding/mcs_nss positions Default behaviour (env var unset) is unchanged: 6M legacy OFDM, bit-identical to the historic kRadiotapLegacy6M[13] constant. Co-Authored-By: Claude Opus 4.7 --- src/RadiotapBuilder.cpp | 264 ++++++++++++++++++++++++----- src/RadiotapBuilder.h | 93 ++++++---- txdemo/stream_duplex_demo/main.cpp | 22 +-- txdemo/stream_tx_demo/main.cpp | 20 +-- 4 files changed, 300 insertions(+), 99 deletions(-) diff --git a/src/RadiotapBuilder.cpp b/src/RadiotapBuilder.cpp index 738937f..f30adfd 100644 --- a/src/RadiotapBuilder.cpp +++ b/src/RadiotapBuilder.cpp @@ -1,67 +1,241 @@ #include "RadiotapBuilder.h" #include +#include #include -#include #include namespace devourer { -std::array build_legacy_radiotap(uint8_t rate_500kbps) { - /* Layout (bytes): - * 0-1: version/pad - * 2-3: length = 0x000d - * 4-7: it_present = 0x00008004 (bit 2 RATE | bit 15 TX_FLAGS) - * 8: RATE (500 kbps units) - * 9: pad (TX_FLAGS' 2-byte alignment) - * 10-11: TX_FLAGS = 0x0008 (no-ack) - * 12: pad +namespace { + +constexpr uint8_t kRadiotapVersion = 0; + +/* it_present bit positions per radiotap spec. */ +constexpr uint32_t kPresentRate = 1u << 2; +constexpr uint32_t kPresentTxFlags = 1u << 15; +constexpr uint32_t kPresentMcs = 1u << 19; +constexpr uint32_t kPresentVht = 1u << 21; + +constexpr uint16_t kTxFlagsNoAck = 0x0008; + +void emit_u8(std::vector& v, uint8_t x) { v.push_back(x); } +void emit_u16_le(std::vector& v, uint16_t x) { + v.push_back(static_cast(x & 0xFF)); + v.push_back(static_cast((x >> 8) & 0xFF)); +} +void emit_u32_le(std::vector& v, uint32_t x) { + v.push_back(static_cast(x & 0xFF)); + v.push_back(static_cast((x >> 8) & 0xFF)); + v.push_back(static_cast((x >> 16) & 0xFF)); + v.push_back(static_cast((x >> 24) & 0xFF)); +} + +std::vector build_legacy(const StreamRateCfg& cfg) { + /* 13-byte legacy-OFDM radiotap. Bit-identical to the historic + * kRadiotapLegacy6M[13] constant except byte 8 (RATE), which the caller + * controls. Length stays 13 so send_packet's vht-detection heuristic + * keeps this on the legacy path. */ + std::vector r; + r.reserve(13); + emit_u8(r, kRadiotapVersion); + emit_u8(r, 0); /* pad */ + emit_u16_le(r, 13); /* it_len */ + emit_u32_le(r, kPresentRate | kPresentTxFlags); /* it_present */ + emit_u8(r, cfg.legacy_rate_500kbps); /* RATE */ + emit_u8(r, 0); /* pad (TX_FLAGS u16 align) */ + emit_u16_le(r, kTxFlagsNoAck); /* TX_FLAGS */ + emit_u8(r, 0); /* trailing pad to 13 */ + return r; +} + +std::vector build_ht(const StreamRateCfg& cfg) { + /* 13-byte HT radiotap: presence = TX_FLAGS | MCS, no RATE field. Total + * header length must remain 13 so send_packet stays on rate_id=8 (HT, + * not VHT). Verified against txdemo/main.cpp's beacon_frame[] HT layout + * at line 300 — same it_present, same MCS field positions. * - * Bit-identical to the historic kRadiotapLegacy6M[13] constant except - * byte 8, which the caller now controls. */ - return std::array{{ - 0x00, 0x00, 0x0d, 0x00, 0x04, 0x80, 0x00, - 0x00, rate_500kbps, 0x00, 0x08, 0x00, 0x00, - }}; + * MCS field layout (3 bytes after TX_FLAGS): + * byte 0: known mask + * byte 1: flags (BW / SGI / FEC / STBC) + * byte 2: MCS index 0..31 + */ + constexpr uint8_t kKnownBw = 1u << 0; + constexpr uint8_t kKnownMcs = 1u << 1; + constexpr uint8_t kKnownGi = 1u << 2; + constexpr uint8_t kKnownFec = 1u << 4; + constexpr uint8_t kKnownStbc = 1u << 5; + + uint8_t known = kKnownMcs | kKnownGi | kKnownFec | kKnownBw | kKnownStbc; + uint8_t flags = 0; + /* BW: 0=20, 1=40. (20L/20U upper/lower not exposed.) */ + if (cfg.bw_mhz >= 40) flags |= 0x01; + if (cfg.sgi) flags |= 0x04; + if (cfg.ldpc) flags |= 0x10; + if (cfg.stbc) flags |= 0x20; /* one STBC stream */ + + std::vector r; + r.reserve(13); + emit_u8(r, kRadiotapVersion); + emit_u8(r, 0); + emit_u16_le(r, 13); + emit_u32_le(r, kPresentTxFlags | kPresentMcs); + emit_u16_le(r, kTxFlagsNoAck); + emit_u8(r, known); + emit_u8(r, flags); + emit_u8(r, cfg.ht_mcs <= 31 ? cfg.ht_mcs : 0); + return r; } -uint8_t parse_stream_rate_env() { - const char* raw = std::getenv("DEVOURER_STREAM_RATE"); - if (raw == nullptr || *raw == '\0') { - return kStreamRateDefault500kbps; +std::vector build_vht(const StreamRateCfg& cfg) { + /* 22-byte VHT radiotap. Identical layout to txdemo/main.cpp's + * DEVOURER_TX_VHT=1 path (lines 374-409) — header(8) + TX_FLAGS(2) + + * VHT info(12). Length > 13 triggers send_packet's vht=true branch + * (rate_id=9), which is required for the VHT info field to be honoured. */ + uint8_t bw_code; + switch (cfg.bw_mhz) { + case 40: bw_code = 1; break; + case 80: bw_code = 4; break; + case 160: bw_code = 11; break; + default: bw_code = 0; break; } - /* Normalise: strip leading/trailing whitespace, upper-case. */ + /* known mask: STBC(0) | GI(2) | BW(6) — matches send_packet's VHT + * decoder at RtlJaguarDevice.cpp:110-138. */ + const uint16_t known = (1u << 0) | (1u << 2) | (1u << 6); + uint8_t vht_flags = 0; + if (cfg.stbc) vht_flags |= 0x01; + if (cfg.sgi) vht_flags |= 0x04; + const uint8_t mcs_nss_user0 = static_cast( + ((cfg.vht_mcs & 0x0F) << 4) | (cfg.vht_nss & 0x0F)); + const uint8_t coding_user0 = cfg.ldpc ? 0x01 : 0x00; + + std::vector r; + r.reserve(22); + emit_u8(r, kRadiotapVersion); + emit_u8(r, 0); + emit_u16_le(r, 22); + emit_u32_le(r, kPresentTxFlags | kPresentVht); + emit_u16_le(r, kTxFlagsNoAck); + emit_u16_le(r, known); + emit_u8(r, vht_flags); + emit_u8(r, bw_code); + emit_u8(r, mcs_nss_user0); + emit_u8(r, 0); /* user 1 mcs_nss */ + emit_u8(r, 0); /* user 2 mcs_nss */ + emit_u8(r, 0); /* user 3 mcs_nss */ + emit_u8(r, coding_user0); + emit_u8(r, 0); /* group_id */ + emit_u16_le(r, 0); /* partial_aid */ + return r; +} + +std::string to_upper_stripped(const char* raw) { std::string s; for (const char* p = raw; *p; ++p) { if (*p == ' ' || *p == '\t' || *p == '\n') continue; s.push_back(static_cast(std::toupper(static_cast(*p)))); } - /* Mnemonic forms first; falls through to int parse. */ - if (s == "6M") return 12; - if (s == "9M") return 18; - if (s == "12M") return 24; - if (s == "18M") return 36; - if (s == "24M") return 48; - if (s == "36M") return 72; - if (s == "48M") return 96; - if (s == "54M") return 108; - /* Bare integer interpreted as 500 kbps units. Reject anything that's - * not a representable legacy-OFDM rate so a typo doesn't quietly send - * an unknown rate (the chip would either silently drop the frame or - * pick its fallback default). */ - char* end = nullptr; - long v = std::strtol(s.c_str(), &end, 0); - if (end && *end == '\0' && v >= 0 && v <= 255) { - switch (v) { - case 12: case 18: case 24: case 36: case 48: case 72: case 96: case 108: - return static_cast(v); - default: - break; + return s; +} + +bool parse_uint(const std::string& s, size_t pos, unsigned* out) { + if (pos >= s.size()) return false; + unsigned v = 0; + size_t i = pos; + while (i < s.size() && s[i] >= '0' && s[i] <= '9') { + v = v * 10 + static_cast(s[i] - '0'); + ++i; + if (v > 1000) return false; + } + if (i == pos) return false; + *out = v; + return true; +} + +} // namespace + +std::vector build_stream_radiotap(const StreamRateCfg& cfg) { + switch (cfg.mode) { + case StreamRateCfg::Mode::HT: return build_ht(cfg); + case StreamRateCfg::Mode::VHT: return build_vht(cfg); + case StreamRateCfg::Mode::Legacy: + default: return build_legacy(cfg); + } +} + +StreamRateCfg parse_stream_rate_env() { + StreamRateCfg cfg; + + /* Bandwidth (cross-cuts modes). */ + if (const char* bw = std::getenv("DEVOURER_STREAM_BW")) { + int v = std::atoi(bw); + if (v == 20 || v == 40 || v == 80 || v == 160) { + cfg.bw_mhz = static_cast(v); } } - /* Unrecognised — fall back to default to keep stream demos forward- - * compatible when a future env var includes "MCS0" etc. */ - return kStreamRateDefault500kbps; + cfg.sgi = std::getenv("DEVOURER_STREAM_SGI") != nullptr; + cfg.ldpc = std::getenv("DEVOURER_STREAM_LDPC") != nullptr; + cfg.stbc = std::getenv("DEVOURER_STREAM_STBC") != nullptr; + + const char* raw = std::getenv("DEVOURER_STREAM_RATE"); + if (raw == nullptr || *raw == '\0') { + return cfg; /* defaults: legacy 6M */ + } + const std::string s = to_upper_stripped(raw); + + /* Legacy mnemonics. */ + if (s == "6M") { cfg.legacy_rate_500kbps = 12; return cfg; } + if (s == "9M") { cfg.legacy_rate_500kbps = 18; return cfg; } + if (s == "12M") { cfg.legacy_rate_500kbps = 24; return cfg; } + if (s == "18M") { cfg.legacy_rate_500kbps = 36; return cfg; } + if (s == "24M") { cfg.legacy_rate_500kbps = 48; return cfg; } + if (s == "36M") { cfg.legacy_rate_500kbps = 72; return cfg; } + if (s == "48M") { cfg.legacy_rate_500kbps = 96; return cfg; } + if (s == "54M") { cfg.legacy_rate_500kbps = 108; return cfg; } + + /* HT: MCS, 0..31. */ + if (s.rfind("MCS", 0) == 0) { + unsigned mcs; + if (parse_uint(s, 3, &mcs) && mcs <= 31) { + cfg.mode = StreamRateCfg::Mode::HT; + cfg.ht_mcs = static_cast(mcs); + if (std::getenv("DEVOURER_TX_HT_MCS") == nullptr) { + std::fprintf( + stderr, + "warning: DEVOURER_STREAM_RATE=MCS%u requires " + "DEVOURER_TX_HT_MCS=1 to actually fly at the requested rate " + "(otherwise send_packet falls back to 1M CCK)\n", + mcs); + } + return cfg; + } + } + + /* VHT: VHTSS_MCS, NSS 1..4, MCS 0..9. */ + if (s.rfind("VHT", 0) == 0) { + unsigned nss; + if (parse_uint(s, 3, &nss) && nss >= 1 && nss <= 4) { + size_t after_nss = 3; + while (after_nss < s.size() && s[after_nss] >= '0' && s[after_nss] <= '9') + ++after_nss; + const std::string tail = "SS_MCS"; + if (s.compare(after_nss, tail.size(), tail) == 0) { + unsigned mcs; + if (parse_uint(s, after_nss + tail.size(), &mcs) && mcs <= 9) { + cfg.mode = StreamRateCfg::Mode::VHT; + cfg.vht_nss = static_cast(nss); + cfg.vht_mcs = static_cast(mcs); + return cfg; + } + } + } + } + + /* Unrecognised — fall back to default 6M legacy. */ + std::fprintf(stderr, + "warning: unrecognised DEVOURER_STREAM_RATE=%s," + " falling back to 6M legacy\n", raw); + return cfg; } } // namespace devourer diff --git a/src/RadiotapBuilder.h b/src/RadiotapBuilder.h index 92a07b4..c28fefa 100644 --- a/src/RadiotapBuilder.h +++ b/src/RadiotapBuilder.h @@ -1,48 +1,75 @@ /* Stream-carrier radiotap helper shared between the stream TX binaries. * - * The two stream demos (StreamDuplexDemo, StreamTxDemo) historically each - * shipped a private `kRadiotapLegacy6M[13]` constant. With the precoder - * stream link's FEC layers now in master, the carrier MCS/BW is a useful - * robustness-vs-throughput knob — but the underlying chip's send_packet - * uses `radiotap_length != 0x0d` (= 13) as the legacy-vs-VHT path - * selector, so anything that swaps the header has to keep the 13-byte - * length to stay on the legacy path. + * The stream demos historically each shipped a private kRadiotapLegacy6M[13] + * constant. This helper switches between three carrier modes — Legacy OFDM, + * HT-MCS, and VHT — under one env var (DEVOURER_STREAM_RATE), so the + * carrier rate becomes a robustness-vs-throughput knob for the precoder + * stream link's FEC layers (PR #86 / #87). * - * This v1 helper switches between the eight legacy OFDM rates (6 / 9 / - * 12 / 18 / 24 / 36 / 48 / 54 Mbps) by mutating byte 8 (the RATE field) - * of an otherwise byte-identical 13-byte header. Higher-rate HT-MCS / VHT - * paths are deliberately out of scope of this first PR — they need PR #88 - * (DEVOURER_TX_HT_MCS) to land first and they ship a non-13-byte radiotap - * which switches send_packet into its VHT branch. Follow-up PR. + * Three wire-format facts the chip-side send_packet relies on: + * + * - Length 13 (0x0d) → legacy path. radiotap_length != 0x0d sets vht=true + * in RtlJaguarDevice::send_packet, switching rate_id to 9. + * - 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 + * rate_id=8. That's what we want for HT-MCS carriers. + * - VHT needs a 22-byte radiotap (it_present=VHT|TX_FLAGS). Length>13 + * puts us on rate_id=9 — that's what the VHT branch requires. + * + * For HT-MCS to actually set fixed_rate (vs falling back to MGN_1M CCK at + * 1 Mbps), the F1 gate DEVOURER_TX_HT_MCS=1 must also be set on the same + * process. parse_stream_rate_env() logs a warning to stderr if an HT + * rate is parsed without that gate; VHT has no such gate (send_packet's + * VHT branch always sets fixed_rate from the VHT info field). */ #ifndef RADIOTAP_BUILDER_H #define RADIOTAP_BUILDER_H -#include #include +#include namespace devourer { -/* RATE field convention: u8 in 500 kbps units. 12 = 6 Mbps, 18 = 9 Mbps, - * 24 = 12 Mbps, 36 = 18 Mbps, 48 = 24 Mbps, 72 = 36 Mbps, 96 = 48 Mbps, - * 108 = 54 Mbps. Validated against IEEE 802.11 §17.3.4 and the - * MGN_*M enum positions in src/RadioManagementModule.h. */ -constexpr uint8_t kStreamRateDefault500kbps = 12; // 6 Mbps - -/* Build a 13-byte legacy-OFDM radiotap header with the given RATE field - * (in 500 kbps units). All other bytes are bit-identical to the historic - * kRadiotapLegacy6M constant: presence = RATE | TX_FLAGS, TX_FLAGS = 8 - * (no-ack). Length stays at 0x0d so send_packet's vht-detection - * heuristic keeps this on the legacy path. */ -std::array build_legacy_radiotap(uint8_t rate_500kbps); - -/* Look up DEVOURER_STREAM_RATE and return the corresponding 500 kbps - * units value, or kStreamRateDefault500kbps if the env var is unset or - * unrecognised. Accepted forms: "6M", "9M", "12M", "18M", "24M", "36M", - * "48M", "54M" (case-insensitive). A bare integer (e.g. "12") is - * interpreted as already in 500 kbps units. */ -uint8_t parse_stream_rate_env(); +struct StreamRateCfg { + enum class Mode { Legacy, HT, VHT }; + Mode mode = Mode::Legacy; + + /* Legacy: in 500 kbps units. 12=6M, 18=9M, 24=12M, 36=18M, 48=24M, + * 72=36M, 96=48M, 108=54M. Default 6M. */ + uint8_t legacy_rate_500kbps = 12; + + /* HT: 0..31 (NSS is implicit — MCS0-7 = 1ss, 8-15 = 2ss, ...). */ + uint8_t ht_mcs = 0; + + /* VHT: per IEEE 802.11ac. mcs 0..9, nss 1..4. */ + uint8_t vht_mcs = 0; + uint8_t vht_nss = 1; + + /* Bandwidth in MHz. Legacy mode ignores this (always 20 MHz). HT + * recognises 20 or 40; VHT recognises 20/40/80/160. */ + uint8_t bw_mhz = 20; + + bool sgi = false; + bool ldpc = false; + bool stbc = false; +}; + +/* Build a radiotap header according to cfg.mode. Output is always a + * complete, well-formed radiotap header — no 802.11 frame body. */ +std::vector build_stream_radiotap(const StreamRateCfg& cfg); + +/* Parse DEVOURER_STREAM_RATE / DEVOURER_STREAM_BW / DEVOURER_STREAM_SGI / + * DEVOURER_STREAM_LDPC / DEVOURER_STREAM_STBC. Unrecognised values fall + * back to default 6M legacy. + * + * DEVOURER_STREAM_RATE grammar (case-insensitive): + * - Legacy: 6M / 9M / 12M / 18M / 24M / 36M / 48M / 54M + * - HT: MCS0 .. MCS31 + * - VHT: VHT1SS_MCS0 .. VHT4SS_MCS9 + */ +StreamRateCfg parse_stream_rate_env(); } // namespace devourer diff --git a/txdemo/stream_duplex_demo/main.cpp b/txdemo/stream_duplex_demo/main.cpp index bfd6032..d01a986 100644 --- a/txdemo/stream_duplex_demo/main.cpp +++ b/txdemo/stream_duplex_demo/main.cpp @@ -22,7 +22,6 @@ // canonical SA. Other stdout output is suppressed; stderr carries logger and // counters. -#include #include #include #include @@ -66,13 +65,14 @@ static constexpr uint16_t kRealtekProductIds[] = { }; // Same probe-request header as StreamTxDemo / PrecoderDemo; radiotap is now -// built once at startup from DEVOURER_STREAM_RATE (default 6M legacy OFDM). -// Length stays 13 bytes so send_packet's vht-detection heuristic keeps this -// on the legacy path. The canonical SA matcher in the packet processor -// below is identical to demo/main.cpp's, so any tooling that already grep'd -// lines keeps working unchanged. -static const std::array kRadiotapLegacy = - devourer::build_legacy_radiotap(devourer::parse_stream_rate_env()); +// built once at startup from DEVOURER_STREAM_RATE — accepts legacy +// (6M..54M), HT (MCS0..MCS31), or VHT (VHT1SS_MCS0..VHT4SS_MCS9) carrier +// modes. Default is 6M legacy OFDM, bit-identical to the historic +// kRadiotapLegacy6M constant. The canonical SA matcher in the packet +// processor below is identical to demo/main.cpp's, so any tooling that +// already grep'd lines keeps working unchanged. +static const std::vector kStreamRadiotap = + devourer::build_stream_radiotap(devourer::parse_stream_rate_env()); static const uint8_t kCanonicalSa[6] = {0x57, 0x42, 0x75, 0x05, 0xd6, 0x00}; static std::vector build_dot11_probe_req() { @@ -139,7 +139,7 @@ struct TxArgs { static void tx_thread(TxArgs args) { auto dot11 = build_dot11_probe_req(); std::vector tx_buf; - tx_buf.reserve(kRadiotapLegacy.size() + dot11.size() + args.max_psdu); + tx_buf.reserve(kStreamRadiotap.size() + dot11.size() + args.max_psdu); long tx_count = 0; while (!args.should_stop->load()) { @@ -165,8 +165,8 @@ static void tx_thread(TxArgs args) { break; } tx_buf.clear(); - tx_buf.insert(tx_buf.end(), kRadiotapLegacy.begin(), - kRadiotapLegacy.end()); + tx_buf.insert(tx_buf.end(), kStreamRadiotap.begin(), + kStreamRadiotap.end()); tx_buf.insert(tx_buf.end(), dot11.begin(), dot11.end()); tx_buf.insert(tx_buf.end(), psdu.begin(), psdu.end()); bool ok = args.rtl->send_packet(tx_buf.data(), tx_buf.size()); diff --git a/txdemo/stream_tx_demo/main.cpp b/txdemo/stream_tx_demo/main.cpp index 1f01245..7dc1279 100644 --- a/txdemo/stream_tx_demo/main.cpp +++ b/txdemo/stream_tx_demo/main.cpp @@ -27,7 +27,6 @@ // Env: same conventions as the other demos (DEVOURER_VID / DEVOURER_PID / // DEVOURER_CHANNEL / DEVOURER_SKIP_RESET). -#include #include #include #include @@ -71,12 +70,13 @@ static constexpr uint16_t kRealtekProductIds[] = { }; // Identical 802.11 probe-request header to PrecoderDemo; radiotap is now -// built once at startup from DEVOURER_STREAM_RATE (default 6M legacy OFDM). -// Length stays 13 bytes so send_packet's vht-detection heuristic keeps this -// on the legacy path. Same canonical SA, same matcher in demo/main.cpp's -// RX path — keep these three in lockstep, see CLAUDE.md. -static const std::array kRadiotapLegacy = - devourer::build_legacy_radiotap(devourer::parse_stream_rate_env()); +// built once at startup from DEVOURER_STREAM_RATE — accepts legacy +// (6M..54M), HT (MCS0..MCS31), or VHT (VHT1SS_MCS0..VHT4SS_MCS9) carrier +// modes. Default is 6M legacy OFDM, bit-identical to the historic +// kRadiotapLegacy6M constant. Same canonical SA, same matcher in +// demo/main.cpp's RX path — keep these three in lockstep, see CLAUDE.md. +static const std::vector kStreamRadiotap = + devourer::build_stream_radiotap(devourer::parse_stream_rate_env()); static const uint8_t kCanonicalSa[6] = {0x57, 0x42, 0x75, 0x05, 0xd6, 0x00}; static std::vector build_dot11_probe_req() { @@ -218,7 +218,7 @@ int main(int argc, char **argv) { auto dot11 = build_dot11_probe_req(); std::vector tx_buf; - tx_buf.reserve(kRadiotapLegacy.size() + dot11.size() + max_psdu); + tx_buf.reserve(kStreamRadiotap.size() + dot11.size() + max_psdu); logger->info( "stream TX ready (legacy 6M OFDM, ch {}); reading length-prefixed PSDUs " @@ -246,8 +246,8 @@ int main(int argc, char **argv) { } tx_buf.clear(); - tx_buf.insert(tx_buf.end(), kRadiotapLegacy.begin(), - kRadiotapLegacy.end()); + tx_buf.insert(tx_buf.end(), kStreamRadiotap.begin(), + kStreamRadiotap.end()); tx_buf.insert(tx_buf.end(), dot11.begin(), dot11.end()); tx_buf.insert(tx_buf.end(), psdu.begin(), psdu.end()); bool ok = rtlDevice->send_packet(tx_buf.data(), tx_buf.size());