vPLASMA slims to a bare glass hardwired to COM12

The window is now just the display: 640x160 dot field (5 px pitch,
4 px bezel) plus the title bar, which carries the port status. COM12 —
the device end of the plasma's null-modem pair — is hardwired and
opened at startup, with a retry timer that keeps trying while the port
is missing or busy and reopens it if it dies. The control strip, port
picker, counters, and wire log are gone; the glass keeps two gestures:
double-click cycles the self-test pages, right-click resets the
display to its power-on state.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Cyd
2026-07-07 15:06:36 -05:00
co-authored by Claude Fable 5
parent a1b7dae3da
commit 19a1abbff2
4 changed files with 54 additions and 189 deletions
+5 -3
View File
@@ -13,9 +13,11 @@ dotnet build vrio.sln -v q -nologo # Debug
# exe: src\VPlasma.App\bin\Debug\net48\VPlasma.App.exe (companion display) # exe: src\VPlasma.App\bin\Debug\net48\VPlasma.App.exe (companion display)
``` ```
Everything below applies to vPLASMA too. Its serial path can be tested Everything below applies to vPLASMA too. It auto-opens **COM12** on
end-to-end on this machine: vPLASMA opens **COM12**, the script plays the launch (hardwired; status in the title bar), so its serial path tests
game writing **COM2** (second com0com pair; vRIO/RIOJoy use COM1⇄COM11). end-to-end with no UI driving: the script just plays the game writing
**COM2** (second com0com pair; vRIO/RIOJoy use COM1⇄COM11). Double-click
the glass = self-test pages, right-click = reset.
Killing a writer mid-stream can leave stale bytes queued in the com0com Killing a writer mid-stream can leave stale bytes queued in the com0com
buffer — the next session's byte counter will run high; re-run clean buffer — the next session's byte counter will run high; re-run clean
before trusting counts. before trusting counts.
+11 -7
View File
@@ -108,8 +108,10 @@ a null-modem cable work the same way.
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. `VPlasma.App` is its software replica:
it opens the device end of a COM port, decodes the display's command a bare-glass window that opens **COM12** (the device end of the plasma's
stream, and renders the dot matrix in plasma orange — text mode included. null-modem pair) on startup — retrying while the port is missing or busy,
port status in the title bar — decodes the display's command stream, and
renders the dot matrix in plasma orange, text mode included.
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
@@ -129,12 +131,14 @@ recovered grammar lives in `src/VPlasma.Core/Protocol/PlasmaProtocol.cs`.
cell, flashing text (and the flashing cursor) blink on the glass. The cell, flashing text (and the flashing cursor) blink on the glass. The
panel's own ROM glyphs are lost with the hardware, so the classic panel's own ROM glyphs are lost with the hardware, so the classic
public-domain 5×7 set stands in. public-domain 5×7 set stands in.
- **Self test** cycles three pages (banner, charset, graphics pattern) - **Double-click** the glass to cycle three self-test pages (banner,
through the same parser the wire feeds — useful without a host. charset, graphics pattern) through the same parser the wire feeds —
useful without a host. **Right-click** resets the display to its
power-on state.
- Pair it with the game like vRIO ↔ RIOJoy: a second com0com null-modem - Pair it with the game like vRIO ↔ RIOJoy: a second com0com null-modem
pair (e.g. `COM2 ⇄ COM12`) — the game's plasma output writes COM2, pair `COM2 ⇄ COM12` — the game's plasma output writes COM2, vPLASMA
vPLASMA listens on COM12. The plasma never talks back, so there is no listens on COM12. The plasma never talks back, so there is no transmit
transmit path to pace. path to pace.
## Repository layout ## Repository layout
+35 -176
View File
@@ -1,238 +1,97 @@
using System.IO.Ports;
using VPlasma.Core.Device; using VPlasma.Core.Device;
namespace VPlasma.App; namespace VPlasma.App;
/// <summary> /// <summary>
/// vPLASMA main window: the plasma glass on the left, a control strip on the /// vPLASMA main window: just the plasma glass, sized to the display. The
/// right — COM port, self-test/clear, live counters, and a decoded-command /// device end is hardwired to <see cref="PortName"/> (the plasma side of the
/// log. Open the COM port, point the game's COM2 (plasma) passthrough at the /// second com0com pair) and opened automatically — a retry timer keeps
/// other end of the null-modem pair, and whatever the sim draws on the /// trying while the port is missing or busy, and reopens it if it dies. The
/// cockpit's plasma display appears here. /// 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.
/// </summary> /// </summary>
internal sealed class MainForm : Form internal sealed class MainForm : Form
{ {
/// <summary>The plasma's fixed port: the device end of the COM2 pair.</summary>
private const string PortName = "COM12";
private readonly VPlasmaDevice _device = new(); private readonly VPlasmaDevice _device = new();
private readonly VPlasmaSerialService _service; private readonly VPlasmaSerialService _service;
private readonly PlasmaCanvas _canvas = new(); private readonly PlasmaCanvas _canvas = new();
private readonly ComboBox _portBox = new() private readonly System.Windows.Forms.Timer _reconnectTimer = new() { Interval = 3000 };
{
Location = new Point(80, 12),
Width = 128,
DropDownStyle = ComboBoxStyle.DropDownList,
};
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 Label _linkStatus = new()
{
Text = "Port closed.",
Location = new Point(12, 42),
AutoSize = true,
ForeColor = Color.Gray,
};
private readonly Button _selfTest = new() { Text = "Self test", Location = new Point(10, 22), Width = 140, Height = 26 };
private readonly Button _clear = new() { Text = "Clear display", Location = new Point(156, 22), Width = 140, Height = 26 };
private readonly Label _displayHint = new()
{
Text = "Self test cycles three pages (banner, charset, graphics) through the same parser the wire feeds.",
Location = new Point(10, 54),
MaximumSize = new Size(288, 0),
AutoSize = true,
ForeColor = Color.Gray,
};
private readonly Label _counters = new()
{
Location = new Point(12, 178),
AutoSize = true,
Font = new Font("Consolas", 8f),
};
private readonly TextBox _logBox = new()
{
Location = new Point(12, 296),
Multiline = true,
ReadOnly = true,
ScrollBars = ScrollBars.Both, // long wire lines don't wrap — scroll to read
BackColor = Color.FromArgb(24, 24, 24),
ForeColor = Color.Gainsboro,
Font = new Font("Consolas", 8f),
Anchor = AnchorStyles.Top | AnchorStyles.Bottom | AnchorStyles.Left,
WordWrap = false,
};
private readonly Button _clearLog = new()
{
Text = "Clear log",
Anchor = AnchorStyles.Bottom | AnchorStyles.Left,
};
private readonly System.Windows.Forms.Timer _uiTimer = new() { Interval = 500 };
private int _selfTestPage; private int _selfTestPage;
public MainForm() public MainForm()
{ {
// ProductVersion carries the git stamp (see StampGitVersion in the csproj).
Text = $"vPLASMA v{Application.ProductVersion} — Virtual plasma display";
// Title-bar/taskbar icon from the exe's embedded ApplicationIcon (vwe.ico).
Icon = Icon.ExtractAssociatedIcon(Application.ExecutablePath); Icon = Icon.ExtractAssociatedIcon(Application.ExecutablePath);
ClientSize = new Size(_canvas.Width + 24 + 332, 560); FormBorderStyle = FormBorderStyle.FixedSingle; // the glass is a fixed-size device
MinimumSize = new Size(_canvas.Width + 24 + 332, 420); MaximizeBox = false;
ClientSize = _canvas.Size;
StartPosition = FormStartPosition.CenterScreen; StartPosition = FormStartPosition.CenterScreen;
_service = new VPlasmaSerialService(_device); _service = new VPlasmaSerialService(_device);
// The glass, on a dark backdrop that absorbs any extra window space. _canvas.Location = new Point(0, 0);
var backdrop = new Panel { Dock = DockStyle.Fill, AutoScroll = true, BackColor = Color.FromArgb(28, 28, 28) }; Controls.Add(_canvas);
_canvas.Location = new Point(12, 12);
backdrop.Controls.Add(_canvas);
Controls.Add(backdrop);
Controls.Add(BuildControlStrip());
// 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));
_device.Logged += line => RunOnUi(() => PrependLog(line)); _service.ConnectionChanged += _ => RunOnUi(UpdateTitle);
_service.Logged += line => RunOnUi(() => PrependLog(line));
_service.ConnectionChanged += open => RunOnUi(() => OnConnectionChanged(open));
_rescan.Click += (_, _) => RefreshPorts(); _canvas.DoubleClick += (_, _) => RunSelfTest();
_openClose.Click += (_, _) => ToggleOpen(); _canvas.MouseClick += (_, e) =>
_selfTest.Click += (_, _) => RunSelfTest();
_clear.Click += (_, _) =>
{ {
_device.Reset(); if (e.Button == MouseButtons.Right)
PrependLog("Display reset to power-on state"); _device.Reset();
}; };
_clearLog.Click += (_, _) => _logBox.Clear();
_uiTimer.Tick += (_, _) => UpdateStatus(); // Open at startup, retry while closed (port missing/busy, host restarts).
_uiTimer.Start(); _reconnectTimer.Tick += (_, _) => EnsureOpen();
_reconnectTimer.Start();
FormClosed += (_, _) => FormClosed += (_, _) =>
{ {
_uiTimer.Dispose(); _reconnectTimer.Dispose();
_service.Dispose(); _service.Dispose();
}; };
RefreshPorts();
UpdateStatus();
_canvas.UpdateFrom(_device); _canvas.UpdateFrom(_device);
PrependLog("vPLASMA ready. Open a COM port, then point the game's plasma output (COM2) at the other end of the pair."); EnsureOpen();
} }
private Panel BuildControlStrip() private void EnsureOpen()
{
var panel = new Panel
{
Dock = DockStyle.Right,
Width = 330,
BackColor = SystemColors.Control,
};
panel.Controls.Add(new Label { Text = "COM port:", Location = new Point(12, 15), AutoSize = true });
panel.Controls.Add(_portBox);
panel.Controls.Add(_rescan);
panel.Controls.Add(_openClose);
panel.Controls.Add(_linkStatus);
var display = new GroupBox { Text = "Display", Location = new Point(12, 68), Size = new Size(306, 100) };
display.Controls.Add(_selfTest);
display.Controls.Add(_clear);
display.Controls.Add(_displayHint);
panel.Controls.Add(display);
panel.Controls.Add(_counters);
var logLabel = new Label { Text = "Wire log:", Location = new Point(12, 278), AutoSize = true };
panel.Controls.Add(logLabel);
panel.Controls.Add(_logBox);
panel.Controls.Add(_clearLog);
panel.Resize += (_, _) =>
{
_logBox.Size = new Size(306, panel.ClientSize.Height - _logBox.Top - 42);
_clearLog.SetBounds(12, panel.ClientSize.Height - 34, 80, 26);
};
return panel;
}
private void RefreshPorts()
{
string? selected = _portBox.SelectedItem as string;
_portBox.Items.Clear();
foreach (string name in SerialPort.GetPortNames().Distinct().OrderBy(n => n, StringComparer.OrdinalIgnoreCase))
_portBox.Items.Add(name);
if (selected is not null && _portBox.Items.Contains(selected))
_portBox.SelectedItem = selected;
else if (_portBox.Items.Count > 0)
_portBox.SelectedIndex = 0;
}
private void ToggleOpen()
{ {
if (_service.IsOpen) if (_service.IsOpen)
{
_service.Close();
return; return;
}
if (_portBox.SelectedItem is not string port)
{
PrependLog("No COM port selected — install a com0com pair or attach an adapter, then Rescan.");
return;
}
try try
{ {
_service.Open(port); _service.Open(PortName);
} }
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidOperationException or ArgumentException) catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidOperationException or ArgumentException)
{ {
PrependLog($"Open failed: {ex.Message}"); UpdateTitle(); // stays closed; the timer tries again
} }
} }
private void OnConnectionChanged(bool open) private void UpdateTitle()
{ {
_openClose.Text = open ? "Close" : "Open"; // ProductVersion carries the git stamp (see StampGitVersion in the csproj).
_portBox.Enabled = !open; string status = _service.IsOpen
_rescan.Enabled = !open; ? $"{PortName} @ 9600 8N1"
_linkStatus.Text = open ? $"Listening on {_service.PortName} @ 9600 8N1." : "Port closed."; : $"{PortName} unavailable — retrying";
_linkStatus.ForeColor = open ? Color.DarkGreen : Color.Gray; Text = $"vPLASMA v{Application.ProductVersion} — {status}";
} }
private void RunSelfTest() private void RunSelfTest()
{ {
byte[] bytes = PlasmaSelfTest.BuildPage(_selfTestPage); byte[] bytes = PlasmaSelfTest.BuildPage(_selfTestPage);
PrependLog($"Self test page {_selfTestPage + 1}/{PlasmaSelfTest.PageCount}: {bytes.Length} bytes fed through the parser");
_device.OnReceived(bytes, bytes.Length); _device.OnReceived(bytes, bytes.Length);
_selfTestPage = (_selfTestPage + 1) % PlasmaSelfTest.PageCount; _selfTestPage = (_selfTestPage + 1) % PlasmaSelfTest.PageCount;
} }
private void UpdateStatus()
{
VPlasmaDevice.Point cursor = _device.CursorCell;
_counters.Text =
$"Bytes received: {_device.BytesReceived}\n" +
$"Graphics rows: {_device.GraphicsRows}\n" +
$"Chars drawn: {_device.TextCharsDrawn}\n" +
$"Font / grid: {_device.Font} ({VPlasmaDevice.Width / _device.CellWidth}×{VPlasmaDevice.Height / _device.CellHeight} cells)\n" +
$"Cursor: col {cursor.Col}, row {cursor.Row} ({_device.CursorMode})\n" +
$"Attributes: {(_device.Attributes == PlasmaAttributes.None ? "default" : _device.Attributes.ToString())}";
}
private void PrependLog(string line)
{
string stamped = $"{DateTime.Now:HH:mm:ss.fff} {line}";
string text = _logBox.TextLength == 0 ? stamped : stamped + Environment.NewLine + _logBox.Text;
if (text.Length > 20000)
text = text.Substring(0, 20000);
_logBox.Text = text;
}
private void RunOnUi(Action action) private void RunOnUi(Action action)
{ {
if (IsDisposed || Disposing) if (IsDisposed || Disposing)
+3 -3
View File
@@ -11,9 +11,9 @@ namespace VPlasma.App;
/// </summary> /// </summary>
internal sealed class PlasmaCanvas : Control internal sealed class PlasmaCanvas : Control
{ {
private const int DotPitch = 6; // 5 px dot + 1 px gap private const int DotPitch = 5; // 4 px dot + 1 px gap → a 640×160 dot field
private const int DotSize = 5; private const int DotSize = 4;
private const int Bezel = 16; private const int Bezel = 4;
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);