vPLASMA: companion app emulating the cockpit plasma display

The cockpit's second serial device joins vRIO: the 128x32 dot-matrix
plasma on COM2 (9600 8N1) that the game draws mission text and status
graphics on. VPlasma.App opens the device end of a COM port, decodes
the display's command stream, and renders the matrix in plasma orange.

The command set is recovered from two Tesla 4.10 artifacts: the game's
driver (L4PLASMA.CPP — ESC P packed-bitmap row writes, ESC G 0 cursor
hide at boot) and the factory test tool PLASMA.EXE, whose data segment
pairs each command literal with its printf description (BS/HT/LF/VT/CR
motion, ESC @ clear, ESC L home, ESC G cursor, ESC K fonts, ESC H
attributes: intensity/underline/reverse/flash). Text renders through
the classic public-domain 5x7 set standing in for the lost ROM glyphs;
fonts 0-3 give 21x4 cells, fonts 4-7 the doubled 10x2. A Self test
cycles banner/charset/graphics pages through the same parser the wire
feeds, and the wire log shows every decoded command.

Verified end-to-end over the second com0com pair (host writing COM2,
vPLASMA listening on COM12). The verify skill gains the cross-process
combo-box lesson: string-carrying CB_* messages hang across processes,
so select ports by locally computed index via CB_SETCURSEL.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Cyd
2026-07-07 14:22:00 -05:00
co-authored by Claude Fable 5
parent 767473d1cf
commit a1b7dae3da
17 changed files with 1747 additions and 4 deletions
+130
View File
@@ -0,0 +1,130 @@
namespace VPlasma.Core.Device;
/// <summary>
/// The display's character generator: a classic 5×7 dot-matrix font for
/// ASCII 0x20..0x7E, stored column-major (5 column bytes per glyph, bit 0 =
/// top row) — the layout every KS0108-era controller used. The real panel's
/// ROM glyphs are lost with the hardware; this is the standard public-domain
/// 5×7 set, which is what such panels shipped with. Codes outside the range
/// render as a solid block so stream corruption is visible on the glass.
/// </summary>
public static class PlasmaFont
{
public const int GlyphWidth = 5;
public const int GlyphHeight = 7;
public const byte First = 0x20;
public const byte Last = 0x7E;
/// <summary>
/// Column bits for <paramref name="code"/>'s glyph. Bit r of
/// <c>result[c]</c> is the dot at column c, row r (row 0 at the top).
/// </summary>
public static void GetColumns(byte code, Span<byte> columns)
{
if (code < First || code > Last)
{
columns.Slice(0, GlyphWidth).Fill(0x7F); // solid block
return;
}
Glyphs.AsSpan((code - First) * GlyphWidth, GlyphWidth).CopyTo(columns);
}
private static readonly byte[] Glyphs =
{
0x00, 0x00, 0x00, 0x00, 0x00, // 0x20 ' '
0x00, 0x00, 0x5F, 0x00, 0x00, // 0x21 '!'
0x00, 0x07, 0x00, 0x07, 0x00, // 0x22 '"'
0x14, 0x7F, 0x14, 0x7F, 0x14, // 0x23 '#'
0x24, 0x2A, 0x7F, 0x2A, 0x12, // 0x24 '$'
0x23, 0x13, 0x08, 0x64, 0x62, // 0x25 '%'
0x36, 0x49, 0x55, 0x22, 0x50, // 0x26 '&'
0x00, 0x05, 0x03, 0x00, 0x00, // 0x27 '''
0x00, 0x1C, 0x22, 0x41, 0x00, // 0x28 '('
0x00, 0x41, 0x22, 0x1C, 0x00, // 0x29 ')'
0x08, 0x2A, 0x1C, 0x2A, 0x08, // 0x2A '*'
0x08, 0x08, 0x3E, 0x08, 0x08, // 0x2B '+'
0x00, 0x50, 0x30, 0x00, 0x00, // 0x2C ','
0x08, 0x08, 0x08, 0x08, 0x08, // 0x2D '-'
0x00, 0x60, 0x60, 0x00, 0x00, // 0x2E '.'
0x20, 0x10, 0x08, 0x04, 0x02, // 0x2F '/'
0x3E, 0x51, 0x49, 0x45, 0x3E, // 0x30 '0'
0x00, 0x42, 0x7F, 0x40, 0x00, // 0x31 '1'
0x42, 0x61, 0x51, 0x49, 0x46, // 0x32 '2'
0x21, 0x41, 0x45, 0x4B, 0x31, // 0x33 '3'
0x18, 0x14, 0x12, 0x7F, 0x10, // 0x34 '4'
0x27, 0x45, 0x45, 0x45, 0x39, // 0x35 '5'
0x3C, 0x4A, 0x49, 0x49, 0x30, // 0x36 '6'
0x01, 0x71, 0x09, 0x05, 0x03, // 0x37 '7'
0x36, 0x49, 0x49, 0x49, 0x36, // 0x38 '8'
0x06, 0x49, 0x49, 0x29, 0x1E, // 0x39 '9'
0x00, 0x36, 0x36, 0x00, 0x00, // 0x3A ':'
0x00, 0x56, 0x36, 0x00, 0x00, // 0x3B ';'
0x00, 0x08, 0x14, 0x22, 0x41, // 0x3C '<'
0x14, 0x14, 0x14, 0x14, 0x14, // 0x3D '='
0x41, 0x22, 0x14, 0x08, 0x00, // 0x3E '>'
0x02, 0x01, 0x51, 0x09, 0x06, // 0x3F '?'
0x32, 0x49, 0x79, 0x41, 0x3E, // 0x40 '@'
0x7E, 0x11, 0x11, 0x11, 0x7E, // 0x41 'A'
0x7F, 0x49, 0x49, 0x49, 0x36, // 0x42 'B'
0x3E, 0x41, 0x41, 0x41, 0x22, // 0x43 'C'
0x7F, 0x41, 0x41, 0x22, 0x1C, // 0x44 'D'
0x7F, 0x49, 0x49, 0x49, 0x41, // 0x45 'E'
0x7F, 0x09, 0x09, 0x09, 0x01, // 0x46 'F'
0x3E, 0x41, 0x49, 0x49, 0x7A, // 0x47 'G'
0x7F, 0x08, 0x08, 0x08, 0x7F, // 0x48 'H'
0x00, 0x41, 0x7F, 0x41, 0x00, // 0x49 'I'
0x20, 0x40, 0x41, 0x3F, 0x01, // 0x4A 'J'
0x7F, 0x08, 0x14, 0x22, 0x41, // 0x4B 'K'
0x7F, 0x40, 0x40, 0x40, 0x40, // 0x4C 'L'
0x7F, 0x02, 0x0C, 0x02, 0x7F, // 0x4D 'M'
0x7F, 0x04, 0x08, 0x10, 0x7F, // 0x4E 'N'
0x3E, 0x41, 0x41, 0x41, 0x3E, // 0x4F 'O'
0x7F, 0x09, 0x09, 0x09, 0x06, // 0x50 'P'
0x3E, 0x41, 0x51, 0x21, 0x5E, // 0x51 'Q'
0x7F, 0x09, 0x19, 0x29, 0x46, // 0x52 'R'
0x46, 0x49, 0x49, 0x49, 0x31, // 0x53 'S'
0x01, 0x01, 0x7F, 0x01, 0x01, // 0x54 'T'
0x3F, 0x40, 0x40, 0x40, 0x3F, // 0x55 'U'
0x1F, 0x20, 0x40, 0x20, 0x1F, // 0x56 'V'
0x3F, 0x40, 0x38, 0x40, 0x3F, // 0x57 'W'
0x63, 0x14, 0x08, 0x14, 0x63, // 0x58 'X'
0x07, 0x08, 0x70, 0x08, 0x07, // 0x59 'Y'
0x61, 0x51, 0x49, 0x45, 0x43, // 0x5A 'Z'
0x00, 0x7F, 0x41, 0x41, 0x00, // 0x5B '['
0x02, 0x04, 0x08, 0x10, 0x20, // 0x5C '\'
0x00, 0x41, 0x41, 0x7F, 0x00, // 0x5D ']'
0x04, 0x02, 0x01, 0x02, 0x04, // 0x5E '^'
0x40, 0x40, 0x40, 0x40, 0x40, // 0x5F '_'
0x00, 0x01, 0x02, 0x04, 0x00, // 0x60 '`'
0x20, 0x54, 0x54, 0x54, 0x78, // 0x61 'a'
0x7F, 0x48, 0x44, 0x44, 0x38, // 0x62 'b'
0x38, 0x44, 0x44, 0x44, 0x20, // 0x63 'c'
0x38, 0x44, 0x44, 0x48, 0x7F, // 0x64 'd'
0x38, 0x54, 0x54, 0x54, 0x18, // 0x65 'e'
0x08, 0x7E, 0x09, 0x01, 0x02, // 0x66 'f'
0x0C, 0x52, 0x52, 0x52, 0x3E, // 0x67 'g'
0x7F, 0x08, 0x04, 0x04, 0x78, // 0x68 'h'
0x00, 0x44, 0x7D, 0x40, 0x00, // 0x69 'i'
0x20, 0x40, 0x44, 0x3D, 0x00, // 0x6A 'j'
0x7F, 0x10, 0x28, 0x44, 0x00, // 0x6B 'k'
0x00, 0x41, 0x7F, 0x40, 0x00, // 0x6C 'l'
0x7C, 0x04, 0x18, 0x04, 0x78, // 0x6D 'm'
0x7C, 0x08, 0x04, 0x04, 0x78, // 0x6E 'n'
0x38, 0x44, 0x44, 0x44, 0x38, // 0x6F 'o'
0x7C, 0x14, 0x14, 0x14, 0x08, // 0x70 'p'
0x08, 0x14, 0x14, 0x14, 0x7C, // 0x71 'q'
0x7C, 0x08, 0x04, 0x04, 0x08, // 0x72 'r'
0x48, 0x54, 0x54, 0x54, 0x20, // 0x73 's'
0x04, 0x3F, 0x44, 0x40, 0x20, // 0x74 't'
0x3C, 0x40, 0x40, 0x20, 0x7C, // 0x75 'u'
0x1C, 0x20, 0x40, 0x20, 0x1C, // 0x76 'v'
0x3C, 0x40, 0x30, 0x40, 0x3C, // 0x77 'w'
0x44, 0x28, 0x10, 0x28, 0x44, // 0x78 'x'
0x0C, 0x50, 0x50, 0x50, 0x3C, // 0x79 'y'
0x44, 0x64, 0x54, 0x4C, 0x44, // 0x7A 'z'
0x00, 0x08, 0x36, 0x41, 0x00, // 0x7B '{'
0x00, 0x00, 0x7F, 0x00, 0x00, // 0x7C '|'
0x00, 0x41, 0x36, 0x08, 0x00, // 0x7D '}'
0x08, 0x04, 0x08, 0x10, 0x08, // 0x7E '~'
};
}
+101
View File
@@ -0,0 +1,101 @@
using VPlasma.Core.Protocol;
namespace VPlasma.Core.Device;
/// <summary>
/// Canned wire streams for the UI's <i>Self test</i> button: each page is a
/// byte sequence exactly as a host would send it over COM2, fed through the
/// same parser as live traffic — so the button doubles as an end-to-end
/// exercise of the command set without a host attached.
/// </summary>
public static class PlasmaSelfTest
{
public const int PageCount = 3;
/// <summary>The wire bytes for self-test page <paramref name="page"/> (0-based).</summary>
public static byte[] BuildPage(int page) => page switch
{
0 => BuildBanner(),
1 => BuildCharset(),
2 => BuildGraphics(),
_ => throw new ArgumentOutOfRangeException(nameof(page)),
};
private static void Esc(List<byte> b, byte cmd, params byte[] operands)
{
b.Add(PlasmaProtocol.Esc);
b.Add(cmd);
b.AddRange(operands);
}
private static void Text(List<byte> b, string s)
{
foreach (char c in s)
b.Add((byte)c);
}
/// <summary>Big-font banner over small-font attribute samples.</summary>
private static byte[] BuildBanner()
{
var b = new List<byte>();
Esc(b, PlasmaProtocol.CmdClearScreen);
Esc(b, PlasmaProtocol.CmdCursorMode, 0x00);
Esc(b, PlasmaProtocol.CmdFontSelect, 0x04); // 12×16 cells: 10 × 2
Text(b, " vPLASMA");
Esc(b, PlasmaProtocol.CmdFontSelect, 0x00); // 6×8 cells: 21 × 4
Esc(b, PlasmaProtocol.CmdHomeCursor);
b.Add(PlasmaProtocol.LineFeed);
b.Add(PlasmaProtocol.LineFeed);
Text(b, "128x32 PLASMA DISPLAY"); // exactly one 21-cell row
Esc(b, PlasmaProtocol.CmdAttributes, 1); Text(b, "HALF ");
Esc(b, PlasmaProtocol.CmdAttributes, 2); Text(b, "UNDER ");
Esc(b, PlasmaProtocol.CmdAttributes, 3); Text(b, "REV");
Esc(b, PlasmaProtocol.CmdAttributes, 4); Text(b, " FLASH");
Esc(b, PlasmaProtocol.CmdAttributes, PlasmaProtocol.OperandDefault);
return b.ToArray();
}
/// <summary>The printable set 0x20..0x6F — a full 21×4 grid, minus a cell.</summary>
private static byte[] BuildCharset()
{
var b = new List<byte>();
Esc(b, PlasmaProtocol.CmdClearScreen);
Esc(b, PlasmaProtocol.CmdCursorMode, 0x00);
for (byte c = 0x20; c <= 0x6F; ++c)
b.Add(c);
return b.ToArray();
}
/// <summary>The rest of the charset plus an ESC P pattern block.</summary>
private static byte[] BuildGraphics()
{
var b = new List<byte>();
Esc(b, PlasmaProtocol.CmdClearScreen);
Esc(b, PlasmaProtocol.CmdCursorMode, 0x00);
for (byte c = 0x70; c <= 0x7E; ++c)
b.Add(c);
// A framed checkerboard sent the way the game sends everything:
// full-width ESC P rows (screen 0, xbyte 0, 16 bytes, 1 row each).
for (int y = 10; y < VPlasmaDevice.Height; ++y)
{
Esc(b, PlasmaProtocol.CmdGraphicsWrite,
0, (byte)y, 0, VPlasmaDevice.WidthBytes, 1);
bool edge = y is 10 or VPlasmaDevice.Height - 1;
for (int x = 0; x < VPlasmaDevice.WidthBytes; ++x)
{
byte fill = edge ? (byte)0xFF : (y & 2) == 0 ? (byte)0xAA : (byte)0x55;
if (!edge)
{
if (x == 0) fill |= 0x80; // left frame edge
if (x == VPlasmaDevice.WidthBytes - 1) fill |= 0x01; // right frame edge
}
b.Add(fill);
}
}
return b.ToArray();
}
}
+447
View File
@@ -0,0 +1,447 @@
using VPlasma.Core.Protocol;
namespace VPlasma.Core.Device;
/// <summary>How the text cursor is shown (set with <c>ESC G</c>).</summary>
public enum PlasmaCursorMode
{
Hidden,
Steady,
Flashing,
}
/// <summary>Text rendering attributes (set with <c>ESC H</c>).</summary>
[Flags]
public enum PlasmaAttributes : byte
{
None = 0,
HalfIntensity = 1,
Underline = 2,
Reverse = 4,
Flash = 8,
}
/// <summary>
/// The plasma display proper: a 128×32 1bpp frame plus the text-mode state
/// (cursor, font, attributes), driven by the byte stream a host writes to
/// COM2. Feed raw wire bytes to <see cref="OnReceived"/>; the parser is a
/// state machine, so commands may arrive split across any chunk boundaries.
///
/// <para>Pixels carry flags rather than a plain bit: graphics writes set
/// full-intensity dots, while text can stamp half-intensity or flashing dots
/// (<c>ESC H</c>); the UI renders <see cref="PixelHalf"/> dimmer and blinks
/// <see cref="PixelFlash"/>. The command set itself is documented on
/// <see cref="PlasmaProtocol"/>.</para>
///
/// <para>Grid geometry: fonts 03 are the 5×7 set in a 6×8 cell (21 columns
/// × 4 rows), fonts 47 the same glyphs doubled into a 12×16 cell
/// (10 × 2). Which of the eight slots the real panel mapped to which face is
/// lost with the hardware; two sizes cover what the surviving software
/// exercises.</para>
///
/// <para>Thread-safe: the serial reader feeds bytes while the UI snapshots
/// frames. Events are raised outside the lock, on the caller's thread.</para>
/// </summary>
public sealed class VPlasmaDevice
{
public const int Width = 128;
public const int Height = 32;
public const int WidthBytes = Width / 8;
// Per-pixel flag bits in the frame buffer.
public const byte PixelLit = 0x01;
public const byte PixelHalf = 0x02;
public const byte PixelFlash = 0x04;
private readonly object _sync = new();
private readonly byte[] _pixels = new byte[Width * Height];
// ---- text-mode state -------------------------------------------------
private int _font; // 0..7
private PlasmaAttributes _attributes;
private int _col, _row; // cursor, in cells of the current grid
private PlasmaCursorMode _cursorMode = PlasmaCursorMode.Steady; // power-on default; the game hides it
// ---- parser state ----------------------------------------------------
private enum State
{
Text, // printable chars + control bytes
Escape, // got ESC, awaiting the command letter
Operand, // awaiting the 1-byte operand of _pendingCommand
GraphicsHeader, // collecting ESC P's 5 header bytes
GraphicsData, // consuming ESC P's w*h data bytes
}
private State _state;
private byte _pendingCommand;
private readonly byte[] _header = new byte[5]; // screen, y, x, w, h
private int _headerFill;
private int _dataIndex, _dataLength;
private bool _dirty; // frame/cursor changed during this chunk
private List<string>? _pendingLog; // lines queued under the lock
private bool _graphicsLogArmed = true; // log the first ESC P of a stream, then go quiet
private readonly HashSet<byte> _loggedUnknown = new();
private long _bytesReceived, _graphicsRows, _textCharsDrawn;
/// <summary>Frame or cursor changed. Raised on the feeding thread.</summary>
public event Action? Updated;
/// <summary>Decoded-command log lines. Raised on the feeding thread.</summary>
public event Action<string>? Logged;
public long BytesReceived { get { lock (_sync) return _bytesReceived; } }
public long GraphicsRows { get { lock (_sync) return _graphicsRows; } }
public long TextCharsDrawn { get { lock (_sync) return _textCharsDrawn; } }
public PlasmaCursorMode CursorMode { get { lock (_sync) return _cursorMode; } }
public int Font { get { lock (_sync) return _font; } }
public PlasmaAttributes Attributes { get { lock (_sync) return _attributes; } }
// Current font grid, for the UI's cursor overlay and status line.
private int FontScale => _font >= 4 ? 2 : 1;
public int CellWidth { get { lock (_sync) return 6 * FontScale; } }
public int CellHeight { get { lock (_sync) return 8 * FontScale; } }
private int Columns => Width / (6 * FontScale);
private int Rows => Height / (8 * FontScale);
public Point CursorCell { get { lock (_sync) return new Point(_col, _row); } }
/// <summary>A cursor cell position (avoids dragging in System.Drawing).</summary>
public readonly record struct Point(int Col, int Row);
/// <summary>Copy the frame into <paramref name="destination"/> (Width*Height flag bytes).</summary>
public void CopyFrame(byte[] destination)
{
if (destination.Length < _pixels.Length)
throw new ArgumentException("Buffer too small.", nameof(destination));
lock (_sync)
Buffer.BlockCopy(_pixels, 0, destination, 0, _pixels.Length);
}
/// <summary>Power-on state: dark glass, home cursor, defaults.</summary>
public void Reset()
{
lock (_sync)
{
Array.Clear(_pixels, 0, _pixels.Length);
_col = _row = 0;
_font = 0;
_attributes = PlasmaAttributes.None;
_cursorMode = PlasmaCursorMode.Steady;
_state = State.Text;
_dirty = true;
}
FlushEvents();
}
/// <summary>Feed <paramref name="count"/> received wire bytes.</summary>
public void OnReceived(byte[] buffer, int count)
{
lock (_sync)
{
_bytesReceived += count;
for (int i = 0; i < count; ++i)
Step(buffer[i]);
}
FlushEvents();
}
// ---- parser ------------------------------------------------------------
private void Step(byte b)
{
switch (_state)
{
case State.Text:
StepText(b);
break;
case State.Escape:
StepEscape(b);
break;
case State.Operand:
_state = State.Text;
ApplyOperand(_pendingCommand, b);
break;
case State.GraphicsHeader:
_header[_headerFill++] = b;
if (_headerFill == _header.Length)
BeginGraphicsData();
break;
case State.GraphicsData:
StepGraphicsData(b);
break;
}
}
private void StepText(byte b)
{
switch (b)
{
case PlasmaProtocol.Esc:
_state = State.Escape;
return;
case PlasmaProtocol.BackSpace:
if (_col > 0) _col--;
_dirty = true;
return;
case PlasmaProtocol.HorizontalTab:
AdvanceCursor();
_dirty = true;
return;
case PlasmaProtocol.LineFeed:
_row = (_row + 1) % Rows;
_dirty = true;
return;
case PlasmaProtocol.VerticalTab:
_row = (_row + Rows - 1) % Rows;
_dirty = true;
return;
case PlasmaProtocol.CarriageReturn:
_col = 0;
_dirty = true;
return;
}
if (b < 0x20)
{
// A control byte the surviving software never sends: swallow it,
// but say so once per value — it's the tell of a desynced stream.
if (_loggedUnknown.Add(b))
Log($"Unhandled control byte 0x{b:X2} ignored");
return;
}
DrawChar(b);
_graphicsLogArmed = true;
}
private void StepEscape(byte b)
{
_state = State.Text;
switch (b)
{
case PlasmaProtocol.CmdClearScreen:
Array.Clear(_pixels, 0, _pixels.Length);
_col = _row = 0;
_font = 0;
_attributes = PlasmaAttributes.None;
_dirty = true;
Log("Clear screen (ESC @)");
_graphicsLogArmed = true;
break;
case PlasmaProtocol.CmdHomeCursor:
_col = _row = 0;
_dirty = true;
Log("Home cursor (ESC L)");
_graphicsLogArmed = true;
break;
case PlasmaProtocol.CmdCursorMode:
case PlasmaProtocol.CmdFontSelect:
case PlasmaProtocol.CmdAttributes:
_pendingCommand = b;
_state = State.Operand;
break;
case PlasmaProtocol.CmdGraphicsWrite:
_headerFill = 0;
_state = State.GraphicsHeader;
break;
default:
if (_loggedUnknown.Add(b))
Log($"Unknown command ESC 0x{b:X2} ('{(char)b}') ignored");
break;
}
}
private void ApplyOperand(byte command, byte operand)
{
switch (command)
{
case PlasmaProtocol.CmdCursorMode:
// The game hides the cursor with 00, the test tool with FF;
// 01 shows it steady, 03 flashing (bit 1 = blink).
_cursorMode =
operand is 0x00 or 0xFF ? PlasmaCursorMode.Hidden :
(operand & 0x02) != 0 ? PlasmaCursorMode.Flashing :
PlasmaCursorMode.Steady;
_dirty = true;
Log($"Cursor {_cursorMode} (ESC G {operand:X2})");
break;
case PlasmaProtocol.CmdFontSelect:
_font = operand == PlasmaProtocol.OperandDefault ? 0 : operand & 0x07;
// The cursor keeps its cell coordinates but the grid changed size.
_col = Math.Min(_col, Columns - 1);
_row = Math.Min(_row, Rows - 1);
_dirty = true;
Log($"Font {_font}: {Columns}×{Rows} cells (ESC K {operand:X2})");
break;
case PlasmaProtocol.CmdAttributes:
_attributes = DecodeAttributes(operand);
Log($"Attributes {(_attributes == PlasmaAttributes.None ? "default" : _attributes.ToString())} (ESC H {operand:X2})");
break;
}
_graphicsLogArmed = true;
}
/// <summary>
/// <c>ESC H</c>'s operand indexes the style list PLASMA.EXE enumerates
/// (its <c>/s</c> option): the 17 intensity/underline/reverse/flash
/// combos below, in the tool's own order. FF (and anything out of range)
/// restores the defaults.
/// </summary>
private static PlasmaAttributes DecodeAttributes(byte operand) => operand switch
{
0 => PlasmaAttributes.None,
1 => PlasmaAttributes.HalfIntensity,
2 => PlasmaAttributes.Underline,
3 => PlasmaAttributes.Reverse,
4 => PlasmaAttributes.Flash,
5 => PlasmaAttributes.Underline,
6 => PlasmaAttributes.Reverse,
7 => PlasmaAttributes.Flash,
8 => PlasmaAttributes.HalfIntensity | PlasmaAttributes.Underline,
9 => PlasmaAttributes.HalfIntensity | PlasmaAttributes.Reverse,
10 => PlasmaAttributes.HalfIntensity | PlasmaAttributes.Flash,
11 => PlasmaAttributes.Underline | PlasmaAttributes.Reverse,
12 => PlasmaAttributes.Underline | PlasmaAttributes.Flash,
13 => PlasmaAttributes.Underline | PlasmaAttributes.Reverse | PlasmaAttributes.Flash,
14 => PlasmaAttributes.HalfIntensity | PlasmaAttributes.Underline | PlasmaAttributes.Reverse,
15 => PlasmaAttributes.HalfIntensity | PlasmaAttributes.Underline | PlasmaAttributes.Flash,
16 => PlasmaAttributes.HalfIntensity | PlasmaAttributes.Underline | PlasmaAttributes.Reverse | PlasmaAttributes.Flash,
_ => PlasmaAttributes.None,
};
// ---- graphics writes (ESC P) -------------------------------------------
private void BeginGraphicsData()
{
int w = _header[3], h = _header[4];
_dataLength = w * h;
_dataIndex = 0;
if (_graphicsLogArmed)
{
// The game streams row upon row; log the first of a run only.
_graphicsLogArmed = false;
Log($"Graphics stream: screen={_header[0]} y={_header[1]} xbyte={_header[2]} " +
$"{w} byte(s)/row × {h} row(s) (further rows counted silently)");
}
_state = _dataLength > 0 ? State.GraphicsData : State.Text;
}
private void StepGraphicsData(byte b)
{
int w = _header[3];
int rowOfBlock = _dataIndex / w;
int byteOfRow = _dataIndex % w;
int y = _header[1] + rowOfBlock;
int xByte = _header[2] + byteOfRow;
if (y < Height && xByte < WidthBytes)
{
// MSB is the leftmost pixel (L4PLASMA.CPP packs 0x80 first).
// Graphics dots are plain full intensity: overwriting text clears
// its half/flash flags, like repainting the glass.
int offset = y * Width + xByte * 8;
for (int bit = 0; bit < 8; ++bit)
_pixels[offset + bit] = (b & (0x80 >> bit)) != 0 ? PixelLit : (byte)0;
_dirty = true;
if (byteOfRow == w - 1)
_graphicsRows++;
}
if (++_dataIndex >= _dataLength)
_state = State.Text;
}
// ---- text rendering ------------------------------------------------------
private void DrawChar(byte code)
{
int scale = FontScale;
int cellW = 6 * scale, cellH = 8 * scale;
int ox = _col * cellW, oy = _row * cellH;
Span<byte> columns = stackalloc byte[PlasmaFont.GlyphWidth];
PlasmaFont.GetColumns(code, columns);
bool reverse = (_attributes & PlasmaAttributes.Reverse) != 0;
bool underline = (_attributes & PlasmaAttributes.Underline) != 0;
byte litFlags = PixelLit;
if ((_attributes & PlasmaAttributes.HalfIntensity) != 0) litFlags |= PixelHalf;
if ((_attributes & PlasmaAttributes.Flash) != 0) litFlags |= PixelFlash;
for (int cy = 0; cy < cellH; ++cy)
{
int glyphRow = cy / scale; // 0..7; row 7 is the gap/underline row
int rowOffset = (oy + cy) * Width + ox;
for (int cx = 0; cx < cellW; ++cx)
{
int glyphCol = cx / scale; // 0..5; column 5 is the gap column
bool on = glyphCol < PlasmaFont.GlyphWidth
&& glyphRow < PlasmaFont.GlyphHeight
&& (columns[glyphCol] >> glyphRow & 1) != 0;
if (underline && glyphRow == 7)
on = true;
if (reverse)
on = !on;
_pixels[rowOffset + cx] = on ? litFlags : (byte)0;
}
}
_textCharsDrawn++;
_dirty = true;
AdvanceCursor();
}
private void AdvanceCursor()
{
if (++_col >= Columns)
{
_col = 0;
// No scroll on these panels: writing past the last row wraps to the top.
if (++_row >= Rows)
_row = 0;
}
}
// ---- event plumbing --------------------------------------------------------
private void Log(string line) => (_pendingLog ??= new List<string>()).Add(line);
/// <summary>Raise queued events outside the lock, on the caller's thread.</summary>
private void FlushEvents()
{
List<string>? log;
bool dirty;
lock (_sync)
{
log = _pendingLog;
_pendingLog = null;
dirty = _dirty;
_dirty = false;
}
if (log is not null && Logged is { } logged)
foreach (string line in log)
logged(line);
if (dirty)
Updated?.Invoke();
}
}
@@ -0,0 +1,123 @@
using System.IO.Ports;
using VPlasma.Core.Protocol;
namespace VPlasma.Core.Device;
/// <summary>
/// Pumps a real COM port into a <see cref="VPlasmaDevice"/> at the plasma's
/// 9600 8N1 settings. On a single PC, pair it with the game through a
/// virtual null-modem (e.g. com0com): the game's COM2 passthrough opens one
/// end, vPLASMA the other.
///
/// <para>Unlike the RIO, the plasma is a pure listener — the game opens the
/// port with flow control disabled and never reads a byte back — so there is
/// no transmit path and no wire pacing to emulate. Our DTR/RTS are asserted
/// so a host that does check its modem lines sees "display present".</para>
/// </summary>
public sealed class VPlasmaSerialService : IDisposable
{
private readonly VPlasmaDevice _device;
private SerialPort? _port;
private Thread? _reader;
private volatile bool _running;
public VPlasmaSerialService(VPlasmaDevice device)
{
_device = device ?? throw new ArgumentNullException(nameof(device));
}
/// <summary>True while a COM port is open.</summary>
public bool IsOpen => _port?.IsOpen == true;
/// <summary>The open port's name, or null.</summary>
public string? PortName => _port?.PortName;
/// <summary>Raised after the port opens (true) or closes (false).</summary>
public event Action<bool>? ConnectionChanged;
/// <summary>Port-level log lines (open/close/errors).</summary>
public event Action<string>? Logged;
/// <summary>Open <paramref name="portName"/> and start listening.</summary>
public void Open(string portName)
{
if (string.IsNullOrWhiteSpace(portName))
throw new ArgumentException("Port name is required.", nameof(portName));
Close();
var port = new SerialPort(portName, PlasmaProtocol.BaudRate, Parity.None, 8, StopBits.One)
{
Handshake = Handshake.None,
// Finite read timeout so the reader thread can notice shutdown.
ReadTimeout = 200,
// Assert our modem lines: through a null modem the host sees
// DSR/CTS high, i.e. "display present".
DtrEnable = true,
RtsEnable = true,
};
port.Open();
_port = port;
_running = true;
_reader = new Thread(ReadLoop) { IsBackground = true, Name = "vPLASMA serial reader" };
_reader.Start();
Logged?.Invoke($"Opened {portName} @ {PlasmaProtocol.BaudRate} 8N1 — listening for the host");
ConnectionChanged?.Invoke(true);
}
/// <summary>Close the port (idempotent).</summary>
public void Close()
{
SerialPort? port = _port;
if (port is null)
return;
_running = false;
_port = null;
try { port.Close(); }
catch (IOException) { }
port.Dispose();
_reader?.Join(1000);
_reader = null;
Logged?.Invoke("Port closed");
ConnectionChanged?.Invoke(false);
}
private void ReadLoop()
{
var buffer = new byte[256];
while (_running)
{
SerialPort? port = _port;
if (port is null)
return;
int n;
try
{
n = port.Read(buffer, 0, buffer.Length);
}
catch (TimeoutException)
{
continue; // just a poll tick; check _running again
}
catch (Exception ex) when (ex is IOException or InvalidOperationException or OperationCanceledException)
{
if (_running)
Logged?.Invoke($"Port error: {ex.Message}");
return;
}
if (n > 0)
_device.OnReceived(buffer, n);
}
}
public void Dispose() => Close();
}