From 7995c0b1c12bac64d1bdb5fabb2a78cd93d1c541 Mon Sep 17 00:00:00 2001 From: Cyd Date: Mon, 6 Jul 2026 07:39:38 -0500 Subject: [PATCH] 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 --- .gitignore | 11 + README.md | 77 +++ VRio.sln | 43 ++ src/VRio.App/MainForm.cs | 290 ++++++++++++ src/VRio.App/PanelCanvas.cs | 470 +++++++++++++++++++ src/VRio.App/Program.cs | 12 + src/VRio.App/VRio.App.csproj | 25 + src/VRio.App/app.manifest | 20 + src/VRio.Core/Device/RioAddressSpace.cs | 80 ++++ src/VRio.Core/Device/VRioDevice.cs | 396 ++++++++++++++++ src/VRio.Core/Device/VRioSerialService.cs | 169 +++++++ src/VRio.Core/Panel/CockpitLayout.cs | 140 ++++++ src/VRio.Core/Protocol/PacketBuilder.cs | 103 ++++ src/VRio.Core/Protocol/PacketParser.cs | 100 ++++ src/VRio.Core/Protocol/RioChecksum.cs | 22 + src/VRio.Core/Protocol/RioCommands.cs | 119 +++++ src/VRio.Core/Protocol/RioLampState.cs | 60 +++ src/VRio.Core/Protocol/RioPacket.cs | 58 +++ src/VRio.Core/VRio.Core.csproj | 22 + tests/VRio.Core.Tests/ProtocolTests.cs | 99 ++++ tests/VRio.Core.Tests/VRio.Core.Tests.csproj | 25 + tests/VRio.Core.Tests/VRioDeviceTests.cs | 258 ++++++++++ 22 files changed, 2599 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 VRio.sln create mode 100644 src/VRio.App/MainForm.cs create mode 100644 src/VRio.App/PanelCanvas.cs create mode 100644 src/VRio.App/Program.cs create mode 100644 src/VRio.App/VRio.App.csproj create mode 100644 src/VRio.App/app.manifest create mode 100644 src/VRio.Core/Device/RioAddressSpace.cs create mode 100644 src/VRio.Core/Device/VRioDevice.cs create mode 100644 src/VRio.Core/Device/VRioSerialService.cs create mode 100644 src/VRio.Core/Panel/CockpitLayout.cs create mode 100644 src/VRio.Core/Protocol/PacketBuilder.cs create mode 100644 src/VRio.Core/Protocol/PacketParser.cs create mode 100644 src/VRio.Core/Protocol/RioChecksum.cs create mode 100644 src/VRio.Core/Protocol/RioCommands.cs create mode 100644 src/VRio.Core/Protocol/RioLampState.cs create mode 100644 src/VRio.Core/Protocol/RioPacket.cs create mode 100644 src/VRio.Core/VRio.Core.csproj create mode 100644 tests/VRio.Core.Tests/ProtocolTests.cs create mode 100644 tests/VRio.Core.Tests/VRio.Core.Tests.csproj create mode 100644 tests/VRio.Core.Tests/VRioDeviceTests.cs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..247b5a1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +# Build output +[Bb]in/ +[Oo]bj/ + +# IDE state +.vs/ +*.user +*.suo + +# Test results +TestResults/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..3e8ddbe --- /dev/null +++ b/README.md @@ -0,0 +1,77 @@ +# vRIO — virtual RIO cockpit device + +A software replica of the cockpit **RIO** (Remote Input/Output) board: it opens +a COM port and speaks the **device side** of the RIO serial protocol, so any +host that expects the real hardware — most importantly +[RIOJoy](../riojoy/) — can talk to it without a cockpit attached. + +The window is an interactive version of the cockpit control panel that +RIOJoy's profile editor draws (the same functional layout from the original +Win32 RIO design: five MFD clusters, four board columns, two keypads, encoder +gauges). But where the editor *edits bindings*, vRIO's cells are the physical +controls: + +- **Left-click** a cell — momentary button press (`ButtonPressed`/`Released` + or `KeyPressed`/`Released` on the wire). **Right-click** — latch it down. +- **Drag** the X/Y box and the Z / L / R gauges — the five analog axes, + returned by the next `AnalogReply` (14-bit signed, 7-bit-pair packed). +- Cells shade to the **lamp state the host commands** (`LampRequest`: + off / dim / bright, with slow/med/fast flash), so RIOJoy's press-feedback + lights the on-screen panel just like the real buttons. + +## Wire behavior + +Protocol per `riojoy/docs/PROTOCOL.md` (9600 8N1, `[cmd][payload…][7-bit +checksum]`, ACK `0xFC` / NAK `0xFD` / RESTART `0xFE` / IDLE `0xFF`), with +device behavior grounded in the **real v4.2 firmware dump** +(`riojoy/rio-firmware/RIOv4_2-ANALYSIS.md`): + +- ACKs every well-formed packet; NAKs bad-checksum packets. +- `CheckRequest` → one `BoardOk` CheckReply per board (the 11 boards from the + legacy firmware's table). `VersionRequest` → configurable version, + default **4.2**. +- `ResetRequest` re-zeroes the targeted axis (or all). +- A NAK re-sends the last event up to **4 times**, then gives up with a + RESTART byte — the real board's retry budget. +- Optional **v4.2 reply-wedge emulation**: after retry exhaustion (or the + "Wedge analog now" button), analog requests are silently dropped — still + ACK'd, RX path alive — until a host `ResetRequest`, reproducing the + latch-leak fault the firmware analysis documents. Use it to exercise + RIOJoy's 5-second no-analog recovery watchdog. + +## Using it with RIOJoy on one PC + +The two apps need a crossed serial link. Install a +[com0com](https://com0com.sourceforge.net/) virtual null-modem pair +(e.g. `COM5 ⇄ COM6`), then: + +1. Run `VRio.App`, pick `COM5`, **Open**. +2. Point RIOJoy at `COM6`. + +RIOJoy's DTR open-pulse shows up in the wire log (DSR handshake), its ~55 ms +analog polling drives the "analog polls served" counter, and every click on +the vRIO panel arrives at RIOJoy as real cockpit input. Two physical PCs with +a null-modem cable work the same way. + +## Repository layout + +| Path | Contents | +|------|----------| +| `src/VRio.Core` | Protocol framing/builder/parser, the `VRioDevice` state machine, serial pump, panel layout data (class library) | +| `src/VRio.App` | WinForms panel UI | +| `tests/VRio.Core.Tests` | xUnit tests for the protocol + device engine | + +## Building + +Same toolchain as RIOJoy: **.NET SDK** (8.0+) with the **.NET Framework 4.8** +targeting pack; apps target net48 so deployed builds run in-box on +Windows 10/11. + +```sh +dotnet build VRio.sln -c Release +dotnet test VRio.sln +``` + +Interop is additionally verified against RIOJoy's real `RioSerialLink` +(version/check/analog/lamp/button/keypad/reset round-trips over an in-memory +transport) — see the RIOJoy repo for the host side. diff --git a/VRio.sln b/VRio.sln new file mode 100644 index 0000000..544fd34 --- /dev/null +++ b/VRio.sln @@ -0,0 +1,43 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{D0ECC9D9-7379-4759-89F7-56CD3214BD57}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "VRio.Core", "src\VRio.Core\VRio.Core.csproj", "{80312F43-09BF-4F09-A0FA-A60FDF86274D}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "VRio.App", "src\VRio.App\VRio.App.csproj", "{2D1A482C-D907-47EB-9830-B78132154E57}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{C4993638-7EB6-47A9-897C-976DB9939601}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "VRio.Core.Tests", "tests\VRio.Core.Tests\VRio.Core.Tests.csproj", "{986638BB-F289-4480-8575-F1699D201590}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {80312F43-09BF-4F09-A0FA-A60FDF86274D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {80312F43-09BF-4F09-A0FA-A60FDF86274D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {80312F43-09BF-4F09-A0FA-A60FDF86274D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {80312F43-09BF-4F09-A0FA-A60FDF86274D}.Release|Any CPU.Build.0 = Release|Any CPU + {2D1A482C-D907-47EB-9830-B78132154E57}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2D1A482C-D907-47EB-9830-B78132154E57}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2D1A482C-D907-47EB-9830-B78132154E57}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2D1A482C-D907-47EB-9830-B78132154E57}.Release|Any CPU.Build.0 = Release|Any CPU + {986638BB-F289-4480-8575-F1699D201590}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {986638BB-F289-4480-8575-F1699D201590}.Debug|Any CPU.Build.0 = Debug|Any CPU + {986638BB-F289-4480-8575-F1699D201590}.Release|Any CPU.ActiveCfg = Release|Any CPU + {986638BB-F289-4480-8575-F1699D201590}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {80312F43-09BF-4F09-A0FA-A60FDF86274D} = {D0ECC9D9-7379-4759-89F7-56CD3214BD57} + {2D1A482C-D907-47EB-9830-B78132154E57} = {D0ECC9D9-7379-4759-89F7-56CD3214BD57} + {986638BB-F289-4480-8575-F1699D201590} = {C4993638-7EB6-47A9-897C-976DB9939601} + EndGlobalSection +EndGlobal diff --git a/src/VRio.App/MainForm.cs b/src/VRio.App/MainForm.cs new file mode 100644 index 0000000..e2100c7 --- /dev/null +++ b/src/VRio.App/MainForm.cs @@ -0,0 +1,290 @@ +using System.Diagnostics; +using System.IO.Ports; +using VRio.Core.Device; + +namespace VRio.App; + +/// +/// vRIO main window: the interactive cockpit panel on the left (the same +/// functional map RIOJoy's profile editor shows) and a control strip on the +/// right — COM port, device settings, and a live wire log. Open the COM port, +/// point RIOJoy at the other end of the null-modem pair, and every click here +/// arrives at RIOJoy exactly as if the physical cockpit sent it. +/// +internal sealed class MainForm : Form +{ + private readonly VRioDevice _device = new(); + private readonly VRioSerialService _service; + private readonly PanelCanvas _canvas = new(); + + private readonly ComboBox _portBox = new() + { + Location = new Point(80, 12), + Width = 128, + DropDownStyle = ComboBoxStyle.DropDownList, + }; + private readonly Button _rescan = new() { Text = "Rescan", Location = new Point(214, 11), Width = 52 }; + private readonly Button _openClose = new() { Text = "Open", Location = new Point(272, 11), Width = 46 }; + private readonly Label _linkStatus = new() + { + Text = "Port closed.", + Location = new Point(12, 42), + AutoSize = true, + ForeColor = Color.Gray, + }; + + private readonly NumericUpDown _verMajor = new() { Location = new Point(80, 24), Width = 44, Minimum = 0, Maximum = 127, Value = 4 }; + private readonly NumericUpDown _verMinor = new() { Location = new Point(140, 24), Width = 44, Minimum = 0, Maximum = 127, Value = 2 }; + private readonly CheckBox _spring = new() { Text = "Stick springs back to center", Location = new Point(10, 56), AutoSize = true, Checked = true }; + private readonly Button _centerAxes = new() { Text = "Center all axes", Location = new Point(10, 82), Width = 140, Height = 26 }; + private readonly Button _lampsOff = new() { Text = "All lamps off", Location = new Point(156, 82), Width = 140, Height = 26 }; + private readonly Button _testEnter = new() { Text = "Enter test mode", Location = new Point(10, 114), Width = 140, Height = 26 }; + private readonly Button _testExit = new() { Text = "Exit test mode", Location = new Point(156, 114), Width = 140, Height = 26 }; + private readonly CheckBox _wedgeBug = new() + { + Text = "Emulate the v4.2 reply-wedge bug", + Location = new Point(10, 148), + AutoSize = true, + }; + private readonly Button _wedgeNow = new() { Text = "Wedge analog now", Location = new Point(10, 172), Width = 140, Height = 26 }; + + private readonly Label _counters = new() + { + Location = new Point(12, 290), + AutoSize = true, + Font = new Font("Consolas", 8f), + }; + + private readonly Label _help = new() + { + Location = new Point(12, 348), + MaximumSize = new Size(306, 0), + AutoSize = true, + ForeColor = Color.Gray, + Text = "Left-click a cell: momentary press. Right-click: latch it down. " + + "Drag the X/Y box and the Z / L / R gauges to move the axes.", + }; + + private readonly TextBox _logBox = new() + { + Location = new Point(12, 428), + Multiline = true, + ReadOnly = true, + ScrollBars = ScrollBars.Vertical, + BackColor = Color.FromArgb(24, 24, 24), + ForeColor = Color.Gainsboro, + Font = new Font("Consolas", 8f), + Anchor = AnchorStyles.Top | AnchorStyles.Bottom | AnchorStyles.Left, + WordWrap = false, + }; + private readonly Button _clearLog = new() + { + Text = "Clear log", + Width = 80, + Anchor = AnchorStyles.Bottom | AnchorStyles.Left, + }; + + private readonly System.Windows.Forms.Timer _uiTimer = new() { Interval = 500 }; + private readonly Stopwatch _clock = Stopwatch.StartNew(); + + public MainForm() + { + Text = "vRIO — Virtual RIO cockpit device"; + ClientSize = new Size(1150, 800); + MinimumSize = new Size(1000, 620); + StartPosition = FormStartPosition.CenterScreen; + + _service = new VRioSerialService(_device); + + // Panel canvas, scrolled if the window is smaller than the grid. + var scroller = new Panel { Dock = DockStyle.Fill, AutoScroll = true, BackColor = Color.FromArgb(28, 28, 28) }; + scroller.Controls.Add(_canvas); + + Controls.Add(scroller); + Controls.Add(BuildControlStrip()); + + // Canvas ↔ device wiring. + _canvas.LampProvider = _device.GetLamp; + _canvas.AxisProvider = _device.GetAxis; + _canvas.AddressPressed += _device.PressAddress; + _canvas.AddressReleased += _device.ReleaseAddress; + _canvas.AxisMoved += (axis, value) => _device.SetAxis(axis, value); + + // Device / service events arrive on worker threads; marshal to the UI. + _device.LampChanged += (_, _) => RunOnUi(_canvas.Invalidate); + _device.AxesChanged += () => RunOnUi(_canvas.Invalidate); + _device.Logged += line => RunOnUi(() => AppendLog(line)); + _service.Logged += line => RunOnUi(() => AppendLog(line)); + _service.ConnectionChanged += open => RunOnUi(() => OnConnectionChanged(open)); + _service.HostHandshake += high => RunOnUi(() => + AppendLog(high ? "Host raised DTR (board-reset handshake)" : "Host dropped DTR")); + + _rescan.Click += (_, _) => RefreshPorts(); + _openClose.Click += (_, _) => ToggleOpen(); + _verMajor.ValueChanged += (_, _) => _device.VersionMajor = (byte)_verMajor.Value; + _verMinor.ValueChanged += (_, _) => _device.VersionMinor = (byte)_verMinor.Value; + _spring.CheckedChanged += (_, _) => _canvas.StickSpringsBack = _spring.Checked; + _centerAxes.Click += (_, _) => + { + foreach (RioAxis axis in (RioAxis[])Enum.GetValues(typeof(RioAxis))) + _device.SetAxis(axis, 0); + }; + _lampsOff.Click += (_, _) => + { + _device.ClearLamps(); + _canvas.Invalidate(); + }; + _testEnter.Click += (_, _) => _device.SendTestMode(1); + _testExit.Click += (_, _) => _device.SendTestMode(0); + _wedgeBug.CheckedChanged += (_, _) => _device.EmulateReplyWedge = _wedgeBug.Checked; + _wedgeNow.Click += (_, _) => + { + _device.WedgeAnalogNow(); + UpdateStatus(); + }; + _clearLog.Click += (_, _) => _logBox.Clear(); + + _uiTimer.Tick += (_, _) => UpdateStatus(); + _uiTimer.Start(); + + FormClosed += (_, _) => + { + _uiTimer.Dispose(); + _service.Dispose(); + }; + + RefreshPorts(); + UpdateStatus(); + AppendLog("vRIO ready. Open a COM port, then point RIOJoy at the other end of the pair."); + } + + private Panel BuildControlStrip() + { + var panel = new Panel + { + Dock = DockStyle.Right, + Width = 330, + Padding = new Padding(8), + BorderStyle = BorderStyle.FixedSingle, + }; + + panel.Controls.Add(new Label { Text = "COM port:", Location = new Point(12, 15), AutoSize = true }); + panel.Controls.Add(_portBox); + panel.Controls.Add(_rescan); + panel.Controls.Add(_openClose); + panel.Controls.Add(_linkStatus); + + var device = new GroupBox { Text = "Device", Location = new Point(12, 68), Size = new Size(306, 210) }; + device.Controls.Add(new Label { Text = "Firmware:", Location = new Point(10, 27), AutoSize = true }); + device.Controls.Add(_verMajor); + device.Controls.Add(new Label { Text = ".", Location = new Point(127, 27), AutoSize = true }); + device.Controls.Add(_verMinor); + device.Controls.AddRange(new Control[] { _spring, _centerAxes, _lampsOff, _testEnter, _testExit, _wedgeBug, _wedgeNow }); + panel.Controls.Add(device); + + panel.Controls.Add(_counters); + panel.Controls.Add(_help); + panel.Controls.Add(new Label { Text = "Wire log:", Location = new Point(12, 410), AutoSize = true }); + + _logBox.Size = new Size(306, ClientSize.Height - _logBox.Top - 44); + panel.Controls.Add(_logBox); + + _clearLog.Location = new Point(12, ClientSize.Height - 36); + panel.Controls.Add(_clearLog); + + return panel; + } + + // ---- Port handling ----------------------------------------------------- + + private void RefreshPorts() + { + string? current = _portBox.SelectedItem?.ToString(); + _portBox.Items.Clear(); + foreach (string name in SerialPort.GetPortNames().Distinct().OrderBy(n => n, StringComparer.OrdinalIgnoreCase)) + _portBox.Items.Add(name); + + if (_portBox.Items.Count == 0) + return; + int idx = current is null ? -1 : _portBox.Items.IndexOf(current); + _portBox.SelectedIndex = idx >= 0 ? idx : 0; + } + + private void ToggleOpen() + { + if (_service.IsOpen) + { + _service.Close(); + return; + } + + if (_portBox.SelectedItem?.ToString() is not { } port) + { + MessageBox.Show(this, "No COM port selected. On a single PC, install a com0com virtual " + + "null-modem pair and open one end here.", "vRIO", MessageBoxButtons.OK, MessageBoxIcon.Information); + return; + } + + try + { + _service.Open(port); + } + catch (Exception ex) + { + MessageBox.Show(this, $"Could not open {port}:\n{ex.Message}", "vRIO", + MessageBoxButtons.OK, MessageBoxIcon.Warning); + } + } + + private void OnConnectionChanged(bool open) + { + _openClose.Text = open ? "Close" : "Open"; + _portBox.Enabled = _rescan.Enabled = !open; + _linkStatus.Text = open ? $"Serving {_service.PortName} @ 9600 8N1." : "Port closed."; + _linkStatus.ForeColor = open ? Color.ForestGreen : Color.Gray; + UpdateStatus(); + } + + // ---- Status / log ------------------------------------------------------ + + private void UpdateStatus() + { + long polls = _device.AnalogRequests; + long dropped = _device.AnalogDropped; + long bad = _device.BadChecksums; + bool wedged = _device.AnalogWedged; + _counters.Text = $"Analog polls served: {polls}\n" + + $"Analog polls dropped: {dropped}\n" + + $"Bad checksums: {bad}"; + + _canvas.StatusText = _service.IsOpen + ? $"vRIO {_device.VersionMajor}.{_device.VersionMinor} on {_service.PortName}\n" + + (wedged + ? "** ANALOG WEDGED (v4.2 bug) — awaiting host reset **" + : $"{polls} analog polls" + (polls > 0 ? " (host is alive)" : " (no host traffic yet)")) + : "PORT CLOSED\nOpen a COM port to go live."; + } + + private void AppendLog(string line) + { + // Bound the log so an all-day session doesn't grow without limit. + if (_logBox.TextLength > 120_000) + _logBox.Text = _logBox.Text.Substring(_logBox.TextLength - 60_000); + + _logBox.AppendText($"[{_clock.Elapsed.TotalSeconds,8:F2}s] {line}{Environment.NewLine}"); + } + + private void RunOnUi(Action action) + { + if (IsDisposed) + return; + if (InvokeRequired) + { + try { BeginInvoke(action); } + catch (ObjectDisposedException) { } + catch (InvalidOperationException) { } // handle not created / closing + return; + } + action(); + } +} diff --git a/src/VRio.App/PanelCanvas.cs b/src/VRio.App/PanelCanvas.cs new file mode 100644 index 0000000..19ffa23 --- /dev/null +++ b/src/VRio.App/PanelCanvas.cs @@ -0,0 +1,470 @@ +using VRio.Core.Device; +using VRio.Core.Panel; +using VRio.Core.Protocol; + +namespace VRio.App; + +/// +/// The interactive cockpit control panel. Visually it mirrors RIOJoy's profile +/// editor map (same functional groups, cell geometry, and lamp colours), but +/// here the cells are the physical controls: pressing one emits a +/// button/keypad packet on the wire, and the fill shade tracks the lamp state +/// the host last commanded (including flash modes). The encoder strip is live +/// too — drag the X/Y box and the Z/L/R gauges to move the analog axes. +/// +/// Left-click = momentary press (release on mouse-up). Right-click = +/// latch the button down / release it (handy for testing holds). +/// +internal sealed class PanelCanvas : Control +{ + // Cell geometry — identical to RIOJoy's PanelView so the two panels align. + private const int CellW = 66; + private const int CellH = 34; + private const int TopStrip = 108; + + private static readonly IReadOnlyList AllButtons = CockpitLayout.Buttons(); + + // Encoder-strip geometry (same placement as RIOJoy's PanelView): Z gauge, + // the pedals' "U" (L / R arms + Rz bar), and the X/Y stick box. + private const int StripTop = 6; + private const int StripH = 68; + private const int Bar = 30; + private static readonly int BaseX = 6 * CellW - 100; + private static readonly Rectangle BoxZ = new(BaseX, StripTop, Bar, StripH); + private static readonly Rectangle BoxL = new(BaseX + 34, StripTop, Bar, StripH); + private static readonly Rectangle BoxR = new(BaseX + 34 + 82 - Bar, StripTop, Bar, StripH); + private static readonly Rectangle BoxRz = new(BaseX + 34, StripTop + StripH, 82, Bar); + private static readonly Rectangle BoxXY = new(BaseX + 120, StripTop, 80, StripH); + + private readonly System.Windows.Forms.Timer _flashTimer = new() { Interval = 100 }; + private bool _wasFlashing; + + private readonly HashSet _latched = new(); + private int? _mouseDownAddress; + + private RioAxis? _dragAxis; // Z / L / R gauge drag + private bool _dragStick; // X/Y box drag + + private string _statusText = string.Empty; + + public PanelCanvas() + { + DoubleBuffered = true; + BackColor = Color.FromArgb(28, 28, 28); + Size = GridSize(); + + _flashTimer.Tick += (_, _) => + { + bool flashing = AnyLampFlashing(); + if (flashing || _wasFlashing) + Invalidate(); + _wasFlashing = flashing; + }; + _flashTimer.Start(); + } + + /// Current lamp-state byte for an address (from the device). + public Func? LampProvider { get; set; } + + /// Current raw value of an axis (from the device). + public Func? AxisProvider { get; set; } + + /// The user pressed a panel control. + public event Action? AddressPressed; + + /// The user released a panel control. + public event Action? AddressReleased; + + /// The user dragged an axis to a new raw value. + public event Action? AxisMoved; + + /// When true (default), the X/Y stick snaps back to center on release. + public bool StickSpringsBack { get; set; } = true; + + /// Green status text drawn in the strip area (the mockup's status panel). + public string StatusText + { + get => _statusText; + set + { + if (_statusText == value) return; + _statusText = value; + Invalidate(); + } + } + + protected override void Dispose(bool disposing) + { + if (disposing) _flashTimer.Dispose(); + base.Dispose(disposing); + } + + private static Size GridSize() + { + int maxCol = 0, maxRow = 0; + foreach (PanelButton b in AllButtons) + { + if (b.Col > maxCol) maxCol = b.Col; + if (b.Row > maxRow) maxRow = b.Row; + } + return new Size((maxCol + 1) * CellW + 6, TopStrip + (maxRow + 2) * CellH); + } + + private static Rectangle Cell(int col, int row) => + new(col * CellW + 2, TopStrip + row * CellH + 2, CellW - 4, CellH - 4); + + // ---- Painting ---------------------------------------------------------- + + protected override void OnPaint(PaintEventArgs e) + { + Graphics g = e.Graphics; + g.Clear(Color.FromArgb(28, 28, 28)); + + using var titleFont = new Font("Segoe UI", 8f, FontStyle.Bold); + using var btnFont = new Font("Segoe UI", 7.5f); + using var subFont = new Font("Segoe UI", 6.75f); + using var groupPen = new Pen(Color.FromArgb(80, 80, 80)); + + DrawEncoderStrip(g, titleFont, subFont); + + // Group frames + titles. + foreach (PanelGroup grp in CockpitLayout.Groups) + { + var frame = new Rectangle( + grp.OriginCol * CellW + 1, + TopStrip + grp.OriginRow * CellH + 1, + grp.Cols * CellW, + (grp.Rows + 1) * CellH); + g.DrawRectangle(groupPen, frame); + TextRenderer.DrawText(g, grp.Title, titleFont, + new Rectangle(frame.X + 2, frame.Y, frame.Width - 2, CellH), Color.Gainsboro, + TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.EndEllipsis); + } + + const TextFormatFlags oneLine = TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter | + TextFormatFlags.EndEllipsis | TextFormatFlags.NoPrefix; + int tick = Environment.TickCount; + + foreach (PanelButton b in AllButtons) + { + Rectangle r = Cell(b.Col, b.Row); + bool held = IsHeld(b.Address); + bool yellow = b.Group.Title is "Secondary" or "Screen"; + + // Lamp shade: locally held renders bright (a real pressed cap floods + // bright); otherwise the host-commanded lamp state decides, with the + // flash bits blinking between the commanded brightness and off. + LampBrightness shade = LampBrightness.Off; + if (b.LampCapable) + { + byte state = LampProvider?.Invoke(b.Address) ?? 0; + shade = RioLampState.Brightness(state); + if (shade != LampBrightness.Off && !FlashPhaseOn(RioLampState.Flash(state), tick)) + shade = LampBrightness.Off; + } + if (held) + shade = LampBrightness.Bright; + + Color fill = !b.LampCapable + ? (held ? Color.FromArgb(205, 228, 255) // keypad — bright blue when pressed + : Color.FromArgb(150, 182, 226)) // keypad — neutral blue + : yellow + ? shade switch + { + LampBrightness.Bright => Color.FromArgb(245, 210, 60), + LampBrightness.Dim => Color.FromArgb(140, 118, 38), + _ => Color.FromArgb(70, 60, 24), + } + : shade switch + { + LampBrightness.Bright => Color.FromArgb(230, 70, 70), + LampBrightness.Dim => Color.FromArgb(120, 50, 50), + _ => Color.FromArgb(64, 40, 40), + }; + using (var fb = new SolidBrush(fill)) g.FillRectangle(fb, r); + + Color fg = (b.LampCapable && !yellow) ? Color.White : Color.Black; + string hex = $"{b.Address:X2}"; + string? label = CockpitLayout.PhysicalLabel(b.Address); + + if (label is not null && b.Group.Kind != PanelGroupKind.Keypad) + { + TextRenderer.DrawText(g, label, btnFont, new Rectangle(r.X, r.Y + 1, r.Width, r.Height / 2), fg, + TextFormatFlags.HorizontalCenter | TextFormatFlags.Top | TextFormatFlags.EndEllipsis | TextFormatFlags.NoPrefix); + TextRenderer.DrawText(g, hex, subFont, new Rectangle(r.X, r.Bottom - r.Height / 2 - 1, r.Width, r.Height / 2), fg, + TextFormatFlags.HorizontalCenter | TextFormatFlags.Bottom | TextFormatFlags.EndEllipsis | TextFormatFlags.NoPrefix); + } + else + { + TextRenderer.DrawText(g, label ?? hex, btnFont, r, fg, oneLine); + } + + if (_latched.Contains(b.Address)) + { + using var latch = new Pen(Color.Gold, 2f); + g.DrawRectangle(latch, r.X, r.Y, r.Width - 1, r.Height - 1); + } + else if (held) + { + using var hi = new Pen(Color.White, 2f); + g.DrawRectangle(hi, r.X, r.Y, r.Width - 1, r.Height - 1); + } + } + } + + private bool IsHeld(int address) => _mouseDownAddress == address || _latched.Contains(address); + + private static bool FlashPhaseOn(LampFlash flash, int tick) + { + int halfPeriod = flash switch + { + LampFlash.FlashSlow => 500, + LampFlash.FlashMed => 250, + LampFlash.FlashFast => 125, + _ => 0, + }; + return halfPeriod == 0 || tick / halfPeriod % 2 == 0; + } + + private bool AnyLampFlashing() + { + if (LampProvider is not { } lamps) + return false; + for (int a = 0; a < RioAddressSpace.LampCount; a++) + { + byte state = lamps(a); + if (RioLampState.Flash(state) != LampFlash.Solid && RioLampState.Brightness(state) != LampBrightness.Off) + return true; + } + return false; + } + + private void DrawEncoderStrip(Graphics g, Font labelFont, Font valueFont) + { + using var pen = new Pen(Color.FromArgb(120, 160, 120)); + using var bg = new SolidBrush(Color.FromArgb(20, 40, 20)); + using var fillBrush = new SolidBrush(Color.FromArgb(90, 190, 90)); + + short Axis(RioAxis a) => AxisProvider?.Invoke(a) ?? (short)0; + + // Vertical gauges: Z (throttle) and the pedal arms L / R. + DrawVGauge(g, pen, bg, fillBrush, labelFont, BoxZ, "Z", Axis(RioAxis.Throttle)); + DrawVGauge(g, pen, bg, fillBrush, labelFont, BoxL, "L", Axis(RioAxis.LeftPedal)); + DrawVGauge(g, pen, bg, fillBrush, labelFont, BoxR, "R", Axis(RioAxis.RightPedal)); + + // Rz bar: the PC-side rudder mix ((R − L) / 2) — display only. + g.FillRectangle(bg, BoxRz); + g.DrawRectangle(pen, BoxRz); + int mix = (Axis(RioAxis.RightPedal) - Axis(RioAxis.LeftPedal)) / 2; + int mx = BoxRz.X + (int)((mix - (float)AnalogCodec.Min) / (AnalogCodec.Max - AnalogCodec.Min) * BoxRz.Width); + g.FillRectangle(fillBrush, Math.Max(BoxRz.X + 1, Math.Min(mx - 2, BoxRz.Right - 4)), BoxRz.Y + 1, 3, BoxRz.Height - 2); + TextRenderer.DrawText(g, "Rz", labelFont, BoxRz, Color.Gainsboro, + TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter); + + // X/Y stick box with a crosshair at the current position. + g.FillRectangle(bg, BoxXY); + g.DrawRectangle(pen, BoxXY); + using (var mid = new Pen(Color.FromArgb(50, 90, 50))) + { + g.DrawLine(mid, BoxXY.X, BoxXY.Y + BoxXY.Height / 2, BoxXY.Right, BoxXY.Y + BoxXY.Height / 2); + g.DrawLine(mid, BoxXY.X + BoxXY.Width / 2, BoxXY.Y, BoxXY.X + BoxXY.Width / 2, BoxXY.Bottom); + } + PointF stick = StickToPoint(Axis(RioAxis.JoystickX), Axis(RioAxis.JoystickY)); + g.FillEllipse(fillBrush, stick.X - 4, stick.Y - 4, 8, 8); + TextRenderer.DrawText(g, "X / Y", labelFont, + new Rectangle(BoxXY.X, BoxXY.Bottom - 16, BoxXY.Width, 14), Color.Gainsboro, + TextFormatFlags.HorizontalCenter | TextFormatFlags.Bottom); + + // The mockup's green status/instruction area, right of the gauges, with a + // live axis readout on its last line (painted per frame, so drags track). + var statusRect = new Rectangle(BoxXY.Right + 16, StripTop, Width - BoxXY.Right - 24, TopStrip - StripTop - 6); + if (statusRect.Width > 60) + { + using var statusFont = new Font("Consolas", 8f); + var green = Color.FromArgb(120, 220, 120); + if (_statusText.Length > 0) + TextRenderer.DrawText(g, _statusText, statusFont, statusRect, green, + TextFormatFlags.Left | TextFormatFlags.Top | TextFormatFlags.WordBreak); + + string readout = + $"Z {Axis(RioAxis.Throttle),6} L {Axis(RioAxis.LeftPedal),6} R {Axis(RioAxis.RightPedal),6} " + + $"X {Axis(RioAxis.JoystickX),6} Y {Axis(RioAxis.JoystickY),6}"; + TextRenderer.DrawText(g, readout, statusFont, statusRect, green, + TextFormatFlags.Left | TextFormatFlags.Bottom | TextFormatFlags.SingleLine); + } + } + + private static void DrawVGauge(Graphics g, Pen pen, Brush bg, Brush fill, Font labelFont, + Rectangle box, string label, short value) + { + g.FillRectangle(bg, box); + // Fill upward from the bottom, proportional to the normalized value. + float norm = (value - (float)AnalogCodec.Min) / (AnalogCodec.Max - AnalogCodec.Min); + int h = (int)(norm * (box.Height - 2)); + if (h > 0) + g.FillRectangle(fill, box.X + 1, box.Bottom - 1 - h, box.Width - 2, h); + g.DrawRectangle(pen, box); + TextRenderer.DrawText(g, label, labelFont, box, Color.Gainsboro, + TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter); + } + + private static PointF StickToPoint(short x, short y) + { + float nx = (x - (float)AnalogCodec.Min) / (AnalogCodec.Max - AnalogCodec.Min); + float ny = (y - (float)AnalogCodec.Min) / (AnalogCodec.Max - AnalogCodec.Min); + return new PointF( + BoxXY.X + 1 + nx * (BoxXY.Width - 2), + BoxXY.Bottom - 1 - ny * (BoxXY.Height - 2)); // up = positive Y + } + + // ---- Interaction ------------------------------------------------------- + + protected override void OnMouseDown(MouseEventArgs e) + { + base.OnMouseDown(e); + Focus(); + + // Axis gauges first (they live above the button grid). + if (e.Button == MouseButtons.Left) + { + if (BoxXY.Contains(e.Location)) + { + _dragStick = true; + Capture = true; + UpdateStick(e.Location); + return; + } + RioAxis? gauge = HitGauge(e.Location); + if (gauge is { } axis) + { + _dragAxis = axis; + Capture = true; + UpdateGauge(axis, e.Location); + return; + } + } + + PanelButton? hit = HitButton(e.Location); + if (hit is null) + return; + + if (e.Button == MouseButtons.Right) + { + // Latch toggle: press-and-hold without keeping the mouse down. + if (_latched.Remove(hit.Address)) + AddressReleased?.Invoke(hit.Address); + else + { + _latched.Add(hit.Address); + AddressPressed?.Invoke(hit.Address); + } + Invalidate(); + } + else if (e.Button == MouseButtons.Left) + { + if (_latched.Remove(hit.Address)) + { + // Left-clicking a latched button releases it. + AddressReleased?.Invoke(hit.Address); + Invalidate(); + return; + } + _mouseDownAddress = hit.Address; + AddressPressed?.Invoke(hit.Address); + Invalidate(); + } + } + + protected override void OnMouseMove(MouseEventArgs e) + { + base.OnMouseMove(e); + if (_dragStick) + UpdateStick(e.Location); + else if (_dragAxis is { } axis) + UpdateGauge(axis, e.Location); + } + + protected override void OnMouseUp(MouseEventArgs e) + { + base.OnMouseUp(e); + + if (_dragStick) + { + _dragStick = false; + Capture = false; + if (StickSpringsBack) + { + AxisMoved?.Invoke(RioAxis.JoystickX, 0); + AxisMoved?.Invoke(RioAxis.JoystickY, 0); + } + return; + } + if (_dragAxis is not null) + { + _dragAxis = null; + Capture = false; + return; + } + + ReleaseMomentary(); + } + + protected override void OnMouseCaptureChanged(EventArgs e) + { + base.OnMouseCaptureChanged(e); + _dragStick = false; + _dragAxis = null; + ReleaseMomentary(); + } + + private void ReleaseMomentary() + { + if (_mouseDownAddress is not { } addr) + return; + _mouseDownAddress = null; + AddressReleased?.Invoke(addr); + Invalidate(); + } + + private static PanelButton? HitButton(Point p) + { + foreach (PanelButton b in AllButtons) + if (Cell(b.Col, b.Row).Contains(p)) + return b; + return null; + } + + private static RioAxis? HitGauge(Point p) + { + if (BoxZ.Contains(p)) return RioAxis.Throttle; + if (BoxL.Contains(p)) return RioAxis.LeftPedal; + if (BoxR.Contains(p)) return RioAxis.RightPedal; + return null; + } + + private void UpdateStick(Point p) + { + short x = FromNorm((p.X - BoxXY.X) / (float)BoxXY.Width); + short y = FromNorm((BoxXY.Bottom - p.Y) / (float)BoxXY.Height); + AxisMoved?.Invoke(RioAxis.JoystickX, x); + AxisMoved?.Invoke(RioAxis.JoystickY, y); + } + + private void UpdateGauge(RioAxis axis, Point p) + { + Rectangle box = axis switch + { + RioAxis.Throttle => BoxZ, + RioAxis.LeftPedal => BoxL, + _ => BoxR, + }; + short v = FromNorm((box.Bottom - p.Y) / (float)box.Height); + AxisMoved?.Invoke(axis, v); + } + + private static short FromNorm(float norm) + { + norm = Math.Max(0f, Math.Min(1f, norm)); + return (short)(AnalogCodec.Min + norm * (AnalogCodec.Max - AnalogCodec.Min)); + } +} diff --git a/src/VRio.App/Program.cs b/src/VRio.App/Program.cs new file mode 100644 index 0000000..be10698 --- /dev/null +++ b/src/VRio.App/Program.cs @@ -0,0 +1,12 @@ +namespace VRio.App; + +internal static class Program +{ + [STAThread] + private static void Main() + { + Application.EnableVisualStyles(); + Application.SetCompatibleTextRenderingDefault(false); + Application.Run(new MainForm()); + } +} diff --git a/src/VRio.App/VRio.App.csproj b/src/VRio.App/VRio.App.csproj new file mode 100644 index 0000000..7114df3 --- /dev/null +++ b/src/VRio.App/VRio.App.csproj @@ -0,0 +1,25 @@ + + + + WinExe + net48 + enable + true + enable + latest + app.manifest + vRIO — Virtual RIO device + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + diff --git a/src/VRio.App/app.manifest b/src/VRio.App/app.manifest new file mode 100644 index 0000000..4e26873 --- /dev/null +++ b/src/VRio.App/app.manifest @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/src/VRio.Core/Device/RioAddressSpace.cs b/src/VRio.Core/Device/RioAddressSpace.cs new file mode 100644 index 0000000..cf801a4 --- /dev/null +++ b/src/VRio.Core/Device/RioAddressSpace.cs @@ -0,0 +1,80 @@ +namespace VRio.Core.Device; + +/// +/// The RIO's digital address space, as RIOJoy's iRIO map sees it: +/// 72 lamp-capable buttons at 0x00–0x47 (reported by index in +/// ButtonPressed/Released), keypad 0 at 0x50–0x5F and keypad 1 at 0x60–0x6F +/// (reported as KeyPressed/Released with a pad + key index; the PC adds the +/// 0x50/0x60 offset). vRIO works in addresses and converts to wire form when +/// sending. +/// +public static class RioAddressSpace +{ + /// Number of digital button inputs / lamps (addresses 0x00–0x47). + public const int LampCount = 72; + + /// Address offset of keypad 0 (pad byte 0 on the wire). + public const int Keypad0Base = 0x50; + + /// Address offset of keypad 1 (pad byte 1 on the wire). + public const int Keypad1Base = 0x60; + + /// Highest valid address (keypad 1, key 0x0F). + public const int MaxAddress = Keypad1Base + 0x0F; // 0x6F + + /// True for a lamp-capable button address (0x00–0x47). + public static bool IsButton(int address) => address is >= 0 and < LampCount; + + /// True for a keypad address (0x50–0x5F or 0x60–0x6F). + public static bool IsKeypad(int address) => + address is >= Keypad0Base and <= Keypad0Base + 0x0F + or >= Keypad1Base and <= MaxAddress; + + /// + /// Convert a keypad address to its wire (pad, index) pair — the inverse of + /// the PC's +0x50/+0x60 offsetting. + /// + public static (byte Pad, byte Index) ToKeypad(int address) + { + if (!IsKeypad(address)) + throw new ArgumentOutOfRangeException(nameof(address), $"0x{address:X2} is not a keypad address."); + + return address >= Keypad1Base + ? ((byte)1, (byte)(address - Keypad1Base)) + : ((byte)0, (byte)(address - Keypad0Base)); + } + + /// + /// The I/O boards a healthy cockpit reports, as (board number, name) — + /// numbering from the legacy firmware's board table (riovjoy2.cpp + /// GetBoardName). vRIO answers CheckRequest with one BoardOk + /// CheckReply per entry. + /// + public static readonly IReadOnlyList<(byte Number, string Name)> Boards = new (byte, string)[] + { + (0x00, "AuxLowerRight"), + (0x08, "AuxLowerLeft"), + (0x10, "Secondary1"), + (0x11, "Secondary2"), + (0x18, "AuxUpperCenter"), + (0x19, "AuxUpperLeft"), + (0x1A, "AuxUpperRight"), + (0x20, "IntKeyPad"), + (0x28, "ExtKeyPad"), + (0x30, "Throttle"), + (0x38, "JoyStick"), + }; +} + +/// +/// The five analog axes, indexed in AnalogReply payload order +/// (riojoy/docs/PROTOCOL.md §4). +/// +public enum RioAxis +{ + Throttle = 0, + LeftPedal = 1, + RightPedal = 2, + JoystickY = 3, + JoystickX = 4, +} diff --git a/src/VRio.Core/Device/VRioDevice.cs b/src/VRio.Core/Device/VRioDevice.cs new file mode 100644 index 0000000..9fdbdb6 --- /dev/null +++ b/src/VRio.Core/Device/VRioDevice.cs @@ -0,0 +1,396 @@ +using VRio.Core.Protocol; + +namespace VRio.Core.Device; + +/// +/// The virtual RIO board: a pure (transport-free) protocol state machine that +/// behaves like the cockpit hardware on the wire. Feed it received bytes via +/// ; everything it wants to transmit is raised through +/// . The UI pokes it with , +/// , etc., and listens for to +/// light its on-screen buttons. +/// +/// Wire behavior (mirroring what RIOJoy expects from the real board): +/// +/// ACKs every well-formed inbound packet, NAKs one with a bad checksum. +/// CheckRequest → a BoardOk CheckReply per known board. +/// VersionRequest → VersionReply with the configured firmware version +/// (default 4.2, matching the real board's dumped EPROM). +/// AnalogRequest → AnalogReply with the current five axis values. +/// ResetRequest → re-zeroes the targeted axis (or all) like a +/// recalibration, and reports it via . +/// LampRequest → stores the lamp state and raises . +/// A NAK from the PC re-sends the last event packet up to 4 times, then +/// gives up with a RESTART byte — the real v4.2 firmware's retry budget +/// (riojoy/rio-firmware/RIOv4_2-ANALYSIS.md, counters $3173-$3175 limit 4, +/// give-up at $D9D5 sends $FE). +/// +/// +/// With on, the give-up path also +/// reproduces the v4.2 firmware's orphaned reply-in-progress latch ($2521): +/// every subsequent AnalogRequest is silently dropped (still ACK'd — the RX +/// path stays alive) until a host ResetRequest arrives, mirroring the +/// mash-stress analog mute seen on real hardware. Useful for exercising +/// RIOJoy's no-analog recovery watchdog. +/// +/// All members are thread-safe; events may fire on the caller's thread. +/// +public sealed class VRioDevice +{ + // The real firmware retries a reply 4 times before giving up ($3173-$3175). + private const int MaxResends = 4; + + private readonly object _gate = new(); + private readonly PacketParser _parser = new(); + private readonly short[] _axes = new short[5]; + private readonly byte[] _lamps = new byte[RioAddressSpace.LampCount]; + + private byte[]? _lastEventPacket; + private int _resends; + private bool _analogMuted; + + /// Firmware version reported by VersionReply (real boards run 4.2). + public byte VersionMajor { get; set; } = 4; + + /// Firmware version reported by VersionReply (real boards run 4.2). + public byte VersionMinor { get; set; } = 2; + + /// + /// When true, retry exhaustion leaves the analog reply path wedged (the + /// v4.2 latch-leak bug) until a host ResetRequest clears it. + /// + public bool EmulateReplyWedge { get; set; } + + /// True while the analog reply path is wedged (see ). + public bool AnalogWedged + { + get { lock (_gate) return _analogMuted; } + } + + /// Bytes the device wants on the wire (already framed). + public event Action? Transmit; + + /// A lamp changed: (address 0x00–0x47, raw lamp-state byte). + public event Action? LampChanged; + + /// The PC asked for an axis reset/recalibration. + public event Action? ResetReceived; + + /// Axis values changed (reset or local set) — refresh gauges. + public event Action? AxesChanged; + + /// Human-readable protocol log lines (analog polls excluded — see ). + public event Action? Logged; + + /// Count of AnalogRequests served (RIOJoy polls ~18×/s; logging each would drown the log). + public long AnalogRequests { get; private set; } + + /// Count of AnalogRequests silently dropped while wedged. + public long AnalogDropped { get; private set; } + + /// Count of packets received with a bad checksum (NAK'd). + public long BadChecksums { get; private set; } + + // ---- Local (UI-facing) state ------------------------------------------ + + /// Current raw value of an axis. + public short GetAxis(RioAxis axis) + { + lock (_gate) return _axes[(int)axis]; + } + + /// + /// Move an axis. Values are clamped to the 14-bit signed range the wire + /// can carry; the new value is returned by the next AnalogReply. + /// + public void SetAxis(RioAxis axis, int value) + { + short clamped = (short)Math.Max(AnalogCodec.Min, Math.Min(AnalogCodec.Max, value)); + lock (_gate) _axes[(int)axis] = clamped; + AxesChanged?.Invoke(); + } + + /// Current lamp state byte for a button address (0x00–0x47). + public byte GetLamp(int address) + { + if (!RioAddressSpace.IsButton(address)) return 0; + lock (_gate) return _lamps[address]; + } + + /// + /// Locally darken every lamp (a fresh board powers up dark; the host + /// re-lights what it wants). Callers should refresh their display. + /// + public void ClearLamps() + { + lock (_gate) Array.Clear(_lamps, 0, _lamps.Length); + Logged?.Invoke("Local: all lamps cleared"); + } + + // ---- Local inputs → wire events --------------------------------------- + + /// Press the input at a RIO address (button or keypad key). + public void PressAddress(int address) => SendInputEvent(address, pressed: true); + + /// Release the input at a RIO address (button or keypad key). + public void ReleaseAddress(int address) => SendInputEvent(address, pressed: false); + + /// Announce a test-mode change (0 = exit test mode). + public void SendTestMode(byte mode) + { + SendEvent(PacketBuilder.TestModeChange((byte)(mode & 0x7F))); + Logged?.Invoke($"TX TestModeChange mode={mode}"); + } + + /// + /// Force the analog reply path into the wedged state right now (as if the + /// retry give-up just leaked the latch) — a one-click way to watch the + /// host's no-analog recovery kick in. A host ResetRequest clears it. + /// + public void WedgeAnalogNow() + { + lock (_gate) _analogMuted = true; + Logged?.Invoke("Local: ANALOG WEDGED (v4.2 bug emulation) — waiting for a host reset"); + } + + private void SendInputEvent(int address, bool pressed) + { + byte[] packet; + if (RioAddressSpace.IsButton(address)) + { + packet = pressed + ? PacketBuilder.ButtonPressed((byte)address) + : PacketBuilder.ButtonReleased((byte)address); + } + else if (RioAddressSpace.IsKeypad(address)) + { + (byte pad, byte index) = RioAddressSpace.ToKeypad(address); + packet = pressed + ? PacketBuilder.KeyPressed(pad, index) + : PacketBuilder.KeyReleased(pad, index); + } + else + { + throw new ArgumentOutOfRangeException(nameof(address), $"0x{address:X2} is not a RIO input address."); + } + + SendEvent(packet); + Logged?.Invoke($"TX {(pressed ? "press" : "release")} 0x{address:X2}"); + } + + // Event packets (button/key/test) are remembered so a NAK can re-send them. + private void SendEvent(byte[] packet) + { + lock (_gate) + { + _lastEventPacket = packet; + _resends = 0; + } + Transmit?.Invoke(packet); + } + + private void Send(byte[] packet) => Transmit?.Invoke(packet); + + private void SendControl(RioControl control) => Transmit?.Invoke(new[] { (byte)control }); + + // ---- Wire → device ----------------------------------------------------- + + /// Feed bytes received from the PC. + public void OnReceived(byte[] data, int count) + { + for (int i = 0; i < count; i++) + { + bool produced; + RioRxEvent ev; + lock (_gate) produced = _parser.Feed(data[i], out ev); + if (produced) + HandleEvent(ev); + } + } + + private void HandleEvent(RioRxEvent ev) + { + switch (ev.Kind) + { + case RioRxKind.Packet when !ev.ChecksumValid: + BadChecksums++; + SendControl(RioControl.Nak); + Logged?.Invoke($"RX {ev.Packet} — bad checksum, NAK'd"); + break; + + case RioRxKind.Packet: + SendControl(RioControl.Ack); + HandlePacket(ev.Packet); + break; + + case RioRxKind.ControlByte: + HandleControl(ev.Byte); + break; + + case RioRxKind.FramingError: + Logged?.Invoke($"RX framing error (0x{ev.Byte:X2} mid-packet) — resynced"); + break; + } + } + + private void HandlePacket(RioPacket packet) + { + byte[] p = packet.Payload; + switch (packet.Command) + { + case RioCommand.CheckRequest: + Logged?.Invoke("RX CheckRequest → all boards OK"); + foreach ((byte number, string _) in RioAddressSpace.Boards) + Send(PacketBuilder.CheckReply(RioStatusType.BoardOk, number)); + break; + + case RioCommand.VersionRequest: + Logged?.Invoke($"RX VersionRequest → {VersionMajor}.{VersionMinor}"); + Send(PacketBuilder.VersionReply(VersionMajor, VersionMinor)); + break; + + case RioCommand.AnalogRequest: + short t, l, r, y, x; + lock (_gate) + { + if (_analogMuted) + { + // The wedged v4.2 board drops the request in reply + // generation ($D758) — the packet was still ACK'd above. + AnalogDropped++; + return; + } + AnalogRequests++; + t = _axes[(int)RioAxis.Throttle]; + l = _axes[(int)RioAxis.LeftPedal]; + r = _axes[(int)RioAxis.RightPedal]; + y = _axes[(int)RioAxis.JoystickY]; + x = _axes[(int)RioAxis.JoystickX]; + } + Send(PacketBuilder.AnalogReply(t, l, r, y, x)); + break; + + case RioCommand.ResetRequest: + var target = (RioResetTarget)p[0]; + ApplyReset(target); + bool unwedged; + lock (_gate) + { + // The host reset/init handler ($C686) is what clears the + // real board's leaked reply latch — mirror that here. + unwedged = _analogMuted; + _analogMuted = false; + } + Logged?.Invoke($"RX ResetRequest {target} → re-zeroed" + (unwedged ? " (analog wedge cleared)" : "")); + ResetReceived?.Invoke(target); + break; + + case RioCommand.LampRequest: + int lamp = p[0]; + byte state = p[1]; + if (RioAddressSpace.IsButton(lamp)) + { + lock (_gate) _lamps[lamp] = state; + Logged?.Invoke($"RX Lamp 0x{lamp:X2} = 0x{state:X2} ({RioLampState.Describe(state)})"); + LampChanged?.Invoke(lamp, state); + } + else + { + Logged?.Invoke($"RX LampRequest for unknown lamp 0x{lamp:X2} — ignored"); + } + break; + + default: + // A RIO→PC message arriving at the device end — echo it to the log. + Logged?.Invoke($"RX unexpected {packet} — ignored"); + break; + } + } + + // On the real board a reset re-references the encoder at its current + // position; the emulator's equivalent is snapping the value back to zero. + private void ApplyReset(RioResetTarget target) + { + lock (_gate) + { + switch (target) + { + case RioResetTarget.Throttle: _axes[(int)RioAxis.Throttle] = 0; break; + case RioResetTarget.LeftPedal: _axes[(int)RioAxis.LeftPedal] = 0; break; + case RioResetTarget.RightPedal: _axes[(int)RioAxis.RightPedal] = 0; break; + case RioResetTarget.VerticalJoystick: _axes[(int)RioAxis.JoystickY] = 0; break; + case RioResetTarget.HorizontalJoystick: _axes[(int)RioAxis.JoystickX] = 0; break; + default: Array.Clear(_axes, 0, _axes.Length); break; // general reset + } + } + AxesChanged?.Invoke(); + } + + private void HandleControl(byte b) + { + switch ((RioControl)b) + { + case RioControl.Ack: + lock (_gate) + { + _lastEventPacket = null; + _resends = 0; + } + break; + + case RioControl.Nak: + byte[]? resend = null; + bool giveUp = false; + lock (_gate) + { + if (_lastEventPacket is null) + { + // nothing pending; stray NAK + } + else if (_resends < MaxResends) + { + _resends++; + resend = _lastEventPacket; + } + else + { + // Retry budget exhausted: the real firmware sends RESTART + // ($D9D5) and tears down — leaking its reply latch. + _lastEventPacket = null; + _resends = 0; + giveUp = true; + if (EmulateReplyWedge) + _analogMuted = true; + } + } + if (resend is not null) + { + Logged?.Invoke("RX NAK — re-sending last event"); + Transmit?.Invoke(resend); + } + else if (giveUp) + { + Logged?.Invoke(EmulateReplyWedge + ? "RX NAK — retries exhausted, RESTART sent; ANALOG WEDGED (v4.2 bug) until a host reset" + : "RX NAK — retries exhausted, RESTART sent"); + SendControl(RioControl.Restart); + } + else + { + Logged?.Invoke("RX NAK — nothing to re-send (dropped)"); + } + break; + + case RioControl.Restart: + Logged?.Invoke("RX RESTART"); + break; + + case RioControl.Idle: + break; // keep-alive noise + + default: + Logged?.Invoke($"RX stray byte 0x{b:X2}"); + break; + } + } +} diff --git a/src/VRio.Core/Device/VRioSerialService.cs b/src/VRio.Core/Device/VRioSerialService.cs new file mode 100644 index 0000000..4816e63 --- /dev/null +++ b/src/VRio.Core/Device/VRioSerialService.cs @@ -0,0 +1,169 @@ +using System.IO.Ports; +using VRio.Core.Protocol; + +namespace VRio.Core.Device; + +/// +/// Pumps a over a real COM port at the RIO's +/// 9600 8N1 settings. On a single PC, pair it with RIOJoy through a virtual +/// null-modem (e.g. com0com): vRIO opens one end, RIOJoy the other. +/// +/// RIOJoy pulses DTR for 50 ms when it opens its end (the board-reset +/// handshake); through a null modem that arrives here as a DSR blip, which is +/// surfaced via so the UI can show that a host +/// connected. +/// +public sealed class VRioSerialService : IDisposable +{ + /// RIO link bit rate (must match RIOJoy's transport). + public const int BaudRate = 9600; + + private readonly VRioDevice _device; + private readonly object _writeGate = new(); + + private SerialPort? _port; + private Thread? _reader; + private volatile bool _running; + + public VRioSerialService(VRioDevice device) + { + _device = device ?? throw new ArgumentNullException(nameof(device)); + _device.Transmit += Write; + } + + /// True while a COM port is open. + public bool IsOpen => _port?.IsOpen == true; + + /// The open port's name, or null. + public string? PortName => _port?.PortName; + + /// Raised after the port opens (true) or closes (false). + public event Action? ConnectionChanged; + + /// + /// The host's DTR line changed (RIOJoy pulses it high for 50 ms when it + /// opens the port — the board-reset handshake). The argument is the new + /// line state as seen on our DSR pin. + /// + public event Action? HostHandshake; + + /// Port-level log lines (open/close/errors). + public event Action? Logged; + + /// Open and start serving the device. + public void Open(string portName) + { + if (string.IsNullOrWhiteSpace(portName)) + throw new ArgumentException("Port name is required.", nameof(portName)); + + Close(); + + var port = new SerialPort(portName, BaudRate, Parity.None, 8, StopBits.One) + { + Handshake = Handshake.None, + // Finite read timeout so the reader thread can notice shutdown. + ReadTimeout = 200, + WriteTimeout = 2000, + // Assert our modem lines: through a null modem the host sees DSR/CTS + // high, i.e. "board present". + DtrEnable = true, + RtsEnable = true, + }; + port.PinChanged += OnPinChanged; + port.Open(); + + _port = port; + _running = true; + _reader = new Thread(ReadLoop) { IsBackground = true, Name = "vRIO serial reader" }; + _reader.Start(); + + Logged?.Invoke($"Opened {portName} @ {BaudRate} 8N1 — waiting for the host"); + ConnectionChanged?.Invoke(true); + } + + /// Close the port (idempotent). + public void Close() + { + SerialPort? port = _port; + if (port is null) + return; + + _running = false; + _port = null; + port.PinChanged -= OnPinChanged; + try { port.Close(); } + catch (IOException) { } + port.Dispose(); + + _reader?.Join(1000); + _reader = null; + + Logged?.Invoke("Port closed"); + ConnectionChanged?.Invoke(false); + } + + private void OnPinChanged(object sender, SerialPinChangedEventArgs e) + { + // RIOJoy raises then drops DTR on open; either edge means a host is there. + if (e.EventType != SerialPinChange.DsrChanged) + return; + + bool high = false; + try { high = _port?.DsrHolding ?? false; } + catch (Exception ex) when (ex is IOException or InvalidOperationException) { } + HostHandshake?.Invoke(high); + } + + private void ReadLoop() + { + var buffer = new byte[256]; + while (_running) + { + SerialPort? port = _port; + if (port is null) + return; + + int n; + try + { + n = port.Read(buffer, 0, buffer.Length); + } + catch (TimeoutException) + { + continue; // just a poll tick; check _running again + } + catch (Exception ex) when (ex is IOException or InvalidOperationException or OperationCanceledException) + { + if (_running) + Logged?.Invoke($"Port error: {ex.Message}"); + return; + } + + if (n > 0) + _device.OnReceived(buffer, n); + } + } + + private void Write(byte[] data) + { + SerialPort? port = _port; + if (port is null || !port.IsOpen) + return; // device poked while offline — drop silently + + try + { + lock (_writeGate) + port.Write(data, 0, data.Length); + } + catch (Exception ex) when (ex is IOException or InvalidOperationException or TimeoutException) + { + Logged?.Invoke($"Write failed: {ex.Message}"); + } + } + + public void Dispose() + { + _device.Transmit -= Write; + Close(); + } +} diff --git a/src/VRio.Core/Panel/CockpitLayout.cs b/src/VRio.Core/Panel/CockpitLayout.cs new file mode 100644 index 0000000..a36a103 --- /dev/null +++ b/src/VRio.Core/Panel/CockpitLayout.cs @@ -0,0 +1,140 @@ +using VRio.Core.Device; + +namespace VRio.Core.Panel; + +/// Shape of a panel group. +public enum PanelGroupKind +{ + /// A 4×2 MFD button cluster (lamp buttons). + Mfd, + + /// A vertical 1×8 board column (lamp buttons). + Column, + + /// A 4×4 keypad (no lamps). + Keypad, +} + +/// +/// One functional group on the cockpit control panel: a titled block of address +/// buttons laid out row-major in × . +/// / place the block on the panel +/// in button-cell units. +/// +public sealed record PanelGroup( + string Title, + PanelGroupKind Kind, + int Cols, + int Rows, + bool LampCapable, + int OriginCol, + int OriginRow, + IReadOnlyList Addresses); + +/// One address button placed on the panel (absolute cell coordinates). +public sealed record PanelButton(int Address, PanelGroup Group, int Col, int Row, bool LampCapable); + +/// +/// The cockpit control panel's logical layout — the same functional grouping +/// RIOJoy's profile editor draws (its CockpitPanel, derived from the +/// original Win32 RIO control-panel mockup): five MFD clusters, four board +/// columns, and the two later-added 4×4 keypads. Every RIO address +/// 0x00–0x47 / 0x50–0x6F is placed exactly once. Keeping the byte-for-byte same +/// layout means a button on vRIO sits where the same button sits in RIOJoy's +/// editor — pressing one lights the other. +/// +public static class CockpitLayout +{ + // Helper: 4×2 MFD address block, top row then bottom row (addresses descend). + private static int?[] Mfd(int hi) => new int?[] + { + hi, hi - 1, hi - 2, hi - 3, + hi - 4, hi - 5, hi - 6, hi - 7, + }; + + // Helper: vertical 1×8 column from a base address upward. + private static int?[] Col8(int baseAddr) => new int?[] + { + baseAddr, baseAddr + 1, baseAddr + 2, baseAddr + 3, + baseAddr + 4, baseAddr + 5, baseAddr + 6, baseAddr + 7, + }; + + // Helper: 4×4 keypad. Physical keys 1 2 3 C / 4 5 6 D / 7 8 9 E / A 0 B F map + // to address = base + that hex digit. + private static int?[] Keypad(int baseAddr) + { + int[] digits = { 0x1, 0x2, 0x3, 0xC, 0x4, 0x5, 0x6, 0xD, 0x7, 0x8, 0x9, 0xE, 0xA, 0x0, 0xB, 0xF }; + var a = new int?[16]; + for (int i = 0; i < 16; i++) a[i] = baseAddr + digits[i]; + return a; + } + + /// All panel groups, positioned for rendering. + public static IReadOnlyList Groups { get; } = new[] + { + // Upper MFD row. + new PanelGroup("Upper Left MFD", PanelGroupKind.Mfd, 4, 2, true, 0, 1, Mfd(0x2F)), + new PanelGroup("Upper Middle MFD", PanelGroupKind.Mfd, 4, 2, true, 4, 1, Mfd(0x27)), + new PanelGroup("Upper Right MFD", PanelGroupKind.Mfd, 4, 2, true, 8, 1, Mfd(0x37)), + + // Lower MFD row. + new PanelGroup("Lower Left MFD", PanelGroupKind.Mfd, 4, 2, true, 0, 5, Mfd(0x0F)), + new PanelGroup("Lower Right MFD", PanelGroupKind.Mfd, 4, 2, true, 8, 5, Mfd(0x07)), + + // Board columns. + new PanelGroup("Throttle", PanelGroupKind.Column, 1, 8, true, 0, 9, Col8(0x38)), + new PanelGroup("Secondary", PanelGroupKind.Column, 1, 8, true, 4, 9, Col8(0x10)), + new PanelGroup("Screen", PanelGroupKind.Column, 1, 8, true, 6, 9, Col8(0x18)), + new PanelGroup("Joystick", PanelGroupKind.Column, 1, 8, true, 11, 9, Col8(0x40)), + + // Keypads (no lamps): internal in the middle, external between the + // Secondary/Screen columns and the Joystick column. + new PanelGroup("Internal Keypad", PanelGroupKind.Keypad, 4, 4, false, 4, 4, Keypad(0x50)), + new PanelGroup("External Keypad", PanelGroupKind.Keypad, 4, 4, false, 7, 9, Keypad(0x60)), + }; + + /// Flatten the groups to positioned s (absolute cells). + public static IReadOnlyList Buttons() + { + var buttons = new List(); + foreach (PanelGroup g in Groups) + { + for (int i = 0; i < g.Addresses.Count; i++) + { + if (g.Addresses[i] is not { } addr) continue; + int localCol = i % g.Cols; + int localRow = i / g.Cols; + // +1 row leaves space under the title. + buttons.Add(new PanelButton(addr, g, g.OriginCol + localCol, g.OriginRow + 1 + localRow, g.LampCapable)); + } + } + return buttons; + } + + // Named physical controls (from the Win32RIO control-panel mockup); every + // other lamp button is known only by its hex address. + private static readonly Dictionary Named = new() + { + [0x3D] = "Panic", + [0x3F] = "Throttle", + [0x40] = "Main", + [0x41] = "Hat Back", + [0x42] = "Hat Up", + [0x43] = "Hat Right", + [0x44] = "Hat Left", + [0x45] = "Pinky", + [0x46] = "Middle", + [0x47] = "Upper", + }; + + /// + /// The label printed on the physical control: keypad keys carry their hex + /// digit, a few controls have names, the rest is null (show the address). + /// + public static string? PhysicalLabel(int address) + { + if (RioAddressSpace.IsKeypad(address)) + return (address & 0x0F).ToString("X1"); + return Named.TryGetValue(address, out string? name) ? name : null; + } +} diff --git a/src/VRio.Core/Protocol/PacketBuilder.cs b/src/VRio.Core/Protocol/PacketBuilder.cs new file mode 100644 index 0000000..1528e09 --- /dev/null +++ b/src/VRio.Core/Protocol/PacketBuilder.cs @@ -0,0 +1,103 @@ +namespace VRio.Core.Protocol; + +/// +/// Builds outbound packets: [command][payload…][checksum]. Same framing +/// as RIOJoy's builder, but the convenience methods here cover the RIO → PC +/// messages, because vRIO is the device. +/// +public static class PacketBuilder +{ + /// + /// Build a wire-ready packet for . The payload + /// length must match the command's entry in the length table. + /// + public static byte[] Build(RioCommand command, ReadOnlySpan payload) + { + int expected = RioCommandTable.PayloadLength(command); + if (payload.Length != expected) + throw new ArgumentException( + $"{command} expects a {expected}-byte payload, got {payload.Length}.", + nameof(payload)); + + var packet = new byte[1 + expected + 1]; + packet[0] = (byte)command; + payload.CopyTo(packet.AsSpan(1)); + packet[packet.Length - 1] = RioChecksum.Compute(packet.AsSpan(0, 1 + expected)); + return packet; + } + + /// Build a zero-payload command packet. + public static byte[] Build(RioCommand command) => Build(command, ReadOnlySpan.Empty); + + // --- Convenience builders for the RIO → PC messages ---------------------- + + public static byte[] CheckReply(RioStatusType type, byte number) => + Build(RioCommand.CheckReply, stackalloc byte[] { (byte)type, number }); + + public static byte[] VersionReply(byte major, byte minor) => + Build(RioCommand.VersionReply, stackalloc byte[] { major, minor }); + + /// + /// AnalogReply: 5 axes × (low, high) in the order throttle, left pedal, + /// right pedal, joystick Y, joystick X. Each axis is a 14-bit signed value + /// split into two 7-bit bytes (see ). + /// + public static byte[] AnalogReply(short throttle, short leftPedal, short rightPedal, short joystickY, short joystickX) + { + Span payload = stackalloc byte[10]; + AnalogCodec.Split(throttle, out payload[0], out payload[1]); + AnalogCodec.Split(leftPedal, out payload[2], out payload[3]); + AnalogCodec.Split(rightPedal, out payload[4], out payload[5]); + AnalogCodec.Split(joystickY, out payload[6], out payload[7]); + AnalogCodec.Split(joystickX, out payload[8], out payload[9]); + return Build(RioCommand.AnalogReply, payload); + } + + public static byte[] ButtonPressed(byte index) => + Build(RioCommand.ButtonPressed, stackalloc byte[] { index }); + + public static byte[] ButtonReleased(byte index) => + Build(RioCommand.ButtonReleased, stackalloc byte[] { index }); + + public static byte[] KeyPressed(byte pad, byte index) => + Build(RioCommand.KeyPressed, stackalloc byte[] { pad, index }); + + public static byte[] KeyReleased(byte pad, byte index) => + Build(RioCommand.KeyReleased, stackalloc byte[] { pad, index }); + + public static byte[] TestModeChange(byte mode) => + Build(RioCommand.TestModeChange, stackalloc byte[] { mode }); +} + +/// +/// Packs/unpacks the 14-bit signed axis values carried by AnalogReply: two +/// 7-bit bytes, low then high; bit 13 is the sign (riojoy/docs/PROTOCOL.md §4). +/// +public static class AnalogCodec +{ + /// Smallest representable axis value (14-bit signed). + public const short Min = -8192; + + /// Largest representable axis value (14-bit signed). + public const short Max = 8191; + + /// Split an axis value into its (low, high) 7-bit wire bytes. + public static void Split(short value, out byte low, out byte high) + { + if (value is < Min or > Max) + throw new ArgumentOutOfRangeException(nameof(value), $"Axis value must be {Min}..{Max}."); + + int raw = value & 0x3FFF; // 14-bit two's complement + low = (byte)(raw & 0x7F); + high = (byte)((raw >> 7) & 0x7F); + } + + /// Recombine (low, high) wire bytes into the signed axis value. + public static short Combine(byte low, byte high) + { + int raw = (low & 0x7F) | (high << 7); // 14 bits + if ((raw & 0x2000) != 0) // bit 13 set → negative + raw |= ~0x3FFF; // sign-extend into the high bits + return (short)raw; + } +} diff --git a/src/VRio.Core/Protocol/PacketParser.cs b/src/VRio.Core/Protocol/PacketParser.cs new file mode 100644 index 0000000..e05470d --- /dev/null +++ b/src/VRio.Core/Protocol/PacketParser.cs @@ -0,0 +1,100 @@ +namespace VRio.Core.Protocol; + +/// +/// Streaming receive-side framing state machine, fed raw bytes from the serial +/// port. Identical framing rules to RIOJoy's parser (riojoy/docs/PROTOCOL.md §2) +/// — vRIO just feeds it the opposite direction of traffic (PC → RIO commands): +/// +/// Outside a packet, a byte in the command range starts a packet of +/// 1 + payloadLen + 1 bytes (command + payload + checksum). +/// Outside a packet, any other byte is a control byte (ACK/NAK/…). +/// Mid-packet, a byte with the high bit set aborts the packet and +/// resynchronizes; that byte is discarded. +/// The final byte completes the packet with a real checksum compare. +/// +/// Not thread-safe; drive it from one reader. +/// +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; + + /// True while a packet is partially received. + public bool InPacket => _remaining != 0; + + /// Discard any in-progress packet and reset to the idle state. + public void Reset() + { + _remaining = 0; + _count = 0; + } + + /// + /// Feed one received byte. Returns and sets + /// when this byte produced an event; otherwise the byte + /// was consumed into an in-progress packet. + /// + 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; + } + + 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); + } +} diff --git a/src/VRio.Core/Protocol/RioChecksum.cs b/src/VRio.Core/Protocol/RioChecksum.cs new file mode 100644 index 0000000..8844ab5 --- /dev/null +++ b/src/VRio.Core/Protocol/RioChecksum.cs @@ -0,0 +1,22 @@ +namespace VRio.Core.Protocol; + +/// +/// The RIO packet checksum: the low 7 bits of the sum of the low 7 bits of +/// every byte in the command+payload (the checksum byte itself excluded). +/// Same algorithm as RIOJoy / the legacy firmware (riojoy/docs/PROTOCOL.md §2). +/// +public static class RioChecksum +{ + /// + /// Compute the 7-bit checksum over + /// (the command byte followed by its payload bytes — not the checksum byte). + /// + public static byte Compute(ReadOnlySpan commandAndPayload) + { + byte sum = 0; + foreach (byte b in commandAndPayload) + sum += (byte)(b & 0x7F); + + return (byte)(sum & 0x7F); + } +} diff --git a/src/VRio.Core/Protocol/RioCommands.cs b/src/VRio.Core/Protocol/RioCommands.cs new file mode 100644 index 0000000..5b8ee0e --- /dev/null +++ b/src/VRio.Core/Protocol/RioCommands.cs @@ -0,0 +1,119 @@ +namespace VRio.Core.Protocol; + +/// +/// RIO message commands. The command byte always has its high bit set; payload +/// bytes are 7-bit. Wire contract shared with RIOJoy (riojoy/docs/PROTOCOL.md §3), +/// originally reverse-engineered from the legacy riovjoy2.cpp. vRIO sits on the +/// device side of this table: it receives the PC→RIO commands and sends +/// the RIO→PC ones. +/// +public enum RioCommand : byte +{ + // PC → RIO (vRIO receives these) + CheckRequest = 0x80, + VersionRequest = 0x81, + AnalogRequest = 0x82, + ResetRequest = 0x83, + LampRequest = 0x84, + + // RIO → PC (vRIO sends these) + CheckReply = 0x85, + VersionReply = 0x86, + AnalogReply = 0x87, + ButtonPressed = 0x88, + ButtonReleased = 0x89, + KeyPressed = 0x8A, + KeyReleased = 0x8B, + TestModeChange = 0x8C, +} + +/// Single-byte control characters that live outside packet framing. +public enum RioControl : byte +{ + /// Packet accepted. + Ack = 0xFC, + + /// Packet rejected; resend. + Nak = 0xFD, + + /// Restart; also used as an in-payload "invalid" sentinel. + Restart = 0xFE, + + /// Idle. + Idle = 0xFF, +} + +/// Reset targets for . +public enum RioResetTarget : byte +{ + All = 0, + Throttle = 1, + LeftPedal = 2, + RightPedal = 3, + VerticalJoystick = 4, // Y + HorizontalJoystick = 5, // X +} + +/// The statusType field of . +public enum RioStatusType : byte +{ + BoardOk = 0, + BoardMissing = 1, + BoardBad = 2, + LampBad = 3, + RestartCount = 4, + AbandonCount = 5, + FullBufferCount = 6, +} + +/// +/// Per-command payload lengths (bytes between the command byte and the checksum +/// byte). Must match RIOJoy's table exactly or the two ends lose framing. +/// +public static class RioCommandTable +{ + /// Lowest command byte value (). + public const byte Base = (byte)RioCommand.CheckRequest; + + // Indexed by (command - Base). Order must match RioCommand. + private static readonly byte[] PayloadLengths = + { + 0, // CheckRequest + 0, // VersionRequest + 0, // AnalogRequest + 1, // ResetRequest + 2, // LampRequest + 2, // CheckReply + 2, // VersionReply + 10, // AnalogReply + 1, // ButtonPressed + 1, // ButtonReleased + 2, // KeyPressed + 2, // KeyReleased + 1, // TestModeChange + }; + + /// Number of defined commands. + public static int Count => PayloadLengths.Length; + + /// Highest valid command byte value (inclusive). + public static byte Max => (byte)(Base + Count - 1); + + /// True if is a known command. + public static bool IsCommand(byte commandByte) => + commandByte >= Base && commandByte <= Max; + + /// Payload length for a command byte (guard with ). + public static int PayloadLength(byte commandByte) + { + if (!IsCommand(commandByte)) + throw new ArgumentOutOfRangeException( + nameof(commandByte), + $"0x{commandByte:X2} is not a RIO command (valid 0x{Base:X2}..0x{Max:X2})."); + + return PayloadLengths[commandByte - Base]; + } + + /// Payload length for a . + public static int PayloadLength(RioCommand command) => PayloadLength((byte)command); +} diff --git a/src/VRio.Core/Protocol/RioLampState.cs b/src/VRio.Core/Protocol/RioLampState.cs new file mode 100644 index 0000000..fc7332e --- /dev/null +++ b/src/VRio.Core/Protocol/RioLampState.cs @@ -0,0 +1,60 @@ +namespace VRio.Core.Protocol; + +/// Flash mode for a lighted button (lamp-state bits 0–1). +public enum LampFlash : byte +{ + Solid = 0, + FlashSlow = 1, + FlashMed = 2, + FlashFast = 3, +} + +/// Brightness level decoded from a lamp-state byte. +public enum LampBrightness +{ + Off, + Dim, + Bright, +} + +/// +/// The state byte of a : flash bits +/// 0–1 plus two brightness fields (bits 2–3 and 4–5). vRIO decodes it to shade +/// its on-screen lamps the way the physical board would light them. +/// +public static class RioLampState +{ + /// Lamp off (SolidOff, 0x00). + public const byte SolidOff = 0x00; + + /// Dim solid (SolidDim, 0x14) — RIOJoy's idle state for mapped lamps. + public const byte SolidDim = 0x14; + + /// Bright solid (SolidBright, 0x3C) — RIOJoy's pressed state. + public const byte SolidBright = 0x3C; + + /// Flash mode from a lamp-state byte. + public static LampFlash Flash(byte state) => (LampFlash)(state & 0x03); + + /// + /// Effective brightness from a lamp-state byte: the brighter of the two + /// brightness fields (field 1 = bits 2–3: 0x04 dim / 0x0C bright; field 2 = + /// bits 4–5: 0x10 dim / 0x30 bright). + /// + public static LampBrightness Brightness(byte state) + { + int f1 = state & 0x0C; + int f2 = state & 0x30; + bool bright = f1 == 0x0C || f2 == 0x30; + bool dim = f1 != 0 || f2 != 0; + return bright ? LampBrightness.Bright : dim ? LampBrightness.Dim : LampBrightness.Off; + } + + /// Human-readable description, e.g. "Bright" or "Dim flash-fast". + public static string Describe(byte state) + { + LampBrightness b = Brightness(state); + LampFlash f = Flash(state); + return f == LampFlash.Solid ? b.ToString() : $"{b} {f}"; + } +} diff --git a/src/VRio.Core/Protocol/RioPacket.cs b/src/VRio.Core/Protocol/RioPacket.cs new file mode 100644 index 0000000..5bec2e6 --- /dev/null +++ b/src/VRio.Core/Protocol/RioPacket.cs @@ -0,0 +1,58 @@ +namespace VRio.Core.Protocol; + +/// One framed RIO message: the command plus its (checksum-stripped) payload. +public readonly struct RioPacket +{ + public RioCommand Command { get; } + public byte[] Payload { get; } + + public RioPacket(RioCommand command, byte[] payload) + { + Command = command; + Payload = payload ?? Array.Empty(); + } + + public override string ToString() => + Payload.Length == 0 + ? Command.ToString() + : $"{Command} [{string.Join(" ", Payload.Select(b => b.ToString("X2")))}]"; +} + +/// What a fed byte produced (see ). +public enum RioRxKind +{ + /// A complete framed packet. + Packet, + + /// A single byte outside framing: ACK/NAK/RESTART/IDLE or garbage. + ControlByte, + + /// A high-bit byte arrived mid-packet; the packet was abandoned. + FramingError, +} + +/// A receive event emitted by . +public readonly struct RioRxEvent +{ + public RioRxKind Kind { get; } + public RioPacket Packet { get; } + public byte Byte { get; } + public bool ChecksumValid { get; } + + private RioRxEvent(RioRxKind kind, RioPacket packet, byte b, bool checksumValid) + { + Kind = kind; + Packet = packet; + Byte = b; + ChecksumValid = checksumValid; + } + + public static RioRxEvent ForPacket(RioPacket packet, bool checksumValid) => + new(RioRxKind.Packet, packet, 0, checksumValid); + + public static RioRxEvent ForControl(byte b) => + new(RioRxKind.ControlByte, default, b, false); + + public static RioRxEvent ForFramingError(byte b) => + new(RioRxKind.FramingError, default, b, false); +} diff --git a/src/VRio.Core/VRio.Core.csproj b/src/VRio.Core/VRio.Core.csproj new file mode 100644 index 0000000..f082bf1 --- /dev/null +++ b/src/VRio.Core/VRio.Core.csproj @@ -0,0 +1,22 @@ + + + + + net48 + enable + enable + latest + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + diff --git a/tests/VRio.Core.Tests/ProtocolTests.cs b/tests/VRio.Core.Tests/ProtocolTests.cs new file mode 100644 index 0000000..14ceda6 --- /dev/null +++ b/tests/VRio.Core.Tests/ProtocolTests.cs @@ -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)); + } +} diff --git a/tests/VRio.Core.Tests/VRio.Core.Tests.csproj b/tests/VRio.Core.Tests/VRio.Core.Tests.csproj new file mode 100644 index 0000000..fa1aabd --- /dev/null +++ b/tests/VRio.Core.Tests/VRio.Core.Tests.csproj @@ -0,0 +1,25 @@ + + + + net48 + enable + enable + latest + false + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + diff --git a/tests/VRio.Core.Tests/VRioDeviceTests.cs b/tests/VRio.Core.Tests/VRioDeviceTests.cs new file mode 100644 index 0000000..28ac905 --- /dev/null +++ b/tests/VRio.Core.Tests/VRioDeviceTests.cs @@ -0,0 +1,258 @@ +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.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)); + } +}