This reverts ceec3bc4. Two independent reasons, either sufficient.
The rendering regression. The slice deleted TextRenderGlStateScope, which
saved GL_MULTISAMPLE and GL_SAMPLE_ALPHA_TO_COVERAGE on entry, disabled them
for the text pass, and restored them on exit (TextRenderGlStateScope.cs:111-112
and 153-154 at the parent commit). Its replacement bakes that state into the
text pipeline but nothing restores it, and GlGpuPassEncoder.Dispose does not
either. Every world renderer is still raw GL at this point in the campaign, so
from the first UI frame onward the world drew with multisampling disabled.
The offline pixel gate caught it: 1,791 of 563,200 compared pixels differed,
0.318% against a 0.001 threshold. The commit message attributed this to
wall-clock-driven ambient animation shifting phase, and committed through the
failure. That explanation does not survive its own control: capturing twice at
the reverted-to commit differs by 19 pixels and twice at the slice's own commit
by 8, while base-versus-head differs by 1,791 - a 224x gap that no shared-noise
source explains. An amplified difference image settles it visually: the changed
pixels are the silhouette edges of every tree, building and rock, with terrain
interiors, water and the entire UI untouched. That is the signature of losing
edge antialiasing, not of animated sprites.
This is the exact failure mode two existing memory notes already warn about -
a mid-frame renderer must set every GL state it uses rather than inherit it,
and issue #52's lesson that a rendering migration must audit per-pass GL state
before declaring itself done.
The scope. The brief was three small leaf renderers plus additive frame-
lifecycle wiring, roughly ten files. The commit changed 334 files with 3,665
insertions and 3,845 deletions, including 323 public-to-internal visibility
conversions across the App assembly, 55 test files, two retired conformance
tests, and a self-described temporary escape hatch for bridging raw-GL viewport
textures. Even without the regression, that is not separable into the part
worth keeping and the part worth dropping.
Reverting rather than patching because the good work here - the RHI frame
lifecycle wiring and a genuine render-state-cache staleness fix - is small
enough to redo cleanly against a tightened spec, while untangling it from 300+
files of unrelated churn is not.
Post-revert: Release build clean, App suite back to 3,843 passed / 3 skipped,
offline pixel gate passing at 19 differing pixels.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
259 lines
13 KiB
C#
259 lines
13 KiB
C#
using System.Numerics;
|
|
|
|
namespace AcDream.App.UI;
|
|
|
|
/// <summary>
|
|
/// A horizontal vital bar (retail HP/Stamina/Mana style): a background rect, a
|
|
/// partial-width solid fill, and an optional centered "current/max" numeric
|
|
/// overlay. <see cref="Fill"/> returns 0..1 (null = no data → empty bar);
|
|
/// <see cref="Label"/> returns the overlay text (null = no number).
|
|
///
|
|
/// <para>
|
|
/// Solid-color fill + debug font for Spec 1. The retail gradient bar sprite
|
|
/// (glassy center highlight) and the retail dat font are a later polish pass —
|
|
/// retail's vitals are bars exactly like this, just sprited.
|
|
/// </para>
|
|
/// </summary>
|
|
public sealed class UiMeter : UiElement, IUiDatStateful
|
|
{
|
|
private readonly Dictionary<uint, uint> _stateFillSprites = new();
|
|
|
|
/// <summary>Dat element id, set by the layout importer so duplicated page copies can be scoped.</summary>
|
|
public uint ElementId { get; set; }
|
|
|
|
/// <summary>Fill fraction provider; a null result draws an empty bar.</summary>
|
|
public Func<float?> Fill { get; set; } = () => 0f;
|
|
/// <summary>Centered overlay text provider (e.g. "291/291"); null = none.</summary>
|
|
public Func<string?> Label { get; set; } = () => null;
|
|
public Vector4 BarColor { get; set; } = new(1f, 0f, 0f, 1f);
|
|
public Vector4 BgColor { get; set; } = new(0f, 0f, 0f, 0.5f);
|
|
public Vector4 LabelColor { get; set; } = new(1f, 1f, 1f, 1f);
|
|
|
|
/// <summary>Retail dat font (Font 0x40000000) for the "cur/max" overlay. When
|
|
/// set, the label renders through the dat-font two-pass blit (outline + fill);
|
|
/// when null, the debug <see cref="UiRenderContext.DefaultFont"/> bitmap font
|
|
/// is used instead. Set by the host when the retail UI is active.</summary>
|
|
public UiDatFont? DatFont { get; set; }
|
|
|
|
/// <summary>Resolver from a RenderSurface DataId to (GL handle, w, h). When set
|
|
/// 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; }
|
|
|
|
// Retail vital bars are a horizontal 3-slice: a fixed-width bevelled left-cap,
|
|
// 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 (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 (tiled gradient) RenderSurface id.</summary>
|
|
public uint FrontTile { get; set; }
|
|
/// <summary>Coloured-fill right-cap RenderSurface id.</summary>
|
|
public uint FrontRight { get; set; }
|
|
|
|
/// <summary>The active numeric DAT state for a stateful fill meter.</summary>
|
|
public uint ActiveRetailStateId { get; private set; }
|
|
|
|
/// <summary>
|
|
/// Registers a fill sprite carried by a child state of the imported meter.
|
|
/// Used by the powerbar shape in LayoutDesc 0x21000072, whose track is on
|
|
/// the meter and whose Jump/Melee/Missile/DDD fills are states of one child.
|
|
/// </summary>
|
|
internal void ConfigureStateFill(uint stateId, uint spriteId)
|
|
{
|
|
if (stateId != 0 && spriteId != 0)
|
|
_stateFillSprites[stateId] = spriteId;
|
|
}
|
|
|
|
public bool TrySetRetailState(uint stateId)
|
|
{
|
|
if (!_stateFillSprites.TryGetValue(stateId, out uint spriteId))
|
|
return false;
|
|
|
|
ActiveRetailStateId = stateId;
|
|
FrontLeft = 0;
|
|
FrontTile = spriteId;
|
|
FrontRight = 0;
|
|
return true;
|
|
}
|
|
|
|
/// <summary>Vertical orientation (retail <c>m_eDirection</c> 2/4). Default false =
|
|
/// horizontal (direction 1). The burden bar is the only vertical meter today.</summary>
|
|
public bool Vertical { get; set; }
|
|
|
|
/// <summary>For a vertical meter, fill grows from the bottom up (retail direction 4)
|
|
/// when true, top-down (direction 2) when false. Default bottom-up. Visual-confirmed.</summary>
|
|
public bool FillFromBottom { get; set; } = true;
|
|
|
|
public UiMeter() { ClickThrough = true; }
|
|
|
|
/// <summary>The meter draws its own 3-slice bars; the importer must not build its
|
|
/// grandchild slice/text elements as separate widgets.</summary>
|
|
public override bool ConsumesDatChildren => true;
|
|
|
|
/// <summary>Clamp <paramref name="pct"/> to [0,1] and return the fill rect
|
|
/// (local px) for a bar of <paramref name="w"/> x <paramref name="h"/>.</summary>
|
|
public static (float x, float y, float w, float h) ComputeFillRect(
|
|
float pct, float w, float h)
|
|
{
|
|
if (pct < 0f) pct = 0f;
|
|
if (pct > 1f) pct = 1f;
|
|
return (0f, 0f, w * pct, h);
|
|
}
|
|
|
|
/// <summary>Clamp <paramref name="pct"/> to [0,1] and return the vertical fill rect
|
|
/// (local px). <paramref name="fromBottom"/> true → the fill occupies the bottom
|
|
/// <c>h*pct</c> px (retail direction 4); false → the top (direction 2).</summary>
|
|
public static (float x, float y, float w, float h) ComputeVFillRect(
|
|
float pct, float w, float h, bool fromBottom)
|
|
{
|
|
if (pct < 0f) pct = 0f;
|
|
if (pct > 1f) pct = 1f;
|
|
float fh = h * pct;
|
|
return (0f, fromBottom ? h - fh : 0f, w, fh);
|
|
}
|
|
|
|
protected override void OnDraw(UiRenderContext ctx)
|
|
{
|
|
float? pct = Fill();
|
|
float p = pct is float pf ? (pf < 0f ? 0f : pf > 1f ? 1f : pf) : 0f;
|
|
|
|
if (SpriteResolve is { } resolve && (BackLeft != 0 || BackTile != 0 || FrontTile != 0))
|
|
{
|
|
if (Vertical)
|
|
{
|
|
// Track full height, fill clipped to the fraction along the fill direction.
|
|
// The burden meter is a single-tile bar (BackTile/FrontTile, no caps).
|
|
DrawVBar(ctx, resolve, BackTile, Height, fromBottom: FillFromBottom, isFill: false);
|
|
if (pct is not null && p > 0f)
|
|
DrawVBar(ctx, resolve, FrontTile, Height * p, fromBottom: FillFromBottom, isFill: true);
|
|
}
|
|
else
|
|
{
|
|
// 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, Width * p);
|
|
}
|
|
}
|
|
else
|
|
{
|
|
// Placeholder solid-color fallback.
|
|
ctx.DrawRect(0, 0, Width, Height, BgColor);
|
|
if (pct is not null && p > 0f)
|
|
{
|
|
var (fx, fy, fw, fh) = ComputeFillRect(p, Width, Height);
|
|
if (fw > 0f) ctx.DrawRect(fx, fy, fw, fh, BarColor);
|
|
}
|
|
}
|
|
|
|
string? label = Label();
|
|
if (!string.IsNullOrEmpty(label))
|
|
{
|
|
if (DatFont is { } datFont)
|
|
{
|
|
// Retail path: centered cur/max via the dat font's two-pass blit.
|
|
float tw = datFont.MeasureWidth(label);
|
|
float tx = (Width - tw) * 0.5f;
|
|
float ty = (Height - datFont.LineHeight) * 0.5f;
|
|
ctx.DrawStringDat(datFont, label, tx, ty, LabelColor);
|
|
}
|
|
else if (ctx.DefaultFont is { } font)
|
|
{
|
|
// Fallback: debug bitmap font (no dat font available).
|
|
float tw = font.MeasureWidth(label);
|
|
float tx = (Width - tw) * 0.5f;
|
|
float ty = (Height - font.LineHeight) * 0.5f;
|
|
ctx.DrawString(label, tx, ty, LabelColor);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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 <paramref name="clipW"/> (local px from the left) is drawn.
|
|
/// The back track passes <c>clipW = Width</c>; the front fill passes
|
|
/// <c>clipW = Width * fraction</c>. 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.
|
|
/// </summary>
|
|
private void DrawHBar(
|
|
UiRenderContext ctx, Func<uint, (uint tex, int w, int h)> resolve,
|
|
uint leftId, uint midId, uint rightId, float clipW)
|
|
{
|
|
if (clipW <= 0f) return;
|
|
float w = Width, h = Height;
|
|
// Only resolve a slice when its id is non-zero. resolve(0) returns the 1x1 MAGENTA
|
|
// placeholder with a NON-ZERO GL handle, so resolving a zero (absent) cap id and then
|
|
// testing `tex != 0` would draw a 1px magenta cap. The single-image meter (toolbar
|
|
// selected-object bar) has no left/right caps (ids 0); the 3-slice vitals meter sets
|
|
// all six ids. Guard on the id, not the resolved handle.
|
|
var (lt, lw, _) = leftId != 0 ? resolve(leftId) : (0u, 0, 0);
|
|
var (mt, mw, _) = midId != 0 ? resolve(midId) : (0u, 0, 0);
|
|
var (rt, rw, _) = rightId != 0 ? resolve(rightId) : (0u, 0, 0);
|
|
|
|
float capL = lt != 0 ? MathF.Min(lw, w) : 0f;
|
|
float capR = rt != 0 ? MathF.Min(rw, w - capL) : 0f;
|
|
float midW = w - capL - capR;
|
|
|
|
// 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>Draws a single-tile vertical bar slice. The track (<paramref name="isFill"/>
|
|
/// false) spans the full height; the fill spans <paramref name="visibleH"/> px from the
|
|
/// bottom (<paramref name="fromBottom"/>) or top, UV-cropped to that fraction of the
|
|
/// sprite so the fill reveals the matching part of the art (retail
|
|
/// UIElement_Meter::DrawChildren Box2D clip, direction 2/4). A 0 id is a no-op.</summary>
|
|
private void DrawVBar(UiRenderContext ctx, Func<uint, (uint tex, int w, int h)> resolve,
|
|
uint tileId, float visibleH, bool fromBottom, bool isFill)
|
|
{
|
|
if (tileId == 0 || visibleH <= 0f) return;
|
|
var (tex, _, _) = resolve(tileId);
|
|
if (tex == 0) return;
|
|
float w = Width, h = Height;
|
|
if (visibleH > h) visibleH = h;
|
|
float frac = h > 0f ? visibleH / h : 0f;
|
|
// Bottom-up fill: bottom of the rect AND bottom of the sprite (v in [1-frac, 1]).
|
|
// Top-down / track: top of the rect AND top of the sprite (v in [0, frac]).
|
|
float y = isFill && fromBottom ? h - visibleH : 0f;
|
|
float v0 = isFill && fromBottom ? 1f - frac : 0f;
|
|
float v1 = isFill && fromBottom ? 1f : frac;
|
|
ctx.DrawSprite(tex, 0f, y, w, visibleH, 0f, v0, 1f, v1, System.Numerics.Vector4.One);
|
|
}
|
|
|
|
/// <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 nativeW, float h, float clipW)
|
|
{
|
|
if (tex == 0 || pieceW <= 0f || nativeW <= 0f) return;
|
|
float visibleW = MathF.Min(pieceW, clipW - pieceX);
|
|
if (visibleW <= 0f) return;
|
|
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);
|
|
}
|
|
}
|