using System;
using System.Collections.Generic;
using System.Numerics;
using AcDream.App.UI;
namespace AcDream.App.UI.Layout;
///
/// Controller for the Character window's Attributes tab — LayoutDesc 0x2100002E,
/// whose tab-content slot 0x1000022B mounts sub-layout 0x2100002C (gmAttributeUI, root
/// type 0x1000002A) which in turn chains into the gmStatManagementUI header content.
///
/// Unlike (which targets the SEPARATE text-report
/// sub-panel 0x2100001A, gmCharacterInfoUI, by creating its runtime m_pMainText element),
/// this controller binds the real, statically-mounted header + list elements that the
/// importer already produces — every id below is confirmed present via
/// .
///
/// Ported from gmStatManagementUI::UpdateCharacterInfo (0x004f0770) +
/// UpdateExperience (0x004f0a70) + UpdatePKStatus (0x004f00a0): name, heritage,
/// PK status, level, total XP and the XP-to-level meter. The attribute list is the
/// gmAttributeUI list box (0x1000023D), built as 9 manual-layout rows per
/// gmAttributeUI::PostInit (0x0049db70) + AttributeInfoRegion / Attribute2ndInfoRegion
/// (0x004f1910 / 0x004f19e0). Row icons loaded via sub-element 0x10000129 in the retail
/// dat template (each icon is a 0x06xxxxxx RenderSurface DataID from SubMap
/// 0x25000006 / 0x25000007, spec §2).
///
/// Footer State A (nothing selected) bound from
/// DisplayDefaultFooter (0x0049cde0): title empty, line-1 value =
/// "Select an Attribute to Improve", line-2 value = available skill credits (InqInt(0x18)).
///
/// Footer State B (row selected) bound from
/// DisplaySelectedAttribute (implicitly): title = "{AttrName}: {value}",
/// line-1 label = "Experience To Raise:", line-1 value = raise cost,
/// line-2 label = "Unassigned Experience:", line-2 value = UnassignedXp.
///
/// Tab button states: Attributes = "Open", Skills = "Closed", Titles = "Closed".
/// Source: UIStateId.Open (0x0C) / UIStateId.Closed (0x0B) — set on the UiButton children
/// inside each tab group container.
///
/// Raise buttons: 0x10000246 (×1) + 0x100005EB (×10). State "Normal" = affordable
/// (UIStateId.Normal, 0x01), state "Ghosted" = unaffordable or no selection
/// (UIStateId.Ghosted, 0x0D). Source: gmAttributeUI::AttributeInfoRegion::Update (0x004f1910).
///
public static class CharacterStatController
{
// ── gmStatManagementUI header element ids (sub-layout 0x2100002C content) ──
public const uint NameId = 0x10000231u; // m_pNameText
public const uint HeritageId = 0x10000232u; // m_pHeritageText
public const uint PkStatusId = 0x10000233u; // m_pPKStatusText
public const uint LevelCaptionId = 0x1000023Au; // "Character Level" caption ABOVE level value
public const uint LevelId = 0x1000023Bu; // m_pLevelText (right-side level area)
public const uint TotalXpLabelId = 0x10000234u; // "Total Experience (XP):" caption left of value
public const uint TotalXpId = 0x10000235u; // m_pTotalXPText
public const uint XpMeterId = 0x10000236u; // m_pXPToLevelMeter (UiMeter)
// Fix 5: 0x10000237 (XP-to-level label) and 0x10000238 (XP-to-level value) are now
// built by the LayoutImporter as UiText children of the XP meter (non-Type-3 children
// are explicitly built and registered in byId). FindElement DOES return them now.
// The controller binds their LinesProvider instead of injecting new runtime nodes.
public const uint XpNextLabelId = 0x10000237u; // "XP for next level:" label child of XP meter
public const uint XpNextValueId = 0x10000238u; // XP-to-next-level value child of XP meter
public const uint ListBoxId = 0x1000023Du; // m_pListBox container
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,
// 0x10000242–0x10000245 labels+values) are the correct State-A versions with wider
// label widths (195px vs 145px in State B). _byId stores the LAST duplicate, which
// is the narrower State-B/C copy — so we walk the tree to 0x10000240 and bind from there.
public const uint FooterStateAId = 0x10000240u; // State-A footer container (nothing selected)
public const uint FooterStateBId = 0x10000241u; // State-B footer container (row selected)
public const uint FooterStateCId = 0x10000247u; // State-C footer container (hide inactive)
// ── Tab bar element ids (LayoutDesc 0x2100002E root) ────────────────────
// In the imported tree each tab group (0x10000228/229/538) resolves to a UiText with
// ConsumesDatChildren=true, so the three button children (left-cap, label, right-cap)
// are consumed at import time and not present in the widget tree.
//
// WHY this is NOT fixed in Fix 5 (importer series):
// The Fix 5 meter patch only builds NON-Type-3 children of UiMeter. The tab group children
// are children of UiText (Type 12), not UiMeter (Type 7). Flipping UiText.ConsumesDatChildren
// globally is not safe — the chat transcript UiText and other Type-12 elements also have
// children in some layouts (scroll decorators etc.), and building them as live UiText widgets
// would risk invisible nodes stealing focus/clicks (the exact problem ConsumesDatChildren was
// designed to prevent). Additionally, the page-visibility pass in Bind() depends on
// tab group Children.Count==0 to skip them — adding children to the tab groups would break
// that pass and hide ALL tab content.
//
// Therefore: the controller injects 3 sprite-drawing root children per tab (absolute coords),
// using the known RenderSurface ids from the retail UI layout dump (2026-06-25):
// Closed (inactive) state: left=0x06005D93, center=0x06005D95, right=0x06005D97
// Open (active) state: left=0x06005D92, center=0x06005D94, right=0x06005D96
// Source: state_id 11=Closed, 12=Open per gmTabUI::SetActive(bool); sprite ids confirmed
// from retail UI layout dump nodes 0x10000439, 0x100000E9, 0x10000215 in layout 0x2100002E.
public const uint TabAttribId = 0x10000228u; // Attributes tab group
public const uint TabSkillsId = 0x10000229u; // Skills tab group
public const uint TabTitlesId = 0x10000538u; // Titles tab group
// Tab 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
/// Row highlight color — semi-translucent gold, matches retail
/// UIStateId.Highlight (0x06) sprite 0x06001397 visual intent.
private static readonly Vector4 HighlightBg = new(1f, 0.75f, 0.2f, 0.25f);
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);
///
/// Handles a retail stat raise and invokes when
/// the request has either been applied or cancelled. The completion seam is
/// required by asynchronous retail confirmation dialogs such as
/// gmSkillUI::TrainSkill @ 0x0049C5F0.
///
public delegate void RaiseRequestHandler(RaiseRequest request, Action completed);
private sealed 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
};
///
/// Bind the Attributes-tab header + 9-row list + footer elements, tab button states,
/// and raise buttons in to .
///
///
/// Interactive mode (Pass 2): each attribute/vital row is a
/// that fires selection logic on left-click. The
/// selected row index is held in a mutable int[] box (single element) so that
/// all closures share the same mutable slot without escaping the static method boundary.
///
///
///
/// resolves a 0x06xxxxxx RenderSurface dat id to
/// a (GL tex handle, pixel width, pixel height) triple. Pass null in tests where
/// icon rendering is not asserted.
///
///
public static void Bind(
ImportedLayout layout,
Func data,
UiDatFont? datFont = null,
UiDatFont? rowDatFont = null,
Func? spriteResolve = null,
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();
var currentAttributeRows = new List();
var currentSkillRows = new List();
var tabVisuals = new List();
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();
var allRaise10 = new List();
if (layout.Root is { } r)
{
// Single-pass tree walk to collect all UiButton copies at the two ids.
// FindElement only returns the last-registered copy in _byId; we need ALL
// copies because duplicated sub-layout mounts each tab page independently.
CollectButtonsById(r, RaiseOneId, allRaise1, layout);
CollectButtonsById(r, RaiseTenId, allRaise10, layout);
}
// If tree-walk found nothing, fall back to _byId (covers fake/test layouts).
if (allRaise1.Count == 0 && layout.FindElement(RaiseOneId) is UiButton b1) allRaise1.Add(b1);
if (allRaise10.Count == 0 && layout.FindElement(RaiseTenId) is UiButton b10) allRaise10.Add(b10);
var footerDefaultGroups = new List();
var footerSelectedGroups = new List();
var footerInactiveGroups = new List();
if (contentPage is not null)
{
CollectElementsByDatId(contentPage, FooterStateAId, footerDefaultGroups);
CollectElementsByDatId(contentPage, FooterStateBId, footerSelectedGroups);
CollectElementsByDatId(contentPage, FooterStateCId, footerInactiveGroups);
}
else if (layout.Root is not null)
{
CollectElementsByDatId(layout.Root, FooterStateAId, footerDefaultGroups);
CollectElementsByDatId(layout.Root, FooterStateBId, footerSelectedGroups);
CollectElementsByDatId(layout.Root, FooterStateCId, footerInactiveGroups);
}
void SetFooterSelected(bool selected)
{
foreach (var g in footerDefaultGroups) g.Visible = !selected;
foreach (var g in footerSelectedGroups) g.Visible = selected;
foreach (var g in footerInactiveGroups) g.Visible = false;
}
// Initial state: raise buttons hidden until a row is selected.
foreach (var b in allRaise1) b.Visible = false;
foreach (var b in allRaise10) b.Visible = false;
// ── Footer state visibility ───────────────────────────────────────────
// There are THREE footer state groups (A=0x10000240, B=0x10000241, C=0x10000247)
// all stacked at the same position within the Attributes page. _byId stores only
// the LAST copy of each id; the others live in the VISIBLE Attributes page and must
// also be hidden.
//
// WHY this cannot be done in the importer (dat state-model audit 2026-06-26):
// All three group elements have DefaultState = StatManagement_Footer_Default
// (0x10000011) — the dat does NOT differentiate them by visibility. The parent
// element (0x1000022F) has a States map {Default, Text, Meter} with PassToChildren=
// true, but each child group also registers all three states (IncFlags=None,
// Media=0) — meaning the state-propagation produces no media change on any group.
// Retail's gmStatManagementUI uses hardcoded element-id dispatch
// (GetChildRecursive(this, 0x10000240) for Default, 0x10000241 for Text, 0x10000247
// for Meter) to access the right group's children at runtime — the groups themselves
// are never hidden/shown via the dat state mechanism. The controller is the correct
// and only place for this visibility management. See retail decomp
// gmStatManagementUI::GetFooterTitleLabel @0x004f0170.
//
// Strategy: collect the footer groups from the explicit Attributes page id.
// State A is visible when nothing is selected; State B is visible when a row is
// selected and owns the retail raise buttons; State C stays hidden for now.
SetFooterSelected(false);
// ── Footer State A initial binding ────────────────────────────────────
// Walk to the State-A container directly (rather than _byId which returns the
// last duplicate) so we get the wider-label copies (195px) for the unselected state.
BindFooterDynamic(layout, datFont, data, activeTab, attrSel, skillSel, contentPage);
SetFooterSelected(false);
UiElement? statList =
(contentPage is not null
? FindInSubtree(contentPage, static el => HasDatElementId(el, ListBoxId))
: null)
?? layout.FindElement(ListBoxId);
// Imported character nodes already carry the exact raw-edge policy from
// the 337px base sublayout into the 575px mounted page. Only synthetic/
// legacy widgets need compatibility anchors; assigning Anchors on an
// imported node deliberately clears its UiLayoutPolicy.
if (statList is not null && statList.LayoutPolicy is null)
statList.Anchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Bottom;
if (layout.Root is { } stretchRoot)
{
SetCompatibilityAnchorsAllById(stretchRoot, ListScrollbarId, AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Bottom);
SetCompatibilityAnchorsAllById(stretchRoot, ListDividerId, AnchorEdges.Left | AnchorEdges.Bottom);
SetCompatibilityAnchorsAllById(stretchRoot, FooterStateAId, AnchorEdges.Left | AnchorEdges.Bottom);
SetCompatibilityAnchorsAllById(stretchRoot, FooterStateBId, AnchorEdges.Left | AnchorEdges.Bottom);
SetCompatibilityAnchorsAllById(stretchRoot, FooterStateCId, AnchorEdges.Left | AnchorEdges.Bottom);
}
UiScrollbar? skillScrollbar = PrepareSkillScrollbar(layout, contentPage, statList, spriteResolve);
WireRaiseButtonClicks(allRaise1, allRaise10, data, activeTab, attrSel, skillSel,
() => currentSkillRows, onRaiseRequest, RefreshAfterRaise);
RebuildActiveList();
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? spriteResolve)
{
if (spriteResolve is null)
return null;
// The mounted character sublayout contains duplicated stat-management
// branches with the same element ids. Bind the scrollbar adjacent to the
// active list first; a page-wide DFS can select an inactive duplicate that
// is never drawn while leaving the visible gutter unconfigured.
UiElement? source = statList?.Parent?.Children.FirstOrDefault(
static el => HasDatElementId(el, ListScrollbarId))
?? (contentPage is not null
? FindInSubtree(contentPage, static el => HasDatElementId(el, ListScrollbarId))
: null)
?? layout.FindElement(ListScrollbarId);
if (source is UiScrollbar existingBar)
{
existingBar.SpriteResolve ??= id =>
{
var (handle, width, height) = spriteResolve(id);
return (handle, width, height);
};
existingBar.Visible = false;
return existingBar;
}
UiElement? parent = source?.Parent ?? statList?.Parent;
if (parent is null || statList is null)
return null;
float left = source is not null
? source.Left
: statList.Left + MathF.Min(statList.Width, SkillContentWidth);
float top = source?.Top ?? statList.Top;
float width = source?.Width > 0f ? source.Width : 16f;
float height = source?.Height > 0f ? source.Height : statList.Height;
int z = (source?.ZOrder ?? statList.ZOrder) + 1;
if (source is not null)
source.Visible = false;
var bar = new UiScrollbar
{
Left = left,
Top = top,
Width = width,
Height = height,
ZOrder = z,
Anchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Bottom,
};
ConfigureSkillScrollbar(bar, spriteResolve);
bar.Visible = false;
parent.AddChild(bar);
return bar;
}
private static void ConfigureSkillScrollbar(
UiScrollbar bar,
Func spriteResolve)
{
bar.SpriteResolve = id => { var (h, w, ht) = spriteResolve(id); return (h, w, ht); };
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 BuildAttributeRows(
UiElement list,
UiDatFont? datFont,
Func? spriteResolve,
Func data,
int[] sel,
List allRaise1,
List allRaise10,
Action setFooterSelected)
{
float listW = list.Width;
float y = 0f;
var rows = new List();
for (int i = 0; i < AttrRows.Length; i++)
{
var (rowName, iconDid, _) = AttrRows[i];
int rowIndex = i;
var row = AddRow(list, datFont, spriteResolve,
left: 0f, top: y, width: listW, height: RowHeight,
iconDid: iconDid,
nameText: rowName,
valueProvider: () =>
{
var s = data();
int v = rowIndex switch
{
0 => s.Strength,
1 => s.Endurance,
2 => s.Coordination,
3 => s.Quickness,
4 => s.Focus,
5 => s.Self,
_ => 0,
};
return v.ToString();
});
row.OnClick = () =>
{
HandleRowClick(rowIndex, sel, rows, spriteResolve, data, allRaise1, allRaise10);
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 BuildSkillRows(
UiElement list,
UiDatFont? datFont,
Func? spriteResolve,
Func data,
int[] sel,
List allRaise1,
List allRaise10,
Action setFooterSelected,
out List skillRows)
{
float listW = list.Width > 0f ? MathF.Min(list.Width, SkillContentWidth) : SkillContentWidth;
float y = 0f;
var entries = new List();
var bindings = new List();
AddBucket("Specialized Skills", SkillHeaderSpecializedSprite,
OrderedSkills(data(), CharacterSkillAdvancementClass.Specialized, usableUntrained: null));
AddBucket("Trained Skills", SkillHeaderTrainedSprite,
OrderedSkills(data(), CharacterSkillAdvancementClass.Trained, usableUntrained: null));
AddBucket("Untrained Skills", SkillHeaderUntrainedSprite,
OrderedSkills(data(), CharacterSkillAdvancementClass.Untrained, usableUntrained: true));
AddBucket("Unusable Skills", SkillHeaderUnusableSprite,
OrderedSkills(data(), CharacterSkillAdvancementClass.Untrained, usableUntrained: false));
skillRows = bindings;
return entries;
void AddBucket(string title, uint spriteId, IReadOnlyList skills)
{
var header = AddSkillHeader(list, datFont, spriteResolve, 0f, y, listW, title, spriteId);
entries.Add(header);
y += SkillHeaderHeight;
foreach (var skill in skills)
{
int rowIndex = bindings.Count;
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? spriteResolve,
float left,
float top,
float width,
string title,
uint spriteId)
{
var header = new UiPanel
{
Left = left,
Top = top,
Width = width,
Height = SkillHeaderHeight,
BackgroundColor = spriteResolve is null ? new Vector4(0.12f, 0.12f, 0.14f, 0.65f) : Vector4.Zero,
BackgroundSprite = spriteResolve is not null ? spriteId : 0u,
SpriteResolve = spriteResolve is not null
? id => { var (h, w, ht) = spriteResolve(id); return (h, w, ht); }
: null,
BorderColor = Vector4.Zero,
Anchors = AnchorEdges.Left | AnchorEdges.Top,
ClickThrough = true,
};
var label = new UiText
{
Left = RowPadX,
Top = 0f,
Width = MathF.Max(1f, width - RowPadX * 2f),
Height = SkillHeaderHeight,
DatFont = datFont,
ClickThrough = true,
Centered = false,
RightAligned = false,
Padding = 1f,
Anchors = AnchorEdges.Left | AnchorEdges.Top,
};
string captured = title;
label.LinesProvider = () => new[] { new UiText.Line(captured, Vector4.One) };
header.AddChild(label);
list.AddChild(header);
return header;
}
private static IReadOnlyList OrderedSkills(
CharacterSheet sheet,
CharacterSkillAdvancementClass advancement,
bool? usableUntrained)
{
var result = new List();
foreach (var skill in sheet.Skills)
{
if (skill.AdvancementClass != advancement) continue;
if (usableUntrained is not null && skill.UsableUntrained != usableUntrained.Value) continue;
result.Add(skill);
}
result.Sort(static (a, b) => string.Compare(a.Name, b.Name, StringComparison.Ordinal));
return result;
}
private static CharacterSkill? 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;
///
/// Handles a row click: toggle (same row → deselect), else select new row.
/// Updates highlight, footer providers, and raise-button state.
///
private static void HandleRowClick(
int clickedIndex,
int[] sel,
List rows,
Func? spriteResolve,
Func data,
List allRaise1,
List allRaise10)
{
int newSel = (sel[0] == clickedIndex) ? -1 : clickedIndex;
sel[0] = newSel;
// Log for live test confirmation (user tests selection in the studio).
string rowName = GetRowName(newSel);
Console.WriteLine($"[CharacterStat] Row click: index={clickedIndex} → selected={newSel} ({rowName})");
// Update highlight on all rows.
// Retail uses sprite 0x06001397 (Button state 6 — the dark horizontal bars)
// for the selected row background. When spriteResolve is available, apply the
// sprite; otherwise fall back to the translucent gold tint.
const uint HighlightSprite = 0x06001397u;
for (int i = 0; i < rows.Count; i++)
{
var row = rows[i];
if (i == newSel)
{
if (spriteResolve is not null)
{
row.BackgroundColor = Vector4.Zero;
row.BackgroundSprite = HighlightSprite;
row.SpriteResolve = spriteResolve;
}
else
{
row.BackgroundColor = HighlightBg;
row.BackgroundSprite = 0u;
row.SpriteResolve = null;
}
}
else
{
row.BackgroundColor = Vector4.Zero;
row.BackgroundSprite = 0u;
row.SpriteResolve = null;
}
}
// Update raise buttons.
RefreshRaiseButtons(newSel, data, allRaise1, allRaise10);
}
private static void HandleSkillRowClick(
int clickedIndex,
int[] sel,
List rows,
Func? spriteResolve,
Func data,
List allRaise1,
List allRaise10)
{
int newSel = (sel[0] == clickedIndex) ? -1 : clickedIndex;
sel[0] = newSel;
string rowName = newSel >= 0 && newSel < rows.Count ? rows[newSel].Skill.Name : string.Empty;
Console.WriteLine($"[CharacterStat] Skill row click: index={clickedIndex} -> selected={newSel} ({rowName})");
ApplySkillSelectionVisuals(newSel, rows, spriteResolve);
CharacterSkill? selectedSkill = newSel >= 0 && newSel < rows.Count ? rows[newSel].Skill : null;
RefreshSkillRaiseButtons(selectedSkill, data(), allRaise1, allRaise10);
}
private static void ApplySkillSelectionVisuals(
int selectedIndex,
IReadOnlyList rows,
Func? spriteResolve)
{
for (int i = 0; i < rows.Count; i++)
{
var row = rows[i].Panel;
if (i == selectedIndex)
{
if (spriteResolve is not null)
{
row.BackgroundColor = Vector4.Zero;
row.BackgroundSprite = RowHighlightSprite;
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;
}
}
}
/// Refresh raise button visibility + state based on current selection.
/// Applies to ALL collected raise button copies (one per tab page) so the
/// Attributes-page buttons are correctly shown/hidden regardless of which
/// copy happens to be in ImportedLayout._byId.
private static void RefreshRaiseButtons(
int selectedIndex,
Func data,
List allRaise1,
List allRaise10)
{
if (allRaise1.Count == 0 && allRaise10.Count == 0) return;
if (selectedIndex < 0)
{
// Nothing selected: hide all raise buttons.
foreach (var b in allRaise1) b.Visible = false;
foreach (var b in allRaise10) b.Visible = false;
return;
}
var sheet = data();
long cost1 = GetRaiseCost(sheet, selectedIndex, amount: 1);
long cost10 = GetRaiseCost(sheet, selectedIndex, amount: 10);
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 allRaise1,
List allRaise10)
{
if (selectedSkill is null)
{
foreach (var b in allRaise1) b.Visible = false;
foreach (var b in allRaise10) b.Visible = false;
return;
}
bool trained = selectedSkill.AdvancementClass >= CharacterSkillAdvancementClass.Trained;
long cost = trained ? selectedSkill.RaiseCost : selectedSkill.TrainedCost;
bool affordable = 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 allRaise1,
List allRaise10,
Func data,
CharacterStatTab[] activeTab,
int[] attrSel,
int[] skillSel,
Func> skillRows,
RaiseRequestHandler? onRaiseRequest,
Action? afterRaiseRequest)
{
foreach (var button in allRaise1)
{
UiButton captured = button;
captured.OnClick = () => HandleRaiseButtonClick(
amount: 1, data, activeTab, attrSel, skillSel, skillRows, onRaiseRequest, afterRaiseRequest);
}
foreach (var button in allRaise10)
{
UiButton captured = button;
captured.OnClick = () => HandleRaiseButtonClick(
amount: 10, data, activeTab, attrSel, skillSel, skillRows, onRaiseRequest, afterRaiseRequest);
}
}
private static void HandleRaiseButtonClick(
int amount,
Func data,
CharacterStatTab[] activeTab,
int[] attrSel,
int[] skillSel,
Func> skillRows,
RaiseRequestHandler? onRaiseRequest,
Action? afterRaiseRequest)
{
if (onRaiseRequest is null) return;
var sheet = data();
RaiseRequest? request;
uint? selectedSkillId = null;
if (activeTab[0] == CharacterStatTab.Attributes)
{
request = TryBuildAttributeRaiseRequest(sheet, attrSel[0], amount);
}
else
{
var rows = skillRows();
CharacterSkill? selectedSkill =
skillSel[0] >= 0 && skillSel[0] < rows.Count
? rows[skillSel[0]].Skill
: null;
selectedSkillId = selectedSkill?.Id;
request = TryBuildSkillRaiseRequest(sheet, selectedSkill, amount);
}
if (request is not { } value) return;
onRaiseRequest(value, () => afterRaiseRequest?.Invoke(selectedSkillId));
}
private static RaiseRequest? TryBuildAttributeRaiseRequest(
CharacterSheet sheet,
int selectedIndex,
int amount)
{
if (selectedIndex < 0) return null;
long cost = GetRaiseCost(sheet, selectedIndex, amount);
if (cost <= 0 || sheet.UnassignedXp < cost) return null;
if (selectedIndex < AttrRows.Length)
return new RaiseRequest(RaiseTargetKind.Attribute, AttrRows[selectedIndex].statId, cost, amount);
int vitalIndex = selectedIndex - AttrRows.Length;
if (vitalIndex >= 0 && vitalIndex < VitalRows.Length)
return new RaiseRequest(RaiseTargetKind.Vital, VitalRows[vitalIndex].maxStatId, cost, amount);
return null;
}
private static RaiseRequest? TryBuildSkillRaiseRequest(
CharacterSheet sheet,
CharacterSkill? selectedSkill,
int amount)
{
if (selectedSkill is null) return null;
bool trained = selectedSkill.AdvancementClass >= CharacterSkillAdvancementClass.Trained;
if (!trained)
{
if (amount != 1) return null;
long trainCost = selectedSkill.TrainedCost;
return trainCost > 0 && sheet.SkillCredits >= trainCost
? new RaiseRequest(RaiseTargetKind.TrainSkill, selectedSkill.Id, trainCost, amount)
: null;
}
long raiseCost = amount == 10 ? selectedSkill.Raise10Cost : selectedSkill.RaiseCost;
return raiseCost > 0 && sheet.UnassignedXp >= raiseCost
? new RaiseRequest(RaiseTargetKind.Skill, selectedSkill.Id, raiseCost, amount == 10 ? 10 : 1)
: null;
}
/// Return the raise cost for row from the sheet.
/// Returns 0 if the cost array is shorter than expected.
internal static long GetRaiseCost(CharacterSheet sheet, int rowIndex)
=> GetRaiseCost(sheet, rowIndex, amount: 1);
/// Return the raise cost for row and retail amount.
/// Returns 0 if the relevant cost array is shorter than expected.
internal static long GetRaiseCost(CharacterSheet sheet, int rowIndex, int amount)
{
var costs = amount == 10 ? sheet.AttributeRaise10Costs : sheet.AttributeRaiseCosts;
if (costs is null || rowIndex < 0 || rowIndex >= costs.Length)
return 0L;
return costs[rowIndex];
}
/// Return the display name for the row at ,
/// or an empty string if the index is out of range.
internal static string GetRowName(int index)
{
if (index < 0) return string.Empty;
if (index < AttrRows.Length) return AttrRows[index].name;
int vi = index - AttrRows.Length;
if (vi < VitalRows.Length) return VitalRows[vi].name;
return string.Empty;
}
/// Return the numeric value for the row at .
internal static string GetRowValueString(CharacterSheet sheet, int index)
{
return index switch
{
0 => sheet.Strength.ToString(),
1 => sheet.Endurance.ToString(),
2 => sheet.Coordination.ToString(),
3 => sheet.Quickness.ToString(),
4 => sheet.Focus.ToString(),
5 => sheet.Self.ToString(),
6 => $"{sheet.HealthCurrent}/{sheet.HealthMax}",
7 => $"{sheet.StaminaCurrent}/{sheet.StaminaMax}",
8 => $"{sheet.ManaCurrent}/{sheet.ManaMax}",
_ => string.Empty,
};
}
///
/// Add a single attribute/vital row to as a
/// containing icon + name + value children.
/// Returns the panel so the caller can wire .
///
private static UiClickablePanel AddRow(
UiElement list,
UiDatFont? datFont,
Func? spriteResolve,
float left, float top, float width, float height,
uint iconDid,
string nameText,
Func valueProvider,
Func? 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(),
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[]) ────────────────────────
///
/// Bind all 5 footer elements with providers that close over :
/// when sel[0] == -1 (nothing selected) they emit State-A content;
/// when a row is selected they emit State-B content.
///
/// State A (DisplayDefaultFooter 0x0049cde0):
/// title = "Select an Attribute to Improve"; line-1 label = "Skill Credits Available:";
/// line-1 value = SkillCredits; line-2 label = "Unassigned Experience:";
/// line-2 value = UnassignedXp.
///
/// State B (attribute selected):
/// title = "{AttrName}: {value}" (e.g. "Focus: 10"); line-1 label = "Experience To Raise:";
/// line-1 value = raise cost; line-2 label = "Unassigned Experience:";
/// line-2 value = UnassignedXp.
///
/// Footer element ids appear in THREE footer-state groups (0x10000240 State A,
/// 0x10000241 State B, 0x10000247 State C). _byId stores the LAST duplicate,
/// which is the State B/C copy with narrower labels (145px vs 195px in State A). To get
/// the correct wide-label State A elements, we find the State A container directly and
/// walk to the child elements by id within that subtree.
///
private static void BindFooterDynamic(
ImportedLayout layout,
UiDatFont? datFont,
Func data,
CharacterStatTab[] activeTab,
int[] attrSel,
int[] skillSel,
UiElement? contentPage = null)
{
// Walk the State A container (0x10000240) to find the wide-label copies of the
// footer child elements. The 5 children of 0x10000240 at their LOCAL coords:
// title (0x1000024E): Left=0, Top=0, W=300, H=55
// line-1 label (0x10000242): Left=5, Top=20, W=195 (State A wide) vs 145 (B/C narrow)
// line-1 value (0x10000243): Left=200, Top=20, W=95
// line-2 label (0x10000244): Left=5, Top=37, W=195 (State A wide) vs 145 (B/C narrow)
// line-2 value (0x10000245): Left=200, Top=37, W=95
// The State B/C copies use narrower labels (145px) to accommodate raise buttons.
//
// IMPORTANT: The footer state id (0x10000240) appears once per tab-page sub-layout
// (Attributes / Skills / Titles). layout._byId stores only the LAST registered copy,
// which ends up in the LAST-imported tab page (Titles). The page-visibility pass
// hides the Titles page → the bound footer elements would be invisible.
//
// Fix: find State A/B inside the explicit Attributes page, not via _byId. The
// id dictionary stores the last duplicate and can point at a hidden Skills/Titles
// page copy.
UiElement? stateA = contentPage is not null
? FindInSubtree(contentPage, static el => el is UiDatElement d && d.ElementId == FooterStateAId)
: null;
UiElement? stateB = contentPage is not null
? FindInSubtree(contentPage, static el => el is UiDatElement d && d.ElementId == FooterStateBId)
: null;
// Fallback: layout._byId (test layouts with a single page).
stateA ??= layout.FindElement(FooterStateAId);
stateB ??= layout.FindElement(FooterStateBId);
// Position-based lookup within stateA's children.
// If stateA is null or the element isn't found, falls back to layout._byId.
UiText? ByPos(float top, float left, uint fallbackId)
{
if (stateA is not null)
{
foreach (var c in stateA.Children)
if (c is UiText t
&& Math.Abs(c.Top - top) < 1f
&& Math.Abs(c.Left - left) < 1f)
return t;
}
return layout.FindElement(fallbackId) as UiText;
}
// Title (Top=0, Left=0): State A = "Select an Attribute to Improve"; State B = "{name}: {value}"
// Clear BackgroundSprite on the title element: it is H=55 (full footer height) in the dat
// and its background sprite would cover the line-1/line-2 elements at local y=20/37.
// The controller owns the footer visual; the dat sprite is superfluous here.
var titleEl = ByPos(0f, 0f, FooterTitleId);
if (titleEl is not null)
{
// The dat title element is H=55 (the full footer box). The dat says VJustify=Center, so
// without an override the text would center vertically in the 55px box, overlapping
// line-1/line-2 below. We set VerticalJustify=Top explicitly so the text renders at the
// top of the 55px box (y≈Padding), keeping all three footer lines non-overlapping.
// The dat says HJustify=Center (Centered=true from BuildText) — the title is centered.
// BackgroundSprite cleared: its full-height sprite would cover line-1/line-2.
titleEl.BackgroundSprite = 0;
titleEl.VerticalJustify = VJustify.Top; // dat says Center; override to Top (see comment above)
titleEl.OneLine = true;
}
// Title (FooterTitle 0x1000024E, 20px from dat): pass null → keep dat font.
// Fix C: the dat has a 20px font for the footer title. Let it drive.
if (titleEl is not null)
{
// DatFont: null → keep the build-time dat font (20px in studio, global fallback in live game).
// Centered=true comes from the dat (HJustify=Center) via BuildText — not overridden here.
// RightAligned stays false (BuildText default for a Center element).
titleEl.ClickThrough = true;
titleEl.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);
}
/// Depth-first search of and its descendants.
/// Returns the first element for which returns true,
/// or null if none found.
private static UiElement? FindInSubtree(UiElement node, Func predicate)
{
if (predicate(node)) return node;
foreach (var child in node.Children)
{
var found = FindInSubtree(child, predicate);
if (found is not null) return found;
}
return null;
}
private static bool HasDatElementId(UiElement element, uint id)
=> DatElementId(element) == id;
private static uint DatElementId(UiElement element)
{
// DatWidgetFactory assigns the canonical id on every imported widget,
// including behavioral types such as UiScrollbar that do not expose a
// type-specific ElementId property. Prefer it so subtree role lookup is
// independent of the concrete widget class. The fallbacks keep the small
// hand-built controller fixtures working when they construct widgets
// directly instead of through the factory.
if (element.DatElementId != 0u)
return element.DatElementId;
return element switch
{
UiDatElement datElement => datElement.ElementId,
UiButton button => button.ElementId,
UiMeter meter => meter.ElementId,
UiText text => text.ElementId,
_ => 0u,
};
}
private static UiElement? FindElementByDatId(ImportedLayout layout, UiElement? scope, uint id)
{
if (scope is not null)
{
var scoped = FindInSubtree(scope, el => HasDatElementId(el, id));
if (scoped is not null)
return scoped;
}
return layout.FindElement(id);
}
private static UiText? FindTextByDatId(ImportedLayout layout, UiElement? scope, uint id)
=> FindElementByDatId(layout, scope, id) as UiText;
private static UiElement? FindDirectChildById(UiElement? root, uint id)
{
if (root is null) return null;
foreach (var child in root.Children)
{
if (DatElementId(child) == id)
return child;
}
return null;
}
private static void CollectElementsByDatId(UiElement node, uint id, List result)
{
if (DatElementId(node) == id)
result.Add(node);
foreach (var child in node.Children)
CollectElementsByDatId(child, id, result);
}
private static void Label(ImportedLayout layout, uint id, UiDatFont? datFont, Vector4 color, Func text)
=> Label(layout, null, id, datFont, color, text);
private static void Label(ImportedLayout layout, UiElement? scope, uint id, UiDatFont? datFont, Vector4 color, Func text)
{
if (FindTextByDatId(layout, scope, id) is UiText t)
{
// Null = keep whatever the importer (dat FontDid resolver) set at build time.
// Non-null = controller explicit override.
if (datFont is not null) t.DatFont = datFont;
t.Centered = true;
t.OneLine = true;
t.ClickThrough = true;
t.LinesProvider = () => new[] { new UiText.Line(text(), color) };
}
}
/// Two-line centered label. Provides TWO lines from LinesProvider so both
/// fit side-by-side in a narrow element without truncation. The scroll path in
/// renders multiple lines oldest-first (top-to-bottom), so
/// line 0 = (top) and line 1 = (bottom).
/// This replaces the single-line "Character Level" caption which truncated in the 65px element.
/// Source: retail spec (2026-06-26-character-window-retail-reference.md §State 1 level caption).
private static void LabelTwoLine(ImportedLayout layout, uint id, UiDatFont? datFont, Vector4 color,
string line1, string line2)
=> LabelTwoLine(layout, null, id, datFont, color, line1, line2);
private static void LabelTwoLine(ImportedLayout layout, UiElement? scope, uint id, UiDatFont? datFont, Vector4 color,
string line1, string line2)
{
if (FindTextByDatId(layout, scope, id) is UiText t)
{
// Null = keep whatever the importer (dat FontDid resolver) set at build time.
if (datFont is not null) t.DatFont = datFont;
t.Centered = false; // non-Centered → scroll/multi-line path
t.RightAligned = false;
t.ClickThrough = true;
t.Padding = 1f;
t.LinesProvider = () => new[]
{
new UiText.Line(line1, color),
new UiText.Line(line2, color),
};
}
}
/// Left-justified label (for captions that should be left-aligned, not centered).
/// Padding=0 so a single dat-font line (≈12px) fits cleanly in a small element without
/// being clipped by the bottom-pin scroll math (top=Padding, bottom=H-Padding).
private static void LabelLeft(ImportedLayout layout, uint id, UiDatFont? datFont, Vector4 color, Func text)
=> LabelLeft(layout, null, id, datFont, color, text);
private static void LabelLeft(ImportedLayout layout, UiElement? scope, uint id, UiDatFont? datFont, Vector4 color, Func text)
{
if (FindTextByDatId(layout, scope, id) is UiText t)
{
// Null = keep whatever the importer (dat FontDid resolver) set at build time.
if (datFont is not null) t.DatFont = datFont;
t.Centered = false;
t.RightAligned = false;
t.ClickThrough = true;
t.Padding = 0f; // avoid scroll clip in small-height header elements
t.LinesProvider = () => new[] { new UiText.Line(text(), color) };
}
}
/// Right-justified numeric header value. The imported layout owns
/// bounds/font; the controller owns runtime text and explicit justification.
private static void LabelRight(
ImportedLayout layout,
UiElement? scope,
uint id,
UiDatFont? datFont,
Vector4 color,
Func text)
{
if (FindTextByDatId(layout, scope, id) is UiText t)
{
if (datFont is not null) t.DatFont = datFont;
t.Centered = false;
t.RightAligned = true;
t.OneLine = true;
t.ClickThrough = true;
t.Padding = 0f;
t.LinesProvider = () => new[] { new UiText.Line(text(), color) };
}
}
/// Bind a directly-located widget with a provider.
/// Used when the widget was found by subtree walk rather than FindElement.
/// Sets Padding = 0 to prevent the scroll-clip from hiding text in small
/// (H≈17–18px) footer elements: with the default Padding=4 and a dat font line-height
/// of ~12px the bottom-pinned baseY ends up above the top clip boundary → blank.
private static void LabelProvider(UiText? t, UiDatFont? datFont, Vector4 color, Func text)
{
if (t is null) return;
// Null = keep whatever the importer (dat FontDid resolver) set at build time.
if (datFont is not null) t.DatFont = datFont;
t.Centered = false;
t.RightAligned = false;
t.ClickThrough = true;
t.Padding = 0f;
t.LinesProvider = () => new[] { new UiText.Line(text(), color) };
}
///
/// Add the three sprite pieces and the label for ONE tab to
/// as direct root children at absolute positions. Sprites go to root (not to the
/// tab group elements) so the tab groups keep Children.Count==0 and survive the
/// page-visibility pass unchanged.
///
/// Sprite ids from the retail UI layout dump (2026-06-25), nodes
/// 0x10000439/0x100000E9/0x10000215 in layout 0x2100002E:
/// Open (active): 0x06005D92/0x06005D94/0x06005D96;
/// Closed (inactive): 0x06005D93/0x06005D95/0x06005D97.
///
/// Tab geometry confirmed from dump: each tab = 92px wide, 25px tall.
/// Left-cap = 17px, center = 58px, right-cap = 17px (total 92px).
///
private static TabVisual AddTabSpritesToRoot(
UiElement root,
Func 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=1–3).
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 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) };
}
}
/// Build a single sprite-drawing leaf at the given position.
/// ZOrder=8 places it above the original tab-group UiTexts (ZOrder=1–3) so it draws on top.
private static UiText MakeSpriteElement(
Func spriteResolve,
float x, float y, float w, float h, uint spriteId)
{
return new UiText
{
Left = x,
Top = y,
Width = w,
Height = h,
ZOrder = 8,
BackgroundSprite = spriteId,
SpriteResolve = id => { var (tex, tw, th) = spriteResolve(id); return (tex, tw, th); },
LinesProvider = static () => System.Array.Empty(),
ClickThrough = true,
Anchors = AnchorEdges.Left | AnchorEdges.Top,
};
}
///
/// Depth-first tree walk to collect every that was registered
/// in under the given dat element .
///
///
/// The standard returns only the LAST widget
/// registered for a given id; for elements duplicated across tab-page sub-layouts
/// (raise buttons, close buttons) we need ALL copies so that visibility changes are
/// reflected in every page — not just the last-mounted one.
///
///
///
/// Implementation: walk the entire tree calling
/// is not enough (it uses a dict). Instead we
/// exploit the fact that identical element ids produce widgets that all share the SAME
/// instance in _byId for THEIR copy; but siblings from
/// different inheritance mounts are SEPARATE instances not in the same _byId slot.
/// We therefore walk the tree recursively and collect every whose
/// ActiveState reflects the dat default (before our code sets it), which is not a
/// reliable discriminator. Instead, we gather ALL instances from
/// the subtree at the known spatial position (bottom of the panel) — but positions can
/// overlap across pages.
///
///
///
/// The correct approach: since _byId stores only one instance per id, we use the
/// for the canonical id, then do a FULL tree walk
/// to find ADDITIONAL instances that have identical Width×Height to
/// the known button. This works because the three page copies share the same dat template
/// and thus the same geometry. Collected via reference-equality guard to avoid duplicates.
///
///
// Match by ElementId, not geometry: the x1 and x10 raise buttons are both 30x26.
private static void CollectButtonsById(
UiElement node,
uint targetId,
List result,
ImportedLayout layout)
{
// Find the canonical copy (last registered in _byId) as the geometry reference.
_ = layout;
// Walk the tree and collect ALL UiButton instances matching the canonical geometry.
// The canonical copy itself will also be found — that's fine; use a HashSet to dedup.
var seen = new HashSet(ReferenceEqualityComparer.Instance);
CollectMatchingButtons(node, targetId, seen, result);
}
private static void CollectMatchingButtons(
UiElement node,
uint targetId,
HashSet seen,
List result)
{
if (node is UiButton btn && btn.ElementId == targetId && seen.Add(btn))
{
result.Add(btn);
}
foreach (var child in node.Children)
CollectMatchingButtons(child, targetId, seen, result);
}
}