Skip to content

sdl: port the joystick, gamepad and audio backends to SDL3 - #922

Draft
edumeneses wants to merge 1 commit into
masterfrom
sdl3
Draft

sdl: port the joystick, gamepad and audio backends to SDL3#922
edumeneses wants to merge 1 commit into
masterfrom
sdl3

Conversation

@edumeneses

@edumeneses edumeneses commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

The libossia half of the SDL3 move. Pairs with ossia/sdk#27 (SDK) and ossia/score#2176 (consumer).

Per @jcelerier's plan on ossia/sdk#28 — prove SDL3 works, then flip sdl.sh in place rather than shipping both SDLs — this is the prerequisite: an SDL3-only SDK can't be validated against libossia until libossia speaks SDL3.

Most of it is upstream's rename table

SDL_oldnames.h (what build-scripts/rename_symbols.py applies) covers the bulk: SDL_JoystickOpenSDL_OpenJoystick, SDL_GameControllerHasAxisSDL_GamepadHasAxis, SDL_CONTROLLER*SDL_GAMEPAD*, the SDL_Controller*Event structs → SDL_Gamepad*Event with union members caxis/cbutton/ctouchpad/csensorgaxis/gbutton/gtouchpad/gsensor, event enums → SDL_EVENT_*.

Four things needed more than a rename

Device indices are gone. SDL2 enumerated 0..SDL_NumJoysticks()-1 and mapped index → identity via SDL_JoystickGetDeviceInstanceID. SDL3 has only SDL_GetJoysticks(), returning an owned array of stable SDL_JoystickID.

joystick_info keeps its index-based signatures on purpose. An index into that array plays exactly the role SDL2's device index did, and is just as transient — valid until the device list changes. So score's enumeration loops and the (id, index) pair it serialises into save files keep working untouched. A new sdl_joystick_ids RAII helper owns the array; an out-of-range index yields SDL3's invalid id of 0, which joystick_info translates back to the -1 sentinel its callers expect. game_controller_protocol no longer needs the index at all (SDL_GetGamepadTypeForID takes the id), so m_joystick_index is gone.

One tradeoff: each joystick_info accessor now materialises the id array, so score's for i in 0..count loop is O(n²) allocations instead of O(n). With a handful of joysticks that's noise, and it keeps the helper trivially correct. Happy to hoist the list into the callers if you'd rather.

Booleans changed shape. SDL_Init and the Has*/SetSensorEnabled family return bool, not an int status — so < 0, >= 0 and == SDL_TRUE tests go away. Button events carry down instead of state == SDL_PRESSED.

Rumble capabilities became properties. SDL_GameControllerHasRumble / HasRumbleTriggersSDL_GetBooleanProperty(SDL_GetGamepadProperties(...), SDL_PROP_GAMEPAD_CAP_RUMBLE_BOOLEAN / ..._TRIGGER_RUMBLE_BOOLEAN, false).

Also worth flagging: SDL3 merged SDL_HINT_JOYSTICK_HIDAPI_PS4_RUMBLE and _PS5_RUMBLE into a single SDL_HINT_JOYSTICK_ENHANCED_REPORTS (per docs/README-migration.md), so those two switch cases collapse into one.

The audio engine is a real rewrite

SDL3 has no pull callback filling a fixed-size buffer. SDL_OpenAudioDeviceStream hands us a stream and asks for additional_amount bytes, which varies per call. The callback now services that request in whole blocks of effective_buffer_size frames, pushing each through SDL_PutAudioStreamData, with SDL_HINT_AUDIO_DEVICE_SAMPLE_FRAMES set to the requested block size so the device buffer matches. Overshooting by at most one block is fine — the stream buffers it.

SDL_AudioSpec lost samples, callback and userdata, so effective_buffer_size is now what we asked for rather than what the device reported. The interleave scratch buffers are preallocated members instead of per-callback alloca, which the fixed block size makes possible — no allocation in the audio callback. Status comes from SDL_AudioStreamDevicePaused.

Two guards dropped

The SDL_VERSION_ATLEAST(2, 26, 0) fallback for SDL_SENSOR_GYRO_R is dead with SDL3 as the floor.

More interesting: sdl_protocol.hpp no longer includes SDL_config.h to test SDL_AUDIO_DISABLED. SDL3 doesn't install SDL_build_config.h — and doesn't need to. I checked a -DSDL_AUDIO=0 SDL3 build and it still exports SDL_OpenAudioDeviceStream, SDL_PutAudioStreamData, SDL_ResumeAudioStreamDevice, SDL_DestroyAudioStream and SDL_AudioStreamDevicePaused. So the engine compiles and links either way, and an audio-less build just fails SDL_Init(SDL_INIT_AUDIO) at runtime. That matters because the SDK builds SDL with SDL_AUDIO=0 on every platform.

WASM

-s USE_SDL=2-s USE_SDL=3, i.e. option (1) from the ossia/sdk#27 discussion. Emscripten's sdl3 port exists at the EMSDK_VERSION the SDK pins and has both SDL_JOYSTICK_EMSCRIPTEN and SDL_AUDIO_DRIVER_EMSCRIPTEN enabled, so both backends stay live in the browser. It does still print sdl3 port is still experimental, and it ships SDL 3.4.2 against the 3.4.14 built natively.

This is the part that most needs your full-WASM verification — it's the one behaviour I can't check from here.

Testing (linux-x86_64, SDL3 3.4.14)

Compiled under score's own flags and warnings, not a synthetic setup:

  • game_controller_protocol.cpp, joystick_protocol.cpp, audio_engine.cpp — clean.
  • Confirmed audio_engine.cpp really compiles the SDL engine rather than skipping it via __has_include: the preprocessed TU contains class sdl_protocol and 8 references to the stream API, and the only SDL_OpenAudioDevice left is SDL3's own (different signature) declaration in its header.
  • A standalone harness mirroring the two rewritten paths: SDL_Init(JOYSTICK|GAMEPAD) succeeds, enumeration + GUID lookup + SDL_IsGamepad work, out-of-range index → id 0, and the audio callback produced exactly 77 blocks × 256 frames = 19712 with the expected paused=1paused=0 transition.

Not covered: real hardware. No gamepad was attached to the test machine, so rumble, sensors and touchpad input are compile- and API-verified only. Someone with a DualSense or an Xbox pad should exercise those before this goes in.

🤖 Generated with Claude Code

https://claude.ai/code/session_01TTUi76cnUWh7dGZC64LqbZ

Moves every SDL2 call site to SDL3 and switches find_package(SDL2) to SDL3.
ossia::sdl2 becomes ossia::sdl3, and the SDK prefix path moves from
$OSSIA_SDK/SDL2 to $OSSIA_SDK/SDL3 to match ossia/sdk#27.

Most of it is upstream's rename table (SDL_oldnames.h, which is also what
build-scripts/rename_symbols.py applies): SDL_JoystickOpen -> SDL_OpenJoystick,
SDL_GameControllerHasAxis -> SDL_GamepadHasAxis, SDL_CONTROLLER* -> SDL_GAMEPAD*,
the SDL_Controller*Event structs -> SDL_Gamepad*Event and their union members
caxis/cbutton/ctouchpad/csensor -> gaxis/gbutton/gtouchpad/gsensor, and the event
enums to SDL_EVENT_*. Four things needed more than a rename:

Device indices are gone. SDL2 enumerated joysticks as 0..SDL_NumJoysticks()-1
and mapped an index to an identity with SDL_JoystickGetDeviceInstanceID; SDL3
has only SDL_GetJoysticks(), returning an owned array of stable SDL_JoystickID.
joystick_info keeps its index-based signatures on purpose: an index into that
array plays exactly the role SDL2's device index did, and is just as transient
(valid until the device list changes), so score's enumeration loop and the
(id, index) pair it serialises into save files keep working untouched. The new
sdl_joystick_ids RAII helper owns the array; index 0 maps to SDL3's invalid id
of 0, which joystick_info translates back to the -1 sentinel its callers expect.
game_controller_protocol no longer needs to remember the index at all, since
SDL_GetGamepadTypeForID takes the id, so m_joystick_index is gone.

Booleans changed shape. SDL_Init and the Has*/SetSensorEnabled family return
bool rather than an int status, so `< 0`, `>= 0` and `== SDL_TRUE` tests are
dropped; button events carry `down` instead of `state == SDL_PRESSED`.

The rumble capability queries were replaced by properties:
SDL_GameControllerHasRumble/HasRumbleTriggers become SDL_GetBooleanProperty on
SDL_GetGamepadProperties with SDL_PROP_GAMEPAD_CAP_RUMBLE_BOOLEAN and
SDL_PROP_GAMEPAD_CAP_TRIGGER_RUMBLE_BOOLEAN.

The audio engine is a real rewrite. SDL3 has no pull callback filling a
fixed-size buffer: SDL_OpenAudioDeviceStream hands us a stream and asks for
`additional_amount` bytes, which varies. The callback now services that request
in whole blocks of effective_buffer_size frames and pushes each through
SDL_PutAudioStreamData, with SDL_HINT_AUDIO_DEVICE_SAMPLE_FRAMES set to the
requested block size so the device buffer matches. Overshooting the request by
at most one block is fine - the stream buffers it. SDL_AudioSpec lost `samples`,
`callback` and `userdata`, so effective_buffer_size is now what we asked for
rather than what the device reported. The interleave scratch buffers are
preallocated members instead of per-callback alloca, which the fixed block size
now makes possible. Status comes from SDL_AudioStreamDevicePaused instead of
SDL_GetAudioDeviceStatus.

Two guards were dropped. The SDL_VERSION_ATLEAST(2, 26, 0) fallback for
SDL_SENSOR_GYRO_R is dead now that SDL3 is the floor. And sdl_protocol.hpp no
longer includes SDL_config.h to test SDL_AUDIO_DISABLED: SDL3 does not install
SDL_build_config.h, and it does not need to - verified that a
-DSDL_AUDIO=0 SDL3 build still exports SDL_OpenAudioDeviceStream,
SDL_PutAudioStreamData, SDL_ResumeAudioStreamDevice, SDL_DestroyAudioStream and
SDL_AudioStreamDevicePaused, so the engine compiles and links either way and an
audio-less build simply fails SDL_Init(SDL_INIT_AUDIO) at runtime.

WASM moves to `-s USE_SDL=3`. Emscripten's sdl3 port exists at the
EMSDK_VERSION the SDK pins and has SDL_JOYSTICK_EMSCRIPTEN and
SDL_AUDIO_DRIVER_EMSCRIPTEN enabled, so both backends stay live in the browser;
it does still warn that the port is experimental.

Verified on linux-x86_64: game_controller_protocol.cpp, joystick_protocol.cpp
and audio_engine.cpp compile clean against SDL3 3.4.14 under score's own flags
and warnings, and audio_engine.cpp really does compile the SDL engine (the
preprocessed TU contains class sdl_protocol and the stream API, with no SDL2
call left). A standalone harness mirroring the two rewritten paths confirms the
behaviour: SDL_Init(JOYSTICK|GAMEPAD) succeeds, enumeration and GUID lookup work,
an out-of-range index yields id 0, and the audio callback produced exactly
77 blocks of 256 frames with the expected paused -> resumed transition.

No gamepad was attached to the test machine, so rumble, sensors and touchpad
input are compile- and API-verified but not exercised against real hardware.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TTUi76cnUWh7dGZC64LqbZ
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant