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 A–F).
///
/// 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();
}
}