Skip to content

Input Pipeline

hifihedgehog edited this page Jun 19, 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[OutputReceived<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 nine 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.Step3.MappingSetEval.cs Step 3 MappingSet evaluator (multi-source row resolve, combine modes, formula eval, shift-layer dispatch)
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 HID state for Extended Custom-profile slots
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: HMController.OutputReceived (via IVirtualController.RegisterFeedbackCallback) 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 per-slot data arrays and VC instances between two slots. Used by EnsureTypeGroupOrder bubble sort on the UI thread. Unlike SwapSlots, this swap also moves _slotInactiveCounter, _slotInitializing, VibrationStates, all Combined*States arrays, and _midiConfigs, so Step 5 sees no type mismatch and avoids needless destroy/recreate cycles that would cause phantom Xbox controllers.

After swapping, updates FeedbackPadIndex directly on each surviving HMaestroVirtualController so the rumble callback's vibrationStates[] write target follows the slot.

Thread safety: Single-word writes for the type/enabled state are torn-write-safe on x64. Atomic array reference swaps happen before any pointer-array fix-up.

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: 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 1e: Enumerate MIDI inputs via UpdateMidiInputDevices()

Windows MIDI Services endpoints become input devices. Enumeration is async (the WinRT device query is expensive and is kept off the poll loop) and gated on Windows MIDI Services being present. Each endpoint becomes a MidiInputDevice and runs through FindOrCreateUserDevice, LoadFromExternalDevice, and IsOnline = true, the same as any other source. The device exposes no gamepad axes or buttons. Its mappable surface is the MIDI namespace in CustomInputState.Midi. See MIDI Input Internals.

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. Two code paths:

    if (ud.IsTouchpad && ud.Device == null && _ptpReader != null && _ptpReader.IsAvailable)
    {
        // Windows Precision Touchpad — no SDL wrapper.
        newState = new CustomInputState();
        if (ud.InstanceGuid == PtpMergedGuid)
            _ptpReader.ReadInto(newState);
        else
        {
            IntPtr ptpHandle = FindPtpHandle(ud.InstanceGuid);
            if (ptpHandle != IntPtr.Zero)
                _ptpReader.ReadInto(ptpHandle, newState);
        }
    }
    else if (ud.Device != null)
    {
        // SDL gamepad / joystick / keyboard / mouse / overlay / web client.
        newState = ud.Device.GetCurrentState(ud.ForceRawJoystickMode);
    }

    For SDL devices, 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. For PTP devices, _ptpReader.ReadInto allocates state.Touchpads[0] if absent and copies the in-progress committed frame state. See Engine Library for the reader's tip-switch, multi-report frame assembly, and HID-contact-id-stable slot assignment. c. Atomic reference swap: ud.InputState = newState (thread-safe for UI readers) d. Set ud.InputStateTime = DateTime.UtcNow e. Drive the touchpad gesture engine: UpdateGestureContexts(ud, newState) ticks the per-(slot, device, padIdx) recognizer for every slot the device is assigned to. See Touchpad for the per-slot fan-out semantics. 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: ApplyForceFeedback(ud) early-routes by source-pad VID/PID before any SDL call. Sony pads (DualShock 4 / DualSense) get skipped here entirely. UserEffectsDispatcher is the sole writer of Sony output packets (rumble + lightbar + adaptive triggers + mic LED) and runs on its own per-device tick. Xbox One+ pads (Xbox One / Elite / Series) are diverted to XboxImpulseHidWriter.Write which writes the raw HID output report (9-byte BT or 13-byte GIP) directly. SDL rumble is also skipped on this family. Logitech, Fanatec, and Thrustmaster wheels and pedals (gated by IsLogitechWheel / IsFanatecWheel / IsThrustmasterWheel / IsFanatecPedal) are diverted to their native vendor writers, which re-encode the decoded force into each vendor's own HID protocol and drive rotation range, auto-center, and RPM LEDs. See Wheel Force Feedback.

Everything else falls through to the standard scratch-vibration handoff:

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

ForceFeedbackState.SetDeviceForces then picks SDL_RumbleJoystick (with uint.MaxValue duration + change-detection) for the scalar-rumble path, or falls back to SDL haptic effects (LeftRight > Sine > Constant) for devices without native rumble. The directional-haptic branch handles HID PID joysticks / wheels.


Trigger Rumble Routing

What this section covers: how a slot's main-motor rumble (the left/right vibration a game sends through XInput) gets copied or moved onto the two trigger feedback channels, Xbox impulse triggers and the DualSense adaptive-trigger (AT) Vibration, per issue #102.

Routing sits on the force-feedback write path, not the input-mapping path. It reads the same per-slot main-motor amplitudes Step 2's Force Feedback already resolved and injects a derived value into the trigger output. The math lives in InputManager.cs (UpdateTriggerRouteEngageStates, ParseRouteSource, RouteSideActive, ParseRouteScale, ApplyTriggerRouting, RouteMain, MarkRedirect, SettleRouteActivator, ApplyTriggerRoutingForSony, GetTriggerRouteMainRedirect). The Xbox physical write applies it in InputManager.Step2.UpdateInputStates.cs. The Sony (DS4 / DualSense) write applies it through InputService.SlotImpulseTriggerForDeviceProvider, which feeds UserEffectsDispatcher.

State settles once per poll. PollingLoop() calls UpdateTriggerRouteEngageStates() at line 940, right after UpdateInputStates() and UpdateGyroEngageStates(). Step 2's FFB write therefore consumes the engaged bits the previous poll settled. At 1000 Hz that is sub-millisecond staleness.

Route Source

internal static byte ParseRouteSource(string s) => s switch
{
    "MainLeft" => 1, "MainRight" => 2, "MaxOfBoth" => 3, "SumOfBoth" => 4, _ => 0,
};

The per-trigger source string parses to a byte that RouteMain switches on:

Byte Source Amplitude fed to the trigger
0 None Nothing routed (impulse-only behavior preserved)
1 MainLeft Left main motor
2 MainRight Right main motor
3 MaxOfBoth Math.Max(mainL, mainR)
4 SumOfBoth Math.Min(mainL + mainR, 65535)

Side-Active Gate

internal static bool RouteSideActive(string source, string mode)
    => ParseRouteSource(source) != 0 && mode != "Off";

A trigger's routing is live only when its source is not None and its mode is not Off. Source None and mode Off are two separate off switches (the UI exposes both), and either one disables the side. Modes:

Mode Effect
Off Routing disabled for the side
Duplicate (default) Main motor keeps spinning on the physical device and the trigger gets a copy
Redirect Main motor is silenced on the physical device, its energy moves to the trigger

_routeRedirectLeft[slot] / _routeRedirectRight[slot] cache mode == "Redirect" for the write path.

Scale

private static double ParseRouteScale(string s)
    => System.Math.Clamp(int.TryParse(s, out int v) ? v : 100, 0, 200) / 100.0;

The per-trigger Scale slider is an integer percent string in 0..200, parsed to a 0.0..2.0 multiplier. Unparseable or out-of-range values clamp into the band. Default "100" maps to 1.0.

Per-Tick Settle: UpdateTriggerRouteEngageStates

private void UpdateTriggerRouteEngageStates()

Runs once per poll across all 16 slots (InputManager.cs ~1167–1234). For each created slot:

  1. Under UserSettings.SyncRoot, pick the first UserSetting mapped to the slot whose left or right side passes RouteSideActive. A slot with no active route source clears TriggerRouteEngagedLeft/Right[slot], the edge-detection scratch (_prevTriggerRouteLeftDown/RightDown), and _routeSourceLeft/Right[slot], then continues.
  2. Resolve and cache the per-side source byte (ParseRouteSource, zeroed when that side fails RouteSideActive), scale (ParseRouteScale into _routeScaleLeft/Right), and Redirect flag.
  3. Settle each side's activator with SettleRouteActivator, then AND it with the source-active flag: TriggerRouteEngagedLeft[slot] = srcL && leftSettled. The activator is settled unconditionally (its edge state must advance even when the source is None) and gated afterward.

TriggerRouteEngagedLeft / TriggerRouteEngagedRight are volatile bool[MaxPads].

Activator: SettleRouteActivator

private static bool SettleRouteActivator(int slot, string descriptor, string deviceGuid,
    string mode, bool[] prevDown, bool curEngaged, out bool buttonDown)

Reads the activator's held state cross-device through SourceCoercion.ButtonHeldProvider(deviceGuid, descriptor, slot), the same picker Gyro Aim Engage uses. Mode behavior:

Activator mode Engaged when
Hold (default) Descriptor empty (always on) or the button is held
Toggle Sticky bit flips on each rising edge (buttonDown && !prevDown[slot])
AlwaysOn Always engaged, descriptor ignored

ResetTriggerRouteEngageStates() clears the engaged bits and edge scratch on profile switch / settings reload so a new profile's Toggle activator does not inherit the prior profile's sticky state. It mirrors ResetGyroEngageStates().

Injection: ApplyTriggerRouting / RouteMain / MarkRedirect

private void ApplyTriggerRouting(int slot, ushort mainL, ushort mainR,
    out ushort routedLeft, out ushort routedRight, out bool zeroMainL, out bool zeroMainR)

Given a slot's post-gain main-motor amplitudes, it emits the routed trigger amplitudes plus flags for which main motors to silence (InputManager.cs ~1264–1301). For each engaged side it calls RouteMain(source, scale, mainL, mainR) and, when Redirect is set, MarkRedirect:

private static ushort RouteMain(byte source, double scale, ushort mainL, ushort mainR)
{
    int v = source switch
    {
        1 => mainL, 2 => mainR,
        3 => System.Math.Max(mainL, mainR),
        4 => System.Math.Min(mainL + mainR, 65535),
        _ => 0,
    };
    if (v <= 0 || scale <= 0) return 0;
    return (ushort)System.Math.Clamp((long)System.Math.Round(v * scale), 0, 65535);
}

private static void MarkRedirect(byte source, ref bool zeroL, ref bool zeroR)
{
    if (source == 1 || source >= 3) zeroL = true;   // MainLeft, Max, Sum
    if (source == 2 || source >= 3) zeroR = true;   // MainRight, Max, Sum
}

The routed value is computed from the pre-redirect main motor. Redirect moves the energy to the trigger rather than dropping it: the caller zeroes the physical main motor only after RouteMain has already read its amplitude.

After the routed value, the macro trigger override is max-combined in:

MacroTriggerRumbleOverrides[slot].ComputeMotors(out ushort macroLT, out ushort macroRT);
if (macroLT > routedLeft) routedLeft = macroLT;
if (macroRT > routedRight) routedRight = macroRT;

MacroTriggerRumbleOverrides[slot] (a MacroRumbleOverride, populated by the Rumble Trigger Override macro action in Step 4b) is independent of the route activator, so it contributes even when both routing sides are disengaged. It max-combines the same way MacroRumbleOverride layers onto the main motors.

Xbox Physical Write

In Step 2's physical-write path (InputManager.Step2.UpdateInputStates.cs ~429–449), after the main and impulse amplitudes are scaled per device:

ApplyTriggerRouting(padIndex, scaledL, scaledR,
    out ushort routedLT, out ushort routedRT,
    out bool zeroMainL, out bool zeroMainR);
if (zeroMainL) scaledL = 0;
if (zeroMainR) scaledR = 0;
// ... main + impulse max-combine ...
if (routedLT > combinedLT) combinedLT = routedLT;   // routed layers onto the
if (routedRT > combinedRT) combinedRT = routedRT;    // impulse-trigger output

The routed amplitude layers onto the impulse-trigger output via max(), and the Redirect flags silence the physical main motors. A second call at ~984–988 mirrors the same math for the Force Feedback tab's motor meter, so the meter reflects what the Scale slider is being tuned against.

Sony Write

DS4 / DualSense output is the sole domain of UserEffectsDispatcher, which runs on its own per-device dispatcher thread. Two InputManager entry points serve it:

internal void ApplyTriggerRoutingForSony(int slot, PadSetting devicePs, Vibration raw,
    Vibration macroScratch, Vibration cfScratch, ref ushort triggerL, ref ushort triggerR)

internal void GetTriggerRouteMainRedirect(int slot, out bool zeroMainL, out bool zeroMainR)

ApplyTriggerRoutingForSony (InputManager.cs ~1319–1331) takes caller-owned scratch Vibration instances to stay off the input thread's buffers. It rebuilds the main-motor amplitude the same way the Sony main-rumble provider does (MacroRumbleOverride.Merge -> ConstantForceEvaluator.Resolve -> ScaleRumbleForDevice), runs ApplyTriggerRouting, and max-combines the routed amplitudes into the caller's triggerL / triggerR. GetTriggerRouteMainRedirect reports whether engaged Redirect routing should silence each physical DualSense main motor. The game-facing virtual-controller state is left untouched.

The dispatcher reaches these through InputService.SlotImpulseTriggerForDeviceProvider (InputService.cs ~566–617). That provider deliberately carries no output-VC gate:

UserEffectsDispatcher.SlotImpulseTriggerForDeviceProvider = (padIndex, deviceGuid) =>
{
    // ... resolve devicePs for (padIndex, deviceGuid) ...
    var effective = ConstantTriggerForceEvaluator.Resolve(raw, devicePs, _constantTriggerForceScratchSony);
    _inputManager.ScaleTriggerRumbleForDevice(
        effective.LeftTriggerMotorSpeed, effective.RightTriggerMotorSpeed,
        devicePs, out ushort scaledL, out ushort scaledR);
    _inputManager.ApplyTriggerRoutingForSony(padIndex, devicePs, raw,
        _routeMainScratchSony, _routeCfScratchSony, ref scaledL, ref scaledR);
    return ((byte)(scaledR >> 8), (byte)(scaledL >> 8));   // high byte, right then left
};

Game-written impulse triggers only ever arrive on Xbox-class VCs, so raw.*TriggerMotorSpeed is zero for a slot running a DualShock 4 / DualSense / generic VC. The main-motor -> trigger routing and the macro override, on the other hand, source from the main motor that every VC type drives. Omitting the gate is what lets them reach a physical DualSense's AT Vibration regardless of the slot's output VC type. The provider returns the high byte of each scaled ushort, right channel first.

One asymmetry: a DualSense's AT Vibration only carries a game's own impulse-trigger feedback when the slot runs an Xbox-class VC. Main-motor routing and the macro override reach it on any VC type.

PadSetting Fields

Twelve string fields on PadSetting back the feature (PadSetting.cs ~384–425), all serialized as [XmlElement], included in ComputeChecksum, and listed in the dirty-tracking allowlist:

Field (Left / Right) Default Meaning
*TriggerRouteSource None Route source enum string
*TriggerRouteMode Duplicate Off / Duplicate / Redirect
*TriggerRouteScale 100 Scale percent (0..200)
*TriggerRouteActivator "" Activator descriptor (empty = always on)
*TriggerRouteActivatorDeviceGuid "" Device the activator reads from
*TriggerRouteActivatorMode Hold Hold / Toggle / AlwaysOn

Both sides being persisted means a per-pad route survives only if it sits in both ComputeChecksum and the MarkDirty allowlist. See Settings and Serialization for the dirty-gate mechanism.

Hardware test status: the routed-rumble path (Xbox impulse triggers and DualSense AT Vibration) is hypothesis-under-test. It has not been verified on physical hardware. See Force Feedback for the trigger-feedback channels it writes into.


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.

Companion file (v3.2): InputManager.Step3.MappingSetEval.cs runs ahead of the per-device pass on slots that carry a MappingSet. It resolves each row's multiple sources against the active shift layer, applies the selected combine mode (Strongest, Combined, Average, Either, Both, Only one, or a Custom formula), and writes a synthesized PadSetting snapshot that the per-device pass below then consumes through the same MapInputToGamepad path. Slots without a MappingSet skip the evaluator and fall straight into the per-device pass.

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]:
    • Extended Custom HID: MapInputToExtendedRaw(ud.InputState, ps, SlotCustomLayouts[slot], mappingSet, deviceGuid, slot) using dictionary-based descriptor 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).

Source Kinds and the Ramped Envelope

How SourceEvaluator dispatches each source by its Kind before the combine layer merges them, and the time-based Ramped axis envelope added in #111.

Every source on a MappingRow carries a Kind discriminator (MappingSource.Kind, default "Direct"). The Step 3 combine layer in InputManager.Step3.MappingSetEval.cs does not read a source's raw value itself. It calls SourceEvaluator (PadForge.Engine/Common/Mapping/SourceEvaluator.cs) once per source, per row, per frame, and SourceEvaluator switches on Kind to produce the per-source contribution that the row's combine mode then folds together.

Three target-shaped entry points. The target's output class picks the method, so each kind returns a value already shaped for the destination:

Method Return Used by row targets
EvaluateForButtonTarget bool Buttons, D-pad directions, POV directions
EvaluateForBipolarAxisTarget float in [-1, +1] Thumbstick axes, extended bipolar axes, KBM mouse/scroll
EvaluateForTriggerTarget float in [0, 1] Triggers, unipolar extended axes

Kind dispatch. src.Kind ?? "Direct" selects the branch. Unknown values fall through to Direct (forward-compatible).

Kind Evaluation
Direct Delegates straight to SourceCoercion.EvaluateFor*Target. No per-frame state.
Incremental SourceKindRuntime.TickIncremental accumulator. ParamUp/ParamDown ramp a value at ParamRate units/s between ParamMin and ParamMax. ParamSticky holds vs. snaps to ParamMin on release.
InvertOnHold CloneAsDirect rebuilds the source as Direct with Invert XOR'd against the live state of the ParamModifier button (ReadButtonLikeBool), then runs it through SourceCoercion. Stateless.
WindingStick, AngleToAxisX, AngleToAxisY, MotionLeanX Steering kinds: read a whole 2D stick (or gravity) and project to one channel. See Steering Source Kinds below and Steering.
Ramped SourceKindRuntime.TickRamped time-based bipolar envelope (#111). Detailed below.

A Direct source whose descriptor is the "Motion Lean" input (matched by SourceCoercion.IsMotionLeanDescriptor) is promoted to MotionLeanX inside EvaluateForBipolarAxisTarget, so the lean descriptor routes through the steering math regardless of the row's stamped Kind.

TickRamped: the ramped axis envelope (#111)

SourceKindRuntime.TickRamped (PadForge.Engine/Common/Mapping/SourceKindRuntime.cs, ~lines 165-228) maintains a signed [-1, +1] envelope per source. It models a keyboard-to-axis throttle: two keys drive a value that ramps over time instead of snapping.

State lives in _rampedAccum, a Dictionary<(int slot, string target, int srcIdx), double> keyed the same way as the Incremental accumulator (_incrementalAccum). Two Ramped sources on one row keep independent envelopes because srcIdx differs. Each frame:

  1. Read intent buttons: up = ReadButtonLikeBool(state, src.ParamUp) (positive direction), down = ReadButtonLikeBool(state, src.ParamDown) (negative direction). Only Button N and POV N Dir descriptors read as true. Analog inputs are not a sensible up/down trigger.
  2. Compute per-tick fractions of full travel: attackStep = dt / ParamAttackTime and releaseStep = dt / ParamReleaseTime. A time of 0 means instant (step = 1.0).
  3. Drive the envelope:
    • up only. If the value is still on the negative side (v < 0), return toward zero at the release rate first, then attack +1 once it crosses zero. Otherwise attack +1 at attackStep.
    • down only. Mirror image: cross back through zero from the positive side, then attack -1.
    • neither (or both) held, ParamAutocenter == true. Ramp back toward zero at releaseStep.
    • neither held, ParamAutocenter == false. Cruise: hold the last value.
  4. Clamp to [-1, +1], store, return.

Reverse speed-up. When the opposite key is pressed while the value is still on the original side, the toward-zero step is multiplied by ParamReverseMultiplier (clamped to >= 1), but only when ParamAutocenter is on. With autocenter off the reverse uses the plain release rate. This is the src.ParamAutocenter ? rev : 1.0 factor on the cross-zero branches.

Ramps are linear. The FreePIE center_reduction curvature shaping referenced in the issue is out of scope.

Per-target folding. The same envelope is read three ways depending on the target:

Target method Ramped handling
EvaluateForButtonTarget Returns false unconditionally. A bipolar envelope has no defensible boolean reading, and picking a threshold would surprise the author.
EvaluateForTriggerTarget Folds to [0, 1]: negative values clamp to 0, so the negative-direction key reads as a released trigger and only the positive key drives it. No Invert applied.
EvaluateForBipolarAxisTarget Returns the full signed value, negated when src.Invert is set.

Ramped fields on MappingSource

Field Default Meaning
Kind "Direct" Set to "Ramped" to select the envelope
ParamUp "" Positive-direction key descriptor (attacks toward +1)
ParamDown "" Negative-direction key descriptor (attacks toward -1)
ParamAttackTime 0.30 Seconds to travel 0 to ±1 while the matching key is held (0 = instant)
ParamReleaseTime 0.30 Seconds to travel ±1 back to 0 after release (and the base reverse rate)
ParamReverseMultiplier 4.0 Toward-zero step multiplier on a direction switch (gated on autocenter, min 1)
ParamAutocenter true true releases back to zero. false cruises (holds the last value)

_rampedAccum is dropped by SourceKindRuntime.Clear() on profile switch and engine stop, so a ramped axis snaps to neutral on the next read after either event. It is not reset on row reorder. Like Incremental, the accumulator survives by Target + srcIdx and lingers harmlessly until the next Clear.

UI surface

MappingSourceItem.cs (the MappingSourceItem ViewModel) exposes Ramped in the Kind dropdown via KindOptions (label Pad_Mapping_Kind_Ramped). IsRampedKind and UsesUpDownKeys (true for both Incremental and Ramped) gate the Up/Down key pickers. The envelope controls bind to ParamAttackTime (UI slider 0-2 s, clamped 0-5), ParamReleaseTime, ParamReverseMultiplier (1-10), and ParamAutocenter. Because a stateful kind is keyed by (slot, target, srcIdx) and needs a concrete DeviceGuid to avoid being ticked once per assigned device on a multi-device slot, StampDeviceFromParamChoice stamps the source's device from the picked Up/Down input when it has none (#111 audit fix A).

Steering Source Kinds

A bipolar-axis row whose source carries a steering Kind (WindingStick, AngleToAxisX, AngleToAxisY, MotionLeanX) is evaluated by SourceKindRuntime instead of read directly. The source reads the whole 2D stick (X from Descriptor, Y from ParamYDescriptor) or, for MotionLeanX, gravity from GravityProvider, and projects to the row's virtual-stick channel:

  • WindingStick accumulates signed angular travel (atan2 delta × deflection) into a per-row winding angle, unwinds it below full deflection, and remaps |angle| / range × 2 raised to Wind Power to the output. The accumulator is unclamped, so an overwind holds lock until it unwinds back through the overshoot.
  • AngleToAxisX / AngleToAxisY project the stick's half-plane angle (atan2(x, |y|) or atan2(y, |x|)) through the inner/outer angle deadzones, scaled by deflection. No state.
  • MotionLeanX derives a lean angle from the gravity vector and the controller orientation (asin of the side component), through the lean deadzones.

Each tick also updates a per-row at-lock state machine (Enter/Exit edges + saturation magnitude). After the bipolar writes, InputManager.Step3.SteeringLockFeedback reads those edges and fires the opt-in feedback channels (rumble, impulse, lightbar, adaptive-trigger resistance). See Steering.

The steering math is original C# written from the geometry described in JoyShockMapper (src/JoyShock.cpp, src/main.cpp). No GPL code is incorporated.

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

Extended Custom HID Mapping

private static ExtendedRawState MapInputToExtendedRaw(CustomInputState state, PadSetting ps,
    CustomControllerLayout cfg,
    MappingSet mappingSet, string thisDeviceGuid, int slotIndex)

Uses dictionary-based mappings (ps.GetExtendedMapping("ExtendedAxis0"), etc.) instead of fixed gamepad field names. Supports arbitrary axis/button/POV counts from CustomControllerLayout. The trailing mappingSet / thisDeviceGuid / slotIndex arguments hand the v3.2 MappingSet evaluator the context it needs to resolve multi-source rows that target Extended channels.

  • 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

Mouse Cursor as a Mapping Source

How the desktop cursor position becomes a [-1..+1] mapping source (#107): a 200 Hz App-side sampler publishes the normalized position, and the engine reads it per row through SourceCoercion.

"Mouse Position X" / "Mouse Position Y" are absolute-position sources, not the relative Mouse Speed X/Y motion deltas a mouse already exposes. They read the desktop cursor's distance from the primary-monitor center, normalized so a stick target tracks the cursor. The feature splits across two layers: CursorControlService (App) samples and publishes, SourceCoercion (Engine) reads and tunes. They communicate through one static delegate hook, SourceCoercion.MouseCursorProvider, with no engine dependency on the App's Win32 code.

CursorControlService (the sampler)

File: PadForge.App/Services/CursorControlService.cs

A single System.Threading.Timer ticks every SampleIntervalMs = 5 (200 Hz). Each Tick():

  1. Resolves the primary monitor via TryGetPrimaryRect: MonitorFromPoint((0,0), MONITOR_DEFAULTTOPRIMARY) then GetMonitorInfo, returning rcMonitor. Re-queried every tick, so a resolution change is picked up on the next sample with no WM_DISPLAYCHANGE hook. Returns early if the monitor or a width <= 0 can't be resolved (the previously published sample stays).
  2. Enforces the cursor-write contracts (EnforcePin, EnforceClamp) before sampling, so the published value reflects the post-write position.
  3. Samples the cursor with GetCursorPos.
  4. Normalizes to [-1..+1] and publishes.

Normalization. Center is the monitor-rect midpoint. The divisor is div = w / 10f where w is the monitor width, used on both axes:

_normX = (p.X - centerX) / div;
_normY = (p.Y - centerY) / div;   // same width/10 divisor, not height

So sensitivity 1.0 reaches full deflection at 10% of screen width from center, and the vertical full-deflection distance equals that same pixel span (10% of width, not 10% of height). The published value is unclamped. A cursor near the edge or on a secondary monitor reads past ±1 and pins at the boundary only after the engine-side clamp.

DPI. The app declares PerMonitorV2 awareness in app.manifest, so GetCursorPos and GetMonitorInfo both return physical pixels. The normalization is straight pixel arithmetic with no DPI conversion, and it stays correct on a scaled primary monitor.

Lock-free publish. The sample is two independent volatile float fields, _normX and _normY, not a struct or tuple. A reader that catches a torn pair (X from tick N, Y from tick N−1) sees at worst one stale axis for one 5 ms tick. The axes are independent, so this is acceptable and avoids a lock on the read path.

Lifecycle and the provider hook. InputService owns the instance: it constructs CursorControlService when the engine starts and disposes it on stop.

Step Action
Constructor Sets the static Active = this, wires SourceCoercion.MouseCursorProvider = () => (_normX, _normY), starts the timer (due time 0, period 5 ms)
Dispose Sets _disposed, clears Active (only if it is this instance), sets MouseCursorProvider = null, disposes the timer

While no service is alive the provider is null, and every engine-side reader returns 0 (center).

Cursor-write ownership (pin / clamp / recenter)

The same service owns the cursor-write macro actions (#108 recenter, #109 pin, #110 region clamp), so reads and writes run on the one 200 Hz thread and cannot race. Each write entry point is invoked from a Step 4b macro action through the static Active instance:

Macro action (MacroActionType) Service method Behavior
MouseRecenter RecenterCursor(centerX, centerY) Fires once per press. SetCursorPos snaps the selected axes to primary-monitor center. An unselected axis keeps its current coordinate
MouseFixPosition TogglePin(mode, x, y) Sticky toggle. While engaged, EnforcePin writes the cursor to the pin target on the pinned axes each tick before sampling
MouseLimitRegion ToggleClamp(mode, insetX, insetY) Sticky toggle. While engaged, EnforceClamp keeps the cursor inside the per-edge inset rectangle on the clamped axes, writing only when an axis is outside

EnforcePin and EnforceClamp run at the top of Tick(), so the next published sample already reflects the write. A pinned axis reports its pin coordinate, a clamped axis reports a boundary value. The _isPinned / _isClamped enable flags are volatile bool. The mode and coordinate fields are published before the flag is set true, so a tick that observes the flag also observes a consistent config (release-on-write, acquire-on-read on the bool). See Step 4b: EvaluateMacros for the macro state machine that calls these.

Engine read side (SourceCoercion)

File: PadForge.Engine/Common/Mapping/SourceCoercion.cs

A "Mouse Position" descriptor is a first-class MappingSource like any other, resolved through the same multi-source row machinery as Step 3's MappingSet evaluator (combine modes, custom formulas, shift layers). Three pieces wire it in:

  • Classification. ClassifyDescriptor returns SourceType.MouseCursor for any descriptor starting with "Mouse Position ". The check sits after the Gyro check and before Midi so the prefix ordering is unambiguous.
  • Predicate. IsMouseCursorDescriptor(descriptor) is true for "Mouse Position X" / "Mouse Position Y". It drives both the per-source Mouse Cursor Sensitivity slider's UI visibility and the reader-branch dispatch.
  • Reader. ReadTunedMouseCursor(MappingSource src):
var (normX, normY) = MouseCursorProvider();          // (0,0) when unwired
float baseVal = descriptorEndsWith(" X") ? normX
              : descriptorEndsWith(" Y") ? normY : 0f;
float v = baseVal * (float)src.MouseCursorSensitivity; // per-source multiplier
return Clamp(v, -1f, +1f);

MappingSource.MouseCursorSensitivity is a per-source double (default 1.0, stored as an XML attribute). Invert is not applied here. The public Evaluate* wrappers apply it, matching the gyro and generic-axis paths.

The three internal readers dispatch to ReadTunedMouseCursor per target class:

Reader (target class) Mouse Position handling
ReadAsBipolar (stick / bipolar axis) Returns ReadTunedMouseCursor(src) directly. EvaluateForBipolarAxisTarget then negates for Invert
ReadAsUnipolar (trigger) Returns Math.Abs(ReadTunedMouseCursor(src)). EvaluateForTriggerTarget applies 1 - raw for Invert
ReadAsBool (button / D-pad) Fires when Math.Abs(ReadTunedMouseCursor(src)) clears the per-source DeadZone, or the global activation threshold when no per-source deadzone is set (> Max(deadZone, 1) / 100)

This matches the gyro source, which is read by its own tuned reader (ReadTunedGyroRate) rather than the generic axis path. The Sticks-tab live preview mirrors the same math in InputService.MouseCursorStickValue (component select, per-source sensitivity, clamp, Invert, Y-negate) so the preview tracks the cursor without re-running the per-slot multi-source dedup.

Status: the cursor → stick runtime is hypothesis-under-test. The sampler, normalization, and reader paths are verified against the code, but the end-to-end cursor-to-virtual-stick behavior has not been validated in a live game.


Shift Layer Activators and the Cycle Cursor

How a slot's MappingSet decides which shift layer is active each frame, and how the #119 Cycle cursor walks a queue of layers.

This is the Step 3 companion path. At the start of each per-device pass, ApplyMappingSetToGamepad (in InputManager.Step3.MappingSetEval.cs) calls ResolveActiveLayerMask to pick the layer mask in force for this slot and device, then rows whose LayerMask does not match are skipped. The activator configuration is static data in MappingSet.ShiftActivators. The engaged/latched/cursor state is per-slot runtime that resets on launch, profile switch, and slot-index compaction. See Shift Layers for the user-facing configuration.

The ShiftActivator DTO

File: PadForge.Engine/Data/ShiftActivator.cs

A MappingSet carries a list of ShiftActivator objects, one per layer. Each activator names the layer it engages via LayerMask (default "Shift") and the input that engages it (DeviceGuid + Descriptor). DeviceGuid may differ from the device the gated sources live on, so cross-device activation is allowed. LayerName is the display label, defaulting to LayerMask on creation but editable independently (e.g. LayerMask="Shift1", LayerName="Pit Stop").

Field Default Purpose
DeviceGuid / Descriptor "" Device + input that owns the activator. Empty Descriptor = input-less Passive layer
Mode "Hold" Hold / Toggle / Custom (Latch) / Cycle / Sticky / Passive (No-Button)
LayerMask "Shift" Layer this activator engages, matched against each MappingRow.LayerMask
LayerName "" Display name on the layer tab
InheritUnmapped false false = layer REPLACES Base. true = overlay-with-fallthrough (see below)
Kind "Button" Button / Chord / Axis read mode (v2)
ChordSecondDeviceGuid / ChordSecondDescriptor "" Second half of a Chord activator (cross-device allowed)
AxisThreshold 0.5 Axis kind engages when `
DelayMs 0 Hold-to-engage debounce. The input must stay down this long before the layer changes
PostponeMapping false true lets the activator's own source row fire alongside the layer change
JumpToLayer / Color "" Legacy v2 jump target (now unused) and per-layer tab color
CycleLayers "" Pipe-separated queue of layer masks for Cycle mode ("Shift1|Shift2|Shift3")
CyclePrevDeviceGuid / CyclePrevDescriptor "" The Previous button for Cycle mode (cross-device allowed)
CycleWrap true Cursor loops the ends together vs clamps
CycleIncludeBase false Whether Base is a stop in the rotation (see ShiftCycleStepper)
Icon "" Single-grapheme glyph on the engaged-layer overlay. Empty falls back to

Overlay vs replace (InheritUnmapped). When a non-Base layer is active, the default (false) is REPLACE: only rows on that layer fire and every target the layer does not map outputs zero/false. Setting InheritUnmapped = true switches to overlay-with-fallthrough, so Base rows fall through for any target the active layer does not cover. In ApplyMappingSetToGamepad, "cover" means a matching-mask row that has at least one source or carries an explicit MappingRow.NoInherit flag. These covered targets are collected into a shiftCoveredTargets set each frame, and a Base row whose target is in that set is skipped. A matching-mask row with zero sources and NoInherit = false is transparent, so an author can write an "intentionally inherit" row without source data.

Per-slot runtime state (ShiftRuntime)

The activator latch state does not live on the DTO. InputManager.Step3.MappingSetEval.cs holds a private static readonly ShiftRuntime[] _shiftRuntime = new ShiftRuntime[MaxPads], one ShiftRuntime per VC slot, allocated lazily and sized to the activator count via EnsureSize.

ShiftRuntime field Meaning
WasDown[i] Previous-frame down latch for activator i (also the Next-button latch in Cycle)
ToggleOn[i] Toggle-mode engaged flag
EngageStartTicks[i] Tick when the input went down, for the DelayMs debounce
Stack (List<int>) Engaged-activator stack. Tail = most-recently-engaged (last-engaged-wins)
CustomLayer Single-valued override set by Latch and Cycle. Non-empty wins over Stack
CycleIndex[i] The shared Cycle cursor: 0 = Base, 1..N index CycleLayers
CyclePrevWasDown[i] Previous-button down latch (Next reuses WasDown)
CycleLayersSplit[i] / CycleLayersSource[i] Cached split of CycleLayers, recomputed only when the source string changes (zero-alloc tick)
StickyEngaged[i] / StickyConsumerActive[i] / StickyBaselines[i] Sticky engagement flag, consumer-held latch, and the cross-device engage-time snapshot
SyncRoot Per-instance lock guarding Stack, CustomLayer, and CycleIndex against UI-thread reads

SyncRoot exists because the UI thread reads the live layer through GetEngagedLayerMask (used by the v3 visual overlay) while the polling thread mutates Stack / CustomLayer. ClearAllShiftRuntime (called from InputService.ApplyProfile and CompactSlotsForGaps) zeroes every slot's runtime so a profile or topology change starts un-engaged. ClearShiftRuntime(slot) does one slot when a single activator topology changes.

ResolveActiveLayerMask and the dispatch loop

ResolveActiveLayerMask(slotIndex, mappingSet, thisDeviceState, thisDeviceGuid) runs once per device pass. It walks mappingSet.ShiftActivators and:

  1. Updates latch state via UpdateActivatorState only on the activator's owning-device pass (act.DeviceGuid matches thisDeviceGuid, or act.DeviceGuid is empty). Other device passes skip the update but still read the resolved mask below, which is how a cross-device activator gates this slot's sources on every device's pass.
  2. Rebuilds _suppressedSourcesBySlot[slot], the "Postpone the mapping" suppression set. An activator that exerted this frame (its WasDown[i] is true) and has PostponeMapping = false adds its deviceGuid|descriptor key so its own press does not also fire that source's normal row. A Cycle activator suppresses each of its two buttons by its own latch (Next via WasDown, Previous via CyclePrevWasDown).
  3. Returns CustomLayer if non-empty (Latch / Cycle override), otherwise the LayerMask of the activator at the tail of Stack, otherwise "Base".

UpdateActivatorState mode machine

UpdateActivatorState reads the activator input through ReadActivatorInput (which dispatches on Kind), applies the DelayMs gate, then switches on Mode. The shared engagement helper is UpdateStack(rt, actIdx, engaged), which keeps Stack's tail at the most-recently-engaged activator. Re-engaging an already-held activator does not churn the stack, but a release-then-press moves it to the tail, giving last-engaged-wins.

Kind Engaged when
Button Descriptor reads down (button-class read via SourceEvaluator.EvaluateForButtonTarget)
Chord both Descriptor and ChordSecondDescriptor are down (second half read against ChordSecondDeviceGuid via LookupDeviceState when set)
Axis |axis| at Descriptor >= AxisThreshold
Mode (XML) UI label Behavior
Hold Hold engaged = inputDown && delayMet, then UpdateStack follows the input
Toggle Toggle rising edge flips ToggleOn[i], then UpdateStack follows the flag
Custom Latch rising edge toggles rt.CustomLayer between this activator's own LayerMask and ""
Cycle Cycle Next / Previous step the shared CycleIndex cursor (below)
Sticky Sticky press engages, next consumer input fires the layer, release of that input disengages
Passive (No-Button) never self-engages. Reachable only via a Cycle queue

Latch (Custom). Displayed as "Latch" since #119. A rising edge sets rt.CustomLayer to this activator's own LayerMask, or back to "" if it is already that layer. Because CustomLayer is single-valued, pressing this Latch again releases to Base and pressing a different Latch switches the active layer outright. The legacy Custom jump-to-a-separate-target behavior is gone. The stored value "Custom" is kept only for config round-trip.

Sticky. Typewriter-shift. A rising edge engages the layer (UpdateStack(true), StickyEngaged = true) and captures a cross-device snapshot via CaptureStickyEngagementSnapshot(slotIndex). That snapshot walks every UserSetting whose MapTo == slotIndex, gathering device GUIDs under UserSettings.SyncRoot, then snapshotting each device's state via LookupDeviceState outside that lock (the GUIDs are gathered and the lock released before LookupDeviceState takes UserDevices.SyncRoot, to avoid inverting the codebase's UserDevices -> UserSettings lock order). Each frame, ComputeStickyConsumerHeldAcrossSlot OR's ComputeStickyConsumerHeld over every snapshotted device. A consumer is "held" when any channel deviates from its baseline: a newly-pressed button, an axis or slider that moved more than StickyAxisDeltaThreshold (8192, about 12.5% of full range), a POV that left center or changed direction, a touchpad-finger rising edge, or a touchpad-click rising edge (Buttons[16]). Gyro and accel are excluded so idle hand movement never releases the layer. The layer disengages on the consumer's falling edge, the frame where StickyConsumerActive was true last frame and is false now, so the shifted mapping fires for the full duration the consumer input is held.

The Cycle cursor (#119)

One Cycle activator holds the entire queue and both buttons. The Next button is the activator's own Descriptor / DeviceGuid (reuses WasDown). The Previous button is CyclePrevDescriptor / CyclePrevDeviceGuid, read cross-device through LookupDeviceState exactly like a chord's second half. Both buttons step a single shared cursor rt.CycleIndex[actIdx] on the press edge:

bool nextRising = inputDown  && !rt.WasDown[actIdx];
bool prevRising = prevDown   && !rt.CyclePrevWasDown[actIdx];

DelayMs does not apply. Cycle is a press-to-step control, not a hold-to-engage one. On a rising edge of either button the code locks rt.SyncRoot, calls ShiftCycleStepper.Step (Next first, then Previous if both rose the same frame), writes back CycleIndex, and maps the cursor to the override: rt.CustomLayer = pos == 0 ? "" : layers[pos - 1]. The pipe-split of CycleLayers is cached in CycleLayersSplit[actIdx] and recomputed only when CycleLayers changes, so the tick allocates nothing.

ShiftCycleStepper.Step

File: PadForge.Engine/Common/ShiftCycleStepper.cs

Pure cursor math, extracted so it unit-tests without a controller. Position 0 = Base, 1..N index the queued layers (N = layers.Length). Step(pos, n, previous, wrap, includeBase) returns the new position.

includeBase wrap previous (Previous) !previous (Next)
true (Base is a ring stop over [0..N]) true (pos + n) % (n + 1) (pos + 1) % (n + 1)
true false max(pos - 1, 0) min(pos + 1, n)
false (layers-only [1..N]) true pos - 1, wrapping 1 -> n pos + 1, wrapping n -> 1
false false pos - 1, clamped at 1 pos + 1, clamped at n

When includeBase = false and pos <= 0 (the resting Base state), the first press jumps to layer 1 for Next, or to n (wrap) / 1 (clamp) for Previous.

With includeBase = false (the default, CycleIncludeBase = false), Base is only the pre-first-press resting state. The first press jumps to a layer and the cursor never re-enters Base via cycling. These are weapon-cursor semantics, where a weapon switch stays on a weapon. A separate Latch or activator can always return to Base regardless of this flag. With includeBase = true, Base is a real stop in the ring and cycling can land back on it.

Implementation notes. The two cycle directions share one cursor (CycleIndex). Next and Previous are not separate positions. The default behavior is not a Base-inclusive wrap. CycleIncludeBase defaults to false, so Base drops out of the rotation after the first press. The runtime is hypothesis-under-test: the stepper math is unit-tested but the live press-edge wiring has not been hardware-verified.


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:
    • Extended Custom HID: 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 / Extended-raw 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 Extended / 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.
    • Extended Custom HID 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.

Macro Clipboard Codec and Cursor Macro Actions

Covers the JSON clipboard format and deep-copy roundtrip behind macro Copy/Paste/Duplicate (#112), the three cursor-write macro actions (#108/#109/#110), and the slot fire-guard that keeps a copied macro from firing off a foreign device.

The macro QOL work (#112) moved copy, paste, and duplicate onto a shared serialize/rebuild pair, and #108/#109/#110 added three macro actions that drive the desktop cursor through the same CursorControlService that feeds the Mouse Position sources.

Macro clipboard codec (#112)

Copy and paste cross the Windows clipboard as JSON. The envelope is defined in SettingsService.cs (~3210):

public sealed class MacroClipboardEnvelope
{
    public string Type { get; set; }      // "PadForgeMacro"
    public int Version { get; set; }       // 1
    public MacroData[] Macros { get; set; }
}
Field Value Purpose
Type "PadForgeMacro" (const MacroClipboardType) Discriminator. Paste rejects clipboard text whose Type is anything else.
Version 1 Schema version stamp for forward compatibility.
Macros MacroData[] One or more serialized macro snapshots. Copy writes a single-element array.

SerializeMacrosToClipboard(MacroData[]) wraps the snapshots in the envelope and calls System.Text.Json.JsonSerializer.Serialize. TryParseMacroClipboard(string) is the matching reader and never throws: it returns null on null/whitespace input, on any deserialization exception, when Type is not "PadForgeMacro" (ordinal compare), or when Macros is null. Arbitrary clipboard contents (a copied PadSetting JSON, plain text, anything) are silently ignored rather than faulting the paste handler in MainWindow.xaml.cs.

Deep-copy roundtrip. Copy/Paste, Duplicate, and cross-pad transfer all reuse one serialize-then-rebuild pair so a pasted macro is an independent object rebound to the destination pad:

  • BuildMacroDataForMacro(MacroItem macro, int padIndex) -> MacroData. Produces a fully serializable DTO snapshot of the macro and every action, including the cursor fields CursorRecenterMode, CursorPinMode / CursorPinX / CursorPinY, and CursorClampMode / CursorClampInsetX / CursorClampInsetY. Extracted from the save path's BuildMacroData, so the in-memory copy and the on-disk save use the same mapping.
  • LoadMacroFromData(MacroData md, VirtualControllerType outputType, int? extendedButtonCount) -> MacroItem. Builds a fresh MacroItem plus fresh MacroAction objects (no shared references with the source). It then rebinds the copy to the target slot's output: MacroButtonNames.DeriveStyle(outputType) sets ButtonStyle, and CustomButtonCount is set to extendedButtonCount for an Extended slot, otherwise 11, propagated onto the macro and every action.

Copy uses only the serialize half. Paste and Duplicate run the full roundtrip and stamp the destination PadIndex:

Path Site Flow
Copy OnCopyMacro (MainWindow.xaml.cs ~4893) BuildMacroDataForMacro -> SerializeMacrosToClipboard -> Clipboard.SetText
Paste OnPasteMacro (MainWindow.xaml.cs ~4909) TryParseMacroClipboard -> per-MacroData LoadMacroFromData(.., padVm.OutputType, padVm.ExtendedConfig?.ButtonCount) -> set PadIndex -> add
Duplicate _duplicateMacroCommand (PadViewModel.cs ~3252) BuildMacroDataForMacro -> LoadMacroFromData -> set PadIndex + copy name

Because LoadMacroFromData rebinds button naming and count to the destination, copying an Xbox-slot macro into an Extended slot relabels its button targets for that slot rather than carrying the source slot's layout.

Cursor-write macro actions (#108 / #109 / #110)

Three MacroActionType members drive the desktop cursor. They are handled in ExecuteSequentialAction (the standard-slot path, ~1074) and mirrored in ExecuteSequentialActionRaw (the custom-Extended path, ~1784), so they work on Xbox/PlayStation/KBM slots and on custom Extended HID slots alike. Each is a one-shot sequential action: it calls into CursorControlService.Active (the running service, null while the engine is stopped) and then AdvanceAction(macro), so with an OnPress trigger it fires once per press.

MacroActionType Service call Behavior
MouseRecenter (#108) RecenterCursor(centerX, centerY) One-shot snap of the cursor to the primary-monitor center. centerX = mode != CursorRecenterMode.YOnly, centerY = mode != CursorRecenterMode.XOnly, so XAndY recenters both axes and a single-axis mode leaves the other coordinate where it is.
MouseFixPosition (#109) TogglePin(CursorPinMode, CursorPinX, CursorPinY) Toggles a sticky pin. First press engages the pin at the stored coordinate on the selected axes, the second press releases it.
MouseLimitRegion (#110) ToggleClamp(CursorClampMode, CursorClampInsetX, CursorClampInsetY) Toggles a region clamp that keeps the cursor inside an inset rectangle on the selected axes. First press engages, second releases.

All three *Mode enums (CursorRecenterMode, CursorPinMode, CursorClampMode, defined in MacroItem.cs) use the same XOnly = 0 / YOnly = 1 / XAndY = 2 shape, which is why the recenter call maps X+Y as "not Y-only" and "not X-only".

Shared 200 Hz timeline. CursorControlService (PadForge.App/Services/CursorControlService.cs) owns one Timer ticking every SampleIntervalMs = 5 (200 Hz). The same Tick that samples the cursor for the Mouse Position sources also enforces the cursor writes, in this order:

  1. EnforcePin(r). If pinned, write the cursor back to the pin target on the pinned axes (SetCursorPos only when a coordinate differs).
  2. EnforceClamp(r). If clamped, push the cursor inside the inset rectangle on the clamped axes (write-only-when-different).
  3. GetCursorPos + normalize by width/10, publish _normX / _normY through SourceCoercion.MouseCursorProvider.

Because the pin/clamp writes and the source sample run on this one thread in that fixed order, the value ReadTunedMouseCursor later reads for a Mouse Position source is always the post-write position. The pin/clamp toggles from the macro evaluator only flip a volatile enable flag and publish config (released before the flag is set), so the timer never reads a half-set target. RecenterCursor is the exception: it is a one-shot SetCursorPos issued from the engine thread with no ongoing enforcement, and the next tick (<=5 ms later) re-samples so the recentered axes report 0. See Button and Axis Mappings for the Mouse Position X/Y sources these actions pair with.

Slot device fire-guard (FindSlotDeviceByInstanceGuid)

A macro must fire only from a device assigned to its own slot. FindSlotDeviceByInstanceGuid(Guid instanceGuid, int slotIndex) (InputManager.Step4b.EvaluateMacros.cs:648) enforces this with two checks before returning a device:

  1. SettingsManager.FindSettingByInstanceGuidAndSlot(instanceGuid, slotIndex) must be non-null, confirming the device is assigned to this macro's slot.
  2. FindOnlineDeviceByInstanceGuid(instanceGuid) must resolve an online device, after which the trigger checks additionally require a live InputState with a Buttons / Povs array.

Guid.Empty short-circuits to null. Both raw-trigger checks route every device lookup through this guard: CheckRawButtonTrigger uses it on each MacroItem.GetTriggerInputEntries() entry (the multi-device path) and on the legacy TriggerDeviceGuid single-device fallback, and CheckRawPovTrigger does the same for POV entries. Without it, a macro copied (via the codec above) into a slot that does not hold its trigger device would still fire from that foreign device on another slot's controller (#112).

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 HMController.SubmitState (gamepad path) and HMController.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 torn-write-safe on x64. SwapSlotData reorders all per-slot arrays atomically as reference swaps before the polling thread can observe a type mismatch.

Error handling: Pass 3 (report submission) wraps each slot in try/catch. A submission failure for one slot is logged but does not abort the cycle for the remaining slots.

Fields

Field Type Description
_hmContext static HMContext Shared HIDMaestro context (one per process), lazy-initialized
_hmContextLock static object Lock for double-checked lazy init
_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.
SlotCustomLayouts CustomControllerLayout[MaxPads] Per-slot HID descriptor layout (axes, buttons, POVs, FFB) for Extended Custom profile
SlotExtendedIsCustom bool[MaxPads] true = HM Custom-profile path (raw descriptor), false = catalog profile (preset path)
SlotExtendedCustomize bool[MaxPads] Per-slot Customize toggle: when true the catalog profile is overridden with the user's SlotCustomLayouts[] shape
SlotExtendedFfbEnabled bool[MaxPads] Per-slot toggle for the HID PID FFB descriptor block
_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: 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)
    {
        var vc = CreateVirtualController(padIndex);
        _virtualControllers[padIndex] = vc;
        if (vc != null && vc.IsConnected) _slotInitializing[padIndex] = false;
    }
}

Pass 3: Submit reports for active slots

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

if (vc is MidiVirtualController midiVc)
    midiVc.SubmitMidiRawState(CombinedMidiRawStates[padIndex]);
else if (vc is KeyboardMouseVirtualController kbmVc)
    kbmVc.SubmitKbmState(CombinedKbmRawStates[padIndex]);
else if (SlotControllerTypes[padIndex] == VirtualControllerType.Extended
         && SlotExtendedIsCustom[padIndex]
         && vc is HMaestroVirtualController hmExt)
{
    var layout = SlotCustomLayouts[padIndex];
    hmExt.SubmitExtendedRawState(
        CombinedExtendedRawStates[padIndex],
        layout.Sticks, layout.Triggers);
}
else
{
    // Xbox / PlayStation / Extended-non-custom slots take the
    // standard XInput-shaped path. PlayStation slots additionally
    // submit Sony Report 0x01 (touchpad / gyro / accel / battery)
    // via SubmitRawReport after SubmitGamepadState in the same poll.
    vc.SubmitGamepadState(CombinedOutputStates[padIndex]);
}

Virtual Controller Creation

private IVirtualController CreateVirtualController(int padIndex)
  1. Check prerequisites: HIDMaestro client required for Xbox, PlayStation, and Extended (not for 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 HMController.OutputReceived 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 the HIDMaestro device through HMController.Dispose(). Without this, devices leak as phantom HID nodes until the next launch.
  5. In finally: Clear _virtualControllers[padIndex] and _slotInitializing[padIndex] even if Disconnect/Dispose throws, so the next Pass 2 can re-create the slot cleanly.

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.


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 works for every output type and avoids the ~1 ms XInput round-trip.


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
  • Single-word SlotControllerTypes[] writes (torn-write-safe on x64) coordinate the UI's reorder with the polling thread's read
  • _hmContextLock for double-checked lazy init of the shared HMContext
  • 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()  /  SubmitExtendedRawState()  /  SubmitMidiRawState()  /  SubmitKbmState()
    |                                               |                        |                       |
    v                                               v                        v                       v
HIDMaestro Xbox / PlayStation / Extended            MIDI (Windows MIDI Services)    Win32 SendInput
(XInput / DirectInput)                              (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()  ->  HMController.OutputReceived  ->  VibrationStates[slot]
    ->  Step 2: ApplyForceFeedback()  ->  per-pad-family output:
            - Sony (DS4/DualSense): UserEffectsDispatcher (sole writer, SDL skipped)
            - Xbox One+ (One/Elite/Series): XboxImpulseHidWriter raw HID (sole writer, SDL skipped)
            - Everything else: SDL_RumbleJoystick / SDL haptic effects

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,          // HIDMaestro Extended profile (DirectInput, appears in joy.cpl)
    Midi = 3,          // Windows MIDI Services virtual endpoint
    KeyboardMouse = 4  // Win32 SendInput keyboard + mouse
}

See Also

Clone this wiki locally