using System; using System.Linq; using AcDream.App.UI; namespace AcDream.App.UI.Layout; /// /// Hybrid factory: behavioral element Types map to dedicated widgets (verbatim /// algorithm ports); everything else (and unknown Types) falls back to /// . /// /// /// Type 12 = UIElement_Text. Editable `0x16` elements become /// in place; other elements become display/selectable widgets. /// Elements that carry their own DAT sprite media keep it as widget background art. /// Pure prototype elements draw nothing because text backgrounds default transparent. /// /// /// /// The meter's back/front 3-slice sprite ids live on grandchild image elements, /// NOT on the meter element itself (format doc §11). /// walks two layers down to extract them: the two Type-3 container children /// ordered by (back behind = lower, front /// on top = higher), then within each container the image children that carry /// a DirectState ("" key) sprite, ordered by their X position to obtain /// left-cap / center-tile / right-cap. /// /// /// /// The expand-detail overlay present in the front container carries ONLY named /// states ("HideDetail"/"ShowDetail") — no "" DirectState entry — so the /// TryGetValue("") filter in excludes it /// automatically. /// /// public static class DatWidgetFactory { /// /// Creates the for , sets its /// rect (Left/Top/Width/Height) and Anchors, and returns it. /// /// Resolved, merged element snapshot from the LayoutDesc importer. /// RenderSurface id → (GL tex handle, pixel width, pixel height). /// Returns (0,0,0) when the texture is not yet uploaded. /// Retail UI font for the meter's "cur/max" number overlay. /// May be null pre-load — the meter falls back to the debug bitmap font. /// Optional font resolver: FontDid → /// (or null when the font can't be loaded). When non-null, any element whose /// is non-zero gets ITS OWN dat font applied instead of /// the shared fallback. Null = original behavior (use /// for every element). /// The widget for this element. Never null — every type produces a widget. public static UiElement? Create(ElementInfo info, Func resolve, UiDatFont? datFont, Func? fontResolve = null, Func? stringResolve = null) { // Retail Type 3 = UIElement_Field (reg :126190), but in acdream's CURRENT layouts // (vitals 0x2100006C / chat 0x21000006) Type-3 elements are sprite-bearing chrome + // containers (the 8-piece bevel corners/edges, the transcript/input panels), NOT // editable fields — retail draws those as inert media-bearing Fields, which our // UiDatElement reproduces pixel-for-pixel (and without the spurious focus/edit // affordance a UiField would add). The one true editable field, the chat input // (0x10000016), resolves to Type 12 and is controller-placed as a UiField. So Type 3 // stays on the generic fallback here; register it as UiField only when a window // actually carries a factory-built editable Type-3 field (and UiField grows a // background-media draw + an opt-in editable flag at that point). UiField (the widget) // still ships — it just isn't wired into the factory switch yet. // Resolve this element's own dat font if a resolver is provided and the element // has a FontDid. Falls back to the shared datFont when not set (FontDid==0) or // when the resolver returns null (font missing from dats). UiDatFont? elementFont = datFont; if (fontResolve is not null && info.FontDid != 0) elementFont = fontResolve(info.FontDid) ?? datFont; UiElement e = info.Type switch { UiRadar.RetailClassId => new UiRadar(), // gmRadarUI (Register 0x004D8B80) 1 => BuildButton(info, resolve, elementFont, stringResolve), // UIElement_Button 6 => new UiMenu(), // UIElement_Menu (reg :120163) 7 => BuildMeter(info, resolve, elementFont), // UIElement_Meter 0xD => new UiViewport(), // UIElement_Viewport — 3-D mini-scene blit leaf 11 => BuildScrollbar(info, resolve), // UIElement_Scrollbar (reg :124137) 12 => BuildText(info, resolve, elementFont, stringResolve), // UIElement_Text 0x10000031u => new UiItemList(resolve), // UIElement_ItemList — toolbar/inventory/paperdoll slots 0x10000035u => BuildCheckbox(info, resolve, elementFont, stringResolve), // UIOption_Checkbox _ => new UiDatElement(info, resolve), // generic fallback (incl. Type 3 chrome/containers) }; e.DatElementId = info.Id; e.SetStateCursors(info.StateCursors); // Propagate position + size (pixel-exact from the dat). e.Left = info.X; e.Top = info.Y; e.Width = info.Width; e.Height = info.Height; // Honor the dat's draw order. ZLevel is the primary layer (higher = further BACK — e.g. the // gmInventoryUI full-window backdrop at ZLevel 100 sits behind the ZLevel-0 panels, #145); // ReadOrder is the within-layer tiebreaker (higher = on top). K=10000 exceeds any window's // element count so ZLevel always dominates. Vitals (all ZLevel 0) keep ZOrder == ReadOrder. e.ZOrder = (int)info.ReadOrder - (int)info.ZLevel * 10000; // Map the four raw edge-anchor values to the AnchorEdges bit-flag that the // compatibility layout engine uses for programmatic overrides. e.Anchors = ElementReader.ToAnchors(info.Left, info.Top, info.Right, info.Bottom); // Imported descendants use the exact four-mode retail policy. Roots have no // design parent and intentionally remain on the compatibility path until a // window mount assigns its own outer-frame policy. if (info.HasOriginalParentSize) { e.LayoutPolicy = new UiLayoutPolicy( info.Left, info.Top, info.Right, info.Bottom, UiPixelRect.FromPositionAndSize( (int)info.X, (int)info.Y, (int)info.Width, (int)info.Height), UiPixelRect.FromPositionAndSize( 0, 0, (int)info.OriginalParentWidth, (int)info.OriginalParentHeight)); } return e; } /// /// Bind inherited scrollbar media structurally. Property 0x77 names the /// increment button and 0x78 the decrement button; the remaining Type-1 /// child is the thumb with ordered top/middle/bottom image slices. /// private static UiScrollbar BuildScrollbar( ElementInfo info, Func resolve) { var bar = new UiScrollbar { SpriteResolve = resolve, TrackSprite = DefaultImage(info), Horizontal = info.Width > info.Height, }; if (bar.Horizontal) { // Retail horizontal scrollbars use structural child ids: element 1 is // the thumb and element 4 is the optional child-authored track. ElementInfo? scalarThumb = info.Children.FirstOrDefault(child => child.Id == 1u); bar.TrackSprite = DefaultImage(info); bar.ThumbSprite = scalarThumb is null ? 0u : DefaultImage(scalarThumb); // The toolbar stack slider authors its track on structural child 4, // while gmCombatUI authors it on the scrollbar's DirectState. Geometry // is never the role discriminator: inheritance can reflow child 1 and // otherwise turn the 12px combat jewel into a tiled background. if (bar.TrackSprite == 0u) { ElementInfo? authoredTrack = info.Children.FirstOrDefault(child => child.Id == 4u); bar.TrackSprite = authoredTrack is null ? 0u : DefaultImage(authoredTrack); } // gmCombatUI's desired-power slider (0x1000004F) authors the // live charge as a nested Type-7 meter. UiScrollbar consumes its // DAT children, so retain the meter's fill image on the scalar // widget itself. The fill container is element 2; the Recklessness // overlay (0x100005EF) is deliberately not the charge image. ElementInfo? meter = info.Children.FirstOrDefault(child => child.Type == 7u); ElementInfo? fill = meter?.Children.FirstOrDefault(child => child.Id == 2u) ?? meter?.Children .Where(child => DefaultImage(child) != 0u) .OrderByDescending(child => child.ReadOrder) .FirstOrDefault(); bar.ScalarFillSprite = fill is null ? 0u : DefaultImage(fill); // gmCombatUI preserves a second authored meter child for the // trained-Recklessness range. The controller owns its visibility, // exactly like gmCombatUI::ListenToElementMessage @ 0x004CC430; // the widget only retains the media because it consumes DAT children. ElementInfo? scalarRange = meter?.Children.FirstOrDefault( child => child.Id == 0x100005EFu); bar.ScalarRangeSprite = scalarRange is null ? 0u : DefaultImage(scalarRange); // gmCombatUI meter 0x10000050 grows from the Power end at the // right toward Speed at the left (retail screenshot oracle + // gmCombatUI::RecvNotice_SetPowerbarLevel 0x004CC0E0). bar.ScalarFillFromRight = meter?.Id == 0x10000050u; return bar; } uint incrementId = ReferencedElementId(info, 0x77u); uint decrementId = ReferencedElementId(info, 0x78u); ElementInfo? increment = info.Children.FirstOrDefault(child => child.Id == incrementId); ElementInfo? decrement = info.Children.FirstOrDefault(child => child.Id == decrementId); bar.UpSprite = decrement is null ? 0u : DefaultImage(decrement); bar.DownSprite = increment is null ? 0u : DefaultImage(increment); ElementInfo? thumb = info.Children.FirstOrDefault(child => child.Type == 1u && child.Id != incrementId && child.Id != decrementId); if (thumb is not null) { ElementInfo[] slices = thumb.Children .Where(child => DefaultImage(child) != 0u) .OrderBy(child => child.Y) .ThenBy(child => child.ReadOrder) .ToArray(); if (slices.Length > 0) bar.ThumbTopSprite = DefaultImage(slices[0]); if (slices.Length > 1) bar.ThumbSprite = DefaultImage(slices[1]); if (slices.Length > 2) bar.ThumbBotSprite = DefaultImage(slices[^1]); } return bar; } private static uint ReferencedElementId(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, }; } private static uint DefaultImage(ElementInfo info) { uint stateId = info.EffectiveDefaultStateId(); if (info.States.TryGetValue(stateId, out var state) && state.Image is { } image) return image.File; if (info.States.TryGetValue(UiStateInfo.DirectStateId, out var direct) && direct.Image is { } directImage) return directImage.File; return 0u; } // ── Meter ──────────────────────────────────────────────────────────────── /// /// Builds a and populates its sprite ids from the meter's /// child/grandchild elements (format doc §11). Two shapes are handled: /// /// /// 3-slice shape (vitals meters — 2 Type-3 containers, each with 3 image grandchildren): /// /// meter (Type 7) /// ├── back-layer container (Type 3, lower ReadOrder — drawn first / behind) /// │ ├── left-cap image (DirectState "" → File = back-left sprite) /// │ ├── center image (DirectState "" → File = back-tile sprite) /// │ └── right-cap image (DirectState "" → File = back-right sprite) /// ├── front-layer container (Type 3, higher ReadOrder — drawn on top) /// │ ├── left-cap image (→ front-left sprite) /// │ ├── center image (→ front-tile sprite) /// │ ├── right-cap image (→ front-right sprite) /// │ └── expand overlay (named "ShowDetail"/"HideDetail" only — NO DirectState — IGNORED) /// └── text label (Type 0) (IGNORED — Fill/Label providers bound by VitalsController) /// /// /// /// /// Single-image shape (toolbar selected-object meters 0x100001A1/0x100001A2 — 1 Type-3 /// child, no grandchildren): the back-track sprite is on the meter element's own DirectState; /// the fill sprite is on the single Type-3 child's own DirectState. Both are placed in the /// TILE slot (Back/FrontTile) with left/right caps 0, so tiles /// them across the full bar geometry (DrawMode=Normal) and clips the fill to the fraction. /// (retail: gmToolbarUI::HandleSelectionChanged :198635, UIElement_Meter::Initialize :123328) /// /// meter (Type 7) [DirectState "" → back-track sprite, e.g. 0x0600193E] /// └── fill container (Type 3) [DirectState "" → fill sprite, e.g. 0x0600193F] /// /// /// /// /// and are NOT set here. /// They are bound to the live stat providers by the controller (VitalsController / /// SelectedObjectController). /// /// private static UiMeter BuildMeter(ElementInfo info, Func resolve, UiDatFont? datFont) { var m = new UiMeter { ElementId = info.Id, SpriteResolve = resolve, DatFont = datFont, }; // The two 3-slice containers are Type-3 children of the meter element. // ReadOrder determines draw order: the back track has a LOWER ReadOrder // (drawn first, behind the fill), the front has a HIGHER ReadOrder (on top). var containers = info.Children .Where(c => c.Type == 3) .OrderBy(c => c.ReadOrder) .ToList(); if (containers.Count >= 2) { // Vitals 3-slice shape: two Type-3 containers each holding 3 grandchild images // (left-cap / center-tile / right-cap). Back is the lower ReadOrder; front is higher. var (bl, bt, br) = SliceIds(containers[0]); m.BackLeft = bl; m.BackTile = bt; m.BackRight = br; var (fl, ft, fr) = SliceIds(containers[1]); m.FrontLeft = fl; m.FrontTile = ft; m.FrontRight = fr; } else if (containers.Count == 1) { // Single-image shape used by the toolbar selected-object meters // (health 0x100001A1, mana 0x100001A2). // - The back-track sprite lives on the meter ELEMENT's own DirectState ("" key of // info.StateMedia) — not on any grandchild image. e.g. health back = 0x0600193E. // - The fill sprite lives on the single Type-3 child's own DirectState ("" key of // containers[0].StateMedia). e.g. health fill = 0x0600193F. // The fill child has NO image grandchildren, so SliceIds would return all-zero — // read the container's StateMedia directly instead. // // These go in the TILE slot (not the left-cap slot): the sprites are DrawMode=Normal, // which retail renders as "tile at native width to fill the full element geometry" // (format doc §6; the generic UiDatElement.OnDraw Normal path; UIElement_Meter:: // DrawChildren :123574 clips the child's FULL 140px geometry box to the fill fraction). // With the sprite on BackLeft instead, UiMeter.DrawHBar would clamp the cap to the // sprite's NATIVE width (capL = min(nativeW, 140)) — leaving a right-side gap and // mapping the fill fraction to native width when nativeW < 140. The tile slot makes // midW = full bar width, so the back tiles across all 140px and the front clips to // 140*fraction correctly for any native sprite width (left/right caps unused = 0). // (retail: gmToolbarUI::HandleSelectionChanged :198635 / UIElement_Meter::DrawChildren :123574) m.BackLeft = 0; m.BackTile = info.StateMedia.TryGetValue("", out var bm) ? bm.File : 0u; m.BackRight = 0; m.FrontLeft = 0; m.FrontTile = containers[0].StateMedia.TryGetValue("", out var fm) ? fm.File : 0u; m.FrontRight = 0; } else { // Count == 0: no Type-3 containers at all — genuinely malformed meter dat. Console.WriteLine($"[D.2b] meter 0x{info.Id:X8}: {containers.Count} Type-3 slice containers (expected 1 or 2) — bars may render as solid-color fallback."); } return m; } /// /// Returns the (left, tile, right) sprite ids for a 3-slice container, /// extracting them from the container's image children that carry a DirectState /// ("" key) with a non-zero file id, ordered left-to-right by their X position. /// /// /// Children that carry ONLY named states (e.g. the expand-detail overlay with /// "ShowDetail"/"HideDetail" entries but no "" key) are excluded automatically /// because for "" returns /// false. /// /// private static (uint left, uint tile, uint right) SliceIds(ElementInfo container) { // Only children that have a non-zero DirectState image are slice candidates. // The expand-detail overlay has NO DirectState entry, so it's excluded here. // Project the File during filtering to avoid a second TryGetValue lookup. // Stable sort: on an X tie, original Children insertion order (dat key-sort order) wins. var slices = container.Children .Where(c => c.StateMedia.TryGetValue("", out var med) && med.File != 0) .Select(c => (c.X, File: c.StateMedia[""].File)) .OrderBy(t => t.X) .ToList(); uint left = slices.Count > 0 ? slices[0].File : 0u; uint tile = slices.Count > 1 ? slices[1].File : 0u; uint right = slices.Count > 2 ? slices[2].File : 0u; return (left, tile, right); } // ── Text ───────────────────────────────────────────────────────────────── /// Type-12 UIElement_Text: an editable field or colored-line text view, /// selected from the canonical property bag. The element's /// own Direct/Normal media (if any) becomes the background sprite, drawn under the text — /// so a Type-12 element that previously rendered via UiDatElement keeps its sprite. Lines /// are bound later by the controller (LinesProvider). An unbound UiText draws nothing /// because defaults to transparent. /// /// /// Justification from the dat ( / /// ) is applied here at build time so that controllers /// that subsequently call / /// on dat-origin elements can be simplified. Controllers that explicitly set those /// properties after still override the build-time /// defaults — the build-time value is just the starting point, not a lock. /// /// /// The font to seed on the widget. When a font resolver was /// provided and the element's FontDid resolved successfully, this is that element-specific /// font; otherwise it is the shared global fallback. Controllers that call /// and set afterward /// still override this — the build-time value is just the starting point. private static UiElement BuildText(ElementInfo info, Func resolve, UiDatFont? elementFont = null, Func? stringResolve = null) { uint bg = info.StateMedia.TryGetValue( !string.IsNullOrEmpty(info.DefaultStateName) ? info.DefaultStateName : info.StateMedia.ContainsKey("Normal") ? "Normal" : "", out var m) ? m.File : 0u; bool editable = info.TryGetEffectiveBool(0x16u, out var editableValue) && editableValue; bool selectable = info.TryGetEffectiveBool(0x27u, out var selectableValue) && selectableValue; bool oneLine = info.TryGetEffectiveBool(0x20u, out var oneLineValue) && oneLineValue; if (editable) { uint focusSprite = info.StateMedia.TryGetValue("Normal_focussed", out var focus) ? focus.File : 0u; var field = new UiField { ElementId = info.Id, DatFont = elementFont, SpriteResolve = resolve, BackgroundSprite = bg, FocusFieldSprite = focusSprite, Selectable = selectable, OneLine = oneLine, Centered = info.HJustify == HJustify.Center, RightAligned = info.HJustify == HJustify.Right, }; if (info.TryGetEffectiveInteger(0x1Eu, out int maxCharacters)) field.MaxCharacters = maxCharacters; if (info.FontColor.HasValue) field.TextColor = info.FontColor.Value; return field; } // Apply horizontal + vertical justification from the dat at build time. // Controllers that call FindElement and set Centered/RightAligned/VerticalJustify // afterward will override these — this is only the dat-driven default. bool centered = info.HJustify == HJustify.Center; bool rightAligned = info.HJustify == HJustify.Right; var vJustify = info.VJustify switch { VJustify.Top => VJustify.Top, VJustify.Bottom => VJustify.Bottom, _ => VJustify.Center, }; var t = new UiText { ElementId = info.Id, BackgroundSprite = bg, SpriteResolve = resolve, Centered = centered, RightAligned = rightAligned, VerticalJustify = vJustify, OneLine = oneLine, Selectable = selectable, // Seed the dat-driven font. When a font resolver was supplied and the element // carries a non-zero FontDid, elementFont is the element-specific dat font; otherwise // it is the shared global fallback. Controllers that call FindElement and explicitly // set DatFont afterward STILL override this (backward-compat guarantee). DatFont = elementFont, }; // Font color from dat property 0x1B (ColorBaseProperty). // When present, seed DefaultColor so controllers that read it don't have to hard-code colors. // Controllers that supply explicit per-line colors via LinesProvider still win — this is only // the build-time default. if (info.FontColor.HasValue) t.DefaultColor = info.FontColor.Value; if (ResolveAuthoredString(info, stringResolve) is { Length: > 0 } authored) { UiText.Line[] line = [new UiText.Line(authored, t.DefaultColor)]; t.LinesProvider = () => line; } return t; } private static UiButton BuildButton( ElementInfo info, Func resolve, UiDatFont? elementFont, Func? stringResolve) => new(info, resolve) { Label = ResolveAuthoredString(info, stringResolve), LabelFont = elementFont, LabelColor = info.FontColor ?? System.Numerics.Vector4.One, }; /// /// Retail UIOption_Checkbox is a UIElement_Button whose visible face is its /// authored indicator child. Its label lives on the option object rather than /// in a child UIElement_Text. /// private static UiButton BuildCheckbox( ElementInfo info, Func resolve, UiDatFont? elementFont, Func? stringResolve) { ElementInfo? indicator = info.Children.FirstOrDefault(child => DefaultImage(child) != 0u); var button = new UiButton(info, resolve, indicator) { Label = ResolveAuthoredString(info, stringResolve), LabelFont = elementFont, LabelColor = info.FontColor ?? System.Numerics.Vector4.One, LabelAlign = UiButton.LabelAlignment.Left, }; if (indicator is not null) { button.FaceLeft = indicator.X; button.FaceTop = indicator.Y; button.FaceWidth = indicator.Width; button.FaceHeight = indicator.Height; button.LabelOffsetX = indicator.X + indicator.Width + 4f; } return button; } private static string? ResolveAuthoredString( ElementInfo info, Func? stringResolve) { if (stringResolve is null || !info.TryGetEffectiveProperty(0x17u, out var property) || property.Kind != UiPropertyKind.StringInfo) return null; return stringResolve(property.StringInfoValue); } }