Files
VRIO/tests/VRio.Core.Tests/VRioDeviceTests.cs
T
CydandClaude Fable 5 1eded793af CheckRequest: send the real board's test-mode handshake
The host waits up to 5s after CheckRequest for TestModeChange ENTER
(8C 01 0D) before anything else, and sends no requests until the
matching EXIT (8C 00 0C) arrives; vRIO jumped straight to the
CheckReply dump, so hosts logged "RIO never came back from check
request" and skipped the version exchange. Bracket the per-board
BoardOk replies with enter/exit, byte-for-byte what the real v4.2
board sends on the wire tap.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 13:13:06 -05:00

272 lines
9.7 KiB
C#

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_enters_test_mode_reports_boards_then_exits()
{
var device = new VRioDevice();
var wire = new Wire(device);
Send(device, PacketBuilder.Build(RioCommand.CheckRequest));
// The init handshake: TestModeChange ENTER, one CheckReply per board,
// TestModeChange EXIT. The game waits on both test-mode packets and
// stays mute forever if the EXIT never arrives.
Assert.Equal(RioAddressSpace.Boards.Count + 2, wire.Packets.Count);
RioPacket enter = wire.Packets[0];
Assert.Equal(RioCommand.TestModeChange, enter.Command);
Assert.Equal(new byte[] { 1 }, enter.Payload);
RioPacket exit = wire.Packets[wire.Packets.Count - 1];
Assert.Equal(RioCommand.TestModeChange, exit.Command);
Assert.Equal(new byte[] { 0 }, exit.Payload);
var checks = wire.Packets.GetRange(1, RioAddressSpace.Boards.Count);
Assert.All(checks, p =>
{
Assert.Equal(RioCommand.CheckReply, p.Command);
Assert.Equal((byte)RioStatusType.BoardOk, p.Payload[0]);
});
Assert.Equal(
RioAddressSpace.Boards.Select(b => b.Number),
checks.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));
}
}