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
+
+
+
+
+
+
+
+
+
+
+
+