-
Notifications
You must be signed in to change notification settings - Fork 6
Input Pipeline
The input pipeline is the core of PadForge. It runs at ~1000Hz on a dedicated background thread and processes physical device input through six steps to produce virtual controller output.
graph TD
subgraph "Engine Thread (~1000Hz)"
SDL[SDL_UpdateJoysticks]
S1[Step 1: UpdateDevices<br/>SDL enumerate + Raw Input<br/>ViGEm/vJoy filter]
S2[Step 2: UpdateInputStates<br/>SDL read axes/buttons/POV<br/>Force feedback + audio bass]
MS[UpdateMotionSnapshots<br/>Gyro/Accel to DSU coords]
DSU[BroadcastDsuMotion<br/>UDP port 26760]
S3[Step 3: UpdateOutputStates<br/>MapInputToGamepad<br/>Dead zones + curves]
S4[Step 4: CombineOutputStates<br/>Multi-device merge<br/>OR/MAX/magnitude rules]
S4b[Step 4b: EvaluateMacros<br/>Trigger state machine<br/>Button/axis/volume/mouse]
S5[Step 5: UpdateVirtualDevices<br/>ViGEm lifecycle<br/>vJoy descriptor sync<br/>Report submission]
S6[Step 6: RetrieveOutputStates<br/>Copy for UI display]
WAIT[Drift-compensated<br/>hybrid sleep/spin-wait]
SDL --> S1
S1 -->|every 2s or first cycle| S2
SDL -->|skip if not due| S2
S2 --> MS
MS --> DSU
DSU --> S3
S3 --> S4
S4 --> S4b
S4b --> S5
S5 --> S6
S6 --> WAIT
WAIT -->|next cycle| SDL
end
subgraph "UI Thread (30Hz)"
UI_READ[Read RetrievedOutputStates]
UI_WRITE[Write MacroSnapshots<br/>SlotControllerTypes<br/>SlotVJoyConfigs]
end
subgraph "ViGEm Callback Thread"
VIB[FeedbackReceived<br/>writes VibrationStates]
end
S6 -.->|struct copy| UI_READ
UI_WRITE -.->|atomic ref/value| S4b
UI_WRITE -.->|atomic ref/value| S5
VIB -.->|motor values| S2
style S1 fill:#e1f5fe
style S2 fill:#e1f5fe
style S3 fill:#f3e5f5
style S4 fill:#f3e5f5
style S4b fill:#fff3e0
style S5 fill:#e8f5e9
style S6 fill:#e8f5e9
The pipeline is implemented as a partial class InputManager split across eight files:
| File | Step | Purpose |
|---|---|---|
InputManager.cs |
Main | Fields, Start/Stop, PollingLoop, motion snapshots, DSU broadcast |
InputManager.Step1.UpdateDevices.cs |
Step 1 | Device enumeration and lifecycle |
InputManager.Step2.UpdateInputStates.cs |
Step 2 | Input state reading and force feedback |
InputManager.Step3.UpdateOutputStates.cs |
Step 3 | Mapping engine (input -> Gamepad) |
InputManager.Step4.CombineOutputStates.cs |
Step 4 | Multi-device merge per slot |
InputManager.Step4b.EvaluateMacros.cs |
Step 4b | Macro trigger/action state machine |
InputManager.Step5.VirtualDevices.cs |
Step 5 | Virtual controller output |
InputManager.Step6.RetrieveOutputStates.cs |
Step 6 | Copy output for UI display |
All files are in PadForge.App/Common/Input/.
Namespace: PadForge.Common.Input
public partial class InputManager : IDisposable| Member | Type | Default | Description |
|---|---|---|---|
PollingIntervalMs |
int (property) |
1 |
Target polling interval in ms. Runtime-adjustable from Settings UI via InputService.OnSettingsPropertyChanged. |
EnumerationIntervalMs |
const int |
2000 |
Device re-enumeration interval (ms). |
MaxPads |
const int |
16 |
Maximum virtual controller slots. |
| Field | Type | Description |
|---|---|---|
_pollingThread |
Thread |
Background thread running PollingLoop. Name=PadForge.InputManager, Priority=AboveNormal, IsBackground=true. |
_running |
volatile bool |
Loop control flag. Set to false by Stop() to terminate the loop. |
_idle |
volatile bool |
When true, skips pipeline steps 3-6 and sleeps at ~20 Hz. Input reading (Step 2) still runs for Devices page preview. |
_sdlInitialized |
bool |
Whether SDL_Init succeeded. |
_disposed |
bool |
Disposal guard. |
_enumerationTimer |
Stopwatch |
Tracks time since last device enumeration. |
_frequencyTimer |
Stopwatch |
Tracks time for frequency measurement. |
_frequencyCounter |
int |
Cycle counter for frequency measurement. |
_deviceSnapshotBuffer |
UserDevice[] |
Pre-allocated buffer for Step 2 device snapshot (avoids LINQ/closure allocations in hot path). Grows dynamically if device count exceeds buffer size. |
_settingSnapshotBuffer |
UserSetting[] |
Pre-allocated buffer for Step 3 settings snapshot. |
_padIndexBuffer |
UserSetting[MaxPads] |
Pre-allocated buffer for FindByPadIndex lookups (Steps 2-5). |
_instanceGuidBuffer |
UserSetting[MaxPads] |
Pre-allocated buffer for FindByInstanceGuid lookups (Step 2 FFB). |
| Property | Type | Written By | Read By | Description |
|---|---|---|---|---|
CombinedOutputStates |
Gamepad[MaxPads] |
Step 4 (engine) | Step 5, Step 6, UI | Combined gamepad state per slot. |
CombinedVJoyRawStates |
VJoyRawState[MaxPads] |
Step 4 (engine) | Step 5 | Combined raw vJoy state for custom presets. |
CombinedMidiRawStates |
MidiRawState[MaxPads] |
Step 4 (engine) | Step 5 | Combined MIDI raw state for MIDI slots. |
CombinedKbmRawStates |
KbmRawState[MaxPads] |
Step 4 (engine) | Step 5 | Combined KBM raw state for KeyboardMouse slots. |
RetrievedOutputStates |
Gamepad[MaxPads] |
Step 6 (engine) | UI timer | Copy of combined states for UI display. |
RetrievedKbmRawStates |
KbmRawState[MaxPads] |
Step 6 (engine) | UI timer | Copy of KBM raw states for UI preview. |
VibrationStates |
Vibration[MaxPads] |
ViGEm callback thread | Step 2 (engine) | Per-slot rumble from games. Cross-thread: written by ViGEm's FeedbackReceived callback (thread pool), read by engine thread. |
MotionSnapshots |
MotionSnapshot[MaxPads] |
Engine (polling loop) | DSU broadcast | Per-slot motion sensor data for Cemuhook. |
MacroSnapshots |
MacroItem[][MaxPads] |
UI timer (30Hz) | Step 4b (engine) | Per-slot macro definitions. Cross-thread: UI writes reference atomically, engine reads reference atomically each cycle. |
TestRumbleTargetGuid |
Guid[MaxPads] |
UI | Step 2 | When non-empty, restricts test rumble to a specific device GUID in the slot. |
CurrentFrequency |
double |
Engine | UI | Measured polling frequency in Hz. Updated ~once/second. |
IsRunning |
bool |
Engine | UI | Whether the polling loop is active. Reads _running. |
IsIdle |
bool |
UI (InputService) | Engine | When true, polling loop runs at ~20 Hz and skips Steps 3-6. Set by InputService when no virtual controller slots are created. |
DsuServer |
DsuMotionServer |
InputService | Engine | DSU motion server reference. When set, the polling thread broadcasts motion data after Step 2. |
AudioBassDetector |
AudioBassDetector |
InputService | Engine | Audio bass detector. When set, bass energy is combined with game rumble via max() in ApplyForceFeedback. |
public event EventHandler DevicesUpdated;
public event EventHandler FrequencyUpdated;
public event EventHandler<InputExceptionEventArgs> ErrorOccurred;| Event | Thread | Description |
|---|---|---|
DevicesUpdated |
Engine thread | Raised when devices connect/disconnect. UI must marshal to dispatcher. |
FrequencyUpdated |
Engine thread | Raised ~once per second with updated CurrentFrequency. |
ErrorOccurred |
Engine thread | Non-fatal errors during polling. Handlers receive message + exception. |
public InputManager()Initializes VibrationStates[] with new Vibration() for each of the 16 slots.
private bool InitializeSdl()Sets SDL hints, then calls SDL_Init with flags:
SDL_INIT_JOYSTICK | SDL_INIT_GAMEPAD | SDL_INIT_VIDEO | SDL_INIT_HAPTICKey hints:
-
SDL_HINT_JOYSTICK_ALLOW_BACKGROUND_EVENTS = "1"-- receive input without window focus -
SDL_HINT_JOYSTICK_XINPUT = "1"-- enable Xbox controller enumeration via XInput backend -
SDL_HINT_JOYSTICK_HIDAPI_SWITCH2 = "1"-- enable Switch 2 Pro Controller HIDAPI driver -
SDL_HINT_VIDEO_ALLOW_SCREENSAVER = "1"-- do not block screensaver -
NEVER set
SDL_HINT_JOYSTICK_RAWINPUT-- conflicts with XInput enumeration, prevents Xbox controllers from appearing (discovered via Cemu comparison)
Post-init steps:
- Loads PadForge community controller mappings from
gamecontrollerdb_padforge.txtviaSDL_AddGamepadMappingsFromFile - Calls
SDL_EnableScreenSaver()-- SDL_INIT_VIDEO disables the screensaver by default - Calls
SetThreadExecutionState(ES_CONTINUOUS)-- clears any execution-state flags so the PC can sleep
Error handling: Catches DllNotFoundException (SDL3.dll missing) and generic exceptions. Raises ErrorOccurred but does not throw -- Start() checks the return value and aborts if false.
private void ShutdownSdl()Calls SDL_Quit(). Called by Dispose().
public void Start()- Guards against double-start (
_running) or disposed state - Calls
InitializeSdl()-- aborts if it fails - Calls
RawInputListener.Start()-- starts the hidden message-only window for keyboard/mouse enumeration - Creates and starts the polling thread (Name=
PadForge.InputManager, Priority=AboveNormal, IsBackground=true)
Thread safety: Safe to call from any thread. Subsequent calls are no-ops if already running.
public void Stop(bool preserveVJoyNodes = false)- Sets
_running = false - Joins polling thread with 3-second timeout
- Stops
RawInputListener - Calls
StopAllForceFeedback()-- iterates all devices and callsForceFeedbackState.StopDeviceForces()(best-effort, catches exceptions) - Calls
DestroyAllVirtualControllers(preserveVJoyNodes)-- disconnects and disposes all VCs - Calls
CloseAllDevices()-- disposes all SDL device handles and clears runtime state
When preserveVJoyNodes is true, the vJoy device node is disabled (not removed) so it can be re-enabled on next Start without a full node recreation cycle. When false (app shutdown), all device nodes are removed to prevent orphaned entries in joy.cpl.
private void PollingLoop()Entry point for the background thread. Sets timeBeginPeriod(1) for the duration of the loop (restored in finally block via timeEndPeriod(1)).
Per-cycle execution order:
SDL_UpdateJoysticks() -- pump SDL event queue
|
v (every 2 seconds, or first cycle)
Step 1: UpdateDevices() -- enumerate, open/close devices
|
v
Step 2: UpdateInputStates() -- read axes/buttons/POV from SDL, apply FFB
|
v
UpdateMotionSnapshots() -- capture gyro/accel for DSU
BroadcastDsuMotion() -- send to Cemuhook clients via UDP
|
v
Step 3: UpdateOutputStates() -- map CustomInputState to Gamepad via PadSetting rules
|
v
Step 4: CombineOutputStates() -- merge multiple devices per slot
|
v
Step 4b: EvaluateMacros() -- trigger/action state machine, inject into Gamepad
|
v
Step 5: UpdateVirtualDevices()-- create/destroy VCs, submit reports
|
v
Step 6: RetrieveOutputStates()-- copy combined output for UI consumption
|
v
Frequency measurement (~1/second)
|
v
Drift-compensated hybrid sleep/spin-wait
3-Tier Polling Sleep Strategy:
The polling loop uses a tiered sleep strategy, falling through to the next tier if the preferred timer is unavailable:
| Tier | Mechanism | Availability | CPU Cost |
|---|---|---|---|
| Tier 1 | High-Resolution Waitable Timer | Windows 10 1803+ | Near-zero (kernel sleep) |
| Tier 2 | Multimedia Timer + ManualResetEvent | All Windows | Near-zero (event wait) |
| Tier 3 | Thread.Sleep(1) + SpinWait | All Windows | ~1-3% of one core |
Tier 1: High-Resolution Waitable Timer -- CreateWaitableTimerExW with CREATE_WAITABLE_TIMER_HIGH_RESOLUTION (0x00000002). Sleeps at sub-ms granularity via the kernel scheduler without busy-waiting. The timer is set as a negative relative due time (100-nanosecond intervals) via SetWaitableTimerEx, then the thread blocks with WaitForSingleObject. Leaves a 0.1ms (spinThresholdTicks) gap before the target to spin-finish.
Tier 2: Multimedia Timer Fallback -- timeSetEvent creates a periodic callback at the polling interval that signals a ManualResetEvent. The polling thread blocks with WaitOne(50) until the callback fires, then resets the event. This is the x360ce-style approach. Precision is ~1-2ms with timeBeginPeriod(1). The callback delegate is prevented from GC via GC.KeepAlive(mmTimerCb) in the finally block.
Tier 3: Thread.Sleep(1) + SpinWait -- Legacy fallback when both timers fail. Thread.Sleep(1) absorbs bulk wait when >1.5ms remains (sleepThresholdTicks).
All three tiers finish with a spin-wait loop for the final sub-ms portion:
while (cycleTimer.ElapsedTicks < adjustedTarget)
Thread.SpinWait(1);Wall-clock drift compensation:
Instead of per-cycle overshoot tracking, the loop compares cumulative expected time against a wall clock Stopwatch:
expectedTicks += targetTicks;
long drift = wallClock.ElapsedTicks - expectedTicks;
long adjustedTarget = targetTicks - drift;If the loop falls behind (positive drift), future cycles are shortened; if ahead (negative drift), they are lengthened. This converges the long-term average rate exactly to the target Hz.
Safety mechanisms:
- If drift exceeds 10x the target interval (e.g., after system sleep/resume), the wall clock resets entirely instead of sprinting to catch up
-
adjustedTargethas a safety floor oftargetTicks / 4to prevent negative or near-zero wait times
Idle mode:
When no virtual controller slots are created (IsIdle == true), the polling loop enters a low-power mode:
- Calls
SDL_UpdateJoysticks()to pump the event queue - Runs
UpdateDevices()at reduced rate (every 5 seconds instead of 2) so newly connected controllers still appear on the Devices page - Runs
UpdateInputStates()so the Devices page raw input preview works for unassigned devices - Skips Steps 3-6 entirely
- Sleeps at ~20 Hz (
Thread.Sleep(50)) - Reports
CurrentFrequency = 0 - On transition back to active: sets
firstCycle = truefor immediate enumeration, resets wall-clock drift state to prevent burst cycles
Sleep guard: Every 5 seconds, calls SetThreadExecutionState(ES_CONTINUOUS) to clear any execution-state flags that SDL may re-assert during SDL_JoystickUpdate() / event processing, ensuring the PC can still enter sleep mode.
public void SwapSlots(int slotA, int slotB)Same-type swaps keep virtual controllers alive -- only input routing changes via MapTo swap in SettingsManager. Since Step 3 maps via MapTo, Step 4 combines by MapTo index, and Step 5 feeds CombinedOutputStates[i] to _virtualControllers[i], all per-slot arrays are recomputed each frame from MapTo. No array swapping needed, zero game disruption.
Cross-type swaps destroy both VCs (via DestroyVirtualController) so Step 5 recreates them with the correct types in ascending slot order. Also swaps SlotControllerTypes, SlotVJoyConfigs, SlotVJoyIsCustom, TestRumbleTargetGuid, MacroSnapshots.
public void SwapSlotData(int slotA, int slotB)Swaps all data arrays AND virtual controllers between two slots under VJoySyncLock. Used by EnsureTypeGroupOrder bubble sort on the UI thread. Unlike SwapSlots, this method also swaps the VC instances themselves (plus _slotInactiveCounter, _slotInitializing, _createCooldown, VibrationStates, all Combined*States arrays, and _midiConfigs) so Step 5 does not see a type mismatch and avoids needless destroy/recreate cycles that cause phantom Xbox controllers.
After swapping, updates FeedbackPadIndex on each VC and calls VJoyVirtualController.UpdateFfbPadIndex to fix FFB device map entries.
Thread safety: Holds VJoySyncLock for the entire swap to prevent the polling thread's vJoy descriptor sync from observing a half-swapped state.
private void UpdateMotionSnapshots()Called on the polling thread after Step 2. For each of the 16 pad slots:
- Finds all UserSettings mapped to this slot via
FindByPadIndex - For each, looks up the online device and checks
HasGyro/HasAccel - Uses the first device with sensors (breaks after finding one)
- 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- If no sensor device found, writes
HasMotion = falsesnapshot
Timestamp: Stopwatch.GetTimestamp() * 1_000_000 / Stopwatch.Frequency (microseconds).
private void BroadcastDsuMotion()Iterates all 16 slots and calls DsuServer.BroadcastMotion(padIndex, snapshot, isConnected) for each. The DSU server is set by InputService and may be null (no-op).
public void Dispose()Calls Stop() then ShutdownSdl(). Has a finalizer that calls Dispose() as a safety net, with GC.SuppressFinalize in the normal path.
// 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);File: InputManager.Step1.UpdateDevices.cs
Enumerates all connected devices at 2-second intervals (5-second in idle mode). Opens new devices, marks disconnected devices offline, and fires DevicesUpdated if the device list changed. Handles three device categories: SDL joysticks/gamepads, Raw Input keyboards, and Raw Input mice.
private void UpdateDevices()Called by: PollingLoop() (every 2 seconds or on first cycle)
Thread safety: Runs exclusively on the engine thread. Device collection modifications use SettingsManager.UserDevices.SyncRoot locking. The DevicesUpdated event is raised on the engine thread -- UI consumers must marshal to the WPF dispatcher.
Error handling: Each device open is wrapped in try/catch. A single device failure does not abort the entire enumeration cycle -- the error is reported via RaiseError and processing continues with the next device.
| Field | Type | Description |
|---|---|---|
_openedSdlInstanceIds |
HashSet<uint> |
SDL instance IDs of currently opened joysticks. Used to skip already-open devices during enumeration. |
_filteredVigemInstanceIds |
HashSet<uint> |
SDL instance IDs identified as ViGEm/vJoy virtual controllers. Never re-opened -- prevents the open/close cycle that kills rumble via SDL's internal XInputSetState(0,0) on close. Cleaned via IntersectWith(currentInstanceIds) each cycle. |
_openedKeyboardHandles |
HashSet<IntPtr> |
Raw Input device handles for tracked keyboards. |
_openedMouseHandles |
HashSet<IntPtr> |
Raw Input device handles for tracked mice. |
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:
- Skip if in
_filteredVigemInstanceIds(known ViGEm/vJoy device) - Skip if in
_openedSdlInstanceIds(already open) - Create
new SdlDeviceWrapper()and callwrapper.Open(instanceId)-- opens as gamepad if recognized, joystick otherwise - Call
IsViGEmVirtualDevice(wrapper)-- if true, add to_filteredVigemInstanceIds, dispose wrapper, skip -
FindOrCreateUserDevice(wrapper.InstanceGuid, wrapper.ProductGuid)-- finds existing or creates new UserDevice -
ud.LoadFromSdlDevice(wrapper)-- populate capabilities, name, VID/PID - Mark
ud.IsOnline = true - Track in
_openedSdlInstanceIds
Phase 1b: Enumerate keyboards via EnumerateKeyboards()
Uses RawInputListener.EnumerateKeyboards() to get device info array. For each new handle not in _openedKeyboardHandles:
- Create
SdlKeyboardWrapper, callOpen(kb) FindOrCreateUserDevice(wrapper.InstanceGuid)-
ud.LoadFromKeyboardDevice(wrapper), mark online - Prunes orphaned handles (device removed via UI while still connected)
Phase 1c: Enumerate mice via EnumerateMice()
Same pattern as keyboards using SdlMouseWrapper.
Phase 2: Detect disconnected joystick devices
Iterates _openedSdlInstanceIds. For each SDL ID, finds the UserDevice via FindOnlineDeviceBySdlInstanceId. If the device is null (not found) or !ud.Device.IsAttached, calls MarkDeviceOffline(ud) and removes from tracking set.
Phase 2b-2c: Detect disconnected keyboards/mice
changed |= DetectDisconnectedHandles(_openedKeyboardHandles, RawInputListener.EnumerateKeyboards());
changed |= DetectDisconnectedHandles(_openedMouseHandles, RawInputListener.EnumerateMice());Compares tracked handles against current Raw Input device set. Marks missing devices offline.
ViGEm filter cleanup:
_filteredVigemInstanceIds.IntersectWith(currentInstanceIds);Removes entries for ViGEm devices that no longer exist (virtual controller destroyed), so the filter set does not grow unboundedly.
private bool IsViGEmVirtualDevice(SdlDeviceWrapper wrapper)Detection heuristics (checked in order, first match returns true):
| # | Criterion | Rationale |
|---|---|---|
| 1 | Device path contains "vigem" or "virtual" (case-insensitive) |
ViGEm bus device paths always contain these strings |
| 2 | VID=0, PID=0 + IsGameController + active/expected ViGEm count > 0 |
ViGEm devices may report zero VID/PID via SDL's pre-open enumeration |
| 3 | VID=0x1234, PID=0xBEAD | vJoy virtual joystick -- PadForge's own output device |
| 4 | VID=0x045E, PID=0x028E (Xbox 360) + _activeXbox360Count > 0 OR _expectedXbox360Count > 0
|
ViGEm Xbox 360 emulates this exact VID/PID. Modern real Xbox controllers use different PIDs (0B12, 0B13, 0B20). Filters ALL matching devices when any active VCs exist to prevent feedback loops. |
| 5 | VID=0x054C, PID=0x05C4 (DS4 v1) + _activeDs4Count > 0 OR _expectedDs4Count > 0
|
Same logic for ViGEm DS4 emulation |
private UserDevice FindOnlineDeviceByInstanceGuid(Guid instanceGuid)Manual loop under SyncRoot lock. Used extensively throughout all steps.
private UserDevice FindOnlineDeviceBySdlInstanceId(uint sdlInstanceId)Manual loop under SyncRoot lock. Only matches online devices with non-null Device.
private UserDevice FindOrCreateUserDevice(Guid instanceGuid, Guid productGuid = default)Three-tier lookup under SyncRoot lock:
- Exact match by InstanceGuid
-
Fallback match: offline device with same ProductGuid (handles Bluetooth controllers that reconnect with a new device path after reboot). Migrates both the UserDevice and its linked UserSetting to the new InstanceGuid via
MigrateUserSettingGuid. -
Create new: Adds a new
UserDevicetodevices.Items
private void MarkDeviceOffline(UserDevice ud)Stops rumble (best-effort), disposes SDL handle (best-effort), calls ud.ClearRuntimeState() to reset all runtime fields including IsOnline = false.
public void RegisterExternalDevice(WebControllerDevice device)
public void UnregisterExternalDevice(Guid instanceGuid)Called by WebControllerServer when browser-based controller clients connect/disconnect. Uses FindOrCreateUserDevice and MarkDeviceOffline internally. Thread-safe via UserDevices.SyncRoot.
public class DeviceCollection
{
public List<UserDevice> Items { get; }
public object SyncRoot { get; }
}
public class SettingsCollection
{
public List<UserSetting> Items { get; }
public object SyncRoot { get; }
public UserSetting FindByInstanceGuid(Guid instanceGuid) // Locking, allocates
public List<UserSetting> FindByPadIndex(int padIndex) // Locking, allocates
public int FindByInstanceGuid(Guid instanceGuid, UserSetting[] buffer) // Non-allocating
public int FindByPadIndex(int padIndex, UserSetting[] buffer) // Non-allocating
}Hot-path optimization: Non-allocating overloads fill pre-allocated buffers and return a count. Used in Steps 2-5 (~1000 calls/second) to avoid GC pressure from LINQ closures and list allocations. Allocating overloads exist for UI-thread use where convenience matters more than allocation.
Declared in this file:
public static partial class SettingsManager
{
public static DeviceCollection UserDevices { get; set; }
public static SettingsCollection UserSettings { get; set; }
}File: InputManager.Step2.UpdateInputStates.cs
Reads current input state from all online devices and applies force feedback (rumble). This step executes even in idle mode so the Devices page raw input preview works for unassigned devices.
private void UpdateInputStates()Called by: PollingLoop() (every cycle, including idle mode)
Thread safety: Snapshots the online device list under SyncRoot lock, then iterates the snapshot without holding the lock. ud.InputState is swapped via atomic reference assignment (safe for cross-thread reading by the UI).
Error handling: Per-device try/catch. A single device read failure marks that device offline (ud.IsOnline = false) and continues. SDL read returning null is treated as disconnection.
-
Snapshot online devices into
_deviceSnapshotBufferunderSyncRootlock: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]; }
-
For each online device (outside lock): a. Save previous state for change detection:
ud.OldInputState = ud.InputState; ud.OldInputUpdates = ud.InputUpdates; ud.OldInputStateTime = ud.InputStateTime;
b. Read new state:
newState = ud.Device.GetCurrentState(ud.ForceRawJoystickMode);
ForceRawJoystickModemakesSdlDeviceWrapper.GetCurrentState()useSDL_GetJoystickAxis/SDL_GetJoystickButtoninstead ofSDL_GetGamepadAxis/SDL_GetGamepadButton, bypassing SDL's gamecontrollerdb remapping. Used for devices like DS3 via DsHidMini SDF where the gamepad API drops buttons. c. Atomic reference swap:ud.InputState = newState(thread-safe for UI readers) d. Setud.InputStateTime = DateTime.UtcNowe. Compute buffered updates:ud.InputUpdates = CustomInputHelper.GetUpdates(ud.OldInputState, newState);
Returns an array of
CustomInputUpdatedescribing which axes/buttons changed. Used by the recording/preview UI. f. CallApplyForceFeedback(ud)-- apply rumble to the physical device
private void ApplyForceFeedback(UserDevice ud)Applies force feedback (rumble) to a physical device based on vibration data received from games via ViGEmBus.
Pre-conditions checked:
-
ud.ForceFeedbackState != null(device has FFB capability tracking) -
ud.Device.HasRumble || ud.Device.HasHaptic(SDL reports rumble or haptic support)
Multi-slot vibration combination:
A single physical device can be mapped to multiple virtual controller slots. Vibration from ALL mapped slots is combined via max() of each motor:
int slotCount = settings.FindByInstanceGuid(ud.InstanceGuid, _instanceGuidBuffer);
ushort combinedL = 0, combinedR = 0;
for (int i = 0; i < slotCount; i++)
{
var vib = VibrationStates[padIndex];
if (vib.LeftMotorSpeed > combinedL) combinedL = vib.LeftMotorSpeed;
if (vib.RightMotorSpeed > combinedR) combinedR = vib.RightMotorSpeed;
}TestRumbleTargetGuid: When TestRumbleTargetGuid[padIndex] is non-empty, only the device with that specific GUID receives rumble for that slot. This allows the Settings page to test rumble on one specific device without affecting others in the same slot.
Audio bass rumble combination:
When firstPadSetting.AudioRumbleEnabled == "1" and AudioBassDetector is set:
- Calls
detector.DecayIfSilent()to apply decay curve when no audio is playing - Reads per-device sensitivity and cutoff Hz from PadSetting
- Scales
detector.MotorValuebyAudioRumbleLeftMotor/AudioRumbleRightMotorpercentages - Combines with game vibration via
max()-- audio rumble fills gaps without overriding native game FFB
Output: Writes to a scratch _combinedVibration instance (reused to avoid allocation) and calls:
ud.ForceFeedbackState.SetDeviceForces(ud, ud.Device, firstPadSetting, _combinedVibration);The ForceFeedbackState class internally uses SDL_RumbleJoystick (with uint.MaxValue duration + change-detection to avoid redundant API calls) or falls back to SDL_Haptic (LeftRight > Sine > Constant effect strategy) for devices without native rumble.
File: InputManager.Step3.UpdateOutputStates.cs
Maps each device's CustomInputState to a Gamepad struct (and optionally VJoyRawState, MidiRawState, or KbmRawState) based on PadSetting mapping descriptors. This is the most complex step, containing the entire mapping engine, dead zone processing, sensitivity curves, and center offset corrections.
private void UpdateOutputStates()Called by: PollingLoop() (every active cycle, skipped in idle mode)
Thread safety: Snapshots UserSettings under SyncRoot lock, then iterates the snapshot without holding the lock. Each UserSetting's OutputState is a struct (value type), so writes are atomic at the word level on aligned fields.
Error handling: Per-setting try/catch. On exception, the OutputState is NOT zeroed -- the last valid state is preserved to prevent transient zero glitches from propagating through Steps 4-6 (e.g., output controller reading a momentary zero during a state refresh).
-
Snapshot all UserSettings into
_settingSnapshotBufferunderSyncRootlock -
For each UserSetting:
a. Find online device by
us.InstanceGuidviaFindOnlineDeviceByInstanceGuidb. If device not found: setus.OutputState = default(zero), continue c. If device found but offline orInputState == null: keep last valid OutputState (no zero), continue d. GetPadSettingviaus.GetPadSetting()-- contains all mapping rules e. Map to gamepad:us.OutputState = MapInputToGamepad(ud.InputState, ps, out rawMapped)f. Saveus.RawMappedState = rawMapped(pre-dead-zone snapshot for UI preview) g. Type-specific raw mapping based onSlotControllerTypes[slot]:- Custom vJoy:
MapInputToVJoyRaw(ud.InputState, ps, cfg)using dictionary-based vJoy mappings - MIDI:
MapInputToMidiRaw(ud.InputState, ps, ccCount, noteCount)for MIDI CC/note output - KeyboardMouse:
MapInputToKbmRaw(ud.InputState, ps)for keyboard/mouse output
- Custom vJoy:
private static Gamepad MapInputToGamepad(CustomInputState state, PadSetting ps, out Gamepad rawMapped)The core mapping function. Processes in this order:
-
Buttons (11 total): A, B, X, Y, LB, RB, Back, Start, LS, RS, Guide -- each calls
MapToButtonPressed(state, ps.ButtonX) -
D-Pad: If individual direction descriptors (
DPadUp/DPadDown/DPadLeft/DPadRight) are set, each is mapped independently. Otherwise, falls back to combinedDPaddescriptor that extracts all 4 directions from a single POV hat viaMapDPadFromPov. -
Triggers:
MapToTrigger(state, ps.LeftTrigger)-> unsigned 0-65535 -
Thumbsticks:
MapToThumbAxisWithNeg(state, ps.LeftThumbAxisX, ps.LeftThumbAxisXNeg)-> signed short. Y axes are negated viaNegateAxis()to convert from unsigned pipeline (0=up -> negative) to XInput convention (positive Y = up). -
Snapshot raw mapped state (
rawMapped = gp) -- captured BEFORE dead zone processing so the UI preview can apply its own pipeline without double-processing -
Trigger dead zones:
ApplyTriggerDeadZonewith dead zone, anti-dead zone, max range, and optional sensitivity curve LUT -
Center offsets:
ApplyCenterOffset(value, offsetPercent)-- shifts the axis by a percentage of the full range. Applied BEFORE dead zone processing. Used to compensate for stick drift. -
Stick dead zones:
ApplyDeadZonewith full parameter set: dead zone X/Y, anti-dead zone X/Y, linear, max range X/Y (both positive and negative directions independently), sensitivity curve LUT X/Y, dead zone shape
PadSetting string fields (e.g., ButtonA, LeftThumbAxisX) contain mapping descriptors:
[Prefix]{MapType} {Index} [Direction]
Prefixes (optional, combinable):
| Prefix | Meaning |
|---|---|
I |
Inverted -- axis values are flipped |
H |
Half-axis -- only the upper half (32768-65535) is used, rescaled to full range |
IH |
Inverted half-axis |
MapType values:
| MapType | Example | Description |
|---|---|---|
Axis |
"Axis 1" |
Joystick axis (unsigned 0-65535) |
Button |
"Button 0" |
Button press (digital, true/false -> 0 or 65535) |
Slider |
"Slider 0" |
Slider control (unsigned 0-65535) |
POV |
"POV 0 Up" |
POV hat direction |
Pipe-separated OR logic:
"Button 0|Button 5" -- pressed if EITHER is pressed (buttons: OR)
"Axis 4|Button 8" -- trigger: max of axis value or button (0 or 65535)
"Axis 1|Axis 3" -- thumbstick: largest absolute magnitude wins
private struct MappingDescriptor
{
public MapType Type;
public int Index;
public bool Inverted;
public bool HalfAxis;
public string PovDirection; // "Up", "Down", "Left", "Right" (for POV)
public bool IsValid;
}private static MappingDescriptor ParseDescriptor(string descriptor)Parses "IHAxis 2" into {Type=Axis, Index=2, Inverted=true, HalfAxis=true, IsValid=true}.
Invalid/empty descriptors return IsValid = false. The strings "0" and "" are treated as empty.
private static bool MapToButtonPressed(CustomInputState state, string descriptor)
private static bool MapToButtonPressedSingle(CustomInputState state, string descriptor)-
Button source: returns
state.Buttons[index] -
Axis source: threshold at 75% of range --
value > 49151(orvalue < 16384if inverted) - Slider source: same threshold logic as axis
-
POV source:
IsPovDirectionActive(state.Povs[index], direction)
Multiple descriptors separated by | are OR'd.
private static bool IsPovDirectionActive(int povValue, string direction)Uses centidegree ranges with sector-based tolerances:
- Cardinals (Up, Right, Down, Left): +/-67.5 degree tolerance (covers the 135-degree sector including adjacent diagonals). Example: "Up" matches 29250-35999 and 0-6750.
- Diagonals (UpRight, DownRight, DownLeft, UpLeft): +/-22.5 degree tolerance (exact 45-degree sector). Example: "UpRight" matches 2250-6750.
private static void MapDPadFromPov(CustomInputState state, string descriptor, ref Gamepad gp)
private static void MapDPadFromPovSingle(CustomInputState state, string descriptor, ref Gamepad gp)When individual D-pad directions (DPadUp, DPadDown, DPadLeft, DPadRight) are set, they take priority (each mapped independently as button presses). Otherwise, the combined DPad descriptor reads a single POV hat value and sets all 4 direction flags simultaneously, supporting 8-way diagonals.
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:
rawValuedirectly (already 0-65535) - Half axis: upper half rescaled:
(rawValue - 32768) * 65535 / 32767 - Inverted:
65535 - rawValueapplied before conversion
private static short MapToThumbAxis(CustomInputState state, string descriptor)
private static short MapToThumbAxisSingle(CustomInputState state, string descriptor)
private static short MapToThumbAxisWithNeg(CustomInputState state, string posDescriptor, string negDescriptor)Converts unsigned (0-65535) to signed (-32768 to 32767): signed = rawValue - 32768.
When both posDescriptor and negDescriptor are set (typically for buttons mapped to axes):
- Positive pressed only:
+32767 - Negative pressed only:
-32768 - Both pressed:
0(cancel out) - Neither pressed:
0
Y-axis negation: NegateAxis() is applied to ThumbLY and ThumbRY:
private static short NegateAxis(short value)
=> value == short.MinValue ? short.MaxValue : (short)-value;Clamps short.MinValue to short.MaxValue to avoid overflow (since -(-32768) overflows short).
private static void ApplyDeadZone(ref short axisX, ref short axisY,
double deadZoneX, double deadZoneY,
double antiDeadZoneX, double antiDeadZoneY, double linear,
double maxRangeX, double maxRangeY,
double maxRangeXNeg, double maxRangeYNeg,
double[] lutX, double[] lutY,
DeadZoneShape shape)Six dead zone shapes are supported, selected via PadSetting.LeftThumbDeadZoneShape / RightThumbDeadZoneShape:
| Shape | Algorithm | Use Case |
|---|---|---|
Axial |
Independent per-axis dead zone (ApplySingleDeadZone on X and Y separately) |
Default, simple |
Radial |
Elliptical distance check (nx/dzX)^2 + (ny/dzY)^2 < 1, raw pass-through outside |
Circular dead zone |
ScaledRadial |
Same elliptical check + rescales magnitude from [dzR, mrR] to [0, 1]
|
Smooth circular with no jump at dead zone edge |
SlopedAxial |
Per-axis DZ scales with other axis magnitude: effDzX = dzXn * magY
|
Cardinal direction locking |
SlopedScaledAxial |
Same + rescale from [effDz, mr] to [0, 1]
|
Cardinal lock without jump |
Hybrid |
Stage 1: Scaled Radial (center noise removal) then Stage 2: Sloped Scaled Axial (cardinal precision) | Best of both approaches |
Post-dead-zone pipeline (applied per-axis regardless of shape):
-
Sensitivity curve: spline LUT lookup via
CurveLut.Lookup(lut, remapped)-- transforms the [0,1] remapped value through a user-defined response curve -
Anti-dead zone:
output = adzNorm + remapped * (1.0 - adzNorm)-- offsets the output minimum so small physical movements register past the game's internal dead zone -
Linear adjustment:
output = remapped * linearFactor + output * (1.0 - linearFactor)-- blends between raw linear and anti-dead-zone-adjusted output -
Scale and clamp:
sign * output * 32767.0, clamped toshortrange
Independent max range: Each axis has separate positive and negative max range values. The sign of the input selects which max range to use: nx >= 0 ? maxRangeX : maxRangeXNeg. This allows asymmetric stick range (e.g., a stick with less travel in one direction).
private static ushort ApplyTriggerDeadZone(ushort value, double deadZone, double antiDeadZone,
double maxRange, double[] lut = null)- Normalize to 0.0-1.0
- Dead zone: values below
deadZone%threshold are zeroed - Max range: caps input ceiling at
maxRange% - Remap from
[dzNorm, maxNorm]to[0, 1] - Sensitivity curve LUT lookup (if provided)
- Anti-dead zone: offsets output minimum by
antiDeadZone% - Scale to 0-65535 and clamp
private static int GetRawValue(CustomInputState state, MappingDescriptor desc)Returns unsigned 0-65535:
- Axis:
state.Axis[index] - Slider:
state.Sliders[index] - Button:
65535(pressed) or0(released) - POV:
PovDirectionToAxisValue(pov, direction)-- Up/Left active = 0, Down/Right active = 65535, inactive = 32767
private static VJoyRawState MapInputToVJoyRaw(CustomInputState state, PadSetting ps,
VJoyVirtualController.VJoyDeviceConfig cfg)Uses dictionary-based vJoy mappings (ps.GetVJoyMapping("VJoyAxis0"), ps.GetVJoyMapping("VJoyBtn0"), etc.) instead of fixed gamepad field names. Supports arbitrary axis/button/POV counts defined by the VJoyDeviceConfig.
-
Axes: Uses
MapToThumbAxisWithNegfor each axis (signed short range). NoNegateAxisneeded -- unlike the gamepad path, the raw path has no second inversion inSubmitRawState. -
Buttons: Uses
MapToButtonPressedfor each button, sets viaraw.SetButton(i, true) -
POVs: Individual direction buttons (
VJoyPov0Up,VJoyPov0Down, etc.) mapped to continuous POV values (0-35900 hundredths of degrees, 0xFFFFFFFF = centered) viaDirectionToContinuousPov() -
Dead zones: Applied per-stick and per-trigger using the same
ApplySingleDeadZone/ApplyTriggerDeadZonemethods
File: InputManager.Step4.CombineOutputStates.cs
Merges mapped Gamepad states from all devices assigned to each virtual controller slot into a single combined state. Handles four output types: Gamepad, VJoyRawState, MidiRawState, and KbmRawState.
private void CombineOutputStates()Called by: PollingLoop() (every active cycle)
Thread safety: Uses non-allocating FindByPadIndex(padIndex, _padIndexBuffer) for zero-allocation lookups. The CombinedOutputStates[] array is written by this step and read by Steps 4b, 5, 6, and the UI timer. Since Gamepad is a small struct and the engine thread is the sole writer, reads from other threads see either the old or new value (no tearing on aligned word-sized fields).
Error handling: Per-slot try/catch. On exception, clears the slot's combined state to zero.
For each of the 16 slots:
- Find all UserSettings mapped to this slot via
FindByPadIndex(padIndex, _padIndexBuffer) - Determine slot type flags:
isCustomVJoy,isMidi,isKbm - 0 devices: clear all applicable state arrays for this slot
- 1 device: direct struct copy -- no merge needed (optimization for the common case)
-
N devices: iterate and call
MergeGamepad()for each. Also merge type-specific raw states:- Custom vJoy:
MergeVJoyRaw()(first device is copied, subsequent are merged) - MIDI:
MidiRawState.Combine()(static method) - KBM:
KbmRawState.Combine()(static method)
- Custom vJoy:
private static void MergeGamepad(ref Gamepad dest, ref Gamepad src)| Field | Merge Rule | Rationale |
|---|---|---|
Buttons |
OR (dest.Buttons |= src.Buttons) |
Any device can activate any button |
LeftTrigger |
MAX (if (src > dest) dest = src) |
Highest trigger value wins |
RightTrigger |
MAX | Highest trigger value wins |
ThumbLX |
Largest absolute magnitude wins | Allows one device to control left stick, another right stick, without interference |
ThumbLY |
Largest absolute magnitude wins | |
ThumbRX |
Largest absolute magnitude wins | |
ThumbRY |
Largest absolute magnitude wins |
private static void MergeVJoyRaw(ref VJoyRawState dest, ref VJoyRawState src)| Field | Merge Rule |
|---|---|
Axes[] |
Largest absolute magnitude wins (per axis, with Math.Min on array lengths) |
Buttons[] |
OR (per uint word) |
Povs[] |
First non-centered wins (dest centered + src non-centered -> use src) |
File: InputManager.Step4b.EvaluateMacros.cs
Evaluates macro trigger conditions and injects macro actions into the combined gamepad/vJoy state. Runs after Step 4 (CombineOutputStates) and before Step 5 (VirtualDevices). This step also contains the Windows Core Audio COM interfaces for system/app volume control and the Win32 SendInput helpers for keyboard/mouse output.
private void EvaluateMacros()Called by: PollingLoop() (every active cycle)
Thread safety: Reads MacroSnapshots[i] atomically (reference read). The UI thread writes the reference at 30Hz via InputService. The macro MacroItem objects contain mutable state (IsExecuting, CurrentActionIndex, ActionStartTime, WasTriggerActive, MouseAccumulator) that is only written by this step on the engine thread -- the UI thread only reads these for display purposes.
Error handling: Per-slot try/catch. A macro error does not affect other slots.
For each slot (0-15):
- Read
MacroSnapshots[i]-- if null or empty, skip - Delegate to type-specific evaluator:
-
EvaluateSlotMacros(ref Gamepad, MacroItem[])for standard slots (Xbox/DS4/Gamepad-preset vJoy/KBM) -
EvaluateSlotMacrosCustomVJoy(ref VJoyRawState, MacroItem[])for custom vJoy slots (operates onuint[]button words instead ofushortGamepad.Buttons)
-
Combo trigger evaluation -- all active components must match simultaneously (AND logic across categories):
-
Button flags: Three sub-types (checked via priority):
-
Raw device buttons (
UsesRawTrigger): ReadsFindOnlineDeviceByInstanceGuid(macro.TriggerDeviceGuid).InputState.Buttons[rawIndices[i]]. Bypasses the mapping pipeline -- reads directly from the physical device's raw button state. -
Custom vJoy button words (
UsesCustomTrigger): Checks(raw.Buttons[w] & tw[w]) == tw[w]against the combined VJoyRawState. -
Xbox bitmask (default):
(gp.Buttons & triggerButtons) == triggerButtonsagainst the combined Gamepad.
-
Raw device buttons (
-
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)
-
-
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. UsesFindOnlineDeviceByInstanceGuidto read raw POV from the trigger device.
Always trigger mode: When TriggerMode == Always, the trigger check is skipped entirely and triggerActive = true. The macro starts on the first frame and runs every frame. Useful for continuous axis-to-mouse or axis-to-volume mappings.
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).
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.
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
}Actions are classified as either sequential or continuous:
-
Sequential actions (ButtonPress, ButtonRelease, KeyPress, KeyRelease, Delay, AxisSet, MouseButtonPress, MouseButtonRelease): Execute one at a time in order, advancing via
AdvanceAction(macro)when the action'sDurationMselapses. - Continuous actions (SystemVolume, AppVolume, MouseMove, MouseScroll): Run every frame regardless of position in the sequence. This allows e.g., MouseMove X + MouseMove Y in the same macro to both execute simultaneously.
private void ExecuteMacroActions(ref Gamepad gp, MacroItem macro)- Run ALL continuous actions every frame (iterate entire action list, skip non-continuous)
-
Process current sequential action (skip over continuous ones in the sequence):
-
ExecuteSequentialAction(ref gp, macro, action)-- handles per-type logic
-
-
Sequence complete: If all actions are continuous, stay executing. Otherwise, handle repeat logic:
- Decrement
RemainingRepeats - If repeats remain (or
UntilRelease), wait forRepeatDelayMsthen restart - Otherwise, set
IsExecuting = false
- Decrement
-
MouseMove: Uses
MouseAccumulator(per-actionfloatfield) for sub-pixel precision. Each frame:action.MouseAccumulator += deflection * action.MouseSensitivity; int delta = (int)action.MouseAccumulator; action.MouseAccumulator -= delta;
The integer part is sent via
SendMouseMoveInput(dx, dy)using Win32SendInput. The fractional remainder stays in the accumulator for the next frame. Axis source determines direction: LeftStickY/RightStickY map to Y movement, others to X. -
MouseScroll: Same accumulator pattern. Non-zero integer part sent via
SendMouseScrollInput(delta * 120)(120 = WHEEL_DELTA). -
Axis source: When
action.AxisSource == MacroAxisSource.InputDevice, reads directly from the physical device viaReadAxisFromDevice(action)instead of the combined Gamepad state. TheInvertAxisflag flips the value.
When macro.ConsumeTriggerButtons is true and the trigger is active:
- For standard slots:
gp.Buttons &= (ushort)~macro.TriggerButtons-- AND-NOT the trigger button flags out of the combined Gamepad - For custom vJoy slots:
raw.Buttons[w] &= ~tw[w]-- clear trigger button words - Only applies to non-raw triggers (raw device buttons are not part of the combined state)
private void SetSystemVolume(float volume, bool showOsd = true)Uses Windows Core Audio COM (IAudioEndpointVolume.SetMasterVolumeLevelScalar) for precise volume setting. Features:
- Change detection: Skips redundant COM calls when volume delta < 0.4% (1/256 tolerance)
-
OSD trigger: Sends a net-zero
VK_VOLUME_UP+VK_VOLUME_DOWNpair to show the modern Windows volume flyout, rate-limited to every 200ms (~5 Hz) - Correction window: After an OSD trigger, keeps correcting for 150ms to counteract the async VK_VOLUME key events that shift the volume by ~2%
- Lazy initialization: COM endpoint is created on first call, cached for subsequent calls
-
Permanent failure: If COM initialization fails, sets
_audioEndpointFailed = trueand stops trying
private void SetAppVolume(float volume, string processName)Enumerates audio sessions via IAudioSessionManager2, identifies sessions by process ID, and sets volume via ISimpleAudioVolume. Uses direct vtable calls (GetProcessIdFn, SetMasterVolumeFn) to bypass QueryInterface limitations from elevated processes.
Change detection: Per-process _lastAppVolumes dictionary with 0.4% tolerance.
private static void SendKeyInput(ushort virtualKeyCode, bool keyUp)
private static void SendMouseMoveInput(int dx, int dy)
private static void SendMouseButtonInput(MacroMouseButton button, bool down)
private static void SendMouseScrollInput(int delta)All use Win32 SendInput with INPUT_KEYBOARD or INPUT_MOUSE. Key presses map VK to scan code via MapVirtualKey(MAPVK_VK_TO_VSC). Multi-key sequences press in forward order and release in reverse order.
File: InputManager.Step5.VirtualDevices.cs
Submits combined gamepad states to virtual controllers (ViGEm Xbox 360, ViGEm DS4, vJoy, MIDI, KeyboardMouse). Manages virtual controller lifecycle: creation, destruction, type changes, activity tracking, vJoy descriptor synchronization, and ViGEm ordering.
private void UpdateVirtualDevices()Called by: PollingLoop() (every active cycle)
Thread safety: SlotControllerTypes[] is written by the UI thread at 30Hz and read by this step at ~1000Hz. Since the values are single-word enum writes, reads are always consistent (no tearing). The VJoySyncLock protects vJoy descriptor sync from concurrent SwapSlotData operations.
Error handling: The overall method has no try/catch, but Pass 3 (report submission) wraps each slot in try/catch. VC creation failures set _createCooldown[padIndex] = CreateCooldownCycles to prevent per-frame retry. ViGEm client initialization failures set _vigemClientFailed = true permanently.
| Field | Type | Description |
|---|---|---|
_vigemClient |
static ViGEmClient |
Shared ViGEm client (one per process). Lazy-initialized. |
_vigemClientLock |
static object |
Lock for double-checked lazy initialization. |
_vigemClientFailed |
static bool |
Permanent failure flag -- once ViGEm init fails, never retry. |
_virtualControllers |
IVirtualController[MaxPads] |
Virtual controller instances per slot. null = no VC for this slot. |
SlotControllerTypes |
VirtualControllerType[MaxPads] |
Configured type per slot. Written by UI at 30Hz, read by Step 5 at ~1000Hz. |
SlotVJoyConfigs |
VJoyDeviceConfig[MaxPads] |
Per-slot vJoy HID descriptor config (axes, buttons, POVs). |
SlotVJoyIsCustom |
bool[MaxPads] |
true = custom vJoy preset (raw axis/button pipeline), false = gamepad preset. |
_midiConfigs |
MidiSlotConfig[MaxPads] |
Per-slot MIDI configuration snapshot (channel, velocity, CC/note numbers). |
_activeVigemCount |
int |
Currently connected ViGEm controllers (Xbox + DS4). Used by Step 1 filter. |
_activeXbox360Count |
int |
Currently connected ViGEm Xbox 360 controllers. |
_activeDs4Count |
int |
Currently connected ViGEm DS4 controllers. |
_expectedXbox360Count |
int |
Pre-initialized count for first-cycle filtering (set by PreInitializeVigemCounts). |
_expectedDs4Count |
int |
Pre-initialized count for first-cycle filtering. |
_slotInactiveCounter |
int[MaxPads] |
Consecutive inactive cycles per slot. |
SlotDestroyGraceCycles |
const int |
10000 (~10 seconds at 1000Hz before destroying an inactive VC). |
_createCooldown |
int[MaxPads] |
Cooldown counter after failed creation. Counts down each cycle. |
CreateCooldownCycles |
const int |
2000 (~2 seconds between creation retries). |
_slotInitializing |
bool[MaxPads] |
true while a VC is being created or reconfigured. Read by UI for flashing green indicator. |
_lastStep5VJoyConfigs |
VJoyDeviceConfig[] |
Cached vJoy configs for detecting content changes without count changes. |
VJoySyncLock |
object |
Lock protecting vJoy descriptor sync from concurrent SwapSlotData. |
_vJoySyncCycleCount |
int |
Grace period counter for vJoy sync (prevents premature cleanup during startup). |
VJoyStartupGraceCycles |
const int |
5000 (~5 seconds of startup grace before allowing vJoy node removal). |
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_slotInitializingif slot is active -
Slot deleted/disabled (
!SlotCreated || !SlotEnabled): Destroy immediately (no grace period), zero vibration -
Slot active (
IsSlotActivereturns true): Reset inactive counter, flaganyNeedsCreateif no VC -
No devices mapped (
!HasAnyDeviceMapped): Destroy immediately (user explicitly unassigned all devices) -
Device mapped but offline (transient disconnect): Increment
_slotInactiveCounter. Destroy VC afterSlotDestroyGraceCycles(10 seconds). This grace period preserves rumble feedback through brief USB hiccups.
Pass 1b: Sync vJoy registry descriptor count (under VJoySyncLock)
- Count
totalVJoyNeeded: slots with running vJoy VCs + active slots that will create one - Skip removal during startup grace period (
_vJoySyncCycleCount < VJoyStartupGraceCycles) to prevent transienttotalVJoyNeeded=0from deleting nodes - If count changed (descriptor count !=
CurrentDescriptorCount): a. Destroy inactive vJoy VCs (active ones survive and will re-acquire lower IDs) b. Build per-device HID descriptor configs array indexed by device ID (1-based), not slot:- Pass A: Place configs for existing VCs at their current device ID position
- Pass B: Fill remaining positions with overflow VCs and new slots
c. Call
VJoyVirtualController.EnsureDevicesAvailable(totalVJoyNeeded, deviceConfigs)d. Force existing VCs toReAcquireIfNeeded()(re-claim device IDs after node restart) e. Fix device ID ordering: if a surviving VC has an out-of-sequence ID, destroy it for recreation in Pass 2
- If count unchanged but config content changed (axes/buttons/POVs): triggers descriptor rewrite inside
EnsureDevicesAvailable, marks slots as initializing
Pass 1c: Ensure ViGEm VC ordering across cycles
ViGEm assigns XInput/DS4 indices based on Connect() call order. When a lower-numbered slot needs a new VC but higher-numbered slots already have same-type VCs (created in a previous cycle), the new VC would get a higher index. Fix: destroy any same-type VCs at higher slot indices so they can be recreated in ascending order alongside the new one in Pass 2.
Pass 2: Create virtual controllers in ascending slot order
for (int padIndex = 0; padIndex < MaxPads; padIndex++)
{
if (_virtualControllers[padIndex] == null && _slotInactiveCounter[padIndex] == 0)
{
// vJoy: skip inactive slots (device IDs are scarce)
// Cooldown: skip if still counting down from failed creation
var vc = CreateVirtualController(padIndex);
_virtualControllers[padIndex] = vc;
if (vc != null && vc.IsConnected) _slotInitializing[padIndex] = false;
else if (vc == null) _createCooldown[padIndex] = CreateCooldownCycles;
}
}Pass 3: Submit reports for active slots
For each slot with a connected VC and zero inactive counter:
if (vc is VJoyVirtualController vjoyVc && SlotVJoyIsCustom[padIndex])
vjoyVc.SubmitRawState(CombinedVJoyRawStates[padIndex]);
else if (vc is MidiVirtualController midiVc)
midiVc.SubmitMidiRawState(CombinedMidiRawStates[padIndex]);
else if (vc is KeyboardMouseVirtualController kbmVc)
kbmVc.SubmitKbmState(CombinedKbmRawStates[padIndex]);
else
vc.SubmitGamepadState(CombinedOutputStates[padIndex]);private IVirtualController CreateVirtualController(int padIndex)- Check prerequisites: ViGEm client required for Xbox 360 and DS4 (not for vJoy/MIDI/KBM)
-
For Xbox 360: Snapshot XInput slot mask BEFORE connecting via
GetXInputConnectedSlotMask() - Create concrete controller instance based on
SlotControllerTypes[padIndex]:-
Xbox360VirtualController(_vigemClient)(default) DS4VirtualController(_vigemClient)-
CreateVJoyController()-- finds free device ID viaFindFreeDeviceId() -
CreateMidiController(padIndex)-- creates virtual MIDI endpoint with computed instance number KeyboardMouseVirtualController(padIndex)
-
- Call
vc.Connect() -
For Xbox 360: Wait up to 50ms (spin-wait with
Thread.SpinWait(100)) for XInput slot to appear (mask delta) - Increment
_activeVigemCount/_activeXbox360Count/_activeDs4Count - Register feedback callback:
vc.RegisterFeedbackCallback(padIndex, VibrationStates)-- wires ViGEm'sFeedbackReceivedevent to write motor values intoVibrationStates[padIndex]
private void DestroyVirtualController(int padIndex)- For Xbox 360: Snapshot XInput slot mask
vc.Disconnect()- For Xbox 360: Wait up to 50ms for slot to disappear from XInput stack
-
vc.Dispose()-- releases native ViGEm target handle (vigem_target_free). Without this, ViGEm targets leak and phantom USB devices remain. -
In
finallyblock: Decrement active counters (Math.Max(0, count - 1)to prevent underflow). MUST execute even if Disconnect/Dispose throws, otherwise_activeXbox360Countstays inflated and the Step 1 filter over-filters on subsequentUpdateDevicescycles.
private bool IsSlotActive(int padIndex)Returns true if:
SettingsManager.SlotCreated[padIndex] && SettingsManager.SlotEnabled[padIndex]- At least one online device is mapped to this slot (found via
FindByPadIndex+FindOnlineDeviceByInstanceGuid)
private bool HasAnyDeviceMapped(int padIndex)Returns true if any UserSetting (online OR offline) has MapTo == padIndex. Distinguishes "user unassigned all devices" (no mappings -> destroy immediately) from "device temporarily offline" (mapping exists -> grace period).
[DllImport("xinput1_4.dll", EntryPoint = "#100")]
private static extern uint XInputGetStateEx(uint dwUserIndex, ref XInputStateInternal pState);
private static uint GetXInputConnectedSlotMask()Probes XInput slots 0-3 (API limit) via the undocumented ordinal #100 (XInputGetStateEx, which reports Guide button unlike the public API). Returns a 4-bit mask of connected XInput devices. Used only for detecting when a new ViGEm Xbox 360 controller appears or disappears, to synchronize slot assignment.
public static void CleanupStaleVigemDevices()Called BEFORE Start(). Enumerates XnaComposite device class via pnputil /enum-devices, identifies ViGEm devices by short numeric serial (1-2 digits vs. real Xbox controllers' longer hardware serials), removes them via pnputil /remove-device /subtree. This cleans up orphaned device nodes from previous sessions where the feeder app crashed without calling Dispose().
public void PreInitializeVigemCounts(int xbox360Count, int ds4Count)Must be called before Start() so the first UpdateDevices() cycle (which runs immediately) can filter ViGEm devices correctly. Without this, _activeXbox360Count is 0 on the first cycle, causing all stale ViGEm 045E:028E devices to pass through the Step 1 filter as "real" Xbox controllers, creating a feedback loop.
File: InputManager.Step6.RetrieveOutputStates.cs
Copies combined gamepad states for UI display. This is the simplest step in the pipeline.
private void RetrieveOutputStates()Called by: PollingLoop() (every active cycle)
Thread safety: Writes to RetrievedOutputStates[] and RetrievedKbmRawStates[] (struct copies). The UI timer reads these arrays at 30Hz. Since Gamepad is a small struct (8 fields), individual field reads are atomic on x64, but a full struct read could theoretically see a mix of old/new fields during a concurrent write. In practice, the visual impact is negligible (one frame of mixed state at worst).
Error handling: Per-slot try/catch. On exception, clears the slot to zero.
For each of the 16 slots:
- Read
_virtualControllers[padIndex] - If VC is non-null and connected:
-
RetrievedOutputStates[padIndex] = CombinedOutputStates[padIndex](struct copy) - For KBM VCs: also copy
RetrievedKbmRawStates[padIndex] = CombinedKbmRawStates[padIndex]
-
- Otherwise:
RetrievedOutputStates[padIndex].Clear()andRetrievedKbmRawStates[padIndex].Clear()
This approach replaced the original XInput P/Invoke readback (XInputGetStateEx). Direct copy is both more universal (works for DS4, vJoy, MIDI, KBM -- not just Xbox 360) and more accurate (shows exactly what was submitted to the virtual controller, without the ~1ms round-trip delay of XInput readback).
The pipeline has three concurrent threads of execution:
| Thread | Role | Writes | Reads |
|---|---|---|---|
Engine (PadForge.InputManager, AboveNormal) |
Runs the 6-step pipeline at ~1000Hz | All Combined*States, Retrieved*States, MotionSnapshots, device InputState, VCs |
MacroSnapshots, SlotControllerTypes, VibrationStates, IsIdle, PollingIntervalMs
|
| UI (WPF Dispatcher, 30Hz timer) | Reads output states for display, writes configuration |
MacroSnapshots, SlotControllerTypes, SlotVJoyConfigs, TestRumbleTargetGuid, IsIdle
|
Retrieved*States, CurrentFrequency, device InputState |
| ViGEm callback (Thread pool) | Receives game rumble feedback | VibrationStates[padIndex].LeftMotorSpeed/RightMotorSpeed |
(none) |
Synchronization mechanisms used:
-
SyncRootlocks onUserDevicesandUserSettingsfor collection modification/iteration -
VJoySyncLockfor vJoy descriptor sync during slot swaps -
_vigemClientLockfor double-checked lazy initialization of the ViGEm client -
volatileon_runningand_idlefor cross-thread flag visibility - Atomic reference swaps for
ud.InputState(object reference) andMacroSnapshots[i](array reference) - Struct value copies for
Gamepadand other small value types (word-aligned fields are atomic on x64)
Physical Device (SDL3 / Raw Input / WebController)
|
v [Step 2: GetCurrentState]
CustomInputState (unsigned axes 0-65535, bool[] buttons, centidegree POVs, gyro/accel)
|
v [Step 3: MapInputToGamepad / MapInputToVJoyRaw / MapInputToMidiRaw / MapInputToKbmRaw]
| Parse mapping descriptors, apply axis conversions, apply dead zones + curves
|
v per-UserSetting OutputState
Gamepad struct (signed axes, XInput button bitmask, ushort triggers)
-- or --
VJoyRawState (signed short[] axes, uint[] button words, int[] POVs)
-- or --
MidiRawState (byte[] cc values, bool[] note states)
-- or --
KbmRawState (VK codes, mouse delta/buttons)
|
v [Step 4: CombineOutputStates]
| Merge multiple devices per slot (OR/MAX/magnitude rules)
|
v per-slot combined state
CombinedOutputStates[slot] / CombinedVJoyRawStates[slot] / etc.
|
v [Step 4b: EvaluateMacros]
| Trigger state machine, inject button/axis/volume/mouse actions (in-place modification)
|
v [Step 5: UpdateVirtualDevices]
| Create/destroy VCs, submit reports
|
IVirtualController.SubmitGamepadState() / SubmitRawState() / SubmitMidiRawState() / SubmitKbmState()
| | | |
v v v v
ViGEm Xbox 360 / DS4 vJoy (HID IOCTL) MIDI (WinRT) Win32 SendInput
(XInput / DirectInput) (joy.cpl) (MIDI endpoint) (keyboard + mouse)
|
v [Step 6: RetrieveOutputStates]
RetrievedOutputStates[slot] -> UI Display (dashboard gauges, axis bars, button indicators)
<--- Feedback path (game -> controller -> PadForge -> physical device) --->
Game calls XInputSetState() -> ViGEm FeedbackReceived -> VibrationStates[slot]
-> Step 2: ApplyForceFeedback() -> SDL_RumbleJoystick() -> Physical device vibrates
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();
}public struct VJoyRawState
{
public short[] Axes; // Signed short range, up to 16 axes
public uint[] Buttons; // 4 x 32-bit words = 128 buttons max
public int[] Povs; // Up to 4, -1=centered, 0-35900=direction (centidegrees)
public static VJoyRawState Create(int nAxes, int nButtons, int nPovs);
public void SetButton(int index, bool pressed);
public bool IsButtonPressed(int index);
public void Clear(); // Zeros axes, clears buttons, sets POVs to -1 (centered)
}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)
}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);
}public enum VirtualControllerType
{
Xbox360 = 0, // ViGEm Xbox 360 (appears in XInput stack)
DualShock4 = 1, // ViGEm DualShock 4 (appears in DirectInput)
VJoy = 2, // vJoy virtual joystick (HID, appears in joy.cpl)
Midi = 3, // Windows MIDI Services virtual endpoint
KeyboardMouse = 4 // Win32 SendInput keyboard + mouse
}