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
@@ -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);
}
}