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 276aec96..5a32b521 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 @@ -269,12 +269,23 @@ row background 0x06001396 selected overlay child 0x10000342 -> 0x06001397 label child 0x10000344 (x=42, width=230) icon child 0x1000033B (x=0, 32 x 32) +school/level filters Type-1, 90 x 14; label property on parent +filter indicator child 0x10000328 (x=0, y=1, 13 x 13) +filter Normal/Highlight art 0x06004D15 / 0x06004D17 delete button 0x100002A5 +delete label child 0x100002A6 (100 x 32, centered, font 0x40000001) ``` The row's authored background contains the narrow horizontal separator visible between entries. It is not an invented procedural line. The selection surface is a separate overlay, so selecting a spell does not replace its icon or label. +The filter controls are ordinary `UIElement_Button` objects whose states have +`PassToChildren`; the green selected face is therefore the `Highlight` media on +their 13-pixel child, while the parent owns the label and the 90-pixel hit box. +The label begins four pixels after that child. Conversely, the Delete button +owns its frame media but its caption/font live in a full-size text child. A +retained leaf must lift those authored child roles; discarding all button +children loses both the filter indicator and the Delete caption. ```text AddSpell(spellID): diff --git a/src/AcDream.App/UI/Layout/DatWidgetFactory.cs b/src/AcDream.App/UI/Layout/DatWidgetFactory.cs index f814d640..187f8c31 100644 --- a/src/AcDream.App/UI/Layout/DatWidgetFactory.cs +++ b/src/AcDream.App/UI/Layout/DatWidgetFactory.cs @@ -76,9 +76,9 @@ public static class DatWidgetFactory UiElement e = info.Type switch { UiRadar.RetailClassId => new UiRadar(), // gmRadarUI (Register 0x004D8B80) - 1 => BuildButton(info, resolve, elementFont, stringResolve), // UIElement_Button + 1 => BuildButton(info, resolve, elementFont, fontResolve, stringResolve), // UIElement_Button EffectsIndicatorController.RetailClassId => BuildButton( - info, resolve, elementFont, stringResolve), // gmUIElement_EffectsIndicator + info, resolve, elementFont, fontResolve, stringResolve), // gmUIElement_EffectsIndicator 6 => new UiMenu(), // UIElement_Menu (reg :120163) 7 => BuildMeter(info, resolve, elementFont), // UIElement_Meter 0xD => new UiViewport(), // UIElement_Viewport — 3-D mini-scene blit leaf @@ -86,7 +86,8 @@ public static class DatWidgetFactory 12 => BuildText(info, resolve, elementFont, stringResolve), // UIElement_Text 0x13 => new UiDialogRoot(), // ConfirmationDialog 0x10000031u => new UiItemList(resolve), // UIElement_ItemList — toolbar/inventory/paperdoll slots - 0x10000035u => BuildCheckbox(info, resolve, elementFont, stringResolve), // UIOption_Checkbox + 0x10000035u => BuildCheckbox( + info, resolve, elementFont, fontResolve, stringResolve), // UIOption_Checkbox _ => new UiDatElement(info, resolve), // generic fallback (incl. Type 3 chrome/containers) }; @@ -544,14 +545,63 @@ public static class DatWidgetFactory ElementInfo info, Func resolve, UiDatFont? elementFont, + Func? fontResolve, Func? stringResolve) - => new(info, resolve) + { + // UIElement_Button propagates its retail state into authored children. + // Spellbook school/level filters are Type-1 buttons with no parent media: + // their 13x13 child carries Normal/Highlight art. Keep that child as the + // face of this retained leaf instead of consuming and losing it. + ElementInfo? face = info.StateMedia.Count == 0 + ? FindStatefulFaceChild(info) + : null; + + string? label = ResolveAuthoredString(info, stringResolve); + ElementInfo labelInfo = info; + if (label is null) { - Label = ResolveAuthoredString(info, stringResolve), - LabelFont = elementFont, - LabelColor = info.FontColor ?? System.Numerics.Vector4.One, + // Normal retail buttons such as the spellbook Delete control keep + // their caption in a full-size UIElement_Text child. UiButton is a + // retained leaf, so lift that authored text/font/color onto the leaf. + foreach (ElementInfo child in info.Children.Where(child => child.Type == 12u)) + { + label = ResolveAuthoredString(child, stringResolve); + if (label is null) continue; + labelInfo = child; + break; + } + } + + UiDatFont? labelFont = elementFont; + if (labelInfo.FontDid != 0u && fontResolve is not null) + labelFont = fontResolve(labelInfo.FontDid) ?? elementFont; + + var button = new UiButton(info, resolve, face) + { + Label = label, + LabelFont = labelFont, + LabelColor = labelInfo.FontColor ?? info.FontColor + ?? System.Numerics.Vector4.One, }; + if (face is not null) + { + button.FaceLeft = face.X; + button.FaceTop = face.Y; + button.FaceWidth = face.Width; + button.FaceHeight = face.Height; + button.LabelAlign = UiButton.LabelAlignment.Left; + button.LabelOffsetX = face.X + face.Width + 4f; + } + else if (!ReferenceEquals(labelInfo, info) && labelInfo.HJustify == HJustify.Left) + { + button.LabelAlign = UiButton.LabelAlignment.Left; + button.LabelOffsetX = labelInfo.X; + } + + return button; + } + /// /// Retail UIOption_Checkbox is a UIElement_Button whose visible face is its /// authored indicator child. Its label lives on the option object rather than @@ -561,13 +611,16 @@ public static class DatWidgetFactory ElementInfo info, Func resolve, UiDatFont? elementFont, + Func? fontResolve, Func? stringResolve) { - ElementInfo? indicator = info.Children.FirstOrDefault(child => DefaultImage(child) != 0u); + ElementInfo? indicator = FindStatefulFaceChild(info); var button = new UiButton(info, resolve, indicator) { Label = ResolveAuthoredString(info, stringResolve), - LabelFont = elementFont, + LabelFont = info.FontDid != 0u && fontResolve is not null + ? fontResolve(info.FontDid) ?? elementFont + : elementFont, LabelColor = info.FontColor ?? System.Numerics.Vector4.One, LabelAlign = UiButton.LabelAlignment.Left, }; @@ -584,6 +637,13 @@ public static class DatWidgetFactory return button; } + private static ElementInfo? FindStatefulFaceChild(ElementInfo info) + => info.Children.FirstOrDefault(child => + child.StateMedia.Count != 0 + && child.StateMedia.Keys.Any(childState => + info.States.Values.Any(parentState => + string.Equals(parentState.Name, childState, StringComparison.Ordinal)))); + private static string? ResolveAuthoredString( ElementInfo info, Func? stringResolve) diff --git a/tests/AcDream.App.Tests/UI/Layout/SpellbookWindowControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/SpellbookWindowControllerTests.cs index c7415378..37cf47f3 100644 --- a/tests/AcDream.App.Tests/UI/Layout/SpellbookWindowControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/SpellbookWindowControllerTests.cs @@ -57,6 +57,39 @@ public sealed class SpellbookWindowControllerTests Assert.Empty(closed); } + [Fact] + public void RetailFixture_BindsFilterIndicatorChildren_AndDeleteCaptionChild() + { + ImportedLayout layout = LayoutImporter.Build( + FixtureLoader.LoadSpellbookInfos(), + _ => (0u, 0, 0), + datFont: null, + stringResolve: value => value.StringId == 164868556u ? "Delete" : "Filter"); + + UiButton creature = Assert.IsType(layout.FindElement(0x10000298u)); + Assert.Equal((0f, 1f, 13f, 13f), + (creature.FaceLeft, creature.FaceTop, creature.FaceWidth, creature.FaceHeight)); + Assert.Equal(UiButton.LabelAlignment.Left, creature.LabelAlign); + Assert.Equal(17f, creature.LabelOffsetX); + Assert.Equal("Filter", creature.Label); + Assert.True(creature.ToggleBehavior); + + creature.Selected = true; + Assert.Equal("Highlight", creature.ActiveState); + Assert.Equal(UiButtonStateMachine.Highlight, creature.ActiveRetailStateId); + + UiButton levelSix = Assert.IsType(layout.FindElement(0x100002A1u)); + Assert.Equal(UiButton.LabelAlignment.Left, levelSix.LabelAlign); + Assert.Equal(17f, levelSix.LabelOffsetX); + + UiButton delete = Assert.IsType( + layout.FindElement(SpellbookWindowController.DeleteButtonId)); + Assert.Equal("Delete", delete.Label); + Assert.Equal(UiButton.LabelAlignment.Center, delete.LabelAlign); + Assert.Equal((187f, 61f, 100f, 32f), + (delete.Left, delete.Top, delete.FaceWidth, delete.FaceHeight)); + } + [Fact] public void LearnedSpells_AreAuthoredRows_InDisplayOrder_WithSelectionAndDragPayload() {