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
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`.
+42 -4
View File
@@ -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 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
| 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
+21
View File
@@ -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
+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);
}
}