vRIO: virtual RIO cockpit device emulator
Speaks the device side of the RIO serial protocol (per riojoy's PROTOCOL.md) on a COM port, behind an interactive replica of the profile editor's cockpit panel: click cells to press buttons/keys, drag the encoder gauges to move the five analog axes, and watch host-commanded lamp states (incl. flash modes) light the cells. Device behavior grounded in the real v4.2 firmware dump: version 4.2, 4-retry NAK budget ending in RESTART, and an optional emulation of the analog reply-wedge latch leak for exercising host recovery watchdogs. Verified: 33 unit tests, plus an interop harness driving RIOJoy's actual RioSerialLink against VRioDevice over an in-memory transport (version/check/analog/lamp/button/keypad/reset all round-trip). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,99 @@
|
||||
using VRio.Core.Protocol;
|
||||
using Xunit;
|
||||
|
||||
namespace VRio.Core.Tests;
|
||||
|
||||
public class ProtocolTests
|
||||
{
|
||||
[Fact]
|
||||
public void Checksum_is_low7_of_sum_of_low7()
|
||||
{
|
||||
// 0x88 & 0x7F = 0x08, + 0x05 = 0x0D.
|
||||
Assert.Equal(0x0D, RioChecksum.Compute(new byte[] { 0x88, 0x05 }));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(1)]
|
||||
[InlineData(-1)]
|
||||
[InlineData(8191)]
|
||||
[InlineData(-8192)]
|
||||
[InlineData(1234)]
|
||||
[InlineData(-4321)]
|
||||
public void AnalogCodec_round_trips_and_stays_7bit(short value)
|
||||
{
|
||||
AnalogCodec.Split(value, out byte low, out byte high);
|
||||
|
||||
Assert.Equal(0, low & 0x80); // payload bytes must keep the high bit clear
|
||||
Assert.Equal(0, high & 0x80);
|
||||
Assert.Equal(value, AnalogCodec.Combine(low, high));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Builder_output_parses_back_with_valid_checksum()
|
||||
{
|
||||
byte[] wire = PacketBuilder.AnalogReply(100, -200, 300, -8192, 8191);
|
||||
|
||||
var parser = new PacketParser();
|
||||
RioRxEvent? got = null;
|
||||
foreach (byte b in wire)
|
||||
if (parser.Feed(b, out RioRxEvent ev))
|
||||
got = ev;
|
||||
|
||||
Assert.NotNull(got);
|
||||
Assert.Equal(RioRxKind.Packet, got!.Value.Kind);
|
||||
Assert.True(got.Value.ChecksumValid);
|
||||
Assert.Equal(RioCommand.AnalogReply, got.Value.Packet.Command);
|
||||
|
||||
byte[] p = got.Value.Packet.Payload;
|
||||
Assert.Equal(100, AnalogCodec.Combine(p[0], p[1]));
|
||||
Assert.Equal(-200, AnalogCodec.Combine(p[2], p[3]));
|
||||
Assert.Equal(300, AnalogCodec.Combine(p[4], p[5]));
|
||||
Assert.Equal(-8192, AnalogCodec.Combine(p[6], p[7]));
|
||||
Assert.Equal(8191, AnalogCodec.Combine(p[8], p[9]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parser_reports_control_bytes_outside_framing()
|
||||
{
|
||||
var parser = new PacketParser();
|
||||
Assert.True(parser.Feed((byte)RioControl.Ack, out RioRxEvent ev));
|
||||
Assert.Equal(RioRxKind.ControlByte, ev.Kind);
|
||||
Assert.Equal((byte)RioControl.Ack, ev.Byte);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parser_resyncs_on_high_bit_byte_mid_packet()
|
||||
{
|
||||
var parser = new PacketParser();
|
||||
|
||||
// Start a LampRequest (2-byte payload), then interrupt with a new command byte.
|
||||
Assert.False(parser.Feed(0x84, out _));
|
||||
Assert.True(parser.Feed(0xFF, out RioRxEvent err)); // IDLE mid-packet = framing error
|
||||
Assert.Equal(RioRxKind.FramingError, err.Kind);
|
||||
Assert.False(parser.InPacket);
|
||||
|
||||
// A clean packet right after must still parse.
|
||||
byte[] wire = PacketBuilder.VersionReply(1, 2);
|
||||
RioRxEvent? got = null;
|
||||
foreach (byte b in wire)
|
||||
if (parser.Feed(b, out RioRxEvent ev))
|
||||
got = ev;
|
||||
Assert.Equal(RioRxKind.Packet, got!.Value.Kind);
|
||||
Assert.True(got.Value.ChecksumValid);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0x00, LampBrightness.Off, LampFlash.Solid)]
|
||||
[InlineData(0x14, LampBrightness.Dim, LampFlash.Solid)]
|
||||
[InlineData(0x3C, LampBrightness.Bright, LampFlash.Solid)]
|
||||
[InlineData(0x3D, LampBrightness.Bright, LampFlash.FlashSlow)]
|
||||
[InlineData(0x16, LampBrightness.Dim, LampFlash.FlashMed)]
|
||||
[InlineData(0x04, LampBrightness.Dim, LampFlash.Solid)] // field 1 only
|
||||
[InlineData(0x30, LampBrightness.Bright, LampFlash.Solid)] // field 2 only
|
||||
public void LampState_decodes_brightness_and_flash(byte state, LampBrightness brightness, LampFlash flash)
|
||||
{
|
||||
Assert.Equal(brightness, RioLampState.Brightness(state));
|
||||
Assert.Equal(flash, RioLampState.Flash(state));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net48</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||
<PackageReference Include="PolySharp" Version="1.14.1">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\VRio.Core\VRio.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,258 @@
|
||||
using VRio.Core.Device;
|
||||
using VRio.Core.Protocol;
|
||||
using Xunit;
|
||||
|
||||
namespace VRio.Core.Tests;
|
||||
|
||||
public class VRioDeviceTests
|
||||
{
|
||||
/// <summary>Captures the device's transmissions and re-frames them for asserts.</summary>
|
||||
private sealed class Wire
|
||||
{
|
||||
private readonly PacketParser _parser = new();
|
||||
public readonly List<RioPacket> Packets = new();
|
||||
public readonly List<byte> Controls = new();
|
||||
|
||||
public Wire(VRioDevice device) => device.Transmit += OnBytes;
|
||||
|
||||
private void OnBytes(byte[] data)
|
||||
{
|
||||
foreach (byte b in data)
|
||||
{
|
||||
if (!_parser.Feed(b, out RioRxEvent ev))
|
||||
continue;
|
||||
if (ev.Kind == RioRxKind.Packet)
|
||||
{
|
||||
Assert.True(ev.ChecksumValid, $"device sent a bad checksum on {ev.Packet}");
|
||||
Packets.Add(ev.Packet);
|
||||
}
|
||||
else if (ev.Kind == RioRxKind.ControlByte)
|
||||
{
|
||||
Controls.Add(ev.Byte);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void Clear()
|
||||
{
|
||||
Packets.Clear();
|
||||
Controls.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
private static void Send(VRioDevice device, byte[] bytes) => device.OnReceived(bytes, bytes.Length);
|
||||
|
||||
[Fact]
|
||||
public void AnalogRequest_returns_current_axes_and_acks()
|
||||
{
|
||||
var device = new VRioDevice();
|
||||
var wire = new Wire(device);
|
||||
device.SetAxis(RioAxis.Throttle, 1000);
|
||||
device.SetAxis(RioAxis.JoystickX, -5000);
|
||||
|
||||
Send(device, PacketBuilder.Build(RioCommand.AnalogRequest));
|
||||
|
||||
Assert.Equal((byte)RioControl.Ack, Assert.Single(wire.Controls));
|
||||
RioPacket reply = Assert.Single(wire.Packets);
|
||||
Assert.Equal(RioCommand.AnalogReply, reply.Command);
|
||||
|
||||
byte[] p = reply.Payload;
|
||||
Assert.Equal(1000, AnalogCodec.Combine(p[0], p[1])); // throttle
|
||||
Assert.Equal(0, AnalogCodec.Combine(p[2], p[3])); // left pedal
|
||||
Assert.Equal(0, AnalogCodec.Combine(p[4], p[5])); // right pedal
|
||||
Assert.Equal(0, AnalogCodec.Combine(p[6], p[7])); // joystick Y
|
||||
Assert.Equal(-5000, AnalogCodec.Combine(p[8], p[9])); // joystick X
|
||||
Assert.Equal(1, device.AnalogRequests);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VersionRequest_reports_configured_firmware()
|
||||
{
|
||||
var device = new VRioDevice { VersionMajor = 2, VersionMinor = 7 };
|
||||
var wire = new Wire(device);
|
||||
|
||||
Send(device, PacketBuilder.Build(RioCommand.VersionRequest));
|
||||
|
||||
RioPacket reply = Assert.Single(wire.Packets);
|
||||
Assert.Equal(RioCommand.VersionReply, reply.Command);
|
||||
Assert.Equal(new byte[] { 2, 7 }, reply.Payload);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CheckRequest_reports_every_board_ok()
|
||||
{
|
||||
var device = new VRioDevice();
|
||||
var wire = new Wire(device);
|
||||
|
||||
Send(device, PacketBuilder.Build(RioCommand.CheckRequest));
|
||||
|
||||
Assert.Equal(RioAddressSpace.Boards.Count, wire.Packets.Count);
|
||||
Assert.All(wire.Packets, p =>
|
||||
{
|
||||
Assert.Equal(RioCommand.CheckReply, p.Command);
|
||||
Assert.Equal((byte)RioStatusType.BoardOk, p.Payload[0]);
|
||||
});
|
||||
Assert.Equal(
|
||||
RioAddressSpace.Boards.Select(b => b.Number),
|
||||
wire.Packets.Select(p => p.Payload[1]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LampRequest_updates_lamp_state_and_raises_event()
|
||||
{
|
||||
var device = new VRioDevice();
|
||||
_ = new Wire(device);
|
||||
(int Address, byte State)? change = null;
|
||||
device.LampChanged += (a, s) => change = (a, s);
|
||||
|
||||
Send(device, PacketBuilder.Build(RioCommand.LampRequest, new byte[] { 0x05, RioLampState.SolidBright }));
|
||||
|
||||
Assert.Equal((0x05, RioLampState.SolidBright), change);
|
||||
Assert.Equal(RioLampState.SolidBright, device.GetLamp(0x05));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResetRequest_zeroes_the_targeted_axis()
|
||||
{
|
||||
var device = new VRioDevice();
|
||||
_ = new Wire(device);
|
||||
device.SetAxis(RioAxis.Throttle, 4000);
|
||||
device.SetAxis(RioAxis.JoystickY, -3000);
|
||||
|
||||
Send(device, PacketBuilder.Build(RioCommand.ResetRequest, new[] { (byte)RioResetTarget.Throttle }));
|
||||
Assert.Equal(0, device.GetAxis(RioAxis.Throttle));
|
||||
Assert.Equal(-3000, device.GetAxis(RioAxis.JoystickY)); // untouched
|
||||
|
||||
Send(device, PacketBuilder.Build(RioCommand.ResetRequest, new[] { (byte)RioResetTarget.All }));
|
||||
Assert.Equal(0, device.GetAxis(RioAxis.JoystickY));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Bad_checksum_gets_nak_and_no_reply()
|
||||
{
|
||||
var device = new VRioDevice();
|
||||
var wire = new Wire(device);
|
||||
|
||||
byte[] bad = PacketBuilder.Build(RioCommand.AnalogRequest);
|
||||
bad[bad.Length - 1] ^= 0x01; // corrupt the checksum
|
||||
Send(device, bad);
|
||||
|
||||
Assert.Equal((byte)RioControl.Nak, Assert.Single(wire.Controls));
|
||||
Assert.Empty(wire.Packets);
|
||||
Assert.Equal(1, device.BadChecksums);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Button_press_and_release_use_button_packets()
|
||||
{
|
||||
var device = new VRioDevice();
|
||||
var wire = new Wire(device);
|
||||
|
||||
device.PressAddress(0x3D); // Panic
|
||||
device.ReleaseAddress(0x3D);
|
||||
|
||||
Assert.Equal(2, wire.Packets.Count);
|
||||
Assert.Equal(RioCommand.ButtonPressed, wire.Packets[0].Command);
|
||||
Assert.Equal(new byte[] { 0x3D }, wire.Packets[0].Payload);
|
||||
Assert.Equal(RioCommand.ButtonReleased, wire.Packets[1].Command);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0x51, 0, 0x1)] // internal keypad "1"
|
||||
[InlineData(0x5F, 0, 0xF)] // internal keypad "F"
|
||||
[InlineData(0x60, 1, 0x0)] // external keypad "0"
|
||||
[InlineData(0x6C, 1, 0xC)] // external keypad "C"
|
||||
public void Keypad_press_uses_key_packets_with_pad_and_index(int address, byte pad, byte index)
|
||||
{
|
||||
var device = new VRioDevice();
|
||||
var wire = new Wire(device);
|
||||
|
||||
device.PressAddress(address);
|
||||
|
||||
RioPacket packet = Assert.Single(wire.Packets);
|
||||
Assert.Equal(RioCommand.KeyPressed, packet.Command);
|
||||
Assert.Equal(new[] { pad, index }, packet.Payload);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Nak_resends_four_times_then_gives_up_with_restart()
|
||||
{
|
||||
var device = new VRioDevice();
|
||||
var wire = new Wire(device);
|
||||
|
||||
device.PressAddress(0x00);
|
||||
Assert.Single(wire.Packets);
|
||||
|
||||
// The v4.2 firmware retry budget: 4 re-sends, then RESTART and give up.
|
||||
for (int i = 0; i < 4; i++)
|
||||
Send(device, new[] { (byte)RioControl.Nak });
|
||||
Assert.Equal(5, wire.Packets.Count);
|
||||
Assert.All(wire.Packets, p => Assert.Equal(RioCommand.ButtonPressed, p.Command));
|
||||
Assert.Empty(wire.Controls);
|
||||
|
||||
Send(device, new[] { (byte)RioControl.Nak }); // budget exhausted
|
||||
Assert.Equal(5, wire.Packets.Count);
|
||||
Assert.Equal((byte)RioControl.Restart, Assert.Single(wire.Controls));
|
||||
|
||||
// An ACK clears the pending event; a following NAK re-sends nothing.
|
||||
wire.Clear();
|
||||
device.PressAddress(0x01);
|
||||
Send(device, new[] { (byte)RioControl.Ack });
|
||||
Send(device, new[] { (byte)RioControl.Nak });
|
||||
Assert.Single(wire.Packets);
|
||||
Assert.Empty(wire.Controls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Wedge_emulation_mutes_analog_until_host_reset()
|
||||
{
|
||||
var device = new VRioDevice { EmulateReplyWedge = true };
|
||||
var wire = new Wire(device);
|
||||
|
||||
// Exhaust the retry budget to trip the latch leak.
|
||||
device.PressAddress(0x00);
|
||||
for (int i = 0; i < 5; i++)
|
||||
Send(device, new[] { (byte)RioControl.Nak });
|
||||
Assert.True(device.AnalogWedged);
|
||||
wire.Clear();
|
||||
|
||||
// Wedged: the request is ACK'd (RX path alive) but no reply comes back.
|
||||
Send(device, PacketBuilder.Build(RioCommand.AnalogRequest));
|
||||
Assert.Equal((byte)RioControl.Ack, Assert.Single(wire.Controls));
|
||||
Assert.Empty(wire.Packets);
|
||||
Assert.Equal(1, device.AnalogDropped);
|
||||
|
||||
// The host's recovery reset clears the latch; analog replies resume.
|
||||
Send(device, PacketBuilder.Build(RioCommand.ResetRequest, new[] { (byte)RioResetTarget.All }));
|
||||
Assert.False(device.AnalogWedged);
|
||||
wire.Clear();
|
||||
Send(device, PacketBuilder.Build(RioCommand.AnalogRequest));
|
||||
Assert.Equal(RioCommand.AnalogReply, Assert.Single(wire.Packets).Command);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WedgeAnalogNow_wedges_without_the_emulation_flag()
|
||||
{
|
||||
var device = new VRioDevice();
|
||||
var wire = new Wire(device);
|
||||
|
||||
device.WedgeAnalogNow();
|
||||
Assert.True(device.AnalogWedged);
|
||||
|
||||
Send(device, PacketBuilder.Build(RioCommand.AnalogRequest));
|
||||
Assert.Empty(wire.Packets);
|
||||
|
||||
Send(device, PacketBuilder.Build(RioCommand.ResetRequest, new[] { (byte)RioResetTarget.Throttle }));
|
||||
Assert.False(device.AnalogWedged);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Axis_values_clamp_to_wire_range()
|
||||
{
|
||||
var device = new VRioDevice();
|
||||
device.SetAxis(RioAxis.Throttle, 60000);
|
||||
Assert.Equal(AnalogCodec.Max, device.GetAxis(RioAxis.Throttle));
|
||||
device.SetAxis(RioAxis.Throttle, -60000);
|
||||
Assert.Equal(AnalogCodec.Min, device.GetAxis(RioAxis.Throttle));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user