Phase 4: axis calibration + plasma display (RioJoy.Core)

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 <noreply@anthropic.com>
This commit is contained in:
Cyd
2026-06-26 15:24:45 -05:00
co-authored by Claude Opus 4.8
parent cc64d241c9
commit 24a1b64519
11 changed files with 704 additions and 8 deletions
+4 -4
View File
@@ -35,7 +35,7 @@ dotnet test RioJoy.sln
## Status
Phases 23 (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 24 (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.
+14 -3
View File
@@ -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,
@@ -0,0 +1,21 @@
namespace RioJoy.Core.Calibration;
/// <summary>
/// Per-profile axis calibration options. Defaults mirror the legacy
/// <c>JoyStick</c> ini section (riovjoy2.cpp#L345): all inverts off,
/// <see cref="EnableZR"/> on. When <see cref="EnableZR"/> 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.
/// </summary>
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; }
/// <summary>Mix the two pedals into a rudder axis (Rz). Legacy default: on.</summary>
public bool EnableZR { get; init; } = true;
}
@@ -0,0 +1,271 @@
using RioJoy.Core.Protocol;
namespace RioJoy.Core.Calibration;
/// <summary>
/// Converts raw RIO analog samples into calibrated virtual-joystick axis values.
/// Stateful port of <c>UpdateThrottle</c>/<c>UpdatePadal</c>/<c>UpdateJoystick</c>
/// (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 <see cref="Update"/> calls exactly as the legacy globals did.
///
/// <para>Final outputs are clamped to <c>0..<see cref="AxisOutputs.Max"/></c> — the
/// documented axis range — which also guards a legacy quirk where a value pinned
/// at its observed extreme could compound across polls (see ⚠️ below).</para>
/// </summary>
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();
}
/// <summary>
/// Calibrate one raw analog sample and return the six axis values. Updates the
/// observed X/Y min/max first (as the legacy <c>AnalogEvent</c> did), then runs
/// the per-axis math.
/// </summary>
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) -------
/// <summary>Dispatch a calibration reset for a RIO-command reset code.</summary>
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;
}
}
@@ -0,0 +1,45 @@
namespace RioJoy.Core.Calibration;
/// <summary>
/// The six calibrated virtual-joystick axis values, each in the documented range
/// <c>0..32766</c> (center 16383). Output of <see cref="AxisCalibrator"/>; fed to
/// the virtual HID device (<c>HID_USAGE_X..RZ</c> in the legacy).
/// </summary>
public readonly struct AxisOutputs
{
/// <summary>Documented axis range maximum (legacy uses 0..32766).</summary>
public const int Max = 32766;
/// <summary>Documented axis center value.</summary>
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}";
}
/// <summary>The six virtual-joystick axes.</summary>
public enum JoyAxis
{
X,
Y,
Z,
Rx,
Ry,
Rz,
}
+4 -1
View File
@@ -66,13 +66,16 @@ public interface IInputSink
/// <summary>
/// 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 <c>0..32766</c> (see <c>AxisOutputs</c>).
/// </summary>
public interface IJoystickSink
{
void SetButton(int button, bool pressed);
void SetHat(RioHat position);
void SetAxis(Calibration.JoyAxis axis, int value);
}
/// <summary>Lamp (lighted-button) feedback, sent back to the RIO over serial.</summary>
+97
View File
@@ -0,0 +1,97 @@
using System.Text;
namespace RioJoy.Core.Plasma;
/// <summary>A glyph cell size in pixels (width × height).</summary>
public readonly record struct FontSize(int Width, int Height);
/// <summary>
/// Builds the ESC-based command byte sequences for the plasma / VFD text display
/// on the secondary COM port. Pure port of the <c>CPlasma</c> 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 <c>char*</c> packets.
/// </summary>
public static class PlasmaCommands
{
/// <summary>The ESC lead byte (legacy <c>#define ESC 27</c>).</summary>
public const byte Esc = 27;
/// <summary>Clear the display (<c>ESC @</c>).</summary>
public static byte[] Clear() => new[] { Esc, (byte)'@' };
/// <summary>Move the cursor home (<c>ESC L</c>).</summary>
public static byte[] CursorHome() => new[] { Esc, (byte)'L' };
/// <summary>Set cursor cell (<c>ESC G n</c>).</summary>
public static byte[] Cursor(byte n) => new[] { Esc, (byte)'G', n };
/// <summary>Set cursor X (<c>ESC R x</c>).</summary>
public static byte[] CursorX(byte x) => new[] { Esc, (byte)'R', x };
/// <summary>Set cursor Y (<c>ESC Q y</c>).</summary>
public static byte[] CursorY(byte y) => new[] { Esc, (byte)'Q', y };
/// <summary>Set font attribute (<c>ESC H attr</c>).</summary>
public static byte[] FontAttr(byte attr) => new[] { Esc, (byte)'H', attr };
/// <summary>Select font (<c>ESC K font</c>).</summary>
public static byte[] Font(byte font) => new[] { Esc, (byte)'K', font };
/// <summary>Draw a box outline (<c>ESC X l t r b</c>).</summary>
public static byte[] BoxDraw(byte left, byte top, byte right, byte bottom) =>
new[] { Esc, (byte)'X', left, top, right, bottom };
/// <summary>Fill a box (<c>ESC x 0 l t r b</c>).</summary>
public static byte[] BoxFill(byte left, byte top, byte right, byte bottom) =>
new[] { Esc, (byte)'x', (byte)0, left, top, right, bottom };
/// <summary>Encode display text as raw bytes (Latin-1, one byte per char).</summary>
public static byte[] Text(string text)
{
ArgumentNullException.ThrowIfNull(text);
return Encoding.Latin1.GetBytes(text);
}
/// <summary>
/// Glyph cell size for a font id. Port of <c>GetFontSize</c> (riovjoy2.cpp#L2198):
/// fonts 03 and 67 are 5×7, fonts 45 are 10×14.
/// </summary>
public static FontSize GetFontSize(int font) => font switch
{
4 or 5 => new FontSize(10, 14),
_ => new FontSize(5, 7),
};
/// <summary>
/// Compute the auto-fit font and centered (x, y) for positioned text, porting
/// the <c>PlasmaPosText</c> layout logic (riovjoy2.cpp#L2235). For non-score
/// text (<paramref name="font"/> ≠ 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.
/// </summary>
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);
}
}
+52
View File
@@ -0,0 +1,52 @@
using RioJoy.Core.Serial;
namespace RioJoy.Core.Plasma;
/// <summary>
/// Drives the plasma / VFD text display over its (secondary) serial transport,
/// writing the ESC sequences built by <see cref="PlasmaCommands"/>. Thin async
/// wrapper around an <see cref="IRioTransport"/>; the display is write-only. The
/// content shown is per-profile (Phase 5+).
/// </summary>
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);
/// <summary>
/// Position the cursor, set attribute + font, and write text — the
/// <c>PlasmaPosText</c> sequence (auto-fit via
/// <see cref="PlasmaCommands.ResolvePosText"/>). Pass (0,0) to auto-center.
/// </summary>
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();
}
@@ -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);
}
}
@@ -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})");
@@ -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);
}
}