-
Notifications
You must be signed in to change notification settings - Fork 2
Virtual Controllers
Developer reference for all five IVirtualController implementations: initialization, state submission, axis/button mapping formulas, rumble/FFB, driver interaction, error handling, and type-specific quirks.
- Architecture Overview
- IVirtualController Interface
- Xbox360VirtualController
- DS4VirtualController
- VJoyVirtualController
- MidiVirtualController
- KeyboardMouseVirtualController
graph TB
subgraph Engine["PadForge.Engine (interface + types)"]
IVC["IVirtualController<br/><i>interface</i>"]
GP["Gamepad struct<br/>(XInput layout)"]
VRS["VJoyRawState"]
KRS["KbmRawState<br/>(256 VK + mouse)"]
MRS["MidiRawState<br/>(CC + notes)"]
end
subgraph App["PadForge.App (implementations)"]
X360["Xbox360VirtualController<br/>ViGEm Xbox 360"]
DS4["DS4VirtualController<br/>ViGEm DualShock 4"]
VJ["VJoyVirtualController<br/>vJoy DirectInput"]
KBM["KeyboardMouseVirtualController<br/>Win32 SendInput"]
MIDI["MidiVirtualController<br/>Windows MIDI Services"]
end
subgraph Drivers["OS / Driver Layer"]
VGM["ViGEmBus<br/>Kernel Driver"]
VJD["vJoy<br/>Kernel Driver"]
WIN["Windows<br/>Input Queue"]
WMS["Windows MIDI<br/>Services"]
end
IVC --> X360 & DS4 & VJ & KBM & MIDI
GP -->|"SubmitGamepadState()"| X360
GP -->|"SubmitGamepadState()"| DS4
GP -->|"SubmitGamepadState()"| VJ
VRS -->|"SubmitRawState()"| VJ
KRS -->|"SubmitKbmState()"| KBM
MRS -->|"SubmitMidiRawState()"| MIDI
X360 -->|"SubmitReport()"| VGM
DS4 -->|"SubmitReport()"| VGM
VJ -->|"UpdateVJD()<br/><i>single IOCTL</i>"| VJD
KBM -->|"SendInput()<br/><i>per event</i>"| WIN
MIDI -->|"SendSingleMessagePacket()<br/><i>per CC/note</i>"| WMS
VGM -.->|"FeedbackReceived<br/>(byte 0-255 motors)"| X360
VGM -.->|"FeedbackReceived<br/>(byte 0-255 motors)"| DS4
VJD -.->|"FfbGenCB<br/>(DirectInput FFB)"| VJ
style IVC fill:#fff3e0
style VGM fill:#e8f5e9
style VJD fill:#e8f5e9
style WIN fill:#e1f5fe
style WMS fill:#f3e5f5
| Property | Xbox360 | DS4 | vJoy | KBM | MIDI |
|---|---|---|---|---|---|
| Driver | ViGEmBus | ViGEmBus | vjoy.sys | None | Windows MIDI Services |
| NuGet | Nefarius.ViGEm.Client |
Nefarius.ViGEm.Client |
None (P/Invoke) | None (P/Invoke) | Microsoft.Windows.Devices.Midi2 |
| Submit method | SubmitGamepadState |
SubmitGamepadState |
SubmitGamepadState + SubmitRawState
|
SubmitKbmState |
SubmitMidiRawState |
| Axis format | short (-32768..32767) | byte (0..255, center=128) | int (0..32767) | short delta | byte CC (0..127) |
| Trigger format | byte (0..255) | byte (0..255) | int (0..32767) | N/A | byte CC (0..127) |
| Button format | Per-button API calls | Per-button API calls | Bitmask (128-bit) | Per-VK SendInput | Note On/Off |
| Rumble/FFB | ViGEm callback | ViGEm callback | DirectInput FFB | No | No |
| Change detection | Gamepad.Equals() |
Gamepad.Equals() |
None (always submits) | XOR per 64-bit word | Per CC/note value |
| Max instances | 16 | 16 | 16 | 16 | 16 |
| Always available | Requires ViGEmBus | Requires ViGEmBus | Requires vJoy driver | Yes | Requires MIDI Services |
Source files:
-
PadForge.Engine/Common/VirtualControllerTypes.cs— interface + enum PadForge.App/Common/Input/Xbox360VirtualController.csPadForge.App/Common/Input/DS4VirtualController.csPadForge.App/Common/Input/VJoyVirtualController.csPadForge.App/Common/Input/KeyboardMouseVirtualController.csPadForge.App/Common/Input/MidiVirtualController.cs
namespace PadForge.Engine
{
public enum VirtualControllerType
{
Xbox360 = 0,
DualShock4 = 1,
VJoy = 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 SwapSlotData
/// 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 Xbox 360, DS4, and vJoy (gamepad mode). Each implementation translates this to its native output format.
| 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 SwapSlotData during slot reordering. The callback captures the property reference (not a copy), so it always reads the current slot index after swaps.
All implementations have SubmitGamepadState(Gamepad gp) for interface compliance, but some types use alternative submit methods and leave it as a no-op:
-
Xbox 360 / DS4 / vJoy (gamepad mode):
SubmitGamepadState(Gamepad gp)— standard path -
vJoy (custom config):
SubmitRawState(VJoyRawState raw)— arbitrary axis/button/POV counts -
Keyboard+Mouse:
SubmitKbmState(KbmRawState raw)— keys, mouse, scroll -
MIDI:
SubmitMidiRawState(MidiRawState state)— CC values and note on/off
Namespace: PadForge.Common.Input
Visibility: internal sealed
NuGet dependency: Nefarius.ViGEm.Client
Max instances: 16 (MaxXbox360Slots = MaxPads in SettingsManager). XInput games see only the first 4; SDL/DirectInput games see all 16.
| Field | Type | Description |
|---|---|---|
_controller |
IXbox360Controller |
ViGEm Xbox 360 controller instance (readonly) |
_disposed |
bool |
Dispose guard |
_lastState |
Gamepad |
Cached previous state for change detection |
| Property | Type | Description |
|---|---|---|
Type |
VirtualControllerType |
Always VirtualControllerType.Xbox360
|
IsConnected |
bool |
True after Connect(), false after Disconnect()
|
FeedbackPadIndex |
int |
Slot index for rumble callback routing |
public Xbox360VirtualController(ViGEmClient client)Creates an Xbox 360 virtual controller via client.CreateXbox360Controller(). The ViGEmClient is owned externally by InputManager; multiple controllers share one client. No driver communication at construction — that happens at Connect().
Calls _controller.Connect() on the ViGEm bus. Windows then sees a new Xbox 360 controller (Device Manager, joy.cpl, XInput). Sets IsConnected = true. No double-connect guard — ViGEm throws if already connected.
Calls _controller.Disconnect(). Removes the virtual device from Windows. Sets IsConnected = false. No double-disconnect guard.
Guarded by _disposed. Calls Disconnect() if connected, then (_controller as IDisposable)?.Dispose(). The cast is needed because IXbox360Controller does not extend IDisposable.
Maps Gamepad to Xbox 360 report format and submits via _controller.SubmitReport().
Change detection: Compares incoming Gamepad against _lastState via Equals(). If unchanged, returns immediately — no API calls, no SubmitReport(). Avoids ~15 per-button/axis calls plus one IOCTL at 1000 Hz when idle.
Buttons (15 buttons, direct bitmask check → per-button SetButtonState call):
| Gamepad Constant | Xbox360Button | Bit |
|---|---|---|
Gamepad.A |
Xbox360Button.A |
0x1000 |
Gamepad.B |
Xbox360Button.B |
0x2000 |
Gamepad.X |
Xbox360Button.X |
0x4000 |
Gamepad.Y |
Xbox360Button.Y |
0x8000 |
Gamepad.LEFT_SHOULDER |
Xbox360Button.LeftShoulder |
0x0100 |
Gamepad.RIGHT_SHOULDER |
Xbox360Button.RightShoulder |
0x0200 |
Gamepad.BACK |
Xbox360Button.Back |
0x0020 |
Gamepad.START |
Xbox360Button.Start |
0x0010 |
Gamepad.LEFT_THUMB |
Xbox360Button.LeftThumb |
0x0040 |
Gamepad.RIGHT_THUMB |
Xbox360Button.RightThumb |
0x0080 |
Gamepad.GUIDE |
Xbox360Button.Guide |
0x0400 |
Gamepad.DPAD_UP |
Xbox360Button.Up |
0x0001 |
Gamepad.DPAD_DOWN |
Xbox360Button.Down |
0x0002 |
Gamepad.DPAD_LEFT |
Xbox360Button.Left |
0x0004 |
Gamepad.DPAD_RIGHT |
Xbox360Button.Right |
0x0008 |
Axes (signed short, direct passthrough — no conversion needed):
| Gamepad Field | Xbox360Axis | Range |
|---|---|---|
ThumbLX |
LeftThumbX |
-32768..32767 |
ThumbLY |
LeftThumbY |
-32768..32767 |
ThumbRX |
RightThumbX |
-32768..32767 |
ThumbRY |
RightThumbY |
-32768..32767 |
Triggers (ushort 0–65535 to byte 0–255 via right shift):
| Gamepad Field | Xbox360Slider | Formula |
|---|---|---|
LeftTrigger |
LeftTrigger |
(byte)(gp.LeftTrigger >> 8) |
RightTrigger |
RightTrigger |
(byte)(gp.RightTrigger >> 8) |
Quirk: Xbox 360 is the only VC type where axes pass through without conversion. DS4 inverts Y and rescales to byte; vJoy rescales to unsigned 0–32767 and inverts Y.
Sets FeedbackPadIndex = padIndex, then subscribes to _controller.FeedbackReceived:
- Reads
FeedbackPadIndex(not a captured copy — always current slot after swaps). - Bounds-checks
idx >= 0 && idx < vibrationStates.Length. - Reads
args.LargeMotorandargs.SmallMotor(byte 0–255, from ViGEm bus). - Scales to ushort via
* 257(maps 0–255 to 0–65535; 255 * 257 = 65535). - Writes to
vibrationStates[idx].LeftMotorSpeed/.RightMotorSpeed. - Logs via
RumbleLogger.Log()only when values change.
Threading: Runs on the ViGEm thread. Vibration fields are ushort; aligned writes are atomic on x86/x64. The polling thread reads these for rumble forwarding via SDL_RumbleJoystick.
Namespace: PadForge.Common.Input
Visibility: internal sealed
NuGet dependency: Nefarius.ViGEm.Client
Max instances: 16 (MaxDS4Slots = MaxPads in SettingsManager). SDL/DirectInput games see all 16.
| Field | Type | Description |
|---|---|---|
_controller |
IDualShock4Controller |
ViGEm DualShock 4 controller instance (readonly) |
_disposed |
bool |
Dispose guard |
_lastState |
Gamepad |
Cached previous state for change detection |
| Property | Type | Description |
|---|---|---|
Type |
VirtualControllerType |
Always VirtualControllerType.DualShock4
|
IsConnected |
bool |
True after Connect(), false after Disconnect()
|
FeedbackPadIndex |
int |
Slot index for rumble callback routing |
public DS4VirtualController(ViGEmClient client)Creates a DS4 virtual controller via client.CreateDualShock4Controller(). Same ownership pattern as Xbox360 — ViGEmClient is external.
Same pattern as Xbox360VirtualController.
Maps Gamepad to DS4 report format. Differences from Xbox 360: Y-axis inversion, byte-range axes, hat-switch D-Pad, and digital trigger buttons.
Change detection: Same as Xbox360 — returns early when Gamepad equals _lastState.
Face buttons (Xbox naming to PlayStation naming):
| Gamepad Constant | DualShock4Button |
|---|---|
Gamepad.A |
DualShock4Button.Cross |
Gamepad.B |
DualShock4Button.Circle |
Gamepad.X |
DualShock4Button.Square |
Gamepad.Y |
DualShock4Button.Triangle |
Shoulder buttons:
| Gamepad Constant | DualShock4Button |
|---|---|
Gamepad.LEFT_SHOULDER |
DualShock4Button.ShoulderLeft |
Gamepad.RIGHT_SHOULDER |
DualShock4Button.ShoulderRight |
Center buttons:
| Gamepad Constant | DualShock4Button |
|---|---|
Gamepad.BACK |
DualShock4Button.Share |
Gamepad.START |
DualShock4Button.Options |
Thumbstick clicks:
| Gamepad Constant | DualShock4Button |
|---|---|
Gamepad.LEFT_THUMB |
DualShock4Button.ThumbLeft |
Gamepad.RIGHT_THUMB |
DualShock4Button.ThumbRight |
Special buttons (uses separate DualShock4SpecialButton type):
| Gamepad Constant | DS4 Special Button |
|---|---|
Gamepad.GUIDE |
DualShock4SpecialButton.Ps |
Digital trigger buttons (DS4-specific — pressed when analog trigger > 0):
_controller.SetButtonState(DualShock4Button.TriggerLeft, gp.LeftTrigger > 0);
_controller.SetButtonState(DualShock4Button.TriggerRight, gp.RightTrigger > 0);Some games (especially PS4 ports) check digital L2/R2 in addition to the analog value. Set whenever the analog trigger is non-zero.
DS4 uses a hat switch instead of individual D-Pad buttons. GetDPadDirection() converts 4 booleans to one of 9 DualShock4DPadDirection values:
private static DualShock4DPadDirection GetDPadDirection(bool up, bool down, bool left, bool right)Priority: diagonals first, then cardinals, then None. Matches physical DS4 D-Pad behavior.
DS4 axes use unsigned byte (0–255, center 128). Y-axes are inverted (DS4: 0=up, 255=down; Xbox: positive=up, negative=down).
Conversion helpers:
// X-axis: offset from signed to unsigned, scale to byte
private static byte ShortToByte(short value) => (byte)((value + 32768) >> 8);
// Y-axis: invert then scale — 0=up becomes 255=down
private static byte ShortToByteInvertY(short value) => (byte)((32767 - value) >> 8);| Gamepad Field | DualShock4Axis | Conversion | Formula |
|---|---|---|---|
ThumbLX |
LeftThumbX |
ShortToByte |
(value + 32768) >> 8 |
ThumbLY |
LeftThumbY |
ShortToByteInvertY |
(32767 - value) >> 8 |
ThumbRX |
RightThumbX |
ShortToByte |
(value + 32768) >> 8 |
ThumbRY |
RightThumbY |
ShortToByteInvertY |
(32767 - value) >> 8 |
Triggers (ushort 0–65535 to byte 0–255 — same formula as Xbox360):
| Gamepad Field | DualShock4Slider | Formula |
|---|---|---|
LeftTrigger |
LeftTrigger |
(byte)(gp.LeftTrigger >> 8) |
RightTrigger |
RightTrigger |
(byte)(gp.RightTrigger >> 8) |
Finishes with _controller.SubmitReport().
Same as Xbox360. Uses #pragma warning disable CS0618 because FeedbackReceived is marked obsolete in the ViGEm client library but remains the only working feedback mechanism.
Namespace: PadForge.Common.Input
Visibility: internal sealed
No NuGet dependency — uses direct P/Invoke to vJoyInterface.dll
Max instances: 16 (MaxVJoySlots in SettingsManager)
The most complex implementation. Manages driver-level device nodes, HID report descriptors, registry entries, and FFB routing alongside standard gamepad output. Also contains two companion static classes (CfgMgr32 and SetupApiRestart) for device node management P/Invoke.
CfgMgr32 — CfgMgr32.dll P/Invoke for direct device node management:
-
CM_Locate_DevNodeW()/CM_Disable_DevNode()— used as fallback when SetupAPI DICS_DISABLE fails
SetupApiRestart — SetupAPI P/Invoke for device node lifecycle:
-
RestartDevice(instanceId)— DICS_PROPCHANGE (same asdevcon.exe restart) -
DisableDevice(instanceId)— DICS_DISABLE -
EnableDevice(instanceId)— DICS_ENABLE -
RemoveDevice(instanceId)— SetupDiRemoveDevice (forceful removal)
Same as ViGEm (Xbox 360/DS4): controllers only appear in joy.cpl when BOTH conditions are met:
- A physical device is mapped to the slot
- That device is connected and online
Created in ascending slot order so ViGEm assigns sequential indices matching PadForge slot numbers. Dashboard shows an initializing indicator during creation or reconfiguration (e.g., descriptor change triggering a node restart).
| Field | Type | Description |
|---|---|---|
_dllLoaded |
bool |
Whether vJoyInterface.dll has been successfully loaded |
_currentDescriptorCount |
int |
Number of DeviceNN registry descriptors currently written |
_driverStoreChecked |
bool |
Whether driver store check has run this session |
_generation |
int |
Incremented on device node restart; triggers handle re-acquire |
_ffbLock |
object |
Lock protecting FFB state dictionaries |
_ffbCallbackRegistered |
bool |
Whether the global FFB callback is registered |
_ffbCallbackDelegate |
VJoyNative.FfbGenCB |
Strong reference to prevent GC of the native callback delegate |
_ffbDeviceMap |
Dictionary<uint, (int padIndex, Vibration[] states)> |
Routes vJoy device ID to vibration output slot |
_ffbDeviceStates |
Dictionary<uint, FfbDeviceState> |
Per-device FFB effect tracking |
_lastDeviceConfigs |
VJoyDeviceConfig[] |
Cached per-device configs from last EnsureDevicesAvailable call |
_cachedInstanceIds |
List<string> |
Cached PnP instance IDs from last enumeration (avoids expensive pnputil on shutdown) |
| Property | Type | Description |
|---|---|---|
CurrentDescriptorCount |
int |
Read-only accessor for _currentDescriptorCount. Used by Step 5 to detect scale-down |
IsDllLoaded |
bool |
Whether vJoyInterface.dll is loaded |
DiagLogEnabled |
bool |
Set to true to enable diagnostic logging to vjoy_diag.log in app directory |
| Field | Type | Description |
|---|---|---|
_deviceId |
uint |
vJoy device ID (1–16), readonly |
_connected |
bool |
Whether this controller is connected |
_connectedGeneration |
int |
Generation at time of Connect()
|
_reacquireFailCount |
int |
Consecutive re-acquire failures (resets on success) |
_submitCallCount |
int |
Diagnostic counter for SubmitGamepadState calls |
_submitFailCount |
int |
Diagnostic counter for failed UpdateVJD calls |
| Constant | Value | Description |
|---|---|---|
MaxReacquireRetries |
50 | Max consecutive re-acquire attempts (~50ms at 1kHz) before disconnecting |
| Property | Type | Description |
|---|---|---|
Type |
VirtualControllerType |
Always VirtualControllerType.VJoy
|
IsConnected |
bool |
Read from _connected
|
DeviceId |
uint |
vJoy device ID (1–16) |
FeedbackPadIndex |
int |
Slot index for FFB callback routing |
public VJoyVirtualController(uint deviceId)Validates deviceId is in range 1–16. Throws ArgumentOutOfRangeException if not. No driver interaction at construction.
internal static void EnsureDllLoaded()Preloads vJoyInterface.dll via NativeLibrary.TryLoad. Search order:
- If
_dllLoaded, return immediately. - Default search paths.
-
C:\Program Files\vJoy\vJoyInterface.dll. -
C:\Program Files\vJoy\x64\vJoyInterface.dll(legacy arch subdirs). - Only sets
_dllLoaded = trueon success. Retries if not found (supports hot-install).
IMPORTANT: Do NOT use NativeLibrary.SetDllImportResolver — it hijacks the entire assembly's DLL resolution, breaking other P/Invoke calls.
internal static void ResetState()Resets all cached static state: _dllLoaded = false, _currentDescriptorCount = 0, _driverStoreChecked = false, increments _generation. Called after driver reinstall so the engine picks up the new driver without restarting PadForge.
internal static void DiagLog(string msg)Writes timestamped messages to vjoy_diag.log when DiagLogEnabled is true. Format: [vJoy HH:mm:ss.fff] msg. Fire-and-forget (catches all exceptions).
- Calls
EnsureDllLoaded(). - Calls
VJoyNative.GetVJDStatus(_deviceId)— must returnVJD_STAT_FREE. - Calls
VJoyNative.AcquireVJD(_deviceId). - Calls
VJoyNative.ResetVJD(_deviceId)to zero all axes/buttons. - Sets
_connected = true, captures_connectedGeneration = _generation, resets_reacquireFailCount = 0. - Sends a test frame via
UpdateVJDwith axes at center (16383) and all POV hats centered (0xFFFF_FFFF). Logs whether the test succeeded.
Throws InvalidOperationException if device is not free or acquisition fails. All steps logged via DiagLog.
- Logs diagnostic info including total submit call/fail counts.
- Removes FFB routing for this device from
_ffbDeviceMapand_ffbDeviceStates(under_ffbLock). - Calls
VJoyNative.ResetVJD(_deviceId)thenVJoyNative.RelinquishVJD(_deviceId). - Sets
_connected = false.
Calls Disconnect() directly. No separate _disposed guard — Disconnect() itself checks _connected.
Called by Step 5 after EnsureDevicesAvailable so existing controllers re-claim their device IDs BEFORE FindFreeDeviceId() runs for new controllers. Prevents a race where a new controller steals an ID being reclaimed.
Flow:
- If
_connectedGeneration == _generation(no restart occurred), returns immediately. - If
_deviceId > CurrentDescriptorCount, this device ID no longer exists in the registry (scale-down). Disconnects immediately for ID reassignment — does NOT callRelinquishVJDon a non-existent device (would corrupt DLL internal state). - Increments
_reacquireFailCount. AfterMaxReacquireRetries(50) consecutive failures, disconnects permanently so Step 5 can recreate with a fresh controller. - Calls
RelinquishVJDthenAcquireVJD. On success: resets the device, updates_connectedGeneration, resets fail counter. - On failure: returns silently (retry next polling cycle, ~1ms later).
public void SubmitGamepadState(Gamepad gp)Uses a single UpdateVJD(rID, ref JoystickPositionV2) call per frame (1 kernel IOCTL). NEVER use individual SetAxis/SetBtn/SetDiscPov calls — each is a separate IOCTL (~1–2ms), dropping 1000 Hz to 11 Hz with 2 controllers.
Generation check: If _connectedGeneration != _generation, calls ReAcquireIfNeeded() to relinquish and re-acquire. Disconnects after MaxReacquireRetries (50) failures. Returns immediately if not connected or generation still mismatches.
Diagnostics: Logs the first call and every 5000th call with axis values, button bitmask, POV, and fail count.
Axis conversion (signed short to vJoy unsigned 0–32767):
int lx = (gp.ThumbLX + 32768) / 2;
int ly = 32767 - (gp.ThumbLY + 32768) / 2; // Y inverted (HID Y-down=max)
int rx = (gp.ThumbRX + 32768) / 2;
int ry = 32767 - (gp.ThumbRY + 32768) / 2; // Y inverted
int lt = gp.LeftTrigger * 32767 / 65535;
int rt = gp.RightTrigger * 32767 / 65535;Y-axis inversion: HID convention is Y-down=max value. Formula: 32767 - (value + 32768) / 2.
Axis mapping to JoystickPositionV2 fields:
| Gamepad | JoystickPositionV2 Field |
|---|---|
ThumbLX |
wAxisX |
ThumbLY |
wAxisY (inverted) |
LeftTrigger |
wAxisZ |
ThumbRX |
wAxisXRot |
ThumbRY |
wAxisYRot (inverted) |
RightTrigger |
wAxisZRot |
Button bitmask (11 buttons, positions 0–10): A, B, X, Y, LB, RB, Back, Start, LS, RS, Guide.
D-Pad to continuous POV hat (centidegrees):
| Direction | Value |
|---|---|
| North | 0 |
| Northeast | 4500 |
| East | 9000 |
| Southeast | 13500 |
| South | 18000 |
| Southwest | 22500 |
| West | 27000 |
| Northwest | 31500 |
| Centered | -1 (stored as 0xFFFF_FFFF) |
Unused POV hats (bHatsEx1, bHatsEx2, bHatsEx3) are set to 0xFFFF_FFFF (centered).
Submits VJoyRawState directly, bypassing Gamepad. Used for custom vJoy configs with arbitrary axis/button/POV counts. Performs generation-check and re-acquire before submission.
Axis mapping (supports up to 8 axes; raw.Axes is short[]):
Axes are converted from signed short (-32768..32767) to vJoy unsigned (0..32767): (raw.Axes[i] + 32768) / 2.
NOTE: Unlike SubmitGamepadState, raw mode does NOT invert Y-axes — the caller is responsible for any axis inversion.
| Index | JoystickPositionV2 Field | HID Usage |
|---|---|---|
| 0 | wAxisX |
X |
| 1 | wAxisY |
Y |
| 2 | wAxisZ |
Z |
| 3 | wAxisXRot |
RX |
| 4 | wAxisYRot |
RY |
| 5 | wAxisZRot |
RZ |
| 6 | wSlider |
Slider |
| 7 | wDial |
Dial |
Axes beyond index 7 are unmapped. JoystickPositionV2 supports 16 axes, but only 8 are wired in the raw submit path.
Button mapping: raw.Buttons is uint[] where each uint is 32 button bits. Maps to lButtons, lButtonsEx1, lButtonsEx2, lButtonsEx3 (128 buttons total, 4 words max).
POV mapping: raw.Povs is int[]. Value -1 = centered (0xFFFFFFFF), else centidegree value (0–35900). Maps to bHats, bHatsEx1, bHatsEx2, bHatsEx3 (4 POVs max).
Registers this device for FFB routing:
- Sets
FeedbackPadIndex = padIndex. - Under
_ffbLock, adds_deviceId -> (padIndex, vibrationStates)to_ffbDeviceMap. - If
_ffbCallbackRegisteredis false, registersFfbCallbackviaVJoyNative.FfbRegisterGenCB(). - Stores delegate in
_ffbCallbackDelegateto prevent GC collection. - Catches
DllNotFoundExceptionand general exceptions (logs viaDiagLog).
The global callback is shared across all vJoy devices, routing by device ID. Only one FfbRegisterGenCB call is made; subsequent calls just add routing map entries.
internal static void UpdateFfbPadIndex(int slotA, int slotB)Updates FFB device map after a slot swap. Under _ffbLock, swaps slotA/slotB references in _ffbDeviceMap. Called by SwapSlotData to keep FFB routing consistent.
private class FfbDeviceState
{
public byte DeviceGain = 255; // 0-255, default 100%
public Dictionary<byte, FfbEffectState> Effects = new(); // keyed by EffectBlockIndex
}private class FfbEffectState
{
public FFBEType Type;
public int Magnitude; // signed for constant (-10000..+10000), absolute for others (0..10000)
public byte Gain = 255; // per-effect gain from effect report (0-255)
public ushort Duration; // ms, 0xFFFF=infinite
public bool Running;
public ushort Direction; // polar direction 0-32767 (HID logical units, maps to 0-360 degrees)
public uint Period; // ms, for periodic effects (Sine, Square, Triangle, etc.)
public FfbConditionAxis[] ConditionAxes = new FfbConditionAxis[2];
public int ConditionAxisCount;
}private struct FfbConditionAxis
{
public short CenterPointOffset; // -10000 to +10000
public short PosCoeff; // -10000 to +10000
public short NegCoeff; // -10000 to +10000
public uint PosSatur; // 0-10000
public uint NegSatur; // 0-10000
public int DeadBand; // 0-10000
public bool IsY;
}Per-axis condition data stored on PT_CONDREP packets. IsY distinguishes X (index 0) from Y (index 1). vJoy sends one CONDREP per axis; ConditionAxisCount tracks received axes.
private static void FfbCallback(IntPtr data, IntPtr userData)Global callback invoked by vJoyInterface.dll on its thread pool. Routes FFB packets by device ID.
| Packet Type | Handler | Description |
|---|---|---|
PT_EFFREP |
Ffb_h_Eff_Report |
Set Effect Report: type, gain, direction, duration |
PT_CONSTREP |
Ffb_h_Eff_Constant |
Set Constant Force: signed magnitude (-10000..+10000) |
PT_PRIDREP |
Ffb_h_Eff_Period |
Set Periodic (Sine/Square/Triangle): unsigned magnitude (0..10000) |
PT_RAMPREP |
Ffb_h_Eff_Ramp |
Set Ramp Force: uses max of abs(Start), abs(End) |
PT_CONDREP |
Ffb_h_Eff_Cond |
Set Condition (Spring/Damper/Friction/Inertia): uses max of pos/neg coefficients |
PT_EFOPREP |
Ffb_h_EffOp |
Effect Operation: EFF_START, EFF_SOLO, EFF_STOP |
PT_GAINREP |
Ffb_h_DevGain |
Device Gain (0–255) |
PT_CTRLREP |
Ffb_h_DevCtrl |
Device Control: CTRL_STOPALL, CTRL_DEVRST, CTRL_DISACT |
PT_BLKFRREP |
Ffb_h_EffectBlockIndex |
Block Free: deletes effect |
Computes aggregate motor output from all running effects and writes to VibrationStates[]. Called after every FFB packet that could change motor output.
Directional motor split (polar direction mapping):
For each running effect with non-zero magnitude:
- Gain-scaled magnitude:
absMag * (effectGain / 255.0) - Constant force with negative magnitude: flips direction 180 degrees
- HID polar direction (0–32767) to degrees:
angleDeg = (direction / 32767.0) * 360.0 - Splits into left/right motor bias using sine:
sinVal = sin(angleRad) leftScale = clamp(0.5 - sinVal * 0.5, 0, 1) // sin(270deg)=-1 → full left rightScale = clamp(0.5 + sinVal * 0.5, 0, 1) // sin(90deg)=+1 → full right - Accumulates:
leftSum += mag * leftScale,rightSum += mag * rightScale
Post-accumulation:
- Applies device-level gain:
leftSum *= deviceGain / 255.0(same for right) - Scales 0–10000 to 0–65535:
(ushort)(sum * 65535.0 / 10000.0), clamped - Writes to
vibrationStates[padIndex].LeftMotorSpeed/.RightMotorSpeed
Directional data passthrough (for haptic FFB devices):
Tracks the dominant running effect and populates Vibration.HasDirectionalData, .EffectType, .SignedMagnitude, .Direction, .Period, .DeviceGain. Consumed by ForceFeedbackState to drive SDL_HapticEffect on directional devices (joysticks, wheels).
Condition data passthrough (Spring/Damper/Friction/Inertia):
Populates Vibration.HasConditionData, .ConditionAxes[], .ConditionAxisCount. Per-axis data (coefficients, center offset, dead band, saturation) passes through to ForceFeedbackState.SetConditionHapticForces(), which maps to SDL_HapticCondition. NumHapticAxes determines 1-axis (wheels) vs 2-axis (joysticks) output.
Logging: Only logs when motor values change. Writes to both DiagLog (file) and RumbleLogger (shared diagnostic).
Returns true if vJoy is installed and enabled. Calls EnsureDllLoaded() then VJoyNative.vJoyEnabled(). Catches all exceptions (returns false).
Scans IDs 1–16, returns first with VJD_STAT_FREE. Returns 0 if none free. Non-blocking — no process spawning or registry access.
Checks all vJoy IDs 1..count report VJD_STAT_FREE. Used by the wait loop in EnsureDevicesAvailable to confirm ALL devices are ready after a node restart.
Checks if vjoy service is in STOP_PENDING via sc.exe query vjoy. This zombie state occurs when a previous uninstall removed the service before its device nodes — only a reboot clears it.
Delegates to EnumerateVJoyInstanceIds().Count.
Enumerates vJoy instance IDs via pnputil /enum-devices /class HIDClass, filtering for ROOT\HIDCLASS\* with "vJoy" description. Caches in _cachedInstanceIds for fast shutdown. More reliable than GetVJDStatus (stale DLL namespace cache).
Forces vJoyInterface.dll to close its stale h0 handle and clear its namespace cache. Finds the DLL's hidden window ("win32app_vJoyInterface_DLL") via FindWindowW and sends WM_DEVICECHANGE / DBT_DEVICEQUERYREMOVE. The Brunner fork's WndProc closes all handles and sets h0 = INVALID_HANDLE_VALUE; the next API call lazily re-opens it. Returns silently if the window does not exist.
Without this, DICS_DISABLE takes ~5s waiting for handle timeout. With it, disable is near-instant.
public static bool EnsureDevicesAvailable(int requiredCount, VJoyDeviceConfig[] perDeviceConfigs)
public static bool EnsureDevicesAvailable(int requiredCount = 1)Ensures N vJoy virtual joysticks are available. Architecture: ONE device node + N registry descriptor keys.
VJoyDeviceConfig struct:
public struct VJoyDeviceConfig
{
public int Axes; // 0-8
public int Buttons; // 0-128
public int Povs; // 0-4
public int Sticks; // thumbsticks (each uses 2 axes, informational)
public int Triggers; // each uses 1 axis (informational)
}Flow:
-
First call per session: Runs
EnsureDriverInStore()(adds vjoy.inf to Windows driver store if missing) andEnsureFfbRegistryKeys()(writes OEMForceFeedback DirectInput registry keys). -
Config change detection: Compares
perDeviceConfigsagainst_lastDeviceConfigselement-by-element (Axes, Buttons, Povs). -
Fast path: If
_currentDescriptorCount == requiredCount, configs match, and DLL is loaded, returns immediately. This is the 1000 Hz hot path. -
Registry update: Calls
WriteDeviceDescriptors()which returns true only if actual registry bytes changed. -
requiredCount == 0: Fully removes the device node via
DisableDeviceNode()(disable then remove, not just disable — ensures child PDOs disappear from WMI). -
No existing node: Creates one via
CreateVJoyDevices(1), waits up to 5s (20 x 250ms) for PnP binding, checkingAllDevicesReady(). - Excess nodes (>1): Removes extras, keeps the first one. Forces descriptor restart.
-
Descriptors changed: Restarts the node via
RestartDeviceNode(countChanged: true). Waits up to 5s for all devices to become ready. - Increments
_generationon any restart so connected controllers know to re-acquire viaReAcquireIfNeeded.
totalVJoyNeeded race fix: Step 5 must count via SlotControllerTypes[i] == VJoy && SlotCreated[i], NOT VC lifecycle state. During EnsureTypeGroupOrder bubble sort on the UI thread, the polling thread can see transient state where a vJoy slot's type was swapped mid-sort, causing undercount and registry descriptor deletion.
Strategy depends on whether the descriptor count changed:
-
Content-only (
countChanged=false):DICS_PROPCHANGErestarts the driver stack in-place, re-reading HID descriptors without recreating child PDOs. Fastest path. -
Count change (
countChanged=true): Must fully remove + recreate because HIDCLASS only creates child PDOs duringEvtDeviceAdd.DICS_PROPCHANGEalone re-reads descriptors but does NOT create new PDOs.
Full restart sequence:
-
RelinquishAllDevices()— releases all handles (1–16). -
RefreshVJoyDllHandles()— closes DLL's staleh0handle. - Try
SetupApiRestart.DisableDevice()(DICS_DISABLE). Fallback:CfgMgr32.CM_Disable_DevNode(). - Try
SetupApiRestart.RemoveDevice(). Fallback:pnputil /remove-device /subtree. Fallback:SetupApiRestart.RemoveDevice()without prior disable. - If all remove attempts fail: try
DICS_PROPCHANGEas last resort, then re-enable if disabled. - On successful remove:
pnputil /scan-devices, thenCreateVJoyDevices(1), wait up to 5s for ready. - Increments
_generation, resets_dllLoaded.
Creates device nodes via an elevated PowerShell script (-NoProfile -ExecutionPolicy Bypass):
- Ensures vjoy.sys service registry key exists (
HKLM\...\services\vjoy), copiesvjoy.systoSystem32\drivers\if needed. - For each device:
SetupDiCreateDeviceInfoList(HIDClass GUID)->SetupDiCreateDeviceInfoW("HIDClass", DICD_GENERATE_ID)-> set HWID ->SetupDiCallClassInstaller(DIF_REGISTERDEVICE). -
Critical: DeviceName must be
"HIDClass"(class name), NOT the hardware ID. Using the HWID would fail silently. -
UpdateDriverForPlugAndPlayDevicesW(hwid, infPath, 0, ...)with flag 0 (noINSTALLFLAG_FORCE). Flag 0 only binds unmatched nodes —INSTALLFLAG_FORCE(1) would re-bind ALL matching devices, creating duplicate HID children and invalidating existing handles. - App runs elevated when vJoy is installed (auto-elevation in App.xaml.cs), so no
Verb="runas"needed. Timeout: 30 seconds.
Result is written to a temp log file (OK:N on success, FAIL:... on error).
Fully removes the vJoy device node. Sequence:
-
RelinquishAllDevices()+RefreshVJoyDllHandles()to release handles. -
SetupApiRestart.DisableDevice(). Fallback:CfgMgr32.CM_Disable_DevNode(). - Wait 500ms, then
SetupApiRestart.RemoveDevice(). Fallback:pnputil /remove-device. Fallback: remove without prior disable. - Increments
_generation, resets_dllLoaded. - On successful remove: synchronous
pnputil /scan-devicesto clean up ghost child PDOs. Must be synchronous — async scan races with ViGEm VC creation on the next polling cycle.
Removes a single device node via pnputil /remove-device "{instanceId}" /subtree. Exit code 3010 (reboot required) still counts as success. On success, fires async pnputil /scan-devices to clean up ghost PDOs in joy.cpl.
Removes ALL vJoy device nodes. Uses _cachedInstanceIds when available to skip pnputil enumeration (~5s saving on shutdown). Tries SetupApiRestart.RemoveDevice() first, falls back to RemoveDeviceNode(). Resets static state. Fire-and-forget pnputil /scan-devices on success.
Calls RelinquishVJD(i) for IDs 1–16. Best-effort (catches all exceptions). Must precede disable/remove — otherwise CM_Disable_DevNode returns CR_REMOVE_VETOED (23) because the DLL still holds the device.
Writes HID report descriptors to HKLM\SYSTEM\CurrentControlSet\services\vjoy\Parameters\DeviceNN. Returns true if any registry changes occurred (writes or deletions).
Behavior:
- Opens
HKLM\..\services\vjoy\Parametersfor write access. -
Deletes excess DeviceNN keys beyond
requiredCount. - For each device 1..requiredCount: builds descriptor via
BuildHidDescriptor(), then compares byte-by-byte against existingHidReportDescriptorvalue. Only writes if different. - Each key stores two values:
HidReportDescriptor(REG_BINARY) +HidReportDescriptorSize(REG_DWORD). - Default config when no
perDeviceConfigsprovided: 6 axes, 11 buttons, 1 POV (Xbox 360 layout).
Why compare before write: The driver reads descriptors at EvtDeviceAdd time; changing them without a node restart has no effect, but unnecessary writes break change detection.
Builds a HID Report Descriptor matching vJoyConf format. Inputs clamped: nAxes 0–8, nButtons 0–128, nPovs 0–4.
Fixed 97-byte report layout: 1 byte report ID + 16 axes x 4 bytes + 4 POV DWORDs + 128 button bits (16 bytes). Disabled axes/POVs/buttons are constant padding so offsets always match.
Axis usages (HID Generic Desktop page):
| Index | Usage | HID Code |
|---|---|---|
| 0 | X | 0x30 |
| 1 | Y | 0x31 |
| 2 | Z | 0x32 |
| 3 | RX | 0x33 |
| 4 | RY | 0x34 |
| 5 | RZ | 0x35 |
| 6 | Slider | 0x36 |
| 7 | Dial | 0x37 |
Active axes emit INPUT (Data, Var, Abs) (0x81, 0x02). Inactive axes emit INPUT (Cnst, Ary, Abs) (0x81, 0x01).
POV hats: Continuous POV using degree values x 100 (0–35900). Active POVs get Usage 0x39 (Hat Switch) + Data. Remaining slots are constant padding. Total: 4 x 32-bit DWORDs.
Buttons: Usage Page 0x09 (Button), 1-bit per button, padded to 128 bits total.
FFB: Appends the full PID descriptor via AppendFfbDescriptor() inside the Application collection.
See vJoy Deep Dive for full descriptor byte sequences.
Appends the full PID (Physical Interface Device) HID descriptor for FFB. Transcribed from vJoy-Brunner's hidReportDescFfb.h.
Report ID offset: baseId + 0x10 * reportId (1-based). Device 1 starts at 0x11, device 2 at 0x21. CRITICAL: Must use 0x10 * reportId, NOT 0x10 * (reportId - 1). Offset 0 collides with joystick input report ID 0x01, breaking vjoy.sys device creation.
FFB reports generated:
- Set Effect Report (Output) — effect type, gain, direction, duration, trigger, axes enable
- Set Envelope Report (Output) — attack/fade level and time
- Set Condition Report (Output) — Spring/Damper/Friction/Inertia parameters per axis
- Set Periodic Report (Output) — magnitude, offset, phase, period for sine/square/triangle
- Set Constant Force Report (Output) — signed magnitude
- Set Ramp Force Report (Output) — start/end force
- Effect Operation Report (Output) — start/stop/solo
- PID Block Free Report (Output) — delete effect
- PID State Report (Input) — effect playing/stalled status
- Device Control (Output) — enable/disable/stop all/reset
- Device Gain Report (Output) — master gain 0–255
- Create New Effect Report (Feature) — allocate effect block
- PID Block Load Report (Feature) — report allocation result
- PID Pool Report (Feature) — report available memory
See vJoy Deep Dive for full descriptor format.
Ensures vjoy.inf is in the driver store via pnputil /add-driver. Without it, PnP won't apply UpperFilters=mshidkmdf from the INF — vjoy.sys handles IOCTLs but no HID reports reach Windows (joy.cpl shows no output). Checks pnputil /enum-drivers first to avoid redundant adds. Called once per session.
Writes OEMForceFeedback registry keys to HKCU\System\CurrentControlSet\Control\MediaProperties\PrivateProperties\Joystick\OEM\VID_1234&PID_BEAD\OEMForceFeedback. Required for DirectInput to enumerate the device as FFB-capable. No elevation needed (HKCU).
Keys written:
-
CLSID={EEC6993A-B3FD-11D2-A916-00C04FB98638}(standard HID PID FFB class driver) -
Attributes= flags=0, maxForce=1000000, minForce=1000000 - 11 effect GUIDs under
Effects\subkey: ConstantForce, RampForce, Square, Sine, Triangle, SawtoothUp, SawtoothDown, Spring, Damper, Inertia, Friction
All public entry points catch DllNotFoundException and return safe defaults (false/0). The app works without vJoy installed — UI is hidden.
FfbCallback catches all exceptions to prevent crashes on the vJoy thread pool. CreateVJoyDevices has a 30s timeout; failure returns false for retry next cycle.
Device node operations use multi-fallback strategies: SetupAPI -> CfgMgr32 -> pnputil.
internal static class VJoyNative
{
// Device management
bool vJoyEnabled();
VjdStat GetVJDStatus(uint rID);
bool AcquireVJD(uint rID);
void RelinquishVJD(uint rID);
bool ResetVJD(uint rID);
// State submission (batch)
bool UpdateVJD(uint rID, ref JoystickPositionV2 pData);
// State submission (individual — DO NOT USE, each is a separate IOCTL)
bool SetAxis(int value, uint rID, uint axis);
bool SetBtn(bool value, uint rID, byte nBtn);
bool SetDiscPov(int value, uint rID, byte nPov);
// FFB callback
delegate void FfbGenCB(IntPtr data, IntPtr userData);
void FfbRegisterGenCB(FfbGenCB cb, IntPtr data);
// FFB packet parsers (return 0 on success)
uint Ffb_h_DeviceID(IntPtr packet, ref uint deviceId);
uint Ffb_h_Type(IntPtr packet, ref FFBPType type);
uint Ffb_h_EffectBlockIndex(IntPtr packet, ref uint index);
uint Ffb_h_Eff_Report(IntPtr packet, ref FFB_EFF_REPORT effect);
uint Ffb_h_Eff_Constant(IntPtr packet, ref FFB_EFF_CONSTANT effect);
uint Ffb_h_Eff_Ramp(IntPtr packet, ref FFB_EFF_RAMP effect);
uint Ffb_h_Eff_Period(IntPtr packet, ref FFB_EFF_PERIOD effect);
uint Ffb_h_Eff_Cond(IntPtr packet, ref FFB_EFF_COND effect);
uint Ffb_h_EffOp(IntPtr packet, ref FFB_EFF_OP operation);
uint Ffb_h_DevCtrl(IntPtr packet, ref FFB_CTRL control);
uint Ffb_h_DevGain(IntPtr packet, ref byte gain);
}All use CallingConvention.Cdecl and DllImport("vJoyInterface.dll"). FFB parsers return uint status (0 = success) via IOCTL path (GUID_DEVINTERFACE_VJOY + GET_FFB_DATA). Works independently of COL02 state.
[StructLayout(LayoutKind.Explicit, Size = 108)]
internal struct JoystickPositionV2
{
[FieldOffset(0)] byte bDevice; // 1-based device index
[FieldOffset(4)] int wThrottle;
[FieldOffset(8)] int wRudder;
[FieldOffset(12)] int wAileron;
[FieldOffset(16)] int wAxisX;
[FieldOffset(20)] int wAxisY;
[FieldOffset(24)] int wAxisZ;
[FieldOffset(28)] int wAxisXRot;
[FieldOffset(32)] int wAxisYRot;
[FieldOffset(36)] int wAxisZRot;
[FieldOffset(40)] int wSlider;
[FieldOffset(44)] int wDial;
[FieldOffset(48)] int wWheel;
[FieldOffset(52)] int wAxisVX;
[FieldOffset(56)] int wAxisVY;
[FieldOffset(60)] int wAxisVZ;
[FieldOffset(64)] int wAxisVBRX;
[FieldOffset(68)] int wAxisVBRY;
[FieldOffset(72)] int wAxisVBRZ;
[FieldOffset(76)] int lButtons; // Buttons 1-32 bitmask
[FieldOffset(80)] uint bHats; // POV hat 1 (continuous: centidegrees, centered=0xFFFFFFFF)
[FieldOffset(84)] uint bHatsEx1; // POV hat 2
[FieldOffset(88)] uint bHatsEx2; // POV hat 3
[FieldOffset(92)] uint bHatsEx3; // POV hat 4
[FieldOffset(96)] int lButtonsEx1; // Buttons 33-64
[FieldOffset(100)] int lButtonsEx2; // Buttons 65-96
[FieldOffset(104)] int lButtonsEx3; // Buttons 97-128
}Matches public.h _JOYSTICK_POSITION_V2 struct. Total size: 108 bytes.
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/DS4/VJoy 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 Xbox 360/DS4 auto-mapping (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,VJoyRawState,KbmRawState,MidiRawState,Vibration -
vJoy Deep Dive —
VJoyVirtualControllerinternals: HID descriptors, FFB, device lifecycle - Driver Installation Internals — ViGEmBus, vJoy, MIDI Services driver install/uninstall
-
Settings and Serialization —
VirtualControllerTypeand vJoy/MIDI config persistence - Build and Publish — ViGEmBus, HidHide, vJoy embedded resources