using System; using System.Collections.Generic; using System.Numerics; using AcDream.App.UI; namespace AcDream.App.UI.Layout; /// /// Controller for the Character window's Attributes tab — LayoutDesc 0x2100002E, /// whose tab-content slot 0x1000022B mounts sub-layout 0x2100002C (gmAttributeUI, root /// type 0x1000002A) which in turn chains into the gmStatManagementUI header content. /// /// Unlike (which targets the SEPARATE text-report /// sub-panel 0x2100001A, gmCharacterInfoUI, by creating its runtime m_pMainText element), /// this controller binds the real, statically-mounted header + list elements that the /// importer already produces — every id below is confirmed present via /// . /// /// Ported from gmStatManagementUI::UpdateCharacterInfo (0x004f0770) + /// UpdateExperience (0x004f0a70) + UpdatePKStatus (0x004f00a0): name, heritage, /// PK status, level, total XP and the XP-to-level meter. The attribute list is the /// gmAttributeUI list box (0x1000023D), built as 9 manual-layout rows per /// gmAttributeUI::PostInit (0x0049db70) + AttributeInfoRegion / Attribute2ndInfoRegion /// (0x004f1910 / 0x004f19e0). Row icons loaded via sub-element 0x10000129 in the retail /// dat template (each icon is a 0x06xxxxxx RenderSurface DataID from SubMap /// 0x25000006 / 0x25000007, spec §2). /// /// Footer State A (nothing selected) bound from /// DisplayDefaultFooter (0x0049cde0): title empty, line-1 value = /// "Select an Attribute to Improve", line-2 value = available skill credits (InqInt(0x18)). /// /// Footer State B (row selected) bound from /// DisplaySelectedAttribute (implicitly): title = "{AttrName}: {value}", /// 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. /// /// Raise buttons: 0x10000246 (×1) + 0x100005EB (×10). State "Normal" = affordable /// (UIStateId.Normal, 0x01), state "Ghosted" = unaffordable or no selection /// (UIStateId.Ghosted, 0x0D). Source: gmAttributeUI::AttributeInfoRegion::Update (0x004f1910). /// 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 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) // Fix 5: 0x10000237 (XP-to-level label) and 0x10000238 (XP-to-level value) are now // built by the LayoutImporter as UiText children of the XP meter (non-Type-3 children // are explicitly built and registered in byId). FindElement DOES return them now. // The controller binds their LinesProvider instead of injecting new runtime nodes. public const uint XpNextLabelId = 0x10000237u; // "XP for next level:" label child of XP meter public const uint XpNextValueId = 0x10000238u; // XP-to-next-level value child of XP meter 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) ──────────────────── // 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. 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 public const uint FooterLine1Label = 0x10000242u; // GetFooterLineOneLabel public const uint FooterLine1Value = 0x10000243u; // GetFooterLineOneValue public const uint FooterLine2Label = 0x10000244u; // GetFooterLineTwoLabel public const uint FooterLine2Value = 0x10000245u; // GetFooterLineTwoValue // ── Raise button element ids ────────────────────────────────────────────── // Source: gmAttributeUI::PostInit (0x0049db70); CM_Train::Event_TrainAttribute. // Button state "Normal" (UIStateId 0x01) = affordable (green/active); // "Ghosted" (UIStateId 0x0D) = disabled. Hidden when nothing is selected. 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 const string StateNormal = "Normal"; // UIStateId.Normal = 0x01 — affordable / default private const string StateGhosted = "Ghosted"; // UIStateId.Ghosted = 0x0D — disabled button 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 /// Row highlight color — semi-translucent gold, matches retail /// UIStateId.Highlight (0x06) sprite 0x06001397 visual intent. private static readonly Vector4 HighlightBg = new(1f, 0.75f, 0.2f, 0.25f); // ── Row layout constants ───────────────────────────────────────────────── // RowHeight 22px + IconSize 16px: retail spec (2026-06-26) says icons ~icon-height // and rows tighter. 16px icon fits inside 22px row with 3px vertical padding each side. // The larger row font (0x40000001, MaxCharHeight=18) is clipped to the 22px height which // gives a tight-but-readable line. Retail spec (2026-06-26 ref): "rows tighter, text ≈ icon height". private const float RowHeight = 22f; private const float IconSize = 16f; private const float RowPadX = 4f; private const float IconGap = 6f; // ── Attribute row descriptors — retail display order per spec §1 ───────── private static readonly (string name, uint iconDid)[] AttrRows = new[] { ("Strength", 0x060002C8u), // enum 1 ("Endurance", 0x060002C4u), // enum 2 ("Coordination", 0x060002C9u), // enum 4 ("Quickness", 0x060002C6u), // enum 3 ("Focus", 0x060002C5u), // enum 5 ("Self", 0x060002C7u), // enum 6 }; private static readonly (string name, uint iconDid)[] VitalRows = new[] { ("Health", 0x06004C3Bu), // CurEnum 2 ("Stamina", 0x06004C3Cu), // CurEnum 4 ("Mana", 0x06004C3Du), // CurEnum 6 }; /// /// Bind the Attributes-tab header + 9-row list + footer elements, tab button states, /// and raise buttons in to . /// /// /// Interactive mode (Pass 2): each attribute/vital row is a /// that fires selection logic on left-click. The /// selected row index is held in a mutable int[] box (single element) so that /// all closures share the same mutable slot without escaping the static method boundary. /// /// /// /// resolves a 0x06xxxxxx RenderSurface dat id to /// a (GL tex handle, pixel width, pixel height) triple. Pass null in tests where /// icon rendering is not asserted. /// /// public static void Bind( ImportedLayout layout, Func data, UiDatFont? datFont = null, UiDatFont? rowDatFont = null, Func? spriteResolve = null) { // rowDatFont: larger font for attribute row name/value text (18px vs 16px default). // Falls back to datFont when null (tests, or dat missing). rowDatFont ??= datFont; // Name (18px from dat FontDid), Heritage (14px), PkStatus (14px): // Fix C: pass null → Label's null-guard keeps the build-time dat font. // Controllers still own the text color and the LinesProvider. // Name = WHITE (retail "Horan" is white — confirmed 2026-06-26). Label(layout, NameId, null, Vector4.One, () => data().Name); Label(layout, HeritageId, null, Body, () => data().Heritage ?? data().Race ?? string.Empty); Label(layout, PkStatusId, null, Body, () => data().PkStatus ?? string.Empty); // ── Header captions (new — retail labels above/left of each number) ────── // LevelCaption (0x1000023A, 16px from dat): pass null → keep build-time dat font. LabelTwoLine(layout, LevelCaptionId, null, Body, "Character", "Level"); // Level number: retail renders this as large gold centered text in the 65×50 element. // Fix C: the dat FontDid for the level element (0x1000023B) is now applied at build // time when the font resolver is provided (studio path). We no longer force rowDatFont // here for the level — the dat's own FontDid drives the font. The Gold color is still // set via LinesProvider. SYNTHESIZED elements (the 9 attribute rows built in // BuildAttributeRows) continue to use datFont directly since they have no dat origin. // Source: spec §Level area (65,50) + decomp gmStatManagementUI::UpdateCharacterInfo 0x004f0770. // runtime color, dat carries none. Label(layout, LevelId, null, Gold, () => data().Level.ToString()); // TotalXpLabel (16px from dat) + TotalXp (16px from dat): pass null → keep dat font. LabelLeft(layout, TotalXpLabelId, null, Body, static () => "Total Experience (XP):"); Label(layout, TotalXpId, null, Body, () => data().TotalXp.ToString("N0")); // XP-to-level meter fill (gmStatManagementUI::UpdateExperience 0x004f0a70). // Fix 5: child elements 0x10000237 (label) and 0x10000238 (value) are now built by // the LayoutImporter as UiText children of the XP meter (non-Type-3 meter children // are explicitly built and registered in byId — see LayoutImporter.BuildWidget). // FindElement now returns them; the controller binds their LinesProvider. // The importer builds them as UiText via DatWidgetFactory.BuildText, applying their // dat-origin HJustify/VJustify/FontDid/FontColor at build time. The controller then // overrides Padding=0 (to avoid scroll clip in the ~13px-tall bar), ClickThrough=true, // and provides the LinesProvider for runtime content. if (layout.FindElement(XpMeterId) is UiMeter meter) { meter.Fill = () => data().XpFraction; // Bind the dat-origin XP label (0x10000237) and value (0x10000238). // These are now real UiText children of the meter (built by the importer). // The retail layout places the caption + value ON TOP of the red bar // (ref 2026-06-26: "value … with the red fill bar behind it"). // Source: retail spec (2026-06-26-character-window-retail-reference.md §State 1). if (layout.FindElement(XpNextLabelId) is UiText xpLabel) { if (datFont is not null) xpLabel.DatFont = datFont; xpLabel.ClickThrough = true; xpLabel.Centered = false; // left-align (retail: aligns with Total XP label above) xpLabel.RightAligned = false; xpLabel.Padding = 0f; // avoid scroll clip — meter bar is ~13px tall // Item 1: align the XP-next label's left edge to match the TotalXpLabel's // absolute left edge. The XP-next label is a child of the meter (local coords), // so its Left = TotalXpLabel.Left − meter.Left. This accounts for the meter's // horizontal offset within the panel (the meter starts to the right of the // "Total Experience (XP):" caption row). Source: retail spec §State 1 (the // "XP for next level:" caption left-aligns with "Total Experience (XP):" above). if (layout.FindElement(TotalXpLabelId) is { } totalXpLbl) { float xpNextLeft = totalXpLbl.Left - meter.Left; xpLabel.Left = xpNextLeft >= 0f ? xpNextLeft : 0f; } xpLabel.LinesProvider = static () => new[] { new UiText.Line("XP for next level:", Body) }; } if (layout.FindElement(XpNextValueId) is UiText xpValue) { if (datFont is not null) xpValue.DatFont = datFont; xpValue.ClickThrough = true; xpValue.RightAligned = true; xpValue.Padding = 0f; // avoid scroll clip xpValue.LinesProvider = () => new[] { new UiText.Line(data().XpToNextLevel.ToString("N0"), Body) }; } } // ── 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. var sel = new int[] { -1 }; // Gather EVERY copy of the raise buttons in the tree. The raise button ids // (0x10000246, 0x100005EB) appear once per tab page (Attributes/Skills/Titles) // in the dat inheritance structure; ImportedLayout._byId keeps only the LAST // mounted copy. We collect all copies so we can hide them all initially and // show/hide the correct set when a row is selected. // // At bind time the tree includes all three tab pages (the page-visibility pass // runs AFTER this). Collecting from the full tree is safe: once the page- // visibility pass hides the inactive pages their raise buttons are invisible // regardless of the Visible flag we set here — but the Attributes page's // buttons (which are NOT hidden by the page pass) must be explicitly hidden. var allRaise1 = new List(); var allRaise10 = new List(); if (layout.Root is { } r) { // Single-pass tree walk to collect all UiButton copies at the two ids. // FindElement only returns the last-registered copy in _byId; we need ALL // copies because duplicated sub-layout mounts each tab page independently. CollectButtonsById(r, RaiseOneId, allRaise1, layout); CollectButtonsById(r, RaiseTenId, allRaise10, layout); } // If tree-walk found nothing, fall back to _byId (covers fake/test layouts). if (allRaise1.Count == 0 && layout.FindElement(RaiseOneId) is UiButton b1) allRaise1.Add(b1); if (allRaise10.Count == 0 && layout.FindElement(RaiseTenId) is UiButton b10) allRaise10.Add(b10); // Initial state: raise buttons hidden until a row is selected. 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 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. // // WHY this cannot be done in the importer (dat state-model audit 2026-06-26): // All three group elements have DefaultState = StatManagement_Footer_Default // (0x10000011) — the dat does NOT differentiate them by visibility. The parent // element (0x1000022F) has a States map {Default, Text, Meter} with PassToChildren= // true, but each child group also registers all three states (IncFlags=None, // Media=0) — meaning the state-propagation produces no media change on any group. // Retail's gmStatManagementUI uses hardcoded element-id dispatch // (GetChildRecursive(this, 0x10000240) for Default, 0x10000241 for Text, 0x10000247 // for Meter) to access the right group's children at runtime — the groups themselves // are never hidden/shown via the dat state mechanism. The controller is the correct // and only place for this visibility management. See retail decomp // gmStatManagementUI::GetFooterTitleLabel @0x004f0170. // // 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; // ── Footer State A initial binding ──────────────────────────────────── // 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; if (layout.FindElement(ListBoxId) is { } list) { rowPanels = BuildAttributeRows(list, rowDatFont, spriteResolve, data, sel, allRaise1, allRaise10); } // ── Active-page selection (fixes the dark-overlay) ───────────────────── // WHY this cannot be done in the importer (dat state-model audit 2026-06-26): // The three tab-page content areas (0x1000022B Attributes, 0x1000022C Skills, // 0x10000539 Titles) all have DefaultState = Undef (0) — the dat carries no // visibility encoding for tabs. Tab visibility is managed at runtime by gmTabUI // via SetVisible(bool) on the page containers. The controller is the correct // and only place for initial tab-page selection. if (layout.FindElement(NameId) is { } anchor && layout.Root is { } root) { foreach (var page in root.Children) { if (page.Children.Count == 0) continue; page.Visible = ContainsWidget(page, anchor); } } } // ── 9-row attribute list ───────────────────────────────────────────────── private static List BuildAttributeRows( UiElement list, UiDatFont? datFont, Func? spriteResolve, Func data, int[] sel, List allRaise1, List allRaise10) { float listW = list.Width; float y = 0f; var rows = new List(); for (int i = 0; i < AttrRows.Length; i++) { var (rowName, iconDid) = AttrRows[i]; int rowIndex = i; var row = AddRow(list, datFont, spriteResolve, left: 0f, top: y, width: listW, height: RowHeight, iconDid: iconDid, nameText: rowName, valueProvider: () => { var s = data(); int v = rowIndex switch { 0 => s.Strength, 1 => s.Endurance, 2 => s.Coordination, 3 => s.Quickness, 4 => s.Focus, 5 => s.Self, _ => 0, }; return v.ToString(); }); row.OnClick = () => HandleRowClick(rowIndex, sel, rows, spriteResolve, data, allRaise1, allRaise10); rows.Add(row); y += RowHeight; } for (int i = 0; i < VitalRows.Length; i++) { var (rowName, iconDid) = VitalRows[i]; int rowIndex = i; int absIndex = AttrRows.Length + i; var row = AddRow(list, datFont, spriteResolve, left: 0f, top: y, width: listW, height: RowHeight, iconDid: iconDid, nameText: rowName, valueProvider: () => { var s = data(); return rowIndex switch { 0 => $"{s.HealthCurrent}/{s.HealthMax}", 1 => $"{s.StaminaCurrent}/{s.StaminaMax}", 2 => $"{s.ManaCurrent}/{s.ManaMax}", _ => string.Empty, }; }); row.OnClick = () => HandleRowClick(absIndex, sel, rows, spriteResolve, data, allRaise1, allRaise10); rows.Add(row); y += RowHeight; } return rows; } /// /// Handles a row click: toggle (same row → deselect), else select new row. /// Updates highlight, footer providers, and raise-button state. /// private static void HandleRowClick( int clickedIndex, int[] sel, List rows, Func? spriteResolve, Func data, List allRaise1, List allRaise10) { int newSel = (sel[0] == clickedIndex) ? -1 : clickedIndex; sel[0] = newSel; // Log for live test confirmation (user tests selection in the studio). string rowName = GetRowName(newSel); Console.WriteLine($"[CharacterStat] Row click: index={clickedIndex} → selected={newSel} ({rowName})"); // Update highlight on all rows. // Retail uses sprite 0x06001397 (Button state 6 — the dark horizontal bars) // for the selected row background. When spriteResolve is available, apply the // sprite; otherwise fall back to the translucent gold tint. const uint HighlightSprite = 0x06001397u; for (int i = 0; i < rows.Count; i++) { var row = rows[i]; if (i == newSel) { if (spriteResolve is not null) { row.BackgroundColor = Vector4.Zero; row.BackgroundSprite = HighlightSprite; row.SpriteResolve = spriteResolve; } else { row.BackgroundColor = HighlightBg; row.BackgroundSprite = 0u; row.SpriteResolve = null; } } else { row.BackgroundColor = Vector4.Zero; row.BackgroundSprite = 0u; row.SpriteResolve = null; } } // Update raise buttons. RefreshRaiseButtons(newSel, data, allRaise1, allRaise10); } /// Refresh raise button visibility + state based on current selection. /// Applies to ALL collected raise button copies (one per tab page) so the /// Attributes-page buttons are correctly shown/hidden regardless of which /// copy happens to be in ImportedLayout._byId. private static void RefreshRaiseButtons( int selectedIndex, Func data, List allRaise1, List allRaise10) { if (allRaise1.Count == 0 && allRaise10.Count == 0) return; if (selectedIndex < 0) { // Nothing selected: hide all raise buttons. foreach (var b in allRaise1) b.Visible = false; foreach (var b in allRaise10) b.Visible = false; return; } var sheet = data(); long cost = GetRaiseCost(sheet, selectedIndex); bool affordable = cost > 0 && sheet.UnassignedXp >= cost; // State "Normal" = affordable/green; "Ghosted" = too expensive or maxed. string btnState = affordable ? StateNormal : StateGhosted; foreach (var b in allRaise1) { b.Visible = true; b.ActiveState = btnState; } foreach (var b in allRaise10) { b.Visible = true; b.ActiveState = btnState; } } /// Return the raise cost for row from the sheet. /// Returns 0 if the cost array is shorter than expected. internal static long GetRaiseCost(CharacterSheet sheet, int rowIndex) { if (sheet.AttributeRaiseCosts is null || rowIndex < 0 || rowIndex >= sheet.AttributeRaiseCosts.Length) return 0L; return sheet.AttributeRaiseCosts[rowIndex]; } /// Return the display name for the row at , /// or an empty string if the index is out of range. internal static string GetRowName(int index) { if (index < 0) return string.Empty; if (index < AttrRows.Length) return AttrRows[index].name; int vi = index - AttrRows.Length; if (vi < VitalRows.Length) return VitalRows[vi].name; return string.Empty; } /// Return the numeric value for the row at . internal static string GetRowValueString(CharacterSheet sheet, int index) { return index switch { 0 => sheet.Strength.ToString(), 1 => sheet.Endurance.ToString(), 2 => sheet.Coordination.ToString(), 3 => sheet.Quickness.ToString(), 4 => sheet.Focus.ToString(), 5 => sheet.Self.ToString(), 6 => $"{sheet.HealthCurrent}/{sheet.HealthMax}", 7 => $"{sheet.StaminaCurrent}/{sheet.StaminaMax}", 8 => $"{sheet.ManaCurrent}/{sheet.ManaMax}", _ => string.Empty, }; } /// /// Add a single attribute/vital row to as a /// containing icon + name + value children. /// Returns the panel so the caller can wire . /// private static UiClickablePanel AddRow( UiElement list, UiDatFont? datFont, Func? spriteResolve, float left, float top, float width, float height, uint iconDid, string nameText, Func valueProvider) { var row = new UiClickablePanel { Left = left, Top = top, Width = width, Height = height, BackgroundColor = Vector4.Zero, // transparent until selected BorderColor = Vector4.Zero, UseSelectionBars = true, // Item 3: draw sprite as top+bottom bars, not full stretch Anchors = AnchorEdges.Left | AnchorEdges.Top, }; float iconY = (height - IconSize) * 0.5f; var iconEl = new UiText { Left = RowPadX, Top = iconY, Width = IconSize, Height = IconSize, ClickThrough = true, DatFont = null, BackgroundSprite = spriteResolve is not null ? iconDid : 0u, SpriteResolve = spriteResolve is not null ? id => { var (h, w, ht) = spriteResolve(id); return (h, w, ht); } : null, LinesProvider = static () => Array.Empty(), Anchors = AnchorEdges.Left | AnchorEdges.Top, }; float nameX = RowPadX + IconSize + IconGap; float nameW = width * 0.60f; float nameY = 0f; string capturedName = nameText; var nameEl = new UiText { Left = nameX, Top = nameY, Width = nameW, Height = height, DatFont = datFont, ClickThrough = true, Centered = false, RightAligned = false, Padding = 1f, Anchors = AnchorEdges.Left | AnchorEdges.Top, }; nameEl.LinesProvider = () => new[] { new UiText.Line(capturedName, Body) }; float valueW = width - nameX - nameW - RowPadX; float valueX = nameX + nameW; var valueEl = new UiText { Left = valueX, Top = nameY, Width = valueW > 0f ? valueW : 40f, Height = height, DatFont = datFont, ClickThrough = true, RightAligned = true, Anchors = AnchorEdges.Left | AnchorEdges.Top, }; var capturedProvider = valueProvider; valueEl.LinesProvider = () => new[] { new UiText.Line(capturedProvider(), Body) }; row.AddChild(iconEl); row.AddChild(nameEl); row.AddChild(valueEl); list.AddChild(row); return row; } // ── Footer — dynamic (State A + State B via sel[]) ──────────────────────── /// /// Bind all 5 footer elements with providers that close over : /// when sel[0] == -1 (nothing selected) they emit State-A content; /// when a row is selected they emit State-B content. /// /// State A (DisplayDefaultFooter 0x0049cde0): /// title = "Select an Attribute to Improve"; line-1 label = "Skill Credits Available:"; /// line-1 value = SkillCredits; line-2 label = "Unassigned Experience:"; /// line-2 value = UnassignedXp. /// /// State B (attribute selected): /// 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, UiDatFont? datFont, Func data, int[] sel) { // Walk the State A container (0x10000240) to find the wide-label copies of the // 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); // 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 (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) { // The dat title element is H=55 (the full footer box). The dat says VJustify=Center, so // without an override the text would center vertically in the 55px box, overlapping // line-1/line-2 below. We set VerticalJustify=Top explicitly so the text renders at the // top of the 55px box (y≈Padding), keeping all three footer lines non-overlapping. // The dat says HJustify=Center (Centered=true from BuildText) — the title is centered. // BackgroundSprite cleared: its full-height sprite would cover line-1/line-2. titleEl.BackgroundSprite = 0; titleEl.VerticalJustify = VJustify.Top; // dat says Center; override to Top (see comment above) } // Title (FooterTitle 0x1000024E, 20px from dat): pass null → keep dat font. // Fix C: the dat has a 20px font for the footer title. Let it drive. if (titleEl is not null) { // DatFont: null → keep the build-time dat font (20px in studio, global fallback in live game). // Centered=true comes from the dat (HJustify=Center) via BuildText — not overridden here. // RightAligned stays false (BuildText default for a Center element). titleEl.ClickThrough = true; titleEl.LinesProvider = () => { if (sel[0] < 0) return new[] { new UiText.Line("Select an Attribute to Improve", Body) }; var s = data(); string name = GetRowName(sel[0]); string value = GetRowValueString(s, sel[0]); // State B title is WHITE (retail confirmed). return new[] { new UiText.Line($"{name}: {value}", Vector4.One) }; }; } // Footer lines (all dat-origin with their own font sizes): pass null → keep dat font. var l1L = ByPos(20f, 5f, FooterLine1Label); LabelProvider(l1L, null, Body, () => sel[0] < 0 ? "Skill Credits Available:" : "Experience To Raise:"); var l1V = ByPos(20f, 200f, FooterLine1Value); LabelProvider(l1V, null, Body, () => { if (sel[0] < 0) return data().SkillCredits.ToString(); long cost = GetRaiseCost(data(), sel[0]); return cost > 0 ? cost.ToString("N0") : "Infinity!"; }); // Line-2 elements: pass null → keep dat font. var l2L = ByPos(37f, 5f, FooterLine2Label); LabelProvider(l2L, null, Body, static () => "Unassigned Experience:"); var l2V = ByPos(37f, 200f, FooterLine2Value); LabelProvider(l2V, null, 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; foreach (var c in node.Children) if (ContainsWidget(c, target)) return true; return false; } private static void Label(ImportedLayout layout, uint id, UiDatFont? datFont, Vector4 color, Func text) { if (layout.FindElement(id) is UiText t) { // Null = keep whatever the importer (dat FontDid resolver) set at build time. // Non-null = controller explicit override. if (datFont is not null) t.DatFont = datFont; t.Centered = true; t.ClickThrough = true; t.LinesProvider = () => new[] { new UiText.Line(text(), color) }; } } /// Two-line centered label. Provides TWO lines from LinesProvider so both /// fit side-by-side in a narrow element without truncation. The scroll path in /// renders multiple lines oldest-first (top-to-bottom), so /// line 0 = (top) and line 1 = (bottom). /// This replaces the single-line "Character Level" caption which truncated in the 65px element. /// Source: retail spec (2026-06-26-character-window-retail-reference.md §State 1 level caption). private static void LabelTwoLine(ImportedLayout layout, uint id, UiDatFont? datFont, Vector4 color, string line1, string line2) { if (layout.FindElement(id) is UiText t) { // Null = keep whatever the importer (dat FontDid resolver) set at build time. if (datFont is not null) t.DatFont = datFont; t.Centered = false; // non-Centered → scroll/multi-line path t.RightAligned = false; t.ClickThrough = true; t.Padding = 1f; t.LinesProvider = () => new[] { new UiText.Line(line1, color), new UiText.Line(line2, color), }; } } /// Left-justified label (for captions that should be left-aligned, not centered). /// Padding=0 so a single dat-font line (≈12px) fits cleanly in a small element without /// being clipped by the bottom-pin scroll math (top=Padding, bottom=H-Padding). private static void LabelLeft(ImportedLayout layout, uint id, UiDatFont? datFont, Vector4 color, Func text) { if (layout.FindElement(id) is UiText t) { // Null = keep whatever the importer (dat FontDid resolver) set at build time. if (datFont is not null) t.DatFont = datFont; t.Centered = false; t.RightAligned = false; t.ClickThrough = true; t.Padding = 0f; // avoid scroll clip in small-height header elements 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. /// 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; // Null = keep whatever the importer (dat FontDid resolver) set at build time. if (datFont is not null) t.DatFont = datFont; t.Centered = false; t.RightAligned = false; t.ClickThrough = true; t.Padding = 0f; 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 void AddTabSpritesToRoot( UiElement root, Func spriteResolve, UiDatFont? datFont, float tabX, string label, bool isOpen) { uint leftId = isOpen ? TabOpenLeft : TabClosedLeft; uint centerId = isOpen ? TabOpenCenter : TabClosedCenter; uint rightId = isOpen ? TabOpenRight : TabClosedRight; // 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); } /// 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 . /// /// /// The standard returns only the LAST widget /// registered for a given id; for elements duplicated across tab-page sub-layouts /// (raise buttons, close buttons) we need ALL copies so that visibility changes are /// reflected in every page — not just the last-mounted one. /// /// /// /// Implementation: walk the entire tree calling /// is not enough (it uses a dict). Instead we /// exploit the fact that identical element ids produce widgets that all share the SAME /// instance in _byId for THEIR copy; but siblings from /// different inheritance mounts are SEPARATE instances not in the same _byId slot. /// We therefore walk the tree recursively and collect every whose /// ActiveState reflects the dat default (before our code sets it), which is not a /// reliable discriminator. Instead, we gather ALL instances from /// the subtree at the known spatial position (bottom of the panel) — but positions can /// overlap across pages. /// /// /// /// The correct approach: since _byId stores only one instance per id, we use the /// for the canonical id, then do a FULL tree walk /// to find ADDITIONAL instances that have identical Width×Height to /// the known button. This works because the three page copies share the same dat template /// and thus the same geometry. Collected via reference-equality guard to avoid duplicates. /// /// private static void CollectButtonsById( UiElement node, uint targetId, List result, ImportedLayout layout) { // Find the canonical copy (last registered in _byId) as the geometry reference. if (layout.FindElement(targetId) is not UiButton canonical) return; // Walk the tree and collect ALL UiButton instances matching the canonical geometry. // The canonical copy itself will also be found — that's fine; use a HashSet to dedup. var seen = new HashSet(ReferenceEqualityComparer.Instance); CollectMatchingButtons(node, canonical, seen, result); } private static void CollectMatchingButtons( UiElement node, UiButton canonical, HashSet seen, List result) { if (node is UiButton btn && Math.Abs(btn.Width - canonical.Width) < 0.5f && Math.Abs(btn.Height - canonical.Height) < 0.5f && seen.Add(btn)) { result.Add(btn); } foreach (var child in node.Children) CollectMatchingButtons(child, canonical, seen, result); } }