Skip to content

Services Layer

hifihedgehog edited this page Mar 8, 2026 · 42 revisions

Services Layer

This page documents the seven 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.


Table of Contents


Architecture Overview

+-------------------+     30Hz Timer      +-------------------+
|   InputManager    | ==================> |   InputService    |
|  (background,     |   reads engine      |  (UI thread,      |
|   ~1000Hz)        |   state arrays      |   pushes to VMs)  |
+-------------------+                     +-------------------+
                                                  |
                          +-----------+-----------+-----------+
                          |           |           |           |
                    SettingsService  DeviceService  RecorderService
                          |
                    DsuMotionServer
                    ForegroundMonitorService
                    WebControllerServer

Thread model: InputManager runs on a dedicated background thread at ~1000Hz. All seven services run their primary logic on the WPF dispatcher thread. Engine events (DevicesUpdated, FrequencyUpdated, ErrorOccurred) are marshalled to the UI thread via Dispatcher.BeginInvoke. The DSU motion server has its own receive thread but is called for broadcast from the engine's polling thread. The WebControllerServer has its own accept thread and per-client WebSocket tasks.


InputService

File: PadForge.App/Services/InputService.cs Namespace: PadForge.Services Implements: IDisposable

The central bridge between the background InputManager engine and WPF ViewModels. Owns the InputManager instance, runs a 30Hz DispatcherTimer, and pushes engine state to ViewModels on every tick.

Constants

Constant Value Description
UiTimerIntervalMs 33 UI update interval (~30 frames per second)

Fields

Field Type Description
_mainVm MainViewModel Root ViewModel reference
_dispatcher Dispatcher WPF dispatcher for thread marshalling
_inputManager InputManager The background engine instance (nullable)
_uiTimer DispatcherTimer 30Hz UI timer
_foregroundMonitor ForegroundMonitorService Auto-profile switching monitor
_defaultProfileSnapshot ProfileData Snapshot of state before any profile switch
_dsuServer DsuMotionServer DSU/Cemuhook motion server (nullable)
_disposed bool Disposal guard
_preservedVJoyNodes bool Whether Stop() preserved vJoy nodes for restart
_hookManager InputHookManager Low-level keyboard/mouse input hook manager (nullable)
_recordingMacro MacroItem Active macro trigger recording target
_recordingPadIndex int Pad slot for active macro recording
_recordedButtons ushort Accumulated Xbox button bitmask during recording
_recordedCustomButtons uint[] Accumulated custom vJoy button words
_recordingDeviceGuid Guid Locked device GUID during raw button recording
_recordedRawButtons HashSet<int> Raw button indices detected
_previousSelectedDevice Dictionary<int, Guid> Tracks previously selected device per pad slot

Properties

Property Type Description
IsDevicesPageVisible bool Set by MainWindow; enables raw state sync to DevicesViewModel
IsPadPageVisible bool Set by MainWindow; enables mapping row live value updates
Engine InputManager Exposes the underlying engine for advanced ops (test rumble)

Constructor

public InputService(MainViewModel mainVm)

Stores the MainViewModel reference and captures Dispatcher.CurrentDispatcher. Subscribes to SelectedDeviceChanged and MappingsRebuilt events on each PadViewModel, and to PropertyChanged on DevicesViewModel for offline device detail display.

Lifecycle Methods

Start

public void Start()

Creates and starts the InputManager and UI timer. Full startup sequence:

  1. InputManager.CleanupStaleVigemDevices() — removes leftover ViGEm USB device nodes
  2. VJoyVirtualController.RemoveAllDeviceNodes() — removes leftover vJoy nodes (skipped if _preservedVJoyNodes is true)
  3. Creates InputManager with configured polling interval
  4. Copies controller types and vJoy configs to engine arrays
  5. Calls PreInitializeVigemCounts() so the device filter catches ViGEm VCs on first UpdateDevices cycle
  6. Subscribes to engine events: DevicesUpdated, FrequencyUpdated, ErrorOccurred
  7. Subscribes to Settings.PropertyChanged and Dashboard.PropertyChanged
  8. Creates ForegroundMonitorService, subscribes to ProfileSwitchRequired
  9. Captures default profile snapshot via SnapshotCurrentProfile()
  10. Calls _inputManager.Start() to begin the background polling thread
  11. Starts DSU server if enabled
  12. Calls ApplyDeviceHiding() to apply HidHide blacklist and low-level hooks
  13. Creates and starts the 30Hz DispatcherTimer

Stop

public void Stop(bool preserveVJoyNodes = false)

Shuts down everything in reverse order:

  1. Stops UI timer
  2. Unsubscribes from settings/dashboard property changes
  3. Disposes foreground monitor
  4. Calls RemoveDeviceHiding() to clean up HidHide blacklist and hooks
  5. Stops DSU server
  6. Stops and disposes InputManager (passes preserveVJoyNodes)
  7. Resets all dashboard/status ViewModel properties
  8. Marks all device rows offline
  9. If !preserveVJoyNodes, removes vJoy device nodes (with 3-second timeout)

The preserveVJoyNodes parameter is used during engine restart (Stop+Start) to avoid unnecessary device node teardown/recreation — the vJoy DLL's internal handles remain valid and EnsureDevicesAvailable re-enables the node.

UI Timer Tick

private void UiTimer_Tick(object sender, EventArgs e)

Called ~30 times per second on the UI thread. Performs all of the following in sequence:

  1. Update Pad ViewModels — reads CombinedOutputStates[i] and VibrationStates[i] from engine, calls padVm.UpdateFromEngineState(). For custom vJoy slots, also pushes CombinedVJoyRawStates[i]. Updates per-device state for stick/trigger tab previews.
  2. UpdateDashboard() — pushes engine statistics to DashboardViewModel
  3. UpdateDevicesRawState() — only if IsDevicesPageVisible; updates axis/button/POV/gyro/accel display
  4. UpdateMappingLiveValues() — only if IsPadPageVisible; updates live value text on mapping rows
  5. UpdateMacroTriggerRecording() — accumulates buttons during macro trigger recording
  6. SyncViewModelToPadSettings() — pushes ViewModel slider values to PadSetting objects at 30Hz
  7. SyncMacroSnapshots() — pushes macro lists to engine's MacroSnapshots[] array
  8. CheckForegroundWindow() — auto-profile switching (via foreground monitor)

Dashboard Updates

private void UpdateDashboard()

Snapshots SettingsManager.UserDevices under lock, computes total/online/mapped device counts, pushes to DashboardViewModel. Calls RefreshSlotSummaryProperties() and RefreshNavItemConnectedCounts().

public void RefreshSlotSummaryProperties(IEnumerable<UserDevice> devices = null)

Updates all SlotSummary properties on the dashboard: output type, label, status, device info, connected counts. Computes per-type instance numbering (Xbox #1, DS4 #1, vJoy #1, etc.).

Devices Page Raw State

private void UpdateDevicesRawState()

Updates the raw input state display for the selected device. Reads UserDevice.InputState and pushes axis values (Axis[i] / 65535.0 normalized), button pressed states, POV centidegrees, and gyro/accel float values to the DevicesViewModel's observable collections. Rebuilds collections when the selected device changes.

Mapping Live Values

private void UpdateMappingLiveValues()

For the active pad page, reads the selected device's CustomInputState and calls ReadMappedValue() to parse each mapping's descriptor string ("Button N", "Axis N", "Slider N", "POV N") into the current integer value.

private static int ReadMappedValue(CustomInputState state, string descriptor)

Simplified Step 3 parser for display. Strips I/H prefixes, splits on space, parses type name and index, returns the raw value from the state arrays.

Runtime Sync: ViewModel to PadSetting

private void SyncViewModelToPadSettings()

Called at 30Hz. For each pad:

  • Syncs OutputType and vJoy config to engine arrays (always, even without a selected device)
  • For the selected device, calls SaveViewModelToPadSetting() which writes dead zones, anti-dead zones, linear, trigger settings, force feedback, and mapping descriptors to the PadSetting object

String reference writes are atomic in .NET, so the engine sees consistent values without locking.

private void SyncVJoyConfigToSlot(int slotIndex, PadViewModel padVm)

Copies the PadViewModel's VJoyConfig (axes, buttons, POVs, sticks, triggers) to _inputManager.SlotVJoyConfigs[slotIndex] and sets SlotVJoyIsCustom[slotIndex] based on whether the preset is Custom.

Per-Device Settings Swap

private void OnSelectedDeviceChanged(object sender, PadViewModel.MappedDeviceInfo newDevice)

Triggered when the user selects a different device in a pad slot's dropdown. Saves the ViewModel state to the previously selected device's PadSetting, then loads the new device's PadSetting into the ViewModel. Tracks the previous device per slot via _previousSelectedDevice dictionary.

private void OnMappingsRebuilt(object sender, EventArgs e)

Triggered when a pad's mappings are rebuilt (OutputType or vJoy preset changed). Reloads mapping descriptors from PadSetting without touching dead zone or force feedback settings.

Copy / Paste Settings

public void ApplyPadSettingToCurrentDevice(int padIndex, PadSetting source)

Copies all settings from a source PadSetting to the currently selected device's PadSetting in the given slot, then reloads the ViewModel.

public PadSetting GetCurrentPadSetting(int padIndex)

Returns the PadSetting for the currently selected device, after syncing ViewModel values to capture unsaved slider changes.

Macro Snapshot Sync

private void SyncMacroSnapshots()

Called at 30Hz. For each pad, copies the MacroItem list to _inputManager.MacroSnapshots[i] as an array snapshot. MacroItem objects are shared references — runtime state is read/written by the engine thread directly.

Engine Event Handlers

private void OnDevicesUpdated(object sender, EventArgs e)

Marshals to UI thread via Dispatcher.BeginInvoke. Calls SyncDevicesList() and UpdatePadDeviceInfo().

private void OnFrequencyUpdated(object sender, EventArgs e)

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

private void OnErrorOccurred(object sender, InputExceptionEventArgs e)

Marshals to UI thread, sets _mainVm.StatusText.

private void OnSettingsPropertyChanged(object sender, PropertyChangedEventArgs e)

Propagates PollingRateMs changes to _inputManager.PollingIntervalMs. Also handles EnableInputHiding — calls ApplyDeviceHiding() when enabled, RemoveDeviceHiding() when disabled.

private void OnDashboardPropertyChanged(object sender, PropertyChangedEventArgs e)

Handles EnableDsuMotionServer and DsuMotionServerPort changes — starts/stops/restarts the DSU server.

DSU Server Lifecycle

private void StartDsuServerIfEnabled()
private void StopDsuServer()

Creates DsuMotionServer, subscribes to StatusChanged event (marshalled to UI thread), starts on configured port. Sets _inputManager.DsuServer so the engine can call BroadcastMotion() from the polling thread.

Device List Sync

private void SyncDevicesList()

Synchronizes DevicesViewModel.Devices with SettingsManager.UserDevices. Snapshots devices under lock, creates/updates DeviceRowViewModel instances, removes rows for disconnected virtual/shadow devices, sorts alphabetically by name then by VID:PID.

private static bool IsVirtualOrShadowDevice(UserDevice ud)

Returns true for online devices whose name contains "ViGEm" or "Virtual Gamepad", whose path contains "vigem" or "virtual", or that have IsHidden set.

public void UpdatePadDeviceInfo()

Rebuilds MappedDevices collections on all PadViewModels. For each slot, finds all UserSettings mapped to it, resolves device names and online status, auto-selects first device if nothing selected. Also refreshes sidebar nav items, dashboard active slots, and profile topology labels.

public void RefreshDeviceList()

Public entry point for the Refresh button. Calls SyncDevicesList() + UpdatePadDeviceInfo().

Test Rumble

public void SendTestRumble(int padIndex, Guid? deviceGuid)
public void SendTestRumble(int padIndex, Guid? deviceGuid, bool left, bool right)

Sets VibrationStates[padIndex].LeftMotorSpeed = 32768 and/or RightMotorSpeed = 32768, optionally filtering by device GUID via TestRumbleTargetGuid[padIndex]. Schedules clearing after 500ms via a one-shot DispatcherTimer.

Macro Trigger Recording

public void StartMacroTriggerRecording(MacroItem macro, int padIndex)

Begins recording button presses for a macro trigger combo. Sets macro.IsRecordingTrigger = true. Each UI tick, UpdateMacroTriggerRecording() accumulates button flags from one of three paths:

  1. InputDevice path: Scans raw buttons from devices mapped to the slot. First device to press a button "locks in" via _recordingDeviceGuid. Stores raw button indices.
  2. Custom vJoy path (ButtonStyle == Numbered): Accumulates from CombinedVJoyRawStates[padIndex].Buttons.
  3. Xbox bitmask path: OR-accumulates from CombinedOutputStates[padIndex].Buttons.
public void StopMacroTriggerRecording()

Finalizes the recording. Based on which path was active, writes TriggerDeviceGuid + TriggerRawButtons, TriggerCustomButtonWords, or TriggerButtons to the MacroItem.

Profile Switching

public ProfileData SnapshotCurrentProfile()

Captures the current runtime state (all UserSettings, their PadSettings, slot created/enabled/types, DSU settings) into a ProfileData object. Pushes ViewModel values to PadSettings first to capture unsaved slider changes.

public void ApplyProfile(ProfileData profile)

Loads a profile's state into the runtime:

  1. Apply topology: Sets SlotCreated, SlotEnabled, OutputType per slot. Unassigns devices from slots being destroyed.
  2. Reset device assignments: Sets all UserSetting.MapTo = -1.
  3. Apply profile entries: For each ProfileEntry, finds an unassigned UserSetting by InstanceGuid (or ProductGuid fallback), clones the PadSetting, sets MapTo.
  4. Apply DSU settings: Updates EnableDsuMotionServer and port.
  5. Rebuild UI: Calls UpdatePadDeviceInfo(), reloads PadSettings into ViewModels, refreshes device list.
private void OnProfileSwitchRequired(string profileId)

Called by ForegroundMonitorService on the UI thread. Saves the outgoing profile state, then applies the target profile (or reverts to _defaultProfileSnapshot if profileId is null).

public void SaveActiveProfileState()

Saves the current runtime state into the active profile (or _defaultProfileSnapshot if no named profile is active).

public void RefreshDefaultSnapshot()
public void ApplyDefaultProfile()
public void RefreshProfileTopology()

Utility methods for profile management.

Device Hiding Lifecycle

public void ApplyDeviceHiding()

Called on engine start and whenever device hiding state changes. Manages two independent hiding mechanisms:

  1. HidHide (driver-level): For devices with HidHideEnabled = true, calls HidHideController.RemoveManagedDevices() to clear PadForge's previous blacklist entries, then adds current devices to the blacklist via AddToBlacklist(). Also calls EnsureWhitelisted() with PadForge's own exe path and SetActive(true) to enable cloaking.

  2. Low-level hooks (application-level): For devices with ConsumeInputEnabled = true, calls CollectSuppressedInputs() to parse mapping descriptors into VKey and mouse button suppression sets, then starts or updates the InputHookManager.

Both mechanisms respect the global EnableInputHiding master switch from Settings.

public void RemoveDeviceHiding()

Called on engine stop. Removes all PadForge-managed entries from the HidHide blacklist (leaving entries from other tools untouched), stops and disposes the InputHookManager.

private (HashSet<int> vkeys, HashSet<int> mouseButtons) CollectSuppressedInputs()

Iterates all UserDevices with ConsumeInputEnabled = true that are assigned to a slot. For each device, calls PadSetting.GetAllMappingDescriptors() to get active mapping strings, then parses descriptors:

  • Keyboard: "Button {vkCode}" -> adds vkCode to the VKey suppression set
  • Mouse: "Button 0" -> Left (0), "Button 1" -> Right (1), "Button 2" -> Middle (2), etc.

Slot Reordering

public void SwapSlots(int padIndexA, int padIndexB)

Swaps two slots across all layers: engine arrays (_inputManager.SwapSlots), SettingsManager.SwapSlots, and ViewModel OutputType. Refreshes UI afterward.

public void MoveSlot(int sourcePadIndex, int targetVisualPosition)

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

public bool EnsureTypeGroupOrder(bool silent = false)

Re-sorts created slots so types are grouped: Xbox 360 first, then DS4, then vJoy. Uses adjacent SwapSlots calls. The silent parameter skips UI refresh (used during startup). Returns true if any reordering was performed.

Dispose

public void Dispose()

Calls Stop(), unsubscribes from PadViewModel events and DevicesViewModel PropertyChanged.


SettingsService

File: PadForge.App/Services/SettingsService.cs Namespace: PadForge.Services

Handles XML persistence for all PadForge settings. Manages bidirectional sync between SettingsManager data collections and WPF ViewModels.

Constants

Constant Value Description
PrimaryFileName "PadForge.xml" Primary settings file name
FallbackFileName "Settings.xml" Legacy fallback file name

Fields

Field Type Description
_mainVm MainViewModel Root ViewModel reference
_settingsFilePath string Full path to the active settings file
_autoSaveTimer DispatcherTimer 250ms debounce timer for autosave

Properties

Property Type Description
SettingsFilePath string Full path to the active settings file
IsDirty bool Whether settings have been modified since last save

Events

Event Type Description
AutoSaved EventHandler Raised after autosave completes

Constructor

public SettingsService(MainViewModel mainVm)

Initialize

public void Initialize()

Ensures SettingsManager.UserDevices and UserSettings collections exist. Calls FindSettingsFile() to locate the settings file, then LoadFromFile() if it exists. Pushes file path to ViewModel.

File Discovery

private static string FindSettingsFile()

Search order:

  1. PadForge.xml in the application directory (preferred)
  2. Settings.xml in the application directory (legacy fallback)
  3. Falls back to PadForge.xml path for new installs

Load

public void LoadFromFile(string filePath)

Deserializes SettingsFileData from the XML file, then:

  1. Populate devices: Locks UserDevices.SyncRoot, clears and repopulates from data.Devices
  2. Populate user settings: Locks UserSettings.SyncRoot, clears and repopulates. For each UserSetting, finds the matching PadSetting by checksum and clones it (not shared reference — each device gets its own independent PadSetting instance)
  3. Purge orphans: Removes UserSettings with MapTo == -1 (left by older versions)
  4. Load app settings: LoadAppSettings(data.AppSettings)
  5. Load pad settings: LoadPadSettings(data.Settings, data.PadSettings)
  6. Load macros: LoadMacros(data.Macros)
  7. Load profiles: LoadProfiles(data.Profiles, data.AppSettings)

LoadAppSettings

private void LoadAppSettings(AppSettingsData appSettings)

Pushes application-level settings to SettingsViewModel: AutoStartEngine, MinimizeToTray, StartMinimized, StartAtLogin, EnablePollingOnFocusLoss, PollingRateMs, SelectedThemeIndex, EnableAutoProfileSwitching.

Critical load order: SlotCreated arrays MUST be loaded BEFORE OutputType because setting OutputType fires PropertyChanged which triggers RefreshNavControllerItems() which reads SlotCreated[]. Loading out of order causes a double-rebuild crash.

After slot created/enabled arrays, loads:

  • Per-slot virtual controller types (only for created slots — uncreated slots keep Xbox360 default)
  • Per-slot vJoy configurations (preset first, then Custom overrides)
  • DSU motion server settings
  • Use2DControllerView

LoadPadSettings

private void LoadPadSettings(UserSetting[] settings, PadSetting[] padSettings)

Pushes per-pad settings to PadViewModels. Only loads the first device encountered per slot — the user switches devices via the dropdown. Loads dead zones (independent X/Y), anti-dead zones, linear response, trigger settings, force feedback settings, then mapping descriptors.

LoadMacros

private void LoadMacros(MacroData[] macros)

Clears all pad macros, then reconstructs MacroItem objects from serialized MacroData: name, trigger buttons, actions, trigger source/mode, repeat settings, custom buttons. Sets ButtonStyle and CustomButtonCount based on the pad's output type.

LoadProfiles

private void LoadProfiles(ProfileData[] profiles, AppSettingsData appSettings)

Clears SettingsManager.Profiles and the ViewModel profile list. Always inserts the built-in "Default" profile at the top. For each saved profile, creates a ProfileListItem with topology counts (Nx Xbox, Nx DS4, Nx vJoy).

Save

public void Save()
public void SaveToFile(string filePath)

Full save sequence:

  1. UpdatePadSettingsFromViewModels() — pushes all ViewModel values to PadSetting objects
  2. FlushVJoyMappings() + UpdateChecksum() on all PadSettings, sync checksums to UserSettings
  3. UpdateActiveProfileSnapshot() — persists edits made while a profile was active
  4. Collect devices under lock
  5. Collect user settings and deduplicated pad settings (by checksum)
  6. BuildAppSettings() — builds AppSettingsData from SettingsViewModel
  7. BuildMacroData() — collects macros from all pad ViewModels
  8. Collect profiles
  9. Serialize via XmlSerializer to file

BuildAppSettings

private AppSettingsData BuildAppSettings()

Reads from SettingsViewModel and constructs AppSettingsData with all application settings, per-slot controller types, slot created/enabled arrays, vJoy configs, and DSU settings.

BuildMacroData

private MacroData[] BuildMacroData()

Iterates all pads, serializes each MacroItem to MacroData with all trigger properties, actions (button flags, custom buttons, key codes, key strings, durations, axis values).

UpdatePadSettingsFromViewModels

private void UpdatePadSettingsFromViewModels()

For each pad, writes all ViewModel properties (force feedback, dead zones, mapping descriptors) to the selected device's PadSetting. Uses reflection for standard PadSetting properties and SetPadSettingProperty() for vJoy dictionary-based mappings.

UpdateActiveProfileSnapshot

private void UpdateActiveProfileSnapshot()

If a named profile is active, updates its stored Entries, PadSettings, slot topology, and DSU settings from the current runtime state.

MarkDirty

public void MarkDirty()

Sets IsDirty = true and HasUnsavedChanges = true on the ViewModel. Starts or restarts a 250ms debounce DispatcherTimer. When the timer fires, calls Save() and raises AutoSaved.

Reset and Reload

public void ResetToDefaults()

Clears all device records and user settings. Resets all ViewModel properties to defaults (dead zones to 0, force feedback to 100, etc.). Resets profiles to just "Default".

public void Reload()

Reloads settings from disk via LoadFromFile(), discarding unsaved changes.

PadSetting Reflection Helpers

private static string GetPadSettingProperty(PadSetting ps, string propertyName)
private static void SetPadSettingProperty(PadSetting ps, string propertyName, string value)

Gets/sets a string property on PadSetting by name. For keys starting with "VJoy", delegates to the dictionary-based ps.GetVJoyMapping() / ps.SetVJoyMapping() system.

Serialization Data Classes

All serialization DTOs are defined at the bottom of SettingsService.cs:

SettingsFileData

[XmlRoot("PadForgeSettings")]
public class SettingsFileData
{
    [XmlArray("Devices")] [XmlArrayItem("Device")]
    public UserDevice[] Devices { get; set; }

    [XmlArray("UserSettings")] [XmlArrayItem("Setting")]
    public UserSetting[] Settings { get; set; }

    [XmlArray("PadSettings")] [XmlArrayItem("PadSetting")]
    public PadSetting[] PadSettings { get; set; }

    [XmlElement("AppSettings")]
    public AppSettingsData AppSettings { get; set; }

    [XmlArray("Macros")] [XmlArrayItem("Macro")]
    public MacroData[] Macros { get; set; }

    [XmlArray("Profiles")] [XmlArrayItem("Profile")]
    public ProfileData[] Profiles { get; set; }
}

AppSettingsData

Property Type Default Description
AutoStartEngine bool true Auto-start engine on launch
MinimizeToTray bool false Minimize to system tray
StartMinimized bool false Start minimized
StartAtLogin bool false Start with Windows
EnablePollingOnFocusLoss bool true Continue polling when unfocused
PollingRateMs int 1 Polling interval in ms
ThemeIndex int 0 UI theme index
EnableAutoProfileSwitching bool false Enable foreground-based profile switching
ActiveProfileId string null Currently active profile ID
SlotControllerTypes int[] null Per-slot VirtualControllerType enum values
SlotCreated bool[] null Which slots are explicitly created
SlotEnabled bool[] null Which slots are enabled
EnableDsuMotionServer bool false Enable DSU motion server
DsuMotionServerPort int 26760 DSU server port
Use2DControllerView bool false Use 2D controller visualization
VJoyConfigs VJoySlotConfigData[] null Per-slot vJoy configuration

MacroData

Property Type Default Description
PadIndex int (attribute) 0 Pad slot this macro belongs to
Name string "New Macro" Display name
IsEnabled bool true Whether macro is active
TriggerButtons ushort 0 Xbox button bitmask trigger
TriggerDeviceGuid string null Device GUID for raw button trigger (N format)
TriggerRawButtons string null Comma-separated raw button indices
TriggerSource MacroTriggerSource OutputController or InputDevice
TriggerMode MacroTriggerMode Press, Hold, Toggle
ConsumeTriggerButtons bool true Consume trigger from output
RepeatMode MacroRepeatMode Once, Count, WhileHeld
RepeatCount int 1 Repeat count
RepeatDelayMs int 100 Delay between repeats
TriggerCustomButtons string null Hex-encoded vJoy button words
Actions ActionData[] null Macro action sequence

ActionData

Property Type Default Description
Type MacroActionType Button, Key, Delay, Axis
ButtonFlags ushort 0 Xbox button flags
CustomButtons string null Hex-encoded vJoy button words
KeyCode int 0 Virtual key code
KeyString string null Multi-key combo in {Key1}{Key2}... format
DurationMs int 50 Action duration
AxisValue short 0 Axis value for axis actions
AxisTarget MacroAxisTarget Which axis to target

ProfileData

Property Type Default Description
Id string (attribute) Guid.NewGuid() Unique profile identifier
Name string "New Profile" Display name
ExecutableNames string "" Pipe-separated full exe paths for auto-switching
Entries ProfileEntry[] null Device-to-slot assignments
PadSettings PadSetting[] null Per-device pad settings
Macros MacroData[] null Per-slot macros
SlotCreated bool[] null Slot topology
SlotEnabled bool[] null Slot enabled states
SlotControllerTypes int[] null Per-slot controller types
EnableDsuMotionServer bool false DSU server state
DsuMotionServerPort int 26760 DSU server port

ProfileEntry

Property Type Description
InstanceGuid Guid Device instance GUID
ProductGuid Guid Product GUID for fallback matching (BT reconnect)
MapTo int Slot index
PadSettingChecksum string Links to a PadSetting

DeviceService

File: PadForge.App/Services/DeviceService.cs Namespace: PadForge.Services

Handles device management operations triggered by the UI: assigning devices to slots, toggling multi-slot assignments, hiding devices, creating/deleting virtual controller slots.

Fields

Field Type Description
_mainVm MainViewModel Root ViewModel reference
_settingsService SettingsService For MarkDirty() calls

Events

Event Type Description
DeviceAssignmentChanged EventHandler Raised after device assignment changes
DeviceHidingStateChanged EventHandler Raised when any device's hiding toggles change
NavigateToSlotRequested EventHandler<int> Raised to navigate to a newly assigned slot

Constructor

public DeviceService(MainViewModel mainVm, SettingsService settingsService)

Wire/Unwire Events

public void WireEvents()
public void UnwireEvents()

Subscribes/unsubscribes to DevicesViewModel events: AssignToSlotRequested, ToggleSlotRequested, HideDeviceRequested, RemoveDeviceRequested, DeviceHidingChanged.

Assign to Slot

private void OnAssignToSlot(object sender, int slotIndex)

Assigns the currently selected device to a controller slot:

  1. Validates selection and slot index
  2. Auto-creates the slot if !SlotCreated[slotIndex]
  3. Calls SettingsManager.AssignDeviceToSlot()
  4. Populates ProductGuid for fallback matching
  5. Creates default PadSetting if none exists (SettingsManager.CreateDefaultPadSetting)
  6. Updates device row display with assigned slots
  7. Marks settings dirty, raises DeviceAssignmentChanged and NavigateToSlotRequested
public void AssignDeviceToSlot(Guid instanceGuid, int slotIndex)

Public version for cross-panel drag-and-drop. Same logic as OnAssignToSlot but takes a device GUID directly instead of using the selected device.

Toggle Slot (Multi-Slot)

private void OnToggleSlot(object sender, int slotIndex)

Toggles the selected device's assignment to a specific slot. Calls SettingsManager.ToggleDeviceSlotAssignment() which returns (bool assigned, UserSetting us). If assigning, creates PadSetting and populates device info. Supports multi-slot: a single device can be assigned to multiple virtual controllers simultaneously.

Hide Device

private void OnHideDevice(object sender, Guid instanceGuid)

Sets IsHidden = true on both the UserDevice and the DeviceRowViewModel. The device remains in SettingsManager but is filtered from the UI.

Remove Device

private void OnRemoveDevice(object sender, Guid instanceGuid)

Calls SettingsManager.RemoveDevice() which deletes the device record, any UserSettings pointing to it, and associated PadSettings. The virtual controller slot itself is NOT deleted — it remains as an empty slot.

Unassign Device

public void UnassignDevice(Guid instanceGuid)

Calls SettingsManager.UnassignDevice(), clears the device row's assigned slots, marks dirty, raises DeviceAssignmentChanged.

Device Hiding

private void OnDeviceHidingChanged(object sender, Guid instanceGuid)

Called when a device's HidHideEnabled or ConsumeInputEnabled toggle changes on the Devices page. Writes the toggle state from the DeviceRowViewModel back to the UserDevice, marks settings dirty, and raises DeviceHidingStateChanged.

private void AutoEnableHidingDefaults(UserDevice ud, DeviceRowViewModel row)

Called when a device is newly assigned to a slot. Sets smart defaults based on device type:

Device Type HidHideEnabled ConsumeInputEnabled
Gamepad true (if HidHide installed) N/A
Keyboard unchanged true
Mouse unchanged true

When a device is removed from all slots (via OnToggleSlot), both flags are cleared.

Virtual Controller Slot Management

public int CreateSlot(VirtualControllerType controllerType = VirtualControllerType.Xbox360)

Finds the first slot where !SlotCreated[i], sets OutputType BEFORE SlotCreated (critical ordering: PropertyChanged handler reads SlotCreated), enables the slot, marks dirty. Returns the slot index (0 to MaxPads-1) or -1 if all slots are taken.

public void DeleteSlot(int slotIndex)

Sets SlotCreated[slotIndex] = false. Removes all UserSetting entries mapped to this slot (no orphan MapTo=-1 entries kept). Marks dirty.

public void SetSlotEnabled(int slotIndex, bool enabled)

Sets SettingsManager.SlotEnabled[slotIndex], marks dirty.


RecorderService

File: PadForge.App/Services/RecorderService.cs Namespace: PadForge.Services Implements: IDisposable

Handles input recording for mapping assignment. When the user clicks "Record" on a mapping row, captures a baseline state and polls at 30Hz for significant changes.

Constants

Constant Value Description
PollIntervalMs 33 Recording poll interval (~30Hz)
TimeoutSeconds 10 Recording timeout
AxisThreshold 16384 Axis movement threshold (~25% of full range)
AxisHoldCycles 3 Minimum poll cycles an axis must be held

State Fields

Field Type Description
_activeMapping MappingItem The mapping item currently being recorded
_activePadIndex int Pad index (-1 when not recording)
_activeDeviceGuid Guid Specific device to record from
_negRecording bool Recording negative direction of an axis
_baseline CustomInputState Baseline state captured at recording start
_axisHoldCounter int Cycles the current axis candidate has been held
_axisCandidateType MapType Type of the tracked axis candidate
_axisCandidateIndex int Index of the tracked axis candidate
_axisCandidatePositive bool Whether axis moved in positive direction
_recordingStartTime DateTime Start time (for timeout)
_waitForRelease bool Whether waiting for all inputs to return to neutral

Properties

Property Type Description
IsRecording bool Whether recording is currently active

Events

Event Type Description
RecordingCompleted EventHandler<RecordingResult> Raised on successful recording
RecordingTimedOut EventHandler Raised on timeout

StartRecording

public void StartRecording(
    MappingItem mapping,
    int padIndex,
    Guid deviceGuid,
    bool neutralizeBaseline = false,
    bool negRecording = false)

Cancels any existing recording, captures baseline state via CaptureCurrentState(), sets mapping.IsRecording = true, starts the 30Hz polling timer.

Parameters:

  • neutralizeBaseline: When true, enters wait-for-release phase first. Used for follow-up recordings where the previous input may still be physically held.
  • negRecording: When true, records the negative direction of a bidirectional axis.

CancelRecording

public void CancelRecording()

Stops recording without assigning a source. Clears IsRecording on the mapping item, stops the timer.

PollTick (Detection Logic)

private void PollTick(object sender, EventArgs e)

Called ~30 times per second. Detection priority:

  1. Timeout check: If >= TimeoutSeconds, cancels and raises RecordingTimedOut
  2. Wait-for-release phase: If _waitForRelease, skips detection until all buttons and POVs are neutral, then captures a fresh baseline
  3. Button detection (instant): Compares current.Buttons[i] vs _baseline.Buttons[i]. First newly-pressed button completes recording immediately
  4. POV hat detection (instant): Detects transition from centered (< 0) to any direction (>= 0). Converts centidegrees to direction string
  5. Axis detection (requires hold confirmation): Finds the axis/slider with the largest delta exceeding AxisThreshold. If the same candidate persists for AxisHoldCycles consecutive cycles, completes recording. New candidates reset the counter.

CompleteRecording

private void CompleteRecording(MapType type, int index, string povDirection, bool axisPositive = false)

Builds the descriptor string via BuildDescriptor(), stops recording, assigns to mapping.SourceDescriptor. For axis/slider recordings, calls ShouldAutoInvert() to determine whether to apply the "I" (invert) prefix.

BuildDescriptor

private static string BuildDescriptor(MapType type, int index, string povDirection)

Returns the mapping descriptor string:

MapType Format Example
Button "Button {index}" "Button 0"
Axis "Axis {index}" "Axis 1"
Slider "Slider {index}" "Slider 0"
POV "POV {index} {direction}" "POV 0 Up"

ShouldAutoInvert

private static bool ShouldAutoInvert(MappingItem mapping, bool axisPositive, bool negRecording)

Determines whether to auto-apply the Invert prefix based on the target mapping:

  • Stick axes (LeftThumbAxisX/Y, RightThumbAxisX/Y, bidirectional VJoy axes): Inverts if the user pushed in the wrong direction for the target (neg recording expects negative delta, pos expects positive)
  • Trigger axes (LeftTrigger, RightTrigger, unidirectional VJoy axes): Inverts when the axis value decreased (negative delta = reverse polarity)
  • All other targets: Inverts when the user pushed negative

CentidegreesToDirection

private static string CentidegreesToDirection(int centidegrees)

Converts a centidegrees POV value (0-35999) to an 8-way direction string: "Up", "UpRight", "Right", "DownRight", "Down", "DownLeft", "Left", "UpLeft".

CaptureCurrentState

private CustomInputState CaptureCurrentState()

Finds the device by _activeDeviceGuid in SettingsManager.UserDevices (under lock), returns ud.InputState.Clone() to prevent race conditions.

RecordingResult

public class RecordingResult
{
    public MappingItem Mapping { get; set; }
    public int PadIndex { get; set; }
    public string Descriptor { get; set; }
    public MapType Type { get; set; }
    public int Index { get; set; }
    public string PovDirection { get; set; }
}

DsuMotionServer

File: PadForge.App/Services/DsuMotionServer.cs Namespace: PadForge.Services Implements: IDisposable

UDP server implementing the cemuhook DSU (DualShock UDP) protocol for streaming controller motion data (gyro/accel) to emulators like Cemu, Dolphin, Yuzu, and Ryujinx.

Protocol spec: https://github.com/v1993/cemuhook-protocol

MotionSnapshot Struct

public struct MotionSnapshot
{
    public float AccelX, AccelY, AccelZ;     // g-force units
    public float GyroPitch, GyroYaw, GyroRoll; // degrees per second
    public long TimestampUs;                   // microseconds
    public bool HasMotion;                     // device has sensors
}

Constants

Constant Value Description
MaxSlots 4 Maximum DSU slots (protocol limit)
ProtocolVersion 1001 DSU protocol version
HeaderSize 16 Server header size in bytes
MsgTypeVersion 0x100000 Version request/response
MsgTypeControllerInfo 0x100001 Controller info request/response
MsgTypePadData 0x100002 Pad data subscription/broadcast
ClientTimeoutMs 5000 Client subscription timeout
SIO_UDP_CONNRESET 0x9800000C Windows IOControl to suppress ICMP port-unreachable

State Fields

Field Type Description
_socket Socket UDP socket bound to loopback
_receiveThread Thread Background receive thread
_running volatile bool Server running flag
_serverId uint Unique server ID (Environment.TickCount)
_port int Listening port
_packetCounters uint[MaxSlots] Per-slot packet counter
_subscriptions Dictionary<(EndPoint, int), long> Per-slot client subscriptions
_allSlotSubscriptions Dictionary<EndPoint, long> All-slot client subscriptions
_slotConnected bool[MaxSlots] Slot connection states
_slotHasMotion bool[MaxSlots] Slot has-motion states

Events

Event Type Description
StatusChanged EventHandler<string> Server status changes for UI display

Start

public bool Start(int port = 26760)

Creates a UDP socket, applies SIO_UDP_CONNRESET to suppress ICMP resets on Windows, binds to IPAddress.Loopback on the specified port, starts the receive thread. Returns false on AddressAlreadyInUse or other socket errors.

Stop

public void Stop()

Sets _running = false, closes socket, joins receive thread (2-second timeout), clears all subscriptions and packet counters.

BroadcastMotion

public void BroadcastMotion(int slot, MotionSnapshot snapshot, bool connected)

Called from the InputManager polling thread at ~1000Hz. Updates slot state, checks for subscribers, builds and sends the pad data packet to all subscribed endpoints. Only broadcasts if there are active subscribers (no wasted work).

Receive Loop

private void ReceiveLoop()

Background thread. Reads UDP packets in a loop via ReceiveFrom. For each packet:

  1. Validates "DSUC" magic (client -> server)
  2. Checks protocol version
  3. Verifies CRC32
  4. Routes by message type to handler

Message Handlers

HandleVersionRequest

private void HandleVersionRequest(EndPoint sender)

Responds with header + 8-byte payload: message type (4 bytes) + protocol version (2 bytes) + padding (2 bytes).

HandleControllerInfoRequest

private void HandleControllerInfoRequest(byte[] data, int length, EndPoint sender)

Reads numPorts and slot indices from the request, sends a controller info response for each requested slot.

HandlePadDataRequest

private void HandlePadDataRequest(byte[] data, int length, EndPoint sender)

Reads subscription flags and slot from the request:

  • flags == 0: Subscribe to all pads (stored in _allSlotSubscriptions)
  • flags & 0x01: Subscribe to specific slot by ID
  • flags & 0x02: Subscribe by MAC (treated as all-slot)

BuildPadDataPacket

private byte[] BuildPadDataPacket(int slot, MotionSnapshot snapshot, bool connected)

Builds the 84-byte payload pad data packet:

Offset Size Field
+0 1 Slot number
+1 1 Slot state (0=disconnected, 2=connected)
+2 1 Device model (0=N/A, 2=full gyro)
+3 1 Connection type (0=N/A)
+4..9 6 MAC address (fake, unique per slot)
+10 1 Battery status (0x05 = charged)
+11 1 Connected flag (1=connected)
+12..15 4 Packet counter (uint32)
+16..17 2 Button bitmasks (zeroed — motion-only)
+18..19 2 Home/touch buttons (zeroed)
+20..23 4 Stick axes (128 = centered)
+24..35 12 Analog D-pad + buttons (zeroed)
+36..47 12 Touch data (zeroed)
+48..55 8 Motion timestamp (uint64, microseconds)
+56..59 4 AccelX (float)
+60..63 4 AccelY (float)
+64..67 4 AccelZ (float)
+68..71 4 GyroPitch (float)
+72..75 4 GyroYaw (float)
+76..79 4 GyroRoll (float)

Total: 16 (header) + 84 (payload) = 100 bytes per packet.

Subscription Management

private List<EndPoint> GetSubscribers(int slot)

Returns active subscribers for the given slot, combining per-slot and all-slot subscriptions. Prunes expired subscriptions (older than ClientTimeoutMs based on Stopwatch.GetTimestamp()).

Packet Helpers

private void WriteHeader(byte[] packet, int payloadLength, uint msgType)

Writes the 16-byte server header: "DSUS" magic, protocol version 1001, payload length, CRC32 placeholder, server ID, then message type as first 4 bytes of payload.

private static void FinalizeCrc(byte[] packet)

Zeros the CRC field, computes CRC32 over the entire packet, writes it back.

private static uint ComputeCrc32(byte[] data, int length)

Standard CRC32 computation using pre-computed lookup table (polynomial 0xEDB88320).

SDL to DSU Axis Mapping

The motion data coordinate conversion (performed in InputManager before calling BroadcastMotion) follows these rules:

  • AccelX = -ax, AccelY = -ay, AccelZ = -az (DS4 uses inverted signs vs SDL)
  • GyroPitch = -gx, GyroYaw = gy, GyroRoll = -gz

This mapping has been verified with both DualSense and Switch 2 Pro Controller.


ForegroundMonitorService

File: PadForge.App/Services/ForegroundMonitorService.cs Namespace: PadForge.Services

Monitors the foreground window process and fires an event when it matches a profile's executable list. Called at 30Hz from InputService.UiTimer_Tick via CheckForegroundWindow().

P/Invoke Declarations

[DllImport("user32.dll")]
private static extern IntPtr GetForegroundWindow();

[DllImport("user32.dll")]
private static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint processId);

Fields

Field Type Description
_lastExePath string Last detected foreground process path
_lastMatchedProfileId string Last matched profile ID (change detection)

Events

Event Type Description
ProfileSwitchRequired Action<string> Raised when foreground matches a different profile. Argument is profile ID or null (revert to default).

CheckForegroundWindow

public void CheckForegroundWindow()

Only runs if SettingsManager.EnableAutoProfileSwitching is true and profiles exist. Gets the foreground window's process path via GetForegroundExePath(). Skips redundant lookups if the exe path hasn't changed since last check. Iterates all profiles, calls MatchesExecutables() for each. Only raises ProfileSwitchRequired when the matched profile changes.

GetForegroundExePath

private static string GetForegroundExePath()

Calls GetForegroundWindow(), GetWindowThreadProcessId(), Process.GetProcessById(), returns proc.MainModule?.FileName. Returns null on any failure (silently catches all exceptions).

MatchesExecutables

private static bool MatchesExecutables(string foregroundPath, string executables)

Splits the profile's ExecutableNames string on | (pipe separator) and does a case-insensitive full-path match against the foreground process path. Each entry is a full executable path (e.g., C:\Games\game.exe|D:\Other\game2.exe).


WebControllerServer

File: PadForge.App/Services/WebControllerServer.cs

Embedded HTTP + WebSocket server that serves a gamepad UI to web browsers. Each connected browser client becomes a WebControllerDevice in the input pipeline.

Architecture

  • HTTP server: System.Net.HttpListener on configurable port (default 8080)
  • Static assets: HTML/CSS/JS served from embedded resources (PadForge.App/WebAssets/)
  • Image serving: Controller PNG overlays from 2DModels/ cached in a Dictionary<string, byte[]> loaded via Application.GetResourceStream on the UI thread
  • Layout API: /api/layout?type=xbox360|ds4 returns JSON position/size data from ControllerOverlayLayout.cs
  • WebSocket: Per-client session for real-time input (button/axis/POV state) and rumble feedback

Lifecycle

public bool Start(int port = 8080)
public void Stop()

Start() initializes the image cache (must run LoadImageCache() on UI thread via Dispatcher.Invoke()), calls EnsureFirewallRule(), creates the HttpListener, and spawns the accept thread. Stop() sets _running = false, closes the listener, and cleans up client sessions.

Firewall Rule

private static void EnsureFirewallRule(int port)

Creates a Windows Firewall inbound rule to allow browser connections. Runs netsh advfirewall firewall add rule with rule name "PadForge Web Controller", direction in, action allow, protocol TCP, and the configured port. Only creates the rule if it does not already exist (checked via netsh advfirewall firewall show rule name=). Called automatically during Start().

Events

Event Payload Description
StatusChanged string Server status text for Dashboard display
DeviceConnected WebControllerDevice New browser client connected
DeviceDisconnected WebControllerDevice Browser client disconnected

HTTP Routes

Route Method Description
/index.html GET Landing page with Xbox 360 / DualShock 4 layout selection
/controller.html GET Full-screen controller UI with touch zones and overlays
/api/layout?type= GET JSON layout descriptor (positions, sizes, input codes) from ControllerOverlayLayout.cs
/img/{filename} GET 2D controller PNG overlay images from the in-memory image cache
/ws WebSocket Full-duplex input and rumble communication

WebSocket Protocol

Client → Server (input):

{ "type": "input", "axes": [lx, ly, lt, rx, ry, rt], "buttons": [0,1,...], "pov": value }

Server → Client (rumble):

{ "type": "rumble", "left": 0-65535, "right": 0-65535 }

Client Sessions

Each WebSocket connection creates a ClientSession with:

  • A persistent pad ID (survives reconnects within the same browser tab via localStorage)
  • A WebControllerDevice implementing ISdlInputDevice
  • Async receive loop for input messages (JSON: button presses, axis values, POV directions)
  • Rumble forwarding via the browser Vibration API

Maximum concurrent clients: MaxClients = 10.

WebControllerDevice

File: PadForge.Engine/Common/WebControllerDevice.cs

Each connected browser client is represented as a WebControllerDevice implementing ISdlInputDevice, making it appear in the input pipeline alongside physical controllers. State is written by the WebSocket receive thread and read by the polling thread via volatile reference swaps for thread-safety.

Property Value
VID / PID 0xBEEF / 0xCA7E
Axes 6 (LX, LY, LT, RX, RY, RT)
Buttons 11 (A, B, X, Y, LB, RB, Back, Start, LS, RS, Guide)
POV Hats 1
Rumble Yes (via browser Vibration API)

Frontend Assets

File Purpose
WebAssets/index.html Landing page with layout cards
WebAssets/controller.html Controller shell with touch layer
WebAssets/css/controller.css Dark theme, responsive scaling, overlay animations
WebAssets/js/controller_client.js Layout loading, touch handling, dual nipplejs sticks, WebSocket
WebAssets/js/nipplejs.min.js Virtual joystick library for analog sticks

Thread Model

  • Accept thread: Dedicated background thread running HttpListener.GetContext() loop
  • WebSocket tasks: Per-client async tasks for WebSocket I/O
  • Image cache loading: Must run on UI thread (WPF pack URI resource access)

Clone this wiki locally