-
Notifications
You must be signed in to change notification settings - Fork 6
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
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/.
- InputManager.cs. Main Class
- Step 1: UpdateDevices
- Step 2: UpdateInputStates
- Trigger Rumble Routing
- Step 3: UpdateOutputStates
- Mouse Cursor as a Mapping Source
- Shift Layer Activators and the Cycle Cursor
- Step 4: CombineOutputStates
- Step 4b: EvaluateMacros
- Step 5: VirtualDevices
- Step 6: RetrieveOutputStates
- Thread Safety Summary
- Data Flow Summary
- Key Types Reference
Namespace: PadForge.Common.Input
public partial class InputManager : IDisposable| 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 |
| 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) |
| 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(). |
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. |
public InputManager()Initializes VibrationStates[] with new Vibration() for each slot.
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 and hides Xbox controllers
Post-init:
- 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 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().
public void Start()- Guards against double-start (
_running) or disposed state - Calls
InitializeSdl(). Aborts on failure - Calls
RawInputListener.Start(). Starts hidden message-only window for keyboard/mouse enumeration - Creates and starts the polling thread
Thread safety: Safe to call from any thread. Subsequent calls are no-ops.
public void Stop()- Sets
_running = false - Joins polling thread with 3-second timeout
- Stops
RawInputListener - Calls
StopAllForceFeedback(). Best-effort stop on all devices - Calls
DestroyAllVirtualControllers(). Disconnects and disposes all VCs - 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.
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
-
adjustedTargetfloors attargetTicks / 4to 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 = truefor 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.
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.
private void UpdateMotionSnapshots()Called after Step 2. For each pad slot:
- Finds all UserSettings mapped to this slot via
FindByPadIndex - Looks up each 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). The DSU server may be null (no-op).
public void Dispose()Calls Stop() then ShutdownSdl(). Finalizer calls Dispose() as a safety net; 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 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.
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.
| 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 |
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
_openedSdlInstanceIds(already open) - Create
SdlDeviceWrapperand callwrapper.Open(instanceId). Opens as gamepad if recognized, joystick otherwise -
FindOrCreateUserDevice(wrapper.InstanceGuid, wrapper.ProductGuid). Find existing or create new -
ud.LoadFromSdlDevice(wrapper). Populate capabilities, name, VID/PID - Mark
ud.IsOnline = true - 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:
- Create
SdlKeyboardWrapper, callOpen(kb) -
FindOrCreateUserDevice(wrapper.InstanceGuid), load and mark online - 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 backgroundTask.Runthread via_rawInputEnumPending/_rawInputEnumRunningflags. The polling thread consumes cached results from_cachedKeyboardsand_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.
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:
- Exact match by InstanceGuid
-
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. -
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 runtime fields including IsOnline = false.
public void RegisterExternalDevice(WebControllerDevice device)
public void UnregisterExternalDevice(Guid instanceGuid)Called by WebControllerServer on browser controller client connect/disconnect. 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/s) to avoid GC pressure. Allocating overloads exist for UI-thread use where convenience matters more.
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). Runs even in idle mode so the Devices page raw input preview works.
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.
-
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. 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,
ForceRawJoystickModeusesSDL_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. For PTP devices,_ptpReader.ReadIntoallocatesstate.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. Setud.InputStateTime = DateTime.UtcNowe. 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. CallApplyForceFeedback(ud). Apply rumble to the physical device.
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.
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:
- 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: 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.
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.
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) |
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.
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.
private void UpdateTriggerRouteEngageStates()Runs once per poll across all 16 slots (InputManager.cs ~1167–1234). For each created slot:
- Under
UserSettings.SyncRoot, pick the first UserSetting mapped to the slot whose left or right side passesRouteSideActive. A slot with no active route source clearsTriggerRouteEngagedLeft/Right[slot], the edge-detection scratch (_prevTriggerRouteLeftDown/RightDown), and_routeSourceLeft/Right[slot], then continues. - Resolve and cache the per-side source byte (
ParseRouteSource, zeroed when that side failsRouteSideActive), scale (ParseRouteScaleinto_routeScaleLeft/Right), and Redirect flag. - 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 isNone) and gated afterward.
TriggerRouteEngagedLeft / TriggerRouteEngagedRight are volatile bool[MaxPads].
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().
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.
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 outputThe 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.
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.
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.
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.
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.
-
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-deadzone snapshot for UI preview) g. Type-specific raw mapping based onSlotControllerTypes[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
- Extended Custom HID:
private static Gamepad MapInputToGamepad(CustomInputState state, PadSetting ps, out Gamepad rawMapped)Core mapping function. Processing 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 maps independently. Otherwise, the combinedDPaddescriptor 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 negated viaNegateAxis()to convert from unsigned pipeline (0=up) to XInput convention (positive Y = up). -
Snapshot raw mapped state (
rawMapped = gp). Captured before deadzone processing so the UI preview avoids double-processing -
Trigger deadzones:
ApplyTriggerDeadZonewith deadzone, anti-deadzone, max range, and optional sensitivity curve LUT -
Center offsets:
ApplyCenterOffset(value, offsetPercent). Shifts axis by a percentage of full range. Applied before deadzone. Compensates for stick drift. -
Stick deadzones:
ApplyDeadZonewith 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
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
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,
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. GlobalAxisToButtonThreshold(default 50%). Used whendeadZonePercentis 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 * twheretis 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.
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.
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.
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()) 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).
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.
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:
- Read intent buttons:
up = ReadButtonLikeBool(state, src.ParamUp)(positive direction),down = ReadButtonLikeBool(state, src.ParamDown)(negative direction). OnlyButton NandPOV N Dirdescriptors read as true. Analog inputs are not a sensible up/down trigger. - Compute per-tick fractions of full travel:
attackStep = dt / ParamAttackTimeandreleaseStep = dt / ParamReleaseTime. A time of 0 means instant (step = 1.0). - Drive the envelope:
-
uponly. If the value is still on the negative side (v < 0), return toward zero at the release rate first, then attack+1once it crosses zero. Otherwise attack+1atattackStep. -
downonly. Mirror image: cross back through zero from the positive side, then attack-1. -
neither (or both) held,
ParamAutocenter == true. Ramp back toward zero atreleaseStep. -
neither held,
ParamAutocenter == false. Cruise: hold the last value.
-
- 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. |
| 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.
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).
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 (
atan2delta × deflection) into a per-row winding angle, unwinds it below full deflection, and remaps|angle| / range × 2raised toWind Powerto 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|)oratan2(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 (
asinof 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.
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).
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):
-
Sensitivity curve:
CurveLut.Lookup(lut, remapped). Transforms [0,1] value through a user-defined response curve -
Anti-deadzone:
output = adzNorm + remapped * (1.0 - adzNorm). Offsets output minimum so small movements register past the game's internal deadzone -
Linear adjustment:
output = remapped * linearFactor + output * (1.0 - linearFactor). Blends raw linear and anti-deadzone-adjusted output -
Scale and clamp:
sign * output * 32767.0, clamped toshortrange
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).
private static ushort ApplyTriggerDeadZone(ushort value, double deadZone, double antiDeadZone,
double maxRange, double[] lut = null)- Normalize to 0.0–1.0
- Deadzone: values below threshold zeroed
- Max range: cap input ceiling
- Remap from
[dzNorm, maxNorm]to[0, 1] - Sensitivity curve LUT (if provided)
- Anti-deadzone: offset output minimum
- Scale to 0–65535 and clamp
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 |
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
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: Direction buttons (
ExtendedPov0Up, etc.) mapped to continuous POV values (0–35900 centidegrees, 0xFFFFFFFF = centered) viaDirectionToContinuousPov() -
Deadzones: Applied per-stick and per-trigger using the same
ApplySingleDeadZone/ApplyTriggerDeadZonemethods
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.
File: PadForge.App/Services/CursorControlService.cs
A single System.Threading.Timer ticks every SampleIntervalMs = 5 (200 Hz). Each Tick():
- Resolves the primary monitor via
TryGetPrimaryRect:MonitorFromPoint((0,0), MONITOR_DEFAULTTOPRIMARY)thenGetMonitorInfo, returningrcMonitor. Re-queried every tick, so a resolution change is picked up on the next sample with noWM_DISPLAYCHANGEhook. Returns early if the monitor or a width<= 0can't be resolved (the previously published sample stays). - Enforces the cursor-write contracts (
EnforcePin,EnforceClamp) before sampling, so the published value reflects the post-write position. - Samples the cursor with
GetCursorPos. - 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 heightSo 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).
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.
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.
ClassifyDescriptorreturnsSourceType.MouseCursorfor any descriptor starting with"Mouse Position ". The check sits after theGyrocheck and beforeMidiso 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.
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.
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.
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(slotIndex, mappingSet, thisDeviceState, thisDeviceGuid) runs once per device pass. It walks mappingSet.ShiftActivators and:
- Updates latch state via
UpdateActivatorStateonly on the activator's owning-device pass (act.DeviceGuidmatchesthisDeviceGuid, oract.DeviceGuidis 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. - Rebuilds
_suppressedSourcesBySlot[slot], the "Postpone the mapping" suppression set. An activator that exerted this frame (itsWasDown[i]is true) and hasPostponeMapping = falseadds itsdeviceGuid|descriptorkey so its own press does not also fire that source's normal row. ACycleactivator suppresses each of its two buttons by its own latch (Next viaWasDown, Previous viaCyclePrevWasDown). - Returns
CustomLayerif non-empty (Latch / Cycle override), otherwise theLayerMaskof the activator at the tail ofStack, otherwise"Base".
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.
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.
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.CycleIncludeBasedefaults tofalse, 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.
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.
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.
For each of the 16 slots:
- Find all UserSettings mapped to this slot via
FindByPadIndex(padIndex, _padIndexBuffer) - Determine slot type flags:
isCustomExtended,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:- Extended Custom HID:
MergeExtendedRaw()(first device is copied, subsequent are merged) - MIDI:
MidiRawState.Combine()(static method) - KBM:
KbmRawState.Combine()(static method)
- Extended Custom HID:
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) |
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.
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.
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 / PlayStation / Gamepad-preset Extended / KBM) -
EvaluateSlotMacrosCustomExtended(ref ExtendedRawState, MacroItem[])for custom Extended 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. -
Extended Custom HID button words (
UsesCustomTrigger): Checks(raw.Buttons[w] & tw[w]) == tw[w]against the combined ExtendedRawState. -
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, trigger check is skipped and triggerActive = true. 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 (ButtonPress, ButtonRelease, KeyPress, KeyRelease, Delay, AxisSet, MouseButtonPress, MouseButtonRelease): Execute one at a time, advancing via
AdvanceAction(macro)whenDurationMselapses. - 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)- 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). 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 viaReadAxisFromDevice(action)instead of the combined Gamepad.InvertAxisflips the value.
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.
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 fieldsCursorRecenterMode,CursorPinMode/CursorPinX/CursorPinY, andCursorClampMode/CursorClampInsetX/CursorClampInsetY. Extracted from the save path'sBuildMacroData, so the in-memory copy and the on-disk save use the same mapping. -
LoadMacroFromData(MacroData md, VirtualControllerType outputType, int? extendedButtonCount)->MacroItem. Builds a freshMacroItemplus freshMacroActionobjects (no shared references with the source). It then rebinds the copy to the target slot's output:MacroButtonNames.DeriveStyle(outputType)setsButtonStyle, andCustomButtonCountis set toextendedButtonCountfor an Extended slot, otherwise11, 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.
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:
-
EnforcePin(r). If pinned, write the cursor back to the pin target on the pinned axes (SetCursorPosonly when a coordinate differs). -
EnforceClamp(r). If clamped, push the cursor inside the inset rectangle on the clamped axes (write-only-when-different). -
GetCursorPos+ normalize bywidth/10, publish_normX/_normYthroughSourceCoercion.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.
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:
-
SettingsManager.FindSettingByInstanceGuidAndSlot(instanceGuid, slotIndex)must be non-null, confirming the device is assigned to this macro's slot. -
FindOnlineDeviceByInstanceGuid(instanceGuid)must resolve an online device, after which the trigger checks additionally require a liveInputStatewith aButtons/Povsarray.
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).
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)
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 |
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).
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.
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 whennormalized >= threshold -
AxisTriggerDirection.Negative. Fires whennormalized <= threshold(inverted sense)
State tracking: gm.WasTriggerActive implements edge detection. The action fires only on the rising edge (triggerActive && !wasTriggerActive).
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.
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.
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.
| 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.
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, flaganyNeedsCreateif no VC -
No devices mapped (
!HasAnyDeviceMapped): Destroy immediately -
Device mapped but offline (transient disconnect): Increment
_slotInactiveCounter. Destroy afterSlotDestroyGraceCycles(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]);
}private IVirtualController CreateVirtualController(int padIndex)- Check prerequisites: HIDMaestro client required for Xbox, PlayStation, and Extended (not for MIDI / KBM)
-
For Xbox: Snapshot XInput slot mask BEFORE connecting via
GetXInputConnectedSlotMask() - 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 toHMaestroProfileCatalog.GetProfileByIdfor synthetic entries likepadforge-custom), applies per-slot product-string / layout / FFB overrides throughHMProfileBuilder+HidDescriptorBuilderfor customized Extended slots, then returnsnew HMaestroVirtualController(_hmaestroContext, effectiveProfile, type) -
CreateMidiController(padIndex). Creates virtual MIDI endpoint with computed instance number KeyboardMouseVirtualController(padIndex)
-
- Call
vc.Connect() - For Xbox: Spin-wait up to 50 ms for XInput slot to appear (mask delta)
- Increment active counters
- Register feedback callback:
vc.RegisterFeedbackCallback(padIndex, VibrationStates). Wires HIDMaestro'sHMController.OutputReceivedtoVibrationStates[padIndex]
private void DestroyVirtualController(int padIndex)- For Xbox: Snapshot XInput slot mask
vc.Disconnect()- For Xbox: Wait up to 50 ms for slot to disappear from XInput
-
vc.Dispose(). Releases the HIDMaestro device throughHMController.Dispose(). Without this, devices leak as phantom HID nodes until the next launch. -
In
finally: Clear_virtualControllers[padIndex]and_slotInitializing[padIndex]even if Disconnect/Dispose throws, so the next Pass 2 can re-create the slot cleanly.
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" (destroy immediately) from "device temporarily offline" (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 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.
File: InputManager.Step6.RetrieveOutputStates.cs
Copies combined gamepad states for UI display. The simplest pipeline step.
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.
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 replaced the original XInput readback (XInputGetStateEx). Direct copy works for every output type and avoids the ~1 ms XInput round-trip.
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:
-
SyncRootlocks onUserDevices/UserSettingsfor collection access - Single-word
SlotControllerTypes[]writes (torn-write-safe on x64) coordinate the UI's reorder with the polling thread's read -
_hmContextLockfor double-checked lazy init of the sharedHMContext -
volatileon_running/_idlefor cross-thread visibility - Atomic reference swaps for
ud.InputStateandMacroSnapshots[i] - Struct value copies for
Gamepadand small value types (word-aligned, 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 / 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
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 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)
}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, // 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
}- Architecture Overview: Solution structure, threading model, design philosophy
-
Engine Library:
Gamepad,CustomInputState,ISdlInputDevice,Vibration,PadSetting -
Services Layer:
InputService(UI-engine bridge),SettingsService,RecorderService -
Virtual Controllers:
IVirtualControllerimplementations consumed by Step 5 - HIDMaestro Deep Dive: Extended HID descriptors, FFB callbacks, device lifecycle (Step 5 details)
-
SDL3 Integration: SDL3 P/Invoke,
SdlDeviceWrapper, sensor reading, haptic -
Settings and Serialization:
SettingsManagerslot arrays,PadSettingmapping descriptors -
DSU Protocol Implementation:
DsuMotionServerbroadcast called after Step 2