diff --git a/src/RioJoy.Core/Calibration/AxisCalibrator.cs b/src/RioJoy.Core/Calibration/AxisCalibrator.cs index b67b0f4..d05a708 100644 --- a/src/RioJoy.Core/Calibration/AxisCalibrator.cs +++ b/src/RioJoy.Core/Calibration/AxisCalibrator.cs @@ -10,8 +10,12 @@ namespace RioJoy.Core.Calibration; /// across calls exactly as the legacy globals did. /// /// Final outputs are clamped to 0.. — the -/// documented axis range — which also guards a legacy quirk where a value pinned -/// at its observed extreme could compound across polls (see ⚠️ below). +/// documented axis range. One legacy quirk is deliberately fixed rather than +/// ported: with the lever exactly at the tracked start (the detent), the legacy +/// held the previous throttle value, which the ×32 rescale compounded into a +/// full-throttle pin — on the very first poll at rest, and for a poll when a +/// release landed exactly on the start. The detent now zeroes like the rest of +/// the deadzone (see the note in ). /// public sealed class AxisCalibrator { @@ -28,8 +32,11 @@ public sealed class AxisCalibrator private int _leftPedalStart = int.MaxValue; private int _rightPedalStart = int.MaxValue; - // Last computed (pre-clamp) outputs — persist across calls like the legacy globals. - private int _throttleLast = AxisOutputs.Center; + // Last computed (pre-clamp) outputs — persist across calls like the legacy + // globals. The throttle starts at 0, its calibrated rest value (the same value + // ResetThrottle/ResetAll restore): the legacy Center init fed the detent-hold + // quirk (see Throttle) and pinned Z at full on the first poll at rest. + private int _throttleLast; private int _leftPedalLast = AxisOutputs.Center; private int _rightPedalLast = AxisOutputs.Center; private int _joystickXLast = AxisOutputs.Center; @@ -98,17 +105,20 @@ public sealed class AxisCalibrator if (lT > 800) lT = 800; - if (lT != 0) - { - lT = lT * 1000 / 800; - if (lT is > -DeadzoneThrottle and < DeadzoneThrottle) - _throttleLast = 0; - else if (_throttleResult > 0) // back - _throttleLast = 900 + (lT * _throttleResult / 10); - else // front - _throttleLast = lT * _throttleResult; - } - // lT == 0 leaves _throttleLast unchanged (legacy behavior). + // Deliberate divergence from the legacy port: lT == 0 (lever exactly at + // the tracked start, i.e. the detent — including samples that raise the + // running max) zeroes the output like the rest of the ±50 deadzone. + // The legacy left _throttleLast unchanged here, and the ×32 rescale + // below then compounded the held value — pinning Z at full throttle on + // the first poll at rest and spiking full for a poll when a release + // landed exactly on the start. + lT = lT * 1000 / 800; + if (lT is > -DeadzoneThrottle and < DeadzoneThrottle) + _throttleLast = 0; + else if (_throttleResult > 0) // back + _throttleLast = 900 + (lT * _throttleResult / 10); + else // front + _throttleLast = lT * _throttleResult; } _throttleLast = Math.Abs(_throttleLast * 32); diff --git a/tests/RioJoy.Core.Tests/Calibration/AxisCalibratorTests.cs b/tests/RioJoy.Core.Tests/Calibration/AxisCalibratorTests.cs index 5eab45f..7496c54 100644 --- a/tests/RioJoy.Core.Tests/Calibration/AxisCalibratorTests.cs +++ b/tests/RioJoy.Core.Tests/Calibration/AxisCalibratorTests.cs @@ -128,11 +128,39 @@ public class AxisCalibratorTests Assert.Equal(AxisOutputs.Center, cal.Update(Report(x: 0)).X); } + // --- Detent regression (legacy quirk deliberately fixed) ----------------- + [Fact] - public void Outputs_AreClampedToAxisRange() + public void Throttle_FirstPollAtRest_IsZero_NotPinned() { - // The init-state throttle quirk yields a value far over range on the first - // poll at rest; the documented 0..Max clamp guards it. - Assert.Equal(AxisOutputs.Max, new AxisCalibrator().Update(Report(throttle: 0)).Z); + // Regression: the legacy Center init + detent-hold compounded through the + // ×32 rescale and pinned Z at Max on the very first poll at rest — full + // reverse on a Centered thumb route, full thrust on a UnipolarPositive one + // (Descent). Rest must read 0 with no reset and stay there. + var cal = new AxisCalibrator(); + Assert.Equal(0, cal.Update(Report(throttle: 0)).Z); + Assert.Equal(0, cal.Update(Report(throttle: 0)).Z); + } + + [Fact] + public void Throttle_ReleaseExactlyOntoStart_ReturnsToZero_NoSpike() + { + // Regression: a release landing exactly on the tracked start (lT == 0) + // used to hold the last moving value, which the ×32 rescale turned into a + // one-poll full-throttle spike until jitter broke it. + var cal = new AxisCalibrator(); + Assert.Equal(0, cal.Update(Report(throttle: 0)).Z); // seed start at rest + Assert.Equal(16000, cal.Update(Report(throttle: -400)).Z); // pushed halfway + Assert.Equal(0, cal.Update(Report(throttle: 0)).Z); // release onto start + } + + [Fact] + public void Throttle_SampleAboveRunningMax_ReadsAsRest() + { + // Samples above the tracked start re-base the auto-range (lT == 0 again) + // and must read as rest, not re-enter the legacy hold path. + var cal = new AxisCalibrator(); + cal.Update(Report(throttle: -400)); // power-on with lever pushed: becomes the start + Assert.Equal(0, cal.Update(Report(throttle: -100)).Z); // above max → re-based, rest } }