diff --git a/README.md b/README.md
index 5a2708a..1b29865 100644
--- a/README.md
+++ b/README.md
@@ -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.
diff --git a/RioJoy.sln b/RioJoy.sln
index 29a2167..d109dc4 100644
--- a/RioJoy.sln
+++ b/RioJoy.sln
@@ -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
diff --git a/docs/PLAN.md b/docs/PLAN.md
index f589a08..45e1fbe 100644
--- a/docs/PLAN.md
+++ b/docs/PLAN.md
@@ -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).
diff --git a/src/RioJoy.Core/Protocol/AnalogReport.cs b/src/RioJoy.Core/Protocol/AnalogReport.cs
new file mode 100644
index 0000000..9013957
--- /dev/null
+++ b/src/RioJoy.Core/Protocol/AnalogReport.cs
@@ -0,0 +1,74 @@
+namespace RioJoy.Core.Protocol;
+
+///
+/// The five raw analog axes decoded from an
+/// 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
+/// AnalogEvent / CombinePair (riovjoy2.cpp#L1116); see
+/// docs/PROTOCOL.md §4.
+///
+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;
+ }
+
+ ///
+ /// Decode a 14-bit signed axis value from a (low, high) byte pair, each
+ /// carrying 7 data bits. Port of CombinePair (riovjoy2.cpp#L1116):
+ /// combine the 7-bit halves then sign-extend bit 13.
+ ///
+ 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;
+ }
+
+ ///
+ /// Try to decode an payload (10 bytes:
+ /// 5 axes × low,high in the order throttle, left pedal, right pedal,
+ /// joystick Y, joystick X). Returns if any byte equals
+ /// 0xFE (), which the RIO uses as an
+ /// "invalid sample" sentinel — the legacy code ignores such replies.
+ ///
+ public static bool TryParse(ReadOnlySpan 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}";
+}
diff --git a/src/RioJoy.Core/Protocol/PacketBuilder.cs b/src/RioJoy.Core/Protocol/PacketBuilder.cs
new file mode 100644
index 0000000..e8d915b
--- /dev/null
+++ b/src/RioJoy.Core/Protocol/PacketBuilder.cs
@@ -0,0 +1,51 @@
+namespace RioJoy.Core.Protocol;
+
+///
+/// Builds outbound (PC → RIO) packets: [command][payload…][checksum].
+/// Port of SendCommand (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.
+///
+public static class PacketBuilder
+{
+ ///
+ /// Build a wire-ready packet for with the given
+ /// . The payload length must match the command's
+ /// entry in the length table.
+ ///
+ public static byte[] Build(RioCommand command, ReadOnlySpan 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;
+ }
+
+ /// Build a zero-payload command packet (CheckRequest etc.).
+ public static byte[] Build(RioCommand command) => Build(command, ReadOnlySpan.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 });
+
+ ///
+ /// LampRequest: payload is [lamp#, state]. The state byte is a
+ /// 7-bit lamp-state value (see ).
+ ///
+ public static byte[] LampRequest(byte lampNumber, byte state) =>
+ Build(RioCommand.LampRequest, stackalloc byte[] { lampNumber, state });
+}
diff --git a/src/RioJoy.Core/Protocol/PacketParser.cs b/src/RioJoy.Core/Protocol/PacketParser.cs
new file mode 100644
index 0000000..5faae99
--- /dev/null
+++ b/src/RioJoy.Core/Protocol/PacketParser.cs
@@ -0,0 +1,123 @@
+namespace RioJoy.Core.Protocol;
+
+///
+/// Streaming receive-side framing state machine, fed raw bytes from the serial
+/// port. Faithful port of the framing loop in ReadCommBlock
+/// (riovjoy2.cpp#L860); see docs/PROTOCOL.md §2.
+///
+/// Rules:
+///
+/// - Outside a packet, a byte in the command range starts a packet whose
+/// total length is 1 + payloadLen + 1 (command + payload + checksum).
+/// - Outside a packet, any other byte is reported as a
+/// (ACK/NAK/RESTART/IDLE or garbage).
+/// - Mid-packet, a byte with the high bit set aborts the packet and
+/// resynchronizes (); that byte is
+/// discarded, matching the legacy continue.
+/// - When the final (checksum) byte arrives, a
+/// is emitted with
+/// set from a real checksum compare.
+///
+///
+/// Each input byte produces at most one event, so the per-byte
+/// returns a single optional event.
+/// This type is not thread-safe; drive it from one reader.
+///
+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;
+
+ /// True while a packet is partially received.
+ public bool InPacket => _remaining != 0;
+
+ /// Discard any in-progress packet and reset to the idle state.
+ public void Reset()
+ {
+ _remaining = 0;
+ _count = 0;
+ }
+
+ ///
+ /// Feed one received byte. Returns and sets
+ /// when this byte produced an event; otherwise returns
+ /// (the byte was consumed into an in-progress packet).
+ ///
+ 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;
+ }
+
+ ///
+ /// Feed a chunk of received bytes, invoking for
+ /// each event produced, in order.
+ ///
+ public void Feed(ReadOnlySpan data, Action 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);
+ }
+}
diff --git a/src/RioJoy.Core/Protocol/RioChecksum.cs b/src/RioJoy.Core/Protocol/RioChecksum.cs
new file mode 100644
index 0000000..2664cf4
--- /dev/null
+++ b/src/RioJoy.Core/Protocol/RioChecksum.cs
@@ -0,0 +1,23 @@
+namespace RioJoy.Core.Protocol;
+
+///
+/// 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 SendCommand (riovjoy2.cpp#L1227) and the
+/// verify loop in ReadCommBlock (riovjoy2.cpp#L884); see docs/PROTOCOL.md §2.
+///
+public static class RioChecksum
+{
+ ///
+ /// Compute the 7-bit checksum over
+ /// (the command byte followed by its payload bytes — not the checksum byte).
+ ///
+ public static byte Compute(ReadOnlySpan commandAndPayload)
+ {
+ byte sum = 0;
+ foreach (byte b in commandAndPayload)
+ sum += (byte)(b & 0x7F);
+
+ return (byte)(sum & 0x7F);
+ }
+}
diff --git a/src/RioJoy.Core/Protocol/RioCommand.cs b/src/RioJoy.Core/Protocol/RioCommand.cs
new file mode 100644
index 0000000..00ef9ab
--- /dev/null
+++ b/src/RioJoy.Core/Protocol/RioCommand.cs
@@ -0,0 +1,129 @@
+namespace RioJoy.Core.Protocol;
+
+///
+/// RIO message commands. The command byte always has its high bit set; the
+/// value identifies the message. Faithful port of enum RIOCommand and
+/// g_baRIOLengthsA in legacy/riovjoy2.cpp (see docs/PROTOCOL.md §3).
+///
+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,
+}
+
+///
+/// Single-byte control characters that live outside packet framing
+/// (legacy #define ACK_CHAR … IDLE_CHAR, riovjoy2.cpp#L154).
+///
+public enum RioControl : byte
+{
+ /// Packet accepted.
+ Ack = 0xFC,
+
+ /// Packet rejected; resend.
+ Nak = 0xFD,
+
+ /// Restart; also used as an in-payload "invalid" sentinel.
+ Restart = 0xFE,
+
+ /// Idle.
+ Idle = 0xFF,
+}
+
+///
+/// Reset targets for (docs/PROTOCOL.md §3).
+///
+public enum RioResetTarget : byte
+{
+ All = 0,
+ Throttle = 1,
+ LeftPedal = 2,
+ RightPedal = 3,
+ VerticalJoystick = 4, // Y
+ HorizontalJoystick = 5, // X
+}
+
+///
+/// The statusType field of
+/// (legacy enum RIOStatusType, riovjoy2.cpp#L204).
+///
+public enum RioStatusType : byte
+{
+ BoardOk = 0,
+ BoardMissing = 1,
+ BoardBad = 2,
+ LampBad = 3,
+ RestartCount = 4,
+ AbandonCount = 5,
+ FullBufferCount = 6,
+}
+
+///
+/// Per-command payload lengths (bytes between the command byte and the
+/// checksum byte). Port of g_baRIOLengthsA.
+///
+public static class RioCommandTable
+{
+ /// Lowest command byte value ().
+ 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
+ };
+
+ /// Number of defined commands.
+ public static int Count => PayloadLengths.Length;
+
+ /// Highest valid command byte value (inclusive).
+ public static byte Max => (byte)(Base + Count - 1);
+
+ /// True if is a known command.
+ public static bool IsCommand(byte commandByte) =>
+ commandByte >= Base && commandByte <= Max;
+
+ ///
+ /// Payload length for a command byte. Throws if the byte is not a known
+ /// command — callers framing a stream should guard with
+ /// first.
+ ///
+ 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];
+ }
+
+ /// Payload length for a .
+ public static int PayloadLength(RioCommand command) => PayloadLength((byte)command);
+}
diff --git a/src/RioJoy.Core/Protocol/RioLampState.cs b/src/RioJoy.Core/Protocol/RioLampState.cs
new file mode 100644
index 0000000..79b063a
--- /dev/null
+++ b/src/RioJoy.Core/Protocol/RioLampState.cs
@@ -0,0 +1,50 @@
+namespace RioJoy.Core.Protocol;
+
+/// Flash mode for a lighted button (lamp-state bits 0–1).
+public enum LampFlash : byte
+{
+ Solid = 0,
+ FlashSlow = 1,
+ FlashMed = 2,
+ FlashFast = 3,
+}
+
+/// Lamp brightness "field 1" (bits 2–3).
+public enum LampField1 : byte
+{
+ Off = 0x00,
+ Dim = 0x04,
+ Bright = 0x0C,
+}
+
+/// Lamp brightness "field 2" (bits 4–5).
+public enum LampField2 : byte
+{
+ Off = 0x00,
+ Dim = 0x10,
+ Bright = 0x30,
+}
+
+///
+/// The state byte of a , composed of
+/// a flash mode plus two brightness fields. Port of enum LampState
+/// (riovjoy2.cpp#L213); see docs/PROTOCOL.md §3.
+///
+public static class RioLampState
+{
+ /// Compose a lamp-state byte from its flash + brightness parts.
+ public static byte Compose(LampFlash flash, LampField1 field1, LampField2 field2) =>
+ (byte)((byte)flash + (byte)field1 + (byte)field2);
+
+ /// Lamp off: SolidOff (0x00).
+ public static readonly byte SolidOff =
+ Compose(LampFlash.Solid, LampField1.Off, LampField2.Off);
+
+ /// Dim solid: SolidDim (0x14). Used for idle/released lamps.
+ public static readonly byte SolidDim =
+ Compose(LampFlash.Solid, LampField1.Dim, LampField2.Dim);
+
+ /// Bright solid: SolidBright (0x3C). Used for pressed lamps.
+ public static readonly byte SolidBright =
+ Compose(LampFlash.Solid, LampField1.Bright, LampField2.Bright);
+}
diff --git a/src/RioJoy.Core/Protocol/RioPacket.cs b/src/RioJoy.Core/Protocol/RioPacket.cs
new file mode 100644
index 0000000..60adc2d
--- /dev/null
+++ b/src/RioJoy.Core/Protocol/RioPacket.cs
@@ -0,0 +1,36 @@
+namespace RioJoy.Core.Protocol;
+
+///
+/// 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.
+///
+public readonly struct RioPacket
+{
+ /// The command byte.
+ public RioCommand Command { get; }
+
+ ///
+ /// The payload bytes (high bit always clear). Length matches
+ /// .
+ ///
+ public ReadOnlyMemory Payload { get; }
+
+ public RioPacket(RioCommand command, ReadOnlyMemory 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}]";
+ }
+}
diff --git a/src/RioJoy.Core/Protocol/RioReplies.cs b/src/RioJoy.Core/Protocol/RioReplies.cs
new file mode 100644
index 0000000..2589f65
--- /dev/null
+++ b/src/RioJoy.Core/Protocol/RioReplies.cs
@@ -0,0 +1,61 @@
+namespace RioJoy.Core.Protocol;
+
+///
+/// The RIO firmware version from a
+/// (port of VersionReply, riovjoy2.cpp#L1484: payload is [major, minor]).
+///
+public readonly struct VersionInfo
+{
+ public byte Major { get; }
+ public byte Minor { get; }
+
+ public VersionInfo(byte major, byte minor)
+ {
+ Major = major;
+ Minor = minor;
+ }
+
+ /// Decode a payload (2 bytes).
+ public static VersionInfo Parse(ReadOnlySpan payload)
+ {
+ Require(payload, RioCommand.VersionReply);
+ return new VersionInfo(payload[0], payload[1]);
+ }
+
+ public override string ToString() => $"{Major}.{Minor}";
+
+ internal static void Require(ReadOnlySpan payload, RioCommand command)
+ {
+ int expected = RioCommandTable.PayloadLength(command);
+ if (payload.Length != expected)
+ throw new ArgumentException(
+ $"{command} payload must be {expected} bytes.", nameof(payload));
+ }
+}
+
+///
+/// A board/lamp status item from a
+/// (port of CheckReply, riovjoy2.cpp#L1303: payload is [statusType, number]).
+/// The is a board number, lamp number, or counter depending
+/// on .
+///
+public readonly struct CheckStatus
+{
+ public RioStatusType Type { get; }
+ public byte Number { get; }
+
+ public CheckStatus(RioStatusType type, byte number)
+ {
+ Type = type;
+ Number = number;
+ }
+
+ /// Decode a payload (2 bytes).
+ public static CheckStatus Parse(ReadOnlySpan payload)
+ {
+ VersionInfo.Require(payload, RioCommand.CheckReply);
+ return new CheckStatus((RioStatusType)payload[0], payload[1]);
+ }
+
+ public override string ToString() => $"{Type}:{Number}";
+}
diff --git a/src/RioJoy.Core/Protocol/RioRxEvent.cs b/src/RioJoy.Core/Protocol/RioRxEvent.cs
new file mode 100644
index 0000000..dd78252
--- /dev/null
+++ b/src/RioJoy.Core/Protocol/RioRxEvent.cs
@@ -0,0 +1,69 @@
+namespace RioJoy.Core.Protocol;
+
+/// What a emitted for an input byte.
+public enum RioRxEventKind
+{
+ /// A complete packet was framed (see ).
+ Packet,
+
+ ///
+ /// A single byte arrived outside packet framing: a control character
+ /// (ACK/NAK/RESTART/IDLE) or stray garbage. See .
+ ///
+ ControlByte,
+
+ ///
+ /// 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
+ /// (ch & 0x80) abort in ReadCommBlock (riovjoy2.cpp#L871).
+ ///
+ FramingError,
+}
+
+///
+/// One event produced by . A given input byte yields
+/// at most one event.
+///
+public readonly struct RioRxEvent
+{
+ public RioRxEventKind Kind { get; }
+
+ /// The framed packet (valid when is ).
+ public RioPacket Packet { get; }
+
+ ///
+ /// Whether the received checksum matched. Valid when is
+ /// . The legacy receive path force-accepts
+ /// regardless (see docs/PROTOCOL.md §2 ⚠️); this flag exposes the real result
+ /// so callers can choose to NAK.
+ ///
+ public bool ChecksumValid { get; }
+
+ ///
+ /// The single byte (valid when is
+ /// or
+ /// ).
+ ///
+ 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);
+
+ /// True if this is a control byte equal to .
+ public bool IsControl(RioControl control) =>
+ Kind == RioRxEventKind.ControlByte && Byte == (byte)control;
+}
diff --git a/src/RioJoy.Core/Serial/IRioTransport.cs b/src/RioJoy.Core/Serial/IRioTransport.cs
new file mode 100644
index 0000000..9692281
--- /dev/null
+++ b/src/RioJoy.Core/Serial/IRioTransport.cs
@@ -0,0 +1,22 @@
+namespace RioJoy.Core.Serial;
+
+///
+/// A bidirectional byte transport to the RIO board. Abstracts the physical COM
+/// port so 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).
+///
+public interface IRioTransport : IDisposable
+{
+ /// Human-readable description (e.g. "COM3 @ 9600 8N1").
+ string Description { get; }
+
+ ///
+ /// Read available bytes into . Returns the number of
+ /// bytes read; a return of 0 indicates the transport has closed.
+ ///
+ ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken);
+
+ /// Write all of to the transport.
+ ValueTask WriteAsync(ReadOnlyMemory data, CancellationToken cancellationToken);
+}
diff --git a/src/RioJoy.Core/Serial/RioSerialLink.cs b/src/RioJoy.Core/Serial/RioSerialLink.cs
new file mode 100644
index 0000000..1104e73
--- /dev/null
+++ b/src/RioJoy.Core/Serial/RioSerialLink.cs
@@ -0,0 +1,223 @@
+using System.Diagnostics;
+using RioJoy.Core.Protocol;
+
+namespace RioJoy.Core.Serial;
+
+///
+/// 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
+/// (CommWatchProc/ReadCommBlock); see docs/PROTOCOL.md §2 and §4.
+///
+/// The link drives a supplied but does not own
+/// it: acquiring/releasing the COM port is the transport's lifecycle (create to
+/// acquire, dispose to yield).
+///
+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();
+ }
+
+ /// Raised for every framed packet (after the ACK/NAK reply is sent).
+ public event Action? PacketReceived;
+
+ /// Raised for a decoded, valid .
+ public event Action? AnalogReceived;
+
+ /// Raised for a decoded .
+ public event Action? VersionReceived;
+
+ /// Raised for a decoded .
+ public event Action? CheckReceived;
+
+ /// Raised for a control byte received outside framing (ACK/NAK/RESTART/IDLE/garbage).
+ public event Action? ControlReceived;
+
+ /// Raised when a mid-packet framing error forced a resync.
+ public event Action? FramingError;
+
+ /// The transport's description, surfaced for status/logging.
+ public string Description => _transport.Description;
+
+ ///
+ /// Run the receive loop and (if enabled) the analog poll loop until
+ /// fires or the transport closes.
+ ///
+ public async Task RunAsync(CancellationToken cancellationToken)
+ {
+ _parser.Reset();
+ _sinceAnalog.Restart();
+
+ using var linked = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+
+ var loops = new List { 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);
+ }
+ }
+
+ /// Send a pre-built packet (see ) to the RIO.
+ public async Task SendAsync(ReadOnlyMemory packet, CancellationToken cancellationToken = default)
+ {
+ await _writeLock.WaitAsync(cancellationToken).ConfigureAwait(false);
+ try
+ {
+ await _transport.WriteAsync(packet, cancellationToken).ConfigureAwait(false);
+ }
+ finally
+ {
+ _writeLock.Release();
+ }
+ }
+
+ /// Request an analog update ().
+ public Task RequestAnalogAsync(CancellationToken cancellationToken = default) =>
+ SendAsync(PacketBuilder.AnalogRequest(), cancellationToken);
+
+ /// Request the RIO firmware version ().
+ public Task RequestVersionAsync(CancellationToken cancellationToken = default) =>
+ SendAsync(PacketBuilder.VersionRequest(), cancellationToken);
+
+ /// Request a board/lamp status check ().
+ public Task RequestCheckAsync(CancellationToken cancellationToken = default) =>
+ SendAsync(PacketBuilder.CheckRequest(), cancellationToken);
+
+ /// Issue a reset to recalibrate an axis or recover the board.
+ public Task ResetAsync(RioResetTarget target, CancellationToken cancellationToken = default) =>
+ SendAsync(PacketBuilder.ResetRequest(target), cancellationToken);
+
+ /// Set a lighted button's state ().
+ 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.
+ }
+ }
+}
diff --git a/src/RioJoy.Core/Serial/RioSerialLinkOptions.cs b/src/RioJoy.Core/Serial/RioSerialLinkOptions.cs
new file mode 100644
index 0000000..cd0e8e1
--- /dev/null
+++ b/src/RioJoy.Core/Serial/RioSerialLinkOptions.cs
@@ -0,0 +1,32 @@
+namespace RioJoy.Core.Serial;
+
+/// Tunables for .
+public sealed record RioSerialLinkOptions
+{
+ ///
+ /// How often to request an analog update while the link runs. The legacy
+ /// watch thread polls on a ~55 ms timeout (riovjoy2.cpp#L1069).
+ ///
+ public TimeSpan AnalogPollInterval { get; init; } = TimeSpan.FromMilliseconds(55);
+
+ ///
+ /// If no arrives within this
+ /// window, issue a general reset to recover (legacy >5 s rule,
+ /// riovjoy2.cpp#L1096).
+ ///
+ public TimeSpan AnalogRecoveryTimeout { get; init; } = TimeSpan.FromSeconds(5);
+
+ /// Whether the link should automatically poll for analog updates.
+ public bool AutoPollAnalog { get; init; } = true;
+
+ ///
+ /// When , a button packet that fails its checksum is
+ /// NAK'd (the documented protocol). When (default),
+ /// every framed packet is ACK'd regardless of checksum, matching the legacy
+ /// receive path which force-accepts (see docs/PROTOCOL.md §2 ⚠️).
+ ///
+ public bool VerifyInboundChecksum { get; init; }
+
+ /// Read buffer size for the receive loop.
+ public int ReadBufferSize { get; init; } = 256;
+}
diff --git a/src/RioJoy.Core/Serial/SerialPortTransport.cs b/src/RioJoy.Core/Serial/SerialPortTransport.cs
new file mode 100644
index 0000000..e808671
--- /dev/null
+++ b/src/RioJoy.Core/Serial/SerialPortTransport.cs
@@ -0,0 +1,58 @@
+using System.IO.Ports;
+
+namespace RioJoy.Core.Serial;
+
+///
+/// backed by a physical .
+/// 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
+/// OpenConnection/SetupConnection, riovjoy2.cpp#L747; see
+/// docs/PROTOCOL.md §1).
+///
+public sealed class SerialPortTransport : IRioTransport
+{
+ /// RIO link bit rate.
+ public const int BaudRate = 9600;
+
+ /// DTR reset-pulse hold time on open.
+ 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 ReadAsync(Memory buffer, CancellationToken cancellationToken) =>
+ _stream.ReadAsync(buffer, cancellationToken);
+
+ public ValueTask WriteAsync(ReadOnlyMemory data, CancellationToken cancellationToken) =>
+ _stream.WriteAsync(data, cancellationToken);
+
+ public void Dispose()
+ {
+ // Disposing closes and releases the COM port for other owners.
+ _port.Dispose();
+ }
+}
diff --git a/tests/RioJoy.Core.Tests/Protocol/AnalogReportTests.cs b/tests/RioJoy.Core.Tests/Protocol/AnalogReportTests.cs
new file mode 100644
index 0000000..04b7f2a
--- /dev/null
+++ b/tests/RioJoy.Core.Tests/Protocol/AnalogReportTests.cs
@@ -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(() => AnalogReport.TryParse(new byte[9], out _));
+ }
+}
diff --git a/tests/RioJoy.Core.Tests/Protocol/PacketBuilderTests.cs b/tests/RioJoy.Core.Tests/Protocol/PacketBuilderTests.cs
new file mode 100644
index 0000000..273d2f0
--- /dev/null
+++ b/tests/RioJoy.Core.Tests/Protocol/PacketBuilderTests.cs
@@ -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(() => 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]);
+ }
+}
diff --git a/tests/RioJoy.Core.Tests/Protocol/PacketParserTests.cs b/tests/RioJoy.Core.Tests/Protocol/PacketParserTests.cs
new file mode 100644
index 0000000..eaa57be
--- /dev/null
+++ b/tests/RioJoy.Core.Tests/Protocol/PacketParserTests.cs
@@ -0,0 +1,120 @@
+using RioJoy.Core.Protocol;
+using Xunit;
+
+namespace RioJoy.Core.Tests.Protocol;
+
+public class PacketParserTests
+{
+ private static List FeedAll(PacketParser parser, ReadOnlySpan data)
+ {
+ var events = new List();
+ 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 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();
+ 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 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();
+ 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);
+ }
+}
diff --git a/tests/RioJoy.Core.Tests/Protocol/RioChecksumTests.cs b/tests/RioJoy.Core.Tests/Protocol/RioChecksumTests.cs
new file mode 100644
index 0000000..cb4691a
--- /dev/null
+++ b/tests/RioJoy.Core.Tests/Protocol/RioChecksumTests.cs
@@ -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.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 }));
+ }
+}
diff --git a/tests/RioJoy.Core.Tests/Protocol/RioCommandTableTests.cs b/tests/RioJoy.Core.Tests/Protocol/RioCommandTableTests.cs
new file mode 100644
index 0000000..44a643c
--- /dev/null
+++ b/tests/RioJoy.Core.Tests/Protocol/RioCommandTableTests.cs
@@ -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(() => RioCommandTable.PayloadLength((byte)0x8D));
+ }
+}
diff --git a/tests/RioJoy.Core.Tests/Protocol/RioLampStateTests.cs b/tests/RioJoy.Core.Tests/Protocol/RioLampStateTests.cs
new file mode 100644
index 0000000..247b382
--- /dev/null
+++ b/tests/RioJoy.Core.Tests/Protocol/RioLampStateTests.cs
@@ -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);
+ }
+}
diff --git a/tests/RioJoy.Core.Tests/Protocol/RioRepliesTests.cs b/tests/RioJoy.Core.Tests/Protocol/RioRepliesTests.cs
new file mode 100644
index 0000000..72bb62d
--- /dev/null
+++ b/tests/RioJoy.Core.Tests/Protocol/RioRepliesTests.cs
@@ -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(() => VersionInfo.Parse(new byte[] { 1 }));
+ Assert.Throws(() => CheckStatus.Parse(new byte[] { 1, 2, 3 }));
+ }
+}
diff --git a/tests/RioJoy.Core.Tests/RioJoy.Core.Tests.csproj b/tests/RioJoy.Core.Tests/RioJoy.Core.Tests.csproj
new file mode 100644
index 0000000..58ada06
--- /dev/null
+++ b/tests/RioJoy.Core.Tests/RioJoy.Core.Tests.csproj
@@ -0,0 +1,22 @@
+
+
+
+ net8.0-windows
+ x64
+ enable
+ enable
+ latest
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/RioJoy.Core.Tests/Serial/FakeTransport.cs b/tests/RioJoy.Core.Tests/Serial/FakeTransport.cs
new file mode 100644
index 0000000..e820fd8
--- /dev/null
+++ b/tests/RioJoy.Core.Tests/Serial/FakeTransport.cs
@@ -0,0 +1,54 @@
+using System.Threading.Channels;
+using RioJoy.Core.Serial;
+
+namespace RioJoy.Core.Tests.Serial;
+
+///
+/// In-memory for driving
+/// in tests: enqueue inbound chunks, inspect outbound writes.
+///
+internal sealed class FakeTransport : IRioTransport
+{
+ private readonly Channel _incoming = Channel.CreateUnbounded();
+ private readonly Channel _writes = Channel.CreateUnbounded();
+
+ public string Description => "fake";
+
+ /// Outbound writes, in order. Each call to WriteAsync yields one item.
+ public ChannelReader Writes => _writes.Reader;
+
+ /// Queue an inbound chunk for the receive loop to read.
+ public void Enqueue(params byte[] data) => _incoming.Writer.TryWrite(data);
+
+ /// Signal that no more inbound data will arrive (transport closed).
+ public void CompleteIncoming() => _incoming.Writer.TryComplete();
+
+ public async ValueTask ReadAsync(Memory 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 data, CancellationToken cancellationToken)
+ {
+ _writes.Writer.TryWrite(data.ToArray());
+ return ValueTask.CompletedTask;
+ }
+
+ /// Read the next outbound write, failing if none arrives in time.
+ public async Task 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() { }
+}
diff --git a/tests/RioJoy.Core.Tests/Serial/RioSerialLinkTests.cs b/tests/RioJoy.Core.Tests/Serial/RioSerialLinkTests.cs
new file mode 100644
index 0000000..50031f6
--- /dev/null
+++ b/tests/RioJoy.Core.Tests/Serial/RioSerialLinkTests.cs
@@ -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(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(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(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;
+ }
+}