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; }