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>
961 lines
47 KiB
C#
961 lines
47 KiB
C#
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 0–4. 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 & 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->vtable->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->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, &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, &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=>MaxWidth, 0x3F=>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 (0–4).</param>
|
||
/// <param name="top">TopEdge dat field value (0–4).</param>
|
||
/// <param name="right">RightEdge dat field value (0–4).</param>
|
||
/// <param name="bottom">BottomEdge dat field value (0–4).</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;
|
||
}
|
||
}
|