From 3c62359e05854ea3ae8f09d176ae2f82d3bbf817 Mon Sep 17 00:00:00 2001 From: Jetsku <59046009+Jetsku@users.noreply.github.com> Date: Thu, 8 Oct 2026 14:00:17 +0200 Subject: [PATCH] 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 * 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 * 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 * 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 * 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 * 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 * Pad: correct trigger effect state reporting --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: nmzik --- src/libs/controller.cpp | 89 +++++++++++++++++ src/libs/controller.h | 2 + src/libs/libPad.cpp | 22 +---- tests/ControllerSettingsTests.cpp | 157 ++++++++++++++++++++++++++++++ 4 files changed, 249 insertions(+), 21 deletions(-) diff --git a/src/libs/controller.cpp b/src/libs/controller.cpp index cdfa6f92f..ea7574f9a 100644 --- a/src/libs/controller.cpp +++ b/src/libs/controller.cpp @@ -67,8 +67,13 @@ struct PadTriggerEffectParam { PadTriggerEffectCommand command[2]; }; +struct PadTriggerEffectStateInformation { + int32_t state[2]; +}; + static_assert(sizeof(PadTriggerEffectCommand) == 56); static_assert(sizeof(PadTriggerEffectParam) == 120); +static_assert(sizeof(PadTriggerEffectStateInformation) == 8); struct DualSenseEffects { uint8_t enable_bits; @@ -118,6 +123,7 @@ public: int GetActiveControllerId(); void SetLightBar(uint8_t r, uint8_t g, uint8_t b); bool SetTriggerEffect(const PadTriggerEffectParam& param); + void GetTriggerEffectState(PadTriggerEffectStateInformation* info); void CycleSetting(Setting setting); float GetSettingScale(Setting setting) const; void ReadState(ControllerState* state, bool* flag, int* count); @@ -131,6 +137,7 @@ private: // Apply cached output without changing its game-requested lifetime; caller holds m_mutex. void ApplyVibration(); bool SendTriggerEffect(const PadTriggerEffectParam& param); + void UpdateTriggerEffectState(); Common::Mutex m_mutex; std::vector m_connected_ids; @@ -152,6 +159,7 @@ private: std::array m_vibration {}; uint64_t m_vibration_until = 0; PadTriggerEffectParam m_trigger_effect {}; + PadTriggerEffectStateInformation m_trigger_state {}; // Setting changes share the output lock; audio only needs an atomic scale snapshot. std::array, 3> m_setting_steps {}; }; @@ -330,6 +338,51 @@ static bool trigger_effect_to_dualsense(const PadTriggerEffectCommand& command, } } +enum TriggerState : int32_t { + TRIGGER_STATE_OFF = 0, + TRIGGER_STATE_FEEDBACK_STANDBY = 1, + TRIGGER_STATE_FEEDBACK_ACTIVE = 2, + TRIGGER_STATE_WEAPON_STANDBY = 3, + TRIGGER_STATE_WEAPON_PULLING = 4, + TRIGGER_STATE_WEAPON_FIRED = 5, + TRIGGER_STATE_VIBRATION_STANDBY = 6, + TRIGGER_STATE_VIBRATION_ACTIVE = 7, +}; + +static int32_t TriggerEffectState(const PadTriggerEffectCommand& command, int value, + int32_t previous) { + // Trigger positions use nonuniform travel ranges, with endpoints at 0 and 255. + const int pos = value == 0 ? 0 : value == 255 ? 9 : 1 + value / 32; + switch (command.mode) { + case 1: // feedback: resistance from position data[0], strength data[1] + return command.data[1] != 0 && pos >= command.data[0] ? TRIGGER_STATE_FEEDBACK_ACTIVE + : TRIGGER_STATE_FEEDBACK_STANDBY; + case 2: // weapon: resistance from data[0], released at data[1] + if (value < command.data[0] * 32) { + return TRIGGER_STATE_WEAPON_STANDBY; + } + return previous == TRIGGER_STATE_WEAPON_FIRED || + value >= std::min(command.data[1] * 32, 254) + ? TRIGGER_STATE_WEAPON_FIRED + : TRIGGER_STATE_WEAPON_PULLING; + case 3: // vibration from position data[0], amplitude data[1], frequency data[2] + return command.data[1] != 0 && command.data[2] != 0 && pos >= command.data[0] + ? TRIGGER_STATE_VIBRATION_ACTIVE + : TRIGGER_STATE_VIBRATION_STANDBY; + case 4: // multiple-position feedback: strength per position + return command.data[pos] != 0 ? TRIGGER_STATE_FEEDBACK_ACTIVE + : TRIGGER_STATE_FEEDBACK_STANDBY; + case 5: // slope feedback between data[0] and data[1] + return pos >= command.data[0] && pos < command.data[1] ? TRIGGER_STATE_FEEDBACK_ACTIVE + : TRIGGER_STATE_FEEDBACK_STANDBY; + case 6: // multiple-position vibration: frequency data[0], amplitude per position + return command.data[0] != 0 && command.data[1 + pos] != 0 + ? TRIGGER_STATE_VIBRATION_ACTIVE + : TRIGGER_STATE_VIBRATION_STANDBY; + default: return TRIGGER_STATE_OFF; + } +} + void Initialize() { EXIT_IF(g_controller != nullptr); @@ -419,6 +472,7 @@ void GameController::CheckActive() { m_vibration = {}; m_vibration_until = 0; m_trigger_effect = {}; + m_trigger_state = {}; } void GameController::AddState() { @@ -466,6 +520,7 @@ void GameController::Axis(int id, Controller::Axis axis, int value) { } if (trigger != 0) { m_state.buttons = value > 0 ? m_state.buttons | trigger : m_state.buttons & ~trigger; + UpdateTriggerEffectState(); } AddState(); @@ -622,6 +677,7 @@ void GameController::ResetInputState() { m_state.buttons = 0; std::fill_n(m_state.axes, 4, 128); std::fill_n(m_state.axes + 4, 2, 0); + UpdateTriggerEffectState(); for (auto& touch: m_state.touch) { touch = {}; } @@ -730,9 +786,25 @@ bool GameController::SetTriggerEffect(const PadTriggerEffectParam& param) { m_trigger_effect.command[i] = param.command[i]; } } + UpdateTriggerEffectState(); return true; } +void GameController::GetTriggerEffectState(PadTriggerEffectStateInformation* info) { + Common::LockGuard lock(m_mutex); + *info = m_trigger_state; +} + +void GameController::UpdateTriggerEffectState() { + // Track every input event so a weapon remains fired even when the game polls after release + // starts. + for (int i = 0; i < 2; i++) { + m_trigger_state.state[i] = TriggerEffectState( + m_trigger_effect.command[i], m_state.axes[static_cast(Axis::TriggerLeft) + i], + m_trigger_state.state[i]); + } +} + bool GameController::SendTriggerEffect(const PadTriggerEffectParam& param) { if ((param.trigger_mask & ~0x03u) != 0) { return false; @@ -1098,4 +1170,21 @@ int KYTY_SYSV_ABI PadSetTriggerEffect(int handle, const PadTriggerEffectParam* p return g_controller->SetTriggerEffect(*param) ? OK : PAD_ERROR_INVALID_ARG; } +int KYTY_SYSV_ABI PadGetTriggerEffectState(int handle, PadTriggerEffectStateInformation* info) { + PRINT_NAME(); + + LOGF("\t handle = %d\n", handle); + + if (handle != 1) { + return PAD_ERROR_INVALID_HANDLE; + } + if (info == nullptr) { + return PAD_ERROR_INVALID_ARG; + } + + g_controller->GetTriggerEffectState(info); + + return OK; +} + } // namespace Libs::Controller diff --git a/src/libs/controller.h b/src/libs/controller.h index 6c83c5ae7..7d3fee34e 100644 --- a/src/libs/controller.h +++ b/src/libs/controller.h @@ -53,6 +53,7 @@ struct PadData; struct PadVibrationParam; struct PadLightBarParam; struct PadTriggerEffectParam; +struct PadTriggerEffectStateInformation; inline int controller_get_axis(int min, int max, int value) { int v = (255 * (value - min)) / (max - min); @@ -92,6 +93,7 @@ int KYTY_SYSV_ABI PadReadState(int handle, PadData* data); int KYTY_SYSV_ABI PadRead(int handle, PadData* data, int num); int KYTY_SYSV_ABI PadSetVibration(int handle, const PadVibrationParam* param); int KYTY_SYSV_ABI PadSetTriggerEffect(int handle, const PadTriggerEffectParam* param); +int KYTY_SYSV_ABI PadGetTriggerEffectState(int handle, PadTriggerEffectStateInformation* info); int KYTY_SYSV_ABI PadResetLightBar(int handle); int KYTY_SYSV_ABI PadSetLightBar(int handle, const PadLightBarParam* param); diff --git a/src/libs/libPad.cpp b/src/libs/libPad.cpp index 38e9c1d6d..ab9a861fe 100644 --- a/src/libs/libPad.cpp +++ b/src/libs/libPad.cpp @@ -22,26 +22,6 @@ static int KYTY_SYSV_ABI PadSetVibrationMode(int handle, int mode) { return 0; } -struct PadTriggerEffectStateInformation { - int32_t state[2]; -}; - -static int KYTY_SYSV_ABI PadGetTriggerEffectState(int handle, - PadTriggerEffectStateInformation* info) { - PRINT_NAME(); - - LOGF("\t handle = %d\n", handle); - - if (info == nullptr) { - return -2137653243; /* 0x80960005 */ - } - - info->state[0] = 0; - info->state[1] = 0; - - return 0; -} - static int KYTY_SYSV_ABI PadSetVibrationTriggerEffectWeakWhileEmbeddedMicInUse(bool enabled) { PRINT_NAME(); @@ -214,7 +194,7 @@ LIB_DEFINE(InitPad_1) { LIB_FUNC("yFVnOdGxvZY", Controller::PadSetVibration); LIB_FUNC("W2G-yoyMF5U", PadSetVibrationMode); LIB_FUNC("Yq0zOH7YNOM", PadSetVibrationTriggerEffectWeakWhileEmbeddedMicInUse); - LIB_FUNC("znaWI0gpuo8", PadGetTriggerEffectState); + LIB_FUNC("znaWI0gpuo8", Controller::PadGetTriggerEffectState); LIB_FUNC("DscD1i9HX1w", Controller::PadResetLightBar); LIB_FUNC("RR4novUEENY", Controller::PadSetLightBar); LIB_FUNC("6ncge5+l5Qs", PadClose); diff --git a/tests/ControllerSettingsTests.cpp b/tests/ControllerSettingsTests.cpp index e26d7c64c..3c5e37f5f 100644 --- a/tests/ControllerSettingsTests.cpp +++ b/tests/ControllerSettingsTests.cpp @@ -343,6 +343,162 @@ void TestIndependentOutputsAndPadSwitch() { "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, ¶m) == 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() { @@ -353,6 +509,7 @@ int main() { TestVibrationLifetime(); TestMaskedTriggersAndValidation(); TestIndependentOutputsAndPadSwitch(); + TestTriggerEffectState(); Config::Shutdown(); return 0; }