Full re-derivation from named-retail decomp: UIElement::StartTooltipAtMouse @0x00460D70 -> UIElementManager::StartTooltip @0x0045DE90/@0x00459700, UIElement::MouseHover @0x00462520 (P0x4B TooltipOn gate + global m_tooltipEnable), UIElementManager::CheckTooltip @0x0045B6E0 (dwell/ auto-hide timer, default 0.25s/10s), SwitchMouseOver/DeletingElement (dismissal). Corrects the earlier GF-16 investigation: P0x47 is the element-desc id WITHIN the popup LayoutDesc (P0x48), not a "behavior enum"; P0x4A is read off the popup's own instantiated root, not the trigger element. - ElementInfo/UiElement gain six tooltip data fields (P0x47/48/49/4A/4B/50), read generically by ElementReader and copied through LayoutImporter, mirroring the existing AuthoredInvisible passthrough pattern. - UiRoot's existing CheckTooltip-derived hover timer gains TooltipShow/ TooltipHide events, a per-element P0x50 delay override, and dismissal wiring at every retail-confirmed teardown site. - RetailTooltipPresenter (owned by RetailUiRuntime, mounted alongside RetailDialogFactory) builds the popup via the existing LayoutImporter dat-lock seam, auto-resizes by the measured-vs-authored text delta (word-wrapped via the existing UiText.WrapWords primitive), positions at the mouse clamped to the display, and stays topmost over dialogs via its own later per-tick BringToFront (register AD-106). - Misc.TooltipEnable/Misc.TooltipDelay are client-local UserPreferences (retail's own 2013 Config tab authors no visible row for either) — SettingsStore gains a MiscSettings section, no new options-panel row. - Live-DAT sweep: 434 elements author >=1 trigger property (243 with literal text this port shows; 191 rely on retail's dynamic InqProperty(0x49) override, deferred as register TS-85 alongside the unmodeled P0x3D wrap-width override). Gates: Release build 0 errors; App suite (live-DAT env) 5410/5407 passed/ 3 skipped (was 5379/3); Runtime 1735/0 unchanged; UI.Abstractions 926/0; full solution 14,617/14,548 passed/69 skipped/0 failed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1203 lines
65 KiB
C#
1203 lines
65 KiB
C#
using System;
|
|
using System.Linq;
|
|
using AcDream.App.UI;
|
|
|
|
namespace AcDream.App.UI.Layout;
|
|
|
|
/// <summary>
|
|
/// Hybrid factory: behavioral element Types map to dedicated widgets (verbatim
|
|
/// algorithm ports); everything else (and unknown Types) falls back to
|
|
/// <see cref="UiDatElement"/>.
|
|
///
|
|
/// <para>
|
|
/// Type 12 = UIElement_Text. Editable `0x16` elements become <see cref="UiField"/>
|
|
/// in place; other elements become display/selectable <see cref="UiText"/> 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.
|
|
/// </para>
|
|
///
|
|
/// <para>
|
|
/// The meter's back/front 3-slice sprite ids live on grandchild image elements,
|
|
/// NOT on the meter element itself (format doc §11). <see cref="BuildMeter"/>
|
|
/// walks two layers down to extract them: the two Type-3 container children
|
|
/// ordered by <see cref="ElementInfo.ReadOrder"/> (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.
|
|
/// </para>
|
|
///
|
|
/// <para>
|
|
/// The expand-detail overlay present in the front container carries ONLY named
|
|
/// states ("HideDetail"/"ShowDetail") — no "" DirectState entry — so the
|
|
/// <c>TryGetValue("")</c> filter in <see cref="SliceIds"/> excludes it
|
|
/// automatically.
|
|
/// </para>
|
|
/// </summary>
|
|
public static class DatWidgetFactory
|
|
{
|
|
/// <summary>
|
|
/// Creates the <see cref="UiElement"/> for <paramref name="info"/>, sets its
|
|
/// rect (Left/Top/Width/Height) and Anchors, and returns it.
|
|
/// </summary>
|
|
/// <param name="info">Resolved, merged element snapshot from the LayoutDesc importer.</param>
|
|
/// <param name="resolve">RenderSurface id → (GL tex handle, pixel width, pixel height).
|
|
/// Returns (0,0,0) when the texture is not yet uploaded.</param>
|
|
/// <param name="datFont">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.</param>
|
|
/// <param name="fontResolve">Optional font resolver: FontDid → <see cref="UiDatFont"/>
|
|
/// (or null when the font can't be loaded). When non-null, any element whose
|
|
/// <see cref="ElementInfo.FontDid"/> is non-zero gets ITS OWN dat font applied instead of
|
|
/// the shared <paramref name="datFont"/> fallback. Null = original behavior (use
|
|
/// <paramref name="datFont"/> for every element).</param>
|
|
/// <returns>The widget for this element. Never null — every type produces a widget.</returns>
|
|
public static UiElement? Create(ElementInfo info,
|
|
Func<uint, (uint, int, int)> resolve, UiDatFont? datFont,
|
|
Func<uint, UiDatFont?>? fontResolve = null,
|
|
Func<UiStringInfoValue, string?>? 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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Bind inherited scrollbar media structurally. Property 0x77 names the
|
|
/// increment button and 0x78 the decrement button; retail
|
|
/// <c>UIElement_Scrollbar::UpdateScrollingArea @ 0x00470AA0</c> 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.
|
|
/// </summary>
|
|
private static UiScrollbar BuildScrollbar(
|
|
ElementInfo info,
|
|
Func<uint, (uint tex, int w, int h)> 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). <see cref="UiScrollbar.OnDraw"/> 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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Builds a <see cref="UiResizeGrip"/> from a Type-9 <c>UIElement_Resizebar</c>
|
|
/// element: retail <c>StartMouseResizing @0x0046B7E0</c> reads four BOOL
|
|
/// attributes — <c>0x2A</c>=bottom, <c>0x2B</c>=left, <c>0x2C</c>=right,
|
|
/// <c>0x2D</c>=top — and decodes them into a <c>BorderLocation</c>. A grip with
|
|
/// no true bool (the inert <c>_Locked</c> 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 <see cref="UiResizeGrip.Border.None"/>
|
|
/// — <see cref="UiRoot"/> then treats it as contributing no resize edges.
|
|
/// <paramref name="resolve"/> is carried through (CH6a/b REJECT-review
|
|
/// BLOCKER 1) so the grip draws its own authored border/corner media
|
|
/// instead of nothing.
|
|
/// </summary>
|
|
private static UiResizeGrip BuildResizeGrip(
|
|
ElementInfo info, Func<uint, (uint tex, int w, int h)> 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 ────────────────────────────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// Builds a <see cref="UiMeter"/> and populates its sprite ids from the meter's
|
|
/// child/grandchild elements (format doc §11). Two shapes are handled:
|
|
///
|
|
/// <para>
|
|
/// <b>3-slice shape</b> (vitals meters — 2 Type-3 containers, each with 3 image grandchildren):
|
|
/// <code>
|
|
/// 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)
|
|
/// </code>
|
|
/// </para>
|
|
///
|
|
/// <para>
|
|
/// <b>Single-image shape</b> (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 <see cref="UiMeter.DrawHBar"/> tiles
|
|
/// them across the full bar geometry (DrawMode=Normal) and clips the fill to the fraction.
|
|
/// (retail: gmToolbarUI::HandleSelectionChanged :198635, UIElement_Meter::Initialize :123328)
|
|
/// <code>
|
|
/// meter (Type 7) [DirectState "" → back-track sprite, e.g. 0x0600193E]
|
|
/// └── fill container (Type 3) [DirectState "" → fill sprite, e.g. 0x0600193F]
|
|
/// </code>
|
|
/// </para>
|
|
///
|
|
/// <para>
|
|
/// <see cref="UiMeter.Fill"/> and <see cref="UiMeter.Label"/> are NOT set here.
|
|
/// They are bound to the live stat providers by the controller (VitalsController /
|
|
/// SelectedObjectController).
|
|
/// </para>
|
|
/// </summary>
|
|
private static UiMeter BuildMeter(ElementInfo info,
|
|
Func<uint, (uint, int, int)> resolve, UiDatFont? datFont,
|
|
Func<UiStringInfoValue, string?>? 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);
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
///
|
|
/// <para>
|
|
/// Children that carry ONLY named states (e.g. the expand-detail overlay with
|
|
/// "ShowDetail"/"HideDetail" entries but no "" key) are excluded automatically
|
|
/// because <see cref="Dictionary{TKey,TValue}.TryGetValue"/> for "" returns
|
|
/// false.
|
|
/// </para>
|
|
/// </summary>
|
|
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 ─────────────────────────────────────────────────────────────────
|
|
|
|
/// <summary>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 <see cref="UiText.BackgroundColor"/> defaults to transparent.
|
|
///
|
|
/// <para>
|
|
/// Justification from the dat (<see cref="ElementInfo.HJustify"/> /
|
|
/// <see cref="ElementInfo.VJustify"/>) is applied here at build time so that controllers
|
|
/// that subsequently call <see cref="UiText.Centered"/> / <see cref="UiText.RightAligned"/>
|
|
/// on dat-origin elements can be simplified. Controllers that <em>explicitly</em> set those
|
|
/// properties after <see cref="ImportedLayout.FindElement"/> still override the build-time
|
|
/// defaults — the build-time value is just the starting point, not a lock.
|
|
/// </para>
|
|
/// </summary>
|
|
/// <param name="elementFont">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
|
|
/// <see cref="ImportedLayout.FindElement"/> and set <see cref="UiText.DatFont"/> afterward
|
|
/// still override this — the build-time value is just the starting point.</param>
|
|
private static UiElement BuildText(ElementInfo info, Func<uint, (uint, int, int)> resolve,
|
|
UiDatFont? elementFont = null,
|
|
Func<UiStringInfoValue, string?>? 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<string, float> 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<uint, string>? 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<uint, string>())[stateId] = text;
|
|
}
|
|
if (stateStrings is not null)
|
|
t.SetAuthoredStateStrings(stateStrings);
|
|
|
|
return t;
|
|
}
|
|
|
|
private static UiButton BuildButton(
|
|
ElementInfo info,
|
|
Func<uint, (uint, int, int)> resolve,
|
|
UiDatFont? elementFont,
|
|
Func<uint, UiDatFont?>? fontResolve,
|
|
Func<UiStringInfoValue, string?>? 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<ElementInfo>? 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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// R4-1 (Campaign CC gate round 1 re-test 3): the "Available Skill
|
|
/// Credits" value overlapped mid-caption ("Available Skill0Credits")
|
|
/// because <see cref="UiButton.ValueBox"/> was built from the value
|
|
/// child's RAW authored rect, un-reflowed. Live-DAT probe: the value
|
|
/// child (<c>0x100002f3</c>) 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 <c>OriginalParentWidth</c> (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
|
|
/// <see cref="UiLayoutPolicy"/> (retail
|
|
/// <c>UIElement::UpdateForParentSizeChange @0x00462640</c>, already the
|
|
/// production raw-edge reflow for live mounted elements via
|
|
/// <see cref="UiElement.ApplyAnchor"/>) 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,
|
|
/// <c>SkillsCreditsButton_CaptionFitsFullWidth_ValueChildStartsAtMidpoint</c>)
|
|
/// 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.
|
|
/// </summary>
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
private static UiButton BuildCheckbox(
|
|
ElementInfo info,
|
|
Func<uint, (uint, int, int)> resolve,
|
|
UiDatFont? elementFont,
|
|
Func<uint, UiDatFont?>? fontResolve,
|
|
Func<UiStringInfoValue, string?>? 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<UiStringInfoValue, string?>? 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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// R2-2 (Campaign CC gate round 1 Batch E): the shared escape-normalize
|
|
/// <see cref="ResolveAuthoredString"/> applies, pulled out so the
|
|
/// per-STATE authored-caption loop below (which resolves a state's own
|
|
/// <c>0x17</c> directly, bypassing the effective-property resolution
|
|
/// <see cref="ResolveAuthoredString"/> wraps) gets the SAME normalize
|
|
/// instead of a second, easily-forgotten copy.
|
|
/// </summary>
|
|
private static string? NormalizeEscapes(string? raw) =>
|
|
raw?.Replace("\\n", "\n").Replace("\r", string.Empty);
|
|
|
|
/// <summary>
|
|
/// #409 (client-wide retail tooltip system): resolves the already-
|
|
/// extracted <see cref="ElementInfo.TooltipText"/> (dat property
|
|
/// <c>0x49</c>) through <paramref name="stringResolve"/>, applying the
|
|
/// SAME escape normalization every other authored <c>StringInfo</c>
|
|
/// (captions, <c>0x17</c>) gets at this one choke point. Null when the
|
|
/// element authors no tooltip text or no resolver is available.
|
|
/// </summary>
|
|
internal static string? ResolveTooltipText(
|
|
ElementInfo info,
|
|
Func<UiStringInfoValue, string?>? stringResolve)
|
|
{
|
|
if (stringResolve is null || info.TooltipText is not { } tooltipText)
|
|
return null;
|
|
return NormalizeEscapes(stringResolve(tooltipText));
|
|
}
|
|
}
|