- diff — compare two frames/images/encodings (.bin ESC P / .png / .txt
bitmap=), each reduced to 128x32; reports differing dots and match %, writes
a red-on-mismatch image, exits 1 if they differ. Closes the differential-test
loop: diff a cropped panel/replica photo against the vPLASMA golden image.
- text / encode / decode — ported from TeslaSuite's three plasma tools (Plasma
Font Tool, Bitmap Decoder, Editor) via PlasmaBitmap.cs:
* text — render text in an auto-sized Windows font to a plasma bitmap.
* encode — an image → plasma bitmap.
* decode — the game's bitmap= hex encoding → a preview PNG.
Each bridges to an ESC P wire stream (and/or the bitmap= hex encoding), so
authored content replays straight to vPLASMA, the replica, or the real panel.
Verified: text "ALERT 12" → ESC P → vPLASMA renders it; the ESC P and hex
paths diff as byte-identical (504/504 lit, 100% match).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
468 lines
18 KiB
C#
468 lines
18 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 "text": return TextCmd(a);
|
||
case "encode": return Encode(a);
|
||
case "decode": return Decode(a);
|
||
case "diff": return Diff(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;
|
||
}
|
||
|
||
// ---- text: render text (Windows font) to a plasma bitmap ---------------
|
||
// (TeslaSuite's Plasma Font Tool)
|
||
|
||
private static int TextCmd(Args a)
|
||
{
|
||
string text = a.Require("text");
|
||
string fontName = a.Get("font", PlasmaBitmap.DefaultFont);
|
||
(int w, int h) = ParseSize(a.Get("size", "128x32"));
|
||
double thresh = a.GetDouble("threshold", 0.5);
|
||
|
||
using Bitmap bmp = PlasmaBitmap.RenderText(w, h, fontName, text);
|
||
bool[,] grid = PlasmaBitmap.ToGrid(bmp, w, h, thresh);
|
||
WriteBitmapOutputs(a, grid, defaultPng: null);
|
||
Console.WriteLine($"Rendered \"{text}\" ({fontName}) into {w}×{h}.");
|
||
return 0;
|
||
}
|
||
|
||
// ---- encode: image file → plasma bitmap encoding / ESC P ----------------
|
||
|
||
private static int Encode(Args a)
|
||
{
|
||
string inPath = a.Require("in");
|
||
(int w, int h) = ParseSize(a.Get("size", "128x32"));
|
||
double thresh = a.GetDouble("threshold", 0.5);
|
||
|
||
using var src = new Bitmap(inPath);
|
||
bool[,] grid = PlasmaBitmap.ToGrid(src, w, h, thresh);
|
||
WriteBitmapOutputs(a, grid, defaultPng: null);
|
||
Console.WriteLine($"Encoded {inPath} ({src.Width}×{src.Height}) → {w}×{h} plasma bitmap.");
|
||
return 0;
|
||
}
|
||
|
||
// ---- decode: plasma bitmap encoding → PNG / ESC P -----------------------
|
||
// (TeslaSuite's Plasma Bitmap Decoder)
|
||
|
||
private static int Decode(Args a)
|
||
{
|
||
string inPath = a.Require("in");
|
||
bool[,] grid = PlasmaBitmap.FromHexEncoding(File.ReadAllText(inPath));
|
||
int scale = a.GetInt("scale", 8);
|
||
|
||
string png = a.Get("png", "");
|
||
if (string.IsNullOrEmpty(png))
|
||
png = Path.ChangeExtension(inPath, ".png");
|
||
PlasmaBitmap.SaveGridPng(grid, png, scale);
|
||
if (a.Get("bin", "") is { Length: > 0 } bin)
|
||
File.WriteAllBytes(bin, PlasmaBitmap.ToEscP(grid));
|
||
Console.WriteLine($"Decoded {inPath} ({grid.GetLength(0)}×{grid.GetLength(1)}) → {png}");
|
||
return 0;
|
||
}
|
||
|
||
// Emit whichever of --png / --bin (ESC P) / --hex outputs were requested.
|
||
private static void WriteBitmapOutputs(Args a, bool[,] grid, string? defaultPng)
|
||
{
|
||
string png = a.Get("png", defaultPng ?? "");
|
||
if (!string.IsNullOrEmpty(png))
|
||
{
|
||
PlasmaBitmap.SaveGridPng(grid, png, a.GetInt("scale", 8));
|
||
Console.WriteLine($" png → {png}");
|
||
}
|
||
if (a.Get("bin", "") is { Length: > 0 } bin)
|
||
{
|
||
File.WriteAllBytes(bin, PlasmaBitmap.ToEscP(grid));
|
||
Console.WriteLine($" ESC P stream → {bin}");
|
||
}
|
||
if (a.Get("hex", "") is { Length: > 0 } hex)
|
||
{
|
||
File.WriteAllText(hex, PlasmaBitmap.ToHexEncoding(grid));
|
||
Console.WriteLine($" bitmap= encoding → {hex}");
|
||
}
|
||
}
|
||
|
||
// ---- diff: compare two frames / images against the golden ---------------
|
||
|
||
private static int Diff(Args a)
|
||
{
|
||
double thresh = a.GetDouble("threshold", 0.5);
|
||
bool[,] ga = LoadAsGrid(a.Require("a"), thresh);
|
||
bool[,] gb = LoadAsGrid(a.Require("b"), thresh);
|
||
int w = ga.GetLength(0), h = ga.GetLength(1);
|
||
if (gb.GetLength(0) != w || gb.GetLength(1) != h)
|
||
{
|
||
Console.Error.WriteLine($"Size mismatch: A is {w}×{h}, B is {gb.GetLength(0)}×{gb.GetLength(1)}.");
|
||
return 2;
|
||
}
|
||
|
||
int diff = 0, litA = 0, litB = 0;
|
||
for (int y = 0; y < h; y++)
|
||
for (int x = 0; x < w; x++)
|
||
{
|
||
if (ga[x, y]) litA++;
|
||
if (gb[x, y]) litB++;
|
||
if (ga[x, y] != gb[x, y]) diff++;
|
||
}
|
||
int total = w * h;
|
||
double matchPct = 100.0 * (total - diff) / total;
|
||
Console.WriteLine($"A lit={litA} B lit={litB} differing dots={diff}/{total} match={matchPct:F2}%");
|
||
|
||
string outPng = a.Get("out", "");
|
||
if (!string.IsNullOrEmpty(outPng))
|
||
{
|
||
int scale = a.GetInt("scale", 8);
|
||
int pitch = scale, dot = Math.Max(1, scale - 2);
|
||
using var bmp = new Bitmap(w * pitch, h * pitch, PixelFormat.Format24bppRgb);
|
||
using var g = Graphics.FromImage(bmp);
|
||
g.Clear(Color.FromArgb(20, 12, 6));
|
||
using var match = new SolidBrush(Color.FromArgb(255, 106, 26)); // both lit
|
||
using var off = new SolidBrush(Color.FromArgb(40, 22, 10)); // both off
|
||
using var miss = new SolidBrush(Color.FromArgb(255, 40, 40)); // differ
|
||
for (int y = 0; y < h; y++)
|
||
for (int x = 0; x < w; x++)
|
||
{
|
||
Brush br = ga[x, y] != gb[x, y] ? miss : (ga[x, y] ? match : off);
|
||
g.FillRectangle(br, x * pitch, y * pitch, dot, dot);
|
||
}
|
||
bmp.Save(outPng, ImageFormat.Png);
|
||
Console.WriteLine($"diff image (red = differing dots) → {outPng}");
|
||
}
|
||
return diff == 0 ? 0 : 1;
|
||
}
|
||
|
||
// Load a .bin (ESC P stream → vPLASMA frame), an image, or a bitmap= .txt
|
||
// encoding, all reduced to a 128×32 on/off grid.
|
||
private static bool[,] LoadAsGrid(string path, double threshold)
|
||
{
|
||
string ext = Path.GetExtension(path).ToLowerInvariant();
|
||
switch (ext)
|
||
{
|
||
case ".bin":
|
||
{
|
||
var device = new VPlasmaDevice();
|
||
byte[] data = File.ReadAllBytes(path);
|
||
device.OnReceived(data, data.Length);
|
||
var frame = new byte[VPlasmaDevice.Width * VPlasmaDevice.Height];
|
||
device.CopyFrame(frame);
|
||
return PlasmaBitmap.FrameToGrid(frame);
|
||
}
|
||
case ".txt":
|
||
return PlasmaBitmap.FromHexEncoding(File.ReadAllText(path));
|
||
default: // an image (png/bmp/jpg): threshold to 128×32
|
||
using (var img = new Bitmap(path))
|
||
return PlasmaBitmap.ToGrid(img, VPlasmaDevice.Width, VPlasmaDevice.Height, threshold);
|
||
}
|
||
}
|
||
|
||
private static (int, int) ParseSize(string s)
|
||
{
|
||
string[] p = s.ToLowerInvariant().Split('x');
|
||
if (p.Length == 2 && int.TryParse(p[0], out int w) && int.TryParse(p[1], out int h))
|
||
return (w, h);
|
||
throw new ArgumentException($"--size must look like 128x32, got '{s}'");
|
||
}
|
||
|
||
// ---- 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 double GetDouble(string k, double dflt) =>
|
||
_kv.TryGetValue(k, out var v) && double.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.
|
||
|
||
text --text "HELLO" [--font "Microsoft Sans Serif"] [--size 128x32]
|
||
[--png p.png] [--bin s.bin] [--hex b.txt]
|
||
Render text (auto-sized Windows font) to a plasma bitmap;
|
||
emit a preview PNG, an ESC P wire stream, and/or the
|
||
game's bitmap= hex encoding. (TeslaSuite Plasma Font Tool)
|
||
|
||
encode --in img.png [--size 128x32] [--threshold 0.5]
|
||
[--bin s.bin] [--hex b.txt] [--png p.png]
|
||
Convert an image to a plasma bitmap (ESC P / bitmap= hex).
|
||
|
||
decode --in b.txt [--png out.png] [--bin s.bin] [--scale 8]
|
||
Render a bitmap= encoding back to a PNG (and/or ESC P).
|
||
(TeslaSuite Plasma Bitmap Decoder)
|
||
|
||
diff --a A --b B [--out diff.png] [--threshold 0.5] [--scale 8]
|
||
Compare two frames/images/encodings (.bin / .png / .txt),
|
||
each reduced to 128×32; report differing dots and write a
|
||
diff image (red = mismatch). Exit 1 if they differ.
|
||
|
||
Differential test: `synth` a stream once, `render` the golden PNG,
|
||
`replay` it to the replica and the real panel, photograph each, and
|
||
`diff` the (cropped) photos against the golden.
|
||
""");
|
||
return 0;
|
||
}
|
||
}
|