acdream/src/AcDream.App/UI/UiButton.cs
Erik 89db9a794c feat(ui): authored state media animates, so the unseen-text indicator blinks
The blink is not code. It is data, and we were throwing it away.

A retail UI state's media is a small program: images interleaved with timed
pauses, branches, and a terminal hand-off to another state. Our importer kept
the FIRST image per state and dropped the rest, so nothing authored could ever
animate — the indicator was correct in every other respect and simply sat
still.

Measured from the installed dats (LayoutDump --media 0x1000048C), the chat
unseen-text indicator's Normal state authors thirteen steps: two frames
alternating every half second, three times, then `State 13` — Ghosted, whose
authored 0x3B is Invisible.

So retail's indicator is a three-second attention FLASH that hides itself, not
a badge that stays lit until you scroll to the bottom. Nobody would guess that
from the code, because there is no blink code anywhere; the behaviour lives
entirely in the authored sequence. Our shipped version stayed lit, which is
the one thing the data says it must not do.

Sampling is a pure function of (steps, elapsed) rather than a playback object
holding a cursor, so an element only has to remember WHEN its state began and
the whole thing is testable without a clock, a GPU or a frame loop. One shared
UiMediaClock is advanced once per frame by RetailUiRuntime; a UI element has
no tick of its own.

The controller change is the other half: it starts the flash on the rising
edge ONLY. Re-setting Normal every frame would pin the sequence on frame zero
and it would never blink at all — which is the failure mode the second new
test exists to catch, and which no "is it visible?" assertion would notice.
When the sequence reaches its terminal step the controller follows it down
instead of re-lighting it.

Two guesses are refused rather than made, and both are registered: a Pause's
max duration (every sequence measured sets min == max, and what the range MEANS
is not in the decomp) and a sub-1 branch probability (falls through, the
direction where a malformed sequence stops rather than animates forever).
A jump-cycle with no elapsed time is bounded so a bad sequence cannot spin
inside a frame.

Kept `Other` steps in the list rather than filtering them, so a jump's authored
index still lands on the entry it names.

Register: CT-3, CT-4.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 11:14:53 +02:00

1244 lines
59 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 string[] _segmentMediaStates;
private string _faceMediaState = "";
private string? _lastMediaCommitState;
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; }
/// <summary>
/// Gives the owning controller exclusive responsibility for the rendered
/// state. Automatic pointer, selection, and enabled-state transitions no
/// longer replace <see cref="ActiveState"/>; input and click delivery remain
/// unchanged. This is used by buttons such as the radar UI lock whose face
/// represents durable application state rather than momentary interaction.
/// </summary>
public bool ControllerOwnsVisualState { 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>
private string _activeState = "";
private double _activeStateStartedAt;
public string ActiveState
{
get => _activeState;
set
{
if (string.Equals(_activeState, value, StringComparison.Ordinal))
return;
_activeState = value;
// An authored media sequence is timed from the moment its state is
// entered, so this is the only thing an element needs to remember.
_activeStateStartedAt = Layout.UiMediaClock.Seconds;
}
}
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();
// Retail media start: the media machine begins on the element's BASE
// media (m_desc.m_media); the first committed state then applies the
// SetState media rule (see SyncMediaStates) — including the default
// state at construction, exactly retail's Initialize -> SetState
// ordering.
_segmentMediaStates = new string[_faceSegments.Length];
// #420: seed every segment with DirectState (""), exactly like
// _faceMediaState's own initializer above. `new string[n]` leaves
// nulls, and NextMediaState returns `current` UNCHANGED on three of
// its four arms (committed state authored with an empty media array,
// or no committed/base state and no "" entry) — so on a multi-segment
// button whose committed state carries no media the null survived the
// first SyncMediaStates and reached
// ElementInfo.StateMedia.TryGetValue(null), throwing
// ArgumentNullException ("Parameter 'key'") from inside OnDraw.
// Observed live: it killed the client mid-paint on the character-
// select screen on every launch (session status.jsonl: connected ->
// characterList -> exited code 1 "crashed").
Array.Fill(_segmentMediaStates, "");
_resolve = resolve;
ClickThrough = false; // buttons are interactive — opt OUT of click-through
// 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>
/// Retail's SetState media rule (<c>UIElement::SetState @0x00464E70</c>
/// tail plus <c>MediaMachine::Update_Image @0x00465870</c>): a committed
/// state replaces the playing IMAGE only when its effective media array
/// contains image media. An empty state—or a cursor/sound/message-only
/// state—keeps the PREVIOUS image (why the toolbar inventory button's
/// inherited non-image <c>Normal_pressed</c> media never blanks its closed
/// backpack art), while an authored File=0 draw-nothing image clears the
/// face (#416: the roster-row bar children's base state). An UNAUTHORED
/// committed state runs retail's state-0 arm against the base media array.
/// Face segments model retail's
/// PassToChildren children, so each segment resolves the rule against
/// its OWN authored states. Synced lazily on the first draw after any
/// <see cref="ActiveState"/> write so every commit path (the visual
/// state machine, TrySetRetailState, external assignments) is covered.
/// </summary>
private void SyncMediaStates()
{
if (string.Equals(ActiveState, _lastMediaCommitState, StringComparison.Ordinal))
return;
uint committedId = ActiveRetailStateId;
if (_faceSegments.Length == 0)
{
_faceMediaState = NextMediaState(
_mediaInfo, committedId, ActiveState, _faceMediaState);
}
else
{
for (int i = 0; i < _faceSegments.Length; i++)
{
_segmentMediaStates[i] = NextMediaState(
_faceSegments[i].Info,
committedId,
ActiveState,
_segmentMediaStates[i]);
}
}
_lastMediaCommitState = ActiveState;
}
private static string NextMediaState(
ElementInfo info,
uint committedId,
string committedName,
string current)
{
if (info.States.TryGetValue(committedId, out UiStateInfo? state))
return HasImageMedia(info, state, committedName) ? committedName : current;
// Synthetic/test infos may carry StateMedia without States entries;
// a drawable entry for the committed name counts as authored media.
if (info.StateMedia.ContainsKey(committedName))
return committedName;
// Retail's state-0 arm: base media if its array is non-empty,
// otherwise the previous media keeps playing.
if (info.States.TryGetValue(
UiStateInfo.DirectStateId, out UiStateInfo? baseState))
return HasImageMedia(info, baseState, "") ? "" : current;
return info.StateMedia.ContainsKey("") ? "" : current;
}
private static bool HasImageMedia(
ElementInfo info,
UiStateInfo state,
string stateName)
// StateMedia keeps committed pre-ImageMediaCount fixtures and compact
// synthetic tests faithful for ordinary non-zero image entries.
=> state.ImageMediaCount != 0 || info.StateMedia.ContainsKey(stateName);
/// <summary>
/// Returns the File id the media rule selected for this face; 0 draws
/// nothing (an authored File=0 image reaches this as a media-state whose
/// name has no drawable entry).
/// </summary>
private static uint ActiveFile(ElementInfo mediaInfo, string mediaState)
=> mediaInfo.StateMedia.TryGetValue(mediaState, out var m) ? m.File : 0u;
/// <summary>
/// The frame this element's active state is showing right now, and the
/// state its sequence hands off to when it ends.
/// </summary>
/// <remarks>
/// A state whose media is a single image is NOT routed through the player;
/// it keeps the still-frame path, so the overwhelming majority of buttons
/// are untouched by this.
/// </remarks>
private uint AnimatedFile(ElementInfo mediaInfo, string mediaState, out uint? handOff)
{
handOff = null;
if (!TryFindStateNamed(mediaInfo, mediaState, out UiStateInfo? state)
|| !Layout.UiMediaSequence.IsAnimated(state!.MediaSteps))
{
return ActiveFile(mediaInfo, mediaState);
}
(uint file, uint? transition) = Layout.UiMediaSequence.Sample(
state.MediaSteps,
(float)(Layout.UiMediaClock.Seconds - _activeStateStartedAt));
handOff = transition;
return file != 0u ? file : ActiveFile(mediaInfo, mediaState);
}
private static bool TryFindStateNamed(
ElementInfo mediaInfo, string mediaState, out UiStateInfo? state)
{
foreach (var (_, candidate) in mediaInfo.States)
{
if (string.Equals(candidate.Name, mediaState, StringComparison.Ordinal))
{
state = candidate;
return true;
}
}
state = null;
return false;
}
protected override void OnDraw(UiRenderContext ctx)
{
SyncMediaStates();
// An authored media sequence can end by handing the element to another
// state (the chat unseen-text indicator blinks three times, then hands
// off to Ghosted). Collected here and applied AFTER the draw: changing
// state mid-draw would invalidate the very media being drawn.
uint? pendingHandOff = null;
if (_faceSegments.Length != 0)
{
for (int i = 0; i < _faceSegments.Length; i++)
{
FaceSegment segment = _faceSegments[i];
uint frame = AnimatedFile(
segment.Info, _segmentMediaStates[i], out uint? segmentHandOff);
DrawFace(ctx, frame, segment.Rect(Width, Height));
pendingHandOff ??= segmentHandOff;
}
}
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
?? AnimatedFile(_mediaInfo, _faceMediaState, out pendingHandOff);
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);
}
}
}
// The sequence has run out and asked for another state. Applied here,
// after every face has drawn this frame.
if (pendingHandOff is { } handOffState)
TrySetRetailState(handOffState);
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 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()
{
if (ControllerOwnsVisualState)
return;
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);
ApplyPerStateLabelStyle(requested);
CascadeStateToChildren(requested);
return;
}
// Retail UIElement_Button::UpdateState_ @0x00471CF0: the machine
// calls SetState ONLY when the requested state is authored on the
// button's OWN ElementDesc (the AccessStateDesc gate @0x00471d8e) —
// an unauthored request is a NO-OP that preserves the current state
// (how custom semantic states like Minimized survive pointer
// traffic). The commit itself (UIElement::SetState @0x00464E70)
// applies the state's properties and PassToChildren cascade; the
// face's DRAWN media follows the separate SetState media rule
// (SyncMediaStates — a committed state replaces the playing media
// only when its media array is non-empty, @0x004651c0). The former
// media-keyed _availableStates gate here latched the #416
// roster-row highlight: the row authors an EMPTY 'Normal'
// descriptor whose commit must reach the bar segments' own state-0
// File=0 clear, but a media-keyed gate could never commit it.
// Synthetic/test infos may carry StateMedia without States entries,
// so a drawable entry for the requested name also counts as
// authored.
string requestedName = UiButtonStateMachine.StateName(requested);
bool authored = _info.States.TryGetValue(
requested, out UiStateInfo? committed);
if (!authored && !HasStateMedia(requestedName))
return;
ActiveState = authored && !string.IsNullOrEmpty(committed!.Name)
? committed.Name
: requestedName;
ApplyPerStateLabelStyle(requested);
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;
}
}