diff --git a/CLAUDE.md b/CLAUDE.md index a6ebed51..f3f42f8c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -249,7 +249,7 @@ those are the ones listed below. `DEVOURER_USB_PORT=a.b.c` select by USB topology when two adapters share VID:PID **and** serial. - `DEVOURER_CHANNEL=N` — monitor channel. -- `DEVOURER_TX_RATE=[/][/SGI][/LDPC][/STBC][/ER|/ER106][/DCM]` — TX +- `DEVOURER_TX_RATE=[/][/SGI][/LDPC][/STBC][/NOAGG][/ER|/ER106][/DCM]` — TX mode for rate-less frames (`MCS7/40/SGI`, `VHT2SS_MCS3/80/LDPC`, `1M`...). Unset = 6M legacy. CCK rates are 2.4 GHz-only; `1M` buys ~9 dB link budget over `6M`. Rate resolution never reads the band, so `VHT*` rates air on @@ -259,7 +259,11 @@ those are the ones listed below. extended-range PPDU + dual-carrier modulation (`docs/he-extended-range.md`). The library itself is radiotap-driven — a frame carrying its own rate radiotap overrides the mode per-packet (ER SU = radiotap-HE FORMAT=EXT_SU). - Programmatic: `SetTxMode` / `ClearTxMode`. + Programmatic: `SetTxMode` / `ClearTxMode`. `/NOAGG` keeps the frame out + of an A-MPDU (`TxMode::no_agg`, `AdapterCaps::tx_no_agg_ok`, + `docs/aggregation.md`). +- `DEVOURER_TX_ALT_RATE=` — txdemo: alternate frames at a second + rate (mixed-rate harness, `tests/tx_no_agg_onair.sh`). - `DEVOURER_SKIP_RESET=1` — skip `libusb_reset_device` before claim (only helps when firmware state is intact). Kestrel adapters skip the reset unconditionally — a USB reset on running firmware can land the chip in the diff --git a/CMakeLists.txt b/CMakeLists.txt index ec19b65f..ae7b302f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1231,6 +1231,17 @@ target_include_directories(RadiotapNoackSelftest PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src) add_test(NAME radiotap_noack COMMAND RadiotapNoackSelftest) +# Headless guard for TxMode::no_agg's wire form - the devourer-private +# TX_FLAGS bit that keeps a frame out of an A-MPDU (RadiotapTxFlags.h). Every +# builder layout, NOACK both ways, and the /NOAGG parse token. +add_executable(RadiotapNoaggSelftest + tests/radiotap_noagg_selftest.cpp +) +target_link_libraries(RadiotapNoaggSelftest PRIVATE devourer) +target_include_directories(RadiotapNoaggSelftest PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/src) +add_test(NAME radiotap_noagg COMMAND RadiotapNoaggSelftest) + # Headless round-trip guard for the 802.11ax Trigger frame path (src/TriggerTwt.h # build_basic_trigger + he_ru_alloc, src/TriggerParse.h parse_trigger): build -> # parse a Basic Trigger, RU-allocation golden vectors. Generation-neutral (the diff --git a/docs/aggregation.md b/docs/aggregation.md index d64041e5..aee2cdc6 100644 --- a/docs/aggregation.md +++ b/docs/aggregation.md @@ -178,6 +178,37 @@ airtime ground truth): accounting-grade only for un-aggregated frames; under A-MPDU, use the windowed RX receipts (`src/cell/RxReceipt.h`) as the delivery truth — the receiver-side ledger is unaffected by TX aggregation. +- **An aggregate airs at its first MPDU's rate, so a mixed-rate stream loses + its per-frame rates.** Consecutive co-queued data frames are folded into one + PPDU at the rate and bandwidth of the frame that opened it; a frame's own + MCS/BW/LDPC/STBC are dropped whenever it lands behind a frame of another + rate. `TxMode::no_agg` (`/NOAGG` in the rate grammar, a devourer-private + radiotap TX_FLAGS bit, `src/RadiotapTxFlags.h`) keeps one frame out: on + Jaguar3 it writes the descriptor `AGG_EN=0` + `BK=1`, the vendor + rtl8822eu recipe for data frames it does not aggregate. Honoured only where + `AdapterCaps::tx_no_agg_ok` is set (Jaguar3); elsewhere the bit is ignored. + Measured with `tests/tx_no_agg_onair.sh` (one 8812EU transmitting, an + 8812EU witness, ch36, `0/6`, 4 senders, 1000 B, MCS5 and MCS0 alternating + by frame): without the flag 40.3 % / 40.4 % of the MCS0 frames aired at + MCS0 (two arms; the rest at MCS5), with it 100.0 % / 100.0 %; the MCS5 + frames kept their rate in every arm. With the flag parsed but the + descriptor write disabled the flagged frames folded again (39.8 %). The + counterpart: a flagged frame breaks the aggregate around it, and flagging + every other frame — this harness's worst case — cut the witness's heard + rate from 2372 to 1250 frames/s (−47 %). The flag also reaches a + rate-less frame through the `SetTxMode` default + (`DEVOURER_TX_RATE=.../NOAGG`), the harness's `basenoagg` arm: 100.0 % of + both parities at their own rate (×2, 8812EU → 8812EU), while with the + default's `no_agg` not propagated the same arm folds like the control + (38.5 % / 40.1 % vs 39.1 %). The cost of occasional flagged frames + (control traffic inside a video stream) is not measured. The 8822C die, + measured on one 8812CU (an independent rig, same script unchanged, + an 8822BU external-antenna witness, same ch/`0/6`/4 senders/1000 B/MCS5 + + MCS0): the fold is deeper there — 23.8 % / 24.6 % of the MCS0 frames kept + their rate without the flag, 100.0 % / 100.0 % with it — and the + every-other-frame cost correspondingly larger, 2749–2913 frames/s heard + unflagged vs 1156–1159 flagged (about −60 %). One unit per die; the 8812EU + arm was not repeated on that rig. - `ppdu_cnt` reads 0 on the 8812CU RX used for the bench; `paggr` + `tsfl` clustering are the working RX markers. diff --git a/docs/logging.md b/docs/logging.md index 90accf87..ba6ae369 100644 --- a/docs/logging.md +++ b/docs/logging.md @@ -77,7 +77,7 @@ Emitters: L = library, RX/TX/... = demo. Optional fields in [brackets]; | ev | emitter | fields | |---|---|---| | `init.timing` | L (`src/InitTimer.h`) + demos | stage ("scope.stage", e.g. "demo.first_rx_frame", "txdemo.first_tx_submit"), ms, [xfers] (register transfers the stage spent on that adapter's transport, USB only — present on the Jaguar3 `j3hal.*` / `j3init.*` stages) | -| `adapter.caps` | RX, TX, doctor, txpower (`examples/common/caps_event.h`) | supported, chip, names, chip_id "0x..", gen, variant, transport, tx_chains, rx_chains, n_ss, stbc, ldpc, sgi, bw_max, bw[] (MHz), txpwr_max, txpwr_step_qdb, txpwr_step_measured, txpwr_min_qdb, txpwr_max_qdb, txpwr_rate_diffs, txpwr_rate_diffs_hw, txpwr_rate_diffs_measured, tune_2g4[]\|null, tune_5g[]\|null, char_2g4[]\|null, char_5g[]\|null, ldpc_rx_ht, ldpc_rx_vht, ldpc_rx_flag, vht_2g4, per_pkt_txpwr, per_pkt_txpwr_steps, per_pkt_txpwr_step_qdb, per_pkt_txpwr_min_qdb, per_pkt_txpwr_max_qdb, per_pkt_txpwr_measured, narrowband, fastretune, ack_responder, station_mode, tx_retry_limit, he_er_su, per_chain_rssi, hw_rx_tsf, hw_beacon_txtsf, tsf_write, xtal_cap_max, xtal_cap_default | +| `adapter.caps` | RX, TX, doctor, txpower (`examples/common/caps_event.h`) | supported, chip, names, chip_id "0x..", gen, variant, transport, tx_chains, rx_chains, n_ss, stbc, ldpc, sgi, bw_max, bw[] (MHz), txpwr_max, txpwr_step_qdb, txpwr_step_measured, txpwr_min_qdb, txpwr_max_qdb, txpwr_rate_diffs, txpwr_rate_diffs_hw, txpwr_rate_diffs_measured, tune_2g4[]\|null, tune_5g[]\|null, char_2g4[]\|null, char_5g[]\|null, ldpc_rx_ht, ldpc_rx_vht, ldpc_rx_flag, vht_2g4, per_pkt_txpwr, per_pkt_txpwr_steps, per_pkt_txpwr_step_qdb, per_pkt_txpwr_min_qdb, per_pkt_txpwr_max_qdb, per_pkt_txpwr_measured, narrowband, fastretune, ack_responder, station_mode, tx_retry_limit, tx_no_agg, he_er_su, per_chain_rssi, hw_rx_tsf, hw_beacon_txtsf, tsf_write, xtal_cap_max, xtal_cap_default | | `debug.wreg` | L (`DEVOURER_LOG_WRITES`) | addr "0x0nnn", width, val "0x…" | | `hop.prof` | L (`DEVOURER_HOP_PROF`) | gen, ch, `_us`…, total_us | | `tx.fail` | L (send failure; regress.py keys on it) | {status, actual_len, timeout} or {rc, timeout} | diff --git a/examples/common/caps_event.h b/examples/common/caps_event.h index 59625ec5..790dfc90 100644 --- a/examples/common/caps_event.h +++ b/examples/common/caps_event.h @@ -84,6 +84,7 @@ inline void emit_adapter_caps(EventSink &sink, IRadio *dev) { .f("ack_responder", c.ack_responder_ok ? 1 : 0) .f("station_mode", c.station_mode_ok ? 1 : 0) .f("tx_retry_limit", c.tx_retry_limit_ok ? 1 : 0) + .f("tx_no_agg", c.tx_no_agg_ok ? 1 : 0) .f("he_er_su", c.he_er_su_ok ? 1 : 0) .f("per_chain_rssi", c.per_chain_rssi ? 1 : 0) .f("hw_rx_tsf", c.hw_rx_timestamp ? 1 : 0) diff --git a/examples/tx/main.cpp b/examples/tx/main.cpp index ed688731..b8c3ad04 100644 --- a/examples/tx/main.cpp +++ b/examples/tx/main.cpp @@ -1378,6 +1378,36 @@ int main(int argc, char **argv) { if (tx_threads > 1) logger->info("DEVOURER_TX_THREADS — {} parallel senders", tx_threads); } + /* DEVOURER_TX_ALT_RATE= — every ODD-counter frame carries its own + * rate radiotap built from this spec (the DEVOURER_TX_RATE grammar, /NOAGG + * included); even frames keep the rate-less default. Needs + * DEVOURER_TX_QOS_DATA: the receiver tells the two apart by the stamped + * counter's parity (rx.seq pctr). The A-MPDU mixed-rate harness + * (tests/tx_no_agg_onair.sh). Replaces the frame each send, so it does not + * combine with the hop markers or DEVOURER_TX_STBC_TOGGLE. */ + std::vector tx_base_buf, tx_alt_buf; + if (const char *e = std::getenv("DEVOURER_TX_ALT_RATE")) { + if (!qos_stamp) { + logger->warn("DEVOURER_TX_ALT_RATE needs DEVOURER_TX_QOS_DATA — ignored"); + } else { + const size_t rl = tx_buf[2] | (tx_buf[3] << 8); + tx_base_buf = tx_buf; + /* NOACK like the base frame's rate-less radiotap. */ + tx_alt_buf = + devourer::build_stream_radiotap(devourer::parse_tx_mode_str(e)); + tx_alt_buf.insert(tx_alt_buf.end(), tx_buf.begin() + rl, tx_buf.end()); + logger->info("DEVOURER_TX_ALT_RATE={} — odd-counter frames", e); + } + } + /* Stamp the QoS per-frame counter at MPDU bytes 26..29 (after the frame's + * own radiotap), picking the ALT_RATE variant by parity first. */ + auto stamp_counter = [&](std::vector &b, uint32_t v) { + if (!tx_alt_buf.empty()) + b = (v & 1) ? tx_alt_buf : tx_base_buf; + const size_t rl = b.size() >= 4 ? (b[2] | (b[3] << 8)) : b.size(); + if (qos_stamp && b.size() >= rl + 26 + 4) + std::memcpy(b.data() + rl + 26, &v, 4); + }; std::atomic tx_counter{0}; /* shared frame-stamp source (threads>1) */ std::vector tx_aux; @@ -2336,10 +2366,7 @@ int main(int argc, char **argv) { /* QoS spike frames carry a per-frame counter at body[0..3] (MPDU bytes * 26..29) so the receiver can count UNIQUE frames vs hardware re-airings * (the A-MPDU engine renumbers seqs per aggregate, so seq can't). */ - if (qos_stamp && tx_buf.size() >= 10 + 26 + 4) { - uint32_t v = static_cast(tx_count); - std::memcpy(tx_buf.data() + 10 + 26, &v, 4); - } + stamp_counter(tx_buf, static_cast(tx_count)); /* Lazy-start the auxiliary senders on the first main-loop pass (the * chip is up and the first frame primed by then). */ if (tx_threads > 1 && tx_aux.empty()) { @@ -2354,10 +2381,7 @@ int main(int argc, char **argv) { tx_counter.fetch_add(static_cast(bufs.size())); for (size_t k = 0; k < bufs.size(); ++k) { auto &b = bufs[k]; - if (qos_stamp && b.size() >= 10 + 26 + 4) { - uint32_t v = static_cast(base + (long)k); - std::memcpy(b.data() + 10 + 26, &v, 4); - } + stamp_counter(b, static_cast(base + (long)k)); views.push_back(TxPacketView{b.data(), b.size()}); } rtlDevice->send_packets(views.data(), views.size()); @@ -2377,11 +2401,9 @@ int main(int argc, char **argv) { tx_batch_views.clear(); for (long k = 0; k < tx_batch; ++k) { auto &b = tx_batch_bufs[static_cast(k)]; - if (qos_stamp && b.size() >= 10 + 26 + 4) { - uint32_t v = static_cast( - tx_threads > 1 ? tx_counter.fetch_add(1) : tx_count + k); - std::memcpy(b.data() + 10 + 26, &v, 4); - } + stamp_counter(b, static_cast(tx_threads > 1 + ? tx_counter.fetch_add(1) + : tx_count + k)); tx_batch_views.push_back(TxPacketView{b.data(), b.size()}); } const size_t okn = rtlDevice->send_packets(tx_batch_views.data(), diff --git a/src/AdapterCaps.h b/src/AdapterCaps.h index 45cfd174..bbc82c7b 100644 --- a/src/AdapterCaps.h +++ b/src/AdapterCaps.h @@ -285,6 +285,15 @@ struct AdapterCaps { * FALSE on every other backend: not ported. */ bool station_mode_ok = false; + /* TxMode::no_agg is honoured: a frame carrying the radiotap TX_FLAGS + * kRadiotapTxFlagNoAgg bit (RadiotapTxFlags.h) airs as its own PPDU at its + * own rate and bandwidth even while SetAmpduMode is on. TRUE on Jaguar3, + * on-air-measured on one 8812EU and one 8812CU (tests/tx_no_agg_onair.sh; + * the 8822C folds deeper and pays more for the flag, docs/aggregation.md). + * False everywhere else: the bit is ignored and a flagged frame can still + * be folded into an aggregate at its neighbour's rate. */ + bool tx_no_agg_ok = false; + /* --- feature flags --- */ /* Per-packet TX power: a per-frame power trim driven by radiotap * DBM_TX_POWER (dB delta vs the calibrated table / session base) or a diff --git a/src/RadiotapBuilder.cpp b/src/RadiotapBuilder.cpp index d86b0289..a93e030c 100644 --- a/src/RadiotapBuilder.cpp +++ b/src/RadiotapBuilder.cpp @@ -1,6 +1,7 @@ #include "RadiotapBuilder.h" #include "ieee80211_radiotap.h" /* HE field masks */ +#include "RadiotapTxFlags.h" /* kRadiotapTxFlagNoAgg */ #include #include @@ -296,7 +297,8 @@ std::vector build_stream_radiotap(const TxMode& cfg) { } std::vector build_stream_radiotap(const TxMode& cfg, bool no_ack) { - const uint16_t tx_flags = no_ack ? kTxFlagsNoAck : 0; + const uint16_t tx_flags = static_cast( + (no_ack ? kTxFlagsNoAck : 0) | (cfg.no_agg ? kRadiotapTxFlagNoAgg : 0)); switch (cfg.mode) { case TxMode::Mode::HT: return build_ht(cfg, tx_flags); case TxMode::Mode::VHT: return build_vht(cfg, tx_flags); @@ -315,7 +317,7 @@ TxMode parse_tx_mode_str(const std::string& spec) { const std::string s = to_upper_stripped(spec.c_str()); /* Split on '/': first token = rate, rest = bandwidth (numeric) or modifier - * flags (SGI / LDPC / STBC). */ + * flags (SGI / LDPC / STBC / NOAGG). */ std::vector tokens; size_t start = 0; while (start <= s.size()) { @@ -340,6 +342,7 @@ TxMode parse_tx_mode_str(const std::string& spec) { if (t == "SGI") cfg.sgi = true; else if (t == "LDPC") cfg.ldpc = true; else if (t == "STBC") cfg.stbc = true; + else if (t == "NOAGG") cfg.no_agg = true; else if (t == "ER" || t == "ER106" || t == "DCM") { /* HE ER SU / DCM are 802.11ax-only modifiers (Kestrel). */ if (cfg.mode != TxMode::Mode::HE) { diff --git a/src/RadiotapBuilder.h b/src/RadiotapBuilder.h index de44c7f6..ae8d6956 100644 --- a/src/RadiotapBuilder.h +++ b/src/RadiotapBuilder.h @@ -36,17 +36,21 @@ std::vector build_stream_radiotap(const TxMode& mode); * Only the MT7612U reads this bit. The Realtek backends ignore radiotap * TX_FLAGS and keep their existing ACK behaviour whatever no_ack says: * Jaguar1 always marks the descriptor BMC; Jaguar2/3, Kestrel and the - * RTL8733B set BMC from addr1's group bit. */ + * RTL8733B set BMC from addr1's group bit. mode.no_agg rides the same field + * as a separate, devourer-private bit (RadiotapTxFlags.h), independent of + * no_ack. */ std::vector build_stream_radiotap(const TxMode& mode, bool no_ack); /* Parse a TX-mode spec string into a TxMode. Single slash-separated string: - * [/][/SGI][/LDPC][/STBC][/ER|/ER106][/DCM] (case-insensitive) + * [/][/SGI][/LDPC][/STBC][/NOAGG][/ER|/ER106][/DCM] + * (case-insensitive) * : 6M|9M|12M|18M|24M|36M|48M|54M | MCS0..MCS31 | * VHT1SS_MCS0..VHT4SS_MCS9 | HE1SS_MCS0..HE4SS_MCS11 * : 20|40|80|160 (default 20) * ER / ER106 / DCM (HE rates only, Kestrel): HE ER SU extended-range PPDU * (242-tone RU, MCS0-2 / 106-tone RU, MCS0) and dual-carrier modulation * (MCS 0/1/3/4; excludes STBC). Out-of-spec combos are clamped (W log). + * NOAGG : TxMode::no_agg - the frame never joins an A-MPDU. * Empty or unrecognised falls back to 6M legacy. * * The rate is resolved without reference to the band, so a VHT rate on a diff --git a/src/RadiotapTxFlags.h b/src/RadiotapTxFlags.h index 2e570e15..9dbf8900 100644 --- a/src/RadiotapTxFlags.h +++ b/src/RadiotapTxFlags.h @@ -14,6 +14,20 @@ extern "C" { namespace devourer { +/* Devourer-private TX_FLAGS bit: "never aggregate this frame" (TxMode::no_agg). + * Under an A-MPDU session the MAC folds consecutive co-queued frames into one + * PPDU aired at its FIRST MPDU's rate and bandwidth, so a frame's own + * MCS/BW/LDPC/STBC are silently replaced (docs/aggregation.md has the + * measurement). A backend that honours the bit (AdapterCaps::tx_no_agg_ok) + * airs the frame as its own PPDU. radiotap.org assigns TX_FLAGS 0x0001-0x0020; + * this bit sits above them and is not a registered assignment, so a future + * radiotap definition of 0x0100 would collide with it. */ +constexpr uint16_t kRadiotapTxFlagNoAgg = 0x0100; + +inline bool radiotap_tx_no_agg(uint16_t tx_flags) { + return (tx_flags & kRadiotapTxFlagNoAgg) != 0; +} + struct RadiotapMcsField { bool have_mcs = false; uint8_t mcs = 0; /* HT MCS index 0..31, valid when have_mcs */ diff --git a/src/TxMode.h b/src/TxMode.h index 43c67bca..69bc4252 100644 --- a/src/TxMode.h +++ b/src/TxMode.h @@ -56,6 +56,15 @@ struct TxMode { bool sgi = false; bool ldpc = false; bool stbc = false; + + /* Never aggregate this frame, even inside an A-MPDU session: it airs as its + * own PPDU at its own rate and bandwidth. Carried per frame in radiotap + * TX_FLAGS (kRadiotapTxFlagNoAgg, RadiotapTxFlags.h); honoured where + * AdapterCaps::tx_no_agg_ok is set, ignored elsewhere. false keeps the + * radiotap byte-identical. As the SetTxMode default it applies to rate-less + * frames only, like the rate fields: a frame with its own rate radiotap + * takes no_agg from its own TX_FLAGS. */ + bool no_agg = false; }; /* The descriptor inputs send_packet writes, derived from a TxMode. `fixed_rate` diff --git a/src/jaguar3/FrameParserJaguar3.h b/src/jaguar3/FrameParserJaguar3.h index 1517c58a..ee717418 100644 --- a/src/jaguar3/FrameParserJaguar3.h +++ b/src/jaguar3/FrameParserJaguar3.h @@ -76,6 +76,12 @@ constexpr size_t RXDESC_SIZE_8822C = 24; /* RX_DESC_SIZE_88XX */ * to aggregate co-queued same-RA/TID frames into an A-MPDU (spike knobs * DEVOURER_TX_AMPDU / DEVOURER_TX_QSEL; see DeviceConfig debug section). */ #define SET_TX_DESC_AGG_EN_8822C(d, v) SET_BITS_TO_LE_4BYTE((d) + 0x08, 12, 1, v) +/* BK (halmac SET_TX_DESC_BK, dword2[16]): break - the MAC does not merge this + * frame into an A-MPDU with its queue neighbours. The vendor rtl8822eu xmit + * path writes AGG_EN=0 + BK=1 for every data frame it does not aggregate + * (EAPOL/ARP/DHCP included); devourer does the same for a TxMode::no_agg + * frame (RadiotapTxFlags.h). */ +#define SET_TX_DESC_BK_8822C(d, v) SET_BITS_TO_LE_4BYTE((d) + 0x08, 16, 1, v) #define SET_TX_DESC_MAX_AGG_NUM_8822C(d, v) SET_BITS_TO_LE_4BYTE((d) + 0x0C, 17, 5, v) #define SET_TX_DESC_AMPDU_DENSITY_8822C(d, v) SET_BITS_TO_LE_4BYTE((d) + 0x08, 20, 3, v) #define SET_TX_DESC_RTY_LMT_EN_8822C(d, v) SET_BITS_TO_LE_4BYTE((d) + 0x10, 17, 1, v) diff --git a/src/jaguar3/RtlJaguar3Device.cpp b/src/jaguar3/RtlJaguar3Device.cpp index f70a8476..af7c9393 100644 --- a/src/jaguar3/RtlJaguar3Device.cpp +++ b/src/jaguar3/RtlJaguar3Device.cpp @@ -1827,6 +1827,8 @@ devourer::AdapterCaps RtlJaguar3Device::GetAdapterCaps() { * measured — responder matrix + retry-knob A/B + the arq_e2e ledgers. */ c.ack_responder_ok = true; c.tx_retry_limit_ok = true; + /* TxMode::no_agg: AGG_EN=0 + BK=1 in build_tx_block. */ + c.tx_no_agg_ok = true; /* Per-packet TX power: the TXPWR_OFSET_TYPE bank selector + programmable * 0x1e70 offset banks (SetTxPacketPowerOffsetQdb / radiotap DBM_TX_POWER; * TxPktPwrBanks.h). Continuous in step_qdb units, ±63/-64 index travel, 2 @@ -2486,6 +2488,8 @@ size_t RtlJaguar3Device::build_tx_block(const uint8_t *packet, size_t length, * calibrated table, the Jaguar2 convention). INT_MIN = not present -> the * SetTxPacketPowerOffsetQdb session default applies. */ int radiotap_pkt_pwr_db = INT_MIN; + /* Radiotap TX_FLAGS devourer-private no-aggregation bit (TxMode::no_agg). */ + bool no_agg = false; auto *rtap_hdr = reinterpret_cast( const_cast(packet)); @@ -2508,6 +2512,9 @@ size_t RtlJaguar3Device::build_tx_block(const uint8_t *packet, size_t length, radiotap_channel = devourer::freq_to_chan(get_unaligned_le16(it.this_arg)); break; + case IEEE80211_RADIOTAP_TX_FLAGS: + no_agg = devourer::radiotap_tx_no_agg(get_unaligned_le16(it.this_arg)); + break; case IEEE80211_RADIOTAP_DBM_TX_POWER: /* Signed dB delta for THIS frame, resolved to a power-offset bank * below (txpkt_type_for_idx). Mirrors the Jaguar2 per-packet path. */ @@ -2584,6 +2591,7 @@ size_t RtlJaguar3Device::build_tx_block(const uint8_t *packet, size_t length, ldpc = tp.ldpc ? 1 : 0; stbc = tp.stbc ? 1 : 0; bwidth = static_cast(tp.bwidth); + no_agg = no_agg || _tx_mode_default->no_agg; } /* DEVOURER_TX_NDPA=N — beamforming-sounding probe: mark injected frames as @@ -2725,6 +2733,13 @@ size_t RtlJaguar3Device::build_tx_block(const uint8_t *packet, size_t length, if (_cfg.debug.tx_ampdu_rty) SET_TX_DESC_RTS_DATA_RTY_LMT_8822C(out, *_cfg.debug.tx_ampdu_rty); } + /* A no_agg frame airs alone, after every override above: the MAC would + * otherwise fold it into its queue neighbour's PPDU at THAT frame's + * rate/bw (RadiotapTxFlags.h). Same queue, so ordering is unchanged. */ + if (no_agg) { + SET_TX_DESC_AGG_EN_8822C(out, 0); + SET_TX_DESC_BK_8822C(out, 1); + } jaguar3::cal_txdesc_chksum_8822c(out); } const size_t frame_off = diff --git a/tests/radiotap_noagg_selftest.cpp b/tests/radiotap_noagg_selftest.cpp new file mode 100644 index 00000000..1e5068cb --- /dev/null +++ b/tests/radiotap_noagg_selftest.cpp @@ -0,0 +1,98 @@ +/* Headless guard for TxMode::no_agg's wire form (RadiotapTxFlags.h): the + * devourer-private TX_FLAGS bit a backend with AdapterCaps::tx_no_agg_ok reads + * to keep a frame out of an A-MPDU. + * + * For every layout the builder emits (legacy, HT, VHT, HE), with NOACK both + * ways, walk the header with the shared radiotap iterator and check + * - a default TxMode carries no NOAGG bit (existing streams byte-identical), + * - "/NOAGG" parses to no_agg, and no_agg sets exactly kRadiotapTxFlagNoAgg, + * leaving the length (the Jaguar3 HT/VHT length contract) and every other + * bit alone, + * - radiotap_tx_no_agg() reads the bit back, and NOACK stays independent. + */ +#include +#include +#include +#include + +#include "RadiotapBuilder.h" +#include "RadiotapTxFlags.h" +#include "ieee80211_radiotap.h" + +static int g_fail = 0; +static void expect(const char *what, bool ok) { + std::printf("%s %s\n", ok ? "ok " : "FAIL", what); + if (!ok) + ++g_fail; +} + +/* The TX_FLAGS value, or -1 if the field is absent. */ +static int tx_flags(const std::vector &rt) { + auto *hdr = reinterpret_cast( + const_cast(rt.data())); + struct ieee80211_radiotap_iterator it; + if (ieee80211_radiotap_iterator_init(&it, hdr, (int)rt.size(), nullptr) != 0) + return -1; + while (ieee80211_radiotap_iterator_next(&it) == 0) + if (it.this_arg_index == IEEE80211_RADIOTAP_TX_FLAGS) + return it.this_arg[0] | (it.this_arg[1] << 8); + return -1; +} + +/* Headers differ only in the NOAGG bit (one byte, that bit). */ +static bool only_noagg_differs(const std::vector &a, + const std::vector &b) { + if (a.size() != b.size()) + return false; + int diff = 0; + for (size_t i = 0; i < a.size(); ++i) + if (a[i] != b[i]) { + ++diff; + if ((a[i] ^ b[i]) != (devourer::kRadiotapTxFlagNoAgg >> 8)) + return false; + } + return diff == 1; +} + +int main() { + using devourer::build_stream_radiotap; + using devourer::parse_tx_mode_str; + const char *specs[] = {"6M", "MCS7", "MCS0/40/SGI", "VHT1SS_MCS3/80/LDPC", + "HE1SS_MCS7/80"}; + char what[160]; + for (const char *spec : specs) { + const devourer::TxMode plain = parse_tx_mode_str(spec); + const devourer::TxMode flagged = + parse_tx_mode_str(std::string(spec) + "/NOAGG"); + std::snprintf(what, sizeof what, "%s: default TxMode has no_agg off", spec); + expect(what, !plain.no_agg); + std::snprintf(what, sizeof what, "%s/NOAGG parses to no_agg", spec); + expect(what, flagged.no_agg); + for (const bool no_ack : {true, false}) { + const auto p = build_stream_radiotap(plain, no_ack); + const auto f = build_stream_radiotap(flagged, no_ack); + const int fp = tx_flags(p), ff = tx_flags(f); + std::snprintf(what, sizeof what, "%s no_ack=%d: default header has no NOAGG bit", + spec, no_ack); + expect(what, fp >= 0 && !devourer::radiotap_tx_no_agg((uint16_t)fp)); + std::snprintf(what, sizeof what, "%s no_ack=%d: no_agg sets the NOAGG bit", + spec, no_ack); + expect(what, ff >= 0 && devourer::radiotap_tx_no_agg((uint16_t)ff)); + std::snprintf(what, sizeof what, "%s no_ack=%d: NOACK unchanged by no_agg", + spec, no_ack); + expect(what, ff >= 0 && fp >= 0 && + (ff & IEEE80211_RADIOTAP_F_TX_NOACK) == + (fp & IEEE80211_RADIOTAP_F_TX_NOACK)); + std::snprintf(what, sizeof what, + "%s no_ack=%d: no_agg changes only that bit (same length)", + spec, no_ack); + expect(what, only_noagg_differs(p, f)); + } + } + if (g_fail) { + std::printf("%d check(s) failed\n", g_fail); + return 1; + } + std::printf("all NOAGG checks passed\n"); + return 0; +} diff --git a/tests/tx_no_agg_onair.sh b/tests/tx_no_agg_onair.sh new file mode 100755 index 00000000..260264a4 --- /dev/null +++ b/tests/tx_no_agg_onair.sh @@ -0,0 +1,184 @@ +#!/usr/bin/env bash +# tx_no_agg_onair.sh — does TxMode::no_agg keep a frame out of an A-MPDU, so +# it airs at its OWN rate? (AdapterCaps::tx_no_agg_ok, RadiotapTxFlags.h) +# +# Under SetAmpduMode the MAC folds consecutive co-queued frames into one PPDU +# aired at the FIRST MPDU's rate, so a frame's own rate is silently replaced +# whenever it lands behind a frame of another rate. The DUT floods QoS-Data +# with an A-MPDU session on, alternating two HT rates by frame counter: +# +# even counter BASE rate (DEVOURER_TX_RATE), aggregatable +# odd counter ALT rate (DEVOURER_TX_ALT_RATE), with /NOAGG in the +# "noagg" arm and without it in the "control" arm +# +# The "basenoagg" arm moves the flag to the BASE side: the even frames are +# rate-less, so /NOAGG reaches them only through the SetTxMode default, not +# their own radiotap. Dropping that default leaves the arm a copy of the +# control (odd frames fold), so it guards the rate-less path. +# +# A passive witness decodes each copy's rate (rx.seq: pctr + hw rate index), +# so every received frame is scored against the rate it was submitted with. +# +# The CONTROL arm is what makes the flagged result mean anything: if the +# control does not show odd frames folding to the BASE rate, no mixed +# aggregates formed (feed too shallow, session off, wrong queue) and a clean +# flagged arm proves nothing. That is an ABORT, not a pass. +# +# Witness: local by default (WIT_VID/WIT_PID). WIT_SSH=user@host runs a +# prebuilt rxdemo (WIT_BIN) on another machine instead — the adapter there +# must be free of any other owner. Ambient traffic is excluded by a +# run-unique SA. +# +# sudo bash tests/tx_no_agg_onair.sh +# CH=36 BASE=MCS5 ALT=MCS0 SECS=15 sudo bash tests/tx_no_agg_onair.sh +# WIT_SSH=root@10.18.0.1 WIT_BIN=/tmp/rxdemo sudo -E bash tests/tx_no_agg_onair.sh +set -u +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +BUILD=${BUILD:-$ROOT/build} + +DUT_VID=${DUT_VID:-0x0bda}; DUT_PID=${DUT_PID:-0xa81a} # RTL8812EU +WIT_VID=${WIT_VID:-0x0bda}; WIT_PID=${WIT_PID:-0xa81a} +WIT_SSH=${WIT_SSH:-}; WIT_BIN=${WIT_BIN:-$BUILD/rxdemo} +CH=${CH:-36} +BASE=${BASE:-MCS5}; ALT=${ALT:-MCS0} +ARMS=${ARMS:-"noagg control basenoagg noagg control basenoagg"} +SECS=${SECS:-12} +AMPDU=${AMPDU:-0/6} # DEVOURER_TX_AMPDU_MODE: TID0, 6 MPDUs +THREADS=${THREADS:-4} # deep feed: aggregation needs co-queued frames +PAYLOAD=${PAYLOAD:-1000} +if [ -z "${TX_SA:-}" ]; then + run_id=$$ + printf -v TX_SA '02:6e:61:%02x:%02x:%02x' \ + $(((run_id >> 16) & 255)) $(((run_id >> 8) & 255)) $((run_id & 255)) +fi +OUT=${OUT:-/tmp/tx_no_agg} +SUDO=${SUDO-sudo} # SUDO= when the adapters' USB nodes are user-writable + +# HT rates only: the witness reports the raw descriptor rate, MCSn = 0x0c + n. +rate_code() { + if [[ "$1" =~ ^MCS([0-9]|[12][0-9]|3[01])$ ]]; then + echo $((12 + BASH_REMATCH[1])) + else + echo "ABORT: '$1' is not an HT MCS0..MCS31 rate" >&2; exit 2 + fi +} +BASE_CODE=$(rate_code "$BASE") || exit 2 +ALT_CODE=$(rate_code "$ALT") || exit 2 +[ "$BASE_CODE" -ne "$ALT_CODE" ] || { echo "ABORT: BASE and ALT must differ" >&2; exit 2; } +case " $ARMS " in *" noagg "*|*" basenoagg "*) ;; *) echo "ABORT: ARMS needs a noagg or basenoagg arm" >&2; exit 2;; esac +case " $ARMS " in *" control "*) ;; *) echo "ABORT: ARMS needs a control arm" >&2; exit 2;; esac + +# Kill only this tree's demos (ERE-escaped build prefix), and the remote +# witness if there is one. +ESC_BUILD=$(printf '%s' "$BUILD" | sed 's#[][\\.^$*+?(){}|/]#\\&#g') +KILL() { + $SUDO pkill -9 -f "^$ESC_BUILD/rxdemo" 2>/dev/null + $SUDO pkill -9 -f "^$ESC_BUILD/txdemo" 2>/dev/null + [ -n "$WIT_SSH" ] && ssh "$WIT_SSH" \ + "pkill -9 -f '^$WIT_BIN' || killall -9 $(basename "$WIT_BIN")" 2>/dev/null + return 0 +} +trap KILL EXIT +mkdir -p "$OUT"; RESULTS="$OUT/results.jsonl"; : >"$RESULTS" + +WIT_ENV="DEVOURER_CHANNEL=$CH DEVOURER_RX_PCTR=1 DEVOURER_RX_AGG_SA=$TX_SA DEVOURER_LOG_LEVEL=info" +idx=0 +for arm in $ARMS; do + idx=$((idx+1)) + tag="$(printf '%02d_%s' "$idx" "$arm")" + case "$arm" in + noagg) base_spec="$BASE"; alt_spec="$ALT/NOAGG" ;; + control) base_spec="$BASE"; alt_spec="$ALT" ;; + basenoagg) base_spec="$BASE/NOAGG"; alt_spec="$ALT" ;; + *) echo "ABORT: unknown arm '$arm' (noagg|control|basenoagg)" >&2; exit 2 ;; + esac + KILL; sleep 3 # USB release after a -9 is not instantaneous + # shellcheck disable=SC2024 + if [ -n "$WIT_SSH" ]; then + ssh "$WIT_SSH" "env DEVOURER_VID=$WIT_VID DEVOURER_PID=$WIT_PID $WIT_ENV $WIT_BIN" \ + >"$OUT/wit_$tag.jsonl" 2>"$OUT/wit_$tag.err" & + else + $SUDO env DEVOURER_VID="$WIT_VID" DEVOURER_PID="$WIT_PID" $WIT_ENV \ + "$WIT_BIN" >"$OUT/wit_$tag.jsonl" 2>"$OUT/wit_$tag.err" & + fi + waited=0 + until grep -qE "async ring of .* URBs submitted|Listening air" "$OUT/wit_$tag.err"; do + sleep 1; waited=$((waited+1)) + if [ "$waited" -ge 30 ]; then + echo "ABORT: witness never reached RX for arm $arm (#$idx)" >&2 + tail -5 "$OUT/wit_$tag.err" >&2; exit 1 + fi + done + sleep 2 + # shellcheck disable=SC2024 + $SUDO env DEVOURER_VID="$DUT_VID" DEVOURER_PID="$DUT_PID" \ + DEVOURER_CHANNEL="$CH" DEVOURER_TX_QOS_DATA=1 DEVOURER_TX_QOS_NOACK=1 \ + DEVOURER_TX_SA="$TX_SA" DEVOURER_TX_RATE="$base_spec" \ + DEVOURER_TX_ALT_RATE="$alt_spec" DEVOURER_TX_AMPDU_MODE="$AMPDU" \ + DEVOURER_TX_THREADS="$THREADS" DEVOURER_TX_GAP_US=0 \ + DEVOURER_TX_PAYLOAD_BYTES="$PAYLOAD" DEVOURER_LOG_LEVEL=warn \ + timeout -s INT "$((SECS + 8))" "$BUILD/txdemo" \ + >"$OUT/tx_$tag.jsonl" 2>"$OUT/tx_$tag.err" || true + sleep 2 + KILL; sleep 1 + sent=$(grep '"ev":"tx.stats"' "$OUT/tx_$tag.jsonl" | tail -1 | + sed -n 's/.*"submitted":\([0-9]*\).*/\1/p'); sent=${sent:-0} + python3 - "$OUT/wit_$tag.jsonl" "$arm" "$idx" "$sent" "$BASE_CODE" "$ALT_CODE" \ + >>"$RESULTS" <<'PY' || exit 1 +import json, sys +path, arm, idx, sent = sys.argv[1], sys.argv[2], int(sys.argv[3]), int(sys.argv[4]) +base, alt = int(sys.argv[5]), int(sys.argv[6]) +n = {0: 0, 1: 0}; own = {0: 0, 1: 0}; t0 = t1 = None +for line in open(path, errors="replace"): + if not line.startswith('{"ev":"rx.seq"'): + continue + try: + e = json.loads(line) + except json.JSONDecodeError: + continue + if e.get("crc"): + continue + par = e["pctr"] & 1 + n[par] += 1 + own[par] += e["rate"] == (alt if par else base) + t = e.get("t") + if t is not None: + t0 = t if t0 is None else t0 + t1 = t +# A cell that did not run is not a measurement: no submissions or no odd +# (ALT) frames heard is a harness failure, never a 0 % or 100 % result. +if sent == 0 or n[0] < 500 or n[1] < 500: + sys.stderr.write(f"ABORT: arm {arm} (#{idx}) did not run - submitted={sent} " + f"heard even={n[0]} odd={n[1]}\n") + sys.exit(1) +secs = (t1 - t0) / 1000.0 if t0 is not None and t1 > t0 else 0.0 +print(json.dumps({"ev": "noagg.arm", "arm": arm, "idx": idx, "submitted": sent, + "odd": n[1], "even": n[0], + "odd_own_pct": round(100.0 * own[1] / n[1], 1), + "even_own_pct": round(100.0 * own[0] / n[0], 1), + "heard_fps": round((n[0] + n[1]) / secs) if secs else None})) +PY + tail -1 "$RESULTS" +done + +echo "==== VERDICT ====" +python3 - "$RESULTS" <<'PY' +import json, sys +rows = [json.loads(l) for l in open(sys.argv[1])] +ctl = [r for r in rows if r["arm"] == "control"] +flg = [r for r in rows if r["arm"] in ("noagg", "basenoagg")] +for r in rows: + print(f" #{r['idx']} {r['arm']:<8} odd@own {r['odd_own_pct']:>5}% " + f"even@own {r['even_own_pct']:>5}% heard {r['heard_fps']} fps") +# The control must show the fold, or nothing aggregated and the flagged arm +# is not a test of no_agg. +folded = all(r["odd_own_pct"] <= 80.0 for r in ctl) +if not folded: + print("ABORT: control arm shows no fold (odd frames kept their own rate " + "without the flag) - no mixed A-MPDUs formed; not a measurement") + sys.exit(2) +ok = all(r["odd_own_pct"] >= 99.0 and r["even_own_pct"] >= 99.0 for r in flg) +print(json.dumps({"ev": "noagg.verdict", "tx_no_agg_ok": bool(ok), + "arms": len(rows)})) +sys.exit(0 if ok else 1) +PY