-
Notifications
You must be signed in to change notification settings - Fork 6
Services Layer
This page documents the five service classes that bridge the PadForge.Engine background processing with the WPF UI layer. All services live in PadForge.App/Services/ and operate primarily on the WPF dispatcher thread unless otherwise noted.
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 -->|870Hz 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
- 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
- DeviceService
- RecorderService
- ForegroundMonitorService
+-------------------+ 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) |
+-------------------+
| Direction | Mechanism | Frequency |
|---|---|---|
| Engine -> UI | InputService reads CombinedOutputStates[], VibrationStates[], CombinedVJoyRawStates[], CombinedMidiRawStates[], CombinedKbmRawStates[]
|
30 Hz (UI timer) |
| UI -> Engine | InputService writes SlotControllerTypes[], SlotVJoyConfigs[], MacroSnapshots[], _midiConfigs[]
|
30 Hz (SyncViewModelToPadSettings) |
| UI -> PadSetting | InputService pushes dead zone, 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) |
PadForge uses three primary threads. Understanding which thread runs what is critical for avoiding 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 | ~870 Hz | SDL input read, mapping, dead zone processing, virtual controller output, rumble, DSU broadcast |
| Subsystem threads | Various | Varies | DSU server (UDP), Web controller (HTTP/WebSocket), Audio bass detector (WASAPI), HidHide controller |
-
SettingsManager collections (
UserDevices,UserSettings) have aSyncRootobject. Both UI and polling threads lock on it when iterating. - PadSetting string properties are written atomically by .NET's reference assignment. The UI thread writes them at 30 Hz; the polling thread reads them at ~870 Hz. No lock needed.
-
InputManager arrays (
CombinedOutputStates[],VibrationStates[], etc.) are written by the polling thread and read by the UI timer. Simple value copies, no locking. - Macro snapshots are atomically swapped (array reference assignment) by the UI thread; the polling thread reads the reference.
-
Engine events (
DevicesUpdated,FrequencyUpdated) fire on the polling thread and are marshalled to the UI thread viaDispatcher.BeginInvoke.
File: PadForge.App/Services/InputService.cs
Implements: IDisposable
The central service bridging the background 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).
public InputService(MainViewModel mainVm)Constructor actions:
- Stores reference to
MainViewModeland capturesDispatcher.CurrentDispatcher. - Subscribes to
Strings.CultureChangedfor language-change server status refresh. - Subscribes to
SelectedDeviceChangedandMappingsRebuiltevents on everyPadViewModel. - Subscribes to
DevicesViewModel.PropertyChangedfor offline device detail display.
Fields initialized:
-
_previousSelectedDevicedictionary (tracks per-pad device GUID for save-before-switch). - Macro trigger recording state fields (all null/default until recording starts).
Full startup sequence:
-
Cleanup stale ViGEm nodes --
InputManager.CleanupStaleVigemDevices()removes USB device nodes from previous sessions (crash recovery). -
Create InputManager -- Sets
PollingIntervalMsfromSettingsViewModel.PollingRateMs. -
Copy slot configuration -- Copies
SlotControllerTypes[], VJoy configs, and MIDI configs from PadViewModels to the engine. Counts expected Xbox 360 / DS4 VCs and callsPreInitializeVigemCounts()so the ViGEm device filter catches virtual controllers on the first cycle. -
Subscribe to engine events --
DevicesUpdated,FrequencyUpdated,ErrorOccurred. -
Subscribe to ViewModel property changes --
SettingsViewModel.PropertyChanged,DashboardViewModel.PropertyChanged. -
Create ForegroundMonitorService -- Subscribes to
ProfileSwitchRequired. -
Capture default profile snapshot -- Uses
SettingsManager.PendingDefaultSnapshot(from previous session's XML) or creates a fresh snapshot viaSnapshotCurrentProfile(). -
Start engine --
_inputManager.Start()launches the polling thread. - Start subsystems -- DSU server, web controller server, audio bass detector (each conditional on settings).
-
Clear stale HidHide state -- Calls
HidHideController.ClearAll()to remove entries from previous crash/kill. -
Apply device hiding -- Calls
ApplyDeviceHiding()(HidHide blacklist + input hooks). -
Start UI timer -- 30 Hz
DispatcherTimeratDispatcherPriority.Render, handler:UiTimer_Tick. -
Update state -- Sets
IsEngineRunning = true, enters idle if no slots are created.
- Stops UI timer and unsubscribes from its Tick event.
- Unsubscribes from all ViewModel property change events.
- Unsubscribes from per-pad events (
SelectedDeviceChanged,MappingsRebuilt). - 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(preserveVJoyNodes)and_inputManager.Dispose(). - Updates MainViewModel state (sets engine status to "Stopped", marks all device rows offline).
- If
!preserveVJoyNodes, removes all vJoy device nodes (up to 3 second timeout).
The preserveVJoyNodes parameter is used when the engine is about to restart immediately (e.g., output type change), to avoid an unnecessary 10+ second device node removal/recreation cycle.
Calls Stop() in a try/catch (best-effort shutdown).
UiTimer_Tick is the heartbeat of the service layer. Called ~30 times per second on the UI thread, it performs all of the following in sequence:
UiTimer_Tick
|-- Update Pad ViewModels (gamepad state, vibration, vJoy/MIDI/KBM raw state)
|-- UpdateDashboard()
|-- 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]
For each of the 16 slots:
- Reads
CombinedOutputStates[i]andVibrationStates[i]and callspadVm.UpdateFromEngineState(). - For custom vJoy slots: reads
CombinedVJoyRawStates[i]and callspadVm.UpdateFromVJoyRawState(). - For MIDI slots: reads
CombinedMidiRawStates[i]and callspadVm.UpdateFromMidiRawState(). - For KBM slots: reads
CombinedKbmRawStates[i]and setspadVm.KbmOutputSnapshot. - Per-device state for stick/trigger tab previews: reads either KBM pre-deadzone values (synthesized into a
Gamepadstruct) or the selected device'sRawMappedState.
Two boolean flags gate expensive per-frame work:
-
IsDevicesPageVisible-- set by MainWindow navigation. When true, callsUpdateDevicesRawState(). -
IsPadPageVisible-- set by MainWindow navigation. When true, callsUpdateMappingLiveValues().
Pushes engine statistics to DashboardViewModel:
- Engine state key ("Running" / "Idle" / "Stopped") and localized status text.
-
PollingFrequencyfrom_inputManager.CurrentFrequency. - Device counts (
TotalDevices,OnlineDevices,MappedDevices) computed underUserDevices.SyncRootlock. - Calls
RefreshSlotSummaryProperties()andRefreshNavItemConnectedCounts().
Updates all SlotSummary items on the dashboard:
- Per-slot:
IsActive,DeviceName,MappedDeviceCount,ConnectedDeviceCount,IsVirtualControllerConnected,IsInitializing,IsEnabled,StatusText. - Assigns per-type numbering (e.g., "Xbox 1", "DS4 2", "vJoy 1").
Updates sidebar NavControllerItem.ConnectedDeviceCount and IsInitializing for power icon color logic.
Updates the visual raw input state display for the selected device:
- Finds
UserDevicefor the selectedDeviceRowViewModel. - On device change: rebuilds axis/button/POV collections via
devVm.RebuildRawStateCollections(). - Updates axis values in-place (
NormalizedValue = Axis[i] / 65535.0). - Updates button states, keyboard layout keys, POV hat values.
- Updates mouse visual properties (
MouseMotionX,MouseMotionY,MouseScrollIntensity). - Updates gyro/accel values if device has those capabilities.
Handles SelectedDevice property changes when the engine is NOT running. Populates the detail panel structure from cached UserDevice capabilities so the layout is visible even offline.
For the active Pad page:
- Finds the selected device for the current slot.
- For each
MappingItem, parses theSourceDescriptorand reads the current raw value fromCustomInputState. - Sets
mapping.CurrentValueTextto the numeric value string.
Simplified Step 3 parser for display purposes. Strips I/H prefixes, parses "Axis N", "Button N", "Slider N", "POV N" descriptors, and reads the corresponding value from the state arrays.
The primary runtime sync path. For each pad slot:
-
Always synced (even with no device selected):
-
SlotControllerTypes[i]frompadVm.OutputType - VJoy config via
SyncVJoyConfigToSlot() - MIDI config via
_inputManager._midiConfigs[i]
-
-
Per-device sync (when a device is selected):
- Calls
SaveViewModelToPadSetting(padVm, instanceGuid, syncMappings: false) - Pushes dead zones (independent X/Y), anti-dead zones, linear, center offsets, max range (independent directions), trigger dead zones, 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
- Calls
-
Audio bass detector lifecycle: detects when
AudioRumbleEnabledtoggles on any slot and callsSyncAudioBassDetector().
Writes all tuning parameters from ViewModel to PadSetting properties. When syncMappings is true (explicit save, preset change, device switch), also clears and rewrites all mapping descriptors.
Reverse direction: reads PadSetting properties and populates the PadViewModel. Includes dead zones, sensitivity curves, max ranges, center offsets, trigger settings, force feedback, audio rumble, and all mapping descriptors.
private void OnSettingsPropertyChanged(object sender, PropertyChangedEventArgs e)Propagates SettingsViewModel property changes to the engine at runtime:
| Property | Action |
|---|---|
PollingRateMs |
Sets _inputManager.PollingIntervalMs
|
EnableInputHiding |
Calls ApplyDeviceHiding() or RemoveDeviceHiding()
|
private void OnDashboardPropertyChanged(object sender, PropertyChangedEventArgs e)Propagates DashboardViewModel property 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 |
All fire on the polling thread and are marshalled to the UI thread.
Marshals to UI thread via Dispatcher.BeginInvoke:
-
SyncDevicesList()-- updates DevicesViewModel collection. -
UpdatePadDeviceInfo()-- refreshes PadViewModel device info. -
ApplyDeviceHiding()-- re-applies hiding for newly connected devices.
No-op (frequency is read on the next UI timer tick).
Marshals error message to _mainVm.StatusText.
Synchronizes DevicesViewModel.Devices with SettingsManager.UserDevices:
- Takes a snapshot under lock.
- Updates existing rows and adds new ones (skips virtual/shadow devices).
- Removes rows for devices no longer in the snapshot or that are virtual.
- Sorts alphabetically by name, then by VID:PID.
- Calls
devVm.RefreshCounts().
Filters virtual controllers from the user-facing device list. Defense-in-depth (Step 1 already filters ViGEm at the engine level). Checks:
- Name containing "ViGEm" or "Virtual Gamepad"
- Device path containing "vigem" or "virtual"
-
IsHiddenflag
Maps all UserDevice properties to the ViewModel row: name, VID/PID, online status, capabilities, device type key, slot assignments, HidHide state, instance path.
Rebuilds each PadViewModel's MappedDevices collection from UserSettings.FindByPadIndex(). Handles multi-device slots, auto-selects first device, refreshes sidebar and dashboard slot lists.
When the user selects a different device in a pad slot's dropdown:
- Saves current ViewModel values to the PREVIOUSLY selected device's PadSetting (only when switching to a different device, not re-adding the same one).
-
Loads the new device's PadSetting into the ViewModel via
LoadPadSettingToViewModel(). - Populates available input dropdown choices via
PopulateAvailableInputs(). - Updates the
_previousSelectedDevicetracker.
When a pad's mappings are rebuilt (OutputType or vJoy preset change), reloads mapping descriptors from PadSetting without touching dead zone / force feedback settings.
Applies a source PadSetting to the currently selected device. Used by clipboard Paste and "Copy From".
Applies with cross-layout translation (e.g., Xbox -> vJoy mapping key conversion).
Pushes all active PadViewModel state back to PadSettings. Call before reading PadSettings across multiple slots (e.g., Copy From dialog).
Returns the PadSetting for the currently selected device, after syncing ViewModel state.
For each slot, creates a snapshot array of MacroItem objects and assigns it to _inputManager.MacroSnapshots[i]. The engine reads these atomically each cycle. Empty macro lists set the snapshot to null.
Starts recording button/axis/POV presses for a macro trigger combo. Captures axis baseline for delta detection.
Finalizes recording. Writes accumulated data to the MacroItem based on the trigger path:
-
InputDevice path: raw device buttons (
TriggerRawButtons) + device GUID -
Custom vJoy path: numbered button words (
TriggerCustomButtonWords) -
OutputController path: Xbox button bitmask (
TriggerButtons)
Also writes axis targets/directions and POV triggers.
Called each UI tick during recording. Reads current state based on TriggerSource:
-
InputDevice: scans raw buttons/POVs from all devices mapped to the slot. First device to press a button "locks in" via
_recordingDeviceGuid. -
Numbered (custom vJoy): accumulates from
CombinedVJoyRawStates. -
OutputController: accumulates from
CombinedOutputStatesXbox button bitmask.
Axis detection uses baseline+delta+hold pattern (same as RecorderService): 25% threshold, 3-cycle hold confirmation.
- Checks
Dashboard.EnableDsuMotionServerand engine existence. - Creates
DsuMotionServer, subscribes toStatusChanged. - Validates port (1024-65535, default 26760).
- Calls
_dsuServer.Start(port)-- if successful, assigns to_inputManager.DsuServer. - On failure: disposes server.
- Clears
_inputManager.DsuServer. - Disposes server instance.
- Checks
Dashboard.EnableWebControllerand engine existence. - Creates
WebControllerServer, subscribes toStatusChanged,DeviceConnected,DeviceDisconnected. - On device connect: calls
_inputManager.RegisterExternalDevice(). - On device disconnect: calls
_inputManager.UnregisterExternalDevice(). - Validates port (1024-65535, default 8080).
- Calls
_webServer.Start(port).
- Unsubscribes from
StatusChanged. - Disposes server, clears status and client count.
Called on engine start, slot changes, and during the 30 Hz sync when AudioRumbleEnabled toggles:
- Scans all created slots for any with
AudioRumbleEnabled == true. - If any enabled and no detector: calls
StartAudioBassDetector(). - If none enabled and detector exists: calls
StopAudioBassDetector().
Creates AudioBassDetector, calls Start(). On success, assigns to _inputManager.AudioBassDetector. On failure, disposes.
Clears _inputManager.AudioBassDetector, disposes detector, clears all pad level meters to 0.
When _audioBassDetector != null, reads BassEnergy and pushes to padVm.AudioRumbleLevelMeter for each created slot with AudioRumbleEnabled.
Only acts if the master switch EnableInputHiding is on. Two mechanisms:
HidHide (driver-level hiding):
- Builds whitelist (PadForge exe + user-configured paths). Calls
SyncWhitelist()to add/remove only PadForge-managed entries. - For each
UserDevicewithHidHideEnabled:- Converts
DevicePathto HID instance ID. - Fallback for synthetic paths (e.g., "XInput#0"): looks up by VID/PID.
- Caches resolved instance IDs on the UserDevice for offline pre-emptive blacklisting.
- Converts
- Calls
HidHideController.SyncManagedDevices(desiredIds)-- atomic diff-based sync. - Activates cloaking if any devices are blacklisted.
Input hooks (keyboard/mouse consumption):
- For each device with
ConsumeInputEnabledand at least one slot assignment:- Parses "Button {index}" descriptors from PadSettings to collect VKey codes (keyboards) or mouse button IDs.
- If any inputs to suppress: creates/updates
InputHookManager. - If nothing to suppress: stops and disposes hook manager.
- Calls
HidHideController.RemoveManagedDevices()(best-effort). - Stops and disposes
InputHookManager.
Converts Windows paths to DOS device paths. Only adds/removes entries that PadForge manages -- entries added by HidHide Client or other tools are left untouched. Tracked via _managedWhitelistDosPaths HashSet.
Sets _inputManager.IsIdle based on whether any slot is created, enabled, and has at least one device assigned. Idle mode skips the expensive input/mapping/output pipeline and sleeps at ~20 Hz, reducing CPU to ~0%.
Captures the current runtime state:
- Flushes all PadViewModel values to PadSettings.
- Collects
ProfileEntry(InstanceGuid, ProductGuid, MapTo, checksum) and deduplicatedPadSettingclones. - Captures
SlotCreated[],SlotEnabled[],SlotControllerTypes[], VJoy/MIDI configs, DSU/Web server settings.
Restores a profile:
-
Topology: sets
SlotCreated[],SlotEnabled[], andOutputTypeper slot. Unassigns devices from slots being destroyed. -
Device assignments: resets all
MapTo = -1, then applies profile entries. Matches by InstanceGuid first, ProductGuid fallback, creates new UserSetting if needed. - VJoy/MIDI configs: restores preset and custom counts.
- Server settings: sets DSU and web controller enable/port.
-
Rebuilds UI:
UpdatePadDeviceInfo(), reloads PadSettings into ViewModels, refreshes Devices page.
Called by ForegroundMonitorService on the UI thread when the foreground process matches a different profile:
- Skips if same profile already active.
- Saves outgoing profile state via
SaveActiveProfileState(). - Applies target profile (or reverts to default snapshot if
profileIdis null).
Snapshots current state and stores it:
- If on default profile: updates
_defaultProfileSnapshotandSettingsManager.PendingDefaultSnapshot. - If on named profile: updates the profile's stored data in
SettingsManager.Profiles.
Refreshes the default profile snapshot from current runtime state. Called after saving when no profile is active.
Applies _defaultProfileSnapshot to revert to the pre-profile state.
Swaps two slots across all layers: engine arrays, SettingsManager, and ViewModel data. Refreshes UI after.
Moves a slot by performing adjacent bubble swaps through the active slots list.
Re-sorts slots so types are grouped: Xbox 360, then DS4, then vJoy, then KB+M, then MIDI. Uses bubble sort with adjacent SwapSlotData calls (data-only, no VC destruction). Returns true if reordering was performed.
Sets vibration state to 32768 on left/right motors. Optional device GUID filter. Schedules clearing after 500ms via a one-shot DispatcherTimer.
// Overload for selective motors
public void SendTestRumble(int padIndex, Guid? deviceGuid, bool left, bool right)| Method | Signature | Description |
|---|---|---|
Start |
void Start() |
Creates engine, starts all subsystems, begins UI timer |
Stop |
void Stop(bool preserveVJoyNodes = false) |
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() |
Removes all device hiding |
SendTestRumble |
void SendTestRumble(int padIndex, Guid? deviceGuid) |
Sends brief test rumble |
SendTestRumble |
void SendTestRumble(int padIndex, Guid? deviceGuid, bool left, bool right) |
Sends selective test rumble |
ApplyPadSettingToCurrentDevice |
void ApplyPadSettingToCurrentDevice(int padIndex, PadSetting source) |
Applies copied PadSetting |
ApplyPadSettingToCurrentDeviceTranslated |
void ApplyPadSettingToCurrentDeviceTranslated(int padIndex, PadSetting source, VirtualControllerType sourceType, bool sourceIsCustomVJoy, VirtualControllerType targetType, bool targetIsCustomVJoy) |
Applies with cross-layout translation |
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 |
EnsureTypeGroupOrder |
bool EnsureTypeGroupOrder(bool silent = false) |
Groups slots by type |
InputService does not expose any custom events. All UI updates flow through ViewModel property changes 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 |
File: PadForge.App/Services/SettingsService.cs
Responsible for loading and saving PadForge settings to XML files. Handles bidirectional sync between SettingsManager data collections and WPF ViewModels.
public SettingsService(MainViewModel mainVm)Stores reference to MainViewModel.
- Ensures
SettingsManager.UserDevicesandUserSettingscollections exist. - Calls
FindSettingsFile()to locate the settings file. - Loads from file if it exists, otherwise initializes with default profiles.
- Sets
SettingsFilePathon SettingsViewModel, clears dirty flag.
Settings file search order:
-
PadForge.xml(primary, preferred for new installs) -
Settings.xml(generic fallback) - If neither exists, uses
PadForge.xmlpath for new file creation.
All paths are relative to AppDomain.CurrentDomain.BaseDirectory.
- Deserializes
SettingsFileDatafrom XML. - Populates
SettingsManager.UserDevicesandUserSettingsunder their respectiveSyncRootlocks. - PadSetting linking: for each UserSetting, finds the PadSetting template by checksum and clones it. Cloning is critical -- without it, devices sharing a checksum would share the same PadSetting object.
- Purges orphaned UserSettings (
MapTo == -1). - Calls
LoadAppSettings(),LoadPadSettings(),LoadMacros(),LoadProfiles().
Pushes to SettingsViewModel: AutoStartEngine, MinimizeToTray, StartMinimized, StartAtLogin, EnablePollingOnFocusLoss, PollingRateMs, theme, language, input hiding, auto-profile switching.
Critical load order: SlotCreated[] and SlotEnabled[] must be loaded BEFORE OutputType, because setting OutputType fires PropertyChanged which reads SlotCreated.
Also loads: slot controller types, VJoy configs, MIDI configs, DSU/web server settings.
For each slot (first device per slot only), loads all tuning parameters into PadViewModel: dead zones, sensitivity curves, max ranges, center offsets, trigger settings, force feedback, audio rumble, vJoy custom stick/trigger configs, and mapping descriptors.
Rebuilds each PadViewModel's macro list from serialized MacroData[]. Groups by pad index.
Calls SaveToFile(_settingsFilePath).
- Calls
UpdatePadSettingsFromViewModels()to push all ViewModel values to PadSettings. - Flushes VJoy/MIDI/KBM mapping dictionaries to serializable arrays, recomputes all checksums.
- Updates active profile snapshot via
UpdateActiveProfileSnapshot(). - Collects: devices (under lock), user settings + deduplicated pad settings (under lock), app settings, macros, profiles.
- Serializes
SettingsFileDatato XML. - Clears dirty flag.
AppSettings special handling: 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.
- Sets
IsDirty = trueandHasUnsavedChanges = true. - Starts a 250ms debounce
DispatcherTimer. If already running, restarts it. - On timer tick: calls
Save()then raisesAutoSavedevent.
The 250ms debounce means rapid changes (e.g., dragging a slider) are batched into a single save.
Clears all SettingsManager collections, resets all PadViewModel properties to defaults, resets SettingsViewModel, clears profiles. Marks dirty.
Reloads settings from disk, discarding unsaved changes.
- Always 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 SlotCreated/SlotEnabled/OutputType/VJoy/MIDI configs and captures the default profile snapshot from XML (
PendingDefaultSnapshot).
Called during Save. If a named profile is active, updates its stored snapshot from current runtime state (entries, PadSettings, topology, server settings).
Counts Xbox/DS4/vJoy/MIDI/KBM slots and sets topology label (e.g., "2x Xbox, 1x DS4").
| 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 |
| 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 |
File: PadForge.App/Services/DeviceService.cs
Handles device management operations triggered by the UI: assigning/unassigning devices, hiding/showing devices, creating/deleting virtual controller slots. Bridges DevicesViewModel commands to SettingsManager and SettingsService.
public DeviceService(MainViewModel mainVm, SettingsService settingsService)Stores references to MainViewModel and SettingsService.
Subscribes to DevicesViewModel events:
-
AssignToSlotRequested->OnAssignToSlot -
ToggleSlotRequested->OnToggleSlot -
HideDeviceRequested->OnHideDevice -
RemoveDeviceRequested->OnRemoveDevice -
DeviceHidingChanged->OnDeviceHidingChanged
Unsubscribes from all DevicesViewModel events.
Assigns the selected device to a slot:
- Auto-creates the virtual controller slot if it doesn't exist.
- Calls
SettingsManager.AssignDeviceToSlot(). - Populates
ProductGuidfor fallback matching. - Creates default
PadSettingif none exists (usingCreateDefaultPadSettingwith the slot's output type). - Calls
AutoEnableHidingDefaults()for newly assigned devices. - Marks settings dirty, raises
DeviceAssignmentChangedandDeviceHidingStateChanged, raisesNavigateToSlotRequested.
Public version for cross-panel drag-and-drop. Same logic as OnAssignToSlot but takes a GUID directly.
Toggles a device's assignment to a specific slot (multi-slot support). If unassigning and the device has no remaining slots, auto-disables hiding.
Removes all slot assignments for a device.
Creates the next available slot:
- Sets
OutputTypeBEFORESlotCreated(ordering matters for sidebar rebuild). - For vJoy: resets to Xbox 360 preset to prevent stale configs from leaking.
- Returns slot index (0-15) or -1 if all slots taken.
- Clears
SlotCreated[slotIndex]. - Calls
padVm.ResetAllSettings()to prevent stale settings from leaking. - Removes all UserSettings mapped to this slot.
Sets SettingsManager.SlotEnabled[slotIndex].
Marks a device as hidden in both SettingsManager and ViewModel.
Removes a device and all associated settings entirely. The virtual controller slot persists as an empty slot.
Handles HidHide/ConsumeInput/ForceRawJoystickMode toggle changes from the UI. Writes state to UserDevice, marks dirty, raises DeviceHidingStateChanged.
Sets default hiding when a device is newly assigned:
- Gamepads: auto-enables HidHide (if driver available).
- Keyboards/mice: does NOT auto-enable (blocking the only keyboard/mouse locks out Windows).
| 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 = Xbox360) |
Creates next available slot |
DeleteSlot |
void DeleteSlot(int slotIndex) |
Deletes a slot and unassigns devices |
SetSlotEnabled |
void SetSlotEnabled(int slotIndex, bool enabled) |
Enables/disables a slot |
| 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 |
File: PadForge.App/Services/RecorderService.cs
Implements: IDisposable
Handles input recording for mapping assignment. When the user clicks "Record" on a mapping row, this service captures the current state as a baseline, polls at 30 Hz for changes, and writes the detected input descriptor to the MappingItem.
public RecorderService(MainViewModel mainVm)Stores reference to MainViewModel.
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
CustomInputStatefrom the target device. - Sets
mapping.IsRecording = true. - Starts 30 Hz
DispatcherTimeratDispatcherPriority.Input. -
neutralizeBaseline: when true, waits for all buttons/POVs to return to neutral before detecting (used for auto-prompt follow-up recordings). -
negRecording: when true, records the negative direction of a bidirectional axis.
Stops the timer, clears all recording state, sets IsRecording = false.
Buttons: instant detection -- any button that transitions from unpressed to pressed.
POV hats: instant detection -- any POV that transitions from centered (-1) to a direction. Direction is resolved to one of 8 cardinal/ordinal directions using 45-degree sectors.
Axes: hold confirmation required:
- Threshold: 16384 unsigned units (~25% of full 65535 range).
- An axis must exceed the threshold for 3 consecutive cycles before being accepted.
- The axis with the largest absolute delta wins.
- Mouse exception: mice produce instantaneous deltas that return to center, so they are accepted immediately (no hold confirmation).
Auto-inversion: after detection, ShouldAutoInvert() determines whether to apply the "I" prefix based on the target type and movement direction:
- Stick axes: inverts if the user pushed in the wrong direction for the target (pos vs neg).
- Trigger axes: inverts if the axis value decreased (reverse polarity).
- KBM axes: never auto-inverts (screen convention is already correct).
- Other targets: inverts if the user pushed in the negative direction.
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 |
| Method | Signature | Description |
|---|---|---|
StartRecording |
void StartRecording(MappingItem mapping, int padIndex, Guid deviceGuid, bool neutralizeBaseline = false, bool negRecording = false) |
Starts input recording |
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 |
| Event | Signature | Description |
|---|---|---|
RecordingCompleted |
event EventHandler<RecordingResult> RecordingCompleted |
Raised on successful detection |
RecordingTimedOut |
event EventHandler RecordingTimedOut |
Raised after 10 second timeout |
RecordingResult fields: Mapping, PadIndex, Descriptor, Type, Index, PovDirection.
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 -- it is called at 30 Hz from InputService's UI timer tick.
Called at 30 Hz by UiTimer_Tick. Flow:
- Bail if
SettingsManager.EnableAutoProfileSwitchingis false or no profiles exist. - Get foreground window handle via
GetForegroundWindow()(P/Invoke). - Get process ID via
GetWindowThreadProcessId(). - Get the process's
MainModule.FileName. - If the exe path is the same as last check, skip (deduplication via
_lastExePath). - Match against all profiles'
ExecutableNames(pipe-separated full paths, case-insensitive). - If matched profile changed from last match, fire
ProfileSwitchRequired.
Change detection: uses _lastExePath to avoid redundant lookups and _lastMatchedProfileId to only fire when the matched profile actually changes. Passing null to ProfileSwitchRequired signals reversion to the default profile.
| Method | Signature | Description |
|---|---|---|
CheckForegroundWindow |
void CheckForegroundWindow() |
Polls foreground window and fires event on profile change |
| Event | Signature | Description |
|---|---|---|
ProfileSwitchRequired |
event Action<string> ProfileSwitchRequired |
Fired with profile ID (or null for default) when foreground process matches a different profile |
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)
MainWindow.OnClosing
|-- InputService.Stop()
| |-- UI timer stops
| |-- Unsubscribe all events
| |-- StopDsuServer()
| |-- StopWebServer()
| |-- StopAudioBassDetector()
| |-- RemoveDeviceHiding()
| |-- InputManager.Stop() + Dispose()
| |-- Remove vJoy device nodes
|-- SettingsService.Save() [final save]
|-- DeviceService.UnwireEvents()
|-- RecorderService.Dispose()
InputManager.UpdateDevices() [polling thread, every 2s]
|-- SDL enumerates devices
|-- DevicesUpdated event fires
|-- Dispatcher.BeginInvoke: [UI thread]
|-- SyncDevicesList()
|-- UpdatePadDeviceInfo()
|-- ApplyDeviceHiding() [blacklist newly connected devices]
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
[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
| |-- Reset all MapTo = -1, apply profile entries
| |-- Apply VJoy/MIDI configs
| |-- Apply DSU/Web server settings
| |-- UpdatePadDeviceInfo()
| |-- Reload PadSettings into ViewModels
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