XInput values have increasingly large error as they approach the analog midpoint
Expectation:
The intended zero point value of 128 should produce an XInput value of zero, and Input values of equivalent distance from the zero point should produce XInput values of equivalent magnitude.
128 -> 0 128 + X -> XInputValue 128 - X -> -XInputValue
Problem:
The existing calculation for converting the uint_8 input from 0 : 255 to -32768 : 32767 does not properly map the intended zero point value of 128 to (0, 0) on the resulting analog stick, and values of equivalent distance from the zero point have an magnitude difference that increases as the distance decreases.
This is the current code
_report.lx = (_outputs.leftStickX - 128) * 65535 / 255 + 128;
This simplifies to
_report.lx = (_outputs.leftStickX) * 257 - 32768;
This follows the slope-intercept form of defining a line, y = mx + b
This calculation maximizes the range of 0 : 255 to across the full range, from -32768 to 32767, with only a small magnitude difference 0 * 257 - 32768 = -32768 255 * 257 - 32768 = 32767
However, as the input values approach the zero point 128, this calculation results magnitude difference of 256 between the two points closest to the zero point 127 * 257 - 32768 = -129 129 * 257 - 32768 = 385
Solution:
Instead of prioritizing hitting the maximum range, the values can be evenly balanced and also have an x-intercept of zero. y = 258x - 33024
The code values can be adjusted like so:
_report.lx = (_outputs.leftStickX) * 258 - 33024;
The magnitude difference for each value of equal distance from the zero point becomes zero, including the zero point itself. The tradeoff is that the calculation becomes constrained from converting 1 : 255 to -32766 : 32766.
Restricting the input minimum value to 1 instead of 0 actually balances out the positive and negative value ranges, because there are now an equivalent (127) number of integers on each side, instead of 128 negative values (0 to 127) and 127 positive values (129 to 255).
Magnitude becomes zero at the zero point 128 * 258 - 33024 = 0
Magnitudes are matched near the zero point 127 * 258 - 33024 = -258 129 * 258 - 33024 = 258
Magnitudes are matched at the maximum points 1 * 258 - 33024 = -32766 255 * 258 - 33024 = 32766
Implementation Note:
This code works seamlessly with any existing modes, with the requirement that the minimum analog value must be restricted to 1, instead of 0 as some modes have occasionally been configured. This value loss is inconsequential, as it only serves to match the negative range resolution to the positive range resolution.
This issue can also be seen in a totally different repo's XInput behavior, and there appears to be one more issue to workaround, which is how the positive and negative ranges that XInput expects are of inherently different sizes and thus divides by different resolutions, so simply making the magnitude of each half equal is not sufficient to ensure that the interpreted value is equal. https://github.com/Ryochan7/DS4Windows/issues/1690
Thanks for this. Would you mind clarifying what specific issue this was causing you in which game(s)?
The formula used currently was intended to ensure that coordinates come through as expected in Melee in Slippi Dolphin (Dolphin scaling is a little weird), which is my main priority when it comes to things behaving in a consistent way. Can you confirm whether or not your PR #89 has any impact on this functionality?
@JonnyHaystack Can you share some documentation defining how Dolphin scaling works for XInput, and if the resulting values from Dolphin scaling can be seen in detail for testing? My assumption is that positive values are divided by 32767 and negative values are divided by 32768 to produce the fractional distance to the max coordinates, and that 0 in -> 0 out.
The issue that led to me discovering the calculation offset was found while playing NASB2. I tried to use a downward diagonal attack and it produced different results for down+left vs down+right. This is because the diagonals were placed directly on the 45° line, and the micro error from this calculation offset resulted in two different quadrants being selected. It's likely best practice to avoid this by not situating outputted coordinates on the border line between action quadrants, but it's still proof that the existing calculation can result in asymmetrical actions for +/- the same intended offset.
As for Slippi/how the new coordinates compare, this can be shown to not impact existing symmetrical functionality (e.g. if ModX + Left and ModX + Right both produce walk, they will both still produce walk). This is proven by comparing the new values to the min/max magnitudes produced from the old calculation. If the old min value produces the same action as the old max value, then any value residing between min/max will also produce this action due to continuity.
I've written a small program to perform and record calculations of all input values for each calculation method. I've outputted the results into this spreadsheet with an accompanying graph.
The values in the graph are scaled against the max magnitude with the fractional min value represented by blue. It can be seen that for all values 1 <-> 127 the new values are in between the existing min/max magnitudes, and thus produce unchanged actions with more symmetrical coordinates. The only outlier is offset 0 at the center, which does not have a corresponding magnitude to compare against (negative 0 still produces the same value), and should output 0 as a corollary of the intended scaling system.
https://docs.google.com/spreadsheets/d/1Y-YCT7YZziHwFtMXhRfq6rMPw2tXklJYUNaOxbOuEoE/edit?usp=sharing
// Online C++ compiler to run C++ program online
#include <iostream>
#include <stdint.h>
// original scaling function
int16_t OldScaleValue(uint8_t input) {
return (input - 128) * 65535 / 255 + 128;
}
// new scaling function under test
int16_t ScaleValue(uint8_t input) {
// 128 -> 0
// 1 -> -32768
// 255 -> 32767
const uint8_t lowThreshold = 63;
const uint8_t highThreshold = 126;
int8_t offset = 0;
if (input < 128 - highThreshold) {
offset = -2;
} else if (input < 128 - lowThreshold) {
offset = -1;
} else if (input > 128 + highThreshold) {
offset = 1;
}
return input * 258 - 33024 + offset;
}
// calculates the fraction of the corresponding +/- range
float GenerateFloatValue(int16_t input) {
return (float) input / (float) (input >= 0 ? 32767 : -32768);
}
bool CheckBounds(float input, float min, float max) {
return input >= min && input <= max;
}
void CalculateBounds(uint8_t offset, int16_t oldNegative, int16_t oldPositive, int16_t negative, int16_t positive) {
// +/- x under old scaling produced +/- y with different float values
// ensure that new scaling produces less variance than old
float floatOldNegative = GenerateFloatValue(oldNegative);
float floatOldPositive = GenerateFloatValue(oldPositive);
float minValue = std::min(floatOldNegative, floatOldPositive);
float maxValue = std::max(floatOldNegative, floatOldPositive);
float floatNegative = GenerateFloatValue(negative);
float floatPositive = GenerateFloatValue(positive);
float minFraction = minValue / maxValue;
float negativeFraction = floatNegative / maxValue;
float positiveFraction = floatPositive / maxValue;
// divide values by maxValue to determine lower bound and test values
std::cout << int(offset) << ", " << minFraction << ", " << negativeFraction << ", " << positiveFraction << "\n";
}
void PrintScaledValues(uint8_t offset) {
uint8_t negativeValue = 128 - offset;
uint8_t positiveValue = 128 + offset;
// calculate upper/lower bounds of value allowed by old scaling
int16_t oldScaledNegativeValue = OldScaleValue(negativeValue);
int16_t oldScaledPositiveValue = OldScaleValue(positiveValue);
int16_t scaledNegativeValue = ScaleValue(negativeValue);
int16_t scaledPositiveValue = ScaleValue(positiveValue);
CalculateBounds(offset, oldScaledNegativeValue, oldScaledPositiveValue, scaledNegativeValue, scaledPositiveValue);
}
int main() {
for (int i = 0; i < 128; i++) {
PrintScaledValues(i);
}
return 0;
}