-
Notifications
You must be signed in to change notification settings - Fork 6
SDL3 Integration
PadForge uses SDL3 as its sole input backend for all controller types, including Xbox/XInput. This page covers the P/Invoke layer, device enumeration, state reading, sensors, haptic force feedback, and the custom SDL3 fork for Switch 2 Pro Controller support.
flowchart TD
START[SDL_GetJoysticks<br/>returns uint instanceId array<br/>HM virtuals are already filtered out by the SDL3 fork] --> LOOP{For each instanceId}
LOOP --> OPEN_CHECK{In _openedSdlInstanceIds?}
OPEN_CHECK -->|Yes| SKIP[Skip<br/>already tracked]
OPEN_CHECK -->|No| OPEN[SdlDeviceWrapper.Open]
OPEN --> FORCERAW{ForceRawMode set<br/>for this device?}
FORCERAW -->|Yes| RAW[SDL_OpenJoystick<br/>raw axes · raw buttons · raw hats]
FORCERAW -->|No| GP_CHECK{SDL_IsGamepad?}
GP_CHECK -->|Yes| GAMEPAD[SDL_OpenGamepad<br/>standardized 6 axes · 11 buttons · 1 hat<br/>sensors · mapped button filtering]
GP_CHECK -->|No| JOYSTICK[SDL_OpenJoystick<br/>raw axes · raw buttons · raw hats]
RAW --> TRACK[FindOrCreateUserDevice<br/>LoadFromSdlDevice · IsOnline = true<br/>add to _openedSdlInstanceIds]
GAMEPAD --> TRACK
JOYSTICK --> TRACK
TRACK --> LOOP
style START fill:#e1f5fe
style OPEN fill:#f3e5f5
style GAMEPAD fill:#e8f5e9
style JOYSTICK fill:#fff3e0
style RAW fill:#fff3e0
style TRACK fill:#e8f5e9
style FILTER fill:#fce4ec
style SKIP1 fill:#f5f5f5
style SKIP2 fill:#f5f5f5
style SKIP3 fill:#f5f5f5
Source files:
-
PadForge.Engine/Common/SDL3Minimal.cs. SDL3 P/Invoke declarations -
PadForge.Engine/Common/SdlDeviceWrapper.cs. Joystick/gamepad wrapper (state, rumble, haptic, GUID) -
PadForge.Engine/Common/SdlKeyboardWrapper.cs. Keyboard device wrapper -
PadForge.Engine/Common/SdlMouseWrapper.cs. Mouse device wrapper -
PadForge.Engine/Common/RawInputListener.cs. Windows Raw Input for keyboard/mouse -
PadForge.Engine/Common/ISdlInputDevice.cs. Common device interface -
PadForge.App/Common/Input/InputManager.cs. SDL initialization, hints, polling loop -
PadForge.App/Common/Input/InputManager.Step1.UpdateDevices.cs. Enumeration, HIDMaestro filtering -
PadForge.App/Common/Input/InputManager.Step2.UpdateInputStates.cs. State reading, force feedback -
PadForge.App/gamecontrollerdb_padforge.txt. Custom gamepad mappings
- SDL3 P/Invoke Layer
- SDL3 Initialization and Hints
- Device Enumeration Flow (Step 1)
- Device Filtering (HIDMaestro)
- SdlDeviceWrapper Class
- Gamepad vs Joystick API
- Sensor Support (Gyro / Accelerometer)
- GUID Construction
- HID Product String Fallback
- Rumble Implementation
- State Reading (Step 2)
- ISdlInputDevice Interface
- Keyboard and Mouse via Raw Input
- Custom Gamepad Mappings (gamecontrollerdb_padforge.txt)
- Device Change Detection (IsAttached, MarkDeviceOffline)
- SDL3 Fork: Switch 2 Pro Controller Support
File: PadForge.Engine/Common/SDL3Minimal.cs
Namespace: SDL3
All SDL3 functions are [DllImport("SDL3")] P/Invoke bindings in the static class SDL3.SDL. Only functions PadForge uses are declared. Not a complete binding. The shipped SDL3.dll at PadForge.App/Resources/SDL3/x64/SDL3.dll is a custom fork build (see SDL3 Fork).
| SDL2 | SDL3 | Notes |
|---|---|---|
SDL_NumJoysticks() + device index |
SDL_GetJoysticks() returning uint[] of instance IDs |
Instance IDs stable per session |
SDL_IsGameController() |
SDL_IsGamepad() |
Renamed |
SDL_GameControllerOpen() |
SDL_OpenGamepad() |
Takes instance ID, not device index |
SDL_JoystickGetGUID() |
SDL_GetJoystickGUID() |
Returns SDL_GUID (was SDL_JoystickGUID) |
SDL_INIT_GAMECONTROLLER |
SDL_INIT_GAMEPAD |
Renamed flag |
Return int (negative = error) |
Return bool
|
SDL3 uses C bool returns |
SDL_JoystickCurrentPowerLevel |
SDL_GetJoystickPowerInfo() |
Returns SDL_PowerState + percentage |
SDL_JoystickHasRumble() |
Properties system | Via SDL_GetJoystickProperties() + SDL_GetBooleanProperty()
|
SDL_GetVersion(SDL_version*) |
SDL_GetVersion() returning packed int |
major * 1000000 + minor * 1000 + patch |
public const uint SDL_INIT_VIDEO = 0x00000020; // Required for keyboard/mouse
public const uint SDL_INIT_JOYSTICK = 0x00000200;
public const uint SDL_INIT_HAPTIC = 0x00001000;
public const uint SDL_INIT_GAMEPAD = 0x00002000; // Was SDL_INIT_GAMECONTROLLERSDL3 returns C bool (1 byte). P/Invoke pattern:
[DllImport(lib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "SDL_IsGamepad")]
[return: MarshalAs(UnmanagedType.U1)]
private static extern bool _SDL_IsGamepad(uint instance_id);
public static bool SDL_IsGamepad(uint instance_id) => _SDL_IsGamepad(instance_id);The private _-prefixed extern uses MarshalAs(UnmanagedType.U1) for correct bool marshaling; the public wrapper provides a clean API surface.
SDL3 returns UTF-8 const char* pointers. Pattern:
[DllImport(lib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "SDL_GetJoystickNameForID")]
private static extern IntPtr _SDL_GetJoystickNameForID(uint instance_id);
public static string SDL_GetJoystickNameForID(uint instance_id)
{
return Marshal.PtrToStringUTF8(_SDL_GetJoystickNameForID(instance_id)) ?? string.Empty;
}SDL_GetJoysticks() returns a heap-allocated array the caller must free with SDL_free(). The C# wrapper handles this transparently:
public static uint[] SDL_GetJoysticks()
{
IntPtr ptr = _SDL_GetJoysticks(out int count);
if (ptr == IntPtr.Zero || count <= 0)
return Array.Empty<uint>();
try
{
var ids = new uint[count];
for (int i = 0; i < count; i++)
ids[i] = unchecked((uint)Marshal.ReadInt32(ptr, i * 4));
return ids;
}
finally
{
SDL_free(ptr);
}
}// Init / Quit
public static bool SDL_Init(uint flags);
public static void SDL_Quit();
public static bool SDL_SetHint(string name, string value);
public static string SDL_GetError();
// Joystick enumeration (instance-ID-based)
public static uint[] SDL_GetJoysticks();
public static IntPtr SDL_OpenJoystick(uint instance_id);
public static void SDL_CloseJoystick(IntPtr joystick);
public static void SDL_UpdateJoysticks();
public static uint SDL_GetJoystickID(IntPtr joystick);
public static bool SDL_JoystickConnected(IntPtr joystick);
// Joystick properties (from opened handle)
public static string SDL_GetJoystickName(IntPtr joystick);
public static ushort SDL_GetJoystickVendor(IntPtr joystick);
public static ushort SDL_GetJoystickProduct(IntPtr joystick);
public static ushort SDL_GetJoystickProductVersion(IntPtr joystick);
public static SDL_JoystickType SDL_GetJoystickType(IntPtr joystick);
public static string SDL_GetJoystickPath(IntPtr joystick);
public static string SDL_GetJoystickSerial(IntPtr joystick);
public static int SDL_GetNumJoystickAxes(IntPtr joystick);
public static int SDL_GetNumJoystickButtons(IntPtr joystick);
public static int SDL_GetNumJoystickHats(IntPtr joystick);
public static uint SDL_GetJoystickProperties(IntPtr joystick);
public static bool SDL_GetBooleanProperty(uint props, string name, bool default_value);
// Joystick state polling
public static short SDL_GetJoystickAxis(IntPtr joystick, int axis);
public static bool SDL_GetJoystickButton(IntPtr joystick, int button);
public static byte SDL_GetJoystickHat(IntPtr joystick, int hat);
// Joystick rumble
public static bool SDL_RumbleJoystick(IntPtr joystick, ushort low, ushort high, uint duration_ms);
// Gamepad (was GameController)
public static bool SDL_IsGamepad(uint instance_id);
public static IntPtr SDL_OpenGamepad(uint instance_id);
public static void SDL_CloseGamepad(IntPtr gamepad);
public static IntPtr SDL_GetGamepadJoystick(IntPtr gamepad);
public static short SDL_GetGamepadAxis(IntPtr gamepad, int axis);
public static bool SDL_GetGamepadButton(IntPtr gamepad, int button);
// Gamepad sensors
public static bool SDL_GamepadHasSensor(IntPtr gamepad, int type);
public static bool SDL_SetGamepadSensorEnabled(IntPtr gamepad, int type, bool enabled);
public static bool SDL_GetGamepadSensorData(IntPtr gamepad, int type, float[] data, int num_values);
// Haptic force feedback
public static IntPtr SDL_OpenHapticFromJoystick(IntPtr joystick);
public static void SDL_CloseHaptic(IntPtr haptic);
public static uint SDL_GetHapticFeatures(IntPtr haptic);
public static bool SDL_SetHapticGain(IntPtr haptic, int gain);
public static int SDL_CreateHapticEffect(IntPtr haptic, ref SDL_HapticEffect effect);
public static bool SDL_UpdateHapticEffect(IntPtr haptic, int effect, ref SDL_HapticEffect data);
public static bool SDL_RunHapticEffect(IntPtr haptic, int effect, uint iterations);
public static bool SDL_StopHapticEffect(IntPtr haptic, int effect);
public static void SDL_DestroyHapticEffect(IntPtr haptic, int effect);
public static int SDL_GetNumHapticAxes(IntPtr haptic);
// Keyboard/Mouse enumeration
public static uint[] SDL_GetKeyboards();
public static uint[] SDL_GetMice();
// Power info
public static SDL_PowerState SDL_GetJoystickPowerInfo(IntPtr joystick, out int percent);[StructLayout(LayoutKind.Sequential)]
public struct SDL_HapticCondition
{
public ushort type; // SDL_HAPTIC_SPRING / DAMPER / INERTIA / FRICTION
private ushort _pad;
public SDL_HapticDirection direction;
public uint length; // Duration in ms
public ushort delay, button, interval;
private ushort _pad2;
// Per-axis arrays (3 axes max). Flattened as individual fields
public ushort right_sat0, right_sat1, right_sat2; // Positive saturation 0-65535
public ushort left_sat0, left_sat1, left_sat2; // Negative saturation 0-65535
public short right_coeff0, right_coeff1, right_coeff2; // Positive coefficient
public short left_coeff0, left_coeff1, left_coeff2; // Negative coefficient
public ushort deadband0, deadband1, deadband2; // Dead band 0-65535
public short center0, center1, center2; // Center point
} // 68 bytesUsed for Spring, Damper, Friction, and Inertia effects. Each axis has independent coefficients, saturation, center, and deadband. SDL supports up to 3 axes; PadForge uses 2 (X and Y). Data flows from HMaestroFfbDecoder (parsing PID FFB packets emitted by the HM driver) through Vibration.ConditionAxes[] into ForceFeedbackState.SetConditionHapticForces(), which populates this struct.
[StructLayout(LayoutKind.Sequential)]
public struct SDL_HapticRamp
{
public ushort type; // SDL_HAPTIC_RAMP
private ushort _pad;
public SDL_HapticDirection direction;
public uint length; // Duration in ms
public ushort delay, button, interval;
public short start; // Start level -32767 to 32767
public short end; // End level -32767 to 32767
public ushort attack_length, attack_level;
public ushort fade_length, fade_level;
} // 44 bytesRamp force effect (linearly changing force from start to end level). Declared in SDL3Minimal.cs and included in the SDL_HapticEffect union. HMaestroFfbDecoder emits ramp from PID PT_RAMPREP packets, using max(abs(Start), abs(End)) as magnitude.
[StructLayout(LayoutKind.Explicit, Size = 72)]
public struct SDL_HapticEffect
{
[FieldOffset(0)] public ushort type;
[FieldOffset(0)] public SDL_HapticLeftRight leftright;
[FieldOffset(0)] public SDL_HapticConstant constant;
[FieldOffset(0)] public SDL_HapticPeriodic periodic;
[FieldOffset(0)] public SDL_HapticCondition condition;
[FieldOffset(0)] public SDL_HapticRamp ramp;
}Uses LayoutKind.Explicit, Size=72 with overlaid sub-structs matching the C union. The 72-byte size provides a safety margin (largest member SDL_HapticCondition is 68 bytes on x64).
public const int SDL_SENSOR_ACCEL = 1; // Accelerometer (m/s^2)
public const int SDL_SENSOR_GYRO = 2; // Gyroscope (rad/s)
public const int SDL_SENSOR_ACCEL_L = 3; // Left Joy-Con accel
public const int SDL_SENSOR_GYRO_L = 4; // Left Joy-Con gyro
public const int SDL_SENSOR_ACCEL_R = 5; // Right Joy-Con accel
public const int SDL_SENSOR_GYRO_R = 6; // Right Joy-Con gyroFile: PadForge.App/Common/Input/InputManager.cs
Method: private bool InitializeSdl()
SDL3 initialization runs once before the polling thread starts. Hints must be set before SDL_Init() because SDL reads them during subsystem initialization.
SDL_Init(SDL_INIT_JOYSTICK | SDL_INIT_GAMEPAD | SDL_INIT_VIDEO | SDL_INIT_HAPTIC)| Flag | Hex | Purpose |
|---|---|---|
SDL_INIT_JOYSTICK |
0x0200 |
Joystick enumeration, polling, rumble |
SDL_INIT_GAMEPAD |
0x2000 |
Loads gamecontrollerdb; enables SDL_IsGamepad() / SDL_OpenGamepad()
|
SDL_INIT_VIDEO |
0x0020 |
Required for SDL_GetKeyboards() / SDL_GetMice(). Side effect: disables screensaver and system sleep |
SDL_INIT_HAPTIC |
0x1000 |
Haptic force feedback for wheels, flight sticks, and devices without rumble |
After SDL_Init() succeeds, two fixups counteract SDL_INIT_VIDEO side effects:
-
SDL_EnableScreenSaver(). Re-enables the screensaver SDL disabled -
SetThreadExecutionState(ES_CONTINUOUS). Clears execution-state flags SDL set, restoring system sleep
A periodic sleep guard (sleepGuardTimer, every 5s) re-applies SetThreadExecutionState(ES_CONTINUOUS) because SDL may re-assert these flags during SDL_UpdateJoysticks().
After initialization, PadForge loads its custom mapping file to extend SDL's built-in gamecontrollerdb for unrecognized devices. See Custom Gamepad Mappings for format and entries.
string mappingsPath = Path.Combine(AppContext.BaseDirectory, "gamecontrollerdb_padforge.txt");
if (File.Exists(mappingsPath))
SDL_AddGamepadMappingsFromFile(mappingsPath);All hints are set before SDL_Init(). Order matters. SDL reads hints during subsystem startup.
| Hint | Value | Rationale |
|---|---|---|
SDL_HINT_JOYSTICK_ALLOW_BACKGROUND_EVENTS |
"1" |
Essential. Without this, SDL stops reading input when PadForge loses focus. A remapper must read input while games have focus. |
SDL_HINT_JOYSTICK_XINPUT |
"1" |
Enables SDL's XInput backend for Xbox controller enumeration. Without this, Xbox controllers (USB or wireless adapter) do not appear in SDL_GetJoysticks(). Was SDL_HINT_XINPUT_ENABLED in SDL2. |
SDL_HINT_JOYSTICK_HIDAPI_SWITCH2 |
"1" |
Activates the HIDAPI driver for the Switch 2 Pro Controller (PadForge's custom fork). Gates SDL_hidapi_switch2.c. Without it, the controller is ignored even though the driver code is compiled in. |
SDL_HINT_VIDEO_ALLOW_SCREENSAVER |
"1" |
Counteracts SDL_INIT_VIDEO's default screensaver suppression. PadForge only needs VIDEO for keyboard/mouse enumeration. |
SDL_HINT_JOYSTICK_RAWINPUT |
NOT SET | Must not be "1". SDL3's raw input backend conflicts with XInput. Raw input claims Xbox controllers first, leaving XInput with no unclaimed devices. Discovered via Cemu comparison. Omitted (not "0" either) so SDL defaults to XInput for Xbox and HIDAPI for others. |
Hint timing:
SDL_SetHint()must precedeSDL_Init(). Post-init hints have no effect on already-initialized subsystems.
File: PadForge.App/Common/Input/InputManager.Step1.UpdateDevices.cs
Method: private void UpdateDevices()
Runs every ~2s (EnumerationIntervalMs = 2000) on the background polling thread. The first cycle runs immediately (firstCycle = true) for instant controller detection. Two phases for joysticks, plus keyboard/mouse enumeration.
SDL_GetJoysticks() -> uint[] joystickIds
|
Build HashSet<uint> currentInstanceIds (for HIDMaestro cleanup later)
|
For each instanceId in joystickIds:
| (HIDMaestro virtuals never reach here — the SDL3 fork's
| substring-list filter drops them before SDL_GetJoysticks returns.
| Every instance ID at this point is a real device.)
|
| 1. Is it in _openedSdlInstanceIds?
| YES -> Skip (already open and tracked)
|
| 2. new SdlDeviceWrapper().Open(instanceId)
| |
| Open() internally:
| | SDL_IsGamepad(instanceId)?
| | |-- YES: SDL_OpenGamepad(instanceId)
| | | Joystick = SDL_GetGamepadJoystick(GameController)
| | |-- NO: SDL_OpenJoystick(instanceId)
| |
| | Populate: Name, VID, PID, Path, Serial, Type
| | RawButtonCount = SDL_GetNumJoystickButtons(Joystick)
| | If gamepad: NumAxes=6, NumButtons=11 (+ Misc1, paddles, Touchpad, Misc2-6), NumHats=1
| | ParseMappedButtonIndices(GameController)
| | Else: NumAxes/Buttons/Hats from raw joystick
| | HID name fallback if Name is raw VID/PID
| | Check rumble via SDL properties system
| | Enable gyro/accel sensors (gamepad only)
| | OpenHaptic() for force feedback
| | Build ProductGuid + InstanceGuid
| |
| Open() failed? -> Dispose wrapper, continue to next
|
| 3. FindOrCreateUserDevice(wrapper.InstanceGuid, wrapper.ProductGuid)
| | Exact match by InstanceGuid? -> return existing
| | Fallback match by ProductGuid (offline device)? -> migrate GUID
| | No match? -> create new UserDevice
|
| 4. ud.LoadFromSdlDevice(wrapper) -- populates UserDevice runtime state
| ud.IsOnline = true
| _openedSdlInstanceIds.Add(wrapper.SdlInstanceId)
| changed = true
Why instance IDs, not GUIDs: _openedSdlInstanceIds uses SDL instance IDs (uint) because serial-based GUIDs (Bluetooth devices) are unavailable until the device is opened. Instance IDs are assigned at connection time and unique per session.
Keyboards and mice use RawInputListener.EnumerateKeyboards() / EnumerateMice() via Windows Raw Input (not SDL). Each new device gets a SdlKeyboardWrapper or SdlMouseWrapper, with UserDevice populated via LoadFromKeyboardDevice() / LoadFromMouseDevice().
Tracking uses _openedKeyboardHandles and _openedMouseHandles (HashSet<IntPtr>) keyed on Raw Input handles.
Why not SDL for keyboards/mice: SDL's APIs report system-wide state without distinguishing physical devices. PadForge's per-device mapping requires per-device tracking, which only Windows Raw Input provides.
Each tracked SDL instance ID is checked via IsAttached (SDL_JoystickConnected()). If disconnected, MarkDeviceOffline():
- Stops rumble via
ForceFeedbackState.StopDeviceForces()(best-effort) - Disposes the SDL handle via
ud.Device.Dispose()(best-effort) - Calls
ud.ClearRuntimeState()(nulls Device, InputState, ForceFeedbackState, etc.) - Removes the SDL instance ID from
_openedSdlInstanceIds
Keyboard/mouse disconnection is detected by comparing tracked handles against RawInputListener.EnumerateKeyboards()/EnumerateMice(), using the same MarkDeviceOffline() path.
// Devices already opened. Keyed on SDL instance ID (uint)
private readonly HashSet<uint> _openedSdlInstanceIds = new();_openedSdlInstanceIds records every device PadForge has called SDL_OpenJoystick on, so the engine does not re-open the same device twice per cycle. The set is pruned each cycle via IntersectWith(currentInstanceIds) to remove IDs for devices that no longer appear in the SDL enumeration.
HIDMaestro virtual controllers are filtered upstream by PadForge's SDL3 fork: the patched SDL_GetJoysticks walks each device's container ID up to the HIDMaestro root enumerator and drops any match before returning the list. This avoids the rumble-killing close path that earlier in-engine filtering used to guard against (SDL_CloseJoystick calls XInputSetState(slot, 0, 0) as cleanup, which would trigger FeedbackReceived(0, 0) on the HIDMaestro bus). The fork's filter means HM devices never enter the engine's open/close cycle at all.
FindOrCreateUserDevice(Guid instanceGuid, Guid productGuid). Three-tier matching:
-
Exact match by
InstanceGuid. Returns existing device with preserved settings -
Fallback match by
ProductGuidagainst offline devices. Handles Bluetooth reconnections with changed device paths/InstanceGuid. Migrates the old GUID and updatesUserSettingviaMigrateUserSettingGuid() -
Create new. Adds a fresh
UserDevice
All lookups use manual for loops (not LINQ) to avoid closure allocations in the hot path.
PruneOrphanedHandles() runs before keyboard/mouse enumeration. It removes tracked handles whose UserDevice was deleted via the UI "Remove" command. Without this, deleted devices could never be re-detected because stale handles would remain in _openedKeyboardHandles / _openedMouseHandles.
HIDMaestro virtual controllers (Xbox / PlayStation / Extended) appear as real input devices to SDL3 by default. Without filtering, SDL would enumerate PadForge's own outputs as inputs, the engine would map them back to themselves, and a feedback loop would create controllers exponentially.
PadForge filters them at the SDL3 fork level. The fork's patched SDL_GetJoysticks walks each device's container ID up to the HIDMaestro root enumerator (Root\HMCOMPANION / similar) and drops any device whose container matches. HM virtuals never appear in the enumeration the engine consumes.
The previous in-engine filter (IsHIDMaestroVirtualDevice in InputManager.Step1) is gone. The engine no longer needs the per-cycle classification by device path, VID/PID, or active/expected count. The fork-side filter is upstream of every consumer (engine, SDL_OpenJoystick, SDL_CloseJoystick), so the rumble-killing close path that the in-engine filter used to guard against can't fire on HM devices.
For the SDL3 fork patches, see PadForge's SDL3 fork branch feat/hidmaestro-filter. The OpenXInput fork carries its own complementary filter for the XInput API surface, documented in HIDMaestro Deep Dive.
File: PadForge.Engine/Common/SdlDeviceWrapper.cs
Implements: ISdlInputDevice, IDisposable
Wraps an SDL joystick (and optionally its Gamepad overlay) for unified device access. One instance per physical device.
| Property | Type | Description |
|---|---|---|
Joystick |
IntPtr |
Raw SDL joystick handle; always valid when open |
GameController |
IntPtr |
SDL Gamepad handle; IntPtr.Zero if not a gamepad |
SdlInstanceId |
uint |
SDL instance ID (unique per session); 0 = invalid |
NumAxes |
int |
6 for gamepads, raw count for joysticks |
NumButtons |
int |
11 for gamepads, raw count for joysticks |
RawButtonCount |
int |
Raw button count before gamepad remapping |
NumHats |
int |
1 for gamepads, raw count for joysticks |
HasRumble |
bool |
Supports SDL_RumbleJoystick
|
Haptic |
IntPtr |
SDL haptic handle; non-zero when FFB is open |
HapticFeatures |
uint |
Bitmask of SDL_HAPTIC_* flags |
HasHaptic |
bool |
Haptic != IntPtr.Zero |
HapticStrategy |
HapticEffectStrategy |
Best strategy: LeftRight > Sine > Constant |
HasGyro / HasAccel
|
bool |
Device has motion sensors |
Name |
string |
Human-readable device name |
VendorId |
ushort |
USB Vendor ID |
ProductId |
ushort |
USB Product ID |
ProductVersion |
ushort |
USB Product Version |
DevicePath |
string |
Device file system path |
JoystickType |
SDL_JoystickType |
Device classification |
SerialNumber |
string |
Serial (e.g., BT MAC address) |
InstanceGuid |
Guid |
Deterministic GUID for settings matching |
ProductGuid |
Guid |
VID+PID-based GUID |
IsGameController |
bool |
GameController != IntPtr.Zero |
IsAttached |
bool |
SDL_JoystickConnected(Joystick) |
Property: ForceRawJoystickMode (per-device setting in UserDevice)
When SDL3's gamecontrollerdb mapping produces incorrect results, the user can enable Force Raw Mode on the Devices page. This bypasses the Gamepad API and reads raw joystick indices.
Behavior per layer:
| Layer | Normal Mode | Force Raw Mode |
|---|---|---|
Open (SdlDeviceWrapper.Open) |
SDL_OpenGamepad() if SDL_IsGamepad()
|
Same. Still opened as gamepad |
Read (GetCurrentState(forceRaw)) |
GetGamepadState() |
GetJoystickState() via forceRaw = true
|
| UI (Devices page) | Shows 11 gamepad buttons | Shows RawButtonCount buttons |
| Auto-mapping | Standard gamepad mapping | Skipped. User must manually record |
Important: Force Raw Mode only changes the read path, not how the device is opened. The device stays opened as a Gamepad (if recognized), with sensors enabled. Re-opening would require a close/open cycle that could cause input drops.
Primary use case: DualShock 3 via DsHidMini SDF mode, where SDL's built-in mapping incorrectly mapped buttons due to the non-standard HID layout.
Files: SdlDeviceWrapper.cs (read dispatch), InputManager.Step2.UpdateInputStates.cs (passes ud.ForceRawJoystickMode), DeviceService.cs (settings sync), DevicesPage.xaml (toggle UI)
public bool Open(uint instanceId)-
Try Gamepad first:
SDL_IsGamepad()->SDL_OpenGamepad(), get joystick viaSDL_GetGamepadJoystick() -
Fall back to Joystick:
SDL_OpenJoystick()if not a gamepad or open failed - Populate properties: name, VID, PID, path, serial, type from joystick handle
-
Capture raw button count before gamepad override:
RawButtonCount = SDL_GetNumJoystickButtons() - Gamepad layout override: NumAxes=6, NumButtons=11, NumHats=1
-
HID name fallback: if Name is raw VID/PID (e.g.,
"0x16c0/0x05e1"), queryHidD_GetProductString -
Check rumble via
SDL_GetJoystickProperties()+SDL_GetBooleanProperty() -
Enable sensors:
SDL_GamepadHasSensor()->SDL_SetGamepadSensorEnabled(true)(gamepad only) -
Open haptic:
OpenHaptic()for FFB devices -
Build GUIDs:
BuildProductGuid()+BuildInstanceGuid()
private void CloseInternal()Haptic must be closed before the joystick it was opened from. Then close gamepad (which also closes its underlying joystick), or close joystick directly if not a gamepad.
Method: private void OpenHaptic() in SdlDeviceWrapper.cs
Decision tree determining whether a device uses simple rumble (SDL_RumbleJoystick), haptic effects (SDL_RunHapticEffect), or neither. Decided once at open time; stored in HapticStrategy.
OpenHaptic()
|
SDL_OpenHapticFromJoystick(Joystick)
|-- Failed (null)? -> return (no haptic support, use simple rumble if HasRumble)
|
features = SDL_GetHapticFeatures(haptic)
|-- features == 0? -> Close haptic, return (empty feature set)
|
HasRumble AND (features & SDL_HAPTIC_LEFTRIGHT)?
|-- YES: Close haptic, return
| Rationale: Simple rumble via SDL_RumbleJoystick is more reliable
| for gamepads than haptic LeftRight effects. Haptic is only needed
| for devices where simple rumble doesn't work.
|
|-- NO: Keep haptic open, pick best strategy:
|
(features & SDL_HAPTIC_LEFTRIGHT)?
|-- YES: HapticStrategy = LeftRight (best. Independent L/R motors)
|
(features & SDL_HAPTIC_SINE)?
|-- YES: HapticStrategy = Sine (good. Periodic vibration)
|
(features & SDL_HAPTIC_CONSTANT)?
|-- YES: HapticStrategy = Constant (acceptable. Steady force)
|
None of the above?
|-- Close haptic, return (no usable effect types)
|
NumHapticAxes = SDL_GetNumHapticAxes(haptic)
| 1 axis -> wheels (single-axis FFB: Spring/Damper on steering axis)
| 2+ axes -> joysticks, gamepads (X+Y axis condition effects)
|
(features & SDL_HAPTIC_GAIN)?
|-- YES: SDL_SetHapticGain(haptic, 100) -- maximize gain
| Priority | Feature Flag | Strategy | Typical Devices |
|---|---|---|---|
| 1 (best) | SDL_HAPTIC_LEFTRIGHT |
HapticEffectStrategy.LeftRight |
Gamepads without simple rumble |
| 2 | SDL_HAPTIC_SINE |
HapticEffectStrategy.Sine |
Racing wheels, flight sticks |
| 3 | SDL_HAPTIC_CONSTANT |
HapticEffectStrategy.Constant |
Older FFB devices |
NumHapticAxes is critical for ForceFeedbackState: it determines whether condition effects (Spring, Damper, Friction, Inertia) use 1 axis (wheels: steering only) or 2 axes (joysticks: X and Y).
PadForge uses two SDL APIs depending on whether the device is in SDL's gamecontrollerdb. The choice is made at open time and cannot change without re-opening.
| Condition | API Used | State Method | Axis/Button Counts |
|---|---|---|---|
SDL_IsGamepad() true and ForceRawMode false |
Gamepad API | GetGamepadState() |
6 axes, 11 buttons, 1 hat (standardized) |
SDL_IsGamepad() false, or ForceRawMode true |
Joystick API | GetJoystickState() |
Raw counts from HID descriptor |
ForceRawMode is a per-device setting (UserDevice.ForceRawJoystickMode), passed via GetCurrentState(bool forceRaw) in Step 2. When true, GetJoystickState() is called even if GameController != IntPtr.Zero.
Used when GameController != IntPtr.Zero and forceRaw is false. Reads through SDL's mapping layer, which remaps DualSense, DualShock, Switch Pro, DS3, etc. to a standardized Xbox-like layout.
Axis layout (CustomInputState.Axis[0..5]):
| Index | Gamepad Axis | SDL Enum | Range Conversion |
|---|---|---|---|
| 0 | Left Stick X |
SDL_GAMEPAD_AXIS_LEFTX (0) |
signed -> unsigned: (ushort)(raw - short.MinValue)
|
| 1 | Left Stick Y |
SDL_GAMEPAD_AXIS_LEFTY (1) |
signed -> unsigned |
| 2 | Left Trigger |
SDL_GAMEPAD_AXIS_LEFT_TRIGGER (4) |
0..32767 -> 0..65535: raw * 65535L / 32767
|
| 3 | Right Stick X |
SDL_GAMEPAD_AXIS_RIGHTX (2) |
signed -> unsigned |
| 4 | Right Stick Y |
SDL_GAMEPAD_AXIS_RIGHTY (3) |
signed -> unsigned |
| 5 | Right Trigger |
SDL_GAMEPAD_AXIS_RIGHT_TRIGGER (5) |
0..32767 -> 0..65535 |
SDL puts triggers at indices 4/5, but PadForge reorders them to indices 2/5 to match the LX(0), LY(1), LT(2), RX(3), RY(4), RT(5) convention used throughout the mapping pipeline.
Button layout (CustomInputState.Buttons[0..10+]):
| Index | Button | SDL Enum |
|---|---|---|
| 0 | A (South) |
SDL_GAMEPAD_BUTTON_SOUTH (0) |
| 1 | B (East) |
SDL_GAMEPAD_BUTTON_EAST (1) |
| 2 | X (West) |
SDL_GAMEPAD_BUTTON_WEST (2) |
| 3 | Y (North) |
SDL_GAMEPAD_BUTTON_NORTH (3) |
| 4 | Left Bumper |
SDL_GAMEPAD_BUTTON_LEFT_SHOULDER (9) |
| 5 | Right Bumper |
SDL_GAMEPAD_BUTTON_RIGHT_SHOULDER (10) |
| 6 | Back/Select |
SDL_GAMEPAD_BUTTON_BACK (4) |
| 7 | Start/Menu |
SDL_GAMEPAD_BUTTON_START (6) |
| 8 | Left Stick |
SDL_GAMEPAD_BUTTON_LEFT_STICK (7) |
| 9 | Right Stick |
SDL_GAMEPAD_BUTTON_RIGHT_STICK (8) |
| 10 | Guide/Home |
SDL_GAMEPAD_BUTTON_GUIDE (5) |
| 11+ | Extra raw |
SDL_GetJoystickButton(Joystick, i) (filtered) |
Button indices differ from SDL's SDL_GamepadButton enum. PadForge reorders to match CreateDefaultPadSetting() (e.g., Back at 6, Guide at 10 instead of SDL's 4 and 5).
Guide button suppression: When Back+Start+Guide are all pressed, Guide is forced false. This suppresses a Windows/XInput quirk where the system synthesizes Guide from Back+Start.
Extra raw buttons (11+): After the 11 standard buttons, GetGamepadState() appends raw buttons via SDL_GetJoystickButton() for indices 11 through RawButtonCount. This exposes device-specific buttons (e.g., DualSense touchpad click, mic) for macro triggers. Already-mapped buttons are skipped (see Mapped Button Filtering).
D-pad to POV[0]: Four D-pad buttons synthesized into a single POV value via DpadToCentidegrees(), supporting all 8 directions.
Sensors: SDL_GetGamepadSensorData() populates state.Gyro[3] (rad/s) and state.Accel[3] (m/s^2). Only available via the Gamepad API; must be enabled during Open().
Used for unrecognized devices (flight sticks, wheels, generic HID) or when ForceRawMode is enabled. Reads raw axes, buttons, and hats without remapping.
-
Axes: First
MaxAxis(24) go toAxis[], overflow toSliders[]. Signed -> unsigned:(ushort)(raw - short.MinValue)maps -32768..32767 to 0..65535 -
Hats: SDL bitmask -> centidegrees via
HatToCentidegrees(), stored inPovs[] -
Buttons: Uses
RawButtonCount(notNumButtons) to read all physical buttons. Critical for gamepad devices switched to ForceRawMode.NumButtonscaps at 11, butRawButtonCountpreserves the actual HID count. See RawButtonCount vs NumButtons Fix
Problem: Gamepad open sets NumButtons = 11. If the user enables ForceRawMode, GetJoystickState() would only read 11 buttons, silently dropping extras (e.g., DS3 via DsHidMini SDF has 17).
Solution: RawButtonCount is captured from SDL_GetNumJoystickButtons() before the gamepad override. GetJoystickState() uses it when available:
int btnCount = Math.Min(
RawButtonCount > 0 ? RawButtonCount : NumButtons,
state.Buttons.Length);All physical buttons are readable regardless of open mode.
Problem: Extra raw buttons (11+) appended in GetGamepadState() may already be consumed by the gamepad mapping (e.g., DS3 SDF maps b11 -> RB, b12 -> Guide). Without filtering, these buttons are double-reported.
Solution: ParseMappedButtonIndices() parses the SDL mapping string to build a HashSet<int> of consumed raw indices:
private static HashSet<int> ParseMappedButtonIndices(IntPtr gameController)
{
string mapping = GetGamepadMapping(gameController);
// Mapping format: "GUID,name,a:b2,b:b1,...,platform:Windows,"
// Parse all "bN" values to find consumed raw button indices.
foreach (var segment in mapping.Split(','))
{
int colonIdx = segment.IndexOf(':');
if (colonIdx < 0) continue;
string value = segment.Substring(colonIdx + 1);
if (value.Length > 1 && value[0] == 'b' && int.TryParse(value.Substring(1), out int btnIdx))
indices.Add(btnIdx);
}
}In the extra raw button loop, any index in _mappedRawButtonIndices is skipped:
for (int i = 11; i < rawCount && i < CustomInputState.MaxButtons; i++)
{
if (_mappedRawButtonIndices != null && _mappedRawButtonIndices.Contains(i))
continue; // Already mapped by gamepad API. Skip to avoid double-reporting
state.Buttons[i] = SDL_GetJoystickButton(Joystick, i);
}_mappedRawButtonIndices is populated during Open() for gamepads; null for raw joystick devices.
| SDL Bitmask | Centidegrees | Direction |
|---|---|---|
SDL_HAT_CENTERED (0x00) |
-1 | Centered |
SDL_HAT_UP (0x01) |
0 | North |
SDL_HAT_RIGHTUP (0x03) |
4500 | Northeast |
SDL_HAT_RIGHT (0x02) |
9000 | East |
SDL_HAT_RIGHTDOWN (0x06) |
13500 | Southeast |
SDL_HAT_DOWN (0x04) |
18000 | South |
SDL_HAT_LEFTDOWN (0x0C) |
22500 | Southwest |
SDL_HAT_LEFT (0x08) |
27000 | West |
SDL_HAT_LEFTUP (0x09) |
31500 | Northwest |
Sensor support is only available through the Gamepad API (GameController != IntPtr.Zero).
HasGyro = SDL_GamepadHasSensor(GameController, SDL_SENSOR_GYRO);
HasAccel = SDL_GamepadHasSensor(GameController, SDL_SENSOR_ACCEL);
if (HasGyro) SDL_SetGamepadSensorEnabled(GameController, SDL_SENSOR_GYRO, true);
if (HasAccel) SDL_SetGamepadSensorEnabled(GameController, SDL_SENSOR_ACCEL, true);Sensors must be explicitly enabled before data can be read.
In InputManager.UpdateMotionSnapshots(), SDL coordinates are converted to DSU/DS4 convention:
const float RadToDeg = 180f / MathF.PI;
const float MsToG = 1f / 9.80665f;
AccelX = -ax * MsToG; // Inverted
AccelY = -ay * MsToG; // Inverted
AccelZ = -az * MsToG; // Inverted
GyroPitch = -gx * RadToDeg; // Inverted
GyroYaw = gy * RadToDeg; // Same sign
GyroRoll = -gz * RadToDeg; // InvertedVerified working with DualSense and Switch 2 Pro Controller. Derived from Switch Pro's BetterJoy-to-DSU mapping, translated through SDL standard coordinates.
public static Guid BuildProductGuid(ushort vid, ushort pid)16-byte GUID: VID(2 LE) + PID(2 LE) + zeros(12). Does NOT include the "PIDVID" ASCII signature DirectInput uses. PadForge detects XInput devices via SDL hints and VID/PID checks instead.
public static Guid BuildInstanceGuid(string devicePath, ushort vid, ushort pid,
uint instanceId, string serial = null)Deterministic GUID from MD5 hash. Priority:
-
VID+PID+Serial (
serial:{VID}:{PID}:{serial}). Best for Bluetooth devices; serial (BT MAC address) is stable across reboots - Device path. Stable for wired/USB devices
-
VID+PID+SDL instance ID (
sdl:{VID}:{PID}:{instanceId}). Session-specific last resort
public DeviceObjectItem[] GetDeviceObjects()Returns DeviceObjectItem[] describing each axis, hat, and button. The button count uses Math.Max(NumButtons, RawButtonCount) so that raw buttons 11+ (beyond the standard gamepad set) are exposed in the source dropdown for mapping. Maps to well-known GUIDs matching DirectInput convention:
| Axis Index | ObjectTypeGuid | Name |
|---|---|---|
| 0 | ObjectGuid.XAxis |
"X Axis" |
| 1 | ObjectGuid.YAxis |
"Y Axis" |
| 2 | ObjectGuid.ZAxis |
"Z Axis" |
| 3 | ObjectGuid.RxAxis |
"X Rotation" |
| 4 | ObjectGuid.RyAxis |
"Y Rotation" |
| 5 | ObjectGuid.RzAxis |
"Z Rotation" |
| 6+ | ObjectGuid.Slider |
"Slider N" |
public int GetInputDeviceType()| SDL_JoystickType | InputDeviceType |
|---|---|
SDL_JOYSTICK_TYPE_GAMEPAD |
Gamepad |
SDL_JOYSTICK_TYPE_WHEEL |
Driving |
SDL_JOYSTICK_TYPE_FLIGHT_STICK |
Flight |
SDL_JOYSTICK_TYPE_ARCADE_STICK |
Joystick |
SDL_JOYSTICK_TYPE_ARCADE_PAD |
Gamepad |
SDL_JOYSTICK_TYPE_DANCE_PAD |
Supplemental |
SDL_JOYSTICK_TYPE_GUITAR |
Supplemental |
SDL_JOYSTICK_TYPE_DRUM_KIT |
Supplemental |
SDL_JOYSTICK_TYPE_THROTTLE |
Flight |
| Unknown / other | Joystick |
The Gamepad type triggers auto-mapping via SettingsManager.CreateDefaultPadSetting() with the standardized SDL3 gamepad layout.
File: PadForge.Engine/Common/SdlDeviceWrapper.cs
Methods: IsRawVidPidName(), TryGetHidProductString()
SDL3 may return a raw VID/PID string (e.g., "0x16c0/0x05e1") for devices not in its name database. Typically niche HID devices where SDL falls back to formatting the USB VID/PID.
IsRawVidPidName() checks for names starting with "0x" containing '/', minimum 11 characters. Matches SDL's fallback format without false positives on real names.
When detected, TryGetHidProductString() queries the Windows HID class driver for the product string:
IntPtr handle = CreateFile(devicePath, 0, FILE_SHARE_READ | FILE_SHARE_WRITE,
IntPtr.Zero, OPEN_EXISTING, 0, IntPtr.Zero);
HidD_GetProductString(handle, buffer, 512);Details:
- Opened with zero access rights (
dwDesiredAccess = 0).HidD_GetProductStringneeds no read/write access -
FILE_SHARE_READ | FILE_SHARE_WRITEallows querying while other processes hold the device - 512-byte buffer, decoded as UTF-16; trimmed of null terminators
- Failures swallowed. Returns
null, keeping the raw VID/PID name - P/Invoke declarations (
CreateFile,CloseHandle,HidD_GetProductString) are private toSdlDeviceWrapper
File: PadForge.Engine/Common/SdlDeviceWrapper.cs
public bool SetRumble(ushort lowFreq, ushort highFreq, uint durationMs = uint.MaxValue)
public bool StopRumble() // SetRumble(0, 0, 0)SetRumble() uses uint.MaxValue (~4,294,967,295 ms, ~49 days) as the default duration, making rumble effectively indefinite. The caller stops it by calling StopRumble() or SetRumble(newL, newR).
Why not refresh each frame? SDL_RumbleJoystick restarts the motor on every call, even with identical values. On some hardware, this creates perceptible stutter. uint.MaxValue avoids this.
ForceFeedbackState tracks last motor values sent. SetRumble() is only called when values change:
Frame 1: combinedL=30000, combinedR=20000 -> SetRumble(30000, 20000) -- sent
Frame 2: combinedL=30000, combinedR=20000 -> no-op (same values)
Frame 3: combinedL=30000, combinedR=0 -> SetRumble(30000, 0) -- sent (highFreq changed)
Frame 4: combinedL=0, combinedR=0 -> StopRumble() -- sent (both zero)
This eliminates hardware restart gaps from calling SDL_RumbleJoystick every frame with unchanged values.
Detected during Open() via SDL3's properties system (replaces SDL2's SDL_JoystickHasRumble()):
uint props = SDL_GetJoystickProperties(Joystick);
HasRumble = props != 0 && SDL_GetBooleanProperty(props, "SDL.joystick.cap.rumble", false);SetRumble() returns false immediately if HasRumble is false or the joystick handle is invalid.
On disconnect, MarkDeviceOffline() calls ForceFeedbackState.StopDeviceForces() before disposing the SDL handle, ensuring rumble stops cleanly.
File: PadForge.App/Common/Input/InputManager.Step2.UpdateInputStates.cs
Method: private void UpdateInputStates()
Runs once per polling cycle (~1000Hz). Uses a pre-allocated _deviceSnapshotBuffer to avoid LINQ allocations.
For each online device:
- Save current state as
OldInputState/OldInputUpdates/OldInputStateTime - Call
ud.Device.GetCurrentState()(dispatches toGetGamepadState()orGetJoystickState()) - Atomic reference swap:
ud.InputState = newState(safe for cross-thread reading) - Compute buffered updates via
CustomInputHelper.GetUpdates(old, new) - Apply force feedback via
ApplyForceFeedback(ud)
When a device maps to multiple virtual controller slots, vibration is combined using max-of-each-motor:
ushort combinedL = 0, combinedR = 0;
for (int i = 0; i < slotCount; i++)
{
var vib = VibrationStates[padIndex];
if (vib.LeftMotorSpeed > combinedL) combinedL = vib.LeftMotorSpeed;
if (vib.RightMotorSpeed > combinedR) combinedR = vib.RightMotorSpeed;
}Rumble from any mapped slot reaches the physical controller. Test rumble targeting via TestRumbleTargetGuid[padIndex] is also supported for multi-device slots.
File: PadForge.Engine/Common/ISdlInputDevice.cs
Common interface for all device wrappers; enables uniform state reading across the pipeline.
public interface ISdlInputDevice : IDisposable
{
uint SdlInstanceId { get; }
string Name { get; }
int NumAxes { get; }
int NumButtons { get; }
int RawButtonCount { get; }
int NumHats { get; }
bool HasRumble { get; }
bool HasHaptic { get; }
bool HasGyro { get; }
bool HasAccel { get; }
HapticEffectStrategy HapticStrategy { get; }
IntPtr HapticHandle { get; }
uint HapticFeatures { get; }
int NumHapticAxes { get; }
bool IsAttached { get; }
ushort VendorId { get; }
ushort ProductId { get; }
Guid InstanceGuid { get; }
Guid ProductGuid { get; }
string DevicePath { get; }
string SerialNumber { get; }
CustomInputState GetCurrentState(bool forceRaw = false);
DeviceObjectItem[] GetDeviceObjects();
int GetInputDeviceType();
bool SetRumble(ushort low, ushort high, uint durationMs = uint.MaxValue);
bool StopRumble();
}| Class | Device Type | State Source | Rumble |
|---|---|---|---|
SdlDeviceWrapper |
Joystick/Gamepad | SDL (gamepad or joystick API) | SDL rumble or haptic |
SdlKeyboardWrapper |
Keyboard | RawInputListener.GetKeyboardState() |
None |
SdlMouseWrapper |
Mouse |
RawInputListener.ConsumeMouseDelta() + GetMouseButtons()
|
None |
Keyboards and mice use Windows Raw Input (not SDL) for per-device tracking. SDL's keyboard/mouse APIs cannot distinguish multiple physical devices, which PadForge's per-device mapping requires.
File: PadForge.Engine/Common/RawInputListener.cs
- Hidden
HWND_MESSAGEwindow on a dedicated background thread receivesWM_INPUTmessages -
RegisterRawInputDevices(RIDEV_INPUTSINK)registers for background input - Per-device state stored in
ConcurrentDictionary<IntPtr, bool[]>(keyboards) andConcurrentDictionary<IntPtr, MouseDeviceState>(mice) - Mouse deltas accumulated via
Interlocked.Add()and consumed atomically viaInterlocked.Exchange()
File: PadForge.App/gamecontrollerdb_padforge.txt (deployed next to exe)
SDL's Gamepad API looks up a device's SDL GUID in gamecontrollerdb and applies a mapping string to remap raw inputs to the standard layout. PadForge extends this with a custom file loaded via SDL_AddGamepadMappingsFromFile() after init.
Follows the SDL_GameControllerDB format:
GUID,Device Name,mapping_entries,platform:Windows,
Each mapping entry is a target:source pair where:
-
target is a standard gamepad element (
a,b,x,y,back,start,guide,leftshoulder,rightshoulder,leftstick,rightstick,leftx,lefty,rightx,righty,lefttrigger,righttrigger,dpup,dpdown,dpleft,dpright) -
source is a raw joystick input (
bN= button N,aN= axis N,hN.M= hat N direction M)
The 32-character hex GUID encodes VID, PID, and a CRC of the device name. Bytes 2–3 are a CRC16, not zero. The same VID/PID can have different GUIDs depending on the driver-reported name. Introduced in SDL 2.0.12 to disambiguate devices with identical VID/PID but different capabilities.
0300d8234c0500006802000000000000,Sony DualShock 3 (DsHidMini SDF and SXS),
a:b2,b:b1,x:b3,y:b0,
back:b4,start:b7,guide:b12,
leftshoulder:b10,rightshoulder:b11,
leftstick:b5,rightstick:b6,
leftx:a0,lefty:a1,rightx:a2,righty:a5,
lefttrigger:a3,righttrigger:a4,
dpup:h0.1,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,
platform:Windows,
DS3 via DsHidMini SDF has a non-standard button layout. SDL's built-in gamecontrollerdb excludes it because the GUID's CRC differs (DsHidMini reports a different device name). This mapping remaps 17 raw buttons and 6 axes to the standard layout.
Key mappings: b12 -> Guide (PS), b11/b10 -> RB/LB (R1/L1), a3/a4 -> LT/RT (analog), hat 0 -> D-pad.
Works with both SDF and SXS (sixaxis.sys emulation) modes. Same raw HID layout.
Two paths depending on whether you are a user contributing a mapping or a maintainer integrating one.
- Plug the device in. The Devices page shows a Submit Mapping button on the card for any joystick-class HID that is not already recognized as a gamepad.
- Click it. A pre-filled GitHub issue opens in the browser. The handler (
SubmitMapping_ClickinPadForge.App/Views/DevicesPage.xaml.cs) builds a URL against thedevice_mapping.ymltemplate withdevice_name,vid,pid,axes,buttons,hats, andsdl_guidpopulated automatically from the live SDL enumeration. No manual GUID transcription, which is the field most prone to typos. - The user fills in the per-input mapping tables (raw axis index for each Xbox 360 axis, raw button index for each Xbox 360 button) by reading them off the same Devices page's raw input visualization while pressing each control.
- The user submits the issue.
- Take the merged mapping submission. SDL GUID and raw indices are in the issue body.
- Construct the SDL gamepad mapping string. Format:
GUID,Device Name,a:bN,b:bN,x:bN,y:bN,leftshoulder:bN,rightshoulder:bN,leftx:aN,lefty:aN,righttrigger:aN,…,platform:Windows,. See the file's existing entries for examples (DS3 DsHidMini, G920, etc.). - Append the line to
PadForge.App/gamecontrollerdb_padforge.txt. - Build and deploy locally. Restart PadForge with the device plugged in and confirm SDL recognizes it as a gamepad on the Devices page (the Submit Mapping button should now disappear, indicating SDL has it mapped).
- Commit and push. The file is an
<EmbeddedResource>inPadForge.App.csproj, so it ships insidePadForge.exe; no separate file deployment.
If working from raw indices without the device in hand (e.g., reviewing a community submission), SDL2 Gamepad Tool is a useful sanity check for the mapping string format.
Property: SdlDeviceWrapper.IsAttached
public bool IsAttached
{
get
{
if (Joystick == IntPtr.Zero)
return false;
return SDL_JoystickConnected(Joystick);
}
}SDL_JoystickConnected() returns false when disconnected. Checked every 2s in Phase 2 of UpdateDevices(), and also in UpdateInputStates() (Step 2). If GetCurrentState() returns null, the device is marked offline immediately without waiting for the next enumeration.
Method: InputManager.MarkDeviceOffline(UserDevice ud)
Full cleanup on disconnect:
-
Stop force feedback:
StopDeviceForces(). Sends rumble stop before handle close (best-effort, try/catch) -
Dispose SDL handle:
CloseInternal(). Closes haptic, gamepad, joystick in order (best-effort) -
Clear runtime state:
ClearRuntimeState(). Nulls Device, InputState, ForceFeedbackState; sets IsOnline=false. TheUserDevicerecord remains for settings preservation
The SDL instance ID is removed from _openedSdlInstanceIds by the caller, enabling the device to be re-detected if reconnected.
PadForge ships a custom SDL3 fork (source: C:\Users\sonic\GitHub\SDL3-build\SDL\) adding Switch 2 Pro Controller support on Windows. Binary: PadForge.App/Resources/SDL3/x64/SDL3.dll.
Upstream SDL3 does not include Switch 2 Pro Controller support. The controller uses a USB composite device architecture requiring both HID and WinUSB access, which no existing SDL driver handles.
USB composite device with two interfaces:
| Interface | Protocol | Purpose |
|---|---|---|
| HID Interface 0 | Standard HID reports | Input reading + rumble (output report ID 0x02) |
| Bulk Interface 1 | WinUSB | Initialization sequence (10 commands via SendBulkData) |
Interface 1 uses a WinUSB-compatible INF (DeviceInterfaceGUID {6F13725E-EF0E-4FD3-AE5F-B2DE989EC825}, MI_01). Must be opened during init to send the bulk command sequence that enables full-featured input mode.
| File | Change |
|---|---|
SDL_hidapi.c |
Filter Switch 2 PIDs from libusb on Windows (libusb cannot open individual interfaces of a composite device); keep in platform HID backend |
SDL_hidapi_switch2.c |
New HIDAPI driver: WinUSB bulk init, HID input parsing, rumble output, stick/sensor calibration |
-
FILE_FLAG_OVERLAPPEDrequired forCreateFileon the WinUSB interface; without it,WinUsb_Initializefails - Init sequence uses overlapped I/O with per-transfer timeouts
- Calibration via WinUSB bulk transfers; timeout (-7) after re-plug is non-fatal. Falls back to default calibration
Gates the Switch 2 driver. Set to "1" before SDL_Init(). Without it, the compiled-in driver is never activated.
Steam exclusively locks WinUSB Interface 1 on detection. Since the init sequence requires exclusive access, Steam must be closed before PadForge can initialize the controller. HID input (Interface 0) works with Steam running, but the controller operates in limited mode without initialization.
# From Developer Command Prompt (vcvarsall.bat x64)
cd C:\Users\sonic\GitHub\SDL3-build\build
cmake --build . --config ReleaseOutput SDL3.dll is copied to PadForge.App/Resources/SDL3/x64/.
- Architecture Overview: Why SDL3 (not DirectInput or raw XInput), SDL3 hints
-
Engine Library:
SdlDeviceWrapper,ISdlInputDevice,CustomInputState,HapticEffectStrategy - Input Pipeline: Step 1 (SDL enumeration), Step 2 (SDL state reading), force feedback
-
Build and Publish:
SDL3.dllandlibusb-1.0.dllcontent items, native DLL deployment - Virtual Controllers: HIDMaestro filtering in Step 1 to avoid re-opening virtual controllers