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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user