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);