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 2c85585d..8cbec9f4 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
@@ -221,6 +221,16 @@ UIElement_Text::OnSetAttribute @ 0x0046A640
SetOneLine(value)
// absent 0x20 therefore remains multi-line; do not force it true
+UIElement_Text::CalcJustification @ 0x00467260
+ available = max(scrollableExtent, surfaceExtent) - leadingMargin - trailingMargin
+ if justification is Center:
+ offset = available / 2 - contentExtent / 2
+ else if justification is Right or Bottom:
+ offset = available - contentExtent
+ else:
+ offset = 0
+ return leadingMargin + offset
+
UIElement::SetState @ 0x00464E70
next = desc.AccessStateDesc(stateId)
if next != current:
@@ -245,6 +255,12 @@ before its retained children lets the center chrome overpaint the caption.
This rule is derived structurally from `PassToChildren`, not from the two
spellbook element ids.
+Layout `0x21000034` supplies the 300 by 600 content surface, including its tab
+bar and close control, but not the enclosing movable-window border. Production
+mounts it through the same shared retained nine-slice frame used by the
+Attributes/Skills window (`0x2100002E`). Treating the root as already framed
+removes the gold outer border.
+
The Magic-combat favorite tab captions are authored text (`I` through `VIII`)
and intentionally omit property `0x20`. Their small rectangles still render
because retail begins with zero text margins. A generic four-pixel inset on
diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs
index 96d4f42b..807c08f4 100644
--- a/src/AcDream.App/UI/RetailUiRuntime.cs
+++ b/src/AcDream.App/UI/RetailUiRuntime.cs
@@ -764,7 +764,10 @@ public sealed class RetailUiRuntime : IDisposable
new RetailWindowFrame.Options
{
WindowName = WindowNames.Spellbook,
- Chrome = RetailWindowChrome.Imported,
+ // LayoutDesc 0x21000034 is the 300x600 content surface. Like the
+ // Attributes/Skills window, retail supplies the outer gold frame
+ // through the shared window chrome rather than this root element.
+ Chrome = RetailWindowChrome.NineSlice,
Left = 18f,
Top = 18f,
Visible = false,
diff --git a/src/AcDream.App/UI/UiText.cs b/src/AcDream.App/UI/UiText.cs
index 966ca3be..f17d2b43 100644
--- a/src/AcDream.App/UI/UiText.cs
+++ b/src/AcDream.App/UI/UiText.cs
@@ -166,6 +166,7 @@ public sealed class UiText : UiElement, IUiDatStateful
private uint _activeRetailStateId = UiStateInfo.DirectStateId;
private string _activeDatStateName = "";
private bool _drawTextAfterChildren;
+ private bool _honorDatVerticalJustification;
// ── Selection state ──────────────────────────────────────────────────
private Pos? _selAnchor; // where the drag started
@@ -206,6 +207,7 @@ public sealed class UiText : UiElement, IUiDatStateful
internal void ConfigureDatState(ElementInfo info)
{
_datInfo = info;
+ _honorDatVerticalJustification = true;
// Retail spellbook tabs are UIElement_Text parents whose Open/Closed
// PassToChildren states drive three authored chrome pieces. In retail's
// software surface those pieces form the tab background while the text
@@ -429,12 +431,18 @@ public sealed class UiText : UiElement, IUiDatStateful
(int)MathF.Floor(innerH),
preserveEnd: true);
- // UiScrollable: ScrollY=0 is TOP/oldest, ScrollY=MaxScroll is BOTTOM/newest.
- // Visual layout: newest at bottom → baseY = bottom - contentH (ScrollY at max).
- // Invert: baseY = bottom - contentH + (MaxScroll - ScrollY).
- // With _pinBottom: ScrollY=MaxScroll → baseY=bottom-contentH → last line ends at bottom. ✓
- // Scrolled to top: ScrollY=0 → baseY=bottom-contentH+MaxScroll=bottom-innerH=top. ✓
- float baseY = bottom - contentH + (Scroll.MaxScroll - Scroll.ScrollY);
+ // Overflow keeps the UiScrollable convention: ScrollY=0 is TOP/oldest and
+ // ScrollY=MaxScroll is BOTTOM/newest. Fitting DAT-authored content instead
+ // uses retail CalcJustification (tabs center their one glyph line); synthesized
+ // transcript widgets retain the established bottom pin.
+ float baseY = ContentBaseY(
+ top,
+ bottom,
+ contentH,
+ Scroll.MaxScroll,
+ Scroll.ScrollY,
+ VerticalJustify,
+ _honorDatVerticalJustification);
_lastBaseY = baseY;
// Normalised selection span (start <= end), if any.
@@ -624,6 +632,35 @@ public sealed class UiText : UiElement, IUiDatStateful
_ => (height - lineHeight) * 0.5f, // Center (default)
};
+ ///
+ /// Resolve the first line's Y coordinate for the normal multi-line path.
+ /// Retail UIElement_Text::CalcJustification @ 0x00467260 applies the
+ /// authored vertical justification when the text content fits the surface;
+ /// overflow continues to use the scroll offset. Synthesized transcript widgets
+ /// retain the historical bottom-pinned behavior by passing
+ /// as .
+ ///
+ public static float ContentBaseY(
+ float top,
+ float bottom,
+ float contentHeight,
+ float maxScroll,
+ float scrollY,
+ VJustify justification,
+ bool honorJustification)
+ {
+ float viewHeight = Math.Max(0f, bottom - top);
+ if (!honorJustification || contentHeight > viewHeight)
+ return bottom - contentHeight + (maxScroll - scrollY);
+
+ return justification switch
+ {
+ VJustify.Top => top,
+ VJustify.Bottom => bottom - contentHeight,
+ _ => top + (viewHeight - contentHeight) * 0.5f,
+ };
+ }
+
/// Order two caret positions so the first is <= the second (by line,
/// then column).
public static (Pos start, Pos end) Order(Pos a, Pos b)
diff --git a/tests/AcDream.App.Tests/UI/Layout/SpellbookWindowControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/SpellbookWindowControllerTests.cs
index 4918455b..9088aa31 100644
--- a/tests/AcDream.App.Tests/UI/Layout/SpellbookWindowControllerTests.cs
+++ b/tests/AcDream.App.Tests/UI/Layout/SpellbookWindowControllerTests.cs
@@ -48,6 +48,9 @@ public sealed class SpellbookWindowControllerTests
Assert.Equal("Spellbook", Assert.Single(spellTab.LinesProvider()).Text);
Assert.Equal("Components", Assert.Single(componentTab.LinesProvider()).Text);
+ Assert.False(spellTab.OneLine);
+ Assert.True(spellTab.Centered);
+ Assert.Equal(VJustify.Center, spellTab.VerticalJustify);
Assert.True(spellTab.DrawTextAfterChildren);
Assert.True(componentTab.DrawTextAfterChildren);
diff --git a/tests/AcDream.App.Tests/UI/UiTextTests.cs b/tests/AcDream.App.Tests/UI/UiTextTests.cs
index 391cc824..ba9b365f 100644
--- a/tests/AcDream.App.Tests/UI/UiTextTests.cs
+++ b/tests/AcDream.App.Tests/UI/UiTextTests.cs
@@ -227,4 +227,54 @@ public class UiTextTests
Assert.Equal(yNoPad, yWithPad); // Center is padding-independent
Assert.Equal((40f - 12f) * 0.5f, yNoPad);
}
+
+ [Theory]
+ [InlineData(VJustify.Top, 0f)]
+ [InlineData(VJustify.Center, 4.5f)]
+ [InlineData(VJustify.Bottom, 9f)]
+ public void ContentBaseY_FittingAuthoredText_UsesRetailVerticalJustification(
+ VJustify justification,
+ float expected)
+ {
+ float y = UiText.ContentBaseY(
+ top: 0f,
+ bottom: 25f,
+ contentHeight: 16f,
+ maxScroll: 0f,
+ scrollY: 0f,
+ justification,
+ honorJustification: true);
+
+ Assert.Equal(expected, y);
+ }
+
+ [Fact]
+ public void ContentBaseY_OverflowingAuthoredText_PreservesScrollPosition()
+ {
+ float y = UiText.ContentBaseY(
+ top: 0f,
+ bottom: 25f,
+ contentHeight: 64f,
+ maxScroll: 39f,
+ scrollY: 12f,
+ VJustify.Center,
+ honorJustification: true);
+
+ Assert.Equal(-12f, y);
+ }
+
+ [Fact]
+ public void ContentBaseY_SynthesizedText_RetainsBottomPinWhenContentFits()
+ {
+ float y = UiText.ContentBaseY(
+ top: 0f,
+ bottom: 25f,
+ contentHeight: 16f,
+ maxScroll: 0f,
+ scrollY: 0f,
+ VJustify.Center,
+ honorJustification: false);
+
+ Assert.Equal(9f, y);
+ }
}