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 5a32b521..2c85585d 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 @@ -202,6 +202,9 @@ The two top-level tabs are not `UIElement_Button` instances. They resolve from base `0x1000043A` as `UIElement_Text` (type 12), with state 11 `Closed` and state 12 `Open`. Both states set `PassToChildren`; the three child chrome elements carry the left/center/right Closed/Open RenderSurface media. +Their direct string property `0x17` resolves through the installed string +tables to `Spellbook` (string id `49867858`) and `Components` (string id +`79360818`). Retail state and text construction used by these tabs: @@ -227,8 +230,21 @@ UIElement::SetState @ 0x00464E70 child.SetState(stateId) diff old/new effective properties call OnSetAttribute for changed properties + +Retained rendering reduction: + draw the UIElement_Text background + draw its three stateful chrome children + draw the authored glyph content as the parent's foreground ``` +Retail `UIElement_Text::DrawSelf @ 0x00467AA0` owns the glyphs while the child +regions own the state chrome. acdream's sprites and DAT glyphs share one +submission-ordered batch, so submitting the glyphs in the parent's foreground +pass preserves that same visible composition; submitting the entire parent +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. + 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/UiText.cs b/src/AcDream.App/UI/UiText.cs index 26a64f69..966ca3be 100644 --- a/src/AcDream.App/UI/UiText.cs +++ b/src/AcDream.App/UI/UiText.cs @@ -165,6 +165,7 @@ public sealed class UiText : UiElement, IUiDatStateful private ElementInfo? _datInfo; private uint _activeRetailStateId = UiStateInfo.DirectStateId; private string _activeDatStateName = ""; + private bool _drawTextAfterChildren; // ── Selection state ────────────────────────────────────────────────── private Pos? _selAnchor; // where the drag started @@ -205,10 +206,25 @@ public sealed class UiText : UiElement, IUiDatStateful internal void ConfigureDatState(ElementInfo info) { _datInfo = info; + // 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 + // remains the foreground. Our retained renderer submits parent and child + // sprites into one painter-ordered batch, so submit only the glyph content + // in the parent's foreground pass after those chrome children. + _drawTextAfterChildren = false; + foreach (UiStateInfo state in info.States.Values) + { + if (!state.PassToChildren) continue; + _drawTextAfterChildren = true; + break; + } _activeRetailStateId = info.EffectiveDefaultStateId(); ApplyDatState(_activeRetailStateId, propagate: false); } + internal bool DrawTextAfterChildren => _drawTextAfterChildren; + public bool TrySetRetailState(uint stateId) => ApplyDatState(stateId, propagate: true); @@ -307,6 +323,18 @@ public sealed class UiText : UiElement, IUiDatStateful // submitted first → text on top. ctx.DrawFill(0, 0, Width, Height, BackgroundColor); + if (!_drawTextAfterChildren) + DrawText(ctx); + } + + protected override void OnDrawAfterChildren(UiRenderContext ctx) + { + if (_drawTextAfterChildren) + DrawText(ctx); + } + + private void DrawText(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 // UIElement_Meter used for its label, then skip the scroll/selection machinery entirely. diff --git a/tests/AcDream.App.Tests/UI/Layout/SpellbookRowStyleTests.cs b/tests/AcDream.App.Tests/UI/Layout/SpellbookRowStyleTests.cs index ff2c31f0..5adbab21 100644 --- a/tests/AcDream.App.Tests/UI/Layout/SpellbookRowStyleTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/SpellbookRowStyleTests.cs @@ -29,4 +29,36 @@ public sealed class SpellbookRowStyleTests Assert.Equal(0x06001396u, style.BackgroundSprite); Assert.Equal(0x06001397u, style.SelectedSprite); } + + [Fact] + public void InstalledDat_ResolvesAuthoredSpellbookTabCaptions() + { + string datDir = System.Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR") + ?? Path.Combine(System.Environment.GetFolderPath(System.Environment.SpecialFolder.UserProfile), + "Documents", "Asheron's Call"); + if (!Directory.Exists(datDir)) return; + + using var dats = new DatCollection(datDir, DatAccessType.Read); + ElementInfo root = Assert.IsType(LayoutImporter.ImportInfos( + dats, SpellbookWindowController.LayoutId, SpellbookWindowController.RootId)); + var strings = new DatStringResolver(dats); + + Assert.Equal("Spellbook", ResolveCaption(SpellbookWindowController.SpellTabId)); + Assert.Equal("Components", ResolveCaption(SpellbookWindowController.ComponentTabId)); + + string? ResolveCaption(uint id) + { + ElementInfo node = Assert.IsType(Find(root, id)); + Assert.True(node.TryGetEffectiveProperty(0x17u, out UiPropertyValue label)); + return strings.Resolve(label.StringInfoValue); + } + } + + private static ElementInfo? Find(ElementInfo root, uint id) + { + if (root.Id == id) return root; + foreach (ElementInfo child in root.Children) + if (Find(child, id) is { } found) return found; + return null; + } } diff --git a/tests/AcDream.App.Tests/UI/Layout/SpellbookWindowControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/SpellbookWindowControllerTests.cs index 37cf47f3..4918455b 100644 --- a/tests/AcDream.App.Tests/UI/Layout/SpellbookWindowControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/SpellbookWindowControllerTests.cs @@ -26,7 +26,16 @@ public sealed class SpellbookWindowControllerTests [Fact] public void RetailFixture_BindsTextTabs_AndPropagatesOpenClosedState() { - ImportedLayout layout = FixtureLoader.LoadSpellbook(); + ImportedLayout layout = LayoutImporter.Build( + FixtureLoader.LoadSpellbookInfos(), + _ => (0u, 0, 0), + datFont: null, + stringResolve: value => value.StringId switch + { + 49867858u => "Spellbook", + 79360818u => "Components", + _ => null, + }); var closed = new List(); using SpellbookWindowController? controller = Bind( layout, new Spellbook(), close: () => closed.Add(1u)); @@ -37,6 +46,11 @@ public sealed class SpellbookWindowControllerTests UiElement spellPage = Assert.IsAssignableFrom(layout.FindElement(SpellbookWindowController.SpellPageId)); UiElement componentPage = Assert.IsAssignableFrom(layout.FindElement(SpellbookWindowController.ComponentPageId)); + Assert.Equal("Spellbook", Assert.Single(spellTab.LinesProvider()).Text); + Assert.Equal("Components", Assert.Single(componentTab.LinesProvider()).Text); + Assert.True(spellTab.DrawTextAfterChildren); + Assert.True(componentTab.DrawTextAfterChildren); + Assert.Equal(RetailUiStateIds.Open, spellTab.ActiveRetailStateId); Assert.Equal(RetailUiStateIds.Closed, componentTab.ActiveRetailStateId); Assert.True(spellPage.Visible);