From 78cd679b533f7436041e32c88821cdcc343ec07b Mon Sep 17 00:00:00 2001 From: Cyd Date: Thu, 16 Jul 2026 20:42:00 -0500 Subject: [PATCH] Add VPlasma.Wire: differential-test helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A console tool for validating the hardware replica against vPLASMA and the real panel by feeding all three identical byte streams. - synth — build a repeatable command stream (selftest/demo/banner/charset). - render — feed a stream through the vPLASMA engine and save the resulting 128x32 frame as a PNG: the pixel-exact golden image. - replay — send a stream to any target port: the real panel (RS-232, self- pacing), the Matrix Portal replica (USB-CDC), or a virtual port (--paced). - capture — log live traffic to a file, with --tee to forward it on to the real display so capture is non-intrusive. Reuses VPlasma.Core (same parser/fonts as the emulator and the ported firmware). The replica README documents the differential-test workflow. Co-Authored-By: Claude Opus 4.8 --- PlasmaNew/replica/README.md | 37 +++- VRio.sln | 9 + tools/VPlasma.Wire/Program.cs | 289 +++++++++++++++++++++++++ tools/VPlasma.Wire/VPlasma.Wire.csproj | 22 ++ 4 files changed, 352 insertions(+), 5 deletions(-) create mode 100644 tools/VPlasma.Wire/Program.cs create mode 100644 tools/VPlasma.Wire/VPlasma.Wire.csproj diff --git a/PlasmaNew/replica/README.md b/PlasmaNew/replica/README.md index 3b9eeb3..e0f3173 100644 --- a/PlasmaNew/replica/README.md +++ b/PlasmaNew/replica/README.md @@ -80,13 +80,40 @@ display listens), exactly as the cockpit drove the real panel. | `MatrixPortalPlasma/plasma_fonts.h` | the 8 real ROM fonts (PROGMEM), generated from `tms27pc512.BIN` | | `MatrixPortalPlasma/demo_screens.h` | the 10 firmware demo screens (PROGMEM) | -## Keeping it faithful +## Keeping it faithful — the differential test `PlasmaDisplay` is a line-for-line port of `VPlasmaDevice`; keep the two in -sync (same commands, fonts, orientation/`plot` mapping). To validate the -replica, run the **differential test**: send identical byte streams to the -hardware and to vPLASMA and compare the glass. The real panel still works, so -it can be the third reference. +sync (same commands, fonts, orientation/`plot` mapping). Validate the replica +by sending **identical byte streams** to the replica, to vPLASMA, and to the +real panel, then comparing the glass. + +The **`VPlasma.Wire`** tool (`tools/VPlasma.Wire`, `dotnet run --project +tools/VPlasma.Wire -- …`) drives this: + +```sh +# 1) Build a repeatable stream (or capture a real one — see below). +VPlasma.Wire synth --kind demo --out demo.bin + +# 2) The vPLASMA golden image (pixel-exact reference PNG). +VPlasma.Wire render --in demo.bin --out demo-golden.png --scale 8 + +# 3) Replay the SAME bytes to each target, and photograph the glass: +VPlasma.Wire replay --in demo.bin --port COM3 # the real panel (RS-232) +VPlasma.Wire replay --in demo.bin --port COM7 # the Matrix Portal (USB-CDC) +``` + +Compare the two photos against `demo-golden.png`. `synth` kinds: `selftest`, +`demo`, `banner`, `charset`. `render` takes `--orient v` for vertical. + +**Capture a real session** to build a test corpus non-intrusively — insert the +tool in the path (point the game at `COM12`, tool tees on to the real display +on `COM3`): + +```sh +VPlasma.Wire capture --port COM12 --out session.bin --tee COM3 +``` + +Then `render`/`replay` `session.bin` like any other stream. Still deferred (as in vPLASMA, documented in `../FIRMWARE.md`): the 10 double-buffered pages (`ESC I`/`ESC i` are consumed but single-page) and the diff --git a/VRio.sln b/VRio.sln index ac3b749..1a73b57 100644 --- a/VRio.sln +++ b/VRio.sln @@ -19,6 +19,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "VPlasma.App", "src\VPlasma. EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "VPlasma.Core.Tests", "tests\VPlasma.Core.Tests\VPlasma.Core.Tests.csproj", "{F31F1D86-546A-4B0C-A283-C04FAAC49F46}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tools", "tools", "{01717774-2AD5-4CF5-A0D7-69AF81A534E8}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "VPlasma.Wire", "tools\VPlasma.Wire\VPlasma.Wire.csproj", "{CA883363-5610-4245-98B1-3192AE652F1A}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -52,6 +56,10 @@ Global {F31F1D86-546A-4B0C-A283-C04FAAC49F46}.Debug|Any CPU.Build.0 = Debug|Any CPU {F31F1D86-546A-4B0C-A283-C04FAAC49F46}.Release|Any CPU.ActiveCfg = Release|Any CPU {F31F1D86-546A-4B0C-A283-C04FAAC49F46}.Release|Any CPU.Build.0 = Release|Any CPU + {CA883363-5610-4245-98B1-3192AE652F1A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CA883363-5610-4245-98B1-3192AE652F1A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CA883363-5610-4245-98B1-3192AE652F1A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CA883363-5610-4245-98B1-3192AE652F1A}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(NestedProjects) = preSolution {80312F43-09BF-4F09-A0FA-A60FDF86274D} = {D0ECC9D9-7379-4759-89F7-56CD3214BD57} @@ -60,5 +68,6 @@ Global {39E7C28F-8B07-495C-A887-21F2F6AF9A86} = {D0ECC9D9-7379-4759-89F7-56CD3214BD57} {72D03B3A-7D4E-496C-8DA9-596DFC91704E} = {D0ECC9D9-7379-4759-89F7-56CD3214BD57} {F31F1D86-546A-4B0C-A283-C04FAAC49F46} = {C4993638-7EB6-47A9-897C-976DB9939601} + {CA883363-5610-4245-98B1-3192AE652F1A} = {01717774-2AD5-4CF5-A0D7-69AF81A534E8} EndGlobalSection EndGlobal diff --git a/tools/VPlasma.Wire/Program.cs b/tools/VPlasma.Wire/Program.cs new file mode 100644 index 0000000..a857b5b --- /dev/null +++ b/tools/VPlasma.Wire/Program.cs @@ -0,0 +1,289 @@ +using System.Diagnostics; +using System.Drawing; +using System.Drawing.Imaging; +using System.IO.Ports; +using VPlasma.Core.Device; +using VPlasma.Core.Protocol; + +namespace VPlasma.Wire; + +/// +/// Differential-test helper for the plasma display work. Generates repeatable +/// command streams, replays them to any target (the real panel, the Matrix +/// Portal replica, or a virtual port), captures live traffic, and renders the +/// vPLASMA "golden image" for comparison. Feed the same stream to all three +/// and the glass should match. +/// +/// Commands: +/// +/// synth --kind selftest|demo|charset|banner --out stream.bin +/// render --in stream.bin --out frame.png [--scale 8] [--orient h|v] +/// replay --in stream.bin --port COM3 [--baud 9600] [--paced] +/// capture --port COM12 --out stream.bin [--tee COM3] [--seconds N] +/// +/// +internal static class Program +{ + private static int Main(string[] args) + { + if (args.Length == 0) + return Usage(); + + var a = new Args(args); + try + { + switch (args[0].ToLowerInvariant()) + { + case "synth": return Synth(a); + case "render": return Render(a); + case "replay": return Replay(a); + case "capture": return Capture(a); + case "-h" or "--help" or "help": return Usage(); + default: + Console.Error.WriteLine($"Unknown command '{args[0]}'."); + return Usage(); + } + } + catch (Exception ex) + { + Console.Error.WriteLine($"Error: {ex.Message}"); + return 1; + } + } + + // ---- synth: build a repeatable command stream -------------------------- + + private static int Synth(Args a) + { + string kind = a.Get("kind", "selftest").ToLowerInvariant(); + string outPath = a.Require("out"); + + var b = new List(); + switch (kind) + { + case "selftest": + for (int p = 0; p < PlasmaSelfTest.PageCount; p++) + b.AddRange(PlasmaSelfTest.BuildPage(p)); + break; + case "demo": + foreach (byte[] screen in PlasmaFirmwareDemo.Screens) + b.AddRange(screen); + break; + case "banner": + b.AddRange(PlasmaSelfTest.BuildPage(0)); + break; + case "charset": + b.Add(PlasmaProtocol.Esc); b.Add(PlasmaProtocol.CmdClearScreen); + b.Add(PlasmaProtocol.Esc); b.Add(PlasmaProtocol.CmdCursorMode); b.Add(0x00); + for (int c = 0x20; c <= 0x7E; c++) b.Add((byte)c); + break; + default: + Console.Error.WriteLine("--kind must be selftest | demo | banner | charset"); + return 1; + } + + File.WriteAllBytes(outPath, b.ToArray()); + Console.WriteLine($"Wrote {b.Count} bytes to {outPath} (kind={kind})"); + return 0; + } + + // ---- render: the vPLASMA golden image ---------------------------------- + + private static int Render(Args a) + { + string inPath = a.Require("in"); + string outPath = a.Require("out"); + int scale = a.GetInt("scale", 8); + bool vertical = a.Get("orient", "h").StartsWith("v", StringComparison.OrdinalIgnoreCase); + + var device = new VPlasmaDevice(); + if (vertical) + device.Orientation = PlasmaOrientation.Vertical; + byte[] data = File.ReadAllBytes(inPath); + device.OnReceived(data, data.Length); + + var frame = new byte[VPlasmaDevice.Width * VPlasmaDevice.Height]; + device.CopyFrame(frame); + RenderPng(frame, outPath, scale); + + Console.WriteLine($"Fed {data.Length} bytes; rendered {VPlasmaDevice.Width}×{VPlasmaDevice.Height} " + + $"({device.TextCharsDrawn} chars, {device.GraphicsRows} graphics rows) → {outPath}"); + return 0; + } + + private static void RenderPng(byte[] frame, string outPath, int scale) + { + int pitch = scale; + int dot = Math.Max(1, scale - 2); + int W = VPlasmaDevice.Width, H = VPlasmaDevice.Height; + + using var bmp = new Bitmap(W * pitch, H * pitch, PixelFormat.Format24bppRgb); + using var g = Graphics.FromImage(bmp); + g.Clear(Color.FromArgb(20, 18, 16)); + using var glass = new SolidBrush(Color.FromArgb(26, 14, 6)); + g.FillRectangle(glass, 0, 0, bmp.Width, bmp.Height); + + using var unlit = new SolidBrush(Color.FromArgb(46, 24, 10)); + using var lit = new SolidBrush(Color.FromArgb(255, 106, 26)); + using var half = new SolidBrush(Color.FromArgb(150, 62, 15)); + + for (int y = 0; y < H; y++) + { + for (int x = 0; x < W; x++) + { + byte px = frame[y * W + x]; + // Static image: render a flashing dot in its "on" phase. + Brush brush = (px & VPlasmaDevice.PixelLit) != 0 + ? ((px & VPlasmaDevice.PixelHalf) != 0 ? half : lit) + : unlit; + g.FillRectangle(brush, x * pitch, y * pitch, dot, dot); + } + } + bmp.Save(outPath, ImageFormat.Png); + } + + // ---- replay: send a stream to a target port ---------------------------- + + private static int Replay(Args a) + { + string inPath = a.Require("in"); + string portName = a.Require("port"); + int baud = a.GetInt("baud", PlasmaProtocol.BaudRate); + bool paced = a.Has("paced"); + + byte[] data = File.ReadAllBytes(inPath); + using var port = new SerialPort(portName, baud, Parity.None, 8, StopBits.One) + { + Handshake = Handshake.None, + WriteTimeout = 5000, + DtrEnable = true, + RtsEnable = true, + }; + port.Open(); + + if (paced) + { + // One byte per 10-bit frame time — for virtual ports with no UART. + long period = Stopwatch.Frequency * 10 / baud; + long slot = Stopwatch.GetTimestamp(); + var one = new byte[1]; + foreach (byte t in data) + { + slot = Math.Max(slot + period, Stopwatch.GetTimestamp()); + while (Stopwatch.GetTimestamp() < slot) { /* spin */ } + one[0] = t; + port.Write(one, 0, 1); + } + } + else + { + // A real UART at `baud` paces itself; a USB-CDC target takes it fast. + port.Write(data, 0, data.Length); + } + + Console.WriteLine($"Replayed {data.Length} bytes to {portName} @ {baud} 8N1{(paced ? " (paced)" : "")}."); + Thread.Sleep(200); // let the last bytes drain before closing + return 0; + } + + // ---- capture: log live traffic (optionally teeing to the display) ------ + + private static int Capture(Args a) + { + string portName = a.Require("port"); + string outPath = a.Require("out"); + string? teeName = a.Get("tee", ""); + int seconds = a.GetInt("seconds", 0); + + using var port = new SerialPort(portName, a.GetInt("baud", PlasmaProtocol.BaudRate), + Parity.None, 8, StopBits.One) { Handshake = Handshake.None, ReadTimeout = 200 }; + port.Open(); + + SerialPort? tee = null; + if (!string.IsNullOrWhiteSpace(teeName)) + { + tee = new SerialPort(teeName, a.GetInt("baud", PlasmaProtocol.BaudRate), Parity.None, 8, StopBits.One) + { Handshake = Handshake.None, DtrEnable = true, RtsEnable = true }; + tee.Open(); + } + + using var outFile = File.Create(outPath); + var stop = new ManualResetEventSlim(false); + Console.CancelKeyPress += (_, e) => { e.Cancel = true; stop.Set(); }; + var deadline = seconds > 0 ? Stopwatch.StartNew() : null; + + Console.WriteLine($"Capturing {portName} → {outPath}" + + (tee != null ? $" (tee → {teeName})" : "") + + $". {(seconds > 0 ? $"{seconds}s or " : "")}Ctrl+C to stop."); + long total = 0; + var buf = new byte[512]; + while (!stop.IsSet && (deadline is null || deadline.Elapsed.TotalSeconds < seconds)) + { + int n; + try { n = port.Read(buf, 0, buf.Length); } + catch (TimeoutException) { continue; } + if (n <= 0) continue; + outFile.Write(buf, 0, n); + tee?.Write(buf, 0, n); + total += n; + Console.Write($"\r{total} bytes captured"); + } + Console.WriteLine($"\nDone. {total} bytes → {outPath}"); + tee?.Dispose(); + return 0; + } + + // ---- tiny arg parser --------------------------------------------------- + + private sealed class Args + { + private readonly Dictionary _kv = new(StringComparer.OrdinalIgnoreCase); + private readonly HashSet _flags = new(StringComparer.OrdinalIgnoreCase); + + public Args(string[] args) + { + for (int i = 1; i < args.Length; i++) + { + if (!args[i].StartsWith("--")) continue; + string key = args[i].Substring(2); + if (i + 1 < args.Length && !args[i + 1].StartsWith("--")) + _kv[key] = args[++i]; + else + _flags.Add(key); + } + } + + public bool Has(string k) => _flags.Contains(k) || _kv.ContainsKey(k); + public string Get(string k, string dflt) => _kv.TryGetValue(k, out var v) ? v : dflt; + public int GetInt(string k, int dflt) => _kv.TryGetValue(k, out var v) && int.TryParse(v, out var n) ? n : dflt; + public string Require(string k) => _kv.TryGetValue(k, out var v) + ? v : throw new ArgumentException($"--{k} is required"); + } + + private static int Usage() + { + Console.WriteLine(""" + vPLASMA wire tool — differential-test helper. + + synth --kind selftest|demo|banner|charset --out stream.bin + Build a repeatable command stream. + + render --in stream.bin --out frame.png [--scale 8] [--orient h|v] + Feed the stream through the vPLASMA engine and save the + resulting 128×32 frame as a PNG (the golden image). + + replay --in stream.bin --port COM3 [--baud 9600] [--paced] + Send the stream to a target: the real panel, the Matrix + Portal replica (USB-CDC), or a virtual port (--paced). + + capture --port COM12 --out stream.bin [--tee COM3] [--seconds N] + Log live traffic to a file; --tee forwards it on to the + real display so capture is non-intrusive. Ctrl+C to stop. + + Differential test: `synth` a stream once, `render` the golden PNG, + `replay` the same stream to the replica and to the real panel, and + compare the three. + """); + return 0; + } +} diff --git a/tools/VPlasma.Wire/VPlasma.Wire.csproj b/tools/VPlasma.Wire/VPlasma.Wire.csproj new file mode 100644 index 0000000..e216214 --- /dev/null +++ b/tools/VPlasma.Wire/VPlasma.Wire.csproj @@ -0,0 +1,22 @@ + + + + Exe + net48 + enable + enable + latest + vPLASMA wire tool + VPlasma.Wire + + + + + + + + + + + +