From c3c69b661e6c0a69815167cb549ed577baecf00c Mon Sep 17 00:00:00 2001 From: Joseph <162703152+josephnef@users.noreply.github.com> Date: Tue, 4 Aug 2026 07:52:30 +0300 Subject: [PATCH 1/3] DEVOURER_TX_RETRY_FALLBACK=off: pin retries at the descriptor rate; floor form measured and rejected MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every generation's inject path leaves the firmware retry rate-fallback ladder enabled (measured on the 8812CU: retried frames step MCS3 -> 54M -> 24M -> 18M -> 9M -> 6M, ~10% of retried frames finishing below the original rate). That is the right default for weak-signal links and the wrong one for constant-rate FPV, where a 6M re-air of an MCS3 frame costs ~4x airtime — the new knob's "off" sets DISDATAFB / DISABLE_FB and pins every retry at the descriptor DATARATE (measured: 1,200/1,200 retried frames re-aired at the original rate; default arm replicates the ladder; both at retry_limit 8, ~60k reports/arm). A DATA_RTY_LOWEST_RATE floor form was implemented, measured, and REJECTED: the fw reinterprets the bound inside the RA-group rate space (inject runs RA-group 9) — an MCS0 floor sent retried HT frames wandering into VHT rates (final_rate 45 = VHT1SS_MCS1) with a 20x retry inflation and a point of delivery loss. The enum carries the finding, the FrameParser headers keep the field position documented as deliberately unwritten, and the env parse warns on any non-"off" value instead of surprising the link. Knob on all three generations (same descriptor-bit semantics; J1's NDPA branch keeps its own unconditional disable). docs/scheduled-mac.md carries the measured distributions next to the retry-limit guidance; arq_e2e_delivery.sh grows the DRONE_FALLBACK passthrough. Co-Authored-By: Claude Opus 4.8 --- examples/common/env_config.cpp | 11 +++++++++++ src/DeviceConfig.h | 19 +++++++++++++++++++ src/jaguar1/RtlJaguarDevice.cpp | 7 +++++++ src/jaguar2/FrameParserJaguar2.h | 5 +++++ src/jaguar2/RtlJaguar2Device.cpp | 6 ++++++ src/jaguar3/FrameParserJaguar3.h | 6 ++++++ src/jaguar3/RtlJaguar3Device.cpp | 6 ++++++ tests/arq_e2e_delivery.sh | 2 ++ 8 files changed, 62 insertions(+) diff --git a/examples/common/env_config.cpp b/examples/common/env_config.cpp index 6fca6dc3..8178bd1a 100644 --- a/examples/common/env_config.cpp +++ b/examples/common/env_config.cpp @@ -115,6 +115,17 @@ devourer::DeviceConfig devourer_config_from_env() { } if (env_long("DEVOURER_TX_RETRY_LIMIT", &v)) cfg.tx.retry_limit = static_cast(v < 0 ? 0 : (v > 63 ? 63 : v)); + if (const char *e = env_str("DEVOURER_TX_RETRY_FALLBACK")) { + if (str_ieq(e, "off")) + cfg.tx.retry_fallback = devourer::RetryFallback::Off; + else + /* A floor form was measured and rejected — the fw reinterprets + * DATA_RTY_LOWEST_RATE inside the RA-group rate space (see the + * RetryFallback enum note in DeviceConfig.h). */ + std::fprintf(stderr, + "devourer [W] DEVOURER_TX_RETRY_FALLBACK='%s' unsupported " + "(only \"off\") — keeping the firmware ladder\n", e); + } /* ---- bf ---- */ if (const char *snd = env_str("DEVOURER_BF_ARM_SOUNDER")) { diff --git a/src/DeviceConfig.h b/src/DeviceConfig.h index 23fa6268..e4ac8df1 100644 --- a/src/DeviceConfig.h +++ b/src/DeviceConfig.h @@ -79,6 +79,19 @@ enum class RxMode : uint8_t { Decoupled, /* naive worker-thread hand-off (known-bad A/B control) */ }; +/* Hardware retry rate-fallback control for injected frames. Every generation + * defaults to the firmware's fallback ladder (measured on the 8812CU: + * retried frames step MCS3 → 54M → 24M → 18M → 9M → 6M, the fw's own 5 GHz + * floor). Off pins every retry at the descriptor DATARATE (DISDATAFB / + * DISABLE_FB = 1; measured: 1,200/1,200 retried frames re-aired at the + * original rate) — for constant-rate links where a low-rate retry costs + * multiples of the frame's airtime. A DATA_RTY_LOWEST_RATE floor form was + * measured and REJECTED: the fw reinterprets the bound inside the RA-group + * rate space (inject runs RA-group 9), wandering retries into VHT rates + * (final_rate 45 = VHT1SS_MCS1 on an HT frame) with a 20x retry inflation — + * the field is not a plain DESC_RATE bound on this fw. */ +enum class RetryFallback : uint8_t { Default, Off }; + /* What the spsc-fat ring does when its buffer pool runs dry (consumer behind * under sustained overload). The choice decides which side of the hardware-ARQ * contract survives overload — the chip ACKs on FIFO admission, so anything @@ -205,6 +218,12 @@ struct DeviceConfig { * (firmware-level retry) and on the 8814A die (vendor DATA_RETRY_LIMIT=0 * carve-out kept). */ int retry_limit = 0; + /* env: DEVOURER_TX_RETRY_FALLBACK — "off" | unset. Unset = the firmware + * fallback ladder with its own floor (the current behaviour, descriptors + * byte-identical). "off" disables per-retry rate fallback (DISDATAFB / + * DISABLE_FB = 1): retries re-air at the descriptor rate. See the + * RetryFallback enum for why there is no floor form. */ + RetryFallback retry_fallback = RetryFallback::Default; /* 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 a55da29f..62459638 100644 --- a/src/jaguar1/RtlJaguarDevice.cpp +++ b/src/jaguar1/RtlJaguarDevice.cpp @@ -1175,6 +1175,13 @@ size_t RtlJaguarDevice::build_tx_block(const uint8_t *packet, size_t length, ? 1 : 0); SET_TX_DESC_RETRY_LIMIT_ENABLE_8812(usb_frame, 1); + /* Retry rate-fallback control (DEVOURER_TX_RETRY_FALLBACK): the data path + * leaves DISABLE_FB at 0 (the fw ladder) by default — only the NDPA branch + * disables it. Off pins retries at the descriptor rate; Floor bounds the + * ladder via DATA_RATE_FB_LIMIT. Dword3/4, inside the checksummed 32 + * bytes. */ + if (_cfg.tx.retry_fallback == devourer::RetryFallback::Off) + SET_TX_DESC_DISABLE_FB_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). diff --git a/src/jaguar2/FrameParserJaguar2.h b/src/jaguar2/FrameParserJaguar2.h index fd31e227..b0b15d33 100644 --- a/src/jaguar2/FrameParserJaguar2.h +++ b/src/jaguar2/FrameParserJaguar2.h @@ -39,6 +39,11 @@ constexpr size_t RXDESC_SIZE_8822B = 24; /* RX_DESC_SIZE_88XX */ #define SET_TX_DESC_CHK_EN_8822B(d, v) SET_BITS_TO_LE_4BYTE((d) + 0x0C, 14, 1, v) #define SET_TX_DESC_DISDATAFB_8822B(d, v) SET_BITS_TO_LE_4BYTE((d) + 0x0C, 10, 1, v) #define SET_TX_DESC_DATARATE_8822B(d, v) SET_BITS_TO_LE_4BYTE((d) + 0x10, 0, 7, v) +/* DATA_RTY_LOWEST_RATE (0x10 [12:8]): measured NOT a plain DESC_RATE bound on + * the sibling 8822C fw (RA-group reinterpretation — RetryFallback note in + * DeviceConfig.h). Field documented, deliberately unwritten. */ +#define SET_TX_DESC_DATA_RTY_LOWEST_RATE_8822B(d, v) \ + SET_BITS_TO_LE_4BYTE((d) + 0x10, 8, 5, v) #define SET_TX_DESC_DATA_SC_8822B(d, v) SET_BITS_TO_LE_4BYTE((d) + 0x14, 0, 4, v) #define SET_TX_DESC_DATA_SHORT_8822B(d, v) SET_BITS_TO_LE_4BYTE((d) + 0x14, 4, 1, v) #define SET_TX_DESC_DATA_BW_8822B(d, v) SET_BITS_TO_LE_4BYTE((d) + 0x14, 5, 2, v) diff --git a/src/jaguar2/RtlJaguar2Device.cpp b/src/jaguar2/RtlJaguar2Device.cpp index 6646e7f8..28401211 100644 --- a/src/jaguar2/RtlJaguar2Device.cpp +++ b/src/jaguar2/RtlJaguar2Device.cpp @@ -1539,6 +1539,12 @@ size_t RtlJaguar2Device::build_tx_block(const uint8_t *packet, size_t length, * 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); + /* Retry rate-fallback control (DEVOURER_TX_RETRY_FALLBACK): default leaves + * the builder's DISDATAFB=0 (the fw ladder); Off pins retries at the + * descriptor rate; Floor bounds the ladder. Same checksummed span as the + * retry limit. */ + if (_cfg.tx.retry_fallback == devourer::RetryFallback::Off) + SET_TX_DESC_DISDATAFB_8822B(out, 1); 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) { diff --git a/src/jaguar3/FrameParserJaguar3.h b/src/jaguar3/FrameParserJaguar3.h index 44d4baa3..433bfdde 100644 --- a/src/jaguar3/FrameParserJaguar3.h +++ b/src/jaguar3/FrameParserJaguar3.h @@ -42,6 +42,12 @@ constexpr size_t RXDESC_SIZE_8822C = 24; /* RX_DESC_SIZE_88XX */ #define SET_TX_DESC_NAVUSEHDR_8822C(d, v) SET_BITS_TO_LE_4BYTE((d) + 0x0C, 15, 1, v) #define SET_TX_DESC_NDPA_8822C(d, v) SET_BITS_TO_LE_4BYTE((d) + 0x0C, 22, 2, v) #define SET_TX_DESC_DATARATE_8822C(d, v) SET_BITS_TO_LE_4BYTE((d) + 0x10, 0, 7, v) +/* DATA_RTY_LOWEST_RATE (0x10 [12:8]): measured NOT a plain DESC_RATE bound — + * the fw reinterprets it inside the RA-group rate space (see the + * RetryFallback note in DeviceConfig.h). Field documented, deliberately + * unwritten. */ +#define SET_TX_DESC_DATA_RTY_LOWEST_RATE_8822C(d, v) \ + SET_BITS_TO_LE_4BYTE((d) + 0x10, 8, 5, v) #define SET_TX_DESC_DATA_SC_8822C(d, v) SET_BITS_TO_LE_4BYTE((d) + 0x14, 0, 4, v) #define SET_TX_DESC_DATA_SHORT_8822C(d, v) SET_BITS_TO_LE_4BYTE((d) + 0x14, 4, 1, v) #define SET_TX_DESC_DATA_BW_8822C(d, v) SET_BITS_TO_LE_4BYTE((d) + 0x14, 5, 2, v) diff --git a/src/jaguar3/RtlJaguar3Device.cpp b/src/jaguar3/RtlJaguar3Device.cpp index b07516e0..fc3f3a67 100644 --- a/src/jaguar3/RtlJaguar3Device.cpp +++ b/src/jaguar3/RtlJaguar3Device.cpp @@ -2017,6 +2017,12 @@ size_t RtlJaguar3Device::build_tx_block(const uint8_t *packet, size_t length, * 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); + /* Retry rate-fallback control (DEVOURER_TX_RETRY_FALLBACK): default leaves + * the builder's DISDATAFB=0 (the fw ladder — measured stepping toward 6M + * on deep retries); Off pins retries at the descriptor rate; Floor bounds + * the ladder. Same checksummed span as the retry limit. */ + if (_cfg.tx.retry_fallback == devourer::RetryFallback::Off) + SET_TX_DESC_DISDATAFB_8822C(out, 1); 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) { diff --git a/tests/arq_e2e_delivery.sh b/tests/arq_e2e_delivery.sh index 080dd86b..6b47dfd5 100644 --- a/tests/arq_e2e_delivery.sh +++ b/tests/arq_e2e_delivery.sh @@ -44,6 +44,7 @@ MAC1=${MAC1:-02:12:34:56:78:9a} # DUT responder identity = drone RA TX_SA=${TX_SA:-02:aa:bb:cc:dd:01} # drone TA (unicast — the I/G footgun) RETRY_LIMIT=${RETRY_LIMIT:-3} # field report used 3 DRONE_REPORT_N=${DRONE_REPORT_N:-1} # CCX sampling divisor (1 = every frame) +DRONE_FALLBACK=${DRONE_FALLBACK:-} # retry rate fallback: off | | "" RECEIPT_MS=${RECEIPT_MS:-} # windowed RX receipts cadence (off="") DRONE_RATE=${DRONE_RATE:-MCS3} DRONE_PAYLOAD=${DRONE_PAYLOAD:-512} # >= 30 so the pctr stamp fits @@ -167,6 +168,7 @@ env DEVOURER_VID="$DRONE_VID" DEVOURER_PID="$DRONE_PID" DEVOURER_CHANNEL="$CH" \ DEVOURER_TX_RATE="$DRONE_RATE" DEVOURER_TX_PAYLOAD_BYTES="$DRONE_PAYLOAD" \ DEVOURER_TX_GAP_US="$DRONE_GAP_US" \ DEVOURER_TX_REPORT="$DRONE_REPORT_N" DEVOURER_TX_RETRY_LIMIT="$RETRY_LIMIT" \ + ${DRONE_FALLBACK:+DEVOURER_TX_RETRY_FALLBACK="$DRONE_FALLBACK"} \ ${RECEIPT_MS:+DEVOURER_TX_RECEIPTS=1 DEVOURER_TX_WITH_RX=thread} \ DEVOURER_DIS_CCA="$DRONE_DIS_CCA" \ DEVOURER_TX_PWR_OFFSET_QDB="$PWR_QDB" \ From 332e4160bc425eac03c078b31509b61340b3cad6 Mon Sep 17 00:00:00 2001 From: Joseph <162703152+josephnef@users.noreply.github.com> Date: Tue, 4 Aug 2026 07:53:23 +0300 Subject: [PATCH 2/3] docs/scheduled-mac.md: retry rate-fallback measured behaviour + the off knob Co-Authored-By: Claude Opus 4.8 --- docs/scheduled-mac.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/docs/scheduled-mac.md b/docs/scheduled-mac.md index 96c07a0e..2658dd25 100644 --- a/docs/scheduled-mac.md +++ b/docs/scheduled-mac.md @@ -190,6 +190,17 @@ worst case doubles, then stops growing). Prefer **16** on an ARQ link, or **8 + a light FEC floor** where airtime is precious; the per-run residual gap analysis is `tests/arq_fec_dimension.py`. +Retries also change RATE by default: every generation's inject path leaves +the firmware fallback ladder enabled, and retried frames step down (measured +on the 8812CU: MCS3 → 54M → 24M → 18M → 9M → 6M, ~10% of retried frames +finishing below the original rate). `DEVOURER_TX_RETRY_FALLBACK=off` pins +every retry at the descriptor rate (measured: 1,200/1,200) — for +constant-rate links where a 6M re-air of an MCS3 frame costs ~4× the +airtime. There is deliberately no floor form: bounding the ladder via +DATA_RTY_LOWEST_RATE was measured anomalous (the fw reinterprets the bound +inside the RA-group rate space — retries wandered into VHT rates with a 20× +retry inflation), see the `RetryFallback` note in `src/DeviceConfig.h`. + 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 — From d75a3fdf53aa28538300382870e68f597e654574 Mon Sep 17 00:00:00 2001 From: Joseph <162703152+josephnef@users.noreply.github.com> Date: Tue, 4 Aug 2026 07:58:36 +0300 Subject: [PATCH 3/3] retry-fallback review round: flush the warn, purge the floor-form comment remnants Co-Authored-By: Claude Opus 4.8 --- examples/common/env_config.cpp | 6 ++++-- src/jaguar1/RtlJaguarDevice.cpp | 6 +++--- src/jaguar2/RtlJaguar2Device.cpp | 4 ++-- src/jaguar3/RtlJaguar3Device.cpp | 5 +++-- tests/arq_e2e_delivery.sh | 2 +- 5 files changed, 13 insertions(+), 10 deletions(-) diff --git a/examples/common/env_config.cpp b/examples/common/env_config.cpp index 8178bd1a..e8c9fb70 100644 --- a/examples/common/env_config.cpp +++ b/examples/common/env_config.cpp @@ -116,15 +116,17 @@ devourer::DeviceConfig devourer_config_from_env() { if (env_long("DEVOURER_TX_RETRY_LIMIT", &v)) cfg.tx.retry_limit = static_cast(v < 0 ? 0 : (v > 63 ? 63 : v)); if (const char *e = env_str("DEVOURER_TX_RETRY_FALLBACK")) { - if (str_ieq(e, "off")) + if (str_ieq(e, "off")) { cfg.tx.retry_fallback = devourer::RetryFallback::Off; - else + } else { /* A floor form was measured and rejected — the fw reinterprets * DATA_RTY_LOWEST_RATE inside the RA-group rate space (see the * RetryFallback enum note in DeviceConfig.h). */ std::fprintf(stderr, "devourer [W] DEVOURER_TX_RETRY_FALLBACK='%s' unsupported " "(only \"off\") — keeping the firmware ladder\n", e); + std::fflush(stderr); /* the diagnostic plane is per-line flushed */ + } } /* ---- bf ---- */ diff --git a/src/jaguar1/RtlJaguarDevice.cpp b/src/jaguar1/RtlJaguarDevice.cpp index 62459638..c028aae8 100644 --- a/src/jaguar1/RtlJaguarDevice.cpp +++ b/src/jaguar1/RtlJaguarDevice.cpp @@ -1177,9 +1177,9 @@ size_t RtlJaguarDevice::build_tx_block(const uint8_t *packet, size_t length, SET_TX_DESC_RETRY_LIMIT_ENABLE_8812(usb_frame, 1); /* Retry rate-fallback control (DEVOURER_TX_RETRY_FALLBACK): the data path * leaves DISABLE_FB at 0 (the fw ladder) by default — only the NDPA branch - * disables it. Off pins retries at the descriptor rate; Floor bounds the - * ladder via DATA_RATE_FB_LIMIT. Dword3/4, inside the checksummed 32 - * bytes. */ + * disables it. Off pins retries at the descriptor rate; there is no floor + * form (the RetryFallback note in DeviceConfig.h has the measurement). + * Dword3, inside the checksummed 32 bytes. */ if (_cfg.tx.retry_fallback == devourer::RetryFallback::Off) SET_TX_DESC_DISABLE_FB_8812(usb_frame, 1); if (!is_8814a) { diff --git a/src/jaguar2/RtlJaguar2Device.cpp b/src/jaguar2/RtlJaguar2Device.cpp index 28401211..7608eaa0 100644 --- a/src/jaguar2/RtlJaguar2Device.cpp +++ b/src/jaguar2/RtlJaguar2Device.cpp @@ -1541,8 +1541,8 @@ size_t RtlJaguar2Device::build_tx_block(const uint8_t *packet, size_t length, SET_TX_DESC_RTS_DATA_RTY_LMT_8822B(out, _cfg.tx.retry_limit); /* Retry rate-fallback control (DEVOURER_TX_RETRY_FALLBACK): default leaves * the builder's DISDATAFB=0 (the fw ladder); Off pins retries at the - * descriptor rate; Floor bounds the ladder. Same checksummed span as the - * retry limit. */ + * descriptor rate. No floor form (the RetryFallback note in DeviceConfig.h + * has the measurement). Same checksummed span as the retry limit. */ if (_cfg.tx.retry_fallback == devourer::RetryFallback::Off) SET_TX_DESC_DISDATAFB_8822B(out, 1); jaguar2::cal_txdesc_chksum_8822b(out); diff --git a/src/jaguar3/RtlJaguar3Device.cpp b/src/jaguar3/RtlJaguar3Device.cpp index fc3f3a67..dee1c90b 100644 --- a/src/jaguar3/RtlJaguar3Device.cpp +++ b/src/jaguar3/RtlJaguar3Device.cpp @@ -2019,8 +2019,9 @@ size_t RtlJaguar3Device::build_tx_block(const uint8_t *packet, size_t length, SET_TX_DESC_RTS_DATA_RTY_LMT_8822C(out, _cfg.tx.retry_limit); /* Retry rate-fallback control (DEVOURER_TX_RETRY_FALLBACK): default leaves * the builder's DISDATAFB=0 (the fw ladder — measured stepping toward 6M - * on deep retries); Off pins retries at the descriptor rate; Floor bounds - * the ladder. Same checksummed span as the retry limit. */ + * on deep retries); Off pins retries at the descriptor rate. No floor form + * (the RetryFallback note in DeviceConfig.h has the measurement). Same + * checksummed span as the retry limit. */ if (_cfg.tx.retry_fallback == devourer::RetryFallback::Off) SET_TX_DESC_DISDATAFB_8822C(out, 1); jaguar3::cal_txdesc_chksum_8822c(out); diff --git a/tests/arq_e2e_delivery.sh b/tests/arq_e2e_delivery.sh index 6b47dfd5..196da701 100644 --- a/tests/arq_e2e_delivery.sh +++ b/tests/arq_e2e_delivery.sh @@ -44,7 +44,7 @@ MAC1=${MAC1:-02:12:34:56:78:9a} # DUT responder identity = drone RA TX_SA=${TX_SA:-02:aa:bb:cc:dd:01} # drone TA (unicast — the I/G footgun) RETRY_LIMIT=${RETRY_LIMIT:-3} # field report used 3 DRONE_REPORT_N=${DRONE_REPORT_N:-1} # CCX sampling divisor (1 = every frame) -DRONE_FALLBACK=${DRONE_FALLBACK:-} # retry rate fallback: off | | "" +DRONE_FALLBACK=${DRONE_FALLBACK:-} # retry rate fallback: off | "" (=fw ladder) RECEIPT_MS=${RECEIPT_MS:-} # windowed RX receipts cadence (off="") DRONE_RATE=${DRONE_RATE:-MCS3} DRONE_PAYLOAD=${DRONE_PAYLOAD:-512} # >= 30 so the pctr stamp fits