Skip to content

SDL3 Integration

How PadForge talks to physical game controllers: the SDL3 P/Invoke layer, the custom fork, and the code that turns SDL devices into mappable inputs.

PadForge reads game controllers through SDL3, Xbox/XInput pads included. OpenXR hand controllers, browser controllers, Remote Link peers and a handheld PC's extra buttons come in through their own device classes instead (see ISdlInputDevice Interface). This page covers the P/Invoke layer, device enumeration, state reading, sensors, haptic force feedback, virtual joysticks, and the custom SDL3 fork (HIDMaestro filter, Switch 2 controllers over USB and BLE, Wii and Joy-Con support, the Switch NFC reader, DualShock 3 motion, 16-XInput, and XInput Share).

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 --> GP_CHECK{SDL_IsGamepad?}
    GP_CHECK -->|Yes| GAMEPAD[SDL_OpenGamepad<br/>standardized 6 axes · 22 buttons · 1 hat<br/>sensors · mapped button filtering]
    GP_CHECK -->|No| JOYSTICK[SDL_OpenJoystick<br/>raw axes · raw buttons · raw hats]
    GAMEPAD --> TRACK[FindOrCreateUserDevice<br/>LoadFromSdlDevice · IsOnline = true<br/>add to _openedSdlInstanceIds]
    JOYSTICK --> TRACK
    TRACK --> LOOP

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

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 (arm64/SDL3.dll for the ARM64 build) 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_GetGamepadPowerInfo() Returns SDL_PowerState value + percentage (PadForge binds the gamepad variant)
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)
        return Array.Empty<uint>();
    try
    {
        if (count <= 0) return Array.Empty<uint>();
        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 int SDL_GetGamepadPowerInfo(IntPtr gamepad, out int percent);

// Player LED (hidapi drivers with player LEDs light them, Windows backends no-op)
public static bool SDL_SetJoystickPlayerIndex(IntPtr joystick, int player_index);

// Trigger (impulse) rumble, Xbox One+ family
public static bool SDL_RumbleGamepadTriggers(IntPtr gamepad, ushort left, ushort right, uint duration_ms);

// Vendor effect output (DualSense adaptive triggers / lightbar / audio, PadForge owns the byte layout)
public static bool SDL_SendGamepadEffect(IntPtr gamepad, byte[] data, int offset, int length);

// Switch NFC tag UID (fork export SDL#15. On stock SDL the wrapper probes once and permanently returns false)
public static bool SDL_TryGetGamepadNfcTagUid(IntPtr gamepad, out string uid);

// Joystick LED (Switch-family HOME LED brightness via equal-RGB writes, #226)
public static bool SDL_SetJoystickLED(IntPtr joystick, byte red, byte green, byte blue);

// Gamepad touchpad
public static int SDL_GetNumGamepadTouchpads(IntPtr gamepad);
public static int SDL_GetNumGamepadTouchpadFingers(IntPtr gamepad, int touchpad);
public static bool SDL_GetGamepadTouchpadFinger(IntPtr gamepad, int touchpad, int finger,
    out bool down, out float x, out float y, out float pressure);

// String property (e.g. Wii Balance Board calibration blob)
public static string SDL_GetStringProperty(uint props, string name, string defaultValue);

// Virtual joystick (pads PadForge reads natively, such as the DS3 bridge, surfaced through the normal pipeline)
public static uint SDL_AttachVirtualJoystick(ref SDL_VirtualJoystickDesc desc);
public static bool SDL_DetachVirtualJoystick(uint instance_id);
public static bool SDL_SetJoystickVirtualAxis(IntPtr joystick, int axis, short value);
public static bool SDL_SetJoystickVirtualButton(IntPtr joystick, int button, bool down);
public static bool SDL_SetJoystickVirtualHat(IntPtr joystick, int hat, byte value);
public static bool SDL_SendJoystickVirtualSensorData(IntPtr joystick, int type, ulong ts, float[] data, int num);
public static bool SDL_IsJoystickVirtual(uint instance_id);

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;
    // NO padding here: right_sat0 follows interval directly (offset 30). A stray
    // pad shifts every condition parameter 2 bytes and SDL reads coeff/saturation as 0.
    // 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 fills at most 2 (X and Y), as many as the game's Set Condition reports address (ConditionAxisCount). 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.DecodeSetRamp handles the PID Set Ramp Force report (output report ID 0x16, [EBI][Start:2][End:2]), scaling both endpoints to canonical units and taking 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(). Only the keyboard and mouse wrappers' IsAttached fallback calls them, and no wrapper reaches it, because both open through Raw Input. 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 mappings to extend SDL's built-in gamecontrollerdb for unrecognized devices. The mappings ship as an embedded resource inside PadForge.exe, not as a loose file, so LoadEmbeddedGamepadMappings() streams the resource and applies each line via SDL_AddGamepadMapping(). The file-path overload (SDL_AddGamepadMappingsFromFile) is unusable when nothing but PadForge.xml and crash.log sits beside the single-file exe. See Custom Gamepad Mappings for format and entries.

private static void LoadEmbeddedGamepadMappings()
{
    var asm = typeof(InputManager).Assembly;
    // Resource name is "<RootNamespace>.<filename>"; RootNamespace is "PadForge".
    using var stream = asm.GetManifestResourceStream("PadForge.gamecontrollerdb_padforge.txt");
    if (stream == null) return;
    using var reader = new StreamReader(stream);
    string line;
    while ((line = reader.ReadLine()) != null)
    {
        string trimmed = line.Trim();
        if (trimmed.Length == 0 || trimmed[0] == '#') continue;
        SDL_AddGamepadMapping(trimmed);  // one P/Invoke per mapping line
    }
}

SDL Hints (Complete Reference)

InitializeSdl sets these before SDL_Init(). SDL reads them during subsystem startup. Two fork HIDAPI hints are deliberately absent from this table and toggled at runtime instead, covered in Runtime-Managed Hints below.

Hint Value Rationale
SDL_HINT_JOYSTICK_ALLOW_BACKGROUND_EVENTS "1" Keeps joystick input flowing while PadForge is in the background, which a remapper needs while games have focus. SDL drops that input only when the app has an SDL window and none holds keyboard focus (SDL_PrivateJoystickShouldIgnoreEvent, SDL_joystick.c). PadForge opens no SDL window, so that condition never holds and the hint changes nothing today.
SDL_HINT_JOYSTICK_XINPUT "1" Has no effect. The constant's string, SDL_JOYSTICK_XINPUT, is not a hint SDL 3 reads. SDL 3 kept SDL2's name for the XInput switch, SDL_HINT_XINPUT_ENABLED (SDL_XINPUT_ENABLED), and that switch defaults to on (SDL_xinputjoystick.c), which is why Xbox controllers enumerate.
SDL_HINT_JOYSTICK_HIDAPI_FLYDIGI "1" by default The Settings page's Flydigi Enhanced Protocol checkbox (#395). On, SDL's Flydigi driver takes the pad's vendor interface for M1 to M4, C, Z, LM, RM and motion. Off leaves the pad on its XInput view, for a user whose Flydigi Space Station must keep sight of the pad. SDL_Quit clears hints, so ApplyFlydigiEnhancedProtocol replays the recorded state before every SDL_Init(), and a toggle applies live through the driver's hint callback.
SDL_HINT_HIDAPI_IGNORE_DEVICES "0x146b/0x0603" Devices SDL's hidapi layer must never enumerate or probe. Connecting a Nacon PS4 Compact (146B:0603) froze the app until unplug (#235). The leading explanation, refined by the fork SDL#19 audit, is a Sony third-party capabilities feature-report probe that never returns, on the UI thread that runs enumeration. PID-scoped seatbelt: other Nacon PIDs stay untouched. The value is the InputManager.HidapiIgnoreDevices constant.
SDL_HINT_JOYSTICK_BLACKLIST_DEVICES "0x054c/0x03d5,0x054c/0x0c5e,0x054c/0x042f" Devices no SDL joystick backend may enumerate. The PS Move family (#277): a docked Move exposes three HID collections and no SDL driver claims the PIDs, so each collection surfaced as its own dead joystick row. PadForge drives these pads itself (PsMoveDirectService, and the DS3 service's navigation profile) and feeds SDL virtual joysticks, which are exempt because the virtual backend never calls SDL_ShouldIgnoreJoystick. The value is the InputManager.JoystickBlacklistDevices constant.
SDL_HINT_JOYSTICK_HIDAPI_SWITCH2 "1" Enables the HIDAPI driver for wired Switch 2 controllers (SDL_hidapi_switch2.c: Pro Controller, Joy-Con 2 L/R, NSO GameCube). The fork carries the driver's Windows USB path. An unset hint falls back to SDL_HINT_JOYSTICK_HIDAPI, which is on by default on Windows (SDL_HIDAPI_DEFAULT), so setting it pins the default.
SDL_HINT_JOYSTICK_HIDAPI_WII "1" Enables SDL's Wii HIDAPI driver (#116). Surfaces the Bluetooth-paired Wii Remote / Nunchuk / Classic / Wii U Pro and lights the player LED. Relies on the fork's hid_write fix (hifihedgehog/SDL#2).
SDL_HINT_JOYSTICK_BLE_SWITCH2 "1" Enables the fork's Bluetooth-LE Switch 2 driver (hifihedgehog/SDL#5, #153). Switch 2 controllers speak a custom BLE GATT service, not HID-over-Bluetooth, so hidapi can't see them. Runs a BLE advertisement scan while PadForge is open.
SDL_HINT_JOYSTICK_BLE_SWITCH2_MOUSE "1" Enables the fork's Joy-Con 2 optical-mouse axes (hifihedgehog/SDL#8, #154). The BLE driver posts absolute 16-bit mouse counters on joystick axes 6/7 (raw axis count 8), read as "Mouse Motion X/Y" sources.
SDL_HINT_JOYSTICK_HIDAPI_SWITCH_SHAPED_RUMBLE "1" Enables the fork's frequency-shaped Switch rumble (#271 item 4, hifihedgehog/SDL#25). Upstream drives the Switch LRAs at two fixed carriers with amplitude-only tables. With the hint on, each motor's intensity also sweeps its frequency band (low ~41-160 Hz, high 160-320 Hz) with attack and decay transients. Classic LRA packet only, Switch 2 encoding untouched.
SDL_HINT_JOYSTICK_BLE_SWITCH2_MAGNETOMETER "1" Enables the fork's Switch 2 BLE magnetometer channel (#271 item 5, same fork commit). Three raw int16 axes after the mouse counters, availability riding the raw axis count (9 = magnetometer, 11 = mouse plus magnetometer). SdlDeviceWrapper.ReadSwitch2Magnetometer reads them every poll for the Gyro tab's compass-anchored yaw (GyroCompassYaw), which stays inert until a figure-8 calibration stores MagBiasX/Y/Z and MagFieldNorm.
SDL_HINT_JOYSTICK_HIDAPI_PS3_SIXAXIS_DRIVER "1" Enables SDL's Sony-sixaxis PS3 driver (#194). Claims a DualShock 3 in DsHidMini SXS mode, the only mode that serves motion, and reads its accelerometer, yaw gyro, and 10 pressure axes. Do not also set SDL_HINT_JOYSTICK_HIDAPI_PS3 (the regular PS3 driver outranks sixaxis and writes at the device).
SDL_HINT_VIDEO_ALLOW_SCREENSAVER "1" Counteracts SDL_INIT_VIDEO's default screensaver suppression. PadForge opens no SDL window, so it has no reason to hold the screensaver off.

Hint timing: subsystem-init hints such as SDL_HINT_XINPUT_ENABLED must precede SDL_Init() because SDL reads them during subsystem startup. The background-events hint can be set at any time. HIDAPI driver hints stay live after init. SDL re-evaluates them when a hint change enables or disables a driver (SDL_HIDAPIDriverHintChanged), and the fork reads the two MCU hints below on every device update (SDL_hidapi_switch.c UpdateNfc and UpdateIR). The Wii rescan depends on the first, and the runtime-managed hints on the second.

Runtime-Managed Hints (MCU Demand Latch)

Two fork HIDAPI hints are toggled at runtime by InputService.RefreshSwitchNfcArming() (on the auto-idle cadence), never set at init:

Hint ON while OFF effect
SDL_HINT_JOYSTICK_HIDAPI_SWITCH_NFC An NFC tag registration capture is running, or a Bluetooth-linked reader-capable pad (right Joy-Con 0x2007, combined pair 0x2008, Pro Controller 0x2009) is online and a configured NFC consumer (local or Remote Link peer) polled an NFC descriptor within the demand window MCU powers down. ReadNfcTag reports "no tag"
SDL_HINT_JOYSTICK_HIDAPI_JOYCON_IR_SENSOR An "IR Brightness" consumer (hifihedgehog/SDL#7, #151: a right Joy-Con posts its NIR MCU average-intensity byte on joystick axis 6) polled within the demand window, no NFC registration capture is active, and an IR-capable Joy-Con is online (standalone right 0x2007, or a combined gen-1 pair 0x2008 whose right half runs the same camera machine, fork SDL#26 / #275) Camera stops, freeing the MCU for NFC

How the latch works:

  • Demand comes from reads. SourceCoercion stamps LastNfcReadRequestTick / LastJoyConIrReadRequestTick whenever a configured, enabled mapping evaluates an NFC or IR descriptor. Configured inputs are read every tick, so an active binding keeps the latch continuously fresh. A deleted or disabled one lapses after McuDemandWindowMs (10 s).
  • Why not init-time. The right Joy-Con camera and the NFC reader share the one MCU (camera = mode 5, NFC = mode 4, and the fork's UpdateNfc abandons while the camera streams). A globally-on IR hint silently killed standalone right Joy-Con NFC (#248 audit). Do not move either hint back into the init table.
  • Registration preempts the camera. A tag-registration capture with the camera streaming would wait forever for a UID, so RegistrationCaptureActive forces IR off. The camera resumes when the dialog closes and IR demand re-latches.
  • The update loop. The fork reads both hints on every device update. UpdateIR starts the camera on a right Joy-Con (0x2007, or the right half of a 0x2008 pair) whose sensors are on while the hint asks for it, and tears it down when the hint stops asking, which frees the MCU for NFC. A flip takes effect on the next update, so the service sets the hint and does nothing more. Until fork commit 32e79e665e (PadForge 51f408fb) the fork decided the camera's fate only at SetSensorsEnabled, and the service bounced every IR-capable Joy-Con's sensors after a flip.
  • Latch only on success. The managed flag updates only when SDL_SetHint accepts the value. A rejected write retries on the next cadence instead of leaving the flag and the MCU disagreeing.
  • When both the camera and NFC are configured for one standalone right Joy-Con, the camera wins by fork arbitration. That conflict is the mapping author's own choice.

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, and every 5 s while the engine idles. The first cycle runs immediately (firstCycle = true) for instant controller detection, and so does the first cycle after idle or focus suspension ends. Two phases for joysticks, plus keyboard/mouse enumeration. The same pass also enumerates the device families SDL never sees (tablets, Precision Touchpads, MIDI inputs, NFC readers, microphones, head trackers, handheld PC buttons, Logitech G-keys), which Input Pipeline covers.

Phase 1: Open Newly Connected Joystick Devices

SDL_GetJoysticks() -> uint[] joystickIds
    |
    Build HashSet<uint> currentInstanceIds (used by Phase 2 to detect disconnects)
    |
    _suppressedSelfVirtualIds.ReleaseLapsed(currentInstanceIds, now)
    |       (Forgets the IDs rejected on USB/IP ancestry alone that SDL no
    |        longer lists, and the ones no persona's scope covers any more
    |        while the device is still in the device tree. The loop below
    |        opens the second kind again.)
    |
    For each instanceId in joystickIds:
    |       (The SDL3 fork's filter drops HIDMaestro virtuals before
    |        SDL_GetJoysticks returns. The self-readback guard in step 2b
    |        catches one that slips past.)
    |
    |   1. Is it in _openedSdlInstanceIds?
    |       YES -> Skip (already open and tracked)
    |      Is it in _suppressedSelfVirtualIds?
    |       YES -> Skip (rejected as PadForge's own virtual on an earlier pass)
    |
    |   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)
    |       |   RawAxisCount   = SDL_GetNumJoystickAxes(Joystick)
    |       |   If gamepad: NumAxes=6, NumButtons=22 (0-10 standard + 11-21 SDL extended), NumHats=1
    |       |               ParseMappedButtonIndices(GameController)
    |       |               Detect v4 capabilities (rumble triggers, aux accel,
    |       |               touchpads, Wii IR / Joy-Con cameras, extra generic axes)
    |       |   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
    |
    |   2b. Self-readback guard (_suppressedSelfVirtualIds.PassOver): serial starts
    |       "HM-CTL-", path contains "HIDMAESTRO", or the parent chain reaches the
    |       usbip-win2 host controller and either passes a USB device named by
    |       HIDMaestro's own serial or the device has the IDs of a composite
    |       persona in scope?
    |       YES -> PassOver logs and records the ID. Dispose wrapper, continue
    |
    |   3. FindOrCreateUserDevice(wrapper.InstanceGuid, wrapper.ProductGuid,
    |                             currentInstanceIds, wrapper.SerialNumber)
    |       |   Same unit re-identifying inside the disconnect debounce? -> its own row
    |       |   Exact match by InstanceGuid? -> return existing
    |       |   Fallback match by ProductGuid (offline device)? -> migrate GUID
    |       |   No match? -> create new UserDevice
    |       If the row's InstanceGuid differs (a same-serial twin),
    |       wrapper.OverrideInstanceGuid(ud.InstanceGuid)
    |
    |   4. ud.LoadFromSdlDevice(wrapper)  -- populates UserDevice runtime state
    |      ud.IsOnline = true
    |      _openedSdlInstanceIds[wrapper.SdlInstanceId] = wrapper
    |      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, mice and Consumer Control collections use RawInputListener.EnumerateKeyboards() / EnumerateMice() / EnumerateConsumerControls() via Windows Raw Input (not SDL). The enumeration runs on a background task, and each pass consumes the previous task's results (the first pass enumerates synchronously). Each new device gets a SdlKeyboardWrapper, SdlMouseWrapper or ConsumerControlWrapper, with UserDevice populated via LoadFromKeyboardDevice() / LoadFromMouseDevice() / LoadFromConsumerDevice().

Tracking uses _openedKeyboardHandles, _openedMouseHandles and _openedConsumerHandles (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

Two paths, depending on whether Step 2 already flipped the device offline.

Already offline (Step 2 saw a null read). A tracked SDL ID whose UserDevice is no longer online means GetCurrentState() returned null when SDL reported the handle detached. Handle detachment is permanent, so no debounce applies. MarkDeviceOffline() runs immediately to finish the cleanup. An orphaned wrapper with no UserDevice at all is disposed here on the poll thread, except when the same physical device is already live under a rebound wrapper (disposing the stale HIDAPI context would clobber the live registration).

Still online but looking disconnected. A device counts as looking disconnected when ud.Device == null, IsAttached is false (SDL_JoystickConnected()), or the ID is missing from the current SDL_GetJoysticks() enumeration. This path is debounced. The first cycle it looks disconnected, Phase 2 records the tick in _sdlDisconnectCandidateSince and moves on. MarkDeviceOffline() runs only after the condition holds for the full window (SdlDisconnectDebounceMs = 2000). This rides out the transient drop that xinputhid's slot-assignment pass can induce on a coexisting physical Xbox pad while a HIDMaestro virtual is being created. If the device reappears inside the window, the pending entry is cleared and nothing happens.

MarkDeviceOffline() itself:

  1. Stops rumble via ForceFeedbackState.StopDeviceForces() (best-effort)
  2. Disposes the SDL handle via ud.Device.Dispose() (best-effort)
  3. Clears the wheel/LED per-device caches keyed on DevicePath so a same-path replug re-applies rotation range and auto-center
  4. Calls NeutralizeMappedOutputsFor(ud), which runs ud.ClearRuntimeState() (nulls Device, InputState, OldInputState, ForceFeedbackState, sets IsOnline = false) and drives the device's per-slot mapped outputs to neutral
  5. Calls XboxImpulseHidWriter.InvalidateCachedTargets(), because any confirmed disconnect can reshuffle the synthetic XInput#N paths the impulse writer caches handles under

After the loop, a follow-up pass removes every disconnected SDL instance ID from _openedSdlInstanceIds (foreach (sdlId in disconnectedIds) _openedSdlInstanceIds.Remove(sdlId)).

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); the value is the
// wrapper, so the disconnect sweep can dispose an orphaned handle.
private readonly Dictionary<uint, SdlDeviceWrapper> _openedSdlInstanceIds = new();

_openedSdlInstanceIds records every device PadForge has opened, keyed on SDL instance ID, so the engine does not re-open the same device twice per cycle. Each value is the device's SdlDeviceWrapper, which lets the Phase 2 disconnect sweep dispose an orphaned handle. There is no bulk prune. Phase 2 collects the IDs that disconnected this cycle and a follow-up loop calls _openedSdlInstanceIds.Remove(sdlId) for each.

HIDMaestro virtual controllers are filtered upstream by PadForge's SDL3 fork: the patched enumerator does a fast substring match for HIDMAESTRO against each device's interface symlink, then walks the PnP parent chain looking for HIDMAESTRO in the hardware-ID list. Any match is dropped before SDL_GetJoysticks returns. 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 reach the HIDMaestro virtual as an OutputReceived(0, 0) packet). The fork's filter keeps HM devices out of the engine's open/close cycle, and Step 1's self-readback guard disposes any that slip past right after Open.

UserDevice Lookup and GUID Migration

FindOrCreateUserDevice(Guid instanceGuid, Guid productGuid = default, HashSet<uint> livePresentSdlIds = null, string serialNumber = null). The SDL sweep passes the instance IDs SDL reports present and the wrapper's serial. Every other caller passes neither. Before the tiers, a same-product row with the same serial that is still online, but whose SDL instance has left the present set, is returned as the same unit re-identifying inside the disconnect debounce (for a device with no serial, only when a live device holds the exact-match row). Three-tier matching:

  1. Exact match by InstanceGuid. Returns existing device with preserved settings. When a live, present device already holds that row (two units reporting one serial), the newcomer takes an offline row of the same product and keeps that row's GUID, or gets a session GUID when no such row exists
  2. Fallback match by ProductGuid against offline devices. Handles Bluetooth reconnections with changed device paths/InstanceGuid. Migrates the old GUID, updates UserSetting via MigrateUserSettingGuid(), and queues the device-pinned references (mapping rows, activators, menus, per-pad slot configs) in PendingDeviceGuidMigrations for the UI thread to re-key
  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 / Nintendo / Extended) appear as real input devices to SDL3 by default. The Nintendo family is the virtual Switch Pro added in 4.1.0 (#215, #246) and rides the same filter as the other three. 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. The walk goes five devnodes deep (HM_FILTER_MAX_DEPTH), far enough to reach the emulated host controller HIDMaestro stamps for its composite USB personas. Any device that matches is dropped before SDL_GetJoysticks returns. The hidapi and DirectInput backends run this check.

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.

One backstop remains in the engine. A driver upgrade recreates the virtual devnodes with fresh instance paths, and one did slip past both the fork filter and the cloak. So Step 1 checks every wrapper right after Open (SelfVirtualSuppressions.PassOver). A serial starting HM-CTL- or a path containing HIDMAESTRO marks PadForge's own virtual. A HIDMaestro hardware id other than the host controller's stamp, on the device's node or one above it, marks it too, for good (HidMaestroAncestry.Classify). A parent chain that reaches the usbip-win2 host controller (the route composite personas take) marks it too, for a device below a USB device named by HIDMaestro's own serial (HM and twelve hex digits), and for one with the vendor and product ID of a composite persona PadForge has live, is creating or disposed within 10 seconds, or of any composite profile while the launch sweep runs (ChecksUsbipAncestry). The wrapper is disposed and its instance ID goes into _suppressedSelfVirtualIds, so later passes skip it without reopening it. An ID rejected on a persona's IDs alone leaves the set once no persona of those IDs is in scope, while its device is still in the device tree (ReleaseLapsed), and that pass opens it again.

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 22 for gamepads (the standardized-range cap), raw count for joysticks
RawButtonCount int Raw button count before gamepad remapping
RawAxisCount int Raw joystick axis count before the gamepad layout caps NumAxes to 6 (#193)
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
HasAccelAux bool Auxiliary/left accelerometer (SDL_SENSOR_ACCEL_L): Nunchuk on a Nunchuk-attached remote, left half of a combined Joy-Con pair (#199)
HasGyroAux bool Auxiliary/left gyroscope (SDL_SENSOR_GYRO_L): left half of a combined Joy-Con pair, gen 1 or gen 2 (#252). Never a Nunchuk, which has no gyro
HasNfcReader bool Switch NFC reader the fork can drive (#241): right Joy-Con, Pro Controller, or a combined pair whose right child carries the MCU
HasRumbleTriggers bool Xbox One+ impulse-trigger motors. SDL trigger-rumble cap OR the Xbox-impulse VID/PID list
HasTouchpad bool Device has one or more touchpad surfaces
NumTouchpads int Touchpad surface count (Steam Controller 2026 / Deck = 2, DualSense / DS4 = 1)
TouchpadFingerCounts int[] Per-pad simultaneous-finger count from SDL_GetNumGamepadTouchpadFingers
HasIrCamera bool Wii Remote IR camera surfaced as an "IR Pointer" source (#146)
IsBalanceBoard bool Wii Balance Board (#146)
HasJoyConIr bool Right Joy-Con NIR camera surfaced as an "IR Brightness" source (#151). Standalone right Joy-Con (0x2007) or a combined gen-1 pair (0x2008), gated on raw axis count >= 7
HasJoyCon2Mouse bool Joy-Con 2 L/R optical mouse surfaced as "Mouse Motion X/Y" sources (#154)
HasSpinDial bool Logitech WingMan Warrior's spin dial, the fork's joystick ball 0, surfaced as the "Mouse Motion X" source (hifihedgehog/SDL#33)
HasSwitch2Magnetometer bool Switch 2 BLE magnetometer stream (#271 item 5). Raw wire units land on the wrapper-local Switch2MagX/Y/Z fields, not on CustomInputState, so the Remote Link codec's full Block enum stays untouched
Switch2MagActive bool False until the first nonzero magnetometer sample, because a zero triple must not feed the compass
HasExtraGenericAxes bool Raw axes beyond the standard six, surfaced as generic "Axis N" sources (#193)
SupportedButtonIndices int[] Sparse list of button positions the device actually exposes (Devices preview gating)
SupportedAxisIndices int[] Sparse list of axis positions the device actually has. Null means dense. Exists because NumAxes is the standardized 6-slot gamepad space, not a count of physical axes
GamepadHandle IntPtr Alias of GameController, used by the DualSense passthrough dispatcher
SdlGuid string SDL joystick GUID (32 hex) for gamecontrollerdb matching and virtual-joystick identity
Name string Human-readable device name
VendorId ushort USB Vendor ID
ProductId ushort USB Product ID
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, enable Force Raw Mode on the Devices page. It 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 the standardized gamepad buttons (SupportedButtonIndices within the 22 slots) 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, SdlGuid from joystick handle
  4. Capture raw counts before the gamepad override: RawButtonCount = SDL_GetNumJoystickButtons(), RawAxisCount = SDL_GetNumJoystickAxes()
  5. Gamepad layout override: NumAxes=6, NumButtons=22, NumHats=1. Parse _mappedRawButtonIndices and compute SupportedButtonIndices
  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(). Set HasRumbleTriggers (SDL trigger-rumble cap OR the Xbox-impulse VID/PID list)
  8. Enable sensors: gyro, accel, the aux accel (SDL_SENSOR_ACCEL_L, sets HasAccelAux), and the aux gyro (SDL_SENSOR_GYRO_L, sets HasGyroAux) via SDL_GamepadHasSensor() -> SDL_SetGamepadSensorEnabled(true). A device opened as a joystick only probes and enables the same four through SDL_JoystickHasSensor() -> SDL_SetJoystickSensorEnabled(true), which is how the Myo, the Rift DK1 and the Windows Mixed Reality controllers deliver motion (hifihedgehog/SDL#33)
  9. Detect v4 capabilities: touchpads (HasTouchpad / NumTouchpads / TouchpadFingerCounts), Wii IR camera and Balance Board (HasIrCamera / IsBalanceBoard, #146, the camera also keyed on the configuration's SDL name through WiiRemoteIdentity), right Joy-Con NIR (HasJoyConIr, #151), Joy-Con 2 optical mouse (HasJoyCon2Mouse, #154), and extra generic axes (HasExtraGenericAxes, #193), keyed off the raw joystick axis count
  10. Open haptic: OpenHaptic() for FFB devices
  11. 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 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. Store Haptic, HapticFeatures, then
            |
            NumHapticAxes = SDL_GetNumHapticAxes(haptic)
            |   1 axis -> wheels (constant/periodic forces on the steering axis)
            |   2+ axes -> joysticks, gamepads (polar direction)
            |
            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, clear Haptic + HapticFeatures, return
            |
            (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

ForceFeedbackState reads NumHapticAxes for directional effects. On a single-axis device (a wheel), constant and periodic forces use the steering axis (SDL_HAPTIC_STEERING_AXIS), with a constant force scaled by the X component of the game's direction. A device with two or more axes gets a polar direction. Condition effects (Spring, Damper, Friction, Inertia) take their axis count from the game's Set Condition reports instead (ConditionAxisCount, at most 2).


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, 22 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 the 22 standardized slots, but RawButtonCount preserves the actual HID count. See RawButtonCount vs NumButtons Fix

RawButtonCount vs NumButtons Fix

Problem: Gamepad open pins NumButtons to the 22 standardized slots. A device with more than 22 physical buttons (arcade sticks, flight HOTAS) read through GetJoystickState() on NumButtons alone would stop at 22 and silently drop the extras.

Solution: RawButtonCount is captured from SDL_GetNumJoystickButtons() before the gamepad override. GetJoystickState() prefers 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: After the 22 standardized positions, GetGamepadState() appends raw buttons (index 22+) as macro triggers. A high raw index may already be consumed by the gamepad mapping. Without filtering, such a button is double-reported, once as its mapped gamepad element and again as a generic raw button. Low raw indices like DS3 SDF's b11/b12, which SDL maps to R1/Guide, sit below 22, so the standardized 0-21 layer handles them and they never reach this passthrough.

Solution: ParseMappedButtonIndices() parses the SDL mapping string once at Open() to build a HashSet<int> of every consumed raw index (all bN values):

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

// Auxiliary (left-side) accelerometer, SDL_SENSOR_ACCEL_L (#199): the Nunchuk
// on a Nunchuk-attached Wii Remote, or the left half of a combined Joy-Con pair.
HasAccelAux = SDL_GamepadHasSensor(GameController, SDL_SENSOR_ACCEL_L);
if (HasAccelAux) SDL_SetGamepadSensorEnabled(GameController, SDL_SENSOR_ACCEL_L, true);

// Auxiliary (left-side) gyroscope, SDL_SENSOR_GYRO_L (#252): the left half of
// a combined Joy-Con pair. Only the Switch drivers register it, and on a pair
// the primary gyro is the right half, so this is a second physical sensor.
HasGyroAux = SDL_GamepadHasSensor(GameController, SDL_SENSOR_GYRO_L);
if (HasGyroAux) SDL_SetGamepadSensorEnabled(GameController, SDL_SENSOR_GYRO_L, true);

Sensors must be explicitly enabled before data can be read. GetGamepadState() reads gyro into state.Gyro, accel into state.Accel, the aux accel into state.AccelAux, and the aux gyro into state.GyroAux. GetJoystickState() reads the same four with SDL_GetJoystickSensorData() on a device opened as a joystick only, and on a gamepad read raw through Force Raw. The fork's SDL_GetGamepadSensorData is that call on the gamepad's joystick. The aux streams back the "Motion Accel L" (#199) and "Motion Gyro L" (#252) mapping sources, letting a slot source its IMU feed from the Nunchuk or the left Joy-Con of a pair instead of the body sensor. "Motion Gyro L" is Switch-only: the Nunchuk has no gyro.

Motion Frame and the DSU Sign Transform

InputManager.UpdateMotionSnapshots() keeps the snapshot in SDL's native sensor frame. It scales units (rad/s -> deg/s, m/s^2 -> g) and applies the per-row mapping-source Invert, but it does not flip axis signs. This keeps MotionSnapshot faithful to a real controller, so the Sony HID report packers stay correct and the virtual Switch Pro's IMU channel (#215, HM v1.3.18) submits the g and deg/s values verbatim, with the per-profile packer owning the wire frame and scale.

const float RadToDeg = 180f / MathF.PI;
const float MsToG = 1f / 9.80665f;

// Native frame preserved. No DSU sign flip here.
ax = accel[0] * MsToG;      // accel[] is SDL's m/s^2 triple
ay = accel[1] * MsToG;
az = accel[2] * MsToG;
gx = tunedPitch * RadToDeg; // tuned = post-Invert, still native frame
gy = tunedYaw   * RadToDeg;
gz = tunedRoll  * RadToDeg;

// ... written straight onto the snapshot:
AccelX = ax, AccelY = ay, AccelZ = az,
GyroPitch = gx, GyroYaw = gy, GyroRoll = gz,

The DSU/cemuhook convention negates accelerometer X/Y/Z and gyro yaw/roll relative to SDL's frame, leaving pitch unchanged. That transform is applied downstream, per DSU packet, in DsuMotionServer.BuildPadDataPacket(), so only DSU clients see it:

WriteFloat(packet, o + 56, -snapshot.AccelX);   // Accel X inverted
WriteFloat(packet, o + 60, -snapshot.AccelY);   // Accel Y inverted
WriteFloat(packet, o + 64, -snapshot.AccelZ);   // Accel Z inverted
WriteFloat(packet, o + 68,  snapshot.GyroPitch); // Pitch unchanged
WriteFloat(packet, o + 72, -snapshot.GyroYaw);   // Yaw inverted
WriteFloat(packet, o + 76, -snapshot.GyroRoll);  // Roll 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, string sdlGuid = null)

Deterministic GUID from MD5 hash. Five tiers, in priority order:

  1. VID+PID+Serial (serial:{VID}:{PID}:{serial}). Best for Bluetooth devices, where the serial (BT MAC address) is stable across reboots
  2. XInput path resolution. When devicePath starts with XInput#, the slot-dependent SDL path and name-derived GUID are both unstable across slot reshuffles. StableXInputInstance.FindAll(vid, pid) resolves to the physical PnP instance (BT MAC / USB hub+port), keyed pnp:{physicalPath} (with :slot{n} appended when two same-model pads are paired). Falls back to sdlguid: then the path tier if no physical match is found
  3. Device path ({devicePath}:{VID}:{PID}). Stable for wired/USB devices. VID/PID is included so different hardware in the same path stays distinct
  4. SDL GUID (sdlguid:{sdlGuid}). For virtual joysticks with no path or serial (the DS3 BT bridge). SDL's joystick GUID is stable across reconnects, unlike the session instance ID
  5. 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 22+ (beyond the 22 standardized gamepad slots) 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-23 ObjectGuid.ZAxis "Axis N"
24+ ObjectGuid.Slider "Slider N"

The names above are the raw-joystick set (GetStandardAxisName). An SDL-recognized gamepad gets the gamepad names instead (GamepadObjectNames.Axis): "Left Stick X", "Left Stick Y", "Left Trigger", "Right Stick X", "Right Stick Y", "Right Trigger". The GUIDs are the same either way.

For SDL-recognized gamepads the standard axis and button positions are capability-gated before they are emitted, so the table above is the maximum layout, not a guaranteed one. The next subsection covers the axis gate.

Axis Capability Gating

How GetDeviceObjects() drops standard gamepad axis positions a controller does not physically have, so the auto-mapper never pins a virtual stick to a phantom axis.

GetDeviceObjects() is the device's capability list. For an SDL-recognized gamepad (GameController != IntPtr.Zero), each of the six standard axis positions (0-5) is gated on SDL_GamepadHasAxis before it is written, the same way the button loop gates positions 0-21 on SDL_GamepadHasButton (every standardized slot, including the standard eleven, since a Move Navigation declares a sparse gamepad mask). Raw joystick devices (isGamepad == false) keep the flat enumeration unchanged, emitting every axis 0..NumAxes-1.

The position-to-enum lookup is GamepadAxisForPosition(int position) (SdlDeviceWrapper.cs), the axis counterpart of GamepadButtonForPosition. It returns the SDL axis enum for a standard position in PadForge's LX/LY/LT/RX/RY/RT order, or -1 for a non-standard position:

Position GamepadAxisForPosition SDL enum value
0 SDL_GAMEPAD_AXIS_LEFTX 0
1 SDL_GAMEPAD_AXIS_LEFTY 1
2 SDL_GAMEPAD_AXIS_LEFT_TRIGGER 4
3 SDL_GAMEPAD_AXIS_RIGHTX 2
4 SDL_GAMEPAD_AXIS_RIGHTY 3
5 SDL_GAMEPAD_AXIS_RIGHT_TRIGGER 5
6+ (returns -1) Slider position, never gated

The gate, in the axis loop:

if (isGamepad && i < standardAxisGuids.Length)
{
    int sdlAxis = GamepadAxisForPosition(i);
    if (sdlAxis < 0 || !SDL_GamepadHasAxis(GameController, sdlAxis))
        continue;
}

Skipped positions are never written. After the axis, hat, and button loops, GetDeviceObjects() trims the array to index (the count actually written) so the caller can iterate Length without hitting nulls.

Why the gate exists. DeviceObjects is the capability list SettingsManager.CreateDefaultPadSetting() trusts. Its HasAxis(idx) closure binds the matching virtual stick or trigger only when ud.DeviceObjects contains an AbsoluteAxis whose InputIndex == idx:

bool HasAxis(int idx) => !haveCaps || objs.Any(o => o != null
    && (o.ObjectType & DeviceObjectTypeFlags.AbsoluteAxis) != 0 && o.InputIndex == idx);
...
if (HasAxis(0)) ps.LeftThumbAxisX = "Axis 0";
if (HasAxis(1)) ps.LeftThumbAxisY = "Axis 1";
if (HasAxis(2)) ps.LeftTrigger    = "Axis 2";

Before the gate, a stickless gamepad still enumerated all six axes. A missing gamepad axis reads 0, and the stick mapper turns that 0 into a hard upper-left deflection instead of the resting 32768 center. The Wii Remote is the case that exposed this: it is an SDL-recognized gamepad with no analog sticks, so the unconditional six-axis layout made CreateDefaultPadSetting bind both virtual sticks to phantom axes 0/1 and 3/4 and pin them to the corner. Gating the enumeration on SDL_GamepadHasAxis makes HasAxis(0) through HasAxis(5) return false for axes the device does not report, so the auto-mapper leaves those stick and trigger bindings empty.

This is the same capability-assuming-defaults bug class as the Misc1/Share auto-bind gate in the same CreateDefaultPadSetting method, where the Xbox Share output binds only when the device exposes button 11: a generator that emits a device's default layout has to gate each binding on the target actually having that input, never assume the full standard set. The Wii Remote was the first recognized gamepad with a real hole in the standard axis range, so the assumption stayed latent until that device class arrived. See Wii Controllers for the controller that exposed it and the Engine Library SdlDeviceWrapper entry for the wrapper's place in the engine.

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. A Bliss-Box port read raw is typed a Joystick, and its controller's placement in that layout takes the gamepad's place (see Bliss-Box Internals).


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, then run through DeviceNameSanitizer.Clean so embedded nulls and control bytes from cheap USB descriptors cannot break XmlSerializer at save time (#53) - 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() passes uint.MaxValue as the default duration, and SDL caps it at 0xFFFF ms, about 65.5 s (SDL_MAX_RUMBLE_DURATION_MS, the Linux kernel's limit, which SDL applies on every platform). SDL's update loop stops the motors when that time runs out. A call that repeats the level SDL holds restarts the time without reaching the driver, but PadForge calls only when a level changes, so a level held unchanged for longer than 65.5 s stops. The caller stops it sooner with StopRumble() or SetRumble(newL, newR).

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)

SDL skips a repeated level before the driver as well (SDL_RumbleJoystick's duplicate check), so the check saves the call and SDL's joystick lock, not a motor restart.

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))
    || PadixConverterIdentity.IsPlayStationConverter(VendorId, ProductId);

A Padix PSX/USB converter counts as rumble-capable without SDL's help, because PadForge writes its two motors itself (PadixConverterRawHidWriter, #440). SetRumble() returns false immediately if HasRumble is false, the joystick handle is invalid, or the device is that converter. On a Steam Deck it scales both magnitudes by DeckRumbleHeadroom (54,394 / 65,535) before the SDL call, staying under the firmware's 16-bit wrap (#179).

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 the current state as OldInputState (ud.OldInputState = ud.InputState) for change detection
  2. Call ud.Device.GetCurrentState() (dispatches to GetGamepadState() or GetJoystickState())
  3. Atomic reference swap: ud.InputState = newState (safe for cross-thread reading)
  4. Tick the touchpad gesture engine via UpdateGestureContexts(ud, newState), once per device per tick, across every touchpad surface the device exposes. UpdateMouseGestureContexts (#200) and UpdateMenuContexts (#9 B-17) run beside it
  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++)
{
    // per-slot: VibrationStates[padIndex] -> macro merge -> constant-force
    // resolve -> ScaleRumbleForDevice -> scaledL / scaledR
    if (scaledL > combinedL) combinedL = scaledL;
    if (scaledR > combinedR) combinedR = scaledR;
}

The trigger motors (combinedLT / combinedRT) combine the same way, taking the max across the slot's own trigger rumble, the #102 trigger routing, and the shared-trigger sources.

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


SDL Virtual Joysticks (DualShock 3 Bridge)

Files: PadForge.Engine/Common/SDL3Minimal.cs (virtual joystick P/Invokes), PadForge.App/Common/Input/InputManager.cs (Ds3DirectService wiring)

The fork build enables SDL_JOYSTICK_VIRTUAL. PadForge uses it to surface a device it reads natively as an ordinary SDL joystick, so the rest of the pipeline consumes it with no special-casing. Four services attach virtual joysticks: Ds3DirectService (the DualShock 3, and the PS Move Navigation controller on its navigation profile), PsMoveDirectService (#277), OpenVrConsumerService (#287 VR slots), and SpaceMouseService (#288). The DS3 is the reference case below.

Attach and feed

Ds3DirectService.Start() runs a monitor loop that tries USB first (the inbox WinUSB interface) and falls back to the BthPS3 raw PDO. USB takes priority because the BthPS3 PDO node lingers present even after the pad leaves Bluetooth. On connect the service fills an SDL_VirtualJoystickDesc (16 axes, 15 buttons, no hats, VID/PID, button and axis masks, sensor descriptors, and Cdecl callback pointers for Rumble, SetLED, SetPlayerIndex, SetSensorsEnabled) and calls SDL_AttachVirtualJoystick(ref desc). SDL returns an instance ID, and Step 1 opens it on the next enumeration like any real device.

Each frame the service pushes decoded HID state into SDL:

  • SDL_SetJoystickVirtualButton for the face, shoulder, stick, system, and D-pad buttons. The desc declares no hats, so the D-pad rides buttons 11-14 in SDL's own gamepad button order
  • SDL_SetJoystickVirtualAxis for the two sticks, the two analog triggers, and the ten button-pressure axes on 6-15, the same shape upstream SDL's PS3 driver uses, so #193's HasExtraGenericAxes surfaces them as "Axis 6" through "Axis 15"
  • SDL_SendJoystickVirtualSensorData for the DS3's accelerometer and yaw gyro
  • SDL_LockJoysticks() / SDL_UnlockJoysticks() batch a whole frame so the poll thread reads one atomic snapshot

Telling the two DS3 transports apart

A DS3 driven by Ds3DirectService and a USB DS3 in DsHidMini SXS mode share VID/PID 054C:0268. SDL_IsJoystickVirtual(instanceId) distinguishes them: the bridged pad is a virtual joystick owned by Ds3DirectService, the SXS pad is a real hidapi device owned by SDL's sixaxis driver. SdlDeviceWrapper.SetPlayerIndex uses exactly this test to keep one player-LED writer per transport, forwarding to SDL_SetJoystickPlayerIndex only for the non-virtual case.

Filling in what a virtual joystick lacks

A virtual joystick has no SDL device path and no SDL power channel. SdlDeviceWrapper falls back to two external providers the service registers so identity, transport display, and battery still resolve:

  • ExternalDevicePathProvider supplies the real transport interface path (the BthPS3 PDO over Bluetooth, the WinUSB interface over USB) for the Dossier view and USB/BT classification. InputManager wires it as Ds3DirectService.GetDevicePath(id) ?? PsMoveDirectService.GetDevicePath(id)
  • ExternalPowerInfoProvider supplies battery when SDL reports unknown (#167), wired the same way as Ds3DirectService.GetPowerInfo(id) ?? PsMoveDirectService.GetPowerInfo(id)

Because the bridge has no path or serial, BuildInstanceGuid keys it on the sdlguid: tier (tier 4 above), which is stable across reconnects. The service also gives both transports one shared virtual-joystick name, since SDL folds the name into the joystick GUID, so a slot mapping made over USB survives a switch to Bluetooth.


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 RawAxisCount { get; }              // default => NumAxes; SdlDeviceWrapper overrides (#193)
    bool HasExtraGenericAxes { get; }      // extra raw axes as generic "Axis N" sources (#193)
    int NumHats { get; }
    int[] SupportedButtonIndices { get; }  // sparse list of exposed button positions
    int[] SupportedAxisIndices => null;    // sparse list of exposed axis positions, null = dense
    IntPtr GamepadHandle { get; }          // SDL_Gamepad*, for SDL_SendGamepadEffect (passthrough)
    bool HasRumble { get; }
    bool HasRumbleTriggers { get; }        // Xbox One+ impulse-trigger motors
    bool HasHaptic { get; }
    bool HasGyro { get; }
    bool HasAccel { get; }
    bool HasAccelAux { get; }              // auxiliary/left accel, SDL_SENSOR_ACCEL_L (#199)
    bool HasGyroAux { get; }               // auxiliary/left gyro, SDL_SENSOR_GYRO_L (#252)
    bool HasTouchpad { get; }
    int NumTouchpads { get; }              // touchpad surface count
    int[] TouchpadFingerCounts { get; }    // per-pad simultaneous-finger count
    bool? TouchpadPressureSupported => null; // null = unknown
    bool? TouchpadClickSupported => null;    // null = unknown
    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; }
    string SdlGuid { get; }                // SDL joystick GUID (gamecontrollerdb / virtual identity)

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

RawAxisCount, HasExtraGenericAxes, SupportedAxisIndices, HasAccelAux, HasGyroAux, NumTouchpads, TouchpadFingerCounts, TouchpadPressureSupported, and TouchpadClickSupported have default interface implementations. Keyboard and mouse wrappers inherit those defaults. SdlDeviceWrapper overrides the first seven with live per-device values and leaves both touchpad capability flags unknown. The Remote Link peer (RemotePeerDevice) mirrors all nine from the owner's device list, so a shared Nunchuk's "Motion Accel L" source, a shared pair's "Motion Gyro L" source, and a shared touchpad stay pickable on the consumer (#199, #252). WindowsTabletDevice reads pressure support from its tablet descriptor and reports no click.

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

Fourteen more classes implement the interface without touching SDL. In the engine: ConsumerControlWrapper, WebControllerDevice, TouchpadOverlayDevice, WindowsTabletDevice, and RemotePeerDevice (see Engine Library). In the app: MidiInputDevice, NfcReaderDevice, MicrophoneInputDevice, SonyHeadsetMotionDevice, SystemMotionDevice, HandheldButtonsDevice, HeadTrackerDevice, OpenXrHandDevice, and LogitechGKeysDevice.


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 (an <EmbeddedResource> shipped inside PadForge.exe, not a loose file)

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 per line via SDL_AddGamepadMapping() after init (LoadEmbeddedGamepadMappings, see SDL3 Initialization and Hints).

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 binds 11 raw buttons, 6 axes, and hat 0 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 Device 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,. The file currently carries one entry, the DS3 DsHidMini line above. Use it as the shape reference.
  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 Device 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. Phase 2 of UpdateDevices() checks it on every enumeration pass. A device that first looks disconnected (handle detached, or ID absent from SDL_GetJoysticks()) enters a debounce window (SdlDisconnectDebounceMs = 2000) and transitions offline only once the condition holds for the full window. The exception is a handle Step 2 already saw detached: UpdateInputStates() drops the device's IsOnline flag on a null GetCurrentState(), and Phase 2 finishes that permanent detachment immediately, with no debounce.

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. Reset per-device writer caches keyed on DevicePath (wheel settings, LEDs, wheel FFB, the Logitech / Thrustmaster / raw HID writers)
  4. Neutralize and clear: NeutralizeMappedOutputsFor(ud), which calls ClearRuntimeState(). Nulls Device, InputState, OldInputState, ForceFeedbackState, and sets IsOnline=false, then drives the device's mapped outputs neutral so a held button or engaged trigger cannot freeze into the slot. The UserDevice record and its DeviceObjects remain for settings preservation
  5. Invalidate impulse-writer targets: XboxImpulseHidWriter.InvalidateCachedTargets()

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


SDL3 Fork

PadForge ships a custom SDL3 fork at hifihedgehog/SDL on branch feat/hidmaestro-filter. The built binaries land at PadForge.App/Resources/SDL3/x64/SDL3.dll and PadForge.App/Resources/SDL3/arm64/SDL3.dll. The development branch carries the fork's Release build at commit b8afb934a2 (2026-11-06) for both architectures. 5.2.0, 5.2.1 and 5.2.2 shipped e0b84e3498 (2026-10-24), 5.1.0 shipped 77cf0379f5 (2026-10-22), 5.0.0 shipped 756e0f2fb1 (2026-10-03), and 4.5.2 and 4.5.3 shipped 5df5eff539 (2026-09-21). Those are the hashes the builds carry. The branch rebases onto libsdl-org/SDL mainline, and a rebase gives every fork commit a new hash. On October 22, 2026 756e0f2fb1 became 85a0117bf7 and 5df5eff539 became 067b770ba8. On November 1, 2026 the sync to upstream c7038eb0a4 turned e0b84e3498 into 17da28a03a, 77cf0379f5 into 68dfa96acf, 85a0117bf7 into 3563535126 and 067b770ba8 into 73abfc7449. That sync also brought upstream's removal of SDL's RawInput and Windows.Gaming.Input joystick drivers. On Windows, SDL now lists joysticks through HIDAPI, GameInput, XInput and DirectInput, the fork's Bluetooth LE, serial, DJI network, RFCOMM and iCade drivers, and the virtual joysticks PadForge's own readers attach. The fork commits below carry the hashes of the current branch, and line citations into the fork's sources on this page are at b8afb934a2.

Why a Fork

The branch began with six commits spanning four logical features. v4 consumes substantially more fork work on top. The original four are listed first, the v4 additions in the next subsection.

# Feature SDL3 commits Paired non-SDL work
1 Switch 2 Pro Controller: WinUSB bulk I/O on Windows. The controller is a USB composite device with two interfaces. SDL needs to drive HID Interface 0 for input and WinUSB Interface 1 for the bulk init sequence. d21cfcc23e None
2 HIDMaestro virtual-controller filter (PadForge-specific): SDL_GetJoysticks walks each device's PnP parent chain and drops any device whose hardware-ID list contains the HIDMAESTRO substring, with a fast-path substring match on the interface symlink. Stops SDL from re-enumerating the virtual controllers PadForge just created. 83a462cf16 (main filter) + d1f43635c6 (HMXINPUT dead-branch cleanup) None
3 16 XInput controllers: bumps XUSER_MAX_COUNT from 4 to 16 so SDL3's XInput driver tracks all of PadForge's Xbox-category slots, rather than the first four alone. 4c5c41b23c hifihedgehog/OpenXinput branch OpenXinput1_4 commit 45c91b1 (CMake default bump)
4 XInput Share button: reads OpenXInput's XInputGetSystemButtons ordinal 109 and exposes Share at raw button 11 + the gamepad-db misc1 mapping. 65a041281d (loader + dispatch) + 5bde134631 (gamepad-db misc1:b11 mapping) Uses an existing OpenXInput export. No OpenXInput change needed.

The 2026 Steam Controller was originally a candidate fifth patch but Valve's upstream PR has since merged into libsdl-org/SDL mainline, so the fork just rebases against that work.

Additional v4 fork behaviors

The hints in InitializeSdl and the axis readers in SdlDeviceWrapper depend on further fork work beyond the original four features. Grounded here in the PadForge code that consumes them:

Fork ref Behavior Consumed by
SDL#5 Bluetooth-LE Switch 2 driver (Pro Controller 2, Joy-Con 2 L/R, NSO GameCube) over a custom BLE GATT service SDL_HINT_JOYSTICK_BLE_SWITCH2 (#153)
SDL#6 Wii Remote IR dots on dedicated joystick axes 6-9 HasIrCamera, ReadIrPointer (#146)
SDL#7 (extended by SDL#26) Right Joy-Con NIR MCU intensity on joystick axis 6, standalone or as the right half of a combined gen-1 pair SDL_HINT_JOYSTICK_HIDAPI_JOYCON_IR_SENSOR, HasJoyConIr, ReadJoyConIr (#151, #275)
SDL#8 Joy-Con 2 optical-mouse counters on joystick axes 6/7 SDL_HINT_JOYSTICK_BLE_SWITCH2_MOUSE, HasJoyCon2Mouse, ReadJoyCon2Mouse (#154)
SDL#9 The BLE Switch 2 driver takes SDL_SendGamepadEffect as a raw command passthrough, tears a controller down on link loss, and reports the Bluetooth address as the serial The Disconnect Controller macro action's Switch 2 power-off (BluetoothLinkHelper.TrySwitch2EffectPassthrough)
DS3 SixAxis Correct gyro and accel scaling for a DsHidMini DS3 in SXS mode SDL_HINT_JOYSTICK_HIDAPI_PS3_SIXAXIS_DRIVER (#194)
SDL#11 / SDL#12 / SDL#13 Wii MotionPlus coexistence, identify dead-window, and presence-churn fixes hidapi_wii driver (#198, #208, #210)
SDL#15 Drives the Switch NFC MCU on a Bluetooth right Joy-Con, combined pair or Pro Controller and exports SDL_GetGamepadNfcTagUid SDL_HINT_JOYSTICK_HIDAPI_SWITCH_NFC (runtime-managed), SDL_TryGetGamepadNfcTagUid, HasNfcReader (#241)
SDL#25 Frequency-shaped Switch rumble, and the Switch 2 BLE magnetometer axes SDL_HINT_JOYSTICK_HIDAPI_SWITCH_SHAPED_RUMBLE, SDL_HINT_JOYSTICK_BLE_SWITCH2_MAGNETOMETER, HasSwitch2Magnetometer (#271)
SDL#27 (fork commit c7155eb834) Five second-generation MOZA wheelbase IDs in initial_wheel_devices (SDL_joystick.c:637-641: 346E:0010 R16/R21, 0012 R9, 0014 R5, 0015 R3, 0016 R12), so a newer base is typed SDL_JOYSTICK_TYPE_WHEEL like the first-generation IDs at :632-636 SdlDeviceWrapper wheel typing, the Wheel tab (#282)
Fork commit c8afc3885a The DualSense's trigger feedback bytes (payload 40..47 of the full report) published as the joystick property SDL.joystick.hidapi.ps5.status_bytes DualSenseStatusBytes.Read, which fills the same bytes in the virtual DualSense's report (#433)
SDL_XINPUT_PADDLES (fork commits daf417d4ff, 45da6380d8, f446f321c0, 766383d741, cd69e20627, 73abfc7449, the last for SDL#32) Adds an Elite controller's four paddles to its XInput joystick as raw buttons 12-15 (paddle1-paddle4), over Bluetooth and, where the Windows GameInput files belong to a version family the fork has read, over USB and the Xbox Wireless Adapter. Builds for x64 and ARM64 Gamepad positions 12-15 (Right/Left Paddle 1 and 2)
SDL#36 (fork commit 3563535126) A Wii Remote with a Classic Controller registers the remote's accelerometer, and the Motion Plus gyro when one is present. Enabling a sensor is what powers the IR camera, and the driver registered none for that configuration, so its four IR axes never moved SdlDeviceWrapper, which enables every sensor a joystick registers. ReadIrPointer and the light gun (#485) then read the camera with a Classic Controller attached
SDL#37 (fork commits fcb93694c8, ba4da0ca1f) The wired Switch 2 search on Windows finds the controller's bulk interface in libusb's device list. It no longer takes a device whose serial string reads as another unit's, and with no serial on the HID side it skips a device whose serial a HIDMaestro virtual controller reports. Before, it fell back to the first device it had opened, which could be the Switch 2 Pro persona No PadForge code. It covers a real wired Switch 2 Pro Controller as an input beside a slot on the switch2-pro-controller-composite preset. Not run with either device
SDL#41 (fork commit 17da28a03a) The PS4 driver reads a Bluetooth report into 547 bytes and checks each state report's CRC at its own length, 78 bytes for 0x11 up to 547 for 0x19, where it read 128 bytes and checked every one at byte 78. A DualShock 4 with its headset microphone on sends its state in reports 0x12 to 0x19, so its input no longer stops AudioPassthroughService.EnableDs4BtMic, the DualShock 4 headset microphone (#528)
SDL#39 / SDL#40 (fork commits f6459bcb4f, 68dfa96acf) A Wii Remote's camera start runs again after 1, 2 and 4 seconds and then every 8 when it fails or a status report shows the camera off, and a remote that stops answering loses its joystick and is taken back when it answers again. The start runs on the driver's update loop, each write sent once the one before it is acknowledged, so it waits on nothing ReadIrPointer, with StepIrCameraCheck as a backstop (#484)
SDL#42 (fork commit bb08b128e6) SDL_GetJoystickFirmwareVersion reports the firmware version four more drivers read. The GIP and Xbox One drivers put an Xbox pad's major version in the high byte and its minor in the low one, so 5.5.2641.0 reads 0x0505. The Switch driver does the same over Bluetooth from its device info reply (3.72 reads 0x0348) and reports 0 over USB, and the Flydigi driver reports the version its info reply carried SdlDeviceWrapper.FirmwareVersion, read again every 5 seconds, and the Device Dossier's FIRMWARE line, which prints Microsoft and Nintendo values as decimal major.minor, Sony values as hex major.minor and others as the raw hex value (DeviceRowViewModel.FormatFirmwareVersion)
SDL#43 (fork commit 636cbca253) A Bluetooth DualSense that opens in simple mode reads its firmware version once it switches to enhanced reports. The switch marks a read, and the next update asks for feature report 0x20 under the joystick lock, takes it only at 64 bytes with a good CRC, and sets the open joystick's version The Device Dossier's FIRMWARE line for a Bluetooth DualSense, through the same 5-second re-read
SDL#44 (fork commit d291ad097d) The XInput driver reports an Xbox pad's firmware version from GameInput's device information. It finds the slot's device by its OpenXInput device path, or by the device instance that path names, and reports the major version in the high byte and the minor in the low one, at open or at the first update after GameInput lists the pad. Nothing else comes from GameInput: no joystick, no input, no change of ownership and no hint. Without a GameInput runtime that offers the v4 interface the version stays 0 The Device Dossier's FIRMWARE line for Xbox pads on USB, the Xbox Wireless Adapter and Bluetooth, through the same 5-second re-read. A Bluetooth LE pad GameInput gives no version for still shows its Device Information revision
SDL#45 (fork commit ab724dd13c) SDL's GameInput driver queues device events under a mutex of its own in its callback and applies them in its detect, under the joystick lock. Its quit had unregistered the callback under that lock while the callback waited on it, so a device event during SDL_Quit could hang. The same commit fixes an upstream read one entry past the device list when a device leaves PadForge's engine stop and exit, which call SDL_Quit (InputManager.ShutdownSdl)
SDL#46 (fork commits 54cad54d6a, 33ee67c4fc) The XInput driver also matches the only GameInput device in a slot's device container, reports only GameInput's version, and publishes all four parts as the joystick string property SDL.joystick.xinput.firmware, for example "5.17.3202.0" The Device Dossier's FIRMWARE line, which shows that string first (SdlDeviceWrapper.FirmwareText)
SDL#47 (fork commit b8afb934a2) A Wii Remote connects only after a status request it takes, so an empty Mayflash DolphinBar slot, which fails every write but can send input, gets no joystick, and a path where no identify gets a reply gets none either. A remote with no serial from the system, as on the bar, gets wiical- and its 21 bytes of EEPROM calibration in hex as its serial, read at each connection. On 64-bit Windows a loader patch gives the USB Vibration driver's EZFRD64.DLL and 811EZFRD64.DLL heaps below 4 GB, so DirectInput force feedback and GameInput run with that driver installed. SDL_PATCH_BROKEN_EZFRD64=0 turns the patch off, and the DirectInput haptic listing then skips devices as the joystick open does SdlDeviceWrapper.BuildInstanceGuid, which keys a remote on its serial, and FindOrCreateUserDevice, which hands a remote that first arrives with a serial the offline entry of the bar slot it sits in (#562)

The three Wii Bluetooth fixes (SDL#2 / SDL#3 / SDL#4) are covered in the Wii Controllers subsection below.

Upstream drivers the fork carries

The fork rebases on libsdl-org/SDL mainline, so every upstream HIDAPI driver is compiled in and registered in SDL_hidapijoystick.c. Three matter for devices this page is asked about. None needs a PadForge hint: each driver reads its own SDL_HINT_JOYSTICK_HIDAPI_* hint with SDL_HINT_JOYSTICK_HIDAPI as the default, which is on. PadForge sets the Flydigi driver's hint anyway, from the Flydigi Enhanced Protocol setting (#395, see the hints table above), and sets no hint for the other two.

Driver Registered at Devices Notes
SDL_HIDAPI_DriverFlydigi (SDL_hidapi_flydigi.c) SDL_hidapijoystick.c:128, enabled by SDL_JOYSTICK_HIDAPI_FLYDIGI at SDL_hidapijoystick_c.h:52 SDL_IsJoystickFlydigiController (SDL_joystick.c:3638) claims 04B4:2412 (first-generation protocol, interface 2 only) and 37D7:2501 / 37D7:2502 / 37D7:2401 (second generation, any interface), from usb_ids.h:46-47 and :137-140. The model comes from the controller's device ID (HIDAPI_DriverFlydigi_UpdateDeviceIdentity): Apex 2, 3, 4, 5, 6 and Vader 2, 2 Pro, 3, 3 Pro, 4 Pro, 5 Pro The gamepad mapping (SDL_gamepad.c:1493-1505) puts the four paddles at b11-b14, the Vader C/Z and the Apex 5 and Apex 6 shoulder macros at misc2:b15,misc3:b16, and the Vader 5 Pro's three extra buttons at misc4:b17,misc5:b18,misc6:b19. SdlDeviceWrapper carries MISC2-MISC6 at CustomInputState button indices 17-21 (SdlDeviceWrapper.cs:3227-3231), and the Steam Workshop importer maps a Vader 5 Pro profile's macro buttons onto them (PhysicalSlotResolver.cs). Apex 5, Apex 6, Vader 3 Pro, 4 Pro and 5 Pro report gyro and accelerometer. Latest upstream changes carried: b0ccb9ef0f (2026-10-30, removes the driver's stubs for unsupported functions), e00dcdc9a5 (2026-09-24, fixes the Apex 6's connection type) and d2050211eb (2026-09-21, Apex 6 support). The fork adds the firmware version (bb08b128e6, SDL#42)
SDL_HIDAPI_DriverSteamDeck (SDL_hidapi_steamdeck.c) SDL_hidapijoystick.c:94 28DE:1205 (controller_list.h:670) Trackpads, gyro, haptics. PadForge also drives the Deck as a virtual-controller persona (steam-deck-composite), which is HIDMaestro work, not SDL's
SDL_HIDAPI_DriverSteamTriton (SDL_hidapi_steam_triton.c) SDL_hidapijoystick.c:97 28DE:1302 wired, 1303 BLE, 1304 Proteus dongle, 1305 Nereid dongle (controller_list.h:671-674) The 2026 Steam Controller. Upstream added the driver in May 2026 (f7a8801227, f6ffa69890, 634dff3725). PadForge's PCM haptic stream (reports 0x86 / 0x88) is written by PadForge itself, outside SDL (TritonPcmSupport.cs)

If you change HM's enumerator name, hardware ID, or ContainerID, the filter substring on feat/hidmaestro-filter (commit 2 above) plus the same substring in the OpenXInput fork, XboxImpulseHidWriter's HID-interface enumeration, and HidHideController.IsHidMaestroDevice all have to be kept in sync. See hidmaestro-fork-resync-recipe.md in project memory.

Architecture

USB composite device with two interfaces:

Interface Protocol Purpose
HID Interface 0 Standard HID reports Input reading + rumble (Pro Controller output report ID 0x02, Joy-Con 2 0x01, NSO GameCube 0x03)
Bulk Interface 1 libusb over WinUSB Flash calibration reads + initialization sequence (10 commands via SendBulkData)

Windows binds Interface 1 to WinUSB from the controller's own MS OS 2.0 descriptors, so no INF ships with PadForge. The driver opens that interface through libusb (a standalone libusb_context of its own, because hidusb.sys holds Interface 0 and hidapi never opened the device over libusb) and claims the bulk endpoints it finds by walking the config descriptor. The bulk command sequence must run during init to put the controller into full-featured input mode.

Key Fork Modifications

File Change
SDL_hidapi.c Route the Switch 2 PIDs away from libusb on Windows (libusb cannot claim HID interfaces owned by hidusb.sys), keeping input on the platform HID backend while the driver opens Interface 1 for bulk I/O separately
SDL_hidapi_switch2.c Upstream driver (libusb bulk init, HID input parsing, rumble output, stick/sensor calibration). The fork adds the Windows path: open Interface 1 through its own libusb context, pick the libusb device whose USB serial matches the HID device so two wired controllers never share one handle, and close the handle and context on teardown and on the init error path

Bulk I/O Details

  • SendBulkData uses libusb_bulk_transfer on the OUT endpoint with a 1000 ms timeout, RecvBulkData reads the IN endpoint in 64-byte chunks with a 100 ms timeout
  • Calibration comes from flash blocks read over the same bulk pipes (serial at 0x13000, gyro and accel bias at 0x13040, stick calibration at 0x13080 / 0x130C0, user calibration at 0x1FC040 / 0x1FC080). A failed read logs a warning and leaves the defaults in place rather than failing the open

SDL Hint: SDL_HINT_JOYSTICK_HIDAPI_SWITCH2

Gates the Switch 2 driver. PadForge sets it to "1" before SDL_Init(). The driver is on without it too: an unset hint falls back to SDL_HINT_JOYSTICK_HIDAPI, whose Windows default is on (HIDAPI_DriverSwitch2_IsEnabled, SDL_HIDAPI_DEFAULT in SDL_hidapijoystick_c.h).

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.

Wii Controllers (hidapi_wii)

What this section covers: the three SDL3 fork fixes and the gating hint that let SDL drive Bluetooth-paired Wii controllers. The Bluetooth pairing ceremony itself is PadForge's code, not SDL's. See Wii Controllers Internals for the pairing flow.

Reading a Wii Remote, Wii Remote Plus, Nunchuk, Classic Controller / Classic Controller Pro, or Wii U Pro Controller is SDL's job, handled by SDL's built-in hidapi_wii driver (SDL_hidapi_wii.c). PadForge only runs the Bluetooth pairing (WiiPairingService), then SDL enumerates and parses the device through PadForge's normal pipeline. Driving a Wii Remote over Bluetooth on Windows 8+ needs three fixes carried in the bundled SDL3.dll:

Fix File Change
BT hid_write fallback (hifihedgehog/SDL#2) src/hidapi/windows/hid.c A Wii Remote's output reports (report length 22) fall under the 512-byte threshold where hidapi uses WriteFile, and the Microsoft Bluetooth stack rejects WriteFile for them with error 87 (ERROR_INVALID_PARAMETER). On that error hid_write sets dev->use_hid_write_output_report and retries through HidD_SetOutputReport, which first zero-pads the short report to dev->output_report_length. The fallback is success-gated, so devices that accept WriteFile stay on it.
Connect-timeout seed (hifihedgehog/SDL#3) SDL_hidapi_wii.c HIDAPI_DriverWii_UpdateDevice disconnects a device once SDL_GetTicks() >= m_ulLastInput + INPUT_WAIT_TIMEOUT_MS (3000 ms). HIDAPI_DriverWii_InitDevice calloc'd the context, leaving m_ulLastInput = 0, so a remote paired after the app's first three seconds was dropped before its first report (a Wii Remote does not stream until OpenJoystick). The fix seeds ctx->m_ulLastInput = SDL_GetTicks() in InitDevice.
Extension hot-plug (hifihedgehog/SDL#4) SDL_hidapi_wii.c Attaching or detaching a Nunchuk or Classic Controller on a connected remote was detected (HandleStatus set m_bDisconnected) but only disconnected the joystick. The extension type was read once at connect time. The fix re-identifies inside UpdateDevice: re-read ReadExtensionControllerType, re-run UpdateDeviceIdentity, re-seed m_ulLastInput (otherwise fix #3's timeout flaps), and re-add via HIDAPI_JoystickConnected.

Motion Plus, as the driver leaves it. The driver identifies a Motion Plus from the extension's six ID bytes, read at 0xA400FA while one is active and at 0xA600FA while one is inactive (GetMotionPlusState, SDL_hidapi_wii.c:720-765 at b8afb934a2). It writes the mode to 0xA600FE before activation and to 0xA400FE once a mode is set (:821). It converts the gyro words with fixed constants, 440 degrees per second per 8192 counts in slow mode and 2000 in fast mode, per axis (HandleMotionPlusData, :2119-2157). It reads no Motion Plus calibration block from the remote. The Balance Board calibration (ReadBalanceBoardCalibration, register 0xA40024) is the only calibration it reads for input, and the stick ranges are learned from the values seen (PostStickCalibrated). The remote's own camera and accelerometer calibration in EEPROM is read only as the serial of a remote with none (UpdateCalibrationSerial, SDL#47). The 4.4.0 grip rotations (sideways, Wii Wheel, upright, #392) are applied by PadForge to the frame SDL delivers, so the fork carries no change for them. The gyro conversion has not changed since the July Motion Plus fixes (bb00af95bb, 3908b7d93e, 490f4d15e0, the SDL#11 / SDL#12 / SDL#13 rows above). Since then 5fe8e81903 reads all six ID bytes, where the old read took two at 0xFE, 3563535126 (SDL#36) registers the gyro with a Classic Controller attached, and 68dfa96acf (SDL#40) sends the mode write and the deactivation through the driver's write queue.

PadForge needs no code change for the extension swap. SdlDeviceWrapper.BuildInstanceGuid keys the device on serial:VID:PID:<BT-MAC>, or on its calibration serial for a remote on a DolphinBar (SDL#47), which stays stable across the extension change, and the device's capabilities and DeviceObjects refresh when SDL re-adds it.

SDL Hint: SDL_HINT_JOYSTICK_HIDAPI_WII

Gates the hidapi_wii driver. InputManager.InitializeSdl sets it to "1" (InputManager.cs:960) before SDL_Init(), next to the Switch 2 hint. The constant resolves to "SDL_JOYSTICK_HIDAPI_WII" (SDL3Minimal.cs:54). Enabling the driver also lights the player LED, which stops the remote's idle flashing.

InputManager.RescanWiiControllers() (InputManager.cs:1483) re-uses the hint as a per-driver restart after a pair. The BluetoothSetServiceState change during pairing invalidates the handle SDL grabbed mid-pairing, leaving a stale device that normally only a full app restart clears. The method toggles the hint "0" then "1" eight times on a background task (200 ms off, 1200 ms on, about 11 s total) so SDL_HIDAPIDriverHintChanged tears down the dead handle and re-enumerates the now-stable device. MainWindow calls it through InputService after the PairDeviceDialog closes, and only when a Wii pairing landed (dialog.PairedWii, MainWindow.xaml.cs:962-963), because the rescan drops every connected Wii Remote.

Build Instructions

Clone the fork, check out feat/hidmaestro-filter, then build with CMake from a Developer Command Prompt (vcvarsall.bat x64):

git clone https://github.com/hifihedgehog/SDL.git
cd SDL
git checkout feat/hidmaestro-filter
cmake -B build -A x64 -DSDL_HIDAPI_LIBUSB=ON -DSDL_HIDAPI_LIBUSB_SHARED=ON -DLibUSB_INCLUDE_PATH=<folder with libusb.h> -DLibUSB_LIBRARY=<path to libusb-1.0.lib>
cmake --build build --config Release --target SDL3-shared

Pass both libusb paths. FindLibUSB.cmake looks only on CMake's search path and in pkg-config's answer, and when it finds nothing HAVE_LIBUSB stays off: the wired Switch 2 driver compiles out (SDL_hidapijoystick_c.h:40-41) and the GameCube adapter becomes unreachable.

Copy the output SDL3.dll into PadForge.App/Resources/SDL3/x64/ before publishing PadForge.

The ARM64 build is the same configuration generated with -A ARM64 into its own build directory, cross-compiled on an x64 machine, and its SDL3.dll goes into PadForge.App/Resources/SDL3/arm64/. libusb publishes no MSVC ARM64 import library, so one is generated from the exports of the ARM64 libusb-1.0.dll with lib /def /machine:arm64. The ARM64 configuration differs from x64 in one way: NEON replaces SSE and AVX. Until fork commit cd69e20627 the Elite paddle reader required SDL_CPU_X64, so SDL_XINPUT_PADDLES was off for ARM64. It builds for both now.

Whichever architecture you build, check the libusb name inside the DLL before bundling it. The configure step reads it off the import library with dumpbin and stops if it does not end in .dll. BundledSdlLibusbNameTests in PadForge checks the delivered file again.


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

Last updated for PadForge 5.2.0.