Skip to content

Input Pipeline

hifihedgehog edited this page May 20, 2026 · 58 revisions

Input Pipeline

The six-step polling loop that runs at 1000 Hz and turns raw device input into virtual controller output.


The input pipeline runs on a dedicated background thread at ~1000 Hz. It 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/>HIDMaestro virtual filter substring list]
        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/>Deadzones + 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/>HIDMaestro lifecycle on thread pool<br/>Per-slot create/destroy/swap<br/>Inactivity timeout + bubble-up cascade]
        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/>SlotExtendedConfigs]
    end

    subgraph "HIDMaestro 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 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/.

Contents


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 (ms). Runtime-adjustable via 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 (AboveNormal priority, IsBackground=true)
_running volatile bool Loop control flag; set false by Stop() to terminate
_idle volatile bool When true, skips Steps 3–6 and sleeps at ~20 Hz. Step 2 still runs for Devices page preview.
_sdlInitialized bool Whether SDL_Init succeeded
_disposed bool Disposal guard
_enumerationTimer Stopwatch Time since last device enumeration
_frequencyTimer Stopwatch Time tracking for frequency measurement
_frequencyCounter int Cycle counter for frequency measurement
_deviceSnapshotBuffer UserDevice[] Pre-allocated buffer for Step 2 device snapshot (avoids LINQ/closure allocations). Grows dynamically.
_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
CombinedExtendedRawStates ExtendedRawState[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
CombinedKbmRawStates KbmRawState[MaxPads] Step 4 (engine) Step 5 Combined KBM raw state
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] HIDMaestro callback thread Step 2 (engine) Per-slot rumble from games. Cross-thread: HIDMaestro's FeedbackReceived writes, engine reads.
MotionSnapshots MotionSnapshot[MaxPads] Engine (polling loop) DSU broadcast Per-slot motion sensor data for Cemuhook
MacroSnapshots MacroItem[][MaxPads] UI timer (30 Hz) Step 4b (engine) Per-slot macro definitions. Cross-thread: atomic reference swap.
TestRumbleTargetGuid Guid[MaxPads] UI Step 2 When non-empty, restricts test rumble to one device GUID in the slot
CurrentFrequency double Engine UI Measured polling frequency (Hz). Updated ~once/second.
IsRunning bool Engine UI Whether the polling loop is active
IsIdle bool UI (InputService) Engine When true, skips Steps 3–6 and runs at ~20 Hz. Set when no VC slots exist.
DsuServer DsuMotionServer InputService Engine DSU motion server. When set, broadcasts motion data after Step 2.
AudioBassDetector AudioBassDetector InputService Engine Audio bass detector. When set, bass energy is combined with game rumble via max().

Events

public event EventHandler DevicesUpdated;
public event EventHandler FrequencyUpdated;
public event EventHandler<InputExceptionEventArgs> ErrorOccurred;
Event Thread Description
DevicesUpdated Engine thread Fired on device connect/disconnect. UI must marshal to dispatcher.
FrequencyUpdated Engine thread Fired ~once per second with updated CurrentFrequency
ErrorOccurred Engine thread Non-fatal polling errors. Handlers receive message + exception.

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 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 and hides Xbox controllers

Post-init:

  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 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 on failure.

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 on failure
  3. Calls RawInputListener.Start(). Starts hidden message-only window for keyboard/mouse enumeration
  4. Creates and starts the polling thread

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

public void Stop()
  1. Sets _running = false
  2. Joins polling thread with 3-second timeout
  3. Stops RawInputListener
  4. Calls StopAllForceFeedback(). Best-effort stop on all devices
  5. Calls DestroyAllVirtualControllers(). Disconnects and disposes all VCs
  6. Calls CloseAllDevices(). Disposes all SDL handles and clears runtime state

In v3 HIDMaestro takes a parameter-free Disconnect(). The v2 vJoy "preserve nodes" path is gone — HM creates and destroys virtual devices dynamically without leaving stale joy.cpl entries behind.

Main Polling Loop

private void PollingLoop()

Background thread entry point. Sets timeBeginPeriod(1) for the loop duration (restored via timeEndPeriod(1) in finally).

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 ns intervals) via SetWaitableTimerEx, then the thread blocks on WaitForSingleObject. Leaves a 0.1 ms (spinThresholdTicks) gap before the target to spin-finish.

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

Tier 3: Thread.Sleep(1) + SpinWait. Legacy fallback when both timers fail. Thread.Sleep(1) absorbs bulk wait when >1.5 ms 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 behind (positive drift), future cycles shorten; if ahead (negative drift), they lengthen. This converges the long-term average rate to the target Hz.

Safety mechanisms:

  • If drift exceeds 10x the target interval (e.g., after sleep/resume), the wall clock resets instead of sprinting to catch up
  • adjustedTarget floors at targetTicks / 4 to prevent negative or near-zero waits

Idle mode:

When no VC slots exist (IsIdle == true), the loop enters low-power mode:

  • Pumps SDL_UpdateJoysticks()
  • Runs UpdateDevices() every 5 seconds (instead of 2) so new controllers still appear on the Devices page
  • Runs UpdateInputStates() for Devices page raw input preview
  • Skips Steps 3–6
  • Sleeps at ~20 Hz (Thread.Sleep(50))
  • Reports CurrentFrequency = 0
  • On transition back to active: sets firstCycle = true for immediate enumeration, resets drift state to prevent burst cycles

Sleep guard: Every 5 seconds, calls SetThreadExecutionState(ES_CONTINUOUS) to clear execution-state flags SDL may re-assert, so the PC can still sleep.

Slot Swap

public void SwapSlots(int slotA, int slotB)

Same-type swaps keep VCs alive. Only input routing changes via MapTo swap in SettingsManager. All per-slot arrays are recomputed each frame from MapTo, so no array swapping is needed. Zero game disruption.

Cross-type swaps destroy both VCs so Step 5 recreates them in ascending slot order. Also swaps SlotControllerTypes, SlotExtendedConfigs, SlotExtendedIsCustom, TestRumbleTargetGuid, MacroSnapshots.

Intra-group reorders (drag within Xbox / PlayStation / Extended) take a different path. They mutate SettingsManager.SlotOrders for the visual order and call InputManager.RerouteVirtualControllersForReorder(groupType, oldOrder, newOrder). The kernel VC at each visual position stays put; the pad-index pointer in _virtualControllers[] moves so the data at the new pad-at-position-V feeds into V's kernel slot. Same-profile positions reuse via pointer-only swap (zero teardown). Different-profile positions destroy the old VC and let Pass 2 recreate it with the new pad's profile. Cross-group / type-change reorders still go through Pass 1's destroy and Pass 2's recreate. See Services Layer#slot-reordering.

public void SwapSlotData(int slotA, int slotB)

Swaps all data arrays and VC instances between two slots under ExtendedSyncLock. Used by EnsureTypeGroupOrder bubble sort on the UI thread. Unlike SwapSlots, also swaps _slotInactiveCounter, _slotInitializing, _createCooldown, VibrationStates, all Combined*States arrays, and _midiConfigs. So Step 5 sees no type mismatch and avoids needless destroy/recreate cycles that cause phantom Xbox controllers.

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

Thread safety: Holds ExtendedSyncLock for the entire swap to prevent the polling thread from observing a half-swapped state.

Motion Snapshots

private void UpdateMotionSnapshots()

Called after Step 2. For each pad slot:

  1. Finds all UserSettings mapped to this slot via FindByPadIndex
  2. Looks up each 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). The DSU server may be null (no-op).

IDisposable

public void Dispose()

Calls Stop() then ShutdownSdl(). Finalizer calls Dispose() as a safety net; 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 connected devices at 2-second intervals (5-second in idle mode). Opens new devices, marks disconnected ones offline, and fires DevicesUpdated on changes. Handles three 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 on the engine thread only. Collection modifications use UserDevices.SyncRoot locking. DevicesUpdated fires on the engine thread. UI consumers must marshal to the dispatcher.

Error handling: Each device open is try/catch-guarded. A single failure does not abort enumeration. The error is reported via RaiseError and the next device is processed.

Tracking Fields

Field Type Description
_openedSdlInstanceIds HashSet<uint> SDL instance IDs of currently opened joysticks; skipped during enumeration
_openedKeyboardHandles HashSet<IntPtr> Raw Input handles for tracked keyboards
_openedMouseHandles HashSet<IntPtr> Raw Input handles for tracked mice
_rawInputEnumPending volatile bool True when a background enumeration task has been dispatched
_rawInputEnumRunning bool True while the background task is actively enumerating
_cachedKeyboards List<RawInputDeviceInfo> Cached keyboard enumeration results from the background thread
_cachedMice List<RawInputDeviceInfo> Cached mouse enumeration results from the background thread
_rawInputCacheLock object Lock protecting _cachedKeyboards and _cachedMice reads/writes

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 _openedSdlInstanceIds (already open)
  2. Create SdlDeviceWrapper and call wrapper.Open(instanceId). Opens as gamepad if recognized, joystick otherwise
  3. FindOrCreateUserDevice(wrapper.InstanceGuid, wrapper.ProductGuid). Find existing or create new
  4. ud.LoadFromSdlDevice(wrapper). Populate capabilities, name, VID/PID
  5. Mark ud.IsOnline = true
  6. Track in _openedSdlInstanceIds

HIDMaestro virtual controllers never reach this loop: PadForge's SDL3 fork filters them out of SDL_GetJoysticks by walking each device's PnP parent chain for HIDMAESTRO in the Hardware ID list. See SDL3 Integration for the fork-side patch.

Phase 1b: Enumerate keyboards via EnumerateKeyboards()

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

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

Async enumeration (v2.2.1+): Raw Input enumeration (CreateFile + HidD_GetAttributes + registry reads per device) is expensive and caused polling dips as low as ~60 Hz on systems with many HID devices. The first cycle runs synchronously to ensure devices are ready for Step 2. Subsequent cycles dispatch enumeration to a background Task.Run thread via _rawInputEnumPending/_rawInputEnumRunning flags; the polling thread consumes cached results from _cachedKeyboards and _cachedMice, protected by _rawInputCacheLock. This keeps the effective polling rate at a stable 1000 Hz regardless of HID device count.

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

UserDevice Lookup Helpers

private UserDevice FindOnlineDeviceByInstanceGuid(Guid instanceGuid)

Manual loop under SyncRoot lock. Used 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 reconnecting with a new device path). Migrates UserDevice and 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 runtime fields including IsOnline = false.

External Device Registration

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

Called by WebControllerServer on browser controller client connect/disconnect. 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/s) to avoid GC pressure. Allocating overloads exist for UI-thread use where convenience matters more.

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). Runs even in idle mode so the Devices page raw input preview works.

Method Signature

private void UpdateInputStates()

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

Thread safety: Snapshots online devices under SyncRoot, then iterates without the lock. ud.InputState is swapped via atomic reference assignment.

Error handling: Per-device try/catch. A read failure marks the device offline and continues. SDL 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 uses 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

Device Object Enumeration

public DeviceObjectItem[] GetDeviceObjects()

Returns the list of axes, buttons, and POVs exposed by the device for mapping UI. Uses Math.Max(NumButtons, RawButtonCount) to include raw buttons beyond the standard gamepad 11. Buttons 0–10 retain their gamepad-standard names (A, B, X, etc.); buttons 11 and above are labeled "Button N". This ensures devices like DS3 via DsHidMini SDF that report more than 11 raw buttons have all buttons available for mapping.

Force Feedback

private void ApplyForceFeedback(UserDevice ud)

Applies rumble to a physical device based on vibration data from games via HIDMaestro.

Pre-conditions:

  • ud.ForceFeedbackState != null (device has FFB tracking)
  • ud.Device.HasRumble || ud.Device.HasHaptic

Multi-slot vibration combination:

A physical device can map to multiple VC slots. Vibration from all mapped slots is combined via max() per 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 non-empty, only the device with that GUID receives rumble for the slot. Allows the Settings page to test rumble on one device without affecting others.

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

ForceFeedbackState uses SDL_RumbleJoystick (with uint.MaxValue duration + change-detection) 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 ExtendedRawState, MidiRawState, or KbmRawState) via PadSetting mapping descriptors. Contains the mapping engine, deadzone 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, then iterates without the lock. OutputState is a struct, so aligned field writes are atomic.

Error handling: Per-setting try/catch. On exception, OutputState is NOT zeroed. The last valid state is preserved to prevent transient zeros from propagating through Steps 4–6.

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-deadzone snapshot for UI preview) g. Type-specific raw mapping based on SlotControllerTypes[slot]:
    • Custom vJoy: MapInputToExtendedRaw(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)

Core mapping function. Processing 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 maps independently. Otherwise, the combined DPad descriptor 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 negated via NegateAxis() to convert from unsigned pipeline (0=up) to XInput convention (positive Y = up).
  5. Snapshot raw mapped state (rawMapped = gp). Captured before deadzone processing so the UI preview avoids double-processing
  6. Trigger deadzones: ApplyTriggerDeadZone with deadzone, anti-deadzone, max range, and optional sensitivity curve LUT
  7. Center offsets: ApplyCenterOffset(value, offsetPercent). Shifts axis by a percentage of full range. Applied before deadzone. Compensates for stick drift.
  8. Stick deadzones: ApplyDeadZone with full parameter set: deadzone X/Y, anti-deadzone X/Y, linear, max range X/Y (both positive and negative directions independently), sensitivity curve LUT X/Y, deadzone 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 flipped
H Half-axis. Upper half (32768–65535) 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,
    int deadZonePercent = 0, int globalThresholdPercent = 50)
private static bool MapToButtonPressedSingle(CustomInputState state, string descriptor,
    int deadZonePercent = 0, int globalThresholdPercent = 50)

Parameters:

  • deadZonePercent. Per-mapping deadzone (0–100). When greater than zero, overrides the global threshold for this mapping. Enables per-axis activation thresholds on individual mapping rows.
  • globalThresholdPercent. Global AxisToButtonThreshold (default 50%). Used when deadZonePercent is zero.
Source Logic
Button state.Buttons[index]
Axis Per-mapping deadzone if set (deadZonePercent > 0), otherwise global AxisToButtonThreshold (globalThresholdPercent, default 50%). Full-axis: threshold applied over 0–65535. Half-axis: threshold applies within the active half range only (see below).
Slider Same as axis
POV IsPovDirectionActive(state.Povs[index], direction)

Half-axis threshold adjustment: When desc.HalfAxis is true, the threshold percentage applies within the active half range (center-to-edge), not the full 0–65535 range. This correctly maps centered joystick axes where the rest position is at midpoint (32768). The formula differs by direction:

  • Non-inverted (positive half, 32768–65535): threshold = 32768 + 32767 * t where t is the normalized threshold (0.0–1.0). For example, 50% threshold = 49151.
  • Inverted (negative half, 0–32767): threshold = 32767 * (1 - t). For example, 50% threshold = 16383.

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 (135-degree sector including adjacent diagonals). Example: "Up" matches 29250–35999 and 0–6750.
  • Diagonals (UpRight, DownRight, DownLeft, UpLeft): +/-22.5-degree tolerance (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. Otherwise, the combined DPad descriptor reads a single POV hat and sets all 4 direction flags, 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()) 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).

TryParseIntStatic

private static bool TryParseIntStatic(string s, out int result)

Allocation-free integer parser used by ParseDescriptor, MapToButtonPressedSingle, and threshold percentage parsing in Step 3. Avoids int.TryParse heap allocations in the hot path (~1000 calls/s per mapped axis).

Deadzone 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 deadzone shapes, selected via PadSetting.LeftThumbDeadZoneShape / RightThumbDeadZoneShape:

Shape Algorithm Use Case
Axial Independent per-axis deadzone (ApplySingleDeadZone on X and Y separately) Default, simple
Radial Elliptical distance check (nx/dzX)^2 + (ny/dzY)^2 < 1, raw pass-through outside Circular deadzone
ScaledRadial Same elliptical check + rescales magnitude from [dzR, mrR] to [0, 1] Smooth circular with no jump at deadzone 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-deadzone pipeline (per-axis, all shapes):

  1. Sensitivity curve: CurveLut.Lookup(lut, remapped). Transforms [0,1] value through a user-defined response curve
  2. Anti-deadzone: output = adzNorm + remapped * (1.0 - adzNorm). Offsets output minimum so small movements register past the game's internal deadzone
  3. Linear adjustment: output = remapped * linearFactor + output * (1.0 - linearFactor). Blends raw linear and anti-deadzone-adjusted output
  4. Scale and clamp: sign * output * 32767.0, clamped to short range

Independent max range: Each axis has separate positive and negative values. Input sign selects: nx >= 0 ? maxRangeX : maxRangeXNeg. Allows asymmetric stick range (e.g., less travel in one direction).

Trigger Deadzone

private static ushort ApplyTriggerDeadZone(ushort value, double deadZone, double antiDeadZone,
    double maxRange, double[] lut = null)
  1. Normalize to 0.0–1.0
  2. Deadzone: values below threshold zeroed
  3. Max range: cap input ceiling
  4. Remap from [dzNorm, maxNorm] to [0, 1]
  5. Sensitivity curve LUT (if provided)
  6. Anti-deadzone: offset output minimum
  7. Scale to 0–65535 and clamp

Raw Value Extraction

private static int GetRawValue(CustomInputState state, MappingDescriptor desc)

Returns unsigned 0–65535:

Source Value
Axis state.Axis[index]
Slider state.Sliders[index]
Button 65535 (pressed) or 0 (released)
POV PovDirectionToAxisValue. Up/Left = 0, Down/Right = 65535, inactive = 32767

vJoy Custom Mapping

private static ExtendedRawState MapInputToExtendedRaw(CustomInputState state, PadSetting ps,
    ExtendedVirtualController.ExtendedDeviceConfig cfg)

Uses dictionary-based mappings (ps.GetExtendedMapping("ExtendedAxis0"), etc.) instead of fixed gamepad field names. Supports arbitrary axis/button/POV counts from ExtendedDeviceConfig.

  • 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: Direction buttons (ExtendedPov0Up, etc.) mapped to continuous POV values (0–35900 centidegrees, 0xFFFFFFFF = centered) via DirectionToContinuousPov()
  • Deadzones: 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 VC slot into a single combined state. Handles four output types: Gamepad, ExtendedRawState, MidiRawState, and KbmRawState.

Method Signature

private void CombineOutputStates()

Called by: PollingLoop() (every active cycle)

Thread safety: Uses non-allocating FindByPadIndex for zero-allocation lookups. CombinedOutputStates[] is written by this step and read by Steps 4b, 5, 6, and the UI timer. The engine thread is the sole writer; 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: isCustomExtended, 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: MergeExtendedRaw() (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 MergeExtendedRaw(ref ExtendedRawState dest, ref ExtendedRawState 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 and before Step 5. Also contains Windows Core Audio COM interfaces for volume control and 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). UI writes the reference at 30 Hz. Mutable MacroItem state (IsExecuting, CurrentActionIndex, etc.) is only written by the engine thread. The UI thread reads it for display only.

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/PlayStation/Gamepad-preset vJoy/KBM)
    • EvaluateSlotMacrosCustomExtended(ref ExtendedRawState, MacroItem[]) for custom Extended 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 ExtendedRawState.
    • 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, trigger check is skipped and triggerActive = true. 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 (ButtonPress, ButtonRelease, KeyPress, KeyRelease, Delay, AxisSet, MouseButtonPress, MouseButtonRelease): Execute one at a time, advancing via AdvanceAction(macro) when DurationMs elapses.
  • Continuous (SystemVolume, AppVolume, MouseMove, MouseScroll): Run every frame regardless of sequence position. Allows MouseMove X + MouseMove Y in the same macro to 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). The fractional remainder stays in the accumulator for the next frame. Axis source determines direction: LeftStickY/RightStickY map to Y, 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 from the physical device via ReadAxisFromDevice(action) instead of the combined Gamepad. InvertAxis 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 Extended 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).

Feature Detail
Change detection Skips redundant COM calls when delta < 0.4%
OSD trigger Net-zero VK_VOLUME_UP + VK_VOLUME_DOWN pair to show Windows flyout, rate-limited to ~5 Hz
Correction window Corrects for 150 ms after OSD to counteract async VK_VOLUME drift (~2%)
Lazy init COM endpoint created on first call, cached thereafter
Permanent failure If COM init fails, sets _audioEndpointFailed = true and stops trying

Per-App Volume Control

private void SetAppVolume(float volume, string processName)

Enumerates audio sessions via IAudioSessionManager2, identifies by process ID, sets volume via ISimpleAudioVolume. Uses direct vtable calls to bypass QueryInterface limitations from elevated processes. Per-process change detection via _lastAppVolumes (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. VK mapped to scan code via MapVirtualKey(MAPVK_VK_TO_VSC). Multi-key sequences press forward, release in reverse.

Global Macro Evaluation (Profile Shortcuts)

EvaluateGlobalMacros() runs at the start of EvaluateMacros(), before per-slot macro evaluation. It reads SettingsManager.GlobalMacros (a GlobalMacroData[] reference) and checks each entry's trigger combo against all online devices.

Suppression: When SuppressGlobalMacros is true (set during shortcut recording), the method returns immediately. This prevents a shortcut from firing while the user is recording its combo.

Trigger detection uses CheckGlobalMacroTrigger(GlobalMacroData gm), which iterates gm.TriggerEntries[]. A TriggerButtonEntry[] where each entry tracks which physical device it was recorded from. This enables cross-device combos (e.g., Button 0 on a gamepad + a key on a keyboard). Each entry can be either a button (IsAxis = false) or an axis deflection (IsAxis = true) with direction and threshold.

For axis entries, the check normalizes the raw axis value to 0.0–1.0 and compares against the threshold:

  • AxisTriggerDirection.Positive. Fires when normalized >= threshold
  • AxisTriggerDirection.Negative. Fires when normalized <= threshold (inverted sense)

State tracking: gm.WasTriggerActive implements edge detection. The action fires only on the rising edge (triggerActive && !wasTriggerActive).

HandleGlobalMacroAction(GlobalMacroData gm)

Dispatches the global macro action based on gm.SwitchMode:

public enum SwitchProfileMode
{
    Specific,      // Switch to gm.TargetProfileId
    Next,          // Cycle forward through profiles (+1)
    Previous,      // Cycle backward through profiles (-1)
    ToggleWindow   // Show/hide the main window (no profile change)
}
Mode Action
ToggleWindow Sets PendingToggleWindow = true and returns immediately. No profile switch.
Specific Sets PendingProfileSwitchId = gm.TargetProfileId.
Next / Previous Calls GetNextProfileId(±1) to cycle through SettingsManager.Profiles, wrapping around. Sets PendingProfileSwitchId.

Both PendingProfileSwitchId and PendingToggleWindow are volatile fields on InputManager, written by the engine thread and consumed by InputService.UiTimer_Tick on the UI thread. PendingProfileSwitchIsManual is set true alongside profile switches so the foreground monitor treats it as a manual override.


Step 5: VirtualDevices

File: InputManager.Step5.VirtualDevices.cs

Submits combined gamepad states to virtual controllers via HMContext.SubmitState (gamepad path) and HMContext.SubmitRawReport (Sony Report 0x01 passthrough on DS4 / DualSense, plus Extended raw HID), plus MidiVirtualController and KeyboardMouseVirtualController for the non-HM categories. Manages VC lifecycle: creation, destruction, type changes, activity tracking, and the inactivity-destroy + bubble-up cascade documented in HIDMaestro Deep Dive. HM lifecycle (create / destroy) is dispatched to the thread pool so the polling thread does not block on driver IPC.

Method Signature

private void UpdateVirtualDevices()

Called by: PollingLoop() (every active cycle)

Thread safety: SlotControllerTypes[] written by UI at 30 Hz, read at ~1000 Hz. Single-word enum writes are always consistent. ExtendedSyncLock protects vJoy descriptor sync from concurrent SwapSlotData.

Error handling: Pass 3 (report submission) wraps each slot in try/catch. VC creation failures set _createCooldown to prevent per-frame retry. HIDMaestro client failures set _vigemClientFailed = true permanently.

Fields

Field Type Description
_vigemClient static HIDMaestro SDK Shared HIDMaestro client (one per process), lazy-initialized
_vigemClientLock static object Lock for double-checked lazy init
_vigemClientFailed static bool Permanent failure flag. Never retry after HIDMaestro init fails
_virtualControllers IVirtualController[MaxPads] VC instances per slot; null = no VC
SlotControllerTypes VirtualControllerType[MaxPads] Type per slot. UI writes at 30 Hz, Step 5 reads at ~1000 Hz.
SlotExtendedConfigs ExtendedDeviceConfig[MaxPads] Per-slot vJoy HID descriptor config (axes, buttons, POVs)
SlotExtendedIsCustom bool[MaxPads] true = custom HIDMaestro profile (raw pipeline), false = gamepad preset
_midiConfigs MidiSlotConfig[MaxPads] Per-slot MIDI config snapshot
_slotInactiveCounter int[MaxPads] Consecutive inactive cycles per slot
SlotDestroyGraceCycles const int 10000 (~10 s at 1000 Hz before destroying an inactive HM virtual)
_slotInitializing bool[MaxPads] True while a VC is being created/reconfigured. UI reads for the flashing indicator.
_createFailed bool[MaxPads] Sticky flag set when a slot's VC failed to create (e.g. driver missing). Cleared on retry.
_hmInactivityFired bool[MaxPads] Tracks whether the slot's HM virtual has already been torn down by the inactivity grace timer, so the next cycle does not redundantly destroy it.
_pendingDisposeTask Task[MaxPads] Off-polling-thread disposal task for each slot (HM lifecycle is async).
_pendingConnectTask Task[MaxPads] Off-polling-thread Connect task.

The v2 vJoy-era fields (_activeVigemCount, _activeXbox360Count, _activeDs4Count, _expectedXbox360Count, _expectedDs4Count, _vJoySyncCycleCount, ExtendedSyncLock, ExtendedStartupGraceCycles, _createCooldown, CreateCooldownCycles) are gone in v3. HIDMaestro creates and destroys virtual devices dynamically without the vJoy descriptor-count sync that motivated those counters.

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
  • Slot deleted/disabled (!SlotCreated || !SlotEnabled): Destroy immediately, zero vibration
  • Slot active (IsSlotActive): Reset inactive counter, flag anyNeedsCreate if no VC
  • No devices mapped (!HasAnyDeviceMapped): Destroy immediately
  • Device mapped but offline (transient disconnect): Increment _slotInactiveCounter. Destroy after SlotDestroyGraceCycles (10 s). Grace period preserves rumble through brief USB hiccups.

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

  1. Count totalExtendedNeeded: running vJoy VCs + active slots that will create one
  2. Skip removal during startup grace (_vJoySyncCycleCount < ExtendedStartupGraceCycles) to prevent transient zero count from deleting nodes
  3. If count changed: a. Destroy inactive vJoy VCs (active ones re-acquire lower IDs) b. Build HID descriptor configs array indexed by device ID (1-based), not slot:
    • Pass A: Place existing VC configs at their current device ID position
    • Pass B: Fill remaining positions with overflow and new slots c. Call ExtendedVirtualController.EnsureDevicesAvailable(totalExtendedNeeded, deviceConfigs) d. Force existing VCs to ReAcquireIfNeeded() after node restart e. Fix ordering: destroy out-of-sequence VCs for recreation in Pass 2
  4. If count unchanged but config content changed: trigger descriptor rewrite, mark slots as initializing

Pass 1c: Ensure HIDMaestro VC ordering across cycles

HIDMaestro assigns XInput/DS4 indices by Connect() call order. When a lower slot needs a new VC but higher slots already have same-type VCs, the new VC would get a higher index. Fix: destroy same-type VCs at higher slots so they recreate in ascending order 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 ExtendedVirtualController vjoyVc && SlotExtendedIsCustom[padIndex])
    vjoyVc.SubmitRawState(CombinedExtendedRawStates[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: HIDMaestro client required for Xbox and PlayStation (not for vJoy/MIDI/KBM)
  2. For Xbox: Snapshot XInput slot mask BEFORE connecting via GetXInputConnectedSlotMask()
  3. Create concrete controller instance based on SlotControllerTypes[padIndex]:
    • CreateHMaestroController(VirtualControllerType.Xbox, profileId, padIndex) for Xbox slots
    • CreateHMaestroController(VirtualControllerType.PlayStation, profileId, padIndex) for PlayStation slots
    • CreateHMaestroController(VirtualControllerType.Extended, profileId, padIndex) for Extended slots. Resolves the slot's HIDMaestro profile slug via _hmaestroContext.GetProfile(profileId) (falling back to HMaestroProfileCatalog.GetProfileById for synthetic entries like padforge-custom), applies per-slot product-string / layout / FFB overrides through HMProfileBuilder + HidDescriptorBuilder for customized Extended slots, then returns new HMaestroVirtualController(_hmaestroContext, effectiveProfile, type)
    • CreateMidiController(padIndex). Creates virtual MIDI endpoint with computed instance number
    • KeyboardMouseVirtualController(padIndex)
  4. Call vc.Connect()
  5. For Xbox: Spin-wait up to 50 ms for XInput slot to appear (mask delta)
  6. Increment active counters
  7. Register feedback callback: vc.RegisterFeedbackCallback(padIndex, VibrationStates). Wires HIDMaestro's FeedbackReceived to VibrationStates[padIndex]

Virtual Controller Destruction

private void DestroyVirtualController(int padIndex)
  1. For Xbox: Snapshot XInput slot mask
  2. vc.Disconnect()
  3. For Xbox: Wait up to 50 ms for slot to disappear from XInput
  4. vc.Dispose(). Releases native HIDMaestro target (vigem_target_free). Without this, targets leak as phantom USB devices.
  5. In finally: Decrement active counters (Math.Max(0, count - 1)). Must execute even if Disconnect/Dispose throws, otherwise Step 1 filter over-filters.

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" (destroy immediately) from "device temporarily offline" (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 undocumented ordinal #100 (XInputGetStateEx, which reports Guide button unlike the public API). Returns a 4-bit mask. Used only to detect HIDMaestro Xbox virtual controller appear/disappear for slot assignment sync.

Stale HIDMaestro Cleanup

public static void CleanupStaleVigemDevices()

Called before Start(). Enumerates XnaComposite device class via pnputil /enum-devices, identifies HIDMaestro devices by short numeric serial (1–2 digits vs. real controllers' longer serials), removes via pnputil /remove-device /subtree. Cleans up orphaned nodes from crashed sessions.

Pre-Initialize HIDMaestro Counts

public void PreInitializeVigemCounts(int xbox360Count, int ds4Count)

Must be called before Start() so the first UpdateDevices() cycle can filter HIDMaestro devices correctly. Without this, _activeXbox360Count is 0 on the first cycle, causing stale HIDMaestro 045E:028E devices to pass through as "real" Xbox controllers and create a feedback loop.


Step 6: RetrieveOutputStates

File: InputManager.Step6.RetrieveOutputStates.cs

Copies combined gamepad states for UI display. The simplest pipeline step.

Method Signature

private void RetrieveOutputStates()

Called by: PollingLoop() (every active cycle)

Thread safety: Writes RetrievedOutputStates[] and RetrievedKbmRawStates[] (struct copies). UI reads at 30 Hz. Individual field reads are atomic on x64; a full struct read could see mixed old/new fields during a concurrent write, but visual impact is negligible (one frame 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 replaced the original XInput readback (XInputGetStateEx). Direct copy is more universal (works for PlayStation, vJoy, MIDI, KBM. Not just Xbox) and more accurate (no ~1 ms round-trip delay).


Thread Safety Summary

Three concurrent threads:

Thread Role Writes Reads
Engine (PadForge.InputManager, AboveNormal) 6-step pipeline at ~1000 Hz All Combined*States, Retrieved*States, MotionSnapshots, device InputState, VCs MacroSnapshots, SlotControllerTypes, VibrationStates, IsIdle, PollingIntervalMs
UI (WPF Dispatcher, 30 Hz timer) Read output for display, write config MacroSnapshots, SlotControllerTypes, SlotExtendedConfigs, TestRumbleTargetGuid, IsIdle Retrieved*States, CurrentFrequency, device InputState
HIDMaestro callback (Thread pool) Game rumble feedback VibrationStates[padIndex].LeftMotorSpeed/RightMotorSpeed (none)

Synchronization mechanisms:

  • SyncRoot locks on UserDevices/UserSettings for collection access
  • ExtendedSyncLock for vJoy descriptor sync during slot swaps
  • _vigemClientLock for double-checked lazy init
  • volatile on _running/_idle for cross-thread visibility
  • Atomic reference swaps for ud.InputState and MacroSnapshots[i]
  • Struct value copies for Gamepad and small value types (word-aligned, 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 / MapInputToExtendedRaw / MapInputToMidiRaw / MapInputToKbmRaw]
    |     Parse mapping descriptors, apply axis conversions, apply deadzones + curves
    |
    v  per-UserSetting OutputState
Gamepad struct (signed axes, XInput button bitmask, ushort triggers)
  -- or --
ExtendedRawState (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]  /  CombinedExtendedRawStates[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
HIDMaestro Xbox / PlayStation                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()  ->  HIDMaestro 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();
}

ExtendedRawState Struct

public struct ExtendedRawState
{
    public short[] Axes;     // Signed short range, up to 8 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 ExtendedRawState 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,       // HIDMaestro Xbox 360 (appears in XInput stack)
    DualShock4 = 1,    // HIDMaestro DualShock 4 (appears in DirectInput)
    Extended = 2,          // vJoy virtual joystick (HID, appears in joy.cpl)
    Midi = 3,          // Windows MIDI Services virtual endpoint
    KeyboardMouse = 4  // Win32 SendInput keyboard + mouse
}

See Also

Clone this wiki locally