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:
Cyd
2026-07-30 09:00:09 -05:00
co-authored by Claude Fable 5
parent 23dec8901b
commit 2f2438717a
9 changed files with 463 additions and 16 deletions
+15
View File
@@ -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.
+26
View File
@@ -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,
+67
View File
@@ -0,0 +1,67 @@
using RioJoy.Core.Calibration;
namespace RioJoy.Core.Output;
/// <summary>
/// A resolved axis write: which pad control to drive and the converted value.
/// <see cref="Value"/> is in the thumb range (<c>-32768..32767</c>) for thumb
/// targets, the trigger range (<c>0..255</c>) for trigger targets, and 0 for
/// <see cref="PadTarget.None"/> (nothing is written).
/// </summary>
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}";
}
/// <summary>
/// Pure route-resolution + value-conversion for the ViGEm sink — no ViGEm types,
/// so the decision logic is unit-testable without the bus driver.
/// <see cref="ViGEmJoystickSink"/> translates the result into ViGEm calls.
/// </summary>
public static class AxisRouter
{
/// <summary>
/// Resolve where the calibrated <paramref name="value"/> (<c>0..32766</c>,
/// center 16383) of <paramref name="axis"/> lands under
/// <paramref name="config"/>, and convert it for that target.
/// </summary>
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);
}
@@ -0,0 +1,82 @@
using RioJoy.Core.Calibration;
namespace RioJoy.Core.Output;
/// <summary>
/// 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); <see cref="ViGEmJoystickSink"/> translates to the
/// ViGEm equivalents. <see cref="None"/> = the axis is not emitted at all.
/// </summary>
public enum PadTarget
{
LeftThumbX,
LeftThumbY,
RightThumbX,
RightThumbY,
LeftTrigger,
RightTrigger,
None,
}
/// <summary>
/// How a calibrated axis value (<c>0..32766</c>, center 16383) converts to a
/// thumb-stick value. Meaningless for trigger targets (triggers always use the
/// legacy <c>value*255/32766</c> byte conversion).
/// </summary>
public enum AxisOutputMode
{
/// <summary>Legacy bipolar mapping: <c>(value - 16383) * 2</c> → center 16383 = thumb 0.</summary>
Centered,
/// <summary>
/// Unipolar mapping for axes whose calibrated rest is 0 (the ratcheted
/// throttle): <c>clamp(value, 0, 32767)</c> → calibrated 0 = thumb center 0,
/// 32766 = thumb max. Only the upper half of the thumb range is used.
/// </summary>
UnipolarPositive,
}
/// <summary>One axis route: which pad control it drives, and how the value converts.</summary>
public sealed record AxisRoute
{
public PadTarget Target { get; init; } = PadTarget.None;
public AxisOutputMode Mode { get; init; } = AxisOutputMode.Centered;
}
/// <summary>
/// Per-profile routing of the six calibrated axes onto the ViGEm Xbox 360 pad
/// (<see cref="RioJoy.Core.Profiles.RioProfile.AxisRouting"/>; 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 <see cref="AxisOutputMode.Centered"/> —
/// so existing profiles behave identically. Two axes routed to the same target
/// are not arbitrated: the last <c>SetAxis</c> write wins.
/// </summary>
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 };
/// <summary>The route for <paramref name="axis"/>.</summary>
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)
};
}
+34 -16
View File
@@ -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;
}
/// <summary>
/// Apply a per-profile axis routing (<see langword="null"/> = 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.
/// </summary>
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();
}
}
/// <summary>
/// Try to connect to ViGEmBus and create a virtual Xbox 360 controller. Returns
/// <see langword="false"/> 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 */ }
+9
View File
@@ -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
/// <summary>Axis calibration / invert options.</summary>
public AxisCalibrationConfig Calibration { get; set; } = new();
/// <summary>
/// How the six calibrated axes route onto the ViGEm Xbox 360 pad; null =
/// the default <see cref="AxisRoutingConfig"/> (the historical fixed
/// routing). Ignored by the RioGamepad HID feeder, whose native 6-axis
/// report needs no routing.
/// </summary>
public AxisRoutingConfig? AxisRouting { get; set; }
/// <summary>Plasma greeting text shown on load (null = leave display as-is).</summary>
public string? PlasmaGreeting { get; set; }
+7
View File
@@ -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
@@ -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()
{