controller: cycle DualSense speaker volume, vibration and trigger intensity in game (#946)

* controller: cycle DualSense speaker volume, vibration and trigger intensity in game

Three new input mapping controls cycle the PS5's controller settings in
game (default keys 1, 2 and 3): speaker volume (100% -> off -> 2% -> 12%
-> 42%), vibration intensity and trigger effect intensity (strong -> off
-> weak -> medium). Each starts at full and is not saved.

Changing vibration or trigger intensity re-sends the game's last rumble
and trigger effect, so the change is felt immediately.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Clean up controller settings test captures and formatting

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: nmzik <Nmzik@mail.ru>
This commit is contained in:
Devran Cosmo Uenal 2026-10-02 13:11:34 +02:00 committed by GitHub
parent e1be2ead54
commit 82a2f4ad42
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 588 additions and 46 deletions

View File

@ -113,7 +113,7 @@ jobs:
shell: cmd
run: |
cmake --build _Build/windows --target launcher ^
audio_out2_port_tests pad_haptics_tests ime_dialog_tests lru_cache_tests virtual_memory_allocation_tests ^
audio_out2_port_tests pad_haptics_tests controller_settings_tests ime_dialog_tests lru_cache_tests virtual_memory_allocation_tests ^
shader_recompiler_compute_tests ^
resource_tracking_tests scalar_provenance_tests ^
resource_materialization_tests shader_vertex_metadata_tests archive_file_tests avplayer_file_tests --parallel
@ -121,7 +121,7 @@ jobs:
- name: Test
shell: cmd
run: |
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|archive_file|avplayer_file|virtual_memory_allocation)$"
ctest --test-dir _Build/windows --output-on-failure -R "^(audio_out2_port|pad_haptics|controller_settings|ime_dialog|lru_cache|resource_tracking|scalar_provenance|resource_materialization|shader_vertex_metadata|archive_file|avplayer_file|virtual_memory_allocation)$"
- name: Install
shell: cmd
@ -202,7 +202,7 @@ jobs:
shell: bash
run: |
cmake --build _Build/macos \
--target launcher audio_out2_port_tests pad_haptics_tests ime_dialog_tests lru_cache_tests virtual_memory_allocation_tests \
--target launcher audio_out2_port_tests pad_haptics_tests controller_settings_tests ime_dialog_tests lru_cache_tests virtual_memory_allocation_tests \
memory_tracker_tests page_manager_tests shader_recompiler_compute_tests \
resource_tracking_tests scalar_provenance_tests \
resource_materialization_tests shader_vertex_metadata_tests archive_file_tests avplayer_file_tests \
@ -212,7 +212,7 @@ jobs:
shell: bash
run: |
ctest --test-dir _Build/macos --output-on-failure \
-R '^(audio_out2_port|pad_haptics|ime_dialog|lru_cache|memory_tracker|page_manager|resource_tracking|scalar_provenance|resource_materialization|shader_vertex_metadata|archive_file|avplayer_file|virtual_memory_allocation)$'
-R '^(audio_out2_port|pad_haptics|controller_settings|ime_dialog|lru_cache|memory_tracker|page_manager|resource_tracking|scalar_provenance|resource_materialization|shader_vertex_metadata|archive_file|avplayer_file|virtual_memory_allocation)$'
- name: Install
shell: bash
@ -386,7 +386,7 @@ jobs:
run: |
cmake --build _Build/linux \
--target launcher page_manager_tests memory_tracker_tests \
audio_out2_port_tests pad_haptics_tests ime_dialog_tests lru_cache_tests virtual_memory_allocation_tests \
audio_out2_port_tests pad_haptics_tests controller_settings_tests ime_dialog_tests lru_cache_tests virtual_memory_allocation_tests \
resource_tracking_tests scalar_provenance_tests \
resource_materialization_tests shader_vertex_metadata_tests archive_file_tests avplayer_file_tests \
--parallel
@ -395,7 +395,7 @@ jobs:
shell: bash
run: |
ctest --test-dir _Build/linux --output-on-failure \
-R '^(audio_out2_port|pad_haptics|ime_dialog|lru_cache|memory_tracker|page_manager|resource_tracking|scalar_provenance|resource_materialization|shader_vertex_metadata|archive_file|avplayer_file|virtual_memory_allocation)$'
-R '^(audio_out2_port|pad_haptics|controller_settings|ime_dialog|lru_cache|memory_tracker|page_manager|resource_tracking|scalar_provenance|resource_materialization|shader_vertex_metadata|archive_file|avplayer_file|virtual_memory_allocation)$'
- name: Install
shell: bash

View File

@ -498,6 +498,12 @@ add_executable(pad_haptics_tests EXCLUDE_FROM_ALL
target_link_libraries(pad_haptics_tests common fmt::fmt SDL3::SDL3-static opus)
target_include_directories(pad_haptics_tests PRIVATE ${inc_headers})
add_executable(controller_settings_tests EXCLUDE_FROM_ALL
"${KYTY_TESTS_DIR}/ControllerSettingsTests.cpp"
)
target_link_libraries(controller_settings_tests common fmt::fmt SDL3::SDL3-static)
target_include_directories(controller_settings_tests PRIVATE ${inc_headers})
add_executable(save_data_memory_tests EXCLUDE_FROM_ALL
"${KYTY_TESTS_DIR}/SaveDataMemoryTests.cpp"
)
@ -613,6 +619,7 @@ if(BUILD_TESTING)
add_test(NAME audio_out_timing COMMAND $<TARGET_FILE:audio_out_timing_tests>)
add_test(NAME ngs2_sampler COMMAND $<TARGET_FILE:ngs2_sampler_tests>)
add_test(NAME pad_haptics COMMAND $<TARGET_FILE:pad_haptics_tests>)
add_test(NAME controller_settings COMMAND $<TARGET_FILE:controller_settings_tests>)
add_test(NAME save_data_memory COMMAND $<TARGET_FILE:save_data_memory_tests>)
add_test(NAME png_enc COMMAND $<TARGET_FILE:png_enc_tests>)
add_test(NAME ces COMMAND $<TARGET_FILE:ces_tests>)
@ -690,6 +697,7 @@ if(BUILD_TESTING)
audio_out_timing_tests
ngs2_sampler_tests
pad_haptics_tests
controller_settings_tests
save_data_memory_tests
png_enc_tests
ces_tests

View File

@ -25,6 +25,8 @@ struct ControlInfo {
Controller::Axis axis = Controller::Axis::AxisMax;
bool positive = false;
float touch_x = 0.0f;
Controller::Setting setting {};
};
static constexpr std::array CONTROL_INFO = {
@ -55,6 +57,10 @@ static constexpr std::array CONTROL_INFO = {
ControlInfo {"RightStickRight", 0, Controller::Axis::RightX, true},
ControlInfo {"RightStickUp", 0, Controller::Axis::RightY, false},
ControlInfo {"RightStickDown", 0, Controller::Axis::RightY, true},
ControlInfo {.name = "SpeakerVolume", .setting = Controller::Setting::SpeakerVolume},
ControlInfo {.name = "VibrationIntensity", .setting = Controller::Setting::VibrationIntensity},
ControlInfo {.name = "TriggerEffectIntensity",
.setting = Controller::Setting::TriggerEffectIntensity},
};
constexpr std::size_t INVALID_CONTROL = CONTROL_INFO.size();
@ -242,6 +248,12 @@ void SetStickAxis(Controller::Axis axis, bool negative, bool positive) {
Controller::SetAxis(Controller::HOST_INPUT_CONTROLLER_ID, axis, value);
}
void CycleSettingOnPress(Controller::Setting setting, bool down) {
if (down) {
Controller::CycleSetting(setting);
}
}
void SetControl(std::size_t control, bool down) {
if (control == INVALID_CONTROL) {
return;
@ -256,6 +268,10 @@ void SetControl(std::size_t control, bool down) {
SetButton(info.button, down);
return;
}
if (info.axis == Controller::Axis::AxisMax) {
CycleSettingOnPress(info.setting, down);
return;
}
struct AxisKeys {
bool negative = false;
@ -280,6 +296,9 @@ void DefaultKeyboardInput(int key_code, bool down) {
switch (NormalizeKey(static_cast<SDL_Keycode>(key_code))) {
case SDLK_BACKSPACE: SetTouchPad(0.25f, down); return;
case SDLK_TAB: SetTouchPad(0.75f, down); return;
case SDLK_1: CycleSettingOnPress(Controller::Setting::SpeakerVolume, down); return;
case SDLK_2: CycleSettingOnPress(Controller::Setting::VibrationIntensity, down); return;
case SDLK_3: CycleSettingOnPress(Controller::Setting::TriggerEffectIntensity, down); return;
case SDLK_A:
left.left = down;
SetStickAxis(Controller::Axis::LeftX, left.left, left.right);

View File

@ -53,6 +53,9 @@ constexpr PadControl PAD_CONTROLS[] = {
{"Options", "Options", "Return"},
{"TouchPad", "Touch pad left (SELECT)", "Backspace"},
{"TouchPadRight", "Touch pad right (START)", "Tab"},
{"SpeakerVolume", "Speaker volume (cycle)", "1"},
{"VibrationIntensity", "Vibration intensity (cycle)", "2"},
{"TriggerEffectIntensity", "Trigger effect intensity (cycle)", "3"},
};
QString KeypadName(int key) {

View File

@ -159,8 +159,8 @@ private:
static void OpenSdlDevice(PortIn* port, Format format);
static void CloseSdlDevice(PortIn* port);
static const void* PrepareOutputBuffer(const PortOut& port, const void* data,
std::vector<uint8_t>* buffer);
static bool QueueSdlAudio(PortOut* port, const void* data, bool blocking);
std::vector<uint8_t>* buffer, float gain);
static bool QueueSdlAudio(PortOut* port, const void* data, bool blocking, float gain);
};
static Audio* g_audio = nullptr;
@ -302,7 +302,7 @@ void Audio::CloseSdlDevice(PortOut* port) {
}
const void* Audio::PrepareOutputBuffer(const PortOut& port, const void* data,
std::vector<uint8_t>* buffer) {
std::vector<uint8_t>* buffer, float gain) {
EXIT_IF(data == nullptr);
EXIT_IF(buffer == nullptr);
@ -312,7 +312,7 @@ const void* Audio::PrepareOutputBuffer(const PortOut& port, const void* data,
const auto bytes_per_sample = BytesPerSample(port.format);
const bool reorder = channels >= 8 && !FormatIsStd(port.format);
bool volume_changed = false;
bool volume_changed = gain != 1.0f;
for (uint32_t ch = 0; ch < channels; ch++) {
if (port.volume[ch] != 32768) {
volume_changed = true;
@ -338,7 +338,7 @@ const void* Audio::PrepareOutputBuffer(const PortOut& port, const void* data,
const auto src_ch = reorder ? SDL_8CH_MAP[ch] : ch;
dst[frame * output_channels + ch] =
src[frame * channels + src_ch] *
(static_cast<float>(port.volume[src_ch]) / 32768.0f);
(static_cast<float>(port.volume[src_ch]) / 32768.0f) * gain;
}
if (channels == 12) {
// Add the four top channels to their front/back channels, turning 12 into 8.
@ -347,7 +347,7 @@ const void* Audio::PrepareOutputBuffer(const PortOut& port, const void* data,
for (uint32_t ch = 0; ch < 4; ch++) {
dst[frame * output_channels + HEIGHT_DST[ch]] +=
src[frame * channels + 8 + ch] *
(static_cast<float>(port.volume[8 + ch]) / 32768.0f);
(static_cast<float>(port.volume[8 + ch]) / 32768.0f) * gain;
}
}
}
@ -360,6 +360,7 @@ const void* Audio::PrepareOutputBuffer(const PortOut& port, const void* data,
const auto src_ch = reorder ? SDL_8CH_MAP[ch] : ch;
int64_t sample =
static_cast<int64_t>(src[frame * channels + src_ch]) * port.volume[src_ch] / 32768;
sample = static_cast<int64_t>(sample * static_cast<double>(gain));
if (sample > std::numeric_limits<int16_t>::max()) {
sample = std::numeric_limits<int16_t>::max();
} else if (sample < std::numeric_limits<int16_t>::min()) {
@ -373,7 +374,7 @@ const void* Audio::PrepareOutputBuffer(const PortOut& port, const void* data,
return buffer->data();
}
bool Audio::QueueSdlAudio(PortOut* port, const void* data, bool blocking) {
bool Audio::QueueSdlAudio(PortOut* port, const void* data, bool blocking, float gain) {
EXIT_IF(port == nullptr);
if (port->stream == nullptr || data == nullptr) {
@ -381,7 +382,7 @@ bool Audio::QueueSdlAudio(PortOut* port, const void* data, bool blocking) {
}
std::vector<uint8_t> prepared_buffer;
const void* prepared_data = PrepareOutputBuffer(*port, data, &prepared_buffer);
const void* prepared_data = PrepareOutputBuffer(*port, data, &prepared_buffer, gain);
const auto output_channels = OutputChannels(*port);
const auto prepared_size =
BytesPerSample(port->format) * output_channels * port->samples_num;
@ -554,6 +555,12 @@ 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()];
const float gain =
port.type == AUDIO_OUT_PORT_TYPE_PADSPK
? Controller::GetSettingScale(Controller::Setting::SpeakerVolume)
: port.type == AUDIO_OUT_PORT_TYPE_VIBRATION
? Controller::GetSettingScale(Controller::Setting::VibrationIntensity)
: 1.0f;
uint64_t controller_queued_us = 0;
bool controller_uses_bluetooth = false;
@ -562,14 +569,15 @@ uint32_t Audio::AudioOutOutputs(OutputParam* params, uint32_t num, bool blocking
Common::LockGuard lock(m_mutex);
controller_queued_us = Controller::DualSenseHaptics::Queue(
port.haptics, Controller::GetActiveControllerId(), params[i].data, port.samples_num,
static_cast<uint32_t>(port.channels_num), FormatIsFloat(port.format), port.volume);
static_cast<uint32_t>(port.channels_num), FormatIsFloat(port.format), port.volume,
gain);
controller_uses_bluetooth =
Controller::DualSenseHaptics::UsesBluetooth(port.haptics);
}
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.
if (QueueSdlAudio(&port, params[i].data, blocking)) {
if (QueueSdlAudio(&port, params[i].data, blocking, gain)) {
any_device = true;
paced[i] = port.queue_primed;
}

View File

@ -15,6 +15,7 @@
#include <algorithm>
#include <array>
#include <atomic>
#include <cmath>
#include <cstring>
#include <vector>
@ -117,6 +118,8 @@ public:
int GetActiveControllerId();
void SetLightBar(uint8_t r, uint8_t g, uint8_t b);
bool SetTriggerEffect(const PadTriggerEffectParam& param);
void CycleSetting(Setting setting);
float GetSettingScale(Setting setting) const;
void ReadState(ControllerState* state, bool* flag, int* count);
int ReadStates(ControllerState* states, int states_num, bool* flag, int* count);
@ -125,6 +128,9 @@ private:
void CheckActive();
void AddState();
// Apply cached output without changing its game-requested lifetime; caller holds m_mutex.
void ApplyVibration();
bool SendTriggerEffect(const PadTriggerEffectParam& param);
Common::Mutex m_mutex;
std::vector<int> m_connected_ids;
@ -142,6 +148,12 @@ private:
uint32_t m_states_num = 0;
uint32_t m_first_state = 0;
uint8_t m_next_touch_id = 1;
// The game's last requests, re-sent when their intensity setting changes.
std::array<uint8_t, 2> m_vibration {};
uint64_t m_vibration_until = 0;
PadTriggerEffectParam m_trigger_effect {};
// Setting changes share the output lock; audio only needs an atomic scale snapshot.
std::array<std::atomic<uint32_t>, 3> m_setting_steps {};
};
static GameController* g_controller = nullptr;
@ -183,17 +195,56 @@ static void pad_fill_data(PadData* data, const ControllerState& state, bool conn
data->device_unique_data_len = 0;
}
static bool trigger_effect_zones(uint8_t* effect, const uint8_t* strengths, uint8_t type,
uint8_t frequency = 0) {
// In cycle order, starting at the PS5 default.
constexpr std::array SPEAKER_VOLUME = {1.0f, 0.0f, 0.02f, 0.12f, 0.42f};
constexpr std::array INTENSITY = {1.0f, 0.0f, 0.33f, 0.66f};
void CycleSetting(Setting setting) {
if (g_controller != nullptr) {
g_controller->CycleSetting(setting);
}
}
float GetSettingScale(Setting setting) {
return g_controller != nullptr ? g_controller->GetSettingScale(setting) : 1.0f;
}
void GameController::CycleSetting(Setting setting) {
Common::LockGuard lock(m_mutex);
auto& step = m_setting_steps[static_cast<size_t>(setting)];
const auto count = setting == Setting::SpeakerVolume ? SPEAKER_VOLUME.size() : INTENSITY.size();
step.store((step.load(std::memory_order_relaxed) + 1) % count, std::memory_order_relaxed);
if (setting == Setting::VibrationIntensity) {
ApplyVibration();
} else if (setting == Setting::TriggerEffectIntensity) {
(void)SendTriggerEffect(m_trigger_effect);
}
}
float GameController::GetSettingScale(Setting setting) const {
const auto step = m_setting_steps[static_cast<size_t>(setting)].load(std::memory_order_relaxed);
return setting == Setting::SpeakerVolume ? SPEAKER_VOLUME[step] : INTENSITY[step];
}
static uint8_t Scale(uint8_t value, float scale) {
if (value == 0 || scale == 0.0f) {
return 0;
}
return static_cast<uint8_t>(std::max(1L, std::lround(value * scale)));
}
static bool trigger_effect_zones(uint8_t* effect, const uint8_t* strengths, float scale,
uint8_t type, uint8_t frequency = 0) {
uint16_t active = 0;
uint32_t packed = 0;
for (int i = 0; i < 10; i++) {
if (strengths[i] > 8) {
return false;
}
if (strengths[i] != 0) {
const auto strength = Scale(strengths[i], scale);
if (strength != 0) {
active |= static_cast<uint16_t>(1u << i);
packed |= static_cast<uint32_t>(strengths[i] - 1u) << (3 * i);
packed |= static_cast<uint32_t>(strength - 1u) << (3 * i);
}
}
@ -208,7 +259,8 @@ static bool trigger_effect_zones(uint8_t* effect, const uint8_t* strengths, uint
return true;
}
static bool trigger_effect_to_dualsense(const PadTriggerEffectCommand& command, uint8_t* effect) {
static bool trigger_effect_to_dualsense(const PadTriggerEffectCommand& command, uint8_t* effect,
float scale) {
std::memset(effect, 0, 11);
effect[0] = 0x05;
@ -220,7 +272,7 @@ static bool trigger_effect_to_dualsense(const PadTriggerEffectCommand& command,
return false;
}
std::fill(strengths + command.data[0], strengths + 10, command.data[1]);
return trigger_effect_zones(effect, strengths, 0x21);
return trigger_effect_zones(effect, strengths, scale, 0x21);
case 2: {
const auto start = command.data[0];
const auto end = command.data[1];
@ -228,14 +280,15 @@ static bool trigger_effect_to_dualsense(const PadTriggerEffectCommand& command,
if (start < 2 || start > 7 || end <= start || end > 8 || strength > 8) {
return false;
}
if (strength == 0) {
const auto scaled = Scale(strength, scale);
if (scaled == 0) {
return true;
}
const uint16_t zones = static_cast<uint16_t>((1u << start) | (1u << end));
effect[0] = 0x25;
effect[1] = static_cast<uint8_t>(zones);
effect[2] = static_cast<uint8_t>(zones >> 8u);
effect[3] = strength - 1u;
effect[3] = scaled - 1u;
return true;
}
case 3:
@ -243,8 +296,8 @@ static bool trigger_effect_to_dualsense(const PadTriggerEffectCommand& command,
return false;
}
std::fill(strengths + command.data[0], strengths + 10, command.data[1]);
return trigger_effect_zones(effect, strengths, 0x26, command.data[2]);
case 4: return trigger_effect_zones(effect, command.data, 0x21);
return trigger_effect_zones(effect, strengths, scale, 0x26, command.data[2]);
case 4: return trigger_effect_zones(effect, command.data, scale, 0x21);
case 5: {
const int start = command.data[0];
const int end = command.data[1];
@ -262,9 +315,9 @@ static bool trigger_effect_to_dualsense(const PadTriggerEffectCommand& command,
: start_strength +
(delta + (delta < 0 ? -length / 2 : length / 2)) / length);
}
return trigger_effect_zones(effect, strengths, 0x21);
return trigger_effect_zones(effect, strengths, scale, 0x21);
}
case 6: return trigger_effect_zones(effect, command.data + 1, 0x26, command.data[0]);
case 6: return trigger_effect_zones(effect, command.data + 1, scale, 0x26, command.data[0]);
default: return false;
}
}
@ -355,6 +408,9 @@ void GameController::CheckActive() {
m_states_num = 0;
m_first_state = 0;
m_next_touch_id = 1;
m_vibration = {};
m_vibration_until = 0;
m_trigger_effect = {};
}
void GameController::AddState() {
@ -600,11 +656,24 @@ void GameController::ReleaseHostPads() {
}
m_connected_ids.clear();
CheckActive();
}
void GameController::SetVibration(uint8_t large_motor, uint8_t small_motor) {
Common::LockGuard lock(m_mutex);
if (DualSenseHaptics::SetVibration(m_active_id, large_motor, small_motor)) {
m_vibration = {large_motor, small_motor};
m_vibration_until = SDL_GetTicks() + RUMBLE_DURATION_MS;
ApplyVibration();
}
void GameController::ApplyVibration() {
const auto now = SDL_GetTicks();
const auto duration =
m_vibration_until > now ? static_cast<uint32_t>(m_vibration_until - now) : 0;
const auto scale = duration != 0 ? GetSettingScale(Setting::VibrationIntensity) : 0.0f;
const auto large_motor = Scale(m_vibration[0], scale);
const auto small_motor = Scale(m_vibration[1], scale);
if (DualSenseHaptics::SetVibration(m_active_id, large_motor, small_motor, duration)) {
return;
}
@ -619,7 +688,7 @@ void GameController::SetVibration(uint8_t large_motor, uint8_t small_motor) {
const auto large = static_cast<uint16_t>(large_motor * 0x101U);
const auto small = static_cast<uint16_t>(small_motor * 0x101U);
if (!SDL_RumbleGamepad(pad, large, small, RUMBLE_DURATION_MS)) {
if (!SDL_RumbleGamepad(pad, large, small, duration)) {
LOGF("\t rumble failed: %s\n", SDL_GetError());
}
}
@ -643,20 +712,35 @@ void GameController::SetLightBar(uint8_t r, uint8_t g, uint8_t b) {
}
bool GameController::SetTriggerEffect(const PadTriggerEffectParam& param) {
Common::LockGuard lock(m_mutex);
if (!SendTriggerEffect(param)) {
return false;
}
for (int i = 0; i < 2; i++) {
if ((param.trigger_mask & (1u << i)) != 0) {
m_trigger_effect.trigger_mask |= static_cast<uint8_t>(1u << i);
m_trigger_effect.command[i] = param.command[i];
}
}
return true;
}
bool GameController::SendTriggerEffect(const PadTriggerEffectParam& param) {
if ((param.trigger_mask & ~0x03u) != 0) {
return false;
}
DualSenseEffects effect {};
const auto scale = GetSettingScale(Setting::TriggerEffectIntensity);
if ((param.trigger_mask & 0x01u) != 0) {
effect.enable_bits |= 0x08;
if (!trigger_effect_to_dualsense(param.command[0], effect.left_trigger)) {
if (!trigger_effect_to_dualsense(param.command[0], effect.left_trigger, scale)) {
return false;
}
}
if ((param.trigger_mask & 0x02u) != 0) {
effect.enable_bits |= 0x04;
if (!trigger_effect_to_dualsense(param.command[1], effect.right_trigger)) {
if (!trigger_effect_to_dualsense(param.command[1], effect.right_trigger, scale)) {
return false;
}
}
@ -664,7 +748,6 @@ bool GameController::SetTriggerEffect(const PadTriggerEffectParam& param) {
return true;
}
Common::LockGuard lock(m_mutex);
auto* pad = SDL_GetGamepadFromID(static_cast<SDL_JoystickID>(m_active_id));
if (pad != nullptr && SDL_GetGamepadType(pad) == SDL_GAMEPAD_TYPE_PS5) {
(void)SDL_SendGamepadEffect(pad, &effect, sizeof(effect));

View File

@ -75,6 +75,11 @@ void SetSensor(int id, Sensor sensor, const float* data, uint64_t time_us);
void ResetInputState();
int GetActiveControllerId();
enum class Setting { SpeakerVolume, VibrationIntensity, TriggerEffectIntensity };
void CycleSetting(Setting setting);
float GetSettingScale(Setting setting);
int KYTY_SYSV_ABI PadInit();
int KYTY_SYSV_ABI PadOpen(int user_id, int type, int index, const void* param);
int KYTY_SYSV_ABI PadGetHandle(int user_id, int type, int index);

View File

@ -311,7 +311,7 @@ bool UsesBluetooth(const Stream* stream) {
}
uint64_t Queue(Stream* stream, int controller, const void* data, uint32_t frames, uint32_t channels,
bool is_float, const int* volume) {
bool is_float, const int* volume, float gain) {
if (stream == nullptr || data == nullptr || frames == 0 || channels == 0 || volume == nullptr) {
return 0;
}
@ -353,7 +353,7 @@ uint64_t Queue(Stream* stream, int controller, const void* data, uint32_t frames
const auto index = (static_cast<size_t>(first_frame) + frame) * channels + src_ch;
float value = is_float ? static_cast<const float*>(data)[index]
: static_cast<const int16_t*>(data)[index] / 32768.0f;
value *= volume[src_ch] / 32768.0f;
value *= volume[src_ch] / 32768.0f * gain;
// USB places the speaker in front and the actuators in back; Bluetooth takes stereo.
stream->buffer[static_cast<size_t>(frame) * output_channels + ch + channel_offset] =
value;
@ -382,7 +382,7 @@ uint64_t Queue(Stream* stream, int controller, const void* data, uint32_t frames
return queued_us;
}
bool SetVibration(int controller, uint8_t large_motor, uint8_t small_motor) {
bool SetVibration(int controller, uint8_t large_motor, uint8_t small_motor, uint32_t duration_ms) {
if (SDL_GetGamepadTypeForID(static_cast<SDL_JoystickID>(controller)) != SDL_GAMEPAD_TYPE_PS5) {
return false;
}
@ -390,7 +390,7 @@ bool SetVibration(int controller, uint8_t large_motor, uint8_t small_motor) {
SelectController(controller);
g_large_motor = large_motor;
g_small_motor = small_motor;
g_rumble_until = SDL_GetTicks() + 0xffff;
g_rumble_until = SDL_GetTicks() + duration_ms;
ApplyRumble();
return true;
}

View File

@ -17,9 +17,10 @@ bool UsesBluetooth(const Stream* stream);
// (the active pad has no usable controller audio device or its speaker cannot be routed).
// Ambiguous USB or Bluetooth endpoints 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);
bool is_float, const int* volume, float gain = 1.0f);
// Returns false for other gamepad types, which retain the normal rumble path.
bool SetVibration(int controller, uint8_t large_motor, uint8_t small_motor);
bool SetVibration(int controller, uint8_t large_motor, uint8_t small_motor,
uint32_t duration_ms = 0xffff);
// Also restores the headphone routing.
void Shutdown();

View File

@ -135,6 +135,9 @@ namespace Libs::Controller {
int GetActiveControllerId() {
return 0;
}
float GetSettingScale(Setting) {
return 1.0f;
}
namespace DualSenseHaptics {
Stream* Open(uint32_t, bool) {
@ -145,7 +148,7 @@ void Close(Stream*) {}
bool UsesBluetooth(const Stream* stream) {
return stream != nullptr && pad_bluetooth;
}
uint64_t Queue(Stream* stream, int, const void*, uint32_t, uint32_t, bool, const int*) {
uint64_t Queue(Stream* stream, int, const void*, uint32_t, uint32_t, bool, const int*, float) {
return stream != nullptr ? pad_queue_us : 0;
}
} // namespace DualSenseHaptics

View File

@ -0,0 +1,296 @@
// Exercise the production controller with deterministic host output and time.
#include <SDL3/SDL.h>
#include <array>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <vector>
namespace {
void Check(bool condition, const char* text) {
if (!condition) {
std::fprintf(stderr, "ControllerSettingsTests: %s\n", text);
std::abort();
}
}
struct Rumble {
Uint16 large, small;
Uint32 duration;
};
Uint64 now = 1000;
std::vector<Rumble> rumble;
std::vector<Rumble> haptics;
std::vector<std::array<Uint8, 32>> effects;
bool haptics_handles_rumble = false;
} // namespace
namespace Fake {
Uint64 GetTicks() {
return now;
}
SDL_Gamepad* GetGamepadFromID(SDL_JoystickID id) {
return id == 1 || id == 2 ? reinterpret_cast<SDL_Gamepad*>(static_cast<uintptr_t>(id))
: nullptr;
}
SDL_GamepadType GetGamepadType(SDL_Gamepad*) {
return SDL_GAMEPAD_TYPE_PS5;
}
bool RumbleGamepad(SDL_Gamepad*, Uint16 large, Uint16 small, Uint32 duration) {
rumble.push_back({large, small, duration});
return true;
}
bool SendGamepadEffect(SDL_Gamepad*, const void* data, int size) {
Check(size == 32, "unexpected DualSense effect size");
std::array<Uint8, 32> effect {};
std::memcpy(effect.data(), data, effect.size());
effects.push_back(effect);
return true;
}
bool SetGamepadLED(SDL_Gamepad*, Uint8, Uint8, Uint8) {
return true;
}
bool GamepadHasSensor(SDL_Gamepad*, SDL_SensorType) {
return false;
}
bool SetGamepadSensorEnabled(SDL_Gamepad*, SDL_SensorType, bool) {
return true;
}
void CloseGamepad(SDL_Gamepad*) {}
void Delay(Uint32) {}
} // namespace Fake
#define SDL_GetTicks Fake::GetTicks
#define SDL_GetGamepadFromID Fake::GetGamepadFromID
#define SDL_GetGamepadType Fake::GetGamepadType
#define SDL_RumbleGamepad Fake::RumbleGamepad
#define SDL_SendGamepadEffect Fake::SendGamepadEffect
#define SDL_SetGamepadLED Fake::SetGamepadLED
#define SDL_GamepadHasSensor Fake::GamepadHasSensor
#define SDL_SetGamepadSensorEnabled Fake::SetGamepadSensorEnabled
#define SDL_CloseGamepad Fake::CloseGamepad
#define SDL_Delay Fake::Delay
#include "libs/controller.cpp"
#undef SDL_GetTicks
#undef SDL_GetGamepadFromID
#undef SDL_GetGamepadType
#undef SDL_RumbleGamepad
#undef SDL_SendGamepadEffect
#undef SDL_SetGamepadLED
#undef SDL_GamepadHasSensor
#undef SDL_SetGamepadSensorEnabled
#undef SDL_CloseGamepad
#undef SDL_Delay
namespace Libs::Controller::DualSenseHaptics {
bool SetVibration(int, uint8_t large_motor, uint8_t small_motor, uint32_t duration_ms) {
haptics.push_back({large_motor, small_motor, duration_ms});
return haptics_handles_rumble;
}
void Shutdown() {}
} // namespace Libs::Controller::DualSenseHaptics
namespace Libs::LibKernel {
uint64_t KYTY_SYSV_ABI KernelGetProcessTime() {
return now * 1000;
}
} // namespace Libs::LibKernel
namespace Loader::Timer {
double GetTimeMs() {
return static_cast<double>(now);
}
} // namespace Loader::Timer
namespace {
using namespace Libs::Controller;
struct Controller {
Controller() {
now = 1000;
haptics_handles_rumble = false;
Initialize();
Connect(1);
Check(GetSettingScale(Setting::SpeakerVolume) == 1.0f &&
GetSettingScale(Setting::VibrationIntensity) == 1.0f &&
GetSettingScale(Setting::TriggerEffectIntensity) == 1.0f,
"controller initialization retained old settings");
rumble.clear();
haptics.clear();
effects.clear();
}
~Controller() { Shutdown(); }
};
DualSenseEffects LastEffect() {
Check(!effects.empty(), "missing trigger output");
DualSenseEffects effect {};
std::memcpy(&effect, effects.back().data(), sizeof(effect));
return effect;
}
int ZoneStrength(const uint8_t* effect, int zone) {
const unsigned active = effect[1] | (effect[2] << 8u);
const uint32_t packed = effect[3] | (effect[4] << 8u) | (effect[5] << 16u) |
(static_cast<uint32_t>(effect[6]) << 24u);
return (active & (1u << zone)) != 0 ? 1 + ((packed >> (3 * zone)) & 7u) : 0;
}
void SetRumble(uint8_t large, uint8_t small) {
const PadVibrationParam param {large, small};
Check(PadSetVibration(1, &param) == 0, "vibration request failed");
}
void TestSettingCycles() {
Controller controller;
for (auto setting:
{Setting::SpeakerVolume, Setting::VibrationIntensity, Setting::TriggerEffectIntensity}) {
Check(GetSettingScale(setting) == 1.0f, "initial setting is not strong");
const std::vector<float> levels = setting == Setting::SpeakerVolume
? std::vector<float> {0.0f, 0.02f, 0.12f, 0.42f, 1.0f}
: std::vector<float> {0.0f, 0.33f, 0.66f, 1.0f};
for (int cycle = 0; cycle < 2; ++cycle) {
for (float level: levels) {
CycleSetting(setting);
Check(GetSettingScale(setting) == level, "setting did not cycle or wrap");
}
}
}
CycleSetting(Setting::SpeakerVolume);
CycleSetting(Setting::VibrationIntensity);
CycleSetting(Setting::TriggerEffectIntensity);
}
void TestVibrationLifetime() {
Controller controller;
SetRumble(255, 1);
Check(rumble.size() == 1 && rumble.back().large == 65535 && rumble.back().small == 257 &&
rumble.back().duration == 65535,
"full vibration changed");
now += 100;
CycleSetting(Setting::VibrationIntensity);
Check(rumble.back().large == 0 && rumble.back().small == 0, "muting did not stop vibration");
now += 100;
CycleSetting(Setting::VibrationIntensity);
Check(rumble.back().large == 84 * 257 && rumble.back().small == 257 &&
rumble.back().duration == 65335,
"changing intensity lost a small motor or extended the vibration deadline");
Check(effects.empty(), "vibration intensity resent triggers");
now = 1000 + 65535;
const auto calls = rumble.size();
CycleSetting(Setting::VibrationIntensity);
CycleSetting(Setting::VibrationIntensity);
Check(rumble.size() == calls || (rumble.back().large == 0 && rumble.back().small == 0),
"expired vibration was resurrected");
SetRumble(0, 0);
const auto stopped = rumble.size();
CycleSetting(Setting::VibrationIntensity);
CycleSetting(Setting::VibrationIntensity);
Check(rumble.size() == stopped || (rumble.back().large == 0 && rumble.back().small == 0),
"explicitly stopped vibration was resurrected");
}
void TestMaskedTriggersAndValidation() {
Controller controller;
PadTriggerEffectParam param {};
param.trigger_mask = 3;
param.command[0].mode = 1;
param.command[0].data[0] = 2;
param.command[0].data[1] = 8;
param.command[1].mode = 2;
param.command[1].data[0] = 2;
param.command[1].data[1] = 7;
param.command[1].data[2] = 6;
Check(PadSetTriggerEffect(1, &param) == 0, "initial trigger request failed");
CycleSetting(Setting::TriggerEffectIntensity);
Check(LastEffect().left_trigger[0] == 5 && LastEffect().right_trigger[0] == 5,
"muting did not disable both triggers");
for (uint32_t mode = 1; mode <= 7; ++mode) {
auto invalid = param;
invalid.command[1] = {};
invalid.command[1].mode = mode;
std::memset(invalid.command[1].data, 255, sizeof(invalid.command[1].data));
const auto calls = effects.size();
Check(PadSetTriggerEffect(1, &invalid) == PAD_ERROR_INVALID_ARG && effects.size() == calls,
"muting bypassed trigger validation or sent a partial invalid request");
}
param.trigger_mask = 4;
Check(PadSetTriggerEffect(1, &param) == PAD_ERROR_INVALID_ARG, "invalid trigger mask accepted");
param.trigger_mask = 1;
param.command[0].data[1] = 6;
Check(PadSetTriggerEffect(1, &param) == 0 && LastEffect().enable_bits == 8,
"left-only update touched the right trigger");
CycleSetting(Setting::TriggerEffectIntensity);
auto effect = LastEffect();
Check(effect.enable_bits == 12 && effect.left_trigger[0] == 0x21 &&
ZoneStrength(effect.left_trigger, 1) == 0 &&
ZoneStrength(effect.left_trigger, 2) == 2 && effect.right_trigger[0] == 0x25 &&
effect.right_trigger[1] == 0x84 && effect.right_trigger[3] == 1,
"masked updates lost a cached trigger or failed to scale its strength");
CycleSetting(Setting::TriggerEffectIntensity);
CycleSetting(Setting::TriggerEffectIntensity);
effect = LastEffect();
Check(ZoneStrength(effect.left_trigger, 2) == 6 && effect.right_trigger[3] == 5,
"restoring strong intensity did not recover original trigger strengths");
Check(rumble.empty() && haptics.empty(), "trigger intensity resent vibration");
}
void TestIndependentOutputsAndPadSwitch() {
Controller controller;
haptics_handles_rumble = true;
SetRumble(200, 100);
now += 100;
CycleSetting(Setting::VibrationIntensity);
CycleSetting(Setting::VibrationIntensity);
Check(rumble.empty() && haptics.back().large == 66 && haptics.back().small == 33 &&
haptics.back().duration == 65435,
"haptics rumble did not receive scaled motors and remaining duration");
PadTriggerEffectParam param {};
param.trigger_mask = 1;
param.command[0].mode = 1;
param.command[0].data[1] = 8;
Check(PadSetTriggerEffect(1, &param) == 0, "trigger request failed");
const auto vibration_calls = haptics.size();
const auto trigger_calls = effects.size();
CycleSetting(Setting::SpeakerVolume);
Check(haptics.size() == vibration_calls && effects.size() == trigger_calls,
"speaker volume resent controller effects");
Connect(2);
Disconnect(1);
Check(GetActiveControllerId() == 2, "active controller did not change");
haptics.clear();
effects.clear();
CycleSetting(Setting::VibrationIntensity);
CycleSetting(Setting::TriggerEffectIntensity);
CycleSetting(Setting::TriggerEffectIntensity);
Check(effects.empty(), "previous controller's trigger effects leaked to new pad");
for (const auto& request: haptics) {
Check(request.large == 0 && request.small == 0,
"previous controller's vibration leaked to new pad");
}
SetRumble(200, 100);
Check(PadSetTriggerEffect(1, &param) == 0, "replacement pad trigger request failed");
EmergencyShutdown();
Check(GetActiveControllerId() == -1, "released controller remained active");
haptics.clear();
effects.clear();
CycleSetting(Setting::VibrationIntensity);
CycleSetting(Setting::TriggerEffectIntensity);
Check(effects.empty(), "released controller received a trigger request");
for (const auto& request: haptics) {
Check(request.large == 0 && request.small == 0,
"released controller retained a cached vibration");
}
}
} // namespace
int main() {
Config::Initialize();
TestSettingCycles();
// Each following test initializes another controller and requires strong defaults.
TestVibrationLifetime();
TestMaskedTriggersAndValidation();
TestIndependentOutputsAndPadSwitch();
Config::Shutdown();
return 0;
}

View File

@ -56,6 +56,7 @@ std::binary_semaphore write_started {0}, allow_write {0};
SDL_AudioStream* default_stream = nullptr;
bool fail_default_resume = false;
int active_controller = 1;
float speaker_scale = 1.0f, vibration_scale = 1.0f;
} // namespace
namespace Fake {
@ -325,6 +326,9 @@ namespace Libs::Controller {
int GetActiveControllerId() {
return active_controller;
}
float GetSettingScale(Setting setting) {
return setting == Setting::SpeakerVolume ? speaker_scale : vibration_scale;
}
} // namespace Libs::Controller
namespace Libs::LibKernel {
@ -364,6 +368,7 @@ struct Fixture {
default_stream = nullptr;
fail_default_resume = false;
active_controller = 1;
speaker_scale = vibration_scale = 1.0f;
now = 1000;
opens = rumble_calls = 0;
fail_open = fail_resume = false;
@ -381,8 +386,8 @@ Port Open(bool speaker = false, uint32_t freq = 48000) {
return Port(stream, Haptics::Close);
}
void Queue(const Port& port, const void* data, uint32_t frames = 2, uint32_t channels = 2,
bool is_float = true, const int* volume = unity.data()) {
Haptics::Queue(port.get(), 1, data, frames, channels, is_float, volume);
bool is_float = true, const int* volume = unity.data(), float gain = 1.0f) {
Haptics::Queue(port.get(), 1, data, frames, channels, is_float, volume, gain);
}
void Pull() {
std::array<float, 64> buffer {};
@ -412,6 +417,8 @@ void TestFormatsAndVolume() {
const std::array<int16_t, 2> mono {16384, -32768};
Queue(port, mono.data(), 2, 1, false, volume.data() + 1);
ExpectPcm({0, 0, 0.25f, 0.25f, 0, 0, -0.5f, -0.5f});
Queue(port, mono.data(), 2, 1, false, volume.data() + 1, 0.5f);
ExpectPcm({0, 0, 0.125f, 0.125f, 0, 0, -0.25f, -0.25f});
std::array<float, 24> multichannel {};
multichannel.fill(0.9f);
multichannel[0] = 0.1f;
@ -561,6 +568,33 @@ void TestRumbleLeaseAndDuration() {
Check(rumble_calls == calls, "audio pull touched rumble after shutdown");
}
void TestScaledRumbleDuration() {
for (bool bluetooth: {false, true}) {
Fixture f;
wireless = hid_available = bluetooth;
auto port = Open();
std::vector<float> sound(2048, 0.5f);
Haptics::SetVibration(1, 100, 50, 1000);
Queue(port, sound.data(), 1024, 2, true, unity.data(), 0.0f);
Check(rumble.large == 100 * 0x101, "muted haptics suppressed motor rumble");
Queue(port, sound.data(), 1024, 2, true, unity.data(), 0.5f);
Check(rumble.large == 0, "scaled haptics did not suppress motor rumble");
now += 100;
Haptics::SetVibration(1, 50, 25, 900);
now += 200;
if (bluetooth) {
Haptics::UpdateBluetoothRumble();
} else {
Pull();
}
Check(rumble.large == 50 * 0x101 && rumble.small == 25 * 0x101 && rumble.duration == 700,
"scaled rumble lost its strength or extended its original expiry");
Haptics::SetVibration(1, 50, 25, 0);
Check(rumble.large == 0 && rumble.small == 0 && rumble.duration == 0,
"expired rumble was restarted");
}
}
void TestSwitchStopsOldRumble() {
Fixture f;
auto port = Open();
@ -732,12 +766,12 @@ void TestBluetoothFormatsAndRoutingFailure() {
now += 2001;
std::vector<int16_t> mono(1024, 16384);
const int volume = 16384;
Queue(speaker, mono.data(), 1024, 1, false, &volume);
Queue(speaker, mono.data(), 1024, 1, false, &volume, 0.5f);
auto pending = Bluetooth::Prepare(now * 1000000);
Check(pending.size() == 1 && hid_opens == 2 && effects[1][7] == 0x30 &&
std::abs(pending[0].audio[200] - 0.25f) < 1e-4f &&
std::abs(pending[0].audio[201] - 0.25f) < 1e-4f,
"Bluetooth mono int16 volume mapping or speaker routing recovery failed");
std::abs(pending[0].audio[200] - 0.125f) < 1e-4f &&
std::abs(pending[0].audio[201] - 0.125f) < 1e-4f,
"Bluetooth mono int16 volume/gain mapping or speaker routing recovery failed");
}
void TestBluetoothEncodingDoesNotBlockAudioQueue() {
@ -1103,6 +1137,82 @@ void TestAudioSpeakerRouting() {
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});
speaker_scale = 0.5f;
audio.AudioOutOutputs(&output, 1, false);
ExpectPcm({0.25f, 0.25f, 0, 0, 0.25f, 0.25f, 0, 0});
speaker_scale = 0.0f;
audio.AudioOutOutputs(&output, 1, false);
ExpectPcm({});
active_controller = 2;
audio.AudioOutOutputs(&output, 1, false);
std::array<float, 4> fallback {};
Check(SDL_GetAudioStreamData(default_stream, fallback.data(), sizeof(fallback)) ==
sizeof(fallback) &&
fallback == std::array<float, 4> {},
"unplugging the controller unmuted its speaker fallback");
}
void TestAudioFallbackGain() {
for (bool is_float: {false, true}) {
Fixture f;
Libs::Audio::Audio audio;
active_controller = 2;
speaker_scale = 0.5f;
const std::array<int16_t, 4> integer_pcm {16384, 16384, 16384, 16384};
const auto format = is_float ? Libs::Audio::Audio::Format::FloatStereo
: Libs::Audio::Audio::Format::Signed16bitStereo;
const void* data = is_float ? static_cast<const void*>(pcm.data()) : integer_pcm.data();
for (int type: {0, 4}) {
const auto port = audio.AudioOutOpen(type, 2, 48000, format);
const std::array<int, 2> volume {16384, 16384};
audio.AudioOutSetVolume(port, 3, volume.data());
Libs::Audio::Audio::OutputParam output {port, data};
audio.AudioOutOutputs(&output, 1, false);
const float expected = type == 4 ? 0.125f : 0.25f;
std::array<float, 4> actual {};
if (is_float) {
Check(SDL_GetAudioStreamData(default_stream, actual.data(), sizeof(actual)) ==
sizeof(actual),
"float fallback output is missing");
} else {
std::array<int16_t, 4> integers {};
Check(SDL_GetAudioStreamData(default_stream, integers.data(), sizeof(integers)) ==
sizeof(integers),
"integer fallback output is missing");
std::transform(integers.begin(), integers.end(), actual.begin(),
[](int16_t value) { return value / 32768.0f; });
}
Check(std::all_of(actual.begin(), actual.end(),
[expected](float value) { return value == expected; }),
"speaker setting gain was lost or applied to ordinary audio");
audio.AudioOutClose(port);
}
}
}
void TestAudioVibrationGain() {
for (bool bluetooth: {false, true}) {
Fixture f;
wireless = hid_available = bluetooth;
Libs::Audio::Audio audio;
vibration_scale = 0.5f;
speaker_scale = 0.0f;
const auto port =
audio.AudioOutOpen(10, 1024, 48000, Libs::Audio::Audio::Format::FloatStereo);
std::vector<float> sound(2048, 0.5f);
Libs::Audio::Audio::OutputParam output {port, sound.data()};
audio.AudioOutOutputs(&output, 1, false);
Check(default_stream == nullptr, "vibration port opened ordinary audio");
if (bluetooth) {
Check(Bluetooth::Prepare(now * 1000000).empty(),
"Bluetooth vibration skipped its coalescing window");
const auto pending = Bluetooth::Prepare((now + 1) * 1000000);
Check(pending.size() == 1 && pending[0].data[76] == 32,
"Bluetooth vibration did not apply its own setting gain");
} else {
ExpectPcm({0, 0, 0.25f, 0.25f, 0, 0, 0.25f, 0.25f});
}
}
}
void TestBluetoothAudioSpeakerRouting() {
@ -1113,12 +1223,15 @@ void TestBluetoothAudioSpeakerRouting() {
const auto port = audio.AudioOutOpen(4, 1024, 48000, Libs::Audio::Audio::Format::FloatStereo);
Check(port.IsValid() && default_stream != nullptr, "Bluetooth pad speaker port did not open");
std::vector<float> sound(2048, 0.25f);
speaker_scale = 0.5f;
Libs::Audio::Audio::OutputParam output {port, sound.data()};
Check(audio.AudioOutOutputs(&output, 1, false) == 1024 && hid_opens == 1 &&
SDL_GetAudioStreamQueued(default_stream) == 0,
"Bluetooth pad speaker also played on the host output");
fail_hid_write = true;
auto pending = Libs::Controller::DualSenseBluetooth::Prepare(now * 1000000);
Check(pending.size() == 1 && std::abs(pending[0].audio[200] - 0.125f) < 1e-4f,
"Bluetooth speaker did not apply its setting gain");
Libs::Controller::DualSenseBluetooth::Send(pending);
Check(audio.AudioOutOutputs(&output, 1, false) == 1024 &&
SDL_GetAudioStreamQueued(default_stream) > 0,
@ -1144,6 +1257,8 @@ int main() {
SDL_SetHint(SDL_HINT_AUDIO_DRIVER, "dummy");
TestAudioSpeakerFallback();
TestAudioSpeakerRouting();
TestAudioFallbackGain();
TestAudioVibrationGain();
TestBluetoothAudioSpeakerRouting();
TestAudioDefaultResumeFailure();
TestFormatsAndVolume();
@ -1173,6 +1288,7 @@ int main() {
TestUsbAndBluetoothFeatureParity();
TestFailuresAndBoundedQueue();
TestRumbleLeaseAndDuration();
TestScaledRumbleDuration();
TestSwitchStopsOldRumble();
TestCloseRestoresRumble();
std::printf("PadHapticsTests: all cases passed\n");