using VRio.Core.Device; using VRio.Core.Protocol; using Xunit; namespace VRio.Core.Tests; public class VRioDeviceTests { /// Captures the device's transmissions and re-frames them for asserts. private sealed class Wire { private readonly PacketParser _parser = new(); public readonly List Packets = new(); public readonly List 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.JoystickY, 3000); 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(-3000, AnalogCodec.Combine(p[6], p[7])); // joystick Y (up is negative on the wire) Assert.Equal(-5000, AnalogCodec.Combine(p[8], p[9])); // joystick X Assert.Equal(1, device.AnalogRequests); } [Fact] public void Joystick_Y_is_negated_on_the_wire_but_not_locally() { var device = new VRioDevice(); var wire = new Wire(device); device.SetAxis(RioAxis.JoystickY, 3000); Send(device, PacketBuilder.Build(RioCommand.AnalogRequest)); byte[] p = Assert.Single(wire.Packets).Payload; Assert.Equal(-3000, AnalogCodec.Combine(p[6], p[7])); Assert.Equal(3000, device.GetAxis(RioAxis.JoystickY)); // the panel's dot is unflipped Assert.Equal(-3000, device.GetWireAxis(RioAxis.JoystickY)); // the panel's readout matches the wire // Full negative deflection: -Min is one past Max, so it clamps rather than throws. device.SetAxis(RioAxis.JoystickY, AnalogCodec.Min); wire.Clear(); Send(device, PacketBuilder.Build(RioCommand.AnalogRequest)); p = Assert.Single(wire.Packets).Payload; Assert.Equal(AnalogCodec.Max, AnalogCodec.Combine(p[6], p[7])); } [Fact] public void InvertJoystickY_sends_the_physical_direction_instead() { var device = new VRioDevice { InvertJoystickY = true }; var wire = new Wire(device); device.SetAxis(RioAxis.JoystickY, 3000); Send(device, PacketBuilder.Build(RioCommand.AnalogRequest)); byte[] p = Assert.Single(wire.Packets).Payload; Assert.Equal(3000, AnalogCodec.Combine(p[6], p[7])); Assert.Equal(3000, device.GetWireAxis(RioAxis.JoystickY)); } [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)); } }