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:
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user