-
Notifications
You must be signed in to change notification settings - Fork 6
Virtual Controllers
Developer reference for all four IVirtualController implementations.
Source files:
-
PadForge.Engine/Common/VirtualControllerTypes.cs— interface + enum PadForge.App/Common/Input/Xbox360VirtualController.csPadForge.App/Common/Input/DS4VirtualController.csPadForge.App/Common/Input/VJoyVirtualController.csPadForge.App/Common/Input/MidiVirtualController.cs
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.
Namespace: PadForge.Common.Input
Visibility: internal sealed
NuGet dependency: Nefarius.ViGEm.Client
| Field | Type | Description |
|---|---|---|
_controller |
IXbox360Controller |
ViGEm Xbox 360 controller instance (readonly) |
_disposed |
bool |
Dispose guard |
| Property | Type | Description |
|---|---|---|
Type |
VirtualControllerType |
Always VirtualControllerType.Xbox360
|
IsConnected |
bool |
True after Connect(), false after Disconnect()
|
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.
public void Connect()Calls _controller.Connect() on the ViGEm bus. After this call, Windows sees a new Xbox 360 controller. Sets IsConnected = true.
public void Disconnect()Calls _controller.Disconnect(). The virtual device is removed from Windows. Sets IsConnected = false.
public void Dispose()Guarded by _disposed. Calls Disconnect() if still connected, then disposes the underlying controller via (IDisposable)?.Dispose().
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().
public void RegisterFeedbackCallback(int padIndex, Vibration[] vibrationStates)Subscribes to _controller.FeedbackReceived. The callback:
- Captures
padIndexin a closure (capturedIndex). - Reads
args.LargeMotorandargs.SmallMotor(byte 0-255 from ViGEm). - Scales to ushort:
(ushort)(args.LargeMotor * 257)and(ushort)(args.SmallMotor * 257). - Writes to
vibrationStates[capturedIndex].LeftMotorSpeed/.RightMotorSpeed. - 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.
Namespace: PadForge.Common.Input
Visibility: internal sealed
NuGet dependency: Nefarius.ViGEm.Client
| Field | Type | Description |
|---|---|---|
_controller |
IDualShock4Controller |
ViGEm DualShock 4 controller instance (readonly) |
_disposed |
bool |
Dispose guard |
| Property | Type | Description |
|---|---|---|
Type |
VirtualControllerType |
Always VirtualControllerType.DualShock4
|
IsConnected |
bool |
True after Connect(), false after Disconnect()
|
public DS4VirtualController(ViGEmClient client)Creates a DualShock 4 virtual controller via client.CreateDualShock4Controller().
Same pattern as Xbox360VirtualController.
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().
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.
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.
vJoy virtual controllers follow the same presentation lifecycle as ViGEm (Xbox 360/DS4): they only appear in joy.cpl when BOTH conditions are true:
- A physical device is mapped to the slot
- That device is connected and online
When no device is mapped or the mapped device is disconnected, the vJoy controller is not active and not visible to games. This matches ViGEm behavior where virtual controllers only exist when a device is feeding input to them.
Virtual controllers are created in ascending slot order to ensure ViGEm assigns sequential indices matching PadForge slot numbers. An initializing indicator is shown in the Dashboard while a controller is being created or reconfigured (e.g., vJoy descriptor change triggering a node restart).
| Field | Type | Description |
|---|---|---|
_dllLoaded |
bool |
Whether vJoyInterface.dll has been successfully loaded |
_currentDescriptorCount |
int |
Number of DeviceNN registry descriptors currently written |
_driverStoreChecked |
bool |
Whether driver store check has run this session |
_generation |
int |
Incremented on device node restart; triggers handle re-acquire |
_ffbLock |
object |
Lock protecting FFB state |
_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 |
| Property | Type | Description |
|---|---|---|
CurrentDescriptorCount |
int |
Read-only accessor for _currentDescriptorCount. Used by Step 5 |
IsDllLoaded |
bool |
Whether vJoyInterface.dll is loaded |
| 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 |
| Property | Type | Description |
|---|---|---|
Type |
VirtualControllerType |
Always VirtualControllerType.VJoy
|
IsConnected |
bool |
Read from _connected
|
DeviceId |
uint |
The vJoy device ID (1-16) |
public VJoyVirtualController(uint deviceId)Validates deviceId is in range 1-16. Throws ArgumentOutOfRangeException if not.
internal static void EnsureDllLoaded()Preloads vJoyInterface.dll into the process. Strategy:
- If
_dllLoadedis true, return immediately. - Try
NativeLibrary.TryLoad("vJoyInterface.dll")(default search paths). - Try
C:\Program Files\vJoy\vJoyInterface.dll. - Try
C:\Program Files\vJoy\x64\vJoyInterface.dll(legacy installs). - Only sets
_dllLoaded = trueon success. Retries on next call if not found.
IMPORTANT: Do NOT use NativeLibrary.SetDllImportResolver — it hijacks the entire assembly's DLL resolution.
internal static void ResetState()Resets all cached static state: _dllLoaded = false, _currentDescriptorCount = 0, _driverStoreChecked = false, increments _generation. Called after driver reinstall.
public void Connect()- Calls
EnsureDllLoaded(). - Checks
GetVJDStatus(_deviceId)— must beVJD_STAT_FREE. - Calls
AcquireVJD(_deviceId). - Calls
ResetVJD(_deviceId). - Sets
_connected = true, captures_connectedGeneration = _generation. - Sends a test frame via
UpdateVJDwith non-zero axes and centered POV hats (0xFFFF_FFFF).
Throws InvalidOperationException if device is not free or acquisition fails.
public void Disconnect()- Removes FFB routing for this device from
_ffbDeviceMapand_ffbDeviceStates(under_ffbLock). - Calls
ResetVJD(_deviceId)andRelinquishVJD(_deviceId). - Sets
_connected = false.
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).
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).
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.
public void RegisterFeedbackCallback(int padIndex, Vibration[] vibrationStates)Registers this device for FFB routing:
- Under
_ffbLock, adds_deviceId -> (padIndex, vibrationStates)to_ffbDeviceMap. - If
_ffbCallbackRegisteredis false, registersFfbCallbackviaVJoyNative.FfbRegisterGenCB(). - Keeps a strong reference to the delegate in
_ffbCallbackDelegateto prevent GC. - Catches
DllNotFoundExceptiongracefully.
The global callback is shared across all vJoy devices — it routes by device ID.
private class FfbDeviceState
{
public byte DeviceGain = 255; // 0-255, default 100%
public Dictionary<byte, FfbEffectState> Effects = new(); // keyed by EffectBlockIndex
}private class FfbEffectState
{
public FFBEType Type;
public int Magnitude; // 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
}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 |
private static void ApplyMotorOutput(uint deviceId, FfbDeviceState devState)Computes aggregate motor output from all running effects:
- Sums
magnitude * (effectGain / 255.0)for all running effects with non-zero magnitude. - Applies device-level gain:
motorSum *= deviceGain / 255.0. - Scales from 0-10000 to 0-65535 (ushort).
- Writes equal value to both
LeftMotorSpeedandRightMotorSpeed(Xbox/DS4 don't have directional rumble).
public static bool CheckVJoyInstalled()Returns true if the vJoy driver is installed and enabled. Catches DllNotFoundException gracefully.
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.
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.
public static int CountExistingDevices()Counts existing vJoy device nodes via pnputil /enum-devices /class HIDClass. More reliable than GetVJDStatus which returns stale data.
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:
- First call:
EnsureDriverInStore()andEnsureFfbRegistryKeys(). - Fast path: if count and configs match cached state, skip expensive operations.
- Writes registry descriptors via
WriteDeviceDescriptors(). - If
requiredCount == 0: disables the device node (not remove — avoids stale DLL handles). - If no device node exists: creates one via
CreateVJoyDevices(1), waits up to 5s for PnP binding. - If excess nodes exist: removes extras, keeps one.
- If descriptors changed: restarts node (disable/enable via pnputil), waits up to 5s.
- Increments
_generationon 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.
internal static bool CreateVJoyDevices(int count = 1)Creates device nodes using SetupAPI in an elevated PowerShell script. Steps:
SetupDiCreateDeviceInfoList(HIDClass GUID)-
SetupDiCreateDeviceInfoW("HIDClass", DICD_GENERATE_ID)— Critical: DeviceName must be"HIDClass"(class name), NOT the hardware ID. SetupDiSetDeviceRegistryPropertyW(SPDRP_HARDWAREID, "root\VID_1234&PID_BEAD&REV_0222")SetupDiCallClassInstaller(DIF_REGISTERDEVICE)-
UpdateDriverForPlugAndPlayDevicesWwith flag 0 (no INSTALLFLAG_FORCE).
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.
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.
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.
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.
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).
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").
[StructLayout(LayoutKind.Explicit, Size = 108)]
internal struct JoystickPositionV2
{
[FieldOffset(0)] byte bDevice; // 1-based device index
[FieldOffset(4)] int wThrottle;
[FieldOffset(8)] int wRudder;
[FieldOffset(12)] int wAileron;
[FieldOffset(16)] int wAxisX;
[FieldOffset(20)] int wAxisY;
[FieldOffset(24)] int wAxisZ;
[FieldOffset(28)] int wAxisXRot;
[FieldOffset(32)] int wAxisYRot;
[FieldOffset(36)] int wAxisZRot;
[FieldOffset(40)] int wSlider;
[FieldOffset(44)] int wDial;
[FieldOffset(48)] int wWheel;
[FieldOffset(52)] int wAxisVX;
[FieldOffset(56)] int wAxisVY;
[FieldOffset(60)] int wAxisVZ;
[FieldOffset(64)] int wAxisVBRX;
[FieldOffset(68)] int wAxisVBRY;
[FieldOffset(72)] int wAxisVBRZ;
[FieldOffset(76)] int lButtons; // Buttons 1-32 bitmask
[FieldOffset(80)] uint bHats; // POV hat 1 (continuous: centidegrees, centered=0xFFFFFFFF)
[FieldOffset(84)] uint bHatsEx1; // POV hat 2
[FieldOffset(88)] uint bHatsEx2; // POV hat 3
[FieldOffset(92)] uint bHatsEx3; // POV hat 4
[FieldOffset(96)] int lButtonsEx1; // Buttons 33-64
[FieldOffset(100)] int lButtonsEx2; // Buttons 65-96
[FieldOffset(104)] int lButtonsEx3; // Buttons 97-128
}Matches public.h _JOYSTICK_POSITION_V2 struct. Total size: 108 bytes.
Namespace: PadForge.Common.Input
Visibility: internal sealed
SDK dependency: Microsoft.Windows.Devices.Midi2 (Windows MIDI Services)
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.
| 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) |
| 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) |
| 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 |
| Property | Type | Description |
|---|---|---|
Type |
VirtualControllerType |
Always VirtualControllerType.Midi
|
IsConnected |
bool |
Read from _connected
|
FeedbackPadIndex |
int |
Slot index for feedback routing (unused — MIDI has no rumble) |
public MidiVirtualController(int padIndex, int channel, int instanceNum)Stores pad index, clamps channel to 0-15, and stores the 1-based instance number.
public void Connect()- Creates a
MidiDeclaredEndpointInfowith name"PadForge MIDI {instanceNum}"and product instance ID"PADFORGE_MIDI_{instanceNum}". - Configures as MIDI 1.0 protocol only (
SupportsMidi10Protocol = true,SupportsMidi20Protocol = false). - Adds a single
MidiFunctionBlock(bidirectional, Group 0, represents MIDI 1.0 connection). - Creates virtual device via
MidiVirtualDeviceManager.CreateVirtualDevice(config). - Creates a
MidiSessionandMidiEndpointConnectionto the virtual device's endpoint. - Opens the connection. Sets
_connected = true. - Initializes change detection arrays sized to match
CcNumbersandNoteNumberslengths. CC values initialize to 64 (center for axes).
public void Disconnect()- Sends Note Off for any held notes (iterates
_lastNotes, sends Note Off where true). - Disconnects the endpoint connection via
_session.DisconnectEndpointConnection(). - Disposes the session. Sets
_connected = false.
public void SubmitGamepadState(Gamepad gp)Legacy path — not used for dynamic MIDI. Kept as a no-op for IVirtualController interface compliance.
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.
// In PadForge.Engine/Common/GamepadTypes.cs
public struct MidiRawState
{
public byte[] CcValues; // CC values 0-127 per CC slot
public bool[] Notes; // Note on/off per note slot
public static MidiRawState Create(int ccCount, int noteCount);
}Dynamic-sized state struct. Create() allocates arrays of the specified sizes with CC values initialized to 64 (center).
No-op — MIDI has no rumble/force feedback.
Guarded by _disposed. Calls Disconnect().
All messages are built as MIDI 1.0 UMP (Universal MIDI Packet) via MidiMessageBuilder.BuildMidi1ChannelVoiceMessage() and sent via _connection.SendSingleMessagePacket().
| Helper | MIDI Status | Description |
|---|---|---|
SendCC(int ccNumber, byte value) |
ControlChange |
Sends CC on configured channel, group 0 |
SendNoteOn(int note, byte velocity) |
NoteOn |
Sends Note On on configured channel |
SendNoteOff(int note) |
NoteOff |
Sends Note Off (velocity 0) on configured channel |
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.
- Creates
MidiDesktopAppSdkInitializer. - Calls
InitializeSdkRuntime(). Returns false if this fails. - Calls
EnsureServiceAvailable(). Returns false if this fails. - On success, keeps
_initializeralive and cachestrue. - On any exception, caches
false.
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.
public static void Shutdown()Disposes the SDK initializer. Call on application exit.