diff --git a/src/AcDream.App/UI/UiMeter.cs b/src/AcDream.App/UI/UiMeter.cs
index de97aff4..5baec4a7 100644
--- a/src/AcDream.App/UI/UiMeter.cs
+++ b/src/AcDream.App/UI/UiMeter.cs
@@ -66,11 +66,14 @@ public sealed class UiMeter : UiElement
if (SpriteResolve is { } resolve && (BackLeft != 0 || BackTile != 0 || FrontTile != 0))
{
- // Empty track: full-width 3-slice (left-cap + stretched gradient + right-cap).
- DrawHBar(ctx, resolve, BackLeft, BackTile, BackRight, 0, 0, Width, Height, withRightCap: true);
- // Coloured fill: grows from the left to the value, no right-cap of its own.
+ // Retail meter (UIElement_Meter::DrawChildren): the BACK 3-slice is the
+ // empty track, drawn full width; the FRONT 3-slice is the coloured fill,
+ // drawn at FULL width too but horizontally CLIPPED to the fill fraction.
+ // The front carries its own right-cap (shown at 100%); clipping below 100%
+ // removes it and reveals the back track's right-cap — retail's scissor-fill.
+ DrawHBar(ctx, resolve, BackLeft, BackTile, BackRight, Width);
if (pct is not null && p > 0f)
- DrawHBar(ctx, resolve, FrontLeft, FrontTile, FrontRight, 0, 0, Width * p, Height, withRightCap: false);
+ DrawHBar(ctx, resolve, FrontLeft, FrontTile, FrontRight, Width * p);
}
else
{
@@ -94,33 +97,43 @@ public sealed class UiMeter : UiElement
}
///
- /// Draws a horizontal 3-slice into x at
- /// (,): a native-width left-cap, a stretched
- /// middle, and (when ) a native-width right-cap. Caps
- /// are clamped so a narrow bar never overdraws. A 0 id skips that slice.
+ /// Draws the full-width horizontal 3-slice (native-width left-cap, stretched
+ /// middle, native-width right-cap) over this meter's rect, horizontally CLIPPED
+ /// so nothing past (local px from the left) is drawn.
+ /// The back track passes clipW = Width; the front fill passes
+ /// clipW = Width * fraction. Clipping UV-crops each slice proportionally,
+ /// so the fill ends cleanly and the back's right-cap shows through when partial.
+ /// A 0 id skips that slice.
///
- private static void DrawHBar(
+ private void DrawHBar(
UiRenderContext ctx, Func resolve,
- uint leftId, uint tileId, uint rightId,
- float x, float y, float w, float h, bool withRightCap)
+ uint leftId, uint midId, uint rightId, float clipW)
{
- if (w <= 0f) return;
+ if (clipW <= 0f) return;
+ float w = Width, h = Height;
var (lt, lw, _) = resolve(leftId);
- var (tt, _, _) = resolve(tileId);
+ var (mt, _, _) = resolve(midId);
var (rt, rw, _) = resolve(rightId);
- float rcap = withRightCap && rt != 0 ? MathF.Min(rw, w) : 0f;
- float lcap = lt != 0 ? MathF.Min(lw, w - rcap) : 0f;
+ float capL = lt != 0 ? MathF.Min(lw, w) : 0f;
+ float capR = rt != 0 ? MathF.Min(rw, w - capL) : 0f;
+ float midW = w - capL - capR;
- if (lt != 0 && lcap > 0f)
- ctx.DrawSprite(lt, x, y, lcap, h, 0, 0, 1, 1, Vector4.One);
+ DrawPiece(ctx, lt, 0f, capL, h, clipW);
+ DrawPiece(ctx, mt, capL, midW, h, clipW);
+ DrawPiece(ctx, rt, w - capR, capR, h, clipW);
+ }
- float midX = x + lcap;
- float midW = w - lcap - rcap;
- if (tt != 0 && midW > 0f)
- ctx.DrawSprite(tt, midX, y, midW, h, 0, 0, 1, 1, Vector4.One);
-
- if (rcap > 0f)
- ctx.DrawSprite(rt, x + w - rcap, y, rcap, h, 0, 0, 1, 1, Vector4.One);
+ /// Draw one slice spanning local [,
+ /// pieceX+], UV-cropped so nothing past
+ /// shows.
+ private static void DrawPiece(
+ UiRenderContext ctx, uint tex, float pieceX, float pieceW, float h, float clipW)
+ {
+ if (tex == 0 || pieceW <= 0f) return;
+ float visibleW = MathF.Min(pieceW, clipW - pieceX);
+ if (visibleW <= 0f) return;
+ float u1 = visibleW / pieceW; // crop the texture horizontally
+ ctx.DrawSprite(tex, pieceX, 0f, visibleW, h, 0f, 0f, u1, 1f, Vector4.One);
}
}
diff --git a/src/AcDream.App/UI/assets/vitals.xml b/src/AcDream.App/UI/assets/vitals.xml
index ca7e665f..eb8dfcbd 100644
--- a/src/AcDream.App/UI/assets/vitals.xml
+++ b/src/AcDream.App/UI/assets/vitals.xml
@@ -1,8 +1,13 @@
-
-
-
-
+
+
+
+
+
diff --git a/src/AcDream.Cli/Program.cs b/src/AcDream.Cli/Program.cs
index 1eef5eb1..0fdad988 100644
--- a/src/AcDream.Cli/Program.cs
+++ b/src/AcDream.Cli/Program.cs
@@ -19,6 +19,18 @@ if (args.Length >= 1 && args[0] == "dump-vitals-bars")
return DumpVitalsBars(dvbDatDir);
}
+if (args.Length >= 1 && args[0] == "dump-vitals-layout")
+{
+ string? dvlDatDir = args.ElementAtOrDefault(1) ?? Env.GetEnvironmentVariable("ACDREAM_DAT_DIR");
+ string? dvlLayout = args.ElementAtOrDefault(2);
+ if (string.IsNullOrWhiteSpace(dvlDatDir))
+ {
+ Console.Error.WriteLine("usage: AcDream.Cli dump-vitals-layout [0xLayoutId]");
+ return 2;
+ }
+ return VitalsLayoutDump.Run(dvlDatDir, dvlLayout);
+}
+
if (args.Length >= 1 && args[0] == "render-vitals-mockup")
{
string? rvmDatDir = args.ElementAtOrDefault(1) ?? Env.GetEnvironmentVariable("ACDREAM_DAT_DIR");
diff --git a/src/AcDream.Cli/VitalsLayoutDump.cs b/src/AcDream.Cli/VitalsLayoutDump.cs
new file mode 100644
index 00000000..675f671b
--- /dev/null
+++ b/src/AcDream.Cli/VitalsLayoutDump.cs
@@ -0,0 +1,152 @@
+using System.Collections;
+using System.Reflection;
+using DatReaderWriter;
+using DatReaderWriter.DBObjs;
+using DatReaderWriter.Options;
+using DatReaderWriter.Types;
+
+namespace AcDream.Cli;
+
+///
+/// Full reflective dump of a vitals LayoutDesc element tree: every scalar
+/// property (position/size/flags) of each ElementDesc + its state sprites,
+/// so the real bar rects + spacing + window size can be read from the dat
+/// instead of guessed. Uses reflection so it doesn't depend on knowing the
+/// DatReaderWriter property names ahead of time.
+///
+public static class VitalsLayoutDump
+{
+ public static int Run(string datDir, string? layoutIdText)
+ {
+ if (!Directory.Exists(datDir)) { Console.Error.WriteLine($"error: dir not found: {datDir}"); return 2; }
+ using var dats = new DatCollection(datDir, DatAccessType.Read);
+
+ // Default to the vitals layout dump-vitals-bars found; allow override.
+ uint layoutId = 0x21000014u;
+ if (!string.IsNullOrWhiteSpace(layoutIdText))
+ {
+ var t = layoutIdText.Trim();
+ if (t.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) t = t[2..];
+ uint.TryParse(t, System.Globalization.NumberStyles.HexNumber, null, out layoutId);
+ }
+
+ // First: scan ALL LayoutDescs that contain a vitals meter element, with root size,
+ // so we can tell whether 0x21000014 is the one the user sees (row vs stacked).
+ Console.WriteLine("=== LayoutDescs containing a vitals meter element (0x100000E6/EC/EE) ===");
+ foreach (var id in dats.GetAllIdsOfType())
+ {
+ var l = dats.Get(id);
+ if (l is null) continue;
+ if (!ContainsAny(l, 0x100000E6u, 0x100000ECu, 0x100000EEu)) continue;
+ Console.WriteLine($" 0x{id:X8} {RootSizeSummary(l)}");
+ }
+ Console.WriteLine();
+
+ var ld = dats.Get(layoutId);
+ if (ld is null) { Console.Error.WriteLine($"layout 0x{layoutId:X8} not found"); return 1; }
+
+ Console.WriteLine($"=== FULL DUMP layout 0x{layoutId:X8} ===");
+ DumpScalars("LayoutDesc", ld, 0);
+ foreach (var kv in ld.Elements)
+ DumpElement(kv.Value, 1);
+ return 0;
+ }
+
+ private static bool ContainsAny(LayoutDesc l, params uint[] ids)
+ {
+ foreach (var kv in l.Elements)
+ if (ElemContains(kv.Value, ids)) return true;
+ return false;
+ }
+
+ private static bool ElemContains(ElementDesc e, uint[] ids)
+ {
+ if (Array.IndexOf(ids, e.ElementId) >= 0) return true;
+ foreach (var kv in e.Children)
+ if (ElemContains(kv.Value, ids)) return true;
+ return false;
+ }
+
+ private static string RootSizeSummary(LayoutDesc l)
+ {
+ // Print any LayoutDesc-level scalar that looks like a size.
+ var sb = new System.Text.StringBuilder();
+ foreach (var p in l.GetType().GetProperties())
+ {
+ if (p.GetIndexParameters().Length > 0) continue;
+ if (p.Name is "Elements") continue;
+ object? v; try { v = p.GetValue(l); } catch { continue; }
+ if (v is null) continue;
+ if (IsScalar(v)) sb.Append($"{p.Name}={v} ");
+ }
+ return sb.ToString().Trim();
+ }
+
+ private static void DumpElement(ElementDesc e, int depth)
+ {
+ string ind = new string(' ', depth * 2);
+ Console.WriteLine($"{ind}element 0x{e.ElementId:X8}");
+ DumpScalars(ind + " ", e, depth);
+
+ if (e.StateDesc is not null) DumpMedia(ind + " [DirectState]", e.StateDesc);
+ foreach (var s in e.States)
+ DumpMedia($"{ind} [state {s.Key}]", s.Value);
+
+ foreach (var c in e.Children)
+ DumpElement(c.Value, depth + 1);
+ }
+
+ private static readonly HashSet Skip = new() { "Children", "States", "StateDesc", "Elements", "Media" };
+
+ private static void DumpScalars(string label, object o, int depth)
+ {
+ foreach (var (name, val) in Members(o))
+ {
+ if (Skip.Contains(name)) continue;
+ if (IsScalar(val))
+ Console.WriteLine($"{label} {name} = {Fmt(name, val)}");
+ }
+ }
+
+ private static void DumpMedia(string label, StateDesc sd)
+ {
+ foreach (var m in sd.Media)
+ {
+ var sb = new System.Text.StringBuilder();
+ foreach (var (name, val) in Members(m))
+ if (IsScalar(val)) sb.Append($"{name}={Fmt(name, val)} ");
+ Console.WriteLine($"{label} {m.GetType().Name}: {sb.ToString().Trim()}");
+ }
+ }
+
+ /// Enumerate public properties AND public fields (the DatReaderWriter
+ /// generated types expose geometry/file ids as fields, not properties).
+ private static IEnumerable<(string name, object val)> Members(object o)
+ {
+ var t = o.GetType();
+ foreach (var p in t.GetProperties(BindingFlags.Public | BindingFlags.Instance))
+ {
+ if (p.GetIndexParameters().Length > 0) continue;
+ object? v; try { v = p.GetValue(o); } catch { continue; }
+ if (v is not null) yield return (p.Name, v);
+ }
+ foreach (var f in t.GetFields(BindingFlags.Public | BindingFlags.Instance))
+ {
+ object? v; try { v = f.GetValue(o); } catch { continue; }
+ if (v is not null) yield return (f.Name, v);
+ }
+ }
+
+ private static string Fmt(string name, object v) =>
+ name.Contains("File", StringComparison.OrdinalIgnoreCase) && v is uint u ? $"0x{u:X8}" : v.ToString() ?? "";
+
+ private static bool IsScalar(object v)
+ {
+ var t = v.GetType();
+ if (v is string) return true;
+ if (t.IsPrimitive || t.IsEnum) return true;
+ if (v is IEnumerable) return false;
+ // value-type structs (Rectangle/Point/etc.) — print via ToString
+ return t.IsValueType;
+ }
+}
diff --git a/src/AcDream.Cli/VitalsMockup.cs b/src/AcDream.Cli/VitalsMockup.cs
index 9d4dbe72..b53d8f4f 100644
--- a/src/AcDream.Cli/VitalsMockup.cs
+++ b/src/AcDream.Cli/VitalsMockup.cs
@@ -9,76 +9,84 @@ using SixLabors.ImageSharp.Processing;
namespace AcDream.Cli;
///
-/// Headless PNG preview of the retail vital bars. Loads the real RenderSurface
-/// sprites from the dats and composites them with the SAME horizontal 3-slice
-/// logic the in-client UiMeter.DrawHBar uses (fixed-width bevelled caps +
-/// a stretched gradient middle; the empty "back" track full width, the coloured
-/// "front" fill grown from the left to the value). This lets the bar assembly be
-/// verified by eye without launching the client + connecting to the server.
-/// Bar sprite ids come from the vitals LayoutDesc (0x21000014) via dump-vitals-bars.
+/// Headless PNG preview of the retail STACKED vitals window (LayoutDesc
+/// 0x2100006C, 160x58), composited with the SAME model the in-client UiMeter
+/// uses: an 8-piece chrome border, then three flush-stacked 150x16 bars, each
+/// drawn as a BACK 3-slice (empty track, full width) + a FRONT 3-slice
+/// (coloured fill) horizontally CLIPPED to the fill fraction — so the front's
+/// own right-cap shows at full, and clipping reveals the back's right-cap when
+/// partial (matching retail's scissor-fill). All ids are dat-verified from
+/// 0x2100006C via dump-vitals-layout.
///
public static class VitalsMockup
{
- private readonly record struct Vital(
- string Name, uint BackL, uint BackT, uint BackR, uint FrontL, uint FrontT, uint FrontR);
+ // 8-piece chrome border (RetailChromeSprites; 5px), dat-verified in 0x2100006C.
+ private const uint TL = 0x060074C3, TOP = 0x060074BF, TR = 0x060074C4;
+ private const uint LEFT = 0x060074C0, RIGHT = 0x060074C2;
+ private const uint BL = 0x060074C5, BOT = 0x060074C1, BR = 0x060074C6;
+ private readonly record struct Vital(
+ string Name, float Frac,
+ uint BackL, uint BackM, uint BackR, uint FrontL, uint FrontM, uint FrontR);
+
+ // Stacked-window (0x2100006C) sprite ids — NOT the floaty-row 0x0600113x set.
private static readonly Vital[] Vitals =
{
- new("health", 0x06001141, 0x06001140, 0x0600113F, 0x06001131, 0x06001132, 0x06001133),
- new("stamina", 0x06001147, 0x06001146, 0x06001145, 0x06001137, 0x06001138, 0x06001139),
- new("mana", 0x06001144, 0x06001143, 0x06001142, 0x06001134, 0x06001135, 0x06001136),
+ 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 static readonly float[] Fills = { 1.0f, 0.6f, 0.25f };
-
- private const int BarW = 200, BarH = 14, PadX = 10, PadY = 10, GapY = 10, ColGap = 16, Zoom = 3;
+ // Window geometry from 0x2100006C: 160x58, 5px border, bars at x=5 y=5/21/37, 150x16.
+ private const int WinW = 160, WinH = 58, Border = 5, BarX = 5, BarW = 150, BarH = 16;
+ private static readonly int[] BarY = { 5, 21, 37 };
+ private const int Zoom = 5;
public static int Render(string datDir, string outPath)
{
- if (!Directory.Exists(datDir)) { Console.Error.WriteLine($"error: directory 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);
- int cols = Fills.Length;
- int canvasW = PadX * 2 + cols * BarW + (cols - 1) * ColGap;
- int canvasH = PadY * 2 + Vitals.Length * BarH + (Vitals.Length - 1) * GapY;
+ using var canvas = new Image(WinW, WinH, new Rgba32(0, 0, 0, 0));
- // Retail vitals window backdrop is a dark translucent panel; pick a neutral
- // dark gray so the bevels + gradient read clearly.
- using var canvas = new Image(canvasW, canvasH, new Rgba32(38, 38, 44, 255));
-
- for (int vi = 0; vi < Vitals.Length; vi++)
+ // 8-piece chrome border.
+ 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))
{
- var v = Vitals[vi];
- using var bl = Load(dats, v.BackL);
- using var bt = Load(dats, v.BackT);
- using var br = Load(dats, v.BackR);
- using var fl = Load(dats, v.FrontL);
- using var ft = Load(dats, v.FrontT);
- using var fr = Load(dats, v.FrontR);
-
- Console.WriteLine($"{v.Name,-8} back[{bl.Width}x{bl.Height} {bt.Width}x{bt.Height} {br.Width}x{br.Height}] " +
- $"front[{fl.Width}x{fl.Height} {ft.Width}x{ft.Height} {fr.Width}x{fr.Height}]");
-
- int y = PadY + vi * (BarH + GapY);
- for (int ci = 0; ci < Fills.Length; ci++)
- {
- int x = PadX + ci * (BarW + ColGap);
- DrawHBar(canvas, bl, bt, br, x, y, BarW, BarH, withRightCap: true);
- int fw = (int)(BarW * Fills[ci]);
- if (fw > 0)
- DrawHBar(canvas, fl, ft, fr, x, y, fw, BarH, withRightCap: false);
- }
+ Blit(canvas, tl, 0, 0, Border, Border);
+ Blit(canvas, top, Border, 0, WinW - 2 * Border, Border);
+ Blit(canvas, tr, WinW - Border, 0, Border, Border);
+ Blit(canvas, le, 0, Border, Border, WinH - 2 * Border);
+ Blit(canvas, ri, WinW - Border, Border, Border, WinH - 2 * Border);
+ Blit(canvas, bl, 0, WinH - Border, Border, Border);
+ Blit(canvas, bo, Border, WinH - Border, WinW - 2 * Border, Border);
+ Blit(canvas, br, WinW - Border, WinH - Border, Border, Border);
}
- canvas.Mutate(c => c.Resize(canvasW * Zoom, canvasH * Zoom, KnownResamplers.NearestNeighbor));
+ for (int i = 0; i < Vitals.Length; i++)
+ {
+ var v = Vitals[i];
+ int y = BarY[i];
+ using var bl_ = Load(dats, v.BackL); using var bm = Load(dats, v.BackM); using var br_ = Load(dats, v.BackR);
+ using var fl = Load(dats, v.FrontL); using var fm = Load(dats, v.FrontM); using var fr = Load(dats, v.FrontR);
+ Console.WriteLine($"{v.Name,-8} back[{bl_.Width}x{bl_.Height} {bm.Width}x{bm.Height} {br_.Width}x{br_.Height}] " +
+ $"front[{fl.Width}x{fl.Height} {fm.Width}x{fm.Height} {fr.Width}x{fr.Height}] frac={v.Frac}");
+ // Back track: full width.
+ DrawHBar(canvas, bl_, bm, br_, BarX, y, BarW, BarH, clipW: BarW);
+ // Front fill: full 3-slice clipped to the fraction.
+ DrawHBar(canvas, fl, fm, fr, BarX, y, BarW, BarH, clipW: (int)MathF.Round(BarW * v.Frac));
+ }
+
+ canvas.Mutate(c => c.Resize(WinW * Zoom, WinH * Zoom, KnownResamplers.NearestNeighbor));
canvas.SaveAsPng(outPath);
- Console.WriteLine($"wrote {outPath} ({canvasW * Zoom}x{canvasH * Zoom}; rows=vitals, cols=100%/60%/25%)");
+ Console.WriteLine($"wrote {outPath} ({WinW * Zoom}x{WinH * Zoom}; stacked window 0x2100006C, fracs h/s/m={Vitals[0].Frac}/{Vitals[1].Frac}/{Vitals[2].Frac})");
return 0;
}
public static int ExportSprite(string datDir, string idText, string outPath)
{
- if (!Directory.Exists(datDir)) { Console.Error.WriteLine($"error: directory not found: {datDir}"); return 2; }
+ if (!Directory.Exists(datDir)) { Console.Error.WriteLine($"error: dir not found: {datDir}"); return 2; }
uint id = ParseHex(idText);
if (id == 0) { Console.Error.WriteLine($"error: bad id '{idText}'"); return 2; }
using var dats = new DatCollection(datDir, DatAccessType.Read);
@@ -88,20 +96,34 @@ public static class VitalsMockup
return 0;
}
- /// Replicates UiMeter.DrawHBar: native-width left-cap, stretched middle,
- /// optional native-width right-cap; caps clamped so a narrow bar never overdraws.
+ /// Horizontal 3-slice (native-width left-cap, stretched middle, native-width
+ /// right-cap) clipped so nothing past (bar-local px) draws.
+ /// Mirrors the in-client UiMeter: back uses clipW=full, front uses clipW=frac*width.
private static void DrawHBar(
- Image canvas, Image left, Image tile, Image right,
- int x, int y, int w, int h, bool withRightCap)
+ Image canvas, Image left, Image mid, Image right,
+ int x, int y, int w, int h, int clipW)
{
- if (w <= 0) return;
- int rcap = withRightCap ? Math.Min(right.Width, w) : 0;
- int lcap = Math.Min(left.Width, w - rcap);
+ if (w <= 0 || clipW <= 0) return;
+ int capL = Math.Min(left.Width, w);
+ int capR = Math.Min(right.Width, w - capL);
+ int midW = w - capL - capR;
+ DrawClippedPiece(canvas, left, x, y, 0, capL, h, clipW);
+ DrawClippedPiece(canvas, mid, x, y, capL, midW, h, clipW);
+ DrawClippedPiece(canvas, right, x, y, w - capR, capR, h, clipW);
+ }
- if (lcap > 0) Blit(canvas, left, x, y, lcap, h);
- int midX = x + lcap, midW = w - lcap - rcap;
- if (midW > 0) Blit(canvas, tile, midX, y, midW, h);
- if (rcap > 0) Blit(canvas, right, x + w - rcap, y, rcap, h);
+ /// Draw one slice spanning bar-local [pieceLocalX, pieceLocalX+pieceW], cropped
+ /// horizontally so nothing past clipW shows (UV-cropping the texture proportionally).
+ private static void DrawClippedPiece(
+ Image canvas, Image src, int x, int y, int pieceLocalX, int pieceW, int h, int clipW)
+ {
+ if (pieceW <= 0) return;
+ int visibleW = Math.Min(pieceW, clipW - pieceLocalX);
+ if (visibleW <= 0) return;
+ int srcCropW = Math.Max(1, (int)MathF.Round(src.Width * (visibleW / (float)pieceW)));
+ srcCropW = Math.Min(srcCropW, src.Width);
+ using var piece = src.Clone(c => c.Crop(new Rectangle(0, 0, srcCropW, src.Height)).Resize(visibleW, h));
+ canvas.Mutate(c => c.DrawImage(piece, new Point(x + pieceLocalX, y), 1f));
}
private static void Blit(Image canvas, Image src, int x, int y, int dw, int dh)