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:
@@ -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.
|
||||
|
||||
+14
-3
@@ -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,
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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 0–3 and 6–7 are 5×7, fonts 4–5 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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user