feat(D.2b): draw the window resize-grip overlay (gold ridges + corner studs)

The retail vitals window border is TWO layers, not one: the bevel chrome
(0x060074BF-C6) PLUS a resize-grip overlay on top — gold ridged edge strips
and a square corner stud at each corner. acdream only drew the bevel, so the
border looked plainer than retail and the corners lacked the little square
sprite the user spotted.

The overlay ids come from the vitals LayoutDesc 0x2100006C (elements
0x1000063B-0x10000642): corner stud 0x06006129 (same 5x5 at all four corners),
edge strips 0x0600612A/2C (top/bottom) and 0x0600612B/2D (left/right). They
have transparent gaps so the bevel shows through — both layers are drawn.
UiNineSlicePanel now draws the grip overlay (edges tiled via the existing
UV-repeat, corner studs 1:1) after the bevel, so every retail-chrome window
(vitals + chat) gets it.

Verified the grip sprites + the composited result headlessly: dump-sprite-sheet
(new CLI: composite arbitrary sprite ids magnified) showed 0x06006129 is a gold
stud and 0x0600612A-D are gold ridged strips; render-vitals-mockup now renders
the faithful default window with the overlay.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-15 11:05:18 +02:00
parent 73468be02a
commit 0f55599ba5
4 changed files with 105 additions and 13 deletions

View file

@ -45,4 +45,22 @@ public static class RetailChromeSprites
/// <summary>Border thickness in pixels = the corner/edge sprite size (5px).</summary> /// <summary>Border thickness in pixels = the corner/edge sprite size (5px).</summary>
public const int Border = 5; 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 0x1000063B0x10000642): 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.
/// <summary>Corner grip stud, all four corners (5×5).</summary>
public const uint GripCorner = 0x06006129;
/// <summary>Top edge grip (10×5, tiled across).</summary>
public const uint GripTop = 0x0600612A;
/// <summary>Left edge grip (5×10, tiled down).</summary>
public const uint GripLeft = 0x0600612B;
/// <summary>Bottom edge grip (10×5).</summary>
public const uint GripBottom = 0x0600612C;
/// <summary>Right edge grip (5×10).</summary>
public const uint GripRight = 0x0600612D;
} }

View file

@ -72,6 +72,19 @@ public sealed class UiNineSlicePanel : UiPanel
DrawStretched(ctx, RetailChromeSprites.CornerTR, r.TR); DrawStretched(ctx, RetailChromeSprites.CornerTR, r.TR);
DrawStretched(ctx, RetailChromeSprites.CornerBL, r.BL); DrawStretched(ctx, RetailChromeSprites.CornerBL, r.BL);
DrawStretched(ctx, RetailChromeSprites.CornerBR, r.BR); 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
// (0x1000063B0x10000642). 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) private void DrawTiled(UiRenderContext ctx, uint id, Rect d)

View file

@ -43,6 +43,19 @@ if (args.Length >= 1 && args[0] == "render-vitals-mockup")
return VitalsMockup.Render(rvmDatDir, rvmOut); 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 <dat-directory> <0xId,0xId,...> [out.png]");
return 2;
}
return VitalsMockup.ExportSheet(dssDir, dssIds, dssOut);
}
if (args.Length >= 1 && args[0] == "export-ui-sprite") if (args.Length >= 1 && args[0] == "export-ui-sprite")
{ {
string? eusDatDir = args.ElementAtOrDefault(1) ?? Env.GetEnvironmentVariable("ACDREAM_DAT_DIR"); string? eusDatDir = args.ElementAtOrDefault(1) ?? Env.GetEnvironmentVariable("ACDREAM_DAT_DIR");

View file

@ -29,14 +29,14 @@ public static class VitalsMockup
private static readonly Vital[] Vitals = private static readonly Vital[] Vitals =
{ {
new("health", 1.00f, 0x0600747E, 0x0600747F, 0x06007480, 0x06007481, 0x06007482, 0x06007483), new("health", 0.80f, 0x0600747E, 0x0600747F, 0x06007480, 0x06007481, 0x06007482, 0x06007483),
new("stamina", 1.00f, 0x06007484, 0x06007485, 0x06007486, 0x06007487, 0x06007488, 0x06007489), new("stamina", 0.50f, 0x06007484, 0x06007485, 0x06007486, 0x06007487, 0x06007488, 0x06007489),
new("mana", 1.00f, 0x0600748A, 0x0600748B, 0x0600748C, 0x0600748D, 0x0600748E, 0x0600748F), new("mana", 0.65f, 0x0600748A, 0x0600748B, 0x0600748C, 0x0600748D, 0x0600748E, 0x0600748F),
}; };
private const int Border = 5, BarH = 16, Zoom = 4; private const uint CenterFill = 0x06004CC2; // dark interior panel (UiNineSlicePanel draws this)
// Widened bars so stretch-vs-tile is obvious (native middle tile ~100px). private const int Border = 5, BarH = 16, Zoom = 6;
private const int BarW = 280; private const int BarW = 150; // default vitals window bar width (0x2100006C)
private static readonly int[] BarLocalY = { 0, 16, 32 }; // flush stacked inside the interior private static readonly int[] BarLocalY = { 0, 16, 32 }; // flush stacked inside the interior
public static int Render(string datDir, string outPath) 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; } if (!Directory.Exists(datDir)) { Console.Error.WriteLine($"error: dir not found: {datDir}"); return 2; }
using var dats = new DatCollection(datDir, DatAccessType.Read); 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 winH = 3 * BarH + 2 * Border; // 58
int gap = 16; using var canvas = new Image<Rgba32>(winW, winH, new Rgba32(20, 20, 24, 255));
using var canvas = new Image<Rgba32>(winW, winH * 2 + gap, new Rgba32(20, 20, 24, 255));
DrawWindow(canvas, dats, 0, winW, winH, tileMid: false); // top: STRETCH (current) DrawWindow(canvas, dats, 0, winW, winH, tileMid: true);
DrawWindow(canvas, dats, winH + gap, winW, winH, tileMid: true); // bottom: TILE (retail)
canvas.Mutate(c => c.Resize(canvas.Width * Zoom, canvas.Height * Zoom, KnownResamplers.NearestNeighbor)); canvas.Mutate(c => c.Resize(canvas.Width * Zoom, canvas.Height * Zoom, KnownResamplers.NearestNeighbor));
canvas.SaveAsPng(outPath); 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; return 0;
} }
private static void DrawWindow(Image<Rgba32> canvas, DatCollection dats, int offY, int winW, int winH, bool tileMid) private static void DrawWindow(Image<Rgba32> 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 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 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)) 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); 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 0x1000063B0x10000642). 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++) for (int i = 0; i < Vitals.Length; i++)
{ {
var v = Vitals[i]; var v = Vitals[i];
@ -144,6 +163,35 @@ public static class VitalsMockup
canvas.Mutate(c => c.DrawImage(s, new Point(x, y), 1f)); canvas.Mutate(c => c.DrawImage(s, new Point(x, y), 1f));
} }
/// <summary>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.</summary>
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<Rgba32>(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) public static int ExportSprite(string datDir, string idText, string outPath)
{ {
if (!Directory.Exists(datDir)) { Console.Error.WriteLine($"error: dir not found: {datDir}"); return 2; } if (!Directory.Exists(datDir)) { Console.Error.WriteLine($"error: dir not found: {datDir}"); return 2; }