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:
@@ -131,10 +131,13 @@ at `$8000`). It's how the new `ESC R/I/Q/i/K` commands were first spotted, e.g.
|
||||
guessed behavior. vPLASMA now uses the **8 real ROM fonts** (extracted to
|
||||
`src/VPlasma.Core/Device/PlasmaFonts.cs`), a **pixel-addressed cursor** with
|
||||
the real `ESC Q` (row) / `ESC R` (column) positioning, `ESC K` 0–7 font
|
||||
select, and `ESC H` attributes as the low 4 bits. Verified: 24 unit tests +
|
||||
the three self-test pages render the real glyphs. Still deferred (documented,
|
||||
single-page model retained): the 10 double-buffered pages (`ESC I`/`ESC i`)
|
||||
and the vector-graphics primitives (`ESC A`–`F`).
|
||||
select, and `ESC H` attributes as the low 4 bits. The standalone app also
|
||||
implements the functional JP1 jumpers — baud (1+2), **orientation (4:
|
||||
horizontal 128×32 / vertical 32×128)**, **display test (5: all-dot pattern)**,
|
||||
and demo (6). Verified: 27 unit tests + the self-test pages render the real
|
||||
glyphs. Still deferred (documented, single-page model retained): the 10
|
||||
double-buffered pages (`ESC I`/`ESC i`) and the vector-graphics primitives
|
||||
(`ESC A`–`F`).
|
||||
|
||||
**For the replica:** this *is* the spec. The firmware confirms a clean model —
|
||||
a byte-stream command parser, a 512-byte-per-page frame buffer, 10 pages with
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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, 0–31),
|
||||
/// <c>ESC R</c> its column (X, 0–127) — 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> (0–7). 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 (0–127).</summary>
|
||||
/// <summary>Cursor column X in logical pixels.</summary>
|
||||
public int CursorX { get { lock (_sync) return _x; } }
|
||||
/// <summary>Cursor row Y in pixels (0–31).</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 0–31.
|
||||
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 0–127.
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -306,6 +306,55 @@ public class VPlasmaDeviceTests
|
||||
}
|
||||
}
|
||||
|
||||
// ---- orientation + test pattern (JP1 jumpers 4 & 5) --------------------
|
||||
|
||||
[Fact]
|
||||
public void Orientation_VerticalRotatesLogicalOntoPhysicalGlass()
|
||||
{
|
||||
var device = new VPlasmaDevice { Orientation = PlasmaOrientation.Vertical };
|
||||
Assert.Equal(32, device.LogicalWidth);
|
||||
Assert.Equal(128, device.LogicalHeight);
|
||||
|
||||
// Logical (0,0) maps to physical (px=ly=0, py=Height-1-lx=31).
|
||||
Feed(device, Esc, (byte)'R', 0, Esc, (byte)'Q', 0, (byte)'H');
|
||||
Assert.Equal(VPlasmaDevice.PixelLit, Pixel(device, 0, 31)); // 'H' top-left dot
|
||||
Assert.Equal(0, Pixel(device, 0, 0));
|
||||
|
||||
// ESC Q (row Y) now accepts up to the 128-tall logical height;
|
||||
// ESC R (column X) is bounded by the 32-wide logical width.
|
||||
Feed(device, Esc, (byte)'Q', 100);
|
||||
Assert.Equal(100, device.CursorY);
|
||||
Feed(device, Esc, (byte)'R', 40); // out of range for 32-wide vertical
|
||||
Assert.NotEqual(40, device.CursorX);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Orientation_ChangeClearsAndHomes()
|
||||
{
|
||||
var device = new VPlasmaDevice();
|
||||
Feed(device, (byte)'H');
|
||||
Assert.NotEqual(0, Pixel(device, 0, 0));
|
||||
|
||||
device.Orientation = PlasmaOrientation.Vertical;
|
||||
Assert.Equal(0, Pixel(device, 0, 0)); // cleared
|
||||
Assert.Equal(0, device.CursorX);
|
||||
Assert.Equal(0, device.CursorY);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ShowTestPattern_LightsEveryDot()
|
||||
{
|
||||
var device = new VPlasmaDevice();
|
||||
device.ShowTestPattern();
|
||||
|
||||
Assert.Equal(VPlasmaDevice.PixelLit, Pixel(device, 0, 0));
|
||||
Assert.Equal(VPlasmaDevice.PixelLit, Pixel(device, 127, 31));
|
||||
Assert.Equal(VPlasmaDevice.PixelLit, Pixel(device, 64, 16));
|
||||
|
||||
device.Reset();
|
||||
Assert.Equal(0, Pixel(device, 64, 16));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reset_RestoresPowerOnState()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user