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));
+ }
}