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:
parent
902160098a
commit
eccacc59de
6 changed files with 178 additions and 50 deletions
|
|
@ -152,4 +152,5 @@ public static class FixtureProvider
|
||||||
var (handle, _, _) = stack.ResolveChrome(iconId);
|
var (handle, _, _) = stack.ResolveChrome(iconId);
|
||||||
return handle;
|
return handle;
|
||||||
};
|
};
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -157,6 +157,9 @@ public static class SampleData
|
||||||
// Available skill credits (retail InqInt(0x18)); shown in Attributes tab footer State-A.
|
// Available skill credits (retail InqInt(0x18)); shown in Attributes tab footer State-A.
|
||||||
SkillCredits = 96,
|
SkillCredits = 96,
|
||||||
|
|
||||||
|
// Unassigned (banked) XP (retail InqInt64(2)); footer State-A line-2 value.
|
||||||
|
UnassignedXp = 87_757_321_741L,
|
||||||
|
|
||||||
AugmentationName = "Swords",
|
AugmentationName = "Swords",
|
||||||
|
|
||||||
BurdenCurrent = 1200,
|
BurdenCurrent = 1200,
|
||||||
|
|
|
||||||
|
|
@ -94,10 +94,17 @@ public sealed class CharacterSheet
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Available (unspent) skill credits shown in the Attributes tab footer State-A.
|
/// Available (unspent) skill credits shown in the Attributes tab footer State-A.
|
||||||
/// Retail InqInt(0x18) — gmStatManagementUI::DisplayDefaultFooter (0x0049cde0).
|
/// Retail InqInt(0x18) — gmStatManagementUI::DisplayDefaultFooter (0x0049cde0).
|
||||||
/// Element 0x10000245 (footer line-2 value).
|
/// Element 0x10000243 (footer line-1 value in the studio's 3-line layout).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public int SkillCredits { get; init; }
|
public int SkillCredits { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Unassigned (banked) experience points.
|
||||||
|
/// Retail InqInt64(2) — shown in footer line-2 in State-A display.
|
||||||
|
/// Element 0x10000245 (footer line-2 value).
|
||||||
|
/// </summary>
|
||||||
|
public long UnassignedXp { get; init; }
|
||||||
|
|
||||||
// ── Augmentations (UpdateAugmentations 0x004b9000) ─────────────────────
|
// ── Augmentations (UpdateAugmentations 0x004b9000) ─────────────────────
|
||||||
// Retail InqInt(0x162) = AugmentationStat; string-switch 1..0xb.
|
// Retail InqInt(0x162) = AugmentationStat; string-switch 1..0xb.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -55,11 +55,14 @@ public static class CharacterStatController
|
||||||
private static readonly Vector4 Gold = new(1f, 0.82f, 0.36f, 1f); // section / emphasis gold
|
private static readonly Vector4 Gold = new(1f, 0.82f, 0.36f, 1f); // section / emphasis gold
|
||||||
|
|
||||||
// ── Row layout constants ─────────────────────────────────────────────────
|
// ── Row layout constants ─────────────────────────────────────────────────
|
||||||
// Row height matches retail's UIElement_ListBox template (~20px; spec §1 "each ~20px tall").
|
// List box 0x1000023D is 398px tall after the anchor pass (dat stores 160px; the
|
||||||
private const float RowHeight = 20f;
|
// Top+Bottom anchor stretches it when the 575px content slot inflates the sub-layout).
|
||||||
private const float IconSize = 16f; // icon element is ~16×16px (spec §Goal)
|
// 9 rows spread evenly over 398px ≈ 44px/row — matching retail's generous spacing.
|
||||||
private const float RowPadX = 2f; // left inset before the icon
|
// Icons are 24px, vertically centered in the row.
|
||||||
private const float IconGap = 4f; // gap between icon right and name text left
|
private const float RowHeight = 44f;
|
||||||
|
private const float IconSize = 24f; // icon ~24×24px, vertically centered in the 44px row
|
||||||
|
private const float RowPadX = 4f; // left inset before the icon
|
||||||
|
private const float IconGap = 6f; // gap between icon right and name text left
|
||||||
|
|
||||||
// ── Attribute row descriptors — retail display order per spec §1 ─────────
|
// ── Attribute row descriptors — retail display order per spec §1 ─────────
|
||||||
// InfoRegion::InfoRegion row sub-element ids:
|
// InfoRegion::InfoRegion row sub-element ids:
|
||||||
|
|
@ -316,32 +319,44 @@ public static class CharacterStatController
|
||||||
// ── Footer State A binding ────────────────────────────────────────────────
|
// ── Footer State A binding ────────────────────────────────────────────────
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Bind footer elements to their State-A (nothing selected) content per
|
/// Bind footer elements to their State-A (nothing selected) content.
|
||||||
/// <c>DisplayDefaultFooter</c> (0x0049cde0). Only the two "value" slots carry
|
///
|
||||||
/// text; the label slots are left empty (retail sets them to "" for State A).
|
/// <para>Matching the user's authoritative shot layout (3-line footer at the panel bottom):
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item>Title (0x1000024e) → "Select an Attribute to Improve" — centered, full-width.</item>
|
||||||
|
/// <item>Line-1 label (0x10000242) → "Skill Credits Available:"</item>
|
||||||
|
/// <item>Line-1 value (0x10000243) → InqInt(0x18) = available skill credits.</item>
|
||||||
|
/// <item>Line-2 label (0x10000244) → "Unassigned Experience:"</item>
|
||||||
|
/// <item>Line-2 value (0x10000245) → InqInt64(2) = unassigned XP.</item>
|
||||||
|
/// </list>
|
||||||
|
/// </para>
|
||||||
|
///
|
||||||
|
/// <para>Source: <c>DisplayDefaultFooter</c> (0x0049cde0) + user's retail screenshot.</para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static void BindFooterStateA(
|
private static void BindFooterStateA(
|
||||||
ImportedLayout layout,
|
ImportedLayout layout,
|
||||||
UiDatFont? datFont,
|
UiDatFont? datFont,
|
||||||
Func<CharacterSheet> data)
|
Func<CharacterSheet> data)
|
||||||
{
|
{
|
||||||
// 0x1000024e title → ""
|
// 0x1000024e title → "Select an Attribute to Improve"
|
||||||
Label(layout, FooterTitleId, datFont, Body, static () => string.Empty);
|
Label(layout, FooterTitleId, datFont, Body,
|
||||||
|
|
||||||
// 0x10000242 line-1 label → ""
|
|
||||||
Label(layout, FooterLine1Label, datFont, Body, static () => string.Empty);
|
|
||||||
|
|
||||||
// 0x10000243 line-1 value → "Select an Attribute to Improve"
|
|
||||||
// StringId "ID_StatManagement_Footer_SelectAttribute"
|
|
||||||
Label(layout, FooterLine1Value, datFont, Body,
|
|
||||||
static () => "Select an Attribute to Improve");
|
static () => "Select an Attribute to Improve");
|
||||||
|
|
||||||
// 0x10000244 line-2 label → ""
|
// 0x10000242 line-1 label → "Skill Credits Available:"
|
||||||
Label(layout, FooterLine2Label, datFont, Body, static () => string.Empty);
|
Label(layout, FooterLine1Label, datFont, Body,
|
||||||
|
static () => "Skill Credits Available:");
|
||||||
|
|
||||||
// 0x10000245 line-2 value → InqInt(0x18) = available skill credits
|
// 0x10000243 line-1 value → InqInt(0x18) = available skill credits
|
||||||
Label(layout, FooterLine2Value, datFont, Body,
|
Label(layout, FooterLine1Value, datFont, Body,
|
||||||
() => data().SkillCredits.ToString());
|
() => data().SkillCredits.ToString());
|
||||||
|
|
||||||
|
// 0x10000244 line-2 label → "Unassigned Experience:"
|
||||||
|
Label(layout, FooterLine2Label, datFont, Body,
|
||||||
|
static () => "Unassigned Experience:");
|
||||||
|
|
||||||
|
// 0x10000245 line-2 value → InqInt64(2) = unassigned XP
|
||||||
|
Label(layout, FooterLine2Value, datFont, Body,
|
||||||
|
() => data().UnassignedXp.ToString("N0"));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Helpers ──────────────────────────────────────────────────────────────
|
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -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
|
// 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.)
|
// FRONT of the backdrop. (B-Controller debug 2026-06-21; continuation of #145.)
|
||||||
result.ZLevel = self.ZLevel;
|
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;
|
return result;
|
||||||
|
|
@ -384,4 +421,60 @@ public static class LayoutImporter
|
||||||
}
|
}
|
||||||
return null;
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -212,62 +212,65 @@ public class CharacterStatControllerTests
|
||||||
// ── Footer State A ────────────────────────────────────────────────────────
|
// ── Footer State A ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Bind_FooterStateA_TitleEmpty()
|
public void Bind_FooterStateA_TitleIsSelectPrompt()
|
||||||
{
|
{
|
||||||
var title = new UiText();
|
var title = new UiText();
|
||||||
var layout = Fake((CharacterStatController.FooterTitleId, title));
|
var layout = Fake((CharacterStatController.FooterTitleId, title));
|
||||||
|
|
||||||
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
|
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
|
||||||
|
|
||||||
Assert.Equal(string.Empty, title.LinesProvider()[0].Text);
|
Assert.Equal("Select an Attribute to Improve", title.LinesProvider()[0].Text);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Bind_FooterStateA_Line1LabelEmpty()
|
public void Bind_FooterStateA_Line1LabelIsSkillCreditsAvailable()
|
||||||
{
|
{
|
||||||
var lbl = new UiText();
|
var lbl = new UiText();
|
||||||
var layout = Fake((CharacterStatController.FooterLine1Label, lbl));
|
var layout = Fake((CharacterStatController.FooterLine1Label, lbl));
|
||||||
|
|
||||||
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
|
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
|
||||||
|
|
||||||
Assert.Equal(string.Empty, lbl.LinesProvider()[0].Text);
|
Assert.Equal("Skill Credits Available:", lbl.LinesProvider()[0].Text);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Bind_FooterStateA_Line1ValueIsSelectPrompt()
|
public void Bind_FooterStateA_Line1ValueIsSkillCredits()
|
||||||
{
|
{
|
||||||
var val = new UiText();
|
var val = new UiText();
|
||||||
var layout = Fake((CharacterStatController.FooterLine1Value, val));
|
var layout = Fake((CharacterStatController.FooterLine1Value, val));
|
||||||
|
|
||||||
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
|
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
|
||||||
|
|
||||||
Assert.Equal("Select an Attribute to Improve", val.LinesProvider()[0].Text);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Bind_FooterStateA_Line2LabelEmpty()
|
|
||||||
{
|
|
||||||
var lbl = new UiText();
|
|
||||||
var layout = Fake((CharacterStatController.FooterLine2Label, lbl));
|
|
||||||
|
|
||||||
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
|
|
||||||
|
|
||||||
Assert.Equal(string.Empty, lbl.LinesProvider()[0].Text);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Bind_FooterStateA_Line2ValueIsSkillCredits()
|
|
||||||
{
|
|
||||||
var val = new UiText();
|
|
||||||
var layout = Fake((CharacterStatController.FooterLine2Value, val));
|
|
||||||
|
|
||||||
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
|
|
||||||
|
|
||||||
// SampleData.SampleCharacter().SkillCredits == 96
|
// SampleData.SampleCharacter().SkillCredits == 96
|
||||||
Assert.Equal("96", val.LinesProvider()[0].Text);
|
Assert.Equal("96", val.LinesProvider()[0].Text);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── SkillCredits propagation through SampleData ───────────────────────────
|
[Fact]
|
||||||
|
public void Bind_FooterStateA_Line2LabelIsUnassignedExperience()
|
||||||
|
{
|
||||||
|
var lbl = new UiText();
|
||||||
|
var layout = Fake((CharacterStatController.FooterLine2Label, lbl));
|
||||||
|
|
||||||
|
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
|
||||||
|
|
||||||
|
Assert.Equal("Unassigned Experience:", lbl.LinesProvider()[0].Text);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Bind_FooterStateA_Line2ValueIsUnassignedXp()
|
||||||
|
{
|
||||||
|
var val = new UiText();
|
||||||
|
var layout = Fake((CharacterStatController.FooterLine2Value, val));
|
||||||
|
|
||||||
|
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
|
||||||
|
|
||||||
|
// SampleData.SampleCharacter().UnassignedXp == 87_757_321_741
|
||||||
|
// Formatted as "N0" with thousands separators.
|
||||||
|
var expected = (87_757_321_741L).ToString("N0");
|
||||||
|
Assert.Equal(expected, val.LinesProvider()[0].Text);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── SkillCredits / UnassignedXp propagation through SampleData ───────────
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void SampleCharacter_SkillCredits_Is96()
|
public void SampleCharacter_SkillCredits_Is96()
|
||||||
|
|
@ -275,6 +278,12 @@ public class CharacterStatControllerTests
|
||||||
Assert.Equal(96, SampleData.SampleCharacter().SkillCredits);
|
Assert.Equal(96, SampleData.SampleCharacter().SkillCredits);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SampleCharacter_UnassignedXp_IsSet()
|
||||||
|
{
|
||||||
|
Assert.Equal(87_757_321_741L, SampleData.SampleCharacter().UnassignedXp);
|
||||||
|
}
|
||||||
|
|
||||||
// ── UiText.RightAligned — unit-level draw mode flag ──────────────────────
|
// ── UiText.RightAligned — unit-level draw mode flag ──────────────────────
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue