diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d499f983..0680bd0d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -113,14 +113,14 @@ jobs: shell: cmd run: | cmake --build _Build/windows --target launcher ^ - audio_out2_port_tests ime_dialog_tests lru_cache_tests virtual_memory_allocation_tests ^ + audio_out2_port_tests pad_haptics_tests ime_dialog_tests lru_cache_tests virtual_memory_allocation_tests ^ resource_tracking_tests scalar_provenance_tests ^ resource_materialization_tests shader_vertex_metadata_tests --parallel - name: Test shell: cmd run: | - ctest --test-dir _Build/windows --output-on-failure -R "^(audio_out2_port|ime_dialog|lru_cache|resource_tracking|scalar_provenance|resource_materialization|shader_vertex_metadata|virtual_memory_allocation)$" + ctest --test-dir _Build/windows --output-on-failure -R "^(audio_out2_port|pad_haptics|ime_dialog|lru_cache|resource_tracking|scalar_provenance|resource_materialization|shader_vertex_metadata|virtual_memory_allocation)$" - name: Install shell: cmd @@ -201,7 +201,7 @@ jobs: shell: bash run: | cmake --build _Build/macos \ - --target launcher audio_out2_port_tests ime_dialog_tests lru_cache_tests virtual_memory_allocation_tests \ + --target launcher audio_out2_port_tests pad_haptics_tests ime_dialog_tests lru_cache_tests virtual_memory_allocation_tests \ resource_tracking_tests scalar_provenance_tests \ resource_materialization_tests shader_vertex_metadata_tests \ --parallel @@ -210,7 +210,7 @@ jobs: shell: bash run: | ctest --test-dir _Build/macos --output-on-failure \ - -R '^(audio_out2_port|ime_dialog|lru_cache|resource_tracking|scalar_provenance|resource_materialization|shader_vertex_metadata|virtual_memory_allocation)$' + -R '^(audio_out2_port|pad_haptics|ime_dialog|lru_cache|resource_tracking|scalar_provenance|resource_materialization|shader_vertex_metadata|virtual_memory_allocation)$' - name: Install shell: bash @@ -384,7 +384,7 @@ jobs: run: | cmake --build _Build/linux \ --target launcher page_manager_tests memory_tracker_tests \ - audio_out2_port_tests ime_dialog_tests lru_cache_tests virtual_memory_allocation_tests \ + audio_out2_port_tests pad_haptics_tests ime_dialog_tests lru_cache_tests virtual_memory_allocation_tests \ resource_tracking_tests scalar_provenance_tests \ resource_materialization_tests shader_vertex_metadata_tests \ --parallel @@ -393,7 +393,7 @@ jobs: shell: bash run: | ctest --test-dir _Build/linux --output-on-failure \ - -R '^(audio_out2_port|ime_dialog|lru_cache|memory_tracker|page_manager|resource_tracking|scalar_provenance|resource_materialization|shader_vertex_metadata|virtual_memory_allocation)$' + -R '^(audio_out2_port|pad_haptics|ime_dialog|lru_cache|memory_tracker|page_manager|resource_tracking|scalar_provenance|resource_materialization|shader_vertex_metadata|virtual_memory_allocation)$' - name: Install shell: bash diff --git a/src/libs/audio.cpp b/src/libs/audio.cpp index 6ba5672b..ec89ff40 100644 --- a/src/libs/audio.cpp +++ b/src/libs/audio.cpp @@ -32,6 +32,7 @@ constexpr int AUDIO_OUT_PORT_TYPE_AUDIO3D = 126; constexpr int AUDIO_OUT_PORT_TYPE_AUX = 127; constexpr uint32_t AUDIO_OUT_PARAM_FORMAT_MASK = 0x000000ffu; +constexpr uint64_t AUDIO_OUT_TARGET_LATENCY_US = 40000; constexpr int AUDIO_IN_SILENT_STATE_DEVICE_NONE = 0x1; constexpr uint32_t AUDIO_IN_GRAIN_MAX_ASYNC = 384; @@ -368,11 +369,11 @@ bool Audio::QueueSdlAudio(PortOut* port, const void* data, bool blocking) { uint32_t min_queued_size = 0; if (blocking) { - constexpr uint64_t target_latency_us = 40000; const auto buffer_us = port->freq != 0 ? (1000000ULL * port->samples_num) / port->freq : 0; const auto buffers = - buffer_us != 0 ? static_cast((target_latency_us + buffer_us - 1) / buffer_us) - : 2u; + buffer_us != 0 + ? static_cast((AUDIO_OUT_TARGET_LATENCY_US + buffer_us - 1) / buffer_us) + : 2u; min_queued_size = prepared_size * std::clamp(buffers, 2u, 16u); const auto wait_start = LibKernel::KernelGetProcessTime(); auto queued = SDL_GetAudioStreamQueued(port->stream); @@ -440,11 +441,14 @@ Audio::Id Audio::AudioOutOpen(int type, uint32_t samples_num, uint32_t freq, For port.volume[i] = 32768; } - if (type == AUDIO_OUT_PORT_TYPE_VIBRATION) { - port.haptics = Controller::DualSenseHaptics::Open(freq); - } else { + // Pad speaker ports keep the main output too; it plays them whenever no DualSense does. + if (type != AUDIO_OUT_PORT_TYPE_VIBRATION) { OpenSdlDevice(&port); } + if (type == AUDIO_OUT_PORT_TYPE_VIBRATION || type == AUDIO_OUT_PORT_TYPE_PADSPK) { + port.haptics = + Controller::DualSenseHaptics::Open(freq, type == AUDIO_OUT_PORT_TYPE_PADSPK); + } return Id::Create(id); } @@ -554,14 +558,22 @@ uint32_t Audio::AudioOutOutputs(OutputParam* params, uint32_t num, bool blocking for (uint32_t i = 0; i < num; i++) { auto& port = m_out_ports[params[i].handle.GetId()]; - if (port.type == AUDIO_OUT_PORT_TYPE_VIBRATION) { - // Haptics never pace output; keep the stream alive against close and volume changes. + uint64_t controller_queued_us = 0; + if (port.haptics != nullptr) { + // Keep the stream alive against close and volume changes. Common::LockGuard lock(m_mutex); - Controller::DualSenseHaptics::Queue( + controller_queued_us = Controller::DualSenseHaptics::Queue( port.haptics, Controller::GetActiveControllerId(), params[i].data, port.samples_num, static_cast(port.channels_num), FormatIsFloat(port.format), port.volume); - } else { + } + if (controller_queued_us == 0) { + // No DualSense took it (e.g. it was unplugged); a pad speaker port plays on the main + // output instead, and a vibration port has none. QueueSdlAudio(&port, params[i].data, blocking); + } else if (blocking && port.type == AUDIO_OUT_PORT_TYPE_PADSPK && + controller_queued_us > AUDIO_OUT_TARGET_LATENCY_US) { + // Vibration never paces output; the speaker is audible, so pace it like other ports. + Common::Thread::SleepMicro(controller_queued_us - AUDIO_OUT_TARGET_LATENCY_US); } } diff --git a/src/libs/dualSenseHaptics.cpp b/src/libs/dualSenseHaptics.cpp index b092f720..95a82dea 100644 --- a/src/libs/dualSenseHaptics.cpp +++ b/src/libs/dualSenseHaptics.cpp @@ -5,13 +5,17 @@ #include #include +#include #include +#include #include namespace Libs::Controller::DualSenseHaptics { struct Stream { uint32_t freq = 0; + bool speaker = false; + int controller = -1; SDL_AudioStream* sdl = nullptr; SDL_AudioDeviceID device = 0; uint64_t next_check = 0; @@ -20,19 +24,26 @@ struct Stream { namespace { -constexpr int FRAME_BYTES = 4 * sizeof(float); -Common::Mutex g_mutex; -int g_controller = -1; -int g_playback_streams = 0; -uint8_t g_large_motor = 0, g_small_motor = 0; -uint64_t g_rumble_until = 0, g_haptics_until = 0; +constexpr int FRAME_BYTES = 4 * sizeof(float); +Common::Mutex g_mutex; +int g_controller = -1; +int g_haptics_streams = 0; +uint8_t g_large_motor = 0, g_small_motor = 0; +uint64_t g_rumble_until = 0, g_haptics_until = 0; +int g_speaker_controller = -1; +std::map g_speaker_streams; -void ApplyRumble() { // Caller holds g_mutex; no calls into Audio or Controller. +void ApplyRumble(bool force = false) { // Caller holds g_mutex; no calls into Audio or Controller. SDL_LockJoysticks(); if (auto* pad = SDL_GetGamepadFromID(static_cast(g_controller)); pad != nullptr) { const auto now = SDL_GetTicks(); const bool muted = g_haptics_until > now || g_rumble_until <= now; + if (force && !muted) { + // SDL caches motor strengths. Force a report after raw speaker routing changed the + // controller's motor mode without changing that cache. + (void)SDL_RumbleGamepad(pad, 0, 0, 0); + } (void)SDL_RumbleGamepad( pad, muted ? 0 : g_large_motor * 0x101U, muted ? 0 : g_small_motor * 0x101U, g_rumble_until > now ? static_cast(g_rumble_until - now) : 0); @@ -42,14 +53,52 @@ void ApplyRumble() { // Caller holds g_mutex; no calls into Audio or Controller. void SelectController(int controller) { if (g_controller != controller) { - g_haptics_until = 0; + g_haptics_until = g_rumble_until = 0; ApplyRumble(); g_controller = controller; g_large_motor = g_small_motor = 0; - g_rumble_until = 0; } } +bool RouteSpeaker(int controller, bool speaker) { // Caller holds g_mutex. + // Route the right channel to the speaker and mute the headphones with the values Linux uses + // without headphones (output report 0x02). Plugged-in headphones are not checked. + std::array report {}; + report[0] = 0x80; // Audio control valid + if (speaker) { + report[0] |= 0x20; // Speaker volume valid + report[1] = 0x80; // Audio control 2 valid + report[5] = 0x64; // Speaker volume + report[7] = 0x30; // Output path: right channel to the speaker, headphones muted + report[37] = 0x02; // Speaker preamp +6 dB + } + bool routed = false; + SDL_LockJoysticks(); + if (auto* pad = SDL_GetGamepadFromID(static_cast(controller)); pad != nullptr) { + routed = SDL_SendGamepadEffect(pad, report.data(), static_cast(report.size())); + } + SDL_UnlockJoysticks(); + if (routed && controller == g_controller) { + ApplyRumble(true); + } + return routed; +} + +bool SelectSpeaker(int controller) { // Caller holds g_mutex; -1 restores headphone routing. + if (g_speaker_controller == controller) { + return true; + } + if (g_speaker_controller != -1) { + RouteSpeaker(g_speaker_controller, false); + } + g_speaker_controller = -1; + if (controller != -1 && !RouteSpeaker(controller, true)) { + return false; + } + g_speaker_controller = controller; + return true; +} + void SDLCALL UpdateRumble(void*, SDL_AudioStream*, int, int) { Common::LockGuard lock(g_mutex); if (g_haptics_until != 0 && SDL_GetTicks() >= g_haptics_until) { @@ -64,22 +113,55 @@ void CloseDevice(Stream* stream) { SDL_DestroyAudioStream(stream->sdl); stream->sdl = nullptr; Common::LockGuard lock(g_mutex); - if (--g_playback_streams == 0 && g_haptics_until != 0) { + if (!stream->speaker && --g_haptics_streams == 0 && g_haptics_until != 0) { g_haptics_until = 0; ApplyRumble(); } + // Route back to the headphones once no speaker port plays on the controller. + if (stream->speaker && --g_speaker_streams.at(stream->controller) == 0) { + g_speaker_streams.erase(stream->controller); + if (g_speaker_controller == stream->controller) { + SelectSpeaker(-1); + } + } } } +bool CanUseDevice(int controller) { + // SDL exposes no portable association between a gamepad and its USB audio endpoint. + // Require one wired DualSense; guessing with multiple pads could play on the wrong one. + SDL_LockJoysticks(); + auto* pad = SDL_GetGamepadFromID(static_cast(controller)); + bool usable = + pad != nullptr && SDL_GetGamepadConnectionState(pad) == SDL_JOYSTICK_CONNECTION_WIRED; + int count = 0; + auto* pads = SDL_GetGamepads(&count); + int dualsense_count = 0; + bool found = false; + for (int i = 0; i < count; i++) { + if (SDL_GetGamepadTypeForID(pads[i]) == SDL_GAMEPAD_TYPE_PS5) { + dualsense_count++; + found |= pads[i] == static_cast(controller); + } + } + SDL_free(pads); + SDL_UnlockJoysticks(); + return usable && found && dualsense_count == 1; +} + SDL_AudioDeviceID FindDevice() { int count = 0; auto* devices = SDL_GetAudioPlaybackDevices(&count); SDL_AudioDeviceID device = 0; - for (int i = 0; i < count && device == 0; i++) { + for (int i = 0; i < count; i++) { const char* name = SDL_GetAudioDeviceName(devices[i]); SDL_AudioSpec spec {}; if (name != nullptr && SDL_strcasestr(name, "DualSense") != nullptr && SDL_GetAudioDeviceFormat(devices[i], &spec, nullptr) && spec.channels == 4) { + if (device != 0) { + device = 0; // Multiple matching endpoints cannot be associated reliably either. + break; + } device = devices[i]; } } @@ -87,13 +169,59 @@ SDL_AudioDeviceID FindDevice() { return device; } +void RefreshDevice(Stream* stream, int controller) { + if (stream->controller != controller) { + CloseDevice(stream); + stream->controller = controller; + stream->next_check = 0; + } + const auto now = SDL_GetTicks(); + if (now < stream->next_check) { + return; + } + stream->next_check = now + 2000; + const auto device = FindDevice(); + if (stream->device == device && stream->sdl != nullptr) { + return; + } + CloseDevice(stream); + stream->device = device; + if (device == 0) { + return; + } + const SDL_AudioSpec desired {SDL_AUDIO_F32, 4, static_cast(stream->freq)}; + auto* sdl = SDL_OpenAudioDeviceStream(device, &desired, UpdateRumble, nullptr); + SDL_AudioSpec actual {}; + bool ready = sdl != nullptr && + SDL_GetAudioDeviceFormat(SDL_GetAudioStreamDevice(sdl), &actual, nullptr) && + actual.channels == 4 && SDL_ResumeAudioStreamDevice(sdl); + if (ready) { + Common::LockGuard lock(g_mutex); + if (stream->speaker) { + ready = SelectSpeaker(controller); + if (ready) { + g_speaker_streams[controller]++; + } + } else { + g_haptics_streams++; + } + } + if (ready) { + stream->sdl = sdl; + LOGF("DualSenseHaptics: playing on '%s'\n", SDL_GetAudioDeviceName(device)); + } else { + SDL_DestroyAudioStream(sdl); + LOGF("DualSenseHaptics: cannot open quad output: %s\n", SDL_GetError()); + } +} + } // namespace -Stream* Open(uint32_t freq) { +Stream* Open(uint32_t freq, bool speaker) { if (freq == 0 || !SDL_InitSubSystem(SDL_INIT_AUDIO)) { return nullptr; } - return new Stream {.freq = freq}; + return new Stream {.freq = freq, .speaker = speaker}; } void Close(Stream* stream) { @@ -104,44 +232,30 @@ void Close(Stream* stream) { } } -void Queue(Stream* stream, int controller, const void* data, uint32_t frames, uint32_t channels, - bool is_float, const int* volume) { +uint64_t Queue(Stream* stream, int controller, const void* data, uint32_t frames, uint32_t channels, + bool is_float, const int* volume) { if (stream == nullptr || data == nullptr || frames == 0 || channels == 0 || volume == nullptr) { - return; + return 0; } - if (SDL_GetGamepadTypeForID(static_cast(controller)) != SDL_GAMEPAD_TYPE_PS5) { + if (!CanUseDevice(controller)) { CloseDevice(stream); - return; - } - const auto now = SDL_GetTicks(); - if (now >= stream->next_check) { - stream->next_check = now + 2000; - const auto device = FindDevice(); - if (stream->device != device || stream->sdl == nullptr) { - CloseDevice(stream); - stream->device = device; - if (device != 0) { - const SDL_AudioSpec desired {SDL_AUDIO_F32, 4, static_cast(stream->freq)}; - auto* sdl = SDL_OpenAudioDeviceStream(device, &desired, UpdateRumble, nullptr); - SDL_AudioSpec actual {}; - if (sdl != nullptr && - SDL_GetAudioDeviceFormat(SDL_GetAudioStreamDevice(sdl), &actual, nullptr) && - actual.channels == 4 && SDL_ResumeAudioStreamDevice(sdl)) { - stream->sdl = sdl; - { - Common::LockGuard lock(g_mutex); - g_playback_streams++; - } - LOGF("DualSenseHaptics: playing on '%s'\n", SDL_GetAudioDeviceName(device)); - } else { - SDL_DestroyAudioStream(sdl); - LOGF("DualSenseHaptics: cannot open quad output: %s\n", SDL_GetError()); - } - } - } + stream->next_check = 0; // Look for the device as soon as a DualSense is active again. + return 0; } + RefreshDevice(stream, controller); if (stream->sdl == nullptr) { - return; + return 0; + } + if (stream->speaker) { + bool routed; + { + Common::LockGuard lock(g_mutex); + routed = SelectSpeaker(controller); + } + if (!routed) { + CloseDevice(stream); + return 0; + } } stream->buffer.assign(static_cast(frames) * 4, 0.0f); bool audible = false; @@ -152,7 +266,8 @@ void Queue(Stream* stream, int controller, const void* data, uint32_t frames, ui float value = is_float ? static_cast(data)[index] : static_cast(data)[index] / 32768.0f; value *= volume[src_ch] / 32768.0f; - stream->buffer[static_cast(frame) * 4 + ch + 2] = value; + // The speaker plays the right front channel; the actuators play the back ones. + stream->buffer[static_cast(frame) * 4 + ch + (stream->speaker ? 0 : 2)] = value; audible |= std::fabs(value) > 1.0f / 1024; } } @@ -161,26 +276,29 @@ void Queue(Stream* stream, int controller, const void* data, uint32_t frames, ui SDL_ClearAudioStream(stream->sdl); queued = 0; } - const int bytes = static_cast(frames * FRAME_BYTES); - if (audible) { + const int bytes = static_cast(frames * FRAME_BYTES); + const auto queued_us = + static_cast(queued + bytes) * 1000000 / (stream->freq * FRAME_BYTES); + if (audible && !stream->speaker) { Common::LockGuard lock(g_mutex); if (SDL_GetGamepadTypeForID(static_cast(controller)) != SDL_GAMEPAD_TYPE_PS5) { - return; + return 0; } SelectController(controller); const auto playback_now = SDL_GetTicks(); const bool starting = g_haptics_until <= playback_now; - const auto duration = (queued + bytes) * 1000ULL / (stream->freq * FRAME_BYTES); g_haptics_until = - std::max(g_haptics_until, playback_now + std::max(duration, 250)); + std::max(g_haptics_until, playback_now + std::max(queued_us / 1000, 250)); if (starting) { ApplyRumble(); } } if (!SDL_PutAudioStreamData(stream->sdl, stream->buffer.data(), bytes)) { CloseDevice(stream); + return 0; } + return queued_us; } bool SetVibration(int controller, uint8_t large_motor, uint8_t small_motor) { @@ -198,8 +316,8 @@ bool SetVibration(int controller, uint8_t large_motor, uint8_t small_motor) { void Shutdown() { Common::LockGuard lock(g_mutex); - g_rumble_until = 0; SelectController(-1); + SelectSpeaker(-1); } } // namespace Libs::Controller::DualSenseHaptics diff --git a/src/libs/dualSenseHaptics.h b/src/libs/dualSenseHaptics.h index e3b546f2..e473e13d 100644 --- a/src/libs/dualSenseHaptics.h +++ b/src/libs/dualSenseHaptics.h @@ -7,12 +7,18 @@ namespace Libs::Controller::DualSenseHaptics { struct Stream; -Stream* Open(uint32_t freq); +// A speaker stream plays a pad speaker port and routes the speaker to it; the others play vibration +// ports on the actuators. +Stream* Open(uint32_t freq, bool speaker); void Close(Stream* stream); -void Queue(Stream* stream, int controller, const void* data, uint32_t frames, uint32_t channels, - bool is_float, const int* volume); +// Returns how long the queued audio still plays, in microseconds, or 0 when no DualSense took it +// (the active pad has no usable USB audio device or its speaker cannot be routed). +// Multiple DualSenses or matching audio endpoints are ambiguous and are not selected. +uint64_t Queue(Stream* stream, int controller, const void* data, uint32_t frames, uint32_t channels, + bool is_float, const int* volume); // Returns false for other gamepad types, which retain the normal rumble path. bool SetVibration(int controller, uint8_t large_motor, uint8_t small_motor); +// Also restores the headphone routing. void Shutdown(); } // namespace Libs::Controller::DualSenseHaptics diff --git a/tests/PadHapticsTests.cpp b/tests/PadHapticsTests.cpp index f4c690b3..33c7e5c2 100644 --- a/tests/PadHapticsTests.cpp +++ b/tests/PadHapticsTests.cpp @@ -24,15 +24,23 @@ struct Rumble { Uint16 large, small; Uint32 duration; }; -std::vector devices; -std::vector streams; -Rumble rumble {}; -Uint64 now = 1000; -int audio_refs = 0, opens = 0, rumble_calls = 0; -bool fail_open = false, fail_resume = false; -int actual_channels = 4; -SDL_AudioDeviceID opened_device = 0; -SDL_AudioSpec opened_spec {}; +std::vector devices; +std::vector streams; +Rumble rumble {}; +Uint64 now = 1000; +int audio_refs = 0, opens = 0, rumble_calls = 0; +bool fail_open = false, fail_resume = false; +int actual_channels = 4; +SDL_AudioDeviceID opened_device = 0; +SDL_AudioSpec opened_spec {}; +std::array pad_rumble {}; +std::array, 4> effects; +std::vector connected_pads {1}; +std::array cached_rumble {}; +bool fail_effect = false, wireless = false; +SDL_AudioStream* default_stream = nullptr; +bool fail_default_resume = false; +int active_controller = 1; } // namespace namespace Fake { @@ -95,10 +103,13 @@ SDL_AudioStream* OpenAudioDeviceStream(SDL_AudioDeviceID id, const SDL_AudioSpec Check(stream != nullptr && SDL_SetAudioStreamGetCallback(stream, callback, userdata), "real SDL stream/callback creation failed"); streams.push_back(stream); + if (id == SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK) { + default_stream = stream; + } return stream; } -bool ResumeAudioStreamDevice(SDL_AudioStream*) { - return !fail_resume; +bool ResumeAudioStreamDevice(SDL_AudioStream* stream) { + return !fail_resume && !(stream == default_stream && fail_default_resume); } void DestroyAudioStream(SDL_AudioStream* stream) { if (stream == nullptr) { @@ -107,38 +118,79 @@ void DestroyAudioStream(SDL_AudioStream* stream) { const auto it = std::find(streams.begin(), streams.end(), stream); Check(it != streams.end(), "stream destroyed twice"); streams.erase(it); + if (stream == default_stream) { + default_stream = nullptr; + } SDL_DestroyAudioStream(stream); } SDL_AudioDeviceID GetAudioStreamDevice(SDL_AudioStream*) { return 999; } +// Pads 1 and 3 are DualSenses. SDL_GamepadType GetGamepadTypeForID(SDL_JoystickID id) { - return id == 1 ? SDL_GAMEPAD_TYPE_PS5 : SDL_GAMEPAD_TYPE_UNKNOWN; + return id == 1 || id == 3 ? SDL_GAMEPAD_TYPE_PS5 : SDL_GAMEPAD_TYPE_UNKNOWN; } SDL_Gamepad* GetGamepadFromID(SDL_JoystickID id) { - return id == 1 ? reinterpret_cast(static_cast(id)) : nullptr; + return id == 1 || id == 3 ? reinterpret_cast(static_cast(id)) + : nullptr; } -bool RumbleGamepad(SDL_Gamepad*, Uint16 large, Uint16 small, Uint32 duration) { - rumble = {large, small, duration}; +SDL_JoystickID* GetGamepads(int* count) { + *count = static_cast(connected_pads.size()); + auto* ids = static_cast( + SDL_malloc((connected_pads.size() + 1) * sizeof(SDL_JoystickID))); + std::copy(connected_pads.begin(), connected_pads.end(), ids); + ids[connected_pads.size()] = 0; + return ids; +} +SDL_JoystickConnectionState GetGamepadConnectionState(SDL_Gamepad*) { + return wireless ? SDL_JOYSTICK_CONNECTION_WIRELESS : SDL_JOYSTICK_CONNECTION_WIRED; +} +bool RumbleGamepad(SDL_Gamepad* pad, Uint16 large, Uint16 small, Uint32 duration) { + const auto id = reinterpret_cast(pad); + // SDL only sends a new report when the strengths change. + if (large != cached_rumble[id].large || small != cached_rumble[id].small) { + pad_rumble[id] = {large, small, duration}; + } + cached_rumble[id] = {large, small, duration}; + rumble = pad_rumble[id]; + rumble.duration = duration; rumble_calls++; return true; } +bool SendGamepadEffect(SDL_Gamepad* pad, const void* data, int size) { + if (fail_effect) { + return false; + } + effects[reinterpret_cast(pad)].assign(static_cast(data), + static_cast(data) + size); + // Raw audio-routing reports clear the emulated-rumble mode on the controller, + // without updating SDL's cached motor strengths. + if ((static_cast(data)[0] & 3) == 0) { + pad_rumble[reinterpret_cast(pad)] = {}; + rumble = {}; + } + return true; +} } // namespace Fake -#define SDL_GetTicks Fake::GetTicks -#define SDL_InitSubSystem Fake::InitSubSystem -#define SDL_QuitSubSystem Fake::QuitSubSystem -#define SDL_GetAudioPlaybackDevices Fake::GetAudioPlaybackDevices -#define SDL_GetAudioDeviceName Fake::GetAudioDeviceName -#define SDL_GetAudioDeviceFormat Fake::GetAudioDeviceFormat -#define SDL_OpenAudioDeviceStream Fake::OpenAudioDeviceStream -#define SDL_ResumeAudioStreamDevice Fake::ResumeAudioStreamDevice -#define SDL_DestroyAudioStream Fake::DestroyAudioStream -#define SDL_GetAudioStreamDevice Fake::GetAudioStreamDevice -#define SDL_GetGamepadTypeForID Fake::GetGamepadTypeForID -#define SDL_GetGamepadFromID Fake::GetGamepadFromID -#define SDL_RumbleGamepad Fake::RumbleGamepad +#define SDL_GetTicks Fake::GetTicks +#define SDL_InitSubSystem Fake::InitSubSystem +#define SDL_QuitSubSystem Fake::QuitSubSystem +#define SDL_GetAudioPlaybackDevices Fake::GetAudioPlaybackDevices +#define SDL_GetAudioDeviceName Fake::GetAudioDeviceName +#define SDL_GetAudioDeviceFormat Fake::GetAudioDeviceFormat +#define SDL_OpenAudioDeviceStream Fake::OpenAudioDeviceStream +#define SDL_ResumeAudioStreamDevice Fake::ResumeAudioStreamDevice +#define SDL_DestroyAudioStream Fake::DestroyAudioStream +#define SDL_GetAudioStreamDevice Fake::GetAudioStreamDevice +#define SDL_GetGamepadTypeForID Fake::GetGamepadTypeForID +#define SDL_GetGamepadFromID Fake::GetGamepadFromID +#define SDL_RumbleGamepad Fake::RumbleGamepad +#define SDL_SendGamepadEffect Fake::SendGamepadEffect +#define SDL_GetGamepads Fake::GetGamepads +#define SDL_GetGamepadConnectionState Fake::GetGamepadConnectionState #include "libs/dualSenseHaptics.cpp" +#include "libs/audio.cpp" #undef SDL_GetTicks #undef SDL_InitSubSystem #undef SDL_QuitSubSystem @@ -152,6 +204,27 @@ bool RumbleGamepad(SDL_Gamepad*, Uint16 large, Uint16 small, Uint32 duration) { #undef SDL_GetGamepadTypeForID #undef SDL_GetGamepadFromID #undef SDL_RumbleGamepad +#undef SDL_SendGamepadEffect +#undef SDL_GetGamepads +#undef SDL_GetGamepadConnectionState + +namespace Libs::Controller { +int GetActiveControllerId() { + return active_controller; +} +} // namespace Libs::Controller + +namespace Libs::LibKernel { +uint64_t KYTY_SYSV_ABI KernelGetProcessTime() { + return now * 1000; +} +} // namespace Libs::LibKernel + +namespace Loader::Timer { +double GetTimeMs() { + return static_cast(now); +} +} // namespace Loader::Timer namespace { namespace Haptics = Libs::Controller::DualSenseHaptics; @@ -161,9 +234,17 @@ constexpr std::array pcm {0.5f, 0.5f, 0.5f, 0.5f}; struct Fixture { Fixture() { - devices = {{10, "Speakers (DualSense Wireless Controller)", 4}}; - rumble = {}; - now = 1000; + devices = {{10, "Speakers (DualSense Wireless Controller)", 4}}; + rumble = {}; + pad_rumble = {}; + cached_rumble = {}; + effects = {}; + connected_pads = {1}; + fail_effect = wireless = false; + default_stream = nullptr; + fail_default_resume = false; + active_controller = 1; + now = 1000; opens = rumble_calls = 0; fail_open = fail_resume = false; actual_channels = 4; @@ -174,8 +255,8 @@ struct Fixture { } }; -Port Open() { - auto* stream = Haptics::Open(48000); +Port Open(bool speaker = false) { + auto* stream = Haptics::Open(48000, speaker); Check(stream != nullptr, "port open failed"); return Port(stream, Haptics::Close); } @@ -237,6 +318,71 @@ void TestDiscoveryAndHotplug() { Check(streams.empty(), "disconnected endpoint retained its stream"); } +void TestSpeaker() { + Fixture f; + devices = {{10, "DualSense Wireless Controller", 2}}; + auto speaker = Open(true); + Check(Haptics::Queue(speaker.get(), 1, pcm.data(), 2, 2, true, unity.data()) == 0 && + streams.empty(), + "speaker played without a quad DualSense device"); + devices = {{10, "Speakers (DualSense Wireless Controller)", 4}}; + now += 2001; + Check(Haptics::SetVibration(1, 100, 50), "set rumble failed"); + const std::array stereo {0.5f, -0.25f, 1.0f, 0.0f}; + Queue(speaker, stereo.data()); + ExpectPcm({0.5f, -0.25f, 0, 0, 1.0f, 0, 0, 0}); + Check(rumble.large == 100 * 0x101, "speaker audio stopped the rumble"); + const auto routed = effects[1]; + Check(routed.size() == 38 && routed[0] == 0xa0 && routed[1] == 0x80 && routed[5] == 0x64 && + routed[7] == 0x30 && routed[37] == 0x02, + "speaker not routed like the Linux driver"); + Check(Haptics::Queue(speaker.get(), 1, stereo.data(), 2, 2, true, unity.data()) == + 2 * 1000000 / 48000, + "queued speaker playback time is wrong"); + auto other = Open(true); + connected_pads = {3}; + Haptics::Queue(other.get(), 3, pcm.data(), 2, 2, true, unity.data()); + Check(effects[1][0] == 0x80 && effects[1][1] == 0 && effects[1][7] == 0 && effects[3] == routed, + "switching pads left the old speaker routed"); + speaker.reset(); + Check(effects[3] == routed, "closing one speaker port unrouted the other"); + other.reset(); + Check(effects[3][0] == 0x80 && effects[3][7] == 0, + "closing the last speaker port left it routed"); + auto last = Open(true); + Haptics::Queue(last.get(), 3, pcm.data(), 2, 2, true, unity.data()); + Check(effects[3] == routed, "reopened speaker port was not routed"); + Haptics::Shutdown(); + Check(effects[3][0] == 0x80 && effects[3][7] == 0, "shutdown left the speaker routed"); +} + +void TestSpeakerUnplug() { + Fixture f; + auto speaker = Open(true); + Check(Haptics::Queue(speaker.get(), 1, pcm.data(), 2, 2, true, unity.data()) != 0, + "speaker did not play on the DualSense"); + const auto routed = effects[1]; + // Unplugged: another controller becomes active, and the port must fall back + // to the main output. + Check(Haptics::Queue(speaker.get(), 2, pcm.data(), 2, 2, true, unity.data()) == 0 && + streams.empty(), + "unplugged pad kept the speaker port"); + Check(effects[1][0] == 0x80 && effects[1][7] == 0, "unplugging left the speaker routed"); + // Plugged back in: the port returns to the controller at once. + effects = {}; + Check(Haptics::Queue(speaker.get(), 1, pcm.data(), 2, 2, true, unity.data()) != 0 && + streams.size() == 1 && effects[1] == routed, + "replugged pad did not take the speaker back"); + // The audio endpoint vanishes while a DualSense stays active. + devices.clear(); + now += 2001; + Check(Haptics::Queue(speaker.get(), 1, pcm.data(), 2, 2, true, unity.data()) == 0 && + streams.empty(), + "lost audio endpoint kept the speaker port"); + Check(effects[1][0] == 0x80 && effects[1][7] == 0, + "lost audio endpoint left the speaker routed"); +} + void TestFailuresAndBoundedQueue() { Fixture f; auto port = Open(); @@ -295,9 +441,23 @@ void TestRumbleLeaseAndDuration() { Check(rumble_calls == calls, "audio pull touched rumble after shutdown"); } +void TestSwitchStopsOldRumble() { + Fixture f; + auto port = Open(); + Haptics::SetVibration(1, 100, 50); + Queue(port, pcm.data()); + Check(pad_rumble[1].large == 0, "haptics did not suppress rumble"); + Haptics::SetVibration(3, 10, 10); + Check(pad_rumble[1].large == 0 && pad_rumble[1].small == 0, + "switching controllers restarted the old pad's rumble"); + Check(pad_rumble[3].large == 10 * 0x101, "new pad did not rumble"); +} + void TestCloseRestoresRumble() { Fixture f; - auto first = Open(), second = Open(); + auto speaker = Open(true); + Queue(speaker, pcm.data()); + auto first = Open(), second = Open(); Haptics::SetVibration(1, 100, 50); Queue(first, pcm.data()); Queue(second, pcm.data()); @@ -308,14 +468,112 @@ void TestCloseRestoresRumble() { Check(rumble.large == 100 * 0x101 && rumble.small == 50 * 0x101, "closing the final stream left rumble suppressed"); } + +void TestSpeakerSwitchAndFailures() { + Fixture f; + auto speaker = Open(true); + auto queue = [&](int controller) { + return Haptics::Queue(speaker.get(), controller, pcm.data(), 2, 2, true, unity.data()); + }; + fail_effect = true; + Check(queue(1) == 0 && streams.empty(), "failed speaker routing suppressed fallback"); + fail_effect = false; + now += 2001; + Check(queue(1) != 0, "speaker did not recover after routing failed"); + connected_pads = {3}; + devices = {{20, "Speakers (DualSense Wireless Controller)", 4}}; + Check(queue(3) != 0 && opened_device == 20 && effects[1][7] == 0 && effects[3][7] == 0x30, + "persistent speaker port did not follow the new controller immediately"); + // An idle stream on the old pad must not keep the new pad routed after its + // last port closes. + connected_pads = {1}; + auto other = Open(true); + Check(Haptics::Queue(other.get(), 1, pcm.data(), 2, 2, true, unity.data()) != 0, + "second speaker port failed"); + other.reset(); + Check(effects[1][7] == 0, "old controller stream kept the new speaker routed"); +} + +void TestSpeakerRequiresUnambiguousUsbDevice() { + Fixture f; + auto speaker = Open(true); + auto queue = [&] { + return Haptics::Queue(speaker.get(), 1, pcm.data(), 2, 2, true, unity.data()); + }; + wireless = true; + Check(queue() == 0 && streams.empty(), "Bluetooth controller stole another pad's USB audio"); + wireless = false; + connected_pads = {1, 3}; + Check(queue() == 0 && streams.empty(), "multiple DualSenses chose an arbitrary controller"); + connected_pads = {1}; + devices.push_back({20, "DualSense Wireless Controller", 4}); + Check(queue() == 0 && streams.empty(), "multiple audio endpoints chose an arbitrary device"); + devices.pop_back(); + now += 2001; + Check(queue() != 0, "unambiguous USB device was not recovered"); + wireless = true; + Check(queue() == 0 && streams.empty() && effects[1][7] == 0, + "USB to Bluetooth change retained the USB speaker"); +} + +void TestAudioSpeakerFallback() { + Fixture f; + Libs::Audio::Audio audio; + active_controller = 2; // A different controller is active while a USB DualSense is attached. + const auto port = audio.AudioOutOpen(4, 2, 48000, Libs::Audio::Audio::Format::FloatStereo); + Check(port.IsValid() && default_stream != nullptr, "pad speaker fallback did not open"); + Libs::Audio::Audio::OutputParam output {port, pcm.data()}; + Check(audio.AudioOutOutputs(&output, 1, false) == 2, "pad speaker output count is wrong"); + Check(streams.size() == 1 && opens == 1 && effects[1].empty(), + "another controller's pad speaker opened or routed the DualSense"); + std::array actual {}; + Check(SDL_GetAudioStreamData(default_stream, actual.data(), sizeof(actual)) == sizeof(actual) && + actual == pcm, + "pad speaker did not fall back to the main output"); +} + +void TestAudioSpeakerRouting() { + Fixture f; + Libs::Audio::Audio audio; + const auto port = audio.AudioOutOpen(4, 2, 48000, Libs::Audio::Audio::Format::FloatStereo); + Check(port.IsValid() && default_stream != nullptr, "pad speaker port did not open"); + Libs::Audio::Audio::OutputParam output {port, pcm.data()}; + audio.AudioOutOutputs(&output, 1, false); + Check(streams.size() == 2 && opened_device == 10 && + SDL_GetAudioStreamQueued(default_stream) == 0, + "successful DualSense playback also queued audio on the main output"); + ExpectPcm({0.5f, 0.5f, 0, 0, 0.5f, 0.5f, 0, 0}); +} + +void TestAudioDefaultResumeFailure() { + Fixture f; + Libs::Audio::Audio audio; + fail_default_resume = true; + const auto port = audio.AudioOutOpen(4, 2, 48000, Libs::Audio::Audio::Format::FloatStereo); + Check(port.IsValid() && opens == 1 && default_stream == nullptr, + "default-output resume failure was not exercised"); + Libs::Audio::Audio::OutputParam output {port, pcm.data()}; + audio.AudioOutOutputs(&output, 1, false); + Check(streams.size() == 1 && opened_device == 10, + "default-output resume failure disabled the DualSense speaker"); + ExpectPcm({0.5f, 0.5f, 0, 0, 0.5f, 0.5f, 0, 0}); +} } // namespace int main() { SDL_SetHint(SDL_HINT_AUDIO_DRIVER, "dummy"); + TestAudioSpeakerFallback(); + TestAudioSpeakerRouting(); + TestAudioDefaultResumeFailure(); TestFormatsAndVolume(); TestDiscoveryAndHotplug(); + TestSpeaker(); + TestSpeakerUnplug(); + TestSpeakerSwitchAndFailures(); + TestSpeakerRequiresUnambiguousUsbDevice(); TestFailuresAndBoundedQueue(); TestRumbleLeaseAndDuration(); + TestSwitchStopsOldRumble(); TestCloseRestoresRumble(); std::printf("PadHapticsTests: all cases passed\n"); }