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

@ -360,6 +360,14 @@ UpdateComponents(change):
row[0x1000046B].inputFilter = NumberInputFilter row[0x1000046B].inputFilter = NumberInputFilter
insertion++ insertion++
SyncComponentRegionWithData(row, componentData):
row[0x1000046A].text = componentData.numItems
UIElement_Text::DrawSelf(rowText, visibleSurface):
position each glyph line using CalcJustification
draw intersecting glyphs clipped to visibleSurface
do not discard a line merely because its line box exceeds the surface
ListenToElementMessage(message): ListenToElementMessage(message):
if selection changed: if selection changed:
if selected entry is a category: if selected entry is a category:
@ -388,6 +396,12 @@ equivalent of `AddItemFromTemplateList`. Controllers bind only the live icon,
texts, selection, and edit callbacks. They do not duplicate the row sprites, texts, selection, and edit callbacks. They do not duplicate the row sprites,
fonts, or geometry. fonts, or geometry.
The owned-count child is 15 pixels high while installed font `0x40000000`
advances 16 pixels. Retail's `UIElement_Text::DrawSelf @ 0x00467AA0` clips the
glyph blits against the supplied visible surface, so the count remains visible.
A retained renderer that accepts only fully contained lines suppresses this
field and is not retail-conformant.
### Spellbook row, selection, filtering, dragging, and deletion ### Spellbook row, selection, filtering, dragging, and deletion
Named-retail anchors: Named-retail anchors:

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. /// typographic block. Handles '\n' as a line break.
/// </summary> /// </summary>
public void DrawString(BitmapFont font, string text, float x, float y, Vector4 color) 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; float cursorX = x;
// The caller provides top-y; shift to baseline for glyph offset math. // 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 gy = baseline + g.OffsetY;
float gw = g.Width; float gw = g.Width;
float gh = g.Height; 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; } if (OverlayMode) { AppendQuad(_overlayTextBuf, gx, gy, gw, gh, u0, v0, u1, v1, color); _overlayTextVerts += 6; }
else { AppendQuad(_textBuf, gx, gy, gw, gh, g.UvMinX, g.UvMinY, g.UvMaxX, g.UvMaxY, color); _textVerts += 6; } else { AppendQuad(_textBuf, gx, gy, gw, gh, u0, v0, u1, v1, color); _textVerts += 6; }
} }
cursorX += g.Advance; cursorX += g.Advance;
} }

View file

@ -18,34 +18,10 @@ internal readonly record struct UiClipRect(float Left, float Top, float Right, f
UiClipRect clip, UiClipRect clip,
ref float x, ref float y, ref float w, ref float h, ref float x, ref float y, ref float w, ref float h,
ref float u0, ref float v0, ref float u1, ref float v1) ref float u0, ref float v0, ref float u1, ref float v1)
{ => QuadClipper.TryClip(
if (clip.IsEmpty || w <= 0f || h <= 0f) clip.Left, clip.Top, clip.Right, clip.Bottom,
return false; ref x, ref y, ref w, ref h,
ref u0, ref v0, ref u1, ref v1);
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;
}
} }
/// <summary> /// <summary>
@ -210,7 +186,16 @@ public sealed class UiRenderContext
{ {
var f = font ?? DefaultFont; var f = font ?? DefaultFont;
if (f is null) return; 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> /// <summary>

View file

@ -336,6 +336,25 @@ public sealed class UiText : UiElement, IUiDatStateful
} }
private void DrawText(UiRenderContext ctx) 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 // 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 // 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++) for (int i = 0; i < lines.Count; i++)
{ {
float y = baseY + i * lh; 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; string text = lines[i].Text;
float lineX = HorizontalOffset(text, datFont, bitmapFont); 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) private float HorizontalOffset(string text, UiDatFont? datFont, BitmapFont? bitmapFont)
{ {
float width = datFont is not null float width = datFont is not null

View file

@ -323,9 +323,12 @@ public sealed class SpellbookWindowControllerTests
Assert.Equal("Copper Scarab", Assert.Single( Assert.Equal("Copper Scarab", Assert.Single(
Assert.IsType<UiText>(copper.Content.FindElement( Assert.IsType<UiText>(copper.Content.FindElement(
ComponentBookTemplateFactory.NameId)).LinesProvider()).Text); ComponentBookTemplateFactory.NameId)).LinesProvider()).Text);
Assert.Equal("2", Assert.Single( UiText ownedCount = Assert.IsType<UiText>(copper.Content.FindElement(
Assert.IsType<UiText>(copper.Content.FindElement( ComponentBookTemplateFactory.OwnedCountId));
ComponentBookTemplateFactory.OwnedCountId)).LinesProvider()).Text); Assert.Equal((224f, 0f, 60f, 15f),
(ownedCount.Left, ownedCount.Top, ownedCount.Width, ownedCount.Height));
Assert.False(ownedCount.OneLine);
Assert.Equal("2", Assert.Single(ownedCount.LinesProvider()).Text);
UiField desiredField = Assert.IsType<UiField>(copper.Content.FindElement( UiField desiredField = Assert.IsType<UiField>(copper.Content.FindElement(
ComponentBookTemplateFactory.DesiredCountId)); ComponentBookTemplateFactory.DesiredCountId));
Assert.Equal((224f, 15f, 60f, 15f), Assert.Equal((224f, 15f, 60f, 15f),
@ -361,6 +364,27 @@ public sealed class SpellbookWindowControllerTests
Assert.False(copper.Selected); Assert.False(copper.Selected);
selection.Select(0xDEADBEEFu, SelectionChangeSource.World); selection.Select(0xDEADBEEFu, SelectionChangeSource.World);
Assert.False(amaranth.Selected); Assert.False(amaranth.Selected);
// ACE consumes components through SetStackSize (0x0197). The object table
// publishes ObjectUpdated; the controller rebuilds the tracker total on Tick.
Assert.True(objects.UpdateStackSize(10u, stackSize: 1, value: 0));
controller.Tick();
UiTemplateListSlot updatedCopper = Assert.IsType<UiTemplateListSlot>(list.GetItem(1));
Assert.Equal("1", Assert.Single(
Assert.IsType<UiText>(updatedCopper.Content.FindElement(
ComponentBookTemplateFactory.OwnedCountId)).LinesProvider()).Text);
Assert.Equal("21", Assert.IsType<UiField>(updatedCopper.Content.FindElement(
ComponentBookTemplateFactory.DesiredCountId)).Text);
// Consuming the final item removes its object. The row disappears while the
// category remains because Diamond Scarab is still owned.
Assert.True(objects.Remove(10u));
controller.Tick();
Assert.DoesNotContain(
Enumerable.Range(0, list.GetNumUIItems())
.Select(list.GetItem)
.OfType<UiTemplateListSlot>(),
slot => slot.EntryId == 100u);
} }
private static SpellbookWindowController? Bind( private static SpellbookWindowController? Bind(

View file

@ -277,4 +277,36 @@ public class UiTextTests
Assert.Equal(9f, y); Assert.Equal(9f, y);
} }
[Fact]
public void LineIntersectsViewport_PartialLineRemainsDrawable()
{
// Component owned-count boxes are 15px tall while Font 0x40000000
// advances 16px. Retail clips the final pixel instead of dropping the line.
Assert.True(UiText.LineIntersectsViewport(
lineTop: 0f,
lineHeight: 16f,
viewportTop: 0f,
viewportBottom: 15f));
Assert.True(UiText.LineIntersectsViewport(
lineTop: -1f,
lineHeight: 16f,
viewportTop: 0f,
viewportBottom: 15f));
}
[Theory]
[InlineData(-16f, 16f)]
[InlineData(15f, 16f)]
public void LineIntersectsViewport_FullyClippedLineIsSkipped(
float lineTop,
float lineHeight)
{
Assert.False(UiText.LineIntersectsViewport(
lineTop,
lineHeight,
viewportTop: 0f,
viewportBottom: 15f));
}
} }