Skip to content

mt7612u: a MediaTek backend, measured — is it in scope? - #412

Merged
josephnef merged 13 commits into
OpenIPC:masterfrom
snokvist:feat/mt7612u-mediatek-backend
Sep 7, 2026
Merged

josephnef merged 13 commits into
OpenIPC:masterfrom
snokvist:feat/mt7612u-mediatek-backend

Conversation

@snokvist

@snokvist snokvist commented Sep 6, 2026

Copy link
Copy Markdown
Collaborator

Opening this to ask a scope question, not to merge: does a MediaTek backend belong in this project at all? Every generation here is Realtek, and MT7612U is not. I would rather ask against measurements than against a proposal, so the work is on the branch and docs/mt7612u.md carries the numbers, the methods and the limits.

If the answer is no, that is a fine answer and the branch can be closed — the measurements are still recorded for anyone who asks later.

What changed

src/mt7612u/ — a standalone C library (~4600 lines including the harness) plus a per-gate bringup tool, and docs/mt7612u.md.

CMakeLists.txt is untouched. There is no IRtlDevice implementation, no WiFiDriver dispatch, no DeviceConfig plumbing and no ctest cell. Nothing in the shipped library changes; CI sees a docs-and-new-directory diff. The subtree builds on its own with make -C src/mt7612u. That integration work is deliberately not done, because doing it before the scope question is answered would be wasted either way.

Why a MediaTek port is small

On the Realtek generations the host programs the synthesizer and carries the PHY tables. Here 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. That is also exactly why it can never hop fast.

What is measured

All on one MT7612U (0e8d:7612, MT_ASIC_VERSION 0x76120044, 2T2R, SuperSpeed) against an RTL8812AU witness running this project's own rxdemo/txdemo.

TX rate is authoritative, unconditionally. Three rates, three exact matches; per-frame alternation 300 sent → 229 received, 229 aired the rate their own index called for, 0 mismatches. The hardware rate LUT cannot override the descriptor:

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

LUT loaded with OFDM 6 Mbps and read back; setting the flag mt76 defines and never sets changed nothing.

TX power — all nine registers identical to what the kernel driver programs for ch149 (MT_TX_PWR_CFG_0..4/7/8/9, MT_TX_ALC_CFG_0 = 2f2f171a), checked against a usbmon capture.

A-MPDU works on injected frames — the opposite of the Jaguar1 result in docs/aggregation.md:

arm frames paggr=1
no AMPDU flag (control) 352 0
AMPDU + QSEL_MGMT 326 326
frame bytes AMPDU off AMPDU on gain
200 7.01 Mbit/s 15.50 Mbit/s 2.21×
1400 34.03 Mbit/s 44.55 Mbit/s 1.31×

Hardware ACK responder — no separate responder-address register exists, so arming retargets the port identity with MT_AUTO_RSP_EN gating; clear moves the identity back, following the finding in #410 that closing the gate alone is not enough.

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

USB bulk aggregationMT_TXD_INFO_NEXT_VLD chains blocks inside one bulk-OUT transfer. mt76 never sets it, so this is not a port and needed on-air proof: 400 frames in 25 transfers, 352 aired.

Also measured: 40 MHz (242/242 frames at bw=1), TSF (200231 µs over a 200000 µs sleep), monitor RX decoding CCK/OFDM/HT with per-chain RSSI, radiotap send_packet.

A register-stream diff against the kernel driver's own probe: 522 kernel EP0 writes vs 521 ours, 376 common addresses, one final-value mismatch (beacon config, which we skip), six kernel-only addresses (all beacon config), and zero addresses we write that the kernel does not.

Two results stated against interest

The async rings bought no throughput. At saturation sync and async both sit at 3040 fps / 34 Mbit/s with an identical 0.329 ms mean submit, because that is the airtime of one frame. What they bought is the callback RX path, concurrent TX+RX on one handle, and 3× lower submit latency below saturation (21 µs vs 65 µs). A-MPDU, not USB parallelism, is what lifted 34 → 44.55 Mbit/s.

The first ACK-responder attempt returned INCONCLUSIVE, not a pass. It used this project's retry-collapse method, but txdemo injects without retries, so there was nothing to collapse. The gate says so rather than reporting success; the passing result above uses a different observable.

What it cannot do

channel switch measured, mean of 8
full, 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, but the floor is four MCU round trips over bulk endpoints plus firmware time — 10–20 ms at best. FHSS and per-packet hopping are out of reach for this part; seconds-scale migration is not. Narrowband 5/10 MHz has no encoding: MT_RATE_BW is two bits with three defined values.

Also worth knowing: unicast injection is a 40× cliff (3037 → 75 fps). The MAC arms an ACK timeout for a peer that never answers, and neither clearing txwi.ack_ctl REQ nor a QoS No-Ack policy prevents it. A one-way link must use broadcast.

Counterparts

Stated because the numbers above are uniformly favourable.

  • One physical unit, one sample. No second MT7612U, no second board revision.
  • One witness generation. Every on-air number is an RTL8812AU running this project's rxdemo. paggr, bw and rate are that implementation's reading, not an independent instrument.
  • TX power was verified against the kernel's registers, not radiated power. No spectrum analyser, no power meter. The claim is "identical to what mt76 programs", nothing more.
  • The RX gain correction has never done anything. This EEPROM carries no gain calibration, so every term is zero and that path is unexercised.
  • 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, and reset_wlan + power_on demonstrably does not clear the firmware-running bit.
  • Nothing here runs in CI. No ctest, no sanitizer build, no lifecycle soak.
  • 80 MHz, VHT on air and NSS=2 are unexercised.

One more, on the code rather than the measurements: enabling MAC RX without draining the bulk-IN endpoint wedges the chip below USB level, unrecoverable by libusb_reset_device(), the authorized toggle or the kernel driver — only a physical replug. Fixed by never enabling RX for a caller that will not drain it, plus an endpoint flush (20 consecutive cycles clean afterwards against a death after ~5 before). Two things changed at once, so that run does not attribute the wedge to one of them.

Verification

  • make -C src/mt7612u clean at -Wall -Wextra.
  • Every gate listed in src/mt7612u/README.md run on hardware; the numbers above are those runs.
  • The library target and CI are untouched by construction — the diff adds a directory and a doc, and modifies nothing.

Open list

If this is in scope, the work I would continue with, in order:

  1. IRtlDevice, WiFiDriver dispatch, DeviceConfig, CMakeLists.txt, ctest cells.
  2. mt76x2_phy_tssi_compensate() — without it output power drifts with die temperature.
  3. Cold-boot verification on a host with switchable USB power.
  4. A second sample and a second witness generation.
  5. 80 MHz; VHT and NSS=2 on air.
  6. Retune tuning (batch registers via CMD_RANDOM_WRITE, drop the inter-command sleep) — worth doing only if 10–20 ms is useful to someone.
  7. Whether the single MCS6 frame in the rate-LUT control arm is a witness artefact or a real fallback. Unexplained.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Tba83kymS5W2v1vn2yRxrj

## Problem

Every backend here is Realtek. A consumer scoping a fixed-channel, fixed-rate
video link had an MT7612U on the bench and no way to drive it from this
library. Whether MediaTek silicon belongs in this project at all is a scope
question for the maintainers, and one worth answering against measurements
rather than a proposal.

## Change

Adds `src/mt7612u/`, a standalone C library plus a per-gate bringup harness,
and `docs/mt7612u.md` carrying the measurements, the methods and the limits.

`CMakeLists.txt` is untouched. There is no `IRtlDevice` implementation and no
`WiFiDriver` dispatch, so nothing in the shipped library changes and CI sees a
docs-and-new-directory diff. The subtree builds on its own with `make -C
src/mt7612u`.

What is measured on hardware, against an RTL8812AU witness running this
project's own rxdemo/txdemo:

- TX rate is authoritative: three rates match exactly, per-frame alternation is
  229/229, and the hardware rate LUT cannot override the descriptor even with
  MT_TXWI_FLAGS_TX_RATE_LUT set.
- TX power: all nine registers identical to what the kernel driver programs for
  the same channel.
- A-MPDU works on injected frames (paggr 0/352 control vs 326/326 armed),
  lifting 34.03 to 44.55 Mbit/s at 1400 bytes and 7.01 to 15.50 at 200. This is
  the opposite of the Jaguar1 result in docs/aggregation.md.
- Hardware ACK responder: 0 frames at the stimulus radio unarmed, 3500+ ACKs
  armed.
- 40 MHz, TSF, monitor RX with per-chain RSSI, radiotap send_packet and
  send_packets with USB chaining via MT_TXD_INFO_NEXT_VLD.

What it cannot do, measured: channel switch is 526 ms full / 48 ms with
calibration skipped against 0.5-2.5 ms on the Realtek parts, because the RF
plane lives behind the MCU. FHSS is out of reach. Narrowband 5/10 MHz has no
encoding in the rate word.

docs/mt7612u.md carries a Counterparts section: one unit, one witness
generation, TX power verified against registers rather than radiated power, an
RX gain path that has never done anything because this EEPROM has no
calibration, no cold-boot test, and nothing in CI.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tba83kymS5W2v1vn2yRxrj

@josephnef josephnef left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Scope: yes. A MediaTek backend belongs here. The project will need a refactor first, and that is the first milestone rather than wiring this subtree in as-is.

The seams the refactor has to open, from reading the current tree:

  • IRtlDevice / RtlAdapter / WiFiDriver::CreateRtlDevice are Realtek-branded names on an interface that is already vendor-neutral (Init / InitWrite / StartRxLoop / send_packet / SetMonitorChannel / FastRetune / GetAdapterCaps). That is a rename, not a redesign.
  • WiFiDriver dispatches from a SYS_CFG2 read issued as a Realtek vendor request. It needs a vendor-neutral VID:PID gate in front of that read, the way Kestrel already gates PID-first.
  • IRtlTransport is shaped around 16-bit registers and Realtek bulk endpoints. MT7612U is 32-bit registers plus in-band MCU commands over bulk EP8/EP5, so the MediaTek backend gets its own transport behind the same factory rather than a shim over the Realtek one.
  • A ChipGeneration entry, a DEVOURER_MT7612U CMake option following the DEVOURER_HAVE_* pattern, and a regress.py cell against the MediaTek vendor driver under reference/.

Licensing is fine. I checked the mt76 file headers upstream: they really are BSD-3-Clause-Clear, so the attribution here is accurate and the code is GPL-2 compatible. A subtree LICENSE notice would be good practice in this tree.

The code has bugs that sit under the measurements, so it is not mergeable even as an unwired subtree. Inline below, most severe first: the public API is declared but never defined; RX corrupts every QoS-data header; TX header length is wrong for BAR/BA/CF-End; async teardown frees in-flight TX transfers; a failed register read feeds all-ones into every RMW; and the harness's caps/ack gates enable MAC RX without draining it, which is the wedge pattern the doc says is fixed.

Two cross-cutting items:

  • initvals.h says it is generated by a script in PLAN.md; neither the script nor PLAN.md / INVESTIGATION.md / BRINGUP-RESULTS.md is in the PR, and 13 comments point at them. This project's rule is that tables come from tools/extract_*.py with pinned source hashes and a --check mode that reproduces the checked-in output. The hand-typed constants in phy.c and init.c should come from the same generator.
  • Windows/MSVC is first-class here. __builtin_ctz under every FIELD_PREP/GET, pthreads throughout async.c, nanosleep, clock_gettime, the <libusb-1.0/libusb.h> include path and the gcc-only Makefile all need to land in portability shims during the integration, not per-platform gating.

Comment thread src/mt7612u/include/mt7612u/mt7612u.h
Comment thread src/mt7612u/rx.c Outdated
Comment thread src/mt7612u/tx.c Outdated
Comment thread src/mt7612u/async.c
Comment thread src/mt7612u/usb.c Outdated
Comment thread src/mt7612u/tools/bringup.c Outdated
Comment thread src/mt7612u/initvals.h Outdated
Comment thread src/mt7612u/regs.h Outdated
Comment thread src/mt7612u/radiotap.c Outdated
Comment thread src/mt7612u/caps.c Outdated
snokvist and others added 5 commits September 6, 2026 17:00
## Problem

Review of OpenIPC#412 found bugs that the hardware measurements sat on top of. Two
of them change what the driver puts on air or hands to a caller, one is a
use-after-free during teardown, and one made the "standalone library" claim
false.

## Change

Ten confirmed findings, each with the fix and its evidence.

**The public header declared nine functions that had no definition anywhere**
(`mt7612u_open/_close/_keep_detached/_set_channel/_set_chainmask/_start/
_stop/_asic_version/_mac_addr`). The bringup tool builds because it calls
internals directly, so nothing caught it. They are now defined, and
`tests/api_link.c` takes the address of all twenty public entry points while
including only the public header, so the link fails if a declaration ever
loses its definition again. `set_channel` also disagreed with the internal
signature on `unsigned` vs `uint8_t`; it is `uint8_t` now.

**The RX L2-pad fold moved a fixed 24 bytes.** L2PAD is only ever set when
the header is not 4-aligned, i.e. 26 bytes (QoS) or 30 (4-address), so the
last two header bytes were left behind and overwritten by the pad. On a QoS
frame those two bytes are the QoS Control field, which means every TID and
ack-policy reached the callback as zero. It now moves the real header
length, as mt76x02_remove_hdr_pad() does.

**Header length treated only RTS and PS-Poll as 16-byte control frames.**
BlockAckReq, BlockAck and both CF-End subtypes are also 16, so on TX the L2
pad was inserted ten bytes in, inside the frame. Replaced with a port of
ieee80211_hdrlen(): control frames are 16 by default and only CTS and ACK
are 10. That is the same function mt76 reaches via
ieee80211_get_hdrlen_from_skb() on both sides, so TX and RX now share one
implementation instead of two that could drift.

Both are covered by `tests/frame_shape.c`, which also carries a negative
control: it redoes the old fixed-24 fold and asserts the QoS Control really
is destroyed, so a passing test says something. Reverting either fix makes
it fail - the RX one reports `QoS Control zeroed by the pad fold: aa aa`.

**mt_async_stop() cancelled only RX, then freed every transfer.** On a
wedged chip - the case this driver's own notes describe - TX URBs never
complete, and libusb owns a submitted transfer until its callback runs. Now
both rings are cancelled, both are waited on with the event thread still
running, and if either still has transfers outstanding at the deadline the
ring is deliberately leaked rather than freeing memory the kernel may still
write into. Each slot names its own ring instead of dev->a, so a leaked
ring's late completions cannot touch a replacement one. The `fail:` path no
longer joins a zeroed pthread_t. `running`, `rx_active` and both inflight
counters move under the mutex - volatile orders nothing and makes no
read-modify-write atomic - and the statistics move with them behind
mt_async_stats(), because the harness was reading them live off the struct.
A caller blocked waiting for a TX slot now gives up when the ring stops.

**mt_rr() returned ~0u on failure and mt_rmw() consumed it unchecked**, so
one exhausted EP0 read wrote 0xFFFFFFFF | val into MT_WLAN_FUN_CTRL,
MT_MAC_SYS_CTRL or the BBP AGC block. 0xffffffff is a real value on this
part (MT_MAC_CSR0 reads it while the core comes up), so it cannot double as
a sentinel: mt_rr_chk() reports failure separately, mt_rmw() refuses to
write after a failed read, and mt_wait_for_mac() distinguishes "not ready"
from "no transport". mt_poll() polls against a real deadline and aborts on
a read failure - one register access can cost VEND_RETRIES * timeout, so
counting sleeps let a caller asking for 200 ms block for seconds. The
control timeout drops to mt76's own 300 ms.

**mt_wr_copy() rounded len up and then memcpy'd from the caller's buffer**,
over-reading whenever len % 4. Latent at the current call sites (8 and 32
bytes); it now rounds the transfer up and zero-fills instead.

**Two bringup gates enabled MAC RX and never drained EP 4** - the exact
wedge pattern documented three paragraphs above them, and one of them then
sat through two 200 ms sleeps and a channel switch. Both start the ring
before the receiver. mt7612u_start() enables RX only when an RX ring is
already running, and the header says why.

**Radiotap DBM_TX_POWER was parsed and silently dropped.** It now says so
once per process, pointing at the two knobs that do work.

**The mt76 TSF bug claim now carries its citation** - mt76x02_usb_core.c:155-158,
where tsf = (u64)dw0 << 32 | dw1 feeds only the dev_dbg() on the next line,
which is why the order has survived upstream.

Also fixed while in send_packets(): the radiotap parse moved into the
selection pass. A frame the build pass could still reject would break the
chain it was building, since NEXT_VLD and the single trailing zero word are
assigned by position - dropping whichever frame happened to be last left the
transfer unterminated.

## Verification

`make -C src/mt7612u check` - twenty public entry points resolved,
frame_shape PASS. Both new tests were mutation-tested: removing one
definition, and reverting each frame-shape fix, each makes them fail.
No hardware and no privileges needed. Clean build, no warnings.

`CMakeLists.txt` is still untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tba83kymS5W2v1vn2yRxrj
## Problem

`src/mt7612u/initvals.h` was hand-copied from mt76 and pointed at a
regeneration script in a PLAN.md that is not in this repository. The project
rule is that tables come from `tools/extract_*.py` against a pinned
`reference/` tree with a `--check` mode, so the checked-in artifact stays
re-verifiable from a fresh checkout.

## Change

Adds `reference/mt76` as a pinned shallow submodule (`openwrt/mt76` at
`be5ce79`) and `tools/extract_mt7612u_tables.py`, following the
`extract_8733b_*.py` shape: `UPSTREAM` string, per-source SHA-256, an
`EXPECTED` count and table hash, and `--check` that byte-compares the
checked-in header.

The source here is C initialiser syntax rather than a vendor parameter blob,
so the script carries a symbol table over `mt76x02_regs.h` and a small
constant-expression evaluator for `BIT` / `GENMASK` / `FIELD_PREP`. Both are
deliberately narrow - they understand only what these definitions use and
raise on anything else rather than guessing. That is what lets the four
`DEFAULT_PROT_CFG_*` macros, defined inside `mt76_write_mac_initvals()`
itself, be computed rather than copied.

`reference/mt76` is the one entry there that is not a Realtek vendor drop:
the MediaTek parts have no out-of-tree vendor driver to mirror, mt76 is the
mainline reference, and it is BSD-3-Clause-Clear rather than GPL-2-only,
which is why `src/mt7612u/` can carry ported sequences at all.
`reference/README.md` says so.

## Verification

The generator reproduces the previously hand-typed table byte for byte - all
sixty rows, register addresses and values, including the four computed
protection-config words. So this also independently confirms the original
transcription was correct rather than silently replacing it.

`--check` is load-bearing: appending one newline to the header makes it exit
1 with `stale generated output`.

No CI workflow checks out submodules, so the twenty builds are unaffected.
`make -C src/mt7612u check` still passes. `CMakeLists.txt` still untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tba83kymS5W2v1vn2yRxrj
## Problem

Review of OpenIPC#412 asked for MSVC/Windows portability, which is first-class in
this project. Three of the items are correctness issues independent of any
compiler; the rest depend on how the subtree eventually joins the build.

## Change

**`_SHIFT` was a reserved identifier.** Leading underscore followed by a
capital is reserved to the implementation in every scope. Renamed.

**`FIELD_PREP`/`FIELD_GET` no longer call `__builtin_ctz`.** MSVC has no such
builtin, and the obvious substitute, `_BitScanForward`, is a function with an
out-parameter and therefore cannot appear in a constant expression. These
macros must stay constant expressions: `ext_cca_chan` in phy.c is a static
table built from them. `MT_CTZ` isolates the low bit and binary-searches its
position - a constant expression on every compiler, folded to one instruction
under optimisation.

**libusb include path.** `<libusb.h>` is what the rest of this project
includes, letting the build system supply the directory; the standalone
Makefile has no pkg-config step and most distributions ship the header under
`libusb-1.0/`. `__has_include` tries the project spelling first and falls
back.

## What is deliberately not here

`async.c` uses pthreads and `usb.c` uses `nanosleep`/`clock_gettime`. This
project has no C threading or time shim - its shim is the C++ standard
library, which every other backend uses directly. A C shim written now would
be deleted when the subtree joins the build, so those two files keep POSIX
until then. They are the only two files involved, and the README says so.

Likewise the Makefile stays a Makefile: the subtree is deliberately not
reachable from `CMakeLists.txt` yet, so a CMake target now would be a target
nothing builds.

## Verification

`tests/field_macros` is new and checks the replacement against
`__builtin_ctz` over every mask the driver can form - all 32 single-bit masks
and all 528 contiguous `GENMASK(h, l)` ranges - plus a `FIELD_PREP`/
`FIELD_GET` round-trip on each. It carries a static initialiser built from
`FIELD_PREP`, so if `MT_CTZ` ever stops being constant-foldable the test
fails to compile rather than silently passing at runtime.

Both libusb spellings build and pass: default, and with the header on the
include path directly.

`make -C src/mt7612u check`: 20 entry points, frame_shape PASS,
field_macros 528 masks PASS. No warnings. `CMakeLists.txt` untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tba83kymS5W2v1vn2yRxrj
The three fix commits are re-measured on the same bench and the same
RTL8812AU witness. Nothing regressed, and the A-MPDU figures reproduce the
original PR numbers exactly.

- `init` clean, so the 300 ms control timeout and the deadline-based
  `mt_poll` did not break bring-up.
- `rtap`: tag A 401 frames, tag B 388 - `send_packet` and `send_packets`
  both still air, with `send_packets` still chaining 16 frames per USB
  transfer.
- `ampdu`: 41 734 of 85 580 witnessed frames carry `paggr=1`. 6.26 ->
  15.50 Mbit/s at 200 bytes, 34.05 -> 44.55 at 1400 - the same two figures
  the PR reported.
- `caps`: PASS on three consecutive cycles with the RX ring now started
  before the receiver rather than after it. 40 MHz re-confirmed at 300/300
  frames reporting `bw=1`, against a witness also at 40 MHz. TSF 200347 us
  over a 200000 us sleep.
- `arx` on ch1: 278 ambient frames, `rx_err=0`, CCK/OFDM/HT all decoded
  through the rewritten L2-pad fold.

The header-length fix has no on-air observable in these gates: it only
changes control frames, and nothing here injects one. The unit test with its
negative control is the evidence for that one.

Counterparts updated: the offline tests and the generator's `--check` pass
locally, but no workflow runs them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tba83kymS5W2v1vn2yRxrj
## Problem

`radiotap.c` mapped VHT bandwidth codes 1-3 to 40 MHz and >=4 to 80. The
code names a channel width and the sub-channel used within it, so 2 is
"40 (20L)" - a 20 MHz frame in the lower half of a 40 MHz channel, not a
40 MHz frame. A caller asking for 20-in-40 got 40 MHz on air, and 20-in-80
got 80.

This contradicted the file's own HT branch, which reads the equivalent HT
codes correctly - and this project's `src/ieee80211_radiotap.h:112` names
them `IEEE80211_RADIOTAP_MCS_BW_20L` and `_20U`, which is the same
semantics spelled out in the repository already.

## Change

A table over the eleven codes this radio can express, mapping each to the
width the frame is actually sent at. Codes 11 and above are 160 MHz and its
sub-channels; the rate word has no 160 MHz encoding, so those log and fall
back to 20 rather than silently narrowing.

## Verification

`tests/frame_shape` gains the whole table, all eleven codes. Restoring the
old expression fails eight of them, so the test is load-bearing rather than
decorative.

## Elsewhere in this repository

`src/jaguar1/RtlJaguarDevice.cpp:1097-1100` and
`src/jaguar3/RtlJaguar3Device.cpp:1940-1943` carry the same
`bw >= 1 && bw <= 3 -> CHANNEL_WIDTH_40`, `bw >= 4 && bw <= 10 ->
CHANNEL_WIDTH_80` mapping, and are not touched here - a shipping backend's
on-air behaviour is not something to change from inside a new-backend PR.
Reported so it can be triaged separately.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tba83kymS5W2v1vn2yRxrj
@snokvist

snokvist commented Sep 6, 2026

Copy link
Copy Markdown
Collaborator Author

Thanks — that review was worth more than the measurements were. Every one of the eleven findings was real, including the VHT one I initially thought I could defend. All are fixed and answered in-thread; I have left the threads for you to resolve.

Five commits on top of the original:

2142a5a the nine confirmed defects
c64975d initvals.h generated instead of transcribed
2ac9b9a the portability fixes that stand on their own
081f952 offline tests documented, everything re-measured on hardware
a9cd156 the radiotap VHT bandwidth mapping

Two of the fixes change what the driver puts on air or hands to a caller, so nothing here is claimed from a rebuild alone. The gates were re-run on the same bench against the same RTL8812AU witness: rtap tags A 401 / B 388, ampdu 41 734 of 85 580 witnessed frames with paggr=1 and the same 15.50 and 44.55 Mbit/s the PR reported, caps clean on three consecutive cycles with the RX ring now started before the receiver, 40 MHz re-confirmed 300/300 at bw=1, and 278 ambient frames on ch1 through the rewritten pad fold with rx_err=0.

make -C src/mt7612u check is new and runs three offline binaries — no hardware, no privileges. Each was mutation-tested rather than merely written: reverting any one of the frame-shape fixes, the VHT mapping or a public definition makes the suite fail. The header-length fix has no on-air observable in these gates, because nothing here injects a control frame, and the docs say that rather than implying otherwise.

CMakeLists.txt is still untouched.


On sequencing. Your review reads two ways and it changes what I build next, so let me state how I read it and you can correct me.

the code has bugs that sit under the measurements, so it is not mergeable even as an unwired subtree

I took that to mean an unwired subtree is the mergeable shape once the bugs are gone, and that

the project will need a refactor first, and that is the first milestone rather than wiring this subtree in as-is

puts the refactor before wiring, not before landing. So my plan is: this PR lands unwired, and the refactor plus integration arrives as its own PR. Bundling a rename that touches all three shipping backends together with a brand-new backend makes one diff nobody can review. Say the word if you meant the other order and I will hold this one.

On the refactor itself, one question, because I would rather ask than guess at the shape of your codebase. Everything substantive in your list I agree with: a vendor-neutral VID:PID gate ahead of the Realtek SYS_CFG2 read the way Kestrel already gates PID-first, MediaTek getting its own transport behind the same factory rather than a shim over IRtlTransport (32-bit registers plus in-band MCU over EP8/EP5 do not fit it), a ChipGeneration entry, a DEVOURER_MT7612U option following DEVOURER_HAVE_*, and a regress.py cell.

The part I would push back on gently is the rename. IRtlDevice is already vendor-neutral in substance — Init / InitWrite / StartRxLoop / send_packet / SetMonitorChannel / FastRetune / GetAdapterCaps say nothing about Realtek — so a MediaTek backend does not need the rename to sit behind it, and doing it inside this work would touch every backend for a naming reason. I would rather implement against IRtlDevice / WiFiDriver::CreateRtlDevice as they stand, and send the rename separately as its own mechanical, reviewable-in-isolation PR if you still want it. Your call — it is your codebase, and if you would rather have the rename first I will do that instead.

That also decides where the remaining portability work lands. async.c still uses pthreads and usb.c nanosleep/clock_gettime; this project has no C threading or time shim because its shim is the C++ standard library. Rather than write a C shim that gets deleted at integration, I would like to do that swap as part of the integration commit, where the subtree joins the build and the answer is <thread>/<chrono> like everywhere else. Two files, and the README already names them.

snokvist and others added 2 commits September 6, 2026 17:21
`tests/field_macros` was staged in 2ac9b9a before `.gitignore` learned about
it, so a 300 KB build artifact went into the tree. The ignore entry landed
one commit later and could not retroactively untrack it.

The other two test binaries were never tracked; `git ls-files
src/mt7612u/tests/` now lists sources only.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tba83kymS5W2v1vn2yRxrj
## Problem

`Packet::Data` in `src/RxPacket.h` is documented as the full 802.11 frame
including the trailing FCS, and every Realtek parser here honours it. Whether
MT7612U can is an integration question, and guessing at it is how a
four-byte truncation ships.

Every RX buffer does carry 4-7 bytes past `MPDU_LEN` - over 4263 ambient
frames the tail was exactly 4 on 3375 of them and 5-7 on the rest, which
reads precisely like a fixed 4-byte field plus USB 4-byte alignment. So the
obvious move is to hand a consumer `len + 4` and call it the FCS.

## What it actually 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 before the negative was believed.

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`. mt76 never sets `RX_FLAG_INCLUDE_FCS` for this
family either. The MAC strips the checksum and does not hand it up.

## Change

Documentation only - the code was already correct, it just did not say what
it could not do. `docs/mt7612u.md` gains the measurement under RX and a
Counterparts entry; the public header's RX callback says the frame carries no
FCS and why the trailing bytes are not one.

This matters at the boundary: a consumer that trims four bytes because the
`Packet::Data` contract invites it - as `tools/bf_report_decode.py` already
does - would eat four bytes of payload off every frame. Whatever shape
integration takes, the divergence gets declared rather than smoothed over.

Also here: `tests/field_macros` was untracked in the previous commit after
slipping past `.gitignore`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tba83kymS5W2v1vn2yRxrj
@snokvist

snokvist commented Sep 6, 2026

Copy link
Copy Markdown
Collaborator Author

One more, found while working out what the integration has to look like, and it is a limit rather than a fix.

Packet::Data in src/RxPacket.h is documented as the full 802.11 frame including the trailing FCS, and every Realtek parser here honours that. MT7612U cannot.

Every RX buffer does carry 4–7 bytes past MPDU_LEN: over 4263 ambient frames the tail was exactly 4 on 3375 of them and 5–7 on the rest — a fixed 4-byte field plus USB 4-byte alignment, which is exactly what an FCS would look like. Handing a consumer len + 4 would have looked right in every log.

It is not the FCS. CRC-32 over the MPDU matched those four bytes on 0 of 4263 frames. I checked the probe's own CRC-32 against the standard "123456789"0xcbf43926 vector before believing the negative. They are the FCE info trailer — mt76u_get_rx_entry_len() computes min_len = MT_DMA_HDR_LEN + MT_RX_RXWI_LEN + MT_FCE_INFO_LEN, and dma.h:48 has MT_FCE_INFO_LEN 4. mt76 never sets RX_FLAG_INCLUDE_FCS for this family either: the MAC strips the checksum and does not hand it up.

So a consumer that trims four bytes at its protocol boundary — which the Packet::Data comment invites, and which tools/bf_report_decode.py already does — would eat four bytes of payload off every MT7612U frame.

Documented in docs/mt7612u.md and on the RX callback in the public header (5e85f7a). No code change; the code was already right, it just did not say what it could not do. Raising it now because it is the kind of thing that should be settled before the integration commit rather than discovered inside it — and because it is a real asymmetry against the Realtek backends that a caps flag or a documented boundary will have to carry.

@snokvist

snokvist commented Sep 6, 2026

Copy link
Copy Markdown
Collaborator Author

The integration exists and works, on a branch rather than a second PR — that ordering is yours to call, so I have not opened one: feat/mt7612u-integration, stacked on this branch.

Shape, following your list: RtlMt7612uDevice : IRtlDevice, dispatched on VID:PID ahead of the SYS_CFG2 read (the same place Kestrel gates, for a stronger version of the reason — 0x00FC is a Realtek address issued with a Realtek vendor request, so on MediaTek silicon that read is the wrong protocol rather than merely ambiguous). It does not shim over IRtlTransport: the C library here is the transport, and mt7612u_open_handle() adopts the handle WiFiDriver already opened, reset and claimed rather than reopening it and racing the caller's lock. ChipGeneration::Mediatek, DEVOURER_MT7612U (OFF by default), three ctest cells.

The one place I diverged from your list is the rename, and I have argued the case in the comment above rather than quietly skipping it. Everything else is as you described it.

Two bugs surfaced that only integration could find — both invisible to the bring-up harness, because it never crosses this boundary:

  • RSSI base. devourer carries RSSI as an unsigned byte biased by 110 (LinkHealth.cpp:8). Casting the HAL's signed dBm straight in reported −63 dBm as 193 — a claimed +83 dBm.
  • The receive filter. mt7612u_start() leaves MT_RX_FILTR_CFG at mt76's managed-station value, which drops control frames and anything not addressed here. A/B against the harness on the same channel in the same minute: the integrated path saw 0 OFDM frames of 244, the harness 103 of 402. With a monitor filter the same run takes 7096 frames carrying the full CCK/OFDM/HT mix. Rtl8733bDevice::Init calls configure_monitor_rx for exactly this reason and I had simply not done the equivalent.

The second is the one worth the paragraph: 244 beacons looks like a working receiver. Only the A/B against a second implementation of the same silicon made it visible, and that is now written down in docs/mt7612u.md rather than just fixed.

ctest 58/58 with the option ON, 55/55 OFF; the mapping selftest is mutation-tested; and this project's own rxdemo drives the adapter end to end through the factory.

One thing to flag about the branch split: it also carries a subtree fix that arguably belongs in this PR instead — mt_rx_parse() now drops a frame whose rate word names no valid PHY. MT_RATE_PHY is three bits, so 5–7 are representable and mean nothing; mt76 returns -EINVAL and drops them, while mine decoded one through the default arm and reported the lowest legacy rate, i.e. garbage presented as a real CCK frame. It measured 0 on the bench so it was not the cause of the filter symptom, just a hole found while chasing it. It is entangled with an API addition made for integration, which is why it landed there — say the word and I will split it back into this PR so the subtree is complete on its own.

mt76 never validates a control channel because it never derives one:
mt76x2u_phy_set_channel() takes the segment centre from cfg80211's chandef, so
an off-grid channel cannot reach it. This port takes a bare channel number and
derives the centre from the standard pairing, which means an off-grid channel
still produces *a* number -- and then transmits 40 MHz wide somewhere the
caller did not ask for, with every register write succeeding and nothing
saying so.

Two cases were reachable straight through mt7612u_set_channel(), which checks
only that the channel is non-zero:

  - the arithmetic ran in uint8_t. Control channel 254 computes 256, which
    truncates to 0, handing the MCU channel index 0 with the 5 GHz register
    set loaded; 255 gives 1.
  - in 2.4 GHz the pairing only ever reaches centres 6-9. Channel 1 computes
    -1, i.e. 255 after truncation. Channels 12 and 13 would need a secondary
    above channel 13.

mt_chan40_centre() computes the centre in int and validates it against the
centres that exist, which catches the off-grid case, both wraps and the
out-of-band case together. The 5 GHz ceiling is 159 rather than 175 because
centres 167 and 175 span past the 5825 MHz that mt7612u_caps declares.

Pinned in tests/frame_shape.c, and mutation-tested: removing the validation
makes nine cases fail. Restoring the uint8_t arithmetic does *not* make the
suite fail, because no wrapped value aliases onto a legal centre -- the int is
there so the refusal names 256 rather than reporting 0 and sending whoever
reads it after the wrong bug. The comment says that rather than claiming a
guard it does not provide.

On air, unchanged: `bringup caps 149` still sends 300 frames at 40 MHz and the
RTL8812AU witness still reports 300 at bw=1.

Also: `make check` now depends on `bringup`. Nothing else compiled it, so a
change that broke a gate shipped green -- which is exactly what happened while
preparing this commit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tba83kymS5W2v1vn2yRxrj
@snokvist
snokvist marked this pull request as ready for review September 6, 2026 18:40
@snokvist

snokvist commented Sep 6, 2026

Copy link
Copy Markdown
Collaborator Author

Marked ready for review. It should have been from the start — asking you to weigh in on merge order while leaving it a draft was my mistake, not a signal.

A defect in this branch, found since your review

mt_set_channel_ex()'s 40 MHz case derives the centre channel from the control channel and never validated the result. mt76 doesn't need to — mt76x2u_phy_set_channel() takes center_freq1 from the chandef, so an off-grid channel can't reach it. This port takes a bare channel number, so an off-grid one still produces a number and then transmits 40 MHz wide somewhere the caller didn't ask for, with every register write succeeding.

Two cases reach the MCU straight through mt7612u_set_channel(), which checks only for a zero channel:

  • the arithmetic ran in uint8_t. Control channel 254 computes 256, truncates to 0, and hands the MCU channel index 0 with the 5 GHz register set loaded. 255 gives 1.
  • in 2.4 GHz the pairing only ever reaches centres 6–9, so channel 1 computes −1, i.e. 255 after truncation.

mt_chan40_centre() computes in int and validates against the centres that exist, catching off-grid, out-of-band and both wraps with one check. 2.4 GHz 40 MHz now works for channels 4–11 and is refused elsewhere — a deliberate narrowing, since a bare channel number can't say which side the secondary sits on where channel 6 pairs equally well with 2 or with 10.

The caveats matter more than the fix. No measurement in this PR moves: bringup caps 149 still sends 300 frames at 40 MHz and the witness still reports 300 at bw=1. The bug was never in the path any published number exercised. The nine-case test is mutation-tested, but restoring the uint8_t arithmetic does not make it fail — no wrapped value aliases onto a legal centre, so the int only buys a refusal that names 256 instead of reporting 0 and sending the reader after the wrong bug. The comment says that rather than claiming a guard it doesn't provide.

Also here: make check now depends on bringup. Nothing else compiled it, so a change that broke a gate shipped with everything green — which is how I found this.

The second branch, and a question back

The stacked branch now carries the integration plus 80 MHz, and I've brought its build wiring to parity with src/rtl8733b and src/kestrel: an mt7612uprobe CMake target beside rtl8733bprobe, the offline tests as native ctest executables instead of a make -C shell-out (that one silently ignored CMAKE_C_COMPILER and DEVOURER_SANITIZE), a POSIX FATAL_ERROR guard mirroring DEVOURER_PCIE's, an mt7612u-only CI cell — nothing built this backend before — and -DDEVOURER_MT7612U=OFF in the no-chip-selected control, which was passing on a default rather than on naming every option.

80 MHz airs and is witnessed as 80 MHz, with the negative control: a 20 MHz witness decodes 0 of it, and a witness on a sibling control channel of the same group decodes it, which is what shows both ends resolved the same centre. Still one witness generation; VHT 2SS MCS5–9 thin out at 80 MHz in a way I attribute to link budget without having measured the cause; and the periodic RX gain worker (mt76x2_phy_update_channel_gain) is unported — only its width-dependent registers are.

On sequencing: you asked for the refactor before wiring this in, and I read that as before wiring, not before landing. That is still a guess, and it's the one thing I don't want to decide for you. Either shape is one push from here — land this unwired and keep the integration as a separate PR, or I push the integration commits onto this branch so you review a single full-parity backend against the others. Say which and it's done.

@qodo-free-for-open-source-projects

Copy link
Copy Markdown

PR Summary by Qodo

Add standalone, hardware-validated MT7612U MediaTek backend

✨ Enhancement 🧪 Tests 📝 Documentation ⚙️ Configuration changes 🕐 40+ Minutes

Grey Divider

AI Description

• Adds a standalone MT7612U userspace HAL without integrating the shipped Realtek library.
• Supports firmware bring-up, channel control, radiotap injection, monitor RX, and asynchronous USB
 I/O.
• Documents hardware measurements, limitations, provenance, and offline regression coverage.
Diagram

graph TD
  API["Public API"] --> CORE["Device Core"] --> FW["Firmware MCU"] --> USB["libusb Transport"] --> HW["MT7612U"]
  CORE --> IO["TX RX Paths"] --> RINGS["Async Rings"] --> USB
  HARNESS["Bringup Tests"] --> API
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Integrate through the existing device abstraction now
  • ➕ Would expose compatibility issues with IRtlDevice, WiFiDriver, DeviceConfig, and CI immediately.
  • ➕ Would deliver a directly consumable backend rather than a separate prototype.
  • ➖ Requires substantial integration before maintainers decide whether MediaTek belongs in scope.
  • ➖ The missing RX FCS conflicts with the existing packet contract and needs an explicit design decision.
  • ➖ Adds avoidable review surface to an already hardware-intensive change.
2. Keep MediaTek support in a companion repository
  • ➕ Preserves the project’s Realtek-only scope and naming assumptions.
  • ➕ Allows independent release, portability, and hardware-validation policies.
  • ➖ Fragments shared radiotap and injection behavior.
  • ➖ Makes future adoption and cross-backend testing more difficult.
  • ➖ Duplicates project-specific interfaces if integration is later approved.
3. Drive the existing kernel mt76 backend
  • ➕ Avoids maintaining firmware loading, register initialization, and USB lifecycle code.
  • ➕ Benefits from upstream kernel testing and broader hardware coverage.
  • ➖ Kernel interfaces do not expose all descriptor-level rate, aggregation, and responder controls demonstrated here.
  • ➖ Behavior becomes dependent on kernel scheduling and monitor-mode facilities.
  • ➖ Does not satisfy consumers requiring direct userspace USB ownership.

Recommendation: For a scope decision, the current standalone prototype is the best approach: it isolates risk, provides measurable evidence, and avoids speculative framework changes. It should not merge as a production backend in its current form; if MediaTek is accepted, follow with integration through the existing device abstraction, CI coverage, an explicit FCS-contract resolution, and broader hardware validation. Otherwise, preserve the work in a companion repository or documentation branch.

Files changed (26) +6315 / -1

Enhancement (15) +3907 / -0
async.cImplement asynchronous USB TX and RX rings +301/-0

Implement asynchronous USB TX and RX rings

• Adds a pthread-driven libusb event loop with a 16-transfer RX ring and 32-slot TX pool. Teardown cancels and drains in-flight transfers, deliberately leaking unrecoverable rings rather than risking use-after-free.

src/mt7612u/async.c

caps.cExpose capabilities, TSF access, and ACK responses +118/-0

Expose capabilities, TSF access, and ACK responses

• Implements capability reporting and corrected TSF word ordering. Adds a hardware ACK responder by retargeting the programmed MAC identity and restoring it when cleared.

src/mt7612u/caps.c

eeprom.cParse EEPROM calibration and power data +276/-0

Parse EEPROM calibration and power data

• Reads and validates the 512-byte EEPROM image, extracts adapter identity, and reconstructs per-rate TX power tables. It also derives channel-specific power, LNA, RSSI, and MCU gain calibration values.

src/mt7612u/eeprom.c

fw.cLoad the MT7662 ROM patch and firmware +237/-0

Load the MT7662 ROM patch and firmware

• Implements FCE setup, chunked ROM patch upload, ILM/DLM firmware transfer, and startup polling. Firmware layouts and destination addresses follow the pinned mt76 implementation.

src/mt7612u/fw.c

mt7612u.hDefine the standalone MT7612U public API +182/-0

Define the standalone MT7612U public API

• Introduces lifecycle, channel, power, chainmask, TX/RX, radiotap, ACK responder, TSF, identity, and capability interfaces. Public types describe per-packet rates and decoded receive metadata.

src/mt7612u/include/mt7612u/mt7612u.h

init.cImplement power-on and MAC lifecycle sequencing +445/-0

Implement power-on and MAC lifecycle sequencing

• Ports power, DMA, MAC, address, key-table, and firmware initialization sequences from mt76. RX startup and endpoint flushing guard against hardware wedges caused by enabling an undrained receive path.

src/mt7612u/init.c

initvals.hAdd generated MT7612U MAC initialization values +75/-0

Add generated MT7612U MAC initialization values

• Provides the 60-register initialization table mechanically extracted from the pinned mt76 source, including provenance and a content hash.

src/mt7612u/initvals.h

internal.hDefine backend state and internal module contracts +219/-0

Define backend state and internal module contracts

• Adds device, calibration, asynchronous-ring, and power-table state shared across the implementation. It also declares transport, MCU, PHY, frame, and lifecycle helpers.

src/mt7612u/internal.h

mcu.cImplement the in-band MCU command transport +164/-0

Implement the in-band MCU command transport

• Frames MCU requests for the command endpoint and matches responses by sequence number. Provides helpers for radio state, function selection, gain setup, channel switching, and calibration.

src/mt7612u/mcu.c

phy.cImplement PHY, channel, calibration, and power control +515/-0

Implement PHY, channel, calibration, and power control

• Programs bands, bandwidth, PA settings, EEPROM-derived TX power, RX gain, and firmware calibration sequences. It validates legal 40 MHz center channels and exposes full versus calibration-skipping retune paths.

src/mt7612u/phy.c

radiotap.cAdd radiotap parsing and chained packet injection +307/-0

Add radiotap parsing and chained packet injection

• Decodes legacy, HT, and VHT injection metadata into MT7612U rate descriptors. Supports single-frame injection and multi-frame bulk transfers chained with NEXT_VLD.

src/mt7612u/radiotap.c

regs.hDefine MT7612U registers, descriptors, and field helpers +407/-0

Define MT7612U registers, descriptors, and field helpers

• Collects the trimmed register map, EEPROM fields, MCU commands, USB endpoints, and TX/RX descriptor layouts. Portable constant-expression field macros replace compiler-specific trailing-zero builtins.

src/mt7612u/regs.h

rx.cParse monitor RX descriptors and remove L2 padding +127/-0

Parse monitor RX descriptors and remove L2 padding

• Decodes RXWI rate, aggregation, sequence, error, and per-chain RSSI metadata. It removes hardware L2 padding using the actual 802.11 header length and returns frames without FCS.

src/mt7612u/rx.c

tx.cBuild MT7612U TX descriptors and submit frames +223/-0

Build MT7612U TX descriptors and submit frames

• Constructs per-packet TXWI and TXINFO metadata, including rates, ACK policy, aggregation, power adjustment, WCID selection, and L2 padding. Supports synchronous and asynchronous bulk submission.

src/mt7612u/tx.c

usb.cImplement resilient libusb transport and device ownership +311/-0

Implement resilient libusb transport and device ownership

• Adds checked register access, retrying vendor requests, bounded polling, bulk transfers, block writes, USB reset handling, interface claiming, and kernel-driver detach/reattach behavior.

src/mt7612u/usb.c

Tests (3) +390 / -0
api_link.cVerify every public API symbol resolves +47/-0

Verify every public API symbol resolves

• Links against all 20 public entry points using only the exported header, catching declarations without implementations.

src/mt7612u/tests/api_link.c

field_macros.cValidate portable register-field macros +68/-0

Validate portable register-field macros

• Checks MT_CTZ and field round-trips across all single-bit and contiguous 32-bit masks. A static initializer verifies compile-time constant behavior.

src/mt7612u/tests/field_macros.c

frame_shape.cCover frame layout and channel-width regressions +275/-0

Cover frame layout and channel-width regressions

• Tests management, control, and data header lengths, QoS L2-pad restoration, VHT bandwidth mappings, and valid 40 MHz centers. Negative controls demonstrate the prior corruption and wrapping failure modes.

src/mt7612u/tests/frame_shape.c

Documentation (3) +445 / -1
mt7612u.mdDocument MT7612U measurements, methods, and limitations +320/-0

Document MT7612U measurements, methods, and limitations

• Records hardware evidence for rates, power, aggregation, ACK responses, RX, USB chaining, TSF, and retuning. It also documents test methodology, known constraints, unverified paths, and remaining integration work.

docs/mt7612u.md

README.mdDescribe mt76 as the MediaTek reference source +13/-1

Describe mt76 as the MediaTek reference source

• Expands the reference-driver documentation to include the pinned mainline mt76 source, its licensing, and its role in regenerating MT7612U tables.

reference/README.md

README.mdExplain standalone backend usage and safety constraints +112/-0

Explain standalone backend usage and safety constraints

• Documents the subtree layout, firmware requirements, verification gates, portability status, provenance, and independent build commands. It emphasizes that RX must never be enabled without a draining endpoint.

src/mt7612u/README.md

Other (5) +1573 / -0
.gitmodulesRegister the pinned mt76 reference submodule +5/-0

Register the pinned mt76 reference submodule

• Adds openwrt/mt76 as a shallow reference submodule used to derive and verify MT7612U register sequences.

.gitmodules

mt76Pin the mt76 source revision +1/-0

Pin the mt76 source revision

• Adds the mt76 submodule at commit be5ce7910521492d4a2e4ce7ee3843680a46c047.

reference/mt76

MakefileAdd a standalone MT7612U build and test workflow +44/-0

Add a standalone MT7612U build and test workflow

• Builds the bring-up harness, library objects, and three offline test binaries with libusb and pthreads. Header dependencies prevent stale object layouts, while the check target runs hardware-independent tests.

src/mt7612u/Makefile

bringup.cAdd per-gate hardware bring-up and measurement harness +1221/-0

Add per-gate hardware bring-up and measurement harness

• Provides independently runnable gates for registers, firmware, initialization, channels, TX/RX, rate control, throughput, duplex operation, power, A-MPDU, capabilities, ACK response, radiotap, and retuning. Outputs are designed for witness-radio and kernel-register comparisons.

src/mt7612u/tools/bringup.c

extract_mt7612u_tables.pyGenerate MAC tables from pinned mt76 sources +302/-0

Generate MAC tables from pinned mt76 sources

• Parses mt76 C initializers and a constrained set of constant expressions to generate initvals.h. Source hashes, expected row counts, output hashes, and check mode make provenance reproducible.

tools/extract_mt7612u_tables.py

@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented Sep 6, 2026

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (3) 📘 Rule violations (2) 📎 Requirement gaps (0) 🎨 UX issues (0) 🔗 Cross-repo conflicts (0) 📜 Skill insights (0)

Grey Divider


Action required

1. Callers can open an unintended adapter 📘 Rule violation ≡ Correctness
Description
open_selected() reads MT7612U_DEV directly from the process environment instead of receiving
adapter selection through an explicit construction setting. When that variable is inherited or
changed outside the caller, it controls which matching device mt7612u_open() opens and locks.
Code

src/mt7612u/usb.c[388]

+	const char *sel = getenv("MT7612U_DEV");
Evidence
Compliance rule 7 prohibits library code from reading environment variables. The new library reads
MT7612U_DEV in open_selected() and uses it to choose among matching USB adapters.

CLAUDE.md: Keep Environment Parsing Out of the Library: CLAUDE.md: Keep Environment Parsing Out of the Library: CLAUDE.md: Keep Environment Parsing Out of the Library: CLAUDE.md: Keep Environment Parsing Out of the Library: CLAUDE.md: Keep Environment Parsing Out of the Library: CLAUDE.md: Keep Environment Parsing Out of the Library: CLAUDE.md: Keep Environment Parsing Out of the Library: CLAUDE.md: Keep Environment Parsing Out of the Library
src/mt7612u/usb.c[386-388]
src/mt7612u/usb.c[416-425]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Remove environment-variable parsing from the MT7612U library and pass adapter selection through an explicit construction setting.
## Issue Context
`open_selected()` currently reads `MT7612U_DEV`, making library behavior depend on ambient process state. Parse any environment variable in the bringup tool or demo layer, then provide the selected adapter to the library explicitly.
## Fix Focus Areas
- src/mt7612u/usb.c[386-425]
- src/mt7612u/include/mt7612u/mt7612u.h[105-127]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. Some radios transmit at wrong power 🐞 Bug ≡ Correctness
Description
power_info_5g() initializes target_power from the unrelated MT_EE_RF_2G_RX_HIGH_GAIN
receive-calibration field rather than the selected 5 GHz transmit-power record. When TSSI is
disabled and that byte is considered valid, the fallback to chain 0 does not run and every 5 GHz
per-rate power register is normalized using the wrong target.
Code

src/mt7612u/eeprom.c[185]

+	t->target_power = mt_ee(d, MT_EE_RF_2G_RX_HIGH_GAIN) & 0xff;
Evidence
The selected 5 GHz record already supplies each chain's target at p[2], but the aggregate target
is instead loaded from a field explicitly declared as 2.4 GHz RX high gain. The later fallback
replaces that value only when TSSI is enabled or the byte is invalid, and the PHY path otherwise
adds it to and normalizes the complete rate-power table.

src/mt7612u/eeprom.c[157-185]
src/mt7612u/eeprom.c[204-229]
src/mt7612u/regs.h[360-387]
src/mt7612u/phy.c[226-275]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The 5 GHz power path derives its aggregate target power from a 2.4 GHz receive-gain EEPROM field. Use the target from the selected 5 GHz transmit-power record and preserve the intended TSSI fallback behavior.
## Issue Context
The current measured adapter enables TSSI, which masks this defect by replacing the value with chain 0's target. EEPROM configurations with TSSI disabled retain the unrelated receive-gain byte and use it to normalize all rate powers.
## Fix Focus Areas
- src/mt7612u/eeprom.c[157-186]
- src/mt7612u/eeprom.c[204-229]
- src/mt7612u/phy.c[226-275]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


3. Rate tests pass when nothing is sent ✓ Resolved 🐞 Bug ≡ Correctness
Description
gate_coding(), gate_sweep(), and gate_vht() record or print transmission shortfalls but
unconditionally return zero. If the count is nonpositive or every mt7612u_tx() call fails, the
tool exits successfully and leaves the external witness with no frames from which to validate the
advertised rate results.
Code

src/mt7612u/tools/bringup.c[R1787-1790]

+	mt_mac_stop(&dev);
+	printf("\n%d arms swept. Payload offset 12 is the expected DESC_RATE,\n"
+	       "offset 14 the expected LDPC|STBC|SGI bits.\n", arms);
+	return 0;
Evidence
Each gate increments sent only for successful submissions and exposes the shortfall in output, but
all three still return zero. The command dispatcher passes unchecked atoi() counts directly to
these functions and returns their result, making both rejected submissions and vacuous
negative-count loops successful executions.

src/mt7612u/tools/bringup.c[1771-1790]
src/mt7612u/tools/bringup.c[1871-1895]
src/mt7612u/tools/bringup.c[1966-1985]
src/mt7612u/tools/bringup.c[2132-2143]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Make the coding, sweep, and VHT measurement gates return failure when their requested frames are not submitted, and reject nonpositive frame counts before running a gate.
## Issue Context
These gates currently display submission counts but always return success. Their caller propagates that return value as the tool's exit status, so automation cannot distinguish a completed measurement from one that transmitted nothing.
## Fix Focus Areas
- src/mt7612u/tools/bringup.c[1773-1790]
- src/mt7612u/tools/bringup.c[1875-1895]
- src/mt7612u/tools/bringup.c[1969-1985]
- src/mt7612u/tools/bringup.c[2132-2143]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


View action required (10)
4. Asynchronous packet bursts are dropped ✓ Resolved 🐞 Bug ≡ Correctness
Description
mt_async_tx_submit() rejects every transfer above MT_TX_BUFSZ even though the public TX builder
and aggregate sender construct transfers up to MT_TX_BUF_MAX and MT_USB_AGG_BUF. Once
receive-driven asynchronous mode is active, a normal large MPDU or a multi-frame send_packets()
batch above 2048 bytes is built successfully but rejected before USB submission, and the aggregate
API returns zero accepted frames.
Code

src/mt7612u/async.c[272]

+			if (!a->tx_busy[i]) { idx = i; break; }
Evidence
The async slot storage and submit guard are limited to 2048 bytes. In contrast, single-frame TX uses
a 4096-byte buffer and aggregate TX packs frames into a 16384-byte buffer before routing either path
to the same async submit function.

src/mt7612u/internal.h[53-80]
src/mt7612u/async.c[260-303]
src/mt7612u/tx.c[219-236]
src/mt7612u/radiotap.c[252-344]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The asynchronous TX ring only has 2048-byte slot buffers, but public TX paths build larger single-frame and aggregated bulk transfers and route them through this ring when async mode is active.
## Issue Context
A receive callback starts the async ring. `mt7612u_send_packets()` can intentionally build a transfer up to the aggregate buffer size, while `mt_async_tx_submit()` rejects it before submission.
## Fix Focus Areas
- src/mt7612u/internal.h[53-80]
- src/mt7612u/async.c[260-303]
- src/mt7612u/radiotap.c[252-344]
- src/mt7612u/tx.c[219-236]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


5. Stuck transfers outlive USB teardown ✓ Resolved 📘 Rule violation ☼ Reliability
Description
mt_async_stop stops the event thread and returns after two seconds even when tx_inflight or
rx_inflight is still nonzero. When cancellation completion is delayed, mt7612u_close proceeds
through mt_close, releasing the interface, handle, and libusb context while libusb still owns the
leaked transfers.
Code

src/mt7612u/async.c[R213-216]

+		ERR("async stop: %d TX and %d RX transfers still in flight after 2 s "
+		    "- leaking the ring rather than freeing memory libusb owns",
+		    stuck_tx, stuck_rx);
+		return;
Evidence
Rule 14 requires all asynchronous transfers to be cancelled and reaped before the interface, handle,
or libusb context is released. The timeout path returns with outstanding transfers, while the close
path subsequently tears down each USB resource.

CLAUDE.md: Destroy Devices Before Tearing Down libusb: CLAUDE.md: Destroy Devices Before Tearing Down libusb: CLAUDE.md: Destroy Devices Before Tearing Down libusb: CLAUDE.md: Destroy Devices Before Tearing Down libusb: CLAUDE.md: Destroy Devices Before Tearing Down libusb: CLAUDE.md: Destroy Devices Before Tearing Down libusb: CLAUDE.md: Destroy Devices Before Tearing Down libusb: CLAUDE.md: Destroy Devices Before Tearing Down libusb: CLAUDE.md: Destroy Devices Before Tearing Down libusb: CLAUDE.md: Destroy Devices Before Tearing Down libusb: CLAUDE.md: Destroy Devices Before Tearing Down libusb: CLAUDE.md: Destroy Devices Before Tearing Down libusb: CLAUDE.md: Destroy Devices Before Tearing Down libusb: CLAUDE.md: Destroy Devices Before Tearing Down libusb: CLAUDE.md: Destroy Devices Before Tearing Down libusb: CLAUDE.md: Destroy Devices Before Tearing Down libusb: CLAUDE.md: Destroy Devices Before Tearing Down libusb: CLAUDE.md: Destroy Devices Before Tearing Down libusb: CLAUDE.md: Destroy Devices Before Tearing Down libusb: CLAUDE.md: Destroy Devices Before Tearing Down libusb: CLAUDE.md: Destroy Devices Before Tearing Down libusb: CLAUDE.md: Destroy Devices Before Tearing Down libusb: CLAUDE.md: Destroy Devices Before Tearing Down libusb: CLAUDE.md: Destroy Devices Before Tearing Down libusb: CLAUDE.md: Destroy Devices Before Tearing Down libusb: CLAUDE.md: Destroy Devices Before Tearing Down libusb: CLAUDE.md: Destroy Devices Before Tearing Down libusb: CLAUDE.md: Destroy Devices Before Tearing Down libusb: CLAUDE.md: Destroy Devices Before Tearing Down libusb: CLAUDE.md: Destroy Devices Before Tearing Down libusb: CLAUDE.md: Destroy Devices Before Tearing Down libusb: CLAUDE.md: Destroy Devices Before Tearing Down libusb: CLAUDE.md: Destroy Devices Before Tearing Down libusb: CLAUDE.md: Destroy Devices Before Tearing Down libusb: CLAUDE.md: Destroy Devices Before Tearing Down libusb: CLAUDE.md: Destroy Devices Before Tearing Down libusb: CLAUDE.md: Destroy Devices Before Tearing Down libusb: CLAUDE.md: Destroy Devices Before Tearing Down libusb: CLAUDE.md: Destroy Devices Before Tearing Down libusb: CLAUDE.md: Destroy Devices Before Tearing Down libusb: CLAUDE.md: Destroy Devices Before Tearing Down libusb: CLAUDE.md: Destroy Devices Before Tearing Down libusb: CLAUDE.md: Destroy Devices Before Tearing Down libusb: CLAUDE.md: Destroy Devices Before Tearing Down libusb: CLAUDE.md: Destroy Devices Before Tearing Down libusb: CLAUDE.md: Destroy Devices Before Tearing Down libusb: CLAUDE.md: Destroy Devices Before Tearing Down libusb: CLAUDE.md: Destroy Devices Before Tearing Down libusb: CLAUDE.md: Destroy Devices Before Tearing Down libusb: CLAUDE.md: Destroy Devices Before Tearing Down libusb: CLAUDE.md: Destroy Devices Before Tearing Down libusb: CLAUDE.md: Destroy Devices Before Tearing Down libusb: CLAUDE.md: Destroy Devices Before Tearing Down libusb: CLAUDE.md: Destroy Devices Before Tearing Down libusb: CLAUDE.md: Destroy Devices Before Tearing Down libusb: CLAUDE.md: Destroy Devices Before Tearing Down libusb: CLAUDE.md: Destroy Devices Before Tearing Down libusb: CLAUDE.md: Destroy Devices Before Tearing Down libusb: CLAUDE.md: Destroy Devices Before Tearing Down libusb: CLAUDE.md: Destroy Devices Before Tearing Down libusb: CLAUDE.md: Destroy Devices Before Tearing Down libusb: CLAUDE.md: Destroy Devices Before Tearing Down libusb: CLAUDE.md: Destroy Devices Before Tearing Down libusb: CLAUDE.md: Destroy Devices Before Tearing Down libusb
src/mt7612u/async.c[193-216]
src/mt7612u/init.c[420-426]
src/mt7612u/usb.c[256-269]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Device teardown can close the libusb handle and context while cancelled asynchronous transfers remain in flight.
## Issue Context
`mt_async_stop` abandons outstanding transfers after a two-second deadline, but `mt7612u_close` then continues with normal USB teardown. Keep the event machinery, handle, and context alive until libusb has returned ownership of every submitted transfer.
## Fix Focus Areas
- src/mt7612u/async.c[175-225]
- src/mt7612u/init.c[420-426]
- src/mt7612u/usb.c[256-269]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


6. Concurrent diagnostics can interleave ✓ Resolved 📘 Rule violation ☼ Reliability
Description
LOG and ERR construct each diagnostic with separate fprintf and fputc calls, omit an
explicit flush, and use a format other than devourer [level] message. When the event thread and a
caller log concurrently, text can be inserted before another message's newline, while buffered
stderr can leave a piped consumer waiting.
Code

src/mt7612u/internal.h[R216-217]

+#define LOG(...)  do { fprintf(stderr, "[mt7612u] " __VA_ARGS__); fputc('\n', stderr); } while (0)
+#define ERR(...)  do { fprintf(stderr, "[mt7612u] ERROR " __VA_ARGS__); fputc('\n', stderr); } while (0)
Evidence
Rule 8 requires human diagnostics to use the documented format, emit each line with one write, and
flush normally. Both newly added macros split a line across two calls without flushing and can be
invoked from concurrent library paths.

CLAUDE.md: Preserve the Machine-Event and Human-Diagnostic Logging Contract: CLAUDE.md: Preserve the Machine-Event and Human-Diagnostic Logging Contract: CLAUDE.md: Preserve the Machine-Event and Human-Diagnostic Logging Contract: CLAUDE.md: Preserve the Machine-Event and Human-Diagnostic Logging Contract: CLAUDE.md: Preserve the Machine-Event and Human-Diagnostic Logging Contract: CLAUDE.md: Preserve the Machine-Event and Human-Diagnostic Logging Contract: CLAUDE.md: Preserve the Machine-Event and Human-Diagnostic Logging Contract: CLAUDE.md: Preserve the Machine-Event and Human-Diagnostic Logging Contract: CLAUDE.md: Preserve the Machine-Event and Human-Diagnostic Logging Contract: CLAUDE.md: Preserve the Machine-Event and Human-Diagnostic Logging Contract: CLAUDE.md: Preserve the Machine-Event and Human-Diagnostic Logging Contract: CLAUDE.md: Preserve the Machine-Event and Human-Diagnostic Logging Contract: CLAUDE.md: Preserve the Machine-Event and Human-Diagnostic Logging Contract: CLAUDE.md: Preserve the Machine-Event and Human-Diagnostic Logging Contract: CLAUDE.md: Preserve the Machine-Event and Human-Diagnostic Logging Contract: CLAUDE.md: Preserve the Machine-Event and Human-Diagnostic Logging Contract: CLAUDE.md: Preserve the Machine-Event and Human-Diagnostic Logging Contract: CLAUDE.md: Preserve the Machine-Event and Human-Diagnostic Logging Contract: CLAUDE.md: Preserve the Machine-Event and Human-Diagnostic Logging Contract: CLAUDE.md: Preserve the Machine-Event and Human-Diagnostic Logging Contract: CLAUDE.md: Preserve the Machine-Event and Human-Diagnostic Logging Contract: CLAUDE.md: Preserve the Machine-Event and Human-Diagnostic Logging Contract: CLAUDE.md: Preserve the Machine-Event and Human-Diagnostic Logging Contract: CLAUDE.md: Preserve the Machine-Event and Human-Diagnostic Logging Contract: CLAUDE.md: Preserve the Machine-Event and Human-Diagnostic Logging Contract: CLAUDE.md: Preserve the Machine-Event and Human-Diagnostic Logging Contract: CLAUDE.md: Preserve the Machine-Event and Human-Diagnostic Logging Contract: CLAUDE.md: Preserve the Machine-Event and Human-Diagnostic Logging Contract: CLAUDE.md: Preserve the Machine-Event and Human-Diagnostic Logging Contract: CLAUDE.md: Preserve the Machine-Event and Human-Diagnostic Logging Contract: CLAUDE.md: Preserve the Machine-Event and Human-Diagnostic Logging Contract: CLAUDE.md: Preserve the Machine-Event and Human-Diagnostic Logging Contract: CLAUDE.md: Preserve the Machine-Event and Human-Diagnostic Logging Contract: CLAUDE.md: Preserve the Machine-Event and Human-Diagnostic Logging Contract: CLAUDE.md: Preserve the Machine-Event and Human-Diagnostic Logging Contract: CLAUDE.md: Preserve the Machine-Event and Human-Diagnostic Logging Contract: CLAUDE.md: Preserve the Machine-Event and Human-Diagnostic Logging Contract: CLAUDE.md: Preserve the Machine-Event and Human-Diagnostic Logging Contract: CLAUDE.md: Preserve the Machine-Event and Human-Diagnostic Logging Contract: CLAUDE.md: Preserve the Machine-Event and Human-Diagnostic Logging Contract: CLAUDE.md: Preserve the Machine-Event and Human-Diagnostic Logging Contract: CLAUDE.md: Preserve the Machine-Event and Human-Diagnostic Logging Contract: CLAUDE.md: Preserve the Machine-Event and Human-Diagnostic Logging Contract: CLAUDE.md: Preserve the Machine-Event and Human-Diagnostic Logging Contract: CLAUDE.md: Preserve the Machine-Event and Human-Diagnostic Logging Contract: CLAUDE.md: Preserve the Machine-Event and Human-Diagnostic Logging Contract: CLAUDE.md: Preserve the Machine-Event and Human-Diagnostic Logging Contract: CLAUDE.md: Preserve the Machine-Event and Human-Diagnostic Logging Contract: CLAUDE.md: Preserve the Machine-Event and Human-Diagnostic Logging Contract: CLAUDE.md: Preserve the Machine-Event and Human-Diagnostic Logging Contract: CLAUDE.md: Preserve the Machine-Event and Human-Diagnostic Logging Contract: CLAUDE.md: Preserve the Machine-Event and Human-Diagnostic Logging Contract: CLAUDE.md: Preserve the Machine-Event and Human-Diagnostic Logging Contract: CLAUDE.md: Preserve the Machine-Event and Human-Diagnostic Logging Contract: CLAUDE.md: Preserve the Machine-Event and Human-Diagnostic Logging Contract: CLAUDE.md: Preserve the Machine-Event and Human-Diagnostic Logging Contract: CLAUDE.md: Preserve the Machine-Event and Human-Diagnostic Logging Contract: CLAUDE.md: Preserve the Machine-Event and Human-Diagnostic Logging Contract: CLAUDE.md: Preserve the Machine-Event and Human-Diagnostic Logging Contract: CLAUDE.md: Preserve the Machine-Event and Human-Diagnostic Logging Contract: CLAUDE.md: Preserve the Machine-Event and Human-Diagnostic Logging Contract: CLAUDE.md: Preserve the Machine-Event and Human-Diagnostic Logging Contract: CLAUDE.md: Preserve the Machine-Event and Human-Diagnostic Logging Contract: CLAUDE.md: Preserve the Machine-Event and Human-Diagnostic Logging Contract
src/mt7612u/internal.h[216-217]
src/mt7612u/async.c[145-155]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
MT7612U diagnostics do not follow the repository's atomic, flushed stderr logging contract.
## Issue Context
Format each complete human diagnostic as `devourer [level] message`, emit it to stderr with one write, and preserve thread safety and required flushing.
## Fix Focus Areas
- src/mt7612u/internal.h[216-217]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


7. Receiver guidance can drift 📘 Rule violation ⚙ Maintainability
Description
src/mt7612u/README.md repeats the public header's warning that enabling receive without draining
bulk-IN wedges the device and requires a physical replug. If the receiver startup contract or
recovery behavior changes, maintainers must update two authoritative-looking explanations and
readers can follow the stale copy.
Code

src/mt7612u/README.md[R36-39]

+Enabling MAC RX with nothing reading the bulk-IN endpoint wedges this part
+*below* the USB level: `libusb_reset_device`, the sysfs `authorized` toggle
+and rebinding the kernel driver all fail to recover it, and only a physical
+replug does. So `mt_mac_start()` takes the receiver as an explicit argument,
Evidence
Rule 2 requires secondary documentation to refer to authoritative header comments rather than
reproduce them. The README and public header both describe the same undrained-receiver wedge,
software-reset limitation, physical-replug requirement, and startup ordering.

CLAUDE.md: Do Not Duplicate Existing Header Documentation: CLAUDE.md: Do Not Duplicate Existing Header Documentation: CLAUDE.md: Do Not Duplicate Existing Header Documentation: CLAUDE.md: Do Not Duplicate Existing Header Documentation: CLAUDE.md: Do Not Duplicate Existing Header Documentation: CLAUDE.md: Do Not Duplicate Existing Header Documentation: CLAUDE.md: Do Not Duplicate Existing Header Documentation: CLAUDE.md: Do Not Duplicate Existing Header Documentation: CLAUDE.md: Do Not Duplicate Existing Header Documentation: CLAUDE.md: Do Not Duplicate Existing Header Documentation: CLAUDE.md: Do Not Duplicate Existing Header Documentation: CLAUDE.md: Do Not Duplicate Existing Header Documentation: CLAUDE.md: Do Not Duplicate Existing Header Documentation: CLAUDE.md: Do Not Duplicate Existing Header Documentation: CLAUDE.md: Do Not Duplicate Existing Header Documentation: CLAUDE.md: Do Not Duplicate Existing Header Documentation: CLAUDE.md: Do Not Duplicate Existing Header Documentation: CLAUDE.md: Do Not Duplicate Existing Header Documentation: CLAUDE.md: Do Not Duplicate Existing Header Documentation: CLAUDE.md: Do Not Duplicate Existing Header Documentation: CLAUDE.md: Do Not Duplicate Existing Header Documentation: CLAUDE.md: Do Not Duplicate Existing Header Documentation: CLAUDE.md: Do Not Duplicate Existing Header Documentation: CLAUDE.md: Do Not Duplicate Existing Header Documentation: CLAUDE.md: Do Not Duplicate Existing Header Documentation: CLAUDE.md: Do Not Duplicate Existing Header Documentation: CLAUDE.md: Do Not Duplicate Existing Header Documentation: CLAUDE.md: Do Not Duplicate Existing Header Documentation: CLAUDE.md: Do Not Duplicate Existing Header Documentation: CLAUDE.md: Do Not Duplicate Existing Header Documentation: CLAUDE.md: Do Not Duplicate Existing Header Documentation: CLAUDE.md: Do Not Duplicate Existing Header Documentation: CLAUDE.md: Do Not Duplicate Existing Header Documentation: CLAUDE.md: Do Not Duplicate Existing Header Documentation: CLAUDE.md: Do Not Duplicate Existing Header Documentation: CLAUDE.md: Do Not Duplicate Existing Header Documentation: CLAUDE.md: Do Not Duplicate Existing Header Documentation: CLAUDE.md: Do Not Duplicate Existing Header Documentation: CLAUDE.md: Do Not Duplicate Existing Header Documentation: CLAUDE.md: Do Not Duplicate Existing Header Documentation: CLAUDE.md: Do Not Duplicate Existing Header Documentation: CLAUDE.md: Do Not Duplicate Existing Header Documentation: CLAUDE.md: Do Not Duplicate Existing Header Documentation: CLAUDE.md: Do Not Duplicate Existing Header Documentation: CLAUDE.md: Do Not Duplicate Existing Header Documentation: CLAUDE.md: Do Not Duplicate Existing Header Documentation: CLAUDE.md: Do Not Duplicate Existing Header Documentation: CLAUDE.md: Do Not Duplicate Existing Header Documentation: CLAUDE.md: Do Not Duplicate Existing Header Documentation: CLAUDE.md: Do Not Duplicate Existing Header Documentation: CLAUDE.md: Do Not Duplicate Existing Header Documentation: CLAUDE.md: Do Not Duplicate Existing Header Documentation: CLAUDE.md: Do Not Duplicate Existing Header Documentation: CLAUDE.md: Do Not Duplicate Existing Header Documentation: CLAUDE.md: Do Not Duplicate Existing Header Documentation: CLAUDE.md: Do Not Duplicate Existing Header Documentation: CLAUDE.md: Do Not Duplicate Existing Header Documentation: CLAUDE.md: Do Not Duplicate Existing Header Documentation: CLAUDE.md: Do Not Duplicate Existing Header Documentation: CLAUDE.md: Do Not Duplicate Existing Header Documentation: CLAUDE.md: Do Not Duplicate Existing Header Documentation: CLAUDE.md: Do Not Duplicate Existing Header Documentation: CLAUDE.md: Do Not Duplicate Existing Header Documentation: CLAUDE.md: Do Not Duplicate Existing Header Documentation
src/mt7612u/README.md[34-41]
src/mt7612u/include/mt7612u/mt7612u.h[101-107]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The README duplicates the receiver startup and recovery contract already documented by the public API header.
## Issue Context
Keep the detailed contract in the authoritative header and replace the README copy with a concise reference to that declaration.
## Fix Focus Areas
- src/mt7612u/README.md[34-41]
- src/mt7612u/include/mt7612u/mt7612u.h[101-107]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


8. 2.4 GHz frames use the wrong power 🐞 Bug ≡ Correctness
Description
mt_get_rate_power() shifts the 2.4 GHz VHT EEPROM word twice, so both VHT power entries are always
decoded from zero. Every 2.4 GHz VHT channel setup then programs those incorrect entries into the
transmit-power registers.
Code

src/mt7612u/eeprom.c[R117-119]

+	if (!is_5ghz)
+		v >>= 8;
+	t->vht[0] = t->vht[1] = rate_power_val(v >> 8);
Evidence
The 2.4 GHz branch first shifts v by eight and then passes v >> 8, which is necessarily zero
because v is 16 bits. The resulting VHT entries flow into the hardware power configuration writes.

src/mt7612u/eeprom.c[116-120]
src/mt7612u/phy.c[237-245]
src/mt7612u/phy.c[273-274]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Correct the 2.4 GHz VHT power-table decoding so the selected EEPROM byte is passed directly to `rate_power_val()` rather than shifted twice.
## Issue Context
The decoded entries are consumed by channel setup and written to the VHT transmit-power registers.
## Fix Focus Areas
- src/mt7612u/eeprom.c[116-120]
- src/mt7612u/phy.c[237-245]
- src/mt7612u/phy.c[273-274]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


9. Malformed radio headers still transmit ✓ Resolved 🐞 Bug ≡ Correctness
Description
mt_radiotap_parse() returns the declared header length when a present field extends beyond that
header instead of rejecting the buffer. Both injection APIs treat that positive result as valid and
transmit the following frame using default or partially parsed rate settings.
Code

src/mt7612u/radiotap.c[R144-146]

+			off = (off + align - 1) & ~((size_t)align - 1);
+			if (off + size > rlen)
+				return (int)rlen;
Evidence
The known-field bounds check returns rlen, while callers reject only non-positive parser results.
The rate structure has already been initialized to OFDM, one stream and 20 MHz, so the malformed
field is silently replaced by those defaults.

src/mt7612u/radiotap.c[126-129]
src/mt7612u/radiotap.c[144-147]
src/mt7612u/radiotap.c[217-223]
src/mt7612u/radiotap.c[258-270]
src/mt7612u/tx.c[141-148]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Return a parsing failure when a declared radiotap field does not fit within the header, and ensure both injection paths reject it.
## Issue Context
The parser currently initializes a default rate before discovering the truncation, allowing malformed requests to transmit at unintended settings.
## Fix Focus Areas
- src/mt7612u/radiotap.c[126-129]
- src/mt7612u/radiotap.c[144-147]
- src/mt7612u/radiotap.c[217-223]
- src/mt7612u/radiotap.c[258-270]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


10. Hardware setup can falsely succeed ✓ Resolved 🐞 Bug ☼ Reliability
Description
mt_wr() discards the result of mt_vendor_req() and provides no failure signal or error-state
update to its callers. When a register write exhausts its USB retries during initialization, channel
selection, or power programming, the public operation can continue and return success with only part
of the requested hardware state applied.
Code

src/mt7612u/usb.c[R100-102]

+	b[0] = val & 0xff; b[1] = (val >> 8) & 0xff;
+	b[2] = (val >> 16) & 0xff; b[3] = (val >> 24) & 0xff;
+	mt_vendor_req(d, req, REQ_OUT, (uint16_t)(a >> 16), (uint16_t)a, b, sizeof b);
Evidence
mt_vendor_req() returns a negative libusb error after exhausting retries, but mt_wr() ignores
that value. Hardware initialization and transmit-power setup issue mandatory writes through this
void helper and can reach their normal return paths without checking whether those writes reached
the device.

src/mt7612u/usb.c[33-47]
src/mt7612u/usb.c[95-107]
src/mt7612u/init.c[343-390]
src/mt7612u/phy.c[263-274]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Make register writes expose USB control-transfer failures and propagate them through operations whose correctness depends on the write succeeding.
## Issue Context
Read failures update `io_err`, but writes currently neither update it nor return status, while initialization and PHY setup perform many mandatory writes.
## Fix Focus Areas
- src/mt7612u/usb.c[95-107]
- src/mt7612u/init.c[343-390]
- src/mt7612u/phy.c[263-274]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


11. Read failures corrupt calibration data ✓ Resolved 🐞 Bug ☼ Reliability
Description
mt_eeprom_init() reads the EEPROM through mt_rr(), which converts every transport failure into
0xffffffff, and never checks the accumulated io_err value. A failure outside the separately
validated chip and MAC cells therefore leaves all-ones power or calibration bytes in memory while
device opening continues successfully.
Code

src/mt7612u/eeprom.c[R22-24]

+	for (unsigned i = 0; i + 4 <= MT7612U_EEPROM_SIZE; i += 4) {
+		uint32_t v = mt_rr(d, EEP_ADDR(i));
+
Evidence
mt_rr() returns all ones when mt_rr_chk() fails and only increments io_err, while the EEPROM
loop stores that value without checking either result. The function validates the chip identifier
and MAC shape but not every calibration cell later consumed by PHY setup.

src/mt7612u/eeprom.c[17-43]
src/mt7612u/usb.c[64-93]
src/mt7612u/init.c[395-410]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Read EEPROM words with the checked register API and abort initialization on any transport failure rather than storing the sentinel value.
## Issue Context
Only the chip identifier and part of the MAC address are validated afterward; power and calibration cells may otherwise remain silently corrupted.
## Fix Focus Areas
- src/mt7612u/eeprom.c[17-35]
- src/mt7612u/usb.c[64-93]
- src/mt7612u/init.c[395-410]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


12. Cleared responders keep acknowledging frames 🐞 Bug ≡ Correctness
Description
mt7612u_clear_ack_responder() restores the saved MAC address and clears ack_saved, but never
clears MT_AUTO_RSP_EN after mt7612u_set_ack_responder() enabled it. A caller that clears the
responder leaves automatic responses enabled for the restored normal device identity, and later
clear calls return without another opportunity to disable it.
Code

src/mt7612u/caps.c[117]

+	d->ack_saved = 0;
Evidence
The arm path explicitly enables the auto-response bit, while the clear path only restores MAC
registers and invalidates the saved-state guard. The public API describes this function as clearing
a hardware ACK responder.

src/mt7612u/caps.c[65-84]
src/mt7612u/caps.c[101-117]
src/mt7612u/include/mt7612u/mt7612u.h[147-154]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Clearing the ACK responder restores its MAC address but leaves the automatic-response enable bit set.
## Issue Context
The clear operation must undo both pieces of state established by arming: the temporary MAC identity and the auto-response gate. Clear the gate before marking the saved state inactive, so repeat calls cannot leave the device responding.
## Fix Focus Areas
- src/mt7612u/caps.c[101-117]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


13. Some 40 MHz packet requests transmit at 20 MHz ✓ Resolved 🐞 Bug ≡ Correctness
Description
mt_radiotap_parse() tests known & 0x02 before applying HT bandwidth, although bit 0x01
declares that the bandwidth field is present. A radiotap header that supplies bandwidth without also
declaring an MCS index leaves r->bw at its initialized 20 MHz value, so requested 40 MHz injection
is narrowed.
Code

src/mt7612u/radiotap.c[R179-180]

+				if ((known & 0x02) && ((flags & 0x03) == 1))
+					r->bw = MT7612U_BW_40;
Evidence
The repository defines MCS_HAVE_BW as 0x01 and MCS_HAVE_MCS as 0x02; the new parser uses the
latter to gate bandwidth decoding. Its default initialized value is 20 MHz, making the missed branch
observable in transmitted rate selection.

src/mt7612u/radiotap.c[126-129]
src/mt7612u/radiotap.c[173-183]
src/ieee80211_radiotap.h[101-113]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The HT radiotap parser uses the MCS-known flag to decide whether bandwidth is available instead of the bandwidth-known flag.
## Issue Context
Use the repository's radiotap field definitions and preserve the default bandwidth only when the bandwidth field is not known. Add a parser test for an MCS field with `MCS_HAVE_BW` but without `MCS_HAVE_MCS`.
## Fix Focus Areas
- src/mt7612u/radiotap.c[173-183]
- src/ieee80211_radiotap.h[101-113]
- src/mt7612u/tests/frame_shape.c[152-194]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

14. Failed opens block later retries ✓ Resolved 🐞 Bug ☼ Reliability
Description
open_selected() stores the acquired adapter lock in g_lock_fd before calling libusb_open() but
leaves it held when that call fails. After a transient permission or USB-open failure, another open
attempt in the same process collides with the stale lock even though no device handle exists.
Code

src/mt7612u/usb.c[R435-437]

+			g_lock_fd = lk;
+			if (libusb_open(list[i], &h))
+				h = NULL;
Evidence
lock_adapter() returns a live locked descriptor, which is assigned to g_lock_fd before
libusb_open(). The failure branch merely clears the handle, while mt_open() returns without
invoking the only normal cleanup that closes g_lock_fd.

src/mt7612u/usb.c[346-383]
src/mt7612u/usb.c[428-452]
src/mt7612u/usb.c[455-466]
src/mt7612u/usb.c[536-550]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Release and reset the process-global adapter lock whenever `libusb_open()` fails after the lock has been acquired.
## Issue Context
`mt_open()` exits its libusb context immediately when `open_selected()` returns no handle. Normal lock cleanup exists only in `mt_close()`, which is not reached for this failed-open path.
## Fix Focus Areas
- src/mt7612u/usb.c[428-438]
- src/mt7612u/usb.c[455-466]
- src/mt7612u/usb.c[536-550]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


15. Custom firmware paths do not work ✓ Resolved 🐞 Bug ≡ Correctness
Description
gate_init() and gate_chan() accept fw_dir but pass NULL to mt_init_hardware() instead of
forwarding it. Invoking the documented init  or chan   forms therefore falls back to firmware
in the current directory, so adapters cannot be initialized from a supplied non-default firmware
location.
Code

src/mt7612u/tools/bringup.c[217]

+	if (mt_init_hardware(&dev, NULL)) {
Evidence
The CLI passes its arguments into these gate functions, but each gate explicitly replaces that value
with NULL. The initialization routine then passes its received value directly to mt_fw_init(),
whose NULL fallback is the default directory.

src/mt7612u/tools/bringup.c[193-224]
src/mt7612u/tools/bringup.c[253-269]
src/mt7612u/tools/bringup.c[2172-2178]
src/mt7612u/init.c[349-370]
src/mt7612u/fw.c[231-237]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The bringup `init` and `chan` gates accept a firmware-directory argument but discard it before hardware initialization.
## Issue Context
`mt_init_hardware()` forwards its `fw_dir` argument to firmware loading, while a NULL value selects the default relative `firmware` directory.
## Fix Focus Areas
- src/mt7612u/tools/bringup.c[193-224]
- src/mt7612u/tools/bringup.c[253-269]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


16. Separate adapters get wrong link intervals ✓ Resolved 🐞 Bug ≡ Correctness
Description
mt7612u_link_stats() stores its previous-sample timestamp in the file-static g_stats_last_us
rather than in struct mt7612u_dev. A sample from one adapter changes the reported interval for
every other adapter, and concurrent callers also unsafely read and write the shared timestamp.
Code

src/mt7612u/init.c[528]

+static uint64_t g_stats_last_us;
Evidence
The timestamp is declared as a single static variable and updated on each call, while the API
accepts a device handle and documents results as an interval since the relevant start or previous
call. Nothing associates the static timestamp with d.

src/mt7612u/init.c[520-563]
src/mt7612u/init.c[530-595]
src/mt7612u/include/mt7612u/mt7612u.h[254-294]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Link-statistics sampling uses one process-global previous timestamp instead of state belonging to the device being sampled.
## Issue Context
The API exposes separate device handles and defines each result as the interval since that device's previous sample or start. Multiple adapters or concurrent users must not share interval state.
## Fix Focus Areas
- src/mt7612u/internal.h[102-144]
- src/mt7612u/init.c[520-563]
- src/mt7612u/init.c[530-595]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


View review recommended (5)
17. Table checks crash on Python 3.11 ✓ Resolved 🐞 Bug ≡ Correctness
Description
The --check path calls Path.read_text() with an unsupported newline keyword argument. Running
the documented verification command on Python 3.11 raises TypeError before the generated table can
be compared.
Code

tools/extract_mt7612u_tables.py[R289-292]

+    if args.check:
+        path = root / OUTPUT_H
+        if not path.exists() or path.read_text(encoding="utf-8", newline="") != output:
+            raise SystemExit(f"stale generated output: {OUTPUT_H}")
Evidence
The documented --check branch necessarily reaches the changed read_text() invocation, whose
Python 3.11 signature has no newline parameter. Both project documents present this path as the
generated-table verification command.

tools/extract_mt7612u_tables.py[286-295]
docs/mt7612u.md[19-19]
src/mt7612u/README.md[101-101]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Replace the unsupported `Path.read_text(..., newline="")` call with a portable byte or text read that preserves the intended exact comparison.
## Issue Context
The write API supports `newline`, but `Path.read_text()` on common supported Python versions does not.
## Fix Focus Areas
- tools/extract_mt7612u_tables.py[286-295]
- docs/mt7612u.md[19-19]
- src/mt7612u/README.md[101-101]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


18. Radio tests pass without sending frames ✓ Resolved 🐞 Bug ≡ Correctness
Description
gate_g() and the first gate_ampdu() experiment discard every mt_tx_raw() return value before
unconditionally returning success. If the asynchronous or synchronous transmit path rejects every
frame, the harness still exits with status zero and presents the failed experiment for witness
evaluation.
Code

src/mt7612u/tools/bringup.c[470]

+			mt_tx_raw(&dev, frame, 40, &mcs7, 1, arm);
Evidence
The rate gate ignores both public and raw transmit results and returns zero, while the aggregation
gate prints its asynchronous error count but also returns zero. In contrast, the basic TX gate
counts successful submissions and fails when the count differs, demonstrating that submission is a
required local precondition.

src/mt7612u/tools/bringup.c[445-479]
src/mt7612u/tools/bringup.c[781-801]
src/mt7612u/tools/bringup.c[845-848]
src/mt7612u/tools/bringup.c[299-317]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Track every frame-submission result in the rate and aggregation gates and return failure when expected submissions are not accepted.
## Issue Context
The witness can validate on-air behavior only after local submission succeeds; a transport failure is not a valid witness-dependent outcome.
## Fix Focus Areas
- src/mt7612u/tools/bringup.c[445-479]
- src/mt7612u/tools/bringup.c[781-801]
- src/mt7612u/tools/bringup.c[845-848]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


19. Invalid radio modes can crash the tool ✓ Resolved 🐞 Bug ☼ Reliability
Description
gate_tx() indexes a five-entry name array with phy & 7, allowing command-line values 5 through 7
to read beyond the array. The receive gate repeats the same unchecked lookup for the three-bit PHY
value decoded from device input, so either malformed user input or an unexpected descriptor can
trigger undefined behavior.
Code

src/mt7612u/tools/bringup.c[R294-295]

+	printf("injecting %d frames on ch%u, %s idx %d, no-ACK, rate word 0x%04x\n",
+	       count, chan, phy_name[phy & 7], mcs, mt_tx_rate_word(&rate));
Evidence
Both arrays contain five entries, but both indexes are merely masked to the range zero through
seven. The TX value comes directly from atoi(), while the RX value comes from the decoded
descriptor without a range check.

src/mt7612u/tools/bringup.c[266-295]
src/mt7612u/tools/bringup.c[320-363]
src/mt7612u/tools/bringup.c[1201-1205]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Validate PHY values against the name-array length before formatting them, and reject invalid command-line transmission modes.
## Issue Context
Masking to three bits does not constrain the value to the five defined enum members.
## Fix Focus Areas
- src/mt7612u/tools/bringup.c[266-295]
- src/mt7612u/tools/bringup.c[320-363]
- src/mt7612u/tools/bringup.c[1201-1205]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


20. Negative durations hang the test tool ✓ Resolved 🐞 Bug ☼ Reliability
Description
The ACK gate casts its unchecked signed secs argument to unsigned before converting it to
microseconds. Passing a negative duration such as -1 therefore sleeps for roughly 49 days instead
of rejecting the invocation, while leaving the receive path active.
Code

[src/mt7612u/tools/bringup.c[R1044-1045]](https://github.com/OpenIPC/devourer/pull/412/files#diff-3e1ecc4f11f7ce1cdb7aa0e0bda96415e69525a159c0f1394bf31732f7...

Comment thread src/mt7612u/async.c
Comment thread src/mt7612u/internal.h Outdated
Comment thread src/mt7612u/README.md
Comment thread src/mt7612u/eeprom.c
Comment thread src/mt7612u/radiotap.c Outdated
Comment thread src/mt7612u/tools/bringup.c
Comment thread src/mt7612u/tools/bringup.c Outdated
Comment thread src/mt7612u/caps.c
Comment thread src/mt7612u/radiotap.c Outdated
Comment thread src/mt7612u/tools/bringup.c Outdated
Fourteen findings; each was checked against the code and against mt76 rather
than taken on trust.

Fixed:

  - **Radiotap HT bandwidth gated on the wrong bit.** The MCS `known` byte
    declares bandwidth with HAVE_BW (0x01); HAVE_MCS is 0x02. We tested 0x02,
    so a header declaring bandwidth without an MCS index -- legal radiotap --
    silently narrowed a requested 40 MHz frame to 20. This is the HT twin of
    the VHT bandwidth bug caught in review; the same slip, missed twice.
    Mutation-tested: restoring 0x02 fails two cases in both directions.

  - **A malformed radiotap header transmitted anyway.** A declared field
    running past the declared header length returned the header length, which
    both injection entry points read as success and then aired at whatever
    defaults had accumulated. Now refused. An unknown *trailing* present bit
    still stops cleanly -- that header is well formed, we just cannot read the
    rest of it.

  - **The table generator's --check crashed on anything before Python 3.13.**
    `Path.read_text(newline=...)` is 3.13-only; the documented command and the
    ctest cell would TypeError on the 3.12 that CI images ship. This one was
    invisible here precisely because this box runs 3.13.

  - **phy_name[phy & 7] indexed a five-entry array.** MT_RATE_PHY is three
    bits, so 5-7 are representable and read past the end -- reachable from
    command-line input and from a descriptor. (by_phy[] was already 8 and is
    fine.)

  - **gate_g reported frames it never sent.** It discarded every transmit
    return, printed the requested count and returned 0, so a run where every
    submit failed still handed the witness an experiment that never aired --
    and "no frames decoded" would have read as a negative finding rather than
    as a silent transmitter. Now counts what actually went out and fails when
    an arm sent nothing.

  - The ACK gate cast an unchecked signed duration to unsigned and slept
    uninterruptibly; -1 meant roughly 49 days with the receiver running. Now
    the SIGINT-aware wait every other gate uses.

Not defects, and now commented so the next reader does not re-derive them:

  - The 2.4 GHz VHT power double shift is byte-for-byte mt76's own
    `mt76x2_get_rate_power()`, and `rate_power_val` takes a u8 on both sides.
    Changing it would be a silent divergence from the reference this port is
    checked against.

  - `mt7612u_clear_ack_responder()` not clearing MT_AUTO_RSP_EN is correct:
    init writes MT_AUTO_RSP_CFG = 0x13 and the EN bit is BIT(0), so the gate is
    already on before any caller arms a responder. Clearing it would leave the
    device in a state its own init never produces.

On air, unchanged: `bringup rtap 149 400` still gives tag A 400/400 and tag B
377/400 at rate 19, against the 401/388 recorded before. 60/60 ctest.

Still open from the review and NOT addressed here: transfer teardown racing
libusb ownership, mt_wr() discarding failures, EEPROM read errors reaching
calibration data as 0xff, the harness counter data race, and the LOG/ERR
format. They are real, but each is a design change rather than a fix, and they
want the maintainer's view first.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tba83kymS5W2v1vn2yRxrj
@snokvist

snokvist commented Sep 6, 2026

Copy link
Copy Markdown
Collaborator Author

Qodo posted fourteen findings when this left draft. I checked each against the code and against the pinned mt76 tree rather than taking them on trust, which turned out to matter: two were wrong, and "fixing" either would have made things worse. eca657c acts on the rest.

Fixed — six, one of which is on-air correctness

The radiotap HT bandwidth was gated on the wrong bit. The MCS known byte declares bandwidth with HAVE_BW (0x01); HAVE_MCS is 0x02. We tested 0x02, so a header declaring bandwidth without an MCS index — legal radiotap — silently narrowed a requested 40 MHz frame to 20. This is the exact twin of the VHT bandwidth bug you caught in review, in the same function, and I fixed that one without noticing this one. Mutation-tested: restoring 0x02 fails two cases in both directions.

A malformed radiotap header transmitted anyway. A declared field running past the declared header length returned the header length, which both injection entry points read as success and then aired at whatever defaults had accumulated. Now refused. An unknown trailing present bit still stops cleanly — that header is well formed, we simply cannot read the rest of it.

--check crashed on anything before Python 3.13. Path.read_text(newline=...) is 3.13-only, so the documented verification command and the new ctest cell would TypeError on the 3.12 the CI images ship. This was invisible here precisely because this machine runs 3.13 — a gate that only passes on the author's box.

Plus three in the harness: phy_name[phy & 7] indexed a five-entry array (by_phy[] was already 8 and fine); gate_g discarded every transmit return, printed the requested count and returned 0, so a run where every submit failed still handed the witness an experiment that never aired — and "no frames decoded" would have read as a negative finding rather than as a silent transmitter; and the ACK gate cast an unchecked signed duration to unsigned, making -1 roughly 49 days with the receiver live.

On air, unchanged: bringup rtap 149 400 still gives tag A 400/400 and tag B 377/400 at rate 19, against the 401/388 recorded before.

Two that are not defects

The 2.4 GHz VHT power "double shift" is mt76x2_get_rate_power() byte for byte, and rate_power_val takes a u8 on both sides, so the behaviour is identical to upstream's. Those two entries do decode from zero on 2.4 GHz — that is mt76's own handling of a 5 GHz-oriented EEPROM field, for a band where VHT is an extension outside 802.11ac. Changing it would be a silent divergence from the reference this port is checked against, so it is commented instead.

clear_ack_responder() not clearing MT_AUTO_RSP_EN is correct. mt_init_hardware() writes MT_AUTO_RSP_CFG = 0x13 and MT_AUTO_RSP_EN is BIT(0), so the gate is already on before any caller arms a responder — the mt_set() in the arm path is a no-op on it. Clearing it would leave the device in a state its own init never produces; moving the identity off the responder address is what actually stops it answering. Also commented.

Five left open deliberately

Transfer teardown racing libusb ownership after the two-second cancel deadline; mt_wr() discarding failures so a partially applied hardware setup can return success; EEPROM read errors reaching calibration data as 0xff; the harness counter data race between the libusb event thread and the gate; and LOG/ERR not matching the devourer [level] message contract.

These are real. I have not touched them because each is a design change rather than a fix — error propagation through mt_wr in particular reshapes every call site in the backend — and I would rather have your view on the shape than guess and hand you a large diff to unpick. Say which you want and in what form.

@qodo-free-for-open-source-projects

Copy link
Copy Markdown

Qodo posted fourteen findings when this left draft. I checked each against the code and against the pinned mt76 tree rather than taking them on trust, which turned out to matter: two were wrong, and "fixing" either would have made things worse. eca657c acts on the rest.

Fixed — six, one of which is on-air correctness

The radiotap HT bandwidth was gated on the wrong bit. The MCS known byte declares bandwidth with HAVE_BW (0x01); HAVE_MCS is 0x02. We tested 0x02, so a header declaring bandwidth without an MCS index — legal radiotap — silently narrowed a requested 40 MHz frame to 20. This is the exact twin of the VHT bandwidth bug you caught in review, in the same function, and I fixed that one without noticing this one. Mutation-tested: restoring 0x02 fails two cases in both directions.

A malformed radiotap header transmitted anyway. A declared field running past the declared header length returned the header length, which both injection entry points read as success and then aired at whatever defaults had accumulated. Now refused. An unknown trailing present bit still stops cleanly — that header is well formed, we simply cannot read the rest of it.

--check crashed on anything before Python 3.13. Path.read_text(newline=...) is 3.13-only, so the documented verification command and the new ctest cell would TypeError on the 3.12 the CI images ship. This was invisible here precisely because this machine runs 3.13 — a gate that only passes on the author's box.

Plus three in the harness: phy_name[phy & 7] indexed a five-entry array (by_phy[] was already 8 and fine); gate_g discarded every transmit return, printed the requested count and returned 0, so a run where every submit failed still handed the witness an experiment that never aired — and "no frames decoded" would have read as a negative finding rather than as a silent transmitter; and the ACK gate cast an unchecked signed duration to unsigned, making -1 roughly 49 days with the receiver live.

On air, unchanged: bringup rtap 149 400 still gives tag A 400/400 and tag B 377/400 at rate 19, against the 401/388 recorded before.

Two that are not defects

The 2.4 GHz VHT power "double shift" is mt76x2_get_rate_power() byte for byte, and rate_power_val takes a u8 on both sides, so the behaviour is identical to upstream's. Those two entries do decode from zero on 2.4 GHz — that is mt76's own handling of a 5 GHz-oriented EEPROM field, for a band where VHT is an extension outside 802.11ac. Changing it would be a silent divergence from the reference this port is checked against, so it is commented instead.

clear_ack_responder() not clearing MT_AUTO_RSP_EN is correct. mt_init_hardware() writes MT_AUTO_RSP_CFG = 0x13 and MT_AUTO_RSP_EN is BIT(0), so the gate is already on before any caller arms a responder — the mt_set() in the arm path is a no-op on it. Clearing it would leave the device in a state its own init never produces; moving the identity off the responder address is what actually stops it answering. Also commented.

Five left open deliberately

Transfer teardown racing libusb ownership after the two-second cancel deadline; mt_wr() discarding failures so a partially applied hardware setup can return success; EEPROM read errors reaching calibration data as 0xff; the harness counter data race between the libusb event thread and the gate; and LOG/ERR not matching the devourer [level] message contract.

These are real. I have not touched them because each is a design change rather than a fix — error propagation through mt_wr in particular reshapes every call site in the backend — and I would rather have your view on the shape than guess and hand you a large diff to unpick. Say which you want and in what form.

Thanks for validating the proposed fixes against the pinned tree. I agree with leaving the two upstream-matching behaviors unchanged and would prioritize the remaining issues in this order:

  1. Transfer teardown ownership — fix this first. Keep the conservative leak-on-timeout behavior, but make the lifetime state explicit and ensure no subsequent API path can reuse d->a while libusb may still invoke callbacks. A follow-up can address cancellation timeouts more comprehensively.
  2. mt_wr() error propagation — introduce a checked variant or an operation/status accumulator rather than changing every existing call site at once. Hardware setup should fail if any required write failed, while optional diagnostic writes can remain best-effort.
  3. EEPROM read errors — distinguish read failure from a valid 0xff value and abort calibration-dependent initialization when required EEPROM data is unavailable.
  4. Harness counter synchronization — protect the callback-updated counters, ideally using the existing async statistics/snapshot mechanism or atomics rather than adding ad-hoc locks to each gate.
  5. LOG/ERR formatting — lowest risk and mostly independent; align the backend logging helpers with the documented devourer [level] message contract.

For the next change, I’d take teardown ownership plus the checked-write/error-status design together, then handle EEPROM validation and the harness race separately. I would not alter either of the two behaviors you identified as matching mt76.

@snokvist

snokvist commented Sep 6, 2026

Copy link
Copy Markdown
Collaborator Author

Filed #413 for the cross-backend half of this: AdapterCaps declares no maximum frame size for any adapter, and the limit turns out to differ per transport — PCIe already enforces a hard 32 KB bounce ceiling invisibly (PcieTransport.cpp:569), the Realtek USB backends size a heap buffer to the frame so their real limit is undocumented, and MT7612U's RX ceiling is a MAC register.

Keeping it out of this PR deliberately: the field is shared, the "what should the default be" question is yours rather than a backend's, and the Realtek numbers need a register hunt plus a bench sweep that has nothing to do with MediaTek.

What did land here, because it was a defect rather than a design question: this backend had two disagreeing ceilings behind one public APImt7612u_tx() refused above 2016 bytes while mt7612u_send_packets() bounded only against its 16 KB aggregate buffer, so the same frame was refused by one entry point and aired by the other. Both now share one ceiling.

The numbers, measured rather than assumed: TX is not the constraint on this part — every size up to 7900 bytes aired 60/60 against an RTL8812AU witness with zero CRC errors, well past 802.11's 2304 non-A-MSDU ceiling, so the old 2048 was a buffer constant with nothing behind it. RX is the constraint and it is the MAC's: MT_MAX_LEN_CFG low 12 bits are the on-air maximum including FCS, so the 0xf00 this driver programs gives 3836 bytes of MPDU, measured to the byte — 3836 arrives, 3837 does not.

Both are now in mt7612u_caps (max_mpdu_tx / max_mpdu_rx, RX read from the register at runtime rather than hardcoded), bringup caps prints them, and a bringup mtu gate is the sweep that produced them.

One correction worth stating plainly, since it was mine: I added an rx_dropped counter mid-investigation and said it would make the oversize loss visible. It does not, and I mutation-tested rather than trusting it — with the TX ceiling temporarily raised, 60 frames of 6000 bytes MT7612U-to-MT7612U gave 0 received with rx_err, rx_invalid and rx_dropped all zero. The MAC discards oversize frames before USB, so that loss cannot be observed from this layer at all. The counter is real but covers short and malformed transfers only, and now says that instead of claiming the case it cannot see.

Caveats that stand: one witness generation, and 7900 is "at least" — I stopped at the buffer I had, not at a refusal.

@josephnef

Copy link
Copy Markdown
Collaborator

Re-verified at eca657c against the code, not the replies: all eleven findings from my review are fixed. Build, make check and the extractor --check against the pinned reference/mt76@be5ce79 all reproduce here, and I have resolved those threads. The 40 MHz centre validation and the HT HAVE_BW twin are good catches.

Answers to the three open questions:

  1. Merge shape — land this PR unwired; the integration comes as a follow-up PR. Please split the invalid-PHY rate-word drop back into this branch so the subtree is complete on its own.

  2. The rename is a must. Once this lands, IRtlDevice / RtlAdapter / CreateRtlDevice have nothing to do with Realtek any more, and the names would be lying to the next reader. Your proposed shape is fine: a separate, mechanical, reviewable-in-isolation PR, landed before the integration PR so the MediaTek device is born under the vendor-neutral name rather than renamed after.

  3. The five items Qodo left and you deferred — please address them in this PR rather than waiting on a shape from me: write-failure propagation through mt_wr, transfer ownership after the teardown deadline, EEPROM read errors reaching calibration as 0xff, the harness counter race, and LOG/ERR following the devourer [level] message contract (docs/logging.md). Where one genuinely reshapes every call site, do it the way the other backends do it and say so in the commit; I would rather review the diff than pre-approve a design in prose.

The pthread / clock_gettime swap to <thread> / <chrono> at integration time is fine as you proposed. #413 is a good issue and stays out of here.

Per review: land this unwired but complete, and address the five items rather
than waiting on a shape. Nothing here touches CMakeLists, WiFiDriver or the
workflows -- the subtree is still unreachable from the shipped library.

**The subtree is now complete on its own.** The invalid-PHY rate-word drop is
back, as asked, and so is the rest of the subtree-level work that had drifted
onto the integration branch, because the same argument applies to all of it:
the per-frame noise floor and derived SNR, 80 MHz, the frame-size limits, and
the adapter lock shared with UsbDeviceLock. What stayed behind is only the
wiring -- the IRtlDevice implementation, the VID:PID table and the mapping
header.

**Write failures now propagate.** mt_wr() discarded mt_vendor_req()'s result,
so a write that exhausted its retries left the hardware partly configured while
the call returned success. Threading a status through every writer would touch
several hundred call sites in a sequence that is deliberately a verbatim port
of mt76's, and the churn would bury what it protects. So this follows the shape
the reads already had -- mt_rr_chk() has always bumped d->io_err: writes stay
best-effort at the call site, and a SEQUENCE checks the accumulator at its
boundary. mt_eeprom_init(), mt_init_hardware() and mt_set_channel_ex() are
bracketed and now fail as a whole; optional writes stay best-effort by not
being bracketed. mt_wr_chk() exists for a caller that wants to fail at the
write instead.

**EEPROM read errors no longer reach calibration as data.** The slurp must not
treat 0xffffffff as an error by value -- unprogrammed cells legitimately read
all-ones -- but the accumulator distinguishes a blank cell from a failed
transfer. A failure outside the two cells that were already validated used to
leave all-ones power and calibration bytes in memory while the device opened
successfully and transmitted from them.

**Teardown ownership is explicit.** Leaking the ring on the two-second deadline
was the safe half; the other half was that mt_close() then released the
interface, closed the handle and exited the context while libusb still owned
those transfers and the event thread had just been joined, so nothing could
ever complete them. The device is marked stranded: mt_close() leaks the USB
objects too rather than freeing what libusb holds, and mt_async_start() refuses
to submit a second ring onto the same endpoints.

**The harness counter race is closed.** arx_cb() and ack_cb() run on the libusb
event thread while gate_arx()/gate_duplex() read the same fields before
stopping it, so a displayed rate or a duplex verdict could be built from torn
counts. Relaxed atomics rather than per-gate locks: they are counters, nothing
orders off them, and this is the RX hot path in a throughput gate.

**LOG/ERR follow docs/logging.md.** They used a private `[mt7612u]` prefix
instead of `devourer [X] `, emitted text and newline as two stdio calls so a
line from the event thread could split one from a caller, and never flushed.
mt_diag() formats the line first and emits it with one fwrite + fflush, as
src/Event.h does for the machine plane. Warning-level diagnostics moved to [W].

Verified on hardware after the rework, 19 gates, 0 failures: regs, fw, init,
chan, caps, hop, pwr, linkstat, ampdu and soak self-check; rtap 781 frames,
sweep 1617 at 20 MHz / 1639 at bw=1 / 828 at bw=2, coding 1919, gateg 450, mtu
400 and vht 748 all witnessed by an RTL8812AU; and MT7612U-to-MT7612U 2999
frames with rx_err, rx_invalid and rx_dropped all zero. Offline: make check
green and the extractor --check still reproduces initvals.h against the pinned
reference/mt76.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tba83kymS5W2v1vn2yRxrj
@snokvist

snokvist commented Sep 7, 2026

Copy link
Copy Markdown
Collaborator Author

Done at 1b032b5, all three answered.

1. Merge shape — unwired, and now complete

Nothing in this branch touches CMakeLists.txt, WiFiDriver.cpp or the workflows; the subtree is still unreachable from the shipped library.

The invalid-PHY rate-word drop is back, as asked. I also brought over the rest of the subtree-level work that had drifted onto the integration branch, because your reason applies to all of it equally: the per-frame noise floor and derived SNR, 80 MHz, the frame-size limits, and the adapter lock shared with UsbDeviceLock. What stayed behind is only wiring — the IRtlDevice implementation, the VID:PID table, the mapping header. If you would rather review a narrower diff, say so and I will split the later work back out; I read "complete on its own" as the governing principle rather than the one file you named.

2. The rename — agreed, and I was wrong to push back

Your reason is better than mine. I was arguing the interface is neutral in substance, which is true and beside the point: after this lands the names are read by people, and RtlAdapter in front of a MediaTek device is a lie they have to work around. Separate mechanical PR, landed before the integration PR so the device is born under the neutral name. I will open it once this merges, so it rebases on a settled tree rather than a moving one.

3. The five items — all closed here

Write-failure propagation. mt_wr() discarded mt_vendor_req()'s result, so a write that exhausted its retries left the hardware partly configured while the call returned success. Threading a status through every writer would touch several hundred call sites in a sequence that is deliberately a verbatim port of mt76's, and that churn would bury the thing it protects — so I did it the way the reads already worked: mt_rr_chk() has always bumped d->io_err. Writes stay best-effort at the call site; a sequence checks the accumulator at its boundary. mt_eeprom_init(), mt_init_hardware() and mt_set_channel_ex() are bracketed and now fail as a whole. Optional writes stay best-effort by not being bracketed, and mt_wr_chk() exists for a caller that wants to fail at the write itself.

EEPROM read errors. The slurp must not treat 0xffffffff as an error by value — unprogrammed cells legitimately read all-ones — but the accumulator separates a blank cell from a failed transfer, which the value alone cannot. Previously a failure outside the two cells already validated left all-ones power and calibration bytes in memory while the device opened successfully and transmitted from them.

Teardown ownership. Leaking the ring on the two-second deadline was the safe half. The other half was that mt_close() then released the interface, closed the handle and exited the context while libusb still owned those transfers — and the event thread had just been joined, so nothing could ever complete them. The device is now marked stranded: mt_close() leaks the USB objects too rather than freeing what libusb holds, and mt_async_start() refuses to submit a second ring onto the same endpoints. Consistent with the existing leak rather than a new policy.

Harness counter race. arx_cb() and ack_cb() run on the libusb event thread while gate_arx()/gate_duplex() read the same fields before stopping it, so a displayed rate or a duplex verdict could be built from torn counts. Relaxed atomics rather than per-gate locks — they are counters, nothing orders off them, and this is the RX hot path in a throughput gate.

LOG/ERR. They used a private [mt7612u] prefix instead of devourer [X] , emitted the text and its newline as two stdio calls so a line from the event thread could split one from a caller, and never flushed. mt_diag() formats the line first and emits it with one fwrite + fflush, which is what src/Event.h does for the machine plane and for the same reason. Warning-level diagnostics moved to [W].

Hardware verification after the rework

Re-run end to end so this is one record rather than a claim — 19 gates, 0 failures:

self-checking regs fw init chan caps hop pwr linkstat ampdu soak
witnessed by an RTL8812AU rtap 781 · sweep 1617 @20 / 1639 @bw=1 / 828 @bw=2 · coding 1919 · gateg 450 · mtu 400 · vht 748
MT7612U → MT7612U 2999 frames, rx_err rx_invalid rx_dropped all 0

Offline: make check green, and tools/extract_mt7612u_tables.py --check still reproduces initvals.h against the pinned reference/mt76@be5ce79.

The standing caveats are unchanged and worth repeating rather than burying: one witness generation (a single RTL8812AU) behind every on-air number, two samples of one board revision, no cold-boot test on this host, and the periodic RX gain worker (mt76x2_phy_update_channel_gain) still unported — only its width-dependent registers are.

josephnef
josephnef previously approved these changes Sep 7, 2026

@josephnef josephnef left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified at 1b032b5: the five deferred items are closed as described (accumulator-bracketed sequences, EEPROM blank-vs-failed separation, stranded-device teardown, atomic harness counters, single-fwrite diagnostics), the subtree-level work is back in this branch, and nothing touches the shipped build. Local build, make check and the extractor --check against the pinned reference/mt76 all reproduce. Landing unwired; the rename PR and the integration PR follow in that order.

@josephnef

Copy link
Copy Markdown
Collaborator

/review

Comment thread src/mt7612u/usb.c
Comment thread src/mt7612u/eeprom.c
Comment thread src/mt7612u/usb.c
Comment thread src/mt7612u/tools/bringup.c
Comment thread src/mt7612u/tools/bringup.c Outdated
Comment thread src/mt7612u/async.c
Comment thread src/mt7612u/init.c Outdated
@qodo-free-for-open-source-projects

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit 1b032b5

@josephnef

Copy link
Copy Markdown
Collaborator

Qodo's re-review of 1b032b5 raised seven. I checked each against the code and the pinned mt76; two are closed in-thread (the 5 GHz target-power one is mt76x2_get_power_info_5g() verbatim, the MT7612U_DEV read is deferred to integration). Five are real and hold the merge — please address them here:

  1. Async ring rejects what the TX builders produce. mt_async_tx_submit() refuses anything above MT_TX_BUFSZ (2048), but mt_tx_raw() builds up to MT_TX_BUF_MAX (4096) and mt7612u_send_packets() up to MT_USB_AGG_BUF (16 KB), and both route through the ring whenever RX is running. So in the normal integrated shape (RX loop up), a frame over ~2 KB or any multi-frame batch is refused after building, and send_packets reports zero accepted. The mtu numbers were measured on the sync path, which is why it did not show. Size the slots to the aggregate buffer or split at the ring — either way one ceiling, and a frame_shape-style test that submits a 16 KB aggregate through the async path.
  2. gate_coding / gate_sweep / gate_vht return 0 when nothing aired. Same shape as the gate_g fix in eca657c: a shortfall is printed and then exit 0. Fail on sent != count and reject non-positive counts.
  3. gate_init / gate_chan discard fw_dirmt_init_hardware(&dev, NULL) at both sites, so the documented init <fw_dir> form silently uses the default directory.
  4. open_selected() keeps the adapter lock when libusb_open() fails. g_lock_fd is set before the open, and only mt_close() releases it, which a failed mt_open() never reaches — so a retry in the same process collides with its own stale lock.
  5. g_stats_last_us is file-static. Two adapters in one process corrupt each other's interval_us. It belongs in struct mt7612u_dev.

Approval stands; I will merge once these land and the gate is green.

@qodo-free-for-open-source-projects

Copy link
Copy Markdown

Qodo's re-review of 1b032b5 raised seven. I checked each against the code and the pinned mt76; two are closed in-thread (the 5 GHz target-power one is mt76x2_get_power_info_5g() verbatim, the MT7612U_DEV read is deferred to integration). Five are real and hold the merge — please address them here:

  1. Async ring rejects what the TX builders produce. mt_async_tx_submit() refuses anything above MT_TX_BUFSZ (2048), but mt_tx_raw() builds up to MT_TX_BUF_MAX (4096) and mt7612u_send_packets() up to MT_USB_AGG_BUF (16 KB), and both route through the ring whenever RX is running. So in the normal integrated shape (RX loop up), a frame over ~2 KB or any multi-frame batch is refused after building, and send_packets reports zero accepted. The mtu numbers were measured on the sync path, which is why it did not show. Size the slots to the aggregate buffer or split at the ring — either way one ceiling, and a frame_shape-style test that submits a 16 KB aggregate through the async path.
  2. gate_coding / gate_sweep / gate_vht return 0 when nothing aired. Same shape as the gate_g fix in eca657c: a shortfall is printed and then exit 0. Fail on sent != count and reject non-positive counts.
  3. gate_init / gate_chan discard fw_dirmt_init_hardware(&dev, NULL) at both sites, so the documented init <fw_dir> form silently uses the default directory.
  4. open_selected() keeps the adapter lock when libusb_open() fails. g_lock_fd is set before the open, and only mt_close() releases it, which a failed mt_open() never reaches — so a retry in the same process collides with its own stale lock.
  5. g_stats_last_us is file-static. Two adapters in one process corrupt each other's interval_us. It belongs in struct mt7612u_dev.

Approval stands; I will merge once these land and the gate is green.

I can’t apply or land code changes to this PR from this chat because implementation actions aren’t available here. The five fixes are clear:

  • Make async TX slots accept the same aggregate size as the builders, or split aggregates before submission; add a 16 KB async-path test.
  • Make gate_coding, gate_sweep, and gate_vht reject non-positive counts and return failure when sent != count.
  • Pass fw_dir through both gate_init and gate_chan calls to mt_init_hardware().
  • Release/reset g_lock_fd when libusb_open() fails in open_selected().
  • Move g_stats_last_us into struct mt7612u_dev and use the per-device field for interval calculation.

I can review a patch for these changes, help draft the tests, or verify the resulting diff.

**The ring rejected what the builders produce.** MT_TX_BUFSZ was 2048 while
mt_tx_raw() built up to MT_TX_BUF_MAX (4096) and mt7612u_send_packets() up to
MT_USB_AGG_BUF (16 KB), and mt_tx_raw() takes the ring whenever one is running
-- the normal integrated shape. So a frame over ~2 KB was refused after being
built and every multi-frame batch reported zero accepted. The bench never saw
it because the sync path has no ring, which is exactly the blind spot: the mtu
sweep only ever exercised mt_bulk.

Fixed by making the ring slot the single ceiling (MT_TX_BUFSZ = MT_USB_AGG_BUF)
with a compile-time check that neither builder can outgrow it -- mutation-tested,
putting the ring back to 2048 now fails the build rather than the link. Ring
depth drops 32 -> 16 so slots carrying a full aggregate cost 256 KB rather than
512 KB; depth is not what buys throughput here, and soak/ampdu were re-measured
after the change.

The `mtu` gate now sweeps **both** TX paths and fails if they disagree, so this
cannot hide again. On air, sync and async are now identical at every size:
40/40 each up to 4064, both refusing 4065, and the witness saw 80 per size --
proof both paths aired.

Also from the re-review:

  - gate_coding / gate_sweep / gate_vht printed a shortfall and returned 0, the
    same structurally-guaranteed pass fixed in gate_g. They now count short
    arms and fail; non-positive frame counts are rejected outright.
  - gate_init and gate_chan passed NULL to mt_init_hardware(), so the
    documented `init <fw_dir>` form silently used the default directory.
  - open_selected() kept the adapter lock when libusb_open() failed. Only
    mt_close() releases it and a failed mt_open() never reaches there, so the
    process collided with its own stale lock on the next retry.
  - g_stats_last_us was file-static, so two adapters in one process overwrote
    each other's mark -- and every MIB counter is read-and-clear, so both got
    wrong rates. Now per device.

Two more found while verifying, neither from the review:

  - bringup.c named a private downstream consumer in a comment. Described by
    role now.
  - gate_duplex's verdict was (n && ctx.n), which its own setup cannot satisfy:
    the radio is half duplex and the gate saturates TX, so RX during the flood
    is ~0. It has therefore never passed. Confirmed pre-existing by building
    the previous commit and reproducing it. Rewritten to assert what it can
    establish -- TX sustained, no errors, and the receiver still delivering
    after the flood stops -- and to say plainly that it needs a peer.

Verified: 18 of 19 gates. The one gap is the MT7612U-to-MT7612U arm, blocked on
the second adapter sitting in the RX-undrained wedge that only a physical
replug clears (EP0 reads answer, bulk-OUT times out). The new ring is still
covered on air by the mtu sweep's async pass against the RTL8812AU witness;
what is NOT re-verified since the ring change is MT-to-MT, and the reworked
duplex assertion has never had a working peer to run against. Both are stated
rather than assumed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tba83kymS5W2v1vn2yRxrj
@snokvist

snokvist commented Sep 7, 2026

Copy link
Copy Markdown
Collaborator Author

All five are in at 737b4c6.

1. Async ring ceiling — the one that mattered

You were right about why it hid: the mtu sweep only ever ran mt_bulk, because mt_tx_raw() takes the ring only when one is running and that gate never started RX. So the bench measured the path the integration does not use.

MT_TX_BUFSZ is now MT_USB_AGG_BUF — one ceiling — with a compile-time check that neither builder can outgrow it. Mutation-tested: putting the ring back to 2048 fails the build, not the link. Ring depth drops 32 → 16 so slots carrying a full aggregate cost 256 KB rather than 512 KB; depth is not what buys throughput here (docs/mt7612u.md, "Async rings bought no throughput") and soak/ampdu were re-measured after the change.

Rather than only add a static test, I made the mtu gate sweep both paths and fail if they disagree, so the blind spot cannot come back:

  bytes  sync       async      note
  2304   40/40      40/40
  4000   40/40      40/40      above the 802.11 MPDU ceiling
  4064   40/40      40/40      above the 802.11 MPDU ceiling
  4065   0/40       0/40       refused by this driver

The witness saw 80 frames per size — 40 from each path — so both genuinely aired. frame_shape also carries the size-relationship test you asked for.

2–5

  • gate_coding / gate_sweep / gate_vht now count short arms and fail; non-positive counts rejected outright.
  • gate_init / gate_chan pass fw_dir through.
  • open_selected() releases the lock when libusb_open() fails.
  • g_stats_last_us moved into struct mt7612u_dev.

Two more, found while verifying

bringup.c named a private downstream consumer in a comment. Described by role now — that should not have been in a public branch.

gate_duplex's verdict was unsatisfiable. It required (n && ctx.n), but the radio is half duplex and the gate saturates TX, so RX during the flood is ~0 — it has therefore never passed. I confirmed that is pre-existing by building the previous commit and reproducing it, rather than assuming I had caused it. Rewritten to assert what it can establish: TX sustained, no errors, and the receiver still delivering after the flood stops. It also now says plainly that it needs a peer.

Verification, and what is not covered

18 of 19 gates pass. Ten self-check; rtap 779, sweep 1609 @20 / 1654 @bw=1 / 816 @bw=2, coding 1920, gateg 450, mtu 600, vht 747 all witnessed by the RTL8812AU. make check green, extractor --check still reproduces initvals.h.

The gap, stated rather than buried: the MT7612U-to-MT7612U arm did not run. The second adapter is sitting in the RX-undrained wedge this port documents — EP0 reads answer, bulk-OUT times out — and only a physical replug clears it, which I cannot do remotely. So:

  • the new ring is covered on air, by the mtu async pass against the witness;
  • what is not re-verified since the ring change is MT-to-MT (it passed at 2998 frames earlier today, but on the pre-change ring);
  • the reworked duplex assertion has never had a working peer to run against.

I chased that one badly before concluding it: three separate "RX is 0" readings turned out to be a dead peer, then a txdemo that emits a 10-frame burst rather than a stream, then a stale process holding the witness. The receiver itself is provably fine — 366 ambient frames on ch1. I will re-run both once the adapter is replugged and post the numbers.

The previous commit said gate_duplex's (n && ctx.n) verdict was unsatisfiable
because RX is ~0 under TX saturation, and that it had therefore never passed.
That was wrong, and wrong for the reason this port keeps relearning: the peer
adapter had silently failed its firmware load, so nothing was on air at all.
Every "RX is 0" reading behind that conclusion was a dead observation channel.

Measured with a live peer: 2894 fps out while 20-44 fps still arrives, against
166 fps when the same peer is received with no local TX. So a saturated
transmit throttles receive to roughly a quarter and does not stop it, and the
receiver is fully healthy afterwards.

The added post-flood check stands on its own merits and is kept: the in-flood
count alone cannot distinguish a throttled receiver from a wedged one or from a
quiet channel, and the failure message now names the peer requirement because a
missing stimulus and a wedged receiver look identical from inside the gate.
What changes here is the justification, which asserted something false.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tba83kymS5W2v1vn2yRxrj
@snokvist

snokvist commented Sep 7, 2026

Copy link
Copy Markdown
Collaborator Author

Adapter replugged, so the two arms I owed you are done — and one of them corrects something I told you.

Correction first

I said gate_duplex's (n && ctx.n) verdict was unsatisfiable and had never passed. That was wrong. The peer adapter had silently failed its firmware load, so nothing was on air; every "RX is 0" reading behind that conclusion was a dead observation channel, not a result.

Measured with a live peer: 2894 fps out while 20–44 fps still arrives, against 166 fps when the same peer is received with no local TX. So a saturated transmit throttles receive to roughly a quarter and does not stop it. The added post-flood check stays on its own merits — the in-flood count alone cannot tell a throttled receiver from a wedged one, or from a quiet channel — but the justification I committed asserted something false and is fixed in f206354.

The two blocked arms

MT7612U → MT7612U on the new ring: 3954 frames across 20/40/80 MHz sweeps, rx_err rx_invalid rx_dropped all 0, HT 959 and VHT 1075 decoded. That closes the gap I flagged — the ring change is now verified peer-to-peer, not only against the witness.

duplex: PASS — 23153 frames out, receiver healthy afterwards.

Full coverage, every gate

Self-checking: regs fw init chan caps pwr hop linkstat ampdu soak rxbytes diversity linktx tx rx. Peer-backed: arx 1097, duplex, linkrx. Witnessed by the RTL8812AU: rtap 776, sweep 1801 @bw=0 / 839 @bw=1 / 763 @bw=2, coding 1661, vht 925, gateg 450, mtu 600 across both TX paths.

Explicit arms for your findings: #3 names the given fw_dir instead of defaulting; #4 leaves no stale lock after a failed open; #2 rejects a non-positive count with rc=2. make check green, extractor --check still reproduces initvals.h.

One methodology note, since it bit me twice more today: both adapters transmit with the same source MAC, so a leaked peer inflates every witness count. One sweep produced sweep 80 → bw=0, 17500 frames, which is impossible and was exactly that. The numbers above are from runs with a single transmitter proven.

Stepping back — is it fit for purpose as a WLAN adapter?

Three gaps that gate-by-gate testing hid, none blocking, all worth naming:

  1. Every throughput figure in this port is transmit. soak and ampdu report Mbit/s; nothing measures sustained receive throughput. For the obvious consumer — a ground station receiving video — that is the number that matters most, and it does not exist. The RX rates quoted anywhere here are peer-limited, not receiver-limited.

  2. A channel change has never been exercised with the receiver running. hop measures retune cost with RX down. Retune-while-receiving is precisely the operation a hopping or channel-migrating consumer performs.

  3. TX and RX ceilings differ on the same silicon — 4064 out, 3836 in — so two of these adapters have a 228-byte band where a frame transmits and vanishes. Both sit well above 802.11's 2304-byte non-A-MSDU limit, so standards-compliant traffic is unaffected; the dead band is entirely in already-oversized territory. Worth documenting rather than fixing, unless you would rather max_mpdu_tx were clamped to what an identical peer can receive.

Incidentally, the adapter lock proved itself in anger today: two sweeps collided on one adapter and the second refused to claim it rather than wedging it, which is the failure this port previously recovered from only with a physical replug.

@josephnef
josephnef enabled auto-merge (squash) September 7, 2026 18:04

@josephnef josephnef left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-approving at f206354 (the push dismissed the earlier approval). Verified: all five re-review fixes present, make check in the subtree green, extractor --check reproduces initvals.h, every Qodo thread resolved.

@josephnef
josephnef merged commit daabab7 into OpenIPC:master Sep 7, 2026
24 of 25 checks passed
snokvist added a commit to snokvist/devourer that referenced this pull request Sep 8, 2026
…oints

Blocking
- The wedge recovery is factored into mt_recover_usb() and runs after identify
  on BOTH open paths, so a libusb-owning consumer arriving through
  mt7612u_open_handle()/mt_adopt() gets it too. It lived in mt_open() alone,
  which left that consumer paying both mt_fw_init() attempts and failing with
  the exact error the recovery removes.
- Teardown no longer runs the receiver undrained. New mt_mac_rx_disable()
  clears ENABLE_RX before the EP 4 ring is cancelled - in mt7612u_close() and
  at every ring-cancelling site in bringup, via one rx_teardown() helper rather
  than the pair open-coded twenty times.

Inline
- phy.c: the AGC 8/9 snapshot moved above the fast return, so a fast retune no
  longer programs the previous channel's gain base.
- rx.c/phy.c: the ambient-RSSI EMA is gone. mt76_get_min_avg_rssi() averages
  ASSOCIATED stations and returns 0 for a monitor consumer, so mt76 sits at the
  -75 fallback; feeding it from every frame let a -40 dBm neighbour step the
  gain down against a wanted peer at -80 dBm. phy_update_channel_gain() uses
  -75 directly and the RX hot path does no cross-thread write at all.
- mcu.c: the EP 5 drain is armed by mcu_stale_pending instead of running before
  every command, where it cost a guaranteed 5 ms timeout each.
- usb.c/phy.c: mt_rr_chk() on the U3DMA and MT_RX_STAT_1 reads - ~0u has
  TX_BUSY set, and reads as 65535 false CCAs.
- fw.c: the retry keys on a distinct upload/device failure, so a missing blob
  fails immediately naming the file; mt_io_errors is restored (not zeroed)
  around the retry.
- bringup: gate_swreset polls MT_MAC_STATUS_TX, and gate_rx says in its own
  output that it is not a valid tick witness under load.

Process
- MT7612U_NO_AUTORECOVER is now d->no_autorecover, set by bringup. MT7612U_DEV
  stays deferred as agreed in OpenIPC#412: there is no public way to pass a selector,
  so removing it would leave a multi-adapter consumer unable to choose at all.
- One figure, one home: the mt7612u_phy_tick() comment. phy.c, bringup.c and
  docs point at it. The 4362/s figure is deleted rather than re-homed - nothing
  says which build produced it.
- docs/mt7612u-usb-wedge.md loses its provenance header.

Also fixed, from an adversarial pass over the merged result - both were
integration defects no single-file change could see:
- Hoisting the AGC reads above the fast return also moved them outside the only
  mt_io_errors() bracket guarding them. A failed read is ~0u, and
  FIELD_GET(MT_BBP_AGC_GAIN, ~0u) is 0x7f, so one EP0 hiccup during a fast
  retune would have programmed a gain base of 127 and gone deaf until the next
  full tune, silently. Now checked, keeping the previous snapshot.
- mt_io_clear() in the fw retry would have wiped the caller's whole bracket:
  mt_init_hardware() opens it before mt_power_cycle(), and there is only one
  accumulator, so a partly powered WLAN core could have reported success.
- Also: the bulk-OUT failure path now arms the drain (a failed OUT can still
  have been delivered), the drain only disarms on a clean timeout, gate_rx got
  the teardown invariant it was missing, and gate_adopt takes the interface
  claim as a guard before resetting and releases/reattaches on every path.

Device-verified on MT7612U 2-1 (peer: a second MT7612U flooding ch149):
  RX      4902-4913 fps over 5 runs, rx_err/invalid/dropped all 0
  adopt   `bringup adopt` brings the device up; with NO_AUTORECOVER=1 the
          recovery announces itself ON THAT PATH, which is the code-path fact
          the review asked for
  drain   9 MCU timeouts under RF load produced ONE drain of 9 replies
  hop     530 ms full / 50.6 ms fast retune
Strict build (-Wall -Wextra -Wshadow -Wmissing-prototypes) and make check clean.

Not reproduced this session: the soft USB wedge (5 kill cycles, U3DMA_CFG
stayed healthy), so "adopt recovers a WEDGED adapter" remains a code-path
fact - the same shared function on both paths - not a measurement.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tba83kymS5W2v1vn2yRxrj
josephnef pushed a commit that referenced this pull request Sep 9, 2026
… wedge (#414)

## Summary

Follow-up to #412 (merged as `daabab7`). Library-internal — no
`IRtlDevice`
surface. Two faults found by exercising the subtree the way a ground
station
would, both device-verified, plus a hardware review of the whole delta.

## Public header (for the wrapper)

One addition, no changed signatures: **`int mt7612u_phy_tick(struct
mt7612u_dev *dev)`** in `include/mt7612u/mt7612u.h`. It is **required
for
receive** — a consumer that receives must call it about once a second or
the
front end goes deaf against a strong transmitter (§1). The `IRtlDevice`
wrapper
should call it on a 1 Hz timer once it drives RX; everything else in the
header
is unchanged, so the rename and integration PRs that only consume this
header
are unaffected apart from adding that one call. `docs/mt7612u.md` is
updated in
this PR for the lock and the RX-undrained recovery story, as requested.

## 1. A receiver went deaf against a strong transmitter

Against a peer ~20 cm away airing 1400-byte HT-MCS7 at 3037 fps, `arx`
took
**3 frames in 10 s**. Not the adapter (symmetric both directions; both
hear
400–1000 fps of ambient on ch1/36/40/44/48), not the ring (its own
counter
agreed at 3), not the air (an RTL8812AU witness counted 103805 frames
from the
same transmitter). mt76 runs `cal_work` every second
(`MT_CALIBRATE_INTERVAL == HZ`, `mt76x2/usb_phy.c:42`); this port ran
none of
it. Bisected with one verified peer per arm:

| arm | frames / 10 s |
|---|---|
| nothing | 3 (reproduced 8×) |
| `MT_RX_STAT_*` reads only | 3 |
| a periodic MCU calibration only | 43902 |
| the ported gain tracking only | 4 |
| the full tick | 49002 |

New public `mt7612u_phy_tick()`, caller-driven (MCU commands share one
4-bit
sequence number and one response endpoint), called once a second from
every
receiving path. Final reliability, both adapters as receiver, single
verified
peer: **eight consecutive runs at 5415–5470 fps.** The MCU answers
slowly under
RF load — nine calibration timeouts per bring-up on this bench — so
`mcu_wait_resp()` waits 10×300 ms and `mt_mcu_send()` drains any late
reply off
EP 5 before the next command (without which the reply channel desynced
and
cascaded `mcu resp mismatch … (want 1)`). The calibration takes
regardless of
those timeouts; the channel is marked calibrated once, as mt76 does.

## 2. A run killed mid-transfer left the adapter unable to load firmware

Register reads *and* writes still round-trip, MAC and RF are fine, and
the
kernel `mt76x2u` driver cannot bind it either — every bulk OUT NAKs.
`libusb_reset_device` (already on every open) does not reach it.
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. That plus an RX-pipe drain is
`mt_recover_usb()`,
which 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. (Review fix: it lived in `mt_open()` alone, which was precisely the
consumer this subtree exists for getting none of it. `bringup adopt` now
exercises that path.)

A separately-observed **transient**: a post-kill firmware upload fails
for 3–8 s
then succeeds by itself (12/12 kills, no repair applied) — a settling
window,
not a wedge. `mt_fw_init()` absorbs it with one bounded retry after a 3
s
settle. On a healthy idle adapter the recovery is a no-op: 0/40
healing-open
failures.

**Known issue, called out honestly:** a harder tier — `TX_BUSY`
**stuck** at
`0x80c00020` — was seen once and is **not** cleared by any of the above;
it
needed a physical replug, and it has not reproduced since (36+ kills).
`docs/mt7612u-usb-wedge.md` traces the vendor tree's separate UDMA and
IFDMA/FCE
resets that none of the attempts reached, implemented as `bringup
swreset` and
still awaiting a reproduction to test against. `mt_open()` warns clearly
when it
hits this state rather than failing with an opaque timeout.

## Verification

- Receiver: 8/8 runs 5415–5470 fps, both adapters.
- Wedge recovery: soft tier 3/3; healthy-adapter soak 0/40; short stress
on the
  shipping binary 0/12 across the scenarios that actually wedge.
- Post-kill transient: 12/12 clear by themselves, absorbed by the loader
retry.
- `mt_mac_start()` now refuses to enable the receiver with no ring
draining
(`MT_RX_DRAIN_*`; the public `mt7612u_start()` always derived this,
three
  gates bypassed it).
- `make check` green; strict `-Wall -Wextra -Wshadow
-Wmissing-prototypes`
  build clean.

## Retracted in the course of this

Two claims made and then falsified: that the inverted ring/receiver
order in
`arx` explained the 3 frames (it didn't), and that a synchronous MCU
command
"hangs forever" beside the RX ring's event thread — it never reproduced
in 8
stack-capture attempts, and libusb's own source shows that shape is
supported.
The comments say so where they used to say otherwise.

## Not in scope

The vendor-neutral rename, `IRtlDevice` wiring, and a proven recovery
for the
stuck-`TX_BUSY` tier (needs a reproduction first).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01Tba83kymS5W2v1vn2yRxrj



---

## Review round (head `4a6a14c`)

Both blocking items and all eight inline points are addressed; see the
review
reply for the point-by-point. Headlines: the recovery is shared by both
open
paths, teardown clears `ENABLE_RX` before the EP 4 ring is cancelled,
the EP 5
drain is armed rather than unconditional, the ambient-RSSI EMA is gone
in favour
of mt76's monitor behaviour, and the library no longer reads
`MT7612U_NO_AUTORECOVER` (`MT7612U_DEV` stays deferred per #412).

**Re-verified on this head** — a different stimulus from §1's, so quoted
separately rather than folded into that figure. Peer: a second MT7612U
flooding
ch149; DUT: MT7612U at `2-1`.

| check | result |
|---|---|
| RX rate | **4902–4913 fps**, 5 runs,
`rx_err`/`rx_invalid`/`rx_dropped` all 0, peer verified airing before
and after every run |
| adopt path | `bringup adopt` brings the device up; with
`MT7612U_NO_AUTORECOVER=1` the recovery announces itself **on that
path** |
| MCU drain | 9 calibration timeouts under RF load produced **one**
drain of 9 replies, where the old code paid a 5 ms timeout before every
command |
| retune | 530 ms full / 50.6 ms fast |
| build | strict `-Wall -Wextra -Wshadow -Wmissing-prototypes` clean;
`make check` green |

**Not** reproduced this session: the soft USB wedge (5 kill cycles with
TX+RX up,
`U3DMA_CFG` stayed healthy each time). So "the adopt path recovers a
*wedged*
adapter" is a code-path fact — one shared function, two call sites — not
a
measurement from this round.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
@josephnef

Copy link
Copy Markdown
Collaborator

The rename PR agreed above is open as #415: IRtlDeviceIRadio, IRtlTransportITransport, CreateRtlDeviceCreateRadio, with the Realtek-only members (SetXtalCap/GetXtalCap, GetRxEnergy, ProbeEfuseStability, DumpChipState) split onto IRtlRadio : IRadio. Hard rename, no aliases. The MT7612U integration (CMake option, 0e8d:7612 dispatch ahead of the SYS_CFG2 read, a MediaTek transport, an IRadio implementation over src/mt7612u with the 1 Hz tick on its own thread) can land on top of it without touching core names again.

josephnef added a commit that referenced this pull request Sep 9, 2026
…; Realtek-only members on IRtlRadio (#415)

Precondition for a non-Realtek backend (the MT7612U subtree from #412 /
#414). Pure rename plus one interface split; no behaviour change on any
Realtek path, no new CMake option, no factory dispatch on `0e8d:7612`.

## Renames (commit 1)

| before | after |
|---|---|
| `IRtlDevice` (`src/IRtlDevice.h`) | `IRadio` (`src/IRadio.h`) |
| `WiFiDriver::CreateRtlDevice` | `WiFiDriver::CreateRadio` |
| `WiFiDriver::CreateRtlDevicePcie` | `WiFiDriver::CreateRadioPcie` |
| `devourer::IRtlTransport` (`src/RtlTransport.h`) |
`devourer::ITransport` (`src/Transport.h`) |

`RtlAdapter`, `UsbTransport`, `PcieTransport`, `WiFiDriver` and the five
backend classes keep their names: they are Realtek by construction or
vendor-neutral already.

**Downstream-breaking, intentionally.** No `using IRtlDevice = IRadio`
alias and no `CreateRtlDevice` forwarder. Embedders (OpenIPC PixelPilot
and any other consumer) update in the same release. The `git grep |
xargs sed` in commit 1's message applies unchanged to a downstream tree,
plus `#include "IRtlDevice.h"` → `"IRadio.h"`.

## The split (commit 3)

`IRadio` keeps only what any 802.11 monitor/injection radio can
implement. The five members whose meaning is fixed by Realtek silicon
move to `IRtlRadio : public IRadio` (`src/IRtlRadio.h`), which every
Realtek backend derives from:

- `SetXtalCap` / `GetXtalCap` — AFE crystal-cap trim code
- `GetRxEnergy` — phydm FA/CCA/IGI/NHM snapshot (`RxEnergy`)
- `ProbeEfuseStability` — EFUSE logical-map re-read, `0x8129` id
- `DumpChipState` — rtw canary register dump

`GetFwBootStatus` stays on `IRadio`: its struct names no Realtek
constant. Callers reach the five through `dynamic_cast<IRtlRadio*>` and
treat `nullptr` as "not on this radio": `doctor` skips the EFUSE leg,
`chipstate` refuses, `rxdemo`'s energy telemetry and sweep, `txdemo`'s
hopset sensing and xtal stepping, `chanmig`'s probe and `chanscout`'s
dwells each say so once and carry on with frame statistics only.
Consequence worth stating: hopset TX-side sensing and the chanmig energy
probe are Realtek-only until a neutral frame-free energy type exists
(integration-PR work).

`tests/radio_iface_selftest.cpp` implements `IRadio`'s pure-virtual core
with no Realtek type and pins the not-ported defaults; ctest cell
`radio_iface`. A Realtek-typed pure virtual added to `IRadio` stops it
compiling.

## Verification

- gcc all-chips Release: build clean, ctest 56/56 (incl. `radio_iface`).
- clang all-chips Release: build clean.
- `build-configs` subsets jaguar1-only (ctest 47/47) and kestrel-only
(50/50); `DEVOURER_PCIE=ON` (56/56, compiles `CreateRadioPcie` and
`PcieTransport : ITransport`).
- `git grep -E
'IRtlDevice|CreateRtlDevice|IRtlTransport|RtlTransport\.h|IRTL_DEVICE_H'`
outside `reference/` and `hal/`: empty.
- On air, channel 36: `regress.py` devourer→devourer cell, TP-Link T3U
(8822BU) TX to 8812CU RX: **6100 hits / 6500 TX / 15 s**. The kernel
cells of that run fail because neither adapter's vendor kernel driver
binds on this host kernel; unrelated to this PR.
- `doctor --pid 0xb812`: EFUSE probe reached through the `IRtlRadio`
cast, verdict HEALTHY (3 reads, 0 mismatched, id 0x8129).
- `rxdemo` on the 8812CU with `DEVOURER_RX_ENERGY_MS=1000`: seven
`rx.energy` events with live FA/CCA/IGI counters.
- `tests/beacon_interval_shift.sh` (inline-compiled C++ calling
`CreateRadio`): compiles and runs; actuator verdict unchanged.
- MSVC and mingw: CI.

## Not in this PR

The MT7612U integration (`DEVOURER_MT7612U`, a VID:PID gate ahead of the
`SYS_CFG2` read, a MediaTek transport, an `IRadio` implementation with
the 1 Hz PHY tick on its own thread, a `regress.py` cell), a neutral RX
type with an explicit `has_fcs` flag, a neutral frame-free energy type,
and removal of the older `Rtl8812aDevice` / `RtlUsbAdapter` aliases.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_015VtdB2ZryKELUwREjPBH3v

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
snokvist added a commit to snokvist/devourer that referenced this pull request Sep 9, 2026
…onment

qodo's first finding on OpenIPC#421, and it is right for a reason that is specific to
this PR: OpenIPC#412 deferred the getenv while the subtree was unreachable, but
DEVOURER_MT7612U now links that open path into libdevourer, so "the library
picks its adapter from ambient process state" stopped being a bench-tool quirk
and became a library property. A consumer with two adapters could claim the
wrong one because of a variable it never set.

open_selected() takes the selector as a parameter, mt7612u_open_selected() is
the public way to pass it, and mt7612u_open() is that with NULL. bringup fills
d->dev_selector from MT7612U_DEV before mt_open(), so the operator-facing
spelling is unchanged. The library now reads no environment at all — the claim
two comments made prematurely in the previous commit is finally true.

The messages had to move with it. They named MT7612U_DEV, which the library no
longer reads, so a consumer that is not bringup would have been told to set
something it does not use. They now name "the selector" and point bringup users
at the variable.

tests/api_link.c covers the new entry point (26 now, still compiled as C), which
is the test's whole purpose: a public declaration with no definition fails there
rather than at the first caller.

Verified on hardware, two adapters attached: MT7612U_DEV=2-1 and =5-1 each open
their own unit (distinct EEPROM MACs 40:a5:ef:50:27:a1 and 40:a5:ef:5a:32:f8),
and with no selector it still takes the first and warns. 59/59 ctest in all
three configs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tba83kymS5W2v1vn2yRxrj
josephnef pushed a commit to snokvist/devourer that referenced this pull request Sep 10, 2026
…onment

qodo's first finding on OpenIPC#421, and it is right for a reason that is specific to
this PR: OpenIPC#412 deferred the getenv while the subtree was unreachable, but
DEVOURER_MT7612U now links that open path into libdevourer, so "the library
picks its adapter from ambient process state" stopped being a bench-tool quirk
and became a library property. A consumer with two adapters could claim the
wrong one because of a variable it never set.

open_selected() takes the selector as a parameter, mt7612u_open_selected() is
the public way to pass it, and mt7612u_open() is that with NULL. bringup fills
d->dev_selector from MT7612U_DEV before mt_open(), so the operator-facing
spelling is unchanged. The library now reads no environment at all — the claim
two comments made prematurely in the previous commit is finally true.

The messages had to move with it. They named MT7612U_DEV, which the library no
longer reads, so a consumer that is not bringup would have been told to set
something it does not use. They now name "the selector" and point bringup users
at the variable.

tests/api_link.c covers the new entry point (26 now, still compiled as C), which
is the test's whole purpose: a public declaration with no definition fails there
rather than at the first caller.

Verified on hardware, two adapters attached: MT7612U_DEV=2-1 and =5-1 each open
their own unit (distinct EEPROM MACs 40:a5:ef:50:27:a1 and 40:a5:ef:5a:32:f8),
and with no selector it still takes the first and warns. 59/59 ctest in all
three configs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tba83kymS5W2v1vn2yRxrj
josephnef pushed a commit that referenced this pull request Sep 10, 2026
… that stops the Jaguar1 misroute (#421)

> **Stacked on #420.** This branch is cut from that PR's head, so the
diff shows
> its four commits too (`8c5dd0d`, `eb10389`, `8dd504c`, `31cb738` — the
FCS
> contract). The five that belong to this one are `dde4766`, `cdbb72d`,
> `20e0b7e`, `9238f39`, `b839df3`. Merge #420 first and this rebases to
nothing;
> happy to retarget it if you would rather they went in together.

Second block of #419. Takes the two scope items that do not need a
radio: the
`DEVOURER_MT7612U` option with the portability work behind it, and the
factory
gate. There is still no `IRadio` backend — `CreateRadio` recognises the
MediaTek
ids only to **refuse** them.

## The bug the gate fixes, measured

`CreateRadio` ends in an unconditional Jaguar1 construction, and
`read_chip_id()`
discarded its libusb return code. So a device that does not answer the
Realtek
`SYS_CFG2` vendor read arrived there as chip-id `0x00` and was brought
up as an
RTL8812AU. Both arms of the same control transfer, on this bench:

```
bmRequestType 0xC0, bRequest 5, wValue 0x00FC, 1 byte

  RTL8812AU  0bda:8812   rc=1                        chip_id=0x04
  MT7612U    0e8d:7612   rc=-7 LIBUSB_ERROR_TIMEOUT  destination byte UNTOUCHED
  MT7612U    0e8d:7612   rc=-7 LIBUSB_ERROR_TIMEOUT  (second adapter, same)
```

The MediaTek arm ran with a `0xAA` poison byte in the destination and it
survived, so the `0` that reached the dispatch was the caller's own
initialiser,
never a reading. `read_chip_id`'s own comment already described the
consequence
— *"Returns 0 on a failed read, which falls through to the Jaguar1
path"* — this
just makes it not happen.

Two guards, both ahead of the Realtek read:

- **`Mt7612uUsbIds.h`** — the complete 16-entry `mt76x2u_device_table`,
taken from
the pinned `reference/mt76 @ be5ce79` so it is re-verifiable from a
fresh
  checkout, gated the way Kestrel gates.
- **`read_chip_id` returns `std::optional`** — `nullopt` when the
*transfer*
failed. A successful read of `0x00` still returns a value and keeps its
  existing fall-through, so no cold-boot transient changes behaviour.

## Why the gate is vid:pid, and not what the issue asked for literally

The scope item says *"an unrecognised non-Realtek VID must refuse"*. I
did not
implement that, because it would refuse working adapters. Six vendor ids
ship
both silicon families, and they do not merely coexist — they
**interleave inside
one vendor's product-id range**:

| vendor | MediaTek ids | Realtek ids |
|---|---|---|
| `0x0846` Netgear | `9014`, `9053` | `9051`, `9052`, `9054` |
| `0x056e` ELECOM | `400a` | `4007`, `400b`, `400d`, `400e`, … |
| `0x0b05` ASUS | `17eb`, `180b`, `1833` | `17d2`, `1817`, `1852`,
`1853`, `1a62` |
| `0x2357` TP-Link | `0137` | `0101`, `0103`, `0106`, `010d`, … |
| `0x7392` Edimax | `b711` | `6822`, `a811`, `a812`, `a813`, … |
| `0x2c4e` Mercury | `0103` | `0127` |

The **pairs**, by contrast, are disjoint: none of the 16 MediaTek ids
matches any
of the **91** Realtek ids devourer can serve. That is the same property
the
Kestrel gate already relies on. The transfer-failure guard then covers
the intent
behind the scope item without the cost — it refuses *every* non-Realtek
part,
named or not. Say the word if you want the literal vendor-id rule as
well; I
think this pair is strictly better, and the table above is why.

## Portability

The subtree is now C++ (`.cpp`), so there is one compilation mode rather
than a
second path that can rot unnoticed. Cost measured by compiling each file
with
`g++ -std=c++20 -Wall -Wextra` *before* changing anything: **12** sites
in the
library (11 implicit `void*` conversions, 1 `uint8_t`→enum) and 40 in
`bringup`,
32 of which cascaded from three `_Atomic` declarations. No C++ keyword
collisions, no VLAs, no compound literals, no tentative definitions;
both
designated initialisers were already in declaration order.

```
pthread_mutex_t          -> std::mutex
io_lock (recursive)      -> std::recursive_mutex
pthread_cond_t           -> std::condition_variable_any
pthread_t                -> std::thread
nanosleep                -> std::this_thread::sleep_for
clock_gettime(MONOTONIC) -> std::chrono::steady_clock
```

`condition_variable_any` rather than `condition_variable` so it waits on
the
bare mutex and every lock site keeps its original shape — a much smaller
diff
through code whose teardown ordering is documented as a use-after-free
hazard.
Those waits are teardown and TX back-pressure, never a hot path.

**The exported surface is still C.** `include/mt7612u/mt7612u.h` already
had an
`extern "C"` guard and `tests/api_link.c` is deliberately still compiled
by
`$(CC)`, so the ABI is tested rather than asserted: it resolves 25 entry
points
against the C++ objects, and `nm` shows the public symbols unmangled.

Two things fell out that are worth naming:

- A constructed `std::recursive_mutex` member **retires the
`io_lock_ready`
flag**. A zeroed `pthread_mutex_t` is a valid NON-recursive lock, so an
open
path that skipped the explicit init self-deadlocked the PHY tick — the
adopt
  path did exactly that once. That state is now unrepresentable.
- The teardown's `pthread_cond_timedwait` deadline arithmetic became one
`wait_for`, dropping a `CLOCK_REALTIME` dependence where a wall-clock
step
  could stretch or skip the 2 s budget.

The two structs holding those members moved from `calloc`/`free` to
`new` /
`delete` — `calloc` never runs a constructor. `{}` still zeroes every
scalar.
`-Wclass-memaccess` then caught a real defect that introduced: a
`memset` over a
constructed device in `frame_shape`. Its loop case now declares the
device
inside the loop, preserving the per-iteration reset.

**Not ported:** the `flock` adapter lock is `_WIN32`-guarded rather than
mirrored. It works by contending for the *same* lock file
`UsbDeviceLock` uses,
but on Windows that class is a named mutex — so a file lock there would
exclude
nobody, and mirroring the mutex would duplicate what the devourer path
already
owns. Unprotected on Windows is a bare `mt7612u_open()` with no devourer
around
it, which is the bench tool's case, and the bench is Linux.

## Capability fallout

`bw_mask_for_generation` ends in a catch-all returning 20/40/80 **plus**
`kBw5|kBw10`, so the new enumerator silently inherited narrowband:

```
old chain, ChipGeneration::Mt7612u -> 0x1f   (kBw5|kBw10 set)
new chain, ChipGeneration::Mt7612u -> 0x1c   (20/40/80 only)
every other generation             ->  unchanged
```

`MT_RATE_BW` encodes nothing narrower than 20 MHz, and this issue lists
that
among the part's known limits. Nothing calls it with `Mt7612u` today,
which is
why it was worth fixing now — the first caller is the backend, and a
capability
claimed there is claimed silently. `adapter_caps_selftest` pins the
value and
the narrowband bits.

## What review changed

Two reviewers went at this, and both found things I had wrong. Recording
them
because they are the substance of the branch, not footnotes.

**The selftest could not see Jaguar.** It cross-checked the MediaTek set
against
`KestrelUsbIds.h` and `Rtl8733bUsbIds.h` — 27 ids. Jaguar1/2/3 dispatch
by
chip-id and have no table in devourer, so 64 more were invisible, and
*every*
interleaved vendor id above lives in that 64. Adding `{0x0b05, 0x17d2}`
— an
ASUS USB-AC56, a real RTL8812AU — passed the old test while refusing a
genuine
Jaguar1 adapter. The test now carries a 64-entry witness list from
`reference/rtl8812au`, checks all 91, and asserts the coverage count so
a future
trim cannot silently restore the blindness. Both attacks now fail it:

```
mt7612u_usb_ids: FAIL 0b05:17d2 is Jaguar1/2/3 but the MediaTek gate claims it
mt7612u_usb_ids: FAIL 0846:9054 is Jaguar1/2/3 but the MediaTek gate claims it
```

**My table was 11 of 16, from the wrong source** — this machine's kernel
tree
rather than the pinned submodule. Recomputing against the complete table
also
turned "three shared vendor ids" into six, which is the table above.

**The library read the environment and wrote to stderr.** qodo raised
both, and
both are things this PR's own `DEVOURER_MT7612U` created by linking the
subtree
into `libdevourer`: a bench tool that reads `MT7612U_DEV` is a quirk, a
*library*
that picks its adapter from ambient process state is a defect, and the
same for
one that writes diagnostics to a stream its host does not control.
`mt7612u_open_selected()` takes the selector and
`mt7612u_set_log_sink()` diverts
the diagnostics; the library now reads no environment and names stderr
in exactly
one place. `mt_diag()` turned out to be reimplementing `Logger` — same
format,
same fwrite+fflush atomicity, none of `set_level` / `set_diag_stream` /
`DEVOURER_LOG_MAX_LEVEL` — and on Android `Logger::emit` has an
`__android_log_write` branch these writes did not, so the subtree's
diagnostics
landed nowhere visible on the platform devourer ships to.

**Two exceptions could cross the `extern "C"` boundary**, both proved by
probe
rather than argued: `catch (const std::system_error &)` is narrower than
what
`std::thread`'s constructor throws (libstdc++ allocates the thread state
with a
*throwing* `new`, so OOM arrives as `bad_alloc`), and `new
(std::nothrow) T{}`
is not nothrow for these types, because `std::condition_variable_any`
holds a
`shared_ptr<mutex>` that allocates in *its* constructor. Both are now
`try`/`catch (...)` returning the existing `-1` / `NULL`.

Also folded: three comments still said `mt_dev_state_init/destroy`
manage
`io_lock` after this branch made them empty; one comment quoted a
`read_chip_id`
note the same commit deleted; and `mt_usleep` is no longer interruptible
(measured: a 200 ms request under 100 Hz `SIGALRM` returned after 10 ms
with
`nanosleep`, 201 ms with `sleep_for`) and now says so.

## Verification

Local:

- Both CMake configs build warning-free, **59/59 ctest** (58 before; the
new
  cell is `mt7612u_usb_ids`).
- The stripped `mt7612u+jaguar1` config builds, 49/49.
- `make -C src/mt7612u check` passes (four binaries now, `log_sink`
added);
  `api_link` resolves 27 C entry points against the C++ objects.
- Sanitized (`address+undefined`, RelWithDebInfo) with the option ON:
59/59.

On hardware, the gate end to end — the same adapter, the same command,
across
the parent commit and this branch:

```
parent 31cb738:  Creating RtlJaguarDevice (PID 0x7612, chip-id 0x00)
this branch:     MediaTek MT7612U (0e8d:7612) detected; devourer has no MediaTek
                 radio backend yet — refusing rather than misdetecting it as Realtek
                 No driver for this chip in this build — exiting   (exit 1)
```

The parent really does construct an `RtlJaguarDevice` for a MediaTek
adapter, so
the bug is demonstrated rather than inferred. Control, unchanged on
both:
`Creating RtlJaguarDevice (PID 0x8812, chip-id 0x04)` for the RTL8812AU.

And the library itself, on an MT7612U at `0e8d:7612`:

- `bringup regs` — `MT_ASIC_VERSION 0x76120044`, CFG- and MAC-space
write /
read-back / restore, EEPROM MAC. **GATE A: PASS**, and again after the
RX run,
  so nothing wedged.
- `bringup arx 1 10` — ROM patch + ILM/DLM load, ring up, **7918 frames
in 10 s
(790/s), rx_err=0 rx_invalid=0 rx_dropped=0**. The callback count and
the
  driver's ring count agree exactly, which is what exercises the new
  `std::atomic` and mutex paths against each other.
- The same run under ASan+UBSan with `detect_leaks=1`: 2623 frames
(sanitizers
cost most of the rate), **no ASan report, no UBSan report, no leak** —
the
  part that actually tests the ring's `new`/`delete` teardown.

CI is what verifies the Windows claim, since none of it builds locally.
`DEVOURER_MT7612U=ON` is now set in the multi-platform matrix (gcc,
clang, MSVC
`cl`, macOS), the mingw job, and `build-sanitizers`, plus one stripped
`build-configs` cell so the subtree cannot quietly acquire a dependency
on a
Realtek chip's sources.

## Blast radius

For anyone who never sets the option, the compiled delta is confined to
`WiFiDriver.cpp` and is exactly the two refusals above, plus an unused
`ChipGeneration` enumerator. No Realtek dispatch path changes: a chip
that
answers `SYS_CFG2` reaches precisely the branch it reached before.

## Still open, deliberately

- No `IRadio` backend, no transport adoption, no firmware embedding —
next
  block.
- `MT7612U_DEV` is still a `getenv` in `open_selected()`. #412 deferred
it
because there is no public way to pass a selector (`mt7612u_open()`
allocates
the device and the struct is opaque). I corrected two comments that
claimed
  the library reads no environment, since it does.
- MT7612U deliberately does **not** join the "No chip support selected"
`FATAL_ERROR` list: with no backend, an MT7612U-only build would produce
a
  library that can open nothing. It joins when the backend lands.

---

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01Tba83kymS5W2v1vn2yRxrj

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants