4 Commits
Author SHA1 Message Date
CydandClaude Fable 5 697cf3129b Pipe endpoints join the connection pickers; no more COM11/COM12 auto-bind
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>
2026-07-12 23:17:34 -05:00
CydandClaude Fable 5 e507f1740c 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>
2026-07-12 23:03:02 -05:00
CydandClaude Fable 5 0674cf5ba4 Drop unused Microsoft.Bcl.HashCode, fix README typo
HashCode.Combine is called nowhere; the package (and its DLL in the dist
zip) was dead weight since the Core projects were created.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 20:40:14 -05:00
CydandClaude Fable 5 90bf6723dc vRIO builds in the vPLASMA display
The plasma glass now lives in vRIO's encoder strip: PlasmaCanvas grew a
dot-pitch parameter (default keeps the standalone glass pixel-identical)
and is compile-linked into VRio.App at pitch 3, parked at the strip's
left slot. The axis gauges move to the grid's right edge, the strip is
as tall as the glass, and the status text sits between them with the
axis readout split onto two lines. The button grid also compacts: rows
shift up past the layout's empty row 0 and the spare bottom row is
trimmed (the shared CockpitLayout is untouched, so coordinates still
match RIOJoy's map).

Control strip: the COM row labels are now the device names -- vRIO and
vPLASMA -- coloured by port status (green open, gray closed), replacing
the port-status line. The plasma row auto-opens COM12 at startup like
the RIO row does COM11; one Rescan refreshes both pickers. Plasma log
lines share the wire log under a vPLASMA: prefix, and the standalone
gestures carry over (double-click self-test, right-click reset).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 15:30:51 -05:00
13 changed files with 1498 additions and 103 deletions
+38 -6
View File
@@ -96,22 +96,54 @@ The two apps need a crossed serial link. Install a
(e.g. `COM1 ⇄ COM11`), then: (e.g. `COM1 ⇄ COM11`), then:
1. Run `VRio.App`, pick `COM11`, **Open**. 1. Run `VRio.App`, pick `COM11`, **Open**.
2. RIOJoy will always poit to `COM1`. 2. RIOJoy will always point to `COM1`.
RIOJoy's DTR open-pulse shows up in the wire log (DSR handshake), its ~55 ms RIOJoy's DTR open-pulse shows up in the wire log (DSR handshake), its ~55 ms
analog polling drives the "analog polls served" counter, and every click on 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 the vRIO panel arrives at RIOJoy as real cockpit input. Two physical PCs with
a null-modem cable work the same way. 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: each device's connection
picker lists its named-pipe endpoint — `pipe:vrio`, `pipe:vplasma`
alongside the machine's COM ports. Select it and **Open** to serve
`\\.\pipe\vrio` / `\\.\pipe\vplasma`; 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. Each row serves one endpoint at a time — the RIO is a single-host
device — and nothing opens automatically at startup; the COM path is
unchanged for RIOJoy and real pods. 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 ## vPLASMA — the companion plasma display
The cockpit's second serial device is the **plasma display**: a 128×32 The cockpit's second serial device is the **plasma display**: a 128×32
dot-matrix panel on COM2 (9600 8N1, no flow control) that the game draws dot-matrix panel on COM2 (9600 8N1, no flow control) that the game draws
mission text and status graphics on. `VPlasma.App` is its software replica: mission text and status graphics on. The software replica comes in two
a bare-glass window that opens **COM12** (the device end of the plasma's forms. **Built into vRIO**: the panel's encoder strip hosts the glass at
null-modem pair) on startup — retrying while the port is missing or busy, top left, with its own connection row in the control strip (label colour =
port status in the title bar — decodes the display's command stream, and connection status; picker offers `pipe:vplasma` and the COM ports). And standalone,
renders the dot matrix in plasma orange, text mode included. `VPlasma.App`: a bare-glass window that opens **COM12** (the device end of
the plasma's null-modem pair) on startup — retrying while the port is
missing or busy, port status in the title bar. Both decode the display's
command stream and render the dot matrix in plasma orange, text mode
included; only one can hold COM12 at a time.
The command set was recovered from two Tesla 4.10 artifacts: The command set was recovered from two Tesla 4.10 artifacts:
`CODE\RP\MUNGA_L4\L4PLASMA.CPP` (the game's driver — it renders everything `CODE\RP\MUNGA_L4\L4PLASMA.CPP` (the game's driver — it renders everything
+10
View File
@@ -7,6 +7,9 @@ namespace VPlasma.App;
/// device end is hardwired to <see cref="PortName"/> (the plasma side of the /// device end is hardwired to <see cref="PortName"/> (the plasma side of the
/// second com0com pair) and opened automatically — a retry timer keeps /// 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 /// 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 /// title bar carries the port status; double-clicking the glass cycles the
/// self-test pages (banner, charset, graphics) through the wire parser, and /// self-test pages (banner, charset, graphics) through the wire parser, and
/// a right-click resets the display to its power-on state. /// 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 VPlasmaDevice _device = new();
private readonly VPlasmaSerialService _service; private readonly VPlasmaSerialService _service;
private readonly VPlasmaPipeService _pipeService;
private readonly PlasmaCanvas _canvas = new(); private readonly PlasmaCanvas _canvas = new();
private readonly System.Windows.Forms.Timer _reconnectTimer = new() { Interval = 3000 }; private readonly System.Windows.Forms.Timer _reconnectTimer = new() { Interval = 3000 };
@@ -32,6 +36,7 @@ internal sealed class MainForm : Form
StartPosition = FormStartPosition.CenterScreen; StartPosition = FormStartPosition.CenterScreen;
_service = new VPlasmaSerialService(_device); _service = new VPlasmaSerialService(_device);
_pipeService = new VPlasmaPipeService(_device);
_canvas.Location = new Point(0, 0); _canvas.Location = new Point(0, 0);
Controls.Add(_canvas); 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 / service events arrive on the serial reader thread; marshal to the UI.
_device.Updated += () => RunOnUi(() => _canvas.UpdateFrom(_device)); _device.Updated += () => RunOnUi(() => _canvas.UpdateFrom(_device));
_service.ConnectionChanged += _ => RunOnUi(UpdateTitle); _service.ConnectionChanged += _ => RunOnUi(UpdateTitle);
_pipeService.ClientChanged += _ => RunOnUi(UpdateTitle);
_canvas.DoubleClick += (_, _) => RunSelfTest(); _canvas.DoubleClick += (_, _) => RunSelfTest();
_canvas.MouseClick += (_, e) => _canvas.MouseClick += (_, e) =>
@@ -55,10 +61,12 @@ internal sealed class MainForm : Form
{ {
_reconnectTimer.Dispose(); _reconnectTimer.Dispose();
_service.Dispose(); _service.Dispose();
_pipeService.Dispose();
}; };
_canvas.UpdateFrom(_device); _canvas.UpdateFrom(_device);
EnsureOpen(); EnsureOpen();
_pipeService.Start();
} }
private void EnsureOpen() private void EnsureOpen()
@@ -82,6 +90,8 @@ internal sealed class MainForm : Form
string status = _service.IsOpen string status = _service.IsOpen
? $"{PortName} @ 9600 8N1" ? $"{PortName} @ 9600 8N1"
: $"{PortName} unavailable — retrying"; : $"{PortName} unavailable — retrying";
if (_pipeService.IsClientConnected)
status += " • pipe host connected";
Text = $"vPLASMA v{Application.ProductVersion} — {status}"; Text = $"vPLASMA v{Application.ProductVersion} — {status}";
} }
+19 -10
View File
@@ -11,10 +11,14 @@ namespace VPlasma.App;
/// </summary> /// </summary>
internal sealed class PlasmaCanvas : Control internal sealed class PlasmaCanvas : Control
{ {
private const int DotPitch = 5; // 4 px dot + 1 px gap → a 640×160 dot field
private const int DotSize = 4;
private const int Bezel = 4; private const int Bezel = 4;
// Dot geometry: an N px pitch renders (N1) px dots with a 1 px gap. The
// standalone glass uses the default pitch 5 (4 px dots, a 640×160 dot
// field); vRIO embeds the same control at pitch 3 to fit its encoder strip.
private readonly int _dotPitch;
private readonly int _dotSize;
private static readonly Color BezelColor = Color.FromArgb(20, 18, 16); private static readonly Color BezelColor = Color.FromArgb(20, 18, 16);
private static readonly Color GlassColor = Color.FromArgb(26, 14, 6); private static readonly Color GlassColor = Color.FromArgb(26, 14, 6);
private static readonly Color UnlitDot = Color.FromArgb(46, 24, 10); private static readonly Color UnlitDot = Color.FromArgb(46, 24, 10);
@@ -30,14 +34,14 @@ internal sealed class PlasmaCanvas : Control
private PlasmaCursorMode _cursorMode; private PlasmaCursorMode _cursorMode;
private int _cellWidth = 6, _cellHeight = 8; private int _cellWidth = 6, _cellHeight = 8;
public PlasmaCanvas() public PlasmaCanvas(int dotPitch = 5)
{ {
_dotPitch = dotPitch;
_dotSize = dotPitch - 1;
SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint | SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint |
ControlStyles.OptimizedDoubleBuffer | ControlStyles.FixedWidth | ControlStyles.OptimizedDoubleBuffer | ControlStyles.FixedWidth |
ControlStyles.FixedHeight, true); ControlStyles.FixedHeight, true);
Size = new Size( Size = SizeFor(dotPitch);
VPlasmaDevice.Width * DotPitch + 2 * Bezel,
VPlasmaDevice.Height * DotPitch + 2 * Bezel);
BackColor = BezelColor; BackColor = BezelColor;
_blinkTimer.Tick += (_, _) => _blinkTimer.Tick += (_, _) =>
@@ -49,6 +53,11 @@ internal sealed class PlasmaCanvas : Control
_blinkTimer.Start(); _blinkTimer.Start();
} }
/// <summary>Control size at a given dot pitch: the dot field plus bezel.</summary>
public static Size SizeFor(int dotPitch) => new(
VPlasmaDevice.Width * dotPitch + 2 * Bezel,
VPlasmaDevice.Height * dotPitch + 2 * Bezel);
/// <summary>Snapshot the device state and repaint. Call on the UI thread.</summary> /// <summary>Snapshot the device state and repaint. Call on the UI thread.</summary>
public void UpdateFrom(VPlasmaDevice device) public void UpdateFrom(VPlasmaDevice device)
{ {
@@ -77,7 +86,7 @@ internal sealed class PlasmaCanvas : Control
using (var glass = new SolidBrush(GlassColor)) using (var glass = new SolidBrush(GlassColor))
g.FillRectangle(glass, Bezel - 4, Bezel - 4, g.FillRectangle(glass, Bezel - 4, Bezel - 4,
VPlasmaDevice.Width * DotPitch + 7, VPlasmaDevice.Height * DotPitch + 7); VPlasmaDevice.Width * _dotPitch + 7, VPlasmaDevice.Height * _dotPitch + 7);
using var unlit = new SolidBrush(UnlitDot); using var unlit = new SolidBrush(UnlitDot);
using var lit = new SolidBrush(LitDot); using var lit = new SolidBrush(LitDot);
@@ -86,7 +95,7 @@ internal sealed class PlasmaCanvas : Control
for (int y = 0; y < VPlasmaDevice.Height; ++y) for (int y = 0; y < VPlasmaDevice.Height; ++y)
{ {
int rowOffset = y * VPlasmaDevice.Width; int rowOffset = y * VPlasmaDevice.Width;
int py = Bezel + y * DotPitch; int py = Bezel + y * _dotPitch;
for (int x = 0; x < VPlasmaDevice.Width; ++x) for (int x = 0; x < VPlasmaDevice.Width; ++x)
{ {
byte dot = _frame[rowOffset + x]; byte dot = _frame[rowOffset + x];
@@ -96,7 +105,7 @@ internal sealed class PlasmaCanvas : Control
{ {
brush = (dot & VPlasmaDevice.PixelHalf) != 0 ? half : lit; brush = (dot & VPlasmaDevice.PixelHalf) != 0 ? half : lit;
} }
g.FillRectangle(brush, Bezel + x * DotPitch, py, DotSize, DotSize); g.FillRectangle(brush, Bezel + x * _dotPitch, py, _dotSize, _dotSize);
} }
} }
@@ -108,7 +117,7 @@ internal sealed class PlasmaCanvas : Control
int cy = _cursor.Row * _cellHeight + _cellHeight - 1; int cy = _cursor.Row * _cellHeight + _cellHeight - 1;
if (cy < VPlasmaDevice.Height) if (cy < VPlasmaDevice.Height)
for (int i = 0; i < _cellWidth && cx + i < VPlasmaDevice.Width; ++i) for (int i = 0; i < _cellWidth && cx + i < VPlasmaDevice.Width; ++i)
g.FillRectangle(lit, Bezel + (cx + i) * DotPitch, Bezel + cy * DotPitch, DotSize, DotSize); g.FillRectangle(lit, Bezel + (cx + i) * _dotPitch, Bezel + cy * _dotPitch, _dotSize, _dotSize);
} }
} }
@@ -0,0 +1,240 @@
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();
}
-1
View File
@@ -12,7 +12,6 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="System.Memory" Version="4.5.5" /> <PackageReference Include="System.Memory" Version="4.5.5" />
<PackageReference Include="Microsoft.Bcl.HashCode" Version="1.1.1" />
<PackageReference Include="PolySharp" Version="1.14.1"> <PackageReference Include="PolySharp" Version="1.14.1">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
+189 -66
View File
@@ -1,5 +1,7 @@
using System.Diagnostics; using System.Diagnostics;
using System.IO.Ports; using System.IO.Ports;
using VPlasma.App;
using VPlasma.Core.Device;
using VRio.Core.Device; using VRio.Core.Device;
using VRio.Core.Input; using VRio.Core.Input;
@@ -8,24 +10,43 @@ namespace VRio.App;
/// <summary> /// <summary>
/// vRIO main window: the interactive cockpit panel on the left (the same /// vRIO main window: the interactive cockpit panel on the left (the same
/// functional map RIOJoy's profile editor shows) and a control strip on the /// functional map RIOJoy's profile editor shows) and a control strip on the
/// right — COM port, device settings, and a live wire log. At startup the /// right — connection rows, device settings, and a live wire log. The
/// usual port (<see cref="PreferredPort"/>) is opened automatically when it's /// panel's encoder strip also hosts the built-in plasma glass (the vPLASMA
/// available; otherwise open a COM port by hand. Point RIOJoy at the other /// display emulator) on its own connection row. Each row's picker offers
/// end of the null-modem pair, and every click here arrives at RIOJoy exactly /// the device's named-pipe endpoint (the DOSBox-X fork's namedpipe backend
/// as if the physical cockpit sent it. /// — the com0com-free path) alongside the machine's COM ports; nothing
/// opens automatically — pick an endpoint and Open. Point RIOJoy at the
/// other end of a null-modem pair and every click here arrives exactly as
/// if the physical cockpit sent it; point the game at
/// <see cref="RioPipeEndpoint"/>/<see cref="PlasmaPipeEndpoint"/> (or the
/// COM2 passthrough at the plasma pair) and the glass lights up.
/// </summary> /// </summary>
internal sealed class MainForm : Form internal sealed class MainForm : Form
{ {
/// <summary> /// <summary>
/// vRIO's usual port: the device end of the COM1⇄COM11 com0com pair. /// The RIO's pipe endpoint as listed in the picker. Matches the
/// Auto-opened at startup when present and free; the picker still allows /// DOSBox-X conf syntax: <c>serial1=namedpipe pipe:vrio</c>.
/// any other port.
/// </summary> /// </summary>
private const string PreferredPort = "COM11"; private static readonly string RioPipeEndpoint = $"pipe:{VRioPipeService.DefaultPipeName}";
/// <summary>
/// The plasma's pipe endpoint as listed in the picker
/// (<c>serial2=namedpipe pipe:vplasma</c>).
/// </summary>
private static readonly string PlasmaPipeEndpoint = $"pipe:{VPlasmaPipeService.DefaultPipeName}";
private readonly VRioDevice _device = new(); private readonly VRioDevice _device = new();
private readonly VRioSerialService _service; 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 PanelCanvas _canvas = new();
private readonly PlasmaCanvas _plasmaCanvas = new(PanelCanvas.PlasmaDotPitch)
{
Location = PanelCanvas.PlasmaSlot,
};
private int _selfTestPage;
private readonly InputRouter _router; private readonly InputRouter _router;
private readonly XInputGamepad _gamepad = new(); private readonly XInputGamepad _gamepad = new();
private readonly KeyboardLampMirror _lampMirror; private readonly KeyboardLampMirror _lampMirror;
@@ -34,6 +55,16 @@ internal sealed class MainForm : Form
private readonly string _bindingsPath = Path.Combine( private readonly string _bindingsPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "vRIO", "bindings.txt"); Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "vRIO", "bindings.txt");
// Port rows: one line per built-in device — a name label whose colour is
// the port status (green = open, gray = closed), the port picker, and an
// Open/Close button. The one Rescan button refreshes both pickers.
private readonly Label _rioLabel = new()
{
Text = "vRIO",
Location = new Point(12, 15),
AutoSize = true,
ForeColor = Color.Gray,
};
private readonly ComboBox _portBox = new() private readonly ComboBox _portBox = new()
{ {
Location = new Point(80, 12), Location = new Point(80, 12),
@@ -42,13 +73,20 @@ internal sealed class MainForm : Form
}; };
private readonly Button _rescan = new() { Text = "Rescan", Location = new Point(214, 11), Width = 52 }; private readonly Button _rescan = new() { Text = "Rescan", Location = new Point(214, 11), Width = 52 };
private readonly Button _openClose = new() { Text = "Open", Location = new Point(272, 11), Width = 46 }; private readonly Button _openClose = new() { Text = "Open", Location = new Point(272, 11), Width = 46 };
private readonly Label _linkStatus = new() private readonly Label _plasmaLabel = new()
{ {
Text = "Port closed.", Text = "vPLASMA",
Location = new Point(12, 42), Location = new Point(12, 45),
AutoSize = true, AutoSize = true,
ForeColor = Color.Gray, ForeColor = Color.Gray,
}; };
private readonly ComboBox _plasmaPortBox = new()
{
Location = new Point(80, 42),
Width = 128,
DropDownStyle = ComboBoxStyle.DropDownList,
};
private readonly Button _plasmaOpenClose = new() { Text = "Open", Location = new Point(272, 41), Width = 46 };
private readonly CheckBox _spring = new() { Text = "Stick springs back to center", Location = new Point(10, 24), AutoSize = true, Checked = true }; private readonly CheckBox _spring = new() { Text = "Stick springs back to center", Location = new Point(10, 24), AutoSize = true, Checked = true };
private readonly Button _centerAxes = new() { Text = "Center all axes", Location = new Point(10, 52), Width = 140, Height = 26 }; private readonly Button _centerAxes = new() { Text = "Center all axes", Location = new Point(10, 52), Width = 140, Height = 26 };
@@ -112,7 +150,8 @@ internal sealed class MainForm : Form
AutoSize = true, AutoSize = true,
ForeColor = Color.Gray, ForeColor = Color.Gray,
Text = "Left-click a cell: momentary press. Right-click: latch it down. " + Text = "Left-click a cell: momentary press. Right-click: latch it down. " +
"Drag the X/Y box and the Z / L / R gauges to move the axes.", "Drag the X/Y box and the Z / L / R gauges to move the axes. " +
"Double-click the plasma glass to cycle its self-test pages; right-click it to reset.",
}; };
private readonly TextBox _logBox = new() private readonly TextBox _logBox = new()
@@ -153,11 +192,18 @@ internal sealed class MainForm : Form
KeyPreview = true; // form-level key routing for the input bindings KeyPreview = true; // form-level key routing for the input bindings
_service = new VRioSerialService(_device); _service = new VRioSerialService(_device);
_plasmaService = new VPlasmaSerialService(_plasmaDevice);
// The named-pipe endpoints for DOSBox-X's namedpipe serial backend,
// offered in the connection pickers alongside the COM ports.
_pipeService = new VRioPipeService(_device);
_plasmaPipeService = new VPlasmaPipeService(_plasmaDevice);
_router = new InputRouter(_device); _router = new InputRouter(_device);
_lampMirror = new KeyboardLampMirror(_device); _lampMirror = new KeyboardLampMirror(_device);
// Panel canvas, scrolled if the window is smaller than the grid. // Panel canvas, scrolled if the window is smaller than the grid. The
// plasma glass rides along as a child in the encoder strip's left slot.
var scroller = new Panel { Dock = DockStyle.Fill, AutoScroll = true, BackColor = Color.FromArgb(28, 28, 28) }; var scroller = new Panel { Dock = DockStyle.Fill, AutoScroll = true, BackColor = Color.FromArgb(28, 28, 28) };
_canvas.Controls.Add(_plasmaCanvas);
scroller.Controls.Add(_canvas); scroller.Controls.Add(_canvas);
Controls.Add(scroller); Controls.Add(scroller);
@@ -182,12 +228,30 @@ internal sealed class MainForm : Form
_device.Logged += line => RunOnUi(() => PrependLog(line)); _device.Logged += line => RunOnUi(() => PrependLog(line));
_service.Logged += line => RunOnUi(() => PrependLog(line)); _service.Logged += line => RunOnUi(() => PrependLog(line));
_lampMirror.Logged += line => RunOnUi(() => PrependLog(line)); _lampMirror.Logged += line => RunOnUi(() => PrependLog(line));
_service.ConnectionChanged += open => RunOnUi(() => OnConnectionChanged(open)); _service.ConnectionChanged += _ => RunOnUi(UpdateRioEndpointUi);
_service.HostHandshake += high => RunOnUi(() => _service.HostHandshake += high => RunOnUi(() =>
PrependLog(high ? "Host raised DTR (board-reset handshake)" : "Host dropped DTR")); 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"));
_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,
// right-click resets the display to its power-on state.
_plasmaDevice.Updated += () => RunOnUi(() => _plasmaCanvas.UpdateFrom(_plasmaDevice));
_plasmaService.Logged += line => RunOnUi(() => PrependLog($"vPLASMA: {line}"));
_plasmaService.ConnectionChanged += _ => RunOnUi(UpdatePlasmaEndpointUi);
_plasmaCanvas.DoubleClick += (_, _) => RunPlasmaSelfTest();
_plasmaCanvas.MouseClick += (_, e) =>
{
if (e.Button == MouseButtons.Right)
_plasmaDevice.Reset();
};
_rescan.Click += (_, _) => RefreshPorts(); _rescan.Click += (_, _) => RefreshPorts();
_openClose.Click += (_, _) => ToggleOpen(); _openClose.Click += (_, _) => ToggleOpen();
_plasmaOpenClose.Click += (_, _) => TogglePlasmaOpen();
_spring.CheckedChanged += (_, _) => _canvas.StickSpringsBack = _spring.Checked; _spring.CheckedChanged += (_, _) => _canvas.StickSpringsBack = _spring.Checked;
_centerAxes.Click += (_, _) => _centerAxes.Click += (_, _) =>
{ {
@@ -251,13 +315,17 @@ internal sealed class MainForm : Form
_lampMirror.Dispose(); _lampMirror.Dispose();
_rawKeyboard.Dispose(); _rawKeyboard.Dispose();
_service.Dispose(); _service.Dispose();
_plasmaService.Dispose();
_pipeService.Dispose();
_plasmaPipeService.Dispose();
}; };
RefreshPorts(); RefreshPorts();
UpdateStatus(); UpdateStatus();
PrependLog("vRIO ready. Open a COM port, then point RIOJoy at the other end of the pair."); PrependLog($"vRIO ready. Pick an endpoint per device — {RioPipeEndpoint} / {PlasmaPipeEndpoint} " +
"for DOSBox-X, or a COM port for RIOJoy — and Open.");
LoadBindings(); LoadBindings();
AutoOpenPreferredPort(); _plasmaCanvas.UpdateFrom(_plasmaDevice); // paint the power-on frame
} }
private Panel BuildControlStrip() private Panel BuildControlStrip()
@@ -275,11 +343,13 @@ internal sealed class MainForm : Form
BorderStyle = BorderStyle.FixedSingle, BorderStyle = BorderStyle.FixedSingle,
}; };
panel.Controls.Add(new Label { Text = "COM port:", Location = new Point(12, 15), AutoSize = true }); panel.Controls.Add(_rioLabel);
panel.Controls.Add(_portBox); panel.Controls.Add(_portBox);
panel.Controls.Add(_rescan); panel.Controls.Add(_rescan);
panel.Controls.Add(_openClose); panel.Controls.Add(_openClose);
panel.Controls.Add(_linkStatus); panel.Controls.Add(_plasmaLabel);
panel.Controls.Add(_plasmaPortBox);
panel.Controls.Add(_plasmaOpenClose);
var device = new GroupBox { Text = "Device", Location = new Point(12, 68), Size = new Size(306, 88) }; var device = new GroupBox { Text = "Device", Location = new Point(12, 68), Size = new Size(306, 88) };
device.Controls.AddRange(new Control[] { _spring, _centerAxes, _lampsOff }); device.Controls.AddRange(new Control[] { _spring, _centerAxes, _lampsOff });
@@ -302,82 +372,132 @@ internal sealed class MainForm : Form
return panel; return panel;
} }
// ---- Port handling ----------------------------------------------------- // ---- Endpoint handling ---------------------------------------------------
// Each device row serves one endpoint at a time — the RIO is a
// single-host device, so its pipe server and COM port never run together.
private bool RioOpen => _service.IsOpen || _pipeService.IsListening;
private bool PlasmaOpen => _plasmaService.IsOpen || _plasmaPipeService.IsListening;
private void RefreshPorts() private void RefreshPorts()
{ {
string? current = _portBox.SelectedItem?.ToString(); string[] names = SerialPort.GetPortNames().Distinct()
_portBox.Items.Clear(); .OrderBy(n => n, StringComparer.OrdinalIgnoreCase).ToArray();
foreach (string name in SerialPort.GetPortNames().Distinct().OrderBy(n => n, StringComparer.OrdinalIgnoreCase)) // An open row's picker is disabled and shows the served endpoint;
_portBox.Items.Add(name); // leave it alone so the display can't drift from reality.
if (!RioOpen)
if (_portBox.Items.Count == 0) RefreshPicker(_portBox, RioPipeEndpoint, names);
return; if (!PlasmaOpen)
int idx = current is null ? -1 : _portBox.Items.IndexOf(current); RefreshPicker(_plasmaPortBox, PlasmaPipeEndpoint, names);
_portBox.SelectedIndex = idx >= 0 ? idx : 0;
} }
/// <summary> private static void RefreshPicker(ComboBox box, string pipeEndpoint, string[] portNames)
/// Startup convenience: if <see cref="PreferredPort"/> exists, select it
/// and try to open it. Failures (port missing, or busy because another
/// vRIO/app holds it) just log — no modal box at launch — and leave the
/// manual picker in charge.
/// </summary>
private void AutoOpenPreferredPort()
{ {
int idx = _portBox.Items.IndexOf(PreferredPort); string? current = box.SelectedItem?.ToString();
if (idx < 0) box.Items.Clear();
{ box.Items.Add(pipeEndpoint); // always present — a pipe server needs no hardware
PrependLog($"{PreferredPort} not present — pick a port and open it manually."); foreach (string name in portNames)
return; box.Items.Add(name);
}
_portBox.SelectedIndex = idx; int idx = current is null ? -1 : box.Items.IndexOf(current);
try box.SelectedIndex = idx >= 0 ? idx : 0;
{
_service.Open(PreferredPort);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidOperationException or ArgumentException)
{
PrependLog($"{PreferredPort} is present but could not be opened ({ex.Message.TrimEnd('.')}) — open it manually once it frees up.");
}
} }
private void ToggleOpen() private void ToggleOpen()
{ {
if (_service.IsOpen) if (RioOpen)
{ {
_service.Close(); _service.Close(); // idempotent —
return; _pipeService.Stop(); // whichever was serving closes
UpdateRioEndpointUi();
}
else
{
OpenFromPicker(_portBox, OpenRioEndpoint);
}
} }
if (_portBox.SelectedItem?.ToString() is not { } port) private void TogglePlasmaOpen()
{ {
MessageBox.Show(this, "No COM port selected. On a single PC, install a com0com virtual " + if (PlasmaOpen)
"null-modem pair and open one end here.", "vRIO", MessageBoxButtons.OK, MessageBoxIcon.Information); {
_plasmaService.Close();
_plasmaPipeService.Stop();
UpdatePlasmaEndpointUi();
}
else
{
OpenFromPicker(_plasmaPortBox, OpenPlasmaEndpoint);
}
}
private void OpenRioEndpoint(string endpoint)
{
if (endpoint == RioPipeEndpoint)
_pipeService.Start();
else
_service.Open(endpoint);
UpdateRioEndpointUi();
}
private void OpenPlasmaEndpoint(string endpoint)
{
if (endpoint == PlasmaPipeEndpoint)
_plasmaPipeService.Start();
else
_plasmaService.Open(endpoint);
UpdatePlasmaEndpointUi();
}
private void OpenFromPicker(ComboBox box, Action<string> open)
{
if (box.SelectedItem?.ToString() is not { } endpoint)
{
MessageBox.Show(this, "No endpoint selected. Pick the device's named pipe (for " +
"DOSBox-X's namedpipe backend) or a COM port (for RIOJoy through a com0com " +
"null-modem pair).", "vRIO", MessageBoxButtons.OK, MessageBoxIcon.Information);
return; return;
} }
try try
{ {
_service.Open(port); open(endpoint);
} }
catch (Exception ex) catch (Exception ex)
{ {
MessageBox.Show(this, $"Could not open {port}:\n{ex.Message}", "vRIO", MessageBox.Show(this, $"Could not open {endpoint}:\n{ex.Message}", "vRIO",
MessageBoxButtons.OK, MessageBoxIcon.Warning); MessageBoxButtons.OK, MessageBoxIcon.Warning);
} }
} }
private void OnConnectionChanged(bool open) private void UpdateRioEndpointUi()
{ {
bool open = RioOpen;
_openClose.Text = open ? "Close" : "Open"; _openClose.Text = open ? "Close" : "Open";
_portBox.Enabled = _rescan.Enabled = !open; _portBox.Enabled = !open;
_linkStatus.Text = open ? $"Serving {_service.PortName} @ 9600 8N1." : "Port closed."; _rioLabel.ForeColor = open ? Color.ForestGreen : Color.Gray;
_linkStatus.ForeColor = open ? Color.ForestGreen : Color.Gray;
UpdateStatus(); UpdateStatus();
} }
private void UpdatePlasmaEndpointUi()
{
bool open = PlasmaOpen;
_plasmaOpenClose.Text = open ? "Close" : "Open";
_plasmaPortBox.Enabled = !open;
_plasmaLabel.ForeColor = open ? Color.ForestGreen : Color.Gray;
}
/// <summary>
/// Feed the next canned self-test page through the plasma's wire parser —
/// the same end-to-end exercise the standalone vPLASMA runs on double-click.
/// </summary>
private void RunPlasmaSelfTest()
{
byte[] bytes = PlasmaSelfTest.BuildPage(_selfTestPage);
_plasmaDevice.OnReceived(bytes, bytes.Length);
_selfTestPage = (_selfTestPage + 1) % PlasmaSelfTest.PageCount;
}
// ---- Keyboard / gamepad input ------------------------------------------- // ---- Keyboard / gamepad input -------------------------------------------
/// <summary> /// <summary>
@@ -588,12 +708,15 @@ internal sealed class MainForm : Form
$"Analog polls dropped: {dropped}\n" + $"Analog polls dropped: {dropped}\n" +
$"Bad checksums: {bad}"; $"Bad checksums: {bad}";
_canvas.StatusText = _service.IsOpen string? endpoint = _service.IsOpen ? _service.PortName
? $"vRIO {_device.VersionMajor}.{_device.VersionMinor} on {_service.PortName}\n" + : _pipeService.IsListening ? RioPipeEndpoint
: null;
_canvas.StatusText = endpoint is not null
? $"vRIO {_device.VersionMajor}.{_device.VersionMinor} on {endpoint}\n" +
(wedged (wedged
? "** ANALOG WEDGED (v4.2 bug) — awaiting host reset **" ? "** ANALOG WEDGED (v4.2 bug) — awaiting host reset **"
: $"{polls} analog polls" + (polls > 0 ? " (host is alive)" : " (no host traffic yet)")) : $"{polls} analog polls" + (polls > 0 ? " (host is alive)" : " (no host traffic yet)"))
: "PORT CLOSED\nOpen a COM port to go live."; : "OFFLINE\nOpen an endpoint (named pipe or COM port) to go live.";
} }
private void PrependLog(string line) private void PrependLog(string line)
+44 -18
View File
@@ -1,3 +1,4 @@
using VPlasma.App;
using VRio.Core.Device; using VRio.Core.Device;
using VRio.Core.Panel; using VRio.Core.Panel;
using VRio.Core.Protocol; using VRio.Core.Protocol;
@@ -16,6 +17,10 @@ namespace VRio.App;
/// fill toward full travel (negative on the wire for the throttle, positive /// fill toward full travel (negative on the wire for the throttle, positive
/// for the pedals), and the stick covers ±80 around its center. /// for the pedals), and the stick covers ±80 around its center.
/// ///
/// <para>The strip's left slot hosts the built-in plasma glass: MainForm parks
/// its <see cref="PlasmaCanvas"/> child at <see cref="PlasmaSlot"/>, and the
/// strip height and gauge positions derive from the glass size.</para>
///
/// <para>Left-click = momentary press (release on mouse-up). Right-click = /// <para>Left-click = momentary press (release on mouse-up). Right-click =
/// latch the button down / release it (handy for testing holds).</para> /// latch the button down / release it (handy for testing holds).</para>
/// </summary> /// </summary>
@@ -24,16 +29,27 @@ internal sealed class PanelCanvas : Control
// Cell geometry — identical to RIOJoy's PanelView so the two panels align. // Cell geometry — identical to RIOJoy's PanelView so the two panels align.
private const int CellW = 66; private const int CellW = 66;
private const int CellH = 34; private const int CellH = 34;
private const int TopStrip = 78; // encoder strip: gauges end at 74, small gap to the grid
private static readonly IReadOnlyList<PanelButton> AllButtons = CockpitLayout.Buttons(); private static readonly IReadOnlyList<PanelButton> AllButtons = CockpitLayout.Buttons();
// Encoder-strip geometry (same placement as RIOJoy's PanelView): Z gauge, // First grid row any group occupies (the shared layout leaves row 0
// the pedal gauges L / R, and the X/Y stick box. // empty). Rendering shifts all rows up by this, so the button grid starts
// right under the encoder strip instead of a blank cell row below it.
private static readonly int FirstRow = CockpitLayout.Groups.Min(g => g.OriginRow);
// Encoder-strip geometry. The built-in plasma glass parks at the strip's
// left edge (MainForm adds its PlasmaCanvas as a child at PlasmaSlot),
// the axis gauges — Z, the pedal gauges L / R, and the X/Y stick box —
// hug the grid's right edge, and the green status text fills the gap
// between them. The strip is exactly as tall as the glass.
internal const int PlasmaDotPitch = 3; // 2 px dots: the 128×32 glass fits the strip
internal static readonly Size PlasmaSize = PlasmaCanvas.SizeFor(PlasmaDotPitch);
private const int StripTop = 6; private const int StripTop = 6;
private const int StripH = 68; internal static readonly Point PlasmaSlot = new(6, StripTop);
private static readonly int StripH = PlasmaSize.Height;
private static readonly int TopStrip = StripTop + StripH + 6; // button grid starts under the strip
private const int Bar = 30; private const int Bar = 30;
private static readonly int BaseX = 6 * CellW - 100; private static readonly int BaseX = GridWidth() - 6 - 200; // the gauge cluster spans 200 px, right-aligned
private static readonly Rectangle BoxZ = new(BaseX, StripTop, Bar, StripH); private static readonly Rectangle BoxZ = new(BaseX, StripTop, Bar, StripH);
private static readonly Rectangle BoxL = new(BaseX + 34, StripTop, Bar, StripH); private static readonly Rectangle BoxL = new(BaseX + 34, StripTop, Bar, StripH);
private static readonly Rectangle BoxR = new(BaseX + 34 + 82 - Bar, StripTop, Bar, StripH); private static readonly Rectangle BoxR = new(BaseX + 34 + 82 - Bar, StripTop, Bar, StripH);
@@ -109,19 +125,27 @@ internal sealed class PanelCanvas : Control
base.Dispose(disposing); base.Dispose(disposing);
} }
// Split from GridSize so the static gauge layout can use the width
// without touching TopStrip (which initializes after AllButtons).
private static int GridWidth()
{
int maxCol = 0;
foreach (PanelButton b in AllButtons)
if (b.Col > maxCol) maxCol = b.Col;
return (maxCol + 1) * CellW + 6;
}
private static Size GridSize() private static Size GridSize()
{ {
int maxCol = 0, maxRow = 0; int maxRow = 0;
foreach (PanelButton b in AllButtons) foreach (PanelButton b in AllButtons)
{
if (b.Col > maxCol) maxCol = b.Col;
if (b.Row > maxRow) maxRow = b.Row; if (b.Row > maxRow) maxRow = b.Row;
} // The last used row plus a small margin under the lowest group frame.
return new Size((maxCol + 1) * CellW + 6, TopStrip + (maxRow + 2) * CellH); return new Size(GridWidth(), TopStrip + (maxRow - FirstRow + 1) * CellH + 6);
} }
private static Rectangle Cell(int col, int row) => private static Rectangle Cell(int col, int row) =>
new(col * CellW + 2, TopStrip + row * CellH + 2, CellW - 4, CellH - 4); new(col * CellW + 2, TopStrip + (row - FirstRow) * CellH + 2, CellW - 4, CellH - 4);
// ---- Painting ---------------------------------------------------------- // ---- Painting ----------------------------------------------------------
@@ -142,7 +166,7 @@ internal sealed class PanelCanvas : Control
{ {
var frame = new Rectangle( var frame = new Rectangle(
grp.OriginCol * CellW + 1, grp.OriginCol * CellW + 1,
TopStrip + grp.OriginRow * CellH + 1, TopStrip + (grp.OriginRow - FirstRow) * CellH + 1,
grp.Cols * CellW, grp.Cols * CellW,
(grp.Rows + 1) * CellH); (grp.Rows + 1) * CellH);
g.DrawRectangle(groupPen, frame); g.DrawRectangle(groupPen, frame);
@@ -288,10 +312,12 @@ internal sealed class PanelCanvas : Control
new Rectangle(BoxXY.X, BoxXY.Bottom - 16, BoxXY.Width, 14), Color.Gainsboro, new Rectangle(BoxXY.X, BoxXY.Bottom - 16, BoxXY.Width, 14), Color.Gainsboro,
TextFormatFlags.HorizontalCenter | TextFormatFlags.Bottom); TextFormatFlags.HorizontalCenter | TextFormatFlags.Bottom);
// The mockup's green status/instruction area, right of the gauges; the // The mockup's green status/instruction area, between the plasma glass
// live axis readout sits directly under the status lines (painted per // and the gauges; the live axis readout sits directly under the status
// frame, so drags track). // lines (painted per frame, so drags track).
var statusRect = new Rectangle(BoxXY.Right + 16, StripTop, Width - BoxXY.Right - 24, TopStrip - StripTop - 6); var statusRect = new Rectangle(
PlasmaSlot.X + PlasmaSize.Width + 12, StripTop,
BoxZ.Left - PlasmaSlot.X - PlasmaSize.Width - 24, StripH);
if (statusRect.Width > 60) if (statusRect.Width > 60)
{ {
using var statusFont = new Font("Consolas", 8f); using var statusFont = new Font("Consolas", 8f);
@@ -307,11 +333,11 @@ internal sealed class PanelCanvas : Control
short WireAxis(RioAxis a) => WireAxisProvider?.Invoke(a) ?? Axis(a); short WireAxis(RioAxis a) => WireAxisProvider?.Invoke(a) ?? Axis(a);
string readout = string readout =
$"Z {WireAxis(RioAxis.Throttle),4} L {WireAxis(RioAxis.LeftPedal),3} R {WireAxis(RioAxis.RightPedal),3} " + $"Z {WireAxis(RioAxis.Throttle),4} L {WireAxis(RioAxis.LeftPedal),3} R {WireAxis(RioAxis.RightPedal),3}\n" +
$"X {WireAxis(RioAxis.JoystickX),3} Y {WireAxis(RioAxis.JoystickY),3}"; $"X {WireAxis(RioAxis.JoystickX),3} Y {WireAxis(RioAxis.JoystickY),3}";
TextRenderer.DrawText(g, readout, statusFont, TextRenderer.DrawText(g, readout, statusFont,
new Rectangle(statusRect.X, readoutTop, statusRect.Width, statusRect.Bottom - readoutTop), green, new Rectangle(statusRect.X, readoutTop, statusRect.Width, statusRect.Bottom - readoutTop), green,
TextFormatFlags.Left | TextFormatFlags.Top | TextFormatFlags.SingleLine); TextFormatFlags.Left | TextFormatFlags.Top);
} }
} }
+5
View File
@@ -17,6 +17,11 @@
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\VRio.Core\VRio.Core.csproj" /> <ProjectReference Include="..\VRio.Core\VRio.Core.csproj" />
<!-- The built-in plasma glass: vPLASMA's device/protocol core, plus its
canvas control compiled in directly (shared file, not a library —
VPlasma.Core stays UI-free). -->
<ProjectReference Include="..\VPlasma.Core\VPlasma.Core.csproj" />
<Compile Include="..\VPlasma.App\PlasmaCanvas.cs" Link="PlasmaCanvas.cs" />
</ItemGroup> </ItemGroup>
<!-- Stamp commit date + short sha into InformationalVersion; the window <!-- Stamp commit date + short sha into InformationalVersion; the window
+134
View File
@@ -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 &lt;len:u8&gt; &lt;len bytes&gt; serial data, len ≥ 1 (batching allowed)
/// 0x01 &lt;lines:u8&gt; 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;
}
}
+392
View File
@@ -0,0 +1,392 @@
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 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>
/// 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();
}
}
-1
View File
@@ -12,7 +12,6 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="System.Memory" Version="4.5.5" /> <PackageReference Include="System.Memory" Version="4.5.5" />
<PackageReference Include="Microsoft.Bcl.HashCode" Version="1.1.1" />
<PackageReference Include="PolySharp" Version="1.14.1"> <PackageReference Include="PolySharp" Version="1.14.1">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
@@ -0,0 +1,99 @@
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 async Task 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(await Task.WhenAny(eof, Task.Delay(2000)) == eof,
"server did not drop the connection");
Assert.True(await eof);
}
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);
}
}
+327
View File
@@ -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]);
}
}