diff --git a/src/VPlasma.App/MainForm.cs b/src/VPlasma.App/MainForm.cs index 71d84b3..ea63b60 100644 --- a/src/VPlasma.App/MainForm.cs +++ b/src/VPlasma.App/MainForm.cs @@ -3,16 +3,20 @@ using VPlasma.Core.Device; namespace VPlasma.App; /// -/// vPLASMA main window: just the plasma glass, sized to the display. The -/// device end is hardwired to (the plasma side of the -/// second com0com pair) and opened automatically — a retry timer keeps -/// trying while the port is missing or busy, and reopens it if it dies. The -/// DOSBox-X fork's named-pipe transport (\\.\pipe\vplasma) is served -/// permanently alongside it (if vRIO's built-in glass already holds the -/// pipe name, the server just retries until it frees up). The -/// title bar carries the port status; double-clicking the glass cycles the -/// self-test pages (banner, charset, graphics) through the wire parser, and -/// a right-click resets the display to its power-on state. +/// vPLASMA main window: the plasma glass on the left, a config panel on the +/// right. The device end is hardwired to (the plasma +/// side of the second com0com pair) and opened automatically — a retry timer +/// keeps trying while the port is missing or busy, and reopens it if it dies. +/// The DOSBox-X fork's named-pipe transport (\\.\pipe\vplasma) is +/// served permanently alongside it. +/// +/// The right panel mirrors the real PD01D221's JP1 configuration +/// jumpers (recovered in PlasmaNew/FIRMWARE.md): the two baud +/// straps drive the open baud, jumper 6 runs the built-in demo (exactly what +/// the firmware does when that jumper is installed), and the rest are the +/// hardware's config bits. Plus display controls, live counters, and a wire +/// log. Double-click the glass to cycle the self-test pages; right-click to +/// reset to the power-on state. /// internal sealed class MainForm : Form { @@ -25,76 +29,216 @@ internal sealed class MainForm : Form private readonly PlasmaCanvas _canvas = new(); private readonly System.Windows.Forms.Timer _reconnectTimer = new() { Interval = 3000 }; + private readonly System.Windows.Forms.Timer _uiTimer = new() { Interval = 500 }; + private readonly System.Windows.Forms.Timer _demoTimer = new() { Interval = 3000 }; private int _selfTestPage; + // ---- config panel controls ------------------------------------------- + private readonly Label _linkStatus = new() + { + Location = new Point(12, 12), + AutoSize = true, + MaximumSize = new Size(286, 0), + ForeColor = Color.Gray, + }; + + // The seven JP1 jumpers, as toggles. Checked = shunt installed (logic 0 to + // the CPU). Text/tooltips carry each strap's real function and HC11 pin. + private readonly CheckBox _jp1 = Jumper("1 — Baud select A (PA0)"); + private readonly CheckBox _jp2 = Jumper("2 — Baud select B (PA2)"); + private readonly CheckBox _jp3 = Jumper("3 — Config bit (PA3)"); + private readonly CheckBox _jp4 = Jumper("4 — Config bit (PD5)"); + private readonly CheckBox _jp5 = Jumper("5 — Config bit (PD4)"); + private readonly CheckBox _jp6 = Jumper("6 — Demo program (PD3)"); + private readonly CheckBox _jp7 = Jumper("7 — Parallel select (J2 SEL)"); + private readonly Label _baudLabel = new() { AutoSize = true, ForeColor = Color.DimGray }; + + private readonly Button _selfTest = new() { Text = "Self test", Width = 130, Height = 26 }; + private readonly Button _clear = new() { Text = "Clear display", Width = 130, Height = 26 }; + + private readonly Label _counters = new() + { + AutoSize = true, + Font = new Font("Consolas", 8f), + }; + + private readonly TextBox _logBox = new() + { + Multiline = true, + ReadOnly = true, + ScrollBars = ScrollBars.Both, + BackColor = Color.FromArgb(24, 24, 24), + ForeColor = Color.Gainsboro, + Font = new Font("Consolas", 8f), + WordWrap = false, + Anchor = AnchorStyles.Top | AnchorStyles.Bottom | AnchorStyles.Left | AnchorStyles.Right, + }; + private readonly Button _clearLog = new() { Text = "Clear log", Width = 80, Height = 24, Anchor = AnchorStyles.Bottom | AnchorStyles.Left }; + private readonly ToolTip _tips = new(); + + private static CheckBox Jumper(string text) => new() { Text = text, AutoSize = true }; + public MainForm() { + Text = $"vPLASMA v{Application.ProductVersion} — Virtual plasma display"; Icon = Icon.ExtractAssociatedIcon(Application.ExecutablePath); - FormBorderStyle = FormBorderStyle.FixedSingle; // the glass is a fixed-size device - MaximizeBox = false; - ClientSize = _canvas.Size; + ClientSize = new Size(_canvas.Width + 316, Math.Max(_canvas.Height + 8, 520)); + MinimumSize = new Size(_canvas.Width + 332, 420); StartPosition = FormStartPosition.CenterScreen; _service = new VPlasmaSerialService(_device); _pipeService = new VPlasmaPipeService(_device); - _canvas.Location = new Point(0, 0); - Controls.Add(_canvas); + // The glass, on a dark backdrop that fills any extra window space. + var backdrop = new Panel { Dock = DockStyle.Fill, BackColor = Color.FromArgb(28, 28, 28) }; + _canvas.Location = new Point(8, 8); + backdrop.Controls.Add(_canvas); + Controls.Add(backdrop); + Controls.Add(BuildConfigPanel()); - // Device / service events arrive on the serial reader thread; marshal to the UI. + // Device / service events arrive on worker threads; marshal to the UI. _device.Updated += () => RunOnUi(() => _canvas.UpdateFrom(_device)); - _service.ConnectionChanged += _ => RunOnUi(UpdateTitle); - _pipeService.ClientChanged += _ => RunOnUi(UpdateTitle); + _device.Logged += line => RunOnUi(() => PrependLog(line)); + _service.Logged += line => RunOnUi(() => PrependLog(line)); + _service.ConnectionChanged += _ => RunOnUi(UpdateStatus); + _pipeService.Logged += line => RunOnUi(() => PrependLog(line)); + _pipeService.ClientChanged += _ => RunOnUi(UpdateStatus); _canvas.DoubleClick += (_, _) => RunSelfTest(); - _canvas.MouseClick += (_, e) => - { - if (e.Button == MouseButtons.Right) - _device.Reset(); - }; + _canvas.MouseClick += (_, e) => { if (e.Button == MouseButtons.Right) ResetDisplay(); }; + + _selfTest.Click += (_, _) => RunSelfTest(); + _clear.Click += (_, _) => ResetDisplay(); + _clearLog.Click += (_, _) => _logBox.Clear(); + + // Baud straps → reopen at the selected rate. Jumper 6 → demo loop. + _jp1.CheckedChanged += (_, _) => ApplyBaud(); + _jp2.CheckedChanged += (_, _) => ApplyBaud(); + _jp6.CheckedChanged += (_, _) => ApplyDemo(); + _demoTimer.Tick += (_, _) => StepDemo(); + + // Default straps: jumper 2 installed only = 9600 (the game's setting). + _jp2.Checked = true; - // Open at startup, retry while closed (port missing/busy, host restarts). _reconnectTimer.Tick += (_, _) => EnsureOpen(); _reconnectTimer.Start(); + _uiTimer.Tick += (_, _) => UpdateCounters(); + _uiTimer.Start(); FormClosed += (_, _) => { _reconnectTimer.Dispose(); + _uiTimer.Dispose(); + _demoTimer.Dispose(); _service.Dispose(); _pipeService.Dispose(); }; _canvas.UpdateFrom(_device); + _service.Baud = SelectedBaud(); + UpdateStatus(); + UpdateCounters(); EnsureOpen(); _pipeService.Start(); + PrependLog("vPLASMA ready. Serving COM12 + \\\\.\\pipe\\vplasma. Toggle jumper 6 for the built-in demo."); } - private void EnsureOpen() + private Panel BuildConfigPanel() { + var panel = new Panel { Dock = DockStyle.Right, Width = 300, BackColor = SystemColors.Control }; + panel.Controls.Add(_linkStatus); + + var jumpers = new GroupBox { Text = "JP1 configuration jumpers", Location = new Point(12, 44), Size = new Size(276, 176) }; + CheckBox[] jp = { _jp1, _jp2, _jp3, _jp4, _jp5, _jp6, _jp7 }; + for (int i = 0; i < jp.Length; i++) + { + jp[i].Location = new Point(12, 20 + i * 20); + jumpers.Controls.Add(jp[i]); + } + _baudLabel.Location = new Point(150, 20); + jumpers.Controls.Add(_baudLabel); + panel.Controls.Add(jumpers); + + _tips.SetToolTip(_jp1, "Baud select, low bit. With jumper 2: 4800/9600/19.2K/38.4K."); + _tips.SetToolTip(_jp2, "Baud select, high bit. Installed alone = 9600 (the game's rate)."); + _tips.SetToolTip(_jp3, "Firmware config bit on PA3 (function not yet decoded)."); + _tips.SetToolTip(_jp4, "Firmware config bit on PD5 (function not yet decoded)."); + _tips.SetToolTip(_jp5, "Firmware config bit on PD4 (function not yet decoded)."); + _tips.SetToolTip(_jp6, "Install to run the built-in demonstration program (as the firmware does)."); + _tips.SetToolTip(_jp7, "Selects the parallel interface on the real board; no effect here (serial only)."); + + var display = new GroupBox { Text = "Display", Location = new Point(12, 230), Size = new Size(276, 62) }; + _selfTest.Location = new Point(12, 22); + _clear.Location = new Point(146, 22); + display.Controls.Add(_selfTest); + display.Controls.Add(_clear); + panel.Controls.Add(display); + + _counters.Location = new Point(12, 302); + panel.Controls.Add(_counters); + + var logLabel = new Label { Text = "Wire log:", Location = new Point(12, 384), AutoSize = true }; + panel.Controls.Add(logLabel); + _logBox.SetBounds(12, 402, 276, 0); + panel.Controls.Add(_logBox); + panel.Controls.Add(_clearLog); + + panel.Resize += (_, _) => + { + _logBox.Size = new Size(276, Math.Max(40, panel.ClientSize.Height - _logBox.Top - 40)); + _clearLog.Location = new Point(12, panel.ClientSize.Height - 32); + }; + + return panel; + } + + // ---- jumper behaviour -------------------------------------------------- + + /// Baud from the two straps (installed = logic 0), per the datasheet. + private int SelectedBaud() => (_jp1.Checked, _jp2.Checked) switch + { + (true, true) => 4800, + (false, true) => 9600, + (true, false) => 19200, + (false, false) => 38400, + }; + + private void ApplyBaud() + { + int baud = SelectedBaud(); + _service.Baud = baud; + PrependLog($"Baud straps → {baud} 8N1 (reopening)"); if (_service.IsOpen) - return; - - try - { - _service.Open(PortName); - } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidOperationException or ArgumentException) - { - UpdateTitle(); // stays closed; the timer tries again - } + _service.Close(); // the reconnect timer reopens at the new baud + EnsureOpen(); + UpdateStatus(); } - private void UpdateTitle() + private void ApplyDemo() { - // ProductVersion carries the git stamp (see StampGitVersion in the csproj). - string status = _service.IsOpen - ? $"{PortName} @ 9600 8N1" - : $"{PortName} unavailable — retrying"; - if (_pipeService.IsClientConnected) - status += " • pipe host connected"; - Text = $"vPLASMA v{Application.ProductVersion} — {status}"; + if (_jp6.Checked) + { + PrependLog("Jumper 6 installed — demonstration mode (cycling the self-test pages)"); + _selfTestPage = 0; + StepDemo(); + _demoTimer.Start(); + } + else + { + _demoTimer.Stop(); + PrependLog("Jumper 6 removed — demo stopped"); + } } + private void StepDemo() + { + byte[] bytes = PlasmaSelfTest.BuildPage(_selfTestPage); + _device.OnReceived(bytes, bytes.Length); + _selfTestPage = (_selfTestPage + 1) % PlasmaSelfTest.PageCount; + } + + // ---- display controls -------------------------------------------------- + private void RunSelfTest() { byte[] bytes = PlasmaSelfTest.BuildPage(_selfTestPage); @@ -102,6 +246,60 @@ internal sealed class MainForm : Form _selfTestPage = (_selfTestPage + 1) % PlasmaSelfTest.PageCount; } + private void ResetDisplay() + { + _device.Reset(); + PrependLog("Display reset to power-on state"); + } + + private void EnsureOpen() + { + if (_service.IsOpen) + return; + try + { + _service.Open(PortName); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidOperationException or ArgumentException) + { + UpdateStatus(); // stays closed; the timer tries again + } + } + + // ---- status / counters / log ------------------------------------------ + + private void UpdateStatus() + { + _baudLabel.Text = $"{SelectedBaud()} baud"; + string line = _service.IsOpen + ? $"Listening on {PortName} @ {_service.Baud} 8N1." + : $"{PortName} unavailable — retrying."; + if (_pipeService.IsClientConnected) + line += " Pipe host connected."; + _linkStatus.Text = line; + _linkStatus.ForeColor = _service.IsOpen ? Color.DarkGreen : Color.Gray; + } + + private void UpdateCounters() + { + _counters.Text = + $"Bytes received: {_device.BytesReceived}\n" + + $"Graphics rows: {_device.GraphicsRows}\n" + + $"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())}"; + } + + private void PrependLog(string line) + { + string stamped = $"{DateTime.Now:HH:mm:ss.fff} {line}"; + string text = _logBox.TextLength == 0 ? stamped : stamped + Environment.NewLine + _logBox.Text; + if (text.Length > 20000) + text = text.Substring(0, 20000); + _logBox.Text = text; + } + private void RunOnUi(Action action) { if (IsDisposed || Disposing) diff --git a/src/VPlasma.Core/Device/VPlasmaSerialService.cs b/src/VPlasma.Core/Device/VPlasmaSerialService.cs index 60de14c..3040523 100644 --- a/src/VPlasma.Core/Device/VPlasmaSerialService.cs +++ b/src/VPlasma.Core/Device/VPlasmaSerialService.cs @@ -27,6 +27,14 @@ public sealed class VPlasmaSerialService : IDisposable _device = device ?? throw new ArgumentNullException(nameof(device)); } + /// + /// Bit rate to open the port at (default 9600). On the real display this is + /// the baud the two JP1 jumpers select (4800/9600/19.2K/38.4K); over a + /// virtual null-modem it's cosmetic, but the standalone app exposes the + /// jumpers so it's honored on the next open. Set before . + /// + public int Baud { get; set; } = PlasmaProtocol.BaudRate; + /// True while a COM port is open. public bool IsOpen => _port?.IsOpen == true; @@ -47,7 +55,7 @@ public sealed class VPlasmaSerialService : IDisposable Close(); - var port = new SerialPort(portName, PlasmaProtocol.BaudRate, Parity.None, 8, StopBits.One) + var port = new SerialPort(portName, Baud, Parity.None, 8, StopBits.One) { Handshake = Handshake.None, // Finite read timeout so the reader thread can notice shutdown. @@ -65,7 +73,7 @@ public sealed class VPlasmaSerialService : IDisposable _reader = new Thread(ReadLoop) { IsBackground = true, Name = "vPLASMA serial reader" }; _reader.Start(); - Logged?.Invoke($"Opened {portName} @ {PlasmaProtocol.BaudRate} 8N1 — listening for the host"); + Logged?.Invoke($"Opened {portName} @ {Baud} 8N1 — listening for the host"); ConnectionChanged?.Invoke(true); }