acdream/src/AcDream.App/UI/Layout/ElementReader.cs
Erik 2719782dc0 fix(ui): #409 tooltip review fix round — F1-F11
Opus review of a377b9bf returned architectural PASS-with-findings /
retail-fidelity FAIL with F1-F12 (F12 info-only). All eleven fixed,
each re-derived against docs/research/named-retail/acclient_2013_pseudo_c.txt:

- F1 PositionAtMouse: retail offsets BOTH axes +32px before the clamp
  (StartTooltip @0x00459700, @0x00459739/@0x00459747) — was landing
  flush at the cursor.
- F2 UiRoot: the dwell timer now anchors to mouse-IDLE like retail's
  m_lastMouseMoveTime (MouseMoveHandler @0x0045E710), resetting on
  every move within the same widget while !_tooltipFired, not just on
  hover-enter.
- F3 register TS-85 rewritten: the "dynamic InqProperty(0x49) override"
  framing was false — UIElement::InqProperty @0x004638D0's base impl
  reads the same authored bags this port already reads. The real
  second text source (m_TTText/SetTooltip, headed by the P0xD0
  truncated-text auto-tooltip @0x00466F80) needs a per-line-position
  truncation model UiText doesn't have — sized disproportionate for
  this round and left honestly deferred rather than stubbed.
- F4 OnTooltipShow: null LayoutPolicy + Anchors=None on the popup root
  and text child before resizing, mirroring RetailMessageDialogView's
  sibling shape.
- F5 OnTooltipShow: return without mounting when the P0x4A text child
  doesn't resolve to a UiText (retail's DynamicCast gate,
  StartTooltip @0x0045DE90 @0x0045df65/@0x0045df6f) — was mounting an
  empty 30x30 bevel artifact.
- F6 UiRoot.Tick: the dwell-arm branch now requires Captured is null
  (CheckTooltip @0x0045B6E0 @0x0045b715) — a widget hovered before a
  drag/resize/capture began must not pop mid-gesture.
- F7 UiRoot.ReleaseCapture: no longer resets _tooltipFired
  (ReleaseMouseCapture @0x0045D2B0 touches only the idle timestamp) —
  a mouse-up while a tooltip is shown no longer tears it down and
  silently re-fires it 250ms later.
- F8 ApplyTooltipText: applies ResizeTo's own max/min width/height
  clamps (P0x3C/0x3D/0x3E/0x3F, @0x00463C30) before assigning the
  grown size; zeroes text.Padding to keep the measured size margin-
  comparable. New ElementInfo/UiElement plumbing for the four
  properties, same shape as the existing tooltip fields.
- F9 doc precision: sweep counts corrected 434->430 / 191->187 (live-
  DAT re-measured), the "243 showable" claim now measured exactly
  (not assumed) via a new Showable column in the sweep test, and the
  MiscSettings citation split into its two real mechanisms
  (RegisterPreference in Init vs. AttachPreference/SetPreferenceRange
  elsewhere).
- F10 register AD-106: the topmost guarantee is versus dialogs/screens
  only (the overlay popup layer and drag ghost still paint above
  regardless), and the per-tick BringToFront ratchet has four rungs,
  not three.
- F11 RetailUiRuntime.ResetSessionDialogs: now also calls the new
  UiRoot.ResetTooltipTracking() so a post-reset hover re-shows
  immediately instead of waiting out the stale fired-latch.

New pinning tests (RetailTooltipPresenterTests: F1/F2/F5/F6/F7/F8) each
verified to fail against the pre-fix behavior via a temporary revert-
and-rerun before being confirmed against the restored fix.
PortalProjectionTests.ProjectToClipLease_ReusesPooledWorkWithoutResultArrays
recurrence logged on issue #346 (already the tracking issue for this
load-sensitive flake) — hit twice under load this review, standalone
26/26, unrelated to #409.

Gates: Release build 0 errors; App suite (live-DAT env) 5416/5413
passed/3 skips (was 5410/5407/3, +6 new tests); Runtime 1735/0;
UI.Abstractions 926/0; full solution (no env, 69 skips expected)
14,623/14,554 passed/69 skipped/0 failed (was 14,617/14,548, +6).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 20:54:15 +02:00

961 lines
47 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System.Collections.Generic;
using System.Linq;
using System.Numerics;
using AcDream.App.UI;
namespace AcDream.App.UI.Layout;
/// <summary>
/// Horizontal text justification read from dat property 0x14 (UIElement HorizontalJustification).
/// Retail <c>CalcJustification @ 0x00467260</c> treats 1 as Center,
/// 3/5 as Right, and every other value (including constructor default 2) as Left.
/// </summary>
public enum HJustify : byte { Left = 0, Center = 1, Right = 2 }
/// <summary>
/// Vertical text justification read from dat property 0x15 (UIElement VerticalJustification).
/// Values: 2=Top, 4=Bottom; absent/other = Center.
/// </summary>
public enum VJustify : byte { Top = 0, Center = 1, Bottom = 2 }
/// <summary>
/// One row of a Type-8 tab control's authored tab table (dat property <c>0x2E</c>, an
/// array of <c>StructBaseProperty</c> with <c>MasterPropertyId 0x2F</c>). Retail
/// <c>0x2100002B</c> §1.3: <see cref="ButtonElementId"/> is the clickable tab button
/// (struct member <c>0x30</c>), <see cref="PageElementId"/> is the page-slot child it
/// activates (<c>0x31</c>), and <see cref="IsDefault"/> marks the one entry
/// (<c>0x32</c>) whose page is shown when the tab control first mounts.
/// </summary>
public readonly record struct UiTabTableEntry(uint ButtonElementId, uint PageElementId, bool IsDefault);
/// <summary>
/// One row of a Type-5 ListBox's authored row-template list (dat property <c>0x64</c>,
/// an array of <c>StructBaseProperty</c>). Retail <c>0x2100002B</c> §1.5:
/// <see cref="TemplateLayoutId"/> is the LayoutDesc DID carrying the template element
/// (struct member <c>0x63</c>), <see cref="TemplateElementId"/> is the template root
/// within that layout (<c>0x62</c>). Retail
/// <c>UIElement_ListBox::AddItemFromTemplateList(index)</c> instantiates row
/// <c>index</c>'s subtree through this pair.
/// </summary>
public readonly record struct UiTemplateListEntry(uint TemplateLayoutId, uint TemplateElementId);
/// <summary>
/// GL-free, dat-free snapshot of a resolved layout element.
/// Populated by the LayoutDesc importer from <c>DatReaderWriter.ElementDesc</c>
/// after inheritance is applied. The pure transforms on <see cref="ElementReader"/>
/// operate on this type so they can be unit-tested without the dats or OpenGL.
///
/// This is the canonical importer/factory seam. Add fields only with matching
/// inheritance, fixture-serialization, and conformance coverage.
/// </summary>
public sealed class ElementInfo
{
/// <summary>Dat element id (e.g. <c>0x100000E6</c>).</summary>
public uint Id;
/// <summary>
/// Raw element class id as a uint.
/// Game-specific ids like <c>0x1000004D</c> (gmVitalsUI root) and <c>0x10000009</c>
/// overflow <c>int</c> when treated as signed, so this stays <c>uint</c>.
/// Known values: 0=text, 2=dragbar, 3=container/chrome, 7=meter,
/// 9=resize-grip, 12=style-prototype (skip), 0x10000009/0x1000004D=window root.
/// </summary>
public uint Type;
/// <summary>Position and size within the parent, in pixels (cast from dat uint fields).</summary>
public float X, Y, Width, Height;
/// <summary>
/// Design-time parent size used by retail raw-edge reflow. Set by the DAT
/// importer for descendants; imported roots deliberately leave this unset.
/// Mounted base-layout children retain their original base parent size.
/// </summary>
public float OriginalParentWidth, OriginalParentHeight;
public bool HasOriginalParentSize;
/// <summary>
/// Raw edge-anchor flag values from the dat (<c>LeftEdge</c>, <c>TopEdge</c>,
/// <c>RightEdge</c>, <c>BottomEdge</c> fields of <c>ElementDesc</c>).
/// Values 04. Imported elements preserve these in <see cref="UiLayoutPolicy"/>;
/// <see cref="ElementReader.ToAnchors"/> is only the compatibility projection
/// for older programmatic consumers.
/// </summary>
public uint Left, Top, Right, Bottom;
/// <summary>Draw order within the parent (lower = drawn first / behind).</summary>
public uint ReadOrder;
/// <summary>Layer level from the dat (<c>ElementDesc.ZLevel</c>). Higher = drawn further
/// BACK (a full-window backdrop at ZLevel 100 sits behind ZLevel-0 panels). The factory
/// folds it into <see cref="UiElement.ZOrder"/> so ZLevel dominates and ReadOrder is the
/// within-layer tiebreaker. Issue #145 (vitals are all ZLevel 0, so they're unaffected).</summary>
public uint ZLevel;
/// <summary>
/// Canonical state descriptors keyed by numeric retail state id. The unnamed
/// DirectState uses <see cref="UiStateInfo.DirectStateId"/>. Unlike the legacy
/// projections below, properties remain scoped to the state that defines them.
/// </summary>
public Dictionary<uint, UiStateInfo> States = new();
/// <summary>Numeric form of <see cref="DefaultStateName"/>; zero means unset.</summary>
public uint DefaultStateId;
/// <summary>
/// Font dat object id inherited from the base element's <c>Properties[0x1A]</c>
/// (<c>ArrayBaseProperty → DataIdBaseProperty</c>). 0 = none / not inherited.
/// </summary>
public uint FontDid;
/// <summary>
/// Horizontal text justification from dat <c>Properties[0x14]</c>
/// (<c>EnumBaseProperty</c>: 0=Left, 1=Center, 3/5=Right).
/// Default is <see cref="HJustify.Center"/> to preserve existing behavior where
/// controllers set <c>Centered=true</c> and no property was read.
/// </summary>
public HJustify HJustify = HJustify.Center;
/// <summary>
/// Vertical text justification from dat <c>Properties[0x15]</c>
/// (<c>EnumBaseProperty</c>: 2=Top, 4=Bottom; absent/other = Center).
/// Default is <see cref="VJustify.Center"/> to preserve existing behavior.
/// </summary>
public VJustify VJustify = VJustify.Center;
/// <summary>
/// Font color from dat <c>Properties[0x1B]</c> (<c>ColorBaseProperty</c>, ARGB bytes).
/// Null when the dat carries no color for this element; the factory then leaves the
/// widget at its default white (<see cref="System.Numerics.Vector4.One"/>).
/// Propagated in <see cref="ElementReader.Merge"/> with the same "non-null derived wins"
/// rule used for <see cref="FontDid"/> and <see cref="HJustify"/>.
/// </summary>
public Vector4? FontColor;
/// <summary>
/// Outline flag from dat <c>Properties[0x21]</c> (<c>BoolBaseProperty</c>). Retail
/// <c>UIElement_Text::SetOutline @0x0046a81c</c> / <c>m_bitField &amp; 0x10</c>.
/// Default false (ctor <c>m_bitField=0x300</c> clears the outline bit) — outlining is
/// opt-in per element. Propagated in <see cref="ElementReader.Merge"/> with the same
/// "derived wins when true" rule used for <see cref="FontDid"/>.
/// </summary>
public bool Outline;
/// <summary>
/// Outline color from dat <c>Properties[0x22]</c> (<c>ColorBaseProperty</c>). Retail
/// <c>m_curOutlineColor</c>, ctor default <c>RGBAColor_Black</c>
/// (<c>UIElement_Text::UIElement_Text @0x004686cb</c>). Null means "not authored" —
/// the factory then leaves the widget at its own black default
/// (<see cref="UiRenderContext.DefaultOutlineColor"/>). Propagated in
/// <see cref="ElementReader.Merge"/> with the same "non-null derived wins" rule used
/// for <see cref="FontColor"/>.
/// </summary>
public Vector4? OutlineColor;
/// <summary>
/// Sprite per state: state name → (RenderSurface file id, DrawMode int).
/// The <c>""</c> key represents the unnamed DirectState (<c>ElementDesc.StateDesc</c>).
/// Named states use the <c>UIStateId.ToString()</c> value as the key
/// (e.g. <c>"HideDetail"</c>, <c>"ShowDetail"</c>).
/// </summary>
public Dictionary<string, (uint File, int DrawMode)> StateMedia = new();
/// <summary>
/// Cursor per state: state name to (RenderSurface file id, hotspot).
/// The "" key represents DirectState, mirroring <see cref="StateMedia"/>.
/// </summary>
public Dictionary<string, UiCursorMedia> StateCursors = new();
/// <summary>
/// The element's initial active state name, taken from <c>ElementDesc.DefaultState.ToString()</c>.
/// Normalized to <c>""</c> when the dat carries Undef/Undefined/0 (no default set).
/// Used by <see cref="UiDatElement"/> to pick which state's sprite to render initially.
/// Examples: <c>"Normal"</c> (Send button), <c>"Minimized"</c> (max/min button), <c>""</c> (DirectState).
/// </summary>
public string DefaultStateName = "";
/// <summary>
/// Resolved child elements. The importer ports retail child-table incorporation:
/// base-only children remain, same-ID children merge recursively, and derived-only
/// children append after the retained inherited entries.
/// </summary>
public List<ElementInfo> Children = new();
/// <summary>
/// Tab table read from dat property <c>0x2E</c> (Type-8 <c>UIElement_TabControl</c>
/// only — empty on every other element). Populated once via
/// <see cref="ElementReader.ApplyCanonicalLegacyProjection"/> using the canonical
/// effective-state resolution, exactly like <see cref="Outline"/>/<see cref="OutlineColor"/>
/// above — never scanned per-state as each <c>StateDesc</c> is read (round-5 N1's
/// "first wins on any state can pick a non-effective override" trap applies equally
/// here; see the comment on the skipped per-state 0x21 read in
/// <c>LayoutImporter.ReadState</c>).
/// </summary>
public List<UiTabTableEntry> TabTable = new();
/// <summary>
/// ListBox row-template list read from dat property <c>0x64</c> (Type-5
/// <c>UIElement_ListBox</c> elements that author one — empty otherwise). Populated
/// the same way as <see cref="TabTable"/>.
/// </summary>
public List<UiTemplateListEntry> TemplateList = new();
/// <summary>
/// AP-195 (Campaign OP slice OP5): <c>UIOption_CheckboxBitfield64</c>'s block-level
/// "all mask bits set" LED media (dat property <c>0x10000082</c>,
/// <c>P0x10000082 = 0x06004D17</c> on the authored template consumer
/// <c>0x10000520</c>) — retail <c>Refresh @0x004859C0</c> swaps a row's LED to THIS
/// sprite when every bit of that row's mask is currently set. 0 when absent.
/// Populated the same way as <see cref="TabTable"/>.
/// </summary>
public uint LedCheckedSprite;
/// <summary>
/// AP-195 counterpart of <see cref="LedCheckedSprite"/>: the "some but not all mask
/// bits set" LED media (dat property <c>0x10000083</c>,
/// <c>P0x10000083 = 0x06004D19</c>). Retail applies this ONLY while the row is
/// checked (any-set) but not fully checked (all-set) — see
/// <c>UiCheckboxBitfield64</c>'s own doc for the full any/all-set decode.
/// </summary>
public uint LedUncheckedSprite;
/// <summary>
/// Element id of this ListBox's linked scrollbar, read from dat property
/// <c>0x72</c> (e.g. the Character page's ListBox <c>0x100001FA</c> names
/// scrollbar <c>0x100001FB</c>). 0 when the element authors no scrollbar
/// reference. Populated the same way as <see cref="TabTable"/>.
/// </summary>
public uint ScrollbarElementId;
/// <summary>
/// GF-13 (Campaign CC gate round 1, Batch A): the authored Invisible flag
/// from dat property <c>0x3B</c> (<c>BoolBaseProperty</c>). Retail
/// <c>UIElement::OnSetAttribute @0x00462d80</c>'s case 8
/// (<c>BaseProperty::GetPropertyName(esi) - 0x33 == 8</c>, i.e. property
/// id <c>0x33 + 8 = 0x3B</c>): <c>this-&gt;vtable-&gt;SetVisible(value == 0)</c> —
/// an authored <c>true</c> HIDES the element at construction. Populated the
/// same way as <see cref="TabTable"/>/<see cref="ScrollbarElementId"/>
/// (recomputed fresh from the effective merged state every call), but this
/// is a PURE DATA ADDITION: the shared <see cref="LayoutImporter"/>/
/// <see cref="DatWidgetFactory"/> path does not act on it. 1,083 elements
/// author this flag client-wide (docs/ISSUES.md #408, its own separately-
/// gated general-honor item) — only screens that explicitly walk their own
/// mounted subtree and check this field may hide elements by it (see
/// <c>CharacterCreationUiController</c>'s chargen-scoped honor, register
/// AP-230).
/// </summary>
public bool Invisible;
/// <summary>
/// Campaign CC gate round 1 Batch E (R2-1): the four independent
/// <c>UIElement_Text</c> text-inset margins, dat properties
/// <c>0x23</c>/<c>0x24</c>/<c>0x25</c>/<c>0x26</c> (IntegerBaseProperty
/// — <c>UIElement_Text::OnSetAttribute @0x0046a640</c> cases
/// <c>0xf</c>/<c>0x10</c>/<c>0x11</c>/<c>0x12</c>, i.e.
/// <c>BaseProperty::GetPropertyName(arg2) - 0x14</c>, writing
/// <c>m_margL</c>/<c>m_margR</c>/<c>m_margU</c>/<c>m_margD</c>). Ctor
/// default is 0 on all four (<c>UIElement_Text::UIElement_Text
/// @0x004686d1-0046872d</c> clears them before any authored value
/// applies). The chargen description boxes author <c>margL=9,
/// margR=26, margU=15, margD=15</c> (live-DAT-probe-confirmed on
/// <c>0x100003C4</c>/<c>0x100003E0</c>/<c>0x10000409</c>/
/// <c>0x10000404</c>) — this codebase never read these four
/// properties before this fix, so every DAT-imported <c>UiText</c>
/// drew flush against its own outer rect (<c>Padding</c> alone,
/// always 0 for DAT-built text) regardless of what the DAT actually
/// authored.
/// </summary>
public int MarginLeft, MarginRight, MarginTop, MarginBottom;
/// <summary>
/// #409 (client-wide retail tooltip system): whether THIS element is
/// eligible to trigger retail's hover tooltip at all — dat property
/// <c>0x4B</c> (<c>BoolBaseProperty</c>). Retail
/// <c>UIElement::OnSetAttribute @0x00462d80</c> case <c>0x18</c>
/// (<c>GetPropertyName()-0x33==0x18</c>, i.e. property id
/// <c>0x33+0x18=0x4B</c>) calls <c>UIRegion::SetTooltipOn</c>, which
/// toggles bit 5 of <c>__bitfield164</c>; <c>UIElement::MouseHover
/// @0x00462520</c> checks exactly that bit (plus the GLOBAL
/// <c>UIElementManager::m_tooltipEnable</c> preference) before calling
/// <c>StartTooltipAtMouse</c>. Default false — the ctor-cleared bit,
/// matching "an element must explicitly author <c>P0x4B=true</c> to
/// ever show a tooltip."
/// </summary>
public bool TooltipEnabled;
/// <summary>
/// #409: the tooltip's TEXT, dat property <c>0x49</c>
/// (<c>StringInfo</c>). Retail <c>UIElement::StartTooltipAtMouse
/// @0x00460D70</c> prefers the element's own runtime-settable
/// <c>m_TTText</c> field (set only by the separate, non-dat-authored
/// <c>UIElement::SetTooltip</c> API — game code showing dynamic
/// status text, out of this property's scope) and falls back to
/// <c>vtable-&gt;InqProperty(0x49, ...)</c> — the generic dat-authored
/// property bag read — whenever <c>m_TTText</c> is empty, which for a
/// pure DAT-imported element is ALWAYS (nothing in
/// <c>OnSetAttribute</c>'s switch populates <c>m_TTText</c> from a dat
/// property). Kept RAW (unresolved <see cref="UiStringInfoValue"/>) at
/// this layer — <see cref="ElementReader"/> is dat-free/string-free by
/// design; resolution through <c>DatStringResolver</c> happens in
/// <c>DatWidgetFactory</c>, mirroring the existing <c>0x17</c> caption
/// pattern exactly.
///
/// <para>
/// F3 correction (2026-08-16 review round): the vtable dispatch through
/// <c>InqProperty(0x49, ...)</c> is NOT itself a "dynamic override"
/// mechanism for a plain element. <c>UIElement::InqProperty
/// @0x004638D0</c> — the BASE implementation every element uses unless
/// its own class overrides the virtual — reads exactly the same
/// authored property bags this reader already walks
/// (<c>m_instanceProperties</c>, <c>m_curStateDesc</c>, <c>m_desc</c>).
/// So an element with no literal <c>P0x49</c> gets NOTHING back from
/// retail's own default <c>InqProperty</c> either — the earlier framing
/// ("191 elements rely on retail's dynamic InqProperty(0x49) override")
/// overstated what that fallback does for the general case. The REAL
/// second text source is <c>m_TTText</c> via the explicit
/// <c>UIElement::SetTooltip</c> call family (~15+ game-code call
/// sites), headed by the truncated-text auto-tooltip
/// (<c>UIElement_Text::RecalculateTruncation @0x00466F80</c>, gated on
/// authored <c>P0xD0</c>: an overflowing line calls
/// <c>SetTooltip(this, ownText) @0x00467064</c>; a line that now fits
/// calls <c>ClearTooltip @0x00467076</c>). See register row TS-85 for
/// the honest scope of what remains unported.
/// </para>
/// </summary>
public UiStringInfoValue? TooltipText;
/// <summary>
/// #409: the ROOT ELEMENT ID to instantiate WITHIN the tooltip popup
/// LayoutDesc (<see cref="TooltipLayoutDid"/>) — dat property
/// <c>0x47</c> (<c>EnumBaseProperty</c>, read via
/// <c>UIElement::GetAttribute_Enum(this, 0x47, ...)</c>). Retail
/// <c>UIElementManager::StartTooltip @0x0045DE90</c> passes this value
/// straight to <c>LayoutDesc::AccessElementDesc</c> — it is an
/// element-desc token WITHIN the popup layout, NOT a "tooltip behavior
/// enum" (an earlier, less complete investigation guessed the latter;
/// full decomp re-derivation for this port corrects it). Zero when the
/// element authors no tooltip.
/// </summary>
public uint TooltipRootElementId;
/// <summary>
/// #409: the tooltip POPUP LayoutDesc DID — dat property <c>0x48</c>
/// (<c>DataIdBaseProperty</c>, read via
/// <c>UIElement::GetAttribute_DataID(this, 0x48, ...)</c>). Retail
/// authors <c>0x21000041</c> here on every tooltip-bearing element
/// (live-DAT-probe-confirmed by the #409 investigation). Retail falls
/// back to the HOVERING element's OWN containing <c>m_layout</c> DID
/// when <c>P0x48</c> is absent but <c>P0x47</c> is present — an edge
/// case this port does not reproduce (every live-DAT element the #409
/// sweep found authors <c>P0x48</c> directly alongside <c>P0x47</c>;
/// see the divergence register for the honest row). Zero when absent.
/// </summary>
public uint TooltipLayoutDid;
/// <summary>
/// #409: the tooltip TEXT CHILD's element id — dat property
/// <c>0x4A</c> (<c>EnumBaseProperty</c>). Unlike the other five
/// tooltip properties, retail reads THIS one off the freshly
/// INSTANTIATED popup's own ROOT element (<c>UIElement::
/// GetAttribute_Enum(tooltipRootElement, 0x4a, &amp;textChildId)</c>
/// inside <c>UIElementManager::StartTooltip @0x0045DE90</c>'s
/// fallback branch), not off the hovering trigger element — so this
/// field is meaningful only when read from the popup layout's OWN
/// resolved root <see cref="ElementInfo"/>/<c>UiElement</c>, which the
/// same generic property-read machinery populates uniformly for every
/// imported element regardless of role.
/// </summary>
public uint TooltipTextChildElementId;
/// <summary>
/// #409: a per-element hover-dwell delay OVERRIDE in seconds — dat
/// property <c>0x50</c> (<c>FloatBaseProperty</c>). Retail
/// <c>UIElementManager::CheckTooltip @0x0045B6E0</c> reads
/// <c>UIElement::GetAttribute_Float(hoveredElement, 0x50, &amp;delay)</c>
/// and uses it in place of the global <c>m_tooltipDelay</c>
/// (<c>Misc.TooltipDelay</c> preference, default 0.25 s) when present.
/// Null when the element authors no override.
/// </summary>
public float? TooltipDelaySeconds;
/// <summary>
/// #409 F8: retail's per-element auto-resize WIDTH clamps — dat
/// properties <c>0x3D</c> (max, <c>IntegerBaseProperty</c>) and
/// <c>0x3F</c> (min). <c>UIElement::ResizeTo @0x00463C30</c> reads both
/// via <c>GetAttribute_Int</c> (<c>@0x00463c9c</c>/<c>@0x00463cba</c>)
/// and clamps a caller-requested width to <c>[min, max]</c> BEFORE
/// applying it — <see cref="RetailTooltipPresenter.ApplyTooltipText"/>
/// is the one caller in this port that grows an element via that path.
/// Same property/direction mapping <c>RetailWindowFrame.ResolveConstraint</c>
/// already uses for window frames (0x3D=&gt;MaxWidth, 0x3F=&gt;MinWidth), ported
/// independently here since that helper only runs at window-mount time
/// off a caller-supplied <see cref="ElementInfo"/>, not generically for
/// every imported element. Null = no authored override (retail's
/// <c>GetAttribute_Int</c> returning false leaves the requested value
/// unclamped on that side).
/// </summary>
public int? MaxWidth;
public int? MinWidth;
/// <summary>
/// #409 F8: retail's per-element auto-resize HEIGHT clamps — dat
/// properties <c>0x3C</c> (max) and <c>0x3E</c> (min), read by the same
/// <c>UIElement::ResizeTo @0x00463C30</c> (<c>@0x00463c64</c>/
/// <c>@0x00463c80</c>). See <see cref="MaxWidth"/>'s doc comment for the
/// full citation; this pair is the height side of the same clamp.
/// </summary>
public int? MaxHeight;
public int? MinHeight;
/// <summary>
/// Resolves a property for a state using retail's DirectState-as-base rule. A
/// named state's key overrides DirectState by presence, including false/zero.
/// When no state is supplied, the element's default, Normal, then DirectState
/// is selected in that order.
/// </summary>
public bool TryGetEffectiveProperty(uint propertyId, out UiPropertyValue value, uint? stateId = null)
{
uint effectiveState = stateId ?? EffectiveDefaultStateId();
UiPropertyValue? resolved = null;
if (States.TryGetValue(UiStateInfo.DirectStateId, out var direct)
&& direct.Properties.TryGetValue(propertyId, out var directValue))
{
resolved = directValue;
}
if (effectiveState != UiStateInfo.DirectStateId
&& States.TryGetValue(effectiveState, out var state)
&& state.Properties.TryGetValue(propertyId, out var stateValue))
{
resolved = stateValue;
}
value = resolved!;
return resolved is not null;
}
public bool TryGetEffectiveBool(uint propertyId, out bool value, uint? stateId = null)
{
if (TryGetEffectiveProperty(propertyId, out var property, stateId)
&& property.Kind == UiPropertyKind.Bool)
{
value = property.BoolValue;
return true;
}
value = false;
return false;
}
public bool TryGetEffectiveInteger(uint propertyId, out int value, uint? stateId = null)
{
if (TryGetEffectiveProperty(propertyId, out var property, stateId)
&& property.Kind == UiPropertyKind.Integer)
{
value = property.IntegerValue;
return true;
}
value = 0;
return false;
}
public bool TryGetEffectiveFloat(uint propertyId, out float value, uint? stateId = null)
{
if (TryGetEffectiveProperty(propertyId, out var property, stateId)
&& property.Kind == UiPropertyKind.Float)
{
value = property.FloatValue;
return true;
}
value = 0f;
return false;
}
public uint EffectiveDefaultStateId()
{
if (DefaultStateId != 0 && States.ContainsKey(DefaultStateId))
return DefaultStateId;
if (States.ContainsKey(1u)) // UIStateId.Normal
return 1u;
return UiStateInfo.DirectStateId;
}
}
/// <summary>
/// Pure, GL-free, dat-free transforms for the LayoutDesc importer.
/// All methods are static and operate on <see cref="ElementInfo"/> POCOs.
/// No OpenGL, no DatReaderWriter types, no rendering dependencies beyond
/// the <see cref="AnchorEdges"/> bit-flag enum from <c>AcDream.App.UI</c>.
/// </summary>
public static class ElementReader
{
/// <summary>Compatibility projection from raw retail modes to the legacy
/// <see cref="AnchorEdges"/> flags. This projection cannot represent centered
/// mode 3 or proportional mode 4 exactly. Imported DAT widgets therefore use
/// <see cref="UiLayoutPolicy"/>; call this only for legacy/programmatic paths.</summary>
/// <param name="left">LeftEdge dat field value (04).</param>
/// <param name="top">TopEdge dat field value (04).</param>
/// <param name="right">RightEdge dat field value (04).</param>
/// <param name="bottom">BottomEdge dat field value (04).</param>
public static AnchorEdges ToAnchors(uint left, uint top, uint right, uint bottom)
{
var a = AnchorEdges.None;
if (left == 1 || left == 4) a |= AnchorEdges.Left;
if (right == 1 || right == 4 || left == 2) a |= AnchorEdges.Right;
if (top == 1 || top == 4) a |= AnchorEdges.Top;
if (bottom == 1 || bottom == 4 || top == 2) a |= AnchorEdges.Bottom;
if (a == AnchorEdges.None) a = AnchorEdges.Left | AnchorEdges.Top; // default: pin top-left
return a;
}
/// <summary>
/// Merges a base element snapshot with a derived element snapshot, mirroring
/// the <c>BaseElement</c> / <c>BaseLayoutId</c> inheritance chain in the dat.
///
/// <para>
/// Rules:
/// <list type="bullet">
/// <item><description>
/// Scalar fields (<see cref="ElementInfo.Id"/>, <see cref="ElementInfo.Type"/>,
/// <see cref="ElementInfo.Width"/>, <see cref="ElementInfo.Height"/>,
/// <see cref="ElementInfo.FontDid"/>): derived wins if non-zero; otherwise
/// inherited from base.
/// </description></item>
/// <item><description>
/// Position (<see cref="ElementInfo.X"/>, <see cref="ElementInfo.Y"/>) and
/// edge flags (<see cref="ElementInfo.Left"/> etc.) and
/// <see cref="ElementInfo.ReadOrder"/>: always taken from the derived element
/// (derived placement, not the base prototype's geometry).
/// </description></item>
/// <item><description>
/// <see cref="ElementInfo.StateMedia"/> and <see cref="ElementInfo.StateCursors"/>:
/// base entries are the default; derived entries override (or add) per state name key.
/// </description></item>
/// <item><description>
/// <see cref="ElementInfo.Children"/>: are not combined by this scalar merge helper;
/// <see cref="LayoutImporter"/> separately ports retail's recursive child-table
/// incorporation.
/// </description></item>
/// </list>
/// </para>
/// </summary>
public static ElementInfo Merge(ElementInfo base_, ElementInfo derived)
{
var m = new ElementInfo
{
Id = derived.Id != 0 ? derived.Id : base_.Id,
// Type: derived wins if non-zero. Layout instances commonly carry Type 0
// and inherit the registered widget type (for example Type 12 text) from
// their base prototype; DatWidgetFactory then builds that behavioral type.
Type = derived.Type != 0 ? derived.Type : base_.Type,
X = derived.X,
Y = derived.Y,
// NOTE: 0 is the "not set, inherit from base" sentinel for Width/Height. This
// diverges from the format doc §12 rule 2 ("derived W/H win even if zero") but is
// indistinguishable for Plan 1 (all base elements are zero-size Type-12 prototypes).
// If a real zero-size derived element ever needs to override a non-zero base in
// switch Width/Height to nullable values and use presence-aware merging.
Width = derived.Width != 0 ? derived.Width : base_.Width,
Height = derived.Height != 0 ? derived.Height : base_.Height,
Left = derived.Left,
Top = derived.Top,
Right = derived.Right,
Bottom = derived.Bottom,
ReadOrder = derived.ReadOrder,
ZLevel = derived.ZLevel != 0 ? derived.ZLevel : base_.ZLevel,
DefaultStateId = derived.DefaultStateId != 0 ? derived.DefaultStateId : base_.DefaultStateId,
FontDid = derived.FontDid != 0 ? derived.FontDid : base_.FontDid,
// HJustify/VJustify: derived wins when it carries an explicit non-Center value
// (the dat property was present and read); otherwise inherit the base prototype's value.
// Center is the default (= "not set by this element") so Center-derived never overrides
// a non-Center base — matching the FontDid "non-zero wins" convention.
HJustify = derived.HJustify != HJustify.Center ? derived.HJustify : base_.HJustify,
VJustify = derived.VJustify != VJustify.Center ? derived.VJustify : base_.VJustify,
// FontColor: derived wins when it has an explicit (non-null) color; otherwise inherit the base.
// Null means "dat carried no 0x1B property" — so null-derived does NOT override a non-null base.
FontColor = derived.FontColor ?? base_.FontColor,
// Outline: derived wins when true (the dat property 0x21 was present and read as
// true); otherwise inherit the base. False-derived never overrides a true base —
// matching the FontDid/HJustify "non-default wins" convention.
Outline = derived.Outline || base_.Outline,
// OutlineColor: same "non-null derived wins" rule as FontColor.
OutlineColor = derived.OutlineColor ?? base_.OutlineColor,
// R2-1: margins follow the same "non-zero derived wins" convention as
// FontDid/ZLevel above — a derived element that authors no margin
// property (0 is ApplyCanonicalLegacyProjection's own unset default,
// matching retail's ctor-cleared default too) inherits the base
// prototype's margin instead of silently zeroing it out.
MarginLeft = derived.MarginLeft != 0 ? derived.MarginLeft : base_.MarginLeft,
MarginRight = derived.MarginRight != 0 ? derived.MarginRight : base_.MarginRight,
MarginTop = derived.MarginTop != 0 ? derived.MarginTop : base_.MarginTop,
MarginBottom = derived.MarginBottom != 0 ? derived.MarginBottom : base_.MarginBottom,
// DefaultStateName: derived wins if set; otherwise inherit the base's default.
DefaultStateName = !string.IsNullOrEmpty(derived.DefaultStateName) ? derived.DefaultStateName : base_.DefaultStateName,
// This helper merges one element snapshot only. LayoutImporter separately
// incorporates the child tables after the scalar/state merge.
// Defensive copy prevents later mutation of either input.
Children = new List<ElementInfo>(derived.Children),
};
// Start with base StateMedia as defaults, then let derived entries override.
m.StateMedia = new Dictionary<string, (uint, int)>(base_.StateMedia);
foreach (var kv in derived.StateMedia)
m.StateMedia[kv.Key] = kv.Value;
m.StateCursors = new Dictionary<string, UiCursorMedia>(base_.StateCursors);
foreach (var kv in derived.StateCursors)
m.StateCursors[kv.Key] = kv.Value;
m.States = new Dictionary<uint, UiStateInfo>();
foreach (var (id, state) in base_.States)
m.States[id] = state.Clone();
foreach (var (id, state) in derived.States)
{
m.States[id] = m.States.TryGetValue(id, out var baseState)
? UiStateInfo.Merge(baseState, state)
: state.Clone();
}
ApplyCanonicalLegacyProjection(m);
return m;
}
internal static void ApplyCanonicalLegacyProjection(ElementInfo info)
{
if (info.TryGetEffectiveProperty(0x1Au, out var font)
&& font.Kind == UiPropertyKind.Array
&& font.ArrayValue.Count > 0
&& font.ArrayValue[0].Kind == UiPropertyKind.DataId)
{
info.FontDid = checked((uint)font.ArrayValue[0].UnsignedValue);
}
if (info.TryGetEffectiveProperty(0x14u, out var horizontal)
&& horizontal.Kind == UiPropertyKind.Enum)
{
info.HJustify = horizontal.UnsignedValue switch
{
0u or 2u => HJustify.Left,
3u or 5u => HJustify.Right,
_ => HJustify.Center,
};
}
if (info.TryGetEffectiveProperty(0x15u, out var vertical)
&& vertical.Kind == UiPropertyKind.Enum)
{
info.VJustify = vertical.UnsignedValue switch
{
2u => VJustify.Top,
4u => VJustify.Bottom,
_ => VJustify.Center,
};
}
if (info.TryGetEffectiveProperty(0x1Bu, out var color))
{
UiPropertyValue? colorValue = color.Kind == UiPropertyKind.Color
? color
: color.Kind == UiPropertyKind.Array
&& color.ArrayValue.Count > 0
&& color.ArrayValue[0].Kind == UiPropertyKind.Color
? color.ArrayValue[0]
: null;
if (colorValue is not null)
{
var c = colorValue.ColorValue;
float alpha = c.Alpha == 0 ? 1f : c.Alpha / 255f;
info.FontColor = new Vector4(c.Red / 255f, c.Green / 255f, c.Blue / 255f, alpha);
}
}
// Outline (0x21): BoolBaseProperty. Retail SetOutline @0x0046a81c / m_bitField & 0x10.
if (info.TryGetEffectiveProperty(0x21u, out var outline)
&& outline.Kind == UiPropertyKind.Bool)
{
info.Outline = outline.BoolValue;
}
// OutlineColor (0x22): ColorBaseProperty (matches FontColor's Array-tolerant read —
// no authored 0x22 is currently array-wrapped, but the fallback costs nothing).
// Retail m_curOutlineColor, ctor default RGBAColor_Black.
if (info.TryGetEffectiveProperty(0x22u, out var outlineColor))
{
UiPropertyValue? outlineColorValue = outlineColor.Kind == UiPropertyKind.Color
? outlineColor
: outlineColor.Kind == UiPropertyKind.Array
&& outlineColor.ArrayValue.Count > 0
&& outlineColor.ArrayValue[0].Kind == UiPropertyKind.Color
? outlineColor.ArrayValue[0]
: null;
if (outlineColorValue is not null)
{
var c = outlineColorValue.ColorValue;
float alpha = c.Alpha == 0 ? 1f : c.Alpha / 255f;
info.OutlineColor = new Vector4(c.Red / 255f, c.Green / 255f, c.Blue / 255f, alpha);
}
}
// R2-1 (Campaign CC gate round 1 Batch E): the four text-inset margins
// (0x23 Left / 0x24 Right / 0x25 Up / 0x26 Down, IntegerBaseProperty —
// see MarginLeft's own doc comment for the decomp anchor). Absent
// properties leave the ElementInfo default of 0, matching retail's
// ctor-cleared default.
if (info.TryGetEffectiveInteger(0x23u, out int marginLeft))
info.MarginLeft = marginLeft;
if (info.TryGetEffectiveInteger(0x24u, out int marginRight))
info.MarginRight = marginRight;
if (info.TryGetEffectiveInteger(0x25u, out int marginTop))
info.MarginTop = marginTop;
if (info.TryGetEffectiveInteger(0x26u, out int marginBottom))
info.MarginBottom = marginBottom;
// Tab table (0x2E): array of StructBaseProperty (MasterPropertyId 0x2F) — the
// Type-8 tab control's authored {button element, page element, isDefault} rows
// (docs/research/2026-08-10-options-panel-structure.md §1.3). Recomputed fresh
// from the current effective state every call, so Merge's base+derived state
// combination is picked up automatically without a separate scalar-merge rule.
info.TabTable = ReadTabTable(info);
// Row-template list (0x64): a ListBox's authored {template layout DID (0x63),
// template element id (0x62)} rows consumed by AddItemFromTemplateList
// (same doc §1.5).
info.TemplateList = ReadTemplateList(info);
// Scrollbar linkage (0x72): the element id of the ListBox's paired scrollbar
// (same doc §10.1 — e.g. Character ListBox 0x100001FA names scrollbar
// 0x100001FB).
info.ScrollbarElementId = ReadReferencedElementId(info, 0x72u);
// AP-195: the CheckboxBitfield64 block's own all/partial-set LED media DIDs
// (0x10000082/0x10000083 — DataId-kind properties, verified against the
// committed options_2100002B.json fixture at element 0x10000520: Kind=2
// (DataId), UnsignedValue 100683031/100683033 == 0x06004D17/0x06004D19).
info.LedCheckedSprite = ReadReferencedElementId(info, 0x10000082u);
info.LedUncheckedSprite = ReadReferencedElementId(info, 0x10000083u);
// GF-13: Invisible (0x3B), BoolBaseProperty. Retail
// UIElement::OnSetAttribute @0x00462d80 case 8 — SetVisible(value == 0),
// so an authored true HIDES the element. Read via the same
// TryGetEffectiveBool the DirectState/default-state resolution rules
// already use for every other canonical-projection property above.
if (info.TryGetEffectiveBool(0x3Bu, out bool invisible))
{
info.Invisible = invisible;
}
// #409 (client-wide retail tooltip system): the six per-element
// tooltip properties. See each ElementInfo field's own doc comment
// for the exact decomp anchor. TooltipTextChildElementId (0x4A) is
// read the SAME generic way here even though retail only consults
// it off a tooltip POPUP's own root element — the read is uniform
// and harmless for every other element (it stays 0).
if (info.TryGetEffectiveBool(0x4Bu, out bool tooltipOn))
{
info.TooltipEnabled = tooltipOn;
}
if (info.TryGetEffectiveProperty(0x49u, out var tooltipText)
&& tooltipText.Kind == UiPropertyKind.StringInfo)
{
info.TooltipText = tooltipText.StringInfoValue;
}
info.TooltipRootElementId = ReadReferencedElementId(info, 0x47u);
info.TooltipLayoutDid = ReadReferencedElementId(info, 0x48u);
info.TooltipTextChildElementId = ReadReferencedElementId(info, 0x4Au);
if (info.TryGetEffectiveFloat(0x50u, out float tooltipDelay))
{
info.TooltipDelaySeconds = tooltipDelay;
}
// #409 F8: the four ResizeTo auto-resize clamps (0x3C/0x3D/0x3E/0x3F
// — see MaxWidth's/MaxHeight's own doc comments for the decomp
// anchor). Read generically for every element, same shape as the
// margins above; only RetailTooltipPresenter consumes them today.
if (info.TryGetEffectiveInteger(0x3Du, out int maxWidth))
info.MaxWidth = maxWidth;
if (info.TryGetEffectiveInteger(0x3Fu, out int minWidth))
info.MinWidth = minWidth;
if (info.TryGetEffectiveInteger(0x3Cu, out int maxHeight))
info.MaxHeight = maxHeight;
if (info.TryGetEffectiveInteger(0x3Eu, out int minHeight))
info.MinHeight = minHeight;
}
private static List<UiTabTableEntry> ReadTabTable(ElementInfo info)
{
var entries = new List<UiTabTableEntry>();
if (!info.TryGetEffectiveProperty(0x2Eu, out var property)
|| property.Kind != UiPropertyKind.Array)
return entries;
foreach (UiPropertyValue item in property.ArrayValue)
{
if (item.Kind != UiPropertyKind.Struct) continue;
uint buttonId = ReadStructMemberId(item.StructValue, 0x30u);
uint pageId = ReadStructMemberId(item.StructValue, 0x31u);
// Retail UIElement_Panel::SetupTabPageHash @0x0046C2E0 skips an entry whose
// InqProperty(0x30) (@0x0046C3E5) or InqProperty(0x31) (@0x0046C3FC) is
// missing — a partial entry never enters m_TabPageHash. ReadStructMemberId
// returns 0 for a missing/uncoercible member, and 0 is never a valid dat
// element id, so treat it the same way: skip, don't admit a zero-id entry
// that could still participate in SwitchTo and hide a page.
if (buttonId == 0u || pageId == 0u) continue;
bool isDefault = item.StructValue.TryGetValue(0x32u, out var flag)
&& flag.Kind == UiPropertyKind.Bool
&& flag.BoolValue;
entries.Add(new UiTabTableEntry(buttonId, pageId, isDefault));
}
return entries;
}
private static List<UiTemplateListEntry> ReadTemplateList(ElementInfo info)
{
var entries = new List<UiTemplateListEntry>();
if (!info.TryGetEffectiveProperty(0x64u, out var property)
|| property.Kind != UiPropertyKind.Array)
return entries;
foreach (UiPropertyValue item in property.ArrayValue)
{
if (item.Kind != UiPropertyKind.Struct) continue;
uint layoutDid = ReadStructMemberId(item.StructValue, 0x63u);
uint elementId = ReadStructMemberId(item.StructValue, 0x62u);
entries.Add(new UiTemplateListEntry(layoutDid, elementId));
}
return entries;
}
/// <summary>
/// Coerces a struct member (a nested <see cref="UiPropertyValue"/> keyed by its own
/// master-property id) into an element/DID-shaped uint. Mirrors
/// <c>DatWidgetFactory.ReferencedElementId</c>'s Enum/DataId/Integer kind handling
/// exactly — element and DID references are authored inconsistently across dat
/// property kinds, so every reader that resolves one needs the same tolerance.
/// </summary>
private static uint ReadStructMemberId(IReadOnlyDictionary<uint, UiPropertyValue> members, uint key)
{
if (!members.TryGetValue(key, out var value))
return 0u;
return value.Kind switch
{
UiPropertyKind.Enum or UiPropertyKind.DataId => (uint)value.UnsignedValue,
UiPropertyKind.Integer when value.IntegerValue >= 0 => (uint)value.IntegerValue,
_ => 0u,
};
}
/// <summary>Same Enum/DataId/Integer coercion as <see cref="ReadStructMemberId"/>,
/// applied to a top-level effective property instead of a struct member — used for
/// scalar element-id references like property 0x72.</summary>
private static uint ReadReferencedElementId(ElementInfo info, uint propertyId)
{
if (!info.TryGetEffectiveProperty(propertyId, out var property))
return 0u;
return property.Kind switch
{
UiPropertyKind.Enum or UiPropertyKind.DataId => (uint)property.UnsignedValue,
UiPropertyKind.Integer when property.IntegerValue >= 0 => (uint)property.IntegerValue,
_ => 0u,
};
}
/// <summary>
/// Resolves an authored color or color-array property after the retail
/// DirectState/default-state inheritance rules have been applied.
/// </summary>
internal static Vector4[] ReadEffectiveColorPalette(
ElementInfo info,
uint propertyId)
{
ArgumentNullException.ThrowIfNull(info);
if (!info.TryGetEffectiveProperty(propertyId, out UiPropertyValue value))
return [];
IEnumerable<UiPropertyValue> entries = value.Kind switch
{
UiPropertyKind.Color => [value],
UiPropertyKind.Array => value.ArrayValue,
_ => [],
};
return entries
.Where(entry => entry.Kind == UiPropertyKind.Color)
.Select(entry =>
{
UiColorValue color = entry.ColorValue;
float alpha = color.Alpha == 0 ? 1f : color.Alpha / 255f;
return new Vector4(
color.Red / 255f,
color.Green / 255f,
color.Blue / 255f,
alpha);
})
.ToArray();
}
/// <summary>
/// AP-222 / GF-11b (Campaign CC gate round 1 Batch B): resolves a color
/// property (0x1B FontColor's Array-tolerant shape, same unwrap as
/// <see cref="ReadEffectiveColorPalette"/>) for EVERY state <paramref
/// name="info"/> itself authors, keyed by retail numeric state id.
/// Returns null unless at least two states resolve to GENUINELY
/// DIFFERENT colors — the overwhelming majority of elements author one
/// color for every state (or none at all), and for those this returns
/// null so the caller keeps its existing single-default-color behavior
/// untouched. Only elements that really do recolor per state (the
/// Appearance spins' Highlight brightening, the Town buttons' Normal-
/// to-white caption swap) get a non-null map.
/// </summary>
internal static IReadOnlyDictionary<uint, Vector4>? BuildPerStateColorMap(
ElementInfo info, uint propertyId)
{
Dictionary<uint, Vector4>? map = null;
foreach (uint stateId in info.States.Keys)
{
if (!info.TryGetEffectiveProperty(propertyId, out UiPropertyValue value, stateId))
continue;
UiPropertyValue? colorValue = value.Kind == UiPropertyKind.Color
? value
: value.Kind == UiPropertyKind.Array
&& value.ArrayValue.Count > 0
&& value.ArrayValue[0].Kind == UiPropertyKind.Color
? value.ArrayValue[0]
: null;
if (colorValue is null)
continue;
UiColorValue c = colorValue.ColorValue;
float alpha = c.Alpha == 0 ? 1f : c.Alpha / 255f;
(map ??= new Dictionary<uint, Vector4>())[stateId] =
new Vector4(c.Red / 255f, c.Green / 255f, c.Blue / 255f, alpha);
}
return map is { Count: > 1 } && map.Values.Distinct().Count() > 1 ? map : null;
}
/// <summary>
/// AP-222 counterpart of <see cref="BuildPerStateColorMap"/> for a bool
/// property (0x21 Outline) — same "null unless genuinely per-state"
/// gating.
/// </summary>
internal static IReadOnlyDictionary<uint, bool>? BuildPerStateBoolMap(
ElementInfo info, uint propertyId)
{
Dictionary<uint, bool>? map = null;
foreach (uint stateId in info.States.Keys)
{
if (!info.TryGetEffectiveProperty(propertyId, out UiPropertyValue value, stateId)
|| value.Kind != UiPropertyKind.Bool)
continue;
(map ??= new Dictionary<uint, bool>())[stateId] = value.BoolValue;
}
return map is { Count: > 1 } && map.Values.Distinct().Count() > 1 ? map : null;
}
}