diff --git a/docs/ISSUES.md b/docs/ISSUES.md index b4ba10c8..240de6d4 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -46,6 +46,21 @@ Copy this block when adding a new issue: --- +## #149 — Studio: inventory window (0x21000023) renders all-black in preview + +**Status:** OPEN +**Severity:** MEDIUM +**Filed:** 2026-06-25 +**Component:** ui (studio) + +**Description:** `AcDream.App ui-studio "" --layout 0x21000023 --screenshot out.png` renders a flat ~1.2 KB dark-canvas image — the inventory frame / grid / paperdoll don't draw. No exception, no console output. Other studio panels render fine (vitals 0x2100006C, character 0x2100002E, toolbar 0x21000016); the inventory works in the live game (F12). Matters for the planned multi-window "full mockup" studio mode. + +**Root cause / status:** Pre-existing (identical at commits 9021600 and eccacc5; NOT the CascadeHeight change). The binding does not throw. Likely the studio host/render-stack omits state the inventory's whole-window Draggable frame + sub-window mounts (paperdoll / gmBackpackUI / gm3DItemsUI) need, or a sprite-resolve / z-order gap specific to the studio host. The 3-D viewports may legitimately need game-only RTT/mesh state (OK to leave empty) but the FRAME should still draw. + +**Files:** `src/AcDream.App/Studio/FixtureProvider.cs` (case 0x21000023u), `src/AcDream.App/Studio/StudioWindow.cs`, `src/AcDream.App/Studio/PanelFbo.cs`. + +**Investigation start:** dump the imported tree (rects / Visible / media) vs a working panel; confirm the frame backdrop sprite resolves via `RenderStack.ResolveChrome`; compare `StudioWindow`'s UiHost build vs `GameWindow`'s inventory path. + ## #148 — Status-bar backpack icon should toggle the inventory window (stateful open/closed) **Status:** OPEN diff --git a/src/AcDream.App/UI/Layout/CharacterStatController.cs b/src/AcDream.App/UI/Layout/CharacterStatController.cs index 3600401c..10022863 100644 --- a/src/AcDream.App/UI/Layout/CharacterStatController.cs +++ b/src/AcDream.App/UI/Layout/CharacterStatController.cs @@ -45,23 +45,59 @@ namespace AcDream.App.UI.Layout; public static class CharacterStatController { // ── gmStatManagementUI header element ids (sub-layout 0x2100002C content) ── - public const uint NameId = 0x10000231u; // m_pNameText - public const uint HeritageId = 0x10000232u; // m_pHeritageText - public const uint PkStatusId = 0x10000233u; // m_pPKStatusText - public const uint LevelId = 0x1000023Bu; // m_pLevelText (right-side level area) - public const uint TotalXpId = 0x10000235u; // m_pTotalXPText - public const uint XpMeterId = 0x10000236u; // m_pXPToLevelMeter (UiMeter) + public const uint NameId = 0x10000231u; // m_pNameText + public const uint HeritageId = 0x10000232u; // m_pHeritageText + public const uint PkStatusId = 0x10000233u; // m_pPKStatusText + public const uint LevelCaptionId = 0x1000023Au; // "Character Level" caption ABOVE level value + public const uint LevelId = 0x1000023Bu; // m_pLevelText (right-side level area) + public const uint TotalXpLabelId = 0x10000234u; // "Total Experience (XP):" caption left of value + public const uint TotalXpId = 0x10000235u; // m_pTotalXPText + public const uint XpMeterId = 0x10000236u; // m_pXPToLevelMeter (UiMeter) + // NOTE: 0x10000237 (XP-to-level label) and 0x10000238 (XP-to-level value) are children of + // the UiMeter element 0x10000236 and are consumed by it (UiMeter.ConsumesDatChildren=true). + // FindElement returns NULL for both. They cannot be bound via the element id. + // The XP meter itself renders the track sprite; the controller binds Fill=XpFraction. public const uint ListBoxId = 0x1000023Du; // m_pListBox container + // ── Footer STATE-A container id ────────────────────────────────────────── + // 0x10000240 is the "nothing selected" footer group. Its children (0x1000024E label row, + // 0x10000242–0x10000245 labels+values) are the correct State-A versions with wider + // label widths (195px vs 145px in State B). _byId stores the LAST duplicate, which + // is the narrower State-B/C copy — so we walk the tree to 0x10000240 and bind from there. + public const uint FooterStateAId = 0x10000240u; // State-A footer container (nothing selected) + public const uint FooterStateBId = 0x10000241u; // State-B footer container (row selected) + public const uint FooterStateCId = 0x10000247u; // State-C footer container (hide inactive) + // ── Tab bar element ids (LayoutDesc 0x2100002E root) ──────────────────── - // Three group containers, each holding three UiButton children forming the 3-piece - // tab visual. State "Open" (UIStateId 0x0C) = active; "Closed" (0x0B) = inactive. - // Source: gmTabUI::SetActive(bool) — the tab-bar controller calls SetState(Open/Closed) - // on the three child buttons of the active vs inactive tab slots. + // 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. + // + // Fix: the controller adds three sprite-drawing children to each UiText manually, 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. public const uint TabAttribId = 0x10000228u; // Attributes tab group public const uint TabSkillsId = 0x10000229u; // Skills tab group public const uint TabTitlesId = 0x10000538u; // Titles tab group + // 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 @@ -141,22 +177,35 @@ public static class CharacterStatController Label(layout, NameId, datFont, Gold, () => data().Name); Label(layout, HeritageId, datFont, Body, () => data().Heritage ?? data().Race ?? string.Empty); Label(layout, PkStatusId, datFont, Body, () => data().PkStatus ?? string.Empty); - Label(layout, LevelId, datFont, Gold, () => data().Level.ToString()); - Label(layout, TotalXpId, datFont, Body, () => data().TotalXp.ToString("N0")); + + // ── Header captions (new — retail labels above/left of each number) ────── + // 0x1000023A = "Character Level" caption area above the level value (235,0,65×35). + // Source: dump idx=29/30; spec §Header element map. + LabelLeft(layout, LevelCaptionId, datFont, Body, static () => "Character Level"); + + Label(layout, LevelId, datFont, Gold, () => data().Level.ToString()); + + // 0x10000234 = "Total Experience (XP):" caption, left of the XP value. + // Source: dump idx=22; spec §Header element map. + LabelLeft(layout, TotalXpLabelId, datFont, Body, static () => "Total Experience (XP):"); + + Label(layout, TotalXpId, datFont, Body, () => data().TotalXp.ToString("N0")); // XP-to-level meter fill (gmStatManagementUI::UpdateExperience 0x004f0a70). + // NOTE: child elements 0x10000237 (label) and 0x10000238 (value) are consumed by + // the UiMeter and cannot be bound — FindElement returns NULL for both. if (layout.FindElement(XpMeterId) is UiMeter meter) meter.Fill = () => data().XpFraction; - // ── Tab bar button states ───────────────────────────────────────────── - // Each tab group (0x10000228/229/538) is a UiDatElement container whose children - // are UiButton widgets that form the 3-piece tab visual. Setting ActiveState on - // all children to "Open" or "Closed" switches the visible sprite set. - // Source: UIStateId.Open (0x0C) / UIStateId.Closed (0x0B). - // Only Attributes is active; Skills + Titles controllers don't exist yet. - SetTabState(layout, TabAttribId, StateOpen); - SetTabState(layout, TabSkillsId, StateClosed); - SetTabState(layout, TabTitlesId, StateClosed); + // ── 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); // ── Attribute list — 9 rows in list box 0x1000023D ──────────────────── // Mutable selected-index box: -1 = nothing selected. @@ -191,8 +240,18 @@ public static class CharacterStatController foreach (var b in allRaise1) b.Visible = false; foreach (var b in allRaise10) b.Visible = false; + // ── 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. + if (layout.FindElement(FooterStateBId) is { } footerB) footerB.Visible = false; + if (layout.FindElement(FooterStateCId) is { } footerC) footerC.Visible = false; + // ── Footer State A initial binding ──────────────────────────────────── - // All footer providers close over sel[] so they can reflect State A vs B per frame. + // Walk to the State-A container directly (rather than _byId which returns the + // last duplicate) so we get the wider-label copies (195px) for the unselected state. BindFooterDynamic(layout, datFont, data, sel); List? rowPanels = null; @@ -214,32 +273,6 @@ public static class CharacterStatController } } - // ── Tab bar state ──────────────────────────────────────────────────────── - - /// - /// Set the on ALL descendants - /// of the tab group container identified by to - /// . The three-piece tab visual is composed of three UiButton - /// children (left-cap, label/center, right-cap) that must all share the same state. - /// - /// Source: retail tab-button assembly with child buttons having UIStateId states - /// "Closed" (0x0B) and "Open" (0x0C) in the dump — confirmed from - /// docs/research/2026-06-25-retail-ui-layout-dump.json. - /// - private static void SetTabState(ImportedLayout layout, uint groupId, string state) - { - if (layout.FindElement(groupId) is not { } group) return; - SetButtonStateRecursive(group, state); - } - - private static void SetButtonStateRecursive(UiElement node, string state) - { - if (node is UiButton btn) - btn.ActiveState = state; - foreach (var child in node.Children) - SetButtonStateRecursive(child, state); - } - // ── 9-row attribute list ───────────────────────────────────────────────── private static List BuildAttributeRows( @@ -515,6 +548,12 @@ public static class CharacterStatController /// title = "{AttrName}: {value}" (e.g. "Focus: 10"); line-1 label = "Experience To Raise:"; /// line-1 value = raise cost; line-2 label = "Unassigned Experience:"; /// line-2 value = UnassignedXp. + /// + /// Footer element ids appear in THREE footer-state groups (0x10000240 State A, + /// 0x10000241 State B, 0x10000247 State C). _byId stores the LAST duplicate, + /// which is the State B/C copy with narrower labels (145px vs 195px in State A). To get + /// the correct wide-label State A elements, we find the State A container directly and + /// walk to the child elements by id within that subtree. /// private static void BindFooterDynamic( ImportedLayout layout, @@ -522,8 +561,28 @@ public static class CharacterStatController Func data, int[] sel) { - // Title: State A = "Select an Attribute to Improve"; State B = "{name}: {value}" - Label(layout, FooterTitleId, datFont, Body, () => + // 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); + + UiText? T(int index, uint fallbackId) + => stateA is not null + ? GetTextChildAt(stateA, index) + : 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, () => { if (sel[0] < 0) return "Select an Attribute to Improve"; var s = data(); @@ -532,24 +591,24 @@ public static class CharacterStatController return $"{name}: {value}"; }); - // Line-1 label: State A = "Skill Credits Available:"; State B = "Experience To Raise:" - Label(layout, FooterLine1Label, datFont, Body, () => + // Line-1 label (0x10000242 = child 1): State A = "Skill Credits Available:"; State B = "Experience To Raise:" + LabelProvider(T(1, FooterLine1Label), datFont, Body, () => sel[0] < 0 ? "Skill Credits Available:" : "Experience To Raise:"); - // Line-1 value: State A = SkillCredits; State B = raise cost - Label(layout, FooterLine1Value, datFont, Body, () => + // Line-1 value (0x10000243 = child 2): State A = SkillCredits; State B = raise cost + LabelProvider(T(2, FooterLine1Value), 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: "Unassigned Experience:" in both states - Label(layout, FooterLine2Label, datFont, Body, + // Line-2 label (0x10000244 = child 3): "Unassigned Experience:" in both states + LabelProvider(T(3, FooterLine2Label), datFont, Body, static () => "Unassigned Experience:"); - // Line-2 value: UnassignedXp in both states - Label(layout, FooterLine2Value, datFont, Body, + // Line-2 value (0x10000245 = child 4): UnassignedXp in both states + LabelProvider(T(4, FooterLine2Value), datFont, Body, () => data().UnassignedXp.ToString("N0")); } @@ -574,6 +633,96 @@ public static class CharacterStatController } } + /// Left-justified label (for captions that should be left-aligned, not centered). + private static void LabelLeft(ImportedLayout layout, uint id, UiDatFont? datFont, Vector4 color, Func text) + { + if (layout.FindElement(id) is UiText t) + { + t.DatFont = datFont; + t.Centered = false; + t.RightAligned = false; + t.ClickThrough = true; + t.LinesProvider = () => new[] { new UiText.Line(text(), color) }; + } + } + + /// Bind a directly-located widget with a provider. + /// Used when the widget was found by subtree walk rather than FindElement. + private static void LabelProvider(UiText? t, UiDatFont? datFont, Vector4 color, Func text) + { + if (t is null) return; + t.DatFont = datFont; + t.Centered = false; + t.RightAligned = false; + t.ClickThrough = true; + 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. + /// + /// 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. + /// + private static void AddTabSprites( + ImportedLayout layout, + uint groupId, + Func? spriteResolve, + 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); + } + + /// Add a single sprite-drawing child to . + private static void AddSpriteChild( + UiElement parent, + Func spriteResolve, + float x, float y, float w, float h, uint spriteId) + { + parent.AddChild(new UiText + { + Left = x, + Top = y, + Width = w, + Height = h, + 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/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs index dc14bf60..cd17f04b 100644 --- a/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs @@ -514,51 +514,71 @@ 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. [Fact] - public void TabButtons_AttributesGroupButtons_SetToOpenState() + public void TabButtons_NoSpriteResolve_AddsNoSpriteChildren() { - var tabGroup = new UiPanel(); // fake tab group container - var btn1 = MakeButton(); - var btn2 = MakeButton(); - tabGroup.AddChild(btn1); - tabGroup.AddChild(btn2); + // When spriteResolve is null, AddTabSprites returns early — no children added. + var tabGroup = new UiText(); + var layout = Fake((CharacterStatController.TabAttribId, tabGroup)); - var layout = Fake((CharacterStatController.TabAttribId, tabGroup)); + CharacterStatController.Bind(layout, SampleData.SampleCharacter, spriteResolve: null); - CharacterStatController.Bind(layout, SampleData.SampleCharacter); - - // All UiButton children of the Attributes group should be "Open". - Assert.Equal("Open", btn1.ActiveState); - Assert.Equal("Open", btn2.ActiveState); + Assert.Empty(tabGroup.Children); } [Fact] - public void TabButtons_SkillsGroupButtons_SetToClosedState() + public void TabButtons_AttributesGroup_AddsThreeOpenSpriteChildren() { - var tabGroup = new UiPanel(); - var btn = MakeButton(); - tabGroup.AddChild(btn); + // Attributes tab (isOpen=true): three UiText sprite children with Open sprite ids. + var tabGroup = new UiText(); + var layout = Fake((CharacterStatController.TabAttribId, tabGroup)); - var layout = Fake((CharacterStatController.TabSkillsId, tabGroup)); + CharacterStatController.Bind(layout, SampleData.SampleCharacter, + spriteResolve: id => (id, 16, 16)); - CharacterStatController.Bind(layout, SampleData.SampleCharacter); - - Assert.Equal("Closed", btn.ActiveState); + 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 } [Fact] - public void TabButtons_TitlesGroupButtons_SetToClosedState() + public void TabButtons_SkillsGroup_AddsThreeClosedSpriteChildren() { - var tabGroup = new UiPanel(); - var btn = MakeButton(); - tabGroup.AddChild(btn); + // Skills tab (isOpen=false): three UiText sprite children with Closed sprite ids. + var tabGroup = new UiText(); + var layout = Fake((CharacterStatController.TabSkillsId, tabGroup)); - var layout = Fake((CharacterStatController.TabTitlesId, tabGroup)); + CharacterStatController.Bind(layout, SampleData.SampleCharacter, + spriteResolve: id => (id, 16, 16)); - CharacterStatController.Bind(layout, SampleData.SampleCharacter); + 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 + } - Assert.Equal("Closed", btn.ActiveState); + [Fact] + public void TabButtons_TitlesGroup_AddsThreeClosedSpriteChildren() + { + // Titles tab (isOpen=false): three UiText sprite children with Closed sprite ids. + var tabGroup = new UiText(); + var layout = Fake((CharacterStatController.TabTitlesId, tabGroup)); + + 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 } // ── Affordability helpers (GetRaiseCost) ────────────────────────────────── @@ -644,6 +664,34 @@ public class CharacterStatControllerTests Assert.True(t.RightAligned); } + // ── Header captions (new — LevelCaptionId + TotalXpLabelId) ───────────── + + [Fact] + public void Bind_LevelCaptionId_SetsCharacterLevelText() + { + var caption = new UiText(); + var layout = Fake((CharacterStatController.LevelCaptionId, caption)); + + CharacterStatController.Bind(layout, SampleData.SampleCharacter); + + Assert.Equal("Character Level", caption.LinesProvider()[0].Text); + Assert.False(caption.Centered, "LevelCaption must be left-justified (Centered=false)"); + Assert.False(caption.RightAligned, "LevelCaption must be left-justified (RightAligned=false)"); + } + + [Fact] + public void Bind_TotalXpLabelId_SetsTotalExperienceXpText() + { + var lbl = new UiText(); + var layout = Fake((CharacterStatController.TotalXpLabelId, lbl)); + + CharacterStatController.Bind(layout, SampleData.SampleCharacter); + + Assert.Equal("Total Experience (XP):", lbl.LinesProvider()[0].Text); + Assert.False(lbl.Centered, "TotalXpLabel must be left-justified (Centered=false)"); + Assert.False(lbl.RightAligned, "TotalXpLabel must be left-justified (RightAligned=false)"); + } + // ── Robustness ──────────────────────────────────────────────────────────── [Fact]