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
+4 -1
View File
@@ -16,6 +16,7 @@ Red Planet — talk to the RIO directly and do not use this app.)
|------|----------|
| [`src/RioJoy.Core`](src/RioJoy.Core/) | Protocol, profile model, input mapper, HID feeder (class library) |
| [`src/RioJoy.Tray`](src/RioJoy.Tray/) | Background tray application |
| [`tests/RioJoy.Core.Tests`](tests/RioJoy.Core.Tests/) | xUnit tests for the protocol core |
| [`driver/`](driver/) | `RioGamepad` virtual HID driver (KMDF + VHF) — replaces vJoy |
| [`docs/PLAN.md`](docs/PLAN.md) | Full modernization plan (7 phases) |
| [`docs/PROTOCOL.md`](docs/PROTOCOL.md) | RIO wire format + `iRIO` input-map reference |
@@ -29,8 +30,10 @@ Requires the **.NET 8 SDK** and Windows. The driver builds separately with the
```sh
dotnet build RioJoy.sln -c Release
dotnet test RioJoy.sln
```
## Status
Phase 0 (scaffold + plan). See [`docs/PLAN.md`](docs/PLAN.md) for what's next.
Phase 2 (serial + RIO protocol core) is code-complete and unit-tested; hardware
verification is pending. See [`docs/PLAN.md`](docs/PLAN.md) for the full roadmap.
+9
View File
@@ -9,6 +9,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RioJoy.Core", "src\RioJoy.C
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RioJoy.Tray", "src\RioJoy.Tray\RioJoy.Tray.csproj", "{FA2BAEAC-D8D5-47D9-B5EC-C9D10530D351}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{9FFCA6FA-1A95-4492-9A18-39D4F5720519}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RioJoy.Core.Tests", "tests\RioJoy.Core.Tests\RioJoy.Core.Tests.csproj", "{4A950510-432A-48C2-92E5-B87D075BA7DB}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -26,9 +30,14 @@ Global
{FA2BAEAC-D8D5-47D9-B5EC-C9D10530D351}.Debug|Any CPU.Build.0 = Debug|Any CPU
{FA2BAEAC-D8D5-47D9-B5EC-C9D10530D351}.Release|Any CPU.ActiveCfg = Release|Any CPU
{FA2BAEAC-D8D5-47D9-B5EC-C9D10530D351}.Release|Any CPU.Build.0 = Release|Any CPU
{4A950510-432A-48C2-92E5-B87D075BA7DB}.Debug|Any CPU.ActiveCfg = Debug|x64
{4A950510-432A-48C2-92E5-B87D075BA7DB}.Debug|Any CPU.Build.0 = Debug|x64
{4A950510-432A-48C2-92E5-B87D075BA7DB}.Release|Any CPU.ActiveCfg = Release|x64
{4A950510-432A-48C2-92E5-B87D075BA7DB}.Release|Any CPU.Build.0 = Release|x64
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{C81FEF57-A33B-4529-BDB7-02787407A545} = {CDDAA3FE-6A05-40A0-85CC-4DB0B3EEEA9C}
{FA2BAEAC-D8D5-47D9-B5EC-C9D10530D351} = {CDDAA3FE-6A05-40A0-85CC-4DB0B3EEEA9C}
{4A950510-432A-48C2-92E5-B87D075BA7DB} = {9FFCA6FA-1A95-4492-9A18-39D4F5720519}
EndGlobalSection
EndGlobal
+15 -6
View File
@@ -113,12 +113,21 @@ tray menu is always available.
- Test harness (throwaway C#) wiggles axes/buttons; verify in `joy.cpl`.
- Test-signing setup for the cabinets.
### Phase 2 — Serial + RIO protocol core (`RioJoy.Core`)
- `SerialPort` wrapper; packet parser/builder (length table, 7-bit checksum,
ACK/NAK, framing resync).
- Analog poll timer + >5 s reset-recovery.
- **Clean COM-port acquire/release** (foundation for serial yield).
- Verify against hardware: version reply, check reply, analog stream.
### Phase 2 — Serial + RIO protocol core (`RioJoy.Core`) — code-complete ✅
Implemented in `src/RioJoy.Core/Protocol` + `Serial`, covered by
`tests/RioJoy.Core.Tests` (xUnit, 54 tests):
- Packet parser/builder: command/length table, 7-bit checksum, control chars,
framing resync on a high-bit byte mid-packet (`PacketParser`, `PacketBuilder`).
- Typed RIO→PC decodes: `AnalogReport` (14-bit sign-extend), `VersionInfo`,
`CheckStatus`; lamp-state composition (`RioLampState`).
- `RioSerialLink`: async receive loop with ACK/NAK policy (legacy force-accept
vs. opt-in `VerifyInboundChecksum`), analog poll timer + >5 s reset-recovery.
- `IRioTransport` abstraction with a `SerialPort`-backed implementation
(9600 8N1, DTR reset pulse); **clean COM-port acquire/release** = create/dispose
the transport (foundation for serial yield). The read loop is tested against an
in-memory fake transport.
-**Remaining:** verify against real hardware (version reply, check reply,
analog stream) — needs a cabinet; can't be done off-device.
### Phase 3 — Input mapping + output routing
- Port `iRIO` decode and routing precedence (keyboard/mouse/joy/hat/RIO-command).
+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}";
}
+51
View File
@@ -0,0 +1,51 @@
namespace RioJoy.Core.Protocol;
/// <summary>
/// Builds outbound (PC → RIO) packets: <c>[command][payload…][checksum]</c>.
/// Port of <c>SendCommand</c> (riovjoy2.cpp#L1217), which copies the command
/// plus the command's fixed number of payload bytes, accumulating the 7-bit
/// checksum, then appends the checksum byte.
/// </summary>
public static class PacketBuilder
{
/// <summary>
/// Build a wire-ready packet for <paramref name="command"/> with the given
/// <paramref name="payload"/>. The payload length must match the command's
/// entry in the length table.
/// </summary>
public static byte[] Build(RioCommand command, ReadOnlySpan<byte> payload)
{
int expected = RioCommandTable.PayloadLength(command);
if (payload.Length != expected)
throw new ArgumentException(
$"{command} expects a {expected}-byte payload, got {payload.Length}.",
nameof(payload));
var packet = new byte[1 + expected + 1];
packet[0] = (byte)command;
payload.CopyTo(packet.AsSpan(1));
packet[^1] = RioChecksum.Compute(packet.AsSpan(0, 1 + expected));
return packet;
}
/// <summary>Build a zero-payload command packet (CheckRequest etc.).</summary>
public static byte[] Build(RioCommand command) => Build(command, ReadOnlySpan<byte>.Empty);
// --- Convenience builders for the PC → RIO commands ---------------------
public static byte[] CheckRequest() => Build(RioCommand.CheckRequest);
public static byte[] VersionRequest() => Build(RioCommand.VersionRequest);
public static byte[] AnalogRequest() => Build(RioCommand.AnalogRequest);
public static byte[] ResetRequest(RioResetTarget target) =>
Build(RioCommand.ResetRequest, stackalloc byte[] { (byte)target });
/// <summary>
/// LampRequest: payload is <c>[lamp#, state]</c>. The state byte is a
/// 7-bit lamp-state value (see <see cref="RioLampState"/>).
/// </summary>
public static byte[] LampRequest(byte lampNumber, byte state) =>
Build(RioCommand.LampRequest, stackalloc byte[] { lampNumber, state });
}
+123
View File
@@ -0,0 +1,123 @@
namespace RioJoy.Core.Protocol;
/// <summary>
/// Streaming receive-side framing state machine, fed raw bytes from the serial
/// port. Faithful port of the framing loop in <c>ReadCommBlock</c>
/// (riovjoy2.cpp#L860); see docs/PROTOCOL.md §2.
///
/// <para>Rules:</para>
/// <list type="bullet">
/// <item>Outside a packet, a byte in the command range starts a packet whose
/// total length is <c>1 + payloadLen + 1</c> (command + payload + checksum).</item>
/// <item>Outside a packet, any other byte is reported as a
/// <see cref="RioRxEventKind.ControlByte"/> (ACK/NAK/RESTART/IDLE or garbage).</item>
/// <item>Mid-packet, a byte with the high bit set aborts the packet and
/// resynchronizes (<see cref="RioRxEventKind.FramingError"/>); that byte is
/// discarded, matching the legacy <c>continue</c>.</item>
/// <item>When the final (checksum) byte arrives, a
/// <see cref="RioRxEventKind.Packet"/> is emitted with
/// <see cref="RioRxEvent.ChecksumValid"/> set from a real checksum compare.</item>
/// </list>
///
/// Each input byte produces at most one event, so the per-byte
/// <see cref="Feed(byte, out RioRxEvent)"/> returns a single optional event.
/// This type is not thread-safe; drive it from one reader.
/// </summary>
public sealed class PacketParser
{
// Max framed packet = command(1) + AnalogReply payload(10) + checksum(1) = 12.
private const int MaxPacketBytes = 16;
private readonly byte[] _buffer = new byte[MaxPacketBytes];
// Bytes still expected to complete the current packet (0 = not in a packet).
private int _remaining;
// Count of bytes stored in _buffer for the current packet (incl. command).
private int _count;
/// <summary>True while a packet is partially received.</summary>
public bool InPacket => _remaining != 0;
/// <summary>Discard any in-progress packet and reset to the idle state.</summary>
public void Reset()
{
_remaining = 0;
_count = 0;
}
/// <summary>
/// Feed one received byte. Returns <see langword="true"/> and sets
/// <paramref name="ev"/> when this byte produced an event; otherwise returns
/// <see langword="false"/> (the byte was consumed into an in-progress packet).
/// </summary>
public bool Feed(byte ch, out RioRxEvent ev)
{
if (_remaining != 0)
{
// Mid-packet. A high-bit byte is a framing error → abort and resync.
if ((ch & 0x80) != 0)
{
Reset();
ev = RioRxEvent.ForFramingError(ch);
return true;
}
_buffer[_count++] = ch;
_remaining--;
if (_remaining == 0)
{
ev = CompletePacket();
return true;
}
ev = default;
return false;
}
// Not in a packet.
if (RioCommandTable.IsCommand(ch))
{
_count = 0;
_buffer[_count++] = ch;
_remaining = RioCommandTable.PayloadLength(ch) + 1; // + checksum byte
ev = default;
return false;
}
// A byte outside framing: control character (ACK/NAK/RESTART/IDLE) or garbage.
ev = RioRxEvent.ForControl(ch);
return true;
}
/// <summary>
/// Feed a chunk of received bytes, invoking <paramref name="onEvent"/> for
/// each event produced, in order.
/// </summary>
public void Feed(ReadOnlySpan<byte> data, Action<RioRxEvent> onEvent)
{
ArgumentNullException.ThrowIfNull(onEvent);
foreach (byte ch in data)
{
if (Feed(ch, out RioRxEvent ev))
onEvent(ev);
}
}
private RioRxEvent CompletePacket()
{
// _buffer = [command][payload…][checksum]; _count includes all of them.
int bodyLen = _count - 1; // command + payload (exclude checksum)
byte receivedChecksum = _buffer[bodyLen];
byte computed = RioChecksum.Compute(_buffer.AsSpan(0, bodyLen));
bool checksumValid = computed == receivedChecksum;
var command = (RioCommand)_buffer[0];
var payload = _buffer.AsSpan(1, bodyLen - 1).ToArray(); // copy out; buffer is reused
var packet = new RioPacket(command, payload);
Reset();
return RioRxEvent.ForPacket(packet, checksumValid);
}
}
+23
View File
@@ -0,0 +1,23 @@
namespace RioJoy.Core.Protocol;
/// <summary>
/// The RIO packet checksum: the low 7 bits of the sum of the low 7 bits of
/// every byte in the command+payload (the checksum byte itself excluded).
/// Port of the build loop in <c>SendCommand</c> (riovjoy2.cpp#L1227) and the
/// verify loop in <c>ReadCommBlock</c> (riovjoy2.cpp#L884); see docs/PROTOCOL.md §2.
/// </summary>
public static class RioChecksum
{
/// <summary>
/// Compute the 7-bit checksum over <paramref name="commandAndPayload"/>
/// (the command byte followed by its payload bytes — not the checksum byte).
/// </summary>
public static byte Compute(ReadOnlySpan<byte> commandAndPayload)
{
byte sum = 0;
foreach (byte b in commandAndPayload)
sum += (byte)(b & 0x7F);
return (byte)(sum & 0x7F);
}
}
+129
View File
@@ -0,0 +1,129 @@
namespace RioJoy.Core.Protocol;
/// <summary>
/// RIO message commands. The command byte always has its high bit set; the
/// value identifies the message. Faithful port of <c>enum RIOCommand</c> and
/// <c>g_baRIOLengthsA</c> in <c>legacy/riovjoy2.cpp</c> (see docs/PROTOCOL.md §3).
/// </summary>
public enum RioCommand : byte
{
// PC → RIO
CheckRequest = 0x80,
VersionRequest = 0x81,
AnalogRequest = 0x82,
ResetRequest = 0x83,
LampRequest = 0x84,
// RIO → PC
CheckReply = 0x85,
VersionReply = 0x86,
AnalogReply = 0x87,
ButtonPressed = 0x88,
ButtonReleased = 0x89,
KeyPressed = 0x8A,
KeyReleased = 0x8B,
TestModeChange = 0x8C,
}
/// <summary>
/// Single-byte control characters that live <i>outside</i> packet framing
/// (legacy <c>#define ACK_CHAR</c> … <c>IDLE_CHAR</c>, riovjoy2.cpp#L154).
/// </summary>
public enum RioControl : byte
{
/// <summary>Packet accepted.</summary>
Ack = 0xFC,
/// <summary>Packet rejected; resend.</summary>
Nak = 0xFD,
/// <summary>Restart; also used as an in-payload "invalid" sentinel.</summary>
Restart = 0xFE,
/// <summary>Idle.</summary>
Idle = 0xFF,
}
/// <summary>
/// Reset targets for <see cref="RioCommand.ResetRequest"/> (docs/PROTOCOL.md §3).
/// </summary>
public enum RioResetTarget : byte
{
All = 0,
Throttle = 1,
LeftPedal = 2,
RightPedal = 3,
VerticalJoystick = 4, // Y
HorizontalJoystick = 5, // X
}
/// <summary>
/// The <c>statusType</c> field of <see cref="RioCommand.CheckReply"/>
/// (legacy <c>enum RIOStatusType</c>, riovjoy2.cpp#L204).
/// </summary>
public enum RioStatusType : byte
{
BoardOk = 0,
BoardMissing = 1,
BoardBad = 2,
LampBad = 3,
RestartCount = 4,
AbandonCount = 5,
FullBufferCount = 6,
}
/// <summary>
/// Per-command payload lengths (bytes between the command byte and the
/// checksum byte). Port of <c>g_baRIOLengthsA</c>.
/// </summary>
public static class RioCommandTable
{
/// <summary>Lowest command byte value (<see cref="RioCommand.CheckRequest"/>).</summary>
public const byte Base = (byte)RioCommand.CheckRequest;
// Indexed by (command - Base). Order must match RioCommand.
private static readonly byte[] PayloadLengths =
{
0, // CheckRequest
0, // VersionRequest
0, // AnalogRequest
1, // ResetRequest
2, // LampRequest
2, // CheckReply
2, // VersionReply
10, // AnalogReply
1, // ButtonPressed
1, // ButtonReleased
2, // KeyPressed
2, // KeyReleased
1, // TestModeChange
};
/// <summary>Number of defined commands.</summary>
public static int Count => PayloadLengths.Length;
/// <summary>Highest valid command byte value (inclusive).</summary>
public static byte Max => (byte)(Base + Count - 1);
/// <summary>True if <paramref name="commandByte"/> is a known command.</summary>
public static bool IsCommand(byte commandByte) =>
commandByte >= Base && commandByte <= Max;
/// <summary>
/// Payload length for a command byte. Throws if the byte is not a known
/// command — callers framing a stream should guard with
/// <see cref="IsCommand"/> first.
/// </summary>
public static int PayloadLength(byte commandByte)
{
if (!IsCommand(commandByte))
throw new ArgumentOutOfRangeException(
nameof(commandByte),
$"0x{commandByte:X2} is not a RIO command (valid 0x{Base:X2}..0x{Max:X2}).");
return PayloadLengths[commandByte - Base];
}
/// <summary>Payload length for a <see cref="RioCommand"/>.</summary>
public static int PayloadLength(RioCommand command) => PayloadLength((byte)command);
}
+50
View File
@@ -0,0 +1,50 @@
namespace RioJoy.Core.Protocol;
/// <summary>Flash mode for a lighted button (lamp-state bits 01).</summary>
public enum LampFlash : byte
{
Solid = 0,
FlashSlow = 1,
FlashMed = 2,
FlashFast = 3,
}
/// <summary>Lamp brightness "field 1" (bits 23).</summary>
public enum LampField1 : byte
{
Off = 0x00,
Dim = 0x04,
Bright = 0x0C,
}
/// <summary>Lamp brightness "field 2" (bits 45).</summary>
public enum LampField2 : byte
{
Off = 0x00,
Dim = 0x10,
Bright = 0x30,
}
/// <summary>
/// The <c>state</c> byte of a <see cref="RioCommand.LampRequest"/>, composed of
/// a flash mode plus two brightness fields. Port of <c>enum LampState</c>
/// (riovjoy2.cpp#L213); see docs/PROTOCOL.md §3.
/// </summary>
public static class RioLampState
{
/// <summary>Compose a lamp-state byte from its flash + brightness parts.</summary>
public static byte Compose(LampFlash flash, LampField1 field1, LampField2 field2) =>
(byte)((byte)flash + (byte)field1 + (byte)field2);
/// <summary>Lamp off: <c>SolidOff</c> (0x00).</summary>
public static readonly byte SolidOff =
Compose(LampFlash.Solid, LampField1.Off, LampField2.Off);
/// <summary>Dim solid: <c>SolidDim</c> (0x14). Used for idle/released lamps.</summary>
public static readonly byte SolidDim =
Compose(LampFlash.Solid, LampField1.Dim, LampField2.Dim);
/// <summary>Bright solid: <c>SolidBright</c> (0x3C). Used for pressed lamps.</summary>
public static readonly byte SolidBright =
Compose(LampFlash.Solid, LampField1.Bright, LampField2.Bright);
}
+36
View File
@@ -0,0 +1,36 @@
namespace RioJoy.Core.Protocol;
/// <summary>
/// A fully-framed RIO packet: a command plus its fixed-length payload (the
/// 7-bit data bytes between the command byte and the checksum). The checksum
/// itself is not stored — it is validated/derived at the framing boundary.
/// </summary>
public readonly struct RioPacket
{
/// <summary>The command byte.</summary>
public RioCommand Command { get; }
/// <summary>
/// The payload bytes (high bit always clear). Length matches
/// <see cref="RioCommandTable.PayloadLength(RioCommand)"/>.
/// </summary>
public ReadOnlyMemory<byte> Payload { get; }
public RioPacket(RioCommand command, ReadOnlyMemory<byte> payload)
{
int expected = RioCommandTable.PayloadLength(command);
if (payload.Length != expected)
throw new ArgumentException(
$"{command} expects a {expected}-byte payload, got {payload.Length}.",
nameof(payload));
Command = command;
Payload = payload;
}
public override string ToString()
{
var hex = Convert.ToHexString(Payload.Span);
return Payload.IsEmpty ? Command.ToString() : $"{Command} [{hex}]";
}
}
+61
View File
@@ -0,0 +1,61 @@
namespace RioJoy.Core.Protocol;
/// <summary>
/// The RIO firmware version from a <see cref="RioCommand.VersionReply"/>
/// (port of <c>VersionReply</c>, riovjoy2.cpp#L1484: payload is [major, minor]).
/// </summary>
public readonly struct VersionInfo
{
public byte Major { get; }
public byte Minor { get; }
public VersionInfo(byte major, byte minor)
{
Major = major;
Minor = minor;
}
/// <summary>Decode a <see cref="RioCommand.VersionReply"/> payload (2 bytes).</summary>
public static VersionInfo Parse(ReadOnlySpan<byte> payload)
{
Require(payload, RioCommand.VersionReply);
return new VersionInfo(payload[0], payload[1]);
}
public override string ToString() => $"{Major}.{Minor}";
internal static void Require(ReadOnlySpan<byte> payload, RioCommand command)
{
int expected = RioCommandTable.PayloadLength(command);
if (payload.Length != expected)
throw new ArgumentException(
$"{command} payload must be {expected} bytes.", nameof(payload));
}
}
/// <summary>
/// A board/lamp status item from a <see cref="RioCommand.CheckReply"/>
/// (port of <c>CheckReply</c>, riovjoy2.cpp#L1303: payload is [statusType, number]).
/// The <see cref="Number"/> is a board number, lamp number, or counter depending
/// on <see cref="Type"/>.
/// </summary>
public readonly struct CheckStatus
{
public RioStatusType Type { get; }
public byte Number { get; }
public CheckStatus(RioStatusType type, byte number)
{
Type = type;
Number = number;
}
/// <summary>Decode a <see cref="RioCommand.CheckReply"/> payload (2 bytes).</summary>
public static CheckStatus Parse(ReadOnlySpan<byte> payload)
{
VersionInfo.Require(payload, RioCommand.CheckReply);
return new CheckStatus((RioStatusType)payload[0], payload[1]);
}
public override string ToString() => $"{Type}:{Number}";
}
+69
View File
@@ -0,0 +1,69 @@
namespace RioJoy.Core.Protocol;
/// <summary>What a <see cref="PacketParser"/> emitted for an input byte.</summary>
public enum RioRxEventKind
{
/// <summary>A complete packet was framed (see <see cref="RioRxEvent.Packet"/>).</summary>
Packet,
/// <summary>
/// A single byte arrived outside packet framing: a control character
/// (ACK/NAK/RESTART/IDLE) or stray garbage. See <see cref="RioRxEvent.Byte"/>.
/// </summary>
ControlByte,
/// <summary>
/// A byte with the high bit set arrived mid-packet — a framing error. The
/// in-progress packet was aborted and the parser resynchronized. Port of the
/// <c>(ch &amp; 0x80)</c> abort in <c>ReadCommBlock</c> (riovjoy2.cpp#L871).
/// </summary>
FramingError,
}
/// <summary>
/// One event produced by <see cref="PacketParser"/>. A given input byte yields
/// at most one event.
/// </summary>
public readonly struct RioRxEvent
{
public RioRxEventKind Kind { get; }
/// <summary>The framed packet (valid when <see cref="Kind"/> is <see cref="RioRxEventKind.Packet"/>).</summary>
public RioPacket Packet { get; }
/// <summary>
/// Whether the received checksum matched. Valid when <see cref="Kind"/> is
/// <see cref="RioRxEventKind.Packet"/>. The legacy receive path force-accepts
/// regardless (see docs/PROTOCOL.md §2 ⚠️); this flag exposes the real result
/// so callers can choose to NAK.
/// </summary>
public bool ChecksumValid { get; }
/// <summary>
/// The single byte (valid when <see cref="Kind"/> is
/// <see cref="RioRxEventKind.ControlByte"/> or
/// <see cref="RioRxEventKind.FramingError"/>).
/// </summary>
public byte Byte { get; }
private RioRxEvent(RioRxEventKind kind, RioPacket packet, bool checksumValid, byte b)
{
Kind = kind;
Packet = packet;
ChecksumValid = checksumValid;
Byte = b;
}
internal static RioRxEvent ForPacket(RioPacket packet, bool checksumValid) =>
new(RioRxEventKind.Packet, packet, checksumValid, 0);
internal static RioRxEvent ForControl(byte b) =>
new(RioRxEventKind.ControlByte, default, false, b);
internal static RioRxEvent ForFramingError(byte b) =>
new(RioRxEventKind.FramingError, default, false, b);
/// <summary>True if this is a control byte equal to <paramref name="control"/>.</summary>
public bool IsControl(RioControl control) =>
Kind == RioRxEventKind.ControlByte && Byte == (byte)control;
}
+22
View File
@@ -0,0 +1,22 @@
namespace RioJoy.Core.Serial;
/// <summary>
/// A bidirectional byte transport to the RIO board. Abstracts the physical COM
/// port so <see cref="RioSerialLink"/> can be driven against an in-memory fake
/// in tests. Disposing the transport releases the underlying port — which is how
/// RIOJoy yields the COM port to a native game (see docs/PLAN.md §Profiles).
/// </summary>
public interface IRioTransport : IDisposable
{
/// <summary>Human-readable description (e.g. "COM3 @ 9600 8N1").</summary>
string Description { get; }
/// <summary>
/// Read available bytes into <paramref name="buffer"/>. Returns the number of
/// bytes read; a return of 0 indicates the transport has closed.
/// </summary>
ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken);
/// <summary>Write all of <paramref name="data"/> to the transport.</summary>
ValueTask WriteAsync(ReadOnlyMemory<byte> data, CancellationToken cancellationToken);
}
+223
View File
@@ -0,0 +1,223 @@
using System.Diagnostics;
using RioJoy.Core.Protocol;
namespace RioJoy.Core.Serial;
/// <summary>
/// Drives the RIO serial link: a receive loop that frames incoming bytes into
/// packets (with ACK/NAK replies), plus the analog poll + reset-recovery timer.
/// The modern equivalent of the legacy overlapped-I/O watch thread
/// (<c>CommWatchProc</c>/<c>ReadCommBlock</c>); see docs/PROTOCOL.md §2 and §4.
///
/// <para>The link drives a supplied <see cref="IRioTransport"/> but does not own
/// it: acquiring/releasing the COM port is the transport's lifecycle (create to
/// acquire, dispose to yield).</para>
/// </summary>
public sealed class RioSerialLink
{
private readonly IRioTransport _transport;
private readonly RioSerialLinkOptions _options;
private readonly PacketParser _parser = new();
private readonly SemaphoreSlim _writeLock = new(1, 1);
// Time since the last accepted AnalogReply, for the recovery watchdog.
private readonly Stopwatch _sinceAnalog = new();
public RioSerialLink(IRioTransport transport, RioSerialLinkOptions? options = null)
{
_transport = transport ?? throw new ArgumentNullException(nameof(transport));
_options = options ?? new RioSerialLinkOptions();
}
/// <summary>Raised for every framed packet (after the ACK/NAK reply is sent).</summary>
public event Action<RioPacket>? PacketReceived;
/// <summary>Raised for a decoded, valid <see cref="RioCommand.AnalogReply"/>.</summary>
public event Action<AnalogReport>? AnalogReceived;
/// <summary>Raised for a decoded <see cref="RioCommand.VersionReply"/>.</summary>
public event Action<VersionInfo>? VersionReceived;
/// <summary>Raised for a decoded <see cref="RioCommand.CheckReply"/>.</summary>
public event Action<CheckStatus>? CheckReceived;
/// <summary>Raised for a control byte received outside framing (ACK/NAK/RESTART/IDLE/garbage).</summary>
public event Action<byte>? ControlReceived;
/// <summary>Raised when a mid-packet framing error forced a resync.</summary>
public event Action? FramingError;
/// <summary>The transport's description, surfaced for status/logging.</summary>
public string Description => _transport.Description;
/// <summary>
/// Run the receive loop and (if enabled) the analog poll loop until
/// <paramref name="cancellationToken"/> fires or the transport closes.
/// </summary>
public async Task RunAsync(CancellationToken cancellationToken)
{
_parser.Reset();
_sinceAnalog.Restart();
using var linked = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
var loops = new List<Task> { ReceiveLoopAsync(linked.Token) };
if (_options.AutoPollAnalog)
loops.Add(PollLoopAsync(linked.Token));
try
{
// If any running loop ends (transport closed / error / cancellation),
// tear the others down too.
await Task.WhenAny(loops).ConfigureAwait(false);
}
finally
{
linked.Cancel();
await Task.WhenAll(loops.Select(Swallow)).ConfigureAwait(false);
}
}
/// <summary>Send a pre-built packet (see <see cref="PacketBuilder"/>) to the RIO.</summary>
public async Task SendAsync(ReadOnlyMemory<byte> packet, CancellationToken cancellationToken = default)
{
await _writeLock.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
await _transport.WriteAsync(packet, cancellationToken).ConfigureAwait(false);
}
finally
{
_writeLock.Release();
}
}
/// <summary>Request an analog update (<see cref="RioCommand.AnalogRequest"/>).</summary>
public Task RequestAnalogAsync(CancellationToken cancellationToken = default) =>
SendAsync(PacketBuilder.AnalogRequest(), cancellationToken);
/// <summary>Request the RIO firmware version (<see cref="RioCommand.VersionRequest"/>).</summary>
public Task RequestVersionAsync(CancellationToken cancellationToken = default) =>
SendAsync(PacketBuilder.VersionRequest(), cancellationToken);
/// <summary>Request a board/lamp status check (<see cref="RioCommand.CheckRequest"/>).</summary>
public Task RequestCheckAsync(CancellationToken cancellationToken = default) =>
SendAsync(PacketBuilder.CheckRequest(), cancellationToken);
/// <summary>Issue a reset to recalibrate an axis or recover the board.</summary>
public Task ResetAsync(RioResetTarget target, CancellationToken cancellationToken = default) =>
SendAsync(PacketBuilder.ResetRequest(target), cancellationToken);
/// <summary>Set a lighted button's state (<see cref="RioCommand.LampRequest"/>).</summary>
public Task SetLampAsync(byte lampNumber, byte state, CancellationToken cancellationToken = default) =>
SendAsync(PacketBuilder.LampRequest(lampNumber, state), cancellationToken);
private async Task ReceiveLoopAsync(CancellationToken ct)
{
var buffer = new byte[_options.ReadBufferSize];
while (!ct.IsCancellationRequested)
{
int n = await _transport.ReadAsync(buffer, ct).ConfigureAwait(false);
if (n == 0)
break; // transport closed
for (int i = 0; i < n; i++)
{
if (_parser.Feed(buffer[i], out RioRxEvent ev))
await HandleEventAsync(ev, ct).ConfigureAwait(false);
}
}
}
private async Task HandleEventAsync(RioRxEvent ev, CancellationToken ct)
{
switch (ev.Kind)
{
case RioRxEventKind.Packet:
await ReplyAsync(ev, ct).ConfigureAwait(false);
DispatchTyped(ev.Packet);
PacketReceived?.Invoke(ev.Packet);
break;
case RioRxEventKind.ControlByte:
ControlReceived?.Invoke(ev.Byte);
break;
case RioRxEventKind.FramingError:
FramingError?.Invoke();
break;
}
}
private void DispatchTyped(RioPacket packet)
{
switch (packet.Command)
{
case RioCommand.AnalogReply:
if (AnalogReport.TryParse(packet.Payload.Span, out AnalogReport report))
{
_sinceAnalog.Restart();
AnalogReceived?.Invoke(report);
}
break;
case RioCommand.VersionReply:
VersionReceived?.Invoke(VersionInfo.Parse(packet.Payload.Span));
break;
case RioCommand.CheckReply:
CheckReceived?.Invoke(CheckStatus.Parse(packet.Payload.Span));
break;
}
}
private Task ReplyAsync(RioRxEvent ev, CancellationToken ct)
{
// Documented contract: ACK an accepted packet; NAK a button packet whose
// checksum failed. The legacy path force-accepts (always ACK) unless
// VerifyInboundChecksum re-enables real verification.
bool nak = _options.VerifyInboundChecksum
&& !ev.ChecksumValid
&& ev.Packet.Command is RioCommand.ButtonPressed or RioCommand.ButtonReleased;
byte reply = nak ? (byte)RioControl.Nak : (byte)RioControl.Ack;
return SendAsync(new[] { reply }, ct);
}
private async Task PollLoopAsync(CancellationToken ct)
{
try
{
while (!ct.IsCancellationRequested)
{
await Task.Delay(_options.AnalogPollInterval, ct).ConfigureAwait(false);
await RequestAnalogAsync(ct).ConfigureAwait(false);
if (_sinceAnalog.Elapsed > _options.AnalogRecoveryTimeout)
{
// No analog data for too long — recover with a general reset.
await SendAsync(PacketBuilder.ResetRequest(RioResetTarget.All), ct).ConfigureAwait(false);
_sinceAnalog.Restart(); // avoid re-issuing every tick
}
}
}
catch (OperationCanceledException)
{
// Normal shutdown.
}
}
private static async Task Swallow(Task task)
{
try
{
await task.ConfigureAwait(false);
}
catch (OperationCanceledException)
{
// Expected on teardown.
}
}
}
@@ -0,0 +1,32 @@
namespace RioJoy.Core.Serial;
/// <summary>Tunables for <see cref="RioSerialLink"/>.</summary>
public sealed record RioSerialLinkOptions
{
/// <summary>
/// How often to request an analog update while the link runs. The legacy
/// watch thread polls on a ~55 ms timeout (riovjoy2.cpp#L1069).
/// </summary>
public TimeSpan AnalogPollInterval { get; init; } = TimeSpan.FromMilliseconds(55);
/// <summary>
/// If no <see cref="Protocol.RioCommand.AnalogReply"/> arrives within this
/// window, issue a general reset to recover (legacy &gt;5 s rule,
/// riovjoy2.cpp#L1096).
/// </summary>
public TimeSpan AnalogRecoveryTimeout { get; init; } = TimeSpan.FromSeconds(5);
/// <summary>Whether the link should automatically poll for analog updates.</summary>
public bool AutoPollAnalog { get; init; } = true;
/// <summary>
/// When <see langword="true"/>, a button packet that fails its checksum is
/// NAK'd (the documented protocol). When <see langword="false"/> (default),
/// every framed packet is ACK'd regardless of checksum, matching the legacy
/// receive path which force-accepts (see docs/PROTOCOL.md §2 ⚠️).
/// </summary>
public bool VerifyInboundChecksum { get; init; }
/// <summary>Read buffer size for the receive loop.</summary>
public int ReadBufferSize { get; init; } = 256;
}
@@ -0,0 +1,58 @@
using System.IO.Ports;
namespace RioJoy.Core.Serial;
/// <summary>
/// <see cref="IRioTransport"/> backed by a physical <see cref="SerialPort"/>.
/// Opens the port at the RIO's 9600 8N1 settings and pulses DTR on open as a
/// board reset/handshake (SETDTR, 50 ms, CLRDTR — port of
/// <c>OpenConnection</c>/<c>SetupConnection</c>, riovjoy2.cpp#L747; see
/// docs/PROTOCOL.md §1).
/// </summary>
public sealed class SerialPortTransport : IRioTransport
{
/// <summary>RIO link bit rate.</summary>
public const int BaudRate = 9600;
/// <summary>DTR reset-pulse hold time on open.</summary>
public static readonly TimeSpan DtrPulse = TimeSpan.FromMilliseconds(50);
private readonly SerialPort _port;
private readonly Stream _stream;
public SerialPortTransport(string portName)
{
ArgumentException.ThrowIfNullOrWhiteSpace(portName);
_port = new SerialPort(portName, BaudRate, Parity.None, 8, StopBits.One)
{
Handshake = Handshake.None,
// The framing state machine handles timing; keep reads non-throwing.
ReadTimeout = SerialPort.InfiniteTimeout,
WriteTimeout = SerialPort.InfiniteTimeout,
};
_port.Open();
// DTR reset pulse: assert, hold, release.
_port.DtrEnable = true;
Thread.Sleep(DtrPulse);
_port.DtrEnable = false;
_stream = _port.BaseStream;
}
public string Description => $"{_port.PortName} @ {BaudRate} 8N1";
public ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken) =>
_stream.ReadAsync(buffer, cancellationToken);
public ValueTask WriteAsync(ReadOnlyMemory<byte> data, CancellationToken cancellationToken) =>
_stream.WriteAsync(data, cancellationToken);
public void Dispose()
{
// Disposing closes and releases the COM port for other owners.
_port.Dispose();
}
}
@@ -0,0 +1,46 @@
using RioJoy.Core.Protocol;
using Xunit;
namespace RioJoy.Core.Tests.Protocol;
public class AnalogReportTests
{
[Theory]
[InlineData(0x00, 0x00, 0)]
[InlineData(0x7F, 0x00, 127)]
[InlineData(0x7F, 0x3F, 8191)] // max positive 13-bit value
[InlineData(0x00, 0x40, -8192)] // bit 13 set → most negative
[InlineData(0x7F, 0x7F, -1)] // all 14 bits set → -1
public void CombinePair_SignExtends14Bit(byte low, byte high, int expected)
{
Assert.Equal((short)expected, AnalogReport.CombinePair(low, high));
}
[Fact]
public void TryParse_DecodesAxesInOrder()
{
// throttle=1, left=2, right=3, Y=4, X=5 (each from a low byte, high=0)
var payload = new byte[] { 1, 0, 2, 0, 3, 0, 4, 0, 5, 0 };
Assert.True(AnalogReport.TryParse(payload, out AnalogReport r));
Assert.Equal(1, r.Throttle);
Assert.Equal(2, r.LeftPedal);
Assert.Equal(3, r.RightPedal);
Assert.Equal(4, r.JoystickY);
Assert.Equal(5, r.JoystickX);
}
[Fact]
public void TryParse_RejectsRestartSentinel()
{
// A 0xFE anywhere in the payload marks the sample invalid.
var payload = new byte[] { 1, 0, 2, 0, 0xFE, 0, 4, 0, 5, 0 };
Assert.False(AnalogReport.TryParse(payload, out _));
}
[Fact]
public void TryParse_RejectsWrongLength()
{
Assert.Throws<ArgumentException>(() => AnalogReport.TryParse(new byte[9], out _));
}
}
@@ -0,0 +1,47 @@
using RioJoy.Core.Protocol;
using Xunit;
namespace RioJoy.Core.Tests.Protocol;
public class PacketBuilderTests
{
[Fact]
public void AnalogRequest_HasCommandAndChecksumOnly()
{
// 0x82, checksum = 0x82 & 0x7F = 0x02
Assert.Equal(new byte[] { 0x82, 0x02 }, PacketBuilder.AnalogRequest());
}
[Fact]
public void ResetRequest_EncodesTarget()
{
// 0x83, payload [0x00], checksum = 0x03
Assert.Equal(new byte[] { 0x83, 0x00, 0x03 }, PacketBuilder.ResetRequest(RioResetTarget.All));
// 0x83, payload [0x01], checksum = 0x04
Assert.Equal(new byte[] { 0x83, 0x01, 0x04 }, PacketBuilder.ResetRequest(RioResetTarget.Throttle));
}
[Fact]
public void LampRequest_EncodesLampAndState()
{
// 0x84, payload [0x05, 0x3C], checksum = (0x04 + 0x05 + 0x3C) & 0x7F = 0x45
Assert.Equal(new byte[] { 0x84, 0x05, 0x3C, 0x45 }, PacketBuilder.LampRequest(5, 0x3C));
}
[Fact]
public void Build_ValidatesPayloadLength()
{
Assert.Throws<ArgumentException>(() => PacketBuilder.Build(RioCommand.LampRequest, new byte[] { 0x01 }));
}
[Fact]
public void Build_AppendsCorrectChecksum_ForFullPayload()
{
var payload = new byte[] { 1, 0, 2, 0, 3, 0, 4, 0, 5, 0 };
byte[] packet = PacketBuilder.Build(RioCommand.AnalogReply, payload);
Assert.Equal((byte)RioCommand.AnalogReply, packet[0]);
Assert.Equal(payload, packet[1..^1]);
Assert.Equal(RioChecksum.Compute(packet[..^1]), packet[^1]);
}
}
@@ -0,0 +1,120 @@
using RioJoy.Core.Protocol;
using Xunit;
namespace RioJoy.Core.Tests.Protocol;
public class PacketParserTests
{
private static List<RioRxEvent> FeedAll(PacketParser parser, ReadOnlySpan<byte> data)
{
var events = new List<RioRxEvent>();
foreach (byte b in data)
{
if (parser.Feed(b, out RioRxEvent ev))
events.Add(ev);
}
return events;
}
[Fact]
public void FramesValidPacket_WithChecksumValid()
{
var parser = new PacketParser();
byte[] frame = PacketBuilder.Build(RioCommand.ButtonPressed, new byte[] { 0x05 });
List<RioRxEvent> events = FeedAll(parser, frame);
RioRxEvent ev = Assert.Single(events);
Assert.Equal(RioRxEventKind.Packet, ev.Kind);
Assert.True(ev.ChecksumValid);
Assert.Equal(RioCommand.ButtonPressed, ev.Packet.Command);
Assert.Equal(new byte[] { 0x05 }, ev.Packet.Payload.ToArray());
Assert.False(parser.InPacket);
}
[Fact]
public void ReportsChecksumInvalid_ButStillFramesPacket()
{
var parser = new PacketParser();
byte[] frame = PacketBuilder.Build(RioCommand.ButtonPressed, new byte[] { 0x05 });
frame[^1] ^= 0x01; // corrupt checksum (stays high-bit clear)
RioRxEvent ev = Assert.Single(FeedAll(parser, frame));
Assert.Equal(RioRxEventKind.Packet, ev.Kind);
Assert.False(ev.ChecksumValid);
}
[Fact]
public void ControlByte_OutsideFraming_IsReported()
{
var parser = new PacketParser();
RioRxEvent ev = Assert.Single(FeedAll(parser, new[] { (byte)RioControl.Ack }));
Assert.Equal(RioRxEventKind.ControlByte, ev.Kind);
Assert.True(ev.IsControl(RioControl.Ack));
}
[Fact]
public void HighBitMidPacket_AbortsAndResyncs()
{
var parser = new PacketParser();
// Start a ButtonPressed (expects 1 payload + 1 checksum), feed one payload
// byte, then a high-bit byte mid-packet → framing error + resync.
var events = new List<RioRxEvent>();
foreach (byte b in new byte[] { 0x88, 0x05, 0x82 })
{
if (parser.Feed(b, out RioRxEvent ev))
events.Add(ev);
}
RioRxEvent framing = Assert.Single(events);
Assert.Equal(RioRxEventKind.FramingError, framing.Kind);
Assert.False(parser.InPacket);
// A subsequent clean frame parses normally (the parser resynced).
byte[] next = PacketBuilder.Build(RioCommand.ButtonReleased, new byte[] { 0x07 });
RioRxEvent ev2 = Assert.Single(FeedAll(parser, next));
Assert.Equal(RioCommand.ButtonReleased, ev2.Packet.Command);
}
[Fact]
public void ParsesMultiplePackets_InOneStream()
{
var parser = new PacketParser();
byte[] a = PacketBuilder.Build(RioCommand.ButtonPressed, new byte[] { 0x01 });
byte[] b = PacketBuilder.Build(RioCommand.ButtonReleased, new byte[] { 0x02 });
byte[] stream = new byte[a.Length + b.Length];
a.CopyTo(stream, 0);
b.CopyTo(stream, a.Length);
List<RioRxEvent> events = FeedAll(parser, stream);
Assert.Equal(2, events.Count);
Assert.Equal(RioCommand.ButtonPressed, events[0].Packet.Command);
Assert.Equal(RioCommand.ButtonReleased, events[1].Packet.Command);
}
[Fact]
public void HandlesPacket_SplitAcrossFeeds()
{
var parser = new PacketParser();
byte[] frame = PacketBuilder.Build(RioCommand.AnalogReply, new byte[] { 1, 0, 2, 0, 3, 0, 4, 0, 5, 0 });
// Feed byte-by-byte: only the final byte should yield the packet event.
var events = new List<RioRxEvent>();
for (int i = 0; i < frame.Length; i++)
{
bool produced = parser.Feed(frame[i], out RioRxEvent ev);
if (produced)
events.Add(ev);
Assert.Equal(i == frame.Length - 1, produced);
}
RioRxEvent done = Assert.Single(events);
Assert.Equal(RioCommand.AnalogReply, done.Packet.Command);
Assert.True(done.ChecksumValid);
}
}
@@ -0,0 +1,36 @@
using RioJoy.Core.Protocol;
using Xunit;
namespace RioJoy.Core.Tests.Protocol;
public class RioChecksumTests
{
[Fact]
public void Empty_IsZero()
{
Assert.Equal(0, RioChecksum.Compute(ReadOnlySpan<byte>.Empty));
}
[Fact]
public void SumsLow7Bits()
{
// (0x84 & 0x7F) + (0x05 & 0x7F) + (0x3C & 0x7F) = 0x04 + 0x05 + 0x3C = 0x45
byte sum = RioChecksum.Compute(new byte[] { 0x84, 0x05, 0x3C });
Assert.Equal(0x45, sum);
}
[Fact]
public void MasksResultTo7Bits()
{
// Sum of low-7-bits = 0x7F + 0x7F = 0xFE; masked to 7 bits → 0x7E.
byte sum = RioChecksum.Compute(new byte[] { 0x7F, 0x7F });
Assert.Equal(0x7E, sum);
}
[Fact]
public void IgnoresHighBitOfInputs()
{
// 0x82 contributes only 0x02.
Assert.Equal(0x02, RioChecksum.Compute(new byte[] { 0x82 }));
}
}
@@ -0,0 +1,51 @@
using RioJoy.Core.Protocol;
using Xunit;
namespace RioJoy.Core.Tests.Protocol;
public class RioCommandTableTests
{
[Theory]
[InlineData(RioCommand.CheckRequest, 0)]
[InlineData(RioCommand.VersionRequest, 0)]
[InlineData(RioCommand.AnalogRequest, 0)]
[InlineData(RioCommand.ResetRequest, 1)]
[InlineData(RioCommand.LampRequest, 2)]
[InlineData(RioCommand.CheckReply, 2)]
[InlineData(RioCommand.VersionReply, 2)]
[InlineData(RioCommand.AnalogReply, 10)]
[InlineData(RioCommand.ButtonPressed, 1)]
[InlineData(RioCommand.ButtonReleased, 1)]
[InlineData(RioCommand.KeyPressed, 2)]
[InlineData(RioCommand.KeyReleased, 2)]
[InlineData(RioCommand.TestModeChange, 1)]
public void PayloadLengths_MatchProtocol(RioCommand command, int expected)
{
Assert.Equal(expected, RioCommandTable.PayloadLength(command));
}
[Fact]
public void Bounds_AreInclusive()
{
Assert.Equal(0x80, RioCommandTable.Base);
Assert.Equal(0x8C, RioCommandTable.Max);
Assert.Equal(13, RioCommandTable.Count);
}
[Theory]
[InlineData(0x7F, false)]
[InlineData(0x80, true)]
[InlineData(0x8C, true)]
[InlineData(0x8D, false)]
[InlineData(0xFC, false)]
public void IsCommand_RespectsRange(byte b, bool expected)
{
Assert.Equal(expected, RioCommandTable.IsCommand(b));
}
[Fact]
public void PayloadLength_ThrowsForNonCommand()
{
Assert.Throws<ArgumentOutOfRangeException>(() => RioCommandTable.PayloadLength((byte)0x8D));
}
}
@@ -0,0 +1,23 @@
using RioJoy.Core.Protocol;
using Xunit;
namespace RioJoy.Core.Tests.Protocol;
public class RioLampStateTests
{
[Fact]
public void CommonCombos_MatchLegacyValues()
{
Assert.Equal(0x00, RioLampState.SolidOff);
Assert.Equal(0x14, RioLampState.SolidDim);
Assert.Equal(0x3C, RioLampState.SolidBright);
}
[Fact]
public void Compose_AddsFlashAndBrightnessFields()
{
// flashMed(2) + field1 Dim(0x04) + field2 Bright(0x30) = 0x36
byte state = RioLampState.Compose(LampFlash.FlashMed, LampField1.Dim, LampField2.Bright);
Assert.Equal(0x36, state);
}
}
@@ -0,0 +1,31 @@
using RioJoy.Core.Protocol;
using Xunit;
namespace RioJoy.Core.Tests.Protocol;
public class RioRepliesTests
{
[Fact]
public void VersionInfo_ParsesMajorMinor()
{
VersionInfo v = VersionInfo.Parse(new byte[] { 0, 3 });
Assert.Equal(0, v.Major);
Assert.Equal(3, v.Minor);
Assert.Equal("0.3", v.ToString());
}
[Fact]
public void CheckStatus_ParsesTypeAndNumber()
{
CheckStatus s = CheckStatus.Parse(new byte[] { (byte)RioStatusType.BoardMissing, 7 });
Assert.Equal(RioStatusType.BoardMissing, s.Type);
Assert.Equal(7, s.Number);
}
[Fact]
public void Parse_RejectsWrongLength()
{
Assert.Throws<ArgumentException>(() => VersionInfo.Parse(new byte[] { 1 }));
Assert.Throws<ArgumentException>(() => CheckStatus.Parse(new byte[] { 1, 2, 3 }));
}
}
@@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0-windows</TargetFramework>
<Platforms>x64</Platforms>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\RioJoy.Core\RioJoy.Core.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,54 @@
using System.Threading.Channels;
using RioJoy.Core.Serial;
namespace RioJoy.Core.Tests.Serial;
/// <summary>
/// In-memory <see cref="IRioTransport"/> for driving <see cref="RioSerialLink"/>
/// in tests: enqueue inbound chunks, inspect outbound writes.
/// </summary>
internal sealed class FakeTransport : IRioTransport
{
private readonly Channel<byte[]> _incoming = Channel.CreateUnbounded<byte[]>();
private readonly Channel<byte[]> _writes = Channel.CreateUnbounded<byte[]>();
public string Description => "fake";
/// <summary>Outbound writes, in order. Each call to WriteAsync yields one item.</summary>
public ChannelReader<byte[]> Writes => _writes.Reader;
/// <summary>Queue an inbound chunk for the receive loop to read.</summary>
public void Enqueue(params byte[] data) => _incoming.Writer.TryWrite(data);
/// <summary>Signal that no more inbound data will arrive (transport closed).</summary>
public void CompleteIncoming() => _incoming.Writer.TryComplete();
public async ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken)
{
while (await _incoming.Reader.WaitToReadAsync(cancellationToken).ConfigureAwait(false))
{
if (_incoming.Reader.TryRead(out byte[]? chunk))
{
chunk.AsSpan().CopyTo(buffer.Span);
return chunk.Length;
}
}
return 0; // completed
}
public ValueTask WriteAsync(ReadOnlyMemory<byte> data, CancellationToken cancellationToken)
{
_writes.Writer.TryWrite(data.ToArray());
return ValueTask.CompletedTask;
}
/// <summary>Read the next outbound write, failing if none arrives in time.</summary>
public async Task<byte[]> NextWriteAsync(TimeSpan? timeout = null)
{
using var cts = new CancellationTokenSource(timeout ?? TimeSpan.FromSeconds(5));
return await _writes.Reader.ReadAsync(cts.Token).ConfigureAwait(false);
}
public void Dispose() { }
}
@@ -0,0 +1,144 @@
using RioJoy.Core.Protocol;
using RioJoy.Core.Serial;
using Xunit;
namespace RioJoy.Core.Tests.Serial;
public class RioSerialLinkTests
{
private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(5);
[Fact]
public async Task AnalogReply_DecodesAndAcks()
{
var fake = new FakeTransport();
var link = new RioSerialLink(fake, new RioSerialLinkOptions { AutoPollAnalog = false });
var gotAnalog = new TaskCompletionSource<AnalogReport>(TaskCreationOptions.RunContinuationsAsynchronously);
link.AnalogReceived += r => gotAnalog.TrySetResult(r);
using var cts = new CancellationTokenSource();
Task run = link.RunAsync(cts.Token);
byte[] frame = PacketBuilder.Build(RioCommand.AnalogReply, new byte[] { 1, 0, 2, 0, 3, 0, 4, 0, 5, 0 });
fake.Enqueue(frame);
AnalogReport report = await gotAnalog.Task.WaitAsync(Timeout);
Assert.Equal(1, report.Throttle);
Assert.Equal(5, report.JoystickX);
// The link should have ACK'd the packet.
byte[] reply = await fake.NextWriteAsync();
Assert.Equal(new byte[] { (byte)RioControl.Ack }, reply);
cts.Cancel();
await run;
}
[Fact]
public async Task BadChecksumButton_IsNakd_WhenVerificationEnabled()
{
var fake = new FakeTransport();
var link = new RioSerialLink(fake, new RioSerialLinkOptions
{
AutoPollAnalog = false,
VerifyInboundChecksum = true,
});
using var cts = new CancellationTokenSource();
Task run = link.RunAsync(cts.Token);
byte[] frame = PacketBuilder.Build(RioCommand.ButtonPressed, new byte[] { 0x05 });
frame[^1] ^= 0x01; // corrupt checksum
fake.Enqueue(frame);
byte[] reply = await fake.NextWriteAsync();
Assert.Equal(new byte[] { (byte)RioControl.Nak }, reply);
cts.Cancel();
await run;
}
[Fact]
public async Task BadChecksumButton_IsAckd_WhenVerificationDisabled()
{
var fake = new FakeTransport();
var link = new RioSerialLink(fake, new RioSerialLinkOptions { AutoPollAnalog = false });
using var cts = new CancellationTokenSource();
Task run = link.RunAsync(cts.Token);
byte[] frame = PacketBuilder.Build(RioCommand.ButtonPressed, new byte[] { 0x05 });
frame[^1] ^= 0x01;
fake.Enqueue(frame);
byte[] reply = await fake.NextWriteAsync();
Assert.Equal(new byte[] { (byte)RioControl.Ack }, reply);
cts.Cancel();
await run;
}
[Fact]
public async Task VersionReply_DecodesAndRaisesEvent()
{
var fake = new FakeTransport();
var link = new RioSerialLink(fake, new RioSerialLinkOptions { AutoPollAnalog = false });
var gotVersion = new TaskCompletionSource<VersionInfo>(TaskCreationOptions.RunContinuationsAsynchronously);
link.VersionReceived += v => gotVersion.TrySetResult(v);
using var cts = new CancellationTokenSource();
Task run = link.RunAsync(cts.Token);
fake.Enqueue(PacketBuilder.Build(RioCommand.VersionReply, new byte[] { 0, 3 }));
VersionInfo version = await gotVersion.Task.WaitAsync(Timeout);
Assert.Equal(0, version.Major);
Assert.Equal(3, version.Minor);
cts.Cancel();
await run;
}
[Fact]
public async Task ControlByte_RaisesControlReceived()
{
var fake = new FakeTransport();
var link = new RioSerialLink(fake, new RioSerialLinkOptions { AutoPollAnalog = false });
var gotControl = new TaskCompletionSource<byte>(TaskCreationOptions.RunContinuationsAsynchronously);
link.ControlReceived += b => gotControl.TrySetResult(b);
using var cts = new CancellationTokenSource();
Task run = link.RunAsync(cts.Token);
fake.Enqueue((byte)RioControl.Ack);
byte control = await gotControl.Task.WaitAsync(Timeout);
Assert.Equal((byte)RioControl.Ack, control);
cts.Cancel();
await run;
}
[Fact]
public async Task AutoPoll_SendsAnalogRequest()
{
var fake = new FakeTransport();
var link = new RioSerialLink(fake, new RioSerialLinkOptions
{
AutoPollAnalog = true,
AnalogPollInterval = TimeSpan.FromMilliseconds(20),
});
using var cts = new CancellationTokenSource();
Task run = link.RunAsync(cts.Token);
byte[] firstWrite = await fake.NextWriteAsync();
Assert.Equal(PacketBuilder.AnalogRequest(), firstWrite);
cts.Cancel();
await run;
}
}