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>
This commit is contained in:
Cyd
2026-07-16 18:26:51 -05:00
co-authored by Claude Opus 4.8
parent d8ab0ffe49
commit 95b701ae0a
4 changed files with 193 additions and 53 deletions
+31 -4
View File
@@ -111,15 +111,19 @@ internal sealed class MainForm : Form
_clear.Click += (_, _) => ResetDisplay();
_clearLog.Click += (_, _) => _logBox.Clear();
// Baud straps → reopen at the selected rate (only if already open).
// Jumper 6 → demo loop.
// Functional straps: baud (1+2), orientation (4), display test (5),
// demo (6). Jumpers 3 and 7 are tracked toggles (board-level effects).
_jp1.CheckedChanged += (_, _) => ApplyBaud();
_jp2.CheckedChanged += (_, _) => ApplyBaud();
_jp4.CheckedChanged += (_, _) => ApplyOrientation();
_jp5.CheckedChanged += (_, _) => ApplyTestPattern();
_jp6.CheckedChanged += (_, _) => ApplyDemo();
_demoTimer.Tick += (_, _) => StepDemo();
// Default straps: jumper 2 installed only = 9600 (the game's setting).
// Default straps: jumper 2 installed = 9600 (the game's rate); jumper 4
// installed = horizontal orientation (both the normal cockpit setup).
_jp2.Checked = true;
_jp4.Checked = true;
_uiTimer.Tick += (_, _) => UpdateCounters();
_uiTimer.Start();
@@ -236,6 +240,28 @@ internal sealed class MainForm : Form
UpdateStatus();
}
private void ApplyOrientation()
{
// Jumper 4 installed (checked) = horizontal 128×32; removed = vertical.
_device.Orientation = _jp4.Checked ? PlasmaOrientation.Horizontal : PlasmaOrientation.Vertical;
PrependLog($"Jumper 4 → {(_jp4.Checked ? "horizontal 128×32" : "vertical 32×128")} orientation");
}
private void ApplyTestPattern()
{
// Jumper 5 installed runs the power-on panel test (all dots lit).
if (_jp5.Checked)
{
_device.ShowTestPattern();
PrependLog("Jumper 5 installed — panel test pattern (all dots lit)");
}
else
{
_device.Reset();
PrependLog("Jumper 5 removed — test pattern cleared");
}
}
private void ApplyDemo()
{
if (_jp6.Checked)
@@ -297,7 +323,8 @@ internal sealed class MainForm : Form
$"Chars drawn: {_device.TextCharsDrawn}\n" +
$"Font: {_device.Font} ({_device.FontWidth}×{_device.FontHeight})\n" +
$"Cursor: X {_device.CursorX}, Y {_device.CursorY} ({_device.CursorMode})\n" +
$"Attributes: {(_device.Attributes == PlasmaAttributes.None ? "default" : _device.Attributes.ToString())}";
$"Attributes: {(_device.Attributes == PlasmaAttributes.None ? "default" : _device.Attributes.ToString())}\n" +
$"Orientation: {_device.Orientation} ({_device.LogicalWidth}×{_device.LogicalHeight})";
}
private void PrependLog(string line)
+106 -45
View File
@@ -21,6 +21,18 @@ public enum PlasmaAttributes : byte
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
@@ -28,22 +40,21 @@ public enum PlasmaAttributes : byte
/// 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, 031),
/// <c>ESC R</c> its column (X, 0127) — 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"/>): 6×8, 6×10, 7×10, and the large 12×16 / 12×20,
/// selected with <c>ESC K</c> (07). Attributes are the low 4 bits of 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>Pixels carry flags rather than a plain bit: graphics writes set
/// full-intensity dots, while text can stamp half-intensity or flashing dots;
/// the UI renders <see cref="PixelHalf"/> dimmer and blinks
/// <see cref="PixelFlash"/>. The command set is documented on
/// <see cref="PlasmaProtocol"/>.</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>). vPLASMA models a single page.</para>
/// 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>
@@ -60,14 +71,15 @@ public sealed class VPlasmaDevice
public const byte PixelFlash = 0x04;
private readonly object _sync = new();
private readonly byte[] _pixels = new byte[Width * Height];
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 pixels (0..127, 0..31)
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
@@ -106,15 +118,59 @@ public sealed class VPlasmaDevice
public int Font { get { lock (_sync) return _font; } }
public PlasmaAttributes Attributes { get { lock (_sync) return _attributes; } }
/// <summary>Cursor column X in pixels (0127).</summary>
/// <summary>Cursor column X in logical pixels.</summary>
public int CursorX { get { lock (_sync) return _x; } }
/// <summary>Cursor row Y in pixels (031).</summary>
/// <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)
{
@@ -124,7 +180,7 @@ public sealed class VPlasmaDevice
Buffer.BlockCopy(_pixels, 0, destination, 0, _pixels.Length);
}
/// <summary>Power-on state: dark glass, home cursor, defaults.</summary>
/// <summary>Power-on state: dark glass, home cursor, defaults (keeps orientation).</summary>
public void Reset()
{
lock (_sync)
@@ -141,6 +197,21 @@ public sealed class VPlasmaDevice
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)
{
@@ -209,7 +280,7 @@ public sealed class VPlasmaDevice
case PlasmaProtocol.VerticalTab:
_y -= _face.Height;
if (_y < 0) _y = Math.Max(0, Height - _face.Height);
if (_y < 0) _y = Math.Max(0, LogicalH - _face.Height);
_dirty = true;
return;
@@ -298,8 +369,8 @@ public sealed class VPlasmaDevice
{
_font = operand;
_face = PlasmaFonts.All[operand];
_x = Math.Min(_x, Width - 1);
_y = Math.Min(_y, Height - 1);
_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})");
}
@@ -316,8 +387,8 @@ public sealed class VPlasmaDevice
break;
case PlasmaProtocol.CmdSetRow:
// ESC Q: set cursor row Y in pixels; firmware range-checks 031.
if (operand < Height)
// ESC Q: set cursor row Y in logical pixels.
if (operand < LogicalH)
{
_y = operand;
_dirty = true;
@@ -326,8 +397,8 @@ public sealed class VPlasmaDevice
break;
case PlasmaProtocol.CmdSetColumn:
// ESC R: set cursor column X in pixels; firmware range-checks 0127.
if (operand < Width)
// ESC R: set cursor column X in logical pixels.
if (operand < LogicalW)
{
_x = operand;
_dirty = true;
@@ -365,18 +436,15 @@ public sealed class VPlasmaDevice
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++;
}
// 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;
@@ -399,22 +467,15 @@ public sealed class VPlasmaDevice
for (int row = 0; row < h; ++row)
{
int py = _y + row;
if ((uint)py >= Height)
continue;
ushort bits = _face.Row(code, row); // bit 15 = leftmost pixel
int rowOffset = py * Width;
for (int col = 0; col < w; ++col)
{
int px = _x + col;
if ((uint)px >= Width)
continue;
bool on = (bits & (0x8000 >> col)) != 0;
if (underline && row == h - 1)
on = true;
if (reverse)
on = !on;
_pixels[rowOffset + px] = on ? litFlags : (byte)0;
Plot(_x + col, _y + row, on ? litFlags : (byte)0);
}
}
@@ -427,7 +488,7 @@ public sealed class VPlasmaDevice
private void AdvanceCursor()
{
_x += _face.Width;
if (_x > Width - _face.Width)
if (_x > LogicalW - _face.Width)
NextLine();
}
@@ -436,7 +497,7 @@ public sealed class VPlasmaDevice
_x = 0;
_y += _face.Height;
// No scroll on these panels: past the last line wraps to the top.
if (_y > Height - _face.Height)
if (_y > LogicalH - _face.Height)
_y = 0;
}