acdream/src/AcDream.App/UI/Layout/CharacterStatController.cs
Erik 4b5961ec04 fix(studio): Character window polish — XP-label left-align, smaller icons/tighter rows, selection bar full-width + top/bottom
Item 1 — XP-next label left-alignment: the "XP for next level:" label (child of the
XP meter) was not left-aligned with the "Total Experience (XP):" caption above it.
Fixed by computing the meter's x-offset at bind time and setting xpLabel.Left =
TotalXpLabel.Left - meter.Left, plus Centered=false/RightAligned=false.

Item 2 — icon size / row height: attribute-row icons reduced from 24px → 16px,
row height from 30px → 22px. The 9 rows are now compact and tightly packed matching
the retail reference (2026-06-26). Row font (18px dat) still fits the 22px row.

Item 3 — selection bar: UiClickablePanel gains UseSelectionBars (default false) and
SelectionBarHeight (default 3px). When UseSelectionBars=true and BackgroundSprite is
set, OnDraw draws the sprite as a thin horizontal bar at the TOP edge (y=0) and BOTTOM
edge (y=H-barH) of the row — full panel width, no left/right end-caps, UV-tiled
horizontally (u1=Width/nativeW). Falls back to the base UiPanel fill (BackgroundColor
or full-stretch sprite) when UseSelectionBars=false. AddRow sets UseSelectionBars=true
on all attribute/vital rows so the selection highlight shows as retail-style bars.
Sprite 0x06001397 is 300×32 px; at 3px bar height the UV crop shows the sprite's
top 3px (top bar) and bottom 3px (bottom bar). Temp pre-select for screenshot
verification was added then removed before this commit.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 12:01:42 +02:00

1081 lines
55 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
// ── 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 button piece widths and shared height (from dump node rects).
private const float TabLeftCapW = 17f; // left-cap button width
private const float TabCenterW = 58f; // center label button width
private const float TabRightCapW = 17f; // right-cap button width
private const float TabH = 25f; // button height
// Tab sprite ids per state (Open = active, Closed = inactive).
// Source: retail UI layout dump nodes 0x10000439/0x100000E9/0x10000215 in 0x2100002E.
private const uint TabOpenLeft = 0x06005D92u;
private const uint TabOpenCenter = 0x06005D94u;
private const uint TabOpenRight = 0x06005D96u;
private const uint TabClosedLeft = 0x06005D93u;
private const uint TabClosedCenter = 0x06005D95u;
private const uint TabClosedRight = 0x06005D97u;
// ── Footer element ids (gmStatManagementUI struct fields) ────────────────
// Source: acclient.h / DisplayDefaultFooter (0x0049cde0)
public const uint FooterTitleId = 0x1000024eu; // GetFooterTitleLabel
public const uint FooterLine1Label = 0x10000242u; // GetFooterLineOneLabel
public const uint FooterLine1Value = 0x10000243u; // GetFooterLineOneValue
public const uint FooterLine2Label = 0x10000244u; // GetFooterLineTwoLabel
public const uint FooterLine2Value = 0x10000245u; // GetFooterLineTwoValue
// ── Raise button element ids ──────────────────────────────────────────────
// Source: gmAttributeUI::PostInit (0x0049db70); CM_Train::Event_TrainAttribute.
// Button state "Normal" (UIStateId 0x01) = affordable (green/active);
// "Ghosted" (UIStateId 0x0D) = disabled. Hidden when nothing is selected.
public const uint RaiseOneId = 0x10000246u; // raise × 1
public const uint RaiseTenId = 0x100005EBu; // raise × 10
// ── UIStateId string keys (DatReaderWriter UIStateId enum.ToString()) ────
// State names as returned by UIStateId.ToString() — used as ActiveState keys on UiButton.
private const string StateOpen = "Open"; // UIStateId.Open = 0x0C — active tab
private const string StateClosed = "Closed"; // UIStateId.Closed = 0x0B — inactive tab
private const string StateNormal = "Normal"; // UIStateId.Normal = 0x01 — affordable / default
private const string StateGhosted = "Ghosted"; // UIStateId.Ghosted = 0x0D — disabled button
private static readonly Vector4 Body = new(0.92f, 0.90f, 0.82f, 1f); // parchment-white body text
private static readonly Vector4 Gold = new(1f, 0.82f, 0.36f, 1f); // section / emphasis gold
/// <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);
// ── Row layout constants ─────────────────────────────────────────────────
// RowHeight 22px + IconSize 16px: retail spec (2026-06-26) says icons ~icon-height
// and rows tighter. 16px icon fits inside 22px row with 3px vertical padding each side.
// The larger row font (0x40000001, MaxCharHeight=18) is clipped to the 22px height which
// gives a tight-but-readable line. Retail spec (2026-06-26 ref): "rows tighter, text ≈ icon height".
private const float RowHeight = 22f;
private const float IconSize = 16f;
private const float RowPadX = 4f;
private const float IconGap = 6f;
// ── Attribute row descriptors — retail display order per spec §1 ─────────
private static readonly (string name, uint iconDid)[] AttrRows = new[]
{
("Strength", 0x060002C8u), // enum 1
("Endurance", 0x060002C4u), // enum 2
("Coordination", 0x060002C9u), // enum 4
("Quickness", 0x060002C6u), // enum 3
("Focus", 0x060002C5u), // enum 5
("Self", 0x060002C7u), // enum 6
};
private static readonly (string name, uint iconDid)[] VitalRows = new[]
{
("Health", 0x06004C3Bu), // CurEnum 2
("Stamina", 0x06004C3Cu), // CurEnum 4
("Mana", 0x06004C3Du), // CurEnum 6
};
/// <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)
{
// rowDatFont: larger font for attribute row name/value text (18px vs 16px default).
// Falls back to datFont when null (tests, or dat missing).
rowDatFont ??= datFont;
// Name (18px from dat FontDid), Heritage (14px), PkStatus (14px):
// Fix C: pass null → Label's null-guard keeps the build-time dat font.
// Controllers still own the text color and the LinesProvider.
// Name = WHITE (retail "Horan" is white — confirmed 2026-06-26).
Label(layout, NameId, null, Vector4.One, () => data().Name);
Label(layout, HeritageId, null, Body, () => data().Heritage ?? data().Race ?? string.Empty);
Label(layout, PkStatusId, null, Body, () => data().PkStatus ?? string.Empty);
// ── Header captions (new — retail labels above/left of each number) ──────
// LevelCaption (0x1000023A, 16px from dat): pass null → keep build-time dat font.
LabelTwoLine(layout, LevelCaptionId, null, Body, "Character", "Level");
// Level number: retail renders this as large gold centered text in the 65×50 element.
// Fix C: the dat FontDid for the level element (0x1000023B) is now applied at build
// time when the font resolver is provided (studio path). We no longer force rowDatFont
// here for the level — the dat's own FontDid drives the font. The Gold color is still
// set via LinesProvider. SYNTHESIZED elements (the 9 attribute rows built in
// BuildAttributeRows) continue to use datFont directly since they have no dat origin.
// Source: spec §Level area (65,50) + decomp gmStatManagementUI::UpdateCharacterInfo 0x004f0770.
// runtime color, dat carries none.
Label(layout, LevelId, null, Gold, () => data().Level.ToString());
// TotalXpLabel (16px from dat) + TotalXp (16px from dat): pass null → keep dat font.
LabelLeft(layout, TotalXpLabelId, null, Body, static () => "Total Experience (XP):");
Label(layout, TotalXpId, null, Body, () => data().TotalXp.ToString("N0"));
// XP-to-level meter fill (gmStatManagementUI::UpdateExperience 0x004f0a70).
// Fix 5: child elements 0x10000237 (label) and 0x10000238 (value) are now built by
// the LayoutImporter as UiText children of the XP meter (non-Type-3 meter children
// are explicitly built and registered in byId — see LayoutImporter.BuildWidget).
// FindElement now returns them; the controller binds their LinesProvider.
// The importer builds them as UiText via DatWidgetFactory.BuildText, applying their
// dat-origin HJustify/VJustify/FontDid/FontColor at build time. The controller then
// overrides Padding=0 (to avoid scroll clip in the ~13px-tall bar), ClickThrough=true,
// and provides the LinesProvider for runtime content.
if (layout.FindElement(XpMeterId) is UiMeter meter)
{
meter.Fill = () => data().XpFraction;
// Bind the dat-origin XP label (0x10000237) and value (0x10000238).
// These are now real UiText children of the meter (built by the importer).
// The retail layout places the caption + value ON TOP of the red bar
// (ref 2026-06-26: "value … with the red fill bar behind it").
// Source: retail spec (2026-06-26-character-window-retail-reference.md §State 1).
if (layout.FindElement(XpNextLabelId) is UiText xpLabel)
{
if (datFont is not null) xpLabel.DatFont = datFont;
xpLabel.ClickThrough = true;
xpLabel.Centered = false; // left-align (retail: aligns with Total XP label above)
xpLabel.RightAligned = false;
xpLabel.Padding = 0f; // avoid scroll clip — meter bar is ~13px tall
// Item 1: align the XP-next label's left edge to match the TotalXpLabel's
// absolute left edge. The XP-next label is a child of the meter (local coords),
// so its Left = TotalXpLabel.Left meter.Left. This accounts for the meter's
// horizontal offset within the panel (the meter starts to the right of the
// "Total Experience (XP):" caption row). Source: retail spec §State 1 (the
// "XP for next level:" caption left-aligns with "Total Experience (XP):" above).
if (layout.FindElement(TotalXpLabelId) is { } totalXpLbl)
{
float xpNextLeft = totalXpLbl.Left - meter.Left;
xpLabel.Left = xpNextLeft >= 0f ? xpNextLeft : 0f;
}
xpLabel.LinesProvider = static () => new[] { new UiText.Line("XP for next level:", Body) };
}
if (layout.FindElement(XpNextValueId) is UiText xpValue)
{
if (datFont is not null) xpValue.DatFont = datFont;
xpValue.ClickThrough = true;
xpValue.RightAligned = true;
xpValue.Padding = 0f; // avoid scroll clip
xpValue.LinesProvider = () => new[] { new UiText.Line(data().XpToNextLevel.ToString("N0"), Body) };
}
}
// ── Tab bar sprite children (added to PANEL ROOT, not to the tab groups) ───
// The tab groups (0x10000228/229/538) are UiText (Type 12) with
// ConsumesDatChildren=true, so their 3 button children were consumed at import.
// We inject the visual equivalent — 3 sprite UiTexts per tab — as CHILDREN OF
// layout.Root at the tab's ABSOLUTE screen position, NOT as children of the tab
// groups. This matters because the page-visibility pass (below) iterates
// root.Children and hides any child that doesn't contain NameId; if we added
// sprites to the tab groups, those groups would have non-zero Children.Count
// and be eligible for the pass, then hidden (since NameId is in the content area).
// By adding to root instead, the tab groups keep Children.Count==0 and are skipped
// by "if (page.Children.Count == 0) continue" — they stay visible always.
// Source: retail dump tab rects confirmed (2026-06-25):
// Attributes = (0,0,92,25), Skills = (92,0,92,25), Titles = (184,0,92,25).
if (layout.Root is { } tabRoot && spriteResolve is not null)
{
// Hide the original tab group elements (0x10000228/229/538): they are UiText
// nodes that render their own state sprite at ZOrder=1/2/3. Our replacement
// sprites go to root at ZOrder=8 (above them), so the originals would paint
// underneath. Hiding them avoids the double-draw and any opaque overlap.
if (layout.FindElement(TabAttribId) is { } origAttr) origAttr.Visible = false;
if (layout.FindElement(TabSkillsId) is { } origSkills) origSkills.Visible = false;
if (layout.FindElement(TabTitlesId) is { } origTitles) origTitles.Visible = false;
AddTabSpritesToRoot(tabRoot, spriteResolve, datFont,
tabX: 0f, label: "Attributes", isOpen: true);
AddTabSpritesToRoot(tabRoot, spriteResolve, datFont,
tabX: 92f, label: "Skills", isOpen: false);
AddTabSpritesToRoot(tabRoot, spriteResolve, datFont,
tabX: 184f, label: "Titles", isOpen: false);
}
// ── Attribute list — 9 rows in list box 0x1000023D ────────────────────
// Mutable selected-index box: -1 = nothing selected.
var sel = new int[] { -1 };
// Gather EVERY copy of the raise buttons in the tree. The raise button ids
// (0x10000246, 0x100005EB) appear once per tab page (Attributes/Skills/Titles)
// in the dat inheritance structure; ImportedLayout._byId keeps only the LAST
// mounted copy. We collect all copies so we can hide them all initially and
// show/hide the correct set when a row is selected.
//
// At bind time the tree includes all three tab pages (the page-visibility pass
// runs AFTER this). Collecting from the full tree is safe: once the page-
// visibility pass hides the inactive pages their raise buttons are invisible
// regardless of the Visible flag we set here — but the Attributes page's
// buttons (which are NOT hidden by the page pass) must be explicitly hidden.
var allRaise1 = new List<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);
// Initial state: raise buttons hidden until a row is selected.
foreach (var b in allRaise1) b.Visible = false;
foreach (var b in allRaise10) b.Visible = false;
// ── Footer State B/C visibility ───────────────────────────────────────
// There are THREE footer state groups (A=0x10000240, B=0x10000241, C=0x10000247)
// all stacked at the same position within the Attributes page. _byId stores only
// the LAST copy of each id; the others live in the VISIBLE Attributes page and must
// also be hidden.
//
// WHY this cannot be done in the importer (dat state-model audit 2026-06-26):
// All three group elements have DefaultState = StatManagement_Footer_Default
// (0x10000011) — the dat does NOT differentiate them by visibility. The parent
// element (0x1000022F) has a States map {Default, Text, Meter} with PassToChildren=
// true, but each child group also registers all three states (IncFlags=None,
// Media=0) — meaning the state-propagation produces no media change on any group.
// Retail's gmStatManagementUI uses hardcoded element-id dispatch
// (GetChildRecursive(this, 0x10000240) for Default, 0x10000241 for Text, 0x10000247
// for Meter) to access the right group's children at runtime — the groups themselves
// are never hidden/shown via the dat state mechanism. The controller is the correct
// and only place for this visibility management. See retail decomp
// gmStatManagementUI::GetFooterTitleLabel @0x004f0170.
//
// Strategy: walk the Attributes page (the root child that contains the NameId anchor)
// and hide every UiDatElement whose ElementId matches B or C. The page-visibility
// pass hides everything in the Skills/Titles pages, so we only need to act on the
// Attributes page's copies.
if (layout.FindElement(NameId) is { } bAnchor && layout.Root is { } fbRoot)
{
foreach (var fbPage in fbRoot.Children)
{
if (fbPage.Children.Count == 0) continue;
if (!ContainsWidget(fbPage, bAnchor)) continue;
// This is the Attributes page. Hide all stateB and stateC copies in it.
HideAllById(fbPage, FooterStateBId);
HideAllById(fbPage, FooterStateCId);
break;
}
}
// Also hide the last-registered copies (in the _byId dict, which may be from a
// different page). Belt-and-suspenders.
if (layout.FindElement(FooterStateBId) is { } footerB) footerB.Visible = false;
if (layout.FindElement(FooterStateCId) is { } footerC) footerC.Visible = false;
// ── Footer State A initial binding ────────────────────────────────────
// Walk to the State-A container directly (rather than _byId which returns the
// last duplicate) so we get the wider-label copies (195px) for the unselected state.
BindFooterDynamic(layout, datFont, data, sel);
List<UiClickablePanel>? rowPanels = null;
if (layout.FindElement(ListBoxId) is { } list)
{
rowPanels = BuildAttributeRows(list, rowDatFont, spriteResolve, data, sel,
allRaise1, allRaise10);
}
// ── Active-page selection (fixes the dark-overlay) ─────────────────────
// WHY this cannot be done in the importer (dat state-model audit 2026-06-26):
// The three tab-page content areas (0x1000022B Attributes, 0x1000022C Skills,
// 0x10000539 Titles) all have DefaultState = Undef (0) — the dat carries no
// visibility encoding for tabs. Tab visibility is managed at runtime by gmTabUI
// via SetVisible(bool) on the page containers. The controller is the correct
// and only place for initial tab-page selection.
if (layout.FindElement(NameId) is { } anchor && layout.Root is { } root)
{
foreach (var page in root.Children)
{
if (page.Children.Count == 0) continue;
page.Visible = ContainsWidget(page, anchor);
}
}
}
// ── 9-row attribute list ─────────────────────────────────────────────────
private static List<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)
{
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);
rows.Add(row);
y += RowHeight;
}
for (int i = 0; i < VitalRows.Length; i++)
{
var (rowName, iconDid) = VitalRows[i];
int rowIndex = i;
int absIndex = AttrRows.Length + i;
var row = AddRow(list, datFont, spriteResolve,
left: 0f, top: y, width: listW, height: RowHeight,
iconDid: iconDid,
nameText: rowName,
valueProvider: () =>
{
var s = data();
return rowIndex switch
{
0 => $"{s.HealthCurrent}/{s.HealthMax}",
1 => $"{s.StaminaCurrent}/{s.StaminaMax}",
2 => $"{s.ManaCurrent}/{s.ManaMax}",
_ => string.Empty,
};
});
row.OnClick = () => HandleRowClick(absIndex, sel, rows, spriteResolve, data, allRaise1, allRaise10);
rows.Add(row);
y += RowHeight;
}
return rows;
}
/// <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);
}
/// <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 cost = GetRaiseCost(sheet, selectedIndex);
bool affordable = cost > 0 && sheet.UnassignedXp >= cost;
// State "Normal" = affordable/green; "Ghosted" = too expensive or maxed.
string btnState = affordable ? StateNormal : StateGhosted;
foreach (var b in allRaise1) { b.Visible = true; b.ActiveState = btnState; }
foreach (var b in allRaise10) { b.Visible = true; b.ActiveState = btnState; }
}
/// <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)
{
if (sheet.AttributeRaiseCosts is null || rowIndex < 0
|| rowIndex >= sheet.AttributeRaiseCosts.Length)
return 0L;
return sheet.AttributeRaiseCosts[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)
{
var row = new UiClickablePanel
{
Left = left,
Top = top,
Width = width,
Height = height,
BackgroundColor = Vector4.Zero, // transparent until selected
BorderColor = Vector4.Zero,
UseSelectionBars = true, // Item 3: draw sprite as top+bottom bars, not full stretch
Anchors = AnchorEdges.Left | AnchorEdges.Top,
};
float iconY = (height - IconSize) * 0.5f;
var iconEl = new UiText
{
Left = RowPadX,
Top = iconY,
Width = IconSize,
Height = IconSize,
ClickThrough = true,
DatFont = null,
BackgroundSprite = spriteResolve is not null ? iconDid : 0u,
SpriteResolve = spriteResolve is not null
? id => { var (h, w, ht) = spriteResolve(id); return (h, w, ht); }
: null,
LinesProvider = static () => Array.Empty<UiText.Line>(),
Anchors = AnchorEdges.Left | AnchorEdges.Top,
};
float nameX = RowPadX + IconSize + IconGap;
float nameW = width * 0.60f;
float nameY = 0f;
string capturedName = nameText;
var nameEl = new UiText
{
Left = nameX,
Top = nameY,
Width = nameW,
Height = height,
DatFont = datFont,
ClickThrough = true,
Centered = false,
RightAligned = false,
Padding = 1f,
Anchors = AnchorEdges.Left | AnchorEdges.Top,
};
nameEl.LinesProvider = () => new[] { new UiText.Line(capturedName, Body) };
float valueW = width - nameX - nameW - RowPadX;
float valueX = nameX + nameW;
var valueEl = new UiText
{
Left = valueX,
Top = nameY,
Width = valueW > 0f ? valueW : 40f,
Height = height,
DatFont = datFont,
ClickThrough = true,
RightAligned = true,
Anchors = AnchorEdges.Left | AnchorEdges.Top,
};
var capturedProvider = valueProvider;
valueEl.LinesProvider = () => new[] { new UiText.Line(capturedProvider(), Body) };
row.AddChild(iconEl);
row.AddChild(nameEl);
row.AddChild(valueEl);
list.AddChild(row);
return row;
}
// ── Footer — dynamic (State A + State B via sel[]) ────────────────────────
/// <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,
int[] sel)
{
// Walk the State A container (0x10000240) to find the wide-label copies of the
// footer child elements. The 5 children of 0x10000240 at their LOCAL coords:
// title (0x1000024E): Left=0, Top=0, W=300, H=55
// line-1 label (0x10000242): Left=5, Top=20, W=195 (State A wide) vs 145 (B/C narrow)
// line-1 value (0x10000243): Left=200, Top=20, W=95
// line-2 label (0x10000244): Left=5, Top=37, W=195 (State A wide) vs 145 (B/C narrow)
// line-2 value (0x10000245): Left=200, Top=37, W=95
// The State B/C copies use narrower labels (145px) to accommodate raise buttons.
//
// IMPORTANT: The footer state id (0x10000240) appears once per tab-page sub-layout
// (Attributes / Skills / Titles). layout._byId stores only the LAST registered copy,
// which ends up in the LAST-imported tab page (Titles). The page-visibility pass
// hides the Titles page → the bound footer elements would be invisible.
//
// Fix: find stateA in the SAME subtree as the anchor element (NameId=0x10000231).
// The anchor is in the Attributes content area (the page the pass keeps Visible=True).
// We walk every root-level content area to find the one that contains the anchor,
// then walk that subtree for the footer container.
UiElement? stateA = null;
if (layout.FindElement(NameId) is { } anchor && layout.Root is { } visRoot)
{
// Find the root-level content page that contains the anchor.
foreach (var page in visRoot.Children)
{
if (page.Children.Count == 0) continue;
if (!ContainsWidget(page, anchor)) continue;
// This page will be Visible=True. Walk it for FooterStateAId.
stateA = FindInSubtree(page, static el =>
el is UiDatElement d && d.ElementId == FooterStateAId);
break;
}
}
// Fallback: layout._byId (test layouts with a single page).
stateA ??= layout.FindElement(FooterStateAId);
// Position-based lookup within stateA's children.
// If stateA is null or the element isn't found, falls back to layout._byId.
UiText? ByPos(float top, float left, uint fallbackId)
{
if (stateA is not null)
{
foreach (var c in stateA.Children)
if (c is UiText t
&& Math.Abs(c.Top - top) < 1f
&& Math.Abs(c.Left - left) < 1f)
return t;
}
return layout.FindElement(fallbackId) as UiText;
}
// Title (Top=0, Left=0): State A = "Select an Attribute to Improve"; State B = "{name}: {value}"
// Clear BackgroundSprite on the title element: it is H=55 (full footer height) in the dat
// and its background sprite would cover the line-1/line-2 elements at local y=20/37.
// The controller owns the footer visual; the dat sprite is superfluous here.
var titleEl = ByPos(0f, 0f, FooterTitleId);
if (titleEl is not null)
{
// The dat title element is H=55 (the full footer box). The dat says VJustify=Center, so
// without an override the text would center vertically in the 55px box, overlapping
// line-1/line-2 below. We set VerticalJustify=Top explicitly so the text renders at the
// top of the 55px box (y≈Padding), keeping all three footer lines non-overlapping.
// The dat says HJustify=Center (Centered=true from BuildText) — the title is centered.
// BackgroundSprite cleared: its full-height sprite would cover line-1/line-2.
titleEl.BackgroundSprite = 0;
titleEl.VerticalJustify = VJustify.Top; // dat says Center; override to Top (see comment above)
}
// Title (FooterTitle 0x1000024E, 20px from dat): pass null → keep dat font.
// Fix C: the dat has a 20px font for the footer title. Let it drive.
if (titleEl is not null)
{
// DatFont: null → keep the build-time dat font (20px in studio, global fallback in live game).
// Centered=true comes from the dat (HJustify=Center) via BuildText — not overridden here.
// RightAligned stays false (BuildText default for a Center element).
titleEl.ClickThrough = true;
titleEl.LinesProvider = () =>
{
if (sel[0] < 0)
return new[] { new UiText.Line("Select an Attribute to Improve", Body) };
var s = data();
string name = GetRowName(sel[0]);
string value = GetRowValueString(s, sel[0]);
// State B title is WHITE (retail confirmed).
return new[] { new UiText.Line($"{name}: {value}", Vector4.One) };
};
}
// Footer lines (all dat-origin with their own font sizes): pass null → keep dat font.
var l1L = ByPos(20f, 5f, FooterLine1Label);
LabelProvider(l1L, null, Body, () =>
sel[0] < 0 ? "Skill Credits Available:" : "Experience To Raise:");
var l1V = ByPos(20f, 200f, FooterLine1Value);
LabelProvider(l1V, null, Body, () =>
{
if (sel[0] < 0) return data().SkillCredits.ToString();
long cost = GetRaiseCost(data(), sel[0]);
return cost > 0 ? cost.ToString("N0") : "Infinity!";
});
// Line-2 elements: pass null → keep dat font.
var l2L = ByPos(37f, 5f, FooterLine2Label);
LabelProvider(l2L, null, Body,
static () => "Unassigned Experience:");
var l2V = ByPos(37f, 200f, FooterLine2Value);
LabelProvider(l2V, null, Body,
() => data().UnassignedXp.ToString("N0"));
}
// ── Helpers ──────────────────────────────────────────────────────────────
/// <summary>Depth-first walk of <paramref name="node"/> and its descendants.
/// Sets <see cref="UiElement.Visible"/> = false on every <see cref="UiDatElement"/>
/// whose <see cref="UiDatElement.ElementId"/> equals <paramref name="targetId"/>.</summary>
private static void HideAllById(UiElement node, uint targetId)
{
if (node is UiDatElement d && d.ElementId == targetId)
node.Visible = false;
foreach (var child in node.Children)
HideAllById(child, targetId);
}
/// <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 ContainsWidget(UiElement node, UiElement target)
{
if (ReferenceEquals(node, target)) return true;
foreach (var c in node.Children)
if (ContainsWidget(c, target)) return true;
return false;
}
private static void Label(ImportedLayout layout, uint id, UiDatFont? datFont, Vector4 color, Func<string> text)
{
if (layout.FindElement(id) is UiText t)
{
// Null = keep whatever the importer (dat FontDid resolver) set at build time.
// Non-null = controller explicit override.
if (datFont is not null) t.DatFont = datFont;
t.Centered = true;
t.ClickThrough = true;
t.LinesProvider = () => new[] { new UiText.Line(text(), color) };
}
}
/// <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)
{
if (layout.FindElement(id) is UiText t)
{
// Null = keep whatever the importer (dat FontDid resolver) set at build time.
if (datFont is not null) t.DatFont = datFont;
t.Centered = false; // non-Centered → scroll/multi-line path
t.RightAligned = false;
t.ClickThrough = true;
t.Padding = 1f;
t.LinesProvider = () => new[]
{
new UiText.Line(line1, color),
new UiText.Line(line2, color),
};
}
}
/// <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)
{
if (layout.FindElement(id) is UiText t)
{
// Null = keep whatever the importer (dat FontDid resolver) set at build time.
if (datFont is not null) t.DatFont = datFont;
t.Centered = false;
t.RightAligned = false;
t.ClickThrough = true;
t.Padding = 0f; // avoid scroll clip in small-height header elements
t.LinesProvider = () => new[] { new UiText.Line(text(), color) };
}
}
/// <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 void AddTabSpritesToRoot(
UiElement root,
Func<uint, (uint handle, int w, int h)> spriteResolve,
UiDatFont? datFont,
float tabX,
string label,
bool isOpen)
{
uint leftId = isOpen ? TabOpenLeft : TabClosedLeft;
uint centerId = isOpen ? TabOpenCenter : TabClosedCenter;
uint rightId = isOpen ? TabOpenRight : TabClosedRight;
// Left cap: 17×25 at (tabX, 0)
root.AddChild(MakeSpriteElement(spriteResolve, x: tabX, y: 0f, w: TabLeftCapW, h: TabH, spriteId: leftId));
// Center: 58×25 at (tabX+17, 0) — carries the label text on top
var center = MakeSpriteElement(spriteResolve, x: tabX + TabLeftCapW, y: 0f, w: TabCenterW, h: TabH, spriteId: centerId);
root.AddChild(center);
// Right cap: 17×25 at (tabX+75, 0)
root.AddChild(MakeSpriteElement(spriteResolve, x: tabX + TabLeftCapW + TabCenterW, y: 0f, w: TabRightCapW, h: TabH, spriteId: rightId));
// Label text centered on the tab center piece.
// Active (Open) tab: gold; inactive (Closed) tabs: parchment body color.
// ZOrder=9 → draws on top of the sprite background (ZOrder=8) and original tab groups (ZOrder=13).
Vector4 labelColor = isOpen ? Gold : Body;
string capturedLabel = label;
var labelEl = new UiText
{
Left = tabX + TabLeftCapW,
Top = 0f,
Width = TabCenterW,
Height = TabH,
ZOrder = 9,
DatFont = datFont,
ClickThrough = true,
Centered = true,
Anchors = AnchorEdges.Left | AnchorEdges.Top,
};
labelEl.LinesProvider = () => new[] { new UiText.Line(capturedLabel, labelColor) };
root.AddChild(labelEl);
}
/// <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>
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.
if (layout.FindElement(targetId) is not UiButton canonical) return;
// Walk the tree and collect ALL UiButton instances matching the canonical geometry.
// The canonical copy itself will also be found — that's fine; use a HashSet to dedup.
var seen = new HashSet<UiButton>(ReferenceEqualityComparer.Instance);
CollectMatchingButtons(node, canonical, seen, result);
}
private static void CollectMatchingButtons(
UiElement node,
UiButton canonical,
HashSet<UiButton> seen,
List<UiButton> result)
{
if (node is UiButton btn
&& Math.Abs(btn.Width - canonical.Width) < 0.5f
&& Math.Abs(btn.Height - canonical.Height) < 0.5f
&& seen.Add(btn))
{
result.Add(btn);
}
foreach (var child in node.Children)
CollectMatchingButtons(child, canonical, seen, result);
}
}