Driver Installation Internals¶
How PadForge installs, detects, and removes the drivers behind its virtual controllers: HIDMaestro, HidHide, Windows MIDI Services, SteamVR, the DualShock 3 Bluetooth stack, and the legacy v2 leftovers.
PadForge v5 deals with six drivers/services and a legacy v2 cleanup path:
- HIDMaestro is the user-mode UMDF2 driver behind the Xbox, PlayStation, Nintendo, and Extended slot types. It is not installed by
DriverInstaller. The driver binaries, INF, profiles, and signing tools all ship insideHIDMaestro.Core.dll.HMContext.InstallDriver()(called lazily the first time one of those four slot types activates) registers them with Windows. VR slots ride HIDMaestro too, but through its OpenVR driver, registered with SteamVR byHMVR.EnsureDriverRegistered()rather than byInstallDriver(). MIDI and Keyboard+Mouse slots use no driver of HIDMaestro's at all. - HidHide is the kernel-mode driver that hides physical controllers from games. On an x64 machine it is an embedded WiX Burn bootstrapper EXE, installed and removed through
msiexec. On an ARM64 machine it is upstream's Microsoft-signed ARM64 driver package, installed and removed byHidHideArm64Installerwith upstream's own tool, nefcon. - Windows MIDI Services is part of Windows 11 25H2 from the late-November 2026 update. Microsoft removed its separate runtime's installers on 2026-10-01, so on a PC without the in-box API
DriverInstallerinstalls PadForge's build of the same runtime from hifihedgehog/PadForge-MIDI-Runtime, checked against a pinned SHA-256, and detects and uninstalls Microsoft's install and PadForge's alike.MidiApiSelectionpicks which API runs, and the legacy WinMM API needs no install. - SteamVR is installed without the Steam client, by downloading Valve's
steamcmdand running the anonymousapp_updatefor app 250820 (issue #49). Uninstall is offered only for the install PadForge itself created. - The DualShock 3 Bluetooth stack (BthPS3 + BthPS3PSM) ships as embedded driver packages and is installed from
Ds3DriverInstaller, which also binds a docked DS3 to inbox WinUSB so the sixpair reports can be sent. - Controllers Windows leaves without a driver get inbox WinUSB, or Windows' own Xbox 360 driver xusb22, from
VendorUsbDriverInstaller, each through a package that names one ID and is signed on this machine: automatically for most, and only on request for four devices whose switch costs Windows something. - Legacy v2 driver cleanup offers to uninstall ViGEmBus and vJoy on first launch when either is detected. v2 used those two drivers as PadForge's virtual-controller backends. HIDMaestro replaces both.
Driver-side code lives in eight files:
PadForge.App/Common/DriverInstaller.cs(PadForge.Common) handles HidHide, Steam-free SteamVR and Windows MIDI Services runtime install/uninstall, plus the legacy v2 ViGEmBus and vJoy uninstall paths.PadForge.App/Common/Input/MidiApiSelection.cs(PadForge.Common.Input) picks the Windows MIDI Services API: the in-box one where Windows registers it, else the runtime where it is installed. It also predicts the legacy fallback for the UI and decides when the card offers the runtime.PadForge.App/Common/HidHideArm64Installer.cs(PadForge.Common) installs and removes HidHide on an ARM64 machine, and carries the startup check that stands in for HidHide's watchdog service there.PadForge.App/Common/Input/InputManager.Step5.VirtualDevices.csownsEnsureHMaestroContext(), which calls into the HM SDK to register the HIDMaestro driver with Windows.PadForge.App/App.xaml.csowns the launch-time HIDMaestro orphan sweep and the OEM-name orphan recovery, both before any virtual is created.PadForge.App/Services/Ds3DriverInstaller.cs(PadForge.Services) installs BthPS3 and BthPS3PSM from embedded driver packages, signs and installs the DS3 WinUSB package on the machine that runs it, and arms PSM patching.PadForge.App/Services/VendorUsbDriverInstaller.cs(PadForge.Services) writes, signs and binds the WinUSB and xusb22 packages, one ID each, and restores Windows' driver for an opted-in device.PadForge.App/Common/Input/VendorUsbBindingService.cs(PadForge.Common.Input) sweeps the present USB nodes and binds each controller that needs a driver as it appears.
Contents¶
- Architecture Overview
- Embedded Resources
- Shared Helpers
- HIDMaestro
- HidHide
- Windows MIDI Services
- SteamVR (Steam-free install)
- DualShock 3 Bluetooth stack (Ds3DriverInstaller)
- Controllers Windows leaves without a driver (VendorUsbDriverInstaller)
- Legacy v2 driver cleanup (ViGEmBus, vJoy)
- HidHide Runtime API (HidHideController)
- Uninstall Guards
- Elevation Strategy
- Temp Directories
- Error Handling and Rollback
Architecture Overview¶
graph TD
subgraph Engine["InputManager.Step5"]
HM["EnsureHMaestroContext()<br/>HMContext.InstallDriver()"]
end
subgraph DriverInstaller["DriverInstaller (static class)"]
direction TB
HH["HidHide<br/>x64: embedded EXE bootstrapper<br/>ARM64: driver package and nefcon"]
MS["Windows MIDI Services runtime<br/>PadForge build from GitHub, SHA-256 pinned"]
SV["SteamVR<br/>steamcmd, anonymous app 250820"]
LC["Legacy v2 cleanup<br/>(detect + uninstall ViGEmBus, vJoy if present)"]
end
subgraph Ds3["Ds3DriverInstaller (PadForge.Services)"]
direction TB
BT["BthPS3 + BthPS3PSM<br/>embedded driver packages"]
WU["DS3 WinUSB package<br/>signed on this machine"]
end
subgraph Vendor["VendorUsbDriverInstaller (PadForge.Services)"]
direction TB
VW["One-device WinUSB package<br/>signed on this machine"]
VX["One-device xusb22 package<br/>signed on this machine"]
end
HM -->|"InstallDriver() inside HIDMaestro.Core.dll"| HM_DRV["HIDMaestro UMDF2 driver<br/>(337 profiles bundled in the SDK,<br/>239 of them offered in PadForge)"]
HH -->|"x64: HidHide_1.5.230_x64.exe<br/>/extract -> msiexec /i HidHide.msi<br/>ARM64: nefconc install, then class filters"| HH_DRV["HidHide kernel driver"]
MS -->|"vc_redist.x64.exe if older than 14.51<br/>-> msiexec /i PadForge-MIDI-Runtime-*.msi"| MS_SVC["C:\Program Files\PadForge MIDI Runtime<br/>(the in-box API needs no install)"]
SV -->|"steamcmd.zip -> +app_update 250820<br/>-> HMVR.SetSteamVRPathHint"| SV_DIR["SteamVR payload<br/>(default C:\SteamVR)"]
BT -->|"Devcon.Install of the two INFs<br/>+ Bluetooth-class lower filter"| BT_DRV["BthPS3 profile driver<br/>+ BthPS3PSM filter"]
WU -->|"Inf2Cat + signtool, then<br/>UpdateDriverForPlugAndPlayDevices"| WU_DEV["Docked DS3 on winusb.sys"]
VW -->|"Inf2Cat + signtool, DiInstallDriver,<br/>then UpdateDriverForPlugAndPlayDevices"| VW_DEV["Vendor-USB controller on winusb.sys<br/>(SDL reads it through libusb)"]
VX -->|"Include xusb22.inf CC_Install,<br/>same signing and forced bind"| VX_DEV["Clone Xbox 360 receiver on xusb22"]
LC -.->|"msiexec /x ViGEm,<br/>cmd script for vJoy<br/>(pnputil /remove-device, sc, reg, rmdir)"| OLD["ViGEmBus / vJoy<br/>(only if detected)"]
HM -.-> ELEV["PadForge process<br/>(elevated via app.manifest)"]
HH -.-> ELEV
MS -.-> ELEV
SV -.-> ELEV
BT -.-> ELEV
WU -.-> ELEV
VW -.-> ELEV
VX -.-> ELEV
LC -.-> ELEV
app.manifest declares <requestedExecutionLevel level="requireAdministrator" uiAccess="false"/>, so Windows shows the UAC shield on the icon and prompts once when the process starts. Every install/uninstall path runs inside the already-elevated process. There are no further UAC prompts mid-session.
The OpenXInput shim (xinput1_4.dll under Resources/OpenXInput/x64/, or arm64/ for the ARM64 build) is not managed by DriverInstaller. It ships embedded inside PadForge.exe as a <Content> item bundled by IncludeNativeLibrariesForSelfExtract. App.xaml.cs calls SetDllDirectory on the single-file extract directory so the loader resolves PadForge's copy ahead of C:\Windows\System32\xinput1_4.dll. Nothing to install or uninstall, the shim is removed when you delete the PadForge folder.
devobj.dll is deliberately not shipped with the EXE. OpenXInput's source tree contains a stub devobj.dll (every export returns 0xCDCDCDCD) only to satisfy xinput1_4.dll's static-link import at compile time. Shipping it would let SetDllDirectory pre-empt C:\Windows\System32\devobj.dll for the entire process, including setupapi.dll's own DevObj* imports. setupapi then crashes during HID class enumeration. See PadForge #69. The system devobj.dll resolves from System32 unaided.
Embedded Resources¶
| Resource | Type | Approximate size | Purpose |
|---|---|---|---|
HIDMaestro.Core.dll (referenced via HintPath, not embedded) |
Managed assembly | varies by version | HIDMaestro SDK and bundled UMDF2 driver. Loaded by the CLR. HMContext.InstallDriver() registers the driver with Windows the first time an HM-backed slot is created. |
Resources\HidHide_1.5.230_x64.exe |
EXE (WiX Burn bootstrapper) | ~7.7 MB | HidHide kernel driver for an x64 machine. Bundled MSI extracted and run silently. In the x64 build only, since only that build runs on an x64 machine. |
Resources\HidHideArm64\HidHide_ARM64.zip |
ZIP (INF + SYS + CAT) | ~44 KB | HidHide's Microsoft-signed ARM64 driver package, driver 1.6.280.0, as upstream publishes it. In both builds, because a kernel driver follows the machine and not the build. |
Resources\HidHideArm64\nefconc.exe |
EXE (ARM64) | ~1 MB | nefcon 1.20.0, the tool HidHide's own setup drives. Runs the ARM64 install and removal as a child process. In both builds. |
Resources\OpenXInput\<arch>\xinput1_4.dll |
DLL (Content) | ~172 KB (arm64), ~180 KB (x64) | OpenXInput shim. Not an installer. Bundled into the single-file EXE via IncludeNativeLibrariesForSelfExtract and loaded via SetDllDirectory on the extract directory at runtime. |
Resources\BthPS3\**\*.* |
INF + SYS + CAT | ~750 KB total | Nefarius BthPS3 (BthPS3\) and BthPS3PSM (BthPS3PSM\) driver packages, each with an x64\ and an ARM64\ binary under it since 4.5.1, plus WinUSB\ds3_winusb.inf. Each resource carries a LogicalName of BthPS3.{RecursiveDir}{Filename}{Extension}, which Ds3DriverInstaller.ExtractDrivers() maps straight back to a directory tree. |
Windows MIDI Services is not embedded. The in-box API comes with Windows, and the runtime downloads from GitHub only when the Settings card's Install runs. PadForge carries only the in-box API's metadata, Resources\WinMD\Windows.Devices.Midi2.winmd, which C#/WinRT reads at build time, and the RefuseInBoxMidiBinaries target refuses a publish that would carry Microsoft's Windows.Devices.Midi2.dll. SteamVR is not embedded either: steamcmd.zip comes from steamcdn-a.akamaihd.net at install time and the payload is several GB.
No signed catalog ships for the DS3 WinUSB package. ds3_winusb.inf is the only file in Resources\BthPS3\WinUSB\, and ds3_winusb.cat is generated and signed on the machine that installs it. The two BthPS3 packages do ship with their vendor .cat files, which are Microsoft-signed already.
Declared in PadForge.App.csproj:
<Reference Include="HIDMaestro.Core">
<HintPath>Resources\HIDMaestro\HIDMaestro.Core.dll</HintPath>
</Reference>
<EmbeddedResource Include="Resources\HidHide_1.5.230_x64.exe" Condition="'$(NativeArch)' == 'x64'" />
<EmbeddedResource Include="Resources\HidHideArm64\HidHide_ARM64.zip" />
<EmbeddedResource Include="Resources\HidHideArm64\nefconc.exe" />
<Content Include="Resources\OpenXInput\$(NativeArch)\xinput1_4.dll" Link="xinput1_4.dll" />
<EmbeddedResource Include="Resources\BthPS3\**\*.*">
<LogicalName>BthPS3.%(RecursiveDir)%(Filename)%(Extension)</LogicalName>
</EmbeddedResource>
HIDMaestro.Core.dll is a <Reference>, not a <ProjectReference>. Using a project reference would build from source and pull in unstable in-progress work from the HIDMaestro repo. Updates happen by copying the Release build of HIDMaestro.Core.dll from the HIDMaestro repo into Resources\HIDMaestro\ after a tag is cut there. PadForge 5.2.0 ships HIDMaestro 2.1.0, and 5.1.0 shipped 2.0.3. 1.12.0, 2.0.0 and 2.1.0 each changed the driver, so the first slot that needs it after the update reinstalls the driver once. 1.9.0 asked Inf2Cat for its ARM64 catalog with /os:10_ARM64, which Inf2Cat refuses, so on an ARM64 PC its install stopped before the driver reached the store. 1.9.1 asks for 10_RS3_ARM64 (HM#63). The 1.6 line introduced the native OpenVR driver behind the VR slot type.
Shared Helpers¶
Helpers reused across the install flows. ExtractEmbeddedResource and CleanupTempDir are internal, because HidHideArm64Installer uses them too. The rest are private.
| Method | Signature | Behavior |
|---|---|---|
ExtractEmbeddedResource |
(string resourceFileName, string tempDir) |
Finds resource via case-insensitive IndexOf on GetManifestResourceNames(), streams to {tempDir}\{resourceFileName}. Throws FileNotFoundException (listing all resource names) if not found. |
ExtractInstallerBundle |
(string exePath, string tempDir) |
Runs the WiX bootstrapper with /extract to unpack its MSI into {tempDir}\Extracted\. Recreates the directory if it exists. 60s timeout. |
FindMsi |
(string extractDir, string primaryName, string fallbackPattern) |
Searches recursively for an MSI: exact name first, then glob fallback. Throws FileNotFoundException if neither matches. |
RunElevated |
(string fileName, string arguments, out bool notTracked) |
Launches a child process with Verb = "runas", hidden window, 180s timeout, and returns its exit code, or null when it is still running after the wait or when Windows started it without handing back a process (notTracked). PadForge is already elevated via app.manifest, so Windows does not show a UAC prompt when launching the child. Used by HidHide install/uninstall on x64, the MIDI runtime install and the legacy ViGEmBus uninstall (all three through RunMsiElevated), by the Visual C++ Redistributable runs, and by the legacy vJoy uninstall script. |
RunMsiElevated |
(string arguments, bool absentIsSuccess) |
RunElevated("msiexec.exe", arguments, out bool untracked), held to its exit code. 0, 3010 and 1641 are success, and 1605 (not installed) only when absentIsSuccess. Anything else, a timeout, or a start Windows handed back no process for, throws InstallerFailedException. |
CleanupTempDir |
(string tempDir) |
Recursive delete, swallows all exceptions. Called in finally blocks. |
FindUninstallProductCode |
(string displayNameSubstring) |
Scans HKLM\...\Uninstall (Registry64 + Registry32) for a DisplayName containing the substring, then returns the subkey name only when Windows Installer marks the entry as its own (WindowsInstaller = 1 under a brace GUID key, MsiProductCodeOrNull), so a bootstrapper's own entry, and Inno and NSIS entries, fall through and the scan continues. Returns the MSI ProductCode GUID {XXXXXXXX-...} or null. Used for ViGEmBus uninstall, where PadForge does not embed the MSI. |
HIDMaestro¶
PadForge does not ship a separate HIDMaestro installer EXE or MSI, and DriverInstaller has no InstallHIDMaestro method. The removal is HIDMaestro's own (Removal). The HM SDK assembly (HIDMaestro.Core.dll) bundles the UMDF2 driver binaries, INF, signing tools, and 337 device profiles. PadForge's pickers offer 239 that carry a captured HID descriptor: HMaestroProfileCatalog keeps only profiles whose HMProfile.IsDeployable is true, which is HasDescriptor, leaves out the one it withholds (WithheldProfileIds, the GameCube adapter's Full persona), and adds its own synthetic Custom profile. The 51 keyboards and 52 mice go to the Keyboard+Mouse slot's pickers (KeyboardProfiles, MouseProfiles) and the 136 controllers to the others. Driver registration happens lazily via the SDK, the first time an Xbox, PlayStation, Nintendo, or Extended slot is created, or a Keyboard+Mouse slot set to a keyboard or mouse model (typically during the first engine Start() that has such a slot configured).
Composite personas need one more driver. The seven profiles whose backend is usbip (dualsense-composite, dualsense-edge-composite, dualshock-4-v2-composite, steam-controller-2, steam-controller-composite, steam-deck-composite and switch2-pro-controller-composite) attach through usbip-win2's virtual USB host controller. HIDMaestro 2.0.1 and later carry usbip-win2 0.9.8.1's host controller driver inside HIDMaestro.Core.dll, three files for each architecture: x64 from the release installer, whose driver Microsoft certified, and ARM64 from the same tag's build from the project's signing service, whose drivers Microsoft attestation-signed. It checks each file against its pinned SHA-256 and installs only the host controller, the first time a composite controller is created (HMContext.CreateController reaches UsbipDriverInstaller.EnsureInstalled): it registers the ROOT\USB devnode usbip-win2's own devnode.exe would, installs the driver on it through Windows' driver update, and records the install under HKLM\SOFTWARE\HIDMaestroUsbip. It never runs usbip-win2's installer and never installs its root-hub filter, so no USB root hub restarts. PadForge makes no call of its own. HIDMaestro 1.9.2 through 2.0.0 ran the 0.9.8.1 installer itself with /VERYSILENT /SUPPRESSMSGBOXES /NORESTART, and the removal's usbip-win2 option takes what that left. Since 2.0.3 that includes the root-hub filter, whose removal restarts every USB 3 root hub. A machine that already has usbip-win2 with its host controller stopped gets a device restart instead of a reinstall. One still carrying the 0.9.7.7 or 0.9.7.5 host controller that HIDMaestro installed through 1.9.1 moves to 0.9.8.1 by one driver update in place, only in a Windows session where that controller has carried no device yet, and HIDMAESTRO_KEEP_TRANSPORT=1 turns the move off. HIDMaestro moves only a host controller whose driver file matches a build it installed, so another program's usbip-win2 of a different build keeps its version, and it adds its own hardware ID, ROOT\HIDMAESTRO_UDE, to every usbip-win2 host controller it finds. It asks the driver which request format it speaks, 0.9.7.x, 0.9.8.0 or 0.9.8.1, before each attach, stop or device-list request. The detach request has one format in all three.
EnsureHMaestroContext()¶
InputManager.Step5.VirtualDevices.cs owns the HM lifecycle:
private void EnsureHMaestroContext()
{
if (_hmaestroContext != null || HmSetupHeld()) return;
lock (_hmaestroContextLock)
{
if (_hmaestroContext != null || HmSetupHeld()) return;
HMContext ctx = null;
try
{
// Preflight: remove what programs that have ended left
// behind, a crashed PadForge's controllers among them. Since
// HIDMaestro 2.0.0 the sweep never touches a running
// program's controllers, this one's included.
try { HMaestroVirtualController.SweepVirtualControllers(); } catch { }
ctx = new HMContext();
int n = ctx.LoadDefaultProfiles();
ctx.InstallDriver();
bool recovered = _hmaestroSetupFailedTick != 0;
Volatile.Write(ref _hmaestroSetupFailedTick, 0);
if (recovered) RaiseErrorResolved(HmSetupFailedMessage);
// No exit sweep of PadForge's own: HIDMaestro's ProcessExit
// handler removes the controllers a program did not dispose.
// Published last, after everything that can throw.
_hmaestroContext = ctx;
}
catch (Exception ex)
{
try { ctx?.Dispose(); } catch { }
Volatile.Write(ref _hmaestroSetupFailedTick, Math.Max(1, Environment.TickCount64));
RaiseError(HmSetupFailedMessage, ex); // "Failed to initialize HIDMaestro."
}
}
}
InstallDriver() is idempotent and safe to call every Start(). Elevation is required, supplied by app.manifest.
A failed setup holds for five seconds (HmSetupRetryHoldMs), so the other slots of the same start fail fast instead of each running the setup again, since creates run one at a time. The next create after that runs it again. A slot whose create failed keeps its own latch, which only a change to that slot clears: turning it off and on, a type or profile change, or a device reconnect. A retry therefore follows the user's next change, or a slot created later, and never loops on its own. A failure used to set a flag that only an engine stop cleared. A successful retry raises ErrorResolved, and InputService.ClearResolvedError clears "Error: Failed to initialize HIDMaestro." from the status line while it is still the message shown. An engine stop resets the hold with the context (DisposeHMaestroContextOnShutdown).
preserveInstall: true is load-bearing on every sweep. The preserving overload still evicts every stale device node, which is the only thing that blocks the install. The flag guards driver-package removal and the HKLM\SOFTWARE\HIDMaestro delete, and that key holds the VR driver's registration gate plus the SteamVRPath hint. Sweeping without it sent a VR slot back to re-extracting driver_hidmaestro.dll into a running vrserver.exe whenever a session mixed a VR slot with a conventional one, and cost the next launch a full deploy by discarding the manifest hash.
Settings page surface¶
The HIDMaestro card in SettingsPage.xaml shows an Ember flame lit while the driver is in the driver store (a Path styled LivenessFlameBase whose DataTrigger on IsHIDMaestroInstalled sets Fill="{DynamicResource EmberBrush}" and the #FF6B2C DropShadowEffect, as the HidHide card's does), the localized "Installed" or "Not Installed" beside it (HIDMaestroStatusText), the SDK assembly version below, and an Uninstall button (EmberDestructiveButton, UninstallHIDMaestroCommand). MainWindow.RefreshHIDMaestroStatus sets IsHIDMaestroInstalled from HIDMaestroRemoval.IsDriverInstalled() at startup, on the five-second driver status timer and after a removal. The version string is bound to HIDMaestroVersion, computed once at startup by SettingsViewModel.GetEmbeddedHidMaestroVersion() (a view-model method, not markup):
private static string GetEmbeddedHidMaestroVersion()
{
try
{
var asm = typeof(HIDMaestro.HMContext).Assembly;
var v = asm.GetName().Version;
return v != null ? $"v{v.Major}.{v.Minor}.{v.Build}" : string.Empty;
}
catch { return string.Empty; }
}
There is no Install button: the SDK assembly is always present in the publish output, so the Xbox / PlayStation / Nintendo / Extended categories are always enabled, and the driver installs with the first controller. Uninstall stays available with the driver gone, since HIDMaestro can leave other things behind, and its preview says when nothing is left. MIDI still depends on a MIDI API starting, Windows MIDI Services or the legacy one, and VR still depends on SteamVR.
Cleanup¶
HMContext.RemoveAllVirtualControllers(preserveInstall: true) runs in two places, both through HMaestroVirtualController.SweepVirtualControllers and both with the preserving flag. Since HIDMaestro 2.0.0 it removes only the controllers of programs that have ended, never a running program's, this one's included:
| Site | When | Purpose |
|---|---|---|
EnsureHMaestroContext preflight |
Before each InstallDriver() |
Purge stragglers from a prior crash so the install can succeed. |
App.xaml.cs startup orphan sweep (OrphanSweepTask) |
App launch, on a background Task in OnStartup |
Purge HM virtuals left by a prior crashed or force-killed session, then wait (bounded) for the devnodes to actually vanish (the HM#38 ordering barrier below). Runs off the UI thread so OnStartup returns at once. UpdateDevices does not block on it. The SDL3 fork filters HM HIDs out of SDL enumeration whether or not the prior session's kernel cleanup has finished, so enumeration is safe immediately (blocking the poll thread on the sweep once pinned startup past 90 seconds). MainWindow shows the startup overlay ("Cleaning up virtual controllers left from a previous session.") while the sweep runs. Same role as the row-1 preflight, not a shutdown mirror. |
Startup sweep ordering barrier (HM#38). RemoveAllVirtualControllers() returns when the call completes, not when PnP removal completes, and a virtual-controller create racing an in-flight removal was one of the trigger windows for the frozen-output bug that the HIDMaestro 1.3.22 driver fixed structurally. So after the sweep, OrphanSweepTask polls SetupApiInterop.AnyPresentHidMaestroDevice() up to 25 times at 200 ms intervals (about 5 s) until the HIDMaestro devnodes are genuinely absent, so a same-session create cannot adopt a dying devnode. A devnode that lingers past the bound logs ORPHANSWEEP devnodes still present after 5 s; proceeding rather than block startup. The wait is consumer-side ordering hygiene, not the fix itself.
OEM-name orphan recovery. Before any virtuals are created, App.xaml.cs calls HIDMaestro.HMOemNameOverride.RecoverOrphans() to replay OEM-name overrides left by a prior session that never ran its cleanup Clear (crash, force-kill, power loss). This restores the DirectInput OEM-name table in HKLM to its pre-override state. Idempotent (a no-op when no orphan records exist) and best-effort (a swallow-all try/catch).
Graceful shutdown does not call RemoveAllVirtualControllers(). InputManager.Stop() tears each virtual down with DestroyAllVirtualControllers(), then DisposeHMaestroContextOnShutdown() disposes the static HMContext. PadForge has had no exit sweep of its own since HIDMaestro 2.0.0: the sweep can no longer reach a running program's controllers, and HIDMaestro's own ProcessExit handler, added with its owner claim at the program's first cleanup, InstallDriver or create, removes the controllers a program did not dispose. A killed PadForge's controllers go with the next sweep of any program on the PC.
Removal¶
Settings, HIDMaestro Driver, Uninstall runs HIDMaestro 2.0.1's own removal (HMUninstall). It takes every HIDMaestro controller and device record, the driver packages, the signing certificate, HIDMaestro's registry keys and files and its OpenVR registration, and puts back the joy.cpl names and GameInput tables a controller changed while they still hold what HIDMaestro wrote. HIDMaestroRemoval.Preview fills UninstallHIDMaestroDialog, and HIDMaestroRemoval.Run(includeUsbip, stopEngine) stops the engine and calls HMaestroVirtualController.RemoveHIDMaestro, the one call site, fenced in the test host. HIDMaestro Deep Dive covers the flow. Removing HIDMaestro's devices in Device Manager or a package with pnputil /delete-driver leaves the rest behind. On a PC without PadForge, HIDMaestroUninstall.exe from the HIDMaestro release ZIP runs the same removal.
HidHide¶
A kernel driver follows the machine, so PlatformSupport.IsArm64Machine picks the path, whichever build is running.
InstallHidHide()¶
On an x64 machine: extract embedded HidHide_1.5.230_x64.exe to a staging folder of this attempt's own under %TEMP%\PadForge_HidHide\, run /extract to unpack the MSI, locate HidHide.msi (with HidHide*.msi glob fallback), then run msiexec /i "{msiPath}" /qb /norestart via RunMsiElevated. The staging folder is deleted in finally, except after a timeout, when the installer may still be reading it.
On an ARM64 machine: HidHideArm64Installer.Install(). See The ARM64 path.
UninstallHidHide()¶
The HidHide that is installed is removed by its registered ProductCode, msiexec /x {ProductCode} /qb /norestart, which is the command Windows itself records for the product. A package path names the product inside that package, so the bundled 1.5.230 package removes an installed HidHide only when the two share a ProductCode. 1605 counts as done on this route.
With no usable ProductCode, an ARM64 machine takes HidHideArm64Installer.Uninstall(). An x64 machine falls back to the bundled package, msiexec /x "{msiPath}", where 1605 stays a failure, because it only says the bundled product is absent while something named HidHide is still registered.
Detection: TryGetHidHideStatus()¶
One look answers both "installed" and the version, because the status timer asks every five seconds on the UI thread. TryGetHidHideMsiInfo(out displayVersion, out productCode) scans the Uninstall keys (Registry64 + Registry32) for "HidHide" or "HID Hide" in DisplayName (case-insensitive). Any such entry counts as installed. The ProductCode comes only from an entry Windows Installer marks as its own (WindowsInstaller = 1 under a brace GUID key). The version falls back to "Installed" when DisplayVersion is null or empty.
On an ARM64 machine with no such registration, HidHideArm64Installer.IsInstalled() decides, and the version is the driver file's own.
The ARM64 path¶
HidHideArm64Installer stages the embedded HidHide_ARM64.zip and the ARM64 nefconc.exe in a folder per attempt and runs nefcon as a child process, so native code performs the install whichever build PadForge is. The commands and their order are those of HidHide's own setup (Installer/Program.cs):
| Step | Commands |
|---|---|
| Install | install "<inf>" "root\HidHide" --no-duplicates --remove-duplicates, then --add-class-filter --position upper --service-name HidHide --class-guid <class> for HIDClass, XnaComposite and XboxComposite |
| Remove | --remove-class-filter for the three classes in reverse, then remove "root\HidHide" |
Exit codes 0 and 3010 are success, and so is 259 on install, which is how Windows reports a driver that is already current. HIDClass has to take. The other two classes are best effort, as they are in HidHide's setup. Each command gets three minutes, the wait the timeout message states.
A class filter entry that names a driver which cannot load can stop every keyboard and mouse from starting. HidHide's x64 setup ships a watchdog service for that, and an ARM64 install has none. Three rules stand in for it.
- Filters are added only after
\\.\HidHideanswers. The control device is exclusive, so "access denied" (error 5) counts as answering: another client holds it. If it does not answer, no filter is added and the device node's removal is attempted. After exit code 3010 the device stays in and the code is reported. - Removal takes the filters off, reads them back, and only then removes the device node. nefcon's
removetakes the node alone. The service and the driver package stay, as they do after HidHide's own uninstall. If the removal stops part way, by an exit code or a throw, with the node still in, the filters that were listed at the start are added back, provided the driver still answers. When every add succeeds the state is a complete install again and Uninstall stays on offer, and an add that fails is logged. Nothing is put back after a command that outlived its wait, or when the node is gone or cannot be read back. nefcon answers 1 for "no device matched" too, so a node that is already gone counts as removed. - At startup on an ARM64 machine,
RemoveDanglingFilters()asks the Service Control Manager about the HidHide service. If it does not exist, or is registered and stopped, every class filter entry that names it is removed, and the diagnostics log names the state and the classes. A running service, a service between two states, and a query that fails all leave the filters alone.
Install, removal and the startup check hold one lock, so the check cannot take out a filter an install has just added. "Installed" means the service, the HIDClass filter and the device node together, read in that order, so a machine without HidHide never enumerates its devices.
Windows MIDI Services¶
The API is part of Windows 11 25H2 from the late-November 2026 update. Microsoft removed the separate "Windows MIDI Services Runtime and Tools" installers from every microsoft/MIDI release on 2026-10-01. Until a PC has the in-box API, PadForge installs its own build of the same App SDK runtime, 1.0.17-rc.4.25 from microsoft/MIDI tag rc-4 under the MIT License, published at hifihedgehog/PadForge-MIDI-Runtime. None of it ships inside PadForge. Where no Windows MIDI Services API starts, MidiVirtualController falls back to the legacy WinMM API, which needs no install (Virtual Controllers, MIDI Input Internals).
Choosing the API (MidiApiSelection)¶
internal static MidiApiKind Choose(int osBuild, Func<int> inBoxActivationHr, Func<bool> appSdkRuntimeInstalled)
| Build | In-box activation | Runtime installed | Result |
|---|---|---|---|
| 26200 or later | Succeeds | Any | InBox |
| 26200 or later | REGDB_E_CLASSNOTREG |
Yes | AppSdk |
| 26200 or later | REGDB_E_CLASSNOTREG |
No | None |
| 26200 or later | Any other failure | Any | None |
| 26100 to 26199 | Not tried | Yes | AppSdk |
| 26100 to 26199 | Not tried | No | None |
| Below 26100 | Not tried | Not asked | None |
Each input is asked only when its gate admits it. An activation failure other than an unregistered class stops at None: the in-box API is present but broken, and swapping in the runtime would hide that. None here means no Windows MIDI Services API. The engine's probe then starts the legacy API.
ProbeInBoxActivation calls RoGetActivationFactory for Windows.Devices.Midi2.MidiApi through combase directly, after CoIncrementMTAUsage keeps a multithreaded apartment alive for the process, and returns the HRESULT. It does not go through C#/WinRT, whose activation falls back to loading a DLL named after the namespace from the app folder and the normal search path when RoGetActivationFactory fails, which could pick up a preview copy another program left there. The engine runs the probe inside its bounded availability check (MidiVirtualController.IsAvailable).
The UI thread never activates anything. PredictForUi takes registration from HKLM\SOFTWARE\Microsoft\WindowsRuntime\ActivatableClassId\Windows.Devices.Midi2.MidiApi (IsInBoxRegistered), and Legacy API mode from the UseLegacyMidi DWORD under HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Drivers32 (IsLegacyApiMode), where 1 is Legacy API mode (Microsoft's Windows MIDI Services vs Legacy API Mode Switch). Legacy API mode at build 26100 or later predicts Legacy, since it stops the service. Otherwise the prediction runs the same rule and turns None into Legacy, as the engine does. ForCard keeps that prediction until the engine's probe has run, then takes the probe's answer. A probe that started nothing, the legacy API included, or that timed out, reads as not started.
When the card offers the runtime¶
internal static bool CanOfferRuntimeInstall(int osBuild, bool inBoxRegistered, bool legacyApiMode, bool appSdkRuntimeInstalled)
True at build 26100 or later with no in-box registration, Legacy API mode off and no runtime installed. In Legacy API mode the service does not run, so the runtime would not start until the mode changes. MainWindow.RefreshMidiServicesStatus sets SettingsViewModel.CanInstallMidiRuntime from it on its 5-second cadence.
Finding the runtime: FindMidiRuntime()¶
| Order | Owner | Found by | Target |
|---|---|---|---|
| 1 | MicrosoftBundle |
The Uninstall entry whose DisplayName is "Windows MIDI Services Runtime and Tools", Registry64 then Registry32 |
Its UninstallString |
| 2 | PadForgePackage |
MsiEnumRelatedProducts for upgrade code {5C4B95B9-C1D9-4C00-8454-71560DDA7106} |
The ProductCode |
| 3 | MicrosoftPackage |
MsiEnumRelatedProducts for upgrade code {297714BB-DD77-4748-A4C1-553AD66DA5D0}, Microsoft's runtime package |
The ProductCode |
The bundle comes first because its uninstall removes its runtime package with everything else it installed.
IsMidiRuntimeInstalled() is FindMidiRuntime().Owner != None. It loads nothing from the runtime, which would lock its DLLs in-process. GetMidiRuntimeVersion() reads the Installed value under HKLM\SOFTWARE\Microsoft\Windows MIDI Services\Desktop App SDK Runtime, the folder the runtime loads itself from (MIDI_ROOT_APP_SDK_REG_KEY in microsoft/MIDI src/api/inc/MidiDefs.h), and returns the product version of Microsoft.Windows.Devices.Midi2.dll there, such as 1.0.17-rc.4.25, else the bundle's DisplayVersion.
InstallMidiRuntime()¶
Runs on a worker thread through RunDriverOperationAsync.
- Stages under
%TEMP%\PadForge_MidiRuntime\<guid>. - When
IsVcRuntimeAtLeast(MidiRuntimeVcMinimum)is false, downloadshttps://aka.ms/vc14/vc_redist.x64.exeand requiresIsSignedByMicrosoft: WinVerifyTrust withWINTRUST_ACTION_GENERIC_VERIFY_V2, then a signer subject carryingO=Microsoft Corporation.InstallVcRuntimeruns the redistributable elevated with/install /quiet /norestart, accepts exit codes 0, 3010, 1641 and 1638 (IsVcRedistSuccess), and checks the version again. While it is still old, it runs/repair /quiet /norestartup to twice, five seconds apart (VcRedistRuns,VcRepairPauseMs). Windows Installer renames a DLL that programs have loaded and puts the new copy in its place, so a 3010 usually leaves the new runtime in place already. Only a program that holds the old file open without letting it be renamed makes Windows queue the new copy for the next startup. Another install of the version already registered plans nothing for its packages, while a repair reinstalls every file of an equal or older version (WiX 3.14 Burn,engine/msiengine.cpp, the engine inside Microsoft's redistributable). Burn refuses a later run only after starting a restart itself (engine/apply.cpp,engine/core.cpp), which/norestartrules out. When the runtime is still old after the repairs,InstallVcRuntimethrowsDriverCheckException, and which failure depends onPendingFileRenameOperations: a queued replacement of a Visual C++ runtime DLL in System32 isVcRuntimeHeld, shown asStatus_VcRuntimeHeld(in English, "A program holds the old Visual C++ runtime open, so Windows could not replace it. Close other programs and select Install again."). Anything else isVcRuntimeStillOld, shown asStatus_VcRuntimeStillOld_Format("The Visual C++ runtime is still older than 14.51 after its installer ran."). A run still going after its three-minute wait, or one Windows started without a process to follow, isVcRedistStillRunning, shown asStatus_VcRedistStillRunning("The Visual C++ Redistributable installer is still running. Select Install again once it finishes."), and the runtime's own installer does not run. - Downloads
PadForge-MIDI-Runtime-1.0.17-rc.4.25-x64.msi, or-arm64.msiwhenPlatformSupport.IsArm64Machine, fromMidiRuntimeReleaseUrl, and refuses it unlessSha256HexequalsMidiRuntimeSha256X64orMidiRuntimeSha256Arm64. - Runs
msiexec /i <msi> /qn /norestart MSIRESTARTMANAGERCONTROL=DisablethroughRunMsiElevated, which throwsInstallerFailedExceptionunless the exit code is 0, 3010 or 1641. - Deletes the staging folder, unless an installer outlived its wait and may still be reading it.
IsVcRuntimeAtLeast reads the System32 copy every process loads. msvcp140.dll, msvcp140_atomic_wait.dll and vcruntime140.dll must exist, plus vcruntime140_1.dll on an x64 machine, and the file version of msvcp140.dll must be at least 14.51. The runtime DLL was linked by MSVC 14.51, and Microsoft requires the installed redistributable to be at least as new as the build tools that made a binary ("Latest supported Visual C++ Redistributable downloads"). The x64 redistributable carries the ARM64 files too, and Microsoft's own MIDI bundle installed that one on both architectures.
MainWindow runs the install through MidiRuntimeSwitch.RunInstall, which calls InputService.SwitchMidiApi() on the same worker once the install has finished. That call is MidiVirtualController.ResetAvailability() alone: it drops the backend in use and starts a new generation. The input sweep then closes MIDI input and its session without waiting on them and enumerates again. The next probe starts the runtime, and Step 5 rebuilds each MIDI slot whose controller was made on another API (MidiVirtualController.ApiKind) or before the reset (CreatedGeneration behind Generation). An installer PadForge could not follow, because it outlived its wait or Windows handed back no process, is marked pending instead (MarkInstallPending). The card's refresh makes the switch once the runtime is in place (TakePendingSwitch), for up to 30 minutes (PendingInstallWatchMs) and never while an uninstall holds anything.
The runtime package¶
The MSI is built from installer/PadForgeMidiRuntime.wxs in the runtime's repository, derived from Microsoft's src/app-sdk/sdk-runtime-installer/sdk-package/WindowsMidiServicesSdkRedist.wxs. It installs Microsoft.Windows.Devices.Midi2.dll, self-registered through its own DllRegisterServer as in Microsoft's package, with its .winmd and .pri, LICENSE.txt and THIRD-PARTY-NOTICES.txt, to C:\Program Files\PadForge MIDI Runtime, and writes the Installed value above. It leaves out the PDB, the diagnostic tools such as mididiag.exe and midifixreg.exe, and the log-collection scripts that Microsoft's package carries. Two launch conditions guard it. It will not install while Microsoft's runtime package is present, since both would own the one Installed value, and it will not install without msvcp140.dll 14.51 or later. build/build.ps1 rebuilds both MSIs from microsoft/MIDI rc-4 (commit d1d274a6), x64 and Arm64X.
UninstallMidiRuntime()¶
Uninstalls through whichever owner FindMidiRuntime found. For the bundle, it parses the quoted or unquoted exe path and any arguments from its UninstallString and appends /quiet /norestart MSIRESTARTMANAGERCONTROL=Disable REBOOT=ReallySuppress. For a package, it runs msiexec /x <ProductCode> /qn /norestart MSIRESTARTMANAGERCONTROL=Disable REBOOT=ReallySuppress, where 1605 (already gone) counts as removed and any other failure throws InstallerFailedException. MSIRESTARTMANAGERCONTROL=Disable keeps Restart Manager from asking PadForge to close, so files in use are scheduled for removal at the next restart, and REBOOT=ReallySuppress keeps that restart from starting on its own. A Burn bundle forwards only the properties its author declared, so MainWindow.OnQueryEndSession also refuses Restart Manager's close request (WM_QUERYENDSESSION with ENDSESSION_CLOSEAPP). Both launch hidden with no runas (PadForge is already elevated) and wait up to 5 minutes (MidiUninstallWaitMs), returning false while the uninstaller is still running then. exited completes when the uninstaller exits, which is when the caller ends its hold on MIDI. Throws DriverCheckException (NoMidiRuntime) when nothing is installed. The in-box API is part of Windows and is never touched. Removing Microsoft's bundle also removes the MIDI Settings app, console and PowerShell module it installed, which Microsoft no longer offers for download.
The uninstall flow¶
MainWindow's uninstall handler first asks through the shared ConfirmDialog when DriverInstaller.MidiUninstallIsIrreversible(FindMidiRuntime().Owner) is true, which it is for Microsoft's bundle alone: that uninstall also removes the MIDI Settings app, console and PowerShell module, and Microsoft no longer offers any of them (title Settings_MidiUninstallBundle_Title, message Settings_MidiUninstallBundleConfirm_Message). A runtime package, PadForge's or Microsoft's, uninstalls without asking, since the card can install a runtime again. It then runs MidiRuntimeSwitch.RunUninstall on the operation's worker, since a release waits behind a probe that hangs on a dead service.
ReleaseForUninstall takes what the uninstall holds while its uninstaller runs, which follows the API in use (MidiVirtualController.TryHoldWithoutRelease):
| API in use | Hold | What it does |
|---|---|---|
| In-box | MidiUninstallHold.None |
Nothing is held. The in-box API never reaches the runtime. |
| Legacy | StartHeld |
MIDI keeps running on its WinMM ports, which the uninstaller does not touch. Only a start of Windows MIDI Services is held off, since the check that follows a refused start and any probe after a reset would load the runtime again. |
| The runtime, or nothing started | Released |
InputService.ShutdownMidiInputs() closes MIDI input, then MidiVirtualController.SuppressForUninstall() drops the backend and latches MIDI released, so no sweep opens new sessions on the runtime while its uninstaller removes it. |
One release is in flight at a time: a call that finds an earlier one still at work waits for it within its own bound and starts no worker beside it. The bound is 60 s (ReleaseTimeoutMs), and the call then gives the worker it starts the 60 s again, so a second Uninstall can wait up to 120 s. A release that misses it keeps the uninstaller from starting, and RunUninstall throws DriverCheckException: MidiReleaseTimedOutOnDriver where the release waited behind a probe hung in a classic MIDI driver, MidiReleaseTimedOut otherwise. MIDI input resumes at once where the worker had begun closing it, and the worker undoes whatever it still takes when it gets through. PadForge also asks for a restart of the MIDI service then (AskForServiceRestart), only when the release's worker has itself been at work for 15 s (ConnectTimeoutMs), and except where the release only waited behind a hung probe, whose own recovery already decides that.
Once the uninstaller has exited, whatever its result, or never started, EndUninstall ends the hold this call took. The latch lifts when that was the last release holding it (MidiVirtualController.EndUninstallHold, which resets availability), and ResumeMidiInputs() follows. The next probe takes the in-box API where Windows has it, the runtime again if it is still installed, else the legacy API. An uninstaller still running after its wait ends its own hold when it exits (LiftWhenExited) and never before, since a probe then could load the files it is deleting. The card offers no Uninstall and no Install until it has exited (MidiRuntimeSwitch.UninstallerRunning), because Windows Installer runs one installation at a time.
SteamVR (Steam-free install)¶
The VR slot type (issue #49) needs SteamVR present, not the Steam client. DriverInstaller installs it from Valve's own steamcmd, which licenses app 250820 anonymously.
InstallSteamVRAsync()¶
NormalizeSteamVrDir trims the argument and drops trailing separators, falling back to SteamVrInstallDir (C:\SteamVR) for null or blank. Two guards then refuse outright: a relative path (the payload would land relative to steamcmd's own working directory) and a bare drive root such as C:\ (a payload at a drive root is never legitimate, and it would arm the uninstall side with a recursive delete of the whole drive).
After the guards, steamcmd.zip is downloaded from steamcdn-a.akamaihd.net/client/installer/steamcmd.zip into %TEMP%\PadForge_SteamCmd\, extracted there, and run as:
That command line runs up to three times, and the loop is not defensive padding. The first steamcmd run self-updates, prints "Update complete, launching...", and exits with code 7 without executing any of the + commands, because the relaunch detaches. Exit codes are not trusted at all. The only install verdict is bin\win64\vrpathreg.exe existing under the target directory. Each attempt gets a 60-minute cancellation token, after which the process tree is killed and a DriverCheckException (SteamCmdTimedOut) is thrown. When vrpathreg.exe is still absent after the third attempt, the failure carries the last 400 characters of steamcmd's output.
On success it calls HIDMaestro.HMVR.SetSteamVRPathHint(targetDir), because a Steam-free install writes no registry keys of its own and HIDMaestro's discovery has nothing else to find. Then ContainOwnedSteamVrDataPaths(), then HMaestroVRController.ResetAvailability() so the VR slot gate lifts without waiting out the 5-second availability TTL. Temp cleanup in finally.
ContainOwnedSteamVrDataPaths()¶
Valve's tooling writes openvrpaths.vrpath with the config and log directories as siblings of the runtime, named by appending -config and -logs. At the default install that drops two folders at the root of the system drive, and every OpenVR process reads the same file, so a per-process environment override would only move PadForge's copy.
ShouldContainDataPaths is the pure decision, testable without the registry or the file. It answers true only when a PadForge-owned install exists, the registry's runtime entry names that same directory, and the config and log entries are not already inside it. Past that, the method rewrites %LOCALAPPDATA%\openvr\openvrpaths.vrpath, copying every other key through untouched: external_drivers carries HIDMaestro's own driver registration and dropping it would unregister the VR slots. It also creates the two directories whenever it runs, because steamcmd finishes with validate, which deletes anything in the install that Valve's manifest does not list.
A SteamVR that PadForge did not install is left alone.
SteamVrInstallStopAfterGuards is an internal test seam that throws right after the guards and before any network or process work. Without it, a regressed guard makes the guard tests launch a live steamcmd.
GetOwnedSteamVrDir()¶
Returns the directory of the Steam-free install PadForge owns, or null. Owned means all of:
HKLM\SOFTWARE\HIDMaestro\SteamVRPathexists.- The directory it names really holds
bin\win64\vrpathreg.exe. - No
Uninstall\Steam App 250820entry resolvesInstallLocationto the same directory. Both registry views are checked, because Steam's 32-bit client writes that entry underWOW6432Nodeand a plain 64-bit read misses it. HKLM\SOFTWARE\WOW6432Node\Valve\Steam\InstallPathplussteamapps\common\SteamVRdoes not resolve to the same directory.
A Steam-client install therefore never reads as owned, which is exactly what gates the Uninstall button.
UninstallSteamVR()¶
Throws when no owned install exists, when vrserver is running, and when the recorded path is a drive root. The drive-root check is deliberately independent of the install-side refusal, because the hint is a plain registry value anyone can edit and this is the line that calls Directory.Delete(dir, recursive: true).
Past the refusals it calls OpenVrConsumerService.ReleaseRuntime() first. PadForge loads SteamVR's own openvr_api.dll into this process and caches the handle, so the recursive delete used to skip that one file, report success anyway, and leave openvr_api.dll on disk under a directory the user believed was gone. Windows drops a freed module's lock asynchronously, so DeleteSteamVrFolder tries the delete up to ten times at 300 ms and catches the IOException or UnauthorizedAccessException of a failed try, the tenth included. It then re-checks Directory.Exists(dir) and throws DriverCheckException (SteamVrNotRemoved) when anything survived, because "uninstalled" has to mean the directory is gone.
It clears the SteamVRPath value last and calls HMaestroVRController.ResetAvailability(). The HM driver registration needs no separate cleanup, because vrpathreg's record lives inside the SteamVR install's own config and dies with the directory.
DualShock 3 Bluetooth stack (Ds3DriverInstaller)¶
File: PadForge.App/Services/Ds3DriverInstaller.cs
Namespace: PadForge.Services
An internal static class that installs the Nefarius BthPS3 profile driver and the BthPS3PSM lower class filter so a DualShock 3 can connect over the shared radio, and binds a docked DS3 to WinUSB so its magic reports can be sent. Three of those reports are missing from the pad's HID descriptor, so HidUsb cannot carry them: 0xF4 enables reporting, and 0xF2 and 0xF5 are the sixpair pair (read the pad's own MAC, write the radio's). Same sequence BthPS3's own MSI performs, driven from the always-elevated app with the drivers embedded. No MSI, no DsHidMini.
EnsureInstalled()¶
Called from Ds3PairingService, whose pairing ceremonies run it under the service's radio gate. Idempotent: when BthPS3 is already a real service it reconciles the consumer registry values, repairs the PSM filter if its control device is missing, and re-arms patching. It also upgrades an older install in place: when the bundled BthPS3.inf carries a newer DriverVer than the installed BthPS3.sys (the bundle is 3.2.1.2117), UpgradeInstalledDriversIfOlder re-runs the three INFs, cycles the radio, and rewrites the consumer values. It does so only while TryProbeBthPs3Children finds no device on the BTHPS3BUS enumerator, that is, no controller connected through BthPS3 (MayReplaceDriversNow), since the radio cycle tears a connected one down, and under 2.12.0 to 3.2.0 that teardown is the 0x10D bugcheck of nefarius/BthPS3#182. A probe that fails counts as a connected controller.
A pad already paired is rarely paired again, so a machine kept the driver installed at its last pairing. Launch now runs the upgrade too (Ds3PairingService.UpgradeDriversAtStartup, under the same radio gate, before the startup PSM reconcile). Ds3DriverInstaller.UpgradeAtStartupIfOlder proceeds when the service is installed, DsHidMini is not (its setup owns the stack it shares), and the bundle is newer (ShouldUpgradeAtStartup). It compares the versions from the embedded INF (BundledBthPs3Version) before extracting anything, then refuses new PlayStation links (RequestPsmPatching(false)), so none forms between the probe and the radio cycle. The startup reconcile that follows arms the filter for whichever drivers are in place. HIDMaestro's launch-time InstallDriver is the precedent.
The "already installed" probe is IsServiceInstalled, which requires ImagePath under SYSTEM\CurrentControlSet\Services\{name}, not merely that the key exists. Any write under Services\BthPS3\Parameters creates the parent on the way down, so a settings write against a driver that was not installed yet left a key that looked installed to a null check and permanently blocked the install. HasOrphanedBthPs3Key() detects that exact damaged shape (key present, ImagePath absent) and deletes it before installing.
The install itself, when the service is absent:
InstallInfthe filter (BthPS3PSM\BthPS3PSM.inf), which usesDevcon.InstallfromNefarius.Utilities.DeviceManagement.DeviceClassFilters.AddLower(BluetoothClass, "BthPS3PSM")registers it as the Bluetooth-class lower filter.CycleBluetoothRadiore-enumerates the radio so the filter attaches.InstallInfthe profile driver (BthPS3\BthPS3.inf) and the raw-PDO placeholder (BthPS3\BthPS3_PDO_NULL_Device.inf). Each INF names anamd64and anarm64source folder, and Windows installs the binary that matches the machine.EnsureConsumerParams()writesRawPDO=1,ExclusivePDO=0, andAutoEnableFilter=0.EnableBthPs3ServiceadvertisesBthPS3Service, which spawns the profile PDO. One retry after a fresh radio cycle when the advertisement fails.- Wait for the
BthPS3service to appear (10 s, then a radio cycle and 15 s more), because PnP creates it asynchronously when the advertised PDO matches the INF. - Write the consumer params again now that the service key genuinely exists, re-enumerating the radio when they changed, then arm PSM patching.
Every step logs through the injected Action<string>, and the whole body is wrapped in one try/catch that returns false on any throw.
Radio re-enumeration¶
UsbPnPDevice.CyclePort() (IOCTL_USB_HUB_CYCLE_PORT) first. When the hub refuses it, the radio devnode is disabled and re-enabled instead. The fallback is not theoretical: on a MediaTek MT7925 the port cycle failed, the filter never attached, and the install stayed broken until the adapter was toggled by hand.
The finally block does two things that callers depend on. It waits up to 20 s for a radio handle to be obtainable again, because a cycle returns before the radio is back and the very next step needs one. It then sleeps 3 s past that, because on a fast radio (Intel AX211) CyclePort returns while the old radio and filter instances still answer, so a probe in that window passes against a dying instance and anything armed on it evaporates.
_cycleLock serializes the cycle primitive itself, including the calls driver installation makes outside the pairing service's radio gate. It is part of the mitigation for the July 9 bugcheck (#204) and does not control BthPS3's callback lifetimes. A transient PDO's removal is left to BthPS3's disconnect path, because forced removal adds teardown churn to the operation investigated after that crash. The one exception is RemoveStaleBthPs3Children: with DsHidMini absent, it removes a BthPS3 child that cannot start (CM problem 19 or 28, a devnode that kept an uninstalled driver's configuration), and BthPS3 re-creates it raw on the pad's next page. A healthy or driver-bound child is never removed.
WinUSB package signing¶
PadForge signs the DS3 WinUSB package on the machine that installs it, the same approach HIDMaestro takes for its own drivers. Shipping a pre-signed catalog is what broke: the one previously in the repo was signed by a prototype certificate that existed on one developer machine.
| Member | Behavior |
|---|---|
EnsureSigningCertificate() |
Finds or creates a CN=PadForge DS3 WinUSB code-signing certificate in LocalMachine\My, ten-year validity, Code Signing EKU. Re-imported with PersistKeySet \| MachineKeySet so signtool can read the private key, then added to My, Root, and TrustedPublisher. Returns the thumbprint. |
SignDriverPackage(dir, catalogName, log) |
Deletes stale *.cat, runs Inf2Cat.exe /driver:"{dir}" with /os:10_X64, or /os:10_RS3_ARM64 on an ARM64 machine (CatalogOs, from RuntimeInformation.OSArchitecture), then signtool sign /sm /s My /sha1 {thumb} /fd SHA256 on the named catalog. Inf2Cat has no 10_ARM64 value: handed one, it prints "Operating systems parameter invalid." and exits -1. 4.5.3 and earlier passed it and never made an ARM64 catalog. Tools come from HIDMaestro.Internal.DriverBuilder.EnsureExtracted(). Always regenerates, because a catalog left by an earlier run is validly signed and would still chain while covering a stale INF. Serialized on _signLock, and shared by the DS3 package and every VendorUsbDriverInstaller package under the one certificate. SignWinUsbPackage(dir, log) is the DS3 call, with ds3_winusb.cat. |
IsCatalogTrusted(cat, out signer) |
Builds an X509Chain over the catalog's signer with RevocationMode.NoCheck. Checked after signing as the proof that signing worked. IsWinUsbPackageTrusted(out signer) is the DS3 call. |
RunTool drains stdout and stderr asynchronously with a 120 s timeout and kills a timed-out child, because a synchronous ReadToEnd on one stream deadlocks once the child fills the other stream's pipe buffer, and both tools write warnings to stderr as a matter of course.
EnsureWinUsbBound()¶
public static bool EnsureWinUsbBound(Action<string> log, CancellationToken ct)
public static bool EnsureWinUsbBound(
Action<string> log, CancellationToken ct, string pidToken, string padLabel)
The two-argument overload delegates to the four-argument one with Ds3PidToken (PID_0268) and the label "DS3". The Navigation controller (NavPidToken, PID_042F) takes the same ceremony through the same code: it is a DS3 in a smaller shell, with the same 0xF2 / 0xF5 reports equally absent from its descriptor. One call binds only the pad it names, so the ceremony for one never drags the other off its inbox driver.
The docked pad decides, never the interface registry. A DS3 carries no USB serial, so every port is its own devnode, and an interface registration living on some other node kept the GUID present while the live pad sat on HidUsb.
ListSonyUsbNodes(pidToken) enumerates present USB\VID_054C&{pidToken} nodes across all four classes such a pad can occupy (Ds3HostClasses: HIDCLASS on inbox HidUsb, UNKNOWN with no driver, USBDEVICE under a WinUSB-class INF, USB for composite parents) and reads each one's DEVPKEY_Device_Service. A node on any service other than HidUsb, WINUSB, or empty belongs to a third-party driver and is left strictly alone. When every node already reports WINUSB and HasActiveWinUsbInterface(pidToken) sees an SPINT_ACTIVE registration of {B35924D6-3E16-4A9E-9782-5524A4B79BAC}, the pad is already ours and the call returns true.
Otherwise it signs the package, verifies trust, InstallInfs ds3_winusb.inf, then forces the bind with UpdateDriverForPlugAndPlayDevices(IntPtr.Zero, @"USB\VID_054C&" + pidToken, infPath, INSTALLFLAG_FORCE | INSTALLFLAG_NONINTERACTIVE, out _). The forced call is required because the store install applies by driver ranking, and ranking prefers inbox WHQL HidUsb over an Authenticode-only package, so on a strict-ranking machine the plain install silently applies nothing. It then polls up to 20 times at 250 ms for the live nodes to report WINUSB with an active interface. LastWinUsbFailure records "sign-failed" or "driver-untrusted" so the pairing dialog reports the actual cause.
PSM patching¶
| Constant | Value |
|---|---|
| Control device | \\.\BthPS3PSMControl |
IOCTL_BTHPS3PSM_ENABLE_PSM_PATCHING |
0x2AAC04 |
IOCTL_BTHPS3PSM_DISABLE_PSM_PATCHING |
0x2AAC08 |
Both take a 4-byte { ULONG DeviceIndex } input, indexing the filter's per-radio collection. A bad index completes with STATUS_NO_SUCH_DEVICE, surfaced as ERROR_NO_SUCH_DEVICE (433), which ends the multi-radio sweep.
EnsureConsumerParams() writes AutoEnableFilter=0, which turns off BthPS3's own arming from its next load (the driver's default is on), so PadForge's policy decides when patching is armed. The running driver keeps its cached setting, and another consumer can still request patching. Patching rewrites new L2CAP connection requests, not existing channels or callbacks, so keeping it off while no PS3-family pad is in play reduces exposure to new connections without draining existing ones (issue #199, #204).
Both the raw-PDO pair and AutoEnableFilter are skipped entirely when IsDsHidMiniInstalled() is true. RawPDO=1 makes BthPS3 enumerate its DS3 children with no function driver, which is what PadForge needs and what DsHidMini cannot live with, since its INF binds a UMDF stack to that same child. App.xaml.cs also calls Ds3PairingService.ReconcilePsmPatchForCrashSafety("startup") on a background task at every launch. Without DsHidMini, PadForge takes ownership and arms patching only when a PS3-family pad is paired or the machine carries a DualShock 3, PS Move or Navigation controller device node, present or not. With DsHidMini installed, PadForge removes its AutoEnableFilter override and keeps patching armed, because DsHidMini's pads connect only while it is.
Extraction¶
Copies every manifest resource whose name starts with BthPS3. to %TEMP%\PadForge\BthPS3Drivers\, turning the LogicalName back into a relative path. Cached in a static after the first call, re-extracting only if the directory has since vanished. Nothing deletes it: the packages are re-read on every repair and every WinUSB bind.
Removal¶
Settings, BthPS3 Bluetooth Driver, Uninstall removes the stack. PadForge installs it without BthPS3's setup, so Windows lists no program to uninstall, and nothing else removes it. BthPS3Removal (PadForge.App/Services/BthPS3Removal.cs) reads the PC into a MachineState and plans from it with Plan, a pure function: BthPS3's own setup's product (found by its display name, Nefarius BthPS3 Bluetooth Drivers, in the machine's uninstall list), the BthPS3PSM class filter, the BthPS3 and BthPS3PSM services, the bthps3.inf, bthps3_pdo_null_device.inf and bthps3psm.inf packages in the driver database, the two binaries under System32\drivers, PadForge's ds3_winusb.inf packages and its signing certificate. UninstallBthPS3Dialog shows the plan from Preview, and Run(stopEngine) stops the engine, reads the PC again and removes what it finds.
The steps follow BthPS3's own removal, UninstallDrivers in BthPS3Installer/CustomActions.cs, in its order: the filter's class registration, the profile service's advertisement withdrawn through a radio that is on (an off radio is switched on for it, as HostRadio's constructor does, and switched off again at the end), the radio off with IOCTL 0x411184 and state 4, the radio device restarted with the port cycle CycleBluetoothRadio uses and a wait for its interface to leave and return, each package deleted from the store (DriverStore.RemoveDriver), and the radio on with state 2. When BthPS3's own setup installed the stack, its uninstaller runs first (msiexec /x {product} /qn /norestart through RunTool), and the steps then act on what it left. That setup leaves the two services and their binaries, which its INFs copy to System32\drivers (DefaultDestDir = 12). PadForge deletes the services through the Service Control Manager and the binaries, and Windows finishes either at its next start while the driver is still loaded. The WinUSB packages go through Devcon.DeleteDriver (DiUninstallDriver, then SetupUninstallOEMInf), so a docked controller gets back the driver Windows gives it, as VendorUsbDriverInstaller.Restore gives a node back. The certificate leaves My, Root and TrustedPublisher with its private key, and stays while another package in the driver database names it as signer.
The stack stays while IsDsHidMiniInstalled is true, since DsHidMini needs it, and the preview lists it as kept. A removal that would take the stack is held while TryProbeBthPs3Children finds a controller on BTHPS3BUS or fails, the gate MayReplaceDriversNow keeps for the driver update: the radio restart tears a connected controller down, which is the 0x10D bugcheck under BthPS3 2.12.0 to 3.2.0 (nefarius/BthPS3#182). What the PC holds after the steps decides each item's outcome. Each step that changes the PC asks OffLimitsForTest first (TestHostFenceTests), and BthPS3RemovalTests runs the plan and the run against fakes. The Settings card reads IsAnyBthPs3DriverInstalled, two registry reads, on the driver status timer.
Controllers Windows leaves without a driver (VendorUsbDriverInstaller)¶
Files: PadForge.App/Services/VendorUsbDriverInstaller.cs (PadForge.Services), PadForge.App/Common/Input/VendorUsbBindingService.cs (PadForge.Common.Input)
The SDL fork reads many of the controllers of hifihedgehog/SDL#33 through libusb, and on Windows libusb reaches such a device only while WinUSB, libusbK or libusb0 serves it. WinUSB is the one Windows ships. Windows gives these devices no driver, or its generic HID driver, which reads nothing from them. VendorUsbDriverInstaller binds WinUSB to them the way Ds3DriverInstaller binds a docked DualShock 3, and binds Windows' own Xbox 360 driver, xusb22, to four Xbox 360 devices that xusb22.inf does not name. Once either driver serves a device, SDL opens it at its next device change.
The packages¶
Each binding gets its own package, written and signed on this machine when it binds:
| Driver | INF and catalog | Install sections | Class |
|---|---|---|---|
| WinUSB | padforge_vendorusb.inf, padforge_vendorusb.cat |
Include = winusb.inf with Needs = WINUSB.NT and WINUSB.NT.Services. [USB_Install.HW] writes DeviceInterfaceGUIDs = {FD826C66-3556-4845-9436-0FE7580DED02}, which marks the node as PadForge's and lets libusb open an interface of a composite device |
USBDevice |
| xusb22 | padforge_xusb22.inf, padforge_xusb22.cat |
Include = xusb22.inf with Needs = CC_Install and CC_Install.Services, the sections xusb22.inf installs every device it names through |
XnaComposite, with a [ClassInstall32] for a PC that never installed xusb22.inf |
Each INF names exactly one ID. For most targets it is the node's own, the VID and PID of a device node plus MI for an interface of a composite device, as libwdi writes one INF per device. The original Xbox class binding names the compatible ID USB\Class_58&SubClass_42 instead, and the Xbox 360 pad and receiver opt-ins name the ID with its revision, such as USB\VID_045E&PID_028E&REV_0114. A fixed INF naming a whole-device ID would also match the composite parent when Windows lists the device as composite, and usbccgp is never a state the binder takes a node from. Both INFs carry NTamd64 and NTarm64 models. BuildInf and BuildXusbInf keep only printable ASCII in the device name, dropping quotes and percent signs, and write ASCII with CRLF line ends.
Targets¶
Plan(UsbNode) returns the binding a node needs, or null:
- xusb22. A device node with no driver whose ID is in
Xusb22Targets: the clone Xbox 360 wireless receivers 045E:0291, 045E:02A9 and 05C6:9244, and the Guitar Hero Live Xbox 360 dongle, 1430:070B. - Original Xbox, by class. A node whose compatible IDs include
USB\Class_58&SubClass_42, from no driver only. The package names that compatible ID itself, so Windows binds any later XID device to it on its own. - WinUSB targets. A node on no driver or
HidUsbthat matches a row ofTargets, the fork's vendor-USB table, by device node, interface number, any interface, or an interface's compatible ID: the Wii U GameCube adapter, the Xbox 360 Big Button receiver, the Gametrak, the DJI RC's interface 1, 14 I-Force wheels and joysticks, the GunCon 2 (never the GunCon 3, 0B9A:0800, which SDL does not read), five train controllers, the Namco USIO, the Konami P3IO and P4IO, the CH Products Multi-Function Panel, the Ergodex DX1's interface 1, the TrackIR 2 and 3, and the Tacx T1904 and T1932. - Original Xbox, by ID. A device node on no driver whose VID and PID are among
XidIdentities, the 63 rows of the fork's XID table plus the Steel Battalion, 0A7B:D000, for a device Windows lists without the class 0x58 compatible ID.
A node on any other driver, WinUSB and xusb22 included, is left alone.
Bind()¶
internal static bool Bind(BindPlan plan, string instanceId, IReadOnlyList<UsbNode> nodes,
Action<string> log, CancellationToken ct, string[] from = null)
- Refuses the ID when any present node it names sits outside
fromand is not already on the target driver.fromdefaults to no driver andHidUsb, or no driver alone for xusb22.UpdateDriverForPlugAndPlayDevicestakes every node the ID names, so a second unit of the same model on another vendor's driver would otherwise go with it. - Writes the INF to
StagingDirectory(BindId), or toStagingDirectory(BindId + "_xusb22")for an xusb22 package, each package in its own folder, because Inf2Cat catalogs every INF it finds in one. - Signs the package through
Ds3DriverInstaller.SignDriverPackagewith this machine's certificate, fresh on every bind, and checks the chain withIsCatalogTrusted. Devcon.Install(DiInstallDriver) stocks the driver store, so a later plug-in with no driver takes the package on its own.UpdateDriverForPlugAndPlayDeviceswithINSTALLFLAG_FORCE | INSTALLFLAG_NONINTERACTIVEbinds the node now, because driver ranking prefers an inbox driver,HidUsbhere, over a package signed on this machine.- Polls up to 20 times at 250 ms. Done means the node reports the service,
WINUSBorxusb22, and an active interface of the driver's GUID sits on it: PadForge's{FD826C66-...}for WinUSB, and for xusb22 the XUSB interface{EC87F1E3-C13B-4100-B5F7-8B84D54260CB}that XInput walks.HasActiveInterfacematches an interface path to the node by its instance ID, with#for each backslash.
VendorUsbBindingService¶
InputManager.InitializeSdl starts it with the other side-band services, and ShutdownSdl stops it. A background thread named VendorUsbBinder lists the present USB nodes every 3 s in one SetupAPI pass (ListPresentUsbNodes: the USB enumerator, all classes, as libwdi lists devices), plans each one, and binds each planned ID once per sweep. An ID is bound at most once every 30 s, and after three failed binds the session stops trying it. The nodes that need a driver go to the diagnostics log as VendorUSB: needs a driver:, only when that list changes. Nothing here talks to SDL.
Opt-in bindings¶
Five kinds of device lose something to WinUSB, so Plan never returns them. OptIns lists them for the Devices page:
| Kind | Node | Bound from | What the switch costs |
|---|---|---|---|
Xbox360Pad |
045E:028E, REV_0110 or REV_0114 |
xusb22 | The pad leaves XInput, DirectInput and Windows.Gaming.Input and gives input only while PadForge runs |
Xbox360Receiver |
045E:0719, REV_0100 |
xusb22 | Every controller and headset on the receiver moves with it |
IntelBaseStation |
8086:C013, interface 0 or the device | no driver, HidUsb |
The base station's keyboard stops typing in Windows |
Prodikeys |
041E:2801, interface 1 | no driver, HidUsb |
The media and sleep keys stop working |
TouchSenseMouse |
046D:C030, 046D:C031 or 046D:C032, the device | HidUsb |
Windows stops using the mouse as a pointer. PadForge moves the pointer from it only while it runs and the mouse is assigned to a Keyboard + Mouse virtual controller |
A TouchSense mouse (#494), a Logitech iFeel Mouse or iFeel MouseMan, is bound so that PadForge can play rumble on its buzz motor (Peripheral Outputs Internals). Three rules are its own:
- It is offered the bind only when its own mouse collection declares the buzz report (
TouchSenseBinding.NodeDeclaresBuzz): one output value of seven bytes on usage page0xFF00, usage 2, with no report ID and no feature report, read from what hid.dll already parsed, through an open with no access right. Only the iFeel Mouse's descriptor is on record, so a sibling's IDs alone do not say that it takes the frames. That is also why the kind is bound fromHidUsbalone: a node with no driver has no mouse collection to read. - It is bound one device at a time (
VendorUsbDriverInstaller.BindsOneDeviceAtATime). The forced update takes every present node the plan's ID names, whichever one was chosen.WhyNotBindtherefore answersBindRefusal.OthersPluggedInwhile another mouse of the model sits on Windows' driver, the bind is refused before the confirm and again after it, and the status bar showsStatus_DriverBindOthersPluggedIn. Its confirm speaks of the one mouse the user chose, and a second one would stop being a pointer unasked. - PadForge holds the bound mouse's one WinUSB handle itself, and a handle held open keeps Windows from restarting the node.
VendorUsbDriverInstaller.LetGoOfcloses it before Windows is asked to change the node's driver and keeps the mouse closed until the wait for the new driver is over (TouchSenseChannels.Hold), since the device pass looks for bound mice every second and would open the mouse again in the middle of the switch.
DevicesViewModel.RefreshDriverOffer asks QueryOptIn when a device is selected, and sweeps the USB nodes only when IsOptInRow says the row's IDs belong to an opt-in device. The answer is a bind offer when a node sits on a driver its target is bound from, or a restore offer when a node is on WinUSB with PadForge's interface active. A pad on the wireless receiver reports the wireless controller's 045E:02A1, so a row with that ID stands for the receiver. Before a bind, MainWindow.SwitchDriverAsync states the cost in a ConfirmDialog, then calls Bind with the target's From list. A restore calls Restore with no prompt. A bind adds the ID to AppSettingsData.WinUsbOptIns, and a restore removes every ID that names the node.
Restore(instanceId) reads the node's DEVPKEY_Device_DriverInfPath and DEVPKEY_Device_DriverProvider, refuses a package whose provider is not PadForge, and deletes it with Devcon.DeleteDriver, which calls DiUninstallDriver to install the best remaining driver on every device the package served, then SetupUninstallOEMInf to take the package out of the driver store. Restore then polls for the node to leave WinUSB.
MainWindow.CheckOptedInDrivers runs 8 s into each start. MovedBack lists the opted-in IDs whose node Windows has put back on the driver it was taken from, from its INF directory or Windows Update, and the status bar names them.
Limits¶
- Nothing here ran on hardware. The bench had none of the targets.
- ARM64 Windows 11 carries xusb22. The file list of cumulative update KB5124008 for version 24H2 on ARM64 names xusb22.sys 10.0.26100.9278 and an xusb22.inf of 6,304 bytes, the size of the x64 one. No ARM64 PC has run the package.
- A chatpad or uDraw on a clone receiver stays unread. The clone receivers get xusb22, and the fork's accessory plan names no WinUSB binding for them.
Legacy v2 driver cleanup¶
PadForge v2 used ViGEmBus (Xbox / DS4 virtuals) and vJoy (everything else) as separate drivers. HIDMaestro replaces both. To keep upgrading users from carrying around dead drivers, MainWindow.xaml.cs::MaybeOfferLegacyDriverCleanup() runs once on first launch after the upgrade.
First-launch dialog¶
The dialog only fires when both:
_viewModel.Settings.LegacyDriverCleanupOfferedisfalse(per-user once-only flag persisted inPadForge.xml), and- At least one of
DriverInstaller.IsExtendedInstalled()(vJoy) orDriverInstaller.GetViGEmVersion() != null(ViGEmBus) returns truthy.
If neither legacy driver is detected, the flag is flipped to true and the offer is silently skipped. Otherwise PadForge raises a Wpf.Ui.Controls.MessageBox titled "Legacy Driver Cleanup", listing the detected legacy drivers, and offers Uninstall / Keep buttons. On Uninstall, UninstallViGEmBus() and UninstallVJoy() run on a worker thread through RunDriverOperationAsync (the "Removing legacy drivers…" overlay), each gated on its own detection result (if (hasViGEm) / if (hasExtended)) and each inside its own try/catch. They used to run inline on the dispatcher, which froze the window for the whole removal. Because each call has its own try block, a throw from UninstallViGEmBus() no longer skips UninstallVJoy(). Every caught failure is worded by DescribeDriverFailure and collected, and the collection feeds one follow-up "Cleanup encountered an error" dialog. The flag is flipped to true afterward regardless of outcome, including a caught uninstall failure, to avoid re-prompting on every launch.
The whole entry point is wrapped in a top-level try/catch that swallows everything because it runs as async void from the dispatcher. An unhandled exception there would surface as a generic "unexpected error" dialog at startup. On detection failure, the flag is not flipped, so the next launch retries.
Detection¶
IsExtendedInstalled() (vJoy)¶
Two-path detection:
| Path | Method | Detail |
|---|---|---|
| Primary | Check C:\Program Files\vJoy\vjoy.sys |
File existence test. Catches v2 PadForge's minimal SetupAPI install. |
| Fallback | GetVJoyVersionFromRegistry() |
Scans Uninstall keys (Registry64 + Registry32) for "vJoy" in DisplayName. Catches legacy Inno Setup installs. |
GetViGEmVersion()¶
Scans Uninstall keys (Registry64 + Registry32) for "ViGEm" in DisplayName (case-insensitive). Returns DisplayVersion, or null if not found.
UninstallViGEmBus()¶
Calls FindUninstallProductCode("ViGEm") to look up the MSI ProductCode GUID. If found, runs msiexec /x {ProductCode} /qb /norestart via RunMsiElevated. No-op if no ProductCode is found.
Registry-based lookup means PadForge does not have to embed the 6 MB ViGEmBus installer. The MSI is already on the user's machine via Windows Installer cache.
UninstallVJoy()¶
Builds a .cmd script at %TEMP%\PadForge_vjoy_uninstall.cmd and runs it via RunElevated("cmd.exe", "/c \"{scriptPath}\""), deletes the script file with a best-effort try/catch after RunElevated returns (not a finally), then calls CleanExtendedRegistryArtifacts() in-process for the registry mop-up.
The script is generated dynamically because the OEM .inf names are determined at runtime by FindExtendedOemInfs().
flowchart TD
A[FindExtendedOemInfs<br/>detect OEM .inf files] --> B
B[Generate PadForge_vjoy_uninstall.cmd] --> C
C["RunElevated cmd.exe /c script<br/>(elevation inherited from PadForge)"]
subgraph CMD["Batch Script Execution"]
direction TB
U1["pnputil /remove-device ROOT\\HIDCLASS\\NNNN /subtree<br/>for NNNN in 0000..0015"] --> U2
U2["timeout /t 2"] --> U3
U3["sc stop vjoy"] --> U4
U4["timeout /t 2"] --> U5
U5["sc delete vjoy"] --> U6
U6["reg delete HKLM\\SYSTEM\\{CurrentControlSet,ControlSet001..003}\\Services\\vjoy /f<br/>(belt-and-braces if sc delete returned STOP_PENDING)"] --> U7
U7["pnputil /delete-driver oemNN.inf /uninstall /force<br/>per stale OEM inf"] --> U8
U8["rmdir /s /q C:\\Program Files\\vJoy<br/>del System32\\drivers\\vjoy.sys"] --> U9
U9["powershell one-liner: scan HKLM Uninstall + WOW6432Node Uninstall<br/>for DisplayName -like '*vJoy*' and Remove-Item -Recurse -Force"]
end
C --> CMD
CMD --> E[Delete .cmd file]
E --> D[CleanExtendedRegistryArtifacts<br/>C# method, no further elevation needed]
Step ordering matters: device nodes are removed before sc stop so the driver can fully unload from the kernel. Stopping the service while devices are still attached leaves it in STOP_PENDING, which is irrecoverable without a reboot.
CleanExtendedRegistryArtifacts()¶
Removes registry keys that can cause a reinstall of vJoy to hang. Best-effort (throwOnMissingSubKey: false, per-key try/catch).
HKLM paths deleted:
| Registry Path | Purpose |
|---|---|
SYSTEM\CurrentControlSet\Services\vjoy |
Service entry (current) |
SYSTEM\ControlSet001\Services\vjoy |
Service entry (ControlSet001) |
SYSTEM\ControlSet002\Services\vjoy |
Service entry (ControlSet002) |
SYSTEM\ControlSet003\Services\vjoy |
Service entry (ControlSet003) |
SYSTEM\CurrentControlSet\Control\MediaProperties\PrivateProperties\Joystick\OEM\VID_1234&PID_BEAD |
Joystick OEM properties |
SYSTEM\ControlSet001\Services\EventLog\System\vjoy |
Event log registration |
SOFTWARE\Microsoft\Windows\CurrentVersion\Setup\PnpLockdownFiles\%SystemRoot%/System32/drivers/hidkmdf.sys |
PnP lockdown entry |
SOFTWARE\Microsoft\Windows\CurrentVersion\Setup\PnpLockdownFiles\%SystemRoot%/System32/drivers/vjoy.sys |
PnP lockdown entry |
SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Setup\PnpLockdownFiles\%SystemRoot%/System32/drivers/hidkmdf.sys |
Same under WOW6432Node |
SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Setup\PnpLockdownFiles\%SystemRoot%/System32/drivers/vjoy.sys |
Same under WOW6432Node |
HKCU path deleted:
| Registry Path | Purpose |
|---|---|
System\CurrentControlSet\Control\MediaProperties\PrivateProperties\Joystick\OEM\VID_1234&PID_BEAD |
User-level joystick OEM properties |
After the path list, calls CleanExtendedDeviceClassEntries().
CleanExtendedDeviceClassEntries()¶
Enumerates subkeys under HKLM\SYSTEM\ControlSet001\Control\Class\{781ef630-72b2-11d2-b852-00c04fad5101} (the shared HID device class key) and deletes any whose Class value equals "vjoy" (case-insensitive). Best-effort. Exceptions caught silently.
FindExtendedOemInfs()¶
Runs pnputil.exe /enum-drivers (30s timeout, output captured), then walks the output line-by-line tracking the most recent oemNN.inf value and flagging each block that mentions "shaul" or "vjoy" (case-insensitive). The parser keys on the oemNN.inf value with a regex, never on the Published Name label. pnputil localizes that label, and the old label-keyed parse returned nothing on German or Japanese Windows, so legacy drivers went undetected there. Returns matching oem*.inf names. Returns an empty array on any error. Used by UninstallVJoy() to know which pnputil /delete-driver lines to emit.
HidHide Runtime API (HidHideController)¶
File: PadForge.App/Common/HidHideController.cs
Namespace: PadForge.Common
Runtime device management (blacklisting, whitelisting, cloaking) communicates directly with the HidHide control device (\\.\HidHide) via P/Invoke IOCTLs.
IOCTL Codes¶
| IOCTL | Code | Direction | Purpose |
|---|---|---|---|
GET_WHITELIST |
0x80016000 |
Read | Get whitelisted application paths |
SET_WHITELIST |
0x80016004 |
Write | Replace whitelisted application paths |
GET_BLACKLIST |
0x80016008 |
Read | Get blacklisted device instance IDs |
SET_BLACKLIST |
0x8001600C |
Write | Replace blacklisted device instance IDs |
GET_ACTIVE |
0x80016010 |
Read | Get cloaking active state (1 byte) |
SET_ACTIVE |
0x80016014 |
Write | Enable/disable cloaking (1 byte) |
GET_WLINVERSE |
0x80016018 |
Read | Get the inverse application cloak flag (1 byte). HidHide 1.2 and later, and an older driver rejects it. |
Buffer Format¶
GET/SET list operations use Multi-SZ format: null-separated UTF-16 strings with double-null terminator. SET replaces the entire list (not append).
Public API¶
static bool IsAvailable() // Can open \\.\HidHide
static bool TryProbe(out int win32Error) // The same open, with the Win32 error on failure
static List<string> GetBlacklist() // Device instance IDs, or NULL when the driver could not be read
static bool SetBlacklist(List<string> ids) // Replace entire blacklist, false when the driver refused
static List<string> GetWhitelist() // DOS device paths, or NULL on a failed read
static bool SetWhitelist(List<string> paths) // Replace entire whitelist, false when the driver refused
static string DescribeReach(params string[] exeNames) // Diagnostics snapshot: driver reachable, active flag, whitelist size, per-name listing
static bool GetActive() // Cloaking enabled?
static bool TryGetActive(out bool active) // The same read, false when the IOCTL failed
static void SetActive(bool active) // Enable/disable cloaking
static bool TryGetInverse(out bool inverse) // Inverse application cloak flag, false when the IOCTL failed
static bool RemoveWhitelistEntries(IEnumerable<string> paths) // Take the named entries off the list and leave the rest
static string CurrentProcessNativeImagePath() // This process's image as \Device\HarddiskVolumeN\..., the form the driver records
static void RemoveManagedDevices() // Remove only PadForge's entries
static int AdoptExistingAsManaged() // Seed the managed set from the driver's list, -1 on a failed read
static bool SyncManagedDevices(HashSet<string> desiredIds) // Diff-based blacklist sync against the driver's own list
static bool SyncManagedDevices(HashSet<string> desiredIds,
out List<string> added,
out List<string> removed) // The same sync, reporting what changed
static bool SyncManagedDevices(HashSet<string> desiredIds,
out List<string> added,
out List<string> removed,
out List<string> heldBack,
bool allowAdditions) // The same sync, also naming the ids it held back
static List<string> MissingFromBlacklist(IEnumerable<string> desiredIds) // Read-back: what the driver does not carry
static void ClearAll() // Clear blacklist + disable cloaking
static List<string> FindInstanceIdsByVidPid(ushort, ushort) // Enumerate HID-class devices by VID/PID (USB + BLE + BT Classic)
static List<string> ExpandToBaseContainerAndChildren(string hidInstanceId)
static List<string> ExpandToBaseContainerAndChildren(
string hidInstanceId, Func<string, bool> keepOut,
ICollection<string> keptOut) // The expansion with the keep-out set applied
static bool IsHidMaestroDeviceInstance(string id) // True if instance (or an ancestor) is a HIDMaestro virtual
static string DevicePathToInstanceId(string p) // \\?\HID#... -> HID\VID_...\...
static string ToDosDevicePathPublic(string filePath) // C:\... -> \Device\HarddiskVolumeN\...
GetBlacklist and GetWhitelist return null for a failed read and an empty list for a successful read of an empty list. Callers must not conflate the two: every consumer does read-modify-write, so treating a failed read as "the list is empty" writes an empty list back and destroys entries the user set outside PadForge.
Reading the Driver's List¶
GetMultiSzList makes two calls, the shape HidHideCLI's FilterDriverProxy.cpp uses. The first passes no output buffer, which the driver answers with the byte count it needs. The second reads into a buffer of exactly that size.
The earlier guess-and-grow read (4096 bytes, then 65536) failed on every list past 2048 characters. The driver copies with RtlStringCchCopyUnicodeStringEx, whose validator rejects any destination over NTSTRSAFE_UNICODE_STRING_MAX_CCH (32767 characters) with STATUS_INVALID_PARAMETER, and 65536 bytes is 32768 characters. A few pads' worth of expanded instance paths therefore read as "driver unreadable", and the null-bail consumers hid nothing.
An empty list is a two-byte reply, one L'\0'. Zero bytes or an odd count is malformed and reads as a failure.
Managed Device Tracking¶
_managedDeviceIds (HashSet<string>) tracks device IDs PadForge added to the blacklist. RemoveManagedDevices() removes only these entries, leaving entries from other tools untouched.
Startup clear and cloak persistence: _managedDeviceIds is in-memory, so a crashed or force-killed session leaves stale blacklist entries that RemoveManagedDevices() can no longer identify. At engine start InputService.Start clears the list with ClearAll() as the first step of its apply (ApplyDeviceHidingAtEngineStart, ClearCloaksLeftBehind). A device change that arrives while the clear waits on the control device is applied after the start's apply, so a start hands each controller to the release once. The clear is conditional: when the Settings toggle Keep Devices Cloaked Between Launches (KeepHidHideCloaksBetweenLaunches, off by default) is on, startup skips ClearAll() so persisted cloaks survive into the new session with no visible decloak window, and ApplyDeviceHiding's per-device walk re-asserts them idempotently. With the setting on, the first apply also adopts the entries the driver already holds as PadForge's own (AdoptExistingAsManaged, through EnsureHidHideCloaksAdopted), so the sync can still retire a kept cloak the new session no longer wants. A failed read leaves adoption for the next apply. The same flag reaches the shutdown path as RemoveDeviceHiding(keepCloaks: ...).
Start then applies hiding without waiting for the poll thread, which by then has marked few rows online or none (Keep-Out covers what such an apply hides). After a clear, that apply adds the hidden rows' ids again, so each hidden USB controller that is connected goes to the release, which takes it from a program that opened it while PadForge was closed or stopped. With the setting on, the apply finds the kept entries in place, adds nothing, and so sends nothing to the release. The exception is a controller set to hide whose nodes the kept list does not carry, such as one moved to another USB port while PadForge was closed: its nodes are added and go to the release.
The diff is taken against the driver, not the managed set. All blacklist management goes through SyncManagedDevices, which reads the driver's list, adds every desired id the driver lacks, removes every managed id that left the desired set and the driver still carries, and writes the whole list once. The managed set moves to the desired set only after a write the driver accepted, or when there was nothing to write.
Diffing against the in-process managed set alone had two holes. An entry another tool dropped was never re-added, because the managed set still listed it (the HidHide client saves its whole list, and the driver's SET is a full replace). And a SET the driver refused was recorded as landed all the same. Either way the read-back printed MISSING on every apply and nothing fixed it.
A failed read returns false without writing. This method's contract is that it never clears the entire blacklist, and a failed read falling through to SetBlacklist did precisely that.
Merge-based cache: ApplyDeviceHiding uses a merge-based approach for each device's resolved instance ID cache (UserDevice.HidHideInstanceIds). New IDs are added and previously cached IDs are kept, except any that turn out to belong to a HIDMaestro virtual, which are scrubbed. This ensures offline devices that were resolved in a prior cycle remain in the blacklist even if they are not currently enumerable.
VID/PID Format Matching¶
FindInstanceIdsByVidPid() enumerates GUID_DEVCLASS_HIDCLASS present devices and matches instance IDs in three formats:
- USB HID:
HID\VID_045E&PID_0B13\.... StandardVID_XXXX&PID_XXXXpattern. - Bluetooth LE (HID-over-GATT):
HID\VID&0202D0&PID&0101\....VID&02XXXX(USB-assigned VID source) orVID&01XXXX(Bluetooth SIG-assigned), paired withPID&XXXX. - Bluetooth Classic (HID-over-RFCOMM, Profile 0x1124):
VID&0002054Cstyle. A four-hex source (0002= USB-IF-assigned,0001= SIG-assigned) then the four-hex VID, paired withPID&XXXX. Added so DualSense over Bluetooth Classic is picked up by the synthetic-path fallback.
The match requires the PID&XXXX fragment plus any one of the four Bluetooth VID prefixes, or the plain USB VID_XXXX&PID_XXXX. Nodes PadForge does not read as a real device are left out (IsOutsideVidPidLookup): HIDMaestro's own, and nodes PadForge's readers drop.
IsHidMaestroDevice walks from the node up through its parents, and IsHidMaestroChain judges the walk. Three markers count on every node: HIDMaestro in an instance id, in a hardware id, or as the manufacturer. A composite persona carries none of them, since every identifier on it is its model's own. It is told by the usbip-win2 host controller above it, which HIDMaestro stamps with the hardware id ROOT\HIDMAESTRO_UDE and whose service is usbip2_ude. That controller also carries every real pad attached through usbip-win2. Under it, a node at or below a USB device whose instance id ends in HIDMaestro's own serial, HM and twelve hex digits, is a persona whatever else holds (HidMaestroAncestry.IsPersonaUsbInstanceId). Any other node there is a persona only for the vendor and product id of one PadForge can own (InputManager.ChecksUsbipAncestry): live, being created, disposed within the last 10 s, or offered by a composite profile while the launch sweep runs. The host controller and the HIDMaestro ids are read by one rule in the engine, HidMaestroAncestry.Classify, which Step 1's walk and an XInput pad's identity read too, on the same hops.
A node PadForge's readers drop is left out too (HidMaestroAncestry.ReadersDrop). Under the stamped host controller, SDL's HIDAPI and DirectInput backends drop a HID interface whose node or one of its four parents names HIDMaestro in a hardware id, and OpenXInput gives no slot to an XUSB interface whose node or one of its three parents does. So a pad attached through usbip-win2 there has no row and is not offered, unless xboxgip serves it: its XUSB interface sits on the HID node, four nodes below the host controller and past OpenXInput's walk, so it keeps its slot, PadForge reads it, and the lookup offers it.
FindInstanceIdsByVidPid returns HID-class instance IDs only. It does not reach parent or base-container nodes. That widening is the separate ExpandToBaseContainerAndChildren method below.
Base-Container Blacklist Expansion¶
ExpandToBaseContainerAndChildren widens a single HID instance ID into the full set of instance paths HidHide needs to hide a device at its container boundary. It follows HidHide's own BlacklistDlg.cpp:294-345 and adds one step the client cannot express:
- Start with the passed HID instance ID.
- Read its Container ID, then walk parents while the Container ID stays the same, recording each node passed. The last matching parent is the base container. A built-in device reports the system container ID, which every built-in device shares, so its walk is bounded by the node's
VID_xxxx&PID_yyyytoken instead and stops at the USB composite parent. - Blacklist every recorded node between the HID node and the base container whose class HidHide filters.
- Enumerate the base container's immediate children, counting how many are HID-class. If the base container is HIDClass, XUSB, or XboxComposite and every child is a HID, add the base-container instance path too. This blocks the device at the parent boundary so XInput and WGI cannot reach it through a sibling child path.
- Always add every HID-child instance path.
IsHidHideFilteredClass names the three setup classes HidHide's installer registers its upper filter on (HidHideMSI.wxs): HIDClass, XnaComposite (the XUSB class), and XboxComposite (the Xbox One and Series GIP class, 05f5cfe2-4733-4950-a6bb-07aad01a3a84). A blacklisted instance path of any other class is inert, because the driver is not on that stack to see the create.
Step 3 is what the client's own tree cannot name, since it lists HID devices only. On a pad whose XUSB node is an interface of a USB composite parent (the Legion Go's built-in controller, a pad on the Xbox 360 wireless receiver) that node is neither the base container nor an immediate HID child. Without step 3, XInput opened it freely while every HID interface beside it was hidden. The driver honors the path all the same: it is an upper filter on XnaComposite and matches the device's own path on create.
For an Xbox 360 wired controller the base container is XUSB-class with one HID child, so the base container is blocked. A USB-class root with mixed children (a controller that also exposes audio) keeps the base container unblocked and only its HID interfaces are filtered.
ComposeBlacklist is the pure rule behind the walk, taking the chain, the base container, and the children as data so the trees can be pinned in tests without cfgmgr32.
Keep-Out: Rows the User Left Visible¶
A handheld's built-in controller is one USB composite device whose touchpad is a separate row on the Devices page. When the pad's row had hiding on and the touchpad's had it off, the pad's expansion hid the touchpad's interface along with every other HID sibling, then the VID/PID sweep hid its HID node again.
InputService.BuildHidHideKeepOut builds the set once per apply from the device snapshot. Every row with Hide from Games off, that is online, and whose DevicePath resolves to a device node's instance ID (HidHideController.IsDeviceNodeId: any HID collection, or an id that carries a vendor ID) contributes HidHideController.ChainInstanceIds(id): its own HID node plus its same-container ancestors. The expansion of any other record then skips those nodes, and never blocks a base container while one of its HID children is kept out, since blocking the parent would hide that row too.
Online is the test only once the engine has listed its devices (InputManager.DevicesEnumerated, read through InputService.RowsAreCurrent). Until this run's poll thread has finished a device enumeration, and while the engine is stopped, a row that reads offline can be connected all the same. A row with Hide from Games off then contributes its chain whether or not it is online. An offline one leaves out the nodes PadForge already hides (HidHideController.IsManagedDevice), so it holds new hides back and takes none away.
The gates are deliberate. Once the engine has listed its devices, an offline record is a memory, and letting one veto a live pad's sweep would reopen the transport-switch double input. Before the listing nothing says which rows are live. An apply that read every row left visible as gone would hide its nodes, hand them to the release, and show them again once the rows came online, so until the listing a row left visible decides for its nodes. That includes a node a hidden row names as well, as with two pads of one model remembered on one port or the pads of a GameCube adapter. If the hidden row is the live one, the apply that follows the listing hides the node and hands it to the release. Synthetic paths (XInput#N, web://, overlay://) resolve to no HID node and contribute nothing. Interfaces PadForge does not show as rows at all stay with the pad, exactly as before.
Every id the keep-out predicate removed is reported back through keptOut and lands in the apply's diag line.
Releasing a Controller Opened Before Its Hide¶
HidHide decides access only when a handle is opened (Logic.c OnDeviceFileCreate), so a game, Steam or a launcher that opened a controller before PadForge hid it kept it. Windows will not remove a device while any handle stays open, and PadForge's own SDL handle is one, so disabling the device is refused. A hub port cycle (IOCTL_USB_HUB_CYCLE_PORT) is a surprise removal no handle can veto, the way HandheldCompanion releases a hidden pad (IController.CyclePort). The controller comes back through HidHide, where only whitelisted programs can open it.
After an apply whose hide PadForge could reach and HidHide did not refuse, InputService hands the ids that apply added, tablets excluded, to HiddenControllerRelease.Release. Batches run on the thread pool one after another, so the apply never waits on the device tree.
A batch can run long after the apply that queued it, so it decides from what is true when it runs. It reads HidHide's blacklist once (ReadBlacklist) and goes on only for the ids still on it, and it reads PadForge's controller rows per USB device once (ControllerRows, which is InputService.CountControllerRowsNow). A controller shown again in the meantime stays connected.
A batch that cannot decide keeps its ids and asks again every 2 s (RetryMs), and the next batch that can decide takes those ids along. A batch waits that way for up to 10 s (ListingWaitMs) while the engine has started and not yet listed its devices (RowsPending, which is InputService.EngineIsListing). After the 10 s it decides from the rows as they stand, as it does while the engine is stopped. Each engine run has its own 10 s: a batch that finds the engine listed or stopped starts the next run's wait afresh. It also waits while the blacklist does not read and HidHide's control device still answers, for up to 60 s (ListWaitMs) for each id, counted from the first time its batch found the list unread. An id that waits the 60 s out is dropped without a cycle and its hide is left as it is, since the program holding the control device can hold it for hours and a cycle when it lets go would disconnect a controller in use. That is the case while another program holds the control device, which takes one client at a time and refuses a second open with access denied (HidHideArm64Installer.ControlDeviceAnswers). A batch ends without a cycle when the control device does not answer at all, since nothing is hidden then.
For each id still hidden, HidHideController.FindUsbDeviceToCycle walks from the node up to the first node whose service is a USB hub, and PickUsbDeviceToCycle decides on that chain:
| Left alone when | Reason it logs |
|---|---|
A node on the chain is Bluetooth (BTHENUM\, BTHLEDEVICE\, BTHPS3BUS\, BTH\). Dropping the link turns most pads off. |
Bluetooth |
No hub on the chain, or the node below the hub is not USB\ |
not on a USB port |
The USB device is not the controller: another container, or inside the system container, which every built-in device shares, another VID_xxxx&PID_yyyy |
the USB device is not the controller |
| The USB device carries more than one controller: its subtree holds more than one XUSB or XboxComposite node, as on the Xbox 360 wireless receiver, or more than one of PadForge's controller rows sits on it, as on a Steam Controller dongle with two pads. Every pad on it would drop. | the USB device carries more than one controller |
The device arrived less than 5 s before its ids were handed to the release, or after that (ReleaseArrivalGrace, from DEVPKEY_Device_LastArrivalDate). The time is taken at the hand-over, so a batch that waits does not age the device past the grace. Such a hide came from the arrival itself, before any program could open the device. |
connected moments ago |
The controllers a USB device carries are the larger of two counts. CountControllerFunctions counts the XUSB and XboxComposite nodes in its subtree, itself included. InputService.CountControllerRowsPerUsbDevice counts PadForge's controller rows on it, which is what finds a receiver with no Xbox node, whose pads are HID interfaces of the one USB device. A controller row is a gamepad, joystick, wheel, flight stick, first person or supplemental row with a real HID path, and each HID node's count goes to every node on its chain. On a HID node the rows the engine has online count. A node with no row online counts the rows that remember it while Windows lists the node as present (HidHideController.IsInstancePresent), the largest group with one serial number, since rows with different serial numbers are different devices that sat on the node in turn.
Each USB device cycles once per batch and at most once per 30 s (HiddenControllerRelease.CooldownMs): its return arrives as a device change that runs the apply again, and a hide another program keeps taking away would otherwise cycle it in a loop. A cycle the cooldown holds back runs as a batch of its own when the cooldown ends. That batch reads the blacklist and the row count again, so it cycles only for a controller HidHide still hides then. Two cycles in one batch run 500 ms apart, as HandheldCompanion spaces them. The cycle itself is PnPDevice.GetDeviceByInstanceId(id, DeviceLocationFlags.Normal).ToUsbPnPDevice().CyclePort() from Nefarius.Utilities.DeviceManagement. The release keeps the HID ids of each controller it cycled with the time of the cycle (TakeCycledIds), and the self-access check opens the controller once more on its new device stack.
Apply Diagnostics¶
InputService.ApplyDeviceHiding logs its whole decision through SdlDiagLog, every line prefixed HIDHIDE. The path used to be silent end to end, so a "the physical was not hidden" report could not be adjudicated from a trace, and a driver whose control device did not open skipped the block with nothing said while the Settings page read Installed off the MSI registry scan.
| Line | Content |
|---|---|
HIDHIDE UNAVAILABLE |
TryProbe failed while at least one device wanted hiding. Carries the Win32 error. |
HIDHIDE adopted |
How many kept entries the first apply adopted from the previous session, when Keep Devices Cloaked Between Launches is on. |
HIDHIDE apply |
Device count and how many want hiding. |
HIDHIDE keepout |
The keep-out set, when non-empty. The line ends with offline rows included, the engine has not listed its devices yet when the apply ran after an engine start and before the engine listed its devices, and with offline rows included, the engine is stopped while the engine is stopped. |
HIDHIDE dev |
Per device: VID:PID, the resolved instance id, the expansion, the sibling sweep, the sweep decision, and anything the keep-out held back. |
HIDHIDE sync |
Desired count, added, removed, cloaking state, write=REFUSED on a refused SET, held=N with the ids a whitelist fault held back, which the read-back leaves out, and the read-back verdict (readback=ok, readback=FAILED, or readback=MISSING with the ids). |
HIDHIDE inverse application cloak is on |
The inverse flag is set, so PadForge took its own whitelist entries off and hides nothing. cleared=False means the removal failed, which counts as trouble. |
HIDHIDE inverse flag did not read |
The read failed while the last answer was on, so the cloak still counts as on. Counts as trouble. |
HIDHIDE inverse application cloak is off |
The flag read off after reading on: the cloak's status line is taken back, a standing refusal lifts, and PadForge hides and runs the self-check again. |
HIDHIDE whitelist could not be read or written |
SyncWhitelist returned false: the driver's list did not read, or it refused the write, so PadForge may be missing from it and hides nothing new in that apply. Counts as trouble. |
HIDHIDE whitelist changed since HidHide refused PadForge |
The whitelist differs from the one recorded at the refusal, so PadForge hides again and runs the self-check again. |
HIDHIDE reach ok |
The self-check opened a hidden device, with its path. |
HIDHIDE reach ok after the release |
The self-check ran again for a controller the release had reconnected and opened it on its new device stack. |
HIDHIDE reach ok after a reconnect |
The self-check ran again for a hidden controller that had left and come back by itself, and opened it. |
HIDHIDE REFUSED |
The self-check was refused: the path, the Win32 error, how many entries PadForge took back, this process's native image path, and listed=yes, no or unreadable for that path on the whitelist at that moment. Listed and refused means the driver knows this process by another path or not at all. Not listed means the entry never landed. |
HIDHIDE reach unknown |
No hidden HID interface answered the self-check, so it runs again on the next apply. |
HIDHIDE apply unchanged |
The heartbeat, at most once a minute. |
HIDHIDE released |
A hidden controller's USB port was cycled, with the USB device id. |
HIDHIDE release skipped |
A hidden HID collection whose USB device was left alone, with the reason from the release table. |
HIDHIDE release held |
The USB device's port was cycled less than 30 s before, so this cycle runs once the 30 s have passed. |
HIDHIDE release waits |
A batch cannot decide yet, with the reason: the engine has not listed its devices, or HidHide's blacklist did not read. Printed once per reason, and the batch asks again every 2 s. |
HIDHIDE release goes ahead |
The engine had not listed its devices 10 s into this engine run, so the batch decided from the rows as they stood. |
HIDHIDE release dropped |
The batch ended without a cycle: HidHide's driver does not answer, or the read of PadForge's controller rows threw. With ids in brackets, those ids waited 60 s on a blacklist that did not read, and their hide is left as it is. |
HIDHIDE release failed |
The cycle threw, with the exception type and message. |
The block prints when the desired set moved, when the self-check was refused in that apply, when the inverse flag differs from the one the last printed block read, when the apply carries a line that happens once (the cloak going off, hiding resumed after a whitelist change, a self-check that passed), or once a minute while the sync, the read-back, the whitelist or the read of the inverse flag reports trouble. A refusal takes the hide back, so the set it leaves can equal the last one printed, and the move test alone would drop the REFUSED line. A cloak that comes on while nothing is hidden leaves the empty set as it was, which is why the flag is compared too. DevicesUpdated fires on every device-list flip, and an idle bench flipped something every enumeration interval: in one owner trace 215 of 305 diag lines were this block, five seconds apart, all identical.
Sibling sweep, scoped by serial. The persisted DevicePath names the transport a pad was last seen on, so the first apply after a transport switch can hide the wrong node while the live one stays open to games. The sweep hides every present node of the record's VID/PID as well, and scopes the selection to the pad's own identity: HidHideSerialScopes reads a Bluetooth address out of the record's serial, and SelectHidHideSweepNodes keeps only present nodes reporting that same serial. A second pad of the same model is never touched, not even before it has a record of its own. Nodes with no readable serial, and records with no serial, fall back to the sole-present-record gate. HidHideSweepDecision writes which rule chose and which way the gate went, because gate=off(same=N) used to be silence, and silence could not be told from a sweep that found nothing.
DOS Device Path Conversion¶
Whitelist requires DOS device paths (\Device\HarddiskVolumeN\...), not regular paths (C:\...). ToDosDevicePath() converts via QueryDosDeviceW.
The driver does not compare that conversion, though. It records each process by the image name its first load-image notification carries (HidHide Logic.c OnSystemLoadImage, Config.c HidHideProcessIdRegister) and matches the list against that name (Config.c HidHideProcessIdCheckFullImageNameAgainstWhitelist). The drive-letter conversion names a different path for the same file when PadForge's folder sits on a volume mounted inside another drive, on a SUBST drive, or behind a junction. SyncWhitelist therefore also lists CurrentProcessNativeImagePath(), which is QueryFullProcessImageNameW with PROCESS_NAME_NATIVE: the kernel's own name for the image.
Self-Access Check¶
PadForge reads every device it hides, and SDL opens each HID device again on every enumeration and drops one it cannot open (hidapi windows/hid.c hid_enumerate, open_device(path, FALSE)). A device HidHide hides from PadForge itself therefore dies at the next device arrival. In discussion #484 a Wii Remote was hidden at 22:13:30.242 and SDL removed it at 30.982, while the slot's virtual controller was being created, and it stayed gone until it was paired again.
A whitelist entry does not settle every case. The inverse flag may be set: Whitelisted returns the negation, so a listed program is the one refused. Or the name HidHide recorded for the process may match no entry: HidHideProcessIdRegister records each process under the image name of the first load-image notification it sees for it.
ApplyDeviceHiding handles the inverse flag up front. With the flag set it removes PadForge's own entries, both forms, through RemoveWhitelistEntries, hides nothing, and shows Status_HidHideInverse once each time the cloak comes on, at the first apply that has a device set to hide. It takes the line back when the flag reads off. A read of the flag that fails keeps the last answer. A whitelist that could not be read or written holds new hides back: SyncManagedDevices runs with allowAdditions false, so removals still land, nothing new is hidden, and the managed set keeps only the ids the driver carries.
It catches the rest by trying: after a sync that added an id, or until a check has passed, and only while PadForge's own entry is confirmed, ProbeOwnHidHideReach takes the hidden HID instance ids, finds each one's interface with HidInterfaceOf, and opens it with TryOpenAsThisProcess, which is SDL's enumeration open (no access rights, read and write shared, overlapped). The verdict belongs to the process, so the first interface that answers settles it. An open that works settles the reach (_hidHideReachConfirmed), and later applies check again only when the sync adds an id, a hidden controller comes back on a new device stack, the cloak goes off, or a refusal lifts. ERROR_ACCESS_DENIED, which is how HidHide completes a refused create (Logic.c OnDeviceFileCreate), clears the desired set, syncs it to take every entry back, sets _hidHideRefusedPadForge, records the whitelist as it stood, and shows Status_HidHideRefusedPadForge. HidHide keeps its verdict on a process until a whitelist write clears it (Config.c caches it per process, Logic.c SetWhitelist flushes the cache), so later applies stand down until SyncWhitelist reads a list that differs from the recorded one, whether PadForge wrote it or another program did. The stand-down also lifts when the inverse flag reads off after reading on, since HidHide applies that flag at every open and flushes nothing when it changes (Logic.c Whitelisted, SetInverse). PadForge then hides again, runs the check again, and clears the refusal message. Any other error, or no interface at all, decides nothing.
The check runs once more for a hidden controller that is back on a new device stack, which can be the first stack HidHide's filter sits on. That is so for a controller the release reconnected (_hidHideReprobeIds, filled from HiddenControllerRelease.TakeCycledIds) and for one that left and came back by itself (_hidHideSeenArrivals). The apply tells either by DEVPKEY_Device_LastArrivalDate (HidHideController.LastArrivalUtc): a date later than the cycle (InputService.ReturnedSinceCycle), or a date that differs from the one it last saw. A cycled controller is opened whatever its date reads once 30 s have passed since the cycle (ReprobeWithoutArrival). The ids that are back are opened first, by themselves, and one open answers for all of them.
ApplyDeviceHiding does not run inside itself. A lock the UI thread waits on dispatches the window messages other threads have sent it, WM_DEVICECHANGE among them, and that message's handler asks for an apply (MainWindow.OnDeviceChangeMessage). A request that arrives while an apply runs is noted (_deviceHidingApplyOwed) and runs once the first is done.
Standing down and taking a hide back both unhide, so each releases a captured pen tablet before its write, through PrepareTabletVisibilityChanges with hideNone set, as every other unhide does.
HidHideSelfAccessTests runs the real ApplyDeviceHiding against a fake control device through IoSeam, InterfaceSeam and OpenSeam, for the refused, allowed, unanswered and inverse cases, and reads the diagnostics ring after a mark of its own for the REFUSED and whitelist lines. Its other seams stand in for the device tree and the engine: ControlOpenSeam (the open behind IsAvailable and TryProbe), ArrivalSeam, PresenceSeam, TreeSeam, OwnerChainSeam, PersonaScopeSeam, PresentHidNodesSeam (the present HID nodes the lookup reads), XusbInterfaceSeam, ReleaseWalkSeam, InputService.RowsCurrentSeam and InputService.EngineListingSeam. HiddenControllerReleaseTests drives the release through the seams on HiddenControllerRelease itself, such as Resolve, CyclePort, Schedule, ReadBlacklist, ControllerRows, RowsPending and UtcNow. The test assembly sets HidHideController.SeamsOnlyForTest for the life of its process (HidHideOffLimits), so a call no seam stands in for, the control device's open, an IOCTL or a hub port cycle, does nothing in a test, and HidHideOffLimitsTests holds every such call to it.
Device Instance ID Conversion¶
DevicePathToInstanceId() converts device paths (\\?\HID#VID_054C&PID_0CE6#...{guid}) to PnP instance IDs (HID\VID_054C&PID_0CE6\...): strip \\?\ prefix, remove trailing GUID {...}, replace # with \.
Uninstall Guards¶
The Settings page disables uninstall buttons when a driver/service is in use, preventing removal while virtual controllers depend on it.
| Driver | Guard Condition | Delegate |
|---|---|---|
| HidHide | Any device has HidHide hiding enabled | HasAnyHidHideDevices |
| MIDI Services runtime | Any created slot uses MIDI while the App SDK runtime is the API in use | HasAnyMidiSlots |
| SteamVR | Any created slot is a VR slot | HasAnyVrSlots |
Guards are Func<bool> delegates on SettingsViewModel, injected by MainWindow.xaml.cs. RefreshDriverGuards() re-evaluates CanExecute on those three uninstall commands after slot creation, deletion, or type changes.
SteamVR adds ownership on top of usage. UninstallSteamVrCommand is gated on IsSteamVrInstalled && IsSteamVrOwned && !HasAnyVrSlots(), and ShowSteamVrUninstall hides the button entirely when the install is Steam's own. RefreshDriverGuards() re-evaluates it alongside the other two, and the two ownership properties also raise NotifyCanExecuteChanged from their own setters, refreshed by RefreshMidiServicesStatus(). The runtime refusal is enforced a second time inside UninstallSteamVR(), which throws when vrserver is running, and MainWindow checks the same process before showing the confirm dialog.
HIDMaestro has no Install command, since PadForge installs its driver with the first virtual controller. Its Uninstall (#518) and the BthPS3 card's Uninstall carry no slot guard and stay enabled even with nothing left to remove, so the dialog lists what remains or says nothing does. HIDMaestro's removal stops the engine first and refuses while another program built on HIDMaestro runs, which its preview names. The BthPS3 removal waits while a PlayStation controller is connected over Bluetooth. The legacy v2 cleanup dialog is single-shot and does not appear on the Settings page, so it has no guard either.
Elevation Strategy¶
PadForge requires elevation unconditionally. app.manifest declares:
Windows shows the UAC shield on the icon and prompts once when the process starts. The previous v2 approach of relaunching mid-session via Verb = "runas" (and the conditional auto-elevation tied to vJoy presence) is gone. Every code path that needs admin runs inside the already-elevated process.
| Scenario | Elevation Method | UAC Prompts |
|---|---|---|
| PadForge launch | app.manifest requireAdministrator |
1 (per launch, if UAC is enabled) |
HMContext.InstallDriver() (HM driver register) |
App already elevated | 0 |
| HidHide install/uninstall | x64: msiexec via RunElevated. ARM64: nefconc.exe via Process.Start. The child inherits PadForge's elevation either way |
0 |
| MIDI runtime install | vc_redist.x64.exe when the Visual C++ runtime is too old, as an install and then up to two repairs while the old runtime stays, then msiexec, all via RunElevated. The child inherits PadForge's elevation |
0 |
| MIDI runtime uninstall | Direct Process.Start of the bundle's cached uninstaller, or of msiexec /x for a runtime package (no runas, which throws Win32Exception on some already-elevated systems) |
0 |
| SteamVR install | Direct Process.Start of steamcmd.exe, plus an HKLM write for the path hint |
0 |
| SteamVR uninstall | Directory.Delete plus an HKLM value delete, both in-process |
0 |
| DS3 driver install (BthPS3, BthPS3PSM, WinUSB) | Devcon.Install, UpdateDriverForPlugAndPlayDevices, class-filter and LocalMachine certificate-store writes, all in-process |
0 |
| DS3 remembered-device record | SeBackupPrivilege / SeRestorePrivilege and REG_OPTION_BACKUP_RESTORE to write SYSTEM-ACL'd BTHPORT keys from the elevated token |
0 |
| Legacy ViGEmBus uninstall | msiexec /x {ProductCode} via RunElevated |
0 |
| Legacy vJoy uninstall | cmd.exe /c {script} via RunElevated |
0 |
| Driver runtime operations (HM device lifecycle, HidHide whitelist edits, etc.) | App already elevated | 0 |
Temp Directories¶
| Driver | Temp Directory |
|---|---|
| HidHide | %TEMP%\PadForge_HidHide\<guid>\, a folder per attempt |
| SteamVR (steamcmd staging) | %TEMP%\PadForge_SteamCmd\ |
| Windows MIDI Services runtime | %TEMP%\PadForge_MidiRuntime\<guid>\, a folder per attempt |
| DS3 driver packages | %TEMP%\PadForge\BthPS3Drivers\ |
| Vendor USB packages | %TEMP%\PadForge\VendorUsb\<ID>\, where <ID> is the bound ID with every character other than a letter or digit turned into _, and <ID>_xusb22\ for an xusb22 package |
| Legacy vJoy uninstall script | %TEMP%\PadForge_vjoy_uninstall.cmd |
The first three are cleaned up after each operation via CleanupTempDir(), and the vJoy script is removed with a direct File.Delete().
%TEMP%\PadForge\BthPS3Drivers\ is the exception: it persists for the process lifetime and beyond, cached in a static inside ExtractDrivers(). The staged INFs are re-read on every filter repair, every WinUSB bind, and every trust check, and the WinUSB catalog is regenerated in place each time. The vendor USB folders persist too, and each bind rewrites its INF and regenerates its catalog.
HIDMaestro has no temp directory because PadForge does not unpack any installer for it.
Error Handling and Rollback¶
General Strategy¶
The temp-dir install flows (HidHide, SteamVR, the MIDI runtime) use try/finally so their temp directory is deleted. HidHide and the MIDI runtime make one exception: an installer that outlived its wait may still be reading its staging folder, so that folder is left for Windows to clear. The vJoy uninstall deletes its .cmd script with a best-effort try/catch after the script runs.
Per-driver¶
| Path | Error Strategy |
|---|---|
HIDMaestro InstallDriver() |
Caught in EnsureHMaestroContext. On failure, stamps _hmaestroSetupFailedTick, which holds every setup off for five seconds (HmSetupRetryHoldMs), and calls RaiseError("Failed to initialize HIDMaestro.", ex). The next create after the hold runs the setup again, and an engine stop clears the stamp (DisposeHMaestroContextOnShutdown()). The engine continues running for KB+M, VR, and (when available) MIDI categories. HM-backed slot creation is gated on the context being non-null. |
| HidHide install/uninstall | On x64 the MSI handles its own rollback. On ARM64 a removal that stops part way tries to put its class filters back. Either way an exit code or a timeout arrives as InstallerFailedException, which MainWindow.DescribeDriverFailure words in the UI language inside the "Driver operation failed" status line. An ARM64 setup that stops on one of PadForge's own checks throws HidHideSetupException, worded the same way (Status_HidHideDriverDidNotStart, Status_HidHideFilterStillListed). |
| MIDI runtime install | The MSI handles its own rollback. Nothing installs when a download fails, the Visual C++ Redistributable is not signed by Microsoft, or the MSI's SHA-256 differs from the pinned one. A failed download keeps the exception it came with. The other two throw DriverCheckException (VcRedistUnsigned, MidiRuntimeHashMismatch), and so does a redistributable that outlives its wait (VcRedistStillRunning) or leaves the runtime old (VcRuntimeHeld, VcRuntimeStillOld). An installer's exit code, and the MSI's timeout, arrive as InstallerFailedException. MainWindow.DescribeDriverFailure words both exception types in the UI language inside the "Driver operation failed" status line, and LogDriverFailure writes the English sentence to the diagnostics log as DRIVER operation failed. |
| MIDI runtime uninstall | The WiX Burn bootstrapper or the MSI handles rollback. Nothing installed throws DriverCheckException (NoMidiRuntime), and a failed package removal throws InstallerFailedException, both worded in the UI language in the "Driver operation failed" status line. A release of MIDI that misses its 60 s throws DriverCheckException (MidiReleaseTimedOut or MidiReleaseTimedOutOnDriver) before any uninstaller starts. An uninstaller still running after five minutes returns false and ends its hold on MIDI when it exits. |
| SteamVR install | No rollback. The install is verdicted on vrpathreg.exe rather than on exit codes, retried up to three times, and throws DriverCheckException (SteamVrDidNotLand) carrying the folder and the tail of steamcmd's output when the payload never lands. The install's status line words it through DescribeDriverFailure. A partial payload is left in place, since the next attempt resumes it. Temp staging is still cleaned in finally. |
| SteamVR uninstall | Three refusals before anything is deleted, each a DriverCheckException: no owned install (NoOwnedSteamVr), vrserver running (SteamVrRunning), recorded path is a drive root (SteamVrFolderIsDriveRoot). Past those, Directory.Delete is not undoable, so the method proves the result instead: it unloads the cached openvr_api.dll, tries the delete up to ten times at 300 ms against the asynchronous lock release, and throws DriverCheckException (SteamVrNotRemoved) when the directory survives. |
| DS3 driver install | EnsureInstalled returns false rather than throwing, and every failure mode logs its own cause. Partial states are repairable rather than rolled back: HasOrphanedBthPs3Key() clears the service shell an interrupted install leaves, and RepairPsmFilter re-runs the filter half when its control device is missing. |
| DS3 WinUSB bind | LastWinUsbFailure distinguishes sign-failed from driver-untrusted so the pairing dialog names the actual cause. A pad owned by a third-party driver is never rebound. |
| Legacy ViGEmBus uninstall | No-op when no ProductCode found. Otherwise relies on MSI rollback. |
| Legacy vJoy uninstall | The .cmd script redirects every step's stderr to nul and continues regardless (>nul 2>&1). This is best-effort: a failed pnputil line should not prevent the next sc delete. The follow-up CleanExtendedRegistryArtifacts() is also per-key best-effort. |
No Rollback¶
There is no explicit rollback machinery in DriverInstaller. On partial failure of a legacy vJoy uninstall, surviving residue (a stuck service, a stale OEM .inf) is harmless because PadForge no longer uses vJoy at all. The .cmd script swallows every step's error, so UninstallVJoy() rarely throws, and once the dialog completes the persisted flag flips regardless of the uninstall result. The offer re-runs only when detection throws before the dialog, leaving the flag unflipped.
See Also¶
- Virtual Controllers:
HMaestroVirtualController(Xbox / PlayStation / Nintendo / Extended),MidiVirtualController,KeyboardMouseVirtualControllerconsuming installed drivers - HIDMaestro Deep Dive: HM SDK surface (
HMContext,HMProfile,HMController), thread-pool lifecycle, OpenXInput shim, bubble-up cascade, inactivity timeout - Architecture Overview: Elevation strategy (
requireAdministratorinapp.manifest) - Build and Publish: Embedded driver resources (the HidHide installer and the ARM64 driver package, while HIDMaestro is referenced as a managed assembly)
- Settings and Serialization: Driver status display in
SettingsViewModel - XAML Views:
SettingsPagedriver install/uninstall buttons and guards
Last updated for PadForge 5.2.0.