From 7abd756c60a59b9815e63658d6b0abec32967a86 Mon Sep 17 00:00:00 2001 From: Ihor Ivlev Date: Sat, 1 Aug 2026 17:01:11 +0200 Subject: [PATCH] Jaguar1/2/3: unified tx_retry knob (DEVOURER_TX_RETRY_LIMIT, default 0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All three Jaguar generations hardcoded a per-frame TX retry limit of 12 (jaguar1: SET_TX_DESC_DATA_RETRY_LIMIT, jaguar2/3: RTS_DATA_RTY_LMT in the fill_data_tx_desc builders). On a busy half-duplex link the chip retried each frame up to 12x on collision, flooding the air and blinding the receiver — consecutive video fragments lost, downlink FEC cannot repair. Now every chip honors cfg.tx.retry_limit (env DEVOURER_TX_RETRY_LIMIT), applied per frame with tx_desc re-checksum; the A-MPDU path uses it too. Default 0 restores the pre-regression behaviour (verified: 0 losses with alink enabled on 8812AU). Review follow-up: the ACK/ARQ capability matrix keeps testing the hardware-ARQ loop by pinning the descriptor limit via DEVOURER_TX_RETRY_LIMIT=12 (ack_txreport_analyze.py takes the expected pin as --expect-retries); docs name the knob and note it is inert on Kestrel and the 8814A die, and that SetAckResponder ARQ needs a nonzero value. --- docs/scheduled-mac.md | 15 ++++++++++++--- examples/common/env_config.cpp | 2 ++ src/DeviceConfig.h | 9 +++++++++ src/jaguar1/RtlJaguarDevice.cpp | 8 +++++--- src/jaguar2/RtlJaguar2Device.cpp | 8 +++++++- src/jaguar3/RtlJaguar3Device.cpp | 8 +++++++- tests/ack_txreport_analyze.py | 25 +++++++++++++++++++------ tests/ack_txreport_matrix.sh | 9 +++++++-- 8 files changed, 68 insertions(+), 16 deletions(-) diff --git a/docs/scheduled-mac.md b/docs/scheduled-mac.md index 73b4e727..6e47f158 100644 --- a/docs/scheduled-mac.md +++ b/docs/scheduled-mac.md @@ -155,7 +155,8 @@ generation three phases: **on** (responder armed with MAC1, unicast QoS-Data to MAC1 → expect ~100% `tx.report ok`, retries ~0), **retarget** (responder re-armed to a different MAC2, TX to MAC2 → proves RA and responder MAC are arbitrary), **off** (no responder → expect 0% ok, retries pinned at the -descriptor limit: the no-ACK outcome must be *visible*, per frame). +descriptor limit set by `DEVOURER_TX_RETRY_LIMIT` — this matrix runs it at +12 — so the no-ACK outcome must be *visible*, per frame). `report_coverage` = reports / frames sent (`tx.stats.submitted`); HalMAC adds SW_DEFINE tag-echo gap counting. @@ -172,6 +173,11 @@ scheduled MAC runs TX+RX anyway, so this is the relevant session shape. | Jaguar2 8812BU | 0.91 / 2.1 (run-to-run 0.12–0.91) | 0.64 / 5.3 | yes (12) | 0.86 | 0 | | Jaguar3 8822CU | 1.00 / 0.24 | 1.00 / 0.13 | yes (12) | 0.96 | 0 | +The OFF-phase pin is set by `DEVOURER_TX_RETRY_LIMIT` (the matrix runs 12, +the value the descriptors used to hardcode) — the knob, not a descriptor +constant, is now the single source of truth for the retry limit on +jaguar1/2/3 (inert on Kestrel and the 8814A die). + Responder-side capability (same setup, J3 TX as the reference soliciting station): **8814AU** closes the loop at retries ~0.1 (the bench responder of choice); **8812AU** works but degraded (97% delivery at ~7 mean retries — @@ -182,7 +188,8 @@ separately proven (`tests/ack_responder_check.sh`). ### The contract 1. **Per-frame delivery detection is GO on all three generations**: the OFF - phase pins retries at the descriptor limit with `state=1` on every report — + phase pins retries at the configured limit (`DEVOURER_TX_RETRY_LIMIT`, + set to 12 by the matrix) with `state=1` on every report — a no-ACK outcome is unambiguously visible per frame, which is all a software retransmission layer needs. Report coverage 86–100% with zero HalMAC tag gaps (interior losses); the reliability layer must tolerate a @@ -191,7 +198,9 @@ separately proven (`tests/ack_responder_check.sh`). 2. **Closed-loop hardware ACK + autonomous retry is GO on Jaguar1 and Jaguar3** (100% delivery, retries ≈ 0.2–0.3) including retargeting an arbitrary UE MAC mid-session (re-arm `SetAckResponder`, change the - descriptor RA — both fully dynamic). + descriptor RA — both fully dynamic). Requires a nonzero + `DEVOURER_TX_RETRY_LIMIT` — the hardware ARQ loop retransmits until ACK + only up to that per-frame limit. 3. **Jaguar2 as the soliciting TX is MARGINAL as measured**: ACK closure varied 12–91% across identical runs (mean retries 2–11) against both 8814AU and 8812AU responders, and its TX pace in the TX+RX-thread shape is diff --git a/examples/common/env_config.cpp b/examples/common/env_config.cpp index 5b146051..1331bc71 100644 --- a/examples/common/env_config.cpp +++ b/examples/common/env_config.cpp @@ -106,6 +106,8 @@ devourer::DeviceConfig devourer_config_from_env() { if (devourer::parse_ampdu_mode(e, m)) cfg.tx.ampdu = m; } + if (env_long("DEVOURER_TX_RETRY_LIMIT", &v)) + cfg.tx.retry_limit = static_cast(v < 0 ? 0 : (v > 63 ? 63 : v)); /* ---- bf ---- */ if (const char *snd = env_str("DEVOURER_BF_ARM_SOUNDER")) { diff --git a/src/DeviceConfig.h b/src/DeviceConfig.h index 1ae71145..b96b779d 100644 --- a/src/DeviceConfig.h +++ b/src/DeviceConfig.h @@ -175,6 +175,15 @@ struct DeviceConfig { * Runtime equivalent: StartCwTone/StopCwTone on the concrete device. */ bool cw_tone = false; uint8_t cw_tone_gain = 0; + /* env: DEVOURER_TX_RETRY_LIMIT — per-frame hardware retry limit (0..63). + * Maps to the TX descriptor DATA_RETRY_LIMIT / RTS_DATA_RTY_LMT field + * (Dword4 bits 18-23). 0 = no retries (WFB default: FEC provides + * reliability, not MAC retries). On a busy half-duplex link retries flood + * the air and blind the receiver. Hardware-ARQ (SetAckResponder + unicast + * TA, docs/scheduled-mac.md) needs a nonzero value. Inert on Kestrel + * (firmware-level retry) and on the 8814A die (vendor DATA_RETRY_LIMIT=0 + * carve-out kept). */ + int retry_limit = 0; /* env: DEVOURER_TX_USB_AGG — USB TX aggregation: max frames packed into * one bulk-OUT URB by send_packets (0 = off, the default: send_packets * degrades to a per-frame loop and every TX path is byte-identical to diff --git a/src/jaguar1/RtlJaguarDevice.cpp b/src/jaguar1/RtlJaguarDevice.cpp index 5b613299..71b3a894 100644 --- a/src/jaguar1/RtlJaguarDevice.cpp +++ b/src/jaguar1/RtlJaguarDevice.cpp @@ -1171,8 +1171,10 @@ size_t RtlJaguarDevice::build_tx_block(const uint8_t *packet, size_t length, SET_TX_DESC_RETRY_LIMIT_ENABLE_8812(usb_frame, 1); if (!is_8814a) { /* 88XXau leaves DATA_RETRY_LIMIT=0 for monitor injection on 8814A - * (RETRY_LIMIT_ENABLE stays set to 1 in both). */ - SET_TX_DESC_DATA_RETRY_LIMIT_8812(usb_frame, 12); + * (RETRY_LIMIT_ENABLE stays set to 1 in both). + * Use cfg.tx.retry_limit (DEVOURER_TX_RETRY_LIMIT, default 0) instead of + * the hardcoded 12 — retries flood the air on a busy half-duplex link. */ + SET_TX_DESC_DATA_RETRY_LIMIT_8812(usb_frame, _cfg.tx.retry_limit); } if (sgi) { _logger->info("short gi enabled,set sgi"); @@ -1228,7 +1230,7 @@ size_t RtlJaguarDevice::build_tx_block(const uint8_t *packet, size_t length, SET_TX_DESC_AGG_ENABLE_8812(usb_frame, 1); SET_TX_DESC_MAX_AGG_NUM_8812(usb_frame, am.max_num & 0x1f); SET_TX_DESC_AMPDU_DENSITY_8812(usb_frame, am.density & 0x7); - SET_TX_DESC_DATA_RETRY_LIMIT_8812(usb_frame, am.no_ack ? 0 : 12); + SET_TX_DESC_DATA_RETRY_LIMIT_8812(usb_frame, am.no_ack ? 0 : _cfg.tx.retry_limit); } if (_cfg.debug.tx_qsel) SET_TX_DESC_QUEUE_SEL_8812(usb_frame, *_cfg.debug.tx_qsel); diff --git a/src/jaguar2/RtlJaguar2Device.cpp b/src/jaguar2/RtlJaguar2Device.cpp index f33e40f3..fa97fcd6 100644 --- a/src/jaguar2/RtlJaguar2Device.cpp +++ b/src/jaguar2/RtlJaguar2Device.cpp @@ -1529,6 +1529,12 @@ size_t RtlJaguar2Device::build_tx_block(const uint8_t *packet, size_t length, SET_TX_DESC_SW_DEFINE_8822B(out, _tx_rpt_tag.fetch_add(1) & 0xff); jaguar2::cal_txdesc_chksum_8822b(out); } + /* Per-frame retry limit from cfg (DEVOURER_TX_RETRY_LIMIT, default 0) — + * the fill_data_tx_desc builder hardcodes 12, which floods a busy + * half-duplex link. Both fields sit inside the checksummed span. */ + SET_TX_DESC_RTY_LMT_EN_8822B(out, 1); + SET_TX_DESC_RTS_DATA_RTY_LMT_8822B(out, _cfg.tx.retry_limit); + jaguar2::cal_txdesc_chksum_8822b(out); const devourer::AmpduMode am = _ampdu; /* one lock-free load */ if (am.enabled || _cfg.debug.tx_qsel || _cfg.debug.tx_ampdu_max) { /* A-MPDU descriptor half. The product SetAmpduMode state applies first @@ -1541,7 +1547,7 @@ size_t RtlJaguar2Device::build_tx_block(const uint8_t *packet, size_t length, SET_TX_DESC_AGG_EN_8822B(out, 1); SET_TX_DESC_MAX_AGG_NUM_8822B(out, am.max_num & 0x1f); SET_TX_DESC_AMPDU_DENSITY_8822B(out, am.density & 0x7); - SET_TX_DESC_RTS_DATA_RTY_LMT_8822B(out, am.no_ack ? 0 : 12); + SET_TX_DESC_RTS_DATA_RTY_LMT_8822B(out, am.no_ack ? 0 : _cfg.tx.retry_limit); } if (_cfg.debug.tx_qsel) SET_TX_DESC_QSEL_8822B(out, *_cfg.debug.tx_qsel); diff --git a/src/jaguar3/RtlJaguar3Device.cpp b/src/jaguar3/RtlJaguar3Device.cpp index 55d7918b..d395bac9 100644 --- a/src/jaguar3/RtlJaguar3Device.cpp +++ b/src/jaguar3/RtlJaguar3Device.cpp @@ -2006,6 +2006,12 @@ size_t RtlJaguar3Device::build_tx_block(const uint8_t *packet, size_t length, SET_TX_DESC_SW_DEFINE_8822C(out, _tx_rpt_tag.fetch_add(1) & 0xff); jaguar3::cal_txdesc_chksum_8822c(out); } + /* Per-frame retry limit from cfg (DEVOURER_TX_RETRY_LIMIT, default 0) — + * the fill_data_tx_desc builder hardcodes 12, which floods a busy + * half-duplex link. Both fields sit inside the checksummed span. */ + SET_TX_DESC_RTY_LMT_EN_8822C(out, 1); + SET_TX_DESC_RTS_DATA_RTY_LMT_8822C(out, _cfg.tx.retry_limit); + jaguar3::cal_txdesc_chksum_8822c(out); const devourer::AmpduMode am = _ampdu; /* one lock-free load */ if (am.enabled || _cfg.debug.tx_qsel || _cfg.debug.tx_ampdu_max) { /* A-MPDU descriptor half. The product SetAmpduMode state applies first, @@ -2017,7 +2023,7 @@ size_t RtlJaguar3Device::build_tx_block(const uint8_t *packet, size_t length, SET_TX_DESC_AGG_EN_8822C(out, 1); SET_TX_DESC_MAX_AGG_NUM_8822C(out, am.max_num & 0x1f); SET_TX_DESC_AMPDU_DENSITY_8822C(out, am.density & 0x7); - SET_TX_DESC_RTS_DATA_RTY_LMT_8822C(out, am.no_ack ? 0 : 12); + SET_TX_DESC_RTS_DATA_RTY_LMT_8822C(out, am.no_ack ? 0 : _cfg.tx.retry_limit); } if (_cfg.debug.tx_qsel) SET_TX_DESC_QSEL_8822C(out, *_cfg.debug.tx_qsel); diff --git a/tests/ack_txreport_analyze.py b/tests/ack_txreport_analyze.py index 278642c2..69b0013d 100644 --- a/tests/ack_txreport_analyze.py +++ b/tests/ack_txreport_analyze.py @@ -13,7 +13,8 @@ # a different unicast RA (SetAckResponder re-armed) -> expect ON behavior. # # Usage: python3 ack_txreport_analyze.py --sent N --cell NAME \ -# --expect on|off +# --expect on|off [--expect-retries PIN] +# (off-phase verdict pins at PIN, default 12) # python3 ack_txreport_analyze.py --selftest import json, sys @@ -31,7 +32,7 @@ def load(path): out.append(r) return out -def analyze(reports, sent, cell="", expect=None): +def analyze(reports, sent, cell="", expect=None, expect_retries=12): n = len(reports) v = {"ev": "ackrep.verdict", "cell": cell, "sent": sent, "reports": n} if n == 0: @@ -57,9 +58,11 @@ def analyze(reports, sent, cell="", expect=None): if expect == "on": v["capability_ok"] = bool(v["ack_rate"] >= 0.9 and v["retries_mean"] < 2) elif expect == "off": - # Nobody ACKs: delivery must FAIL and retries pin at the limit — this - # proves the no-ACK outcome is visible, not that the link is bad. - v["capability_ok"] = bool(v["ack_rate"] <= 0.1 and v["retries_max"] >= 8) + # Nobody ACKs: delivery must FAIL and retries pin at the configured + # limit (--expect-retries, DEVOURER_TX_RETRY_LIMIT) — this proves the + # no-ACK outcome is visible, not that the link is bad. + v["capability_ok"] = bool(v["ack_rate"] <= 0.1 and + v["retries_max"] >= expect_retries) else: v["capability_ok"] = None return v @@ -79,6 +82,12 @@ def check(cond, msg): "tag": i % 256, "missed": 0} for i in range(100)] v = analyze(off, 100, "off", expect="off") check(v["capability_ok"] and v["retries_max"] == 12, "pinned OFF passes") + off8 = [{"ev": "tx.report", "ok": False, "state": 1, "retries": 8} + for _ in range(100)] + v = analyze(off8, 100, "off8", expect="off", expect_retries=8) + check(v["capability_ok"], "custom pin (8) OFF passes") + v = analyze(off8, 100, "off8-mismatch", expect="off", expect_retries=12) + check(not v["capability_ok"], "below-pin OFF fails") v = analyze(off, 100, "off-as-on", expect="on") check(not v["capability_ok"], "OFF behavior fails an ON expectation") v = analyze([], 100, "dead", expect="on") @@ -102,13 +111,17 @@ def main(): sys.exit(selftest()) args = sys.argv[1:] sent, cell, expect = 0, "", None + expect_retries = 12 if "--sent" in args: i = args.index("--sent"); sent = int(args[i + 1]); del args[i:i + 2] if "--cell" in args: i = args.index("--cell"); cell = args[i + 1]; del args[i:i + 2] if "--expect" in args: i = args.index("--expect"); expect = args[i + 1]; del args[i:i + 2] - v = analyze(load(args[0]), sent, cell, expect) + if "--expect-retries" in args: + i = args.index("--expect-retries") + expect_retries = int(args[i + 1]); del args[i:i + 2] + v = analyze(load(args[0]), sent, cell, expect, expect_retries) print(json.dumps(v)) sys.exit(0 if v.get("capability_ok") else 1) diff --git a/tests/ack_txreport_matrix.sh b/tests/ack_txreport_matrix.sh index 6b23eaeb..909aa5ac 100644 --- a/tests/ack_txreport_matrix.sh +++ b/tests/ack_txreport_matrix.sh @@ -8,7 +8,9 @@ # MAC2: proves the injected descriptor's RA and the responder MAC # are both arbitrary, not baked-in. # off — no responder, TX to MAC1: expect ok~0, retries pinned at the -# descriptor limit — the no-ACK outcome is VISIBLE per frame. +# descriptor limit set by DEVOURER_TX_RETRY_LIMIT (this matrix +# pins 12 so the hardware-ARQ capability stays visible) — the +# no-ACK outcome is VISIBLE per frame. # Every phase also measures report_coverage (reports / frames sent) and, on # HalMAC (J2/J3), SW_DEFINE tag-echo gaps + the firmware missed counter. # @@ -29,6 +31,7 @@ CH=${CH:-36}; SECS=${SECS:-8}; GAP_US=${GAP_US:-5000} MAC1=${MAC1:-02:12:34:56:78:9a} MAC2=${MAC2:-02:12:34:56:78:9b} TX_SA=${TX_SA:-02:aa:bb:cc:dd:01} # unicast TA (the ACK RA I/G footgun) +RETRY_LIMIT=${RETRY_LIMIT:-12} # descriptor retry pin for the off phase OUT=${OUT:-/tmp/ack_txreport} CELLS=${CELLS:-"j1-8812au:0x0bda:0x8812 j2-8812bu:0x2357:0x012d j3-8822cu:0x0bda:0xc812"} @@ -51,6 +54,7 @@ run_phase() { # $1 cell $2 phase $3 tx vid $4 tx pid $5 RA mac $6 responder mac DEVOURER_TX_QOS_DATA=1 DEVOURER_TX_RA=$ra DEVOURER_TX_SA=$TX_SA \ DEVOURER_TX_RATE=MCS3 DEVOURER_TX_PAYLOAD_BYTES=200 \ DEVOURER_TX_GAP_US=$GAP_US DEVOURER_TX_REPORT=1 \ + DEVOURER_TX_RETRY_LIMIT=$RETRY_LIMIT \ DEVOURER_TX_WITH_RX=thread DEVOURER_LOG_LEVEL=warn \ timeout -s INT $SECS ./build/txdemo \ >"$OUT/tx_$tag.jsonl" 2>"$OUT/tx_$tag.err" || true @@ -64,7 +68,8 @@ run_phase() { # $1 cell $2 phase $3 tx vid $4 tx pid $5 RA mac $6 responder mac sent=${sent:-0} echo "-- $tag: sent=$sent reports=$(grep -c '"ev":"tx.report"' "$OUT/tx_$tag.jsonl" || true)" python3 tests/ack_txreport_analyze.py "$OUT/tx_$tag.jsonl" \ - --sent "$sent" --cell "$tag" --expect "$expect" | tee -a "$VERDICTS" || true + --sent "$sent" --cell "$tag" --expect "$expect" \ + --expect-retries "$RETRY_LIMIT" | tee -a "$VERDICTS" || true } for cell in $CELLS; do