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:
@@ -103,6 +103,33 @@ analog polling drives the "analog polls served" counter, and every click on
|
||||
the vRIO panel arrives at RIOJoy as real cockpit input. Two physical PCs with
|
||||
a null-modem cable work the same way.
|
||||
|
||||
## Named pipes for DOSBox-X (no com0com)
|
||||
|
||||
When the game runs in the patched DOSBox-X (the fork's `namedpipe` serial
|
||||
backend), the virtual ports aren't needed at all: both devices also serve a
|
||||
named pipe for the whole app lifetime — `\\.\pipe\vrio` and
|
||||
`\\.\pipe\vplasma` — and DOSBox-X connects as the client, retrying in the
|
||||
background until the pipe exists:
|
||||
|
||||
```ini
|
||||
[serial]
|
||||
serial1 = namedpipe pipe:vrio rxpollus:100 rxburst:16
|
||||
serial2 = namedpipe pipe:vplasma
|
||||
```
|
||||
|
||||
A pipe is a plain byte stream, so serial data and modem lines travel as
|
||||
typed frames (`0x00 len bytes` data, `0x01 bits` DTR/RTS) — the game's DTR
|
||||
reset pulse keeps its exact position in the byte stream, and a disconnect
|
||||
reads as all-lines-low (cable unplugged). The contract lives in
|
||||
`src/VRio.Core/Device/PipeFraming.cs` on this side and `serialnamedpipe.h`
|
||||
on the fork's. TX is paced exactly like the COM path — with the 9600-baud
|
||||
wire gone, the pacer is the only thing between the host and an impossible
|
||||
burst. The COM rows keep working unchanged (RIOJoy, real pods); if a pipe
|
||||
client connects while a COM host is active the wire log warns, since the
|
||||
RIO is a single-host device. vRIO's built-in glass and the standalone
|
||||
vPLASMA share the `vplasma` pipe name — whoever starts second retries until
|
||||
the name frees up.
|
||||
|
||||
## vPLASMA — the companion plasma display
|
||||
|
||||
The cockpit's second serial device is the **plasma display**: a 128×32
|
||||
|
||||
@@ -7,6 +7,9 @@ namespace VPlasma.App;
|
||||
/// device end is hardwired to <see cref="PortName"/> (the plasma side of the
|
||||
/// second com0com pair) and opened automatically — a retry timer keeps
|
||||
/// trying while the port is missing or busy, and reopens it if it dies. The
|
||||
/// DOSBox-X fork's named-pipe transport (<c>\\.\pipe\vplasma</c>) is served
|
||||
/// permanently alongside it (if vRIO's built-in glass already holds the
|
||||
/// pipe name, the server just retries until it frees up). The
|
||||
/// title bar carries the port status; double-clicking the glass cycles the
|
||||
/// self-test pages (banner, charset, graphics) through the wire parser, and
|
||||
/// a right-click resets the display to its power-on state.
|
||||
@@ -18,6 +21,7 @@ internal sealed class MainForm : Form
|
||||
|
||||
private readonly VPlasmaDevice _device = new();
|
||||
private readonly VPlasmaSerialService _service;
|
||||
private readonly VPlasmaPipeService _pipeService;
|
||||
private readonly PlasmaCanvas _canvas = new();
|
||||
|
||||
private readonly System.Windows.Forms.Timer _reconnectTimer = new() { Interval = 3000 };
|
||||
@@ -32,6 +36,7 @@ internal sealed class MainForm : Form
|
||||
StartPosition = FormStartPosition.CenterScreen;
|
||||
|
||||
_service = new VPlasmaSerialService(_device);
|
||||
_pipeService = new VPlasmaPipeService(_device);
|
||||
|
||||
_canvas.Location = new Point(0, 0);
|
||||
Controls.Add(_canvas);
|
||||
@@ -39,6 +44,7 @@ internal sealed class MainForm : Form
|
||||
// Device / service events arrive on the serial reader thread; marshal to the UI.
|
||||
_device.Updated += () => RunOnUi(() => _canvas.UpdateFrom(_device));
|
||||
_service.ConnectionChanged += _ => RunOnUi(UpdateTitle);
|
||||
_pipeService.ClientChanged += _ => RunOnUi(UpdateTitle);
|
||||
|
||||
_canvas.DoubleClick += (_, _) => RunSelfTest();
|
||||
_canvas.MouseClick += (_, e) =>
|
||||
@@ -55,10 +61,12 @@ internal sealed class MainForm : Form
|
||||
{
|
||||
_reconnectTimer.Dispose();
|
||||
_service.Dispose();
|
||||
_pipeService.Dispose();
|
||||
};
|
||||
|
||||
_canvas.UpdateFrom(_device);
|
||||
EnsureOpen();
|
||||
_pipeService.Start();
|
||||
}
|
||||
|
||||
private void EnsureOpen()
|
||||
@@ -82,6 +90,8 @@ internal sealed class MainForm : Form
|
||||
string status = _service.IsOpen
|
||||
? $"{PortName} @ 9600 8N1"
|
||||
: $"{PortName} unavailable — retrying";
|
||||
if (_pipeService.IsClientConnected)
|
||||
status += " • pipe host connected";
|
||||
Text = $"vPLASMA v{Application.ProductVersion} — {status}";
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -18,6 +18,9 @@ namespace VRio.App;
|
||||
/// RIOJoy at the other end of the null-modem pair, and every click here
|
||||
/// arrives at RIOJoy exactly as if the physical cockpit sent it; point the
|
||||
/// game's COM2 passthrough at the plasma pair and the glass lights up.
|
||||
/// Both devices also serve the DOSBox-X fork's named-pipe transport
|
||||
/// (<c>\\.\pipe\vrio</c>, <c>\\.\pipe\vplasma</c>) for the whole app
|
||||
/// lifetime — the com0com-free path.
|
||||
/// </summary>
|
||||
internal sealed class MainForm : Form
|
||||
{
|
||||
@@ -36,8 +39,10 @@ internal sealed class MainForm : Form
|
||||
|
||||
private readonly VRioDevice _device = new();
|
||||
private readonly VRioSerialService _service;
|
||||
private readonly VRioPipeService _pipeService;
|
||||
private readonly VPlasmaDevice _plasmaDevice = new();
|
||||
private readonly VPlasmaSerialService _plasmaService;
|
||||
private readonly VPlasmaPipeService _plasmaPipeService;
|
||||
private readonly PanelCanvas _canvas = new();
|
||||
private readonly PlasmaCanvas _plasmaCanvas = new(PanelCanvas.PlasmaDotPitch)
|
||||
{
|
||||
@@ -190,6 +195,11 @@ internal sealed class MainForm : Form
|
||||
|
||||
_service = new VRioSerialService(_device);
|
||||
_plasmaService = new VPlasmaSerialService(_plasmaDevice);
|
||||
// The named-pipe endpoints for DOSBox-X's namedpipe serial backend.
|
||||
// Unlike a COM port, a listening pipe costs nothing and conflicts with
|
||||
// nothing, so both servers run for the whole app lifetime.
|
||||
_pipeService = new VRioPipeService(_device);
|
||||
_plasmaPipeService = new VPlasmaPipeService(_plasmaDevice);
|
||||
_router = new InputRouter(_device);
|
||||
_lampMirror = new KeyboardLampMirror(_device);
|
||||
|
||||
@@ -224,6 +234,17 @@ internal sealed class MainForm : Form
|
||||
_service.ConnectionChanged += open => RunOnUi(() => OnConnectionChanged(open));
|
||||
_service.HostHandshake += high => RunOnUi(() =>
|
||||
PrependLog(high ? "Host raised DTR (board-reset handshake)" : "Host dropped DTR"));
|
||||
_pipeService.Logged += line => RunOnUi(() => PrependLog($"pipe: {line}"));
|
||||
_pipeService.HostHandshake += high => RunOnUi(() =>
|
||||
PrependLog(high ? "Pipe host raised DTR (board-reset handshake)" : "Pipe host dropped DTR"));
|
||||
_pipeService.ClientChanged += connected => RunOnUi(() =>
|
||||
{
|
||||
// The RIO is a single-host device; two live transports means two
|
||||
// hosts fighting over one board.
|
||||
if (connected && _service.IsOpen)
|
||||
PrependLog($"Warning: a pipe client connected while {_service.PortName} is open — close one transport.");
|
||||
});
|
||||
_plasmaPipeService.Logged += line => RunOnUi(() => PrependLog($"vPLASMA pipe: {line}"));
|
||||
|
||||
// Built-in plasma glass: a pure listener on its own port, same gestures
|
||||
// as the standalone vPLASMA — double-click cycles the self-test pages,
|
||||
@@ -305,6 +326,8 @@ internal sealed class MainForm : Form
|
||||
_rawKeyboard.Dispose();
|
||||
_service.Dispose();
|
||||
_plasmaService.Dispose();
|
||||
_pipeService.Dispose();
|
||||
_plasmaPipeService.Dispose();
|
||||
};
|
||||
|
||||
RefreshPorts();
|
||||
@@ -313,6 +336,8 @@ internal sealed class MainForm : Form
|
||||
LoadBindings();
|
||||
_plasmaCanvas.UpdateFrom(_plasmaDevice); // paint the power-on frame
|
||||
AutoOpenPreferredPorts();
|
||||
_pipeService.Start();
|
||||
_plasmaPipeService.Start();
|
||||
}
|
||||
|
||||
private Panel BuildControlStrip()
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
namespace VRio.Core.Device;
|
||||
|
||||
/// <summary>
|
||||
/// The framing shared with the DOSBox-X fork's <c>namedpipe</c> serial
|
||||
/// backend (its <c>serialnamedpipe.h</c> header comment is the contract's
|
||||
/// source of truth on that side). A pipe is a plain byte stream, so serial
|
||||
/// data and modem control lines are multiplexed as typed frames:
|
||||
///
|
||||
/// <code>
|
||||
/// 0x00 <len:u8> <len bytes> serial data, len ≥ 1 (batching allowed)
|
||||
/// 0x01 <lines:u8> the sender's OWN output lines (bit0 DTR,
|
||||
/// bit1 RTS); the receiver applies the
|
||||
/// null-modem cross: peer DTR → local DSR,
|
||||
/// peer RTS → local CTS
|
||||
/// </code>
|
||||
///
|
||||
/// Each side sends one lines frame immediately on connect; until it arrives
|
||||
/// the peer's lines are assumed low, and a disconnect drops them low again.
|
||||
/// Any other frame type is a protocol bug, not line noise — pipes don't drop
|
||||
/// bytes — so the receiver logs it and drops the connection instead of
|
||||
/// trying to resync.
|
||||
/// </summary>
|
||||
public static class PipeFraming
|
||||
{
|
||||
public const byte DataType = 0x00;
|
||||
public const byte LinesType = 0x01;
|
||||
|
||||
/// <summary>Lines-frame bit: the sender's DTR output.</summary>
|
||||
public const byte LineDtr = 0x01;
|
||||
|
||||
/// <summary>Lines-frame bit: the sender's RTS output.</summary>
|
||||
public const byte LineRts = 0x02;
|
||||
|
||||
/// <summary>Both outputs asserted — what a present, powered device reports.</summary>
|
||||
public const byte LinesPresent = LineDtr | LineRts;
|
||||
|
||||
/// <summary>Build a lines frame carrying <paramref name="lines"/>.</summary>
|
||||
public static byte[] EncodeLines(byte lines) => new[] { LinesType, lines };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Incremental decoder for <see cref="PipeFraming"/>: feed it raw pipe reads,
|
||||
/// get one <see cref="Data"/> event per complete data frame and one
|
||||
/// <see cref="Lines"/> event per lines frame. Frames may split across reads
|
||||
/// at any byte boundary. A malformed stream (unknown type, zero-length data
|
||||
/// frame) poisons the decoder: <see cref="Feed"/> returns false and
|
||||
/// <see cref="Violation"/> says why — drop the connection and
|
||||
/// <see cref="Reset"/> before serving the next one.
|
||||
/// </summary>
|
||||
public sealed class PipeFrameDecoder
|
||||
{
|
||||
private enum State { Type, Length, Payload, Lines }
|
||||
|
||||
private readonly byte[] _payload = new byte[byte.MaxValue];
|
||||
private State _state;
|
||||
private int _fill, _length;
|
||||
|
||||
/// <summary>
|
||||
/// A complete data frame's payload as (buffer, count). The buffer is
|
||||
/// reused across frames — consume it synchronously.
|
||||
/// </summary>
|
||||
public event Action<byte[], int>? Data;
|
||||
|
||||
/// <summary>A lines frame's bits (see <see cref="PipeFraming.LineDtr"/>).</summary>
|
||||
public event Action<byte>? Lines;
|
||||
|
||||
/// <summary>Why the stream was rejected, or null while it is healthy.</summary>
|
||||
public string? Violation { get; private set; }
|
||||
|
||||
/// <summary>Forget any partial frame and clear a violation (new connection).</summary>
|
||||
public void Reset()
|
||||
{
|
||||
_state = State.Type;
|
||||
_fill = _length = 0;
|
||||
Violation = null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Consume <paramref name="count"/> received bytes from
|
||||
/// <paramref name="buffer"/>. Returns false when the stream violates the
|
||||
/// framing contract (see <see cref="Violation"/>); a poisoned decoder
|
||||
/// keeps returning false until <see cref="Reset"/>.
|
||||
/// </summary>
|
||||
public bool Feed(byte[] buffer, int count)
|
||||
{
|
||||
if (Violation is not null)
|
||||
return false;
|
||||
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
byte b = buffer[i];
|
||||
switch (_state)
|
||||
{
|
||||
case State.Type when b == PipeFraming.DataType:
|
||||
_state = State.Length;
|
||||
break;
|
||||
|
||||
case State.Type when b == PipeFraming.LinesType:
|
||||
_state = State.Lines;
|
||||
break;
|
||||
|
||||
case State.Type:
|
||||
Violation = $"unknown frame type 0x{b:X2}";
|
||||
return false;
|
||||
|
||||
case State.Length when b == 0:
|
||||
Violation = "zero-length data frame";
|
||||
return false;
|
||||
|
||||
case State.Length:
|
||||
_length = b;
|
||||
_fill = 0;
|
||||
_state = State.Payload;
|
||||
break;
|
||||
|
||||
case State.Payload:
|
||||
_payload[_fill++] = b;
|
||||
if (_fill == _length)
|
||||
{
|
||||
_state = State.Type;
|
||||
Data?.Invoke(_payload, _length);
|
||||
}
|
||||
break;
|
||||
|
||||
case State.Lines:
|
||||
_state = State.Type;
|
||||
Lines?.Invoke(b);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,389 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
using System.Diagnostics;
|
||||
using System.IO.Pipes;
|
||||
using VPlasma.Core.Device;
|
||||
using Xunit;
|
||||
|
||||
namespace VPlasma.Core.Tests;
|
||||
|
||||
public class VPlasmaPipeServiceTests
|
||||
{
|
||||
private static string UniqueName() => $"vplasma-test-{Guid.NewGuid():N}";
|
||||
|
||||
private static void WaitFor(Func<bool> condition, int timeoutMs = 2000)
|
||||
{
|
||||
var clock = Stopwatch.StartNew();
|
||||
while (!condition())
|
||||
{
|
||||
Assert.True(clock.ElapsedMilliseconds < timeoutMs, "timed out waiting for a condition");
|
||||
Thread.Sleep(10);
|
||||
}
|
||||
}
|
||||
|
||||
private static NamedPipeClientStream Connect(string name)
|
||||
{
|
||||
var pipe = new NamedPipeClientStream(".", name, PipeDirection.InOut, PipeOptions.Asynchronous);
|
||||
var clock = Stopwatch.StartNew();
|
||||
while (true)
|
||||
{
|
||||
try
|
||||
{
|
||||
pipe.Connect(200);
|
||||
return pipe;
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or TimeoutException)
|
||||
{
|
||||
if (clock.ElapsedMilliseconds >= 3000)
|
||||
throw;
|
||||
Thread.Sleep(25);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Framed_text_reaches_the_display_and_lines_are_ignored()
|
||||
{
|
||||
string name = UniqueName();
|
||||
var device = new VPlasmaDevice();
|
||||
using var service = new VPlasmaPipeService(device, name);
|
||||
service.Start();
|
||||
|
||||
using var client = Connect(name);
|
||||
var hello = new byte[2];
|
||||
Assert.Equal(1, client.Read(hello, 0, 1)); // on-connect lines frame,
|
||||
Assert.Equal(1, client.Read(hello, 1, 1)); // possibly split
|
||||
Assert.Equal(new byte[] { 0x01, 0x03 }, hello); // DTR+RTS: display present
|
||||
|
||||
// RTS chatter (DOS console redirection toggles it per byte) then "HI",
|
||||
// the data frame torn in half mid-payload.
|
||||
client.Write(new byte[] { 0x01, 0x02, 0x00, 0x02, (byte)'H' }, 0, 5);
|
||||
client.Flush();
|
||||
Thread.Sleep(20);
|
||||
client.Write(new byte[] { (byte)'I' }, 0, 1);
|
||||
client.Flush();
|
||||
|
||||
WaitFor(() => device.BytesReceived == 2);
|
||||
Assert.Equal(2, device.TextCharsDrawn);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Unknown_frame_type_drops_the_client_and_server_recovers()
|
||||
{
|
||||
string name = UniqueName();
|
||||
var device = new VPlasmaDevice();
|
||||
using var service = new VPlasmaPipeService(device, name);
|
||||
service.Start();
|
||||
|
||||
using (var bad = Connect(name))
|
||||
{
|
||||
var hello = new byte[2];
|
||||
bad.Read(hello, 0, 2);
|
||||
bad.Write(new byte[] { 0x7F }, 0, 1); // not a frame type
|
||||
bad.Flush();
|
||||
// Contract: log + drop, no resync — our next read sees the close.
|
||||
var eof = Task.Run(() =>
|
||||
{
|
||||
try { return bad.Read(new byte[1], 0, 1) == 0; }
|
||||
catch (IOException) { return true; }
|
||||
});
|
||||
Assert.True(eof.Wait(2000), "server did not drop the connection");
|
||||
Assert.True(eof.Result);
|
||||
}
|
||||
|
||||
using var good = Connect(name);
|
||||
var again = new byte[2];
|
||||
Assert.Equal(1, good.Read(again, 0, 1));
|
||||
Assert.Equal(1, good.Read(again, 1, 1));
|
||||
Assert.Equal(new byte[] { 0x01, 0x03 }, again);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
using System.Diagnostics;
|
||||
using System.IO.Pipes;
|
||||
using VRio.Core.Device;
|
||||
using VRio.Core.Protocol;
|
||||
using Xunit;
|
||||
|
||||
namespace VRio.Core.Tests;
|
||||
|
||||
public class PipeFrameDecoderTests
|
||||
{
|
||||
[Fact]
|
||||
public void Data_frame_delivers_payload()
|
||||
{
|
||||
var decoder = new PipeFrameDecoder();
|
||||
byte[]? got = null;
|
||||
decoder.Data += (buf, count) => got = buf.Take(count).ToArray();
|
||||
|
||||
Assert.True(decoder.Feed(new byte[] { 0x00, 0x03, 0xAA, 0xBB, 0xCC }, 5));
|
||||
Assert.Equal(new byte[] { 0xAA, 0xBB, 0xCC }, got);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Frames_survive_any_split_across_reads()
|
||||
{
|
||||
var decoder = new PipeFrameDecoder();
|
||||
var data = new List<byte>();
|
||||
var lines = new List<byte>();
|
||||
decoder.Data += (buf, count) => data.AddRange(buf.Take(count));
|
||||
decoder.Lines += lines.Add;
|
||||
|
||||
// A lines frame, a 2-byte data frame, a 1-byte data frame — one byte at a time.
|
||||
byte[] stream = { 0x01, 0x03, 0x00, 0x02, 0x81, 0x01, 0x00, 0x01, 0xFC };
|
||||
foreach (byte b in stream)
|
||||
Assert.True(decoder.Feed(new[] { b }, 1));
|
||||
|
||||
Assert.Equal(new byte[] { 0x03 }, lines);
|
||||
Assert.Equal(new byte[] { 0x81, 0x01, 0xFC }, data);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Multiple_frames_in_one_read_all_deliver()
|
||||
{
|
||||
var decoder = new PipeFrameDecoder();
|
||||
var data = new List<byte>();
|
||||
var lines = new List<byte>();
|
||||
decoder.Data += (buf, count) => data.AddRange(buf.Take(count));
|
||||
decoder.Lines += lines.Add;
|
||||
|
||||
byte[] chunk = { 0x00, 0x01, 0x42, 0x01, 0x00, 0x00, 0x01, 0x43 };
|
||||
Assert.True(decoder.Feed(chunk, chunk.Length));
|
||||
|
||||
Assert.Equal(new byte[] { 0x42, 0x43 }, data);
|
||||
Assert.Equal(new byte[] { 0x00 }, lines);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Unknown_frame_type_poisons_the_decoder()
|
||||
{
|
||||
var decoder = new PipeFrameDecoder();
|
||||
|
||||
Assert.False(decoder.Feed(new byte[] { 0x7F }, 1));
|
||||
Assert.Contains("0x7F", decoder.Violation);
|
||||
// Poisoned: even a healthy frame is rejected until Reset.
|
||||
Assert.False(decoder.Feed(new byte[] { 0x00, 0x01, 0x42 }, 3));
|
||||
|
||||
decoder.Reset();
|
||||
Assert.Null(decoder.Violation);
|
||||
Assert.True(decoder.Feed(new byte[] { 0x00, 0x01, 0x42 }, 3));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Zero_length_data_frame_is_a_violation()
|
||||
{
|
||||
var decoder = new PipeFrameDecoder();
|
||||
Assert.False(decoder.Feed(new byte[] { 0x00, 0x00 }, 2));
|
||||
Assert.Contains("zero-length", decoder.Violation);
|
||||
}
|
||||
}
|
||||
|
||||
public class VRioPipeServiceTests
|
||||
{
|
||||
/// <summary>
|
||||
/// A contract-speaking client: connects to the service's pipe, deframes
|
||||
/// everything it receives (timestamping data bytes for the pacing test),
|
||||
/// and sends raw frame bytes.
|
||||
/// </summary>
|
||||
private sealed class TestClient : IDisposable
|
||||
{
|
||||
private readonly NamedPipeClientStream _pipe;
|
||||
private readonly Thread _reader;
|
||||
private readonly object _sync = new();
|
||||
private readonly List<byte> _data = new();
|
||||
private readonly List<long> _dataTicks = new();
|
||||
private readonly List<byte> _lines = new();
|
||||
private volatile bool _closed;
|
||||
|
||||
public TestClient(string pipeName)
|
||||
{
|
||||
_pipe = new NamedPipeClientStream(".", pipeName, PipeDirection.InOut,
|
||||
PipeOptions.Asynchronous);
|
||||
|
||||
// The server's listener may still be spinning up; retry briefly.
|
||||
var connectClock = Stopwatch.StartNew();
|
||||
while (true)
|
||||
{
|
||||
try
|
||||
{
|
||||
_pipe.Connect(200);
|
||||
break;
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or TimeoutException)
|
||||
{
|
||||
if (connectClock.ElapsedMilliseconds >= 3000)
|
||||
throw;
|
||||
Thread.Sleep(25);
|
||||
}
|
||||
}
|
||||
|
||||
var decoder = new PipeFrameDecoder();
|
||||
decoder.Data += (buf, count) =>
|
||||
{
|
||||
long now = Stopwatch.GetTimestamp();
|
||||
lock (_sync)
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
_data.Add(buf[i]);
|
||||
_dataTicks.Add(now);
|
||||
}
|
||||
};
|
||||
decoder.Lines += b => { lock (_sync) _lines.Add(b); };
|
||||
|
||||
_reader = new Thread(() =>
|
||||
{
|
||||
var buffer = new byte[512];
|
||||
while (true)
|
||||
{
|
||||
int n;
|
||||
try { n = _pipe.Read(buffer, 0, buffer.Length); }
|
||||
catch (Exception ex) when (ex is IOException or ObjectDisposedException) { break; }
|
||||
if (n == 0 || !decoder.Feed(buffer, n))
|
||||
break;
|
||||
}
|
||||
_closed = true;
|
||||
})
|
||||
{ IsBackground = true };
|
||||
_reader.Start();
|
||||
}
|
||||
|
||||
public bool Closed => _closed;
|
||||
public byte[] Data { get { lock (_sync) return _data.ToArray(); } }
|
||||
public long[] DataTicks { get { lock (_sync) return _dataTicks.ToArray(); } }
|
||||
public byte[] Lines { get { lock (_sync) return _lines.ToArray(); } }
|
||||
|
||||
public void SendRaw(params byte[] bytes)
|
||||
{
|
||||
_pipe.Write(bytes, 0, bytes.Length);
|
||||
_pipe.Flush();
|
||||
}
|
||||
|
||||
public void SendData(byte[] payload)
|
||||
{
|
||||
var frame = new byte[2 + payload.Length];
|
||||
frame[0] = PipeFraming.DataType;
|
||||
frame[1] = (byte)payload.Length;
|
||||
payload.CopyTo(frame, 2);
|
||||
SendRaw(frame);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_pipe.Dispose();
|
||||
_reader.Join(1000);
|
||||
}
|
||||
}
|
||||
|
||||
private static string UniqueName() => $"vrio-test-{Guid.NewGuid():N}";
|
||||
|
||||
private static void WaitFor(Func<bool> condition, int timeoutMs = 2000)
|
||||
{
|
||||
var clock = Stopwatch.StartNew();
|
||||
while (!condition())
|
||||
{
|
||||
Assert.True(clock.ElapsedMilliseconds < timeoutMs, "timed out waiting for a condition");
|
||||
Thread.Sleep(10);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Client_gets_board_present_lines_on_connect()
|
||||
{
|
||||
string name = UniqueName();
|
||||
var device = new VRioDevice();
|
||||
using var service = new VRioPipeService(device, name);
|
||||
service.Start();
|
||||
|
||||
using var client = new TestClient(name);
|
||||
WaitFor(() => client.Lines.Length > 0);
|
||||
|
||||
Assert.Equal(PipeFraming.LinesPresent, client.Lines[0]); // DTR+RTS: board present
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Peer_dtr_edges_surface_as_host_handshake()
|
||||
{
|
||||
string name = UniqueName();
|
||||
var device = new VRioDevice();
|
||||
using var service = new VRioPipeService(device, name);
|
||||
var edges = new List<bool>();
|
||||
service.HostHandshake += high => { lock (edges) edges.Add(high); };
|
||||
service.Start();
|
||||
|
||||
using var client = new TestClient(name);
|
||||
client.SendRaw(PipeFraming.LinesType, PipeFraming.LineDtr); // DTR up
|
||||
client.SendRaw(PipeFraming.LinesType, 0x00); // DTR down (the reset pulse)
|
||||
|
||||
WaitFor(() => { lock (edges) return edges.Count == 2; });
|
||||
lock (edges)
|
||||
Assert.Equal(new[] { true, false }, edges);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Version_request_round_trips_ack_then_reply()
|
||||
{
|
||||
string name = UniqueName();
|
||||
var device = new VRioDevice();
|
||||
using var service = new VRioPipeService(device, name);
|
||||
service.Start();
|
||||
|
||||
using var client = new TestClient(name);
|
||||
WaitFor(() => client.Lines.Length > 0); // fully connected
|
||||
client.SendData(PacketBuilder.Build(RioCommand.VersionRequest));
|
||||
|
||||
byte[] expected = new[] { (byte)RioControl.Ack }
|
||||
.Concat(PacketBuilder.VersionReply(4, 2)).ToArray();
|
||||
WaitFor(() => client.Data.Length >= expected.Length);
|
||||
|
||||
Assert.Equal(expected, client.Data);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reply_bytes_are_paced_at_the_wire_rate()
|
||||
{
|
||||
string name = UniqueName();
|
||||
var device = new VRioDevice();
|
||||
using var service = new VRioPipeService(device, name);
|
||||
service.Start();
|
||||
|
||||
using var client = new TestClient(name);
|
||||
WaitFor(() => client.Lines.Length > 0);
|
||||
client.SendData(PacketBuilder.Build(RioCommand.VersionRequest));
|
||||
|
||||
// ACK + 4-byte VersionReply = 5 bytes = 4 inter-byte gaps ≈ 4.2 ms at
|
||||
// 9600 baud. An unpaced writer would land them in well under 1 ms.
|
||||
WaitFor(() => client.Data.Length >= 5);
|
||||
long[] ticks = client.DataTicks;
|
||||
double spanMs = (ticks[4] - ticks[0]) * 1000.0 / Stopwatch.Frequency;
|
||||
|
||||
Assert.True(spanMs >= 2.5, $"5 reply bytes arrived in {spanMs:F2} ms — pipe TX is not paced");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Unknown_frame_type_drops_the_client_and_server_recovers()
|
||||
{
|
||||
string name = UniqueName();
|
||||
var device = new VRioDevice();
|
||||
using var service = new VRioPipeService(device, name);
|
||||
service.Start();
|
||||
|
||||
using (var bad = new TestClient(name))
|
||||
{
|
||||
WaitFor(() => bad.Lines.Length > 0);
|
||||
bad.SendRaw(0x7F); // not a frame type
|
||||
WaitFor(() => bad.Closed); // contract: log + drop, no resync
|
||||
}
|
||||
|
||||
// The listener must come back for the next client.
|
||||
using var good = new TestClient(name);
|
||||
WaitFor(() => good.Lines.Length > 0);
|
||||
Assert.Equal(PipeFraming.LinesPresent, good.Lines[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Client_disconnect_drops_dtr_low()
|
||||
{
|
||||
string name = UniqueName();
|
||||
var device = new VRioDevice();
|
||||
using var service = new VRioPipeService(device, name);
|
||||
var edges = new List<bool>();
|
||||
service.HostHandshake += high => { lock (edges) edges.Add(high); };
|
||||
service.Start();
|
||||
|
||||
using (var client = new TestClient(name))
|
||||
{
|
||||
client.SendRaw(PipeFraming.LinesType, PipeFraming.LineDtr);
|
||||
WaitFor(() => { lock (edges) return edges.Count == 1; });
|
||||
} // client leaves with DTR still high
|
||||
|
||||
WaitFor(() => { lock (edges) return edges.Count == 2; });
|
||||
lock (edges)
|
||||
Assert.Equal(new[] { true, false }, edges);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Data_split_mid_frame_still_reaches_the_device()
|
||||
{
|
||||
string name = UniqueName();
|
||||
var device = new VRioDevice();
|
||||
using var service = new VRioPipeService(device, name);
|
||||
var lamps = new List<(int Address, byte State)>();
|
||||
device.LampChanged += (address, state) => { lock (lamps) lamps.Add((address, state)); };
|
||||
service.Start();
|
||||
|
||||
using var client = new TestClient(name);
|
||||
WaitFor(() => client.Lines.Length > 0);
|
||||
|
||||
// LampRequest 0x00 ← 0x01, framed, then torn in half mid-payload.
|
||||
byte[] packet = PacketBuilder.Build(RioCommand.LampRequest, new byte[] { 0x00, 0x01 });
|
||||
var frame = new byte[] { PipeFraming.DataType, (byte)packet.Length }.Concat(packet).ToArray();
|
||||
client.SendRaw(frame.Take(3).ToArray());
|
||||
Thread.Sleep(20);
|
||||
client.SendRaw(frame.Skip(3).ToArray());
|
||||
|
||||
WaitFor(() => { lock (lamps) return lamps.Count == 1; });
|
||||
lock (lamps)
|
||||
Assert.Equal((0x00, (byte)0x01), lamps[0]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user