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; } }