From af8d8dae9fd10b7820e13f54f9d58e93bfda195a Mon Sep 17 00:00:00 2001 From: bicarus <202771338+bicarus-dev@users.noreply.github.com> Date: Fri, 1 May 2026 19:02:32 -0700 Subject: [PATCH] rawinput: distinguish between circular and linear analog input (#665) ## Link to GitHub Issue or related Pull Request, if one exists n/a ## Description of change Currently, all analogs are treated as circular values that wrap around. This works for knobs and turntables, but can get weird for linear values (like tilt sensors and sliders) especially once analog options are enabled, such as sensitivity. This PR groups analogs into 3 buckets: circular, linear (centered), and linear (positive). Linear types get different algorithms applied to it when options are set (e.g., sensitivity caps out at 0, 1 instead of wrapping around). In linear mode: * Sensitivity value applies an exponential response curve (power curve) to the original value. * Multiplier/divisor serves as a linear cut-off. * Relative mode works as you expect, but of course, values don't wrap around. * Invert and deadzone work as you expect (since they applied to the controller input anyway) * Smoothing is removed from the UI and has no effect. * Delay is unaffected. Also, fix a small bug with -/+ button not working for analog `Delay` option. ## Testing WIP --- src/spice2x/cfg/analog.cpp | 44 +++++++++---- src/spice2x/cfg/analog.h | 29 ++++++++- src/spice2x/cfg/api.cpp | 89 +++++++++++++++++++++++++- src/spice2x/cfg/api.h | 15 +++-- src/spice2x/games/bc/io.cpp | 11 ++-- src/spice2x/games/ccj/io.cpp | 15 +++-- src/spice2x/games/ddr/io.cpp | 15 +++-- src/spice2x/games/ftt/io.cpp | 15 +++-- src/spice2x/games/gitadora/io.cpp | 22 ++++--- src/spice2x/games/iidx/io.cpp | 21 +++--- src/spice2x/games/mga/io.cpp | 10 +-- src/spice2x/games/nost/io.cpp | 63 +++++++++--------- src/spice2x/games/pc/io.cpp | 11 ++-- src/spice2x/games/rf3d/io.cpp | 13 ++-- src/spice2x/games/sc/io.cpp | 15 +++-- src/spice2x/games/silentscope/io.cpp | 11 ++-- src/spice2x/games/we/io.cpp | 15 +++-- src/spice2x/overlay/windows/config.cpp | 76 +++++++++++++--------- 18 files changed, 324 insertions(+), 166 deletions(-) diff --git a/src/spice2x/cfg/analog.cpp b/src/spice2x/cfg/analog.cpp index e8c6ba9..2c3815f 100644 --- a/src/spice2x/cfg/analog.cpp +++ b/src/spice2x/cfg/analog.cpp @@ -207,22 +207,38 @@ float Analog::applyMultiplier(float value) { } float Analog::normalizeAnalogValue(float value) { - // effectively the same as fmodf(value, 1.f) - // for small values, this is MUCH faster than fmodf. - float new_value = value; - while (new_value > 1.f) { - new_value -= 1.f; + if (getType() == GameAPI::Analogs::AnalogType::Circular) { + // effectively the same as fmodf(value, 1.f) + // for small values, this is MUCH faster than fmodf. + float new_value = value; + while (new_value > 1.f) { + new_value -= 1.f; + } + while (new_value < 0.f) { + new_value += 1.f; + } + return new_value; + + } else { + // clamp to [0, 1] range + return std::clamp(value, 0.f, 1.f); } - while (new_value < 0.f) { - new_value += 1.f; - } - return new_value; } float Analog::applyDeadzone(float raw_value) { float value = raw_value; - const auto deadzone = this->getDeadzone(); - if (deadzone > 0) { + auto deadzone = this->getDeadzone(); + + // in the past, positive deadzone applied in the center, negative deadzone applied to 0 + // after each analog value received a type (circular/linear) this has been simpliifed to + // positive values only since we can figure out where the rest value is + // for back compat, treat negative value as positive + if (deadzone < 0.f) { + deadzone = -deadzone; + } + + // relative mode assumes that user is using a stick, so center is neutral regardless of analog type + if (getType() != GameAPI::Analogs::AnalogType::LinearPositive || isRelativeMode()) { // calculate values const auto delta = value - 0.5f; @@ -255,7 +271,7 @@ float Analog::applyDeadzone(float raw_value) { } } - } else if (deadzone < 0) { + } else { // invert for mirror if (this->getDeadzoneMirror()) { @@ -263,8 +279,8 @@ float Analog::applyDeadzone(float raw_value) { } // deadzone from minimum value - if (deadzone > -1 && value > -deadzone) { - value = std::min(1.f, (value + deadzone) / (1.f + deadzone)); + if (deadzone < 1.f && deadzone < value) { + value = std::max(0.f, (value - deadzone) / (1.f - deadzone)); } else { value = 0.f; } diff --git a/src/spice2x/cfg/analog.h b/src/spice2x/cfg/analog.h index b3f4738..4900b4a 100644 --- a/src/spice2x/cfg/analog.h +++ b/src/spice2x/cfg/analog.h @@ -13,6 +13,21 @@ namespace rawinput { class RawInputManager; } +namespace GameAPI::Analogs { + enum class AnalogType { + // default; values wrap around (below 0 turns into 1, over 1 is 0) + // knobs, turntables + Circular = 0, + + // typical joystick that rests at the center and caps at [0, 1] + LinearCentered = 1, + + // one-directional value (sliders and instruments like piano/drum velocity) + // starts at 0 and goes up to 1 + LinearPositive = 2, + }; +} + struct AnalogMovingAverage { double time_in_ms; float sine; @@ -31,10 +46,11 @@ private: float last_state = 0.5f; bool sensitivity_set = false; bool deadzone_set = false; + GameAPI::Analogs::AnalogType type = GameAPI::Analogs::AnalogType::Circular; // smoothing function bool smoothing = false; - std::array vector_history; + std::array vector_history = {}; int vector_history_index = 0; float smoothed_last_state = 0.f; @@ -66,7 +82,8 @@ public: float override_state = 0.5f; explicit Analog(std::string name) : name(std::move(name)) { - vector_history.fill({0.0, 0.f, 0.f}); + }; + explicit Analog(std::string name, GameAPI::Analogs::AnalogType type) : name(std::move(name)), type(type) { }; std::string getDisplayString(rawinput::RawInputManager* manager); @@ -214,4 +231,12 @@ public: inline std::queue &getDelayBuffer() { return this->delay_buffer; } + + inline GameAPI::Analogs::AnalogType getType() const { + return this->type; + } + + inline void setType(GameAPI::Analogs::AnalogType type) { + this->type = type; + } }; diff --git a/src/spice2x/cfg/api.cpp b/src/spice2x/cfg/api.cpp index 0961f2e..6399f9a 100644 --- a/src/spice2x/cfg/api.cpp +++ b/src/spice2x/cfg/api.cpp @@ -679,7 +679,9 @@ float GameAPI::Analogs::getState(rawinput::RawInputManager *manager, rawinput::D } // deadzone - if (analog.isDeadzoneSet()) { + // do not apply deadzone to circular analogs since it doesn't make sense (except in relative mode) + if (analog.isDeadzoneSet() && + (analog.getType() != AnalogType::Circular || analog.isRelativeMode())) { value = analog.applyDeadzone(value); } @@ -704,7 +706,7 @@ float GameAPI::Analogs::getState(rawinput::RawInputManager *manager, rawinput::D // translate relative movement to absolute value value = analog.getAbsoluteValue(relative_delta); - } else { + } else if (analog.getType() == AnalogType::Circular) { // integer multiplier value = analog.applyMultiplier(value); @@ -732,6 +734,53 @@ float GameAPI::Analogs::getState(rawinput::RawInputManager *manager, rawinput::D // apply to value value = rads * (float) M_1_TAU; } + } else { + // sensitivity + if (analog.isSensitivitySet()) { + // adjust curve + // values < 1.f : less sensitive around neutral + // values > 1.f : more sensitive around neutral + float curve = analog.getSensitivity(); + if (curve <= 0.f) { + curve = 0.01f; + } + curve = 1.f / curve; + + if (analog.getType() == AnalogType::LinearCentered) { + // convert 0.0..1.0 to -1.0..+1.0 + float signed_raw = (value - 0.5f) * 2.0f; + // apply curve + float sign = signed_raw < 0.0f ? -1.0f : 1.0f; + float magnitude = fabsf(signed_raw); + float curved = sign * powf(magnitude, curve); + // convert back to 0.0..1.0 + value = curved * 0.5f + 0.5f; + } else { + value = powf(value, curve); + } + + value = std::clamp(value, 0.f, 1.f); + } + + // multiplier / divisor + if (analog.getMultiplier() < -1) { + if (analog.getType() == AnalogType::LinearCentered) { + value = (value - 0.5f) / (-analog.getMultiplier()) + 0.5f; + } else { + value /= -analog.getMultiplier(); + } + + value = std::clamp(value, 0.f, 1.f); + + } else if (analog.getMultiplier() > 1) { + if (analog.getType() == AnalogType::LinearCentered) { + value = (value - 0.5f) * analog.getMultiplier() + 0.5f; + } else { + value *= analog.getMultiplier(); + } + + value = std::clamp(value, 0.f, 1.f); + } } // delay @@ -839,6 +888,42 @@ std::vector GameAPI::Analogs::sortAnalogs( return sorted; } +static std::vector sortAnalogsWithTypeInternal( + std::vector &analogs, + const std::initializer_list list) { + + std::vector sorted; + + bool analog_found; + for (auto &a : list) { + analog_found = false; + + for (auto &analog : analogs) { + if (a.name == analog.getName()) { + analog_found = true; + analog.setType(a.type); + sorted.push_back(analog); + break; + } + } + + if (!analog_found) { + sorted.emplace_back(a.name, a.type); + } + } + + return sorted; +} + +void GameAPI::Analogs::sortAnalogsWithType( + std::vector *analogs, + const std::initializer_list list) { + + if (analogs) { + *analogs = sortAnalogsWithTypeInternal(*analogs, list); + } +} + float GameAPI::Analogs::getState(rawinput::RawInputManager *manager, Analog &analog) { // check override diff --git a/src/spice2x/cfg/api.h b/src/spice2x/cfg/api.h index e8ed32f..ea3960f 100644 --- a/src/spice2x/cfg/api.h +++ b/src/spice2x/cfg/api.h @@ -85,14 +85,15 @@ namespace GameAPI { const std::vector &analogs, const std::vector &analog_names); - template - void sortAnalogs(std::vector *analogs, T t) { - const std::vector analog_names { t }; + struct AnalogWithType { + const std::string name; + const AnalogType type; - if (analogs) { - *analogs = GameAPI::Analogs::sortAnalogs(*analogs, analog_names); - } - } + AnalogWithType(std::string name, AnalogType type) : + name(std::move(name)), type(type) {} + }; + + void sortAnalogsWithType(std::vector *analogs, const std::initializer_list list); template void sortAnalogs(std::vector *analogs, T t, Rest... rest) { diff --git a/src/spice2x/games/bc/io.cpp b/src/spice2x/games/bc/io.cpp index 2843009..ffbf6d0 100644 --- a/src/spice2x/games/bc/io.cpp +++ b/src/spice2x/games/bc/io.cpp @@ -38,11 +38,12 @@ std::vector &games::bc::get_analogs() { if (analogs.empty()) { analogs = GameAPI::Analogs::getAnalogs("Busou Shinki: Armored Princess Battle Conductor"); - GameAPI::Analogs::sortAnalogs( - &analogs, - "Stick X", - "Stick Y" - ); + using GameAPI::Analogs::AnalogType; + + GameAPI::Analogs::sortAnalogsWithType(&analogs, { + { "Stick X", AnalogType::LinearCentered }, + { "Stick Y", AnalogType::LinearCentered } + }); } return analogs; diff --git a/src/spice2x/games/ccj/io.cpp b/src/spice2x/games/ccj/io.cpp index d3e4abb..18dd715 100644 --- a/src/spice2x/games/ccj/io.cpp +++ b/src/spice2x/games/ccj/io.cpp @@ -45,13 +45,14 @@ std::vector &games::ccj::get_analogs() { if (analogs.empty()) { analogs = GameAPI::Analogs::getAnalogs("Chase Chase Jokers"); - GameAPI::Analogs::sortAnalogs( - &analogs, - "Joystick X", - "Joystick Y", - "Trackball DX", - "Trackball DY" - ); + using GameAPI::Analogs::AnalogType; + + GameAPI::Analogs::sortAnalogsWithType(&analogs, { + { "Joystick X", AnalogType::LinearCentered }, + { "Joystick Y", AnalogType::LinearCentered }, + { "Trackball DX", AnalogType::LinearCentered }, + { "Trackball DY", AnalogType::LinearCentered } + }); } return analogs; diff --git a/src/spice2x/games/ddr/io.cpp b/src/spice2x/games/ddr/io.cpp index 4450497..2bb1e5f 100644 --- a/src/spice2x/games/ddr/io.cpp +++ b/src/spice2x/games/ddr/io.cpp @@ -55,13 +55,14 @@ std::vector &games::ddr::get_analogs() { if (analogs.empty()) { analogs = GameAPI::Analogs::getAnalogs("Dance Dance Revolution"); - GameAPI::Analogs::sortAnalogs( - &analogs, - "P1 Left-Right (Axis Fix)", - "P1 Up-Down (Axis Fix)", - "P2 Left-Right (Axis Fix)", - "P2 Up-Down (Axis Fix)" - ); + using namespace GameAPI::Analogs; + + GameAPI::Analogs::sortAnalogsWithType(&analogs, { + { "P1 Left-Right (Axis Fix)", AnalogType::LinearCentered }, + { "P1 Up-Down (Axis Fix)", AnalogType::LinearCentered }, + { "P2 Left-Right (Axis Fix)", AnalogType::LinearCentered }, + { "P2 Up-Down (Axis Fix)", AnalogType::LinearCentered } + }); } return analogs; } diff --git a/src/spice2x/games/ftt/io.cpp b/src/spice2x/games/ftt/io.cpp index e9af3c9..51dcada 100644 --- a/src/spice2x/games/ftt/io.cpp +++ b/src/spice2x/games/ftt/io.cpp @@ -35,13 +35,14 @@ std::vector &games::ftt::get_analogs() { if (analogs.empty()) { analogs = GameAPI::Analogs::getAnalogs("FutureTomTom"); - GameAPI::Analogs::sortAnalogs( - &analogs, - "Pad 1", - "Pad 2", - "Pad 3", - "Pad 4" - ); + using GameAPI::Analogs::AnalogType; + + GameAPI::Analogs::sortAnalogsWithType(&analogs, { + { "Pad 1", AnalogType::LinearPositive }, + { "Pad 2", AnalogType::LinearPositive }, + { "Pad 3", AnalogType::LinearPositive }, + { "Pad 4", AnalogType::LinearPositive } + }); } return analogs; diff --git a/src/spice2x/games/gitadora/io.cpp b/src/spice2x/games/gitadora/io.cpp index 252b7f8..67a196d 100644 --- a/src/spice2x/games/gitadora/io.cpp +++ b/src/spice2x/games/gitadora/io.cpp @@ -86,19 +86,21 @@ std::vector