From b3cb764f4d826a5f00e8c2e6b10dac91055dda1f Mon Sep 17 00:00:00 2001 From: Cyd Date: Fri, 26 Jun 2026 13:04:03 -0500 Subject: [PATCH] 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 --- README.md | 5 +- RioJoy.sln | 9 + docs/PLAN.md | 21 +- src/RioJoy.Core/Protocol/AnalogReport.cs | 74 ++++++ src/RioJoy.Core/Protocol/PacketBuilder.cs | 51 ++++ src/RioJoy.Core/Protocol/PacketParser.cs | 123 ++++++++++ src/RioJoy.Core/Protocol/RioChecksum.cs | 23 ++ src/RioJoy.Core/Protocol/RioCommand.cs | 129 ++++++++++ src/RioJoy.Core/Protocol/RioLampState.cs | 50 ++++ src/RioJoy.Core/Protocol/RioPacket.cs | 36 +++ src/RioJoy.Core/Protocol/RioReplies.cs | 61 +++++ src/RioJoy.Core/Protocol/RioRxEvent.cs | 69 ++++++ src/RioJoy.Core/Serial/IRioTransport.cs | 22 ++ src/RioJoy.Core/Serial/RioSerialLink.cs | 223 ++++++++++++++++++ .../Serial/RioSerialLinkOptions.cs | 32 +++ src/RioJoy.Core/Serial/SerialPortTransport.cs | 58 +++++ .../Protocol/AnalogReportTests.cs | 46 ++++ .../Protocol/PacketBuilderTests.cs | 47 ++++ .../Protocol/PacketParserTests.cs | 120 ++++++++++ .../Protocol/RioChecksumTests.cs | 36 +++ .../Protocol/RioCommandTableTests.cs | 51 ++++ .../Protocol/RioLampStateTests.cs | 23 ++ .../Protocol/RioRepliesTests.cs | 31 +++ .../RioJoy.Core.Tests.csproj | 22 ++ .../RioJoy.Core.Tests/Serial/FakeTransport.cs | 54 +++++ .../Serial/RioSerialLinkTests.cs | 144 +++++++++++ 26 files changed, 1553 insertions(+), 7 deletions(-) create mode 100644 src/RioJoy.Core/Protocol/AnalogReport.cs create mode 100644 src/RioJoy.Core/Protocol/PacketBuilder.cs create mode 100644 src/RioJoy.Core/Protocol/PacketParser.cs create mode 100644 src/RioJoy.Core/Protocol/RioChecksum.cs create mode 100644 src/RioJoy.Core/Protocol/RioCommand.cs create mode 100644 src/RioJoy.Core/Protocol/RioLampState.cs create mode 100644 src/RioJoy.Core/Protocol/RioPacket.cs create mode 100644 src/RioJoy.Core/Protocol/RioReplies.cs create mode 100644 src/RioJoy.Core/Protocol/RioRxEvent.cs create mode 100644 src/RioJoy.Core/Serial/IRioTransport.cs create mode 100644 src/RioJoy.Core/Serial/RioSerialLink.cs create mode 100644 src/RioJoy.Core/Serial/RioSerialLinkOptions.cs create mode 100644 src/RioJoy.Core/Serial/SerialPortTransport.cs create mode 100644 tests/RioJoy.Core.Tests/Protocol/AnalogReportTests.cs create mode 100644 tests/RioJoy.Core.Tests/Protocol/PacketBuilderTests.cs create mode 100644 tests/RioJoy.Core.Tests/Protocol/PacketParserTests.cs create mode 100644 tests/RioJoy.Core.Tests/Protocol/RioChecksumTests.cs create mode 100644 tests/RioJoy.Core.Tests/Protocol/RioCommandTableTests.cs create mode 100644 tests/RioJoy.Core.Tests/Protocol/RioLampStateTests.cs create mode 100644 tests/RioJoy.Core.Tests/Protocol/RioRepliesTests.cs create mode 100644 tests/RioJoy.Core.Tests/RioJoy.Core.Tests.csproj create mode 100644 tests/RioJoy.Core.Tests/Serial/FakeTransport.cs create mode 100644 tests/RioJoy.Core.Tests/Serial/RioSerialLinkTests.cs 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_CHARIDLE_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; + } +}