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 stable device identity that keeps a controller's paths the same on every life, 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\HIDClass\<token> (SetupAPI) |
(no companion) |
| Non-xinputhid Xbox | ROOT\VID_045E&PID_*&IG_00\<token> (SetupAPI) |
SWD\HIDMAESTRO\<token> (SwDevice) |
| xinputhid Xbox | SWD\HIDMAESTRO_VID_045E_PID_*&IG_00\<token> (SwDevice) |
(no companion) |
<token> is the controller's identity token: HM_0000 for the default key of index 0, HM_ plus sixteen hex digits for a consumer key. See Stable device identity below.
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.
Stable device identity¶
Since v1.8.0 every virtual controller has a durable identity, and every id a consumer keys on comes back the same on each life of that controller: the same process recreating it, a process restart, a reboot, and a driver upgrade. The identity is the key passed to CreateController(profile, identityKey). A caller that passes none gets the controller index as its key.
What consumers key on, measured on Windows 11 26200:
| Consumer | Keys on | Before v1.8.0 |
|---|---|---|
| DirectInput | Instance GUID, a per-VID/PID ordinal persisted under HKCU |
Stable for a lone pad |
| SDL3 joystick path, RawInput | The HID interface path | Changed on every life |
| Steam (USB/IP personas) | The USB serial string | Port-dependent for the Sony composites |
| Windows.Gaming.Input, GameInput | ContainerId and device path | Path changed on every life |
| XInput | Slot ordinal | Unchanged |
The HID interface path is built from the HID child's instance id, and that id is <ParentIdPrefix>&<collection>, where ParentIdPrefix is a 1&hash&n value PnP keeps in the parent's instance key. PnP reads it from the key when the child first arrives and mints a new counter only when it is absent. Every parent used to be created with a Windows-generated instance name and its key was deleted at teardown, so the counter advanced on every life. One reporter's pad had counted 74.
Every derived value is a pure function of the key, nothing is stored, and the mechanism differs per family:
| Family | Parent | How the child path stays put |
|---|---|---|
| Plain HID | ROOT\HIDClass\<token>, SetupAPI with an explicit instance id |
The identity's ParentIdPrefix is written into the instance key between SetupDiCreateDeviceInfo and DIF_REGISTERDEVICE, before the HID child exists. |
| Non-xinputhid Xbox | ROOT\VID_045E&PID_*&IG_00\<token> plus SWD\HIDMAESTRO\<token> |
Main devnode as above. The XUSB companion has no HID child, so a fixed SwDevice tuple is the whole fix. |
| xinputhid Xbox | SWD\HIDMAESTRO_VID_045E_PID_*&IG_00\<token> |
Fixed SwDevice tuple. SwDeviceCreate starts the driver inside the call, so the child has already enumerated by the time the SDK can write anything. The identity's prefix is reconciled into the key afterwards, and the restart the creation path already performs re-keys the child under it. |
| USB/IP composite personas | USB\VID_054C&PID_0CE6\<serial> |
A profile without a captured serial serves the identity's synthetic serial at a string index the descriptor did not use, so Windows keys the USB instance on the serial instead of the vhci port. Profiles with a captured serial keep it, varied by identity. |
<token> is HM_0000 for the default key of index 0, and HM_ plus sixteen hex digits for a consumer key. A consumer key also derives a container GUID in the HIDMAESTRO family, a 1&hash&0 prefix and a HM plus twelve hex digit serial, all from one SHA-256 of the key.
The suffix that came before¶
Releases v1.1.30 through v1.7.3 put a session-unique suffix on every SwDeviceCreate call. It existed because a DIF_REMOVE on a SWDeviceLifetimeParentPresent device left the software device alive in the kernel, and a create with the same tuple reconnected to that half-removed shell: S_OK with no Service, no driver, no interface. Phase-1 creation of four mixed controllers ballooned from about 2 s to 65 s on every same-boot relaunch, and PadForge users with multiple controllers were hit on every relaunch. Teardown has gone through SwDeviceSetLifetime(Handle) plus SwDeviceClose since v1.1.31, which destroys the software device, and a fixed tuple recreates cleanly after it.
The identity lab (test/probes/identity_lab, three lives per variant on 26200) measured the matrix:
| Variant | Bound | Same parent | Same child |
|---|---|---|---|
| Unique SwDevice suffix, fixed container (v1.7.3 behavior) | yes | no | no |
| Fixed suffix, fixed container, phantom record retained | yes | yes | yes |
| Fixed suffix, fixed container, phantom record purged between lives | yes | yes | no (counter 0, 1, 2) |
| Fixed suffix, purged, prefix written after create plus restart | yes | yes | yes |
| Fixed suffix, changing container, retained | yes | yes | yes |
| Fixed suffix, fast remove without waiting for the cascade, immediate recreate | yes | yes | yes |
| XUSB companion, fixed suffix, any of the above | yes | yes | (no child) |
| ROOT parent, explicit instance id, no prefix written | yes | yes | no (counter 0, 1, 2) |
| ROOT parent, explicit id, prefix written before registration | yes | yes | yes |
Instance key created BEFORE SwDeviceCreate |
no |
The last row is the one hazard: a SWD instance key that exists before the software device does leaves a record PnP stamps with a SYSTEM-only Properties subkey and never enumerates, and an administrator cannot delete it. Nothing in the SDK creates one.
FindExistingCompanion still matches by ControllerIndex in Device Parameters, so cleanup and teardown sweep across instances regardless of which session created them. Battery scenarios S58 (derivation) and S59 (nine lives per family, overlap, profile change at one key) hold the behavior.
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, 299 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 identity token maps at scale¶
For 6 controllers running in one process:
SWD\HIDMAESTRO\HM_0000 ← controller 0 (Xbox 360 Wired companion)
SWD\HIDMAESTRO\HM_0001 ← controller 1's companion, if it has one
SWD\HIDMAESTRO_VID_045E_PID_0B13&IG_00\HM_0001 ← xinputhid path, different enumerator
...
The token is HM_ plus the four-digit index for the default key, or HM_ plus sixteen hex digits derived from a consumer identity key. It is the same on every life of that controller, which is the whole point of it. The per-call session-unique suffix that v1.1.30 through v1.7.3 used is gone as of v1.8.0.
FindExistingCompanion walks HKLM\SYSTEM\CurrentControlSet\Enum\SWD\HIDMAESTRO\ and matches devices whose Device Parameters\ControllerIndex value matches the controller we're operating on, so cleanup and teardown reach instances created by any session.
For across-process matching (e.g. RemoveAllVirtualControllers from a fresh process sweeping orphans from a prior crashed session), the HIDMAESTRO enumerator name is the stable identifier. The sweep walks every SWD\HIDMAESTRO*\* entry regardless of token.
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.