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:
@@ -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 |
Reference in New Issue
Block a user