InputRouter.InvertJoystickY flipped the stored axis, so the X/Y dot and readout moved opposite the physical stick. The toggle now lives on VRioDevice and negates joystick Y only while building AnalogReply: local state keeps the physical direction for every source (pad, keys, panel drags) and only the host sees the flip. Negating a full -8192 deflection lands one past the 14-bit Max, so the reply clamps it to 8191. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
294 lines
11 KiB
C#
294 lines
11 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.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
|
|
Assert.Equal(-5000, AnalogCodec.Combine(p[8], p[9])); // joystick X
|
|
Assert.Equal(1, device.AnalogRequests);
|
|
}
|
|
|
|
[Fact]
|
|
public void InvertJoystickY_flips_the_wire_but_not_local_state()
|
|
{
|
|
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.GetAxis(RioAxis.JoystickY)); // the panel's view is unflipped
|
|
|
|
// 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 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));
|
|
}
|
|
}
|