Fix pedal gauges frozen under ZR mix: feed L/R from pre-mix pedal readouts

The editor's L/R gauges read Rx/Ry, but EnableZR (on in real profiles)
folds the pedals into Rz and pins Rx/Ry to center, so the bars never
moved with the physical pedals. AxisCalibrator now exposes
LeftPedalOutput/RightPedalOutput (the calibrated pre-mix positions) and
RioRuntime.AxesUpdated carries an AxisReadout (the six virtual axes +
both pedals); the strip draws L/R from the pedal readouts, Z/Rz/X/Y
from the axes. With ZR off the readouts equal Rx/Ry, so nothing
changes there. Verified by screenshot: Rx/Ry centered while L=75% and
R=25% render correctly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Cyd
2026-07-06 10:22:05 -05:00
co-authored by Claude Fable 5
parent b2d1927c51
commit 2ac3188528
9 changed files with 94 additions and 28 deletions
+4 -3
View File
@@ -281,11 +281,12 @@ Replaces the legacy Google-Sheet → `.data` → GIMP → Script-Fu pipeline
context-sensitive picker (keyboard key by name via `KeyCatalog`, joystick Button N,
hat direction, mouse/RIO-command enum); modifiers enable only for keyboard.
Opened from the tray ("Edit profile…"); Save persists the profile. The encoder
gauges are **live**: `RioRuntime.AxesUpdated` streams the calibrated axis values
(post invert/deadzone/mix — exactly what the virtual joystick receives) into the
gauges are **live**: `RioRuntime.AxesUpdated` streams an `AxisReadout` (the six
virtual-joystick outputs plus the calibrated pre-mix pedal positions) into the
strip at the analog poll rate — Z and the L/R pedals fill bottom-up, Rz deflects
from its center tick, and the X/Y box tracks the stick as a dot (pure fraction
math in `RioJoy.Core.Editing.AxisGauges`, unit-tested).
math in `RioJoy.Core.Editing.AxisGauges`, unit-tested). L/R read the pedals
from before the ZR mix, since the mix pins Rx/Ry to center.
⏳ Still to refine: showing each button's assigned key as a
caption, grouping a button's two bank addresses, and clone-from-existing.
- The unified profile JSON supersedes both `RIO.ini` and the Google Sheet, with
@@ -70,6 +70,16 @@ public sealed class AxisCalibrator
private static int Clamp(int v) => RioJoy.Core.Compat.Net48Math.Clamp(v, 0, AxisOutputs.Max);
/// <summary>
/// The calibrated left-pedal position from the last <see cref="Update"/>
/// (<c>0..32766</c>) — always tracks the pedal, even when
/// <see cref="AxisCalibrationConfig.EnableZR"/> centers <see cref="AxisOutputs.Rx"/>.
/// </summary>
public int LeftPedalOutput => Clamp(_leftPedalLast);
/// <summary>Right-pedal counterpart of <see cref="LeftPedalOutput"/>.</summary>
public int RightPedalOutput => Clamp(_rightPedalLast);
private int Throttle(int throttle)
{
if (throttle < -RangeThrottle || throttle > RangeThrottle)
@@ -0,0 +1,27 @@
namespace RioJoy.Core.Calibration;
/// <summary>
/// One live calibration sample for gauge displays: the six virtual-joystick axis
/// outputs plus the calibrated pedal positions. The pedals are surfaced
/// separately because the ZR mix (<see cref="AxisCalibrationConfig.EnableZR"/>)
/// folds them into Rz and pins Rx/Ry to center — a pedal gauge fed from the axes
/// alone would freeze while the physical pedals move.
/// </summary>
public readonly struct AxisReadout
{
/// <summary>The six virtual-joystick outputs (what the HID device receives).</summary>
public AxisOutputs Axes { get; }
/// <summary>Calibrated left-pedal position (<c>0..32766</c>), before the ZR mix.</summary>
public int LeftPedal { get; }
/// <summary>Calibrated right-pedal position (<c>0..32766</c>), before the ZR mix.</summary>
public int RightPedal { get; }
public AxisReadout(AxisOutputs axes, int leftPedal, int rightPedal)
{
Axes = axes;
LeftPedal = leftPedal;
RightPedal = rightPedal;
}
}
+6 -5
View File
@@ -57,11 +57,11 @@ public sealed class RioRuntime : IRioCommandSink, IDisposable
public event Action<int, bool>? ButtonActivity;
/// <summary>
/// Raised with the six calibrated axis values after every analog reply —
/// independent of the joystick sink, so the editor can show live gauges even
/// while input routing is suppressed.
/// Raised with the calibrated readout (virtual axes + pre-mix pedal positions)
/// after every analog reply — independent of the joystick sink, so the editor
/// can show live gauges even while input routing is suppressed.
/// </summary>
public event Action<AxisOutputs>? AxesUpdated;
public event Action<AxisReadout>? AxesUpdated;
/// <summary>Raised with the firmware version when a <see cref="RioCommand.VersionReply"/> arrives.</summary>
public event Action<VersionInfo>? VersionReceived;
@@ -137,7 +137,8 @@ public sealed class RioRuntime : IRioCommandSink, IDisposable
// Output is best-effort: a faulty joystick sink must not kill the link.
}
AxesUpdated?.Invoke(axes);
AxesUpdated?.Invoke(new AxisReadout(
axes, _calibrator.LeftPedalOutput, _calibrator.RightPedalOutput));
}
private void OnPacket(RioPacket packet)
+7 -5
View File
@@ -32,7 +32,7 @@ internal sealed class PanelCanvas : Control
public Func<JoyStickSetting, bool>? CalibrationProvider { get; set; }
private readonly HashSet<int> _livePressed = new();
private AxisOutputs? _axes;
private AxisReadout? _axes;
public int? SelectedAddress { get; private set; }
@@ -46,14 +46,16 @@ internal sealed class PanelCanvas : Control
}
/// <summary>
/// Show the latest calibrated axis values on the encoder-gauge strip. Arrives at
/// Show the latest calibrated readout on the encoder-gauge strip. Arrives at
/// the analog poll rate (~18 Hz), so only the strip is repainted, and only when a
/// value actually changed.
/// </summary>
public void SetAxes(AxisOutputs axes)
public void SetAxes(AxisReadout axes)
{
if (_axes is { } p && p.X == axes.X && p.Y == axes.Y && p.Z == axes.Z &&
p.Rx == axes.Rx && p.Ry == axes.Ry && p.Rz == axes.Rz)
if (_axes is { } p &&
p.Axes.X == axes.Axes.X && p.Axes.Y == axes.Axes.Y && p.Axes.Z == axes.Axes.Z &&
p.Axes.Rx == axes.Axes.Rx && p.Axes.Ry == axes.Axes.Ry && p.Axes.Rz == axes.Axes.Rz &&
p.LeftPedal == axes.LeftPedal && p.RightPedal == axes.RightPedal)
return;
_axes = axes;
Invalidate(new Rectangle(0, 0, Width, PanelView.TopStrip));
+10 -9
View File
@@ -75,7 +75,7 @@ public static class PanelView
Func<int, string?> function,
Func<int, bool> lit,
Func<int, bool> live,
AxisOutputs? axes,
AxisReadout? axes,
int? selected)
{
g.Clear(Color.FromArgb(28, 28, 28));
@@ -152,7 +152,7 @@ public static class PanelView
}
}
private static void DrawEncoderStrip(Graphics g, Font font, AxisOutputs? axes)
private static void DrawEncoderStrip(Graphics g, Font font, AxisReadout? axes)
{
using var pen = new Pen(Color.FromArgb(120, 160, 120));
using var gauge = new SolidBrush(Color.FromArgb(20, 40, 20));
@@ -181,12 +181,13 @@ public static class PanelView
g.FillRectangle(gauge, box);
// Live values from the RIO's analog stream (null when the link is down).
// The gauges show the calibrated outputs — post invert/deadzone/mix — i.e.
// exactly what the virtual joystick receives.
// Z/Rz/X/Y show the virtual-joystick outputs; L and R show the calibrated
// pedal positions from before the ZR mix, which pins Rx/Ry to center — the
// pedal gauges must track the physical pedals either way.
if (axes is { } a)
{
// Z (throttle) and the L/R pedals (Rx/Ry) fill bottom-up.
foreach (var (box, value) in new[] { (zBox, a.Z), (lBox, a.Rx), (rBox, a.Ry) })
// Z (throttle) and the L/R pedals fill bottom-up.
foreach (var (box, value) in new[] { (zBox, a.Axes.Z), (lBox, a.LeftPedal), (rBox, a.RightPedal) })
{
int h = (int)Math.Round(box.Height * AxisGauges.Fraction(value));
if (h > 0)
@@ -194,7 +195,7 @@ public static class PanelView
}
// Rz (mixed rudder) deflects left/right from the bar's center.
double d = AxisGauges.Deflection(a.Rz);
double d = AxisGauges.Deflection(a.Axes.Rz);
int cx = rzBox.X + rzBox.Width / 2;
int w = (int)Math.Round(rzBox.Width / 2.0 * Math.Abs(d));
if (w > 0)
@@ -217,8 +218,8 @@ public static class PanelView
// (0 = left/top, matching the HID axis convention).
if (axes is { } live)
{
int px = xyBox.X + (int)Math.Round(AxisGauges.Fraction(live.X) * (xyBox.Width - 1));
int py = xyBox.Y + (int)Math.Round(AxisGauges.Fraction(live.Y) * (xyBox.Height - 1));
int px = xyBox.X + (int)Math.Round(AxisGauges.Fraction(live.Axes.X) * (xyBox.Width - 1));
int py = xyBox.Y + (int)Math.Round(AxisGauges.Fraction(live.Axes.Y) * (xyBox.Height - 1));
using var dot = new SolidBrush(Color.FromArgb(150, 230, 150));
g.FillEllipse(dot, px - 3, py - 3, 7, 7);
}
+2 -2
View File
@@ -466,8 +466,8 @@ public sealed class ProfileEditorForm : Form
public void ShowLiveActivity(int address, bool pressed) =>
RunOnUi(() => _canvas.SetLivePressed(address, pressed));
/// <summary>Show the live calibrated axis values on the encoder gauges (serial thread).</summary>
public void ShowAxes(AxisOutputs axes) =>
/// <summary>Show the live calibrated readout on the encoder gauges (serial thread).</summary>
public void ShowAxes(AxisReadout axes) =>
RunOnUi(() => _canvas.SetAxes(axes));
/// <summary>Show the firmware version from a RIO version reply (serial thread).</summary>
@@ -69,6 +69,26 @@ public class AxisCalibratorTests
// lP = (-100) - 0 = -100 → ×1000/500 = -200 → |·|×32 = 6400.
Assert.Equal(6400, o.Rx);
Assert.Equal(AxisOutputs.Center, o.Rz); // Rz centered when ZR disabled
// The pedal readouts mirror Rx/Ry when there is no mix.
Assert.Equal(o.Rx, cal.LeftPedalOutput);
Assert.Equal(o.Ry, cal.RightPedalOutput);
}
[Fact]
public void PedalOutputs_TrackPedals_EvenWithZrMix()
{
var cal = new AxisCalibrator(); // EnableZR default true
cal.Update(Report(left: -100, right: -100));
AxisOutputs o = cal.Update(Report(left: 0, right: 0));
// The mix centers Rx/Ry, but the pedal readouts still track the pedals
// (same math as the ZR-off Rx/Ry: 6400 for this deflection).
Assert.Equal(AxisOutputs.Center, o.Rx);
Assert.Equal(AxisOutputs.Center, o.Ry);
Assert.Equal(6400, cal.LeftPedalOutput);
Assert.Equal(6400, cal.RightPedalOutput);
}
// --- Joystick (X / Y) ----------------------------------------------------
+8 -4
View File
@@ -1,5 +1,6 @@
using System.Diagnostics;
using AxisOutputs = RioJoy.Core.Calibration.AxisOutputs;
using AxisReadout = RioJoy.Core.Calibration.AxisReadout;
using RioJoy.Core;
using RioJoy.Core.Mapping;
using RioJoy.Core.Protocol;
@@ -81,19 +82,22 @@ public class RioRuntimeTests
var recorder = new RecordingSink();
using var runtime = new RioRuntime(link, new RioInputMap(), recorder, recorder);
AxisOutputs? seen = null;
AxisReadout? seen = null;
runtime.AxesUpdated += axes => seen = axes;
runtime.Start();
using var cts = new CancellationTokenSource();
Task run = link.RunAsync(cts.Token);
// All raw axes at zero → joystick X/Y centered, same values the sink receives.
// All raw axes at zero → joystick X/Y centered, same values the sink receives;
// both pedals at rest → pedal readouts empty.
fake.Enqueue(PacketBuilder.Build(RioCommand.AnalogReply, new byte[10]));
await WaitUntilAsync(() => seen is not null);
Assert.Equal(AxisOutputs.Center, seen!.Value.X);
Assert.Equal(AxisOutputs.Center, seen.Value.Y);
Assert.Equal(AxisOutputs.Center, seen!.Value.Axes.X);
Assert.Equal(AxisOutputs.Center, seen.Value.Axes.Y);
Assert.Equal(0, seen.Value.LeftPedal);
Assert.Equal(0, seen.Value.RightPedal);
cts.Cancel();
await run;