using System;
using System.Numerics;
using AcDream.App.UI.Layout;
namespace AcDream.App.UI;
///
/// 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).
///
///
/// Draws per-state sprite media exactly like (same
/// ActiveState defaulting, same ActiveMedia() fallback chain, same tiled
/// DrawSprite call with UV-repeat so chrome edges tile correctly) plus an
/// optional centered text label. The click behavior mirrors
/// one-for-one so the chat Send and Max/Min buttons that previously bound through
/// UiDatElement.OnClick continue to work without behavioral change.
///
///
///
/// State selection: picks if set, then
/// "Normal" if the element has a Normal state sprite, then falls back to the unnamed
/// DirectState ("" key) — identical to .
///
///
///
/// Built by for Type-1 elements (chat Send 0x10000019,
/// Max/Min 0x1000046F). NOT the same as , which is an
/// earlier dev-scaffold widget with no dat sprites.
///
///
public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
{
private readonly ElementInfo _info;
private readonly ElementInfo _mediaInfo;
private readonly FaceSegment[] _faceSegments;
private readonly Func _resolve;
private readonly HashSet _availableStates = new();
private readonly bool _hasCustomSelectionPair;
private IReadOnlyDictionary? _stateLabelColors;
private IReadOnlyDictionary? _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;
/// Optional click handler. Wired by the controller (e.g. chat Submit, ToggleMaximize).
public Action? OnClick { get; set; }
///
/// 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).
///
public Action? OnDoubleClick { get; set; }
///
/// Optional right-click handler (Campaign OP slice OP8's Configure Keyboard
/// screen: right-click a bound key button to erase that one binding —
/// UIOption_ActionKeyMap::EraseBinding @0x00487780). Null by default,
/// so every pre-existing is unaffected — this only adds
/// a new optional event, it does not change any existing click/drag behavior.
///
public Action? OnRightClick { get; set; }
///
/// 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.
///
public Action? OnPressed { get; set; }
public Action? OnReleased { get; set; }
///
/// Optional position-aware click handler. Coordinates are local pixels in this button,
/// matching retail's UIElementMessageInfo.ptWindow paperdoll hit-test input.
///
public Action? OnClickAt { get; set; }
///
/// 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.
///
public Func? OnItemDragOver { get; set; }
public Action? OnItemDrop { get; set; }
public uint ItemDragAcceptSprite { get; set; }
public uint ItemDragRejectSprite { get; set; }
private ItemDragAcceptance _itemDragAcceptance;
internal ItemDragAcceptance ItemDragAcceptanceForTest => _itemDragAcceptance;
/// The dat element id from .
public uint ElementId => _info.Id;
/// Optional centered text label drawn over the sprite (e.g. "Send" on a blank gold frame).
public string? Label { get; set; }
/// Dat font for . Required for the label to draw.
public UiDatFont? LabelFont { get; set; }
/// Label color (default white).
public Vector4 LabelColor { get; set; } = Vector4.One;
/// Settable tooltip, surfaced through the shared
/// hover pipeline (same pattern as
/// ). Retail option rows stamp theirs via
/// UIElement::SetTooltip during row construction
/// (e.g. UIOption_CheckboxBitfield64::CreateChildren @0x00485DF0).
public string? TooltipText { get; set; }
///
public override string? GetTooltipText() =>
string.IsNullOrWhiteSpace(TooltipText) ? null : TooltipText;
/// Retail LayoutDesc property 0x21 (two-pass glyph outline,
/// UIElement_Text::SetOutline @0x0046a81c). Seeded by DatWidgetFactory
/// from the element's effective-default state, same as
/// (round-5 review S2 — per-STATE switching is AP-192).
public bool Outline { get; set; }
/// Retail LayoutDesc property 0x22 (m_curOutlineColor,
/// ctor default black). Only meaningful when is true.
public Vector4 OutlineColor { get; set; } = UiRenderContext.DefaultOutlineColor;
/// Optional authored face rectangle. Full-button by default; retail
/// UIOption_Checkbox uses its 13x13 indicator child as the button face.
public float FaceLeft { get; set; }
public float FaceTop { get; set; }
public float FaceWidth { get; set; }
public float FaceHeight { get; set; }
///
/// AP-195 (Campaign OP slice OP5): when non-null, drawn as the face media INSTEAD
/// of 's resolved sprite — a dat RenderSurface id, not a
/// pre-resolved texture handle (resolved through the same _resolve callback
/// as the normal path, so tiling/UV math stays identical). Ports retail
/// UIOption_CheckboxBitfield64::Refresh @0x004859C0's
/// SetMediaImageForState(indicatorChild, dataId, 1, 6): 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 -driven lookup — every other button
/// instance in the codebase is unaffected. Same shape as
/// , deliberately a DID here
/// (not a texture handle) because the checkbox draw path already resolves its
/// file id through _resolve for correct native-size UV tiling.
///
public uint? FaceFileOverride { get; set; }
///
/// Campaign CC gate round 1 closeout (Group 1, R2-5): per-instance
/// multiplicative sprite tint, threaded into every
/// call this class makes (main face, face-segment, drag-acceptance
/// overlay) — retail's own SurfaceWindow::BlitAndColor(...,
/// Blit_Multiply, color). Default (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. 's
/// color-wheel swatches) changes what draws.
///
public Vector4 Tint { get; set; } = Vector4.One;
///
/// R3-5 (Campaign CC gate round 1 re-test 2): optional resolver
/// returning a PRE-BAKED, already color-key-recolored texture handle
/// (from
/// or equivalent), drawn UNTINTED (1:1, no UV repeat) INSTEAD of the
/// ordinary /ActiveFile sprite.
/// Retail's own gmCGAppearancePage::DoColorSpots @0x0047d850
/// 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
/// SurfaceWindow::ReplaceColor: 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.
/// 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
/// are unaffected) — this resolver is a SEPARATE
/// decision (deliberately not fed by : 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.
///
public Func? ColorKeyFaceResolver { get; set; }
/// Additional left inset for left-aligned labels.
public float LabelOffsetX { get; set; } = 3f;
/// Horizontal alignment of . Center (default) for normal buttons;
/// Left for the paperdoll "Slots" caption that sits at the left edge, before the slots.
public LabelAlignment LabelAlign { get; set; } = LabelAlignment.Center;
///
/// 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),
/// 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 — still adds
/// to the button's own local origin, and
/// still centers within the whole
/// button width/height.
///
public (float X, float Y, float Width, float Height)? LabelBox { get; set; }
///
/// GF-4a (Campaign CC gate round 1 Batch C): optional secondary VALUE
/// text, coexisting with (the authored CAPTION).
/// Retail's chargen display buttons (Attribute/Skill Credits, Health,
/// Stamina, Mana — 0x100003e2-e5, 0x100003f9) author the
/// caption directly as this element's own dat property 0x17
/// AND carry a SEPARATE, media-less Type-12 child for the live value
/// (gmCGProfessionPage::InitializePage @0x00482f90-0x00483062,
/// gmCGSkillsPage::InitializePage @0x00481e1c) —
/// consumes ALL of its dat children
/// (), which used to mean a page
/// controller had nowhere faithful to put the value except
/// overwriting itself, destroying the caption.
/// now surfaces that
/// child's geometry/font/color here instead. Null (default) draws
/// nothing extra — every pre-existing button that only ever wrote
/// is unaffected.
///
public string? ValueLabel { get; set; }
/// Dat font for .
public UiDatFont? ValueFont { get; set; }
/// Color for (default white).
public Vector4 ValueColor { get; set; } = Vector4.One;
/// Authored rectangle for , LOCAL to
/// this button — the lifted value child's own rect
/// ( sets this). Null
/// (no value child found) means is never set
/// either, so this is never read in that case.
public (float X, float Y, float Width, float Height)? ValueBox { get; set; }
/// Horizontal alignment of within
/// — the lifted child's own authored justify.
public LabelAlignment ValueAlign { get; set; } = LabelAlignment.Center;
///
/// Label horizontal alignment options. (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
/// UIElement_Text::CalcJustification @0x00467260's
/// ecx_5==3||5 branch (edi = availWidth - textWidth, i.e.
/// flush to the box's own far edge) — distinct from Center's halved
/// offset. never authors Right today so no
/// existing switch over it needs a new arm.
///
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; }
///
/// Opt-out for a button whose
/// 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.
/// mirroring a floating chat
/// window's own visibility). CH6a/b REJECT-review SHOULD-FIX 3: the chat-window
/// 1-4 indicators (0x10000522-0x10000525) carry DAT property
/// 0x0B () = 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.
///
///
/// 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
/// 's doc for the full retail
/// mechanism this reconciles). The click drives the real toggle, and
/// SetIndicatorOpen — the SAME single writer as before — reports the
/// outcome back onto ; 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
/// .
///
///
public bool SuppressSelfToggle { get; set; }
public bool Selected
{
get => _selected;
set
{
if (_selected == value) return;
_selected = value;
UpdateVisualState();
}
}
///
/// Active state name, runtime-settable (e.g. Max/Min toggling Normal ↔ Minimized).
/// Matches .
///
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;
}
}
/// Reads a resolved enum-valued DAT attribute from this button.
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;
}
/// Merged for this element.
/// Dat file-id → (GL texture handle, native px width, native px height).
/// Returns (0,0,0) when the texture is not yet uploaded.
public UiButton(
ElementInfo info,
Func resolve,
ElementInfo? mediaInfo = null,
IReadOnlyList? 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();
}
/// 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.
public override bool ConsumesDatChildren => true;
/// 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.
public override bool HandlesClick => true;
///
/// Returns the File id for the current , falling back to
/// the DirectState ("" key) if the named state is absent.
/// Returns 0 if neither exists.
/// Mirrors .
///
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);
}
}
///
/// R2-2 (Campaign CC gate round 1 Batch E) + R3-1/R3-2 (re-test 2
/// correction): retail's UIElement_Button IS a
/// UIElement_Text (struct UIElement_Button : UIElement_Text,
/// acclient.h) — a caption that carries an authored newline
/// (already normalized to a real '\n' by
/// 's shared
/// ResolveAuthoredString) 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).
///
/// Batch E ALSO auto-wrapped a paragraph that doesn't fit
/// via — 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 (0x100002ed) authors OneLine=
/// true (dat property 0x20) and the Skills credits button
/// (0x100003f9) authors OneLine=false yet BOTH render one
/// line in retail. Tracing GlyphList::Recalculate
/// @0x00473800's per-glyph loop: the ENTIRE width-triggered break
/// decision (and, separately, the explicit-newline break) sits behind
/// one gate, if (arg3 == 0) where arg3 is the SAME
/// OneLine boolean passed in from
/// UIElement_Text::ResizeToPaper/InqSize — 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 \n (never invoke ) — 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.
///
///
/// R3-2 deliberately does NOT clip a single (unwrapped) line to
/// either, even when the caller narrowed it
/// via a coexisting — 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.
///
///
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();
}
}
///
/// Pure geometry half of — split ONLY on an
/// authored explicit '\n', then block-centered vertically within
/// . Pulled out as a static/pure method
/// (same shape as ) so the geometry
/// is unit-testable without a font atlas or draw context —
/// takes the place of
/// .
///
/// R3-1/R3-2 (re-test 2): deliberately does NOT width-wrap a paragraph
/// that overflows — see
/// 's own doc for the decomp citation
/// (GlyphList::Recalculate's width-triggered break sits behind
/// the SAME OneLine 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 \n) button
/// caption in retail, which is never observed to wrap.
///
///
internal static IReadOnlyList<(string Text, float X, float Y)> WrapBlockLines(
string text,
Func 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 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);
}
///
/// Retail UIElement::SetState @0x00464E70's PassToChildren
/// cascade, ported for buttons (2026-08-17 morning gate finding 3 —
/// previously only
/// had it). First consumer: the map town-hotspot template
/// (0x100001F0 in 0x21000026) authors media-less
/// Normal/Normal_rollover state descriptors with
/// PassToChildren=true whose only job is driving the highlight
/// child's per-state P0x3B visibility flip (the green
/// 0x06004CC9 rollover frame). No-op unless a state descriptor
/// for exists AND authors PassToChildren AND
/// this button actually has stateful children — dat-built buttons
/// normally have none (); only
/// explicitly-attached subtrees (the map markers' highlight, the
/// AD-108 icon seam) can receive the cascade.
///
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));
///
/// AP-222 / GF-11b (Campaign CC gate round 1 Batch B): optional per-
/// RETAIL-STATE label color/outline override, additive over the single
/// default / lifted once at
/// construction. Set by 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 0x1B/0x21, live-DAT-measured
/// 218,167,85 -> 255,221,131 plus outline off -> on) or the Town
/// buttons' Normal-to-white caption swap (218,167,85 -> 255,255,255).
/// A button with a single authored color (the overwhelming majority)
/// never calls this, so /
/// keep behaving exactly as before — including every existing external
/// post-construction assignment (e.g. ChatWindowController's Send
/// caption, PaperdollController's Slots label), none of which
/// author a second distinct per-state color.
///
internal void SetPerStateLabelStyle(
IReadOnlyDictionary? colors,
IReadOnlyDictionary? 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;
}
}