using System; using System.Collections.Generic; using AcDream.Content; using DatReaderWriter; using DatReaderWriter.DBObjs; using DatReaderWriter.Enums; using DatReaderWriter.Types; namespace AcDream.App.UI.Layout; /// /// The result of importing a retail LayoutDesc: a tree with /// an O(1) lookup table for finding any element by its dat id. /// public sealed class ImportedLayout { /// Root widget of the imported tree. public UiElement Root { get; } private readonly Dictionary _byId; public ImportedLayout(UiElement root, Dictionary byId) { Root = root; _byId = byId; } /// Find a widget by its dat element id (e.g. 0x100000E6). /// Returns null if the id was skipped (Type-12 prototype) or not present. public UiElement? FindElement(uint id) => _byId.TryGetValue(id, out var e) ? e : null; } /// /// Two-layer layout importer for retail LayoutDesc dat objects. /// /// /// Pure layer ( / ): /// converts a pre-resolved tree into a /// tree via . Testable without dats or OpenGL — all tests /// in LayoutImporterTests.cs exercise this layer only. /// /// /// /// Dat shell (): reads a , /// converts each top-level to a fully resolved /// (applying BaseElement / BaseLayoutId /// inheritance with a cycle guard), then delegates to . /// /// /// /// Meter elements (Type 7) consume their own dat-children: /// reads the grandchild slice-sprite ids during construction, so the /// children must NOT be added as separate nodes in the tree. /// Every other element type recurses its children generically. /// /// public static class LayoutImporter { // ── Pure layer ──────────────────────────────────────────────────────────── /// /// Convenience for tests: attach to /// , then call . /// The children list is set directly on ; /// any existing children are replaced. /// public static ImportedLayout BuildFromInfos( ElementInfo rootInfo, IEnumerable children, Func resolve, UiDatFont? datFont, Func? fontResolve = null, Func? stringResolve = null) { rootInfo.Children = new List(children); return Build(rootInfo, resolve, datFont, fontResolve, stringResolve); } /// /// Pure builder: produce the widget tree from a fully resolved /// tree (children already attached). /// /// Optional per-element font resolver — FontDid → /// (or null if the font can't be loaded). When supplied, /// elements with a non-zero get their own dat /// font at build time instead of the shared fallback. /// Null preserves the original single-font behavior for all callers that don't /// pass it — no behavior change for the live game path. public static ImportedLayout Build( ElementInfo rootInfo, Func resolve, UiDatFont? datFont, Func? fontResolve = null, Func? stringResolve = null) { var byId = new Dictionary(); // Root is never a Type-12 prototype in practice; fall back to a generic // container if the factory returns null for an exotic root type. var root = BuildWidget(rootInfo, resolve, datFont, fontResolve, stringResolve, byId); if (root is null) { Console.WriteLine($"[D.2b] LayoutImporter: root element 0x{rootInfo.Id:X8} (type {rootInfo.Type}) produced no widget — using empty container fallback."); root = new UiDatElement(rootInfo, resolve); } return new ImportedLayout(root, byId); } private static UiElement? BuildWidget( ElementInfo info, Func resolve, UiDatFont? datFont, Func? fontResolve, Func? stringResolve, Dictionary byId) { var w = DatWidgetFactory.Create(info, resolve, datFont, fontResolve, stringResolve); if (w is null) return null; // Type-12 style prototype — skip // GF-13: pure data passthrough — see UiElement.AuthoredInvisible's own // doc comment for why this does NOT set Visible here. w.AuthoredInvisible = info.Invisible; // #409: the six per-element tooltip properties, same pure-data- // passthrough shape as AuthoredInvisible above. TooltipText is the // one property resolved to a display string here (mirrors every // other authored StringInfo caption in this file); the rest stay // raw ids/flags for RetailTooltipPresenter to act on. w.AuthoredTooltipEnabled = info.TooltipEnabled; w.AuthoredTooltipText = DatWidgetFactory.ResolveTooltipText(info, stringResolve); w.AuthoredTooltipRootElementId = info.TooltipRootElementId; w.AuthoredTooltipLayoutDid = info.TooltipLayoutDid; w.AuthoredTooltipTextChildElementId = info.TooltipTextChildElementId; w.AuthoredTooltipDelaySeconds = info.TooltipDelaySeconds; // #409 F8: the four ResizeTo auto-resize clamps (0x3C/0x3D/0x3E/0x3F). w.AuthoredResizeMaxWidth = info.MaxWidth; w.AuthoredResizeMinWidth = info.MinWidth; w.AuthoredResizeMaxHeight = info.MaxHeight; w.AuthoredResizeMinHeight = info.MinHeight; if (info.Id != 0) byId[info.Id] = w; // Behavioral widgets that draw their full appearance + reproduce their dat // sub-elements procedurally (Meter's 3-slice, Menu's label/rows, Field/Text caps, // Button labels, Scrollbar arrows) CONSUME their dat children — building those as // separate widgets double-draws and lets an invisible child steal pointer/focus // from the behavioral widget (e.g. the channel Menu's label child intercepting the // button click). Only generic containers (UiDatElement, panels) recurse. See // UiElement.ConsumesDatChildren. if (!w.ConsumesDatChildren) { foreach (var child in info.Children) { var cw = BuildWidget(child, resolve, datFont, fontResolve, stringResolve, byId); if (cw is not null) w.AddChild(cw); } } else if (w is UiMeter) { // Fix 5: UiMeter.ConsumesDatChildren=true swallows ALL children, including text // label/value overlays that are separate renderable widgets (not part of the bar // art). BuildMeter in DatWidgetFactory already consumed the Type-3 slice containers // (reads their grandchild sprite ids to populate Back*/Front* properties). The // remaining non-Type-3 children (typically Type-12 UIElement_Text overlays such // as the XP meter's 0x10000237 label + 0x10000238 value) ARE renderable and belong // in the widget tree. We build them here explicitly, registered in byId so // FindElement can locate them, and attached as children of the meter so they render // as overlays at their dat-local coordinates. The controller can then locate these // widgets via FindElement and bind LinesProvider without injecting new runtime nodes. // // Type-3 children are SKIPPED here because BuildMeter already consumed them (they // carry the 3-slice sprite ids, not text content; building them again would // double-draw the bar art). All other child types are built normally. // // Safe for vitals: the health/stamina/mana meters have ONLY Type-3 slice children // (no text children). This loop finds nothing for them → no change to vitals. foreach (var child in info.Children) { if (child.Type == 3) continue; // slice containers: already consumed by BuildMeter var cw = BuildWidget(child, resolve, datFont, fontResolve, stringResolve, byId); if (cw is not null) w.AddChild(cw); } } else if (w is UiText or UiField) { // Campaign CC gate round 1 Batch C, Commit 2: UiText/UiField's // coarse ConsumesDatChildren=true (UiText outside its // PassToChildren carve-out; UiField unconditionally) used to // drop EVERY dat child, including ones that carry their own // renderable media — retail's UIElement_Text/Field genuinely // composites those as real chrome/controls, not swallowed // caption/face art the way a Button's or Meter's children are. // Live-DAT-measured (chargen's three shared description boxes, // 0x100003e0/0x10000409/0x10000404): the eight gold-frame // pieces (0x100002DE-E3, 0x100000E8/EA, Type 3, one DirectState // sprite each) and the linked scrollbar (0x100002E7, Type 11, // its own DirectState track sprite plus three Button // sub-children BuildScrollbar resolves internally) all carry // non-empty StateMedia on THEMSELVES. Purely structural/ // property-only children (StateMedia.Count == 0 — e.g. a // lifted-caption-only child some OTHER element type might // still want swallowed) stay dropped exactly as before; this // is additive, not a relaxation of the PassToChildren gate // itself. foreach (var child in info.Children) { if (child.StateMedia.Count == 0) continue; var cw = BuildWidget(child, resolve, datFont, fontResolve, stringResolve, byId); if (cw is null) continue; // F5/F6 (Campaign CC gate round 1 closeout): a NARROW honor // of AuthoredInvisible, scoped to children reached through // THIS carve-out only — e.g. the chat new-text indicator // (0x1000048C, live-DAT-confirmed Invisible=true on every // layout it appears in) would otherwise render as a phantom // element retail never shows, now that this carve-out // builds it as a real widget instead of silently dropping // it. This is NOT the general client-wide honor (#408, // 1,083 elements) — every OTHER AuthoredInvisible consumer // stays data-only, acted on nowhere but chargen's own // HideAuthoredInvisibleElements walk (register AP-230). if (cw.AuthoredInvisible) cw.Visible = false; w.AddChild(cw); } } // UIElement::SetState @ 0x00464E70 propagates a state's id only after the // child tree exists. Re-applying the imported default here gives retained // PassToChildren tabs their authored Open/Closed child media without turning // spatial construction into controller-specific special cases. if (w is IUiDatStateful stateful) stateful.TrySetRetailState(stateful.ActiveRetailStateId); // See IUiChildrenAttachedListener: a widget that must resolve OTHER children by // dat element id (e.g. UiTabPanel's tab table) can only do so once its subtree // is actually attached, which just happened above. if (w is IUiChildrenAttachedListener childrenAttached) childrenAttached.OnChildrenAttached(); return w; } // ── Dat shell ───────────────────────────────────────────────────────────── /// /// Dat shell, ElementInfo half: load the layout + resolve inheritance + build the /// ElementInfo tree (no widgets). Exposed for fixture generation + conformance tests. /// Returns null if the layout is missing. /// /// The dat collection to read the LayoutDesc from. /// The LayoutDesc dat id to read. public static ElementInfo? ImportInfos(IDatReaderWriter dats, uint layoutId) { var ld = dats.Get(layoutId); if (ld is null) return null; // Collect the set of element ids that are referenced as a BaseElement by ANY // element in THIS layout (where BaseLayoutId == layoutId). Such elements are // purely inheritance templates ("prototypes") — retail never instantiates them // as live widgets. Example: the toolbar slot prototype 0x100001B2 in LayoutDesc // 0x21000016, which all 18 slot elements inherit from and which has no own media. // // NOTE: the Resolve path reads BaseElement from the raw dat directly (via // dats.Get), so the prototype never needs to appear in the built // widget tree for inheritance to work. Skipping it here is safe. var referencedAsBase = new HashSet(); foreach (var kv in ld.Elements) CollectBaseRefsInDesc(kv.Value, layoutId, referencedAsBase); var tops = new List(); foreach (var kv in ld.Elements) { // Skip pure prototype elements: top-level elements that are referenced as a // base template by another element in this same layout AND have no own state // media (so they draw nothing and contribute nothing but their inherited shape). var d = kv.Value; if (referencedAsBase.Contains(d.ElementId) && HasNoOwnMedia(d)) { Console.WriteLine($"[D.2b] LayoutImporter: skipping prototype element 0x{d.ElementId:X8} in layout 0x{layoutId:X8} (no own media, referenced as BaseElement)."); continue; } tops.Add(Resolve(dats, d, new HashSet<(uint, uint)>())); } // #375: a Type-5 ListBox's row-template list (dat property 0x64) can // name SAME-LAYOUT elements as its row prototypes — gmKeyboardUI // (0x21000009) authors its header (0x1000002E) and action-row // (0x1000002F, the three key buttons) templates as ordinary top-level // siblings of the screen. Retail never instantiates a template-list // element as a live widget (AddItemFromTemplateList clones from the // desc on demand — the SAME re-import our UiTemplateListBox's // TemplateResolver performs), so building them here parked two live // prototype rows at the screen's (0,0), over and outside the framed // panel. Same skip class as the BaseElement prototypes above, keyed on // the template-list reference instead. Same-LAYOUT references only: // element ids collide across layouts (0x10000211 is a page in BOTH the // options and keyboard layouts), so a cross-layout entry must never // suppress a coincidentally-same-id element here. var referencedAsTemplate = new HashSet(); foreach (ElementInfo top in tops) CollectTemplateRefs(top, layoutId, referencedAsTemplate); if (referencedAsTemplate.Count > 0) { for (int i = tops.Count - 1; i >= 0; i--) { if (!referencedAsTemplate.Contains(tops[i].Id)) continue; Console.WriteLine( $"[D.2b] LayoutImporter: skipping row-template element 0x{tops[i].Id:X8} " + $"in layout 0x{layoutId:X8} (referenced by a same-layout template list)."); tops.RemoveAt(i); } } if (tops.Count == 1) return tops[0]; foreach (var top in tops) SetOriginalParentSize(top, ld.Width, ld.Height); return new ElementInfo { Id = 0, Type = 3, Width = ld.Width, Height = ld.Height, Children = tops, }; } /// Recursively gathers every SAME-LAYOUT element id referenced by a /// row-template list (dat property 0x64) anywhere in 's /// resolved subtree — the skip set for the #375 parked-prototype filter above. private static void CollectTemplateRefs( ElementInfo info, uint layoutId, HashSet referenced) { foreach (UiTemplateListEntry entry in info.TemplateList) { if (entry.TemplateLayoutId == layoutId) referenced.Add(entry.TemplateElementId); } foreach (ElementInfo child in info.Children) CollectTemplateRefs(child, layoutId, referenced); } /// /// Retail UIElementManager::CreateRootElementByDataID counterpart: resolve one /// authored root from a catalog-style LayoutDesc instead of instantiating every /// top-level template. DialogFactory uses this path for the shared dialog catalog. /// public static ElementInfo? ImportInfos( IDatReaderWriter dats, uint layoutId, uint rootElementId) { var ld = dats.Get(layoutId); if (ld is null) return null; ElementDesc? root = FindDesc(ld, rootElementId); return root is null ? null : Resolve(dats, root, new HashSet<(uint, uint)>()); } /// /// Dat shell: load the LayoutDesc, resolve inheritance for every top-level /// element, and build the widget tree. Returns null if the layout is absent /// from the dats. /// /// /// Dat UIState visibility model (2026-06-26 audit): /// The dat's ElementDesc.DefaultState field specifies which SPRITE/MEDIA /// state an element starts in (e.g., Normal, Minimized). It does /// NOT encode visibility of sibling Group containers. The StateDesc's /// contains X/Y/Width/Height/ /// ZLevel/PassToChildren — there is no Visible flag. /// /// /// /// Windows that display multiple sibling Group containers at the same position /// (the character footer's three state-groups; the tab-page content areas) manage /// visibility purely at runtime via C++ controller code. Retail uses /// UIElement::SetState(stateId) on the parent to propagate state, then /// C++ getters access the right sub-group by element id. All groups are shipped /// as visible in the imported widget tree; the relevant controllers /// () perform the initial show/hide. /// /// /// Optional per-element font resolver (see /// for details). Null = original single-font behavior. public static ImportedLayout? Import( IDatReaderWriter dats, uint layoutId, Func resolve, UiDatFont? datFont, Func? fontResolve = null) { var rootInfo = ImportInfos(dats, layoutId); if (rootInfo is null) return null; var strings = new DatStringResolver(dats); return Build(rootInfo, resolve, datFont, fontResolve, strings.Resolve); } /// Import one selected root from a catalog-style LayoutDesc. public static ImportedLayout? Import( IDatReaderWriter dats, uint layoutId, uint rootElementId, Func resolve, UiDatFont? datFont, Func? fontResolve = null) { var rootInfo = ImportInfos(dats, layoutId, rootElementId); if (rootInfo is null) return null; var strings = new DatStringResolver(dats); return Build(rootInfo, resolve, datFont, fontResolve, strings.Resolve); } // ── Inheritance resolution ──────────────────────────────────────────────── /// True when a pure-container inheritor needs the mounted-base Z-layer /// correction. Child inheritance itself is unconditional and follows retail /// ElementDesc::Incorporate (0x0069B5A0). internal static bool ShouldMountBaseChildren(int derivedChildCount, int derivedMediaCount, int baseChildCount) => derivedChildCount == 0 && derivedMediaCount == 0 && baseChildCount > 0; /// /// Converts an to a resolved : /// reads own fields + media, applies the BaseElement / BaseLayoutId chain /// (cycle-guarded by ), then resolves + attaches children. /// private static ElementInfo Resolve( IDatReaderWriter dats, ElementDesc d, HashSet<(uint layoutId, uint elementId)> baseChain) { // Read this element's own fields + media (no inheritance, no children yet). var self = ToInfo(d); var result = self; ElementInfo? baseInfo = null; // Apply BaseElement / BaseLayoutId inheritance if present. if (d.BaseElement != 0 && d.BaseLayoutId != 0 && baseChain.Add((d.BaseLayoutId, d.BaseElement))) { var baseLd = dats.Get(d.BaseLayoutId); var baseDesc = baseLd is null ? null : FindDesc(baseLd, d.BaseElement); if (baseDesc is not null) { // Recurse the base chain (already guarded by the HashSet add above). baseInfo = Resolve(dats, baseDesc, baseChain); // Derived fields override the base; children are attached below. result = ElementReader.Merge(baseInfo, self); } } // Retail LayoutDesc::InqFullDesc (0x0069A520) recursively resolves the base, // then ElementDesc::Incorporate (0x0069B5A0) merges the complete child table: // base-only children remain, same-ID children incorporate recursively, and // derived-only children append after the retained base entries. IncorporateChildren(dats, result, baseInfo?.Children, d); // A pure-container sub-window mount needs one additional layer correction. // Child-table incorporation has already happened above for every inheritor. if (baseInfo is not null && ShouldMountBaseChildren(d.Children.Count, self.StateMedia.Count, baseInfo.Children.Count)) { // The mounted slot's layer WITHIN THE FRAME is its OWN ZLevel, not the mounted // sub-window root's. The gm*UI sub-window roots carry ZLevel 1000 (their standalone // top-window layer); ElementReader.Merge's zero-wins-base rule made the slot (own // ZLevel 0) inherit that 1000, and the #145 ZOrder fold (ReadOrder − ZLevel·10000) // turns 1000 into ZOrder ≈ −10,000,000 — sinking the whole panel BEHIND the frame's // Alphablend backdrop (ZLevel 100 → ≈ −1,000,000). The backdrop then overpaints the // panel's captions/meter/cells (the wash-out bug; the paperdoll root happens to be // ZLevel 0 so it escaped). Restore the slot's own frame-layer so the panel sits in // FRONT of the backdrop. (B-Controller debug 2026-06-21; continuation of #145.) result.ZLevel = self.ZLevel; // Mounted descendants retain the base layout's design parent size. The runtime // UiLayoutPolicy therefore performs the retail parent-size update from the correct // baseline when this slot is larger than the mounted layout. } return result; } private static void IncorporateChildren( IDatReaderWriter dats, ElementInfo result, IReadOnlyList? baseChildren, ElementDesc derived) { baseChildren ??= Array.Empty(); var baseById = baseChildren.ToDictionary(child => child.Id); int retainedBaseCount = baseChildren.Count(child => !derived.Children.ContainsKey(child.Id)); foreach (ElementInfo baseChild in baseChildren) { bool hasOverlay = derived.Children.TryGetValue( baseChild.Id, out ElementDesc? overlay); ElementInfo child = hasOverlay ? IncorporateResolvedChild(dats, baseChild, overlay!) : baseChild; // Base-only descendants retain the base layout's design parent size; // UiLayoutPolicy then performs retail's base-to-derived parent resize. if (hasOverlay) SetOriginalParentSize(child, result.Width, result.Height); result.Children.Add(child); } // Every new child receives a fresh base-chain set. Retail offsets its read order // by the count of inherited children not replaced by a same-ID overlay. foreach (var pair in derived.Children) { if (baseById.ContainsKey(pair.Key)) continue; ElementInfo child = Resolve(dats, pair.Value, new HashSet<(uint, uint)>()); child.ReadOrder += checked((uint)retainedBaseCount); SetOriginalParentSize(child, result.Width, result.Height); result.Children.Add(child); } } private static ElementInfo IncorporateResolvedChild( IDatReaderWriter dats, ElementInfo baseChild, ElementDesc derivedChild) { // ElementDesc::Incorporate consumes the partial child directly. It does not // independently re-resolve that child's BaseElement when the inherited table // already contains the same identity. ElementInfo self = ToInfo(derivedChild); ElementInfo result = ElementReader.Merge(baseChild, self); IncorporateChildren(dats, result, baseChild.Children, derivedChild); return result; } /// /// Read an 's own scalar fields + state media into a /// fresh . No inheritance is applied; children are not /// attached (the caller handles those). /// private static ElementInfo ToInfo(ElementDesc d) { // Normalize DefaultState: UIStateId.ToString() gives "Undef"/"Undefined" or "0" when // no default is set; map those to "" so UiDatElement treats them as "no preference". var defState = d.DefaultState.ToString(); var info = new ElementInfo { Id = d.ElementId, Type = d.Type, X = (float)d.X, Y = (float)d.Y, Width = (float)d.Width, Height = (float)d.Height, Left = d.LeftEdge, Top = d.TopEdge, Right = d.RightEdge, Bottom = d.BottomEdge, ReadOrder = d.ReadOrder, ZLevel = d.ZLevel, DefaultStateId = (uint)d.DefaultState, DefaultStateName = (defState is "Undef" or "Undefined" or "0") ? "" : defState, }; // DirectState (unnamed, key ""). if (d.StateDesc is not null) ReadState(d.StateDesc, UiStateInfo.DirectStateId, "", info); // Named states (e.g. UIStateId.HideDetail → "HideDetail"). foreach (var s in d.States) ReadState(s.Value, (uint)s.Key, s.Key.ToString(), info); ElementReader.ApplyCanonicalLegacyProjection(info); return info; } /// /// Read the first from into /// info.StateMedia[name], read any into /// info.StateCursors[name], and extract the font DID from property 0x1A /// (ArrayBaseProperty → DataIdBaseProperty) if not yet set. /// private static void ReadState(StateDesc sd, uint stateId, string name, ElementInfo info) { var state = new UiStateInfo { Id = stateId, Name = name, PassToChildren = sd.PassToChildren, IncorporationFlags = (uint)sd.IncorporationFlags, }; bool imageRead = false; foreach (var m in sd.Media) { if (!imageRead && m is MediaDescImage img && img.File != 0) { info.StateMedia[name] = (img.File, (int)img.DrawMode); state.Image = new UiImageMedia(img.File, (int)img.DrawMode); imageRead = true; } if (m is MediaDescCursor cursor && cursor.File != 0) { info.StateCursors[name] = new UiCursorMedia( cursor.File, checked((int)cursor.XHotspot), checked((int)cursor.YHotspot)); state.Cursor = info.StateCursors[name]; } } if (sd.Properties is not null) { foreach (var (propertyId, property) in sd.Properties) state.Properties.Values[propertyId] = ConvertProperty(property); } info.States[stateId] = state; // Font DID: Properties[0x1A] is ArrayBaseProperty{ DataIdBaseProperty }. // Format doc §3: "ArrayBaseProperty containing ONE DataIdBaseProperty". if (info.FontDid == 0 && sd.Properties is not null && sd.Properties.TryGetValue(0x1Au, out var raw) && raw is ArrayBaseProperty arr && arr.Value.Count > 0 && arr.Value[0] is DataIdBaseProperty did) { info.FontDid = did.Value; } if (sd.Properties is not null) { // HorizontalJustification (0x14): EnumBaseProperty. // Retail CalcJustification @ 0x00467260: 1=Center, 3/5=Right, // every other value (including constructor default 2)=Left. // Only update if still at the default (Center); derived-wins handled in Merge. if (info.HJustify == HJustify.Center && sd.Properties.TryGetValue(0x14u, out var hRaw) && hRaw is EnumBaseProperty hEnum) { info.HJustify = hEnum.Value switch { 0u or 2u => HJustify.Left, 1u => HJustify.Center, 3u => HJustify.Right, 5u => HJustify.Right, _ => HJustify.Left, }; } // VerticalJustification (0x15): EnumBaseProperty. // Retail values: 2=Top, 4=Bottom; absent/other = Center. if (info.VJustify == VJustify.Center && sd.Properties.TryGetValue(0x15u, out var vRaw) && vRaw is EnumBaseProperty vEnum) { info.VJustify = vEnum.Value switch { 2u => VJustify.Top, 4u => VJustify.Bottom, _ => VJustify.Center, }; } // ColorBaseProperty (0x1B): ARGB bytes → normalized [0,1] Vector4 (R,G,B,A). // Only read when not already set (first dat state wins; Merge propagates from base). if (info.FontColor is null && sd.Properties.TryGetValue(0x1Bu, out var cRaw) && cRaw is ColorBaseProperty cProp) { var c = cProp.Value; // ColorARGB stores components as bytes (0–255); normalize to [0,1] for Vector4. // Alpha=0 in the dat typically means fully opaque (retail convention: 0 → 255). float a = c.Alpha == 0 ? 1f : c.Alpha / 255f; info.FontColor = new System.Numerics.Vector4(c.Red / 255f, c.Green / 255f, c.Blue / 255f, a); } // Outline (0x21): BoolBaseProperty. Retail SetOutline @0x0046a81c / m_bitField & // 0x10. NOT read here — reading it per-state as each StateDesc is visited is an // any-state "first wins" scan that can pick up a NON-effective state's property // (e.g. a Pressed-only override) ahead of the state retail would actually use. // ElementReader.ApplyCanonicalLegacyProjection (called once per element, right // after every state is read) is the single correct source: it resolves 0x21 // through TryGetEffectiveProperty's DirectState-then-effective-default-state rule, // exactly like FontDid/HJustify/VJustify/FontColor already do. Round-5 review N1: // this duplicate early read was masked while Outline only reached UiText; S2's // widening to six more text-bearing widgets un-masks a state mismatch here. // OutlineColor (0x22): ColorBaseProperty. Retail m_curOutlineColor, ctor default // RGBAColor_Black. Only read when not already set — same pattern as FontColor. if (info.OutlineColor is null && sd.Properties.TryGetValue(0x22u, out var outlineColorRaw) && outlineColorRaw is ColorBaseProperty outlineColorProp) { var oc = outlineColorProp.Value; float oa = oc.Alpha == 0 ? 1f : oc.Alpha / 255f; info.OutlineColor = new System.Numerics.Vector4(oc.Red / 255f, oc.Green / 255f, oc.Blue / 255f, oa); } } } internal static UiPropertyValue ConvertProperty(BaseProperty property) { var value = new UiPropertyValue { MasterPropertyId = property.MasterPropertyId }; switch (property) { case EnumBaseProperty p: value.Kind = UiPropertyKind.Enum; value.UnsignedValue = p.Value; break; case BoolBaseProperty p: value.Kind = UiPropertyKind.Bool; value.BoolValue = p.Value; break; case DataIdBaseProperty p: value.Kind = UiPropertyKind.DataId; value.UnsignedValue = p.Value; break; case FloatBaseProperty p: value.Kind = UiPropertyKind.Float; value.FloatValue = p.Value; break; case IntegerBaseProperty p: value.Kind = UiPropertyKind.Integer; value.IntegerValue = p.Value; break; case StringInfoBaseProperty p: value.Kind = UiPropertyKind.StringInfo; value.StringInfoValue = new UiStringInfoValue( p.Value.Token, p.Value.StringId, p.Value.TableId.DataId, (byte)p.Value.Override, p.Value.English, p.Value.Comment); break; case ColorBaseProperty p: value.Kind = UiPropertyKind.Color; value.ColorValue = new UiColorValue( p.Value.Blue, p.Value.Green, p.Value.Red, p.Value.Alpha); break; case ArrayBaseProperty p: value.Kind = UiPropertyKind.Array; foreach (var item in p.Value) value.ArrayValue.Add(ConvertProperty(item)); break; case StructBaseProperty p: value.Kind = UiPropertyKind.Struct; foreach (var (key, item) in p.Value) value.StructValue[key] = ConvertProperty(item); break; case VectorBaseProperty p: value.Kind = UiPropertyKind.Vector; value.VectorValue = p.Value; break; case Bitfield32BaseProperty p: value.Kind = UiPropertyKind.Bitfield32; value.UnsignedValue = p.Value; break; case Bitfield64BaseProperty p: value.Kind = UiPropertyKind.Bitfield64; value.UnsignedValue = p.Value; break; case InstanceIdBaseProperty p: value.Kind = UiPropertyKind.InstanceId; value.UnsignedValue = p.Value; break; default: throw new NotSupportedException($"Unsupported UI base-property type {property.GetType().FullName}."); } return value; } // ── Prototype detection helpers ─────────────────────────────────────────── /// /// Recursively walks and all its children, adding to /// the BaseElement of every descriptor that /// references this layout (BaseLayoutId == layoutId). Used by /// to identify pure prototype/template elements that /// should not be instantiated as live widgets. /// private static void CollectBaseRefsInDesc(ElementDesc d, uint layoutId, HashSet result) { if (d.BaseElement != 0 && d.BaseLayoutId == layoutId) result.Add(d.BaseElement); foreach (var kv in d.Children) CollectBaseRefsInDesc(kv.Value, layoutId, result); } /// /// Returns true when carries no own state media — i.e. its /// StateDesc (DirectState) and States (named states) yield no /// entries with a non-zero file id. /// Such elements are pure inheritance templates with no rendering content. /// private static bool HasNoOwnMedia(ElementDesc d) { // Re-use ToInfo's media extraction: if the resulting StateMedia is empty the // element has no renderable image in any state. var info = ToInfo(d); return info.StateMedia.Count == 0; } // ── Element tree search ─────────────────────────────────────────────────── /// /// Find an by id anywhere in the top-level tree of /// (depth-first). Returns null if not found. /// private static ElementDesc? FindDesc(LayoutDesc ld, uint id) { foreach (var kv in ld.Elements) { var f = FindDescIn(kv.Value, id); if (f is not null) return f; } return null; } private static ElementDesc? FindDescIn(ElementDesc d, uint id) { if (d.ElementId == id) return d; foreach (var kv in d.Children) { var f = FindDescIn(kv.Value, id); if (f is not null) return f; } return null; } // ── Raw-edge layout provenance ──────────────────────────────────────────── private static void SetOriginalParentSize(ElementInfo child, float width, float height) { child.OriginalParentWidth = width; child.OriginalParentHeight = height; child.HasOriginalParentSize = true; } }