Add VPlasma.Wire: differential-test helper

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 <noreply@anthropic.com>
This commit is contained in:
Cyd
2026-07-16 20:42:00 -05:00
co-authored by Claude Opus 4.8
parent ebad6ee5fe
commit 78cd679b53
4 changed files with 352 additions and 5 deletions
+32 -5
View File
@@ -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
+9
View File
@@ -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
+289
View File
@@ -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;
/// <summary>
/// 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.
///
/// <para>Commands:</para>
/// <code>
/// 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]
/// </code>
/// </summary>
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<byte>();
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<string, string> _kv = new(StringComparer.OrdinalIgnoreCase);
private readonly HashSet<string> _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;
}
}
+22
View File
@@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net48</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
<AssemblyTitle>vPLASMA wire tool</AssemblyTitle>
<RootNamespace>VPlasma.Wire</RootNamespace>
</PropertyGroup>
<ItemGroup>
<!-- System.Drawing for the golden-image PNG render. -->
<Reference Include="System.Drawing" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\VPlasma.Core\VPlasma.Core.csproj" />
</ItemGroup>
</Project>