-
Notifications
You must be signed in to change notification settings - Fork 6
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.
- Architecture Overview
- InputService
- SettingsService
- DeviceService
- RecorderService
- DsuMotionServer
- ForegroundMonitorService
- WebControllerServer
+-------------------+ 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.
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.
| Constant | Value | Description |
|---|---|---|
UiTimerIntervalMs |
33 |
UI update interval (~30 frames per second) |
| 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 |
| 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) |
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.
public void Start()Creates and starts the InputManager and UI timer. Full startup sequence:
-
InputManager.CleanupStaleVigemDevices()— removes leftover ViGEm USB device nodes -
VJoyVirtualController.RemoveAllDeviceNodes()— removes leftover vJoy nodes (skipped if_preservedVJoyNodesis true) - Creates
InputManagerwith configured polling interval - Copies controller types and vJoy configs to engine arrays
- Calls
PreInitializeVigemCounts()so the device filter catches ViGEm VCs on first UpdateDevices cycle - Subscribes to engine events:
DevicesUpdated,FrequencyUpdated,ErrorOccurred - Subscribes to
Settings.PropertyChangedandDashboard.PropertyChanged - Creates
ForegroundMonitorService, subscribes toProfileSwitchRequired - Captures default profile snapshot via
SnapshotCurrentProfile() - Calls
_inputManager.Start()to begin the background polling thread - Starts DSU server if enabled
- Calls
ApplyDeviceHiding()to apply HidHide blacklist and low-level hooks - Creates and starts the 30Hz
DispatcherTimer
public void Stop(bool preserveVJoyNodes = false)Shuts down everything in reverse order:
- Stops UI timer
- Unsubscribes from settings/dashboard property changes
- Disposes foreground monitor
- Calls
RemoveDeviceHiding()to clean up HidHide blacklist and hooks - Stops DSU server
- Stops and disposes InputManager (passes
preserveVJoyNodes) - Resets all dashboard/status ViewModel properties
- Marks all device rows offline
- 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.
private void UiTimer_Tick(object sender, EventArgs e)Called ~30 times per second on the UI thread. Performs all of the following in sequence:
-
Update Pad ViewModels — reads
CombinedOutputStates[i]andVibrationStates[i]from engine, callspadVm.UpdateFromEngineState(). For custom vJoy slots, also pushesCombinedVJoyRawStates[i]. Updates per-device state for stick/trigger tab previews. - UpdateDashboard() — pushes engine statistics to DashboardViewModel
-
UpdateDevicesRawState() — only if
IsDevicesPageVisible; updates axis/button/POV/gyro/accel display -
UpdateMappingLiveValues() — only if
IsPadPageVisible; updates live value text on mapping rows - UpdateMacroTriggerRecording() — accumulates buttons during macro trigger recording
- SyncViewModelToPadSettings() — pushes ViewModel slider values to PadSetting objects at 30Hz
-
SyncMacroSnapshots() — pushes macro lists to engine's
MacroSnapshots[]array - CheckForegroundWindow() — auto-profile switching (via foreground monitor)
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.).
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.
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.
private void SyncViewModelToPadSettings()Called at 30Hz. For each pad:
- Syncs
OutputTypeand 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.
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.
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.
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.
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.
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.
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().
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.
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:
-
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. -
Custom vJoy path (
ButtonStyle == Numbered): Accumulates fromCombinedVJoyRawStates[padIndex].Buttons. -
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.
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:
-
Apply topology: Sets
SlotCreated,SlotEnabled,OutputTypeper slot. Unassigns devices from slots being destroyed. -
Reset device assignments: Sets all
UserSetting.MapTo = -1. -
Apply profile entries: For each
ProfileEntry, finds an unassigned UserSetting byInstanceGuid(orProductGuidfallback), clones the PadSetting, setsMapTo. -
Apply DSU settings: Updates
EnableDsuMotionServerand port. -
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.
public void ApplyDeviceHiding()Called on engine start and whenever device hiding state changes. Manages two independent hiding mechanisms:
-
HidHide (driver-level): For devices with
HidHideEnabled = true, callsHidHideController.RemoveManagedDevices()to clear PadForge's previous blacklist entries, then adds current devices to the blacklist viaAddToBlacklist(). Also callsEnsureWhitelisted()with PadForge's own exe path andSetActive(true)to enable cloaking. -
Low-level hooks (application-level): For devices with
ConsumeInputEnabled = true, callsCollectSuppressedInputs()to parse mapping descriptors into VKey and mouse button suppression sets, then starts or updates theInputHookManager.
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}"-> addsvkCodeto the VKey suppression set -
Mouse:
"Button 0"-> Left (0),"Button 1"-> Right (1),"Button 2"-> Middle (2), etc.
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.
public void Dispose()Calls Stop(), unsubscribes from PadViewModel events and DevicesViewModel PropertyChanged.
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.
| Constant | Value | Description |
|---|---|---|
PrimaryFileName |
"PadForge.xml" |
Primary settings file name |
FallbackFileName |
"Settings.xml" |
Legacy fallback file name |
| Field | Type | Description |
|---|---|---|
_mainVm |
MainViewModel |
Root ViewModel reference |
_settingsFilePath |
string |
Full path to the active settings file |
_autoSaveTimer |
DispatcherTimer |
250ms debounce timer for autosave |
| Property | Type | Description |
|---|---|---|
SettingsFilePath |
string |
Full path to the active settings file |
IsDirty |
bool |
Whether settings have been modified since last save |
| Event | Type | Description |
|---|---|---|
AutoSaved |
EventHandler |
Raised after autosave completes |
public SettingsService(MainViewModel mainVm)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.
private static string FindSettingsFile()Search order:
-
PadForge.xmlin the application directory (preferred) -
Settings.xmlin the application directory (legacy fallback) - Falls back to
PadForge.xmlpath for new installs
public void LoadFromFile(string filePath)Deserializes SettingsFileData from the XML file, then:
-
Populate devices: Locks
UserDevices.SyncRoot, clears and repopulates fromdata.Devices -
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) -
Purge orphans: Removes UserSettings with
MapTo == -1(left by older versions) -
Load app settings:
LoadAppSettings(data.AppSettings) -
Load pad settings:
LoadPadSettings(data.Settings, data.PadSettings) -
Load macros:
LoadMacros(data.Macros) -
Load profiles:
LoadProfiles(data.Profiles, data.AppSettings)
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
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.
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.
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).
public void Save()
public void SaveToFile(string filePath)Full save sequence:
-
UpdatePadSettingsFromViewModels()— pushes all ViewModel values to PadSetting objects -
FlushVJoyMappings()+UpdateChecksum()on all PadSettings, sync checksums to UserSettings -
UpdateActiveProfileSnapshot()— persists edits made while a profile was active - Collect devices under lock
- Collect user settings and deduplicated pad settings (by checksum)
-
BuildAppSettings()— buildsAppSettingsDatafrom SettingsViewModel -
BuildMacroData()— collects macros from all pad ViewModels - Collect profiles
- Serialize via
XmlSerializerto file
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.
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).
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.
private void UpdateActiveProfileSnapshot()If a named profile is active, updates its stored Entries, PadSettings, slot topology, and DSU settings from the current runtime state.
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.
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.
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.
All serialization DTOs are defined at the bottom of SettingsService.cs:
[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; }
}| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
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.
| Field | Type | Description |
|---|---|---|
_mainVm |
MainViewModel |
Root ViewModel reference |
_settingsService |
SettingsService |
For MarkDirty() calls |
| 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 |
public DeviceService(MainViewModel mainVm, SettingsService settingsService)public void WireEvents()
public void UnwireEvents()Subscribes/unsubscribes to DevicesViewModel events: AssignToSlotRequested, ToggleSlotRequested, HideDeviceRequested, RemoveDeviceRequested, DeviceHidingChanged.
private void OnAssignToSlot(object sender, int slotIndex)Assigns the currently selected device to a controller slot:
- Validates selection and slot index
- Auto-creates the slot if
!SlotCreated[slotIndex] - Calls
SettingsManager.AssignDeviceToSlot() - Populates
ProductGuidfor fallback matching - Creates default PadSetting if none exists (
SettingsManager.CreateDefaultPadSetting) - Updates device row display with assigned slots
- Marks settings dirty, raises
DeviceAssignmentChangedandNavigateToSlotRequested
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.
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.
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.
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.
public void UnassignDevice(Guid instanceGuid)Calls SettingsManager.UnassignDevice(), clears the device row's assigned slots, marks dirty, raises DeviceAssignmentChanged.
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.
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.
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.
| 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 |
| 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 |
| Property | Type | Description |
|---|---|---|
IsRecording |
bool |
Whether recording is currently active |
| Event | Type | Description |
|---|---|---|
RecordingCompleted |
EventHandler<RecordingResult> |
Raised on successful recording |
RecordingTimedOut |
EventHandler |
Raised on timeout |
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.
public void CancelRecording()Stops recording without assigning a source. Clears IsRecording on the mapping item, stops the timer.
private void PollTick(object sender, EventArgs e)Called ~30 times per second. Detection priority:
-
Timeout check: If
>= TimeoutSeconds, cancels and raisesRecordingTimedOut -
Wait-for-release phase: If
_waitForRelease, skips detection until all buttons and POVs are neutral, then captures a fresh baseline -
Button detection (instant): Compares
current.Buttons[i]vs_baseline.Buttons[i]. First newly-pressed button completes recording immediately -
POV hat detection (instant): Detects transition from centered (
< 0) to any direction (>= 0). Converts centidegrees to direction string -
Axis detection (requires hold confirmation): Finds the axis/slider with the largest delta exceeding
AxisThreshold. If the same candidate persists forAxisHoldCyclesconsecutive cycles, completes recording. New candidates reset the counter.
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.
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" |
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
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".
private CustomInputState CaptureCurrentState()Finds the device by _activeDeviceGuid in SettingsManager.UserDevices (under lock), returns ud.InputState.Clone() to prevent race conditions.
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; }
}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
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
}| 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 |
| 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 |
| Event | Type | Description |
|---|---|---|
StatusChanged |
EventHandler<string> |
Server status changes for UI display |
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.
public void Stop()Sets _running = false, closes socket, joins receive thread (2-second timeout), clears all subscriptions and packet counters.
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).
private void ReceiveLoop()Background thread. Reads UDP packets in a loop via ReceiveFrom. For each packet:
- Validates "DSUC" magic (client -> server)
- Checks protocol version
- Verifies CRC32
- Routes by message type to handler
private void HandleVersionRequest(EndPoint sender)Responds with header + 8-byte payload: message type (4 bytes) + protocol version (2 bytes) + padding (2 bytes).
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.
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)
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.
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()).
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).
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.
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().
[DllImport("user32.dll")]
private static extern IntPtr GetForegroundWindow();
[DllImport("user32.dll")]
private static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint processId);| Field | Type | Description |
|---|---|---|
_lastExePath |
string |
Last detected foreground process path |
_lastMatchedProfileId |
string |
Last matched profile ID (change detection) |
| Event | Type | Description |
|---|---|---|
ProfileSwitchRequired |
Action<string> |
Raised when foreground matches a different profile. Argument is profile ID or null (revert to default). |
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.
private static string GetForegroundExePath()Calls GetForegroundWindow(), GetWindowThreadProcessId(), Process.GetProcessById(), returns proc.MainModule?.FileName. Returns null on any failure (silently catches all exceptions).
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).
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.
-
HTTP server:
System.Net.HttpListeneron 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 aDictionary<string, byte[]>loaded viaApplication.GetResourceStreamon the UI thread -
Layout API:
/api/layout?type=xbox360|ds4returns JSON position/size data fromControllerOverlayLayout.cs - WebSocket: Per-client session for real-time input (button/axis/POV state) and rumble feedback
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.
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().
| Event | Payload | Description |
|---|---|---|
StatusChanged |
string |
Server status text for Dashboard display |
DeviceConnected |
WebControllerDevice |
New browser client connected |
DeviceDisconnected |
WebControllerDevice |
Browser client disconnected |
| 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 |
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 }Each WebSocket connection creates a ClientSession with:
- A persistent pad ID (survives reconnects within the same browser tab via
localStorage) - A
WebControllerDeviceimplementingISdlInputDevice - 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.
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) |
| 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 |
-
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)