Files
VRIO/tools/VPlasma.Wire/Program.cs
T
CydandClaude Opus 4.8 68e3d1f367 VPlasma.Wire: add diff + TeslaSuite plasma authoring tools
- 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>
2026-07-16 21:20:50 -05:00

468 lines
18 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 "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;
}
}