diff --git a/README.md b/README.md
index 0e109c0..90cb622 100644
--- a/README.md
+++ b/README.md
@@ -35,7 +35,7 @@ dotnet test RioJoy.sln
## Status
-Phases 2–3 (serial + RIO protocol core, input mapping + output routing) are
-code-complete and unit-tested; the virtual-HID feeder and hardware verification
-are pending on the Phase 1 driver. See [`docs/PLAN.md`](docs/PLAN.md) for the full
-roadmap.
+Phases 2–4 (serial + RIO protocol core, input mapping + output routing, axis
+calibration + plasma display) are code-complete and unit-tested (105 tests); the
+virtual-HID feeder and hardware verification are pending on the Phase 1 driver.
+See [`docs/PLAN.md`](docs/PLAN.md) for the full roadmap.
diff --git a/docs/PLAN.md b/docs/PLAN.md
index c321df5..1f2de5f 100644
--- a/docs/PLAN.md
+++ b/docs/PLAN.md
@@ -149,9 +149,20 @@ Implemented in `src/RioJoy.Core/Mapping` + `Output`, covered by the
driver exists. The legacy default map / `RIO.ini` becomes an importable profile
(Phase 5/7).
-### Phase 4 — Axis calibration + plasma display
-- Port `UpdateJoystick/Throttle/Padal` math (deadzones, ratchet, rudder).
-- Port the `CPlasma` ESC command set on the secondary COM port.
+### Phase 4 — Axis calibration + plasma display — code-complete ✅
+Implemented in `src/RioJoy.Core/Calibration` + `Plasma` (105 xUnit tests total):
+- `AxisCalibrator` ports `UpdateThrottle`/`UpdatePadal`/`UpdateJoystick`: throttle
+ deadzone + ratchet field, pedal deadzones, X/Y auto-ranging from observed
+ min/max, rudder mixing (`enableZR`), and all per-axis invert flags. Stateful
+ (start positions, last outputs) like the legacy globals, with the RIOcmd axis
+ resets. Outputs clamp to the documented `0..32766` range.
+- `IJoystickSink` gains `SetAxis(JoyAxis, value)` so calibrated axes reach the HID
+ feeder; `AxisOutputs` carries the six values.
+- `PlasmaCommands` ports the `CPlasma` ESC command set (clear/cursor/font/attr/box
+ draw+fill/text) + `GetFontSize` + the `PlasmaPosText` auto-fit/centering;
+ `PlasmaDisplay` writes them over the secondary COM transport.
+- ⏳ **Remaining:** hardware verification of axis feel + plasma output; the
+ game-specific `PlasmaScoreDraw` layout is profile content (Phase 5/7).
### Phase 5 — Tray app + profiles
- NotifyIcon + menu mirroring the legacy console menu (reset/recalibrate axes,
diff --git a/src/RioJoy.Core/Calibration/AxisCalibrationConfig.cs b/src/RioJoy.Core/Calibration/AxisCalibrationConfig.cs
new file mode 100644
index 0000000..1bbfbbc
--- /dev/null
+++ b/src/RioJoy.Core/Calibration/AxisCalibrationConfig.cs
@@ -0,0 +1,21 @@
+namespace RioJoy.Core.Calibration;
+
+///
+/// Per-profile axis calibration options. Defaults mirror the legacy
+/// JoyStick ini section (riovjoy2.cpp#L345): all inverts off,
+/// on. When is set, the pedals are
+/// mixed into a single rudder axis (Rz) and Rx/Ry are held centered; when clear,
+/// the pedals drive Rx/Ry directly and Rz is held centered.
+///
+public sealed record AxisCalibrationConfig
+{
+ public bool InvertX { get; init; }
+ public bool InvertY { get; init; }
+ public bool InvertZ { get; init; }
+ public bool InvertXR { get; init; }
+ public bool InvertYR { get; init; }
+ public bool InvertZR { get; init; }
+
+ /// Mix the two pedals into a rudder axis (Rz). Legacy default: on.
+ public bool EnableZR { get; init; } = true;
+}
diff --git a/src/RioJoy.Core/Calibration/AxisCalibrator.cs b/src/RioJoy.Core/Calibration/AxisCalibrator.cs
new file mode 100644
index 0000000..14387fb
--- /dev/null
+++ b/src/RioJoy.Core/Calibration/AxisCalibrator.cs
@@ -0,0 +1,271 @@
+using RioJoy.Core.Protocol;
+
+namespace RioJoy.Core.Calibration;
+
+///
+/// Converts raw RIO analog samples into calibrated virtual-joystick axis values.
+/// Stateful port of UpdateThrottle/UpdatePadal/UpdateJoystick
+/// (riovjoy2.cpp#L1504+); see docs/PROTOCOL.md §4. State (auto-ranging start
+/// positions, observed min/max for X/Y, last outputs, throttle ratchet) carries
+/// across calls exactly as the legacy globals did.
+///
+/// Final outputs are clamped to 0.. — the
+/// documented axis range — which also guards a legacy quirk where a value pinned
+/// at its observed extreme could compound across polls (see ⚠️ below).
+///
+public sealed class AxisCalibrator
+{
+ private const int RangeThrottle = 800;
+ private const int DeadzoneThrottle = 50;
+ private const int RangePadal = 500;
+ private const int DeadzonePadal = 10;
+ private const int DeadzoneJoystick = 5;
+
+ private readonly AxisCalibrationConfig _config;
+
+ // Auto-ranging start positions.
+ private int _throttleStart = int.MinValue;
+ private int _leftPedalStart = int.MaxValue;
+ private int _rightPedalStart = int.MaxValue;
+
+ // Last computed (pre-clamp) outputs — persist across calls like the legacy globals.
+ private int _throttleLast = AxisOutputs.Center;
+ private int _leftPedalLast = AxisOutputs.Center;
+ private int _rightPedalLast = AxisOutputs.Center;
+ private int _joystickXLast = AxisOutputs.Center;
+ private int _joystickYLast = AxisOutputs.Center;
+
+ // Throttle ratchet direction. Initialized -1; the legacy never sets it
+ // positive, so the "back" branches are effectively dead but ported faithfully.
+ private int _throttleResult = -1;
+
+ // Observed min/max for the joystick axes (auto-ranging rate).
+ private int _minX, _maxX, _minY, _maxY;
+
+ public AxisCalibrator(AxisCalibrationConfig? config = null)
+ {
+ _config = config ?? new AxisCalibrationConfig();
+ }
+
+ ///
+ /// Calibrate one raw analog sample and return the six axis values. Updates the
+ /// observed X/Y min/max first (as the legacy AnalogEvent did), then runs
+ /// the per-axis math.
+ ///
+ public AxisOutputs Update(AnalogReport raw)
+ {
+ _maxX = Math.Max(raw.JoystickX, _maxX);
+ _minX = Math.Min(raw.JoystickX, _minX);
+ _maxY = Math.Max(raw.JoystickY, _maxY);
+ _minY = Math.Min(raw.JoystickY, _minY);
+
+ int z = Throttle(raw.Throttle);
+ (int rx, int ry, int rz) = Pedals(raw.LeftPedal, raw.RightPedal);
+ int x = JoystickX(raw.JoystickX);
+ int y = JoystickY(raw.JoystickY);
+
+ return new AxisOutputs(
+ Clamp(x), Clamp(y), Clamp(z), Clamp(rx), Clamp(ry), Clamp(rz));
+ }
+
+ private static int Clamp(int v) => Math.Clamp(v, 0, AxisOutputs.Max);
+
+ private int Throttle(int throttle)
+ {
+ if (throttle < -RangeThrottle || throttle > RangeThrottle)
+ {
+ if (throttle < RangeThrottle)
+ _throttleLast = _throttleResult > 0 ? 1000 : -1000;
+ else
+ _throttleLast = 0;
+ }
+ else
+ {
+ if (_throttleStart < throttle)
+ _throttleStart = throttle;
+
+ int lT = _throttleStart - throttle;
+ if (lT > 800)
+ lT = 800;
+
+ if (lT != 0)
+ {
+ lT = lT * 1000 / 800;
+ if (lT is > -DeadzoneThrottle and < DeadzoneThrottle)
+ _throttleLast = 0;
+ else if (_throttleResult > 0) // back
+ _throttleLast = 900 + (lT * _throttleResult / 10);
+ else // front
+ _throttleLast = lT * _throttleResult;
+ }
+ // lT == 0 leaves _throttleLast unchanged (legacy behavior).
+ }
+
+ _throttleLast = Math.Abs(_throttleLast * 32);
+ return _config.InvertZ ? AxisOutputs.Max - _throttleLast : _throttleLast;
+ }
+
+ private (int rx, int ry, int rz) Pedals(int leftPedal, int rightPedal)
+ {
+ // LEFT pedal
+ if (leftPedal < -RangePadal || leftPedal > RangePadal)
+ {
+ _leftPedalLast = leftPedal > RangePadal ? -1000 : 0;
+ }
+ else
+ {
+ if (_leftPedalStart > leftPedal)
+ _leftPedalStart = leftPedal;
+
+ int lP = _leftPedalStart - leftPedal;
+ if (lP != 0)
+ lP = lP * 1000 / RangePadal;
+ if (Math.Abs(lP) < DeadzonePadal)
+ lP = 0;
+ _leftPedalLast = lP;
+ }
+ _leftPedalLast = Math.Abs(_leftPedalLast * 32);
+ if (_config.InvertXR)
+ _leftPedalLast = AxisOutputs.Max - _leftPedalLast;
+
+ // RIGHT pedal
+ if (rightPedal < -RangePadal || rightPedal > RangePadal)
+ {
+ _rightPedalLast = rightPedal > RangePadal ? 1000 : 0;
+ }
+ else
+ {
+ if (_rightPedalStart > rightPedal)
+ _rightPedalStart = rightPedal;
+
+ int lP = _rightPedalStart - rightPedal;
+ if (lP != 0)
+ lP = lP * 1000 / RangePadal;
+ if (Math.Abs(lP) < DeadzonePadal)
+ lP = 0;
+ _rightPedalLast = -lP;
+ }
+ _rightPedalLast = Math.Abs(_rightPedalLast * 32);
+ if (_config.InvertYR)
+ _rightPedalLast = AxisOutputs.Max - _rightPedalLast;
+
+ int rudder = AxisOutputs.Center - (_leftPedalLast / 2) + (_rightPedalLast / 2);
+ if (_config.InvertZR)
+ rudder = AxisOutputs.Max - rudder;
+
+ // EnableZR mixes the pedals into the rudder (Rz) and centers Rx/Ry;
+ // otherwise the pedals drive Rx/Ry directly and Rz is centered.
+ int rx = _config.EnableZR ? AxisOutputs.Center : _leftPedalLast;
+ int ry = _config.EnableZR ? AxisOutputs.Center : _rightPedalLast;
+ int rz = _config.EnableZR ? rudder : AxisOutputs.Center;
+ return (rx, ry, rz);
+ }
+
+ private int JoystickX(int joystickX)
+ {
+ int startingLeftRate = Math.Min(_minX, -80);
+ int startingRightRate = Math.Max(_maxX, 80);
+ int sLeftRate = AxisOutputs.Center / (Math.Abs(startingLeftRate) - DeadzoneJoystick);
+ int sRightRate = AxisOutputs.Center / (startingRightRate - DeadzoneJoystick);
+
+ int lJx = joystickX;
+ if (lJx < 0) // LEFT
+ {
+ lJx += DeadzoneJoystick;
+ _joystickXLast = lJx < 0 ? AxisOutputs.Center - (lJx * sLeftRate) : AxisOutputs.Center;
+ }
+ else if (lJx > 0) // RIGHT
+ {
+ lJx -= DeadzoneJoystick;
+ // 16838 (not 16383) and a +2 nudge are deliberate legacy anti-snap tweaks.
+ _joystickXLast = lJx > 0 ? 16838 - ((lJx + 2) * sRightRate) : AxisOutputs.Center;
+ }
+ // lJx == 0 leaves _joystickXLast unchanged (legacy behavior).
+
+ return _config.InvertX ? AxisOutputs.Max - _joystickXLast : _joystickXLast;
+ }
+
+ private int JoystickY(int joystickY)
+ {
+ int startingUpRate = Math.Min(_minY, -80);
+ int startingDownRate = Math.Max(_maxY, 80);
+ int sUpRate = AxisOutputs.Center / (Math.Abs(startingUpRate) - DeadzoneJoystick);
+ int sDownRate = AxisOutputs.Center / (startingDownRate - DeadzoneJoystick);
+
+ int lJy = joystickY;
+ if (lJy < 0) // UP
+ {
+ lJy += DeadzoneJoystick;
+ _joystickYLast = lJy < 0 ? AxisOutputs.Center - (lJy * sUpRate) : AxisOutputs.Center;
+ }
+ else if (lJy > 0) // DOWN
+ {
+ lJy -= DeadzoneJoystick;
+ _joystickYLast = lJy > 0 ? AxisOutputs.Center - (lJy * sDownRate) : AxisOutputs.Center;
+ }
+ // lJy == 0 leaves _joystickYLast unchanged (legacy behavior).
+
+ return _config.InvertY ? AxisOutputs.Max - _joystickYLast : _joystickYLast;
+ }
+
+ // --- Resets (port of the RIOcmd axis-reset cases, riovjoy2.cpp#L1852) -------
+
+ /// Dispatch a calibration reset for a RIO-command reset code.
+ public void Reset(Mapping.RioCommandCode command)
+ {
+ switch (command)
+ {
+ case Mapping.RioCommandCode.ResetAllAxes: ResetAll(); break;
+ case Mapping.RioCommandCode.ResetThrottle: ResetThrottle(); break;
+ case Mapping.RioCommandCode.ResetLeftPedal: ResetLeftPedal(); break;
+ case Mapping.RioCommandCode.ResetRightPedal: ResetRightPedal(); break;
+ case Mapping.RioCommandCode.ResetVerticalJoystick: ResetVerticalJoystick(); break;
+ case Mapping.RioCommandCode.ResetHorizontalJoystick: ResetHorizontalJoystick(); break;
+ }
+ }
+
+ public void ResetAll()
+ {
+ _throttleResult = -1;
+ _throttleStart = int.MinValue;
+ _leftPedalStart = int.MaxValue;
+ _rightPedalStart = int.MaxValue;
+ _joystickXLast = AxisOutputs.Center;
+ _joystickYLast = AxisOutputs.Center;
+ _throttleLast = 0;
+ _leftPedalLast = 0;
+ _rightPedalLast = 0;
+ _minX = _maxX = _minY = _maxY = 0;
+ }
+
+ public void ResetThrottle()
+ {
+ _throttleResult = -1;
+ _throttleStart = int.MinValue;
+ _throttleLast = 0;
+ }
+
+ public void ResetLeftPedal()
+ {
+ _leftPedalStart = int.MaxValue;
+ _leftPedalLast = 0;
+ }
+
+ public void ResetRightPedal()
+ {
+ _rightPedalStart = int.MaxValue;
+ _rightPedalLast = 0;
+ }
+
+ public void ResetVerticalJoystick()
+ {
+ _joystickYLast = AxisOutputs.Center;
+ _maxY = _minY = 0;
+ }
+
+ public void ResetHorizontalJoystick()
+ {
+ _joystickXLast = AxisOutputs.Center;
+ _maxX = _minX = 0;
+ }
+}
diff --git a/src/RioJoy.Core/Calibration/AxisOutputs.cs b/src/RioJoy.Core/Calibration/AxisOutputs.cs
new file mode 100644
index 0000000..fca59e2
--- /dev/null
+++ b/src/RioJoy.Core/Calibration/AxisOutputs.cs
@@ -0,0 +1,45 @@
+namespace RioJoy.Core.Calibration;
+
+///
+/// The six calibrated virtual-joystick axis values, each in the documented range
+/// 0..32766 (center 16383). Output of ; fed to
+/// the virtual HID device (HID_USAGE_X..RZ in the legacy).
+///
+public readonly struct AxisOutputs
+{
+ /// Documented axis range maximum (legacy uses 0..32766).
+ public const int Max = 32766;
+
+ /// Documented axis center value.
+ public const int Center = 16383;
+
+ public int X { get; }
+ public int Y { get; }
+ public int Z { get; }
+ public int Rx { get; }
+ public int Ry { get; }
+ public int Rz { get; }
+
+ public AxisOutputs(int x, int y, int z, int rx, int ry, int rz)
+ {
+ X = x;
+ Y = y;
+ Z = z;
+ Rx = rx;
+ Ry = ry;
+ Rz = rz;
+ }
+
+ public override string ToString() => $"X:{X} Y:{Y} Z:{Z} Rx:{Rx} Ry:{Ry} Rz:{Rz}";
+}
+
+/// The six virtual-joystick axes.
+public enum JoyAxis
+{
+ X,
+ Y,
+ Z,
+ Rx,
+ Ry,
+ Rz,
+}
diff --git a/src/RioJoy.Core/Mapping/OutputSinks.cs b/src/RioJoy.Core/Mapping/OutputSinks.cs
index 29e4c5e..4f1491d 100644
--- a/src/RioJoy.Core/Mapping/OutputSinks.cs
+++ b/src/RioJoy.Core/Mapping/OutputSinks.cs
@@ -66,13 +66,16 @@ public interface IInputSink
///
/// Virtual joystick output (the HID feeder → RioGamepad driver in Phase 1).
-/// Button numbers are 0-based into the 96-button report; the hat is the single POV.
+/// Button numbers are 0-based into the 96-button report; the hat is the single POV;
+/// axis values are in the range 0..32766 (see AxisOutputs).
///
public interface IJoystickSink
{
void SetButton(int button, bool pressed);
void SetHat(RioHat position);
+
+ void SetAxis(Calibration.JoyAxis axis, int value);
}
/// Lamp (lighted-button) feedback, sent back to the RIO over serial.
diff --git a/src/RioJoy.Core/Plasma/PlasmaCommands.cs b/src/RioJoy.Core/Plasma/PlasmaCommands.cs
new file mode 100644
index 0000000..4f3fe0c
--- /dev/null
+++ b/src/RioJoy.Core/Plasma/PlasmaCommands.cs
@@ -0,0 +1,97 @@
+using System.Text;
+
+namespace RioJoy.Core.Plasma;
+
+/// A glyph cell size in pixels (width × height).
+public readonly record struct FontSize(int Width, int Height);
+
+///
+/// Builds the ESC-based command byte sequences for the plasma / VFD text display
+/// on the secondary COM port. Pure port of the CPlasma command methods
+/// (riovjoy2.cpp#L2146); see docs/PROTOCOL.md §7. Text is encoded as Latin-1
+/// (each char → one byte), matching the legacy 8-bit char* packets.
+///
+public static class PlasmaCommands
+{
+ /// The ESC lead byte (legacy #define ESC 27).
+ public const byte Esc = 27;
+
+ /// Clear the display (ESC @).
+ public static byte[] Clear() => new[] { Esc, (byte)'@' };
+
+ /// Move the cursor home (ESC L).
+ public static byte[] CursorHome() => new[] { Esc, (byte)'L' };
+
+ /// Set cursor cell (ESC G n).
+ public static byte[] Cursor(byte n) => new[] { Esc, (byte)'G', n };
+
+ /// Set cursor X (ESC R x).
+ public static byte[] CursorX(byte x) => new[] { Esc, (byte)'R', x };
+
+ /// Set cursor Y (ESC Q y).
+ public static byte[] CursorY(byte y) => new[] { Esc, (byte)'Q', y };
+
+ /// Set font attribute (ESC H attr).
+ public static byte[] FontAttr(byte attr) => new[] { Esc, (byte)'H', attr };
+
+ /// Select font (ESC K font).
+ public static byte[] Font(byte font) => new[] { Esc, (byte)'K', font };
+
+ /// Draw a box outline (ESC X l t r b).
+ public static byte[] BoxDraw(byte left, byte top, byte right, byte bottom) =>
+ new[] { Esc, (byte)'X', left, top, right, bottom };
+
+ /// Fill a box (ESC x 0 l t r b).
+ public static byte[] BoxFill(byte left, byte top, byte right, byte bottom) =>
+ new[] { Esc, (byte)'x', (byte)0, left, top, right, bottom };
+
+ /// Encode display text as raw bytes (Latin-1, one byte per char).
+ public static byte[] Text(string text)
+ {
+ ArgumentNullException.ThrowIfNull(text);
+ return Encoding.Latin1.GetBytes(text);
+ }
+
+ ///
+ /// Glyph cell size for a font id. Port of GetFontSize (riovjoy2.cpp#L2198):
+ /// fonts 0–3 and 6–7 are 5×7, fonts 4–5 are 10×14.
+ ///
+ public static FontSize GetFontSize(int font) => font switch
+ {
+ 4 or 5 => new FontSize(10, 14),
+ _ => new FontSize(5, 7),
+ };
+
+ ///
+ /// Compute the auto-fit font and centered (x, y) for positioned text, porting
+ /// the PlasmaPosText layout logic (riovjoy2.cpp#L2235). For non-score
+ /// text ( ≠ 2), the font is chosen from the length
+ /// (≤9 → font 5, else font 2, capping length at 20). When the caller passes
+ /// (0, 0), the text is centered around cell (56, 15) for the chosen font.
+ ///
+ public static (byte x, byte y, byte font, int length) ResolvePosText(
+ string text, byte x, byte y, byte font)
+ {
+ ArgumentNullException.ThrowIfNull(text);
+ int len = text.Length;
+
+ if (font != 2) // not the Score font
+ {
+ if (len <= 9) font = 5;
+ else { font = 2; if (len > 20) len = 20; }
+ }
+ else if (len > 20)
+ {
+ len = 20;
+ }
+
+ if (x == 0 && y == 0)
+ {
+ FontSize size = GetFontSize(font);
+ x = (byte)(56 - (len * size.Width / 2));
+ y = (byte)(15 - (size.Height / 2));
+ }
+
+ return (x, y, font, len);
+ }
+}
diff --git a/src/RioJoy.Core/Plasma/PlasmaDisplay.cs b/src/RioJoy.Core/Plasma/PlasmaDisplay.cs
new file mode 100644
index 0000000..3e8fe69
--- /dev/null
+++ b/src/RioJoy.Core/Plasma/PlasmaDisplay.cs
@@ -0,0 +1,52 @@
+using RioJoy.Core.Serial;
+
+namespace RioJoy.Core.Plasma;
+
+///
+/// Drives the plasma / VFD text display over its (secondary) serial transport,
+/// writing the ESC sequences built by . Thin async
+/// wrapper around an ; the display is write-only. The
+/// content shown is per-profile (Phase 5+).
+///
+public sealed class PlasmaDisplay
+{
+ private readonly IRioTransport _transport;
+
+ public PlasmaDisplay(IRioTransport transport)
+ {
+ _transport = transport ?? throw new ArgumentNullException(nameof(transport));
+ }
+
+ public Task ClearAsync(CancellationToken ct = default) =>
+ WriteAsync(PlasmaCommands.Clear(), ct);
+
+ public Task CursorHomeAsync(CancellationToken ct = default) =>
+ WriteAsync(PlasmaCommands.CursorHome(), ct);
+
+ public Task TextAsync(string text, CancellationToken ct = default) =>
+ WriteAsync(PlasmaCommands.Text(text), ct);
+
+ ///
+ /// Position the cursor, set attribute + font, and write text — the
+ /// PlasmaPosText sequence (auto-fit via
+ /// ). Pass (0,0) to auto-center.
+ ///
+ public async Task PosTextAsync(
+ string text, byte x = 0, byte y = 0, byte attr = 0, byte font = 0,
+ CancellationToken ct = default)
+ {
+ if (string.IsNullOrEmpty(text))
+ return;
+
+ (byte rx, byte ry, byte rfont, int len) = PlasmaCommands.ResolvePosText(text, x, y, font);
+
+ await WriteAsync(PlasmaCommands.CursorX(rx), ct).ConfigureAwait(false);
+ await WriteAsync(PlasmaCommands.CursorY(ry), ct).ConfigureAwait(false);
+ await WriteAsync(PlasmaCommands.FontAttr(attr), ct).ConfigureAwait(false);
+ await WriteAsync(PlasmaCommands.Font(rfont), ct).ConfigureAwait(false);
+ await WriteAsync(PlasmaCommands.Text(text[..len]), ct).ConfigureAwait(false);
+ }
+
+ private Task WriteAsync(byte[] data, CancellationToken ct) =>
+ _transport.WriteAsync(data, ct).AsTask();
+}
diff --git a/tests/RioJoy.Core.Tests/Calibration/AxisCalibratorTests.cs b/tests/RioJoy.Core.Tests/Calibration/AxisCalibratorTests.cs
new file mode 100644
index 0000000..b8e1456
--- /dev/null
+++ b/tests/RioJoy.Core.Tests/Calibration/AxisCalibratorTests.cs
@@ -0,0 +1,118 @@
+using RioJoy.Core.Calibration;
+using RioJoy.Core.Protocol;
+using Xunit;
+
+namespace RioJoy.Core.Tests.Calibration;
+
+public class AxisCalibratorTests
+{
+ // AnalogReport(throttle, leftPedal, rightPedal, joystickY, joystickX)
+ private static AnalogReport Report(
+ short throttle = 0, short left = 0, short right = 0, short y = 0, short x = 0) =>
+ new(throttle, left, right, y, x);
+
+ // --- Throttle (Z) --------------------------------------------------------
+
+ [Fact]
+ public void Throttle_BelowStart_IsProportional()
+ {
+ var cal = new AxisCalibrator();
+ cal.ResetThrottle();
+
+ // At-rest after reset: throttle equals its tracked start ⇒ centered at 0.
+ Assert.Equal(0, cal.Update(Report(throttle: 0)).Z);
+
+ // Pulled back 400 of 800 → half range × 32 = 16000.
+ Assert.Equal(16000, cal.Update(Report(throttle: -400)).Z);
+
+ // Full back (800) → 32000.
+ Assert.Equal(32000, cal.Update(Report(throttle: -800)).Z);
+ }
+
+ [Fact]
+ public void Throttle_OutOfRange_Saturates()
+ {
+ Assert.Equal(0, new AxisCalibrator().Update(Report(throttle: 900)).Z); // > +range
+ Assert.Equal(32000, new AxisCalibrator().Update(Report(throttle: -900)).Z); // < -range
+ }
+
+ [Fact]
+ public void Throttle_Invert_Flips()
+ {
+ var cal = new AxisCalibrator(new AxisCalibrationConfig { InvertZ = true });
+ // throttle=900 → raw 0 → inverted to Max.
+ Assert.Equal(AxisOutputs.Max, cal.Update(Report(throttle: 900)).Z);
+ }
+
+ // --- Pedals (Rx / Ry / Rz) ----------------------------------------------
+
+ [Fact]
+ public void Pedals_EnableZR_CentersRxRy_AndMixesRudder()
+ {
+ var cal = new AxisCalibrator(); // EnableZR default true
+ AxisOutputs o = cal.Update(Report(left: 0, right: 0));
+
+ Assert.Equal(AxisOutputs.Center, o.Rx);
+ Assert.Equal(AxisOutputs.Center, o.Ry);
+ Assert.Equal(AxisOutputs.Center, o.Rz); // rudder with both pedals at rest
+ }
+
+ [Fact]
+ public void Pedals_DisableZR_DriveRxDirectly_AndCenterRz()
+ {
+ var cal = new AxisCalibrator(new AxisCalibrationConfig { EnableZR = false });
+
+ // First sample sets the auto-range start; second (less depressed) produces a value.
+ cal.Update(Report(left: -100));
+ AxisOutputs o = cal.Update(Report(left: 0));
+
+ // 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
+ }
+
+ // --- Joystick (X / Y) ----------------------------------------------------
+
+ [Fact]
+ public void Joystick_AtZero_IsCentered()
+ {
+ AxisOutputs o = new AxisCalibrator().Update(Report(x: 0, y: 0));
+ Assert.Equal(AxisOutputs.Center, o.X);
+ Assert.Equal(AxisOutputs.Center, o.Y);
+ }
+
+ [Fact]
+ public void JoystickX_RightAndLeft_MoveOppositeSidesOfCenter()
+ {
+ // Right (positive raw) → below center; with auto-range seeded by this sample.
+ Assert.Equal(52, new AxisCalibrator().Update(Report(x: 80)).X);
+ // Left (negative raw) → above center.
+ Assert.Equal(32733, new AxisCalibrator().Update(Report(x: -80)).X);
+ }
+
+ [Fact]
+ public void JoystickX_Invert_Flips()
+ {
+ var cal = new AxisCalibrator(new AxisCalibrationConfig { InvertX = true });
+ Assert.Equal(AxisOutputs.Max - 52, cal.Update(Report(x: 80)).X);
+ }
+
+ [Fact]
+ public void ResetHorizontalJoystick_RecentersX()
+ {
+ var cal = new AxisCalibrator();
+ Assert.Equal(52, cal.Update(Report(x: 80)).X);
+
+ cal.ResetHorizontalJoystick();
+ // After reset, X holds center until the stick next moves off zero.
+ Assert.Equal(AxisOutputs.Center, cal.Update(Report(x: 0)).X);
+ }
+
+ [Fact]
+ public void Outputs_AreClampedToAxisRange()
+ {
+ // The init-state throttle quirk yields a value far over range on the first
+ // poll at rest; the documented 0..Max clamp guards it.
+ Assert.Equal(AxisOutputs.Max, new AxisCalibrator().Update(Report(throttle: 0)).Z);
+ }
+}
diff --git a/tests/RioJoy.Core.Tests/Mapping/RecordingSink.cs b/tests/RioJoy.Core.Tests/Mapping/RecordingSink.cs
index dd96593..3d921b5 100644
--- a/tests/RioJoy.Core.Tests/Mapping/RecordingSink.cs
+++ b/tests/RioJoy.Core.Tests/Mapping/RecordingSink.cs
@@ -22,6 +22,8 @@ internal sealed class RecordingSink : IInputSink, IJoystickSink, ILampSink, IRio
public void SetHat(RioHat position) => Log.Add($"Hat({position})");
+ public void SetAxis(RioJoy.Core.Calibration.JoyAxis axis, int value) => Log.Add($"Axis({axis},{value})");
+
public void SetLamp(int address, byte lampState) => Log.Add($"Lamp(0x{address:X2},0x{lampState:X2})");
public void Execute(RioCommandCode command) => Log.Add($"Cmd({command})");
diff --git a/tests/RioJoy.Core.Tests/Plasma/PlasmaCommandsTests.cs b/tests/RioJoy.Core.Tests/Plasma/PlasmaCommandsTests.cs
new file mode 100644
index 0000000..65a280e
--- /dev/null
+++ b/tests/RioJoy.Core.Tests/Plasma/PlasmaCommandsTests.cs
@@ -0,0 +1,76 @@
+using RioJoy.Core.Plasma;
+using Xunit;
+
+namespace RioJoy.Core.Tests.Plasma;
+
+public class PlasmaCommandsTests
+{
+ [Fact]
+ public void EscSequences_AreCorrect()
+ {
+ Assert.Equal(new byte[] { 27, (byte)'@' }, PlasmaCommands.Clear());
+ Assert.Equal(new byte[] { 27, (byte)'L' }, PlasmaCommands.CursorHome());
+ Assert.Equal(new byte[] { 27, (byte)'R', 10 }, PlasmaCommands.CursorX(10));
+ Assert.Equal(new byte[] { 27, (byte)'Q', 20 }, PlasmaCommands.CursorY(20));
+ Assert.Equal(new byte[] { 27, (byte)'H', 3 }, PlasmaCommands.FontAttr(3));
+ Assert.Equal(new byte[] { 27, (byte)'K', 5 }, PlasmaCommands.Font(5));
+ }
+
+ [Fact]
+ public void Box_DrawAndFill_HaveExpectedLayout()
+ {
+ Assert.Equal(new byte[] { 27, (byte)'X', 1, 2, 3, 4 }, PlasmaCommands.BoxDraw(1, 2, 3, 4));
+ // Fill inserts a leading 0 parameter.
+ Assert.Equal(new byte[] { 27, (byte)'x', 0, 1, 2, 3, 4 }, PlasmaCommands.BoxFill(1, 2, 3, 4));
+ }
+
+ [Fact]
+ public void Text_EncodesOneBytePerChar()
+ {
+ Assert.Equal(new byte[] { (byte)'A', (byte)'B', (byte)'C' }, PlasmaCommands.Text("ABC"));
+ }
+
+ [Theory]
+ [InlineData(0, 5, 7)]
+ [InlineData(3, 5, 7)]
+ [InlineData(4, 10, 14)]
+ [InlineData(5, 10, 14)]
+ [InlineData(7, 5, 7)]
+ public void GetFontSize_MatchesLegacy(int font, int w, int h)
+ {
+ Assert.Equal(new FontSize(w, h), PlasmaCommands.GetFontSize(font));
+ }
+
+ [Fact]
+ public void ResolvePosText_ShortText_PicksFont5_AndCenters()
+ {
+ // "HELLO" (5) is non-score and ≤9 → font 5 (10×14). Centered around (56,15).
+ (byte x, byte y, byte font, int len) = PlasmaCommands.ResolvePosText("HELLO", 0, 0, 0);
+ Assert.Equal(5, font);
+ Assert.Equal(5, len);
+ Assert.Equal(31, x); // 56 - (5*10/2)
+ Assert.Equal(8, y); // 15 - (14/2)
+ }
+
+ [Fact]
+ public void ResolvePosText_LongText_Font2_AndCapsLength()
+ {
+ string text = new string('1', 24);
+ (byte x, byte y, byte font, int len) = PlasmaCommands.ResolvePosText(text, 0, 0, 0);
+ Assert.Equal(2, font);
+ Assert.Equal(20, len); // capped
+ Assert.Equal(6, x); // 56 - (20*5/2)
+ Assert.Equal(12, y); // 15 - (7/2)
+ }
+
+ [Fact]
+ public void ResolvePosText_ScoreFont_AndExplicitPosition_Unchanged()
+ {
+ // font 2 (score) keeps its font; non-zero position is not re-centered.
+ (byte x, byte y, byte font, int len) = PlasmaCommands.ResolvePosText("AB", 5, 6, 2);
+ Assert.Equal(2, font);
+ Assert.Equal(2, len);
+ Assert.Equal(5, x);
+ Assert.Equal(6, y);
+ }
+}