acdream/src/AcDream.App/UI/Layout/CharacterStatController.cs
Erik 84b7d2d7cd fix(ui): port live experience quality updates (#217)
Parse retail PrivateUpdatePropertyInt64 and route authoritative Total/Available Experience through both local-player projections so Attributes, the level meter, and Skills refresh together. Preserve the existing retail XP curve and right-align the Total XP value.

Close the user-confirmed item-give gate for #216 and record the named-retail/ACE/holtburger conformance evidence.

Co-Authored-By: Codex <noreply@openai.com>
2026-07-13 20:43:48 +02:00

2032 lines
92 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.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 button states: Attributes = "Open", Skills = "Closed", Titles = "Closed".
/// Source: UIStateId.Open (0x0C) / UIStateId.Closed (0x0B) — set on the UiButton children
/// inside each tab group container.</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
// ── 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) ────────────────────
// In the imported tree each tab group (0x10000228/229/538) resolves to a UiText with
// ConsumesDatChildren=true, so the three button children (left-cap, label, right-cap)
// are consumed at import time and not present in the widget tree.
//
// WHY this is NOT fixed in Fix 5 (importer series):
// The Fix 5 meter patch only builds NON-Type-3 children of UiMeter. The tab group children
// are children of UiText (Type 12), not UiMeter (Type 7). Flipping UiText.ConsumesDatChildren
// globally is not safe — the chat transcript UiText and other Type-12 elements also have
// children in some layouts (scroll decorators etc.), and building them as live UiText widgets
// would risk invisible nodes stealing focus/clicks (the exact problem ConsumesDatChildren was
// designed to prevent). Additionally, the page-visibility pass in Bind() depends on
// tab group Children.Count==0 to skip them — adding children to the tab groups would break
// that pass and hide ALL tab content.
//
// Therefore: the controller injects 3 sprite-drawing root children per tab (absolute coords),
// using the known RenderSurface ids from the retail UI layout dump (2026-06-25):
// Closed (inactive) state: left=0x06005D93, center=0x06005D95, right=0x06005D97
// Open (active) state: left=0x06005D92, center=0x06005D94, right=0x06005D96
// Source: state_id 11=Closed, 12=Open per gmTabUI::SetActive(bool); sprite ids confirmed
// from retail UI layout dump nodes 0x10000439, 0x100000E9, 0x10000215 in layout 0x2100002E.
public const uint TabAttribId = 0x10000228u; // Attributes tab group
public const uint TabSkillsId = 0x10000229u; // Skills tab group
public const uint TabTitlesId = 0x10000538u; // Titles tab group
// Tab 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;
// Tab button piece widths and shared height (from dump node rects).
private const float TabLeftCapW = 17f; // left-cap button width
private const float TabCenterW = 58f; // center label button width
private const float TabRightCapW = 17f; // right-cap button width
private const float TabH = 25f; // button height
// Tab sprite ids per state (Open = active, Closed = inactive).
// Source: retail UI layout dump nodes 0x10000439/0x100000E9/0x10000215 in 0x2100002E.
private const uint TabOpenLeft = 0x06005D92u;
private const uint TabOpenCenter = 0x06005D94u;
private const uint TabOpenRight = 0x06005D96u;
private const uint TabClosedLeft = 0x06005D93u;
private const uint TabClosedCenter = 0x06005D95u;
private const uint TabClosedRight = 0x06005D97u;
// ── Footer element ids (gmStatManagementUI struct fields) ────────────────
// Source: acclient.h / DisplayDefaultFooter (0x0049cde0)
public const uint FooterTitleId = 0x1000024eu; // GetFooterTitleLabel
public const uint FooterLine1Label = 0x10000242u; // GetFooterLineOneLabel
public const uint FooterLine1Value = 0x10000243u; // GetFooterLineOneValue
public const uint FooterLine2Label = 0x10000244u; // GetFooterLineTwoLabel
public const uint FooterLine2Value = 0x10000245u; // GetFooterLineTwoValue
// ── Raise button element ids ──────────────────────────────────────────────
// Source: gmAttributeUI::PostInit (0x0049db70); CM_Train::Event_TrainAttribute.
// Button state "Normal" (UIStateId 0x01) = affordable (green/active);
// "Ghosted" (UIStateId 0x0D) = disabled. Hidden when nothing is selected.
public const uint RaiseOneId = 0x10000246u; // raise × 1
public const uint RaiseTenId = 0x100005EBu; // raise × 10
// ── UIStateId string keys (DatReaderWriter UIStateId enum.ToString()) ────
// State names as returned by UIStateId.ToString() — used as ActiveState keys on UiButton.
private const string StateOpen = "Open"; // UIStateId.Open = 0x0C — active tab
private const string StateClosed = "Closed"; // UIStateId.Closed = 0x0B — inactive tab
private static readonly Vector4 Body = new(0.92f, 0.90f, 0.82f, 1f); // parchment-white body text
private static readonly Vector4 Gold = new(1f, 0.82f, 0.36f, 1f); // section / emphasis gold
/// <summary>Row highlight color — semi-translucent gold, matches retail
/// UIStateId.Highlight (0x06) sprite 0x06001397 visual intent.</summary>
private static readonly Vector4 HighlightBg = new(1f, 0.75f, 0.2f, 0.25f);
private static readonly Vector4 BuffedSkillGreen = new(0.55f, 1f, 0.55f, 1f);
// ── Row layout constants ─────────────────────────────────────────────────
// RowHeight 22px + IconSize 16px: retail spec (2026-06-26) says icons ~icon-height
// and rows tighter. 16px icon fits inside 22px row with 3px vertical padding each side.
// The larger row font (0x40000001, MaxCharHeight=18) is clipped to the 22px height which
// gives a tight-but-readable line. Retail spec (2026-06-26 ref): "rows tighter, text ≈ icon height".
private const float RowHeight = 22f;
private const float IconSize = 16f;
private const float RowPadX = 4f;
private const float IconGap = 6f;
private const float SkillRowHeight = 20f;
private const float SkillHeaderHeight = 20f;
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;
private const uint RowHighlightSprite = 0x06001397u;
// Scrollbar chrome from base layout 0x2100003E, shared with chat/inventory.
private const uint ScrollTrackSprite = 0x06004C5Fu;
private const uint ScrollThumbSprite = 0x06004C63u;
private const uint ScrollThumbTop = 0x06004C60u;
private const uint ScrollThumbBot = 0x06004C66u;
private const uint ScrollUpSprite = 0x06004C69u;
private const uint ScrollDownSprite = 0x06004C6Cu;
private enum CharacterStatTab
{
Attributes,
Skills,
}
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 class TabVisual
{
public required CharacterStatTab Tab { get; init; }
public required UiText Left { get; init; }
public required UiText Center { get; init; }
public required UiText Right { get; init; }
public required UiText Label { get; init; }
public required string Text { get; init; }
}
private sealed record SkillRowBinding(UiClickablePanel Panel, CharacterSkill Skill);
// ── Attribute row descriptors — retail display order per spec §1 ─────────
private 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),
};
private 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>
/// 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>
/// </summary>
public static void 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)
{
// 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>();
var tabVisuals = new List<TabVisual>();
UiElement? contentPage = FindDirectChildById(layout.Root, AttributesPageId);
// Name (18px from dat FontDid), Heritage (14px), PkStatus (14px):
// Fix C: pass null → Label's null-guard keeps the build-time dat font.
// Controllers still own the text color and the LinesProvider.
// Name = WHITE (retail "Horan" is white — confirmed 2026-06-26).
Label(layout, contentPage, NameId, null, Vector4.One, () => data().Name);
Label(layout, contentPage, HeritageId, null, Body, () => CharacterIdentityText.StatHeaderLine(data()));
Label(layout, contentPage, PkStatusId, null, Body, () => data().PkStatus ?? string.Empty);
// ── Header captions (new — retail labels above/left of each number) ──────
// LevelCaption (0x1000023A, 16px from dat): pass null → keep build-time dat font.
LabelTwoLine(layout, 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. The Gold color is still
// set via LinesProvider. SYNTHESIZED elements (the 9 attribute rows built in
// BuildAttributeRows) continue to use datFont directly since they have no dat origin.
// Source: spec §Level area (65,50) + decomp gmStatManagementUI::UpdateCharacterInfo 0x004f0770.
// runtime color, dat carries none.
Label(layout, contentPage, LevelId, null, Gold, () => data().Level.ToString());
// 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, () => data().TotalXp.ToString("N0"));
// XP-to-level meter fill (gmStatManagementUI::UpdateExperience 0x004f0a70).
// Fix 5: child elements 0x10000237 (label) and 0x10000238 (value) are now built by
// the LayoutImporter as UiText children of the XP meter (non-Type-3 meter children
// are explicitly built and registered in byId — see LayoutImporter.BuildWidget).
// FindElement now returns them; the controller binds their LinesProvider.
// The importer builds them as UiText via DatWidgetFactory.BuildText, applying their
// dat-origin HJustify/VJustify/FontDid/FontColor at build time. The controller then
// overrides Padding=0 (to avoid scroll clip in the ~13px-tall bar), ClickThrough=true,
// and provides the LinesProvider for runtime content.
if (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(data().XpToNextLevel.ToString("N0"), Body) };
}
}
// ── Tab bar sprite children (added to PANEL ROOT, not to the tab groups) ───
// The tab groups (0x10000228/229/538) are UiText (Type 12) with
// ConsumesDatChildren=true, so their 3 button children were consumed at import.
// We inject the visual equivalent — 3 sprite UiTexts per tab — as CHILDREN OF
// layout.Root at the tab's ABSOLUTE screen position, NOT as children of the tab
// groups. This matters because the page-visibility pass (below) iterates
// root.Children and hides any child that doesn't contain NameId; if we added
// sprites to the tab groups, those groups would have non-zero Children.Count
// and be eligible for the pass, then hidden (since NameId is in the content area).
// By adding to root instead, the tab groups keep Children.Count==0 and are skipped
// by "if (page.Children.Count == 0) continue" — they stay visible always.
// Source: retail dump tab rects confirmed (2026-06-25):
// Attributes = (0,0,92,25), Skills = (92,0,92,25), Titles = (184,0,92,25).
// Interactive tab sprites are injected after the list/footer closures are wired.
// ── 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) appear once per tab page (Attributes/Skills/Titles)
// in the dat inheritance structure; ImportedLayout._byId keeps only the LAST
// mounted copy. We collect all copies so we can hide them all initially and
// show/hide the correct set when a row is selected.
//
// At bind time the tree includes all three tab pages (the page-visibility pass
// runs AFTER this). Collecting from the full tree is safe: once the page-
// visibility pass hides the inactive pages their raise buttons are invisible
// regardless of the Visible flag we set here — but the Attributes page's
// buttons (which are NOT hidden by the page pass) must be explicitly hidden.
var allRaise1 = new List<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();
if (layout.Root is { } tabRoot2 && spriteResolve is not null)
{
if (layout.FindElement(TabAttribId) is { } origAttr) origAttr.Visible = false;
if (layout.FindElement(TabSkillsId) is { } origSkills) origSkills.Visible = false;
if (layout.FindElement(TabTitlesId) is { } origTitles) origTitles.Visible = false;
tabVisuals.Add(AddTabSpritesToRoot(tabRoot2, spriteResolve, datFont,
tabX: 0f, label: "Attributes", tab: CharacterStatTab.Attributes,
onClick: () => SwitchTab(CharacterStatTab.Attributes)));
tabVisuals.Add(AddTabSpritesToRoot(tabRoot2, spriteResolve, datFont,
tabX: 92f, label: "Skills", tab: CharacterStatTab.Skills,
onClick: () => SwitchTab(CharacterStatTab.Skills)));
AddTabSpritesToRoot(tabRoot2, spriteResolve, datFont,
tabX: 184f, label: "Titles", tab: CharacterStatTab.Attributes,
onClick: static () => { });
UpdateTabVisuals(tabVisuals, activeTab[0]);
}
// ── 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);
RebuildActiveList();
RefreshActiveRaiseButtons();
UpdateTabVisuals(tabVisuals, tab);
Console.WriteLine($"[CharacterStat] Tab click: {tab}");
}
void RebuildActiveList()
{
if (statList is null) return;
foreach (var entry in activeListEntries)
statList.RemoveChild(entry);
activeListEntries.Clear();
currentAttributeRows.Clear();
currentSkillRows.Clear();
if (activeTab[0] == CharacterStatTab.Attributes)
{
if (skillScrollbar is not null)
{
skillScrollbar.Model = null;
skillScrollbar.Visible = false;
}
currentAttributeRows = BuildAttributeRows(statList, rowDatFont, spriteResolve, data, attrSel,
allRaise1, allRaise10, SetFooterSelected);
activeListEntries.AddRange(currentAttributeRows);
}
else
{
float contentW = SkillViewportWidth(statList, skillScrollbar);
var viewport = new UiScrollablePanel
{
Left = 0f,
Top = 0f,
Width = contentW,
Height = statList.Height,
LineHeight = (int)SkillRowHeight,
Anchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Bottom,
};
statList.AddChild(viewport);
activeListEntries.Add(viewport);
BuildSkillRows(viewport, rowDatFont, spriteResolve, data, skillSel,
allRaise1, allRaise10, SetFooterSelected, out currentSkillRows);
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();
}
}
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); };
bar.TrackSprite = ScrollTrackSprite;
bar.ThumbSprite = ScrollThumbSprite;
bar.ThumbTopSprite = ScrollThumbTop;
bar.ThumbBotSprite = ScrollThumbBot;
bar.UpSprite = ScrollUpSprite;
bar.DownSprite = ScrollDownSprite;
}
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;
}
// ── 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)
{
float listW = list.Width;
float y = 0f;
var rows = new List<UiClickablePanel>();
for (int i = 0; i < AttrRows.Length; i++)
{
var (rowName, iconDid, _) = AttrRows[i];
int rowIndex = i;
var row = AddRow(list, datFont, spriteResolve,
left: 0f, top: y, width: listW, height: RowHeight,
iconDid: iconDid,
nameText: rowName,
valueProvider: () =>
{
var s = data();
int v = rowIndex switch
{
0 => s.Strength,
1 => s.Endurance,
2 => s.Coordination,
3 => s.Quickness,
4 => s.Focus,
5 => s.Self,
_ => 0,
};
return v.ToString();
});
row.OnClick = () =>
{
HandleRowClick(rowIndex, sel, rows, spriteResolve, data, allRaise1, allRaise10);
setFooterSelected(sel[0] >= 0);
};
rows.Add(row);
y += RowHeight;
}
for (int i = 0; i < VitalRows.Length; i++)
{
var (rowName, iconDid, _) = VitalRows[i];
int rowIndex = i;
int absIndex = AttrRows.Length + i;
var row = AddRow(list, datFont, spriteResolve,
left: 0f, top: y, width: listW, height: RowHeight,
iconDid: iconDid,
nameText: rowName,
valueProvider: () =>
{
var s = data();
return rowIndex switch
{
0 => $"{s.HealthCurrent}/{s.HealthMax}",
1 => $"{s.StaminaCurrent}/{s.StaminaMax}",
2 => $"{s.ManaCurrent}/{s.ManaMax}",
_ => string.Empty,
};
});
row.OnClick = () =>
{
HandleRowClick(absIndex, sel, rows, spriteResolve, data, allRaise1, allRaise10);
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 = list.Width > 0f ? MathF.Min(list.Width, SkillContentWidth) : SkillContentWidth;
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;
var row = AddRow(list, datFont, spriteResolve,
left: 0f, top: y, width: listW, height: SkillRowHeight,
iconDid: skill.IconDid,
nameText: skill.Name,
valueProvider: () => skill.CurrentLevel.ToString(),
valueColorProvider: () => SkillValueColor(skill),
nameColor: Vector4.One);
row.OnClick = () =>
{
HandleSkillRowClick(rowIndex, sel, bindings, spriteResolve, data, allRaise1, allRaise10);
setFooterSelected(sel[0] >= 0);
};
bindings.Add(new SkillRowBinding(row, skill));
entries.Add(row);
y += SkillRowHeight;
}
}
}
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? 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;
}
private static Vector4 SkillValueColor(CharacterSkill skill)
=> skill.CurrentLevel > skill.BaseLevel ? BuffedSkillGreen
: skill.CurrentLevel < skill.BaseLevel ? new Vector4(1f, 0.45f, 0.45f, 1f)
: 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.
// Retail uses sprite 0x06001397 (Button state 6 — the dark horizontal bars)
// for the selected row background. When spriteResolve is available, apply the
// sprite; otherwise fall back to the translucent gold tint.
const uint HighlightSprite = 0x06001397u;
for (int i = 0; i < rows.Count; i++)
{
var row = rows[i];
if (i == newSel)
{
if (spriteResolve is not null)
{
row.BackgroundColor = Vector4.Zero;
row.BackgroundSprite = HighlightSprite;
row.SpriteResolve = spriteResolve;
}
else
{
row.BackgroundColor = HighlightBg;
row.BackgroundSprite = 0u;
row.SpriteResolve = null;
}
}
else
{
row.BackgroundColor = Vector4.Zero;
row.BackgroundSprite = 0u;
row.SpriteResolve = null;
}
}
// Update raise buttons.
RefreshRaiseButtons(newSel, data, allRaise1, allRaise10);
}
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;
row.SpriteResolve = id => { var (h, w, ht) = spriteResolve(id); return (h, w, ht); };
row.UseSelectionBars = true;
}
else
{
row.BackgroundColor = HighlightBg;
row.BackgroundSprite = 0u;
row.SpriteResolve = null;
row.UseSelectionBars = false;
}
}
else
{
row.BackgroundColor = Vector4.Zero;
row.BackgroundSprite = 0u;
row.SpriteResolve = null;
row.UseSelectionBars = true;
}
}
}
/// <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);
bool affordable1 = cost1 > 0 && sheet.UnassignedXp >= cost1;
bool affordable10 = 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 = 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 = 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,
};
}
/// <summary>
/// Add a single attribute/vital row to <paramref name="list"/> as a
/// <see cref="UiClickablePanel"/> containing icon + name + value children.
/// Returns the panel so the caller can wire <see cref="UiClickablePanel.OnClick"/>.
/// </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,
uint backgroundSprite = 0u,
bool useSelectionBars = true)
{
var row = new UiClickablePanel
{
Left = left,
Top = top,
Width = width,
Height = height,
BackgroundColor = Vector4.Zero, // transparent until selected
BackgroundSprite = spriteResolve is not null ? backgroundSprite : 0u,
SpriteResolve = spriteResolve is not null && backgroundSprite != 0u
? id => { var (h, w, ht) = spriteResolve(id); return (h, w, ht); }
: null,
BorderColor = Vector4.Zero,
UseSelectionBars = useSelectionBars,
Anchors = AnchorEdges.Left | AnchorEdges.Top,
};
float iconY = (height - IconSize) * 0.5f;
var iconEl = new UiText
{
Left = RowPadX,
Top = iconY,
Width = IconSize,
Height = IconSize,
ClickThrough = true,
DatFont = null,
BackgroundSprite = spriteResolve is not null ? iconDid : 0u,
SpriteResolve = spriteResolve is not null
? id => { var (h, w, ht) = spriteResolve(id); return (h, w, ht); }
: null,
LinesProvider = static () => Array.Empty<UiText.Line>(),
Anchors = AnchorEdges.Left | AnchorEdges.Top,
};
float nameX = RowPadX + IconSize + IconGap;
float nameW = width * 0.60f;
float nameY = 0f;
string capturedName = nameText;
Vector4 capturedNameColor = nameColor ?? Body;
var nameEl = new UiText
{
Left = nameX,
Top = nameY,
Width = nameW,
Height = height,
DatFont = datFont,
ClickThrough = true,
Centered = false,
RightAligned = false,
Padding = 1f,
Anchors = AnchorEdges.Left | AnchorEdges.Top,
};
nameEl.LinesProvider = () => new[] { new UiText.Line(capturedName, capturedNameColor) };
float valueW = width - nameX - nameW - RowPadX;
float valueX = nameX + nameW;
var valueEl = new UiText
{
Left = valueX,
Top = nameY,
Width = valueW > 0f ? valueW : 40f,
Height = height,
DatFont = datFont,
ClickThrough = true,
RightAligned = true,
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.LinesProvider = () =>
{
if (activeTab[0] == CharacterStatTab.Skills)
{
var skill = SkillAtDisplayIndex(data(), skillSel[0]);
if (skill is null)
return new[] { new UiText.Line("Select a Skill to Improve", Body) };
string title = skill.AdvancementClass >= CharacterSkillAdvancementClass.Trained
? $"{skill.Name}: {skill.CurrentLevel}"
: skill.Name;
return new[] { new UiText.Line(title, Vector4.One) };
}
if (attrSel[0] < 0)
return new[] { new UiText.Line("Select an Attribute to Improve", Body) };
var s = data();
string name = GetRowName(attrSel[0]);
string value = GetRowValueString(s, attrSel[0]);
// State B title is WHITE (retail confirmed).
return new[] { new UiText.Line($"{name}: {value}", Vector4.One) };
};
}
// Footer lines (all dat-origin with their own font sizes): pass null → keep dat font.
var l1L = ByPos(20f, 5f, FooterLine1Label);
LabelProvider(l1L, null, Body, () =>
{
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 ? skillCost.ToString("N0") : "Infinity!";
}
if (attrSel[0] < 0) return sheet.SkillCredits.ToString();
long cost = GetRaiseCost(sheet, attrSel[0]);
return cost > 0 ? cost.ToString("N0") : "Infinity!";
});
// Line-2 elements: pass null → keep dat font.
var l2L = ByPos(37f, 5f, FooterLine2Label);
LabelProvider(l2L, null, Body, () =>
{
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 sheet.UnassignedXp.ToString("N0");
});
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 = () =>
{
if (activeTab[0] == CharacterStatTab.Skills)
{
var skill = SkillAtDisplayIndex(data(), skillSel[0]);
if (skill is null)
return new[] { new UiText.Line("Select a Skill to Improve", Body) };
string skillTitle = skill.AdvancementClass >= CharacterSkillAdvancementClass.Trained
? $"{skill.Name}: {skill.CurrentLevel}"
: skill.Name;
return new[] { new UiText.Line(skillTitle, Vector4.One) };
}
if (attrSel[0] < 0)
return new[] { new UiText.Line("Select an Attribute to Improve", Body) };
var sheet = data();
string name = GetRowName(attrSel[0]);
string value = GetRowValueString(sheet, attrSel[0]);
return new[] { new UiText.Line($"{name}: {value}", 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 ? skillCost.ToString("N0") : "Infinity!";
}
if (attrSel[0] < 0) return sheet.SkillCredits.ToString();
long cost = GetRaiseCost(sheet, attrSel[0]);
return cost > 0 ? cost.ToString("N0") : "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 sheet.UnassignedXp.ToString("N0");
});
}
}
// ── 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>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>
/// Add the three sprite pieces and the label for ONE tab to <paramref name="root"/>
/// as direct root children at absolute positions. Sprites go to root (not to the
/// tab group elements) so the tab groups keep Children.Count==0 and survive the
/// page-visibility pass unchanged.
///
/// <para>Sprite ids from the retail UI layout dump (2026-06-25), nodes
/// 0x10000439/0x100000E9/0x10000215 in layout 0x2100002E:
/// Open (active): 0x06005D92/0x06005D94/0x06005D96;
/// Closed (inactive): 0x06005D93/0x06005D95/0x06005D97.</para>
///
/// <para>Tab geometry confirmed from dump: each tab = 92px wide, 25px tall.
/// Left-cap = 17px, center = 58px, right-cap = 17px (total 92px).</para>
/// </summary>
private static TabVisual AddTabSpritesToRoot(
UiElement root,
Func<uint, (uint handle, int w, int h)> spriteResolve,
UiDatFont? datFont,
float tabX,
string label,
CharacterStatTab tab,
Action onClick)
{
uint leftId = TabClosedLeft;
uint centerId = TabClosedCenter;
uint rightId = TabClosedRight;
// Left cap: 17×25 at (tabX, 0)
var left = MakeSpriteElement(spriteResolve, x: tabX, y: 0f, w: TabLeftCapW, h: TabH, spriteId: leftId);
root.AddChild(left);
// Center: 58×25 at (tabX+17, 0) — carries the label text on top
var center = MakeSpriteElement(spriteResolve, x: tabX + TabLeftCapW, y: 0f, w: TabCenterW, h: TabH, spriteId: centerId);
root.AddChild(center);
// Right cap: 17×25 at (tabX+75, 0)
var right = MakeSpriteElement(spriteResolve, x: tabX + TabLeftCapW + TabCenterW, y: 0f, w: TabRightCapW, h: TabH, spriteId: rightId);
root.AddChild(right);
// Label text centered on the tab center piece.
// Active (Open) tab: gold; inactive (Closed) tabs: parchment body color.
// ZOrder=9 → draws on top of the sprite background (ZOrder=8) and original tab groups (ZOrder=13).
Vector4 labelColor = Body;
string capturedLabel = label;
var labelEl = new UiText
{
Left = tabX + TabLeftCapW,
Top = 0f,
Width = TabCenterW,
Height = TabH,
ZOrder = 9,
DatFont = datFont,
ClickThrough = true,
Centered = true,
OneLine = true,
Anchors = AnchorEdges.Left | AnchorEdges.Top,
};
labelEl.LinesProvider = () => new[] { new UiText.Line(capturedLabel, labelColor) };
root.AddChild(labelEl);
var hit = new UiClickablePanel
{
Left = tabX,
Top = 0f,
Width = TabLeftCapW + TabCenterW + TabRightCapW,
Height = TabH,
ZOrder = 10,
BackgroundColor = Vector4.Zero,
BorderColor = Vector4.Zero,
Anchors = AnchorEdges.Left | AnchorEdges.Top,
OnClick = onClick,
};
root.AddChild(hit);
return new TabVisual
{
Tab = tab,
Left = left,
Center = center,
Right = right,
Label = labelEl,
Text = label,
};
}
private static void UpdateTabVisuals(IReadOnlyList<TabVisual> tabs, CharacterStatTab activeTab)
{
foreach (var tab in tabs)
{
bool isOpen = tab.Tab == activeTab;
tab.Left.BackgroundSprite = isOpen ? TabOpenLeft : TabClosedLeft;
tab.Center.BackgroundSprite = isOpen ? TabOpenCenter : TabClosedCenter;
tab.Right.BackgroundSprite = isOpen ? TabOpenRight : TabClosedRight;
Vector4 color = isOpen ? Gold : Body;
string text = tab.Text;
tab.Label.LinesProvider = () => new[] { new UiText.Line(text, color) };
}
}
/// <summary>Build a single sprite-drawing <see cref="UiText"/> leaf at the given position.
/// ZOrder=8 places it above the original tab-group UiTexts (ZOrder=13) so it draws on top.</summary>
private static UiText MakeSpriteElement(
Func<uint, (uint handle, int w, int h)> spriteResolve,
float x, float y, float w, float h, uint spriteId)
{
return new UiText
{
Left = x,
Top = y,
Width = w,
Height = h,
ZOrder = 8,
BackgroundSprite = spriteId,
SpriteResolve = id => { var (tex, tw, th) = spriteResolve(id); return (tex, tw, th); },
LinesProvider = static () => System.Array.Empty<UiText.Line>(),
ClickThrough = true,
Anchors = AnchorEdges.Left | AnchorEdges.Top,
};
}
/// <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);
}
}