watchthelight labs

MtkWlanNative: the native Wi-Fi front end

MtkWlanNative.kext is an IO80211Controller subclass (plus an IO80211InfraProtocol interface) over the MtkWlan core, so macOS treats the MT7925 as Wi-Fi: the System Settings pane, the menu bar, scan and join from the UI. The core keeps the radio, the firmware, the join machines and WPA2; the front end translates between Apple's private Wi-Fi family and the core's C ops table (../MtkWlan/MtkCoreApi.h, version 3). Plan: ~/.claude/plans/memoized-spinning-rossum.md. Design reference: ga403wm-hackintosh kexts/itlmt7925Native (BSD-3-Clause-Clear), which carried traffic on Sequoia 24H32.

Status 2026-10-17: stage 0 done (tooling, headers, a kext that builds and passes the slot and link checks), nothing loaded.

Files

File What
abi/<build>/*.h, iface_stubs.inc, abi.json generated by tools/kcabi/genhdr.py from abi/native-wifi/<build>/: slot-exact class declarations, the 200 request-handler stubs, the sizes for the runtime guard. Never edited by hand.
MtkNativeAbi.hpp the runtime guard: kernel version string must contain an allow-listed xnu tag, every guarded class size must match OSMetaClass::getClassSize(); else probe() declines and the core starts its Ethernet front end
MtkWlanNative.{hpp,cpp} the controller: work queue before the base start, CoreCapture logger and fault reporter, media dictionary, the request allow-list (isCommandProhibited), the static answers (versions, capabilities, power, country), interface creation
MtkWlanNativeIf.{hpp,cpp} the interface: role 1, one pool + four Tx submission + Tx/Rx completion queues registered with registerInfraEthernetInterface, the data path bridge, link state to the Family
MtkWlanNativeReq.cpp request handlers with Apple's payload layouts (channel, rate, RSSI, op mode, supported channels, ...) and the generated stubs for everything else
MtkNativeTrace.{hpp,cpp} MtkNativeTrace / MtkNativeTraceTail / MtkNativeLast registry properties (IOLog does not reach the unified log here) and MtkNativeCalls (which requests and slots userland reaches)
corecapture_min.h, skywalk_min.h the exported factories of the opaque CoreCapture and IOSkywalk classes the kext calls
build-mac.sh regenerate headers, compile, link, allow-list from abi.json, then the checks: kext bundle, no C++ runtime, genhdr.py check-native (both classes against Apple's concrete classes), linkcheck.py against the running kernel collection

Switches

Where Key Meaning
Info.plist personality MtkNativeEnabled false: probe() declines (boot-arg `mtknative=0
Info.plist personality MtkNativeInterface true: the controller also creates the interface and takes the data path (stage B+); false: controller only (stage A). Boot-arg `mtknativeif=0
Info.plist personality MtkNativeAbi.Builds the allow-list: Build, Xnu tag, Sizes per guarded class; one entry per verified build, written by build-mac.sh / refresh.py
core Info.plist MtkFrontEnd ethernet (default) or native: with native the core waits for this kext and falls back to MtkWlanEth after a decline or 15 s. Boot-arg mtkfe= overrides

The two must agree for stage B and later: MtkFrontEnd native and MtkNativeInterface true. Stage A is MtkFrontEnd ethernet and MtkNativeInterface false (Wi-Fi unchanged through en1, the controller loads beside it).

Threading

  • getWorkLoop() answers the core's loop until IO80211Controller::start has returned, then the IO80211WorkQueue created before it (IONetworkController::start otherwise calls ml_thread_policy on the queue's NULL thread; the Family's Glue later checks that the BSD client's gate sits on the queue).
  • The core calls back with its gate held; the callbacks only copy (RX into a 512-frame ring) or flag (link, tx space, scan done, join result). A 2 ms timer on the work queue drains the ring into pool packets, completes sent packets, and every 10 ms applies the flags (pollControl). Calls into the core (tx, set_enabled, set_mode, later scan / join) go through coreLoop->runAction. So the work queue gate may wait for the core gate, never the reverse.

Checks after a boot

ioreg -r -c MtkWlanNative -l -w0 | grep -E 'MtkNative|IO80211' (trace, calls, build, rejected count), ioreg -r -c MtkWlanNativeIf -l -w0 | grep MtkNative (rx/tx counters, datapath, radio link, trace of the interface), ioreg -r -c MtkWlan -l -w0 | grep -E 'MtkFrontEnd|MtkNative' (what the core saw: wanted, active, fallback, declined), kmutil showloaded | grep -i mtk, networksetup -listallhardwareports, system_profiler SPAirPortDataType.

After a macOS update

~/.venvs/legion/bin/python -I tools/kcabi/refresh.py on the updated system: see docs/native-wifi.md.

0.2.0 (2026-10-18): the private address reaches the firmware

Slot 470 setMacAddress(mloAddrArray&) takes the first six bytes of the array and hands them to the core through mtk_core_ops_v4::set_mac (gatedSetMac on the core loop); the Family applies the address to the BSD interface only on success. A v3 core (MtkWlan 0.10.0) still binds (callbacks version 3) and the call answers not-ready. Trace lines if:setMacAddress xx:xx:xx:xx:xx:xx -> rc; MtkMacChanges / MtkMacChangeRefused on the core. See docs/native-wifi.md.

0.3.0 (2026-10-18): stage C, scan requests and scan-result events

setWCL_SCAN_REQ (slot 603) starts the core's scan (gatedScan, cache answered when the core refuses) and records the requested SSID filter; serviceScan (10 ms poll) delivers on the core's scan_done, at once for a refused scan, or after 25 s: gatedSnapBss copies the cached table under the core gate, buildBeacon writes the 25G241 message (header 0x44, flags at +0x40) and postEvent posts 0xc9 per BSS and 0xed. setWCL_SCAN_ABORT (598) posts 0xed. Property MtkNativeScan, traces scan:request, scan:delivered; MtkNativeCalls P:c9 / P:ed count the posts.

0.3.1 (N-140): the ioctl allowlist in front of the WCL handlers

N-139 showed stage C never ran: MtkNativeCalls had I:1b9=32 and no S:25b. On 25G241 every apple80211setWCL_* / getWCL_* dispatcher first calls isCommandProhibited(<ioctl code>) on the interface (vtable word 0xcc8) and only on 0 casts to IO80211InfraProtocol and calls the handler slot. commandAllowed() in MtkWlanNative.cpp listed the stage A/B codes only, so the scan request got ENOTSUP 32 times. 0.3.1 admits 0x1b9 (WCL_SCAN_REQ) and 0x1b3 (WCL_SCAN_ABORT). Each later handler needs its code admitted too (table in docs/native-wifi.md, "WCL ioctl codes").

0.4.0 (N-141): stage D, the Family drives the association

setWCL_ASSOCIATE (slot 604) turns the apple80211AssocCandidates into a core join (SSID length +0x1c, SSID +0x20; an apple80211_key at +0x40: key length +0x44, cipher +0x48, key +0x50, all confirmed in AppleBCMWLANCore::setWCL_ASSOCIATE and AppleBCMWLANJoinAdapter::performJoin on 25G241; key length 0 = open, cipher 6..9 with 32 bytes = PMK). The result goes back as 0xd8 link status (16 bytes, only this takes WCLNetManager out of LINK_DOWN), 0x4e, 0xd3 (0x1c), 0xd4 (0x14), 0xd5 (0xa4); the join manager checks exactly those sizes. getWCL_BSS_INFO (526) answers the associated BSS from the core's cache as a beacon message, getWCL_EXTENDED_BSS_INFO (533) the 0x214-byte 25G241 layout (rates +0, HT/VHT/HE MCS +0xbc/ +0xcc/+0xd4, MLO context +0xdc, WPA/RSN element +0x113). setWCL_LEAVE_NETWORK (592) and setWCL_JOIN_ABORT (601) call the core's disconnect; getWCL_LOW_LATENCY_INFO (525) answers 0 (a nonzero answer aborts every join); 468, 606, 602, 607, 605, 600, 594, 628, 635 and 527 are accepted and counted. A link the core drops is reported with 0xd8 down (reason 1); a link the core restores by itself is announced again. bindCore now sets the front-end mode without MTK_FE_KEEP_AUTOOPEN: macOS drives every join (the guard asks for it with networksetup -setairportnetwork at stages D/E), mtkwifi join answers busy. Property MtkNativeJoin; traces assoc:*, wcl:*. The allowlist admits the WCL codes 0x1ab 0x1ba 0x1b6 0x1a9 0x1b7 0x1b1 0x1cc 0x1b2 0x1bc 0x1c6 0x1be 0x1f6 0x1bb 0x1b5 0x1ac 0x1eb. Sequence and event layouts follow ga403wm itlmt7925Native (BSD-3-Clause-Clear), re-read against the 25G241 handlers.

0.4.1 (N-142): the two requests the join path sends before ASSOCIATE

N-141 showed the sequence the 25G241 Family runs for a join request from airportd: a directed scan, "Will associate" with a per-network private MAC, GET 0x1ab WCL_LOW_LATENCY_INFO, SET 0x170 SET_MAC_ADDRESS (the private address; IO80211SkywalkInterface::setSET_MAC_ADDRESS, Family code that ends in our slot 470 setMacAddress), then SET 0x1ba ASSOCIATE. 0x170 was refused by the allowlist, so ASSOCIATE failed with 58 and airportd retried forever (I-064). 0.4.1 admits 0x170 and 0x22d (DBRG_ENTROPY, slot 662, new on 25G241; the bytes are accepted and dropped). setMacAddress answers NotPermitted when the firmware refuses an address: WCLJoinManager tolerates success and NotPermitted only. Stage C is proven end to end by the same log: airportd parsed the 48 scan results (security, channel, phy, rssi).

0.4.2: stage D passed on N-142; one string

N-142 ran the whole join from macOS: 0x170 (slot 470, the private address applied to the firmware), ASSOCIATE, the five events, then the Family's link-up requests, all answered; macOS saved the network and auto-joined it on the next boots. What kept apps offline was the captive portal (Boingo, WISPr): the private address is a new client to it, so configd ranked en1 Never until the Captive Network Assistant is passed (I-065), a user matter as on a real Mac. 0.4.2 only single-sources the version string (MTK_NATIVE_VERSION, used by the MtkNativeVersion property and getDRIVER_VERSION, which the Family shows as "Firmware Version"; 0.4.1 still said 0.1.0 there).

0.4.3 (N-144): CoreCapture pipes must be started

N-143 panicked in CCDataPipe::enqueueBlob when the Family completed a fault report (I-066): the data pipe's notify timer is created in CCDataPipe::start, and the factories (CCPipe::withOwnerNameCapacity, CCStream::withPipeAndName) only alloc and init. On 25G241 nothing else starts a pipe that no catalogue personality matches (ours was "!registered, !matched" in the registry). 0.4.3 calls IOService::start(owner) on the fault data pipe, its stream and the log pipe right after creating them (the pipe then creates its timer on its work loop, publishes the CCPipe resource and registers itself), traced as ctl:fault pipe start=1. The pointer cast is safe: both classes have IOService as their only base.

0.4.4 and 0.4.5 (N-145, N-146): the pipe order and the candidate BSSID

N-144: starting the CoreCapture objects before the interface bound the core took the native start past the core's 15 s front-end fallback (FE_FALLBACK_TICKS), so the Ethernet front end won. 0.4.4 starts them after mk:startWithInfo ok. N-145 passed stage E (native interface, SSID, real default route after the captive portal), but the Family left and re-joined every 15 to 60 s (I-067): it asked for channels 52, 11, 108 in turn and the core always joined its own pick because setWCL_ASSOCIATE zeroed the BSSID. 0.4.5 reads the candidate list (WCLJoinRequest::addAssocCandidates: u32 count at +0x214, entries of 18 bytes from +0x21c, BSSID at +6, u16 rssi at +12) and passes the first candidate's BSSID to the core. Published as watchthelight-labs/MtkWlanNative, release 0.4.5 (pre-release until N-146).

0.4.6 and 0.4.7 (N-147, N-148): the candidate BSSID offset, and beacons the Family wants to hear about

N-146 showed the candidate's BSSID at +0x21c (0.4.5 read +0x222); 0.4.6 joins the right AP (N-147). The Family still left about 60 s after every join: corecapture logged the fault "missed beacons timeout", raised by WCLNetManager::handleMissedBeacons (reportFault, then leaveNetworkCommand; airportd records BeaconLoss). Its input is event 0xaf, WCLNetManager::updateBeaconCounter: it adds the u32 at payload +0x54 to a counter and sets a flag that the periodic check clears; nothing arrives, the fault fires. 0.4.7 posts 0xaf (0x58 bytes, count at +0x54) once a second while the link is up, as long as the core's cache entry for the associated BSS was refreshed in the last 3 s (the core updates it on every beacon or probe response). MtkNativeJoin shows beaconreports.

0.4.8 and 0.4.9 (N-149, N-150): what the Family's assoc timer wants

0.4.8 posted the beacon counter (0xaf) while the core holds the link; N-149 still lost the link with BeaconLoss 60 s after each join. WCLNetManager::assocTimerAction (every 5 s, setTimeoutMS(5000)) forces a BSS info refresh every 60 s, runs checkTrafficCounters (0x1b2) and calls handleMissedBeacons when the net manager's timestamps +0x150 and +0x158 are both older than 60001 ms; the first call reports the CoreCapture fault "missed beacons timeout", the next one leaves. Both timestamps are refreshed only by handleLqmUpdate, event 0x27 with exactly 0x1dc bytes (see the comment in MtkWlanNativeReq.cpp for the fields). The beacon counter 0xaf feeds a different counter. 0.4.9 posts 0x27 once a second next to 0xaf: valid flag, RSSI, a growing counter pair at +0x14/+0x1c, frames received at +0x24, beacons at +0x28.

0.4.9 proven (N-150): stage E done

Ten quiet minutes on N-150: one join, zero leaves, zero link drops, one 0x27 and one 0xaf per second, lease, unscoped default route, internet. Released as 0.4.9 (latest) on mt7925-hackintosh-wifi-native; OCEFI promoted to the USB stick. Left for later: MtkNativeJoin is refreshed only on a join (beaconreports shows 0 between joins), MtkNativeInterface on the controller stays No, Country Code XZ, WPA2-PSK through the menu untested on air, no roaming (the Family asks for one AP and gets it), sleep, AWDL.