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, low 4 bits). [Flags] public enum PlasmaAttributes : byte { None = 0, HalfIntensity = 1, Underline = 2, Reverse = 4, Flash = 8, } /// /// Display orientation — the JP1 jumper-4 (PD5) strap the firmware reads at /// boot (PlasmaNew/README.md). Horizontal is the normal 128×32 /// landscape; Vertical treats the panel as 32×128 and rotates content onto /// the physical glass (for a portrait-mounted panel). /// public enum PlasmaOrientation { Horizontal, Vertical, } /// /// 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. /// /// Grounded in the real firmware (PlasmaNew/FIRMWARE.md): the /// cursor is a pixel position — ESC Q sets its row (Y), /// ESC R its column (X) — and glyphs are drawn there and advance X by /// the font's width. The eight fonts are the real ROM character generator /// (). Attributes are the low 4 bits of the /// ESC H operand. /// /// mirrors JP1 jumper 4: in Vertical the /// logical space is 32×128 and every dot is rotated onto the physical 128×32 /// glass. All drawing goes through , so the same code serves /// both orientations. lights the whole panel — /// the power-on dead-dot check that JP1 jumper 5 triggers on the real board. /// /// Not yet folded in from the firmware (documented, deferred): the 10 /// double-buffered pages (ESC I/ESC i) and the vector-graphics /// primitives (ESC AF). /// /// 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]; // always physical 128×32 // ---- text-mode state ------------------------------------------------- private int _font; // 0..7 private PlasmaFonts.Face _face = PlasmaFonts.Default; private PlasmaAttributes _attributes; private int _x, _y; // cursor, in logical pixels private PlasmaCursorMode _cursorMode = PlasmaCursorMode.Steady; // power-on default; the game hides it private PlasmaOrientation _orientation = PlasmaOrientation.Horizontal; // ---- 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; } } /// Cursor column X in logical pixels. public int CursorX { get { lock (_sync) return _x; } } /// Cursor row Y in logical pixels. public int CursorY { get { lock (_sync) return _y; } } /// Current font cell width in pixels. public int FontWidth { get { lock (_sync) return _face.Width; } } /// Current font cell height in pixels. public int FontHeight { get { lock (_sync) return _face.Height; } } /// Logical drawing width (128 horizontal, 32 vertical). public int LogicalWidth { get { lock (_sync) return LogicalW; } } /// Logical drawing height (32 horizontal, 128 vertical). public int LogicalHeight { get { lock (_sync) return LogicalH; } } private int LogicalW => _orientation == PlasmaOrientation.Horizontal ? Width : Height; private int LogicalH => _orientation == PlasmaOrientation.Horizontal ? Height : Width; /// /// Display orientation (JP1 jumper 4). Changing it re-inits the panel — /// clears the glass and homes the cursor, like the boot-time strap read. /// public PlasmaOrientation Orientation { get { lock (_sync) return _orientation; } set { lock (_sync) { if (_orientation == value) return; _orientation = value; Array.Clear(_pixels, 0, _pixels.Length); _x = _y = 0; _dirty = true; } FlushEvents(); } } /// /// Set one logical dot, mapping to the physical 128×32 buffer per /// . Out-of-range logical coordinates are ignored. /// private void Plot(int lx, int ly, byte flags) { if ((uint)lx >= (uint)LogicalW || (uint)ly >= (uint)LogicalH) return; int px, py; if (_orientation == PlasmaOrientation.Horizontal) { px = lx; py = ly; } else { px = ly; py = Height - 1 - lx; } // 90° rotation onto landscape glass _pixels[py * Width + px] = flags; } /// 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 (keeps orientation). public void Reset() { lock (_sync) { Array.Clear(_pixels, 0, _pixels.Length); _x = _y = 0; _font = 0; _face = PlasmaFonts.Default; _attributes = PlasmaAttributes.None; _cursorMode = PlasmaCursorMode.Steady; _state = State.Text; _dirty = true; } FlushEvents(); } /// /// Light every dot — the panel test pattern JP1 jumper 5 runs at power-on /// (the firmware's dead-dot check). Clear it with . /// public void ShowTestPattern() { lock (_sync) { for (int i = 0; i < _pixels.Length; ++i) _pixels[i] = PixelLit; _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: _x = Math.Max(0, _x - _face.Width); _dirty = true; return; case PlasmaProtocol.HorizontalTab: AdvanceCursor(); _dirty = true; return; case PlasmaProtocol.LineFeed: NextLine(); _dirty = true; return; case PlasmaProtocol.VerticalTab: _y -= _face.Height; if (_y < 0) _y = Math.Max(0, LogicalH - _face.Height); _dirty = true; return; case PlasmaProtocol.CarriageReturn: _x = 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); _x = _y = 0; _font = 0; _face = PlasmaFonts.Default; _attributes = PlasmaAttributes.None; _dirty = true; Log("Clear screen (ESC @)"); _graphicsLogArmed = true; break; case PlasmaProtocol.CmdHomeCursor: _x = _y = 0; _dirty = true; Log("Home cursor (ESC L)"); _graphicsLogArmed = true; break; case PlasmaProtocol.CmdCursorMode: case PlasmaProtocol.CmdFontSelect: case PlasmaProtocol.CmdAttributes: case PlasmaProtocol.CmdSetRow: case PlasmaProtocol.CmdSetColumn: _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: // Firmware: operands 0–7 select a real font (8 fonts); it range- // checks and ignores anything larger, so ESC K FF is a no-op. if (operand < PlasmaFonts.All.Length) { _font = operand; _face = PlasmaFonts.All[operand]; _x = Math.Min(_x, LogicalW - 1); _y = Math.Min(_y, LogicalH - 1); _dirty = true; Log($"Font {_font}: {_face.Width}×{_face.Height} (ESC K {operand:X2})"); } else { Log($"Font select ignored (ESC K {operand:X2} out of range)"); } break; case PlasmaProtocol.CmdAttributes: // Firmware stores the low 4 bits directly as flags. _attributes = (PlasmaAttributes)(operand & 0x0F); Log($"Attributes {(_attributes == PlasmaAttributes.None ? "default" : _attributes.ToString())} (ESC H {operand:X2})"); break; case PlasmaProtocol.CmdSetRow: // ESC Q: set cursor row Y in logical pixels. if (operand < LogicalH) { _y = operand; _dirty = true; Log($"Cursor row Y={operand} (ESC Q {operand:X2})"); } break; case PlasmaProtocol.CmdSetColumn: // ESC R: set cursor column X in logical pixels. if (operand < LogicalW) { _x = operand; _dirty = true; Log($"Cursor col X={operand} (ESC R {operand:X2})"); } break; } _graphicsLogArmed = true; } // ---- 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; // MSB is the leftmost pixel (L4PLASMA.CPP packs 0x80 first). Graphics // dots are plain full intensity; Plot maps them through the orientation. int baseX = xByte * 8; for (int bit = 0; bit < 8; ++bit) Plot(baseX + bit, y, (b & (0x80 >> bit)) != 0 ? PixelLit : (byte)0); _dirty = true; // Count only rows that land on the glass (a fully-clipped row is a no-op). if (byteOfRow == w - 1 && (uint)y < (uint)LogicalH) _graphicsRows++; if (++_dataIndex >= _dataLength) _state = State.Text; } // ---- text rendering ------------------------------------------------------ private void DrawChar(byte code) { // The firmware ignores a character outside the current font's range. if (!_face.Has(code)) return; int w = _face.Width, h = _face.Height; 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 row = 0; row < h; ++row) { ushort bits = _face.Row(code, row); // bit 15 = leftmost pixel for (int col = 0; col < w; ++col) { bool on = (bits & (0x8000 >> col)) != 0; if (underline && row == h - 1) on = true; if (reverse) on = !on; Plot(_x + col, _y + row, on ? litFlags : (byte)0); } } _textCharsDrawn++; _dirty = true; AdvanceCursor(); } /// Advance the cursor one cell, wrapping at the right/bottom edges. private void AdvanceCursor() { _x += _face.Width; if (_x > LogicalW - _face.Width) NextLine(); } private void NextLine() { _x = 0; _y += _face.Height; // No scroll on these panels: past the last line wraps to the top. if (_y > LogicalH - _face.Height) _y = 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(); } }