From 24a1b645191502dcf880e93624e9621db6eb55c9 Mon Sep 17 00:00:00 2001 From: Cyd Date: Fri, 26 Jun 2026 15:24:45 -0500 Subject: [PATCH] Phase 4: axis calibration + plasma display (RioJoy.Core) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port the analog calibration math and the plasma/VFD command set: - Calibration/: AxisCalibrator ports UpdateThrottle/UpdatePadal/UpdateJoystick (riovjoy2.cpp#L1504+) — throttle deadzone + ratchet field, pedal deadzones, joystick X/Y auto-ranging from observed min/max, rudder mixing (enableZR), and the per-axis invert flags. Stateful (start positions, last outputs, observed extremes) like the legacy globals, with the RIOcmd axis resets. Final outputs clamp to the documented 0..32766 range (also guards a legacy compounding quirk). AxisOutputs carries the six values; IJoystickSink gains SetAxis(JoyAxis,value) so calibrated axes reach the HID feeder. - Plasma/: PlasmaCommands builds the CPlasma ESC sequences (clear/cursor/font/ attr/box draw+fill/text) + GetFontSize + the PlasmaPosText auto-fit/centering; PlasmaDisplay writes them over the secondary COM transport. - tests: 21 new xUnit tests (105 total) for throttle proportional/saturation/ invert, pedal ZR mix vs. direct, joystick centering/direction/invert/reset, the output clamp, and plasma byte sequences/font sizes/auto-fit positioning. Hardware verification of axis feel + plasma output remains; the game-specific PlasmaScoreDraw layout is deferred to profile content (Phase 5/7). Co-Authored-By: Claude Opus 4.8 --- README.md | 8 +- docs/PLAN.md | 17 +- .../Calibration/AxisCalibrationConfig.cs | 21 ++ src/RioJoy.Core/Calibration/AxisCalibrator.cs | 271 ++++++++++++++++++ src/RioJoy.Core/Calibration/AxisOutputs.cs | 45 +++ src/RioJoy.Core/Mapping/OutputSinks.cs | 5 +- src/RioJoy.Core/Plasma/PlasmaCommands.cs | 97 +++++++ src/RioJoy.Core/Plasma/PlasmaDisplay.cs | 52 ++++ .../Calibration/AxisCalibratorTests.cs | 118 ++++++++ .../Mapping/RecordingSink.cs | 2 + .../Plasma/PlasmaCommandsTests.cs | 76 +++++ 11 files changed, 704 insertions(+), 8 deletions(-) create mode 100644 src/RioJoy.Core/Calibration/AxisCalibrationConfig.cs create mode 100644 src/RioJoy.Core/Calibration/AxisCalibrator.cs create mode 100644 src/RioJoy.Core/Calibration/AxisOutputs.cs create mode 100644 src/RioJoy.Core/Plasma/PlasmaCommands.cs create mode 100644 src/RioJoy.Core/Plasma/PlasmaDisplay.cs create mode 100644 tests/RioJoy.Core.Tests/Calibration/AxisCalibratorTests.cs create mode 100644 tests/RioJoy.Core.Tests/Plasma/PlasmaCommandsTests.cs 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); + } +}