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:
parent
4e60c03a74
commit
73468be02a
2 changed files with 108 additions and 76 deletions
|
|
@ -35,20 +35,21 @@ public sealed class UiMeter : UiElement
|
|||
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,
|
||||
// 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.
|
||||
// a TILED gradient middle (the "fill-tile" repeats at native width — it does not
|
||||
// stretch), and a fixed-width right-cap. The "back" slice is the empty track
|
||||
// (drawn full width); the "front" slice is the coloured fill (drawn full-geometry
|
||||
// but CLIPPED to the fill fraction — its own right-cap shows at 100%, the back's
|
||||
// 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>
|
||||
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; }
|
||||
/// <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>
|
||||
/// <summary>Coloured-fill middle (tiled gradient) RenderSurface id.</summary>
|
||||
public uint FrontTile { get; set; }
|
||||
/// <summary>Coloured-fill right-cap RenderSurface id.</summary>
|
||||
public uint FrontRight { get; set; }
|
||||
|
|
@ -130,28 +131,36 @@ public sealed class UiMeter : UiElement
|
|||
if (clipW <= 0f) return;
|
||||
float w = Width, h = Height;
|
||||
var (lt, lw, _) = resolve(leftId);
|
||||
var (mt, _, _) = resolve(midId);
|
||||
var (mt, mw, _) = resolve(midId);
|
||||
var (rt, rw, _) = resolve(rightId);
|
||||
|
||||
float capL = lt != 0 ? MathF.Min(lw, w) : 0f;
|
||||
float capR = rt != 0 ? MathF.Min(rw, w - capL) : 0f;
|
||||
float midW = w - capL - capR;
|
||||
|
||||
DrawPiece(ctx, lt, 0f, capL, h, clipW);
|
||||
DrawPiece(ctx, mt, capL, midW, h, clipW);
|
||||
DrawPiece(ctx, rt, w - capR, capR, h, clipW);
|
||||
// Each slice's texture repeats every NATIVE-width px (UV-repeat; the UI
|
||||
// texture is GL_REPEAT-wrapped — TextureCache.UploadRgba8). Caps span their
|
||||
// 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"/>,
|
||||
/// pieceX+<paramref name="pieceW"/>], UV-cropped so nothing past
|
||||
/// <paramref name="clipW"/> shows.</summary>
|
||||
/// <summary>Draw a slice over local [<paramref name="pieceX"/>,
|
||||
/// pieceX+<paramref name="pieceW"/>], with the texture repeating every
|
||||
/// <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(
|
||||
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);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,17 +10,15 @@ namespace AcDream.Cli;
|
|||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// 0x2100006C). Renders the window WIDENED, twice: once with the middle slice
|
||||
/// STRETCHED (acdream's current behaviour) and once TILED (retail behaviour —
|
||||
/// the "fill-tile" element is repeated at native width, last copy clipped).
|
||||
/// Lets the stretch-vs-tile difference be judged by eye before touching the
|
||||
/// client. Bars = back 3-slice (empty track, full) + front 3-slice (fill).
|
||||
/// </summary>
|
||||
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 LEFT = 0x060074C0, RIGHT = 0x060074C2;
|
||||
private const uint BL = 0x060074C5, BOT = 0x060074C1, BR = 0x060074C6;
|
||||
|
|
@ -29,91 +27,104 @@ public static class VitalsMockup
|
|||
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", 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),
|
||||
new("health", 1.00f, 0x0600747E, 0x0600747F, 0x06007480, 0x06007481, 0x06007482, 0x06007483),
|
||||
new("stamina", 1.00f, 0x06007484, 0x06007485, 0x06007486, 0x06007487, 0x06007488, 0x06007489),
|
||||
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 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;
|
||||
private const int Border = 5, BarH = 16, Zoom = 4;
|
||||
// Widened bars so stretch-vs-tile is obvious (native middle tile ~100px).
|
||||
private const int BarW = 280;
|
||||
private static readonly int[] BarLocalY = { 0, 16, 32 }; // flush stacked inside the interior
|
||||
|
||||
public static int Render(string datDir, string outPath)
|
||||
{
|
||||
if (!Directory.Exists(datDir)) { Console.Error.WriteLine($"error: dir not found: {datDir}"); return 2; }
|
||||
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 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))
|
||||
{
|
||||
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);
|
||||
Blit(canvas, tl, 0, offY, Border, Border);
|
||||
Blit(canvas, top, Border, offY, winW - 2 * Border, Border);
|
||||
Blit(canvas, tr, winW - Border, offY, Border, Border);
|
||||
Blit(canvas, le, 0, offY + Border, Border, winH - 2 * Border);
|
||||
Blit(canvas, ri, winW - Border, offY + Border, Border, winH - 2 * Border);
|
||||
Blit(canvas, bl, 0, offY + winH - Border, Border, Border);
|
||||
Blit(canvas, bo, Border, offY + winH - Border, winW - 2 * Border, Border);
|
||||
Blit(canvas, br, winW - Border, offY + winH - Border, Border, Border);
|
||||
}
|
||||
|
||||
for (int i = 0; i < Vitals.Length; 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 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));
|
||||
DrawHBar(canvas, bl_, bm, br_, Border, y, BarW, BarH, BarW, tileMid);
|
||||
int fw = (int)MathF.Round(BarW * v.Frac);
|
||||
if (fw > 0) DrawHBar(canvas, fl, fm, fr, Border, y, BarW, BarH, fw, tileMid);
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
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>
|
||||
/// <summary>Horizontal 3-slice: native-width left-cap, middle (STRETCHED or TILED
|
||||
/// per <paramref name="tileMid"/>), native-width right-cap; clipped to clipW.</summary>
|
||||
private static void DrawHBar(
|
||||
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;
|
||||
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);
|
||||
|
||||
DrawClippedPiece(canvas, left, x, y, 0, capL, h, clipW); // left cap (once, native)
|
||||
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
|
||||
/// horizontally so nothing past clipW shows (UV-cropping the texture proportionally).</summary>
|
||||
/// <summary>Fill [midLocalX, midLocalX+midW] by repeating the native-width tile at
|
||||
/// 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(
|
||||
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));
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
var rs = dats.Get<RenderSurface>(id);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue