diff --git a/code/cmdline/cmdline.cpp b/code/cmdline/cmdline.cpp index 1351c72f1db..da658a768e0 100644 --- a/code/cmdline/cmdline.cpp +++ b/code/cmdline/cmdline.cpp @@ -404,7 +404,7 @@ cmdline_parm stretch_menu("-stretch_menu", nullptr, AT_NONE); // Cmdline_stretch cmdline_parm capture_mouse("-capture_mouse", nullptr, AT_NONE); // Cmdline_capture_mouse cmdline_parm vr("-vr", nullptr, AT_NONE); cmdline_parm deadzone("-deadzone", -"Sets the joystick deadzone. Integer value from 0 to 100 as a percentage of the joystick's range (100% would make the stick do nothing). Disables deadzone slider in the in-game Options menu.", AT_INT); //Cmdline_deadzone +"Sets the joystick deadzone. Integer value from 0 to 100 as a percentage of the joystick's range (100% would make the stick do nothing). Disables deadzone slider in the in-game Options menu; a per-axis deadzone set there still applies to its axis.", AT_INT); //Cmdline_deadzone int Cmdline_autopilot_interruptable = 1; bool Cmdline_stretch_menu = false; diff --git a/code/controlconfig/controlsconfig.cpp b/code/controlconfig/controlsconfig.cpp index 3b6a4938cce..bb47d58a752 100644 --- a/code/controlconfig/controlsconfig.cpp +++ b/code/controlconfig/controlsconfig.cpp @@ -454,14 +454,21 @@ static int joy_get_unscaled_reading(int raw) return (int) ((std::uint64_t) raw * (std::uint64_t) F1_0 / (std::uint64_t) rng); // convert to 0 - F1_0 range, 64bit ints used to avoid uint overflow } -int joy_get_scaled_reading(int raw) +int joy_get_scaled_reading(int raw, int action) { int x, d, dead_zone, rng; float percent, sensitivity_percent, non_sensitivity_percent; + const bool per_axis = action >= 0 && action < Action::NUM_VALUES; + const int axis_dead_zone = per_axis ? Joy_axis_dead_zone_size[action] : -1; + const int sensitivity = + (per_axis && Joy_axis_sensitivity[action] >= 0) ? Joy_axis_sensitivity[action] : Joy_sensitivity; + raw -= JOY_AXIS_CENTER; - if (Cmdline_deadzone >= 0) { + if (axis_dead_zone >= 0) { + dead_zone = (JOY_AXIS_MAX - JOY_AXIS_MIN) * axis_dead_zone / 100; + } else if (Cmdline_deadzone >= 0) { dead_zone = (JOY_AXIS_MAX - JOY_AXIS_MIN) * Cmdline_deadzone / 200; //Making this div by 200 is what allows us to have the granularity of 0 to 100 in the cmdline. //This allows a larger deadzone than the original maximum, all the way to the stick's full range. @@ -481,11 +488,11 @@ int joy_get_scaled_reading(int raw) if (d > rng) d = rng; - Assert(Joy_sensitivity >= 0 && Joy_sensitivity <= 9); + Assert(sensitivity >= 0 && sensitivity <= 9); // compute percentages as a range between 0 and 1 - sensitivity_percent = (float) Joy_sensitivity / 9.0f; - non_sensitivity_percent = (float) (9 - Joy_sensitivity) / 9.0f; + sensitivity_percent = (float) sensitivity / 9.0f; + non_sensitivity_percent = (float) (9 - sensitivity) / 9.0f; // find percent of max axis is at percent = (float) d / (float) rng; @@ -3055,7 +3062,7 @@ void scale_invert(const CC_bind &bind, case CC_TYPE_AXIS_BTN_NEG: case CC_TYPE_AXIS_BTN_POS: default: - dx = joy_get_scaled_reading(axis_in[cid][btn]); + dx = joy_get_scaled_reading(axis_in[cid][btn], action); break; } diff --git a/code/controlconfig/controlsconfig.h b/code/controlconfig/controlsconfig.h index 24e538d6882..7a1b4cf7cf9 100644 --- a/code/controlconfig/controlsconfig.h +++ b/code/controlconfig/controlsconfig.h @@ -698,6 +698,8 @@ extern int Failed_key_index; extern int Joy_dead_zone_size; extern int Joy_sensitivity; +extern int Joy_axis_dead_zone_size[Action::NUM_VALUES]; +extern int Joy_axis_sensitivity[Action::NUM_VALUES]; extern int Control_config_overlay_id; @@ -1037,11 +1039,13 @@ void control_config_clear_used_status(); /** * @brief Applies sensitivity multiplier and deadzone histerisis to the raw axis value * - * @param[in] raw The raw axis value to transform + * @param[in] raw The raw axis value to transform + * @param[in] action The axis action (Action::HEADING and so on) whose own deadzone and sensitivity apply when set, + * or -1 for the global ones * * @return The transformed value */ -int joy_get_scaled_reading(int raw); +int joy_get_scaled_reading(int raw, int action = -1); /** * Lookups the given stringified enum to find its value diff --git a/code/controlconfig/controlsconfigcommon.cpp b/code/controlconfig/controlsconfigcommon.cpp index 5a01816a19c..1635ad20ae0 100644 --- a/code/controlconfig/controlsconfigcommon.cpp +++ b/code/controlconfig/controlsconfigcommon.cpp @@ -54,7 +54,7 @@ auto DeadZoneOption = options::OptionBuilder("Input.JoystickDeadZone", .level(options::ExpertLevel::Beginner) .default_val(10) .bind_to(&Joy_dead_zone_size) - .importance(1) + .importance(11) .flags({options::OptionFlags::RetailBuiltinOption}) .finish(); @@ -69,10 +69,94 @@ auto SensitivityOption = options::OptionBuilder("Input.JoystickSensitivity" .level(options::ExpertLevel::Beginner) .default_val(9) .bind_to(&Joy_sensitivity) - .importance(2) + .importance(12) .flags({options::OptionFlags::RetailBuiltinOption}) .finish(); +// -1 means "use the global setting above" +static_assert(Action::NUM_VALUES == 5, "give every axis action a -1 entry below"); +int Joy_axis_dead_zone_size[Action::NUM_VALUES] = {-1, -1, -1, -1, -1}; +int Joy_axis_sensitivity[Action::NUM_VALUES] = {-1, -1, -1, -1, -1}; + +static SCP_string axis_setting_display(const int& val) +{ + return val < 0 ? SCP_string("Use Global") : std::to_string(val); +} + +static std::shared_ptr> +make_axis_option(const char* key, const char* title, const char* description, int max, int* dest, int importance) +{ + return options::OptionBuilder(key, + std::pair{title, -1}, + std::pair{description, -1}) + .category(std::make_pair("Input", 1827)) + .range(-1, max) + .level(options::ExpertLevel::Advanced) + .default_val(-1) + .display(axis_setting_display) + .flags({options::OptionFlags::RangeTypeInteger}) + .bind_to(dest) + .importance(importance) + .finish(); +} + +// coverity[GLOBAL_INIT_ORDER] -- safe; OptionBuilder::finish() uses Meyers singleton +static auto HeadingDeadZoneOption __UNUSED = make_axis_option("Input.HeadingDeadZone", + "Heading Deadzone", + "The deadzone for the axis bound to heading; Use Global follows Deadzone", + 45, + &Joy_axis_dead_zone_size[Action::HEADING], + 10); +// coverity[GLOBAL_INIT_ORDER] -- safe; OptionBuilder::finish() uses Meyers singleton +static auto PitchDeadZoneOption __UNUSED = make_axis_option("Input.PitchDeadZone", + "Pitch Deadzone", + "The deadzone for the axis bound to pitch; Use Global follows Deadzone", + 45, + &Joy_axis_dead_zone_size[Action::PITCH], + 9); +// coverity[GLOBAL_INIT_ORDER] -- safe; OptionBuilder::finish() uses Meyers singleton +static auto BankDeadZoneOption __UNUSED = make_axis_option("Input.BankDeadZone", + "Bank Deadzone", + "The deadzone for the axis bound to bank; Use Global follows Deadzone", + 45, + &Joy_axis_dead_zone_size[Action::BANK], + 8); +// coverity[GLOBAL_INIT_ORDER] -- safe; OptionBuilder::finish() uses Meyers singleton +static auto RelThrottleDeadZoneOption __UNUSED = make_axis_option("Input.RelThrottleDeadZone", + "Relative Throttle Deadzone", + "The deadzone for the axis bound to relative throttle; Use Global follows Deadzone", + 45, + &Joy_axis_dead_zone_size[Action::REL_THROTTLE], + 7); +// coverity[GLOBAL_INIT_ORDER] -- safe; OptionBuilder::finish() uses Meyers singleton +static auto HeadingSensitivityOption __UNUSED = make_axis_option("Input.HeadingSensitivity", + "Heading Sensitivity", + "The sensitivity for the axis bound to heading; Use Global follows Sensitivity", + 9, + &Joy_axis_sensitivity[Action::HEADING], + 6); +// coverity[GLOBAL_INIT_ORDER] -- safe; OptionBuilder::finish() uses Meyers singleton +static auto PitchSensitivityOption __UNUSED = make_axis_option("Input.PitchSensitivity", + "Pitch Sensitivity", + "The sensitivity for the axis bound to pitch; Use Global follows Sensitivity", + 9, + &Joy_axis_sensitivity[Action::PITCH], + 5); +// coverity[GLOBAL_INIT_ORDER] -- safe; OptionBuilder::finish() uses Meyers singleton +static auto BankSensitivityOption __UNUSED = make_axis_option("Input.BankSensitivity", + "Bank Sensitivity", + "The sensitivity for the axis bound to bank; Use Global follows Sensitivity", + 9, + &Joy_axis_sensitivity[Action::BANK], + 4); +// coverity[GLOBAL_INIT_ORDER] -- safe; OptionBuilder::finish() uses Meyers singleton +static auto RelThrottleSensitivityOption __UNUSED = make_axis_option("Input.RelThrottleSensitivity", + "Relative Throttle Sensitivity", + "The sensitivity for the axis bound to relative throttle; Use Global follows Sensitivity", + 9, + &Joy_axis_sensitivity[Action::REL_THROTTLE], + 3); + //! arrays which hold the key mappings. The array index represents a key-independent action. //! please use SPACES for aligning the fields of this array //! When adding new controls, order that they show up is dependant on their location in IoActionId, not on their hardcoded locations here diff --git a/test/src/controlconfig/test_joy_scaling.cpp b/test/src/controlconfig/test_joy_scaling.cpp new file mode 100644 index 00000000000..73ced1bd175 --- /dev/null +++ b/test/src/controlconfig/test_joy_scaling.cpp @@ -0,0 +1,177 @@ +#include "cmdline/cmdline.h" +#include "controlconfig/controlsconfig.h" +#include "io/joy.h" +#include "options/Option.h" +#include "options/OptionsManager.h" + +#include + +// not in a header; defined in controlsconfig.cpp +void scale_invert(const CC_bind& bind, + int action, + CC_type type, + float frame_time, + int (&axis_in)[CID_JOY_MAX + 1][JOY_NUM_AXES], + int* axis_out); + +class JoyScalingTest : public ::testing::Test { + protected: + void SetUp() override + { + saved_dead_zone = Joy_dead_zone_size; + saved_sensitivity = Joy_sensitivity; + saved_cmdline_dead_zone = Cmdline_deadzone; + for (int i = 0; i < Action::NUM_VALUES; ++i) { + saved_axis_dead_zone[i] = Joy_axis_dead_zone_size[i]; + saved_axis_sensitivity[i] = Joy_axis_sensitivity[i]; + Joy_axis_dead_zone_size[i] = -1; + Joy_axis_sensitivity[i] = -1; + } + Joy_dead_zone_size = 10; + Joy_sensitivity = 9; + Cmdline_deadzone = -1; + } + + void TearDown() override + { + // also here, so a failed assertion can't leave an override behind for later tests + options::OptionsManager::instance()->clearOverrides(); + Joy_dead_zone_size = saved_dead_zone; + Joy_sensitivity = saved_sensitivity; + Cmdline_deadzone = saved_cmdline_dead_zone; + for (int i = 0; i < Action::NUM_VALUES; ++i) { + Joy_axis_dead_zone_size[i] = saved_axis_dead_zone[i]; + Joy_axis_sensitivity[i] = saved_axis_sensitivity[i]; + } + } + + int saved_dead_zone = 0; + int saved_sensitivity = 0; + int saved_cmdline_dead_zone = 0; + int saved_axis_dead_zone[Action::NUM_VALUES] = {}; + int saved_axis_sensitivity[Action::NUM_VALUES] = {}; +}; + +// 10% of the axis range is 6553 units, so 5000 off center sits inside the global deadzone and 5% (3276) is not. +TEST_F(JoyScalingTest, AxisDeadZoneOverridesGlobal) +{ + const int raw = JOY_AXIS_CENTER + 5000; + ASSERT_EQ(joy_get_scaled_reading(raw), 0); + ASSERT_EQ(joy_get_scaled_reading(raw, Action::BANK), 0); + + Joy_axis_dead_zone_size[Action::BANK] = 5; + ASSERT_GT(joy_get_scaled_reading(raw, Action::BANK), 0); + ASSERT_EQ(joy_get_scaled_reading(raw, Action::HEADING), 0); + ASSERT_EQ(joy_get_scaled_reading(raw), 0); + + Joy_axis_dead_zone_size[Action::HEADING] = 0; + ASSERT_GT(joy_get_scaled_reading(JOY_AXIS_CENTER + 1, Action::HEADING), 0); +} + +TEST_F(JoyScalingTest, AxisDeadZoneWinsOverCommandLine) +{ + const int raw = JOY_AXIS_CENTER + 5000; + Cmdline_deadzone = 0; + ASSERT_GT(joy_get_scaled_reading(raw, Action::PITCH), 0); + + Joy_axis_dead_zone_size[Action::PITCH] = 20; + ASSERT_EQ(joy_get_scaled_reading(raw, Action::PITCH), 0); +} + +TEST_F(JoyScalingTest, AxisSensitivityOverridesGlobal) +{ + const int raw = JOY_AXIS_CENTER + JOY_AXIS_RANGE / 2; + const int global = joy_get_scaled_reading(raw, Action::PITCH); + + Joy_axis_sensitivity[Action::PITCH] = 0; + const int low = joy_get_scaled_reading(raw, Action::PITCH); + ASSERT_LT(low, global); + ASSERT_EQ(joy_get_scaled_reading(raw, Action::HEADING), global); + + Joy_sensitivity = 0; + ASSERT_EQ(joy_get_scaled_reading(raw), low); + Joy_axis_sensitivity[Action::PITCH] = 9; + ASSERT_EQ(joy_get_scaled_reading(raw, Action::PITCH), global); +} + +TEST_F(JoyScalingTest, NegativeDeflectionIsSymmetric) +{ + Joy_axis_dead_zone_size[Action::BANK] = 5; + Joy_axis_sensitivity[Action::BANK] = 4; + const int offset = JOY_AXIS_RANGE / 3; + ASSERT_EQ(joy_get_scaled_reading(JOY_AXIS_CENTER - offset, Action::BANK), + -joy_get_scaled_reading(JOY_AXIS_CENTER + offset, Action::BANK)); +} + +TEST_F(JoyScalingTest, AxisDeadZoneUsesTheGlobalScale) +{ + for (int dz : {0, 10, 20, 45}) { + for (int offset : {1000, 5000, 9000, 15000, 30000}) { + Joy_dead_zone_size = dz; + const int global = joy_get_scaled_reading(JOY_AXIS_CENTER + offset); + Joy_dead_zone_size = 10; + Joy_axis_dead_zone_size[Action::HEADING] = dz; + ASSERT_EQ(joy_get_scaled_reading(JOY_AXIS_CENTER + offset, Action::HEADING), global) + << "deadzone " << dz << ", offset " << offset; + Joy_axis_dead_zone_size[Action::HEADING] = -1; + } + } +} + +TEST_F(JoyScalingTest, ActionsOutsideTheRangeUseTheGlobalSettings) +{ + for (int i = 0; i < Action::NUM_VALUES; ++i) { + Joy_axis_dead_zone_size[i] = 0; + Joy_axis_sensitivity[i] = 0; + } + const int raw = JOY_AXIS_CENTER + 5000; + ASSERT_EQ(joy_get_scaled_reading(raw, -1), 0); + ASSERT_EQ(joy_get_scaled_reading(raw, Action::NUM_VALUES), 0); + ASSERT_GT(joy_get_scaled_reading(raw, Action::NUM_VALUES - 1), 0); +} + +TEST_F(JoyScalingTest, BoundAxisUsesItsActionSettings) +{ + int axis_in[CID_JOY_MAX + 1][JOY_NUM_AXES] = {}; + int axis_out[Action::NUM_VALUES] = {}; + axis_in[CID_JOY1][JOY_X_AXIS] = JOY_AXIS_CENTER + 5000; + const CC_bind bind(CID_JOY1, JOY_X_AXIS); + + scale_invert(bind, Action::BANK, CC_TYPE_AXIS_REL, 0.016f, axis_in, axis_out); + ASSERT_EQ(axis_out[Action::BANK], 0); + + Joy_axis_dead_zone_size[Action::BANK] = 5; + scale_invert(bind, Action::BANK, CC_TYPE_AXIS_REL, 0.016f, axis_in, axis_out); + ASSERT_GT(axis_out[Action::BANK], 0); +} + +TEST_F(JoyScalingTest, OptionsBindToTheirOwnAction) +{ + const struct { + const char* key; + int* dest; + } options_to_check[] = { + {"Input.HeadingDeadZone", &Joy_axis_dead_zone_size[Action::HEADING]}, + {"Input.PitchDeadZone", &Joy_axis_dead_zone_size[Action::PITCH]}, + {"Input.BankDeadZone", &Joy_axis_dead_zone_size[Action::BANK]}, + {"Input.RelThrottleDeadZone", &Joy_axis_dead_zone_size[Action::REL_THROTTLE]}, + {"Input.HeadingSensitivity", &Joy_axis_sensitivity[Action::HEADING]}, + {"Input.PitchSensitivity", &Joy_axis_sensitivity[Action::PITCH]}, + {"Input.BankSensitivity", &Joy_axis_sensitivity[Action::BANK]}, + {"Input.RelThrottleSensitivity", &Joy_axis_sensitivity[Action::REL_THROTTLE]}, + }; + auto* mgr = options::OptionsManager::instance(); + for (const auto& entry : options_to_check) { + auto* opt = mgr->getOptionByKey(entry.key); + ASSERT_NE(opt, nullptr) << entry.key; + ASSERT_TRUE(opt->getFlags()[options::OptionFlags::RangeTypeInteger]) << entry.key; + + mgr->setOverride(entry.key, "7", "test"); + opt->loadInitial(); + for (const auto& other : options_to_check) { + ASSERT_EQ(*other.dest, other.dest == entry.dest ? 7 : -1) << entry.key << " set " << other.key; + } + mgr->clearOverrides(); + *entry.dest = -1; + } +} diff --git a/test/src/source_groups.cmake b/test/src/source_groups.cmake index 1c268dda86e..1ea89b6e821 100644 --- a/test/src/source_groups.cmake +++ b/test/src/source_groups.cmake @@ -17,6 +17,10 @@ add_file_folder("CFile" cfile/cfile.cpp ) +add_file_folder("ControlConfig" + controlconfig/test_joy_scaling.cpp +) + add_file_folder("Globalincs" globalincs/test_flagset.cpp globalincs/test_safe_strings.cpp