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:
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user