diff --git a/src/AcDream.App/UI/Layout/CharacterStatController.cs b/src/AcDream.App/UI/Layout/CharacterStatController.cs index 10022863..a1816504 100644 --- a/src/AcDream.App/UI/Layout/CharacterStatController.cs +++ b/src/AcDream.App/UI/Layout/CharacterStatController.cs @@ -197,15 +197,36 @@ public static class CharacterStatController if (layout.FindElement(XpMeterId) is UiMeter meter) meter.Fill = () => data().XpFraction; - // ── Tab bar sprite children ────────────────────────────────────────── - // The tab groups are UiText (Type 12) with ConsumesDatChildren=true, so the - // three button children (left-cap/label/right-cap) were consumed at import and - // are absent from the widget tree. Add sprite-drawing children manually here - // using the known RenderSurface ids from the dump. - // Attributes tab = Open (active); Skills + Titles = Closed (inactive). - AddTabSprites(layout, TabAttribId, spriteResolve, isOpen: true); - AddTabSprites(layout, TabSkillsId, spriteResolve, isOpen: false); - AddTabSprites(layout, TabTitlesId, spriteResolve, isOpen: false); + // ── 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). + if (layout.Root is { } tabRoot && spriteResolve is not null) + { + // Hide the original tab group elements (0x10000228/229/538): they are UiText + // nodes that render their own state sprite at ZOrder=1/2/3. Our replacement + // sprites go to root at ZOrder=8 (above them), so the originals would paint + // underneath. Hiding them avoids the double-draw and any opaque overlap. + 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; + + AddTabSpritesToRoot(tabRoot, spriteResolve, datFont, + tabX: 0f, label: "Attributes", isOpen: true); + AddTabSpritesToRoot(tabRoot, spriteResolve, datFont, + tabX: 92f, label: "Skills", isOpen: false); + AddTabSpritesToRoot(tabRoot, spriteResolve, datFont, + tabX: 184f, label: "Titles", isOpen: false); + } // ── Attribute list — 9 rows in list box 0x1000023D ──────────────────── // Mutable selected-index box: -1 = nothing selected. @@ -242,10 +263,28 @@ public static class CharacterStatController // ── Footer State B/C visibility ─────────────────────────────────────── // There are THREE footer state groups (A=0x10000240, B=0x10000241, C=0x10000247) - // all stacked at the same position. The shared child ids (0x1000024E, 0x10000242, - // etc.) appear once in each; _byId stores only the LAST duplicate (State C or B copy) - // which uses narrower label widths (145px vs 195px in State A) designed to fit the - // raise buttons. Hide B and C initially so only State A is visible. + // all stacked at the same position within the Attributes page. _byId stores only + // the LAST copy of each id; the others live in the VISIBLE Attributes page and must + // also be hidden. + // + // Strategy: walk the Attributes page (the root child that contains the NameId anchor) + // and hide every UiDatElement whose ElementId matches B or C. The page-visibility + // pass hides everything in the Skills/Titles pages, so we only need to act on the + // Attributes page's copies. + if (layout.FindElement(NameId) is { } bAnchor && layout.Root is { } fbRoot) + { + foreach (var fbPage in fbRoot.Children) + { + if (fbPage.Children.Count == 0) continue; + if (!ContainsWidget(fbPage, bAnchor)) continue; + // This is the Attributes page. Hide all stateB and stateC copies in it. + HideAllById(fbPage, FooterStateBId); + HideAllById(fbPage, FooterStateCId); + break; + } + } + // Also hide the last-registered copies (in the _byId dict, which may be from a + // different page). Belt-and-suspenders. if (layout.FindElement(FooterStateBId) is { } footerB) footerB.Visible = false; if (layout.FindElement(FooterStateCId) is { } footerC) footerC.Visible = false; @@ -562,27 +601,62 @@ public static class CharacterStatController int[] sel) { // Walk the State A container (0x10000240) to find the wide-label copies of the - // footer child elements. The children of 0x10000240 in positional order are: - // [0] title (0x1000024E) at (0,0,300,20) - // [1] line-1 label (0x10000242) at (5,20,195,17) ← wide: 195px (State A) - // [2] line-1 value (0x10000243) at (200,20,95,17) - // [3] line-2 label (0x10000244) at (5,37,195,18) - // [4] line-2 value (0x10000245) at (200,37,95,18) - // The State B/C copies use narrower labels (145px) to accommodate raise buttons at x=260. - // We use positional indexing rather than EventId (which is auto-assigned by UiRoot, not - // set from the dat element id, so EventId does NOT equal the dat handle). - // If the State A container exists in the tree (production layout), bind from its - // positional children so we get the 195px-wide labels rather than the narrower - // State B/C copies that _byId would return. Fall back to _byId for fake/test layouts. - var stateA = layout.FindElement(FooterStateAId); + // footer child elements. The 5 children of 0x10000240 at their LOCAL coords: + // title (0x1000024E): Left=0, Top=0, W=300, H=55 + // line-1 label (0x10000242): Left=5, Top=20, W=195 (State A wide) vs 145 (B/C narrow) + // line-1 value (0x10000243): Left=200, Top=20, W=95 + // line-2 label (0x10000244): Left=5, Top=37, W=195 (State A wide) vs 145 (B/C narrow) + // line-2 value (0x10000245): Left=200, Top=37, W=95 + // The State B/C copies use narrower labels (145px) to accommodate raise buttons. + // + // IMPORTANT: The footer state id (0x10000240) appears once per tab-page sub-layout + // (Attributes / Skills / Titles). layout._byId stores only the LAST registered copy, + // which ends up in the LAST-imported tab page (Titles). The page-visibility pass + // hides the Titles page → the bound footer elements would be invisible. + // + // Fix: find stateA in the SAME subtree as the anchor element (NameId=0x10000231). + // The anchor is in the Attributes content area (the page the pass keeps Visible=True). + // We walk every root-level content area to find the one that contains the anchor, + // then walk that subtree for the footer container. + UiElement? stateA = null; + if (layout.FindElement(NameId) is { } anchor && layout.Root is { } visRoot) + { + // Find the root-level content page that contains the anchor. + foreach (var page in visRoot.Children) + { + if (page.Children.Count == 0) continue; + if (!ContainsWidget(page, anchor)) continue; + // This page will be Visible=True. Walk it for FooterStateAId. + stateA = FindInSubtree(page, static el => + el is UiDatElement d && d.ElementId == FooterStateAId); + break; + } + } + // Fallback: layout._byId (test layouts with a single page). + stateA ??= layout.FindElement(FooterStateAId); - UiText? T(int index, uint fallbackId) - => stateA is not null - ? GetTextChildAt(stateA, index) - : layout.FindElement(fallbackId) as UiText; + // Position-based lookup within stateA's children. + // If stateA is null or the element isn't found, falls back to layout._byId. + UiText? ByPos(float top, float left, uint fallbackId) + { + if (stateA is not null) + { + foreach (var c in stateA.Children) + if (c is UiText t + && Math.Abs(c.Top - top) < 1f + && Math.Abs(c.Left - left) < 1f) + return t; + } + return layout.FindElement(fallbackId) as UiText; + } - // Title (0x1000024E = child 0): State A = "Select an Attribute to Improve"; State B = "{name}: {value}" - LabelProvider(T(0, FooterTitleId), datFont, Body, () => + // Title (Top=0, Left=0): State A = "Select an Attribute to Improve"; State B = "{name}: {value}" + // Clear BackgroundSprite on the title element: it is H=55 (full footer height) in the dat + // and its background sprite would cover the line-1/line-2 elements at local y=20/37. + // The controller owns the footer visual; the dat sprite is superfluous here. + var titleEl = ByPos(0f, 0f, FooterTitleId); + if (titleEl is not null) titleEl.BackgroundSprite = 0; + LabelProvider(titleEl, datFont, Body, () => { if (sel[0] < 0) return "Select an Attribute to Improve"; var s = data(); @@ -591,29 +665,58 @@ public static class CharacterStatController return $"{name}: {value}"; }); - // Line-1 label (0x10000242 = child 1): State A = "Skill Credits Available:"; State B = "Experience To Raise:" - LabelProvider(T(1, FooterLine1Label), datFont, Body, () => + // Line-1 label (Top=20, Left=5): State A = "Skill Credits Available:"; State B = "Experience To Raise:" + var l1L = ByPos(20f, 5f, FooterLine1Label); + LabelProvider(l1L, datFont, Body, () => sel[0] < 0 ? "Skill Credits Available:" : "Experience To Raise:"); - // Line-1 value (0x10000243 = child 2): State A = SkillCredits; State B = raise cost - LabelProvider(T(2, FooterLine1Value), datFont, Body, () => + // Line-1 value (Top=20, Left=200): State A = SkillCredits; State B = raise cost + var l1V = ByPos(20f, 200f, FooterLine1Value); + LabelProvider(l1V, datFont, Body, () => { if (sel[0] < 0) return data().SkillCredits.ToString(); long cost = GetRaiseCost(data(), sel[0]); return cost > 0 ? cost.ToString("N0") : "—"; }); - // Line-2 label (0x10000244 = child 3): "Unassigned Experience:" in both states - LabelProvider(T(3, FooterLine2Label), datFont, Body, + // Line-2 label (Top=37, Left=5): "Unassigned Experience:" in both states + var l2L = ByPos(37f, 5f, FooterLine2Label); + LabelProvider(l2L, datFont, Body, static () => "Unassigned Experience:"); - // Line-2 value (0x10000245 = child 4): UnassignedXp in both states - LabelProvider(T(4, FooterLine2Value), datFont, Body, + // Line-2 value (Top=37, Left=200): UnassignedXp in both states + var l2V = ByPos(37f, 200f, FooterLine2Value); + LabelProvider(l2V, datFont, Body, () => data().UnassignedXp.ToString("N0")); } // ── Helpers ────────────────────────────────────────────────────────────── + /// Depth-first walk of and its descendants. + /// Sets = false on every + /// whose equals . + private static void HideAllById(UiElement node, uint targetId) + { + if (node is UiDatElement d && d.ElementId == targetId) + node.Visible = false; + foreach (var child in node.Children) + HideAllById(child, targetId); + } + + /// Depth-first search of and its descendants. + /// Returns the first element for which returns true, + /// or null if none found. + private static UiElement? FindInSubtree(UiElement node, Func predicate) + { + if (predicate(node)) return node; + foreach (var child in node.Children) + { + var found = FindInSubtree(child, predicate); + if (found is not null) return found; + } + return null; + } + private static bool ContainsWidget(UiElement node, UiElement target) { if (ReferenceEquals(node, target)) return true; @@ -647,7 +750,10 @@ public static class CharacterStatController } /// Bind a directly-located widget with a provider. - /// Used when the widget was found by subtree walk rather than FindElement. + /// Used when the widget was found by subtree walk rather than FindElement. + /// Sets Padding = 0 to prevent the scroll-clip from hiding text in small + /// (H≈17–18px) footer elements: with the default Padding=4 and a dat font line-height + /// of ~12px the bottom-pinned baseY ends up above the top clip boundary → blank. private static void LabelProvider(UiText? t, UiDatFont? datFont, Vector4 color, Func text) { if (t is null) return; @@ -655,72 +761,84 @@ public static class CharacterStatController t.Centered = false; t.RightAligned = false; t.ClickThrough = true; + t.Padding = 0f; t.LinesProvider = () => new[] { new UiText.Line(text(), color) }; } /// - /// Returns the child of at positional - /// index (zero-based). Used to locate footer children within the - /// State-A container by position rather than by dat id (which is not stored in EventId - /// by the production importer — EventId is auto-assigned by UiRoot.AddPanel). - /// Returns null if the index is out of range or the child is not UiText. - /// - private static UiText? GetTextChildAt(UiElement? parent, int index) - { - if (parent is null || index < 0 || index >= parent.Children.Count) return null; - return parent.Children[index] as UiText; - } - - /// - /// Add the three sprite-drawing children to a tab group element - /// to render the 3-piece tab button (left-cap / center / right-cap). The tab group - /// elements are type-12 UIElement_Text in the dat, so their button children were consumed - /// at import (ConsumesDatChildren=true). This method injects the visual equivalent. + /// 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 void AddTabSprites( - ImportedLayout layout, - uint groupId, - Func? spriteResolve, + private static void AddTabSpritesToRoot( + UiElement root, + Func spriteResolve, + UiDatFont? datFont, + float tabX, + string label, bool isOpen) { - if (layout.FindElement(groupId) is not { } group) return; - if (spriteResolve is null) return; - uint leftId = isOpen ? TabOpenLeft : TabClosedLeft; uint centerId = isOpen ? TabOpenCenter : TabClosedCenter; uint rightId = isOpen ? TabOpenRight : TabClosedRight; - // Left cap: 17×25 at x=0 - AddSpriteChild(group, spriteResolve, x: 0f, y: 0f, w: TabLeftCapW, h: TabH, spriteId: leftId); - // Center: 58×25 at x=17 - AddSpriteChild(group, spriteResolve, x: TabLeftCapW, y: 0f, w: TabCenterW, h: TabH, spriteId: centerId); - // Right cap: 17×25 at x=75 - AddSpriteChild(group, spriteResolve, x: TabLeftCapW + TabCenterW, y: 0f, w: TabRightCapW, h: TabH, spriteId: rightId); + // Left cap: 17×25 at (tabX, 0) + root.AddChild(MakeSpriteElement(spriteResolve, x: tabX, y: 0f, w: TabLeftCapW, h: TabH, spriteId: leftId)); + // 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) + root.AddChild(MakeSpriteElement(spriteResolve, x: tabX + TabLeftCapW + TabCenterW, y: 0f, w: TabRightCapW, h: TabH, spriteId: rightId)); + + // 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 = isOpen ? Gold : 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, + Anchors = AnchorEdges.Left | AnchorEdges.Top, + }; + labelEl.LinesProvider = () => new[] { new UiText.Line(capturedLabel, labelColor) }; + root.AddChild(labelEl); } - /// Add a single sprite-drawing child to . - private static void AddSpriteChild( - UiElement parent, + /// 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) { - parent.AddChild(new UiText + 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, - }); + }; } /// @@ -786,4 +904,5 @@ public static class CharacterStatController foreach (var child in node.Children) CollectMatchingButtons(child, canonical, seen, result); } + } diff --git a/src/AcDream.App/UI/Layout/UiDatElement.cs b/src/AcDream.App/UI/Layout/UiDatElement.cs index 5f6ea79c..a187f008 100644 --- a/src/AcDream.App/UI/Layout/UiDatElement.cs +++ b/src/AcDream.App/UI/Layout/UiDatElement.cs @@ -42,6 +42,11 @@ public sealed class UiDatElement : UiElement private readonly ElementInfo _info; private readonly Func _resolve; + /// The dat element id from . Exposed so controllers + /// can identify which logical element a UiDatElement represents when walking subtrees + /// (e.g. footer state groups that appear once per tab page but share the same dat id). + public uint ElementId => _info.Id; + /// Which state name to render. "" = the unnamed DirectState. /// Falls back to DirectState if the named state is absent. public string ActiveState { get; set; } = ""; diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs index cd17f04b..3feb03aa 100644 --- a/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs @@ -514,71 +514,72 @@ public class CharacterStatControllerTests } // ── Pass 2: Tab button states ───────────────────────────────────────────── - // Tab groups are UiText (Type 12) with ConsumesDatChildren=true; their button - // children are consumed at import time. AddTabSprites() injects 3 UiText - // sprite-children per group when a spriteResolve is provided. + // 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. [Fact] - public void TabButtons_NoSpriteResolve_AddsNoSpriteChildren() + public void TabButtons_NoSpriteResolve_AddsNoSpriteChildrenToRoot() { - // When spriteResolve is null, AddTabSprites returns early — no children added. - var tabGroup = new UiText(); - var layout = Fake((CharacterStatController.TabAttribId, tabGroup)); + // 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; CharacterStatController.Bind(layout, SampleData.SampleCharacter, spriteResolve: null); - Assert.Empty(tabGroup.Children); + // No extra children added to root when spriteResolve is null. + Assert.Equal(rootChildCountBefore, layout.Root.Children.Count); } [Fact] - public void TabButtons_AttributesGroup_AddsThreeOpenSpriteChildren() + public void TabButtons_AttributesGroup_AddsOpenSpriteIdsToRoot() { - // Attributes tab (isOpen=true): three UiText sprite children with Open sprite ids. - var tabGroup = new UiText(); - var layout = Fake((CharacterStatController.TabAttribId, tabGroup)); + // 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 CharacterStatController.Bind(layout, SampleData.SampleCharacter, spriteResolve: id => (id, 16, 16)); - var sprites = tabGroup.Children.OfType().ToList(); - Assert.Equal(3, sprites.Count); - Assert.Equal(0x06005D92u, sprites[0].BackgroundSprite); // Open left-cap - Assert.Equal(0x06005D94u, sprites[1].BackgroundSprite); // Open center - Assert.Equal(0x06005D96u, sprites[2].BackgroundSprite); // Open right-cap + // 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 } [Fact] - public void TabButtons_SkillsGroup_AddsThreeClosedSpriteChildren() + public void TabButtons_SkillsAndTitles_AddClosedSpriteIdsToRoot() { - // Skills tab (isOpen=false): three UiText sprite children with Closed sprite ids. - var tabGroup = new UiText(); - var layout = Fake((CharacterStatController.TabSkillsId, tabGroup)); + // Skills + Titles tabs (isOpen=false): Closed sprite ids appear on root. + var layout = Fake(); CharacterStatController.Bind(layout, SampleData.SampleCharacter, spriteResolve: id => (id, 16, 16)); - var sprites = tabGroup.Children.OfType().ToList(); - Assert.Equal(3, sprites.Count); - Assert.Equal(0x06005D93u, sprites[0].BackgroundSprite); // Closed left-cap - Assert.Equal(0x06005D95u, sprites[1].BackgroundSprite); // Closed center - Assert.Equal(0x06005D97u, sprites[2].BackgroundSprite); // Closed right-cap + 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_TitlesGroup_AddsThreeClosedSpriteChildren() + public void TabButtons_WithSpriteResolve_AddsAllThreeTabsToRoot() { - // Titles tab (isOpen=false): three UiText sprite children with Closed sprite ids. - var tabGroup = new UiText(); - var layout = Fake((CharacterStatController.TabTitlesId, tabGroup)); + // 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)); - var sprites = tabGroup.Children.OfType().ToList(); - Assert.Equal(3, sprites.Count); - Assert.Equal(0x06005D93u, sprites[0].BackgroundSprite); // Closed left-cap - Assert.Equal(0x06005D95u, sprites[1].BackgroundSprite); // Closed center - Assert.Equal(0x06005D97u, sprites[2].BackgroundSprite); // Closed right-cap + // 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); } // ── Affordability helpers (GetRaiseCost) ──────────────────────────────────