From eccacc59dedab4f4449b19c47fdb34e261c0f468 Mon Sep 17 00:00:00 2001 From: Erik Date: Thu, 25 Jun 2026 21:19:45 +0200 Subject: [PATCH] =?UTF-8?q?fix(D.2b):=20Attributes=20tab=20=E2=80=94=20fil?= =?UTF-8?q?l=20panel=20height,=20center=20row=20icons,=20footer=20at=20bot?= =?UTF-8?q?tom?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/AcDream.App/Studio/FixtureProvider.cs | 1 + src/AcDream.App/Studio/SampleData.cs | 3 + src/AcDream.App/UI/Layout/CharacterSheet.cs | 9 +- .../UI/Layout/CharacterStatController.cs | 57 +++++++----- src/AcDream.App/UI/Layout/LayoutImporter.cs | 93 +++++++++++++++++++ .../UI/Layout/CharacterStatControllerTests.cs | 65 +++++++------ 6 files changed, 178 insertions(+), 50 deletions(-) diff --git a/src/AcDream.App/Studio/FixtureProvider.cs b/src/AcDream.App/Studio/FixtureProvider.cs index bf0e6fbd..d2d537a7 100644 --- a/src/AcDream.App/Studio/FixtureProvider.cs +++ b/src/AcDream.App/Studio/FixtureProvider.cs @@ -152,4 +152,5 @@ public static class FixtureProvider var (handle, _, _) = stack.ResolveChrome(iconId); return handle; }; + } diff --git a/src/AcDream.App/Studio/SampleData.cs b/src/AcDream.App/Studio/SampleData.cs index 0877285f..de4a9eb9 100644 --- a/src/AcDream.App/Studio/SampleData.cs +++ b/src/AcDream.App/Studio/SampleData.cs @@ -157,6 +157,9 @@ public static class SampleData // Available skill credits (retail InqInt(0x18)); shown in Attributes tab footer State-A. SkillCredits = 96, + // Unassigned (banked) XP (retail InqInt64(2)); footer State-A line-2 value. + UnassignedXp = 87_757_321_741L, + AugmentationName = "Swords", BurdenCurrent = 1200, diff --git a/src/AcDream.App/UI/Layout/CharacterSheet.cs b/src/AcDream.App/UI/Layout/CharacterSheet.cs index a562fd47..4fe1c657 100644 --- a/src/AcDream.App/UI/Layout/CharacterSheet.cs +++ b/src/AcDream.App/UI/Layout/CharacterSheet.cs @@ -94,10 +94,17 @@ public sealed class CharacterSheet /// /// Available (unspent) skill credits shown in the Attributes tab footer State-A. /// 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). /// public int SkillCredits { get; init; } + /// + /// Unassigned (banked) experience points. + /// Retail InqInt64(2) — shown in footer line-2 in State-A display. + /// Element 0x10000245 (footer line-2 value). + /// + public long UnassignedXp { get; init; } + // ── Augmentations (UpdateAugmentations 0x004b9000) ───────────────────── // Retail InqInt(0x162) = AugmentationStat; string-switch 1..0xb. diff --git a/src/AcDream.App/UI/Layout/CharacterStatController.cs b/src/AcDream.App/UI/Layout/CharacterStatController.cs index d507af08..310707d6 100644 --- a/src/AcDream.App/UI/Layout/CharacterStatController.cs +++ b/src/AcDream.App/UI/Layout/CharacterStatController.cs @@ -55,11 +55,14 @@ public static class CharacterStatController private static readonly Vector4 Gold = new(1f, 0.82f, 0.36f, 1f); // section / emphasis gold // ── Row layout constants ───────────────────────────────────────────────── - // Row height matches retail's UIElement_ListBox template (~20px; spec §1 "each ~20px tall"). - private const float RowHeight = 20f; - private const float IconSize = 16f; // icon element is ~16×16px (spec §Goal) - private const float RowPadX = 2f; // left inset before the icon - private const float IconGap = 4f; // gap between icon right and name text left + // List box 0x1000023D is 398px tall after the anchor pass (dat stores 160px; the + // Top+Bottom anchor stretches it when the 575px content slot inflates the sub-layout). + // 9 rows spread evenly over 398px ≈ 44px/row — matching retail's generous spacing. + // Icons are 24px, vertically centered in the row. + 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 ───────── // InfoRegion::InfoRegion row sub-element ids: @@ -316,32 +319,44 @@ public static class CharacterStatController // ── Footer State A binding ──────────────────────────────────────────────── /// - /// Bind footer elements to their State-A (nothing selected) content per - /// DisplayDefaultFooter (0x0049cde0). Only the two "value" slots carry - /// text; the label slots are left empty (retail sets them to "" for State A). + /// Bind footer elements to their State-A (nothing selected) content. + /// + /// Matching the user's authoritative shot layout (3-line footer at the panel bottom): + /// + /// Title (0x1000024e) → "Select an Attribute to Improve" — centered, full-width. + /// Line-1 label (0x10000242) → "Skill Credits Available:" + /// Line-1 value (0x10000243) → InqInt(0x18) = available skill credits. + /// Line-2 label (0x10000244) → "Unassigned Experience:" + /// Line-2 value (0x10000245) → InqInt64(2) = unassigned XP. + /// + /// + /// + /// Source: DisplayDefaultFooter (0x0049cde0) + user's retail screenshot. /// private static void BindFooterStateA( ImportedLayout layout, UiDatFont? datFont, Func data) { - // 0x1000024e title → "" - Label(layout, FooterTitleId, datFont, Body, static () => string.Empty); - - // 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, + // 0x1000024e title → "Select an Attribute to Improve" + Label(layout, FooterTitleId, datFont, Body, static () => "Select an Attribute to Improve"); - // 0x10000244 line-2 label → "" - Label(layout, FooterLine2Label, datFont, Body, static () => string.Empty); + // 0x10000242 line-1 label → "Skill Credits Available:" + Label(layout, FooterLine1Label, datFont, Body, + static () => "Skill Credits Available:"); - // 0x10000245 line-2 value → InqInt(0x18) = available skill credits - Label(layout, FooterLine2Value, datFont, Body, + // 0x10000243 line-1 value → InqInt(0x18) = available skill credits + Label(layout, FooterLine1Value, datFont, Body, () => 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 ────────────────────────────────────────────────────────────── diff --git a/src/AcDream.App/UI/Layout/LayoutImporter.cs b/src/AcDream.App/UI/Layout/LayoutImporter.cs index 0a63d5dd..8859f25f 100644 --- a/src/AcDream.App/UI/Layout/LayoutImporter.cs +++ b/src/AcDream.App/UI/Layout/LayoutImporter.cs @@ -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 ──────────────────────────────────────── + + /// + /// Recursively propagates a parent-height change from + /// to through and all its + /// descendants, replicating UIElement::UpdateForParentSizeChange from the + /// retail runtime. + /// + /// Three anchor cases for the vertical axis: + /// + /// Top+Bottom (stretch): maintain top margin AND bottom margin; height grows. + /// Bottom only (pin-to-bottom, fixed height): maintain bottom margin; Y moves, H unchanged. + /// Top only or None: Y and H unchanged (pinned to top with fixed height). + /// + /// Recurse with the element's new height so grandchildren see the correct parent size. + /// + /// This is called once at import time on base-children that are mounted into a + /// slot taller than the sub-layout's design height. + /// + 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); + } } diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs index 58152ca6..d1d66107 100644 --- a/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs @@ -212,62 +212,65 @@ public class CharacterStatControllerTests // ── Footer State A ──────────────────────────────────────────────────────── [Fact] - public void Bind_FooterStateA_TitleEmpty() + public void Bind_FooterStateA_TitleIsSelectPrompt() { var title = new UiText(); var layout = Fake((CharacterStatController.FooterTitleId, title)); 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] - public void Bind_FooterStateA_Line1LabelEmpty() + public void Bind_FooterStateA_Line1LabelIsSkillCreditsAvailable() { var lbl = new UiText(); var layout = Fake((CharacterStatController.FooterLine1Label, lbl)); CharacterStatController.Bind(layout, SampleData.SampleCharacter); - Assert.Equal(string.Empty, lbl.LinesProvider()[0].Text); + Assert.Equal("Skill Credits Available:", lbl.LinesProvider()[0].Text); } [Fact] - public void Bind_FooterStateA_Line1ValueIsSelectPrompt() + public void Bind_FooterStateA_Line1ValueIsSkillCredits() { var val = new UiText(); var layout = Fake((CharacterStatController.FooterLine1Value, val)); 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 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] public void SampleCharacter_SkillCredits_Is96() @@ -275,6 +278,12 @@ public class CharacterStatControllerTests 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 ────────────────────── [Fact]