From 0527325b25b238f9754ddae0df6da03798f7158f Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 15 Jul 2026 18:11:02 +0200 Subject: [PATCH] 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 --- ...-retail-magic-ui-and-casting-pseudocode.md | 14 +++++ src/AcDream.App/Rendering/QuadClipper.cs | 51 +++++++++++++++++++ src/AcDream.App/Rendering/TextRenderer.cs | 49 ++++++++++++++++-- src/AcDream.App/UI/UiRenderContext.cs | 43 +++++----------- src/AcDream.App/UI/UiText.cs | 34 ++++++++++++- .../Layout/SpellbookWindowControllerTests.cs | 30 +++++++++-- tests/AcDream.App.Tests/UI/UiTextTests.cs | 32 ++++++++++++ 7 files changed, 217 insertions(+), 36 deletions(-) create mode 100644 src/AcDream.App/Rendering/QuadClipper.cs diff --git a/docs/research/2026-07-15-retail-magic-ui-and-casting-pseudocode.md b/docs/research/2026-07-15-retail-magic-ui-and-casting-pseudocode.md index 2065ddcd..4a0cb683 100644 --- a/docs/research/2026-07-15-retail-magic-ui-and-casting-pseudocode.md +++ b/docs/research/2026-07-15-retail-magic-ui-and-casting-pseudocode.md @@ -360,6 +360,14 @@ UpdateComponents(change): row[0x1000046B].inputFilter = NumberInputFilter 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): if selection changed: 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, 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 Named-retail anchors: diff --git a/src/AcDream.App/Rendering/QuadClipper.cs b/src/AcDream.App/Rendering/QuadClipper.cs new file mode 100644 index 00000000..8005e70e --- /dev/null +++ b/src/AcDream.App/Rendering/QuadClipper.cs @@ -0,0 +1,51 @@ +namespace AcDream.App.Rendering; + +/// +/// 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. +/// +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; + } +} diff --git a/src/AcDream.App/Rendering/TextRenderer.cs b/src/AcDream.App/Rendering/TextRenderer.cs index 88592057..8c3a969d 100644 --- a/src/AcDream.App/Rendering/TextRenderer.cs +++ b/src/AcDream.App/Rendering/TextRenderer.cs @@ -144,6 +144,41 @@ public sealed unsafe class TextRenderer : IDisposable /// typographic block. Handles '\n' as a line break. /// 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); + + /// + /// 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 UIElement_Text::DrawSelf @ 0x00467AA0 clips the + /// individual glyph blits instead of discarding the whole line. + /// + 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; } diff --git a/src/AcDream.App/UI/UiRenderContext.cs b/src/AcDream.App/UI/UiRenderContext.cs index ac576533..9c191d40 100644 --- a/src/AcDream.App/UI/UiRenderContext.cs +++ b/src/AcDream.App/UI/UiRenderContext.cs @@ -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); } /// @@ -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); } /// diff --git a/src/AcDream.App/UI/UiText.cs b/src/AcDream.App/UI/UiText.cs index f17d2b43..fb54c08d 100644 --- a/src/AcDream.App/UI/UiText.cs +++ b/src/AcDream.App/UI/UiText.cs @@ -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 } } + /// + /// 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. + /// + 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 diff --git a/tests/AcDream.App.Tests/UI/Layout/SpellbookWindowControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/SpellbookWindowControllerTests.cs index 4123490a..2854435f 100644 --- a/tests/AcDream.App.Tests/UI/Layout/SpellbookWindowControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/SpellbookWindowControllerTests.cs @@ -323,9 +323,12 @@ public sealed class SpellbookWindowControllerTests Assert.Equal("Copper Scarab", Assert.Single( Assert.IsType(copper.Content.FindElement( ComponentBookTemplateFactory.NameId)).LinesProvider()).Text); - Assert.Equal("2", Assert.Single( - Assert.IsType(copper.Content.FindElement( - ComponentBookTemplateFactory.OwnedCountId)).LinesProvider()).Text); + UiText ownedCount = Assert.IsType(copper.Content.FindElement( + ComponentBookTemplateFactory.OwnedCountId)); + 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(copper.Content.FindElement( ComponentBookTemplateFactory.DesiredCountId)); Assert.Equal((224f, 15f, 60f, 15f), @@ -361,6 +364,27 @@ public sealed class SpellbookWindowControllerTests Assert.False(copper.Selected); selection.Select(0xDEADBEEFu, SelectionChangeSource.World); 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(list.GetItem(1)); + Assert.Equal("1", Assert.Single( + Assert.IsType(updatedCopper.Content.FindElement( + ComponentBookTemplateFactory.OwnedCountId)).LinesProvider()).Text); + Assert.Equal("21", Assert.IsType(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(), + slot => slot.EntryId == 100u); } private static SpellbookWindowController? Bind( diff --git a/tests/AcDream.App.Tests/UI/UiTextTests.cs b/tests/AcDream.App.Tests/UI/UiTextTests.cs index ba9b365f..4ad1a966 100644 --- a/tests/AcDream.App.Tests/UI/UiTextTests.cs +++ b/tests/AcDream.App.Tests/UI/UiTextTests.cs @@ -277,4 +277,36 @@ public class UiTextTests 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)); + } }