Skip to content

SDL3 Integration

hifihedgehog edited this page Mar 6, 2026 · 33 revisions

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

SDL3 P/Invoke Layer

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).

Key SDL3 vs SDL2 API Changes

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

Init Flags

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_GAMECONTROLLER

Bool Marshaling Pattern

SDL3 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.

String Marshaling

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;
}

Memory-Owning Array Returns

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);
    }
}

Key Functions Declared

// 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);

SDL_HapticEffect Union Struct

[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).

Sensor Type Constants

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 gyro

SDL3 Initialization and Hints

SDL3 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

Critical SDL Hints

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.


Device Enumeration Flow (Step 1)

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.

Phase 1: Open Newly Connected Devices

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)

Phase 1b/1c: Keyboard and Mouse Enumeration

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.

Phase 2: Detect Disconnected Devices

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:

  1. Stops rumble via ForceFeedbackState.StopDeviceForces()
  2. Disposes the SDL handle
  3. Calls ud.ClearRuntimeState() (nulls out Device, InputState, etc.)

Disconnected keyboard/mouse handles are detected by comparing tracked handles against RawInputListener.EnumerateKeyboards()/EnumerateMice().

ViGEm Filtering Tracking Sets

// 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.

UserDevice Lookup and GUID Migration

FindOrCreateUserDevice(Guid instanceGuid, Guid productGuid) performs three-tier matching:

  1. Exact match by InstanceGuid
  2. Fallback match by ProductGuid against offline devices (handles Bluetooth controllers reconnecting with different device paths)
  3. 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.

Orphaned Handle Pruning

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.


Device Filtering (ViGEm, vJoy)

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.


SdlDeviceWrapper Class

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.

Properties

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)

Force Raw Mode

Property: ForceRawMode (per-device setting in UserDevice)

For devices where SDL3's gamecontrollerdb mapping produces incorrect button mapping, enable ForceRawMode to bypass the Gamepad API and read raw joystick indices.

When set, Open() uses SDL_OpenJoystick() instead of SDL_OpenGamepad() regardless of SDL_IsGamepad(). The device will not have auto-mapping; buttons and axes must be manually recorded via the Mappings tab.

Files: SdlDeviceWrapper.cs (open logic), InputManager.Step2.UpdateInputStates.cs (read logic), DeviceService.cs (settings sync)

Open Flow

public bool Open(uint instanceId)
  1. Try Gamepad first: SDL_IsGamepad(instanceId) -> SDL_OpenGamepad(instanceId), get underlying joystick via SDL_GetGamepadJoystick(GameController)
  2. Fall back to Joystick: SDL_OpenJoystick(instanceId) if gamepad failed or not recognized
  3. Populate properties from the opened joystick handle (name, VID, PID, path, serial, type)
  4. Raw button count captured before any gamepad override: RawButtonCount = SDL_GetNumJoystickButtons(Joystick)
  5. Gamepad layout override: for gamepad devices, set NumAxes=6, NumButtons=11, NumHats=1 (standardized layout)
  6. HID name fallback: if Name is a raw VID/PID string (e.g., "0x16c0/0x05e1"), query HidD_GetProductString via Win32 P/Invoke
  7. Check rumble via SDL_GetJoystickProperties() + SDL_GetBooleanProperty(SDL_PROP_JOYSTICK_CAP_RUMBLE_BOOLEAN)
  8. Enable sensors: SDL_GamepadHasSensor(GYRO/ACCEL) -> SDL_SetGamepadSensorEnabled(true) (gamepad only)
  9. Open haptic: OpenHaptic() for force feedback devices
  10. Build GUIDs: BuildProductGuid() + BuildInstanceGuid()

Close Order (Critical)

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.

Haptic Opening Strategy

private void OpenHaptic()
  1. Open haptic from joystick: SDL_OpenHapticFromJoystick(Joystick)
  2. Query features: SDL_GetHapticFeatures(haptic)
  3. For devices with simple rumble + LeftRight haptic: skip haptic (simple rumble is more reliable for gamepads)
  4. 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
  1. Set gain to maximum: SDL_SetHapticGain(h, 100) if SDL_HAPTIC_GAIN supported

Gamepad vs Joystick API

PadForge uses two different SDL APIs depending on whether the device is recognized in SDL's gamecontrollerdb.

Gamepad API (GetGamepadState())

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).

Joystick API (GetJoystickState())

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 to Axis[], overflow to Sliders[]. Signed -> unsigned conversion: (ushort)(raw - short.MinValue)
  • Hats: converted from SDL bitmask to centidegrees via HatToCentidegrees()
  • Buttons: direct copy from SDL_GetJoystickButton()

Hat/POV Conversion

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 (Gyro / Accelerometer)

Sensor support is only available through the Gamepad API (GameController != IntPtr.Zero).

Detection and Activation (in Open())

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.

DSU Motion Conversion

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; // Inverted

This 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.


GUID Construction

Product GUID

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.

Instance GUID

public static Guid BuildInstanceGuid(string devicePath, ushort vid, ushort pid,
    uint instanceId, string serial = null)

Deterministic GUID from MD5 hash. Priority:

  1. VID+PID+Serial (serial:{VID}:{PID}:{serial}) — best for Bluetooth devices; serial (BT MAC address) is stable across reboots
  2. Device path — stable for wired/USB devices
  3. VID+PID+SDL instance ID (sdl:{VID}:{PID}:{instanceId}) — session-specific last resort

Device Objects Enumeration

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"

Device Type Mapping

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.


HID Product String Fallback

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).


Rumble

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.


State Reading (Step 2)

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:

  1. Save current state as OldInputState/OldInputUpdates/OldInputStateTime
  2. Call ud.Device.GetCurrentState() (dispatches to GetGamepadState() or GetJoystickState())
  3. Atomic reference swap: ud.InputState = newState (safe for cross-thread reading)
  4. Compute buffered updates via CustomInputHelper.GetUpdates(old, new)
  5. Apply force feedback via ApplyForceFeedback(ud)

Multi-Slot Force Feedback Combining

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.


ISdlInputDevice Interface

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();
}

Implementations

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

Keyboard and Mouse via Raw Input

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_MESSAGE window on a dedicated background thread receives WM_INPUT messages
  • RegisterRawInputDevices(RIDEV_INPUTSINK) registers for background input
  • Per-device state stored in ConcurrentDictionary<IntPtr, bool[]> (keyboards) and ConcurrentDictionary<IntPtr, MouseDeviceState> (mice)
  • Mouse deltas accumulated via Interlocked.Add() and consumed atomically via Interlocked.Exchange()

SDL3 Fork: Switch 2 Pro Controller Support

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.

Architecture

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)

Key Fork Modifications

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

WinUSB Details

  • FILE_FLAG_OVERLAPPED required for WinUsb_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 Conflict

Steam exclusively locks WinUSB Interface 1. Steam must be closed for Switch 2 initialization to succeed.

Build

# From Developer Command Prompt (vcvarsall.bat x64)
cd C:\Users\sonic\GitHub\SDL3-build\build
cmake --build . --config Release

Output SDL3.dll is copied to PadForge.App/Resources/SDL3/x64/.

Clone this wiki locally