Phase 2: serial + RIO protocol core (RioJoy.Core) with unit tests
Port the RIO wire protocol from legacy/riovjoy2.cpp into testable C#: - Protocol/: command + length table, 7-bit checksum, packet builder, and a streaming receive-side framing state machine (PacketParser) that mirrors the legacy ReadCommBlock framing/resync (high-bit-mid-packet abort). Typed RIO->PC decodes: AnalogReport (14-bit sign-extend), VersionInfo, CheckStatus; lamp-state composition. - Serial/: RioSerialLink drives an async receive loop with ACK/NAK reply policy (legacy force-accept vs. opt-in VerifyInboundChecksum), the analog poll timer, and the >5s reset-recovery watchdog. IRioTransport abstracts the COM port; the SerialPort-backed transport does 9600 8N1 + DTR reset pulse, and acquire/release is just create/dispose (foundation for native-game serial yield). - tests/RioJoy.Core.Tests: 54 xUnit tests covering checksum, framing/resync, builder round-trips, analog sign-extension + sentinel rejection, lamp combos, and the read loop driven against an in-memory fake transport. Hardware verification (version/check/analog against a cabinet) remains; it can't be done off-device. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,123 @@
|
||||
namespace RioJoy.Core.Protocol;
|
||||
|
||||
/// <summary>
|
||||
/// Streaming receive-side framing state machine, fed raw bytes from the serial
|
||||
/// port. Faithful port of the framing loop in <c>ReadCommBlock</c>
|
||||
/// (riovjoy2.cpp#L860); see docs/PROTOCOL.md §2.
|
||||
///
|
||||
/// <para>Rules:</para>
|
||||
/// <list type="bullet">
|
||||
/// <item>Outside a packet, a byte in the command range starts a packet whose
|
||||
/// total length is <c>1 + payloadLen + 1</c> (command + payload + checksum).</item>
|
||||
/// <item>Outside a packet, any other byte is reported as a
|
||||
/// <see cref="RioRxEventKind.ControlByte"/> (ACK/NAK/RESTART/IDLE or garbage).</item>
|
||||
/// <item>Mid-packet, a byte with the high bit set aborts the packet and
|
||||
/// resynchronizes (<see cref="RioRxEventKind.FramingError"/>); that byte is
|
||||
/// discarded, matching the legacy <c>continue</c>.</item>
|
||||
/// <item>When the final (checksum) byte arrives, a
|
||||
/// <see cref="RioRxEventKind.Packet"/> is emitted with
|
||||
/// <see cref="RioRxEvent.ChecksumValid"/> set from a real checksum compare.</item>
|
||||
/// </list>
|
||||
///
|
||||
/// Each input byte produces at most one event, so the per-byte
|
||||
/// <see cref="Feed(byte, out RioRxEvent)"/> returns a single optional event.
|
||||
/// This type is not thread-safe; drive it from one reader.
|
||||
/// </summary>
|
||||
public sealed class PacketParser
|
||||
{
|
||||
// Max framed packet = command(1) + AnalogReply payload(10) + checksum(1) = 12.
|
||||
private const int MaxPacketBytes = 16;
|
||||
|
||||
private readonly byte[] _buffer = new byte[MaxPacketBytes];
|
||||
|
||||
// Bytes still expected to complete the current packet (0 = not in a packet).
|
||||
private int _remaining;
|
||||
|
||||
// Count of bytes stored in _buffer for the current packet (incl. command).
|
||||
private int _count;
|
||||
|
||||
/// <summary>True while a packet is partially received.</summary>
|
||||
public bool InPacket => _remaining != 0;
|
||||
|
||||
/// <summary>Discard any in-progress packet and reset to the idle state.</summary>
|
||||
public void Reset()
|
||||
{
|
||||
_remaining = 0;
|
||||
_count = 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Feed one received byte. Returns <see langword="true"/> and sets
|
||||
/// <paramref name="ev"/> when this byte produced an event; otherwise returns
|
||||
/// <see langword="false"/> (the byte was consumed into an in-progress packet).
|
||||
/// </summary>
|
||||
public bool Feed(byte ch, out RioRxEvent ev)
|
||||
{
|
||||
if (_remaining != 0)
|
||||
{
|
||||
// Mid-packet. A high-bit byte is a framing error → abort and resync.
|
||||
if ((ch & 0x80) != 0)
|
||||
{
|
||||
Reset();
|
||||
ev = RioRxEvent.ForFramingError(ch);
|
||||
return true;
|
||||
}
|
||||
|
||||
_buffer[_count++] = ch;
|
||||
_remaining--;
|
||||
|
||||
if (_remaining == 0)
|
||||
{
|
||||
ev = CompletePacket();
|
||||
return true;
|
||||
}
|
||||
|
||||
ev = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Not in a packet.
|
||||
if (RioCommandTable.IsCommand(ch))
|
||||
{
|
||||
_count = 0;
|
||||
_buffer[_count++] = ch;
|
||||
_remaining = RioCommandTable.PayloadLength(ch) + 1; // + checksum byte
|
||||
ev = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
// A byte outside framing: control character (ACK/NAK/RESTART/IDLE) or garbage.
|
||||
ev = RioRxEvent.ForControl(ch);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Feed a chunk of received bytes, invoking <paramref name="onEvent"/> for
|
||||
/// each event produced, in order.
|
||||
/// </summary>
|
||||
public void Feed(ReadOnlySpan<byte> data, Action<RioRxEvent> onEvent)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(onEvent);
|
||||
foreach (byte ch in data)
|
||||
{
|
||||
if (Feed(ch, out RioRxEvent ev))
|
||||
onEvent(ev);
|
||||
}
|
||||
}
|
||||
|
||||
private RioRxEvent CompletePacket()
|
||||
{
|
||||
// _buffer = [command][payload…][checksum]; _count includes all of them.
|
||||
int bodyLen = _count - 1; // command + payload (exclude checksum)
|
||||
byte receivedChecksum = _buffer[bodyLen];
|
||||
byte computed = RioChecksum.Compute(_buffer.AsSpan(0, bodyLen));
|
||||
bool checksumValid = computed == receivedChecksum;
|
||||
|
||||
var command = (RioCommand)_buffer[0];
|
||||
var payload = _buffer.AsSpan(1, bodyLen - 1).ToArray(); // copy out; buffer is reused
|
||||
var packet = new RioPacket(command, payload);
|
||||
|
||||
Reset();
|
||||
return RioRxEvent.ForPacket(packet, checksumValid);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user