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
+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