fix(D.2b): tile the vital-bar middle instead of stretching it

Retail repeats the bar's "fill-tile" graphic at native width (verified:
the dat element 0x100000E9 is literally the fill-tile; the engine fills via
ImgTex::TileCSI; and a widened side-by-side shows retail tiling, not
stretching). acdream was stretching one copy of the middle slice across the
whole span, so the bevel/bead pattern smeared as the window widened.

UiMeter.DrawHBar now UV-repeats each slice at its NATIVE width: caps span one
native width (a single 1:1 copy), the wide middle spans many (it tiles, last
copy UV-cropped). This works because the UI textures are already GL_REPEAT-
wrapped (TextureCache.UploadRgba8) — the exact mechanism UiNineSlicePanel's
chrome border already uses, so the border edges were ALREADY tiling and need
no change. One draw call per slice; composes with the existing fill-fraction
clip (the partial last tile shows a partial bead).

render-vitals-mockup now renders a widened window twice (stretch vs tile) so
the difference is verifiable headless. Confirmed the tile repeats seamlessly
(no seams).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-15 10:39:56 +02:00
parent 4e60c03a74
commit 73468be02a
2 changed files with 108 additions and 76 deletions

View file

@ -35,20 +35,21 @@ public sealed class UiMeter : UiElement
public Func<uint, (uint tex, int w, int h)>? SpriteResolve { get; set; } public Func<uint, (uint tex, int w, int h)>? SpriteResolve { get; set; }
// Retail vital bars are a horizontal 3-slice: a fixed-width bevelled left-cap, // 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 // a TILED gradient middle (the "fill-tile" repeats at native width — it does not
// the empty track (drawn full width); the "front" slice is the coloured fill // stretch), and a fixed-width right-cap. The "back" slice is the empty track
// (drawn from the left, grown to the fill fraction — the track owns the right // (drawn full width); the "front" slice is the coloured fill (drawn full-geometry
// end, so the fill omits its own right-cap). Ids come from the vitals LayoutDesc // but CLIPPED to the fill fraction — its own right-cap shows at 100%, the back's
// (0x21000014) via tools/dump-vitals-bars; 0 = none. // shows through when partial). Ids come from the stacked vitals LayoutDesc
// (0x2100006C) via the dump-vitals-layout CLI; 0 = none.
/// <summary>Empty-track left-cap RenderSurface id.</summary> /// <summary>Empty-track left-cap RenderSurface id.</summary>
public uint BackLeft { get; set; } public uint BackLeft { get; set; }
/// <summary>Empty-track middle (stretched gradient) RenderSurface id.</summary> /// <summary>Empty-track middle (tiled gradient) RenderSurface id.</summary>
public uint BackTile { get; set; } public uint BackTile { get; set; }
/// <summary>Empty-track right-cap RenderSurface id.</summary> /// <summary>Empty-track right-cap RenderSurface id.</summary>
public uint BackRight { get; set; } public uint BackRight { get; set; }
/// <summary>Coloured-fill left-cap RenderSurface id.</summary> /// <summary>Coloured-fill left-cap RenderSurface id.</summary>
public uint FrontLeft { get; set; } public uint FrontLeft { get; set; }
/// <summary>Coloured-fill middle (stretched gradient) RenderSurface id.</summary> /// <summary>Coloured-fill middle (tiled gradient) RenderSurface id.</summary>
public uint FrontTile { get; set; } public uint FrontTile { get; set; }
/// <summary>Coloured-fill right-cap RenderSurface id.</summary> /// <summary>Coloured-fill right-cap RenderSurface id.</summary>
public uint FrontRight { get; set; } public uint FrontRight { get; set; }
@ -130,28 +131,36 @@ public sealed class UiMeter : UiElement
if (clipW <= 0f) return; if (clipW <= 0f) return;
float w = Width, h = Height; float w = Width, h = Height;
var (lt, lw, _) = resolve(leftId); var (lt, lw, _) = resolve(leftId);
var (mt, _, _) = resolve(midId); var (mt, mw, _) = resolve(midId);
var (rt, rw, _) = resolve(rightId); var (rt, rw, _) = resolve(rightId);
float capL = lt != 0 ? MathF.Min(lw, w) : 0f; float capL = lt != 0 ? MathF.Min(lw, w) : 0f;
float capR = rt != 0 ? MathF.Min(rw, w - capL) : 0f; float capR = rt != 0 ? MathF.Min(rw, w - capL) : 0f;
float midW = w - capL - capR; float midW = w - capL - capR;
DrawPiece(ctx, lt, 0f, capL, h, clipW); // Each slice's texture repeats every NATIVE-width px (UV-repeat; the UI
DrawPiece(ctx, mt, capL, midW, h, clipW); // texture is GL_REPEAT-wrapped — TextureCache.UploadRgba8). Caps span their
DrawPiece(ctx, rt, w - capR, capR, h, clipW); // own native width → a single 1:1 copy. The wide middle spans many native
// widths → it TILES, matching retail's "fill-tile" + ImgTex::TileCSI rather
// than stretching one copy. (Same UV-repeat the chrome border already uses.)
DrawPiece(ctx, lt, 0f, capL, lw, h, clipW);
DrawPiece(ctx, mt, capL, midW, mw, h, clipW);
DrawPiece(ctx, rt, w - capR, capR, rw, h, clipW);
} }
/// <summary>Draw one slice spanning local [<paramref name="pieceX"/>, /// <summary>Draw a slice over local [<paramref name="pieceX"/>,
/// pieceX+<paramref name="pieceW"/>], UV-cropped so nothing past /// pieceX+<paramref name="pieceW"/>], with the texture repeating every
/// <paramref name="clipW"/> shows.</summary> /// <paramref name="nativeW"/> px (UV-repeat — the UI texture is GL_REPEAT-wrapped).
/// Clipped so nothing past <paramref name="clipW"/> shows. For a cap (span == native)
/// this is one 1:1 copy; for the wide middle it tiles; a partial last copy is
/// UV-cropped.</summary>
private static void DrawPiece( private static void DrawPiece(
UiRenderContext ctx, uint tex, float pieceX, float pieceW, float h, float clipW) UiRenderContext ctx, uint tex, float pieceX, float pieceW, float nativeW, float h, float clipW)
{ {
if (tex == 0 || pieceW <= 0f) return; if (tex == 0 || pieceW <= 0f || nativeW <= 0f) return;
float visibleW = MathF.Min(pieceW, clipW - pieceX); float visibleW = MathF.Min(pieceW, clipW - pieceX);
if (visibleW <= 0f) return; if (visibleW <= 0f) return;
float u1 = visibleW / pieceW; // crop the texture horizontally float u1 = visibleW / nativeW; // >1 ⇒ texture repeats (tiles); ≤1 ⇒ a partial copy
ctx.DrawSprite(tex, pieceX, 0f, visibleW, h, 0f, 0f, u1, 1f, Vector4.One); ctx.DrawSprite(tex, pieceX, 0f, visibleW, h, 0f, 0f, u1, 1f, Vector4.One);
} }
} }

View file

@ -10,17 +10,15 @@ namespace AcDream.Cli;
/// <summary> /// <summary>
/// Headless PNG preview of the retail STACKED vitals window (LayoutDesc /// Headless PNG preview of the retail STACKED vitals window (LayoutDesc
/// 0x2100006C, 160x58), composited with the SAME model the in-client UiMeter /// 0x2100006C). Renders the window WIDENED, twice: once with the middle slice
/// uses: an 8-piece chrome border, then three flush-stacked 150x16 bars, each /// STRETCHED (acdream's current behaviour) and once TILED (retail behaviour —
/// drawn as a BACK 3-slice (empty track, full width) + a FRONT 3-slice /// the "fill-tile" element is repeated at native width, last copy clipped).
/// (coloured fill) horizontally CLIPPED to the fill fraction — so the front's /// Lets the stretch-vs-tile difference be judged by eye before touching the
/// own right-cap shows at full, and clipping reveals the back's right-cap when /// client. Bars = back 3-slice (empty track, full) + front 3-slice (fill).
/// partial (matching retail's scissor-fill). All ids are dat-verified from
/// 0x2100006C via dump-vitals-layout.
/// </summary> /// </summary>
public static class VitalsMockup public static class VitalsMockup
{ {
// 8-piece chrome border (RetailChromeSprites; 5px), dat-verified in 0x2100006C. // 8-piece chrome border (dat-verified in 0x2100006C; 5px).
private const uint TL = 0x060074C3, TOP = 0x060074BF, TR = 0x060074C4; private const uint TL = 0x060074C3, TOP = 0x060074BF, TR = 0x060074C4;
private const uint LEFT = 0x060074C0, RIGHT = 0x060074C2; private const uint LEFT = 0x060074C0, RIGHT = 0x060074C2;
private const uint BL = 0x060074C5, BOT = 0x060074C1, BR = 0x060074C6; private const uint BL = 0x060074C5, BOT = 0x060074C1, BR = 0x060074C6;
@ -29,91 +27,104 @@ public static class VitalsMockup
string Name, float Frac, string Name, float Frac,
uint BackL, uint BackM, uint BackR, uint FrontL, uint FrontM, uint FrontR); 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 = private static readonly Vital[] Vitals =
{ {
new("health", 0.80f, 0x0600747E, 0x0600747F, 0x06007480, 0x06007481, 0x06007482, 0x06007483), new("health", 1.00f, 0x0600747E, 0x0600747F, 0x06007480, 0x06007481, 0x06007482, 0x06007483),
new("stamina", 0.50f, 0x06007484, 0x06007485, 0x06007486, 0x06007487, 0x06007488, 0x06007489), new("stamina", 1.00f, 0x06007484, 0x06007485, 0x06007486, 0x06007487, 0x06007488, 0x06007489),
new("mana", 0.65f, 0x0600748A, 0x0600748B, 0x0600748C, 0x0600748D, 0x0600748E, 0x0600748F), new("mana", 1.00f, 0x0600748A, 0x0600748B, 0x0600748C, 0x0600748D, 0x0600748E, 0x0600748F),
}; };
// Window geometry from 0x2100006C: 160x58, 5px border, bars at x=5 y=5/21/37, 150x16. private const int Border = 5, BarH = 16, Zoom = 4;
private const int WinW = 160, WinH = 58, Border = 5, BarX = 5, BarW = 150, BarH = 16; // Widened bars so stretch-vs-tile is obvious (native middle tile ~100px).
private static readonly int[] BarY = { 5, 21, 37 }; private const int BarW = 280;
private const int Zoom = 5; 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)
{ {
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);
using var canvas = new Image<Rgba32>(WinW, WinH, new Rgba32(0, 0, 0, 0)); int winW = BarW + 2 * Border; // 290
int winH = 3 * BarH + 2 * Border; // 58
int gap = 16;
using var canvas = new Image<Rgba32>(winW, winH * 2 + gap, new Rgba32(20, 20, 24, 255));
// 8-piece chrome border. DrawWindow(canvas, dats, 0, winW, winH, tileMid: false); // top: STRETCH (current)
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.SaveAsPng(outPath);
Console.WriteLine($"wrote {outPath} ({canvas.Width}x{canvas.Height}) — TOP=stretch(current) BOTTOM=tile(retail), widened {BarW}px bars");
return 0;
}
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).
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))
{ {
Blit(canvas, tl, 0, 0, Border, Border); Blit(canvas, tl, 0, offY, Border, Border);
Blit(canvas, top, Border, 0, WinW - 2 * Border, Border); Blit(canvas, top, Border, offY, winW - 2 * Border, Border);
Blit(canvas, tr, WinW - Border, 0, Border, Border); Blit(canvas, tr, winW - Border, offY, Border, Border);
Blit(canvas, le, 0, Border, Border, WinH - 2 * Border); Blit(canvas, le, 0, offY + Border, Border, winH - 2 * Border);
Blit(canvas, ri, WinW - Border, Border, Border, WinH - 2 * Border); Blit(canvas, ri, winW - Border, offY + Border, Border, winH - 2 * Border);
Blit(canvas, bl, 0, WinH - Border, Border, Border); Blit(canvas, bl, 0, offY + winH - Border, Border, Border);
Blit(canvas, bo, Border, WinH - Border, WinW - 2 * Border, Border); Blit(canvas, bo, Border, offY + winH - Border, winW - 2 * Border, Border);
Blit(canvas, br, WinW - Border, WinH - Border, Border, Border); Blit(canvas, br, 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];
int y = BarY[i]; int y = offY + Border + BarLocalY[i];
using var bl_ = Load(dats, v.BackL); using var bm = Load(dats, v.BackM); using var br_ = Load(dats, v.BackR); 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); 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}] " + DrawHBar(canvas, bl_, bm, br_, Border, y, BarW, BarH, BarW, tileMid);
$"front[{fl.Width}x{fl.Height} {fm.Width}x{fm.Height} {fr.Width}x{fr.Height}] frac={v.Frac}"); int fw = (int)MathF.Round(BarW * v.Frac);
// Back track: full width. if (fw > 0) DrawHBar(canvas, fl, fm, fr, Border, y, BarW, BarH, fw, tileMid);
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} ({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) /// <summary>Horizontal 3-slice: native-width left-cap, middle (STRETCHED or TILED
{ /// per <paramref name="tileMid"/>), native-width right-cap; clipped to clipW.</summary>
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);
using var img = Load(dats, id);
img.SaveAsPng(outPath);
Console.WriteLine($"wrote {outPath} (0x{id:X8} {img.Width}x{img.Height})");
return 0;
}
/// <summary>Horizontal 3-slice (native-width left-cap, stretched middle, native-width
/// right-cap) clipped so nothing past <paramref name="clipW"/> (bar-local px) draws.
/// Mirrors the in-client UiMeter: back uses clipW=full, front uses clipW=frac*width.</summary>
private static void DrawHBar( private static void DrawHBar(
Image<Rgba32> canvas, Image<Rgba32> left, Image<Rgba32> mid, Image<Rgba32> right, Image<Rgba32> canvas, Image<Rgba32> left, Image<Rgba32> mid, Image<Rgba32> right,
int x, int y, int w, int h, int clipW) int x, int y, int w, int h, int clipW, bool tileMid)
{ {
if (w <= 0 || clipW <= 0) return; if (w <= 0 || clipW <= 0) return;
int capL = Math.Min(left.Width, w); int capL = Math.Min(left.Width, w);
int capR = Math.Min(right.Width, w - capL); int capR = Math.Min(right.Width, w - capL);
int midW = w - capL - capR; 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, left, x, y, 0, capL, h, clipW); // left cap (once, native)
DrawClippedPiece(canvas, right, x, y, w - capR, capR, h, clipW); if (tileMid) TileMiddle(canvas, mid, x, y, capL, midW, h, clipW); // repeat native-width copies
else DrawClippedPiece(canvas, mid, x, y, capL, midW, h, clipW); // stretch across the span
DrawClippedPiece(canvas, right, x, y, w - capR, capR, h, clipW); // right cap (once, native)
} }
/// <summary>Draw one slice spanning bar-local [pieceLocalX, pieceLocalX+pieceW], cropped /// <summary>Fill [midLocalX, midLocalX+midW] by repeating the native-width tile at
/// horizontally so nothing past clipW shows (UV-cropping the texture proportionally).</summary> /// 1:1 (no horizontal scaling), clipping the final partial copy and honouring clipW.</summary>
private static void TileMiddle(
Image<Rgba32> canvas, Image<Rgba32> mid, int x, int y, int midLocalX, int midW, int h, int clipW)
{
int tileW = Math.Max(1, mid.Width);
for (int mx = 0; mx < midW; mx += tileW)
{
int localX = midLocalX + mx;
int segW = Math.Min(tileW, midW - mx); // last copy may be partial
int visible = Math.Min(segW, clipW - localX); // fill-fraction clip
if (visible <= 0) break;
// 1:1 — crop the source to `visible` px (no resize-stretch), draw at native scale.
int cropW = Math.Min(visible, mid.Width);
using var seg = mid.Clone(c => c.Crop(new Rectangle(0, 0, cropW, mid.Height)).Resize(visible, h));
canvas.Mutate(c => c.DrawImage(seg, new Point(x + localX, y), 1f));
}
}
/// <summary>Draw one slice spanning [pieceLocalX, +pieceW] STRETCHED to fill, UV-cropped
/// (proportionally) so nothing past clipW shows.</summary>
private static void DrawClippedPiece( private static void DrawClippedPiece(
Image<Rgba32> canvas, Image<Rgba32> src, int x, int y, int pieceLocalX, int pieceW, int h, int clipW) Image<Rgba32> canvas, Image<Rgba32> src, int x, int y, int pieceLocalX, int pieceW, int h, int clipW)
{ {
@ -133,6 +144,18 @@ 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));
} }
public static int ExportSprite(string datDir, string idText, string outPath)
{
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);
using var img = Load(dats, id);
img.SaveAsPng(outPath);
Console.WriteLine($"wrote {outPath} (0x{id:X8} {img.Width}x{img.Height})");
return 0;
}
private static Image<Rgba32> Load(DatCollection dats, uint id) private static Image<Rgba32> Load(DatCollection dats, uint id)
{ {
var rs = dats.Get<RenderSurface>(id); var rs = dats.Get<RenderSurface>(id);