fix(D.2b): Attributes tab — fill panel height, center row icons, footer at bottom

Root cause: the sub-layout 0x2100002C (design H=337px) mounted into tab slot
0x1000022B (H=575px) via ShouldMountBaseChildren. ElementReader.Merge takes the
derived (sub-layout) H=337 as canonical, so the background element's first
ApplyAnchor call captured _amB=238 and the list box captured _amB=65 — both
stayed at their 337px-parent sizes even though the slot is 575px.

Fix: CascadeHeight in LayoutImporter.Resolve mirrors retail's
UIElement::UpdateForParentSizeChange. When ShouldMountBaseChildren fires and the
slot is taller than the base design, every full-stretch background child has its
height cascaded: Top+Bottom anchors → stretch, Bottom-only → pin-to-bottom,
Top-only/None → unchanged. This propagates the correct bottom margins through the
entire subtree before the first render frame.

Layout result (confirmed via headless screenshot):
- List box grows from 160px → 398px (9 rows × 44px ≈ 396px)
- Footer elements move from abs Y≈307 → abs Y≈545 (matching retail dump)
- Separator moves to abs Y≈535

Row constants updated: RowHeight 44px, IconSize 24px, RowPadX 4px, IconGap 6px.
Footer State-A text corrected per spec: title="Select an Attribute to Improve",
line-1 label="Skill Credits Available:", line-1 value=SkillCredits (96),
line-2 label="Unassigned Experience:", line-2 value=UnassignedXp (formatted N0).
CharacterSheet.UnassignedXp (long, retail InqInt64(2)) added.
SampleData.SampleCharacter().UnassignedXp = 87_757_321_741L.
5 footer tests renamed + assertions updated; SampleCharacter_UnassignedXp_IsSet added.
639 tests green.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-25 21:19:45 +02:00
parent 902160098a
commit eccacc59de
6 changed files with 178 additions and 50 deletions

View file

@ -255,6 +255,43 @@ public static class LayoutImporter
// ZLevel 0 so it escaped). Restore the slot's own frame-layer so the panel sits in
// FRONT of the backdrop. (B-Controller debug 2026-06-21; continuation of #145.)
result.ZLevel = self.ZLevel;
// Sub-layout slot sizing: when a sub-layout is mounted into a slot that is TALLER
// (or wider) than the sub-layout's own design height, the cascade of Bottom/Right
// anchored elements must be resized to fill the slot. Without this step, the
// background element (0x10000226 for the Attributes tab, designed at 337px) captures
// _amB = slotH - designH = 238 and permanently stays at 337px, and every element
// within it with Bottom anchor stays at its design size too (list box at 160px
// instead of the correct 398px).
//
// Retail reference: UIElement::UpdateForParentSizeChange (C++ runtime) propagates a
// new parent height down the whole tree, updating each Bottom-anchored child's
// rect by maintaining its bottom margin. We replicate that cascade here at import
// time on the base-children subtree so the anchor-capture during the first render
// frame sees zero (or unchanged) margins — not the spurious "slot bigger than design"
// margin.
//
// Safe guard: only fire when the slot has explicit size AND the base children are
// smaller (i.e., this is the "slot bigger than design" case).
// Inventory/paperdoll slots are unaffected because their slot H already matches the
// sub-layout design H, so no cascade occurs.
if (result.Width > 0 && result.Height > 0)
{
foreach (var child in baseChildren)
{
var childAnchors = ElementReader.ToAnchors(child.Left, child.Top, child.Right, child.Bottom);
const AnchorEdges StretchAll = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Right | AnchorEdges.Bottom;
if (child.X == 0 && child.Y == 0
&& (childAnchors & StretchAll) == StretchAll // full-stretch background
&& child.Width <= result.Width
&& child.Height > 0 && child.Height < result.Height) // smaller than slot
{
// Cascade UpdateForParentSizeChange down the subtree: maintain each
// Bottom-anchored element's bottom margin while growing the parent height.
CascadeHeight(child, child.Height, result.Height);
}
}
}
}
return result;
@ -384,4 +421,60 @@ public static class LayoutImporter
}
return null;
}
// ── Sub-layout slot sizing helpers ────────────────────────────────────────
/// <summary>
/// Recursively propagates a parent-height change from <paramref name="oldParentH"/>
/// to <paramref name="newParentH"/> through <paramref name="el"/> and all its
/// descendants, replicating <c>UIElement::UpdateForParentSizeChange</c> from the
/// retail runtime.
///
/// <para>Three anchor cases for the vertical axis:
/// <list type="bullet">
/// <item>Top+Bottom (stretch): maintain top margin AND bottom margin; height grows.</item>
/// <item>Bottom only (pin-to-bottom, fixed height): maintain bottom margin; Y moves, H unchanged.</item>
/// <item>Top only or None: Y and H unchanged (pinned to top with fixed height).</item>
/// </list>
/// Recurse with the element's new height so grandchildren see the correct parent size.</para>
///
/// <para>This is called once at import time on base-children that are mounted into a
/// slot taller than the sub-layout's design height.</para>
/// </summary>
private static void CascadeHeight(ElementInfo el, float oldParentH, float newParentH)
{
float oldH = el.Height;
float newH = el.Height;
float oldY = el.Y;
var anchors = ElementReader.ToAnchors(el.Left, el.Top, el.Right, el.Bottom);
bool hasTop = (anchors & AnchorEdges.Top) != 0;
bool hasBottom = (anchors & AnchorEdges.Bottom) != 0;
if (hasTop && hasBottom)
{
// Stretch: maintain both top and bottom margins.
// amT = el.Y (pinned to top, unchanged)
// amB = oldParentH - (el.Y + el.H)
float amB = oldParentH - (el.Y + el.Height);
newH = newParentH - el.Y - amB;
if (newH < 0f) newH = 0f;
el.Height = newH;
}
else if (hasBottom && !hasTop)
{
// Pin to bottom: maintain bottom margin, height fixed, Y moves.
// amB = oldParentH - (el.Y + el.H)
float amB = oldParentH - (el.Y + el.Height);
float newY = newParentH - amB - el.Height;
el.Y = newY;
// Height unchanged; newH = oldH for recursion.
}
// else: Top-only or None — element is top-pinned with fixed height; nothing moves.
// Recurse into children with the element's new height as their parent.
foreach (var child in el.Children)
CascadeHeight(child, oldH, newH);
}
}