Add Steam Input-style advanced deadzone controls to analog calibration

This commit is contained in:
David Cottingham committed 2026-05-16 19:09:25 -10:00
1 parent fa50bb1976
commit 043b0a437c
5 files changed
+198 -10

No files matched your search

+8
View File
@@ -1023,6 +1023,14 @@ static const ConfigSetting controlSettings[] = {
ConfigSetting("AnalogIsCircular", SETTING(g_Config, bAnalogIsCircular), false, CfgFlag::PER_GAME),
ConfigSetting("AnalogAutoRotSpeed", SETTING(g_Config, fAnalogAutoRotSpeed), 8.0f, CfgFlag::PER_GAME),
// Advanced analog deadzone settings.
ConfigSetting("AnalogDeadzoneShape", SETTING(g_Config, iAnalogDeadzoneShape), 1, CfgFlag::PER_GAME), // Default 1 (Square) matches legacy max-norm behavior
ConfigSetting("AnalogAxialDeadzone", SETTING(g_Config, fAnalogAxialDeadzone), 0.0f, CfgFlag::PER_GAME),
ConfigSetting("AnalogOuterDeadzone", SETTING(g_Config, fAnalogOuterDeadzone), 0.0f, CfgFlag::PER_GAME),
ConfigSetting("AnalogOutputAntiDeadzone", SETTING(g_Config, fAnalogOutputAntiDeadzone), 0.0f, CfgFlag::PER_GAME),
ConfigSetting("AnalogOutputAntiDeadzoneBuffer", SETTING(g_Config, fAnalogOutputAntiDeadzoneBuffer), 0.0f, CfgFlag::PER_GAME),
ConfigSetting("AnalogResponseCurve", SETTING(g_Config, iAnalogResponseCurve), 0, CfgFlag::PER_GAME),
ConfigSetting("AnalogLimiterDeadzone", SETTING(g_Config, fAnalogLimiterDeadzone), 0.6f, CfgFlag::DEFAULT),
ConfigSetting("AnalogTriggerThreshold", SETTING(g_Config, fAnalogTriggerThreshold), 0.75f, CfgFlag::DEFAULT),
ConfigSetting("AnalogStickThreshold", SETTING(g_Config, fAnalogStickThreshold), 0.75f, CfgFlag::DEFAULT),
+15
View File
@@ -515,6 +515,21 @@ public:
// Auto rotation speed
float fAnalogAutoRotSpeed;
// Advanced analog deadzone settings (Steam Input-style).
// Deadzone shape: 0 = Circle, 1 = Square (default, matches legacy max-norm), 2 = Cross
int iAnalogDeadzoneShape;
// Cross-shaped axial anti-deadzone. Boosts small off-axis values past this threshold,
// making the output skip the zone near each cardinal axis to prevent axis snapping.
float fAnalogAxialDeadzone;
// Outer deadzone: defines where 100% output is reached. Shrinks the effective stick range.
float fAnalogOuterDeadzone;
// Output anti-deadzone: minimum output floor to bypass game-internal deadzones.
float fAnalogOutputAntiDeadzone;
// Anti-deadzone buffer: re-adds a small safe zone after anti-deadzone is applied.
float fAnalogOutputAntiDeadzoneBuffer;
// Response curve type: 0 = Linear, 1 = Aggressive, 2 = Relaxed, 3 = Wide
int iAnalogResponseCurve;
// Sets up how much the analog limiter button restricts digital->analog input.
float fAnalogLimiterDeadzone;
+84 -8
View File
@@ -105,28 +105,91 @@ static bool IsSignedAxis(int axis) {
}
}
// Apply a response curve to a 0-1 magnitude value.
static float ApplyResponseCurve(float v, int curveType) {
switch (curveType) {
case 1: // Aggressive - fast response, reaches high output quickly
return sqrtf(v);
case 2: // Relaxed - more range devoted to fine/slow movement
return v * v;
case 3: // Wide - even more precision at low end
return v * v * v;
default: // Linear (0) - 1:1 mapping
return v;
}
}
// Apply axial anti-deadzone to a single axis value.
// For non-zero inputs, boosts the output to at least the threshold value.
// This makes the output "skip" the zone near each axis, preventing the stick
// from lingering in the near-cardinal region. Pure cardinal (0.0) is still reachable.
static float ApplyAxialAntiDeadzone(float v, float antiDZ) {
if (antiDZ <= 0.0f || v == 0.0f)
return v;
float sign = v >= 0.0f ? 1.0f : -1.0f;
float absV = fabsf(v);
// Remap (0, 1] -> [antiDZ, 1]: any non-zero input jumps past the anti-deadzone threshold.
float remapped = antiDZ + absV * (1.0f - antiDZ);
return sign * Clamp(remapped, 0.0f, 1.0f);
}
// This is applied on the circular radius, not directly on the axes.
// TODO: Share logic with tilt?
// Now includes outer deadzone, response curve, and output anti-deadzone stages.
static float MapAxisValue(float v) {
const float deadzone = g_Config.fAnalogDeadzone;
const float invDeadzone = g_Config.fAnalogInverseDeadzone;
const float sensitivity = g_Config.fAnalogSensitivity;
const float outerDeadzone = g_Config.fAnalogOuterDeadzone;
const float outputAntiDZ = g_Config.fAnalogOutputAntiDeadzone;
const float outputADBuffer = g_Config.fAnalogOutputAntiDeadzoneBuffer;
const int responseCurve = g_Config.iAnalogResponseCurve;
const float sign = v >= 0.0f ? 1.0f : -1.0f;
// Apply deadzone.
v = Clamp((fabsf(v) - deadzone) / (1.0f - deadzone), 0.0f, 1.0f);
float absV = fabsf(v);
// Apply sensitivity and inverse deadzone.
if (v != 0.0f) {
v = Clamp(invDeadzone + v * (sensitivity - invDeadzone), 0.0f, 1.0f);
// Stage 1: Apply inner deadzone and rescale to [0, 1].
// The effective range is [deadzone, 1 - outerDeadzone].
float effectiveMax = 1.0f - outerDeadzone;
float effectiveRange = effectiveMax - deadzone;
if (effectiveRange <= 0.0f) {
// Degenerate case: deadzone + outerDeadzone >= 1.0. Output is either 0 or 1.
absV = (absV > deadzone) ? 1.0f : 0.0f;
} else {
absV = Clamp((absV - deadzone) / effectiveRange, 0.0f, 1.0f);
}
return sign * v;
// Stage 2: Apply sensitivity (legacy, works the same as before when new settings are at defaults).
if (absV != 0.0f) {
absV = Clamp(invDeadzone + absV * (sensitivity - invDeadzone), 0.0f, 1.0f);
}
// Stage 3: Apply response curve.
if (absV != 0.0f) {
absV = ApplyResponseCurve(absV, responseCurve);
}
// Stage 4: Apply output anti-deadzone with buffer.
// Anti-deadzone sets a minimum output floor so that even the smallest
// stick input past the deadzone produces enough signal to overcome
// game-internal deadzones. The buffer re-adds a small safe zone so
// resting your thumb on the stick doesn't cause unintended drift.
if (absV != 0.0f && outputAntiDZ > 0.0f) {
// Remap [0, 1] -> [outputAntiDZ, 1]
absV = outputAntiDZ + absV * (1.0f - outputAntiDZ);
}
if (outputADBuffer > 0.0f && absV > 0.0f && absV < outputAntiDZ + outputADBuffer) {
// Within the buffer zone past the anti-deadzone floor: zero it out.
absV = 0.0f;
}
return sign * Clamp(absV, 0.0f, 1.0f);
}
void ConvertAnalogStick(float x, float y, float *outX, float *outY) {
const bool isCircular = g_Config.bAnalogIsCircular;
const int deadzoneShape = g_Config.iAnalogDeadzoneShape;
const float axialDZ = g_Config.fAnalogAxialDeadzone;
float norm = std::max(fabsf(x), fabsf(y));
if (norm == 0.0f) {
@@ -135,17 +198,30 @@ void ConvertAnalogStick(float x, float y, float *outX, float *outY) {
return;
}
if (isCircular) {
if (isCircular || deadzoneShape == 0) {
// Circle shape or legacy circular mode: use Euclidean norm.
float newNorm = sqrtf(x * x + y * y);
float factor = newNorm / norm;
x *= factor;
y *= factor;
norm = newNorm;
}
// deadzoneShape == 1 (Square) uses max norm (the default path, no conversion needed).
// deadzoneShape == 2 (Cross) also uses max norm for the radial processing.
float mappedNorm = MapAxisValue(norm);
*outX = Clamp(x / norm * mappedNorm, -1.0f, 1.0f);
*outY = Clamp(y / norm * mappedNorm, -1.0f, 1.0f);
// Final stage: Apply cross-shaped axial anti-deadzone.
// This boosts small non-zero axis values past the threshold, making the output
// "skip" the zone near each cardinal axis. This prevents the stick from lingering
// near cardinals and opens up the full diagonal range.
// Pure cardinals (0.0 on an axis) are still reachable.
if (deadzoneShape == 2 && axialDZ > 0.0f) {
*outX = ApplyAxialAntiDeadzone(*outX, axialDZ);
*outY = ApplyAxialAntiDeadzone(*outY, axialDZ);
}
}
void ControlMapper::SetPSPAxis(int device, int stick, char axis, float value) {
+34 -2
View File
@@ -551,9 +551,34 @@ void AnalogCalibrationScreen::CreateSettingsViews(UI::ViewGroup *scrollContents)
scrollContents->Add(new PopupSliderChoiceFloat(&g_Config.fAnalogDeadzone, 0.0f, 0.5f, 0.15f, co->T("Deadzone radius"), 0.01f, screenManager(), "/ 1.0"));
scrollContents->Add(new PopupSliderChoiceFloat(&g_Config.fAnalogInverseDeadzone, 0.0f, 1.0f, 0.0f, co->T("Low end radius"), 0.01f, screenManager(), "/ 1.0"));
scrollContents->Add(new PopupSliderChoiceFloat(&g_Config.fAnalogSensitivity, 0.0f, 2.0f, 1.1f, co->T("Sensitivity (scale)", "Sensitivity"), 0.01f, screenManager(), "x"));
// TODO: This should probably be a slider.
scrollContents->Add(new CheckBox(&g_Config.bAnalogIsCircular, co->T("Circular stick input")));
// Legacy circular toggle. Disabled when deadzone shape is set to Circle, since that already provides circular behavior.
CheckBox *circularCheck = scrollContents->Add(new CheckBox(&g_Config.bAnalogIsCircular, co->T("Circular stick input")));
circularCheck->SetEnabledFunc([] {
return g_Config.iAnalogDeadzoneShape != 0; // Disabled when shape is Circle (redundant)
});
scrollContents->Add(new PopupSliderChoiceFloat(&g_Config.fAnalogAutoRotSpeed, 0.1f, 20.0f, 8.0f, co->T("Auto-rotation speed"), 1.0f, screenManager()));
// Advanced deadzone settings (Steam Input-style).
scrollContents->Add(new ItemHeader(co->T("Advanced Deadzone Settings")));
static const char *deadzoneShapes[] = { "Circle", "Square", "Cross" };
scrollContents->Add(new PopupMultiChoice(&g_Config.iAnalogDeadzoneShape, co->T("Deadzone shape"), deadzoneShapes, 0, ARRAY_SIZE(deadzoneShapes), I18NCat::CONTROLS, screenManager()));
PopupSliderChoiceFloat *axialDZ = scrollContents->Add(new PopupSliderChoiceFloat(&g_Config.fAnalogAxialDeadzone, 0.0f, 0.5f, 0.0f, co->T("Axial anti-deadzone"), 0.01f, screenManager(), "/ 1.0"));
axialDZ->SetEnabledFunc([] {
return g_Config.iAnalogDeadzoneShape == 2; // Only enabled for Cross shape
});
scrollContents->Add(new PopupSliderChoiceFloat(&g_Config.fAnalogOuterDeadzone, 0.0f, 0.3f, 0.0f, co->T("Outer deadzone"), 0.01f, screenManager(), "/ 1.0"));
scrollContents->Add(new PopupSliderChoiceFloat(&g_Config.fAnalogOutputAntiDeadzone, 0.0f, 0.5f, 0.0f, co->T("Output anti-deadzone"), 0.01f, screenManager(), "/ 1.0"));
PopupSliderChoiceFloat *adBuffer = scrollContents->Add(new PopupSliderChoiceFloat(&g_Config.fAnalogOutputAntiDeadzoneBuffer, 0.0f, 0.2f, 0.0f, co->T("Anti-deadzone buffer"), 0.01f, screenManager(), "/ 1.0"));
adBuffer->SetEnabledFunc([] {
return g_Config.fAnalogOutputAntiDeadzone > 0.0f; // Buffer only makes sense with anti-deadzone active
});
static const char *responseCurves[] = { "Linear", "Aggressive", "Relaxed", "Wide" };
scrollContents->Add(new PopupMultiChoice(&g_Config.iAnalogResponseCurve, co->T("Response curve"), responseCurves, 0, ARRAY_SIZE(responseCurves), I18NCat::CONTROLS, screenManager()));
scrollContents->Add(new Choice(co->T("Reset to defaults")))->OnClick.Handle(this, &AnalogCalibrationScreen::OnResetToDefaults);
}
@@ -576,6 +601,13 @@ void AnalogCalibrationScreen::OnResetToDefaults(UI::EventParams &e) {
g_Config.fAnalogSensitivity = 1.1f;
g_Config.bAnalogIsCircular = false;
g_Config.fAnalogAutoRotSpeed = 8.0f;
// Advanced settings
g_Config.iAnalogDeadzoneShape = 1; // Square (matches legacy default)
g_Config.fAnalogAxialDeadzone = 0.0f;
g_Config.fAnalogOuterDeadzone = 0.0f;
g_Config.fAnalogOutputAntiDeadzone = 0.0f;
g_Config.fAnalogOutputAntiDeadzoneBuffer = 0.0f;
g_Config.iAnalogResponseCurve = 0;
}
class Backplate : public UI::InertView {
+57
View File
@@ -1,10 +1,13 @@
#include <algorithm>
#include <cmath>
#include "UI/JoystickHistoryView.h"
#include "Common/Math/math_util.h"
#include "Common/UI/Context.h"
#include "Common/UI/UI.h"
#include "Core/Config.h"
#include "Core/ControlMapper.h"
void JoystickHistoryView::Draw(UIContext &dc) {
@@ -20,6 +23,22 @@ void JoystickHistoryView::Draw(UIContext &dc) {
dc.Flush();
dc.BeginNoTex();
dc.Draw()->RectOutline(bounds_.centerX() - minRadius, bounds_.centerY() - minRadius, minRadius * 2.0f, minRadius * 2.0f, 0x80FFFFFF);
// Draw cross-shaped deadzone strip outlines on the raw input view (untextured geometry).
if (type_ == StickHistoryViewType::INPUT) {
float cx = bounds_.centerX();
float cy = bounds_.centerY();
// Cross-shaped (axial) deadzone strips.
if (g_Config.iAnalogDeadzoneShape == 2 && g_Config.fAnalogAxialDeadzone > 0.0f) {
float axialW = g_Config.fAnalogAxialDeadzone * minRadius;
// Vertical strip (X axis deadzone — zeroes X when |x| < threshold).
dc.Draw()->RectOutline(cx - axialW, cy - minRadius, axialW * 2.0f, minRadius * 2.0f, 0x604488FF);
// Horizontal strip (Y axis deadzone — zeroes Y when |y| < threshold).
dc.Draw()->RectOutline(cx - minRadius, cy - axialW, minRadius * 2.0f, axialW * 2.0f, 0x604488FF);
}
}
dc.Flush();
dc.Begin();
@@ -74,6 +93,44 @@ void JoystickHistoryView::Draw(UIContext &dc) {
}
// Draw circular deadzone overlays on the raw input view (on top of the grid, textured context).
if (type_ == StickHistoryViewType::INPUT) {
float cx = bounds_.centerX();
float cy = bounds_.centerY();
// Inner deadzone circle.
float innerDZ = g_Config.fAnalogDeadzone;
if (innerDZ > 0.0f) {
float innerR = innerDZ * minRadius;
const int segments = 32;
for (int i = 0; i < segments; i++) {
float a1 = (float)i / (float)segments * 2.0f * (float)M_PI;
float a2 = (float)(i + 1) / (float)segments * 2.0f * (float)M_PI;
float x1 = cx + cosf(a1) * innerR;
float y1 = cy + sinf(a1) * innerR;
float x2 = cx + cosf(a2) * innerR;
float y2 = cy + sinf(a2) * innerR;
dc.Draw()->Line(dc.GetTheme().whiteImage, x1, y1, x2, y2, 1.5f, 0x60FF6666);
}
}
// Outer deadzone ring.
float outerDZ = g_Config.fAnalogOuterDeadzone;
if (outerDZ > 0.0f) {
float outerR = (1.0f - outerDZ) * minRadius;
const int segments = 32;
for (int i = 0; i < segments; i++) {
float a1 = (float)i / (float)segments * 2.0f * (float)M_PI;
float a2 = (float)(i + 1) / (float)segments * 2.0f * (float)M_PI;
float x1 = cx + cosf(a1) * outerR;
float y1 = cy + sinf(a1) * outerR;
float x2 = cx + cosf(a2) * outerR;
float y2 = cy + sinf(a2) * outerR;
dc.Draw()->Line(dc.GetTheme().whiteImage, x1, y1, x2, y2, 1.5f, 0x6066FF66);
}
}
}
int a = maxCount_ - (int)locations_.size();
for (auto iter = locations_.begin(); iter != locations_.end(); ++iter) {
float x = bounds_.centerX() + minRadius * iter->x;