-
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 -
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.Step1.UpdateDevices.cs-- device enumeration -
PadForge.App/Common/Input/InputManager.Step2.UpdateInputStates.cs-- state reading + force feedback
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);
// 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.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;
}Uses LayoutKind.Explicit, Size=72 with overlaid sub-structs matching the C union. Size of 72 bytes provides a safety margin (largest actual member 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 gyroSDL3 is initialized with these subsystem flags:
SDL_Init(SDL_INIT_JOYSTICK | SDL_INIT_GAMEPAD | SDL_INIT_HAPTIC | SDL_INIT_VIDEO)-
SDL_INIT_JOYSTICK-- core joystick enumeration and polling -
SDL_INIT_GAMEPAD-- gamepad mapping database (gamecontrollerdb) -
SDL_INIT_HAPTIC-- haptic force feedback for devices without simple rumble -
SDL_INIT_VIDEO-- required for keyboard/mouse state functions
| Hint | Value | Purpose |
|---|---|---|
SDL_HINT_JOYSTICK_ALLOW_BACKGROUND_EVENTS |
"1" |
Enables input reading when PadForge does not have focus |
SDL_HINT_JOYSTICK_XINPUT |
"1" |
Enables Xbox controller enumeration through XInput backend |
SDL_HINT_JOYSTICK_HIDAPI_SWITCH2 |
"1" |
Enables HIDAPI driver for Nintendo Switch 2 Pro Controller (custom fork) |
SDL_HINT_JOYSTICK_RAWINPUT |
NOT SET | Must not be set. Setting RAWINPUT conflicts with XInput enumeration and prevents Xbox controllers from appearing (discovered via Cemu comparison) |
The RAWINPUT hint is a critical "do not touch" -- SDL3's raw input backend for joysticks interferes with its XInput backend, causing Xbox controllers to disappear from enumeration entirely.
File: PadForge.App/Common/Input/InputManager.Step1.UpdateDevices.cs
Method: private void UpdateDevices()
Device enumeration runs every ~2 seconds on the background polling thread. The flow has two phases for joysticks, plus keyboard/mouse enumeration.
SDL_GetJoysticks() -> uint[] instanceIds
|
For each instanceId:
| Is it in _filteredVigemInstanceIds? -> Skip (ViGEm output device)
| Is it in _openedSdlInstanceIds? -> Skip (already open)
|
| new SdlDeviceWrapper().Open(instanceId)
| |
| SDL_IsGamepad(instanceId)?
| |-- Yes: SDL_OpenGamepad(instanceId) -> get Joystick via SDL_GetGamepadJoystick()
| |-- No: SDL_OpenJoystick(instanceId)
|
| IsViGEmVirtualDevice(wrapper)?
| |-- Yes: Add to _filteredVigemInstanceIds, Dispose wrapper
| |-- No: Continue
|
| FindOrCreateUserDevice(wrapper.InstanceGuid, wrapper.ProductGuid)
| ud.LoadFromSdlDevice(wrapper)
| _openedSdlInstanceIds.Add(instanceId)
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.
For each SDL instance ID in _openedSdlInstanceIds, the device's IsAttached property (which calls SDL_JoystickConnected()) is checked. If not attached, the device is marked offline via MarkDeviceOffline() which:
- Stops rumble via
ForceFeedbackState.StopDeviceForces() - Disposes the SDL handle
- Calls
ud.ClearRuntimeState()(nulls out Device, InputState, etc.)
Disconnected keyboard/mouse handles are detected by comparing tracked handles against RawInputListener.EnumerateKeyboards()/EnumerateMice().
// Devices already opened
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 bug: SDL3's SDL_CloseJoystick() internally calls XInputSetState(0,0), which triggers ViGEm's FeedbackReceived(0,0) callback and kills active rumble on all virtual controllers. By remembering ViGEm instance IDs, they are never re-opened on subsequent enumeration cycles.
The set is cleaned each cycle via _filteredVigemInstanceIds.IntersectWith(currentInstanceIds) to remove IDs for virtual controllers that have been destroyed.
FindOrCreateUserDevice(Guid instanceGuid, Guid productGuid) performs three-tier matching:
-
Exact match by
InstanceGuid -
Fallback match by
ProductGuidagainst offline devices (handles Bluetooth controllers reconnecting with different device paths) - Create new if no match found
On fallback match, the old device's InstanceGuid is migrated to the new one, and the linked UserSetting is updated via MigrateUserSettingGuid() to preserve slot assignment and PadSetting.
PruneOrphanedHandles() removes tracked keyboard/mouse handles whose UserDevice was removed via the UI "Remove" command. Without this, the device could never be re-detected because the handle would remain in the tracked set even though the UserDevice no longer exists.
Method: InputManager.IsViGEmVirtualDevice(SdlDeviceWrapper wrapper)
PadForge creates virtual controllers (ViGEm Xbox 360, ViGEm DS4, vJoy) that SDL can see as input devices. These must be filtered to prevent feedback loops.
| Detection Method | Criteria | Notes |
|---|---|---|
| Device path | Path contains "vigem" or "virtual" (case-insensitive) |
Primary detection for ViGEm |
| Zero VID/PID | VID=0 + PID=0 + recognized as gamepad + active ViGEm count > 0 | ViGEm devices may report zero VID/PID through SDL pre-open queries |
| vJoy VID/PID | VID=0x1234 + PID=0xBEAD | vJoy virtual joystick output devices |
| Xbox 360 VID/PID | VID=0x045E + PID=0x028E + active/expected Xbox VC count > 0 | ViGEm emulates this exact PID; real modern Xbox controllers use different PIDs (0B12, 0B13, 0B20) |
| DS4 VID/PID | VID=0x054C + PID=0x05C4 + active/expected DS4 VC count > 0 | ViGEm emulates original DualShock 4 v1 PID |
The Xbox 360 and DS4 filters use a counter-based approach with Math.Max(_activeCount, _expectedCount): when PadForge has active or expected virtual controllers, ALL devices with matching VID/PID are filtered. This prevents a race condition where stale ViGEm device nodes slip through and cause exponential virtual controller creation.
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) |
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.
private void OpenHaptic()- Open haptic from joystick:
SDL_OpenHapticFromJoystick(Joystick) - Query features:
SDL_GetHapticFeatures(haptic) - For devices with simple rumble + LeftRight haptic: skip haptic (simple rumble is more reliable for gamepads)
- For other devices: pick best strategy:
| Priority | Feature Flag | Strategy |
|---|---|---|
| 1 (best) | SDL_HAPTIC_LEFTRIGHT |
HapticEffectStrategy.LeftRight |
| 2 | SDL_HAPTIC_SINE |
HapticEffectStrategy.Sine |
| 3 | SDL_HAPTIC_CONSTANT |
HapticEffectStrategy.Constant |
- Set gain to maximum:
SDL_SetHapticGain(h, 100)ifSDL_HAPTIC_GAINsupported
PadForge uses two different SDL APIs depending on whether the device is recognized in SDL's gamecontrollerdb.
Used when GameController != IntPtr.Zero. Reads through SDL's built-in mapping layer that automatically remaps DualSense, DualShock, Switch Pro, 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 4/5, but PadForge's CustomInputState puts triggers at indices 2/5 to match the auto-mapping layout in SettingsManager.CreateDefaultPadSetting().
Button layout (CustomInputState.Buttons[0..10+]):
| Index | Button | SDL Enum |
|---|---|---|
| 0 | A (South) | SDL_GAMEPAD_BUTTON_SOUTH |
| 1 | B (East) | SDL_GAMEPAD_BUTTON_EAST |
| 2 | X (West) | SDL_GAMEPAD_BUTTON_WEST |
| 3 | Y (North) | SDL_GAMEPAD_BUTTON_NORTH |
| 4 | Left Bumper | SDL_GAMEPAD_BUTTON_LEFT_SHOULDER |
| 5 | Right Bumper | SDL_GAMEPAD_BUTTON_RIGHT_SHOULDER |
| 6 | Back/Select | SDL_GAMEPAD_BUTTON_BACK |
| 7 | Start/Menu | SDL_GAMEPAD_BUTTON_START |
| 8 | Left Stick | SDL_GAMEPAD_BUTTON_LEFT_STICK |
| 9 | Right Stick | SDL_GAMEPAD_BUTTON_RIGHT_STICK |
| 10 | Guide/Home | SDL_GAMEPAD_BUTTON_GUIDE |
| 11+ | Extra raw | SDL_GetJoystickButton(Joystick, i) |
Guide button suppression: When Back+Start+Guide are all pressed, Guide is forced to false. Windows/XInput synthesizes Guide from the Back+Start combo when the app has focus.
Extra raw buttons: Buttons 11+ are read via the raw SDL_GetJoystickButton() API (bypassing gamepad mapping) to expose device-specific buttons like DualSense touchpad click or mic button for use as macro triggers.
D-pad to POV[0]: Four gamepad D-pad buttons are synthesized into a single POV value via DpadToCentidegrees(up, down, left, right).
Sensors: SDL_GetGamepadSensorData() populates state.Gyro[3] (rad/s) and state.Accel[3] (m/s^2).
Used for devices not recognized as gamepads (flight sticks, racing wheels, generic HID devices). Reads raw axes, buttons, and hats without any remapping.
-
Axes: first
MaxAxis(24) go toAxis[], overflow toSliders[]. Signed -> unsigned conversion:(ushort)(raw - short.MinValue) -
Hats: converted from SDL bitmask to centidegrees via
HatToCentidegrees() -
Buttons: direct copy from
SDL_GetJoystickButton()
| 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.
Methods: IsRawVidPidName(), TryGetHidProductString()
SDL3 may return a raw VID/PID string (e.g., "0x16c0/0x05e1") for devices not in its internal database. When detected, PadForge queries the Windows HID 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);The file is opened with zero access rights (no read/write needed for the HID query).
public bool SetRumble(ushort lowFreq, ushort highFreq, uint durationMs = uint.MaxValue)
public bool StopRumble() // SetRumble(0, 0, 0)Uses SDL_RumbleJoystick with uint.MaxValue duration (~49 days) so the caller controls when rumble stops. Change-detection in ForceFeedbackState ensures we only call when values differ, avoiding hardware restart gaps from redundant calls.
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; }
bool IsAttached { get; }
ushort VendorId { get; }
ushort ProductId { get; }
Guid InstanceGuid { get; }
Guid ProductGuid { get; }
string DevicePath { get; }
string SerialNumber { get; }
CustomInputState GetCurrentState();
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()
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 Switch 2 Pro Controller is a USB composite device with two interfaces:
| Interface | Protocol | Purpose |
|---|---|---|
| HID Interface 0 | HID reports | Input reading + rumble (output report ID 0x02) |
| Bulk Interface 1 | WinUSB | Initialization sequence (10 commands via SendBulkData) |
| File | Change |
|---|---|
SDL_hidapi.c |
Filter Switch 2 PIDs from libusb on Windows (composite device issue); keep in platform HID backend |
SDL_hidapi_switch2.c |
New driver: WinUSB bulk init sequence + HID rumble + calibration |
-
FILE_FLAG_OVERLAPPEDrequired forWinUsb_Initialize - Overlapped I/O with events and timeouts for init sequence
- DeviceInterfaceGUID:
{6F13725E-EF0E-4FD3-AE5F-B2DE989EC825}(MI_01) - Calibration reads via WinUSB bulk; may timeout (-7) after re-plug (non-fatal, uses default calibration)
Steam exclusively locks WinUSB Interface 1. Steam must be closed for Switch 2 initialization to succeed.
# 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/.