diff --git a/docs/aggregation.md b/docs/aggregation.md index 1bc2c768..c4801b9c 100644 --- a/docs/aggregation.md +++ b/docs/aggregation.md @@ -202,7 +202,8 @@ CCX reports: responder ON = 100% delivered at mean 0.4 retries (67% first-try); OFF = 0% delivered, every frame pinned at the 12-retry limit. The retry distribution is the per-frame TX-side link-quality sensor. -The same responder is a hardware **BlockAck** responder: the MAC's +On the adapter combinations measured by `tests/ampdu_ba_check.sh`, the same +responder is a hardware **BlockAck** responder: the MAC's immediate-response engine generates a SIFS-timed BlockAck for a received A-MPDU addressed to its MACID, on the same MACID + net_type gate. So reliable-unicast **ACKed A-MPDU** works end to end — the TX runs `SetAmpduMode` @@ -212,7 +213,15 @@ aggregates deliver at 100% / mean 0.1 retries and ~27× the throughput of the responder-off case (where every aggregate re-airs to the retry limit). The `no_ack = true` default is the broadcast/FEC flavor (OpenIPC wfb — no responder, no re-air storm); `false` is the reliable-unicast flavor against a -BA responder. +BA responder. RTL8733B is also established as the **responder** by the +CCX-independent `tests/rtl8733b_blockack_onair.sh`: Jaguar2 `0bda:b812` TX, +RTL8733B `0bda:f72b` responder, and Jaguar1 `0bda:8812` passive witness at +ch36/MCS3. Armed, 128,702 unique aggregated payloads measured 1.001 witnessed +copies/frame and the witness decoded 14,402 addressed `0x94` BlockAck frames, +all with nonzero bitmaps. Active but unarmed, 1,605 payloads measured 12.720 +copies/frame at retry limit 12 and zero matching BlockAcks. Both arms had +`paggr >= 0.665` and aggregate bursts of 9. RTL8733B's own A-MPDU **TX** path +remains unported. Every MAC address in the loop must be **unicast** (I/G bit clear): the responder `mac` (an ACK/BlockAck cannot target a group address) and the TX diff --git a/docs/logging.md b/docs/logging.md index 26517d58..621b811a 100644 --- a/docs/logging.md +++ b/docs/logging.md @@ -91,6 +91,7 @@ Emitters: L = library, RX/TX/... = demo. Optional fields in [brackets]; | `rx.corrupt` | RX (`DEVOURER_RX_DUMP_ALL`) | len, crc, icv, rate, bw, stbc, ldpc, sgi, rssi[2], evm[2], snr[2] | | `rx.txhit` | RX, TX | hits, total_rx, len, seq, paggr, ppdu, rate, bw, stbc, ldpc, ppdu_type — canonical-SA (57:42:75:05:d6:00) matcher; rate/ldpc prove what encoding was decoded (8814A reports ldpc=0 always — no HW indicator); ppdu_type is the AX RXD format nibble (7=HE_SU, 8=HE_ERSU; 255 on pre-AX chips) | | `rx.seq` | RX, duplex (`DEVOURER_RX_PCTR`) | pctr, tsfl, seq, crc, paggr, ppdu, rate (hw rate index of this copy — retransmissions of one pctr can air at different rates, so per-copy rate + tsfl order reconstructs the fw fallback ladder on air, `tests/retry_ladder_probe.sh`) — the ground-truth per-frame delivery sequence for the RX-ring loss study: pctr is the u32 the txdemo QoS-Data path stamps at MPDU offset 26, so gaps in it are per-frame loss; paggr/ppdu carry the aggregate structure the host-vs-RF discriminator keys on. Lean by design (no body hex) so the emit can't perturb the pump thread. SA gate follows `DEVOURER_RX_AGG_SA` (required in duplex, whose canonical-SA `rx.frame` stream is a different transmitter), else canonical SA | +| `rx.blockack` | RX (`DEVOURER_RX_CONTROL`) | crc, rate, ctrl, start_seq, ra hex, ta hex, bitmap hex — one event per received 0x94 BlockAck control frame (bitmap carries the first 64 bits); narrow metadata-only stream used by `tests/rtl8733b_blockack_onair.sh` to prove the response subtype/address/bitmap without mirroring every A-MPDU data body | | `rx.parse_abort` | RX (every generation, always on) | t, off, buf_len, remaining, frame_len, drvinfo, shift, total (cumulative) — the RX descriptor walk abandoned the rest of a bulk-IN aggregate; semantics doc-commented at `src/RxParseAbort.h`. No event in a session = the walk never aborted | | `rx.ring` | L (`DEVOURER_RX_RING_MS`) | t, mode ("async"/"sync"/"reorder-pool"/"spsc-fat"), n_urbs, armed (URBs posted to the HCD and awaiting a frame — the depth that starves under a slow inline consumer), min_armed (low-water mark since the last emit), cb_max_us (worst inline-consume latency in the window), resubmit_fail, completions (cumulative URB callbacks), empties (cumulative callbacks that left the ring with zero posted URBs), pool_free (−1 = no host pool), qdepth (spsc-fat consumer-queue depth; 0 in the other ring modes), pool_dropped (cumulative received frames discarded at spsc-fat pool exhaustion under the `drop` policy — pool exhausted, or a failed re-arm, the latter also ticking resubmit_fail; each was already chip-ACKed, so a hardware-ARQ peer counts it delivered), pool_stalls (the `backpressure`-policy counterpart, cumulative URB park events: the payload still reaches the consumer, the ring shrinks and the chip declines further ACKs, so overload loss stays ARQ-visible — `DEVOURER_RX_POOL_EXHAUST`, default backpressure; `tests/arq_e2e_delivery.sh` measures both). Sync mode emits a reduced line (pool_free pinned at −1; no qdepth/pool_dropped/pool_stalls/completions/empties). The mechanism-proof telemetry: empties/completions is the host-starvation rate — near-0 under RF loss (the ring stays armed because frames don't arrive), high under host starvation (frames out-race resubmit and drain the ring). Counted in the callback, so robust to the pump-thread starvation that makes the periodic emit sparse — but blind while the pump itself is frozen: a stalled consumer drops frames these counters never see, which the per-frame `rx.seq` ledger exists to catch | | `rx.count` | TX (its RX thread) | total, len | diff --git a/docs/rtl8733b.md b/docs/rtl8733b.md index 4ad30a35..2686d828 100644 --- a/docs/rtl8733b.md +++ b/docs/rtl8733b.md @@ -372,35 +372,34 @@ a request a caller may be making only through an inherited environment. ## Hardware ARQ -Measured against an RTL8812AU peer on the validation unit. -Both directions of a reliable-unicast link now work on this die. +Measured on the `f72b` validation unit with an RTL8812AU peer and an +independent RTL8812CU witness. Both directions of a reliable-unicast link work +in these cells; the result is not a multi-sample or multi-peer qualification. | knob | state | evidence | | --- | --- | --- | -| `tx.ack_timeout_us` | honoured | REG_ACKTO read back at bring-up: `/128/33/200 -> 128/128/33/200` | -| `SetAckResponder` | ported, `ack_responder_ok = true` (normal-ACK singles; BlockAck untested) | 8733B as responder: armed 1736/1736 ACKed, retries_mean 0.00; re-armed on a different MAC 1736/1736; disarmed 0.00 with retries pinned at 12 | -| `tx.retry_limit` | live, `tx_retry_limit_ok = true` | airtime dose-response 0/3/12 -> 0.93/3.93/12.27 airings per frame (expected 1 + N), repeatable over a 0/3/12/0/12 ladder | -| `tx.report` (CCX) | **not** ported — firmware emits nothing | see Known gaps below | +| `tx.ack_timeout_us` | honoured for CCK and non-CCK | 0x640 and CCK 0x639 both read back 33/200; an 11M dead-peer run submitted 1513/1129 frames in 8 s respectively (expected direction, not a timing calibration) | +| `SetAckResponder` | ported, `ack_responder_ok = true` (normal ACK and BlockAck response) | singles: on 1725/1725 ACKed, retarget 1728/1728, off 0/1723 with retries pinned at 12; A-MPDU armed/active-unarmed: 1.001/12.720 witnessed copies per payload and 14402/0 addressed BlockAck frames, with aggregation proven in both arms | +| `tx.retry_limit` | live, `tx_retry_limit_ok = true` | dead-peer dose response 0/3/12 -> 1.00/4.00/12.32–12.33 witnessed copies per frame (expected 1 + N), repeated over a 0/3/12/0/12 ladder | +| soliciting TX | normal ACK recognition measured | MCS3 responder on/off: 1.032/12.948 copies per observed frame; 11M CCK: 1.002/11.908, with 92.3–100% witness coverage | +| `tx.report` (CCX) | **not** ported — no reports observed | missing H2C/MEDIA_STATUS_RPT path remains the leading explanation; see Known gaps below | Implementation detail, the register recipe and the full CCX bench narrative: `src/rtl8733b/CLAUDE.md` "Hardware ARQ". The cross-generation ARQ matrix this -die now joins is `docs/scheduled-mac.md`. - -The retry measurement could not use the 12/0/12 CCX A/B the Jaguars are judged -by, because this die has no `tx.report` to judge itself with; it counts airings -at a passive monitor instead (`tests/rtl8733b_retry_limit_onair.sh`), and takes -three dose levels rather than an on/off pair so that a straight line — not a -single delta that ambient conditions could fake — carries the claim. Its -counterparts: one physical unit and one peer generation, like every other -on-air claim in this section — note the document as a whole rests on two units, -but these ARQ cells ran on the `f72b` sample only; the monitor can only lose -airings, never invent them, so the measured ratio is a floor. Two different things account for the shortfalls, and only the smaller one is an -artefact: the `rx.txhit` readout quantizes to at most 99 airings (the event -fires on the first 10 hits then every 100th), which is <=0.066/frame at 1500 -frames and covers the 0 and 3 arms entirely. It does NOT cover the 12 arm — -12.27 against 13 is 0.73/frame, about 1095 airings, an order of magnitude past -that bound. That residue is monitor loss (or genuinely fewer airings), which is -why the ratio is reported as a floor and not a point estimate. +die is compared in is `docs/scheduled-mac.md`. + +The retry measurement counts airings at a passive monitor +(`tests/rtl8733b_retry_limit_onair.sh`) because this die has no `tx.report`. +It takes three dose levels rather than an on/off pair so that a straight line — +not a single delta that ambient conditions could fake — carries the claim. Its +counterparts: one physical RTL8733B unit, an RTL8812AU peer, and an RTL8812CU +passive witness. The document as a whole rests on two units, but these ARQ +cells ran on the `f72b` sample only. The harness uses a run-specific unicast SA +and counts every clean payload-counter event rather than the sampled and +quantized `rx.txhit` stream. At retry 12 the witness saw 12.32–12.33 rather +than the ideal 13 copies/frame; passive-monitor loss (or genuinely fewer +airings) keeps that number a conservative observation rather than an exact +retry count. ## Known gaps and deferred validation @@ -435,24 +434,21 @@ These results have **not** been claimed: the normal NIC image. - SGI remains disabled: a descriptor with its short-GI bit set submitted successfully, but an independent RTL8812AU decoded both broadcast and - unicast probes as long GI. STBC and LDPC remain rejected. A-MPDU and - throughput were not independently validated and remain unadvertised or - unsupported by this backend. Normal-ACK response IS ported and measured - (Hardware ARQ above); BlockAck response to an aggregate remains untested, as - does A-MPDU itself. -- No CCX path, so no `tx.report` events — and unlike every other gap here the - cause is the firmware, not a missing port. Everything under the backend's - control was verified correct on air (descriptor SPE_RPT/SW_DEFINE set at the - vendor's own bit positions, C2H decoded at the vendor's dword2[28], bulk-IN - delivery as the vendor uses, and the fw-offload C2H format this firmware - speaks per its own dispatch), yet with an RX loop live and 3160 frames - received the firmware returned **zero** C2H packets in any format — with and - without net_type armed, peer ACKing and silent. The outstanding lead is the - halmac H2C queue + a MEDIA_STATUS_RPT registering the descriptor MACID; this - backend has no H2C transport at all. The knob warns at bring-up instead of - stamping descriptors that buy nothing. Consequence: an 8733BU can be either - end of a hardware-ARQ link but cannot see per-frame delivery, so detecting a - departed peer needs an application-level timeout + unicast probes as long GI. STBC and LDPC remain rejected. RTL8733B A-MPDU + **TX** and throughput remain unvalidated and unported. Its responder side is + separate and measured: `tests/rtl8733b_blockack_onair.sh` proved BlockAck + response to incoming Jaguar2 A-MPDUs by an independent air-side retry A/B + (Hardware ARQ above). +- No CCX path, so no `tx.report` events. The descriptor request bits, + receive-side C2H marker, USB delivery path, and expected fw-offload format + were checked, yet a live RX loop saw zero C2H packets across 3160 received + frames. That does **not** isolate a firmware defect: this backend also lacks + the halmac H2C queue and MEDIA_STATUS_RPT needed to register the descriptor + MACID, which is the leading prerequisite to implement and test. The knob + warns at bring-up instead of implying reports will arrive. Consequence: the + measured `f72b` can be either end of a normal-ACK hardware-ARQ link, but it + cannot see per-frame delivery, so detecting a departed peer needs an + application-level timeout (`src/rtl8733b/CLAUDE.md` "Hardware ARQ"). - Fast retune is now ported and independently witnessed (intra-band, same-width; `src/rtl8733b/CLAUDE.md` has the measured contract and its diff --git a/docs/scheduled-mac.md b/docs/scheduled-mac.md index 2dcf6030..ec69b7e0 100644 --- a/docs/scheduled-mac.md +++ b/docs/scheduled-mac.md @@ -173,12 +173,18 @@ 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 RTL8733B is deliberately absent from this table: it is the one die that -runs closed-loop hardware ARQ with **no CCX report at all**, so none of these -columns can be filled for it as the soliciting TX. Its retry knob is measured -from the air instead (airings per submitted frame, 0/3/12 -> 0.93/3.93/12.27) -and its responder side is measured with this same harness pointed the other -way — `docs/rtl8733b.md` "Hardware ARQ". +The RTL8733B is deliberately absent from this CCX table: no `tx.report` events +arrive on that backend, so these columns cannot be filled for it. Independent +airtime evidence does cover its soliciting side: a passive RTL8812CU witness +measured 1.032 copies/frame with an RTL8812AU responder armed versus 12.948 +with it off at MCS3; at 11M CCK the corresponding values were 1.002/11.908. +Its dead-peer retry dose response was 0/3/12 -> 1.00/4.00/12.32–12.33 copies/frame. +The same unit's BlockAck responder was judged without CCX: a Jaguar2 +aggregating TX plus Jaguar1 passive witness measured armed/active-unarmed +1.001/12.720 copies per payload, with A-MPDU structure present in both arms; +the armed/active-unarmed control-frame counts were 14402/0 addressed +BlockAcks (`tests/rtl8733b_blockack_onair.sh`). All are one-RTL8733B bench +results; details are in `docs/rtl8733b.md`. 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 @@ -258,7 +264,7 @@ nonzero limit for absolute numbers; 8821AU row re-measured ch6): | 8821AU | 62% | 0% | works (94% closed-loop at retry 8) | | 8812EU | 98% | 0% | works | | 8812CU | 69% | 0% | works | -| 8733B | unmeasured | 0% | works (closed-loop 1736/1736 at retry 12; single-shot cell never run) | +| 8733B | unmeasured | 0% | works (closed-loop 1725/1725 at retry 12; single-shot cell never run) | | 8852CU (Kestrel) | 0% | 0% | not implemented on the AX generation | Unmeasured for lack of plugged hardware: 8821CU / PCIe 8821CE (recipe-shared @@ -309,9 +315,10 @@ carry this table per die. stalled spsc-fat pool drained ~3 k frames in one receipt interval; the 8192 default clears that bench worst case ~2.7×). 2. **Closed-loop hardware ACK + autonomous retry is GO on Jaguar1, Jaguar3 and - the RTL8733B** (100% delivery, retries ≈ 0.2–0.3; the 8733B closes the loop - as responder at 1736/1736 but has no CCX report of its own, so its TX-side - retry evidence is airtime rather than `tx.report` — `docs/rtl8733b.md`) + the RTL8733B** (the CCX delivery/retry figures apply to the Jaguars; the + one-sample 8733B closes the loop as responder at 1725/1725 and as soliciting + TX collapses from 12.948 to 1.032 witnessed copies/frame at MCS3, but has no + per-frame report of its own — `docs/rtl8733b.md`) including retargeting an arbitrary UE MAC mid-session (re-arm `SetAckResponder`, change the descriptor RA — both fully dynamic). Requires a nonzero diff --git a/examples/chanmig/main.cpp b/examples/chanmig/main.cpp index dcadab83..bb74f660 100644 --- a/examples/chanmig/main.cpp +++ b/examples/chanmig/main.cpp @@ -573,7 +573,11 @@ int main(int argc, char **argv) { /* link-derived unicast for the ACK responder */ devourer::MacAddr am{{0x57, 0x42, 0x75, static_cast(g_link >> 8), static_cast(g_link), 0x01}}; - dev->SetAckResponder(am); + if (!dev->SetAckResponder(am)) { + logger->error("chanmig: ACK responder arm was refused; aborting drone " + "bring-up instead of running with a silent responder"); + return 1; + } std::thread rx([&] { dev->StartRxLoop(drone_rx); }); /* synthetic video pump */ diff --git a/examples/rx/main.cpp b/examples/rx/main.cpp index 461b25cf..bfb48a1b 100644 --- a/examples/rx/main.cpp +++ b/examples/rx/main.cpp @@ -955,6 +955,31 @@ static void packetProcessor(const Packet &packet) { } } + /* DEVOURER_RX_CONTROL=1 — emit addressed BlockAck frames for + * air-side responder qualification. This is deliberately a narrow event, + * not DEVOURER_STREAM_OUT=1: the latter mirrors every data body and would + * make a saturated A-MPDU witness log unnecessarily huge. BlockAck control + * frames have FC subtype 0x94, RA at 4, TA at 10, BA control/start-sequence + * at 16/18 and the first 64 bitmap bits at 20. The CRC flag remains in + * the event so a harness can refuse corrupt control observations. */ + static const bool rx_control = + std::getenv("DEVOURER_RX_CONTROL") != nullptr; + if (rx_control && packet.Data.size() >= 28 && + (packet.Data[0] & 0xfcu) == 0x94u) { + const uint16_t ba_ctrl = static_cast(packet.Data[16]) | + (static_cast(packet.Data[17]) << 8); + const uint16_t start_seq = static_cast(packet.Data[18]) | + (static_cast(packet.Data[19]) << 8); + devourer::Ev(*g_ev, "rx.blockack") + .f("crc", packet.RxAtrib.crc_err ? 1 : 0) + .f("rate", packet.RxAtrib.data_rate) + .f("ctrl", ba_ctrl) + .f("start_seq", start_seq) + .hex("ra", packet.Data.data() + 4, 6) + .hex("ta", packet.Data.data() + 10, 6) + .hex("bitmap", packet.Data.data() + 20, 8); + } + if (g_rx_count == 1) { devourer::Ev(*g_ev, "init.timing") .f("stage", "demo.first_rx_frame") diff --git a/src/AckResponder.h b/src/AckResponder.h index 8d2c35ba..64cda594 100644 --- a/src/AckResponder.h +++ b/src/AckResponder.h @@ -12,14 +12,14 @@ * responder for one MAC address while everything else about monitor mode * (promiscuous RX, injection) is unchanged. * - * The SAME gate is a hardware BlockAck responder: the MAC's immediate-response - * engine generates a SIFS-timed BlockAck for a received A-MPDU addressed to - * its MACID, no ADDBA session state required (bench-proven, - * tests/ampdu_ba_check.sh). So this one knob enables both reliable-unicast - * ACK (for singles) and reliable-unicast A-MPDU (SetAmpduMode no_ack=false) - * on the peer. + * On the adapter combinations exercised by tests/ampdu_ba_check.sh, the SAME + * gate also enables the hardware BlockAck responder. RTL8733B has its own + * air-side proof in tests/rtl8733b_blockack_onair.sh: a Jaguar2 TX forms real + * A-MPDUs and an independent Jaguar1 witness observes retry copies collapse + * only while the RTL8733B responder is armed. * - * The registers are generation-neutral (same map on Jaguar1/2/3): + * The registers are generation-neutral (same map on Jaguar1/2/3 and + * RTL8733B): * 0x0610..0x0615 REG_MACID — the RA the ACK engine matches * 0x0618..0x061d REG_BSSID — port identity companion (the proven AP * recipe programs both) @@ -40,23 +40,65 @@ namespace devourer { namespace ack { -inline void enable(RtlAdapter &dev, const uint8_t mac[6]) { - dev.rtw_write(0x0610, (uint32_t)mac[0] | ((uint32_t)mac[1] << 8) | - ((uint32_t)mac[2] << 16) | - ((uint32_t)mac[3] << 24)); - dev.rtw_write16(0x0614, (uint16_t)(mac[4] | (mac[5] << 8))); - dev.rtw_write(0x0618, (uint32_t)mac[0] | ((uint32_t)mac[1] << 8) | - ((uint32_t)mac[2] << 16) | - ((uint32_t)mac[3] << 24)); - dev.rtw_write16(0x061c, (uint16_t)(mac[4] | (mac[5] << 8))); - const uint8_t nt = dev.rtw_read8(0x0102); - dev.rtw_write8(0x0102, static_cast((nt & ~0x03u) | 0x03u)); +inline bool enable(RtlAdapter &dev, const uint8_t mac[6]) noexcept { + try { + const uint8_t nt = dev.rtw_read8(0x0102); + /* Close the gate before changing identity. Besides avoiding a transient + * responder for a half-written MAC during retargeting, this makes every + * failed identity write leave the radio passive. */ + if (!dev.rtw_write8(0x0102, static_cast(nt & ~0x03u))) { + /* The transfer status is not state readback. Retry the safety clear using + * the value read before the failed transfer; callers verify it before + * reporting a failed arm as passive. */ + (void)dev.rtw_write8(0x0102, static_cast(nt & ~0x03u)); + return false; + } + if (!dev.rtw_write( + 0x0610, (uint32_t)mac[0] | ((uint32_t)mac[1] << 8) | + ((uint32_t)mac[2] << 16) | + ((uint32_t)mac[3] << 24)) || + !dev.rtw_write16(0x0614, (uint16_t)(mac[4] | (mac[5] << 8))) || + !dev.rtw_write( + 0x0618, (uint32_t)mac[0] | ((uint32_t)mac[1] << 8) | + ((uint32_t)mac[2] << 16) | + ((uint32_t)mac[3] << 24)) || + !dev.rtw_write16(0x061c, (uint16_t)(mac[4] | (mac[5] << 8)))) + return false; + if (dev.rtw_write8(0x0102, + static_cast((nt & ~0x03u) | 0x03u))) + return true; + /* A failed status does not prove the gate write had no side effect. Make a + * best-effort close before reporting failure; callers additionally verify + * the passive rollback. */ + (void)dev.rtw_write8(0x0102, static_cast(nt & ~0x03u)); + return false; + } catch (...) { + /* SetAckResponder is a bool contract. Its callers perform a verified + * rollback and report UNKNOWN state if transport reads remain unavailable. */ + return false; + } } /* Disarm: net_type back to No Link — the gate, so the MACID may stay. */ -inline void disable(RtlAdapter &dev) { +inline bool disable(RtlAdapter &dev) { const uint8_t nt = dev.rtw_read8(0x0102); - dev.rtw_write8(0x0102, static_cast(nt & ~0x03u)); + return dev.rtw_write8(0x0102, static_cast(nt & ~0x03u)); +} + +inline bool is_disabled(RtlAdapter &dev) { + return (dev.rtw_read8(0x0102) & 0x03u) == 0; +} + +inline bool disable_verified(RtlAdapter &dev) noexcept { + try { + (void)disable(dev); + /* Readback is the safety result: a control transfer may report failure even + * though the write landed, while a successful transfer alone proves no + * state. The caller only needs to know whether the active gate is closed. */ + return is_disabled(dev); + } catch (...) { + return false; + } } /* The MAC must be UNICAST: a station cannot ACK-target a group address, so an @@ -73,13 +115,18 @@ inline bool is_unicast(const uint8_t mac[6]) { return (mac[0] & 0x01u) == 0; } * matches on MACID, and 0x0618 is programmed only because the proven AP recipe * programs both. Verifying the two fields that gate the behaviour keeps this * honest without asserting on one that does not. */ -inline bool verify(RtlAdapter &dev, const uint8_t mac[6]) { - const uint32_t want_lo = (uint32_t)mac[0] | ((uint32_t)mac[1] << 8) | - ((uint32_t)mac[2] << 16) | ((uint32_t)mac[3] << 24); - const uint16_t want_hi = (uint16_t)(mac[4] | (mac[5] << 8)); - return (dev.rtw_read8(0x0102) & 0x03u) == 0x03u && - dev.rtw_read(0x0610) == want_lo && - dev.rtw_read16(0x0614) == want_hi; +inline bool verify(RtlAdapter &dev, const uint8_t mac[6]) noexcept { + try { + const uint32_t want_lo = + (uint32_t)mac[0] | ((uint32_t)mac[1] << 8) | + ((uint32_t)mac[2] << 16) | ((uint32_t)mac[3] << 24); + const uint16_t want_hi = (uint16_t)(mac[4] | (mac[5] << 8)); + return (dev.rtw_read8(0x0102) & 0x03u) == 0x03u && + dev.rtw_read(0x0610) == want_lo && + dev.rtw_read16(0x0614) == want_hi; + } catch (...) { + return false; + } } } /* namespace ack */ diff --git a/src/AdapterCaps.h b/src/AdapterCaps.h index 20e2a35f..7c41ad08 100644 --- a/src/AdapterCaps.h +++ b/src/AdapterCaps.h @@ -175,7 +175,7 @@ struct AdapterCaps { * an earlier "broken" verdict was a harness artifact: the responder's arm * was never verified, so a silently dead responder read as on=0/off=0), * 8822B, 8812C/8822C, 8812E/8822E (the 8811A rides the 8812 die path and - * inherits its row), 8733B (1736/1736 frames ACKed at retries_mean 0.00, + * inherits its row), 8733B (1725/1725 frames ACKed at retries_mean 0.00, * retarget-proof and disarm-proof — tests/ack_txreport_matrix.sh run with * the 8733B as the responder). False-as-unmeasured (the * vht_2g4_ok reading: unmeasured, not incapable): the 8821C — it shares @@ -183,7 +183,7 @@ struct AdapterCaps { * SetAckResponder is not implemented on the AX generation. * tx_retry_limit_ok: DEVOURER_TX_RETRY_LIMIT drives hardware autonomous * retransmission (measured 12/0/12 A/B: 8821AU, 8812BU, 8822CU; the 8733B - * by airtime dose-response instead, 0/3/12 -> 0.93/3.93/12.27 airings per + * by airtime dose-response instead, 0/3/12 -> 1.00/4.00/12.32–12.33 airings per * frame, because that die has no CCX path to judge its own frames * (tests/rtl8733b_retry_limit_onair.sh); Kestrel * 8832CU witness-measured — the AX WD DATA_TXCNT_LMT field counts diff --git a/src/DeviceConfig.h b/src/DeviceConfig.h index e517d7be..4fba451e 100644 --- a/src/DeviceConfig.h +++ b/src/DeviceConfig.h @@ -235,17 +235,19 @@ struct DeviceConfig { * per-bandwidth vendor value is 117 µs), so it also replaces the * per-chip / per-bandwidth vendor defaults (which ranged 33..128 µs * and made hardware-ARQ range silently die-dependent). The register: - * REG_ACKTO 0x640 on the 11ac generations and the RTL8733B (which - * overwrites its HALMAC vendor default at bring-up), R_AX_RSP_CHK_SIG - * 0xCC00 byte0 on Kestrel; the CTS window (REG_CTS2TO 0x641) is separate and - * untouched. Sizing: ~6.7 µs x round-trip km + ~50 µs ACK flight and - * detection margin; a longer window is NOT free — every retry of a - * LOST frame waits the full window, measured (dead RA, retry 8, max + * REG_ACKTO 0x640 on the 11ac generations; RTL8733B programs both 0x640 + * (OFDM/HT) and its CCK companion 0x639. Kestrel uses + * R_AX_RSP_CHK_SIG 0xCC00 byte0. The CTS window (REG_CTS2TO 0x641) is + * separate and untouched. Sizing: ~6.7 µs x round-trip km plus ~50 µs + * ACK flight/detection margin; a longer window is NOT free — every retry + * of a LOST frame waits the full window, measured (dead RA, retry 8, max * duty): 2719 write-offs/8 s at 33 µs vs 2015 at 128 vs 1507 at 255. * Bench proof the register gates the ARQ verdict: at 8 µs (below the * ACK's flight time) retries pin at the limit with 0% ok against a * live responder; at 128/255 the responder cell runs 100% ok, - * retries ~0. */ + * retries ~0. RTL8733B CCK direction check (11M, dead RA, retry 8, + * max duty, 8 s): 1513 submissions at 33 µs versus 1129 at 200 µs; + * this establishes that the CCK window is live, not precise timing. */ int ack_timeout_us = 128; /* env: DEVOURER_TX_RETRY_FALLBACK — "off" | unset. Unset = the firmware * fallback ladder with its own floor (the current behaviour, descriptors @@ -267,7 +269,9 @@ struct DeviceConfig { * `tx.report` events. The TX-side link sensor. On the HalMAC chips the * descriptor SW_DEFINE also carries a rotating 8-bit tag the report * echoes (per-frame correlation). Default off (descriptors - * byte-identical). Needs an RX loop to deliver the C2H reports. + * byte-identical). Needs an RX loop to deliver the C2H reports. RTL8733B + * is an explicit exception: its backend has no H2C/MEDIA_STATUS_RPT path, + * warns that the request is unsupported, and emits no `tx.report` events. * * Value = sampling divisor N: 1 requests a report on EVERY frame, N > 1 * on every Nth (0..255). The CCX emission path saturates at ~1.3–1.4 k diff --git a/src/IRtlDevice.h b/src/IRtlDevice.h index 2b20272a..f77b15a9 100644 --- a/src/IRtlDevice.h +++ b/src/IRtlDevice.h @@ -220,8 +220,10 @@ class IRtlDevice { * a peer TXing to `mac` with normal ack-policy gets hardware * retransmissions until the ACK (its tx.report shows retries~0). `mac` * must be unicast (I/G clear). Turning a passive monitor into an active - * transmitter is opt-in only — never a default. Returns false where - * unsupported. Clear = net_type back to No Link. */ + * transmitter is opt-in only — never a default. Returns false when + * unsupported or when arm/verification fails; false is not proof of passive + * state, so implementations log if rollback cannot be verified. Clear is a + * non-throwing best effort to return net_type to No Link. */ virtual bool SetAckResponder(const devourer::MacAddr &mac) { (void)mac; return false; diff --git a/src/jaguar1/RtlJaguarDevice.cpp b/src/jaguar1/RtlJaguarDevice.cpp index dc69850e..eca90ea9 100644 --- a/src/jaguar1/RtlJaguarDevice.cpp +++ b/src/jaguar1/RtlJaguarDevice.cpp @@ -73,8 +73,10 @@ void RtlJaguarDevice::InitWrite(SelectedChannel channel) { SetMonitorChannel(channel); _logger->info("In Monitor Mode"); - if (_cfg.rx.ack_responder) - SetAckResponder(*_cfg.rx.ack_responder); /* DEVOURER_ACK_RESPONDER */ + if (_cfg.rx.ack_responder && + !SetAckResponder(*_cfg.rx.ack_responder)) /* DEVOURER_ACK_RESPONDER */ + throw std::runtime_error( + "Jaguar1: configured ACK responder could not be armed"); /* Carrier-sense default: EDCCA + primary CCA enabled unless * DEVOURER_DIS_CCA. Always applied — the enable path is what programs @@ -768,7 +770,15 @@ bool RtlJaguarDevice::SetAckResponder(const devourer::MacAddr &mac) { } /* Hardware ACK responder (src/AckResponder.h) — same register recipe as * the HalMAC generations (0x610/0x618/0x102 are map-identical here). */ - devourer::ack::enable(_device, mac.data()); + if (!devourer::ack::enable(_device, mac.data())) { + if (!devourer::ack::disable_verified(_device)) { + _logger->error("Jaguar1: ACK responder arm failed and rollback did " + "not latch; hardware state is unknown"); + } else { + _logger->error("Jaguar1: ACK responder arm register write failed"); + } + return false; + } _logger->info("Jaguar1: hardware ACK responder armed for " "{:02x}:{:02x}:{:02x}:{:02x}:{:02x}:{:02x}", mac.bytes[0], mac.bytes[1], mac.bytes[2], mac.bytes[3], @@ -777,7 +787,10 @@ bool RtlJaguarDevice::SetAckResponder(const devourer::MacAddr &mac) { } void RtlJaguarDevice::ClearAckResponder() { - devourer::ack::disable(_device); + if (!devourer::ack::disable_verified(_device)) { + _logger->error("Jaguar1: ACK responder disarm did not latch"); + return; + } _logger->info("Jaguar1: hardware ACK responder disarmed (net_type=NoLink)"); } @@ -1356,8 +1369,10 @@ void RtlJaguarDevice::Init(Action_ParsedRadioPacket packetProcessor, StartWithMonitorMode(channel); SetMonitorChannel(channel); - if (_cfg.rx.ack_responder) - SetAckResponder(*_cfg.rx.ack_responder); /* DEVOURER_ACK_RESPONDER */ + if (_cfg.rx.ack_responder && + !SetAckResponder(*_cfg.rx.ack_responder)) /* DEVOURER_ACK_RESPONDER */ + throw std::runtime_error( + "Jaguar1: configured ACK responder could not be armed"); /* Carrier-sense default: EDCCA + primary CCA enabled unless * DEVOURER_DIS_CCA. Always applied — the enable path is what programs diff --git a/src/jaguar2/RtlJaguar2Device.cpp b/src/jaguar2/RtlJaguar2Device.cpp index 3922bcf3..1291c574 100644 --- a/src/jaguar2/RtlJaguar2Device.cpp +++ b/src/jaguar2/RtlJaguar2Device.cpp @@ -380,7 +380,15 @@ bool RtlJaguar2Device::SetAckResponder(const devourer::MacAddr &mac) { /* Hardware ACK responder (src/AckResponder.h): port identity + net_type so * the MAC auto-ACKs unicast frames to `mac`. Same registers the proven * StartBeacon/AP path programs, minus the beacon machinery. */ - devourer::ack::enable(_device, mac.data()); + if (!devourer::ack::enable(_device, mac.data())) { + if (!devourer::ack::disable_verified(_device)) { + _logger->error("Jaguar2: ACK responder arm failed and rollback did " + "not latch; hardware state is unknown"); + } else { + _logger->error("Jaguar2: ACK responder arm register write failed"); + } + return false; + } _logger->info("Jaguar2: hardware ACK responder armed for " "{:02x}:{:02x}:{:02x}:{:02x}:{:02x}:{:02x}", mac.bytes[0], mac.bytes[1], mac.bytes[2], mac.bytes[3], @@ -389,7 +397,10 @@ bool RtlJaguar2Device::SetAckResponder(const devourer::MacAddr &mac) { } void RtlJaguar2Device::ClearAckResponder() { - devourer::ack::disable(_device); + if (!devourer::ack::disable_verified(_device)) { + _logger->error("Jaguar2: ACK responder disarm did not latch"); + return; + } _logger->info("Jaguar2: hardware ACK responder disarmed (net_type=NoLink)"); } @@ -470,8 +481,10 @@ void RtlJaguar2Device::Init(Action_ParsedRadioPacket packetProcessor, } } - if (_cfg.rx.ack_responder) - SetAckResponder(*_cfg.rx.ack_responder); /* DEVOURER_ACK_RESPONDER */ + if (_cfg.rx.ack_responder && + !SetAckResponder(*_cfg.rx.ack_responder)) /* DEVOURER_ACK_RESPONDER */ + throw std::runtime_error( + "Jaguar2: configured ACK responder could not be armed"); apply_replay_wseq(); if (_cfg.debug.bb_dump) { @@ -689,8 +702,10 @@ void RtlJaguar2Device::InitWrite(SelectedChannel channel) { * center frequency. DEVOURER_CW_TONE_GAIN=0..31 sets RF 0x00[4:0]. */ if (_cfg.tx.cw_tone) StartCwTone(_cfg.tx.cw_tone_gain & 0x1F); - if (_cfg.rx.ack_responder) - SetAckResponder(*_cfg.rx.ack_responder); /* DEVOURER_ACK_RESPONDER */ + if (_cfg.rx.ack_responder && + !SetAckResponder(*_cfg.rx.ack_responder)) /* DEVOURER_ACK_RESPONDER */ + throw std::runtime_error( + "Jaguar2: configured ACK responder could not be armed"); if (_cfg.tx.ampdu) SetAmpduMode(*_cfg.tx.ampdu); /* DEVOURER_TX_AMPDU_MODE */ apply_replay_wseq(); diff --git a/src/jaguar3/RtlJaguar3Device.cpp b/src/jaguar3/RtlJaguar3Device.cpp index 93073ad6..77c70b51 100644 --- a/src/jaguar3/RtlJaguar3Device.cpp +++ b/src/jaguar3/RtlJaguar3Device.cpp @@ -134,8 +134,10 @@ void RtlJaguar3Device::Init(Action_ParsedRadioPacket packetProcessor, } } - if (_cfg.rx.ack_responder) - SetAckResponder(*_cfg.rx.ack_responder); /* DEVOURER_ACK_RESPONDER */ + if (_cfg.rx.ack_responder && + !SetAckResponder(*_cfg.rx.ack_responder)) /* DEVOURER_ACK_RESPONDER */ + throw std::runtime_error( + "Jaguar3: configured ACK responder could not be armed"); apply_replay_wseq(); /* DEVOURER_REPLAY_WSEQ — end of both bring-ups, * like Jaguar2's. */ if (_cfg.debug.bb_dump) { @@ -906,8 +908,10 @@ void RtlJaguar3Device::InitWrite(SelectedChannel channel) { _device.rtw_read32(a + 8), _device.rtw_read32(a + 12)); } _coex_thread = std::thread([this] { coex_runtime_loop(); }); - if (_cfg.rx.ack_responder) - SetAckResponder(*_cfg.rx.ack_responder); /* DEVOURER_ACK_RESPONDER */ + if (_cfg.rx.ack_responder && + !SetAckResponder(*_cfg.rx.ack_responder)) /* DEVOURER_ACK_RESPONDER */ + throw std::runtime_error( + "Jaguar3: configured ACK responder could not be armed"); if (_cfg.tx.ampdu) SetAmpduMode(*_cfg.tx.ampdu); /* DEVOURER_TX_AMPDU_MODE */ _logger->info("Jaguar3: ready for TX (monitor inject)"); @@ -2167,7 +2171,15 @@ bool RtlJaguar3Device::SetAckResponder(const devourer::MacAddr &mac) { * StartBeacon/AP path programs, minus the beacon machinery. Serialized on * _reg_mu like every other register-touching control call. */ std::lock_guard lk(_reg_mu); - devourer::ack::enable(_device, mac.data()); + if (!devourer::ack::enable(_device, mac.data())) { + if (!devourer::ack::disable_verified(_device)) { + _logger->error("Jaguar3: ACK responder arm failed and rollback did " + "not latch; hardware state is unknown"); + } else { + _logger->error("Jaguar3: ACK responder arm register write failed"); + } + return false; + } _logger->info("Jaguar3: hardware ACK responder armed for " "{:02x}:{:02x}:{:02x}:{:02x}:{:02x}:{:02x}", mac.bytes[0], mac.bytes[1], mac.bytes[2], mac.bytes[3], @@ -2177,7 +2189,10 @@ bool RtlJaguar3Device::SetAckResponder(const devourer::MacAddr &mac) { void RtlJaguar3Device::ClearAckResponder() { std::lock_guard lk(_reg_mu); - devourer::ack::disable(_device); + if (!devourer::ack::disable_verified(_device)) { + _logger->error("Jaguar3: ACK responder disarm did not latch"); + return; + } _logger->info("Jaguar3: hardware ACK responder disarmed (net_type=NoLink)"); } diff --git a/src/rtl8733b/CLAUDE.md b/src/rtl8733b/CLAUDE.md index 4dd5f0e0..e19a5206 100644 --- a/src/rtl8733b/CLAUDE.md +++ b/src/rtl8733b/CLAUDE.md @@ -279,27 +279,26 @@ knob is told too, and so it fires exactly once per bring-up. ## Hardware ARQ Two of the three hardware-ARQ knobs are measured true on this die; the third is -refused loudly for a reason that took a bench to find. - -The counterparts for everything measured below, stated once here because the -numbers that follow are uniformly favourable: **one physical unit and one peer -generation** (the `f72b` RTL8731BU against an RTL8812AU), the same caveat every -on-air claim in this subtree carries. Neither direction has been cross-checked -against a second responder die, the second `b733` sample has not run these -cells, and no vendor-driver A/B exists to compare against. The retry number in -particular is witnessed by a monitor that can only ever LOSE airings, never -invent them, so the measured ratio is a floor rather than a point estimate. - -`DeviceConfig::tx::ack_timeout_us` is honoured. The field's contract — range, clamp, default, register and the range -budget it buys — is doc-commented at its declaration in `src/DeviceConfig.h` -and is not restated here. What is specific to this backend: `init_wmac()` still -writes the vendor `0x21`, so `bring_up_to_phy` overwrites it from the config -afterwards (the vendor write stays because that MAC plane is shared verbatim -with `rtl8733bprobe`, which carries no `DeviceConfig`), and the write is read -back and logged rather than assumed: a knob reported without a readback cannot -be told apart from one the radio never received. Measured -`/128/33/200 -> 128/128/33/200`. The CCK companion 0x0639 keeps its -vendor value, as on every other generation. +refused loudly because the backend lacks the control path needed to complete +its diagnosis safely. + +The counterparts for everything measured below: **one physical RTL8733B +unit**, the `f72b` RTL8731BU, an RTL8812AU peer, and an RTL8812CU passive +witness. The second `b733` sample has not run these cells, no second responder +die has been cross-checked, and no vendor-driver A/B exists. These are strong +single-bench results, not population qualification. Witness copy counts are +conservative observations because a passive monitor can miss airings. + +`DeviceConfig::tx::ack_timeout_us` is honoured. The field's contract — range, +clamp, default, registers and range budget — is doc-commented at its +declaration in `src/DeviceConfig.h`. What is specific to this backend: +`init_wmac()` writes vendor defaults first, then `bring_up_to_phy` overwrites +and verifies both REG_ACKTO 0x0640 (OFDM/HT) and REG_ACKTO_CCK 0x0639. A +failure or mismatched readback aborts bring-up instead of reporting a partly +applied range knob. Both registers read back 33 and 200 in direct runs. At 11M +CCK to a dead RA, retry 8 and maximum duty, an 8-second run submitted 1513 +frames at 33 µs versus 1129 at 200 µs. That establishes the expected timing +direction; it is not a precise calibration of the register's timebase. **`SetAckResponder` is ported and measured.** The `src/AckResponder.h` recipe applies unchanged — not an assumption, the vendor's own port-0 descriptor names @@ -322,42 +321,49 @@ session ends with `teardown_power_down` off, the peer reads ack_rate 0.00 with retries pinned at 12. Measured against an RTL8812AU soliciting TX (`tests/ack_txreport_matrix.sh`, -8733B as RESPONDER): armed 1736/1736 frames ACKed at retries_mean 0.00; -re-armed on a **different** MAC, 1736/1736 again (the address is arbitrary, not -baked in); disarmed, 0.00 with retries pinned at the descriptor limit of 12. - -**What ran is normal-ACK response to unicast singles, and the claim scopes to -that.** `AckResponder.h` notes the same gate is also a hardware *BlockAck* -responder on the generations where that was proven; it is NOT proven here. -`tests/ampdu_ba_check.sh` was pointed at this die (8812CU aggregating TX, 8733B -responder) and came back indeterminate — armed and disarmed both read 0% -delivered at retries 0, so the control arm did not separate — which is the -documented consequence of per-frame CCX accounting not surviving AGG_EN -(`docs/aggregation.md`), not a verdict on this chip. A-MPDU is unported here -anyway. So: normal-ACK response measured; BlockAck response untested, pending -an A-MPDU-capable instrument that does not judge by `tx.report`. +8733B as RESPONDER): armed 1725/1725 reports ACKed at retries_mean 0.00; +re-armed on a **different** MAC, 1728/1728 again (the address is arbitrary, not +baked in); disarmed, 0/1723 successful reports with retries pinned at 12. + +Soliciting-TX ACK recognition is measured independently. +`tests/rtl8733b_arq_tx_onair.sh` puts the RTL8733B in the soliciting-TX role, +the RTL8812AU in the responder role, and uses an RTL8812CU only as a passive +payload-counter witness. At MCS3, responder on/off produced 1.032/12.948 +copies per observed frame with 99.7/100% coverage. At 11M CCK the result was +1.002/11.908 with 95.8/92.3% coverage. This proves that the RTL8733B recognizes +a real ACK and stops autonomous retry in these normal-ACK cells; it does not +substitute for the unavailable per-frame delivery report. + +**BlockAck response is measured without CCX.** Per-frame CCX accounting is not +a valid retry oracle under A-MPDU (`docs/aggregation.md`), so +`tests/rtl8733b_blockack_onair.sh` uses a Jaguar2 `0bda:b812` TX, this RTL8733B +as responder, and a Jaguar1 `0bda:8812` passive witness. At ch36/MCS3 with +retry limit 12, the fully initialized but unarmed control produced 1,605 unique +payloads at 12.720 copies/payload and zero matching BlockAck frames; armed +produced 128,702 at 1.001 plus 14,402 addressed `0x94` BlockAcks, every one +carrying a nonzero bitmap. Both arms were real A-MPDUs (`paggr` 0.665/1.000, +max burst 9). The control-frame event checks RA=the Jaguar2 TA and TA=the +configured RTL8733B MAC, so ambient BlockAcks do not count. This establishes +the RTL8733B responder on that measured combination only. RTL8733B A-MPDU +**TX** remains unported and unmeasured. **`tx.retry_limit` drives real autonomous retransmission** — `tx_retry_limit_ok` -is now true. It could not be measured the way the Jaguars were: that A/B reads -the TX side's own CCX reports, and this die has none. `tests/rtl8733b_retry_limit_onair.sh` -judges from the air instead — unicast to an unowned RA so no ACK ever returns, -a passive monitor counting airings per submitted frame — and takes a -dose-response rather than an on/off pair, because one pair could be ambient and -a straight line through three levels cannot. Measured 0 -> 0.93, 3 -> 3.93, -12 -> 12.27 airings/frame against an expected 1 + N, repeatable across a -0/3/12/0/12 ladder. Two different things account for the shortfalls, and only the smaller one is an -artefact: the `rx.txhit` readout quantizes to at most 99 airings (the event -fires on the first 10 hits then every 100th), which is <=0.066/frame at 1500 -frames and covers the 0 and 3 arms entirely. It does NOT cover the 12 arm — -12.27 against 13 is 0.73/frame, about 1095 airings, an order of magnitude past -that bound. That residue is monitor loss (or genuinely fewer airings), which is -why the ratio is reported as a floor and not a point estimate. - -**CCX / `tx.report` is NOT ported, and the reason is the firmware.** This is -the one entry on the Not-ported list whose cause is known but not fixable from -the descriptor side, so it is recorded in full to save the next person the -bench time. Everything under this backend's control was verified correct on -air: SPE_RPT is dword2[19] and SW_DEFINE dword6[11:0] via the generic halmac +is true. `tests/rtl8733b_retry_limit_onair.sh` judges from the air because this +die has no TX-side CCX reports: unicast to an unowned RA ensures no ACK ever +returns, while a passive monitor counts clean airings per submitted frame. A +dose-response rather than an on/off pair gives multi-level evidence. The +harness uses a run-specific unicast SA, counts every clean payload-counter +event, refuses fewer than three distinct levels or a missing zero baseline, +and rejects partial runs. Measured 0 -> 1.00, 3 -> 4.00, 12 -> 12.32–12.33 +copies/frame against expected 1 + N, repeatable across a 0/3/12/0/12 ladder. +The retry-12 shortfall from the ideal 13 may be passive-monitor loss or +genuinely fewer airings, so the ratio is reported as an observation, not an +exact hardware count. + +**CCX / `tx.report` is NOT ported, and its root cause is unresolved.** The +descriptor and receive-side investigation narrows the problem but does not +prove a firmware defect: SPE_RPT is dword2[19] and SW_DEFINE dword6[11:0] via +the generic halmac NIC macros the 8733B maps straight onto (`hal/halmac/halmac_tx_desc_chip.h` maps `SET_TX_DESC_{SPE_RPT,SW_DEFINE}_8733B` onto the non-V2 pair, not the 0x20/0x24 V2 placement), and a probe build confirmed the bit set in a live @@ -369,14 +375,16 @@ fw-offload format `parse_ccx_halmac` already decodes (`C2H_EXTEND` 0xFF + sub_cmd 0x0F, `hal/rtl8733b/rtl8733b_cmd.c`). With an RX loop live and 3160 frames received, the firmware returned **zero** C2H packets in any format — with and without a net_type armed, and with the peer both ACKing and silent. -The outstanding lead is the halmac H2C queue plus a MEDIA_STATUS_RPT -registering the descriptor MACID with the firmware; this backend has no H2C -transport at all, which is the gap to close first. Until then the knob warns -at bring-up rather than stamping descriptors that buy nothing — `tx.report` -requested on this die is refused out loud, not silently dropped. - -Consequence a consumer should plan around: an 8733BU can be **either end** of a -hardware-ARQ link, but it cannot see per-frame delivery. `TxReport.state == 1` +The leading missing prerequisite is the halmac H2C queue plus a +MEDIA_STATUS_RPT registering the descriptor MACID with the firmware; this +backend has no H2C transport. Until that path is implemented and tested, the +absence of reports cannot be assigned to firmware. The knob warns at bring-up +rather than stamping descriptors that are not known to produce anything — +`tx.report` requested on this die is refused out loud, not silently dropped. + +Consequence a consumer should plan around: the measured 8733BU can be **either +end** of a normal-ACK hardware-ARQ link, but it cannot see per-frame delivery. +`TxReport.state == 1` — the retry write-off that says a peer stopped ACKing — is unavailable here, so detecting a departed peer needs an application-level timeout. diff --git a/src/rtl8733b/Halmac8733bMac.cpp b/src/rtl8733b/Halmac8733bMac.cpp index 53659a5c..f89a20e3 100644 --- a/src/rtl8733b/Halmac8733bMac.cpp +++ b/src/rtl8733b/Halmac8733bMac.cpp @@ -681,12 +681,14 @@ void Halmac8733bMac::init_wmac() { _device.rtw_write8(kRegWmacOption1, 0x18); // 0x98 with early-drop disabled } -uint8_t Halmac8733bMac::set_ack_timeout_us(uint8_t microseconds) { - _device.rtw_write8(kRegAckTimeout, microseconds); - /* Read back and return what the register carries, so the caller can report - * the window the hardware actually holds: a value reported without a - * readback cannot be told apart from one the radio never received. */ - return _device.rtw_read8(kRegAckTimeout); +AckTimeoutState Halmac8733bMac::set_ack_timeout_us(uint8_t microseconds) { + const bool non_cck_ok = _device.rtw_write8(kRegAckTimeout, microseconds); + const bool cck_ok = _device.rtw_write8(kRegAckTimeoutCck, microseconds); + /* Read back both rate families. A successful control transfer alone does + * not prove that either register accepted the requested value. */ + return {.non_cck = _device.rtw_read8(kRegAckTimeout), + .cck = _device.rtw_read8(kRegAckTimeoutCck), + .writes_ok = non_cck_ok && cck_ok}; } void Halmac8733bMac::init_usb() { @@ -813,7 +815,8 @@ void Halmac8733bMac::stop() { * unicast frames with SIFS-timed ACKs after the session that armed it is * gone. Unconditional and sited here so no caller can reach _mac.stop() * without it; a no-op on a session (or on rtl8733bprobe) that never armed. */ - devourer::ack::disable(_device); + if (!devourer::ack::disable_verified(_device)) + _logger->warn("RTL8733B: ACK responder disarm did not latch during stop"); _device.rtw_write32(kRegRcr, 0); _device.rtw_write8( kRegTxdmaPqMap, diff --git a/src/rtl8733b/Halmac8733bMac.h b/src/rtl8733b/Halmac8733bMac.h index 0b82b1c0..fe89b401 100644 --- a/src/rtl8733b/Halmac8733bMac.h +++ b/src/rtl8733b/Halmac8733bMac.h @@ -102,6 +102,12 @@ struct MacState { bool matches_normal_usb3out() const; }; +struct AckTimeoutState { + uint8_t non_cck = 0; + uint8_t cck = 0; + bool writes_ok = false; +}; + /* RTL8733B HALMAC 87xx MAC/EFUSE plane: physical OTP, logical-map decode, * normal-mode queue/page allocation, protocol/EDCA/WMAC, USB RX-DMA, and TRX * lifecycle. The probe and production device deliberately share this path. */ @@ -112,11 +118,11 @@ class Halmac8733bMac { bool read_efuse(EfuseInfo &out); bool initialize(const EfuseInfo &efuse); bool configure_monitor_rx(bool keep_corrupted); - /* REG_ACKTO (0x0640) only, in microseconds. The caller owns the - * 1..255 clamp DeviceConfig::tx::ack_timeout_us documents; this is the - * register plane, and the CCK companion 0x0639 is deliberately left at - * its vendor value, matching the register the Jaguar backends touch. */ - uint8_t set_ack_timeout_us(uint8_t microseconds); + /* Program both response-window registers: REG_ACKTO (0x0640) for OFDM/HT + * and REG_ACKTO_CCK (0x0639) for CCK. Returns the actual readback plus the + * transport-write result for diagnostics; readback is authoritative because + * a failed transfer status does not prove that a write missed hardware. */ + AckTimeoutState set_ack_timeout_us(uint8_t microseconds); void stop(); MacState read_mac_state(); diff --git a/src/rtl8733b/Rtl8733bDevice.cpp b/src/rtl8733b/Rtl8733bDevice.cpp index 53408842..534f96d8 100644 --- a/src/rtl8733b/Rtl8733bDevice.cpp +++ b/src/rtl8733b/Rtl8733bDevice.cpp @@ -7,7 +7,7 @@ #include #include -#include "AckResponder.h" /* hardware ACK/BlockAck responder recipe */ +#include "AckResponder.h" /* hardware ACK responder recipe */ #include "RateDefinitions.h" #include "RadiotapPeek.h" /* send_packets batch pre-parse */ #include "RadiotapTxFlags.h" @@ -81,8 +81,9 @@ void Rtl8733bDevice::bring_up_to_phy() { /* ACK window (DEVOURER_ACK_TIMEOUT_US): the one library default every * generation programs identically, replacing the vendor value init_wmac just * wrote (0x21 = 33 us — the bottom of the 33..128 per-chip spread the single - * default exists to abolish). Same clamp and same register as jaguar1/2/3; - * see the DeviceConfig field doc for the range budget it buys. + * default exists to abolish). Same clamp as jaguar1/2/3, applied here to + * both the normal and CCK response-window registers; see the DeviceConfig + * field doc for the range budget it buys. * * Applied rather than left at the vendor value for the reason this backend * refuses knobs elsewhere (disable_cca, SetTxPowerIndexOverride): a config @@ -98,14 +99,22 @@ void Rtl8733bDevice::bring_up_to_phy() { _cfg.tx.ack_timeout_us > 255 ? 255 : _cfg.tx.ack_timeout_us < 1 ? 1 : _cfg.tx.ack_timeout_us); - const uint8_t ackto_got = _mac.set_ack_timeout_us(ackto_want); - if (ackto_got != ackto_want) - _logger->warn("RTL8733B: ACK window did not latch — REG_ACKTO reads {} us, " - "wanted {} us", - ackto_got, ackto_want); - else - _logger->info("RTL8733B: ACK window {} us (REG_ACKTO 0x640, verified)", - ackto_got); + const auto ackto = _mac.set_ack_timeout_us(ackto_want); + if (ackto.non_cck != ackto_want || ackto.cck != ackto_want) { + _logger->error( + "RTL8733B: ACK window did not latch — REG_ACKTO={} us " + "REG_ACKTO_CCK={} us, wanted {} us", + ackto.non_cck, ackto.cck, ackto_want); + throw std::runtime_error("RTL8733B: configured ACK window did not latch"); + } + if (!ackto.writes_ok) + _logger->warn( + "RTL8733B: ACK-window write reported a transport failure, but both " + "register readbacks match {} us", + ackto_want); + _logger->info( + "RTL8733B: ACK window {} us (REG_ACKTO 0x640 + CCK 0x639, verified)", + ackto_want); /* DEVOURER_ACK_RESPONDER — opt-in only, never a default: it turns a passive * monitor into an active SIFS-timed transmitter. Sited here with the other * bring-up knobs so an RX-only session (Init) arms it too, which is the @@ -141,12 +150,11 @@ void Rtl8733bDevice::bring_up_to_phy() { _logger->warn( "RTL8733B: DEVOURER_DIS_CCA / tuning.disable_cca is not implemented by " "this backend — carrier-sense stays ENABLED for this session"); - /* DEVOURER_TX_REPORT: the CCX per-frame TX-status path is not ported, and - * unlike the knobs above the reason is the firmware rather than a missing - * register — everything under this backend's control was verified correct on - * air and the chip still returned no C2H at all. The full bench narrative and - * the outstanding lead live in src/rtl8733b/CLAUDE.md "Hardware ARQ"; it is - * not repeated here. + /* DEVOURER_TX_REPORT: the CCX per-frame TX-status path is not ported. The + * descriptor and receive-side pieces were checked, but this backend lacks + * the H2C/MEDIA_STATUS_RPT path needed to register the MACID, so the observed + * absence of C2H reports is not assigned to firmware. The full bench + * narrative lives in src/rtl8733b/CLAUDE.md "Hardware ARQ". * * Warn rather than drop it silently: a consumer that sets this knob is * asking for its per-frame delivery sensor, and would otherwise read the @@ -155,8 +163,8 @@ void Rtl8733bDevice::bring_up_to_phy() { if (_cfg.tx.report) _logger->warn( "RTL8733B: DEVOURER_TX_REPORT / tx.report is not implemented by this " - "backend — the firmware emits no CCX reports, so no tx.report events " - "will arrive (see src/rtl8733b/CLAUDE.md)"); + "backend — no CCX reports were observed, and the H2C/MACID " + "registration path is missing (see src/rtl8733b/CLAUDE.md)"); } void Rtl8733bDevice::Init(Action_ParsedRadioPacket packetProcessor, @@ -837,17 +845,29 @@ bool Rtl8733bDevice::SetAckResponder(const devourer::MacAddr &mac) { mac.bytes[0]); return false; } - devourer::ack::enable(_device, mac.data()); - /* Verify with the shared readback rather than a local copy of the map: this - * backend does not report a write it cannot confirm (the standard SetCcaMode - * and SetTxPowerOffsetQdb are held to). Teardown disarms unconditionally in - * Halmac8733bMac::stop(), so a half-landed arm cannot outlive the session - * whatever this returns. */ + if (!devourer::ack::enable(_device, mac.data())) { + if (!devourer::ack::disable_verified(_device)) { + _logger->error( + "RTL8733B: ACK responder arm failed with hardware state UNKNOWN"); + } else { + _logger->error( + "RTL8733B: ACK responder register write failed — not armed"); + } + return false; + } + /* Verify with the shared readback rather than a local copy of the map: + * this backend does not report a write it cannot confirm. A failed arm is + * rolled back and the NoLink gate is read back before false is returned. */ if (!devourer::ack::verify(_device, mac.data())) { - _logger->error("RTL8733B: ACK responder did not latch for " - "{:02x}:{:02x}:{:02x}:{:02x}:{:02x}:{:02x}", - mac.bytes[0], mac.bytes[1], mac.bytes[2], mac.bytes[3], - mac.bytes[4], mac.bytes[5]); + if (!devourer::ack::disable_verified(_device)) { + _logger->error( + "RTL8733B: ACK responder verify failed with hardware state UNKNOWN"); + } else { + _logger->error("RTL8733B: ACK responder did not latch for " + "{:02x}:{:02x}:{:02x}:{:02x}:{:02x}:{:02x}", + mac.bytes[0], mac.bytes[1], mac.bytes[2], mac.bytes[3], + mac.bytes[4], mac.bytes[5]); + } return false; } _logger->info("RTL8733B: hardware ACK responder armed for " @@ -861,7 +881,10 @@ void Rtl8733bDevice::ClearAckResponder() { std::lock_guard lock(_reg_mu); if (!_mac_ready) return; - devourer::ack::disable(_device); + if (!devourer::ack::disable_verified(_device)) { + _logger->error("RTL8733B: ACK responder disarm did not latch"); + return; + } _logger->info("RTL8733B: hardware ACK responder disarmed (net_type=NoLink)"); } diff --git a/tests/rtl8733b_arq_tx_onair.sh b/tests/rtl8733b_arq_tx_onair.sh new file mode 100755 index 00000000..7809d021 --- /dev/null +++ b/tests/rtl8733b_arq_tx_onair.sh @@ -0,0 +1,176 @@ +#!/usr/bin/env bash +# Does an RTL8733B soliciting TX recognize a real hardware ACK and stop its +# autonomous retry loop? A third adapter witnesses copies per payload counter: +# responder ON must collapse each frame toward one airing; responder OFF must +# drive the same descriptor to 1 + RETRY_LIMIT airings. +# +# Three adapters are required: RTL8733B DUT, an ACK-capable responder, and an +# independent passive witness. RATE=11M CH=6 exercises the CCK path. +set -uo pipefail +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +BUILD=${BUILD:-$ROOT/build} +OUT=${OUT:-/tmp/rtl8733b-arq-tx} + +DUT_VID=${DUT_VID:-0x0bda}; DUT_PID=${DUT_PID:-0xf72b} +RESP_VID=${RESP_VID:-0x0bda}; RESP_PID=${RESP_PID:-0x8812} +WIT_VID=${WIT_VID:-0x0bda}; WIT_PID=${WIT_PID:-0xc812} +CH=${CH:-36}; RATE=${RATE:-MCS3}; FRAMES=${FRAMES:-1000} +GAP_US=${GAP_US:-5000}; RETRY_LIMIT=${RETRY_LIMIT:-12} +RESP_MAC=${RESP_MAC:-02:12:34:56:78:9a} +if [ -z "${TX_SA:-}" ]; then + run_id=$$ + printf -v TX_SA '02:73:33:%02x:%02x:%02x' \ + $(((run_id >> 16) & 255)) $(((run_id >> 8) & 255)) $((run_id & 255)) +fi + +if ! [[ "$FRAMES" =~ ^[0-9]+$ && "$RETRY_LIMIT" =~ ^[0-9]+$ ]] || + [ "$FRAMES" -lt 1000 ] || [ "$RETRY_LIMIT" -lt 5 ] || + [ "$RETRY_LIMIT" -gt 63 ]; then + echo "ABORT: require FRAMES>=1000 and RETRY_LIMIT=5..63" >&2 + exit 2 +fi + +# pkill/pgrep use EREs. Escape every ERE metacharacter so a build directory +# containing characters such as '+', '?', '(' or '|' remains an exact prefix. +ESC_BUILD=$(printf '%s' "$BUILD" | sed 's#[][\\.^$*+?(){}|/]#\\&#g') +cleanup() { + sudo pkill -9 -f "^$ESC_BUILD/rxdemo" 2>/dev/null + sudo pkill -9 -f "^$ESC_BUILD/txdemo" 2>/dev/null + return 0 +} +trap cleanup EXIT +mkdir -p "$OUT" +RESULTS="$OUT/results.jsonl"; : >"$RESULTS" + +stop_receivers() { + sudo pkill -INT -f "^$ESC_BUILD/rxdemo" 2>/dev/null || true + for _ in 1 2 3 4 5; do + if ! pgrep -f "^$ESC_BUILD/rxdemo" >/dev/null; then + return 0 + fi + sleep 1 + done + sudo pkill -9 -f "^$ESC_BUILD/rxdemo" 2>/dev/null || true + return 1 +} + +wait_rx() { + local err="$1" waited=0 + until grep -qE 'async ring of .* URBs submitted|Listening air' "$err"; do + sleep 1; waited=$((waited + 1)) + if [ "$waited" -ge 25 ]; then + echo "ABORT: RX process never became ready: $err" >&2 + tail -8 "$err" >&2 + exit 1 + fi + done +} + +run_phase() { + local phase="$1" responder="$2" + cleanup; sleep 2 + if [ "$responder" -eq 1 ]; then + # Redirects intentionally belong to the invoking user, not root. + # shellcheck disable=SC2024 + sudo env DEVOURER_VID="$RESP_VID" DEVOURER_PID="$RESP_PID" \ + DEVOURER_CHANNEL="$CH" DEVOURER_ACK_RESPONDER="$RESP_MAC" \ + DEVOURER_LOG_LEVEL=info "$BUILD/rxdemo" \ + >"$OUT/resp_$phase.jsonl" 2>"$OUT/resp_$phase.err" & + waited=0 + until grep -q "hardware ACK responder armed for $RESP_MAC" \ + "$OUT/resp_$phase.err"; do + sleep 1; waited=$((waited + 1)) + if [ "$waited" -ge 25 ]; then + echo "ABORT: responder never armed in phase=$phase" >&2 + tail -8 "$OUT/resp_$phase.err" >&2 + exit 1 + fi + done + fi + + # shellcheck disable=SC2024 + sudo env DEVOURER_VID="$WIT_VID" DEVOURER_PID="$WIT_PID" \ + DEVOURER_CHANNEL="$CH" DEVOURER_RX_PCTR=1 \ + DEVOURER_RX_AGG_SA="$TX_SA" DEVOURER_LOG_LEVEL=info \ + "$BUILD/rxdemo" >"$OUT/wit_$phase.jsonl" 2>"$OUT/wit_$phase.err" & + wait_rx "$OUT/wit_$phase.err" + 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_RA="$RESP_MAC" DEVOURER_TX_SA="$TX_SA" \ + DEVOURER_TX_RATE="$RATE" DEVOURER_TX_PAYLOAD_BYTES=200 \ + DEVOURER_TX_GAP_US="$GAP_US" DEVOURER_TX_FRAMES="$FRAMES" \ + DEVOURER_TX_RETRY_LIMIT="$RETRY_LIMIT" \ + DEVOURER_TX_PWR_OFFSET_QDB=12 DEVOURER_LOG_LEVEL=warn \ + timeout -s INT 90 "$BUILD/txdemo" \ + >"$OUT/tx_$phase.jsonl" 2>"$OUT/tx_$phase.err" || true + sleep 2 + if ! stop_receivers; then + echo "ABORT: phase=$phase receivers did not stop gracefully" >&2 + cleanup + exit 1 + fi + if grep -qE 'ACK responder disarm (did not latch|failed)|ACK responder disarm register write failed' \ + "$OUT/resp_$phase.err" 2>/dev/null; then + echo "ABORT: phase=$phase responder disarm was not verified" >&2 + tail -8 "$OUT/resp_$phase.err" >&2 + cleanup + exit 1 + fi + cleanup + + sent=$(grep '"ev":"tx.stats"' "$OUT/tx_$phase.jsonl" | tail -1 | + sed -n 's/.*"submitted":\([0-9]*\).*/\1/p') + sent=${sent:-0} + if [ "$sent" -ne "$FRAMES" ]; then + echo "ABORT: phase=$phase submitted $sent of $FRAMES frames" >&2 + exit 1 + fi + + python3 - "$phase" "$sent" "$OUT/wit_$phase.jsonl" >>"$RESULTS" <<'PY' +import collections, json, sys +phase, sent, path = sys.argv[1], int(sys.argv[2]), sys.argv[3] +copies = collections.Counter() +for line in open(path, errors="replace"): + if not line.startswith('{"ev":"rx.seq"'): + continue + try: + ev = json.loads(line) + except json.JSONDecodeError: + continue + if not ev.get("crc"): + copies[int(ev["pctr"])] += 1 +if not copies: + raise SystemExit("ABORT: witness recorded no payload counters") +total = sum(copies.values()) +print(json.dumps({"ev": "rtl8733b.arq_tx", "phase": phase, + "sent": sent, "observed_frames": len(copies), + "coverage": round(len(copies) / sent, 3), + "copies": total, + "copies_per_observed_frame": round(total / len(copies), 3)})) +PY + tail -1 "$RESULTS" +} + +run_phase on 1 +run_phase off 0 + +python3 - "$RESULTS" "$RETRY_LIMIT" "$RATE" <<'PY' +import json, sys +rows = {r["phase"]: r for r in map(json.loads, open(sys.argv[1]))} +limit = int(sys.argv[2]) +rate = sys.argv[3] +on, off = rows["on"], rows["off"] +on_c = on["copies_per_observed_frame"] +off_c = off["copies_per_observed_frame"] +ok = (on["coverage"] >= 0.60 and off["coverage"] >= 0.60 and + on_c <= 1.25 and 0.60 * (limit + 1) <= off_c <= 1.15 * (limit + 1) and + off_c >= 5 * on_c) +print(json.dumps({"ev": "rtl8733b.arq_tx.verdict", "ok": ok, + "rate": rate, "on_copies": on_c, "off_copies": off_c, + "on_coverage": on["coverage"], + "off_coverage": off["coverage"]})) +raise SystemExit(0 if ok else 1) +PY diff --git a/tests/rtl8733b_blockack_onair.sh b/tests/rtl8733b_blockack_onair.sh new file mode 100755 index 00000000..9250666c --- /dev/null +++ b/tests/rtl8733b_blockack_onair.sh @@ -0,0 +1,321 @@ +#!/usr/bin/env bash +# Does the RTL8733B immediate-response gate return a BlockAck for a received +# A-MPDU? A Jaguar2 TX forms normal-ack-policy A-MPDUs and a third adapter +# witnesses aggregate structure, copies per payload counter, and the response +# control frames themselves. +# +# This deliberately does not use tx.report: per-frame CCX accounting is not a +# valid retry oracle under A-MPDU (docs/aggregation.md). The air-side witness +# must first prove that both phases contain real aggregates (paggr plus bursts +# of >=4 MPDUs sharing one RX TSF), then show that arming the RTL8733B both +# collapses retry copies toward one and produces addressed 0x94 BlockAck frames +# with nonzero bitmaps. The active-but-unarmed control must approach +# 1 + RETRY_LIMIT copies and produce no such BlockAck. +# +# Three distinct adapters are required. Defaults match the local bench: +# TX 0bda:b812 RTL8822B/88x2BU, Jaguar2 +# RESP 0bda:f72b RTL8733B under test +# WITNESS 0bda:8812 RTL8812AU, Jaguar1 +# +# sudo bash tests/rtl8733b_blockack_onair.sh +set -uo pipefail +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +BUILD=${BUILD:-$ROOT/build} +OUT=${OUT:-/tmp/rtl8733b-blockack} + +TX_VID=${TX_VID:-0x0bda}; TX_PID=${TX_PID:-0xb812} +RESP_VID=${RESP_VID:-0x0bda}; RESP_PID=${RESP_PID:-0xf72b} +WIT_VID=${WIT_VID:-0x0bda}; WIT_PID=${WIT_PID:-0x8812} +CH=${CH:-36}; RATE=${RATE:-MCS3}; SECS=${SECS:-14} +RETRY_LIMIT=${RETRY_LIMIT:-12}; RESP_MAC=${RESP_MAC:-02:12:34:56:78:9a} +if [ -z "${TX_SA:-}" ]; then + run_id=$$ + printf -v TX_SA '02:ba:33:%02x:%02x:%02x' \ + $(((run_id >> 16) & 255)) $(((run_id >> 8) & 255)) $((run_id & 255)) +fi + +if ! [[ "$SECS" =~ ^[0-9]+$ && "$RETRY_LIMIT" =~ ^[0-9]+$ ]] || + [ "$SECS" -lt 10 ] || [ "$RETRY_LIMIT" -lt 5 ] || + [ "$RETRY_LIMIT" -gt 63 ]; then + echo "ABORT: require SECS>=10 and RETRY_LIMIT=5..63" >&2 + exit 2 +fi +min_secs=$((RETRY_LIMIT + 1)) +if [ "$min_secs" -lt 10 ]; then min_secs=10; fi +if [ "$SECS" -lt "$min_secs" ]; then + echo "ABORT: SECS=$SECS is too short for the 1000-payload off-arm floor" >&2 + echo " at RETRY_LIMIT=$RETRY_LIMIT; require at least $min_secs" >&2 + exit 2 +fi +if [ "$TX_VID:$TX_PID" = "$RESP_VID:$RESP_PID" ] || + [ "$TX_VID:$TX_PID" = "$WIT_VID:$WIT_PID" ] || + [ "$RESP_VID:$RESP_PID" = "$WIT_VID:$WIT_PID" ]; then + echo "ABORT: TX, responder, and witness must be distinct adapters" >&2 + exit 2 +fi + +# pkill/pgrep use EREs. Escape every ERE metacharacter so a build directory +# containing characters such as '+', '?', '(' or '|' remains an exact prefix. +ESC_BUILD=$(printf '%s' "$BUILD" | sed 's#[][\\.^$*+?(){}|/]#\\&#g') +cleanup() { + sudo pkill -9 -f "^$ESC_BUILD/rxdemo" 2>/dev/null + sudo pkill -9 -f "^$ESC_BUILD/txdemo" 2>/dev/null + return 0 +} +trap cleanup EXIT +mkdir -p "$OUT" +RESULTS="$OUT/results.jsonl"; : >"$RESULTS" + +wait_rx() { + local err="$1" waited=0 + until grep -qE 'async ring of .* URBs submitted' "$err"; do + sleep 1; waited=$((waited + 1)) + if [ "$waited" -ge 25 ]; then + echo "ABORT: RX process never became ready: $err" >&2 + tail -8 "$err" >&2 + exit 1 + fi + done +} + +stop_receivers() { + sudo pkill -INT -f "^$ESC_BUILD/rxdemo" 2>/dev/null || true + for _ in 1 2 3 4 5; do + if ! pgrep -f "^$ESC_BUILD/rxdemo" >/dev/null; then + return 0 + fi + sleep 1 + done + sudo pkill -9 -f "^$ESC_BUILD/rxdemo" 2>/dev/null || true + return 1 +} + +# Establish a known-passive RTL8733B before the responder-off control. Merely +# killing a previous responder process is not such a guarantee: SIGKILL cannot +# run Halmac8733bMac::stop(). A normal no-responder session plus its verified +# stop-time clear makes the negative arm independent of prior bench state. +force_responder_passive() { + cleanup; sleep 2 + # Redirects intentionally belong to the invoking user, not root. + # shellcheck disable=SC2024 + sudo env DEVOURER_VID="$RESP_VID" DEVOURER_PID="$RESP_PID" \ + DEVOURER_CHANNEL="$CH" DEVOURER_LOG_LEVEL=info "$BUILD/rxdemo" \ + >"$OUT/resp_passive.jsonl" 2>"$OUT/resp_passive.err" & + wait_rx "$OUT/resp_passive.err" + if ! stop_receivers; then + echo "ABORT: passive-reset receiver did not stop gracefully" >&2 + exit 1 + fi + if grep -qE 'ACK responder disarm (did not latch|failed)' \ + "$OUT/resp_passive.err"; then + echo "ABORT: passive-reset ACK gate clear was not verified" >&2 + tail -8 "$OUT/resp_passive.err" >&2 + exit 1 + fi + sleep 2 +} + +run_phase() { + local phase="$1" responder="$2" + local resp_pid wit_pid + local -a resp_env=() + cleanup; sleep 2 + if [ "$responder" -eq 1 ]; then + resp_env+=("DEVOURER_ACK_RESPONDER=$RESP_MAC") + fi + # Both arms keep the same RTL8733B process fully initialized and receiving; + # only DEVOURER_ACK_RESPONDER differs. A powered-down off arm would confound + # the gate with the entire active MAC/RX state. + # shellcheck disable=SC2024 + sudo env DEVOURER_VID="$RESP_VID" DEVOURER_PID="$RESP_PID" \ + DEVOURER_CHANNEL="$CH" "${resp_env[@]}" DEVOURER_LOG_LEVEL=info \ + "$BUILD/rxdemo" >"$OUT/resp_$phase.jsonl" 2>"$OUT/resp_$phase.err" & + resp_pid=$! + if [ "$responder" -eq 1 ]; then + waited=0 + until grep -q "hardware ACK responder armed for $RESP_MAC" \ + "$OUT/resp_$phase.err"; do + sleep 1; waited=$((waited + 1)) + if [ "$waited" -ge 25 ]; then + echo "ABORT: RTL8733B responder never armed in phase=$phase" >&2 + tail -8 "$OUT/resp_$phase.err" >&2 + exit 1 + fi + done + fi + wait_rx "$OUT/resp_$phase.err" + if ! kill -0 "$resp_pid" 2>/dev/null; then + echo "ABORT: RTL8733B responder process exited during phase=$phase init" >&2 + tail -8 "$OUT/resp_$phase.err" >&2 + exit 1 + fi + + # shellcheck disable=SC2024 + sudo env DEVOURER_VID="$WIT_VID" DEVOURER_PID="$WIT_PID" \ + DEVOURER_CHANNEL="$CH" DEVOURER_RX_PCTR=1 \ + DEVOURER_RX_AGG_SA="$TX_SA" DEVOURER_RX_CONTROL=1 \ + DEVOURER_LOG_LEVEL=info \ + "$BUILD/rxdemo" >"$OUT/wit_$phase.jsonl" 2>"$OUT/wit_$phase.err" & + wit_pid=$! + wait_rx "$OUT/wit_$phase.err" + if ! kill -0 "$wit_pid" 2>/dev/null; then + echo "ABORT: witness process exited during phase=$phase init" >&2 + tail -8 "$OUT/wit_$phase.err" >&2 + exit 1 + fi + sleep 2 + + # no_ack=0 keeps normal acknowledgement policy and the configured retry + # limit. QSEL 0 and the gapless feed let the Jaguar2 MAC form A-MPDUs. Keep + # one host feeder: on this bench a two-thread feed can fill the no-BA queue + # without presenting a measurable negative arm to the independent witness. + # shellcheck disable=SC2024 + sudo env DEVOURER_VID="$TX_VID" DEVOURER_PID="$TX_PID" \ + DEVOURER_CHANNEL="$CH" DEVOURER_TX_QOS_DATA=1 \ + DEVOURER_TX_RA="$RESP_MAC" DEVOURER_TX_SA="$TX_SA" \ + DEVOURER_TX_RATE="$RATE" DEVOURER_TX_PAYLOAD_BYTES=200 \ + DEVOURER_TX_GAP_US=0 DEVOURER_TX_RETRY_LIMIT="$RETRY_LIMIT" \ + DEVOURER_TX_AMPDU_MODE=0/16/7/0/20 \ + DEVOURER_LOG_LEVEL=warn timeout -s INT "$SECS" "$BUILD/txdemo" \ + >"$OUT/tx_$phase.jsonl" 2>"$OUT/tx_$phase.err" || true + if ! kill -0 "$resp_pid" 2>/dev/null; then + echo "ABORT: RTL8733B responder exited during phase=$phase TX" >&2 + tail -8 "$OUT/resp_$phase.err" >&2 + exit 1 + fi + if ! kill -0 "$wit_pid" 2>/dev/null; then + echo "ABORT: witness exited during phase=$phase TX" >&2 + tail -8 "$OUT/wit_$phase.err" >&2 + exit 1 + fi + sleep 2 + if ! stop_receivers; then + echo "ABORT: phase=$phase receivers did not stop gracefully" >&2 + cleanup + exit 1 + fi + if grep -qE 'ACK responder disarm (did not latch|failed)|ACK responder disarm register write failed' \ + "$OUT/resp_$phase.err" 2>/dev/null; then + echo "ABORT: phase=$phase responder disarm was not verified" >&2 + tail -8 "$OUT/resp_$phase.err" >&2 + cleanup + exit 1 + fi + cleanup + + sent=$(grep '"ev":"tx.stats"' "$OUT/tx_$phase.jsonl" | tail -1 | + sed -n 's/.*"submitted":\([0-9]*\).*/\1/p') + sent=${sent:-0} + if [ "$sent" -lt 500 ]; then + echo "ABORT: phase=$phase submitted only $sent frames" >&2 + tail -8 "$OUT/tx_$phase.err" >&2 + exit 1 + fi + + if ! python3 - "$phase" "$sent" "$OUT/wit_$phase.jsonl" \ + "$TX_SA" "$RESP_MAC" \ + >>"$RESULTS" <<'PY' +import collections, json, sys +phase, sent, path = sys.argv[1], int(sys.argv[2]), sys.argv[3] +tx_sa = sys.argv[4].replace(":", "").lower() +resp_mac = sys.argv[5].replace(":", "").lower() +events = [] +copies = collections.Counter() +blockacks = [] +for line in open(path, errors="replace"): + if line.startswith('{"ev":"rx.blockack"'): + try: + ba = json.loads(line) + except json.JSONDecodeError: + continue + if (not ba.get("crc") and ba.get("ra", "").lower() == tx_sa and + ba.get("ta", "").lower() == resp_mac): + blockacks.append(ba) + continue + if not line.startswith('{"ev":"rx.seq"'): + continue + try: + ev = json.loads(line) + except json.JSONDecodeError: + continue + if ev.get("crc"): + continue + events.append(ev) + copies[int(ev["pctr"])] += 1 +if len(copies) < 1000: + raise SystemExit(f"ABORT: witness recorded only {len(copies)} unique " + "clean payload counters; require 1000") + +bursts = [] +run = 1 +for prev, cur in zip(events, events[1:]): + if cur.get("tsfl") == prev.get("tsfl"): + run += 1 + else: + bursts.append(run) + run = 1 +bursts.append(run) +aggregated = sum(bool(e.get("paggr")) for e in events) +total = len(events) +nonzero_bitmaps = sum(ba.get("bitmap", "0" * 16) != "0" * 16 + for ba in blockacks) +compressed = sum(bool(int(ba.get("ctrl", 0)) & 0x4) for ba in blockacks) +compressed_nonzero = sum(bool(int(ba.get("ctrl", 0)) & 0x4) and + ba.get("bitmap", "0" * 16) != "0" * 16 + for ba in blockacks) +print(json.dumps({"ev": "rtl8733b.blockack", "phase": phase, + "sent": sent, "observed_frames": len(copies), + "copies": total, + "copies_per_observed_frame": round(total / len(copies), 3), + "paggr_ratio": round(aggregated / total, 3), + "mean_burst": round(sum(bursts) / len(bursts), 3), + "max_burst": max(bursts), + "blockacks": len(blockacks), + "compressed_blockacks": compressed, + "nonzero_bitmaps": nonzero_bitmaps, + "compressed_nonzero_bitmaps": compressed_nonzero})) +PY + then + exit 1 + fi + tail -1 "$RESULTS" +} + +force_responder_passive +run_phase off 0 +run_phase on 1 + +python3 - "$RESULTS" "$RETRY_LIMIT" "$RATE" <<'PY' +import json, sys +rows = {r["phase"]: r for r in map(json.loads, open(sys.argv[1]))} +limit, rate = int(sys.argv[2]), sys.argv[3] +on, off = rows["on"], rows["off"] +on_c, off_c = on["copies_per_observed_frame"], off["copies_per_observed_frame"] +structure = all(r["paggr_ratio"] >= 0.50 and r["max_burst"] >= 4 + for r in (on, off)) +closure = (on_c <= 1.25 and + 0.60 * (limit + 1) <= off_c <= 1.15 * (limit + 1) and + off_c >= 5 * on_c) +control = (on["blockacks"] >= 100 and + on["compressed_blockacks"] >= 100 and + on["nonzero_bitmaps"] >= 100 and + on["compressed_nonzero_bitmaps"] >= 100 and + off["blockacks"] == 0) +ok = structure and closure and control +print(json.dumps({"ev": "rtl8733b.blockack.verdict", "ok": ok, + "rate": rate, "aggregation_proven": structure, + "control_frames_proven": control, + "on_copies": on_c, "off_copies": off_c, + "on_paggr": on["paggr_ratio"], + "off_paggr": off["paggr_ratio"], + "on_max_burst": on["max_burst"], + "off_max_burst": off["max_burst"], + "on_blockacks": on["blockacks"], + "off_blockacks": off["blockacks"], + "on_compressed_blockacks": on["compressed_blockacks"], + "on_nonzero_bitmaps": on["nonzero_bitmaps"], + "on_compressed_nonzero_bitmaps": on["compressed_nonzero_bitmaps"], + "on_observed": on["observed_frames"], + "off_observed": off["observed_frames"]})) +raise SystemExit(0 if ok else 1) +PY diff --git a/tests/rtl8733b_retry_limit_onair.sh b/tests/rtl8733b_retry_limit_onair.sh index f4e259b1..732a7473 100755 --- a/tests/rtl8733b_retry_limit_onair.sh +++ b/tests/rtl8733b_retry_limit_onair.sh @@ -2,7 +2,7 @@ # rtl8733b_retry_limit_onair.sh — is DeviceConfig::tx::retry_limit a LIVE # actuator on the RTL8733B, or just an encoded descriptor field? # -# The 8733B has no CCX / tx.report path (the firmware emits no C2H reports — +# The 8733B has no working CCX / tx.report path (no C2H reports were observed — # src/rtl8733b/CLAUDE.md), so the TX side cannot be its own witness the way # tests/ack_txreport_matrix.sh judges the Jaguars. This bench judges from the # AIR instead: the DUT sends unicast QoS-Data to a MAC that nobody owns, so no @@ -15,14 +15,11 @@ # That is a DOSE-RESPONSE, not an A/B: a single on/off pair could be explained # by ambient conditions, three or more levels on a straight line cannot. # -# COUNTING NOTE: rxdemo emits rx.txhit sampled (first 10, then every 100th) — -# counting EVENTS undercounts by 100x. The event's own `hits` field is the -# cumulative truth, so we read the LAST hits value. That quantizes the total to -# the largest multiple of 100 <= H, i.e. H is understated by up to 99 airings -# (at the FRAMES_MIN floor below, <=0.099 airings/frame). Each arm gets a FRESH -# witness -# so `hits` starts at zero — the counter is static for the process lifetime and -# does not reset between arms. +# COUNTING NOTE: the witness emits one `rx.seq` event per received copy, keyed +# by a test-specific unicast SA and carrying txdemo's payload counter. This is +# deliberately not the sampled `rx.txhit` stream: counting those events would +# undercount by 100x, while reading their cumulative field would quantize every +# arm by up to 99 airings. Each arm gets a fresh witness and log. # # sudo bash tests/rtl8733b_retry_limit_onair.sh # ARMS="0 3 12" FRAMES=3000 CH=36 sudo bash tests/rtl8733b_retry_limit_onair.sh @@ -40,36 +37,74 @@ GAP_US=${GAP_US:-3000} PWR_QDB=${PWR_QDB:-12} # RA must be UNICAST (so an ACK is expected) and unowned (so none ever comes). RA=${RA:-02:de:ad:be:ef:01} +if [ -z "${TX_SA:-}" ]; then + run_id=$$ + printf -v TX_SA '02:73:33:%02x:%02x:%02x' \ + $(((run_id >> 16) & 255)) $(((run_id >> 8) & 255)) $((run_id & 255)) +fi OUT=${OUT:-/tmp/rtl8733b_retry} -# The readout is quantized to the last rx.txhit checkpoint (see COUNTING NOTE), -# so the error is a fixed <=99 airings whatever FRAMES is — which only stays -# negligible while FRAMES is large. At FRAMES=60 a healthy retry=0 arm airs ~55 -# times, the last checkpoint reads hits=10, and the arm scores 0.17 against a -# 0.6 floor: a FAIL on working hardware. Refuse below a floor rather than -# report that, for the same reason an arm that did not run is refused instead -# of reported as zero. -FRAMES_MIN=${FRAMES_MIN:-1000} -if [ "$FRAMES" -lt "$FRAMES_MIN" ]; then - echo "ABORT: FRAMES=$FRAMES is below FRAMES_MIN=$FRAMES_MIN — the rx.txhit" >&2 - echo " readout quantizes to <=99 airings, which at this size is a" >&2 - echo " false FAIL on healthy hardware, not a measurement." >&2 +# A capability verdict requires an actual dose-response: baseline zero plus at +# least two distinct nonzero doses, with one dose >=3. Without this guard, +# ARMS=0 could certify tx_retry_limit_ok after proving only that frames air. +read -r -a raw_arm_values <<< "$ARMS" +arm_values=() +declare -A arm_seen=() +have_zero=0; max_arm=-1 +for arm_text in "${raw_arm_values[@]}"; do + if ! [[ "$arm_text" =~ ^[0-9]+$ ]]; then + echo "ABORT: retry arm '$arm_text' is outside the descriptor range 0..63" >&2 + exit 2 + fi + arm=$((10#$arm_text)) + if [ "$arm" -gt 63 ]; then + echo "ABORT: retry arm '$arm_text' is outside the descriptor range 0..63" >&2 + exit 2 + fi + arm_values+=("$arm") + arm_seen[$arm]=1 + [ "$arm" -eq 0 ] && have_zero=1 + [ "$arm" -gt "$max_arm" ] && max_arm=$arm +done +if [ "${#arm_seen[@]}" -lt 3 ] || [ "$have_zero" -ne 1 ] || + [ "$max_arm" -lt 3 ]; then + echo "ABORT: ARMS must contain baseline 0 and at least two distinct" >&2 + echo " nonzero retry levels (one >=3); got: '$ARMS'" >&2 exit 2 fi -KILL(){ sudo pkill -9 -x rxdemo 2>/dev/null; sudo pkill -9 -x txdemo 2>/dev/null; return 0; } +# Fixed statistical floor, not an overrideable tuning knob. +FRAMES_MIN=1000 +if ! [[ "$FRAMES" =~ ^[0-9]+$ ]] || [ "$FRAMES" -lt "$FRAMES_MIN" ]; then + echo "ABORT: FRAMES=$FRAMES is invalid or below the validity floor $FRAMES_MIN" >&2 + exit 2 +fi + +# Kill only this tree's demos; do not terminate unrelated bench sessions. +# pkill uses an ERE. Escape every ERE metacharacter so a build directory +# containing characters such as '+', '?', '(' or '|' remains an exact prefix. +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 + return 0 +} trap KILL EXIT mkdir -p "$OUT"; RESULTS="$OUT/results.jsonl"; : >"$RESULTS" idx=0 -for arm in $ARMS; do +for arm in "${arm_values[@]}"; do idx=$((idx+1)) # Per-ARM-INDEX filenames, not per-retry-value: a ladder repeats values # (0/3/12/0/12) and reusing the value as the name lets a slow-dying witness # from the earlier arm append into the next one's log. tag="$(printf '%02d_r%s' "$idx" "$arm")" KILL; sleep 3 # USB release after a -9 is not instantaneous - sudo env DEVOURER_VID=$WIT_VID DEVOURER_PID=$WIT_PID DEVOURER_CHANNEL=$CH \ + # Redirects intentionally belong to the invoking user, not root. + # shellcheck disable=SC2024 + sudo env DEVOURER_VID="$WIT_VID" DEVOURER_PID="$WIT_PID" \ + DEVOURER_CHANNEL="$CH" DEVOURER_RX_PCTR=1 \ + DEVOURER_RX_AGG_SA="$TX_SA" \ DEVOURER_LOG_LEVEL=info \ "$BUILD/rxdemo" >"$OUT/wit_$tag.jsonl" 2>"$OUT/wit_$tag.err" & waited=0 @@ -81,18 +116,34 @@ for arm in $ARMS; do fi done sleep 2 - sudo env DEVOURER_VID=$DUT_VID DEVOURER_PID=$DUT_PID DEVOURER_CHANNEL=$CH \ - DEVOURER_TX_QOS_DATA=1 DEVOURER_TX_RA=$RA \ - DEVOURER_TX_RATE=$RATE DEVOURER_TX_PAYLOAD_BYTES=200 \ - DEVOURER_TX_GAP_US=$GAP_US DEVOURER_TX_FRAMES=$FRAMES \ - DEVOURER_TX_RETRY_LIMIT=$arm DEVOURER_TX_PWR_OFFSET_QDB=$PWR_QDB \ + # shellcheck disable=SC2024 + sudo env DEVOURER_VID="$DUT_VID" DEVOURER_PID="$DUT_PID" \ + DEVOURER_CHANNEL="$CH" DEVOURER_TX_QOS_DATA=1 \ + DEVOURER_TX_RA="$RA" DEVOURER_TX_SA="$TX_SA" \ + DEVOURER_TX_RATE="$RATE" DEVOURER_TX_PAYLOAD_BYTES=200 \ + DEVOURER_TX_GAP_US="$GAP_US" DEVOURER_TX_FRAMES="$FRAMES" \ + DEVOURER_TX_RETRY_LIMIT="$arm" DEVOURER_TX_PWR_OFFSET_QDB="$PWR_QDB" \ DEVOURER_LOG_LEVEL=warn \ timeout -s INT 90 "$BUILD/txdemo" >"$OUT/tx_$tag.jsonl" 2>"$OUT/tx_$tag.err" || true sleep 3 sent=$(grep '"ev":"tx.stats"' "$OUT/tx_$tag.jsonl" | tail -1 | sed -n 's/.*"submitted":\([0-9]*\).*/\1/p'); sent=${sent:-0} - hits=$(grep -o '"ev":"rx.txhit","hits":[0-9]*' "$OUT/wit_$tag.jsonl" | tail -1 | - sed -n 's/.*"hits":\([0-9]*\).*/\1/p'); hits=${hits:-0} + hits=$(python3 - "$OUT/wit_$tag.jsonl" <<'PY' +import json, sys + +hits = 0 +for line in open(sys.argv[1], errors="replace"): + if not line.startswith('{"ev":"rx.seq"'): + continue + try: + event = json.loads(line) + except json.JSONDecodeError: + continue + if not event.get("crc"): + hits += 1 +print(hits) +PY + ) KILL # A cell that did not run is NOT a measurement of zero. An arm whose DUT # never opened (sent=0) or whose witness heard nothing at all (hits=0) is a @@ -105,8 +156,10 @@ for arm in $ARMS; do tail -5 "$OUT/tx_$tag.err" >&2 exit 1 fi - if [ "$sent" -ne "$FRAMES" ]; then - echo "WARN: arm=$arm (#$idx) submitted $sent of $FRAMES requested" >&2 + if [ "$sent" -ne "$FRAMES" ] || [ "$sent" -lt "$FRAMES_MIN" ]; then + echo "ABORT: arm=$arm (#$idx) submitted $sent of $FRAMES requested;" >&2 + echo " partial runs do not meet the $FRAMES_MIN-frame validity floor" >&2 + exit 1 fi python3 - "$arm" "$sent" "$hits" >>"$RESULTS" <<'PY' import json, sys @@ -126,9 +179,9 @@ rows = [json.loads(l) for l in open(sys.argv[1])] ok = True for r in rows: exp, got = r["expected"], r["airings_per_frame"] - # Generous band: airings can only be LOST (a monitor misses frames), never - # invented, so the floor is what matters. 0.6*expected still separates - # every adjacent level in a 0/3/12 ladder. + # The SA filter excludes ambient traffic; the witness can still lose + # copies, so the floor is what matters. 0.6*expected separates every + # adjacent level in the required 0/3/12-style ladder. good = 0.6 * exp <= got <= 1.15 * exp ok &= good print(f" retry={r['retry_limit']:>2} expected~{exp:>2} measured={got:>5}"