Swap ChatWindowController's imported main-chat LayoutDesc from the wrong 0x21000006 (an unrelated layout whose root and 800px resize bar appear nowhere in the EoR gameplay UI) to retail's ACTUAL main chat window, 0x2100006F (window root 0x10000600, authored 410x100 — confirmed by a direct DAT dump, found in dats.Local not dats.Portal). Every downstream compensation that existed only to paper over the wrong import is deleted: the hand-cropped 490px content width, the dropped 800px resize bar, the 9px transcript patch, the orphan-sibling pruning, the max/min-vs-scrollbar overlap shift, and the scrollbar top-reclaim. The window now mounts with RetailWindowChrome.Imported (0x2100006F's own 8 border/corner elements are its complete chrome) instead of the universal nine-slice wrapper. LayoutImporter/DatWidgetFactory gain a Type-9 (UIElement_Resizebar) case: UiResizeGrip decodes retail's exact four-bool BorderLocation algorithm (0x2A=bottom/0x2B=left/0x2C=right/0x2D=top, UIElement_Resizebar::StartMouseResizing @0x0046B7E0) into a ResizeEdges bitmask. A direct DAT dump established the true shape: only 7 of the 8 grip-position ids are Type 9 — the straight top-EDGE strip (0x1000069C) is a Type-2 Dragbar (move handle), not a Resizebar, because the main window has no title bar. UiRoot now gives a directly-hit grip's own edges priority over its generic proximity heuristic, and a directly-hit move handle the same priority over ambient proximity — so the plain top strip moves the window while its two corner grips resize it including the Y axis, and all 4 edges + 4 corners work everywhere else. This also fixes the reported "no diagonal cursor at corners" (CursorFeedbackController's existing RetailCursorCatalog cursor ids already matched the DAT exactly; they just never received a genuine diagonal edge combination) and "cannot grow in Y from the bottom-right corner" (the old NineSlice+crop mount's indirection is gone; the Imported mount uses the DAT's real minH=100/maxH=2000/minW=300/maxW=2000 directly). The 8 cosmetic "_Locked" border-art twins default hidden (register row AP-185 — retail's UiLocked-driven art swap between the two skins is not ported; UiRoot.UiLocked continues to gate the underlying interaction correctly either way). The 4 chat-window-1..4 indicator buttons import generically (visible, inert) for CH6b to wire. The two hand-drawn translucent-black tints on the transcript/input are removed now that their parent panels draw their own authored background sprites. Filed #366 (chat window's new-unseen-text indicator 0x1000048C is swallowed by UiText.ConsumesDatChildren, pre-existing and out of scope). Corrected the research doc's "all eight grips" claim against the direct DAT dump. Full Release suite: 12,317 passed / 4 skipped / 0 failed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
749 lines
37 KiB
C#
749 lines
37 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
|
|
6 => new UiMenu(), // UIElement_Menu (reg :120163)
|
|
7 => BuildMeter(info, resolve, elementFont), // UIElement_Meter
|
|
9 => BuildResizeGrip(info), // 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
|
|
0x10000031u => new UiItemList(resolve), // UIElement_ItemList — toolbar/inventory/paperdoll slots
|
|
0x10000035u => BuildCheckbox(
|
|
info, resolve, elementFont, fontResolve, stringResolve), // UIOption_Checkbox
|
|
_ => new UiDatElement(info, resolve), // generic fallback (incl. Type 3 chrome/containers)
|
|
};
|
|
|
|
e.DatElementId = info.Id;
|
|
e.SetStateCursors(info.StateCursors);
|
|
|
|
// Propagate position + size (pixel-exact from the dat).
|
|
e.Left = info.X;
|
|
e.Top = info.Y;
|
|
e.Width = info.Width;
|
|
e.Height = info.Height;
|
|
|
|
// Honor the dat's draw order. ZLevel is the primary layer (higher = further BACK — e.g. the
|
|
// gmInventoryUI full-window backdrop at ZLevel 100 sits behind the ZLevel-0 panels, #145);
|
|
// ReadOrder is the within-layer tiebreaker (higher = on top). K=10000 exceeds any window's
|
|
// element count so ZLevel always dominates. Vitals (all ZLevel 0) keep ZOrder == ReadOrder.
|
|
e.ZOrder = (int)info.ReadOrder - (int)info.ZLevel * 10000;
|
|
|
|
// Map the four raw edge-anchor values to the AnchorEdges bit-flag that the
|
|
// compatibility layout engine uses for programmatic overrides.
|
|
e.Anchors = ElementReader.ToAnchors(info.Left, info.Top, info.Right, info.Bottom);
|
|
|
|
// Imported descendants use the exact four-mode retail policy. Roots have no
|
|
// design parent and intentionally remain on the compatibility path until a
|
|
// window mount assigns its own outer-frame policy.
|
|
if (info.HasOriginalParentSize)
|
|
e.LayoutPolicy = 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]);
|
|
}
|
|
|
|
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.
|
|
/// </summary>
|
|
private static UiResizeGrip BuildResizeGrip(ElementInfo info)
|
|
{
|
|
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
|
|
{
|
|
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)
|
|
{
|
|
var m = new UiMeter
|
|
{
|
|
ElementId = info.Id,
|
|
SpriteResolve = resolve,
|
|
DatFont = datFont,
|
|
};
|
|
|
|
// The two 3-slice containers are Type-3 children of the meter element.
|
|
// ReadOrder determines draw order: the back track has a LOWER ReadOrder
|
|
// (drawn first, behind the fill), the front has a HIGHER ReadOrder (on top).
|
|
var containers = info.Children
|
|
.Where(c => c.Type == 3)
|
|
.OrderBy(c => c.ReadOrder)
|
|
.ToList();
|
|
|
|
if (containers.Count >= 2
|
|
&& 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);
|
|
}
|
|
}
|
|
}
|
|
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,
|
|
};
|
|
if (info.TryGetEffectiveInteger(0x1Eu, out int maxCharacters))
|
|
field.MaxCharacters = maxCharacters;
|
|
if (info.FontColor.HasValue)
|
|
field.TextColor = info.FontColor.Value;
|
|
return field;
|
|
}
|
|
|
|
// Apply horizontal + vertical justification from the dat at build time.
|
|
// Controllers that call FindElement and set Centered/RightAligned/VerticalJustify
|
|
// afterward will override these — this is only the dat-driven default.
|
|
bool centered = info.HJustify == HJustify.Center;
|
|
bool rightAligned = info.HJustify == HJustify.Right;
|
|
var vJustify = info.VJustify switch
|
|
{
|
|
VJustify.Top => VJustify.Top,
|
|
VJustify.Bottom => VJustify.Bottom,
|
|
_ => VJustify.Center,
|
|
};
|
|
|
|
var t = new UiText
|
|
{
|
|
ElementId = info.Id,
|
|
BackgroundSprite = bg,
|
|
SpriteResolve = resolve,
|
|
Centered = centered,
|
|
RightAligned = rightAligned,
|
|
VerticalJustify = vJustify,
|
|
OneLine = oneLine,
|
|
Selectable = selectable,
|
|
// Seed the dat-driven font. When a font resolver was supplied and the element
|
|
// carries a non-zero FontDid, elementFont is the element-specific dat font; otherwise
|
|
// it is the shared global fallback. Controllers that call FindElement and explicitly
|
|
// set DatFont afterward STILL override this (backward-compat guarantee).
|
|
DatFont = elementFont,
|
|
FontColorPalette = ElementReader.ReadEffectiveColorPalette(
|
|
info,
|
|
0x1Bu),
|
|
};
|
|
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;
|
|
|
|
if (ResolveAuthoredString(info, stringResolve) is { Length: > 0 } authored)
|
|
t.LinesProvider = () => [new UiText.Line(authored, t.DefaultColor)];
|
|
|
|
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,
|
|
};
|
|
|
|
if (face is not null)
|
|
{
|
|
button.FaceLeft = face.X;
|
|
button.FaceTop = face.Y;
|
|
button.FaceWidth = face.Width;
|
|
button.FaceHeight = face.Height;
|
|
button.LabelAlign = UiButton.LabelAlignment.Left;
|
|
button.LabelOffsetX = face.X + face.Width + 4f;
|
|
}
|
|
else if (!ReferenceEquals(labelInfo, info) && labelInfo.HJustify == HJustify.Left)
|
|
{
|
|
button.LabelAlign = UiButton.LabelAlignment.Left;
|
|
button.LabelOffsetX = labelInfo.X;
|
|
}
|
|
|
|
return button;
|
|
}
|
|
|
|
/// <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,
|
|
};
|
|
|
|
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;
|
|
return stringResolve(property.StringInfoValue);
|
|
}
|
|
}
|