Files
KytyPS5/tests/ControllerSettingsTests.cpp
T
Jetsku 3c62359e05 Fix/trigger effect state (#1132)
* Pad: report the adaptive trigger state instead of always 0

scePadGetTriggerEffectState returned 0 for both triggers. Games that wait for
the state of the trigger effect they set before acting on L2/R2 never saw it
change; in Astro Bot, L2/R2 actions such as punches did nothing in some levels.

The state now follows from the game's last effect per trigger (already kept
for the DualSense output) and the trigger's travel, 0..255 over the effect's
ten positions: feedback no force / pushing, weapon not / almost / fully
pressed, vibration not firing / firing, with the values 1..7 that duaLib
(WujekFoliarz/duaLib) reads from a DualSense input report. SDL does not expose
that report, so this is an approximation; it also works with other pads and
the keyboard, where a digital L2/R2 is full travel. The trigger intensity
setting changes only the output, not the reported state.

Tested in Astro Bot (PPSA21567 1.018) by a player in the levels where the
triggers did nothing.

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

* tests: controller_settings covers scePadGetTriggerEffectState

Feedback, weapon and vibration states across the trigger travel, a trigger without an effect, a masked update keeping the other trigger's effect, and the intensity setting leaving the reported state unchanged.

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

* Pad: a feedback trigger reports pushing only while it is pressed

A feedback effect from position 0 reported FEEDBACK_IS_PUSHING on an untouched trigger, so a game waiting for the change from no force to pushing never saw it. In Astro's Playroom the gacha capsules did not break on R2. Feedback states (modes 1, 4, 5) now need some travel (>= 8 of 255). Tested in Astro's Playroom (PPSA01325): the capsules break on R2 again.

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

* Pad: a vibration trigger reports firing only while it is pressed

A vibration effect from position 0 reported VIBRATION_IS_FIRING on an untouched trigger; in Astro's Playroom the gun fired by itself. Vibration states (modes 3 and 6) now need some travel, as the feedback states do. Tested in Astro's Playroom.

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

* Pad: reject invalid handles in scePadGetTriggerEffectState; test the remaining effect modes

scePadGetTriggerEffectState ignored its handle and returned pad 1's state for any handle. It now returns
SCE_PAD_ERROR_INVALID_HANDLE (0x80920003) for handles other than 1, like the other pad queries in libPad.cpp
and duaLib.

controller_settings now also covers multiple-position feedback (mode 4: pushes only at positions with a
strength), slope feedback (mode 5: pushes from its start position, not on an untouched trigger) and
multiple-position vibration (mode 6: fires only at positions with an amplitude and a frequency).

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

* Pad: scePadGetTriggerEffectState returns libScePad's invalid-argument code

A null state returned 0x80960005, which is SCE_USER_SERVICE_ERROR_INVALID_ARGUMENT,
not a pad error; libScePad's code is SCE_PAD_ERROR_INVALID_ARG (0x80920001), as
scePadSetTriggerEffect returns. The call now lives with the other pad calls in
controller.cpp so controller_settings tests its handle and argument checks.

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

* Pad: correct trigger effect state reporting

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: nmzik <Nmzik@mail.ru>
2026-10-08 16:00:17 +04:00

516 lines
19 KiB
C++

// 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) ==
Config::GetControllerSpeakerVolume() / 50.0f &&
GetSettingScale(Setting::VibrationIntensity) ==
Config::GetControllerVibrationIntensity() / 100.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 TestGlobalControllerLevels() {
Config::ConfigOptions options;
options.controller_speaker_volume = 25;
options.controller_vibration_intensity = 25;
Config::Load(options);
{
Controller controller;
Check(GetSettingScale(Setting::SpeakerVolume) == 0.5f &&
GetSettingScale(Setting::VibrationIntensity) == 0.25f &&
GetSettingScale(Setting::TriggerEffectIntensity) == 1.0f,
"global controller levels did not scale their outputs independently");
SetRumble(200, 100);
Check(rumble.back().large == 50 * 257 && rumble.back().small == 25 * 257,
"global vibration level did not scale rumble");
CycleSetting(Setting::SpeakerVolume);
CycleSetting(Setting::VibrationIntensity);
Check(GetSettingScale(Setting::SpeakerVolume) == 0.0f &&
GetSettingScale(Setting::VibrationIntensity) == 0.0f,
"cycle hotkeys did not mute globally scaled outputs");
CycleSetting(Setting::SpeakerVolume);
CycleSetting(Setting::VibrationIntensity);
Check(GetSettingScale(Setting::SpeakerVolume) == 0.01f &&
GetSettingScale(Setting::VibrationIntensity) == 0.0825f,
"cycle hotkeys did not multiply the global controller levels");
Check(rumble.back().large == 17 * 257 && rumble.back().small == 8 * 257,
"cycling vibration did not apply the combined intensity to cached rumble");
}
options.controller_speaker_volume = 50;
Config::Load(options);
{
Controller controller;
Check(GetSettingScale(Setting::SpeakerVolume) == 1.0f,
"speaker midpoint did not preserve the previous default level");
}
options.controller_speaker_volume = 100;
Config::Load(options);
{
Controller controller;
Check(GetSettingScale(Setting::SpeakerVolume) == 2.0f,
"speaker maximum did not add 6 dB of gain");
CycleSetting(Setting::SpeakerVolume);
Check(GetSettingScale(Setting::SpeakerVolume) == 0.0f,
"speaker maximum prevented the hotkey from muting");
}
options.controller_speaker_volume = 0;
options.controller_vibration_intensity = 0;
Config::Load(options);
{
Controller controller;
Check(GetSettingScale(Setting::SpeakerVolume) == 0.0f &&
GetSettingScale(Setting::VibrationIntensity) == 0.0f,
"zero global controller levels did not mute outputs");
SetRumble(200, 100);
Check(rumble.back().large == 0 && rumble.back().small == 0,
"zero global vibration level did not mute motors");
}
Config::Load(Config::ConfigOptions {});
}
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");
}
}
void TestTriggerEffectState() {
Controller controller;
PadTriggerEffectStateInformation info {{-1, -1}};
const auto check_state = [&](int32_t left, int32_t right, const char* message) {
Check(PadGetTriggerEffectState(1, &info) == OK && info.state[0] == left &&
info.state[1] == right,
message);
};
const auto axis = [&](int left, int right) {
SetAxis(1, Axis::TriggerLeft, left);
SetAxis(1, Axis::TriggerRight, right);
};
PadTriggerEffectParam param {};
const auto set_effect = [&] {
Check(PadSetTriggerEffect(1, &param) == OK, "trigger request failed");
};
Check(PadGetTriggerEffectState(2, &info) == PAD_ERROR_INVALID_HANDLE && info.state[0] == -1 &&
info.state[1] == -1,
"invalid trigger handle changed the output");
Check(PadGetTriggerEffectState(2, nullptr) == PAD_ERROR_INVALID_HANDLE,
"trigger state checked the output before the handle");
Check(PadGetTriggerEffectState(1, nullptr) == PAD_ERROR_INVALID_ARG,
"trigger state accepted a null output");
check_state(0, 0, "unset trigger effects did not report off");
param.trigger_mask = 3;
param.command[0].mode = 1;
param.command[0].data[0] = 7;
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;
set_effect();
axis(191, 63);
check_state(1, 3, "triggers activated before the documented start positions");
axis(192, 64);
check_state(2, 4, "triggers did not activate at the documented start positions");
axis(255, 223);
check_state(2, 4, "weapon fired before its end position");
SetAxis(1, Axis::TriggerRight, 224);
SetAxis(1, Axis::TriggerRight, 100);
check_state(2, 5, "unpolled weapon firing was lost on partial release");
check_state(2, 5, "reading trigger state changed the weapon latch");
set_effect();
check_state(2, 5, "repeating a weapon request rearmed a held trigger");
CycleSetting(Setting::TriggerEffectIntensity);
check_state(2, 5, "muting trigger effects changed their reported states");
// A rejected two-trigger request must leave both cached commands and states intact.
auto invalid = param;
invalid.command[0].mode = 0;
invalid.command[1].data[1] = 2;
Check(PadSetTriggerEffect(1, &invalid) == PAD_ERROR_INVALID_ARG,
"invalid weapon end position was accepted");
SetAxis(1, Axis::TriggerLeft, 255);
check_state(2, 5, "an invalid request changed cached trigger commands or states");
SetAxis(1, Axis::TriggerRight, 63);
SetAxis(1, Axis::TriggerRight, 100);
check_state(2, 4, "unpolled release did not rearm the weapon");
param.trigger_mask = 2;
param.command[1].data[1] = 8;
set_effect();
axis(255, 253);
check_state(2, 4, "weapon end position 8 fired before output 254");
SetAxis(1, Axis::TriggerRight, 254);
check_state(2, 5, "weapon end position 8 did not fire at output 254");
// Masked updates preserve the other trigger, including its fired state.
param.trigger_mask = 1;
param.command[0].data[0] = 9;
set_effect();
SetAxis(1, Axis::TriggerLeft, 254);
check_state(1, 5, "feedback position 9 activated before full travel");
SetAxis(HOST_INPUT_CONTROLLER_ID, Axis::TriggerLeft, 255);
check_state(2, 5, "keyboard trigger travel did not activate feedback");
param.command[0].data[0] = 0;
set_effect();
SetAxis(1, Axis::TriggerLeft, 0);
check_state(2, 5, "feedback at position 0 was suppressed by a dead zone");
param.command[0].data[1] = 0;
set_effect();
check_state(1, 5, "zero-strength feedback reported active");
param.command[0] = {};
param.command[0].mode = 4;
param.command[0].data[0] = 4;
param.command[0].data[2] = 4;
param.command[0].data[8] = 4;
set_effect();
const int feedback_cases[][2] = {{0, 2}, {1, 1}, {31, 1}, {32, 2}, {63, 2},
{64, 1}, {223, 1}, {224, 2}, {254, 2}, {255, 1}};
for (const auto& test: feedback_cases) {
SetAxis(1, Axis::TriggerLeft, test[0]);
check_state(test[1], 5, "multi-position feedback used the wrong position");
}
param.command[0] = {};
param.command[0].mode = 5;
param.command[0].data[0] = 3;
param.command[0].data[1] = 7;
param.command[0].data[2] = 2;
param.command[0].data[3] = 6;
set_effect();
const int slope_cases[][2] = {{63, 1}, {64, 2}, {191, 2}, {192, 1}, {255, 1}};
for (const auto& test: slope_cases) {
SetAxis(1, Axis::TriggerLeft, test[0]);
check_state(test[1], 5, "slope feedback did not respect its start and end");
}
param.command[0].data[0] = 0;
set_effect();
SetAxis(1, Axis::TriggerLeft, 0);
check_state(2, 5, "slope feedback at position 0 was suppressed");
param.command[0] = {};
param.command[0].mode = 3;
param.command[0].data[0] = 6;
param.command[0].data[1] = 4;
param.command[0].data[2] = 20;
set_effect();
SetAxis(1, Axis::TriggerLeft, 159);
check_state(6, 5, "vibration activated before position 6");
SetAxis(1, Axis::TriggerLeft, 160);
check_state(7, 5, "vibration did not activate at position 6");
param.command[0].data[0] = 0;
set_effect();
SetAxis(1, Axis::TriggerLeft, 0);
check_state(7, 5, "vibration at position 0 was suppressed");
param.command[0].data[2] = 0;
set_effect();
check_state(6, 5, "zero-frequency vibration reported active");
param.command[0] = {};
param.command[0].mode = 6;
param.command[0].data[0] = 20;
param.command[0].data[10] = 4;
set_effect();
SetAxis(1, Axis::TriggerLeft, 254);
check_state(6, 5, "multi-position vibration activated before position 9");
SetAxis(1, Axis::TriggerLeft, 255);
check_state(7, 5, "multi-position vibration did not activate at position 9");
param.command[0].data[0] = 0;
set_effect();
check_state(6, 5, "zero-frequency multi-position vibration reported active");
ResetInputState();
check_state(6, 3, "input reset retained a fired weapon");
param.command[0].mode = 0;
set_effect();
check_state(0, 3, "off mode retained an effect state");
Connect(2);
Disconnect(1);
check_state(0, 0, "switching controllers retained effect states");
}
} // namespace
int main() {
Config::Initialize();
TestSettingCycles();
TestGlobalControllerLevels();
// Each following test initializes another controller and requires strong defaults.
TestVibrationLifetime();
TestMaskedTriggersAndValidation();
TestIndependentOutputsAndPadSwitch();
TestTriggerEffectState();
Config::Shutdown();
return 0;
}