acdream/src/AcDream.App/UI/Layout/CharacterStatController.cs
Erik 0c699240e0
All checks were successful
CI / linux-portable (push) Successful in 3m46s
CI / windows-gate (push) Successful in 6m21s
CI / release (push) Successful in 2m15s
fix(ci): make release gates portable and deterministic
2026-08-25 19:38:10 +02:00

2506 lines
120 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System;
using System.Collections.Generic;
using System.Globalization;
using System.Numerics;
using AcDream.App.UI;
namespace AcDream.App.UI.Layout;
/// <summary>
/// Controller for the Character window's <b>Attributes tab</b> — LayoutDesc 0x2100002E,
/// whose tab-content slot 0x1000022B mounts sub-layout 0x2100002C (gmAttributeUI, root
/// type 0x1000002A) which in turn chains into the gmStatManagementUI header content.
///
/// <para>Unlike <see cref="CharacterController"/> (which targets the SEPARATE text-report
/// sub-panel 0x2100001A, gmCharacterInfoUI, by creating its runtime m_pMainText element),
/// this controller binds the <b>real, statically-mounted</b> header + list elements that the
/// importer already produces — every id below is confirmed present via
/// <see cref="ImportedLayout.FindElement"/>.</para>
///
/// <para>Ported from <c>gmStatManagementUI::UpdateCharacterInfo</c> (0x004f0770) +
/// <c>UpdateExperience</c> (0x004f0a70) + <c>UpdatePKStatus</c> (0x004f00a0): name, heritage,
/// PK status, level, total XP and the XP-to-level meter. The attribute list is the
/// <c>gmAttributeUI</c> list box (0x1000023D), built as 9 manual-layout rows per
/// <c>gmAttributeUI::PostInit</c> (0x0049db70) + <c>AttributeInfoRegion</c> / <c>Attribute2ndInfoRegion</c>
/// (0x004f1910 / 0x004f19e0). Row icons loaded via sub-element 0x10000129 in the retail
/// dat template (each icon is a <c>0x06xxxxxx</c> RenderSurface DataID from SubMap
/// 0x25000006 / 0x25000007, spec §2).</para>
///
/// <para>Footer State A (nothing selected) bound from
/// <c>DisplayDefaultFooter</c> (0x0049cde0): title empty, line-1 value =
/// "Select an Attribute to Improve", line-2 value = available skill credits (InqInt(0x18)).</para>
///
/// <para>Footer State B (row selected) bound from
/// <c>DisplaySelectedAttribute</c> (implicitly): title = "{AttrName}: {value}",
/// line-1 label = "Experience To Raise:", line-1 value = raise cost,
/// line-2 label = "Unassigned Experience:", line-2 value = UnassignedXp.</para>
///
/// <para>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: <c>UIElement::SetState @ 0x00464E70</c>. 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);
/// <see cref="CharacterTitlesController"/> owns the Titles page's OWN content
/// (row list, display-title text, Set-as-Display button) once its page container
/// is shown.</para>
///
/// <para>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).</para>
/// </summary>
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
/// <summary>Campaign CT slice CT4: the luminance pair
/// (m_pLuminanceLabelText/m_pLuminanceText), shown only past level 200
/// with nonzero MaximumLuminance — see
/// <c>gmStatManagementUI::UpdateExperience</c> (0x004f0a70)'s luminance
/// branch. CT4 fix round (2026-08-25, BLOCKER 1): the caption/value
/// SetText calls PE-byte-decoded from the <c>gmStatManagementUI</c>
/// vftable-adjacent data region — caption UTF-16 <c>"Luminance:"</c> at
/// <c>@0x007c3dd4</c>, value narrow <c>"%s / %s"</c> at
/// <c>@0x007c3dcc</c> — are now bound; see the Bind method's own
/// remarks.</summary>
public const uint LuminanceLabelId = 0x100005C5u;
public const uint LuminanceValueId = 0x100005C6u;
/// <summary>Retail literal <c>"Luminance:"</c> — see
/// <see cref="LuminanceLabelId"/>'s remarks for the PE-byte-decode
/// citation.</summary>
private const string LuminanceCaption = "Luminance:";
// ── Footer STATE-A container id ──────────────────────────────────────────
// 0x10000240 is the "nothing selected" footer group. Its children (0x1000024E label row,
// 0x100002420x10000245 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.
/// <summary>Row highlight FALLBACK color — used only when
/// <c>spriteResolve</c> is unavailable (tests, headless mode) and the
/// real <see cref="RowHighlightSprite"/> 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.</summary>
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);
/// <summary>
/// Handles a retail stat raise and invokes <paramref name="completed"/> when
/// the request has either been applied or cancelled. The completion seam is
/// required by asynchronous retail confirmation dialogs such as
/// <c>gmSkillUI::TrainSkill @ 0x0049C5F0</c>.
/// </summary>
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
};
/// <summary>
/// TS-85 (character-panel tooltips): retail <c>SkillSystem::InqAttributeDescription
/// @ 0x005c8e30</c> — six hardcoded strings, byte-decoded from the retail binary's
/// string pool (the pseudo-C dump truncates them with "…"). Ported from
/// <c>AttributeInfoRegion::AttributeInfoRegion @ 0x004f1530</c>'s
/// <c>UIElement::SetTooltip</c> call at 0x004f1617, keyed by retail attribute id
/// (matches <see cref="AttrRows"/>' statId column, NOT the array index — the
/// authored row order swaps Coordination/Quickness relative to the id numbering).
/// </summary>
private static readonly IReadOnlyDictionary<uint, string> AttributeDescriptions =
new Dictionary<uint, string>
{
[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
};
/// <summary>
/// TS-85 (character-panel tooltips): retail <c>SkillSystem::InqAttribute2ndDescription
/// @ 0x005c8f70</c> — three hardcoded strings shared by each Max/Current pair (1&amp;2,
/// 3&amp;4, 5&amp;6), byte-decoded from the retail string pool. Ported from
/// <c>Attribute2ndInfoRegion::Attribute2ndInfoRegion @ 0x004f1680</c>'s
/// <c>UIElement::SetTooltip</c> call at 0x004f1777, keyed by <see cref="VitalRows"/>'
/// maxStatId column (1/3/5 — either member of the pair resolves the same text in
/// retail).
/// </summary>
private static readonly IReadOnlyDictionary<uint, string> Attribute2ndDescriptions =
new Dictionary<uint, string>
{
[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
};
/// <summary>
/// Bind the Attributes-tab header + 9-row list + footer elements, tab button states,
/// and raise buttons in <paramref name="layout"/> to <paramref name="data"/>.
///
/// <para>
/// <b>Interactive mode (Pass 2):</b> each attribute/vital row is a
/// <see cref="UiClickablePanel"/> that fires selection logic on left-click. The
/// selected row index is held in a mutable <c>int[]</c> box (single element) so that
/// all closures share the same mutable slot without escaping the static method boundary.
/// </para>
///
/// <para>
/// <paramref name="spriteResolve"/> resolves a <c>0x06xxxxxx</c> RenderSurface dat id to
/// a (GL tex handle, pixel width, pixel height) triple. Pass <c>null</c> in tests where
/// icon rendering is not asserted.
/// </para>
///
/// <para>
/// CT5: <paramref name="iconDidResolve"/> ports <c>DBObj::GetDIDByEnum(enumValue,
/// category)</c> (master map -> category map -> value) for the per-attribute/
/// per-vital row icon DIDs — pass <c>(enumValue, category) =>
/// RetailDataIdResolver.Resolve(dats, enumValue, category)</c> under the
/// caller's dat lock. <c>null</c> (tests, or no live dat) falls back to
/// <see cref="AttrRows"/>/<see cref="VitalRows"/>' hardcoded DID column,
/// which the CT5 InstalledDat pin proves already matches the live-resolved
/// value byte-exact.
/// </para>
/// </summary>
/// <returns>
/// #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.
/// </returns>
public static Action Bind(
ImportedLayout layout,
Func<CharacterSheet> data,
UiDatFont? datFont = null,
UiDatFont? rowDatFont = null,
Func<uint, (uint handle, int w, int h)>? spriteResolve = null,
RaiseRequestHandler? onRaiseRequest = null,
Action? onClose = null,
Func<uint, uint, uint>? 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<UiElement>();
var currentAttributeRows = new List<UiClickablePanel>();
var currentSkillRows = new List<SkillRowBinding>();
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<UiText.Line>();
}
if (FindTextByDatId(layout, contentPage, LuminanceValueId) is UiText luminanceValue)
{
luminanceValue.LinesProvider = () =>
{
var sheet = data();
if (!LuminanceVisible(sheet)) return Array.Empty<UiText.Line>();
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<UiButton>();
var allRaise10 = new List<UiButton>();
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<UiElement>();
var footerSelectedGroups = new List<UiElement>();
var footerInactiveGroups = new List<UiElement>();
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<uint, (uint handle, int w, int h)>? 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<uint, (uint handle, int w, int h)> 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;
}
/// <summary>
/// CT5: the row's own rendered width is <paramref name="list"/>'s own
/// width clamped to the AUTHORED row-template CEILING
/// (282px, <see cref="SkillContentWidth"/>) — the two numbers never
/// compose into a derived "gutter" formula (ground-truth doc §2's
/// explicit warning); a shorter <paramref name="list"/> clamps the row
/// down, but a wider one never stretches it past 282.
/// <para>CT5 fix round (NOTE b, tempered wording): this does NOT mean
/// every row is 282px. Attribute/vital rows pass the raw ListBox
/// (300px, <see cref="CharacterStatController.ListBoxId"/>'s own dat
/// rect) and land on the 282 ceiling exactly. The skill page instead
/// passes the narrower <c>SkillViewportWidth</c>-computed viewport
/// (281px in production — the real scrollbar's authored gutter), so
/// skill rows clamp to 281, one pixel short of the template ceiling.
/// </para>
/// </summary>
private static float RowContentWidth(UiElement list)
=> list.Width > 0f ? MathF.Min(list.Width, SkillContentWidth) : SkillContentWidth;
/// <summary>
/// CT5 (AP-235): resolve a row icon DID through the live
/// <c>DBObj::GetDIDByEnum</c> chain when a resolver is available,
/// falling back to the hardcoded <see cref="AttrRows"/>/<see cref="VitalRows"/>
/// column value otherwise (tests, or a resolve miss).
/// </summary>
private static uint ResolveIconDid(
Func<uint, uint, uint>? 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<UiClickablePanel> BuildAttributeRows(
UiElement list,
UiDatFont? datFont,
Func<uint, (uint handle, int w, int h)>? spriteResolve,
Func<CharacterSheet> data,
int[] sel,
List<UiButton> allRaise1,
List<UiButton> allRaise10,
Action<bool> setFooterSelected,
Func<uint, uint, uint>? iconDidResolve)
{
float listW = RowContentWidth(list);
float y = 0f;
var rows = new List<UiClickablePanel>();
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<UiElement> BuildSkillRows(
UiElement list,
UiDatFont? datFont,
Func<uint, (uint handle, int w, int h)>? spriteResolve,
Func<CharacterSheet> data,
int[] sel,
List<UiButton> allRaise1,
List<UiButton> allRaise10,
Action<bool> setFooterSelected,
out List<SkillRowBinding> skillRows)
{
float listW = RowContentWidth(list);
float y = 0f;
var entries = new List<UiElement>();
var bindings = new List<SkillRowBinding>();
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<CharacterSkill> 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<uint, (uint handle, int w, int h)>? 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<CharacterSkill> OrderedSkills(
CharacterSheet sheet,
CharacterSkillAdvancementClass advancement,
bool? usableUntrained)
{
var result = new List<CharacterSkill>();
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<CharacterSkill> 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;
}
/// <summary>
/// Handles a row click: toggle (same row → deselect), else select new row.
/// Updates highlight, footer providers, and raise-button state.
/// </summary>
private static void HandleRowClick(
int clickedIndex,
int[] sel,
List<UiClickablePanel> rows,
Func<uint, (uint handle, int w, int h)>? spriteResolve,
Func<CharacterSheet> data,
List<UiButton> allRaise1,
List<UiButton> 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<SkillRowBinding> rows,
Func<uint, (uint handle, int w, int h)>? spriteResolve,
Func<CharacterSheet> data,
List<UiButton> allRaise1,
List<UiButton> 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<SkillRowBinding> rows,
Func<uint, (uint handle, int w, int h)>? 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;
}
}
}
/// <summary>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.</summary>
private static void RefreshRaiseButtons(
int selectedIndex,
Func<CharacterSheet> data,
List<UiButton> allRaise1,
List<UiButton> 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<UiButton> allRaise1,
List<UiButton> 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<UiButton> allRaise1,
List<UiButton> allRaise10,
Func<CharacterSheet> data,
CharacterStatTab[] activeTab,
int[] attrSel,
int[] skillSel,
Func<IReadOnlyList<SkillRowBinding>> skillRows,
RaiseRequestHandler? onRaiseRequest,
Action<uint?>? 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<CharacterSheet> data,
CharacterStatTab[] activeTab,
int[] attrSel,
int[] skillSel,
Func<IReadOnlyList<SkillRowBinding>> skillRows,
RaiseRequestHandler? onRaiseRequest,
Action<uint?>? 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;
}
/// <summary>Return the raise cost for row <paramref name="rowIndex"/> from the sheet.
/// Returns 0 if the cost array is shorter than expected.</summary>
internal static long GetRaiseCost(CharacterSheet sheet, int rowIndex)
=> GetRaiseCost(sheet, rowIndex, amount: 1);
/// <summary>Return the raise cost for row <paramref name="rowIndex"/> and retail amount.
/// Returns 0 if the relevant cost array is shorter than expected.</summary>
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];
}
/// <summary>Return the display name for the row at <paramref name="index"/>,
/// or an empty string if the index is out of range.</summary>
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;
}
/// <summary>Return the numeric value for the row at <paramref name="index"/>.</summary>
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.
/// <summary>Effective value for attribute/vital row <paramref name="index"/>
/// as an int (numeric twin of <see cref="GetRowValueString"/> for the 6
/// primary-attribute rows only — vitals use "cur/max" and have no single
/// effective int).</summary>
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,
};
/// <summary>Buff-only delta (effective base) for the primary-attribute
/// row at <paramref name="index"/>. Zero for vital rows (6-8) — vitals use
/// a separate "cur/max" footer format in retail
/// (<c>gmAttributeUI::DisplaySelectionFooter_Vital</c> 0x0049d6b0), not
/// this delta pattern.</summary>
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];
}
/// <summary>Skill's buff-only delta, excluding vitae — retail computes
/// this as <c>(current vitaeModifier) base</c>
/// (<c>gmSkillUI::DisplaySelectionFooter_Trained</c> 0x0049b860): vitae's
/// own contribution is reported separately via
/// <see cref="FormatVitaeDelta"/>.</summary>
internal static int GetSkillBuffOnlyDelta(CharacterSkill skill) =>
(skill.CurrentLevel - skill.VitaeModifier) - skill.BaseLevel;
/// <summary>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.</summary>
/// <remarks>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)".</remarks>
private static string FormatBuffDelta(int delta) => delta switch
{
0 => string.Empty,
> 0 => string.Create(CultureInfo.InvariantCulture, $" (+{delta})"),
_ => string.Create(CultureInfo.InvariantCulture, $" ({delta})"),
};
/// <summary>Retail " (%d)" vitae-specific parenthetical
/// (<c>SkillInfoRegion::GetVitaeModifier</c> 0x004f0fa0): shown only
/// while vitae is an active penalty (modifier &lt; 0) — vitae never
/// grants a bonus, so no "+" case exists.</summary>
private static string FormatVitaeDelta(int vitaeModifier) =>
vitaeModifier < 0
? string.Create(CultureInfo.InvariantCulture, $" ({vitaeModifier})")
: string.Empty;
/// <summary>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.</summary>
private static string BuildSelectedTitleText(
CharacterStatTab tab,
Func<CharacterSheet> 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<UiText.TextRun> BuildSelectedTitleRuns(
UiText target,
CharacterStatTab tab,
Func<CharacterSheet> 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<UiText.TextRun>
{
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<UiText.TextRun>
{
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;
}
/// <summary>
/// Add a single attribute/vital/skill row to <paramref name="list"/> as a
/// <see cref="UiClickablePanel"/> 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 <see cref="UiClickablePanel.OnClick"/>.
///
/// <para>CT5 hand-built-vs-template ruling: this row stays HAND-BUILT
/// (not converted to <c>UiTemplateListBox</c> 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 <c>CharacterPanelLiveDatTests.AttributeRowTemplate_...</c>).
/// 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.</para>
/// </summary>
private static UiClickablePanel AddRow(
UiElement list,
UiDatFont? datFont,
Func<uint, (uint handle, int w, int h)>? spriteResolve,
float left, float top, float width, float height,
uint iconDid,
string nameText,
Func<string> valueProvider,
Func<Vector4>? 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<UiText.Line>(),
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[]) ────────────────────────
/// <summary>
/// Bind all 5 footer elements with providers that close over <paramref name="sel"/>:
/// when <c>sel[0] == -1</c> (nothing selected) they emit State-A content;
/// when a row is selected they emit State-B content.
///
/// <para><b>State A</b> (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.</para>
///
/// <para><b>State B</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.</para>
///
/// <para>Footer element ids appear in THREE footer-state groups (0x10000240 State A,
/// 0x10000241 State B, 0x10000247 State C). <c>_byId</c> 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.</para>
/// </summary>
private static void BindFooterDynamic(
ImportedLayout layout,
UiDatFont? datFont,
Func<CharacterSheet> 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);
}
/// <summary>Depth-first search of <paramref name="node"/> and its descendants.
/// Returns the first element for which <paramref name="predicate"/> returns true,
/// or null if none found.</summary>
private static UiElement? FindInSubtree(UiElement node, Func<UiElement, bool> 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<UiElement> 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<string> text)
=> Label(layout, null, id, datFont, color, text);
private static void Label(ImportedLayout layout, UiElement? scope, uint id, UiDatFont? datFont, Vector4 color, Func<string> 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) };
}
}
/// <summary>
/// Retail-equivalent of <c>ExperienceSystem::XPToString</c>
/// (<c>sprintf("%I64d", value)</c> → <c>GetNumberFormatA</c>'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 <c>InvariantCulture</c>, not
/// <c>CurrentCulture</c> — the pre-CT4-fix-round call sites used a bare
/// <c>.ToString("N0")</c>, which silently followed the host OS locale.
/// </summary>
private static string FormatXp(long value) => value.ToString("N0", CultureInfo.InvariantCulture);
/// <summary>
/// Same binding shape as <see cref="Label"/>, but the per-line color is
/// read from the widget's own <see cref="UiText.DefaultColor"/> — the
/// value <c>DatWidgetFactory.BuildText</c> 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
/// <see cref="CharacterTitlesController"/>'s row/display text already
/// set (<c>rowText.DefaultColor</c>). CT4 fix-round consistency note
/// (2026-08-25): this helper unconditionally forces
/// <c>Centered = true</c>/<c>OneLine = true</c>, 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.
/// </summary>
private static void LabelAuthoredColor(ImportedLayout layout, UiElement? scope, uint id, UiDatFont? datFont, Func<string> 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) };
}
}
/// <summary>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
/// <see cref="UiText"/> renders multiple lines oldest-first (top-to-bottom), so
/// line 0 = <paramref name="line1"/> (top) and line 1 = <paramref name="line2"/> (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).</summary>
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),
};
}
}
/// <summary>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).</summary>
private static void LabelLeft(ImportedLayout layout, uint id, UiDatFont? datFont, Vector4 color, Func<string> text)
=> LabelLeft(layout, null, id, datFont, color, text);
private static void LabelLeft(ImportedLayout layout, UiElement? scope, uint id, UiDatFont? datFont, Vector4 color, Func<string> 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) };
}
}
/// <summary>Right-justified numeric header value. The imported layout owns
/// bounds/font; the controller owns runtime text and explicit justification.</summary>
private static void LabelRight(
ImportedLayout layout,
UiElement? scope,
uint id,
UiDatFont? datFont,
Vector4 color,
Func<string> 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) };
}
}
/// <summary>Bind a directly-located <see cref="UiText"/> widget with a provider.
/// Used when the widget was found by subtree walk rather than <c>FindElement</c>.
/// Sets <c>Padding = 0</c> to prevent the scroll-clip from hiding text in small
/// (H≈1718px) 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.</summary>
private static void LabelProvider(UiText? t, UiDatFont? datFont, Vector4 color, Func<string> 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) };
}
/// <summary>
/// Depth-first tree walk to collect every <see cref="UiButton"/> that was registered
/// in <paramref name="layout"/> under the given dat element <paramref name="id"/>.
///
/// <para>
/// The standard <see cref="ImportedLayout.FindElement"/> 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.
/// </para>
///
/// <para>
/// Implementation: walk the entire <paramref name="root"/> tree calling
/// <see cref="ImportedLayout.FindElement"/> is not enough (it uses a dict). Instead we
/// exploit the fact that identical element ids produce widgets that all share the SAME
/// <see cref="UiButton"/> instance in <c>_byId</c> for THEIR copy; but siblings from
/// different inheritance mounts are SEPARATE instances not in the same <c>_byId</c> slot.
/// We therefore walk the tree recursively and collect every <see cref="UiButton"/> whose
/// <c>ActiveState</c> reflects the dat default (before our code sets it), which is not a
/// reliable discriminator. Instead, we gather ALL <see cref="UiButton"/> instances from
/// the subtree at the known spatial position (bottom of the panel) — but positions can
/// overlap across pages.
/// </para>
///
/// <para>
/// The correct approach: since <c>_byId</c> stores only one instance per id, we use the
/// <see cref="ImportedLayout.FindElement"/> for the canonical id, then do a FULL tree walk
/// to find ADDITIONAL <see cref="UiButton"/> 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.
/// </para>
/// </summary>
// Match by ElementId, not geometry: the x1 and x10 raise buttons are both 30x26.
private static void CollectButtonsById(
UiElement node,
uint targetId,
List<UiButton> 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<UiButton>(ReferenceEqualityComparer.Instance);
CollectMatchingButtons(node, targetId, seen, result);
}
private static void CollectMatchingButtons(
UiElement node,
uint targetId,
HashSet<UiButton> seen,
List<UiButton> 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);
}
}