Skip to content

HIDMaestro Deep Dive

PadForge routes every virtual gamepad except the MIDI and Keyboard+Mouse targets through HIDMaestro: its user-mode UMDF2 HID driver, plus its native OpenVR driver for the VR slot. A Keyboard+Mouse slot goes through it too for each half set to a keyboard or mouse model, and through Windows input otherwise. This page documents the contract between PadForge and HIDMaestro, the OpenXInput shim that keeps PadForge's own slots out of its own enumeration, and the lifecycle invariants every Step 5 / Input Manager edit must uphold.

If you are reading this looking for the legacy vJoy-Deep-Dive.md, that page is gone. v2 used vJoy + ViGEmBus as two separate drivers and inherited a long list of phantom-controller / N²-slot / DLL-cache bugs that came with vJoy's kernel-mode HID stack. v3 replaces both with HIDMaestro and the headaches with them. The seven virtual controller categories (Xbox, PlayStation, Extended, MIDI, KB+M, Nintendo, VR) live in Virtual Controllers.


What HIDMaestro is

HIDMaestro (HM) is a UMDF2 (User-Mode Driver Framework 2) virtual HID minidriver. HM's SDK creates each virtual controller's device nodes from user mode, and the driver serves their HID reports. Each PadForge slot that is not MIDI, Keyboard+Mouse, or VR asks HM to instantiate a virtual device matching one of HM's device profiles, and so does each Keyboard+Mouse half set to a keyboard or mouse model. A profile bundles:

  • A USB VID/PID pair
  • A display name, a product string, and a manufacturer string
  • A pre-recorded HID report descriptor (input + output + feature reports)
  • Optional FFB PID descriptor pages

PadForge 5.2.0 ships HM 2.1.0 (HIDMaestro.Core.dll, FileVersion 2.1.0.0), where 5.1.0 shipped 2.0.3. It embeds 337 profiles. 234 are controllers spanning Xbox 360 / Xbox One / Xbox Series / Elite / Adaptive, DualShock 3/4, DualSense / DualSense Edge, Switch Pro and Switch 2 Pro, the Steam Deck and both Steam Controllers, Logitech G-series, Thrustmaster, Fanatec, and PXN wheels, HOTAS / flight sticks, and third-party gamepads (Hori, 8BitDo, etc.). The other 103 are the 51 keyboards and 52 mice HM 1.12.0 added (HM#67). 137 of the 234 controllers carry a captured HID descriptor, and PadForge's controller pickers offer 136 of them (22 Xbox, 14 PlayStation, 4 Nintendo, 96 Extended), because a profile with no descriptor cannot be deployed (HMProfile.IsDeployable) and the GameCube adapter's Full persona is withheld (HMaestroProfileCatalog.WithheldProfileIds). Every keyboard and mouse carries one. They stay out of the controller pickers and fill the Keyboard+Mouse slot's own two (HMaestroProfileCatalog.KeyboardProfiles and MouseProfiles), so PadForge offers 239 profiles in all. PadForge adds a synthetic "Custom" entry at the top of the Extended list that lets the Extended slot type build a HID descriptor from scratch.

The interim milestones a successor should know, each one PadForge's own call sites still cite by version:

HM version What landed Where PadForge depends on it
v1.3.18 (HM#33) Virtual Switch Pro profile and the IMU submission channel HMaestroVirtualController.cs:80 and :1321
v1.3.21 (HM#37) Switch Pro Bluetooth descriptor corrected to the real pad's wire shape The Nintendo category's BT report shape
v1.3.22 (HM#38) Input worker survives foreign stop signals, the structural fix for the frozen-output bug App.xaml.cs:431 (the startup orphan sweep's ordering barrier)
v1.4.0 (HM#39) Composite USB personas with audio surfaces (speaker and haptic PCM out, mic in) AudioPassthroughService.cs:1494, HMaestroVirtualController.cs:97
v1.4.1 (HM#41) Ring-side audio truncation fixed AudioPassthroughService.cs:2697
v1.4.3 (HM#42) The usbip-vhci node HM owns is stamped, so the persona guard can identify it InputManager.Step1.UsbipVhciGuard.cs:21
v1.5.1 (HM#48) Second DS5 Edge paddle/Fn pair HMaestroVirtualController.cs:1995-1996
v1.6.0 (HM#32) Native OpenVR driver behind HMVRController HMaestroVRController.cs:9
v1.7.0 (HM#56) Per-instance usbip serials and the three Valve composite persona profiles (steam-deck-composite, steam-controller-composite, steam-controller-2). They were withheld from the pickers until their art landed. WithheldProfileIds is empty in 5.0.0 HMaestroProfileCatalog.cs:448 (WithheldProfileIds), ValveReportPackers.cs
v1.7.1 (HM#58) The Triton raw path: a profile that declares an input report id and is always armed emits a raw frame verbatim, and SubmitRawExtendedReport is the explicit form of that. Also corrects the 2026 pad's rear-button pairing to SDL's and throws at profile load on a button name that resolves to nothing HMaestroVirtualController.cs:730 (SubmitRawReport), PadForge.App.csproj:434. See Raw frames
v1.7.2 (HM#59) One Windows.Gaming.Input gamepad per Xbox 360 virtual instead of two No PadForge code. Commit 6e9a9780 bumps the DLL. See One WGI gamepad
v1.7.3 A version resource (company, product, description) on each native binary PadForge.App.csproj:415
v1.8.0 (HM#60) Durable identity: the device paths, the container id, and a composite persona's USB serial derive from an identity key PadForge passes per slot, so a pad comes back at the same paths after a restart, a reboot, or a driver upgrade (#395) HMaestroVirtualController.cs:233 (the identity key), :422 (CreateController(_profile, _identityKey))
v1.8.1 (HM#61) The XUSB battery reply, which packed its fields one byte early, corrected (#447). The DualSense composite persona's name settled on "DualSense (PS5): Full" PadForge.App.csproj:405, InputManager.Step5.VirtualDevices.cs:2772
v1.9.0 An ARM64 driver payload embedded beside the x64 one, and the USB/IP transport pinned to usbip-win2 0.9.7.5, the last release with an ARM64 build PadForge.App.csproj:403
v1.9.1 (HM#63, HM#62) The ARM64 driver catalog asked of Inf2Cat with 10_RS3_ARM64, which it accepts, where 1.9.0's 10_ARM64 stopped the ARM64 install. The Steam Controller (2026) persona answers with the firmware build Steam's own updater names, so Steam stops offering it an update No PadForge code. Commit 3da34fa7 takes it inside 1.9.2
v1.9.2 The USB/IP transport moved to usbip-win2 0.9.8.1, with the fix for the attach work item that blocked driver unload, and a client that speaks the 0.9.7.x, 0.9.8.0 and 0.9.8.1 request formats PadForge.App.csproj:383
v1.10.0 (HM#64) dualshock-3-full, the DualShock 3 (SIXAXIS): Full: the pad in the form Sony's sixaxis driver and DsHidMini's SXS mode present, with analog L2 and R2 and ten HMGamepadState pressure fields (discussion #476). Also the Bluetooth DualShock 4's report 0x11 at SDL's offsets, an identity motion calibration for the DualSense and DualShock 4 profiles, Bluetooth L2 and R2 bits that follow the trigger byte, Valve personas that repeat a raw consumer's own frame, and composite stream state read from the audio traffic when SET_INTERFACE never arrives HMaestroVirtualController.cs:1000 (WritePressure), InputManager.PressureRows.cs. See Button pressure
v1.10.1 (HM#65) The plain dualshock-3 profile decodes output report 0x01, the native pad's rumble and LED report, with the block dualshock-3-full carries, so a game's rumble on the DualShock 3 (SIXAXIS) preset reaches the controller No PadForge code: HMaestroVirtualController.TryDualShock3Motors keys on the decoded field names. Commit 5e325569 takes it
v1.11.0 (HM#66) switch2-pro-controller-composite, the Switch 2 Pro Controller as a two-interface USB/IP persona: HID on interface 0, and on interface 1 the bulk pair Steam and SDL start the pad through. It sends report 0x05 with the accelerometer and the gyro on its own 4 ms clock, decodes output report 0x02 into leftMotor and rightMotor, and refuses raw reports. Both Switch 2 Pro profiles' trigger axes now follow their axisMap HMaestroProfileCatalog.cs:622 (IsNintendoProfile), Switch2ProStateMap.cs, HMaestroVirtualController.cs:174 (DeviceBuildsReports). Commit f1e3b6c7 takes it. See Switch 2 Pro state
v1.12.0 (HM#67) Keyboards and mice: 51 keyboard and 52 mouse profiles, each the report descriptor of a real device's keyboard or mouse interface, driven through SubmitKeyboard, SubmitConsumer and SubmitMouse. The driver acknowledges each keyboard and mouse report HID class takes, and the SDK waits for that and spaces reports 1 ms apart, so none is lost. A consumer that dies leaves nothing held: the driver sends the release reports the SDK keeps, and Dispose sends them first and then waits until Windows reports the device gone. KeyboardLeds holds the LED state Windows sends a keyboard while CreateController runs. The GameInput mapping and the joy.cpl OEM key go through a ledger that restores the prior state when the last holder goes, and catalog profiles get no GameInput table. The Valve personas name their bits the way SDL numbers them and add the stick and grip touch bits. HMVR.FindDriverNameConflicts lists another OpenVR driver registered under HIDMaestro's name HMaestroProfileCatalog.cs (KeyboardProfiles, MouseProfiles), KbmHidDevices.cs (the keyboard and mouse senders), KeyboardMouseVirtualController.cs, ValveReportPackers.cs (the touch bits), SettingsViewModel.cs (SteamVrDriverConflictText). Commit 31bb0c65 takes the DLL. See Virtual keyboards and mice
v2.0.0 Profiles load strictly: a file the schema or a check refuses, or one that repeats an id already loaded, is listed in HMContext.ProfileLoadIssues with its JSON path and reason and never thrown. RemoveAllVirtualControllers sweeps only the controllers of programs that have ended, and the SDK's own ProcessExit handler removes the ones a program did not dispose. Tools stage under %ProgramFiles%\HIDMaestro\Staging, for an elevated process only. HMProfile.BuildsItsOwnReports marks the profiles whose raw submit is a state block. switch-pro-usb arrives, and the GameCube adapter's Full persona gains a descriptor HMaestroProfileCatalog.cs (LoadUserProfiles, CheckUserProfile, WithheldProfileIds), InputManager.Step5.VirtualDevices.cs (EnsureHMaestroContext, whose exit hook is gone), HMaestroVirtualController.cs (DeviceBuildsReports). Commit 5ef24f24 takes 2.0.2. See Imported profiles HIDMaestro refuses
v2.0.1 HMUninstall.Preview and Run, the code behind HIDMaestroUninstall.exe, take every controller and its records, the driver packages, the signing certificate, HIDMaestro's registry keys and files off the PC, and put back the shared Windows keys a controller changed. usbip-win2's host controller installs without usbip-win2's installer Services/HIDMaestroRemoval.cs, HMaestroVirtualController.RemoveHIDMaestro, Views/UninstallHIDMaestroDialog.xaml, MainWindow.xaml.cs (UninstallHIDMaestroAsync). See Removing HIDMaestro
v2.0.2 The Valve personas report on their own clock (4 ms on the Deck and the 2026 pad, 9 ms on the 2015 pad), read calibrated motion, answer a unit serial per identity, and carry Valve's trigger click thresholds ValveReportPackers.cs (TriggerClickPull, PadPressure, MicrosNow), which now writes the 2015 roll sign, the trigger bits, pad pressure and the 2026 IMU timestamp as SDL reads them. See Valve personas
v2.0.3 With usbip-win2 included and no other program using it, the removal also takes usbip-win2's root-hub filter, usbip2_filter, after the host controller, with pnputil /delete-driver /uninstall as usbip-win2's own uninstaller does. Each USB 3 root hub restarts without it, so its USB devices disconnect for a moment, and the filter's service goes at the next start (RemovedAtRestart). The embedded driver, profiles and usbip-win2 package are the same bytes as 2.0.2's HIDMaestroUninstall_IncludeUsbipDesc and Status_HIDMaestroRemovedRestart say so. Commit f411a3d1 takes it. See Removing HIDMaestro
v2.1.0 Each frame reaches a read once and in order: a read no longer waits beside a frame the driver already holds, and no frame goes out twice or after a newer one. The ten Xbox 360 profiles keep their HID device through a system sleep, and their XInput companion answers every waiting reader on each report, as Microsoft's xusb22 does. Each device's driver host runs at high priority, so input holds while other programs load every processor. A removal deletes a device's records only once the device has left the tree, so a device another program holds open is no longer left without a driver. The profile maps (ButtonMap, AxisMap, ExtendedReport, ExtendedOutputReport, Layout) return copies, and HMVRController.SubmitState throws ObjectDisposedException after Dispose. The driver is a new build, 2.1.0.1716, so the first slot that needs it installs it once No PadForge code: PadForge only reads the profile maps, and HMaestroVRController.SubmitVrState already catches a throw from SubmitState. Commit db29ee68 takes it

One driver, seven categories

The seven VirtualControllerType values map to HM as follows:

Category Backend Description
Xbox (Xbox = 0) HM Xbox 360 / One / Series / Elite / Adaptive profiles. Acts as XInput device 1–4 when allocated a slot.
PlayStation (PlayStation = 1) HM DualShock 3/4, DualSense, DualSense Edge profiles. Reports as HID + DirectInput, plus the DualShock 4 extended report (touchpad, gyro/accel, battery) when supported.
Extended (Extended = 2) HM The remaining deployable HM profiles plus user-defined custom HID descriptors. Up to 8 axes, 128 buttons, 4 POV hats. The five Valve profiles live here: steam-deck, steam-deck-composite, steam-controller, steam-controller-composite, steam-controller-2. Four of them submit the pad's native input frame through ValveReportPackers instead of the field-encoded raw surface (see Virtual Controllers).
MIDI (Midi = 3) Windows MIDI Services NOT HM. Virtual MIDI endpoint through the in-box Windows.Devices.Midi2 API or the App SDK runtime, else an existing output port through the legacy WinMM API.
KeyboardMouse (KeyboardMouse = 4) Win32 SendInput, or HM per half Windows input pumps INPUT structures into the OS input queue and needs no driver. A half set to a keyboard or mouse model sends through a virtual HID keyboard or mouse HM makes from one of its 51 keyboard or 52 mouse profiles (HM 1.12.0).
Nintendo (Nintendo = 5) HM A virtual Switch Pro Controller (switch-pro, VID 057E, PID 2009, the Bluetooth wire shape, the default) or Switch 2 Pro Controller (VID 057E, PID 2069) in one of two forms: switch2-pro-controller, a HID gamepad, or switch2-pro-controller-composite, the USB persona with motion that only Steam and SDL built with libusb read. Picked from the catalog with no Customize. Rides the same raw-HID data path as Extended, with gyro passthrough over the HM v1.3.18 IMU channel and HOME LED control.
VR (Vr = 6) HM A SteamVR left plus right hand pair (issue #49) served by HIDMaestro's native OpenVR driver, one HMVRController pipe per slot. HMaestroVRController wraps it: SubmitVrState(in VrRawState) packs the pipeline state into HMVRState, and inbound HapticReceived pulses fan into the slot's Vibration lanes (left hand to left motor, right to right) with a 50 ms minimum pulse and a one-shot expiry timer. Slot creation refuses early when HMVR.IsSteamVRInstalled is false.

Numeric values are preserved across the rename so legacy PadForge.xml files keep loading. Xbox carries [XmlEnum("Microsoft")] and PlayStation carries [XmlEnum("Sony")] purely as a back-compat accept-list for older settings files. This is the exception path, not the canonical naming.


SDK surface PadForge talks to

The relevant assembly is HIDMaestro.Core (bundled at PadForge.App/Resources/HIDMaestro/HIDMaestro.Core.dll). Three primary types:

// HMContext: process-wide entry point. One instance.
var context = new HMContext();
context.LoadDefaultProfiles();    // load HM's 337 embedded profile JSONs (240 carry a descriptor)
context.InstallDriver();          // register HM with Windows (idempotent)

// HMProfile: handle to a profile (Xbox 360 wired, DualSense Edge, etc.).
// Returns HMProfile? (null when the id is not in the catalog).
HMProfile profile = context.GetProfile("xbox-series-xs-bt");
//   profile.Id, .Name, .ProductString, .VendorId, .ProductId
//   profile.AxisCount, .StickCount, .TriggerCount, .ButtonCount, .HasHat
//   profile.InputReportSize, .ExtendedReport (.AlwaysArmed, .ReportIdByte,
//   .Fields), .GetDescriptorBytes()
// HMProfile lives inside the HIDMaestro.Core binary. The members above are
// the ones PadForge's call sites read. PadViewModel and Step 5's custom
// descriptor build read StickCount / TriggerCount directly off the SDK's
// simple-view properties (v1.3.9), and HMaestroVirtualController caches the
// Sticks / Triggers lists at construction. ExtendedReport is the spec of a
// persona's native frame. The Valve wire test reads its Fields by
// reflection and asserts every named bit against the packer.

// HMController: a live virtual device instance. Construct via the context.
// v1.8.0: the optional identity key keeps the device paths stable per slot.
HMController controller = context.CreateController(profile, identityKey);
//   controller.Profile               // HMProfile this device was built from
//   controller.SubmitState(in state) // ~1000 Hz hot path; HMGamepadState
//   controller.SubmitRawReport(rs)   // ReadOnlySpan<byte>; DS4 extended / custom HID
//   controller.SubmitRawExtendedReport(rs) // v1.7.1: the frame goes out verbatim, id included
//   controller.OutputReceived  += handler   // FFB / rumble feedback packets
//   controller.OutputDecoded   += handler   // decoded FFB events
//   controller.Dispose()             // tears down the live device

The VR category does not use those three. It talks to a separate trio in the same assembly: the static HMVR (IsSteamVRInstalled, EnsureDriverRegistered), HMVRController (the named-pipe device pair, SubmitState, HapticReceived), and the HMVRState / HMVRButton wire types. Because the transport is a named pipe inside HIDMaestro.Core rather than an RPC to a service, HMaestroVRController.Connect and Disconnect carry none of the bounded-call ceremony the MIDI wrapper needs for midisrv.

For Extended slots that build a custom HID descriptor, PadForge starts from the catalog profile with new HMProfileBuilder().FromProfile(baseProfile), feeds a HidDescriptorBuilder (sticks, triggers, buttons, hats, plus AddPidFfbBlock() when FFB is on) through FromDescriptorBuilder, and calls builder.Build(). That returns an HMProfile handed straight to CreateController, the same as any catalog profile. There is no separate register step.

Property availability gating

Every HM SDK call carries [SupportedOSPlatform("windows10.0.26100.0")], the assembly-level attribute HIDMaestro.Core's net10.0-windows10.0.26100.0 target generates. The main project targets net10.0-windows10.0.26100.0, which satisfies that platform requirement, so the main build's calls are reachable without a CA1416 warning. CA1416 still fires from the auto-generated WPF temp project (*_wpftmp.csproj), which does not inherit TargetPlatformVersion from the main csproj. That is the reason the csproj comment gives for suppressing it via <NoWarn>$(NoWarn);CA1416;WFO0003</NoWarn>. WFO0003 in the same line is the WinForms HighDPI-migration recommendation, left in the manifest because the app is WPF-primary.


OpenXInput: filtering PadForge's own slots out of its own view

PadForge enumerates physical gamepads through SDL3, which in turn uses XInput. When PadForge owns an Xbox-category virtual slot, that slot also reports as XInput device 1–4. Without filtering, SDL would re-enumerate the virtual slot as an input device, PadForge would map it to itself, and you'd get a feedback loop.

The fix is a fork of OpenXInput (branch OpenXinput1_4) that ships as xinput1_4.dll under PadForge.App/Resources/OpenXInput/x64/ (arm64/ for the ARM64 build), bundled into the single-file PadForge.exe. At launch, App.xaml.cs calls SetDllDirectory on the single-file extract directory so the OS resolves the local copy ahead of C:\Windows\System32\xinput1_4.dll. The fork's IsHidMaestroInterface classifier (src/OpenXinput.cpp) drops any device whose interface symlink contains the literal HIDMAESTRO substring (fast path) or whose devnode or one of its first three parents carries HIDMAESTRO in its hardware-ID list (a four-level walk, which covers the HID child that spoofs the real gamepad's hardware IDs).

devobj.dll is deliberately not bundled. OpenXInput's source tree contains a stub devobj.dll that exists only to satisfy xinput1_4.dll's static-link import at compile time. Shipping that stub would hijack setupapi.dll's own DevObj* imports and crash HID class enumeration. See PadForge #69. The system devobj.dll resolves from System32 unaided.

This filter is PadForge-only. Other applications (games, Steam, etc.) load the system XInput and see PadForge's virtuals normally. That's the entire point of having virtual controllers.

The same filter logic exists in three other places PadForge owns:

  1. SDL3 fork, branch feat/hidmaestro-filter of hifihedgehog/SDL. Keeps SDL from ever listing the HM virtuals as joysticks: hid_enumerate skips them, and so does the DirectInput detection path. The classifier (hid_internal_is_hidmaestro_device + a 256-entry path cache, walking a devnode and its first four parents) lives in src/hidapi/windows/hid.c. The DirectInput enumeration path carries one SDL_HidmaestroIsAnsiHidPathHm call site in src/joystick/windows/SDL_dinputjoystick.c. The XInput backend carries no HIDMaestro filter. XInput-side filtering happens through the OpenXInput fork PadForge ships next to SDL3.
  2. XboxImpulseHidWriter XUSB interface enumeration, in PadForge.App/Common/Input/XboxImpulseHidWriter.cs. When PadForge writes rumble + impulse-trigger reports directly to a physical Xbox One+ pad, it enumerates the XUSB interface class (XUSB_INTERFACE_CLASS_GUID + DIGCF_PRESENT | DIGCF_DEVICEINTERFACE) in SetupAPI order, skips any interface whose path contains hidmaestro (case-insensitive, the same fast path OpenXInput uses), and takes the Nth survivor where N is the slot parsed from SDL's XInput#N device path.
  3. HidHideController also classifies HM devices through a hardware-ID PnP walk (IsHidMaestroDevice), so the HidHide cloak whitelist treatment is consistent with the joystick-enumeration filters.

Step 1's UpdateDevices carries a fourth, narrower check of its own: the self-readback guard (InputManager.Step1.UpdateDevices.cs:163). It is a backstop, not a replacement for the fork filter. A driver upgrade recreates the virtual devnodes with fresh instance paths and can slip past both the fork enumeration filter and the cloak, and when it does, SDL's Switch driver fights the virtual Switch Pro's protocol responder, cyclically resetting its inputs and interleaving rumble. The test is SelfVirtualSuppressions.PassOver (InputManager.Step1.UsbipVhciGuard.cs), which UpdateDevices calls on each device right after Open. PassOver logs the rejected ID and records it, and UpdateDevices disposes the wrapper. A device is passed over on any of five grounds:

  • The serial starts with HM-CTL-.
  • The device path contains HIDMAESTRO.
  • A Hardware ID on the device's own node or on a node above it contains HIDMaestro and is not the usbip-win2 host controller's stamp, ROOT\HIDMAESTRO_UDE. The node HIDMaestro makes for a controller on its own driver carries root\HIDMaestro, and a non-Xbox HID child HID\HIDMaestro (HidMaestroAncestry.Classify, UsbipAncestry.HidMaestroNode).
  • The path sits under the usbip-win2 emulated host controller, below a USB device whose instance ID ends in HIDMaestro's own serial, HM and twelve hex digits (IsPersonaUsbInstanceId). HidMaestroAncestry.Classify reads the device's own node and up to eight above it (MaxHops). A composite persona rides the real USB stack and has none of the first three markers. It serves that serial unless its profile carries a captured one, as the Steam Deck's does.
  • The path sits under that controller with no such serial, and the vendor and product ID are those of a composite persona PadForge has live, is creating, or disposed within 10 seconds (PersonaRetireGraceMs), or of any composite profile while the launch sweep runs (ChecksUsbipAncestry, InputManager.Step1.UsbipVhciGuard.cs:253).

An ID rejected on one of the first four grounds stays rejected for the life of the process. One rejected on the fifth alone is opened again once no persona of its IDs is in scope, while its device is still in the device tree (SelfVirtualSuppressions.ReleaseLapsed). The tests reach the walk through the UsbipAncestryOf seam. The walk decides only under a host controller that carries no HIDMaestro stamp, on a machine that refused the write or with a driver older than 1.4.3, because the fork filter drops a persona under a stamped one before Step 1.

The guard's coverage is genuinely narrow. SDL's HIDAPI drivers overwrite the hid-level HM-CTL-<n> serial with a fabricated MAC during their identity handshake, and non-Xbox virtuals' interface paths carry no HIDMAESTRO marker (which is exactly why the fork filter reads DEVPKEY hardware IDs instead). The serial and the path therefore catch only failed-handshake and serial-preserving cases. The third ground catches every other controller on HIDMaestro's own driver, whatever its serial and its path, by the hardware IDs HIDMaestro writes on the nodes it makes. The fork enumeration filter remains the primary defense.

If you change HM's enumerator name, hardware ID, or ContainerID, all five surfaces (OpenXInput fork, SDL3 fork, XboxImpulseHidWriter, HidHideController, and Step 1's self-readback guard) need to be kept in sync. The guard also reads the serial HM's composite personas serve (IsPersonaUsbInstanceId), so a change to that format belongs on the same list. See hidmaestro-fork-resync-recipe.md in project memory.


Lifecycle: Step 5 invariants

InputManager.Step5.VirtualDevices.cs runs once per polling cycle and is responsible for matching the desired controller set (driven by user actions in the UI) against the live _virtualControllers[] array. Three invariants govern it:

Invariant 1: HM lifecycle does NOT block the polling thread

Creating an HM controller can take 100 ms to several seconds depending on driver state and Windows PnP queues. Destroying one is similar. Doing either on the polling thread would freeze every other slot's input.

The fix (committed aee6811) routes both CreateVirtualController and Destroy through Task.Run. Per-slot state lives in two parallel arrays:

private System.Threading.Tasks.Task[] _pendingConnectTask;
private System.Threading.Tasks.Task[] _pendingDisposeTask;

Pass 1 of Step 5 skips a slot while its connect task is in flight:

{
    var inFlight = _pendingConnectTask[padIndex];
    if (inFlight != null && !inFlight.IsCompleted) continue;
}

Pass 2 creates nothing while any slot's dispose or connect task is still running, so HM creates run one at a time, which is what preserves the ascending-kernel-slot allocation guarantee. Its create kickoff is fire-and-forget, and the task publishes the new controller through TryPublishCreatedController, which claims the slot under the VC lifecycle lock rather than with a plain assignment:

_pendingConnectTask[padIndex] = Task.Run(() =>
{
    try {
        vcAsync = CreateVirtualController(capturedIndex, capturedType, capturedProfile, capturedBuild);
        if (vcAsync != null && vcAsync.IsConnected)
        {
            // Claim only if the slot is still empty. HM bring-up takes
            // seconds, and a UI-thread reorder can install a reused VC at
            // this index meanwhile. A blind assign overwrote that pointer
            // and leaked the live kernel controller, unreachable from the
            // array that was its only handle.
            bool closed = !TryPublishCreatedController(capturedIndex, vcAsync,
                out var prior, out var effects, out var personaFeed, capturedPersonaOwner);
            if (closed || prior != null) vcAsync.Dispose();
            else { /* persona audio, deferred effects */ _hmaestroContext?.FinalizeNames(); }
        }
        // null => abort or driver failure; connected==false => dispose + latch
    }
    finally { _slotInitializing[capturedIndex] = false; }
});

Losing that race means this task built the spare, so it disposes itself. Effects dispatchers register only for the winning controller, inside the publication, so disposing the spare leaves the winner's binding alone. A create that finishes after the engine closed its lifecycle disposes its controller the same way.

InputManager.Stop() calls AwaitPendingLifecycleTasks() (30 s timeout via Task.WaitAll) before DestroyAllVirtualControllers() to make sure no orphan HM controllers leak past engine shutdown.

Invariant 2: Inactivity destroy timeout

A user whose mapped device goes offline (laptop sleeps, USB hub unplugged, controller battery dies) will have the slot showing 0 online devices for as long as the device stays gone. Holding the HM controller open while no device feeds it wastes a kernel slot.

HmInactivityTimeoutSeconds (default 60, 0 disables) drives a per-slot grace period in Pass 1. The engine property mirrors the HmInactivityDestroyTimeoutSeconds setting. The grace only runs when the slot has at least one mapped device that's currently offline. A slot with no mappings at all destroys its VC immediately, not on a timer.

The elapsed time is wall clock, not polling cycles. _slotInactiveSinceMs[padIndex] is stamped on the grace's first tick and the threshold compares milliseconds. The older shape converted seconds into cycles using the live PollingIntervalMs, so changing the polling rate mid-grace rescaled a pending timeout (60 s at 1 ms became roughly 4 s after a switch to 16 ms, and the reverse stretched it). _slotInactiveCounter survives only for the trace and for Pass 2's zero versus non-zero eligibility test, and it saturates at int.MaxValue rather than wrapping.

long inactiveMs = Environment.TickCount64 - _slotInactiveSinceMs[padIndex];
bool isHMaestro = vc is HMaestroVirtualController;

if (!isHMaestro && vc != null && HmInactivityTimeoutSeconds > 0
    && inactiveMs >= HmInactivityTimeoutSeconds * 1000L)
{
    // MIDI / KeyboardMouse: teardown is cheap and has no kernel-slot
    // ordering concern, so destroy inline.
    DestroyVirtualController(padIndex);
    _virtualControllers[padIndex] = null;
    VibrationStates[padIndex].LeftMotorSpeed = 0;
    VibrationStates[padIndex].RightMotorSpeed = 0;
}
else if (isHMaestro && vc != null && HmInactivityTimeoutSeconds > 0
         && !_hmInactivityFired[padIndex])
{
    if (inactiveMs >= HmInactivityTimeoutSeconds * 1000L)
    {
        System.Threading.Volatile.Write(ref _hmInactivityFired[padIndex], true);
        VibrationStates[padIndex].LeftMotorSpeed = 0;
        VibrationStates[padIndex].RightMotorSpeed = 0;
        HmVcInactivityDestroyed?.Invoke(this, padIndex);
    }
}

The dropout grace is one user-facing contract across every slot type: MIDI and Keyboard+Mouse ride the same HmInactivityTimeoutSeconds as the HM-backed categories. They differ only in what happens at the end, because a non-HM slot has no kernel-slot ordering to repair and tears down inline instead of raising the cascade event. A Keyboard+Mouse slot on a virtual keyboard or mouse makes the same inline call, and DestroyVirtualController hands the device removal to a worker, because removing a HID device waits on Windows.

The event hops to the UI thread, which calls InputService.OnSlotInactivityTimedOut(padIndex). That method calls InputManager.TryInactivityTeardown(padIndex, slotType), which works as one operation under the VC lifecycle lock: it re-checks the fired latch (a device that came back during the dispatcher hop vetoes the whole teardown), tears down the live HM controller asynchronously (freeing its kernel slot), and runs the bubble-down cascade across the HM VCs at higher visual positions in the same subgroup. This runs for every HM-backed subgroup (Xbox / PlayStation / Nintendo / Extended), not Xbox alone. Slot configuration is preserved end-to-end: SlotCreated, SlotEnabled, the PadSetting, the device mappings, the per-group slot order, and every other piece of slot state stays intact. PadForge.xml is not touched by the timeout firing.

Once the slot's mapped devices return online, IsSlotActive(padIndex) flips back to true, the latch clears, and Pass 2 recreates the same VC automatically at the correct visual-position kernel slot. The user's slot, mappings, profile, and per-group order all persist across the timeout cycle. The sidebar flame (#175) stays ember during the grace window (VC alive, devices offline) and turns gold only after the timeout fires and the VC is torn down.

Invariant 3: Bubble-down cascade on mid-stack destroy

Kernel-slot allocation hands out the lowest free index when a controller connects. Take an Xbox subgroup: positions 1, 2, 3 are all HM virtuals and the user destroys position 2. The kernel's user-index for positions 1 and 3 stays at 0 and 2. Position 3 does NOT drop to index 1. That contradicts the visual layout (positions renumber 1, 2 in the UI). The same order sensitivity applies to PlayStation, Nintendo, and Extended: DirectInput, the SDL fork, and the raw-HID writers observe HM device creation order just as xinputhid does. So the cascade runs for all four HM-backed subgroups, not Xbox alone.

The fix is a destroy-and-recreate cascade, split across two InputService calls on the delete path (InputService.OnSlotDeleted):

  1. When the deleted slot held a live VC, RunBubbleDownCascadeAfterDelete(deletedType, oldPosition) async-destroys (DestroyVirtualControllerAsync) every surviving HM VC at a position at or above the deleted slot's old position in the same subgroup. This is the step that tears the survivors' VCs down.
  2. CompactSlotsForGaps() then compacts the pad indices so the controllers list stays contiguous from 0, driving a PadViewModel rebuild through ApplyProfile. It handles pad-index bookkeeping, not VC teardown.

Pass 2 of Step 5 recreates the destroyed VCs in ascending position order, so each lands one kernel slot lower than before. An external observer sees a natural disconnect/reconnect, exactly what happens when you unplug a real controller.

The engine gates each survivor on IsHmVcAt, which is a plain is HMaestroVirtualController type check, so it covers Nintendo alongside Xbox / PlayStation / Extended. The older Xbox-only IsXboxHmVcAt is kept only for Xbox-specific diagnostics. MIDI, KeyboardMouse, and VR fail that type check (MidiVirtualController, KeyboardMouseVirtualController, and HMaestroVRController are separate types), and none of the three has a kernel-slot ordering to repair, so they are no-ops.

The same bubble-down cascade fires on the non-delete transitions. The sidebar-disable and all-devices-unassigned paths arrive via the engine's HmVcWentNonActive event, and RunBubbleDownCascadeFromPosition finds the slot's still-present position in its order list and destroys survivors above it. The inactivity-timeout path runs its own copy of that walk inside TryInactivityTeardown (Invariant 2).

This cascade fires on destroy transitions. Intra-group reorder is a separate flow that does not go through it. A drag-reorder within Xbox / PlayStation / Nintendo / Extended calls InputManager.RerouteVirtualControllersForReorder, which keeps the kernel VC at each visual position in place and just moves pad-index pointers. Same-profile positions reuse via pointer swap (zero teardown). Different-profile positions destroy and recreate. See Services Layer for the full per-position decision.


Raw frames: SubmitRawReport versus SubmitRawExtendedReport

Two raw submit paths exist on HMController, and which one a frame takes decides which report id it leaves on.

SubmitRawReport takes data bytes. The driver prepends a report id, and the id it prepends is the descriptor's first input report id. That is the right answer for every Sony USB profile, whose descriptor leads with Report 0x01. It is the wrong answer for Valve's 2026 Steam Controller (steam-controller-2): that pad carries its lizard-mode mouse (report 0x40) and keyboard (0x41) on the same interface as its controller state (0x42), and the mouse comes first. PadForge's packer builds the full 54-byte on-wire frame with 0x42 at byte 0. Treated as data, the frame shifted one byte and went out re-headed as 0x40, so byte 1, the rolling sequence number, landed on the mouse's relative X at 250 Hz and creating the virtual controller sent the cursor tearing sideways until the slot was deleted (owner report, 2026-08-28).

HM v1.7.1 (HM#58) fixed it on the driver side. A profile that declares an extendedReport.reportId and is alwaysArmed now emits a raw frame verbatim through the same extended path SubmitState uses. The driver infers the caller's convention from length: a frame the size of the declared input report already carries its id, one byte shorter is the data-only form and gets the id prepended. SubmitRawExtendedReport is the explicit form: the frame goes out verbatim whatever the profile declares.

PadForge calls the explicit one. HMaestroVirtualController.SubmitRawReport (HMaestroVirtualController.cs:730) forwards to SubmitRawExtendedReport when _extendedFrameCarriesItsOwnId is set, and to SubmitRawReport otherwise. The flag is computed once in the constructor (:311): ExtendedReport != null && ExtendedReport.AlwaysArmed && ExtendedReport.ReportIdByte != 0. Saying it outright means the pairing cannot flip the day a packer size or a declared size moves by one, which HM's length inference would let happen in silence.

One persona takes neither path. switch2-pro-controller-composite builds every report on the device side, from the report its host selected and its own counters, and both raw submits throw NotSupportedException on it (HM#66). Its slot submits state alone. See Switch 2 Pro state.

What stays on the PadForge side is a tripwire, never a gate. HMaestroProfileCatalog.LeadsWithAPointingReport (HMaestroProfileCatalog.cs:500) parses a descriptor and returns true when its first input report sits in a Generic Desktop Mouse or Keyboard collection. PadForge.Tests/PointingReportProfileGuardTests.cs uses it two ways: PackerFramesCarryTheirOwnReportId asserts every Valve packer's frame size equals its profile's InputReportSize, and APointingLedPackerProfileTakesTheVerbatimPath asserts that any pointing-led profile with a packer declares the always-armed report id that puts it on the verbatim path. Commit 581264e9 had dropped such profiles from the pickers and refused to build them on the Extended creation path. Commit 662e174a reversed that the same day: withdrawing a working profile over a driver defect was not a call to make on the owner's behalf. Since 4.4.0 the 2026 Steam Controller is in the picker, no Valve id is in WithheldProfileIds, and no creation path refuses a profile. Since HIDMaestro 2.0.0 the set holds the GameCube adapter's Full persona alone.

HM#58 also corrected the 2026 profile's rear-button pairing to SDL's. PadForge's packer already had it right (R4 on bit 7, R5 on bit 8, L4 and L5 on 17 and 18), so the packer did not change. The profile now names all four (RightPaddle, RightPaddle2, LeftPaddle, LeftPaddle2).


One WGI gamepad per Xbox 360 virtual (HM#59)

A Virtual Xbox 360 Controller registered as two gamepads on Windows.Gaming.Input surfaces while XInput saw one, so the Start menu double-stepped and games on WGI read every input twice (discussion #378, issue #380). Xbox One, Elite, and Series virtuals never doubled: they run in HM's single-device xinputhid mode.

The cause was in the driver. HM's Xbox 360 architecture builds an XUSB companion beside the main HID parent and stamps UpperFilters="xinputhid" on that parent so WGI's PnP-added handler skips the HID-backed duplicate. DeviceOrchestrator started the main devnode in step 3 and stamped the marker in step 5, after creating the companion, and WGI never re-evaluates a devnode on a registry-only change. Measured on a Windows 11 26200 bench: the marker was absent at devnode arrival on 5 of 5 fresh creates and 4 of 5 doubled. Toggling the property by hand and restarting the devnode moved the count between 2 and 1 reversibly, which is the 30-second test that settled the mechanism.

HM v1.7.2 writes the marker before devnode registration and restarts the main devnode once after the companion exists. The restart is the operative leg: WGI's first arrival is not gated on the property even when it is pre-set, only a re-arrival is. Verified against the v1.7.2 harness: five fresh xbox-360-wired creates each enumerate exactly one WGI Gamepad with XInput intact, and the Series and One-S controls hold at one. No PadForge code is involved. The harness reproduced the double without PadForge, and the fix arrived with the vendored SDK bump in commit 6e9a9780 (full suite 5,202 green with the new DLL).


FFB through HM PID descriptors

For Extended (and Custom HID) slots that expose force feedback, PadForge wires a HID PID (Physical Interface Device) descriptor inside the HM profile's HID report descriptor. Games using DirectInput discover the FFB device and write PID effect reports. HMController.OutputReceived delivers those raw output packets to PadForge, which feeds them into HMaestroFfbDecoder for parsing.

Decoder internals (all in PadForge.App/Common/Input/HMaestroFfbDecoder.cs):

internal sealed class HMaestroFfbDecoder
{
    // Per-effect state, keyed by EffectBlockIndex.
    private sealed class EffectState     { ... }   // type, magnitude, direction, duration, started/stopped
    private struct ConditionAxis         { ... }   // per-axis Spring/Damper/Friction/Inertia coefficients
}

The decoder's Apply(Vibration vib) step collapses every active effect into a Vibration (a class in PadForge.Engine/Common/ForceFeedbackState.cs, null-checked on entry with if (vib == null) return;) carrying LeftMotorSpeed / RightMotorSpeed plus a directional / condition payload. Step 2's ApplyForceFeedback reads that Vibration through VibrationStates[padIndex] and hands it off to the per-pad-family writer (UserEffectsDispatcher for Sony, XboxImpulseHidWriter for Xbox One+, ForceFeedbackState.SetDeviceForces for SDL-rumble devices). Mirrors the v2 ApplyMotorOutput polar-split + dominant-effect-passthrough semantics but lives inside the decoder now.


What's gone from the v2 vJoy story

Anything you remember from the old vJoy-Deep-Dive.md that does not appear above is gone. Specifically:

  • Phantom controller doubling (N nodes × N registry keys = N² controllers). Gone. HM uses one driver and keeps its own per-controller registry state, so PadForge has none to manage.
  • DLL namespace cache (StatNS_global). Gone. HM SDK is managed. No per-process caching DLL.
  • VJOYRAWPDO vs HID collection accounting. Gone. HM exposes one HID device per controller, no sideband IOCTL PDO.
  • Single-node architecture rules / DICS_PROPCHANGE rebuild rules. Gone from PadForge. HM's SDK creates and removes its own device nodes (software devices through SwDeviceCreate, plus a root-enumerated main node for plain HID profiles), so PadForge carries no device-node rules.
  • Generation-based re-acquire for vJoyInterface.dll handles. Gone. HM SDK handles are GC-managed and don't go stale across device restarts.
  • HID descriptor written to registry, parsed only at EvtDeviceAdd. Gone. HM profiles bundle the descriptor. Changing button/axis counts is a profile swap.
  • Auto-elevation for vJoy SetupAPI calls. Gone. PadForge declares requireAdministrator in its app.manifest, so the whole process starts elevated. HM's InstallDriver() runs inside that already-elevated session to register the INF. No v2-style mid-session relaunch via Verb = "runas".

The legacy v2 driver cleanup dialog (offered on the first launch that detects ViGEmBus or vJoy from a prior v2 install) handles uninstalling them. After that dialog runs, neither v2 driver remains. The drivers PadForge itself puts on the machine are HIDMaestro (with the usbip-win2 transport HM deploys the first time a composite persona is created), HidHide, and, on demand, Windows MIDI Services and the BthPS3 DualShock 3 stack. See Driver Installation Internals.


Imported profiles HIDMaestro refuses

HIDMaestro 2.0.0 loads a profile file strictly. A file that does not parse, carries a key the schema lacks or a key written twice, holds a value a check refuses, uses a removed spelling, or repeats an id already loaded is refused alone, and the load lists it in HMContext.ProfileLoadIssues (File, ProfileId, JsonPath, Reason, Kind) instead of throwing. A profile whose device model does not fit loads with the model its identity gives it, listed as a ModelProblem.

HMaestroProfileCatalog.LoadUserProfiles(ctx, jsons) stages the imported profiles PadForge.xml holds as user-NNNN.json in a temporary folder, named under the folder's full path as the load names them, loads them into the catalog's context beside the built-in profiles, and keys each issue back to the JSON it came from. A refused file gets a UserProfileProblem carrying the issue. A profile that loads with no HID descriptor gets one with no issue, since IsDeployable keeps it out of the pickers all the same. Model problems only reach the diagnostics log, as PROFILES: lines, and so do any issues of the built-in catalog.

Manage Profiles shows the problem under the profile's row (SettingsService.DescribeUserProfileProblem): "HIDMaestro refused this profile at {path}: {reason}", or the missing descriptor. SettingsService.AddUserProfile(json, out problem) asks HMaestroProfileCatalog.CheckUserProfile first, in a context of its own beside the built-in catalog, and stores nothing when the answer is a problem, so no slot is pointed at a profile that is not there. Profiles written by HIDMaestro 1.12.0's extractor load under 2.0.2: all 336 catalog profiles and the HID devices of the bench PC, captured with 1.12.0's ToJson under PadForge's -user ids, loaded with no issue but repeated ids among interfaces of one device, which the import replaces rather than keeps.

Removing HIDMaestro

Settings, HIDMaestro Driver, Uninstall (#518) runs HIDMaestro's own removal, HMUninstall (2.0.1). UninstallHIDMaestroDialog.Confirm shows HMUninstall.Preview (HIDMaestroRemoval.Preview, read on a worker) as rows of location, kind and, for an item kept or not read, the reason. A preview whose status is Blocked names the programs that block it. Uninstall opens only when no program blocks the removal and the preview found something to take or a part of the PC it could not read, which the removal reads again (CanRemove). Also remove usbip-win2 set up by its own installer sets HMUninstallOptions.IncludeUsbip and reads the PC again. With it, HIDMaestro 2.0.3 also takes usbip-win2's root-hub filter, which restarts every USB 3 root hub. Windows marks the filter's service for deletion at its next start, which the report lists as RemovedAtRestart, so a run that fails no item ends RemovedRestartPending and the status line reads Status_HIDMaestroRemovedRestart (Uninstaller.cs in HIDMaestro ranks Failed first).

On confirm, MainWindow.UninstallHIDMaestroAsync shows the engine stopping, waits out a stop the Dashboard toggle already started (_engineStopTask), and runs HIDMaestroRemoval.Run(includeUsbip, _inputService.Stop) on the driver operation's worker behind its overlay. The engine's stop removes PadForge's controllers and disposes its context first, so nothing PadForge holds outlives its device and nothing creates a controller while the removal runs. HIDMaestro would take PadForge's controllers anyway, since a program never blocks its own removal. The engine stays stopped, and EnsureHMaestroContext installs the driver again at the next controller create. HIDMaestroRemoval.Log writes each item to the diagnostics log, the status line follows HMUninstallStatus, and a Failed run lists what it could not take in the same dialog with Close alone.

The Settings card reads DriverStore\FileRepository\hidmaestro.inf_* (HIDMaestroRemoval.IsDriverInstalled) at startup, on the five-second driver status timer and after a removal, never through HMContext.IsDriverInstalled. Constructing a context starts HIDMaestro's warm-up, and in an elevated process with no driver installed the warm-up takes the owner claim and stages the driver files under %ProgramFiles%\HIDMaestro again (HMContext constructor).

On the development PC the removal took 3.2 seconds and reported 345 items removed and 50 kept by HIDMaestro's rules: USB persona records that wait for a restart, the GameCube adapter's WinUSB package that a device record shared with real adapters still names, the certificate that package was signed with, and GameInputSvc's start type. With PadForge running, a removal from another process reported Blocked and named PadForge. PadForge installed HIDMaestro again with its first controller after a relaunch.

The test host never removes anything: HMaestroVirtualController.RemoveHIDMaestro answers null under OffLimitsForTest (TestHostFenceTests.HIDMaestro_IsNotRemoved).

See also


Last updated for PadForge 5.2.0.