Pipe endpoints join the connection pickers; no more COM11/COM12 auto-bind

Each device row now serves one endpoint at a time, picked from a list of
pipe:vrio / pipe:vplasma (first, default) plus the machine's COM ports —
nothing opens automatically at startup. The pipe services gained an
IsListening property and are started/stopped by the row's Open/Close
button instead of running for the whole app lifetime, which also retires
the dual-transport warning. Status line shows the active endpoint;
OFFLINE when none. Standalone vPLASMA is unchanged (hardwired COM12 +
permanent pipe). Also fixes the xUnit1031 warnings in the plasma pipe
tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Cyd
2026-07-12 23:17:34 -05:00
co-authored by Claude Fable 5
parent e507f1740c
commit 697cf3129b
5 changed files with 108 additions and 104 deletions
+12 -11
View File
@@ -106,10 +106,11 @@ a null-modem cable work the same way.
## Named pipes for DOSBox-X (no com0com) ## Named pipes for DOSBox-X (no com0com)
When the game runs in the patched DOSBox-X (the fork's `namedpipe` serial When the game runs in the patched DOSBox-X (the fork's `namedpipe` serial
backend), the virtual ports aren't needed at all: both devices also serve a backend), the virtual ports aren't needed at all: each device's connection
named pipe for the whole app lifetime`\\.\pipe\vrio` and picker lists its named-pipe endpoint`pipe:vrio`, `pipe:vplasma`
`\\.\pipe\vplasma` — and DOSBox-X connects as the client, retrying in the alongside the machine's COM ports. Select it and **Open** to serve
background until the pipe exists: `\\.\pipe\vrio` / `\\.\pipe\vplasma`; DOSBox-X connects as the client,
retrying in the background until the pipe exists:
```ini ```ini
[serial] [serial]
@@ -124,11 +125,11 @@ reads as all-lines-low (cable unplugged). The contract lives in
`src/VRio.Core/Device/PipeFraming.cs` on this side and `serialnamedpipe.h` `src/VRio.Core/Device/PipeFraming.cs` on this side and `serialnamedpipe.h`
on the fork's. TX is paced exactly like the COM path — with the 9600-baud on the fork's. TX is paced exactly like the COM path — with the 9600-baud
wire gone, the pacer is the only thing between the host and an impossible wire gone, the pacer is the only thing between the host and an impossible
burst. The COM rows keep working unchanged (RIOJoy, real pods); if a pipe burst. Each row serves one endpoint at a time — the RIO is a single-host
client connects while a COM host is active the wire log warns, since the device — and nothing opens automatically at startup; the COM path is
RIO is a single-host device. vRIO's built-in glass and the standalone unchanged for RIOJoy and real pods. vRIO's built-in glass and the
vPLASMA share the `vplasma` pipe name — whoever starts second retries until standalone vPLASMA share the `vplasma` pipe name — whoever starts second
the name frees up. retries until the name frees up.
## vPLASMA — the companion plasma display ## vPLASMA — the companion plasma display
@@ -136,8 +137,8 @@ The cockpit's second serial device is the **plasma display**: a 128×32
dot-matrix panel on COM2 (9600 8N1, no flow control) that the game draws dot-matrix panel on COM2 (9600 8N1, no flow control) that the game draws
mission text and status graphics on. The software replica comes in two mission text and status graphics on. The software replica comes in two
forms. **Built into vRIO**: the panel's encoder strip hosts the glass at forms. **Built into vRIO**: the panel's encoder strip hosts the glass at
top left, with its own port row in the control strip (label colour = port top left, with its own connection row in the control strip (label colour =
status; auto-opens **COM12** at startup when present). And standalone, connection status; picker offers `pipe:vplasma` and the COM ports). And standalone,
`VPlasma.App`: a bare-glass window that opens **COM12** (the device end of `VPlasma.App`: a bare-glass window that opens **COM12** (the device end of
the plasma's null-modem pair) on startup — retrying while the port is the plasma's null-modem pair) on startup — retrying while the port is
missing or busy, port status in the title bar. Both decode the display's missing or busy, port status in the title bar. Both decode the display's
@@ -48,6 +48,9 @@ public sealed class VPlasmaPipeService : IDisposable
/// <summary>The served pipe name (without the <c>\\.\pipe\</c> prefix).</summary> /// <summary>The served pipe name (without the <c>\\.\pipe\</c> prefix).</summary>
public string PipeName => _pipeName; public string PipeName => _pipeName;
/// <summary>True while the server is up (whether or not a client is connected).</summary>
public bool IsListening => _running;
/// <summary>True while a client is connected.</summary> /// <summary>True while a client is connected.</summary>
public bool IsClientConnected => _clientConnected; public bool IsClientConnected => _clientConnected;
+86 -90
View File
@@ -10,32 +10,30 @@ namespace VRio.App;
/// <summary> /// <summary>
/// vRIO main window: the interactive cockpit panel on the left (the same /// 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 /// functional map RIOJoy's profile editor shows) and a control strip on the
/// right — COM ports, device settings, and a live wire log. The panel's /// right — connection rows, device settings, and a live wire log. The
/// encoder strip also hosts the built-in plasma glass (the vPLASMA display /// panel's encoder strip also hosts the built-in plasma glass (the vPLASMA
/// emulator) on its own COM port. At startup the usual ports /// display emulator) on its own connection row. Each row's picker offers
/// (<see cref="PreferredPort"/>, <see cref="PreferredPlasmaPort"/>) are /// the device's named-pipe endpoint (the DOSBox-X fork's namedpipe backend
/// opened automatically when available; otherwise open them by hand. Point /// — the com0com-free path) alongside the machine's COM ports; nothing
/// RIOJoy at the other end of the null-modem pair, and every click here /// opens automatically — pick an endpoint and Open. Point RIOJoy at the
/// arrives at RIOJoy exactly as if the physical cockpit sent it; point the /// other end of a null-modem pair and every click here arrives exactly as
/// game's COM2 passthrough at the plasma pair and the glass lights up. /// if the physical cockpit sent it; point the game at
/// Both devices also serve the DOSBox-X fork's named-pipe transport /// <see cref="RioPipeEndpoint"/>/<see cref="PlasmaPipeEndpoint"/> (or the
/// (<c>\\.\pipe\vrio</c>, <c>\\.\pipe\vplasma</c>) for the whole app /// COM2 passthrough at the plasma pair) and the glass lights up.
/// lifetime — the com0com-free path.
/// </summary> /// </summary>
internal sealed class MainForm : Form internal sealed class MainForm : Form
{ {
/// <summary> /// <summary>
/// vRIO's usual port: the device end of the COM1⇄COM11 com0com pair. /// The RIO's pipe endpoint as listed in the picker. Matches the
/// Auto-opened at startup when present and free; the picker still allows /// DOSBox-X conf syntax: <c>serial1=namedpipe pipe:vrio</c>.
/// any other port.
/// </summary> /// </summary>
private const string PreferredPort = "COM11"; private static readonly string RioPipeEndpoint = $"pipe:{VRioPipeService.DefaultPipeName}";
/// <summary> /// <summary>
/// The built-in plasma's usual port: the device end of the COM2⇄COM12 /// The plasma's pipe endpoint as listed in the picker
/// com0com pair (the game's COM2 passthrough opens the other end). /// (<c>serial2=namedpipe pipe:vplasma</c>).
/// </summary> /// </summary>
private const string PreferredPlasmaPort = "COM12"; private static readonly string PlasmaPipeEndpoint = $"pipe:{VPlasmaPipeService.DefaultPipeName}";
private readonly VRioDevice _device = new(); private readonly VRioDevice _device = new();
private readonly VRioSerialService _service; private readonly VRioSerialService _service;
@@ -195,9 +193,8 @@ internal sealed class MainForm : Form
_service = new VRioSerialService(_device); _service = new VRioSerialService(_device);
_plasmaService = new VPlasmaSerialService(_plasmaDevice); _plasmaService = new VPlasmaSerialService(_plasmaDevice);
// The named-pipe endpoints for DOSBox-X's namedpipe serial backend. // The named-pipe endpoints for DOSBox-X's namedpipe serial backend,
// Unlike a COM port, a listening pipe costs nothing and conflicts with // offered in the connection pickers alongside the COM ports.
// nothing, so both servers run for the whole app lifetime.
_pipeService = new VRioPipeService(_device); _pipeService = new VRioPipeService(_device);
_plasmaPipeService = new VPlasmaPipeService(_plasmaDevice); _plasmaPipeService = new VPlasmaPipeService(_plasmaDevice);
_router = new InputRouter(_device); _router = new InputRouter(_device);
@@ -231,19 +228,12 @@ internal sealed class MainForm : Form
_device.Logged += line => RunOnUi(() => PrependLog(line)); _device.Logged += line => RunOnUi(() => PrependLog(line));
_service.Logged += line => RunOnUi(() => PrependLog(line)); _service.Logged += line => RunOnUi(() => PrependLog(line));
_lampMirror.Logged += line => RunOnUi(() => PrependLog(line)); _lampMirror.Logged += line => RunOnUi(() => PrependLog(line));
_service.ConnectionChanged += open => RunOnUi(() => OnConnectionChanged(open)); _service.ConnectionChanged += _ => RunOnUi(UpdateRioEndpointUi);
_service.HostHandshake += high => RunOnUi(() => _service.HostHandshake += high => RunOnUi(() =>
PrependLog(high ? "Host raised DTR (board-reset handshake)" : "Host dropped DTR")); PrependLog(high ? "Host raised DTR (board-reset handshake)" : "Host dropped DTR"));
_pipeService.Logged += line => RunOnUi(() => PrependLog($"pipe: {line}")); _pipeService.Logged += line => RunOnUi(() => PrependLog($"pipe: {line}"));
_pipeService.HostHandshake += high => RunOnUi(() => _pipeService.HostHandshake += high => RunOnUi(() =>
PrependLog(high ? "Pipe host raised DTR (board-reset handshake)" : "Pipe host dropped DTR")); PrependLog(high ? "Pipe host raised DTR (board-reset handshake)" : "Pipe host dropped DTR"));
_pipeService.ClientChanged += connected => RunOnUi(() =>
{
// The RIO is a single-host device; two live transports means two
// hosts fighting over one board.
if (connected && _service.IsOpen)
PrependLog($"Warning: a pipe client connected while {_service.PortName} is open — close one transport.");
});
_plasmaPipeService.Logged += line => RunOnUi(() => PrependLog($"vPLASMA pipe: {line}")); _plasmaPipeService.Logged += line => RunOnUi(() => PrependLog($"vPLASMA pipe: {line}"));
// Built-in plasma glass: a pure listener on its own port, same gestures // Built-in plasma glass: a pure listener on its own port, same gestures
@@ -251,7 +241,7 @@ internal sealed class MainForm : Form
// right-click resets the display to its power-on state. // right-click resets the display to its power-on state.
_plasmaDevice.Updated += () => RunOnUi(() => _plasmaCanvas.UpdateFrom(_plasmaDevice)); _plasmaDevice.Updated += () => RunOnUi(() => _plasmaCanvas.UpdateFrom(_plasmaDevice));
_plasmaService.Logged += line => RunOnUi(() => PrependLog($"vPLASMA: {line}")); _plasmaService.Logged += line => RunOnUi(() => PrependLog($"vPLASMA: {line}"));
_plasmaService.ConnectionChanged += open => RunOnUi(() => OnPlasmaConnectionChanged(open)); _plasmaService.ConnectionChanged += _ => RunOnUi(UpdatePlasmaEndpointUi);
_plasmaCanvas.DoubleClick += (_, _) => RunPlasmaSelfTest(); _plasmaCanvas.DoubleClick += (_, _) => RunPlasmaSelfTest();
_plasmaCanvas.MouseClick += (_, e) => _plasmaCanvas.MouseClick += (_, e) =>
{ {
@@ -332,12 +322,10 @@ internal sealed class MainForm : Form
RefreshPorts(); RefreshPorts();
UpdateStatus(); UpdateStatus();
PrependLog("vRIO ready. Open a COM port, then point RIOJoy at the other end of the pair."); PrependLog($"vRIO ready. Pick an endpoint per device — {RioPipeEndpoint} / {PlasmaPipeEndpoint} " +
"for DOSBox-X, or a COM port for RIOJoy — and Open.");
LoadBindings(); LoadBindings();
_plasmaCanvas.UpdateFrom(_plasmaDevice); // paint the power-on frame _plasmaCanvas.UpdateFrom(_plasmaDevice); // paint the power-on frame
AutoOpenPreferredPorts();
_pipeService.Start();
_plasmaPipeService.Start();
} }
private Panel BuildControlStrip() private Panel BuildControlStrip()
@@ -384,111 +372,116 @@ internal sealed class MainForm : Form
return panel; return panel;
} }
// ---- Port handling ----------------------------------------------------- // ---- Endpoint handling ---------------------------------------------------
// Each device row serves one endpoint at a time — the RIO is a
// single-host device, so its pipe server and COM port never run together.
private bool RioOpen => _service.IsOpen || _pipeService.IsListening;
private bool PlasmaOpen => _plasmaService.IsOpen || _plasmaPipeService.IsListening;
private void RefreshPorts() private void RefreshPorts()
{ {
string[] names = SerialPort.GetPortNames().Distinct() string[] names = SerialPort.GetPortNames().Distinct()
.OrderBy(n => n, StringComparer.OrdinalIgnoreCase).ToArray(); .OrderBy(n => n, StringComparer.OrdinalIgnoreCase).ToArray();
// An open service's picker is disabled and shows the served port; // An open row's picker is disabled and shows the served endpoint;
// leave it alone so the display can't drift from reality. // leave it alone so the display can't drift from reality.
if (!_service.IsOpen) if (!RioOpen)
RefreshPicker(_portBox, names); RefreshPicker(_portBox, RioPipeEndpoint, names);
if (!_plasmaService.IsOpen) if (!PlasmaOpen)
RefreshPicker(_plasmaPortBox, names); RefreshPicker(_plasmaPortBox, PlasmaPipeEndpoint, names);
} }
private static void RefreshPicker(ComboBox box, string[] portNames) private static void RefreshPicker(ComboBox box, string pipeEndpoint, string[] portNames)
{ {
string? current = box.SelectedItem?.ToString(); string? current = box.SelectedItem?.ToString();
box.Items.Clear(); box.Items.Clear();
box.Items.Add(pipeEndpoint); // always present — a pipe server needs no hardware
foreach (string name in portNames) foreach (string name in portNames)
box.Items.Add(name); box.Items.Add(name);
if (box.Items.Count == 0)
return;
int idx = current is null ? -1 : box.Items.IndexOf(current); int idx = current is null ? -1 : box.Items.IndexOf(current);
box.SelectedIndex = idx >= 0 ? idx : 0; box.SelectedIndex = idx >= 0 ? idx : 0;
} }
/// <summary>
/// Startup convenience: if a device's usual port exists, select it and try
/// to open it. Failures (port missing, or busy because another vRIO/app
/// holds it) just log — no modal box at launch — and leave the manual
/// pickers in charge.
/// </summary>
private void AutoOpenPreferredPorts()
{
AutoOpen(_portBox, PreferredPort, _service.Open);
AutoOpen(_plasmaPortBox, PreferredPlasmaPort, _plasmaService.Open);
}
private void AutoOpen(ComboBox box, string port, Action<string> open)
{
int idx = box.Items.IndexOf(port);
if (idx < 0)
{
PrependLog($"{port} not present — pick a port and open it manually.");
return;
}
box.SelectedIndex = idx;
try
{
open(port);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidOperationException or ArgumentException)
{
PrependLog($"{port} is present but could not be opened ({ex.Message.TrimEnd('.')}) — open it manually once it frees up.");
}
}
private void ToggleOpen() private void ToggleOpen()
{ {
if (_service.IsOpen) if (RioOpen)
_service.Close(); {
_service.Close(); // idempotent —
_pipeService.Stop(); // whichever was serving closes
UpdateRioEndpointUi();
}
else else
OpenFromPicker(_portBox, _service.Open); {
OpenFromPicker(_portBox, OpenRioEndpoint);
}
} }
private void TogglePlasmaOpen() private void TogglePlasmaOpen()
{ {
if (_plasmaService.IsOpen) if (PlasmaOpen)
{
_plasmaService.Close(); _plasmaService.Close();
_plasmaPipeService.Stop();
UpdatePlasmaEndpointUi();
}
else else
OpenFromPicker(_plasmaPortBox, _plasmaService.Open); {
OpenFromPicker(_plasmaPortBox, OpenPlasmaEndpoint);
}
}
private void OpenRioEndpoint(string endpoint)
{
if (endpoint == RioPipeEndpoint)
_pipeService.Start();
else
_service.Open(endpoint);
UpdateRioEndpointUi();
}
private void OpenPlasmaEndpoint(string endpoint)
{
if (endpoint == PlasmaPipeEndpoint)
_plasmaPipeService.Start();
else
_plasmaService.Open(endpoint);
UpdatePlasmaEndpointUi();
} }
private void OpenFromPicker(ComboBox box, Action<string> open) private void OpenFromPicker(ComboBox box, Action<string> open)
{ {
if (box.SelectedItem?.ToString() is not { } port) if (box.SelectedItem?.ToString() is not { } endpoint)
{ {
MessageBox.Show(this, "No COM port selected. On a single PC, install a com0com virtual " + MessageBox.Show(this, "No endpoint selected. Pick the device's named pipe (for " +
"null-modem pair and open one end here.", "vRIO", MessageBoxButtons.OK, MessageBoxIcon.Information); "DOSBox-X's namedpipe backend) or a COM port (for RIOJoy through a com0com " +
"null-modem pair).", "vRIO", MessageBoxButtons.OK, MessageBoxIcon.Information);
return; return;
} }
try try
{ {
open(port); open(endpoint);
} }
catch (Exception ex) catch (Exception ex)
{ {
MessageBox.Show(this, $"Could not open {port}:\n{ex.Message}", "vRIO", MessageBox.Show(this, $"Could not open {endpoint}:\n{ex.Message}", "vRIO",
MessageBoxButtons.OK, MessageBoxIcon.Warning); MessageBoxButtons.OK, MessageBoxIcon.Warning);
} }
} }
private void OnConnectionChanged(bool open) private void UpdateRioEndpointUi()
{ {
bool open = RioOpen;
_openClose.Text = open ? "Close" : "Open"; _openClose.Text = open ? "Close" : "Open";
_portBox.Enabled = !open; _portBox.Enabled = !open;
_rioLabel.ForeColor = open ? Color.ForestGreen : Color.Gray; _rioLabel.ForeColor = open ? Color.ForestGreen : Color.Gray;
UpdateStatus(); UpdateStatus();
} }
private void OnPlasmaConnectionChanged(bool open) private void UpdatePlasmaEndpointUi()
{ {
bool open = PlasmaOpen;
_plasmaOpenClose.Text = open ? "Close" : "Open"; _plasmaOpenClose.Text = open ? "Close" : "Open";
_plasmaPortBox.Enabled = !open; _plasmaPortBox.Enabled = !open;
_plasmaLabel.ForeColor = open ? Color.ForestGreen : Color.Gray; _plasmaLabel.ForeColor = open ? Color.ForestGreen : Color.Gray;
@@ -715,12 +708,15 @@ internal sealed class MainForm : Form
$"Analog polls dropped: {dropped}\n" + $"Analog polls dropped: {dropped}\n" +
$"Bad checksums: {bad}"; $"Bad checksums: {bad}";
_canvas.StatusText = _service.IsOpen string? endpoint = _service.IsOpen ? _service.PortName
? $"vRIO {_device.VersionMajor}.{_device.VersionMinor} on {_service.PortName}\n" + : _pipeService.IsListening ? RioPipeEndpoint
: null;
_canvas.StatusText = endpoint is not null
? $"vRIO {_device.VersionMajor}.{_device.VersionMinor} on {endpoint}\n" +
(wedged (wedged
? "** ANALOG WEDGED (v4.2 bug) — awaiting host reset **" ? "** ANALOG WEDGED (v4.2 bug) — awaiting host reset **"
: $"{polls} analog polls" + (polls > 0 ? " (host is alive)" : " (no host traffic yet)")) : $"{polls} analog polls" + (polls > 0 ? " (host is alive)" : " (no host traffic yet)"))
: "PORT CLOSED\nOpen a COM port to go live."; : "OFFLINE\nOpen an endpoint (named pipe or COM port) to go live.";
} }
private void PrependLog(string line) private void PrependLog(string line)
+3
View File
@@ -67,6 +67,9 @@ public sealed class VRioPipeService : IDisposable
/// <summary>The served pipe name (without the <c>\\.\pipe\</c> prefix).</summary> /// <summary>The served pipe name (without the <c>\\.\pipe\</c> prefix).</summary>
public string PipeName => _pipeName; public string PipeName => _pipeName;
/// <summary>True while the server is up (whether or not a client is connected).</summary>
public bool IsListening => _running;
/// <summary>True while a client is connected.</summary> /// <summary>True while a client is connected.</summary>
public bool IsClientConnected => _clientConnected; public bool IsClientConnected => _clientConnected;
@@ -66,7 +66,7 @@ public class VPlasmaPipeServiceTests
} }
[Fact] [Fact]
public void Unknown_frame_type_drops_the_client_and_server_recovers() public async Task Unknown_frame_type_drops_the_client_and_server_recovers()
{ {
string name = UniqueName(); string name = UniqueName();
var device = new VPlasmaDevice(); var device = new VPlasmaDevice();
@@ -85,8 +85,9 @@ public class VPlasmaPipeServiceTests
try { return bad.Read(new byte[1], 0, 1) == 0; } try { return bad.Read(new byte[1], 0, 1) == 0; }
catch (IOException) { return true; } catch (IOException) { return true; }
}); });
Assert.True(eof.Wait(2000), "server did not drop the connection"); Assert.True(await Task.WhenAny(eof, Task.Delay(2000)) == eof,
Assert.True(eof.Result); "server did not drop the connection");
Assert.True(await eof);
} }
using var good = Connect(name); using var good = Connect(name);