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 AF). vPLASMA models a single page. +/// primitives (ESC AF). /// /// 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() {