diff --git a/docs/PLAN.md b/docs/PLAN.md
index 36aed68..1a6ec4b 100644
--- a/docs/PLAN.md
+++ b/docs/PLAN.md
@@ -206,6 +206,21 @@ Core logic in `src/RioJoy.Core/Profiles` + `RioRuntime`; UI/OS in `src/RioJoy.Tr
- Joystick output now uses the real `HidFeederJoystickSink` when the driver is
present (verified end-to-end); `NullJoystickSink` remains only as the
no-driver fallback.
+- **Per-profile ViGEm axis routing — code-complete ✅.** `RioProfile.AxisRouting`
+ (`Output/AxisRoutingConfig`: one route per calibrated axis = pad target —
+ four thumbs, two triggers, or None — + output mode; shared types with no
+ ViGEm references, so net40 keeps compiling) re-routes the six axes on the
+ Xbox 360 pad. Modes: `Centered` (legacy bipolar) and `UnipolarPositive`
+ (calibrated 0 → thumb center — for the ratcheted unipolar throttle). Null/
+ absent = the historical fixed routing, so existing profiles are untouched.
+ Resolution + conversion is the pure unit-tested `AxisRouter`;
+ `ViGEmJoystickSink.SetRouting` applies it thread-safely and neutralizes all
+ thumbs/triggers each profile switch (no stale axes); wired in
+ `RioCoordinator.Activate` beside the calibrator config. First consumer:
+ `profiles/descent-d1x.json` (throttle→RightThumbY unipolar, rudder→
+ RightThumbX, triggers untouched — DXX fires on the LT/RT axis-buttons).
+ The HID feeder (native 6-axis report) intentionally ignores routing.
+ ⏳ Remaining: on-cabinet throttle/rudder feel check (first Descent flight).
- ⏳ **Remaining:** full on-cabinet verification of the auto-switch +
acquire/release lifecycle against real RIO hardware.
diff --git a/profiles/descent-d1x.json b/profiles/descent-d1x.json
index b85de73..93e485a 100644
--- a/profiles/descent-d1x.json
+++ b/profiles/descent-d1x.json
@@ -15,6 +15,32 @@
"InvertZR": false,
"EnableZR": true
},
+ "AxisRouting": {
+ "X": {
+ "Target": "LeftThumbX",
+ "Mode": "Centered"
+ },
+ "Y": {
+ "Target": "LeftThumbY",
+ "Mode": "Centered"
+ },
+ "Z": {
+ "Target": "RightThumbY",
+ "Mode": "UnipolarPositive"
+ },
+ "Rx": {
+ "Target": "None",
+ "Mode": "Centered"
+ },
+ "Ry": {
+ "Target": "None",
+ "Mode": "Centered"
+ },
+ "Rz": {
+ "Target": "RightThumbX",
+ "Mode": "Centered"
+ }
+ },
"Buttons": {
"16": 32822,
"17": 32823,
diff --git a/src/RioJoy.Core/Output/AxisRouter.cs b/src/RioJoy.Core/Output/AxisRouter.cs
new file mode 100644
index 0000000..a34d31d
--- /dev/null
+++ b/src/RioJoy.Core/Output/AxisRouter.cs
@@ -0,0 +1,67 @@
+using RioJoy.Core.Calibration;
+
+namespace RioJoy.Core.Output;
+
+///
+/// A resolved axis write: which pad control to drive and the converted value.
+/// is in the thumb range (-32768..32767) for thumb
+/// targets, the trigger range (0..255) for trigger targets, and 0 for
+/// (nothing is written).
+///
+public readonly struct ResolvedAxis
+{
+ public PadTarget Target { get; }
+
+ public int Value { get; }
+
+ public ResolvedAxis(PadTarget target, int value)
+ {
+ Target = target;
+ Value = value;
+ }
+
+ public override string ToString() => $"{Target}:{Value}";
+}
+
+///
+/// Pure route-resolution + value-conversion for the ViGEm sink — no ViGEm types,
+/// so the decision logic is unit-testable without the bus driver.
+/// translates the result into ViGEm calls.
+///
+public static class AxisRouter
+{
+ ///
+ /// Resolve where the calibrated (0..32766,
+ /// center 16383) of lands under
+ /// , and convert it for that target.
+ ///
+ public static ResolvedAxis Resolve(AxisRoutingConfig config, JoyAxis axis, int value)
+ {
+ if (config is null) throw new ArgumentNullException(nameof(config));
+
+ AxisRoute route = config.RouteFor(axis);
+ return route.Target switch
+ {
+ PadTarget.None => new ResolvedAxis(PadTarget.None, 0),
+ PadTarget.LeftTrigger or PadTarget.RightTrigger =>
+ new ResolvedAxis(route.Target, ToTrigger(value)),
+ _ => new ResolvedAxis(
+ route.Target,
+ route.Mode == AxisOutputMode.UnipolarPositive
+ ? ToUnipolarThumb(value)
+ : ToCenteredThumb(value)),
+ };
+ }
+
+ // RIO axis 0..32766 (centre 16383) -> Xbox thumb short -32768..32767 (legacy math).
+ private static int ToCenteredThumb(int value) =>
+ Compat.Net48Math.Clamp((value - AxisOutputs.Center) * 2, (int)short.MinValue, short.MaxValue);
+
+ // Unipolar RIO axis (rest = 0) -> upper thumb half: 0 -> center 0, 32766 -> max.
+ private static int ToUnipolarThumb(int value) =>
+ Compat.Net48Math.Clamp(value, 0, (int)short.MaxValue);
+
+ // RIO axis 0..32766 -> Xbox trigger byte 0..255 (legacy math).
+ private static int ToTrigger(int value) =>
+ Compat.Net48Math.Clamp(value * 255 / AxisOutputs.Max, 0, 255);
+}
diff --git a/src/RioJoy.Core/Output/AxisRoutingConfig.cs b/src/RioJoy.Core/Output/AxisRoutingConfig.cs
new file mode 100644
index 0000000..721190f
--- /dev/null
+++ b/src/RioJoy.Core/Output/AxisRoutingConfig.cs
@@ -0,0 +1,82 @@
+using RioJoy.Core.Calibration;
+
+namespace RioJoy.Core.Output;
+
+///
+/// Where a calibrated axis lands on the virtual Xbox 360 pad. Our own enum (no
+/// ViGEm types — these are shared config types, serialized into profiles and
+/// compiled for net40 too); translates to the
+/// ViGEm equivalents. = the axis is not emitted at all.
+///
+public enum PadTarget
+{
+ LeftThumbX,
+ LeftThumbY,
+ RightThumbX,
+ RightThumbY,
+ LeftTrigger,
+ RightTrigger,
+ None,
+}
+
+///
+/// How a calibrated axis value (0..32766, center 16383) converts to a
+/// thumb-stick value. Meaningless for trigger targets (triggers always use the
+/// legacy value*255/32766 byte conversion).
+///
+public enum AxisOutputMode
+{
+ /// Legacy bipolar mapping: (value - 16383) * 2 → center 16383 = thumb 0.
+ Centered,
+
+ ///
+ /// Unipolar mapping for axes whose calibrated rest is 0 (the ratcheted
+ /// throttle): clamp(value, 0, 32767) → calibrated 0 = thumb center 0,
+ /// 32766 = thumb max. Only the upper half of the thumb range is used.
+ ///
+ UnipolarPositive,
+}
+
+/// One axis route: which pad control it drives, and how the value converts.
+public sealed record AxisRoute
+{
+ public PadTarget Target { get; init; } = PadTarget.None;
+
+ public AxisOutputMode Mode { get; init; } = AxisOutputMode.Centered;
+}
+
+///
+/// Per-profile routing of the six calibrated axes onto the ViGEm Xbox 360 pad
+/// (; null there = this
+/// default). The defaults reproduce the historical hardcoded sink routing
+/// exactly — X→LeftThumbX, Y→LeftThumbY, Rx→RightThumbX, Ry→RightThumbY,
+/// Z→LeftTrigger, Rz→RightTrigger, all —
+/// so existing profiles behave identically. Two axes routed to the same target
+/// are not arbitrated: the last SetAxis write wins.
+///
+public sealed record AxisRoutingConfig
+{
+ public AxisRoute X { get; init; } = new() { Target = PadTarget.LeftThumbX };
+
+ public AxisRoute Y { get; init; } = new() { Target = PadTarget.LeftThumbY };
+
+ public AxisRoute Z { get; init; } = new() { Target = PadTarget.LeftTrigger };
+
+ public AxisRoute Rx { get; init; } = new() { Target = PadTarget.RightThumbX };
+
+ public AxisRoute Ry { get; init; } = new() { Target = PadTarget.RightThumbY };
+
+ public AxisRoute Rz { get; init; } = new() { Target = PadTarget.RightTrigger };
+
+ /// The route for .
+ public AxisRoute RouteFor(JoyAxis axis) => axis switch
+ {
+ JoyAxis.X => X,
+ JoyAxis.Y => Y,
+ JoyAxis.Z => Z,
+ JoyAxis.Rx => Rx,
+ JoyAxis.Ry => Ry,
+ JoyAxis.Rz => Rz,
+ _ => new AxisRoute(), // unknown axis → None (not emitted)
+ };
+}
diff --git a/src/RioJoy.Core/Output/ViGEmJoystickSink.cs b/src/RioJoy.Core/Output/ViGEmJoystickSink.cs
index 357c192..6f382d9 100644
--- a/src/RioJoy.Core/Output/ViGEmJoystickSink.cs
+++ b/src/RioJoy.Core/Output/ViGEmJoystickSink.cs
@@ -38,6 +38,7 @@ public sealed class ViGEmJoystickSink : IJoystickSink, IDisposable
private readonly ViGEmClient _client;
private readonly IXbox360Controller _pad;
private readonly object _gate = new();
+ private AxisRoutingConfig _routing = new();
private ViGEmJoystickSink(ViGEmClient client, IXbox360Controller pad)
{
@@ -45,6 +46,28 @@ public sealed class ViGEmJoystickSink : IJoystickSink, IDisposable
_pad = pad;
}
+ ///
+ /// Apply a per-profile axis routing ( = the default
+ /// legacy routing) and neutralize the pad's axis state — all four thumb axes
+ /// to 0 and both triggers to 0 in one report — so values written under the
+ /// previous routing cannot persist across a profile switch. Thread-safe.
+ /// Two axes routed to the same target are not arbitrated: last writer wins.
+ ///
+ public void SetRouting(AxisRoutingConfig? routing)
+ {
+ lock (_gate)
+ {
+ _routing = routing ?? new AxisRoutingConfig();
+ _pad.SetAxisValue(Xbox360Axis.LeftThumbX, 0);
+ _pad.SetAxisValue(Xbox360Axis.LeftThumbY, 0);
+ _pad.SetAxisValue(Xbox360Axis.RightThumbX, 0);
+ _pad.SetAxisValue(Xbox360Axis.RightThumbY, 0);
+ _pad.SetSliderValue(Xbox360Slider.LeftTrigger, 0);
+ _pad.SetSliderValue(Xbox360Slider.RightTrigger, 0);
+ _pad.SubmitReport();
+ }
+ }
+
///
/// Try to connect to ViGEmBus and create a virtual Xbox 360 controller. Returns
/// if ViGEmBus is not installed, so callers can fall back
@@ -94,32 +117,27 @@ public sealed class ViGEmJoystickSink : IJoystickSink, IDisposable
}
}
+ // Routing + conversion live in the pure AxisRouter (unit-tested without the
+ // bus); this method only translates the resolved target into ViGEm calls.
public void SetAxis(JoyAxis axis, int value)
{
lock (_gate)
{
- switch (axis)
+ ResolvedAxis resolved = AxisRouter.Resolve(_routing, axis, value);
+ switch (resolved.Target)
{
- case JoyAxis.X: _pad.SetAxisValue(Xbox360Axis.LeftThumbX, ToThumb(value)); break;
- case JoyAxis.Y: _pad.SetAxisValue(Xbox360Axis.LeftThumbY, ToThumb(value)); break;
- case JoyAxis.Rx: _pad.SetAxisValue(Xbox360Axis.RightThumbX, ToThumb(value)); break;
- case JoyAxis.Ry: _pad.SetAxisValue(Xbox360Axis.RightThumbY, ToThumb(value)); break;
- case JoyAxis.Z: _pad.SetSliderValue(Xbox360Slider.LeftTrigger, ToTrigger(value)); break;
- case JoyAxis.Rz: _pad.SetSliderValue(Xbox360Slider.RightTrigger, ToTrigger(value)); break;
- default: return;
+ case PadTarget.LeftThumbX: _pad.SetAxisValue(Xbox360Axis.LeftThumbX, (short)resolved.Value); break;
+ case PadTarget.LeftThumbY: _pad.SetAxisValue(Xbox360Axis.LeftThumbY, (short)resolved.Value); break;
+ case PadTarget.RightThumbX: _pad.SetAxisValue(Xbox360Axis.RightThumbX, (short)resolved.Value); break;
+ case PadTarget.RightThumbY: _pad.SetAxisValue(Xbox360Axis.RightThumbY, (short)resolved.Value); break;
+ case PadTarget.LeftTrigger: _pad.SetSliderValue(Xbox360Slider.LeftTrigger, (byte)resolved.Value); break;
+ case PadTarget.RightTrigger: _pad.SetSliderValue(Xbox360Slider.RightTrigger, (byte)resolved.Value); break;
+ default: return; // PadTarget.None — the axis is not emitted
}
_pad.SubmitReport();
}
}
- // RIO axis 0..32766 (centre 16383) -> Xbox thumb short -32768..32767.
- private static short ToThumb(int value) =>
- (short)RioJoy.Core.Compat.Net48Math.Clamp((value - AxisOutputs.Center) * 2, short.MinValue, short.MaxValue);
-
- // RIO axis 0..32766 -> Xbox trigger byte 0..255.
- private static byte ToTrigger(int value) =>
- (byte)RioJoy.Core.Compat.Net48Math.Clamp(value * 255 / AxisOutputs.Max, 0, 255);
-
public void Dispose()
{
try { _pad.Disconnect(); } catch { /* already disconnected / bus gone */ }
diff --git a/src/RioJoy.Core/Profiles/RioProfile.cs b/src/RioJoy.Core/Profiles/RioProfile.cs
index f2ce1a3..7fc6b6a 100644
--- a/src/RioJoy.Core/Profiles/RioProfile.cs
+++ b/src/RioJoy.Core/Profiles/RioProfile.cs
@@ -1,5 +1,6 @@
using RioJoy.Core.Calibration;
using RioJoy.Core.Mapping;
+using RioJoy.Core.Output;
namespace RioJoy.Core.Profiles;
@@ -29,6 +30,14 @@ public sealed class RioProfile
/// Axis calibration / invert options.
public AxisCalibrationConfig Calibration { get; set; } = new();
+ ///
+ /// How the six calibrated axes route onto the ViGEm Xbox 360 pad; null =
+ /// the default (the historical fixed
+ /// routing). Ignored by the RioGamepad HID feeder, whose native 6-axis
+ /// report needs no routing.
+ ///
+ public AxisRoutingConfig? AxisRouting { get; set; }
+
/// Plasma greeting text shown on load (null = leave display as-is).
public string? PlasmaGreeting { get; set; }
diff --git a/src/RioJoy.Tray/RioCoordinator.cs b/src/RioJoy.Tray/RioCoordinator.cs
index 16effc1..0468253 100644
--- a/src/RioJoy.Tray/RioCoordinator.cs
+++ b/src/RioJoy.Tray/RioCoordinator.cs
@@ -199,6 +199,13 @@ public sealed class RioCoordinator : IDisposable
IJoystickSink joystick;
string note;
IJoystickSink realJoystick = CreateJoystickSink(out string joystickNote);
+#if !NET40
+ // Per-profile ViGEm axis routing, applied the same way the profile's
+ // Calibration reaches the AxisCalibrator below (null = default legacy
+ // routing). Also neutralizes the pad so nothing persists across a switch.
+ if (realJoystick is ViGEmJoystickSink vigemPad)
+ vigemPad.SetRouting(profile.AxisRouting);
+#endif
if (!routeInput)
{
// Keyboard/mouse + joystick are gated off while editing so the RIO can
diff --git a/tests/RioJoy.Core.Tests/Output/AxisRouterTests.cs b/tests/RioJoy.Core.Tests/Output/AxisRouterTests.cs
new file mode 100644
index 0000000..51db0bb
--- /dev/null
+++ b/tests/RioJoy.Core.Tests/Output/AxisRouterTests.cs
@@ -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);
+ }
+ }
+}
diff --git a/tests/RioJoy.Core.Tests/Profiles/ConfigStoreTests.cs b/tests/RioJoy.Core.Tests/Profiles/ConfigStoreTests.cs
index fc67814..8348948 100644
--- a/tests/RioJoy.Core.Tests/Profiles/ConfigStoreTests.cs
+++ b/tests/RioJoy.Core.Tests/Profiles/ConfigStoreTests.cs
@@ -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()
{