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(); } }