using System; using System.Collections.Generic; using System.Globalization; 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 states: whichever of Attributes/Skills/Titles is active shows "Open" /// (0x0C); the other two show "Closed" (0x0B) — Attributes is the retail-authored /// default. Source: UIElement::SetState @ 0x00464E70. The imported Type-12 /// tab owns its authored font color and propagates the state to its three chrome /// children through PassToChildren. Campaign CT slice CT3 (2026-08-24) wires the /// Titles tab click to a real page switch (previously a known-inert AP-109 gap); /// owns the Titles page's OWN content /// (row list, display-title text, Set-as-Display button) once its page container /// is shown. /// /// 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 public const uint ListScrollbarId = 0x1000023Eu; // m_pListBox vertical scrollbar gutter public const uint ListDividerId = 0x1000023Fu; // bottom divider above footer /// Campaign CT slice CT4: the luminance pair /// (m_pLuminanceLabelText/m_pLuminanceText), shown only past level 200 /// with nonzero MaximumLuminance — see /// gmStatManagementUI::UpdateExperience (0x004f0a70)'s luminance /// branch. CT4 fix round (2026-08-25, BLOCKER 1): the caption/value /// SetText calls PE-byte-decoded from the gmStatManagementUI /// vftable-adjacent data region — caption UTF-16 "Luminance:" at /// @0x007c3dd4, value narrow "%s / %s" at /// @0x007c3dcc — are now bound; see the Bind method's own /// remarks. public const uint LuminanceLabelId = 0x100005C5u; public const uint LuminanceValueId = 0x100005C6u; /// Retail literal "Luminance:" — see /// 's remarks for the PE-byte-decode /// citation. private const string LuminanceCaption = "Luminance:"; // ── 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) ──────────────────── // These are imported Type-12 UIElement_Text tabs. Their Closed/Open states carry // the caption color and PassToChildren=true; their three retained children carry // the authored left/center/right chrome. Character and Spellbook therefore share // RetailTabBinding and the generic IUiDatStateful path rather than synthesizing art. public const uint TabAttribId = 0x10000228u; // Attributes tab group public const uint TabSkillsId = 0x10000229u; // Skills tab group public const uint TabTitlesId = 0x10000538u; // Titles tab group // Tab page content containers. The imported layout contains duplicated child ids // inside these pages, so page selection must use the page ids themselves rather // than layout.FindElement(childId)'s last-registered duplicate. public const uint AttributesPageId = 0x1000022Bu; public const uint SkillsPageId = 0x1000022Cu; public const uint TitlesPageId = 0x10000539u; // ── 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 private static readonly Vector4 Body = new(0.92f, 0.90f, 0.82f, 1f); // parchment-white body text // Campaign CT slice CT4 (2026-08-24): the former hand-picked "Gold" // header-level color constant is deleted — CT1's live-DAT pin confirmed // the level element authors its own pale-gold FontColor (+ Outline); // LabelAuthoredColor reads it from the widget instead. /// Row highlight FALLBACK color — used only when /// spriteResolve is unavailable (tests, headless mode) and the /// real art cannot be drawn. CT5 fix /// round: the former comment here claimed this tint "matches retail /// sprite 0x06001397 visual intent" — that was never accurate (0x06001397 /// is the spellbook's unrelated overlay sprite, and this solid tint was /// never tuned to any specific sprite's actual pixels either way); it is /// a synthetic no-art placeholder, not a retail-faithful color. private static readonly Vector4 HighlightBg = new(1f, 0.75f, 0.2f, 0.25f); // LayoutDesc 0x2100002E, FooterTitle 0x1000024E property 0x1B: // [0]=white, [1]=green, [2]=red, [3]=light blue (#7FFFFF). private static readonly Vector4 RetailBuffGreen = new(0f, 1f, 0f, 1f); private static readonly Vector4 RetailDebuffRed = new(1f, 0f, 0f, 1f); private static readonly Vector4 RetailVitaeBlue = new(127f / 255f, 1f, 1f, 1f); // ── Row layout constants ───────────────────────────────────────────────── // Campaign CT slice CT5 (2026-08-25): the shared attribute/skill data-row // template 0x10000248 (LayoutDesc 0x21000045 — InfoRegion::InfoRegion // @0x004F1450 template index 0, used for BOTH gmAttributeUI and // gmSkillUI rows) authors W=282 H=20 with icon 0x10000129 FLUSH LEFT // (X=0 Y=0 W=20 H=20, full row height), name 0x1000012A at FIXED X=25 // W=150, and value 0x1000012B at FIXED X=175 W=100 right-justified — // its right edge (275) sits 7px short of the row's own 282px right // edge, the authored gutter the owner reported (item 2). These are // AUTHORED PIXEL VALUES, not width-relative fractions or derived // offsets — the former RowHeight=22/IconSize=16px-at-X=4/ // nameW=width*0.60 geometry had no dat basis at all. Ground truth + // the explicit warning against composing a derived "gutter" formula: // docs/research/2026-08-24-campaign-ct-dat-ground-truth.md §2, pinned // by CharacterPanelLiveDatTests.AttributeRowTemplate_IconIsFlushLeftTwentyPixels_NameAndValueAreFixedColumns. private const float RowHeight = 20f; private const float RowIconX = 0f; private const float RowIconSize = 20f; private const float RowNameX = 25f; private const float RowNameW = 150f; private const float RowValueX = 175f; private const float RowValueW = 100f; // Section-header caption inset ONLY (AddSkillHeader's 0x10000249..0x1000024C // captions carry their own authored geometry, distinct from the data-row // template above) — retained at its pre-CT5 value. CT5 fix round // (NOTE c): CT1 verified only the FOUR SPRITES for these header templates // (SkillSectionHeaderTemplates_MatchExistingSpriteConstants: Width=280, // Height=20, sprite match) — the caption LABEL's own authored geometry // (W=280 with L5/R5 margins per the header template, versus our current // header width, which comes from RowContentWidth/SkillViewportWidth same // as the data rows, plus this flat 4px inset) was never pinned or // cross-checked against that. This is a KNOWN, RECORDED residual gap — // NOT a cleared divergence — left for a future slice to pin the caption // child's authored margins the same way CT1 pinned the data-row // template's icon/name/value columns; behavior is unchanged here. private const float RowPadX = 4f; private const float SkillHeaderHeight = 20f; // Authored row-template CEILING width (0x10000248 W=282) — CT5: the // row's own width comes from THIS authored constant as an upper bound, // never the ListBox's raw 300px Width, and never a derived // "listWidth minus gutter" formula (the ground-truth doc's explicit // warning). CT5 fix round (NOTE b, tempered wording): this ceiling is // NOT the row's width in every context. Attribute/vital rows land on it // exactly (300px ListBox clamped to 282). The SKILL page instead first // narrows the viewport to SkillViewportWidth's scrollbar-gutter // measurement (scrollbar.Left − list.Left = 281px in the real // production layout — CharacterPanelLiveDatTests. // StatListBox_AuthorsFiveRowTemplatesInSharedLayout pins the scrollbar // at X=281), and RowContentWidth then clamps skill rows to THAT // (281 < 282) rather than the raw 282 ceiling — see // Bind_SkillRow_ClampsToAuthoredScrollbarGutterWidth. private const float SkillContentWidth = 282f; private const uint SkillHeaderSpecializedSprite = 0x06000F90u; private const uint SkillHeaderTrainedSprite = 0x06000F86u; private const uint SkillHeaderUntrainedSprite = 0x06000F98u; private const uint SkillHeaderUnusableSprite = 0x06000F89u; // CT5 SEALED VERDICT (ground-truth doc §2 "SEALED VERDICT: RowHighlightSprite // is wrong, not merely flagged"): gmAttributeUI::UpdateSelection // @0x0049DEE0 calls SetState(selected ? 6 : 1) on the row; InfoRegion:: // SetState @0x004F0EE0 forwards that to the row instantiated from THIS // exact template (0x10000248), whose Highlight-state media is // 0x06000F93 — a full-row background SWAP, not an overlay. The former // 0x06001397 constant belongs to a DIFFERENT mechanism entirely: the // spellbook row's separate selected-overlay CHILD element // (UIElement_UIItem::SetSelectedState @0x004E1240, SpellbookRowStyle.cs) // — that file and its tests are correct and must NOT be touched. private const uint RowHighlightSprite = 0x06000F93u; // CT5 fix round (SHOULD-FIX 1, visible retail gap): the row template's // NORMAL-state media — StateMedia[Normal] on 0x10000248, pinned byte- // exact by CharacterPanelLiveDatTests. // AttributeRowTemplate_IconIsFlushLeftTwentyPixels_NameAndValueAreFixedColumns // (row.StateMedia["Normal"].File == 0x06004CC2). CT5 itself only wired // the Highlight-state swap above; UNSELECTED rows drew fully // transparent instead of this authored background. Independently // decoded against the installed DAT (2026-08-25): PFID_A8R8G8B8, 48x48, // a single uniform color (0,0,0,175) — i.e. a ~69%-opaque (175/255) // flat black tile that retail's native-size copy-or-tile blit repeats // across the row (see UiPanel.BackgroundSprite's doc comment for the // tiling mechanism) to produce the dark band under every unselected // attribute/skill row. Selected rows keep RowHighlightSprite above. private const uint RowNormalSprite = 0x06004CC2u; // CT5 (AP-235 unification, gmAttributeUI::PostInit @0x0049DB70 verbatim): // per-attribute icon DIDs resolve via DBObj::GetDIDByEnum(statEnum, // category 0x10000002); per-vital (Attribute2ndInfoRegion) icon DIDs via // DBObj::GetDIDByEnum(vitalEnum, category 0x10000003). Both categories // are consumed through the shared RetailDataIdResolver.Resolve seam // (the same master-map -> category-map -> value indirection already // ported for the title chain and RetailKeyNames) rather than a fourth // ad-hoc hardcoded DID table. Live-DAT-verified (2026-08-25): every // hardcoded fallback value in AttrRows/VitalRows below already matches // the resolved DID byte-exact — see // CharacterPanelLiveDatTests.AttributeAndVitalIconDids_MatchTheRetailEnumMapperChain. private const uint AttributeIconCategory = 0x10000002u; private const uint VitalIconCategory = 0x10000003u; // Scrollbar chrome from base layout 0x2100003E, shared with chat/ // inventory — sprite set + retail button seating live in // RetailScrollbarChrome (2026-08-24: the previous local constants seated // the DOWN-arrow art on the top button). private enum CharacterStatTab { Attributes, Skills, Titles, } public enum RaiseTargetKind { Attribute, Vital, Skill, TrainSkill, } public readonly record struct RaiseRequest( RaiseTargetKind Kind, uint StatId, long Cost, int Amount); /// /// Handles a retail stat raise and invokes when /// the request has either been applied or cancelled. The completion seam is /// required by asynchronous retail confirmation dialogs such as /// gmSkillUI::TrainSkill @ 0x0049C5F0. /// public delegate void RaiseRequestHandler(RaiseRequest request, Action completed); private sealed record SkillRowBinding(UiClickablePanel Panel, CharacterSkill Skill); // ── Attribute row descriptors — retail display order per spec §1 ───────── // CT5 fix round (NOTE d): internal (not private) so // CharacterPanelLiveDatTests.AttributeAndVitalIconDids_MatchTheRetailEnumMapperChain // can iterate THESE tuples directly instead of a re-typed duplicate // literal array — a divergence between the two would previously have // gone undetected. InternalsVisibleTo("AcDream.App.Tests") already // covers this assembly (AcDream.App.csproj). internal static readonly (string name, uint iconDid, uint statId)[] AttrRows = new[] { ("Strength", 0x060002C8u, 1u), ("Endurance", 0x060002C4u, 2u), ("Coordination", 0x060002C9u, 4u), ("Quickness", 0x060002C6u, 3u), ("Focus", 0x060002C5u, 5u), ("Self", 0x060002C7u, 6u), }; // CT5 fix round (NOTE d): internal for the same reason as AttrRows above. internal static readonly (string name, uint iconDid, uint maxStatId)[] VitalRows = new[] { ("Health", 0x06004C3Bu, 1u), // max enum 1; current enum 2 ("Stamina", 0x06004C3Cu, 3u), // max enum 3; current enum 4 ("Mana", 0x06004C3Du, 5u), // max enum 5; current enum 6 }; /// /// TS-85 (character-panel tooltips): retail SkillSystem::InqAttributeDescription /// @ 0x005c8e30 — six hardcoded strings, byte-decoded from the retail binary's /// string pool (the pseudo-C dump truncates them with "…"). Ported from /// AttributeInfoRegion::AttributeInfoRegion @ 0x004f1530's /// UIElement::SetTooltip call at 0x004f1617, keyed by retail attribute id /// (matches ' statId column, NOT the array index — the /// authored row order swaps Coordination/Quickness relative to the id numbering). /// private static readonly IReadOnlyDictionary AttributeDescriptions = new Dictionary { [1u] = "Measures your character's muscular power.", // Strength [2u] = "Measures how healthy your character is.", // Endurance [3u] = "Measures how fast your character is.", // Quickness [4u] = "Measures your character's reflexes", // Coordination (no trailing period — verified byte-exact) [5u] = "Measures your character's mind and senses.", // Focus [6u] = "Measures your character's willpower.", // Self }; /// /// TS-85 (character-panel tooltips): retail SkillSystem::InqAttribute2ndDescription /// @ 0x005c8f70 — three hardcoded strings shared by each Max/Current pair (1&2, /// 3&4, 5&6), byte-decoded from the retail string pool. Ported from /// Attribute2ndInfoRegion::Attribute2ndInfoRegion @ 0x004f1680's /// UIElement::SetTooltip call at 0x004f1777, keyed by ' /// maxStatId column (1/3/5 — either member of the pair resolves the same text in /// retail). /// private static readonly IReadOnlyDictionary Attribute2ndDescriptions = new Dictionary { [1u] = "(Endurance/2)\nIf you run out of health, you will die!", // Health [3u] = "(Endurance)\nAffects your actions and movement.", // Stamina [5u] = "(Self)\nAffects how much magic you can cast.", // Mana }; /// /// 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. /// /// /// /// CT5: ports DBObj::GetDIDByEnum(enumValue, /// category) (master map -> category map -> value) for the per-attribute/ /// per-vital row icon DIDs — pass (enumValue, category) => /// RetailDataIdResolver.Resolve(dats, enumValue, category) under the /// caller's dat lock. null (tests, or no live dat) falls back to /// /' hardcoded DID column, /// which the CT5 InstalledDat pin proves already matches the live-resolved /// value byte-exact. /// /// /// /// #431-CA5 gate fix (2026-08-24): the data-changed refresh. Retail's /// panel refreshes rows from qualities on every authoritative /// quality-change element message (InfoRegion::OnQualityChanged @ /// 0x004F0EB0 → the 0x10000004 broadcast); before this, rows only /// rebuilt on CLICKS, so a trained skill stayed in the untrained /// section (and CA4's awaiting-ghost never visually released) until the /// next click. The caller invokes this from the sheet-changed /// subscription. /// public static Action Bind( ImportedLayout layout, Func data, UiDatFont? datFont = null, UiDatFont? rowDatFont = null, Func? spriteResolve = null, RaiseRequestHandler? onRaiseRequest = null, Action? onClose = null, Func? iconDidResolve = 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; WindowChromeController.BindCloseButton(layout, onClose); var activeTab = new[] { CharacterStatTab.Attributes }; var attrSel = new[] { -1 }; var skillSel = new[] { -1 }; var activeListEntries = new List(); var currentAttributeRows = new List(); var currentSkillRows = new List(); UiElement? attributesTab = layout.FindElement(TabAttribId); UiElement? skillsTab = layout.FindElement(TabSkillsId); UiElement? titlesTab = layout.FindElement(TabTitlesId); UiElement? contentPage = FindDirectChildById(layout.Root, AttributesPageId); // CT3: unlike Attributes/Skills (which share ONE mounted page and just // rebind its content — see the Attributes/Skills tab-switch note above), // the Titles page (0x10000539) is its OWN separate, non-duplicated subtree // (CT1 ground truth §3) that must be actually shown/hidden. UiElement? titlesPage = FindDirectChildById(layout.Root, TitlesPageId); // Name (18px from dat FontDid), Heritage (14px), PkStatus (14px): // Fix C: pass null → Label's null-guard keeps the build-time dat font. // Campaign CT slice CT4 (2026-08-24): CT1's live-DAT pin // (HeaderElements_AuthorExpectedFontsAndColors) confirmed all FOUR // header identity elements — Name, Heritage, PkStatus, Level — carry // their own authored FontColor (white/white/white/pale-gold with // Outline). The "runtime color, dat carries none" reasoning this // block used to justify a hand-picked Body/Gold constant per element // was FALSIFIED by that pin: every element below now sources its // color from the widget's own DAT-set DefaultColor // (LabelAuthoredColor), matching the "authored color/font wins" // pattern CT3's CharacterTitlesController already established for // its row/display text. Level's Outline is likewise already applied // at import time (DatWidgetFactory.BuildText reads dat property // 0x21) — no controller-side Outline flag needed. LabelAuthoredColor(layout, contentPage, NameId, null, () => data().Name); LabelAuthoredColor(layout, contentPage, HeritageId, null, () => CharacterIdentityText.StatHeaderLine(data())); LabelAuthoredColor(layout, contentPage, PkStatusId, null, () => 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, contentPage, 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. // Source: spec §Level area (65,50) + decomp gmStatManagementUI::UpdateCharacterInfo // 0x004f0770. CT4 contract (PE-recovered 2026-08-24): InqInt(0x19) present formats // with "%d" semantics (a bare integer, data_7a0184); absent shows the literal "???" // (data_7b0f34) — CharacterSheet.Level is null in that case. LabelAuthoredColor(layout, contentPage, LevelId, null, () => data().Level is int lvl ? lvl.ToString(CultureInfo.InvariantCulture) : "???"); // TotalXpLabel (16px from dat) + TotalXp (16px from dat): pass null → keep dat font. LabelLeft(layout, contentPage, TotalXpLabelId, null, Body, static () => "Total Experience (XP):"); LabelRight(layout, contentPage, TotalXpId, null, Body, () => FormatXp(data().TotalXp)); // 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 (FindElementByDatId(layout, contentPage, 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 (FindTextByDatId(layout, contentPage, 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 (FindElementByDatId(layout, contentPage, 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 (FindTextByDatId(layout, contentPage, XpNextValueId) is UiText xpValue) { if (datFont is not null) xpValue.DatFont = datFont; xpValue.ClickThrough = true; xpValue.RightAligned = true; xpValue.OneLine = true; xpValue.Padding = 0f; // avoid scroll clip xpValue.LinesProvider = () => new[] { new UiText.Line(FormatXp(data().XpToNextLevel), Body) }; } } // ── Luminance pair (0x100005C5/C6) — CT4 item 5, text bound at the ── // ── CT4 fix round (2026-08-25, BLOCKER 1) ──────────────────────────── // gmStatManagementUI::UpdateExperience (0x004f0a70): InqInt64(6) // (AvailableLuminance) and InqInt64(7) (MaximumLuminance) are read // unconditionally; the pair is hidden — UIElement_Text::ClearAllText // (@0x004f0e31/@0x004f0e3c) on BOTH m_pLuminanceLabelText and // m_pLuminanceText — whenever "InqInt(0x19) < 0xc8 (200) || // MaximumLuminance == 0". ClearAllText empties the widget's text and // leaves layout/Visible untouched, so this binds the SAME way every // other dynamic label in this method does: a LinesProvider that // re-reads data() on every draw and returns an EMPTY line set when // the gate is closed (retail's ClearAllText) or the resolved content // when it is open — no separate "refresh" call is needed, and no // Visible flag is touched. Retail's SetText calls (recovered by // PE-byte-decoding the gmStatManagementUI vftable-adjacent data // region, since Binary Ninja mislabels the two string pointers as // vftable slots rather than a StringTable key like the PK line): // caption = literal "Luminance:" (UTF-16 @0x007c3dd4); value = // narrow "%s / %s" (@0x007c3dcc) with (available, maximum) in that // order, both formatted through ExperienceSystem::XPToString — ported // as the shared FormatXp helper below (the same one Total XP / XP-to- // next-level already use). bool LuminanceVisible(CharacterSheet sheet) => sheet.Level is int lvl && lvl >= 200 && sheet.MaximumLuminance != 0; if (FindTextByDatId(layout, contentPage, LuminanceLabelId) is UiText luminanceLabel) { luminanceLabel.LinesProvider = () => LuminanceVisible(data()) ? new[] { new UiText.Line(LuminanceCaption, luminanceLabel.DefaultColor) } : Array.Empty(); } if (FindTextByDatId(layout, contentPage, LuminanceValueId) is UiText luminanceValue) { luminanceValue.LinesProvider = () => { var sheet = data(); if (!LuminanceVisible(sheet)) return Array.Empty(); string text = $"{FormatXp(sheet.AvailableLuminance)} / {FormatXp(sheet.MaximumLuminance)}"; return new[] { new UiText.Line(text, luminanceValue.DefaultColor) }; }; } // The tab visuals are already retained in the imported LayoutDesc. Controllers // bind only click behavior and the active Open/Closed state below. // ── Attribute list — 9 rows in list box 0x1000023D ──────────────────── // Mutable selected-index box: -1 = nothing selected. // Gather EVERY copy of the raise buttons in the tree. The raise button ids // (0x10000246, 0x100005EB) each appear TWICE under BOTH the Attributes page // (0x1000022B) and the Skills page (0x1000022C) — once per footer-state group // (0x10000247/0x10000241) — four copies total. Verified against the committed // fixture (CT3 fix round): Titles (0x10000539) authors NO copies of its own — // correcting this comment's earlier, false "once per tab page // (Attributes/Skills/Titles)" claim. 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); var footerDefaultGroups = new List(); var footerSelectedGroups = new List(); var footerInactiveGroups = new List(); if (contentPage is not null) { CollectElementsByDatId(contentPage, FooterStateAId, footerDefaultGroups); CollectElementsByDatId(contentPage, FooterStateBId, footerSelectedGroups); CollectElementsByDatId(contentPage, FooterStateCId, footerInactiveGroups); } else if (layout.Root is not null) { CollectElementsByDatId(layout.Root, FooterStateAId, footerDefaultGroups); CollectElementsByDatId(layout.Root, FooterStateBId, footerSelectedGroups); CollectElementsByDatId(layout.Root, FooterStateCId, footerInactiveGroups); } void SetFooterSelected(bool selected) { foreach (var g in footerDefaultGroups) g.Visible = !selected; foreach (var g in footerSelectedGroups) g.Visible = selected; foreach (var g in footerInactiveGroups) g.Visible = false; } // 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 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: collect the footer groups from the explicit Attributes page id. // State A is visible when nothing is selected; State B is visible when a row is // selected and owns the retail raise buttons; State C stays hidden for now. SetFooterSelected(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, activeTab, attrSel, skillSel, contentPage); SetFooterSelected(false); UiElement? statList = (contentPage is not null ? FindInSubtree(contentPage, static el => HasDatElementId(el, ListBoxId)) : null) ?? layout.FindElement(ListBoxId); // Imported character nodes already carry the exact raw-edge policy from // the 337px base sublayout into the 575px mounted page. Only synthetic/ // legacy widgets need compatibility anchors; assigning Anchors on an // imported node deliberately clears its UiLayoutPolicy. if (statList is not null && statList.LayoutPolicy is null) statList.Anchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Bottom; if (layout.Root is { } stretchRoot) { SetCompatibilityAnchorsAllById(stretchRoot, ListScrollbarId, AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Bottom); SetCompatibilityAnchorsAllById(stretchRoot, ListDividerId, AnchorEdges.Left | AnchorEdges.Bottom); SetCompatibilityAnchorsAllById(stretchRoot, FooterStateAId, AnchorEdges.Left | AnchorEdges.Bottom); SetCompatibilityAnchorsAllById(stretchRoot, FooterStateBId, AnchorEdges.Left | AnchorEdges.Bottom); SetCompatibilityAnchorsAllById(stretchRoot, FooterStateCId, AnchorEdges.Left | AnchorEdges.Bottom); } UiScrollbar? skillScrollbar = PrepareSkillScrollbar(layout, contentPage, statList, spriteResolve); WireRaiseButtonClicks(allRaise1, allRaise10, data, activeTab, attrSel, skillSel, () => currentSkillRows, onRaiseRequest, RefreshAfterRaise); RebuildActiveList(); RetailTabBinding.SetClick(attributesTab, () => SwitchTab(CharacterStatTab.Attributes)); RetailTabBinding.SetClick(skillsTab, () => SwitchTab(CharacterStatTab.Skills)); // CT3 (2026-08-24): the Titles tab now switches to its own real page // (previously the known AP-109 gap — retail-authored closed visual, but // no click routing at all). Content population is // CharacterTitlesController's job, bound separately by the caller // against the SAME imported layout root. RetailTabBinding.SetClick(titlesTab, () => SwitchTab(CharacterStatTab.Titles)); UpdateTabStates(); // ── 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.Root is { } root) { foreach (var page in root.Children) { uint id = DatElementId(page); if (id is AttributesPageId or SkillsPageId or TitlesPageId) page.Visible = id == AttributesPageId; } } void SwitchTab(CharacterStatTab tab) { if (activeTab[0] == tab) return; activeTab[0] = tab; attrSel[0] = -1; skillSel[0] = -1; SetFooterSelected(false); // CT3: Titles is a genuinely separate page container (unlike // Attributes/Skills, which share one mounted page and only rebind // its content — see RebuildActiveList) — actually flip visibility // between it and the shared Attributes/Skills content page. bool showTitles = tab == CharacterStatTab.Titles; if (titlesPage is not null) titlesPage.Visible = showTitles; if (contentPage is not null) contentPage.Visible = !showTitles; if (showTitles) { // CT3 fix round: the prior comment here ("Titles authors its // own copies of the raise buttons") was FALSE — verified // against the fixture, Titles (0x10000539) has none; see the // corrected collection comment above. contentPage.Visible = // false (just above) already suppresses the Attributes // page's real raise-button copies for both draw and click // routing (UiElement early-returns on an invisible node // before descending to children), so this loop is a no-op // in the common case. It is kept only as a defensive // fallback for the case where contentPage was not found at // bind time (contentPage is null, line ~525) but allRaise1/ // allRaise10 were still populated via the tree-walk/FindElement // fallback above. foreach (var b in allRaise1) b.Visible = false; foreach (var b in allRaise10) b.Visible = false; } else { RebuildActiveList(); RefreshActiveRaiseButtons(); } UpdateTabStates(); Console.WriteLine($"[CharacterStat] Tab click: {tab}"); } void UpdateTabStates() { RetailTabBinding.SetOpen(attributesTab, activeTab[0] == CharacterStatTab.Attributes); RetailTabBinding.SetOpen(skillsTab, activeTab[0] == CharacterStatTab.Skills); RetailTabBinding.SetOpen(titlesTab, activeTab[0] == CharacterStatTab.Titles); } void RebuildActiveList() { if (statList is null) return; foreach (var entry in activeListEntries) statList.RemoveChild(entry); activeListEntries.Clear(); currentAttributeRows.Clear(); currentSkillRows.Clear(); // CT6 (2026-08-25): both tabs now stack their rows inside a // UiScrollablePanel viewport child of statList — the SAME // shrink-and-scroll contract the Skills tab already had. // UiScrollablePanel.LayoutScrollableChildren (called every // OnDraw) recomputes Scroll.ViewHeight from the viewport's OWN // current Height every frame; the viewport's Anchors // (Left|Top|Bottom, a child of statList) track statList's live // Height as the window resizes, so Scroll.HasOverflow flips live // with no extra wiring. Previously only Skills got this // treatment — Attributes added rows directly to statList with no // clipping/scrolling and the shared scrollbar was force-hidden, // which is the "we never show the scrollbar on Attributes" gap // (owner report item 2) and the reason a shrunk window could not // show the overflowing attribute/vital rows at all. bool isSkills = activeTab[0] == CharacterStatTab.Skills; float contentW = isSkills ? SkillViewportWidth(statList, skillScrollbar) : RowContentWidth(statList); var viewport = new UiScrollablePanel { Left = 0f, Top = 0f, Width = contentW, Height = statList.Height, LineHeight = (int)RowHeight, Anchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Bottom, }; statList.AddChild(viewport); // #372-class fix (same mechanism as UiTemplateListBox.Viewport's // own lazy getter): RebuildActiveList runs during Bind, BEFORE // the window's first anchor pass ever reflows statList up from // its raw DAT-authored height (160px) to its actual mounted // height. Left to its lazy default, viewport's Left|Top|Bottom // anchor would capture its baseline margins on ITS OWN first // ApplyAnchor call — which happens AFTER statList has already // grown — measuring a bogus non-zero bottom margin (e.g. // "398 grown list − 160 stale viewport = 238px margin") that then // permanently caps the viewport 238px short on every future // resize. Capturing NOW, while viewport.Height still exactly // equals statList's own CURRENT (pre-reflow, zero-margin) // height, gives a true (0,0,0,0) baseline that then correctly // full-stretches on every later resize. viewport.CaptureCurrentAnchorBaseline(); activeListEntries.Add(viewport); if (isSkills) { BuildSkillRows(viewport, rowDatFont, spriteResolve, data, skillSel, allRaise1, allRaise10, SetFooterSelected, out currentSkillRows); } else { currentAttributeRows = BuildAttributeRows(viewport, rowDatFont, spriteResolve, data, attrSel, allRaise1, allRaise10, SetFooterSelected, iconDidResolve); } // Always BOUND for whichever tab is active — no per-tab visibility // toggle is needed here. CT6 fix round (S2 correction): the // scrollbar's own .Visible=true just keeps it in the tree; what // actually shows or hides it on screen is UiScrollbar's // IsPresentationVisible, and 0x1000023E authors 0x79 // (HideWhenDisabled) TRUE (fixture-verified — the 121/0x79 // property on the fixture's scrollbar element carries // BoolValue=true). That means a fitting list HIDES the bar // entirely, not a full-track "disabled" thumb left visible — the // previous comment here had the authored default backwards. // Content that overflows still makes the bar visible and // interactive, same as always. if (skillScrollbar is not null) { skillScrollbar.Model = viewport.Scroll; skillScrollbar.Visible = true; } } void RefreshActiveRaiseButtons() { if (activeTab[0] == CharacterStatTab.Attributes) { RefreshRaiseButtons(attrSel[0], data, allRaise1, allRaise10); return; } CharacterSkill? selectedSkill = skillSel[0] >= 0 && skillSel[0] < currentSkillRows.Count ? currentSkillRows[skillSel[0]].Skill : null; RefreshSkillRaiseButtons(selectedSkill, data(), allRaise1, allRaise10); } void RefreshAfterRaise(uint? selectedSkillId) { if (activeTab[0] == CharacterStatTab.Skills) { if (selectedSkillId is null && skillSel[0] >= 0 && skillSel[0] < currentSkillRows.Count) { selectedSkillId = currentSkillRows[skillSel[0]].Skill.Id; } RebuildActiveList(); skillSel[0] = -1; if (selectedSkillId is uint id) { for (int i = 0; i < currentSkillRows.Count; i++) { if (currentSkillRows[i].Skill.Id == id) { skillSel[0] = i; break; } } } ApplySkillSelectionVisuals(skillSel[0], currentSkillRows, spriteResolve); SetFooterSelected(skillSel[0] >= 0); } RefreshActiveRaiseButtons(); // CT4 fix round: the luminance pair's LinesProvider re-reads // data() on every draw (same as every other dynamic label here), // so no explicit refresh call is needed for a level-up or a // luminance-award quality change. } return () => RefreshAfterRaise(null); } private static UiScrollbar? PrepareSkillScrollbar( ImportedLayout layout, UiElement? contentPage, UiElement? statList, Func? spriteResolve) { if (spriteResolve is null) return null; // The mounted character sublayout contains duplicated stat-management // branches with the same element ids. Bind the scrollbar adjacent to the // active list first; a page-wide DFS can select an inactive duplicate that // is never drawn while leaving the visible gutter unconfigured. UiElement? source = statList?.Parent?.Children.FirstOrDefault( static el => HasDatElementId(el, ListScrollbarId)) ?? (contentPage is not null ? FindInSubtree(contentPage, static el => HasDatElementId(el, ListScrollbarId)) : null) ?? layout.FindElement(ListScrollbarId); if (source is UiScrollbar existingBar) { existingBar.SpriteResolve ??= id => { var (handle, width, height) = spriteResolve(id); return (handle, width, height); }; existingBar.Visible = false; return existingBar; } UiElement? parent = source?.Parent ?? statList?.Parent; if (parent is null || statList is null) return null; float left = source is not null ? source.Left : statList.Left + MathF.Min(statList.Width, SkillContentWidth); float top = source?.Top ?? statList.Top; float width = source?.Width > 0f ? source.Width : 16f; float height = source?.Height > 0f ? source.Height : statList.Height; int z = (source?.ZOrder ?? statList.ZOrder) + 1; if (source is not null) source.Visible = false; var bar = new UiScrollbar { Left = left, Top = top, Width = width, Height = height, ZOrder = z, Anchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Bottom, }; ConfigureSkillScrollbar(bar, spriteResolve); bar.Visible = false; parent.AddChild(bar); return bar; } private static void ConfigureSkillScrollbar( UiScrollbar bar, Func spriteResolve) { bar.SpriteResolve = id => { var (h, w, ht) = spriteResolve(id); return (h, w, ht); }; RetailScrollbarChrome.ApplyVertical(bar); } private static float SkillViewportWidth(UiElement statList, UiScrollbar? bar) { if (bar is not null && ReferenceEquals(bar.Parent, statList.Parent)) { float widthToGutter = bar.Left - statList.Left; if (widthToGutter > 32f) return widthToGutter; } return statList.Width > 0f ? MathF.Min(statList.Width, SkillContentWidth) : SkillContentWidth; } /// /// CT5: the row's own rendered width is 's own /// width clamped to the AUTHORED row-template CEILING /// (282px, ) — the two numbers never /// compose into a derived "gutter" formula (ground-truth doc §2's /// explicit warning); a shorter clamps the row /// down, but a wider one never stretches it past 282. /// CT5 fix round (NOTE b, tempered wording): this does NOT mean /// every row is 282px. Attribute/vital rows pass the raw ListBox /// (300px, 's own dat /// rect) and land on the 282 ceiling exactly. The skill page instead /// passes the narrower SkillViewportWidth-computed viewport /// (281px in production — the real scrollbar's authored gutter), so /// skill rows clamp to 281, one pixel short of the template ceiling. /// /// private static float RowContentWidth(UiElement list) => list.Width > 0f ? MathF.Min(list.Width, SkillContentWidth) : SkillContentWidth; /// /// CT5 (AP-235): resolve a row icon DID through the live /// DBObj::GetDIDByEnum chain when a resolver is available, /// falling back to the hardcoded / /// column value otherwise (tests, or a resolve miss). /// private static uint ResolveIconDid( Func? resolver, uint enumValue, uint category, uint fallback) { if (resolver is null) return fallback; uint resolved = resolver(enumValue, category); return resolved != 0u ? resolved : fallback; } // ── 9-row attribute list ───────────────────────────────────────────────── private static List BuildAttributeRows( UiElement list, UiDatFont? datFont, Func? spriteResolve, Func data, int[] sel, List allRaise1, List allRaise10, Action setFooterSelected, Func? iconDidResolve) { float listW = RowContentWidth(list); float y = 0f; var rows = new List(); for (int i = 0; i < AttrRows.Length; i++) { var (rowName, iconDid, statId) = AttrRows[i]; int rowIndex = i; var row = AddRow(list, datFont, spriteResolve, left: 0f, top: y, width: listW, height: RowHeight, iconDid: ResolveIconDid(iconDidResolve, statId, AttributeIconCategory, 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(); }, valueColorProvider: () => AttributeValueColor(data(), rowIndex)); row.TooltipText = AttributeDescriptions.GetValueOrDefault(statId); row.OnClick = () => { HandleRowClick(rowIndex, sel, rows, spriteResolve, data, allRaise1, allRaise10); setFooterSelected(sel[0] >= 0); }; rows.Add(row); y += RowHeight; } for (int i = 0; i < VitalRows.Length; i++) { var (rowName, iconDid, maxStatId) = 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: ResolveIconDid(iconDidResolve, maxStatId, VitalIconCategory, 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, }; }, valueColorProvider: () => VitalValueColor(data(), rowIndex)); row.TooltipText = Attribute2ndDescriptions.GetValueOrDefault(maxStatId); row.OnClick = () => { HandleRowClick(absIndex, sel, rows, spriteResolve, data, allRaise1, allRaise10); setFooterSelected(sel[0] >= 0); }; rows.Add(row); y += RowHeight; } return rows; } private static List BuildSkillRows( UiElement list, UiDatFont? datFont, Func? spriteResolve, Func data, int[] sel, List allRaise1, List allRaise10, Action setFooterSelected, out List skillRows) { float listW = RowContentWidth(list); float y = 0f; var entries = new List(); var bindings = new List(); AddBucket("Specialized Skills", SkillHeaderSpecializedSprite, OrderedSkills(data(), CharacterSkillAdvancementClass.Specialized, usableUntrained: null)); AddBucket("Trained Skills", SkillHeaderTrainedSprite, OrderedSkills(data(), CharacterSkillAdvancementClass.Trained, usableUntrained: null)); AddBucket("Untrained Skills", SkillHeaderUntrainedSprite, OrderedSkills(data(), CharacterSkillAdvancementClass.Untrained, usableUntrained: true)); AddBucket("Unusable Skills", SkillHeaderUnusableSprite, OrderedSkills(data(), CharacterSkillAdvancementClass.Untrained, usableUntrained: false)); skillRows = bindings; return entries; void AddBucket(string title, uint spriteId, IReadOnlyList skills) { var header = AddSkillHeader(list, datFont, spriteResolve, 0f, y, listW, title, spriteId); entries.Add(header); y += SkillHeaderHeight; foreach (var skill in skills) { int rowIndex = bindings.Count; CharacterSkill LiveSkill() => FindSkill(data(), skill.Id) ?? skill; var row = AddRow(list, datFont, spriteResolve, left: 0f, top: y, width: listW, height: RowHeight, iconDid: skill.IconDid, nameText: skill.Name, valueProvider: () => LiveSkill().CurrentLevel.ToString(), valueColorProvider: () => SkillValueColor(LiveSkill()), nameColor: Vector4.One); // TS-85: SkillInfoRegion::GetTooltip (0x004f1fe0), stamped once at // row construction — matches retail (never recomputed per frame). row.TooltipText = skill.TooltipText; row.OnClick = () => { HandleSkillRowClick(rowIndex, sel, bindings, spriteResolve, data, allRaise1, allRaise10); setFooterSelected(sel[0] >= 0); }; bindings.Add(new SkillRowBinding(row, skill)); entries.Add(row); y += RowHeight; } } } private static UiPanel AddSkillHeader( UiElement list, UiDatFont? datFont, Func? spriteResolve, float left, float top, float width, string title, uint spriteId) { var header = new UiPanel { Left = left, Top = top, Width = width, Height = SkillHeaderHeight, BackgroundColor = spriteResolve is null ? new Vector4(0.12f, 0.12f, 0.14f, 0.65f) : Vector4.Zero, BackgroundSprite = spriteResolve is not null ? spriteId : 0u, SpriteResolve = spriteResolve is not null ? id => { var (h, w, ht) = spriteResolve(id); return (h, w, ht); } : null, BorderColor = Vector4.Zero, Anchors = AnchorEdges.Left | AnchorEdges.Top, ClickThrough = true, }; var label = new UiText { Left = RowPadX, Top = 0f, Width = MathF.Max(1f, width - RowPadX * 2f), Height = SkillHeaderHeight, DatFont = datFont, ClickThrough = true, Centered = false, RightAligned = false, Padding = 1f, Anchors = AnchorEdges.Left | AnchorEdges.Top, }; string captured = title; label.LinesProvider = () => new[] { new UiText.Line(captured, Vector4.One) }; header.AddChild(label); list.AddChild(header); return header; } private static IReadOnlyList OrderedSkills( CharacterSheet sheet, CharacterSkillAdvancementClass advancement, bool? usableUntrained) { var result = new List(); foreach (var skill in sheet.Skills) { if (skill.AdvancementClass != advancement) continue; if (usableUntrained is not null && skill.UsableUntrained != usableUntrained.Value) continue; result.Add(skill); } result.Sort(static (a, b) => string.Compare(a.Name, b.Name, StringComparison.Ordinal)); return result; } private static CharacterSkill? FindSkill(CharacterSheet sheet, uint skillId) { IReadOnlyList skills = sheet.Skills; for (int i = 0; i < skills.Count; i++) { CharacterSkill skill = skills[i]; if (skill.Id == skillId) return skill; } return null; } private static CharacterSkill? SkillAtDisplayIndex(CharacterSheet sheet, int index) { if (index < 0) return null; int n = 0; foreach (var bucket in new[] { OrderedSkills(sheet, CharacterSkillAdvancementClass.Specialized, usableUntrained: null), OrderedSkills(sheet, CharacterSkillAdvancementClass.Trained, usableUntrained: null), OrderedSkills(sheet, CharacterSkillAdvancementClass.Untrained, usableUntrained: true), OrderedSkills(sheet, CharacterSkillAdvancementClass.Untrained, usableUntrained: false), }) { foreach (var skill in bucket) { if (n == index) return skill; n++; } } return null; } internal static Vector4 SkillValueColor(CharacterSkill skill) { int withoutVitae = skill.CurrentLevel - skill.VitaeModifier; return withoutVitae > skill.BaseLevel ? RetailBuffGreen : withoutVitae < skill.BaseLevel ? RetailDebuffRed : Vector4.One; } internal static Vector4 AttributeValueColor( CharacterSheet sheet, int rowIndex) { int delta = GetAttributeDelta(sheet, rowIndex); return delta > 0 ? RetailBuffGreen : delta < 0 ? RetailDebuffRed : Vector4.One; } internal static Vector4 VitalValueColor( CharacterSheet sheet, int vitalIndex) { if ((uint)vitalIndex >= 3u || vitalIndex >= sheet.VitalBaseMaxValues.Length || vitalIndex >= sheet.VitalVitaeModifiers.Length) { return Vector4.One; } int effective = vitalIndex switch { 0 => sheet.HealthMax, 1 => sheet.StaminaMax, 2 => sheet.ManaMax, _ => 0, }; int withoutVitae = effective - sheet.VitalVitaeModifiers[vitalIndex]; int baseline = sheet.VitalBaseMaxValues[vitalIndex]; return withoutVitae > baseline ? RetailBuffGreen : withoutVitae < baseline ? RetailDebuffRed : Vector4.One; } /// /// 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. // CT5: retail's InfoRegion::SetState swaps the row's whole background // to the template's Highlight-state media (RowHighlightSprite, // 0x06000F93 — see its own doc comment) when spriteResolve is // available; otherwise fall back to the translucent gold tint. // CT5 fix round (SHOULD-FIX 1): the UNSELECTED branch now draws the // template's Normal-state media (RowNormalSprite, 0x06004CC2 — see // its own doc comment) the same way, instead of leaving the row // fully transparent. 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 = RowHighlightSprite; row.SpriteResolve = spriteResolve; } else { row.BackgroundColor = HighlightBg; row.BackgroundSprite = 0u; row.SpriteResolve = null; } } else { row.BackgroundColor = Vector4.Zero; row.BackgroundSprite = spriteResolve is not null ? RowNormalSprite : 0u; row.SpriteResolve = spriteResolve; } } // Update raise buttons. RefreshRaiseButtons(newSel, data, allRaise1, allRaise10); } private static void HandleSkillRowClick( int clickedIndex, int[] sel, List rows, Func? spriteResolve, Func data, List allRaise1, List allRaise10) { int newSel = (sel[0] == clickedIndex) ? -1 : clickedIndex; sel[0] = newSel; string rowName = newSel >= 0 && newSel < rows.Count ? rows[newSel].Skill.Name : string.Empty; Console.WriteLine($"[CharacterStat] Skill row click: index={clickedIndex} -> selected={newSel} ({rowName})"); ApplySkillSelectionVisuals(newSel, rows, spriteResolve); CharacterSkill? selectedSkill = newSel >= 0 && newSel < rows.Count ? rows[newSel].Skill : null; RefreshSkillRaiseButtons(selectedSkill, data(), allRaise1, allRaise10); } private static void ApplySkillSelectionVisuals( int selectedIndex, IReadOnlyList rows, Func? spriteResolve) { for (int i = 0; i < rows.Count; i++) { var row = rows[i].Panel; if (i == selectedIndex) { if (spriteResolve is not null) { row.BackgroundColor = Vector4.Zero; row.BackgroundSprite = RowHighlightSprite; // CT5 fix round (NOTE f): unified with HandleRowClick's // direct assignment — the per-row wrapper closure this // used to allocate was functionally identical (same // tuple shape, just differently-named elements, which // the delegate conversion already accepts without it). row.SpriteResolve = spriteResolve; } else { row.BackgroundColor = HighlightBg; row.BackgroundSprite = 0u; row.SpriteResolve = null; } } else { // CT5 fix round (SHOULD-FIX 1): draw the Normal-state media // (RowNormalSprite) on unselected rows — see HandleRowClick's // matching comment for the full citation. row.BackgroundColor = Vector4.Zero; row.BackgroundSprite = spriteResolve is not null ? RowNormalSprite : 0u; row.SpriteResolve = spriteResolve; } } } /// 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 cost1 = GetRaiseCost(sheet, selectedIndex, amount: 1); long cost10 = GetRaiseCost(sheet, selectedIndex, amount: 10); // CA4 (retired AP-73): while a raise awaits its authoritative // record, retail ghosts the raise controls (one request in flight). bool affordable1 = !sheet.AwaitingRaise && cost1 > 0 && sheet.UnassignedXp >= cost1; bool affordable10 = !sheet.AwaitingRaise && cost10 > 0 && sheet.UnassignedXp >= cost10; foreach (var b in allRaise1) { b.Visible = true; b.TrySetRetailState(affordable1 ? UiButtonStateMachine.Normal : UiButtonStateMachine.Ghosted); } foreach (var b in allRaise10) { b.Visible = true; b.TrySetRetailState(affordable10 ? UiButtonStateMachine.Normal : UiButtonStateMachine.Ghosted); } } private static void RefreshSkillRaiseButtons( CharacterSkill? selectedSkill, CharacterSheet sheet, List allRaise1, List allRaise10) { if (selectedSkill is null) { foreach (var b in allRaise1) b.Visible = false; foreach (var b in allRaise10) b.Visible = false; return; } bool trained = selectedSkill.AdvancementClass >= CharacterSkillAdvancementClass.Trained; long cost = trained ? selectedSkill.RaiseCost : selectedSkill.TrainedCost; bool affordable = !sheet.AwaitingRaise && (trained ? cost > 0 && sheet.UnassignedXp >= cost : cost > 0 && sheet.SkillCredits >= cost); foreach (var b in allRaise1) { b.Visible = true; b.TrySetRetailState(affordable ? UiButtonStateMachine.Normal : UiButtonStateMachine.Ghosted); } foreach (var b in allRaise10) { b.Visible = trained; if (trained) { long cost10 = selectedSkill.Raise10Cost; bool affordable10 = !sheet.AwaitingRaise && cost10 > 0 && sheet.UnassignedXp >= cost10; b.TrySetRetailState(affordable10 ? UiButtonStateMachine.Normal : UiButtonStateMachine.Ghosted); } } } private static void WireRaiseButtonClicks( List allRaise1, List allRaise10, Func data, CharacterStatTab[] activeTab, int[] attrSel, int[] skillSel, Func> skillRows, RaiseRequestHandler? onRaiseRequest, Action? afterRaiseRequest) { foreach (var button in allRaise1) { UiButton captured = button; captured.OnClick = () => HandleRaiseButtonClick( amount: 1, data, activeTab, attrSel, skillSel, skillRows, onRaiseRequest, afterRaiseRequest); } foreach (var button in allRaise10) { UiButton captured = button; captured.OnClick = () => HandleRaiseButtonClick( amount: 10, data, activeTab, attrSel, skillSel, skillRows, onRaiseRequest, afterRaiseRequest); } } private static void HandleRaiseButtonClick( int amount, Func data, CharacterStatTab[] activeTab, int[] attrSel, int[] skillSel, Func> skillRows, RaiseRequestHandler? onRaiseRequest, Action? afterRaiseRequest) { if (onRaiseRequest is null) return; var sheet = data(); RaiseRequest? request; uint? selectedSkillId = null; if (activeTab[0] == CharacterStatTab.Attributes) { request = TryBuildAttributeRaiseRequest(sheet, attrSel[0], amount); } else { var rows = skillRows(); CharacterSkill? selectedSkill = skillSel[0] >= 0 && skillSel[0] < rows.Count ? rows[skillSel[0]].Skill : null; selectedSkillId = selectedSkill?.Id; request = TryBuildSkillRaiseRequest(sheet, selectedSkill, amount); } if (request is not { } value) return; onRaiseRequest(value, () => afterRaiseRequest?.Invoke(selectedSkillId)); } private static RaiseRequest? TryBuildAttributeRaiseRequest( CharacterSheet sheet, int selectedIndex, int amount) { if (selectedIndex < 0) return null; long cost = GetRaiseCost(sheet, selectedIndex, amount); if (cost <= 0 || sheet.UnassignedXp < cost) return null; if (selectedIndex < AttrRows.Length) return new RaiseRequest(RaiseTargetKind.Attribute, AttrRows[selectedIndex].statId, cost, amount); int vitalIndex = selectedIndex - AttrRows.Length; if (vitalIndex >= 0 && vitalIndex < VitalRows.Length) return new RaiseRequest(RaiseTargetKind.Vital, VitalRows[vitalIndex].maxStatId, cost, amount); return null; } private static RaiseRequest? TryBuildSkillRaiseRequest( CharacterSheet sheet, CharacterSkill? selectedSkill, int amount) { if (selectedSkill is null) return null; bool trained = selectedSkill.AdvancementClass >= CharacterSkillAdvancementClass.Trained; if (!trained) { if (amount != 1) return null; long trainCost = selectedSkill.TrainedCost; return trainCost > 0 && sheet.SkillCredits >= trainCost ? new RaiseRequest(RaiseTargetKind.TrainSkill, selectedSkill.Id, trainCost, amount) : null; } long raiseCost = amount == 10 ? selectedSkill.Raise10Cost : selectedSkill.RaiseCost; return raiseCost > 0 && sheet.UnassignedXp >= raiseCost ? new RaiseRequest(RaiseTargetKind.Skill, selectedSkill.Id, raiseCost, amount == 10 ? 10 : 1) : null; } /// 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) => GetRaiseCost(sheet, rowIndex, amount: 1); /// Return the raise cost for row and retail amount. /// Returns 0 if the relevant cost array is shorter than expected. internal static long GetRaiseCost(CharacterSheet sheet, int rowIndex, int amount) { var costs = amount == 10 ? sheet.AttributeRaise10Costs : sheet.AttributeRaiseCosts; if (costs is null || rowIndex < 0 || rowIndex >= costs.Length) return 0L; return costs[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, }; } // ── Issue #267 — retail vitae/buff delta parenthetical ─────────────────── // Format cited from gmAttributeUI::DisplaySelectionFooter_Attribute // (0x0049d280, format " (%s%d)") and gmSkillUI::DisplaySelectionFooter_Trained // (0x0049b860, vitae segment format " (%d)" via SkillInfoRegion:: // GetVitaeModifier 0x004f0fa0, buff segment " (%s%d)"). Retail colors each // segment independently (AppendTextWithFont state 1/2/3); our UiText footer // title element renders OneLine/single-color, so segments here share the // title's white color rather than retail's per-run tint — a presentation // simplification, not a value/format deviation. /// Effective value for attribute/vital row /// as an int (numeric twin of for the 6 /// primary-attribute rows only — vitals use "cur/max" and have no single /// effective int). private static int GetRowEffectiveAttributeValue(CharacterSheet sheet, int index) => index switch { 0 => sheet.Strength, 1 => sheet.Endurance, 2 => sheet.Coordination, 3 => sheet.Quickness, 4 => sheet.Focus, 5 => sheet.Self, _ => 0, }; /// Buff-only delta (effective − base) for the primary-attribute /// row at . Zero for vital rows (6-8) — vitals use /// a separate "cur/max" footer format in retail /// (gmAttributeUI::DisplaySelectionFooter_Vital 0x0049d6b0), not /// this delta pattern. internal static int GetAttributeDelta(CharacterSheet sheet, int index) { if ((uint)index >= (uint)AttrRows.Length) return 0; int[] baseValues = sheet.AttributeBaseValues; if (baseValues is null || index >= baseValues.Length) return 0; return GetRowEffectiveAttributeValue(sheet, index) - baseValues[index]; } /// Skill's buff-only delta, excluding vitae — retail computes /// this as (current − vitaeModifier) − base /// (gmSkillUI::DisplaySelectionFooter_Trained 0x0049b860): vitae's /// own contribution is reported separately via /// . internal static int GetSkillBuffOnlyDelta(CharacterSkill skill) => (skill.CurrentLevel - skill.VitaeModifier) - skill.BaseLevel; /// Retail " (%s%d)" buff-delta parenthetical: an explicit "+" /// prefix on an increase (the natural %d has no leading plus), the /// natural minus sign on a decrease (no prefix string set in that /// branch), nothing when the delta is zero. /// Invariant, not the machine's culture: retail's minus is the /// ASCII '-', and several European cultures (sv-SE among them) render a /// negative integer with U+2212 MINUS SIGN, so a Swedish player would /// otherwise read "(−20)" where retail shows "(-20)". private static string FormatBuffDelta(int delta) => delta switch { 0 => string.Empty, > 0 => string.Create(CultureInfo.InvariantCulture, $" (+{delta})"), _ => string.Create(CultureInfo.InvariantCulture, $" ({delta})"), }; /// Retail " (%d)" vitae-specific parenthetical /// (SkillInfoRegion::GetVitaeModifier 0x004f0fa0): shown only /// while vitae is an active penalty (modifier < 0) — vitae never /// grants a bonus, so no "+" case exists. private static string FormatVitaeDelta(int vitaeModifier) => vitaeModifier < 0 ? string.Create(CultureInfo.InvariantCulture, $" ({vitaeModifier})") : string.Empty; /// Build the retail footer-title text for the current selection: /// "{Name}: {value}" with the vitae + buff-delta parentheticals appended /// for a selected trained/specialized skill or a selected attribute row. /// Shared by both footer-state bindings (State A/B use physically /// separate UiText elements in the imported layout) so the composition /// logic lives in exactly one place. private static string BuildSelectedTitleText( CharacterStatTab tab, Func data, int[] attrSel, int[] skillSel) { if (tab == CharacterStatTab.Skills) { CharacterSkill? skill = SkillAtDisplayIndex(data(), skillSel[0]); if (skill is null) return "Select a Skill to Improve"; if (skill.AdvancementClass < CharacterSkillAdvancementClass.Trained) return skill.Name; string vitaeSuffix = FormatVitaeDelta(skill.VitaeModifier); string buffSuffix = FormatBuffDelta(GetSkillBuffOnlyDelta(skill)); return $"{skill.Name}: {skill.CurrentLevel}{vitaeSuffix}{buffSuffix}"; } if (attrSel[0] < 0) return "Select an Attribute to Improve"; CharacterSheet sheet = data(); string name = GetRowName(attrSel[0]); string value = GetRowValueString(sheet, attrSel[0]); string delta = FormatBuffDelta(GetAttributeDelta(sheet, attrSel[0])); return $"{name}: {value}{delta}"; } private static IReadOnlyList BuildSelectedTitleRuns( UiText target, CharacterStatTab tab, Func data, int[] attrSel, int[] skillSel) { Vector4 Color(int index) => index >= 0 && index < target.FontColorPalette.Count ? target.FontColorPalette[index] : index switch { 1 => RetailBuffGreen, 2 => RetailDebuffRed, 3 => RetailVitaeBlue, _ => Vector4.One, }; if (tab == CharacterStatTab.Skills) { CharacterSkill? skill = SkillAtDisplayIndex(data(), skillSel[0]); if (skill is null) return [new("Select a Skill to Improve", Body)]; if (skill.AdvancementClass < CharacterSkillAdvancementClass.Trained) return [new(skill.Name, Color(0))]; var runs = new List { new($"{skill.Name}: {skill.CurrentLevel}", Color(0)), }; if (skill.VitaeModifier < 0) runs.Add(new(FormatVitaeDelta(skill.VitaeModifier), Color(3))); int buffDelta = GetSkillBuffOnlyDelta(skill); if (buffDelta != 0) runs.Add(new( FormatBuffDelta(buffDelta), Color(buffDelta > 0 ? 1 : 2))); return runs; } if (attrSel[0] < 0) return [new("Select an Attribute to Improve", Body)]; CharacterSheet sheet = data(); var attributeRuns = new List { new( $"{GetRowName(attrSel[0])}: {GetRowValueString(sheet, attrSel[0])}", Color(0)), }; int delta = GetAttributeDelta(sheet, attrSel[0]); if (delta != 0) attributeRuns.Add(new( FormatBuffDelta(delta), Color(delta > 0 ? 1 : 2))); return attributeRuns; } /// /// Add a single attribute/vital/skill row to as a /// containing icon + name + value children, /// laid out at the AUTHORED template 0x10000248 pixel geometry (icon /// flush left 20x20, name at X=25 W=150, value at X=175 W=100 /// right-justified — see the row-layout-constants block above). Returns /// the panel so the caller can wire . /// /// CT5 hand-built-vs-template ruling: this row stays HAND-BUILT /// (not converted to UiTemplateListBox row instantiation). /// Converting would touch every one of the ~15 call sites this method's /// row feeds — raise-button affordability, footer State A/B, per-row /// tooltip, section-header bucketing, live-refresh-on-quality-change, /// and the selection-highlight swap — for a purely cosmetic slice whose /// authored numbers this hand-built path can already hit byte-exact /// (proven by CharacterPanelLiveDatTests.AttributeRowTemplate_...). /// That is a large-blast-radius rewrite for a geometry-only fix; the /// smaller-risk path is implementing the authored constants directly /// here, which this method now does. /// private static UiClickablePanel AddRow( UiElement list, UiDatFont? datFont, Func? spriteResolve, float left, float top, float width, float height, uint iconDid, string nameText, Func valueProvider, Func? valueColorProvider = null, Vector4? nameColor = null) { var row = new UiClickablePanel { // #430: runtime-built rows author no P0x47/P0x48 of their own, and // OnTooltipShow refuses a widget without a popup locator — so the // Batch-B TooltipText never mounted. Use the shared popup skin, // the ONLY locator pair the character layout (0x2100002E) itself // references (live-DAT probed 2026-08-24) and the same inference // UiItemSlot already ships for runtime-built widgets. AuthoredTooltipRootElementId = RetailTooltipPresenter.SharedPopupSkinRootElementId, AuthoredTooltipLayoutDid = RetailTooltipPresenter.SharedPopupSkinLayoutDid, Left = left, Top = top, Width = width, Height = height, BackgroundColor = Vector4.Zero, // CT5 fix round (SHOULD-FIX 1): a freshly-built row starts // unselected, so it gets the template's Normal-state media // (RowNormalSprite) up front — matching HandleRowClick's/ // ApplySkillSelectionVisuals' unselected branch exactly, so a // row never flashes transparent before its first click. BackgroundSprite = spriteResolve is not null ? RowNormalSprite : 0u, SpriteResolve = spriteResolve, BorderColor = Vector4.Zero, Anchors = AnchorEdges.Left | AnchorEdges.Top, }; // Icon 0x10000129: flush left (X=0), Y=0, 20x20 — full row height, // no vertical centering math needed since RowIconSize == RowHeight. var iconEl = new UiText { Left = RowIconX, Top = 0f, Width = RowIconSize, Height = RowIconSize, 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, }; // Name 0x1000012A: X=25 W=150 — fixed authored pixels, not a // width-relative fraction. string capturedName = nameText; Vector4 capturedNameColor = nameColor ?? Body; var nameEl = new UiText { Left = RowNameX, Top = 0f, Width = RowNameW, Height = height, DatFont = datFont, ClickThrough = true, Centered = false, RightAligned = false, // CT5 fix round (SHOULD-FIX 3): the authored template's // 0x1000012A name column carries no margin property at all — // Padding=1f re-created the X=26 glyph start (RowNameX + 1) this // very slice existed to remove (see AttributeRowTemplate_...'s // pin: name.X == 25f, no margin). 0f matches the value column's // own (already-0) Padding. Padding = 0f, // CT5 fix round (NOTE a): both the name and value columns author // VJustify=Center in the template (0x1000012A/0x1000012B share // the same default UiText.VerticalJustify). OneLine=true routes // this element through the same single-line vertical-centering // draw path the value column below already uses instead of the // multi-line/scroll path, which does not honor VerticalJustify // the same way. OneLine = true, Anchors = AnchorEdges.Left | AnchorEdges.Top, }; nameEl.LinesProvider = () => new[] { new UiText.Line(capturedName, capturedNameColor) }; // Value 0x1000012B: X=175 W=100, right-justified — its right edge // (275) sits 7px short of the row's own 282px right edge, the // authored gutter the owner reported. var valueEl = new UiText { Left = RowValueX, Top = 0f, Width = RowValueW, Height = height, DatFont = datFont, ClickThrough = true, RightAligned = true, OneLine = true, Anchors = AnchorEdges.Left | AnchorEdges.Top, }; var capturedProvider = valueProvider; valueEl.LinesProvider = () => new[] { new UiText.Line(capturedProvider(), valueColorProvider?.Invoke() ?? 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, CharacterStatTab[] activeTab, int[] attrSel, int[] skillSel, UiElement? contentPage = null) { // 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 State A/B inside the explicit Attributes page, not via _byId. The // id dictionary stores the last duplicate and can point at a hidden Skills/Titles // page copy. UiElement? stateA = contentPage is not null ? FindInSubtree(contentPage, static el => el is UiDatElement d && d.ElementId == FooterStateAId) : null; UiElement? stateB = contentPage is not null ? FindInSubtree(contentPage, static el => el is UiDatElement d && d.ElementId == FooterStateBId) : null; // Fallback: layout._byId (test layouts with a single page). stateA ??= layout.FindElement(FooterStateAId); stateB ??= layout.FindElement(FooterStateBId); // 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) titleEl.OneLine = true; } // 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.RunsProvider = () => BuildSelectedTitleRuns( titleEl, activeTab[0], data, attrSel, skillSel); titleEl.LinesProvider = () => { string title = BuildSelectedTitleText(activeTab[0], data, attrSel, skillSel); bool nothingSelected = activeTab[0] == CharacterStatTab.Skills ? SkillAtDisplayIndex(data(), skillSel[0]) is null : attrSel[0] < 0; // State B title is WHITE (retail confirmed); the "nothing // selected" prompt keeps the dimmer Body color. return new[] { new UiText.Line(title, nothingSelected ? Body : 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, () => { if (activeTab[0] == CharacterStatTab.Skills) { var skill = SkillAtDisplayIndex(data(), skillSel[0]); if (skill is null) return "Skill Credits Available:"; return skill.AdvancementClass >= CharacterSkillAdvancementClass.Trained ? "Experience To Raise:" : "Skill Credits To Raise:"; } return attrSel[0] < 0 ? "Skill Credits Available:" : "Experience To Raise:"; }); var l1V = ByPos(20f, 200f, FooterLine1Value); LabelProvider(l1V, null, Body, () => { var sheet = data(); if (activeTab[0] == CharacterStatTab.Skills) { var skill = SkillAtDisplayIndex(sheet, skillSel[0]); if (skill is null) return sheet.SkillCredits.ToString(); long skillCost = skill.AdvancementClass >= CharacterSkillAdvancementClass.Trained ? skill.RaiseCost : skill.TrainedCost; return skillCost > 0 ? FormatXp(skillCost) : "Infinity!"; } if (attrSel[0] < 0) return sheet.SkillCredits.ToString(); long cost = GetRaiseCost(sheet, attrSel[0]); return cost > 0 ? FormatXp(cost) : "Infinity!"; }); // Line-2 elements: pass null → keep dat font. var l2L = ByPos(37f, 5f, FooterLine2Label); LabelProvider(l2L, null, Body, () => { if (activeTab[0] == CharacterStatTab.Skills) { var skill = SkillAtDisplayIndex(data(), skillSel[0]); if (skill is not null && skill.AdvancementClass < CharacterSkillAdvancementClass.Trained) return "Skill Credits Available:"; } return "Unassigned Experience:"; }); var l2V = ByPos(37f, 200f, FooterLine2Value); LabelProvider(l2V, null, Body, () => { var sheet = data(); if (activeTab[0] == CharacterStatTab.Skills) { var skill = SkillAtDisplayIndex(sheet, skillSel[0]); if (skill is not null && skill.AdvancementClass < CharacterSkillAdvancementClass.Trained) return sheet.SkillCredits.ToString(); } return FormatXp(sheet.UnassignedXp); }); BindSelectedFooterState(stateB); UiText? TextById(UiElement? state, uint id) => state is null ? null : FindInSubtree(state, el => el is UiText t && t.ElementId == id) as UiText; void BindSelectedFooterState(UiElement? state) { var title = TextById(state, FooterTitleId); if (title is not null) { title.BackgroundSprite = 0; title.VerticalJustify = VJustify.Top; title.ClickThrough = true; title.LinesProvider = () => { string titleText = BuildSelectedTitleText(activeTab[0], data, attrSel, skillSel); bool nothingSelected = activeTab[0] == CharacterStatTab.Skills ? SkillAtDisplayIndex(data(), skillSel[0]) is null : attrSel[0] < 0; return new[] { new UiText.Line(titleText, nothingSelected ? Body : Vector4.One) }; }; } LabelProvider(TextById(state, FooterLine1Label), null, Body, () => { if (activeTab[0] == CharacterStatTab.Skills) { var skill = SkillAtDisplayIndex(data(), skillSel[0]); if (skill is null) return "Skill Credits Available:"; return skill.AdvancementClass >= CharacterSkillAdvancementClass.Trained ? "Experience To Raise:" : "Skill Credits To Raise:"; } return attrSel[0] < 0 ? "Skill Credits Available:" : "Experience To Raise:"; }); LabelProvider(TextById(state, FooterLine1Value), null, Body, () => { var sheet = data(); if (activeTab[0] == CharacterStatTab.Skills) { var skill = SkillAtDisplayIndex(sheet, skillSel[0]); if (skill is null) return sheet.SkillCredits.ToString(); long skillCost = skill.AdvancementClass >= CharacterSkillAdvancementClass.Trained ? skill.RaiseCost : skill.TrainedCost; return skillCost > 0 ? FormatXp(skillCost) : "Infinity!"; } if (attrSel[0] < 0) return sheet.SkillCredits.ToString(); long cost = GetRaiseCost(sheet, attrSel[0]); return cost > 0 ? FormatXp(cost) : "Infinity!"; }); LabelProvider(TextById(state, FooterLine2Label), null, Body, () => { if (activeTab[0] == CharacterStatTab.Skills) { var skill = SkillAtDisplayIndex(data(), skillSel[0]); if (skill is not null && skill.AdvancementClass < CharacterSkillAdvancementClass.Trained) return "Skill Credits Available:"; } return "Unassigned Experience:"; }); LabelProvider(TextById(state, FooterLine2Value), null, Body, () => { var sheet = data(); if (activeTab[0] == CharacterStatTab.Skills) { var skill = SkillAtDisplayIndex(sheet, skillSel[0]); if (skill is not null && skill.AdvancementClass < CharacterSkillAdvancementClass.Trained) return sheet.SkillCredits.ToString(); } return FormatXp(sheet.UnassignedXp); }); } } // ── Helpers ────────────────────────────────────────────────────────────── private static void SetCompatibilityAnchorsAllById( UiElement node, uint targetId, AnchorEdges anchors) { if (node is UiDatElement d && d.ElementId == targetId && node.LayoutPolicy is null) { node.Anchors = anchors; } foreach (var child in node.Children) SetCompatibilityAnchorsAllById(child, targetId, anchors); } /// 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 HasDatElementId(UiElement element, uint id) => DatElementId(element) == id; private static uint DatElementId(UiElement element) { // DatWidgetFactory assigns the canonical id on every imported widget, // including behavioral types such as UiScrollbar that do not expose a // type-specific ElementId property. Prefer it so subtree role lookup is // independent of the concrete widget class. The fallbacks keep the small // hand-built controller fixtures working when they construct widgets // directly instead of through the factory. if (element.DatElementId != 0u) return element.DatElementId; return element switch { UiDatElement datElement => datElement.ElementId, UiButton button => button.ElementId, UiMeter meter => meter.ElementId, UiText text => text.ElementId, _ => 0u, }; } private static UiElement? FindElementByDatId(ImportedLayout layout, UiElement? scope, uint id) { if (scope is not null) { var scoped = FindInSubtree(scope, el => HasDatElementId(el, id)); if (scoped is not null) return scoped; } return layout.FindElement(id); } private static UiText? FindTextByDatId(ImportedLayout layout, UiElement? scope, uint id) => FindElementByDatId(layout, scope, id) as UiText; private static UiElement? FindDirectChildById(UiElement? root, uint id) { if (root is null) return null; foreach (var child in root.Children) { if (DatElementId(child) == id) return child; } return null; } private static void CollectElementsByDatId(UiElement node, uint id, List result) { if (DatElementId(node) == id) result.Add(node); foreach (var child in node.Children) CollectElementsByDatId(child, id, result); } private static void Label(ImportedLayout layout, uint id, UiDatFont? datFont, Vector4 color, Func text) => Label(layout, null, id, datFont, color, text); private static void Label(ImportedLayout layout, UiElement? scope, uint id, UiDatFont? datFont, Vector4 color, Func text) { if (FindTextByDatId(layout, scope, 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.OneLine = true; t.ClickThrough = true; t.LinesProvider = () => new[] { new UiText.Line(text(), color) }; } } /// /// Retail-equivalent of ExperienceSystem::XPToString /// (sprintf("%I64d", value)GetNumberFormatA's /// locale-grouped-decimal formatting) — shared by every field that /// formats a retail XP-shaped 64-bit count: Total XP, XP-to-next-level, /// and (CT4 fix round, 2026-08-25) the luminance available/maximum pair. /// Retail text is US-formatted for everyone (the project's locale- /// independence rule), so this is InvariantCulture, not /// CurrentCulture — the pre-CT4-fix-round call sites used a bare /// .ToString("N0"), which silently followed the host OS locale. /// private static string FormatXp(long value) => value.ToString("N0", CultureInfo.InvariantCulture); /// /// Same binding shape as , but the per-line color is /// read from the widget's own — the /// value DatWidgetFactory.BuildText already seeded from the /// element's authored dat property 0x1B — instead of a caller-supplied /// constant. Campaign CT slice CT4 (2026-08-24): the header identity /// block's four elements (Name/Heritage/PkStatus/Level) all carry their /// own correct authored color (CT1's live-DAT pin), so "authored color /// wins" here is both simpler and more correct than hand-picking a /// runtime constant — the same precedent /// 's row/display text already /// set (rowText.DefaultColor). CT4 fix-round consistency note /// (2026-08-25): this helper unconditionally forces /// Centered = true/OneLine = true, which is correct for /// the four elements it is actually called on (Name/Heritage/PkStatus/ /// Level, all centered in the DAT), but would be WRONG for a /// left/right-justified authored element (e.g. the luminance pair, /// which is deliberately bound with its own inline LinesProvider below /// rather than through this helper, precisely to preserve its authored /// Left/Right justification). Left as-is rather than parameterizing /// Centered/OneLine, since no current caller needs the non-centered /// case — a future caller that does should not reuse this helper as-is. /// private static void LabelAuthoredColor(ImportedLayout layout, UiElement? scope, uint id, UiDatFont? datFont, Func text) { if (FindTextByDatId(layout, scope, id) is UiText t) { if (datFont is not null) t.DatFont = datFont; t.Centered = true; t.OneLine = true; t.ClickThrough = true; t.LinesProvider = () => new[] { new UiText.Line(text(), t.DefaultColor) }; } } /// 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) => LabelTwoLine(layout, null, id, datFont, color, line1, line2); private static void LabelTwoLine(ImportedLayout layout, UiElement? scope, uint id, UiDatFont? datFont, Vector4 color, string line1, string line2) { if (FindTextByDatId(layout, scope, 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) => LabelLeft(layout, null, id, datFont, color, text); private static void LabelLeft(ImportedLayout layout, UiElement? scope, uint id, UiDatFont? datFont, Vector4 color, Func text) { if (FindTextByDatId(layout, scope, 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) }; } } /// Right-justified numeric header value. The imported layout owns /// bounds/font; the controller owns runtime text and explicit justification. private static void LabelRight( ImportedLayout layout, UiElement? scope, uint id, UiDatFont? datFont, Vector4 color, Func text) { if (FindTextByDatId(layout, scope, id) is UiText t) { if (datFont is not null) t.DatFont = datFont; t.Centered = false; t.RightAligned = true; t.OneLine = true; t.ClickThrough = true; t.Padding = 0f; 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) }; } /// /// 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. /// /// // Match by ElementId, not geometry: the x1 and x10 raise buttons are both 30x26. private static void CollectButtonsById( UiElement node, uint targetId, List result, ImportedLayout layout) { // Find the canonical copy (last registered in _byId) as the geometry reference. _ = layout; // 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, targetId, seen, result); } private static void CollectMatchingButtons( UiElement node, uint targetId, HashSet seen, List result) { if (node is UiButton btn && btn.ElementId == targetId && seen.Add(btn)) { result.Add(btn); } foreach (var child in node.Children) CollectMatchingButtons(child, targetId, seen, result); } }