-
Notifications
You must be signed in to change notification settings - Fork 6
HIDMaestro Deep Dive
PadForge v3 routes every virtual gamepad except the Keyboard+Mouse target through HIDMaestro, a single user-mode UMDF2 driver. 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 "five virtual controller categories" Xbox / PlayStation / Extended / MIDI / KB+M live in Virtual Controllers.
HIDMaestro (HM) is a UMDF2 (User-Mode Driver Framework 2) bus driver that publishes virtual HID controllers from user-mode. Each PadForge slot that is not MIDI or Keyboard+Mouse asks HM to instantiate a virtual device matching one of HM's device profiles. A profile bundles:
- A USB VID/PID pair
- A product string and OEM name
- A pre-recorded HID report descriptor (input + output + feature reports)
- Optional FFB PID descriptor pages
PadForge ships with HM 1.3.13, which covers 225+ profiles spanning Xbox 360 / Xbox One / Xbox Series / Elite / Adaptive, DualShock 3/4, DualSense / DualSense Edge, Logitech G-series wheels, Thrustmaster / Fanatec wheels, HOTAS / flight sticks, third-party gamepads (Hori, 8BitDo, PowerA, PXN, etc.), and a "Custom" profile that lets the Extended slot type build a HID descriptor from scratch.
The five 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 | Any of the 220+ remaining HM profiles plus user-defined custom HID descriptors. Up to 8 axes, 128 buttons, 4 POV hats. |
MIDI (Midi = 3) |
Windows MIDI Services | NOT HM. Virtual MIDI endpoint via the Windows MIDI Services SDK. |
KeyboardMouse (KeyboardMouse = 4) |
Win32 SendInput | NOT HM. No driver. Pumps INPUT structures into the OS input queue. |
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.
The relevant assembly is HIDMaestro.Core (bundled at Resources/HIDMaestro/HIDMaestro.Core.dll). Three primary types:
// HMContext: process-wide entry point. One instance.
var context = new HMContext();
context.LoadDefaultProfiles(); // load the 225+ embedded profile JSONs
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, .ButtonCount, .HasHat
// profile.StickBits, .TriggerBits, .InputReportSize
// profile.ExtendedReport, .ExtendedOutputReport (DS4 / DualSense extras)
// HMController: a live virtual device instance. Construct via the context.
HMController controller = context.CreateController(profile);
// 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.OutputReceived += handler // FFB / rumble feedback packets
// controller.OutputDecoded += handler // decoded FFB events
// controller.Dispose() // tears down the live deviceFor Extended slots that build a custom HID descriptor, PadForge constructs the descriptor via HMProfileBuilder (axes, triggers, buttons, hats, FFB pages) and calls context.RegisterCustomProfile(builder.Build()).
Every HM SDK call is annotated [SupportedOSPlatform("windows10.0.26100.0")]. PadForge targets net10.0-windows10.0.26100.0, so the calls are reachable, but the .NET CA1416 analyzer warnings fire at every HM call site. Suppressed via <NoWarn>$(NoWarn);CA1416;WFO0003</NoWarn> in the csproj.
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 Resources/OpenXInput/x64/, 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 PnP parent chain holds an ancestor with HIDMAESTRO in its hardware-ID list (depth-4 walk, 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:
-
SDL3 fork, branch
feat/hidmaestro-filterofhifihedgehog/SDL. Stops SDL from opening the HM virtuals as joysticks duringSDL_OpenJoystick. The classifier (hid_internal_is_hidmaestro_device+ a 256-entry path cache) lives insrc/hidapi/windows/hid.c. The DirectInput and Raw Input enumeration paths each carry oneSDL_HidmaestroIsAnsiHidPathHmcall site insrc/joystick/windows/SDL_dinputjoystick.candSDL_rawinputjoystick.c. The XInput backend is pristine upstream. XInput-side filtering happens through the OpenXInput fork PadForge ships next to SDL3. -
XboxImpulseHidWriterraw-HID enumeration, inPadForge.App/Common/Input/XboxImpulseHidWriter.cs. When PadForge writes rumble + impulse-trigger reports directly to a physical Xbox One+ pad, it walksHidD_GetHidGuid+DIGCF_DEVICEINTERFACEand rejects any interface whose path containsHIDMAESTROor whoseStableXInputInstance.FindAlllookup misses (substring + 16-level PnP parent walk against hardware IDs). -
HidHideControlleralso 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 does not filter HM separately. It trusts the SDL3 fork's pre-filtered SDL_GetJoysticks result and opens every instance ID returned.
If you change HM's enumerator name, hardware ID, or ContainerID, all four surfaces (OpenXInput fork, SDL3 fork, XboxImpulseHidWriter, HidHideController) need to be kept in sync. See hidmaestro-fork-resync-recipe.md in project memory.
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:
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 short-circuits if either task is in flight for that slot:
{
var inFlight = _pendingConnectTask[padIndex];
if (inFlight != null && !inFlight.IsCompleted) continue;
}Pass 2's create kickoff is fire-and-forget:
_pendingConnectTask[capturedIndex] = Task.Run(() =>
{
try {
var vc = CreateVirtualController(capturedIndex);
if (vc != null && vc.IsConnected) _virtualControllers[capturedIndex] = vc;
}
finally { _slotInitializing[capturedIndex] = false; }
});InputManager.Stop() calls AwaitPendingLifecycleTasks() (30 s timeout via Task.WaitAll) before DestroyAllVirtualControllers() to make sure no orphan HM controllers leak past engine shutdown.
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.
HmInactivityDestroyTimeoutSeconds (default 60, 0 disables) drives a per-slot countdown in Pass 1. The grace counter only ticks 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:
else if (isHMaestro && vc != null && HmInactivityTimeoutSeconds > 0 && !_hmInactivityFired[padIndex])
{
int hmThresholdCycles = (HmInactivityTimeoutSeconds * 1000) / Math.Max(1, PollingIntervalMs);
if (_slotInactiveCounter[padIndex] >= hmThresholdCycles)
{
_hmInactivityFired[padIndex] = true;
VibrationStates[padIndex].LeftMotorSpeed = 0;
VibrationStates[padIndex].RightMotorSpeed = 0;
HmVcInactivityDestroyed?.Invoke(this, padIndex);
}
}The event hops to the UI thread, which calls InputService.OnSlotInactivityTimedOut(padIndex). That method tears down the live HM controller (freeing its kernel slot) and, for Xbox-category slots, runs the bubble-up cascade across surviving Xbox HM VCs at higher visual positions in the same group. 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 power dot stays green during the grace window (VC alive, devices offline) and turns yellow only after the timeout fires and the VC is torn down.
XInput allocates the lowest free user-index when a controller connects. If slots 1, 2, 3 are all Xbox-category HM virtuals and the user destroys slot 2, XInput's user-index assignment for slots 1 and 3 stays at 0 and 2 respectively. Slot 3 does NOT shift down to slot 1. That contradicts the visual layout (slots renumber 1, 2 in the UI).
The fix is a deliberate destroy-and-recreate cascade. After the slot data shifts down (so slot 3 becomes slot 2 in UI terms), InputService.CompactSlotsForGaps() runs an ApplyProfile pass that destroys the surviving Xbox HM controllers that shifted and re-creates them. The kernel allocates fresh user-indices in the new order. xinputhid sees this as a natural disconnect/reconnect, exactly what XInput does when you unplug a real controller.
The cascade applies only to Xbox-category slots. PlayStation and Extended HM virtuals don't go through XInput's user-index allocation, so their slot mapping is purely UI-side.
This cascade fires on deletion of a slot (or its inactivity-timeout equivalent). Intra-group reorder is a separate flow that does not go through it. A drag-reorder within Xbox / PlayStation / 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#slot-reordering for the full per-position decision.
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 resolve step collapses every active effect into a Vibration struct (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.
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 a single bus driver. There is no per-instance registry 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. HM doesn't use SetupAPI device-node creation. It's WDF.
-
Generation-based re-acquire for
vJoyInterface.dllhandles. 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
requireAdministratorin its app.manifest, so the whole process starts elevated. HM'sInstallDriver()runs inside that already-elevated session to register the INF. No v2-style mid-session relaunch viaVerb = "runas".
The legacy v2 driver cleanup dialog (offered on first v3 launch when ViGEmBus or vJoy is detected) handles uninstalling them. After that dialog runs, the user's machine has only HIDMaestro, HidHide, and (optionally) Windows MIDI Services. See Driver Installation Internals.
- Virtual Controllers: IVirtualController surface, per-category implementations.
- Input Pipeline: Step 5 in the broader 6-step polling loop context.
- Driver Management: install + status flow for HIDMaestro plus install / uninstall flow for HidHide and Windows MIDI Services.
- Driver Installation Internals: the embedded installer and INF / pnputil mechanics.
- Force Feedback: user-facing rumble/FFB tuning controls.