diff --git a/.claude/skills/verify/SKILL.md b/.claude/skills/verify/SKILL.md
index 5e81f5d..321560a 100644
--- a/.claude/skills/verify/SKILL.md
+++ b/.claude/skills/verify/SKILL.md
@@ -10,8 +10,16 @@ Build + tests (tests are CI's job; run the app for verification):
```powershell
dotnet build vrio.sln -v q -nologo # Debug
# exe: src\VRio.App\bin\Debug\net48\VRio.App.exe
+# 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).
+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.
+
## Driving the GUI without touching the user's desktop
This is the user's live desktop — **never** use SetForegroundWindow +
@@ -33,6 +41,14 @@ VS Code and your clicks land in the user's windows. Instead:
(they surface as bare Panes) and don't answer BM_GETCHECK — but
`SendMessage(hwnd, BM_CLICK, 0, 0)` works. Get the HWND from the UIA
element's NativeWindowHandle (find by Name).
+- **Combo boxes** (COM port pickers): string-carrying messages
+ (`CB_FINDSTRINGEXACT`, `CB_GETLBTEXT`) do **not** marshal across
+ processes — the SendMessage hangs. Compute the item index locally
+ (both apps fill from `SerialPort.GetPortNames()` sorted
+ OrdinalIgnoreCase) and send index-only `CB_SETCURSEL`; the apps read
+ `SelectedItem` lazily so no CBN_SELCHANGE notification is needed.
+ Find the combo child HWND via EnumChildWindows + GetClassName
+ containing `COMBOBOX` (UIA ClassName "ComboBox" doesn't match).
- Pattern that works: Add-Type user32 P/Invokes, Start-Process the exe,
drive via messages, PrintWindow screenshot to the scratchpad, kill
the process in `finally`.
diff --git a/README.md b/README.md
index b3aaf7c..89be816 100644
--- a/README.md
+++ b/README.md
@@ -3,7 +3,9 @@
A software replica of the cockpit **RIO** (Remote Input/Output) board: it opens
a COM port and speaks the **device side** of the RIO serial protocol, so any
host that expects the real hardware — most importantly
-[RIOJoy](../riojoy/) — can talk to it without a cockpit attached.
+[RIOJoy](../riojoy/) — can talk to it without a cockpit attached. (The
+cockpit's other serial device gets the same treatment: see
+[vPLASMA](#vplasma--the-companion-plasma-display) below.)
The window is an interactive version of the cockpit control panel that
RIOJoy's profile editor draws (the same functional layout from the original
@@ -91,24 +93,60 @@ device behavior grounded in the **real v4.2 firmware dump**
The two apps need a crossed serial link. Install a
[com0com](https://com0com.sourceforge.net/) virtual null-modem pair
-(e.g. `COM5 ⇄ COM6`), then:
+(e.g. `COM1 ⇄ COM11`), then:
-1. Run `VRio.App`, pick `COM5`, **Open**.
-2. Point RIOJoy at `COM6`.
+1. Run `VRio.App`, pick `COM11`, **Open**.
+2. RIOJoy will always poit to `COM1`.
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
the vRIO panel arrives at RIOJoy as real cockpit input. Two physical PCs with
a null-modem cable work the same way.
+## vPLASMA — the companion plasma display
+
+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.
+
+The command set was recovered from two Tesla 4.10 artifacts:
+`CODE\RP\MUNGA_L4\L4PLASMA.CPP` (the game's driver — it renders everything
+into a local 1bpp buffer and streams changed rows as `ESC P` graphics
+writes, hiding the cursor with `ESC G 0` at boot) and the factory test tool
+`VWETEST\VGLTEST\PLASMA.EXE` (the text mode: BS/HT/LF/VT/CR cursor motion,
+`ESC @` clear, `ESC L` home, `ESC G` cursor visibility, `ESC K` font
+select, `ESC H` attributes — intensity/underline/reverse/flash). The full
+recovered grammar lives in `src/VPlasma.Core/Protocol/PlasmaProtocol.cs`.
+
+- **Graphics** — `ESC P screen y xbyte width rows` + packed 1bpp row data,
+ MSB = leftmost pixel. This is everything the game sends, so it is the
+ wire path a pod's plasma actually sees.
+- **Text** — printable ASCII renders through a 5×7 font at a cursor:
+ fonts 0–3 give a 21×4 cell grid, fonts 4–7 the same glyphs doubled to
+ 10×2. Half-intensity draws dimmer, reverse/underline render in 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
+ 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.
+- 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.
+
## Repository layout
| Path | Contents |
|------|----------|
| `src/VRio.Core` | Protocol framing/builder/parser, the `VRioDevice` state machine, serial pump, panel layout data (class library) |
| `src/VRio.App` | WinForms panel UI |
+| `src/VPlasma.Core` | Plasma command-stream parser, the `VPlasmaDevice` display state machine, 5×7 font, serial listener (class library) |
+| `src/VPlasma.App` | WinForms dot-matrix display UI |
| `pkg` | Sparse-package manifest + registration script: grants VRio.App.exe the package identity Dynamic Lighting's background-control list requires |
| `tests/VRio.Core.Tests` | xUnit tests for the protocol + device engine |
+| `tests/VPlasma.Core.Tests` | xUnit tests for the plasma parser + display engine |
## Building
diff --git a/VRio.sln b/VRio.sln
index 544fd34..ac3b749 100644
--- a/VRio.sln
+++ b/VRio.sln
@@ -13,6 +13,12 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{C4993638
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "VRio.Core.Tests", "tests\VRio.Core.Tests\VRio.Core.Tests.csproj", "{986638BB-F289-4480-8575-F1699D201590}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "VPlasma.Core", "src\VPlasma.Core\VPlasma.Core.csproj", "{39E7C28F-8B07-495C-A887-21F2F6AF9A86}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "VPlasma.App", "src\VPlasma.App\VPlasma.App.csproj", "{72D03B3A-7D4E-496C-8DA9-596DFC91704E}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "VPlasma.Core.Tests", "tests\VPlasma.Core.Tests\VPlasma.Core.Tests.csproj", "{F31F1D86-546A-4B0C-A283-C04FAAC49F46}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -34,10 +40,25 @@ Global
{986638BB-F289-4480-8575-F1699D201590}.Debug|Any CPU.Build.0 = Debug|Any CPU
{986638BB-F289-4480-8575-F1699D201590}.Release|Any CPU.ActiveCfg = Release|Any CPU
{986638BB-F289-4480-8575-F1699D201590}.Release|Any CPU.Build.0 = Release|Any CPU
+ {39E7C28F-8B07-495C-A887-21F2F6AF9A86}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {39E7C28F-8B07-495C-A887-21F2F6AF9A86}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {39E7C28F-8B07-495C-A887-21F2F6AF9A86}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {39E7C28F-8B07-495C-A887-21F2F6AF9A86}.Release|Any CPU.Build.0 = Release|Any CPU
+ {72D03B3A-7D4E-496C-8DA9-596DFC91704E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {72D03B3A-7D4E-496C-8DA9-596DFC91704E}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {72D03B3A-7D4E-496C-8DA9-596DFC91704E}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {72D03B3A-7D4E-496C-8DA9-596DFC91704E}.Release|Any CPU.Build.0 = Release|Any CPU
+ {F31F1D86-546A-4B0C-A283-C04FAAC49F46}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {F31F1D86-546A-4B0C-A283-C04FAAC49F46}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {F31F1D86-546A-4B0C-A283-C04FAAC49F46}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {F31F1D86-546A-4B0C-A283-C04FAAC49F46}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{80312F43-09BF-4F09-A0FA-A60FDF86274D} = {D0ECC9D9-7379-4759-89F7-56CD3214BD57}
{2D1A482C-D907-47EB-9830-B78132154E57} = {D0ECC9D9-7379-4759-89F7-56CD3214BD57}
{986638BB-F289-4480-8575-F1699D201590} = {C4993638-7EB6-47A9-897C-976DB9939601}
+ {39E7C28F-8B07-495C-A887-21F2F6AF9A86} = {D0ECC9D9-7379-4759-89F7-56CD3214BD57}
+ {72D03B3A-7D4E-496C-8DA9-596DFC91704E} = {D0ECC9D9-7379-4759-89F7-56CD3214BD57}
+ {F31F1D86-546A-4B0C-A283-C04FAAC49F46} = {C4993638-7EB6-47A9-897C-976DB9939601}
EndGlobalSection
EndGlobal
diff --git a/src/VPlasma.App/MainForm.cs b/src/VPlasma.App/MainForm.cs
new file mode 100644
index 0000000..5d0ece1
--- /dev/null
+++ b/src/VPlasma.App/MainForm.cs
@@ -0,0 +1,245 @@
+using System.IO.Ports;
+using VPlasma.Core.Device;
+
+namespace VPlasma.App;
+
+///
+/// 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.
+///
+internal sealed class MainForm : Form
+{
+ 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 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);
+ 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());
+
+ // 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));
+
+ _rescan.Click += (_, _) => RefreshPorts();
+ _openClose.Click += (_, _) => ToggleOpen();
+ _selfTest.Click += (_, _) => RunSelfTest();
+ _clear.Click += (_, _) =>
+ {
+ _device.Reset();
+ PrependLog("Display reset to power-on state");
+ };
+ _clearLog.Click += (_, _) => _logBox.Clear();
+
+ _uiTimer.Tick += (_, _) => UpdateStatus();
+ _uiTimer.Start();
+
+ FormClosed += (_, _) =>
+ {
+ _uiTimer.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.");
+ }
+
+ 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()
+ {
+ 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);
+ }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidOperationException or ArgumentException)
+ {
+ PrependLog($"Open failed: {ex.Message}");
+ }
+ }
+
+ private void OnConnectionChanged(bool open)
+ {
+ _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;
+ }
+
+ 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)
+ return;
+ if (InvokeRequired)
+ BeginInvoke(action);
+ else
+ action();
+ }
+}
diff --git a/src/VPlasma.App/PlasmaCanvas.cs b/src/VPlasma.App/PlasmaCanvas.cs
new file mode 100644
index 0000000..835e9a6
--- /dev/null
+++ b/src/VPlasma.App/PlasmaCanvas.cs
@@ -0,0 +1,121 @@
+using VPlasma.Core.Device;
+
+namespace VPlasma.App;
+
+///
+/// The glass: renders the device's 128×32 frame as a dot-matrix panel —
+/// neon-orange plasma dots on dark glass, with a faint unlit grid so the
+/// matrix reads as hardware. Half-intensity dots draw dimmer; flashing dots
+/// and the flashing cursor blink on a shared phase timer that only runs
+/// invalidations while something on screen actually blinks.
+///
+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 static readonly Color BezelColor = Color.FromArgb(20, 18, 16);
+ private static readonly Color GlassColor = Color.FromArgb(26, 14, 6);
+ private static readonly Color UnlitDot = Color.FromArgb(46, 24, 10);
+ private static readonly Color LitDot = Color.FromArgb(255, 106, 26);
+ private static readonly Color HalfDot = Color.FromArgb(150, 62, 15);
+
+ private readonly byte[] _frame = new byte[VPlasmaDevice.Width * VPlasmaDevice.Height];
+ private readonly System.Windows.Forms.Timer _blinkTimer = new() { Interval = 266 };
+ private bool _blinkPhase = true;
+ private bool _anythingBlinks;
+
+ private VPlasmaDevice.Point _cursor;
+ private PlasmaCursorMode _cursorMode;
+ private int _cellWidth = 6, _cellHeight = 8;
+
+ public PlasmaCanvas()
+ {
+ SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint |
+ ControlStyles.OptimizedDoubleBuffer | ControlStyles.FixedWidth |
+ ControlStyles.FixedHeight, true);
+ Size = new Size(
+ VPlasmaDevice.Width * DotPitch + 2 * Bezel,
+ VPlasmaDevice.Height * DotPitch + 2 * Bezel);
+ BackColor = BezelColor;
+
+ _blinkTimer.Tick += (_, _) =>
+ {
+ _blinkPhase = !_blinkPhase;
+ if (_anythingBlinks)
+ Invalidate();
+ };
+ _blinkTimer.Start();
+ }
+
+ /// Snapshot the device state and repaint. Call on the UI thread.
+ public void UpdateFrom(VPlasmaDevice device)
+ {
+ device.CopyFrame(_frame);
+ _cursor = device.CursorCell;
+ _cursorMode = device.CursorMode;
+ _cellWidth = device.CellWidth;
+ _cellHeight = device.CellHeight;
+
+ _anythingBlinks = _cursorMode == PlasmaCursorMode.Flashing;
+ if (!_anythingBlinks)
+ foreach (byte dot in _frame)
+ if ((dot & VPlasmaDevice.PixelFlash) != 0)
+ {
+ _anythingBlinks = true;
+ break;
+ }
+
+ Invalidate();
+ }
+
+ protected override void OnPaint(PaintEventArgs e)
+ {
+ Graphics g = e.Graphics;
+ g.Clear(BezelColor);
+
+ using (var glass = new SolidBrush(GlassColor))
+ g.FillRectangle(glass, Bezel - 4, Bezel - 4,
+ VPlasmaDevice.Width * DotPitch + 7, VPlasmaDevice.Height * DotPitch + 7);
+
+ using var unlit = new SolidBrush(UnlitDot);
+ using var lit = new SolidBrush(LitDot);
+ using var half = new SolidBrush(HalfDot);
+
+ for (int y = 0; y < VPlasmaDevice.Height; ++y)
+ {
+ int rowOffset = y * VPlasmaDevice.Width;
+ int py = Bezel + y * DotPitch;
+ for (int x = 0; x < VPlasmaDevice.Width; ++x)
+ {
+ byte dot = _frame[rowOffset + x];
+ Brush brush = unlit;
+ if ((dot & VPlasmaDevice.PixelLit) != 0
+ && ((dot & VPlasmaDevice.PixelFlash) == 0 || _blinkPhase))
+ {
+ brush = (dot & VPlasmaDevice.PixelHalf) != 0 ? half : lit;
+ }
+ g.FillRectangle(brush, Bezel + x * DotPitch, py, DotSize, DotSize);
+ }
+ }
+
+ // Underline cursor on the bottom dot-row of its cell.
+ if (_cursorMode == PlasmaCursorMode.Steady
+ || (_cursorMode == PlasmaCursorMode.Flashing && _blinkPhase))
+ {
+ int cx = _cursor.Col * _cellWidth;
+ int cy = _cursor.Row * _cellHeight + _cellHeight - 1;
+ if (cy < VPlasmaDevice.Height)
+ for (int i = 0; i < _cellWidth && cx + i < VPlasmaDevice.Width; ++i)
+ g.FillRectangle(lit, Bezel + (cx + i) * DotPitch, Bezel + cy * DotPitch, DotSize, DotSize);
+ }
+ }
+
+ protected override void Dispose(bool disposing)
+ {
+ if (disposing)
+ _blinkTimer.Dispose();
+ base.Dispose(disposing);
+ }
+}
diff --git a/src/VPlasma.App/Program.cs b/src/VPlasma.App/Program.cs
new file mode 100644
index 0000000..930e4ae
--- /dev/null
+++ b/src/VPlasma.App/Program.cs
@@ -0,0 +1,12 @@
+namespace VPlasma.App;
+
+internal static class Program
+{
+ [STAThread]
+ private static void Main()
+ {
+ Application.EnableVisualStyles();
+ Application.SetCompatibleTextRenderingDefault(false);
+ Application.Run(new MainForm());
+ }
+}
diff --git a/src/VPlasma.App/VPlasma.App.csproj b/src/VPlasma.App/VPlasma.App.csproj
new file mode 100644
index 0000000..f8c121c
--- /dev/null
+++ b/src/VPlasma.App/VPlasma.App.csproj
@@ -0,0 +1,51 @@
+
+
+
+ WinExe
+ net48
+ enable
+ true
+ enable
+ latest
+ app.manifest
+ vwe.ico
+ vPLASMA — Virtual plasma display
+
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ $(GitCommitDate.Trim().Replace('-', '.')) ($(GitShortSha))
+
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
diff --git a/src/VPlasma.App/app.manifest b/src/VPlasma.App/app.manifest
new file mode 100644
index 0000000..9151c19
--- /dev/null
+++ b/src/VPlasma.App/app.manifest
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/VPlasma.App/vwe.ico b/src/VPlasma.App/vwe.ico
new file mode 100644
index 0000000..f58deec
Binary files /dev/null and b/src/VPlasma.App/vwe.ico differ
diff --git a/src/VPlasma.Core/Device/PlasmaFont.cs b/src/VPlasma.Core/Device/PlasmaFont.cs
new file mode 100644
index 0000000..c88f2be
--- /dev/null
+++ b/src/VPlasma.Core/Device/PlasmaFont.cs
@@ -0,0 +1,130 @@
+namespace VPlasma.Core.Device;
+
+///
+/// The display's character generator: a classic 5×7 dot-matrix font for
+/// ASCII 0x20..0x7E, stored column-major (5 column bytes per glyph, bit 0 =
+/// top row) — the layout every KS0108-era controller used. The real panel's
+/// ROM glyphs are lost with the hardware; this is the standard public-domain
+/// 5×7 set, which is what such panels shipped with. Codes outside the range
+/// render as a solid block so stream corruption is visible on the glass.
+///
+public static class PlasmaFont
+{
+ public const int GlyphWidth = 5;
+ public const int GlyphHeight = 7;
+ public const byte First = 0x20;
+ public const byte Last = 0x7E;
+
+ ///
+ /// Column bits for 's glyph. Bit r of
+ /// result[c] is the dot at column c, row r (row 0 at the top).
+ ///
+ public static void GetColumns(byte code, Span columns)
+ {
+ if (code < First || code > Last)
+ {
+ columns.Slice(0, GlyphWidth).Fill(0x7F); // solid block
+ return;
+ }
+ Glyphs.AsSpan((code - First) * GlyphWidth, GlyphWidth).CopyTo(columns);
+ }
+
+ private static readonly byte[] Glyphs =
+ {
+ 0x00, 0x00, 0x00, 0x00, 0x00, // 0x20 ' '
+ 0x00, 0x00, 0x5F, 0x00, 0x00, // 0x21 '!'
+ 0x00, 0x07, 0x00, 0x07, 0x00, // 0x22 '"'
+ 0x14, 0x7F, 0x14, 0x7F, 0x14, // 0x23 '#'
+ 0x24, 0x2A, 0x7F, 0x2A, 0x12, // 0x24 '$'
+ 0x23, 0x13, 0x08, 0x64, 0x62, // 0x25 '%'
+ 0x36, 0x49, 0x55, 0x22, 0x50, // 0x26 '&'
+ 0x00, 0x05, 0x03, 0x00, 0x00, // 0x27 '''
+ 0x00, 0x1C, 0x22, 0x41, 0x00, // 0x28 '('
+ 0x00, 0x41, 0x22, 0x1C, 0x00, // 0x29 ')'
+ 0x08, 0x2A, 0x1C, 0x2A, 0x08, // 0x2A '*'
+ 0x08, 0x08, 0x3E, 0x08, 0x08, // 0x2B '+'
+ 0x00, 0x50, 0x30, 0x00, 0x00, // 0x2C ','
+ 0x08, 0x08, 0x08, 0x08, 0x08, // 0x2D '-'
+ 0x00, 0x60, 0x60, 0x00, 0x00, // 0x2E '.'
+ 0x20, 0x10, 0x08, 0x04, 0x02, // 0x2F '/'
+ 0x3E, 0x51, 0x49, 0x45, 0x3E, // 0x30 '0'
+ 0x00, 0x42, 0x7F, 0x40, 0x00, // 0x31 '1'
+ 0x42, 0x61, 0x51, 0x49, 0x46, // 0x32 '2'
+ 0x21, 0x41, 0x45, 0x4B, 0x31, // 0x33 '3'
+ 0x18, 0x14, 0x12, 0x7F, 0x10, // 0x34 '4'
+ 0x27, 0x45, 0x45, 0x45, 0x39, // 0x35 '5'
+ 0x3C, 0x4A, 0x49, 0x49, 0x30, // 0x36 '6'
+ 0x01, 0x71, 0x09, 0x05, 0x03, // 0x37 '7'
+ 0x36, 0x49, 0x49, 0x49, 0x36, // 0x38 '8'
+ 0x06, 0x49, 0x49, 0x29, 0x1E, // 0x39 '9'
+ 0x00, 0x36, 0x36, 0x00, 0x00, // 0x3A ':'
+ 0x00, 0x56, 0x36, 0x00, 0x00, // 0x3B ';'
+ 0x00, 0x08, 0x14, 0x22, 0x41, // 0x3C '<'
+ 0x14, 0x14, 0x14, 0x14, 0x14, // 0x3D '='
+ 0x41, 0x22, 0x14, 0x08, 0x00, // 0x3E '>'
+ 0x02, 0x01, 0x51, 0x09, 0x06, // 0x3F '?'
+ 0x32, 0x49, 0x79, 0x41, 0x3E, // 0x40 '@'
+ 0x7E, 0x11, 0x11, 0x11, 0x7E, // 0x41 'A'
+ 0x7F, 0x49, 0x49, 0x49, 0x36, // 0x42 'B'
+ 0x3E, 0x41, 0x41, 0x41, 0x22, // 0x43 'C'
+ 0x7F, 0x41, 0x41, 0x22, 0x1C, // 0x44 'D'
+ 0x7F, 0x49, 0x49, 0x49, 0x41, // 0x45 'E'
+ 0x7F, 0x09, 0x09, 0x09, 0x01, // 0x46 'F'
+ 0x3E, 0x41, 0x49, 0x49, 0x7A, // 0x47 'G'
+ 0x7F, 0x08, 0x08, 0x08, 0x7F, // 0x48 'H'
+ 0x00, 0x41, 0x7F, 0x41, 0x00, // 0x49 'I'
+ 0x20, 0x40, 0x41, 0x3F, 0x01, // 0x4A 'J'
+ 0x7F, 0x08, 0x14, 0x22, 0x41, // 0x4B 'K'
+ 0x7F, 0x40, 0x40, 0x40, 0x40, // 0x4C 'L'
+ 0x7F, 0x02, 0x0C, 0x02, 0x7F, // 0x4D 'M'
+ 0x7F, 0x04, 0x08, 0x10, 0x7F, // 0x4E 'N'
+ 0x3E, 0x41, 0x41, 0x41, 0x3E, // 0x4F 'O'
+ 0x7F, 0x09, 0x09, 0x09, 0x06, // 0x50 'P'
+ 0x3E, 0x41, 0x51, 0x21, 0x5E, // 0x51 'Q'
+ 0x7F, 0x09, 0x19, 0x29, 0x46, // 0x52 'R'
+ 0x46, 0x49, 0x49, 0x49, 0x31, // 0x53 'S'
+ 0x01, 0x01, 0x7F, 0x01, 0x01, // 0x54 'T'
+ 0x3F, 0x40, 0x40, 0x40, 0x3F, // 0x55 'U'
+ 0x1F, 0x20, 0x40, 0x20, 0x1F, // 0x56 'V'
+ 0x3F, 0x40, 0x38, 0x40, 0x3F, // 0x57 'W'
+ 0x63, 0x14, 0x08, 0x14, 0x63, // 0x58 'X'
+ 0x07, 0x08, 0x70, 0x08, 0x07, // 0x59 'Y'
+ 0x61, 0x51, 0x49, 0x45, 0x43, // 0x5A 'Z'
+ 0x00, 0x7F, 0x41, 0x41, 0x00, // 0x5B '['
+ 0x02, 0x04, 0x08, 0x10, 0x20, // 0x5C '\'
+ 0x00, 0x41, 0x41, 0x7F, 0x00, // 0x5D ']'
+ 0x04, 0x02, 0x01, 0x02, 0x04, // 0x5E '^'
+ 0x40, 0x40, 0x40, 0x40, 0x40, // 0x5F '_'
+ 0x00, 0x01, 0x02, 0x04, 0x00, // 0x60 '`'
+ 0x20, 0x54, 0x54, 0x54, 0x78, // 0x61 'a'
+ 0x7F, 0x48, 0x44, 0x44, 0x38, // 0x62 'b'
+ 0x38, 0x44, 0x44, 0x44, 0x20, // 0x63 'c'
+ 0x38, 0x44, 0x44, 0x48, 0x7F, // 0x64 'd'
+ 0x38, 0x54, 0x54, 0x54, 0x18, // 0x65 'e'
+ 0x08, 0x7E, 0x09, 0x01, 0x02, // 0x66 'f'
+ 0x0C, 0x52, 0x52, 0x52, 0x3E, // 0x67 'g'
+ 0x7F, 0x08, 0x04, 0x04, 0x78, // 0x68 'h'
+ 0x00, 0x44, 0x7D, 0x40, 0x00, // 0x69 'i'
+ 0x20, 0x40, 0x44, 0x3D, 0x00, // 0x6A 'j'
+ 0x7F, 0x10, 0x28, 0x44, 0x00, // 0x6B 'k'
+ 0x00, 0x41, 0x7F, 0x40, 0x00, // 0x6C 'l'
+ 0x7C, 0x04, 0x18, 0x04, 0x78, // 0x6D 'm'
+ 0x7C, 0x08, 0x04, 0x04, 0x78, // 0x6E 'n'
+ 0x38, 0x44, 0x44, 0x44, 0x38, // 0x6F 'o'
+ 0x7C, 0x14, 0x14, 0x14, 0x08, // 0x70 'p'
+ 0x08, 0x14, 0x14, 0x14, 0x7C, // 0x71 'q'
+ 0x7C, 0x08, 0x04, 0x04, 0x08, // 0x72 'r'
+ 0x48, 0x54, 0x54, 0x54, 0x20, // 0x73 's'
+ 0x04, 0x3F, 0x44, 0x40, 0x20, // 0x74 't'
+ 0x3C, 0x40, 0x40, 0x20, 0x7C, // 0x75 'u'
+ 0x1C, 0x20, 0x40, 0x20, 0x1C, // 0x76 'v'
+ 0x3C, 0x40, 0x30, 0x40, 0x3C, // 0x77 'w'
+ 0x44, 0x28, 0x10, 0x28, 0x44, // 0x78 'x'
+ 0x0C, 0x50, 0x50, 0x50, 0x3C, // 0x79 'y'
+ 0x44, 0x64, 0x54, 0x4C, 0x44, // 0x7A 'z'
+ 0x00, 0x08, 0x36, 0x41, 0x00, // 0x7B '{'
+ 0x00, 0x00, 0x7F, 0x00, 0x00, // 0x7C '|'
+ 0x00, 0x41, 0x36, 0x08, 0x00, // 0x7D '}'
+ 0x08, 0x04, 0x08, 0x10, 0x08, // 0x7E '~'
+ };
+}
diff --git a/src/VPlasma.Core/Device/PlasmaSelfTest.cs b/src/VPlasma.Core/Device/PlasmaSelfTest.cs
new file mode 100644
index 0000000..584efb6
--- /dev/null
+++ b/src/VPlasma.Core/Device/PlasmaSelfTest.cs
@@ -0,0 +1,101 @@
+using VPlasma.Core.Protocol;
+
+namespace VPlasma.Core.Device;
+
+///
+/// Canned wire streams for the UI's Self test button: each page is a
+/// byte sequence exactly as a host would send it over COM2, fed through the
+/// same parser as live traffic — so the button doubles as an end-to-end
+/// exercise of the command set without a host attached.
+///
+public static class PlasmaSelfTest
+{
+ public const int PageCount = 3;
+
+ /// The wire bytes for self-test page (0-based).
+ public static byte[] BuildPage(int page) => page switch
+ {
+ 0 => BuildBanner(),
+ 1 => BuildCharset(),
+ 2 => BuildGraphics(),
+ _ => throw new ArgumentOutOfRangeException(nameof(page)),
+ };
+
+ private static void Esc(List b, byte cmd, params byte[] operands)
+ {
+ b.Add(PlasmaProtocol.Esc);
+ b.Add(cmd);
+ b.AddRange(operands);
+ }
+
+ private static void Text(List b, string s)
+ {
+ foreach (char c in s)
+ b.Add((byte)c);
+ }
+
+ /// Big-font banner over small-font attribute samples.
+ private static byte[] BuildBanner()
+ {
+ var b = new List();
+ Esc(b, PlasmaProtocol.CmdClearScreen);
+ Esc(b, PlasmaProtocol.CmdCursorMode, 0x00);
+
+ Esc(b, PlasmaProtocol.CmdFontSelect, 0x04); // 12×16 cells: 10 × 2
+ Text(b, " vPLASMA");
+
+ Esc(b, PlasmaProtocol.CmdFontSelect, 0x00); // 6×8 cells: 21 × 4
+ Esc(b, PlasmaProtocol.CmdHomeCursor);
+ b.Add(PlasmaProtocol.LineFeed);
+ b.Add(PlasmaProtocol.LineFeed);
+ Text(b, "128x32 PLASMA DISPLAY"); // exactly one 21-cell row
+
+ Esc(b, PlasmaProtocol.CmdAttributes, 1); Text(b, "HALF ");
+ Esc(b, PlasmaProtocol.CmdAttributes, 2); Text(b, "UNDER ");
+ Esc(b, PlasmaProtocol.CmdAttributes, 3); Text(b, "REV");
+ Esc(b, PlasmaProtocol.CmdAttributes, 4); Text(b, " FLASH");
+ Esc(b, PlasmaProtocol.CmdAttributes, PlasmaProtocol.OperandDefault);
+ return b.ToArray();
+ }
+
+ /// The printable set 0x20..0x6F — a full 21×4 grid, minus a cell.
+ private static byte[] BuildCharset()
+ {
+ var b = new List();
+ Esc(b, PlasmaProtocol.CmdClearScreen);
+ Esc(b, PlasmaProtocol.CmdCursorMode, 0x00);
+ for (byte c = 0x20; c <= 0x6F; ++c)
+ b.Add(c);
+ return b.ToArray();
+ }
+
+ /// The rest of the charset plus an ESC P pattern block.
+ private static byte[] BuildGraphics()
+ {
+ var b = new List();
+ Esc(b, PlasmaProtocol.CmdClearScreen);
+ Esc(b, PlasmaProtocol.CmdCursorMode, 0x00);
+ for (byte c = 0x70; c <= 0x7E; ++c)
+ b.Add(c);
+
+ // A framed checkerboard sent the way the game sends everything:
+ // full-width ESC P rows (screen 0, xbyte 0, 16 bytes, 1 row each).
+ for (int y = 10; y < VPlasmaDevice.Height; ++y)
+ {
+ Esc(b, PlasmaProtocol.CmdGraphicsWrite,
+ 0, (byte)y, 0, VPlasmaDevice.WidthBytes, 1);
+ bool edge = y is 10 or VPlasmaDevice.Height - 1;
+ for (int x = 0; x < VPlasmaDevice.WidthBytes; ++x)
+ {
+ byte fill = edge ? (byte)0xFF : (y & 2) == 0 ? (byte)0xAA : (byte)0x55;
+ if (!edge)
+ {
+ if (x == 0) fill |= 0x80; // left frame edge
+ if (x == VPlasmaDevice.WidthBytes - 1) fill |= 0x01; // right frame edge
+ }
+ b.Add(fill);
+ }
+ }
+ return b.ToArray();
+ }
+}
diff --git a/src/VPlasma.Core/Device/VPlasmaDevice.cs b/src/VPlasma.Core/Device/VPlasmaDevice.cs
new file mode 100644
index 0000000..ff1c0e0
--- /dev/null
+++ b/src/VPlasma.Core/Device/VPlasmaDevice.cs
@@ -0,0 +1,447 @@
+using VPlasma.Core.Protocol;
+
+namespace VPlasma.Core.Device;
+
+/// How the text cursor is shown (set with ESC G).
+public enum PlasmaCursorMode
+{
+ Hidden,
+ Steady,
+ Flashing,
+}
+
+/// Text rendering attributes (set with ESC H).
+[Flags]
+public enum PlasmaAttributes : byte
+{
+ None = 0,
+ HalfIntensity = 1,
+ Underline = 2,
+ Reverse = 4,
+ Flash = 8,
+}
+
+///
+/// The plasma display proper: a 128×32 1bpp frame plus the text-mode state
+/// (cursor, font, attributes), driven by the byte stream a host writes to
+/// COM2. Feed raw wire bytes to ; the parser is a
+/// state machine, so commands may arrive split across any chunk boundaries.
+///
+/// Pixels carry flags rather than a plain bit: graphics writes set
+/// full-intensity dots, while text can stamp half-intensity or flashing dots
+/// (ESC H); the UI renders dimmer and blinks
+/// . The command set itself is documented on
+/// .
+///
+/// Grid geometry: fonts 0–3 are the 5×7 set in a 6×8 cell (21 columns
+/// × 4 rows), fonts 4–7 the same glyphs doubled into a 12×16 cell
+/// (10 × 2). Which of the eight slots the real panel mapped to which face is
+/// lost with the hardware; two sizes cover what the surviving software
+/// exercises.
+///
+/// Thread-safe: the serial reader feeds bytes while the UI snapshots
+/// frames. Events are raised outside the lock, on the caller's thread.
+///
+public sealed class VPlasmaDevice
+{
+ public const int Width = 128;
+ public const int Height = 32;
+ public const int WidthBytes = Width / 8;
+
+ // Per-pixel flag bits in the frame buffer.
+ public const byte PixelLit = 0x01;
+ public const byte PixelHalf = 0x02;
+ public const byte PixelFlash = 0x04;
+
+ private readonly object _sync = new();
+ private readonly byte[] _pixels = new byte[Width * Height];
+
+ // ---- text-mode state -------------------------------------------------
+ private int _font; // 0..7
+ private PlasmaAttributes _attributes;
+ private int _col, _row; // cursor, in cells of the current grid
+ private PlasmaCursorMode _cursorMode = PlasmaCursorMode.Steady; // power-on default; the game hides it
+
+ // ---- parser state ----------------------------------------------------
+ private enum State
+ {
+ Text, // printable chars + control bytes
+ Escape, // got ESC, awaiting the command letter
+ Operand, // awaiting the 1-byte operand of _pendingCommand
+ GraphicsHeader, // collecting ESC P's 5 header bytes
+ GraphicsData, // consuming ESC P's w*h data bytes
+ }
+
+ private State _state;
+ private byte _pendingCommand;
+ private readonly byte[] _header = new byte[5]; // screen, y, x, w, h
+ private int _headerFill;
+ private int _dataIndex, _dataLength;
+
+ private bool _dirty; // frame/cursor changed during this chunk
+ private List? _pendingLog; // lines queued under the lock
+ private bool _graphicsLogArmed = true; // log the first ESC P of a stream, then go quiet
+ private readonly HashSet _loggedUnknown = new();
+
+ private long _bytesReceived, _graphicsRows, _textCharsDrawn;
+
+ /// Frame or cursor changed. Raised on the feeding thread.
+ public event Action? Updated;
+
+ /// Decoded-command log lines. Raised on the feeding thread.
+ public event Action? Logged;
+
+ public long BytesReceived { get { lock (_sync) return _bytesReceived; } }
+ public long GraphicsRows { get { lock (_sync) return _graphicsRows; } }
+ public long TextCharsDrawn { get { lock (_sync) return _textCharsDrawn; } }
+
+ public PlasmaCursorMode CursorMode { get { lock (_sync) return _cursorMode; } }
+ public int Font { get { lock (_sync) return _font; } }
+ public PlasmaAttributes Attributes { get { lock (_sync) return _attributes; } }
+
+ // Current font grid, for the UI's cursor overlay and status line.
+ private int FontScale => _font >= 4 ? 2 : 1;
+ public int CellWidth { get { lock (_sync) return 6 * FontScale; } }
+ public int CellHeight { get { lock (_sync) return 8 * FontScale; } }
+ private int Columns => Width / (6 * FontScale);
+ private int Rows => Height / (8 * FontScale);
+ public Point CursorCell { get { lock (_sync) return new Point(_col, _row); } }
+
+ /// A cursor cell position (avoids dragging in System.Drawing).
+ public readonly record struct Point(int Col, int Row);
+
+ /// Copy the frame into (Width*Height flag bytes).
+ public void CopyFrame(byte[] destination)
+ {
+ if (destination.Length < _pixels.Length)
+ throw new ArgumentException("Buffer too small.", nameof(destination));
+ lock (_sync)
+ Buffer.BlockCopy(_pixels, 0, destination, 0, _pixels.Length);
+ }
+
+ /// Power-on state: dark glass, home cursor, defaults.
+ public void Reset()
+ {
+ lock (_sync)
+ {
+ Array.Clear(_pixels, 0, _pixels.Length);
+ _col = _row = 0;
+ _font = 0;
+ _attributes = PlasmaAttributes.None;
+ _cursorMode = PlasmaCursorMode.Steady;
+ _state = State.Text;
+ _dirty = true;
+ }
+ FlushEvents();
+ }
+
+ /// Feed received wire bytes.
+ public void OnReceived(byte[] buffer, int count)
+ {
+ lock (_sync)
+ {
+ _bytesReceived += count;
+ for (int i = 0; i < count; ++i)
+ Step(buffer[i]);
+ }
+ FlushEvents();
+ }
+
+ // ---- parser ------------------------------------------------------------
+
+ private void Step(byte b)
+ {
+ switch (_state)
+ {
+ case State.Text:
+ StepText(b);
+ break;
+
+ case State.Escape:
+ StepEscape(b);
+ break;
+
+ case State.Operand:
+ _state = State.Text;
+ ApplyOperand(_pendingCommand, b);
+ break;
+
+ case State.GraphicsHeader:
+ _header[_headerFill++] = b;
+ if (_headerFill == _header.Length)
+ BeginGraphicsData();
+ break;
+
+ case State.GraphicsData:
+ StepGraphicsData(b);
+ break;
+ }
+ }
+
+ private void StepText(byte b)
+ {
+ switch (b)
+ {
+ case PlasmaProtocol.Esc:
+ _state = State.Escape;
+ return;
+
+ case PlasmaProtocol.BackSpace:
+ if (_col > 0) _col--;
+ _dirty = true;
+ return;
+
+ case PlasmaProtocol.HorizontalTab:
+ AdvanceCursor();
+ _dirty = true;
+ return;
+
+ case PlasmaProtocol.LineFeed:
+ _row = (_row + 1) % Rows;
+ _dirty = true;
+ return;
+
+ case PlasmaProtocol.VerticalTab:
+ _row = (_row + Rows - 1) % Rows;
+ _dirty = true;
+ return;
+
+ case PlasmaProtocol.CarriageReturn:
+ _col = 0;
+ _dirty = true;
+ return;
+ }
+
+ if (b < 0x20)
+ {
+ // A control byte the surviving software never sends: swallow it,
+ // but say so once per value — it's the tell of a desynced stream.
+ if (_loggedUnknown.Add(b))
+ Log($"Unhandled control byte 0x{b:X2} ignored");
+ return;
+ }
+
+ DrawChar(b);
+ _graphicsLogArmed = true;
+ }
+
+ private void StepEscape(byte b)
+ {
+ _state = State.Text;
+ switch (b)
+ {
+ case PlasmaProtocol.CmdClearScreen:
+ Array.Clear(_pixels, 0, _pixels.Length);
+ _col = _row = 0;
+ _font = 0;
+ _attributes = PlasmaAttributes.None;
+ _dirty = true;
+ Log("Clear screen (ESC @)");
+ _graphicsLogArmed = true;
+ break;
+
+ case PlasmaProtocol.CmdHomeCursor:
+ _col = _row = 0;
+ _dirty = true;
+ Log("Home cursor (ESC L)");
+ _graphicsLogArmed = true;
+ break;
+
+ case PlasmaProtocol.CmdCursorMode:
+ case PlasmaProtocol.CmdFontSelect:
+ case PlasmaProtocol.CmdAttributes:
+ _pendingCommand = b;
+ _state = State.Operand;
+ break;
+
+ case PlasmaProtocol.CmdGraphicsWrite:
+ _headerFill = 0;
+ _state = State.GraphicsHeader;
+ break;
+
+ default:
+ if (_loggedUnknown.Add(b))
+ Log($"Unknown command ESC 0x{b:X2} ('{(char)b}') ignored");
+ break;
+ }
+ }
+
+ private void ApplyOperand(byte command, byte operand)
+ {
+ switch (command)
+ {
+ case PlasmaProtocol.CmdCursorMode:
+ // The game hides the cursor with 00, the test tool with FF;
+ // 01 shows it steady, 03 flashing (bit 1 = blink).
+ _cursorMode =
+ operand is 0x00 or 0xFF ? PlasmaCursorMode.Hidden :
+ (operand & 0x02) != 0 ? PlasmaCursorMode.Flashing :
+ PlasmaCursorMode.Steady;
+ _dirty = true;
+ Log($"Cursor {_cursorMode} (ESC G {operand:X2})");
+ break;
+
+ case PlasmaProtocol.CmdFontSelect:
+ _font = operand == PlasmaProtocol.OperandDefault ? 0 : operand & 0x07;
+ // The cursor keeps its cell coordinates but the grid changed size.
+ _col = Math.Min(_col, Columns - 1);
+ _row = Math.Min(_row, Rows - 1);
+ _dirty = true;
+ Log($"Font {_font}: {Columns}×{Rows} cells (ESC K {operand:X2})");
+ break;
+
+ case PlasmaProtocol.CmdAttributes:
+ _attributes = DecodeAttributes(operand);
+ Log($"Attributes {(_attributes == PlasmaAttributes.None ? "default" : _attributes.ToString())} (ESC H {operand:X2})");
+ break;
+ }
+ _graphicsLogArmed = true;
+ }
+
+ ///
+ /// ESC H's operand indexes the style list PLASMA.EXE enumerates
+ /// (its /s option): the 17 intensity/underline/reverse/flash
+ /// combos below, in the tool's own order. FF (and anything out of range)
+ /// restores the defaults.
+ ///
+ private static PlasmaAttributes DecodeAttributes(byte operand) => operand switch
+ {
+ 0 => PlasmaAttributes.None,
+ 1 => PlasmaAttributes.HalfIntensity,
+ 2 => PlasmaAttributes.Underline,
+ 3 => PlasmaAttributes.Reverse,
+ 4 => PlasmaAttributes.Flash,
+ 5 => PlasmaAttributes.Underline,
+ 6 => PlasmaAttributes.Reverse,
+ 7 => PlasmaAttributes.Flash,
+ 8 => PlasmaAttributes.HalfIntensity | PlasmaAttributes.Underline,
+ 9 => PlasmaAttributes.HalfIntensity | PlasmaAttributes.Reverse,
+ 10 => PlasmaAttributes.HalfIntensity | PlasmaAttributes.Flash,
+ 11 => PlasmaAttributes.Underline | PlasmaAttributes.Reverse,
+ 12 => PlasmaAttributes.Underline | PlasmaAttributes.Flash,
+ 13 => PlasmaAttributes.Underline | PlasmaAttributes.Reverse | PlasmaAttributes.Flash,
+ 14 => PlasmaAttributes.HalfIntensity | PlasmaAttributes.Underline | PlasmaAttributes.Reverse,
+ 15 => PlasmaAttributes.HalfIntensity | PlasmaAttributes.Underline | PlasmaAttributes.Flash,
+ 16 => PlasmaAttributes.HalfIntensity | PlasmaAttributes.Underline | PlasmaAttributes.Reverse | PlasmaAttributes.Flash,
+ _ => PlasmaAttributes.None,
+ };
+
+ // ---- graphics writes (ESC P) -------------------------------------------
+
+ private void BeginGraphicsData()
+ {
+ int w = _header[3], h = _header[4];
+ _dataLength = w * h;
+ _dataIndex = 0;
+
+ if (_graphicsLogArmed)
+ {
+ // The game streams row upon row; log the first of a run only.
+ _graphicsLogArmed = false;
+ Log($"Graphics stream: screen={_header[0]} y={_header[1]} xbyte={_header[2]} " +
+ $"{w} byte(s)/row × {h} row(s) (further rows counted silently)");
+ }
+
+ _state = _dataLength > 0 ? State.GraphicsData : State.Text;
+ }
+
+ private void StepGraphicsData(byte b)
+ {
+ int w = _header[3];
+ int rowOfBlock = _dataIndex / w;
+ int byteOfRow = _dataIndex % w;
+
+ int y = _header[1] + rowOfBlock;
+ int xByte = _header[2] + byteOfRow;
+ if (y < Height && xByte < WidthBytes)
+ {
+ // MSB is the leftmost pixel (L4PLASMA.CPP packs 0x80 first).
+ // Graphics dots are plain full intensity: overwriting text clears
+ // its half/flash flags, like repainting the glass.
+ int offset = y * Width + xByte * 8;
+ for (int bit = 0; bit < 8; ++bit)
+ _pixels[offset + bit] = (b & (0x80 >> bit)) != 0 ? PixelLit : (byte)0;
+ _dirty = true;
+ if (byteOfRow == w - 1)
+ _graphicsRows++;
+ }
+
+ if (++_dataIndex >= _dataLength)
+ _state = State.Text;
+ }
+
+ // ---- text rendering ------------------------------------------------------
+
+ private void DrawChar(byte code)
+ {
+ int scale = FontScale;
+ int cellW = 6 * scale, cellH = 8 * scale;
+ int ox = _col * cellW, oy = _row * cellH;
+
+ Span columns = stackalloc byte[PlasmaFont.GlyphWidth];
+ PlasmaFont.GetColumns(code, columns);
+
+ bool reverse = (_attributes & PlasmaAttributes.Reverse) != 0;
+ bool underline = (_attributes & PlasmaAttributes.Underline) != 0;
+ byte litFlags = PixelLit;
+ if ((_attributes & PlasmaAttributes.HalfIntensity) != 0) litFlags |= PixelHalf;
+ if ((_attributes & PlasmaAttributes.Flash) != 0) litFlags |= PixelFlash;
+
+ for (int cy = 0; cy < cellH; ++cy)
+ {
+ int glyphRow = cy / scale; // 0..7; row 7 is the gap/underline row
+ int rowOffset = (oy + cy) * Width + ox;
+ for (int cx = 0; cx < cellW; ++cx)
+ {
+ int glyphCol = cx / scale; // 0..5; column 5 is the gap column
+ bool on = glyphCol < PlasmaFont.GlyphWidth
+ && glyphRow < PlasmaFont.GlyphHeight
+ && (columns[glyphCol] >> glyphRow & 1) != 0;
+ if (underline && glyphRow == 7)
+ on = true;
+ if (reverse)
+ on = !on;
+ _pixels[rowOffset + cx] = on ? litFlags : (byte)0;
+ }
+ }
+
+ _textCharsDrawn++;
+ _dirty = true;
+ AdvanceCursor();
+ }
+
+ private void AdvanceCursor()
+ {
+ if (++_col >= Columns)
+ {
+ _col = 0;
+ // No scroll on these panels: writing past the last row wraps to the top.
+ if (++_row >= Rows)
+ _row = 0;
+ }
+ }
+
+ // ---- event plumbing --------------------------------------------------------
+
+ private void Log(string line) => (_pendingLog ??= new List()).Add(line);
+
+ /// Raise queued events outside the lock, on the caller's thread.
+ private void FlushEvents()
+ {
+ List? log;
+ bool dirty;
+ lock (_sync)
+ {
+ log = _pendingLog;
+ _pendingLog = null;
+ dirty = _dirty;
+ _dirty = false;
+ }
+
+ if (log is not null && Logged is { } logged)
+ foreach (string line in log)
+ logged(line);
+ if (dirty)
+ Updated?.Invoke();
+ }
+}
diff --git a/src/VPlasma.Core/Device/VPlasmaSerialService.cs b/src/VPlasma.Core/Device/VPlasmaSerialService.cs
new file mode 100644
index 0000000..60de14c
--- /dev/null
+++ b/src/VPlasma.Core/Device/VPlasmaSerialService.cs
@@ -0,0 +1,123 @@
+using System.IO.Ports;
+using VPlasma.Core.Protocol;
+
+namespace VPlasma.Core.Device;
+
+///
+/// Pumps a real COM port into a at the plasma's
+/// 9600 8N1 settings. On a single PC, pair it with the game through a
+/// virtual null-modem (e.g. com0com): the game's COM2 passthrough opens one
+/// end, vPLASMA the other.
+///
+/// Unlike the RIO, the plasma is a pure listener — the game opens the
+/// port with flow control disabled and never reads a byte back — so there is
+/// no transmit path and no wire pacing to emulate. Our DTR/RTS are asserted
+/// so a host that does check its modem lines sees "display present".
+///
+public sealed class VPlasmaSerialService : IDisposable
+{
+ private readonly VPlasmaDevice _device;
+
+ private SerialPort? _port;
+ private Thread? _reader;
+ private volatile bool _running;
+
+ public VPlasmaSerialService(VPlasmaDevice device)
+ {
+ _device = device ?? throw new ArgumentNullException(nameof(device));
+ }
+
+ /// True while a COM port is open.
+ public bool IsOpen => _port?.IsOpen == true;
+
+ /// The open port's name, or null.
+ public string? PortName => _port?.PortName;
+
+ /// Raised after the port opens (true) or closes (false).
+ public event Action? ConnectionChanged;
+
+ /// Port-level log lines (open/close/errors).
+ public event Action? Logged;
+
+ /// Open and start listening.
+ public void Open(string portName)
+ {
+ if (string.IsNullOrWhiteSpace(portName))
+ throw new ArgumentException("Port name is required.", nameof(portName));
+
+ Close();
+
+ var port = new SerialPort(portName, PlasmaProtocol.BaudRate, Parity.None, 8, StopBits.One)
+ {
+ Handshake = Handshake.None,
+ // Finite read timeout so the reader thread can notice shutdown.
+ ReadTimeout = 200,
+ // Assert our modem lines: through a null modem the host sees
+ // DSR/CTS high, i.e. "display present".
+ DtrEnable = true,
+ RtsEnable = true,
+ };
+ port.Open();
+
+ _port = port;
+ _running = true;
+
+ _reader = new Thread(ReadLoop) { IsBackground = true, Name = "vPLASMA serial reader" };
+ _reader.Start();
+
+ Logged?.Invoke($"Opened {portName} @ {PlasmaProtocol.BaudRate} 8N1 — listening for the host");
+ ConnectionChanged?.Invoke(true);
+ }
+
+ /// Close the port (idempotent).
+ public void Close()
+ {
+ SerialPort? port = _port;
+ if (port is null)
+ return;
+
+ _running = false;
+ _port = null;
+ try { port.Close(); }
+ catch (IOException) { }
+ port.Dispose();
+
+ _reader?.Join(1000);
+ _reader = null;
+
+ Logged?.Invoke("Port closed");
+ ConnectionChanged?.Invoke(false);
+ }
+
+ private void ReadLoop()
+ {
+ var buffer = new byte[256];
+ while (_running)
+ {
+ SerialPort? port = _port;
+ if (port is null)
+ return;
+
+ int n;
+ try
+ {
+ n = port.Read(buffer, 0, buffer.Length);
+ }
+ catch (TimeoutException)
+ {
+ continue; // just a poll tick; check _running again
+ }
+ catch (Exception ex) when (ex is IOException or InvalidOperationException or OperationCanceledException)
+ {
+ if (_running)
+ Logged?.Invoke($"Port error: {ex.Message}");
+ return;
+ }
+
+ if (n > 0)
+ _device.OnReceived(buffer, n);
+ }
+ }
+
+ public void Dispose() => Close();
+}
diff --git a/src/VPlasma.Core/Protocol/PlasmaProtocol.cs b/src/VPlasma.Core/Protocol/PlasmaProtocol.cs
new file mode 100644
index 0000000..2fa1be0
--- /dev/null
+++ b/src/VPlasma.Core/Protocol/PlasmaProtocol.cs
@@ -0,0 +1,64 @@
+namespace VPlasma.Core.Protocol;
+
+///
+/// The cockpit plasma display's serial command set, as recovered from the
+/// Tesla 4.10 sources and tools:
+///
+/// The display is a 128×32 dot-matrix plasma panel on COM2 at
+/// 9600 8N1, no flow control. The game side
+/// (CODE\RP\MUNGA_L4\L4PLASMA.CPP) renders everything into a local
+/// 1bpp buffer and streams changed rows with the ESC P graphics
+/// command, sending ESC G 0 once at startup to hide the cursor. The
+/// factory test tool (VWETEST\VGLTEST\PLASMA.EXE) additionally
+/// exercises a text mode: printable ASCII renders at a cursor, with escape
+/// commands for clear/home, cursor visibility, font select, and text
+/// attributes (intensity/underline/reverse/flash), plus BS/HT/LF/VT/CR
+/// cursor motion.
+///
+/// Every multi-byte command begins with ESC (0x1B) followed by one
+/// command letter:
+///
+///
+/// ESC @ clear screen, reset text state
+/// ESC L home the cursor (0,0)
+/// ESC G n cursor mode: 00/FF hidden, 01 steady, 03 flashing
+/// ESC K n select font n (FF = default font 0)
+/// ESC H n text attributes: index 0..16 into the
+/// intensity/underline/reverse/flash combos the
+/// test tool enumerates; FF = defaults
+/// ESC P s y x w h data… graphics write: screen s (single-screen, ignored),
+/// top row y (0..31), left byte column x (0..15),
+/// w bytes per row, h rows, then w*h data bytes,
+/// MSB = leftmost pixel. The game always sends
+/// whole rows: x=0, w=16, h=1.
+///
+///
+/// Where the two sources disagree on ESC G (the game hides the
+/// cursor with 00, the test tool with FF) both operands are treated as
+/// hidden.
+///
+public static class PlasmaProtocol
+{
+ /// Wire bit rate — the game opens PCS_9600, PCS_N81.
+ public const int BaudRate = 9600;
+
+ public const byte Esc = 0x1B;
+
+ // Single-byte cursor-motion controls (PLASMA.EXE's /b /c /l /t /v options).
+ public const byte BackSpace = 0x08;
+ public const byte HorizontalTab = 0x09;
+ public const byte LineFeed = 0x0A;
+ public const byte VerticalTab = 0x0B;
+ public const byte CarriageReturn = 0x0D;
+
+ // ESC command letters.
+ public const byte CmdClearScreen = (byte)'@';
+ public const byte CmdCursorMode = (byte)'G';
+ public const byte CmdAttributes = (byte)'H';
+ public const byte CmdFontSelect = (byte)'K';
+ public const byte CmdHomeCursor = (byte)'L';
+ public const byte CmdGraphicsWrite = (byte)'P';
+
+ /// Operand meaning "restore the default" for ESC K / ESC H.
+ public const byte OperandDefault = 0xFF;
+}
diff --git a/src/VPlasma.Core/VPlasma.Core.csproj b/src/VPlasma.Core/VPlasma.Core.csproj
new file mode 100644
index 0000000..d866044
--- /dev/null
+++ b/src/VPlasma.Core/VPlasma.Core.csproj
@@ -0,0 +1,22 @@
+
+
+
+
+ net48
+ enable
+ enable
+ latest
+
+
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
diff --git a/tests/VPlasma.Core.Tests/VPlasma.Core.Tests.csproj b/tests/VPlasma.Core.Tests/VPlasma.Core.Tests.csproj
new file mode 100644
index 0000000..53f23ef
--- /dev/null
+++ b/tests/VPlasma.Core.Tests/VPlasma.Core.Tests.csproj
@@ -0,0 +1,25 @@
+
+
+
+ net48
+ enable
+ enable
+ latest
+ false
+
+
+
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
+
+
+
diff --git a/tests/VPlasma.Core.Tests/VPlasmaDeviceTests.cs b/tests/VPlasma.Core.Tests/VPlasmaDeviceTests.cs
new file mode 100644
index 0000000..891a62b
--- /dev/null
+++ b/tests/VPlasma.Core.Tests/VPlasmaDeviceTests.cs
@@ -0,0 +1,307 @@
+using VPlasma.Core.Device;
+using Xunit;
+
+namespace VPlasma.Core.Tests;
+
+public class VPlasmaDeviceTests
+{
+ private const byte Esc = 0x1B;
+
+ private static void Feed(VPlasmaDevice device, params byte[] bytes)
+ => device.OnReceived(bytes, bytes.Length);
+
+ private static void Feed(VPlasmaDevice device, IEnumerable bytes)
+ {
+ byte[] arr = bytes.ToArray();
+ device.OnReceived(arr, arr.Length);
+ }
+
+ private static byte Pixel(VPlasmaDevice device, int x, int y)
+ {
+ var frame = new byte[VPlasmaDevice.Width * VPlasmaDevice.Height];
+ device.CopyFrame(frame);
+ return frame[y * VPlasmaDevice.Width + x];
+ }
+
+ /// A full-width ESC P row the way L4PLASMA.CPP sends one.
+ private static byte[] GraphicsRow(int y, params byte[] data)
+ {
+ var row = new List { Esc, (byte)'P', 0, (byte)y, 0, (byte)data.Length, 1 };
+ row.AddRange(data);
+ return row.ToArray();
+ }
+
+ // ---- graphics writes -------------------------------------------------
+
+ [Fact]
+ public void GraphicsRow_SetsPixelsMsbFirst()
+ {
+ var device = new VPlasmaDevice();
+ Feed(device, GraphicsRow(5, 0x80, 0x01)); // xbyte 0..1
+
+ Assert.Equal(VPlasmaDevice.PixelLit, Pixel(device, 0, 5)); // MSB of byte 0
+ Assert.Equal(0, Pixel(device, 1, 5));
+ Assert.Equal(0, Pixel(device, 14, 5));
+ Assert.Equal(VPlasmaDevice.PixelLit, Pixel(device, 15, 5)); // LSB of byte 1
+ Assert.Equal(0, Pixel(device, 0, 4));
+ Assert.Equal(0, Pixel(device, 0, 6));
+ Assert.Equal(1, device.GraphicsRows);
+ }
+
+ [Fact]
+ public void GraphicsRow_SurvivesAnyChunkBoundary()
+ {
+ byte[] wire = GraphicsRow(3, Enumerable.Repeat((byte)0xFF, 16).ToArray());
+
+ // Split the same command at every possible boundary.
+ for (int split = 1; split < wire.Length; ++split)
+ {
+ var device = new VPlasmaDevice();
+ device.OnReceived(wire, split);
+ byte[] rest = wire.Skip(split).ToArray();
+ device.OnReceived(rest, rest.Length);
+
+ Assert.Equal(VPlasmaDevice.PixelLit, Pixel(device, 0, 3));
+ Assert.Equal(VPlasmaDevice.PixelLit, Pixel(device, 127, 3));
+ Assert.Equal(1, device.GraphicsRows);
+ }
+ }
+
+ [Fact]
+ public void GraphicsBlock_MultipleRowsAdvanceY()
+ {
+ var device = new VPlasmaDevice();
+ // screen 0, y=10, xbyte=0, 1 byte/row, 3 rows.
+ Feed(device, Esc, (byte)'P', 0, 10, 0, 1, 3, 0x80, 0x80, 0x80);
+
+ Assert.Equal(VPlasmaDevice.PixelLit, Pixel(device, 0, 10));
+ Assert.Equal(VPlasmaDevice.PixelLit, Pixel(device, 0, 11));
+ Assert.Equal(VPlasmaDevice.PixelLit, Pixel(device, 0, 12));
+ Assert.Equal(0, Pixel(device, 0, 13));
+ Assert.Equal(3, device.GraphicsRows);
+ }
+
+ [Fact]
+ public void GraphicsWrite_HonorsByteColumnOffset()
+ {
+ var device = new VPlasmaDevice();
+ Feed(device, Esc, (byte)'P', 0, 0, 2, 1, 1, 0xFF); // xbyte=2 → x 16..23
+
+ Assert.Equal(0, Pixel(device, 15, 0));
+ Assert.Equal(VPlasmaDevice.PixelLit, Pixel(device, 16, 0));
+ Assert.Equal(VPlasmaDevice.PixelLit, Pixel(device, 23, 0));
+ Assert.Equal(0, Pixel(device, 24, 0));
+ }
+
+ [Fact]
+ public void GraphicsWrite_OutOfRangeRowIsConsumedNotDrawn()
+ {
+ var device = new VPlasmaDevice();
+ Feed(device, GraphicsRow(40, Enumerable.Repeat((byte)0xFF, 16).ToArray()));
+ Feed(device, (byte)'!'); // parser must be back in text mode
+
+ var frame = new byte[VPlasmaDevice.Width * VPlasmaDevice.Height];
+ device.CopyFrame(frame);
+ Assert.Equal(1, device.TextCharsDrawn);
+ Assert.Equal(0, device.GraphicsRows);
+ }
+
+ [Fact]
+ public void GraphicsWrite_OverwritesTextAttributes()
+ {
+ var device = new VPlasmaDevice();
+ Feed(device, Esc, (byte)'H', 4); // flashing text
+ Feed(device, (byte)'H'); // glyph col 0 is a full bar at x=0
+ Assert.Equal(VPlasmaDevice.PixelLit | VPlasmaDevice.PixelFlash, Pixel(device, 0, 0));
+
+ Feed(device, GraphicsRow(0, 0x80)); // repaint that row from the wire
+ Assert.Equal(VPlasmaDevice.PixelLit, Pixel(device, 0, 0));
+ }
+
+ // ---- text mode ---------------------------------------------------------
+
+ [Fact]
+ public void Text_DrawsGlyphAndAdvancesCursor()
+ {
+ var device = new VPlasmaDevice();
+ Feed(device, (byte)'H'); // 5×7 'H': column 0 = 0x7F (all seven rows)
+
+ Assert.Equal(VPlasmaDevice.PixelLit, Pixel(device, 0, 0));
+ Assert.Equal(VPlasmaDevice.PixelLit, Pixel(device, 0, 6));
+ Assert.Equal(0, Pixel(device, 0, 7)); // gap row
+ Assert.Equal(0, Pixel(device, 5, 0)); // gap column
+ Assert.Equal(new VPlasmaDevice.Point(1, 0), device.CursorCell);
+ Assert.Equal(1, device.TextCharsDrawn);
+ }
+
+ [Fact]
+ public void ControlChars_MoveTheCursor()
+ {
+ var device = new VPlasmaDevice();
+
+ Feed(device, 0x09, 0x09, 0x09); // HT ×3
+ Assert.Equal(new VPlasmaDevice.Point(3, 0), device.CursorCell);
+
+ Feed(device, 0x08); // BS
+ Assert.Equal(new VPlasmaDevice.Point(2, 0), device.CursorCell);
+
+ Feed(device, 0x0A); // LF
+ Assert.Equal(new VPlasmaDevice.Point(2, 1), device.CursorCell);
+
+ Feed(device, 0x0D); // CR
+ Assert.Equal(new VPlasmaDevice.Point(0, 1), device.CursorCell);
+
+ Feed(device, 0x0B); // VT
+ Assert.Equal(new VPlasmaDevice.Point(0, 0), device.CursorCell);
+
+ Feed(device, 0x0B); // VT off the top wraps to the bottom row
+ Assert.Equal(new VPlasmaDevice.Point(0, 3), device.CursorCell);
+ }
+
+ [Fact]
+ public void Text_WrapsAtRowAndScreenEnd()
+ {
+ var device = new VPlasmaDevice();
+ Feed(device, Enumerable.Repeat((byte)'X', 22)); // one full 21-cell row + 1
+
+ Assert.Equal(new VPlasmaDevice.Point(1, 1), device.CursorCell);
+
+ Feed(device, Enumerable.Repeat((byte)'X', 21 * 3 - 1)); // exactly to the end
+ Assert.Equal(new VPlasmaDevice.Point(0, 0), device.CursorCell); // wrapped to top
+ }
+
+ [Fact]
+ public void EscAt_ClearsScreenAndResetsTextState()
+ {
+ var device = new VPlasmaDevice();
+ Feed(device, Esc, (byte)'K', 4, Esc, (byte)'H', 1, (byte)'H');
+ Feed(device, Esc, (byte)'@');
+
+ Assert.Equal(0, Pixel(device, 0, 0));
+ Assert.Equal(new VPlasmaDevice.Point(0, 0), device.CursorCell);
+ Assert.Equal(0, device.Font);
+ Assert.Equal(PlasmaAttributes.None, device.Attributes);
+ }
+
+ [Fact]
+ public void EscL_HomesCursorWithoutClearing()
+ {
+ var device = new VPlasmaDevice();
+ Feed(device, (byte)'H', Esc, (byte)'L');
+
+ Assert.Equal(new VPlasmaDevice.Point(0, 0), device.CursorCell);
+ Assert.Equal(VPlasmaDevice.PixelLit, Pixel(device, 0, 0)); // glyph survives
+ }
+
+ [Theory]
+ [InlineData(0x00, PlasmaCursorMode.Hidden)] // the game's cursor-off
+ [InlineData(0xFF, PlasmaCursorMode.Hidden)] // the test tool's hide
+ [InlineData(0x01, PlasmaCursorMode.Steady)]
+ [InlineData(0x03, PlasmaCursorMode.Flashing)]
+ public void EscG_SetsCursorMode(byte operand, PlasmaCursorMode expected)
+ {
+ var device = new VPlasmaDevice();
+ Feed(device, Esc, (byte)'G', operand);
+ Assert.Equal(expected, device.CursorMode);
+ }
+
+ [Fact]
+ public void EscK_SelectsFontGrids()
+ {
+ var device = new VPlasmaDevice();
+
+ Feed(device, Esc, (byte)'K', 4); // large: 12×16 cells
+ Assert.Equal(4, device.Font);
+ Assert.Equal(12, device.CellWidth);
+ Assert.Equal(16, device.CellHeight);
+
+ Feed(device, (byte)'A'); // large glyph: 'A' col 1 (0x11) row 0 → 2×2 dots at (2..3, 0..1)
+ Assert.Equal(VPlasmaDevice.PixelLit, Pixel(device, 2, 0));
+ Assert.Equal(VPlasmaDevice.PixelLit, Pixel(device, 3, 1));
+
+ Feed(device, Esc, (byte)'K', 0xFF); // FF → default font 0
+ Assert.Equal(0, device.Font);
+ Assert.Equal(6, device.CellWidth);
+ }
+
+ [Fact]
+ public void EscH_AppliesAttributes()
+ {
+ var device = new VPlasmaDevice();
+
+ Feed(device, Esc, (byte)'H', 1, (byte)'H'); // half intensity
+ Assert.Equal(VPlasmaDevice.PixelLit | VPlasmaDevice.PixelHalf, Pixel(device, 0, 0));
+
+ Feed(device, Esc, (byte)'H', 2, (byte)'H'); // underline: gap row lit
+ Assert.Equal(VPlasmaDevice.PixelLit, Pixel(device, 6, 7));
+
+ Feed(device, Esc, (byte)'H', 3, (byte)' '); // reverse: a space renders solid
+ Assert.Equal(VPlasmaDevice.PixelLit, Pixel(device, 12, 0));
+ Assert.Equal(VPlasmaDevice.PixelLit, Pixel(device, 17, 7));
+
+ Feed(device, Esc, (byte)'H', 0xFF, (byte)'H'); // defaults restored
+ Assert.Equal(VPlasmaDevice.PixelLit, Pixel(device, 18, 0));
+ Assert.Equal(0, Pixel(device, 18, 7));
+ Assert.Equal(PlasmaAttributes.None, device.Attributes);
+ }
+
+ // ---- robustness ----------------------------------------------------------
+
+ [Fact]
+ public void UnknownEscape_IsConsumedAndTextResumes()
+ {
+ var device = new VPlasmaDevice();
+ Feed(device, Esc, (byte)'Z', (byte)'H');
+
+ Assert.Equal(1, device.TextCharsDrawn);
+ Assert.Equal(VPlasmaDevice.PixelLit, Pixel(device, 0, 0));
+ }
+
+ [Fact]
+ public void GameStartupSequence_HidesCursor()
+ {
+ // L4PLASMA.CPP's constructor sends exactly ESC 'G' 0x00.
+ var device = new VPlasmaDevice();
+ Assert.Equal(PlasmaCursorMode.Steady, device.CursorMode); // power-on default
+
+ Feed(device, 27, (byte)'G', 0x00);
+ Assert.Equal(PlasmaCursorMode.Hidden, device.CursorMode);
+ }
+
+ [Fact]
+ public void SelfTestPages_ParseWithoutUnknownCommands()
+ {
+ for (int page = 0; page < PlasmaSelfTest.PageCount; ++page)
+ {
+ var device = new VPlasmaDevice();
+ var complaints = new List();
+ device.Logged += line =>
+ {
+ if (line.StartsWith("Unknown", StringComparison.Ordinal)
+ || line.StartsWith("Unhandled", StringComparison.Ordinal))
+ complaints.Add(line);
+ };
+
+ byte[] bytes = PlasmaSelfTest.BuildPage(page);
+ device.OnReceived(bytes, bytes.Length);
+
+ Assert.Empty(complaints);
+ Assert.True(device.BytesReceived > 0);
+ }
+ }
+
+ [Fact]
+ public void Reset_RestoresPowerOnState()
+ {
+ var device = new VPlasmaDevice();
+ Feed(device, Esc, (byte)'K', 5, Esc, (byte)'H', 4, Esc, (byte)'G', 0, (byte)'H');
+
+ device.Reset();
+
+ Assert.Equal(0, Pixel(device, 0, 0));
+ Assert.Equal(0, device.Font);
+ Assert.Equal(PlasmaAttributes.None, device.Attributes);
+ Assert.Equal(PlasmaCursorMode.Steady, device.CursorMode);
+ Assert.Equal(new VPlasmaDevice.Point(0, 0), device.CursorCell);
+ }
+}