Named-pipe transport for the DOSBox-X fork's namedpipe backend

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>
This commit is contained in:
Cyd
2026-07-12 23:03:02 -05:00
co-authored by Claude Fable 5
parent 0674cf5ba4
commit e507f1740c
8 changed files with 1247 additions and 0 deletions
@@ -0,0 +1,237 @@
using System.IO.Pipes;
namespace VPlasma.Core.Device;
/// <summary>
/// Serves a <see cref="VPlasmaDevice"/> over the named pipe that the
/// DOSBox-X fork's <c>serial2=namedpipe pipe:vplasma</c> backend connects
/// to — the com0com-free transport. We are the pipe <em>server</em> (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.
///
/// <para>Framing is the contract shared with the fork (see VRio.Core's
/// <c>PipeFraming</c>, and <c>serialnamedpipe.h</c> on the fork side):
/// <c>0x00 len bytes</c> carries serial data, <c>0x01 bits</c> 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.</para>
/// </summary>
public sealed class VPlasmaPipeService : IDisposable
{
/// <summary>The contract pipe name: DOSBox-X connects to <c>\\.\pipe\vplasma</c>.</summary>
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;
}
/// <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>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;
_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");
}
/// <summary>Stop listening and drop any client (idempotent).</summary>
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();
}