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; /// /// 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. /// /// Commands: /// /// 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] /// /// 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(); 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 _kv = new(StringComparer.OrdinalIgnoreCase); private readonly HashSet _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; } }