Skip to content

Virtual Controllers

hifihedgehog edited this page Mar 19, 2026 · 15 revisions

Virtual Controllers

Developer reference for all five IVirtualController implementations: initialization, state submission, axis/button mapping formulas, rumble/FFB, driver interaction, error handling, and type-specific quirks.

Contents


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 16 16 16 16 16
Always available Requires ViGEmBus Requires ViGEmBus Requires vJoy driver Yes Requires MIDI Services

Source files:

  • PadForge.Engine/Common/VirtualControllerTypes.cs — interface + enum
  • PadForge.App/Common/Input/Xbox360VirtualController.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)

XInput-layout struct used as the universal input format for Xbox 360, DS4, and vJoy (gamepad mode). Each implementation translates this to its native output format.

Field Type Range Description
ThumbLX short -32768..32767 Left stick X (positive = right)
ThumbLY short -32768..32767 Left stick Y (positive = up)
ThumbRX short -32768..32767 Right stick X (positive = right)
ThumbRY short -32768..32767 Right stick Y (positive = up)
LeftTrigger ushort 0..65535 Left trigger (unsigned)
RightTrigger ushort 0..65535 Right trigger (unsigned)
Buttons ushort Bitmask 15 buttons (A/B/X/Y/LB/RB/Back/Start/LS/RS/Guide/DPad)

FeedbackPadIndex

Tracks which slot this VC occupies for correct VibrationStates[] writes. Updated by SwapSlotData during slot reordering. The callback captures the property reference (not a copy), so it always reads the current slot index after swaps.

Type-Specific Submit Methods

All implementations have SubmitGamepadState(Gamepad gp) for interface compliance, but some types use alternative submit methods and leave it as a no-op:

  • Xbox 360 / DS4 / vJoy (gamepad mode): SubmitGamepadState(Gamepad gp) — standard path
  • vJoy (custom config): SubmitRawState(VJoyRawState raw) — arbitrary axis/button/POV counts
  • Keyboard+Mouse: SubmitKbmState(KbmRawState raw) — keys, mouse, scroll
  • MIDI: SubmitMidiRawState(MidiRawState state) — CC values and note on/off

Xbox360VirtualController

Namespace: PadForge.Common.Input Visibility: internal sealed NuGet dependency: Nefarius.ViGEm.Client Max instances: 16 (MaxXbox360Slots = MaxPads in SettingsManager). XInput games see only the first 4; SDL/DirectInput games see all 16.

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 at construction — that happens at Connect().

Connect()

Calls _controller.Connect() on the ViGEm bus. Windows then sees a new Xbox 360 controller (Device Manager, joy.cpl, XInput). Sets IsConnected = true. No double-connect guard — ViGEm throws if already connected.

Disconnect()

Calls _controller.Disconnect(). Removes the virtual device from Windows. Sets IsConnected = false. No double-disconnect guard.

Dispose()

Guarded by _disposed. Calls Disconnect() if connected, then (_controller as IDisposable)?.Dispose(). The cast is needed because IXbox360Controller does not extend IDisposable.

SubmitGamepadState(Gamepad gp)

Maps Gamepad to Xbox 360 report format and submits via _controller.SubmitReport().

Change detection: Compares incoming Gamepad against _lastState via Equals(). If unchanged, returns immediately — no API calls, no SubmitReport(). Avoids ~15 per-button/axis calls plus one IOCTL at 1000 Hz when idle.

Buttons (15 buttons, direct bitmask check → per-button SetButtonState call):

Gamepad Constant Xbox360Button Bit
Gamepad.A Xbox360Button.A 0x1000
Gamepad.B Xbox360Button.B 0x2000
Gamepad.X Xbox360Button.X 0x4000
Gamepad.Y Xbox360Button.Y 0x8000
Gamepad.LEFT_SHOULDER Xbox360Button.LeftShoulder 0x0100
Gamepad.RIGHT_SHOULDER Xbox360Button.RightShoulder 0x0200
Gamepad.BACK Xbox360Button.Back 0x0020
Gamepad.START Xbox360Button.Start 0x0010
Gamepad.LEFT_THUMB Xbox360Button.LeftThumb 0x0040
Gamepad.RIGHT_THUMB Xbox360Button.RightThumb 0x0080
Gamepad.GUIDE Xbox360Button.Guide 0x0400
Gamepad.DPAD_UP Xbox360Button.Up 0x0001
Gamepad.DPAD_DOWN Xbox360Button.Down 0x0002
Gamepad.DPAD_LEFT Xbox360Button.Left 0x0004
Gamepad.DPAD_RIGHT Xbox360Button.Right 0x0008

Axes (signed short, direct passthrough — no conversion needed):

Gamepad Field Xbox360Axis Range
ThumbLX LeftThumbX -32768..32767
ThumbLY LeftThumbY -32768..32767
ThumbRX RightThumbX -32768..32767
ThumbRY RightThumbY -32768..32767

Triggers (ushort 0–65535 to byte 0–255 via right shift):

Gamepad Field Xbox360Slider Formula
LeftTrigger LeftTrigger (byte)(gp.LeftTrigger >> 8)
RightTrigger RightTrigger (byte)(gp.RightTrigger >> 8)

Quirk: Xbox 360 is the only VC type where axes pass through without conversion. DS4 inverts Y and rescales to byte; vJoy rescales to unsigned 0–32767 and inverts Y.

RegisterFeedbackCallback(int padIndex, Vibration[] vibrationStates)

Sets FeedbackPadIndex = padIndex, then subscribes to _controller.FeedbackReceived:

  1. Reads FeedbackPadIndex (not a captured copy — always 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).
  4. Scales to ushort via * 257 (maps 0–255 to 0–65535; 255 * 257 = 65535).
  5. Writes to vibrationStates[idx].LeftMotorSpeed / .RightMotorSpeed.
  6. Logs via RumbleLogger.Log() only when values change.

Threading: Runs on the ViGEm thread. Vibration fields are ushort; aligned writes are atomic on x86/x64. The polling thread reads these for rumble forwarding via SDL_RumbleJoystick.


DS4VirtualController

Namespace: PadForge.Common.Input Visibility: internal sealed NuGet dependency: Nefarius.ViGEm.Client Max instances: 16 (MaxDS4Slots = MaxPads in SettingsManager). SDL/DirectInput games see all 16.

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 DS4 virtual controller via client.CreateDualShock4Controller(). Same ownership pattern as Xbox360 — ViGEmClient is external.

Connect() / Disconnect() / Dispose()

Same pattern as Xbox360VirtualController.

SubmitGamepadState(Gamepad gp)

Maps Gamepad to DS4 report format. Differences from Xbox 360: Y-axis inversion, byte-range axes, hat-switch D-Pad, and digital trigger buttons.

Change detection: Same as Xbox360 — returns early when Gamepad equals _lastState.

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 (especially PS4 ports) check digital L2/R2 in addition to the analog value. Set whenever the analog trigger is non-zero.

D-Pad (Hat Switch)

DS4 uses a hat switch instead of individual D-Pad buttons. GetDPadDirection() converts 4 booleans to one of 9 DualShock4DPadDirection values:

private static DualShock4DPadDirection GetDPadDirection(bool up, bool down, bool left, bool right)

Priority: diagonals first, then cardinals, then None. Matches physical DS4 D-Pad behavior.

Axis Conversion (Y-Axis Inversion)

DS4 axes use unsigned byte (0–255, center 128). Y-axes are inverted (DS4: 0=up, 255=down; Xbox: positive=up, negative=down).

Conversion helpers:

// X-axis: offset from signed to unsigned, scale to byte
private static byte ShortToByte(short value) => (byte)((value + 32768) >> 8);

// Y-axis: invert then scale — 0=up becomes 255=down
private static byte ShortToByteInvertY(short value) => (byte)((32767 - value) >> 8);
Gamepad Field DualShock4Axis Conversion Formula
ThumbLX LeftThumbX ShortToByte (value + 32768) >> 8
ThumbLY LeftThumbY ShortToByteInvertY (32767 - value) >> 8
ThumbRX RightThumbX ShortToByte (value + 32768) >> 8
ThumbRY RightThumbY ShortToByteInvertY (32767 - value) >> 8

Triggers (ushort 0–65535 to byte 0–255 — same formula as Xbox360):

Gamepad Field DualShock4Slider Formula
LeftTrigger LeftTrigger (byte)(gp.LeftTrigger >> 8)
RightTrigger RightTrigger (byte)(gp.RightTrigger >> 8)

Finishes with _controller.SubmitReport().

RegisterFeedbackCallback(int padIndex, Vibration[] vibrationStates)

Same as Xbox360. Uses #pragma warning disable CS0618 because FeedbackReceived is marked obsolete in the ViGEm client library but remains the only working feedback mechanism.


VJoyVirtualController

Namespace: PadForge.Common.Input Visibility: internal sealed No NuGet dependency — uses direct P/Invoke to vJoyInterface.dll Max instances: 16 (MaxVJoySlots in SettingsManager)

The most complex implementation. Manages driver-level device nodes, HID report descriptors, registry entries, and FFB routing alongside standard gamepad output. Also contains two companion static classes (CfgMgr32 and SetupApiRestart) for device node management P/Invoke.

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

Same as ViGEm (Xbox 360/DS4): controllers only appear in joy.cpl when BOTH conditions are met:

  1. A physical device is mapped to the slot
  2. That device is connected and online

Virtual Controller Ordering

Created in ascending slot order so ViGEm assigns sequential indices matching PadForge slot numbers. Dashboard shows an initializing indicator during creation or reconfiguration (e.g., descriptor change triggering a node restart).

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

EnsureDllLoaded()

internal static void EnsureDllLoaded()

Preloads vJoyInterface.dll via NativeLibrary.TryLoad. Search order:

  1. If _dllLoaded, return immediately.
  2. Default search paths.
  3. C:\Program Files\vJoy\vJoyInterface.dll.
  4. C:\Program Files\vJoy\x64\vJoyInterface.dll (legacy arch subdirs).
  5. Only sets _dllLoaded = true on success. Retries if not found (supports hot-install).

IMPORTANT: Do NOT use NativeLibrary.SetDllImportResolver — it hijacks the entire assembly's DLL resolution, breaking other P/Invoke calls.

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 messages to vjoy_diag.log when DiagLogEnabled is true. Format: [vJoy HH:mm:ss.fff] msg. Fire-and-forget (catches all exceptions).

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 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 so existing controllers re-claim their device IDs BEFORE FindFreeDeviceId() runs for new controllers. Prevents a race where a new controller steals an ID being reclaimed.

Flow:

  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 IOCTL (~1–2ms), dropping 1000 Hz to 11 Hz with 2 controllers.

Generation check: If _connectedGeneration != _generation, calls ReAcquireIfNeeded() to relinquish and re-acquire. Disconnects after MaxReacquireRetries (50) failures. Returns immediately if not connected or generation still mismatches.

Diagnostics: Logs the first call and every 5000th call with axis values, button bitmask, POV, and fail count.

Axis conversion (signed short to vJoy unsigned 0–32767):

int lx = (gp.ThumbLX + 32768) / 2;
int ly = 32767 - (gp.ThumbLY + 32768) / 2;   // Y inverted (HID Y-down=max)
int rx = (gp.ThumbRX + 32768) / 2;
int ry = 32767 - (gp.ThumbRY + 32768) / 2;   // Y inverted
int lt = gp.LeftTrigger * 32767 / 65535;
int rt = gp.RightTrigger * 32767 / 65535;

Y-axis inversion: HID convention is Y-down=max value. Formula: 32767 - (value + 32768) / 2.

Axis mapping to JoystickPositionV2 fields:

Gamepad JoystickPositionV2 Field
ThumbLX wAxisX
ThumbLY wAxisY (inverted)
LeftTrigger wAxisZ
ThumbRX wAxisXRot
ThumbRY wAxisYRot (inverted)
RightTrigger wAxisZRot

Button bitmask (11 buttons, positions 0–10): A, B, X, Y, LB, RB, Back, Start, LS, RS, Guide.

D-Pad to continuous POV hat (centidegrees):

Direction Value
North 0
Northeast 4500
East 9000
Southeast 13500
South 18000
Southwest 22500
West 27000
Northwest 31500
Centered -1 (stored as 0xFFFF_FFFF)

Unused POV hats (bHatsEx1, bHatsEx2, bHatsEx3) are set to 0xFFFF_FFFF (centered).

SubmitRawState(VJoyRawState raw)

Submits VJoyRawState directly, bypassing Gamepad. Used for custom vJoy configs with arbitrary axis/button/POV counts. Performs generation-check and re-acquire before submission.

Axis mapping (supports up to 8 axes; raw.Axes is short[]):

Axes are converted from signed short (-32768..32767) to vJoy unsigned (0..32767): (raw.Axes[i] + 32768) / 2.

NOTE: Unlike SubmitGamepadState, raw mode does NOT invert Y-axes — the caller is responsible for any axis inversion.

Index JoystickPositionV2 Field HID Usage
0 wAxisX X
1 wAxisY Y
2 wAxisZ Z
3 wAxisXRot RX
4 wAxisYRot RY
5 wAxisZRot RZ
6 wSlider Slider
7 wDial Dial

Axes beyond index 7 are unmapped. JoystickPositionV2 supports 16 axes, but only 8 are wired in the raw submit path.

Button mapping: raw.Buttons is uint[] where each uint is 32 button bits. Maps to lButtons, lButtonsEx1, lButtonsEx2, lButtonsEx3 (128 buttons total, 4 words max).

POV mapping: raw.Povs is int[]. Value -1 = centered (0xFFFFFFFF), else centidegree value (0–35900). Maps to bHats, bHatsEx1, bHatsEx2, bHatsEx3 (4 POVs max).

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 delegate in _ffbCallbackDelegate to prevent GC collection.
  5. Catches DllNotFoundException and general exceptions (logs via DiagLog).

The global callback is shared across all vJoy devices, routing by device ID. Only one FfbRegisterGenCB call is made; subsequent calls just add routing map entries.

UpdateFfbPadIndex(int slotA, int slotB)

internal static void UpdateFfbPadIndex(int slotA, int slotB)

Updates FFB device map after a slot swap. Under _ffbLock, swaps slotA/slotB references in _ffbDeviceMap. Called by SwapSlotData to keep FFB routing consistent.

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 stored on PT_CONDREP packets. IsY distinguishes X (index 0) from Y (index 1). vJoy sends one CONDREP per axis; ConditionAxisCount tracks received axes.

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.

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 change motor output.

Directional motor split (polar direction mapping):

For each running effect with non-zero magnitude:

  1. Gain-scaled magnitude: absMag * (effectGain / 255.0)
  2. Constant force with negative magnitude: flips direction 180 degrees
  3. 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 0–10000 to 0–65535: (ushort)(sum * 65535.0 / 10000.0), clamped
  3. Writes to vibrationStates[padIndex].LeftMotorSpeed / .RightMotorSpeed

Directional data passthrough (for haptic FFB devices):

Tracks the dominant running effect and populates Vibration.HasDirectionalData, .EffectType, .SignedMagnitude, .Direction, .Period, .DeviceGain. Consumed by ForceFeedbackState to drive SDL_HapticEffect on directional devices (joysticks, wheels).

Condition data passthrough (Spring/Damper/Friction/Inertia):

Populates Vibration.HasConditionData, .ConditionAxes[], .ConditionAxisCount. Per-axis data (coefficients, center offset, dead band, saturation) passes through to ForceFeedbackState.SetConditionHapticForces(), which maps to SDL_HapticCondition. NumHapticAxes determines 1-axis (wheels) vs 2-axis (joysticks) output.

Logging: Only logs when motor values change. Writes to both DiagLog (file) and RumbleLogger (shared diagnostic).

Static Device Management Methods

CheckVJoyInstalled() -> bool

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

FindFreeDeviceId() -> uint

Scans IDs 1–16, returns first with VJD_STAT_FREE. Returns 0 if none free. Non-blocking — no process spawning or registry access.

AllDevicesReady(int count) -> bool

Checks all vJoy IDs 1..count report VJD_STAT_FREE. Used by the wait loop in EnsureDevicesAvailable to confirm ALL devices are ready after a node restart.

IsServiceStuck() -> bool

Checks if vjoy service is in STOP_PENDING via sc.exe query vjoy. This zombie state occurs when a previous uninstall removed the service before its device nodes — only a reboot clears it.

CountExistingDevices() -> int

Delegates to EnumerateVJoyInstanceIds().Count.

EnumerateVJoyInstanceIds() -> List<string>

Enumerates vJoy instance IDs via pnputil /enum-devices /class HIDClass, filtering for ROOT\HIDCLASS\* with "vJoy" description. Caches in _cachedInstanceIds for fast shutdown. More reliable than GetVJDStatus (stale DLL namespace cache).

RefreshVJoyDllHandles()

Forces vJoyInterface.dll to close its stale h0 handle and clear its namespace cache. Finds the DLL's hidden window ("win32app_vJoyInterface_DLL") via FindWindowW and sends WM_DEVICECHANGE / DBT_DEVICEQUERYREMOVE. The Brunner fork's WndProc closes all handles and sets h0 = INVALID_HANDLE_VALUE; the next API call lazily re-opens it. Returns silently if the window does not exist.

Without this, DICS_DISABLE takes ~5s waiting for handle timeout. With it, disable is near-instant.

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

public static bool EnsureDevicesAvailable(int requiredCount, VJoyDeviceConfig[] perDeviceConfigs)
public static bool EnsureDevicesAvailable(int requiredCount = 1)

Ensures N vJoy virtual joysticks are available. Architecture: ONE device node + N registry descriptor keys.

VJoyDeviceConfig struct:

public struct VJoyDeviceConfig
{
    public int Axes;        // 0-8
    public int Buttons;     // 0-128
    public int Povs;        // 0-4
    public int Sticks;      // thumbsticks (each uses 2 axes, informational)
    public int Triggers;    // each uses 1 axis (informational)
}

Flow:

  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, configs match, and DLL is loaded, returns immediately. This is the 1000 Hz hot path.
  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: Step 5 must count via SlotControllerTypes[i] == VJoy && SlotCreated[i], NOT VC lifecycle state. During EnsureTypeGroupOrder bubble sort on the UI thread, the polling thread can see transient state where a vJoy slot's type was swapped mid-sort, causing undercount and registry descriptor deletion.

RestartDeviceNode(bool countChanged = true)

Strategy depends on whether the descriptor count changed:

  • Content-only (countChanged=false): DICS_PROPCHANGE restarts the driver stack in-place, re-reading HID descriptors without recreating child PDOs. Fastest path.
  • Count change (countChanged=true): Must fully remove + recreate because HIDCLASS only creates child PDOs during EvtDeviceAdd. DICS_PROPCHANGE alone re-reads descriptors but does NOT create new PDOs.

Full restart sequence:

  1. RelinquishAllDevices() — releases all 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 via an elevated PowerShell script (-NoProfile -ExecutionPolicy Bypass):

  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 pnputil enumeration (~5s saving on shutdown). Tries SetupApiRestart.RemoveDevice() first, falls back to RemoveDeviceNode(). Resets static state. Fire-and-forget pnputil /scan-devices on success.

RelinquishAllDevices()

Calls RelinquishVJD(i) for IDs 1–16. Best-effort (catches all exceptions). Must precede disable/remove — otherwise CM_Disable_DevNode returns CR_REMOVE_VETOED (23) because the DLL still holds the device.

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: The driver reads descriptors at EvtDeviceAdd time; changing them without a node restart has no effect, but unnecessary writes break change detection.

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

Builds a HID Report Descriptor matching vJoyConf format. Inputs clamped: nAxes 0–8, nButtons 0–128, nPovs 0–4.

Fixed 97-byte report layout: 1 byte report ID + 16 axes x 4 bytes + 4 POV DWORDs + 128 button bits (16 bytes). Disabled axes/POVs/buttons are constant padding so offsets always match.

Axis usages (HID Generic Desktop page):

Index Usage HID Code
0 X 0x30
1 Y 0x31
2 Z 0x32
3 RX 0x33
4 RY 0x34
5 RZ 0x35
6 Slider 0x36
7 Dial 0x37

Active axes emit INPUT (Data, Var, Abs) (0x81, 0x02). Inactive axes emit INPUT (Cnst, Ary, Abs) (0x81, 0x01).

POV hats: Continuous POV using degree values x 100 (0–35900). Active POVs get Usage 0x39 (Hat Switch) + Data. Remaining slots are constant padding. Total: 4 x 32-bit DWORDs.

Buttons: Usage Page 0x09 (Button), 1-bit per button, padded to 128 bits total.

FFB: Appends the full PID descriptor via AppendFfbDescriptor() inside the Application collection.

See vJoy Deep Dive for full descriptor byte sequences.

AppendFfbDescriptor(List<byte> d, byte reportId)

Appends the full PID (Physical Interface Device) HID descriptor for FFB. Transcribed from vJoy-Brunner's hidReportDescFfb.h.

Report ID offset: baseId + 0x10 * reportId (1-based). Device 1 starts at 0x11, device 2 at 0x21. CRITICAL: Must use 0x10 * reportId, NOT 0x10 * (reportId - 1). Offset 0 collides with joystick input report ID 0x01, breaking vjoy.sys device creation.

FFB reports generated:

  • Set Effect Report (Output) — effect type, gain, direction, duration, trigger, axes enable
  • Set Envelope Report (Output) — attack/fade level and time
  • Set Condition Report (Output) — Spring/Damper/Friction/Inertia parameters per axis
  • Set Periodic Report (Output) — magnitude, offset, phase, period for sine/square/triangle
  • Set Constant Force Report (Output) — signed magnitude
  • Set Ramp Force Report (Output) — start/end force
  • Effect Operation Report (Output) — start/stop/solo
  • PID Block Free Report (Output) — delete effect
  • PID State Report (Input) — effect playing/stalled status
  • Device Control (Output) — enable/disable/stop all/reset
  • Device Gain Report (Output) — master gain 0–255
  • Create New Effect Report (Feature) — allocate effect block
  • PID Block Load Report (Feature) — report allocation result
  • PID Pool Report (Feature) — report available memory

See vJoy Deep Dive for full descriptor format.

EnsureDriverInStore()

Ensures vjoy.inf is in the driver store via pnputil /add-driver. Without it, PnP won't apply UpperFilters=mshidkmdf from the INF — vjoy.sys handles IOCTLs but no HID reports reach Windows (joy.cpl shows no output). Checks pnputil /enum-drivers first to avoid redundant adds. Called once per session.

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 catch DllNotFoundException and return safe defaults (false/0). The app works without vJoy installed — UI is hidden.

FfbCallback catches all exceptions to prevent crashes on the vJoy thread pool. CreateVJoyDevices has a 30s timeout; failure returns false for retry next cycle.

Device node operations use multi-fallback strategies: SetupAPI -> CfgMgr32 -> pnputil.

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 use CallingConvention.Cdecl and DllImport("vJoyInterface.dll"). FFB parsers return uint status (0 = success) via IOCTL path (GUID_DEVINTERFACE_VJOY + GET_FFB_DATA). Works independently of COL02 state.

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. Appears in DAWs and MIDI applications as "PadForge MIDI N". Falls back gracefully without MIDI Services.

Type isolation: MIDI cards cannot switch to Xbox/DS4/VJoy and vice versa — type dropdown is disabled for MIDI slots.

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 -> MidiCC0MidiCC5)
  • 11 buttons to Note slots 0–10 (A, B, X, Y, LB, RB, Back, Start, LS, RS, Guide -> MidiNote0MidiNote10)

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, stores 1-based instance number.

Connect()

Returns early if already connected. Initialization sequence:

  1. Creates MidiDeclaredEndpointInfo with name "PadForge MIDI {instanceNum}", product ID "PADFORGE_MIDI_{instanceNum}", MIDI 1.0 protocol.
  2. Creates MidiVirtualDeviceCreationConfig with slot description.
  3. Adds a MidiFunctionBlock (bidirectional, Group 0, RepresentsMidi10Connection = YesBandwidthUnrestricted).
  4. MidiSession.Create(deviceName). Throws if null.
  5. Creates virtual device via MidiVirtualDeviceManager.CreateVirtualDevice(config). SuppressHandledMessages = true.
  6. Creates MidiEndpointConnection to the device's endpoint ID.
  7. Adds virtual device as message processing plugin.
  8. Opens connection. Throws if false.
  9. Sets _connected = true.
  10. Initializes _lastCcValues (filled with 64) and _lastNotes.

Error handling: Steps 5–8 failure triggers full cleanup (disconnect, dispose session, null references) before re-throw. Prevents leaked MIDI sessions.

Disconnect()

Returns early if not connected. Sequence:

  1. Sets _connected = false immediately (prevents sends during cleanup).
  2. Sends Note Off for held notes to prevent stuck notes in DAWs.
  3. Nulls _lastNotes.
  4. Disconnects endpoint, nulls _connection.
  5. Nulls _virtualDevice.
  6. Disposes and 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 MidiRawState. Returns immediately if not connected. Only sends on value change.

CC messages: Iterates min(state.CcValues.Length, _lastCcValues.Length, CcNumbers.Length). Changed CC values trigger SendCC(). Triple-min guards against mid-stream config changes.

Note messages: Same triple-min pattern. Changed notes trigger SendNoteOn() or SendNoteOff().

Thread safety: _connection is read into a local before null-check and send, preventing races with Disconnect().

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

Thread-safe, double-checked locking on _availLock. Caches result in _isAvailable.

  1. Fast path: if _isAvailable.HasValue, returns cached value.
  2. Under lock: creates MidiDesktopAppSdkInitializer.Create().
  3. InitializeSdkRuntime() — disposes and caches false on failure.
  4. EnsureServiceAvailable() — disposes and caches false on failure.
  5. Success: keeps _initializer alive (required for SDK lifetime), caches true.
  6. Any exception: caches false.

ResetAvailability()

Resets cached availability so IsAvailable() re-evaluates. Disposes _initializer if present, sets _isAvailable = null. Call after installing MIDI Services.

Shutdown(bool skipDispose = false)

public static void Shutdown(bool skipDispose = false)

Disposes the SDK initializer. Call on application exit.

skipDispose: When true, abandons the initializer without Dispose(). Use before uninstalling MIDI Services — Dispose() calls into the runtime and crashes if the service is being removed. Resets _isAvailable = null.


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 Win32 SendInput. Maps controller inputs to key presses, mouse movement, clicks, and scroll. No virtual device — 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 pad index. No resources acquired, no driver interaction.

Connect()

Returns early if connected. Sets _connected = true and resets all tracking to zero. Lightweight — no virtual device created.

Disconnect()

Returns early if not connected. Sets _connected = false then calls ReleaseAll().

ReleaseAll(): Sends key-up for all held keys and button-up for all held mouse buttons (XOR with 0 generates releases for every set bit). Resets tracking to zero. Prevents stuck keys/buttons on disconnect.

Dispose()

Guarded by _disposed. Calls Disconnect().

SubmitGamepadState(Gamepad gp)

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

SubmitKbmState(KbmRawState raw)

Primary output method. Returns if not connected. Processes four input categories per frame:

1. Keyboard keys (change detection via XOR):

private void ProcessKeyWord(ulong current, ulong previous, int baseVk)
{
    ulong changed = current ^ previous;
    if (changed == 0) return;    // fast path: no changes in this 64-key block

    for (int bit = 0; bit < 64; bit++)
    {
        if ((changed & (1UL << bit)) == 0) continue;
        bool pressed = (current & (1UL << bit)) != 0;
        SendKeyboard((ushort)(baseVk + bit), pressed);
    }
}

Compares 4 ulong words (raw.Keys0..3) against previous frame via XOR. Only changed bits generate SendInput calls. Critical at 1000 Hz with 256 VK codes.

2. Mouse buttons (change detection):

Compares raw.MouseButtons against _prevMouseButtons via XOR.

Bit Button Down Flag Up Flag
0 Left (LMB) MOUSEEVENTF_LEFTDOWN MOUSEEVENTF_LEFTUP
1 Right (RMB) MOUSEEVENTF_RIGHTDOWN MOUSEEVENTF_RIGHTUP
2 Middle (MMB) MOUSEEVENTF_MIDDLEDOWN MOUSEEVENTF_MIDDLEUP
3 XButton1 MOUSEEVENTF_XDOWN MOUSEEVENTF_XUP
4 XButton2 MOUSEEVENTF_XDOWN MOUSEEVENTF_XUP

XButton1/XButton2 use mouseData field to specify which extra button.

3. Mouse movement (continuous, no change detection):

float mx = raw.MouseDeltaX / 32767.0f * MouseSensitivity;      // pixels
float my = -(raw.MouseDeltaY / 32767.0f * MouseSensitivity);    // Y inverted

Signed short deltas (-32767 to +32767) scaled and sent as relative pixels via MOUSEEVENTF_MOVE. Y negated (raw up = screen down). Only sends if non-zero. Deadzone already applied in Step 3.

4. Mouse scroll (continuous, no change detection):

float scroll = raw.ScrollDelta / 32767.0f * ScrollSensitivity;
SendMouseWheel((int)(scroll * 120));    // 120 = WHEEL_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 states: keys and mouse buttons OR'd; deltas take the largest absolute magnitude. Used when multiple devices map to one 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): INPUT uses LayoutKind.Sequential with an inner Explicit union at FieldOffset(0). On x64, ULONG_PTR fields need 8-byte alignment, so the union starts at offset 8. A flat Explicit layout with hardcoded offsets would break across architectures.

Key scan codes: SendKeyboard sets both wVk and wScan (via MapVirtualKeyW). Some games using DirectInput raw input require the scan code.

Individual calls: Each SendInput submits exactly 1 event. Batching key down + up would give identical timestamps, breaking some applications.


See Also

Clone this wiki locally