Both devices now serve \.\pipe\vrio and \.\pipe\vplasma for the whole app lifetime alongside the COM rows — the com0com-free path. Framing per the pinned contract (0x00 len data / 0x01 DTR+RTS lines, one lines frame on connect, unknown type = log + drop): PipeFraming/PipeFrameDecoder and VRioPipeService (TX paced at the wire rate, peer DTR edges feed HostHandshake) in VRio.Core, listener-only VPlasmaPipeService twin in VPlasma.Core. Busy pipe names retry, so vRIO's built-in glass and the standalone vPLASMA coexist. README documents the transport and the fork conf lines. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
390 lines
13 KiB
C#
390 lines
13 KiB
C#
using System.Diagnostics;
|
|
using System.IO.Pipes;
|
|
using System.Runtime.InteropServices;
|
|
|
|
namespace VRio.Core.Device;
|
|
|
|
/// <summary>
|
|
/// Serves a <see cref="VRioDevice"/> over the named pipe that the DOSBox-X
|
|
/// fork's <c>serial1=namedpipe pipe:vrio</c> backend connects to — the
|
|
/// com0com-free transport. vRIO is the pipe <em>server</em> (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.
|
|
///
|
|
/// <para>Framing per <see cref="PipeFraming"/>. 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 <see cref="HostHandshake"/> 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.</para>
|
|
///
|
|
/// <para>Outbound bytes are paced one per 10-bit frame time exactly like
|
|
/// <see cref="VRioSerialService"/> (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.</para>
|
|
/// </summary>
|
|
public sealed class VRioPipeService : IDisposable
|
|
{
|
|
/// <summary>The contract pipe name: DOSBox-X connects to <c>\\.\pipe\vrio</c>.</summary>
|
|
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<byte> _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;
|
|
}
|
|
|
|
/// <summary>The served pipe name (without the <c>\\.\pipe\</c> prefix).</summary>
|
|
public string PipeName => _pipeName;
|
|
|
|
/// <summary>True while a client is connected.</summary>
|
|
public bool IsClientConnected => _clientConnected;
|
|
|
|
/// <summary>A client connected (true) or went away (false).</summary>
|
|
public event Action<bool>? ClientChanged;
|
|
|
|
/// <summary>
|
|
/// The host's DTR line changed (the game pulses it to hardware-reset the
|
|
/// board). Same semantics as <see cref="VRioSerialService.HostHandshake"/>:
|
|
/// the argument is the new line state as seen on our DSR pin.
|
|
/// </summary>
|
|
public event Action<bool>? HostHandshake;
|
|
|
|
/// <summary>Pipe-level log lines (listen/connect/errors).</summary>
|
|
public event Action<string>? Logged;
|
|
|
|
/// <summary>Start listening (idempotent). Clients may come and go forever.</summary>
|
|
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");
|
|
}
|
|
|
|
/// <summary>Stop listening and drop any client (idempotent).</summary>
|
|
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) { }
|
|
}
|
|
}
|
|
|
|
/// <summary>Per-connection setup; false if the client died before it finished.</summary>
|
|
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();
|
|
}
|
|
}
|