vPLASMA: companion app emulating the cockpit plasma display

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 <noreply@anthropic.com>
This commit is contained in:
Cyd
2026-07-07 14:22:00 -05:00
co-authored by Claude Fable 5
parent 767473d1cf
commit a1b7dae3da
17 changed files with 1747 additions and 4 deletions
+16
View File
@@ -10,8 +10,16 @@ Build + tests (tests are CI's job; run the app for verification):
```powershell ```powershell
dotnet build vrio.sln -v q -nologo # Debug dotnet build vrio.sln -v q -nologo # Debug
# exe: src\VRio.App\bin\Debug\net48\VRio.App.exe # 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 ## Driving the GUI without touching the user's desktop
This is the user's live desktop — **never** use SetForegroundWindow + 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 (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 `SendMessage(hwnd, BM_CLICK, 0, 0)` works. Get the HWND from the UIA
element's NativeWindowHandle (find by Name). 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, - Pattern that works: Add-Type user32 P/Invokes, Start-Process the exe,
drive via messages, PrintWindow screenshot to the scratchpad, kill drive via messages, PrintWindow screenshot to the scratchpad, kill
the process in `finally`. the process in `finally`.
+42 -4
View File
@@ -3,7 +3,9 @@
A software replica of the cockpit **RIO** (Remote Input/Output) board: it opens 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 a COM port and speaks the **device side** of the RIO serial protocol, so any
host that expects the real hardware — most importantly 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 The window is an interactive version of the cockpit control panel that
RIOJoy's profile editor draws (the same functional layout from the original 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 The two apps need a crossed serial link. Install a
[com0com](https://com0com.sourceforge.net/) virtual null-modem pair [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**. 1. Run `VRio.App`, pick `COM11`, **Open**.
2. Point RIOJoy at `COM6`. 2. RIOJoy will always poit to `COM1`.
RIOJoy's DTR open-pulse shows up in the wire log (DSR handshake), its ~55 ms RIOJoy's DTR open-pulse shows up in the wire log (DSR handshake), its ~55 ms
analog polling drives the "analog polls served" counter, and every click on analog polling drives the "analog polls served" counter, and every click on
the vRIO panel arrives at RIOJoy as real cockpit input. Two physical PCs with the vRIO panel arrives at RIOJoy as real cockpit input. Two physical PCs with
a null-modem cable work the same way. a null-modem cable work the same way.
## 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 03 give a 21×4 cell grid, fonts 47 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 ## Repository layout
| Path | Contents | | Path | Contents |
|------|----------| |------|----------|
| `src/VRio.Core` | Protocol framing/builder/parser, the `VRioDevice` state machine, serial pump, panel layout data (class library) | | `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/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 | | `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/VRio.Core.Tests` | xUnit tests for the protocol + device engine |
| `tests/VPlasma.Core.Tests` | xUnit tests for the plasma parser + display engine |
## Building ## Building
+21
View File
@@ -13,6 +13,12 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{C4993638
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "VRio.Core.Tests", "tests\VRio.Core.Tests\VRio.Core.Tests.csproj", "{986638BB-F289-4480-8575-F1699D201590}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "VRio.Core.Tests", "tests\VRio.Core.Tests\VRio.Core.Tests.csproj", "{986638BB-F289-4480-8575-F1699D201590}"
EndProject 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 Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU 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}.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.ActiveCfg = Release|Any CPU
{986638BB-F289-4480-8575-F1699D201590}.Release|Any CPU.Build.0 = 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 EndGlobalSection
GlobalSection(NestedProjects) = preSolution GlobalSection(NestedProjects) = preSolution
{80312F43-09BF-4F09-A0FA-A60FDF86274D} = {D0ECC9D9-7379-4759-89F7-56CD3214BD57} {80312F43-09BF-4F09-A0FA-A60FDF86274D} = {D0ECC9D9-7379-4759-89F7-56CD3214BD57}
{2D1A482C-D907-47EB-9830-B78132154E57} = {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} {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 EndGlobalSection
EndGlobal EndGlobal
+245
View File
@@ -0,0 +1,245 @@
using System.IO.Ports;
using VPlasma.Core.Device;
namespace VPlasma.App;
/// <summary>
/// vPLASMA main window: the plasma glass on the left, a control strip on the
/// right — COM port, self-test/clear, live counters, and a decoded-command
/// log. Open the COM port, point the game's COM2 (plasma) passthrough at the
/// other end of the null-modem pair, and whatever the sim draws on the
/// cockpit's plasma display appears here.
/// </summary>
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();
}
}
+121
View File
@@ -0,0 +1,121 @@
using VPlasma.Core.Device;
namespace VPlasma.App;
/// <summary>
/// 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.
/// </summary>
internal sealed class PlasmaCanvas : Control
{
private const int DotPitch = 6; // 5 px dot + 1 px gap
private const int DotSize = 5;
private const int Bezel = 16;
private 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();
}
/// <summary>Snapshot the device state and repaint. Call on the UI thread.</summary>
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);
}
}
+12
View File
@@ -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());
}
}
+51
View File
@@ -0,0 +1,51 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net48</TargetFramework>
<Nullable>enable</Nullable>
<UseWindowsForms>true</UseWindowsForms>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>latest</LangVersion>
<ApplicationManifest>app.manifest</ApplicationManifest>
<ApplicationIcon>vwe.ico</ApplicationIcon>
<AssemblyTitle>vPLASMA — Virtual plasma display</AssemblyTitle>
<!-- StampGitVersion already puts the sha in InformationalVersion; stop the
SDK appending "+fullsha" on top of it. -->
<IncludeSourceRevisionInInformationalVersion>false</IncludeSourceRevisionInInformationalVersion>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\VPlasma.Core\VPlasma.Core.csproj" />
</ItemGroup>
<!-- Stamp commit date + short sha into InformationalVersion; the window
title shows it (Application.ProductVersion) so a running build can be
matched to its vYYYY.MM.DD release tag at a glance. Falls back to the
default 1.0.0 when git isn't available. -->
<Target Name="StampGitVersion" BeforeTargets="GetAssemblyVersion" Condition="'$(DesignTimeBuild)' != 'true'">
<!-- %%cs survives cmd's percent expansion as %cs: committer date, YYYY-MM-DD. -->
<Exec Command="git -C &quot;$(MSBuildProjectDirectory)&quot; log -1 --format=%%cs"
ConsoleToMSBuild="true" StandardOutputImportance="low" IgnoreExitCode="true" ContinueOnError="true">
<Output TaskParameter="ConsoleOutput" PropertyName="GitCommitDate" />
<Output TaskParameter="ExitCode" PropertyName="GitDateExitCode" />
</Exec>
<!-- The exclude flag keeps describe off the release tags: always the short
sha, with a "dirty" suffix when the working tree has local edits. -->
<Exec Command="git -C &quot;$(MSBuildProjectDirectory)&quot; describe --always --dirty --exclude=*"
ConsoleToMSBuild="true" StandardOutputImportance="low" IgnoreExitCode="true" ContinueOnError="true">
<Output TaskParameter="ConsoleOutput" PropertyName="GitShortSha" />
</Exec>
<PropertyGroup Condition="'$(GitDateExitCode)' == '0' And '$(GitShortSha)' != ''">
<InformationalVersion>$(GitCommitDate.Trim().Replace('-', '.')) ($(GitShortSha))</InformationalVersion>
</PropertyGroup>
</Target>
<ItemGroup>
<PackageReference Include="PolySharp" Version="1.14.1">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
</Project>
+20
View File
@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<assemblyIdentity version="1.0.0.0" name="VPlasma.App" type="win32" />
<!-- Run as the invoking user: vPLASMA only opens a COM port and draws a window. -->
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
<security>
<requestedPrivileges>
<requestedExecutionLevel level="asInvoker" uiAccess="false" />
</requestedPrivileges>
</security>
</trustInfo>
<!-- Declare Windows 10/11 compatibility for correct DPI / API behavior. -->
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
<application>
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" /> <!-- Windows 10/11 -->
</application>
</compatibility>
</assembly>
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

+130
View File
@@ -0,0 +1,130 @@
namespace VPlasma.Core.Device;
/// <summary>
/// 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.
/// </summary>
public static class PlasmaFont
{
public const int GlyphWidth = 5;
public const int GlyphHeight = 7;
public const byte First = 0x20;
public const byte Last = 0x7E;
/// <summary>
/// Column bits for <paramref name="code"/>'s glyph. Bit r of
/// <c>result[c]</c> is the dot at column c, row r (row 0 at the top).
/// </summary>
public static void GetColumns(byte code, Span<byte> 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 '~'
};
}
+101
View File
@@ -0,0 +1,101 @@
using VPlasma.Core.Protocol;
namespace VPlasma.Core.Device;
/// <summary>
/// Canned wire streams for the UI's <i>Self test</i> 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.
/// </summary>
public static class PlasmaSelfTest
{
public const int PageCount = 3;
/// <summary>The wire bytes for self-test page <paramref name="page"/> (0-based).</summary>
public static byte[] BuildPage(int page) => page switch
{
0 => BuildBanner(),
1 => BuildCharset(),
2 => BuildGraphics(),
_ => throw new ArgumentOutOfRangeException(nameof(page)),
};
private static void Esc(List<byte> b, byte cmd, params byte[] operands)
{
b.Add(PlasmaProtocol.Esc);
b.Add(cmd);
b.AddRange(operands);
}
private static void Text(List<byte> b, string s)
{
foreach (char c in s)
b.Add((byte)c);
}
/// <summary>Big-font banner over small-font attribute samples.</summary>
private static byte[] BuildBanner()
{
var b = new List<byte>();
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();
}
/// <summary>The printable set 0x20..0x6F — a full 21×4 grid, minus a cell.</summary>
private static byte[] BuildCharset()
{
var b = new List<byte>();
Esc(b, PlasmaProtocol.CmdClearScreen);
Esc(b, PlasmaProtocol.CmdCursorMode, 0x00);
for (byte c = 0x20; c <= 0x6F; ++c)
b.Add(c);
return b.ToArray();
}
/// <summary>The rest of the charset plus an ESC P pattern block.</summary>
private static byte[] BuildGraphics()
{
var b = new List<byte>();
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();
}
}
+447
View File
@@ -0,0 +1,447 @@
using VPlasma.Core.Protocol;
namespace VPlasma.Core.Device;
/// <summary>How the text cursor is shown (set with <c>ESC G</c>).</summary>
public enum PlasmaCursorMode
{
Hidden,
Steady,
Flashing,
}
/// <summary>Text rendering attributes (set with <c>ESC H</c>).</summary>
[Flags]
public enum PlasmaAttributes : byte
{
None = 0,
HalfIntensity = 1,
Underline = 2,
Reverse = 4,
Flash = 8,
}
/// <summary>
/// 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 <see cref="OnReceived"/>; the parser is a
/// state machine, so commands may arrive split across any chunk boundaries.
///
/// <para>Pixels carry flags rather than a plain bit: graphics writes set
/// full-intensity dots, while text can stamp half-intensity or flashing dots
/// (<c>ESC H</c>); the UI renders <see cref="PixelHalf"/> dimmer and blinks
/// <see cref="PixelFlash"/>. The command set itself is documented on
/// <see cref="PlasmaProtocol"/>.</para>
///
/// <para>Grid geometry: fonts 03 are the 5×7 set in a 6×8 cell (21 columns
/// × 4 rows), fonts 47 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.</para>
///
/// <para>Thread-safe: the serial reader feeds bytes while the UI snapshots
/// frames. Events are raised outside the lock, on the caller's thread.</para>
/// </summary>
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<string>? _pendingLog; // lines queued under the lock
private bool _graphicsLogArmed = true; // log the first ESC P of a stream, then go quiet
private readonly HashSet<byte> _loggedUnknown = new();
private long _bytesReceived, _graphicsRows, _textCharsDrawn;
/// <summary>Frame or cursor changed. Raised on the feeding thread.</summary>
public event Action? Updated;
/// <summary>Decoded-command log lines. Raised on the feeding thread.</summary>
public event Action<string>? 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); } }
/// <summary>A cursor cell position (avoids dragging in System.Drawing).</summary>
public readonly record struct Point(int Col, int Row);
/// <summary>Copy the frame into <paramref name="destination"/> (Width*Height flag bytes).</summary>
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);
}
/// <summary>Power-on state: dark glass, home cursor, defaults.</summary>
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();
}
/// <summary>Feed <paramref name="count"/> received wire bytes.</summary>
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;
}
/// <summary>
/// <c>ESC H</c>'s operand indexes the style list PLASMA.EXE enumerates
/// (its <c>/s</c> option): the 17 intensity/underline/reverse/flash
/// combos below, in the tool's own order. FF (and anything out of range)
/// restores the defaults.
/// </summary>
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<byte> 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<string>()).Add(line);
/// <summary>Raise queued events outside the lock, on the caller's thread.</summary>
private void FlushEvents()
{
List<string>? 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();
}
}
@@ -0,0 +1,123 @@
using System.IO.Ports;
using VPlasma.Core.Protocol;
namespace VPlasma.Core.Device;
/// <summary>
/// Pumps a real COM port into a <see cref="VPlasmaDevice"/> 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.
///
/// <para>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".</para>
/// </summary>
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));
}
/// <summary>True while a COM port is open.</summary>
public bool IsOpen => _port?.IsOpen == true;
/// <summary>The open port's name, or null.</summary>
public string? PortName => _port?.PortName;
/// <summary>Raised after the port opens (true) or closes (false).</summary>
public event Action<bool>? ConnectionChanged;
/// <summary>Port-level log lines (open/close/errors).</summary>
public event Action<string>? Logged;
/// <summary>Open <paramref name="portName"/> and start listening.</summary>
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);
}
/// <summary>Close the port (idempotent).</summary>
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();
}
@@ -0,0 +1,64 @@
namespace VPlasma.Core.Protocol;
/// <summary>
/// The cockpit plasma display's serial command set, as recovered from the
/// Tesla 4.10 sources and tools:
///
/// <para>The display is a 128×32 dot-matrix plasma panel on COM2 at
/// <b>9600 8N1</b>, no flow control. The game side
/// (<c>CODE\RP\MUNGA_L4\L4PLASMA.CPP</c>) renders everything into a local
/// 1bpp buffer and streams changed rows with the <c>ESC P</c> graphics
/// command, sending <c>ESC G 0</c> once at startup to hide the cursor. The
/// factory test tool (<c>VWETEST\VGLTEST\PLASMA.EXE</c>) 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.</para>
///
/// <para>Every multi-byte command begins with ESC (0x1B) followed by one
/// command letter:</para>
///
/// <code>
/// 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.
/// </code>
///
/// <para>Where the two sources disagree on <c>ESC G</c> (the game hides the
/// cursor with 00, the test tool with FF) both operands are treated as
/// hidden.</para>
/// </summary>
public static class PlasmaProtocol
{
/// <summary>Wire bit rate — the game opens PCS_9600, PCS_N81.</summary>
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';
/// <summary>Operand meaning "restore the default" for ESC K / ESC H.</summary>
public const byte OperandDefault = 0xFF;
}
+22
View File
@@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<!-- net48 so it runs in-box on the cabinet PCs, same as VRio.Core. Uses
System.IO.Ports from the framework BCL; Span/records/init come from
System.Memory + PolySharp. -->
<TargetFramework>net48</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="System.Memory" Version="4.5.5" />
<PackageReference Include="Microsoft.Bcl.HashCode" Version="1.1.1" />
<PackageReference Include="PolySharp" Version="1.14.1">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
</Project>
@@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net48</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
<PackageReference Include="PolySharp" Version="1.14.1">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\VPlasma.Core\VPlasma.Core.csproj" />
</ItemGroup>
</Project>
@@ -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<byte> 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];
}
/// <summary>A full-width ESC P row the way L4PLASMA.CPP sends one.</summary>
private static byte[] GraphicsRow(int y, params byte[] data)
{
var row = new List<byte> { 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<string>();
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);
}
}