-
Notifications
You must be signed in to change notification settings - Fork 6
Settings and Serialization
XML file format, data models, the SettingsManager, and the save/load pipeline that backs every persistent setting.
v3 (2026-04-26): Rewritten for v3. The HIDMaestro SDK surface, OpenXInput shim, thread-pool lifecycle, and bubble-up cascade live on HIDMaestro Deep Dive. If anything here drifts from the live source, the live source wins.
This page is a developer reference for PadForge's settings persistence.
flowchart TD
subgraph "Save Flow"
S1[User Action<br/>slider drag · mapping change · toggle] --> S2[SettingsService.MarkDirty]
S2 --> S3[250ms Debounce Timer<br/>restarts on each call]
S3 --> S4[UpdatePadSettingsFromViewModels<br/>write ViewModel values to PadSettings]
S4 --> S5[Flush Mappings + UpdateChecksum<br/>Extended · MIDI · KBM dicts to arrays · MD5]
S5 --> S6[UpdateActiveProfileSnapshot<br/>deep clone to active profile if named]
S6 --> S7[Collect Data under SyncRoot<br/>Devices · Settings · PadSettings deduplicated]
S7 --> S8[Build DTOs<br/>AppSettings · Macros · Profiles]
S8 --> S9[XmlSerializer.Serialize<br/>write PadForge.xml]
S9 --> S10[IsDirty = false<br/>raise AutoSaved event]
end
subgraph "Load Flow"
L1[LoadFromFile] --> L2[XmlSerializer.Deserialize<br/>stream to SettingsFileData]
L2 --> L3[Populate UserDevices<br/>lock · clear · add from XML]
L3 --> L4[Populate UserSettings<br/>match PadSetting by checksum · CloneDeep]
L4 --> L5[Purge Orphans<br/>remove stale MapTo entries]
L5 --> L6[LoadAppSettings<br/>SlotCreated before OutputType]
L6 --> L7[LoadPadSettings<br/>deadzones · curves · ranges · mappings]
L7 --> L8[LoadMacros<br/>reconstruct from serialized data]
L8 --> L9[LoadProfiles<br/>Default profile · restore active profile topology]
end
style S1 fill:#e1f5fe
style S2 fill:#e1f5fe
style S3 fill:#fff3e0
style S9 fill:#e8f5e9
style S10 fill:#e8f5e9
style L1 fill:#f3e5f5
style L2 fill:#f3e5f5
style L4 fill:#fff3e0
style L9 fill:#e8f5e9
Source files:
-
PadForge.App/Services/SettingsService.cs. XML load/save, serialization DTOs -
PadForge.App/Common/SettingsManager.cs. Thread-safe collections, slot management -
PadForge.Engine/Data/PadSetting.cs. Mapping configuration model -
PadForge.Engine/Data/UserDevice.cs. Physical device record -
PadForge.Engine/Data/UserSetting.cs. Device-to-slot linkage
- PadForge.xml File Format
- UserDevice
- UserSetting
- PadSetting
- AppSettingsData
- MacroData and ActionData
- ProfileData
- GlobalMacroData
- SettingsManager
- Serialization Pipeline
- Cross-Layout Mapping Translation
- Backward Compatibility
XML document with SettingsFileData as the root element (serialized as <PadForgeSettings>). Lives next to the executable.
SettingsService.FindSettingsFile() search order:
-
PadForge.xml. Preferred -
Settings.xml. Legacy fallback - If neither exists, creates
PadForge.xmlin the application directory
[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; }
}<PadForgeSettings>
<!-- Physical input devices (persisted across sessions) -->
<Devices>
<Device>
<InstanceGuid>00000000-0000-0000-0000-000000000000</InstanceGuid>
<InstanceName>Xbox Controller</InstanceName>
<ProductGuid>...</ProductGuid>
<ProductName>Xbox One Controller</ProductName>
<VendorId>1118</VendorId>
<ProdId>654</ProdId>
<DevRevision>0</DevRevision>
<DevicePath>\\?\hid#vid_045e&pid_028e...</DevicePath>
<SerialNumber></SerialNumber>
<CapAxeCount>6</CapAxeCount>
<CapButtonCount>11</CapButtonCount>
<RawButtonCount>11</RawButtonCount>
<CapPovCount>1</CapPovCount>
<CapType>21</CapType>
<CapSubType>0</CapSubType>
<CapFlags>0</CapFlags>
<HasGyro>false</HasGyro>
<HasAccel>false</HasAccel>
<DateCreated>2025-01-15T10:30:00</DateCreated>
<DateUpdated>2025-01-15T10:30:00</DateUpdated>
<IsEnabled>true</IsEnabled>
<IsHidden>false</IsHidden>
<DisplayName></DisplayName>
<HidHideEnabled>false</HidHideEnabled>
<ConsumeInputEnabled>false</ConsumeInputEnabled>
<ForceRawJoystickMode>false</ForceRawJoystickMode>
<HidHideInstanceIds />
</Device>
</Devices>
<!-- Device-to-slot assignments (links device to a virtual controller slot) -->
<UserSettings>
<Setting>
<InstanceGuid>00000000-0000-0000-0000-000000000000</InstanceGuid>
<InstanceName>Xbox Controller</InstanceName>
<ProductGuid>...</ProductGuid>
<ProductName>Xbox One Controller</ProductName>
<MapTo>0</MapTo>
<PadSettingChecksum>A1B2C3D4</PadSettingChecksum>
<IsEnabled>true</IsEnabled>
<DateCreated>2025-01-15T10:30:00</DateCreated>
<DateUpdated>2025-01-15T10:30:00</DateUpdated>
</Setting>
</UserSettings>
<!-- Mapping configurations (deduplicated by checksum) -->
<PadSettings>
<PadSetting>
<PadSettingChecksum>A1B2C3D4</PadSettingChecksum>
<!-- Button mappings -->
<ButtonA>Button 0</ButtonA>
<ButtonB>Button 1</ButtonB>
<ButtonX>Button 2</ButtonX>
<ButtonY>Button 3</ButtonY>
<LeftShoulder>Button 4</LeftShoulder>
<RightShoulder>Button 5</RightShoulder>
<ButtonBack>Button 6</ButtonBack>
<ButtonStart>Button 7</ButtonStart>
<ButtonGuide>Button 10</ButtonGuide>
<LeftThumbButton>Button 8</LeftThumbButton>
<RightThumbButton>Button 9</RightThumbButton>
<!-- D-Pad -->
<DPad></DPad>
<DPadUp>POV 0 Up</DPadUp>
<DPadDown>POV 0 Down</DPadDown>
<DPadLeft>POV 0 Left</DPadLeft>
<DPadRight>POV 0 Right</DPadRight>
<!-- Triggers -->
<LeftTrigger>Axis 2</LeftTrigger>
<RightTrigger>Axis 5</RightTrigger>
<LeftTriggerDeadZone>0</LeftTriggerDeadZone>
<RightTriggerDeadZone>0</RightTriggerDeadZone>
<LeftTriggerAntiDeadZone>0</LeftTriggerAntiDeadZone>
<RightTriggerAntiDeadZone>0</RightTriggerAntiDeadZone>
<LeftTriggerMaxRange>100</LeftTriggerMaxRange>
<RightTriggerMaxRange>100</RightTriggerMaxRange>
<!-- Thumbstick axes -->
<LeftThumbAxisX>Axis 0</LeftThumbAxisX>
<LeftThumbAxisY>Axis 1</LeftThumbAxisY>
<RightThumbAxisX>Axis 3</RightThumbAxisX>
<RightThumbAxisY>Axis 4</RightThumbAxisY>
<LeftThumbAxisXNeg></LeftThumbAxisXNeg>
<LeftThumbAxisYNeg></LeftThumbAxisYNeg>
<RightThumbAxisXNeg></RightThumbAxisXNeg>
<RightThumbAxisYNeg></RightThumbAxisYNeg>
<!-- Deadzones -->
<LeftThumbDeadZoneX>0</LeftThumbDeadZoneX>
<LeftThumbDeadZoneY>0</LeftThumbDeadZoneY>
<RightThumbDeadZoneX>0</RightThumbDeadZoneX>
<RightThumbDeadZoneY>0</RightThumbDeadZoneY>
<LeftThumbDeadZoneShape>2</LeftThumbDeadZoneShape>
<RightThumbDeadZoneShape>2</RightThumbDeadZoneShape>
<LeftThumbAntiDeadZone>0</LeftThumbAntiDeadZone>
<RightThumbAntiDeadZone>0</RightThumbAntiDeadZone>
<LeftThumbAntiDeadZoneX>0</LeftThumbAntiDeadZoneX>
<LeftThumbAntiDeadZoneY>0</LeftThumbAntiDeadZoneY>
<RightThumbAntiDeadZoneX>0</RightThumbAntiDeadZoneX>
<RightThumbAntiDeadZoneY>0</RightThumbAntiDeadZoneY>
<LeftThumbLinear>0</LeftThumbLinear>
<RightThumbLinear>0</RightThumbLinear>
<!-- Sensitivity curves -->
<LeftThumbSensitivityCurveX>0</LeftThumbSensitivityCurveX>
<LeftThumbSensitivityCurveY>0</LeftThumbSensitivityCurveY>
<RightThumbSensitivityCurveX>0</RightThumbSensitivityCurveX>
<RightThumbSensitivityCurveY>0</RightThumbSensitivityCurveY>
<LeftTriggerSensitivityCurve>0</LeftTriggerSensitivityCurve>
<RightTriggerSensitivityCurve>0</RightTriggerSensitivityCurve>
<!-- Max range -->
<LeftThumbMaxRangeX>100</LeftThumbMaxRangeX>
<LeftThumbMaxRangeY>100</LeftThumbMaxRangeY>
<RightThumbMaxRangeX>100</RightThumbMaxRangeX>
<RightThumbMaxRangeY>100</RightThumbMaxRangeY>
<!-- Independent per-direction max range (null = symmetric) -->
<LeftThumbMaxRangeXNeg>100</LeftThumbMaxRangeXNeg>
<LeftThumbMaxRangeYNeg>100</LeftThumbMaxRangeYNeg>
<RightThumbMaxRangeXNeg>100</RightThumbMaxRangeXNeg>
<RightThumbMaxRangeYNeg>100</RightThumbMaxRangeYNeg>
<!-- Center offset calibration -->
<LeftThumbCenterOffsetX>0</LeftThumbCenterOffsetX>
<LeftThumbCenterOffsetY>0</LeftThumbCenterOffsetY>
<RightThumbCenterOffsetX>0</RightThumbCenterOffsetX>
<RightThumbCenterOffsetY>0</RightThumbCenterOffsetY>
<!-- Force feedback -->
<ForceType>1</ForceType>
<ForceOverall>100</ForceOverall>
<ForceSwapMotor>0</ForceSwapMotor>
<LeftMotorStrength>100</LeftMotorStrength>
<RightMotorStrength>100</RightMotorStrength>
<!-- Audio bass rumble -->
<AudioRumbleEnabled>0</AudioRumbleEnabled>
<AudioRumbleSensitivity>4</AudioRumbleSensitivity>
<AudioRumbleCutoffHz>80</AudioRumbleCutoffHz>
<AudioRumbleLeftMotor>100</AudioRumbleLeftMotor>
<AudioRumbleRightMotor>100</AudioRumbleRightMotor>
<!-- Axis inversion -->
<LeftThumbAxisXInvert>0</LeftThumbAxisXInvert>
<LeftThumbAxisYInvert>0</LeftThumbAxisYInvert>
<RightThumbAxisXInvert>0</RightThumbAxisXInvert>
<RightThumbAxisYInvert>0</RightThumbAxisYInvert>
<!-- Other -->
<AxisToButtonThreshold>50</AxisToButtonThreshold>
<!-- Dictionary-based mappings (only present when non-empty) -->
<ExtendedMappings>
<Map Key="ExtendedAxis0" Value="Axis 0" />
<Map Key="ExtendedBtn0" Value="Button 0" />
</ExtendedMappings>
<MidiMappings>
<Map Key="MidiCC0" Value="Axis 0" />
<Map Key="MidiNote0" Value="Button 0" />
</MidiMappings>
<KbmMappings>
<Map Key="KbmKey41" Value="Button 0" />
<Map Key="KbmMouseX" Value="Axis 0" />
</KbmMappings>
<MappingDeadZones>
<Map><Key>ButtonA</Key><Value>30</Value></Map>
<Map><Key>DPadUp</Key><Value>75</Value></Map>
</MappingDeadZones>
</PadSetting>
</PadSettings>
<!-- Application-level settings (single element) -->
<AppSettings>
<AutoStartEngine>true</AutoStartEngine>
<MinimizeToTray>false</MinimizeToTray>
<StartMinimized>false</StartMinimized>
<StartAtLogin>false</StartAtLogin>
<EnablePollingOnFocusLoss>true</EnablePollingOnFocusLoss>
<PollingRateMs>1</PollingRateMs>
<ThemeIndex>0</ThemeIndex>
<Language></Language>
<EnableAutoProfileSwitching>false</EnableAutoProfileSwitching>
<ActiveProfileId />
<SlotControllerTypes>
<Type>0</Type> <!-- VirtualControllerType enum: 0=Microsoft, 1=PlayStation (XmlEnum="Sony" on disk), 2=Extended, 3=Midi, 4=KeyboardMouse -->
<Type>1</Type>
</SlotControllerTypes>
<SlotCreated>
<Created>true</Created>
<Created>true</Created>
<Created>false</Created>
<!-- ... up to 16 entries -->
</SlotCreated>
<SlotEnabled>
<Enabled>true</Enabled>
<Enabled>true</Enabled>
<Enabled>true</Enabled>
</SlotEnabled>
<EnableDsuMotionServer>false</EnableDsuMotionServer>
<DsuMotionServerPort>26760</DsuMotionServerPort>
<EnableWebController>false</EnableWebController>
<WebControllerPort>8080</WebControllerPort>
<Use2DControllerView>false</Use2DControllerView>
<EnableInputHiding>true</EnableInputHiding>
<HidHideWhitelistPaths>
<Path>C:\Games\emulator.exe</Path>
</HidHideWhitelistPaths>
<ExtendedConfigs>
<Config SlotIndex="2">
<Preset>Custom</Preset>
<ThumbstickCount>2</ThumbstickCount>
<TriggerCount>2</TriggerCount>
<PovCount>1</PovCount>
<ButtonCount>11</ButtonCount>
</Config>
</ExtendedConfigs>
<MidiConfigs>
<Config SlotIndex="3" Channel="1" CcCount="6" StartCc="1" NoteCount="11" StartNote="60" Velocity="127" />
</MidiConfigs>
<!-- Snapshot of default profile state when a named profile is active (null when default is active) -->
<DefaultProfileSnapshot>
<!-- Same structure as a Profile element -->
</DefaultProfileSnapshot>
</AppSettings>
<!-- Macros (per-slot, PadIndex attribute identifies the slot) -->
<Macros>
<Macro PadIndex="0">
<Name>Turbo A</Name>
<IsEnabled>true</IsEnabled>
<TriggerButtons>4096</TriggerButtons>
<TriggerSource>OutputController</TriggerSource>
<TriggerMode>Hold</TriggerMode>
<ConsumeTriggerButtons>true</ConsumeTriggerButtons>
<RepeatMode>WhileHeld</RepeatMode>
<RepeatCount>1</RepeatCount>
<RepeatDelayMs>50</RepeatDelayMs>
<TriggerAxisThreshold>50</TriggerAxisThreshold>
<Actions>
<Action>
<Type>Button</Type>
<ButtonFlags>4096</ButtonFlags>
<DurationMs>50</DurationMs>
</Action>
</Actions>
</Macro>
</Macros>
<!-- Per-application profiles (self-contained snapshots) -->
<Profiles>
<Profile Id="abc123def456">
<Name>Game Profile</Name>
<ExecutableNames>C:\Games\game.exe|D:\Other\game2.exe</ExecutableNames>
<Entries>
<Entry>
<InstanceGuid>00000000-0000-0000-0000-000000000000</InstanceGuid>
<ProductGuid>...</ProductGuid>
<MapTo>0</MapTo>
<PadSettingChecksum>A1B2C3D4</PadSettingChecksum>
</Entry>
</Entries>
<ProfilePadSettings>
<PadSetting>
<!-- Full PadSetting structure as above -->
</PadSetting>
</ProfilePadSettings>
<ProfileMacros>
<Macro PadIndex="0"><!-- ... --></Macro>
</ProfileMacros>
<ProfileSlotCreated>
<Created>true</Created>
<Created>false</Created>
</ProfileSlotCreated>
<ProfileSlotEnabled>
<Enabled>true</Enabled>
<Enabled>true</Enabled>
</ProfileSlotEnabled>
<ProfileSlotControllerTypes>
<Type>0</Type>
<Type>1</Type>
</ProfileSlotControllerTypes>
<ProfileExtendedConfigs>
<ExtendedConfig SlotIndex="2"><!-- ... --></ExtendedConfig>
</ProfileExtendedConfigs>
<ProfileMidiConfigs>
<MidiConfig SlotIndex="3" Channel="1" CcCount="6" StartCc="1" NoteCount="11" StartNote="60" Velocity="127" />
</ProfileMidiConfigs>
<EnableDsuMotionServer>false</EnableDsuMotionServer>
<DsuMotionServerPort>26760</DsuMotionServerPort>
<EnableWebController>false</EnableWebController>
<WebControllerPort>8080</WebControllerPort>
</Profile>
</Profiles>
</PadForgeSettings>-
PadSettings deduplicated by checksum. Multiple UserSettings may reference the same
PadSettingChecksum; only one copy is serialized. Keeps the file small when devices share identical mappings. -
All PadSetting mapping/numeric properties are string-typed. Matches the original x360ce XML format. Empty strings represent "unmapped."
-
Profiles are self-contained snapshots. Each
ProfileDatastores its ownPadSettings[],Entries[], slot topology, Extended/MIDI configs, and server settings independently. Switching profiles replaces runtime state wholesale. -
Default profile snapshot preservation. When a named profile is active,
AppSettings.DefaultProfileSnapshotstores the default profile's state for lossless restoration on restart. Root-levelSlotCreated/SlotEnabled/SlotControllerTypesalways represent the default profile; the active named profile's topology overwrites them at load time.
File: PadForge.Engine/Data/UserDevice.cs
Namespace: PadForge.Engine.Data
Implements: INotifyPropertyChanged
Represents a physical input device. Contains serializable (XML-persisted) properties and runtime-only fields for the input pipeline.
| Property | Type | XML Element | Description |
|---|---|---|---|
InstanceGuid |
Guid |
<InstanceGuid> |
Deterministic GUID from device path. Unique per USB port + device. |
InstanceName |
string |
<InstanceName> |
Instance name (e.g., "Xbox Controller"). May differ from ProductName. |
ProductGuid |
Guid |
<ProductGuid> |
Product GUID (PIDVID format). Fallback when instance GUIDs change (e.g., different USB port). |
ProductName |
string |
<ProductName> |
Product name. |
VendorId |
ushort |
<VendorId> |
USB Vendor ID (e.g., 1118 = Microsoft). |
ProdId |
ushort |
<ProdId> |
USB Product ID. |
DevRevision |
ushort |
<DevRevision> |
USB revision. Joystick devices only (from SdlDeviceWrapper.ProductVersion). |
DevicePath |
string |
<DevicePath> |
File system device path. Used for InstanceGuid generation. |
SerialNumber |
string |
<SerialNumber> |
Serial number (e.g., Bluetooth MAC). Empty if unavailable. |
| Property | Type | XML Element | Description |
|---|---|---|---|
CapAxeCount |
int |
<CapAxeCount> |
Axis count. |
CapButtonCount |
int |
<CapButtonCount> |
Button count (gamepad-mapped count for gamepads, always 11). |
RawButtonCount |
int |
<RawButtonCount> |
Raw joystick button count before gamepad remapping. Higher than CapButtonCount on gamepads (exposes extras like DualSense touchpad click). Equals CapButtonCount on non-gamepads. |
CapPovCount |
int |
<CapPovCount> |
POV hat count. |
CapType |
int |
<CapType> |
InputDeviceType static class constants (17=Device, 18=Mouse, 19=Keyboard, 20=Joystick, 21=Gamepad, 22=Driving, 23=Flight, 24=FirstPerson, 25=Supplemental). Values match DirectInput. |
CapSubType |
int |
<CapSubType> |
Subtype (SDL unavailable, always 0). |
CapFlags |
int |
<CapFlags> |
Capability flags (SDL unavailable, always 0). |
HasGyro |
bool |
<HasGyro> |
Has gyroscope (e.g., DualSense, Switch Pro). |
HasAccel |
bool |
<HasAccel> |
Has accelerometer. |
DeviceObjects |
DeviceObjectItem[] |
<DeviceObjects> |
Axis/button/hat metadata. Populated in Step 1 and persisted so mapping dropdowns remain populated when devices are offline. |
| Property | Type | XML Element | Description |
|---|---|---|---|
DateCreated |
DateTime |
<DateCreated> |
Record creation time (set in constructor). |
DateUpdated |
DateTime |
<DateUpdated> |
Last update time (set by LoadInstance()/LoadCapabilities()). |
IsEnabled |
bool |
<IsEnabled> |
Enabled for mapping (default: true). |
IsHidden |
bool |
<IsHidden> |
Hidden from UI. Remains in SettingsManager but filtered from device list. |
DisplayName |
string |
<DisplayName> |
User-assigned name. Overrides InstanceName in UI when set. |
HidHideEnabled |
bool |
<HidHideEnabled> |
Hide from games via HidHide driver (default: false). |
ConsumeInputEnabled |
bool |
<ConsumeInputEnabled> |
Suppress mapped inputs via low-level hooks (default: false). Keyboards/mice only. |
| XML Element | Type | Default | Description |
|---|---|---|---|
HidHideEnabled |
bool |
false |
Hide device from games via HidHide |
ConsumeInputEnabled |
bool |
false |
Suppress mapped keyboard/mouse inputs via hooks |
ForceRawJoystickMode |
bool |
false |
Bypass SDL3 gamepad remapping |
HidHideInstanceIds |
string[] |
empty | Cached HID instance IDs for offline blacklisting |
| Property | Type | Description |
|---|---|---|
Device |
ISdlInputDevice |
Opened SDL device wrapper. Live handle for reads and rumble. Set in Step 1, cleared on disconnect. |
IsOnline |
bool |
Physically connected and opened. |
InputState |
CustomInputState |
Current state snapshot. Written by background thread (Step 2), read by UI. Atomic reference assignment. |
InputUpdates |
CustomInputUpdate[] |
Buffered updates since last poll cycle. |
OldInputState |
CustomInputState |
Previous state for change detection. |
OrgInputState |
CustomInputState |
State at recording start (for recorder delta detection). |
AxeMask |
int |
Bitmask of present axes (bit N = axis N exists). |
ActuatorMask |
int |
Bitmask of FFB actuator axes. |
ActuatorCount |
int |
FFB actuator axis count. |
SliderMask |
int |
Bitmask of present sliders. |
DeviceEffects |
DeviceEffectItem[] |
Rumble capability metadata. |
ForceFeedbackState |
ForceFeedbackState |
FFB/haptic state tracker. Created for devices with rumble or haptic. |
| Property | Type | Description |
|---|---|---|
IsMouse |
bool |
CapType == InputDeviceType.Mouse |
IsKeyboard |
bool |
CapType == InputDeviceType.Keyboard |
HasForceFeedback |
bool |
ActuatorCount > 0 || Device.HasRumble || Device.HasHaptic |
ResolvedName |
string |
DisplayName > InstanceName > ProductName > "(Unknown Device)" |
StatusText |
string |
"Disabled", "Online", or "Offline" |
public void LoadFromSdlDevice(SdlDeviceWrapper wrapper)Entry point for joystick/gamepad devices. Calls LoadFromDevice() and sets DevRevision.
public void LoadFromKeyboardDevice(SdlKeyboardWrapper wrapper)
public void LoadFromMouseDevice(SdlMouseWrapper wrapper)Entry points for keyboard and mouse. Call LoadFromDevice().
private void LoadFromDevice(ISdlInputDevice wrapper)Shared logic:
-
LoadInstance(). Identity (InstanceGuid, Name, ProductGuid) -
LoadCapabilities(). Axes, buttons, hats, type RawButtonCount = Math.Max(wrapper.RawButtonCount, wrapper.NumButtons)- Sensor flags (
HasGyro,HasAccel) -
VendorId,ProdId,DevicePath,SerialNumber - Build
DeviceObjectsandDeviceEffects - Compute
AxeMask,ActuatorMask,SliderMask - Create
ForceFeedbackStateif rumble/haptic supported - Store wrapper as
Device
public void ClearRuntimeState()Called on disconnect. Nulls runtime fields, sets IsOnline = false, raises NotifyStateChanged().
File: PadForge.Engine/Data/UserSetting.cs
Namespace: PadForge.Engine.Data
Implements: INotifyPropertyChanged
Links a physical device (InstanceGuid) to a virtual controller slot (MapTo) and a mapping configuration (PadSettingChecksum). One UserSetting per device-to-slot assignment.
| Property | Type | XML Element | Default | Description |
|---|---|---|---|---|
InstanceGuid |
Guid |
<InstanceGuid> |
Guid.Empty |
Must match UserDevice.InstanceGuid. |
InstanceName |
string |
<InstanceName> |
"" |
Name at creation time. Shown when device is offline. |
ProductGuid |
Guid |
<ProductGuid> |
Guid.Empty |
Fallback matching when instance GUIDs change (e.g., different USB port). |
ProductName |
string |
<ProductName> |
"" |
Product name. |
MapTo |
int |
<MapTo> |
-1 |
Slot index (0–15). -1 = unmapped. Fires PropertyChanged. |
PadSettingChecksum |
string |
<PadSettingChecksum> |
"" |
Links to a PadSetting record. Multiple UserSettings can share one checksum. |
IsEnabled |
bool |
<IsEnabled> |
true |
Active mapping. Disabled mappings are skipped. |
DateCreated |
DateTime |
<DateCreated> |
DateTime.Now |
Creation time. |
DateUpdated |
DateTime |
<DateUpdated> |
DateTime.Now |
Last modified time. |
| Property | Type | Description |
|---|---|---|
OutputState |
Gamepad |
Mapped output from Step 3. Written by background thread, read by Step 4 and UI. |
RawMappedState |
Gamepad |
Axis-selected and Y-negated but before deadzone/anti-deadzone/linear/max range. Used by UI preview to avoid double-processing. |
ExtendedRawOutputState |
ExtendedRawState |
Raw Extended (HIDMaestro custom-HID) output. Populated only for Extended slots whose layout is in the custom (non-gamepad) shape (SlotExtendedIsCustom == true). |
MidiRawOutputState |
MidiRawState |
Raw MIDI output. Populated only for MIDI slots. |
KbmRawOutputState |
KbmRawState |
Raw KB+M output. Populated only for KeyboardMouse slots. |
_cachedPadSetting |
PadSetting (internal) |
Cached PadSetting. Set during load, accessed via GetPadSetting()/SetPadSetting(). |
Multiple UserSettings can share the same InstanceGuid with different MapTo values, letting one physical device feed multiple virtual controllers. Each assignment has an independent PadSetting (cloned during load to prevent shared-mutation bugs).
Example: DualSense assigned to slot 0 and slot 2 produces two entries:
{ InstanceGuid = "abc...", MapTo = 0, PadSettingChecksum = "X1Y2Z3A4" }{ InstanceGuid = "abc...", MapTo = 2, PadSettingChecksum = "B5C6D7E8" }
File: PadForge.Engine/Data/PadSetting.cs
Namespace: PadForge.Engine.Data
Partial class
Complete mapping configuration for a device-to-slot assignment. All mapping properties are string-typed descriptors consumed by InputManager Step 3.
[XmlElement]
public string PadSettingChecksum { get; set; } = string.Empty;8-character uppercase hex string from an MD5 hash of all mapping/setting properties. Serves three purposes:
- Linking: UserSettings reference PadSettings by checksum (not index or GUID)
- Deduplication: On save, identical checksums serialize only once
- Change detection: Any property change produces a new checksum
ComputeChecksum() builds a pipe-delimited string from all behavior-affecting properties in fixed order:
- Button mappings (11): ButtonA, ButtonB, ButtonX, ButtonY, LeftShoulder, RightShoulder, ButtonBack, ButtonStart, ButtonGuide, LeftThumbButton, RightThumbButton
- D-Pad (5): DPad, DPadUp, DPadDown, DPadLeft, DPadRight
- Triggers (8): LeftTrigger, RightTrigger, LeftTriggerDeadZone, RightTriggerDeadZone, LeftTriggerAntiDeadZone, RightTriggerAntiDeadZone, LeftTriggerMaxRange, RightTriggerMaxRange
- Thumbstick axes (8): LeftThumbAxisX, LeftThumbAxisY, RightThumbAxisX, RightThumbAxisY, LeftThumbAxisXNeg, LeftThumbAxisYNeg, RightThumbAxisXNeg, RightThumbAxisYNeg
- Deadzones (32): LeftThumbDeadZoneX/Y, RightThumbDeadZoneX/Y, LeftThumbDeadZoneShape, RightThumbDeadZoneShape, LeftThumbAntiDeadZone, RightThumbAntiDeadZone, LeftThumbAntiDeadZoneX/Y, RightThumbAntiDeadZoneX/Y, LeftThumbLinear, RightThumbLinear, all 6 sensitivity curves, all 8 max range properties, all 4 center offset properties
- Force feedback (5): ForceType, ForceOverall, ForceSwapMotor, LeftMotorStrength, RightMotorStrength
- Audio bass rumble (5): AudioRumbleEnabled, AudioRumbleSensitivity, AudioRumbleCutoffHz, AudioRumbleLeftMotor, AudioRumbleRightMotor
- Axis inversion (4): LeftThumbAxisXInvert, LeftThumbAxisYInvert, RightThumbAxisXInvert, RightThumbAxisYInvert
- Threshold (1): AxisToButtonThreshold
-
Extended custom mappings. Dictionary entries sorted by key (
StringComparer.Ordinal), formatted askey=value| - MIDI custom mappings. Same sorted key=value format
- KBM custom mappings. Same sorted key=value format
-
Mapping deadzones. Same sorted key=value format (from
MappingDeadZonesdictionary), prefixed withMDZ:in the checksum string
The string is UTF-8 encoded, hashed with MD5.HashData(), and the first 4 bytes returned as 8-char uppercase hex:
byte[] hash = MD5.HashData(Encoding.UTF8.GetBytes(sb.ToString()));
return BitConverter.ToString(hash, 0, 4).Replace("-", "").ToUpperInvariant();Not included in the checksum: PadSettingChecksum itself.
During SaveToFile(), PadSettings are deduplicated by checksum via HashSet<string>:
var seen = new HashSet<string>();
var uniquePadSettings = new List<PadSetting>();
foreach (var us in UserSettings.Items)
{
var ps = us.GetPadSetting();
if (ps != null && seen.Add(ps.PadSettingChecksum))
uniquePadSettings.Add(ps);
}
data.PadSettings = uniquePadSettings.ToArray();The XML stores N unique PadSettings where N <= UserSettings count. Multiple <Setting> elements can reference the same <PadSettingChecksum>.
All mapping properties use string descriptors parsed by Step 3:
| Format | Example | Description |
|---|---|---|
"Button N" |
"Button 0" |
Button at index N |
"Axis N" |
"Axis 1" |
Full-range axis at index N |
"IAxis N" |
"IAxis 1" |
Inverted axis |
"HAxis N" |
"HAxis 2" |
Half-axis, 0–100% range |
"IHAxis N" |
"IHAxis 2" |
Inverted half-axis |
"Slider N" |
"Slider 0" |
Slider at index N |
"POV N Dir" |
"POV 0 Up" |
POV hat at index N, direction: Up, Down, Left, Right, UpRight, UpLeft, DownRight, DownLeft |
"" |
"" |
Unmapped (empty string) |
Prefixes:
- I (Invert): Flips axis direction. Applied by recorder auto-inversion.
- H (Half): Maps full-range axis to 0–100% (triggers and unidirectional mappings).
All [XmlElement], all string, all default to "":
| Property | Description |
|---|---|
ButtonA |
A / Cross button |
ButtonB |
B / Circle button |
ButtonX |
X / Square button |
ButtonY |
Y / Triangle button |
LeftShoulder |
LB / L1 |
RightShoulder |
RB / R1 |
ButtonBack |
Back / Share / Select |
ButtonStart |
Start / Options |
ButtonGuide |
Guide / PS button |
LeftThumbButton |
LS / L3 (left stick press) |
RightThumbButton |
RS / R3 (right stick press) |
| Property | Default | Description |
|---|---|---|
DPad |
"" |
Combined D-Pad. "POV 0" auto-extracts all four directions in Step 3. |
DPadUp |
"" |
D-Pad up override |
DPadDown |
"" |
D-Pad down override |
DPadLeft |
"" |
D-Pad left override |
DPadRight |
"" |
D-Pad right override |
Individual DPadUp/Down/Left/Right override the combined DPad property.
| Property | Default | Description |
|---|---|---|
LeftTrigger |
"" |
Left trigger source mapping |
RightTrigger |
"" |
Right trigger source mapping |
LeftTriggerDeadZone |
"0" |
Deadzone (0–100%). Below threshold treated as zero. |
RightTriggerDeadZone |
"0" |
Deadzone (0–100%). |
LeftTriggerAntiDeadZone |
"0" |
Anti-deadzone (0–100%). Offsets output minimum past game's built-in deadzone. |
RightTriggerAntiDeadZone |
"0" |
Anti-deadzone (0–100%). |
LeftTriggerMaxRange |
"100" |
Max range (1–100%). Full press maps to this ceiling. |
RightTriggerMaxRange |
"100" |
Max range (1–100%). |
| Property | Default | Description |
|---|---|---|
LeftThumbAxisX |
"" |
Left stick X positive direction |
LeftThumbAxisY |
"" |
Left stick Y positive direction |
RightThumbAxisX |
"" |
Right stick X positive direction |
RightThumbAxisY |
"" |
Right stick Y positive direction |
LeftThumbAxisXNeg |
"" |
Left stick X negative direction (for button-to-axis mappings) |
LeftThumbAxisYNeg |
"" |
Left stick Y negative direction |
RightThumbAxisXNeg |
"" |
Right stick X negative direction |
RightThumbAxisYNeg |
"" |
Right stick Y negative direction |
"Neg" variants map separate physical inputs to opposite directions of one virtual axis (e.g., two buttons to left/right stick).
| Property | Default | Description |
|---|---|---|
LeftThumbDeadZoneX |
"0" |
Left stick DZ X (0–100%) |
LeftThumbDeadZoneY |
"0" |
Left stick DZ Y (0–100%) |
RightThumbDeadZoneX |
"0" |
Right stick DZ X (0–100%) |
RightThumbDeadZoneY |
"0" |
Right stick DZ Y (0–100%) |
LeftThumbAntiDeadZone |
"0" |
Legacy unified ADZ (migrated to per-axis on load) |
RightThumbAntiDeadZone |
"0" |
Legacy unified ADZ |
LeftThumbAntiDeadZoneX |
"0" |
Left stick ADZ X (0–100%) |
LeftThumbAntiDeadZoneY |
"0" |
Left stick ADZ Y (0–100%) |
RightThumbAntiDeadZoneX |
"0" |
Right stick ADZ X (0–100%) |
RightThumbAntiDeadZoneY |
"0" |
Right stick ADZ Y (0–100%) |
LeftThumbLinear |
"0" |
Left stick linearity (0–100). 0 = default curve, 100 = fully linear. |
RightThumbLinear |
"0" |
Right stick linearity (0–100). |
| Property | Default | Description |
|---|---|---|
LeftThumbDeadZoneShape |
"2" |
Left stick DZ shape (see enum below). Default 2 = ScaledRadial. |
RightThumbDeadZoneShape |
"2" |
Right stick DZ shape. |
DeadZoneShape enum (PadForge.Engine/Data/DeadZoneShape.cs):
| Value | Name | Description |
|---|---|---|
| 0 | Axial |
Per-axis (square). Legacy. |
| 1 | Radial |
Circular magnitude, no rescaling. |
| 2 | ScaledRadial |
Circular magnitude with rescaling (industry standard). Default. |
| 3 | SlopedAxial |
DZ grows on one axis as the other increases. |
| 4 | SlopedScaledAxial |
Sloped thresholds with rescaling. |
| 5 | Hybrid |
ScaledRadial then SlopedScaledAxial. |
| Property | Default | Description |
|---|---|---|
LeftThumbSensitivityCurveX |
"0" |
Left stick X curve. Format: "0,0;0.5,0.2;1,1" (semicolon-separated control points). "0" or "0,0;1,1" = linear. |
LeftThumbSensitivityCurveY |
"0" |
Left stick Y curve. |
RightThumbSensitivityCurveX |
"0" |
Right stick X curve. |
RightThumbSensitivityCurveY |
"0" |
Right stick Y curve. |
LeftTriggerSensitivityCurve |
"0" |
Left trigger curve. |
RightTriggerSensitivityCurve |
"0" |
Right trigger curve. |
| Property | Default | Description |
|---|---|---|
LeftThumbCenterOffsetX |
"0" |
Left stick X center offset (-100–100%). Subtracted before DZ processing. |
LeftThumbCenterOffsetY |
"0" |
Left stick Y center offset |
RightThumbCenterOffsetX |
"0" |
Right stick X center offset |
RightThumbCenterOffsetY |
"0" |
Right stick Y center offset |
LeftThumbMaxRangeX |
"100" |
Left stick X max range (1–100%). Full deflection maps to this ceiling. |
LeftThumbMaxRangeY |
"100" |
Left stick Y max range |
RightThumbMaxRangeX |
"100" |
Right stick X max range |
RightThumbMaxRangeY |
"100" |
Right stick Y max range |
LeftThumbMaxRangeXNeg |
(null) | Left stick X negative max range (1–100%). Null = symmetric. |
LeftThumbMaxRangeYNeg |
(null) | Left stick Y negative max range. Null = symmetric. |
RightThumbMaxRangeXNeg |
(null) | Right stick X negative max range. Null = symmetric. |
RightThumbMaxRangeYNeg |
(null) | Right stick Y negative max range. Null = symmetric. |
| Property | Default | Description |
|---|---|---|
ForceType |
"1" |
0 = Off, 1 = SDL Rumble. |
ForceOverall |
"100" |
Overall gain (0–100%). Multiplier for both motors. |
ForceSwapMotor |
"0" |
Swap left/right motors. 0 = no, 1 = yes. |
LeftMotorStrength |
"100" |
Left (low-freq) motor (0–100%). |
RightMotorStrength |
"100" |
Right (high-freq) motor (0–100%). |
| Property | Default | Description |
|---|---|---|
AudioRumbleEnabled |
"0" |
Audio bass rumble. 0 = off, 1 = on. |
AudioRumbleSensitivity |
"4" |
Bass sensitivity (higher = more reactive). |
AudioRumbleCutoffHz |
"80" |
Low-pass cutoff (Hz) for bass extraction. |
AudioRumbleLeftMotor |
"100" |
Left motor for audio rumble (0–100%). |
AudioRumbleRightMotor |
"100" |
Right motor for audio rumble (0–100%). |
| Property | Default | Description |
|---|---|---|
LeftThumbAxisXInvert |
"0" |
Invert left stick X. 0 or 1. |
LeftThumbAxisYInvert |
"0" |
Invert left stick Y. |
RightThumbAxisXInvert |
"0" |
Invert right stick X. |
RightThumbAxisYInvert |
"0" |
Invert right stick Y. |
AxisToButtonThreshold |
"50" |
Axis-to-button threshold (0–100%). Axis must exceed this to register as pressed. |
Extended, MIDI, and KB+M use dictionary-based storage for arbitrary key counts. All three share ExtendedMappingEntry as the serialization type and follow the same pattern: in-memory Dictionary<string, string> backed by a serializable ExtendedMappingEntry[]. Dictionary is lazily populated on first access and flushed to the array before serialization.
Extended (HIDMaestro custom-HID) slot with arbitrary axis/button/POV counts. Keys:
-
ExtendedAxis0,ExtendedAxis0Neg. Axis mappings (positive and negative directions) -
ExtendedBtn0,ExtendedBtn5. Button mappings -
ExtendedPov0Up,ExtendedPov0Down,ExtendedPov0Left,ExtendedPov0Right. POV directions -
ExtendedStick{N}DzX,ExtendedStick{N}DzY,ExtendedStick{N}AdzX, etc.. Per-stick deadzone/calibration settings
[XmlArray("ExtendedMappings")]
[XmlArrayItem("Map")]
public ExtendedMappingEntry[] ExtendedMappingEntries { get; set; }MIDI output. Keys:
-
MidiCC0,MidiCC0Neg. CC (Control Change) mappings for axes -
MidiNote0,MidiNote5. Note mappings for buttons
[XmlArray("MidiMappings")]
[XmlArrayItem("Map")]
public ExtendedMappingEntry[] MidiMappingEntries { get; set; }Keyboard+Mouse output. Keys:
-
KbmKey{VK:X2}. Keyboard key by VK hex (e.g.,KbmKey41= VK_A) -
KbmMouseX/XNeg/Y/YNeg. Mouse movement axes -
KbmMBtn0–KbmMBtn4. Mouse buttons (LMB, RMB, MMB, X1, X2) -
KbmScroll,KbmScrollNeg. Scroll wheel
[XmlArray("KbmMappings")]
[XmlArrayItem("Map")]
public ExtendedMappingEntry[] KbmMappingEntries { get; set; }Per-mapping deadzone overrides. Each entry sets the deadzone threshold for a specific target mapping, overriding the global AxisToButtonThreshold. Keys are target setting names from any VC type:
-
ButtonA,DPadUp,LeftShoulder. Standard gamepad mappings -
ExtendedBtn0,ExtendedPov0Up. Extended mappings -
KbmKey41,KbmMBtn0. KB+M mappings -
MidiNote0. MIDI mappings
Values are deadzone percentages 0–100. Entries at the default value (50) are not stored. Only non-default overrides are serialized.
<MappingDeadZones>
<Map><Key>ButtonA</Key><Value>30</Value></Map>
<Map><Key>DPadUp</Key><Value>75</Value></Map>
</MappingDeadZones>[XmlArray("MappingDeadZones")]
[XmlArrayItem("Map")]
public ExtendedMappingEntry[] MappingDeadZoneEntries { get; set; }| Method | Description |
|---|---|
int GetMappingDeadZone(string key) |
Get deadzone for a target mapping. Returns 50 (default) if not found. |
void SetMappingDeadZone(string key, int value) |
Set deadzone for a target mapping. Values equal to 50 remove the entry. |
void FlushMappingDeadZones() |
Flush dictionary to the serializable array. Must be called before serialization. |
Lazily initialized from the array via EnsureMappingDeadZoneDict() (double-checked locking). JSON key: __MappingDeadZones.
public class ExtendedMappingEntry
{
[XmlAttribute] public string Key { get; set; } = "";
[XmlAttribute] public string Value { get; set; } = "";
}| Method | Description |
|---|---|
string Get{Type}Mapping(string key) |
Get descriptor by key. Returns empty string if not found. |
void Set{Type}Mapping(string key, string value) |
Set descriptor by key. Empty/null values remove the entry. |
void Flush{Type}Mappings() |
Flush dictionary to the serializable array. Must be called before serialization. |
Where {Type} is Extended, Midi, or Kbm. Lazily initialized from the array via Ensure{Type}Dict() (double-checked locking).
Static array defining which properties participate in CopyFrom(), ToJson(), and FromJson(). Includes all user-facing configuration; excludes identity and metadata.
Complete list (79 properties):
| Category | Properties |
|---|---|
| Buttons (11) |
ButtonA, ButtonB, ButtonX, ButtonY, LeftShoulder, RightShoulder, ButtonBack, ButtonStart, ButtonGuide, LeftThumbButton, RightThumbButton
|
| D-Pad (5) |
DPad, DPadUp, DPadDown, DPadLeft, DPadRight
|
| Triggers (8) |
LeftTrigger, RightTrigger, LeftTriggerDeadZone, RightTriggerDeadZone, LeftTriggerAntiDeadZone, RightTriggerAntiDeadZone, LeftTriggerMaxRange, RightTriggerMaxRange
|
| Stick axes (8) |
LeftThumbAxisX, LeftThumbAxisY, RightThumbAxisX, RightThumbAxisY, LeftThumbAxisXNeg, LeftThumbAxisYNeg, RightThumbAxisXNeg, RightThumbAxisYNeg
|
| Deadzones (14) |
LeftThumbDeadZoneX, LeftThumbDeadZoneY, RightThumbDeadZoneX, RightThumbDeadZoneY, LeftThumbDeadZoneShape, RightThumbDeadZoneShape, LeftThumbAntiDeadZone, RightThumbAntiDeadZone, LeftThumbAntiDeadZoneX, LeftThumbAntiDeadZoneY, RightThumbAntiDeadZoneX, RightThumbAntiDeadZoneY, LeftThumbLinear, RightThumbLinear
|
| Sensitivity curves (6) |
LeftThumbSensitivityCurveX, LeftThumbSensitivityCurveY, RightThumbSensitivityCurveX, RightThumbSensitivityCurveY, LeftTriggerSensitivityCurve, RightTriggerSensitivityCurve
|
| Max range (8) |
LeftThumbMaxRangeX, LeftThumbMaxRangeY, RightThumbMaxRangeX, RightThumbMaxRangeY, LeftThumbMaxRangeXNeg, LeftThumbMaxRangeYNeg, RightThumbMaxRangeXNeg, RightThumbMaxRangeYNeg
|
| Center offset (4) |
LeftThumbCenterOffsetX, LeftThumbCenterOffsetY, RightThumbCenterOffsetX, RightThumbCenterOffsetY
|
| Force feedback (5) |
ForceType, ForceOverall, ForceSwapMotor, LeftMotorStrength, RightMotorStrength
|
| Audio bass rumble (5) |
AudioRumbleEnabled, AudioRumbleSensitivity, AudioRumbleCutoffHz, AudioRumbleLeftMotor, AudioRumbleRightMotor
|
| Axis inversion (4) |
LeftThumbAxisXInvert, LeftThumbAxisYInvert, RightThumbAxisXInvert, RightThumbAxisYInvert
|
| Threshold (1) | AxisToButtonThreshold |
Excluded:
-
PadSettingChecksum. Recomputed after copy -
ExtendedMappingEntries,MidiMappingEntries,KbmMappingEntries,MappingDeadZoneEntries. Deep-copied separately inCopyFrom()and serialized separately inToJson()/FromJson()
Usage:
-
CopyFrom(). Copies all properties via reflection, then deep-copies mapping arrays -
ToJson(). Serializes properties +__ExtendedMappings/__MidiMappings/__KbmMappings/__MappingDeadZones+ layout metadata (__OutputType,__IsCustomExtended) for clipboard -
FromJson(). Deserializes JSON, extracts layout metadata for cross-layout paste
| Method | Signature | Description |
|---|---|---|
CloneDeep |
PadSetting CloneDeep() |
Deep clone via CopyFrom(this), plus PadSettingChecksum. |
CopyFrom |
void CopyFrom(PadSetting source) |
Reflection copy of all CopyablePropertyNames. Deep-copies mapping arrays, invalidates cached dicts. |
CopyFromTranslated |
void CopyFromTranslated(PadSetting source, ...) |
Cross-layout copy with positional translation (see Cross-Layout Mapping Translation). |
ToJson |
string ToJson(VirtualControllerType, bool) |
JSON for clipboard. Includes layout metadata. |
FromJson |
static PadSetting FromJson(string json, out ...) |
Deserialize JSON. Returns null on invalid input. Extracts layout metadata. |
ClearMappingDescriptors |
void ClearMappingDescriptors() |
Clears all mapping descriptors; preserves DZ, FFB, and other config. |
GetAllMappingDescriptors |
List<string> GetAllMappingDescriptors() |
All non-empty descriptors from standard, Extended, and MIDI. |
HasAnyMapping |
bool (property) |
True if any mapping property has a non-empty descriptor. |
| Method | Description |
|---|---|
MigrateAntiDeadZones() |
Migrates unified ADZ to per-axis X/Y. Only runs when per-axis is empty/zero and unified is non-zero. Idempotent. |
MigrateMaxRangeDirections() |
Copies symmetric max range to per-direction properties when negative-direction is null/empty. |
File: PadForge.Engine/Data/MappingSet.cs
Per-virtual-controller mapping store. One MappingSet per slot, persisted under <SlotMappingSets> in PadForge.xml. Replaces the per-device button/axis dictionaries that earlier PadSetting builds carried. The migrator (MappingSetMigrator) converts pre-3.2 settings on load.
| Element | Type | Description |
|---|---|---|
<Layers> |
MappingLayer[] |
One entry per shift layer. Always contains a Base layer (LayerMask = "Base"); additional shift layers append. |
<Activators> |
ShiftActivator[] |
One entry per non-Base shift layer. Defines the input that engages the layer, the mode, color, emoji, and debounce. |
<DefaultCombineModes> |
dictionary | Per-output-category default combine mode for auto-mapping (buttons / D-pad → Either; sticks / triggers → Strongest). |
Each MappingLayer is a list of MappingRow entries with a LayerMask tag.
File: PadForge.Engine/Data/MappingRow.cs
| Element | Type | Description |
|---|---|---|
<Output> |
string |
Virtual output target ("A", "LeftStickX", "DPadUp"). |
<Sources> |
MappingSource[] |
Physical inputs feeding this row. Letter-tagged a, b, c, ... in order. |
<Combine> |
CombineMode |
Strongest, Combined, Average, Either, Both, OnlyOne, Custom. |
<Formula> |
string |
Custom-mode formula. Empty unless Combine == Custom. |
<LayerMask> |
string |
Layer this row belongs to. Base for the Base layer, else the activator's mask. |
<NoInherit> |
bool |
When Combine != Custom and InheritUnmapped is on at the layer level, this row still blocks fallthrough. |
File: PadForge.Engine/Data/MappingSource.cs
| Element | Type | Description |
|---|---|---|
<DeviceGuid> |
Guid |
Physical device instance GUID. |
<Descriptor> |
string |
Input descriptor ("Button 0", "Axis 1", "POV 0 Up", "Gyro Pitch", etc.). |
<Kind> |
SourceKind |
Direct, Incremental, InvertOnHold. |
<Invert> |
bool |
Flip sign before combine. |
<Half> |
bool |
Bipolar axis treated as half-range. |
<Bidirectional> |
bool |
Half-axis fires past deadzone on either side of center. |
<Deadzone> |
int |
Per-source axis-to-button activation threshold (0–100). |
<GyroSensitivity> |
float |
Gyro-source-only per-row multiplier on the calibrated rate. |
<IncrementalRate> / <IncrementalSticky> / <IncrementalMin> / <IncrementalMax>
|
— | Incremental-kind tuning. |
<InvertOnHoldModifier> |
MappingSource |
Modifier-input descriptor for InvertOnHold kind. |
File: PadForge.Engine/Data/ShiftActivator.cs
| Element | Type | Description |
|---|---|---|
<LayerName> |
string |
Display label on the tab and engaged-layer flyout. |
<LayerMask> |
string |
Stable identifier rows tag against. |
<Kind> |
ActivatorKind |
Button, Chord, Axis. |
<Mode> |
ActivatorMode |
Hold, Toggle, Sticky, Cycle, Custom. |
<Input> |
MappingSource |
The input that engages the layer. Cross-device picker uses the same MappingSource shape as MappingRow. |
<ChordSecondInput> |
MappingSource |
Optional second input for Chord kind. |
<AxisThreshold> |
float |
Axis kind: deflection threshold (0.0–1.0). |
<Color> |
string |
Tab and flyout tint. Hex #RRGGBB. |
<EmojiIcon> |
string |
Single grapheme on the flyout. |
<DelayMs> |
int |
Debounce. |
<PostponeMapping> |
bool |
Activator input also fires its own row alongside the layer change. |
<InheritUnmapped> |
bool |
Per-layer overlay-with-fallthrough vs replace. |
<CycleLayerList> |
string |
Cycle-mode pipe-separated layer list. |
<CustomTargetLayer> |
string |
Custom-mode jump target. |
File: PadForge.Engine/Data/MappingSetMigrator.cs
Converts pre-3.2 per-device button/axis dictionaries (still on PadSetting) into v3.2 MappingSet instances on load. Idempotent. Triggers only when <SlotMappingSets> is empty for a slot.
File: PadForge.App/Services/SettingsService.cs (inner class)
Application-level settings stored as a single <AppSettings> element.
| Property | Type | XML Serialization | Default | Description |
|---|---|---|---|---|
AutoStartEngine |
bool |
[XmlElement] |
true |
Auto-start engine on launch |
MinimizeToTray |
bool |
[XmlElement] |
false |
Minimize to tray instead of taskbar |
StartMinimized |
bool |
[XmlElement] |
false |
Start minimized |
StartAtLogin |
bool |
[XmlElement] |
false |
Register as startup app |
EnablePollingOnFocusLoss |
bool |
[XmlElement] |
true |
Continue polling on focus loss |
PollingRateMs |
int |
[XmlElement] |
1 |
Polling interval in ms (~1000 Hz at 1 ms) |
ThemeIndex |
int |
[XmlElement] |
0 |
UI theme index |
Language |
string |
[XmlElement] |
"" |
Language code ("en", "fr", "ja"). Empty = system default. |
EnableAutoProfileSwitching |
bool |
[XmlElement] |
false |
Foreground-based auto profile switching |
ActiveProfileId |
string |
[XmlElement] |
null |
Active named profile ID (null = default) |
SlotControllerTypes |
int[] |
[XmlArray][XmlArrayItem("Type")] |
null |
Per-slot VirtualControllerType (0=Microsoft, 1=PlayStation, 2=Extended, 3=Midi, 4=KeyboardMouse). Numeric values preserved from v2 so existing files load. |
SlotCreated |
bool[] |
[XmlArray][XmlArrayItem("Created")] |
null |
Which slots are created |
SlotEnabled |
bool[] |
[XmlArray][XmlArrayItem("Enabled")] |
null |
Which slots are enabled |
EnableDsuMotionServer |
bool |
[XmlElement] |
false |
DSU/Cemuhook motion server |
DsuMotionServerPort |
int |
[XmlElement] |
26760 |
DSU server port |
EnableWebController |
bool |
[XmlElement] |
false |
Embedded web controller server |
WebControllerPort |
int |
[XmlElement] |
8080 |
Web controller port |
Use2DControllerView |
bool |
[XmlElement] |
false |
2D controller view (instead of 3D) |
EnableInputHiding |
bool |
[XmlElement] |
true |
Master switch for HidHide + hooks. When false, no hiding occurs. |
HidHideWhitelistPaths |
string[] |
[XmlArray][XmlArrayItem("Path")] |
null |
HidHide whitelisted app paths. Null = empty. |
ExtendedConfigs |
ExtendedSlotConfigData[] |
[XmlArray][XmlArrayItem("Config")] |
null |
Per-slot Extended config (preset, axis/trigger/POV/button counts, HIDMaestro OEM/product overrides) |
MidiConfigs |
MidiSlotConfigData[] |
[XmlArray][XmlArrayItem("Config")] |
null |
Per-slot MIDI config (channel, CC/note ranges, velocity) |
DefaultProfileSnapshot |
ProfileData |
[XmlElement] |
null |
Default profile snapshot. Populated only when a named profile is active. See Default Profile Snapshot. |
Root-level SlotCreated/SlotEnabled/SlotControllerTypes/ExtendedConfigs/MidiConfigs must always represent the default profile. Without this, switching back to default would lose its topology.
Save (BuildAppSettings()):
- Default active (
ActiveProfileIdnull): slot arrays from live state,DefaultProfileSnapshot= null. - Named profile active: slot arrays from
SettingsManager.PendingDefaultSnapshot,DefaultProfileSnapshotset to that snapshot.
Load (LoadProfiles()):
- Named profile was active at shutdown:
PendingDefaultSnapshotrestored fromappSettings.DefaultProfileSnapshot. Named profile's topology applied over default's loaded values. -
InputService.Start()usesPendingDefaultSnapshotto initialize_defaultProfileSnapshot.
public class ExtendedSlotConfigData
{
[XmlAttribute] public int SlotIndex { get; set; }
[XmlAttribute] public bool Customize { get; set; }
[XmlAttribute] public int ThumbstickCount { get; set; } = 2;
[XmlAttribute] public int TriggerCount { get; set; } = 2;
[XmlAttribute] public int PovCount { get; set; } = 1;
[XmlAttribute] public int ButtonCount { get; set; } = 11;
[XmlAttribute] public bool OemNameOverride { get; set; }
[XmlAttribute] public string ProductString { get; set; } = string.Empty;
}The v2 Preset attribute (an ExtendedPreset enum: Xbox360 / DualShock4 / Custom) was dropped in commit d57a725. Older v2 PadForge.xml files still containing the attribute deserialize cleanly — the unknown attribute is ignored — and the slot picks up v3 defaults from the field initializers above.
File: PadForge.App/ViewModels/MidiSlotConfig.cs
Per-slot MIDI configuration DTO. Stored in <MidiConfigs>.
public class MidiSlotConfigData
{
[XmlAttribute] public int SlotIndex { get; set; }
[XmlAttribute] public int Channel { get; set; } = 1;
[XmlAttribute] public int CcCount { get; set; } = 6;
[XmlAttribute] public int StartCc { get; set; } = 1;
[XmlAttribute] public int NoteCount { get; set; } = 11;
[XmlAttribute] public int StartNote { get; set; } = 60;
[XmlAttribute] public byte Velocity { get; set; } = 127;
}| Property | Default | Description |
|---|---|---|
SlotIndex |
. | Zero-based pad slot index |
Channel |
1 |
MIDI channel (1–16) |
CcCount |
6 |
CC count (maps to axes) |
StartCc |
1 |
First CC number |
NoteCount |
11 |
Note count (maps to buttons) |
StartNote |
60 |
First note (Middle C) |
Velocity |
127 |
Note-on velocity (0–127) |
Macro configuration DTO. Stored per pad slot via PadIndex attribute.
public class MacroData
{
[XmlAttribute] public int PadIndex { get; set; }
[XmlElement] public string Name { get; set; } = "New Macro";
[XmlElement] public bool IsEnabled { get; set; } = true;
[XmlElement] public ushort TriggerButtons { get; set; }
[XmlElement] public string TriggerDeviceGuid { get; set; }
[XmlElement] public string TriggerRawButtons { get; set; }
[XmlElement] public MacroTriggerSource TriggerSource { get; set; }
[XmlElement] public MacroTriggerMode TriggerMode { get; set; }
[XmlElement] public bool ConsumeTriggerButtons { get; set; } = true;
[XmlElement] public MacroRepeatMode RepeatMode { get; set; }
[XmlElement] public int RepeatCount { get; set; } = 1;
[XmlElement] public int RepeatDelayMs { get; set; } = 100;
[XmlElement] public string TriggerCustomButtons { get; set; }
[XmlElement] public string TriggerAxisTargets { get; set; }
[XmlElement] public int TriggerAxisThreshold { get; set; } = 50;
[XmlArray("TriggerPovs")]
[XmlArrayItem("Pov")]
public string[] TriggerPovs { get; set; }
[XmlArray("Actions")]
[XmlArrayItem("Action")]
public ActionData[] Actions { get; set; }
}| Property | Type | Description |
|---|---|---|
PadIndex |
int (attribute) |
Which pad slot this macro belongs to |
TriggerButtons |
ushort |
Xbox button bitmask for trigger combo |
TriggerDeviceGuid |
string |
Device GUID for raw trigger (N format, no hyphens) |
TriggerRawButtons |
string |
Comma-separated raw button indices (e.g., "13,14") |
TriggerSource |
MacroTriggerSource |
InputDevice or OutputController
|
TriggerMode |
MacroTriggerMode |
OnPress, OnRelease, WhileHeld, or Always
|
ConsumeTriggerButtons |
bool |
Remove trigger buttons from output |
RepeatMode |
MacroRepeatMode |
Once, FixedCount, or UntilRelease
|
TriggerCustomButtons |
string |
Hex-encoded Extended button words (e.g., "00000003,...") |
TriggerAxisTargets |
string |
Comma-separated axis names (e.g., "LeftStickX,LeftTrigger") |
TriggerAxisThreshold |
int |
Axis threshold (1–100%, default 50). Normalized value must exceed to match. |
TriggerPovs |
string[] |
POV directions as "povIndex:centidegrees" (e.g., "0:0" = POV 0 Up). |
public class ActionData
{
[XmlElement] public MacroActionType Type { get; set; }
[XmlElement] public ushort ButtonFlags { get; set; }
[XmlElement] public string CustomButtons { get; set; }
[XmlElement] public int KeyCode { get; set; }
[XmlElement] public string KeyString { get; set; }
[XmlElement] public int DurationMs { get; set; } = 50;
[XmlElement] public short AxisValue { get; set; }
[XmlElement] public MacroAxisTarget AxisTarget { get; set; }
[XmlElement] public MacroAxisSource AxisSource { get; set; }
[XmlElement] public string SourceDeviceGuid { get; set; }
[XmlElement] public int SourceDeviceAxisIndex { get; set; }
[XmlElement] public string ProcessName { get; set; }
[XmlElement] public int VolumeLimit { get; set; } = 100;
[XmlElement] public float MouseSensitivity { get; set; } = 10f;
[XmlElement] public MacroMouseButton MouseButton { get; set; }
[XmlElement] public bool InvertAxis { get; set; }
[XmlElement] public bool ShowVolumeOsd { get; set; } = true;
}| Property | Type | Default | Description |
|---|---|---|---|
Type |
MacroActionType |
. |
ButtonPress, ButtonRelease, KeyPress, KeyRelease, Delay, AxisSet, SystemVolume, AppVolume, MouseMove, MouseButtonPress, MouseButtonRelease, MouseScroll
|
ButtonFlags |
ushort |
0 |
Xbox button flags to press/release |
CustomButtons |
string |
null |
Hex-encoded Extended button words |
KeyCode |
int |
0 |
Virtual key code (single key) |
KeyString |
string |
null |
Multi-key combo as {Key1}{Key2}... (e.g., {LShiftKey}{A}). Overrides KeyCode. |
DurationMs |
int |
50 |
Hold duration in ms |
AxisValue |
short |
0 |
Axis value for axis actions |
AxisTarget |
MacroAxisTarget |
. | Which axis to target (e.g., LeftThumbX) |
AxisSource |
MacroAxisSource |
. |
OutputController (combined) or InputDevice (physical) |
SourceDeviceGuid |
string |
null |
Physical device GUID when AxisSource == InputDevice (N format) |
SourceDeviceAxisIndex |
int |
0 |
Axis index on source device |
ProcessName |
string |
null |
Process name for AppVolume (e.g., "firefox") |
VolumeLimit |
int |
100 |
Max volume % for SystemVolume/AppVolume (1–100) |
MouseSensitivity |
float |
10 |
Pixels/scroll units per frame at full deflection |
MouseButton |
MacroMouseButton |
. |
Left, Right, Middle, X1, X2
|
InvertAxis |
bool |
false |
Invert axis value |
ShowVolumeOsd |
bool |
true |
Show Windows volume flyout on changes |
File: PadForge.App/Services/SettingsService.cs (inner class)
Per-application profile. Complete snapshot of device assignments, PadSettings, macros, slot topology, Extended/MIDI configs, and server settings.
public class ProfileData
{
[XmlAttribute] public string Id { get; set; } = Guid.NewGuid().ToString("N");
[XmlElement] public string Name { get; set; } = "New Profile";
[XmlElement] public string ExecutableNames { get; set; } = string.Empty;
[XmlArray("Entries")][XmlArrayItem("Entry")]
public ProfileEntry[] Entries { get; set; }
[XmlArray("ProfilePadSettings")][XmlArrayItem("PadSetting")]
public PadSetting[] PadSettings { get; set; }
[XmlArray("ProfileMacros")][XmlArrayItem("Macro")]
public MacroData[] Macros { get; set; }
[XmlArray("ProfileSlotCreated")][XmlArrayItem("Created")]
public bool[] SlotCreated { get; set; }
[XmlArray("ProfileSlotEnabled")][XmlArrayItem("Enabled")]
public bool[] SlotEnabled { get; set; }
[XmlArray("ProfileSlotControllerTypes")][XmlArrayItem("Type")]
public int[] SlotControllerTypes { get; set; }
[XmlArray("ProfileExtendedConfigs")][XmlArrayItem("ExtendedConfig")]
public ExtendedSlotConfigData[] ExtendedConfigs { get; set; }
[XmlArray("ProfileMidiConfigs")][XmlArrayItem("MidiConfig")]
public MidiSlotConfigData[] MidiConfigs { get; set; }
[XmlElement] public bool EnableDsuMotionServer { get; set; }
[XmlElement] public int DsuMotionServerPort { get; set; } = 26760;
[XmlElement] public bool EnableWebController { get; set; }
[XmlElement] public int WebControllerPort { get; set; } = 8080;
}| Property | Type | Description |
|---|---|---|
Id |
string (attribute) |
GUID without hyphens |
Name |
string |
Display name |
ExecutableNames |
string |
Pipe-separated exe paths for auto-switching |
Entries |
ProfileEntry[] |
Device-to-slot assignments |
PadSettings |
PadSetting[] |
Deep-cloned, checksum-deduplicated PadSettings |
Macros |
MacroData[] |
Per-slot macros (via PadIndex) |
SlotCreated |
bool[] |
Slot topology. Null on old profiles (topology skipped). |
SlotEnabled |
bool[] |
Slot enabled states. Null on old profiles. |
SlotControllerTypes |
int[] |
Per-slot controller types. Null on old profiles. |
ExtendedConfigs |
ExtendedSlotConfigData[] |
Extended slot configs for this profile |
MidiConfigs |
MidiSlotConfigData[] |
MIDI configs for this profile |
EnableDsuMotionServer |
bool |
DSU server state |
DsuMotionServerPort |
int |
DSU port (default: 26760) |
EnableWebController |
bool |
Web controller state |
WebControllerPort |
int |
Web controller port (default: 8080) |
Device-to-slot link within a profile:
public class ProfileEntry
{
[XmlElement] public Guid InstanceGuid { get; set; }
[XmlElement] public Guid ProductGuid { get; set; }
[XmlElement] public int MapTo { get; set; }
[XmlElement] public string PadSettingChecksum { get; set; }
}ProductGuid enables fallback matching when InstanceGuid changes (e.g., BT reconnect, different USB port).
File: PadForge.App/Services/SettingsService.cs (inner class)
A global (not per-slot) macro entry. Currently used for profile shortcuts and window toggle. Serialized inside <GlobalMacros> in the XML.
public class GlobalMacroData
{
[XmlAttribute] public string Id { get; set; } = Guid.NewGuid().ToString("N");
[XmlElement] public SwitchProfileMode SwitchMode { get; set; }
[XmlElement] public string TargetProfileId { get; set; }
[XmlElement] public Guid TriggerDeviceGuid { get; set; }
[XmlArray("TriggerEntries")][XmlArrayItem("Entry")]
public TriggerButtonEntry[] TriggerEntries { get; set; }
// Legacy migration fields (read-only, cleared after migration)
[XmlArray("TriggerButtons")][XmlArrayItem("Btn")]
public int[] LegacyTriggerRawButtons { get; set; }
}| Property | Type | Description |
|---|---|---|
Id |
string (attribute) |
GUID without hyphens |
SwitchMode |
SwitchProfileMode |
What the shortcut does. See enum below |
TargetProfileId |
string |
For Specific mode: target profile ID. null = default profile. |
TriggerDeviceGuid |
Guid |
Legacy device filter. Guid.Empty = any device. |
TriggerEntries |
TriggerButtonEntry[] |
Per-button device-tracked combo entries |
LegacyTriggerRawButtons |
int[] |
Old flat button indices. Migrated to TriggerEntries on load via MigrateLegacyTrigger(). |
Legacy migration: MigrateLegacyTrigger() converts LegacyTriggerRawButtons into TriggerButtonEntry[], copying the top-level TriggerDeviceGuid into each entry's DeviceInstanceGuid. Runs once on deserialization; the legacy array is left in XML until the next save overwrites it.
public enum SwitchProfileMode
{
Specific, // Switch to a specific profile (TargetProfileId)
Next, // Cycle forward through profiles
Previous, // Cycle backward through profiles
ToggleWindow // Show/hide the main window (no profile change)
}ToggleWindow was added to support controller-based window toggle without a profile switch. The engine sets InputManager.PendingToggleWindow instead of PendingProfileSwitchId.
File: PadForge.App/Services/SettingsService.cs (inner class)
A single input in a global macro trigger combo. Each entry tracks which physical device it was recorded from, enabling cross-device combos (e.g., a gamepad button + a keyboard key).
public class TriggerButtonEntry
{
[XmlElement] public int ButtonIndex { get; set; }
[XmlElement] public Guid DeviceInstanceGuid { get; set; }
[XmlElement] public Guid DeviceProductGuid { get; set; }
[XmlElement] public bool IsAxis { get; set; }
[XmlElement] public int AxisIndex { get; set; }
[XmlElement] public float AxisThreshold { get; set; }
[XmlElement] public AxisTriggerDirection AxisDirection { get; set; }
}| Field | Type | Description |
|---|---|---|
ButtonIndex |
int |
Raw button index on the source device (when IsAxis = false) |
DeviceInstanceGuid |
Guid |
Instance GUID of the device this entry was recorded from |
DeviceProductGuid |
Guid |
Product GUID. Enables fallback matching after reconnect |
IsAxis |
bool |
true = axis trigger, false = button trigger |
AxisIndex |
int |
Axis index on the source device (when IsAxis = true) |
AxisThreshold |
float |
Normalized threshold (0.0–1.0) the axis must exceed |
AxisDirection |
AxisTriggerDirection |
Which direction the axis must deflect |
public enum AxisTriggerDirection
{
Positive, // Axis value above threshold (e.g., stick right, trigger pulled)
Negative // Axis value below 1-threshold (e.g., stick left)
}UpdateActiveProfileSnapshot() captures current runtime state:
-
Entries: Creates
ProfileEntryfor each UserSetting. -
PadSettings: Deep clones (
CloneDeep()), deduplicated by checksum viaHashSet. -
Slot topology: Clones
SlotCreated/SlotEnabled, collectsOutputTypeper PadViewModel. - Type configs: Snapshots Extended/MIDI configs for created slots.
- Server settings: DSU and web controller enable/port states.
Called during Save() after checksum recomputation, so profiles always reflect latest edits.
File: PadForge.App/Common/SettingsManager.cs
Namespace: PadForge.Common.Input
Static partial class (canonical partial in SettingsManager.cs; additional declarations in InputManager.Step1.UpdateDevices.cs for UserDevices/UserSettings and collection class definitions)
Central manager for device records and mapping settings. Shared between engine thread and UI thread.
-
SettingsService.Initialize()creates collections, loads XML. -
InputManager.Step1adds/updates UserDevices on connect/disconnect. -
InputService(UI thread) reads collections to sync ViewModels. -
SettingsService.Save()serializes to XML.
All access to UserDevices and UserSettings must be done inside a lock on the respective collection's SyncRoot:
lock (SettingsManager.UserDevices.SyncRoot)
{
// Safe to iterate/modify UserDevices.Items
}| Property | Type | Description |
|---|---|---|
UserDevices |
DeviceCollection |
All known physical devices. |
UserSettings |
SettingsCollection |
All device-to-slot assignments. |
Profiles |
List<ProfileData> |
Saved profiles. Empty = none. |
ActiveProfileId |
string |
Active named profile ID, or null for default. |
PendingDefaultSnapshot |
ProfileData |
Default profile snapshot from load, before named profile applies. Used by InputService.Start. |
EnableAutoProfileSwitching |
bool |
Auto-switch on foreground app change. |
SlotCreated |
bool[16] |
Which slots are created. Persisted. |
SlotEnabled |
bool[16] |
Which slots are enabled. Default: all true. Persisted. |
| Constant | Value | Description |
|---|---|---|
MaxXbox360Slots |
MaxPads (16) |
Maximum Xbox category virtual controllers (constant name kept from v2) |
MaxPlayStationSlots |
MaxPads (16) |
Maximum PlayStation category virtual controllers |
MaxExtendedSlots |
16 | Maximum Extended virtual controllers |
MaxMidiSlots |
MaxPads (16) |
Maximum MIDI virtual controllers |
MaxKeyboardMouseSlots |
MaxPads (16) |
Maximum Keyboard+Mouse virtual controllers |
All types share a global 16-slot limit. "Add Controller" disappears when all 16 are in use.
Defined in InputManager.Step1.UpdateDevices.cs:
public class DeviceCollection
{
public List<UserDevice> Items { get; } = new();
public object SyncRoot { get; } = new();
}
public class SettingsCollection
{
public List<UserSetting> Items { get; } = new();
public object SyncRoot { get; } = new();
public UserSetting FindByInstanceGuid(Guid guid) { ... }
public List<UserSetting> FindByPadIndex(int padIndex) { ... }
}| Method | Description |
|---|---|
EnsureInitialized() |
Creates collections if null. Safe to call multiple times. |
FindDeviceByInstanceGuid(Guid) |
Thread-safe lookup. Returns null if not found. |
RemoveDevice(Guid) |
Removes device and all associated UserSettings. Thread-safe. Returns true if removed. |
| Method | Description |
|---|---|
FindSettingByInstanceGuid(Guid) |
First UserSetting for device. Thread-safe. |
FindSettingByInstanceGuidAndSlot(Guid, int) |
UserSetting for device+slot pair. Thread-safe. |
AssignDeviceToSlot(Guid, int) |
Create or return existing. Multi-slot: new entry per additional slot. Does NOT create PadSetting. Thread-safe. |
UnassignDevice(Guid) |
Remove all UserSettings for device. Thread-safe. |
ToggleDeviceSlotAssignment(Guid, int) |
Toggle assignment. Returns (bool Assigned, UserSetting). Thread-safe. |
GetAssignedSlots(Guid) |
Sorted slot indices for device. Thread-safe. |
GetSettingsForSlot(int) |
All UserSettings mapped to a slot. Thread-safe. |
public static void SwapSlots(int slotA, int slotB)Swaps all persisted slot data between two indices.
- Swaps
SlotCreated[A]andSlotCreated[B] - Swaps
SlotEnabled[A]andSlotEnabled[B] - Updates all
UserSetting.MapTovalues (A->B, B->A) under lock
public static PadSetting CreateDefaultPadSetting(UserDevice ud,
VirtualControllerType outputType = VirtualControllerType.Microsoft)Creates a default PadSetting with auto-mapped inputs. Only auto-maps when ud.CapType == InputDeviceType.Gamepad and ForceRawJoystickMode is off. Non-gamepads get an empty PadSetting.
MIDI auto-mapping: When outputType == Midi, maps 6 CCs (MidiCC0–MidiCC5 from Axis 0–Axis 5) and 11 notes (MidiNote0–MidiNote10 from Button 0–Button 10).
Standardized SDL3 Gamepad Layout (Xbox / PlayStation / Extended gamepad preset):
| Target | Source | Descriptor |
|---|---|---|
| LeftThumbAxisX | SDL Gamepad Axis 0 (LX) | "Axis 0" |
| LeftThumbAxisY | SDL Gamepad Axis 1 (LY) | "Axis 1" |
| LeftTrigger | SDL Gamepad Axis 2 (LT) | "Axis 2" |
| RightThumbAxisX | SDL Gamepad Axis 3 (RX) | "Axis 3" |
| RightThumbAxisY | SDL Gamepad Axis 4 (RY) | "Axis 4" |
| RightTrigger | SDL Gamepad Axis 5 (RT) | "Axis 5" |
| DPadUp / DPadDown / DPadLeft / DPadRight | SDL Gamepad Hat |
"POV 0 Up" / "POV 0 Down" / "POV 0 Left" / "POV 0 Right"
|
| ButtonA | SDL Gamepad Button 0 (A/Cross) | "Button 0" |
| ButtonB | SDL Gamepad Button 1 (B/Circle) | "Button 1" |
| ButtonX | SDL Gamepad Button 2 (X/Square) | "Button 2" |
| ButtonY | SDL Gamepad Button 3 (Y/Triangle) | "Button 3" |
| LeftShoulder | SDL Gamepad Button 4 (LB/L1) | "Button 4" |
| RightShoulder | SDL Gamepad Button 5 (RB/R1) | "Button 5" |
| ButtonBack | SDL Gamepad Button 6 (Back/Share) | "Button 6" |
| ButtonStart | SDL Gamepad Button 7 (Start/Options) | "Button 7" |
| LeftThumbButton | SDL Gamepad Button 8 (LS/L3) | "Button 8" |
| RightThumbButton | SDL Gamepad Button 9 (RS/R3) | "Button 9" |
| ButtonGuide | SDL Gamepad Button 10 (Guide/PS) | "Button 10" |
Default deadzones are set to 0, force feedback to 100%, no motor swap.
public static void ReAutoMapSlot(int padIndex, VirtualControllerType outputType)Re-automaps all devices assigned to a slot for the given output type. Called when switching VC type. Creates a new default PadSetting per device, updates checksums.
SettingsService.MarkDirty() starts a 250 ms debounce timer. On fire, calls Save() then SaveToFile(filePath):
User action (slider drag, mapping change, etc.)
|
v
SettingsService.MarkDirty()
|-- Sets IsDirty = true
|-- Sets ViewModel.HasUnsavedChanges = true
|-- Starts/restarts 250ms DispatcherTimer
|
... (250ms debounce, timer restarts on each MarkDirty call) ...
|
v
Timer fires -> Save() -> SaveToFile(filePath)
|
v Step 1: UpdatePadSettingsFromViewModels()
| For each PadViewModel (slot 0-15):
| - Find the selected device's UserSetting for this slot
| - Write all ViewModel slider/toggle values back to PadSetting:
| ForceOverall, LeftMotorStrength, RightMotorStrength, ForceSwapMotor,
| AudioRumble settings, deadzones (X/Y), anti-deadzones (X/Y),
| linear, sensitivity curves, max ranges (pos + neg), center offsets,
| trigger deadzones, trigger anti-deadzones, trigger max ranges
| - Write Extended custom stick/trigger settings for indices 2+ via SetExtendedMapping()
| - Write mapping descriptors via SetPadSettingProperty() (reflection or dict)
|
v Step 2: Flush dictionaries and recompute checksums
| For each UserSetting's PadSetting:
| - FlushExtendedMappings() -- dict -> ExtendedMappingEntries[]
| - FlushMidiMappings() -- dict -> MidiMappingEntries[]
| - FlushKbmMappings() -- dict -> KbmMappingEntries[]
| - UpdateChecksum() -- recompute MD5 from all properties
| - Sync: us.PadSettingChecksum = ps.PadSettingChecksum
|
v Step 3: UpdateActiveProfileSnapshot()
| If a named profile is active, write current runtime state back to it:
| entries, PadSettings (deep cloned + deduplicated), slot topology,
| Extended/MIDI configs, DSU/web server settings
|
v Step 4: Collect data under SyncRoot locks
| data.Devices = UserDevices.Items.ToArray()
| data.Settings = UserSettings.Items.ToArray()
| data.PadSettings = unique PadSettings (deduplicated by checksum via HashSet)
|
v Step 5: Build DTOs
| data.AppSettings = BuildAppSettings() -- from SettingsViewModel
| data.Macros = BuildMacroData() -- from all PadViewModels
| data.Profiles = SettingsManager.Profiles.ToArray()
|
v Step 6: XmlSerializer.Serialize(stream, data)
|
v Step 7: IsDirty = false, raise AutoSaved event
After saving, AutoSaved fires so InputService can refresh the default profile snapshot.
SettingsService.LoadFromFile(string filePath):
LoadFromFile(filePath)
|
v Step 1: XmlSerializer.Deserialize(stream) -> SettingsFileData
|
v Step 2: Populate UserDevices
| Lock UserDevices.SyncRoot, clear, add all devices from XML
|
v Step 3: Populate UserSettings with PadSetting cloning
| Lock UserSettings.SyncRoot, clear, then for each UserSetting:
| - Find matching PadSetting by PadSettingChecksum
| - CLONE it via CloneDeep() (critical: prevents shared mutation)
| - SetPadSetting(clone) on the UserSetting
| - Add to UserSettings.Items
|
v Step 4: Purge orphaned UserSettings
| RemoveAll(us => us.MapTo < 0) -- stale entries from older versions
|
v Step 5: LoadAppSettings(data.AppSettings) -- ORDER MATTERS
| a. Load scalar settings (AutoStartEngine, MinimizeToTray, etc.)
| b. SetLanguageFromCode(appSettings.Language)
| c. Sync EnableAutoProfileSwitching and ActiveProfileId
| d. Load SlotCreated[] (MUST be before OutputType!)
| - If null: AutoCreateSlotsFromExistingAssignments()
| e. Load SlotEnabled[] (defaults to all-true on null)
| f. Load SlotControllerTypes[] (only for created slots)
| g. ApplyExtendedConfigs() and ApplyMidiConfigs()
| h. Load DSU/web server settings
|
v Step 6: LoadPadSettings(data.Settings, data.PadSettings)
| For each UserSetting, first device per slot only:
| - Load force feedback settings to PadViewModel
| - Load deadzones (X/Y), call MigrateAntiDeadZones()
| - Load sensitivity curves, max ranges, call MigrateMaxRangeDirections()
| - Load center offsets, trigger settings
| - SyncAllConfigItemsFromVm()
| - Load Extended custom stick/trigger settings for indices 2+
| - LoadMappingDescriptors() -- mapping rows from PadSetting
|
v Step 7: LoadMacros(data.Macros)
| Clear all pad macros, reconstruct from serialized data
|
v Step 8: LoadProfiles(data.Profiles, data.AppSettings)
| Always includes built-in "Default" profile at top
| Add all serialized profiles
| If a named profile was active at shutdown:
| - Restore PendingDefaultSnapshot from appSettings.DefaultProfileSnapshot
| - Apply the named profile's topology (SlotCreated, SlotEnabled, types)
| - Apply the named profile's Extended/MIDI configs
Critical load order: SlotCreated must load before OutputType. Setting OutputType fires PropertyChanged which calls RefreshNavControllerItems() which reads SlotCreated[]. Wrong order causes a double-rebuild crash.
CloneDeep during load is critical: Without cloning, devices sharing a checksum would share one PadSetting instance. Changing one device's deadzone would silently corrupt the other's.
250 ms debounce via DispatcherTimer. Rapid changes (e.g., slider drag) batch into one save:
MarkDirty() called --> start/restart 250ms timer
MarkDirty() called --> restart 250ms timer
MarkDirty() called --> restart 250ms timer
...250ms passes...
Timer fires --> Save() --> AutoSaved event
Source file: PadForge.Engine/Data/MappingTranslation.cs
When copying mappings between different VC types (e.g., Xbox to PlayStation, Extended to MIDI), PadForge translates property names through a canonical positional system.
Each layout maps its property names (e.g., ButtonA, ExtendedBtn0, MidiNote60) to a canonical MappingSlot(Category, Position) where category is Button, Axis, AxisNeg, or DPad. Translation:
- Source property name -> source layout table ->
MappingSlot -
MappingSlot-> target layout table -> target property name
Example: Xbox to Extended with custom layout: ButtonA -> MappingSlot(Button, 0) -> ExtendedBtn0.
| Layout | Types | Example Properties |
|---|---|---|
| Gamepad | Xbox, PlayStation, Extended (gamepad-shape default) |
ButtonA, LeftThumbAxisX, DPadUp
|
| Extended Custom | Extended with a custom layout (non-gamepad axis/button/POV counts) |
ExtendedBtn0, ExtendedAxis0, ExtendedPov0Up
|
| MIDI | MIDI |
MidiNote0, MidiCC0
|
| KB+M | Keyboard + Mouse |
KbmMBtn0, KbmMouseX, KbmKey20
|
The bool isExtended parameter on MappingTranslation methods routes Extended slots into the Extended Custom layout when their layout no longer matches the gamepad shape.
Xbox and PlayStation share property names, so IsSameLayout() skips translation between them.
Translation runs automatically during Pad page copy/paste when controller types differ.
Old files lack <SlotCreated> (null on deserialization). AutoCreateSlotsFromExistingAssignments() scans UserSettings and creates slots for assigned MapTo indices:
private static void AutoCreateSlotsFromExistingAssignments()
{
foreach (var us in settings.Items)
{
int idx = us.MapTo;
if (idx >= 0 && idx < InputManager.MaxPads) // MaxPads = 16
{
SettingsManager.SlotCreated[idx] = true;
SettingsManager.SlotEnabled[idx] = true;
}
}
}SlotCreated/SlotEnabled/SlotControllerTypes use Math.Min(source.Length, target.Length) copy for files saved with a different MaxPads:
int count = Math.Min(appSettings.SlotCreated.Length, SettingsManager.SlotCreated.Length);
Array.Copy(appSettings.SlotCreated, SettingsManager.SlotCreated, count);| Array | Null Default |
|---|---|
SlotEnabled |
All true
|
SlotControllerTypes |
Xbox (Xbox = 0). Uncreated slots skipped to prevent stale values. |
ExtendedConfigs |
One ExtendedSlotConfigData per slot with default counts (ThumbstickCount=2, TriggerCount=2, PovCount=1, ButtonCount=11) and Customize=false. The v2 Preset enum that previously seeded these defaults was dropped in commit d57a725; the same numeric defaults now come from the field initializers. |
MidiConfigs |
Channel 1, 6 CCs at CC 1, 11 notes at note 60, velocity 127 |
MigrateAntiDeadZones() migrates unified LeftThumbAntiDeadZone/RightThumbAntiDeadZone to per-axis X/Y. Only runs when per-axis values are empty/zero and unified is non-zero. Idempotent.
MigrateMaxRangeDirections() copies symmetric max range to negative-direction properties when null/empty (e.g., LeftThumbMaxRangeXNeg = LeftThumbMaxRangeX). Backward-compatible with pre-independent-range files.
Old profiles without topology (SlotCreated == null) skip topology application during switch. Previous slot layout is preserved.
On load, RemoveAll(us => us.MapTo < 0) purges stale entries with MapTo == -1 from older versions.
-
Architecture Overview:
SettingsManagervsSettingsServiceroles, slot system -
Services Layer:
SettingsServiceload/save lifecycle, auto-save, profile CRUD -
Engine Library:
PadSetting,UserDevice,UserSettingdata model definitions -
Input Pipeline: How
SettingsManagerslot arrays andPadSettingdrive the mapping engine -
ViewModels: ViewModel properties synced from
SettingsManagerbySettingsService -
Virtual Controllers: Per-slot
VirtualControllerTypeand Extended/MIDI config serialization