Skip to content

SDL3 Integration

hifihedgehog edited this page May 20, 2026 · 33 revisions

SDL3 Integration

PadForge uses SDL3 as its sole input backend for all controller types, including Xbox/XInput. This page covers the P/Invoke layer, device enumeration, state reading, sensors, haptic force feedback, and the custom SDL3 fork for Switch 2 Pro Controller support.

flowchart TD
    START[SDL_GetJoysticks<br/>returns uint instanceId array<br/>HM virtuals are already filtered out by the SDL3 fork] --> LOOP{For each instanceId}
    LOOP --> OPEN_CHECK{In _openedSdlInstanceIds?}
    OPEN_CHECK -->|Yes| SKIP[Skip<br/>already tracked]
    OPEN_CHECK -->|No| OPEN[SdlDeviceWrapper.Open]
    OPEN --> FORCERAW{ForceRawMode set<br/>for this device?}
    FORCERAW -->|Yes| RAW[SDL_OpenJoystick<br/>raw axes · raw buttons · raw hats]
    FORCERAW -->|No| GP_CHECK{SDL_IsGamepad?}
    GP_CHECK -->|Yes| GAMEPAD[SDL_OpenGamepad<br/>standardized 6 axes · 11 buttons · 1 hat<br/>sensors · mapped button filtering]
    GP_CHECK -->|No| JOYSTICK[SDL_OpenJoystick<br/>raw axes · raw buttons · raw hats]
    RAW --> TRACK[FindOrCreateUserDevice<br/>LoadFromSdlDevice · IsOnline = true<br/>add to _openedSdlInstanceIds]
    GAMEPAD --> TRACK
    JOYSTICK --> TRACK
    TRACK --> LOOP

    style START fill:#e1f5fe
    style OPEN fill:#f3e5f5
    style GAMEPAD fill:#e8f5e9
    style JOYSTICK fill:#fff3e0
    style RAW fill:#fff3e0
    style TRACK fill:#e8f5e9
Loading

Source files:

  • PadForge.Engine/Common/SDL3Minimal.cs. SDL3 P/Invoke declarations
  • PadForge.Engine/Common/SdlDeviceWrapper.cs. Joystick/gamepad wrapper (state, rumble, haptic, GUID)
  • PadForge.Engine/Common/SdlKeyboardWrapper.cs. Keyboard device wrapper
  • PadForge.Engine/Common/SdlMouseWrapper.cs. Mouse device wrapper
  • PadForge.Engine/Common/RawInputListener.cs. Windows Raw Input for keyboard/mouse
  • PadForge.Engine/Common/ISdlInputDevice.cs. Common device interface
  • PadForge.App/Common/Input/InputManager.cs. SDL initialization, hints, polling loop
  • PadForge.App/Common/Input/InputManager.Step1.UpdateDevices.cs. Device enumeration loop (HM virtuals are filtered upstream by the SDL3 fork)
  • PadForge.App/Common/Input/InputManager.Step2.UpdateInputStates.cs. State reading, force feedback
  • PadForge.App/gamecontrollerdb_padforge.txt. Custom gamepad mappings

Contents


SDL3 P/Invoke Layer

File: PadForge.Engine/Common/SDL3Minimal.cs Namespace: SDL3

All SDL3 functions are [DllImport("SDL3")] P/Invoke bindings in the static class SDL3.SDL. Only functions PadForge uses are declared. Not a complete binding. The shipped SDL3.dll at PadForge.App/Resources/SDL3/x64/SDL3.dll is a custom fork build (see SDL3 Fork).

Key SDL3 vs SDL2 API Changes

SDL2 SDL3 Notes
SDL_NumJoysticks() + device index SDL_GetJoysticks() returning uint[] of instance IDs Instance IDs stable per session
SDL_IsGameController() SDL_IsGamepad() Renamed
SDL_GameControllerOpen() SDL_OpenGamepad() Takes instance ID, not device index
SDL_JoystickGetGUID() SDL_GetJoystickGUID() Returns SDL_GUID (was SDL_JoystickGUID)
SDL_INIT_GAMECONTROLLER SDL_INIT_GAMEPAD Renamed flag
Return int (negative = error) Return bool SDL3 uses C bool returns
SDL_JoystickCurrentPowerLevel SDL_GetJoystickPowerInfo() Returns SDL_PowerState + percentage
SDL_JoystickHasRumble() Properties system Via SDL_GetJoystickProperties() + SDL_GetBooleanProperty()
SDL_GetVersion(SDL_version*) SDL_GetVersion() returning packed int major * 1000000 + minor * 1000 + patch

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). P/Invoke pattern:

[DllImport(lib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "SDL_IsGamepad")]
[return: MarshalAs(UnmanagedType.U1)]
private static extern bool _SDL_IsGamepad(uint instance_id);

public static bool SDL_IsGamepad(uint instance_id) => _SDL_IsGamepad(instance_id);

The private _-prefixed extern uses MarshalAs(UnmanagedType.U1) for correct bool marshaling; the public wrapper provides a clean API surface.

String Marshaling

SDL3 returns UTF-8 const char* pointers. Pattern:

[DllImport(lib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "SDL_GetJoystickNameForID")]
private static extern IntPtr _SDL_GetJoystickNameForID(uint instance_id);

public static string SDL_GetJoystickNameForID(uint instance_id)
{
    return Marshal.PtrToStringUTF8(_SDL_GetJoystickNameForID(instance_id)) ?? string.Empty;
}

Memory-Owning Array Returns

SDL_GetJoysticks() returns a heap-allocated array the caller must free with SDL_free(). The C# wrapper handles this transparently:

public static uint[] SDL_GetJoysticks()
{
    IntPtr ptr = _SDL_GetJoysticks(out int count);
    if (ptr == IntPtr.Zero || count <= 0)
        return Array.Empty<uint>();
    try
    {
        var ids = new uint[count];
        for (int i = 0; i < count; i++)
            ids[i] = unchecked((uint)Marshal.ReadInt32(ptr, i * 4));
        return ids;
    }
    finally
    {
        SDL_free(ptr);
    }
}

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

SDL_HapticCondition

[StructLayout(LayoutKind.Sequential)]
public struct SDL_HapticCondition
{
    public ushort type;              // SDL_HAPTIC_SPRING / DAMPER / INERTIA / FRICTION
    private ushort _pad;
    public SDL_HapticDirection direction;
    public uint length;              // Duration in ms
    public ushort delay, button, interval;
    private ushort _pad2;
    // Per-axis arrays (3 axes max). Flattened as individual fields
    public ushort right_sat0, right_sat1, right_sat2;   // Positive saturation 0-65535
    public ushort left_sat0, left_sat1, left_sat2;      // Negative saturation 0-65535
    public short right_coeff0, right_coeff1, right_coeff2; // Positive coefficient
    public short left_coeff0, left_coeff1, left_coeff2;   // Negative coefficient
    public ushort deadband0, deadband1, deadband2;      // Dead band 0-65535
    public short center0, center1, center2;             // Center point
} // 68 bytes

Used for Spring, Damper, Friction, and Inertia effects. Each axis has independent coefficients, saturation, center, and deadband. SDL supports up to 3 axes; PadForge uses 2 (X and Y). Data flows from HMaestroFfbDecoder (parsing PID FFB packets emitted by the HM driver) through Vibration.ConditionAxes[] into ForceFeedbackState.SetConditionHapticForces(), which populates this struct.

SDL_HapticRamp

[StructLayout(LayoutKind.Sequential)]
public struct SDL_HapticRamp
{
    public ushort type;              // SDL_HAPTIC_RAMP
    private ushort _pad;
    public SDL_HapticDirection direction;
    public uint length;              // Duration in ms
    public ushort delay, button, interval;
    public short start;              // Start level -32767 to 32767
    public short end;                // End level -32767 to 32767
    public ushort attack_length, attack_level;
    public ushort fade_length, fade_level;
} // 44 bytes

Ramp force effect (linearly changing force from start to end level). Declared in SDL3Minimal.cs and included in the SDL_HapticEffect union. HMaestroFfbDecoder emits ramp from PID PT_RAMPREP packets, using max(abs(Start), abs(End)) as magnitude.

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;
    [FieldOffset(0)] public SDL_HapticCondition condition;
    [FieldOffset(0)] public SDL_HapticRamp ramp;
}

Uses LayoutKind.Explicit, Size=72 with overlaid sub-structs matching the C union. The 72-byte size provides a safety margin (largest member SDL_HapticCondition is 68 bytes on x64).

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

File: PadForge.App/Common/Input/InputManager.cs Method: private bool InitializeSdl()

SDL3 initialization runs once before the polling thread starts. Hints must be set before SDL_Init() because SDL reads them during subsystem initialization.

Subsystem Flags

SDL_Init(SDL_INIT_JOYSTICK | SDL_INIT_GAMEPAD | SDL_INIT_VIDEO | SDL_INIT_HAPTIC)
Flag Hex Purpose
SDL_INIT_JOYSTICK 0x0200 Joystick enumeration, polling, rumble
SDL_INIT_GAMEPAD 0x2000 Loads gamecontrollerdb; enables SDL_IsGamepad() / SDL_OpenGamepad()
SDL_INIT_VIDEO 0x0020 Required for SDL_GetKeyboards() / SDL_GetMice(). Side effect: disables screensaver and system sleep
SDL_INIT_HAPTIC 0x1000 Haptic force feedback for wheels, flight sticks, and devices without rumble

Post-Init Fixups

After SDL_Init() succeeds, two fixups counteract SDL_INIT_VIDEO side effects:

  1. SDL_EnableScreenSaver(). Re-enables the screensaver SDL disabled
  2. SetThreadExecutionState(ES_CONTINUOUS). Clears execution-state flags SDL set, restoring system sleep

A periodic sleep guard (sleepGuardTimer, every 5s) re-applies SetThreadExecutionState(ES_CONTINUOUS) because SDL may re-assert these flags during SDL_UpdateJoysticks().

Custom Gamepad Mappings

After initialization, PadForge loads its custom mapping file to extend SDL's built-in gamecontrollerdb for unrecognized devices. See Custom Gamepad Mappings for format and entries.

string mappingsPath = Path.Combine(AppContext.BaseDirectory, "gamecontrollerdb_padforge.txt");
if (File.Exists(mappingsPath))
    SDL_AddGamepadMappingsFromFile(mappingsPath);

SDL Hints (Complete Reference)

All hints are set before SDL_Init(). Order matters. SDL reads hints during subsystem startup.

Hint Value Rationale
SDL_HINT_JOYSTICK_ALLOW_BACKGROUND_EVENTS "1" Essential. Without this, SDL stops reading input when PadForge loses focus. A remapper must read input while games have focus.
SDL_HINT_JOYSTICK_XINPUT "1" Enables SDL's XInput backend for Xbox controller enumeration. Without this, Xbox controllers (USB or wireless adapter) do not appear in SDL_GetJoysticks(). Was SDL_HINT_XINPUT_ENABLED in SDL2.
SDL_HINT_JOYSTICK_HIDAPI_SWITCH2 "1" Activates the HIDAPI driver for the Switch 2 Pro Controller (PadForge's custom fork). Gates SDL_hidapi_switch2.c. Without it, the controller is ignored even though the driver code is compiled in.
SDL_HINT_VIDEO_ALLOW_SCREENSAVER "1" Counteracts SDL_INIT_VIDEO's default screensaver suppression. PadForge only needs VIDEO for keyboard/mouse enumeration.
SDL_HINT_JOYSTICK_RAWINPUT NOT SET Must not be "1". SDL3's raw input backend conflicts with XInput. Raw input claims Xbox controllers first, leaving XInput with no unclaimed devices. Discovered via Cemu comparison. Omitted (not "0" either) so SDL defaults to XInput for Xbox and HIDAPI for others.

Hint timing: SDL_SetHint() must precede SDL_Init(). Post-init hints have no effect on already-initialized subsystems.


Device Enumeration Flow (Step 1)

File: PadForge.App/Common/Input/InputManager.Step1.UpdateDevices.cs Method: private void UpdateDevices()

Runs every ~2s (EnumerationIntervalMs = 2000) on the background polling thread. The first cycle runs immediately (firstCycle = true) for instant controller detection. Two phases for joysticks, plus keyboard/mouse enumeration.

Phase 1: Open Newly Connected Joystick Devices

SDL_GetJoysticks() -> uint[] joystickIds
    |
    Build HashSet<uint> currentInstanceIds (for HIDMaestro cleanup later)
    |
    For each instanceId in joystickIds:
    |       (HIDMaestro virtuals never reach here — the SDL3 fork's
    |        substring-list filter drops them before SDL_GetJoysticks returns.
    |        Every instance ID at this point is a real device.)
    |
    |   1. Is it in _openedSdlInstanceIds?
    |       YES -> Skip (already open and tracked)
    |
    |   2. new SdlDeviceWrapper().Open(instanceId)
    |       |
    |       Open() internally:
    |       |   SDL_IsGamepad(instanceId)?
    |       |   |-- YES: SDL_OpenGamepad(instanceId)
    |       |   |        Joystick = SDL_GetGamepadJoystick(GameController)
    |       |   |-- NO:  SDL_OpenJoystick(instanceId)
    |       |
    |       |   Populate: Name, VID, PID, Path, Serial, Type
    |       |   RawButtonCount = SDL_GetNumJoystickButtons(Joystick)
    |       |   If gamepad: NumAxes=6, NumButtons=11 (+ Misc1, paddles, Touchpad, Misc2-6), NumHats=1
    |       |               ParseMappedButtonIndices(GameController)
    |       |   Else:       NumAxes/Buttons/Hats from raw joystick
    |       |   HID name fallback if Name is raw VID/PID
    |       |   Check rumble via SDL properties system
    |       |   Enable gyro/accel sensors (gamepad only)
    |       |   OpenHaptic() for force feedback
    |       |   Build ProductGuid + InstanceGuid
    |       |
    |       Open() failed? -> Dispose wrapper, continue to next
    |
    |   3. FindOrCreateUserDevice(wrapper.InstanceGuid, wrapper.ProductGuid)
    |       |   Exact match by InstanceGuid? -> return existing
    |       |   Fallback match by ProductGuid (offline device)? -> migrate GUID
    |       |   No match? -> create new UserDevice
    |
    |   4. ud.LoadFromSdlDevice(wrapper)  -- populates UserDevice runtime state
    |      ud.IsOnline = true
    |      _openedSdlInstanceIds.Add(wrapper.SdlInstanceId)
    |      changed = true

Why instance IDs, not GUIDs: _openedSdlInstanceIds uses SDL instance IDs (uint) because serial-based GUIDs (Bluetooth devices) are unavailable until the device is opened. Instance IDs are assigned at connection time and unique per session.

Phase 1b/1c: Keyboard and Mouse Enumeration

Keyboards and mice use RawInputListener.EnumerateKeyboards() / EnumerateMice() via Windows Raw Input (not SDL). Each new device gets a SdlKeyboardWrapper or SdlMouseWrapper, with UserDevice populated via LoadFromKeyboardDevice() / LoadFromMouseDevice().

Tracking uses _openedKeyboardHandles and _openedMouseHandles (HashSet<IntPtr>) keyed on Raw Input handles.

Why not SDL for keyboards/mice: SDL's APIs report system-wide state without distinguishing physical devices. PadForge's per-device mapping requires per-device tracking, which only Windows Raw Input provides.

Phase 2: Detect Disconnected Devices

Each tracked SDL instance ID is checked via IsAttached (SDL_JoystickConnected()). If disconnected, MarkDeviceOffline():

  1. Stops rumble via ForceFeedbackState.StopDeviceForces() (best-effort)
  2. Disposes the SDL handle via ud.Device.Dispose() (best-effort)
  3. Calls ud.ClearRuntimeState() (nulls Device, InputState, ForceFeedbackState, etc.)
  4. Removes the SDL instance ID from _openedSdlInstanceIds

Keyboard/mouse disconnection is detected by comparing tracked handles against RawInputListener.EnumerateKeyboards()/EnumerateMice(), using the same MarkDeviceOffline() path.

Opened-Device Tracking Set

// Devices already opened. Keyed on SDL instance ID (uint)
private readonly HashSet<uint> _openedSdlInstanceIds = new();

_openedSdlInstanceIds records every device PadForge has called SDL_OpenJoystick on, so the engine does not re-open the same device twice per cycle. The set is pruned each cycle via IntersectWith(currentInstanceIds) to remove IDs for devices that no longer appear in the SDL enumeration.

HIDMaestro virtual controllers are filtered upstream by PadForge's SDL3 fork: the patched SDL_GetJoysticks walks each device's container ID up to the HIDMaestro root enumerator and drops any match before returning the list. This avoids the rumble-killing close path that earlier in-engine filtering used to guard against (SDL_CloseJoystick calls XInputSetState(slot, 0, 0) as cleanup, which would trigger FeedbackReceived(0, 0) on the HIDMaestro bus). The fork's filter means HM devices never enter the engine's open/close cycle at all.

UserDevice Lookup and GUID Migration

FindOrCreateUserDevice(Guid instanceGuid, Guid productGuid). Three-tier matching:

  1. Exact match by InstanceGuid. Returns existing device with preserved settings
  2. Fallback match by ProductGuid against offline devices. Handles Bluetooth reconnections with changed device paths/InstanceGuid. Migrates the old GUID and updates UserSetting via MigrateUserSettingGuid()
  3. Create new. Adds a fresh UserDevice

All lookups use manual for loops (not LINQ) to avoid closure allocations in the hot path.

Orphaned Handle Pruning

PruneOrphanedHandles() runs before keyboard/mouse enumeration. It removes tracked handles whose UserDevice was deleted via the UI "Remove" command. Without this, deleted devices could never be re-detected because stale handles would remain in _openedKeyboardHandles / _openedMouseHandles.


Device Filtering (HIDMaestro)

HIDMaestro virtual controllers (Xbox / PlayStation / Extended) appear as real input devices to SDL3 by default. Without filtering, SDL would enumerate PadForge's own outputs as inputs, the engine would map them back to themselves, and a feedback loop would create controllers exponentially.

PadForge filters them at the SDL3 fork level. The fork's patched enumeration walks each device's PnP parent chain looking for HIDMAESTRO in the Hardware ID list, with a substring fast path against the interface symlink before falling back to the parent walk. Any device that matches is dropped before SDL_GetJoysticks returns. HM virtuals never appear in the enumeration the engine consumes.

The previous in-engine filter (IsHIDMaestroVirtualDevice in InputManager.Step1) is gone. The engine no longer needs the per-cycle classification by device path, VID/PID, or active/expected count. The fork-side filter is upstream of every consumer (engine, SDL_OpenJoystick, SDL_CloseJoystick), so the rumble-killing close path that the in-engine filter used to guard against can't fire on HM devices.

For the SDL3 fork patches, see PadForge's SDL3 fork branch feat/hidmaestro-filter. The OpenXInput fork carries its own complementary filter for the XInput API surface, documented in HIDMaestro Deep Dive.


SdlDeviceWrapper Class

File: PadForge.Engine/Common/SdlDeviceWrapper.cs Implements: ISdlInputDevice, IDisposable

Wraps an SDL joystick (and optionally its Gamepad overlay) for unified device access. One instance per physical device.

Properties

Property Type Description
Joystick IntPtr Raw SDL joystick handle; always valid when open
GameController IntPtr SDL Gamepad handle; IntPtr.Zero if not a gamepad
SdlInstanceId uint SDL instance ID (unique per session); 0 = invalid
NumAxes int 6 for gamepads, raw count for joysticks
NumButtons int 11 for gamepads, raw count for joysticks
RawButtonCount int Raw button count before gamepad remapping
NumHats int 1 for gamepads, raw count for joysticks
HasRumble bool Supports SDL_RumbleJoystick
Haptic IntPtr SDL haptic handle; non-zero when FFB is open
HapticFeatures uint Bitmask of SDL_HAPTIC_* flags
HasHaptic bool Haptic != IntPtr.Zero
HapticStrategy HapticEffectStrategy Best strategy: LeftRight > Sine > Constant
HasGyro / HasAccel bool Device has motion sensors
Name string Human-readable device name
VendorId ushort USB Vendor ID
ProductId ushort USB Product ID
ProductVersion ushort USB Product Version
DevicePath string Device file system path
JoystickType SDL_JoystickType Device classification
SerialNumber string Serial (e.g., BT MAC address)
InstanceGuid Guid Deterministic GUID for settings matching
ProductGuid Guid VID+PID-based GUID
IsGameController bool GameController != IntPtr.Zero
IsAttached bool SDL_JoystickConnected(Joystick)

Force Raw Mode

Property: ForceRawJoystickMode (per-device setting in UserDevice)

When SDL3's gamecontrollerdb mapping produces incorrect results, the user can enable Force Raw Mode on the Devices page. This bypasses the Gamepad API and reads raw joystick indices.

Behavior per layer:

Layer Normal Mode Force Raw Mode
Open (SdlDeviceWrapper.Open) SDL_OpenGamepad() if SDL_IsGamepad() Same. Still opened as gamepad
Read (GetCurrentState(forceRaw)) GetGamepadState() GetJoystickState() via forceRaw = true
UI (Devices page) Shows 11 gamepad buttons Shows RawButtonCount buttons
Auto-mapping Standard gamepad mapping Skipped. User must manually record

Important: Force Raw Mode only changes the read path, not how the device is opened. The device stays opened as a Gamepad (if recognized), with sensors enabled. Re-opening would require a close/open cycle that could cause input drops.

Primary use case: DualShock 3 via DsHidMini SDF mode, where SDL's built-in mapping incorrectly mapped buttons due to the non-standard HID layout.

Files: SdlDeviceWrapper.cs (read dispatch), InputManager.Step2.UpdateInputStates.cs (passes ud.ForceRawJoystickMode), DeviceService.cs (settings sync), DevicesPage.xaml (toggle UI)

Open Flow

public bool Open(uint instanceId)
  1. Try Gamepad first: SDL_IsGamepad() -> SDL_OpenGamepad(), get joystick via SDL_GetGamepadJoystick()
  2. Fall back to Joystick: SDL_OpenJoystick() if not a gamepad or open failed
  3. Populate properties: name, VID, PID, path, serial, type from joystick handle
  4. Capture raw button count before gamepad override: RawButtonCount = SDL_GetNumJoystickButtons()
  5. Gamepad layout override: NumAxes=6, NumButtons=11, NumHats=1
  6. HID name fallback: if Name is raw VID/PID (e.g., "0x16c0/0x05e1"), query HidD_GetProductString
  7. Check rumble via SDL_GetJoystickProperties() + SDL_GetBooleanProperty()
  8. Enable sensors: SDL_GamepadHasSensor() -> SDL_SetGamepadSensorEnabled(true) (gamepad only)
  9. Open haptic: OpenHaptic() for FFB 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

Method: private void OpenHaptic() in SdlDeviceWrapper.cs

Decision tree determining whether a device uses simple rumble (SDL_RumbleJoystick), haptic effects (SDL_RunHapticEffect), or neither. Decided once at open time; stored in HapticStrategy.

OpenHaptic()
    |
    SDL_OpenHapticFromJoystick(Joystick)
    |-- Failed (null)? -> return (no haptic support, use simple rumble if HasRumble)
    |
    features = SDL_GetHapticFeatures(haptic)
    |-- features == 0? -> Close haptic, return (empty feature set)
    |
    HasRumble AND (features & SDL_HAPTIC_LEFTRIGHT)?
    |-- YES: Close haptic, return
    |        Rationale: Simple rumble via SDL_RumbleJoystick is more reliable
    |        for gamepads than haptic LeftRight effects. Haptic is only needed
    |        for devices where simple rumble doesn't work.
    |
    |-- NO: Keep haptic open, pick best strategy:
            |
            (features & SDL_HAPTIC_LEFTRIGHT)?
            |-- YES: HapticStrategy = LeftRight (best. Independent L/R motors)
            |
            (features & SDL_HAPTIC_SINE)?
            |-- YES: HapticStrategy = Sine (good. Periodic vibration)
            |
            (features & SDL_HAPTIC_CONSTANT)?
            |-- YES: HapticStrategy = Constant (acceptable. Steady force)
            |
            None of the above?
            |-- Close haptic, return (no usable effect types)
            |
            NumHapticAxes = SDL_GetNumHapticAxes(haptic)
            |   1 axis -> wheels (single-axis FFB: Spring/Damper on steering axis)
            |   2+ axes -> joysticks, gamepads (X+Y axis condition effects)
            |
            (features & SDL_HAPTIC_GAIN)?
            |-- YES: SDL_SetHapticGain(haptic, 100) -- maximize gain
Priority Feature Flag Strategy Typical Devices
1 (best) SDL_HAPTIC_LEFTRIGHT HapticEffectStrategy.LeftRight Gamepads without simple rumble
2 SDL_HAPTIC_SINE HapticEffectStrategy.Sine Racing wheels, flight sticks
3 SDL_HAPTIC_CONSTANT HapticEffectStrategy.Constant Older FFB devices

NumHapticAxes is critical for ForceFeedbackState: it determines whether condition effects (Spring, Damper, Friction, Inertia) use 1 axis (wheels: steering only) or 2 axes (joysticks: X and Y).


Gamepad vs Joystick API

PadForge uses two SDL APIs depending on whether the device is in SDL's gamecontrollerdb. The choice is made at open time and cannot change without re-opening.

Condition API Used State Method Axis/Button Counts
SDL_IsGamepad() true and ForceRawMode false Gamepad API GetGamepadState() 6 axes, 11 buttons, 1 hat (standardized)
SDL_IsGamepad() false, or ForceRawMode true Joystick API GetJoystickState() Raw counts from HID descriptor

ForceRawMode is a per-device setting (UserDevice.ForceRawJoystickMode), passed via GetCurrentState(bool forceRaw) in Step 2. When true, GetJoystickState() is called even if GameController != IntPtr.Zero.

Gamepad API (GetGamepadState())

Used when GameController != IntPtr.Zero and forceRaw is false. Reads through SDL's mapping layer, which remaps DualSense, DualShock, Switch Pro, DS3, etc. to a standardized Xbox-like layout.

Axis layout (CustomInputState.Axis[0..5]):

Index Gamepad Axis SDL Enum Range Conversion
0 Left Stick X SDL_GAMEPAD_AXIS_LEFTX (0) signed -> unsigned: (ushort)(raw - short.MinValue)
1 Left Stick Y SDL_GAMEPAD_AXIS_LEFTY (1) signed -> unsigned
2 Left Trigger SDL_GAMEPAD_AXIS_LEFT_TRIGGER (4) 0..32767 -> 0..65535: raw * 65535L / 32767
3 Right Stick X SDL_GAMEPAD_AXIS_RIGHTX (2) signed -> unsigned
4 Right Stick Y SDL_GAMEPAD_AXIS_RIGHTY (3) signed -> unsigned
5 Right Trigger SDL_GAMEPAD_AXIS_RIGHT_TRIGGER (5) 0..32767 -> 0..65535

SDL puts triggers at indices 4/5, but PadForge reorders them to indices 2/5 to match the LX(0), LY(1), LT(2), RX(3), RY(4), RT(5) convention used throughout the mapping pipeline.

Button layout (CustomInputState.Buttons[0..21+]):

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 Misc 1 (Share) SDL_GAMEPAD_BUTTON_MISC1 (15)
12 Right Paddle 1 SDL_GAMEPAD_BUTTON_RIGHT_PADDLE1 (16)
13 Left Paddle 1 SDL_GAMEPAD_BUTTON_LEFT_PADDLE1 (17)
14 Right Paddle 2 SDL_GAMEPAD_BUTTON_RIGHT_PADDLE2 (18)
15 Left Paddle 2 SDL_GAMEPAD_BUTTON_LEFT_PADDLE2 (19)
16 Touchpad click SDL_GAMEPAD_BUTTON_TOUCHPAD (20)
17 Misc 2 SDL_GAMEPAD_BUTTON_MISC2 (21)
18 Misc 3 SDL_GAMEPAD_BUTTON_MISC3 (22)
19 Misc 4 SDL_GAMEPAD_BUTTON_MISC4 (23)
20 Misc 5 SDL_GAMEPAD_BUTTON_MISC5 (24)
21 Misc 6 SDL_GAMEPAD_BUTTON_MISC6 (25)
22+ Extra raw SDL_GetJoystickButton(Joystick, i) (filtered)

Button indices differ from SDL's SDL_GamepadButton enum. PadForge reorders to match CreateDefaultPadSetting() (e.g., Back at 6, Guide at 10 instead of SDL's 4 and 5).

Guide button suppression: When Back+Start+Guide are all pressed, Guide is forced false. This suppresses a Windows/XInput quirk where the system synthesizes Guide from Back+Start.

Extra raw buttons (22+): After the 22 standardized SDL gamepad-button positions (0-10 PadForge standard + 11-21 SDL extended), GetGamepadState() appends raw buttons via SDL_GetJoystickButton() for indices 22 through RawButtonCount. This exposes native device buttons that are not part of any SDL gamepad-button enum for use as macro triggers. Already-mapped buttons (raw indices SDL already consumed for the gamepad mapping) are skipped (see Mapped Button Filtering).

D-pad to POV[0]: Four D-pad buttons synthesized into a single POV value via DpadToCentidegrees(), supporting all 8 directions.

Sensors: SDL_GetGamepadSensorData() populates state.Gyro[3] (rad/s) and state.Accel[3] (m/s^2). Only available via the Gamepad API; must be enabled during Open().

Joystick API (GetJoystickState())

Used for unrecognized devices (flight sticks, wheels, generic HID) or when ForceRawMode is enabled. Reads raw axes, buttons, and hats without remapping.

  • Axes: First MaxAxis (24) go to Axis[], overflow to Sliders[]. Signed -> unsigned: (ushort)(raw - short.MinValue) maps -32768..32767 to 0..65535
  • Hats: SDL bitmask -> centidegrees via HatToCentidegrees(), stored in Povs[]
  • Buttons: Uses RawButtonCount (not NumButtons) to read all physical buttons. Critical for gamepad devices switched to ForceRawMode. NumButtons caps at 11, but RawButtonCount preserves the actual HID count. See RawButtonCount vs NumButtons Fix

RawButtonCount vs NumButtons Fix

Problem: Gamepad open sets NumButtons = 11. If the user enables ForceRawMode, GetJoystickState() would only read 11 buttons, silently dropping extras (e.g., DS3 via DsHidMini SDF has 17).

Solution: RawButtonCount is captured from SDL_GetNumJoystickButtons() before the gamepad override. GetJoystickState() uses it when available:

int btnCount = Math.Min(
    RawButtonCount > 0 ? RawButtonCount : NumButtons,
    state.Buttons.Length);

All physical buttons are readable regardless of open mode.

Mapped Button Filtering (ParseMappedButtonIndices)

Problem: Extra raw buttons (11+) appended in GetGamepadState() may already be consumed by the gamepad mapping (e.g., DS3 SDF maps b11 -> RB, b12 -> Guide). Without filtering, these buttons are double-reported.

Solution: ParseMappedButtonIndices() parses the SDL mapping string to build a HashSet<int> of consumed raw indices:

private static HashSet<int> ParseMappedButtonIndices(IntPtr gameController)
{
    string mapping = GetGamepadMapping(gameController);
    // Mapping format: "GUID,name,a:b2,b:b1,...,platform:Windows,"
    // Parse all "bN" values to find consumed raw button indices.
    foreach (var segment in mapping.Split(','))
    {
        int colonIdx = segment.IndexOf(':');
        if (colonIdx < 0) continue;
        string value = segment.Substring(colonIdx + 1);
        if (value.Length > 1 && value[0] == 'b' && int.TryParse(value.Substring(1), out int btnIdx))
            indices.Add(btnIdx);
    }
}

In the extra raw button loop, any index in _mappedRawButtonIndices is skipped:

for (int i = 11; i < rawCount && i < CustomInputState.MaxButtons; i++)
{
    if (_mappedRawButtonIndices != null && _mappedRawButtonIndices.Contains(i))
        continue;  // Already mapped by gamepad API. Skip to avoid double-reporting
    state.Buttons[i] = SDL_GetJoystickButton(Joystick, i);
}

_mappedRawButtonIndices is populated during Open() for gamepads; null for raw joystick devices.

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

Verified working with DualSense and Switch 2 Pro Controller. Derived from Switch Pro's 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 DirectInput uses. PadForge detects XInput devices 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. The button count uses Math.Max(NumButtons, RawButtonCount) so that raw buttons 11+ (beyond the standard gamepad set) are exposed in the source dropdown for mapping. Maps to well-known GUIDs matching DirectInput convention:

Axis Index ObjectTypeGuid Name
0 ObjectGuid.XAxis "X Axis"
1 ObjectGuid.YAxis "Y Axis"
2 ObjectGuid.ZAxis "Z Axis"
3 ObjectGuid.RxAxis "X Rotation"
4 ObjectGuid.RyAxis "Y Rotation"
5 ObjectGuid.RzAxis "Z Rotation"
6+ ObjectGuid.Slider "Slider N"

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 triggers auto-mapping via SettingsManager.CreateDefaultPadSetting() with the standardized SDL3 gamepad layout.


HID Product String Fallback

File: PadForge.Engine/Common/SdlDeviceWrapper.cs Methods: IsRawVidPidName(), TryGetHidProductString()

SDL3 may return a raw VID/PID string (e.g., "0x16c0/0x05e1") for devices not in its name database. Typically niche HID devices where SDL falls back to formatting the USB VID/PID.

Detection

IsRawVidPidName() checks for names starting with "0x" containing '/', minimum 11 characters. Matches SDL's fallback format without false positives on real names.

Fallback Query

When detected, TryGetHidProductString() queries the Windows HID class driver for the product string:

IntPtr handle = CreateFile(devicePath, 0, FILE_SHARE_READ | FILE_SHARE_WRITE,
    IntPtr.Zero, OPEN_EXISTING, 0, IntPtr.Zero);
HidD_GetProductString(handle, buffer, 512);

Details:

  • Opened with zero access rights (dwDesiredAccess = 0). HidD_GetProductString needs no read/write access
  • FILE_SHARE_READ | FILE_SHARE_WRITE allows querying while other processes hold the device
  • 512-byte buffer, decoded as UTF-16; trimmed of null terminators
  • Failures swallowed. Returns null, keeping the raw VID/PID name
  • P/Invoke declarations (CreateFile, CloseHandle, HidD_GetProductString) are private to SdlDeviceWrapper

Rumble Implementation

File: PadForge.Engine/Common/SdlDeviceWrapper.cs

API Surface

public bool SetRumble(ushort lowFreq, ushort highFreq, uint durationMs = uint.MaxValue)
public bool StopRumble()  // SetRumble(0, 0, 0)

Duration Strategy: uint.MaxValue

SetRumble() uses uint.MaxValue (~4,294,967,295 ms, ~49 days) as the default duration, making rumble effectively indefinite. The caller stops it by calling StopRumble() or SetRumble(newL, newR).

Why not refresh each frame? SDL_RumbleJoystick restarts the motor on every call, even with identical values. On some hardware, this creates perceptible stutter. uint.MaxValue avoids this.

Change Detection

ForceFeedbackState tracks last motor values sent. SetRumble() is only called when values change:

Frame 1: combinedL=30000, combinedR=20000 -> SetRumble(30000, 20000) -- sent
Frame 2: combinedL=30000, combinedR=20000 -> no-op (same values)
Frame 3: combinedL=30000, combinedR=0     -> SetRumble(30000, 0)    -- sent (highFreq changed)
Frame 4: combinedL=0,     combinedR=0     -> StopRumble()           -- sent (both zero)

This eliminates hardware restart gaps from calling SDL_RumbleJoystick every frame with unchanged values.

Rumble Capability Detection

Detected during Open() via SDL3's properties system (replaces SDL2's SDL_JoystickHasRumble()):

uint props = SDL_GetJoystickProperties(Joystick);
HasRumble = props != 0 && SDL_GetBooleanProperty(props, "SDL.joystick.cap.rumble", false);

SetRumble() returns false immediately if HasRumble is false or the joystick handle is invalid.

Stop on Disconnect

On disconnect, MarkDeviceOffline() calls ForceFeedbackState.StopDeviceForces() before disposing the SDL handle, ensuring rumble stops cleanly.


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 maps to multiple virtual controller slots, vibration is combined using max-of-each-motor:

ushort combinedL = 0, combinedR = 0;
for (int i = 0; i < slotCount; i++)
{
    var vib = VibrationStates[padIndex];
    if (vib.LeftMotorSpeed > combinedL)  combinedL = vib.LeftMotorSpeed;
    if (vib.RightMotorSpeed > combinedR) combinedR = vib.RightMotorSpeed;
}

Rumble from any mapped slot reaches the physical controller. Test rumble targeting via TestRumbleTargetGuid[padIndex] is also supported for multi-device slots.


ISdlInputDevice Interface

File: PadForge.Engine/Common/ISdlInputDevice.cs

Common interface for all device wrappers; enables uniform state reading across the pipeline.

public interface ISdlInputDevice : IDisposable
{
    uint SdlInstanceId { get; }
    string Name { get; }
    int NumAxes { get; }
    int NumButtons { get; }
    int RawButtonCount { get; }
    int NumHats { get; }
    bool HasRumble { get; }
    bool HasHaptic { get; }
    bool HasGyro { get; }
    bool HasAccel { get; }
    HapticEffectStrategy HapticStrategy { get; }
    IntPtr HapticHandle { get; }
    uint HapticFeatures { get; }
    int NumHapticAxes { get; }
    bool IsAttached { get; }
    ushort VendorId { get; }
    ushort ProductId { get; }
    Guid InstanceGuid { get; }
    Guid ProductGuid { get; }
    string DevicePath { get; }
    string SerialNumber { get; }

    CustomInputState GetCurrentState(bool forceRaw = false);
    DeviceObjectItem[] GetDeviceObjects();
    int GetInputDeviceType();
    bool SetRumble(ushort low, ushort high, uint durationMs = uint.MaxValue);
    bool StopRumble();
}

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 Windows Raw Input (not SDL) for per-device tracking. SDL's keyboard/mouse APIs cannot distinguish multiple physical devices, which PadForge's per-device mapping requires.

File: PadForge.Engine/Common/RawInputListener.cs

  • Hidden HWND_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()

Custom Gamepad Mappings (gamecontrollerdb_padforge.txt)

File: PadForge.App/gamecontrollerdb_padforge.txt (deployed next to exe)

SDL's Gamepad API looks up a device's SDL GUID in gamecontrollerdb and applies a mapping string to remap raw inputs to the standard layout. PadForge extends this with a custom file loaded via SDL_AddGamepadMappingsFromFile() after init.

File Format

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)

SDL GUID Format

The 32-character hex GUID encodes VID, PID, and a CRC of the device name. Bytes 2–3 are a CRC16, not zero. The same VID/PID can have different GUIDs depending on the driver-reported name. Introduced in SDL 2.0.12 to disambiguate devices with identical VID/PID but different capabilities.

Current Mappings

Sony DualShock 3 (DsHidMini SDF and SXS modes)

0300d8234c0500006802000000000000,Sony DualShock 3 (DsHidMini SDF and SXS),
  a:b2,b:b1,x:b3,y:b0,
  back:b4,start:b7,guide:b12,
  leftshoulder:b10,rightshoulder:b11,
  leftstick:b5,rightstick:b6,
  leftx:a0,lefty:a1,rightx:a2,righty:a5,
  lefttrigger:a3,righttrigger:a4,
  dpup:h0.1,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,
  platform:Windows,

DS3 via DsHidMini SDF has a non-standard button layout. SDL's built-in gamecontrollerdb excludes it because the GUID's CRC differs (DsHidMini reports a different device name). This mapping remaps 17 raw buttons and 6 axes to the standard layout.

Key mappings: b12 -> Guide (PS), b11/b10 -> RB/LB (R1/L1), a3/a4 -> LT/RT (analog), hat 0 -> D-pad.

Works with both SDF and SXS (sixaxis.sys emulation) modes. Same raw HID layout.

Adding New Mappings

Two paths depending on whether you are a user contributing a mapping or a maintainer integrating one.

User submission path (preferred)

  1. Plug the device in. The Devices page shows a Submit Mapping button on the card for any joystick-class HID that is not already recognized as a gamepad.
  2. Click it. A pre-filled GitHub issue opens in the browser. The handler (SubmitMapping_Click in PadForge.App/Views/DevicesPage.xaml.cs) builds a URL against the device_mapping.yml template with device_name, vid, pid, axes, buttons, hats, and sdl_guid populated automatically from the live SDL enumeration. No manual GUID transcription, which is the field most prone to typos.
  3. The user fills in the per-input mapping tables (raw axis index for each Xbox 360 axis, raw button index for each Xbox 360 button) by reading them off the same Devices page's raw input visualization while pressing each control.
  4. The user submits the issue.

Maintainer integration path

  1. Take the merged mapping submission. SDL GUID and raw indices are in the issue body.
  2. Construct the SDL gamepad mapping string. Format: GUID,Device Name,a:bN,b:bN,x:bN,y:bN,leftshoulder:bN,rightshoulder:bN,leftx:aN,lefty:aN,righttrigger:aN,…,platform:Windows,. See the file's existing entries for examples (DS3 DsHidMini, G920, etc.).
  3. Append the line to PadForge.App/gamecontrollerdb_padforge.txt.
  4. Build and deploy locally. Restart PadForge with the device plugged in and confirm SDL recognizes it as a gamepad on the Devices page (the Submit Mapping button should now disappear, indicating SDL has it mapped).
  5. Commit and push. The file is an <EmbeddedResource> in PadForge.App.csproj, so it ships inside PadForge.exe; no separate file deployment.

If working from raw indices without the device in hand (e.g., reviewing a community submission), SDL2 Gamepad Tool is a useful sanity check for the mapping string format.


Device Change Detection (IsAttached, MarkDeviceOffline)

Connection Check: IsAttached

Property: SdlDeviceWrapper.IsAttached

public bool IsAttached
{
    get
    {
        if (Joystick == IntPtr.Zero)
            return false;
        return SDL_JoystickConnected(Joystick);
    }
}

SDL_JoystickConnected() returns false when disconnected. Checked every 2s in Phase 2 of UpdateDevices(), and also in UpdateInputStates() (Step 2). If GetCurrentState() returns null, the device is marked offline immediately without waiting for the next enumeration.

Offline Transition: MarkDeviceOffline()

Method: InputManager.MarkDeviceOffline(UserDevice ud)

Full cleanup on disconnect:

  1. Stop force feedback: StopDeviceForces(). Sends rumble stop before handle close (best-effort, try/catch)
  2. Dispose SDL handle: CloseInternal(). Closes haptic, gamepad, joystick in order (best-effort)
  3. Clear runtime state: ClearRuntimeState(). Nulls Device, InputState, ForceFeedbackState; sets IsOnline=false. The UserDevice record remains for settings preservation

The SDL instance ID is removed from _openedSdlInstanceIds by the caller, enabling the device to be re-detected if reconnected.


SDL3 Fork: Switch 2 Pro Controller Support

PadForge ships a custom SDL3 fork (source: C:\Users\sonic\GitHub\SDL3-build\SDL\) adding Switch 2 Pro Controller support on Windows. Binary: PadForge.App/Resources/SDL3/x64/SDL3.dll.

Why a Fork

Upstream SDL3 does not include Switch 2 Pro Controller support. The controller uses a USB composite device architecture requiring both HID and WinUSB access, which no existing SDL driver handles.

Architecture

USB composite device with two interfaces:

Interface Protocol Purpose
HID Interface 0 Standard HID reports Input reading + rumble (output report ID 0x02)
Bulk Interface 1 WinUSB Initialization sequence (10 commands via SendBulkData)

Interface 1 uses a WinUSB-compatible INF (DeviceInterfaceGUID {6F13725E-EF0E-4FD3-AE5F-B2DE989EC825}, MI_01). Must be opened during init to send the bulk command sequence that enables full-featured input mode.

Key Fork Modifications

File Change
SDL_hidapi.c Filter Switch 2 PIDs from libusb on Windows (libusb cannot open individual interfaces of a composite device); keep in platform HID backend
SDL_hidapi_switch2.c New HIDAPI driver: WinUSB bulk init, HID input parsing, rumble output, stick/sensor calibration

WinUSB Details

  • FILE_FLAG_OVERLAPPED required for CreateFile on the WinUSB interface; without it, WinUsb_Initialize fails
  • Init sequence uses overlapped I/O with per-transfer timeouts
  • Calibration via WinUSB bulk transfers; timeout (-7) after re-plug is non-fatal. Falls back to default calibration

SDL Hint: SDL_HINT_JOYSTICK_HIDAPI_SWITCH2

Gates the Switch 2 driver. Set to "1" before SDL_Init(). Without it, the compiled-in driver is never activated.

Steam Conflict

Steam exclusively locks WinUSB Interface 1 on detection. Since the init sequence requires exclusive access, Steam must be closed before PadForge can initialize the controller. HID input (Interface 0) works with Steam running, but the controller operates in limited mode without initialization.

Build Instructions

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


See Also

  • Architecture Overview: Why SDL3 (not DirectInput or raw XInput), SDL3 hints
  • Engine Library: SdlDeviceWrapper, ISdlInputDevice, CustomInputState, HapticEffectStrategy
  • Input Pipeline: Step 1 (SDL enumeration), Step 2 (SDL state reading), force feedback
  • Build and Publish: SDL3.dll and libusb-1.0.dll content items, native DLL deployment
  • Virtual Controllers: HIDMaestro filtering in Step 1 to avoid re-opening virtual controllers

Clone this wiki locally