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