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>
1081 lines
55 KiB
C#
1081 lines
55 KiB
C#
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,
|
||
// 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 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≈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.</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=1–3).
|
||
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=1–3) 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);
|
||
}
|
||
|
||
}
|