Skip to content

Input Pipeline

hifihedgehog edited this page Mar 19, 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.

graph TD
    subgraph "Engine Thread (~1000Hz)"
        SDL[SDL_UpdateJoysticks]
        S1[Step 1: UpdateDevices<br/>SDL enumerate + Raw Input<br/>ViGEm/vJoy filter]
        S2[Step 2: UpdateInputStates<br/>SDL read axes/buttons/POV<br/>Force feedback + audio bass]
        MS[UpdateMotionSnapshots<br/>Gyro/Accel to DSU coords]
        DSU[BroadcastDsuMotion<br/>UDP port 26760]
        S3[Step 3: UpdateOutputStates<br/>MapInputToGamepad<br/>Dead zones + curves]
        S4[Step 4: CombineOutputStates<br/>Multi-device merge<br/>OR/MAX/magnitude rules]
        S4b[Step 4b: EvaluateMacros<br/>Trigger state machine<br/>Button/axis/volume/mouse]
        S5[Step 5: UpdateVirtualDevices<br/>ViGEm lifecycle<br/>vJoy descriptor sync<br/>Report submission]
        S6[Step 6: RetrieveOutputStates<br/>Copy for UI display]
        WAIT[Drift-compensated<br/>hybrid sleep/spin-wait]

        SDL --> S1
        S1 -->|every 2s or first cycle| S2
        SDL -->|skip if not due| S2
        S2 --> MS
        MS --> DSU
        DSU --> S3
        S3 --> S4
        S4 --> S4b
        S4b --> S5
        S5 --> S6
        S6 --> WAIT
        WAIT -->|next cycle| SDL
    end

    subgraph "UI Thread (30Hz)"
        UI_READ[Read RetrievedOutputStates]
        UI_WRITE[Write MacroSnapshots<br/>SlotControllerTypes<br/>SlotVJoyConfigs]
    end

    subgraph "ViGEm Callback Thread"
        VIB[FeedbackReceived<br/>writes VibrationStates]
    end

    S6 -.->|struct copy| UI_READ
    UI_WRITE -.->|atomic ref/value| S4b
    UI_WRITE -.->|atomic ref/value| S5
    VIB -.->|motor values| S2

    style S1 fill:#e1f5fe
    style S2 fill:#e1f5fe
    style S3 fill:#f3e5f5
    style S4 fill:#f3e5f5
    style S4b fill:#fff3e0
    style S5 fill:#e8f5e9
    style S6 fill:#e8f5e9
Loading

The pipeline is implemented as a partial class InputManager split across eight 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 via InputService.OnSettingsPropertyChanged.
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. Set to false by Stop() to terminate the loop.
_idle volatile bool When true, skips pipeline steps 3-6 and sleeps at ~20 Hz. Input reading (Step 2) still runs for Devices page preview.
_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/closure allocations in hot path). Grows dynamically if device count exceeds buffer size.
_settingSnapshotBuffer UserSetting[] Pre-allocated buffer for Step 3 settings snapshot.
_padIndexBuffer UserSetting[MaxPads] Pre-allocated buffer for FindByPadIndex lookups (Steps 2-5).
_instanceGuidBuffer UserSetting[MaxPads] Pre-allocated buffer for FindByInstanceGuid lookups (Step 2 FFB).

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.
CombinedMidiRawStates MidiRawState[MaxPads] Step 4 (engine) Step 5 Combined MIDI raw state for MIDI slots.
CombinedKbmRawStates KbmRawState[MaxPads] Step 4 (engine) Step 5 Combined KBM raw state for KeyboardMouse slots.
RetrievedOutputStates Gamepad[MaxPads] Step 6 (engine) UI timer Copy of combined states for UI display.
RetrievedKbmRawStates KbmRawState[MaxPads] Step 6 (engine) UI timer Copy of KBM raw states for UI preview.
VibrationStates Vibration[MaxPads] ViGEm callback thread Step 2 (engine) Per-slot rumble from games. Cross-thread: written by ViGEm's FeedbackReceived callback (thread pool), read by engine thread.
MotionSnapshots MotionSnapshot[MaxPads] Engine (polling loop) DSU broadcast Per-slot motion sensor data for Cemuhook.
MacroSnapshots MacroItem[][MaxPads] UI timer (30Hz) Step 4b (engine) Per-slot macro definitions. Cross-thread: UI writes reference atomically, engine reads reference atomically each cycle.
TestRumbleTargetGuid Guid[MaxPads] UI Step 2 When non-empty, restricts test rumble to a specific device GUID in the slot.
CurrentFrequency double Engine UI Measured polling frequency in Hz. Updated ~once/second.
IsRunning bool Engine UI Whether the polling loop is active. Reads _running.
IsIdle bool UI (InputService) Engine When true, polling loop runs at ~20 Hz and skips Steps 3-6. Set by InputService when no virtual controller slots are created.
DsuServer DsuMotionServer InputService Engine DSU motion server reference. When set, the polling thread broadcasts motion data after Step 2.
AudioBassDetector AudioBassDetector InputService Engine Audio bass detector. When set, bass energy is combined with game rumble via max() in ApplyForceFeedback.

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. Handlers receive message + exception.

Constructor

public InputManager()

Initializes VibrationStates[] with new Vibration() for each of the 16 slots.

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 window focus
  • SDL_HINT_JOYSTICK_XINPUT = "1" -- enable Xbox controller enumeration via XInput backend
  • SDL_HINT_JOYSTICK_HIDAPI_SWITCH2 = "1" -- enable Switch 2 Pro Controller HIDAPI driver
  • SDL_HINT_VIDEO_ALLOW_SCREENSAVER = "1" -- do not block screensaver
  • NEVER set SDL_HINT_JOYSTICK_RAWINPUT -- conflicts with XInput enumeration, prevents Xbox controllers from appearing (discovered via Cemu comparison)

Post-init steps:

  1. Loads PadForge community controller mappings from gamecontrollerdb_padforge.txt via SDL_AddGamepadMappingsFromFile
  2. Calls SDL_EnableScreenSaver() -- SDL_INIT_VIDEO disables the screensaver by default
  3. Calls SetThreadExecutionState(ES_CONTINUOUS) -- clears any execution-state flags so the PC can sleep

Error handling: Catches DllNotFoundException (SDL3.dll missing) and generic exceptions. Raises ErrorOccurred but does not throw -- Start() checks the return value and aborts if false.

private void ShutdownSdl()

Calls SDL_Quit(). Called by Dispose().

Start / Stop

public void Start()
  1. Guards against double-start (_running) or disposed state
  2. Calls InitializeSdl() -- aborts if it fails
  3. Calls RawInputListener.Start() -- starts the hidden message-only window for keyboard/mouse enumeration
  4. Creates and starts the polling thread (Name=PadForge.InputManager, Priority=AboveNormal, IsBackground=true)

Thread safety: Safe to call from any thread. Subsequent calls are no-ops if already running.

public void Stop(bool preserveVJoyNodes = false)
  1. Sets _running = false
  2. Joins polling thread with 3-second timeout
  3. Stops RawInputListener
  4. Calls StopAllForceFeedback() -- iterates all devices and calls ForceFeedbackState.StopDeviceForces() (best-effort, catches exceptions)
  5. Calls DestroyAllVirtualControllers(preserveVJoyNodes) -- disconnects and disposes all VCs
  6. Calls CloseAllDevices() -- disposes all SDL device handles and clears runtime state

When preserveVJoyNodes is true, the vJoy device node is disabled (not removed) so it can be re-enabled on next Start without a full node recreation cycle. When false (app shutdown), all device nodes are removed to prevent orphaned entries in joy.cpl.

Main Polling Loop

private void PollingLoop()

Entry point for the background thread. Sets timeBeginPeriod(1) for the duration of the loop (restored in finally block via timeEndPeriod(1)).

Per-cycle execution order:

SDL_UpdateJoysticks()         -- pump SDL event queue
  |
  v (every 2 seconds, or first cycle)
Step 1: UpdateDevices()       -- enumerate, open/close devices
  |
  v
Step 2: UpdateInputStates()   -- read axes/buttons/POV from SDL, apply FFB
  |
  v
UpdateMotionSnapshots()       -- capture gyro/accel for DSU
BroadcastDsuMotion()          -- send to Cemuhook clients via UDP
  |
  v
Step 3: UpdateOutputStates()  -- map CustomInputState to Gamepad via PadSetting rules
  |
  v
Step 4: CombineOutputStates() -- merge multiple devices per slot
  |
  v
Step 4b: EvaluateMacros()     -- trigger/action state machine, inject into Gamepad
  |
  v
Step 5: UpdateVirtualDevices()-- create/destroy VCs, submit reports
  |
  v
Step 6: RetrieveOutputStates()-- copy combined output for UI consumption
  |
  v
Frequency measurement (~1/second)
  |
  v
Drift-compensated hybrid sleep/spin-wait

3-Tier Polling Sleep Strategy:

The polling loop uses a tiered sleep strategy, falling through to the next tier if the preferred timer is unavailable:

Tier Mechanism Availability CPU Cost
Tier 1 High-Resolution Waitable Timer Windows 10 1803+ Near-zero (kernel sleep)
Tier 2 Multimedia Timer + ManualResetEvent All Windows Near-zero (event wait)
Tier 3 Thread.Sleep(1) + SpinWait All Windows ~1-3% of one core

Tier 1: High-Resolution Waitable Timer -- CreateWaitableTimerExW with CREATE_WAITABLE_TIMER_HIGH_RESOLUTION (0x00000002). Sleeps at sub-ms granularity via the kernel scheduler without busy-waiting. The timer is set as a negative relative due time (100-nanosecond intervals) via SetWaitableTimerEx, then the thread blocks with WaitForSingleObject. Leaves a 0.1ms (spinThresholdTicks) gap before the target to spin-finish.

Tier 2: Multimedia Timer Fallback -- timeSetEvent creates a periodic callback at the polling interval that signals a ManualResetEvent. The polling thread blocks with WaitOne(50) until the callback fires, then resets the event. This is the x360ce-style approach. Precision is ~1-2ms with timeBeginPeriod(1). The callback delegate is prevented from GC via GC.KeepAlive(mmTimerCb) in the finally block.

Tier 3: Thread.Sleep(1) + SpinWait -- Legacy fallback when both timers fail. Thread.Sleep(1) absorbs bulk wait when >1.5ms remains (sleepThresholdTicks).

All three tiers finish with a spin-wait loop for the final sub-ms portion:

while (cycleTimer.ElapsedTicks < adjustedTarget)
    Thread.SpinWait(1);

Wall-clock drift compensation:

Instead of per-cycle overshoot tracking, the loop compares cumulative expected time against a wall clock Stopwatch:

expectedTicks += targetTicks;
long drift = wallClock.ElapsedTicks - expectedTicks;
long adjustedTarget = targetTicks - drift;

If the loop falls behind (positive drift), future cycles are shortened; if ahead (negative drift), they are lengthened. This converges the long-term average rate exactly to the target Hz.

Safety mechanisms:

  • If drift exceeds 10x the target interval (e.g., after system sleep/resume), the wall clock resets entirely instead of sprinting to catch up
  • adjustedTarget has a safety floor of targetTicks / 4 to prevent negative or near-zero wait times

Idle mode:

When no virtual controller slots are created (IsIdle == true), the polling loop enters a low-power mode:

  • Calls SDL_UpdateJoysticks() to pump the event queue
  • Runs UpdateDevices() at reduced rate (every 5 seconds instead of 2) so newly connected controllers still appear on the Devices page
  • Runs UpdateInputStates() so the Devices page raw input preview works for unassigned devices
  • Skips Steps 3-6 entirely
  • Sleeps at ~20 Hz (Thread.Sleep(50))
  • Reports CurrentFrequency = 0
  • On transition back to active: sets firstCycle = true for immediate enumeration, resets wall-clock drift state to prevent burst cycles

Sleep guard: Every 5 seconds, calls SetThreadExecutionState(ES_CONTINUOUS) to clear any execution-state flags that SDL may re-assert during SDL_JoystickUpdate() / event processing, ensuring the PC can still enter sleep mode.

Slot Swap

public void SwapSlots(int slotA, int slotB)

Same-type swaps keep virtual controllers alive -- only input routing changes via MapTo swap in SettingsManager. Since Step 3 maps via MapTo, Step 4 combines by MapTo index, and Step 5 feeds CombinedOutputStates[i] to _virtualControllers[i], all per-slot arrays are recomputed each frame from MapTo. No array swapping needed, zero game disruption.

Cross-type swaps destroy both VCs (via DestroyVirtualController) so Step 5 recreates them with the correct types in ascending slot order. Also swaps SlotControllerTypes, SlotVJoyConfigs, SlotVJoyIsCustom, TestRumbleTargetGuid, MacroSnapshots.

public void SwapSlotData(int slotA, int slotB)

Swaps all data arrays AND virtual controllers between two slots under VJoySyncLock. Used by EnsureTypeGroupOrder bubble sort on the UI thread. Unlike SwapSlots, this method also swaps the VC instances themselves (plus _slotInactiveCounter, _slotInitializing, _createCooldown, VibrationStates, all Combined*States arrays, and _midiConfigs) so Step 5 does not see a type mismatch and avoids needless destroy/recreate cycles that cause phantom Xbox controllers.

After swapping, updates FeedbackPadIndex on each VC and calls VJoyVirtualController.UpdateFfbPadIndex to fix FFB device map entries.

Thread safety: Holds VJoySyncLock for the entire swap to prevent the polling thread's vJoy descriptor sync from observing a half-swapped state.

Motion Snapshots

private void UpdateMotionSnapshots()

Called on the polling thread after Step 2. For each of the 16 pad slots:

  1. Finds all UserSettings mapped to this slot via FindByPadIndex
  2. For each, looks up the online device and checks HasGyro/HasAccel
  3. Uses the first device with sensors (breaks after finding one)
  4. Converts SDL coordinates (m/s^2, rad/s) to DSU/DS4 convention:
// SDL --> DSU axis mapping with sign corrections:
AccelX  = -ax * MsToG    // MsToG = 1/9.80665
AccelY  = -ay * MsToG
AccelZ  = -az * MsToG
GyroPitch = -gx * RadToDeg  // RadToDeg = 180/PI
GyroYaw   = gy * RadToDeg   // Same sign (only one not inverted)
GyroRoll  = -gz * RadToDeg
  1. If no sensor device found, writes HasMotion = false snapshot

Timestamp: Stopwatch.GetTimestamp() * 1_000_000 / Stopwatch.Frequency (microseconds).

private void BroadcastDsuMotion()

Iterates all 16 slots and calls DsuServer.BroadcastMotion(padIndex, snapshot, isConnected) for each. The DSU server is set by InputService and may be null (no-op).

IDisposable

public void Dispose()

Calls Stop() then ShutdownSdl(). Has a finalizer that calls Dispose() as a safety net, with GC.SuppressFinalize in the normal path.

Win32 P/Invoke

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

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

// Multimedia timer (Tier 2 fallback)
private delegate void TimerCallback(uint uTimerID, uint uMsg,
    IntPtr dwUser, IntPtr dw1, IntPtr dw2);

[DllImport("winmm.dll")]
private static extern uint timeSetEvent(uint uDelay, uint uResolution,
    TimerCallback lpTimeProc, IntPtr dwUser, uint fuEvent);

[DllImport("winmm.dll")]
private static extern uint timeKillEvent(uint uTimerID);

// High-resolution waitable timer (Tier 1)
[DllImport("kernel32.dll")]
private static extern IntPtr CreateWaitableTimerExW(
    IntPtr lpTimerAttributes, IntPtr lpTimerName, uint dwFlags, uint dwDesiredAccess);

[DllImport("kernel32.dll")]
private static extern bool SetWaitableTimerEx(
    IntPtr hTimer, ref long lpDueTime, int lPeriod,
    IntPtr pfnCompletionRoutine, IntPtr lpArgToCompletionRoutine,
    IntPtr WakeContext, uint TolerableDelay);

[DllImport("kernel32.dll")]
private static extern uint WaitForSingleObject(IntPtr hHandle, uint dwMilliseconds);

[DllImport("kernel32.dll")]
private static extern bool CloseHandle(IntPtr hObject);

// Power management
[DllImport("kernel32.dll")]
private static extern uint SetThreadExecutionState(uint esFlags);

Step 1: UpdateDevices

File: InputManager.Step1.UpdateDevices.cs

Enumerates all connected devices at 2-second intervals (5-second in idle mode). Opens new devices, marks disconnected devices offline, and fires DevicesUpdated if the device list changed. Handles three device categories: SDL joysticks/gamepads, Raw Input keyboards, and Raw Input mice.

Method Signature

private void UpdateDevices()

Called by: PollingLoop() (every 2 seconds or on first cycle)

Thread safety: Runs exclusively on the engine thread. Device collection modifications use SettingsManager.UserDevices.SyncRoot locking. The DevicesUpdated event is raised on the engine thread -- UI consumers must marshal to the WPF dispatcher.

Error handling: Each device open is wrapped in try/catch. A single device failure does not abort the entire enumeration cycle -- the error is reported via RaiseError and processing continues with the next device.

Tracking Fields

Field Type Description
_openedSdlInstanceIds HashSet<uint> SDL instance IDs of currently opened joysticks. Used to skip already-open devices during enumeration.
_filteredVigemInstanceIds HashSet<uint> SDL instance IDs identified as ViGEm/vJoy virtual controllers. Never re-opened -- prevents the open/close cycle that kills rumble via SDL's internal XInputSetState(0,0) on close. Cleaned via IntersectWith(currentInstanceIds) each cycle.
_openedKeyboardHandles HashSet<IntPtr> Raw Input device handles for tracked keyboards.
_openedMouseHandles HashSet<IntPtr> Raw Input device handles for tracked mice.

Algorithm

Phase 1: Open newly connected joystick devices

uint[] joystickIds = SDL_GetJoysticks();  // SDL3 API: returns array of instance IDs
var currentInstanceIds = new HashSet<uint>(joystickIds);

For each SDL instance ID:

  1. Skip if in _filteredVigemInstanceIds (known ViGEm/vJoy device)
  2. Skip if in _openedSdlInstanceIds (already open)
  3. Create new SdlDeviceWrapper() and call wrapper.Open(instanceId) -- opens as gamepad if recognized, joystick otherwise
  4. Call IsViGEmVirtualDevice(wrapper) -- if true, add to _filteredVigemInstanceIds, dispose wrapper, skip
  5. FindOrCreateUserDevice(wrapper.InstanceGuid, wrapper.ProductGuid) -- finds existing or creates new UserDevice
  6. ud.LoadFromSdlDevice(wrapper) -- populate capabilities, name, VID/PID
  7. Mark ud.IsOnline = true
  8. Track in _openedSdlInstanceIds

Phase 1b: Enumerate keyboards via EnumerateKeyboards()

Uses RawInputListener.EnumerateKeyboards() to get device info array. For each new handle not in _openedKeyboardHandles:

  1. Create SdlKeyboardWrapper, call Open(kb)
  2. FindOrCreateUserDevice(wrapper.InstanceGuid)
  3. ud.LoadFromKeyboardDevice(wrapper), mark online
  4. Prunes orphaned handles (device removed via UI while still connected)

Phase 1c: Enumerate mice via EnumerateMice()

Same pattern as keyboards using SdlMouseWrapper.

Phase 2: Detect disconnected joystick devices

Iterates _openedSdlInstanceIds. For each SDL ID, finds the UserDevice via FindOnlineDeviceBySdlInstanceId. If the device is null (not found) or !ud.Device.IsAttached, calls MarkDeviceOffline(ud) and removes from tracking set.

Phase 2b-2c: Detect disconnected keyboards/mice

changed |= DetectDisconnectedHandles(_openedKeyboardHandles, RawInputListener.EnumerateKeyboards());
changed |= DetectDisconnectedHandles(_openedMouseHandles, RawInputListener.EnumerateMice());

Compares tracked handles against current Raw Input device set. Marks missing devices offline.

ViGEm filter cleanup:

_filteredVigemInstanceIds.IntersectWith(currentInstanceIds);

Removes entries for ViGEm devices that no longer exist (virtual controller destroyed), so the filter set does not grow unboundedly.

ViGEm Virtual Device Detection

private bool IsViGEmVirtualDevice(SdlDeviceWrapper wrapper)

Detection heuristics (checked in order, first match returns true):

# Criterion Rationale
1 Device path contains "vigem" or "virtual" (case-insensitive) ViGEm bus device paths always contain these strings
2 VID=0, PID=0 + IsGameController + active/expected ViGEm count > 0 ViGEm devices may report zero VID/PID via SDL's pre-open enumeration
3 VID=0x1234, PID=0xBEAD vJoy virtual joystick -- PadForge's own output device
4 VID=0x045E, PID=0x028E (Xbox 360) + _activeXbox360Count > 0 OR _expectedXbox360Count > 0 ViGEm Xbox 360 emulates this exact VID/PID. Modern real Xbox controllers use different PIDs (0B12, 0B13, 0B20). Filters ALL matching devices when any active VCs exist to prevent feedback loops.
5 VID=0x054C, PID=0x05C4 (DS4 v1) + _activeDs4Count > 0 OR _expectedDs4Count > 0 Same logic for ViGEm DS4 emulation

UserDevice Lookup Helpers

private UserDevice FindOnlineDeviceByInstanceGuid(Guid instanceGuid)

Manual loop under SyncRoot lock. Used extensively throughout all steps.

private UserDevice FindOnlineDeviceBySdlInstanceId(uint sdlInstanceId)

Manual loop under SyncRoot lock. Only matches online devices with non-null Device.

private UserDevice FindOrCreateUserDevice(Guid instanceGuid, Guid productGuid = default)

Three-tier lookup under SyncRoot lock:

  1. Exact match by InstanceGuid
  2. Fallback match: offline device with same ProductGuid (handles Bluetooth controllers that reconnect with a new device path after reboot). Migrates both the UserDevice and its linked UserSetting to the new InstanceGuid via MigrateUserSettingGuid.
  3. Create new: Adds a new UserDevice to devices.Items
private void MarkDeviceOffline(UserDevice ud)

Stops rumble (best-effort), disposes SDL handle (best-effort), calls ud.ClearRuntimeState() to reset all runtime fields including IsOnline = false.

External Device Registration

public void RegisterExternalDevice(WebControllerDevice device)
public void UnregisterExternalDevice(Guid instanceGuid)

Called by WebControllerServer when browser-based controller clients connect/disconnect. Uses FindOrCreateUserDevice and MarkDeviceOffline internally. Thread-safe via UserDevices.SyncRoot.

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)          // Locking, allocates
    public List<UserSetting> FindByPadIndex(int padIndex)              // Locking, allocates
    public int FindByInstanceGuid(Guid instanceGuid, UserSetting[] buffer)  // Non-allocating
    public int FindByPadIndex(int padIndex, UserSetting[] buffer)           // Non-allocating
}

Hot-path optimization: Non-allocating overloads fill pre-allocated buffers and return a count. Used in Steps 2-5 (~1000 calls/second) to avoid GC pressure from LINQ closures and list allocations. Allocating overloads exist for UI-thread use where convenience matters more than allocation.

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). This step executes even in idle mode so the Devices page raw input preview works for unassigned devices.

Method Signature

private void UpdateInputStates()

Called by: PollingLoop() (every cycle, including idle mode)

Thread safety: Snapshots the online device list under SyncRoot lock, then iterates the snapshot without holding the lock. ud.InputState is swapped via atomic reference assignment (safe for cross-thread reading by the UI).

Error handling: Per-device try/catch. A single device read failure marks that device offline (ud.IsOnline = false) and continues. SDL read returning null is treated as disconnection.

Algorithm

  1. Snapshot online devices into _deviceSnapshotBuffer under SyncRoot lock:

    lock (SettingsManager.UserDevices.SyncRoot)
    {
        // Grow buffer if needed
        if (_deviceSnapshotBuffer.Length < devices.Count)
            _deviceSnapshotBuffer = new UserDevice[devices.Count];
        // Copy only online devices
        snapshotCount = 0;
        for (int i = 0; i < devices.Count; i++)
            if (devices[i].IsOnline)
                _deviceSnapshotBuffer[snapshotCount++] = devices[i];
    }
  2. For each online device (outside lock): a. Save previous state for change detection:

    ud.OldInputState = ud.InputState;
    ud.OldInputUpdates = ud.InputUpdates;
    ud.OldInputStateTime = ud.InputStateTime;

    b. Read new state:

    newState = ud.Device.GetCurrentState(ud.ForceRawJoystickMode);

    ForceRawJoystickMode makes SdlDeviceWrapper.GetCurrentState() use SDL_GetJoystickAxis/SDL_GetJoystickButton instead of SDL_GetGamepadAxis/SDL_GetGamepadButton, bypassing SDL's gamecontrollerdb remapping. Used for devices like DS3 via DsHidMini SDF where the gamepad API drops buttons. c. Atomic reference swap: ud.InputState = newState (thread-safe for UI readers) d. Set ud.InputStateTime = DateTime.UtcNow e. Compute buffered updates:

    ud.InputUpdates = CustomInputHelper.GetUpdates(ud.OldInputState, newState);

    Returns an array of CustomInputUpdate describing which axes/buttons changed. Used by the recording/preview UI. f. Call ApplyForceFeedback(ud) -- apply rumble to the physical device

Force Feedback

private void ApplyForceFeedback(UserDevice ud)

Applies force feedback (rumble) to a physical device based on vibration data received from games via ViGEmBus.

Pre-conditions checked:

  • ud.ForceFeedbackState != null (device has FFB capability tracking)
  • ud.Device.HasRumble || ud.Device.HasHaptic (SDL reports rumble or haptic support)

Multi-slot vibration combination:

A single physical device can be mapped to multiple virtual controller slots. Vibration from ALL mapped slots is combined via max() of each motor:

int slotCount = settings.FindByInstanceGuid(ud.InstanceGuid, _instanceGuidBuffer);
ushort combinedL = 0, combinedR = 0;
for (int i = 0; i < slotCount; i++)
{
    var vib = VibrationStates[padIndex];
    if (vib.LeftMotorSpeed > combinedL)  combinedL = vib.LeftMotorSpeed;
    if (vib.RightMotorSpeed > combinedR) combinedR = vib.RightMotorSpeed;
}

TestRumbleTargetGuid: When TestRumbleTargetGuid[padIndex] is non-empty, only the device with that specific GUID receives rumble for that slot. This allows the Settings page to test rumble on one specific device without affecting others in the same slot.

Audio bass rumble combination:

When firstPadSetting.AudioRumbleEnabled == "1" and AudioBassDetector is set:

  1. Calls detector.DecayIfSilent() to apply decay curve when no audio is playing
  2. Reads per-device sensitivity and cutoff Hz from PadSetting
  3. Scales detector.MotorValue by AudioRumbleLeftMotor / AudioRumbleRightMotor percentages
  4. Combines with game vibration via max() -- audio rumble fills gaps without overriding native game FFB

Output: Writes to a scratch _combinedVibration instance (reused to avoid allocation) and calls:

ud.ForceFeedbackState.SetDeviceForces(ud, ud.Device, firstPadSetting, _combinedVibration);

The ForceFeedbackState class internally uses SDL_RumbleJoystick (with uint.MaxValue duration + change-detection to avoid redundant API calls) or falls back to SDL_Haptic (LeftRight > Sine > Constant effect strategy) for devices without native rumble.


Step 3: UpdateOutputStates

File: InputManager.Step3.UpdateOutputStates.cs

Maps each device's CustomInputState to a Gamepad struct (and optionally VJoyRawState, MidiRawState, or KbmRawState) based on PadSetting mapping descriptors. This is the most complex step, containing the entire mapping engine, dead zone processing, sensitivity curves, and center offset corrections.

Method Signature

private void UpdateOutputStates()

Called by: PollingLoop() (every active cycle, skipped in idle mode)

Thread safety: Snapshots UserSettings under SyncRoot lock, then iterates the snapshot without holding the lock. Each UserSetting's OutputState is a struct (value type), so writes are atomic at the word level on aligned fields.

Error handling: Per-setting try/catch. On exception, the OutputState is NOT zeroed -- the last valid state is preserved to prevent transient zero glitches from propagating through Steps 4-6 (e.g., output controller reading a momentary zero during a state refresh).

Algorithm

  1. Snapshot all UserSettings into _settingSnapshotBuffer under SyncRoot lock
  2. For each UserSetting: a. Find online device by us.InstanceGuid via FindOnlineDeviceByInstanceGuid b. If device not found: set us.OutputState = default (zero), continue c. If device found but offline or InputState == null: keep last valid OutputState (no zero), continue d. Get PadSetting via us.GetPadSetting() -- contains all mapping rules e. Map to gamepad: us.OutputState = MapInputToGamepad(ud.InputState, ps, out rawMapped) f. Save us.RawMappedState = rawMapped (pre-dead-zone snapshot for UI preview) g. Type-specific raw mapping based on SlotControllerTypes[slot]:
    • Custom vJoy: MapInputToVJoyRaw(ud.InputState, ps, cfg) using dictionary-based vJoy mappings
    • MIDI: MapInputToMidiRaw(ud.InputState, ps, ccCount, noteCount) for MIDI CC/note output
    • KeyboardMouse: MapInputToKbmRaw(ud.InputState, ps) for keyboard/mouse output

MapInputToGamepad

private static Gamepad MapInputToGamepad(CustomInputState state, PadSetting ps, out Gamepad rawMapped)

The core mapping function. Processes in this order:

  1. Buttons (11 total): A, B, X, Y, LB, RB, Back, Start, LS, RS, Guide -- each calls MapToButtonPressed(state, ps.ButtonX)
  2. D-Pad: If individual direction descriptors (DPadUp/DPadDown/DPadLeft/DPadRight) are set, each is mapped independently. Otherwise, falls back to combined DPad descriptor that extracts all 4 directions from a single POV hat via MapDPadFromPov.
  3. Triggers: MapToTrigger(state, ps.LeftTrigger) -> unsigned 0-65535
  4. Thumbsticks: MapToThumbAxisWithNeg(state, ps.LeftThumbAxisX, ps.LeftThumbAxisXNeg) -> signed short. Y axes are negated via NegateAxis() to convert from unsigned pipeline (0=up -> negative) to XInput convention (positive Y = up).
  5. Snapshot raw mapped state (rawMapped = gp) -- captured BEFORE dead zone processing so the UI preview can apply its own pipeline without double-processing
  6. Trigger dead zones: ApplyTriggerDeadZone with dead zone, anti-dead zone, max range, and optional sensitivity curve LUT
  7. Center offsets: ApplyCenterOffset(value, offsetPercent) -- shifts the axis by a percentage of the full range. Applied BEFORE dead zone processing. Used to compensate for stick drift.
  8. Stick dead zones: ApplyDeadZone with full parameter set: dead zone X/Y, anti-dead zone X/Y, linear, max range X/Y (both positive and negative directions independently), sensitivity curve LUT X/Y, dead zone shape

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, rescaled to full range
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 -> 0 or 65535)
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 (buttons: OR)
"Axis 4|Button 8"      -- trigger: max of axis value or button (0 or 65535)
"Axis 1|Axis 3"        -- thumbstick: largest absolute magnitude wins

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

ParseDescriptor

private static MappingDescriptor ParseDescriptor(string descriptor)

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

Invalid/empty descriptors return IsValid = false. The strings "0" and "" are treated as empty.

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% of range -- 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 sector-based tolerances:

  • Cardinals (Up, Right, Down, Left): +/-67.5 degree tolerance (covers the 135-degree sector including adjacent diagonals). Example: "Up" matches 29250-35999 and 0-6750.
  • Diagonals (UpRight, DownRight, DownLeft, UpLeft): +/-22.5 degree tolerance (exact 45-degree sector). Example: "UpRight" matches 2250-6750.

D-Pad from POV

private static void MapDPadFromPov(CustomInputState state, string descriptor, ref Gamepad gp)
private static void MapDPadFromPovSingle(CustomInputState state, string descriptor, ref Gamepad gp)

When individual D-pad directions (DPadUp, DPadDown, DPadLeft, DPadRight) are set, they take priority (each mapped independently as button presses). Otherwise, the combined DPad descriptor reads a single POV hat value and sets all 4 direction flags simultaneously, supporting 8-way diagonals.

Trigger Mapping

private static ushort MapToTrigger(CustomInputState state, string descriptor)
private static ushort MapToTriggerSingle(CustomInputState state, string descriptor)

Returns unsigned 16-bit (0-65535). Multiple descriptors: highest value wins (MAX).

  • Full axis: rawValue directly (already 0-65535)
  • Half axis: upper half rescaled: (rawValue - 32768) * 65535 / 32767
  • Inverted: 65535 - rawValue applied before conversion

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 only: +32767
  • Negative pressed only: -32768
  • Both pressed: 0 (cancel out)
  • Neither pressed: 0

Y-axis negation: NegateAxis() is applied to ThumbLY and ThumbRY:

private static short NegateAxis(short value)
    => value == short.MinValue ? short.MaxValue : (short)-value;

Clamps short.MinValue to short.MaxValue to avoid overflow (since -(-32768) overflows short).

Dead Zone Processing

private static void ApplyDeadZone(ref short axisX, ref short axisY,
    double deadZoneX, double deadZoneY,
    double antiDeadZoneX, double antiDeadZoneY, double linear,
    double maxRangeX, double maxRangeY,
    double maxRangeXNeg, double maxRangeYNeg,
    double[] lutX, double[] lutY,
    DeadZoneShape shape)

Six dead zone shapes are supported, selected via PadSetting.LeftThumbDeadZoneShape / RightThumbDeadZoneShape:

Shape Algorithm Use Case
Axial Independent per-axis dead zone (ApplySingleDeadZone on X and Y separately) Default, simple
Radial Elliptical distance check (nx/dzX)^2 + (ny/dzY)^2 < 1, raw pass-through outside Circular dead zone
ScaledRadial Same elliptical check + rescales magnitude from [dzR, mrR] to [0, 1] Smooth circular with no jump at dead zone edge
SlopedAxial Per-axis DZ scales with other axis magnitude: effDzX = dzXn * magY Cardinal direction locking
SlopedScaledAxial Same + rescale from [effDz, mr] to [0, 1] Cardinal lock without jump
Hybrid Stage 1: Scaled Radial (center noise removal) then Stage 2: Sloped Scaled Axial (cardinal precision) Best of both approaches

Post-dead-zone pipeline (applied per-axis regardless of shape):

  1. Sensitivity curve: spline LUT lookup via CurveLut.Lookup(lut, remapped) -- transforms the [0,1] remapped value through a user-defined response curve
  2. Anti-dead zone: output = adzNorm + remapped * (1.0 - adzNorm) -- offsets the output minimum so small physical movements register past the game's internal dead zone
  3. Linear adjustment: output = remapped * linearFactor + output * (1.0 - linearFactor) -- blends between raw linear and anti-dead-zone-adjusted output
  4. Scale and clamp: sign * output * 32767.0, clamped to short range

Independent max range: Each axis has separate positive and negative max range values. The sign of the input selects which max range to use: nx >= 0 ? maxRangeX : maxRangeXNeg. This allows asymmetric stick range (e.g., a stick with less travel in one direction).

Trigger Dead Zone

private static ushort ApplyTriggerDeadZone(ushort value, double deadZone, double antiDeadZone,
    double maxRange, double[] lut = null)
  1. Normalize to 0.0-1.0
  2. Dead zone: values below deadZone% threshold are zeroed
  3. Max range: caps input ceiling at maxRange%
  4. Remap from [dzNorm, maxNorm] to [0, 1]
  5. Sensitivity curve LUT lookup (if provided)
  6. Anti-dead zone: offsets output minimum by antiDeadZone%
  7. Scale to 0-65535 and clamp

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 defined by the VJoyDeviceConfig.

  • Axes: Uses MapToThumbAxisWithNeg for each axis (signed short range). No NegateAxis needed -- unlike the gamepad path, the raw path has no second inversion in SubmitRawState.
  • Buttons: Uses MapToButtonPressed for each button, sets via raw.SetButton(i, true)
  • POVs: Individual direction buttons (VJoyPov0Up, VJoyPov0Down, etc.) mapped to continuous POV values (0-35900 hundredths of degrees, 0xFFFFFFFF = centered) via DirectionToContinuousPov()
  • Dead zones: Applied per-stick and per-trigger using the same ApplySingleDeadZone / ApplyTriggerDeadZone methods

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. Handles four output types: Gamepad, VJoyRawState, MidiRawState, and KbmRawState.

Method Signature

private void CombineOutputStates()

Called by: PollingLoop() (every active cycle)

Thread safety: Uses non-allocating FindByPadIndex(padIndex, _padIndexBuffer) for zero-allocation lookups. The CombinedOutputStates[] array is written by this step and read by Steps 4b, 5, 6, and the UI timer. Since Gamepad is a small struct and the engine thread is the sole writer, reads from other threads see either the old or new value (no tearing on aligned word-sized fields).

Error handling: Per-slot try/catch. On exception, clears the slot's combined state to zero.

Algorithm

For each of the 16 slots:

  1. Find all UserSettings mapped to this slot via FindByPadIndex(padIndex, _padIndexBuffer)
  2. Determine slot type flags: isCustomVJoy, isMidi, isKbm
  3. 0 devices: clear all applicable state arrays for this slot
  4. 1 device: direct struct copy -- no merge needed (optimization for the common case)
  5. N devices: iterate and call MergeGamepad() for each. Also merge type-specific raw states:
    • Custom vJoy: MergeVJoyRaw() (first device is copied, subsequent are merged)
    • MIDI: MidiRawState.Combine() (static method)
    • KBM: KbmRawState.Combine() (static method)

Merge Rules

private static void MergeGamepad(ref Gamepad dest, ref Gamepad src)
Field Merge Rule Rationale
Buttons OR (dest.Buttons |= src.Buttons) Any device can activate any button
LeftTrigger MAX (if (src > dest) dest = src) Highest trigger value wins
RightTrigger MAX Highest trigger value wins
ThumbLX Largest absolute magnitude wins Allows one device to control left stick, another right stick, without interference
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, with Math.Min on array lengths)
Buttons[] OR (per uint word)
Povs[] First non-centered wins (dest centered + src non-centered -> use src)

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). This step also contains the Windows Core Audio COM interfaces for system/app volume control and the Win32 SendInput helpers for keyboard/mouse output.

Method Signature

private void EvaluateMacros()

Called by: PollingLoop() (every active cycle)

Thread safety: Reads MacroSnapshots[i] atomically (reference read). The UI thread writes the reference at 30Hz via InputService. The macro MacroItem objects contain mutable state (IsExecuting, CurrentActionIndex, ActionStartTime, WasTriggerActive, MouseAccumulator) that is only written by this step on the engine thread -- the UI thread only reads these for display purposes.

Error handling: Per-slot try/catch. A macro error does not affect other slots.

Algorithm

For each slot (0-15):

  1. Read MacroSnapshots[i] -- if null or empty, skip
  2. Delegate to type-specific evaluator:
    • EvaluateSlotMacros(ref Gamepad, MacroItem[]) for standard slots (Xbox/DS4/Gamepad-preset vJoy/KBM)
    • EvaluateSlotMacrosCustomVJoy(ref VJoyRawState, MacroItem[]) for custom vJoy slots (operates on uint[] button words instead of ushort Gamepad.Buttons)

Trigger Detection

Combo trigger evaluation -- all active components must match simultaneously (AND logic across categories):

  1. Button flags: Three sub-types (checked via priority):

    • Raw device buttons (UsesRawTrigger): Reads FindOnlineDeviceByInstanceGuid(macro.TriggerDeviceGuid).InputState.Buttons[rawIndices[i]]. Bypasses the mapping pipeline -- reads directly from the physical device's raw button state.
    • Custom vJoy button words (UsesCustomTrigger): Checks (raw.Buttons[w] & tw[w]) == tw[w] against the combined VJoyRawState.
    • Xbox bitmask (default): (gp.Buttons & triggerButtons) == triggerButtons against the combined Gamepad.
  2. Axis thresholds (macro.TriggerAxisTargets[]): Each axis target is evaluated:

    • MacroAxisDirection.Positive: fires when axis is in positive half (>= 0.5 + threshold*0.5)
    • MacroAxisDirection.Negative: fires when axis is in negative half (<= 0.5 - threshold*0.5)
    • MacroAxisDirection.Any (default): fires when normalized axis value >= threshold
    • ALL specified axes must exceed their threshold (AND logic within axis group)
  3. POV directions (macro.TriggerPovs[]): Stored as "povIndex:centidegrees" strings (e.g., "0:0" for POV 0 Up). Each POV must be within a 45-degree sector (+/-2250 centidegrees) of the target direction. Uses FindOnlineDeviceByInstanceGuid to read raw POV from the trigger device.

Always trigger mode: When TriggerMode == Always, the trigger check is skipped entirely and triggerActive = true. The macro starts on the first frame and runs every frame. Useful for continuous axis-to-mouse or axis-to-volume mappings.

Trigger Modes

public enum MacroTriggerMode
{
    OnPress,     // Fire once when trigger transitions inactive -> active
    OnRelease,   // Fire once when trigger transitions active -> inactive
    WhileHeld,   // Fire continuously while trigger is active
    Always       // Skips trigger check, runs every frame until stopped
}

State tracking via macro.WasTriggerActive (set to triggerActive at end of each evaluation cycle).

Repeat Modes

public enum MacroRepeatMode
{
    Once,         // Execute action sequence once then stop
    FixedCount,   // Execute N times (macro.RepeatCount) then stop
    UntilRelease  // Keep repeating until trigger released (WhileHeld/Always modes)
}

Repeat delay: after the action sequence completes, waits macro.RepeatDelayMs before restarting the sequence.

Action Types

public enum MacroActionType
{
    ButtonPress,        // OR button flags into Gamepad for DurationMs
    ButtonRelease,      // AND-NOT button flags (clear immediately)
    KeyPress,           // SendInput VK down, hold for DurationMs, then up
    KeyRelease,         // SendInput VK up immediately
    Delay,              // Wait for DurationMs (no output modification)
    AxisSet,            // Set a specific axis to a specific value
    SystemVolume,       // Map axis value to Windows system master volume (0-100%)
    AppVolume,          // Map axis value to per-app volume in Windows audio mixer
    MouseMove,          // Map source axis deflection to mouse cursor movement
    MouseButtonPress,   // Press a mouse button via SendInput, hold for DurationMs
    MouseButtonRelease, // Release a mouse button via SendInput immediately
    MouseScroll         // Map source axis deflection to mouse scroll wheel
}

Action Execution Architecture

Actions are classified as either sequential or continuous:

  • Sequential actions (ButtonPress, ButtonRelease, KeyPress, KeyRelease, Delay, AxisSet, MouseButtonPress, MouseButtonRelease): Execute one at a time in order, advancing via AdvanceAction(macro) when the action's DurationMs elapses.
  • Continuous actions (SystemVolume, AppVolume, MouseMove, MouseScroll): Run every frame regardless of position in the sequence. This allows e.g., MouseMove X + MouseMove Y in the same macro to both execute simultaneously.
private void ExecuteMacroActions(ref Gamepad gp, MacroItem macro)
  1. Run ALL continuous actions every frame (iterate entire action list, skip non-continuous)
  2. Process current sequential action (skip over continuous ones in the sequence):
    • ExecuteSequentialAction(ref gp, macro, action) -- handles per-type logic
  3. Sequence complete: If all actions are continuous, stay executing. Otherwise, handle repeat logic:
    • Decrement RemainingRepeats
    • If repeats remain (or UntilRelease), wait for RepeatDelayMs then restart
    • Otherwise, set IsExecuting = false

Mouse Action Execution

  • MouseMove: Uses MouseAccumulator (per-action float field) for sub-pixel precision. Each frame:

    action.MouseAccumulator += deflection * action.MouseSensitivity;
    int delta = (int)action.MouseAccumulator;
    action.MouseAccumulator -= delta;

    The integer part is sent via SendMouseMoveInput(dx, dy) using Win32 SendInput. The fractional remainder stays in the accumulator for the next frame. Axis source determines direction: LeftStickY/RightStickY map to Y movement, others to X.

  • MouseScroll: Same accumulator pattern. Non-zero integer part sent via SendMouseScrollInput(delta * 120) (120 = WHEEL_DELTA).

  • Axis source: When action.AxisSource == MacroAxisSource.InputDevice, reads directly from the physical device via ReadAxisFromDevice(action) instead of the combined Gamepad state. The InvertAxis flag flips the value.

ConsumeTriggerButtons

When macro.ConsumeTriggerButtons is true and the trigger is active:

  • For standard slots: gp.Buttons &= (ushort)~macro.TriggerButtons -- AND-NOT the trigger button flags out of the combined Gamepad
  • For custom vJoy slots: raw.Buttons[w] &= ~tw[w] -- clear trigger button words
  • Only applies to non-raw triggers (raw device buttons are not part of the combined state)

System Volume Control

private void SetSystemVolume(float volume, bool showOsd = true)

Uses Windows Core Audio COM (IAudioEndpointVolume.SetMasterVolumeLevelScalar) for precise volume setting. Features:

  • Change detection: Skips redundant COM calls when volume delta < 0.4% (1/256 tolerance)
  • OSD trigger: Sends a net-zero VK_VOLUME_UP + VK_VOLUME_DOWN pair to show the modern Windows volume flyout, rate-limited to every 200ms (~5 Hz)
  • Correction window: After an OSD trigger, keeps correcting for 150ms to counteract the async VK_VOLUME key events that shift the volume by ~2%
  • Lazy initialization: COM endpoint is created on first call, cached for subsequent calls
  • Permanent failure: If COM initialization fails, sets _audioEndpointFailed = true and stops trying

Per-App Volume Control

private void SetAppVolume(float volume, string processName)

Enumerates audio sessions via IAudioSessionManager2, identifies sessions by process ID, and sets volume via ISimpleAudioVolume. Uses direct vtable calls (GetProcessIdFn, SetMasterVolumeFn) to bypass QueryInterface limitations from elevated processes.

Change detection: Per-process _lastAppVolumes dictionary with 0.4% tolerance.

SendInput Helpers

private static void SendKeyInput(ushort virtualKeyCode, bool keyUp)
private static void SendMouseMoveInput(int dx, int dy)
private static void SendMouseButtonInput(MacroMouseButton button, bool down)
private static void SendMouseScrollInput(int delta)

All use Win32 SendInput with INPUT_KEYBOARD or INPUT_MOUSE. Key presses map VK to scan code via MapVirtualKey(MAPVK_VK_TO_VSC). Multi-key sequences press in forward order and release in reverse order.


Step 5: VirtualDevices

File: InputManager.Step5.VirtualDevices.cs

Submits combined gamepad states to virtual controllers (ViGEm Xbox 360, ViGEm DS4, vJoy, MIDI, KeyboardMouse). Manages virtual controller lifecycle: creation, destruction, type changes, activity tracking, vJoy descriptor synchronization, and ViGEm ordering.

Method Signature

private void UpdateVirtualDevices()

Called by: PollingLoop() (every active cycle)

Thread safety: SlotControllerTypes[] is written by the UI thread at 30Hz and read by this step at ~1000Hz. Since the values are single-word enum writes, reads are always consistent (no tearing). The VJoySyncLock protects vJoy descriptor sync from concurrent SwapSlotData operations.

Error handling: The overall method has no try/catch, but Pass 3 (report submission) wraps each slot in try/catch. VC creation failures set _createCooldown[padIndex] = CreateCooldownCycles to prevent per-frame retry. ViGEm client initialization failures set _vigemClientFailed = true permanently.

Fields

Field Type Description
_vigemClient static ViGEmClient Shared ViGEm client (one per process). Lazy-initialized.
_vigemClientLock static object Lock for double-checked lazy initialization.
_vigemClientFailed static bool Permanent failure flag -- once ViGEm init fails, never retry.
_virtualControllers IVirtualController[MaxPads] Virtual controller instances per slot. null = no VC for this slot.
SlotControllerTypes VirtualControllerType[MaxPads] Configured type per slot. Written by UI at 30Hz, read by Step 5 at ~1000Hz.
SlotVJoyConfigs VJoyDeviceConfig[MaxPads] Per-slot vJoy HID descriptor config (axes, buttons, POVs).
SlotVJoyIsCustom bool[MaxPads] true = custom vJoy preset (raw axis/button pipeline), false = gamepad preset.
_midiConfigs MidiSlotConfig[MaxPads] Per-slot MIDI configuration snapshot (channel, velocity, CC/note numbers).
_activeVigemCount int Currently connected ViGEm controllers (Xbox + DS4). Used by Step 1 filter.
_activeXbox360Count int Currently connected ViGEm Xbox 360 controllers.
_activeDs4Count int Currently connected ViGEm DS4 controllers.
_expectedXbox360Count int Pre-initialized count for first-cycle filtering (set by PreInitializeVigemCounts).
_expectedDs4Count int Pre-initialized count for first-cycle filtering.
_slotInactiveCounter int[MaxPads] Consecutive inactive cycles per slot.
SlotDestroyGraceCycles const int 10000 (~10 seconds at 1000Hz before destroying an inactive VC).
_createCooldown int[MaxPads] Cooldown counter after failed creation. Counts down each cycle.
CreateCooldownCycles const int 2000 (~2 seconds between creation retries).
_slotInitializing bool[MaxPads] true while a VC is being created or reconfigured. Read by UI for flashing green indicator.
_lastStep5VJoyConfigs VJoyDeviceConfig[] Cached vJoy configs for detecting content changes without count changes.
VJoySyncLock object Lock protecting vJoy descriptor sync from concurrent SwapSlotData.
_vJoySyncCycleCount int Grace period counter for vJoy sync (prevents premature cleanup during startup).
VJoyStartupGraceCycles const int 5000 (~5 seconds of startup grace before allowing vJoy node removal).

UpdateVirtualDevices Architecture

Four-pass architecture:

Pass 1: Handle type changes, destruction, and activity tracking

For each slot:

  • Type change (vc.Type != SlotControllerTypes[padIndex]): Destroy old VC, reset cooldown, mark _slotInitializing if slot is active
  • Slot deleted/disabled (!SlotCreated || !SlotEnabled): Destroy immediately (no grace period), zero vibration
  • Slot active (IsSlotActive returns true): Reset inactive counter, flag anyNeedsCreate if no VC
  • No devices mapped (!HasAnyDeviceMapped): Destroy immediately (user explicitly unassigned all devices)
  • Device mapped but offline (transient disconnect): Increment _slotInactiveCounter. Destroy VC after SlotDestroyGraceCycles (10 seconds). This grace period preserves rumble feedback through brief USB hiccups.

Pass 1b: Sync vJoy registry descriptor count (under VJoySyncLock)

  1. Count totalVJoyNeeded: slots with running vJoy VCs + active slots that will create one
  2. Skip removal during startup grace period (_vJoySyncCycleCount < VJoyStartupGraceCycles) to prevent transient totalVJoyNeeded=0 from deleting nodes
  3. If count changed (descriptor count != CurrentDescriptorCount): a. Destroy inactive vJoy VCs (active ones survive and will re-acquire lower IDs) b. Build per-device HID descriptor configs array indexed by device ID (1-based), not slot:
    • Pass A: Place configs for existing VCs at their current device ID position
    • Pass B: Fill remaining positions with overflow VCs and new slots c. Call VJoyVirtualController.EnsureDevicesAvailable(totalVJoyNeeded, deviceConfigs) d. Force existing VCs to ReAcquireIfNeeded() (re-claim device IDs after node restart) e. Fix device ID ordering: if a surviving VC has an out-of-sequence ID, destroy it for recreation in Pass 2
  4. If count unchanged but config content changed (axes/buttons/POVs): triggers descriptor rewrite inside EnsureDevicesAvailable, marks slots as initializing

Pass 1c: Ensure ViGEm VC ordering across cycles

ViGEm assigns XInput/DS4 indices based on Connect() call order. When a lower-numbered slot needs a new VC but higher-numbered slots already have same-type VCs (created in a previous cycle), the new VC would get a higher index. Fix: destroy any same-type VCs at higher slot indices so they can be recreated in ascending order alongside the new one in Pass 2.

Pass 2: Create virtual controllers in ascending slot order

for (int padIndex = 0; padIndex < MaxPads; padIndex++)
{
    if (_virtualControllers[padIndex] == null && _slotInactiveCounter[padIndex] == 0)
    {
        // vJoy: skip inactive slots (device IDs are scarce)
        // Cooldown: skip if still counting down from failed creation
        var vc = CreateVirtualController(padIndex);
        _virtualControllers[padIndex] = vc;
        if (vc != null && vc.IsConnected) _slotInitializing[padIndex] = false;
        else if (vc == null) _createCooldown[padIndex] = CreateCooldownCycles;
    }
}

Pass 3: Submit reports for active slots

For each slot with a connected VC and zero inactive counter:

if (vc is VJoyVirtualController vjoyVc && SlotVJoyIsCustom[padIndex])
    vjoyVc.SubmitRawState(CombinedVJoyRawStates[padIndex]);
else if (vc is MidiVirtualController midiVc)
    midiVc.SubmitMidiRawState(CombinedMidiRawStates[padIndex]);
else if (vc is KeyboardMouseVirtualController kbmVc)
    kbmVc.SubmitKbmState(CombinedKbmRawStates[padIndex]);
else
    vc.SubmitGamepadState(CombinedOutputStates[padIndex]);

Virtual Controller Creation

private IVirtualController CreateVirtualController(int padIndex)
  1. Check prerequisites: ViGEm client required for Xbox 360 and DS4 (not for vJoy/MIDI/KBM)
  2. For Xbox 360: Snapshot XInput slot mask BEFORE connecting via GetXInputConnectedSlotMask()
  3. Create concrete controller instance based on SlotControllerTypes[padIndex]:
    • Xbox360VirtualController(_vigemClient) (default)
    • DS4VirtualController(_vigemClient)
    • CreateVJoyController() -- finds free device ID via FindFreeDeviceId()
    • CreateMidiController(padIndex) -- creates virtual MIDI endpoint with computed instance number
    • KeyboardMouseVirtualController(padIndex)
  4. Call vc.Connect()
  5. For Xbox 360: Wait up to 50ms (spin-wait with Thread.SpinWait(100)) for XInput slot to appear (mask delta)
  6. Increment _activeVigemCount / _activeXbox360Count / _activeDs4Count
  7. Register feedback callback: vc.RegisterFeedbackCallback(padIndex, VibrationStates) -- wires ViGEm's FeedbackReceived event to write motor values into VibrationStates[padIndex]

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 from XInput stack
  4. vc.Dispose() -- releases native ViGEm target handle (vigem_target_free). Without this, ViGEm targets leak and phantom USB devices remain.
  5. In finally block: Decrement active counters (Math.Max(0, count - 1) to prevent underflow). MUST execute even if Disconnect/Dispose throws, otherwise _activeXbox360Count stays inflated and the Step 1 filter over-filters on subsequent UpdateDevices cycles.

Slot Activity Check

private bool IsSlotActive(int padIndex)

Returns true if:

  1. SettingsManager.SlotCreated[padIndex] && SettingsManager.SlotEnabled[padIndex]
  2. At least one online device is mapped to this slot (found via FindByPadIndex + FindOnlineDeviceByInstanceGuid)
private bool HasAnyDeviceMapped(int padIndex)

Returns true if any UserSetting (online OR offline) has MapTo == padIndex. Distinguishes "user unassigned all devices" (no mappings -> destroy immediately) from "device temporarily offline" (mapping exists -> grace period).

XInput Slot Mask

[DllImport("xinput1_4.dll", EntryPoint = "#100")]
private static extern uint XInputGetStateEx(uint dwUserIndex, ref XInputStateInternal pState);

private static uint GetXInputConnectedSlotMask()

Probes XInput slots 0-3 (API limit) via the undocumented ordinal #100 (XInputGetStateEx, which reports Guide button unlike the public API). Returns a 4-bit mask of connected XInput devices. Used only for detecting when a new ViGEm Xbox 360 controller appears or disappears, to synchronize slot assignment.

Stale ViGEm Cleanup

public static void CleanupStaleVigemDevices()

Called BEFORE Start(). Enumerates XnaComposite device class via pnputil /enum-devices, identifies ViGEm devices by short numeric serial (1-2 digits vs. real Xbox controllers' longer hardware serials), removes them via pnputil /remove-device /subtree. This cleans up orphaned device nodes from previous sessions where the feeder app crashed without calling Dispose().

Pre-Initialize ViGEm Counts

public void PreInitializeVigemCounts(int xbox360Count, int ds4Count)

Must be called before Start() so the first UpdateDevices() cycle (which runs immediately) can filter ViGEm devices correctly. Without this, _activeXbox360Count is 0 on the first cycle, causing all stale ViGEm 045E:028E devices to pass through the Step 1 filter as "real" Xbox controllers, creating a feedback loop.


Step 6: RetrieveOutputStates

File: InputManager.Step6.RetrieveOutputStates.cs

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

Method Signature

private void RetrieveOutputStates()

Called by: PollingLoop() (every active cycle)

Thread safety: Writes to RetrievedOutputStates[] and RetrievedKbmRawStates[] (struct copies). The UI timer reads these arrays at 30Hz. Since Gamepad is a small struct (8 fields), individual field reads are atomic on x64, but a full struct read could theoretically see a mix of old/new fields during a concurrent write. In practice, the visual impact is negligible (one frame of mixed state at worst).

Error handling: Per-slot try/catch. On exception, clears the slot to zero.

Algorithm

For each of the 16 slots:

  1. Read _virtualControllers[padIndex]
  2. If VC is non-null and connected:
    • RetrievedOutputStates[padIndex] = CombinedOutputStates[padIndex] (struct copy)
    • For KBM VCs: also copy RetrievedKbmRawStates[padIndex] = CombinedKbmRawStates[padIndex]
  3. Otherwise: RetrievedOutputStates[padIndex].Clear() and RetrievedKbmRawStates[padIndex].Clear()

This approach replaced the original XInput P/Invoke readback (XInputGetStateEx). Direct copy is both more universal (works for DS4, vJoy, MIDI, KBM -- not just Xbox 360) and more accurate (shows exactly what was submitted to the virtual controller, without the ~1ms round-trip delay of XInput readback).


Thread Safety Summary

The pipeline has three concurrent threads of execution:

Thread Role Writes Reads
Engine (PadForge.InputManager, AboveNormal) Runs the 6-step pipeline at ~1000Hz All Combined*States, Retrieved*States, MotionSnapshots, device InputState, VCs MacroSnapshots, SlotControllerTypes, VibrationStates, IsIdle, PollingIntervalMs
UI (WPF Dispatcher, 30Hz timer) Reads output states for display, writes configuration MacroSnapshots, SlotControllerTypes, SlotVJoyConfigs, TestRumbleTargetGuid, IsIdle Retrieved*States, CurrentFrequency, device InputState
ViGEm callback (Thread pool) Receives game rumble feedback VibrationStates[padIndex].LeftMotorSpeed/RightMotorSpeed (none)

Synchronization mechanisms used:

  • SyncRoot locks on UserDevices and UserSettings for collection modification/iteration
  • VJoySyncLock for vJoy descriptor sync during slot swaps
  • _vigemClientLock for double-checked lazy initialization of the ViGEm client
  • volatile on _running and _idle for cross-thread flag visibility
  • Atomic reference swaps for ud.InputState (object reference) and MacroSnapshots[i] (array reference)
  • Struct value copies for Gamepad and other small value types (word-aligned fields are atomic on x64)

Data Flow Summary

Physical Device (SDL3 / Raw Input / WebController)
    |
    v  [Step 2: GetCurrentState]
CustomInputState (unsigned axes 0-65535, bool[] buttons, centidegree POVs, gyro/accel)
    |
    v  [Step 3: MapInputToGamepad / MapInputToVJoyRaw / MapInputToMidiRaw / MapInputToKbmRaw]
    |     Parse mapping descriptors, apply axis conversions, apply dead zones + curves
    |
    v  per-UserSetting OutputState
Gamepad struct (signed axes, XInput button bitmask, ushort triggers)
  -- or --
VJoyRawState (signed short[] axes, uint[] button words, int[] POVs)
  -- or --
MidiRawState (byte[] cc values, bool[] note states)
  -- or --
KbmRawState (VK codes, mouse delta/buttons)
    |
    v  [Step 4: CombineOutputStates]
    |     Merge multiple devices per slot (OR/MAX/magnitude rules)
    |
    v  per-slot combined state
CombinedOutputStates[slot]  /  CombinedVJoyRawStates[slot]  /  etc.
    |
    v  [Step 4b: EvaluateMacros]
    |     Trigger state machine, inject button/axis/volume/mouse actions (in-place modification)
    |
    v  [Step 5: UpdateVirtualDevices]
    |     Create/destroy VCs, submit reports
    |
IVirtualController.SubmitGamepadState()  /  SubmitRawState()  /  SubmitMidiRawState()  /  SubmitKbmState()
    |                                           |                    |                       |
    v                                           v                    v                       v
ViGEm Xbox 360 / DS4                    vJoy (HID IOCTL)    MIDI (WinRT)         Win32 SendInput
(XInput / DirectInput)                  (joy.cpl)            (MIDI endpoint)     (keyboard + mouse)
    |
    v  [Step 6: RetrieveOutputStates]
RetrievedOutputStates[slot]  ->  UI Display (dashboard gauges, axis bars, button indicators)

    <--- Feedback path (game -> controller -> PadForge -> physical device) --->
Game calls XInputSetState()  ->  ViGEm FeedbackReceived  ->  VibrationStates[slot]
    ->  Step 2: ApplyForceFeedback()  ->  SDL_RumbleJoystick()  ->  Physical device vibrates

Key Types Reference

Gamepad Struct

public struct Gamepad
{
    public ushort Buttons;
    public ushort LeftTrigger;     // 0-65535
    public ushort RightTrigger;    // 0-65535
    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 16 axes
    public uint[] Buttons;   // 4 x 32-bit words = 128 buttons max
    public int[] Povs;       // Up to 4, -1=centered, 0-35900=direction (centidegrees)

    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();     // Zeros axes, clears buttons, sets POVs to -1 (centered)
}

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 (SDL standard)
    public float[] Accel;   // [X,Y,Z] m/s^2 (SDL standard, Y=up has gravity)
}

IVirtualController Interface

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

VirtualControllerType Enum

public enum VirtualControllerType
{
    Xbox360 = 0,       // ViGEm Xbox 360 (appears in XInput stack)
    DualShock4 = 1,    // ViGEm DualShock 4 (appears in DirectInput)
    VJoy = 2,          // vJoy virtual joystick (HID, appears in joy.cpl)
    Midi = 3,          // Windows MIDI Services virtual endpoint
    KeyboardMouse = 4  // Win32 SendInput keyboard + mouse
}

Clone this wiki locally