diff --git a/README.md b/README.md
index 8176f25..397fb01 100644
--- a/README.md
+++ b/README.md
@@ -103,6 +103,33 @@ analog polling drives the "analog polls served" counter, and every click on
the vRIO panel arrives at RIOJoy as real cockpit input. Two physical PCs with
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:
+
+```ini
+[serial]
+serial1 = namedpipe pipe:vrio rxpollus:100 rxburst:16
+serial2 = namedpipe pipe:vplasma
+```
+
+A pipe is a plain byte stream, so serial data and modem lines travel as
+typed frames (`0x00 len bytes` data, `0x01 bits` DTR/RTS) — the game's DTR
+reset pulse keeps its exact position in the byte stream, and a disconnect
+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.
+
## vPLASMA — the companion plasma display
The cockpit's second serial device is the **plasma display**: a 128×32
diff --git a/src/VPlasma.App/MainForm.cs b/src/VPlasma.App/MainForm.cs
index ad5e6b8..71d84b3 100644
--- a/src/VPlasma.App/MainForm.cs
+++ b/src/VPlasma.App/MainForm.cs
@@ -7,6 +7,9 @@ namespace VPlasma.App;
/// device end is hardwired to (the plasma side of the
/// second com0com pair) and opened automatically — a retry timer keeps
/// trying while the port is missing or busy, and reopens it if it dies. The
+/// DOSBox-X fork's named-pipe transport (\\.\pipe\vplasma) is served
+/// permanently alongside it (if vRIO's built-in glass already holds the
+/// pipe name, the server just retries until it frees up). The
/// title bar carries the port status; double-clicking the glass cycles the
/// self-test pages (banner, charset, graphics) through the wire parser, and
/// a right-click resets the display to its power-on state.
@@ -18,6 +21,7 @@ internal sealed class MainForm : Form
private readonly VPlasmaDevice _device = new();
private readonly VPlasmaSerialService _service;
+ private readonly VPlasmaPipeService _pipeService;
private readonly PlasmaCanvas _canvas = new();
private readonly System.Windows.Forms.Timer _reconnectTimer = new() { Interval = 3000 };
@@ -32,6 +36,7 @@ internal sealed class MainForm : Form
StartPosition = FormStartPosition.CenterScreen;
_service = new VPlasmaSerialService(_device);
+ _pipeService = new VPlasmaPipeService(_device);
_canvas.Location = new Point(0, 0);
Controls.Add(_canvas);
@@ -39,6 +44,7 @@ internal sealed class MainForm : Form
// Device / service events arrive on the serial reader thread; marshal to the UI.
_device.Updated += () => RunOnUi(() => _canvas.UpdateFrom(_device));
_service.ConnectionChanged += _ => RunOnUi(UpdateTitle);
+ _pipeService.ClientChanged += _ => RunOnUi(UpdateTitle);
_canvas.DoubleClick += (_, _) => RunSelfTest();
_canvas.MouseClick += (_, e) =>
@@ -55,10 +61,12 @@ internal sealed class MainForm : Form
{
_reconnectTimer.Dispose();
_service.Dispose();
+ _pipeService.Dispose();
};
_canvas.UpdateFrom(_device);
EnsureOpen();
+ _pipeService.Start();
}
private void EnsureOpen()
@@ -82,6 +90,8 @@ internal sealed class MainForm : Form
string status = _service.IsOpen
? $"{PortName} @ 9600 8N1"
: $"{PortName} unavailable — retrying";
+ if (_pipeService.IsClientConnected)
+ status += " • pipe host connected";
Text = $"vPLASMA v{Application.ProductVersion} — {status}";
}
diff --git a/src/VPlasma.Core/Device/VPlasmaPipeService.cs b/src/VPlasma.Core/Device/VPlasmaPipeService.cs
new file mode 100644
index 0000000..0cfd998
--- /dev/null
+++ b/src/VPlasma.Core/Device/VPlasmaPipeService.cs
@@ -0,0 +1,237 @@
+using System.IO.Pipes;
+
+namespace VPlasma.Core.Device;
+
+///
+/// Serves a over the named pipe that the
+/// DOSBox-X fork's serial2=namedpipe pipe:vplasma backend connects
+/// to — the com0com-free transport. We are the pipe server (the
+/// display is always present); DOSBox-X is the client and background-retries
+/// while the pipe is missing, so this service listens for the whole app
+/// lifetime alongside the COM path.
+///
+/// Framing is the contract shared with the fork (see VRio.Core's
+/// PipeFraming, and serialnamedpipe.h on the fork side):
+/// 0x00 len bytes carries serial data, 0x01 bits carries the
+/// sender's DTR/RTS. Like the serial twin, the plasma is a pure listener —
+/// we send one lines frame on connect (DTR+RTS high, "display present") and
+/// then only ever read. The game may toggle its own lines around writes
+/// (DOS console redirection flips RTS per byte); the display doesn't care,
+/// so inbound lines frames are consumed silently.
+///
+public sealed class VPlasmaPipeService : IDisposable
+{
+ /// The contract pipe name: DOSBox-X connects to \\.\pipe\vplasma.
+ public const string DefaultPipeName = "vplasma";
+
+ // Frame types and line bits, per the shared contract.
+ private const byte DataType = 0x00;
+ private const byte LinesType = 0x01;
+ private const byte LinesPresent = 0x03; // DTR + RTS
+
+ private readonly VPlasmaDevice _device;
+ private readonly string _pipeName;
+
+ private NamedPipeServerStream? _pipe;
+ private Thread? _server;
+ private volatile bool _running;
+ private volatile bool _clientConnected;
+
+ public VPlasmaPipeService(VPlasmaDevice device, string pipeName = DefaultPipeName)
+ {
+ _device = device ?? throw new ArgumentNullException(nameof(device));
+ if (string.IsNullOrWhiteSpace(pipeName))
+ throw new ArgumentException("Pipe name is required.", nameof(pipeName));
+ _pipeName = pipeName;
+ }
+
+ /// The served pipe name (without the \\.\pipe\ prefix).
+ public string PipeName => _pipeName;
+
+ /// True while a client is connected.
+ public bool IsClientConnected => _clientConnected;
+
+ /// A client connected (true) or went away (false).
+ public event Action? ClientChanged;
+
+ /// Pipe-level log lines (listen/connect/errors).
+ public event Action? Logged;
+
+ /// Start listening (idempotent). Clients may come and go forever.
+ public void Start()
+ {
+ if (_running)
+ return;
+
+ _running = true;
+ _server = new Thread(ServerLoop) { IsBackground = true, Name = $"vPLASMA pipe server ({_pipeName})" };
+ _server.Start();
+
+ Logged?.Invoke($@"Listening on \\.\pipe\{_pipeName} — DOSBox-X connects when it boots");
+ }
+
+ /// Stop listening and drop any client (idempotent).
+ public void Stop()
+ {
+ if (!_running)
+ return;
+
+ _running = false;
+ NamedPipeServerStream? pipe = _pipe;
+ _pipe = null;
+ try { pipe?.Dispose(); }
+ catch (IOException) { }
+
+ // A WaitForConnection pending on the disposed stream can survive the
+ // Dispose on net48; a throwaway client connect releases it either way.
+ try
+ {
+ using var poke = new NamedPipeClientStream(".", _pipeName, PipeDirection.Out);
+ poke.Connect(100);
+ }
+ catch (Exception ex) when (ex is IOException or TimeoutException or UnauthorizedAccessException) { }
+
+ _server?.Join(1000);
+ _server = null;
+ _clientConnected = false;
+
+ Logged?.Invoke("Pipe server stopped");
+ }
+
+ private void ServerLoop()
+ {
+ bool busyLogged = false; // log the name collision once, not per retry
+
+ while (_running)
+ {
+ NamedPipeServerStream pipe;
+ try
+ {
+ pipe = new NamedPipeServerStream(_pipeName, PipeDirection.InOut, 1,
+ PipeTransmissionMode.Byte, PipeOptions.Asynchronous);
+ }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
+ {
+ // Name already served — e.g. vRIO's built-in glass and the
+ // standalone vPLASMA both running. Whoever loses just waits.
+ if (!busyLogged)
+ {
+ busyLogged = true;
+ Logged?.Invoke($@"\\.\pipe\{_pipeName} is busy ({ex.Message.TrimEnd('.')}) — retrying until it frees up");
+ }
+ for (int i = 0; i < 20 && _running; i++)
+ Thread.Sleep(100);
+ continue;
+ }
+ busyLogged = false;
+
+ _pipe = pipe;
+ try
+ {
+ pipe.WaitForConnection();
+
+ if (_running)
+ {
+ // Our lines frame goes first: display present.
+ pipe.Write(new byte[] { LinesType, LinesPresent }, 0, 2);
+ pipe.Flush();
+
+ _clientConnected = true;
+ Logged?.Invoke("Pipe client connected — sent lines DTR+RTS (display present)");
+ ClientChanged?.Invoke(true);
+
+ ReadUntilDisconnect(pipe);
+ }
+ }
+ catch (Exception ex) when (ex is IOException or ObjectDisposedException or InvalidOperationException)
+ {
+ // Disposed by Stop(), or a client vanished mid-connect.
+ }
+
+ bool wasConnected = _clientConnected;
+ _clientConnected = false;
+ _pipe = null;
+ try { pipe.Dispose(); }
+ catch (IOException) { }
+
+ if (wasConnected && _running)
+ {
+ Logged?.Invoke("Pipe client disconnected");
+ ClientChanged?.Invoke(false);
+ }
+ }
+ }
+
+ private void ReadUntilDisconnect(NamedPipeServerStream pipe)
+ {
+ // Inline deframer (the listener-only subset of VRio.Core's
+ // PipeFrameDecoder): data payloads go to the device, lines frames are
+ // consumed silently, anything else is a protocol bug — log and drop
+ // the connection rather than resync.
+ const int StateType = 0, StateLength = 1, StatePayload = 2, StateLines = 3;
+ int state = StateType, remaining = 0;
+
+ var buffer = new byte[512];
+ var payload = new byte[byte.MaxValue];
+ int fill = 0;
+
+ while (_running)
+ {
+ int n;
+ try
+ {
+ n = pipe.Read(buffer, 0, buffer.Length);
+ }
+ catch (Exception ex) when (ex is IOException or ObjectDisposedException or InvalidOperationException or OperationCanceledException)
+ {
+ if (_running)
+ Logged?.Invoke($"Pipe error: {ex.Message}");
+ return;
+ }
+
+ if (n == 0)
+ return; // client closed its end
+
+ for (int i = 0; i < n; i++)
+ {
+ byte b = buffer[i];
+ switch (state)
+ {
+ case StateType when b == DataType:
+ state = StateLength;
+ break;
+ case StateType when b == LinesType:
+ state = StateLines;
+ break;
+ case StateType:
+ Logged?.Invoke($"Pipe protocol violation: unknown frame type 0x{b:X2} — dropping the connection");
+ return;
+
+ case StateLength when b == 0:
+ Logged?.Invoke("Pipe protocol violation: zero-length data frame — dropping the connection");
+ return;
+ case StateLength:
+ remaining = b;
+ fill = 0;
+ state = StatePayload;
+ break;
+
+ case StatePayload:
+ payload[fill++] = b;
+ if (fill == remaining)
+ {
+ state = StateType;
+ _device.OnReceived(payload, remaining);
+ }
+ break;
+
+ case StateLines:
+ state = StateType; // the display ignores host line state
+ break;
+ }
+ }
+ }
+ }
+
+ public void Dispose() => Stop();
+}
diff --git a/src/VRio.App/MainForm.cs b/src/VRio.App/MainForm.cs
index 9733d8b..4c75402 100644
--- a/src/VRio.App/MainForm.cs
+++ b/src/VRio.App/MainForm.cs
@@ -18,6 +18,9 @@ namespace VRio.App;
/// 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.
///
internal sealed class MainForm : Form
{
@@ -36,8 +39,10 @@ internal sealed class MainForm : Form
private readonly VRioDevice _device = new();
private readonly VRioSerialService _service;
+ private readonly VRioPipeService _pipeService;
private readonly VPlasmaDevice _plasmaDevice = new();
private readonly VPlasmaSerialService _plasmaService;
+ private readonly VPlasmaPipeService _plasmaPipeService;
private readonly PanelCanvas _canvas = new();
private readonly PlasmaCanvas _plasmaCanvas = new(PanelCanvas.PlasmaDotPitch)
{
@@ -190,6 +195,11 @@ 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.
+ _pipeService = new VRioPipeService(_device);
+ _plasmaPipeService = new VPlasmaPipeService(_plasmaDevice);
_router = new InputRouter(_device);
_lampMirror = new KeyboardLampMirror(_device);
@@ -224,6 +234,17 @@ internal sealed class MainForm : Form
_service.ConnectionChanged += open => RunOnUi(() => OnConnectionChanged(open));
_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
// as the standalone vPLASMA — double-click cycles the self-test pages,
@@ -305,6 +326,8 @@ internal sealed class MainForm : Form
_rawKeyboard.Dispose();
_service.Dispose();
_plasmaService.Dispose();
+ _pipeService.Dispose();
+ _plasmaPipeService.Dispose();
};
RefreshPorts();
@@ -313,6 +336,8 @@ internal sealed class MainForm : Form
LoadBindings();
_plasmaCanvas.UpdateFrom(_plasmaDevice); // paint the power-on frame
AutoOpenPreferredPorts();
+ _pipeService.Start();
+ _plasmaPipeService.Start();
}
private Panel BuildControlStrip()
diff --git a/src/VRio.Core/Device/PipeFraming.cs b/src/VRio.Core/Device/PipeFraming.cs
new file mode 100644
index 0000000..f97a7ee
--- /dev/null
+++ b/src/VRio.Core/Device/PipeFraming.cs
@@ -0,0 +1,134 @@
+namespace VRio.Core.Device;
+
+///
+/// The framing shared with the DOSBox-X fork's namedpipe serial
+/// backend (its serialnamedpipe.h header comment is the contract's
+/// source of truth on that side). A pipe is a plain byte stream, so serial
+/// data and modem control lines are multiplexed as typed frames:
+///
+///
+/// 0x00 <len:u8> <len bytes> serial data, len ≥ 1 (batching allowed)
+/// 0x01 <lines:u8> the sender's OWN output lines (bit0 DTR,
+/// bit1 RTS); the receiver applies the
+/// null-modem cross: peer DTR → local DSR,
+/// peer RTS → local CTS
+///
+///
+/// Each side sends one lines frame immediately on connect; until it arrives
+/// the peer's lines are assumed low, and a disconnect drops them low again.
+/// Any other frame type is a protocol bug, not line noise — pipes don't drop
+/// bytes — so the receiver logs it and drops the connection instead of
+/// trying to resync.
+///
+public static class PipeFraming
+{
+ public const byte DataType = 0x00;
+ public const byte LinesType = 0x01;
+
+ /// Lines-frame bit: the sender's DTR output.
+ public const byte LineDtr = 0x01;
+
+ /// Lines-frame bit: the sender's RTS output.
+ public const byte LineRts = 0x02;
+
+ /// Both outputs asserted — what a present, powered device reports.
+ public const byte LinesPresent = LineDtr | LineRts;
+
+ /// Build a lines frame carrying .
+ public static byte[] EncodeLines(byte lines) => new[] { LinesType, lines };
+}
+
+///
+/// Incremental decoder for : feed it raw pipe reads,
+/// get one event per complete data frame and one
+/// event per lines frame. Frames may split across reads
+/// at any byte boundary. A malformed stream (unknown type, zero-length data
+/// frame) poisons the decoder: returns false and
+/// says why — drop the connection and
+/// before serving the next one.
+///
+public sealed class PipeFrameDecoder
+{
+ private enum State { Type, Length, Payload, Lines }
+
+ private readonly byte[] _payload = new byte[byte.MaxValue];
+ private State _state;
+ private int _fill, _length;
+
+ ///
+ /// A complete data frame's payload as (buffer, count). The buffer is
+ /// reused across frames — consume it synchronously.
+ ///
+ public event Action? Data;
+
+ /// A lines frame's bits (see ).
+ public event Action? Lines;
+
+ /// Why the stream was rejected, or null while it is healthy.
+ public string? Violation { get; private set; }
+
+ /// Forget any partial frame and clear a violation (new connection).
+ public void Reset()
+ {
+ _state = State.Type;
+ _fill = _length = 0;
+ Violation = null;
+ }
+
+ ///
+ /// Consume received bytes from
+ /// . Returns false when the stream violates the
+ /// framing contract (see ); a poisoned decoder
+ /// keeps returning false until .
+ ///
+ public bool Feed(byte[] buffer, int count)
+ {
+ if (Violation is not null)
+ return false;
+
+ for (int i = 0; i < count; i++)
+ {
+ byte b = buffer[i];
+ switch (_state)
+ {
+ case State.Type when b == PipeFraming.DataType:
+ _state = State.Length;
+ break;
+
+ case State.Type when b == PipeFraming.LinesType:
+ _state = State.Lines;
+ break;
+
+ case State.Type:
+ Violation = $"unknown frame type 0x{b:X2}";
+ return false;
+
+ case State.Length when b == 0:
+ Violation = "zero-length data frame";
+ return false;
+
+ case State.Length:
+ _length = b;
+ _fill = 0;
+ _state = State.Payload;
+ break;
+
+ case State.Payload:
+ _payload[_fill++] = b;
+ if (_fill == _length)
+ {
+ _state = State.Type;
+ Data?.Invoke(_payload, _length);
+ }
+ break;
+
+ case State.Lines:
+ _state = State.Type;
+ Lines?.Invoke(b);
+ break;
+ }
+ }
+
+ return true;
+ }
+}
diff --git a/src/VRio.Core/Device/VRioPipeService.cs b/src/VRio.Core/Device/VRioPipeService.cs
new file mode 100644
index 0000000..6dd92b4
--- /dev/null
+++ b/src/VRio.Core/Device/VRioPipeService.cs
@@ -0,0 +1,389 @@
+using System.Diagnostics;
+using System.IO.Pipes;
+using System.Runtime.InteropServices;
+
+namespace VRio.Core.Device;
+
+///
+/// Serves a over the named pipe that the DOSBox-X
+/// fork's serial1=namedpipe pipe:vrio backend connects to — the
+/// com0com-free transport. vRIO is the pipe server (the device is
+/// always present); DOSBox-X is the client and background-retries while the
+/// pipe is missing, so this service listens for the whole app lifetime
+/// alongside the COM path at no cost. An unconnected pipe is an unplugged
+/// cable: the device keeps running, transmit bytes fall on the floor.
+///
+/// Framing per . On connect we immediately
+/// send our lines frame (DTR+RTS high — "board present", the same lines the
+/// serial path asserts via DtrEnable/RtsEnable); the peer's DTR edges surface
+/// through exactly like the DSR blips through a
+/// null modem, and a disconnect drops all lines low. The game hardware-resets
+/// the RIO by pulsing DTR, and the in-band lines frames keep that edge's
+/// position in the byte stream — the reason the contract multiplexes control
+/// onto the data pipe instead of using a second one.
+///
+/// Outbound bytes are paced one per 10-bit frame time exactly like
+/// (see its remarks for the full rationale).
+/// With the 9600-baud wire gone, this pacer is the only thing standing
+/// between the host and an impossible burst, so each paced byte travels as
+/// its own 3-byte data frame and the inter-byte timing survives the pipe.
+///
+public sealed class VRioPipeService : IDisposable
+{
+ /// The contract pipe name: DOSBox-X connects to \\.\pipe\vrio.
+ public const string DefaultPipeName = "vrio";
+
+ // Pacing mirrors VRioSerialService: one byte per 10-bit frame at 9600
+ // baud; sleep until ~1.8 ms from the slot, then spin the remainder.
+ private static readonly long BytePeriodTicks = Stopwatch.Frequency * 10 / VRioSerialService.BaudRate;
+ private static readonly long SpinThresholdTicks = Stopwatch.Frequency * 18 / 10_000;
+
+ private readonly VRioDevice _device;
+ private readonly string _pipeName;
+ private readonly object _txGate = new();
+ private readonly Queue _txQueue = new();
+ private readonly PipeFrameDecoder _decoder = new();
+
+ private NamedPipeServerStream? _pipe;
+ private Thread? _server;
+ private Thread? _writer;
+ private volatile bool _running;
+ private volatile bool _clientConnected;
+ private bool _peerDtr, _peerRts;
+ private bool _timerResolutionRaised;
+
+ public VRioPipeService(VRioDevice device, string pipeName = DefaultPipeName)
+ {
+ _device = device ?? throw new ArgumentNullException(nameof(device));
+ if (string.IsNullOrWhiteSpace(pipeName))
+ throw new ArgumentException("Pipe name is required.", nameof(pipeName));
+ _pipeName = pipeName;
+
+ _device.Transmit += Write;
+ _decoder.Data += (buffer, count) => _device.OnReceived(buffer, count);
+ _decoder.Lines += OnPeerLines;
+ }
+
+ /// The served pipe name (without the \\.\pipe\ prefix).
+ public string PipeName => _pipeName;
+
+ /// True while a client is connected.
+ public bool IsClientConnected => _clientConnected;
+
+ /// A client connected (true) or went away (false).
+ public event Action? ClientChanged;
+
+ ///
+ /// The host's DTR line changed (the game pulses it to hardware-reset the
+ /// board). Same semantics as :
+ /// the argument is the new line state as seen on our DSR pin.
+ ///
+ public event Action? HostHandshake;
+
+ /// Pipe-level log lines (listen/connect/errors).
+ public event Action? Logged;
+
+ /// Start listening (idempotent). Clients may come and go forever.
+ public void Start()
+ {
+ if (_running)
+ return;
+
+ _running = true;
+ lock (_txGate) _txQueue.Clear();
+
+ _server = new Thread(ServerLoop) { IsBackground = true, Name = $"vRIO pipe server ({_pipeName})" };
+ _server.Start();
+ _writer = new Thread(WriteLoop) { IsBackground = true, Name = $"vRIO pipe writer ({_pipeName})" };
+ _writer.Start();
+
+ Logged?.Invoke($@"Listening on \\.\pipe\{_pipeName} (TX paced at the wire rate) — DOSBox-X connects when it boots");
+ }
+
+ /// Stop listening and drop any client (idempotent).
+ public void Stop()
+ {
+ if (!_running)
+ return;
+
+ _running = false;
+ lock (_txGate)
+ {
+ _txQueue.Clear();
+ Monitor.PulseAll(_txGate); // wake the writer so it can exit
+ }
+
+ NamedPipeServerStream? pipe = _pipe;
+ _pipe = null;
+ try { pipe?.Dispose(); }
+ catch (IOException) { }
+
+ // A WaitForConnection pending on the disposed stream can survive the
+ // Dispose on net48; a throwaway client connect releases it either way.
+ try
+ {
+ using var poke = new NamedPipeClientStream(".", _pipeName, PipeDirection.Out);
+ poke.Connect(100);
+ }
+ catch (Exception ex) when (ex is IOException or TimeoutException or UnauthorizedAccessException) { }
+
+ _server?.Join(1000);
+ _server = null;
+ _writer?.Join(1000);
+ _writer = null;
+
+ if (_timerResolutionRaised)
+ {
+ timeEndPeriod(1);
+ _timerResolutionRaised = false;
+ }
+ _clientConnected = false;
+
+ Logged?.Invoke("Pipe server stopped");
+ }
+
+ private void ServerLoop()
+ {
+ bool busyLogged = false; // log the name collision once, not per retry
+
+ while (_running)
+ {
+ NamedPipeServerStream pipe;
+ try
+ {
+ pipe = new NamedPipeServerStream(_pipeName, PipeDirection.InOut, 1,
+ PipeTransmissionMode.Byte, PipeOptions.Asynchronous);
+ }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
+ {
+ // Name already served — most likely a second vRIO instance.
+ if (!busyLogged)
+ {
+ busyLogged = true;
+ Logged?.Invoke($@"\\.\pipe\{_pipeName} is busy ({ex.Message.TrimEnd('.')}) — retrying until it frees up");
+ }
+ for (int i = 0; i < 20 && _running; i++)
+ Thread.Sleep(100);
+ continue;
+ }
+ busyLogged = false;
+
+ _pipe = pipe;
+ try
+ {
+ pipe.WaitForConnection();
+ }
+ catch (Exception ex) when (ex is IOException or ObjectDisposedException or InvalidOperationException)
+ {
+ // Disposed by Stop(), or a client vanished mid-connect.
+ _pipe = null;
+ try { pipe.Dispose(); }
+ catch (IOException) { }
+ continue;
+ }
+
+ if (!_running || !OnClientConnected(pipe))
+ {
+ _pipe = null;
+ try { pipe.Dispose(); }
+ catch (IOException) { }
+ continue;
+ }
+
+ ReadUntilDisconnect(pipe);
+
+ OnClientDisconnected();
+ _pipe = null;
+ try { pipe.Dispose(); }
+ catch (IOException) { }
+ }
+ }
+
+ /// Per-connection setup; false if the client died before it finished.
+ private bool OnClientConnected(NamedPipeServerStream pipe)
+ {
+ _decoder.Reset();
+ _peerDtr = _peerRts = false;
+ lock (_txGate) _txQueue.Clear();
+
+ // Our lines frame goes first, before the writer can interleave data
+ // (it only writes once _clientConnected flips below): DTR+RTS high,
+ // board present — the peer maps them to its DSR/CTS.
+ try
+ {
+ byte[] lines = PipeFraming.EncodeLines(PipeFraming.LinesPresent);
+ pipe.Write(lines, 0, lines.Length);
+ pipe.Flush();
+ }
+ catch (Exception ex) when (ex is IOException or ObjectDisposedException or InvalidOperationException)
+ {
+ return false;
+ }
+
+ // 1 ms system timer resolution while a client is connected, so the
+ // pacer's Thread.Sleep(1) actually sleeps ~1 ms.
+ _timerResolutionRaised = timeBeginPeriod(1) == 0;
+ _clientConnected = true;
+
+ Logged?.Invoke("Pipe client connected — sent lines DTR+RTS (board present)");
+ ClientChanged?.Invoke(true);
+ return true;
+ }
+
+ private void OnClientDisconnected()
+ {
+ _clientConnected = false;
+ lock (_txGate) _txQueue.Clear();
+
+ if (_timerResolutionRaised)
+ {
+ timeEndPeriod(1);
+ _timerResolutionRaised = false;
+ }
+
+ // Contract: a disconnect drops all lines low on the surviving side.
+ if (_peerDtr)
+ {
+ _peerDtr = false;
+ HostHandshake?.Invoke(false);
+ }
+ _peerRts = false;
+
+ if (_running)
+ {
+ Logged?.Invoke("Pipe client disconnected");
+ ClientChanged?.Invoke(false);
+ }
+ }
+
+ private void ReadUntilDisconnect(NamedPipeServerStream pipe)
+ {
+ var buffer = new byte[512];
+ while (_running)
+ {
+ int n;
+ try
+ {
+ n = pipe.Read(buffer, 0, buffer.Length);
+ }
+ catch (Exception ex) when (ex is IOException or ObjectDisposedException or InvalidOperationException or OperationCanceledException)
+ {
+ if (_running)
+ Logged?.Invoke($"Pipe error: {ex.Message}");
+ return;
+ }
+
+ if (n == 0)
+ return; // client closed its end
+
+ if (!_decoder.Feed(buffer, n))
+ {
+ Logged?.Invoke($"Pipe protocol violation: {_decoder.Violation} — dropping the connection");
+ return;
+ }
+ }
+ }
+
+ private void OnPeerLines(byte lines)
+ {
+ _peerRts = (lines & PipeFraming.LineRts) != 0; // tracked; nothing consumes CTS today
+ bool dtr = (lines & PipeFraming.LineDtr) != 0;
+ if (dtr == _peerDtr)
+ return;
+
+ _peerDtr = dtr;
+ HostHandshake?.Invoke(dtr);
+ }
+
+ // The device's Transmit handler: queue the frame for the paced writer so
+ // the caller (UI click, reader thread mid-reply) never blocks on the pipe.
+ private void Write(byte[] data)
+ {
+ if (!_running || !_clientConnected)
+ return; // no host on the pipe — a real UART shifts into an unterminated line
+
+ lock (_txGate)
+ {
+ foreach (byte b in data)
+ _txQueue.Enqueue(b);
+ Monitor.Pulse(_txGate);
+ }
+ }
+
+ private void WriteLoop()
+ {
+ // One data frame per paced byte, so the pipe carries the same
+ // inter-byte spacing a 9600-baud wire would.
+ var frame = new byte[] { PipeFraming.DataType, 1, 0 };
+ long slot = Stopwatch.GetTimestamp();
+
+ while (_running)
+ {
+ lock (_txGate)
+ {
+ while (_txQueue.Count == 0)
+ {
+ if (!_running)
+ return;
+ Monitor.Wait(_txGate, 200); // timed, so a missed pulse can't wedge shutdown
+ }
+ frame[2] = _txQueue.Dequeue();
+ }
+
+ // This byte's wire slot: one frame after the previous byte, or now
+ // if the line has been idle (no burst "catch-up" debt).
+ slot = Math.Max(slot + BytePeriodTicks, Stopwatch.GetTimestamp());
+ PaceUntil(slot);
+
+ NamedPipeServerStream? pipe = _pipe;
+ if (pipe is null || !_clientConnected)
+ continue; // client left while this byte waited its slot — it's stale, drop it
+
+ try
+ {
+ pipe.Write(frame, 0, frame.Length);
+ pipe.Flush();
+ }
+ catch (Exception ex) when (ex is IOException or ObjectDisposedException or InvalidOperationException)
+ {
+ // The client died mid-write; the backlog is already stale.
+ // Drop it and keep serving — the server loop notices the EOF
+ // and cycles back to WaitForConnection.
+ lock (_txGate) _txQueue.Clear();
+ continue;
+ }
+
+ // If the wait overshot its slot, pace the next byte from the
+ // actual emission instead — a stall must not cause a catch-up burst.
+ long now = Stopwatch.GetTimestamp();
+ if (now > slot)
+ slot = now;
+ }
+ }
+
+ private static void PaceUntil(long slotTicks)
+ {
+ while (true)
+ {
+ long remaining = slotTicks - Stopwatch.GetTimestamp();
+ if (remaining <= 0)
+ return;
+ if (remaining > SpinThresholdTicks)
+ Thread.Sleep(1);
+ else
+ Thread.SpinWait(64);
+ }
+ }
+
+ [DllImport("winmm.dll")]
+ private static extern uint timeBeginPeriod(uint uMilliseconds);
+
+ [DllImport("winmm.dll")]
+ private static extern uint timeEndPeriod(uint uMilliseconds);
+
+ public void Dispose()
+ {
+ _device.Transmit -= Write;
+ Stop();
+ }
+}
diff --git a/tests/VPlasma.Core.Tests/VPlasmaPipeServiceTests.cs b/tests/VPlasma.Core.Tests/VPlasmaPipeServiceTests.cs
new file mode 100644
index 0000000..18b7647
--- /dev/null
+++ b/tests/VPlasma.Core.Tests/VPlasmaPipeServiceTests.cs
@@ -0,0 +1,98 @@
+using System.Diagnostics;
+using System.IO.Pipes;
+using VPlasma.Core.Device;
+using Xunit;
+
+namespace VPlasma.Core.Tests;
+
+public class VPlasmaPipeServiceTests
+{
+ private static string UniqueName() => $"vplasma-test-{Guid.NewGuid():N}";
+
+ private static void WaitFor(Func condition, int timeoutMs = 2000)
+ {
+ var clock = Stopwatch.StartNew();
+ while (!condition())
+ {
+ Assert.True(clock.ElapsedMilliseconds < timeoutMs, "timed out waiting for a condition");
+ Thread.Sleep(10);
+ }
+ }
+
+ private static NamedPipeClientStream Connect(string name)
+ {
+ var pipe = new NamedPipeClientStream(".", name, PipeDirection.InOut, PipeOptions.Asynchronous);
+ var clock = Stopwatch.StartNew();
+ while (true)
+ {
+ try
+ {
+ pipe.Connect(200);
+ return pipe;
+ }
+ catch (Exception ex) when (ex is IOException or TimeoutException)
+ {
+ if (clock.ElapsedMilliseconds >= 3000)
+ throw;
+ Thread.Sleep(25);
+ }
+ }
+ }
+
+ [Fact]
+ public void Framed_text_reaches_the_display_and_lines_are_ignored()
+ {
+ string name = UniqueName();
+ var device = new VPlasmaDevice();
+ using var service = new VPlasmaPipeService(device, name);
+ service.Start();
+
+ using var client = Connect(name);
+ var hello = new byte[2];
+ Assert.Equal(1, client.Read(hello, 0, 1)); // on-connect lines frame,
+ Assert.Equal(1, client.Read(hello, 1, 1)); // possibly split
+ Assert.Equal(new byte[] { 0x01, 0x03 }, hello); // DTR+RTS: display present
+
+ // RTS chatter (DOS console redirection toggles it per byte) then "HI",
+ // the data frame torn in half mid-payload.
+ client.Write(new byte[] { 0x01, 0x02, 0x00, 0x02, (byte)'H' }, 0, 5);
+ client.Flush();
+ Thread.Sleep(20);
+ client.Write(new byte[] { (byte)'I' }, 0, 1);
+ client.Flush();
+
+ WaitFor(() => device.BytesReceived == 2);
+ Assert.Equal(2, device.TextCharsDrawn);
+ }
+
+ [Fact]
+ public void Unknown_frame_type_drops_the_client_and_server_recovers()
+ {
+ string name = UniqueName();
+ var device = new VPlasmaDevice();
+ using var service = new VPlasmaPipeService(device, name);
+ service.Start();
+
+ using (var bad = Connect(name))
+ {
+ var hello = new byte[2];
+ bad.Read(hello, 0, 2);
+ bad.Write(new byte[] { 0x7F }, 0, 1); // not a frame type
+ bad.Flush();
+ // Contract: log + drop, no resync — our next read sees the close.
+ var eof = Task.Run(() =>
+ {
+ 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);
+ }
+
+ using var good = Connect(name);
+ var again = new byte[2];
+ Assert.Equal(1, good.Read(again, 0, 1));
+ Assert.Equal(1, good.Read(again, 1, 1));
+ Assert.Equal(new byte[] { 0x01, 0x03 }, again);
+ }
+}
diff --git a/tests/VRio.Core.Tests/PipeServiceTests.cs b/tests/VRio.Core.Tests/PipeServiceTests.cs
new file mode 100644
index 0000000..2ecfbb5
--- /dev/null
+++ b/tests/VRio.Core.Tests/PipeServiceTests.cs
@@ -0,0 +1,327 @@
+using System.Diagnostics;
+using System.IO.Pipes;
+using VRio.Core.Device;
+using VRio.Core.Protocol;
+using Xunit;
+
+namespace VRio.Core.Tests;
+
+public class PipeFrameDecoderTests
+{
+ [Fact]
+ public void Data_frame_delivers_payload()
+ {
+ var decoder = new PipeFrameDecoder();
+ byte[]? got = null;
+ decoder.Data += (buf, count) => got = buf.Take(count).ToArray();
+
+ Assert.True(decoder.Feed(new byte[] { 0x00, 0x03, 0xAA, 0xBB, 0xCC }, 5));
+ Assert.Equal(new byte[] { 0xAA, 0xBB, 0xCC }, got);
+ }
+
+ [Fact]
+ public void Frames_survive_any_split_across_reads()
+ {
+ var decoder = new PipeFrameDecoder();
+ var data = new List();
+ var lines = new List();
+ decoder.Data += (buf, count) => data.AddRange(buf.Take(count));
+ decoder.Lines += lines.Add;
+
+ // A lines frame, a 2-byte data frame, a 1-byte data frame — one byte at a time.
+ byte[] stream = { 0x01, 0x03, 0x00, 0x02, 0x81, 0x01, 0x00, 0x01, 0xFC };
+ foreach (byte b in stream)
+ Assert.True(decoder.Feed(new[] { b }, 1));
+
+ Assert.Equal(new byte[] { 0x03 }, lines);
+ Assert.Equal(new byte[] { 0x81, 0x01, 0xFC }, data);
+ }
+
+ [Fact]
+ public void Multiple_frames_in_one_read_all_deliver()
+ {
+ var decoder = new PipeFrameDecoder();
+ var data = new List();
+ var lines = new List();
+ decoder.Data += (buf, count) => data.AddRange(buf.Take(count));
+ decoder.Lines += lines.Add;
+
+ byte[] chunk = { 0x00, 0x01, 0x42, 0x01, 0x00, 0x00, 0x01, 0x43 };
+ Assert.True(decoder.Feed(chunk, chunk.Length));
+
+ Assert.Equal(new byte[] { 0x42, 0x43 }, data);
+ Assert.Equal(new byte[] { 0x00 }, lines);
+ }
+
+ [Fact]
+ public void Unknown_frame_type_poisons_the_decoder()
+ {
+ var decoder = new PipeFrameDecoder();
+
+ Assert.False(decoder.Feed(new byte[] { 0x7F }, 1));
+ Assert.Contains("0x7F", decoder.Violation);
+ // Poisoned: even a healthy frame is rejected until Reset.
+ Assert.False(decoder.Feed(new byte[] { 0x00, 0x01, 0x42 }, 3));
+
+ decoder.Reset();
+ Assert.Null(decoder.Violation);
+ Assert.True(decoder.Feed(new byte[] { 0x00, 0x01, 0x42 }, 3));
+ }
+
+ [Fact]
+ public void Zero_length_data_frame_is_a_violation()
+ {
+ var decoder = new PipeFrameDecoder();
+ Assert.False(decoder.Feed(new byte[] { 0x00, 0x00 }, 2));
+ Assert.Contains("zero-length", decoder.Violation);
+ }
+}
+
+public class VRioPipeServiceTests
+{
+ ///
+ /// A contract-speaking client: connects to the service's pipe, deframes
+ /// everything it receives (timestamping data bytes for the pacing test),
+ /// and sends raw frame bytes.
+ ///
+ private sealed class TestClient : IDisposable
+ {
+ private readonly NamedPipeClientStream _pipe;
+ private readonly Thread _reader;
+ private readonly object _sync = new();
+ private readonly List _data = new();
+ private readonly List _dataTicks = new();
+ private readonly List _lines = new();
+ private volatile bool _closed;
+
+ public TestClient(string pipeName)
+ {
+ _pipe = new NamedPipeClientStream(".", pipeName, PipeDirection.InOut,
+ PipeOptions.Asynchronous);
+
+ // The server's listener may still be spinning up; retry briefly.
+ var connectClock = Stopwatch.StartNew();
+ while (true)
+ {
+ try
+ {
+ _pipe.Connect(200);
+ break;
+ }
+ catch (Exception ex) when (ex is IOException or TimeoutException)
+ {
+ if (connectClock.ElapsedMilliseconds >= 3000)
+ throw;
+ Thread.Sleep(25);
+ }
+ }
+
+ var decoder = new PipeFrameDecoder();
+ decoder.Data += (buf, count) =>
+ {
+ long now = Stopwatch.GetTimestamp();
+ lock (_sync)
+ for (int i = 0; i < count; i++)
+ {
+ _data.Add(buf[i]);
+ _dataTicks.Add(now);
+ }
+ };
+ decoder.Lines += b => { lock (_sync) _lines.Add(b); };
+
+ _reader = new Thread(() =>
+ {
+ var buffer = new byte[512];
+ while (true)
+ {
+ int n;
+ try { n = _pipe.Read(buffer, 0, buffer.Length); }
+ catch (Exception ex) when (ex is IOException or ObjectDisposedException) { break; }
+ if (n == 0 || !decoder.Feed(buffer, n))
+ break;
+ }
+ _closed = true;
+ })
+ { IsBackground = true };
+ _reader.Start();
+ }
+
+ public bool Closed => _closed;
+ public byte[] Data { get { lock (_sync) return _data.ToArray(); } }
+ public long[] DataTicks { get { lock (_sync) return _dataTicks.ToArray(); } }
+ public byte[] Lines { get { lock (_sync) return _lines.ToArray(); } }
+
+ public void SendRaw(params byte[] bytes)
+ {
+ _pipe.Write(bytes, 0, bytes.Length);
+ _pipe.Flush();
+ }
+
+ public void SendData(byte[] payload)
+ {
+ var frame = new byte[2 + payload.Length];
+ frame[0] = PipeFraming.DataType;
+ frame[1] = (byte)payload.Length;
+ payload.CopyTo(frame, 2);
+ SendRaw(frame);
+ }
+
+ public void Dispose()
+ {
+ _pipe.Dispose();
+ _reader.Join(1000);
+ }
+ }
+
+ private static string UniqueName() => $"vrio-test-{Guid.NewGuid():N}";
+
+ private static void WaitFor(Func condition, int timeoutMs = 2000)
+ {
+ var clock = Stopwatch.StartNew();
+ while (!condition())
+ {
+ Assert.True(clock.ElapsedMilliseconds < timeoutMs, "timed out waiting for a condition");
+ Thread.Sleep(10);
+ }
+ }
+
+ [Fact]
+ public void Client_gets_board_present_lines_on_connect()
+ {
+ string name = UniqueName();
+ var device = new VRioDevice();
+ using var service = new VRioPipeService(device, name);
+ service.Start();
+
+ using var client = new TestClient(name);
+ WaitFor(() => client.Lines.Length > 0);
+
+ Assert.Equal(PipeFraming.LinesPresent, client.Lines[0]); // DTR+RTS: board present
+ }
+
+ [Fact]
+ public void Peer_dtr_edges_surface_as_host_handshake()
+ {
+ string name = UniqueName();
+ var device = new VRioDevice();
+ using var service = new VRioPipeService(device, name);
+ var edges = new List();
+ service.HostHandshake += high => { lock (edges) edges.Add(high); };
+ service.Start();
+
+ using var client = new TestClient(name);
+ client.SendRaw(PipeFraming.LinesType, PipeFraming.LineDtr); // DTR up
+ client.SendRaw(PipeFraming.LinesType, 0x00); // DTR down (the reset pulse)
+
+ WaitFor(() => { lock (edges) return edges.Count == 2; });
+ lock (edges)
+ Assert.Equal(new[] { true, false }, edges);
+ }
+
+ [Fact]
+ public void Version_request_round_trips_ack_then_reply()
+ {
+ string name = UniqueName();
+ var device = new VRioDevice();
+ using var service = new VRioPipeService(device, name);
+ service.Start();
+
+ using var client = new TestClient(name);
+ WaitFor(() => client.Lines.Length > 0); // fully connected
+ client.SendData(PacketBuilder.Build(RioCommand.VersionRequest));
+
+ byte[] expected = new[] { (byte)RioControl.Ack }
+ .Concat(PacketBuilder.VersionReply(4, 2)).ToArray();
+ WaitFor(() => client.Data.Length >= expected.Length);
+
+ Assert.Equal(expected, client.Data);
+ }
+
+ [Fact]
+ public void Reply_bytes_are_paced_at_the_wire_rate()
+ {
+ string name = UniqueName();
+ var device = new VRioDevice();
+ using var service = new VRioPipeService(device, name);
+ service.Start();
+
+ using var client = new TestClient(name);
+ WaitFor(() => client.Lines.Length > 0);
+ client.SendData(PacketBuilder.Build(RioCommand.VersionRequest));
+
+ // ACK + 4-byte VersionReply = 5 bytes = 4 inter-byte gaps ≈ 4.2 ms at
+ // 9600 baud. An unpaced writer would land them in well under 1 ms.
+ WaitFor(() => client.Data.Length >= 5);
+ long[] ticks = client.DataTicks;
+ double spanMs = (ticks[4] - ticks[0]) * 1000.0 / Stopwatch.Frequency;
+
+ Assert.True(spanMs >= 2.5, $"5 reply bytes arrived in {spanMs:F2} ms — pipe TX is not paced");
+ }
+
+ [Fact]
+ public void Unknown_frame_type_drops_the_client_and_server_recovers()
+ {
+ string name = UniqueName();
+ var device = new VRioDevice();
+ using var service = new VRioPipeService(device, name);
+ service.Start();
+
+ using (var bad = new TestClient(name))
+ {
+ WaitFor(() => bad.Lines.Length > 0);
+ bad.SendRaw(0x7F); // not a frame type
+ WaitFor(() => bad.Closed); // contract: log + drop, no resync
+ }
+
+ // The listener must come back for the next client.
+ using var good = new TestClient(name);
+ WaitFor(() => good.Lines.Length > 0);
+ Assert.Equal(PipeFraming.LinesPresent, good.Lines[0]);
+ }
+
+ [Fact]
+ public void Client_disconnect_drops_dtr_low()
+ {
+ string name = UniqueName();
+ var device = new VRioDevice();
+ using var service = new VRioPipeService(device, name);
+ var edges = new List();
+ service.HostHandshake += high => { lock (edges) edges.Add(high); };
+ service.Start();
+
+ using (var client = new TestClient(name))
+ {
+ client.SendRaw(PipeFraming.LinesType, PipeFraming.LineDtr);
+ WaitFor(() => { lock (edges) return edges.Count == 1; });
+ } // client leaves with DTR still high
+
+ WaitFor(() => { lock (edges) return edges.Count == 2; });
+ lock (edges)
+ Assert.Equal(new[] { true, false }, edges);
+ }
+
+ [Fact]
+ public void Data_split_mid_frame_still_reaches_the_device()
+ {
+ string name = UniqueName();
+ var device = new VRioDevice();
+ using var service = new VRioPipeService(device, name);
+ var lamps = new List<(int Address, byte State)>();
+ device.LampChanged += (address, state) => { lock (lamps) lamps.Add((address, state)); };
+ service.Start();
+
+ using var client = new TestClient(name);
+ WaitFor(() => client.Lines.Length > 0);
+
+ // LampRequest 0x00 ← 0x01, framed, then torn in half mid-payload.
+ byte[] packet = PacketBuilder.Build(RioCommand.LampRequest, new byte[] { 0x00, 0x01 });
+ var frame = new byte[] { PipeFraming.DataType, (byte)packet.Length }.Concat(packet).ToArray();
+ client.SendRaw(frame.Take(3).ToArray());
+ Thread.Sleep(20);
+ client.SendRaw(frame.Skip(3).ToArray());
+
+ WaitFor(() => { lock (lamps) return lamps.Count == 1; });
+ lock (lamps)
+ Assert.Equal((0x00, (byte)0x01), lamps[0]);
+ }
+}