2506 lines
120 KiB
C#
2506 lines
120 KiB
C#
using System;
|
||
using System.Collections.Generic;
|
||
using System.Globalization;
|
||
using System.Numerics;
|
||
using AcDream.App.UI;
|
||
|
||
namespace AcDream.App.UI.Layout;
|
||
|
||
/// <summary>
|
||
/// Controller for the Character window's <b>Attributes tab</b> — LayoutDesc 0x2100002E,
|
||
/// whose tab-content slot 0x1000022B mounts sub-layout 0x2100002C (gmAttributeUI, root
|
||
/// type 0x1000002A) which in turn chains into the gmStatManagementUI header content.
|
||
///
|
||
/// <para>Unlike <see cref="CharacterController"/> (which targets the SEPARATE text-report
|
||
/// sub-panel 0x2100001A, gmCharacterInfoUI, by creating its runtime m_pMainText element),
|
||
/// this controller binds the <b>real, statically-mounted</b> header + list elements that the
|
||
/// importer already produces — every id below is confirmed present via
|
||
/// <see cref="ImportedLayout.FindElement"/>.</para>
|
||
///
|
||
/// <para>Ported from <c>gmStatManagementUI::UpdateCharacterInfo</c> (0x004f0770) +
|
||
/// <c>UpdateExperience</c> (0x004f0a70) + <c>UpdatePKStatus</c> (0x004f00a0): name, heritage,
|
||
/// PK status, level, total XP and the XP-to-level meter. The attribute list is the
|
||
/// <c>gmAttributeUI</c> list box (0x1000023D), built as 9 manual-layout rows per
|
||
/// <c>gmAttributeUI::PostInit</c> (0x0049db70) + <c>AttributeInfoRegion</c> / <c>Attribute2ndInfoRegion</c>
|
||
/// (0x004f1910 / 0x004f19e0). Row icons loaded via sub-element 0x10000129 in the retail
|
||
/// dat template (each icon is a <c>0x06xxxxxx</c> RenderSurface DataID from SubMap
|
||
/// 0x25000006 / 0x25000007, spec §2).</para>
|
||
///
|
||
/// <para>Footer State A (nothing selected) bound from
|
||
/// <c>DisplayDefaultFooter</c> (0x0049cde0): title empty, line-1 value =
|
||
/// "Select an Attribute to Improve", line-2 value = available skill credits (InqInt(0x18)).</para>
|
||
///
|
||
/// <para>Footer State B (row selected) bound from
|
||
/// <c>DisplaySelectedAttribute</c> (implicitly): title = "{AttrName}: {value}",
|
||
/// line-1 label = "Experience To Raise:", line-1 value = raise cost,
|
||
/// line-2 label = "Unassigned Experience:", line-2 value = UnassignedXp.</para>
|
||
///
|
||
/// <para>Tab states: whichever of Attributes/Skills/Titles is active shows "Open"
|
||
/// (0x0C); the other two show "Closed" (0x0B) — Attributes is the retail-authored
|
||
/// default. Source: <c>UIElement::SetState @ 0x00464E70</c>. The imported Type-12
|
||
/// tab owns its authored font color and propagates the state to its three chrome
|
||
/// children through PassToChildren. Campaign CT slice CT3 (2026-08-24) wires the
|
||
/// Titles tab click to a real page switch (previously a known-inert AP-109 gap);
|
||
/// <see cref="CharacterTitlesController"/> owns the Titles page's OWN content
|
||
/// (row list, display-title text, Set-as-Display button) once its page container
|
||
/// is shown.</para>
|
||
///
|
||
/// <para>Raise buttons: 0x10000246 (×1) + 0x100005EB (×10). State "Normal" = affordable
|
||
/// (UIStateId.Normal, 0x01), state "Ghosted" = unaffordable or no selection
|
||
/// (UIStateId.Ghosted, 0x0D). Source: gmAttributeUI::AttributeInfoRegion::Update (0x004f1910).</para>
|
||
/// </summary>
|
||
public static class CharacterStatController
|
||
{
|
||
// ── gmStatManagementUI header element ids (sub-layout 0x2100002C content) ──
|
||
public const uint NameId = 0x10000231u; // m_pNameText
|
||
public const uint HeritageId = 0x10000232u; // m_pHeritageText
|
||
public const uint PkStatusId = 0x10000233u; // m_pPKStatusText
|
||
public const uint LevelCaptionId = 0x1000023Au; // "Character Level" caption ABOVE level value
|
||
public const uint LevelId = 0x1000023Bu; // m_pLevelText (right-side level area)
|
||
public const uint TotalXpLabelId = 0x10000234u; // "Total Experience (XP):" caption left of value
|
||
public const uint TotalXpId = 0x10000235u; // m_pTotalXPText
|
||
public const uint XpMeterId = 0x10000236u; // m_pXPToLevelMeter (UiMeter)
|
||
// Fix 5: 0x10000237 (XP-to-level label) and 0x10000238 (XP-to-level value) are now
|
||
// built by the LayoutImporter as UiText children of the XP meter (non-Type-3 children
|
||
// are explicitly built and registered in byId). FindElement DOES return them now.
|
||
// The controller binds their LinesProvider instead of injecting new runtime nodes.
|
||
public const uint XpNextLabelId = 0x10000237u; // "XP for next level:" label child of XP meter
|
||
public const uint XpNextValueId = 0x10000238u; // XP-to-next-level value child of XP meter
|
||
public const uint ListBoxId = 0x1000023Du; // m_pListBox container
|
||
public const uint ListScrollbarId = 0x1000023Eu; // m_pListBox vertical scrollbar gutter
|
||
public const uint ListDividerId = 0x1000023Fu; // bottom divider above footer
|
||
|
||
/// <summary>Campaign CT slice CT4: the luminance pair
|
||
/// (m_pLuminanceLabelText/m_pLuminanceText), shown only past level 200
|
||
/// with nonzero MaximumLuminance — see
|
||
/// <c>gmStatManagementUI::UpdateExperience</c> (0x004f0a70)'s luminance
|
||
/// branch. CT4 fix round (2026-08-25, BLOCKER 1): the caption/value
|
||
/// SetText calls PE-byte-decoded from the <c>gmStatManagementUI</c>
|
||
/// vftable-adjacent data region — caption UTF-16 <c>"Luminance:"</c> at
|
||
/// <c>@0x007c3dd4</c>, value narrow <c>"%s / %s"</c> at
|
||
/// <c>@0x007c3dcc</c> — are now bound; see the Bind method's own
|
||
/// remarks.</summary>
|
||
public const uint LuminanceLabelId = 0x100005C5u;
|
||
public const uint LuminanceValueId = 0x100005C6u;
|
||
|
||
/// <summary>Retail literal <c>"Luminance:"</c> — see
|
||
/// <see cref="LuminanceLabelId"/>'s remarks for the PE-byte-decode
|
||
/// citation.</summary>
|
||
private const string LuminanceCaption = "Luminance:";
|
||
|
||
// ── Footer STATE-A container id ──────────────────────────────────────────
|
||
// 0x10000240 is the "nothing selected" footer group. Its children (0x1000024E label row,
|
||
// 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) ────────────────────
|
||
// These are imported Type-12 UIElement_Text tabs. Their Closed/Open states carry
|
||
// the caption color and PassToChildren=true; their three retained children carry
|
||
// the authored left/center/right chrome. Character and Spellbook therefore share
|
||
// RetailTabBinding and the generic IUiDatStateful path rather than synthesizing art.
|
||
public const uint TabAttribId = 0x10000228u; // Attributes tab group
|
||
public const uint TabSkillsId = 0x10000229u; // Skills tab group
|
||
public const uint TabTitlesId = 0x10000538u; // Titles tab group
|
||
|
||
// Tab page content containers. The imported layout contains duplicated child ids
|
||
// inside these pages, so page selection must use the page ids themselves rather
|
||
// than layout.FindElement(childId)'s last-registered duplicate.
|
||
public const uint AttributesPageId = 0x1000022Bu;
|
||
public const uint SkillsPageId = 0x1000022Cu;
|
||
public const uint TitlesPageId = 0x10000539u;
|
||
|
||
// ── Footer element ids (gmStatManagementUI struct fields) ────────────────
|
||
// Source: acclient.h / DisplayDefaultFooter (0x0049cde0)
|
||
public const uint FooterTitleId = 0x1000024eu; // GetFooterTitleLabel
|
||
public const uint FooterLine1Label = 0x10000242u; // GetFooterLineOneLabel
|
||
public const uint FooterLine1Value = 0x10000243u; // GetFooterLineOneValue
|
||
public const uint FooterLine2Label = 0x10000244u; // GetFooterLineTwoLabel
|
||
public const uint FooterLine2Value = 0x10000245u; // GetFooterLineTwoValue
|
||
|
||
// ── Raise button element ids ──────────────────────────────────────────────
|
||
// Source: gmAttributeUI::PostInit (0x0049db70); CM_Train::Event_TrainAttribute.
|
||
// Button state "Normal" (UIStateId 0x01) = affordable (green/active);
|
||
// "Ghosted" (UIStateId 0x0D) = disabled. Hidden when nothing is selected.
|
||
public const uint RaiseOneId = 0x10000246u; // raise × 1
|
||
public const uint RaiseTenId = 0x100005EBu; // raise × 10
|
||
|
||
private static readonly Vector4 Body = new(0.92f, 0.90f, 0.82f, 1f); // parchment-white body text
|
||
// Campaign CT slice CT4 (2026-08-24): the former hand-picked "Gold"
|
||
// header-level color constant is deleted — CT1's live-DAT pin confirmed
|
||
// the level element authors its own pale-gold FontColor (+ Outline);
|
||
// LabelAuthoredColor reads it from the widget instead.
|
||
|
||
/// <summary>Row highlight FALLBACK color — used only when
|
||
/// <c>spriteResolve</c> is unavailable (tests, headless mode) and the
|
||
/// real <see cref="RowHighlightSprite"/> art cannot be drawn. CT5 fix
|
||
/// round: the former comment here claimed this tint "matches retail
|
||
/// sprite 0x06001397 visual intent" — that was never accurate (0x06001397
|
||
/// is the spellbook's unrelated overlay sprite, and this solid tint was
|
||
/// never tuned to any specific sprite's actual pixels either way); it is
|
||
/// a synthetic no-art placeholder, not a retail-faithful color.</summary>
|
||
private static readonly Vector4 HighlightBg = new(1f, 0.75f, 0.2f, 0.25f);
|
||
// LayoutDesc 0x2100002E, FooterTitle 0x1000024E property 0x1B:
|
||
// [0]=white, [1]=green, [2]=red, [3]=light blue (#7FFFFF).
|
||
private static readonly Vector4 RetailBuffGreen = new(0f, 1f, 0f, 1f);
|
||
private static readonly Vector4 RetailDebuffRed = new(1f, 0f, 0f, 1f);
|
||
private static readonly Vector4 RetailVitaeBlue = new(127f / 255f, 1f, 1f, 1f);
|
||
|
||
// ── Row layout constants ─────────────────────────────────────────────────
|
||
// Campaign CT slice CT5 (2026-08-25): the shared attribute/skill data-row
|
||
// template 0x10000248 (LayoutDesc 0x21000045 — InfoRegion::InfoRegion
|
||
// @0x004F1450 template index 0, used for BOTH gmAttributeUI and
|
||
// gmSkillUI rows) authors W=282 H=20 with icon 0x10000129 FLUSH LEFT
|
||
// (X=0 Y=0 W=20 H=20, full row height), name 0x1000012A at FIXED X=25
|
||
// W=150, and value 0x1000012B at FIXED X=175 W=100 right-justified —
|
||
// its right edge (275) sits 7px short of the row's own 282px right
|
||
// edge, the authored gutter the owner reported (item 2). These are
|
||
// AUTHORED PIXEL VALUES, not width-relative fractions or derived
|
||
// offsets — the former RowHeight=22/IconSize=16px-at-X=4/
|
||
// nameW=width*0.60 geometry had no dat basis at all. Ground truth +
|
||
// the explicit warning against composing a derived "gutter" formula:
|
||
// docs/research/2026-08-24-campaign-ct-dat-ground-truth.md §2, pinned
|
||
// by CharacterPanelLiveDatTests.AttributeRowTemplate_IconIsFlushLeftTwentyPixels_NameAndValueAreFixedColumns.
|
||
private const float RowHeight = 20f;
|
||
private const float RowIconX = 0f;
|
||
private const float RowIconSize = 20f;
|
||
private const float RowNameX = 25f;
|
||
private const float RowNameW = 150f;
|
||
private const float RowValueX = 175f;
|
||
private const float RowValueW = 100f;
|
||
|
||
// Section-header caption inset ONLY (AddSkillHeader's 0x10000249..0x1000024C
|
||
// captions carry their own authored geometry, distinct from the data-row
|
||
// template above) — retained at its pre-CT5 value. CT5 fix round
|
||
// (NOTE c): CT1 verified only the FOUR SPRITES for these header templates
|
||
// (SkillSectionHeaderTemplates_MatchExistingSpriteConstants: Width=280,
|
||
// Height=20, sprite match) — the caption LABEL's own authored geometry
|
||
// (W=280 with L5/R5 margins per the header template, versus our current
|
||
// header width, which comes from RowContentWidth/SkillViewportWidth same
|
||
// as the data rows, plus this flat 4px inset) was never pinned or
|
||
// cross-checked against that. This is a KNOWN, RECORDED residual gap —
|
||
// NOT a cleared divergence — left for a future slice to pin the caption
|
||
// child's authored margins the same way CT1 pinned the data-row
|
||
// template's icon/name/value columns; behavior is unchanged here.
|
||
private const float RowPadX = 4f;
|
||
|
||
private const float SkillHeaderHeight = 20f;
|
||
// Authored row-template CEILING width (0x10000248 W=282) — CT5: the
|
||
// row's own width comes from THIS authored constant as an upper bound,
|
||
// never the ListBox's raw 300px Width, and never a derived
|
||
// "listWidth minus gutter" formula (the ground-truth doc's explicit
|
||
// warning). CT5 fix round (NOTE b, tempered wording): this ceiling is
|
||
// NOT the row's width in every context. Attribute/vital rows land on it
|
||
// exactly (300px ListBox clamped to 282). The SKILL page instead first
|
||
// narrows the viewport to SkillViewportWidth's scrollbar-gutter
|
||
// measurement (scrollbar.Left − list.Left = 281px in the real
|
||
// production layout — CharacterPanelLiveDatTests.
|
||
// StatListBox_AuthorsFiveRowTemplatesInSharedLayout pins the scrollbar
|
||
// at X=281), and RowContentWidth then clamps skill rows to THAT
|
||
// (281 < 282) rather than the raw 282 ceiling — see
|
||
// Bind_SkillRow_ClampsToAuthoredScrollbarGutterWidth.
|
||
private const float SkillContentWidth = 282f;
|
||
|
||
private const uint SkillHeaderSpecializedSprite = 0x06000F90u;
|
||
private const uint SkillHeaderTrainedSprite = 0x06000F86u;
|
||
private const uint SkillHeaderUntrainedSprite = 0x06000F98u;
|
||
private const uint SkillHeaderUnusableSprite = 0x06000F89u;
|
||
|
||
// CT5 SEALED VERDICT (ground-truth doc §2 "SEALED VERDICT: RowHighlightSprite
|
||
// is wrong, not merely flagged"): gmAttributeUI::UpdateSelection
|
||
// @0x0049DEE0 calls SetState(selected ? 6 : 1) on the row; InfoRegion::
|
||
// SetState @0x004F0EE0 forwards that to the row instantiated from THIS
|
||
// exact template (0x10000248), whose Highlight-state media is
|
||
// 0x06000F93 — a full-row background SWAP, not an overlay. The former
|
||
// 0x06001397 constant belongs to a DIFFERENT mechanism entirely: the
|
||
// spellbook row's separate selected-overlay CHILD element
|
||
// (UIElement_UIItem::SetSelectedState @0x004E1240, SpellbookRowStyle.cs)
|
||
// — that file and its tests are correct and must NOT be touched.
|
||
private const uint RowHighlightSprite = 0x06000F93u;
|
||
|
||
// CT5 fix round (SHOULD-FIX 1, visible retail gap): the row template's
|
||
// NORMAL-state media — StateMedia[Normal] on 0x10000248, pinned byte-
|
||
// exact by CharacterPanelLiveDatTests.
|
||
// AttributeRowTemplate_IconIsFlushLeftTwentyPixels_NameAndValueAreFixedColumns
|
||
// (row.StateMedia["Normal"].File == 0x06004CC2). CT5 itself only wired
|
||
// the Highlight-state swap above; UNSELECTED rows drew fully
|
||
// transparent instead of this authored background. Independently
|
||
// decoded against the installed DAT (2026-08-25): PFID_A8R8G8B8, 48x48,
|
||
// a single uniform color (0,0,0,175) — i.e. a ~69%-opaque (175/255)
|
||
// flat black tile that retail's native-size copy-or-tile blit repeats
|
||
// across the row (see UiPanel.BackgroundSprite's doc comment for the
|
||
// tiling mechanism) to produce the dark band under every unselected
|
||
// attribute/skill row. Selected rows keep RowHighlightSprite above.
|
||
private const uint RowNormalSprite = 0x06004CC2u;
|
||
|
||
// CT5 (AP-235 unification, gmAttributeUI::PostInit @0x0049DB70 verbatim):
|
||
// per-attribute icon DIDs resolve via DBObj::GetDIDByEnum(statEnum,
|
||
// category 0x10000002); per-vital (Attribute2ndInfoRegion) icon DIDs via
|
||
// DBObj::GetDIDByEnum(vitalEnum, category 0x10000003). Both categories
|
||
// are consumed through the shared RetailDataIdResolver.Resolve seam
|
||
// (the same master-map -> category-map -> value indirection already
|
||
// ported for the title chain and RetailKeyNames) rather than a fourth
|
||
// ad-hoc hardcoded DID table. Live-DAT-verified (2026-08-25): every
|
||
// hardcoded fallback value in AttrRows/VitalRows below already matches
|
||
// the resolved DID byte-exact — see
|
||
// CharacterPanelLiveDatTests.AttributeAndVitalIconDids_MatchTheRetailEnumMapperChain.
|
||
private const uint AttributeIconCategory = 0x10000002u;
|
||
private const uint VitalIconCategory = 0x10000003u;
|
||
|
||
// Scrollbar chrome from base layout 0x2100003E, shared with chat/
|
||
// inventory — sprite set + retail button seating live in
|
||
// RetailScrollbarChrome (2026-08-24: the previous local constants seated
|
||
// the DOWN-arrow art on the top button).
|
||
|
||
private enum CharacterStatTab
|
||
{
|
||
Attributes,
|
||
Skills,
|
||
Titles,
|
||
}
|
||
|
||
public enum RaiseTargetKind
|
||
{
|
||
Attribute,
|
||
Vital,
|
||
Skill,
|
||
TrainSkill,
|
||
}
|
||
|
||
public readonly record struct RaiseRequest(
|
||
RaiseTargetKind Kind,
|
||
uint StatId,
|
||
long Cost,
|
||
int Amount);
|
||
|
||
/// <summary>
|
||
/// Handles a retail stat raise and invokes <paramref name="completed"/> when
|
||
/// the request has either been applied or cancelled. The completion seam is
|
||
/// required by asynchronous retail confirmation dialogs such as
|
||
/// <c>gmSkillUI::TrainSkill @ 0x0049C5F0</c>.
|
||
/// </summary>
|
||
public delegate void RaiseRequestHandler(RaiseRequest request, Action completed);
|
||
|
||
private sealed record SkillRowBinding(UiClickablePanel Panel, CharacterSkill Skill);
|
||
|
||
// ── Attribute row descriptors — retail display order per spec §1 ─────────
|
||
// CT5 fix round (NOTE d): internal (not private) so
|
||
// CharacterPanelLiveDatTests.AttributeAndVitalIconDids_MatchTheRetailEnumMapperChain
|
||
// can iterate THESE tuples directly instead of a re-typed duplicate
|
||
// literal array — a divergence between the two would previously have
|
||
// gone undetected. InternalsVisibleTo("AcDream.App.Tests") already
|
||
// covers this assembly (AcDream.App.csproj).
|
||
internal static readonly (string name, uint iconDid, uint statId)[] AttrRows = new[]
|
||
{
|
||
("Strength", 0x060002C8u, 1u),
|
||
("Endurance", 0x060002C4u, 2u),
|
||
("Coordination", 0x060002C9u, 4u),
|
||
("Quickness", 0x060002C6u, 3u),
|
||
("Focus", 0x060002C5u, 5u),
|
||
("Self", 0x060002C7u, 6u),
|
||
};
|
||
|
||
// CT5 fix round (NOTE d): internal for the same reason as AttrRows above.
|
||
internal static readonly (string name, uint iconDid, uint maxStatId)[] VitalRows = new[]
|
||
{
|
||
("Health", 0x06004C3Bu, 1u), // max enum 1; current enum 2
|
||
("Stamina", 0x06004C3Cu, 3u), // max enum 3; current enum 4
|
||
("Mana", 0x06004C3Du, 5u), // max enum 5; current enum 6
|
||
};
|
||
|
||
/// <summary>
|
||
/// TS-85 (character-panel tooltips): retail <c>SkillSystem::InqAttributeDescription
|
||
/// @ 0x005c8e30</c> — six hardcoded strings, byte-decoded from the retail binary's
|
||
/// string pool (the pseudo-C dump truncates them with "…"). Ported from
|
||
/// <c>AttributeInfoRegion::AttributeInfoRegion @ 0x004f1530</c>'s
|
||
/// <c>UIElement::SetTooltip</c> call at 0x004f1617, keyed by retail attribute id
|
||
/// (matches <see cref="AttrRows"/>' statId column, NOT the array index — the
|
||
/// authored row order swaps Coordination/Quickness relative to the id numbering).
|
||
/// </summary>
|
||
private static readonly IReadOnlyDictionary<uint, string> AttributeDescriptions =
|
||
new Dictionary<uint, string>
|
||
{
|
||
[1u] = "Measures your character's muscular power.", // Strength
|
||
[2u] = "Measures how healthy your character is.", // Endurance
|
||
[3u] = "Measures how fast your character is.", // Quickness
|
||
[4u] = "Measures your character's reflexes", // Coordination (no trailing period — verified byte-exact)
|
||
[5u] = "Measures your character's mind and senses.", // Focus
|
||
[6u] = "Measures your character's willpower.", // Self
|
||
};
|
||
|
||
/// <summary>
|
||
/// TS-85 (character-panel tooltips): retail <c>SkillSystem::InqAttribute2ndDescription
|
||
/// @ 0x005c8f70</c> — three hardcoded strings shared by each Max/Current pair (1&2,
|
||
/// 3&4, 5&6), byte-decoded from the retail string pool. Ported from
|
||
/// <c>Attribute2ndInfoRegion::Attribute2ndInfoRegion @ 0x004f1680</c>'s
|
||
/// <c>UIElement::SetTooltip</c> call at 0x004f1777, keyed by <see cref="VitalRows"/>'
|
||
/// maxStatId column (1/3/5 — either member of the pair resolves the same text in
|
||
/// retail).
|
||
/// </summary>
|
||
private static readonly IReadOnlyDictionary<uint, string> Attribute2ndDescriptions =
|
||
new Dictionary<uint, string>
|
||
{
|
||
[1u] = "(Endurance/2)\nIf you run out of health, you will die!", // Health
|
||
[3u] = "(Endurance)\nAffects your actions and movement.", // Stamina
|
||
[5u] = "(Self)\nAffects how much magic you can cast.", // Mana
|
||
};
|
||
|
||
/// <summary>
|
||
/// Bind the Attributes-tab header + 9-row list + footer elements, tab button states,
|
||
/// and raise buttons in <paramref name="layout"/> to <paramref name="data"/>.
|
||
///
|
||
/// <para>
|
||
/// <b>Interactive mode (Pass 2):</b> each attribute/vital row is a
|
||
/// <see cref="UiClickablePanel"/> that fires selection logic on left-click. The
|
||
/// selected row index is held in a mutable <c>int[]</c> box (single element) so that
|
||
/// all closures share the same mutable slot without escaping the static method boundary.
|
||
/// </para>
|
||
///
|
||
/// <para>
|
||
/// <paramref name="spriteResolve"/> resolves a <c>0x06xxxxxx</c> RenderSurface dat id to
|
||
/// a (GL tex handle, pixel width, pixel height) triple. Pass <c>null</c> in tests where
|
||
/// icon rendering is not asserted.
|
||
/// </para>
|
||
///
|
||
/// <para>
|
||
/// CT5: <paramref name="iconDidResolve"/> ports <c>DBObj::GetDIDByEnum(enumValue,
|
||
/// category)</c> (master map -> category map -> value) for the per-attribute/
|
||
/// per-vital row icon DIDs — pass <c>(enumValue, category) =>
|
||
/// RetailDataIdResolver.Resolve(dats, enumValue, category)</c> under the
|
||
/// caller's dat lock. <c>null</c> (tests, or no live dat) falls back to
|
||
/// <see cref="AttrRows"/>/<see cref="VitalRows"/>' hardcoded DID column,
|
||
/// which the CT5 InstalledDat pin proves already matches the live-resolved
|
||
/// value byte-exact.
|
||
/// </para>
|
||
/// </summary>
|
||
/// <returns>
|
||
/// #431-CA5 gate fix (2026-08-24): the data-changed refresh. Retail's
|
||
/// panel refreshes rows from qualities on every authoritative
|
||
/// quality-change element message (InfoRegion::OnQualityChanged @
|
||
/// 0x004F0EB0 → the 0x10000004 broadcast); before this, rows only
|
||
/// rebuilt on CLICKS, so a trained skill stayed in the untrained
|
||
/// section (and CA4's awaiting-ghost never visually released) until the
|
||
/// next click. The caller invokes this from the sheet-changed
|
||
/// subscription.
|
||
/// </returns>
|
||
public static Action Bind(
|
||
ImportedLayout layout,
|
||
Func<CharacterSheet> data,
|
||
UiDatFont? datFont = null,
|
||
UiDatFont? rowDatFont = null,
|
||
Func<uint, (uint handle, int w, int h)>? spriteResolve = null,
|
||
RaiseRequestHandler? onRaiseRequest = null,
|
||
Action? onClose = null,
|
||
Func<uint, uint, uint>? iconDidResolve = null)
|
||
{
|
||
// rowDatFont: larger font for attribute row name/value text (18px vs 16px default).
|
||
// Falls back to datFont when null (tests, or dat missing).
|
||
rowDatFont ??= datFont;
|
||
WindowChromeController.BindCloseButton(layout, onClose);
|
||
|
||
var activeTab = new[] { CharacterStatTab.Attributes };
|
||
var attrSel = new[] { -1 };
|
||
var skillSel = new[] { -1 };
|
||
var activeListEntries = new List<UiElement>();
|
||
var currentAttributeRows = new List<UiClickablePanel>();
|
||
var currentSkillRows = new List<SkillRowBinding>();
|
||
UiElement? attributesTab = layout.FindElement(TabAttribId);
|
||
UiElement? skillsTab = layout.FindElement(TabSkillsId);
|
||
UiElement? titlesTab = layout.FindElement(TabTitlesId);
|
||
UiElement? contentPage = FindDirectChildById(layout.Root, AttributesPageId);
|
||
// CT3: unlike Attributes/Skills (which share ONE mounted page and just
|
||
// rebind its content — see the Attributes/Skills tab-switch note above),
|
||
// the Titles page (0x10000539) is its OWN separate, non-duplicated subtree
|
||
// (CT1 ground truth §3) that must be actually shown/hidden.
|
||
UiElement? titlesPage = FindDirectChildById(layout.Root, TitlesPageId);
|
||
|
||
// Name (18px from dat FontDid), Heritage (14px), PkStatus (14px):
|
||
// Fix C: pass null → Label's null-guard keeps the build-time dat font.
|
||
// Campaign CT slice CT4 (2026-08-24): CT1's live-DAT pin
|
||
// (HeaderElements_AuthorExpectedFontsAndColors) confirmed all FOUR
|
||
// header identity elements — Name, Heritage, PkStatus, Level — carry
|
||
// their own authored FontColor (white/white/white/pale-gold with
|
||
// Outline). The "runtime color, dat carries none" reasoning this
|
||
// block used to justify a hand-picked Body/Gold constant per element
|
||
// was FALSIFIED by that pin: every element below now sources its
|
||
// color from the widget's own DAT-set DefaultColor
|
||
// (LabelAuthoredColor), matching the "authored color/font wins"
|
||
// pattern CT3's CharacterTitlesController already established for
|
||
// its row/display text. Level's Outline is likewise already applied
|
||
// at import time (DatWidgetFactory.BuildText reads dat property
|
||
// 0x21) — no controller-side Outline flag needed.
|
||
LabelAuthoredColor(layout, contentPage, NameId, null, () => data().Name);
|
||
LabelAuthoredColor(layout, contentPage, HeritageId, null, () => CharacterIdentityText.StatHeaderLine(data()));
|
||
LabelAuthoredColor(layout, contentPage, PkStatusId, null, () => data().PkStatus ?? string.Empty);
|
||
|
||
// ── Header captions (new — retail labels above/left of each number) ──────
|
||
// LevelCaption (0x1000023A, 16px from dat): pass null → keep build-time dat font.
|
||
LabelTwoLine(layout, contentPage, LevelCaptionId, null, Body, "Character", "Level");
|
||
|
||
// Level number: retail renders this as large gold centered text in the 65×50 element.
|
||
// Fix C: the dat FontDid for the level element (0x1000023B) is now applied at build
|
||
// time when the font resolver is provided (studio path). We no longer force rowDatFont
|
||
// here for the level — the dat's own FontDid drives the font.
|
||
// Source: spec §Level area (65,50) + decomp gmStatManagementUI::UpdateCharacterInfo
|
||
// 0x004f0770. CT4 contract (PE-recovered 2026-08-24): InqInt(0x19) present formats
|
||
// with "%d" semantics (a bare integer, data_7a0184); absent shows the literal "???"
|
||
// (data_7b0f34) — CharacterSheet.Level is null in that case.
|
||
LabelAuthoredColor(layout, contentPage, LevelId, null,
|
||
() => data().Level is int lvl ? lvl.ToString(CultureInfo.InvariantCulture) : "???");
|
||
|
||
// TotalXpLabel (16px from dat) + TotalXp (16px from dat): pass null → keep dat font.
|
||
LabelLeft(layout, contentPage, TotalXpLabelId, null, Body, static () => "Total Experience (XP):");
|
||
LabelRight(layout, contentPage, TotalXpId, null, Body, () => FormatXp(data().TotalXp));
|
||
|
||
// XP-to-level meter fill (gmStatManagementUI::UpdateExperience 0x004f0a70).
|
||
// Fix 5: child elements 0x10000237 (label) and 0x10000238 (value) are now built by
|
||
// the LayoutImporter as UiText children of the XP meter (non-Type-3 meter children
|
||
// are explicitly built and registered in byId — see LayoutImporter.BuildWidget).
|
||
// FindElement now returns them; the controller binds their LinesProvider.
|
||
// The importer builds them as UiText via DatWidgetFactory.BuildText, applying their
|
||
// dat-origin HJustify/VJustify/FontDid/FontColor at build time. The controller then
|
||
// overrides Padding=0 (to avoid scroll clip in the ~13px-tall bar), ClickThrough=true,
|
||
// and provides the LinesProvider for runtime content.
|
||
if (FindElementByDatId(layout, contentPage, XpMeterId) is UiMeter meter)
|
||
{
|
||
meter.Fill = () => data().XpFraction;
|
||
|
||
// Bind the dat-origin XP label (0x10000237) and value (0x10000238).
|
||
// These are now real UiText children of the meter (built by the importer).
|
||
// The retail layout places the caption + value ON TOP of the red bar
|
||
// (ref 2026-06-26: "value … with the red fill bar behind it").
|
||
// Source: retail spec (2026-06-26-character-window-retail-reference.md §State 1).
|
||
if (FindTextByDatId(layout, contentPage, XpNextLabelId) is UiText xpLabel)
|
||
{
|
||
if (datFont is not null) xpLabel.DatFont = datFont;
|
||
xpLabel.ClickThrough = true;
|
||
xpLabel.Centered = false; // left-align (retail: aligns with Total XP label above)
|
||
xpLabel.RightAligned = false;
|
||
xpLabel.Padding = 0f; // avoid scroll clip — meter bar is ~13px tall
|
||
|
||
// Item 1: align the XP-next label's left edge to match the TotalXpLabel's
|
||
// absolute left edge. The XP-next label is a child of the meter (local coords),
|
||
// so its Left = TotalXpLabel.Left − meter.Left. This accounts for the meter's
|
||
// horizontal offset within the panel (the meter starts to the right of the
|
||
// "Total Experience (XP):" caption row). Source: retail spec §State 1 (the
|
||
// "XP for next level:" caption left-aligns with "Total Experience (XP):" above).
|
||
if (FindElementByDatId(layout, contentPage, TotalXpLabelId) is { } totalXpLbl)
|
||
{
|
||
float xpNextLeft = totalXpLbl.Left - meter.Left;
|
||
xpLabel.Left = xpNextLeft >= 0f ? xpNextLeft : 0f;
|
||
}
|
||
|
||
xpLabel.LinesProvider = static () => new[] { new UiText.Line("XP for next level:", Body) };
|
||
}
|
||
if (FindTextByDatId(layout, contentPage, XpNextValueId) is UiText xpValue)
|
||
{
|
||
if (datFont is not null) xpValue.DatFont = datFont;
|
||
xpValue.ClickThrough = true;
|
||
xpValue.RightAligned = true;
|
||
xpValue.OneLine = true;
|
||
xpValue.Padding = 0f; // avoid scroll clip
|
||
xpValue.LinesProvider = () => new[] { new UiText.Line(FormatXp(data().XpToNextLevel), Body) };
|
||
}
|
||
}
|
||
|
||
// ── Luminance pair (0x100005C5/C6) — CT4 item 5, text bound at the ──
|
||
// ── CT4 fix round (2026-08-25, BLOCKER 1) ────────────────────────────
|
||
// gmStatManagementUI::UpdateExperience (0x004f0a70): InqInt64(6)
|
||
// (AvailableLuminance) and InqInt64(7) (MaximumLuminance) are read
|
||
// unconditionally; the pair is hidden — UIElement_Text::ClearAllText
|
||
// (@0x004f0e31/@0x004f0e3c) on BOTH m_pLuminanceLabelText and
|
||
// m_pLuminanceText — whenever "InqInt(0x19) < 0xc8 (200) ||
|
||
// MaximumLuminance == 0". ClearAllText empties the widget's text and
|
||
// leaves layout/Visible untouched, so this binds the SAME way every
|
||
// other dynamic label in this method does: a LinesProvider that
|
||
// re-reads data() on every draw and returns an EMPTY line set when
|
||
// the gate is closed (retail's ClearAllText) or the resolved content
|
||
// when it is open — no separate "refresh" call is needed, and no
|
||
// Visible flag is touched. Retail's SetText calls (recovered by
|
||
// PE-byte-decoding the gmStatManagementUI vftable-adjacent data
|
||
// region, since Binary Ninja mislabels the two string pointers as
|
||
// vftable slots rather than a StringTable key like the PK line):
|
||
// caption = literal "Luminance:" (UTF-16 @0x007c3dd4); value =
|
||
// narrow "%s / %s" (@0x007c3dcc) with (available, maximum) in that
|
||
// order, both formatted through ExperienceSystem::XPToString — ported
|
||
// as the shared FormatXp helper below (the same one Total XP / XP-to-
|
||
// next-level already use).
|
||
bool LuminanceVisible(CharacterSheet sheet) =>
|
||
sheet.Level is int lvl && lvl >= 200 && sheet.MaximumLuminance != 0;
|
||
|
||
if (FindTextByDatId(layout, contentPage, LuminanceLabelId) is UiText luminanceLabel)
|
||
{
|
||
luminanceLabel.LinesProvider = () => LuminanceVisible(data())
|
||
? new[] { new UiText.Line(LuminanceCaption, luminanceLabel.DefaultColor) }
|
||
: Array.Empty<UiText.Line>();
|
||
}
|
||
if (FindTextByDatId(layout, contentPage, LuminanceValueId) is UiText luminanceValue)
|
||
{
|
||
luminanceValue.LinesProvider = () =>
|
||
{
|
||
var sheet = data();
|
||
if (!LuminanceVisible(sheet)) return Array.Empty<UiText.Line>();
|
||
string text = $"{FormatXp(sheet.AvailableLuminance)} / {FormatXp(sheet.MaximumLuminance)}";
|
||
return new[] { new UiText.Line(text, luminanceValue.DefaultColor) };
|
||
};
|
||
}
|
||
|
||
// The tab visuals are already retained in the imported LayoutDesc. Controllers
|
||
// bind only click behavior and the active Open/Closed state below.
|
||
|
||
// ── Attribute list — 9 rows in list box 0x1000023D ────────────────────
|
||
// Mutable selected-index box: -1 = nothing selected.
|
||
|
||
// Gather EVERY copy of the raise buttons in the tree. The raise button ids
|
||
// (0x10000246, 0x100005EB) each appear TWICE under BOTH the Attributes page
|
||
// (0x1000022B) and the Skills page (0x1000022C) — once per footer-state group
|
||
// (0x10000247/0x10000241) — four copies total. Verified against the committed
|
||
// fixture (CT3 fix round): Titles (0x10000539) authors NO copies of its own —
|
||
// correcting this comment's earlier, false "once per tab page
|
||
// (Attributes/Skills/Titles)" claim. ImportedLayout._byId keeps only the LAST
|
||
// mounted copy. We collect all copies so we can hide them all initially and
|
||
// show/hide the correct set when a row is selected.
|
||
//
|
||
// At bind time the tree includes all three tab pages (the page-visibility pass
|
||
// runs AFTER this). Collecting from the full tree is safe: once the page-
|
||
// visibility pass hides the inactive pages their raise buttons are invisible
|
||
// regardless of the Visible flag we set here — but the Attributes page's
|
||
// buttons (which are NOT hidden by the page pass) must be explicitly hidden.
|
||
var allRaise1 = new List<UiButton>();
|
||
var allRaise10 = new List<UiButton>();
|
||
if (layout.Root is { } r)
|
||
{
|
||
// Single-pass tree walk to collect all UiButton copies at the two ids.
|
||
// FindElement only returns the last-registered copy in _byId; we need ALL
|
||
// copies because duplicated sub-layout mounts each tab page independently.
|
||
CollectButtonsById(r, RaiseOneId, allRaise1, layout);
|
||
CollectButtonsById(r, RaiseTenId, allRaise10, layout);
|
||
}
|
||
// If tree-walk found nothing, fall back to _byId (covers fake/test layouts).
|
||
if (allRaise1.Count == 0 && layout.FindElement(RaiseOneId) is UiButton b1) allRaise1.Add(b1);
|
||
if (allRaise10.Count == 0 && layout.FindElement(RaiseTenId) is UiButton b10) allRaise10.Add(b10);
|
||
|
||
var footerDefaultGroups = new List<UiElement>();
|
||
var footerSelectedGroups = new List<UiElement>();
|
||
var footerInactiveGroups = new List<UiElement>();
|
||
if (contentPage is not null)
|
||
{
|
||
CollectElementsByDatId(contentPage, FooterStateAId, footerDefaultGroups);
|
||
CollectElementsByDatId(contentPage, FooterStateBId, footerSelectedGroups);
|
||
CollectElementsByDatId(contentPage, FooterStateCId, footerInactiveGroups);
|
||
}
|
||
else if (layout.Root is not null)
|
||
{
|
||
CollectElementsByDatId(layout.Root, FooterStateAId, footerDefaultGroups);
|
||
CollectElementsByDatId(layout.Root, FooterStateBId, footerSelectedGroups);
|
||
CollectElementsByDatId(layout.Root, FooterStateCId, footerInactiveGroups);
|
||
}
|
||
|
||
void SetFooterSelected(bool selected)
|
||
{
|
||
foreach (var g in footerDefaultGroups) g.Visible = !selected;
|
||
foreach (var g in footerSelectedGroups) g.Visible = selected;
|
||
foreach (var g in footerInactiveGroups) g.Visible = false;
|
||
}
|
||
|
||
// Initial state: raise buttons hidden until a row is selected.
|
||
foreach (var b in allRaise1) b.Visible = false;
|
||
foreach (var b in allRaise10) b.Visible = false;
|
||
|
||
// ── Footer state visibility ───────────────────────────────────────────
|
||
// There are THREE footer state groups (A=0x10000240, B=0x10000241, C=0x10000247)
|
||
// all stacked at the same position within the Attributes page. _byId stores only
|
||
// the LAST copy of each id; the others live in the VISIBLE Attributes page and must
|
||
// also be hidden.
|
||
//
|
||
// WHY this cannot be done in the importer (dat state-model audit 2026-06-26):
|
||
// All three group elements have DefaultState = StatManagement_Footer_Default
|
||
// (0x10000011) — the dat does NOT differentiate them by visibility. The parent
|
||
// element (0x1000022F) has a States map {Default, Text, Meter} with PassToChildren=
|
||
// true, but each child group also registers all three states (IncFlags=None,
|
||
// Media=0) — meaning the state-propagation produces no media change on any group.
|
||
// Retail's gmStatManagementUI uses hardcoded element-id dispatch
|
||
// (GetChildRecursive(this, 0x10000240) for Default, 0x10000241 for Text, 0x10000247
|
||
// for Meter) to access the right group's children at runtime — the groups themselves
|
||
// are never hidden/shown via the dat state mechanism. The controller is the correct
|
||
// and only place for this visibility management. See retail decomp
|
||
// gmStatManagementUI::GetFooterTitleLabel @0x004f0170.
|
||
//
|
||
// Strategy: collect the footer groups from the explicit Attributes page id.
|
||
// State A is visible when nothing is selected; State B is visible when a row is
|
||
// selected and owns the retail raise buttons; State C stays hidden for now.
|
||
SetFooterSelected(false);
|
||
|
||
// ── Footer State A initial binding ────────────────────────────────────
|
||
// Walk to the State-A container directly (rather than _byId which returns the
|
||
// last duplicate) so we get the wider-label copies (195px) for the unselected state.
|
||
BindFooterDynamic(layout, datFont, data, activeTab, attrSel, skillSel, contentPage);
|
||
SetFooterSelected(false);
|
||
|
||
UiElement? statList =
|
||
(contentPage is not null
|
||
? FindInSubtree(contentPage, static el => HasDatElementId(el, ListBoxId))
|
||
: null)
|
||
?? layout.FindElement(ListBoxId);
|
||
// Imported character nodes already carry the exact raw-edge policy from
|
||
// the 337px base sublayout into the 575px mounted page. Only synthetic/
|
||
// legacy widgets need compatibility anchors; assigning Anchors on an
|
||
// imported node deliberately clears its UiLayoutPolicy.
|
||
if (statList is not null && statList.LayoutPolicy is null)
|
||
statList.Anchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Bottom;
|
||
|
||
if (layout.Root is { } stretchRoot)
|
||
{
|
||
SetCompatibilityAnchorsAllById(stretchRoot, ListScrollbarId, AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Bottom);
|
||
SetCompatibilityAnchorsAllById(stretchRoot, ListDividerId, AnchorEdges.Left | AnchorEdges.Bottom);
|
||
SetCompatibilityAnchorsAllById(stretchRoot, FooterStateAId, AnchorEdges.Left | AnchorEdges.Bottom);
|
||
SetCompatibilityAnchorsAllById(stretchRoot, FooterStateBId, AnchorEdges.Left | AnchorEdges.Bottom);
|
||
SetCompatibilityAnchorsAllById(stretchRoot, FooterStateCId, AnchorEdges.Left | AnchorEdges.Bottom);
|
||
}
|
||
|
||
UiScrollbar? skillScrollbar = PrepareSkillScrollbar(layout, contentPage, statList, spriteResolve);
|
||
WireRaiseButtonClicks(allRaise1, allRaise10, data, activeTab, attrSel, skillSel,
|
||
() => currentSkillRows, onRaiseRequest, RefreshAfterRaise);
|
||
RebuildActiveList();
|
||
|
||
RetailTabBinding.SetClick(attributesTab, () => SwitchTab(CharacterStatTab.Attributes));
|
||
RetailTabBinding.SetClick(skillsTab, () => SwitchTab(CharacterStatTab.Skills));
|
||
// CT3 (2026-08-24): the Titles tab now switches to its own real page
|
||
// (previously the known AP-109 gap — retail-authored closed visual, but
|
||
// no click routing at all). Content population is
|
||
// CharacterTitlesController's job, bound separately by the caller
|
||
// against the SAME imported layout root.
|
||
RetailTabBinding.SetClick(titlesTab, () => SwitchTab(CharacterStatTab.Titles));
|
||
UpdateTabStates();
|
||
|
||
// ── Active-page selection (fixes the dark-overlay) ─────────────────────
|
||
// WHY this cannot be done in the importer (dat state-model audit 2026-06-26):
|
||
// The three tab-page content areas (0x1000022B Attributes, 0x1000022C Skills,
|
||
// 0x10000539 Titles) all have DefaultState = Undef (0) — the dat carries no
|
||
// visibility encoding for tabs. Tab visibility is managed at runtime by gmTabUI
|
||
// via SetVisible(bool) on the page containers. The controller is the correct
|
||
// and only place for initial tab-page selection.
|
||
if (layout.Root is { } root)
|
||
{
|
||
foreach (var page in root.Children)
|
||
{
|
||
uint id = DatElementId(page);
|
||
if (id is AttributesPageId or SkillsPageId or TitlesPageId)
|
||
page.Visible = id == AttributesPageId;
|
||
}
|
||
}
|
||
|
||
void SwitchTab(CharacterStatTab tab)
|
||
{
|
||
if (activeTab[0] == tab) return;
|
||
activeTab[0] = tab;
|
||
attrSel[0] = -1;
|
||
skillSel[0] = -1;
|
||
SetFooterSelected(false);
|
||
|
||
// CT3: Titles is a genuinely separate page container (unlike
|
||
// Attributes/Skills, which share one mounted page and only rebind
|
||
// its content — see RebuildActiveList) — actually flip visibility
|
||
// between it and the shared Attributes/Skills content page.
|
||
bool showTitles = tab == CharacterStatTab.Titles;
|
||
if (titlesPage is not null) titlesPage.Visible = showTitles;
|
||
if (contentPage is not null) contentPage.Visible = !showTitles;
|
||
|
||
if (showTitles)
|
||
{
|
||
// CT3 fix round: the prior comment here ("Titles authors its
|
||
// own copies of the raise buttons") was FALSE — verified
|
||
// against the fixture, Titles (0x10000539) has none; see the
|
||
// corrected collection comment above. contentPage.Visible =
|
||
// false (just above) already suppresses the Attributes
|
||
// page's real raise-button copies for both draw and click
|
||
// routing (UiElement early-returns on an invisible node
|
||
// before descending to children), so this loop is a no-op
|
||
// in the common case. It is kept only as a defensive
|
||
// fallback for the case where contentPage was not found at
|
||
// bind time (contentPage is null, line ~525) but allRaise1/
|
||
// allRaise10 were still populated via the tree-walk/FindElement
|
||
// fallback above.
|
||
foreach (var b in allRaise1) b.Visible = false;
|
||
foreach (var b in allRaise10) b.Visible = false;
|
||
}
|
||
else
|
||
{
|
||
RebuildActiveList();
|
||
RefreshActiveRaiseButtons();
|
||
}
|
||
|
||
UpdateTabStates();
|
||
Console.WriteLine($"[CharacterStat] Tab click: {tab}");
|
||
}
|
||
|
||
void UpdateTabStates()
|
||
{
|
||
RetailTabBinding.SetOpen(attributesTab, activeTab[0] == CharacterStatTab.Attributes);
|
||
RetailTabBinding.SetOpen(skillsTab, activeTab[0] == CharacterStatTab.Skills);
|
||
RetailTabBinding.SetOpen(titlesTab, activeTab[0] == CharacterStatTab.Titles);
|
||
}
|
||
|
||
void RebuildActiveList()
|
||
{
|
||
if (statList is null) return;
|
||
|
||
foreach (var entry in activeListEntries)
|
||
statList.RemoveChild(entry);
|
||
activeListEntries.Clear();
|
||
currentAttributeRows.Clear();
|
||
currentSkillRows.Clear();
|
||
|
||
// CT6 (2026-08-25): both tabs now stack their rows inside a
|
||
// UiScrollablePanel viewport child of statList — the SAME
|
||
// shrink-and-scroll contract the Skills tab already had.
|
||
// UiScrollablePanel.LayoutScrollableChildren (called every
|
||
// OnDraw) recomputes Scroll.ViewHeight from the viewport's OWN
|
||
// current Height every frame; the viewport's Anchors
|
||
// (Left|Top|Bottom, a child of statList) track statList's live
|
||
// Height as the window resizes, so Scroll.HasOverflow flips live
|
||
// with no extra wiring. Previously only Skills got this
|
||
// treatment — Attributes added rows directly to statList with no
|
||
// clipping/scrolling and the shared scrollbar was force-hidden,
|
||
// which is the "we never show the scrollbar on Attributes" gap
|
||
// (owner report item 2) and the reason a shrunk window could not
|
||
// show the overflowing attribute/vital rows at all.
|
||
bool isSkills = activeTab[0] == CharacterStatTab.Skills;
|
||
float contentW = isSkills
|
||
? SkillViewportWidth(statList, skillScrollbar)
|
||
: RowContentWidth(statList);
|
||
var viewport = new UiScrollablePanel
|
||
{
|
||
Left = 0f,
|
||
Top = 0f,
|
||
Width = contentW,
|
||
Height = statList.Height,
|
||
LineHeight = (int)RowHeight,
|
||
Anchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Bottom,
|
||
};
|
||
statList.AddChild(viewport);
|
||
// #372-class fix (same mechanism as UiTemplateListBox.Viewport's
|
||
// own lazy getter): RebuildActiveList runs during Bind, BEFORE
|
||
// the window's first anchor pass ever reflows statList up from
|
||
// its raw DAT-authored height (160px) to its actual mounted
|
||
// height. Left to its lazy default, viewport's Left|Top|Bottom
|
||
// anchor would capture its baseline margins on ITS OWN first
|
||
// ApplyAnchor call — which happens AFTER statList has already
|
||
// grown — measuring a bogus non-zero bottom margin (e.g.
|
||
// "398 grown list − 160 stale viewport = 238px margin") that then
|
||
// permanently caps the viewport 238px short on every future
|
||
// resize. Capturing NOW, while viewport.Height still exactly
|
||
// equals statList's own CURRENT (pre-reflow, zero-margin)
|
||
// height, gives a true (0,0,0,0) baseline that then correctly
|
||
// full-stretches on every later resize.
|
||
viewport.CaptureCurrentAnchorBaseline();
|
||
activeListEntries.Add(viewport);
|
||
|
||
if (isSkills)
|
||
{
|
||
BuildSkillRows(viewport, rowDatFont, spriteResolve, data, skillSel,
|
||
allRaise1, allRaise10, SetFooterSelected, out currentSkillRows);
|
||
}
|
||
else
|
||
{
|
||
currentAttributeRows = BuildAttributeRows(viewport, rowDatFont, spriteResolve, data, attrSel,
|
||
allRaise1, allRaise10, SetFooterSelected, iconDidResolve);
|
||
}
|
||
|
||
// Always BOUND for whichever tab is active — no per-tab visibility
|
||
// toggle is needed here. CT6 fix round (S2 correction): the
|
||
// scrollbar's own .Visible=true just keeps it in the tree; what
|
||
// actually shows or hides it on screen is UiScrollbar's
|
||
// IsPresentationVisible, and 0x1000023E authors 0x79
|
||
// (HideWhenDisabled) TRUE (fixture-verified — the 121/0x79
|
||
// property on the fixture's scrollbar element carries
|
||
// BoolValue=true). That means a fitting list HIDES the bar
|
||
// entirely, not a full-track "disabled" thumb left visible — the
|
||
// previous comment here had the authored default backwards.
|
||
// Content that overflows still makes the bar visible and
|
||
// interactive, same as always.
|
||
if (skillScrollbar is not null)
|
||
{
|
||
skillScrollbar.Model = viewport.Scroll;
|
||
skillScrollbar.Visible = true;
|
||
}
|
||
}
|
||
|
||
void RefreshActiveRaiseButtons()
|
||
{
|
||
if (activeTab[0] == CharacterStatTab.Attributes)
|
||
{
|
||
RefreshRaiseButtons(attrSel[0], data, allRaise1, allRaise10);
|
||
return;
|
||
}
|
||
|
||
CharacterSkill? selectedSkill =
|
||
skillSel[0] >= 0 && skillSel[0] < currentSkillRows.Count
|
||
? currentSkillRows[skillSel[0]].Skill
|
||
: null;
|
||
RefreshSkillRaiseButtons(selectedSkill, data(), allRaise1, allRaise10);
|
||
}
|
||
|
||
void RefreshAfterRaise(uint? selectedSkillId)
|
||
{
|
||
if (activeTab[0] == CharacterStatTab.Skills)
|
||
{
|
||
if (selectedSkillId is null
|
||
&& skillSel[0] >= 0
|
||
&& skillSel[0] < currentSkillRows.Count)
|
||
{
|
||
selectedSkillId = currentSkillRows[skillSel[0]].Skill.Id;
|
||
}
|
||
|
||
RebuildActiveList();
|
||
|
||
skillSel[0] = -1;
|
||
if (selectedSkillId is uint id)
|
||
{
|
||
for (int i = 0; i < currentSkillRows.Count; i++)
|
||
{
|
||
if (currentSkillRows[i].Skill.Id == id)
|
||
{
|
||
skillSel[0] = i;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
ApplySkillSelectionVisuals(skillSel[0], currentSkillRows, spriteResolve);
|
||
SetFooterSelected(skillSel[0] >= 0);
|
||
}
|
||
|
||
RefreshActiveRaiseButtons();
|
||
// CT4 fix round: the luminance pair's LinesProvider re-reads
|
||
// data() on every draw (same as every other dynamic label here),
|
||
// so no explicit refresh call is needed for a level-up or a
|
||
// luminance-award quality change.
|
||
}
|
||
|
||
return () => RefreshAfterRaise(null);
|
||
}
|
||
|
||
private static UiScrollbar? PrepareSkillScrollbar(
|
||
ImportedLayout layout,
|
||
UiElement? contentPage,
|
||
UiElement? statList,
|
||
Func<uint, (uint handle, int w, int h)>? spriteResolve)
|
||
{
|
||
if (spriteResolve is null)
|
||
return null;
|
||
|
||
// The mounted character sublayout contains duplicated stat-management
|
||
// branches with the same element ids. Bind the scrollbar adjacent to the
|
||
// active list first; a page-wide DFS can select an inactive duplicate that
|
||
// is never drawn while leaving the visible gutter unconfigured.
|
||
UiElement? source = statList?.Parent?.Children.FirstOrDefault(
|
||
static el => HasDatElementId(el, ListScrollbarId))
|
||
?? (contentPage is not null
|
||
? FindInSubtree(contentPage, static el => HasDatElementId(el, ListScrollbarId))
|
||
: null)
|
||
?? layout.FindElement(ListScrollbarId);
|
||
|
||
if (source is UiScrollbar existingBar)
|
||
{
|
||
existingBar.SpriteResolve ??= id =>
|
||
{
|
||
var (handle, width, height) = spriteResolve(id);
|
||
return (handle, width, height);
|
||
};
|
||
existingBar.Visible = false;
|
||
return existingBar;
|
||
}
|
||
|
||
UiElement? parent = source?.Parent ?? statList?.Parent;
|
||
if (parent is null || statList is null)
|
||
return null;
|
||
|
||
float left = source is not null
|
||
? source.Left
|
||
: statList.Left + MathF.Min(statList.Width, SkillContentWidth);
|
||
float top = source?.Top ?? statList.Top;
|
||
float width = source?.Width > 0f ? source.Width : 16f;
|
||
float height = source?.Height > 0f ? source.Height : statList.Height;
|
||
int z = (source?.ZOrder ?? statList.ZOrder) + 1;
|
||
|
||
if (source is not null)
|
||
source.Visible = false;
|
||
|
||
var bar = new UiScrollbar
|
||
{
|
||
Left = left,
|
||
Top = top,
|
||
Width = width,
|
||
Height = height,
|
||
ZOrder = z,
|
||
Anchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Bottom,
|
||
};
|
||
ConfigureSkillScrollbar(bar, spriteResolve);
|
||
bar.Visible = false;
|
||
parent.AddChild(bar);
|
||
return bar;
|
||
}
|
||
|
||
private static void ConfigureSkillScrollbar(
|
||
UiScrollbar bar,
|
||
Func<uint, (uint handle, int w, int h)> spriteResolve)
|
||
{
|
||
bar.SpriteResolve = id => { var (h, w, ht) = spriteResolve(id); return (h, w, ht); };
|
||
RetailScrollbarChrome.ApplyVertical(bar);
|
||
}
|
||
|
||
private static float SkillViewportWidth(UiElement statList, UiScrollbar? bar)
|
||
{
|
||
if (bar is not null && ReferenceEquals(bar.Parent, statList.Parent))
|
||
{
|
||
float widthToGutter = bar.Left - statList.Left;
|
||
if (widthToGutter > 32f)
|
||
return widthToGutter;
|
||
}
|
||
|
||
return statList.Width > 0f
|
||
? MathF.Min(statList.Width, SkillContentWidth)
|
||
: SkillContentWidth;
|
||
}
|
||
|
||
/// <summary>
|
||
/// CT5: the row's own rendered width is <paramref name="list"/>'s own
|
||
/// width clamped to the AUTHORED row-template CEILING
|
||
/// (282px, <see cref="SkillContentWidth"/>) — the two numbers never
|
||
/// compose into a derived "gutter" formula (ground-truth doc §2's
|
||
/// explicit warning); a shorter <paramref name="list"/> clamps the row
|
||
/// down, but a wider one never stretches it past 282.
|
||
/// <para>CT5 fix round (NOTE b, tempered wording): this does NOT mean
|
||
/// every row is 282px. Attribute/vital rows pass the raw ListBox
|
||
/// (300px, <see cref="CharacterStatController.ListBoxId"/>'s own dat
|
||
/// rect) and land on the 282 ceiling exactly. The skill page instead
|
||
/// passes the narrower <c>SkillViewportWidth</c>-computed viewport
|
||
/// (281px in production — the real scrollbar's authored gutter), so
|
||
/// skill rows clamp to 281, one pixel short of the template ceiling.
|
||
/// </para>
|
||
/// </summary>
|
||
private static float RowContentWidth(UiElement list)
|
||
=> list.Width > 0f ? MathF.Min(list.Width, SkillContentWidth) : SkillContentWidth;
|
||
|
||
/// <summary>
|
||
/// CT5 (AP-235): resolve a row icon DID through the live
|
||
/// <c>DBObj::GetDIDByEnum</c> chain when a resolver is available,
|
||
/// falling back to the hardcoded <see cref="AttrRows"/>/<see cref="VitalRows"/>
|
||
/// column value otherwise (tests, or a resolve miss).
|
||
/// </summary>
|
||
private static uint ResolveIconDid(
|
||
Func<uint, uint, uint>? resolver,
|
||
uint enumValue,
|
||
uint category,
|
||
uint fallback)
|
||
{
|
||
if (resolver is null) return fallback;
|
||
uint resolved = resolver(enumValue, category);
|
||
return resolved != 0u ? resolved : fallback;
|
||
}
|
||
|
||
// ── 9-row attribute list ─────────────────────────────────────────────────
|
||
|
||
private static List<UiClickablePanel> BuildAttributeRows(
|
||
UiElement list,
|
||
UiDatFont? datFont,
|
||
Func<uint, (uint handle, int w, int h)>? spriteResolve,
|
||
Func<CharacterSheet> data,
|
||
int[] sel,
|
||
List<UiButton> allRaise1,
|
||
List<UiButton> allRaise10,
|
||
Action<bool> setFooterSelected,
|
||
Func<uint, uint, uint>? iconDidResolve)
|
||
{
|
||
float listW = RowContentWidth(list);
|
||
float y = 0f;
|
||
var rows = new List<UiClickablePanel>();
|
||
|
||
for (int i = 0; i < AttrRows.Length; i++)
|
||
{
|
||
var (rowName, iconDid, statId) = AttrRows[i];
|
||
int rowIndex = i;
|
||
|
||
var row = AddRow(list, datFont, spriteResolve,
|
||
left: 0f, top: y, width: listW, height: RowHeight,
|
||
iconDid: ResolveIconDid(iconDidResolve, statId, AttributeIconCategory, iconDid),
|
||
nameText: rowName,
|
||
valueProvider: () =>
|
||
{
|
||
var s = data();
|
||
int v = rowIndex switch
|
||
{
|
||
0 => s.Strength,
|
||
1 => s.Endurance,
|
||
2 => s.Coordination,
|
||
3 => s.Quickness,
|
||
4 => s.Focus,
|
||
5 => s.Self,
|
||
_ => 0,
|
||
};
|
||
return v.ToString();
|
||
},
|
||
valueColorProvider: () => AttributeValueColor(data(), rowIndex));
|
||
row.TooltipText = AttributeDescriptions.GetValueOrDefault(statId);
|
||
|
||
row.OnClick = () =>
|
||
{
|
||
HandleRowClick(rowIndex, sel, rows, spriteResolve, data, allRaise1, allRaise10);
|
||
setFooterSelected(sel[0] >= 0);
|
||
};
|
||
rows.Add(row);
|
||
y += RowHeight;
|
||
}
|
||
|
||
for (int i = 0; i < VitalRows.Length; i++)
|
||
{
|
||
var (rowName, iconDid, maxStatId) = VitalRows[i];
|
||
int rowIndex = i;
|
||
int absIndex = AttrRows.Length + i;
|
||
|
||
var row = AddRow(list, datFont, spriteResolve,
|
||
left: 0f, top: y, width: listW, height: RowHeight,
|
||
iconDid: ResolveIconDid(iconDidResolve, maxStatId, VitalIconCategory, iconDid),
|
||
nameText: rowName,
|
||
valueProvider: () =>
|
||
{
|
||
var s = data();
|
||
return rowIndex switch
|
||
{
|
||
0 => $"{s.HealthCurrent}/{s.HealthMax}",
|
||
1 => $"{s.StaminaCurrent}/{s.StaminaMax}",
|
||
2 => $"{s.ManaCurrent}/{s.ManaMax}",
|
||
_ => string.Empty,
|
||
};
|
||
},
|
||
valueColorProvider: () => VitalValueColor(data(), rowIndex));
|
||
row.TooltipText = Attribute2ndDescriptions.GetValueOrDefault(maxStatId);
|
||
|
||
row.OnClick = () =>
|
||
{
|
||
HandleRowClick(absIndex, sel, rows, spriteResolve, data, allRaise1, allRaise10);
|
||
setFooterSelected(sel[0] >= 0);
|
||
};
|
||
rows.Add(row);
|
||
y += RowHeight;
|
||
}
|
||
|
||
return rows;
|
||
}
|
||
|
||
private static List<UiElement> BuildSkillRows(
|
||
UiElement list,
|
||
UiDatFont? datFont,
|
||
Func<uint, (uint handle, int w, int h)>? spriteResolve,
|
||
Func<CharacterSheet> data,
|
||
int[] sel,
|
||
List<UiButton> allRaise1,
|
||
List<UiButton> allRaise10,
|
||
Action<bool> setFooterSelected,
|
||
out List<SkillRowBinding> skillRows)
|
||
{
|
||
float listW = RowContentWidth(list);
|
||
float y = 0f;
|
||
var entries = new List<UiElement>();
|
||
var bindings = new List<SkillRowBinding>();
|
||
|
||
AddBucket("Specialized Skills", SkillHeaderSpecializedSprite,
|
||
OrderedSkills(data(), CharacterSkillAdvancementClass.Specialized, usableUntrained: null));
|
||
AddBucket("Trained Skills", SkillHeaderTrainedSprite,
|
||
OrderedSkills(data(), CharacterSkillAdvancementClass.Trained, usableUntrained: null));
|
||
AddBucket("Untrained Skills", SkillHeaderUntrainedSprite,
|
||
OrderedSkills(data(), CharacterSkillAdvancementClass.Untrained, usableUntrained: true));
|
||
AddBucket("Unusable Skills", SkillHeaderUnusableSprite,
|
||
OrderedSkills(data(), CharacterSkillAdvancementClass.Untrained, usableUntrained: false));
|
||
|
||
skillRows = bindings;
|
||
return entries;
|
||
|
||
void AddBucket(string title, uint spriteId, IReadOnlyList<CharacterSkill> skills)
|
||
{
|
||
var header = AddSkillHeader(list, datFont, spriteResolve, 0f, y, listW, title, spriteId);
|
||
entries.Add(header);
|
||
y += SkillHeaderHeight;
|
||
|
||
foreach (var skill in skills)
|
||
{
|
||
int rowIndex = bindings.Count;
|
||
CharacterSkill LiveSkill() =>
|
||
FindSkill(data(), skill.Id) ?? skill;
|
||
var row = AddRow(list, datFont, spriteResolve,
|
||
left: 0f, top: y, width: listW, height: RowHeight,
|
||
iconDid: skill.IconDid,
|
||
nameText: skill.Name,
|
||
valueProvider: () => LiveSkill().CurrentLevel.ToString(),
|
||
valueColorProvider: () => SkillValueColor(LiveSkill()),
|
||
nameColor: Vector4.One);
|
||
// TS-85: SkillInfoRegion::GetTooltip (0x004f1fe0), stamped once at
|
||
// row construction — matches retail (never recomputed per frame).
|
||
row.TooltipText = skill.TooltipText;
|
||
row.OnClick = () =>
|
||
{
|
||
HandleSkillRowClick(rowIndex, sel, bindings, spriteResolve, data, allRaise1, allRaise10);
|
||
setFooterSelected(sel[0] >= 0);
|
||
};
|
||
bindings.Add(new SkillRowBinding(row, skill));
|
||
entries.Add(row);
|
||
y += RowHeight;
|
||
}
|
||
}
|
||
}
|
||
|
||
private static UiPanel AddSkillHeader(
|
||
UiElement list,
|
||
UiDatFont? datFont,
|
||
Func<uint, (uint handle, int w, int h)>? spriteResolve,
|
||
float left,
|
||
float top,
|
||
float width,
|
||
string title,
|
||
uint spriteId)
|
||
{
|
||
var header = new UiPanel
|
||
{
|
||
Left = left,
|
||
Top = top,
|
||
Width = width,
|
||
Height = SkillHeaderHeight,
|
||
BackgroundColor = spriteResolve is null ? new Vector4(0.12f, 0.12f, 0.14f, 0.65f) : Vector4.Zero,
|
||
BackgroundSprite = spriteResolve is not null ? spriteId : 0u,
|
||
SpriteResolve = spriteResolve is not null
|
||
? id => { var (h, w, ht) = spriteResolve(id); return (h, w, ht); }
|
||
: null,
|
||
BorderColor = Vector4.Zero,
|
||
Anchors = AnchorEdges.Left | AnchorEdges.Top,
|
||
ClickThrough = true,
|
||
};
|
||
|
||
var label = new UiText
|
||
{
|
||
Left = RowPadX,
|
||
Top = 0f,
|
||
Width = MathF.Max(1f, width - RowPadX * 2f),
|
||
Height = SkillHeaderHeight,
|
||
DatFont = datFont,
|
||
ClickThrough = true,
|
||
Centered = false,
|
||
RightAligned = false,
|
||
Padding = 1f,
|
||
Anchors = AnchorEdges.Left | AnchorEdges.Top,
|
||
};
|
||
string captured = title;
|
||
label.LinesProvider = () => new[] { new UiText.Line(captured, Vector4.One) };
|
||
header.AddChild(label);
|
||
list.AddChild(header);
|
||
return header;
|
||
}
|
||
|
||
private static IReadOnlyList<CharacterSkill> OrderedSkills(
|
||
CharacterSheet sheet,
|
||
CharacterSkillAdvancementClass advancement,
|
||
bool? usableUntrained)
|
||
{
|
||
var result = new List<CharacterSkill>();
|
||
foreach (var skill in sheet.Skills)
|
||
{
|
||
if (skill.AdvancementClass != advancement) continue;
|
||
if (usableUntrained is not null && skill.UsableUntrained != usableUntrained.Value) continue;
|
||
result.Add(skill);
|
||
}
|
||
result.Sort(static (a, b) => string.Compare(a.Name, b.Name, StringComparison.Ordinal));
|
||
return result;
|
||
}
|
||
|
||
private static CharacterSkill? FindSkill(CharacterSheet sheet, uint skillId)
|
||
{
|
||
IReadOnlyList<CharacterSkill> skills = sheet.Skills;
|
||
for (int i = 0; i < skills.Count; i++)
|
||
{
|
||
CharacterSkill skill = skills[i];
|
||
if (skill.Id == skillId)
|
||
return skill;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
private static CharacterSkill? SkillAtDisplayIndex(CharacterSheet sheet, int index)
|
||
{
|
||
if (index < 0) return null;
|
||
int n = 0;
|
||
foreach (var bucket in new[]
|
||
{
|
||
OrderedSkills(sheet, CharacterSkillAdvancementClass.Specialized, usableUntrained: null),
|
||
OrderedSkills(sheet, CharacterSkillAdvancementClass.Trained, usableUntrained: null),
|
||
OrderedSkills(sheet, CharacterSkillAdvancementClass.Untrained, usableUntrained: true),
|
||
OrderedSkills(sheet, CharacterSkillAdvancementClass.Untrained, usableUntrained: false),
|
||
})
|
||
{
|
||
foreach (var skill in bucket)
|
||
{
|
||
if (n == index) return skill;
|
||
n++;
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
internal static Vector4 SkillValueColor(CharacterSkill skill)
|
||
{
|
||
int withoutVitae = skill.CurrentLevel - skill.VitaeModifier;
|
||
return withoutVitae > skill.BaseLevel ? RetailBuffGreen
|
||
: withoutVitae < skill.BaseLevel ? RetailDebuffRed
|
||
: Vector4.One;
|
||
}
|
||
|
||
internal static Vector4 AttributeValueColor(
|
||
CharacterSheet sheet,
|
||
int rowIndex)
|
||
{
|
||
int delta = GetAttributeDelta(sheet, rowIndex);
|
||
return delta > 0 ? RetailBuffGreen
|
||
: delta < 0 ? RetailDebuffRed
|
||
: Vector4.One;
|
||
}
|
||
|
||
internal static Vector4 VitalValueColor(
|
||
CharacterSheet sheet,
|
||
int vitalIndex)
|
||
{
|
||
if ((uint)vitalIndex >= 3u
|
||
|| vitalIndex >= sheet.VitalBaseMaxValues.Length
|
||
|| vitalIndex >= sheet.VitalVitaeModifiers.Length)
|
||
{
|
||
return Vector4.One;
|
||
}
|
||
|
||
int effective = vitalIndex switch
|
||
{
|
||
0 => sheet.HealthMax,
|
||
1 => sheet.StaminaMax,
|
||
2 => sheet.ManaMax,
|
||
_ => 0,
|
||
};
|
||
int withoutVitae = effective - sheet.VitalVitaeModifiers[vitalIndex];
|
||
int baseline = sheet.VitalBaseMaxValues[vitalIndex];
|
||
return withoutVitae > baseline ? RetailBuffGreen
|
||
: withoutVitae < baseline ? RetailDebuffRed
|
||
: Vector4.One;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Handles a row click: toggle (same row → deselect), else select new row.
|
||
/// Updates highlight, footer providers, and raise-button state.
|
||
/// </summary>
|
||
private static void HandleRowClick(
|
||
int clickedIndex,
|
||
int[] sel,
|
||
List<UiClickablePanel> rows,
|
||
Func<uint, (uint handle, int w, int h)>? spriteResolve,
|
||
Func<CharacterSheet> data,
|
||
List<UiButton> allRaise1,
|
||
List<UiButton> allRaise10)
|
||
{
|
||
int newSel = (sel[0] == clickedIndex) ? -1 : clickedIndex;
|
||
sel[0] = newSel;
|
||
|
||
// Log for live test confirmation (user tests selection in the studio).
|
||
string rowName = GetRowName(newSel);
|
||
Console.WriteLine($"[CharacterStat] Row click: index={clickedIndex} → selected={newSel} ({rowName})");
|
||
|
||
// Update highlight on all rows.
|
||
// CT5: retail's InfoRegion::SetState swaps the row's whole background
|
||
// to the template's Highlight-state media (RowHighlightSprite,
|
||
// 0x06000F93 — see its own doc comment) when spriteResolve is
|
||
// available; otherwise fall back to the translucent gold tint.
|
||
// CT5 fix round (SHOULD-FIX 1): the UNSELECTED branch now draws the
|
||
// template's Normal-state media (RowNormalSprite, 0x06004CC2 — see
|
||
// its own doc comment) the same way, instead of leaving the row
|
||
// fully transparent.
|
||
for (int i = 0; i < rows.Count; i++)
|
||
{
|
||
var row = rows[i];
|
||
if (i == newSel)
|
||
{
|
||
if (spriteResolve is not null)
|
||
{
|
||
row.BackgroundColor = Vector4.Zero;
|
||
row.BackgroundSprite = RowHighlightSprite;
|
||
row.SpriteResolve = spriteResolve;
|
||
}
|
||
else
|
||
{
|
||
row.BackgroundColor = HighlightBg;
|
||
row.BackgroundSprite = 0u;
|
||
row.SpriteResolve = null;
|
||
}
|
||
}
|
||
else
|
||
{
|
||
row.BackgroundColor = Vector4.Zero;
|
||
row.BackgroundSprite = spriteResolve is not null ? RowNormalSprite : 0u;
|
||
row.SpriteResolve = spriteResolve;
|
||
}
|
||
}
|
||
|
||
// Update raise buttons.
|
||
RefreshRaiseButtons(newSel, data, allRaise1, allRaise10);
|
||
}
|
||
|
||
private static void HandleSkillRowClick(
|
||
int clickedIndex,
|
||
int[] sel,
|
||
List<SkillRowBinding> rows,
|
||
Func<uint, (uint handle, int w, int h)>? spriteResolve,
|
||
Func<CharacterSheet> data,
|
||
List<UiButton> allRaise1,
|
||
List<UiButton> allRaise10)
|
||
{
|
||
int newSel = (sel[0] == clickedIndex) ? -1 : clickedIndex;
|
||
sel[0] = newSel;
|
||
|
||
string rowName = newSel >= 0 && newSel < rows.Count ? rows[newSel].Skill.Name : string.Empty;
|
||
Console.WriteLine($"[CharacterStat] Skill row click: index={clickedIndex} -> selected={newSel} ({rowName})");
|
||
|
||
ApplySkillSelectionVisuals(newSel, rows, spriteResolve);
|
||
|
||
CharacterSkill? selectedSkill = newSel >= 0 && newSel < rows.Count ? rows[newSel].Skill : null;
|
||
RefreshSkillRaiseButtons(selectedSkill, data(), allRaise1, allRaise10);
|
||
}
|
||
|
||
private static void ApplySkillSelectionVisuals(
|
||
int selectedIndex,
|
||
IReadOnlyList<SkillRowBinding> rows,
|
||
Func<uint, (uint handle, int w, int h)>? spriteResolve)
|
||
{
|
||
for (int i = 0; i < rows.Count; i++)
|
||
{
|
||
var row = rows[i].Panel;
|
||
if (i == selectedIndex)
|
||
{
|
||
if (spriteResolve is not null)
|
||
{
|
||
row.BackgroundColor = Vector4.Zero;
|
||
row.BackgroundSprite = RowHighlightSprite;
|
||
// CT5 fix round (NOTE f): unified with HandleRowClick's
|
||
// direct assignment — the per-row wrapper closure this
|
||
// used to allocate was functionally identical (same
|
||
// tuple shape, just differently-named elements, which
|
||
// the delegate conversion already accepts without it).
|
||
row.SpriteResolve = spriteResolve;
|
||
}
|
||
else
|
||
{
|
||
row.BackgroundColor = HighlightBg;
|
||
row.BackgroundSprite = 0u;
|
||
row.SpriteResolve = null;
|
||
}
|
||
}
|
||
else
|
||
{
|
||
// CT5 fix round (SHOULD-FIX 1): draw the Normal-state media
|
||
// (RowNormalSprite) on unselected rows — see HandleRowClick's
|
||
// matching comment for the full citation.
|
||
row.BackgroundColor = Vector4.Zero;
|
||
row.BackgroundSprite = spriteResolve is not null ? RowNormalSprite : 0u;
|
||
row.SpriteResolve = spriteResolve;
|
||
}
|
||
}
|
||
}
|
||
|
||
/// <summary>Refresh raise button visibility + state based on current selection.
|
||
/// Applies to ALL collected raise button copies (one per tab page) so the
|
||
/// Attributes-page buttons are correctly shown/hidden regardless of which
|
||
/// copy happens to be in ImportedLayout._byId.</summary>
|
||
private static void RefreshRaiseButtons(
|
||
int selectedIndex,
|
||
Func<CharacterSheet> data,
|
||
List<UiButton> allRaise1,
|
||
List<UiButton> allRaise10)
|
||
{
|
||
if (allRaise1.Count == 0 && allRaise10.Count == 0) return;
|
||
|
||
if (selectedIndex < 0)
|
||
{
|
||
// Nothing selected: hide all raise buttons.
|
||
foreach (var b in allRaise1) b.Visible = false;
|
||
foreach (var b in allRaise10) b.Visible = false;
|
||
return;
|
||
}
|
||
|
||
var sheet = data();
|
||
long cost1 = GetRaiseCost(sheet, selectedIndex, amount: 1);
|
||
long cost10 = GetRaiseCost(sheet, selectedIndex, amount: 10);
|
||
// CA4 (retired AP-73): while a raise awaits its authoritative
|
||
// record, retail ghosts the raise controls (one request in flight).
|
||
bool affordable1 = !sheet.AwaitingRaise && cost1 > 0 && sheet.UnassignedXp >= cost1;
|
||
bool affordable10 = !sheet.AwaitingRaise && cost10 > 0 && sheet.UnassignedXp >= cost10;
|
||
|
||
foreach (var b in allRaise1)
|
||
{
|
||
b.Visible = true;
|
||
b.TrySetRetailState(affordable1
|
||
? UiButtonStateMachine.Normal
|
||
: UiButtonStateMachine.Ghosted);
|
||
}
|
||
foreach (var b in allRaise10)
|
||
{
|
||
b.Visible = true;
|
||
b.TrySetRetailState(affordable10
|
||
? UiButtonStateMachine.Normal
|
||
: UiButtonStateMachine.Ghosted);
|
||
}
|
||
}
|
||
|
||
private static void RefreshSkillRaiseButtons(
|
||
CharacterSkill? selectedSkill,
|
||
CharacterSheet sheet,
|
||
List<UiButton> allRaise1,
|
||
List<UiButton> allRaise10)
|
||
{
|
||
if (selectedSkill is null)
|
||
{
|
||
foreach (var b in allRaise1) b.Visible = false;
|
||
foreach (var b in allRaise10) b.Visible = false;
|
||
return;
|
||
}
|
||
|
||
bool trained = selectedSkill.AdvancementClass >= CharacterSkillAdvancementClass.Trained;
|
||
long cost = trained ? selectedSkill.RaiseCost : selectedSkill.TrainedCost;
|
||
bool affordable = !sheet.AwaitingRaise && (trained
|
||
? cost > 0 && sheet.UnassignedXp >= cost
|
||
: cost > 0 && sheet.SkillCredits >= cost);
|
||
foreach (var b in allRaise1)
|
||
{
|
||
b.Visible = true;
|
||
b.TrySetRetailState(affordable
|
||
? UiButtonStateMachine.Normal
|
||
: UiButtonStateMachine.Ghosted);
|
||
}
|
||
|
||
foreach (var b in allRaise10)
|
||
{
|
||
b.Visible = trained;
|
||
if (trained)
|
||
{
|
||
long cost10 = selectedSkill.Raise10Cost;
|
||
bool affordable10 = !sheet.AwaitingRaise && cost10 > 0 && sheet.UnassignedXp >= cost10;
|
||
b.TrySetRetailState(affordable10
|
||
? UiButtonStateMachine.Normal
|
||
: UiButtonStateMachine.Ghosted);
|
||
}
|
||
}
|
||
}
|
||
|
||
private static void WireRaiseButtonClicks(
|
||
List<UiButton> allRaise1,
|
||
List<UiButton> allRaise10,
|
||
Func<CharacterSheet> data,
|
||
CharacterStatTab[] activeTab,
|
||
int[] attrSel,
|
||
int[] skillSel,
|
||
Func<IReadOnlyList<SkillRowBinding>> skillRows,
|
||
RaiseRequestHandler? onRaiseRequest,
|
||
Action<uint?>? afterRaiseRequest)
|
||
{
|
||
foreach (var button in allRaise1)
|
||
{
|
||
UiButton captured = button;
|
||
captured.OnClick = () => HandleRaiseButtonClick(
|
||
amount: 1, data, activeTab, attrSel, skillSel, skillRows, onRaiseRequest, afterRaiseRequest);
|
||
}
|
||
|
||
foreach (var button in allRaise10)
|
||
{
|
||
UiButton captured = button;
|
||
captured.OnClick = () => HandleRaiseButtonClick(
|
||
amount: 10, data, activeTab, attrSel, skillSel, skillRows, onRaiseRequest, afterRaiseRequest);
|
||
}
|
||
}
|
||
|
||
private static void HandleRaiseButtonClick(
|
||
int amount,
|
||
Func<CharacterSheet> data,
|
||
CharacterStatTab[] activeTab,
|
||
int[] attrSel,
|
||
int[] skillSel,
|
||
Func<IReadOnlyList<SkillRowBinding>> skillRows,
|
||
RaiseRequestHandler? onRaiseRequest,
|
||
Action<uint?>? afterRaiseRequest)
|
||
{
|
||
if (onRaiseRequest is null) return;
|
||
|
||
var sheet = data();
|
||
RaiseRequest? request;
|
||
uint? selectedSkillId = null;
|
||
|
||
if (activeTab[0] == CharacterStatTab.Attributes)
|
||
{
|
||
request = TryBuildAttributeRaiseRequest(sheet, attrSel[0], amount);
|
||
}
|
||
else
|
||
{
|
||
var rows = skillRows();
|
||
CharacterSkill? selectedSkill =
|
||
skillSel[0] >= 0 && skillSel[0] < rows.Count
|
||
? rows[skillSel[0]].Skill
|
||
: null;
|
||
selectedSkillId = selectedSkill?.Id;
|
||
request = TryBuildSkillRaiseRequest(sheet, selectedSkill, amount);
|
||
}
|
||
|
||
if (request is not { } value) return;
|
||
|
||
onRaiseRequest(value, () => afterRaiseRequest?.Invoke(selectedSkillId));
|
||
}
|
||
|
||
private static RaiseRequest? TryBuildAttributeRaiseRequest(
|
||
CharacterSheet sheet,
|
||
int selectedIndex,
|
||
int amount)
|
||
{
|
||
if (selectedIndex < 0) return null;
|
||
|
||
long cost = GetRaiseCost(sheet, selectedIndex, amount);
|
||
if (cost <= 0 || sheet.UnassignedXp < cost) return null;
|
||
|
||
if (selectedIndex < AttrRows.Length)
|
||
return new RaiseRequest(RaiseTargetKind.Attribute, AttrRows[selectedIndex].statId, cost, amount);
|
||
|
||
int vitalIndex = selectedIndex - AttrRows.Length;
|
||
if (vitalIndex >= 0 && vitalIndex < VitalRows.Length)
|
||
return new RaiseRequest(RaiseTargetKind.Vital, VitalRows[vitalIndex].maxStatId, cost, amount);
|
||
|
||
return null;
|
||
}
|
||
|
||
private static RaiseRequest? TryBuildSkillRaiseRequest(
|
||
CharacterSheet sheet,
|
||
CharacterSkill? selectedSkill,
|
||
int amount)
|
||
{
|
||
if (selectedSkill is null) return null;
|
||
|
||
bool trained = selectedSkill.AdvancementClass >= CharacterSkillAdvancementClass.Trained;
|
||
if (!trained)
|
||
{
|
||
if (amount != 1) return null;
|
||
long trainCost = selectedSkill.TrainedCost;
|
||
return trainCost > 0 && sheet.SkillCredits >= trainCost
|
||
? new RaiseRequest(RaiseTargetKind.TrainSkill, selectedSkill.Id, trainCost, amount)
|
||
: null;
|
||
}
|
||
|
||
long raiseCost = amount == 10 ? selectedSkill.Raise10Cost : selectedSkill.RaiseCost;
|
||
return raiseCost > 0 && sheet.UnassignedXp >= raiseCost
|
||
? new RaiseRequest(RaiseTargetKind.Skill, selectedSkill.Id, raiseCost, amount == 10 ? 10 : 1)
|
||
: null;
|
||
}
|
||
|
||
/// <summary>Return the raise cost for row <paramref name="rowIndex"/> from the sheet.
|
||
/// Returns 0 if the cost array is shorter than expected.</summary>
|
||
internal static long GetRaiseCost(CharacterSheet sheet, int rowIndex)
|
||
=> GetRaiseCost(sheet, rowIndex, amount: 1);
|
||
|
||
/// <summary>Return the raise cost for row <paramref name="rowIndex"/> and retail amount.
|
||
/// Returns 0 if the relevant cost array is shorter than expected.</summary>
|
||
internal static long GetRaiseCost(CharacterSheet sheet, int rowIndex, int amount)
|
||
{
|
||
var costs = amount == 10 ? sheet.AttributeRaise10Costs : sheet.AttributeRaiseCosts;
|
||
if (costs is null || rowIndex < 0 || rowIndex >= costs.Length)
|
||
return 0L;
|
||
return costs[rowIndex];
|
||
}
|
||
|
||
/// <summary>Return the display name for the row at <paramref name="index"/>,
|
||
/// or an empty string if the index is out of range.</summary>
|
||
internal static string GetRowName(int index)
|
||
{
|
||
if (index < 0) return string.Empty;
|
||
if (index < AttrRows.Length) return AttrRows[index].name;
|
||
int vi = index - AttrRows.Length;
|
||
if (vi < VitalRows.Length) return VitalRows[vi].name;
|
||
return string.Empty;
|
||
}
|
||
|
||
/// <summary>Return the numeric value for the row at <paramref name="index"/>.</summary>
|
||
internal static string GetRowValueString(CharacterSheet sheet, int index)
|
||
{
|
||
return index switch
|
||
{
|
||
0 => sheet.Strength.ToString(),
|
||
1 => sheet.Endurance.ToString(),
|
||
2 => sheet.Coordination.ToString(),
|
||
3 => sheet.Quickness.ToString(),
|
||
4 => sheet.Focus.ToString(),
|
||
5 => sheet.Self.ToString(),
|
||
6 => $"{sheet.HealthCurrent}/{sheet.HealthMax}",
|
||
7 => $"{sheet.StaminaCurrent}/{sheet.StaminaMax}",
|
||
8 => $"{sheet.ManaCurrent}/{sheet.ManaMax}",
|
||
_ => string.Empty,
|
||
};
|
||
}
|
||
|
||
// ── Issue #267 — retail vitae/buff delta parenthetical ───────────────────
|
||
// Format cited from gmAttributeUI::DisplaySelectionFooter_Attribute
|
||
// (0x0049d280, format " (%s%d)") and gmSkillUI::DisplaySelectionFooter_Trained
|
||
// (0x0049b860, vitae segment format " (%d)" via SkillInfoRegion::
|
||
// GetVitaeModifier 0x004f0fa0, buff segment " (%s%d)"). Retail colors each
|
||
// segment independently (AppendTextWithFont state 1/2/3); our UiText footer
|
||
// title element renders OneLine/single-color, so segments here share the
|
||
// title's white color rather than retail's per-run tint — a presentation
|
||
// simplification, not a value/format deviation.
|
||
|
||
/// <summary>Effective value for attribute/vital row <paramref name="index"/>
|
||
/// as an int (numeric twin of <see cref="GetRowValueString"/> for the 6
|
||
/// primary-attribute rows only — vitals use "cur/max" and have no single
|
||
/// effective int).</summary>
|
||
private static int GetRowEffectiveAttributeValue(CharacterSheet sheet, int index) => index switch
|
||
{
|
||
0 => sheet.Strength,
|
||
1 => sheet.Endurance,
|
||
2 => sheet.Coordination,
|
||
3 => sheet.Quickness,
|
||
4 => sheet.Focus,
|
||
5 => sheet.Self,
|
||
_ => 0,
|
||
};
|
||
|
||
/// <summary>Buff-only delta (effective − base) for the primary-attribute
|
||
/// row at <paramref name="index"/>. Zero for vital rows (6-8) — vitals use
|
||
/// a separate "cur/max" footer format in retail
|
||
/// (<c>gmAttributeUI::DisplaySelectionFooter_Vital</c> 0x0049d6b0), not
|
||
/// this delta pattern.</summary>
|
||
internal static int GetAttributeDelta(CharacterSheet sheet, int index)
|
||
{
|
||
if ((uint)index >= (uint)AttrRows.Length) return 0;
|
||
int[] baseValues = sheet.AttributeBaseValues;
|
||
if (baseValues is null || index >= baseValues.Length) return 0;
|
||
return GetRowEffectiveAttributeValue(sheet, index) - baseValues[index];
|
||
}
|
||
|
||
/// <summary>Skill's buff-only delta, excluding vitae — retail computes
|
||
/// this as <c>(current − vitaeModifier) − base</c>
|
||
/// (<c>gmSkillUI::DisplaySelectionFooter_Trained</c> 0x0049b860): vitae's
|
||
/// own contribution is reported separately via
|
||
/// <see cref="FormatVitaeDelta"/>.</summary>
|
||
internal static int GetSkillBuffOnlyDelta(CharacterSkill skill) =>
|
||
(skill.CurrentLevel - skill.VitaeModifier) - skill.BaseLevel;
|
||
|
||
/// <summary>Retail " (%s%d)" buff-delta parenthetical: an explicit "+"
|
||
/// prefix on an increase (the natural %d has no leading plus), the
|
||
/// natural minus sign on a decrease (no prefix string set in that
|
||
/// branch), nothing when the delta is zero.</summary>
|
||
/// <remarks>Invariant, not the machine's culture: retail's minus is the
|
||
/// ASCII '-', and several European cultures (sv-SE among them) render a
|
||
/// negative integer with U+2212 MINUS SIGN, so a Swedish player would
|
||
/// otherwise read "(−20)" where retail shows "(-20)".</remarks>
|
||
private static string FormatBuffDelta(int delta) => delta switch
|
||
{
|
||
0 => string.Empty,
|
||
> 0 => string.Create(CultureInfo.InvariantCulture, $" (+{delta})"),
|
||
_ => string.Create(CultureInfo.InvariantCulture, $" ({delta})"),
|
||
};
|
||
|
||
/// <summary>Retail " (%d)" vitae-specific parenthetical
|
||
/// (<c>SkillInfoRegion::GetVitaeModifier</c> 0x004f0fa0): shown only
|
||
/// while vitae is an active penalty (modifier < 0) — vitae never
|
||
/// grants a bonus, so no "+" case exists.</summary>
|
||
private static string FormatVitaeDelta(int vitaeModifier) =>
|
||
vitaeModifier < 0
|
||
? string.Create(CultureInfo.InvariantCulture, $" ({vitaeModifier})")
|
||
: string.Empty;
|
||
|
||
/// <summary>Build the retail footer-title text for the current selection:
|
||
/// "{Name}: {value}" with the vitae + buff-delta parentheticals appended
|
||
/// for a selected trained/specialized skill or a selected attribute row.
|
||
/// Shared by both footer-state bindings (State A/B use physically
|
||
/// separate UiText elements in the imported layout) so the composition
|
||
/// logic lives in exactly one place.</summary>
|
||
private static string BuildSelectedTitleText(
|
||
CharacterStatTab tab,
|
||
Func<CharacterSheet> data,
|
||
int[] attrSel,
|
||
int[] skillSel)
|
||
{
|
||
if (tab == CharacterStatTab.Skills)
|
||
{
|
||
CharacterSkill? skill = SkillAtDisplayIndex(data(), skillSel[0]);
|
||
if (skill is null) return "Select a Skill to Improve";
|
||
if (skill.AdvancementClass < CharacterSkillAdvancementClass.Trained)
|
||
return skill.Name;
|
||
|
||
string vitaeSuffix = FormatVitaeDelta(skill.VitaeModifier);
|
||
string buffSuffix = FormatBuffDelta(GetSkillBuffOnlyDelta(skill));
|
||
return $"{skill.Name}: {skill.CurrentLevel}{vitaeSuffix}{buffSuffix}";
|
||
}
|
||
|
||
if (attrSel[0] < 0) return "Select an Attribute to Improve";
|
||
CharacterSheet sheet = data();
|
||
string name = GetRowName(attrSel[0]);
|
||
string value = GetRowValueString(sheet, attrSel[0]);
|
||
string delta = FormatBuffDelta(GetAttributeDelta(sheet, attrSel[0]));
|
||
return $"{name}: {value}{delta}";
|
||
}
|
||
|
||
private static IReadOnlyList<UiText.TextRun> BuildSelectedTitleRuns(
|
||
UiText target,
|
||
CharacterStatTab tab,
|
||
Func<CharacterSheet> data,
|
||
int[] attrSel,
|
||
int[] skillSel)
|
||
{
|
||
Vector4 Color(int index) =>
|
||
index >= 0 && index < target.FontColorPalette.Count
|
||
? target.FontColorPalette[index]
|
||
: index switch
|
||
{
|
||
1 => RetailBuffGreen,
|
||
2 => RetailDebuffRed,
|
||
3 => RetailVitaeBlue,
|
||
_ => Vector4.One,
|
||
};
|
||
|
||
if (tab == CharacterStatTab.Skills)
|
||
{
|
||
CharacterSkill? skill = SkillAtDisplayIndex(data(), skillSel[0]);
|
||
if (skill is null)
|
||
return [new("Select a Skill to Improve", Body)];
|
||
if (skill.AdvancementClass < CharacterSkillAdvancementClass.Trained)
|
||
return [new(skill.Name, Color(0))];
|
||
|
||
var runs = new List<UiText.TextRun>
|
||
{
|
||
new($"{skill.Name}: {skill.CurrentLevel}", Color(0)),
|
||
};
|
||
if (skill.VitaeModifier < 0)
|
||
runs.Add(new(FormatVitaeDelta(skill.VitaeModifier), Color(3)));
|
||
int buffDelta = GetSkillBuffOnlyDelta(skill);
|
||
if (buffDelta != 0)
|
||
runs.Add(new(
|
||
FormatBuffDelta(buffDelta),
|
||
Color(buffDelta > 0 ? 1 : 2)));
|
||
return runs;
|
||
}
|
||
|
||
if (attrSel[0] < 0)
|
||
return [new("Select an Attribute to Improve", Body)];
|
||
|
||
CharacterSheet sheet = data();
|
||
var attributeRuns = new List<UiText.TextRun>
|
||
{
|
||
new(
|
||
$"{GetRowName(attrSel[0])}: {GetRowValueString(sheet, attrSel[0])}",
|
||
Color(0)),
|
||
};
|
||
int delta = GetAttributeDelta(sheet, attrSel[0]);
|
||
if (delta != 0)
|
||
attributeRuns.Add(new(
|
||
FormatBuffDelta(delta),
|
||
Color(delta > 0 ? 1 : 2)));
|
||
return attributeRuns;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Add a single attribute/vital/skill row to <paramref name="list"/> as a
|
||
/// <see cref="UiClickablePanel"/> containing icon + name + value children,
|
||
/// laid out at the AUTHORED template 0x10000248 pixel geometry (icon
|
||
/// flush left 20x20, name at X=25 W=150, value at X=175 W=100
|
||
/// right-justified — see the row-layout-constants block above). Returns
|
||
/// the panel so the caller can wire <see cref="UiClickablePanel.OnClick"/>.
|
||
///
|
||
/// <para>CT5 hand-built-vs-template ruling: this row stays HAND-BUILT
|
||
/// (not converted to <c>UiTemplateListBox</c> row instantiation).
|
||
/// Converting would touch every one of the ~15 call sites this method's
|
||
/// row feeds — raise-button affordability, footer State A/B, per-row
|
||
/// tooltip, section-header bucketing, live-refresh-on-quality-change,
|
||
/// and the selection-highlight swap — for a purely cosmetic slice whose
|
||
/// authored numbers this hand-built path can already hit byte-exact
|
||
/// (proven by <c>CharacterPanelLiveDatTests.AttributeRowTemplate_...</c>).
|
||
/// That is a large-blast-radius rewrite for a geometry-only fix; the
|
||
/// smaller-risk path is implementing the authored constants directly
|
||
/// here, which this method now does.</para>
|
||
/// </summary>
|
||
private static UiClickablePanel AddRow(
|
||
UiElement list,
|
||
UiDatFont? datFont,
|
||
Func<uint, (uint handle, int w, int h)>? spriteResolve,
|
||
float left, float top, float width, float height,
|
||
uint iconDid,
|
||
string nameText,
|
||
Func<string> valueProvider,
|
||
Func<Vector4>? valueColorProvider = null,
|
||
Vector4? nameColor = null)
|
||
{
|
||
var row = new UiClickablePanel
|
||
{
|
||
// #430: runtime-built rows author no P0x47/P0x48 of their own, and
|
||
// OnTooltipShow refuses a widget without a popup locator — so the
|
||
// Batch-B TooltipText never mounted. Use the shared popup skin,
|
||
// the ONLY locator pair the character layout (0x2100002E) itself
|
||
// references (live-DAT probed 2026-08-24) and the same inference
|
||
// UiItemSlot already ships for runtime-built widgets.
|
||
AuthoredTooltipRootElementId =
|
||
RetailTooltipPresenter.SharedPopupSkinRootElementId,
|
||
AuthoredTooltipLayoutDid =
|
||
RetailTooltipPresenter.SharedPopupSkinLayoutDid,
|
||
Left = left,
|
||
Top = top,
|
||
Width = width,
|
||
Height = height,
|
||
BackgroundColor = Vector4.Zero,
|
||
// CT5 fix round (SHOULD-FIX 1): a freshly-built row starts
|
||
// unselected, so it gets the template's Normal-state media
|
||
// (RowNormalSprite) up front — matching HandleRowClick's/
|
||
// ApplySkillSelectionVisuals' unselected branch exactly, so a
|
||
// row never flashes transparent before its first click.
|
||
BackgroundSprite = spriteResolve is not null ? RowNormalSprite : 0u,
|
||
SpriteResolve = spriteResolve,
|
||
BorderColor = Vector4.Zero,
|
||
Anchors = AnchorEdges.Left | AnchorEdges.Top,
|
||
};
|
||
|
||
// Icon 0x10000129: flush left (X=0), Y=0, 20x20 — full row height,
|
||
// no vertical centering math needed since RowIconSize == RowHeight.
|
||
var iconEl = new UiText
|
||
{
|
||
Left = RowIconX,
|
||
Top = 0f,
|
||
Width = RowIconSize,
|
||
Height = RowIconSize,
|
||
ClickThrough = true,
|
||
DatFont = null,
|
||
BackgroundSprite = spriteResolve is not null ? iconDid : 0u,
|
||
SpriteResolve = spriteResolve is not null
|
||
? id => { var (h, w, ht) = spriteResolve(id); return (h, w, ht); }
|
||
: null,
|
||
LinesProvider = static () => Array.Empty<UiText.Line>(),
|
||
Anchors = AnchorEdges.Left | AnchorEdges.Top,
|
||
};
|
||
|
||
// Name 0x1000012A: X=25 W=150 — fixed authored pixels, not a
|
||
// width-relative fraction.
|
||
string capturedName = nameText;
|
||
Vector4 capturedNameColor = nameColor ?? Body;
|
||
var nameEl = new UiText
|
||
{
|
||
Left = RowNameX,
|
||
Top = 0f,
|
||
Width = RowNameW,
|
||
Height = height,
|
||
DatFont = datFont,
|
||
ClickThrough = true,
|
||
Centered = false,
|
||
RightAligned = false,
|
||
// CT5 fix round (SHOULD-FIX 3): the authored template's
|
||
// 0x1000012A name column carries no margin property at all —
|
||
// Padding=1f re-created the X=26 glyph start (RowNameX + 1) this
|
||
// very slice existed to remove (see AttributeRowTemplate_...'s
|
||
// pin: name.X == 25f, no margin). 0f matches the value column's
|
||
// own (already-0) Padding.
|
||
Padding = 0f,
|
||
// CT5 fix round (NOTE a): both the name and value columns author
|
||
// VJustify=Center in the template (0x1000012A/0x1000012B share
|
||
// the same default UiText.VerticalJustify). OneLine=true routes
|
||
// this element through the same single-line vertical-centering
|
||
// draw path the value column below already uses instead of the
|
||
// multi-line/scroll path, which does not honor VerticalJustify
|
||
// the same way.
|
||
OneLine = true,
|
||
Anchors = AnchorEdges.Left | AnchorEdges.Top,
|
||
};
|
||
nameEl.LinesProvider = () => new[] { new UiText.Line(capturedName, capturedNameColor) };
|
||
|
||
// Value 0x1000012B: X=175 W=100, right-justified — its right edge
|
||
// (275) sits 7px short of the row's own 282px right edge, the
|
||
// authored gutter the owner reported.
|
||
var valueEl = new UiText
|
||
{
|
||
Left = RowValueX,
|
||
Top = 0f,
|
||
Width = RowValueW,
|
||
Height = height,
|
||
DatFont = datFont,
|
||
ClickThrough = true,
|
||
RightAligned = true,
|
||
OneLine = true,
|
||
Anchors = AnchorEdges.Left | AnchorEdges.Top,
|
||
};
|
||
var capturedProvider = valueProvider;
|
||
valueEl.LinesProvider = () => new[] { new UiText.Line(capturedProvider(), valueColorProvider?.Invoke() ?? Body) };
|
||
|
||
row.AddChild(iconEl);
|
||
row.AddChild(nameEl);
|
||
row.AddChild(valueEl);
|
||
list.AddChild(row);
|
||
return row;
|
||
}
|
||
|
||
// ── Footer — dynamic (State A + State B via sel[]) ────────────────────────
|
||
|
||
/// <summary>
|
||
/// Bind all 5 footer elements with providers that close over <paramref name="sel"/>:
|
||
/// when <c>sel[0] == -1</c> (nothing selected) they emit State-A content;
|
||
/// when a row is selected they emit State-B content.
|
||
///
|
||
/// <para><b>State A</b> (DisplayDefaultFooter 0x0049cde0):
|
||
/// title = "Select an Attribute to Improve"; line-1 label = "Skill Credits Available:";
|
||
/// line-1 value = SkillCredits; line-2 label = "Unassigned Experience:";
|
||
/// line-2 value = UnassignedXp.</para>
|
||
///
|
||
/// <para><b>State B</b> (attribute selected):
|
||
/// title = "{AttrName}: {value}" (e.g. "Focus: 10"); line-1 label = "Experience To Raise:";
|
||
/// line-1 value = raise cost; line-2 label = "Unassigned Experience:";
|
||
/// line-2 value = UnassignedXp.</para>
|
||
///
|
||
/// <para>Footer element ids appear in THREE footer-state groups (0x10000240 State A,
|
||
/// 0x10000241 State B, 0x10000247 State C). <c>_byId</c> stores the LAST duplicate,
|
||
/// which is the State B/C copy with narrower labels (145px vs 195px in State A). To get
|
||
/// the correct wide-label State A elements, we find the State A container directly and
|
||
/// walk to the child elements by id within that subtree.</para>
|
||
/// </summary>
|
||
private static void BindFooterDynamic(
|
||
ImportedLayout layout,
|
||
UiDatFont? datFont,
|
||
Func<CharacterSheet> data,
|
||
CharacterStatTab[] activeTab,
|
||
int[] attrSel,
|
||
int[] skillSel,
|
||
UiElement? contentPage = null)
|
||
{
|
||
// Walk the State A container (0x10000240) to find the wide-label copies of the
|
||
// footer child elements. The 5 children of 0x10000240 at their LOCAL coords:
|
||
// title (0x1000024E): Left=0, Top=0, W=300, H=55
|
||
// line-1 label (0x10000242): Left=5, Top=20, W=195 (State A wide) vs 145 (B/C narrow)
|
||
// line-1 value (0x10000243): Left=200, Top=20, W=95
|
||
// line-2 label (0x10000244): Left=5, Top=37, W=195 (State A wide) vs 145 (B/C narrow)
|
||
// line-2 value (0x10000245): Left=200, Top=37, W=95
|
||
// The State B/C copies use narrower labels (145px) to accommodate raise buttons.
|
||
//
|
||
// IMPORTANT: The footer state id (0x10000240) appears once per tab-page sub-layout
|
||
// (Attributes / Skills / Titles). layout._byId stores only the LAST registered copy,
|
||
// which ends up in the LAST-imported tab page (Titles). The page-visibility pass
|
||
// hides the Titles page → the bound footer elements would be invisible.
|
||
//
|
||
// Fix: find State A/B inside the explicit Attributes page, not via _byId. The
|
||
// id dictionary stores the last duplicate and can point at a hidden Skills/Titles
|
||
// page copy.
|
||
UiElement? stateA = contentPage is not null
|
||
? FindInSubtree(contentPage, static el => el is UiDatElement d && d.ElementId == FooterStateAId)
|
||
: null;
|
||
UiElement? stateB = contentPage is not null
|
||
? FindInSubtree(contentPage, static el => el is UiDatElement d && d.ElementId == FooterStateBId)
|
||
: null;
|
||
// Fallback: layout._byId (test layouts with a single page).
|
||
stateA ??= layout.FindElement(FooterStateAId);
|
||
stateB ??= layout.FindElement(FooterStateBId);
|
||
|
||
// Position-based lookup within stateA's children.
|
||
// If stateA is null or the element isn't found, falls back to layout._byId.
|
||
UiText? ByPos(float top, float left, uint fallbackId)
|
||
{
|
||
if (stateA is not null)
|
||
{
|
||
foreach (var c in stateA.Children)
|
||
if (c is UiText t
|
||
&& Math.Abs(c.Top - top) < 1f
|
||
&& Math.Abs(c.Left - left) < 1f)
|
||
return t;
|
||
}
|
||
return layout.FindElement(fallbackId) as UiText;
|
||
}
|
||
|
||
// Title (Top=0, Left=0): State A = "Select an Attribute to Improve"; State B = "{name}: {value}"
|
||
// Clear BackgroundSprite on the title element: it is H=55 (full footer height) in the dat
|
||
// and its background sprite would cover the line-1/line-2 elements at local y=20/37.
|
||
// The controller owns the footer visual; the dat sprite is superfluous here.
|
||
var titleEl = ByPos(0f, 0f, FooterTitleId);
|
||
if (titleEl is not null)
|
||
{
|
||
// The dat title element is H=55 (the full footer box). The dat says VJustify=Center, so
|
||
// without an override the text would center vertically in the 55px box, overlapping
|
||
// line-1/line-2 below. We set VerticalJustify=Top explicitly so the text renders at the
|
||
// top of the 55px box (y≈Padding), keeping all three footer lines non-overlapping.
|
||
// The dat says HJustify=Center (Centered=true from BuildText) — the title is centered.
|
||
// BackgroundSprite cleared: its full-height sprite would cover line-1/line-2.
|
||
titleEl.BackgroundSprite = 0;
|
||
titleEl.VerticalJustify = VJustify.Top; // dat says Center; override to Top (see comment above)
|
||
titleEl.OneLine = true;
|
||
}
|
||
// Title (FooterTitle 0x1000024E, 20px from dat): pass null → keep dat font.
|
||
// Fix C: the dat has a 20px font for the footer title. Let it drive.
|
||
if (titleEl is not null)
|
||
{
|
||
// DatFont: null → keep the build-time dat font (20px in studio, global fallback in live game).
|
||
// Centered=true comes from the dat (HJustify=Center) via BuildText — not overridden here.
|
||
// RightAligned stays false (BuildText default for a Center element).
|
||
titleEl.ClickThrough = true;
|
||
titleEl.RunsProvider = () => BuildSelectedTitleRuns(
|
||
titleEl,
|
||
activeTab[0],
|
||
data,
|
||
attrSel,
|
||
skillSel);
|
||
titleEl.LinesProvider = () =>
|
||
{
|
||
string title = BuildSelectedTitleText(activeTab[0], data, attrSel, skillSel);
|
||
bool nothingSelected = activeTab[0] == CharacterStatTab.Skills
|
||
? SkillAtDisplayIndex(data(), skillSel[0]) is null
|
||
: attrSel[0] < 0;
|
||
// State B title is WHITE (retail confirmed); the "nothing
|
||
// selected" prompt keeps the dimmer Body color.
|
||
return new[] { new UiText.Line(title, nothingSelected ? Body : Vector4.One) };
|
||
};
|
||
}
|
||
|
||
// Footer lines (all dat-origin with their own font sizes): pass null → keep dat font.
|
||
var l1L = ByPos(20f, 5f, FooterLine1Label);
|
||
LabelProvider(l1L, null, Body, () =>
|
||
{
|
||
if (activeTab[0] == CharacterStatTab.Skills)
|
||
{
|
||
var skill = SkillAtDisplayIndex(data(), skillSel[0]);
|
||
if (skill is null) return "Skill Credits Available:";
|
||
return skill.AdvancementClass >= CharacterSkillAdvancementClass.Trained
|
||
? "Experience To Raise:"
|
||
: "Skill Credits To Raise:";
|
||
}
|
||
|
||
return attrSel[0] < 0 ? "Skill Credits Available:" : "Experience To Raise:";
|
||
});
|
||
|
||
var l1V = ByPos(20f, 200f, FooterLine1Value);
|
||
LabelProvider(l1V, null, Body, () =>
|
||
{
|
||
var sheet = data();
|
||
if (activeTab[0] == CharacterStatTab.Skills)
|
||
{
|
||
var skill = SkillAtDisplayIndex(sheet, skillSel[0]);
|
||
if (skill is null) return sheet.SkillCredits.ToString();
|
||
long skillCost = skill.AdvancementClass >= CharacterSkillAdvancementClass.Trained
|
||
? skill.RaiseCost
|
||
: skill.TrainedCost;
|
||
return skillCost > 0 ? FormatXp(skillCost) : "Infinity!";
|
||
}
|
||
|
||
if (attrSel[0] < 0) return sheet.SkillCredits.ToString();
|
||
long cost = GetRaiseCost(sheet, attrSel[0]);
|
||
return cost > 0 ? FormatXp(cost) : "Infinity!";
|
||
});
|
||
|
||
// Line-2 elements: pass null → keep dat font.
|
||
var l2L = ByPos(37f, 5f, FooterLine2Label);
|
||
LabelProvider(l2L, null, Body, () =>
|
||
{
|
||
if (activeTab[0] == CharacterStatTab.Skills)
|
||
{
|
||
var skill = SkillAtDisplayIndex(data(), skillSel[0]);
|
||
if (skill is not null && skill.AdvancementClass < CharacterSkillAdvancementClass.Trained)
|
||
return "Skill Credits Available:";
|
||
}
|
||
return "Unassigned Experience:";
|
||
});
|
||
|
||
var l2V = ByPos(37f, 200f, FooterLine2Value);
|
||
LabelProvider(l2V, null, Body, () =>
|
||
{
|
||
var sheet = data();
|
||
if (activeTab[0] == CharacterStatTab.Skills)
|
||
{
|
||
var skill = SkillAtDisplayIndex(sheet, skillSel[0]);
|
||
if (skill is not null && skill.AdvancementClass < CharacterSkillAdvancementClass.Trained)
|
||
return sheet.SkillCredits.ToString();
|
||
}
|
||
return FormatXp(sheet.UnassignedXp);
|
||
});
|
||
|
||
BindSelectedFooterState(stateB);
|
||
|
||
UiText? TextById(UiElement? state, uint id)
|
||
=> state is null
|
||
? null
|
||
: FindInSubtree(state, el => el is UiText t && t.ElementId == id) as UiText;
|
||
|
||
void BindSelectedFooterState(UiElement? state)
|
||
{
|
||
var title = TextById(state, FooterTitleId);
|
||
if (title is not null)
|
||
{
|
||
title.BackgroundSprite = 0;
|
||
title.VerticalJustify = VJustify.Top;
|
||
title.ClickThrough = true;
|
||
title.LinesProvider = () =>
|
||
{
|
||
string titleText = BuildSelectedTitleText(activeTab[0], data, attrSel, skillSel);
|
||
bool nothingSelected = activeTab[0] == CharacterStatTab.Skills
|
||
? SkillAtDisplayIndex(data(), skillSel[0]) is null
|
||
: attrSel[0] < 0;
|
||
return new[] { new UiText.Line(titleText, nothingSelected ? Body : Vector4.One) };
|
||
};
|
||
}
|
||
|
||
LabelProvider(TextById(state, FooterLine1Label), null, Body, () =>
|
||
{
|
||
if (activeTab[0] == CharacterStatTab.Skills)
|
||
{
|
||
var skill = SkillAtDisplayIndex(data(), skillSel[0]);
|
||
if (skill is null) return "Skill Credits Available:";
|
||
return skill.AdvancementClass >= CharacterSkillAdvancementClass.Trained
|
||
? "Experience To Raise:"
|
||
: "Skill Credits To Raise:";
|
||
}
|
||
|
||
return attrSel[0] < 0 ? "Skill Credits Available:" : "Experience To Raise:";
|
||
});
|
||
|
||
LabelProvider(TextById(state, FooterLine1Value), null, Body, () =>
|
||
{
|
||
var sheet = data();
|
||
if (activeTab[0] == CharacterStatTab.Skills)
|
||
{
|
||
var skill = SkillAtDisplayIndex(sheet, skillSel[0]);
|
||
if (skill is null) return sheet.SkillCredits.ToString();
|
||
long skillCost = skill.AdvancementClass >= CharacterSkillAdvancementClass.Trained
|
||
? skill.RaiseCost
|
||
: skill.TrainedCost;
|
||
return skillCost > 0 ? FormatXp(skillCost) : "Infinity!";
|
||
}
|
||
|
||
if (attrSel[0] < 0) return sheet.SkillCredits.ToString();
|
||
long cost = GetRaiseCost(sheet, attrSel[0]);
|
||
return cost > 0 ? FormatXp(cost) : "Infinity!";
|
||
});
|
||
|
||
LabelProvider(TextById(state, FooterLine2Label), null, Body, () =>
|
||
{
|
||
if (activeTab[0] == CharacterStatTab.Skills)
|
||
{
|
||
var skill = SkillAtDisplayIndex(data(), skillSel[0]);
|
||
if (skill is not null && skill.AdvancementClass < CharacterSkillAdvancementClass.Trained)
|
||
return "Skill Credits Available:";
|
||
}
|
||
return "Unassigned Experience:";
|
||
});
|
||
|
||
LabelProvider(TextById(state, FooterLine2Value), null, Body, () =>
|
||
{
|
||
var sheet = data();
|
||
if (activeTab[0] == CharacterStatTab.Skills)
|
||
{
|
||
var skill = SkillAtDisplayIndex(sheet, skillSel[0]);
|
||
if (skill is not null && skill.AdvancementClass < CharacterSkillAdvancementClass.Trained)
|
||
return sheet.SkillCredits.ToString();
|
||
}
|
||
return FormatXp(sheet.UnassignedXp);
|
||
});
|
||
}
|
||
}
|
||
|
||
// ── Helpers ──────────────────────────────────────────────────────────────
|
||
|
||
private static void SetCompatibilityAnchorsAllById(
|
||
UiElement node,
|
||
uint targetId,
|
||
AnchorEdges anchors)
|
||
{
|
||
if (node is UiDatElement d
|
||
&& d.ElementId == targetId
|
||
&& node.LayoutPolicy is null)
|
||
{
|
||
node.Anchors = anchors;
|
||
}
|
||
foreach (var child in node.Children)
|
||
SetCompatibilityAnchorsAllById(child, targetId, anchors);
|
||
}
|
||
|
||
/// <summary>Depth-first search of <paramref name="node"/> and its descendants.
|
||
/// Returns the first element for which <paramref name="predicate"/> returns true,
|
||
/// or null if none found.</summary>
|
||
private static UiElement? FindInSubtree(UiElement node, Func<UiElement, bool> predicate)
|
||
{
|
||
if (predicate(node)) return node;
|
||
foreach (var child in node.Children)
|
||
{
|
||
var found = FindInSubtree(child, predicate);
|
||
if (found is not null) return found;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
private static bool HasDatElementId(UiElement element, uint id)
|
||
=> DatElementId(element) == id;
|
||
|
||
private static uint DatElementId(UiElement element)
|
||
{
|
||
// DatWidgetFactory assigns the canonical id on every imported widget,
|
||
// including behavioral types such as UiScrollbar that do not expose a
|
||
// type-specific ElementId property. Prefer it so subtree role lookup is
|
||
// independent of the concrete widget class. The fallbacks keep the small
|
||
// hand-built controller fixtures working when they construct widgets
|
||
// directly instead of through the factory.
|
||
if (element.DatElementId != 0u)
|
||
return element.DatElementId;
|
||
|
||
return element switch
|
||
{
|
||
UiDatElement datElement => datElement.ElementId,
|
||
UiButton button => button.ElementId,
|
||
UiMeter meter => meter.ElementId,
|
||
UiText text => text.ElementId,
|
||
_ => 0u,
|
||
};
|
||
}
|
||
|
||
private static UiElement? FindElementByDatId(ImportedLayout layout, UiElement? scope, uint id)
|
||
{
|
||
if (scope is not null)
|
||
{
|
||
var scoped = FindInSubtree(scope, el => HasDatElementId(el, id));
|
||
if (scoped is not null)
|
||
return scoped;
|
||
}
|
||
|
||
return layout.FindElement(id);
|
||
}
|
||
|
||
private static UiText? FindTextByDatId(ImportedLayout layout, UiElement? scope, uint id)
|
||
=> FindElementByDatId(layout, scope, id) as UiText;
|
||
|
||
private static UiElement? FindDirectChildById(UiElement? root, uint id)
|
||
{
|
||
if (root is null) return null;
|
||
foreach (var child in root.Children)
|
||
{
|
||
if (DatElementId(child) == id)
|
||
return child;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
private static void CollectElementsByDatId(UiElement node, uint id, List<UiElement> result)
|
||
{
|
||
if (DatElementId(node) == id)
|
||
result.Add(node);
|
||
foreach (var child in node.Children)
|
||
CollectElementsByDatId(child, id, result);
|
||
}
|
||
|
||
private static void Label(ImportedLayout layout, uint id, UiDatFont? datFont, Vector4 color, Func<string> text)
|
||
=> Label(layout, null, id, datFont, color, text);
|
||
|
||
private static void Label(ImportedLayout layout, UiElement? scope, uint id, UiDatFont? datFont, Vector4 color, Func<string> text)
|
||
{
|
||
if (FindTextByDatId(layout, scope, id) is UiText t)
|
||
{
|
||
// Null = keep whatever the importer (dat FontDid resolver) set at build time.
|
||
// Non-null = controller explicit override.
|
||
if (datFont is not null) t.DatFont = datFont;
|
||
t.Centered = true;
|
||
t.OneLine = true;
|
||
t.ClickThrough = true;
|
||
t.LinesProvider = () => new[] { new UiText.Line(text(), color) };
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Retail-equivalent of <c>ExperienceSystem::XPToString</c>
|
||
/// (<c>sprintf("%I64d", value)</c> → <c>GetNumberFormatA</c>'s
|
||
/// locale-grouped-decimal formatting) — shared by every field that
|
||
/// formats a retail XP-shaped 64-bit count: Total XP, XP-to-next-level,
|
||
/// and (CT4 fix round, 2026-08-25) the luminance available/maximum pair.
|
||
/// Retail text is US-formatted for everyone (the project's locale-
|
||
/// independence rule), so this is <c>InvariantCulture</c>, not
|
||
/// <c>CurrentCulture</c> — the pre-CT4-fix-round call sites used a bare
|
||
/// <c>.ToString("N0")</c>, which silently followed the host OS locale.
|
||
/// </summary>
|
||
private static string FormatXp(long value) => value.ToString("N0", CultureInfo.InvariantCulture);
|
||
|
||
/// <summary>
|
||
/// Same binding shape as <see cref="Label"/>, but the per-line color is
|
||
/// read from the widget's own <see cref="UiText.DefaultColor"/> — the
|
||
/// value <c>DatWidgetFactory.BuildText</c> already seeded from the
|
||
/// element's authored dat property 0x1B — instead of a caller-supplied
|
||
/// constant. Campaign CT slice CT4 (2026-08-24): the header identity
|
||
/// block's four elements (Name/Heritage/PkStatus/Level) all carry their
|
||
/// own correct authored color (CT1's live-DAT pin), so "authored color
|
||
/// wins" here is both simpler and more correct than hand-picking a
|
||
/// runtime constant — the same precedent
|
||
/// <see cref="CharacterTitlesController"/>'s row/display text already
|
||
/// set (<c>rowText.DefaultColor</c>). CT4 fix-round consistency note
|
||
/// (2026-08-25): this helper unconditionally forces
|
||
/// <c>Centered = true</c>/<c>OneLine = true</c>, which is correct for
|
||
/// the four elements it is actually called on (Name/Heritage/PkStatus/
|
||
/// Level, all centered in the DAT), but would be WRONG for a
|
||
/// left/right-justified authored element (e.g. the luminance pair,
|
||
/// which is deliberately bound with its own inline LinesProvider below
|
||
/// rather than through this helper, precisely to preserve its authored
|
||
/// Left/Right justification). Left as-is rather than parameterizing
|
||
/// Centered/OneLine, since no current caller needs the non-centered
|
||
/// case — a future caller that does should not reuse this helper as-is.
|
||
/// </summary>
|
||
private static void LabelAuthoredColor(ImportedLayout layout, UiElement? scope, uint id, UiDatFont? datFont, Func<string> text)
|
||
{
|
||
if (FindTextByDatId(layout, scope, id) is UiText t)
|
||
{
|
||
if (datFont is not null) t.DatFont = datFont;
|
||
t.Centered = true;
|
||
t.OneLine = true;
|
||
t.ClickThrough = true;
|
||
t.LinesProvider = () => new[] { new UiText.Line(text(), t.DefaultColor) };
|
||
}
|
||
}
|
||
|
||
/// <summary>Two-line centered label. Provides TWO lines from LinesProvider so both
|
||
/// fit side-by-side in a narrow element without truncation. The scroll path in
|
||
/// <see cref="UiText"/> renders multiple lines oldest-first (top-to-bottom), so
|
||
/// line 0 = <paramref name="line1"/> (top) and line 1 = <paramref name="line2"/> (bottom).
|
||
/// This replaces the single-line "Character Level" caption which truncated in the 65px element.
|
||
/// Source: retail spec (2026-06-26-character-window-retail-reference.md §State 1 level caption).</summary>
|
||
private static void LabelTwoLine(ImportedLayout layout, uint id, UiDatFont? datFont, Vector4 color,
|
||
string line1, string line2)
|
||
=> LabelTwoLine(layout, null, id, datFont, color, line1, line2);
|
||
|
||
private static void LabelTwoLine(ImportedLayout layout, UiElement? scope, uint id, UiDatFont? datFont, Vector4 color,
|
||
string line1, string line2)
|
||
{
|
||
if (FindTextByDatId(layout, scope, id) is UiText t)
|
||
{
|
||
// Null = keep whatever the importer (dat FontDid resolver) set at build time.
|
||
if (datFont is not null) t.DatFont = datFont;
|
||
t.Centered = false; // non-Centered → scroll/multi-line path
|
||
t.RightAligned = false;
|
||
t.ClickThrough = true;
|
||
t.Padding = 1f;
|
||
t.LinesProvider = () => new[]
|
||
{
|
||
new UiText.Line(line1, color),
|
||
new UiText.Line(line2, color),
|
||
};
|
||
}
|
||
}
|
||
|
||
/// <summary>Left-justified label (for captions that should be left-aligned, not centered).
|
||
/// Padding=0 so a single dat-font line (≈12px) fits cleanly in a small element without
|
||
/// being clipped by the bottom-pin scroll math (top=Padding, bottom=H-Padding).</summary>
|
||
private static void LabelLeft(ImportedLayout layout, uint id, UiDatFont? datFont, Vector4 color, Func<string> text)
|
||
=> LabelLeft(layout, null, id, datFont, color, text);
|
||
|
||
private static void LabelLeft(ImportedLayout layout, UiElement? scope, uint id, UiDatFont? datFont, Vector4 color, Func<string> text)
|
||
{
|
||
if (FindTextByDatId(layout, scope, id) is UiText t)
|
||
{
|
||
// Null = keep whatever the importer (dat FontDid resolver) set at build time.
|
||
if (datFont is not null) t.DatFont = datFont;
|
||
t.Centered = false;
|
||
t.RightAligned = false;
|
||
t.ClickThrough = true;
|
||
t.Padding = 0f; // avoid scroll clip in small-height header elements
|
||
t.LinesProvider = () => new[] { new UiText.Line(text(), color) };
|
||
}
|
||
}
|
||
|
||
/// <summary>Right-justified numeric header value. The imported layout owns
|
||
/// bounds/font; the controller owns runtime text and explicit justification.</summary>
|
||
private static void LabelRight(
|
||
ImportedLayout layout,
|
||
UiElement? scope,
|
||
uint id,
|
||
UiDatFont? datFont,
|
||
Vector4 color,
|
||
Func<string> text)
|
||
{
|
||
if (FindTextByDatId(layout, scope, id) is UiText t)
|
||
{
|
||
if (datFont is not null) t.DatFont = datFont;
|
||
t.Centered = false;
|
||
t.RightAligned = true;
|
||
t.OneLine = true;
|
||
t.ClickThrough = true;
|
||
t.Padding = 0f;
|
||
t.LinesProvider = () => new[] { new UiText.Line(text(), color) };
|
||
}
|
||
}
|
||
|
||
/// <summary>Bind a directly-located <see cref="UiText"/> widget with a provider.
|
||
/// Used when the widget was found by subtree walk rather than <c>FindElement</c>.
|
||
/// Sets <c>Padding = 0</c> to prevent the scroll-clip from hiding text in small
|
||
/// (H≈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>
|
||
/// Depth-first tree walk to collect every <see cref="UiButton"/> that was registered
|
||
/// in <paramref name="layout"/> under the given dat element <paramref name="id"/>.
|
||
///
|
||
/// <para>
|
||
/// The standard <see cref="ImportedLayout.FindElement"/> returns only the LAST widget
|
||
/// registered for a given id; for elements duplicated across tab-page sub-layouts
|
||
/// (raise buttons, close buttons) we need ALL copies so that visibility changes are
|
||
/// reflected in every page — not just the last-mounted one.
|
||
/// </para>
|
||
///
|
||
/// <para>
|
||
/// Implementation: walk the entire <paramref name="root"/> tree calling
|
||
/// <see cref="ImportedLayout.FindElement"/> is not enough (it uses a dict). Instead we
|
||
/// exploit the fact that identical element ids produce widgets that all share the SAME
|
||
/// <see cref="UiButton"/> instance in <c>_byId</c> for THEIR copy; but siblings from
|
||
/// different inheritance mounts are SEPARATE instances not in the same <c>_byId</c> slot.
|
||
/// We therefore walk the tree recursively and collect every <see cref="UiButton"/> whose
|
||
/// <c>ActiveState</c> reflects the dat default (before our code sets it), which is not a
|
||
/// reliable discriminator. Instead, we gather ALL <see cref="UiButton"/> instances from
|
||
/// the subtree at the known spatial position (bottom of the panel) — but positions can
|
||
/// overlap across pages.
|
||
/// </para>
|
||
///
|
||
/// <para>
|
||
/// The correct approach: since <c>_byId</c> stores only one instance per id, we use the
|
||
/// <see cref="ImportedLayout.FindElement"/> for the canonical id, then do a FULL tree walk
|
||
/// to find ADDITIONAL <see cref="UiButton"/> instances that have identical Width×Height to
|
||
/// the known button. This works because the three page copies share the same dat template
|
||
/// and thus the same geometry. Collected via reference-equality guard to avoid duplicates.
|
||
/// </para>
|
||
/// </summary>
|
||
// Match by ElementId, not geometry: the x1 and x10 raise buttons are both 30x26.
|
||
private static void CollectButtonsById(
|
||
UiElement node,
|
||
uint targetId,
|
||
List<UiButton> result,
|
||
ImportedLayout layout)
|
||
{
|
||
// Find the canonical copy (last registered in _byId) as the geometry reference.
|
||
_ = layout;
|
||
|
||
// Walk the tree and collect ALL UiButton instances matching the canonical geometry.
|
||
// The canonical copy itself will also be found — that's fine; use a HashSet to dedup.
|
||
var seen = new HashSet<UiButton>(ReferenceEqualityComparer.Instance);
|
||
CollectMatchingButtons(node, targetId, seen, result);
|
||
}
|
||
|
||
private static void CollectMatchingButtons(
|
||
UiElement node,
|
||
uint targetId,
|
||
HashSet<UiButton> seen,
|
||
List<UiButton> result)
|
||
{
|
||
if (node is UiButton btn && btn.ElementId == targetId && seen.Add(btn))
|
||
{
|
||
result.Add(btn);
|
||
}
|
||
foreach (var child in node.Children)
|
||
CollectMatchingButtons(child, targetId, seen, result);
|
||
}
|
||
|
||
}
|