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