Phase 2: serial + RIO protocol core (RioJoy.Core) with unit tests

Port the RIO wire protocol from legacy/riovjoy2.cpp into testable C#:

- Protocol/: command + length table, 7-bit checksum, packet builder, and a
  streaming receive-side framing state machine (PacketParser) that mirrors the
  legacy ReadCommBlock framing/resync (high-bit-mid-packet abort). Typed RIO->PC
  decodes: AnalogReport (14-bit sign-extend), VersionInfo, CheckStatus; lamp-state
  composition.
- Serial/: RioSerialLink drives an async receive loop with ACK/NAK reply policy
  (legacy force-accept vs. opt-in VerifyInboundChecksum), the analog poll timer,
  and the >5s reset-recovery watchdog. IRioTransport abstracts the COM port; the
  SerialPort-backed transport does 9600 8N1 + DTR reset pulse, and acquire/release
  is just create/dispose (foundation for native-game serial yield).
- tests/RioJoy.Core.Tests: 54 xUnit tests covering checksum, framing/resync,
  builder round-trips, analog sign-extension + sentinel rejection, lamp combos,
  and the read loop driven against an in-memory fake transport.

Hardware verification (version/check/analog against a cabinet) remains; it can't
be done off-device.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Cyd
2026-06-26 13:04:03 -05:00
co-authored by Claude Opus 4.8
parent 39a3dab1fc
commit b3cb764f4d
26 changed files with 1553 additions and 7 deletions
+74
View File
@@ -0,0 +1,74 @@
namespace RioJoy.Core.Protocol;
/// <summary>
/// The five raw analog axes decoded from an <see cref="RioCommand.AnalogReply"/>
/// payload. Values are the 14-bit signed raw counts straight off the RIO, before
/// any calibration/deadzone mapping (that math is Phase 4). Port of
/// <c>AnalogEvent</c> / <c>CombinePair</c> (riovjoy2.cpp#L1116); see
/// docs/PROTOCOL.md §4.
/// </summary>
public readonly struct AnalogReport
{
public short Throttle { get; }
public short LeftPedal { get; }
public short RightPedal { get; }
public short JoystickY { get; }
public short JoystickX { get; }
public AnalogReport(short throttle, short leftPedal, short rightPedal, short joystickY, short joystickX)
{
Throttle = throttle;
LeftPedal = leftPedal;
RightPedal = rightPedal;
JoystickY = joystickY;
JoystickX = joystickX;
}
/// <summary>
/// Decode a 14-bit signed axis value from a (low, high) byte pair, each
/// carrying 7 data bits. Port of <c>CombinePair</c> (riovjoy2.cpp#L1116):
/// combine the 7-bit halves then sign-extend bit 13.
/// </summary>
public static short CombinePair(byte low, byte high)
{
int raw = (low & 0x7F) | (high << 7); // 14 bits
if ((raw & 0x2000) != 0) // bit 13 set → negative
raw |= ~0x3FFF; // sign-extend into the high bits
return (short)raw;
}
/// <summary>
/// Try to decode an <see cref="RioCommand.AnalogReply"/> payload (10 bytes:
/// 5 axes × low,high in the order throttle, left pedal, right pedal,
/// joystick Y, joystick X). Returns <see langword="false"/> if any byte equals
/// <c>0xFE</c> (<see cref="RioControl.Restart"/>), which the RIO uses as an
/// "invalid sample" sentinel — the legacy code ignores such replies.
/// </summary>
public static bool TryParse(ReadOnlySpan<byte> payload, out AnalogReport report)
{
if (payload.Length != RioCommandTable.PayloadLength(RioCommand.AnalogReply))
throw new ArgumentException(
$"AnalogReply payload must be {RioCommandTable.PayloadLength(RioCommand.AnalogReply)} bytes.",
nameof(payload));
foreach (byte b in payload)
{
if (b == (byte)RioControl.Restart)
{
report = default;
return false;
}
}
report = new AnalogReport(
throttle: CombinePair(payload[0], payload[1]),
leftPedal: CombinePair(payload[2], payload[3]),
rightPedal: CombinePair(payload[4], payload[5]),
joystickY: CombinePair(payload[6], payload[7]),
joystickX: CombinePair(payload[8], payload[9]));
return true;
}
public override string ToString() =>
$"T:{Throttle} L:{LeftPedal} R:{RightPedal} Y:{JoystickY} X:{JoystickX}";
}