Skip to content

HIDMaestro Deep Dive

hifihedgehog edited this page May 21, 2026 · 24 revisions

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.


What HIDMaestro is

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.12, 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.

One driver, five categories

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.


SDK surface PadForge talks to

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.Initialize();

// HMProfile: handle to a profile (Xbox 360 wired, DualSense Edge, etc.).
HMProfile profile = context.GetProfile("xbox-series-xs-bt");
//   profile.Id, .Name, .ProductString, .ProductId
//   profile.AxisCount, .ButtonCount, .HasHat
//   profile.HasFeedback, .HasMotion, .HasTouchpad

// HMController: a live virtual device instance. Construct via the context.
HMController controller = context.CreateController(profile);
//   controller.IsConnected         // set when the kernel slot is allocated
//   controller.XInputSlot          // 0..3 for Xbox category, -1 otherwise
//   controller.SubmitGamepadState(...)  // ~1000 Hz hot path
//   controller.SubmitRawReport(byte[])  // for DS4 extended / custom HID
//   controller.FeedbackReceived  += handler  // FFB
//   controller.Destroy()

For 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()).

Property availability gating

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.


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 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 XInputGetState / XInputGetCapabilities filter out controllers whose product string matches HM's virtual-controller signature.

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 exists in two other places for redundancy:

  1. SDL3 fork — branch feat/hidmaestro-filter. Stops SDL from opening the HM virtuals as joysticks during SDL_OpenJoystick. Substring list lives in joystick/windows/SDL_xinputjoystick.c.
  2. PnP enumeration walks — Step 1's UpdateDevices walks \\?\HID#... device paths and skips paths whose ContainerID matches HM's bus enumerator GUID.

If you change HM's enumerator name, hardware ID, or ContainerID, all three places need to be kept in sync. 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 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.

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.

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.

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

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), CompactSlots(rebuildHmVcs=true) 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.


FFB through HM PID descriptors

For Extended (and PlayStation) 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, send PT_EFOPREP / PT_BLOCKFREE / PT_DEVCTL effect reports, and PadForge's FfbCallback (registered via HMController.FeedbackReceived) routes per-effect state into the polling thread's VibrationStates[].

Three structures track FFB:

class FfbDeviceState   // per-controller: gain, current effects table
class FfbEffectState   // per-effect: type, magnitude, direction, duration, started/stopped
class FfbConditionAxis // per-axis condition data for Spring/Damper/Friction/Inertia

ApplyMotorOutput collapses the active effects into a LeftMotor / RightMotor pair using polar-direction-to-motor-split for vector forces and condition-effect dispatch for spring/damper/friction/inertia. The result lands in the same VibrationStates[] that Xbox-category rumble passthrough writes to.


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 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.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 — still required, but for HM it's so the user-mode driver can register its INF, not for SetupAPI device creation.

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.


See also

Clone this wiki locally