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:
@@ -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`.
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
A software replica of the cockpit **RIO** (Remote Input/Output) board: it opens
|
||||
a COM port and speaks the **device side** of the RIO serial protocol, so any
|
||||
host that expects the real hardware — most importantly
|
||||
[RIOJoy](../riojoy/) — can talk to it without a cockpit attached.
|
||||
[RIOJoy](../riojoy/) — can talk to it without a cockpit attached. (The
|
||||
cockpit's other serial device gets the same treatment: see
|
||||
[vPLASMA](#vplasma--the-companion-plasma-display) below.)
|
||||
|
||||
The window is an interactive version of the cockpit control panel that
|
||||
RIOJoy's profile editor draws (the same functional layout from the original
|
||||
@@ -91,24 +93,60 @@ device behavior grounded in the **real v4.2 firmware dump**
|
||||
|
||||
The two apps need a crossed serial link. Install a
|
||||
[com0com](https://com0com.sourceforge.net/) virtual null-modem pair
|
||||
(e.g. `COM5 ⇄ COM6`), then:
|
||||
(e.g. `COM1 ⇄ COM11`), then:
|
||||
|
||||
1. Run `VRio.App`, pick `COM5`, **Open**.
|
||||
2. Point RIOJoy at `COM6`.
|
||||
1. Run `VRio.App`, pick `COM11`, **Open**.
|
||||
2. RIOJoy will always poit to `COM1`.
|
||||
|
||||
RIOJoy's DTR open-pulse shows up in the wire log (DSR handshake), its ~55 ms
|
||||
analog polling drives the "analog polls served" counter, and every click on
|
||||
the vRIO panel arrives at RIOJoy as real cockpit input. Two physical PCs with
|
||||
a null-modem cable work the same way.
|
||||
|
||||
## vPLASMA — the companion plasma display
|
||||
|
||||
The cockpit's second serial device is the **plasma display**: a 128×32
|
||||
dot-matrix panel on COM2 (9600 8N1, no flow control) that the game draws
|
||||
mission text and status graphics on. `VPlasma.App` is its software replica:
|
||||
it opens the device end of a COM port, decodes the display's command
|
||||
stream, and renders the dot matrix in plasma orange — text mode included.
|
||||
|
||||
The command set was recovered from two Tesla 4.10 artifacts:
|
||||
`CODE\RP\MUNGA_L4\L4PLASMA.CPP` (the game's driver — it renders everything
|
||||
into a local 1bpp buffer and streams changed rows as `ESC P` graphics
|
||||
writes, hiding the cursor with `ESC G 0` at boot) and the factory test tool
|
||||
`VWETEST\VGLTEST\PLASMA.EXE` (the text mode: BS/HT/LF/VT/CR cursor motion,
|
||||
`ESC @` clear, `ESC L` home, `ESC G` cursor visibility, `ESC K` font
|
||||
select, `ESC H` attributes — intensity/underline/reverse/flash). The full
|
||||
recovered grammar lives in `src/VPlasma.Core/Protocol/PlasmaProtocol.cs`.
|
||||
|
||||
- **Graphics** — `ESC P screen y xbyte width rows` + packed 1bpp row data,
|
||||
MSB = leftmost pixel. This is everything the game sends, so it is the
|
||||
wire path a pod's plasma actually sees.
|
||||
- **Text** — printable ASCII renders through a 5×7 font at a cursor:
|
||||
fonts 0–3 give a 21×4 cell grid, fonts 4–7 the same glyphs doubled to
|
||||
10×2. Half-intensity draws dimmer, reverse/underline render in the
|
||||
cell, flashing text (and the flashing cursor) blink on the glass. The
|
||||
panel's own ROM glyphs are lost with the hardware, so the classic
|
||||
public-domain 5×7 set stands in.
|
||||
- **Self test** cycles three pages (banner, charset, graphics pattern)
|
||||
through the same parser the wire feeds — useful without a host.
|
||||
- Pair it with the game like vRIO ↔ RIOJoy: a second com0com null-modem
|
||||
pair (e.g. `COM2 ⇄ COM12`) — the game's plasma output writes COM2,
|
||||
vPLASMA listens on COM12. The plasma never talks back, so there is no
|
||||
transmit path to pace.
|
||||
|
||||
## Repository layout
|
||||
|
||||
| Path | Contents |
|
||||
|------|----------|
|
||||
| `src/VRio.Core` | Protocol framing/builder/parser, the `VRioDevice` state machine, serial pump, panel layout data (class library) |
|
||||
| `src/VRio.App` | WinForms panel UI |
|
||||
| `src/VPlasma.Core` | Plasma command-stream parser, the `VPlasmaDevice` display state machine, 5×7 font, serial listener (class library) |
|
||||
| `src/VPlasma.App` | WinForms dot-matrix display UI |
|
||||
| `pkg` | Sparse-package manifest + registration script: grants VRio.App.exe the package identity Dynamic Lighting's background-control list requires |
|
||||
| `tests/VRio.Core.Tests` | xUnit tests for the protocol + device engine |
|
||||
| `tests/VPlasma.Core.Tests` | xUnit tests for the plasma parser + display engine |
|
||||
|
||||
## Building
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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 "$(MSBuildProjectDirectory)" 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 "$(MSBuildProjectDirectory)" 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>
|
||||
@@ -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 |
@@ -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 '~'
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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 0–3 are the 5×7 set in a 6×8 cell (21 columns
|
||||
/// × 4 rows), fonts 4–7 the same glyphs doubled into a 12×16 cell
|
||||
/// (10 × 2). Which of the eight slots the real panel mapped to which face is
|
||||
/// lost with the hardware; two sizes cover what the surviving software
|
||||
/// exercises.</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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user