feat(D.2b): retail 3-slice vital bars + headless mockup verifier

Render each vital bar as a horizontal 3-slice from the real retail
RenderSurface sprites (authoritative ids from the vitals LayoutDesc
0x21000014 via dump-vitals-bars): a fixed-width bevelled left-cap, a
stretched glassy-gradient middle, and a fixed-width right-cap. The
empty back track draws full width; the coloured front fill grows from
the left to the value (the track owns the right end, so the fill omits
its own right-cap). Replaces the flat single-sprite Alphablend overlay
that read as the old UI - this is the bordered gradient look from the
retail screenshot (red HP / gold stamina / blue mana).

UiMeter gains the six 9-slice ids (BackLeft/Tile/Right +
FrontLeft/Tile/Right) and a DrawHBar helper; MarkupDocument parses the
backleft/backtile/backright/frontleft/fronttile/frontright attrs;
vitals.xml carries the 18 per-vital ids. The temporary
ACDREAM_BAR_PROVEOUT component grid is removed.

Adds AcDream.Cli render-vitals-mockup: a headless ImageSharp composite
that assembles the bars with the SAME DrawHBar logic, so the sprite
assembly can be verified by eye (Read the PNG) without launching the
client + server - the fast UI-iteration loop the user asked for.
export-ui-sprite dumps a single RenderSurface to PNG for HTML mockups.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-14 21:40:11 +02:00
parent 84630517e3
commit 1453ff7da2
7 changed files with 242 additions and 26 deletions

View file

@ -67,8 +67,12 @@ public static class MarkupDocument
Label = () => (cur(), max()) is (uint c, uint m) ? $"{c}/{m}" : null, Label = () => (cur(), max()) is (uint c, uint m) ? $"{c}/{m}" : null,
Anchors = Anchor((string?)el.Attribute("anchor")), Anchors = Anchor((string?)el.Attribute("anchor")),
SpriteResolve = resolve, SpriteResolve = resolve,
BackSpriteId = Hex((string?)el.Attribute("back")), BackLeft = Hex((string?)el.Attribute("backleft")),
FrontSpriteId = Hex((string?)el.Attribute("front")), 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; break;
// future element kinds (label, button, image) added here // future element kinds (label, button, image) added here

View file

@ -25,12 +25,27 @@ public sealed class UiMeter : UiElement
public Vector4 LabelColor { get; set; } = new(1f, 1f, 1f, 1f); public Vector4 LabelColor { get; set; } = new(1f, 1f, 1f, 1f);
/// <summary>Resolver from a RenderSurface DataId to (GL handle, w, h). When set /// <summary>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.</summary> /// with the 9-slice ids below, the bar draws the retail sprites instead of solid color.</summary>
public Func<uint, (uint tex, int w, int h)>? SpriteResolve { get; set; } public Func<uint, (uint tex, int w, int h)>? SpriteResolve { get; set; }
/// <summary>Empty-track sprite (drawn full width). 0 = none.</summary>
public uint BackSpriteId { get; set; } // Retail vital bars are a horizontal 3-slice: a fixed-width bevelled left-cap,
/// <summary>Colored-fill sprite (drawn cropped to the fill fraction). 0 = none.</summary> // a stretched gradient middle, and a fixed-width right-cap. The "back" slice is
public uint FrontSpriteId { get; set; } // 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.
/// <summary>Empty-track left-cap RenderSurface id.</summary>
public uint BackLeft { get; set; }
/// <summary>Empty-track middle (stretched gradient) RenderSurface id.</summary>
public uint BackTile { get; set; }
/// <summary>Empty-track right-cap RenderSurface id.</summary>
public uint BackRight { get; set; }
/// <summary>Coloured-fill left-cap RenderSurface id.</summary>
public uint FrontLeft { get; set; }
/// <summary>Coloured-fill middle (stretched gradient) RenderSurface id.</summary>
public uint FrontTile { get; set; }
/// <summary>Coloured-fill right-cap RenderSurface id.</summary>
public uint FrontRight { get; set; }
public UiMeter() { ClickThrough = true; } public UiMeter() { ClickThrough = true; }
@ -49,19 +64,13 @@ public sealed class UiMeter : UiElement
float? pct = Fill(); float? pct = Fill();
float p = pct is float pf ? (pf < 0f ? 0f : pf > 1f ? 1f : pf) : 0f; 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). // Empty track: full-width 3-slice (left-cap + stretched gradient + right-cap).
if (BackSpriteId != 0) 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.
var (bt, _, _) = resolve(BackSpriteId); if (pct is not null && p > 0f)
if (bt != 0) ctx.DrawSprite(bt, 0, 0, Width, Height, 0, 0, 1, 1, Vector4.One); DrawHBar(ctx, resolve, FrontLeft, FrontTile, FrontRight, 0, 0, Width * p, Height, withRightCap: false);
}
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);
}
} }
else else
{ {
@ -83,4 +92,35 @@ public sealed class UiMeter : UiElement
ctx.DrawString(label, tx, ty, LabelColor); ctx.DrawString(label, tx, ty, LabelColor);
} }
} }
/// <summary>
/// Draws a horizontal 3-slice into <paramref name="w"/> x <paramref name="h"/> at
/// (<paramref name="x"/>,<paramref name="y"/>): a native-width left-cap, a stretched
/// middle, and (when <paramref name="withRightCap"/>) a native-width right-cap. Caps
/// are clamped so a narrow bar never overdraws. A 0 id skips that slice.
/// </summary>
private static void DrawHBar(
UiRenderContext ctx, Func<uint, (uint tex, int w, int h)> 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);
}
} }

View file

@ -1,5 +1,8 @@
<panel id="acdream.vitals" x="10" y="30" w="220" h="96" resize="x"> <panel id="acdream.vitals" x="10" y="30" w="220" h="96" resize="x">
<meter id="health" x="8" y="24" w="200" h="14" fill="{HealthPercent}" cur="{HealthCurrent}" max="{HealthMax}" color="#FFC70D0D" anchor="left,top,right" back="0x06005F3C" front="0x06005F3D"/> <meter id="health" x="8" y="24" w="200" h="14" fill="{HealthPercent}" cur="{HealthCurrent}" max="{HealthMax}" color="#FFC70D0D" anchor="left,top,right"
<meter id="stamina" x="8" y="44" w="200" h="14" fill="{StaminaPercent}" cur="{StaminaCurrent}" max="{StaminaMax}" color="#FFD49E1F" anchor="left,top,right" back="0x06005F3E" front="0x06005F3F"/> backleft="0x06001141" backtile="0x06001140" backright="0x0600113F" frontleft="0x06001131" fronttile="0x06001132" frontright="0x06001133"/>
<meter id="mana" x="8" y="64" w="200" h="14" fill="{ManaPercent}" cur="{ManaCurrent}" max="{ManaMax}" color="#FF1F33D9" anchor="left,top,right" back="0x06005F40" front="0x06005F41"/> <meter id="stamina" x="8" y="44" w="200" h="14" fill="{StaminaPercent}" cur="{StaminaCurrent}" max="{StaminaMax}" color="#FFD49E1F" anchor="left,top,right"
backleft="0x06001147" backtile="0x06001146" backright="0x06001145" frontleft="0x06001137" fronttile="0x06001138" frontright="0x06001139"/>
<meter id="mana" x="8" y="64" w="200" h="14" fill="{ManaPercent}" cur="{ManaCurrent}" max="{ManaMax}" color="#FF1F33D9" anchor="left,top,right"
backleft="0x06001144" backtile="0x06001143" backright="0x06001142" frontleft="0x06001134" fronttile="0x06001135" frontright="0x06001136"/>
</panel> </panel>

View file

@ -9,6 +9,14 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="Chorizite.DatReaderWriter" Version="2.1.7" /> <PackageReference Include="Chorizite.DatReaderWriter" Version="2.1.7" />
<!-- render-vitals-mockup: SurfaceDecoder (Core) + ImageSharp for a headless
PNG composite of the retail vital bars, so the 3-slice assembly can be
verified without launching the client. -->
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.12" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\AcDream.Core\AcDream.Core.csproj" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View file

@ -1,4 +1,5 @@
using System.Diagnostics; using System.Diagnostics;
using AcDream.Cli;
using DatReaderWriter; using DatReaderWriter;
using DatReaderWriter.DBObjs; using DatReaderWriter.DBObjs;
using DatReaderWriter.Enums; using DatReaderWriter.Enums;
@ -18,6 +19,31 @@ if (args.Length >= 1 && args[0] == "dump-vitals-bars")
return DumpVitalsBars(dvbDatDir); 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 <dat-directory> [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 <dat-directory> <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. // 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 // This proves DatReaderWriter works on our retail dats and gives us a baseline inventory
// to compare against what a future renderer needs. // to compare against what a future renderer needs.

View file

@ -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;
/// <summary>
/// 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 <c>UiMeter.DrawHBar</c> 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.
/// </summary>
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<Rgba32>(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;
}
/// <summary>Replicates UiMeter.DrawHBar: native-width left-cap, stretched middle,
/// optional native-width right-cap; caps clamped so a narrow bar never overdraws.</summary>
private static void DrawHBar(
Image<Rgba32> canvas, Image<Rgba32> left, Image<Rgba32> tile, Image<Rgba32> 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<Rgba32> canvas, Image<Rgba32> 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<Rgba32> Load(DatCollection dats, uint id)
{
var rs = dats.Get<RenderSurface>(id);
if (rs is null) { Console.Error.WriteLine($" missing RenderSurface 0x{id:X8}"); return new Image<Rgba32>(1, 1); }
var dt = SurfaceDecoder.DecodeRenderSurface(rs);
return Image.LoadPixelData<Rgba32>(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;
}
}

View file

@ -58,15 +58,21 @@ public class MarkupDocumentTests
} }
[Fact] [Fact]
public void Build_ParsesBackFrontSpriteIds() public void Build_ParsesNineSliceBarSpriteIds()
{ {
const string xml = "<panel id=\"v\" x=\"0\" y=\"0\" w=\"100\" h=\"50\" title=\"V\">" + const string xml = "<panel id=\"v\" x=\"0\" y=\"0\" w=\"100\" h=\"50\" title=\"V\">" +
"<meter id=\"h\" x=\"0\" y=\"0\" w=\"100\" h=\"14\" fill=\"{HealthPercent}\" back=\"0x06005F3C\" front=\"0x06005F3D\"/>" + "<meter id=\"h\" x=\"0\" y=\"0\" w=\"100\" h=\"14\" fill=\"{HealthPercent}\" " +
"backleft=\"0x06001141\" backtile=\"0x06001140\" backright=\"0x0600113F\" " +
"frontleft=\"0x06001131\" fronttile=\"0x06001132\" frontright=\"0x06001133\"/>" +
"</panel>"; "</panel>";
var panel = MarkupDocument.Build(xml, new FakeBinding(), _ => ((uint)7, 32, 32)); var panel = MarkupDocument.Build(xml, new FakeBinding(), _ => ((uint)7, 32, 32));
var meter = Assert.IsType<UiMeter>(panel.Children[1]); var meter = Assert.IsType<UiMeter>(panel.Children[1]);
Assert.Equal(0x06005F3Cu, meter.BackSpriteId); Assert.Equal(0x06001141u, meter.BackLeft);
Assert.Equal(0x06005F3Du, meter.FrontSpriteId); 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); Assert.NotNull(meter.SpriteResolve);
} }
} }