From a1b7dae3dab65d2eec75853de87ad10992464684 Mon Sep 17 00:00:00 2001 From: Cyd Date: Tue, 7 Jul 2026 14:22:00 -0500 Subject: [PATCH] vPLASMA: companion app emulating the cockpit plasma display MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cockpit's second serial device joins vRIO: the 128x32 dot-matrix plasma on COM2 (9600 8N1) that the game draws mission text and status graphics on. VPlasma.App opens the device end of a COM port, decodes the display's command stream, and renders the matrix in plasma orange. The command set is recovered from two Tesla 4.10 artifacts: the game's driver (L4PLASMA.CPP — ESC P packed-bitmap row writes, ESC G 0 cursor hide at boot) and the factory test tool PLASMA.EXE, whose data segment pairs each command literal with its printf description (BS/HT/LF/VT/CR motion, ESC @ clear, ESC L home, ESC G cursor, ESC K fonts, ESC H attributes: intensity/underline/reverse/flash). Text renders through the classic public-domain 5x7 set standing in for the lost ROM glyphs; fonts 0-3 give 21x4 cells, fonts 4-7 the doubled 10x2. A Self test cycles banner/charset/graphics pages through the same parser the wire feeds, and the wire log shows every decoded command. Verified end-to-end over the second com0com pair (host writing COM2, vPLASMA listening on COM12). The verify skill gains the cross-process combo-box lesson: string-carrying CB_* messages hang across processes, so select ports by locally computed index via CB_SETCURSEL. Co-Authored-By: Claude Fable 5 --- .claude/skills/verify/SKILL.md | 16 + README.md | 46 +- VRio.sln | 21 + src/VPlasma.App/MainForm.cs | 245 ++++++++++ src/VPlasma.App/PlasmaCanvas.cs | 121 +++++ src/VPlasma.App/Program.cs | 12 + src/VPlasma.App/VPlasma.App.csproj | 51 ++ src/VPlasma.App/app.manifest | 20 + src/VPlasma.App/vwe.ico | Bin 0 -> 2998 bytes src/VPlasma.Core/Device/PlasmaFont.cs | 130 +++++ src/VPlasma.Core/Device/PlasmaSelfTest.cs | 101 ++++ src/VPlasma.Core/Device/VPlasmaDevice.cs | 447 ++++++++++++++++++ .../Device/VPlasmaSerialService.cs | 123 +++++ src/VPlasma.Core/Protocol/PlasmaProtocol.cs | 64 +++ src/VPlasma.Core/VPlasma.Core.csproj | 22 + .../VPlasma.Core.Tests.csproj | 25 + .../VPlasma.Core.Tests/VPlasmaDeviceTests.cs | 307 ++++++++++++ 17 files changed, 1747 insertions(+), 4 deletions(-) create mode 100644 src/VPlasma.App/MainForm.cs create mode 100644 src/VPlasma.App/PlasmaCanvas.cs create mode 100644 src/VPlasma.App/Program.cs create mode 100644 src/VPlasma.App/VPlasma.App.csproj create mode 100644 src/VPlasma.App/app.manifest create mode 100644 src/VPlasma.App/vwe.ico create mode 100644 src/VPlasma.Core/Device/PlasmaFont.cs create mode 100644 src/VPlasma.Core/Device/PlasmaSelfTest.cs create mode 100644 src/VPlasma.Core/Device/VPlasmaDevice.cs create mode 100644 src/VPlasma.Core/Device/VPlasmaSerialService.cs create mode 100644 src/VPlasma.Core/Protocol/PlasmaProtocol.cs create mode 100644 src/VPlasma.Core/VPlasma.Core.csproj create mode 100644 tests/VPlasma.Core.Tests/VPlasma.Core.Tests.csproj create mode 100644 tests/VPlasma.Core.Tests/VPlasmaDeviceTests.cs 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 0000000000000000000000000000000000000000..f58deece31d154f0815fdb2bca52880453b0df16 GIT binary patch literal 2998 zcmdUvJB$=Z5I_rj5-T*Cp4kr(!I{7^aYtkp6C;rjlXbQd83dL*5#H=9EFn!sU|V+J zgqYwW2c~04OhkaSH|X}j(yip6{Hl9)Z$Xe)NS6DqKlSOV*VR2Ma!$_6{(dFa{yZ=8 z1@r#CNg_XwM9R-ZzGS^m|B8N2_83K4d_UY;j9A(>PmC<(@bHk?44VE$)mC)=Ol8BT zX`^p-(Vm4G5n9)0iUX5%y>S@nV(|@%jl;oW(VAl8Fp`6nEVd4#c4Q`kY&RG=@+wEP zba#Yt2;h*{J90{I8%~qIIUW#e6VPVIhC$A8oNi>)chY7SwqNyhA;NJFQa;>Q7fhe_ zjc^(&k>vJXYc^{`cvji{OqP2n7h8Nntrv*fqpP5@8y|jALX}a$MWpeEBWr1UrFTYf!@($7CkzT&ZG0D<4u@L zKp$8H^Z|W9Z`(Tb9r_NTL*Joil88Q{kLV+ch+dHuS!2rV89l*0fjpZ$-)&0I;9>AE zco;kk2?+)dgNMPx;9*FZFnAa|3?2p#Lqdna!{A}?FnAceqJzW3;f;eg4^^TFi~*Ja zOMoRE8J0luK%zj<081(Z#=s)L5@1PXfg`{X;0SO8I8v$L2rvW~0t{BV07rl!z!1|f zR?H^#|b+BurD(UoSm>KOFsX-)Pge}4m$(OFyIH*B{BN_(>OV`b&2up+|0uf1= zo&YS7jS;7aBErHyojBY{ge6)%5{tbCy9IPGC=3dN!k{oH4Cy(+5MfXl6ov=`Vd(h7 zpfD&53In0(=wVP86b1!A$sY-(Y*d^AF#>XcLsM+?3G%u8D7SClmb-WF%KiKI<>AAJ z^7QFbdHndXJb(UNUc7iAFJHct<#H(}CnvIAuVwYO$`6ko@g28R*S-Oph?gXbYof}< zPuuu;DCr*wT9mGCnx?K?`2m-rtY*vpiN5WRCXFlKk5RaK32|=yq<$|%Q8q^#tIhM~ z-3UcBSAE_0{c7-<*Sijd8|%?zGM~(*^9$KyS?xd;wJw{65RI#+eRi0-Glz0At`gte zag6M6w3DIKx|*hKv$CuvIl%0Uz%_$ekE^1r^HFvPs(SWU-lXHQa5+F=>%k0S zSvHB_WSgMK3hm9Sugf|UPB#E9m$q+aZ*Um90bpZy8NCN>W@%F zDgN7>Kkw1w`4oHefEw4iLh$n>s^)=hp&er{O4^WbUKQ0cllJp*Q*CIAIwjGmyV&c> z;#JO7Zu9PnYHFvjb6zZ+EAvY!PAhwwWUPh@+`T_un>(En2ApwDIo`d0>_zq9&3G~y zfb`w>W3RQS#+Ej +/// 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); + } +}