-
Notifications
You must be signed in to change notification settings - Fork 5
Virtual Controllers
Developer reference for the three IVirtualController implementations in v3: HIDMaestro-backed (one class for Xbox / PlayStation / Extended), KeyboardMouse, and MIDI. Covers state submission, axis/button mapping formulas, rumble/FFB, driver interaction, and type-specific quirks. The lifecycle layer that wraps these classes (off-polling-thread Connect/Disconnect, inactivity destroy, bubble-up cascade) lives on HIDMaestro Deep Dive.
- Architecture Overview
- IVirtualController Interface
- HMaestroVirtualController
- KeyboardMouseVirtualController
- MidiVirtualController
graph TB
subgraph Engine["PadForge.Engine (interface + types)"]
IVC["IVirtualController<br/><i>interface</i>"]
VCT["VirtualControllerType<br/>Xbox / PlayStation / Extended / Midi / KeyboardMouse"]
GP["Gamepad struct<br/>(XInput layout)"]
ERS["ExtendedRawState<br/>(dynamic axes/buttons/POVs)"]
KRS["KbmRawState<br/>(256 VK + mouse)"]
MRS["MidiRawState<br/>(CC + notes)"]
end
subgraph App["PadForge.App (implementations)"]
HMVC["HMaestroVirtualController<br/>One class for Xbox / PlayStation / Extended<br/>(HMContext + HMProfile + Type at ctor)"]
KBM["KeyboardMouseVirtualController<br/>Win32 SendInput"]
MIDI["MidiVirtualController<br/>Windows MIDI Services"]
end
subgraph Drivers["OS / Driver Layer"]
HM["HIDMaestro<br/>UMDF2 user-mode driver<br/>(225+ profiles)"]
WIN["Windows<br/>Input Queue"]
WMS["Windows MIDI<br/>Services"]
end
IVC --> HMVC & KBM & MIDI
VCT --> HMVC
GP -->|"SubmitGamepadState()"| HMVC
ERS -->|"SubmitRawReport()"| HMVC
KRS -->|"SubmitKbmState()"| KBM
MRS -->|"SubmitMidiRawState()"| MIDI
HMVC -->|"HMController.SubmitState()<br/>or SubmitRawReport()"| HM
KBM -->|"SendInput()<br/><i>per event</i>"| WIN
MIDI -->|"SendSingleMessagePacket()<br/><i>per CC/note</i>"| WMS
HM -.->|"OutputReceived<br/>(motors / FFB)"| HMVC
style IVC fill:#fff3e0
style VCT fill:#fff3e0
style HMVC fill:#f3e5f5
style HM fill:#e8f5e9
style WIN fill:#e1f5fe
style WMS fill:#f3e5f5
| Property | Xbox / PlayStation / Extended | KBM | MIDI |
|---|---|---|---|
| Class | HMaestroVirtualController |
KeyboardMouseVirtualController |
MidiVirtualController |
| Backend | HIDMaestro (UMDF2 user-mode driver) | Win32 SendInput (no driver) | Windows MIDI Services SDK |
| Required driver | HIDMaestro | None | Windows MIDI Services (Win11 24H2+) |
| Submit methods |
SubmitGamepadState(Gamepad) for the three Xbox-shaped categories; SubmitRawReport(byte[]) for Extended custom HID descriptors |
SubmitKbmState(KbmRawState) |
SubmitMidiRawState(MidiRawState) |
| Axis format at the SDK boundary |
HMGamepadState floats in [-1, 1] (XInput-style; Y inverted at the boundary so up is +Y) |
short delta (mouse) | byte CC (0..127) |
| Button format |
HMGamepadState per-button bools |
Per-VK SendInput
|
MIDI Note On/Off |
| Rumble/FFB |
HMController.OutputReceived event (rumble for Xbox/PlayStation, full PID FFB for Extended) |
No | No |
| Change detection | None. HM is consumer-driven and every poll forwards a fresh frame. Deduping risked dropping rapid press+release bursts between the game's HID reads. | XOR per 64-bit word in the keymask | Per CC/note value |
| Max instances | 16 (across all three HM-backed categories combined, sharing the 16-slot total with KBM and MIDI) | 16 | 16 (limited by Windows MIDI Services) |
| Lifecycle | HM Connect()/Disconnect() are off-polling-thread (wrapped in Task.Run by Step5.VirtualDevices); see Lifecycle: Step 5 invariants
|
Synchronous | Synchronous |
Source files:
-
PadForge.Engine/Common/VirtualControllerTypes.cs—IVirtualControllerinterface +VirtualControllerTypeenum ([XmlEnum("Sony")]preserves the on-disk name "Sony" for v2 PadForge.xml back-compat). -
PadForge.App/Common/Input/HMaestroVirtualController.cs— single class for all three HM-backed categories. -
PadForge.App/Common/Input/KeyboardMouseVirtualController.cs— Win32 SendInput, KbmRawState combine logic. -
PadForge.App/Common/Input/MidiVirtualController.cs— Windows MIDI Services SDK, virtual endpoint creation.
The lifecycle code that creates / destroys / reorders these instances per slot lives in PadForge.App/Common/Input/InputManager.Step5.VirtualDevices.cs and is documented under HIDMaestro Deep Dive#Lifecycle Step 5 invariants (HM thread pool, inactivity timeout, bubble-up cascade for mid-stack destroys, etc.).
namespace PadForge.Engine
{
public enum VirtualControllerType
{
[XmlEnum("Microsoft")] Xbox = 0, // on-disk alias preserves v2 PadForge.xml
[XmlEnum("Sony")] PlayStation = 1, // on-disk alias preserves v2 PadForge.xml
Extended = 2,
Midi = 3,
KeyboardMouse = 4
}
public interface IVirtualController : IDisposable
{
VirtualControllerType Type { get; }
bool IsConnected { get; }
/// The pad slot index this VC currently occupies. Updated by the
/// reorder/swap paths so feedback callbacks write to the correct
/// VibrationStates element after a slot reorder.
int FeedbackPadIndex { get; set; }
void Connect();
void Disconnect();
void SubmitGamepadState(Gamepad gp);
void RegisterFeedbackCallback(int padIndex, Vibration[] vibrationStates);
}
}Defined in: PadForge.Engine/Common/VirtualControllerTypes.cs
XInput-layout struct used as the universal input format for the Xbox / PlayStation / Extended categories. HMaestroVirtualController.SubmitGamepadState translates it to HMGamepadState. The KBM and MIDI implementations leave SubmitGamepadState as a no-op and read KbmRawState / MidiRawState directly through their own submit methods.
| Field | Type | Range | Description |
|---|---|---|---|
ThumbLX |
short |
-32768..32767 | Left stick X (positive = right) |
ThumbLY |
short |
-32768..32767 | Left stick Y (positive = up) |
ThumbRX |
short |
-32768..32767 | Right stick X (positive = right) |
ThumbRY |
short |
-32768..32767 | Right stick Y (positive = up) |
LeftTrigger |
ushort |
0..65535 | Left trigger (unsigned) |
RightTrigger |
ushort |
0..65535 | Right trigger (unsigned) |
Buttons |
ushort |
Bitmask | 15 buttons (A/B/X/Y/LB/RB/Back/Start/LS/RS/Guide/DPad) |
Tracks which slot this VC occupies for correct VibrationStates[] writes. Updated by InputManager.RerouteVirtualControllersForReorder (intra-group reorder) and SwapSlotData (cross-group / type-change swap). The callback captures the property reference (not a copy), so it always reads the current slot index after a reorder.
All implementations have SubmitGamepadState(Gamepad gp) for interface compliance, but the non-gamepad outputs use alternative submit methods and leave it as a no-op:
-
Xbox / PlayStation / Extended (preset profile):
SubmitGamepadState(Gamepad gp)(HMaestroVirtualController.cs:275). Standard XInput-shaped path. A second overload (SubmitGamepadState(gp, in TouchpadState, in MotionSnapshot, byte batteryPercent, bool batteryCharging)at line 359) carries the PlayStation sensor / battery payload alongside the gamepad state. -
Extended (Custom HID profile):
SubmitExtendedRawState(ExtendedRawState raw, int sticks, int triggers)(HMaestroVirtualController.cs:457). Up to 6 axes, 32-bit button mask, 1 hat. Covers Touchpad / Share / profile-specific bits the 11-button XInput bitmap can't represent. -
PlayStation (DS4 extended report):
SubmitRawReport(ReadOnlySpan<byte> report)(HMaestroVirtualController.cs:269). Sony Report 0x01 carrying touchpad / gyro / accel / battery. Called AFTERSubmitGamepadStatein the same poll. -
KeyboardMouse:
SubmitKbmState(KbmRawState raw). Keys, mouse, scroll. -
MIDI:
SubmitMidiRawState(MidiRawState state). CC values and note on/off.
Namespace: PadForge.Common.Input
Visibility: internal sealed
File: PadForge.App/Common/Input/HMaestroVirtualController.cs
Backs: VirtualControllerType.Xbox, .PlayStation, .Extended
Max instances: 16 (MaxPads). HM-backed slots share that 16-slot pool with KBM and MIDI. Xbox-category slots are visible to XInput games as user index 1 through 4 (Microsoft API limit); SDL / DirectInput games see all of them.
One IVirtualController implementation handles every preset and every custom HID descriptor through a single SDK surface: HMContext + HMProfile + HMController. The user-facing category (Xbox / PlayStation / Extended) is supplied at construction so per-type counting in InputManager and InputService keeps working. The actual driver-side device shape is determined by the supplied HMProfile.
For lifecycle invariants that wrap this class (off-polling-thread Connect/Disconnect, inactivity-destroy timeout, bubble-up cascade on mid-stack destroy), see HIDMaestro Deep Dive#Lifecycle Step 5 invariants. This page covers only the controller class itself.
| Field | Type | Description |
|---|---|---|
_ctx |
HMContext |
Shared HM SDK context owned by InputManager (InputManager.Step5.VirtualDevices.cs:100, _hmaestroContext); one instance per process. |
_profile |
HMProfile |
Profile handle resolved via _hmaestroContext.GetProfile(profileId) ?? HMaestroProfileCatalog.GetProfileById(profileId), optionally rebuilt via HMProfileBuilder.FromProfile for descriptor overrides. |
_type |
VirtualControllerType |
Reported through Type so SlotControllerTypes-based counting keeps working. |
_controller |
HMController |
Live HM device returned by _ctx.CreateController(_profile). Null until Connect(). |
_ffbDecoder |
HMaestroFfbDecoder |
Set when the active profile's HID descriptor contains a PID FFB block (DescriptorHasPidFfbBlock(descriptorHex) at Connect() time). Includes the synthetic Custom profile and any catalog profile whose FFB capability is wired via HMProfileBuilder. Null when the profile has no PID block. |
_disposed |
bool |
Dispose guard. |
| Property | Type | Description |
|---|---|---|
Type |
VirtualControllerType |
Xbox, PlayStation, or Extended. Matches the slot's SlotControllerTypes[i]. |
IsConnected |
bool |
True after a successful Connect(), false after Disconnect(). |
FeedbackPadIndex |
int |
Slot index for the rumble/FFB callback's vibrationStates[] write target. The property is read on every callback (not captured), so post-reorder swaps still route correctly. |
ProfileId |
string |
_profile.Id. Examples: "xbox-series-xs-bt", "dualsense-edge-usb", the Custom profile ID. |
ProfileVendorId |
ushort |
_profile.VendorId. Used by the rumble callback to select the right HID layout. |
ProfileProductId |
ushort |
_profile.ProductId. |
public HMaestroVirtualController(HMContext ctx, HMProfile profile, VirtualControllerType type)Stores the three arguments. Throws ArgumentNullException on null ctx or profile. No driver work happens here. The actual virtual device is created in Connect().
InputManager.CreateHMaestroController is the only call site (InputManager.Step5.VirtualDevices.cs:1430). It resolves the profile, applies any per-slot overrides via HMProfileBuilder.FromProfile (InputManager.Step5.VirtualDevices.cs:1500), then constructs the wrapper.
HMaestroVirtualController.cs:150.
- Returns immediately if already connected.
-
_controller = _ctx.CreateController(_profile). The driver-side device appears here. - If
DescriptorHasPidFfbBlock(descriptorHex)returns true (any profile with a PID FFB descriptor block, including the synthetic Custom profile and catalog profiles built withHMProfileBuilderFFB pages): allocates_ffbDecoderand calls_ffbDecoder.PublishInitialState(). The PID Pool + initial PID State must be published before any hostGetFeaturecan race in. DirectInput'sCDIEffect::CreateEffectissuesGetFeature(PidPool)up-front to discover capabilities, so lazy init on firstOutputReceivedis too late. - Sets
IsConnected = true.
InputManager calls Connect() from a Task.Run continuation in Step 5 Pass 2 (see HIDMaestro Deep Dive#Invariant 1 HM lifecycle does NOT block the polling thread).
HMaestroVirtualController.cs:181.
- Returns immediately if not connected.
-
_controller?.Dispose(). Driver-side device goes away. - Sets
_controller = null,IsConnected = false.
Guarded by _disposed. Calls Disconnect(). The FFB decoder, if present, is released by GC once the controller's OutputReceived subscription is gone.
HMaestroVirtualController.cs:275. Hot path for Xbox / PlayStation slots and for Extended slots whose profile is a catalog entry (not the Custom profile). Builds an HMGamepadState and calls _controller.SubmitState(state).
No deduping. Step 5 already honors the user-configured polling interval (default 1 kHz). HIDMaestro is consumer-driven, so every call forwards a fresh frame. Deduping on unchanged state risks dropping rapid press+release bursts between the game's HID reads.
Axis normalization (XInput signed short to HM normalized float, Y inverted at the boundary):
| Gamepad field | HMGamepadState field | Formula |
|---|---|---|
ThumbLX |
LeftStickX |
gp.ThumbLX / 32767f |
ThumbLY |
LeftStickY |
-gp.ThumbLY / 32767f (Y negated; XInput Y+ = up, HID Y+ = down) |
ThumbRX |
RightStickX |
gp.ThumbRX / 32767f |
ThumbRY |
RightStickY |
-gp.ThumbRY / 32767f |
LeftTrigger |
LeftTrigger |
gp.LeftTrigger / 65535f |
RightTrigger |
RightTrigger |
gp.RightTrigger / 65535f |
Buttons. MapButtons (HMaestroVirtualController.cs:748) translates the 11 named XInput button bits to HMButton flags: A, B, X, Y, LeftBumper, RightBumper, Back, Start, LeftStick, RightStick, Guide.
Hat. MapHat (HMaestroVirtualController.cs:803) collapses the four D-Pad bits into a single HMHat direction (North, NorthEast, East, SouthEast, South, SouthWest, West, NorthWest, None). Diagonals take priority over cardinals.
HMaestroVirtualController.cs:457. Used by Step 5 only when an Extended slot's profile is the Custom profile (SlotControllerTypes[i] == Extended && SlotExtendedCustomize[i]). Submits up to 6 axes, up to 32 button bits (the named 13 plus profile-specific extras), and 1 hat from a single 8-way POV.
Why not just SubmitGamepadState: the XInput-shaped Gamepad struct only models 11 named buttons, so Touchpad / Share and any profile-specific extras would be truncated by MapButtons.
Axis layout: computed by replicating ExtendedSlotConfig.ComputeAxisLayout. Sticks and triggers are interleaved in groups of (stickX, stickY, trigger) while both are available, then trailing sticks pack at (prev, prev+1) and trailing triggers pack one index at a time. Hardcoded (3, 4) for right-stick X/Y silently dropped Stick 2 Y on every 0-trigger or 1-trigger profile, hence the explicit interleave logic.
Trigger conversion (signed short centered at 0 to 0..1 float):
float Trig(short v) => (v + 32768) / 65535f;Button mask: all 32 bits of ExtendedRawState.IsButtonPressed(i) are passed through to (HMButton)buttonMask. HidReportBuilder iterates bits 0..31, so any bit beyond the named 13 surfaces at the descriptor's corresponding position (direct index, or via the profile's ButtonMap if declared). Profiles with 13+ buttons (Stadia, flight sticks, wheels) rely on this.
Hat: raw.Povs[0] (centidegrees, -1 = centered) is rounded to the nearest octant via ((pov + 2250) / 4500) % 8, then mapped to HMHat.
HMaestroVirtualController.cs:269. Pass-through to _controller.SubmitRawReport(report). Step 5 calls this for PlayStation slots whose profile carries DS4-extended fields (touchpad, gyro, accel, battery) that HMGamepadState doesn't model. The call lands AFTER SubmitGamepadState in the same poll, so the GIP buffer stays consistent and the raw Sony Report 0x01 layout overrides the HID surface with the full Sony report.
HMaestroVirtualController.cs:567. Stores padIndex in FeedbackPadIndex, then subscribes to _controller.OutputReceived. The handler dispatches by pkt.Source and _profile.VendorId:
| Profile VID | pkt.Source |
Length | Meaning | Motor write |
|---|---|---|---|---|
| any | XInput |
>= 5 | XUSB SET_STATE rumble: data[2] = L, data[3] = R |
* 257 to ushort |
0x054C (Sony) |
HidOutput |
>= 4 | DS4 / DualSense Output Report 0x05: data[2] = L, data[3] = R |
* 257 |
0x045E (Microsoft) |
HidOutput |
4..7 | Xbox Series BT short rumble: data[2] = L, data[3] = R, magnitude 0..100 |
* 655 |
0x045E |
HidOutput |
>= 8 | Xbox wired / wireless-receiver long rumble: data[5] = L, data[6] = R |
* 257 |
0xBEEF (Custom) |
HidOutput |
any | HID PID FFB output report. Routes to _ffbDecoder.OnHidOutput(reportId, data), then _ffbDecoder.Apply(vibrationStates[idx]). |
written by decoder |
0xBEEF |
HidFeature |
any | HID PID FFB feature report (Create New Effect). Routes to _ffbDecoder.OnHidFeature(reportId, data). |
none directly |
The Xbox Series BT 0..100 magnitude scaled by 655 was verified against HM's own test app log of xbox-series-xs-bt plus gamepad-tester.com. Browser Gamepad API square-wave dual-rumble alternates hi=127 / hi=0. The "off" phase is part of the duty cycle, not noise, so packets where both motor bytes are zero must still be forwarded.
Threading: the callback runs on the HM SDK's output-dispatch thread. Vibration motor fields are ushort and aligned writes are atomic on x64. The polling thread reads these for rumble forwarding, dispatching to one of three writers depending on the source pad family: UserEffectsDispatcher for Sony pads (DualShock 4 / DualSense), XboxImpulseHidWriter raw HID for Xbox One+ pads (Xbox One / Elite / Series), or SDL_RumbleJoystick / SDL haptic effects for everything else. Per the architecture-invariant rules, each pad family has a single writer and SDL rumble is skipped on Sony and Xbox One+ pads.
File: PadForge.App/Common/Input/HMaestroFfbDecoder.cs
Visibility: internal sealed
Allocated: by HMaestroVirtualController.Connect() when the profile's HID descriptor contains a PID FFB block (gate: DescriptorHasPidFfbBlock(descriptorHex)).
Decodes HID PID 1.0 effect reports delivered through HMOutputPacket and aggregates running effects into a single Vibration for the physical FFB device. One instance per Custom-profile Extended virtual controller.
The dispatch logic mirrors the v2 vJoy FfbCallback semantics:
- Dominant running effect drives
Vibration.DirectionandVibration.SignedMagnitude. - Running effects polar-split into left/right motor scalars for rumble-only physical devices.
- Condition effects pass through to
Vibration.ConditionAxes.
Report IDs are defined in HMaestroFfbDescriptor.OutputReportId and dispatched by OnHidOutput (HMaestroFfbDecoder.cs:183):
| Report ID | Constant | Decoder |
|---|---|---|
0x11 |
SetEffect |
DecodeSetEffect (also handled by OnHidFeature for Create New Effect) |
0x13 |
SetCondition |
DecodeSetCondition |
0x14 |
SetPeriodic |
DecodeSetPeriodic |
0x15 |
SetConstantForce |
DecodeSetConstant |
0x16 |
SetRampForce |
DecodeSetRamp |
0x1A |
EffectOperation |
DecodeEffectOperation |
0x1B |
BlockFree |
DecodeBlockFree |
0x1C |
DeviceControl |
DecodeDeviceControl |
0x1D |
DeviceGain |
DecodeDeviceGain |
The descriptor bytes that carry these IDs to the host are produced by HIDMaestro v1.1.41+'s HidDescriptorBuilder.AddPidFfbBlock(). PadForge only needs the IDs. It does not emit the descriptor itself.
HMaestroFfbDecoder.cs:97 (method PublishInitialState). Called from HMaestroVirtualController.Connect() immediately after CreateController. Publishes the PID Pool and the initial PID State via _controller.PublishPidPool(...) and _controller.PublishPidState(0, _stateFlags). Without these the SDK returns STATUS_NO_SUCH_DEVICE for the Pool Report and DirectInput cleanly concludes "no FFB". This is the gate that turns the Custom-VID device into a real DirectInput PID FFB device on the wire.
Block Load is driver-owned: HM v1.1.37+ allocates the EffectBlockIndex and writes BL fields synchronously inside the SetFeature(0x11) IOCTL handler, so PadForge does not pre-publish a Block Load.
private readonly Dictionary<byte, EffectState> _effects; // keyed by EffectBlockIndex
private byte _deviceGain = 255; // 0..255 from DeviceGain report
private byte _lastEbi; // last allocated EBI
private PidStateFlags _stateFlags; // ActuatorsEnabled | ActuatorPower
private EffectState _pending; // EBI=0 parameter bufferpid.dll's flow on CreateEffect is:
- Set Periodic / Set Constant / Set Ramp / Set Condition with
EBI=0. -
SetFeature(0x11)Create New Effect. Driver allocatesEBI=N. - Set Effect (0x11) with
EBI=Nto bind type, duration, direction.
Step 1 carries the magnitude / period; _pending captures it, then OnHidFeature (step 2) drains it into the freshly created effect at EBI=N. DecodeSetEffect also drains _pending when it sees a non-zero EBI, because some host stacks pick the EBI internally and never round-trip through SetFeature.
HMaestroFfbDecoder.cs:238 (method Apply). Aggregates running effects into the supplied Vibration:
- For each running, non-zero effect: gain-scale magnitude (
absMag * (es.Gain / 255.0)), pick dominant by gain-scaled magnitude, polar-split viasin((direction / 32767 * 360 + 180) % 360)intoleftScale/rightScale, accumulate intoleftSum/rightSum. - Apply device-level gain:
* (_deviceGain / 255.0). - Scale 0..10000 to 0..65535 and clamp. Write to
vib.LeftMotorSpeedandvib.RightMotorSpeed. - If any effect was running, set
vib.HasDirectionalData = trueand populateEffectType,SignedMagnitude,Direction,Period,DeviceGainfrom the dominant effect.ForceFeedbackStateconsumes these to driveSDL_HapticEffecton physical FFB devices. - If a Spring / Damper / Inertia / Friction effect is running, populate
vib.ConditionAxesfrom itsConditionAxisCountaxes, thenvib.HasConditionData = true.ForceFeedbackState.SetConditionHapticForcesmaps these toSDL_HapticCondition.
The "where force COMES FROM" to "toward" 180-degree shift is per HID PID 1.0: a force coming from East (90 degrees) pushes West, biasing the left motor.
Namespace: PadForge.Common.Input
Visibility: internal sealed
SDK dependency: Microsoft.Windows.Devices.Midi2 (Windows MIDI Services, from nuget-local/)
Max instances: 16 (MaxMidiSlots = MaxPads)
Availability: Requires Windows MIDI Services (Win11 recent builds only). MIDI button hidden when unavailable.
Creates a system-wide virtual MIDI endpoint via Windows MIDI Services. Appears in DAWs and MIDI applications as "PadForge MIDI N". Falls back gracefully without MIDI Services.
Type isolation: MIDI cards cannot switch to Xbox / PlayStation / Extended and vice versa. Type dropdown is disabled for MIDI slots.
| Field | Type | Description |
|---|---|---|
_isAvailable |
bool? |
Cached availability check result (nullable for first-check detection) |
_availLock |
object |
Lock protecting availability check (readonly) |
_initializer |
MidiDesktopAppSdkInitializer |
SDK initializer instance (kept alive for SDK lifetime) |
| Field | Type | Description |
|---|---|---|
_session |
MidiSession |
Windows MIDI Services session |
_connection |
MidiEndpointConnection |
Endpoint connection for sending messages |
_virtualDevice |
MidiVirtualDevice |
The virtual MIDI device (SuppressHandledMessages = true) |
_connected |
bool |
Whether this controller is connected |
_disposed |
bool |
Dispose guard |
_padIndex |
int |
Slot index (readonly) |
_channel |
int |
MIDI channel 0–15 (readonly, clamped via Math.Clamp) |
_instanceNum |
int |
1-based MIDI-type instance number (readonly) |
_lastCcValues |
byte[] |
Last sent CC values (change detection, initialized to 64 = center) |
_lastNotes |
bool[] |
Last sent note states (change detection) |
| Property | Type | Default | Description |
|---|---|---|---|
CcNumbers |
int[] |
{1, 2, 3, 4, 5, 6} |
MIDI CC numbers for each CC slot |
NoteNumbers |
int[] |
{60, 61, ..., 70} |
MIDI note numbers for each note slot (11 notes) |
Velocity |
byte |
127 |
Note-on velocity for button presses |
These are internal properties set by the mapping system before Connect(). They determine array sizes for change detection.
When a recognized gamepad is assigned to a MIDI slot, PadForge auto-maps:
-
6 axes to CC slots 0–5 (LX, LY, LT, RX, RY, RT ->
MidiCC0–MidiCC5) -
11 buttons to Note slots 0–10 (A, B, X, Y, LB, RB, Back, Start, LS, RS, Guide ->
MidiNote0–MidiNote10)
Same gamepad detection as the HM-backed slots (CapType == InputDeviceType.Gamepad). Non-gamepad devices get no auto-mapping.
| Property | Type | Description |
|---|---|---|
Type |
VirtualControllerType |
Always VirtualControllerType.Midi
|
IsConnected |
bool |
Read from _connected
|
FeedbackPadIndex |
int |
Slot index for feedback routing (unused. MIDI has no rumble) |
public MidiVirtualController(int padIndex, int channel, int instanceNum)Stores pad index, clamps channel to 0–15, stores 1-based instance number.
Returns early if already connected. Initialization sequence:
- Creates
MidiDeclaredEndpointInfowith name"PadForge MIDI {instanceNum}", product ID"PADFORGE_MIDI_{instanceNum}", MIDI 1.0 protocol. - Creates
MidiVirtualDeviceCreationConfigwith slot description. - Adds a
MidiFunctionBlock(bidirectional, Group 0,RepresentsMidi10Connection = YesBandwidthUnrestricted). -
MidiSession.Create(deviceName). Throws if null. - Creates virtual device via
MidiVirtualDeviceManager.CreateVirtualDevice(config).SuppressHandledMessages = true. - Creates
MidiEndpointConnectionto the device's endpoint ID. - Adds virtual device as message processing plugin.
- Opens connection. Throws if false.
- Sets
_connected = true. - Initializes
_lastCcValues(filled with 64) and_lastNotes.
Error handling: Steps 5–8 failure triggers full cleanup (disconnect, dispose session, null references) before re-throw. Prevents leaked MIDI sessions.
Returns early if not connected. Sequence:
- Sets
_connected = falseimmediately (prevents sends during cleanup). - Sends Note Off for held notes to prevent stuck notes in DAWs.
- Nulls
_lastNotes. - Disconnects endpoint, nulls
_connection. - Nulls
_virtualDevice. - Disposes and nulls
_session.
public void SubmitGamepadState(Gamepad gp)Legacy path. Not used for dynamic MIDI. Kept as a no-op for IVirtualController interface compliance.
Sends MIDI messages from MidiRawState. Returns immediately if not connected. Only sends on value change.
CC messages: Iterates min(state.CcValues.Length, _lastCcValues.Length, CcNumbers.Length). Changed CC values trigger SendCC(). Triple-min guards against mid-stream config changes.
Note messages: Same triple-min pattern. Changed notes trigger SendNoteOn() or SendNoteOff().
Thread safety: _connection is read into a local before null-check and send, preventing races with Disconnect().
// In PadForge.Engine/Common/GamepadTypes.cs
public struct MidiRawState
{
public byte[] CcValues; // CC values 0–127 per CC slot
public bool[] Notes; // Note on/off per note slot
public static MidiRawState Create(int ccCount, int noteCount);
}Dynamic-sized state struct. Create() allocates arrays of the specified sizes with CC values initialized to 64 (center).
No-op. MIDI has no rumble/force feedback.
Guarded by _disposed. Calls Disconnect().
All messages are built as MIDI 1.0 UMP (Universal MIDI Packet) via MidiMessageBuilder.BuildMidi1ChannelVoiceMessage() and sent via _connection.SendSingleMessagePacket().
| Helper | MIDI Status | Description |
|---|---|---|
SendCC(int ccNumber, byte value) |
ControlChange |
Sends CC on configured channel, group 0 |
SendNoteOn(int note, byte velocity) |
NoteOn |
Sends Note On on configured channel |
SendNoteOff(int note) |
NoteOff |
Sends Note Off (velocity 0) on configured channel |
Thread-safe, double-checked locking on _availLock. Caches result in _isAvailable.
- Fast path: if
_isAvailable.HasValue, returns cached value. - Under lock: creates
MidiDesktopAppSdkInitializer.Create(). -
InitializeSdkRuntime(). Disposes and caches false on failure. -
EnsureServiceAvailable(). Disposes and caches false on failure. - Success: keeps
_initializeralive (required for SDK lifetime), caches true. - Any exception: caches false.
Resets cached availability so IsAvailable() re-evaluates. Disposes _initializer if present, sets _isAvailable = null. Call after installing MIDI Services.
public static void Shutdown(bool skipDispose = false)Disposes the SDK initializer. Call on application exit.
skipDispose: When true, abandons the initializer without Dispose(). Use before uninstalling MIDI Services. Dispose() calls into the runtime and crashes if the service is being removed. Resets _isAvailable = null.
Namespace: PadForge.Common.Input
Visibility: internal sealed
No driver required. Always available on all Windows systems
Max instances: 16 (MaxPads)
Translates KbmRawState into keyboard and mouse input via Win32 SendInput. Maps controller inputs to key presses, mouse movement, clicks, and scroll. No virtual device. Output goes directly to the Windows input queue.
| Field | Type | Description |
|---|---|---|
_connected |
bool |
Connection state |
_disposed |
bool |
Dispose guard |
_padIndex |
int |
Slot index (readonly) |
_prevKeys0..3 |
ulong |
Previous key states for change detection (4 x 64 bits = 256 VK codes) |
_prevMouseButtons |
byte |
Previous mouse button state for change detection |
| Constant | Type | Value | Description |
|---|---|---|---|
MouseSensitivity |
float |
15.0f |
Pixels per frame at full axis deflection |
ScrollSensitivity |
float |
3.0f |
Lines per frame at full axis deflection |
| Property | Type | Description |
|---|---|---|
Type |
VirtualControllerType |
Always VirtualControllerType.KeyboardMouse
|
IsConnected |
bool |
Read from _connected
|
FeedbackPadIndex |
int |
Slot index (unused. KBM has no rumble) |
public KeyboardMouseVirtualController(int padIndex)Stores pad index. No resources acquired, no driver interaction.
Returns early if connected. Sets _connected = true and resets all tracking to zero. Lightweight. No virtual device created.
Returns early if not connected. Sets _connected = false then calls ReleaseAll().
ReleaseAll(): Sends key-up for all held keys and button-up for all held mouse buttons (XOR with 0 generates releases for every set bit). Resets tracking to zero. Prevents stuck keys/buttons on disconnect.
Guarded by _disposed. Calls Disconnect().
No-op. KBM uses SubmitKbmState() instead. Required by the IVirtualController interface.
Primary output method. Returns if not connected. Processes four input categories per frame:
1. Keyboard keys (change detection via XOR):
private void ProcessKeyWord(ulong current, ulong previous, int baseVk)
{
ulong changed = current ^ previous;
if (changed == 0) return; // fast path: no changes in this 64-key block
for (int bit = 0; bit < 64; bit++)
{
if ((changed & (1UL << bit)) == 0) continue;
bool pressed = (current & (1UL << bit)) != 0;
SendKeyboard((ushort)(baseVk + bit), pressed);
}
}Compares 4 ulong words (raw.Keys0..3) against previous frame via XOR. Only changed bits generate SendInput calls. Critical at 1000 Hz with 256 VK codes.
2. Mouse buttons (change detection):
Compares raw.MouseButtons against _prevMouseButtons via XOR.
| Bit | Button | Down Flag | Up Flag |
|---|---|---|---|
| 0 | Left (LMB) | MOUSEEVENTF_LEFTDOWN |
MOUSEEVENTF_LEFTUP |
| 1 | Right (RMB) | MOUSEEVENTF_RIGHTDOWN |
MOUSEEVENTF_RIGHTUP |
| 2 | Middle (MMB) | MOUSEEVENTF_MIDDLEDOWN |
MOUSEEVENTF_MIDDLEUP |
| 3 | XButton1 | MOUSEEVENTF_XDOWN |
MOUSEEVENTF_XUP |
| 4 | XButton2 | MOUSEEVENTF_XDOWN |
MOUSEEVENTF_XUP |
XButton1/XButton2 use mouseData field to specify which extra button.
3. Mouse movement (continuous, no change detection):
float mx = raw.MouseDeltaX / 32767.0f * MouseSensitivity; // pixels
float my = -(raw.MouseDeltaY / 32767.0f * MouseSensitivity); // Y invertedSigned short deltas (-32767 to +32767) scaled and sent as relative pixels via MOUSEEVENTF_MOVE. Y negated (raw up = screen down). Only sends if non-zero. Deadzone already applied in Step 3.
4. Mouse scroll (continuous, no change detection):
float scroll = raw.ScrollDelta / 32767.0f * ScrollSensitivity;
SendMouseWheel((int)(scroll * 120)); // 120 = WHEEL_DELTAraw.ScrollDelta (signed short) is scaled and multiplied by 120 (WHEEL_DELTA) for MOUSEEVENTF_WHEEL. Only sends if non-zero.
No-op. Keyboard/mouse has no rumble feedback.
public struct KbmRawState
{
public ulong Keys0, Keys1, Keys2, Keys3; // 256 VK codes packed into 4 x 64-bit words
public short MouseDeltaX; // Mouse X delta (signed, post-deadzone)
public short MouseDeltaY; // Mouse Y delta (signed, post-deadzone)
public short ScrollDelta; // Scroll delta (positive = up, post-deadzone)
public byte MouseButtons; // Bit 0=LMB, 1=RMB, 2=MMB, 3=X1, 4=X2
public short PreDzMouseDeltaX; // Mouse X before deadzone (for UI preview only)
public short PreDzMouseDeltaY; // Mouse Y before deadzone (for UI preview only)
public short PreDzScrollDelta; // Scroll before deadzone (for UI preview only)
public bool GetKey(byte vk); // Read bit for VK code
public void SetKey(byte vk, bool pressed); // Set bit for VK code
public bool GetMouseButton(int index); // Read bit 0-4
public void SetMouseButton(int index, bool pressed);
public void Clear(); // Zero all fields
public static KbmRawState Combine(KbmRawState a, KbmRawState b);
}Combine() merges two states: keys and mouse buttons OR'd, deltas take the largest absolute magnitude. Used when multiple devices map to one KBM slot.
[DllImport("user32.dll", SetLastError = true)]
private static extern uint SendInput(uint nInputs, INPUT[] pInputs, int cbSize);
[DllImport("user32.dll")]
private static extern uint MapVirtualKeyW(uint uCode, uint uMapType);Struct alignment (x64): INPUT uses LayoutKind.Sequential with an inner Explicit union at FieldOffset(0). On x64, ULONG_PTR fields need 8-byte alignment, so the union starts at offset 8. A flat Explicit layout with hardcoded offsets would break across architectures.
Key scan codes: SendKeyboard sets both wVk and wScan (via MapVirtualKeyW). Some games using DirectInput raw input require the scan code.
Individual calls: Each SendInput submits exactly 1 event. Batching key down + up would give identical timestamps, breaking some applications.
- Architecture Overview: Virtual controller types, slot system, per-type limits
-
Input Pipeline: Step 5 (
UpdateVirtualDevices) creates / destroys VCs and submits state -
Engine Library:
IVirtualControllerinterface,Gamepad,ExtendedRawState,KbmRawState,MidiRawState,Vibration - HIDMaestro Deep Dive: HM SDK surface, OpenXInput filter, Step 5 lifecycle invariants, FFB through HM PID descriptors
- Driver Installation Internals: HIDMaestro registration (no in-app uninstall) plus HidHide and Windows MIDI Services install / uninstall
-
Settings and Serialization:
VirtualControllerType([XmlEnum("Microsoft")]/[XmlEnum("Sony")]aliases) and per-slot HM profile persistence - Build and Publish: HIDMaestro and HidHide embedded resources