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 8cbec9f4..678fba78 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 @@ -255,6 +255,26 @@ 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 same mechanism applies to the Character window `0x2100002E`. Its +Attributes `0x10000228`, Skills `0x10000229`, and Titles `0x10000538` controls +are also compound Type-12 tabs with Closed/Open state color and three authored +chrome children. The controller reduction is therefore shared: + +```text +BindRetailTab(tab, activate): + tab.Click = activate + +SetActiveTab(active): + for each tab: + tab.SetState(tab == active ? Open(12) : Closed(11)) + // UIElement::SetState propagates the same state to the retained children +``` + +Page visibility remains controller-owned because the three character page +containers carry no active-page visibility state. Tab appearance does not: +injecting replacement sprites or hard-coded caption colors bypasses the exact +same DAT state machine already used by Spellbook/Components. + 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 diff --git a/src/AcDream.App/UI/Layout/CharacterStatController.cs b/src/AcDream.App/UI/Layout/CharacterStatController.cs index 4979eb27..4ac8f737 100644 --- a/src/AcDream.App/UI/Layout/CharacterStatController.cs +++ b/src/AcDream.App/UI/Layout/CharacterStatController.cs @@ -34,9 +34,10 @@ namespace AcDream.App.UI.Layout; /// line-1 label = "Experience To Raise:", line-1 value = raise cost, /// line-2 label = "Unassigned Experience:", line-2 value = UnassignedXp. /// -/// Tab button states: Attributes = "Open", Skills = "Closed", Titles = "Closed". -/// Source: UIStateId.Open (0x0C) / UIStateId.Closed (0x0B) — set on the UiButton children -/// inside each tab group container. +/// Tab states: Attributes = "Open", Skills = "Closed", Titles = "Closed". +/// Source: UIElement::SetState @ 0x00464E70 and UIStateId.Open (0x0C) / +/// UIStateId.Closed (0x0B). The imported Type-12 tab owns its authored font color and +/// propagates the state to its three chrome children through PassToChildren. /// /// Raise buttons: 0x10000246 (×1) + 0x100005EB (×10). State "Normal" = affordable /// (UIStateId.Normal, 0x01), state "Ghosted" = unaffordable or no selection @@ -73,26 +74,10 @@ public static class CharacterStatController public const uint FooterStateCId = 0x10000247u; // State-C footer container (hide inactive) // ── Tab bar element ids (LayoutDesc 0x2100002E root) ──────────────────── - // In the imported tree each tab group (0x10000228/229/538) resolves to a UiText with - // ConsumesDatChildren=true, so the three button children (left-cap, label, right-cap) - // are consumed at import time and not present in the widget tree. - // - // WHY this is NOT fixed in Fix 5 (importer series): - // The Fix 5 meter patch only builds NON-Type-3 children of UiMeter. The tab group children - // are children of UiText (Type 12), not UiMeter (Type 7). Flipping UiText.ConsumesDatChildren - // globally is not safe — the chat transcript UiText and other Type-12 elements also have - // children in some layouts (scroll decorators etc.), and building them as live UiText widgets - // would risk invisible nodes stealing focus/clicks (the exact problem ConsumesDatChildren was - // designed to prevent). Additionally, the page-visibility pass in Bind() depends on - // tab group Children.Count==0 to skip them — adding children to the tab groups would break - // that pass and hide ALL tab content. - // - // Therefore: the controller injects 3 sprite-drawing root children per tab (absolute coords), - // using the known RenderSurface ids from the retail UI layout dump (2026-06-25): - // Closed (inactive) state: left=0x06005D93, center=0x06005D95, right=0x06005D97 - // Open (active) state: left=0x06005D92, center=0x06005D94, right=0x06005D96 - // Source: state_id 11=Closed, 12=Open per gmTabUI::SetActive(bool); sprite ids confirmed - // from retail UI layout dump nodes 0x10000439, 0x100000E9, 0x10000215 in layout 0x2100002E. + // These are imported Type-12 UIElement_Text tabs. Their Closed/Open states carry + // the caption color and PassToChildren=true; their three retained children carry + // the authored left/center/right chrome. Character and Spellbook therefore share + // RetailTabBinding and the generic IUiDatStateful path rather than synthesizing art. public const uint TabAttribId = 0x10000228u; // Attributes tab group public const uint TabSkillsId = 0x10000229u; // Skills tab group public const uint TabTitlesId = 0x10000538u; // Titles tab group @@ -104,21 +89,6 @@ public static class CharacterStatController public const uint SkillsPageId = 0x1000022Cu; public const uint TitlesPageId = 0x10000539u; - // Tab button piece widths and shared height (from dump node rects). - private const float TabLeftCapW = 17f; // left-cap button width - private const float TabCenterW = 58f; // center label button width - private const float TabRightCapW = 17f; // right-cap button width - private const float TabH = 25f; // button height - - // Tab sprite ids per state (Open = active, Closed = inactive). - // Source: retail UI layout dump nodes 0x10000439/0x100000E9/0x10000215 in 0x2100002E. - private const uint TabOpenLeft = 0x06005D92u; - private const uint TabOpenCenter = 0x06005D94u; - private const uint TabOpenRight = 0x06005D96u; - private const uint TabClosedLeft = 0x06005D93u; - private const uint TabClosedCenter = 0x06005D95u; - private const uint TabClosedRight = 0x06005D97u; - // ── Footer element ids (gmStatManagementUI struct fields) ──────────────── // Source: acclient.h / DisplayDefaultFooter (0x0049cde0) public const uint FooterTitleId = 0x1000024eu; // GetFooterTitleLabel @@ -134,11 +104,6 @@ public static class CharacterStatController public const uint RaiseOneId = 0x10000246u; // raise × 1 public const uint RaiseTenId = 0x100005EBu; // raise × 10 - // ── UIStateId string keys (DatReaderWriter UIStateId enum.ToString()) ──── - // State names as returned by UIStateId.ToString() — used as ActiveState keys on UiButton. - private const string StateOpen = "Open"; // UIStateId.Open = 0x0C — active tab - private const string StateClosed = "Closed"; // UIStateId.Closed = 0x0B — inactive tab - private static readonly Vector4 Body = new(0.92f, 0.90f, 0.82f, 1f); // parchment-white body text private static readonly Vector4 Gold = new(1f, 0.82f, 0.36f, 1f); // section / emphasis gold @@ -203,16 +168,6 @@ public static class CharacterStatController /// public delegate void RaiseRequestHandler(RaiseRequest request, Action completed); - private sealed class TabVisual - { - public required CharacterStatTab Tab { get; init; } - public required UiText Left { get; init; } - public required UiText Center { get; init; } - public required UiText Right { get; init; } - public required UiText Label { get; init; } - public required string Text { get; init; } - } - private sealed record SkillRowBinding(UiClickablePanel Panel, CharacterSkill Skill); // ── Attribute row descriptors — retail display order per spec §1 ───────── @@ -270,7 +225,9 @@ public static class CharacterStatController var activeListEntries = new List(); var currentAttributeRows = new List(); var currentSkillRows = new List(); - var tabVisuals = new List(); + UiElement? attributesTab = layout.FindElement(TabAttribId); + UiElement? skillsTab = layout.FindElement(TabSkillsId); + UiElement? titlesTab = layout.FindElement(TabTitlesId); UiElement? contentPage = FindDirectChildById(layout.Root, AttributesPageId); // Name (18px from dat FontDid), Heritage (14px), PkStatus (14px): @@ -350,20 +307,8 @@ public static class CharacterStatController } } - // ── Tab bar sprite children (added to PANEL ROOT, not to the tab groups) ─── - // The tab groups (0x10000228/229/538) are UiText (Type 12) with - // ConsumesDatChildren=true, so their 3 button children were consumed at import. - // We inject the visual equivalent — 3 sprite UiTexts per tab — as CHILDREN OF - // layout.Root at the tab's ABSOLUTE screen position, NOT as children of the tab - // groups. This matters because the page-visibility pass (below) iterates - // root.Children and hides any child that doesn't contain NameId; if we added - // sprites to the tab groups, those groups would have non-zero Children.Count - // and be eligible for the pass, then hidden (since NameId is in the content area). - // By adding to root instead, the tab groups keep Children.Count==0 and are skipped - // by "if (page.Children.Count == 0) continue" — they stay visible always. - // Source: retail dump tab rects confirmed (2026-06-25): - // Attributes = (0,0,92,25), Skills = (92,0,92,25), Titles = (184,0,92,25). - // Interactive tab sprites are injected after the list/footer closures are wired. + // The tab visuals are already retained in the imported LayoutDesc. Controllers + // bind only click behavior and the active Open/Closed state below. // ── Attribute list — 9 rows in list box 0x1000023D ──────────────────── // Mutable selected-index box: -1 = nothing selected. @@ -476,23 +421,12 @@ public static class CharacterStatController () => currentSkillRows, onRaiseRequest, RefreshAfterRaise); RebuildActiveList(); - if (layout.Root is { } tabRoot2 && spriteResolve is not null) - { - if (layout.FindElement(TabAttribId) is { } origAttr) origAttr.Visible = false; - if (layout.FindElement(TabSkillsId) is { } origSkills) origSkills.Visible = false; - if (layout.FindElement(TabTitlesId) is { } origTitles) origTitles.Visible = false; - - tabVisuals.Add(AddTabSpritesToRoot(tabRoot2, spriteResolve, datFont, - tabX: 0f, label: "Attributes", tab: CharacterStatTab.Attributes, - onClick: () => SwitchTab(CharacterStatTab.Attributes))); - tabVisuals.Add(AddTabSpritesToRoot(tabRoot2, spriteResolve, datFont, - tabX: 92f, label: "Skills", tab: CharacterStatTab.Skills, - onClick: () => SwitchTab(CharacterStatTab.Skills))); - AddTabSpritesToRoot(tabRoot2, spriteResolve, datFont, - tabX: 184f, label: "Titles", tab: CharacterStatTab.Attributes, - onClick: static () => { }); - UpdateTabVisuals(tabVisuals, activeTab[0]); - } + RetailTabBinding.SetClick(attributesTab, () => SwitchTab(CharacterStatTab.Attributes)); + RetailTabBinding.SetClick(skillsTab, () => SwitchTab(CharacterStatTab.Skills)); + // Titles remain the known AP-109 gap. Keep its retail-authored closed visual, + // but do not make an inert page look interactive until that controller lands. + RetailTabBinding.SetClick(titlesTab, null); + UpdateTabStates(); // ── Active-page selection (fixes the dark-overlay) ───────────────────── // WHY this cannot be done in the importer (dat state-model audit 2026-06-26): @@ -520,10 +454,17 @@ public static class CharacterStatController SetFooterSelected(false); RebuildActiveList(); RefreshActiveRaiseButtons(); - UpdateTabVisuals(tabVisuals, tab); + UpdateTabStates(); Console.WriteLine($"[CharacterStat] Tab click: {tab}"); } + void UpdateTabStates() + { + RetailTabBinding.SetOpen(attributesTab, activeTab[0] == CharacterStatTab.Attributes); + RetailTabBinding.SetOpen(skillsTab, activeTab[0] == CharacterStatTab.Skills); + RetailTabBinding.SetOpen(titlesTab, false); + } + void RebuildActiveList() { if (statList is null) return; @@ -1849,124 +1790,6 @@ public static class CharacterStatController t.LinesProvider = () => new[] { new UiText.Line(text(), color) }; } - /// - /// Add the three sprite pieces and the label for ONE tab to - /// as direct root children at absolute positions. Sprites go to root (not to the - /// tab group elements) so the tab groups keep Children.Count==0 and survive the - /// page-visibility pass unchanged. - /// - /// Sprite ids from the retail UI layout dump (2026-06-25), nodes - /// 0x10000439/0x100000E9/0x10000215 in layout 0x2100002E: - /// Open (active): 0x06005D92/0x06005D94/0x06005D96; - /// Closed (inactive): 0x06005D93/0x06005D95/0x06005D97. - /// - /// Tab geometry confirmed from dump: each tab = 92px wide, 25px tall. - /// Left-cap = 17px, center = 58px, right-cap = 17px (total 92px). - /// - private static TabVisual AddTabSpritesToRoot( - UiElement root, - Func spriteResolve, - UiDatFont? datFont, - float tabX, - string label, - CharacterStatTab tab, - Action onClick) - { - uint leftId = TabClosedLeft; - uint centerId = TabClosedCenter; - uint rightId = TabClosedRight; - - // Left cap: 17×25 at (tabX, 0) - var left = MakeSpriteElement(spriteResolve, x: tabX, y: 0f, w: TabLeftCapW, h: TabH, spriteId: leftId); - root.AddChild(left); - // Center: 58×25 at (tabX+17, 0) — carries the label text on top - var center = MakeSpriteElement(spriteResolve, x: tabX + TabLeftCapW, y: 0f, w: TabCenterW, h: TabH, spriteId: centerId); - root.AddChild(center); - // Right cap: 17×25 at (tabX+75, 0) - var right = MakeSpriteElement(spriteResolve, x: tabX + TabLeftCapW + TabCenterW, y: 0f, w: TabRightCapW, h: TabH, spriteId: rightId); - root.AddChild(right); - - // Label text centered on the tab center piece. - // Active (Open) tab: gold; inactive (Closed) tabs: parchment body color. - // ZOrder=9 → draws on top of the sprite background (ZOrder=8) and original tab groups (ZOrder=1–3). - Vector4 labelColor = Body; - string capturedLabel = label; - var labelEl = new UiText - { - Left = tabX + TabLeftCapW, - Top = 0f, - Width = TabCenterW, - Height = TabH, - ZOrder = 9, - DatFont = datFont, - ClickThrough = true, - Centered = true, - OneLine = true, - Anchors = AnchorEdges.Left | AnchorEdges.Top, - }; - labelEl.LinesProvider = () => new[] { new UiText.Line(capturedLabel, labelColor) }; - root.AddChild(labelEl); - - var hit = new UiClickablePanel - { - Left = tabX, - Top = 0f, - Width = TabLeftCapW + TabCenterW + TabRightCapW, - Height = TabH, - ZOrder = 10, - BackgroundColor = Vector4.Zero, - BorderColor = Vector4.Zero, - Anchors = AnchorEdges.Left | AnchorEdges.Top, - OnClick = onClick, - }; - root.AddChild(hit); - - return new TabVisual - { - Tab = tab, - Left = left, - Center = center, - Right = right, - Label = labelEl, - Text = label, - }; - } - - private static void UpdateTabVisuals(IReadOnlyList tabs, CharacterStatTab activeTab) - { - foreach (var tab in tabs) - { - bool isOpen = tab.Tab == activeTab; - tab.Left.BackgroundSprite = isOpen ? TabOpenLeft : TabClosedLeft; - tab.Center.BackgroundSprite = isOpen ? TabOpenCenter : TabClosedCenter; - tab.Right.BackgroundSprite = isOpen ? TabOpenRight : TabClosedRight; - Vector4 color = isOpen ? Gold : Body; - string text = tab.Text; - tab.Label.LinesProvider = () => new[] { new UiText.Line(text, color) }; - } - } - - /// Build a single sprite-drawing leaf at the given position. - /// ZOrder=8 places it above the original tab-group UiTexts (ZOrder=1–3) so it draws on top. - private static UiText MakeSpriteElement( - Func spriteResolve, - float x, float y, float w, float h, uint spriteId) - { - return new UiText - { - Left = x, - Top = y, - Width = w, - Height = h, - ZOrder = 8, - BackgroundSprite = spriteId, - SpriteResolve = id => { var (tex, tw, th) = spriteResolve(id); return (tex, tw, th); }, - LinesProvider = static () => System.Array.Empty(), - ClickThrough = true, - Anchors = AnchorEdges.Left | AnchorEdges.Top, - }; - } - /// /// Depth-first tree walk to collect every that was registered /// in under the given dat element . diff --git a/src/AcDream.App/UI/Layout/RetailTabBinding.cs b/src/AcDream.App/UI/Layout/RetailTabBinding.cs new file mode 100644 index 00000000..d83a2008 --- /dev/null +++ b/src/AcDream.App/UI/Layout/RetailTabBinding.cs @@ -0,0 +1,39 @@ +using System; + +namespace AcDream.App.UI.Layout; + +/// +/// Shared controller seam for retail DAT-authored tabs. The LayoutDesc owns the +/// tab's font, colors, and child chrome; controllers only bind activation and +/// select the retail Open/Closed state. +/// +internal static class RetailTabBinding +{ + public static void SetClick(UiElement? element, Action? action) + { + if (element is null) return; + + element.ClickThrough = action is null; + switch (element) + { + case UiButton button: button.OnClick = action; break; + case UiText text: text.OnClick = action; break; + case UiDatElement dat: dat.OnClick = action; break; + } + } + + public static bool SetOpen(UiElement? element, bool open) + { + if (element is IUiDatStateful stateful + && stateful.TrySetRetailState(open ? RetailUiStateIds.Open : RetailUiStateIds.Closed)) + return true; + + if (element is UiButton button) + { + button.Selected = open; + return true; + } + + return false; + } +} diff --git a/src/AcDream.App/UI/Layout/SpellbookWindowController.cs b/src/AcDream.App/UI/Layout/SpellbookWindowController.cs index a7ee7a8e..7301ff1a 100644 --- a/src/AcDream.App/UI/Layout/SpellbookWindowController.cs +++ b/src/AcDream.App/UI/Layout/SpellbookWindowController.cs @@ -127,8 +127,8 @@ public sealed class SpellbookWindowController : IRetainedPanelController _rowStyle = rowStyle; _rowFont = rowFont; - SetClick(spellTab, () => ShowPage(SpellbookWindowPage.Spells)); - SetClick(componentTab, () => ShowPage(SpellbookWindowPage.Components)); + RetailTabBinding.SetClick(spellTab, () => ShowPage(SpellbookWindowPage.Spells)); + RetailTabBinding.SetClick(componentTab, () => ShowPage(SpellbookWindowPage.Components)); closeButton.OnClick = close; deleteButton.OnClick = RequestDeleteSelected; foreach ((uint id, uint mask) in FilterButtons) @@ -213,8 +213,8 @@ public sealed class SpellbookWindowController : IRetainedPanelController CurrentPage = page; _spellPage.Visible = page == SpellbookWindowPage.Spells; _componentPage.Visible = page == SpellbookWindowPage.Components; - SetTabOpen(_spellTab, page == SpellbookWindowPage.Spells); - SetTabOpen(_componentTab, page == SpellbookWindowPage.Components); + RetailTabBinding.SetOpen(_spellTab, page == SpellbookWindowPage.Spells); + RetailTabBinding.SetOpen(_componentTab, page == SpellbookWindowPage.Components); } private void ConfigureSpellList(ImportedLayout layout) @@ -520,30 +520,11 @@ public sealed class SpellbookWindowController : IRetainedPanelController _objects.ObjectRemoved -= OnObjectRemoved; _objects.ObjectMoved -= OnObjectMoved; _objects.ContainerContentsReplaced -= OnContainerContentsReplaced; - SetClick(_spellTab, null); - SetClick(_componentTab, null); + RetailTabBinding.SetClick(_spellTab, null); + RetailTabBinding.SetClick(_componentTab, null); _closeButton.OnClick = null; _deleteButton.OnClick = null; foreach ((UiButton button, _) in _filters) button.OnClick = null; } - private static void SetClick(UiElement element, Action? action) - { - element.ClickThrough = action is null; - switch (element) - { - case UiButton button: button.OnClick = action; break; - case UiText text: text.OnClick = action; break; - case UiDatElement dat: dat.OnClick = action; break; - } - } - - private static void SetTabOpen(UiElement element, bool open) - { - if (element is IUiDatStateful stateful - && stateful.TrySetRetailState(open ? RetailUiStateIds.Open : RetailUiStateIds.Closed)) - return; - if (element is UiButton button) - button.Selected = open; - } } diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs index a5487650..5d2a39f0 100644 --- a/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs @@ -775,73 +775,56 @@ public class CharacterStatControllerTests Assert.False(btn1.Visible, "raise button hidden after deselect"); } - // ── Pass 2: Tab button states ───────────────────────────────────────────── - // Tab sprites are added to layout.Root (NOT to the tab group elements), so - // the tab groups keep Children.Count==0 and survive the page-visibility pass. - // AddTabSpritesToRoot() adds 3 sprite UiTexts + 1 label UiText per tab to the - // root when a spriteResolve is provided; nothing is added when spriteResolve=null. + // ── Pass 2: DAT-authored tab states ─────────────────────────────────────── [Fact] - public void TabButtons_NoSpriteResolve_AddsNoSpriteChildrenToRoot() + public void CharacterTabs_UseImportedChromeWithoutSyntheticRootChildren() { - // When spriteResolve is null, AddTabSpritesToRoot is not called — no sprites added. - var layout = Fake(); // empty fake layout with just the root UiPanel - int rootChildCountBefore = layout.Root.Children.Count; + var layout = FixtureLoader.LoadCharacter(); + int rootChildCount = layout.Root.Children.Count; + var attributes = Assert.IsType(layout.FindElement(CharacterStatController.TabAttribId)); + var skills = Assert.IsType(layout.FindElement(CharacterStatController.TabSkillsId)); + var titles = Assert.IsType(layout.FindElement(CharacterStatController.TabTitlesId)); - CharacterStatController.Bind(layout, SampleData.SampleCharacter, spriteResolve: null); - - // No extra children added to root when spriteResolve is null. - Assert.Equal(rootChildCountBefore, layout.Root.Children.Count); - } - - [Fact] - public void TabButtons_AttributesGroup_AddsOpenSpriteIdsToRoot() - { - // Attributes tab (isOpen=true): 3 sprite children with Open sprite ids on ROOT. - // The tab group element itself has 0 children (sprites go to root, not the group). - var layout = Fake(); // minimal fake + Assert.Equal(3, attributes.Children.Count); + Assert.Equal(3, skills.Children.Count); + Assert.Equal(3, titles.Children.Count); CharacterStatController.Bind(layout, SampleData.SampleCharacter, spriteResolve: id => (id, 16, 16)); - // Root should contain the Open sprite ids among all tab sprite children. - var sprites = layout.Root.Children.OfType() - .Where(t => t.BackgroundSprite != 0u).ToList(); - Assert.Contains(sprites, t => t.BackgroundSprite == 0x06005D92u); // Open left-cap - Assert.Contains(sprites, t => t.BackgroundSprite == 0x06005D94u); // Open center - Assert.Contains(sprites, t => t.BackgroundSprite == 0x06005D96u); // Open right-cap + Assert.Equal(rootChildCount, layout.Root.Children.Count); + Assert.Equal(RetailUiStateIds.Open, attributes.ActiveRetailStateId); + Assert.Equal(RetailUiStateIds.Closed, skills.ActiveRetailStateId); + Assert.Equal(RetailUiStateIds.Closed, titles.ActiveRetailStateId); + Assert.Equal( + new uint[] { 0x06005D92u, 0x06005D94u, 0x06005D96u }, + attributes.Children.Cast().Select(child => child.ActiveMedia().File)); + Assert.Equal( + new uint[] { 0x06005D93u, 0x06005D95u, 0x06005D97u }, + skills.Children.Cast().Select(child => child.ActiveMedia().File)); + Assert.True(titles.ClickThrough); } [Fact] - public void TabButtons_SkillsAndTitles_AddClosedSpriteIdsToRoot() + public void CharacterTabs_ClickUsesRetailStateColorAndPropagatesToChrome() { - // Skills + Titles tabs (isOpen=false): Closed sprite ids appear on root. - var layout = Fake(); + var layout = FixtureLoader.LoadCharacter(); + var attributes = Assert.IsType(layout.FindElement(CharacterStatController.TabAttribId)); + var skills = Assert.IsType(layout.FindElement(CharacterStatController.TabSkillsId)); CharacterStatController.Bind(layout, SampleData.SampleCharacter, spriteResolve: id => (id, 16, 16)); + skills.OnClick!(); - var sprites = layout.Root.Children.OfType() - .Where(t => t.BackgroundSprite != 0u).ToList(); - Assert.Contains(sprites, t => t.BackgroundSprite == 0x06005D93u); // Closed left-cap - Assert.Contains(sprites, t => t.BackgroundSprite == 0x06005D95u); // Closed center - Assert.Contains(sprites, t => t.BackgroundSprite == 0x06005D97u); // Closed right-cap - } - - [Fact] - public void TabButtons_WithSpriteResolve_AddsAllThreeTabsToRoot() - { - // With spriteResolve: all 3 tabs inject 4 children each (3 sprites + 1 label) - // = 12 sprite children total across 3 tabs on the root. - var layout = Fake(); - - CharacterStatController.Bind(layout, SampleData.SampleCharacter, - spriteResolve: id => (id, 16, 16)); - - // 3 tabs × 3 sprites = 9 sprite UiTexts on root. - var sprites = layout.Root.Children.OfType() - .Where(t => t.BackgroundSprite != 0u).ToList(); - Assert.Equal(9, sprites.Count); + Assert.Equal(RetailUiStateIds.Closed, attributes.ActiveRetailStateId); + Assert.Equal(RetailUiStateIds.Open, skills.ActiveRetailStateId); + Assert.Equal(127f / 255f, attributes.DefaultColor.X, 5); + Assert.Equal(204f / 255f, skills.DefaultColor.X, 5); + Assert.All(attributes.Children, child => + Assert.Equal(RetailUiStateIds.Closed, Assert.IsAssignableFrom(child).ActiveRetailStateId)); + Assert.All(skills.Children, child => + Assert.Equal(RetailUiStateIds.Open, Assert.IsAssignableFrom(child).ActiveRetailStateId)); } // ── Affordability helpers (GetRaiseCost) ────────────────────────────────── @@ -934,12 +917,15 @@ public class CharacterStatControllerTests hiddenPage.AddChild(hiddenDuplicateList); root.AddChild(attrPage); root.AddChild(hiddenPage); + var skillsTab = MakeTab(CharacterStatController.TabSkillsId, left: 92f); + root.AddChild(skillsTab); var layout = new ImportedLayout(root, new Dictionary { [CharacterStatController.NameId] = name, // Mirrors the real import: the id dictionary can point at a hidden duplicate. [CharacterStatController.ListBoxId] = hiddenDuplicateList, + [CharacterStatController.TabSkillsId] = skillsTab, }); CharacterStatController.Bind(layout, SampleData.SampleCharacter, @@ -950,7 +936,7 @@ public class CharacterStatControllerTests var ui = new UiRoot { Width = 300, Height = 600 }; ui.AddChild(root); - Assert.IsType(ui.Pick(132, 12)); + Assert.Same(skillsTab, ui.Pick(132, 12)); ui.OnMouseDown(UiMouseButton.Left, 132, 12); ui.OnMouseUp(UiMouseButton.Left, 132, 12); @@ -1215,12 +1201,15 @@ public class CharacterStatControllerTests page.AddChild(list); page.AddChild(scrollbarShell); root.AddChild(page); + var skillsTab = MakeTab(CharacterStatController.TabSkillsId, left: 92f); + root.AddChild(skillsTab); var layout = new ImportedLayout(root, new Dictionary { [CharacterStatController.NameId] = name, [CharacterStatController.ListBoxId] = list, [CharacterStatController.ListScrollbarId] = scrollbarShell, + [CharacterStatController.TabSkillsId] = skillsTab, }); CharacterStatController.Bind(layout, SampleData.SampleCharacter, @@ -1589,10 +1578,13 @@ public class CharacterStatControllerTests private static void ClickTab(ImportedLayout layout, float left) { - var tab = layout.Root.Children.OfType() - .Single(p => System.Math.Abs(p.Left - left) < 0.5f - && System.Math.Abs(p.Top) < 0.5f - && System.Math.Abs(p.Height - 25f) < 0.5f); + uint id = left switch + { + 0f => CharacterStatController.TabAttribId, + 92f => CharacterStatController.TabSkillsId, + _ => CharacterStatController.TabTitlesId, + }; + var tab = Assert.IsType(layout.FindElement(id)); tab.OnClick!(); } @@ -1660,6 +1652,33 @@ public class CharacterStatControllerTests return new UiButton(info, static _ => (0u, 0, 0)); } + private static UiText MakeTab(uint id, float left) + { + var info = new ElementInfo + { + Id = id, + Type = 12, + X = left, + Width = 92f, + Height = 25f, + DefaultStateId = RetailUiStateIds.Closed, + DefaultStateName = "Closed", + }; + info.States[RetailUiStateIds.Closed] = new UiStateInfo + { + Id = RetailUiStateIds.Closed, + Name = "Closed", + PassToChildren = true, + }; + info.States[RetailUiStateIds.Open] = new UiStateInfo + { + Id = RetailUiStateIds.Open, + Name = "Open", + PassToChildren = true, + }; + return Assert.IsType(DatWidgetFactory.Create(info, static _ => (0u, 0, 0), null)); + } + private static ImportedLayout Fake(params (uint id, UiElement e)[] items) { var dict = new Dictionary(); @@ -1669,6 +1688,18 @@ public class CharacterStatControllerTests root.AddChild(e); dict[id] = e; } + foreach ((uint id, float left) in new[] + { + (CharacterStatController.TabAttribId, 0f), + (CharacterStatController.TabSkillsId, 92f), + (CharacterStatController.TabTitlesId, 184f), + }) + { + if (dict.ContainsKey(id)) continue; + UiText tab = MakeTab(id, left); + root.AddChild(tab); + dict[id] = tab; + } return new ImportedLayout(root, dict); } }