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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user