diff --git a/PlasmaNew/FIRMWARE.md b/PlasmaNew/FIRMWARE.md
index 25fa481..2fc584f 100644
--- a/PlasmaNew/FIRMWARE.md
+++ b/PlasmaNew/FIRMWARE.md
@@ -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
diff --git a/src/VPlasma.App/MainForm.cs b/src/VPlasma.App/MainForm.cs
index 9f1b290..297adf8 100644
--- a/src/VPlasma.App/MainForm.cs
+++ b/src/VPlasma.App/MainForm.cs
@@ -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)
diff --git a/src/VPlasma.Core/Device/VPlasmaDevice.cs b/src/VPlasma.Core/Device/VPlasmaDevice.cs
index 6a567d9..f9ccff6 100644
--- a/src/VPlasma.Core/Device/VPlasmaDevice.cs
+++ b/src/VPlasma.Core/Device/VPlasmaDevice.cs
@@ -21,6 +21,18 @@ public enum PlasmaAttributes : byte
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
@@ -28,22 +40,21 @@ public enum PlasmaAttributes : byte
/// 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, 0–31),
-/// ESC R 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
-/// (): 6×8, 6×10, 7×10, and the large 12×16 / 12×20,
-/// selected with ESC K (0–7). Attributes are the low 4 bits of 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.
///
-/// 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 dimmer and blinks
-/// . The command set is documented on
-/// .
+/// 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). vPLASMA models a single page.
+/// 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.
@@ -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; } }
- /// Cursor column X in pixels (0–127).
+ /// Cursor column X in logical pixels.
public int CursorX { get { lock (_sync) return _x; } }
- /// Cursor row Y in pixels (0–31).
+ /// 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)
{
@@ -124,7 +180,7 @@ public sealed class VPlasmaDevice
Buffer.BlockCopy(_pixels, 0, destination, 0, _pixels.Length);
}
- /// Power-on state: dark glass, home cursor, defaults.
+ /// Power-on state: dark glass, home cursor, defaults (keeps orientation).
public void Reset()
{
lock (_sync)
@@ -141,6 +197,21 @@ public sealed class VPlasmaDevice
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)
{
@@ -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;
}
diff --git a/tests/VPlasma.Core.Tests/VPlasmaDeviceTests.cs b/tests/VPlasma.Core.Tests/VPlasmaDeviceTests.cs
index 4bd1e90..f61d8d0 100644
--- a/tests/VPlasma.Core.Tests/VPlasmaDeviceTests.cs
+++ b/tests/VPlasma.Core.Tests/VPlasmaDeviceTests.cs
@@ -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()
{