diff --git a/docs/PLAN.md b/docs/PLAN.md
index c52925a..b78d3d3 100644
--- a/docs/PLAN.md
+++ b/docs/PLAN.md
@@ -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
diff --git a/src/RioJoy.Core/Calibration/AxisCalibrator.cs b/src/RioJoy.Core/Calibration/AxisCalibrator.cs
index d285c9c..b67b0f4 100644
--- a/src/RioJoy.Core/Calibration/AxisCalibrator.cs
+++ b/src/RioJoy.Core/Calibration/AxisCalibrator.cs
@@ -70,6 +70,16 @@ public sealed class AxisCalibrator
private static int Clamp(int v) => RioJoy.Core.Compat.Net48Math.Clamp(v, 0, AxisOutputs.Max);
+ ///
+ /// The calibrated left-pedal position from the last
+ /// (0..32766) — always tracks the pedal, even when
+ /// centers .
+ ///
+ public int LeftPedalOutput => Clamp(_leftPedalLast);
+
+ /// Right-pedal counterpart of .
+ public int RightPedalOutput => Clamp(_rightPedalLast);
+
private int Throttle(int throttle)
{
if (throttle < -RangeThrottle || throttle > RangeThrottle)
diff --git a/src/RioJoy.Core/Calibration/AxisReadout.cs b/src/RioJoy.Core/Calibration/AxisReadout.cs
new file mode 100644
index 0000000..dea4019
--- /dev/null
+++ b/src/RioJoy.Core/Calibration/AxisReadout.cs
@@ -0,0 +1,27 @@
+namespace RioJoy.Core.Calibration;
+
+///
+/// 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 ()
+/// 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.
+///
+public readonly struct AxisReadout
+{
+ /// The six virtual-joystick outputs (what the HID device receives).
+ public AxisOutputs Axes { get; }
+
+ /// Calibrated left-pedal position (0..32766), before the ZR mix.
+ public int LeftPedal { get; }
+
+ /// Calibrated right-pedal position (0..32766), before the ZR mix.
+ public int RightPedal { get; }
+
+ public AxisReadout(AxisOutputs axes, int leftPedal, int rightPedal)
+ {
+ Axes = axes;
+ LeftPedal = leftPedal;
+ RightPedal = rightPedal;
+ }
+}
diff --git a/src/RioJoy.Core/RioRuntime.cs b/src/RioJoy.Core/RioRuntime.cs
index 1744b73..d57b5d3 100644
--- a/src/RioJoy.Core/RioRuntime.cs
+++ b/src/RioJoy.Core/RioRuntime.cs
@@ -57,11 +57,11 @@ public sealed class RioRuntime : IRioCommandSink, IDisposable
public event Action? ButtonActivity;
///
- /// 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.
///
- public event Action? AxesUpdated;
+ public event Action? AxesUpdated;
/// Raised with the firmware version when a arrives.
public event Action? 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)
diff --git a/src/RioJoy.Tray/Editor/PanelCanvas.cs b/src/RioJoy.Tray/Editor/PanelCanvas.cs
index 8a32e20..9b6429a 100644
--- a/src/RioJoy.Tray/Editor/PanelCanvas.cs
+++ b/src/RioJoy.Tray/Editor/PanelCanvas.cs
@@ -32,7 +32,7 @@ internal sealed class PanelCanvas : Control
public Func? CalibrationProvider { get; set; }
private readonly HashSet _livePressed = new();
- private AxisOutputs? _axes;
+ private AxisReadout? _axes;
public int? SelectedAddress { get; private set; }
@@ -46,14 +46,16 @@ internal sealed class PanelCanvas : Control
}
///
- /// 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.
///
- 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));
diff --git a/src/RioJoy.Tray/Editor/PanelView.cs b/src/RioJoy.Tray/Editor/PanelView.cs
index fc931fc..f842c6d 100644
--- a/src/RioJoy.Tray/Editor/PanelView.cs
+++ b/src/RioJoy.Tray/Editor/PanelView.cs
@@ -75,7 +75,7 @@ public static class PanelView
Func function,
Func lit,
Func 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);
}
diff --git a/src/RioJoy.Tray/Editor/ProfileEditorForm.cs b/src/RioJoy.Tray/Editor/ProfileEditorForm.cs
index b9b0344..25d185a 100644
--- a/src/RioJoy.Tray/Editor/ProfileEditorForm.cs
+++ b/src/RioJoy.Tray/Editor/ProfileEditorForm.cs
@@ -466,8 +466,8 @@ public sealed class ProfileEditorForm : Form
public void ShowLiveActivity(int address, bool pressed) =>
RunOnUi(() => _canvas.SetLivePressed(address, pressed));
- /// Show the live calibrated axis values on the encoder gauges (serial thread).
- public void ShowAxes(AxisOutputs axes) =>
+ /// Show the live calibrated readout on the encoder gauges (serial thread).
+ public void ShowAxes(AxisReadout axes) =>
RunOnUi(() => _canvas.SetAxes(axes));
/// Show the firmware version from a RIO version reply (serial thread).
diff --git a/tests/RioJoy.Core.Tests/Calibration/AxisCalibratorTests.cs b/tests/RioJoy.Core.Tests/Calibration/AxisCalibratorTests.cs
index b8e1456..5eab45f 100644
--- a/tests/RioJoy.Core.Tests/Calibration/AxisCalibratorTests.cs
+++ b/tests/RioJoy.Core.Tests/Calibration/AxisCalibratorTests.cs
@@ -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) ----------------------------------------------------
diff --git a/tests/RioJoy.Core.Tests/RioRuntimeTests.cs b/tests/RioJoy.Core.Tests/RioRuntimeTests.cs
index 9505f56..f606c03 100644
--- a/tests/RioJoy.Core.Tests/RioRuntimeTests.cs
+++ b/tests/RioJoy.Core.Tests/RioRuntimeTests.cs
@@ -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;