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