From 9e1066fccfcefbf21a673cc4d880822d5a2e1f65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Agust=C3=ADn=20Gonz=C3=A1lez?= Date: Tue, 13 Oct 2026 19:20:33 -0300 Subject: [PATCH 1/2] kestrel: hardware ACK responder on the 8852B ## The problem `SetAckResponder` was not implemented on the AX generation, and `ack_responder_ok` read false with the note "matrix-measured 0% closure". The Jaguar recipe has nothing to port: the AX MAC has no port identity register to retarget. Its RMAC answers for an address only when the firmware has committed a valid ADDR_CAM entry carrying it as SMA. Reading the physical CAM back (MAC table SRAM at 0x18850000, through `R_AX_FILTER_MODEL_ADDR` / `R_AX_INDIR_ACCESS_ENTRY`) shows why it was 0%: the entry `add_self_sta` sends for the NO_LINK self role is accepted over H2C and never committed. It reads valid=0. ## The change - **What commits the entry.** A USBPcap capture of the vendor Windows driver associating the adapter shows the sequence a real association uses. Registering the responder address as an INFRA station that way makes the firmware commit it. Two commands, in the vendor's order: `join_info(INFRA, connect)`, then the station form of the addr_cam (SMA = responder address, TMA = a BSSID, the SHORT entry, BSSID CAM at index 1). New: `KestrelFw::fw_join_info`, `KestrelFw::fw_upd_addr_cam_sta`, `HalKestrel::register_infra_sta`. - **Only what was measured to matter is sent.** The vendor sends role CLIENT, a macid unpause, and the CMAC and DMAC tables around those two. Each was dropped on the 8852B with no change in the commit or the ACK rate, and so was setting REC_ACK/DONE_ACK in the H2C header. Without `join_info` the entry never commits. - **`SetAckResponder` verifies by reading the CAM back** (`addr_cam_holds`) and returns false when the entry did not commit. The commit is asynchronous: typically read back about 2.2 ms after the H2Cs, once 19.5 ms, and a one-shot read refused healthy arms during development. So it polls for up to 100 ms. - **It clears the response-abort CCA gates** (`R_AX_TRXPTCL_RESP_0` bits 23-28, new `B_AX_RSP_CHK_*`). On ch149, shared with a neighbour AP, one of three runs without the clear fell to 82.4% single-shot, while three with it stayed at >= 99.6%. On a quiet ch161 it made no difference. - **It refuses a group address or a call before bring-up.** `DEVOURER_ACK_RESPONDER` arms at the end of `Init` and of `InitWrite` and throws if refused, as on Jaguar3. - **`ClearAckResponder` puts macid 0 back to the bring-up NO_LINK entry** and restores the gates, then confirms the CAM no longer holds the address. rxdemo's `DEVOURER_ACK_DISARM_AFTER_MS` now accepts Kestrel through a `ScheduleAckResponderDisarmForTest` hook like the RTL8733B's; `Stop()` cancels a pending one. - **`ack_responder_ok` is true on the 8852B only.** The 8852C firmware accepts the same two commands and leaves the entry invalid, so the arm refuses there. The vendor's 8852C association sends much more before the addr_cam, and which of it the C needs is open. The change is additive. Nothing that ran before this calls the new code unless `SetAckResponder` or `DEVOURER_ACK_RESPONDER` is used. ## Measured There is no CCX `tx.report` on Kestrel, so the soliciting TX is an RTL8832CU (TP-Link TX50UH) counting the ACK frames addressed to its own TA, one attempt per frame. The responder is an RTL8852BU (TP-Link TX20U Nano) running rxdemo, or txdemo for the `InitWrite` row. Setup: ch161, MCS1, 20 MHz, ~480 frames/s for 10 s, both dongles on one desk with the injector at -15 dB, Windows 11. Three runs per row: | row | ACKed / sent | |---|---| | armed at `Init` (rxdemo) | 14230 / 14430 (98.6%) | | armed at `InitWrite` (txdemo) | 14391 / 14431 (99.7%) | | retargeted to 02:12:34:56:78:9a | 14347 / 14425 (99.5%) | | same arm, TX aimed at the adapter's own MAC | 0 / 6440 | | never armed | 0 / 6194 | | armed, then live disarm before TX | 0 / 6603 | The 8852C refuses the arm with "the firmware accepted but did not commit the ADDR_CAM entry". Full build and `ctest` pass on MSVC (78 tests, 4 skipped as on master). What this does not show: - **Near field only.** These numbers are a near-field bench, not a link budget. - **Host-counted ACKs, not the TX's own ARQ verdict.** A separate run with the soliciting TMAC retrying and its ACK window narrowed found the responder's timing indistinguishable from a commercial AP's (both pass a 24 us window and fail at 20 us). - **The responder's own transmissions are outside this matrix.** With this die's TX carrier-sense default (gates cleared), a responder that also sends unicast loses some ACKs to its own queued frames, and the response scheduler abort counter (0xCC18[23:16]) moves. That is noted in `docs/kestrel-ack-responder.md` and is not addressed here. --- docs/kestrel-ack-responder.md | 40 ++++++++++ examples/rx/main.cpp | 15 +++- src/AdapterCaps.h | 9 ++- src/kestrel/HalKestrel.h | 50 ++++++++++++ src/kestrel/KestrelFw.cpp | 68 ++++++++++++++++ src/kestrel/KestrelFw.h | 14 ++++ src/kestrel/MacRegAx.h | 22 +++++ src/kestrel/RtlKestrelDevice.cpp | 133 ++++++++++++++++++++++++++++++- src/kestrel/RtlKestrelDevice.h | 27 +++++++ 9 files changed, 372 insertions(+), 6 deletions(-) create mode 100644 docs/kestrel-ack-responder.md diff --git a/docs/kestrel-ack-responder.md b/docs/kestrel-ack-responder.md new file mode 100644 index 00000000..3eb2b892 --- /dev/null +++ b/docs/kestrel-ack-responder.md @@ -0,0 +1,40 @@ +# Hardware ACK responder on Kestrel (RTL8852BU) + +How `SetAckResponder` arms the AX MAC's SIFS ACK on the 8852B, what was +measured to be needed, and what is still open. The bench numbers are the +`ack_responder_ok` row in `src/AdapterCaps.h`. + +## Mechanism + +The AX MAC has no Jaguar-style port identity register to retarget: the RMAC +answers for an address only when the firmware has COMMITTED a valid +ADDR_CAM entry carrying it as SMA. `add_self_sta`'s NO_LINK entry is accepted +over H2C and never committed (the physical entry, read through +`FILTER_MODEL_ADDR`/`INDIR_ACCESS_ENTRY` at 0x18850000, stays valid=0), which +is why the responder used to measure 0%. What commits it is registering a +station the way a real association does (captured from the vendor Windows +driver over USBPcap): `join_info(INFRA, connect)` THEN the station addr_cam +(SMA = responder address, TMA = a BSSID, SHORT entry, BSSID CAM at index 1). +The join is the necessary step: without it the entry never commits. The +vendor's surrounding commands (role CLIENT, macid unpause, CMAC/DMAC tables, +H2C REC/DONE_ACK) were each dropped with no change in commit or ACK rate, so +`register_infra_sta` sends only the two. + +- **The commit is asynchronous.** Typically read back ~2.2 ms after the H2Cs + (a single read takes about that long over USB), once 19.5 ms. A one-shot + read-back refused healthy arms, so `SetAckResponder` polls for up to 100 ms. +- **Response-abort gates.** The arm clears `R_AX_TRXPTCL_RESP_0` bits 23-28. + On ch149, shared with a neighbour AP, one of three runs without the clear + dropped to 82.4% single-shot; three with it stayed at >= 99.6%. On a quiet + ch161 it made no difference. +- **Clear** re-registers the bring-up NO_LINK entry (which does not commit) + and restores the gates; the read-back confirms the CAM no longer holds the + address. rxdemo's `DEVOURER_ACK_DISARM_AFTER_MS` exercises it. +- **8852C:** its firmware accepts both commands and leaves the entry invalid, + so the arm refuses. The vendor's 8852C association sends far more before the + addr_cam (BB/RF offload, port configuration); which of it the C needs is open. +- Measured near field (two dongles, injector at -15 dB), single-shot, no CCX: + the numbers are in `AdapterCaps.h`. The responder's own transmissions are + not part of that matrix: with carrier sense off (this die's TX default) a + responder that also sends unicast loses some ACKs to its own queued frames + (the response scheduler abort counter at 0xCC18[23:16] moves). diff --git a/examples/rx/main.cpp b/examples/rx/main.cpp index 9d09cad8..dde2baa3 100644 --- a/examples/rx/main.cpp +++ b/examples/rx/main.cpp @@ -46,6 +46,9 @@ #if defined(DEVOURER_HAVE_8733B) #include "rtl8733b/Rtl8733bDevice.h" #endif +#if defined(DEVOURER_HAVE_KESTREL) +#include "kestrel/RtlKestrelDevice.h" +#endif #include "RtlAdapter.h" #include "SignalStop.h" #include "UsbOpen.h" @@ -1704,12 +1707,18 @@ int main(int argc, char **argv) { static_cast(ms)); scheduled = true; } +#endif +#if defined(DEVOURER_HAVE_KESTREL) + if (auto *kestrel = dynamic_cast(rtlDevice)) { + kestrel->ScheduleAckResponderDisarmForTest(static_cast(ms)); + scheduled = true; + } #endif if (!scheduled) { logger->error( - "DEVOURER_ACK_DISARM_AFTER_MS is supported only by RTL8733B " - "and the Jaguar1/CHIP_8812 path (measured on RTL8812AU); " - "refusing {}", + "DEVOURER_ACK_DISARM_AFTER_MS is supported only by RTL8733B, " + "the Jaguar1/CHIP_8812 path (measured on RTL8812AU) and Kestrel " + "(measured on RTL8852BU); refusing {}", devourer::generation_name( rtlDevice->GetAdapterCaps().generation)); return 1; diff --git a/src/AdapterCaps.h b/src/AdapterCaps.h index c6d14691..42e6dc5f 100644 --- a/src/AdapterCaps.h +++ b/src/AdapterCaps.h @@ -191,7 +191,14 @@ struct AdapterCaps { * 8822B, 8812C/8822C, 8812E/8822E (the 8811A rides the 8812 die path and * inherits its row), 8733B (1725/1725 frames ACKed at retries_mean 0.00, * and retarget-proof: re-armed on a different MAC, 1728/1728 — - * tests/ack_txreport_matrix.sh run with the 8733B as the responder). + * tests/ack_txreport_matrix.sh run with the 8733B as the responder), + * 8852B (Kestrel has no CCX report, so the soliciting TX is an 8832CU + * counting the ACK frames it receives, one attempt per frame, MCS1 on + * ch161, 3 runs per row: 14230/14430 armed at Init, 14391/14431 armed at + * InitWrite, 14347/14425 retargeted, 0/6440 at the old address after the + * retarget, 0/6194 never armed, 0/6603 after a live disarm). The 8852C + * reads false: its firmware does not commit the station entry, and the + * arm refuses on the read-back. * * The `on`, `retarget`, and legacy `off` rows establish arming, retargeting, * and a never-armed control. A backend-owned `disarmed` row supports only diff --git a/src/kestrel/HalKestrel.h b/src/kestrel/HalKestrel.h index c3d070c7..4fea87e5 100644 --- a/src/kestrel/HalKestrel.h +++ b/src/kestrel/HalKestrel.h @@ -3,6 +3,7 @@ #include #include +#include #include "ChipVariant.h" #include "KestrelFw.h" @@ -265,6 +266,55 @@ class HalKestrel { return ok; } + /* Register `own` as an associated INFRA station of `bssid` -- what makes the + * firmware COMMIT the physical ADDR_CAM entry, which add_self_sta's NO_LINK + * entry never gets (accepted over H2C, reads back valid=0). Two commands, in + * the order the vendor Windows driver sends them on a real association + * (captured over USBPcap): join_info(INFRA, connect), THEN the station + * addr_cam (SMA=own, TMA=bssid, SHORT entry, BSSID CAM at index 1). The + * join is the step that matters: without it the entry never commits. The + * vendor's surrounding commands (role CLIENT, macid unpause, CMAC and DMAC + * tables) were each dropped on the 8852B with no change in the commit or the + * ACK rate, so they are not sent. The commit is asynchronous; callers verify + * with addr_cam_holds(). The 8852C firmware accepts both commands and does + * not commit. */ + bool register_infra_sta(const uint8_t own[6], const uint8_t bssid[6], + uint8_t macid = 0, uint8_t band = 0, uint8_t port = 0) { + bool ok = _fw.fw_join_info(macid, reg::MAC_AX_NET_TYPE_INFRA, + reg::MAC_AX_SELF_ROLE_CLIENT, + reg::MAC_AX_WIFI_ROLE_STATION, port, band, + /*connect=*/true); + ok = _fw.fw_upd_addr_cam_sta(macid, own, bssid, reg::MAC_AX_NET_TYPE_INFRA, + /*addr_cam_idx=*/0, /*bssid_cam_idx=*/1) && + ok; + return ok; + } + + /* One dword of the MAC's internal table SRAM (rtw89 mac_mem_read): the + * internal address goes to R_AX_FILTER_MODEL_ADDR, the data comes back + * through the R_AX_INDIR_ACCESS_ENTRY window (0x40000, a 32-bit-address + * access). */ + uint32_t read_mac_mem32(uint32_t internal_addr) { + _device.rtw_write32(reg::R_AX_FILTER_MODEL_ADDR, internal_addr); + return _device.rtw_read32_wide(reg::R_AX_INDIR_ACCESS_ENTRY); + } + + /* Whether the PHYSICAL address-CAM entry `idx` is valid and carries `sma` -- + * what the RMAC actually matches A1 against, as opposed to what an H2C asked + * the firmware to write. Layout (rtw89 cam.h): word0 bit0 = VALID, then + * SMA[0..3] in word2 and SMA[4..5] in the low half of word3. */ + bool addr_cam_holds(uint8_t idx, const uint8_t sma[6]) { + const uint32_t base = reg::ADDR_CAM_MEM_BASE + idx * reg::ADDR_CAM_ENT_LONG_SIZE; + if (!(read_mac_mem32(base) & 0x1u)) + return false; + const uint32_t w2 = read_mac_mem32(base + 8); + const uint32_t w3 = read_mac_mem32(base + 12); + const uint8_t got[6] = {static_cast(w2), static_cast(w2 >> 8), + static_cast(w2 >> 16), static_cast(w2 >> 24), + static_cast(w3), static_cast(w3 >> 8)}; + return std::memcmp(got, sma, 6) == 0; + } + /* Register an associated peer STA (an AP-mode client we schedule UL for): * the same role + ADDR_CAM + CMAC-control chain as add_self_sta but for a * distinct macid and net_type=AP, so the fw tracks the peer and a Trigger's diff --git a/src/kestrel/KestrelFw.cpp b/src/kestrel/KestrelFw.cpp index 2a122cc3..6f1baaaa 100644 --- a/src/kestrel/KestrelFw.cpp +++ b/src/kestrel/KestrelFw.cpp @@ -419,6 +419,29 @@ bool KestrelFw::fw_role_maintain(uint8_t macid, uint8_t self_role, return ok; } +bool KestrelFw::fw_join_info(uint8_t macid, uint8_t net_type, uint8_t self_role, + uint8_t wifi_role, uint8_t port, uint8_t band, + bool connect) { + /* One dword; OP is "disconnect", so connect sends OP=0. The HE/UL-OFDMA + * fields (WMM/TGR/ISHESTA/DLBW/TF_MAC_PAD/DL_T_PE) stay 0. */ + uint8_t content[4] = {0}; + const uint32_t op = connect ? 0u : 1u; + const uint32_t w0 = (static_cast(macid) & 0xff) | (op << 8) | + ((static_cast(band) & 0x1) << 9) | + ((static_cast(port) & 0x7) << 21) | + ((static_cast(net_type) & 0x3) << 24) | + ((static_cast(wifi_role) & 0xf) << 26) | + ((static_cast(self_role) & 0x3) << 30); + put_le32(content + 0, w0); + bool ok = send_h2c_cmd(r::FWCMD_H2C_CAT_MAC, r::FWCMD_H2C_CL_MEDIA_RPT, + r::FWCMD_H2C_FUNC_JOININFO, content, sizeof(content)); + _logger->info("Kestrel: join_info (macid={} op={} net_type={} self={} wifi={} " + "port={} band={}) -> {}", + macid, op, net_type, self_role, wifi_role, port, band, + ok ? "sent" : "FAILED"); + return ok; +} + bool KestrelFw::fw_upd_addr_cam(uint8_t macid, const uint8_t self_mac[6], uint8_t net_type, uint8_t addr_cam_idx, uint8_t bssid_cam_idx) { @@ -478,6 +501,51 @@ bool KestrelFw::fw_upd_addr_cam(uint8_t macid, const uint8_t self_mac[6], return ok; } +bool KestrelFw::fw_upd_addr_cam_sta(uint8_t macid, const uint8_t sma[6], + const uint8_t tma[6], uint8_t net_type, + uint8_t addr_cam_idx, uint8_t bssid_cam_idx) { + /* Field layout as fw_upd_addr_cam (rtw89 cam.h), with the station values the + * vendor driver uses: SHORT length, separate SMA/TMA hashes, the BSSID CAM + * index in dword3, macid plus the vendor's bit 14 in dword8, and an embedded + * valid BSSID CAM entry carrying tma. */ + uint8_t sma_hash = 0, tma_hash = 0; + for (int i = 0; i < 6; i++) { + sma_hash ^= sma[i]; + tma_hash ^= tma[i]; + } + uint8_t c[60] = {0}; + auto sw = [](uint32_t v, uint32_t msk, uint32_t sh) { + return (v & msk) << sh; + }; + put_le32(c + 4, sw(addr_cam_idx, 0xff, 0) | sw(0, 0xff, 8) | + sw(r::ADDR_CAM_ENT_SHORT_SIZE, 0xff, 16)); + put_le32(c + 8, 0x1u | sw(net_type, 0x3, 1) | sw(r::MAC_AX_NO_MSK, 0x3, 14) | + sw(sma_hash, 0xff, 16) | sw(tma_hash, 0xff, 24)); + put_le32(c + 12, sw(bssid_cam_idx, 0x3f, 0)); + put_le32(c + 16, sw(sma[0], 0xff, 0) | sw(sma[1], 0xff, 8) | + sw(sma[2], 0xff, 16) | sw(sma[3], 0xff, 24)); + put_le32(c + 20, sw(sma[4], 0xff, 0) | sw(sma[5], 0xff, 8) | + sw(tma[0], 0xff, 16) | sw(tma[1], 0xff, 24)); + put_le32(c + 24, sw(tma[2], 0xff, 0) | sw(tma[3], 0xff, 8) | + sw(tma[4], 0xff, 16) | sw(tma[5], 0xff, 24)); + put_le32(c + 32, sw(macid, 0xff, 0) | (1u << 14)); + put_le32(c + 48, sw(bssid_cam_idx, 0xff, 0) | sw(0, 0xff, 8) | + sw(r::BSSID_CAM_ENT_SIZE, 0xff, 16)); + put_le32(c + 52, 0x1u | sw(r::MAC_AX_NO_MSK, 0x3f, 2) | sw(0, 0x3f, 8) | + sw(tma[0], 0xff, 16) | sw(tma[1], 0xff, 24)); + put_le32(c + 56, sw(tma[2], 0xff, 0) | sw(tma[3], 0xff, 8) | + sw(tma[4], 0xff, 16) | sw(tma[5], 0xff, 24)); + bool ok = send_h2c_cmd(r::FWCMD_H2C_CAT_MAC, r::FWCMD_H2C_CL_ADDR_CAM_UPDATE, + r::FWCMD_H2C_FUNC_ADDRCAM_INFO, c, sizeof(c)); + _logger->info("Kestrel: upd_addr_cam_sta (macid={} net_type={} a_idx={} " + "b_idx={} sma={:02x}:{:02x}:{:02x}:{:02x}:{:02x}:{:02x} " + "tma={:02x}:{:02x}:{:02x}:{:02x}:{:02x}:{:02x}) -> {}", + macid, net_type, addr_cam_idx, bssid_cam_idx, sma[0], sma[1], + sma[2], sma[3], sma[4], sma[5], tma[0], tma[1], tma[2], tma[3], + tma[4], tma[5], ok ? "sent" : "FAILED"); + return ok; +} + bool KestrelFw::fw_upd_cctl_basic(uint8_t macid, uint8_t addr_cam_idx, uint16_t datarate, uint8_t ntx_path_en, uint8_t path_map_a, bool bmc) { diff --git a/src/kestrel/KestrelFw.h b/src/kestrel/KestrelFw.h index 072d6413..aa90aa12 100644 --- a/src/kestrel/KestrelFw.h +++ b/src/kestrel/KestrelFw.h @@ -67,6 +67,13 @@ class KestrelFw { bool fw_role_maintain(uint8_t macid, uint8_t self_role, uint8_t wifi_role, uint8_t upd_mode, uint8_t band, uint8_t port); + /* rtw89_fw_h2c_join_info: H2C cat=MAC, class=MEDIA_RPT, func=JOININFO -- + * the firmware's "station connected" op (`connect` sends OP=0). Without it + * the firmware accepts a station's addr_cam H2C and does not commit it. + * Roles are mac_ax_self_role / mac_ax_wifi_role, net_type mac_ax_net_type. */ + bool fw_join_info(uint8_t macid, uint8_t net_type, uint8_t self_role, + uint8_t wifi_role, uint8_t port, uint8_t band, bool connect); + /* mac_upd_addr_cam (addr_cam.c): H2C cat=MAC, class=ADDR_CAM_UPDATE, * func=ADDRCAM_INFO — programs the address-CAM entry (BSSID+SMA+TMA+macid+ * net_type) for `macid`. `self_mac` is the 6-byte transmitter address (our @@ -76,6 +83,13 @@ class KestrelFw { bool fw_upd_addr_cam(uint8_t macid, const uint8_t self_mac[6], uint8_t net_type, uint8_t addr_cam_idx, uint8_t bssid_cam_idx); + /* The station form of the addr_cam, as the vendor Windows driver writes it on + * an association: SMA = our own address, TMA = the AP's BSSID (different), + * the SHORT entry length, and the BSSID CAM entry embedded with BSSID = tma. */ + bool fw_upd_addr_cam_sta(uint8_t macid, const uint8_t sma[6], + const uint8_t tma[6], uint8_t net_type, + uint8_t addr_cam_idx, uint8_t bssid_cam_idx); + /* mac_upd_cctl_info (tblupd.c): H2C cat=MAC, class=FR_EXCHG, func=CCTLINFO_UD * — the per-MACID CMAC control table. Sets the default TX rate, the TX * antenna path enable (ntx_path_en — without it a frame has NO antenna and diff --git a/src/kestrel/MacRegAx.h b/src/kestrel/MacRegAx.h index dfe96b6c..79b4aea9 100644 --- a/src/kestrel/MacRegAx.h +++ b/src/kestrel/MacRegAx.h @@ -626,6 +626,12 @@ constexpr uint8_t USR_TX_RPT_MODE_LAST_PKT = 2; * | WIFI_ROLE[16:13] | BAND[18:17] | PORT[21:19]. */ constexpr uint8_t FWCMD_H2C_CL_MEDIA_RPT = 0x8; constexpr uint8_t FWCMD_H2C_FUNC_FWROLE_MAINTAIN = 0x4; +/* JOININFO (same MEDIA_RPT class, func 0): the fw "STA connected" op. One dword + * w0 = MACID[7:0] | OP[8] (0=connect,1=disconnect) | BAND[9] | WMM[11:10] | + * TGR[12] | ISHESTA[13] | DLBW[15:14] | TF_MAC_PAD[17:16] | DL_T_PE[20:18] | + * PORT_ID[23:21] | NET_TYPE[25:24] | WIFI_ROLE[29:26] | SELF_ROLE[31:30] + * (rtw89 fw.c rtw89_fw_h2c_join_info, fw.h RTW89_H2C_JOININFO_W0_*). */ +constexpr uint8_t FWCMD_H2C_FUNC_JOININFO = 0x0; constexpr uint8_t H2C_FWROLE_SELF_ROLE_SH = 8; /* mask 0x3 */ constexpr uint8_t H2C_FWROLE_UPD_MODE_SH = 10; /* mask 0x7 */ constexpr uint8_t H2C_FWROLE_WIFI_ROLE_SH = 13; /* mask 0xf */ @@ -679,6 +685,11 @@ constexpr uint8_t FWCMD_H2C_FUNC_DCTLINFO_UD = 0x1; /* addr_cam entry geometry (addr_cam.h): 8852B uses the LONG entry (0x40). */ constexpr uint8_t ADDR_CAM_ENT_LONG_SIZE = 0x40; +/* The entry length the vendor driver writes for an associated station. */ +constexpr uint8_t ADDR_CAM_ENT_SHORT_SIZE = 0x20; +/* ADDR_CAM in the MAC table SRAM (rtw89 ADDR_CAM_BASE_ADDR), read through the + * FILTER_MODEL_ADDR / INDIR_ACCESS_ENTRY window. */ +constexpr uint32_t ADDR_CAM_MEM_BASE = 0x18850000; constexpr uint8_t BSSID_CAM_ENT_SIZE = 0x08; /* mac_ax_addr_msk_sel (mac_def.h). */ constexpr uint8_t MAC_AX_NO_MSK = 0; @@ -1567,6 +1578,17 @@ constexpr uint32_t WMAC_SPEC_SIFS_CCK = 0xA; constexpr uint8_t B_AX_WMAC_SPEC_SIFS_OFDM_SH = 8; constexpr uint32_t B_AX_WMAC_SPEC_SIFS_OFDM_MSK = 0xff; constexpr uint32_t WMAC_SPEC_SIFS_OFDM_52B = 0x11; +/* Response (ACK/CTS/BA) abort gates in the same register (rtw89 reg.h): the + * RMAC drops a SIFS response when an armed check reads the medium busy. */ +constexpr uint32_t B_AX_RSP_CHK_CCA = 1u << 23; +constexpr uint32_t B_AX_RSP_CHK_EDCCA = 1u << 24; +constexpr uint32_t B_AX_RSP_CHK_BTCCA = 1u << 25; +constexpr uint32_t B_AX_RSP_CHK_SEC_CCA_20 = 1u << 26; +constexpr uint32_t B_AX_RSP_CHK_SEC_CCA_40 = 1u << 27; +constexpr uint32_t B_AX_RSP_CHK_SEC_CCA_80 = 1u << 28; +constexpr uint32_t B_AX_RSP_CHK_ALL = + B_AX_RSP_CHK_CCA | B_AX_RSP_CHK_EDCCA | B_AX_RSP_CHK_BTCCA | + B_AX_RSP_CHK_SEC_CCA_20 | B_AX_RSP_CHK_SEC_CCA_40 | B_AX_RSP_CHK_SEC_CCA_80; constexpr uint16_t R_AX_RXTRIG_TEST_USER_2 = 0xCCB0; constexpr uint32_t B_AX_RXTRIG_FCSCHK_EN = 1u << 20; constexpr uint16_t R_AX_TRXPTCL_RRSR_CTL_0 = 0xCC08; diff --git a/src/kestrel/RtlKestrelDevice.cpp b/src/kestrel/RtlKestrelDevice.cpp index 4f0d8731..e9707a50 100644 --- a/src/kestrel/RtlKestrelDevice.cpp +++ b/src/kestrel/RtlKestrelDevice.cpp @@ -4,6 +4,8 @@ #include #include /* INT_MIN = "no radiotap DBM_TX_POWER" sentinel */ #include +#include +#include #include #include #include @@ -236,6 +238,25 @@ void RtlKestrelDevice::Init(Action_ParsedRadioPacket packetProcessor, _logger->error("Kestrel: monitor bring-up failed"); return; } + arm_configured_ack_responder(); /* DEVOURER_ACK_RESPONDER */ + /* Measurement-only live disarm, started after the arm so even a zero delay + * cannot clear before it. The stop token bounds shutdown on a long delay. */ + if (_ack_disarm_after_ms) { + const uint32_t delay_ms = *_ack_disarm_after_ms; + _ack_disarm_after_ms.reset(); + _ack_disarm_thread = std::jthread([this, delay_ms](std::stop_token stop) { + const auto deadline = std::chrono::steady_clock::now() + + std::chrono::milliseconds(delay_ms); + while (!stop.stop_requested() && + std::chrono::steady_clock::now() < deadline) + std::this_thread::sleep_for(std::chrono::milliseconds(25)); + if (stop.stop_requested()) + return; + _logger->info("DEVOURER_ACK_DISARM_AFTER_MS: disarming Kestrel ACK " + "responder {} ms after completed bring-up", delay_ms); + ClearAckResponder(); + }); + } StartRxLoop(std::move(packetProcessor)); } @@ -341,6 +362,7 @@ void RtlKestrelDevice::InitWrite(SelectedChannel channel) { * inside every send — no drain thread. */ if (_device.is_usb()) start_wp_drain(); + arm_configured_ack_responder(); /* DEVOURER_ACK_RESPONDER */ } void RtlKestrelDevice::StartRxLoop(Action_ParsedRadioPacket packetProcessor) { @@ -506,6 +528,110 @@ bool RtlKestrelDevice::GetCcaGates(bool &primary_disabled, return true; } +namespace { +/* The BSSID half of the station the ACK responder registers. Nothing matches + * on it (A1 alone selects the response), it only has to be a unicast address + * distinct from the responder's own. */ +constexpr uint8_t kAckResponderBssid[6] = {0x02, 0x00, 0x00, 0x00, 0x00, 0x01}; + +std::string mac_text(const uint8_t *m) { + char b[18]; + std::snprintf(b, sizeof b, "%02x:%02x:%02x:%02x:%02x:%02x", m[0], m[1], m[2], + m[3], m[4], m[5]); + return b; +} +} // namespace + +bool RtlKestrelDevice::SetAckResponder(const devourer::MacAddr &mac) { + namespace r = kestrel::reg; + const uint8_t *m = mac.bytes.data(); + if (m[0] & 0x01u) { + _logger->error("Kestrel: ACK responder needs a UNICAST MAC (I/G set in " + "{:02x}) - not armed", m[0]); + return false; + } + if (!_brought_up) { + _logger->error("Kestrel: ACK responder refused until bring-up " + "(Init/InitWrite) has finished"); + return false; + } + uint8_t bssid[6]; + std::memcpy(bssid, kAckResponderBssid, sizeof bssid); + if (std::memcmp(bssid, m, sizeof bssid) == 0) + bssid[5] ^= 0x02; + /* Clear the response-abort CCA gates: armed, the RMAC drops the SIFS ACK + * whenever a check reads the medium busy (measured: a co-channel neighbour + * AP, docs/kestrel-ack-responder.md). Saved once so a re-arm keeps the original. */ + const uint32_t resp0 = _device.rtw_read32(r::R_AX_TRXPTCL_RESP_0); + if (!_ack_responder) + _ack_resp0_saved = resp0; + const bool sent = _hal.register_infra_sta(m, bssid); + _device.rtw_write32(r::R_AX_TRXPTCL_RESP_0, resp0 & ~r::B_AX_RSP_CHK_ALL); + /* The H2Cs going out says nothing about the firmware committing them: read + * the physical CAM entry the RMAC matches against. The firmware applies the + * queue asynchronously, so poll for a bounded time rather than read once. */ + const auto t0 = std::chrono::steady_clock::now(); + bool committed = false; + int polls = 0; + while (sent && !(committed = _hal.addr_cam_holds(/*idx=*/0, m)) && + std::chrono::steady_clock::now() - t0 < std::chrono::milliseconds(100)) { + ++polls; + std::this_thread::sleep_for(std::chrono::milliseconds(1)); + } + const auto waited_us = std::chrono::duration_cast( + std::chrono::steady_clock::now() - t0).count(); + _logger->debug("Kestrel: ACK responder CAM read-back {} after {} us ({} " + "re-reads)", committed ? "matched" : "did not match", + waited_us, polls); + if (!sent || !committed) { + _logger->error("Kestrel: ACK responder for {} did not arm: the firmware " + "{} the ADDR_CAM entry (H2C {})", + mac_text(m), + sent ? "accepted but did not commit" : "refused", + sent ? "sent" : "failed"); + _ack_responder = mac; + ClearAckResponder(); + return false; + } + _ack_responder = mac; + _logger->info("Kestrel: hardware ACK responder armed for {} (ADDR_CAM " + "entry 0 committed, read back after {} us)", mac_text(m), + waited_us); + return true; +} + +void RtlKestrelDevice::ClearAckResponder() { + namespace r = kestrel::reg; + if (!_ack_responder) + return; + const devourer::MacAddr mac = *_ack_responder; + _ack_responder.reset(); + /* Back to what InitWrite registers for macid 0: the NO_LINK self entry, + * which the firmware does not commit -- so the RMAC stops answering. */ + _hal.register_sta_role(0, 0, 0); + _hal.add_self_sta(kInjectSA, /*macid=*/0); + _device.rtw_write32(r::R_AX_TRXPTCL_RESP_0, _ack_resp0_saved); + /* The rewrite is as asynchronous as the arm: give it the same bound. */ + const auto t0 = std::chrono::steady_clock::now(); + bool held = true; + while ((held = _hal.addr_cam_holds(/*idx=*/0, mac.bytes.data())) && + std::chrono::steady_clock::now() - t0 < std::chrono::milliseconds(100)) + std::this_thread::sleep_for(std::chrono::milliseconds(1)); + if (held) + _logger->warn("Kestrel: ACK responder clear did not take: ADDR_CAM entry " + "0 still holds {}, the MAC may keep answering for it", + mac_text(mac.bytes.data())); + else + _logger->info("Kestrel: hardware ACK responder disarmed (ADDR_CAM entry 0 " + "no longer holds {})", mac_text(mac.bytes.data())); +} + +void RtlKestrelDevice::arm_configured_ack_responder() { + if (_cfg.rx.ack_responder && !SetAckResponder(*_cfg.rx.ack_responder)) + throw std::runtime_error( + "Kestrel: configured ACK responder could not be armed"); +} + RxEnergy RtlKestrelDevice::GetRxEnergy(bool with_nhm) { RxEnergy e; /* no phydm FA/CCA/IGI DIG monitor on Kestrel */ /* DEVOURER_RX_NOISE_FLOOR — active/frame-free absolute floor via the halbb NHM @@ -937,14 +1063,17 @@ devourer::AdapterCaps RtlKestrelDevice::GetAdapterCaps() { * computed and discarded. No phydm FA/CCA/IGI monitor either. */ c.busy_airtime_ok = false; c.rx_energy_ok = false; - /* Hardware ARQ: SetAckResponder is not implemented on the AX generation - * (matrix-measured 0% closure) — that flag stays false. The retry knob IS + /* Hardware ARQ: SetAckResponder arms on the 8852B -- a committed INFRA + * station entry (HalKestrel::register_infra_sta), verified by CAM read-back. + * The 8852C firmware does not commit that entry, so its arm refuses and the + * flag stays false there. The retry knob IS * wired (WD DATA_TXCNT_LMT per frame, attempts-semantics folded to the * N-retries contract in send_packet; witness-measured on the 8832CU: * limits {0,2} -> modal {1,3} on-air copies exactly, limit 8 -> an 8/9 * near-tie consistent with ~90% witness capture of a 9-copy truth — * obedient, no wedge). */ c.tx_retry_limit_ok = true; + c.ack_responder_ok = _variant == kestrel::ChipVariant::C8852B; c.bw_mask = devourer::bw_mask_for_generation(c.generation); if (_variant == kestrel::ChipVariant::C8852C) c.bw_mask |= devourer::kBw160; /* 8852C-only (vendor bw_sup BW_CAP_160M) */ diff --git a/src/kestrel/RtlKestrelDevice.h b/src/kestrel/RtlKestrelDevice.h index ce5fa081..1c855635 100644 --- a/src/kestrel/RtlKestrelDevice.h +++ b/src/kestrel/RtlKestrelDevice.h @@ -67,6 +67,7 @@ class RtlKestrelDevice : public IRtlRadio { * demo shutdown). The destructor joins too, but the demos destroy the * device object after libusb_exit, so Stop() is the ordered join point. */ void Stop() override { + _ack_disarm_thread = {}; /* cancel + join a pending test disarm */ _rx_stop = true; stop_wp_drain(); pcie_quiesce(); @@ -83,6 +84,24 @@ class RtlKestrelDevice : public IRtlRadio { * IRtlRadio contract; SetCcaMode(d) == SetCcaGates(d, d). */ bool SetCcaGates(bool primary_disabled, bool edcca_disabled) override; bool GetCcaGates(bool &primary_disabled, bool &edcca_disabled) override; + /* Hardware ACK responder (IRadio contract). The AX MAC has no Jaguar-style + * port identity register to retarget: the RMAC answers for an address only + * once the firmware has COMMITTED a valid ADDR_CAM entry for it, and it does + * that only for a station registered the way a real association registers + * one (HalKestrel::register_infra_sta). The arm programs that station with + * `mac` as its own address, clears the response-abort CCA gates, and is + * verified by reading the physical CAM entry back -- false when the entry + * did not commit, which is the 8852C today. Post-bring-up only. Clear puts + * macid 0 back to the bring-up NO_LINK entry and verifies the CAM no + * longer holds `mac`. */ + bool SetAckResponder(const devourer::MacAddr &mac) override; + void ClearAckResponder() override; + /* rxdemo's live-disarm measurement hook (DEVOURER_ACK_DISARM_AFTER_MS): the + * delay starts once Init has finished the bring-up, including a configured + * SetAckResponder, never from process start. */ + void ScheduleAckResponderDisarmForTest(uint32_t delay_ms) { + _ack_disarm_after_ms = delay_ms; + } bool send_packet(const uint8_t *packet, size_t length) override; devourer::TxStats GetTxStats() override { return _device.GetTxStats(); } SelectedChannel GetSelectedChannel() override { return _channel; } @@ -275,6 +294,14 @@ class RtlKestrelDevice : public IRtlRadio { * ACH0 is channel 0, so "TX is up" is its own flag, not a non-zero handle. */ bool _tx_up = false; bool _brought_up = false; /* Init/InitWrite completed the MAC bring-up */ + /* Armed ACK responder address, and the R_AX_TRXPTCL_RESP_0 value the arm + * replaced (restored by ClearAckResponder). */ + std::optional _ack_responder; + uint32_t _ack_resp0_saved = 0; + std::optional _ack_disarm_after_ms; + std::jthread _ack_disarm_thread; + /* DEVOURER_ACK_RESPONDER: arm at the end of bring-up, throw if refused. */ + void arm_configured_ack_responder(); uint8_t _tx_mgmt_q = 0; uint8_t _tx_data_q = 0; bool _tx_data_ok = false; /* data frames may use _tx_data_q */ From 808d384f5f98a10cb3218a392178065afcdb0a35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Agust=C3=ADn=20Gonz=C3=A1lez?= Date: Wed, 14 Oct 2026 04:38:33 -0300 Subject: [PATCH 2/2] kestrel: retryable ACK responder clear, serialized arm/clear Review follow-ups on the 8852B ACK responder: - ClearAckResponder forgot the armed address before the NO_LINK rewrite was verified, so a clear that did not take could never be retried. The address is now dropped only once the CAM read-back confirms it is gone. - A failed retarget (A armed, arming B fails) recorded B and rolled back checking only B, while A could still be committed. The rollback now verifies both and keeps whichever is still held on record. - SetAckResponder and ClearAckResponder share a mutex: the DEVOURER_ACK_DISARM_AFTER_MS test disarm runs on its own thread. - The destructor joins that thread first, before quiescing the transport, for callers that destroy the device without Stop(). - The header documents that the responder shares macid 0 / ADDR_CAM entry 0 with injection, and docs/kestrel-ack-responder.md keeps the evidence and measurements but points at the header for the contract instead of restating it. TX20U Nano (8852B), rxdemo on ch149, DEVOURER_ACK_DISARM_AFTER_MS=2000: armed (read back after 2.2 ms), disarmed from the timer thread with the read-back confirming the entry no longer holds the address, no errors. The retry and retarget-rollback branches need a CAM write that fails to take and were not reproduced on hardware; the destructor ordering was not exercised (rxdemo exits through Stop()). --- docs/kestrel-ack-responder.md | 31 +++++++++----------- src/kestrel/RtlKestrelDevice.cpp | 49 ++++++++++++++++++++++++-------- src/kestrel/RtlKestrelDevice.h | 20 ++++++++++++- 3 files changed, 70 insertions(+), 30 deletions(-) diff --git a/docs/kestrel-ack-responder.md b/docs/kestrel-ack-responder.md index 3eb2b892..c383d893 100644 --- a/docs/kestrel-ack-responder.md +++ b/docs/kestrel-ack-responder.md @@ -4,22 +4,21 @@ How `SetAckResponder` arms the AX MAC's SIFS ACK on the 8852B, what was measured to be needed, and what is still open. The bench numbers are the `ack_responder_ok` row in `src/AdapterCaps.h`. -## Mechanism +## Evidence behind the mechanism -The AX MAC has no Jaguar-style port identity register to retarget: the RMAC -answers for an address only when the firmware has COMMITTED a valid -ADDR_CAM entry carrying it as SMA. `add_self_sta`'s NO_LINK entry is accepted -over H2C and never committed (the physical entry, read through -`FILTER_MODEL_ADDR`/`INDIR_ACCESS_ENTRY` at 0x18850000, stays valid=0), which -is why the responder used to measure 0%. What commits it is registering a -station the way a real association does (captured from the vendor Windows -driver over USBPcap): `join_info(INFRA, connect)` THEN the station addr_cam -(SMA = responder address, TMA = a BSSID, SHORT entry, BSSID CAM at index 1). -The join is the necessary step: without it the entry never commits. The -vendor's surrounding commands (role CLIENT, macid unpause, CMAC/DMAC tables, -H2C REC/DONE_ACK) were each dropped with no change in commit or ACK rate, so -`register_infra_sta` sends only the two. +What `SetAckResponder` / `ClearAckResponder` do and verify is documented once, +on their declaration in `src/kestrel/RtlKestrelDevice.h`. This page keeps what +was measured to get there. +- **Why it used to measure 0%.** `add_self_sta`'s NO_LINK entry is accepted + over H2C and never committed: the physical entry, read through + `FILTER_MODEL_ADDR`/`INDIR_ACCESS_ENTRY` at 0x18850000, stays valid=0. +- **What commits it** was captured from the vendor Windows driver over + USBPcap: `join_info(INFRA, connect)` THEN the station addr_cam (SHORT + entry, BSSID CAM at index 1). Without the join the entry never commits. The + vendor's surrounding commands (role CLIENT, macid unpause, CMAC/DMAC tables, + H2C REC/DONE_ACK) were each dropped with no change in commit or ACK rate, + so `register_infra_sta` sends only the two. - **The commit is asynchronous.** Typically read back ~2.2 ms after the H2Cs (a single read takes about that long over USB), once 19.5 ms. A one-shot read-back refused healthy arms, so `SetAckResponder` polls for up to 100 ms. @@ -27,9 +26,7 @@ H2C REC/DONE_ACK) were each dropped with no change in commit or ACK rate, so On ch149, shared with a neighbour AP, one of three runs without the clear dropped to 82.4% single-shot; three with it stayed at >= 99.6%. On a quiet ch161 it made no difference. -- **Clear** re-registers the bring-up NO_LINK entry (which does not commit) - and restores the gates; the read-back confirms the CAM no longer holds the - address. rxdemo's `DEVOURER_ACK_DISARM_AFTER_MS` exercises it. +- **Clear** is exercised live by rxdemo's `DEVOURER_ACK_DISARM_AFTER_MS`. - **8852C:** its firmware accepts both commands and leaves the entry invalid, so the arm refuses. The vendor's 8852C association sends far more before the addr_cam (BB/RF offload, port configuration); which of it the C needs is open. diff --git a/src/kestrel/RtlKestrelDevice.cpp b/src/kestrel/RtlKestrelDevice.cpp index e9707a50..62e6cf49 100644 --- a/src/kestrel/RtlKestrelDevice.cpp +++ b/src/kestrel/RtlKestrelDevice.cpp @@ -93,6 +93,9 @@ RtlKestrelDevice::RtlKestrelDevice(RtlAdapter device, Logger_t logger, } RtlKestrelDevice::~RtlKestrelDevice() { + /* Cancel + join a pending test disarm before the transport is quiesced + * under it (Stop() does the same; a caller may skip Stop()). */ + _ack_disarm_thread = {}; /* Inert on this generation (its send path is synchronous), but the invariant * belongs to every device: nothing gets released while a transfer the * transport still owns is outstanding. */ @@ -555,6 +558,7 @@ bool RtlKestrelDevice::SetAckResponder(const devourer::MacAddr &mac) { "(Init/InitWrite) has finished"); return false; } + std::lock_guard lock(_ack_mutex); uint8_t bssid[6]; std::memcpy(bssid, kAckResponderBssid, sizeof bssid); if (std::memcmp(bssid, m, sizeof bssid) == 0) @@ -589,8 +593,14 @@ bool RtlKestrelDevice::SetAckResponder(const devourer::MacAddr &mac) { mac_text(m), sent ? "accepted but did not commit" : "refused", sent ? "sent" : "failed"); - _ack_responder = mac; - ClearAckResponder(); + /* Roll back. On a retarget the previous address may still be committed + * (a refused H2C leaves it in place), so verify it is gone too; whatever + * is still held stays recorded for ClearAckResponder to retry. */ + devourer::MacAddr held[2] = {mac}; + size_t n = 1; + if (_ack_responder && _ack_responder->bytes != mac.bytes) + held[n++] = *_ack_responder; + _ack_responder = disarm_ack_locked({held, n}); return false; } _ack_responder = mac; @@ -601,29 +611,44 @@ bool RtlKestrelDevice::SetAckResponder(const devourer::MacAddr &mac) { } void RtlKestrelDevice::ClearAckResponder() { - namespace r = kestrel::reg; + std::lock_guard lock(_ack_mutex); if (!_ack_responder) return; const devourer::MacAddr mac = *_ack_responder; - _ack_responder.reset(); + /* Forgotten only once the CAM read-back confirms it; a failed clear stays + * armed on record so the next call retries. */ + _ack_responder = disarm_ack_locked({&mac, 1}); +} + +std::optional +RtlKestrelDevice::disarm_ack_locked(std::span held) { + namespace r = kestrel::reg; /* Back to what InitWrite registers for macid 0: the NO_LINK self entry, * which the firmware does not commit -- so the RMAC stops answering. */ _hal.register_sta_role(0, 0, 0); _hal.add_self_sta(kInjectSA, /*macid=*/0); _device.rtw_write32(r::R_AX_TRXPTCL_RESP_0, _ack_resp0_saved); /* The rewrite is as asynchronous as the arm: give it the same bound. */ + const auto still_held = [&]() -> const devourer::MacAddr * { + for (const auto &a : held) + if (_hal.addr_cam_holds(/*idx=*/0, a.bytes.data())) + return &a; + return nullptr; + }; const auto t0 = std::chrono::steady_clock::now(); - bool held = true; - while ((held = _hal.addr_cam_holds(/*idx=*/0, mac.bytes.data())) && + const devourer::MacAddr *left; + while ((left = still_held()) && std::chrono::steady_clock::now() - t0 < std::chrono::milliseconds(100)) std::this_thread::sleep_for(std::chrono::milliseconds(1)); - if (held) + if (left) { _logger->warn("Kestrel: ACK responder clear did not take: ADDR_CAM entry " - "0 still holds {}, the MAC may keep answering for it", - mac_text(mac.bytes.data())); - else - _logger->info("Kestrel: hardware ACK responder disarmed (ADDR_CAM entry 0 " - "no longer holds {})", mac_text(mac.bytes.data())); + "0 still holds {}, the MAC may keep answering for it " + "(ClearAckResponder retries)", mac_text(left->bytes.data())); + return *left; + } + _logger->info("Kestrel: hardware ACK responder disarmed (ADDR_CAM entry 0 " + "no longer holds {})", mac_text(held.front().bytes.data())); + return std::nullopt; } void RtlKestrelDevice::arm_configured_ack_responder() { diff --git a/src/kestrel/RtlKestrelDevice.h b/src/kestrel/RtlKestrelDevice.h index 1c855635..76982311 100644 --- a/src/kestrel/RtlKestrelDevice.h +++ b/src/kestrel/RtlKestrelDevice.h @@ -4,7 +4,9 @@ #include #include #include +#include #include +#include #include #include "TxMode.h" @@ -93,7 +95,15 @@ class RtlKestrelDevice : public IRtlRadio { * verified by reading the physical CAM entry back -- false when the entry * did not commit, which is the 8852C today. Post-bring-up only. Clear puts * macid 0 back to the bring-up NO_LINK entry and verifies the CAM no - * longer holds `mac`. */ + * longer holds `mac`; an address still held after a failed clear (or a + * failed retarget, which checks the old address too) stays recorded, so a + * later ClearAckResponder retries it. Arm and clear are serialized. + * + * The responder shares macid 0 / ADDR_CAM entry 0 with injection: while + * armed, injected frames (whose WD carries macid 0) go out under the + * station context the arm registered, and clear restores the bring-up + * entry. A second macid for the responder is untested: whether the + * firmware commits an entry other than 0 has not been measured. */ bool SetAckResponder(const devourer::MacAddr &mac) override; void ClearAckResponder() override; /* rxdemo's live-disarm measurement hook (DEVOURER_ACK_DISARM_AFTER_MS): the @@ -298,6 +308,14 @@ class RtlKestrelDevice : public IRtlRadio { * replaced (restored by ClearAckResponder). */ std::optional _ack_responder; uint32_t _ack_resp0_saved = 0; + /* Serializes SetAckResponder/ClearAckResponder: the test disarm below + * calls ClearAckResponder from its own thread. */ + std::mutex _ack_mutex; + /* Rewrite macid 0 to the NO_LINK entry, restore RESP_0 and poll the CAM + * until it holds none of `held`. Returns the address still held on + * timeout, nullopt once gone. Caller holds _ack_mutex. */ + std::optional + disarm_ack_locked(std::span held); std::optional _ack_disarm_after_ms; std::jthread _ack_disarm_thread; /* DEVOURER_ACK_RESPONDER: arm at the end of bring-up, throw if refused. */