core: per-profile ViGEm axis routing with unipolar output mode
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>
This commit is contained in:
@@ -0,0 +1,130 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using RioJoy.Core.Calibration;
|
||||
using RioJoy.Core.Output;
|
||||
using RioJoy.Core.Profiles;
|
||||
using Xunit;
|
||||
|
||||
@@ -45,6 +46,98 @@ public class ConfigStoreTests
|
||||
Assert.Equal(new[] { "doom.exe" }, p.MatchExecutables);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RoundTrips_AxisRouting_WithStringEnums()
|
||||
{
|
||||
var config = new AppConfig
|
||||
{
|
||||
Profiles =
|
||||
{
|
||||
new RioProfile
|
||||
{
|
||||
Name = "Descent",
|
||||
AxisRouting = 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 },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
string json = ConfigStore.Serialize(config);
|
||||
Assert.Contains("\"RightThumbY\"", json); // enums serialize as strings
|
||||
Assert.Contains("\"UnipolarPositive\"", json); // (house StringEnumConverter convention)
|
||||
|
||||
RioProfile back = Assert.Single(ConfigStore.Deserialize(json).Profiles);
|
||||
Assert.Equal(config.Profiles[0].AxisRouting, back.AxisRouting); // record value equality
|
||||
Assert.Equal(PadTarget.LeftThumbX, back.AxisRouting!.X.Target); // untouched routes keep defaults
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AxisRouting_Unset_StaysNull_AndOffJson()
|
||||
{
|
||||
// null = default routing; NullValueHandling.Ignore keeps it out of the JSON,
|
||||
// so pre-existing profiles on disk are byte-compatible.
|
||||
string json = ConfigStore.Serialize(new AppConfig { Profiles = { new RioProfile { Name = "P" } } });
|
||||
Assert.DoesNotContain("AxisRouting", json);
|
||||
Assert.Null(Assert.Single(ConfigStore.Deserialize(json).Profiles).AxisRouting);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ShippedDescentProfile_ParsesWithDescentRouting_NoTriggerTargets()
|
||||
{
|
||||
// Guards the shipped fixture itself: Newtonsoft's default
|
||||
// MissingMemberHandling.Ignore silently drops a misspelled property or axis
|
||||
// name, which would fall back to the default routing and put the throttle
|
||||
// back on LeftTrigger — the stock fire button in Descent's SDL
|
||||
// GameController mapping. So parse the real file, not a C#-built config.
|
||||
string json = File.ReadAllText(Path.Combine(TestRepo.Root(), "profiles", "descent-d1x.json"));
|
||||
|
||||
// The shipped file is a single-profile document users paste into the
|
||||
// config's Profiles list; wrap it so it flows through the exact
|
||||
// ConfigStore serializer settings.
|
||||
RioProfile p = Assert.Single(ConfigStore.Deserialize($"{{\"Profiles\":[{json}]}}").Profiles);
|
||||
|
||||
Assert.Equal("Descent", p.Name);
|
||||
Assert.True(p.Calibration.EnableZR); // pedal-differential rudder feeds Rz
|
||||
|
||||
Assert.NotNull(p.AxisRouting);
|
||||
AxisRoutingConfig routing = p.AxisRouting!;
|
||||
Assert.Equal(new AxisRoute { Target = PadTarget.LeftThumbX }, routing.X);
|
||||
Assert.Equal(new AxisRoute { Target = PadTarget.LeftThumbY }, routing.Y);
|
||||
Assert.Equal(new AxisRoute { Target = PadTarget.RightThumbY, Mode = AxisOutputMode.UnipolarPositive }, routing.Z);
|
||||
Assert.Equal(new AxisRoute { Target = PadTarget.RightThumbX }, routing.Rz);
|
||||
Assert.Equal(new AxisRoute { Target = PadTarget.None }, routing.Rx);
|
||||
Assert.Equal(new AxisRoute { Target = PadTarget.None }, routing.Ry);
|
||||
|
||||
// No axis may resolve onto a trigger — triggers are Descent's fire buttons.
|
||||
foreach (JoyAxis axis in new[] { JoyAxis.X, JoyAxis.Y, JoyAxis.Z, JoyAxis.Rx, JoyAxis.Ry, JoyAxis.Rz })
|
||||
{
|
||||
PadTarget target = AxisRouter.Resolve(routing, axis, AxisOutputs.Center).Target;
|
||||
Assert.NotEqual(PadTarget.LeftTrigger, target);
|
||||
Assert.NotEqual(PadTarget.RightTrigger, target);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ShippedDescentProfile_MatchesDxxRebirthReferenceCopy()
|
||||
{
|
||||
// The dxx-rebirth docs keep a reference copy of the Descent profile that
|
||||
// must stay byte-identical to ours. The sibling checkout is a cabinet-
|
||||
// machine convention, not a repo guarantee, so no-op quietly when the
|
||||
// second repo is not checked out next to this one.
|
||||
string reference = Path.GetFullPath(Path.Combine(
|
||||
TestRepo.Root(), "..", "dxx-rebirth", "docs", "reference", "descent-riojoy-profile.json"));
|
||||
if (!File.Exists(reference))
|
||||
return;
|
||||
|
||||
string shipped = Path.Combine(TestRepo.Root(), "profiles", "descent-d1x.json");
|
||||
Assert.Equal(File.ReadAllBytes(shipped), File.ReadAllBytes(reference));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_MissingFile_ReturnsDefaults()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user