Services Layer¶
Five ViewModel-bridge services carry engine state to the WPF UI and back, with a bench of smaller workers beside them.
Five service classes bridge PadForge.Engine with the WPF UI layer and get full sections on this page: InputService, SettingsService, DeviceService, RecorderService, and ForegroundMonitorService. They run on the WPF dispatcher thread unless noted otherwise. PadForge.App/Services/ holds ten more residents:
| Resident | Role |
|---|---|
DsuMotionServer |
DSU motion server on its own UDP thread. Lifecycle below, protocol on DSU Protocol Implementation |
WebControllerServer |
Phone-as-controller HTTP/WebSocket server. Lifecycle below |
NfcReaderService |
PC/SC monitor for NFC macro triggers (#150). Below |
WorkshopProfileMaterializer |
Workshop import to ProfileData bridge (#9). Below |
WorkshopTuningApplier |
Folds a Workshop import's parked tuning stamps into the assigned device's own settings (#9). Below |
WiiPairingService |
In-app Bluetooth pairing ceremony for Wii controllers (#116), following the sequence Dolphin documents, over the Win32 Bluetooth API |
Ds3PairingService |
DualShock 3 guided USB pairing ceremony (#116): sixpair over WinUSB plus the radio-side device record BthPS3 needs |
Ds3DriverInstaller |
Installs and arms the embedded BthPS3 / BthPS3PSM drivers and binds a docked DS3 to WinUSB, reboot-free |
GyroCalibratorService |
Samples an at-rest controller and writes the per-(device, slot) gyro bias onto its PadSetting |
CursorControlService |
Owns the 200 Hz desktop-cursor timeline feeding the "Mouse Position X" / "Mouse Position Y" sources (#107) |
Engine-side subsystems (3.4). Two more runtime subsystems sit alongside these services.
AudioPassthroughServicedrives controller speaker output on its own worker and Bluetooth threads, and the Remote Link server runs the device-sharing transport. Both are wired throughInputServiceand documented on their own pages: Controller Audio Internals and Remote Link Internals.App-side helpers (3.6.0). Five more App-side services and helpers, added in 3.6.0, run off the dispatcher thread on their own workers or as
staticP/Invoke surfaces.HapticToneServiceandWiiSpeakerServiceturn macro sounds into HD-haptic tones and Wii-speaker PCM.NfcReaderService(withWinScard) owns the PC/SC monitor for NFC macro triggers.BluetoothLinkHelperperforms per-family Bluetooth disconnect. They live inPadForge.Common.Input(andPadForge.ServicesforNfcReaderService), and are documented in App-Side Services and Helpers (3.6.0) below. 4.1.0 added three more to the same section:TouchpadPulseService(#219),SwitchHomeLedSetter(#226), andRumbleAudioService(#236, the Bass Shakers renderer).
graph TB
MW[MainWindow]
IS[InputService]
SS[SettingsService]
DS[DeviceService]
RS[RecorderService]
FMS[ForegroundMonitorService]
IM[InputManager<br/>Polling Thread]
DSU[DsuMotionServer<br/>UDP Thread]
WCS[WebControllerServer<br/>HTTP Thread]
ABD[AudioBassDetector<br/>WASAPI Thread]
MW --> IS
MW --> SS
MW --> DS
MW --> RS
IS --> IM
IS --> DSU
IS --> WCS
IS --> ABD
IS --> FMS
SS -->|Load/Save XML| IS
DS -->|Device events| IS
IS -->|30Hz UI timer| MW
IM -->|1000Hz polling| IS
style IS fill:#f3e5f5
style IM fill:#e1f5fe
style DSU fill:#e8f5e9
style WCS fill:#e8f5e9
style ABD fill:#fff3e0
style FMS fill:#fff3e0
Table of Contents¶
- Architecture Overview
- Threading Model
- InputService
- Constructor and Initialization
- Start / Stop / Dispose
- 30Hz UI Timer Tick
- Dashboard Updates
- Devices Page Raw State
- Mapping Live Values
- Settings Sync (ViewModel to PadSetting)
- Settings Forwarding (OnSettingsPropertyChanged)
- Dashboard Forwarding (OnDashboardPropertyChanged)
- Engine Event Handlers
- Device List Sync
- Per-Device Settings Swap
- Copy / Paste Settings
- Macro Snapshot Sync
- Macro Trigger Recording
- DSU Server Lifecycle
- Web Controller Server Lifecycle
- Audio Bass Detector Lifecycle
- Device Hiding
- Auto-Idle
- Profile Switching
- Slot Reordering
- Test Rumble
- All Public Methods
- All Events
- SettingsService
- Constructor and Initialization
- File Discovery
- Load
- Save
- MarkDirty and Autosave
- Reset and Reload
- Profile Loading
- All Public Methods
- All Events
- DeviceService
- Constructor and Initialization
- Device Assignment
- Slot Management
- Device Hiding Toggle
- All Public Methods
- All Events
- RecorderService
- Constructor
- Recording Flow
- Detection Algorithm
- All Public Methods
- All Events
- ForegroundMonitorService
- How It Works
- All Public Methods
- All Events
- App-Side Services and Helpers (3.6.0)
- HapticToneService
- TouchpadPulseService
- SwitchHomeLedSetter
- WiiSpeakerService
- RumbleAudioService
- NfcReaderService
- WinScard
- BluetoothLinkHelper
- WorkshopProfileMaterializer (v4.1, #9)
- WorkshopTuningApplier (v4.1, #9)
Architecture Overview¶
+-------------------+ 30Hz Timer +-------------------+
| InputManager | ==================> | InputService |
| (background, | reads engine | (UI thread, |
| ~1000Hz poll) | state arrays | 30Hz timer) |
+-------------------+ +---------+---------+
^ |
| writes PadSettings, | pushes to ViewModels
| slot types, macro snapshots v
+-------------------+ +-------------------+
| SettingsManager | | MainViewModel |
| (static, shared) | <================= | PadViewModels |
+-------------------+ | DashboardVM |
^ | DevicesVM |
| | SettingsVM |
| +-------------------+
+-------------------+
| SettingsService |
| (XML load/save) |
+-------------------+
Data flow¶
| Direction | Mechanism | Frequency |
|---|---|---|
| Engine -> UI | InputService reads CombinedOutputStates[], VibrationStates[], CombinedExtendedRawStates[], CombinedMidiRawStates[], CombinedKbmRawStates[] |
30 Hz (UI timer) |
| UI -> Engine | InputService writes SlotControllerTypes[], MacroSnapshots[], _midiConfigs[], _kbmConfigs[] (KBM SOCD #205), plus per-slot Extended config via SyncExtendedConfigToSlot() |
30 Hz (SyncViewModelToPadSettings) |
| UI -> PadSetting | InputService pushes deadzone, force feedback, mapping values to PadSetting objects | 30 Hz (SyncViewModelToPadSettings) |
| Engine event -> UI | DevicesUpdated, FrequencyUpdated, ErrorOccurred marshalled via Dispatcher.BeginInvoke |
On engine event |
| Settings file -> Memory | SettingsService deserializes XML into SettingsManager collections | On load |
| Memory -> Settings file | SettingsService serializes SettingsManager + ViewModel state to XML | On MarkDirty (250ms debounce) |
Button SOCD (4.1.0, discussion #240). KB+M slots get keyboard SOCD (#205) through the
_kbmConfigs[]sync above. Controller slots (Xbox / PlayStation / Nintendo / Extended) have their own SOCD lane that bypasses the 30 Hz sync entirely:MappingSet.SocdMode/SocdPairson the slot's MappingSet, read directly by the engine fromSettingsManager.SlotMappingSetsand applied to the slot's final combined output right before the Step 5 submit (SlotButtonSocd). Gamepad-surface slots pair buttons by mapping target name ("ButtonA:ButtonB"). Raw-surface slots (Extended, Nintendo) pair flat button indices ("12:13"). Pair semantics are the #205SocdCleanerstate machine: LastWins, Neutral, or FirstWins, with the winner's release re-pressing the still-held partner the same frame.
Threading Model¶
PadForge uses three primary threads. Knowing which thread owns what prevents race conditions.
| Thread | Owner | Rate | Responsibilities |
|---|---|---|---|
| UI thread (WPF Dispatcher) | MainWindow | 30 Hz timer | All ViewModel property writes, device list sync, dashboard updates, macro recording, profile switching, settings forwarding |
| Polling thread | InputManager | ~1000 Hz | SDL input read, mapping, deadzone processing, virtual controller output, rumble, DSU broadcast |
| Subsystem threads | Various | Varies | DSU server (UDP), Web controller (HTTP/WebSocket), Audio bass detector (WASAPI), HidHide controller |
Thread-safety conventions¶
| Data | Strategy |
|---|---|
SettingsManager collections (UserDevices, UserSettings) |
SyncRoot lock on both UI and polling threads |
| PadSetting string properties | Atomic reference assignment. UI writes at 30 Hz, polling reads at ~870 Hz |
InputManager arrays (CombinedOutputStates[], VibrationStates[], etc.) |
Simple value copies, no locking |
| Macro snapshots | Atomic array reference swap by UI thread. Polling thread reads the reference |
Engine events (DevicesUpdated, FrequencyUpdated) |
Fire on polling thread, marshalled to UI via Dispatcher.BeginInvoke |
InputService¶
File: PadForge.App/Services/InputService.cs
Implements: IDisposable
Central service bridging the InputManager engine with WPF ViewModels. Owns the InputManager instance, runs the 30 Hz UI timer, and manages all subsystem lifecycles (DSU, web server, audio bass detector, foreground monitor, device hiding).
Constructor and Initialization¶
Constructor:
1. Stores MainViewModel reference, captures Dispatcher.CurrentDispatcher.
2. Subscribes to Strings.CultureChanged for language-change status refresh.
3. Subscribes to SelectedDeviceChanged and MappingsRebuilt on every PadViewModel.
4. Subscribes to DevicesViewModel.PropertyChanged for offline device detail display.
5. Initializes _previousSelectedDevice dictionary (tracks per-pad device GUID for save-before-switch).
Start / Stop / Dispose¶
Start()¶
Startup sequence:
- Cleanup stale HIDMaestro nodes. Removes USB device nodes from previous sessions (crash recovery).
- Create InputManager. Sets
PollingIntervalMsfromSettingsViewModel.PollingRateMs. - Copy slot config. Copies
SlotControllerTypes[], Extended/MIDI configs from PadViewModels to engine. - Subscribe to the NFC tag registry (#150).
NfcTagRegistry.RegistryChanged += OnNfcTagRegistryChanged. On a tag register/remove, the handler re-reads each NFC reader'sDeviceObjectsunderUserDevices.SyncRoot, rebuilds every pad's input picker off the lock so the named tag appears or disappears as a bindable row, and refreshes the Devices-page tag preview if a reader is selected. Subscribed here, after settings load, so the load-time registry fan-out is not double-handled. Torn down inStop(). - Subscribe to engine events.
DevicesUpdated,FrequencyUpdated,ErrorOccurred. - Subscribe to ViewModel changes.
SettingsViewModel.PropertyChanged,DashboardViewModel.PropertyChanged. - Create ForegroundMonitorService. Subscribes to
ProfileSwitchRequired. - Capture default profile snapshot. Uses
PendingDefaultSnapshot(from prior XML) or creates one viaSnapshotCurrentProfile(). - Async Raw Input enumeration. Keyboard and mouse devices are discovered on a background thread via
Task.Run, preventing UI thread stalls from slow HID enumeration. Results are merged into the device list when the task completes. - Start engine.
_inputManager.Start()launches the polling thread. - Start subsystems. DSU, web controller, audio bass detector (each conditional on settings), plus the self-healing sink reconcilers, which start unconditionally:
RumbleAudioService.EnsureStarted()(#236),WiiSpeakerService.EnsureStarted(), andHapticToneService.EnsureStarted(). - Clear stale HidHide state.
HidHideController.ClearAll()removes leftover entries. - Apply device hiding. HidHide blacklist + input hooks.
- Start UI timer. 30 Hz
DispatcherTimeratDispatcherPriority.Render. - Update state. Sets
IsEngineRunning = true, enters idle if no slots created.
Stop()¶
- Stops UI timer and unsubscribes from its Tick event.
- Unsubscribes from all ViewModel property change events.
- Unsubscribes from per-pad events (
SelectedDeviceChanged,MappingsRebuilt). - Unsubscribes from
NfcTagRegistry.RegistryChanged(#150). - Disposes ForegroundMonitorService.
- Stops DSU server, web controller server, audio bass detector.
- Calls
RemoveDeviceHiding()(HidHide blacklist cleanup + input hook teardown). - Unsubscribes from engine events, calls
_inputManager.Stop()and_inputManager.Dispose(). - Updates MainViewModel state (sets engine status to "Stopped", marks all device rows offline).
The v2 preserveExtendedNodes parameter is gone. HIDMaestro creates and destroys virtual devices dynamically, so there's no need for the v2-era "keep the vJoy node alive across a restart" path.
Dispose()¶
Calls Stop() in a try/catch (best-effort shutdown).
30Hz UI Timer Tick¶
UiTimer_Tick is the service layer heartbeat. Called ~30 times/second on the UI thread, it runs these steps in sequence:
UiTimer_Tick
|-- Update Pad ViewModels (gamepad state, vibration, Extended/MIDI/KBM raw state)
|-- UpdateDashboard()
|-- UpdateMenuOverlayWindow() [radial / touch menu HUD, gated by EnableMenuOverlay]
|-- UpdateDevicesRawState() [only if Devices page visible]
|-- UpdateMappingLiveValues() [only if a Pad page visible]
|-- UpdateMacroTriggerRecording() [only if recording active]
|-- SyncViewModelToPadSettings() [always, 30Hz]
|-- SyncMacroSnapshots() [always, 30Hz]
|-- Audio rumble level meters [only if detector active]
|-- UpdateIdleState() [auto-idle when no active slots]
|-- ForegroundMonitor.CheckForegroundWindow() [auto-profile switching]
Pad ViewModel updates (per slot)¶
For each of the 16 slots:
| Slot type | Source array | Update call |
|---|---|---|
| All | CombinedOutputStates[i], VibrationStates[i] |
padVm.UpdateFromEngineState() |
| Custom Extended | CombinedExtendedRawStates[i] |
padVm.UpdateFromExtendedRawState() |
| MIDI | CombinedMidiRawStates[i] |
padVm.UpdateFromMidiRawState() |
| KB+M | CombinedKbmRawStates[i] |
Sets padVm.KbmOutputSnapshot |
Per-device stick/trigger previews read either KBM pre-deadzone values (synthesized into a Gamepad struct) or the selected device's RawMappedState.
KB+M cursor and scroll outputs are time-based rates, not per-poll displacements (4.1.0): full stick deflection moves the cursor at 1,200 px/s (KeyboardMouseVirtualController.MouseFullScalePxPerSec, the DS4Windows stick-as-mouse scale) and turns the wheel at ~33 notches/s, independent of the polling-rate setting. The per-stick KBM speed knob scales from those constants.
Visibility gating¶
Two flags gate expensive per-frame work:
| Flag | When true |
|---|---|
IsDevicesPageVisible |
Calls UpdateDevicesRawState() |
IsPadPageVisible |
Calls UpdateMappingLiveValues() |
Both are set by MainWindow navigation.
Menu overlay window (4.1.0, #9 B-17)¶
UpdateMenuOverlayWindow() pulls the poll thread's engaged-menu snapshot (InputManager.ActiveMenuOverlay, first-engaged menu wins) and drives the click-through MenuOverlayWindow HUD: lazily created on first engage, hidden when no menu is engaged or when DashboardViewModel.EnableMenuOverlay (default true) is off. Menus keep committing blind when the overlay is disabled. The read side is wired at engine start beside the touchpad and mouse gesture fired providers: SourceCoercion.MenuItemFiredProvider maps to InputManager.IsMenuItemFired, through which mapping rows, shift activators, and macro descriptor triggers all read fired menu items. Cleared with the other providers at Stop().
Dashboard Updates¶
UpdateDashboard() (private, 30Hz)¶
Pushes engine statistics to DashboardViewModel: state key ("Running"/"Idle"/"Stopped"), localized status, PollingFrequency, and device counts (TotalDevices, OnlineDevices, MappedDevices) computed under UserDevices.SyncRoot lock. Calls RefreshSlotSummaryProperties() and RefreshNavItemConnectedCounts().
RefreshSlotSummaryProperties(IEnumerable<UserDevice> devices = null) (public)¶
Updates all SlotSummary items on the dashboard with per-slot status (IsActive, DeviceName, MappedDeviceCount, ConnectedDeviceCount, IsVirtualControllerConnected, IsInitializing, IsEnabled, StatusText) and per-type numbering (e.g., "Xbox 1", "PlayStation 2").
RefreshNavItemConnectedCounts(IEnumerable<UserDevice>) (private)¶
Updates sidebar NavControllerItem.ConnectedDeviceCount and IsInitializing for power icon color logic.
Devices Page Raw State¶
UpdateDevicesRawState() (private, 30Hz, gated by IsDevicesPageVisible)¶
Updates the raw input state display for the selected device:
1. Finds UserDevice for the selected DeviceRowViewModel.
2. On device change: rebuilds axis/button/POV collections via devVm.RebuildRawStateCollections().
3. Updates axes in-place (NormalizedValue = Axis[i] / 65535.0), buttons, keyboard keys, POV hats, mouse motion/scroll, and gyro/accel (if supported).
OnDevicesVmPropertyChanged() (private)¶
Handles SelectedDevice changes when the engine is not running. Populates the detail panel from cached UserDevice capabilities so the layout is visible offline.
Mapping Live Values¶
UpdateMappingLiveValues() (private, 30Hz, gated by IsPadPageVisible)¶
For the active Pad page: finds the selected device, parses each MappingItem.SourceDescriptor, reads the raw value from CustomInputState, and sets mapping.CurrentValueText.
ReadMappedValue(CustomInputState, string descriptor) (private, static)¶
Simplified Step 3 parser for display. Strips I/H prefixes, parses "Axis N", "Button N", "Slider N", "POV N" descriptors, and reads from the state arrays.
Settings Sync (ViewModel to PadSetting)¶
SyncViewModelToPadSettings() (private, 30Hz)¶
Primary runtime sync path. For each pad slot:
- Always synced (even with no device selected):
SlotControllerTypes[i]frompadVm.OutputType- Extended config via
SyncExtendedConfigToSlot() -
MIDI config via
_inputManager._midiConfigs[i] -
Per-device sync (when a device is selected):
- Calls
SaveViewModelToPadSetting(padVm, instanceGuid, syncMappings: false) - Pushes deadzones (independent X/Y), anti-deadzones, linear, center offsets, max range (independent directions), trigger deadzones, force feedback gains, audio rumble settings
-
Mapping descriptors are NOT synced at 30 Hz to avoid a race condition.
ClearMappingDescriptors()creates a window where the polling thread sees empty mappings -
Audio bass detector lifecycle: detects when
AudioRumbleEnabledtoggles on any slot and callsSyncAudioBassDetector().
SaveViewModelToPadSetting(PadViewModel, Guid, bool syncMappings) (private, static)¶
Writes all tuning parameters from ViewModel to PadSetting. When syncMappings is true (explicit save, preset change, device switch), also clears and rewrites all mapping descriptors.
LoadPadSettingToViewModel(PadViewModel, Guid) (private, static)¶
Reverse direction: reads PadSetting and populates the PadViewModel (deadzones, sensitivity curves, max ranges, center offsets, triggers, force feedback, audio rumble, mapping descriptors).
Settings Forwarding (OnSettingsPropertyChanged)¶
Propagates SettingsViewModel changes to the engine at runtime:
| Property | Action |
|---|---|
PollingRateMs |
Sets _inputManager.PollingIntervalMs |
EnableInputHiding |
Calls ApplyDeviceHiding() or RemoveDeviceHiding() |
Dashboard Forwarding (OnDashboardPropertyChanged)¶
Propagates DashboardViewModel changes:
| Property | Action |
|---|---|
EnableDsuMotionServer |
Starts or stops DSU server |
DsuMotionServerPort |
Restarts DSU server if enabled |
EnableWebController |
Starts or stops web controller server |
WebControllerPort |
Restarts web controller server if enabled |
Engine Event Handlers¶
All fire on the polling thread and are marshalled to UI via Dispatcher.BeginInvoke.
| Handler | Action |
|---|---|
OnDevicesUpdated |
SyncDevicesList(), UpdatePadDeviceInfo(), ApplyDeviceHiding() |
OnFrequencyUpdated |
No-op (frequency read on next UI tick) |
OnErrorOccurred |
Sets _mainVm.StatusText |
OnHmVcInactivityDestroyed |
Raises SlotInactivityTimedOut so MainWindow tears the slot down and runs the cascade (#206) |
OnHmVcWentNonActive |
Runs the bubble-down cascade + UpdatePadDeviceInfo() after a non-delete VC teardown, sidebar disable or all-devices-unassigned (#206) |
Device List Sync¶
SyncDevicesList() (private)¶
Synchronizes DevicesViewModel.Devices with SettingsManager.UserDevices:
1. Snapshots under lock.
2. Updates existing rows, adds new ones (skips virtual/shadow devices).
3. Removes stale or virtual rows.
4. Sorts by name, then VID:PID.
5. Calls devVm.RefreshCounts().
IsVirtualOrShadowDevice(UserDevice) (private, static)¶
Filters legacy and shadow virtual controllers from the Devices-page list (defense-in-depth; Step 1 already filters HIDMaestro upstream). Returns true when any of these match on an online device: name contains "ViGEm" or "Virtual Gamepad" (case-insensitive), device path lowercase contains "vigem" or "virtual", or the device has IsHidden = true. Offline devices always return false because virtual controllers only exist while the engine is running.
PopulateDeviceRow(DeviceRowViewModel, UserDevice) (private)¶
Maps UserDevice properties to the ViewModel row: name, VID/PID, online status, capabilities, device type, slot assignments, HidHide state, instance path.
UpdatePadDeviceInfo() (public)¶
Rebuilds each PadViewModel's MappedDevices from UserSettings.FindByPadIndex(). Handles multi-device slots, auto-selects first device, refreshes sidebar and dashboard.
Per-Device Settings Swap¶
PopulateAvailableInputs() (private)¶
Builds the input source dropdown for a pad slot's selected device. Uses Math.Max(CapButtonCount, RawButtonCount) to generate button entries, ensuring the full button list is available regardless of whether the device is in gamepad or raw joystick mode. When the device is offline, falls back to cached DeviceObjects and stored capability counts so the mapping UI remains functional without a live connection.
RefreshMappingDropdowns() (public)¶
Called when ForceRawJoystickMode is toggled on a device. Rebuilds the input source dropdown and mapping descriptors for all pad slots using that device, reflecting the change in available raw vs. gamepad inputs.
OnSelectedDeviceChanged(PadViewModel, MappedDeviceInfo)¶
When the user selects a different device in a pad slot's dropdown:
1. Saves current ViewModel values to the previous device's PadSetting (skipped when re-adding the same device).
2. Loads the new device's PadSetting via LoadPadSettingToViewModel().
3. Populates input dropdown via PopulateAvailableInputs().
4. Updates the _previousSelectedDevice tracker.
OnMappingsRebuilt(PadViewModel)¶
When mappings are rebuilt (OutputType or HIDMaestro profile change), reloads mapping descriptors from PadSetting without touching deadzone or force feedback settings.
Copy / Paste Settings¶
ApplyPadSettingToCurrentDevice(int padIndex, PadSetting source) (public)¶
Applies a source PadSetting to the selected device. Used by Paste and "Copy From".
ApplyPadSettingToCurrentDeviceTranslated(...) (public)¶
Applies with cross-layout translation (e.g., Xbox to Extended mapping key conversion).
BuildPerDeviceSettingsSnapshot(int sourcePadIndex, VirtualControllerType layoutType, bool layoutIsExtended) (public static)¶
Snapshots every assigned device's full PadSetting on the source slot into a PerDeviceSettingsEntry[]. Each entry carries the device's InstanceGuid, ProductGuid, ProductName, and a nested PadSettingJson produced by PadSetting.ToJson() after clearing the outer-only slot-level payloads (SlotDeviceConfigsJson, SlotExtendedConfigJson, SlotMidiConfigJson, SlotKbmConfigJson, SlotPerDeviceSettingsJson, SlotMultiSourceRows, DeviceScopedMultiSourceRows) so the nesting doesn't recurse. Returns null when the source slot has zero UserSettings. The clipboard JSON path serializes the returned array as __SlotPerDeviceSettings on the wrapping PadSetting.
ApplyPerDeviceSettingsToSlot(int targetPadIndex, PerDeviceSettingsEntry[] entries, VirtualControllerType sourceLayoutType, bool sourceLayoutIsExtended, VirtualControllerType targetLayoutType, bool targetLayoutIsExtended) (public)¶
Applies the per-device payload to a target slot. Iterates each entry, matches it to a target-slot device by InstanceGuid first (perfect round-trip on the same machine), then ProductGuid as a fallback (same controller model, different physical unit). Entries that match nothing are skipped. Paste never auto-creates devices. Each matched entry's nested PadSetting is reapplied via ApplyPadSettingToCurrentDeviceTranslated per device, so cross-layout pastes (e.g. Xbox→PS) still get the layout translation that single-device paste enjoys. The outer Copy / Paste flow's wholesale MappingSet replacement runs before this helper; this method only carries per-device tuning (deadzones, sensitivity, FFB, gyro, impulse triggers, adaptive triggers, lighting, TouchpadSettings).
FlushAllPadViewModels() (public)¶
Flushes all active PadViewModel state to PadSettings. Call before reading PadSettings across slots (e.g., Copy From dialog).
GetCurrentPadSetting(int padIndex) (public)¶
Returns the PadSetting for the selected device after syncing ViewModel state.
Macro Snapshot Sync¶
SyncMacroSnapshots() (private, 30Hz)¶
Creates a snapshot array of MacroItem objects per slot and assigns it to _inputManager.MacroSnapshots[i]. The engine reads these atomically each cycle. Empty lists set the snapshot to null.
Macro Trigger Recording¶
StartMacroTriggerRecording(MacroItem macro, int padIndex) (public)¶
Starts recording button/axis/POV presses for a macro trigger combo. Captures axis baselines for delta detection.
StopMacroTriggerRecording() (public)¶
Finalizes recording. Writes accumulated data to the MacroItem by trigger path:
| Path | Data written |
|---|---|
| InputDevice | Raw device buttons (TriggerRawButtons) + device GUID |
| Custom Extended | Numbered button words (TriggerCustomButtonWords) |
| OutputController | Xbox button bitmask (TriggerButtons) |
Also writes axis targets/directions and POV triggers.
UpdateMacroTriggerRecording() (private, 30Hz)¶
Called each UI tick during recording. Reads state per TriggerSource:
| Source | Behavior |
|---|---|
| InputDevice | Scans raw buttons/POVs from mapped devices. First press locks _recordingDeviceGuid |
| Numbered (custom Extended) | Accumulates from CombinedExtendedRawStates |
| OutputController | Accumulates from CombinedOutputStates Xbox button bitmask |
Axis detection: 25% threshold, 3-cycle hold confirmation (same as RecorderService).
DSU Server Lifecycle¶
StartDsuServerIfEnabled() (private)¶
- Checks
Dashboard.EnableDsuMotionServerand engine existence. - Creates
DsuMotionServer, subscribes toStatusChanged. - Validates port (1024–65535; default 26760).
- Starts server. On success assigns to
_inputManager.DsuServer; on failure disposes.
StopDsuServer() (private)¶
- Clears
_inputManager.DsuServer. - Disposes server instance.
Web Controller Server Lifecycle¶
StartWebServerIfEnabled() (private)¶
- Checks
Dashboard.EnableWebControllerand engine existence. - Creates
WebControllerServer, subscribes toStatusChanged,DeviceConnected,DeviceDisconnected. - Device connect/disconnect calls
_inputManager.RegisterExternalDevice()/UnregisterExternalDevice(). - Validates port (1024–65535; default 8080), starts server.
StopWebServer() (private)¶
Unsubscribes from StatusChanged, disposes server, clears status and client count.
Audio Bass Detector Lifecycle¶
SyncAudioBassDetector() (internal)¶
Called on engine start, slot changes, and during 30 Hz sync when AudioRumbleEnabled toggles. Starts the detector if any slot enables audio rumble. Stops it if none do.
StartAudioBassDetector() (private)¶
Creates and starts AudioBassDetector. On success, assigns to _inputManager.AudioBassDetector; on failure, disposes.
StopAudioBassDetector() (private)¶
Clears _inputManager.AudioBassDetector, disposes detector, zeros all pad level meters.
Audio rumble level meter update (in UiTimer_Tick)¶
When _audioBassDetector != null, reads BassEnergy and pushes to padVm.AudioRumbleLevelMeter for each created slot with AudioRumbleEnabled.
Device Hiding¶
ApplyDeviceHiding() (public)¶
Only acts if EnableInputHiding is on. Two mechanisms:
HidHide (driver-level):
1. Builds whitelist (PadForge exe + user paths). SyncWhitelist() adds/removes only PadForge-managed entries.
2. For each UserDevice with HidHideEnabled: converts DevicePath to HID instance ID (fallback: VID/PID lookup for synthetic paths like "XInput#0"). Caches resolved IDs for offline pre-emptive blacklisting.
3. HidHideController.SyncManagedDevices(desiredIds). Atomic diff-based sync.
4. Activates cloaking if any devices are blacklisted.
Input hooks (keyboard/mouse):
1. For each device with ConsumeInputEnabled and a slot assignment: parses "Button {index}" descriptors to collect VKey codes or mouse button IDs.
2. Creates/updates InputHookManager if inputs need suppressing. Otherwise stops and disposes it.
RemoveDeviceHiding(bool keepCloaks = false) (public)¶
Calls HidHideController.RemoveManagedDevices() (best-effort), stops and disposes InputHookManager. With keepCloaks: true the HidHide blacklist removal and whitelist-tracking clear are skipped, so only the input hooks tear down.
SyncWhitelist(HashSet<string> desiredWinPaths) (private)¶
Converts Windows paths to DOS device paths. Only modifies PadForge-managed entries. Entries from HidHide Client or other tools are left untouched. Tracked via _managedWhitelistDosPaths.
Auto-Idle¶
UpdateIdleState() (private, 30Hz)¶
Sets _inputManager.IsIdle based on whether any slot is created, enabled, and has a device assigned. Idle mode skips input/mapping/output and sleeps at ~20 Hz, reducing CPU to ~0%.
Profile Switching¶
SnapshotCurrentProfile() (public) -> ProfileData¶
Captures current runtime state:
1. Flushes all PadViewModel values to PadSettings.
2. Collects ProfileEntry (InstanceGuid, ProductGuid, MapTo, checksum) and deduplicated PadSetting clones.
3. Captures SlotCreated[], SlotEnabled[], SlotControllerTypes[], SlotProfileIds[] (per-slot HIDMaestro profile slug), Extended/MIDI configs, DSU/web server settings, the per-group slot orders, and touchpad-overlay settings.
4. Captures ProfileData.Macros (<ProfileMacros>), a copy of the current macro set. A profile carries its own macros, so switching profiles swaps macros too. A null Macros marks a pre-macro-era profile and leaves the live macros untouched on apply.
ApplyProfile(ProfileData profile) (public)¶
Restores a profile:
1. Topology. Sets SlotCreated[], SlotEnabled[], OutputType, ProfileId (per-slot HM profile slug), and unassigns devices from destroyed slots. The HM slug update gates Step 5's per-slot diff: UpdateVirtualDevices() Pass 1 in InputManager.Step5.VirtualDevices.cs compares each slot's SlotProfileIds[] against the live HMaestroVirtualController.ProfileId. Slots whose new slug matches stay live untouched. Slots whose slug differs are destroyed and recreated with the new identity.
2. Device assignments (single-pass transition). Builds the desired final assignment map from profile.Entries first, then transitions each UserSetting directly old → new MapTo (or → -1 for entries dropped from the new profile). The "find UserSetting" gate is "not yet consumed by a prior entry in this same apply pass," not the previous reset-MapTo-to-negative gate. This avoids the reset window where the polling thread could observe HasAnyDeviceMapped == false for surviving slots and fall into the !HasAnyDeviceMapped immediate-destroy branch of UpdateVirtualDevices() in InputManager.Step5.VirtualDevices.cs. Slots whose mapping is unchanged across profiles transition with zero teardown.
3. Extended/MIDI configs. Restores per-slot Extended config (Customize toggle, axis/trigger/POV/button counts, OEM-name override, product string) and MIDI config (channel, CC/note ranges, velocity).
4. Server settings. Sets DSU and web controller enable/port.
5. Macros. When profile.Macros is non-null, replaces the live macro set via LoadMacros(profile.Macros). A null value leaves the current macros in place (pre-macro-era profile).
6. Rebuilds UI. UpdatePadDeviceInfo(), reloads PadSettings, refreshes Devices page.
OnProfileSwitchRequired(string profileId) (private)¶
Called by ForegroundMonitorService when the foreground process matches a different profile. Skips if same profile already active. Saves outgoing state via SaveActiveProfileState(), then applies the target profile (or reverts to default if profileId is null).
SaveActiveProfileState() (public)¶
Snapshots current state. Default profile: updates _defaultProfileSnapshot and PendingDefaultSnapshot. Named profile: updates stored data in SettingsManager.Profiles.
RefreshDefaultSnapshot() (public)¶
Refreshes the default profile snapshot from current state. Called after saving when no profile is active.
ApplyDefaultProfile() (public)¶
Applies _defaultProfileSnapshot to revert to the pre-profile state.
Slot Reordering¶
The reorder model rests on five rules:
- Pad indices are data identity. A pad's mappings, profile, devices, settings, and dirty flags live at its pad index and never move on reorder.
- Visual position is the kernel-slot anchor. Within an HM-backed group (Xbox / PlayStation / Nintendo / Extended), the VC at visual position V holds kernel slot V.
SlotOrders[group][V] = padIndexsays which pad's data the VC at slot V is serving. - Reorder repoints, not rebuilds.
SwapSlots/MoveSlotmutateSlotOrders(visual order). 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 reorders are zero-flicker. Pointer swap in
_virtualControllers[]plusFeedbackPadIndexupdate on the moved VC. Per-VC state arrays move with the VC. - Different-profile positions destroy + recreate. Only the specific positions whose profile changed. Matching positions in the same reorder still pointer-swap.
Per-pad state (_slotInactiveCounter, _createFailed, _hmInactivityFired, _slotInitializing, _pendingDisposeTask, _pendingConnectTask) describes the pad's lifecycle and stays at the pad index. Per-VC state (_loggedFirstSubmit, _extendedAppliedProductString, _extendedAppliedLayout, _oemOverrideClaimedVidPid, _lastAppliedOemLabel) moves with the VC.
SwapSlots(int padIndexA, int padIndexB) (public)¶
Swaps two slots' visual positions within their (shared) group. Snapshots the pre-swap order, mutates SlotOrders via SwapWithinGroup, then calls RebuildKernelOrderAfterReorder(groupType, oldOrder). Cross-group calls are rejected. Refreshes UI after.
MoveSlot(int sourcePadIndex, int targetVisualPosition) (public)¶
Moves a slot from its current visual position to a new visual position within the same group. Snapshots the pre-move order, mutates SlotOrders via MoveWithinGroup, then calls RebuildKernelOrderAfterReorder(groupType, oldOrder).
RebuildKernelOrderAfterReorder(VirtualControllerType groupType, IReadOnlyList<int> oldOrder) (private)¶
Thin delegator. Reads the new order from SettingsManager.SlotOrders.GetOrderFor(groupType) and calls _inputManager.RerouteVirtualControllersForReorder(groupType, oldOrder, newOrder). Non-HM groups (KBM, MIDI) are filtered inside the engine method since their slot order is not tied to a kernel-side index allocation.
InputManager.RerouteVirtualControllersForReorder(VirtualControllerType groupType, IReadOnlyList<int> oldOrder, IReadOnlyList<int> newOrder) (public, engine)¶
Walks oldOrder against newOrder position by position and decides per visual position whether to reuse the existing VC at that kernel slot or destroy it. Three-step implementation:
- Decide per position. For each visual position V, compare the profile of the VC at
oldOrder[V]againstSlotProfileIds[newOrder[V]]. Same profile: the VC at V is reused. Different profile: the old VC is queued for destruction. While walking, snapshot the per-VC state at eacholdPadso it can travel with the VC. - Destroy mismatched VCs. Each goes through
DestroyVirtualController(oldPad, asyncDispose: true), which releases OEM override claims and queues HIDMaestro teardown to the thread pool. Per-pad state at these old pads is cleared. - Re-route reused VCs. For each reused VC, write the VC pointer plus its per-VC state snapshot into the destination pad's slot in the engine arrays, and update
FeedbackPadIndexso vibration callbacks land in the rightVibrationStates[]entry. Per-VC state cleared at the old pad if it differs from the new pad.
Same-profile cycles (Example: insert a Profile-A slot at the top of an all-Profile-A group) collapse to a pure pointer rotation across _virtualControllers[] with no kernel teardown. Zero-flicker for the game side. Different-profile positions go through the regular destroy + recreate path; Pass 2's visual-order gate plus ApplyAscendingIndexPreemption recreate them with the new pad's profile, taking the lowest free kernel slot (which is V, because surviving VCs at positions < V keep theirs). The swap-only path does not engage Pass 2's preemption.
Non-HM group types are rejected at the entry. Cross-group moves do not route through here at all. MoveSlotToGroupTail changes SlotControllerTypes[padIndex], which Pass 1 detects as a type change and destroys the old-group VC. The new group's ordinary creation logic spins up the new VC at the tail.
This replaced the v2-era ShouldRebuildKernelOrder predicate and the live-subsequence walk that destroyed every live VC at and below the lowest changed position. Reorders now make per-position reuse decisions, so a same-profile rotation involves zero VC destroys.
MoveSlotToGroupTail(int padIndex) (public)¶
Moves a slot to the tail of its type group. Changes SlotControllerTypes[padIndex], which the reorder engine's Pass 1 detects as a type change and destroys the old-group VC. The new group's ordinary creation logic spins up the VC at the tail.
OnSlotDeleted(int padIndex, VirtualControllerType deletedType, int oldGroupPosition, bool deletedSlotHadActiveVc = true) (public)¶
Runs the bubble-down cascade after DeviceService.DeleteSlot removes a slot. Surviving HM VCs at higher visual positions in the same group drop to the lowest free kernel slot, matching the disconnect/reconnect shape an external observer sees. Takes the pre-removal group position captured by DeleteSlot (returned in its SlotDeletionInfo).
Test Rumble¶
SendTestRumble(int padIndex, Guid? deviceGuid) (public)¶
Sets both main motors to 65535 (full-scale). Optional device GUID filter. Clears after 500 ms via a one-shot DispatcherTimer. The two-argument overload delegates to the four-argument form with left: true, right: true.
// Overload for selective motors
public void SendTestRumble(int padIndex, Guid? deviceGuid, bool left, bool right)
For Extended slots the four-argument form also emits a directional constant-force effect (East for left, West for right) so wheels and joysticks push the correct way instead of only rattling. The scalar motors are still set for rumble-only devices sharing the slot.
When deviceGuid is non-null, the call also stores the GUID in _inputManager.TestRumbleTargetGuid[padIndex]. The UserEffectsDispatcher.TestRumbleTargetGuidProvider callback (wired in Start()) reads that value so per-device effects on Impulse Triggers / Force Feedback / Adaptive Triggers / Lighting tabs only fire on the selected pad. The gate is target == Guid.Empty || ud.InstanceGuid == target. See feedback_per_device_test_isolation.md in project memory.
SendTestImpulseTrigger(int padIndex, Guid? deviceGuid, bool left, bool right) (public)¶
Trigger-motor sibling for Xbox One+ impulse triggers (#74). Sets VibrationStates[padIndex].LeftTriggerMotorSpeed / RightTriggerMotorSpeed to 65535 and lets the Step-2 ApplyForceFeedback path forward them via SDL_RumbleGamepadTriggers. Same device-GUID filter and 500 ms clear as SendTestRumble.
Bulk Virtual Controller toggle (3.2, Issue #91)¶
InputService.ToggleVCsDisabled is an Action set by MainWindow so the engine can fan out a profile-shortcut combo into DeviceService.SetSlotEnabled calls for every created slot. The flow:
- Engine thread observes
_inputManager.PendingToggleVCsDisabled(set by a profile-shortcut activator). - Marshals
ToggleVCsDisabled?.Invoke()to the UI thread inside the polling cycle. - UI handler reads each
SlotCreated[i]; if anySlotEnabled[i]is true, disables them all, else enables them all. MainViewModel.RefreshNavControllerItems()updates the sidebar;ProfileSwitchOverlayshows a Fluent green / red flyout.
InputService All Public Methods¶
| Method | Signature | Description |
|---|---|---|
Start |
void Start() |
Creates engine, starts all subsystems, begins UI timer |
Stop |
void Stop() |
Stops engine and all subsystems |
Dispose |
void Dispose() |
Calls Stop() for cleanup |
RefreshSlotSummaryProperties |
void RefreshSlotSummaryProperties(IEnumerable<UserDevice> devices = null) |
Updates dashboard slot summary cards |
RefreshDeviceList |
void RefreshDeviceList() |
Full re-sync of device list UI |
UpdatePadDeviceInfo |
void UpdatePadDeviceInfo() |
Refreshes PadViewModel device info for all pads |
ApplyDeviceHiding |
void ApplyDeviceHiding() |
Applies HidHide + input hooks based on settings |
RemoveDeviceHiding |
void RemoveDeviceHiding(bool keepCloaks = false) |
Removes all device hiding (keepCloaks: true leaves the HidHide blacklist in place) |
SendTestRumble |
void SendTestRumble(int padIndex, Guid? deviceGuid) |
Sends brief test rumble (both motors 65535) |
SendTestRumble |
void SendTestRumble(int padIndex, Guid? deviceGuid, bool left, bool right) |
Sends selective test rumble |
SendTestImpulseTrigger |
void SendTestImpulseTrigger(int padIndex, Guid? deviceGuid, bool left, bool right) |
Test pulse on the impulse-trigger motors (#74) |
ApplyPadSettingToCurrentDevice |
void ApplyPadSettingToCurrentDevice(int padIndex, PadSetting source) |
Applies copied PadSetting |
ApplyPadSettingToCurrentDeviceTranslated |
void ApplyPadSettingToCurrentDeviceTranslated(int padIndex, PadSetting source, VirtualControllerType sourceType, bool sourceIsCustomExtended, VirtualControllerType targetType, bool targetIsCustomExtended) |
Applies with cross-layout translation |
ApplyPerDeviceSettingsToSlot |
void ApplyPerDeviceSettingsToSlot(int targetPadIndex, PerDeviceSettingsEntry[] entries, VirtualControllerType sourceLayoutType, bool sourceLayoutIsExtended, VirtualControllerType targetLayoutType, bool targetLayoutIsExtended) |
Reapplies per-device tuning from a copied slot, matched by InstanceGuid then ProductGuid |
FlushAllPadViewModels |
void FlushAllPadViewModels() |
Saves all ViewModel state to PadSettings |
GetCurrentPadSetting |
PadSetting GetCurrentPadSetting(int padIndex) |
Gets PadSetting for selected device |
StartMacroTriggerRecording |
void StartMacroTriggerRecording(MacroItem macro, int padIndex) |
Starts macro trigger recording |
StopMacroTriggerRecording |
void StopMacroTriggerRecording() |
Stops macro trigger recording |
SnapshotCurrentProfile |
ProfileData SnapshotCurrentProfile() |
Captures current state as profile |
ApplyProfile |
void ApplyProfile(ProfileData profile) |
Loads a profile into runtime state |
SaveActiveProfileState |
void SaveActiveProfileState() |
Saves current state into active profile |
RefreshDefaultSnapshot |
void RefreshDefaultSnapshot() |
Refreshes default profile from current state |
ApplyDefaultProfile |
void ApplyDefaultProfile() |
Reverts to default profile |
RefreshProfileTopology |
void RefreshProfileTopology() |
Refreshes active profile topology label |
SwapSlots |
void SwapSlots(int padIndexA, int padIndexB) |
Swaps two controller slots |
MoveSlot |
void MoveSlot(int sourcePadIndex, int targetVisualPosition) |
Moves slot to visual position |
MoveSlotToGroupTail |
void MoveSlotToGroupTail(int padIndex) |
Moves a slot to the tail of its type group |
OnSlotDeleted |
void OnSlotDeleted(int padIndex, VirtualControllerType deletedType, int oldGroupPosition, bool deletedSlotHadActiveVc = true) |
Bubble-down cascade after DeviceService.DeleteSlot |
OnSlotInactivityTimedOut |
void OnSlotInactivityTimedOut(int padIndex) |
Tears down the VC after the engine's HM inactivity timeout, runs the cascade (#206) |
ReseedPlayerIdentities |
void ReseedPlayerIdentities(bool applySonyDispatchers = true) |
Re-seeds every assigned device's player number (#191) |
CreateEmptyProfile |
ProfileData CreateEmptyProfile(string name, string pipeSeparatedExePaths) |
Creates a new empty profile |
CreateSnapshotProfile |
ProfileData CreateSnapshotProfile(string name, string pipeSeparatedExePaths) |
Snapshots current runtime state into a named profile |
DeleteProfile |
bool DeleteProfile(string profileId) |
Deletes a profile. Returns true if the active profile reverted to default |
EditProfile |
ProfileData EditProfile(string profileId, string newName, string newPipeSeparatedExePaths) |
Renames a profile and updates its exe paths |
LoadProfile |
void LoadProfile(string profileId) |
Activates a profile, saving outgoing state first |
RevertToDefaultProfile |
void RevertToDefaultProfile() |
Reverts to the default profile |
AddCustomTouchpadGesture |
void AddCustomTouchpadGesture(TouchpadCustomGesture gesture) |
Adds a recorded gesture to the active profile's library |
DeleteCustomTouchpadGesture |
void DeleteCustomTouchpadGesture(string name) |
Removes a custom gesture by name |
SetTouchpadRecordingTarget |
void SetTouchpadRecordingTarget(Guid deviceGuid, int padIdx, Action<TouchpadInputState> onTick) |
Routes live touchpad frames to a gesture recorder |
ClearTouchpadRecordingTarget |
void ClearTouchpadRecordingTarget() |
Stops touchpad-frame routing |
StartExpressionVariableRecording |
void StartExpressionVariableRecording(MacroExpressionVariable variable, int padIndex) |
Records one input binding for a macro custom-expression variable |
StopExpressionVariableRecording |
void StopExpressionVariableRecording() |
Stops a per-variable recording session |
RefreshAvailableInputsForSlot |
void RefreshAvailableInputsForSlot(PadViewModel padVm) |
Rebuilds a slot's mapping input choices after an assignment change |
SetBalanceTare |
void SetBalanceTare(Guid deviceGuid) |
Captures the Wii Balance Board's current weight as the tare zero (#146) |
PanicQuiesceOutputs |
void PanicQuiesceOutputs() |
Zeros rumble and stops haptic tones on abnormal exit |
PurgeStaleHidHideCloaks |
void PurgeStaleHidHideCloaks() |
Clears every HidHide blacklist entry (Reset to Defaults) |
ClearGyroAutoCalibLatch |
void ClearGyroAutoCalibLatch(Guid instanceGuid, int slot) |
Re-arms auto-calibration for a (device, slot) pair |
IsHmVcAt |
bool IsHmVcAt(int padIndex) |
Whether the slot currently has an HM virtual controller |
NoteManualProfileSwitch |
void NoteManualProfileSwitch() |
Records a manual switch so auto-switching won't re-trigger it |
ShutdownMidiInputs |
void ShutdownMidiInputs() |
Tears down MIDI inputs before uninstalling Windows MIDI Services |
PumpSdlEvents |
void PumpSdlEvents() |
Pumps SDL's event queue on the UI thread for hot-plug (#116) |
RescanWiiControllers |
void RescanWiiControllers() |
Re-opens SDL's Wii hidapi devices after a pairing (#116) |
The profile-CRUD, touchpad-gesture, and expression-variable methods are the domain logic behind MainWindow's UI handlers.
InputService All Events¶
| Event | Signature | Description |
|---|---|---|
AutoProfileSwitchApplied |
event Action AutoProfileSwitchApplied |
Raised on the UI thread after an auto (foreground-match) switch actually changes the active profile. Manual paths never raise it. The profile pills flare on it (#175) |
SlotInactivityTimedOut |
event EventHandler<int> SlotInactivityTimedOut |
Raised (marshalled to the UI thread) after the engine reports an HM VC's inactivity timeout. MainWindow calls OnSlotInactivityTimedOut in response (#206). Argument is the pad index |
Beyond these two, UI updates flow through ViewModel properties and InputManager's marshalled events.
Properties used as communication channels:
| Property | Type | Description |
|---|---|---|
Engine |
InputManager (get-only) |
Access to underlying InputManager |
IsDevicesPageVisible |
bool (get/set) |
Gates Devices page raw state updates |
IsPadPageVisible |
bool (get/set) |
Gates mapping live value updates |
SettingsService |
SettingsService (set-only) |
For triggering saves on cache updates |
ToggleMainWindow |
Action (get/set) |
Callback to show/hide the main window. Set by MainWindow at startup. |
ToggleVCsDisabled |
Action (get/set) |
Callback to bulk-toggle all created VC slots enabled/disabled (#91). Set by MainWindow. See Bulk Virtual Controller toggle. |
Window Toggle via Global Macro¶
The ToggleMainWindow property is an Action delegate set by MainWindow during initialization. It handles three visibility states:
- Hidden (system tray). Calls
RestoreFromTray()+ForceToForeground(). - Minimized or inactive. Restores
WindowStateand callsForceToForeground(). - Foreground and visible. Minimizes (or hides to tray if
MinimizeToTrayenabled).
The engine thread sets InputManager.PendingToggleWindow = true when a global macro with SwitchProfileMode.ToggleWindow fires. The UI thread consumes this volatile flag inside UiTimer_Tick, immediately after pending profile switch handling:
if (_inputManager.PendingToggleWindow)
{
_inputManager.PendingToggleWindow = false;
ToggleMainWindow?.Invoke();
}
Profile Switch Overlay¶
ShowProfileSwitchOverlay(string profileId) creates (or reuses) a ProfileSwitchOverlay window and wires two callbacks that the overlay polls at ~30 Hz to track virtual controller initialization:
| Callback | Signature | Purpose |
|---|---|---|
CheckAllSlotsInitState |
Func<(bool anyInitializing, bool allReady)> |
Returns whether any created+enabled slots are still initializing, and whether all are ready. Checks each VC's lifecycle state. |
CheckAnyControllerOffline |
Func<bool> |
Returns true if any created+enabled slot has no online physical devices assigned. Used to show a warning after the "Active" state. |
CheckAllSlotsInitState() iterates all 16 slots. If SlotCreated[i] && SlotEnabled[i], it checks whether the virtual controller exists and is connected. CheckAnyControllerOffline() similarly iterates slots and checks whether any assigned UserSetting references a device that is offline.
Profile Shortcut Recording (ProfilesPage Code-Behind)¶
Shortcut combo recording is implemented in ProfilesPage.xaml.cs, not InputService, because it requires direct access to the XAML ItemsControl and per-row ProfileShortcutViewModel instances.
ShortcutLearn_Click(object sender, RoutedEventArgs e). Starts a 5-second recording window:
1. Cancels any in-progress recording on another shortcut row.
2. Snapshots axis baselines from all online devices (_recordAxisBaselines).
3. Sets InputService.SetSuppressGlobalMacros(true) to prevent the combo from firing during recording.
4. Creates a 33 ms DispatcherTimer (_recordTimer).
RecordTimer_Tick(object sender, EventArgs e). Fires at ~30 Hz during recording:
1. Updates the countdown display via RecordingCountdown.
2. Scans all online devices for pressed buttons and axis deflections exceeding AxisRecordDeltaThreshold (0.25).
3. Builds TriggerButtonEntry[] with per-button device tracking (DeviceInstanceGuid, DeviceProductGuid, IsAxis, AxisIndex, AxisThreshold, AxisDirection).
4. Temporarily sets entries on the ViewModel for live display.
5. Auto-stops after RecordTimeoutSeconds (5 seconds).
StopRecording(). Finalizes:
1. Stops _recordTimer.
2. If valid entries were captured, calls _recordingShortcut.SetLearnedButtons(entries).
3. Otherwise calls CancelRecording() on the ViewModel.
4. Clears _recordAxisBaselines and calls SetSuppressGlobalMacros(false).
SettingsService¶
File: PadForge.App/Services/SettingsService.cs
Loads and saves PadForge settings to XML. Handles bidirectional sync between SettingsManager data and WPF ViewModels.
SettingsService Constructor and Initialization¶
Stores reference to MainViewModel.
Initialize()¶
- Ensures
UserDevicesandUserSettingscollections exist. - Finds settings file via
FindSettingsFile(). - Loads if found. Otherwise initializes with defaults.
- Sets
SettingsFilePath, clears dirty flag.
File Discovery¶
Search order (all relative to AppDomain.CurrentDomain.BaseDirectory):
1. PadForge.xml (primary)
2. Settings.xml (fallback)
3. If neither exists, creates PadForge.xml.
Load¶
LoadFromFile(string filePath) (public)¶
- Deserializes
SettingsFileDatafrom XML. - Populates
UserDevicesandUserSettingsunderSyncRootlocks. - PadSetting linking: finds PadSetting by checksum and clones it. Cloning is critical. Without it, devices sharing a checksum would share one object.
- Purges orphaned UserSettings (
MapTo == -1). - Calls
LoadAppSettings(),LoadPadSettings(),LoadMacros(),LoadProfiles().
LoadAppSettings(AppSettingsData) (private)¶
Pushes to SettingsViewModel: AutoStartEngine, MinimizeToTray, StartMinimized, StartAtLogin, EnablePollingOnFocusLoss, PollingRateMs, theme, language, input hiding, auto-profile switching, slot types, Extended/MIDI configs, DSU/web server settings.
Critical load order: SlotCreated[] and SlotEnabled[] must load BEFORE OutputType, because OutputType fires PropertyChanged which reads SlotCreated.
LoadPadSettings(UserSetting[], PadSetting[]) (private)¶
For each slot (first device only), loads all tuning parameters into PadViewModel: deadzones, sensitivity curves, max ranges, center offsets, triggers, force feedback, audio rumble, Extended HID custom configs, and mapping descriptors. Per-mapping deadzones are loaded with a default of 50 (centered, no effect) for mappings that lack a stored value.
LoadMacros(MacroData[]) (internal)¶
Rebuilds each PadViewModel's macro list from MacroData[], grouped by pad index.
Touchpad custom-gesture plumbing (v3.3)¶
SettingsService doesn't own the live custom-gesture list. That lives on InputService._activeTouchpadGestures. Two callback hooks bridge the two services without a reverse reference:
TouchpadGesturesProvider(Func<TouchpadCustomGesture[]>): SettingsService calls this at save time to get the current gestures. Returns null when there are none.TouchpadGesturesApplier(Action<TouchpadCustomGesture[]>): SettingsService calls this after a load to seed InputService's working list.
Startup order matters: LoadFromFile runs before StartEngine wires the applier, so the load path stashes loaded gestures in _pendingTouchpadGesturesToApply. The applier-setter property auto-flushes the pending slot on first assignment.
Save¶
Save() (public)¶
Calls SaveToFile(_settingsFilePath).
SaveToFile(string filePath) (public)¶
UpdatePadSettingsFromViewModels()pushes all ViewModel values to PadSettings.- Flushes Extended/MIDI/KBM mapping dictionaries to serializable arrays, recomputes checksums.
FlushMappingDeadZones(). Collects per-mapping deadzone values from all PadViewModels and writes them into the corresponding PadSetting objects before serialization.- Updates active profile snapshot via
UpdateActiveProfileSnapshot(). - Collects devices, user settings, deduplicated pad settings (under locks), app settings, macros, profiles.
- Serializes
SettingsFileDatato XML, clears dirty flag.
Note: When a named profile is active, BuildAppSettings() stores the default profile's slot state (from PendingDefaultSnapshot), not the current runtime state. This prevents the named profile's topology from contaminating the default.
MarkDirty and Autosave¶
MarkDirty() (public)¶
Sets IsDirty = true, starts a 250 ms debounce DispatcherTimer (restarted if already running). On tick: calls Save(), raises AutoSaved. The debounce batches rapid changes (e.g., slider drags) into a single save.
Reset and Reload¶
ResetToDefaults() (public)¶
Clears all SettingsManager collections, resets all ViewModels to defaults, clears profiles, marks dirty.
Reload() (public)¶
Reloads from disk, discarding unsaved changes.
Profile Loading¶
LoadProfiles(ProfileData[], AppSettingsData) (private)¶
- Adds the built-in Default profile at the top.
- Adds each saved profile with topology counts.
- If a named profile was active at shutdown, restores its slot config and captures the default snapshot from XML (
PendingDefaultSnapshot).
UpdateActiveProfileSnapshot() (private)¶
Called during Save. If a named profile is active, updates its stored snapshot from current state (entries, PadSettings, topology, server settings).
UpdateTopologyCounts(ProfileListItem, bool[], int[]) (internal, static)¶
Counts Xbox/PlayStation/Nintendo/Extended/MIDI/KBM slots and sets topology label (e.g., "2x Xbox, 1x PlayStation").
SettingsService All Public Methods¶
| Method | Signature | Description |
|---|---|---|
Initialize |
void Initialize() |
Finds settings file, loads, initializes collections |
LoadFromFile |
void LoadFromFile(string filePath) |
Loads settings from XML |
Save |
void Save() |
Saves to active settings file |
SaveToFile |
void SaveToFile(string filePath) |
Saves to specified XML file |
MarkDirty |
void MarkDirty() |
Marks dirty, schedules 250ms autosave |
Reload |
void Reload() |
Reloads from disk |
ResetToDefaults |
void ResetToDefaults() |
Resets all settings to defaults |
ApplyDeviceSlotConfigsToSlot |
void ApplyDeviceSlotConfigsToSlot(int slotIndex, DeviceSlotConfigData[] configs) |
Applies per-device slot config (lighting, adaptive triggers, audio) to a slot |
ApplyKbmConfigToSlot |
void ApplyKbmConfigToSlot(int slotIndex, KbmSlotConfigData cfg) |
Applies the KB+M slot config (SOCD #205) |
ApplyExtendedConfigToSlot |
void ApplyExtendedConfigToSlot(int slotIndex, ExtendedSlotConfigData cfg) |
Applies the Extended custom-layout config |
ApplyMidiConfigToSlot |
void ApplyMidiConfigToSlot(int slotIndex, MidiSlotConfigData cfg) |
Applies the MIDI slot config |
CopySlotConfigsAcrossSlots |
void CopySlotConfigsAcrossSlots(int srcSlot, int dstSlot) |
Copies per-slot configs that live on PadViewModel between two slots |
AddUserProfile |
string AddUserProfile(string extractedJson) |
Imports a user profile from JSON, suffixing its id. Returns the stored id |
RemoveUserProfile |
void RemoveUserProfile(string id) |
Removes a saved user profile |
ExportUserProfile |
void ExportUserProfile(string id, string filePath) |
Exports a saved profile to a file |
SettingsService All Events¶
| Event | Signature | Description |
|---|---|---|
AutoSaved |
event EventHandler AutoSaved |
Raised after autosave completes |
Properties:
| Property | Type | Description |
|---|---|---|
SettingsFilePath |
string (get) |
Full path to active settings file |
IsDirty |
bool (get) |
Whether unsaved changes exist |
DeviceService¶
File: PadForge.App/Services/DeviceService.cs
Handles UI-triggered device management: assign/unassign, hide/show, create/delete virtual controller slots. Bridges DevicesViewModel commands to SettingsManager and SettingsService.
DeviceService Constructor and Initialization¶
Stores MainViewModel and SettingsService references.
WireEvents() (public)¶
Subscribes to DevicesViewModel events:
| Event | Handler |
|---|---|
AssignToSlotRequested |
OnAssignToSlot |
ToggleSlotRequested |
OnToggleSlot |
HideDeviceRequested |
OnHideDevice |
RemoveDeviceRequested |
OnRemoveDevice |
DeviceHidingChanged |
OnDeviceHidingChanged |
UnwireEvents() (public)¶
Unsubscribes from all DevicesViewModel events.
Device Assignment¶
OnAssignToSlot(int slotIndex) (private)¶
Assigns the selected device to a slot:
1. Auto-creates the slot if needed.
2. SettingsManager.AssignDeviceToSlot(), populates ProductGuid.
3. Creates default PadSetting if none exists.
4. AutoEnableHidingDefaults() for newly assigned devices.
5. Marks dirty, raises DeviceAssignmentChanged, DeviceHidingStateChanged, NavigateToSlotRequested.
AssignDeviceToSlot(Guid instanceGuid, int slotIndex) (public)¶
Public version for drag-and-drop. Same logic as OnAssignToSlot but takes a GUID directly.
OnToggleSlot(int slotIndex) (private)¶
Toggles device assignment for a slot (multi-slot support). If unassigning leaves no remaining slots, auto-disables hiding.
UnassignDevice(Guid instanceGuid) (public)¶
Removes all slot assignments for a device.
Slot Management¶
CreateSlot(VirtualControllerType type = Xbox) (public) -> int¶
Creates the next available slot:
1. Sets OutputType before SlotCreated (order matters for sidebar rebuild).
2. Sets ProfileId = GetDefaultProfileId(type) so the profile picker shows a selection immediately. Per-category defaults (InputManager.Step5.VirtualDevices.cs): Xbox gets DefaultXboxProfileId (xbox-series-xs-bt), PlayStation gets dualshock-4-v2, Nintendo gets DefaultNintendoProfileId (switch-pro, the category's only profile), Extended gets the Custom "PadForge Game Controller" profile (padforge-custom). MIDI and Keyboard + Mouse have no HIDMaestro profile (null).
3. Returns slot index (0–15) or -1 if full.
The Nintendo type (4.1.0, #246) is VirtualControllerType.Nintendo = 5: a console-family bucket like Xbox / PlayStation (own sidebar group, icon, fixed catalog profile) riding the Extended raw-HID data path. It has no Customize surface. The fixed group order across the sidebar and dashboard is Xbox / PlayStation / Nintendo / Extended / Keyboard + Mouse / MIDI (VirtualControllerGroups.InOrder), and Nintendo slots cap at SettingsManager.MaxNintendoSlots (all 16 pads, like the other HM groups).
DeleteSlot(int slotIndex) (public) -> SlotDeletionInfo¶
Clears SlotCreated[slotIndex], calls padVm.ResetAllSettings() to prevent stale leaks, removes all UserSettings mapped to this slot. Returns a SlotDeletionInfo record struct (VirtualControllerType Type, int OldGroupPosition) carrying the deleted slot's type and its pre-removal index in the matching group's order list. Both are captured before SlotOrders.Remove mutates the list so InputService.OnSlotDeleted can drive the bubble-down cascade without re-querying. OldGroupPosition is -1 when the slot wasn't in any order list.
SetSlotEnabled(int slotIndex, bool enabled) (public)¶
Sets SettingsManager.SlotEnabled[slotIndex].
Device Hiding Toggle¶
OnHideDevice(Guid instanceGuid) (private)¶
Marks a device as hidden in SettingsManager and ViewModel.
OnRemoveDevice(Guid instanceGuid) (private)¶
Removes a device and all associated settings. The virtual controller slot persists empty.
OnDeviceHidingChanged(Guid instanceGuid) (private)¶
Handles HidHide/ConsumeInput/ForceRawJoystickMode toggles. Writes state to UserDevice, marks dirty, raises DeviceHidingStateChanged.
AutoEnableHidingDefaults(UserDevice, DeviceRowViewModel) (private)¶
Sets default hiding for newly assigned devices. Gamepads: auto-enables HidHide (if driver available). Keyboards/mice: does not auto-enable (blocking the only keyboard/mouse would lock out Windows).
DeviceService All Public Methods¶
| Method | Signature | Description |
|---|---|---|
WireEvents |
void WireEvents() |
Subscribes to DevicesViewModel events |
UnwireEvents |
void UnwireEvents() |
Unsubscribes from events |
AssignDeviceToSlot |
void AssignDeviceToSlot(Guid instanceGuid, int slotIndex) |
Public assignment for drag-and-drop |
UnassignDevice |
void UnassignDevice(Guid instanceGuid) |
Removes all slot assignments |
CreateSlot |
int CreateSlot(VirtualControllerType type = Xbox) |
Creates next available slot |
DeleteSlot |
SlotDeletionInfo DeleteSlot(int slotIndex) |
Deletes a slot, unassigns devices, returns deleted type + pre-removal group position |
SetSlotEnabled |
void SetSlotEnabled(int slotIndex, bool enabled) |
Enables/disables a slot |
DeviceService All Events¶
| Event | Signature | Description |
|---|---|---|
DeviceAssignmentChanged |
event EventHandler DeviceAssignmentChanged |
Fired after assign/unassign. MainWindow refreshes PadViewModel device info |
NavigateToSlotRequested |
event EventHandler<int> NavigateToSlotRequested |
Fired after assignment. MainWindow navigates to the assigned slot's page |
DeviceHidingStateChanged |
event EventHandler DeviceHidingStateChanged |
Fired when hiding toggles change. InputService re-applies device hiding |
RecorderService¶
File: PadForge.App/Services/RecorderService.cs
Implements: IDisposable
Handles input recording for mapping assignment. When the user clicks "Record", captures current state as a baseline, polls at 30 Hz for changes, and writes the detected input descriptor to the MappingItem.
RecorderService Constructor¶
Stores reference to MainViewModel.
Recording Flow¶
User clicks Record
|
StartRecording(mapping, padIndex, deviceGuid)
|
v
Capture baseline state (clone of CustomInputState)
|
v
Start 30Hz DispatcherTimer (PollTick)
|
v [each tick]
PollTick:
1. Check timeout (10 seconds)
2. Read current state (clone)
3. Wait-for-release phase (if neutralizeBaseline)
4. Check buttons (instant detection)
5. Check POV hats (instant detection)
6. Check axes (3-cycle hold confirmation)
|
v [on detection]
CompleteRecording:
1. Build descriptor string ("Button 0", "Axis 1", "POV 0 Up")
2. Auto-detect inversion based on movement direction + target type
3. Call mapping.LoadDescriptor(descriptor)
4. Raise RecordingCompleted event
StartRecording(MappingItem, int padIndex, Guid deviceGuid, bool neutralizeBaseline, bool negRecording) (public)¶
Cancels any existing recording, captures baseline CustomInputState, sets mapping.IsRecording = true, starts 30 Hz timer.
- neutralizeBaseline: waits for all buttons/POVs to return to neutral before detecting (for auto-prompt follow-ups).
- negRecording: records the negative direction of a bidirectional axis.
CancelRecording() (public)¶
Stops the timer, clears all recording state, sets IsRecording = false.
Detection Algorithm¶
| Input type | Detection | Details |
|---|---|---|
| Buttons | Instant | Any button transitioning from unpressed to pressed |
| POV hats | Instant | Any POV transitioning from centered (-1) to a direction (8 sectors, 45 degrees each) |
| Axes | 3-cycle hold | Threshold: 16384 units (~25% of 65535 range). Largest absolute delta wins. Mouse exception: instant accept (deltas return to center) |
Auto-inversion: ShouldAutoInvert() applies the "I" prefix based on target type and direction:
| Target | Inverts when |
|---|---|
| Stick axes | User pushed wrong direction for target |
| Trigger axes | Axis value decreased (reverse polarity) |
| KBM axes | Never (screen convention is correct) |
| Other | User pushed negative |
Constants:
| Constant | Value | Description |
|---|---|---|
PollIntervalMs |
33 | ~30 Hz poll rate |
TimeoutSeconds |
10 | Recording auto-cancels after this |
AxisThreshold |
16384 | ~25% of full range |
AxisHoldCycles |
3 | Cycles axis must be held |
RecorderService All Public Methods¶
| Method | Signature | Description |
|---|---|---|
StartRecording |
void StartRecording(MappingItem mapping, int padIndex, Guid deviceGuid, bool neutralizeBaseline = false, bool negRecording = false) |
Starts input recording for a mapping row's primary source |
StartRecordingExtraSource |
void StartRecordingExtraSource(MappingItem parent, MappingSourceItem extraSource, int padIndex, bool neutralizeBaseline = false, bool negRecording = false) |
Cross-device recording for a multi-source ExtraSource row. First device to fire wins |
StartRecordingExtraSourceParam |
void StartRecordingExtraSourceParam(MappingItem parent, MappingSourceItem extraSource, int padIndex, ParamTarget target) |
Records a button descriptor into an ExtraSource's Up / Down / Modifier param field |
StartRecordingFreeform |
void StartRecordingFreeform(int padIndex, Action<string, string> onComplete) |
Recording that delivers (deviceGuid, descriptor) to a callback without writing a MappingItem (shift activator dialog) |
CancelRecording |
void CancelRecording() |
Cancels without assigning |
Dispose |
void Dispose() |
Cancels recording, disposes resources |
Properties:
| Property | Type | Description |
|---|---|---|
IsRecording |
bool (get) |
True when recording is active |
RecorderService All Events¶
| Event | Signature | Description |
|---|---|---|
RecordingCompleted |
event EventHandler<RecordingResult> RecordingCompleted |
Raised on successful detection |
RecordingTimedOut |
event EventHandler RecordingTimedOut |
Raised after 10 second timeout |
RecordingResult fields: Mapping (MappingItem), ExtraSource (MappingSourceItem, non-null when the recording targeted an extra source), Descriptor (string), Type (MapType), IsParamRecording (bool, true when only an Up / Down / Modifier param field was updated).
ForegroundMonitorService¶
File: PadForge.App/Services/ForegroundMonitorService.cs
Monitors the foreground window and fires an event when the foreground process matches a profile's executable list. Not a standalone timer. Called at 30 Hz from InputService's UI timer tick.
How It Works¶
CheckForegroundWindow() (public)¶
Called at 30 Hz by UiTimer_Tick:
- Bails if
EnableAutoProfileSwitchingis false or no profiles exist. - Gets foreground window handle via
GetForegroundWindow(), then process ID andMainModule.FileName. - Skips if exe path unchanged (
_lastExePathdeduplication). - Matches against all profiles'
ExecutableNames(pipe-separated full paths, case-insensitive). - Fires
ProfileSwitchRequiredonly when the matched profile changes (_lastMatchedProfileId). Null signals reversion to default.
ForegroundMonitorService All Public Methods¶
| Method | Signature | Description |
|---|---|---|
CheckForegroundWindow |
void CheckForegroundWindow() |
Polls foreground window and fires event on profile change |
SetManualOverride |
void SetManualOverride(string currentProfileId) |
Sets the manual-override flag so auto-switching won't re-trigger the profile the user just overrode |
Properties:
| Property | Type | Description |
|---|---|---|
ManualOverrideActive |
bool (get) |
True while a manual override suppresses re-triggering the overridden profile |
LastForegroundExePath |
string (get) |
Last foreground exe path observed. Read-only UI feed (#175), tracked only while auto-switching is enabled |
LastMatchedProfileId |
string (get) |
Profile id the last foreground exe matched, or null |
ForegroundMonitorService All Events¶
| Event | Signature | Description |
|---|---|---|
ProfileSwitchRequired |
event Action<string> ProfileSwitchRequired |
Fired with profile ID (or null for default) when foreground process matches a different profile |
App-Side Services and Helpers (3.6.0)¶
These do not live in PadForge.App/Services/ (except NfcReaderService) and do not run on the WPF dispatcher. Each owns its own worker thread or is a static P/Invoke surface. The two speaker/haptic services and NfcReaderService reconcile off slot assignments like AudioPassthroughService. BluetoothLinkHelper and WinScard are stateless helper surfaces.
The Engine-side pieces these lean on (the Haptics/ encoders and reducers, ConsumerControlWrapper, ConsumerUsageTable, IdleInputDetector) are documented on Engine Library, not here.
HapticToneService¶
File: PadForge.App/Common/Input/HapticToneService.cs
Namespace: PadForge.Common.Input
Type: internal static
Turns macro sounds into HD-haptic tones on controllers whose haptics are LRAs (issue #147). Covers Nintendo Joy-Con and Pro (HD Rumble, report 0x10), the Steam Controller 2015 (0x8F feature square wave), the Steam Deck, and the Steam Controller 2026 / Triton (0x83 LFO-tone output report). An LRA cannot play PCM, so each rumble tick reduces the macro mix to one (dominant frequency, amplitude) pair via HapticToneReducer and encodes it per family through HapticToneEncoder. A pad with more than one actuator still plays that single mono tone. The combined Joy-Con pair is the exception since #223: its sink opens both children and drives both coils, routing the tone by the slot's active body motor (left motor → left coil, right motor → right, both or neither commanded → both coils), with each side staying hot for 300 ms after its motor drops so packet-rate rumble flapping doesn't strobe the audio.
Structurally the Valve/Nintendo analogue of the Sony speaker path: a controller assigned to a slot becomes an output sink whose MacroMixer is returned to SoundMacroService, so a macro PlaySound fans out to it with no macro-layer change. Family detection (FamilyOf) mirrors the bundled SDL's controller_list.h VID/PID table rather than a hand-picked PID list. Switch 2 is deliberately excluded (no reference plays an audible tone on its actuator).
Reconciled from InputService.Start(): EnsureStarted() starts a 3 s self-healing reconcile timer that builds and tears down sinks off the current slot assignments, and Reconcile() runs once at start to resume any persisted mirror toggle on launch. Also carries a Remote Link lane (#138 x #147): ApplyRemoteTone() drives a device's tone from a paired peer's shipped (freq, amp) frame.
| Member | Signature | Description |
|---|---|---|
DeviceHasHaptics |
bool DeviceHasHaptics(UserDevice) |
Gates the Audio tab, and through TouchpadPulseService.DeviceHasSwipePulse the Touchpad tab's Swipe Haptics card. True for a Joy-Con (L / R / combined pair), Switch Pro, Steam Controller 2015, Steam Deck, or Steam Controller 2026 (Triton) family device |
EnsureStarted |
void EnsureStarted() |
Starts the periodic reconcile. Idempotent |
Reconcile |
void Reconcile() |
Rebuilds the sink set from current slot assignments |
GetSlotSinkMixers |
List<MixingSampleProvider> GetSlotSinkMixers(int slot, Guid? deviceFilter = null) |
Live macro-sink mixers for SoundMacroService |
TriggerTestTone |
bool TriggerTestTone(Guid deviceGuid, float freqHz = 880f, int durationMs = 350) |
Plays a fixed test tone by device GUID, bypassing the mixer/reducer |
QueueTouchpadPulse |
void QueueTouchpadPulse(Guid deviceGuid, int padIdx, float amplitude) |
(4.1.0, #219) Queues one touchpad swipe-haptic tick for the device's pad-side actuator (pad 0 = left, pad 1 = right). Called from the polling thread. The sink's stream thread drains and sends the family-specific one-shot, so pulse writes never interleave with tone writes on the same handle |
ApplyRemoteTone |
void ApplyRemoteTone(UserDevice, float toneHz, float amplitude) |
Remote Link owner lane. Drives a local device's tone from a peer's frame |
Shutdown |
void Shutdown() |
Tears down every sink |
TouchpadPulseService¶
File: PadForge.App/Common/Input/TouchpadPulseService.cs
Namespace: PadForge.Common.Input
Type: internal static
Sony-side delivery for touchpad swipe-haptic ticks (4.1.0, discussion #219). A tick from the Engine's SwipeHapticsEvaluator raises a short-lived pulse level per (slot, device), and InputService's per-device rumble provider mixes the level into the DS4 / DualSense rumble bytes via max(), the same idiom audio-bass rumble uses, so the effects dispatcher stays the sole rumble writer and a pulse coexists with live game rumble instead of replacing it. Burst shape: hold the intensity for PulseDurationMs = 80 ms, then drop to zero (DS4MapperTest's DS4 haptic burst duration). Repeated ticks re-arm the window, overlapping ticks max-combine, and 80 ms guarantees the dispatcher's 33 ms tick samples the pulse at least twice (on, then off). The Steam Controller family does NOT come through here. Its ticks ride HapticToneService.QueueTouchpadPulse's per-side actuator commands.
| Member | Signature | Description |
|---|---|---|
PulseDurationMs |
const int = 80 |
Pulse hold time in ms (DS4MapperTest HAPTICS_DURATION_DEFAULT) |
IsSonyRumblePad |
bool IsSonyRumblePad(UserDevice) |
The Sony pads the effects dispatcher is the sole rumble writer for. Mirrors the exact PID set of the SDL-rumble skip in InputManager.Step2.ApplyForceFeedback |
DeviceHasSwipePulse |
bool DeviceHasSwipePulse(UserDevice) |
Gates the Touchpad tab's Swipe Haptics card: the device has a touchpad AND a haptic lane PadForge drives (HapticToneService.DeviceHasHaptics or a dispatcher-driven Sony pad) |
Pulse |
void Pulse(int slot, Guid device, float amp) |
Raises the pulse level for (slot, device). Called from the polling thread on a swipe tick |
CurrentLevel |
float CurrentLevel(int slot, Guid device) |
Current pulse level 0..1, read by the dispatcher's per-device rumble provider. 0 once the burst expires |
MixIntoMotors |
void MixIntoMotors(ref ushort scaledLeft, ref ushort scaledRight, float level) |
Max-merges a pulse level into the scaled motor pair. The DS4 / DS5 touchpad sits center, so the tick drives both motors |
IsSlotActive |
bool IsSlotActive(int slot) |
True while any device on the slot has a live pulse. Keepalive input for Step 2's dispatcher poke, without which the dispatcher's 33 ms timer parks on an otherwise idle slot and the pulse never reaches the motors |
Clear |
void Clear() |
Drops every live pulse. Called from InputManager.ResetGestureContexts (profile switch / engine stop) so a burst never outlives its source |
SwitchHomeLedSetter¶
File: PadForge.App/Common/Input/SwitchHomeLedSetter.cs
Namespace: PadForge.Common.Input
Type: internal static
HOME-button LED brightness for Nintendo Switch controllers (4.1.0, discussion #226), the third #209 Guide-LED lane beside XboxGipGuideLedWriter and SteamHomeLedSetter. Rides SDL's per-device SDL_SetJoystickLED through SdlDeviceWrapper.SetHomeLedBrightness: the Switch HIDAPI driver converts max(r,g,b) to a 0–100 brightness and builds a subcommand 0x38 Set HOME Light packet that holds the LED steady at a 4-bit intensity, so brightness is genuinely variable (15 nonzero hardware steps). Unlike the 2015 Steam Controller's process-global hint, this lane is per device: two Switch pads on different slots hold different brightness values.
PID gate (IsSwitchHomeLedDevice, Nintendo VID 0x057E): 0x2007 Joy-Con (R), 0x2008 combined Joy-Con pair (the write fans to both children and the right one acts), 0x2009 Pro Controller, 0x200E charging grip (right slot acts). The gate is PID-only, with no transport check (the Xbox GIP lane, by contrast, is USB-only). Excluded on the SDL source: the standalone Joy-Con (L) (no HOME LED), the Switch 2 family (SDL's driver refuses the write), NSO classic controllers, and third-party pads. A masquerading clone that probes as a licensed controller fails safely inside SDL's own type check.
The Switch driver's subcommand path waits for the controller's ACK (~30 ms typical, 100 ms worst case per attempt) while SDL's global joystick lock is held, so TrySet only enqueues: a lazy background worker owns every SDL_SetJoystickLED call, latest-wins per device (a slider drag or a flash-on-engage macro collapses to the newest value), change-detected per SDL instance id. Only a successful write is recorded in the ledger, so a failed write retries on the next apply pass. Instance ids are never reused, so a reconnected pad structurally misses the ledger and the configured brightness reapplies on the connect-window ApplyGuideLeds pass. Nothing here ever throws into a caller. ShouldWrite / RecordWritten split the ledger contract out as a unit-test seam (GuideLedTests), no SDL required.
| Member | Signature | Description |
|---|---|---|
TrySet |
bool TrySet(UserDevice ud, int percent0to100) |
Queues a HOME LED brightness write for one Switch device. Latest-wins per device. Never throws, never blocks on device I/O |
IsSwitchHomeLedDevice |
bool IsSwitchHomeLedDevice(ushort vendorId, ushort productId) |
The PID gate above |
WiiSpeakerService¶
File: PadForge.App/Common/Input/WiiSpeakerService.cs
Namespace: PadForge.Common.Input
Type: internal static
Plays macro sounds through a Wii Remote's built-in speaker as low-rate PCM (issue #146, sub-feature 2). The 48 kHz macro mix is resampled to signed 8-bit PCM at 2000 Hz mono and written as one 0x18 speaker report (20 samples) per 10 ms tick. PCM is used over 4-bit ADPCM because it is memoryless: a dropped or late report on the SDL-shared Bluetooth link is a single click, not a cascading decoder desync. The wire protocol (I2C register map, 0x14/0x16/0x18/0x19 reports) is grounded in dolphin's Speaker.cpp. Write path is chosen per device by a BuildSink probe: overlapped WriteFile when the BT stack accepts it, else synchronous HidD_SetOutputReport.
Same sink shape as HapticToneService: a Wii Remote assigned to a slot exposes its MacroMixer to SoundMacroService, and a per-slot system-audio loopback mirror is available (same option DualSense exposes). Wired from InputService.Start(): EnsureStarted() starts the 3 s reconcile timer, and Reconcile() runs once at start to resume a persisted mirror on launch.
| Member | Signature | Description |
|---|---|---|
DeviceHasSpeaker |
bool DeviceHasSpeaker(UserDevice) |
Gates the Audio tab. True for a Wii Remote (RVL-CNT-01 / -TR) |
EnsureStarted |
void EnsureStarted() |
Starts the periodic reconcile. Idempotent |
Reconcile |
void Reconcile() |
Rebuilds the sink set from current slot assignments |
GetSlotSinkMixers |
List<MixingSampleProvider> GetSlotSinkMixers(int slot, Guid? deviceFilter = null) |
Live macro-sink mixers for SoundMacroService |
Shutdown |
void Shutdown() |
Tears down every speaker sink |
RumbleAudioService¶
File: PadForge.App/Common/Input/RumbleAudioService.cs
Namespace: PadForge.Common.Input
Type: internal static
Rumble-to-audio renderer for bass shakers and LFE channels (4.1.0, issue #236), surfaced in the UI as the Pad page's "Bass Shakers" tab. Routes the game feedback each slot's virtual controller receives to WASAPI render endpoints as low-frequency sine tones, four fixed voices per slot.
Data path: VC output callbacks fill a controller-local pack, the poll thread's feedback lane evaluates the slot's voice bindings once per tick and publishes the result (PublishIfCurrent), and the render thread reads the published packs as its only input. The class never reads VibrationStates, FinalVibrationStates, macro rumble, test rumble, or any per-physical-device projection, which makes the feedback loop (shaker tone to loopback to AudioBassDetector to audio rumble to louder tone) impossible by construction. Players are keyed by endpoint, not slot: all slots routed to one endpoint share a single WasapiOut, one sample clock, and one composite limiter.
Lifecycle: EnsureStarted() is called unconditionally from InputService.Start() (cheap when no slot has a config). Silence is an explicit edge, never an inference from callback inactivity: idle entry and engine stop (InputManager), panic quiesce (InputService.PanicQuiesceOutputs), and slot delete all call SilenceSlot / SilenceAll, and a per-slot generation makes a stale in-flight poll publish lose against a newer silence edge. A configured-but-unresolved endpoint fails closed, with no fallback device. The renderer dies with the engine (StopAll in InputManager.Stop), deliberately not with SoundMacroService.StopAll, which runs on every profile apply.
| Member | Signature | Description |
|---|---|---|
EnsureStarted |
void EnsureStarted() |
Starts the 5 s reconcile worker. Idempotent |
RequestReconcile |
void RequestReconcile() |
Nudges the worker after a config edit. Never touches WASAPI on the calling thread |
PublishIfCurrent |
void PublishIfCurrent(int slot, int generation, long packed) |
Poll-thread publish, discarded if a silence edge advanced the slot's generation since GetGeneration was read |
SilenceSlot / SilenceAll |
void SilenceSlot(int slot) / void SilenceAll() |
Explicit per-slot / all-slot silence edges |
PulseTestVoice |
void PulseTestVoice(int slot, int voice, int durationMs) |
Bass Shakers tab test button: plays one voice at full authored gain |
StartSweep / StopTest |
void StartSweep(int slot, int durationMs) / void StopTest(int slot) |
20–120 Hz resonance-finding sweep on the LOW voice, and its stop |
GetSlotStatus |
string GetSlotStatus(int slot) |
Per-slot endpoint status for the UI: null = inactive, "!" prefix = fail-closed error marker |
StopAll |
void StopAll() |
Fades and disposes every endpoint player, stops the worker. Engine stop / app exit |
NfcReaderService¶
File: PadForge.App/Services/NfcReaderService.cs
Namespace: PadForge.Services
Implements: IDisposable
Type: internal sealed, singleton (static Active property)
Owns one PC/SC context and a single background monitor thread that blocks in SCardGetStatusChange and raises TagDetected(reader, uid) when a tag arrives on any reader (issue #150). Event-driven, not polled: NFC arrival is an event, so there is no fixed-rate timer. Tolerates a stopped Smart Card service or zero readers (treated as "no NFC devices", inert like absent MIDI services), and re-establishes a dead context in place if the service stops and restarts mid-session.
Started lazily from Step 1 device enumeration (InputManager.Step1.UpdateDevices.cs, UpdateNfcReaderDevices()), which calls Start() and reads GetReaders() to register each reader as a device. Disposed by ShutdownNfcReaders(). RegisterNfcTagDialog subscribes to TagDetected directly while open.
| Member | Signature | Description |
|---|---|---|
Active |
static NfcReaderService Active { get; } |
The running singleton, or null when NFC is absent |
TagDetected |
event Action<string, string> |
Raised on the monitor thread with (reader, uid). UID is uppercase hex |
Start |
static NfcReaderService Start() |
Establishes the context, starts the monitor. Returns null when the Smart Card service is unavailable |
GetReaders |
List<string> GetReaders() |
Snapshot of currently visible reader names |
Dispose |
void Dispose() |
Cancels the blocking wait, joins the thread, releases the context |
WinScard¶
File: PadForge.App/Common/Input/WinScard.cs
Namespace: PadForge.Common.Input
Type: internal static
Thin winscard.dll P/Invoke surface (the Windows PC/SC stack) used by NfcReaderService. Signatures and the call sequence are taken from pcsc-sharp (BSD-2-Clause). The tag identity is its UID: ReadUid connects to the card on a reader, sends the ISO 7816 "Get Data" APDU FF CA 00 00 00, and returns the UID as an uppercase hex string. No tag writing, no keyed sectors.
| Member | Signature | Description |
|---|---|---|
ListReaders |
List<string> ListReaders(IntPtr ctx) |
Enumerates reader names. Empty list (never throws) when the service is stopped or no reader is present |
ReadUid |
string ReadUid(string reader) |
Reads the tag UID on a reader as uppercase hex, or null on any failure. Uses a short-lived per-read context |
The SCard* DllImports (SCardEstablishContext, SCardGetStatusChange, SCardConnect, SCardTransmit, SCardCancel, etc.), status-flag and return-code constants, and the SCARD_READERSTATE / SCARD_IO_REQUEST structs live here as well.
BluetoothLinkHelper¶
File: PadForge.App/Common/Input/BluetoothLinkHelper.cs
Namespace: PadForge.Common.Input
Type: public static
Per-family Bluetooth disconnect / power-off (issue #162). TryDisconnectDevice() routes each family to its own lane:
| Family | Mechanism |
|---|---|
| Sony / Wii (BR/EDR classic BT) | IOCTL_BTH_DISCONNECT_DEVICE to the host radio, target MAC from the HID serial (the DS4Windows path) |
Xbox (XInput-backend, SDL path XInput#N) |
XInputPowerOff (ordinal 103), with a BTHLE devnode disable-cycle fallback for BLE Series pads |
| Xbox (HID-pathed) | XInputPowerOff slot-matched by VID/PID via XInputGetCapabilitiesEx (ordinal 108) |
| Valve | Steam 0x9F (ID_TURN_OFF_CONTROLLER) feature report on the device's own HID handle |
| Switch 2 | SDL fork effect passthrough (SDL_SendGamepadEffect), with a direct GATT write fallback |
IsDisconnectTarget() gates the four #162 UI surfaces (macro candidates, idle countdown, Devices-page control, Specific-device picker) so all four agree. The device-aware overload excludes peer:// (Remote Link) devices and adds the Switch 2 family (which the SDL BLE driver leaves without a DevicePath or serial) plus the combined gen-1 Joy-Con pair (PID 0x2008), whose synthetic path the plain-path predicate cannot see. Powers the idle-disconnect countdown and the Disconnect Controller macro action. ReEnablePendingDevNodes() is called at the top of InputService.Stop() to re-enable any devnode still inside its 30 s disable window.
| Member | Signature | Description |
|---|---|---|
TryDisconnectDevice |
bool TryDisconnectDevice(ushort vendorId, ushort productId, string devicePath, string serial, IReadOnlyList<string> bthInstanceIds = null, IntPtr gamepadHandle = default) |
Per-family disconnect. Debounced per device (3 s) |
IsDisconnectTarget |
bool IsDisconnectTarget(string devicePath) |
Whether a BT-HID or XInput-backend path can be targeted |
IsDisconnectTarget |
bool IsDisconnectTarget(string devicePath, ushort vendorId, ushort productId) |
Device-aware overload. Excludes peer://, includes Switch 2 and the combined gen-1 Joy-Con pair (0x2008) |
IsSwitch2 |
bool IsSwitch2(ushort vendorId, ushort productId) |
The Switch 2 family, mirroring SDL usb_ids.h |
TryDisconnect |
bool TryDisconnect(string serial) |
BR/EDR radio-IOCTL disconnect by HID-serial MAC |
TryParseAddress |
bool TryParseAddress(string serial, out long address) |
Parses a HID serial into the 8-byte little-endian IOCTL address |
ReEnablePendingDevNodes |
void ReEnablePendingDevNodes() |
Shutdown flush. Re-enables any devnode still in its disable window |
WorkshopProfileMaterializer (v4.1, #9)¶
File: PadForge.App/Services/WorkshopProfileMaterializer.cs
Static bridge between the PadForge.SteamWorkshop translator and the app's profile model. Materialize(TranslatedProfile, SteamWorkshopSource) builds a ProfileData that carries only the slots the translation demands (NeedsXboxSlot / NeedsKbmSlot), packed from slot 0 with the Xbox VC first when present, so a split config lands Xbox at slot 0 and Keyboard + Mouse at slot 1 while a keyboard-only config imports as a single KbM VC at slot 0 (each created slot enabled, default HIDMaestro profile id per type, device assignments deliberately empty). It attaches the translated MappingSets, clones each translated menu (MenuDefinitionEntry, #9 B-17) onto every created slot's MappingSet.Menus (the menu runtime and the fired-set provider are slot-keyed, so a split config's two slots each carry their own copy, and the overlay publisher dedupes at display time), converts translated macros to MacroData on pad 0 with OutputController triggers (a macro carrying a device-free descriptor trigger converts instead through the exact picker path, MacroItem.TryBuildTriggerEntry, and is dropped when any descriptor fails to convert), converts Steam's normalized 0..65535 cursor coordinates to primary-monitor pixels for MoveMouseToScreenPosition, and stamps the provenance block. MainWindow's AddWorkshopProfile sink then mirrors the .pfprofile import path (name dedup, Profiles append, MarkDirty, optional LoadProfile). Full detail on Steam Workshop Config Import Internals.
WorkshopTuningApplier (v4.1, #9)¶
File: PadForge.App/Services/WorkshopTuningApplier.cs
Static companion to the materializer. A Steam config assumes one controller, so its tuning is per physical input, but the import runs before any device is assigned and device tuning is keyed by device GUID. The import therefore parks the values on the slot as MappingSet.Workshop* stamps. ApplyToAssignedDevice(slotIndex, ps, deviceGuid) folds those stamps into the assigned device's own PadSetting at assignment time, then clears them, so from then on the values live in the user's settings and the existing cards show and edit them.
Rules:
- Applied only where the user has not already chosen something. Re-assigning a device cannot silently overwrite hand-set tuning.
- Cleared unconditionally. A stamp offered once has done its job. Leaving it would re-apply after the user deliberately changed the value back.
- Called from every assignment funnel. Both
DeviceService.OnAssignToSlot(the device list's assign command) andDeviceService.AssignDeviceToSlot(drag-and-drop and programmatic). Idempotent and cheap, so over-calling is free and under-calling is a silent regression. - Also folds per-source response shaping the import writes onto its rows (
FoldSourceShaping), which had the same live-in-the-engine, absent-from-the-cards defect one level down.
Two stamps stay runtime overlays because no user-facing card exists to fold them into: WorkshopGyroRatchetDescriptors (no ratchet field on PadSetting) and ParamFlickRotationOffsetDeg (no rotation-offset control). The second is a known gap, not a deliberate exclusion.
MainWindow Service Wiring¶
File: PadForge.App/MainWindow.xaml.cs
MainWindow owns the service instances and wires them to ViewModels. Two notable behaviors:
SyncBarBackgrounds¶
SyncBarBackgrounds() pixel-samples the current theme's background brush at runtime to match the sidebar and app branding bar backgrounds. Re-invoked on theme changes via the ThemeManager.Current.ActualApplicationThemeChanged handler, ensuring the bars stay consistent when the user switches between light and dark themes.
AddController Popup Dismiss¶
The "Add Controller" popup auto-dismisses on navigation, window move, window resize, and window deactivation. This prevents the popup from floating over stale content when the user interacts with other parts of the application.
Service Interaction Patterns¶
Startup Sequence¶
App.OnStartup
|-- MainWindow constructor
| |-- Creates MainViewModel, SettingsService, InputService, DeviceService, RecorderService
| |-- SettingsService.Initialize() [loads XML into SettingsManager]
| |-- DeviceService.WireEvents() [subscribes to DevicesViewModel events]
| |-- InputService.SettingsService = ss [for save triggers]
| |-- InputService.Start() [creates engine, starts all subsystems]
| | |-- InputManager.Start() [launches polling thread]
| | |-- StartDsuServerIfEnabled()
| | |-- StartWebServerIfEnabled()
| | |-- SyncAudioBassDetector()
| | |-- ApplyDeviceHiding()
| | |-- UI timer starts (30Hz)
Shutdown Sequence¶
MainWindow.OnClosing
|-- InputService.Stop()
| |-- UI timer stops
| |-- Unsubscribe all events
| |-- StopDsuServer()
| |-- StopWebServer()
| |-- StopAudioBassDetector()
| |-- RemoveDeviceHiding()
| |-- InputManager.Stop() + Dispose()
| |-- Tear down all HM virtuals via HMContext.Dispose()
|-- SettingsService.Save() [final save]
|-- DeviceService.UnwireEvents()
|-- RecorderService.Dispose()
Device Connected Flow¶
InputManager.UpdateDevices() [polling thread, every 2s]
|-- SDL enumerates devices
|-- DevicesUpdated event fires
|-- Dispatcher.BeginInvoke: [UI thread]
|-- SyncDevicesList()
|-- UpdatePadDeviceInfo()
|-- ApplyDeviceHiding() [blacklist newly connected devices]
Settings Change Flow¶
User drags slider on Pad page
|-- PadViewModel property updates (data binding)
|-- [next 30Hz tick] SyncViewModelToPadSettings()
| |-- SaveViewModelToPadSetting(syncMappings: false)
| | |-- PadSetting properties updated (string refs, atomic)
| | |-- Polling thread reads new values next cycle
Profile Switch Flow¶
[30Hz tick] ForegroundMonitor.CheckForegroundWindow()
|-- Detects new foreground matches different profile
|-- ProfileSwitchRequired event fires
|-- InputService.OnProfileSwitchRequired(profileId)
|-- SaveActiveProfileState() [snapshot outgoing profile]
|-- ApplyProfile(target) [load incoming profile]
| |-- Set SlotCreated/Enabled/OutputType
| |-- Transition device assignments (single-pass, survivors untouched)
| |-- Apply Extended/MIDI configs
| |-- Apply DSU/Web server settings
| |-- UpdatePadDeviceInfo()
| |-- Reload PadSettings into ViewModels
Recording Flow¶
User clicks Record button
|-- MainWindow calls RecorderService.StartRecording(mapping, padIndex, deviceGuid)
|-- [30Hz timer] PollTick detects input change
|-- CompleteRecording(type, index, direction)
|-- RecordingCompleted event fires
|-- MainWindow receives result, optionally prompts for next mapping
See Also¶
- Architecture Overview: Solution structure, threading model,
SettingsManagervsSettingsService - Input Pipeline:
InputManagerpolling loop driven byInputService - Settings and Serialization:
SettingsServiceXML persistence,PadSettingdata model - ViewModels:
PadViewModel,DashboardViewModelconsumed by service event handlers - XAML Views:
MainWindow.xaml.cswires services to ViewModels - Engine Library:
Gamepad,CustomInputState,UserDevice,UserSetting - DSU Protocol Implementation:
DsuMotionServerlifecycle managed byInputService
Last updated for PadForge 4.1.0.