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