SwDevice and PnP¶
The PnP machinery behind every HIDMaestro virtual controller. This page covers the SWD migration story (why HIDMaestro can't use SetupDiCreateDeviceInfoW for everything), the slot-1-skip ContainerID fix, the session-unique instance-ID suffix that bypasses Windows' sticky reuse-fast-path, and why hmswd.exe exists as a separate native executable.
If you have skimmed the README's "Techniques" section, this is the long-form. The README compresses years of debugging into ~10 paragraphs; this page expands them with the empirical evidence and the alternative paths that didn't work.
For the SDK orchestration that calls into all of this, see Lifecycle and Teardown. For the companion device this enables, see XUSB Companion.
The two device-creation APIs¶
Windows offers two paths to create a software-only device node:
| API | Header | Constraint | Used by |
|---|---|---|---|
SetupDiCreateDeviceInfoW |
SetupAPI.h |
Cannot specify pContainerId; assigned the null-sentinel automatically. |
Plain HID profiles (DualSense, etc.) |
SwDeviceCreate |
swdevice.h (via cfgmgr32.dll) |
Can specify pContainerId. Modern device-creation API (Win10+). |
xinputhid Xbox profiles, XUSB companion |
Search Microsoft Learn for the exact symbol name to find the contract docs for either API.
SetupDiCreateDeviceInfoW is older and well-documented but doesn't expose a way to set the ContainerID. Windows assigns the null-sentinel {00000000-0000-0000-FFFF-FFFFFFFFFFFF} to ROOT-enumerated devices created via this API. ContainerID semantics are documented under DEVPKEY_Device_ContainerId on Microsoft Learn.
SwDeviceCreate lets the creator specify an explicit pContainerId. This is the linchpin of the slot-1-skip fix. And it's why HIDMaestro has to call the API at all rather than staying on the simpler SetupAPI path.
The slot-1-skip bug¶
Pre-SWD-migration, HIDMaestro created every devnode via SetupDiCreateDeviceInfoW under ROOT\. With the null-sentinel ContainerID, XInput exhibited a bug: virtual Xbox controllers were assigned to slot 1, slot 2, slot 3, but slot 0 was empty. Multiple consumers (PadForge users, the regression battery) consistently reported:
Slot 0: NOT CONNECTED
Slot 1: HM-CTL-0001 (Xbox 360 Wired)
Slot 2: NOT CONNECTED
Slot 3: NOT CONNECTED
Or in 4-controller mixed configurations, the lowest user-index assigned was 1, not 0. With 4 Xbox-family virtuals you'd see slots 1, 2, 3 (and one missing — XInput's 4-slot cap is hit by 1+2+3+4, but if the allocator skips 0, you only get 3 working).
Ghidra trace¶
The behavior comes from xinput1_4.dll's slot allocator. Decomp (Win11 26200):
// FUN_18000de2c — tests for slot-1-skip eligibility
ULONG FUN_18000de2c(DEVICE_INFO *dev) {
if (memcmp(&dev->ContainerId, &NULL_SENTINEL_GUID, 16) == 0) return 1;
if (StringContains(dev->HardwareIds, "XINPUT_EMBEDDED_DEVICE")) return 1;
return 0;
}
// FUN_18000c728 at 0x18000C8AE — sets bit 2 on the device struct
result = FUN_18000de2c(dev);
test al, al
jne L_set_bit_2
jmp L_normal
L_set_bit_2:
or dword ptr [rbx], 4 // ← bit 2 set on bit-2 path
// FUN_18000f85c — fallback slot allocator
if (FeatureManagerFlag_0x39EB83D && (dev->Flags & 4)) {
// bit-2 device + feature flag on → SKIP iter 0
iter = 1;
}
// FUN_18000f178 — promotes first bit-2 slot to "primary"
// FUN_18000f08c — query-time swap surfaces empty slot 1 to consumers
The chain: null-sentinel ContainerID → bit 2 set on the device struct → slot allocator skips iter 0 (only when Feature Manager flag 0x39EB83D is on, which it is on Win11 26200) → first bit-2 slot becomes "primary" → query-time swap surfaces an empty slot 1 to consumers.
Setting XINPUT_EMBEDDED_DEVICE in HardwareIDs would also trigger this; the null-sentinel ContainerID is one of two sufficient conditions.
The fix¶
Use SwDeviceCreate with an explicit non-sentinel pContainerId. We pass:
Decoded as ASCII: H I D M A E S T R O \0 \0 + a 16-bit big-endian index. So controller 0 gets {48494430-4D41-4553-5452-4F0000000000}, controller 1 gets {48494430-4D41-4553-5452-4F0000000001}, etc.
FUN_18000de2c returns 0 (not the null sentinel; no XINPUT_EMBEDDED_DEVICE in HardwareIDs), bit 2 stays clear, the slot allocator fills 0..3 contiguously. Verified empirically across 5 back-to-back runs.
What moved to SWD vs what stayed¶
Not every profile needs SWD. The architecture group determines:
| Group | Main HID enumerator | XUSB companion enumerator |
|---|---|---|
| Plain HID | ROOT\VID_xxxx&PID_yyyy&IG_00\NNNN (SetupAPI) |
(no companion) |
| Non-xinputhid Xbox | ROOT\VID_045E&PID_*&IG_00\NNNN (SetupAPI) |
SWD\HIDMAESTRO\<sid>_NNNN (SwDevice) |
| xinputhid Xbox | SWD\HIDMAESTRO_VID_045E_PID_*&IG_00\<sid>_NNNN (SwDevice) |
(no companion) |
Plain HID profiles (DualSense, wheels, HOTAS, etc.) don't go through XInput, so the slot-1-skip bug doesn't apply — staying on the simpler SetupAPI path is fine.
Non-xinputhid Xbox profiles do go through XInput, but only via the XUSB companion. The main HID device on the ROOT\ path doesn't carry the XInput device interface; the companion does. So only the companion needs SWD's explicit ContainerID. The main HID stays on SetupAPI.
xinputhid Xbox profiles bind xinputhid.sys as a kernel filter on the HID child, and xinputhid is what publishes the XUSB interface. There's no separate companion; the main HID device must itself be SWD-enumerated with a real ContainerID so its XInput dispatch lands in slot 0.
Why the underscore between VID and PID¶
Look closely at the xinputhid Xbox enumerator name:
The conventional Windows hardware-ID format is VID_xxxx&PID_yyyy with &. We use _PID_ instead.
Reason: any SWD enumerator name matching the substring VID_*&PID_*&IG_* triggers a Windows PnP edge case where the registry record exists but the devnode never enumerates as a live PnP object. Empirically observed on Win11 26200; not documented anywhere; presumably a heuristic in PnP that recognizes that pattern as "hardware ID-shaped" and treats it specially.
Replacing & with _ between VID and PID avoids the heuristic. The &IG_00 suffix is preserved because:
- The HID child inherits its parent's enumerator name as the first segment of its instance path.
- HIDAPI / SDL3 / Chromium all blocklist
&IG_substrings to avoid duplicating XInput-claimed devices — we want the suffix in the instance path so those skip the device.
So we get the best of both: PnP enumerates the SWD parent (no & in VID_*&PID_*), and the HID-class libraries skip the inherited &IG_00 substring on the children.
The session-unique instance-ID suffix¶
The SWD migration immediately exposed a second Windows PnP behavior on Win11 26200: after SwDeviceClose finalizes a devnode with SWDeviceLifetimeParentPresent, the kernel retains a sticky per-(enumerator + instanceId + ContainerId) record. A subsequent SwDeviceCreate with the identical tuple takes a "reuse-existing" fast path that creates an empty registry shell — no Service or Driver bound, no device-interface class registered — and reports S_OK synchronously to the caller.
The sticky state survives across processes and across same-boot uninstall + reinstall of the INF.
Symptoms before the fix¶
- First run after a fresh boot: fast (~2 s for 4 mixed), all APIs pass.
- Every subsequent run on the same boot:
SwDeviceCreatereturnsS_OKsynchronously while the devnode never materializes. CM_Locate_DevNodeWreturnedCR_NO_SUCH_DEVNODEthe entire time the SDK waited.- The creation callback timed out at 30 s with
E_FAIL. - Phase-1 creation ballooned from ~2 s to 65 s (15 s callback wait × 2 BT slots + 15 s XInput slot-claim wait × 2 Xbox 360 slots).
- XInput lost visibility for the XUSB-companion path because the empty-shell devnode never bound
HMXInput.dlland so never registered the XUSB device-interface class.
PadForge users with multiple controllers were hit on every relaunch.
The two-tier fix¶
Two iterations.
v1.x.x.0 (PID prefix): Prepend the launching process's PID in hex to every SwD instance-ID suffix, e.g. SWD\HIDMAESTRO\A7B4_0002.
private static readonly string s_sessionId =
System.Diagnostics.Process.GetCurrentProcess().Id.ToString("X").ToUpperInvariant();
Each launch gets a unique tuple; the kernel runs a fresh full install. Verified for fresh-boot-AND-subsequent-run parity. 5 back-to-back same-boot 4-controller runs all passed.
But: a same-process live-swap recreation (e.g. remove 2; 2 dualsense; remove 2; 2 dualsense) reused <PID>_<idx> and hit the same reuse-existing fast path on the second recreation in a swap cycle. The 2nd Series BT recreation in a cycle was an empty shell; the 2nd Xbox 360 wired ROOT was [Stopped].
v1.x.x.1 (per-call atomic sequence): Add a per-call atomic sequence number. Format <PID-hex><seq:X4>_<idx:D4>.
private static int s_swdCreateSeq;
private static string NextSwdSuffix(int controllerIndex)
{
int seq = System.Threading.Interlocked.Increment(ref s_swdCreateSeq);
return $"{s_sessionId}{seq:X4}_{controllerIndex:D4}";
}
Every SwDeviceCreate call within this process gets a unique (enumerator + suffix + ContainerId) tuple. The kernel never hits the reuse-existing path. FindExistingCompanion matches by ControllerIndex in Device Parameters (not by suffix), so cleanup and teardown sweep across instances regardless of which session created them.
Verified via the regression battery's S03_Single_LongCycle_8swaps and S08_Multi_SwapOneSlot scenarios — previously failed, now pass.
Why hmswd.exe exists as a separate native binary¶
.NET 10's P/Invoke to cfgmgr32!SwDeviceCreate on Win11 26200 returns 0x8007007E ERROR_MOD_NOT_FOUND synchronously, while the identical C call succeeds. We tried, in order:
- Plain P/Invoke. Failed with
0x8007007E. CoInitializeEx(COINIT_MULTITHREADED)first. No effect.- Preloaded
cfgmgr32.dllandswdevice.dllviaLoadLibrarybefore the P/Invoke. No effect. UnmanagedCallersOnlycallback function pointer marshaling. No effect; same error.- Explicit function-pointer marshaling via
Marshal.GetDelegateForFunctionPointer. No effect. UnmanagedCallersOnlywithCallConvCdecl/CallConvStdcallvariations. No effect.- Wrapping the call in
CallerMustBeAdminchecks just in case. No effect.
Nothing managed worked. Rather than ship a broken managed migration path, we wrote a small native executable (driver/hmswd/hmswd.c, 286 lines) that does the call from C and prints the result to stdout. The SDK's SwdDeviceFactory invokes hmswd.exe via Process.Start and parses the stdout.
hmswd.exe create <enumerator> <instance-id-suffix> <container-guid>
<hw-ids-csv> <compat-ids-csv> <description>
Returns instance-id on success (OK <full-instance-id>); writes errors to stderr.
The helper is included in the SDK's embedded resource payload alongside the driver DLLs. Total binary size: ~25 KB. Lifetime per call: <1 second (call, get instance-ID, exit).
The performance overhead of OOP-helper SwDeviceCreate has been verified empirically as not observable vs an in-process call — the P/Invoke marshaling path itself takes long enough that adding Process.Start doesn't move the needle. Architectural-cleanup alone doesn't justify chasing the 0x8007007E mystery.
SwDevice removal¶
SWDeviceLifetimeParentPresent (a value of the SW_DEVICE_LIFETIME enum — search Microsoft Learn for the exact name) keeps the device alive across process exit. The only documented removal path:
- Re-
SwDeviceCreatewith identical args. The docs guarantee this returns a fresh handle to the existing device (not a new device). The companion usesFindExistingCompanionlookup byControllerIndexto find the right (suffix, ContainerID) tuple to pass. - Downgrade lifetime from
SWDeviceLifetimeParentPresenttoSWDeviceLifetimeHandleviaSwDeviceSetLifetime. SwDeviceClosethe handle.- Block on
CM_NOTIFY_ACTION_DEVICEINSTANCEREMOVEDso callers know the kernel has actually propagated removal.
pnputil /remove-device /force and DIF_REMOVE both silently no-op on FAILEDINSTALL-state SWD phantoms, which is why this helper-mediated path is required.
The teardown is implemented in hmswd.exe's remove subcommand:
hmswd.exe remove <enumerator> <instance-id-suffix> <container-guid>
<hw-ids-csv> <compat-ids-csv> <description>
Same args as create; the helper does the reconnect-then-downgrade-then-close dance.
SwD-first removal ordering (v1.3.1)¶
Two of the three architecture groups (Xbox 360 Wired and Xbox Series BT) own a SwDevice-enumerated parent. SwDevice lifetimes are anchored to the HSWDEVICE handle, not the PnP devnode — children of a SwD parent cannot fully unwind their query-remove cascade until the parent's handle drops its kernel refcount.
Pre-v1.3.1 disposal:
DeviceManager.RemoveDeviceissuedDIF_REMOVEon every HID child first.- Each
DIF_REMOVEwas followed by a 2,000 msWaitForDeviceRemovalthat timed out (parent was still holding the lifetime lock). - Finally closed the SwDevice handle.
- Net cost: ~5,700 ms for Xbox 360 Wired, ~11,000 ms for Xbox Series BT, scaling worse with more children.
v1.3.1 inverts the order:
- For any
SWD\parent, close the SwDevice handle FIRST viaSwdDeviceFactory.Remove. - Block on
CM_NOTIFY_ACTION_DEVICEINSTANCEREMOVED(a value of theCM_NOTIFY_ACTIONenum on Microsoft Learn) for the parent — so callers know the kernel has actually propagated removal, not just that the handle closed. - Mop up any HID children that survived the cascade. Usually none, because the SwD parent's release fires its children's removal in one cascade.
- Net cost: ~135 ms for Xbox 360 Wired, ~500 ms for Xbox Series BT.
A second optimization in the same change: when a HIDMAESTRO sweep walks registry entries that exist only as PHANTOM (registry residue from prior sessions, no live devnode), skip the hmswd.exe SwDeviceCreate-reconnect roundtrip entirely. Saves ~50-75 ms per stale entry and prevents creep across same-process recreation cycles.
ContainerID sharing¶
For non-xinputhid Xbox profiles, the main HID device (ROOT) and the XUSB companion (SWD\HIDMAESTRO) must share the same per-controller ContainerID. Why:
xinput1_4.dllreads ContainerID to dedupe. If main HID has ContainerID A and companion has ContainerID B,xinput1_4sees two separate devices and may double-count slots.- Settings groups by ContainerID for the "Xbox 360 Wired" entry. Without shared ContainerID, two devices appear in Settings.
The main HID gets ContainerID via SetupDiSetDeviceProperty(DEVPKEY_Device_ContainerId). The XUSB companion gets the same value via SwDeviceCreate's pContainerId parameter. Both writes happen in the SDK's SetupController orchestration before the devices fully start.
For xinputhid Xbox profiles there's only one device (no companion), but the same per-controller ContainerID {48494430-4D41-4553-5452-4F00...<idx>} is still used so the bit-2 path stays closed.
How the suffix mapping works at scale¶
For 6 controllers running in one process:
SWD\HIDMAESTRO\A7B40001_0000 ← controller 0 (Xbox 360 Wired companion), seq=1
SWD\HIDMAESTRO\A7B40002_0001 ← controller 1 (Xbox Series BT main HID), seq=2
SWD\HIDMAESTRO_VID_045E_PID_0B13&IG_00\A7B40002_0001 ← (xinputhid path uses different enumerator)
SWD\HIDMAESTRO\A7B40003_0002 ← controller 2 (DualSense), if it had a companion (it doesn't)
...
PID hex is A7B4; per-call sequence increments globally per process; controller index varies per call.
FindExistingCompanion walks HKLM\SYSTEM\CurrentControlSet\Enum\SWD\HIDMAESTRO\ and matches devices whose Device Parameters\ControllerIndex value matches the controller we're operating on. The suffix isn't load-bearing for matching — it's there to make the kernel (enumerator + suffix + ContainerId) tuple unique.
For across-process matching (e.g. RemoveAllVirtualControllers from a fresh process sweeping orphans from a prior crashed session), the HIDMAESTRO enumerator name is the only stable identifier — sweep walks every SWD\HIDMAESTRO*\* entry regardless of suffix.
See also¶
- Architecture Overview — PnP layer's place in the full stack.
- XUSB Companion — the device the SwDevice path is most needed for.
- Lifecycle and Teardown — the SDK orchestration that drives
SwDeviceCreateand the SwD-first removal ordering. - Multi-Controller — why per-controller ContainerIDs scale.
- Driver Install and Signing — the broader install flow that creates these devnodes.
References¶
The Win32 PnP API symbols below are documented on learn.microsoft.com — search by the exact symbol name; the page is one click away.
SwDeviceCreate— the API andpContainerIdparameter.SetupDiCreateDeviceInfoW— the older device-creation API.DEVPKEY_Device_ContainerId— ContainerID semantics, including the null-sentinel default.SW_DEVICE_LIFETIME—SWDeviceLifetimeParentPresentvsHandlelifetime.CM_NOTIFY_ACTION— theDEVICEINSTANCEREMOVEDaction used as the kernel-side removal guarantee.- References — full source bibliography.