acdream/src/AcDream.App/UI/UiButton.cs
Erik 942a02af11 fix(ui): morning gate — map town markers: green rollover highlight + the authored map-note tooltip skin/font
User finding 3 (retail screenshot): hovering a town on the Map tab turns
its marker GREEN and shows the name on a special-font tooltip — clearly
not our generic 0x10000395 popup skin, and we had no hover highlight at
all.

Re-derivation (live-DAT probe + raw ElementDesc dump + surface
byte-decode; MapNoteLiveDatTests pins all of it):

- m_pMap (0x100001EC)'s P0x47/P0x48 = 0x100001F0 @ 0x21000026 are the
  note CONSTRUCTION template (AddMapNote @0x004a1bb0's
  CreateChildElement args) — that part we had right.
- The TEMPLATE's own DirectState authors the note's tooltip popup
  locator P0x47=0x10000398/P0x48=0x21000041 — the FOURTH popup skin,
  whose incorporated text child 0x10000396 fonts 0x40000015 where the
  other three skins font 0x40000002 (the user's "special font") — plus
  P0x50=0.0 (zero per-element tooltip delay: town tooltips fire the
  instant the dwell arms; UiRoot already honors it), P0x4B TooltipOn,
  and P0x13 RolloverEnabled. Batch C's "the template authors no locator
  of its own" claim was WRONG, and BuildTownMarkers' hardcoded
  shared-skin override was clobbering the authored values — removed.
- The hover highlight: the template's Normal/Normal_rollover states are
  PassToChildren descriptors driving the swallowed highlight child
  0x100001F1 (base 0x100002B7@0x21000042 — a four-piece frame all
  drawing 0x06004CC9, byte-decoded PURE GREEN A=FF R=00 G=FF B=00) via
  per-state P0x3B (Invisible): hidden at rest, green on rollover.

Port:
- UiButton.CascadeStateToChildren — retail UIElement::SetState
  @0x00464E70's PassToChildren cascade, keyed off the REQUESTED state id
  (properties commit unconditionally; only the sprite draw is art-gated,
  the existing #382/AP-222 distinction).
- UiDatElement.TrySetRetailState honors per-state P0x3B for NAMED states
  (OnSetAttribute @0x00462d80 case 8: SetVisible(value==0)). The
  unnamed-DirectState case is explicitly excluded — honoring it would
  un-gate ISSUES #408 (1,083 authored-invisible elements) through
  BuildWidget's post-children state reapply; measured breaking the
  spell-favorite drag tests before the scoping (note added to #408).
- MapPageController.BuildTownMarkers rebuilds the button-swallowed
  highlight child per marker through the AD-108 IconBuilder seam
  (Bindings.TemplateInfoResolver, backed by
  RowTemplateResolver.ResolveInfo — same cache) and arms it with the
  initial Normal cascade.

Register TS-85's Batch C paragraph corrected; RetailTooltipPresenter's
F10 shared-skin remark updated (MapPageController no longer a consumer).
Tests: 3 installed-DAT pins (locator/delay/rollover; per-state P0x3B +
green frame; the four-skin font sweep), UiButton cascade + UiDatElement
P0x3B units, MapHousePanel marker no-clobber + hover-highlight fixture.
App suite 5487 passed / 3 skips (5490 total, +11 over baseline);
Runtime 1744/1744.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 09:22:46 +02:00

1079 lines
51 KiB
C#

using System;
using System.Numerics;
using AcDream.App.UI.Layout;
namespace AcDream.App.UI;
/// <summary>
/// Generic dat-widget button — the production replacement for any dat element of
/// Type 1 (UIElement_Button, registered via RegisterElementClass(1, UIElement_Button::Create)
/// @ acclient_2013_pseudo_c.txt:125828).
///
/// <para>
/// Draws per-state sprite media exactly like <see cref="UiDatElement"/> (same
/// <c>ActiveState</c> defaulting, same <c>ActiveMedia()</c> fallback chain, same tiled
/// <c>DrawSprite</c> call with UV-repeat so chrome edges tile correctly) plus an
/// optional centered text label. The click behavior mirrors <see cref="UiDatElement"/>
/// one-for-one so the chat Send and Max/Min buttons that previously bound through
/// <c>UiDatElement.OnClick</c> continue to work without behavioral change.
/// </para>
///
/// <para>
/// State selection: picks <see cref="ElementInfo.DefaultStateName"/> if set, then
/// "Normal" if the element has a Normal state sprite, then falls back to the unnamed
/// DirectState ("" key) — identical to <see cref="UiDatElement"/>.
/// </para>
///
/// <para>
/// Built by <see cref="DatWidgetFactory"/> for Type-1 elements (chat Send 0x10000019,
/// Max/Min 0x1000046F). NOT the same as <see cref="UiSimpleButton"/>, which is an
/// earlier dev-scaffold widget with no dat sprites.
/// </para>
/// </summary>
public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
{
private readonly ElementInfo _info;
private readonly ElementInfo _mediaInfo;
private readonly FaceSegment[] _faceSegments;
private readonly Func<uint, (uint tex, int w, int h)> _resolve;
private readonly HashSet<uint> _availableStates = new();
private readonly bool _hasCustomSelectionPair;
private IReadOnlyDictionary<uint, Vector4>? _stateLabelColors;
private IReadOnlyDictionary<uint, bool>? _stateLabelOutlines;
private bool _pressed;
private bool _pointerOver;
private bool _selected;
private bool _hotClicking;
private bool _suppressNextClick;
private int _pointerX;
private int _pointerY;
private double _nextHotClickTime = double.NaN;
/// <summary>Optional click handler. Wired by the controller (e.g. chat Submit, ToggleMaximize).</summary>
public Action? OnClick { get; set; }
/// <summary>
/// Optional left-button double-click handler. Null preserves the existing
/// bubbling behavior; character-management row template 0x100003A5 opts in
/// for retail's element message 0x1A (activate the selected character).
/// </summary>
public Action? OnDoubleClick { get; set; }
/// <summary>
/// Optional right-click handler (Campaign OP slice OP8's Configure Keyboard
/// screen: right-click a bound key button to erase that one binding —
/// <c>UIOption_ActionKeyMap::EraseBinding @0x00487780</c>). Null by default,
/// so every pre-existing <see cref="UiButton"/> is unaffected — this only adds
/// a new optional event, it does not change any existing click/drag behavior.
/// </summary>
public Action? OnRightClick { get; set; }
/// <summary>
/// Optional pointer transition handlers. These expose retail's distinct
/// pressed/released element messages for controls such as the combat-height
/// buttons, where mouse-down begins charging and mouse-up commits the attack.
/// </summary>
public Action? OnPressed { get; set; }
public Action? OnReleased { get; set; }
/// <summary>
/// Optional position-aware click handler. Coordinates are local pixels in this button,
/// matching retail's <c>UIElementMessageInfo.ptWindow</c> paperdoll hit-test input.
/// </summary>
public Action<int, int>? OnClickAt { get; set; }
/// <summary>
/// Optional item-drop target callbacks. Retail uses ordinary buttons as drop surfaces
/// in a few panels (notably gmToolbarUI's inventory button), so this belongs on the
/// retained button rather than in UiRoot or a panel-specific hit-test workaround.
/// </summary>
public Func<ItemDragPayload, ItemDragAcceptance>? OnItemDragOver { get; set; }
public Action<ItemDragPayload>? OnItemDrop { get; set; }
public uint ItemDragAcceptSprite { get; set; }
public uint ItemDragRejectSprite { get; set; }
private ItemDragAcceptance _itemDragAcceptance;
internal ItemDragAcceptance ItemDragAcceptanceForTest => _itemDragAcceptance;
/// <summary>The dat element id from <see cref="ElementInfo.Id"/>.</summary>
public uint ElementId => _info.Id;
/// <summary>Optional centered text label drawn over the sprite (e.g. "Send" on a blank gold frame).</summary>
public string? Label { get; set; }
/// <summary>Dat font for <see cref="Label"/>. Required for the label to draw.</summary>
public UiDatFont? LabelFont { get; set; }
/// <summary>Label color (default white).</summary>
public Vector4 LabelColor { get; set; } = Vector4.One;
/// <summary>Settable tooltip, surfaced through the shared
/// <see cref="UiElement.GetTooltipText"/> hover pipeline (same pattern as
/// <see cref="UiCatalogSlot"/>). Retail option rows stamp theirs via
/// <c>UIElement::SetTooltip</c> during row construction
/// (e.g. <c>UIOption_CheckboxBitfield64::CreateChildren @0x00485DF0</c>).</summary>
public string? TooltipText { get; set; }
/// <inheritdoc />
public override string? GetTooltipText() =>
string.IsNullOrWhiteSpace(TooltipText) ? null : TooltipText;
/// <summary>Retail LayoutDesc property <c>0x21</c> (two-pass glyph outline,
/// <c>UIElement_Text::SetOutline @0x0046a81c</c>). Seeded by DatWidgetFactory
/// from the element's effective-default state, same as <see cref="UiText.Outline"/>
/// (round-5 review S2 — per-STATE switching is AP-192).</summary>
public bool Outline { get; set; }
/// <summary>Retail LayoutDesc property <c>0x22</c> (<c>m_curOutlineColor</c>,
/// ctor default black). Only meaningful when <see cref="Outline"/> is true.</summary>
public Vector4 OutlineColor { get; set; } = UiRenderContext.DefaultOutlineColor;
/// <summary>Optional authored face rectangle. Full-button by default; retail
/// UIOption_Checkbox uses its 13x13 indicator child as the button face.</summary>
public float FaceLeft { get; set; }
public float FaceTop { get; set; }
public float FaceWidth { get; set; }
public float FaceHeight { get; set; }
/// <summary>
/// AP-195 (Campaign OP slice OP5): when non-null, drawn as the face media INSTEAD
/// of <see cref="ActiveState"/>'s resolved sprite — a dat RenderSurface id, not a
/// pre-resolved texture handle (resolved through the same <c>_resolve</c> callback
/// as the normal path, so tiling/UV math stays identical). Ports retail
/// <c>UIOption_CheckboxBitfield64::Refresh @0x004859C0</c>'s
/// <c>SetMediaImageForState(indicatorChild, dataId, 1, 6)</c>: the Chat tab's
/// per-window filter rows force their 5-state LED indicator to ONE fixed image
/// (the block-level all/partial-set sprite) regardless of hover/press state,
/// bypassing the checkbox's own per-state media entirely. Null (default) restores
/// the ordinary <see cref="ActiveState"/>-driven lookup — every other button
/// instance in the codebase is unaffected. Same shape as
/// <see cref="Layout.UiDatElement.RuntimeImageTexture"/>, deliberately a DID here
/// (not a texture handle) because the checkbox draw path already resolves its
/// file id through <c>_resolve</c> for correct native-size UV tiling.
/// </summary>
public uint? FaceFileOverride { get; set; }
/// <summary>
/// Campaign CC gate round 1 closeout (Group 1, R2-5): per-instance
/// multiplicative sprite tint, threaded into every <see cref="UiRenderContext.DrawSprite"/>
/// call this class makes (main face, face-segment, drag-acceptance
/// overlay) — retail's own <c>SurfaceWindow::BlitAndColor(...,
/// Blit_Multiply, color)</c>. Default <see cref="Vector4.One"/> (white,
/// full alpha) leaves every DrawSprite call byte-identical to before
/// this property existed; only a caller that explicitly sets a
/// non-identity tint (e.g. <see cref="Layout.CharacterCreationAppearancePage"/>'s
/// color-wheel swatches) changes what draws.
/// </summary>
public Vector4 Tint { get; set; } = Vector4.One;
/// <summary>
/// R3-5 (Campaign CC gate round 1 re-test 2): optional resolver
/// returning a PRE-BAKED, already color-key-recolored texture handle
/// (from <see cref="AcDream.App.Rendering.TextureCache.UploadRgba8"/>
/// or equivalent), drawn UNTINTED (1:1, no UV repeat) INSTEAD of the
/// ordinary <see cref="FaceFileOverride"/>/<c>ActiveFile</c> sprite.
/// Retail's own <c>gmCGAppearancePage::DoColorSpots @0x0047d850</c>
/// does NOT multiply-tint the swatch's authored ring+spot sprite (a
/// multiply of a target color against BLACK — the spot template's own
/// placeholder fill, live-DAT-pixel-confirmed — stays black regardless
/// of the tint, and multiplying the ring's own non-black border pixels
/// shifts their hue/brightness, corrupting them). Retail instead calls
/// <c>SurfaceWindow::ReplaceColor</c>: build a fresh composited surface
/// once, blit the spot template onto it, then swap every EXACT-black
/// pixel for the swatch's real color — the ring border (never black)
/// is untouched. This property is that same mechanism's C# seam.
/// <see cref="Tint"/> itself is left completely unchanged in meaning
/// and is STILL the value callers set to communicate "this button's
/// color is X" (existing callers/tests that only read
/// <see cref="Tint"/> are unaffected) — this resolver is a SEPARATE
/// decision (deliberately not fed by <see cref="Tint"/>: a caller may
/// need to distinguish more states — e.g. "beyond count, show the
/// blocked art" versus "no color data yet, show nothing" — than one
/// Vector4 can encode) that only changes what OnDraw does when
/// non-null: consult it for a texture instead of directly multiplying
/// the authored sprite. Null (default, every pre-existing button)
/// preserves the exact prior FaceFileOverride/ActiveFile +
/// multiply-Tint draw.
/// </summary>
public Func<uint>? ColorKeyFaceResolver { get; set; }
/// <summary>Additional left inset for left-aligned labels.</summary>
public float LabelOffsetX { get; set; } = 3f;
/// <summary>Horizontal alignment of <see cref="Label"/>. Center (default) for normal buttons;
/// Left for the paperdoll "Slots" caption that sits at the left edge, before the slots.</summary>
public LabelAlignment LabelAlign { get; set; } = LabelAlignment.Center;
/// <summary>
/// GF-11c (Campaign CC gate round 1 Batch B): optional authored label
/// rectangle, LOCAL to this button. When a caption is LIFTED from a
/// DISTINCT Type-12 child that carries its own independent rect (e.g.
/// the Town page's per-marker name label, positioned below/beside its
/// marker rather than immediately right of it), <see cref="OnDraw"/>
/// draws the label within THIS box using its own authored geometry
/// instead of the FaceLeft-derived offset / full-button-width centering
/// the ordinary case uses (label authored directly on the button, right
/// beside a single-purpose face segment — the heritage/template/Face-
/// Clothes row family, where the current face-relative math is already
/// correct). Null (default, every pre-existing button) preserves the
/// EXACT prior draw math — <see cref="LabelAlignment.Left"/> still adds
/// <see cref="LabelOffsetX"/> to the button's own local origin, and
/// <see cref="LabelAlignment.Center"/> still centers within the whole
/// button width/height.
/// </summary>
public (float X, float Y, float Width, float Height)? LabelBox { get; set; }
/// <summary>
/// GF-4a (Campaign CC gate round 1 Batch C): optional secondary VALUE
/// text, coexisting with <see cref="Label"/> (the authored CAPTION).
/// Retail's chargen display buttons (Attribute/Skill Credits, Health,
/// Stamina, Mana — <c>0x100003e2-e5</c>, <c>0x100003f9</c>) author the
/// caption directly as this element's own dat property <c>0x17</c>
/// AND carry a SEPARATE, media-less Type-12 child for the live value
/// (<c>gmCGProfessionPage::InitializePage @0x00482f90-0x00483062</c>,
/// <c>gmCGSkillsPage::InitializePage @0x00481e1c</c>) —
/// <see cref="UiButton"/> consumes ALL of its dat children
/// (<see cref="ConsumesDatChildren"/>), which used to mean a page
/// controller had nowhere faithful to put the value except
/// overwriting <see cref="Label"/> itself, destroying the caption.
/// <see cref="Layout.DatWidgetFactory.BuildButton"/> now surfaces that
/// child's geometry/font/color here instead. Null (default) draws
/// nothing extra — every pre-existing button that only ever wrote
/// <see cref="Label"/> is unaffected.
/// </summary>
public string? ValueLabel { get; set; }
/// <summary>Dat font for <see cref="ValueLabel"/>.</summary>
public UiDatFont? ValueFont { get; set; }
/// <summary>Color for <see cref="ValueLabel"/> (default white).</summary>
public Vector4 ValueColor { get; set; } = Vector4.One;
/// <summary>Authored rectangle for <see cref="ValueLabel"/>, LOCAL to
/// this button — the lifted value child's own rect
/// (<see cref="Layout.DatWidgetFactory.BuildButton"/> sets this). Null
/// (no value child found) means <see cref="ValueLabel"/> is never set
/// either, so this is never read in that case.</summary>
public (float X, float Y, float Width, float Height)? ValueBox { get; set; }
/// <summary>Horizontal alignment of <see cref="ValueLabel"/> within
/// <see cref="ValueBox"/> — the lifted child's own authored justify.</summary>
public LabelAlignment ValueAlign { get; set; } = LabelAlignment.Center;
/// <summary>
/// Label horizontal alignment options. <see cref="Right"/> (R4-1, Campaign
/// CC gate round 1 re-test 3) is ValueLabel-only today — every value
/// child on the chargen credit-display family (0x100002f1/0x100002f3)
/// authors dat HJustify Right (raw 3/5), decomp-confirmed by
/// <c>UIElement_Text::CalcJustification @0x00467260</c>'s
/// <c>ecx_5==3||5</c> branch (<c>edi = availWidth - textWidth</c>, i.e.
/// flush to the box's own far edge) — distinct from Center's halved
/// offset. <see cref="LabelAlign"/> never authors Right today so no
/// existing switch over it needs a new arm.
/// </summary>
public enum LabelAlignment { Center, Left, Right }
public bool ToggleBehavior { get; }
public bool RolloverEnabled { get; }
public bool HotClickEnabled { get; }
public float HotClickInitialDelay { get; }
public float HotClickRepeatInterval { get; }
/// <summary>
/// Opt-out for a <see cref="ToggleBehavior"/> button whose <see cref="Selected"/>
/// state is a PURE MIRROR of external state (a producer other than the button's
/// own blind self-flip is the sole legitimate writer — e.g.
/// <see cref="ChatWindowController.SetIndicatorOpen"/> mirroring a floating chat
/// window's own visibility). CH6a/b REJECT-review SHOULD-FIX 3: the chat-window
/// 1-4 indicators (<c>0x10000522</c>-<c>0x10000525</c>) carry DAT property
/// <c>0x0B</c> (<see cref="ToggleBehavior"/>) = true, so without this flag a
/// click flips their Highlight/Normal art with no underlying visibility change —
/// the mirror lies until the next real toggle.
///
/// <para>
/// Round 4 (2026-08-10): this stays set for the chat-window indicators even
/// though clicking them now DOES toggle their floating window (via
/// <see cref="ChatWindowController.BindIndicatorClicks"/> — see
/// <see cref="ChatWindowController.SetIndicatorOpen"/>'s doc for the full retail
/// mechanism this reconciles). The click drives the real toggle, and
/// <c>SetIndicatorOpen</c> — the SAME single writer as before — reports the
/// outcome back onto <see cref="Selected"/>; this button's own MouseUp-time
/// blind flip stays suppressed so the visual never races or diverges from the
/// window's actual state. Every OTHER toggle button (max/min, checkboxes) keeps
/// retail's normal click-toggles-itself behavior; default
/// <see langword="false"/>.
/// </para>
/// </summary>
public bool SuppressSelfToggle { get; set; }
public bool Selected
{
get => _selected;
set
{
if (_selected == value) return;
_selected = value;
UpdateVisualState();
}
}
/// <summary>
/// Active state name, runtime-settable (e.g. Max/Min toggling Normal ↔ Minimized).
/// Matches <see cref="UiDatElement.ActiveState"/>.
/// </summary>
public string ActiveState { get; set; } = "";
public uint ActiveRetailStateId
{
get
{
if (string.IsNullOrEmpty(ActiveState))
return UiStateInfo.DirectStateId;
foreach (var (id, state) in _mediaInfo.States)
if (string.Equals(state.Name, ActiveState, StringComparison.Ordinal))
return id;
return UiButtonStateMachine.TryStateId(ActiveState, out uint standard)
? standard
: RetailUiStateIds.TryStateId(ActiveState, out uint custom) ? custom : 0u;
}
}
/// <summary>Reads a resolved enum-valued DAT attribute from this button.</summary>
public bool TryGetEnumAttribute(uint propertyId, out uint value)
{
if (_info.TryGetEffectiveProperty(propertyId, out var property)
&& property.Kind == UiPropertyKind.Enum)
{
value = checked((uint)property.UnsignedValue);
return true;
}
value = 0;
return false;
}
public override string ActiveCursorStateName => ActiveState;
public bool TrySetRetailState(uint stateId)
{
if (ToggleBehavior && stateId is UiButtonStateMachine.Normal or UiButtonStateMachine.Highlight)
{
Selected = stateId == UiButtonStateMachine.Highlight;
return true;
}
if (stateId == UiButtonStateMachine.Ghosted)
{
Enabled = false;
return true;
}
if (!Enabled && stateId != UiButtonStateMachine.Ghosted)
Enabled = true;
if (stateId == UiStateInfo.DirectStateId)
{
// #382: a DirectState transition only actually applies when the button
// has REAL "" media to show. Every button structurally carries a
// DirectStateId entry in _mediaInfo.States purely as the property bag
// for its own base-level dat properties (ToggleBehavior 0x0B, RolloverEnabled
// 0x13, etc. — see UiButtonTests.AddBoolProperty), independent of whether
// it authors any blank/unnamed sprite. TryFindState(DirectStateId) succeeding
// on that property-only entry used to be enough to accept the transition
// (the retail-decompiled UIElement::SetState @0x00464e70 commits m_curStateDesc
// unconditionally once ElementDesc::AccessStateDesc finds ANY StateDesc, media
// or not — retail's OWN buttons dodge the resulting blank draw purely through
// construction TIMING: Initialize()'s SetState(m_defaultState) call happens
// before m_children is populated, so a PassToChildren cascade from an ancestor
// can never reach an already-initialized child during import). Our port's
// LayoutImporter.BuildWidget deliberately reapplies a PARENT's default AFTER its
// children are built (so retained PassToChildren tabs get their authored
// Open/Closed child media — see that method's own comment), which means a
// chrome ancestor's structural (media-less) DirectState — e.g. the floating
// chat window's indicator-button backing panel, which authors PassToChildren=
// true on its own empty DirectState purely to route HideDetail/ShowDetail to
// an unrelated sibling — can and does reach an already-correctly-resolved
// button (ActiveState="Normal") and blank it before first paint. Requiring
// actual "" media closes that gap without touching the reapply ordering (which
// CharacterStatController's three-chrome-children PassToChildren cascade still
// depends on) or the cascade mechanism itself (both remain faithful ports).
if (!HasStateMedia(""))
return false;
ActiveState = "";
CascadeStateToChildren(stateId);
return true;
}
if (TryFindState(stateId, out var state))
{
ActiveState = state.Name;
CascadeStateToChildren(stateId);
return true;
}
string stateName = UiButtonStateMachine.StateName(stateId);
if (string.IsNullOrEmpty(stateName))
stateName = RetailUiStateIds.StateName(stateId);
if (!string.IsNullOrEmpty(stateName) && HasStateMedia(stateName))
{
ActiveState = stateName;
CascadeStateToChildren(stateId);
return true;
}
return false;
}
/// <param name="info">Merged <see cref="ElementInfo"/> for this element.</param>
/// <param name="resolve">Dat file-id → (GL texture handle, native px width, native px height).
/// Returns (0,0,0) when the texture is not yet uploaded.</param>
public UiButton(
ElementInfo info,
Func<uint, (uint tex, int w, int h)> resolve,
ElementInfo? mediaInfo = null,
IReadOnlyList<ElementInfo>? faceSegments = null)
{
_info = info;
_mediaInfo = mediaInfo ?? info;
_faceSegments = faceSegments is null
? []
: faceSegments.Select(static segment => new FaceSegment(segment)).ToArray();
_resolve = resolve;
ClickThrough = false; // buttons are interactive — opt OUT of click-through
// Visual transitions can select only states with an actual button face.
// Retail layouts commonly declare an empty Normal_pressed descriptor while
// supplying art only for Normal/Highlight. Treating that property-only state
// as drawable briefly blanks the button during mouse-down.
if (_faceSegments.Length == 0)
AddAvailableStates(_mediaInfo);
else
foreach (FaceSegment segment in _faceSegments)
AddAvailableStates(segment.Info);
// Campaign CC gate round 1 Batch B (GF-1/GF-8): retail's custom
// "Unselected"/"Selected" radio-selection state pair
// (RetailUiStateIds.Unselected/Selected, 0x10000016/0x10000017) is
// authored as STATE DESCRIPTORS whose names UiButtonStateMachine's
// Normal/Highlight machine doesn't recognize — the standard
// AddAvailableStates loop above never admits them, so the ordinary
// RequestedState()-driven UpdateVisualState can never select them
// (measured: Selected=true committed nothing against the installed
// dat before this fix). HasStateMedia already checks the same media
// presence (face-segment child OR the button's own StateMedia) used
// everywhere else in this class, so this reuses that exact
// detection rather than adding a new one.
_hasCustomSelectionPair = HasStateMedia(RetailUiStateIds.StateName(RetailUiStateIds.Unselected))
&& HasStateMedia(RetailUiStateIds.StateName(RetailUiStateIds.Selected));
ToggleBehavior = info.TryGetEffectiveBool(0x0Bu, out bool toggle) && toggle;
RolloverEnabled = info.TryGetEffectiveBool(0x13u, out bool rollover) && rollover;
HotClickEnabled = info.TryGetEffectiveBool(0x0Fu, out bool hotClick) && hotClick;
HotClickInitialDelay = info.TryGetEffectiveFloat(0x10u, out float initialDelay)
? initialDelay
: 0f;
HotClickRepeatInterval = info.TryGetEffectiveFloat(0x11u, out float repeatInterval)
? repeatInterval
: 0f;
_selected = info.TryGetEffectiveBool(0x0Eu, out bool selected) && selected;
bool disabled = info.TryGetEffectiveBool(0x0Du, out bool ghosted) && ghosted;
// State defaulting matches UiDatElement exactly:
// DefaultStateName wins; else "Normal" if that state has a sprite; else DirectState ("").
if (!string.IsNullOrEmpty(info.DefaultStateName))
ActiveState = info.DefaultStateName;
else if (HasStateMedia("Normal"))
ActiveState = "Normal";
// else ActiveState stays "" (DirectState)
Enabled = !disabled;
FaceWidth = mediaInfo?.Width ?? info.Width;
FaceHeight = mediaInfo?.Height ?? info.Height;
UpdateVisualState();
}
/// <summary>The button draws its own face + label; any dat label child is reproduced
/// procedurally, so the importer must not build the button's children as widgets.</summary>
public override bool ConsumesDatChildren => true;
/// <summary>A button is interactive — it must receive its Click even inside a whole-window-Draggable
/// frame (e.g. the paperdoll "Slots" toggle in the inventory window), so it opts out of the
/// IA-12 whole-window-drag that would otherwise swallow the press.</summary>
public override bool HandlesClick => true;
/// <summary>
/// Returns the File id for the current <see cref="ActiveState"/>, falling back to
/// the DirectState ("" key) if the named state is absent.
/// Returns 0 if neither exists.
/// Mirrors <see cref="UiDatElement.ActiveMedia()"/>.
/// </summary>
private uint ActiveFile(ElementInfo mediaInfo)
=> mediaInfo.StateMedia.TryGetValue(ActiveState, out var m) ? m.File
: mediaInfo.StateMedia.TryGetValue("", out var d) ? d.File : 0u;
protected override void OnDraw(UiRenderContext ctx)
{
if (_faceSegments.Length != 0)
{
foreach (FaceSegment segment in _faceSegments)
DrawFace(ctx, ActiveFile(segment.Info), segment.Rect(Width, Height));
}
else if (ColorKeyFaceResolver is { } colorKeyResolver)
{
// R3-5: a pre-baked, already-recolored texture (see this
// property's own doc) — drawn UNTINTED and 1:1 (no UV repeat;
// the baked bitmap is uploaded at its own native size, which
// for the chargen swatches equals the button's own authored
// rect, live-DAT-measured).
uint bakedTexture = colorKeyResolver();
if (bakedTexture != 0)
{
float faceWidth = FaceWidth > 0f ? FaceWidth : Width;
float faceHeight = FaceHeight > 0f ? FaceHeight : Height;
ctx.DrawSprite(bakedTexture, FaceLeft, FaceTop, faceWidth, faceHeight,
0f, 0f, 1f, 1f, Vector4.One);
}
}
else
{
uint file = FaceFileOverride ?? ActiveFile(_mediaInfo);
if (file != 0)
{
var (tex, tw, th) = _resolve(file);
if (tex != 0 && tw != 0 && th != 0)
{
// Tiled draw — same call shape as UiDatElement.OnDraw (UV-repeat; GL_REPEAT-wrapped
// UI texture). Matches ImgTex::TileCSI; no Stretch mode exists.
float faceWidth = FaceWidth > 0f ? FaceWidth : Width;
float faceHeight = FaceHeight > 0f ? FaceHeight : Height;
ctx.DrawSprite(tex, FaceLeft, FaceTop, faceWidth, faceHeight,
0, 0, faceWidth / tw, faceHeight / th, Tint);
}
}
}
if (Label is { Length: > 0 } label && LabelFont is { } lf)
{
// GF-11c: LabelBox null (every pre-existing button) reduces boxX/
// boxY to 0 and boxWidth/boxHeight to the button's own Width/
// Height — byte-identical to the prior unconditional math.
float boxX = LabelBox?.X ?? 0f;
float boxY = LabelBox?.Y ?? 0f;
float boxWidth = LabelBox?.Width ?? Width;
float boxHeight = LabelBox?.Height ?? Height;
// R2-2/R2-3 (Campaign CC gate round 1 Batch E) + R3-2 correction
// (re-test 2): when this button ALSO carries a coexisting
// ValueLabel (GF-4a's own-caption + separate value slot — the
// Profession attribute/health/stamina/mana credits buttons, the
// Skills credits button), boxWidth still narrows to stop before
// the value's authored rect for the (currently unused, since
// every known ValueBox button is Left-aligned) Center-tx
// formula and the explicit-newline clip rect below — see
// DrawBlockLabel's own doc for why this no longer gates
// WHETHER a single-line caption wraps or clips (R3-2: it never
// did in retail — live-DAT-measured, "Available Skill Credits"
// fits the button's own full 231px width with room to spare).
if (ValueBox is { X: var valueBoxX } && valueBoxX > boxX)
boxWidth = MathF.Min(boxWidth, valueBoxX - boxX);
DrawBlockLabel(ctx, label, lf, LabelColor, boxX, boxY, boxWidth, boxHeight, LabelAlign, LabelOffsetX);
}
if (ValueLabel is { Length: > 0 } value && ValueFont is { } vf)
{
float boxX = ValueBox?.X ?? 0f;
float boxY = ValueBox?.Y ?? 0f;
float boxWidth = ValueBox?.Width ?? Width;
float boxHeight = ValueBox?.Height ?? Height;
float valueWidth = vf.MeasureWidth(value);
// R4-1: Right mirrors CalcJustification's own far-edge formula
// (box's own right edge minus the measured text width, no
// decorative inset — the decomp's Right branch adds none either,
// and this box carries no threaded marginR of its own).
float vx = ValueAlign switch
{
LabelAlignment.Left => boxX + LabelOffsetX,
LabelAlignment.Right => boxX + boxWidth - valueWidth,
_ => boxX + (boxWidth - valueWidth) * 0.5f,
};
float vy = boxY + (boxHeight - vf.LineHeight) * 0.5f;
ctx.DrawStringDat(vf, value, vx, vy, ValueColor, Outline, OutlineColor);
}
uint dragSprite = _itemDragAcceptance switch
{
ItemDragAcceptance.Accept => ItemDragAcceptSprite,
ItemDragAcceptance.Reject => ItemDragRejectSprite,
_ => 0u,
};
if (dragSprite != 0)
{
var (tex, _, _) = _resolve(dragSprite);
if (tex != 0)
ctx.DrawSprite(tex, 0f, 0f, Width, Height, 0f, 0f, 1f, 1f, Tint);
}
}
/// <summary>
/// R2-2 (Campaign CC gate round 1 Batch E) + R3-1/R3-2 (re-test 2
/// correction): retail's <c>UIElement_Button</c> IS a
/// <c>UIElement_Text</c> (<c>struct UIElement_Button : UIElement_Text</c>,
/// <c>acclient.h</c>) — a caption that carries an authored newline
/// (already normalized to a real <c>'\n'</c> by
/// <see cref="Layout.DatWidgetFactory"/>'s shared
/// <c>ResolveAuthoredString</c>) lays out as multiple stacked lines. A
/// single line that already fits draws with byte-identical geometry to
/// the pre-Batch-E unconditional one-line math (same centered-block Y,
/// same tx formula).
/// <para>
/// Batch E ALSO auto-wrapped a paragraph that doesn't fit
/// <paramref name="boxWidth"/> via <see cref="UiText.WrapWords"/> — re-
/// derived at re-test 2 (R3-1 "Coordination"/R3-2 "Available Skill
/// Credits") as the wrong shape and REMOVED: live-DAT-probed, the
/// Coordination slider label (<c>0x100002ed</c>) authors <c>OneLine=
/// true</c> (dat property <c>0x20</c>) and the Skills credits button
/// (<c>0x100003f9</c>) authors <c>OneLine=false</c> yet BOTH render one
/// line in retail. Tracing <c>GlyphList::Recalculate
/// @0x00473800</c>'s per-glyph loop: the ENTIRE width-triggered break
/// decision (and, separately, the explicit-newline break) sits behind
/// one gate, <c>if (arg3 == 0)</c> where <c>arg3</c> is the SAME
/// <c>OneLine</c> boolean passed in from
/// <c>UIElement_Text::ResizeToPaper</c>/<c>InqSize</c> — i.e. a
/// caption's width is measured against its own FULL element rect (minus
/// margins), never against a sibling/child element's geometry; nothing
/// in the decomp confines a caption's wrap width to stop before another
/// element's rect. The 193px "Available Skill Credits" caption fits the
/// button's own full 231px width (live-DAT-measured) with room to
/// spare — it never needed to wrap at all. So: split ONLY on the
/// explicit <c>\n</c> (never invoke <see cref="UiText.WrapWords"/>) — a
/// strict superset of the pre-Batch-E single-line draw for every
/// caption that was already correct, and the exact shape "Attribute\n
/// Credits" (an authored break) still needs.
/// </para>
/// <para>
/// R3-2 deliberately does NOT clip a single (unwrapped) line to
/// <paramref name="boxWidth"/> either, even when the caller narrowed it
/// via a coexisting <see cref="ValueBox"/> — clipping would cut the
/// caption's own tail off mid-word, which contradicts "retail is ONE
/// line" just as much as wrapping does (a viewer would call that
/// truncated, not "one line"). The 193px-in-231px Skills-credits
/// geometry means the caption's rendered span (x≈3 to x≈196) does
/// overlap the value's own rect (x=116 to x=150, live-DAT-measured) in
/// principle — Batch E's own diagnosis of the ORIGINAL R2-2/R2-3
/// "24dits"/"Credit0Credits" reports. That overlap is NOT re-solved
/// here: this fix only removes the false wrap this specific finding
/// (R3-2) reported, and inventing an unevidenced clip boundary to
/// pre-empt a DIFFERENT, not-currently-reported symptom would be
/// exactly the guessing this project's workflow forbids. Flagged in
/// the findings doc for the user's own re-check once the wrap is gone.
/// </para>
/// </summary>
private void DrawBlockLabel(
UiRenderContext ctx,
string text,
UiDatFont font,
Vector4 color,
float boxX,
float boxY,
float boxWidth,
float boxHeight,
LabelAlignment align,
float leftOffset)
{
IReadOnlyList<(string Text, float X, float Y)> lines = WrapBlockLines(
text, font.MeasureWidth, font.LineHeight,
boxX, boxY, boxWidth, boxHeight, align, leftOffset);
// A multi-line result (an authored '\n') clips to its own box — the
// button's normal draw has no ambient clip, and an oversized
// wrapped caption (e.g. the Skills credits button's own tight 28px
// height) should be cut off at the box edge rather than spill into
// whatever sits below the button, matching every other clipped
// Type-12 text box in this codebase (UiText.DrawText's own
// PushClip). Single-line captions — the overwhelming majority,
// and (post-R3-2) EVERY caption with no authored newline — never
// pay this cost; see this method's own doc for why a single line
// is deliberately left unclipped even when boxWidth was narrowed.
bool clip = lines.Count > 1;
if (clip)
ctx.PushClip(boxX, boxY, boxWidth, boxHeight);
try
{
foreach ((string line, float tx, float ty) in lines)
ctx.DrawStringDat(font, line, tx, ty, color, Outline, OutlineColor);
}
finally
{
if (clip)
ctx.PopClip();
}
}
/// <summary>
/// Pure geometry half of <see cref="DrawBlockLabel"/> — split ONLY on an
/// authored explicit <c>'\n'</c>, then block-centered vertically within
/// <paramref name="boxHeight"/>. Pulled out as a static/pure method
/// (same shape as <see cref="UiText.ContentOffsetX"/>) so the geometry
/// is unit-testable without a font atlas or draw context —
/// <paramref name="measureWidth"/> takes the place of
/// <see cref="UiDatFont.MeasureWidth(string)"/>.
/// <para>
/// R3-1/R3-2 (re-test 2): deliberately does NOT width-wrap a paragraph
/// that overflows <paramref name="boxWidth"/> — see
/// <see cref="DrawBlockLabel"/>'s own doc for the decomp citation
/// (<c>GlyphList::Recalculate</c>'s width-triggered break sits behind
/// the SAME <c>OneLine</c> gate as the explicit-newline break, and
/// retail never confines a caption's wrap width to a sibling element's
/// rect). A paragraph that overflows still draws as one line, unclipped
/// by width — matching every plain (no authored <c>\n</c>) button
/// caption in retail, which is never observed to wrap.
/// </para>
/// </summary>
internal static IReadOnlyList<(string Text, float X, float Y)> WrapBlockLines(
string text,
Func<string, float> measureWidth,
float lineHeight,
float boxX,
float boxY,
float boxWidth,
float boxHeight,
LabelAlignment align,
float leftOffset)
{
string[] lines = text.Split('\n');
float totalHeight = lines.Length * lineHeight;
float startY = boxY + (boxHeight - totalHeight) * 0.5f;
var result = new List<(string, float, float)>(lines.Length);
for (int i = 0; i < lines.Length; i++)
{
string line = lines[i];
float tx = align == LabelAlignment.Left
? boxX + leftOffset
: boxX + (boxWidth - measureWidth(line)) * 0.5f;
float ty = startY + i * lineHeight;
result.Add((line, tx, ty));
}
return result;
}
private void DrawFace(UiRenderContext ctx, uint file, UiPixelRect rect)
{
if (file == 0 || rect.Width <= 0 || rect.Height <= 0)
return;
var (texture, textureWidth, textureHeight) = _resolve(file);
if (texture == 0 || textureWidth == 0 || textureHeight == 0)
return;
// Same tiled/cropped media path as UiDatElement. Segment geometry is
// first reflowed by its own four-edge retail layout policy.
ctx.DrawSprite(texture, rect.X0, rect.Y0, rect.Width, rect.Height,
0f, 0f, (float)rect.Width / textureWidth, (float)rect.Height / textureHeight,
Tint);
}
private void AddAvailableStates(ElementInfo mediaInfo)
{
foreach (string stateName in mediaInfo.StateMedia.Keys)
if (UiButtonStateMachine.TryStateId(stateName, out uint stateId))
_availableStates.Add(stateId);
}
private bool HasStateMedia(string stateName)
{
if (_faceSegments.Length == 0)
return _mediaInfo.StateMedia.ContainsKey(stateName);
foreach (FaceSegment segment in _faceSegments)
if (segment.Info.StateMedia.ContainsKey(stateName))
return true;
return false;
}
private bool TryFindState(uint stateId, out UiStateInfo state)
{
if (_faceSegments.Length == 0)
return _mediaInfo.States.TryGetValue(stateId, out state!);
foreach (FaceSegment segment in _faceSegments)
if (segment.Info.States.TryGetValue(stateId, out state!))
return true;
state = null!;
return false;
}
private sealed class FaceSegment
{
private readonly UiPixelRect _original;
private readonly UiLayoutPolicy? _layout;
public FaceSegment(ElementInfo info)
{
Info = info;
_original = UiPixelRect.FromPositionAndSize(
(int)info.X, (int)info.Y, (int)info.Width, (int)info.Height);
if (info.HasOriginalParentSize)
_layout = new UiLayoutPolicy(
info.Left, info.Top, info.Right, info.Bottom,
_original,
UiPixelRect.FromPositionAndSize(
0, 0, (int)info.OriginalParentWidth, (int)info.OriginalParentHeight));
}
public ElementInfo Info { get; }
public UiPixelRect Rect(float parentWidth, float parentHeight)
{
if (_layout is null)
return _original;
return _layout.Apply(
_original,
UiPixelRect.FromPositionAndSize(0, 0, (int)parentWidth, (int)parentHeight));
}
}
public override bool OnEvent(in UiEvent e)
{
switch (e.Type)
{
case UiEventType.HoverEnter:
_pointerOver = true;
UpdateVisualState();
return true;
case UiEventType.HoverLeave:
_pointerOver = false;
UpdateVisualState();
return true;
case UiEventType.MouseDown:
_pointerX = e.Data1;
_pointerY = e.Data2;
_pointerOver = ContainsLocal(e.Data1, e.Data2);
_pressed = true;
UpdateVisualState();
if (Enabled)
OnPressed?.Invoke();
if (HotClickEnabled && Enabled)
{
OnClick?.Invoke();
OnClickAt?.Invoke(_pointerX, _pointerY);
_hotClicking = true;
_nextHotClickTime = double.NaN;
}
return true;
case UiEventType.MouseMove:
if (_pressed)
{
_pointerX = e.Data1;
_pointerY = e.Data2;
_pointerOver = ContainsLocal(e.Data1, e.Data2);
UpdateVisualState();
return true;
}
return false;
case UiEventType.MouseUp:
_pointerX = e.Data1;
_pointerY = e.Data2;
_pointerOver = ContainsLocal(e.Data1, e.Data2);
_suppressNextClick = _hotClicking && _pointerOver;
_hotClicking = false;
_nextHotClickTime = double.NaN;
if (_pressed && _pointerOver && Enabled && ToggleBehavior && !SuppressSelfToggle)
_selected = !_selected;
if (_pressed && Enabled)
OnReleased?.Invoke();
_pressed = false;
UpdateVisualState();
return true;
case UiEventType.Click:
if (!Enabled) return true;
if (_suppressNextClick)
{
_suppressNextClick = false;
return true;
}
OnClick?.Invoke();
OnClickAt?.Invoke(e.Data1, e.Data2);
return OnClick is not null || OnClickAt is not null;
case UiEventType.DoubleClick:
if (OnDoubleClick is null) return false;
if (!Enabled) return true;
OnDoubleClick.Invoke();
return true;
case UiEventType.RightClick:
// S6 (2026-08-11 review): unlike Click (whose swallow-when-
// disabled is pre-existing, harmless-by-construction behavior
// every button already had), RightClick was UNHANDLED before
// this class grew OnRightClick — it fell through to `default:
// return false` and bubbled to the parent. Preserve that for
// every button with no handler, disabled or not, so this
// addition is genuinely a no-op for every pre-existing button
// (matching this property's own doc comment) and only changes
// behavior for the ones that opt in.
if (OnRightClick is null) return false;
if (!Enabled) return true;
OnRightClick.Invoke();
return true;
case UiEventType.DragEnter:
_itemDragAcceptance = e.Payload is ItemDragPayload payload
? OnItemDragOver?.Invoke(payload) ?? ItemDragAcceptance.None
: ItemDragAcceptance.None;
return OnItemDragOver is not null;
case UiEventType.DragOver:
_itemDragAcceptance = ItemDragAcceptance.None;
return OnItemDragOver is not null;
case UiEventType.DropReleased:
_itemDragAcceptance = ItemDragAcceptance.None;
if (e.Payload is ItemDragPayload dropped)
OnItemDrop?.Invoke(dropped);
return OnItemDrop is not null;
default:
return false;
}
}
protected override void OnEnabledChanged()
{
if (!Enabled)
{
_pressed = false;
_hotClicking = false;
_nextHotClickTime = double.NaN;
}
UpdateVisualState();
}
internal int FaceSegmentCount => _faceSegments.Length;
internal IReadOnlyList<UiPixelRect> FaceSegmentRectsForTest()
=> _faceSegments.Select(segment => segment.Rect(Width, Height)).ToArray();
public void OnGlobalUiTime(double nowSeconds)
{
if (!_hotClicking || !HotClickEnabled)
return;
if (double.IsNaN(_nextHotClickTime))
_nextHotClickTime = nowSeconds + HotClickInitialDelay;
if (!_pointerOver && nowSeconds >= _nextHotClickTime)
{
_nextHotClickTime = nowSeconds;
return;
}
if (_pointerOver && nowSeconds >= _nextHotClickTime)
{
OnClick?.Invoke();
OnClickAt?.Invoke(_pointerX, _pointerY);
_nextHotClickTime += HotClickRepeatInterval;
}
}
private bool ContainsLocal(int x, int y)
=> x >= 0 && y >= 0 && x < Width && y < Height;
private void UpdateVisualState()
{
uint requested = ComputeRequestedStateId();
if (_hasCustomSelectionPair)
{
// gmCGHeritagePage::Update @0x00483219-0x0048372D (and the
// mirrored template/sub-tab/gender call sites): retail sets
// this pair directly by SELECTION, not through the ordinary
// Normal/Highlight/rollover/pressed machine — these buttons
// never author rollover or pressed media for the pair, so
// there is nothing faithful to compute beyond selected-or-not.
ActiveState = RetailUiStateIds.StateName(requested);
}
else if (_availableStates.Contains(requested))
{
ActiveState = UiButtonStateMachine.StateName(requested);
}
// AP-222: apply the per-state label style off the REQUESTED id, not
// the (possibly art-gated) committed ActiveState — retail's own
// SetState(6) commits the state's PROPERTIES (including text color)
// unconditionally; only the SPRITE draw silently no-ops when a
// state has no media (this class's own #382 comment on
// TrySetRetailState documents the same distinction). The
// Appearance spins' current-part highlight is exactly this case:
// _availableStates never contains Highlight (their arrow face
// segments carry no Highlight art), so ActiveState stays "Normal"
// forever, but the spin's OWN label color must still swap.
ApplyPerStateLabelStyle(requested);
// Same unconditional-commit principle for the child cascade: retail
// UIElement::SetState @0x00464E70 propagates the committed state to
// children whenever the StateDesc authors PassToChildren, regardless
// of whether THIS element's own sprite changed — keyed off the
// REQUESTED id for the same reason as the label style above.
CascadeStateToChildren(requested);
}
/// <summary>
/// Retail <c>UIElement::SetState @0x00464E70</c>'s PassToChildren
/// cascade, ported for buttons (2026-08-17 morning gate finding 3 —
/// previously only <see cref="Layout.UiDatElement.TrySetRetailState"/>
/// had it). First consumer: the map town-hotspot template
/// (<c>0x100001F0</c> in <c>0x21000026</c>) authors media-less
/// <c>Normal</c>/<c>Normal_rollover</c> state descriptors with
/// <c>PassToChildren=true</c> whose only job is driving the highlight
/// child's per-state <c>P0x3B</c> visibility flip (the green
/// <c>0x06004CC9</c> rollover frame). No-op unless a state descriptor
/// for <paramref name="stateId"/> exists AND authors PassToChildren AND
/// this button actually has stateful children — dat-built buttons
/// normally have none (<see cref="ConsumesDatChildren"/>); only
/// explicitly-attached subtrees (the map markers' highlight, the
/// AD-108 icon seam) can receive the cascade.
/// </summary>
private void CascadeStateToChildren(uint stateId)
{
if (Children.Count == 0)
return;
if (!TryFindState(stateId, out UiStateInfo state) || !state.PassToChildren)
return;
foreach (UiElement child in Children)
if (child is IUiDatStateful stateful)
stateful.TrySetRetailState(stateId);
}
private uint ComputeRequestedStateId()
=> _hasCustomSelectionPair
? (_selected ? RetailUiStateIds.Selected : RetailUiStateIds.Unselected)
: UiButtonStateMachine.RequestedState(new UiButtonVisualInput(
Disabled: !Enabled,
Selected: _selected,
RolloverEnabled: RolloverEnabled,
Pressed: _pressed,
PointerOver: _pointerOver));
/// <summary>
/// AP-222 / GF-11b (Campaign CC gate round 1 Batch B): optional per-
/// RETAIL-STATE label color/outline override, additive over the single
/// default <see cref="LabelColor"/>/<see cref="Outline"/> lifted once at
/// construction. Set by <see cref="Layout.DatWidgetFactory"/> ONLY when
/// the authored dat genuinely carries more than one distinct value
/// across this button's (or its lifted caption child's) own states —
/// e.g. the Appearance spins' Highlight-state gold brightening
/// (dat properties <c>0x1B</c>/<c>0x21</c>, live-DAT-measured
/// 218,167,85 -&gt; 255,221,131 plus outline off -&gt; on) or the Town
/// buttons' Normal-to-white caption swap (218,167,85 -&gt; 255,255,255).
/// A button with a single authored color (the overwhelming majority)
/// never calls this, so <see cref="LabelColor"/>/<see cref="Outline"/>
/// keep behaving exactly as before — including every existing external
/// post-construction assignment (e.g. <c>ChatWindowController</c>'s Send
/// caption, <c>PaperdollController</c>'s Slots label), none of which
/// author a second distinct per-state color.
/// </summary>
internal void SetPerStateLabelStyle(
IReadOnlyDictionary<uint, Vector4>? colors,
IReadOnlyDictionary<uint, bool>? outlines)
{
_stateLabelColors = colors;
_stateLabelOutlines = outlines;
ApplyPerStateLabelStyle(ComputeRequestedStateId());
}
private void ApplyPerStateLabelStyle(uint requestedStateId)
{
if (_stateLabelColors is { } colors && colors.TryGetValue(requestedStateId, out Vector4 color))
LabelColor = color;
if (_stateLabelOutlines is { } outlines && outlines.TryGetValue(requestedStateId, out bool outline))
Outline = outline;
}
}