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
}
}