Skip to content

Virtual Controllers

hifihedgehog edited this page Mar 19, 2026 · 40 revisions

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.

Architecture Overview

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
Loading

Quick Comparison

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.cs
  • PadForge.App/Common/Input/DS4VirtualController.cs
  • PadForge.App/Common/Input/VJoyVirtualController.cs
  • PadForge.App/Common/Input/KeyboardMouseVirtualController.cs
  • PadForge.App/Common/Input/MidiVirtualController.cs

IVirtualController Interface

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

Gamepad Struct (Input Contract)

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)

FeedbackPadIndex

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.

Type-Specific Submit Methods

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

Xbox360VirtualController

Namespace: PadForge.Common.Input Visibility: internal sealed NuGet dependency: Nefarius.ViGEm.Client Max instances: 4 (MaxXbox360Slots in SettingsManager)

Fields

Field Type Description
_controller IXbox360Controller ViGEm Xbox 360 controller instance (readonly)
_disposed bool Dispose guard
_lastState Gamepad Cached previous state for change detection

Properties

Property Type Description
Type VirtualControllerType Always VirtualControllerType.Xbox360
IsConnected bool True after Connect(), false after Disconnect()
FeedbackPadIndex int Slot index for rumble callback routing

Constructor

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

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.

Disconnect()

Calls _controller.Disconnect(). The virtual device is removed from Windows. Sets IsConnected = false. No guard against double-disconnect.

Dispose()

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.

SubmitGamepadState(Gamepad gp)

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.

RegisterFeedbackCallback(int padIndex, Vibration[] vibrationStates)

Sets FeedbackPadIndex = padIndex, then subscribes to _controller.FeedbackReceived via an event handler lambda:

  1. Reads FeedbackPadIndex (not a captured copy -- always the current slot after swaps).
  2. Bounds-checks idx >= 0 && idx < vibrationStates.Length.
  3. Reads args.LargeMotor and args.SmallMotor (byte 0-255, from ViGEm bus driver).
  4. Scales to ushort: (ushort)(args.LargeMotor * 257) and (ushort)(args.SmallMotor * 257). The * 257 maps byte 0-255 to the full ushort 0-65535 range (255 * 257 = 65535).
  5. Writes to vibrationStates[idx].LeftMotorSpeed / .RightMotorSpeed.
  6. 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.


DS4VirtualController

Namespace: PadForge.Common.Input Visibility: internal sealed NuGet dependency: Nefarius.ViGEm.Client Max instances: 4 (MaxDS4Slots in SettingsManager)

Fields

Field Type Description
_controller IDualShock4Controller ViGEm DualShock 4 controller instance (readonly)
_disposed bool Dispose guard
_lastState Gamepad Cached previous state for change detection

Properties

Property Type Description
Type VirtualControllerType Always VirtualControllerType.DualShock4
IsConnected bool True after Connect(), false after Disconnect()
FeedbackPadIndex int Slot index for rumble callback routing

Constructor

public DS4VirtualController(ViGEmClient client)

Creates a DualShock 4 virtual controller via client.CreateDualShock4Controller(). Same ownership pattern as Xbox360 -- the ViGEmClient is external.

Connect() / Disconnect() / Dispose()

Identical pattern to Xbox360VirtualController. Connect() calls _controller.Connect(), Disconnect() calls _controller.Disconnect(), Dispose() is guarded by _disposed and calls (_controller as IDisposable)?.Dispose().

SubmitGamepadState(Gamepad gp)

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.

Button Mapping

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.

D-Pad (Hat Switch)

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.

Axis Conversion (Y-Axis Inversion)

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

RegisterFeedbackCallback(int padIndex, Vibration[] vibrationStates)

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.


VJoyVirtualController

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.

Companion Static Classes

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 as devcon.exe restart)
  • DisableDevice(instanceId) -- DICS_DISABLE
  • EnableDevice(instanceId) -- DICS_ENABLE
  • RemoveDevice(instanceId) -- SetupDiRemoveDevice (forceful removal)

Presentation Lifecycle

vJoy virtual controllers follow the same presentation lifecycle as ViGEm (Xbox 360/DS4): they only appear in joy.cpl when BOTH conditions are true:

  1. A physical device is mapped to the slot
  2. 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 Controller Ordering

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

Static Fields

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)

Static Properties

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

Instance Fields

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

Instance Constants

Constant Value Description
MaxReacquireRetries 50 Max consecutive re-acquire attempts (~50ms at 1kHz) before disconnecting

Instance Properties

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

Constructor

public VJoyVirtualController(uint deviceId)

Validates deviceId is in range 1-16. Throws ArgumentOutOfRangeException if not. No driver interaction at construction time.

EnsureDllLoaded()

internal static void EnsureDllLoaded()

Preloads vJoyInterface.dll into the process via NativeLibrary.TryLoad. Strategy:

  1. If _dllLoaded is true, return immediately.
  2. Try NativeLibrary.TryLoad("vJoyInterface.dll") (default search paths).
  3. Try C:\Program Files\vJoy\vJoyInterface.dll.
  4. Try C:\Program Files\vJoy\x64\vJoyInterface.dll (legacy installs with arch subdirectories).
  5. Only sets _dllLoaded = true on 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.).

ResetState()

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.

DiagLog(string msg)

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

Connect()

  1. Calls EnsureDllLoaded().
  2. Calls VJoyNative.GetVJDStatus(_deviceId) -- must return VJD_STAT_FREE.
  3. Calls VJoyNative.AcquireVJD(_deviceId).
  4. Calls VJoyNative.ResetVJD(_deviceId) to zero all axes/buttons.
  5. Sets _connected = true, captures _connectedGeneration = _generation, resets _reacquireFailCount = 0.
  6. Sends a test frame via UpdateVJD with 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.

Disconnect()

  1. Logs diagnostic info including total submit call/fail counts.
  2. Removes FFB routing for this device from _ffbDeviceMap and _ffbDeviceStates (under _ffbLock).
  3. Calls VJoyNative.ResetVJD(_deviceId) then VJoyNative.RelinquishVJD(_deviceId).
  4. Sets _connected = false.

Dispose()

Calls Disconnect() directly. No separate _disposed guard -- Disconnect() itself checks _connected.

ReAcquireIfNeeded()

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:

  1. If _connectedGeneration == _generation (no restart occurred), returns immediately.
  2. If _deviceId > CurrentDescriptorCount, this device ID no longer exists in the registry (scale-down). Disconnects immediately for ID reassignment -- does NOT call RelinquishVJD on a non-existent device (would corrupt DLL internal state).
  3. Increments _reacquireFailCount. After MaxReacquireRetries (50) consecutive failures, disconnects permanently so Step 5 can recreate with a fresh controller.
  4. Calls RelinquishVJD then AcquireVJD. On success: resets the device, updates _connectedGeneration, resets fail counter.
  5. On failure: returns silently (retry next polling cycle, ~1ms later).

SubmitGamepadState(Gamepad gp)

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

SubmitRawState(VJoyRawState raw)

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

RegisterFeedbackCallback(int padIndex, Vibration[] vibrationStates)

Registers this device for FFB routing:

  1. Sets FeedbackPadIndex = padIndex.
  2. Under _ffbLock, adds _deviceId -> (padIndex, vibrationStates) to _ffbDeviceMap.
  3. If _ffbCallbackRegistered is false, registers FfbCallback via VJoyNative.FfbRegisterGenCB().
  4. Stores the delegate in _ffbCallbackDelegate to prevent GC from collecting the native callback.
  5. Catches DllNotFoundException and general exceptions gracefully (logs via DiagLog).

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.

UpdateFfbPadIndex(int slotA, int slotB)

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.

FFB Architecture

FfbDeviceState

private class FfbDeviceState
{
    public byte DeviceGain = 255;                              // 0-255, default 100%
    public Dictionary<byte, FfbEffectState> Effects = new();   // keyed by EffectBlockIndex
}

FfbEffectState

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;
}

FfbConditionAxis

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.

FfbCallback(IntPtr data, IntPtr userData)

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

ApplyMotorOutput(uint deviceId, FfbDeviceState devState)

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:

  1. Computes gain-scaled magnitude: absMag * (effectGain / 255.0)
  2. For constant force with negative magnitude: flips direction 180 degrees
  3. Converts HID polar direction (0-32767) to degrees: angleDeg = (direction / 32767.0) * 360.0
  4. 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
    
  5. Accumulates: leftSum += mag * leftScale, rightSum += mag * rightScale

Post-accumulation:

  1. Applies device-level gain: leftSum *= deviceGain / 255.0, same for right
  2. Scales from 0-10000 to 0-65535: (ushort)(sum * 65535.0 / 10000.0), clamped to 65535
  3. 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).

Static Device Management Methods

CheckVJoyInstalled() -> bool

Returns true if the vJoy driver is installed and enabled. Calls EnsureDllLoaded() then VJoyNative.vJoyEnabled(). Catches DllNotFoundException and all other exceptions gracefully (returns false).

FindFreeDeviceId() -> uint

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.

AllDevicesReady(int count) -> bool

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.

IsServiceStuck() -> bool

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.

CountExistingDevices() -> int

Delegates to EnumerateVJoyInstanceIds().Count.

EnumerateVJoyInstanceIds() -> List<string>

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.

RefreshVJoyDllHandles()

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.

EnsureDevicesAvailable(int requiredCount, VJoyDeviceConfig[] perDeviceConfigs) -> bool

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:

  1. First call per session: Runs EnsureDriverInStore() (adds vjoy.inf to Windows driver store if missing) and EnsureFfbRegistryKeys() (writes OEMForceFeedback DirectInput registry keys).
  2. Config change detection: Compares perDeviceConfigs against _lastDeviceConfigs element-by-element (Axes, Buttons, Povs).
  3. Fast path: If _currentDescriptorCount == requiredCount and configs match and DLL is loaded, returns immediately. This is the hot path at 1000Hz.
  4. Registry update: Calls WriteDeviceDescriptors() which returns true only if actual registry bytes changed.
  5. requiredCount == 0: Fully removes the device node via DisableDeviceNode() (disable then remove, not just disable -- ensures child PDOs disappear from WMI).
  6. No existing node: Creates one via CreateVJoyDevices(1), waits up to 5s (20 x 250ms) for PnP binding, checking AllDevicesReady().
  7. Excess nodes (>1): Removes extras, keeps the first one. Forces descriptor restart.
  8. Descriptors changed: Restarts the node via RestartDeviceNode(countChanged: true). Waits up to 5s for all devices to become ready.
  9. Increments _generation on any restart so connected controllers know to re-acquire via ReAcquireIfNeeded.

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.

RestartDeviceNode(bool countChanged = true)

Strategy depends on whether the descriptor count changed:

  • Content-only change (same device count, countChanged=false): Uses DICS_PROPCHANGE to 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 during EvtDeviceAdd (device node creation). DICS_PROPCHANGE alone would re-read descriptors but NOT create new PDOs.

Full restart sequence:

  1. RelinquishAllDevices() -- releases all device handles (1-16).
  2. RefreshVJoyDllHandles() -- closes DLL's stale h0 handle.
  3. Try SetupApiRestart.DisableDevice() (DICS_DISABLE). Fallback: CfgMgr32.CM_Disable_DevNode().
  4. Try SetupApiRestart.RemoveDevice(). Fallback: pnputil /remove-device /subtree. Fallback: SetupApiRestart.RemoveDevice() without prior disable.
  5. If all remove attempts fail: try DICS_PROPCHANGE as last resort, then re-enable if disabled.
  6. On successful remove: pnputil /scan-devices, then CreateVJoyDevices(1), wait up to 5s for ready.
  7. Increments _generation, resets _dllLoaded.

CreateVJoyDevices(int count) -> bool

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:

  1. Ensures vjoy.sys service registry key exists (HKLM\...\services\vjoy), copies vjoy.sys to System32\drivers\ if needed.
  2. For each device: SetupDiCreateDeviceInfoList(HIDClass GUID) -> SetupDiCreateDeviceInfoW("HIDClass", DICD_GENERATE_ID) -> set HWID -> SetupDiCallClassInstaller(DIF_REGISTERDEVICE).
  3. Critical: DeviceName must be "HIDClass" (class name), NOT the hardware ID. Using the HWID would fail silently.
  4. UpdateDriverForPlugAndPlayDevicesW(hwid, infPath, 0, ...) with flag 0 (no INSTALLFLAG_FORCE). Flag 0 only binds unmatched nodes -- INSTALLFLAG_FORCE (1) would re-bind ALL matching devices, creating duplicate HID children and invalidating existing handles.
  5. 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).

DisableDeviceNode()

Fully removes the vJoy device node. Sequence:

  1. RelinquishAllDevices() + RefreshVJoyDllHandles() to release handles.
  2. SetupApiRestart.DisableDevice(). Fallback: CfgMgr32.CM_Disable_DevNode().
  3. Wait 500ms, then SetupApiRestart.RemoveDevice(). Fallback: pnputil /remove-device. Fallback: remove without prior disable.
  4. Increments _generation, resets _dllLoaded.
  5. On successful remove: synchronous pnputil /scan-devices to clean up ghost child PDOs. Must be synchronous -- async scan races with ViGEm VC creation on the next polling cycle.

RemoveDeviceNode(string instanceId) -> bool

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.

RemoveAllDeviceNodes() -> bool

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.

RelinquishAllDevices()

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.

WriteDeviceDescriptors(int requiredCount, VJoyDeviceConfig[] perDeviceConfigs) -> bool

Writes HID report descriptors to HKLM\SYSTEM\CurrentControlSet\services\vjoy\Parameters\DeviceNN. Returns true if any registry changes occurred (writes or deletions).

Behavior:

  1. Opens HKLM\..\services\vjoy\Parameters for write access.
  2. Deletes excess DeviceNN keys beyond requiredCount.
  3. For each device 1..requiredCount: builds descriptor via BuildHidDescriptor(), then compares byte-by-byte against existing HidReportDescriptor value. Only writes if different.
  4. Each key stores two values: HidReportDescriptor (REG_BINARY) + HidReportDescriptorSize (REG_DWORD).
  5. Default config when no perDeviceConfigs provided: 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.

BuildHidDescriptor(byte reportId, int nAxes, int nButtons, int nPovs) -> byte[]

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.

AppendFfbDescriptor(List<byte> d, byte reportId)

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.

EnsureDriverInStore()

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.

EnsureFfbRegistryKeys()

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

Error Handling and Graceful Degradation

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.

P/Invoke Declarations (VJoyNative)

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.

JoystickPositionV2 Struct

[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.


MidiVirtualController

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.

Static Fields

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)

Instance Fields

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)

Configurable Properties

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.

Auto-Mapping

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.

Properties

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)

Constructor

public MidiVirtualController(int padIndex, int channel, int instanceNum)

Stores pad index, clamps channel to 0-15, and stores the 1-based instance number.

Connect()

Returns early if already connected. Full initialization sequence:

  1. Creates a MidiDeclaredEndpointInfo with name "PadForge MIDI {instanceNum}" and product instance ID "PADFORGE_MIDI_{instanceNum}". MIDI 1.0 protocol only.
  2. Creates a MidiVirtualDeviceCreationConfig with user-supplied info including a description showing the slot number.
  3. Adds a single MidiFunctionBlock (bidirectional, Group 0, RepresentsMidi10Connection = YesBandwidthUnrestricted).
  4. Creates MidiSession.Create(deviceName). Throws InvalidOperationException if null.
  5. Creates virtual device via MidiVirtualDeviceManager.CreateVirtualDevice(config). Sets SuppressHandledMessages = true.
  6. Creates MidiEndpointConnection to the virtual device's DeviceEndpointDeviceId.
  7. Adds virtual device as message processing plugin via _connection.AddMessageProcessingPlugin().
  8. Opens the connection via _connection.Open(). Throws InvalidOperationException if false.
  9. Sets _connected = true.
  10. 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.

Disconnect()

Returns early if not connected. Sequence:

  1. Sets _connected = false immediately (prevents further sends during cleanup).
  2. Sends Note Off for any held notes: iterates _lastNotes, calls SendNoteOff(NoteNumbers[i]) for each true entry. Prevents stuck notes in the DAW.
  3. Nulls _lastNotes.
  4. Disconnects the endpoint: _session.DisconnectEndpointConnection(_connection.ConnectionId). Nulls _connection.
  5. Nulls _virtualDevice.
  6. Disposes _session via _session?.Dispose(). Nulls _session.

SubmitGamepadState(Gamepad gp)

public void SubmitGamepadState(Gamepad gp)

Legacy path -- not used for dynamic MIDI. Kept as a no-op for IVirtualController interface compliance.

SubmitMidiRawState(MidiRawState state)

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.

MidiRawState

// 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).

RegisterFeedbackCallback(int padIndex, Vibration[] vibrationStates)

No-op -- MIDI has no rumble/force feedback.

Dispose()

Guarded by _disposed. Calls Disconnect().

MIDI Message Helpers

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

Static Availability Check

IsAvailable() -> bool

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.

  1. Fast path: if _isAvailable.HasValue, returns cached value immediately.
  2. Under lock: creates MidiDesktopAppSdkInitializer.Create() (static factory method).
  3. Calls _initializer.InitializeSdkRuntime(). On failure: disposes, caches false.
  4. Calls _initializer.EnsureServiceAvailable(). On failure: disposes, caches false.
  5. On success: keeps _initializer alive (must remain alive for SDK lifetime) and caches true.
  6. On any exception: caches false.

ResetAvailability()

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.

Shutdown(bool skipDispose = false)

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.


KeyboardMouseVirtualController

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.

Fields

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

Constants

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

Properties

Property Type Description
Type VirtualControllerType Always VirtualControllerType.KeyboardMouse
IsConnected bool Read from _connected
FeedbackPadIndex int Slot index (unused -- KBM has no rumble)

Constructor

public KeyboardMouseVirtualController(int padIndex)

Stores the pad index. No external resources are acquired. No driver interaction.

Connect()

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.

Disconnect()

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.

Dispose()

Guarded by _disposed. Calls Disconnect().

SubmitGamepadState(Gamepad gp)

No-op. KBM uses SubmitKbmState() instead. Required by the IVirtualController interface.

SubmitKbmState(KbmRawState raw)

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 inverted

raw.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_DELTA

raw.ScrollDelta (signed short) is scaled and multiplied by 120 (WHEEL_DELTA) for MOUSEEVENTF_WHEEL. Only sends if non-zero.

RegisterFeedbackCallback(int padIndex, Vibration[] vibrationStates)

No-op -- keyboard/mouse has no rumble feedback.

KbmRawState (from Engine)

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.

Win32 SendInput P/Invoke

[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.

Clone this wiki locally