using VPlasma.Core.Protocol;
namespace VPlasma.Core.Device;
/// How the text cursor is shown (set with ESC G).
public enum PlasmaCursorMode
{
Hidden,
Steady,
Flashing,
}
/// Text rendering attributes (set with ESC H).
[Flags]
public enum PlasmaAttributes : byte
{
None = 0,
HalfIntensity = 1,
Underline = 2,
Reverse = 4,
Flash = 8,
}
///
/// 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 ; the parser is a
/// state machine, so commands may arrive split across any chunk boundaries.
///
/// Pixels carry flags rather than a plain bit: graphics writes set
/// full-intensity dots, while text can stamp half-intensity or flashing dots
/// (ESC H); the UI renders dimmer and blinks
/// . The command set itself is documented on
/// .
///
/// 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.
///
/// Thread-safe: the serial reader feeds bytes while the UI snapshots
/// frames. Events are raised outside the lock, on the caller's thread.
///
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? _pendingLog; // lines queued under the lock
private bool _graphicsLogArmed = true; // log the first ESC P of a stream, then go quiet
private readonly HashSet _loggedUnknown = new();
private long _bytesReceived, _graphicsRows, _textCharsDrawn;
/// Frame or cursor changed. Raised on the feeding thread.
public event Action? Updated;
/// Decoded-command log lines. Raised on the feeding thread.
public event Action? 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); } }
/// A cursor cell position (avoids dragging in System.Drawing).
public readonly record struct Point(int Col, int Row);
/// Copy the frame into (Width*Height flag bytes).
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);
}
/// Power-on state: dark glass, home cursor, defaults.
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();
}
/// Feed received wire bytes.
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;
}
///
/// ESC H's operand indexes the style list PLASMA.EXE enumerates
/// (its /s option): the 17 intensity/underline/reverse/flash
/// combos below, in the tool's own order. FF (and anything out of range)
/// restores the defaults.
///
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 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()).Add(line);
/// Raise queued events outside the lock, on the caller's thread.
private void FlushEvents()
{
List? 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();
}
}