using System; using System.Collections.Generic; 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) { rootInfo.Children = new List(children); return Build(rootInfo, resolve, datFont, fontResolve); } /// /// 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) { 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, 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, Dictionary byId) { var w = DatWidgetFactory.Create(info, resolve, datFont, fontResolve); if (w is null) return null; // Type-12 style prototype — skip 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, 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, byId); if (cw is not null) w.AddChild(cw); } } 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(DatCollection 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)>())); } return tops.Count == 1 ? tops[0] : new ElementInfo { Id = 0, Type = 3, Children = tops }; } /// /// 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( DatCollection dats, uint layoutId, Func resolve, UiDatFont? datFont, Func? fontResolve = null) { var rootInfo = ImportInfos(dats, layoutId); if (rootInfo is null) return null; return Build(rootInfo, resolve, datFont, fontResolve); } // ── Inheritance resolution ──────────────────────────────────────────────── /// True when a pure-container leaf should inherit its base's subtree (the /// gmInventoryUI sub-window mount): the derived element has no own children, no own /// state media, and the base resolved to ≥1 child. Inert for media-bearing inheritors /// (close button / title), elements with their own children, and childless style /// prototypes (vitals/chat/toolbar text — the base prototype has no children). 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( DatCollection 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; List? baseChildren = 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). var baseInfo = Resolve(dats, baseDesc, baseChain); // Derived fields override the base; children are attached below. result = ElementReader.Merge(baseInfo, self); baseChildren = baseInfo.Children; // capture for the sub-window mount } } // Resolve + attach children. Each child gets a FRESH base-chain set: // the cycle guard is per-element, not shared across siblings. foreach (var kv in d.Children) result.Children.Add(Resolve(dats, kv.Value, new HashSet<(uint, uint)>())); // Sub-window mount: a pure-container leaf (no own children, no own media) that inherits // from a base WITH content attaches the base's subtree. Targets the gmInventoryUI panels // (0x100001CD/CE/CF — Type-0, media-less, childless, BaseLayoutId → a gm*UI window); // inert for media-bearing inheritors (close button/title) and childless style prototypes // (vitals/chat/toolbar). See ShouldMountBaseChildren + the B-Grid spec. if (baseChildren is not null && ShouldMountBaseChildren(d.Children.Count, self.StateMedia.Count, baseChildren.Count)) { result.Children.AddRange(baseChildren); // 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; // Sub-layout slot sizing: when a sub-layout is mounted into a slot that is TALLER // (or wider) than the sub-layout's own design height, the cascade of Bottom/Right // anchored elements must be resized to fill the slot. Without this step, the // background element (0x10000226 for the Attributes tab, designed at 337px) captures // _amB = slotH - designH = 238 and permanently stays at 337px, and every element // within it with Bottom anchor stays at its design size too (list box at 160px // instead of the correct 398px). // // Retail reference: UIElement::UpdateForParentSizeChange (C++ runtime) propagates a // new parent height down the whole tree, updating each Bottom-anchored child's // rect by maintaining its bottom margin. We replicate that cascade here at import // time on the base-children subtree so the anchor-capture during the first render // frame sees zero (or unchanged) margins — not the spurious "slot bigger than design" // margin. // // Safe guard: only fire when the slot has explicit size AND the base children are // smaller (i.e., this is the "slot bigger than design" case). // Inventory/paperdoll slots are unaffected because their slot H already matches the // sub-layout design H, so no cascade occurs. if (result.Width > 0 && result.Height > 0) { foreach (var child in baseChildren) { var childAnchors = ElementReader.ToAnchors(child.Left, child.Top, child.Right, child.Bottom); const AnchorEdges StretchAll = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Right | AnchorEdges.Bottom; if (child.X == 0 && child.Y == 0 && (childAnchors & StretchAll) == StretchAll // full-stretch background && child.Width <= result.Width && child.Height > 0 && child.Height < result.Height) // smaller than slot { // Cascade UpdateForParentSizeChange down the subtree: maintain each // Bottom-anchored element's bottom margin while growing the parent height. CascadeHeight(child, child.Height, result.Height); } } } } 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, DefaultStateName = (defState is "Undef" or "Undefined" or "0") ? "" : defState, }; // DirectState (unnamed, key ""). if (d.StateDesc is not null) ReadState(d.StateDesc, "", info); // Named states (e.g. UIStateId.HideDetail → "HideDetail"). foreach (var s in d.States) ReadState(s.Value, s.Key.ToString(), info); return info; } /// /// Read the first from into /// info.StateMedia[name] and extract the font DID from property 0x1A /// (ArrayBaseProperty → DataIdBaseProperty) if not yet set. /// private static void ReadState(StateDesc sd, string name, ElementInfo info) { // Only MediaDescImage is read for rendering; MediaDescCursor items (on grips/drag bars) // are intentionally skipped — cursor behavior is Plan 2. foreach (var m in sd.Media) { if (m is MediaDescImage img && img.File != 0) { info.StateMedia[name] = (img.File, (int)img.DrawMode); break; } } // 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 values: 0=Left, 1=Center, 3=Right, 5=Right (treat 5 as Right per spec). // 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 => HJustify.Left, 1u => HJustify.Center, 3u => HJustify.Right, 5u => HJustify.Right, _ => HJustify.Center, // unknown → center (safe default) }; } // 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); } } } // ── 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; } // ── Sub-layout slot sizing helpers ──────────────────────────────────────── /// /// Recursively propagates a parent-height change from /// to through and all its /// descendants, replicating UIElement::UpdateForParentSizeChange from the /// retail runtime. /// /// Three anchor cases for the vertical axis: /// /// Top+Bottom (stretch): maintain top margin AND bottom margin; height grows. /// Bottom only (pin-to-bottom, fixed height): maintain bottom margin; Y moves, H unchanged. /// Top only or None: Y and H unchanged (pinned to top with fixed height). /// /// Recurse with the element's new height so grandchildren see the correct parent size. /// /// This is called once at import time on base-children that are mounted into a /// slot taller than the sub-layout's design height. /// private static void CascadeHeight(ElementInfo el, float oldParentH, float newParentH) { float oldH = el.Height; float newH = el.Height; float oldY = el.Y; var anchors = ElementReader.ToAnchors(el.Left, el.Top, el.Right, el.Bottom); bool hasTop = (anchors & AnchorEdges.Top) != 0; bool hasBottom = (anchors & AnchorEdges.Bottom) != 0; if (hasTop && hasBottom) { // Stretch: maintain both top and bottom margins. // amT = el.Y (pinned to top, unchanged) // amB = oldParentH - (el.Y + el.H) float amB = oldParentH - (el.Y + el.Height); newH = newParentH - el.Y - amB; if (newH < 0f) newH = 0f; el.Height = newH; } else if (hasBottom && !hasTop) { // Pin to bottom: maintain bottom margin, height fixed, Y moves. // amB = oldParentH - (el.Y + el.H) float amB = oldParentH - (el.Y + el.Height); float newY = newParentH - amB - el.Height; el.Y = newY; // Height unchanged; newH = oldH for recursion. } // else: Top-only or None — element is top-pinned with fixed height; nothing moves. // Recurse into children with the element's new height as their parent. foreach (var child in el.Children) CascadeHeight(child, oldH, newH); } }