diff --git a/src/AcDream.App/UI/MarkupDocument.cs b/src/AcDream.App/UI/MarkupDocument.cs
index 5c7baaa3..1132479b 100644
--- a/src/AcDream.App/UI/MarkupDocument.cs
+++ b/src/AcDream.App/UI/MarkupDocument.cs
@@ -67,8 +67,12 @@ public static class MarkupDocument
Label = () => (cur(), max()) is (uint c, uint m) ? $"{c}/{m}" : null,
Anchors = Anchor((string?)el.Attribute("anchor")),
SpriteResolve = resolve,
- BackSpriteId = Hex((string?)el.Attribute("back")),
- FrontSpriteId = Hex((string?)el.Attribute("front")),
+ BackLeft = Hex((string?)el.Attribute("backleft")),
+ BackTile = Hex((string?)el.Attribute("backtile")),
+ BackRight = Hex((string?)el.Attribute("backright")),
+ FrontLeft = Hex((string?)el.Attribute("frontleft")),
+ FrontTile = Hex((string?)el.Attribute("fronttile")),
+ FrontRight = Hex((string?)el.Attribute("frontright")),
});
break;
// future element kinds (label, button, image) added here
diff --git a/src/AcDream.App/UI/UiMeter.cs b/src/AcDream.App/UI/UiMeter.cs
index 48911c14..de97aff4 100644
--- a/src/AcDream.App/UI/UiMeter.cs
+++ b/src/AcDream.App/UI/UiMeter.cs
@@ -25,12 +25,27 @@ public sealed class UiMeter : UiElement
public Vector4 LabelColor { get; set; } = new(1f, 1f, 1f, 1f);
/// Resolver from a RenderSurface DataId to (GL handle, w, h). When set
- /// with Back/Front sprite ids, the bar draws the retail sprites instead of solid color.
+ /// with the 9-slice ids below, the bar draws the retail sprites instead of solid color.
public Func? SpriteResolve { get; set; }
- /// Empty-track sprite (drawn full width). 0 = none.
- public uint BackSpriteId { get; set; }
- /// Colored-fill sprite (drawn cropped to the fill fraction). 0 = none.
- public uint FrontSpriteId { get; set; }
+
+ // Retail vital bars are a horizontal 3-slice: a fixed-width bevelled left-cap,
+ // a stretched gradient middle, and a fixed-width right-cap. The "back" slice is
+ // the empty track (drawn full width); the "front" slice is the coloured fill
+ // (drawn from the left, grown to the fill fraction — the track owns the right
+ // end, so the fill omits its own right-cap). Ids come from the vitals LayoutDesc
+ // (0x21000014) via tools/dump-vitals-bars; 0 = none.
+ /// Empty-track left-cap RenderSurface id.
+ public uint BackLeft { get; set; }
+ /// Empty-track middle (stretched gradient) RenderSurface id.
+ public uint BackTile { get; set; }
+ /// Empty-track right-cap RenderSurface id.
+ public uint BackRight { get; set; }
+ /// Coloured-fill left-cap RenderSurface id.
+ public uint FrontLeft { get; set; }
+ /// Coloured-fill middle (stretched gradient) RenderSurface id.
+ public uint FrontTile { get; set; }
+ /// Coloured-fill right-cap RenderSurface id.
+ public uint FrontRight { get; set; }
public UiMeter() { ClickThrough = true; }
@@ -49,19 +64,13 @@ public sealed class UiMeter : UiElement
float? pct = Fill();
float p = pct is float pf ? (pf < 0f ? 0f : pf > 1f ? 1f : pf) : 0f;
- if (SpriteResolve is { } resolve && (BackSpriteId != 0 || FrontSpriteId != 0))
+ if (SpriteResolve is { } resolve && (BackLeft != 0 || BackTile != 0 || FrontTile != 0))
{
- // Retail bar: empty track full width, colored fill cropped to p (left→right).
- if (BackSpriteId != 0)
- {
- var (bt, _, _) = resolve(BackSpriteId);
- if (bt != 0) ctx.DrawSprite(bt, 0, 0, Width, Height, 0, 0, 1, 1, Vector4.One);
- }
- if (FrontSpriteId != 0 && pct is not null && p > 0f)
- {
- var (ft, _, _) = resolve(FrontSpriteId);
- if (ft != 0) ctx.DrawSprite(ft, 0, 0, Width * p, Height, 0, 0, p, 1, Vector4.One);
- }
+ // 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.
+ if (pct is not null && p > 0f)
+ DrawHBar(ctx, resolve, FrontLeft, FrontTile, FrontRight, 0, 0, Width * p, Height, withRightCap: false);
}
else
{
@@ -83,4 +92,35 @@ public sealed class UiMeter : UiElement
ctx.DrawString(label, tx, ty, LabelColor);
}
}
+
+ ///
+ /// 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.
+ ///
+ private static void DrawHBar(
+ UiRenderContext ctx, Func resolve,
+ uint leftId, uint tileId, uint rightId,
+ float x, float y, float w, float h, bool withRightCap)
+ {
+ if (w <= 0f) return;
+ var (lt, lw, _) = resolve(leftId);
+ var (tt, _, _) = resolve(tileId);
+ 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;
+
+ if (lt != 0 && lcap > 0f)
+ ctx.DrawSprite(lt, x, y, lcap, h, 0, 0, 1, 1, Vector4.One);
+
+ 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);
+ }
}
diff --git a/src/AcDream.App/UI/assets/vitals.xml b/src/AcDream.App/UI/assets/vitals.xml
index 2f7292e5..ca7e665f 100644
--- a/src/AcDream.App/UI/assets/vitals.xml
+++ b/src/AcDream.App/UI/assets/vitals.xml
@@ -1,5 +1,8 @@
-
-
-
+
+
+
diff --git a/src/AcDream.Cli/AcDream.Cli.csproj b/src/AcDream.Cli/AcDream.Cli.csproj
index 7d30223e..e964e5cb 100644
--- a/src/AcDream.Cli/AcDream.Cli.csproj
+++ b/src/AcDream.Cli/AcDream.Cli.csproj
@@ -9,6 +9,14 @@
+
+
+
+
+
+
diff --git a/src/AcDream.Cli/Program.cs b/src/AcDream.Cli/Program.cs
index c4ad9e71..1eef5eb1 100644
--- a/src/AcDream.Cli/Program.cs
+++ b/src/AcDream.Cli/Program.cs
@@ -1,4 +1,5 @@
using System.Diagnostics;
+using AcDream.Cli;
using DatReaderWriter;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Enums;
@@ -18,6 +19,31 @@ if (args.Length >= 1 && args[0] == "dump-vitals-bars")
return DumpVitalsBars(dvbDatDir);
}
+if (args.Length >= 1 && args[0] == "render-vitals-mockup")
+{
+ string? rvmDatDir = args.ElementAtOrDefault(1) ?? Env.GetEnvironmentVariable("ACDREAM_DAT_DIR");
+ string rvmOut = args.ElementAtOrDefault(2) ?? "vitals-mockup.png";
+ if (string.IsNullOrWhiteSpace(rvmDatDir))
+ {
+ Console.Error.WriteLine("usage: AcDream.Cli render-vitals-mockup [out.png]");
+ return 2;
+ }
+ return VitalsMockup.Render(rvmDatDir, rvmOut);
+}
+
+if (args.Length >= 1 && args[0] == "export-ui-sprite")
+{
+ string? eusDatDir = args.ElementAtOrDefault(1) ?? Env.GetEnvironmentVariable("ACDREAM_DAT_DIR");
+ string? eusId = args.ElementAtOrDefault(2);
+ string eusOut = args.ElementAtOrDefault(3) ?? "sprite.png";
+ if (string.IsNullOrWhiteSpace(eusDatDir) || string.IsNullOrWhiteSpace(eusId))
+ {
+ Console.Error.WriteLine("usage: AcDream.Cli export-ui-sprite <0xId> [out.png]");
+ return 2;
+ }
+ return VitalsMockup.ExportSprite(eusDatDir, eusId, eusOut);
+}
+
// Phase 0: open the four AC dat files and print how many of each asset type live in them.
// This proves DatReaderWriter works on our retail dats and gives us a baseline inventory
// to compare against what a future renderer needs.
diff --git a/src/AcDream.Cli/VitalsMockup.cs b/src/AcDream.Cli/VitalsMockup.cs
new file mode 100644
index 00000000..9d4dbe72
--- /dev/null
+++ b/src/AcDream.Cli/VitalsMockup.cs
@@ -0,0 +1,129 @@
+using AcDream.Core.Textures;
+using DatReaderWriter;
+using DatReaderWriter.DBObjs;
+using DatReaderWriter.Options;
+using SixLabors.ImageSharp;
+using SixLabors.ImageSharp.PixelFormats;
+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.
+///
+public static class VitalsMockup
+{
+ private readonly record struct Vital(
+ string Name, uint BackL, uint BackT, uint BackR, uint FrontL, uint FrontT, uint FrontR);
+
+ 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),
+ };
+
+ 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;
+
+ public static int Render(string datDir, string outPath)
+ {
+ if (!Directory.Exists(datDir)) { Console.Error.WriteLine($"error: directory 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;
+
+ // 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++)
+ {
+ 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);
+ }
+ }
+
+ canvas.Mutate(c => c.Resize(canvasW * Zoom, canvasH * Zoom, KnownResamplers.NearestNeighbor));
+ canvas.SaveAsPng(outPath);
+ Console.WriteLine($"wrote {outPath} ({canvasW * Zoom}x{canvasH * Zoom}; rows=vitals, cols=100%/60%/25%)");
+ 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; }
+ uint id = ParseHex(idText);
+ if (id == 0) { Console.Error.WriteLine($"error: bad id '{idText}'"); return 2; }
+ using var dats = new DatCollection(datDir, DatAccessType.Read);
+ using var img = Load(dats, id);
+ img.SaveAsPng(outPath);
+ Console.WriteLine($"wrote {outPath} (0x{id:X8} {img.Width}x{img.Height})");
+ return 0;
+ }
+
+ /// Replicates UiMeter.DrawHBar: native-width left-cap, stretched middle,
+ /// optional native-width right-cap; caps clamped so a narrow bar never overdraws.
+ private static void DrawHBar(
+ Image canvas, Image left, Image tile, Image right,
+ int x, int y, int w, int h, bool withRightCap)
+ {
+ if (w <= 0) return;
+ int rcap = withRightCap ? Math.Min(right.Width, w) : 0;
+ int lcap = Math.Min(left.Width, w - rcap);
+
+ 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);
+ }
+
+ private static void Blit(Image canvas, Image src, int x, int y, int dw, int dh)
+ {
+ if (dw <= 0 || dh <= 0) return;
+ using var s = src.Clone(c => c.Resize(dw, dh));
+ canvas.Mutate(c => c.DrawImage(s, new Point(x, y), 1f));
+ }
+
+ private static Image Load(DatCollection dats, uint id)
+ {
+ var rs = dats.Get(id);
+ if (rs is null) { Console.Error.WriteLine($" missing RenderSurface 0x{id:X8}"); return new Image(1, 1); }
+ var dt = SurfaceDecoder.DecodeRenderSurface(rs);
+ return Image.LoadPixelData(dt.Rgba8, dt.Width, dt.Height);
+ }
+
+ private static uint ParseHex(string s)
+ {
+ s = s.Trim();
+ if (s.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) s = s[2..];
+ return uint.TryParse(s, System.Globalization.NumberStyles.HexNumber,
+ System.Globalization.CultureInfo.InvariantCulture, out var v) ? v : 0u;
+ }
+}
diff --git a/tests/AcDream.App.Tests/UI/MarkupDocumentTests.cs b/tests/AcDream.App.Tests/UI/MarkupDocumentTests.cs
index ed717bbd..d45aa374 100644
--- a/tests/AcDream.App.Tests/UI/MarkupDocumentTests.cs
+++ b/tests/AcDream.App.Tests/UI/MarkupDocumentTests.cs
@@ -58,15 +58,21 @@ public class MarkupDocumentTests
}
[Fact]
- public void Build_ParsesBackFrontSpriteIds()
+ public void Build_ParsesNineSliceBarSpriteIds()
{
const string xml = "" +
- "" +
+ "" +
"";
var panel = MarkupDocument.Build(xml, new FakeBinding(), _ => ((uint)7, 32, 32));
var meter = Assert.IsType(panel.Children[1]);
- Assert.Equal(0x06005F3Cu, meter.BackSpriteId);
- Assert.Equal(0x06005F3Du, meter.FrontSpriteId);
+ Assert.Equal(0x06001141u, meter.BackLeft);
+ Assert.Equal(0x06001140u, meter.BackTile);
+ Assert.Equal(0x0600113Fu, meter.BackRight);
+ Assert.Equal(0x06001131u, meter.FrontLeft);
+ Assert.Equal(0x06001132u, meter.FrontTile);
+ Assert.Equal(0x06001133u, meter.FrontRight);
Assert.NotNull(meter.SpriteResolve);
}
}