From 06eabdf2f074b96556f99398155de429f566e7c3 Mon Sep 17 00:00:00 2001 From: Gilang Date: Mon, 28 Sep 2026 22:43:07 +0700 Subject: [PATCH 1/2] tx: TxMode::no_agg keeps a frame out of an A-MPDU (Jaguar3) Under SetAmpduMode the MAC folds consecutive co-queued data frames into one PPDU aired at the FIRST MPDU's rate and bandwidth, so a frame's own MCS/BW/LDPC/STBC are silently replaced whenever it lands behind a frame of another rate. A caller mixing rates in one aggregated stream (a robust control frame among video, a probe at another rate) cannot rely on the rate it asked for. TxMode::no_agg (rate grammar token /NOAGG) rides a devourer-private radiotap TX_FLAGS bit, kRadiotapTxFlagNoAgg = 0x0100 (RadiotapTxFlags.h; radiotap assigns only 0x0001-0x0020, so a future registration of 0x0100 would collide). The HT radiotap stays 13 bytes, and a default TxMode is byte-identical. Jaguar3 reads the bit in build_tx_block and writes the descriptor AGG_EN=0 + BK=1 (dword2[16]) after the A-MPDU overrides - the vendor rtl8822eu xmit recipe for data frames it does not aggregate. Same queue, so ordering is unchanged. AdapterCaps::tx_no_agg_ok advertises it (adapter.caps "tx_no_agg"): true on Jaguar3 only; Jaguar1/2, Kestrel, RTL8733B and the MT7612U ignore the bit. On air, tests/tx_no_agg_onair.sh (one 8812EU transmitting, an 8812EU witness, ch36, A-MPDU 0/6, 4 senders, 1000 B, MCS5 and MCS0 alternating by frame counter via the new txdemo DEVOURER_TX_ALT_RATE): arm MCS0 frames at MCS0 MCS5 at MCS5 heard fps no flag (x2) 40.3 % / 40.4 % 100 % 2372 /NOAGG (x2) 100.0 % / 100.0 % 100 % 1250 /NOAGG, descriptor write 39.8 % 100 % 2391 disabled The counterpart: a flagged frame breaks the aggregate around it, and flagging every other frame (this harness's worst case) cut the heard rate by 47 %. The cost of occasional flagged frames is not measured, nor is the 8822C, which shares the descriptor recipe. The script aborts rather than passes when the unflagged control shows no fold (no mixed aggregates formed, so nothing was tested). Headless: radiotap_noagg covers every builder layout with NOACK both ways and the /NOAGG parse; removing the builder OR or the parse token fails it. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01C8fMvzDQNYJSePWPBmUP8f --- CLAUDE.md | 10 +- CMakeLists.txt | 11 ++ docs/aggregation.md | 20 ++++ docs/logging.md | 2 +- examples/common/caps_event.h | 1 + examples/tx/main.cpp | 49 +++++--- src/AdapterCaps.h | 9 ++ src/RadiotapBuilder.cpp | 7 +- src/RadiotapBuilder.h | 8 +- src/RadiotapTxFlags.h | 14 +++ src/TxMode.h | 7 ++ src/jaguar3/FrameParserJaguar3.h | 6 + src/jaguar3/RtlJaguar3Device.cpp | 14 +++ tests/radiotap_noagg_selftest.cpp | 98 ++++++++++++++++ tests/tx_no_agg_onair.sh | 178 ++++++++++++++++++++++++++++++ 15 files changed, 414 insertions(+), 20 deletions(-) create mode 100644 tests/radiotap_noagg_selftest.cpp create mode 100755 tests/tx_no_agg_onair.sh diff --git a/CLAUDE.md b/CLAUDE.md index a6ebed51..7be9502c 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,13 @@ 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 (with `DEVOURER_TX_QOS_DATA`): + odd-counter frames carry their own rate radiotap from this spec, even ones + keep the default; the witness splits them by `rx.seq` pctr parity + (`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..f6f0bb23 100644 --- a/docs/aggregation.md +++ b/docs/aggregation.md @@ -178,6 +178,26 @@ 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 cost of occasional flagged + frames (control traffic inside a video stream) is not measured; the 8822C + shares the descriptor recipe and was not measured. - `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..badde619 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,10 @@ 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); - } + if (qos_stamp) + 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..d7e92340 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 (tests/tx_no_agg_onair.sh); the 8822C + * shares the descriptor recipe and has not been measured. 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..32dcdf2c 100644 --- a/src/TxMode.h +++ b/src/TxMode.h @@ -56,6 +56,13 @@ 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. */ + 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..dc985803 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. */ @@ -2725,6 +2732,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..4899e02b --- /dev/null +++ b/tests/tx_no_agg_onair.sh @@ -0,0 +1,178 @@ +#!/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 +# +# 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 noagg control"} +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 "*) ;; *) echo "ABORT: ARMS needs a noagg 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) alt_spec="$ALT/NOAGG" ;; + control) alt_spec="$ALT" ;; + *) echo "ABORT: unknown arm '$arm' (noagg|control)" >&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" \ + 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"] == "noagg"] +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 From 7b5f7599e06a63d6152d774bd00ce99ee2f16e28 Mon Sep 17 00:00:00 2001 From: Gilang Date: Wed, 30 Sep 2026 22:52:28 +0700 Subject: [PATCH 2/2] tx: #459 review - honour a SetTxMode /NOAGG on rate-less frames build_tx_block's rate-less fallback copied every TxMode field from the SetTxMode default except no_agg, so DEVOURER_TX_RATE=.../NOAGG was inert for frames without their own rate radiotap. Propagate it; a frame with its own rate still takes no_agg from its own TX_FLAGS. tests/tx_no_agg_onair.sh gains a basenoagg arm that flags the rate-less base frames, the path the harness never exercised. On air (8812EU -> 8812EU, ch36, 0/6, 4 senders, 1000 B, MCS5/MCS0): basenoagg 100.0 % of both parities at their own rate (x2) with the fix; with the propagation line removed the same arm folds like the control (38.5 / 40.1 % vs 39.1 %). Also: the 8822C row reads as measured (one 8812CU, reviewer's rig) in AdapterCaps and docs/aggregation.md, the CLAUDE.md DEVOURER_TX_ALT_RATE entry is cut to one line, and txdemo's batch path calls stamp_counter unconditionally like the single-frame path. Co-Authored-By: Claude Opus 5.5 (1M context) --- CLAUDE.md | 6 ++---- docs/aggregation.md | 17 ++++++++++++++--- examples/tx/main.cpp | 7 +++---- src/AdapterCaps.h | 8 ++++---- src/TxMode.h | 4 +++- src/jaguar3/RtlJaguar3Device.cpp | 1 + tests/tx_no_agg_onair.sh | 20 +++++++++++++------- 7 files changed, 40 insertions(+), 23 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 7be9502c..f3f42f8c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -262,10 +262,8 @@ those are the ones listed below. 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 (with `DEVOURER_TX_QOS_DATA`): - odd-counter frames carry their own rate radiotap from this spec, even ones - keep the default; the witness splits them by `rx.seq` pctr parity - (`tests/tx_no_agg_onair.sh`). +- `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/docs/aggregation.md b/docs/aggregation.md index f6f0bb23..aee2cdc6 100644 --- a/docs/aggregation.md +++ b/docs/aggregation.md @@ -195,9 +195,20 @@ airtime ground truth): 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 cost of occasional flagged - frames (control traffic inside a video stream) is not measured; the 8822C - shares the descriptor recipe and was not measured. + 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/examples/tx/main.cpp b/examples/tx/main.cpp index badde619..b8c3ad04 100644 --- a/examples/tx/main.cpp +++ b/examples/tx/main.cpp @@ -2401,10 +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) - stamp_counter(b, static_cast( - tx_threads > 1 ? tx_counter.fetch_add(1) - : tx_count + k)); + 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 d7e92340..bbc82c7b 100644 --- a/src/AdapterCaps.h +++ b/src/AdapterCaps.h @@ -288,10 +288,10 @@ struct AdapterCaps { /* 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 (tests/tx_no_agg_onair.sh); the 8822C - * shares the descriptor recipe and has not been measured. False - * everywhere else: the bit is ignored and a flagged frame can still be - * folded into an aggregate at its neighbour's rate. */ + * 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 --- */ diff --git a/src/TxMode.h b/src/TxMode.h index 32dcdf2c..69bc4252 100644 --- a/src/TxMode.h +++ b/src/TxMode.h @@ -61,7 +61,9 @@ struct TxMode { * 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. */ + * 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; }; diff --git a/src/jaguar3/RtlJaguar3Device.cpp b/src/jaguar3/RtlJaguar3Device.cpp index dc985803..af7c9393 100644 --- a/src/jaguar3/RtlJaguar3Device.cpp +++ b/src/jaguar3/RtlJaguar3Device.cpp @@ -2591,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 diff --git a/tests/tx_no_agg_onair.sh b/tests/tx_no_agg_onair.sh index 4899e02b..260264a4 100755 --- a/tests/tx_no_agg_onair.sh +++ b/tests/tx_no_agg_onair.sh @@ -11,6 +11,11 @@ # 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. # @@ -36,7 +41,7 @@ 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 noagg control"} +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 @@ -60,7 +65,7 @@ rate_code() { 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 "*) ;; *) echo "ABORT: ARMS needs a noagg arm" >&2; exit 2;; esac +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 @@ -82,9 +87,10 @@ for arm in $ARMS; do idx=$((idx+1)) tag="$(printf '%02d_%s' "$idx" "$arm")" case "$arm" in - noagg) alt_spec="$ALT/NOAGG" ;; - control) alt_spec="$ALT" ;; - *) echo "ABORT: unknown arm '$arm' (noagg|control)" >&2; exit 2 ;; + 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 @@ -107,7 +113,7 @@ for arm in $ARMS; do # 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" \ + 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 \ @@ -160,7 +166,7 @@ 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"] == "noagg"] +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")