chore(cli): UI-debug apparatus — mock-selbar, dump-edges, crop, probe

Standalone AcDream.Cli subcommands built during the D.5.3a visual gate, kept as
reusable UI/sprite/framebuffer debugging apparatus (alongside the existing
export-ui-sprite / dump-sprite-sheet / render-vitals-mockup tools):
- mock-selbar: composite the selected-object health bar (back + fill at fractions)
- dump-edges: print a sprite's first/last column RGB at every row
- crop: crop + nearest-upscale a region of a PNG (zoom into a framebuffer dump)
- probe: print the RGB of a pixel block from a PNG

Dev-only (reached via explicit args[0]); no game-runtime impact.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-20 09:37:29 +02:00
parent 8f627cce0e
commit 07965852e0
2 changed files with 141 additions and 0 deletions

View file

@ -82,6 +82,45 @@ if (args.Length >= 1 && args[0] == "dump-font-atlas")
return FontAtlasDump.Run(dfaDir, dfaFont, dfaSample, dfaOut);
}
if (args.Length >= 1 && args[0] == "probe")
{
// probe <in.png> <x0> <y0> <x1> <y1>
if (args.Length < 6) { Console.Error.WriteLine("usage: AcDream.Cli probe <in.png> <x0> <y0> <x1> <y1>"); return 2; }
return VitalsMockup.Probe(args[1], int.Parse(args[2]), int.Parse(args[3]), int.Parse(args[4]), int.Parse(args[5]));
}
if (args.Length >= 1 && args[0] == "crop")
{
// crop <in.png> <x> <y> <w> <h> <zoom> <out.png>
if (args.Length < 8) { Console.Error.WriteLine("usage: AcDream.Cli crop <in.png> <x> <y> <w> <h> <zoom> <out.png>"); return 2; }
return VitalsMockup.Crop(args[1],
int.Parse(args[2]), int.Parse(args[3]), int.Parse(args[4]), int.Parse(args[5]), int.Parse(args[6]), args[7]);
}
if (args.Length >= 1 && args[0] == "dump-edges")
{
string? deDir = args.ElementAtOrDefault(1) ?? Env.GetEnvironmentVariable("ACDREAM_DAT_DIR");
string? deId = args.ElementAtOrDefault(2);
if (string.IsNullOrWhiteSpace(deDir) || string.IsNullOrWhiteSpace(deId))
{
Console.Error.WriteLine("usage: AcDream.Cli dump-edges <dat-directory> <0xId>");
return 2;
}
return VitalsMockup.DumpEdges(deDir, deId);
}
if (args.Length >= 1 && args[0] == "mock-selbar")
{
string? msbDir = args.ElementAtOrDefault(1) ?? Env.GetEnvironmentVariable("ACDREAM_DAT_DIR");
string msbOut = args.ElementAtOrDefault(2) ?? "selbar.png";
if (string.IsNullOrWhiteSpace(msbDir))
{
Console.Error.WriteLine("usage: AcDream.Cli mock-selbar <dat-directory> [out.png]");
return 2;
}
return VitalsMockup.MockSelBar(msbDir, msbOut);
}
if (args.Length >= 1 && args[0] == "export-ui-sprite")
{
string? eusDatDir = args.ElementAtOrDefault(1) ?? Env.GetEnvironmentVariable("ACDREAM_DAT_DIR");

View file

@ -192,6 +192,108 @@ public static class VitalsMockup
return 0;
}
/// <summary>
/// Composite the selected-object health bar (back-track 0x0600193E + red fill 0x0600193F)
/// the same way the in-game UiMeter draws it: the 146px sprite mapped 1:1 into the 140px
/// meter element (right 6px cropped), back drawn full, fill drawn over the left
/// fraction*width. Rendered at several health fractions stacked so the end-caps / purple
/// can be eyeballed offline (D.5.3a purple-end investigation).
/// </summary>
public static int MockSelBar(string datDir, string outPath)
{
if (!Directory.Exists(datDir)) { Console.Error.WriteLine($"error: dir not found: {datDir}"); return 2; }
using var dats = new DatCollection(datDir, DatAccessType.Read);
using var back = Load(dats, 0x0600193E);
using var fill = Load(dats, 0x0600193F);
const int elemW = 140, zoom = 8, gap = 4;
int elemH = Math.Min(back.Height, fill.Height);
float[] fracs = { 1.0f, 0.9f, 0.7f, 0.5f, 0.0f };
int rowH = elemH + gap;
using var canvas = new Image<Rgba32>(elemW, rowH * fracs.Length, new Rgba32(20, 20, 24, 255));
for (int i = 0; i < fracs.Length; i++)
{
int y = i * rowH;
float p = fracs[i];
int backCrop = Math.Min(elemW, back.Width);
using (var b = back.Clone(c => c.Crop(new Rectangle(0, 0, backCrop, elemH))))
canvas.Mutate(c => c.DrawImage(b, new Point(0, y), 1f));
int fillW = (int)MathF.Round(elemW * p);
if (fillW > 0)
{
int fillCrop = Math.Min(fillW, fill.Width);
using var f = fill.Clone(c => c.Crop(new Rectangle(0, 0, fillCrop, elemH)));
canvas.Mutate(c => c.DrawImage(f, new Point(0, y), 1f));
}
}
canvas.Mutate(c => c.Resize(canvas.Width * zoom, canvas.Height * zoom, KnownResamplers.NearestNeighbor));
canvas.SaveAsPng(outPath);
Console.WriteLine($"wrote {outPath} — selbar composite, rows = health 1.0 / 0.9 / 0.7 / 0.5 / 0.0");
return 0;
}
/// <summary>Print the RGB of a rectangular block of pixels from a PNG (framebuffer probe).</summary>
public static int Probe(string inPath, int x0, int y0, int x1, int y1)
{
if (!File.Exists(inPath)) { Console.Error.WriteLine($"not found: {inPath}"); return 2; }
using var img = Image.Load<Rgba32>(inPath);
x0 = Math.Clamp(x0, 0, img.Width - 1); x1 = Math.Clamp(x1, 0, img.Width - 1);
y0 = Math.Clamp(y0, 0, img.Height - 1); y1 = Math.Clamp(y1, 0, img.Height - 1);
Console.WriteLine($"{inPath} {img.Width}x{img.Height} cols x={x0}..{x1}");
for (int y = y0; y <= y1; y++)
{
var sb = new System.Text.StringBuilder($"y={y,4}: ");
for (int x = x0; x <= x1; x++) { var p = img[x, y]; sb.Append($"{p.R:X2}{p.G:X2}{p.B:X2} "); }
Console.WriteLine(sb.ToString());
}
return 0;
}
/// <summary>Crop a region of a PNG and upscale (nearest) — for zooming into a framebuffer dump.</summary>
public static int Crop(string inPath, int x, int y, int w, int h, int zoom, string outPath)
{
if (!File.Exists(inPath)) { Console.Error.WriteLine($"not found: {inPath}"); return 2; }
using var img = Image.Load<Rgba32>(inPath);
x = Math.Clamp(x, 0, img.Width - 1);
y = Math.Clamp(y, 0, img.Height - 1);
w = Math.Clamp(w, 1, img.Width - x);
h = Math.Clamp(h, 1, img.Height - y);
if (zoom < 1) zoom = 1;
img.Mutate(c => c.Crop(new Rectangle(x, y, w, h)).Resize(w * zoom, h * zoom, KnownResamplers.NearestNeighbor));
img.SaveAsPng(outPath);
Console.WriteLine($"wrote {outPath} ({w * zoom}x{h * zoom}) from {inPath} region ({x},{y},{w},{h})");
return 0;
}
/// <summary>Print the RGB of the first/last few columns of a sprite at every row, so the
/// end-cap colors can be inspected (D.5.3a purple-end investigation).</summary>
public static int DumpEdges(string datDir, string idText)
{
if (!Directory.Exists(datDir)) { Console.Error.WriteLine($"error: dir not found: {datDir}"); return 2; }
uint id = ParseHex(idText);
using var dats = new DatCollection(datDir, DatAccessType.Read);
var rs = dats.Get<RenderSurface>(id);
if (rs is null) { Console.Error.WriteLine($"no RenderSurface 0x{id:X8}"); return 1; }
var pal = rs.DefaultPaletteId != 0 ? dats.Get<Palette>(rs.DefaultPaletteId) : null;
var dec = SurfaceDecoder.DecodeRenderSurface(rs, pal);
Console.WriteLine($"0x{id:X8} {rs.Format} {dec.Width}x{dec.Height}");
int[] cols = { 0, 1, 2, 3, dec.Width - 4, dec.Width - 3, dec.Width - 2, dec.Width - 1 };
foreach (int cx in cols)
{
if (cx < 0 || cx >= dec.Width) continue;
var sb = new System.Text.StringBuilder();
for (int y = 0; y < dec.Height; y++)
{
int i = (y * dec.Width + cx) * 4;
sb.Append($"{dec.Rgba8[i]:X2}{dec.Rgba8[i + 1]:X2}{dec.Rgba8[i + 2]:X2} ");
}
Console.WriteLine($"x={cx,3}: {sb}");
}
return 0;
}
public static int ExportSprite(string datDir, string idText, string outPath)
{
if (!Directory.Exists(datDir)) { Console.Error.WriteLine($"error: dir not found: {datDir}"); return 2; }