A scheduled, cellular-style MAC on devourer — collision-free TDMA downlink slots, a grant map carried in the beacon body, per-frame delivery detection with hardware retransmission, per-UE link adaptation — rests on four radio capabilities. Each section states the capability, how it was measured on hardware, and the resulting per-generation contract a scheduler can build on. The shared-clock and TBTT machinery these lean on is time-distribution.md; the multi-cell coordination story they add up to is multi-ap-cellular.md.
A scheduled MAC that promises collision-free downlink slots needs to know how
long after the host calls send_packet the frame is actually on the air —
per transport, as a distribution, not an average. This is that
measurement and its per-transport contract.
One witness receiver captures, from the SAME frame, the transmitter's embedded
submit stamps and its own hardware RX timestamp (RxAtrib.tsfl, MAC-latched):
- TD v2 tag (
examples/tdma/tdma.h, version byte 2):tx_tsf= ReadTsf near send (TX hardware clock) +host_ns=steady_clockimmediately before thesend_packetcall (the clock a host-side slot scheduler actually controls). tests/txegress_analyze.pyleast-squares fitsrx_tsflagainst each stamp; the line absorbs clock offset, crystal skew and propagation, so the residual is per-frame submit→air jitter. Robust (3×MAD) rejection separates the floor (frames that aired immediately = transport + MAC-pipeline jitter) from the tail (channel deferral — which a scheduler must budget too).- The contract number:
guard_us= p99.9 of all host-clock residuals above the median, emitted as a machine-checkabletxeg.verdictJSONL line with p50/p90/p99/p99.9/max and afine_dl_slotsgo/no-go against--slot-us.
Probes: tests/dl_departure_tx.cpp (any USB adapter),
tests/pcie_txegress_tx.cpp (8821CE over vfio). Orchestration:
tests/dl_departure_matrix.sh (fixed witness, TX swept over the transports;
the PCIe cell ships the probe to the remote rig, vfio-binds, runs, restores).
| transport | floor RMS | p90 | p99 | p99.9 (=guard) | max |
|---|---|---|---|---|---|
| Jaguar1 8812AU (async USB2) | 22 µs | 28 µs | 101 µs | 0.76 ms | 2.1 ms |
| Jaguar2 8812BU (sync USB3) | 14 µs | 64 µs | 1.7 ms | 3.1 ms | 3.3 ms |
| Jaguar3 8822CU (sync USB3) | 16 µs | 61 µs | 2.2 ms | 3.2 ms | 3.3 ms |
| 8821CE (PCIe, vfio) | 11 µs | 54 µs | 2.4 ms | 3.2 ms | 3.3 ms |
Crystal ppm sane (−17 ppm, same reference witness) on every cell — the robust fits locked. The tail is run-to-run ambient-dependent: the same J1 cell has measured p99.9 between ~0.8 ms and ~3.2 ms across runs on the same channel.
- The transport floor is NOT the bottleneck. All four transports place the bulk of frames within tens of µs of nominal (floor RMS 11–26 µs; p90 ≤ 64 µs). PCIe is the tightest (11 µs) but the USB floors are the same order — transport choice does not gate slot design at ≥ ms slot sizes.
- The tail is channel deferral under default carrier-sense. Even at 5 GHz
on a mostly-idle channel, ~1% of frames air 0.1–2.4 ms late with CSMA on
(ambient beacons + carrier-sense backoff;
SetCcaModedisables the MAC carrier-sense gate — primary CCA + EDCCA — the lever that lets injected TX punch through this deferral). p99.9 sits at ~1–3 ms on every transport. - Go/no-go: fine (sub-ms) DL slots are REFUTED on all transports for a
p99.9-grade deadline on a real channel. The design consequence is a
submission-ahead scheduler — submit a slot's frame
guard_usbefore the slot boundary and size slots ≥ ~2× the measured guard (i.e. multi-ms slots), OR accept a bounded deadline-miss ratio (~1% at a 1 ms guard, per the p99 row) and let HARQ absorb it. Re-measureguard_usper deployment environment; the verdict line exists so that check is one script run.
The hardware-beacon path (MAC-timed TBTT, PinBeaconTbtt) is unaffected by
any of this — beacons depart on the TBTT grid below the CSMA/queueing layer,
which is why scheduled UL rides beacon-steered timing, not send_packet.
A scheduled MAC that carries its DCI-style grant map in the beacon body must
be able to change the airing beacon's content without missing, duplicating
or tearing beacons. The primitive is
IRadio::UpdateBeaconPayload(beacon, len) — an in-place content swap for
an active StartBeacon (same buffer contract; interval, TBTT phase and port
identity untouched) riding the same reserved-page re-download the TBTT steers
use. Its companion StopBeacon() silences the beacon function: the chip
beacons autonomously, so a beaconing session that ends without a device
power-cycle must call it — a killed process leaves the beacon airing
indefinitely (bench-bitten: a stale beacon with the same SA contaminated the
next test's witness).
Per generation: Jaguar2 replaces the retained _bcn_mpdu and re-downloads via
the steer path (the J2 engine loses the bcn-valid latch on re-latch, so the
download is also the re-arm); Jaguar1 is a fresh BCNQ-boundary store bracket
(no re-ignite needed — the port stays configured); Jaguar3 is a fresh HalMAC
download_beacon_page (its latch is stable — no steer machinery involved).
tests/beacon_update_check.sh — the probe beacons a versioned vendor IE
(u32 version + version-derived 32-byte pattern + CRC16, so one frame proves a
torn swap) and calls UpdateBeaconPayload every 10 intervals; the witness
records (hw seq, tsfl, version, crc_ok) per beacon and
tests/beacon_update_analyze.py reconstructs the TBTT grid from tsfl,
separating update-caused missing slots from background witness loss.
| generation | updates aired | excess skips/update | torn | version regress | update→air p50 | p99 |
|---|---|---|---|---|---|---|
| Jaguar1 8812AU | 30/30 | 0.0 | 0 | 0 | 40 ms | 67 ms |
| Jaguar2 8812BU | 30/30 | 0.0 | 0 | 0 | 63 ms | 174 ms |
| Jaguar3 8822CU | 30/30 | 0.0 | 0 | 0 | 72 ms | 99 ms |
- Dynamic beacon grants are GO on all three generations. Every update aired; the background-corrected skip cost was 0 in this measurement (the ≤ 1 skipped beacon per re-download that TBTT steers pay was not even resolvable above witness loss at this cadence). No torn frames — the swap is frame-atomic as observed on air (the CRC never caught a half-old, half-new body). No old content re-airing after the new version's first appearance.
- Update→air latency is TBTT-quantized: content lands on the next (or next-but-one) beacon — p50 ≈ half a period to a period, p99 ≤ ~2 periods at 100 TU. A grant map published via the beacon is therefore effective one to two beacon intervals after the scheduler decides it, and the scheduler's grant timing must budget that pipeline (grants for slot epoch N+2, decided at epoch N).
- Not atomic versus TBTT by design: a beacon airing during the download
may still carry the previous content, and the API guarantees only
whole-version frames (measured, via the CRC), not a bounded switchover
instant. The
effective_tbttdiscipline lives in the grant-map payload (epoch field), not in the radio primitive. - A static-beacon + scheduled-unicast-delta fallback is not needed on any of the three generations.
Tooling: tests/beacon_update_probe.cpp (TX + witness modes; build line in
header), tests/beacon_update_analyze.py (--selftest covers the
skip/dup/late/stale/torn classifier on synthetic streams),
tests/beacon_update_check.sh (per-generation orchestration).
A scheduled MAC's reliability layer (per-UE delivery detection, HARQ-style
retransmission, link adaptation) rests on two capabilities per generation:
an injected unicast descriptor solicits a hardware ACK (with autonomous
MAC retransmission until it arrives), and TxReport reports the per-frame
ACK / no-ACK outcome to the host. This measures both, per generation, plus
the report delivery rate — tests/ack_txreport_matrix.sh /
tests/ack_txreport_analyze.py (--selftest covers the verdict logic).
Fixed hardware-ACK responder (SetAckResponder on a second adapter); per TX
generation three phases: on (responder armed with MAC1, unicast QoS-Data
to MAC1 → expect ~100% tx.report ok, retries ~0), retarget (responder
re-armed to a different MAC2, TX to MAC2 → proves RA and responder MAC are
arbitrary), off (no responder → expect 0% ok, retries pinned at the
descriptor limit set by DEVOURER_TX_RETRY_LIMIT — this matrix runs it at
12 — so the no-ACK outcome must be visible, per frame).
report_coverage = reports / frames sent (tx.stats.submitted); a verdict
requires at least 0.80 by default (MIN_REPORT_COVERAGE), so a handful of
matching reports cannot make a mostly unobserved cell pass. HalMAC adds
SW_DEFINE tag-echo gap counting. An off verdict also requires at least 90% of
reported frames at the configured retry limit (MIN_RETRY_PIN_RATE); one
pinned outlier cannot stand in for the claimed retry-pinned distribution.
When the configured responder has a measured backend-owned hook — RTL8733B
(0bda:f72b / 0bda:b733) or the reference RTL8812AU (0bda:8812) — the
harness adds a fourth disarmed phase: arm MAC1, start a timer only after
that backend has completed bring-up and arming, clear it in the same live
process, then solicit MAC1 and expect the off verdict. Other responders are
skipped rather than using a generic timer whose ordering against Init() is
undefined.
The Jaguar1 disarm cell was measured on a reference 0bda:8812 responder
with a 0bda:c812 solicitor at channel 36/MCS3/retry limit 12. It reproduced
the gate-only failure and passed after restoring the captured pre-arm MACID:
| Jaguar1 identity/disarm cell | ACKed / reported |
|---|---|
| arm MAC1, then gate-only clear | 1946 / 1946 |
| arm MAC1, then verified identity restore | 0 / 997 |
| never arm, solicit captured MACID | 0 / 1074 |
| arm captured MACID, then gate-only clear | 1120 / 1120 |
The last two rows are the same-address adversary: restoring the captured MACID cannot disarm a responder armed to that address, so the arm is refused. The implementation also restores and readback-verifies BSSID as defensive port-state cleanup; the ACK-rate result does not establish that BSSID affected response behavior.
TX sessions run DEVOURER_TX_WITH_RX=thread: CCX reports arrive on the C2H
RX path, so J1/J2 TX-only sessions never see them (measured: J2 TX-only = 0
reports; only J3 drains C2H off its coex runtime without an RX loop). A
scheduled MAC runs TX+RX anyway, so this is the relevant session shape.
| TX generation | on: ACK rate / mean retries | retarget | off: retries pinned | report coverage | tag gaps |
|---|---|---|---|---|---|
| Jaguar1 8812AU | 1.00 / 0.34 | 1.00 / 0.25 | yes (12) | 1.00 | n/a (8812 fmt) |
| 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 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 vendor descriptor default) — the knob, not a descriptor constant, is
the single source of truth for the retry limit on
jaguar1/2/3 and Kestrel (inert on the 8814A die only). On Kestrel it
rides the AX WD DATA_TXCNT_LMT per-frame field, which counts attempts
— devourer folds +1 so N means N retries on every generation. Witness-
measured on the 8832CU (tests/kestrel_retry_witness.sh: on-air copies of
an unACKable unicast per stamped payload counter): limits {0,2} → modal
copies {1,3} exactly; limit 8 → an 8/9 near-tie consistent with ~90%
witness capture of a 9-copy truth. Kestrel has no CCX tx.report path, so
the witness copy-count is the retry ground truth there — the fw-level
delivery outcome stays invisible until a receipts-tier consumer counts it.
The ACK window itself is a knob — DEVOURER_ACK_TIMEOUT_US, the
hardware-ARQ range lever (round-trip propagation eats ~6.7 µs/km; the
per-chip defaults and the bench proof live at the field doc in
src/DeviceConfig.h).
Choosing the limit (tests/arq_retry_sweep.sh, collision regime: a ~1 k fps
retrying unicast flood into an 8812EU duplex ground station airing
PixelPilot-shaped feedback bursts, near-field): retries are backoff-spaced,
so a small limit can burn entirely inside one 2–3 ms burst. Measured curve —
limit 3: 99.72% delivered, residual 0.26%; limit 8: 99.97%, residual 0.03%
(gaps ≤3, a K=8/N=11 FEC floor covers it); limit 16: 100.00% at +5.4%
retry airtime (mean 0.054 retries/frame); limit 32: no further gain, +17%
more retries than 16. Queue-time p99 is flat across limits (only the rare
worst case doubles, then stops growing). Prefer 16 on an ARQ link, or
8 + a light FEC floor where airtime is precious; the per-run residual
gap analysis is tests/arq_fec_dimension.py.
Retries also change RATE on the HalMAC generations, and the ladder is
governed by the descriptor's RA group (RATE_ID) — witness-measured per
on-air copy (tests/retry_ladder_probe.sh, ~99% capture, modal chains):
| TX | chain at MCS3, retry 8 (family-correct RA group) |
|---|---|
| Jaguar1 8821AU | MCS3 ×9 — no fallback at all (fw pins the rate) |
| Jaguar2 8822BU | MCS3 ×4 → MCS2 ×2 → MCS1 ×2 → MCS0 — pure MCS ladder |
| Jaguar3 8812CU @5 GHz | MCS3 ×4 → MCS2 → 6M ×4 |
| Jaguar3 8812CU @2.4 GHz | MCS3 ×4 → MCS2 → 5.5M → 1M ×3 (CCK floor) |
| Jaguar3 8812CU, VHT | M7 ×4 → M4 → M1 → M0 → 6M (coarse −3 steps) |
Why the ladder is MCS-native, the family-mismatch failure modes it avoids
(the 8822C legacy chain, the 8822B VHT wander), the fallback
knob and the rejected floor form are documented at the source of truth:
rateid_for_mgn in src/RateDefinitions.h and the RetryFallback note in
src/DeviceConfig.h — read those, not a copy here. The closed ARQ loop
re-measured clean after the RA-group change (100% delivered at retry 8,
OFF-phase retries pinned 846/846).
Responder-side capability (same setup, J3 TX as the reference soliciting
station): 8814AU closes the loop at retries ~0.1 (the bench responder of
choice); 8812AU works but degraded (97% delivery at ~7 mean retries —
its SIFS ACKs only land intermittently); 8821AU works (61–64% single-shot
across three reps, 94% at retry 8 with a healthy retry histogram,
arm-verified (the off cell is never-armed, so it establishes no disarm): 0% with the responder powered down); the 8812BU
responder was separately proven (tests/ack_responder_check.sh).
A cell whose responder never armed reads exactly like a broken chip — on=0%
/ off=0% — which is how the 8821AU carried a false "broken" verdict through
three runs (silently dead responder: stale advisory adapter lock / open
failure; root-caused with concurrent register peeks off the live armed die,
chipstate --no-claim --peek). The check script verifies the arm line
and aborts loudly; treat an on=0/off=0 row from a
harness without arm-verification as unmeasured, not broken.
The full responder matrix (six cells, ch36, MCS3 unicast; run with
DEVOURER_TX_RETRY_LIMIT at its 0 default, so delivered% is the
single-shot ACK rate and capability is the on-vs-off delta — pin a
nonzero limit for absolute numbers; 8821AU row re-measured ch6):
| responder | on | off | verdict |
|---|---|---|---|
| 8814AU | 79% | 0% | works |
| 8812BU | 98% | 0% | works |
| 8821AU | 62% | 0% | works (94% closed-loop at retry 8) |
| 8812EU | 98% | 0% | works |
| 8812CU | 69% | 0% | works |
| 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
with the 8822B; their AdapterCaps.ack_responder_ok stays false-as-unmeasured
until a cell runs). The ack_responder_ok / tx_retry_limit_ok caps flags
carry this table per die.
-
Per-frame delivery detection is GO on all three generations: the OFF phase pins retries at the configured limit (
DEVOURER_TX_RETRY_LIMIT, set to 12 by the matrix) withstate=1on every report — a no-ACK outcome is unambiguously visible per frame, which is all a software retransmission layer needs. Report coverage 86–100% with zero HalMAC tag gaps (interior losses); the reliability layer must tolerate a ~5–15% report-less frame tail (treat missing report as "unknown", not "delivered").Coverage is rate-bounded (
tests/txrpt_coverage_attrib.py, 8812CU TX): the CCX emission path saturates at ~1.3–1.4k reports/s — full coverage to ~1.25 k fps, thencoverage ≈ ceiling/fps(measured 99.4% @ 1.26 k, 77.2% @ 1.82 k, 53–56% @ 2.39 k fps). The excess drops per-report and interleaved (99.3% of unreported frames sit in tag gaps ≤ 2 — an emission-time rate limiter, not transport-batch loss), and the CCX MISSED_RPT_NUM field is stuffed with a constant on this fw (verified against the 8822B/C/E vendor headers — parse is exact, the fw just doesn't populate it), so tag gaps are the only drop signal. Above the ceiling, sample:DEVOURER_TX_REPORT=Nrequests the report on every Nth frame while the tag still stamps every frame, so received-tag deltas are exact multiples of N and coverage of the sampled frames is deterministic (measured at 2.4 k fps: N=1 collapses to 56%, N=2 delivers 100.0% of the sampled reports, zero off-modulo anomalies; the sampled ok-rate read 99.71% against a 99.99% ledger truth — pessimistic by the ACK-loss asymmetry, the safe direction). Pick N ≥ fps/1300, or account report-less frames as "unknown".Above both sits the app-layer truth tier: windowed RX receipts (
src/cell/RxReceipt.h) — the receiver's application notes every consumed frame index in a sliding bitmap and mails overlapping receipt TLVs back on its feedback path (DEVOURER_RX_RECEIPT_MSin duplex; the TX side absorbs withDEVOURER_TX_RECEIPTSand emitstx.receipt). Neither the ACK horizon nor the CCX ceiling applies: delivery is counted where it is consumed. Measured frame-exact against the receiver's own rx.seq ledger — 126,594 frames clean and 349,455 frames under 150 ms consumer stalls at 2.4 k fps (tests/receipt_verify.py) — after one sizing lesson the header documents: the window must exceed the worst backlog drain in frames (a 2,048-bit window leaked 2,846 delivered frames out of coverage when a stalled spsc-fat pool drained ~3 k frames in one receipt interval; the 8192 default clears that bench worst case ~2.7×). -
Closed-loop hardware ACK + autonomous retry is GO on Jaguar1, Jaguar3 and 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-armSetAckResponder, change the descriptor RA — both fully dynamic). Requires a nonzeroDEVOURER_TX_RETRY_LIMIT— the hardware ARQ loop retransmits until ACK only up to that per-frame limit. -
Jaguar2 as the soliciting TX is MARGINAL as measured: ACK closure varied 12–91% across identical runs (mean retries 2–11) against both 8814AU and 8812AU responders, and its TX pace in the TX+RX-thread shape is ~24 ms/frame regardless of the requested gap (~37 fps vs J1/J3's ~150). As a responder J2 is proven good. Prefer J1/J3 (or the 8821CE) for the soliciting role — the J2 anomaly is measured but unexplained; treat it as open, not as silicon folklore.
-
Bench quirk recorded: the J3 report's
missedfield reads a constant 4× the report count while tag continuity shows zero loss — the 8822Cmissed_rptoffset likely decodes something else; trusttaggaps on J3.
GetRxQuality() is device-wide by design: one draining accumulator fed by
every decoded frame, whoever sent it. A cell scheduler adapting per-UE rate and
power needs the same windowed statistics attributed to each transmitter —
that is devourer::cell::UeRxAttribution (src/cell/UeRxAttribution.h —
src/cell/ holds the caller-side per-cell helpers built on the device API).
Pure caller-side logic — the device RX loops are untouched and GetRxQuality
stays the radio-wide diagnostic. Everything needed is already per-frame in the
Packet callback:
- key — the transmitter address (802.11 addr2/TA), extracted by
cell::extract_ta: bytes [10..16) of the MPDU for every frame type except the two control subtypes that end at addr1 (CTS, ACK). - values —
rx_pkt_attrib's path-A RSSI/SNR/EVM plus the hardware RX timestamptsfl, folded with the exactRxQualityAccumulatorconventions (rssi_raw <= 0is not a sample; SNR/EVM folded only when present; passive noise floor =(rssi_raw − 110) − snr_raw/2).
add() (or add_mpdu(), which extracts the TA itself) per frame;
snapshot() drains the whole table into one UeRxWindow per TA (delta
semantics, like GetRxQuality) with converted units, window mean/extremes and
last_tsfl for staleness. The table is bounded (default 64 TAs per window);
overflow frames are counted in evicted_frames, never silently lost.
tests/ue_rx_attribution_check.sh: two transmitters with distinct unicast SAs
(8812AU at a 2 ms inter-frame gap, 8822CU at 8 ms) against one ue_rx_probe
witness (8812BU), 12 s. The probe attributed the streams separately —
TX1 2955 frames at −51 dBm mean, TX2 741 frames at −44 dBm mean, a 4.0×
count ratio exactly matching the 4× cadence ratio — confirming per-UE frame
counts and per-UE signal statistics don't bleed between transmitters.
tests/ue_rx_probe.cpp— on-air probe: feeds every decoded frame into aUeRxAttribution, drains once a second, emits oneue.rxJSONL event per UE per window (ta,frames,rssi_dbm,rssi_max_dbm,snr_db,snr_min_db,evm_db,nf_dbm,last_tsfl) plusue.rx.evictedwhen the cap was hit. Build line in the header.tests/ue_rx_attribution_selftest.cpp— headless ctest guard (ue_rx_attribution_derive): TA extraction over frame types, folding conventions, drain semantics, eviction accounting.tests/ue_rx_attribution_check.sh— the two-TX on-air validation above.
A cell scheduler wraps this into its UE registry (association state, timing advance, the per-UE RX window as the link-adaptation input).