Implements issue #14. A dedicated thread owns SDL3 (3.4.16, statically linked via sdl3 =0.20.0) and publishes per-slot snapshots re-expressed as the XInput-shaped XboxState, so the TU3 conversion in skate-core is unchanged. Conversions are exact for XInput-backed pads (stick Y via bitwise NOT, trigger round-to-nearest). SKATE3_INPUT=xinput selects the original transport, which is also the fallback if SDL fails to start. Optional settings/controller.json maps paddles to XInput buttons. Build: scripts/Ensure-CMake.ps1 locates CMake (PATH or Visual Studio's copy) for the SDL source build. sdl3-sys is vendored with a build.rs patch setting CMAKE_MSVC_RUNTIME_LIBRARY from crt-static, because SDL's CMake (policy CMP0091) ignores cmake-rs' -MT and the static-CRT release build failed with unresolved __imp_* symbols. See docs/hails-additions/03-sdl3-gamepad-input.md and vendor/README.md.
6.0 KiB
3. SDL3 gamepad input
Implements upstream issue #14 ("Extending Controller support via SDL Gamepad API"), discussed in PR #9. The Windows XInput transport remains available as a fallback and for comparison.
Motivation
- One controller API on every platform instead of XInput on Windows plus a separate backend elsewhere (PR #9 uses gilrs on Linux/macOS).
- SDL3 supports far more controllers (PlayStation, Switch, generic HID) and exposes features XInput cannot (extra buttons such as back paddles, rumble on triggers, sensors, lights) for future use.
Design
The gameplay path is unchanged: skate_core::input::xbox::convert (the TU3
conversion with its own dead zone) still receives an XInput-shaped
XboxState with raw values. Only the transport in
crates/skate-game/src/input/platform.rs changes.
- Threading. SDL must be pumped on the thread that initialised it, while
Bevy runs systems on a pool. A dedicated
sdl-gamepadthread owns SDL, polls every 1 ms, and publishes a per-slot snapshot behind a mutex; the game reads the latest snapshot each frame, exactly asXInputGetStatereturns the latest state. - Conversion back to XInput shape (exact for XInput-backed devices):
- buttons → XInput
wButtonsbits (BUTTONStable); - stick Y: SDL stores
~y(positive down), soxinput_y = !sdl_yrestores the original value without overflow at the extremes; - triggers: SDL expands XInput's byte as
b*257over the full axis, then rescales to0..=32767; rounding to nearest ((v*255 + 16383) / 32767) returns the original byte for all 256 values.
- buttons → XInput
- Packet number advances only when the state changes, mirroring XInput's
dwPacketNumber. Device subtype is reported asXINPUT_DEVSUBTYPE_GAMEPAD. - Slots. A new pad takes its SDL player index if that is 0–3 and free (SDL reports the XInput user index there, so the ring light matches), otherwise the first free slot.
- Paddles. Optional
settings\controller.json, e.g.{"paddles": {"right1": "a", "left1": "x"}}, ORs a paddle into an XInput button bit (names inplatform::BUTTON_NAMES). - Backend selection. SDL by default.
SKATE3_INPUT=xinputselects the original XInput transport (Windows), which is also the automatic fallback if SDL fails to start. The backend starts atStartupso the first gameplay frame is not stalled. - Diagnostics. Each connection logs name, SDL gamepad type,
vendor/product, paddle availability and SDL driver path, e.g.
Controller 0: SDL gamepad "Xbox One Elite 2 Controller" (XboxOne, vendor 045e product 0b22, paddles not reported, path "XInput#0").
Files
crates/skate-game/src/input/platform.rs— SDL backend, conversions, backend selection, paddle masks, unit tests.crates/skate-game/src/input.rs—start_controllersstartup system (settings + early backend start); log wording.crates/skate-game/src/app.rs— comment on why Bevy'sGilrsPluginstays disabled.crates/skate-core/src/input/xbox.rs—XboxStatederivesClone, Copy, Debug, Default, PartialEq, Eq(snapshots cross threads).crates/skate-game/Cargo.toml—sdl3 = "=0.20.0"withbuild-from-source-static(SDL 3.4.16, statically linked, no DLL to ship).scripts/Ensure-CMake.ps1(new) — finds CMake for the SDL build: usescmakeon PATH, else Visual Studio's bundled copy viavswhere, and setsCMAKE. Dot-sourced byBuild.ps1,Build-Release.ps1,build-multiplayer-test.ps1. CI runners already have CMake.vendor/sdl3-sys/+ rootCargo.toml[patch.crates-io]+Cargo.lock— static C runtime fix, below.
Static C runtime (release/CI builds)
Release packages, CI and the multiplayer test build with +crt-static and
static Bevy. The first release-style build failed to link:
libsdl3_sys-*.rlib(SDL_stdlib.obj) : error LNK2019: unresolved external symbol __imp_modff ...
fatal error LNK1120: 16 unresolved externals
cmake-rs passes -MT for crt-static, but SDL's CMake project enables policy
CMP0091, which ignores runtime flags in CMAKE_C_FLAGS and applies
CMAKE_MSVC_RUNTIME_LIBRARY (default MultiThreadedDLL). CMake has no
environment variable for this, and a CMAKE_TOOLCHAIN_FILE environment
variable would put cmake-rs into cross-compile mode.
Fix: sdl3-sys 0.7.1+SDL-3.4.16 is vendored unmodified except build.rs,
which defines CMAKE_MSVC_RUNTIME_LIBRARY from CARGO_CFG_TARGET_FEATURE
(MultiThreaded with crt-static, otherwise MultiThreadedDLL; never the
debug CRT). Documented in vendor/README.md. Dropping the patch is possible
once cmake-rs or sdl3-sys handle CMP0091 themselves.
Verification
- Unit tests: every trigger byte survives the SDL round trip; stick Y
inversion is exact at
i16::MIN/MAX; paddle masks and button names; existing controller tests (10 total ininput::platform/input::controllers) pass. - Dev build (
scripts/Build.ps1) and release-style build (+crt-static --no-default-features --target x86_64-pc-windows-msvc) both link; the static executable imports novcruntime140.dll/api-ms-win-crt-*. The dev build's SDL CMake cache showsMultiThreadedDLL. - In play: Xbox Elite Series 2 over Bluetooth LE detected as
Controller 0 ... ready; normal skating across several sessions.
Known limitations / open questions
- Elite paddles are not reported on Windows with this setup. SDL picks its
XInput driver for the pad ("XInput#0"); the XInput API has no paddles. The
hints
SDL_JOYSTICK_GAMEINPUT=1,SDL_JOYSTICK_HIDAPI_XBOX(_ONE)=1andSDL_JOYSTICK_RAWINPUT=0made no difference; withSDL_XINPUT_ENABLED=0no backend detected the Bluetooth LE pad. The paddle mapping works wherever SDL does report paddles. - Controller type is detected but only logged; carrying it into the game (for button prompts, per-model defaults) is future work. Nintendo layouts keep SDL's positional mapping on purpose.
- Linux/macOS: the backend is platform-neutral but was only built and tested on Windows here.
- The SDL build adds about 1¾ minutes to a clean build and needs CMake.