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