-
Notifications
You must be signed in to change notification settings - Fork 6
Virtual Controllers
Definitive developer reference for all five IVirtualController implementations. Each section covers constructor/initialization, state submission, axis/button mapping with exact formulas, rumble/FFB handling, driver interaction, error handling, and type-specific quirks.
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 | 4 | 4 | 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
The Gamepad struct uses the XInput layout and is the universal input format for Xbox 360, DS4, and vJoy (gamepad mode). Each implementation translates from this common format 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 so feedback callbacks write to the correct VibrationStates[] element. Updated by SwapSlotData during slot reordering. The callback closure captures FeedbackPadIndex (not a copy) so it always reads the current slot index even after swaps.
While all implementations must have SubmitGamepadState(Gamepad gp) for interface compliance, some types use alternative submit methods and leave SubmitGamepadState 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: 4 (MaxXbox360Slots in SettingsManager)
| 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 occurs at construction -- that happens at Connect().
Calls _controller.Connect() on the ViGEm bus. After this call, Windows sees a new Xbox 360 controller (visible in Device Manager, joy.cpl, and XInput). Sets IsConnected = true. No guard against double-connect -- ViGEm throws if already connected.
Calls _controller.Disconnect(). The virtual device is removed from Windows. Sets IsConnected = false. No guard against double-disconnect.
Guarded by _disposed. Calls Disconnect() if still connected, then disposes the underlying controller via (_controller as IDisposable)?.Dispose(). The cast-to-IDisposable pattern is used because the IXbox360Controller interface does not itself extend IDisposable.
Maps the Gamepad struct to Xbox 360 report format and submits via _controller.SubmitReport().
Change detection: Compares the incoming Gamepad struct against _lastState via Equals(). If unchanged, the entire method returns immediately -- no API calls, no SubmitReport(). This avoids ~15 per-button/axis API calls plus one IOCTL at 1000 Hz when the controller is idle.
Buttons (15 buttons, direct bitmask check → per-button SetButtonState call):
| Gamepad Constant | Xbox360Button | Bit |
|---|---|---|
Gamepad.A |
Xbox360Button.A |
0x0400 |
Gamepad.B |
Xbox360Button.B |
0x0200 |
Gamepad.X |
Xbox360Button.X |
0x0800 |
Gamepad.Y |
Xbox360Button.Y |
0x1000 |
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 |
0x0004 |
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: The Xbox 360 is the only VC type where axes pass through without any conversion. DS4 must invert Y and rescale to byte; vJoy must rescale to unsigned 0-32767 and invert Y.
Sets FeedbackPadIndex = padIndex, then subscribes to _controller.FeedbackReceived via an event handler lambda:
- Reads
FeedbackPadIndex(not a captured copy -- always the current slot after swaps). - Bounds-checks
idx >= 0 && idx < vibrationStates.Length. - Reads
args.LargeMotorandargs.SmallMotor(byte 0-255, from ViGEm bus driver). - Scales to ushort:
(ushort)(args.LargeMotor * 257)and(ushort)(args.SmallMotor * 257). The* 257maps byte 0-255 to the full ushort 0-65535 range (255 * 257 = 65535). - Writes to
vibrationStates[idx].LeftMotorSpeed/.RightMotorSpeed. - Logs via
RumbleLogger.Log()only when values actually change (compares old vs new).
Threading: The callback runs on the ViGEm thread (not the polling thread). The Vibration struct fields are ushort, and aligned writes are atomic on x86/x64. Step 2 reads these values on the polling thread for rumble forwarding to physical devices via SDL_RumbleJoystick.
Namespace: PadForge.Common.Input
Visibility: internal sealed
NuGet dependency: Nefarius.ViGEm.Client
Max instances: 4 (MaxDS4Slots in SettingsManager)
| 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 DualShock 4 virtual controller via client.CreateDualShock4Controller(). Same ownership pattern as Xbox360 -- the ViGEmClient is external.
Identical pattern to Xbox360VirtualController. Connect() calls _controller.Connect(), Disconnect() calls _controller.Disconnect(), Dispose() is guarded by _disposed and calls (_controller as IDisposable)?.Dispose().
Maps the Gamepad struct to DS4 report format. Key differences from Xbox 360: Y-axis inversion, byte-range axes, hat-switch D-Pad, and additional digital trigger buttons.
Change detection: Same as Xbox360 -- Gamepad struct compared against _lastState via Equals(). Entire method returns early when unchanged.
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 (particularly PS4 ports) check the digital L2/R2 buttons in addition to the analog value. These are set whenever the analog trigger is non-zero.
The 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 order: diagonals first (Northeast, Northwest, Southeast, Southwest), then cardinals (North, South, West, East), then None. This matches how physical DS4 controllers report D-Pad state.
DS4 axes use unsigned byte (0-255) with center at 128. Y-axes are inverted relative to the Xbox convention (DS4: 0=up, 255=down; Xbox: positive=up, negative=down).
Two helper methods handle conversion:
// 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().
Identical implementation to Xbox360. Uses #pragma warning disable CS0618 because FeedbackReceived is marked obsolete in the ViGEm client library but remains the only functional feedback mechanism. The callback pattern, motor scaling (* 257), and logging are the same as Xbox360.
Namespace: PadForge.Common.Input
Visibility: internal sealed
No NuGet dependency -- uses direct P/Invoke to vJoyInterface.dll
Max instances: 16 (MaxVJoySlots in SettingsManager)
This is the most complex implementation. It manages driver-level device nodes, HID report descriptors, registry entries, and force feedback routing in addition to standard gamepad output. The file also contains two companion static classes (CfgMgr32 and SetupApiRestart) that provide 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)
vJoy virtual controllers follow the same presentation lifecycle as ViGEm (Xbox 360/DS4): they only appear in joy.cpl when BOTH conditions are true:
- A physical device is mapped to the slot
- That device is connected and online
When no device is mapped or the mapped device is disconnected, the vJoy controller is not active and not visible to games. This matches ViGEm behavior where virtual controllers only exist when a device is feeding input to them.
Virtual controllers are created in ascending slot order to ensure ViGEm assigns sequential indices matching PadForge slot numbers. An initializing indicator is shown in the Dashboard while a controller is being created or reconfigured (e.g., vJoy 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 |
The 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 time.
internal static void EnsureDllLoaded()Preloads vJoyInterface.dll into the process via NativeLibrary.TryLoad. Strategy:
- If
_dllLoadedis true, return immediately. - Try
NativeLibrary.TryLoad("vJoyInterface.dll")(default search paths). - Try
C:\Program Files\vJoy\vJoyInterface.dll. - Try
C:\Program Files\vJoy\x64\vJoyInterface.dll(legacy installs with arch subdirectories). - Only sets
_dllLoaded = trueon success. Retries on next call if not found (supports hot-install of vJoy).
IMPORTANT: Do NOT use NativeLibrary.SetDllImportResolver -- it hijacks the entire assembly's DLL resolution and would break other P/Invoke calls (xinput1_4.dll, user32.dll, etc.).
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 diagnostic messages to vjoy_diag.log in the app directory. Only writes when DiagLogEnabled is true. Format: [vJoy HH:mm:ss.fff] msg. Catches all exceptions (fire-and-forget logging).
- 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 are 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 to ensure existing controllers re-claim their device IDs BEFORE FindFreeDeviceId() runs for new controllers. This prevents a race where a new controller steals an ID that a restarted controller was about to reclaim.
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 kernel IOCTL (~1-2ms), causing 1000Hz to drop to 11Hz with 2 controllers.
Generation check: If _connectedGeneration != _generation, calls ReAcquireIfNeeded() which attempts to relinquish and re-acquire the device handle. If re-acquire fails after MaxReacquireRetries (50) attempts, disconnects. Returns immediately if not connected or generation still mismatches after re-acquire attempt.
Diagnostics: Logs the first call and every 5000th call via DiagLog with axis values, button bitmask, POV value, and submit fail count.
Axis conversion (signed short to vJoy unsigned range 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 a VJoyRawState directly, bypassing the Gamepad struct. Used for custom vJoy configurations with arbitrary axis/button/POV counts. Like SubmitGamepadState, 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 not currently mapped in SubmitRawState. The JoystickPositionV2 struct supports 16 axes total (see struct definition below), 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 direct 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 the delegate in
_ffbCallbackDelegateto prevent GC from collecting the native callback. - Catches
DllNotFoundExceptionand general exceptions gracefully (logs viaDiagLog).
The global callback is shared across all vJoy devices -- it routes by device ID. Only one FfbRegisterGenCB call is ever made; subsequent RegisterFeedbackCallback calls just add entries to the routing map.
internal static void UpdateFfbPadIndex(int slotA, int slotB)Updates FFB device map entries after a slot swap. Under _ffbLock, iterates all entries in _ffbDeviceMap: any entry referencing slotA is changed to slotB, and vice versa. Called by SwapSlotData to keep FFB routing consistent after reordering.
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 is stored when PT_CONDREP packets arrive. The IsY flag distinguishes X-axis (index 0) from Y-axis (index 1) condition reports. vJoy sends one CONDREP per axis. The axis index is used to store into ConditionAxes[0] (X) or ConditionAxes[1] (Y), and ConditionAxisCount tracks how many axes have been received.
private static void FfbCallback(IntPtr data, IntPtr userData)Global callback invoked by vJoyInterface.dll on its thread pool. Routes FFB packets by device ID. Handles these packet types:
| 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 affect motor output.
Directional motor split (polar direction mapping):
For each running effect with non-zero magnitude:
- Computes gain-scaled magnitude:
absMag * (effectGain / 255.0) - For constant force with negative magnitude: flips direction 180 degrees
- Converts 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 from 0-10000 to 0-65535:
(ushort)(sum * 65535.0 / 10000.0), clamped to 65535 - Writes to
vibrationStates[padIndex].LeftMotorSpeed/.RightMotorSpeed
Directional data passthrough (for haptic FFB devices):
Tracks the dominant (strongest) running effect and populates Vibration.HasDirectionalData, .EffectType, .SignedMagnitude, .Direction, .Period, and .DeviceGain. This data is consumed by ForceFeedbackState on the input side to drive SDL_HapticEffect on devices that support directional force (joysticks, wheels).
Condition data passthrough (Spring/Damper/Friction/Inertia):
For condition effects, populates Vibration.HasConditionData, .ConditionAxes[], and .ConditionAxisCount. The per-axis data (positive/negative coefficients, center offset, dead band, saturation) is passed through to ForceFeedbackState.SetConditionHapticForces(), which translates the HID condition parameters into SDL_HapticCondition effects. NumHapticAxes on the ISdlInputDevice determines whether the device receives 1-axis (wheels) or 2-axis (joysticks) condition data.
Logging: Only logs when motor values actually change (compares old vs new). Logs both to DiagLog (file) and RumbleLogger (shared rumble diagnostic).
Returns true if the vJoy driver is installed and enabled. Calls EnsureDllLoaded() then VJoyNative.vJoyEnabled(). Catches DllNotFoundException and all other exceptions gracefully (returns false).
Scans device IDs 1-16, returns the first with VJD_STAT_FREE status. Returns 0 if none available. Fast, non-blocking -- safe for the engine thread (no process spawning, no registry access). Catches DllNotFoundException gracefully.
Private helper that checks all vJoy device IDs 1..count report VJD_STAT_FREE status. Used by the wait loop in EnsureDevicesAvailable to confirm ALL devices are initialized after a node restart, not just the first one.
Checks if the vjoy service is in STOP_PENDING state via sc.exe query vjoy. This zombie state occurs when a previous uninstall removed the service before removing device nodes -- only a full system restart clears it.
Delegates to EnumerateVJoyInstanceIds().Count.
Enumerates vJoy device instance IDs via pnputil /enum-devices /class HIDClass. Parses the text output looking for ROOT\HIDCLASS\* devices whose description contains "vJoy". Caches results in _cachedInstanceIds for fast shutdown. More reliable than GetVJDStatus which returns stale data from the DLL's namespace cache.
Forces vJoyInterface.dll to close its stale internal control device handle (h0) and clear its cached device namespace. Does this by finding the DLL's hidden window (class name "win32app_vJoyInterface_DLL") via FindWindowW and sending WM_DEVICECHANGE / DBT_DEVICEQUERYREMOVE. The Brunner fork's WndProc responds by closing all handles, clearing namespace cache, and setting h0 = INVALID_HANDLE_VALUE. The next API call lazily re-opens h0 for the current device node. If the window doesn't exist (DLL not initialized yet), returns silently -- no stale state to clear.
Why this matters: Without this, DICS_DISABLE takes ~5 seconds waiting for the DLL's handle timeout. With it, disable is near-instant.
public static bool EnsureDevicesAvailable(int requiredCount, VJoyDeviceConfig[] perDeviceConfigs)
public static bool EnsureDevicesAvailable(int requiredCount = 1)Ensures the specified number of 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 == requiredCountand configs match and DLL is loaded, returns immediately. This is the hot path at 1000Hz. -
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: The caller (Step 5) must count using SlotControllerTypes[i] == VJoy && SlotCreated[i], NOT check VC lifecycle state. During EnsureTypeGroupOrder bubble sort (UI thread), the polling thread can catch transient state where a vJoy slot's type was swapped mid-sort, causing undercount that deletes needed registry descriptors.
Strategy depends on whether the descriptor count changed:
-
Content-only change (same device count,
countChanged=false): UsesDICS_PROPCHANGEto restart the driver stack in-place. This re-reads the HID descriptor from registry without recreating child PDOs. Fastest path. -
Count change (
countChanged=true): Must fully remove + recreate the device node because HIDCLASS only creates child PDOs duringEvtDeviceAdd(device node creation). DICS_PROPCHANGE alone would re-read descriptors but NOT create new PDOs.
Full restart sequence:
-
RelinquishAllDevices()-- releases all device 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 using SetupAPI in an elevated PowerShell script. The script is written to a temp file and executed via powershell.exe -NoProfile -ExecutionPolicy Bypass. Steps:
- 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 expensive pnputil enumeration (saves ~5s on shutdown). For each node: tries SetupApiRestart.RemoveDevice() first (direct API, no process spawn), falls back to RemoveDeviceNode() (pnputil). Resets _dllLoaded, _currentDescriptorCount, _cachedInstanceIds. Fire-and-forget pnputil /scan-devices on success.
Calls VJoyNative.RelinquishVJD(i) for all IDs 1-16. Best-effort (catches all exceptions). Must be called before disabling/removing device nodes, otherwise CM_Disable_DevNode returns CR_REMOVE_VETOED (23) because vJoyInterface.dll still holds the device open.
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: Avoids disturbing live device nodes whose driver has already read the registry. The driver reads descriptors at EvtDeviceAdd time; changing them without a node restart has no effect, but unnecessary writes would make change detection unreliable.
Builds a HID Report Descriptor matching vJoyConf format. Inputs are 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 always.
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 force feedback. Transcribed from vJoy-Brunner's hidReportDescFfb.h.
Report ID offset formula: baseId + 0x10 * reportId (1-based). Device 1 gets FFB IDs starting at 0x11, device 2 at 0x21, etc. CRITICAL: Must use 0x10 * reportId, NOT 0x10 * (reportId - 1). Using offset 0 causes collision with joystick input report ID 0x01, breaking vjoy.sys device creation entirely.
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 the vJoy driver INF is in the Windows driver store via pnputil /add-driver. Without it, PnP won't apply UpperFilters=mshidkmdf from the INF when binding new device nodes -- 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 (CheckVJoyInstalled, FindFreeDeviceId, IsServiceStuck, EnsureDevicesAvailable, RegisterFeedbackCallback) catch DllNotFoundException and return safe defaults (false/0). This means the app works without vJoy installed -- the vJoy UI is simply hidden.
The FfbCallback catches all exceptions at the top level to prevent unhandled exceptions on the vJoy thread pool from crashing the process. Errors are logged to Debug.WriteLine.
CreateVJoyDevices uses PowerShell process invocation with 30-second timeout. If the script hangs or fails, the method returns false and the caller can retry on the next polling cycle.
Device node operations (DisableDeviceNode, RestartDeviceNode, RemoveDeviceNode) use multi-fallback strategies: SetupAPI -> CfgMgr32 -> pnputil, to handle different Windows versions and permission scenarios.
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 functions use CallingConvention.Cdecl and DllImport("vJoyInterface.dll"). The FFB parser functions return uint status codes (0 = success) via the IOCTL path (vJoyInterface.dll opens GUID_DEVINTERFACE_VJOY and issues GET_FFB_DATA IOCTLs). This works independently of whether COL02 (HID PID collection) is enabled.
[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. The device appears in DAWs, synths, and any MIDI-aware application as "PadForge MIDI N". Falls back gracefully on systems without Windows MIDI Services installed.
Type isolation: MIDI cards cannot switch to Xbox/DS4/VJoy and vice versa. This is enforced in the UI -- the 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 mapped to
MidiCC0-MidiCC5) -
11 buttons to Note slots 0-10 (A, B, X, Y, LB, RB, Back, Start, LS, RS, Guide mapped to
MidiNote0-MidiNote10)
This uses the 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, and stores the 1-based instance number.
Returns early if already connected. Full initialization sequence:
- Creates a
MidiDeclaredEndpointInfowith name"PadForge MIDI {instanceNum}"and product instance ID"PADFORGE_MIDI_{instanceNum}". MIDI 1.0 protocol only. - Creates a
MidiVirtualDeviceCreationConfigwith user-supplied info including a description showing the slot number. - Adds a single
MidiFunctionBlock(bidirectional, Group 0,RepresentsMidi10Connection = YesBandwidthUnrestricted). - Creates
MidiSession.Create(deviceName). ThrowsInvalidOperationExceptionif null. - Creates virtual device via
MidiVirtualDeviceManager.CreateVirtualDevice(config). SetsSuppressHandledMessages = true. - Creates
MidiEndpointConnectionto the virtual device'sDeviceEndpointDeviceId. - Adds virtual device as message processing plugin via
_connection.AddMessageProcessingPlugin(). - Opens the connection via
_connection.Open(). ThrowsInvalidOperationExceptionif false. - Sets
_connected = true. - Initializes change detection arrays:
_lastCcValues=new byte[CcNumbers.Length]filled with 64 (center for axes);_lastNotes=new bool[NoteNumbers.Length].
Error handling: If any step 5-8 fails, cleans up all created resources (disconnects endpoint, disposes session, nulls references) before re-throwing. This prevents leaked MIDI sessions.
Returns early if not connected. Sequence:
- Sets
_connected = falseimmediately (prevents further sends during cleanup). - Sends Note Off for any held notes: iterates
_lastNotes, callsSendNoteOff(NoteNumbers[i])for each true entry. Prevents stuck notes in the DAW. - Nulls
_lastNotes. - Disconnects the endpoint:
_session.DisconnectEndpointConnection(_connection.ConnectionId). Nulls_connection. - Nulls
_virtualDevice. - Disposes
_sessionvia_session?.Dispose(). 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 a MidiRawState with arbitrary CC and note counts. Returns immediately if !_connected || _connection == null. Only sends messages when values change (change detection per CC and per note).
CC messages: Iterates min(state.CcValues.Length, _lastCcValues.Length, CcNumbers.Length) slots. For each CC slot where state.CcValues[i] != _lastCcValues[i], sends a MIDI 1.0 Control Change message via SendCC(CcNumbers[i], state.CcValues[i]) and updates _lastCcValues[i]. The triple-min ensures safety when arrays have different lengths (e.g., configuration change mid-stream).
Note messages: Same triple-min pattern with state.Notes.Length, _lastNotes.Length, NoteNumbers.Length. For each note slot where state.Notes[i] != _lastNotes[i], sends SendNoteOn(NoteNumbers[i], Velocity) for true or SendNoteOff(NoteNumbers[i]) for false, and updates _lastNotes[i].
Thread safety: The _connection field is read into a local variable in each Send* helper before null-checking and sending. This prevents races where Disconnect() nulls _connection between the null check and the send call.
// 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 |
Returns true if Windows MIDI Services is available on this system. Thread-safe with double-checked locking on _availLock. Caches result in _isAvailable after first check.
- Fast path: if
_isAvailable.HasValue, returns cached value immediately. - Under lock: creates
MidiDesktopAppSdkInitializer.Create()(static factory method). - Calls
_initializer.InitializeSdkRuntime(). On failure: disposes, caches false. - Calls
_initializer.EnsureServiceAvailable(). On failure: disposes, caches false. - On success: keeps
_initializeralive (must remain alive for SDK lifetime) and cachestrue. - On any exception: caches
false.
Resets the cached availability check so the next call to IsAvailable() re-evaluates. Under _availLock, disposes the existing _initializer if present and sets _isAvailable = null. Call after installing Windows MIDI Services.
public static void Shutdown(bool skipDispose = false)Disposes the SDK initializer. Call on application exit.
skipDispose parameter: When true, abandons the initializer without calling Dispose(). Use before uninstalling MIDI Services -- Dispose() calls into the MIDI Services runtime, which triggers a native crash if the service is being removed simultaneously. Under _availLock, 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 the Win32 SendInput API. Maps physical controller inputs (sticks, triggers, buttons) to keyboard key presses, mouse movement, mouse button clicks, and mouse scroll. Unlike other VC types, no virtual device node or driver is involved -- 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 the pad index. No external resources are acquired. No driver interaction.
Returns early if already connected (if (_connected) return). Sets _connected = true and resets all previous-state tracking fields to zero (_prevKeys0..3 = 0, _prevMouseButtons = 0). This is a lightweight operation -- no virtual device is created.
Returns early if not connected (if (!_connected) return). Sets _connected = false then calls ReleaseAll().
ReleaseAll() (private): Sends key-up for all currently held keys and button-up for all held mouse buttons by calling ProcessKeyWord(0, _prevKeysN, baseVk) for each word (XOR with 0 generates key-up for every set bit) and ProcessMouseButtons(0). Then resets all tracking to zero. This prevents stuck keys/buttons when a controller is disconnected mid-press.
Guarded by _disposed. Calls Disconnect().
No-op. KBM uses SubmitKbmState() instead. Required by the IVirtualController interface.
The primary output method. Returns immediately if !_connected. Processes three 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 each of the 4 ulong words (raw.Keys0..3) against the previous frame (_prevKeys0..3) via XOR. Only changed bits generate SendInput calls (key down or key up). At 1000 Hz with 256 VK codes, this is critical to avoid flooding the input queue.
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 invertedraw.MouseDeltaX and raw.MouseDeltaY (signed short, range -32767 to +32767) are scaled by MouseSensitivity / 32767.0 and sent as relative pixel movement via MOUSEEVENTF_MOVE. Y is negated because raw positive = up but screen Y increases downward. Only sends if either mx or my is non-zero after float conversion. Deadzone is 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 KBM states: keys and mouse buttons are OR'd; mouse deltas and scroll take the largest absolute magnitude. Used when multiple physical devices are mapped to the same 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 quirk): The INPUT struct uses LayoutKind.Sequential with an inner LayoutKind.Explicit union (InputUnion) containing MOUSEINPUT and KEYBDINPUT at FieldOffset(0). This lets the CLR handle platform-correct alignment. On x64, the ULONG_PTR (IntPtr) fields in MOUSEINPUT/KEYBDINPUT require 8-byte alignment, so the union starts at offset 8 (after DWORD type + 4 bytes padding). Using a flat LayoutKind.Explicit with hardcoded offsets would break across x86/x64.
Key scan codes: SendKeyboard populates both wVk (virtual key code) and wScan (hardware scan code via MapVirtualKeyW(vk, 0)). Some applications (particularly games using DirectInput raw input) require the scan code to be present.
Individual calls: Each SendInput call submits exactly 1 input event. This is by design -- batching key down + up into a single SendInput call would make them appear simultaneous (same timestamp), which breaks some applications. Individual calls also simplify error handling.