Files
VRIO/src/VPlasma.Core/Device/VPlasmaDevice.cs
T
CydandClaude Opus 4.8 95b701ae0a vPLASMA: make jumpers 4 (orientation) and 5 (display test) functional
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>
2026-07-16 18:26:51 -05:00

528 lines
18 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 07 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();
}
}