[ DOTSTAR_SYSTEMS ]

blog article

From EWDK Build to Bidirectional Ping: Debugging Windows' New WiFiCx Sample Driver

In 2024 I filed a GitHub issue asking Microsoft for a WiFiCx reference driver. It closed this year, pointing at a brand-new sample that didn't actually pass traffic. Here's the pktmon trace that found the missing access point, and the PR that fixed it.

From EWDK Build to Bidirectional Ping: Debugging Windows' New WiFiCx Sample Driver

Background

Back in mid-2024 I was scoping a proposal for a client who needed a Windows 11 Wi-Fi driver for real hardware. Windows moved the Wi-Fi driver model to WiFiCx starting with Windows 11 — the older WDI miniport model is deprecated — but when I went looking at microsoft/Windows-driver-samples for a reference to build from, the network/wlan folder only had the old WDI sample. No WiFiCx equivalent.

So on July 15, 2024 I filed issue #1197 asking Microsoft to add one. It sat quietly for over a year, got swept up in a stale-issue cleanup and closed, and I reopened it. On June 22, 2026 — almost two years later — it was finally closed for real: a Microsoft engineer pointed at PR #1394, which had just landed a WiFiCx sample: wificx, netvadapter, and the Ethernet-flavored netvmini, wired together with demo videos to prove it worked.

Good news for future clients — a real WiFiCx reference finally exists. So I did what anyone evaluating a new sample driver should do before recommending it: I checked it out, built it, and tried to make two instances of it talk to each other. That’s when the fun started.

This post is the field report — the architecture, the simulation model, the debugging path (DbgPrintExpktmon), and the one conceptual gap that took the longest to see: nobody was playing the access point. The result is PR #1399, open against the sample as of this writing.

WiFiCx, briefly

WiFiCx is Microsoft’s WDF-based class extension for Wi-Fi drivers. Like other Cx frameworks, it exists to take a large, hard-to-get-right slice of driver logic out of every IHV’s hands and put it in one framework Microsoft owns and tests. Concretely, it splits a Wi-Fi driver into two lanes:

  • WiFiCx owns the control path — scan, connect, disconnect, radio state, authentication — expressed to the IHV driver as WDI (Wireless Driver Interface) task and property messages.
  • NetAdapterCx owns the data path — tx/rx packet queues and rings — the same class extension used by ordinary Ethernet NetAdapterCx drivers.

The IHV driver’s job shrinks to implementing a set of callbacks (WifiIhv* for control, EVT_NET* for data) and talking to the actual radio. Everything above that — NDIS, the Native WiFi filter (nwifi.sys), the WLAN service — is unmodified OS code.

WiFiCx architecture: netsh/WLAN service down through NDIS and nwifi.sys, splitting into the WiFiCx control-path class extension and the NetAdapterCx data-path class extension, both landing in the IHV miniport driver, which talks to hardware — or, in simulation, to the Emulated Network Link (ENL).

One detail from that diagram matters for everything that follows: WDI task commands need two completions. Where a WDI property request completes with a single M3 indication, a WDI task — like WDI_TASK_CONNECT — needs both an M3 (immediate ack) and an M4 (final completion) before the framework considers it done. Miss the M4 and the framework just… waits.

How the simulation works — and why it isn’t mac80211_hwsim

There’s no radio hardware in this story at all. The Microsoft sample proves out the whole stack in software using something called the ENL — Emulated Network Link — a small packet-forwarding engine living in a shared static library (netvadapterlibrary) that both wificx and its Ethernet sibling netvmini link against. ENL’s entire job is to take a frame out of one virtual adapter’s tx ring and drop it into a peer adapter’s rx ring. It’s the “wire” between two software-only NICs on the same machine.

If you’ve done this on Linux, the reflex comparison is mac80211_hwsim — but the comparison is worth making precisely, because the two exist to test different things. mac80211_hwsim fakes exactly one layer: the radio. It’s a kernel module that presents virtual radios which broadcast to each other like real RF, and it does no spoofing, no header rewriting, no protocol logic of its own. Everything above that line — mac80211, cfg80211, hostapd, wpa_supplicant — is the real, unmodified Linux Wi-Fi stack, run exactly as it would against real hardware. So when a station sends a frame to the AP (a “to-DS” frame, addressed to the AP’s BSSID) and the AP relays it onward as a “from-DS” frame with the receiver address rewritten to the real destination, that isn’t hwsim doing anything clever — it’s hostapd’s real, production AP-mode code performing intra-BSS relay exactly as it does on a physical access point. hwsim’s entire job is to let you validate the actual software stack — including a real AP — with nothing faked above the antenna. (If you want to go further and simulate real network conditions — topology, per-link path loss, mobility — hwsim pairs with wmediumd, a userspace daemon that controls attenuation between each pair of virtual radios instead of the default “every radio hears every other radio perfectly.” Still zero spoofing anywhere in that picture.)

WiFiCx’s sample driver is answering a different question, and doesn’t need any of that. It’s a reference for bringing up a real hardware client driver — there is no sample access point, because on real hardware the AP is a physical device sitting next to you, not something a driver reference is expected to simulate. ENL exists purely to let two instances of that client driver exchange frames locally, so the data path can be exercised with zero hardware. The moment the goal became “make two client instances ping each other with no real AP anywhere,” something had to perform the AP’s one relay hop — and standing up a genuine AP-mode instance of the sample was out of scope, so ENL had to fake that one hop directly.

Comparing who plays the access point: on Linux, hwsim only fakes the radio, so hostapd's real AP-mode code performs a genuine intra-BSS relay — no spoofing anywhere. On Windows, WiFiCx's sample ships no AP at all, so ENL has to stand in for that one relay hop directly — a deliberate, narrow fake for a test harness that was never meant to simulate a network.

That’s not a shortcoming of WiFiCx relative to hwsim — it’s two harnesses built for different jobs. hwsim exists to validate a complete software stack, AP included, with only the transceiver faked. The WiFiCx sample exists to bring up one hardware client driver, and was never designed to include an AP at all. Reconciling that gap — teaching ENL to stand in for the one piece of infrastructure the sample doesn’t ship — is the thing this whole investigation turned on. Everything below is how it was found.

Test setup

Everything here ran on one Azure VM — no lab, no physical radios:

  • Standard D2s v3 (2 vCPUs, 8 GiB memory)

  • Windows Server 2025 Datacenter, Gen 2 (2025-datacenter-g2)

  • Built and deployed with the EWDK — no Visual Studio install on the box

  • Test signing enabled, since these are locally-signed driver catalogs, not WHQL-signed ones:

    bcdedit /set testsigning on
    # reboot required

That’s the entire hardware budget for two “Wi-Fi” NICs, a fake access point, and a bidirectional ping between them.

Getting connect to actually connect

Before any data could move, three control-path bugs had to go first (all in one commit, “drive the connect-to-data-path flow on the sample station”):

  1. The adapter started life already “Connected.” NetvAdapter::Initialize set MediaConnectStateConnected up front, so WiFiCx treated every netsh wlan connect as a roam, not a fresh connect, and quietly skipped the association / data-path activation flow entirely. Fix: report MediaConnectStateDisconnected after AdapterStart, in EvtWifiDeviceCreateAdapter.
  2. The radio defaulted to Software Off, and Windows Server 2025 never issues WDI_TASK_CONNECT to a radio that reports itself off. Fix: WifiIhvSetRadioState unconditionally reports Software=On for this radio-less loopback adapter.
  3. Missing M4s. As above — the default handler for unhandled WDI_TASK_* commands sent only an M3. Task commands need an M4 too, or the request just hangs. Fixed by mapping unhandled tasks to their M4 indication.

With those three in, both adapters connected to a fake open SSID (WFC_OPEN), got IPs 192.168.100.1 / .2 — and ping was 100% loss. That’s where the real debugging starts.

The ping mystery, narrowed one layer at a time

The method here was simple and it’s the part worth remembering: at every step, ask “did the frame get copied, or did it get received?” Those are different questions, and conflating them is exactly how this bug hides.

Step 1 — ENL forwards fine, in both directions. I instrumented EnlpIterationRoutine with DbgPrintEx and watched it in DebugView during a forced-unicast ping (ping 192.168.100.99 -S 192.168.100.1, with a static ARP entry pointing .99 at the peer’s MAC, so the frame is unambiguously unicast on the wire):

ENL fwd tx-port=0 rx-port=1 rxQCount=1 rxState=1 hasFrag=1 hasPkt=1 drop=0

drop=0, four times, one per ping. The frame is being copied into the peer’s rx ring. But Get-NetAdapterStatistics on the peer showed ReceivedUnicastPackets = 0. Contradiction — so the bug isn’t in ENL.

Step 2 — the rx ring drains too. Instrumenting NetvRxQueue::Advance showed the same story from the other side:

RX adv port=1 fBegin=82 fEnd=81 haveFrag=1 havePkt=1 firstScr=1 drained=1

drained=1, and the ring’s begin pointer advances every time. The NetAdapterCx data path is handing the filled fragment up and out of the ring correctly. So ENL is right, and the ring is right — and the NIC’s own receive counter still reads zero. At this point Get-NetAdapterStatistics had already burned two rounds of instrumentation on a wild goose chase, which is exactly why the next tool switch mattered.

Step 3 — pktmon finds it. ReceivedUnicastPackets turned out to be an unreliable signal for this path. Switching to pktmon — which traces packets at the boundary of every network component, with no rebuild required — was the turning point:

pktmon filter add ping99 -i 192.168.100.99
pktmon start --capture --comp all --pkt-size 128 -f C:\pktmon.etl
ping 192.168.100.99 -S 192.168.100.1 -n 4
pktmon stop
pktmon format C:\pktmon.etl -o C:\pktmon.txt

The per-component receive-side counters told the whole story in one table:

ComponentEdgeRx pktsMeaning
netadaptercx.sys miniportUpper4WiFiCx indicates all 4 frames up ✓
nwifi.sys (Native WiFi filter)Lower4nwifi receives all 4 ✓
nwifi.sysUpper0nwifi drops all 4

nwifi.sys — the OS’s own Native WiFi filter driver — was the one eating the frames, and the decoded capture showed exactly why:

BSSID:00-20-30-40-50-60  SA:22-22-22-22-00-01  DA:00-20-30-40-50-60  … ICMP echo request

The forwarded 802.11 frame was to-DS (station-to-AP): Address 1 (the receiver address) was the fake AP’s BSSID, not the actual destination station. nwifi.sys correctly refuses a frame that isn’t addressed to it.

That’s the “aha”: both stations associate to the same hard-coded fake BSSID, and ENL is just a medium — but there is no AP. ENL had copied the to-DS frame verbatim, so the receiving station saw a frame addressed to a BSSID it isn’t, going in the wrong DS direction. Nothing in the stack was performing the AP’s relay role, because in the real hwsim world that role is filled by an actual hostapd process, and here it simply wasn’t filled by anything.

The fix: teach ENL to be the AP for one hop

In enl.cpp, EnlpRelayWiFiFrame now rewrites a to-DS 802.11 data frame in place into the from-DS frame the peer will accept — exactly what an AP does when it bridges a frame between two associated stations:

Addr1 (RA) <- old Addr3   (final destination = peer MAC)
Addr2 (TA) <- old Addr1   (BSSID)
Addr3 (SA) <- old Addr2   (original sender)
FrameControl: clear ToDS, set FromDS

Address offsets are identical for data and QoS-data frames, and a guard (type == data && ToDS==1 && FromDS==0) makes the rewrite a no-op for the 802.3 traffic that netvmini pushes through the same shared library.

Ping worked. The lesson generalizes past this one driver, and it isn’t “ENL had a bug” — it’s that a hardware bring-up harness and a full-stack test harness make different trade-offs about which infrastructure they actually include. hwsim can lean on a real hostapd for the AP because full-stack validation is its entire purpose. A driver bring-up harness like this one never shipped an AP in the first place, so the one relay hop it needed had to be built in on purpose, deliberately, as a narrow stand-in — not discovered missing by accident.

Same-host networking gotchas

Two adapters, one subnet, one physical host — that trips a few OS shortcuts that have nothing to do with WiFiCx specifically:

  • Duplicate-address ARP suppression. A stack won’t answer an ARP whose sender IP is one of its own local addresses, which matters when both “ends” of a link live on the same box. Worked around by seeding static neighbor entries (netsh interface ipv4 add neighbors …) in run.ps1.
  • Martian-source / loopback shortcuts for same-subnet local IPs are a related risk. The Linux fix for this class of problem is a network namespace; the Windows equivalent is a network compartment. It wasn’t needed once the AP-relay fix landed, but it’s the right tool if a reply path ever gets short-circuited by the loopback path instead of going through the emulated NICs.

The deploy determinism saga: wrong registry key

Getting two instances to reliably come up as …00:01 and …00:02 — no manual Device Manager clicking, no reboot — turned into its own multi-round investigation.

Symptoms: the second adapter routinely landed in Error, problem code 10 (CM_PROB_FAILED_START) with no MAC assigned; it “worked” only on a freshly rebooted machine; and the framework spawns extra WiFiDirect companion devnodes sharing the same hardware ID and friendly name as the real adapters, so you can’t tell them apart by either of those.

Root cause: the deploy script wrote the pairing value, MACLastByte, to the device’s hardware key (HKLM\SYSTEM\CurrentControlSet\Enum\<id>\Device Parameters\MACLastByte). But the driver reads configuration via NetDeviceOpenConfigurationNetConfigurationQueryUlong, which resolves to the per-adapter software (Class) key (…\Control\Class\{4d36e972-…}\<NNNN>\MACLastByte, found via DEVPKEY_Device_Driver). The hardware-key write was a complete no-op — the driver never saw it. Every instance kept the INF default of 1, both wanted address …00:01, collided, and the second one failed to start. Setting the value by hand in Device Manager’s Advanced tab “worked” purely because that UI happens to write the software key, not the hardware key.

The diagnostic that nailed it:

$drv   = (Get-PnpDeviceProperty -InstanceId $id -KeyName DEVPKEY_Device_Driver).Data
$swKey = "HKLM:\SYSTEM\CurrentControlSet\Control\Class\$drv"
(Get-ItemProperty $swKey -Name MACLastByte).MACLastByte    # showed 1 on BOTH, always

The fix, in a rewritten deploy.ps1:

  • Resolve each devnode’s software key and read/write MACLastByte there — not the hardware key.
  • Clean-slate before install (and on uninstall): remove every Root\wificxsampleclientkm devnode — mains, companions, phantoms — so a fresh install can’t collide with a stale node still defaulting to 1.
  • After installing both instances, promote a second main to MACLastByte=2 in its software key and restart it, iterating candidate NICs (any whose software key exposes MACLastByte) and reverting any that turn out to be companions, until …00:02 actually shows up.
  • Verify the real success signal — both …00:01 and …00:02 present — rather than assuming success from exit codes.

While in there, a latent driver bug in configuration.cpp also got fixed: it stored the value it read before validating it, and never wrote the fallback back on failure, and the MACLastByte table default was 0 — which AdapterCreateAddress rejects as < 1. Now it validates, then stores once, with a default/minimum of 1.

Lesson: NetAdapterCx configuration lives in the software/Class key, not the Enum hardware key. It’s an easy, invisible mistake to make, and Device Manager’s UI will paper over it for you right up until you automate it.

Tools that carried the investigation

  • DebugView, for kernel DbgPrintEx output (DPFLTR_IHVDRIVER_ID / DPFLTR_ERROR_LEVEL) — good for “did my code run, and with what values,” bad for “did the OS actually accept this frame.”
  • pktmon (--comp all, then pktmon format to decode) — the decisive tool. Per-component flow counters pinpointed exactly which driver in the stack dropped the frame, with zero rebuilds.
  • Get-PnpDevice / Get-PnpDeviceProperty (DEVPKEY_Device_Driver, DEVPKEY_Device_HardwareIds, DEVPKEY_Device_ProblemCode) for the devnode detective work in the deploy saga.
  • Get-NetAdapterStatistics — useful, but not authoritative on this path. Trust pktmon over it.
  • Claude Code, as a second pair of eyes across the whole investigation — reading DbgPrintEx/pktmon output, narrowing down hypotheses, and drafting the PowerShell scripts below. The pktmon trace and the AP-relay insight were the actual find; the tool sped up getting there.

Automating it: deploy.ps1 and run.ps1

None of the above is worth much if it only works once by hand. The two scripts that came out of this are the actual deliverable for anyone trying this sample themselves:

  • deploy.ps1 — builds with the EWDK, signs, installs two instances of the driver, and pairs them deterministically by writing MACLastByte to each adapter’s software key after a clean-slate devnode removal. No manual Device Manager step, no reboot, and it’s idempotent enough to run in a loop while iterating.
  • run.ps1 — connects both adapters to the fake SSID, assigns static IPs, seeds the static neighbor entries needed to route around duplicate-address ARP suppression, and pings in both directions, printing PASS: Bidirectional ping succeeded on success.

Turning a debugging spike into something reviewable took its own pass, separate from the actual fixes:

  • Gated the debug tracing. The ~16 DbgPrintEx probes were essential during the hunt but have no business shipping active. They’re now behind off-by-default macros (NETV_DATAPATH_DEBUG in the shared library, a WFC_TRACE macro guarded by WIFICX_DATAPATH_DEBUG in the wificx driver), isolated in a single droppable commit.
  • Rebuilt 33 debugging-session commits into 7 logical ones — build fix, config fix, control-path fixes, AP-relay rx fix, deploy/test scripts, README, gated debug — via git reset --soft plus staged file groups, double-checked by diffing the reconstructed tree against the tested tip to confirm they were byte-identical.
  • A .gitattributes UTF-16 rabbit hole, purely in local tooling: rebasing kept failing with failed to encode … from UTF-8 to UTF-16LE-BOM, because the upstream netvadapter*.inf blobs are stored as UTF-16 while .gitattributes declares working-tree-encoding=UTF-16LE-BOM — an attribute that requires the blob to be UTF-8 so git can render it as UTF-16 in the working tree. Upstream’s blob violates its own attribute, which mangles the file on every local checkout (though not on GitHub’s server-side merge). Two chained rebases through that broken blob even scrambled the branch once, recovered from a backup tag. Since the PR touches zero .inf files, the fix was to leave it out of scope entirely and let GitHub’s merge machinery deal with it.

Where it landed

PR #1399 is open against microsoft/Windows-driver-samples as of this post. On a Windows Server 2025 VM built with the EWDK — no Visual Studio, no physical Wi-Fi hardware anywhere — deploy.ps1 followed by run.ps1 now ends in:

PASS: Bidirectional ping succeeded

Two virtual stations, one shared fake access point, and one relay hop that nothing was performing until ENL was taught to do it.

Extending this further

Most of the individual issues here are ordinary bugs — a missing M4 completion, a config value read from the wrong registry key. The AP-relay item is different in kind: not a bug, but scope the sample never carried in the first place, made visible only because this PR pushed it into a scenario — two client instances, no real infrastructure — that it was never asked to handle before. What makes WiFiCx worth this level of attention right now is timing: the reference driver is brand new, still rough in exactly the places a real IHV driver has to be solid, and every gap found and fixed against the sample now is a gap a client’s production driver won’t hit later. That’s the actual case for extending Windows’s Wi-Fi driver capabilities — not just filing the one missing sample, but hardening the control-path/data-path split, the test-harness boundaries, and the deploy tooling around it before it’s load-bearing for someone’s hardware.

The bug classes here aren’t Windows-only, either — a missing completion message, or configuration read before it’s validated and never written back on fallback, are exactly the kind of thing that recurs in Linux driver bring-up too (cfg80211/mac80211 command completions, OTP/NVRAM config ordering). The AP-relay item specifically doesn’t transfer, though, and that distinction matters: mac80211_hwsim never has this gap, by design, because it was built to validate a complete stack — real hostapd, zero spoofing — with only the radio faked. A hardware bring-up harness like WiFiCx’s sample makes a different, equally valid trade-off: fake nothing but the one hop you actually need, and ship no infrastructure at all. Neither approach is wrong; picking the wrong one for your actual goal is where bugs like this one come from. None of this is IoT-specific, and none of it is even Wi-Fi-specific — the same discipline applies to any wireless stack (Bluetooth, cellular, Zigbee/Thread, whatever the radio is), on whichever OS the hardware program happens to target.


PS: This debugging session leaned on Claude Code throughout, and this post was drafted with it from the raw session notes. The trace, the fix, and the PR are mine; the tool helped get through both faster.

Dotstar Systems builds and debugs wireless drivers and stacks — Windows, Linux, RTOS, any OS the hardware needs — from a WiFiCx class extension like this one, to mac80211-based drivers, to firmware for constrained silicon. This isn’t an IoT-only practice, and it isn’t a Wi-Fi-only one either: if you’re bringing up a driver for real hardware on any wireless technology and want someone who has already been through the rough edges, let’s talk.