$title =

How a WiFi Adapter Lied About Its TX Power and I Took It Personally

;

$content = [

I noticed something was wrong because reality stopped matching numbers.

Every WiFi adapter I tested behaved like a normal, law-abiding citizen. Set TX power. Read TX power. The universe nodded politely.

Then there was this one.

$ iw dev wlan0 set txpower fixed 1500
$ iw dev wlan0 info | grep txpower
    txpower 3.00 dBm

Three.

Always three.

Set five? Three.

Set ten? Three.

Set twenty? Three.

Disconnect from the network? Still three.

This wasn’t configuration drift. This was what Formal Methods Researchers call “horseshit.”

The adapter in question was an Alfa AWUS036AXML. Many tens of dollars. External antennas. WiFi 6. Serious pentest pedigree. And it was insisting, with the confidence of a baby in a windstorm, that no matter what I asked for, it was transmitting at exactly 3 dBm.

This was not “close enough.”

This was vibes-based power reporting. My favourite.


Act 1: The Variable That Never Got the Memo

The mt76 driver stack handles MediaTek chips, including the MT7921AU inside this adapter. TX power reporting eventually boils down to one variable:

phy->txpower_cur

mac80211 asks the driver what power it’s using. The driver returns whatever lives in that variable. Simple. Honest. Boring.

So I followed the trail upstream to where TX power is actually calculated and sent to firmware.

And there it was.

// drivers/net/wireless/mediatek/mt76/mt76_connac_mcu.c

int mt76_connac_mcu_set_rate_txpower(struct mt76_phy *phy)
{
    // ... 100+ lines of power table calculation ...
    // ... sends everything to firmware ...
    // ... never does: phy->txpower_cur = calculated_value; ...
    return 0;
}

A function that:

  • Calculates power tables
  • Applies regulatory limits
  • Talks to firmware
  • Does real math
  • Looks extremely serious

And then…

…never updates phy->txpower_cur.

Like a chef who cooks the meal perfectly and then refuses to tell the waiter what’s on the plate.

The firmware knew the truth. Userspace did not.

So mac80211 kept asking, and the driver kept answering with the default value it was born with. Which just happened to look like “3.00 dBm” when printed.

Technically computing. Spiritually dubious.


Act 2: INT_MIN Enters the Chat

While tracing this, I ran into something that looked like a crime:

info->txpower == INT_MIN

Negative two billion. A number so aggressive it feels personal. How did it know my chequing account balance?

At first glance, this screams “bug.” In reality, it screams “I genuinely do not know.”

// net/mac80211/iface.c

bool __ieee80211_recalc_txpower(struct ieee80211_link_data *link)
{
    chanctx_conf = rcu_dereference(link->conf->chanctx_conf);
    if (!chanctx_conf) {
        return false;  // No channel context = no txpower calculation
    }
    // Only calculates txpower if chanctx exists
}

mac80211 initializes TX power to INT_MIN when the interface is unassociated. No channel context means no meaningful power calculation. So it shrugs and says, “Ask me later.”

That part is correct.

The bug was what happened next.

The mt7921 driver took that INT_MIN, nodded thoughtfully, and casually tossed it straight into the power-setting logic as if it wasn’t a smelly bag of lies.

Somewhere deep inside, those lies got rounded, clamped, firmware-interpreted, and politely surfaced as…

3 dBm.

The driver wasn’t broken. It was being groomed.


Act 3: Kernel 6.18 and the Callback Shuffle

Kernel 6.18 decided it was time for character development.

The old bss_info_changed callback was split into:

  • vif_cfg_changed — for virtual interface config
  • link_info_changed — for per-link settings like TX power

TX power now lives in the per-link world, not the monolithic one. So the fix had to grow up too.

The solution ended up being three things:

  1. Convert the driver to the new MLO callback structure
  2. Treat INT_MIN as “unknown,” not “real”
  3. Actually remember the power value we set

The logic became refreshingly honest:

static void mt7921_link_info_changed(struct ieee80211_hw *hw,
                                     struct ieee80211_vif *vif,
                                     struct ieee80211_bss_conf *info,
                                     u64 changed)
{
    if (changed & BSS_CHANGED_TXPOWER) {
        int txpower;

        if (info->txpower == INT_MIN) {
            // Unassociated: use regulatory maximum
            struct ieee80211_channel *chan = hw->conf.chandef.chan;
            txpower = chan ? chan->max_reg_power : 20;
        } else {
            // Associated: use user's requested value
            txpower = info->txpower;
        }

        mt7921_set_tx_power(phy, txpower);
    }
}

If mac80211 doesn’t know the TX power yet, use the regulatory maximum. If the user asked for a specific value, use it. Once we set it, record it.

No vibes. Just accounting. Hawt.

Patches worked on Kali 6.18.3. Ship it!


Act 4: The Fedora Incident

Everything worked.

On Kali.

Which is how hubris gets you.

I booted Fedora to validate before upstream submission and was immediately rewarded with:

BUG: kernel NULL pointer dereference, address: 0000000000000038
#PF: supervisor read access in kernel mode
#PF: error_code(0x0000) - not-present page
RIP: 0010:mt76_connac2_load_patch+0x4f/0x110 [mt76_connac_lib]
Call Trace:
 <TASK>
 mt7921_load_firmware+0xb5/0x1c0 [mt7921_common]
 mt7921u_probe+0x2a3/0x360 [mt7921u]
 usb_probe_interface+0xe3/0x2d0

Hard crash. Full freeze. Reboot required. Wine poured. Daddy needs his medicine.

The fault was in mt76_is_sdio(). A macro checking dev->bus->type.

static inline bool mt76_is_sdio(struct mt76_dev *dev)
{
    return dev->bus->type == MT76_BUS_SDIO;  // Offset 0x38
}
//           ^^^^^^^^
//           This was NULL!

Except dev->bus was NULL.

Offset 0x38. A clean, precise NULL + offset crash.

This is the part where you assume you broke something fundamental and begin bargaining with the universe. Remember kids, when the magic smoke comes out of the machine, you can’t put it back in again…


Act 5: The Crash That Wasn’t My Fault

I chased ghosts:

  • Initialization order
  • USB vs SDIO detection
  • Race conditions
  • MLO fallout (hi Dogmeat!)

None of it mattered.

Hours evaporated. Thinks were thought. The kernel was blameless.

The clue was sitting quietly in the crash dump:

Modules linked in: mt7921u(OE) mt7921_common(OE) mt792x_usb(OE)
 mt792x_lib(OE) mt76_connac_lib mt76_usb(OE) mt76(OE)
                ^^^^^^^^^^^^^^^^
                No (OE) = original kernel module, not rebuilt!

Some modules were marked (OE). One was not.

That one module — mt76_connac_lib — was the kernel’s original, not my rebuilt version.

Different struct layouts. Different offsets. Same symbol names.

I had installed a new engine and left the old transmission bolted in place.

The code wasn’t wrong. The universe was mislinked.


Act 6: The Fix for the Fix

# The right way, nerd - manual loading in dependency order
MT76_DIR="/lib/modules/$(uname -r)/kernel/drivers/net/wireless/mediatek/mt76"

sudo insmod $MT76_DIR/mt76.ko
sudo insmod $MT76_DIR/mt76-usb.ko
sudo insmod $MT76_DIR/mt76-connac-lib.ko   # THE PROBLEM MODULE
sudo insmod $MT76_DIR/mt792x-lib.ko
sudo insmod $MT76_DIR/mt792x-usb.ko
sudo insmod $MT76_DIR/mt7921/mt7921-common.ko
sudo insmod $MT76_DIR/mt7921/mt7921u.ko

Once I rebuilt and loaded all dependent modules in the correct order (imagine that!), the crash vanished.

Like it had never been there.

Because it never really was.


Act 7: Validation, Finally

With everything actually loaded correctly:

Unassociated interface:

$ sudo iw dev wlan0 set txpower fixed 500   # Request 5 dBm
$ iw dev wlan0 info | grep txpower
    txpower 15.00 dBm                        # Shows regulatory max ✓

Associated interface (5GHz, 80MHz channel):

Requested Reported Offset
5 dBm 8 dBm +3 dBm
10 dBm 13 dBm +3 dBm
15 dBm 18 dBm +3 dBm
20 dBm 23 dBm +3 dBm

A consistent +3 dBm offset.

That’s antenna gain being added by firmware. AKA: The adapter has rabbit ears. If you don’t know what that means, that’s ok. Oh look! A TikTok!

The driver reports EIRP (effective isotropic radiated power) rather than raw transmitter output. Cosmetic. Expected.

The adapter stopped lying. Reality and numbers shook hands again.


Act 8: Upstream

Rebased patches against latest mt76 upstream. Ran checkpatch.

$ ./scripts/checkpatch.pl --no-tree patches/upstream-series/*.patch
total: 0 errors, 0 warnings, 277 lines checked

Sent via git send-email to the mt76 maintainers and linux-wireless.

From: Lucid Duck <lucid_duck@justthetip.ca>
To: nbd@nbd.name, lorenzo.bianconi83@gmail.com
Cc: linux-wireless@vger.kernel.org
Subject: [PATCH 0/3] wifi: mt76: kernel 6.18 compatibility and txpower reporting fix

Result: 250

Four emails. Four “Result: 250” confirmations. I high-fived the adapter, now it has a bent ear.

 mt76.h             |  1 +
 mt7615/main.c      |  6 ++--
 mt76_connac_mcu.c  | 13 ++++++-
 mt7921/main.c      | 84 +++++++++++++++++++++++++++++++++++-----------
 ...
 12 files changed, 93 insertions(+), 37 deletions(-)

What I Learned (Again)

  1. INT_MIN is not a bug. It’s a boundary marker. When mac80211 says “I genuinely don’t know,” drivers must translate that into “safe,” not “LOL K.”

  2. Drivers must write down what they know. The firmware had the correct power value. The variable that reports it to userspace didn’t. This is a documentation failure wearing a technical mask.

  3. Module ABI mismatches can impersonate kernel bugs with unsettling accuracy. Offset 0x38 looked like a driver bug. It was a build system problem. The crash site is rarely the crime scene.

  4. (OE) markers are tiny, quiet heroes. That two-character flag in the crash dump told the whole story. I just had to investigate. It even sounds like “Oy!” when you read it.

  5. Test on multiple distros. Kali worked because its module autoloading happened to grab the right files. Fedora’s didn’t. Same kernel version family. Different outcomes.

And most importantly:

Sometimes the problem isn’t broken code. Sometimes it’s code that never wrote down what it already knew.


The patches are upstream.

The adapter works.

And somewhere out there, a variable finally knows what power it’s using.


12 files changed. 93 lines added. 37 removed. Time debugging the wrong problem on Fedora: shut up. Satisfaction of seeing “Result: 250” four times: immeasurable.


Proof I actually shipped code: View the patch series on lore.kernel.org

];

$date =

;

$category =

;

$author =

;

$next =

;