-
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 controllers. This page documents the SDL3 P/Invoke layer, device enumeration flow, state reading, sensor support, haptic force feedback, and the custom SDL3 fork for Switch 2 Pro Controller support.
Source files:
-
PadForge.Engine/Common/SDL3Minimal.cs— SDL3 P/Invoke declarations -
PadForge.Engine/Common/SdlDeviceWrapper.cs— joystick/gamepad device wrapper (state reading, rumble, haptic, GUID construction) -
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— device enumeration, ViGEm filtering -
PadForge.App/Common/Input/InputManager.Step2.UpdateInputStates.cs— state reading + force feedback -
PadForge.App/gamecontrollerdb_padforge.txt— custom gamepad mappings
File: PadForge.Engine/Common/SDL3Minimal.cs
Namespace: SDL3
All SDL3 functions are declared as [DllImport("SDL3")] P/Invoke bindings in the static class SDL3.SDL. Only the functions actually used by PadForge are declared — this is not a complete SDL3 binding. The shipped SDL3.dll binary lives at PadForge.App/Resources/SDL3/x64/SDL3.dll and is a custom fork build (see SDL3 Fork below).
| SDL2 | SDL3 | Notes |
|---|---|---|
SDL_NumJoysticks() + device index |
SDL_GetJoysticks() returning uint[] of instance IDs |
Instance IDs are stable per connection 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 + percent |
SDL_JoystickHasRumble() |
Properties system |
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). The P/Invoke pattern used throughout:
[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, and a public wrapper provides the clean API surface.
SDL3 returns UTF-8 const char* pointers. The 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 that 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
public SDL_HapticDirection direction;
public uint length; // Duration in ms
public ushort delay, button, interval;
// 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 up to 2 (X and Y). The condition data flows from vJoy FFB callback through Vibration.ConditionAxes[] to ForceFeedbackState.SetConditionHapticForces() which populates this struct.
[StructLayout(LayoutKind.Sequential)]
public struct SDL_HapticRamp
{
public ushort type; // SDL_HAPTIC_RAMP
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. vJoy FFB handles ramp via PT_RAMPREP packets using max(abs(Start), abs(End)) as the 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. Size of 72 bytes provides a safety margin (largest actual 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 happens once before the polling thread starts. Hints must be set before SDL_Init() is called, 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 |
Core joystick enumeration, polling, and rumble |
SDL_INIT_GAMEPAD |
0x2000 |
Loads the gamecontrollerdb mapping database; enables SDL_IsGamepad() / SDL_OpenGamepad()
|
SDL_INIT_VIDEO |
0x0020 |
Required for keyboard/mouse state functions (SDL_GetKeyboards(), SDL_GetMice()). Side effect: SDL disables the screensaver and system sleep by default when VIDEO is initialized |
SDL_INIT_HAPTIC |
0x1000 |
Haptic force feedback for wheels, flight sticks, and devices without simple rumble |
After SDL_Init() succeeds, two fixups are applied to counteract SDL_INIT_VIDEO side effects:
-
SDL_EnableScreenSaver()— re-enables the Windows screensaver that SDL disabled -
SetThreadExecutionState(ES_CONTINUOUS)— clears any execution-state flags SDL may have set, allowing the PC to enter sleep mode
A periodic sleep guard (sleepGuardTimer, every 5 seconds) in the polling loop re-applies SetThreadExecutionState(ES_CONTINUOUS) because SDL may re-assert execution-state flags during SDL_UpdateJoysticks() / event processing.
After initialization, PadForge loads its community mapping file:
string mappingsPath = Path.Combine(AppContext.BaseDirectory, "gamecontrollerdb_padforge.txt");
if (File.Exists(mappingsPath))
SDL_AddGamepadMappingsFromFile(mappingsPath);This extends SDL's built-in gamecontrollerdb with PadForge-specific mappings for devices that SDL does not recognize out of the box. See Custom Gamepad Mappings below for the file format and current entries.
All hints are set before SDL_Init(). The order matters because SDL reads hints during subsystem startup.
| Hint | Value | Rationale |
|---|---|---|
SDL_HINT_JOYSTICK_ALLOW_BACKGROUND_EVENTS |
"1" |
Essential for a remapper. Without this, SDL stops reading joystick input when PadForge loses focus. Since PadForge's entire purpose is to translate input to virtual controllers while games have focus, background input reading is mandatory. |
SDL_HINT_JOYSTICK_XINPUT |
"1" |
Enables SDL's XInput backend for Xbox controller enumeration. Without this, Xbox controllers connected via the Xbox Wireless Adapter or USB would not appear in SDL_GetJoysticks(). This was SDL_HINT_XINPUT_ENABLED in SDL2. |
SDL_HINT_JOYSTICK_HIDAPI_SWITCH2 |
"1" |
Enables the HIDAPI driver for the Nintendo Switch 2 Pro Controller, added in PadForge's custom SDL3 fork. This hint gates the SDL_hidapi_switch2.c driver. Without it, the Switch 2 Pro Controller is ignored by SDL even though the fork code is compiled in. |
SDL_HINT_VIDEO_ALLOW_SCREENSAVER |
"1" |
Counteracts SDL_INIT_VIDEO's default behavior of blocking the screensaver. PadForge only needs VIDEO for keyboard/mouse enumeration, not display output, so the screensaver should not be suppressed. |
SDL_HINT_JOYSTICK_RAWINPUT |
NOT SET |
Must not be set to "1". SDL3's raw input backend for joysticks interferes with its XInput backend. When both are active, XInput controllers disappear from SDL_GetJoysticks() entirely because raw input claims them first, and the XInput backend then finds no unclaimed devices. This was discovered by comparing against Cemu's SDL configuration. The hint is deliberately omitted (not set to "0" either) so SDL uses its default behavior, which on Windows favors XInput for Xbox controllers and HIDAPI for everything else. |
Hint timing:
SDL_SetHint()calls are placed beforeSDL_Init()inInitializeSdl(). Setting hints after init has no effect on subsystems that already read them during startup.
File: PadForge.App/Common/Input/InputManager.Step1.UpdateDevices.cs
Method: private void UpdateDevices()
Device enumeration runs every ~2 seconds (EnumerationIntervalMs = 2000) on the background polling thread. The very first polling cycle runs enumeration immediately (firstCycle = true) so controllers are detected without waiting. The flow has two phases for joysticks, plus keyboard/mouse enumeration.
SDL_GetJoysticks() -> uint[] joystickIds
|
Build HashSet<uint> currentInstanceIds (for ViGEm cleanup later)
|
For each instanceId in joystickIds:
|
| 1. Is it in _filteredVigemInstanceIds?
| YES -> Skip (known ViGEm output device, never re-open)
|
| 2. Is it in _openedSdlInstanceIds?
| YES -> Skip (already open and tracked)
|
| 3. 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, 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
|
| 4. IsViGEmVirtualDevice(wrapper)?
| |-- YES: Log, add to _filteredVigemInstanceIds, Dispose wrapper
| |-- NO: Continue
|
| 5. FindOrCreateUserDevice(wrapper.InstanceGuid, wrapper.ProductGuid)
| | Exact match by InstanceGuid? -> return existing
| | Fallback match by ProductGuid (offline device)? -> migrate GUID
| | No match? -> create new UserDevice
|
| 6. ud.LoadFromSdlDevice(wrapper) -- populates UserDevice runtime state
| ud.IsOnline = true
| _openedSdlInstanceIds.Add(wrapper.SdlInstanceId)
| changed = true
Why instance ID tracking, not GUID matching: _openedSdlInstanceIds uses SDL instance IDs (uint) rather than InstanceGuid because serial-based GUIDs (used for Bluetooth devices) are not available until after the device is opened. Instance IDs are assigned by SDL at connection time and are unique per session.
Keyboards and mice are enumerated via RawInputListener.EnumerateKeyboards() and RawInputListener.EnumerateMice() using the Windows Raw Input API (not SDL). Each new device gets a SdlKeyboardWrapper or SdlMouseWrapper created, and a UserDevice record is populated via LoadFromKeyboardDevice() / LoadFromMouseDevice().
Tracking uses _openedKeyboardHandles and _openedMouseHandles (both HashSet<IntPtr>) keyed on Raw Input device handles.
Why not SDL for keyboards/mice: SDL's keyboard/mouse APIs (SDL_GetKeyboardState(), SDL_GetMouseState()) report system-wide state without distinguishing which physical device generated the input. PadForge's per-device mapping model requires per-device input tracking, which only Windows Raw Input provides.
For each SDL instance ID in _openedSdlInstanceIds, the device's IsAttached property is checked. This property calls SDL_JoystickConnected(Joystick), which returns false when the physical device has been unplugged.
If not attached, the device is marked offline via MarkDeviceOffline() which:
- Stops rumble via
ForceFeedbackState.StopDeviceForces()(best-effort, catches exceptions) - Disposes the SDL handle via
ud.Device.Dispose()(best-effort) - Calls
ud.ClearRuntimeState()(nulls out Device, InputState, ForceFeedbackState, etc.) - The SDL instance ID is removed from
_openedSdlInstanceIds
Disconnected keyboard/mouse handles are detected by comparing tracked handles against RawInputListener.EnumerateKeyboards()/EnumerateMice(). The same MarkDeviceOffline() is called for disconnected keyboard/mouse devices, found via FindOnlineDeviceByHandle().
// Devices already opened — keyed on SDL instance ID (uint)
private readonly HashSet<uint> _openedSdlInstanceIds = new();
// Devices identified as ViGEm virtual controllers — never re-opened
private readonly HashSet<uint> _filteredVigemInstanceIds = new();The _filteredVigemInstanceIds set prevents a critical rumble-killing bug. Without it, every 2-second enumeration cycle would:
- See the ViGEm device in
SDL_GetJoysticks() - Open it with
SDL_OpenJoystick()to check if it's ViGEm - Identify it as ViGEm and close it with
SDL_CloseJoystick()
The problem: SDL3's SDL_CloseJoystick() internally calls XInputSetState(slot, 0, 0) as cleanup. For ViGEm virtual controllers, this triggers FeedbackReceived(0, 0) on the ViGEm bus, which kills active rumble on the virtual controller. By remembering ViGEm instance IDs in the set, they are never opened again.
The set is cleaned each cycle via _filteredVigemInstanceIds.IntersectWith(currentInstanceIds) to remove IDs for virtual controllers that have been destroyed (no longer in SDL_GetJoysticks() output).
FindOrCreateUserDevice(Guid instanceGuid, Guid productGuid) performs three-tier matching:
-
Exact match by
InstanceGuid— returns existing device with preserved settings -
Fallback match by
ProductGuidagainst offline devices — handles Bluetooth controllers that reconnect with a different device path (and thus a differentInstanceGuid). The old device'sInstanceGuidis migrated to the new one, and the linkedUserSettingis updated viaMigrateUserSettingGuid()to preserve slot assignment and PadSetting -
Create new if no match found — adds a fresh
UserDeviceto the collection
All lookups use manual for loops instead of 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, the device could never be re-detected because the handle would remain in _openedKeyboardHandles / _openedMouseHandles even though the UserDevice no longer exists.
File: PadForge.App/Common/Input/InputManager.Step1.UpdateDevices.cs
Method: private bool IsViGEmVirtualDevice(SdlDeviceWrapper wrapper)
PadForge creates virtual controllers (ViGEm Xbox 360, ViGEm DS4, vJoy) that appear as real input devices to SDL. These must be filtered out during enumeration to prevent feedback loops (reading our own output as input, causing exponential virtual controller creation).
The filter runs after the device is opened (post-open filtering) because some detection criteria require properties only available from an opened handle (VID/PID, device path).
The checks are evaluated in order; the first match returns true (filter the device):
| # | Detection Method | Criteria | Rationale |
|---|---|---|---|
| 1 | Device path | Path contains "vigem" or "virtual" (case-insensitive) |
Primary detection for ViGEm Bus devices. ViGEm's bus enumerator puts these strings in the device instance path |
| 2 | Zero VID/PID | VID=0 AND PID=0 AND IsGameController AND (_activeVigemCount > 0 OR _expectedXbox360Count > 0 OR _expectedDs4Count > 0) |
ViGEm devices may report VID/PID as 0 through SDL's pre-open queries. Only filters when PadForge has virtual controllers active/expected |
| 3 | vJoy VID/PID | VID=0x1234 AND PID=0xBEAD | vJoy virtual joystick output devices. Always filtered unconditionally — PadForge should never read its own vJoy output. SDL opening the HID device can interfere with vJoyInterface.dll's write path |
| 4 | Xbox 360 VID/PID | VID=0x045E AND PID=0x028E AND Math.Max(_activeXbox360Count, _expectedXbox360Count) > 0
|
ViGEm Xbox 360 emulates exactly this VID/PID. Real modern Xbox controllers use different PIDs (0B12, 0B13, 0B20). Only the original 2005 Xbox 360 controller uses 028E |
| 5 | DS4 VID/PID | VID=0x054C AND PID=0x05C4 AND Math.Max(_activeDs4Count, _expectedDs4Count) > 0
|
ViGEm DS4 emulates the original DualShock 4 v1 PID. Real DS4v2 and DualSense use different PIDs |
The Xbox 360 and DS4 filters use Math.Max(_activeCount, _expectedCount) to cover the window between when PadForge intends to create a virtual controller and when it actually exists:
-
_activeCount: Number of currently live ViGEm virtual controllers of this type (from Step 5) -
_expectedCount: Number of slots configured for this type (fromSlotControllerTypes[], set by UI)
When Math.Max() is greater than 0, ALL devices with the matching VID/PID are filtered. This is intentionally aggressive to prevent a race condition where stale ViGEm device nodes slip through during the gap between SlotCreated[i] = true and the virtual controller actually being created. Without this, SDL would open the ViGEm device as input, PadForge would create another ViGEm device, SDL would open that one too, causing exponential growth.
ViGEm DS4 devices do not register under USB\VID_054C&PID_05C4 in the Windows registry — they use ViGEm Bus's own bus enumerator. Walking the PnP device tree to distinguish real from virtual devices is unreliable. The counter-based approach is simpler and more robust.
File: PadForge.Engine/Common/SdlDeviceWrapper.cs
Implements: ISdlInputDevice, IDisposable
Wraps an SDL joystick (and optionally its Gamepad overlay) for unified device access. Each physical device gets one SdlDeviceWrapper instance.
| Property | Type | Description |
|---|---|---|
Joystick |
IntPtr |
Raw SDL joystick handle. Always valid when open |
GameController |
IntPtr |
SDL Gamepad handle. IntPtr.Zero if not recognized as gamepad |
SdlInstanceId |
uint |
SDL instance ID (unique per connection session). 0 = invalid |
NumAxes |
int |
Axis count. 6 for gamepads, raw count for joysticks |
NumButtons |
int |
Button count. 11 for gamepads, raw count for joysticks |
RawButtonCount |
int |
Raw joystick button count (before gamepad remapping) |
NumHats |
int |
Hat switch count. 1 for gamepads, raw count for joysticks |
HasRumble |
bool |
Supports SDL_RumbleJoystick
|
Haptic |
IntPtr |
SDL haptic handle. Non-zero when haptic FFB is open |
HapticFeatures |
uint |
Bitmask of SDL_HAPTIC_* flags |
HasHaptic |
bool |
Haptic != IntPtr.Zero |
HapticStrategy |
HapticEffectStrategy |
Best haptic strategy (LeftRight > Sine > Constant) |
HasGyro / HasAccel
|
bool |
Whether the 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)
For devices where 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 instead.
How it works at each layer:
| Layer | Normal Mode | Force Raw Mode |
|---|---|---|
Open (SdlDeviceWrapper.Open) |
SDL_OpenGamepad() if SDL_IsGamepad()
|
Same — device is still opened as gamepad (so the handle is valid) |
Read (GetCurrentState(forceRaw)) |
Calls GetGamepadState()
|
Calls GetJoystickState() via forceRaw = true
|
| UI (Devices page) | Shows 11 gamepad buttons | Shows RawButtonCount buttons |
| Auto-mapping |
CreateDefaultPadSetting() creates standard gamepad mapping |
Skipped — user must manually record mappings |
Important: Force Raw Mode does not change how the device is opened. It only changes the read path. The device is still opened as a Gamepad (if recognized), which means GameController != IntPtr.Zero, sensors are enabled, etc. The forceRaw parameter to GetCurrentState() bypasses the Gamepad API at read time. This is because re-opening the device would require a full close/open cycle that could cause input drops.
Primary use case: DualShock 3 via DsHidMini in SDF mode, where SDL's built-in mapping (before the custom gamecontrollerdb_padforge.txt entry was added) incorrectly mapped buttons because the SDF mode exposes a 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(instanceId)->SDL_OpenGamepad(instanceId), get underlying joystick viaSDL_GetGamepadJoystick(GameController) -
Fall back to Joystick:
SDL_OpenJoystick(instanceId)if gamepad failed or not recognized - Populate properties from the opened joystick handle (name, VID, PID, path, serial, type)
-
Raw button count captured before any gamepad override:
RawButtonCount = SDL_GetNumJoystickButtons(Joystick) - Gamepad layout override: for gamepad devices, set NumAxes=6, NumButtons=11, NumHats=1 (standardized layout)
-
HID name fallback: if
Nameis a raw VID/PID string (e.g.,"0x16c0/0x05e1"), queryHidD_GetProductStringvia Win32 P/Invoke -
Check rumble via
SDL_GetJoystickProperties()+SDL_GetBooleanProperty(SDL_PROP_JOYSTICK_CAP_RUMBLE_BOOLEAN) -
Enable sensors:
SDL_GamepadHasSensor(GYRO/ACCEL)->SDL_SetGamepadSensorEnabled(true)(gamepad only) -
Open haptic:
OpenHaptic()for force feedback 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
The haptic opening strategy is a full decision tree that determines whether a device should use simple rumble (SDL_RumbleJoystick), haptic effects (SDL_RunHapticEffect), or neither. The decision is made once at device open time and 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 |
The NumHapticAxes value is critical for the downstream ForceFeedbackState: it determines whether condition effects (Spring, Damper, Friction, Inertia) are sent as 1-axis (wheels: force only on the steering axis) or 2-axis (joysticks: force on both X and Y).
PadForge uses two different SDL APIs depending on whether the device is recognized in SDL's gamecontrollerdb. The decision is made at device open time in SdlDeviceWrapper.Open() and cannot change without re-opening the device.
| Condition | API Used | State Method | Axis/Button Counts |
|---|---|---|---|
SDL_IsGamepad(instanceId) returns true and ForceRawMode is false |
Gamepad API | GetGamepadState() |
6 axes, 11 buttons, 1 hat (standardized) |
SDL_IsGamepad() returns false, or ForceRawMode is true |
Joystick API | GetJoystickState() |
Raw counts from HID descriptor |
The ForceRawMode flag is a per-device setting stored in UserDevice.ForceRawJoystickMode. It is passed through GetCurrentState(bool forceRaw) at read time in Step 2. When true, GetCurrentState() calls GetJoystickState() even if GameController != IntPtr.Zero.
Used when GameController != IntPtr.Zero and forceRaw is false. Reads through SDL's built-in mapping layer that automatically remaps DualSense, DualShock, Switch Pro, DS3 (with custom mapping), etc. to the 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 |
Note the reordering: SDL's gamepad axis enum puts triggers at indices 4/5, but PadForge's CustomInputState puts triggers at indices 2/5 to match the auto-mapping layout in SettingsManager.CreateDefaultPadSetting(). This reordering is intentional and matches 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) |
Note that PadForge's button indices differ from SDL's SDL_GamepadButton enum values. PadForge reorders buttons to match the auto-mapping layout expected by CreateDefaultPadSetting(). The SDL enum puts Back at 4 and Guide at 5; PadForge puts them at 6 and 10 respectively.
Guide button suppression: When Back+Start+Guide are all pressed simultaneously, Guide is forced to false. This suppresses a Windows/XInput quirk where the system synthesizes a Guide button press from the Back+Start combo when the app has focus.
Extra raw buttons (indices 11+): After the 11 standard gamepad buttons, GetGamepadState() appends additional buttons by reading the raw joystick API (SDL_GetJoystickButton()) for indices 11 through RawButtonCount. This exposes device-specific buttons that are not part of the standard gamepad layout, such as DualSense touchpad click or mic button, for use as macro triggers. Buttons already consumed by the gamepad mapping are skipped (see Mapped Button Filtering below).
D-pad to POV[0]: Four gamepad D-pad buttons (DPAD_UP, DPAD_DOWN, DPAD_LEFT, DPAD_RIGHT) are synthesized into a single POV value via DpadToCentidegrees(up, down, left, right). This supports all 8 directions including diagonals.
Sensors: SDL_GetGamepadSensorData() populates state.Gyro[3] (rad/s) and state.Accel[3] (m/s^2). Sensors are only available through the Gamepad API and must have been enabled during Open().
Used for devices not recognized as gamepads (flight sticks, racing wheels, generic HID devices) or when ForceRawMode is enabled. Reads raw axes, buttons, and hats without any remapping.
-
Axes: First
MaxAxis(24) axes go toAxis[], overflow goes toSliders[]. Signed -> unsigned conversion:(ushort)(raw - short.MinValue)maps SDL's -32768..32767 range to 0..65535 -
Hats: Converted from SDL bitmask to centidegrees via
HatToCentidegrees(). Each hat uses thePovs[]array -
Buttons: Uses
RawButtonCount(notNumButtons) to ensure all physical buttons are read. This is critical for devices opened as gamepads and then switched to ForceRawMode —NumButtonswould be capped at 11, butRawButtonCountpreserves the actual HID button count. See RawButtonCount vs NumButtons Fix below
Problem: When a device is opened as a gamepad, NumButtons is set to 11 (the standard gamepad button count). If the user later enables ForceRawMode, GetJoystickState() would only read 11 buttons instead of the device's actual button count. For devices like the DS3 via DsHidMini SDF (which has 17 raw buttons), this silently dropped 6 buttons.
Solution: RawButtonCount is captured from SDL_GetNumJoystickButtons(Joystick) before the gamepad layout override sets NumButtons = 11. GetJoystickState() uses RawButtonCount when available:
int btnCount = Math.Min(
RawButtonCount > 0 ? RawButtonCount : NumButtons,
state.Buttons.Length);This ensures all physical buttons are readable regardless of how the device was opened.
Problem: When extra raw buttons (indices 11+) are appended in GetGamepadState(), some of those raw indices may already be consumed by the gamepad mapping. For example, the DS3 via DsHidMini SDF mapping maps raw button 11 to Right Shoulder (rightshoulder:b11) and raw button 12 to Guide (guide:b12). Without filtering, these buttons would be reported twice: once via the gamepad API (at their standard positions) and again via the raw passthrough.
Solution: ParseMappedButtonIndices() parses the SDL gamepad mapping string to build a HashSet<int> of raw button indices that are already consumed:
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() when the device is opened as a gamepad, and set to 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; // InvertedThis mapping is verified working with both DualSense and Switch 2 Pro Controller. Derived from Switch Pro's known-working 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 that DirectInput uses — since PadForge uses SDL (not raw DirectInput), XInput devices are detected 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. 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 is significant: devices with CapType == InputDeviceType.Gamepad get auto-mapped by SettingsManager.CreateDefaultPadSetting() with the standardized SDL3 gamepad axis/button layout.
File: PadForge.Engine/Common/SdlDeviceWrapper.cs
Methods: IsRawVidPidName(), TryGetHidProductString()
SDL3 may return a raw VID/PID string (e.g., "0x16c0/0x05e1") as the device name for devices not in its internal name database. This typically happens with niche HID devices (custom microcontrollers, specialty peripherals) where SDL falls back to formatting the USB VID/PID.
IsRawVidPidName() checks if the name starts with "0x" and contains a '/' character, with a minimum length of 11 characters. This pattern matches SDL's fallback name format without false-positiving on real device names.
When a raw VID/PID name is detected, TryGetHidProductString() queries the Windows HID class driver for the device's product string descriptor:
IntPtr handle = CreateFile(devicePath, 0, FILE_SHARE_READ | FILE_SHARE_WRITE,
IntPtr.Zero, OPEN_EXISTING, 0, IntPtr.Zero);
HidD_GetProductString(handle, buffer, 512);Key implementation details:
- The file is opened with zero access rights (
dwDesiredAccess = 0) —HidD_GetProductStringdoes not require read or write access to the HID device -
FILE_SHARE_READ | FILE_SHARE_WRITE(value3) allows the query while other processes have the device open - The buffer is 512 bytes, decoded as UTF-16 (
Encoding.Unicode) - The result is trimmed of null terminators and whitespace
- All exceptions are caught and swallowed — failure returns
null, leaving the raw VID/PID name in place - P/Invoke declarations for
CreateFile,CloseHandle, andHidD_GetProductStringare private toSdlDeviceWrapper(not shared with other classes)
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, approximately 49 days) as the default duration. This effectively makes the rumble "indefinite" — the caller is responsible for stopping it by calling StopRumble() or SetRumble(newL, newR).
Why not use a short duration (e.g., 100ms) and refresh each frame? Because SDL_RumbleJoystick restarts the motor each time it is called, even with the same values. On some hardware, this restart creates a brief gap in vibration that is perceptible as a stutter or buzz. Using uint.MaxValue avoids this entirely.
The ForceFeedbackState class (in ForceFeedbackState.cs) tracks the last motor values sent to the device. SetRumble() is only called when the new values differ from the previously sent values:
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 the hardware restart gaps that would occur if SDL_RumbleJoystick were called every frame with the same values.
Rumble support is detected during Open() using SDL3's properties system (replacing 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.
When a device is disconnected (detected in Phase 2 of Step 1), MarkDeviceOffline() calls ForceFeedbackState.StopDeviceForces() before disposing the SDL handle. This ensures rumble stops cleanly even if the device was actively vibrating.
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 is mapped to multiple virtual controller slots, vibration from all slots 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;
}This ensures rumble from any game on any mapped slot reaches the physical controller. Test rumble targeting is also supported via TestRumbleTargetGuid[padIndex] to rumble a specific device in multi-device slots.
File: PadForge.Engine/Common/ISdlInputDevice.cs
Common interface for all device wrappers, enabling the pipeline to read state uniformly.
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 the Windows Raw Input API (not SDL's keyboard/mouse functions) for per-device input tracking. SDL's keyboard/mouse APIs cannot distinguish between multiple keyboards or mice, which is required for PadForge's per-device mapping model.
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 (SDL_IsGamepad(), SDL_OpenGamepad()) works by looking up a device's SDL GUID in its built-in gamecontrollerdb and applying the mapping string to remap raw joystick axes/buttons to the standard gamepad layout. PadForge extends this database with a community mapping file loaded after SDL_Init():
SDL_AddGamepadMappingsFromFile(mappingsPath);The file 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. This means the same VID/PID can have different SDL GUIDs depending on the device name reported by the driver. The CRC was introduced in SDL 2.0.12 to disambiguate devices with the same 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,
Background: The DS3 via DsHidMini in SDF (raw HID) mode has a non-standard button layout. SDL's built-in gamecontrollerdb does not include this device because the SDL GUID includes a CRC of the device name, and DsHidMini's SDF mode reports a different name than a native DS3. This custom mapping remaps the DS3 SDF's 17 raw buttons and 6 raw axes to the standard gamepad layout.
Key details:
- Button 12 maps to Guide (PS button)
- Buttons 11/10 map to Right/Left Shoulder (R1/L1)
- Axes 3/4 are Left/Right Trigger (L2/R2 as analog axes)
- D-pad is hat 0 with standard bitmask values
This mapping works with both DsHidMini SDF and SXS (sixaxis.sys emulation) modes because they expose the same raw HID layout.
- Connect the device and note its SDL GUID (shown in PadForge's Devices page debug output, or via
SDL_GetJoystickGUIDForID()) - Use SDL2 Gamepad Tool or manually record raw button/axis indices
- Add the mapping line to
gamecontrollerdb_padforge.txt - Restart PadForge (mappings are loaded once at startup)
- Submit new mappings via the Device Mapping issue template
Property: SdlDeviceWrapper.IsAttached
public bool IsAttached
{
get
{
if (Joystick == IntPtr.Zero)
return false;
return SDL_JoystickConnected(Joystick);
}
}SDL_JoystickConnected() returns false when the physical device has been disconnected. This is checked every 2 seconds in Phase 2 of UpdateDevices(). It is also checked by UpdateInputStates() (Step 2) — if GetCurrentState() returns null, the device is marked offline immediately rather than waiting for the next enumeration cycle.
Method: InputManager.MarkDeviceOffline(UserDevice ud)
This method handles the full cleanup sequence when a device is disconnected:
-
Stop force feedback:
ud.ForceFeedbackState.StopDeviceForces(ud.Device)— sends rumble stop command to the hardware before the handle is closed. Wrapped in try/catch for best-effort cleanup -
Dispose SDL handle:
ud.Device.Dispose()— callsCloseInternal()which closes haptic, gamepad, and joystick handles in the correct order. Wrapped in try/catch -
Clear runtime state:
ud.ClearRuntimeState()— setsDevice = null,InputState = null,ForceFeedbackState = null,IsOnline = false. TheUserDevicerecord remains inUserDevices.Itemsso saved settings are preserved; only the runtime state is cleared
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 built from C:\Users\sonic\GitHub\SDL3-build\SDL\. The fork adds Nintendo Switch 2 Pro Controller support on Windows. The built SDL3.dll binary lives at PadForge.App/Resources/SDL3/x64/SDL3.dll.
As of the PadForge fork date, upstream SDL3 does not include Nintendo Switch 2 Pro Controller support. The controller uses a novel USB composite device architecture that requires both HID and WinUSB access, which existing SDL drivers do not handle.
The Switch 2 Pro Controller is a 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 with DeviceInterfaceGUID {6F13725E-EF0E-4FD3-AE5F-B2DE989EC825} (MI_01). This interface must be opened during initialization to send the bulk command sequence that puts the controller into full-featured input mode.
| File | Change |
|---|---|
SDL_hidapi.c |
Filter Switch 2 PIDs from libusb on Windows (composite device issue — libusb cannot open individual interfaces of a composite device); keep in platform HID backend |
SDL_hidapi_switch2.c |
New HIDAPI driver: WinUSB bulk init sequence, HID input parsing, HID rumble output, stick/sensor calibration |
-
FILE_FLAG_OVERLAPPEDis required when callingCreateFilefor the WinUSB interface; without it,WinUsb_Initializefails - The init sequence uses overlapped I/O with events and timeouts for each bulk transfer
- Calibration data is read via WinUSB bulk transfers; a timeout (-7) may occur after re-plug, which is non-fatal — the driver falls back to default calibration values
The hint SDL_JOYSTICK_HIDAPI_SWITCH2 gates the Switch 2 driver. PadForge sets it to "1" before SDL_Init(). Without this hint, the compiled-in driver code is never activated and the controller is ignored during enumeration.
Steam exclusively locks WinUSB Interface 1 when it detects the Switch 2 Pro Controller. Because the WinUSB init sequence requires exclusive access to Interface 1, Steam must be closed before PadForge can initialize the controller. Input via Interface 0 (HID) works even with Steam running, but without the initialization the controller operates in a limited mode.
# 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/.