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]