Both now do what the firmware does, at the device level so the shared PlasmaCanvas (and vRIO's embedded glass) are untouched: - Jumper 4 → display orientation. VPlasmaDevice gains a PlasmaOrientation property; in Vertical the logical space is 32x128 and every dot is rotated onto the physical 128x32 glass via a new Plot() helper that all drawing (text, cursor, ESC P graphics, ESC Q/R bounds) now goes through. Jumper 4 installed = horizontal (the normal cockpit setup, default), removed = vertical. Changing it re-inits the panel, like the boot strap read. - Jumper 5 → ShowTestPattern(): lights every dot (the power-on dead-dot check the firmware runs at $B888). Removing the jumper clears it. The config panel wires both up and shows the live orientation in the counters. 27 unit tests pass (3 new: vertical rotation mapping, orientation re-init, test pattern), including the unchanged horizontal path. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
528 lines
18 KiB
C#
528 lines
18 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>, low 4 bits).</summary>
|
||
[Flags]
|
||
public enum PlasmaAttributes : byte
|
||
{
|
||
None = 0,
|
||
HalfIntensity = 1,
|
||
Underline = 2,
|
||
Reverse = 4,
|
||
Flash = 8,
|
||
}
|
||
|
||
/// <summary>
|
||
/// Display orientation — the JP1 jumper-4 (PD5) strap the firmware reads at
|
||
/// boot (<c>PlasmaNew/README.md</c>). 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).
|
||
/// </summary>
|
||
public enum PlasmaOrientation
|
||
{
|
||
Horizontal,
|
||
Vertical,
|
||
}
|
||
|
||
/// <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>Grounded in the real firmware (<c>PlasmaNew/FIRMWARE.md</c>): the
|
||
/// cursor is a <b>pixel</b> position — <c>ESC Q</c> sets its row (Y),
|
||
/// <c>ESC R</c> 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
|
||
/// (<see cref="PlasmaFonts"/>). Attributes are the low 4 bits of the
|
||
/// <c>ESC H</c> operand.</para>
|
||
///
|
||
/// <para><see cref="Orientation"/> 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 <see cref="Plot"/>, so the same code serves
|
||
/// both orientations. <see cref="ShowTestPattern"/> lights the whole panel —
|
||
/// the power-on dead-dot check that JP1 jumper 5 triggers on the real board.</para>
|
||
///
|
||
/// <para>Not yet folded in from the firmware (documented, deferred): the 10
|
||
/// double-buffered pages (<c>ESC I</c>/<c>ESC i</c>) and the vector-graphics
|
||
/// primitives (<c>ESC A</c>–<c>F</c>).</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]; // 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<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; } }
|
||
|
||
/// <summary>Cursor column X in logical pixels.</summary>
|
||
public int CursorX { get { lock (_sync) return _x; } }
|
||
/// <summary>Cursor row Y in logical pixels.</summary>
|
||
public int CursorY { get { lock (_sync) return _y; } }
|
||
/// <summary>Current font cell width in pixels.</summary>
|
||
public int FontWidth { get { lock (_sync) return _face.Width; } }
|
||
/// <summary>Current font cell height in pixels.</summary>
|
||
public int FontHeight { get { lock (_sync) return _face.Height; } }
|
||
|
||
/// <summary>Logical drawing width (128 horizontal, 32 vertical).</summary>
|
||
public int LogicalWidth { get { lock (_sync) return LogicalW; } }
|
||
/// <summary>Logical drawing height (32 horizontal, 128 vertical).</summary>
|
||
public int LogicalHeight { get { lock (_sync) return LogicalH; } }
|
||
|
||
private int LogicalW => _orientation == PlasmaOrientation.Horizontal ? Width : Height;
|
||
private int LogicalH => _orientation == PlasmaOrientation.Horizontal ? Height : Width;
|
||
|
||
/// <summary>
|
||
/// Display orientation (JP1 jumper 4). Changing it re-inits the panel —
|
||
/// clears the glass and homes the cursor, like the boot-time strap read.
|
||
/// </summary>
|
||
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();
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Set one logical dot, mapping to the physical 128×32 buffer per
|
||
/// <see cref="Orientation"/>. Out-of-range logical coordinates are ignored.
|
||
/// </summary>
|
||
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;
|
||
}
|
||
|
||
/// <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 (keeps orientation).</summary>
|
||
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();
|
||
}
|
||
|
||
/// <summary>
|
||
/// Light every dot — the panel test pattern JP1 jumper 5 runs at power-on
|
||
/// (the firmware's dead-dot check). Clear it with <see cref="Reset"/>.
|
||
/// </summary>
|
||
public void ShowTestPattern()
|
||
{
|
||
lock (_sync)
|
||
{
|
||
for (int i = 0; i < _pixels.Length; ++i)
|
||
_pixels[i] = PixelLit;
|
||
_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:
|
||
_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();
|
||
}
|
||
|
||
/// <summary>Advance the cursor one cell, wrapping at the right/bottom edges.</summary>
|
||
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<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();
|
||
}
|
||
}
|