Skip to content

Input Pipeline

hifihedgehog edited this page Mar 7, 2026 · 58 revisions

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


InputManager.cs — Main Class

Namespace: PadForge.Common.Input

Class Declaration

public partial class InputManager : IDisposable

Constants and Properties

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.

State Fields

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.

Public State Arrays

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.

Events

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.

Constructor

public InputManager()

Initializes VibrationStates[] with new Vibration() for each slot.

SDL Initialization

private bool InitializeSdl()

Sets SDL hints, then calls SDL_Init with flags:

SDL_INIT_JOYSTICK | SDL_INIT_GAMEPAD | SDL_INIT_VIDEO | SDL_INIT_HAPTIC

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

Start / Stop

public void Start()
  1. Guards against double-start or disposed state
  2. Calls InitializeSdl()
  3. Calls RawInputListener.Start()
  4. Creates and starts the polling thread
public void Stop(bool preserveVJoyNodes = false)
  1. Sets _running = false
  2. Joins polling thread with 3-second timeout
  3. Stops RawInputListener
  4. Calls StopAllForceFeedback(), DestroyAllVirtualControllers(), CloseAllDevices()

Main Polling Loop

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.

Slot Swap

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.

Motion Snapshots

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 // Inverted

Unit conversions: RadToDeg = 180/PI for gyro, MsToG = 1/9.80665 for accel.

private void BroadcastDsuMotion()

Iterates all slots and calls DsuServer.BroadcastMotion() for each.

Win32 P/Invoke

[DllImport("winmm.dll")]
private static extern uint timeBeginPeriod(uint uPeriod);

[DllImport("winmm.dll")]
private static extern uint timeEndPeriod(uint uPeriod);

Step 1: UpdateDevices

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.

Tracking Fields

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.

UpdateDevices Method

private void UpdateDevices()

Phase 1: Open newly connected joystick devices

uint[] joystickIds = SDL_GetJoysticks();

For each SDL instance ID:

  1. Skip if in _filteredVigemInstanceIds (known ViGEm device)
  2. Skip if in _openedSdlInstanceIds (already open)
  3. new SdlDeviceWrapper().Open(instanceId)
  4. Call IsViGEmVirtualDevice() — if true, add to filter set and dispose
  5. FindOrCreateUserDevice(wrapper.InstanceGuid, wrapper.ProductGuid) — finds existing or creates new
  6. ud.LoadFromSdlDevice(wrapper) and mark ud.IsOnline = true
  7. 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).

ViGEm Virtual Device Detection

private bool IsViGEmVirtualDevice(SdlDeviceWrapper wrapper)

Detection heuristics (checked in order):

  1. Device path contains "vigem" or "virtual" (case-insensitive)
  2. VID=0, PID=0 + recognized as game controller + active ViGEm count > 0 — ViGEm devices may report zero VID/PID
  3. VID=0x1234, PID=0xBEAD — vJoy virtual joystick output device
  4. VID=0x045E, PID=0x028E (Xbox 360) — filtered when _activeXbox360Count > 0 or _expectedXbox360Count > 0
  5. VID=0x054C, PID=0x05C4 (DS4 v1) — filtered when _activeDs4Count > 0 or _expectedDs4Count > 0

UserDevice Lookup Helpers

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:

  1. Exact match by InstanceGuid
  2. Fallback match: offline device with same ProductGuid (handles BT reconnect with new device path)
  3. Create new UserDevice

When a fallback match is found, both the UserDevice and its linked UserSetting are migrated to the new InstanceGuid.

Supporting Collection Classes

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.

SettingsManager Partial

Declared in this file:

public static partial class SettingsManager
{
    public static DeviceCollection UserDevices { get; set; }
    public static SettingsCollection UserSettings { get; set; }
}

Step 2: UpdateInputStates

File: InputManager.Step2.UpdateInputStates.cs

Reads current input state from all online devices and applies force feedback (rumble).

UpdateInputStates Method

private void UpdateInputStates()
  1. Snapshot online devices into _deviceSnapshotBuffer under SyncRoot lock
  2. For each online device: a. Save ud.OldInputState = ud.InputState (for change detection) b. Call ud.Device.GetCurrentState() — returns CustomInputState or null c. Atomic reference swap: ud.InputState = newState d. Compute buffered updates: CustomInputHelper.GetUpdates(oldState, newState) e. Call ApplyForceFeedback(ud)

Force Feedback

private void ApplyForceFeedback(UserDevice ud)
  1. Skips devices without rumble or haptic support
  2. Finds ALL pad slots this device is mapped to (multi-slot assignment) using FindByInstanceGuid
  3. Combines vibration across all mapped slots: MAX of each motor
  4. Respects TestRumbleTargetGuid — if set, only applies to the targeted device
  5. Calls ud.ForceFeedbackState.SetDeviceForces(ud, ud.Device, padSetting, combinedVibration)
private Vibration _combinedVibration;  // Scratch buffer to avoid allocation

Consumed Input State Merging

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

Parse Helpers

private static int TryParseInt(string value, int defaultValue)
private static bool TryParseBool(string value)

Step 3: UpdateOutputStates

File: InputManager.Step3.UpdateOutputStates.cs

Maps each device's CustomInputState to a Gamepad struct (and optionally a VJoyRawState) based on PadSetting mapping descriptors.

UpdateOutputStates Method

private void UpdateOutputStates()
  1. Snapshot all UserSettings into _settingSnapshotBuffer
  2. For each UserSetting: a. Find online device by us.InstanceGuid b. Get PadSetting via us.GetPadSetting() c. Apply center offset correction to stick axes (before dead zone): ApplyCenterOffset(value, offsetPercent) shifts the axis by the percentage offset stored in PadSetting.LeftThumbCenterOffsetX/Y etc. 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)

Mapping Descriptor Format

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)

MappingDescriptor Struct

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

Parsing

private static MappingDescriptor ParseDescriptor(string descriptor)

Parses "IHAxis 2" into {Type=Axis, Index=2, Inverted=true, HalfAxis=true, IsValid=true}.

Button Mapping

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 (or value < 16384 if inverted)
  • Slider source: same threshold logic as axis
  • POV source: IsPovDirectionActive(state.Povs[index], direction)

Multiple descriptors separated by | are OR'd.

POV Direction Matching

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)

D-Pad from POV

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.

Trigger Mapping

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 - rawValue before conversion

Trigger Dead Zone

private static byte ApplyTriggerDeadZone(byte value, int deadZone, int antiDeadZone, int maxRange)
  1. Normalize to 0.0-1.0
  2. Dead zone: values below deadZone% threshold are zeroed
  3. Max range: caps input at maxRange% ceiling
  4. Remap from [dzNorm, maxNorm] to [0, 1]
  5. Anti-dead zone: offsets output minimum by antiDeadZone%

Thumbstick Axis Mapping

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;

Stick Dead Zones

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:

  1. Normalize to float (-1.0 to 1.0)
  2. Dead zone: |magnitude| < dzNorm -> return 0
  3. Remap: (magnitude - dzNorm) / (1.0 - dzNorm)
  4. Anti-dead zone: adzNorm + remapped * (1.0 - adzNorm)
  5. Linear adjustment: blends between remapped and anti-dead-zone output

Raw Value Extraction

private static int GetRawValue(CustomInputState state, MappingDescriptor desc)

Returns unsigned 0-65535:

  • Axis: state.Axis[index]
  • Slider: state.Sliders[index]
  • Button: 65535 (pressed) or 0 (released)
  • POV: PovDirectionToAxisValue(pov, direction) — Up/Left active = 0, Down/Right active = 65535, inactive = 32767

vJoy Custom Mapping

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


Step 4: CombineOutputStates

File: InputManager.Step4.CombineOutputStates.cs

Merges mapped Gamepad states from all devices assigned to each virtual controller slot into a single combined state.

CombineOutputStates Method

private void CombineOutputStates()

For each of the 16 slots:

  1. Find all UserSettings mapped to this slot via FindByPadIndex(padIndex, _padIndexBuffer)
  2. 0 devices: clear the slot
  3. 1 device: direct copy (no merge needed)
  4. N devices: call MergeGamepad() for each

For custom vJoy slots, also merges VJoyRawState via MergeVJoyRaw().

Merge Rules

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

Step 4b: EvaluateMacros

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

EvaluateMacros Method

private void EvaluateMacros()

For each slot, delegates to:

  • EvaluateSlotMacros(ref Gamepad, MacroItem[]) for standard slots
  • EvaluateSlotMacrosCustomVJoy(ref VJoyRawState, MacroItem[]) for custom vJoy slots

Trigger Detection

Three trigger source types:

  1. Xbox bitmask trigger (macro.TriggerButtons): (gp.Buttons & triggerButtons) == triggerButtons
  2. Raw device button trigger (macro.UsesRawTrigger): checks FindOnlineDeviceByInstanceGuid(macro.TriggerDeviceGuid).InputState.Buttons[rawIndices[i]]
  3. Custom button word trigger (macro.UsesCustomTrigger): compares raw.Buttons[w] & tw[w] for vJoy uint words

Trigger Modes

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
}

Repeat Modes

public enum MacroRepeatMode
{
    Once,         // Execute once
    FixedCount,   // Execute N times then stop
    UntilRelease  // Keep repeating until trigger is released (WhileHeld only)
}

Action Types

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
    SystemVolume   // Map axis value to Windows system volume (0-100%)
}

Action Execution

private static void ExecuteMacroActions(ref Gamepad gp, MacroItem macro)

State machine per macro:

  • macro.CurrentActionIndex — current position in action sequence
  • macro.ActionStartTimeDateTime.UtcNow at 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.

ConsumeTriggerButtons

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.

SendInput for Keyboard Actions

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.

Axis Actions

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.


Step 5: VirtualDevices

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.

Fields

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.

UpdateVirtualDevices Method

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:

  1. Destroy VCs with device IDs exceeding new count
  2. Build per-device HID descriptor configs array
  3. Call VJoyVirtualController.EnsureDevicesAvailable(count, configs)
  4. Force existing VCs to re-acquire (ReAcquireIfNeeded())
  5. 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]);

Virtual Controller Creation

private IVirtualController CreateVirtualController(int padIndex)
  1. For Xbox 360: snapshot XInput slot mask before connecting
  2. Create concrete controller: Xbox360VirtualController, DS4VirtualController, or VJoyVirtualController
  3. Call vc.Connect()
  4. For Xbox 360: wait up to 50ms for XInput slot to appear
  5. Increment _activeVigemCount / _activeXbox360Count / _activeDs4Count
  6. Register feedback callback: vc.RegisterFeedbackCallback(padIndex, VibrationStates)
private IVirtualController CreateVJoyController()

Calls VJoyVirtualController.EnsureDllLoaded(), FindFreeDeviceId(), returns new VJoyVirtualController(deviceId).

Virtual Controller Destruction

private void DestroyVirtualController(int padIndex)
  1. For Xbox 360: snapshot XInput slot mask
  2. vc.Disconnect()
  3. For Xbox 360: wait up to 50ms for slot to disappear
  4. vc.Dispose() — releases native ViGEm target handle
  5. Decrement active counters in finally block (must run even if Disconnect throws)

Slot Activity Check

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

XInput Slot Mask

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

Stale ViGEm Cleanup

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.

Pre-Initialize ViGEm Counts

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


Step 6: RetrieveOutputStates

File: InputManager.Step6.RetrieveOutputStates.cs

Copies combined gamepad states for UI display. This is the simplest step.

RetrieveOutputStates Method

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


Data Flow Summary

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

Key Types Reference

Gamepad Struct

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

VJoyRawState Struct

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

CustomInputState Class

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

IVirtualController Interface

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

VirtualControllerType Enum

public enum VirtualControllerType
{
    Xbox360 = 0,
    DualShock4 = 1,
    VJoy = 2
}

Clone this wiki locally