RioProfile gains a nullable AxisRouting section mapping each calibrated axis (X/Y/Z/Rx/Ry/Rz) to a pad target (thumbs, triggers, or None) with a Centered or UnipolarPositive conversion; the default reproduces the old hardcoded routing exactly, so existing profiles are untouched. Routing resolution lives in a pure, ViGEm-free AxisRouter for testability; the sink neutralizes the pad on routing change so stale trigger state cannot leak across profile switches. Motivation: Descent reads the pad via SDL GameController, where the triggers are its stock fire axis-buttons - the old fixed routing put throttle on LeftTrigger (fires) and detent would have read as full reverse. descent-d1x.json now routes Z->RightThumbY (UnipolarPositive, detent = center) and Rz->RightThumbX, triggers untargeted; guarded by tests that parse the shipped JSON through the real deserializer and byte-compare the dxx-rebirth reference copy. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
131 lines
5.2 KiB
C#
131 lines
5.2 KiB
C#
using RioJoy.Core.Calibration;
|
|
using RioJoy.Core.Output;
|
|
using Xunit;
|
|
|
|
namespace RioJoy.Core.Tests.Output;
|
|
|
|
public class AxisRouterTests
|
|
{
|
|
// --- Default routing reproduces the legacy hardcoded sink exactly --------
|
|
|
|
[Theory]
|
|
[InlineData(JoyAxis.X, PadTarget.LeftThumbX)]
|
|
[InlineData(JoyAxis.Y, PadTarget.LeftThumbY)]
|
|
[InlineData(JoyAxis.Rx, PadTarget.RightThumbX)]
|
|
[InlineData(JoyAxis.Ry, PadTarget.RightThumbY)]
|
|
public void Default_ThumbAxes_UseLegacyCenteredMath(JoyAxis axis, PadTarget expected)
|
|
{
|
|
var config = new AxisRoutingConfig();
|
|
|
|
// Legacy ToThumb: (value - 16383) * 2, clamped to the short range.
|
|
ResolvedAxis min = AxisRouter.Resolve(config, axis, 0);
|
|
Assert.Equal(expected, min.Target);
|
|
Assert.Equal(-32766, min.Value);
|
|
|
|
Assert.Equal(0, AxisRouter.Resolve(config, axis, AxisOutputs.Center).Value);
|
|
Assert.Equal(32766, AxisRouter.Resolve(config, axis, AxisOutputs.Max).Value);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(JoyAxis.Z, PadTarget.LeftTrigger)]
|
|
[InlineData(JoyAxis.Rz, PadTarget.RightTrigger)]
|
|
public void Default_TriggerAxes_UseLegacyTriggerMath(JoyAxis axis, PadTarget expected)
|
|
{
|
|
var config = new AxisRoutingConfig();
|
|
|
|
// Legacy ToTrigger: value * 255 / 32766, clamped to the byte range.
|
|
ResolvedAxis rest = AxisRouter.Resolve(config, axis, 0);
|
|
Assert.Equal(expected, rest.Target);
|
|
Assert.Equal(0, rest.Value);
|
|
|
|
Assert.Equal(127, AxisRouter.Resolve(config, axis, AxisOutputs.Center).Value);
|
|
Assert.Equal(255, AxisRouter.Resolve(config, axis, AxisOutputs.Max).Value);
|
|
}
|
|
|
|
[Fact]
|
|
public void Default_CenteredThumb_ClampsToShortRange()
|
|
{
|
|
var config = new AxisRoutingConfig();
|
|
|
|
// (40000 - 16383) * 2 = 47234 → clamped to short.MaxValue.
|
|
Assert.Equal(32767, AxisRouter.Resolve(config, JoyAxis.X, 40000).Value);
|
|
// (-2000 - 16383) * 2 = -36766 → clamped to short.MinValue.
|
|
Assert.Equal(-32768, AxisRouter.Resolve(config, JoyAxis.X, -2000).Value);
|
|
}
|
|
|
|
// --- UnipolarPositive: clamp(value, 0, 32767) ----------------------------
|
|
|
|
[Fact]
|
|
public void UnipolarPositive_MapsCalibratedZeroToThumbCenter()
|
|
{
|
|
var config = new AxisRoutingConfig
|
|
{
|
|
Z = new AxisRoute { Target = PadTarget.RightThumbY, Mode = AxisOutputMode.UnipolarPositive },
|
|
};
|
|
|
|
Assert.Equal(0, AxisRouter.Resolve(config, JoyAxis.Z, 0).Value); // detent → center
|
|
Assert.Equal(16383, AxisRouter.Resolve(config, JoyAxis.Z, 16383).Value); // half → half up
|
|
Assert.Equal(32766, AxisRouter.Resolve(config, JoyAxis.Z, AxisOutputs.Max).Value); // full → max (32766 of 32767)
|
|
Assert.Equal(0, AxisRouter.Resolve(config, JoyAxis.Z, -5).Value); // clamps below
|
|
Assert.Equal(32767, AxisRouter.Resolve(config, JoyAxis.Z, 40000).Value); // clamps above
|
|
}
|
|
|
|
[Fact]
|
|
public void UnipolarPositive_OnTriggerTarget_IsIgnored_TriggerMathApplies()
|
|
{
|
|
// Mode is meaningless for trigger targets — they keep the trigger byte math.
|
|
var config = new AxisRoutingConfig
|
|
{
|
|
Z = new AxisRoute { Target = PadTarget.LeftTrigger, Mode = AxisOutputMode.UnipolarPositive },
|
|
};
|
|
|
|
Assert.Equal(255, AxisRouter.Resolve(config, JoyAxis.Z, AxisOutputs.Max).Value);
|
|
}
|
|
|
|
// --- None: the axis is not emitted ---------------------------------------
|
|
|
|
[Fact]
|
|
public void NoneTarget_EmitsNothing()
|
|
{
|
|
var config = new AxisRoutingConfig
|
|
{
|
|
Rx = new AxisRoute { Target = PadTarget.None },
|
|
};
|
|
|
|
ResolvedAxis r = AxisRouter.Resolve(config, JoyAxis.Rx, 12345);
|
|
Assert.Equal(PadTarget.None, r.Target);
|
|
Assert.Equal(0, r.Value);
|
|
}
|
|
|
|
// --- The Descent shape ----------------------------------------------------
|
|
|
|
[Fact]
|
|
public void DescentConfig_ThrottleToRightThumbY_TriggersUntargeted()
|
|
{
|
|
// profiles/descent-d1x.json: DXX's stock GameController defaults fire on
|
|
// the LT/RT axis-buttons, so no axis may land on a trigger.
|
|
var config = new AxisRoutingConfig
|
|
{
|
|
Z = new AxisRoute { Target = PadTarget.RightThumbY, Mode = AxisOutputMode.UnipolarPositive },
|
|
Rz = new AxisRoute { Target = PadTarget.RightThumbX },
|
|
Rx = new AxisRoute { Target = PadTarget.None },
|
|
Ry = new AxisRoute { Target = PadTarget.None },
|
|
};
|
|
|
|
ResolvedAxis z = AxisRouter.Resolve(config, JoyAxis.Z, 16383);
|
|
Assert.Equal(PadTarget.RightThumbY, z.Target);
|
|
Assert.Equal(16383, z.Value); // unipolar: half throttle → half deflection up
|
|
|
|
ResolvedAxis rz = AxisRouter.Resolve(config, JoyAxis.Rz, AxisOutputs.Center);
|
|
Assert.Equal(PadTarget.RightThumbX, rz.Target);
|
|
Assert.Equal(0, rz.Value); // rudder stays bipolar: center → thumb 0
|
|
|
|
foreach (JoyAxis axis in new[] { JoyAxis.X, JoyAxis.Y, JoyAxis.Z, JoyAxis.Rx, JoyAxis.Ry, JoyAxis.Rz })
|
|
{
|
|
PadTarget target = AxisRouter.Resolve(config, axis, AxisOutputs.Center).Target;
|
|
Assert.NotEqual(PadTarget.LeftTrigger, target);
|
|
Assert.NotEqual(PadTarget.RightTrigger, target);
|
|
}
|
|
}
|
|
}
|