Files
VRIO/tools/VPlasma.Wire/Program.cs
T
CydandClaude Opus 4.8 78cd679b53 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>
2026-07-16 20:42:00 -05:00

290 lines
11 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
}
}