fix(ui): render live component counts

Port retail text-surface clipping so the 15px component count field remains visible under the 16px DAT font. Cover SetStackSize decrements and final component removal while preserving desired restock values.

Co-authored-by: OpenAI Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-15 18:11:02 +02:00
parent a1175e6aac
commit 0527325b25
7 changed files with 217 additions and 36 deletions

View file

@ -0,0 +1,51 @@
namespace AcDream.App.Rendering;
/// <summary>
/// Clips one screen-space textured quad and remaps its UV rectangle to the surviving
/// pixels. Shared by retained DAT sprites and fallback bitmap-font glyphs so every UI
/// draw path applies the same edge semantics.
/// </summary>
internal static class QuadClipper
{
public static bool TryClip(
float clipLeft,
float clipTop,
float clipRight,
float clipBottom,
ref float x,
ref float y,
ref float w,
ref float h,
ref float u0,
ref float v0,
ref float u1,
ref float v1)
{
if (clipRight <= clipLeft || clipBottom <= clipTop || w <= 0f || h <= 0f)
return false;
float left = MathF.Max(x, clipLeft);
float top = MathF.Max(y, clipTop);
float right = MathF.Min(x + w, clipRight);
float bottom = MathF.Min(y + h, clipBottom);
if (right <= left || bottom <= top)
return false;
float oldX = x, oldY = y, oldW = w, oldH = h;
float oldU0 = u0, oldV0 = v0, oldU1 = u1, oldV1 = v1;
float tx0 = (left - oldX) / oldW;
float tx1 = (right - oldX) / oldW;
float ty0 = (top - oldY) / oldH;
float ty1 = (bottom - oldY) / oldH;
x = left;
y = top;
w = right - left;
h = bottom - top;
u0 = oldU0 + (oldU1 - oldU0) * tx0;
u1 = oldU0 + (oldU1 - oldU0) * tx1;
v0 = oldV0 + (oldV1 - oldV0) * ty0;
v1 = oldV0 + (oldV1 - oldV0) * ty1;
return true;
}
}

View file

@ -144,6 +144,41 @@ public sealed unsafe class TextRenderer : IDisposable
/// typographic block. Handles '\n' as a line break.
/// </summary>
public void DrawString(BitmapFont font, string text, float x, float y, Vector4 color)
=> DrawStringCore(
font, text, x, y, color,
clip: false, 0f, 0f, 0f, 0f);
/// <summary>
/// Draw a bitmap-font string clipped to an absolute screen-space rectangle.
/// The retained UI uses this overload when a text element intersects its authored
/// surface edge. Retail <c>UIElement_Text::DrawSelf @ 0x00467AA0</c> clips the
/// individual glyph blits instead of discarding the whole line.
/// </summary>
internal void DrawStringClipped(
BitmapFont font,
string text,
float x,
float y,
Vector4 color,
float clipLeft,
float clipTop,
float clipRight,
float clipBottom)
=> DrawStringCore(
font, text, x, y, color,
clip: true, clipLeft, clipTop, clipRight, clipBottom);
private void DrawStringCore(
BitmapFont font,
string text,
float x,
float y,
Vector4 color,
bool clip,
float clipLeft,
float clipTop,
float clipRight,
float clipBottom)
{
float cursorX = x;
// The caller provides top-y; shift to baseline for glyph offset math.
@ -170,11 +205,19 @@ public sealed unsafe class TextRenderer : IDisposable
float gy = baseline + g.OffsetY;
float gw = g.Width;
float gh = g.Height;
float u0 = g.UvMinX;
float v0 = g.UvMinY;
float u1 = g.UvMaxX;
float v1 = g.UvMaxY;
if (gw > 0 && gh > 0)
if (gw > 0 && gh > 0
&& (!clip || QuadClipper.TryClip(
clipLeft, clipTop, clipRight, clipBottom,
ref gx, ref gy, ref gw, ref gh,
ref u0, ref v0, ref u1, ref v1)))
{
if (OverlayMode) { AppendQuad(_overlayTextBuf, gx, gy, gw, gh, g.UvMinX, g.UvMinY, g.UvMaxX, g.UvMaxY, color); _overlayTextVerts += 6; }
else { AppendQuad(_textBuf, gx, gy, gw, gh, g.UvMinX, g.UvMinY, g.UvMaxX, g.UvMaxY, color); _textVerts += 6; }
if (OverlayMode) { AppendQuad(_overlayTextBuf, gx, gy, gw, gh, u0, v0, u1, v1, color); _overlayTextVerts += 6; }
else { AppendQuad(_textBuf, gx, gy, gw, gh, u0, v0, u1, v1, color); _textVerts += 6; }
}
cursorX += g.Advance;
}

View file

@ -18,34 +18,10 @@ internal readonly record struct UiClipRect(float Left, float Top, float Right, f
UiClipRect clip,
ref float x, ref float y, ref float w, ref float h,
ref float u0, ref float v0, ref float u1, ref float v1)
{
if (clip.IsEmpty || w <= 0f || h <= 0f)
return false;
float left = MathF.Max(x, clip.Left);
float top = MathF.Max(y, clip.Top);
float right = MathF.Min(x + w, clip.Right);
float bottom = MathF.Min(y + h, clip.Bottom);
if (right <= left || bottom <= top)
return false;
float oldX = x, oldY = y, oldW = w, oldH = h;
float oldU0 = u0, oldV0 = v0, oldU1 = u1, oldV1 = v1;
float tx0 = (left - oldX) / oldW;
float tx1 = (right - oldX) / oldW;
float ty0 = (top - oldY) / oldH;
float ty1 = (bottom - oldY) / oldH;
x = left;
y = top;
w = right - left;
h = bottom - top;
u0 = oldU0 + (oldU1 - oldU0) * tx0;
u1 = oldU0 + (oldU1 - oldU0) * tx1;
v0 = oldV0 + (oldV1 - oldV0) * ty0;
v1 = oldV0 + (oldV1 - oldV0) * ty1;
return true;
}
=> QuadClipper.TryClip(
clip.Left, clip.Top, clip.Right, clip.Bottom,
ref x, ref y, ref w, ref h,
ref u0, ref v0, ref u1, ref v1);
}
/// <summary>
@ -210,7 +186,16 @@ public sealed class UiRenderContext
{
var f = font ?? DefaultFont;
if (f is null) return;
TextRenderer.DrawString(f, text, _current.X + x, _current.Y + y, color);
float screenX = _current.X + x;
float screenY = _current.Y + y;
if (_clip is { } clip)
{
TextRenderer.DrawStringClipped(
f, text, screenX, screenY, color,
clip.Left, clip.Top, clip.Right, clip.Bottom);
return;
}
TextRenderer.DrawString(f, text, screenX, screenY, color);
}
/// <summary>

View file

@ -336,6 +336,25 @@ public sealed class UiText : UiElement, IUiDatStateful
}
private void DrawText(UiRenderContext ctx)
{
// Retail UIElement_Text::DrawSelf @ 0x00467AA0 receives the text element's
// visible surface as arg3 and clips each glyph blit to that rectangle. This is
// observable in LayoutDesc 0x21000033: the owned component count is a 15px-high
// text element using a 16px DAT font, so rejecting a partially visible line makes
// the value disappear entirely. The shared render context clips both DAT and
// bitmap glyph quads and composes this bound with any list/window ancestor clip.
ctx.PushClip(0f, 0f, Width, Height);
try
{
DrawClippedText(ctx);
}
finally
{
ctx.PopClip();
}
}
private void DrawClippedText(UiRenderContext ctx)
{
// Static centered single-line mode (vitals cur/max numbers etc.): draw the first
// line centered H+V (or H+Top/Bottom per VerticalJustify) with the SAME formula
@ -451,7 +470,7 @@ public sealed class UiText : UiElement, IUiDatStateful
for (int i = 0; i < lines.Count; i++)
{
float y = baseY + i * lh;
if (y < top || y + lh > bottom) continue; // whole-line vertical clip (no scissor yet)
if (!LineIntersectsViewport(y, lh, top, bottom)) continue;
string text = lines[i].Text;
float lineX = HorizontalOffset(text, datFont, bitmapFont);
@ -489,6 +508,19 @@ public sealed class UiText : UiElement, IUiDatStateful
}
}
/// <summary>
/// True when any vertical portion of a line intersects a text viewport. Retail
/// clips the glyphs at the viewport edge; it does not require the full line box to fit.
/// </summary>
internal static bool LineIntersectsViewport(
float lineTop,
float lineHeight,
float viewportTop,
float viewportBottom)
=> lineHeight > 0f
&& lineTop < viewportBottom
&& lineTop + lineHeight > viewportTop;
private float HorizontalOffset(string text, UiDatFont? datFont, BitmapFont? bitmapFont)
{
float width = datFont is not null