From 07965852e0043c218fbbb6b4bbb972dbd6e831a4 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 20 Jun 2026 09:37:29 +0200 Subject: [PATCH] =?UTF-8?q?chore(cli):=20UI-debug=20apparatus=20=E2=80=94?= =?UTF-8?q?=20mock-selbar,=20dump-edges,=20crop,=20probe?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/AcDream.Cli/Program.cs | 39 ++++++++++++ src/AcDream.Cli/VitalsMockup.cs | 102 ++++++++++++++++++++++++++++++++ 2 files changed, 141 insertions(+) diff --git a/src/AcDream.Cli/Program.cs b/src/AcDream.Cli/Program.cs index 5e0e03be..4bb7cba8 100644 --- a/src/AcDream.Cli/Program.cs +++ b/src/AcDream.Cli/Program.cs @@ -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 + if (args.Length < 6) { Console.Error.WriteLine("usage: AcDream.Cli probe "); 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 + if (args.Length < 8) { Console.Error.WriteLine("usage: AcDream.Cli crop "); 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 <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 [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"); diff --git a/src/AcDream.Cli/VitalsMockup.cs b/src/AcDream.Cli/VitalsMockup.cs index 445a918b..312c97d1 100644 --- a/src/AcDream.Cli/VitalsMockup.cs +++ b/src/AcDream.Cli/VitalsMockup.cs @@ -192,6 +192,108 @@ public static class VitalsMockup return 0; } + /// + /// 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). + /// + 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(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; + } + + /// Print the RGB of a rectangular block of pixels from a PNG (framebuffer probe). + 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(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; + } + + /// Crop a region of a PNG and upscale (nearest) — for zooming into a framebuffer dump. + 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(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; + } + + /// 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). + 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(id); + if (rs is null) { Console.Error.WriteLine($"no RenderSurface 0x{id:X8}"); return 1; } + var pal = rs.DefaultPaletteId != 0 ? dats.Get(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; }