Skip to content

SDL3 Integration

hifihedgehog edited this page Mar 19, 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 (state reading, rumble, haptic, GUID construction)
  • PadForge.Engine/Common/SdlKeyboardWrapper.cs — keyboard device wrapper
  • PadForge.Engine/Common/SdlMouseWrapper.cs — mouse device wrapper
  • PadForge.Engine/Common/RawInputListener.cs — Windows Raw Input for keyboard/mouse
  • PadForge.Engine/Common/ISdlInputDevice.cs — common device interface
  • PadForge.App/Common/Input/InputManager.cs — SDL initialization, hints, polling loop
  • PadForge.App/Common/Input/InputManager.Step1.UpdateDevices.cs — device enumeration, ViGEm filtering
  • PadForge.App/Common/Input/InputManager.Step2.UpdateInputStates.cs — state reading + force feedback
  • PadForge.App/gamecontrollerdb_padforge.txt — custom gamepad mappings

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);
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
    public SDL_HapticDirection direction;
    public uint length;              // Duration in ms
    public ushort delay, button, interval;
    // Per-axis arrays (3 axes max) — flattened as individual fields
    public ushort right_sat0, right_sat1, right_sat2;   // Positive saturation 0-65535
    public ushort left_sat0, left_sat1, left_sat2;      // Negative saturation 0-65535
    public short right_coeff0, right_coeff1, right_coeff2; // Positive coefficient
    public short left_coeff0, left_coeff1, left_coeff2;   // Negative coefficient
    public ushort deadband0, deadband1, deadband2;      // Dead band 0-65535
    public short center0, center1, center2;             // Center point
} // 68 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 up to 2 (X and Y). The condition data flows from vJoy FFB callback through Vibration.ConditionAxes[] to ForceFeedbackState.SetConditionHapticForces() which populates this struct.

SDL_HapticRamp

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

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

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. Size of 72 bytes provides a safety margin (largest actual 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 happens once before the polling thread starts. Hints must be set before SDL_Init() is called, 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 Core joystick enumeration, polling, and rumble
SDL_INIT_GAMEPAD 0x2000 Loads the gamecontrollerdb mapping database; enables SDL_IsGamepad() / SDL_OpenGamepad()
SDL_INIT_VIDEO 0x0020 Required for keyboard/mouse state functions (SDL_GetKeyboards(), SDL_GetMice()). Side effect: SDL disables the screensaver and system sleep by default when VIDEO is initialized
SDL_INIT_HAPTIC 0x1000 Haptic force feedback for wheels, flight sticks, and devices without simple rumble

Post-Init Fixups

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

  1. SDL_EnableScreenSaver() — re-enables the Windows screensaver that SDL disabled
  2. SetThreadExecutionState(ES_CONTINUOUS) — clears any execution-state flags SDL may have set, allowing the PC to enter sleep mode

A periodic sleep guard (sleepGuardTimer, every 5 seconds) in the polling loop re-applies SetThreadExecutionState(ES_CONTINUOUS) because SDL may re-assert execution-state flags during SDL_UpdateJoysticks() / event processing.

Custom Gamepad Mappings

After initialization, PadForge loads its community mapping file:

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

This extends SDL's built-in gamecontrollerdb with PadForge-specific mappings for devices that SDL does not recognize out of the box. See Custom Gamepad Mappings below for the file format and current entries.

SDL Hints (Complete Reference)

All hints are set before SDL_Init(). The order matters because SDL reads hints during subsystem startup.

Hint Value Rationale
SDL_HINT_JOYSTICK_ALLOW_BACKGROUND_EVENTS "1" Essential for a remapper. Without this, SDL stops reading joystick input when PadForge loses focus. Since PadForge's entire purpose is to translate input to virtual controllers while games have focus, background input reading is mandatory.
SDL_HINT_JOYSTICK_XINPUT "1" Enables SDL's XInput backend for Xbox controller enumeration. Without this, Xbox controllers connected via the Xbox Wireless Adapter or USB would not appear in SDL_GetJoysticks(). This was SDL_HINT_XINPUT_ENABLED in SDL2.
SDL_HINT_JOYSTICK_HIDAPI_SWITCH2 "1" Enables the HIDAPI driver for the Nintendo Switch 2 Pro Controller, added in PadForge's custom SDL3 fork. This hint gates the SDL_hidapi_switch2.c driver. Without it, the Switch 2 Pro Controller is ignored by SDL even though the fork code is compiled in.
SDL_HINT_VIDEO_ALLOW_SCREENSAVER "1" Counteracts SDL_INIT_VIDEO's default behavior of blocking the screensaver. PadForge only needs VIDEO for keyboard/mouse enumeration, not display output, so the screensaver should not be suppressed.
SDL_HINT_JOYSTICK_RAWINPUT NOT SET Must not be set to "1". SDL3's raw input backend for joysticks interferes with its XInput backend. When both are active, XInput controllers disappear from SDL_GetJoysticks() entirely because raw input claims them first, and the XInput backend then finds no unclaimed devices. This was discovered by comparing against Cemu's SDL configuration. The hint is deliberately omitted (not set to "0" either) so SDL uses its default behavior, which on Windows favors XInput for Xbox controllers and HIDAPI for everything else.

Hint timing: SDL_SetHint() calls are placed before SDL_Init() in InitializeSdl(). Setting hints after init has no effect on subsystems that already read them during startup.


Device Enumeration Flow (Step 1)

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

Device enumeration runs every ~2 seconds (EnumerationIntervalMs = 2000) on the background polling thread. The very first polling cycle runs enumeration immediately (firstCycle = true) so controllers are detected without waiting. The flow has two phases for joysticks, plus keyboard/mouse enumeration.

Phase 1: Open Newly Connected Joystick Devices

SDL_GetJoysticks() -> uint[] joystickIds
    |
    Build HashSet<uint> currentInstanceIds (for ViGEm cleanup later)
    |
    For each instanceId in joystickIds:
    |
    |   1. Is it in _filteredVigemInstanceIds?
    |       YES -> Skip (known ViGEm output device, never re-open)
    |
    |   2. Is it in _openedSdlInstanceIds?
    |       YES -> Skip (already open and tracked)
    |
    |   3. new SdlDeviceWrapper().Open(instanceId)
    |       |
    |       Open() internally:
    |       |   SDL_IsGamepad(instanceId)?
    |       |   |-- YES: SDL_OpenGamepad(instanceId)
    |       |   |        Joystick = SDL_GetGamepadJoystick(GameController)
    |       |   |-- NO:  SDL_OpenJoystick(instanceId)
    |       |
    |       |   Populate: Name, VID, PID, Path, Serial, Type
    |       |   RawButtonCount = SDL_GetNumJoystickButtons(Joystick)
    |       |   If gamepad: NumAxes=6, NumButtons=11, NumHats=1
    |       |               ParseMappedButtonIndices(GameController)
    |       |   Else:       NumAxes/Buttons/Hats from raw joystick
    |       |   HID name fallback if Name is raw VID/PID
    |       |   Check rumble via SDL properties system
    |       |   Enable gyro/accel sensors (gamepad only)
    |       |   OpenHaptic() for force feedback
    |       |   Build ProductGuid + InstanceGuid
    |       |
    |       Open() failed? -> Dispose wrapper, continue to next
    |
    |   4. IsViGEmVirtualDevice(wrapper)?
    |       |-- YES: Log, add to _filteredVigemInstanceIds, Dispose wrapper
    |       |-- NO:  Continue
    |
    |   5. FindOrCreateUserDevice(wrapper.InstanceGuid, wrapper.ProductGuid)
    |       |   Exact match by InstanceGuid? -> return existing
    |       |   Fallback match by ProductGuid (offline device)? -> migrate GUID
    |       |   No match? -> create new UserDevice
    |
    |   6. ud.LoadFromSdlDevice(wrapper)  -- populates UserDevice runtime state
    |      ud.IsOnline = true
    |      _openedSdlInstanceIds.Add(wrapper.SdlInstanceId)
    |      changed = true

Why instance ID tracking, not GUID matching: _openedSdlInstanceIds uses SDL instance IDs (uint) rather than InstanceGuid because serial-based GUIDs (used for Bluetooth devices) are not available until after the device is opened. Instance IDs are assigned by SDL at connection time and are unique per session.

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.

Why not SDL for keyboards/mice: SDL's keyboard/mouse APIs (SDL_GetKeyboardState(), SDL_GetMouseState()) report system-wide state without distinguishing which physical device generated the input. PadForge's per-device mapping model requires per-device input tracking, which only Windows Raw Input provides.

Phase 2: Detect Disconnected Devices

For each SDL instance ID in _openedSdlInstanceIds, the device's IsAttached property is checked. This property calls SDL_JoystickConnected(Joystick), which returns false when the physical device has been unplugged.

If not attached, the device is marked offline via MarkDeviceOffline() which:

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

Disconnected keyboard/mouse handles are detected by comparing tracked handles against RawInputListener.EnumerateKeyboards()/EnumerateMice(). The same MarkDeviceOffline() is called for disconnected keyboard/mouse devices, found via FindOnlineDeviceByHandle().

ViGEm Filtering Tracking Sets

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

// Devices identified as ViGEm virtual controllers — never re-opened
private readonly HashSet<uint> _filteredVigemInstanceIds = new();

The _filteredVigemInstanceIds set prevents a critical rumble-killing bug. Without it, every 2-second enumeration cycle would:

  1. See the ViGEm device in SDL_GetJoysticks()
  2. Open it with SDL_OpenJoystick() to check if it's ViGEm
  3. Identify it as ViGEm and close it with SDL_CloseJoystick()

The problem: SDL3's SDL_CloseJoystick() internally calls XInputSetState(slot, 0, 0) as cleanup. For ViGEm virtual controllers, this triggers FeedbackReceived(0, 0) on the ViGEm bus, which kills active rumble on the virtual controller. By remembering ViGEm instance IDs in the set, they are never opened again.

The set is cleaned each cycle via _filteredVigemInstanceIds.IntersectWith(currentInstanceIds) to remove IDs for virtual controllers that have been destroyed (no longer in SDL_GetJoysticks() output).

UserDevice Lookup and GUID Migration

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

  1. Exact match by InstanceGuid — returns existing device with preserved settings
  2. Fallback match by ProductGuid against offline devices — handles Bluetooth controllers that reconnect with a different device path (and thus a different InstanceGuid). 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
  3. Create new if no match found — adds a fresh UserDevice to the collection

All lookups use manual for loops instead of 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, the device could never be re-detected because the handle would remain in _openedKeyboardHandles / _openedMouseHandles even though the UserDevice no longer exists.


Device Filtering (ViGEm, vJoy)

File: PadForge.App/Common/Input/InputManager.Step1.UpdateDevices.cs Method: private bool IsViGEmVirtualDevice(SdlDeviceWrapper wrapper)

PadForge creates virtual controllers (ViGEm Xbox 360, ViGEm DS4, vJoy) that appear as real input devices to SDL. These must be filtered out during enumeration to prevent feedback loops (reading our own output as input, causing exponential virtual controller creation).

The filter runs after the device is opened (post-open filtering) because some detection criteria require properties only available from an opened handle (VID/PID, device path).

Detection Order (Short-Circuit)

The checks are evaluated in order; the first match returns true (filter the device):

# Detection Method Criteria Rationale
1 Device path Path contains "vigem" or "virtual" (case-insensitive) Primary detection for ViGEm Bus devices. ViGEm's bus enumerator puts these strings in the device instance path
2 Zero VID/PID VID=0 AND PID=0 AND IsGameController AND (_activeVigemCount > 0 OR _expectedXbox360Count > 0 OR _expectedDs4Count > 0) ViGEm devices may report VID/PID as 0 through SDL's pre-open queries. Only filters when PadForge has virtual controllers active/expected
3 vJoy VID/PID VID=0x1234 AND PID=0xBEAD vJoy virtual joystick output devices. Always filtered unconditionally — PadForge should never read its own vJoy output. SDL opening the HID device can interfere with vJoyInterface.dll's write path
4 Xbox 360 VID/PID VID=0x045E AND PID=0x028E AND Math.Max(_activeXbox360Count, _expectedXbox360Count) > 0 ViGEm Xbox 360 emulates exactly this VID/PID. Real modern Xbox controllers use different PIDs (0B12, 0B13, 0B20). Only the original 2005 Xbox 360 controller uses 028E
5 DS4 VID/PID VID=0x054C AND PID=0x05C4 AND Math.Max(_activeDs4Count, _expectedDs4Count) > 0 ViGEm DS4 emulates the original DualShock 4 v1 PID. Real DS4v2 and DualSense use different PIDs

Counter-Based Filtering for Xbox 360 and DS4

The Xbox 360 and DS4 filters use Math.Max(_activeCount, _expectedCount) to cover the window between when PadForge intends to create a virtual controller and when it actually exists:

  • _activeCount: Number of currently live ViGEm virtual controllers of this type (from Step 5)
  • _expectedCount: Number of slots configured for this type (from SlotControllerTypes[], set by UI)

When Math.Max() is greater than 0, ALL devices with the matching VID/PID are filtered. This is intentionally aggressive to prevent a race condition where stale ViGEm device nodes slip through during the gap between SlotCreated[i] = true and the virtual controller actually being created. Without this, SDL would open the ViGEm device as input, PadForge would create another ViGEm device, SDL would open that one too, causing exponential growth.

Why Not PnP Tree Walking

ViGEm DS4 devices do not register under USB\VID_054C&PID_05C4 in the Windows registry — they use ViGEm Bus's own bus enumerator. Walking the PnP device tree to distinguish real from virtual devices is unreliable. The counter-based approach is simpler and more robust.


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: ForceRawJoystickMode (per-device setting in UserDevice)

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

How it works at each layer:

Layer Normal Mode Force Raw Mode
Open (SdlDeviceWrapper.Open) SDL_OpenGamepad() if SDL_IsGamepad() Same — device is still opened as gamepad (so the handle is valid)
Read (GetCurrentState(forceRaw)) Calls GetGamepadState() Calls GetJoystickState() via forceRaw = true
UI (Devices page) Shows 11 gamepad buttons Shows RawButtonCount buttons
Auto-mapping CreateDefaultPadSetting() creates standard gamepad mapping Skipped — user must manually record mappings

Important: Force Raw Mode does not change how the device is opened. It only changes the read path. The device is still opened as a Gamepad (if recognized), which means GameController != IntPtr.Zero, sensors are enabled, etc. The forceRaw parameter to GetCurrentState() bypasses the Gamepad API at read time. This is because re-opening the device would require a full close/open cycle that could cause input drops.

Primary use case: DualShock 3 via DsHidMini in SDF mode, where SDL's built-in mapping (before the custom gamecontrollerdb_padforge.txt entry was added) incorrectly mapped buttons because the SDF mode exposes a non-standard HID layout.

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

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

Method: private void OpenHaptic() in SdlDeviceWrapper.cs

The haptic opening strategy is a full decision tree that determines whether a device should use simple rumble (SDL_RumbleJoystick), haptic effects (SDL_RunHapticEffect), or neither. The decision is made once at device open time and stored in HapticStrategy.

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

The NumHapticAxes value is critical for the downstream ForceFeedbackState: it determines whether condition effects (Spring, Damper, Friction, Inertia) are sent as 1-axis (wheels: force only on the steering axis) or 2-axis (joysticks: force on both X and Y).


Gamepad vs Joystick API

PadForge uses two different SDL APIs depending on whether the device is recognized in SDL's gamecontrollerdb. The decision is made at device open time in SdlDeviceWrapper.Open() and cannot change without re-opening the device.

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

The ForceRawMode flag is a per-device setting stored in UserDevice.ForceRawJoystickMode. It is passed through GetCurrentState(bool forceRaw) at read time in Step 2. When true, GetCurrentState() calls GetJoystickState() even if GameController != IntPtr.Zero.

Gamepad API (GetGamepadState())

Used when GameController != IntPtr.Zero and forceRaw is false. Reads through SDL's built-in mapping layer that automatically remaps DualSense, DualShock, Switch Pro, DS3 (with custom mapping), etc. to the standardized Xbox-like layout.

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

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

Note the reordering: SDL's gamepad axis enum puts triggers at indices 4/5, but PadForge's CustomInputState puts triggers at indices 2/5 to match the auto-mapping layout in SettingsManager.CreateDefaultPadSetting(). This reordering is intentional and matches the LX(0), LY(1), LT(2), RX(3), RY(4), RT(5) convention used throughout the mapping pipeline.

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

Index Button SDL Enum
0 A (South) SDL_GAMEPAD_BUTTON_SOUTH (0)
1 B (East) SDL_GAMEPAD_BUTTON_EAST (1)
2 X (West) SDL_GAMEPAD_BUTTON_WEST (2)
3 Y (North) SDL_GAMEPAD_BUTTON_NORTH (3)
4 Left Bumper SDL_GAMEPAD_BUTTON_LEFT_SHOULDER (9)
5 Right Bumper SDL_GAMEPAD_BUTTON_RIGHT_SHOULDER (10)
6 Back/Select SDL_GAMEPAD_BUTTON_BACK (4)
7 Start/Menu SDL_GAMEPAD_BUTTON_START (6)
8 Left Stick SDL_GAMEPAD_BUTTON_LEFT_STICK (7)
9 Right Stick SDL_GAMEPAD_BUTTON_RIGHT_STICK (8)
10 Guide/Home SDL_GAMEPAD_BUTTON_GUIDE (5)
11+ Extra raw SDL_GetJoystickButton(Joystick, i) (filtered)

Note that PadForge's button indices differ from SDL's SDL_GamepadButton enum values. PadForge reorders buttons to match the auto-mapping layout expected by CreateDefaultPadSetting(). The SDL enum puts Back at 4 and Guide at 5; PadForge puts them at 6 and 10 respectively.

Guide button suppression: When Back+Start+Guide are all pressed simultaneously, Guide is forced to false. This suppresses a Windows/XInput quirk where the system synthesizes a Guide button press from the Back+Start combo when the app has focus.

Extra raw buttons (indices 11+): After the 11 standard gamepad buttons, GetGamepadState() appends additional buttons by reading the raw joystick API (SDL_GetJoystickButton()) for indices 11 through RawButtonCount. This exposes device-specific buttons that are not part of the standard gamepad layout, such as DualSense touchpad click or mic button, for use as macro triggers. Buttons already consumed by the gamepad mapping are skipped (see Mapped Button Filtering below).

D-pad to POV[0]: Four gamepad D-pad buttons (DPAD_UP, DPAD_DOWN, DPAD_LEFT, DPAD_RIGHT) are synthesized into a single POV value via DpadToCentidegrees(up, down, left, right). This supports all 8 directions including diagonals.

Sensors: SDL_GetGamepadSensorData() populates state.Gyro[3] (rad/s) and state.Accel[3] (m/s^2). Sensors are only available through the Gamepad API and must have been enabled during Open().

Joystick API (GetJoystickState())

Used for devices not recognized as gamepads (flight sticks, racing wheels, generic HID devices) or when ForceRawMode is enabled. Reads raw axes, buttons, and hats without any remapping.

  • Axes: First MaxAxis (24) axes go to Axis[], overflow goes to Sliders[]. Signed -> unsigned conversion: (ushort)(raw - short.MinValue) maps SDL's -32768..32767 range to 0..65535
  • Hats: Converted from SDL bitmask to centidegrees via HatToCentidegrees(). Each hat uses the Povs[] array
  • Buttons: Uses RawButtonCount (not NumButtons) to ensure all physical buttons are read. This is critical for devices opened as gamepads and then switched to ForceRawMode — NumButtons would be capped at 11, but RawButtonCount preserves the actual HID button count. See RawButtonCount vs NumButtons Fix below

RawButtonCount vs NumButtons Fix

Problem: When a device is opened as a gamepad, NumButtons is set to 11 (the standard gamepad button count). If the user later enables ForceRawMode, GetJoystickState() would only read 11 buttons instead of the device's actual button count. For devices like the DS3 via DsHidMini SDF (which has 17 raw buttons), this silently dropped 6 buttons.

Solution: RawButtonCount is captured from SDL_GetNumJoystickButtons(Joystick) before the gamepad layout override sets NumButtons = 11. GetJoystickState() uses RawButtonCount when available:

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

This ensures all physical buttons are readable regardless of how the device was opened.

Mapped Button Filtering (ParseMappedButtonIndices)

Problem: When extra raw buttons (indices 11+) are appended in GetGamepadState(), some of those raw indices may already be consumed by the gamepad mapping. For example, the DS3 via DsHidMini SDF mapping maps raw button 11 to Right Shoulder (rightshoulder:b11) and raw button 12 to Guide (guide:b12). Without filtering, these buttons would be reported twice: once via the gamepad API (at their standard positions) and again via the raw passthrough.

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

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

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

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

_mappedRawButtonIndices is populated during Open() when the device is opened as a gamepad, and set to null for raw joystick devices.

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

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

SDL3 may return a raw VID/PID string (e.g., "0x16c0/0x05e1") as the device name for devices not in its internal name database. This typically happens with niche HID devices (custom microcontrollers, specialty peripherals) where SDL falls back to formatting the USB VID/PID.

Detection

IsRawVidPidName() checks if the name starts with "0x" and contains a '/' character, with a minimum length of 11 characters. This pattern matches SDL's fallback name format without false-positiving on real device names.

Fallback Query

When a raw VID/PID name is detected, TryGetHidProductString() queries the Windows HID class driver for the device's product string descriptor:

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

Key implementation details:

  • The file is opened with zero access rights (dwDesiredAccess = 0) — HidD_GetProductString does not require read or write access to the HID device
  • FILE_SHARE_READ | FILE_SHARE_WRITE (value 3) allows the query while other processes have the device open
  • The buffer is 512 bytes, decoded as UTF-16 (Encoding.Unicode)
  • The result is trimmed of null terminators and whitespace
  • All exceptions are caught and swallowed — failure returns null, leaving the raw VID/PID name in place
  • P/Invoke declarations for CreateFile, CloseHandle, and HidD_GetProductString are private to SdlDeviceWrapper (not shared with other classes)

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, approximately 49 days) as the default duration. This effectively makes the rumble "indefinite" — the caller is responsible for stopping it by calling StopRumble() or SetRumble(newL, newR).

Why not use a short duration (e.g., 100ms) and refresh each frame? Because SDL_RumbleJoystick restarts the motor each time it is called, even with the same values. On some hardware, this restart creates a brief gap in vibration that is perceptible as a stutter or buzz. Using uint.MaxValue avoids this entirely.

Change Detection

The ForceFeedbackState class (in ForceFeedbackState.cs) tracks the last motor values sent to the device. SetRumble() is only called when the new values differ from the previously sent values:

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

This eliminates the hardware restart gaps that would occur if SDL_RumbleJoystick were called every frame with the same values.

Rumble Capability Detection

Rumble support is detected during Open() using SDL3's properties system (replacing SDL2's SDL_JoystickHasRumble()):

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

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

Stop on Disconnect

When a device is disconnected (detected in Phase 2 of Step 1), MarkDeviceOffline() calls ForceFeedbackState.StopDeviceForces() before disposing the SDL handle. This ensures rumble stops cleanly even if the device was actively vibrating.


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

Custom Gamepad Mappings (gamecontrollerdb_padforge.txt)

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

SDL's Gamepad API (SDL_IsGamepad(), SDL_OpenGamepad()) works by looking up a device's SDL GUID in its built-in gamecontrollerdb and applying the mapping string to remap raw joystick axes/buttons to the standard gamepad layout. PadForge extends this database with a community mapping file loaded after SDL_Init():

SDL_AddGamepadMappingsFromFile(mappingsPath);

File Format

The file follows the SDL_GameControllerDB format:

GUID,Device Name,mapping_entries,platform:Windows,

Each mapping entry is a target:source pair where:

  • target is a standard gamepad element (a, b, x, y, back, start, guide, leftshoulder, rightshoulder, leftstick, rightstick, leftx, lefty, rightx, righty, lefttrigger, righttrigger, dpup, dpdown, dpleft, dpright)
  • source is a raw joystick input (bN = button N, aN = axis N, hN.M = hat N direction M)

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. This means the same VID/PID can have different SDL GUIDs depending on the device name reported by the driver. The CRC was introduced in SDL 2.0.12 to disambiguate devices with the same VID/PID but different capabilities.

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,

Background: The DS3 via DsHidMini in SDF (raw HID) mode has a non-standard button layout. SDL's built-in gamecontrollerdb does not include this device because the SDL GUID includes a CRC of the device name, and DsHidMini's SDF mode reports a different name than a native DS3. This custom mapping remaps the DS3 SDF's 17 raw buttons and 6 raw axes to the standard gamepad layout.

Key details:

  • Button 12 maps to Guide (PS button)
  • Buttons 11/10 map to Right/Left Shoulder (R1/L1)
  • Axes 3/4 are Left/Right Trigger (L2/R2 as analog axes)
  • D-pad is hat 0 with standard bitmask values

This mapping works with both DsHidMini SDF and SXS (sixaxis.sys emulation) modes because they expose the same raw HID layout.

Adding New Mappings

  1. Connect the device and note its SDL GUID (shown in PadForge's Devices page debug output, or via SDL_GetJoystickGUIDForID())
  2. Use SDL2 Gamepad Tool or manually record raw button/axis indices
  3. Add the mapping line to gamecontrollerdb_padforge.txt
  4. Restart PadForge (mappings are loaded once at startup)
  5. Submit new mappings via the Device Mapping issue template

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 the physical device has been disconnected. This is checked every 2 seconds in Phase 2 of UpdateDevices(). It is also checked by UpdateInputStates() (Step 2) — if GetCurrentState() returns null, the device is marked offline immediately rather than waiting for the next enumeration cycle.

Offline Transition: MarkDeviceOffline()

Method: InputManager.MarkDeviceOffline(UserDevice ud)

This method handles the full cleanup sequence when a device is disconnected:

  1. Stop force feedback: ud.ForceFeedbackState.StopDeviceForces(ud.Device) — sends rumble stop command to the hardware before the handle is closed. Wrapped in try/catch for best-effort cleanup
  2. Dispose SDL handle: ud.Device.Dispose() — calls CloseInternal() which closes haptic, gamepad, and joystick handles in the correct order. Wrapped in try/catch
  3. Clear runtime state: ud.ClearRuntimeState() — sets Device = null, InputState = null, ForceFeedbackState = null, IsOnline = false. The UserDevice record remains in UserDevices.Items so saved settings are preserved; only the runtime state is cleared

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


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. The built SDL3.dll binary lives at PadForge.App/Resources/SDL3/x64/SDL3.dll.

Why a Fork

As of the PadForge fork date, upstream SDL3 does not include Nintendo Switch 2 Pro Controller support. The controller uses a novel USB composite device architecture that requires both HID and WinUSB access, which existing SDL drivers do not handle.

Architecture

The Switch 2 Pro Controller is a USB composite device with two interfaces:

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

Interface 1 uses a WinUSB-compatible INF with DeviceInterfaceGUID {6F13725E-EF0E-4FD3-AE5F-B2DE989EC825} (MI_01). This interface must be opened during initialization to send the bulk command sequence that puts the controller into full-featured input mode.

Key Fork Modifications

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

WinUSB Details

  • FILE_FLAG_OVERLAPPED is required when calling CreateFile for the WinUSB interface; without it, WinUsb_Initialize fails
  • The init sequence uses overlapped I/O with events and timeouts for each bulk transfer
  • Calibration data is read via WinUSB bulk transfers; a timeout (-7) may occur after re-plug, which is non-fatal — the driver falls back to default calibration values

SDL Hint: SDL_HINT_JOYSTICK_HIDAPI_SWITCH2

The hint SDL_JOYSTICK_HIDAPI_SWITCH2 gates the Switch 2 driver. PadForge sets it to "1" before SDL_Init(). Without this hint, the compiled-in driver code is never activated and the controller is ignored during enumeration.

Steam Conflict

Steam exclusively locks WinUSB Interface 1 when it detects the Switch 2 Pro Controller. Because the WinUSB init sequence requires exclusive access to Interface 1, Steam must be closed before PadForge can initialize the controller. Input via Interface 0 (HID) works even with Steam running, but without the initialization the controller operates in a limited mode.

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

Clone this wiki locally