-
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
- Step 3: UpdateOutputStates
- 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 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.
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.
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).
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
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.
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