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
+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();
}
}