-
Notifications
You must be signed in to change notification settings - Fork 6
Input Pipeline
The input pipeline is the core of PadForge. It runs at ~1000Hz on a dedicated background thread and processes physical device input through six steps to produce virtual controller output.
The pipeline is implemented as a partial class InputManager split across seven files:
| File | Step | Purpose |
|---|---|---|
InputManager.cs |
Main | Fields, Start/Stop, PollingLoop, motion snapshots, DSU broadcast |
InputManager.Step1.UpdateDevices.cs |
Step 1 | Device enumeration and lifecycle |
InputManager.Step2.UpdateInputStates.cs |
Step 2 | Input state reading and force feedback |
InputManager.Step3.UpdateOutputStates.cs |
Step 3 | Mapping engine (input -> Gamepad) |
InputManager.Step4.CombineOutputStates.cs |
Step 4 | Multi-device merge per slot |
InputManager.Step4b.EvaluateMacros.cs |
Step 4b | Macro trigger/action state machine |
InputManager.Step5.VirtualDevices.cs |
Step 5 | Virtual controller output |
InputManager.Step6.RetrieveOutputStates.cs |
Step 6 | Copy output for UI display |
All files are in PadForge.App/Common/Input/.
Namespace: PadForge.Common.Input
public partial class InputManager : IDisposable| Member | Type | Default | Description |
|---|---|---|---|
PollingIntervalMs |
int (property) |
1 |
Target polling interval in ms. Runtime-adjustable from Settings UI. |
EnumerationIntervalMs |
const int |
2000 |
Device re-enumeration interval (ms). |
MaxPads |
const int |
16 |
Maximum virtual controller slots. |
| Field | Type | Description |
|---|---|---|
_pollingThread |
Thread |
Background thread running PollingLoop. Name=PadForge.InputManager, Priority=AboveNormal, IsBackground=true. |
_running |
volatile bool |
Loop control flag. |
_sdlInitialized |
bool |
Whether SDL_Init succeeded. |
_disposed |
bool |
Disposal guard. |
_enumerationTimer |
Stopwatch |
Tracks time since last device enumeration. |
_frequencyTimer |
Stopwatch |
Tracks time for frequency measurement. |
_frequencyCounter |
int |
Cycle counter for frequency measurement. |
_deviceSnapshotBuffer |
UserDevice[] |
Pre-allocated buffer for Step 2 device snapshot (avoids LINQ allocations). |
_settingSnapshotBuffer |
UserSetting[] |
Pre-allocated buffer for Step 3 settings snapshot. |
_padIndexBuffer |
UserSetting[MaxPads] |
Pre-allocated buffer for FindByPadIndex lookups. |
_instanceGuidBuffer |
UserSetting[MaxPads] |
Pre-allocated buffer for FindByInstanceGuid lookups. |
| Property | Type | Written By | Read By | Description |
|---|---|---|---|---|
CombinedOutputStates |
Gamepad[MaxPads] |
Step 4 (engine) | Step 5, Step 6, UI | Combined gamepad state per slot. |
CombinedVJoyRawStates |
VJoyRawState[MaxPads] |
Step 4 (engine) | Step 5 | Combined raw vJoy state for custom presets. |
RetrievedOutputStates |
Gamepad[MaxPads] |
Step 6 (engine) | UI timer | Copy of combined states for UI display. |
VibrationStates |
Vibration[MaxPads] |
ViGEm callback | Step 2 (engine) | Per-slot rumble from games. |
MotionSnapshots |
MotionSnapshot[MaxPads] |
Engine (polling loop) | DSU broadcast | Per-slot motion sensor data. |
MacroSnapshots |
MacroItem[][MaxPads] |
UI timer (30Hz) | Step 4b (engine) | Per-slot macro definitions. |
TestRumbleTargetGuid |
Guid[MaxPads] |
UI | Step 2 | When non-empty, restricts test rumble to a specific device GUID. |
CurrentFrequency |
double |
Engine | UI | Measured polling frequency in Hz. Updated ~once/second. |
IsRunning |
bool |
Engine | UI | Whether the polling loop is active. |
public event EventHandler DevicesUpdated;
public event EventHandler FrequencyUpdated;
public event EventHandler<InputExceptionEventArgs> ErrorOccurred;| Event | Thread | Description |
|---|---|---|
DevicesUpdated |
Engine thread | Raised when devices connect/disconnect. UI must marshal to dispatcher. |
FrequencyUpdated |
Engine thread | Raised ~once per second with updated CurrentFrequency. |
ErrorOccurred |
Engine thread | Non-fatal errors during polling. |
public InputManager()Initializes VibrationStates[] with new Vibration() for each slot.
private bool InitializeSdl()Sets SDL hints, then calls SDL_Init with flags:
SDL_INIT_JOYSTICK | SDL_INIT_GAMEPAD | SDL_INIT_VIDEO | SDL_INIT_HAPTICKey hints:
-
SDL_HINT_JOYSTICK_ALLOW_BACKGROUND_EVENTS = "1"— receive input without focus -
SDL_HINT_JOYSTICK_XINPUT = "1"— enable Xbox controller enumeration -
SDL_HINT_JOYSTICK_HIDAPI_SWITCH2 = "1"— enable Switch 2 Pro Controller driver -
NEVER set
SDL_HINT_JOYSTICK_RAWINPUT— conflicts with XInput enumeration
private void ShutdownSdl()Calls SDL_Quit().
public void Start()- Guards against double-start or disposed state
- Calls
InitializeSdl() - Calls
RawInputListener.Start() - Creates and starts the polling thread
public void Stop(bool preserveVJoyNodes = false)- Sets
_running = false - Joins polling thread with 3-second timeout
- Stops
RawInputListener - Calls
StopAllForceFeedback(),DestroyAllVirtualControllers(),CloseAllDevices()
private void PollingLoop()Entry point for the background thread. Sets timeBeginPeriod(1) for the duration.
Per-cycle execution order:
SDL_UpdateJoysticks()
|
v (every 2 seconds, or first cycle)
Step 1: UpdateDevices()
|
v
Step 2: UpdateInputStates()
|
v
UpdateMotionSnapshots()
BroadcastDsuMotion()
|
v
Step 3: UpdateOutputStates()
|
v
Step 4: CombineOutputStates()
|
v
Step 4b: EvaluateMacros()
|
v
Step 5: UpdateVirtualDevices()
|
v
Step 6: RetrieveOutputStates()
|
v
Frequency measurement (~1/second)
|
v
Hybrid sleep/spin-wait
Timing strategy:
long targetTicks = Stopwatch.Frequency / 1000 * PollingIntervalMs;
long sleepThresholdTicks = Stopwatch.Frequency * 3 / 2000; // 1.5ms in ticks
while (remaining > 0)
{
if (remaining > sleepThresholdTicks)
Thread.Sleep(1); // Real sleep, near-zero CPU
else
Thread.SpinWait(1); // Precise busy-wait (CPU PAUSE instruction)
remaining = targetTicks - cycleTimer.ElapsedTicks;
}At PollingIntervalMs=1: mostly spin-wait (~0.5-0.7ms spinning, ~1-3% of one core).
At PollingIntervalMs=2+: Thread.Sleep(1) absorbs bulk of wait, CPU drops to near-zero.
public void SwapSlots(int slotA, int slotB)Same-type swaps keep virtual controllers alive (only input routing changes via MapTo swap). Cross-type swaps destroy both VCs for recreation with correct types.
public void SwapSlotData(int slotA, int slotB)Swaps only data arrays (SlotControllerTypes, TestRumbleTargetGuid, MacroSnapshots) without touching virtual controllers. Used by EnsureTypeGroupOrder bubble sort on the UI thread.
private void UpdateMotionSnapshots()For each pad slot, finds the first online device with gyro/accel sensors. Converts SDL coordinates to DSU/DS4 convention:
// SDL → DSU axis mapping:
AccelX = -ax // Inverted
AccelY = -ay // Inverted
AccelZ = -az // Inverted
GyroPitch = -gx // Inverted
GyroYaw = gy // Same sign
GyroRoll = -gz // InvertedUnit conversions: RadToDeg = 180/PI for gyro, MsToG = 1/9.80665 for accel.
private void BroadcastDsuMotion()Iterates all slots and calls DsuServer.BroadcastMotion() for each.
[DllImport("winmm.dll")]
private static extern uint timeBeginPeriod(uint uPeriod);
[DllImport("winmm.dll")]
private static extern uint timeEndPeriod(uint uPeriod);File: InputManager.Step1.UpdateDevices.cs
Enumerates all connected devices at 2-second intervals. Opens new devices, marks disconnected devices offline, and fires DevicesUpdated if the device list changed.
| Field | Type | Description |
|---|---|---|
_openedSdlInstanceIds |
HashSet<uint> |
SDL instance IDs of currently opened joysticks. |
_filteredVigemInstanceIds |
HashSet<uint> |
SDL instance IDs identified as ViGEm virtual controllers. Never re-opened. |
_openedKeyboardHandles |
HashSet<IntPtr> |
Raw Input handles for tracked keyboards. |
_openedMouseHandles |
HashSet<IntPtr> |
Raw Input handles for tracked mice. |
private void UpdateDevices()Phase 1: Open newly connected joystick devices
uint[] joystickIds = SDL_GetJoysticks();For each SDL instance ID:
- Skip if in
_filteredVigemInstanceIds(known ViGEm device) - Skip if in
_openedSdlInstanceIds(already open) new SdlDeviceWrapper().Open(instanceId)- Call
IsViGEmVirtualDevice()— if true, add to filter set and dispose -
FindOrCreateUserDevice(wrapper.InstanceGuid, wrapper.ProductGuid)— finds existing or creates new -
ud.LoadFromSdlDevice(wrapper)and markud.IsOnline = true - Track in
_openedSdlInstanceIds
Phase 1b: Enumerate keyboards
changed |= EnumerateKeyboards();Uses RawInputListener.EnumerateKeyboards(), creates SdlKeyboardWrapper per device, finds/creates UserDevice records.
Phase 1c: Enumerate mice
changed |= EnumerateMice();Uses RawInputListener.EnumerateMice(), creates SdlMouseWrapper per device.
Phase 2: Detect disconnected joystick devices
Iterates _openedSdlInstanceIds, finds the UserDevice for each, checks IsAttached. If detached, calls MarkDeviceOffline(ud) and removes from tracking.
Phase 2b-2c: Detect disconnected keyboards/mice
changed |= DetectDisconnectedHandles(_openedKeyboardHandles, RawInputListener.EnumerateKeyboards());
changed |= DetectDisconnectedHandles(_openedMouseHandles, RawInputListener.EnumerateMice());ViGEm filter cleanup:
_filteredVigemInstanceIds.IntersectWith(currentInstanceIds);Removes entries for ViGEm devices that no longer exist (virtual controller destroyed).
private bool IsViGEmVirtualDevice(SdlDeviceWrapper wrapper)Detection heuristics (checked in order):
- Device path contains "vigem" or "virtual" (case-insensitive)
- VID=0, PID=0 + recognized as game controller + active ViGEm count > 0 — ViGEm devices may report zero VID/PID
- VID=0x1234, PID=0xBEAD — vJoy virtual joystick output device
-
VID=0x045E, PID=0x028E (Xbox 360) — filtered when
_activeXbox360Count > 0or_expectedXbox360Count > 0 -
VID=0x054C, PID=0x05C4 (DS4 v1) — filtered when
_activeDs4Count > 0or_expectedDs4Count > 0
private UserDevice FindOnlineDeviceByInstanceGuid(Guid instanceGuid)
private UserDevice FindOnlineDeviceBySdlInstanceId(uint sdlInstanceId)
private UserDevice FindOrCreateUserDevice(Guid instanceGuid, Guid productGuid = default)
private static void MigrateUserSettingGuid(Guid oldGuid, Guid newGuid)
private void MarkDeviceOffline(UserDevice ud)FindOrCreateUserDevice performs a three-tier lookup:
- Exact match by InstanceGuid
- Fallback match: offline device with same ProductGuid (handles BT reconnect with new device path)
- Create new UserDevice
When a fallback match is found, both the UserDevice and its linked UserSetting are migrated to the new InstanceGuid.
public class DeviceCollection
{
public List<UserDevice> Items { get; }
public object SyncRoot { get; }
}
public class SettingsCollection
{
public List<UserSetting> Items { get; }
public object SyncRoot { get; }
public UserSetting FindByInstanceGuid(Guid instanceGuid)
public List<UserSetting> FindByPadIndex(int padIndex) // Allocating
public int FindByInstanceGuid(Guid instanceGuid, UserSetting[] buffer) // Non-allocating
public int FindByPadIndex(int padIndex, UserSetting[] buffer) // Non-allocating
}Non-allocating overloads are used in the hot path (Steps 2-5) to avoid GC pressure.
Declared in this file:
public static partial class SettingsManager
{
public static DeviceCollection UserDevices { get; set; }
public static SettingsCollection UserSettings { get; set; }
}File: InputManager.Step2.UpdateInputStates.cs
Reads current input state from all online devices and applies force feedback (rumble).
private void UpdateInputStates()- Snapshot online devices into
_deviceSnapshotBufferunderSyncRootlock - For each online device:
a. Save
ud.OldInputState = ud.InputState(for change detection) b. Callud.Device.GetCurrentState()— returnsCustomInputStateor null c. Atomic reference swap:ud.InputState = newStated. Compute buffered updates:CustomInputHelper.GetUpdates(oldState, newState)e. CallApplyForceFeedback(ud)
private void ApplyForceFeedback(UserDevice ud)- Skips devices without rumble or haptic support
- Finds ALL pad slots this device is mapped to (multi-slot assignment) using
FindByInstanceGuid - Combines vibration across all mapped slots: MAX of each motor
- Respects
TestRumbleTargetGuid— if set, only applies to the targeted device - Calls
ud.ForceFeedbackState.SetDeviceForces(ud, ud.Device, padSetting, combinedVibration)
private Vibration _combinedVibration; // Scratch buffer to avoid allocationWhen InputHookManager suppresses keyboard or mouse inputs via low-level hooks (WH_KEYBOARD_LL / WH_MOUSE_LL), the suppressed keys/buttons are captured within the hook callback before blocking. On the next polling cycle, SdlKeyboardWrapper.GetCurrentState() and SdlMouseWrapper.GetCurrentState() merge the suppressed input state into their results, ensuring that consumed inputs still appear in the CustomInputState sent to the mapping pipeline.
private static int TryParseInt(string value, int defaultValue)
private static bool TryParseBool(string value)File: InputManager.Step3.UpdateOutputStates.cs
Maps each device's CustomInputState to a Gamepad struct (and optionally a VJoyRawState) based on PadSetting mapping descriptors.
private void UpdateOutputStates()- Snapshot all UserSettings into
_settingSnapshotBuffer - For each UserSetting:
a. Find online device by
us.InstanceGuidb. GetPadSettingviaus.GetPadSetting()c. Apply center offset correction to stick axes (before dead zone):ApplyCenterOffset(value, offsetPercent)shifts the axis by the percentage offset stored inPadSetting.LeftThumbCenterOffsetX/Yetc. d. Apply max range scaling:ApplyMaxRange(value, maxRangePercent)scales the output so full physical deflection maps to the configured ceiling e.us.OutputState = MapInputToGamepad(ud.InputState, ps)f. For custom vJoy slots:us.VJoyRawOutputState = MapInputToVJoyRaw(ud.InputState, ps, cfg)
PadSetting string fields (e.g., ButtonA, LeftThumbAxisX) contain mapping descriptors:
[Prefix]{MapType} {Index} [Direction]
Prefixes (optional, combinable):
| Prefix | Meaning |
|---|---|
I |
Inverted — axis values are flipped |
H |
Half-axis — only the upper half (32768-65535) is used |
IH |
Inverted half-axis |
MapType values:
| MapType | Example | Description |
|---|---|---|
Axis |
"Axis 1" |
Joystick axis (unsigned 0-65535) |
Button |
"Button 0" |
Button press (digital, true/false) |
Slider |
"Slider 0" |
Slider control (unsigned 0-65535) |
POV |
"POV 0 Up" |
POV hat direction |
Pipe-separated OR logic:
"Button 0|Button 5" -- pressed if EITHER is pressed
"Axis 4|Button 8" -- max of axis value or button (0 or 255)
private struct MappingDescriptor
{
public MapType Type;
public int Index;
public bool Inverted;
public bool HalfAxis;
public string PovDirection; // "Up", "Down", "Left", "Right" (for POV)
public bool IsValid;
}private static MappingDescriptor ParseDescriptor(string descriptor)Parses "IHAxis 2" into {Type=Axis, Index=2, Inverted=true, HalfAxis=true, IsValid=true}.
private static bool MapToButtonPressed(CustomInputState state, string descriptor)
private static bool MapToButtonPressedSingle(CustomInputState state, string descriptor)-
Button source: returns
state.Buttons[index] -
Axis source: threshold at 75% —
value > 49151(orvalue < 16384if inverted) - Slider source: same threshold logic as axis
-
POV source:
IsPovDirectionActive(state.Povs[index], direction)
Multiple descriptors separated by | are OR'd.
private static bool IsPovDirectionActive(int povValue, string direction)Uses centidegree ranges with tolerances:
- Cardinals: +/-67.5 degree tolerance (e.g., "Up" = 29250-36000 or 0-6750)
- Diagonals: +/-22.5 degree tolerance (exact sector)
private static void MapDPadFromPov(CustomInputState state, string descriptor, ref Gamepad gp)When individual D-pad directions (DPadUp, DPadDown, DPadLeft, DPadRight) are set, they take priority. Otherwise, the combined DPad descriptor extracts all 4 directions from a single POV hat.
private static byte MapToTrigger(CustomInputState state, string descriptor)
private static byte MapToTriggerSingle(CustomInputState state, string descriptor)Converts unsigned 16-bit (0-65535) to trigger range (0-255). Multiple descriptors: highest value wins.
- Full axis:
rawValue * 255 / 65535 - Half axis:
(rawValue - 32768) * 255 / 32767(upper half only) - Inverted:
65535 - rawValuebefore conversion
private static byte ApplyTriggerDeadZone(byte value, int deadZone, int antiDeadZone, int maxRange)- Normalize to 0.0-1.0
- Dead zone: values below
deadZone%threshold are zeroed - Max range: caps input at
maxRange%ceiling - Remap from
[dzNorm, maxNorm]to[0, 1] - Anti-dead zone: offsets output minimum by
antiDeadZone%
private static short MapToThumbAxis(CustomInputState state, string descriptor)
private static short MapToThumbAxisSingle(CustomInputState state, string descriptor)
private static short MapToThumbAxisWithNeg(CustomInputState state, string posDescriptor, string negDescriptor)Converts unsigned (0-65535) to signed (-32768 to 32767): signed = rawValue - 32768.
When both posDescriptor and negDescriptor are set (typically for buttons mapped to axes):
- Positive pressed:
+32767 - Negative pressed:
-32768 - Both pressed:
0(cancel)
Y-axis negation: NegateAxis() is applied to ThumbLY and ThumbRY to correct orientation (unsigned pipeline produces 0=up as negative, but XInput convention is positive Y = up).
private static short NegateAxis(short value)
=> value == short.MinValue ? short.MaxValue : (short)-value;private static void ApplyDeadZone(ref short axisX, ref short axisY,
int deadZoneX, int deadZoneY,
string antiDeadZoneXStr, string antiDeadZoneYStr, string linearStr)
private static short ApplySingleDeadZone(short value, int deadZone, int antiDeadZone, int linear)Processing per axis:
- Normalize to float (-1.0 to 1.0)
- Dead zone:
|magnitude| < dzNorm-> return 0 - Remap:
(magnitude - dzNorm) / (1.0 - dzNorm) - Anti-dead zone:
adzNorm + remapped * (1.0 - adzNorm) - Linear adjustment: blends between remapped and anti-dead-zone output
private static int GetRawValue(CustomInputState state, MappingDescriptor desc)Returns unsigned 0-65535:
- Axis:
state.Axis[index] - Slider:
state.Sliders[index] - Button:
65535(pressed) or0(released) - POV:
PovDirectionToAxisValue(pov, direction)— Up/Left active = 0, Down/Right active = 65535, inactive = 32767
private static VJoyRawState MapInputToVJoyRaw(CustomInputState state, PadSetting ps,
VJoyVirtualController.VJoyDeviceConfig cfg)Uses dictionary-based vJoy mappings (ps.GetVJoyMapping("VJoyAxis0"), ps.GetVJoyMapping("VJoyBtn0"), etc.) instead of fixed gamepad field names. Supports arbitrary axis/button/POV counts.
Axis layout follows VJoySlotConfig.ComputeAxisLayout — interleaved groups of (X, Y, Trigger):
For sticks=2, triggers=2:
Axis 0: Stick0 X
Axis 1: Stick0 Y
Axis 2: Trigger0
Axis 3: Stick1 X
Axis 4: Stick1 Y
Axis 5: Trigger1
Dead zones are applied per-stick and per-trigger using the same ApplySingleDeadZone / ApplyTriggerDeadZone methods.
POV directions: individual direction buttons (VJoyPov0Up, VJoyPov0Down, etc.) -> DirectionToContinuousPov() (0-35900 hundredths of degrees, -1 = centered).
File: InputManager.Step4.CombineOutputStates.cs
Merges mapped Gamepad states from all devices assigned to each virtual controller slot into a single combined state.
private void CombineOutputStates()For each of the 16 slots:
- Find all UserSettings mapped to this slot via
FindByPadIndex(padIndex, _padIndexBuffer) - 0 devices: clear the slot
- 1 device: direct copy (no merge needed)
-
N devices: call
MergeGamepad()for each
For custom vJoy slots, also merges VJoyRawState via MergeVJoyRaw().
private static void MergeGamepad(ref Gamepad dest, ref Gamepad src)| Field | Merge Rule |
|---|---|
Buttons |
OR (dest.Buttons |= src.Buttons) — any device can activate any button |
LeftTrigger |
MAX (if (src > dest) dest = src) |
RightTrigger |
MAX |
ThumbLX |
Largest absolute magnitude wins |
ThumbLY |
Largest absolute magnitude wins |
ThumbRX |
Largest absolute magnitude wins |
ThumbRY |
Largest absolute magnitude wins |
private static void MergeVJoyRaw(ref VJoyRawState dest, ref VJoyRawState src)| Field | Merge Rule |
|---|---|
Axes[] |
Largest absolute magnitude wins (per axis) |
Buttons[] |
OR (per uint word) |
Povs[] |
First non-centered wins |
File: InputManager.Step4b.EvaluateMacros.cs
Evaluates macro trigger conditions and injects macro actions into the combined gamepad/vJoy state. Runs after Step 4 (CombineOutputStates) and before Step 5 (VirtualDevices).
private void EvaluateMacros()For each slot, delegates to:
-
EvaluateSlotMacros(ref Gamepad, MacroItem[])for standard slots -
EvaluateSlotMacrosCustomVJoy(ref VJoyRawState, MacroItem[])for custom vJoy slots
Three trigger source types:
-
Xbox bitmask trigger (
macro.TriggerButtons):(gp.Buttons & triggerButtons) == triggerButtons -
Raw device button trigger (
macro.UsesRawTrigger): checksFindOnlineDeviceByInstanceGuid(macro.TriggerDeviceGuid).InputState.Buttons[rawIndices[i]] -
Custom button word trigger (
macro.UsesCustomTrigger): comparesraw.Buttons[w] & tw[w]for vJoy uint words
public enum MacroTriggerMode
{
OnPress, // Fire once when trigger transitions from inactive to active
OnRelease, // Fire once when trigger transitions from active to inactive
WhileHeld // Fire continuously while trigger is active
}public enum MacroRepeatMode
{
Once, // Execute once
FixedCount, // Execute N times then stop
UntilRelease // Keep repeating until trigger is released (WhileHeld only)
}public enum MacroActionType
{
ButtonPress, // OR button flags into gamepad for DurationMs
ButtonRelease, // AND-NOT button flags (clear)
KeyPress, // SendInput VK down, hold for DurationMs, then up
KeyRelease, // SendInput VK up immediately
Delay, // Wait for DurationMs
AxisSet // Set axis to a specific value
}private static void ExecuteMacroActions(ref Gamepad gp, MacroItem macro)State machine per macro:
-
macro.CurrentActionIndex— current position in action sequence -
macro.ActionStartTime—DateTime.UtcNowat start of current action -
macro.RemainingRepeats— countdown for FixedCount mode -
macro.IsExecuting— overall execution flag
When an action's elapsed time exceeds its DurationMs, AdvanceAction(macro) increments the index and resets the timer. When the sequence completes, the repeat logic either restarts (after RepeatDelayMs) or stops.
When macro.ConsumeTriggerButtons is true, the trigger button flags are AND-NOT'd out of the combined Gamepad/VJoyRawState to prevent them from reaching the virtual controller. This allows a button to be "consumed" by the macro without passing through to the game.
private static void SendKeyInput(ushort virtualKeyCode, bool keyUp)Uses Win32 SendInput with INPUT_KEYBOARD. Maps VK to scan code via MapVirtualKey(MAPVK_VK_TO_VSC). Multi-key support: keys are pressed in forward order and released in reverse order.
private static void ApplyAxisAction(ref Gamepad gp, MacroAction action)public enum MacroAxisTarget
{
None,
LeftStickX, LeftStickY,
RightStickX, RightStickY,
LeftTrigger, RightTrigger
}Directly writes to the corresponding Gamepad or VJoyRawState field.
File: InputManager.Step5.VirtualDevices.cs
Submits combined gamepad states to virtual controllers (ViGEm Xbox 360, ViGEm DS4, vJoy). Manages virtual controller lifecycle: creation, destruction, type changes, activity tracking.
| Field | Type | Description |
|---|---|---|
_vigemClient |
static ViGEmClient |
Shared ViGEm client (one per process). |
_vigemClientLock |
static object |
Lock for lazy initialization. |
_vigemClientFailed |
static bool |
Permanent failure flag (no retry). |
_virtualControllers |
IVirtualController[MaxPads] |
Virtual controller instances per slot. |
SlotControllerTypes |
VirtualControllerType[MaxPads] |
Configured type per slot (written by UI). |
SlotVJoyConfigs |
VJoyDeviceConfig[MaxPads] |
Per-slot vJoy HID descriptor config. |
SlotVJoyIsCustom |
bool[MaxPads] |
Custom vs gamepad preset flag per slot. |
_activeVigemCount |
int |
Currently connected ViGEm controllers (Xbox + DS4). |
_activeXbox360Count |
int |
Currently connected ViGEm Xbox 360 controllers. |
_activeDs4Count |
int |
Currently connected ViGEm DS4 controllers. |
_expectedXbox360Count |
int |
Pre-initialized count for first-cycle ViGEm filtering. |
_expectedDs4Count |
int |
Pre-initialized count for first-cycle ViGEm filtering. |
_slotInactiveCounter |
int[MaxPads] |
Consecutive inactive cycles per slot. |
SlotDestroyGraceCycles |
const int |
10000 (~10 seconds at 1000Hz). |
_createCooldown |
int[MaxPads] |
Cooldown counter after failed creation. |
CreateCooldownCycles |
const int |
2000 (~2 seconds between retries). |
VirtualControllersEnabled |
bool |
Master enable/disable. |
private void UpdateVirtualDevices()Three-pass architecture:
Pass 1: Handle type changes, destruction, and activity tracking
For each slot:
- Detect type change (
vc.Type != SlotControllerTypes[padIndex]) — destroy old VC - Slot deleted/disabled — destroy immediately (no grace period)
- Slot active (
IsSlotActive) — reset inactive counter, flag for creation - No devices mapped — destroy immediately (vJoy presentation rule: only active when associated and connected)
- Device mapped but offline — increment inactive counter, destroy after
SlotDestroyGraceCycles
vJoy Presentation Rule (v2.0.0-RC2): vJoy controllers now follow the same lifecycle as ViGEm — they only appear in joy.cpl when a physical device is mapped to the slot AND that device is connected. This matches ViGEm behavior where virtual controllers only exist when actively serving input.
Pass 1b: Sync vJoy registry descriptor count
Counts total vJoy VCs needed (running + about to create). If count changed:
- Destroy VCs with device IDs exceeding new count
- Build per-device HID descriptor configs array
- Call
VJoyVirtualController.EnsureDevicesAvailable(count, configs) - Force existing VCs to re-acquire (
ReAcquireIfNeeded()) - Fix device ID ordering (sequential IDs must match sequential slot positions)
Pass 2: Create virtual controllers in ascending slot order
ViGEm assigns indices sequentially on Connect(), so creation order must match slot order:
for (int padIndex = 0; padIndex < MaxPads; padIndex++)
{
if (_virtualControllers[padIndex] == null && _slotInactiveCounter[padIndex] == 0)
{
if (_createCooldown[padIndex] > 0) { _createCooldown[padIndex]--; continue; }
_virtualControllers[padIndex] = CreateVirtualController(padIndex);
}
}Pass 3: Submit reports for active slots
if (vc is VJoyVirtualController vjoyVc && SlotVJoyIsCustom[padIndex])
vjoyVc.SubmitRawState(CombinedVJoyRawStates[padIndex]);
else
vc.SubmitGamepadState(CombinedOutputStates[padIndex]);private IVirtualController CreateVirtualController(int padIndex)- For Xbox 360: snapshot XInput slot mask before connecting
- Create concrete controller:
Xbox360VirtualController,DS4VirtualController, orVJoyVirtualController - Call
vc.Connect() - For Xbox 360: wait up to 50ms for XInput slot to appear
- Increment
_activeVigemCount/_activeXbox360Count/_activeDs4Count - Register feedback callback:
vc.RegisterFeedbackCallback(padIndex, VibrationStates)
private IVirtualController CreateVJoyController()Calls VJoyVirtualController.EnsureDllLoaded(), FindFreeDeviceId(), returns new VJoyVirtualController(deviceId).
private void DestroyVirtualController(int padIndex)- For Xbox 360: snapshot XInput slot mask
vc.Disconnect()- For Xbox 360: wait up to 50ms for slot to disappear
-
vc.Dispose()— releases native ViGEm target handle - Decrement active counters in
finallyblock (must run even if Disconnect throws)
private bool IsSlotActive(int padIndex)Returns true if:
SlotCreated[padIndex] && SlotEnabled[padIndex]- At least one online device is mapped to this slot
private bool HasAnyDeviceMapped(int padIndex)Returns true if any UserSetting (online or offline) has MapTo == padIndex. Distinguishes "user unassigned all devices" from "device temporarily offline".
[DllImport("xinput1_4.dll", EntryPoint = "#100")]
private static extern uint XInputGetStateEx(uint dwUserIndex, ref XInputStateInternal pState);
private static uint GetXInputConnectedSlotMask()Probes slots 0-3 via xinput1_4.dll undocumented ordinal #100 (XInputGetStateEx). Returns a 4-bit mask of connected XInput devices. Used only for detecting when a new ViGEm Xbox 360 controller appears.
public static void CleanupStaleVigemDevices()Enumerates XnaComposite device class via pnputil /enum-devices, identifies ViGEm devices by short numeric serial (1-2 digits), removes them via pnputil /remove-device. Called before Start() to clear orphaned nodes from previous sessions.
public void PreInitializeVigemCounts(int xbox360Count, int ds4Count)Must be called before Start() so the first UpdateDevices() cycle filters ViGEm devices correctly (before Step 5 has created any actual VCs).
File: InputManager.Step6.RetrieveOutputStates.cs
Copies combined gamepad states for UI display. This is the simplest step.
private void RetrieveOutputStates()For each slot:
- If virtual controller is connected:
RetrievedOutputStates[padIndex] = CombinedOutputStates[padIndex](struct copy) - Otherwise:
RetrievedOutputStates[padIndex].Clear()
This approach replaced the original XInput P/Invoke readback. Direct copy is both more universal (works for DS4, vJoy, not just Xbox 360) and more accurate (shows exactly what was submitted to the virtual controller).
Physical Device (SDL3)
|
v
CustomInputState (unsigned axes 0-65535, bool[] buttons, centidegree POVs)
|
v [Step 3: MapInputToGamepad / MapInputToVJoyRaw]
Gamepad struct (signed axes, XInput button bitmask, byte triggers)
-- or --
VJoyRawState (signed short[] axes, uint[] button words, int[] POVs)
|
v [Step 4: CombineOutputStates]
CombinedOutputStates[slot] / CombinedVJoyRawStates[slot]
|
v [Step 4b: EvaluateMacros]
(Modified in-place by macro actions)
|
v [Step 5: UpdateVirtualDevices]
IVirtualController.SubmitGamepadState() / VJoyVirtualController.SubmitRawState()
| |
v v
ViGEm Xbox 360 / DS4 vJoy (HID IOCTL)
|
v [Step 6: RetrieveOutputStates]
RetrievedOutputStates[slot] -> UI Display
public struct Gamepad
{
public ushort Buttons;
public byte LeftTrigger; // 0-255
public byte RightTrigger; // 0-255
public short ThumbLX; // -32768 to 32767
public short ThumbLY;
public short ThumbRX;
public short ThumbRY;
// Button flag constants
public const ushort DPAD_UP = 0x0001;
public const ushort DPAD_DOWN = 0x0002;
public const ushort DPAD_LEFT = 0x0004;
public const ushort DPAD_RIGHT = 0x0008;
public const ushort START = 0x0010;
public const ushort BACK = 0x0020;
public const ushort LEFT_THUMB = 0x0040;
public const ushort RIGHT_THUMB = 0x0080;
public const ushort LEFT_SHOULDER = 0x0100;
public const ushort RIGHT_SHOULDER = 0x0200;
public const ushort GUIDE = 0x0400;
public const ushort A = 0x1000;
public const ushort B = 0x2000;
public const ushort X = 0x4000;
public const ushort Y = 0x8000;
public bool IsButtonPressed(ushort flag);
public void SetButton(ushort flag, bool pressed);
public void Clear();
}public struct VJoyRawState
{
public short[] Axes; // Signed short range, up to 8 axes (clamped by Create())
public uint[] Buttons; // 4 x 32-bit words = 128 buttons max
public int[] Povs; // Up to 4, -1=centered, 0-35900=direction
public static VJoyRawState Create(int nAxes, int nButtons, int nPovs);
public void SetButton(int index, bool pressed);
public bool IsButtonPressed(int index);
public void Clear();
}public class CustomInputState
{
public const int MaxAxis = 24;
public const int MaxSliders = 8;
public const int MaxPovs = 4;
public const int MaxButtons = 256;
public int[] Axis; // Unsigned 0-65535
public int[] Sliders; // Unsigned 0-65535
public int[] Povs; // Centidegrees, -1=centered
public bool[] Buttons; // true=pressed
public float[] Gyro; // [X,Y,Z] rad/s
public float[] Accel; // [X,Y,Z] m/s^2
public CustomInputState();
public CustomInputState(int[] axes, int[] sliders, int[] povs, bool[] buttons);
public CustomInputState Clone();
public static void GetAxisMask(DeviceObjectItem[] items, int numAxes,
out int axisMask, out int actuatorMask, out int actuatorCount);
public static int GetSlidersMask(DeviceObjectItem[] items, int numSliders);
}public interface IVirtualController : IDisposable
{
VirtualControllerType Type { get; }
bool IsConnected { get; }
void Connect();
void Disconnect();
void SubmitGamepadState(Gamepad gp);
void RegisterFeedbackCallback(int padIndex, Vibration[] vibrationStates);
}public enum VirtualControllerType
{
Xbox360 = 0,
DualShock4 = 1,
VJoy = 2
}