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:
@@ -13,9 +13,11 @@ dotnet build vrio.sln -v q -nologo # Debug
|
||||
# exe: src\VPlasma.App\bin\Debug\net48\VPlasma.App.exe (companion display)
|
||||
```
|
||||
|
||||
Everything below applies to vPLASMA too. Its serial path can be tested
|
||||
end-to-end on this machine: vPLASMA opens **COM12**, the script plays the
|
||||
game writing **COM2** (second com0com pair; vRIO/RIOJoy use COM1⇄COM11).
|
||||
Everything below applies to vPLASMA too. It auto-opens **COM12** on
|
||||
launch (hardwired; status in the title bar), so its serial path tests
|
||||
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
|
||||
buffer — the next session's byte counter will run high; re-run clean
|
||||
before trusting counts.
|
||||
|
||||
@@ -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
|
||||
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:
|
||||
it opens the device end of a COM port, decodes the display's command
|
||||
stream, and renders the dot matrix in plasma orange — text mode included.
|
||||
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 — 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:
|
||||
`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
|
||||
panel's own ROM glyphs are lost with the hardware, so the classic
|
||||
public-domain 5×7 set stands in.
|
||||
- **Self test** cycles three pages (banner, charset, graphics pattern)
|
||||
through the same parser the wire feeds — useful without a host.
|
||||
- **Double-click** the glass to cycle three self-test pages (banner,
|
||||
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 (e.g. `COM2 ⇄ COM12`) — the game's plasma output writes COM2,
|
||||
vPLASMA listens on COM12. The plasma never talks back, so there is no
|
||||
transmit path to pace.
|
||||
pair `COM2 ⇄ COM12` — the game's plasma output writes COM2, vPLASMA
|
||||
listens on COM12. The plasma never talks back, so there is no transmit
|
||||
path to pace.
|
||||
|
||||
## Repository layout
|
||||
|
||||
|
||||
+35
-176
@@ -1,238 +1,97 @@
|
||||
using System.IO.Ports;
|
||||
using VPlasma.Core.Device;
|
||||
|
||||
namespace VPlasma.App;
|
||||
|
||||
/// <summary>
|
||||
/// vPLASMA main window: the plasma glass on the left, a control strip on the
|
||||
/// right — COM port, self-test/clear, live counters, and a decoded-command
|
||||
/// log. Open the COM port, point the game's COM2 (plasma) passthrough at the
|
||||
/// other end of the null-modem pair, and whatever the sim draws on the
|
||||
/// cockpit's plasma display appears here.
|
||||
/// vPLASMA main window: just the plasma glass, sized to the display. The
|
||||
/// 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
|
||||
/// 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>
|
||||
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 VPlasmaSerialService _service;
|
||||
private readonly PlasmaCanvas _canvas = new();
|
||||
|
||||
private readonly ComboBox _portBox = new()
|
||||
{
|
||||
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 readonly System.Windows.Forms.Timer _reconnectTimer = new() { Interval = 3000 };
|
||||
private int _selfTestPage;
|
||||
|
||||
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);
|
||||
ClientSize = new Size(_canvas.Width + 24 + 332, 560);
|
||||
MinimumSize = new Size(_canvas.Width + 24 + 332, 420);
|
||||
FormBorderStyle = FormBorderStyle.FixedSingle; // the glass is a fixed-size device
|
||||
MaximizeBox = false;
|
||||
ClientSize = _canvas.Size;
|
||||
StartPosition = FormStartPosition.CenterScreen;
|
||||
|
||||
_service = new VPlasmaSerialService(_device);
|
||||
|
||||
// The glass, on a dark backdrop that absorbs any extra window space.
|
||||
var backdrop = new Panel { Dock = DockStyle.Fill, AutoScroll = true, BackColor = Color.FromArgb(28, 28, 28) };
|
||||
_canvas.Location = new Point(12, 12);
|
||||
backdrop.Controls.Add(_canvas);
|
||||
|
||||
Controls.Add(backdrop);
|
||||
Controls.Add(BuildControlStrip());
|
||||
_canvas.Location = new Point(0, 0);
|
||||
Controls.Add(_canvas);
|
||||
|
||||
// Device / service events arrive on the serial reader thread; marshal to the UI.
|
||||
_device.Updated += () => RunOnUi(() => _canvas.UpdateFrom(_device));
|
||||
_device.Logged += line => RunOnUi(() => PrependLog(line));
|
||||
_service.Logged += line => RunOnUi(() => PrependLog(line));
|
||||
_service.ConnectionChanged += open => RunOnUi(() => OnConnectionChanged(open));
|
||||
_service.ConnectionChanged += _ => RunOnUi(UpdateTitle);
|
||||
|
||||
_rescan.Click += (_, _) => RefreshPorts();
|
||||
_openClose.Click += (_, _) => ToggleOpen();
|
||||
_selfTest.Click += (_, _) => RunSelfTest();
|
||||
_clear.Click += (_, _) =>
|
||||
_canvas.DoubleClick += (_, _) => RunSelfTest();
|
||||
_canvas.MouseClick += (_, e) =>
|
||||
{
|
||||
_device.Reset();
|
||||
PrependLog("Display reset to power-on state");
|
||||
if (e.Button == MouseButtons.Right)
|
||||
_device.Reset();
|
||||
};
|
||||
_clearLog.Click += (_, _) => _logBox.Clear();
|
||||
|
||||
_uiTimer.Tick += (_, _) => UpdateStatus();
|
||||
_uiTimer.Start();
|
||||
// Open at startup, retry while closed (port missing/busy, host restarts).
|
||||
_reconnectTimer.Tick += (_, _) => EnsureOpen();
|
||||
_reconnectTimer.Start();
|
||||
|
||||
FormClosed += (_, _) =>
|
||||
{
|
||||
_uiTimer.Dispose();
|
||||
_reconnectTimer.Dispose();
|
||||
_service.Dispose();
|
||||
};
|
||||
|
||||
RefreshPorts();
|
||||
UpdateStatus();
|
||||
_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()
|
||||
{
|
||||
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()
|
||||
private void EnsureOpen()
|
||||
{
|
||||
if (_service.IsOpen)
|
||||
{
|
||||
_service.Close();
|
||||
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
|
||||
{
|
||||
_service.Open(port);
|
||||
_service.Open(PortName);
|
||||
}
|
||||
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";
|
||||
_portBox.Enabled = !open;
|
||||
_rescan.Enabled = !open;
|
||||
_linkStatus.Text = open ? $"Listening on {_service.PortName} @ 9600 8N1." : "Port closed.";
|
||||
_linkStatus.ForeColor = open ? Color.DarkGreen : Color.Gray;
|
||||
// ProductVersion carries the git stamp (see StampGitVersion in the csproj).
|
||||
string status = _service.IsOpen
|
||||
? $"{PortName} @ 9600 8N1"
|
||||
: $"{PortName} unavailable — retrying";
|
||||
Text = $"vPLASMA v{Application.ProductVersion} — {status}";
|
||||
}
|
||||
|
||||
private void RunSelfTest()
|
||||
{
|
||||
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);
|
||||
_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)
|
||||
{
|
||||
if (IsDisposed || Disposing)
|
||||
|
||||
@@ -11,9 +11,9 @@ namespace VPlasma.App;
|
||||
/// </summary>
|
||||
internal sealed class PlasmaCanvas : Control
|
||||
{
|
||||
private const int DotPitch = 6; // 5 px dot + 1 px gap
|
||||
private const int DotSize = 5;
|
||||
private const int Bezel = 16;
|
||||
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 static readonly Color BezelColor = Color.FromArgb(20, 18, 16);
|
||||
private static readonly Color GlassColor = Color.FromArgb(26, 14, 6);
|
||||
|
||||
Reference in New Issue
Block a user