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>
448 lines
16 KiB
C#
448 lines
16 KiB
C#
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();
|
||
}
|
||
}
|