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>
290 lines
11 KiB
C#
290 lines
11 KiB
C#
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;
|
||
}
|
||
}
|