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:
@@ -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.
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
};
|
||||
}
|
||||
@@ -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 */ }
|
||||
|
||||
@@ -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; }
|
||||
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user