Each device row now serves one endpoint at a time, picked from a list of pipe:vrio / pipe:vplasma (first, default) plus the machine's COM ports — nothing opens automatically at startup. The pipe services gained an IsListening property and are started/stopped by the row's Open/Close button instead of running for the whole app lifetime, which also retires the dual-transport warning. Status line shows the active endpoint; OFFLINE when none. Standalone vPLASMA is unchanged (hardwired COM12 + permanent pipe). Also fixes the xUnit1031 warnings in the plasma pipe tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
241 lines
8.7 KiB
C#
241 lines
8.7 KiB
C#
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 the server is up (whether or not a client is connected).</summary>
|
|
public bool IsListening => _running;
|
|
|
|
/// <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();
|
|
}
|