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>
This commit is contained in:
@@ -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`).
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Plasma bitmap authoring — ported from TeslaSuite's <c>PlasmaBitmaps</c>
|
||||
/// (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 <c>bitmap=</c> hex encoding, and bridges either to an <c>ESC P</c>
|
||||
/// wire stream the display (or vPLASMA / the replica) can show.
|
||||
/// </summary>
|
||||
internal static class PlasmaBitmap
|
||||
{
|
||||
public const string DefaultFont = "Microsoft Sans Serif";
|
||||
|
||||
/// <summary>
|
||||
/// Render <paramref name="text"/> centered in a <paramref name="width"/>×
|
||||
/// <paramref name="height"/> 1bpp image, auto-shrinking the font to fit —
|
||||
/// the Plasma Font Tool's behavior.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Threshold an image to a <paramref name="w"/>×<paramref name="h"/> on/off grid.</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>The game's <c>bitmap=</c> hex encoding (MSB-first, 8 px/byte).</summary>
|
||||
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();
|
||||
}
|
||||
|
||||
/// <summary>Parse a <c>bitmap=</c>/<c>x=</c>/<c>y=</c> encoding back to a grid.</summary>
|
||||
public static bool[,] FromHexEncoding(string text)
|
||||
{
|
||||
var rows = new List<byte[]>();
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pack a grid into an <c>ESC P</c> wire stream (clear + cursor-off + one
|
||||
/// full-width row per line), the way L4PLASMA streams a frame.
|
||||
/// </summary>
|
||||
public static byte[] ToEscP(bool[,] grid)
|
||||
{
|
||||
int w = grid.GetLength(0), h = grid.GetLength(1);
|
||||
int wbytes = (w + 7) / 8;
|
||||
var b = new List<byte>
|
||||
{
|
||||
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();
|
||||
}
|
||||
|
||||
/// <summary>Render a grid to a plasma-orange PNG (for previews / decoding).</summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>Reduce a rendered vPLASMA frame (flag bytes) to an on/off grid.</summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user