diff --git a/README.md b/README.md
index 397fb01..e4c3e3c 100644
--- a/README.md
+++ b/README.md
@@ -106,10 +106,11 @@ a null-modem cable work the same way.
## Named pipes for DOSBox-X (no com0com)
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
-named pipe for the whole app lifetime — `\\.\pipe\vrio` and
-`\\.\pipe\vplasma` — and DOSBox-X connects as the client, retrying in the
-background until the pipe exists:
+backend), the virtual ports aren't needed at all: each device's connection
+picker lists its named-pipe endpoint — `pipe:vrio`, `pipe:vplasma` —
+alongside the machine's COM ports. Select it and **Open** to serve
+`\\.\pipe\vrio` / `\\.\pipe\vplasma`; DOSBox-X connects as the client,
+retrying in the background until the pipe exists:
```ini
[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`
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
-burst. The COM rows keep working unchanged (RIOJoy, real pods); if a pipe
-client connects while a COM host is active the wire log warns, since the
-RIO is a single-host device. vRIO's built-in glass and the standalone
-vPLASMA share the `vplasma` pipe name — whoever starts second retries until
-the name frees up.
+burst. Each row serves one endpoint at a time — the RIO is a single-host
+device — and nothing opens automatically at startup; the COM path is
+unchanged for RIOJoy and real pods. vRIO's built-in glass and the
+standalone vPLASMA share the `vplasma` pipe name — whoever starts second
+retries until the name frees up.
## 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
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
-top left, with its own port row in the control strip (label colour = port
-status; auto-opens **COM12** at startup when present). And standalone,
+top left, with its own connection row in the control strip (label colour =
+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
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
diff --git a/src/VPlasma.Core/Device/VPlasmaPipeService.cs b/src/VPlasma.Core/Device/VPlasmaPipeService.cs
index 0cfd998..c7b554f 100644
--- a/src/VPlasma.Core/Device/VPlasmaPipeService.cs
+++ b/src/VPlasma.Core/Device/VPlasmaPipeService.cs
@@ -48,6 +48,9 @@ public sealed class VPlasmaPipeService : IDisposable
/// The served pipe name (without the \\.\pipe\ prefix).
public string PipeName => _pipeName;
+ /// True while the server is up (whether or not a client is connected).
+ public bool IsListening => _running;
+
/// True while a client is connected.
public bool IsClientConnected => _clientConnected;
diff --git a/src/VRio.App/MainForm.cs b/src/VRio.App/MainForm.cs
index 4c75402..9aee109 100644
--- a/src/VRio.App/MainForm.cs
+++ b/src/VRio.App/MainForm.cs
@@ -10,32 +10,30 @@ namespace VRio.App;
///
/// 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 ports, device settings, and a live wire log. The panel's
-/// encoder strip also hosts the built-in plasma glass (the vPLASMA display
-/// emulator) on its own COM port. At startup the usual ports
-/// (, ) are
-/// opened automatically when available; otherwise open them by hand. 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; point the
-/// game's COM2 passthrough at the plasma pair and the glass lights up.
-/// Both devices also serve the DOSBox-X fork's named-pipe transport
-/// (\\.\pipe\vrio, \\.\pipe\vplasma) for the whole app
-/// lifetime — the com0com-free path.
+/// right — connection rows, device settings, and a live wire log. The
+/// panel's encoder strip also hosts the built-in plasma glass (the vPLASMA
+/// display emulator) on its own connection row. Each row's picker offers
+/// the device's named-pipe endpoint (the DOSBox-X fork's namedpipe backend
+/// — the com0com-free path) alongside the machine's COM ports; nothing
+/// opens automatically — pick an endpoint and Open. Point RIOJoy at the
+/// other end of a null-modem pair and every click here arrives exactly as
+/// if the physical cockpit sent it; point the game at
+/// / (or the
+/// COM2 passthrough at the plasma pair) and the glass lights up.
///
internal sealed class MainForm : Form
{
///
- /// vRIO's usual port: the device end of the COM1⇄COM11 com0com pair.
- /// Auto-opened at startup when present and free; the picker still allows
- /// any other port.
+ /// The RIO's pipe endpoint as listed in the picker. Matches the
+ /// DOSBox-X conf syntax: serial1=namedpipe pipe:vrio.
///
- private const string PreferredPort = "COM11";
+ private static readonly string RioPipeEndpoint = $"pipe:{VRioPipeService.DefaultPipeName}";
///
- /// The built-in plasma's usual port: the device end of the COM2⇄COM12
- /// com0com pair (the game's COM2 passthrough opens the other end).
+ /// The plasma's pipe endpoint as listed in the picker
+ /// (serial2=namedpipe pipe:vplasma).
///
- private const string PreferredPlasmaPort = "COM12";
+ private static readonly string PlasmaPipeEndpoint = $"pipe:{VPlasmaPipeService.DefaultPipeName}";
private readonly VRioDevice _device = new();
private readonly VRioSerialService _service;
@@ -195,9 +193,8 @@ internal sealed class MainForm : Form
_service = new VRioSerialService(_device);
_plasmaService = new VPlasmaSerialService(_plasmaDevice);
- // The named-pipe endpoints for DOSBox-X's namedpipe serial backend.
- // Unlike a COM port, a listening pipe costs nothing and conflicts with
- // nothing, so both servers run for the whole app lifetime.
+ // The named-pipe endpoints for DOSBox-X's namedpipe serial backend,
+ // offered in the connection pickers alongside the COM ports.
_pipeService = new VRioPipeService(_device);
_plasmaPipeService = new VPlasmaPipeService(_plasmaDevice);
_router = new InputRouter(_device);
@@ -231,19 +228,12 @@ internal sealed class MainForm : Form
_device.Logged += line => RunOnUi(() => PrependLog(line));
_service.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(() =>
PrependLog(high ? "Host raised DTR (board-reset handshake)" : "Host dropped DTR"));
_pipeService.Logged += line => RunOnUi(() => PrependLog($"pipe: {line}"));
_pipeService.HostHandshake += high => RunOnUi(() =>
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}"));
// 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.
_plasmaDevice.Updated += () => RunOnUi(() => _plasmaCanvas.UpdateFrom(_plasmaDevice));
_plasmaService.Logged += line => RunOnUi(() => PrependLog($"vPLASMA: {line}"));
- _plasmaService.ConnectionChanged += open => RunOnUi(() => OnPlasmaConnectionChanged(open));
+ _plasmaService.ConnectionChanged += _ => RunOnUi(UpdatePlasmaEndpointUi);
_plasmaCanvas.DoubleClick += (_, _) => RunPlasmaSelfTest();
_plasmaCanvas.MouseClick += (_, e) =>
{
@@ -332,12 +322,10 @@ internal sealed class MainForm : Form
RefreshPorts();
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();
_plasmaCanvas.UpdateFrom(_plasmaDevice); // paint the power-on frame
- AutoOpenPreferredPorts();
- _pipeService.Start();
- _plasmaPipeService.Start();
}
private Panel BuildControlStrip()
@@ -384,111 +372,116 @@ internal sealed class MainForm : Form
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()
{
string[] names = SerialPort.GetPortNames().Distinct()
.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.
- if (!_service.IsOpen)
- RefreshPicker(_portBox, names);
- if (!_plasmaService.IsOpen)
- RefreshPicker(_plasmaPortBox, names);
+ if (!RioOpen)
+ RefreshPicker(_portBox, RioPipeEndpoint, names);
+ if (!PlasmaOpen)
+ 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();
box.Items.Clear();
+ box.Items.Add(pipeEndpoint); // always present — a pipe server needs no hardware
foreach (string name in portNames)
box.Items.Add(name);
- if (box.Items.Count == 0)
- return;
int idx = current is null ? -1 : box.Items.IndexOf(current);
box.SelectedIndex = idx >= 0 ? idx : 0;
}
- ///
- /// 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.
- ///
- private void AutoOpenPreferredPorts()
- {
- AutoOpen(_portBox, PreferredPort, _service.Open);
- AutoOpen(_plasmaPortBox, PreferredPlasmaPort, _plasmaService.Open);
- }
-
- private void AutoOpen(ComboBox box, string port, Action 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()
{
- if (_service.IsOpen)
- _service.Close();
+ if (RioOpen)
+ {
+ _service.Close(); // idempotent —
+ _pipeService.Stop(); // whichever was serving closes
+ UpdateRioEndpointUi();
+ }
else
- OpenFromPicker(_portBox, _service.Open);
+ {
+ OpenFromPicker(_portBox, OpenRioEndpoint);
+ }
}
private void TogglePlasmaOpen()
{
- if (_plasmaService.IsOpen)
+ if (PlasmaOpen)
+ {
_plasmaService.Close();
+ _plasmaPipeService.Stop();
+ UpdatePlasmaEndpointUi();
+ }
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 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 " +
- "null-modem pair and open one end here.", "vRIO", MessageBoxButtons.OK, MessageBoxIcon.Information);
+ MessageBox.Show(this, "No endpoint selected. Pick the device's named pipe (for " +
+ "DOSBox-X's namedpipe backend) or a COM port (for RIOJoy through a com0com " +
+ "null-modem pair).", "vRIO", MessageBoxButtons.OK, MessageBoxIcon.Information);
return;
}
try
{
- open(port);
+ open(endpoint);
}
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);
}
}
- private void OnConnectionChanged(bool open)
+ private void UpdateRioEndpointUi()
{
+ bool open = RioOpen;
_openClose.Text = open ? "Close" : "Open";
_portBox.Enabled = !open;
_rioLabel.ForeColor = open ? Color.ForestGreen : Color.Gray;
UpdateStatus();
}
- private void OnPlasmaConnectionChanged(bool open)
+ private void UpdatePlasmaEndpointUi()
{
+ bool open = PlasmaOpen;
_plasmaOpenClose.Text = open ? "Close" : "Open";
_plasmaPortBox.Enabled = !open;
_plasmaLabel.ForeColor = open ? Color.ForestGreen : Color.Gray;
@@ -715,12 +708,15 @@ internal sealed class MainForm : Form
$"Analog polls dropped: {dropped}\n" +
$"Bad checksums: {bad}";
- _canvas.StatusText = _service.IsOpen
- ? $"vRIO {_device.VersionMajor}.{_device.VersionMinor} on {_service.PortName}\n" +
+ string? endpoint = _service.IsOpen ? _service.PortName
+ : _pipeService.IsListening ? RioPipeEndpoint
+ : null;
+ _canvas.StatusText = endpoint is not null
+ ? $"vRIO {_device.VersionMajor}.{_device.VersionMinor} on {endpoint}\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.";
+ : "OFFLINE\nOpen an endpoint (named pipe or COM port) to go live.";
}
private void PrependLog(string line)
diff --git a/src/VRio.Core/Device/VRioPipeService.cs b/src/VRio.Core/Device/VRioPipeService.cs
index 6dd92b4..5f134f8 100644
--- a/src/VRio.Core/Device/VRioPipeService.cs
+++ b/src/VRio.Core/Device/VRioPipeService.cs
@@ -67,6 +67,9 @@ public sealed class VRioPipeService : IDisposable
/// The served pipe name (without the \\.\pipe\ prefix).
public string PipeName => _pipeName;
+ /// True while the server is up (whether or not a client is connected).
+ public bool IsListening => _running;
+
/// True while a client is connected.
public bool IsClientConnected => _clientConnected;
diff --git a/tests/VPlasma.Core.Tests/VPlasmaPipeServiceTests.cs b/tests/VPlasma.Core.Tests/VPlasmaPipeServiceTests.cs
index 18b7647..47a6490 100644
--- a/tests/VPlasma.Core.Tests/VPlasmaPipeServiceTests.cs
+++ b/tests/VPlasma.Core.Tests/VPlasmaPipeServiceTests.cs
@@ -66,7 +66,7 @@ public class VPlasmaPipeServiceTests
}
[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();
var device = new VPlasmaDevice();
@@ -85,8 +85,9 @@ public class VPlasmaPipeServiceTests
try { return bad.Read(new byte[1], 0, 1) == 0; }
catch (IOException) { return true; }
});
- Assert.True(eof.Wait(2000), "server did not drop the connection");
- Assert.True(eof.Result);
+ Assert.True(await Task.WhenAny(eof, Task.Delay(2000)) == eof,
+ "server did not drop the connection");
+ Assert.True(await eof);
}
using var good = Connect(name);