vRIO: virtual RIO cockpit device emulator

Speaks the device side of the RIO serial protocol (per riojoy's
PROTOCOL.md) on a COM port, behind an interactive replica of the
profile editor's cockpit panel: click cells to press buttons/keys,
drag the encoder gauges to move the five analog axes, and watch
host-commanded lamp states (incl. flash modes) light the cells.

Device behavior grounded in the real v4.2 firmware dump: version 4.2,
4-retry NAK budget ending in RESTART, and an optional emulation of the
analog reply-wedge latch leak for exercising host recovery watchdogs.

Verified: 33 unit tests, plus an interop harness driving RIOJoy's
actual RioSerialLink against VRioDevice over an in-memory transport
(version/check/analog/lamp/button/keypad/reset all round-trip).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Cyd
2026-07-06 07:39:38 -05:00
co-authored by Claude Fable 5
commit 7995c0b1c1
22 changed files with 2599 additions and 0 deletions
+11
View File
@@ -0,0 +1,11 @@
# Build output
[Bb]in/
[Oo]bj/
# IDE state
.vs/
*.user
*.suo
# Test results
TestResults/
+77
View File
@@ -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.
+43
View File
@@ -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
+290
View File
@@ -0,0 +1,290 @@
using System.Diagnostics;
using System.IO.Ports;
using VRio.Core.Device;
namespace VRio.App;
/// <summary>
/// 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.
/// </summary>
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();
}
}
+470
View File
@@ -0,0 +1,470 @@
using VRio.Core.Device;
using VRio.Core.Panel;
using VRio.Core.Protocol;
namespace VRio.App;
/// <summary>
/// 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 <em>physical controls</em>: 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.
///
/// <para>Left-click = momentary press (release on mouse-up). Right-click =
/// latch the button down / release it (handy for testing holds).</para>
/// </summary>
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<PanelButton> 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<int> _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();
}
/// <summary>Current lamp-state byte for an address (from the device).</summary>
public Func<int, byte>? LampProvider { get; set; }
/// <summary>Current raw value of an axis (from the device).</summary>
public Func<RioAxis, short>? AxisProvider { get; set; }
/// <summary>The user pressed a panel control.</summary>
public event Action<int>? AddressPressed;
/// <summary>The user released a panel control.</summary>
public event Action<int>? AddressReleased;
/// <summary>The user dragged an axis to a new raw value.</summary>
public event Action<RioAxis, short>? AxisMoved;
/// <summary>When true (default), the X/Y stick snaps back to center on release.</summary>
public bool StickSpringsBack { get; set; } = true;
/// <summary>Green status text drawn in the strip area (the mockup's status panel).</summary>
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));
}
}
+12
View File
@@ -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());
}
}
+25
View File
@@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net48</TargetFramework>
<Nullable>enable</Nullable>
<UseWindowsForms>true</UseWindowsForms>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>latest</LangVersion>
<ApplicationManifest>app.manifest</ApplicationManifest>
<AssemblyTitle>vRIO — Virtual RIO device</AssemblyTitle>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\VRio.Core\VRio.Core.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="PolySharp" Version="1.14.1">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
</Project>
+20
View File
@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<assemblyIdentity version="1.0.0.0" name="VRio.App" type="win32" />
<!-- Run as the invoking user: vRIO only opens a COM port and draws a window. -->
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
<security>
<requestedPrivileges>
<requestedExecutionLevel level="asInvoker" uiAccess="false" />
</requestedPrivileges>
</security>
</trustInfo>
<!-- Declare Windows 10/11 compatibility for correct DPI / API behavior. -->
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
<application>
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" /> <!-- Windows 10/11 -->
</application>
</compatibility>
</assembly>
+80
View File
@@ -0,0 +1,80 @@
namespace VRio.Core.Device;
/// <summary>
/// The RIO's digital address space, as RIOJoy's <c>iRIO</c> map sees it:
/// 72 lamp-capable buttons at 0x000x47 (reported by index in
/// ButtonPressed/Released), keypad 0 at 0x500x5F and keypad 1 at 0x600x6F
/// (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.
/// </summary>
public static class RioAddressSpace
{
/// <summary>Number of digital button inputs / lamps (addresses 0x000x47).</summary>
public const int LampCount = 72;
/// <summary>Address offset of keypad 0 (pad byte 0 on the wire).</summary>
public const int Keypad0Base = 0x50;
/// <summary>Address offset of keypad 1 (pad byte 1 on the wire).</summary>
public const int Keypad1Base = 0x60;
/// <summary>Highest valid address (keypad 1, key 0x0F).</summary>
public const int MaxAddress = Keypad1Base + 0x0F; // 0x6F
/// <summary>True for a lamp-capable button address (0x000x47).</summary>
public static bool IsButton(int address) => address is >= 0 and < LampCount;
/// <summary>True for a keypad address (0x500x5F or 0x600x6F).</summary>
public static bool IsKeypad(int address) =>
address is >= Keypad0Base and <= Keypad0Base + 0x0F
or >= Keypad1Base and <= MaxAddress;
/// <summary>
/// Convert a keypad address to its wire (pad, index) pair — the inverse of
/// the PC's +0x50/+0x60 offsetting.
/// </summary>
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));
}
/// <summary>
/// The I/O boards a healthy cockpit reports, as (board number, name) —
/// numbering from the legacy firmware's board table (riovjoy2.cpp
/// <c>GetBoardName</c>). vRIO answers CheckRequest with one BoardOk
/// CheckReply per entry.
/// </summary>
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"),
};
}
/// <summary>
/// The five analog axes, indexed in AnalogReply payload order
/// (riojoy/docs/PROTOCOL.md §4).
/// </summary>
public enum RioAxis
{
Throttle = 0,
LeftPedal = 1,
RightPedal = 2,
JoystickY = 3,
JoystickX = 4,
}
+396
View File
@@ -0,0 +1,396 @@
using VRio.Core.Protocol;
namespace VRio.Core.Device;
/// <summary>
/// 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
/// <see cref="OnReceived"/>; everything it wants to transmit is raised through
/// <see cref="Transmit"/>. The UI pokes it with <see cref="PressAddress"/>,
/// <see cref="SetAxis"/>, etc., and listens for <see cref="LampChanged"/> to
/// light its on-screen buttons.
///
/// <para>Wire behavior (mirroring what RIOJoy expects from the real board):</para>
/// <list type="bullet">
/// <item>ACKs every well-formed inbound packet, NAKs one with a bad checksum.</item>
/// <item>CheckRequest → a BoardOk CheckReply per known board.</item>
/// <item>VersionRequest → VersionReply with the configured firmware version
/// (default 4.2, matching the real board's dumped EPROM).</item>
/// <item>AnalogRequest → AnalogReply with the current five axis values.</item>
/// <item>ResetRequest → re-zeroes the targeted axis (or all) like a
/// recalibration, and reports it via <see cref="ResetReceived"/>.</item>
/// <item>LampRequest → stores the lamp state and raises <see cref="LampChanged"/>.</item>
/// <item>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).</item>
/// </list>
///
/// <para>With <see cref="EmulateReplyWedge"/> 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.</para>
///
/// All members are thread-safe; events may fire on the caller's thread.
/// </summary>
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;
/// <summary>Firmware version reported by VersionReply (real boards run 4.2).</summary>
public byte VersionMajor { get; set; } = 4;
/// <summary>Firmware version reported by VersionReply (real boards run 4.2).</summary>
public byte VersionMinor { get; set; } = 2;
/// <summary>
/// When true, retry exhaustion leaves the analog reply path wedged (the
/// v4.2 latch-leak bug) until a host ResetRequest clears it.
/// </summary>
public bool EmulateReplyWedge { get; set; }
/// <summary>True while the analog reply path is wedged (see <see cref="EmulateReplyWedge"/>).</summary>
public bool AnalogWedged
{
get { lock (_gate) return _analogMuted; }
}
/// <summary>Bytes the device wants on the wire (already framed).</summary>
public event Action<byte[]>? Transmit;
/// <summary>A lamp changed: (address 0x000x47, raw lamp-state byte).</summary>
public event Action<int, byte>? LampChanged;
/// <summary>The PC asked for an axis reset/recalibration.</summary>
public event Action<RioResetTarget>? ResetReceived;
/// <summary>Axis values changed (reset or local set) — refresh gauges.</summary>
public event Action? AxesChanged;
/// <summary>Human-readable protocol log lines (analog polls excluded — see <see cref="AnalogRequests"/>).</summary>
public event Action<string>? Logged;
/// <summary>Count of AnalogRequests served (RIOJoy polls ~18×/s; logging each would drown the log).</summary>
public long AnalogRequests { get; private set; }
/// <summary>Count of AnalogRequests silently dropped while wedged.</summary>
public long AnalogDropped { get; private set; }
/// <summary>Count of packets received with a bad checksum (NAK'd).</summary>
public long BadChecksums { get; private set; }
// ---- Local (UI-facing) state ------------------------------------------
/// <summary>Current raw value of an axis.</summary>
public short GetAxis(RioAxis axis)
{
lock (_gate) return _axes[(int)axis];
}
/// <summary>
/// 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.
/// </summary>
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();
}
/// <summary>Current lamp state byte for a button address (0x000x47).</summary>
public byte GetLamp(int address)
{
if (!RioAddressSpace.IsButton(address)) return 0;
lock (_gate) return _lamps[address];
}
/// <summary>
/// Locally darken every lamp (a fresh board powers up dark; the host
/// re-lights what it wants). Callers should refresh their display.
/// </summary>
public void ClearLamps()
{
lock (_gate) Array.Clear(_lamps, 0, _lamps.Length);
Logged?.Invoke("Local: all lamps cleared");
}
// ---- Local inputs → wire events ---------------------------------------
/// <summary>Press the input at a RIO address (button or keypad key).</summary>
public void PressAddress(int address) => SendInputEvent(address, pressed: true);
/// <summary>Release the input at a RIO address (button or keypad key).</summary>
public void ReleaseAddress(int address) => SendInputEvent(address, pressed: false);
/// <summary>Announce a test-mode change (0 = exit test mode).</summary>
public void SendTestMode(byte mode)
{
SendEvent(PacketBuilder.TestModeChange((byte)(mode & 0x7F)));
Logged?.Invoke($"TX TestModeChange mode={mode}");
}
/// <summary>
/// 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.
/// </summary>
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 -----------------------------------------------------
/// <summary>Feed bytes received from the PC.</summary>
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;
}
}
}
+169
View File
@@ -0,0 +1,169 @@
using System.IO.Ports;
using VRio.Core.Protocol;
namespace VRio.Core.Device;
/// <summary>
/// Pumps a <see cref="VRioDevice"/> 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.
///
/// <para>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 <see cref="HostHandshake"/> so the UI can show that a host
/// connected.</para>
/// </summary>
public sealed class VRioSerialService : IDisposable
{
/// <summary>RIO link bit rate (must match RIOJoy's transport).</summary>
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;
}
/// <summary>True while a COM port is open.</summary>
public bool IsOpen => _port?.IsOpen == true;
/// <summary>The open port's name, or null.</summary>
public string? PortName => _port?.PortName;
/// <summary>Raised after the port opens (true) or closes (false).</summary>
public event Action<bool>? ConnectionChanged;
/// <summary>
/// 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.
/// </summary>
public event Action<bool>? HostHandshake;
/// <summary>Port-level log lines (open/close/errors).</summary>
public event Action<string>? Logged;
/// <summary>Open <paramref name="portName"/> and start serving the device.</summary>
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);
}
/// <summary>Close the port (idempotent).</summary>
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();
}
}
+140
View File
@@ -0,0 +1,140 @@
using VRio.Core.Device;
namespace VRio.Core.Panel;
/// <summary>Shape of a panel group.</summary>
public enum PanelGroupKind
{
/// <summary>A 4×2 MFD button cluster (lamp buttons).</summary>
Mfd,
/// <summary>A vertical 1×8 board column (lamp buttons).</summary>
Column,
/// <summary>A 4×4 keypad (no lamps).</summary>
Keypad,
}
/// <summary>
/// One functional group on the cockpit control panel: a titled block of address
/// buttons laid out row-major in <see cref="Rows"/> × <see cref="Cols"/>.
/// <see cref="OriginCol"/>/<see cref="OriginRow"/> place the block on the panel
/// in button-cell units.
/// </summary>
public sealed record PanelGroup(
string Title,
PanelGroupKind Kind,
int Cols,
int Rows,
bool LampCapable,
int OriginCol,
int OriginRow,
IReadOnlyList<int?> Addresses);
/// <summary>One address button placed on the panel (absolute cell coordinates).</summary>
public sealed record PanelButton(int Address, PanelGroup Group, int Col, int Row, bool LampCapable);
/// <summary>
/// The cockpit control panel's logical layout — the same functional grouping
/// RIOJoy's profile editor draws (its <c>CockpitPanel</c>, 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
/// 0x000x47 / 0x500x6F 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.
/// </summary>
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;
}
/// <summary>All panel groups, positioned for rendering.</summary>
public static IReadOnlyList<PanelGroup> 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)),
};
/// <summary>Flatten the groups to positioned <see cref="PanelButton"/>s (absolute cells).</summary>
public static IReadOnlyList<PanelButton> Buttons()
{
var buttons = new List<PanelButton>();
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<int, string> 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",
};
/// <summary>
/// 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).
/// </summary>
public static string? PhysicalLabel(int address)
{
if (RioAddressSpace.IsKeypad(address))
return (address & 0x0F).ToString("X1");
return Named.TryGetValue(address, out string? name) ? name : null;
}
}
+103
View File
@@ -0,0 +1,103 @@
namespace VRio.Core.Protocol;
/// <summary>
/// Builds outbound packets: <c>[command][payload…][checksum]</c>. Same framing
/// as RIOJoy's builder, but the convenience methods here cover the RIO → PC
/// messages, because vRIO <em>is</em> the device.
/// </summary>
public static class PacketBuilder
{
/// <summary>
/// Build a wire-ready packet for <paramref name="command"/>. The payload
/// length must match the command's entry in the length table.
/// </summary>
public static byte[] Build(RioCommand command, ReadOnlySpan<byte> 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;
}
/// <summary>Build a zero-payload command packet.</summary>
public static byte[] Build(RioCommand command) => Build(command, ReadOnlySpan<byte>.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 });
/// <summary>
/// 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 <see cref="AnalogCodec"/>).
/// </summary>
public static byte[] AnalogReply(short throttle, short leftPedal, short rightPedal, short joystickY, short joystickX)
{
Span<byte> 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 });
}
/// <summary>
/// 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).
/// </summary>
public static class AnalogCodec
{
/// <summary>Smallest representable axis value (14-bit signed).</summary>
public const short Min = -8192;
/// <summary>Largest representable axis value (14-bit signed).</summary>
public const short Max = 8191;
/// <summary>Split an axis value into its (low, high) 7-bit wire bytes.</summary>
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);
}
/// <summary>Recombine (low, high) wire bytes into the signed axis value.</summary>
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;
}
}
+100
View File
@@ -0,0 +1,100 @@
namespace VRio.Core.Protocol;
/// <summary>
/// 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):
/// <list type="bullet">
/// <item>Outside a packet, a byte in the command range starts a packet of
/// <c>1 + payloadLen + 1</c> bytes (command + payload + checksum).</item>
/// <item>Outside a packet, any other byte is a control byte (ACK/NAK/…).</item>
/// <item>Mid-packet, a byte with the high bit set aborts the packet and
/// resynchronizes; that byte is discarded.</item>
/// <item>The final byte completes the packet with a real checksum compare.</item>
/// </list>
/// Not thread-safe; drive it from one reader.
/// </summary>
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;
/// <summary>True while a packet is partially received.</summary>
public bool InPacket => _remaining != 0;
/// <summary>Discard any in-progress packet and reset to the idle state.</summary>
public void Reset()
{
_remaining = 0;
_count = 0;
}
/// <summary>
/// Feed one received byte. Returns <see langword="true"/> and sets
/// <paramref name="ev"/> when this byte produced an event; otherwise the byte
/// was consumed into an in-progress packet.
/// </summary>
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);
}
}
+22
View File
@@ -0,0 +1,22 @@
namespace VRio.Core.Protocol;
/// <summary>
/// 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).
/// </summary>
public static class RioChecksum
{
/// <summary>
/// Compute the 7-bit checksum over <paramref name="commandAndPayload"/>
/// (the command byte followed by its payload bytes — not the checksum byte).
/// </summary>
public static byte Compute(ReadOnlySpan<byte> commandAndPayload)
{
byte sum = 0;
foreach (byte b in commandAndPayload)
sum += (byte)(b & 0x7F);
return (byte)(sum & 0x7F);
}
}
+119
View File
@@ -0,0 +1,119 @@
namespace VRio.Core.Protocol;
/// <summary>
/// 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
/// <em>device</em> side of this table: it receives the PC→RIO commands and sends
/// the RIO→PC ones.
/// </summary>
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,
}
/// <summary>Single-byte control characters that live outside packet framing.</summary>
public enum RioControl : byte
{
/// <summary>Packet accepted.</summary>
Ack = 0xFC,
/// <summary>Packet rejected; resend.</summary>
Nak = 0xFD,
/// <summary>Restart; also used as an in-payload "invalid" sentinel.</summary>
Restart = 0xFE,
/// <summary>Idle.</summary>
Idle = 0xFF,
}
/// <summary>Reset targets for <see cref="RioCommand.ResetRequest"/>.</summary>
public enum RioResetTarget : byte
{
All = 0,
Throttle = 1,
LeftPedal = 2,
RightPedal = 3,
VerticalJoystick = 4, // Y
HorizontalJoystick = 5, // X
}
/// <summary>The <c>statusType</c> field of <see cref="RioCommand.CheckReply"/>.</summary>
public enum RioStatusType : byte
{
BoardOk = 0,
BoardMissing = 1,
BoardBad = 2,
LampBad = 3,
RestartCount = 4,
AbandonCount = 5,
FullBufferCount = 6,
}
/// <summary>
/// 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.
/// </summary>
public static class RioCommandTable
{
/// <summary>Lowest command byte value (<see cref="RioCommand.CheckRequest"/>).</summary>
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
};
/// <summary>Number of defined commands.</summary>
public static int Count => PayloadLengths.Length;
/// <summary>Highest valid command byte value (inclusive).</summary>
public static byte Max => (byte)(Base + Count - 1);
/// <summary>True if <paramref name="commandByte"/> is a known command.</summary>
public static bool IsCommand(byte commandByte) =>
commandByte >= Base && commandByte <= Max;
/// <summary>Payload length for a command byte (guard with <see cref="IsCommand"/>).</summary>
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];
}
/// <summary>Payload length for a <see cref="RioCommand"/>.</summary>
public static int PayloadLength(RioCommand command) => PayloadLength((byte)command);
}
+60
View File
@@ -0,0 +1,60 @@
namespace VRio.Core.Protocol;
/// <summary>Flash mode for a lighted button (lamp-state bits 01).</summary>
public enum LampFlash : byte
{
Solid = 0,
FlashSlow = 1,
FlashMed = 2,
FlashFast = 3,
}
/// <summary>Brightness level decoded from a lamp-state byte.</summary>
public enum LampBrightness
{
Off,
Dim,
Bright,
}
/// <summary>
/// The <c>state</c> byte of a <see cref="RioCommand.LampRequest"/>: flash bits
/// 01 plus two brightness fields (bits 23 and 45). vRIO decodes it to shade
/// its on-screen lamps the way the physical board would light them.
/// </summary>
public static class RioLampState
{
/// <summary>Lamp off (SolidOff, 0x00).</summary>
public const byte SolidOff = 0x00;
/// <summary>Dim solid (SolidDim, 0x14) — RIOJoy's idle state for mapped lamps.</summary>
public const byte SolidDim = 0x14;
/// <summary>Bright solid (SolidBright, 0x3C) — RIOJoy's pressed state.</summary>
public const byte SolidBright = 0x3C;
/// <summary>Flash mode from a lamp-state byte.</summary>
public static LampFlash Flash(byte state) => (LampFlash)(state & 0x03);
/// <summary>
/// Effective brightness from a lamp-state byte: the brighter of the two
/// brightness fields (field 1 = bits 23: 0x04 dim / 0x0C bright; field 2 =
/// bits 45: 0x10 dim / 0x30 bright).
/// </summary>
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;
}
/// <summary>Human-readable description, e.g. <c>"Bright"</c> or <c>"Dim flash-fast"</c>.</summary>
public static string Describe(byte state)
{
LampBrightness b = Brightness(state);
LampFlash f = Flash(state);
return f == LampFlash.Solid ? b.ToString() : $"{b} {f}";
}
}
+58
View File
@@ -0,0 +1,58 @@
namespace VRio.Core.Protocol;
/// <summary>One framed RIO message: the command plus its (checksum-stripped) payload.</summary>
public readonly struct RioPacket
{
public RioCommand Command { get; }
public byte[] Payload { get; }
public RioPacket(RioCommand command, byte[] payload)
{
Command = command;
Payload = payload ?? Array.Empty<byte>();
}
public override string ToString() =>
Payload.Length == 0
? Command.ToString()
: $"{Command} [{string.Join(" ", Payload.Select(b => b.ToString("X2")))}]";
}
/// <summary>What a fed byte produced (see <see cref="PacketParser"/>).</summary>
public enum RioRxKind
{
/// <summary>A complete framed packet.</summary>
Packet,
/// <summary>A single byte outside framing: ACK/NAK/RESTART/IDLE or garbage.</summary>
ControlByte,
/// <summary>A high-bit byte arrived mid-packet; the packet was abandoned.</summary>
FramingError,
}
/// <summary>A receive event emitted by <see cref="PacketParser"/>.</summary>
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);
}
+22
View File
@@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<!-- net48 so it runs in-box on the cabinet PCs, same as RIOJoy. Uses
System.IO.Ports from the framework BCL; Span/records/init come from
System.Memory + PolySharp. -->
<TargetFramework>net48</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="System.Memory" Version="4.5.5" />
<PackageReference Include="Microsoft.Bcl.HashCode" Version="1.1.1" />
<PackageReference Include="PolySharp" Version="1.14.1">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
</Project>
+99
View File
@@ -0,0 +1,99 @@
using VRio.Core.Protocol;
using Xunit;
namespace VRio.Core.Tests;
public class ProtocolTests
{
[Fact]
public void Checksum_is_low7_of_sum_of_low7()
{
// 0x88 & 0x7F = 0x08, + 0x05 = 0x0D.
Assert.Equal(0x0D, RioChecksum.Compute(new byte[] { 0x88, 0x05 }));
}
[Theory]
[InlineData(0)]
[InlineData(1)]
[InlineData(-1)]
[InlineData(8191)]
[InlineData(-8192)]
[InlineData(1234)]
[InlineData(-4321)]
public void AnalogCodec_round_trips_and_stays_7bit(short value)
{
AnalogCodec.Split(value, out byte low, out byte high);
Assert.Equal(0, low & 0x80); // payload bytes must keep the high bit clear
Assert.Equal(0, high & 0x80);
Assert.Equal(value, AnalogCodec.Combine(low, high));
}
[Fact]
public void Builder_output_parses_back_with_valid_checksum()
{
byte[] wire = PacketBuilder.AnalogReply(100, -200, 300, -8192, 8191);
var parser = new PacketParser();
RioRxEvent? got = null;
foreach (byte b in wire)
if (parser.Feed(b, out RioRxEvent ev))
got = ev;
Assert.NotNull(got);
Assert.Equal(RioRxKind.Packet, got!.Value.Kind);
Assert.True(got.Value.ChecksumValid);
Assert.Equal(RioCommand.AnalogReply, got.Value.Packet.Command);
byte[] p = got.Value.Packet.Payload;
Assert.Equal(100, AnalogCodec.Combine(p[0], p[1]));
Assert.Equal(-200, AnalogCodec.Combine(p[2], p[3]));
Assert.Equal(300, AnalogCodec.Combine(p[4], p[5]));
Assert.Equal(-8192, AnalogCodec.Combine(p[6], p[7]));
Assert.Equal(8191, AnalogCodec.Combine(p[8], p[9]));
}
[Fact]
public void Parser_reports_control_bytes_outside_framing()
{
var parser = new PacketParser();
Assert.True(parser.Feed((byte)RioControl.Ack, out RioRxEvent ev));
Assert.Equal(RioRxKind.ControlByte, ev.Kind);
Assert.Equal((byte)RioControl.Ack, ev.Byte);
}
[Fact]
public void Parser_resyncs_on_high_bit_byte_mid_packet()
{
var parser = new PacketParser();
// Start a LampRequest (2-byte payload), then interrupt with a new command byte.
Assert.False(parser.Feed(0x84, out _));
Assert.True(parser.Feed(0xFF, out RioRxEvent err)); // IDLE mid-packet = framing error
Assert.Equal(RioRxKind.FramingError, err.Kind);
Assert.False(parser.InPacket);
// A clean packet right after must still parse.
byte[] wire = PacketBuilder.VersionReply(1, 2);
RioRxEvent? got = null;
foreach (byte b in wire)
if (parser.Feed(b, out RioRxEvent ev))
got = ev;
Assert.Equal(RioRxKind.Packet, got!.Value.Kind);
Assert.True(got.Value.ChecksumValid);
}
[Theory]
[InlineData(0x00, LampBrightness.Off, LampFlash.Solid)]
[InlineData(0x14, LampBrightness.Dim, LampFlash.Solid)]
[InlineData(0x3C, LampBrightness.Bright, LampFlash.Solid)]
[InlineData(0x3D, LampBrightness.Bright, LampFlash.FlashSlow)]
[InlineData(0x16, LampBrightness.Dim, LampFlash.FlashMed)]
[InlineData(0x04, LampBrightness.Dim, LampFlash.Solid)] // field 1 only
[InlineData(0x30, LampBrightness.Bright, LampFlash.Solid)] // field 2 only
public void LampState_decodes_brightness_and_flash(byte state, LampBrightness brightness, LampFlash flash)
{
Assert.Equal(brightness, RioLampState.Brightness(state));
Assert.Equal(flash, RioLampState.Flash(state));
}
}
@@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net48</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
<PackageReference Include="PolySharp" Version="1.14.1">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\VRio.Core\VRio.Core.csproj" />
</ItemGroup>
</Project>
+258
View File
@@ -0,0 +1,258 @@
using VRio.Core.Device;
using VRio.Core.Protocol;
using Xunit;
namespace VRio.Core.Tests;
public class VRioDeviceTests
{
/// <summary>Captures the device's transmissions and re-frames them for asserts.</summary>
private sealed class Wire
{
private readonly PacketParser _parser = new();
public readonly List<RioPacket> Packets = new();
public readonly List<byte> Controls = new();
public Wire(VRioDevice device) => device.Transmit += OnBytes;
private void OnBytes(byte[] data)
{
foreach (byte b in data)
{
if (!_parser.Feed(b, out RioRxEvent ev))
continue;
if (ev.Kind == RioRxKind.Packet)
{
Assert.True(ev.ChecksumValid, $"device sent a bad checksum on {ev.Packet}");
Packets.Add(ev.Packet);
}
else if (ev.Kind == RioRxKind.ControlByte)
{
Controls.Add(ev.Byte);
}
}
}
public void Clear()
{
Packets.Clear();
Controls.Clear();
}
}
private static void Send(VRioDevice device, byte[] bytes) => device.OnReceived(bytes, bytes.Length);
[Fact]
public void AnalogRequest_returns_current_axes_and_acks()
{
var device = new VRioDevice();
var wire = new Wire(device);
device.SetAxis(RioAxis.Throttle, 1000);
device.SetAxis(RioAxis.JoystickX, -5000);
Send(device, PacketBuilder.Build(RioCommand.AnalogRequest));
Assert.Equal((byte)RioControl.Ack, Assert.Single(wire.Controls));
RioPacket reply = Assert.Single(wire.Packets);
Assert.Equal(RioCommand.AnalogReply, reply.Command);
byte[] p = reply.Payload;
Assert.Equal(1000, AnalogCodec.Combine(p[0], p[1])); // throttle
Assert.Equal(0, AnalogCodec.Combine(p[2], p[3])); // left pedal
Assert.Equal(0, AnalogCodec.Combine(p[4], p[5])); // right pedal
Assert.Equal(0, AnalogCodec.Combine(p[6], p[7])); // joystick Y
Assert.Equal(-5000, AnalogCodec.Combine(p[8], p[9])); // joystick X
Assert.Equal(1, device.AnalogRequests);
}
[Fact]
public void VersionRequest_reports_configured_firmware()
{
var device = new VRioDevice { VersionMajor = 2, VersionMinor = 7 };
var wire = new Wire(device);
Send(device, PacketBuilder.Build(RioCommand.VersionRequest));
RioPacket reply = Assert.Single(wire.Packets);
Assert.Equal(RioCommand.VersionReply, reply.Command);
Assert.Equal(new byte[] { 2, 7 }, reply.Payload);
}
[Fact]
public void CheckRequest_reports_every_board_ok()
{
var device = new VRioDevice();
var wire = new Wire(device);
Send(device, PacketBuilder.Build(RioCommand.CheckRequest));
Assert.Equal(RioAddressSpace.Boards.Count, wire.Packets.Count);
Assert.All(wire.Packets, p =>
{
Assert.Equal(RioCommand.CheckReply, p.Command);
Assert.Equal((byte)RioStatusType.BoardOk, p.Payload[0]);
});
Assert.Equal(
RioAddressSpace.Boards.Select(b => b.Number),
wire.Packets.Select(p => p.Payload[1]));
}
[Fact]
public void LampRequest_updates_lamp_state_and_raises_event()
{
var device = new VRioDevice();
_ = new Wire(device);
(int Address, byte State)? change = null;
device.LampChanged += (a, s) => change = (a, s);
Send(device, PacketBuilder.Build(RioCommand.LampRequest, new byte[] { 0x05, RioLampState.SolidBright }));
Assert.Equal((0x05, RioLampState.SolidBright), change);
Assert.Equal(RioLampState.SolidBright, device.GetLamp(0x05));
}
[Fact]
public void ResetRequest_zeroes_the_targeted_axis()
{
var device = new VRioDevice();
_ = new Wire(device);
device.SetAxis(RioAxis.Throttle, 4000);
device.SetAxis(RioAxis.JoystickY, -3000);
Send(device, PacketBuilder.Build(RioCommand.ResetRequest, new[] { (byte)RioResetTarget.Throttle }));
Assert.Equal(0, device.GetAxis(RioAxis.Throttle));
Assert.Equal(-3000, device.GetAxis(RioAxis.JoystickY)); // untouched
Send(device, PacketBuilder.Build(RioCommand.ResetRequest, new[] { (byte)RioResetTarget.All }));
Assert.Equal(0, device.GetAxis(RioAxis.JoystickY));
}
[Fact]
public void Bad_checksum_gets_nak_and_no_reply()
{
var device = new VRioDevice();
var wire = new Wire(device);
byte[] bad = PacketBuilder.Build(RioCommand.AnalogRequest);
bad[bad.Length - 1] ^= 0x01; // corrupt the checksum
Send(device, bad);
Assert.Equal((byte)RioControl.Nak, Assert.Single(wire.Controls));
Assert.Empty(wire.Packets);
Assert.Equal(1, device.BadChecksums);
}
[Fact]
public void Button_press_and_release_use_button_packets()
{
var device = new VRioDevice();
var wire = new Wire(device);
device.PressAddress(0x3D); // Panic
device.ReleaseAddress(0x3D);
Assert.Equal(2, wire.Packets.Count);
Assert.Equal(RioCommand.ButtonPressed, wire.Packets[0].Command);
Assert.Equal(new byte[] { 0x3D }, wire.Packets[0].Payload);
Assert.Equal(RioCommand.ButtonReleased, wire.Packets[1].Command);
}
[Theory]
[InlineData(0x51, 0, 0x1)] // internal keypad "1"
[InlineData(0x5F, 0, 0xF)] // internal keypad "F"
[InlineData(0x60, 1, 0x0)] // external keypad "0"
[InlineData(0x6C, 1, 0xC)] // external keypad "C"
public void Keypad_press_uses_key_packets_with_pad_and_index(int address, byte pad, byte index)
{
var device = new VRioDevice();
var wire = new Wire(device);
device.PressAddress(address);
RioPacket packet = Assert.Single(wire.Packets);
Assert.Equal(RioCommand.KeyPressed, packet.Command);
Assert.Equal(new[] { pad, index }, packet.Payload);
}
[Fact]
public void Nak_resends_four_times_then_gives_up_with_restart()
{
var device = new VRioDevice();
var wire = new Wire(device);
device.PressAddress(0x00);
Assert.Single(wire.Packets);
// The v4.2 firmware retry budget: 4 re-sends, then RESTART and give up.
for (int i = 0; i < 4; i++)
Send(device, new[] { (byte)RioControl.Nak });
Assert.Equal(5, wire.Packets.Count);
Assert.All(wire.Packets, p => Assert.Equal(RioCommand.ButtonPressed, p.Command));
Assert.Empty(wire.Controls);
Send(device, new[] { (byte)RioControl.Nak }); // budget exhausted
Assert.Equal(5, wire.Packets.Count);
Assert.Equal((byte)RioControl.Restart, Assert.Single(wire.Controls));
// An ACK clears the pending event; a following NAK re-sends nothing.
wire.Clear();
device.PressAddress(0x01);
Send(device, new[] { (byte)RioControl.Ack });
Send(device, new[] { (byte)RioControl.Nak });
Assert.Single(wire.Packets);
Assert.Empty(wire.Controls);
}
[Fact]
public void Wedge_emulation_mutes_analog_until_host_reset()
{
var device = new VRioDevice { EmulateReplyWedge = true };
var wire = new Wire(device);
// Exhaust the retry budget to trip the latch leak.
device.PressAddress(0x00);
for (int i = 0; i < 5; i++)
Send(device, new[] { (byte)RioControl.Nak });
Assert.True(device.AnalogWedged);
wire.Clear();
// Wedged: the request is ACK'd (RX path alive) but no reply comes back.
Send(device, PacketBuilder.Build(RioCommand.AnalogRequest));
Assert.Equal((byte)RioControl.Ack, Assert.Single(wire.Controls));
Assert.Empty(wire.Packets);
Assert.Equal(1, device.AnalogDropped);
// The host's recovery reset clears the latch; analog replies resume.
Send(device, PacketBuilder.Build(RioCommand.ResetRequest, new[] { (byte)RioResetTarget.All }));
Assert.False(device.AnalogWedged);
wire.Clear();
Send(device, PacketBuilder.Build(RioCommand.AnalogRequest));
Assert.Equal(RioCommand.AnalogReply, Assert.Single(wire.Packets).Command);
}
[Fact]
public void WedgeAnalogNow_wedges_without_the_emulation_flag()
{
var device = new VRioDevice();
var wire = new Wire(device);
device.WedgeAnalogNow();
Assert.True(device.AnalogWedged);
Send(device, PacketBuilder.Build(RioCommand.AnalogRequest));
Assert.Empty(wire.Packets);
Send(device, PacketBuilder.Build(RioCommand.ResetRequest, new[] { (byte)RioResetTarget.Throttle }));
Assert.False(device.AnalogWedged);
}
[Fact]
public void Axis_values_clamp_to_wire_range()
{
var device = new VRioDevice();
device.SetAxis(RioAxis.Throttle, 60000);
Assert.Equal(AnalogCodec.Max, device.GetAxis(RioAxis.Throttle));
device.SetAxis(RioAxis.Throttle, -60000);
Assert.Equal(AnalogCodec.Min, device.GetAxis(RioAxis.Throttle));
}
}