diff --git a/src/AcDream.App/UI/RetailChromeSprites.cs b/src/AcDream.App/UI/RetailChromeSprites.cs
index 70a8cb4e..f2a80fd7 100644
--- a/src/AcDream.App/UI/RetailChromeSprites.cs
+++ b/src/AcDream.App/UI/RetailChromeSprites.cs
@@ -45,4 +45,22 @@ public static class RetailChromeSprites
/// Border thickness in pixels = the corner/edge sprite size (5px).
public const int Border = 5;
+
+ // ── Resize-grip overlay ──────────────────────────────────────────────
+ // A second 8-piece layer drawn ON TOP of the bevel above: the gold ridged
+ // accents + square corner studs that frame a resizable retail window. From
+ // the vitals LayoutDesc 0x2100006C (elements 0x1000063B–0x10000642): each
+ // corner is the same 5×5 stud (0x06006129); the edges are gold double-line
+ // strips tiled along each side. These have transparent gaps, so the bevel
+ // shows through — both layers are needed.
+ /// Corner grip stud, all four corners (5×5).
+ public const uint GripCorner = 0x06006129;
+ /// Top edge grip (10×5, tiled across).
+ public const uint GripTop = 0x0600612A;
+ /// Left edge grip (5×10, tiled down).
+ public const uint GripLeft = 0x0600612B;
+ /// Bottom edge grip (10×5).
+ public const uint GripBottom = 0x0600612C;
+ /// Right edge grip (5×10).
+ public const uint GripRight = 0x0600612D;
}
diff --git a/src/AcDream.App/UI/UiNineSlicePanel.cs b/src/AcDream.App/UI/UiNineSlicePanel.cs
index 2e4465a1..9c18f095 100644
--- a/src/AcDream.App/UI/UiNineSlicePanel.cs
+++ b/src/AcDream.App/UI/UiNineSlicePanel.cs
@@ -72,6 +72,19 @@ public sealed class UiNineSlicePanel : UiPanel
DrawStretched(ctx, RetailChromeSprites.CornerTR, r.TR);
DrawStretched(ctx, RetailChromeSprites.CornerBL, r.BL);
DrawStretched(ctx, RetailChromeSprites.CornerBR, r.BR);
+
+ // Resize-grip overlay (gold ridged edges + square corner studs) drawn on
+ // top of the bevel — the second border layer the vitals LayoutDesc carries
+ // (0x1000063B–0x10000642). Edges tile; the corner stud is the same sprite
+ // at all four corners.
+ DrawTiled(ctx, RetailChromeSprites.GripTop, r.Top);
+ DrawTiled(ctx, RetailChromeSprites.GripBottom, r.Bottom);
+ DrawTiled(ctx, RetailChromeSprites.GripLeft, r.Left);
+ DrawTiled(ctx, RetailChromeSprites.GripRight, r.Right);
+ DrawStretched(ctx, RetailChromeSprites.GripCorner, r.TL);
+ DrawStretched(ctx, RetailChromeSprites.GripCorner, r.TR);
+ DrawStretched(ctx, RetailChromeSprites.GripCorner, r.BL);
+ DrawStretched(ctx, RetailChromeSprites.GripCorner, r.BR);
}
private void DrawTiled(UiRenderContext ctx, uint id, Rect d)
diff --git a/src/AcDream.Cli/Program.cs b/src/AcDream.Cli/Program.cs
index 0fdad988..6be503c4 100644
--- a/src/AcDream.Cli/Program.cs
+++ b/src/AcDream.Cli/Program.cs
@@ -43,6 +43,19 @@ if (args.Length >= 1 && args[0] == "render-vitals-mockup")
return VitalsMockup.Render(rvmDatDir, rvmOut);
}
+if (args.Length >= 1 && args[0] == "dump-sprite-sheet")
+{
+ string? dssDir = args.ElementAtOrDefault(1) ?? Env.GetEnvironmentVariable("ACDREAM_DAT_DIR");
+ string? dssIds = args.ElementAtOrDefault(2);
+ string dssOut = args.ElementAtOrDefault(3) ?? "sprite-sheet.png";
+ if (string.IsNullOrWhiteSpace(dssDir) || string.IsNullOrWhiteSpace(dssIds))
+ {
+ Console.Error.WriteLine("usage: AcDream.Cli dump-sprite-sheet <0xId,0xId,...> [out.png]");
+ return 2;
+ }
+ return VitalsMockup.ExportSheet(dssDir, dssIds, dssOut);
+}
+
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 90c222f4..445a918b 100644
--- a/src/AcDream.Cli/VitalsMockup.cs
+++ b/src/AcDream.Cli/VitalsMockup.cs
@@ -29,14 +29,14 @@ public static class VitalsMockup
private static readonly Vital[] Vitals =
{
- new("health", 1.00f, 0x0600747E, 0x0600747F, 0x06007480, 0x06007481, 0x06007482, 0x06007483),
- new("stamina", 1.00f, 0x06007484, 0x06007485, 0x06007486, 0x06007487, 0x06007488, 0x06007489),
- new("mana", 1.00f, 0x0600748A, 0x0600748B, 0x0600748C, 0x0600748D, 0x0600748E, 0x0600748F),
+ new("health", 0.80f, 0x0600747E, 0x0600747F, 0x06007480, 0x06007481, 0x06007482, 0x06007483),
+ new("stamina", 0.50f, 0x06007484, 0x06007485, 0x06007486, 0x06007487, 0x06007488, 0x06007489),
+ new("mana", 0.65f, 0x0600748A, 0x0600748B, 0x0600748C, 0x0600748D, 0x0600748E, 0x0600748F),
};
- private const int Border = 5, BarH = 16, Zoom = 4;
- // Widened bars so stretch-vs-tile is obvious (native middle tile ~100px).
- private const int BarW = 280;
+ private const uint CenterFill = 0x06004CC2; // dark interior panel (UiNineSlicePanel draws this)
+ private const int Border = 5, BarH = 16, Zoom = 6;
+ private const int BarW = 150; // default vitals window bar width (0x2100006C)
private static readonly int[] BarLocalY = { 0, 16, 32 }; // flush stacked inside the interior
public static int Render(string datDir, string outPath)
@@ -44,23 +44,25 @@ public static class VitalsMockup
if (!Directory.Exists(datDir)) { Console.Error.WriteLine($"error: dir not found: {datDir}"); return 2; }
using var dats = new DatCollection(datDir, DatAccessType.Read);
- int winW = BarW + 2 * Border; // 290
+ int winW = BarW + 2 * Border; // 160
int winH = 3 * BarH + 2 * Border; // 58
- int gap = 16;
- using var canvas = new Image(winW, winH * 2 + gap, new Rgba32(20, 20, 24, 255));
+ using var canvas = new Image(winW, winH, new Rgba32(20, 20, 24, 255));
- DrawWindow(canvas, dats, 0, winW, winH, tileMid: false); // top: STRETCH (current)
- DrawWindow(canvas, dats, winH + gap, winW, winH, tileMid: true); // bottom: TILE (retail)
+ DrawWindow(canvas, dats, 0, winW, winH, tileMid: true);
canvas.Mutate(c => c.Resize(canvas.Width * Zoom, canvas.Height * Zoom, KnownResamplers.NearestNeighbor));
canvas.SaveAsPng(outPath);
- Console.WriteLine($"wrote {outPath} ({canvas.Width}x{canvas.Height}) — TOP=stretch(current) BOTTOM=tile(retail), widened {BarW}px bars");
+ Console.WriteLine($"wrote {outPath} ({canvas.Width}x{canvas.Height}) — faithful default vitals window 0x2100006C");
return 0;
}
private static void DrawWindow(Image canvas, DatCollection dats, int offY, int winW, int winH, bool tileMid)
{
- // 8-piece chrome border (kept identical in both rows; only the bar fill varies).
+ // Dark interior fill (matches UiNineSlicePanel's CenterFill behind the bars).
+ using (var cf = Load(dats, CenterFill))
+ Blit(canvas, cf, Border, offY + Border, winW - 2 * Border, winH - 2 * Border);
+
+ // 8-piece chrome border (corners native 5x5, edges stretched for this preview).
using (var tl = Load(dats, TL)) using (var top = Load(dats, TOP)) using (var tr = Load(dats, TR))
using (var le = Load(dats, LEFT)) using (var ri = Load(dats, RIGHT))
using (var bl = Load(dats, BL)) using (var bo = Load(dats, BOT)) using (var br = Load(dats, BR))
@@ -75,6 +77,23 @@ public static class VitalsMockup
Blit(canvas, br, winW - Border, offY + winH - Border, Border, Border);
}
+ // Resize-grip overlay: gold ridged edge strips + square corner studs, on
+ // top of the bevel (vitals LayoutDesc 0x1000063B–0x10000642). Edges shown
+ // stretched here for the preview; the client tiles them via UV-repeat.
+ using (var gc = Load(dats, 0x06006129))
+ using (var gt = Load(dats, 0x0600612A)) using (var gb = Load(dats, 0x0600612C))
+ using (var gl = Load(dats, 0x0600612B)) using (var gr = Load(dats, 0x0600612D))
+ {
+ Blit(canvas, gt, Border, offY, winW - 2 * Border, Border);
+ Blit(canvas, gb, Border, offY + winH - Border, winW - 2 * Border, Border);
+ Blit(canvas, gl, 0, offY + Border, Border, winH - 2 * Border);
+ Blit(canvas, gr, winW - Border, offY + Border, Border, winH - 2 * Border);
+ Blit(canvas, gc, 0, offY, Border, Border);
+ Blit(canvas, gc, winW - Border, offY, Border, Border);
+ Blit(canvas, gc, 0, offY + winH - Border, Border, Border);
+ Blit(canvas, gc, winW - Border, offY + winH - Border, Border, Border);
+ }
+
for (int i = 0; i < Vitals.Length; i++)
{
var v = Vitals[i];
@@ -144,6 +163,35 @@ public static class VitalsMockup
canvas.Mutate(c => c.DrawImage(s, new Point(x, y), 1f));
}
+ /// Composite a comma-separated list of sprite ids into one row, magnified,
+ /// on a neutral background — so the exact chrome/bar graphics can be eyeballed.
+ public static int ExportSheet(string datDir, string idsCsv, string outPath)
+ {
+ if (!Directory.Exists(datDir)) { Console.Error.WriteLine($"error: dir not found: {datDir}"); return 2; }
+ using var dats = new DatCollection(datDir, DatAccessType.Read);
+
+ var ids = idsCsv.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
+ .Select(ParseHex).Where(x => x != 0).ToArray();
+ if (ids.Length == 0) { Console.Error.WriteLine("no valid ids"); return 2; }
+
+ var imgs = ids.Select(id => Load(dats, id)).ToArray();
+ const int pad = 6, zoom = 10;
+ int totalW = pad + imgs.Sum(i => i.Width + pad);
+ int maxH = imgs.Max(i => i.Height);
+ using var canvas = new Image(totalW, maxH + 2 * pad, new Rgba32(64, 64, 72, 255));
+ int x = pad;
+ foreach (var im in imgs)
+ {
+ canvas.Mutate(c => c.DrawImage(im, new Point(x, pad), 1f));
+ x += im.Width + pad;
+ }
+ canvas.Mutate(c => c.Resize(canvas.Width * zoom, canvas.Height * zoom, KnownResamplers.NearestNeighbor));
+ canvas.SaveAsPng(outPath);
+ Console.WriteLine("order (L→R): " + string.Join(" ", ids.Zip(imgs, (id, im) => $"0x{id:X8}={im.Width}x{im.Height}")));
+ foreach (var im in imgs) im.Dispose();
+ 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; }