diff --git a/src/AcDream.App/Studio/FixtureProvider.cs b/src/AcDream.App/Studio/FixtureProvider.cs
index e35be76c..bf0e6fbd 100644
--- a/src/AcDream.App/Studio/FixtureProvider.cs
+++ b/src/AcDream.App/Studio/FixtureProvider.cs
@@ -116,12 +116,13 @@ public static class FixtureProvider
case 0x2100002Eu: // gmStatManagementUI — Attributes/Skills/Titles window (LayoutDesc 0x2100002E)
// Bind the REAL importer-mounted header + list elements (name/heritage/PK/level/
- // total-XP/XP-meter + the attribute list). NOT the text-report sub-panel
- // (that is gmCharacterInfoUI 0x2100001A → CharacterController).
+ // total-XP/XP-meter + the 9-row attribute list + footer State-A). NOT the text-report
+ // sub-panel (that is gmCharacterInfoUI 0x2100001A → CharacterController).
CharacterStatController.Bind(
layout,
- data: SampleData.SampleCharacter,
- datFont: stack.VitalsDatFont);
+ data: SampleData.SampleCharacter,
+ datFont: stack.VitalsDatFont,
+ spriteResolve: stack.ResolveChrome);
break;
default:
diff --git a/src/AcDream.App/Studio/SampleData.cs b/src/AcDream.App/Studio/SampleData.cs
index 969af65c..0877285f 100644
--- a/src/AcDream.App/Studio/SampleData.cs
+++ b/src/AcDream.App/Studio/SampleData.cs
@@ -138,20 +138,25 @@ public static class SampleData
XpToNextLevel = 42_000_000,
XpFraction = 0.63f,
- HealthCurrent = 80, HealthMax = 100,
- StaminaCurrent = 60, StaminaMax = 100,
- ManaCurrent = 90, ManaMax = 100,
+ // Vitals: retail screenshot spec (Pass 1 acceptance criteria §Goal).
+ HealthCurrent = 5, HealthMax = 5,
+ StaminaCurrent = 10, StaminaMax = 10,
+ ManaCurrent = 10, ManaMax = 10,
- Strength = 100,
- Endurance = 100,
- Quickness = 100,
- Coordination = 100,
- Focus = 100,
- Self = 100,
+ // Attributes: Strength + Quickness = 200; all others = 10 (retail screenshot spec §Goal).
+ Strength = 200,
+ Endurance = 10,
+ Quickness = 200,
+ Coordination = 10,
+ Focus = 10,
+ Self = 10,
UnspentSkillCredits = 12,
SpecializedSkillCredits = 4,
+ // Available skill credits (retail InqInt(0x18)); shown in Attributes tab footer State-A.
+ SkillCredits = 96,
+
AugmentationName = "Swords",
BurdenCurrent = 1200,
diff --git a/src/AcDream.App/UI/Layout/CharacterSheet.cs b/src/AcDream.App/UI/Layout/CharacterSheet.cs
index aeeec885..a562fd47 100644
--- a/src/AcDream.App/UI/Layout/CharacterSheet.cs
+++ b/src/AcDream.App/UI/Layout/CharacterSheet.cs
@@ -86,10 +86,18 @@ public sealed class CharacterSheet
// ── Skills (UpdateFakeSkills 0x004b8930) ────────────────────────────────
// Retail InqInt(0xb5) = SkillCredits; InqInt(0xc0) = AvailableSkillCredits.
+ // InqInt(0x18) = available skill credits — footer 0x10000245 in the Attributes tab.
public int UnspentSkillCredits { get; init; }
public int SpecializedSkillCredits { get; init; }
+ ///
+ /// Available (unspent) skill credits shown in the Attributes tab footer State-A.
+ /// Retail InqInt(0x18) — gmStatManagementUI::DisplayDefaultFooter (0x0049cde0).
+ /// Element 0x10000245 (footer line-2 value).
+ ///
+ public int SkillCredits { 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 fccf8508..d507af08 100644
--- a/src/AcDream.App/UI/Layout/CharacterStatController.cs
+++ b/src/AcDream.App/UI/Layout/CharacterStatController.cs
@@ -19,12 +19,18 @@ namespace AcDream.App.UI.Layout;
/// Ported from gmStatManagementUI::UpdateCharacterInfo (0x004f0770) +
/// UpdateExperience (0x004f0a70) + UpdatePKStatus (0x004f00a0): name, heritage,
/// PK status, level, total XP and the XP-to-level meter. The attribute list is the
-/// gmAttributeUI list box (0x1000023D).
+/// gmAttributeUI list box (0x1000023D), built as 9 manual-layout rows per
+/// gmAttributeUI::PostInit (0x0049db70) + AttributeInfoRegion / Attribute2ndInfoRegion
+/// (0x004f1910 / 0x004f19e0). Row icons loaded via sub-element 0x10000129 in the retail
+/// dat template (each icon is a 0x06xxxxxx RenderSurface DataID from SubMap
+/// 0x25000006 / 0x25000007, spec §2).
///
-/// Refinement pending: exact label wording comes from the dat StringTable
-/// (table enum 0x10000001 → dat 0x31000001) — not yet ported, so labels use the canonical
-/// AC text. Column alignment + per-row raise buttons (the full AttributeInfoRegion row
-/// template) are a later pass; this binds values into the real element rects first.
+/// Footer State A (nothing selected) bound from
+/// DisplayDefaultFooter (0x0049cde0): title empty, line-1 value =
+/// "Select an Attribute to Improve", line-2 value = available skill credits (InqInt(0x18)).
+///
+/// Pass 2 pending: selection highlight (Button SetState(6)), State-B footer,
+/// raise buttons (CM_Train::Event_TrainAttribute).
///
public static class CharacterStatController
{
@@ -37,16 +43,64 @@ public static class CharacterStatController
public const uint XpMeterId = 0x10000236u; // m_pXPToLevelMeter (UiMeter)
public const uint ListBoxId = 0x1000023Du; // m_pListBox container
+ // ── Footer element ids (gmStatManagementUI struct fields) ────────────────
+ // Source: acclient.h / DisplayDefaultFooter (0x0049cde0)
+ public const uint FooterTitleId = 0x1000024eu; // GetFooterTitleLabel
+ public const uint FooterLine1Label = 0x10000242u; // GetFooterLineOneLabel
+ public const uint FooterLine1Value = 0x10000243u; // GetFooterLineOneValue
+ public const uint FooterLine2Label = 0x10000244u; // GetFooterLineTwoLabel
+ public const uint FooterLine2Value = 0x10000245u; // GetFooterLineTwoValue
+
private static readonly Vector4 Body = new(0.92f, 0.90f, 0.82f, 1f); // parchment-white body text
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
+
+ // ── Attribute row descriptors — retail display order per spec §1 ─────────
+ // InfoRegion::InfoRegion row sub-element ids:
+ // 0x10000129 = icon image
+ // 0x1000012a = m_pLabelText (name)
+ // 0x1000012b = m_pValueText (value)
+ // Icon DataIDs from SubMap 0x25000006 (typeId 0x10000002) — spec §2.
+ private static readonly (string name, uint iconDid)[] AttrRows = new[]
+ {
+ ("Strength", 0x060002C8u), // enum 1 — spec §1/§2
+ ("Endurance", 0x060002C4u), // enum 2
+ ("Coordination", 0x060002C9u), // enum 4 (COORD before QUICK — retail display order)
+ ("Quickness", 0x060002C6u), // enum 3
+ ("Focus", 0x060002C5u), // enum 5
+ ("Self", 0x060002C7u), // enum 6
+ };
+
+ // Vital icon DataIDs from SubMap 0x25000007 (typeId 0x10000003) — spec §2.
+ private static readonly (string name, uint iconDid)[] VitalRows = new[]
+ {
+ ("Health", 0x06004C3Bu), // CurEnum 2
+ ("Stamina", 0x06004C3Cu), // CurEnum 4
+ ("Mana", 0x06004C3Du), // CurEnum 6
+ };
+
///
- /// Bind the Attributes-tab header + list elements in to
- /// . Each element is the real importer-mounted widget; the
- /// providers are polled each frame so a live session or the Studio can swap the sheet
- /// without re-binding.
+ /// Bind the Attributes-tab header + 9-row list + footer State-A elements in
+ /// to .
+ ///
+ ///
+ /// resolves a 0x06xxxxxx RenderSurface dat id to
+ /// a (GL tex handle, pixel width, pixel height) triple — the same resolver that
+ /// DatWidgetFactory uses for chrome sprites. Pass null in tests where
+ /// icon rendering is not asserted; icons are skipped when the resolver is absent.
+ ///
///
- public static void Bind(ImportedLayout layout, Func data, UiDatFont? datFont = null)
+ public static void Bind(
+ ImportedLayout layout,
+ Func data,
+ UiDatFont? datFont = null,
+ Func? spriteResolve = null)
{
Label(layout, NameId, datFont, Gold, () => data().Name);
Label(layout, HeritageId, datFont, Body, () => data().Heritage ?? data().Race ?? string.Empty);
@@ -58,25 +112,20 @@ public static class CharacterStatController
if (layout.FindElement(XpMeterId) is UiMeter meter)
meter.Fill = () => data().XpFraction;
- // Attribute list (gmAttributeUI list box 0x1000023D). The list box is a generic
- // container in the import, so attach a single text view sized to the rows and pinned
- // to the top of the list region. (The full per-row AttributeInfoRegion widget is a
- // later pass; this renders the six innate attributes + values faithfully.)
+ // ── Attribute list — 9 rows in list box 0x1000023D ────────────────────
+ // gmAttributeUI::PostInit (0x0049db70) calls AddItemFromTemplateList nine times —
+ // in acdream we manually add 9 UiPanel row containers (no dat template machinery).
+ // Each row = icon (left, ~16×16) + name (left-justified) + value (right-aligned).
if (layout.FindElement(ListBoxId) is { } list)
{
- var rows = new UiText
- {
- DatFont = datFont,
- ClickThrough = true,
- Left = 12f,
- Top = 10f,
- Width = MathF.Max(60f, list.Width - 24f),
- Height = 9f * LineHeight, // sized to the 8 content lines so it top-aligns
- };
- rows.LinesProvider = () => AttributeRows(data());
- list.AddChild(rows);
+ BuildAttributeRows(list, datFont, spriteResolve, data);
}
+ // ── Footer — State A (nothing selected): DisplayDefaultFooter (0x0049cde0) ────
+ // title = "" ; line-1 value = "Select an Attribute to Improve" ;
+ // line-2 value = InqInt(0x18) = available skill credits.
+ BindFooterStateA(layout, datFont, data);
+
// ── Active-page selection (fixes the "dark overlay") ──────────────────
// 0x2100002E is a tabbed window: all three tab pages (Attributes / Skills / Titles) mount
// their content as separate root children that OVERLAP in the same content rect. Retail
@@ -94,6 +143,209 @@ public static class CharacterStatController
}
}
+ // ── 9-row attribute list ─────────────────────────────────────────────────
+
+ private static void BuildAttributeRows(
+ UiElement list,
+ UiDatFont? datFont,
+ Func? spriteResolve,
+ Func data)
+ {
+ float listW = list.Width;
+ float y = 0f; // row-local Y within the list box; each row stacks top-down
+
+ // Six attribute rows (Strength, Endurance, Coordination, Quickness, Focus, Self).
+ // Value format: "%d" (buffed integer) — AttributeInfoRegion::Update (0x004f1910).
+ for (int i = 0; i < AttrRows.Length; i++)
+ {
+ var (rowName, iconDid) = AttrRows[i];
+
+ // Capture for the lambda — locals from the loop are captured by VALUE here.
+ int rowIndex = i; // not used in value yet but keeps the closure honest
+
+ AddRow(list, datFont, spriteResolve,
+ left: 0f, top: y, width: listW, height: RowHeight,
+ iconDid: iconDid,
+ nameText: rowName,
+ valueProvider: () =>
+ {
+ var s = data();
+ int v = rowIndex switch
+ {
+ 0 => s.Strength,
+ 1 => s.Endurance,
+ 2 => s.Coordination,
+ 3 => s.Quickness,
+ 4 => s.Focus,
+ 5 => s.Self,
+ _ => 0,
+ };
+ return v.ToString();
+ });
+
+ y += RowHeight;
+ }
+
+ // Three vital rows (Health, Stamina, Mana).
+ // Value format: "%d/%d" cur/max — Attribute2ndInfoRegion::Update (0x004f19e0).
+ for (int i = 0; i < VitalRows.Length; i++)
+ {
+ var (rowName, iconDid) = VitalRows[i];
+ int rowIndex = i;
+
+ AddRow(list, datFont, spriteResolve,
+ left: 0f, top: y, width: listW, height: RowHeight,
+ iconDid: iconDid,
+ nameText: rowName,
+ valueProvider: () =>
+ {
+ var s = data();
+ return rowIndex switch
+ {
+ 0 => $"{s.HealthCurrent}/{s.HealthMax}",
+ 1 => $"{s.StaminaCurrent}/{s.StaminaMax}",
+ 2 => $"{s.ManaCurrent}/{s.ManaMax}",
+ _ => string.Empty,
+ };
+ });
+
+ y += RowHeight;
+ }
+ }
+
+ ///
+ /// Add a single row to : a transparent UiPanel container
+ /// positioned at , of the list, holding
+ /// three children: icon (UiText with BackgroundSprite), name (UiText, left-justified),
+ /// value (UiText, right-aligned). All three are ClickThrough non-interactive.
+ ///
+ /// Row sub-element ids from the retail dat template (InfoRegion::InfoRegion
+ /// 0x004f1450): 0x10000129 icon, 0x1000012a name, 0x1000012b value.
+ ///
+ private static void AddRow(
+ UiElement list,
+ UiDatFont? datFont,
+ Func? spriteResolve,
+ float left, float top, float width, float height,
+ uint iconDid,
+ string nameText,
+ Func valueProvider)
+ {
+ // Transparent container — the list box backdrop draws behind everything.
+ var row = new UiPanel
+ {
+ Left = left,
+ Top = top,
+ Width = width,
+ Height = height,
+ BackgroundColor = Vector4.Zero, // transparent
+ BorderColor = Vector4.Zero,
+ ClickThrough = true,
+ Anchors = AnchorEdges.Left | AnchorEdges.Top,
+ };
+
+ float iconY = (height - IconSize) * 0.5f;
+
+ // Icon element — UiText with BackgroundSprite draws the dat sprite under (empty) text.
+ // Retail: InfoRegion::InfoRegion sub-element 0x10000129 → UIRegion::SetImageByDID.
+ var iconEl = new UiText
+ {
+ Left = RowPadX,
+ Top = iconY,
+ Width = IconSize,
+ Height = IconSize,
+ ClickThrough = true,
+ DatFont = null, // icon has no text
+ BackgroundSprite = spriteResolve is not null ? iconDid : 0u,
+ SpriteResolve = spriteResolve is not null
+ ? id => { var (h, w, ht) = spriteResolve(id); return (h, w, ht); }
+ : null,
+ LinesProvider = static () => Array.Empty(),
+ Anchors = AnchorEdges.Left | AnchorEdges.Top,
+ };
+
+ // Name label — left-justified within its column width.
+ // Retail: InfoRegion sub-element 0x1000012a (m_pLabelText).
+ // UiText has no explicit LeftAligned flag; we use the default non-Centered/non-RightAligned
+ // scroll path with a single-line LinesProvider. Padding=1 keeps the text off the left edge.
+ float nameX = RowPadX + IconSize + IconGap;
+ float nameW = width * 0.60f; // ~60% of the row width for the name column
+ float nameY = 0f; // fill full row height; one line top-pins to the row top
+
+ string capturedName = nameText; // close over the name string directly
+ var nameEl = new UiText
+ {
+ Left = nameX,
+ Top = nameY,
+ Width = nameW,
+ Height = height,
+ DatFont = datFont,
+ ClickThrough = true,
+ Centered = false,
+ RightAligned = false,
+ Padding = 1f,
+ Anchors = AnchorEdges.Left | AnchorEdges.Top,
+ };
+ nameEl.LinesProvider = () => new[] { new UiText.Line(capturedName, Body) };
+
+ // Value label — right-aligned, vertically centered.
+ // Retail: InfoRegion sub-element 0x1000012b (m_pValueText).
+ float valueW = width - nameX - nameW - RowPadX;
+ float valueX = nameX + nameW;
+
+ var valueEl = new UiText
+ {
+ Left = valueX,
+ Top = nameY,
+ Width = valueW > 0f ? valueW : 40f,
+ Height = height,
+ DatFont = datFont,
+ ClickThrough = true,
+ RightAligned = true,
+ Anchors = AnchorEdges.Left | AnchorEdges.Top,
+ };
+ var capturedProvider = valueProvider;
+ valueEl.LinesProvider = () => new[] { new UiText.Line(capturedProvider(), Body) };
+
+ row.AddChild(iconEl);
+ row.AddChild(nameEl);
+ row.AddChild(valueEl);
+ list.AddChild(row);
+ }
+
+ // ── 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).
+ ///
+ 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,
+ static () => "Select an Attribute to Improve");
+
+ // 0x10000244 line-2 label → ""
+ Label(layout, FooterLine2Label, datFont, Body, static () => string.Empty);
+
+ // 0x10000245 line-2 value → InqInt(0x18) = available skill credits
+ Label(layout, FooterLine2Value, datFont, Body,
+ () => data().SkillCredits.ToString());
+ }
+
+ // ── Helpers ──────────────────────────────────────────────────────────────
+
/// Depth-first reference search: is somewhere under
/// ? Used to find which overlapping tab page owns the bound widgets.
private static bool ContainsWidget(UiElement node, UiElement target)
@@ -104,8 +356,6 @@ public static class CharacterStatController
return false;
}
- private const float LineHeight = 18f;
-
private static void Label(ImportedLayout layout, uint id, UiDatFont? datFont, Vector4 color, Func text)
{
if (layout.FindElement(id) is UiText t)
@@ -116,24 +366,4 @@ public static class CharacterStatController
t.LinesProvider = () => new[] { new UiText.Line(text(), color) };
}
}
-
- ///
- /// The six innate attributes in canonical AC panel order
- /// (Strength, Endurance, Coordination, Quickness, Focus, Self), preceded by a gold header.
- ///
- private static IReadOnlyList AttributeRows(CharacterSheet s)
- => new List
- {
- new("Attributes", Gold),
- new(string.Empty, Body),
- Row("Strength", s.Strength),
- Row("Endurance", s.Endurance),
- Row("Coordination", s.Coordination),
- Row("Quickness", s.Quickness),
- Row("Focus", s.Focus),
- Row("Self", s.Self),
- };
-
- private static UiText.Line Row(string name, int value)
- => new($"{name,-13}{value,4}", Body);
}
diff --git a/src/AcDream.App/UI/UiText.cs b/src/AcDream.App/UI/UiText.cs
index c89f4ae7..6e7222ab 100644
--- a/src/AcDream.App/UI/UiText.cs
+++ b/src/AcDream.App/UI/UiText.cs
@@ -71,6 +71,13 @@ public sealed class UiText : UiElement
/// after the rewire. Pair with ClickThrough = true for non-interactive labels.
public bool Centered { get; set; }
+ /// Static right-aligned single-line mode: draws the FIRST line right-justified
+ /// within the element rect, vertically centered, with NO scroll/selection machinery.
+ /// Used for value labels in attribute/skill rows where the number must hug the right edge.
+ /// Mutually exclusive with — if both are true, Centered takes
+ /// precedence. Pair with ClickThrough = true for non-interactive labels.
+ public bool RightAligned { get; set; }
+
/// The scroll model — also read by the linked UiScrollbar.
public UiScrollable Scroll { get; } = new();
@@ -153,6 +160,28 @@ public sealed class UiText : UiElement
return;
}
+ // Static right-aligned single-line mode: draw the first line flush with the right
+ // edge, vertically centered, then skip the scroll/selection machinery.
+ if (RightAligned)
+ {
+ var rLines = LinesProvider();
+ if (rLines.Count == 0) return;
+ var line0 = rLines[0];
+ if (DatFont is { } rdf)
+ {
+ float rx = Width - rdf.MeasureWidth(line0.Text) - Padding;
+ float ry = (Height - rdf.LineHeight) * 0.5f;
+ ctx.DrawStringDat(rdf, line0.Text, rx, ry, line0.Color);
+ }
+ else if ((Font ?? ctx.DefaultFont) is { } rbf)
+ {
+ float rx = Width - rbf.MeasureWidth(line0.Text) - Padding;
+ float ry = (Height - rbf.LineHeight) * 0.5f;
+ ctx.DrawString(line0.Text, rx, ry, line0.Color, rbf);
+ }
+ return;
+ }
+
// Prefer the retail dat font when set; fall back to BitmapFont.
var datFont = DatFont;
var bitmapFont = datFont is null ? (Font ?? ctx.DefaultFont) : null;
diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs
index 70faab1a..58152ca6 100644
--- a/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs
+++ b/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs
@@ -6,8 +6,8 @@ namespace AcDream.App.Tests.UI.Layout;
///
/// Unit tests for — the Attributes-tab controller that
-/// binds the REAL importer-mounted header + list elements (no created overlay). Pure data
-/// wiring against fake layouts; no dats, no GL.
+/// binds the REAL importer-mounted header + 9-row list + footer elements (no created overlay).
+/// Pure data wiring against fake layouts; no dats, no GL.
///
public class CharacterStatControllerTests
{
@@ -64,26 +64,231 @@ public class CharacterStatControllerTests
Assert.True(System.MathF.Abs(fill!.Value - 0.63f) < 0.001f, $"expected ~0.63, got {fill}");
}
- // ── Attribute list ───────────────────────────────────────────────────────
+ // ── Attribute list — 9 rows in list box 0x1000023D ───────────────────────
+ /// Bind adds exactly 9 UiPanel row containers to the list box.
[Fact]
- public void Bind_PopulatesAttributeList()
+ public void Bind_AttributeList_Has9Rows()
{
- var list = new UiPanel();
+ var list = new UiPanel();
var layout = Fake((CharacterStatController.ListBoxId, list));
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
- var rows = list.Children.OfType().First();
- var text = string.Join("\n", rows.LinesProvider().Select(l => l.Text));
+ var rows = list.Children.OfType().ToList();
+ Assert.Equal(9, rows.Count);
+ }
- Assert.Contains("Strength", text);
- Assert.Contains("Endurance", text);
- Assert.Contains("Coordination", text);
- Assert.Contains("Quickness", text);
- Assert.Contains("Focus", text);
- Assert.Contains("Self", text);
- Assert.Contains("100", text);
+ ///
+ /// Every row must have a right-aligned value UiText child (the last UiText in
+ /// the row), confirming the RightAligned path was wired.
+ ///
+ [Fact]
+ public void Bind_AttributeList_EachRowHasRightAlignedValueLabel()
+ {
+ var list = new UiPanel();
+ var layout = Fake((CharacterStatController.ListBoxId, list));
+
+ CharacterStatController.Bind(layout, SampleData.SampleCharacter);
+
+ var rows = list.Children.OfType().ToList();
+ Assert.Equal(9, rows.Count);
+ foreach (var row in rows)
+ {
+ // Last UiText in each row is the value (right-aligned).
+ var texts = row.Children.OfType().ToList();
+ Assert.True(texts.Count >= 2, "each row must have at least name + value UiText");
+ var valueEl = texts[^1]; // last = value
+ Assert.True(valueEl.RightAligned, "value label must be RightAligned");
+ }
+ }
+
+ ///
+ /// Retail display order: Strength, Endurance, Coordination, Quickness, Focus, Self,
+ /// Health, Stamina, Mana (spec §1). Name is the SECOND UiText child of each row.
+ ///
+ [Fact]
+ public void Bind_AttributeList_RowNamesInRetailOrder()
+ {
+ var list = new UiPanel();
+ var layout = Fake((CharacterStatController.ListBoxId, list));
+
+ CharacterStatController.Bind(layout, SampleData.SampleCharacter);
+
+ var rows = list.Children.OfType().ToList();
+ Assert.Equal(9, rows.Count);
+
+ string[] expectedNames =
+ {
+ "Strength", "Endurance", "Coordination", "Quickness", "Focus", "Self",
+ "Health", "Stamina", "Mana",
+ };
+
+ for (int i = 0; i < 9; i++)
+ {
+ // Name UiText = second UiText in the row (after the icon UiText).
+ var texts = rows[i].Children.OfType().ToList();
+ Assert.True(texts.Count >= 2, $"row {i} must have name + value");
+ string rowName = texts[1].LinesProvider()[0].Text; // index 1 = name (0 = icon)
+ Assert.Equal(expectedNames[i], rowName);
+ }
+ }
+
+ ///
+ /// Attribute row values are plain integers; vital row values are "cur/max".
+ /// Validates against SampleData fixture: Str=200, Quick=200, rest=10;
+ /// Health=5/5, Stamina=10/10, Mana=10/10.
+ ///
+ [Fact]
+ public void Bind_AttributeList_RowValues_AttributeIntegersAndVitalsCurMax()
+ {
+ var list = new UiPanel();
+ var layout = Fake((CharacterStatController.ListBoxId, list));
+
+ CharacterStatController.Bind(layout, SampleData.SampleCharacter);
+
+ var rows = list.Children.OfType().ToList();
+
+ // Helper: value text is the last UiText in the row.
+ string ValueOf(UiPanel row) => row.Children.OfType().ToList()[^1].LinesProvider()[0].Text;
+
+ // Attribute rows 0-5.
+ Assert.Equal("200", ValueOf(rows[0])); // Strength
+ Assert.Equal("10", ValueOf(rows[1])); // Endurance
+ Assert.Equal("10", ValueOf(rows[2])); // Coordination
+ Assert.Equal("200", ValueOf(rows[3])); // Quickness
+ Assert.Equal("10", ValueOf(rows[4])); // Focus
+ Assert.Equal("10", ValueOf(rows[5])); // Self
+
+ // Vital rows 6-8: "cur/max" format.
+ Assert.Equal("5/5", ValueOf(rows[6])); // Health
+ Assert.Equal("10/10", ValueOf(rows[7])); // Stamina
+ Assert.Equal("10/10", ValueOf(rows[8])); // Mana
+ }
+
+ ///
+ /// When spriteResolve is provided the icon UiText must have BackgroundSprite set
+ /// to the correct DataID; when null the BackgroundSprite must be 0.
+ ///
+ [Fact]
+ public void Bind_AttributeList_IconHasBackgroundSpriteWhenResolverProvided()
+ {
+ var list = new UiPanel();
+ var layout = Fake((CharacterStatController.ListBoxId, list));
+
+ // Non-null resolver — just returns a fake handle for any id.
+ CharacterStatController.Bind(layout, SampleData.SampleCharacter,
+ spriteResolve: id => (id, 16, 16)); // fake: handle == id
+
+ var rows = list.Children.OfType().ToList();
+ Assert.Equal(9, rows.Count);
+
+ // First icon = Strength row; expected DataID 0x060002C8.
+ var iconEl = rows[0].Children.OfType().First();
+ Assert.Equal(0x060002C8u, iconEl.BackgroundSprite);
+
+ // Vital (Health) = row 6; expected DataID 0x06004C3B.
+ var healthIcon = rows[6].Children.OfType().First();
+ Assert.Equal(0x06004C3Bu, healthIcon.BackgroundSprite);
+ }
+
+ [Fact]
+ public void Bind_AttributeList_IconBackgroundSprite_ZeroWhenNoResolver()
+ {
+ var list = new UiPanel();
+ var layout = Fake((CharacterStatController.ListBoxId, list));
+
+ CharacterStatController.Bind(layout, SampleData.SampleCharacter, spriteResolve: null);
+
+ var rows = list.Children.OfType().ToList();
+ Assert.Equal(9, rows.Count);
+ foreach (var row in rows)
+ {
+ var iconEl = row.Children.OfType().First();
+ Assert.Equal(0u, iconEl.BackgroundSprite);
+ }
+ }
+
+ // ── Footer State A ────────────────────────────────────────────────────────
+
+ [Fact]
+ public void Bind_FooterStateA_TitleEmpty()
+ {
+ var title = new UiText();
+ var layout = Fake((CharacterStatController.FooterTitleId, title));
+
+ CharacterStatController.Bind(layout, SampleData.SampleCharacter);
+
+ Assert.Equal(string.Empty, title.LinesProvider()[0].Text);
+ }
+
+ [Fact]
+ public void Bind_FooterStateA_Line1LabelEmpty()
+ {
+ var lbl = new UiText();
+ var layout = Fake((CharacterStatController.FooterLine1Label, lbl));
+
+ CharacterStatController.Bind(layout, SampleData.SampleCharacter);
+
+ Assert.Equal(string.Empty, lbl.LinesProvider()[0].Text);
+ }
+
+ [Fact]
+ public void Bind_FooterStateA_Line1ValueIsSelectPrompt()
+ {
+ 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 SampleCharacter_SkillCredits_Is96()
+ {
+ Assert.Equal(96, SampleData.SampleCharacter().SkillCredits);
+ }
+
+ // ── UiText.RightAligned — unit-level draw mode flag ──────────────────────
+
+ [Fact]
+ public void UiText_RightAligned_DefaultFalse()
+ {
+ var t = new UiText();
+ Assert.False(t.RightAligned);
+ }
+
+ [Fact]
+ public void UiText_RightAligned_CanBeSetTrue()
+ {
+ var t = new UiText { RightAligned = true };
+ Assert.True(t.RightAligned);
}
// ── Robustness: a partial layout (missing ids) must not throw ────────────