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 0x2100006F) 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, fontResolve, stringResolve), // UIElement_Button 2 => new UiDatElement(info, resolve) // UIElement_Dragbar (Register @ 0x0046C840) { // The authored window-move handle: it must claim the pointer // (UiDatElement defaults to ClickThrough decoration) so a press // starts the window move and hover shows the move cursor // (StartMouseMoving @ 0x0046C760 → UIElement::StartMovement). WindowMoveHandle = true, ClickThrough = false, }, IndicatorBarController.BurdenClassId or IndicatorBarController.EffectsClassId or IndicatorBarController.LinkClassId or IndicatorBarController.MiniGameClassId or IndicatorBarController.VitaeClassId => BuildButton( info, resolve, elementFont, fontResolve, stringResolve), // gmUIElement_*Indicator custom button classes // UIElement_ListBox (Type 5). OP2 rework (docs/research/2026-08-11-op2- // review-blast.md MUST-FIX 2): every pre-existing Type-5 element that // reaches this factory already authors a non-empty row-template array // (dat property 0x64) — the original "none currently reach this factory" // premise was false. UiTemplateListBox now derives from UiDatElement and // stays DORMANT (no viewport, no behavior change) until a controller calls // AddItemFromTemplateList, so mapping every Type-5 element unconditionally // is safe: an element with an empty TemplateList behaves EXACTLY like the // pre-OP2 UiDatElement fallback (media drawn, ClickThrough=true, state // propagation) because nothing ever activates it. 5 => new UiTemplateListBox(info, resolve, info.TemplateList, info.ScrollbarElementId), 6 => new UiMenu(), // UIElement_Menu (reg :120163) 7 => BuildMeter(info, resolve, elementFont, stringResolve), // UIElement_Meter // UIElement_Panel (Type 8) — retail's tab-strip host (dat property 0x2E; // research doc §1.3/§10.1). OP2 rework (docs/research/2026-08-11-op2- // review-mechanism.md MUST-FIX 5): Type 8 is UIElement_Panel, NOT a class // called "UIElement_TabControl" (that name does not exist in the named- // retail PDB). UiTabPanel derives from UiDatElement and stays DORMANT (see // its class doc) until a controller calls ActivateTabBehavior(), so mapping // every Type-8 element unconditionally is safe for the same reason as the // Type-5 arm above — including the vendor backdrop 0x1000008D, which has NO // tab table and now keeps its authored DirectState fill via the UiDatElement // base instead of losing it to a bare UiElement with no OnDraw. 8 => new UiTabPanel(info, resolve, info.TabTable), 9 => BuildResizeGrip(info, resolve), // UIElement_Resizebar (reg 0x0046B920) 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 0x13 => new UiDialogRoot(), // ConfirmationDialog 0x15 => new UiDialogRoot(), // ConfirmationTextInputDialog 0x17 => new UiDialogRoot(), // MessageDialog 0x19 => new UiDialogRoot(), // WaitDialog (catalog root 0x31 — OP8 #396) 0x10000031u => new UiItemList(resolve), // UIElement_ItemList — toolbar/inventory/paperdoll slots 0x10000035u => BuildCheckbox( info, resolve, elementFont, fontResolve, stringResolve), // UIOption_Checkbox // UIOption_CheckboxSlider (Type 0x10000036): a composite row whose class id // lands on the row root itself, but whose content is two NESTED option // widgets (a UIOption_Checkbox child + a UIOption_Slider child — verified // against options_2100002B.json's templates 0x10000220/0x10000221). It does // not consume its dat children, so those build normally through the two // mappings immediately below; UiOptionToggleSlider just grabs references to // them once attached (see docs/research/2026-08-10-options-panel-structure.md // §1.1/§1.5). 0x10000036u => new UiOptionToggleSlider(), // UIOption_Slider (Type 0x10000037): structurally an ordinary HORIZONTAL // UIElement_Scrollbar — its own DirectState carries the track sprite and its // child id 1 is the drag thumb, the exact convention BuildScrollbar's // horizontal branch already implements (verified against // options_2100002B.json's slider control 0x1000021C: W=120 > H=12, one // Type-1 child at id 1). No new drawing code — same "compose existing // primitives" directive as UiOptionToggleSlider above. 0x10000037u => BuildScrollbar(info, resolve), // UIOption_Menu (Type 0x10000038): a label + arrow-cap dropdown button, // structurally identical to the vendor category dropdown UiMenu already // models (verified against options_2100002B.json's menu control 0x10000224: // a Text label child + a 17x19 image child, matching UiMenu's own // ArrowCapClosedSprite doc comment). Built blank, exactly like the Type-6 // case above — a page controller wires its sprites/items the same way // ChatWindowController wires the channel menu. 0x10000038u => new UiMenu(), // UIOption_CheckboxBitfield64 (Type 0x10000044): the Chat tab's per-window // text-filter block. OP2 rework (docs/research/2026-08-11-op2-review- // mechanism.md MUST-FIX 4): the authored template (0x10000520) DOES author // content — its own row-template list (dat property 0x64 -> {0x2100002B, // 0x10000521}) — and retail CreateChildren @0x00485DF0 builds every row // through AddItemFromTemplateList(this, 0, nullptr), matching retail's own // gmChatOptionsUI::AddCheckboxBitfield64Option call pattern (research doc // §5.2). AddChild(lowMask, highMask, label, tooltip) resolves that SAME // template per row instead of synthesizing a fake ElementInfo. // AP-195 (OP5): the block ALSO authors its own all/partial-set LED media // (dat properties 0x10000082/0x10000083) directly on this element — thread // them through so Refresh's ported LED swap has real sprites to apply. 0x10000044u => new UiCheckboxBitfield64( info.TemplateList, info.LedCheckedSprite, info.LedUncheckedSprite), _ => 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 = CreateLayoutPolicy(info); return e; } /// /// Bind inherited scrollbar media structurally. Property 0x77 names the /// increment button and 0x78 the decrement button; retail /// UIElement_Scrollbar::UpdateScrollingArea @ 0x00470AA0 then places /// those referenced child buttons by their authored leading/trailing /// positions. 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, }; 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); ElementInfo? leadingButton = new[] { increment, decrement } .Where(child => child is not null) .OrderBy(child => bar.Horizontal ? child!.X : child!.Y) .ThenBy(child => child!.ReadOrder) .FirstOrDefault(); ElementInfo? trailingButton = new[] { increment, decrement } .Where(child => child is not null) .OrderByDescending(child => bar.Horizontal ? child!.X : child!.Y) .ThenByDescending(child => child!.ReadOrder) .FirstOrDefault(); bar.UpSprite = ButtonStateImage(leadingButton, "Normal"); bar.UpRolloverSprite = ButtonStateImage(leadingButton, "Normal_rollover"); bar.UpPressedSprite = ButtonStateImage(leadingButton, "Normal_pressed"); bar.DownSprite = ButtonStateImage(trailingButton, "Normal"); bar.DownRolloverSprite = ButtonStateImage(trailingButton, "Normal_rollover"); bar.DownPressedSprite = ButtonStateImage(trailingButton, "Normal_pressed"); if (info.TryGetEffectiveBool(0x79u, out bool hideDisabled)) bar.HideWhenDisabled = hideDisabled; if (bar.Horizontal) { if (leadingButton is { Width: > 0f }) bar.DecrementButtonExtent = leadingButton.Width; if (trailingButton is { Width: > 0f }) bar.IncrementButtonExtent = trailingButton.Width; // 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 its dark // red interior range. The widget retains the media because horizontal // scrollbars consume their DAT children. ElementInfo? scalarRange = meter?.Children.FirstOrDefault( child => child.Id == 0x100005EFu); bar.ScalarRangeSprite = scalarRange is null ? 0u : DefaultImage(scalarRange); bar.ScalarRangeLayoutPolicy = scalarRange is null ? null : CreateLayoutPolicy(scalarRange); // UIElement_Meter::UIElement_Meter @ 0x0046F4C0 defaults direction // 1; DrawChildren @ 0x0046FBD0 clips that direction left-to-right. // Direction 3 is the horizontal reverse. Read authored attribute // 0x6F instead of inferring direction from the combat element id. bar.ScalarFillFromRight = meter is not null && meter.TryGetEffectiveProperty(0x6Fu, out UiPropertyValue direction) && direction.Kind == UiPropertyKind.Enum && direction.UnsignedValue == 3u; return bar; } if (leadingButton is { Height: > 0f }) bar.DecrementButtonExtent = leadingButton.Height; if (trailingButton is { Height: > 0f }) bar.IncrementButtonExtent = trailingButton.Height; 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]); // R3-4/R3-7 (Campaign CC gate round 1 re-test 2): retail authors // TWO distinct thumb shapes for UIElement_Scrollbar (Type 11) — // chat's own scrollbar (0x10000012) is the 3-slice composite the // block above was built against (the thumb CHILD carries no media // of its own; three Type-3 grandchildren supply the top-cap/ // middle/bottom-cap sprites) — but the chargen Skills listbox // (0x100003f8), Summary's OVERVIEW listbox (0x10000401), and the // Summary how-to box (0x100002e7 under 0x10000404) all author a // SIMPLE single-sprite thumb instead: the SAME structural child // (Type 1, id 1, not the inc/dec button) carries its OWN direct // Normal/Normal_rollover/Normal_pressed media and has ZERO // children (live-DAT-probe-confirmed against all three — no // slice grandchildren to find, so `slices` above is always // empty for this shape and every Thumb*Sprite stayed 0, // matching the reported "track+arrows render, no thumb" // symptom). already falls back // to a single tiled `ThumbSprite` blit when the cap sprites are // unset (`ThumbTopSprite != 0 && ThumbBotSprite != 0` gate), so // the only missing piece is feeding it the thumb's OWN media // when it has no slice children — additive: a thumb WITH real // slice children (chat) is unaffected since `slices.Length == 0` // is false for that shape. if (slices.Length == 0) bar.ThumbSprite = DefaultImage(thumb); } return bar; } /// /// Builds a from a Type-9 UIElement_Resizebar /// element: retail StartMouseResizing @0x0046B7E0 reads four BOOL /// attributes — 0x2A=bottom, 0x2B=left, 0x2C=right, /// 0x2D=top — and decodes them into a BorderLocation. A grip with /// no true bool (the inert _Locked cosmetic twins are a DIFFERENT /// element type entirely and never reach this factory case, but an /// all-false Type-9 element is handled defensively the same way retail's own /// BORDER_NONE fallback does) decodes to /// — then treats it as contributing no resize edges. /// is carried through (CH6a/b REJECT-review /// BLOCKER 1) so the grip draws its own authored border/corner media /// instead of nothing. /// private static UiResizeGrip BuildResizeGrip( ElementInfo info, Func resolve) { bool bottom = info.TryGetEffectiveBool(0x2Au, out bool bottomValue) && bottomValue; bool left = info.TryGetEffectiveBool(0x2Bu, out bool leftValue) && leftValue; bool right = info.TryGetEffectiveBool(0x2Cu, out bool rightValue) && rightValue; bool top = info.TryGetEffectiveBool(0x2Du, out bool topValue) && topValue; return new UiResizeGrip(info, resolve) { BorderLocation = UiResizeGrip.DecodeBorderLocation(bottom, left, right, top), }; } private static UiLayoutPolicy? CreateLayoutPolicy(ElementInfo info) { if (!info.HasOriginalParentSize) return null; return 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)); } 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; } private static uint ButtonStateImage(ElementInfo? info, string stateName) { if (info is null) return 0u; if (info.StateMedia.TryGetValue(stateName, out var media)) return media.File; UiStateInfo? state = info.States.Values.FirstOrDefault( candidate => string.Equals(candidate.Name, stateName, StringComparison.Ordinal)); if (state?.Image is { } image) return image.File; return stateName == "Normal" ? DefaultImage(info) : 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, Func? stringResolve = null) { var m = new UiMeter { ElementId = info.Id, SpriteResolve = resolve, DatFont = datFont, // Outline 0x21 from the meter element (round-5 review S2). Outline = info.Outline, }; if (info.OutlineColor.HasValue) m.OutlineColor = info.OutlineColor.Value; // 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 && HasThreeSliceShape(containers[0]) && HasThreeSliceShape(containers[1])) { // 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 && containers[0].StateMedia.ContainsKey("")) { // 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 if (containers.Any(HasStatefulFill)) { // Stateful single-image shape used by gmPowerbarUI (LayoutDesc // 0x21000072). The meter's DirectState is the empty track. One // Type-3 child supplies named Jump/Melee/Missile/DDD state media; // another carries the optional recklessness range as DirectState. // gmPowerbarUI::PostInit @ 0x004DA4E0 hides that range, so it is not // promoted into a meter slice here. m.BackLeft = 0; m.BackTile = info.StateMedia.TryGetValue("", out var track) ? track.File : 0u; m.BackRight = 0; m.FrontLeft = 0; m.FrontTile = 0; m.FrontRight = 0; foreach (ElementInfo container in containers) { foreach (var (stateId, state) in container.States) { if (stateId == UiStateInfo.DirectStateId || !container.StateMedia.TryGetValue(state.Name, out var media)) continue; m.ConfigureStateFill(stateId, media.File); } } // The absorbed Type-12 caption child (gmPowerbarUI's 0x10000035) // authors the per-mode caption on its own states — JumpMode // 'Height', MeleeMode 'Power', MissileMode 'Accuracy'. Each mode // STATE also authors its own justification (0x14 = 0x3 Right on // every powerbar mode; the element default is centered — // installed-DAT probe 2026-08-14, the user's retail gate). Retail // shows it through the meter's PassToChildren state cascade; the // absorbed equivalent is a state-label table consulted by // TrySetRetailState. foreach (ElementInfo textChild in info.Children.Where(static c => c.Type == 12)) { foreach (var (stateId, state) in textChild.States) { if (stateId == UiStateInfo.DirectStateId || !state.Properties.Values.TryGetValue(0x17u, out var caption) || caption.Kind != UiPropertyKind.StringInfo) continue; if (stringResolve?.Invoke(caption.StringInfoValue) is not { Length: > 0 } text) continue; // The state's own 0x14 wins; absent → the element-level // justification (ElementReader's same enum mapping). UiMeterLabelAlign align = textChild.HJustify switch { HJustify.Left => UiMeterLabelAlign.Left, HJustify.Right => UiMeterLabelAlign.Right, _ => UiMeterLabelAlign.Center, }; if (state.Properties.Values.TryGetValue(0x14u, out var justify) && justify.Kind == UiPropertyKind.Enum) { align = justify.UnsignedValue switch { 0u or 2u => UiMeterLabelAlign.Left, 3u or 5u => UiMeterLabelAlign.Right, _ => UiMeterLabelAlign.Center, }; } m.ConfigureStateLabel(stateId, text, align); } } } else { Console.WriteLine($"[D.2b] meter 0x{info.Id:X8}: {containers.Count} Type-3 containers but no recognized 3-slice, direct-fill, or stateful-fill shape — bar may render as solid-color fallback."); } return m; } private static bool HasThreeSliceShape(ElementInfo container) => container.Children.Count(c => c.StateMedia.TryGetValue("", out var media) && media.File != 0) >= 3; private static bool HasStatefulFill(ElementInfo container) => container.States.Any(pair => pair.Key != UiStateInfo.DirectStateId && container.StateMedia.TryGetValue(pair.Value.Name, out var media) && media.File != 0); /// /// 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, // Outline 0x21 from the field element (round-5 review S2). Outline = info.Outline, }; if (info.TryGetEffectiveInteger(0x1Eu, out int maxCharacters)) field.MaxCharacters = maxCharacters; if (info.FontColor.HasValue) field.TextColor = info.FontColor.Value; if (info.OutlineColor.HasValue) field.OutlineColor = info.OutlineColor.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, FontColorPalette = ElementReader.ReadEffectiveColorPalette( info, 0x1Bu), // Outline from dat property 0x21 (BoolBaseProperty). Default false — matches // ElementInfo.Outline's own default, so this is a no-op for the ~99% of text // elements that don't author it. Outline = info.Outline, // R2-1 (Campaign CC gate round 1 Batch E): the four text-inset // margins (dat properties 0x23-0x26 — MarginLeft's own doc // comment on UiText). Default 0 — a no-op for every element that // doesn't author them (only consumed by the multi-line path). MarginLeft = info.MarginLeft, MarginRight = info.MarginRight, MarginTop = info.MarginTop, MarginBottom = info.MarginBottom, }; t.ConfigureDatState(info); // 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; // Outline color from dat property 0x22 (ColorBaseProperty). Only 9 elements in the // whole DAT set author a non-black value; when absent, UiText's own ctor default // (black, matching retail's m_curOutlineColor default) already applies. if (info.OutlineColor.HasValue) t.OutlineColor = info.OutlineColor.Value; if (ResolveAuthoredString(info, stringResolve) is { Length: > 0 } authored) { // 2026-08-13 social gate: authored strings can carry embedded // newlines (the fellowship empty-state is three sentences over // '\n's). Gate round 2: the DAT stores the LITERAL two-character // escape "\n" (0x5C 0x6E — probe-verified: the dump printed // backslash-n, not a line break), so normalize the escape first. // Gate round 3: retail additionally WORD-WRAPS each authored line // within the element extent (its GlyphList draw — the same wrap // the confirmation dialog view already uses), so a multiline // authored block re-wraps to the widget's live width instead of // clipping at the edge. Single-line authored labels keep the // one-run shape they have always had (they are authored to fit; // re-wrapping them is a client-wide behavior change no gate has // asked for). Providers re-read DefaultColor/width/font per call // (NOT captured eagerly) so state-driven changes keep tracking. string normalized = authored .Replace("\\n", "\n") .Replace("\r", string.Empty); if (normalized.Contains('\n')) { float cachedWidth = float.NaN; UiDatFont? cachedFont = null; System.Numerics.Vector4 cachedColor = default; UiText.Line[]? cachedLines = null; t.LinesProvider = () => { if (cachedLines is null || cachedWidth != t.Width || !ReferenceEquals(cachedFont, t.DatFont) || cachedColor != t.DefaultColor) { cachedWidth = t.Width; cachedFont = t.DatFont; cachedColor = t.DefaultColor; // R2-1: shrink by BOTH Padding and the four retail // margins — see DatRichText.Compose's own comment on // the same formula. float maximumWidth = Math.Max( 1f, t.Width - (t.Padding + t.MarginLeft) - (t.Padding + t.MarginRight)); Func measure = t.DatFont is { } font ? font.MeasureWidth : static value => value.Length * 8f; cachedLines = [.. UiText .WrapWords(normalized, measure, maximumWidth) .Select(line => new UiText.Line(line, t.DefaultColor))]; } return cachedLines; }; } else { t.LinesProvider = () => [new UiText.Line(normalized, t.DefaultColor)]; } } // Per-STATE authored strings (0x17 on the element's own states — the // friends row's status cell authors 'Online'/'Offline' this way, // with per-state colors). TrySetRetailState swaps the line when the // incoming state authors one; see UiText.SetAuthoredStateStrings. Dictionary? stateStrings = null; foreach (var (stateId, state) in info.States) { if (stateId == UiStateInfo.DirectStateId || !state.Properties.Values.TryGetValue(0x17u, out var stateCaption) || stateCaption.Kind != UiPropertyKind.StringInfo) continue; if (NormalizeEscapes(stringResolve?.Invoke(stateCaption.StringInfoValue)) is { Length: > 0 } text) (stateStrings ??= new Dictionary())[stateId] = text; } if (stateStrings is not null) t.SetAuthoredStateStrings(stateStrings); return t; } private static UiButton BuildButton( ElementInfo info, Func resolve, UiDatFont? elementFont, Func? fontResolve, Func? stringResolve) { // UIElement_Button propagates its retail state into authored children. // Spellbook school/level filters are Type-1 buttons with no parent media: // their 13x13 child carries Normal/Highlight art. Keep that child as the // face of this retained leaf instead of consuming and losing it. ElementInfo[] authoredFaces = info.StateMedia.Count == 0 ? FindStatefulFaceChildren(info) : []; ElementInfo? face = authoredFaces.Length == 1 ? authoredFaces[0] : null; IReadOnlyList? faceSegments = authoredFaces.Length > 1 ? authoredFaces : null; string? label = ResolveAuthoredString(info, stringResolve); ElementInfo labelInfo = info; if (label is null) { // Normal retail buttons such as the spellbook Delete control keep // their caption in a full-size UIElement_Text child. UiButton is a // retained leaf, so lift that authored text/font/color onto the leaf. foreach (ElementInfo child in info.Children.Where(child => child.Type == 12u)) { label = ResolveAuthoredString(child, stringResolve); if (label is null) continue; labelInfo = child; break; } } UiDatFont? labelFont = elementFont; if (labelInfo.FontDid != 0u && fontResolve is not null) labelFont = fontResolve(labelInfo.FontDid) ?? elementFont; var button = new UiButton(info, resolve, face, faceSegments) { Label = label, LabelFont = labelFont, LabelColor = labelInfo.FontColor ?? info.FontColor ?? System.Numerics.Vector4.One, // Outline 0x21 / OutlineColor 0x22 follow the same lift chain as the label // and its color: the label-bearing Text child's authored value first, the // button's own second (round-5 review S2). Outline = labelInfo.Outline || info.Outline, }; if ((labelInfo.OutlineColor ?? info.OutlineColor) is { } buttonOutlineColor) button.OutlineColor = buttonOutlineColor; if (face is not null) { button.FaceLeft = face.X; button.FaceTop = face.Y; button.FaceWidth = face.Width; button.FaceHeight = face.Height; if (!ReferenceEquals(labelInfo, info)) { // GF-11c (Campaign CC gate round 1 Batch B): a DISTINCT // Type-12 caption was lifted (e.g. the Town page's per- // marker name label, 0x10000409 under each town button — // live-DAT-probe-confirmed authored rect + Center justify, // independent of the marker face's own geometry) — honor // ITS OWN authored rect/justify instead of the face- // relative offset below, which is only correct when the // label text is authored DIRECTLY on the button itself, // immediately beside a single-purpose face segment (the // heritage/template/Face-Clothes sub-tab row family — // still handled by the else-branch two lines down, since // ReferenceEquals(labelInfo, info) is true there). button.LabelBox = (labelInfo.X, labelInfo.Y, labelInfo.Width, labelInfo.Height); button.LabelAlign = labelInfo.HJustify == HJustify.Left ? UiButton.LabelAlignment.Left : UiButton.LabelAlignment.Center; } else { button.LabelAlign = UiButton.LabelAlignment.Left; // F10 (Campaign CC gate round 1 closeout): this +4f gap and // UiButton.LabelOffsetX's own class-default 3f (used by the // "no face, not lifted" branch below, AND by any caller — // e.g. PaperdollController's "Slots" label — that sets // LabelAlign=Left directly with no DatWidgetFactory // involvement at all) are DELIBERATELY not the same number, // not an unreconciled oversight: neither carries a retail // decomp citation (both are acdream-synthesized small // insets), and they answer different questions — this one // is "gap after a REAL adjacent face element" (a geometry- // derived offset), the other is "default left inset when // there is no reference geometry at all" (a context-free // fallback). Moving either number to match the other would // be an unfounded 1px guess on whichever button currently // works, not a fix — see DatWidgetFactoryTests' own // `face.X(0) + face.Width(32) + 4` pin for this exact site. button.LabelOffsetX = face.X + face.Width + 4f; } } else if (labelInfo.HJustify == HJustify.Left) { // Campaign LA gate round 2 finding 2: the guard used to require // labelInfo to be a LIFTED Type-12 text child (!ReferenceEquals), // so a button authoring its OWN HJustify=Left with no separate // label child — e.g. gmCharacterManagementUI's character-list row // template (0x21000004/0x100003A5: HJustify=Left, three stateful // Type-3 highlight-art children, no Type-12 caption child) — fell // through with LabelAlign left at UiButton's Center default. // Live-DAT probe confirmed: rowInfo.HJustify=Left, // authoredFaces.Length=3 (faceSegments, not a single face), no // Type-12 child, and the built row's LabelAlign came out Center. // labelInfo.X is only a valid inner-offset when a distinct child // was actually lifted; for the direct (labelInfo == info) case, // leave UiButton's own default 3px LabelOffsetX in place — see // the face-relative +4f branch above (F10) for why this 3px // default and that 4px gap are deliberately different numbers, // not an unreconciled asymmetry. button.LabelAlign = UiButton.LabelAlignment.Left; if (!ReferenceEquals(labelInfo, info)) button.LabelOffsetX = labelInfo.X; } // AP-222 / GF-11b (Campaign CC gate round 1 Batch B): per-state label // color/outline (dat properties 0x1B/0x21 authored PER STATE on the // label-bearing element — the Appearance spins' own states, or the // Town caption child's states) — additive, only non-null when the // authored dat genuinely carries more than one distinct value. button.SetPerStateLabelStyle( ElementReader.BuildPerStateColorMap(labelInfo, 0x1Bu), ElementReader.BuildPerStateBoolMap(labelInfo, 0x21u)); // GF-4a (Campaign CC gate round 1 Batch C): retail's chargen // display buttons author the caption directly as THEIR OWN P0x17 // (so `label` above resolved from `info` itself, not a lifted // child) AND carry a SEPARATE, media-less Type-12 child for the // live value (gmCGProfessionPage::InitializePage // @0x00482f90-0x00483062, gmCGSkillsPage::InitializePage // @0x00481e1c — live-DAT-measured: exactly one Type-12 child, zero // StateMedia entries). Gated tightly to that exact shape so this // stays a no-op for every other button (a lifted-caption button // never reaches here with labelInfo==info; a button with an icon/ // face child instead of a value child has no media-less Type-12 // child to find). if (ReferenceEquals(labelInfo, info) && label is not null) { ElementInfo? valueChild = info.Children.FirstOrDefault( child => child.Type == 12u && child.StateMedia.Count == 0); if (valueChild is not null) { // R4-1 (Campaign CC gate round 1 re-test 3): reflow the value // child's authored rect through retail's own raw-edge policy // (UIElement::UpdateForParentSizeChange @0x00462640, ported // as UiLayoutPolicy) before it becomes ValueBox — see // ReflowValueChildRect's own doc for why this is needed and // decomp-cited. button.ValueBox = ReflowValueChildRect(valueChild, info); button.ValueFont = valueChild.FontDid != 0u && fontResolve is not null ? fontResolve(valueChild.FontDid) ?? elementFont : elementFont; button.ValueColor = valueChild.FontColor ?? System.Numerics.Vector4.One; button.ValueAlign = valueChild.HJustify switch { HJustify.Left => UiButton.LabelAlignment.Left, // R4-1: HJustify.Right (raw dat 3/5) previously fell into // this ternary's Center branch — CalcJustification's own // ecx_5==3||5 case is a DISTINCT far-edge formula (see // UiButton.LabelAlignment.Right's own doc), and every // value child in this family (0x100002f1/0x100002f3) // authors HJustify Right, live-DAT-confirmed. HJustify.Right => UiButton.LabelAlignment.Right, _ => UiButton.LabelAlignment.Center, }; // Seed with whatever the child itself authors (typically // blank) so an unbound button doesn't draw stray leftover // text before a controller writes a real value. button.ValueLabel = ResolveAuthoredString(valueChild, stringResolve); } } return button; } /// /// R4-1 (Campaign CC gate round 1 re-test 3): the "Available Skill /// Credits" value overlapped mid-caption ("Available Skill0Credits") /// because was built from the value /// child's RAW authored rect, un-reflowed. Live-DAT probe: the value /// child (0x100002f3) is BASE-INHERITED across four sibling /// buttons of DIFFERING widths — Health/Stamina/Mana at 150px share the /// exact same child id/rect (local X=116) as the wider, 231px Skills /// credits button, and the child's own OriginalParentWidth (the /// design-time parent size baked in at whichever button FIRST resolved /// it — 150, matching Health's own actual width) diverges from Skills /// credits' actual current parent width (231) — exactly the shape /// (retail /// UIElement::UpdateForParentSizeChange @0x00462640, already the /// production raw-edge reflow for live mounted elements via /// ) exists to correct. The child's /// own edge modes (Left=2/Right=1, live-DAT-confirmed) are retail's /// "track the far edge as the parent grows" reflow: applying them moves /// the value box from local X=116 to X=197 for Skills credits — landing /// immediately after the caption's own measured end (~x=196, /// SkillsCreditsButton_CaptionFitsFullWidth_ValueChildStartsAtMidpoint) /// instead of colliding mid-caption. Health/Stamina/Mana and the /// Attribute/Credits value child (whose OWN OriginalParentWidth already /// matches their actual parent, or whose edge modes are all 0/fixed) /// reflow to their byte-identical raw rect (deltaX=0 or mode-0 passthrough) /// — this is additive for every already-correct button, not a per-button /// special case. /// private static (float X, float Y, float Width, float Height) ReflowValueChildRect( ElementInfo child, ElementInfo parent) { float originalParentWidth = child.HasOriginalParentSize ? child.OriginalParentWidth : parent.Width; float originalParentHeight = child.HasOriginalParentSize ? child.OriginalParentHeight : parent.Height; var originalChild = UiPixelRect.FromPositionAndSize( (int)child.X, (int)child.Y, (int)child.Width, (int)child.Height); var originalParent = UiPixelRect.FromPositionAndSize( 0, 0, (int)originalParentWidth, (int)originalParentHeight); var currentParent = UiPixelRect.FromPositionAndSize( 0, 0, (int)parent.Width, (int)parent.Height); // Empty (Width=0/Height=0) "current child" so the static Apply's // currentChild-preservation branch never engages — every axis comes // from the Near/Far formula, matching mode 0's own "keep the raw // authored edge" default for the (frequent) no-anchor case. var noCurrentChild = new UiPixelRect(0, 0, -1, -1); UiPixelRect reflowed = UiLayoutPolicy.Apply( child.Left, child.Top, child.Right, child.Bottom, originalChild, originalParent, noCurrentChild, currentParent); return (reflowed.X0, reflowed.Y0, reflowed.Width, reflowed.Height); } /// /// 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? fontResolve, Func? stringResolve) { ElementInfo? indicator = FindStatefulFaceChild(info); var button = new UiButton(info, resolve, indicator) { Label = ResolveAuthoredString(info, stringResolve), LabelFont = info.FontDid != 0u && fontResolve is not null ? fontResolve(info.FontDid) ?? elementFont : elementFont, LabelColor = info.FontColor ?? System.Numerics.Vector4.One, LabelAlign = UiButton.LabelAlignment.Left, // Outline 0x21 from the checkbox element itself (round-5 review S2). Outline = info.Outline, }; if (info.OutlineColor.HasValue) button.OutlineColor = info.OutlineColor.Value; 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 ElementInfo? FindStatefulFaceChild(ElementInfo info) => FindStatefulFaceChildren(info).FirstOrDefault(); private static ElementInfo[] FindStatefulFaceChildren(ElementInfo info) => info.Children.Where(child => child.StateMedia.Count != 0 && child.StateMedia.Keys.Any(childState => info.States.Values.Any(parentState => string.Equals(parentState.Name, childState, StringComparison.Ordinal)))) .OrderBy(child => child.ReadOrder) .ToArray(); private static string? ResolveAuthoredString( ElementInfo info, Func? stringResolve) { if (stringResolve is null || !info.TryGetEffectiveProperty(0x17u, out var property) || property.Kind != UiPropertyKind.StringInfo) return null; string? resolved = stringResolve(property.StringInfoValue); // R2-2 (Campaign CC gate round 1 Batch E): the DAT stores the LITERAL // two-character escape "\n" (0x5C 0x6E), not a real line break — same // fact BuildText's own authored-string path already normalized for // (see that call site's own comment). Centralizing the normalize // HERE, at the single choke point every P0x17 caption resolution in // this file goes through (BuildText, BuildButton's own caption AND // its lifted-child caption, BuildButton's coexisting ValueLabel, // BuildCheckbox), closes the exact class of bug R2-2 found: a caption // like the Profession credits button's own "Attribute\n Credits" // rendered the literal backslash-n because BuildButton never // normalized while BuildText did. BuildText's own subsequent // Replace("\\n","\n") is now a harmless no-op (idempotent) — left in // place rather than removed, since it costs nothing and documents the // same fact locally. return NormalizeEscapes(resolved); } /// /// R2-2 (Campaign CC gate round 1 Batch E): the shared escape-normalize /// applies, pulled out so the /// per-STATE authored-caption loop below (which resolves a state's own /// 0x17 directly, bypassing the effective-property resolution /// wraps) gets the SAME normalize /// instead of a second, easily-forgotten copy. /// private static string? NormalizeEscapes(string? raw) => raw?.Replace("\\n", "\n").Replace("\r", string.Empty); }