Skip to content

Virtual Controllers

hifihedgehog edited this page Mar 8, 2026 · 40 revisions

Virtual Controllers

Developer reference for all four IVirtualController implementations.

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/MidiVirtualController.cs

IVirtualController Interface

namespace PadForge.Engine
{
    public enum VirtualControllerType
    {
        Xbox360 = 0,
        DualShock4 = 1,
        VJoy = 2,
        Midi = 3
    }

    public interface IVirtualController : IDisposable
    {
        VirtualControllerType Type { get; }
        bool IsConnected { get; }
        int FeedbackPadIndex { get; set; }
        void Connect();
        void Disconnect();
        void SubmitGamepadState(Gamepad gp);
        void RegisterFeedbackCallback(int padIndex, Vibration[] vibrationStates);
    }
}

The Gamepad struct uses the XInput layout (signed short axes, byte triggers, ushort button bitmask). FeedbackPadIndex tracks which slot this VC occupies so feedback callbacks write to the correct VibrationStates element after a slot reorder via SwapSlotData. Xbox 360, DS4, and vJoy implementations receive the same Gamepad and translate it to their output format. The MIDI implementation uses MidiRawState instead.


Xbox360VirtualController

Namespace: PadForge.Common.Input Visibility: internal sealed NuGet dependency: Nefarius.ViGEm.Client

Fields

Field Type Description
_controller IXbox360Controller ViGEm Xbox 360 controller instance (readonly)
_disposed bool Dispose guard

Properties

Property Type Description
Type VirtualControllerType Always VirtualControllerType.Xbox360
IsConnected bool True after Connect(), false after Disconnect()

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.

Connect()

public void Connect()

Calls _controller.Connect() on the ViGEm bus. After this call, Windows sees a new Xbox 360 controller. Sets IsConnected = true.

Disconnect()

public void Disconnect()

Calls _controller.Disconnect(). The virtual device is removed from Windows. Sets IsConnected = false.

Dispose()

public void Dispose()

Guarded by _disposed. Calls Disconnect() if still connected, then disposes the underlying controller via (IDisposable)?.Dispose().

SubmitGamepadState(Gamepad gp)

public void SubmitGamepadState(Gamepad gp)

Maps the Gamepad struct to Xbox 360 report format and submits:

Buttons (direct bitmask check against Gamepad constants):

Gamepad Flag Xbox360Button
Gamepad.A Xbox360Button.A
Gamepad.B Xbox360Button.B
Gamepad.X Xbox360Button.X
Gamepad.Y Xbox360Button.Y
Gamepad.LEFT_SHOULDER Xbox360Button.LeftShoulder
Gamepad.RIGHT_SHOULDER Xbox360Button.RightShoulder
Gamepad.BACK Xbox360Button.Back
Gamepad.START Xbox360Button.Start
Gamepad.LEFT_THUMB Xbox360Button.LeftThumb
Gamepad.RIGHT_THUMB Xbox360Button.RightThumb
Gamepad.GUIDE Xbox360Button.Guide
Gamepad.DPAD_UP Xbox360Button.Up
Gamepad.DPAD_DOWN Xbox360Button.Down
Gamepad.DPAD_LEFT Xbox360Button.Left
Gamepad.DPAD_RIGHT Xbox360Button.Right

Axes (signed short, direct passthrough):

Gamepad Field Xbox360Axis
ThumbLX LeftThumbX
ThumbLY LeftThumbY
ThumbRX RightThumbX
ThumbRY RightThumbY

Triggers (byte, direct passthrough):

Gamepad Field Xbox360Slider
LeftTrigger LeftTrigger
RightTrigger RightTrigger

Finishes with _controller.SubmitReport().

RegisterFeedbackCallback(int padIndex, Vibration[] vibrationStates)

public void RegisterFeedbackCallback(int padIndex, Vibration[] vibrationStates)

Subscribes to _controller.FeedbackReceived. The callback:

  1. Captures padIndex in a closure (capturedIndex).
  2. Reads args.LargeMotor and args.SmallMotor (byte 0-255 from ViGEm).
  3. Scales to ushort: (ushort)(args.LargeMotor * 257) and (ushort)(args.SmallMotor * 257).
  4. Writes to vibrationStates[capturedIndex].LeftMotorSpeed / .RightMotorSpeed.
  5. Logs via RumbleLogger.Log() on change detection.

The callback runs on the ViGEm thread (not the polling thread). The Vibration struct fields are ushort, and writes are atomic. Step 2 reads these values on the polling thread for rumble forwarding.


DS4VirtualController

Namespace: PadForge.Common.Input Visibility: internal sealed NuGet dependency: Nefarius.ViGEm.Client

Fields

Field Type Description
_controller IDualShock4Controller ViGEm DualShock 4 controller instance (readonly)
_disposed bool Dispose guard

Properties

Property Type Description
Type VirtualControllerType Always VirtualControllerType.DualShock4
IsConnected bool True after Connect(), false after Disconnect()

Constructor

public DS4VirtualController(ViGEmClient client)

Creates a DualShock 4 virtual controller via client.CreateDualShock4Controller().

Connect() / Disconnect() / Dispose()

Same pattern as Xbox360VirtualController.

SubmitGamepadState(Gamepad gp)

public void SubmitGamepadState(Gamepad gp)

Maps the Gamepad struct to DS4 report format. Key differences from Xbox 360:

Face buttons (different naming convention):

Gamepad Flag DualShock4Button
Gamepad.A DualShock4Button.Cross
Gamepad.B DualShock4Button.Circle
Gamepad.X DualShock4Button.Square
Gamepad.Y DualShock4Button.Triangle

Shoulder buttons:

Gamepad Flag DualShock4Button
Gamepad.LEFT_SHOULDER DualShock4Button.ShoulderLeft
Gamepad.RIGHT_SHOULDER DualShock4Button.ShoulderRight

Center buttons:

Gamepad Flag DualShock4Button
Gamepad.BACK DualShock4Button.Share
Gamepad.START DualShock4Button.Options

Thumbstick clicks: ThumbLeft, ThumbRight

Special buttons:

Gamepad Flag DS4 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);

D-Pad (mapped to hat switch via GetDPadDirection()):

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

Returns one of 9 values: None, North, Northeast, East, Southeast, South, Southwest, West, Northwest. Priority order: diagonals first (up+right, up+left, down+right, down+left), then cardinals.

Axes (signed short to unsigned byte conversion):

private static byte ShortToByte(short value) => (byte)((value + 32768) >> 8);
private static byte ShortToByteInvertY(short value) => (byte)((32767 - value) >> 8);

DS4 axes use byte 0-255 with center at 128. Y-axes are inverted (0=up in DS4 vs positive=up in Xbox).

Gamepad Field DualShock4Axis Conversion
ThumbLX LeftThumbX ShortToByte
ThumbLY LeftThumbY ShortToByteInvertY
ThumbRX RightThumbX ShortToByte
ThumbRY RightThumbY ShortToByteInvertY

Triggers (byte direct passthrough):

Gamepad Field DualShock4Slider
LeftTrigger LeftTrigger
RightTrigger RightTrigger

Finishes with _controller.SubmitReport().

RegisterFeedbackCallback(int padIndex, Vibration[] vibrationStates)

public void RegisterFeedbackCallback(int padIndex, Vibration[] vibrationStates)

Same pattern as Xbox360. Uses #pragma warning disable CS0618 because FeedbackReceived is marked obsolete but still functional in the ViGEm client library.


VJoyVirtualController

Namespace: PadForge.Common.Input Visibility: internal sealed No NuGet dependency — uses direct P/Invoke to vJoyInterface.dll.

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.

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
_ffbCallbackRegistered bool Whether the global FFB callback is registered
_ffbCallbackDelegate VJoyNative.FfbGenCB Strong reference to prevent GC of the callback delegate
_ffbDeviceMap Dictionary<uint, (int padIndex, Vibration[] states)> Routes vJoy device ID to vibration output
_ffbDeviceStates Dictionary<uint, FfbDeviceState> Per-device FFB effect tracking
_lastDeviceConfigs VJoyDeviceConfig[] Cached per-device configs from last EnsureDevicesAvailable call

Static Properties

Property Type Description
CurrentDescriptorCount int Read-only accessor for _currentDescriptorCount. Used by Step 5
IsDllLoaded bool Whether vJoyInterface.dll is loaded

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()
_submitCallCount int Diagnostic counter for SubmitGamepadState calls
_submitFailCount int Diagnostic counter for failed UpdateVJD calls

Instance Properties

Property Type Description
Type VirtualControllerType Always VirtualControllerType.VJoy
IsConnected bool Read from _connected
DeviceId uint The vJoy device ID (1-16)

Constructor

public VJoyVirtualController(uint deviceId)

Validates deviceId is in range 1-16. Throws ArgumentOutOfRangeException if not.

EnsureDllLoaded()

internal static void EnsureDllLoaded()

Preloads vJoyInterface.dll into the process. 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).
  5. Only sets _dllLoaded = true on success. Retries on next call if not found.

IMPORTANT: Do NOT use NativeLibrary.SetDllImportResolver — it hijacks the entire assembly's DLL resolution.

ResetState()

internal static void ResetState()

Resets all cached static state: _dllLoaded = false, _currentDescriptorCount = 0, _driverStoreChecked = false, increments _generation. Called after driver reinstall.

Connect()

public void Connect()
  1. Calls EnsureDllLoaded().
  2. Checks GetVJDStatus(_deviceId) — must be VJD_STAT_FREE.
  3. Calls AcquireVJD(_deviceId).
  4. Calls ResetVJD(_deviceId).
  5. Sets _connected = true, captures _connectedGeneration = _generation.
  6. Sends a test frame via UpdateVJD with non-zero axes and centered POV hats (0xFFFF_FFFF).

Throws InvalidOperationException if device is not free or acquisition fails.

Disconnect()

public void Disconnect()
  1. Removes FFB routing for this device from _ffbDeviceMap and _ffbDeviceStates (under _ffbLock).
  2. Calls ResetVJD(_deviceId) and RelinquishVJD(_deviceId).
  3. Sets _connected = false.

ReAcquireIfNeeded()

public void ReAcquireIfNeeded()

Called by Step 5 after EnsureDevicesAvailable to ensure existing controllers re-claim their device IDs BEFORE FindFreeDeviceId() runs for new controllers. If _connectedGeneration != _generation, relinquishes and re-acquires the device. Non-fatal on failure (retries next cycle).

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, transparently re-acquires the device handle before submitting.

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 / 255;
int rt = gp.RightTrigger * 32767 / 255;

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)

public void SubmitRawState(VJoyRawState raw)

Submits a VJoyRawState directly, bypassing the Gamepad struct. Used for custom vJoy configurations with arbitrary axis/button/POV counts.

Axis mapping (supports up to 16 axes):

Index JoystickPositionV2 Field
0 wAxisX
1 wAxisY
2 wAxisZ
3 wAxisXRot
4 wAxisYRot
5 wAxisZRot
6 wSlider
7 wDial
8 wWheel
9 wAxisVX
10 wAxisVY
11 wAxisVZ
12 wAxisVBRX
13 wAileron
14 wRudder
15 wThrottle

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

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

RegisterFeedbackCallback(int padIndex, Vibration[] vibrationStates)

public void RegisterFeedbackCallback(int padIndex, Vibration[] vibrationStates)

Registers this device for FFB routing:

  1. Under _ffbLock, adds _deviceId -> (padIndex, vibrationStates) to _ffbDeviceMap.
  2. If _ffbCallbackRegistered is false, registers FfbCallback via VJoyNative.FfbRegisterGenCB().
  3. Keeps a strong reference to the delegate in _ffbCallbackDelegate to prevent GC.
  4. Catches DllNotFoundException gracefully.

The global callback is shared across all vJoy devices — it routes by device ID.

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;        // absolute, 0-10000
    public byte Gain = 255;      // per-effect gain (0-255)
    public ushort Duration;      // ms, 0xFFFF=infinite
    public bool Running;
    public ushort Direction;     // polar direction 0-32767
}

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)

private static void ApplyMotorOutput(uint deviceId, FfbDeviceState devState)

Computes aggregate motor output from all running effects:

  1. Sums magnitude * (effectGain / 255.0) for all running effects with non-zero magnitude.
  2. Applies device-level gain: motorSum *= deviceGain / 255.0.
  3. Scales from 0-10000 to 0-65535 (ushort).
  4. Writes equal value to both LeftMotorSpeed and RightMotorSpeed (Xbox/DS4 don't have directional rumble).

Static Device Management Methods

CheckVJoyInstalled() -> bool

public static bool CheckVJoyInstalled()

Returns true if the vJoy driver is installed and enabled. Catches DllNotFoundException gracefully.

FindFreeDeviceId() -> uint

public static uint FindFreeDeviceId()

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.

IsServiceStuck() -> bool

public static bool IsServiceStuck()

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 device nodes — only a full restart clears it.

CountExistingDevices() -> int

public static int CountExistingDevices()

Counts existing vJoy device nodes via pnputil /enum-devices /class HIDClass. More reliable than GetVJDStatus which returns stale data.

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;
    public int Buttons;
    public int Povs;
    public int Sticks;    // thumbsticks (each uses 2 axes)
    public int Triggers;  // each uses 1 axis
}

Flow:

  1. First call: EnsureDriverInStore() and EnsureFfbRegistryKeys().
  2. Fast path: if count and configs match cached state, skip expensive operations.
  3. Writes registry descriptors via WriteDeviceDescriptors().
  4. If requiredCount == 0: disables the device node (not remove — avoids stale DLL handles).
  5. If no device node exists: creates one via CreateVJoyDevices(1), waits up to 5s for PnP binding.
  6. If excess nodes exist: removes extras, keeps one.
  7. If descriptors changed: restarts node (disable/enable via pnputil), waits up to 5s.
  8. Increments _generation on restart so connected controllers know to re-acquire.

totalVJoyNeeded race fix: Count must use 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.

CreateVJoyDevices(int count) -> bool

internal static bool CreateVJoyDevices(int count = 1)

Creates device nodes using SetupAPI in an elevated PowerShell script. Steps:

  1. SetupDiCreateDeviceInfoList(HIDClass GUID)
  2. SetupDiCreateDeviceInfoW("HIDClass", DICD_GENERATE_ID)Critical: DeviceName must be "HIDClass" (class name), NOT the hardware ID.
  3. SetupDiSetDeviceRegistryPropertyW(SPDRP_HARDWAREID, "root\VID_1234&PID_BEAD&REV_0222")
  4. SetupDiCallClassInstaller(DIF_REGISTERDEVICE)
  5. UpdateDriverForPlugAndPlayDevicesW with flag 0 (no INSTALLFLAG_FORCE).

RemoveDeviceNode(string instanceId) -> bool / RemoveAllDeviceNodes() -> bool

internal static bool RemoveDeviceNode(string instanceId)
internal static bool RemoveAllDeviceNodes()

Removes device nodes via pnputil /remove-device "{instanceId}" /subtree. Resets _dllLoaded and _currentDescriptorCount after removal.

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

private static bool WriteDeviceDescriptors(int requiredCount, VJoyDeviceConfig[] perDeviceConfigs)

Writes HID report descriptors to HKLM\SYSTEM\CurrentControlSet\services\vjoy\Parameters\DeviceNN. Only writes when descriptors differ from existing (avoids disturbing live devices). Excess DeviceNN keys beyond required count are removed. Returns true if any registry changes occurred.

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

private static byte[] BuildHidDescriptor(byte reportId, int nAxes, int nButtons, int nPovs)

Builds a HID Report Descriptor matching vJoyConf format. Fixed 97-byte report layout. See vJoy Deep Dive for full descriptor format.

AppendFfbDescriptor(List<byte> d, byte reportId)

private static void AppendFfbDescriptor(List<byte> d, byte reportId)

Appends the full PID (Physical Interface Device) HID descriptor for force feedback. Report IDs are offset by 0x10 * reportId (1-based). See vJoy Deep Dive for full FFB descriptor.

EnsureFfbRegistryKeys()

private static void EnsureFfbRegistryKeys()

Writes OEMForceFeedback registry keys to HKCU\...\OEM\VID_1234&PID_BEAD\OEMForceFeedback. Required for DirectInput to enumerate the device as FFB-capable. Keys include CLSID, Attributes, and 11 effect GUIDs (ConstantForce, RampForce, Square, Sine, Triangle, SawtoothUp, SawtoothDown, Spring, Damper, Inertia, Friction).

P/Invoke Declarations (VJoyNative)

internal static class VJoyNative
{
    bool vJoyEnabled();
    VjdStat GetVJDStatus(uint rID);
    bool AcquireVJD(uint rID);
    void RelinquishVJD(uint rID);
    bool ResetVJD(uint rID);
    bool UpdateVJD(uint rID, ref JoystickPositionV2 pData);
    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
    delegate void FfbGenCB(IntPtr data, IntPtr userData);
    void FfbRegisterGenCB(FfbGenCB cb, IntPtr data);
    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").

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)

Creates a system-wide virtual MIDI endpoint via Windows MIDI Services. The device appears in DAWs, synths, and any MIDI-aware application. Falls back gracefully on systems without Windows MIDI Services installed.

Static Fields

Field Type Description
_isAvailable bool? Cached availability check result
_availLock object Lock protecting availability check
_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 created via MidiVirtualDeviceManager
_connected bool Whether this controller is connected
_disposed bool Dispose guard
_padIndex int Slot index (readonly)
_channel int MIDI channel 0-15 (readonly)
_instanceNum int 1-based MIDI-type instance number (readonly)
_lastCcValues byte[] Last sent CC values (change detection)
_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
Velocity byte 127 Note-on velocity for button presses

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

public void Connect()
  1. Creates a MidiDeclaredEndpointInfo with name "PadForge MIDI {instanceNum}" and product instance ID "PADFORGE_MIDI_{instanceNum}".
  2. Configures as MIDI 1.0 protocol only (SupportsMidi10Protocol = true, SupportsMidi20Protocol = false).
  3. Adds a single MidiFunctionBlock (bidirectional, Group 0, represents MIDI 1.0 connection).
  4. Creates virtual device via MidiVirtualDeviceManager.CreateVirtualDevice(config).
  5. Creates a MidiSession and MidiEndpointConnection to the virtual device's endpoint.
  6. Opens the connection. Sets _connected = true.
  7. Initializes change detection arrays sized to match CcNumbers and NoteNumbers lengths. CC values initialize to 64 (center for axes).

Disconnect()

public void Disconnect()
  1. Sends Note Off for any held notes (iterates _lastNotes, sends Note Off where true).
  2. Disconnects the endpoint connection via _session.DisconnectEndpointConnection().
  3. Disposes the session. Sets _connected = false.

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)

public void SubmitMidiRawState(MidiRawState state)

Sends MIDI messages from a MidiRawState with arbitrary CC and note counts. Only sends messages when values change (change detection per CC and per note).

CC messages: For each CC slot where state.CcValues[i] != _lastCcValues[i], sends a MIDI 1.0 Control Change message via MidiMessageBuilder.BuildMidi1ChannelVoiceMessage() on the configured channel and group 0.

Note messages: For each note slot where state.Notes[i] != _lastNotes[i], sends Note On (with configured velocity) or Note Off as appropriate.

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

public static bool IsAvailable()

Returns true if Windows MIDI Services is available on this system. Thread-safe with double-checked locking on _availLock. Caches result after first check.

  1. Creates MidiDesktopAppSdkInitializer.
  2. Calls InitializeSdkRuntime(). Returns false if this fails.
  3. Calls EnsureServiceAvailable(). Returns false if this fails.
  4. On success, keeps _initializer alive and caches true.
  5. On any exception, caches false.

ResetAvailability()

public static void ResetAvailability()

Resets the cached availability check so the next call to IsAvailable() re-evaluates. Call after installing Windows MIDI Services. Disposes the existing initializer if present.

Shutdown()

public static void Shutdown()

Disposes the SDK initializer. Call on application exit.

Clone this wiki locally