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
12 changed files with 588 additions and 46 deletions
+4 -1
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
+296
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;
}
+122 -6
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");