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

@ -825,6 +825,78 @@ public class CharacterStatControllerTests
public void Bind_MissingElements_DoesNotThrow()
=> CharacterStatController.Bind(Fake(), SampleData.SampleCharacter);
// ── Fix 5: XP meter text children bound via FindElement ──────────────────
/// <summary>
/// Fix 5: the XP label (0x10000237) is a dat-origin UiText child of the XP meter
/// (now built by the importer). After Bind(), FindElement must return a UiText with
/// a LinesProvider that emits "XP for next level:".
/// </summary>
[Fact]
public void Bind_XpMeter_XpNextLabel_IsBoundViaFindElement()
{
var meter = new UiMeter();
var xpLabel = new UiText();
// Attach the label as a child of the meter so it matches the real importer layout.
meter.AddChild(xpLabel);
var layout = Fake(
(CharacterStatController.XpMeterId, meter),
(CharacterStatController.XpNextLabelId, xpLabel));
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
Assert.NotNull(xpLabel.LinesProvider);
var lines = xpLabel.LinesProvider();
Assert.Single(lines);
Assert.Equal("XP for next level:", lines[0].Text);
}
/// <summary>
/// Fix 5: the XP value (0x10000238) is bound to the XpToNextLevel string.
/// ClickThrough=true and RightAligned=true must be set by the controller.
/// </summary>
[Fact]
public void Bind_XpMeter_XpNextValue_IsBoundViaFindElement()
{
var meter = new UiMeter();
var xpValue = new UiText();
meter.AddChild(xpValue);
var layout = Fake(
(CharacterStatController.XpMeterId, meter),
(CharacterStatController.XpNextValueId, xpValue));
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
Assert.NotNull(xpValue.LinesProvider);
Assert.True(xpValue.ClickThrough, "XP value overlay must be ClickThrough");
Assert.True(xpValue.RightAligned, "XP value overlay must be RightAligned");
var lines = xpValue.LinesProvider();
Assert.Single(lines);
// XpToNextLevel from SampleData = 42_000_000L formatted as "42,000,000"
Assert.Equal((42_000_000L).ToString("N0"), lines[0].Text);
}
/// <summary>
/// Fix 5: if XpNextLabelId / XpNextValueId are absent from the layout (the old
/// ConsumesDatChildren path, or a test layout that doesn't include them), Bind()
/// must not throw — the meter Fill is still bound.
/// </summary>
[Fact]
public void Bind_XpMeter_MissingTextChildren_DoesNotThrow()
{
var meter = new UiMeter();
var layout = Fake((CharacterStatController.XpMeterId, meter));
// No XpNextLabelId or XpNextValueId in the layout.
var ex = Record.Exception(() =>
CharacterStatController.Bind(layout, SampleData.SampleCharacter));
Assert.Null(ex);
// Fill must still be bound.
Assert.NotNull(meter.Fill());
}
// ── Helpers ──────────────────────────────────────────────────────────────
/// <summary>Manufacture a minimal UiButton with a fake ElementInfo (no dat sprites).</summary>

View file

@ -55,15 +55,15 @@ public class LayoutImporterTests
Assert.NotNull(tree.FindElement(0x20000002));
}
// ── Test 3: Meter consumes its children — child ids not in byId ──────────
// ── Test 3: Meter consumes Type-3 slice children — child ids not in byId ────
/// <summary>
/// A meter (Type 7) whose children are the 3-slice back/front containers.
/// The meter itself must be findable; its direct children must NOT appear as
/// separate nodes in the tree (meters own their children, not the generic tree).
/// A meter (Type 7) whose children are ONLY the 3-slice back/front containers (Type 3).
/// The meter itself must be findable; its Type-3 children must NOT appear as separate
/// nodes in the tree (Fix 5: only non-Type-3 children are built as separate widgets).
/// </summary>
[Fact]
public void BuildFromInfos_MeterWithChildren_MeterPresent_ChildrenNotInTree()
public void BuildFromInfos_MeterWithSliceChildren_MeterPresent_SliceChildrenNotInTree()
{
const uint MeterId = 0x100000E6u;
const uint BackLayerId = 0x100000E7u;
@ -85,10 +85,103 @@ public class LayoutImporterTests
// The meter widget is present.
Assert.IsType<UiMeter>(tree.FindElement(MeterId));
// The meter's dat-children are NOT separate UiElement nodes.
// The meter's Type-3 slice children are NOT separate UiElement nodes.
Assert.Null(tree.FindElement(BackLayerId));
Assert.Null(tree.FindElement(FrontLayerId));
// The UiMeter itself has no Ui children (meters consume their children internally).
// The UiMeter itself has no UiElement children (all children were Type-3, consumed).
var uiMeter = (UiMeter)tree.FindElement(MeterId)!;
Assert.Empty(uiMeter.Children);
}
// ── Test 5: Fix 5 — meter text children (Type-12) are built and registered ─
/// <summary>
/// Fix 5: a meter (Type 7) with BOTH Type-3 slice containers AND a Type-12 text
/// child. The Type-3 containers must be consumed (not in byId); the Type-12 text
/// child must be built as a UiText, registered in byId, and attached as a UiElement
/// child of the meter (so it renders as an overlay).
/// </summary>
[Fact]
public void BuildFromInfos_MeterWithTextChild_TextChildInTreeAsUiTextChildOfMeter()
{
const uint MeterId = 0x10000236u;
const uint BackId = 0x10000237u; // Type-3 slice
const uint FrontId = 0x10000238u; // Type-3 slice
const uint LabelId = 0x10000239u; // Type-12 text child
const uint ValueId = 0x1000023Au; // Type-12 text child
var backContainer = BuildSliceContainer(BackId, ReadOrder: 0,
l: 0x0600747Eu, t: 0x0600747Fu, r: 0x06007480u);
var frontContainer = BuildSliceContainer(FrontId, ReadOrder: 1,
l: 0x06007481u, t: 0x06007482u, r: 0x06007483u);
// Two Type-12 text children (the XP "label" and "value" overlay).
var labelInfo = new ElementInfo { Id = LabelId, Type = 12, X = 0, Y = 0, Width = 120, Height = 13 };
var valueInfo = new ElementInfo { Id = ValueId, Type = 12, X = 0, Y = 0, Width = 200, Height = 13,
HJustify = HJustify.Right };
var meter = new ElementInfo { Id = MeterId, Type = 7, Width = 200, Height = 13 };
meter.Children.Add(backContainer);
meter.Children.Add(frontContainer);
meter.Children.Add(labelInfo);
meter.Children.Add(valueInfo);
var root = new ElementInfo { Id = 0x100005F9, Type = 3, Width = 210, Height = 20 };
var tree = LayoutImporter.BuildFromInfos(root, new[] { meter }, NoTex, null);
// Meter is present.
Assert.IsType<UiMeter>(tree.FindElement(MeterId));
// Type-3 slice containers are NOT in byId (consumed by BuildMeter).
Assert.Null(tree.FindElement(BackId));
Assert.Null(tree.FindElement(FrontId));
// Type-12 text children ARE in byId as UiText.
Assert.IsType<UiText>(tree.FindElement(LabelId));
Assert.IsType<UiText>(tree.FindElement(ValueId));
// The UiMeter has exactly 2 UiElement children (the two text overlays).
var uiMeter = (UiMeter)tree.FindElement(MeterId)!;
Assert.Equal(2, uiMeter.Children.Count);
Assert.All(uiMeter.Children, c => Assert.IsType<UiText>(c));
// The value child should have RightAligned=true (HJustify.Right in the dat).
var valueWidget = (UiText)tree.FindElement(ValueId)!;
Assert.True(valueWidget.RightAligned, "Type-12 text child with HJustify.Right must build as RightAligned=true");
}
// ── Test 6: Fix 5 — vitals meters (Type-3 only) are unaffected ────────────
/// <summary>
/// Fix 5 regression guard: vitals health/stamina/mana meters have ONLY Type-3 slice
/// children (no Type-12 text children). The meter must have zero UiElement children
/// after build — VitalsController injects its number overlay at runtime, not the importer.
/// </summary>
[Fact]
public void BuildFromInfos_VitalsMeter_NoTextChildren_MeterHasNoUiChildren()
{
const uint MeterId = 0x100000E6u; // vitals health meter
const uint BackId = 0x100000E7u;
const uint FrontId = 0x100000E8u;
var backContainer = BuildSliceContainer(BackId, ReadOrder: 0,
l: 0x0600747Eu, t: 0x0600747Fu, r: 0x06007480u);
var frontContainer = BuildSliceContainer(FrontId, ReadOrder: 1,
l: 0x06007481u, t: 0x06007482u, r: 0x06007483u);
var meter = new ElementInfo { Id = MeterId, Type = 7, Width = 150, Height = 16 };
meter.Children.Add(backContainer);
meter.Children.Add(frontContainer);
var root = new ElementInfo { Id = 0x100005F9, Type = 3, Width = 160, Height = 58 };
var tree = LayoutImporter.BuildFromInfos(root, new[] { meter }, NoTex, null);
Assert.IsType<UiMeter>(tree.FindElement(MeterId));
// Vitals meter has no UiElement children — only Type-3 children were present,
// all consumed, none built as UiText overlays.
var uiMeter = (UiMeter)tree.FindElement(MeterId)!;
Assert.Empty(uiMeter.Children);
}