using System.Collections.Generic; using System.Linq; using System.Numerics; using AcDream.App.UI; namespace AcDream.App.UI.Layout; /// /// Horizontal text justification read from dat property 0x14 (UIElement HorizontalJustification). /// Retail CalcJustification @ 0x00467260 treats 1 as Center, /// 3/5 as Right, and every other value (including constructor default 2) as Left. /// public enum HJustify : byte { Left = 0, Center = 1, Right = 2 } /// /// Vertical text justification read from dat property 0x15 (UIElement VerticalJustification). /// Values: 2=Top, 4=Bottom; absent/other = Center. /// public enum VJustify : byte { Top = 0, Center = 1, Bottom = 2 } /// /// One row of a Type-8 tab control's authored tab table (dat property 0x2E, an /// array of StructBaseProperty with MasterPropertyId 0x2F). Retail /// 0x2100002B §1.3: is the clickable tab button /// (struct member 0x30), is the page-slot child it /// activates (0x31), and marks the one entry /// (0x32) whose page is shown when the tab control first mounts. /// public readonly record struct UiTabTableEntry(uint ButtonElementId, uint PageElementId, bool IsDefault); /// /// One row of a Type-5 ListBox's authored row-template list (dat property 0x64, /// an array of StructBaseProperty). Retail 0x2100002B §1.5: /// is the LayoutDesc DID carrying the template element /// (struct member 0x63), is the template root /// within that layout (0x62). Retail /// UIElement_ListBox::AddItemFromTemplateList(index) instantiates row /// index's subtree through this pair. /// public readonly record struct UiTemplateListEntry(uint TemplateLayoutId, uint TemplateElementId); /// /// GL-free, dat-free snapshot of a resolved layout element. /// Populated by the LayoutDesc importer from DatReaderWriter.ElementDesc /// after inheritance is applied. The pure transforms on /// 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. /// public sealed class ElementInfo { /// Dat element id (e.g. 0x100000E6). public uint Id; /// /// Raw element class id as a uint. /// Game-specific ids like 0x1000004D (gmVitalsUI root) and 0x10000009 /// overflow int when treated as signed, so this stays uint. /// Known values: 0=text, 2=dragbar, 3=container/chrome, 7=meter, /// 9=resize-grip, 12=style-prototype (skip), 0x10000009/0x1000004D=window root. /// public uint Type; /// Position and size within the parent, in pixels (cast from dat uint fields). public float X, Y, Width, Height; /// /// 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. /// public float OriginalParentWidth, OriginalParentHeight; public bool HasOriginalParentSize; /// /// Raw edge-anchor flag values from the dat (LeftEdge, TopEdge, /// RightEdge, BottomEdge fields of ElementDesc). /// Values 0–4. Imported elements preserve these in ; /// is only the compatibility projection /// for older programmatic consumers. /// public uint Left, Top, Right, Bottom; /// Draw order within the parent (lower = drawn first / behind). public uint ReadOrder; /// Layer level from the dat (ElementDesc.ZLevel). Higher = drawn further /// BACK (a full-window backdrop at ZLevel 100 sits behind ZLevel-0 panels). The factory /// folds it into so ZLevel dominates and ReadOrder is the /// within-layer tiebreaker. Issue #145 (vitals are all ZLevel 0, so they're unaffected). public uint ZLevel; /// /// Canonical state descriptors keyed by numeric retail state id. The unnamed /// DirectState uses . Unlike the legacy /// projections below, properties remain scoped to the state that defines them. /// public Dictionary States = new(); /// Numeric form of ; zero means unset. public uint DefaultStateId; /// /// Font dat object id inherited from the base element's Properties[0x1A] /// (ArrayBaseProperty → DataIdBaseProperty). 0 = none / not inherited. /// public uint FontDid; /// /// Horizontal text justification from dat Properties[0x14] /// (EnumBaseProperty: 0=Left, 1=Center, 3/5=Right). /// Default is to preserve existing behavior where /// controllers set Centered=true and no property was read. /// public HJustify HJustify = HJustify.Center; /// /// Vertical text justification from dat Properties[0x15] /// (EnumBaseProperty: 2=Top, 4=Bottom; absent/other = Center). /// Default is to preserve existing behavior. /// public VJustify VJustify = VJustify.Center; /// /// Font color from dat Properties[0x1B] (ColorBaseProperty, ARGB bytes). /// Null when the dat carries no color for this element; the factory then leaves the /// widget at its default white (). /// Propagated in with the same "non-null derived wins" /// rule used for and . /// public Vector4? FontColor; /// /// Outline flag from dat Properties[0x21] (BoolBaseProperty). Retail /// UIElement_Text::SetOutline @0x0046a81c / m_bitField & 0x10. /// Default false (ctor m_bitField=0x300 clears the outline bit) — outlining is /// opt-in per element. Propagated in with the same /// "derived wins when true" rule used for . /// public bool Outline; /// /// Outline color from dat Properties[0x22] (ColorBaseProperty). Retail /// m_curOutlineColor, ctor default RGBAColor_Black /// (UIElement_Text::UIElement_Text @0x004686cb). Null means "not authored" — /// the factory then leaves the widget at its own black default /// (). Propagated in /// with the same "non-null derived wins" rule used /// for . /// public Vector4? OutlineColor; /// /// Sprite per state: state name → (RenderSurface file id, DrawMode int). /// The "" key represents the unnamed DirectState (ElementDesc.StateDesc). /// Named states use the UIStateId.ToString() value as the key /// (e.g. "HideDetail", "ShowDetail"). /// public Dictionary StateMedia = new(); /// /// Cursor per state: state name to (RenderSurface file id, hotspot). /// The "" key represents DirectState, mirroring . /// public Dictionary StateCursors = new(); /// /// The element's initial active state name, taken from ElementDesc.DefaultState.ToString(). /// Normalized to "" when the dat carries Undef/Undefined/0 (no default set). /// Used by to pick which state's sprite to render initially. /// Examples: "Normal" (Send button), "Minimized" (max/min button), "" (DirectState). /// public string DefaultStateName = ""; /// /// 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. /// public List Children = new(); /// /// Tab table read from dat property 0x2E (Type-8 UIElement_TabControl /// only — empty on every other element). Populated once via /// using the canonical /// effective-state resolution, exactly like / /// above — never scanned per-state as each StateDesc 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 /// LayoutImporter.ReadState). /// public List TabTable = new(); /// /// ListBox row-template list read from dat property 0x64 (Type-5 /// UIElement_ListBox elements that author one — empty otherwise). Populated /// the same way as . /// public List TemplateList = new(); /// /// AP-195 (Campaign OP slice OP5): UIOption_CheckboxBitfield64's block-level /// "all mask bits set" LED media (dat property 0x10000082, /// P0x10000082 = 0x06004D17 on the authored template consumer /// 0x10000520) — retail Refresh @0x004859C0 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 . /// public uint LedCheckedSprite; /// /// AP-195 counterpart of : the "some but not all mask /// bits set" LED media (dat property 0x10000083, /// P0x10000083 = 0x06004D19). Retail applies this ONLY while the row is /// checked (any-set) but not fully checked (all-set) — see /// UiCheckboxBitfield64's own doc for the full any/all-set decode. /// public uint LedUncheckedSprite; /// /// Element id of this ListBox's linked scrollbar, read from dat property /// 0x72 (e.g. the Character page's ListBox 0x100001FA names /// scrollbar 0x100001FB). 0 when the element authors no scrollbar /// reference. Populated the same way as . /// public uint ScrollbarElementId; /// /// GF-13 (Campaign CC gate round 1, Batch A): the authored Invisible flag /// from dat property 0x3B (BoolBaseProperty). Retail /// UIElement::OnSetAttribute @0x00462d80's case 8 /// (BaseProperty::GetPropertyName(esi) - 0x33 == 8, i.e. property /// id 0x33 + 8 = 0x3B): this->vtable->SetVisible(value == 0) — /// an authored true HIDES the element at construction. Populated the /// same way as / /// (recomputed fresh from the effective merged state every call), but this /// is a PURE DATA ADDITION: the shared / /// 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 /// CharacterCreationUiController's chargen-scoped honor, register /// AP-230). /// public bool Invisible; /// /// Campaign CC gate round 1 Batch E (R2-1): the four independent /// UIElement_Text text-inset margins, dat properties /// 0x23/0x24/0x25/0x26 (IntegerBaseProperty /// — UIElement_Text::OnSetAttribute @0x0046a640 cases /// 0xf/0x10/0x11/0x12, i.e. /// BaseProperty::GetPropertyName(arg2) - 0x14, writing /// m_margL/m_margR/m_margU/m_margD). Ctor /// default is 0 on all four (UIElement_Text::UIElement_Text /// @0x004686d1-0046872d clears them before any authored value /// applies). The chargen description boxes author margL=9, /// margR=26, margU=15, margD=15 (live-DAT-probe-confirmed on /// 0x100003C4/0x100003E0/0x10000409/ /// 0x10000404) — this codebase never read these four /// properties before this fix, so every DAT-imported UiText /// drew flush against its own outer rect (Padding alone, /// always 0 for DAT-built text) regardless of what the DAT actually /// authored. /// public int MarginLeft, MarginRight, MarginTop, MarginBottom; /// /// #409 (client-wide retail tooltip system): whether THIS element is /// eligible to trigger retail's hover tooltip at all — dat property /// 0x4B (BoolBaseProperty). Retail /// UIElement::OnSetAttribute @0x00462d80 case 0x18 /// (GetPropertyName()-0x33==0x18, i.e. property id /// 0x33+0x18=0x4B) calls UIRegion::SetTooltipOn, which /// toggles bit 5 of __bitfield164; UIElement::MouseHover /// @0x00462520 checks exactly that bit (plus the GLOBAL /// UIElementManager::m_tooltipEnable preference) before calling /// StartTooltipAtMouse. Default false — the ctor-cleared bit, /// matching "an element must explicitly author P0x4B=true to /// ever show a tooltip." /// public bool TooltipEnabled; /// /// #409: the tooltip's TEXT, dat property 0x49 /// (StringInfo). Retail UIElement::StartTooltipAtMouse /// @0x00460D70 prefers the element's own runtime-settable /// m_TTText field (set only by the separate, non-dat-authored /// UIElement::SetTooltip API — game code showing dynamic /// status text, out of this property's scope) and falls back to /// vtable->InqProperty(0x49, ...) — the generic dat-authored /// property bag read — whenever m_TTText is empty, which for a /// pure DAT-imported element is ALWAYS (nothing in /// OnSetAttribute's switch populates m_TTText from a dat /// property). Kept RAW (unresolved ) at /// this layer — is dat-free/string-free by /// design; resolution through DatStringResolver happens in /// DatWidgetFactory, mirroring the existing 0x17 caption /// pattern exactly. /// /// /// F3 correction (2026-08-16 review round): the vtable dispatch through /// InqProperty(0x49, ...) is NOT itself a "dynamic override" /// mechanism for a plain element. UIElement::InqProperty /// @0x004638D0 — the BASE implementation every element uses unless /// its own class overrides the virtual — reads exactly the same /// authored property bags this reader already walks /// (m_instanceProperties, m_curStateDesc, m_desc). /// So an element with no literal P0x49 gets NOTHING back from /// retail's own default InqProperty 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 m_TTText via the explicit /// UIElement::SetTooltip call family (~15+ game-code call /// sites), headed by the truncated-text auto-tooltip /// (UIElement_Text::RecalculateTruncation @0x00466F80, gated on /// authored P0xD0: an overflowing line calls /// SetTooltip(this, ownText) @0x00467064; a line that now fits /// calls ClearTooltip @0x00467076). See register row TS-85 for /// the honest scope of what remains unported. /// /// public UiStringInfoValue? TooltipText; /// /// #409: the ROOT ELEMENT ID to instantiate WITHIN the tooltip popup /// LayoutDesc () — dat property /// 0x47 (EnumBaseProperty, read via /// UIElement::GetAttribute_Enum(this, 0x47, ...)). Retail /// UIElementManager::StartTooltip @0x0045DE90 passes this value /// straight to LayoutDesc::AccessElementDesc — 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. /// public uint TooltipRootElementId; /// /// #409: the tooltip POPUP LayoutDesc DID — dat property 0x48 /// (DataIdBaseProperty, read via /// UIElement::GetAttribute_DataID(this, 0x48, ...)). Retail /// authors 0x21000041 here on every tooltip-bearing element /// (live-DAT-probe-confirmed by the #409 investigation). Retail falls /// back to the HOVERING element's OWN containing m_layout DID /// when P0x48 is absent but P0x47 is present — an edge /// case this port does not reproduce (every live-DAT element the #409 /// sweep found authors P0x48 directly alongside P0x47; /// see the divergence register for the honest row). Zero when absent. /// public uint TooltipLayoutDid; /// /// #409: the tooltip TEXT CHILD's element id — dat property /// 0x4A (EnumBaseProperty). Unlike the other five /// tooltip properties, retail reads THIS one off the freshly /// INSTANTIATED popup's own ROOT element (UIElement:: /// GetAttribute_Enum(tooltipRootElement, 0x4a, &textChildId) /// inside UIElementManager::StartTooltip @0x0045DE90'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 /UiElement, which the /// same generic property-read machinery populates uniformly for every /// imported element regardless of role. /// public uint TooltipTextChildElementId; /// /// #409: a per-element hover-dwell delay OVERRIDE in seconds — dat /// property 0x50 (FloatBaseProperty). Retail /// UIElementManager::CheckTooltip @0x0045B6E0 reads /// UIElement::GetAttribute_Float(hoveredElement, 0x50, &delay) /// and uses it in place of the global m_tooltipDelay /// (Misc.TooltipDelay preference, default 0.25 s) when present. /// Null when the element authors no override. /// public float? TooltipDelaySeconds; /// /// #409 F8: retail's per-element auto-resize WIDTH clamps — dat /// properties 0x3D (max, IntegerBaseProperty) and /// 0x3F (min). UIElement::ResizeTo @0x00463C30 reads both /// via GetAttribute_Int (@0x00463c9c/@0x00463cba) /// and clamps a caller-requested width to [min, max] BEFORE /// applying it — /// is the one caller in this port that grows an element via that path. /// Same property/direction mapping RetailWindowFrame.ResolveConstraint /// 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 , not generically for /// every imported element. Null = no authored override (retail's /// GetAttribute_Int returning false leaves the requested value /// unclamped on that side). /// public int? MaxWidth; public int? MinWidth; /// /// #409 F8: retail's per-element auto-resize HEIGHT clamps — dat /// properties 0x3C (max) and 0x3E (min), read by the same /// UIElement::ResizeTo @0x00463C30 (@0x00463c64/ /// @0x00463c80). See 's doc comment for the /// full citation; this pair is the height side of the same clamp. /// public int? MaxHeight; public int? MinHeight; /// /// 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. /// 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; } } /// /// Pure, GL-free, dat-free transforms for the LayoutDesc importer. /// All methods are static and operate on POCOs. /// No OpenGL, no DatReaderWriter types, no rendering dependencies beyond /// the bit-flag enum from AcDream.App.UI. /// public static class ElementReader { /// Compatibility projection from raw retail modes to the legacy /// flags. This projection cannot represent centered /// mode 3 or proportional mode 4 exactly. Imported DAT widgets therefore use /// ; call this only for legacy/programmatic paths. /// LeftEdge dat field value (0–4). /// TopEdge dat field value (0–4). /// RightEdge dat field value (0–4). /// BottomEdge dat field value (0–4). 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; } /// /// Merges a base element snapshot with a derived element snapshot, mirroring /// the BaseElement / BaseLayoutId inheritance chain in the dat. /// /// /// Rules: /// /// /// Scalar fields (, , /// , , /// ): derived wins if non-zero; otherwise /// inherited from base. /// /// /// Position (, ) and /// edge flags ( etc.) and /// : always taken from the derived element /// (derived placement, not the base prototype's geometry). /// /// /// and : /// base entries are the default; derived entries override (or add) per state name key. /// /// /// : are not combined by this scalar merge helper; /// separately ports retail's recursive child-table /// incorporation. /// /// /// /// 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(derived.Children), }; // Start with base StateMedia as defaults, then let derived entries override. m.StateMedia = new Dictionary(base_.StateMedia); foreach (var kv in derived.StateMedia) m.StateMedia[kv.Key] = kv.Value; m.StateCursors = new Dictionary(base_.StateCursors); foreach (var kv in derived.StateCursors) m.StateCursors[kv.Key] = kv.Value; m.States = new Dictionary(); 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 ReadTabTable(ElementInfo info) { var entries = new List(); 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 ReadTemplateList(ElementInfo info) { var entries = new List(); 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; } /// /// Coerces a struct member (a nested keyed by its own /// master-property id) into an element/DID-shaped uint. Mirrors /// DatWidgetFactory.ReferencedElementId'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. /// private static uint ReadStructMemberId(IReadOnlyDictionary 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, }; } /// Same Enum/DataId/Integer coercion as , /// applied to a top-level effective property instead of a struct member — used for /// scalar element-id references like property 0x72. 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, }; } /// /// Resolves an authored color or color-array property after the retail /// DirectState/default-state inheritance rules have been applied. /// internal static Vector4[] ReadEffectiveColorPalette( ElementInfo info, uint propertyId) { ArgumentNullException.ThrowIfNull(info); if (!info.TryGetEffectiveProperty(propertyId, out UiPropertyValue value)) return []; IEnumerable 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(); } /// /// AP-222 / GF-11b (Campaign CC gate round 1 Batch B): resolves a color /// property (0x1B FontColor's Array-tolerant shape, same unwrap as /// ) for EVERY state 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. /// internal static IReadOnlyDictionary? BuildPerStateColorMap( ElementInfo info, uint propertyId) { Dictionary? 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())[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; } /// /// AP-222 counterpart of for a bool /// property (0x21 Outline) — same "null unless genuinely per-state" /// gating. /// internal static IReadOnlyDictionary? BuildPerStateBoolMap( ElementInfo info, uint propertyId) { Dictionary? 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())[stateId] = value.BoolValue; } return map is { Count: > 1 } && map.Values.Distinct().Count() > 1 ? map : null; } }