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