Skip to content

Latest commit

 

History

History
643 lines (543 loc) · 37.3 KB

File metadata and controls

643 lines (543 loc) · 37.3 KB

MT7612U — a MediaTek backend, measured

Everything below was measured on one MT7612U (0e8d:7612, MT_ASIC_VERSION 0x76120044, MT7662 MAC core, 2T2R, SuperSpeed) against an RTL8812AU witness running this project's own rxdemo/txdemo — except ## On-air, against the kernel driver, which puts a second, USB 2.0 unit of the same part on the other end and the MediaTek kernel driver on one side of it. Read ## Offline tests and ## Counterparts before quoting any number here.

It is wired in. DEVOURER_MT7612U (default OFF) compiles the subtree into libdevourer and WiFiDriver::CreateRadio constructs an Mt7612uRadio, so a devourer binary opens, receives and transmits on this part. Measured through rxdemo/txdemo: 12000 frames received on ch36, and 19 rx.txhit on an independent RTL8812AU witnessing our transmit at −38 dBm.

The knobs this backend does NOT implement refuse or warn rather than going quiet — SetTxMode, SetAmpduMode, SetCcaMode(true), SetTxPowerIndexOverride, and the tx.ack_timeout_us / tuning.disable_cca / tx.usb_agg_max config values. See "Counterparts" for what that costs.

Why a MediaTek port is small

On the Realtek generations the host programs the synthesizer and carries the PHY tables. On MT7612U the RF plane lives behind the MCU: a channel change is one 8-byte CMD_SWITCH_CHANNEL_OP plus a firmware calibration burst, and there are no RF register tables to reimplement at all. The whole backend is ~4600 lines including the harness.

The same property is why it can never hop fast — see ## What it cannot do.

TX rate is authoritative, unconditionally

The MAC transmits at the 16-bit txwi.rate word verbatim. Three rates, three exact matches on the witness:

requested txwi.rate witness rate
OFDM index 0 0x2000 4 (OFDM 6 Mbps)
OFDM index 7 0x2007 11 (OFDM 54 Mbps)
HT MCS7 0x4007 19 (12 + 7)

Per-frame alternation, 300 frames, even index MCS0 / odd MCS7, each carrying its own index in the payload: 229 received, 229 aired the rate their index called for, 0 mismatches. Correlating on the index rather than demanding an unbroken alternating sequence is deliberate — frames 1, 7 and 9 were lost, and a strict-sequence test fails a working driver for that.

The hardware rate LUT cannot override it. MT_WCID_TX_RATE(1) loaded with OFDM 6 Mbps (0x80012000, read back), frames sent with txwi.wcid = 1 and txwi.rate = HT MCS7:

MT_TXWI_FLAGS_TX_RATE_LUT frames aired
clear 84 83 × MCS7, 1 × MCS6
set 93 93 × MCS7

The flag is defined in mt76 and set nowhere; setting it here changed nothing, so on this part the LUT appears unreachable rather than merely unused.

TX power

Ported from mt76's EEPROM path and checked against a usbmon capture of the kernel driver configuring the same channel. All nine registers identical:

register ours kernel (ch149)
MT_TX_PWR_CFG_0..4 04070606 04060202 04060101 04060101 00000101 identical
MT_TX_PWR_CFG_7/8/9 00010002 00000001 00000001 identical
MT_TX_ALC_CFG_0 2f2f171a identical

mt76x02_sign_extend() treats the top bit set as positive; reading it the usual way silently halves output power.

A-MPDU works on injected frames

Three arms, identical QoS-data frame and rate, differing only in the TXWI AMPDU flag. paggr on the witness is the observable:

arm frames paggr=1
no AMPDU flag (control) 352 0
AMPDU, QSEL_EDCA 289 288
AMPDU, QSEL_MGMT 326 326

Throughput, broadcast QoS, HT MCS7, 20 MHz, 3 s per cell, with paggr confirming every frame in the "on" cells aggregated (25547/25547 and 10444/10444) against 0/12010 and 0/8210 for the controls:

frame bytes AMPDU off AMPDU on gain
200 4380 fps / 7.01 Mbit/s 9686 fps / 15.50 Mbit/s 2.21×
1400 3038 fps / 34.03 Mbit/s 3977 fps / 44.55 Mbit/s 1.31×

This is the opposite of the Jaguar1 result recorded in docs/aggregation.md, where monitor-injected aggregation airs nothing.

Unicast injection is a 40× cliff

Chasing an unexplained 54 fps. Four variables differed at once, so they were bisected one at a time:

configuration fps Mbit/s
bcast non-QoS wcid=ff 2106 23.59
bcast QoS wcid=ff 3037 34.01
ucast QoS wcid=ff 75 0.83
ucast QoS wcid=1 51 0.57
ucast QoS wcid=1 + AMPDU 85 0.95
bcast QoS wcid=1 + AMPDU 3217 36.03

Unicast alone is the cliff. The MAC arms an ACK timeout for a peer that never answers; clearing txwi.ack_ctl's REQ bit does not prevent it, and neither does setting the QoS Ack Policy to No Ack (tried, no change). A one-way injected link must address frames to broadcast or multicast.

Hardware ACK responder

No separate responder-address register exists here, so arming retargets the port identity (MT_MAC_ADDR_DW0/DW1) with MT_AUTO_RSP_EN gating the engine. Following this project's own finding that closing the gate alone does not silence a die matching on identity, clear moves the identity back.

Stimulus: an 8812AU sending ACK-requiring QoS data to the responder address and receiving concurrently (DEVOURER_TX_WITH_RX=thread).

responder frames the stimulus radio received
off (control) 0
armed 3500+, every one len 14

An ACK is 10 bytes + FCS. The armed count tracks the 3660 frames the stimulus sent roughly 1:1.

The first attempt used this project's retry-collapse method and returned INCONCLUSIVE, not a passtxdemo injects without retries, so there was nothing to collapse. The gate says so rather than reporting success.

RX

Verified against ambient traffic from equipment not under our control:

len=248  CCK   mcs=0  nss=1 bw=20 sgi=0 ldpc=0 stbc=0 rssi=[-68,-66]
len=28   OFDM  mcs=4  nss=1 bw=20 sgi=0 ldpc=0 stbc=0 rssi=[-45,-45]
len=102  HT    mcs=15 nss=2 bw=20 sgi=1 ldpc=1 stbc=0 rssi=[-71,-63]

nss is derived as 1 + (15 >> 3); SGI and LDPC come from the same 16-bit rate word the TX path writes, so one codec serves both directions.

A receiver needs the 1 Hz tick

The cadence, what it costs to skip it and why it is caller-driven are the contract on mt7612u_phy_tick() in the public header, which is where the numbers live. Kept here: why the finding is believed, and which part of the tick does the work.

Stimulus throughout is a peer 20 cm away airing 1400-byte HT-MCS7 frames at 3037 fps. The no-tick collapse is not the adapter — it is symmetric in both directions, and both hear 400–1000 fps of ambient on ch1/36/40/44/48; not the ring — its own counter agreed at 3; and not the air — an RTL8812AU witness counted 103805 frames from the same transmitter.

Bisected with one verified peer per arm: reading the read-and-clear MT_RX_STAT_* counters alone does nothing (3 frames); a periodic MCU calibration alone restores it (43902); the ported gain tracking alone does not (4 frames). The tick therefore issues one MCU_CAL_TEMP_SENSOR per call — the cheapest command that keeps the MCU in the loop, standing in for the TSSI compensation this port lacks — and runs the gain tracking for fidelity. Every receiving gate in bringup ticks; arx <chan> <secs> 1 is the negative control.

This part does not deliver the FCS

Measured, because it constrains integration rather than being a detail. Packet::Data carries the trailing FCS whenever rx_pkt_attrib::fcs_present is set, which every Realtek parser leaves at its default. MT7612U cannot: the MAC strips it, so Mt7612uRadio::on_rx() clears that flag on every frame it delivers.

Four to seven bytes do sit past MPDU_LEN in every RX buffer — over 4263 ambient frames the tail was 4 bytes on 3375 of them and 5-7 on the rest, which reads exactly like a fixed 4-byte field plus USB 4-byte alignment. So the obvious move is to hand the consumer len + 4 and call it the FCS.

It is not the FCS. CRC-32 over the MPDU matched those four bytes on 0 of 4263 frames. (The probe's own CRC-32 was checked against the standard "123456789" -> 0xcbf43926 vector first, so the negative means something.) They are the FCE info trailer: mt76's mt76u_get_rx_entry_len() computes min_len = MT_DMA_HDR_LEN + MT_RX_RXWI_LEN + MT_FCE_INFO_LEN, and dma.h:48 defines MT_FCE_INFO_LEN 4.

A consumer that trims four bytes unconditionally would eat four bytes of payload off every frame. That divergence is now declared at the boundary rather than smoothed over: rx_pkt_attrib::fcs_present travels with each frame, and the consumers that trim — devourer::bf::parse_report(), BfReportDetect, tools/bf_report_decode.py — honour it. Airtime accounting adds the four bytes back, since they occupied the channel even though the MAC did not hand them over.

USB bulk aggregation

MT_TXD_INFO_NEXT_VLD chains blocks inside one bulk-OUT transfer. mt76 never sets it, so this is the one mechanism here that is not a port and needed on-air proof: 400 frames in 25 transfers (16 per transfer), 352 aired at rate 19 / len 44.

Async rings bought no throughput

Stated plainly because it would be easy to claim otherwise:

fps Mbit/s CPU submit mean
sync, saturated 3040 34.05 4.2 % 0.329 ms
async, saturated 3044 34.10 4.8 % 0.328 ms
sync, paced ~750 fps 730 2.3 % 0.065 ms
async, paced ~750 fps 754 2.6 % 0.021 ms

At saturation both sit at the same number because that is the airtime of one frame. What the rings bought is the callback RX path, concurrent TX+RX on one handle (2560 fps TX alongside RX, no errors), and 3× lower submit latency below saturation. A-MPDU, not USB parallelism, is what lifted 34 → 44.55 Mbit/s.

Other measured items

  • 40 MHz: 242 of 242 frames reached a 40 MHz witness reporting bw=1.

  • TSF: 200231 µs over a 200000 µs sleep. Two traps — the counter does not run until MT_BEACON_TIME_CFG_TIMER_EN is set (part of beacon config, which an injector otherwise skips), and DW0 is the low word. mt76 assembles (dw0 << 32) | dw1, which is backwards, but that value only feeds a dev_dbg() print upstream so the bug is never exercised. Ported faithfully it produced a clock advancing 8.6e14 "µs" per 200 ms.

  • No tested sequence loads the TSF (the contract is mt7612u_caps::tsf_write and IRadio::WriteTsf): the DW0/DW1 registers hold the counter, they do not load it. Every sequence the gate tries was measured ignored on two units (40:a5:ef:50:27:a1 and 40:a5:ef:5a:32:f8, channels 6 and 149 each) — DW0 then DW1 and the reverse, each word alone (with a high word that actually differs), a write with the MAC stopped, and MT_BEACON_TIME_CFG_TIMER_EN cleared around the write — after a positive control confirms the clock is alive, and with a per-arm check that the clock was still advancing. In the running arms the clock kept free-running at wall rate and never jumped to the target. The timer-off arm needs its own read: clearing TIMER_EN zeroes the counter and restoring it restarts the count from ~0, which would wipe a load before the normal readback, so the gate also reads the counter while the timer is still off — it reads 0, not the target. Each arm is also bounded in host time, so a silently retried control transfer cannot turn a late readback of a real load into a "no-op". Mainline mt76 registers no .set_tsf for the mt76x02 family, so the absence is a property of the part rather than of this port. The bringup tsfwrite gate runs that control and sweep and PASSes only while the clock is alive and every sequence is confirmed ignored. That result is what sets mt7612u_caps::tsf_write, which AdapterCaps::tsf_write_ok is taken from.

  • The two TSF halves are not latched. A DW0 read does not freeze DW1, so a read whose halves straddle a low-word wrap tears by 2^32 µs. Bring-up restarts the counter near 0 (~0.35–0.40 s on both units), so the first wrap is 71.6 min in. A plain DW0-then-DW1 read hits it about once per wrap when polled back to back, and roughly (read time ÷ poll interval) per wrap otherwise. The read that survives it, and what it costs, is the contract at mt7612u::tsf_read (Mt7612uTsfRead.h) and the declarations it serves.

    bringup tsfwrap [gap] (wrapped by tests/mt7612u_tsf_wrap.sh, which owns the runtime and the re-run rule) holds that against the part, judging every read against a host-clock model fitted over the preceding minute rather than against the read under test. Around the wrap it forces the library's read template to take the wrap in the chosen gap, interleaves a plain DW0,DW1 read across the same wrap as a positive control, and checks every continuous mt7612u_read_tsf_chk read near the wrap. Measured once per unit, one wrap each:

    40:a5:ef:50:27:a1, gap 1 40:a5:ef:5a:32:f8, gap 2
    forced read retried, −3 µs off the model retried, −10 µs
    DW0,DW1 control across the same wrap +2^32 − 6 µs +2^32 + 4955 µs
    continuous reads checked near the wrap 1.70 M of 31.7 M, worst 348 µs 211 k of 3.9 M, worst 74 µs
    failed reads, backwards steps, reads off the model 0, 0, 0 0, 0, 0

    The second unit sits on a USB 2.0 bus where a control transfer can take ~10 ms, which is where its control's extra 4955 µs comes from — the read is judged over the interval that bracketed its transfer, so that is slack in the measurement, not in the counter.

    What that does not show. The forced read occupies the wrap instant, so the exported function never takes its own retry across a wrap in that run; the retry path is the forced template read (the same code through another reader) and the mt7612u_tsf_read cell. It is one wrap per unit, and the gate's wrap_bits smoke mode cannot tear a read at all.

    A failed transfer fails the read rather than joining a 0xffffffff word into a plausible clock; the return and throw semantics are the declarations' (mt7612u_read_tsf_chk / mt7612u_read_tsf in include/mt7612u/mt7612u.h, IRadio::ReadTsf). What is measured here is that the failure is reachable and reaches the caller. Checked once by hand, not by a gate: under a 100 Hz ReadTsf poll, de-authorising the adapter's whole bus (usbN/authorized — a logical disconnect, not a cable pull) gave 795 plausible reads and then 200 of 200 calls threw, with no value returned after the disconnect and a clean teardown. De-authorising only the device (its own authorized) is not a disconnect on this part: EP0 kept answering and the TSF kept reading correctly for 40 s while the MCU bulk endpoint failed.

  • Register-stream equivalence: our EP0 write stream during bring-up was diffed against a usbmon capture of the kernel driver's own probe. 522 kernel writes vs 521 ours, 376 common addresses, one final-value mismatch (MT_MAC_BSSID_DW1, which the kernel's beacon config rewrites afterwards), and six kernel-only addresses — all beacon configuration. Zero addresses we write that the kernel does not.

What it cannot do

Channel switch is 20–100× slower than the Realtek parts.

path measured, mean of 8
full switch, with the firmware calibration burst 526 ms
calibration skipped 48 ms
this project on Realtek, for reference 0.5–2.5 ms

Our implementation has headroom (a 7.5 ms inter-command sleep, register writes that could batch through CMD_RANDOM_WRITE, an MCU_CAL_RXDCOC still running on the fast path), but the floor is four MCU request/response round trips over bulk endpoints plus firmware time. A tuned version might reach 10–20 ms. It will not reach 2 ms. FHSS and per-packet hopping are out of reach for this part; seconds-scale channel migration is not.

Narrowband 5/10 MHz has no encoding. MT_RATE_BW is a two-bit field with three defined values (20/40/80) and mt76 exposes no narrowband path for this part.

Enabling MAC RX without draining the bulk-IN endpoint wedges the chip below USB level. Prevented at both ends. Starting: mt_mac_start() takes an MT_RX_DRAIN_* argument and refuses to enable the receiver unless a ring is draining EP 4 (the public mt7612u_start() always derived this; the internal entry point now does too). Stopping: mt_mac_rx_disable() clears ENABLE_RX before the ring is cancelled, because mt_async_stop() reaps the EP 4 drainer while mt_mac_stop() would not clear the bit until after its own mt_rx_flush() and a TX-idle wait of up to 150 ms — the same undrained window, at the end of every session.

A run that dies mid-transfer leaves the adapter unable to load firmware, and both open paths now recover it. Register reads and writes still round-trip and the MAC and RF are fine, but every bulk OUT NAKs — libusb_reset_device() (already called on every open) does not reach it, and the kernel mt76x2u driver cannot bind it either. Two tiers, told apart by MT_USB_U3DMA_CFG:

  • Soft (TX_BUSY clear). Isolated one step at a time against a freshly wedged adapter: clear_halt on all four endpoints, MAC + USB DMA stop, WLAN_EN/WLAN_CLK_EN down, and the PBF block reset each left it wedged; pulsing MT_USB_DMA_CFG_TX_CLR — a bit mt76 declares and never writes — clears it alone. mt_recover_usb() pulses it and drains the RX pipe (mt_rx_flush runs from mt_mac_stop, which a killed process never reaches). It runs after identify on both open paths — mt_open() and mt_adopt() — so a libusb-owning consumer arriving through mt7612u_open_handle() self-heals too; while it lived in mt_open() alone that consumer got none of it. bringup adopt exercises that path. A post-kill firmware upload can also fail for 3–8 s and then succeed by itself; mt_fw_init() absorbs that with one retry of the upload after a settle. On a healthy idle adapter the recovery is a no-op (0/40 opens).

  • Hard (TX_BUSY stuck, 0x80c00020). Seen once; not cleared by any of the above, nor by the kernel driver — a physical replug was required, and it has not reproduced since. The vendor-derived MT76x2U tree has dedicated UDMA and IFDMA/FCE resets beyond TX_CLR (USB TX wedge reset evidence), implemented as bringup swreset and not yet hardware-verified for want of a reproduction.

Locking. The 1 Hz mt7612u_phy_tick() issues MCU commands, and the RX ring runs its own libusb event thread, so register and MCU I/O are serialised by a recursive io_lock initialised in mt_dev_state_init(), which both open paths run before any register I/O. A single-threaded consumer never contends; the lock is what lets a consumer safely drive the tick from a second thread. mt_mcu_send() also drains any late reply off EP 5, but only when a previous command actually gave up (mcu_stale_pending) — draining before every command cost a guaranteed 5 ms bulk timeout each, including the 3-6 inside every tune, and nothing was ever queued on the healthy path.

Offline tests

ctest runs seven MediaTek cells. The first four are C++ and need neither hardware nor the DEVOURER_MT7612U option — the code they cover is header-only. The fifth is C++ and needs the option, because it calls the subtree's own symbols. The last two are Python and need the reference/mt76 submodule:

cell what it holds
mt7612u_usb_ids that the 16 MediaTek pairs never claim a device one of the 91 Realtek ids owns. The gate runs before the Realtek SYS_CFG2 read, so an id we wrongly own is refused with no second chance
mt7612u_mapping the descriptor translations — RSSI bias, per-chain signal, rate codes, TID offset, widths. Every one of these has been wrong once
mt7612u_rx_queue the RX hand-off queue's two load-bearing properties: a full ring drops the newest frame and counts it, and a popped slot outlives the queue lock. Broken, the first reorders frames or wedges the part and the second is a use-after-free — and both look like a healthy link until a packet processor falls behind
mt7612u_tsf_read that the TSF read stays coherent when the low word wraps between any two of its register accesses: a scripted counter swept across the wrap one microsecond at a time, every failure position, and an all-ones low word as a value. The pre-fix DW0,DW1 order runs against the same sweep and must tear, or the cell cannot see the bug. Mutation-tested by hand three ways — no retry, a retry that keeps the first high word, a retry that skips the low-word re-read — each fails
mt7612u_tsf_api that a failed TSF read stays distinguishable from a value at the C entry points: mt7612u_read_tsf_chk refuses a NULL device or output with -1 and does not write through the pointer, and mt7612u_read_tsf answers 0. 0xffffffff is a legitimate register word here, so only the return code can carry a failure. Not free: deleting the guard in mt7612u_read_tsf_chk segfaults the cell (hand-run). Reaches the NULL refusals only; a failed transfer on a live device, the Mt7612uRadio::ReadTsf throw and the tsf_write caps bit need the part (bringup caps prints it, and Mt7612uRadio::GetAdapterCaps takes tsf_write_ok from it rather than restating it)
mt7612u_usb_ids_vs_mt76 that the id table above really is the complete mt76x2u_device_table from the pinned reference, byte-checked. An id we forgot is invisible to mt7612u_usb_ids — the adapter just falls through to the Realtek path — and the first draft of that header had 11 of the 16, taken from the host's kernel tree
mt7612u_initvals_generated that the checked-in initvals.h still matches what tools/extract_mt7612u_tables.py produces from the pinned reference

The last two SKIP when reference/mt76 is absent, which is the case in CI — actions/checkout here does not fetch submodules. They are bench cells. Both are also registered only if(Python3_Interpreter_FOUND), so on a host without Python they are absent rather than skipped, and ctest says nothing at all.

mt7612u_tsf_read has the same limit: it holds the read discipline, not the wiring. Reverting mt7612u_read_tsf_chk to a plain two-register join, or Mt7612uRadio::ReadTsf to the non-throwing wrapper, passes it; those are held by the bringup tsfwrap gate recorded under Other measured items.

What still has no cell is the wiring: Mt7612uRadio's calls into the queue — the reset before the ring is armed, the commit after the processor returns — are covered by nothing headless. Deleting the pop_commit() compiles clean and passes the whole suite.

mt7612u_rx_queue was mutation-tested five ways: dropping the oldest instead of the newest, not counting the drop, using the whole allocation instead of leaving the sentinel slot, a reset() that forgets the counter, and a wake() that does nothing. Each fails the cell, and each names the property it broke.

make -C src/mt7612u check runs four more binaries. No hardware, no privileges.

test what it holds
api_link takes the address of all 33 public entry points while including only the public header, so a declaration that loses its definition is a link error. Still compiled as C, which is what keeps the extern "C" guard honest now the library itself is C++
frame_shape mt_hdrlen_from_fc() over management, all eight control subtypes and the five data shapes; the RX L2-pad fold on a synthetic QoS frame, with a negative control that redoes the old fixed-24 fold and asserts the QoS Control really is destroyed; the radiotap VHT bandwidth mapping over all eleven codes the part can express
field_macros MT_CTZ against __builtin_ctz over all 32 single-bit and all 528 contiguous masks, plus a FIELD_PREP/FIELD_GET round-trip, plus a static initialiser that fails to compile if the macro stops being constant-foldable
log_sink that mt7612u_set_log_sink() diverts rather than copies — stderr must stay silent while a sink is installed — that the sink gets the bare message with no prefix to double up, that every level letter arrives, and that NULL restores the built-in sink instead of silencing the library

Each was mutation-tested: removing one public definition, reverting either frame-shape fix, and reverting the header-length fix each make the suite fail, with the RX one reporting QoS Control zeroed by the pad fold: aa aa.

tools/extract_mt7612u_tables.py --check byte-compares the generated initvals.h against reference/mt76 at the pinned commit. It is the mt7612u_initvals_generated cell above; until now nothing ran it.

On-air, against the kernel driver

tests/regress.py runs its four-cell matrix on this part, so "devourer's MediaTek backend interoperates with the MediaTek kernel driver" is a command you can re-run rather than a story. Both ends are MT7612U — the SuperSpeed unit transmitting, a second USB 2.0 unit receiving:

sudo python3 tests/regress.py --channel 36 --tx-pid 2-1 --rx-pid 5-1 \
     --mt7612u-fw-dir <dir> --duration 15

Cells are hits / frames offered, 15 s each. Twenty cells over five runs — ch36 three times, ch6 twice — and every one passed on its first attempt. The last run of each band:

ch36 (UNII-1, 5180 MHz) RX = devourer RX = kernel mt76x2u
TX = devourer 7000 / 9375 ✓ 6758 / 9451 ✓
TX = kernel (scapy) 400 / 477 ✓ 474 / 475 ✓
ch6 (2.4 GHz, 2437 MHz) RX = devourer RX = kernel mt76x2u
TX = devourer 8600 / 9436 ✓ 8631 / 9425 ✓
TX = kernel (scapy) 400 / 474 ✓ 450 / 474 ✓

The kernel-RX figures are tcpdump record counts. The harness published each of them one higher until this change: it counted splitlines(), and the log's trailing newline made a phantom record — which is how an earlier baseline cell came to report a suspiciously perfect 459 / 459 when 458 of 459 arrived.

The verdict repeats; the counts do not. Across the three ch36 runs the devourer-TX → kernel-RX cell read 9077, 8921 and 6758 against a near-constant ~9400 offered — a 26 % spread on the same pair, same channel, same command, minutes apart. 5 GHz here carries heavy ambient traffic and an injection with no rate control contends with it. Every run says "the path works"; none of them says how well, which is the other reason these counts do not belong in the README's bandwidth column.

Some detail from the per-cell logs that the hit counts do not show:

  • The kernel driver decodes our frames at 6 Mbps on both bands. tcpdump on the mt76x2u side reads 6.0 Mb/s 5180 MHz 11a ... SA:57:42:75:05:d6:00 at a −29 dBm mode on ch36 and 6.0 Mb/s 2437 MHz 11g at −35 dBm on ch6, each with a per-antenna breakdown. That is an independent decoder confirming the rate SetTxMode's refusal predicts, not our own reading of our own transmit. (6 Mbps because txdemo builds rate-less frames; the part itself airs whatever the TXWI says — see ## TX rate is authoritative.)
  • Nothing was dropped at the hand-off queue, in any cell that ran a devourer receiver. The queue only exists on that side; the other half of each matrix is tcpdump. The busiest carried 21842 frames — ambient included — over the ~28 s the receiver was up, and StopRxLoop logged the plain "RX stopped after N frames" line rather than the DROPPED one.
  • TX reported 0 failed in every devourer-TX cell (9375–9451 submitted each in the two runs above) — with the standing caveat that on this backend failed counts refusals rather than wire deaths (Counterparts).
  • The hit counters are sampled on the devourer side only: rx.txhit fires at hits <= 10 or every 100th, so a reported 7000 means 7000–7099. The kernel-RX counts are tcpdump lines, granularity 1. frames offered is tx.stats for the devourer rows and the injector's own tally for the kernel rows, both exact. The matrix's own table prints tx.frame instead, sampled every 500th and therefore low — which is why a cell there can show more hits than TX.

What this does not show

  • The kernel side is the host's in-tree mt76x2u (6.14.0-37, mainline mt76), not a build of reference/mt76 @ be5ce79. They are the same codebase at different revisions. Loading the reference tree instead would mean replacing the mt76 core module — refcount 9 on this host, mt7921e among its users, i.e. the machine's own Wi-Fi. The exact pinned revision is held instead by the two reference/-comparing cells above, which is a stronger check of the transcription and no check at all of the interop. Note that regress.py has a designed way round this — its VM mode exists precisely to run kernel cells against an out-of-tree driver in a pinned-kernel guest, where the host's module refcount is irrelevant. That VM was never set up for MediaTek, so this is "not done", not "not possible".
  • A hit count is not a throughput number. The bench discipline in this repo is to judge TX by SDR duty cycle × PHY rate and never by receiver frame counts (tests/ampdu_onair_ab.sh says so at the top); there is no USRP on this bench, which is why the README's bandwidth cells for this part carry a — measured, but by the receiver-count method that column is not — rather than a number that would read as comparable with the Realtek rows.
  • A ✓ is one frame. The pass threshold is regress.py's default of 1, so the marks say "the path is not dead" and only the counts say how well it works. Nor can the artifacts show a cell was not re-run: what they show is three consecutive runs whose every cell passed, with no re-run in between.
  • The matrix reports; it does not gate. regress.py exits 0 whatever the cells say — a run in which all four failed exits 0 too. It is output to read, not a check to wire into anything.
  • Both adapters are ~20 cm apart on one bench, so every RSSI here is near-field and says nothing about range.
  • The kernel-TX cells are scapy injection through mt76x2u, which airs ~33 frames/s. The ~500-vs-~9000 asymmetry between the rows is the injector's rate, not a receiver difference.
  • UNII-2/3 is untested. This bench runs iw reg set SE, and ETSI has no ch149, so the third column of the README's table has no MediaTek reading and is not going to get one here without changing the regulatory domain.

Counterparts

Stated because the numbers above are uniformly favourable.

  • Two units, one design. Everything above the regression matrix was measured on a single SuperSpeed MT7612U. The matrix adds a second unit — same 0e8d:7612, same bcdDevice, but a USB 2.0 one — which is enough to say the backend is not fitted to one adapter's quirks and not enough to call it a second sample of the silicon. No second board revision, no second OEM id, and no vendor-driver A/B beyond the register diff and the interop matrix.
  • Two adapters share one diagnostic route. The C library's log sink is a single process-global pair, so Mt7612uRadio keeps a registry and routes to the first live radio's Logger. With two MediaTek adapters open, the second one's library diagnostics are gated by the first one's log level and stream and carry no adapter identity. That is better than the alternative — per-object install meant the second constructor stole the first's routing and the first destructor unhooked the survivor's, dropping it to raw stderr — but it is not right. The fix is a per-device sink in the C library (mt7612u_set_log_sink(dev, ...)), which is a public-header change.
  • TxStats::failed counts refusals, not wire deaths. A frame the transport accepted and the URB then failed to deliver does not move it. The library's own tx_err cannot simply be added: it double-counts submit failures, counts URBs where this counts frames, and restarts at zero when a ring is torn down, so failed would go backwards across an RX restart. Needs a monotonic wire-failure counter in the library.
  • Effectively one witness implementation. Every on-air number outside the regression matrix is an RTL8812AU running this project's rxdemo: paggr, bw and rate are that implementation's reading, not an independent instrument. The matrix's kernel-RX cells add a second one — tcpdump over mt76x2u — which is genuinely independent code, but on the same silicon family, and it reports only legacy rate and dBm.
  • TX power was verified against the kernel's registers, not against radiated power. No spectrum analyser, no power meter. Register equality is not dBm — the correctness claim is "identical to what mt76 programs", nothing more.
  • The RX gain correction has never done anything. This EEPROM carries no gain calibration (LNA_GAIN=0x0000, GRP4_5_RX_HIGH_GAIN=0xffff), so every correction term is zero and that code path is unexercised. An adapter with a programmed EEPROM would run code that has never run.
  • The ACK identification rests on length, the 1:1 count and a zero control arm. The RA bytes inside those ACKs were not read — the dump path omits control-frame bodies.
  • No cold boot was ever tested. No hub on the test host supports per-port power switching, so every run started from a chip something else had already initialised. reset_wlan + power_on demonstrably does not clear the firmware-running bit.
  • The 48 ms fast retune is our implementation, not the floor. The floor is unmeasured.
  • Five ctest cells run in CI, two more only on a bench, and the subtree's own four still run nowhere automatically. With DEVOURER_MT7612U=ON the whole platform matrix (gcc, clang, MSVC, mingw, macOS) builds the subtree and the sanitizer job links it, so a portability or lifetime regression is caught. mt7612u_usb_ids, mt7612u_mapping, mt7612u_rx_queue and mt7612u_tsf_read run on every configuration, and mt7612u_tsf_api wherever the option is on, which the MediaTek CI jobs set. The TSF pair and the queue cover backend behaviour rather than a lookup, and nothing else does. Bring-up, the teardown ordering, the 1 Hz tick and TX still have no automated coverage, and the two reference/-comparing cells SKIP in CI because the submodule is not fetched there. Every hardware claim in this document is hand-run except the regression matrix, which is tests/regress.py. The four offline tests under src/mt7612u/tests/ are still driven only by src/mt7612u/Makefile, which no workflow invokes — nor is there a lifecycle soak of the kind the Realtek backends carry.
  • 80 MHz, VHT on air, and NSS=2 are unexercised. The rate word encodes them and the RX path decodes them; neither has been transmitted.
  • The RX path delivers no FCS (see above). That is a measured hardware limit the port cannot fix. It is no longer an integration blocker - the metadata and every consumer that trims four bytes are in place - but the adapter still has to USE it: see Open.

Open list

Ordered, and honest about which are unknowns rather than typing:

  1. Two data races the library has when a channel change runs while the RX ring is up. Found with ThreadSanitizer against real hardware, driving rxdemo with DEVOURER_RX_SWEEP so the main thread retunes while the libusb event thread delivers frames. Neither is in Mt7612uRadio — that class's _mu discipline held, and TSan reported nothing inside it — and neither is reachable from the bring-up harness, which sets the channel before starting a ring and measured 0 warnings over 10667 frames.

    • mt_read_rx_gain() (eeprom.cpp) rewrites the per-channel lna_gain and rssi_offset[] from the EEPROM on every tune, while mt_rx_parse() (rx.cpp) reads them on the event thread to correct each frame's RSSI. A frame parsed mid-retune therefore gets a mixed correction, i.e. a wrong RSSI for that frame. Wrong number, not a crash — which is the class this port cares most about.
    • A synchronous control transfer issued during the tune (mt_rr_chk -> libusb_control_transfer) reaches libusb_free_transfer(), destroying a transfer's mutex, while the async ring's event thread locks it. That is inside libusb, and it is the documented hazard of mixing the synchronous API with a dedicated event thread on one context.

    For scale: the same stress on the shipping RTL8812AU path produces 8 TSan reports, all in devourer's own Jaguar1 state (RtlJaguarDevice.cpp, RtlAdapter.h). Retune-during-RX is not a race-free operation anywhere in this project today, so this is a shared gap rather than a MediaTek regression — but the two above are specific and fixable, and the second one is a use-after-destroy rather than a torn read.

  2. mt76x2_phy_tssi_compensate() — periodic temperature correction. Without it output power drifts with die temperature.

  3. Cold-boot verification on a host with switchable USB power.

  4. A witness on different silicon. The regression matrix added a second witness implementation (tcpdump over mt76x2u), and a second unit of the part — but not a second board revision, and not a decoder outside the MediaTek family.

  5. 80 MHz; VHT and NSS=2 on air.

  6. Retune tuning — batch registers via CMD_RANDOM_WRITE, drop the inter-command sleep, skip the RXDCOC on a fast path. Worth doing only if 10–20 ms is useful to someone.

  7. Whether the single MCS6 frame in the rate-LUT control arm (1 of 84) is a witness decode artefact or a real fallback. Unexplained.