feat(ui): importer Fix 5 — build meter text children; drop character XP injection hacks

LayoutImporter.BuildWidget gains a UiMeter-specific branch after the
ConsumesDatChildren gate: Type-3 slice containers (already consumed by
DatWidgetFactory.BuildMeter to extract sprite ids) are skipped, but all
other children (Type-12 UIElement_Text overlays) are built normally,
registered in byId, and attached as UiElement children of the meter.

This fixes the XP meter (0x10000236): its two Type-12 children
  0x10000237 "XP for next level:" label
  0x10000238 XP-to-next-level value
are now real UiText widgets in the tree, findable via FindElement.

CharacterStatController.Bind now uses FindElement + LinesProvider binding
instead of injecting runtime AddChild nodes. The two previously-injected
UiText overlays are removed; the controller binds Padding=0, ClickThrough,
RightAligned on the dat-origin widgets instead.

New constants XpNextLabelId + XpNextValueId replace the old comment block.

TAB GROUPS (SKIPPED — documented): UiText.ConsumesDatChildren flipping is
NOT safe globally (risks chat/vitals) and the page-visibility pass in
CharacterStatController depends on tab group Children.Count==0. Tab sprite
injection onto layout.Root stays and is explicitly commented as deliberate.

VITALS SAFE: health/stamina/mana meters have only Type-3 children → new
loop finds nothing → zero UiElement children added → byte-identical render.
VitalsController.BindMeter AddChild(number) pattern unchanged.

Tests: 3 new LayoutImporterTests (slice-only, Fix-5 text children,
vitals regression guard) + 3 CharacterStatControllerTests (label bound,
value bound, missing children no-throw). 715 tests pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-26 11:35:19 +02:00
parent 1b9dd6c7a8
commit ad4ed51d6b
4 changed files with 241 additions and 58 deletions

View file

@ -53,10 +53,12 @@ public static class CharacterStatController
public const uint TotalXpLabelId = 0x10000234u; // "Total Experience (XP):" caption left of value
public const uint TotalXpId = 0x10000235u; // m_pTotalXPText
public const uint XpMeterId = 0x10000236u; // m_pXPToLevelMeter (UiMeter)
// NOTE: 0x10000237 (XP-to-level label) and 0x10000238 (XP-to-level value) are children of
// the UiMeter element 0x10000236 and are consumed by it (UiMeter.ConsumesDatChildren=true).
// FindElement returns NULL for both. They cannot be bound via the element id.
// The XP meter itself renders the track sprite; the controller binds Fill=XpFraction.
// Fix 5: 0x10000237 (XP-to-level label) and 0x10000238 (XP-to-level value) are now
// built by the LayoutImporter as UiText children of the XP meter (non-Type-3 children
// are explicitly built and registered in byId). FindElement DOES return them now.
// The controller binds their LinesProvider instead of injecting new runtime nodes.
public const uint XpNextLabelId = 0x10000237u; // "XP for next level:" label child of XP meter
public const uint XpNextValueId = 0x10000238u; // XP-to-next-level value child of XP meter
public const uint ListBoxId = 0x1000023Du; // m_pListBox container
// ── Footer STATE-A container id ──────────────────────────────────────────
@ -73,8 +75,18 @@ public static class CharacterStatController
// ConsumesDatChildren=true, so the three button children (left-cap, label, right-cap)
// are consumed at import time and not present in the widget tree.
//
// Fix: the controller adds three sprite-drawing children to each UiText manually, using
// the known RenderSurface ids from the retail UI layout dump (2026-06-25):
// WHY this is NOT fixed in Fix 5 (importer series):
// The Fix 5 meter patch only builds NON-Type-3 children of UiMeter. The tab group children
// are children of UiText (Type 12), not UiMeter (Type 7). Flipping UiText.ConsumesDatChildren
// globally is not safe — the chat transcript UiText and other Type-12 elements also have
// children in some layouts (scroll decorators etc.), and building them as live UiText widgets
// would risk invisible nodes stealing focus/clicks (the exact problem ConsumesDatChildren was
// designed to prevent). Additionally, the page-visibility pass in Bind() depends on
// tab group Children.Count==0 to skip them — adding children to the tab groups would break
// that pass and hide ALL tab content.
//
// Therefore: the controller injects 3 sprite-drawing root children per tab (absolute coords),
// using the known RenderSurface ids from the retail UI layout dump (2026-06-25):
// Closed (inactive) state: left=0x06005D93, center=0x06005D95, right=0x06005D97
// Open (active) state: left=0x06005D92, center=0x06005D94, right=0x06005D96
// Source: state_id 11=Closed, 12=Open per gmTabUI::SetActive(bool); sprite ids confirmed
@ -209,57 +221,37 @@ public static class CharacterStatController
Label(layout, TotalXpId, null, Body, () => data().TotalXp.ToString("N0"));
// XP-to-level meter fill (gmStatManagementUI::UpdateExperience 0x004f0a70).
// NOTE: child elements 0x10000237 (label) and 0x10000238 (value) are consumed by
// the UiMeter and cannot be bound — FindElement returns NULL for both.
// We inject a UiText caption + value ABOVE the meter's parent container instead,
// positioned to the left and right of a thin strip above the red bar.
// Fix 5: child elements 0x10000237 (label) and 0x10000238 (value) are now built by
// the LayoutImporter as UiText children of the XP meter (non-Type-3 meter children
// are explicitly built and registered in byId — see LayoutImporter.BuildWidget).
// FindElement now returns them; the controller binds their LinesProvider.
// The importer builds them as UiText via DatWidgetFactory.BuildText, applying their
// dat-origin HJustify/VJustify/FontDid/FontColor at build time. The controller then
// overrides Padding=0 (to avoid scroll clip in the ~13px-tall bar), ClickThrough=true,
// and provides the LinesProvider for runtime content.
if (layout.FindElement(XpMeterId) is UiMeter meter)
{
meter.Fill = () => data().XpFraction;
// Inject "XP for next level:" label and value ON the meter as children.
// The retail layout puts this caption + value ON TOP of the red bar — the bar
// is behind the text (ref 2026-06-26: "value … with the red fill bar behind it").
// UiMeter.ConsumesDatChildren=true consumed the original 0x10000237/0x10000238
// children at import; we re-inject them as runtime UiText overlays at LOCAL (0,0)
// filling the meter's full rect. The caption is left-aligned; the value is right-aligned.
// Bind the dat-origin XP label (0x10000237) and value (0x10000238).
// These are now real UiText children of the meter (built by the importer).
// The retail layout places the caption + value ON TOP of the red bar
// (ref 2026-06-26: "value … with the red fill bar behind it").
// Source: retail spec (2026-06-26-character-window-retail-reference.md §State 1).
if (layout.FindElement(XpNextLabelId) is UiText xpLabel)
{
float mW = meter.Width;
float mH = meter.Height;
var xpNextLabel = new UiText
{
Left = 0f,
Top = 0f,
Width = mW * 0.60f,
Height = mH,
DatFont = datFont,
ClickThrough = true,
Centered = false,
RightAligned = false,
Padding = 0f, // avoid scroll clip — meter bar is ~13px tall
Anchors = AnchorEdges.Left | AnchorEdges.Top,
};
xpNextLabel.LinesProvider = static () => new[] { new UiText.Line("XP for next level:", Body) };
var xpNextValue = new UiText
{
Left = 0f,
Top = 0f,
Width = mW,
Height = mH,
DatFont = datFont,
ClickThrough = true,
Centered = false,
RightAligned = true,
Padding = 0f, // avoid scroll clip
Anchors = AnchorEdges.Left | AnchorEdges.Top,
};
xpNextValue.LinesProvider = () => new[] { new UiText.Line(data().XpToNextLevel.ToString("N0"), Body) };
meter.AddChild(xpNextLabel);
meter.AddChild(xpNextValue);
if (datFont is not null) xpLabel.DatFont = datFont;
xpLabel.ClickThrough = true;
xpLabel.Padding = 0f; // avoid scroll clip — meter bar is ~13px tall
xpLabel.LinesProvider = static () => new[] { new UiText.Line("XP for next level:", Body) };
}
if (layout.FindElement(XpNextValueId) is UiText xpValue)
{
if (datFont is not null) xpValue.DatFont = datFont;
xpValue.ClickThrough = true;
xpValue.RightAligned = true;
xpValue.Padding = 0f; // avoid scroll clip
xpValue.LinesProvider = () => new[] { new UiText.Line(data().XpToNextLevel.ToString("N0"), Body) };
}
}