Skip to content

Services Layer

hifihedgehog edited this page Mar 19, 2026 · 42 revisions

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
Loading

Table of Contents


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)  |
+-------------------+

Key data flow patterns

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)

Threading Model

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

Thread-safety conventions

  • SettingsManager collections (UserDevices, UserSettings) have a SyncRoot object. 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 via Dispatcher.BeginInvoke.

InputService

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).

Constructor and Initialization

public InputService(MainViewModel mainVm)

Constructor actions:

  1. Stores reference to MainViewModel and captures Dispatcher.CurrentDispatcher.
  2. Subscribes to Strings.CultureChanged for language-change server status refresh.
  3. Subscribes to SelectedDeviceChanged and MappingsRebuilt events on every PadViewModel.
  4. Subscribes to DevicesViewModel.PropertyChanged for offline device detail display.

Fields initialized:

  • _previousSelectedDevice dictionary (tracks per-pad device GUID for save-before-switch).
  • Macro trigger recording state fields (all null/default until recording starts).

Start / Stop / Dispose

Start()

Full startup sequence:

  1. Cleanup stale ViGEm nodes -- InputManager.CleanupStaleVigemDevices() removes USB device nodes from previous sessions (crash recovery).
  2. Create InputManager -- Sets PollingIntervalMs from SettingsViewModel.PollingRateMs.
  3. Copy slot configuration -- Copies SlotControllerTypes[], VJoy configs, and MIDI configs from PadViewModels to the engine. Counts expected Xbox 360 / DS4 VCs and calls PreInitializeVigemCounts() so the ViGEm device filter catches virtual controllers on the first cycle.
  4. Subscribe to engine events -- DevicesUpdated, FrequencyUpdated, ErrorOccurred.
  5. Subscribe to ViewModel property changes -- SettingsViewModel.PropertyChanged, DashboardViewModel.PropertyChanged.
  6. Create ForegroundMonitorService -- Subscribes to ProfileSwitchRequired.
  7. Capture default profile snapshot -- Uses SettingsManager.PendingDefaultSnapshot (from previous session's XML) or creates a fresh snapshot via SnapshotCurrentProfile().
  8. Start engine -- _inputManager.Start() launches the polling thread.
  9. Start subsystems -- DSU server, web controller server, audio bass detector (each conditional on settings).
  10. Clear stale HidHide state -- Calls HidHideController.ClearAll() to remove entries from previous crash/kill.
  11. Apply device hiding -- Calls ApplyDeviceHiding() (HidHide blacklist + input hooks).
  12. Start UI timer -- 30 Hz DispatcherTimer at DispatcherPriority.Render, handler: UiTimer_Tick.
  13. Update state -- Sets IsEngineRunning = true, enters idle if no slots are created.

Stop(bool preserveVJoyNodes = false)

  1. Stops UI timer and unsubscribes from its Tick event.
  2. Unsubscribes from all ViewModel property change events.
  3. Unsubscribes from per-pad events (SelectedDeviceChanged, MappingsRebuilt).
  4. Disposes ForegroundMonitorService.
  5. Stops DSU server, web controller server, audio bass detector.
  6. Calls RemoveDeviceHiding() (HidHide blacklist cleanup + input hook teardown).
  7. Unsubscribes from engine events, calls _inputManager.Stop(preserveVJoyNodes) and _inputManager.Dispose().
  8. Updates MainViewModel state (sets engine status to "Stopped", marks all device rows offline).
  9. 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.

Dispose()

Calls Stop() in a try/catch (best-effort shutdown).

30Hz UI Timer Tick

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]

Pad ViewModel updates (per slot)

For each of the 16 slots:

  • Reads CombinedOutputStates[i] and VibrationStates[i] and calls padVm.UpdateFromEngineState().
  • For custom vJoy slots: reads CombinedVJoyRawStates[i] and calls padVm.UpdateFromVJoyRawState().
  • For MIDI slots: reads CombinedMidiRawStates[i] and calls padVm.UpdateFromMidiRawState().
  • For KBM slots: reads CombinedKbmRawStates[i] and sets padVm.KbmOutputSnapshot.
  • Per-device state for stick/trigger tab previews: reads either KBM pre-deadzone values (synthesized into a Gamepad struct) or the selected device's RawMappedState.

Visibility gating

Two boolean flags gate expensive per-frame work:

  • IsDevicesPageVisible -- set by MainWindow navigation. When true, calls UpdateDevicesRawState().
  • IsPadPageVisible -- set by MainWindow navigation. When true, calls UpdateMappingLiveValues().

Dashboard Updates

UpdateDashboard() (private, 30Hz)

Pushes engine statistics to DashboardViewModel:

  • Engine state key ("Running" / "Idle" / "Stopped") and localized status text.
  • PollingFrequency from _inputManager.CurrentFrequency.
  • 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:

  • Per-slot: IsActive, DeviceName, MappedDeviceCount, ConnectedDeviceCount, IsVirtualControllerConnected, IsInitializing, IsEnabled, StatusText.
  • Assigns per-type numbering (e.g., "Xbox 1", "DS4 2", "vJoy 1").

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 visual 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 axis values in-place (NormalizedValue = Axis[i] / 65535.0).
  4. Updates button states, keyboard layout keys, POV hat values.
  5. Updates mouse visual properties (MouseMotionX, MouseMotionY, MouseScrollIntensity).
  6. Updates gyro/accel values if device has those capabilities.

OnDevicesVmPropertyChanged() (private)

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.

Mapping Live Values

UpdateMappingLiveValues() (private, 30Hz, gated by IsPadPageVisible)

For the active Pad page:

  1. Finds the selected device for the current slot.
  2. For each MappingItem, parses the SourceDescriptor and reads the current raw value from CustomInputState.
  3. Sets mapping.CurrentValueText to the numeric value string.

ReadMappedValue(CustomInputState, string descriptor) (private, static)

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.

Settings Sync (ViewModel to PadSetting)

SyncViewModelToPadSettings() (private, 30Hz)

The primary runtime sync path. For each pad slot:

  1. Always synced (even with no device selected):

    • SlotControllerTypes[i] from padVm.OutputType
    • VJoy config via SyncVJoyConfigToSlot()
    • MIDI config via _inputManager._midiConfigs[i]
  2. 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
  3. Audio bass detector lifecycle: detects when AudioRumbleEnabled toggles on any slot and calls SyncAudioBassDetector().

SaveViewModelToPadSetting(PadViewModel, Guid, bool syncMappings) (private, static)

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.

LoadPadSettingToViewModel(PadViewModel, Guid) (private, static)

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.

Settings Forwarding (OnSettingsPropertyChanged)

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()

Dashboard Forwarding (OnDashboardPropertyChanged)

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

Engine Event Handlers

All fire on the polling thread and are marshalled to the UI thread.

OnDevicesUpdated

Marshals to UI thread via Dispatcher.BeginInvoke:

  1. SyncDevicesList() -- updates DevicesViewModel collection.
  2. UpdatePadDeviceInfo() -- refreshes PadViewModel device info.
  3. ApplyDeviceHiding() -- re-applies hiding for newly connected devices.

OnFrequencyUpdated

No-op (frequency is read on the next UI timer tick).

OnErrorOccurred

Marshals error message to _mainVm.StatusText.

Device List Sync

SyncDevicesList() (private)

Synchronizes DevicesViewModel.Devices with SettingsManager.UserDevices:

  1. Takes a snapshot under lock.
  2. Updates existing rows and adds new ones (skips virtual/shadow devices).
  3. Removes rows for devices no longer in the snapshot or that are virtual.
  4. Sorts alphabetically by name, then by VID:PID.
  5. Calls devVm.RefreshCounts().

IsVirtualOrShadowDevice(UserDevice) (private, static)

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"
  • IsHidden flag

PopulateDeviceRow(DeviceRowViewModel, UserDevice) (private)

Maps all UserDevice properties to the ViewModel row: name, VID/PID, online status, capabilities, device type key, slot assignments, HidHide state, instance path.

UpdatePadDeviceInfo() (public)

Rebuilds each PadViewModel's MappedDevices collection from UserSettings.FindByPadIndex(). Handles multi-device slots, auto-selects first device, refreshes sidebar and dashboard slot lists.

Per-Device Settings Swap

OnSelectedDeviceChanged(PadViewModel, MappedDeviceInfo)

When the user selects a different device in a pad slot's dropdown:

  1. Saves current ViewModel values to the PREVIOUSLY selected device's PadSetting (only when switching to a different device, not re-adding the same one).
  2. Loads the new device's PadSetting into the ViewModel via LoadPadSettingToViewModel().
  3. Populates available input dropdown choices via PopulateAvailableInputs().
  4. Updates the _previousSelectedDevice tracker.

OnMappingsRebuilt(PadViewModel)

When a pad's mappings are rebuilt (OutputType or vJoy preset change), reloads mapping descriptors from PadSetting without touching dead zone / force feedback settings.

Copy / Paste Settings

ApplyPadSettingToCurrentDevice(int padIndex, PadSetting source) (public)

Applies a source PadSetting to the currently selected device. Used by clipboard Paste and "Copy From".

ApplyPadSettingToCurrentDeviceTranslated(...) (public)

Applies with cross-layout translation (e.g., Xbox -> vJoy mapping key conversion).

FlushAllPadViewModels() (public)

Pushes all active PadViewModel state back to PadSettings. Call before reading PadSettings across multiple slots (e.g., Copy From dialog).

GetCurrentPadSetting(int padIndex) (public)

Returns the PadSetting for the currently selected device, after syncing ViewModel state.

Macro Snapshot Sync

SyncMacroSnapshots() (private, 30Hz)

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.

Macro Trigger Recording

StartMacroTriggerRecording(MacroItem macro, int padIndex) (public)

Starts recording button/axis/POV presses for a macro trigger combo. Captures axis baseline for delta detection.

StopMacroTriggerRecording() (public)

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.

UpdateMacroTriggerRecording() (private, 30Hz)

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 CombinedOutputStates Xbox button bitmask.

Axis detection uses baseline+delta+hold pattern (same as RecorderService): 25% threshold, 3-cycle hold confirmation.

DSU Server Lifecycle

StartDsuServerIfEnabled() (private)

  1. Checks Dashboard.EnableDsuMotionServer and engine existence.
  2. Creates DsuMotionServer, subscribes to StatusChanged.
  3. Validates port (1024-65535, default 26760).
  4. Calls _dsuServer.Start(port) -- if successful, assigns to _inputManager.DsuServer.
  5. On failure: disposes server.

StopDsuServer() (private)

  1. Clears _inputManager.DsuServer.
  2. Disposes server instance.

Web Controller Server Lifecycle

StartWebServerIfEnabled() (private)

  1. Checks Dashboard.EnableWebController and engine existence.
  2. Creates WebControllerServer, subscribes to StatusChanged, DeviceConnected, DeviceDisconnected.
  3. On device connect: calls _inputManager.RegisterExternalDevice().
  4. On device disconnect: calls _inputManager.UnregisterExternalDevice().
  5. Validates port (1024-65535, default 8080).
  6. Calls _webServer.Start(port).

StopWebServer() (private)

  1. Unsubscribes from StatusChanged.
  2. Disposes server, clears status and client count.

Audio Bass Detector Lifecycle

SyncAudioBassDetector() (internal)

Called on engine start, slot changes, and during the 30 Hz sync when AudioRumbleEnabled toggles:

  1. Scans all created slots for any with AudioRumbleEnabled == true.
  2. If any enabled and no detector: calls StartAudioBassDetector().
  3. If none enabled and detector exists: calls StopAudioBassDetector().

StartAudioBassDetector() (private)

Creates AudioBassDetector, calls Start(). On success, assigns to _inputManager.AudioBassDetector. On failure, disposes.

StopAudioBassDetector() (private)

Clears _inputManager.AudioBassDetector, disposes detector, clears all pad level meters to 0.

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 the master switch EnableInputHiding is on. Two mechanisms:

HidHide (driver-level hiding):

  1. Builds whitelist (PadForge exe + user-configured paths). Calls SyncWhitelist() to add/remove only PadForge-managed entries.
  2. For each UserDevice with HidHideEnabled:
    • Converts DevicePath to 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.
  3. Calls HidHideController.SyncManagedDevices(desiredIds) -- atomic diff-based sync.
  4. Activates cloaking if any devices are blacklisted.

Input hooks (keyboard/mouse consumption):

  1. For each device with ConsumeInputEnabled and at least one slot assignment:
    • Parses "Button {index}" descriptors from PadSettings to collect VKey codes (keyboards) or mouse button IDs.
  2. If any inputs to suppress: creates/updates InputHookManager.
  3. If nothing to suppress: stops and disposes hook manager.

RemoveDeviceHiding() (public)

  1. Calls HidHideController.RemoveManagedDevices() (best-effort).
  2. Stops and disposes InputHookManager.

SyncWhitelist(HashSet<string> desiredWinPaths) (private)

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.

Auto-Idle

UpdateIdleState() (private, 30Hz)

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%.

Profile Switching

SnapshotCurrentProfile() (public) -> ProfileData

Captures the 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[], VJoy/MIDI configs, DSU/Web server settings.

ApplyProfile(ProfileData profile) (public)

Restores a profile:

  1. Topology: sets SlotCreated[], SlotEnabled[], and OutputType per slot. Unassigns devices from slots being destroyed.
  2. Device assignments: resets all MapTo = -1, then applies profile entries. Matches by InstanceGuid first, ProductGuid fallback, creates new UserSetting if needed.
  3. VJoy/MIDI configs: restores preset and custom counts.
  4. Server settings: sets DSU and web controller enable/port.
  5. Rebuilds UI: UpdatePadDeviceInfo(), reloads PadSettings into ViewModels, refreshes Devices page.

OnProfileSwitchRequired(string profileId) (private)

Called by ForegroundMonitorService on the UI thread when the foreground process matches a different profile:

  1. Skips if same profile already active.
  2. Saves outgoing profile state via SaveActiveProfileState().
  3. Applies target profile (or reverts to default snapshot if profileId is null).

SaveActiveProfileState() (public)

Snapshots current state and stores it:

  • If on default profile: updates _defaultProfileSnapshot and SettingsManager.PendingDefaultSnapshot.
  • If on named profile: updates the profile's stored data in SettingsManager.Profiles.

RefreshDefaultSnapshot() (public)

Refreshes the default profile snapshot from current runtime state. Called after saving when no profile is active.

ApplyDefaultProfile() (public)

Applies _defaultProfileSnapshot to revert to the pre-profile state.

Slot Reordering

SwapSlots(int padIndexA, int padIndexB) (public)

Swaps two slots across all layers: engine arrays, SettingsManager, and ViewModel data. Refreshes UI after.

MoveSlot(int sourcePadIndex, int targetVisualPosition) (public)

Moves a slot by performing adjacent bubble swaps through the active slots list.

EnsureTypeGroupOrder(bool silent = false) (public) -> bool

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.

Test Rumble

SendTestRumble(int padIndex, Guid? deviceGuid) (public)

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)

InputService All Public Methods

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 All Events

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

SettingsService

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.

SettingsService Constructor and Initialization

public SettingsService(MainViewModel mainVm)

Stores reference to MainViewModel.

Initialize()

  1. Ensures SettingsManager.UserDevices and UserSettings collections exist.
  2. Calls FindSettingsFile() to locate the settings file.
  3. Loads from file if it exists, otherwise initializes with default profiles.
  4. Sets SettingsFilePath on SettingsViewModel, clears dirty flag.

File Discovery

Settings file search order:

  1. PadForge.xml (primary, preferred for new installs)
  2. Settings.xml (generic fallback)
  3. If neither exists, uses PadForge.xml path for new file creation.

All paths are relative to AppDomain.CurrentDomain.BaseDirectory.

Load

LoadFromFile(string filePath) (public)

  1. Deserializes SettingsFileData from XML.
  2. Populates SettingsManager.UserDevices and UserSettings under their respective SyncRoot locks.
  3. 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.
  4. Purges orphaned UserSettings (MapTo == -1).
  5. Calls LoadAppSettings(), LoadPadSettings(), LoadMacros(), LoadProfiles().

LoadAppSettings(AppSettingsData) (private)

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.

LoadPadSettings(UserSetting[], PadSetting[]) (private)

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.

LoadMacros(MacroData[]) (private)

Rebuilds each PadViewModel's macro list from serialized MacroData[]. Groups by pad index.

Save

Save() (public)

Calls SaveToFile(_settingsFilePath).

SaveToFile(string filePath) (public)

  1. Calls UpdatePadSettingsFromViewModels() to push all ViewModel values to PadSettings.
  2. Flushes VJoy/MIDI/KBM mapping dictionaries to serializable arrays, recomputes all checksums.
  3. Updates active profile snapshot via UpdateActiveProfileSnapshot().
  4. Collects: devices (under lock), user settings + deduplicated pad settings (under lock), app settings, macros, profiles.
  5. Serializes SettingsFileData to XML.
  6. 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.

MarkDirty and Autosave

MarkDirty() (public)

  1. Sets IsDirty = true and HasUnsavedChanges = true.
  2. Starts a 250ms debounce DispatcherTimer. If already running, restarts it.
  3. On timer tick: calls Save() then raises AutoSaved event.

The 250ms debounce means rapid changes (e.g., dragging a slider) are batched into a single save.

Reset and Reload

ResetToDefaults() (public)

Clears all SettingsManager collections, resets all PadViewModel properties to defaults, resets SettingsViewModel, clears profiles. Marks dirty.

Reload() (public)

Reloads settings from disk, discarding unsaved changes.

Profile Loading

LoadProfiles(ProfileData[], AppSettingsData) (private)

  1. Always adds the built-in Default profile at the top.
  2. Adds each saved profile with topology counts.
  3. 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).

UpdateActiveProfileSnapshot() (private)

Called during Save. If a named profile is active, updates its stored snapshot from current runtime state (entries, PadSettings, topology, server settings).

UpdateTopologyCounts(ProfileListItem, bool[], int[]) (internal, static)

Counts Xbox/DS4/vJoy/MIDI/KBM slots and sets topology label (e.g., "2x Xbox, 1x DS4").

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

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 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.

DeviceService Constructor and Initialization

public DeviceService(MainViewModel mainVm, SettingsService settingsService)

Stores references to MainViewModel and SettingsService.

WireEvents() (public)

Subscribes to DevicesViewModel events:

  • 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 virtual controller slot if it doesn't exist.
  2. Calls SettingsManager.AssignDeviceToSlot().
  3. Populates ProductGuid for fallback matching.
  4. Creates default PadSetting if none exists (using CreateDefaultPadSetting with the slot's output type).
  5. Calls AutoEnableHidingDefaults() for newly assigned devices.
  6. Marks settings dirty, raises DeviceAssignmentChanged and DeviceHidingStateChanged, raises NavigateToSlotRequested.

AssignDeviceToSlot(Guid instanceGuid, int slotIndex) (public)

Public version for cross-panel drag-and-drop. Same logic as OnAssignToSlot but takes a GUID directly.

OnToggleSlot(int slotIndex) (private)

Toggles a device's assignment to a specific slot (multi-slot support). If unassigning and the device has no remaining slots, auto-disables hiding.

UnassignDevice(Guid instanceGuid) (public)

Removes all slot assignments for a device.

Slot Management

CreateSlot(VirtualControllerType type = Xbox360) (public) -> int

Creates the next available slot:

  1. Sets OutputType BEFORE SlotCreated (ordering matters for sidebar rebuild).
  2. For vJoy: resets to Xbox 360 preset to prevent stale configs from leaking.
  3. Returns slot index (0-15) or -1 if all slots taken.

DeleteSlot(int slotIndex) (public)

  1. Clears SlotCreated[slotIndex].
  2. Calls padVm.ResetAllSettings() to prevent stale settings from leaking.
  3. Removes all UserSettings mapped to this slot.

SetSlotEnabled(int slotIndex, bool enabled) (public)

Sets SettingsManager.SlotEnabled[slotIndex].

Device Hiding Toggle

OnHideDevice(Guid instanceGuid) (private)

Marks a device as hidden in both SettingsManager and ViewModel.

OnRemoveDevice(Guid instanceGuid) (private)

Removes a device and all associated settings entirely. The virtual controller slot persists as an empty slot.

OnDeviceHidingChanged(Guid instanceGuid) (private)

Handles HidHide/ConsumeInput/ForceRawJoystickMode toggle changes from the UI. Writes state to UserDevice, marks dirty, raises DeviceHidingStateChanged.

AutoEnableHidingDefaults(UserDevice, DeviceRowViewModel) (private)

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).

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 = 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

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" 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.

RecorderService Constructor

public RecorderService(MainViewModel mainVm)

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 from the target device.
  • Sets mapping.IsRecording = true.
  • Starts 30 Hz DispatcherTimer at DispatcherPriority.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.

CancelRecording() (public)

Stops the timer, clears all recording state, sets IsRecording = false.

Detection Algorithm

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

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
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, PadIndex, Descriptor, Type, Index, PovDirection.


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 -- it is called at 30 Hz from InputService's UI timer tick.

How It Works

CheckForegroundWindow() (public)

Called at 30 Hz by UiTimer_Tick. Flow:

  1. Bail if SettingsManager.EnableAutoProfileSwitching is false or no profiles exist.
  2. Get foreground window handle via GetForegroundWindow() (P/Invoke).
  3. Get process ID via GetWindowThreadProcessId().
  4. Get the process's MainModule.FileName.
  5. If the exe path is the same as last check, skip (deduplication via _lastExePath).
  6. Match against all profiles' ExecutableNames (pipe-separated full paths, case-insensitive).
  7. 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.

ForegroundMonitorService All Public Methods

Method Signature Description
CheckForegroundWindow void CheckForegroundWindow() Polls foreground window and fires event on profile change

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

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()
  |     |-- Remove vJoy device nodes
  |-- 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
        |     |-- Reset all MapTo = -1, apply profile entries
        |     |-- Apply VJoy/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

Clone this wiki locally