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() {