From 68e3d1f367f7f9337491f5d428bdd8f7556f53f8 Mon Sep 17 00:00:00 2001 From: Cyd Date: Thu, 16 Jul 2026 21:20:50 -0500 Subject: [PATCH] VPlasma.Wire: add diff + TeslaSuite plasma authoring tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- PlasmaNew/replica/README.md | 25 ++++ tools/VPlasma.Wire/PlasmaBitmap.cs | 182 +++++++++++++++++++++++++++++ tools/VPlasma.Wire/Program.cs | 182 ++++++++++++++++++++++++++++- 3 files changed, 387 insertions(+), 2 deletions(-) create mode 100644 tools/VPlasma.Wire/PlasmaBitmap.cs diff --git a/PlasmaNew/replica/README.md b/PlasmaNew/replica/README.md index e0f3173..928c317 100644 --- a/PlasmaNew/replica/README.md +++ b/PlasmaNew/replica/README.md @@ -115,6 +115,31 @@ VPlasma.Wire capture --port COM12 --out session.bin --tee COM3 Then `render`/`replay` `session.bin` like any other stream. +**Auto-compare** a photo (cropped to the glass) against the golden image, or +two streams against each other — each is reduced to 128×32 and diffed: + +```sh +VPlasma.Wire diff --a demo-golden.png --b panel-photo.png --out delta.png +# prints "differing dots=N/4096 match=XX%", writes a red-on-mismatch image, +# exits 1 if they differ. Inputs may be .bin (ESC P) / .png / .txt (bitmap=). +``` + +### Authoring plasma content (from TeslaSuite's plasma tools) + +Generate display content from text or images and push it over the wire: + +```sh +# Text in a Windows font, auto-sized to the panel → preview PNG + ESC P + hex: +VPlasma.Wire text --text "ALERT 12" --font "Arial" --png a.png --bin a.bin --hex a.txt +VPlasma.Wire replay --in a.bin --port COM7 # show it on the replica + +# An image → the ESC P wire stream (and/or the game's bitmap= encoding): +VPlasma.Wire encode --in logo.png --bin logo.bin --hex logo.txt + +# A bitmap= encoding → a preview PNG (and/or ESC P): +VPlasma.Wire decode --in logo.txt --png logo.png +``` + 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 vector-graphics primitives (`ESC A`–`F`). diff --git a/tools/VPlasma.Wire/PlasmaBitmap.cs b/tools/VPlasma.Wire/PlasmaBitmap.cs new file mode 100644 index 0000000..57ff0e9 --- /dev/null +++ b/tools/VPlasma.Wire/PlasmaBitmap.cs @@ -0,0 +1,182 @@ +using System.Drawing; +using System.Drawing.Drawing2D; +using System.Drawing.Imaging; +using System.Drawing.Text; +using System.Text; +using VPlasma.Core.Device; +using VPlasma.Core.Protocol; + +namespace VPlasma.Wire; + +/// +/// Plasma bitmap authoring — ported from TeslaSuite's PlasmaBitmaps +/// (the console's Plasma Font Tool / Bitmap Decoder / Editor). Renders text in +/// a Windows font to a 1bpp panel bitmap, converts bitmaps to and from the +/// game's bitmap= hex encoding, and bridges either to an ESC P +/// wire stream the display (or vPLASMA / the replica) can show. +/// +internal static class PlasmaBitmap +{ + public const string DefaultFont = "Microsoft Sans Serif"; + + /// + /// Render centered in a × + /// 1bpp image, auto-shrinking the font to fit — + /// the Plasma Font Tool's behavior. + /// + public static Bitmap RenderText(int width, int height, string fontName, string text) + { + var bmp = new Bitmap(width, height, PixelFormat.Format24bppRgb); + using var g = Graphics.FromImage(bmp); + g.SmoothingMode = SmoothingMode.None; + g.InterpolationMode = InterpolationMode.NearestNeighbor; + g.TextRenderingHint = TextRenderingHint.SingleBitPerPixelGridFit; + g.Clear(Color.Black); + + var rect = new Rectangle(0, 0, width, height); + using var fmt = new StringFormat { Alignment = StringAlignment.Center, LineAlignment = StringAlignment.Center }; + for (int size = 24; ; size = size <= 2 ? size / 2 : size - 2) + { + using var font = new Font(fontName, Math.Max(1, size)); + SizeF sz = g.MeasureString(text, font); + if (size <= 1 || (sz.Width <= width && sz.Height <= height)) + { + g.DrawString(text, font, Brushes.White, rect, fmt); + return bmp; + } + } + } + + /// Threshold an image to a × on/off grid. + public static bool[,] ToGrid(Bitmap src, int w, int h, double threshold) + { + using var scaled = new Bitmap(w, h, PixelFormat.Format24bppRgb); + using (var g = Graphics.FromImage(scaled)) + { + g.InterpolationMode = src.Width == w && src.Height == h + ? InterpolationMode.NearestNeighbor : InterpolationMode.HighQualityBicubic; + g.DrawImage(src, 0, 0, w, h); + } + var grid = new bool[w, h]; + for (int y = 0; y < h; y++) + for (int x = 0; x < w; x++) + grid[x, y] = scaled.GetPixel(x, y).GetBrightness() >= threshold; + return grid; + } + + /// The game's bitmap= hex encoding (MSB-first, 8 px/byte). + public static string ToHexEncoding(bool[,] grid) + { + int w = grid.GetLength(0), h = grid.GetLength(1); + if (w % 8 != 0) + throw new ArgumentException("width must be a multiple of 8"); + var sb = new StringBuilder(); + for (int y = 0; y < h; y++) + { + sb.Append("bitmap="); + for (int j = 0; j < w / 8; j++) + { + byte b = 0; + for (int k = 0; k < 8; k++) + if (grid[j * 8 + k, y]) b |= (byte)(0x80 >> k); + sb.Append(b.ToString("X2")); + } + sb.Append('\n'); + } + sb.Append($"x={w}\n"); + sb.Append($"y={h}\n"); + return sb.ToString(); + } + + /// Parse a bitmap=/x=/y= encoding back to a grid. + public static bool[,] FromHexEncoding(string text) + { + var rows = new List(); + int declaredX = 0, declaredY = 0; + foreach (string raw in text.Split('\n')) + { + string line = raw.Trim(); + if (line.StartsWith("bitmap=", StringComparison.OrdinalIgnoreCase)) + { + string hex = line.Substring("bitmap=".Length).Trim(); + var bytes = new byte[hex.Length / 2]; + for (int i = 0; i < bytes.Length; i++) + bytes[i] = Convert.ToByte(hex.Substring(i * 2, 2), 16); + rows.Add(bytes); + } + else if (line.StartsWith("x=", StringComparison.OrdinalIgnoreCase)) + int.TryParse(line.Substring(2), out declaredX); + else if (line.StartsWith("y=", StringComparison.OrdinalIgnoreCase)) + int.TryParse(line.Substring(2), out declaredY); + } + if (rows.Count == 0) + throw new ArgumentException("no 'bitmap=' rows found"); + int w = declaredX > 0 ? declaredX : rows[0].Length * 8; + int h = declaredY > 0 ? declaredY : rows.Count; + var grid = new bool[w, h]; + for (int y = 0; y < h && y < rows.Count; y++) + for (int j = 0; j < rows[y].Length && j * 8 < w; j++) + for (int k = 0; k < 8 && j * 8 + k < w; k++) + grid[j * 8 + k, y] = (rows[y][j] & (0x80 >> k)) != 0; + return grid; + } + + /// + /// Pack a grid into an ESC P wire stream (clear + cursor-off + one + /// full-width row per line), the way L4PLASMA streams a frame. + /// + public static byte[] ToEscP(bool[,] grid) + { + int w = grid.GetLength(0), h = grid.GetLength(1); + int wbytes = (w + 7) / 8; + var b = new List + { + PlasmaProtocol.Esc, PlasmaProtocol.CmdCursorMode, 0x00, // hide cursor + PlasmaProtocol.Esc, PlasmaProtocol.CmdClearScreen, + }; + for (int y = 0; y < h; y++) + { + b.Add(PlasmaProtocol.Esc); + b.Add(PlasmaProtocol.CmdGraphicsWrite); + b.Add(0); // screen + b.Add((byte)y); // y + b.Add(0); // x byte + b.Add((byte)wbytes); // width in bytes + b.Add(1); // one row + for (int j = 0; j < wbytes; j++) + { + byte v = 0; + for (int k = 0; k < 8; k++) + if (j * 8 + k < w && grid[j * 8 + k, y]) v |= (byte)(0x80 >> k); + b.Add(v); + } + } + return b.ToArray(); + } + + /// Render a grid to a plasma-orange PNG (for previews / decoding). + public static void SaveGridPng(bool[,] grid, string path, int scale) + { + int w = grid.GetLength(0), h = grid.GetLength(1); + 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(26, 14, 6)); + using var unlit = new SolidBrush(Color.FromArgb(46, 24, 10)); + using var lit = new SolidBrush(Color.FromArgb(255, 106, 26)); + for (int y = 0; y < h; y++) + for (int x = 0; x < w; x++) + g.FillRectangle(grid[x, y] ? lit : unlit, x * pitch, y * pitch, dot, dot); + bmp.Save(path, ImageFormat.Png); + } + + /// Reduce a rendered vPLASMA frame (flag bytes) to an on/off grid. + public static bool[,] FrameToGrid(byte[] frame) + { + var grid = new bool[VPlasmaDevice.Width, VPlasmaDevice.Height]; + for (int y = 0; y < VPlasmaDevice.Height; y++) + for (int x = 0; x < VPlasmaDevice.Width; x++) + grid[x, y] = (frame[y * VPlasmaDevice.Width + x] & VPlasmaDevice.PixelLit) != 0; + return grid; + } +} diff --git a/tools/VPlasma.Wire/Program.cs b/tools/VPlasma.Wire/Program.cs index a857b5b..ffaa1d0 100644 --- a/tools/VPlasma.Wire/Program.cs +++ b/tools/VPlasma.Wire/Program.cs @@ -38,6 +38,10 @@ internal static class Program 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]}'."); @@ -233,6 +237,159 @@ internal static class Program 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 @@ -256,6 +413,8 @@ internal static class Program 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"); } @@ -280,9 +439,28 @@ internal static class Program 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` the same stream to the replica and to the real panel, and - compare the three. + `replay` it to the replica and the real panel, photograph each, and + `diff` the (cropped) photos against the golden. """); return 0; }