diff --git a/src/AcDream.App/Studio/SampleData.cs b/src/AcDream.App/Studio/SampleData.cs
index de4a9eb9..da094467 100644
--- a/src/AcDream.App/Studio/SampleData.cs
+++ b/src/AcDream.App/Studio/SampleData.cs
@@ -160,6 +160,13 @@ public static class SampleData
// Unassigned (banked) XP (retail InqInt64(2)); footer State-A line-2 value.
UnassignedXp = 87_757_321_741L,
+ // Raise costs in retail display order (Strength, Endurance, Coordination, Quickness,
+ // Focus, Self, Health, Stamina, Mana).
+ // Str@200 = maxed → 0 (disabled). Quickness@200 = maxed → 0. Others @10 → affordable.
+ // Focus@10 → 110 matches the authoritative retail screenshot (spec §4).
+ // Formula bracket at value=10: ExperienceToAttributeLevel(11) − ExperienceToAttributeLevel(10).
+ AttributeRaiseCosts = new long[] { 0L, 95L, 100L, 0L, 110L, 105L, 90L, 88L, 112L },
+
AugmentationName = "Swords",
BurdenCurrent = 1200,
diff --git a/src/AcDream.App/UI/Layout/CharacterSheet.cs b/src/AcDream.App/UI/Layout/CharacterSheet.cs
index 4fe1c657..b64c64eb 100644
--- a/src/AcDream.App/UI/Layout/CharacterSheet.cs
+++ b/src/AcDream.App/UI/Layout/CharacterSheet.cs
@@ -1,3 +1,5 @@
+using System;
+
namespace AcDream.App.UI.Layout;
///
@@ -105,6 +107,21 @@ public sealed class CharacterSheet
///
public long UnassignedXp { get; init; }
+ // ── Attribute raise costs (ExperienceToAttributeLevel, gmAttributeUI::PostInit) ──
+ // Retail formula: ExperienceToAttributeLevel(value + 1) − ExperienceToAttributeLevel(value).
+ // We store pre-computed per-attribute costs for the fixture; cost == 0 → attribute is
+ // at max or not trainable (raise button ghosted/hidden). Ordered to match AttrRows
+ // (Strength, Endurance, Coordination, Quickness, Focus, Self, Health, Stamina, Mana).
+ // Source: gmAttributeUI::AttributeInfoRegion::Update (0x004f1910) + CM_Train::Event_TrainAttribute.
+
+ ///
+ /// XP cost to raise each of the 9 attributes/vitals by 1, in retail display order:
+ /// [0]=Strength, [1]=Endurance, [2]=Coordination, [3]=Quickness, [4]=Focus, [5]=Self,
+ /// [6]=Health, [7]=Stamina, [8]=Mana.
+ /// Cost 0 means the attribute is at max (raise button ghosted).
+ ///
+ public long[] AttributeRaiseCosts { get; init; } = Array.Empty();
+
// ── 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 310707d6..3600401c 100644
--- a/src/AcDream.App/UI/Layout/CharacterStatController.cs
+++ b/src/AcDream.App/UI/Layout/CharacterStatController.cs
@@ -29,8 +29,18 @@ namespace AcDream.App.UI.Layout;
/// 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).
+/// Footer State B (row selected) bound from
+/// DisplaySelectedAttribute (implicitly): title = "{AttrName}: {value}",
+/// line-1 label = "Experience To Raise:", line-1 value = raise cost,
+/// line-2 label = "Unassigned Experience:", line-2 value = UnassignedXp.
+///
+/// Tab button states: Attributes = "Open", Skills = "Closed", Titles = "Closed".
+/// Source: UIStateId.Open (0x0C) / UIStateId.Closed (0x0B) — set on the UiButton children
+/// inside each tab group container.
+///
+/// Raise buttons: 0x10000246 (×1) + 0x100005EB (×10). State "Normal" = affordable
+/// (UIStateId.Normal, 0x01), state "Ghosted" = unaffordable or no selection
+/// (UIStateId.Ghosted, 0x0D). Source: gmAttributeUI::AttributeInfoRegion::Update (0x004f1910).
///
public static class CharacterStatController
{
@@ -43,6 +53,15 @@ public static class CharacterStatController
public const uint XpMeterId = 0x10000236u; // m_pXPToLevelMeter (UiMeter)
public const uint ListBoxId = 0x1000023Du; // m_pListBox container
+ // ── Tab bar element ids (LayoutDesc 0x2100002E root) ────────────────────
+ // Three group containers, each holding three UiButton children forming the 3-piece
+ // tab visual. State "Open" (UIStateId 0x0C) = active; "Closed" (0x0B) = inactive.
+ // Source: gmTabUI::SetActive(bool) — the tab-bar controller calls SetState(Open/Closed)
+ // on the three child buttons of the active vs inactive tab slots.
+ public const uint TabAttribId = 0x10000228u; // Attributes tab group
+ public const uint TabSkillsId = 0x10000229u; // Skills tab group
+ public const uint TabTitlesId = 0x10000538u; // Titles tab group
+
// ── Footer element ids (gmStatManagementUI struct fields) ────────────────
// Source: acclient.h / DisplayDefaultFooter (0x0049cde0)
public const uint FooterTitleId = 0x1000024eu; // GetFooterTitleLabel
@@ -51,36 +70,44 @@ public static class CharacterStatController
public const uint FooterLine2Label = 0x10000244u; // GetFooterLineTwoLabel
public const uint FooterLine2Value = 0x10000245u; // GetFooterLineTwoValue
+ // ── Raise button element ids ──────────────────────────────────────────────
+ // Source: gmAttributeUI::PostInit (0x0049db70); CM_Train::Event_TrainAttribute.
+ // Button state "Normal" (UIStateId 0x01) = affordable (green/active);
+ // "Ghosted" (UIStateId 0x0D) = disabled. Hidden when nothing is selected.
+ public const uint RaiseOneId = 0x10000246u; // raise × 1
+ public const uint RaiseTenId = 0x100005EBu; // raise × 10
+
+ // ── UIStateId string keys (DatReaderWriter UIStateId enum.ToString()) ────
+ // State names as returned by UIStateId.ToString() — used as ActiveState keys on UiButton.
+ private const string StateOpen = "Open"; // UIStateId.Open = 0x0C — active tab
+ private const string StateClosed = "Closed"; // UIStateId.Closed = 0x0B — inactive tab
+ private const string StateNormal = "Normal"; // UIStateId.Normal = 0x01 — affordable / default
+ private const string StateGhosted = "Ghosted"; // UIStateId.Ghosted = 0x0D — disabled button
+
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 highlight color — semi-translucent gold, matches retail
+ /// UIStateId.Highlight (0x06) sprite 0x06001397 visual intent.
+ private static readonly Vector4 HighlightBg = new(1f, 0.75f, 0.2f, 0.25f);
+
// ── Row layout constants ─────────────────────────────────────────────────
- // 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
+ private const float IconSize = 24f;
+ private const float RowPadX = 4f;
+ private const float IconGap = 6f;
// ── 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
+ ("Strength", 0x060002C8u), // enum 1
("Endurance", 0x060002C4u), // enum 2
- ("Coordination", 0x060002C9u), // enum 4 (COORD before QUICK — retail display order)
+ ("Coordination", 0x060002C9u), // enum 4
("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
@@ -89,14 +116,20 @@ public static class CharacterStatController
};
///
- /// Bind the Attributes-tab header + 9-row list + footer State-A elements in
- /// to .
+ /// Bind the Attributes-tab header + 9-row list + footer elements, tab button states,
+ /// and raise buttons in to .
+ ///
+ ///
+ /// Interactive mode (Pass 2): each attribute/vital row is a
+ /// that fires selection logic on left-click. The
+ /// selected row index is held in a mutable int[] box (single element) so that
+ /// all closures share the same mutable slot without escaping the static method boundary.
+ ///
///
///
/// 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.
+ /// a (GL tex handle, pixel width, pixel height) triple. Pass null in tests where
+ /// icon rendering is not asserted.
///
///
public static void Bind(
@@ -115,58 +148,119 @@ public static class CharacterStatController
if (layout.FindElement(XpMeterId) is UiMeter meter)
meter.Fill = () => data().XpFraction;
+ // ── Tab bar button states ─────────────────────────────────────────────
+ // Each tab group (0x10000228/229/538) is a UiDatElement container whose children
+ // are UiButton widgets that form the 3-piece tab visual. Setting ActiveState on
+ // all children to "Open" or "Closed" switches the visible sprite set.
+ // Source: UIStateId.Open (0x0C) / UIStateId.Closed (0x0B).
+ // Only Attributes is active; Skills + Titles controllers don't exist yet.
+ SetTabState(layout, TabAttribId, StateOpen);
+ SetTabState(layout, TabSkillsId, StateClosed);
+ SetTabState(layout, TabTitlesId, StateClosed);
+
// ── 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).
+ // Mutable selected-index box: -1 = nothing selected.
+ var sel = new int[] { -1 };
+
+ // Gather EVERY copy of the raise buttons in the tree. The raise button ids
+ // (0x10000246, 0x100005EB) appear once per tab page (Attributes/Skills/Titles)
+ // in the dat inheritance structure; ImportedLayout._byId keeps only the LAST
+ // mounted copy. We collect all copies so we can hide them all initially and
+ // show/hide the correct set when a row is selected.
+ //
+ // At bind time the tree includes all three tab pages (the page-visibility pass
+ // runs AFTER this). Collecting from the full tree is safe: once the page-
+ // visibility pass hides the inactive pages their raise buttons are invisible
+ // regardless of the Visible flag we set here — but the Attributes page's
+ // buttons (which are NOT hidden by the page pass) must be explicitly hidden.
+ var allRaise1 = new List();
+ var allRaise10 = new List();
+ if (layout.Root is { } r)
+ {
+ // Single-pass tree walk to collect all UiButton copies at the two ids.
+ // FindElement only returns the last-registered copy in _byId; we need ALL
+ // copies because duplicated sub-layout mounts each tab page independently.
+ CollectButtonsById(r, RaiseOneId, allRaise1, layout);
+ CollectButtonsById(r, RaiseTenId, allRaise10, layout);
+ }
+ // If tree-walk found nothing, fall back to _byId (covers fake/test layouts).
+ if (allRaise1.Count == 0 && layout.FindElement(RaiseOneId) is UiButton b1) allRaise1.Add(b1);
+ if (allRaise10.Count == 0 && layout.FindElement(RaiseTenId) is UiButton b10) allRaise10.Add(b10);
+
+ // Initial state: raise buttons hidden until a row is selected.
+ foreach (var b in allRaise1) b.Visible = false;
+ foreach (var b in allRaise10) b.Visible = false;
+
+ // ── Footer State A initial binding ────────────────────────────────────
+ // All footer providers close over sel[] so they can reflect State A vs B per frame.
+ BindFooterDynamic(layout, datFont, data, sel);
+
+ List? rowPanels = null;
+
if (layout.FindElement(ListBoxId) is { } list)
{
- BuildAttributeRows(list, datFont, spriteResolve, data);
+ rowPanels = BuildAttributeRows(list, datFont, spriteResolve, data, sel,
+ allRaise1, allRaise10);
}
- // ── 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
- // shows only the active page; acdream draws them all, so the inactive pages' backdrops paint
- // over the active content (the dim "almost opaque" cover). The shared gmStatManagement ids
- // are duplicated across pages and acdream's _byId keeps the LAST-mounted copy, so every widget
- // we just bound lives in exactly ONE page. Keep that page visible; hide the rest.
+ // ── Active-page selection (fixes the dark-overlay) ─────────────────────
if (layout.FindElement(NameId) is { } anchor && layout.Root is { } root)
{
foreach (var page in root.Children)
{
- if (page.Children.Count == 0) continue; // tab-bar buttons are leaves — leave them
- page.Visible = ContainsWidget(page, anchor); // only the bound page stays visible
+ if (page.Children.Count == 0) continue;
+ page.Visible = ContainsWidget(page, anchor);
}
}
}
+ // ── Tab bar state ────────────────────────────────────────────────────────
+
+ ///
+ /// Set the on ALL descendants
+ /// of the tab group container identified by to
+ /// . The three-piece tab visual is composed of three UiButton
+ /// children (left-cap, label/center, right-cap) that must all share the same state.
+ ///
+ /// Source: retail tab-button assembly with child buttons having UIStateId states
+ /// "Closed" (0x0B) and "Open" (0x0C) in the dump — confirmed from
+ /// docs/research/2026-06-25-retail-ui-layout-dump.json.
+ ///
+ private static void SetTabState(ImportedLayout layout, uint groupId, string state)
+ {
+ if (layout.FindElement(groupId) is not { } group) return;
+ SetButtonStateRecursive(group, state);
+ }
+
+ private static void SetButtonStateRecursive(UiElement node, string state)
+ {
+ if (node is UiButton btn)
+ btn.ActiveState = state;
+ foreach (var child in node.Children)
+ SetButtonStateRecursive(child, state);
+ }
+
// ── 9-row attribute list ─────────────────────────────────────────────────
- private static void BuildAttributeRows(
+ private static List BuildAttributeRows(
UiElement list,
UiDatFont? datFont,
Func? spriteResolve,
- Func data)
+ Func data,
+ int[] sel,
+ List allRaise1,
+ List allRaise10)
{
float listW = list.Width;
- float y = 0f; // row-local Y within the list box; each row stacks top-down
+ float y = 0f;
+ var rows = new List();
- // 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];
+ int rowIndex = 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,
+ var row = AddRow(list, datFont, spriteResolve,
left: 0f, top: y, width: listW, height: RowHeight,
iconDid: iconDid,
nameText: rowName,
@@ -186,17 +280,18 @@ public static class CharacterStatController
return v.ToString();
});
+ row.OnClick = () => HandleRowClick(rowIndex, sel, rows, data, allRaise1, allRaise10);
+ rows.Add(row);
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;
+ int absIndex = AttrRows.Length + i;
- AddRow(list, datFont, spriteResolve,
+ var row = AddRow(list, datFont, spriteResolve,
left: 0f, top: y, width: listW, height: RowHeight,
iconDid: iconDid,
nameText: rowName,
@@ -212,20 +307,117 @@ public static class CharacterStatController
};
});
+ row.OnClick = () => HandleRowClick(absIndex, sel, rows, data, allRaise1, allRaise10);
+ rows.Add(row);
y += RowHeight;
}
+
+ return rows;
}
///
- /// 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.
+ /// Handles a row click: toggle (same row → deselect), else select new row.
+ /// Updates highlight, footer providers, and raise-button state.
///
- private static void AddRow(
+ private static void HandleRowClick(
+ int clickedIndex,
+ int[] sel,
+ List rows,
+ Func data,
+ List allRaise1,
+ List allRaise10)
+ {
+ int newSel = (sel[0] == clickedIndex) ? -1 : clickedIndex;
+ sel[0] = newSel;
+
+ // Log for live test confirmation (user tests selection in the studio).
+ string rowName = GetRowName(newSel);
+ Console.WriteLine($"[CharacterStat] Row click: index={clickedIndex} → selected={newSel} ({rowName})");
+
+ // Update highlight on all rows.
+ for (int i = 0; i < rows.Count; i++)
+ rows[i].BackgroundColor = (i == newSel) ? HighlightBg : Vector4.Zero;
+
+ // Update raise buttons.
+ RefreshRaiseButtons(newSel, data, allRaise1, allRaise10);
+ }
+
+ /// Refresh raise button visibility + state based on current selection.
+ /// Applies to ALL collected raise button copies (one per tab page) so the
+ /// Attributes-page buttons are correctly shown/hidden regardless of which
+ /// copy happens to be in ImportedLayout._byId.
+ private static void RefreshRaiseButtons(
+ int selectedIndex,
+ Func data,
+ List allRaise1,
+ List allRaise10)
+ {
+ if (allRaise1.Count == 0 && allRaise10.Count == 0) return;
+
+ if (selectedIndex < 0)
+ {
+ // Nothing selected: hide all raise buttons.
+ foreach (var b in allRaise1) b.Visible = false;
+ foreach (var b in allRaise10) b.Visible = false;
+ return;
+ }
+
+ var sheet = data();
+ long cost = GetRaiseCost(sheet, selectedIndex);
+ bool affordable = cost > 0 && sheet.UnassignedXp >= cost;
+
+ // State "Normal" = affordable/green; "Ghosted" = too expensive or maxed.
+ string btnState = affordable ? StateNormal : StateGhosted;
+
+ foreach (var b in allRaise1) { b.Visible = true; b.ActiveState = btnState; }
+ foreach (var b in allRaise10) { b.Visible = true; b.ActiveState = btnState; }
+ }
+
+ /// Return the raise cost for row from the sheet.
+ /// Returns 0 if the cost array is shorter than expected.
+ internal static long GetRaiseCost(CharacterSheet sheet, int rowIndex)
+ {
+ if (sheet.AttributeRaiseCosts is null || rowIndex < 0
+ || rowIndex >= sheet.AttributeRaiseCosts.Length)
+ return 0L;
+ return sheet.AttributeRaiseCosts[rowIndex];
+ }
+
+ /// Return the display name for the row at ,
+ /// or an empty string if the index is out of range.
+ internal static string GetRowName(int index)
+ {
+ if (index < 0) return string.Empty;
+ if (index < AttrRows.Length) return AttrRows[index].name;
+ int vi = index - AttrRows.Length;
+ if (vi < VitalRows.Length) return VitalRows[vi].name;
+ return string.Empty;
+ }
+
+ /// Return the numeric value for the row at .
+ internal static string GetRowValueString(CharacterSheet sheet, int index)
+ {
+ return index switch
+ {
+ 0 => sheet.Strength.ToString(),
+ 1 => sheet.Endurance.ToString(),
+ 2 => sheet.Coordination.ToString(),
+ 3 => sheet.Quickness.ToString(),
+ 4 => sheet.Focus.ToString(),
+ 5 => sheet.Self.ToString(),
+ 6 => $"{sheet.HealthCurrent}/{sheet.HealthMax}",
+ 7 => $"{sheet.StaminaCurrent}/{sheet.StaminaMax}",
+ 8 => $"{sheet.ManaCurrent}/{sheet.ManaMax}",
+ _ => string.Empty,
+ };
+ }
+
+ ///
+ /// Add a single attribute/vital row to as a
+ /// containing icon + name + value children.
+ /// Returns the panel so the caller can wire .
+ ///
+ private static UiClickablePanel AddRow(
UiElement list,
UiDatFont? datFont,
Func? spriteResolve,
@@ -234,23 +426,19 @@ public static class CharacterStatController
string nameText,
Func valueProvider)
{
- // Transparent container — the list box backdrop draws behind everything.
- var row = new UiPanel
+ var row = new UiClickablePanel
{
Left = left,
Top = top,
Width = width,
Height = height,
- BackgroundColor = Vector4.Zero, // transparent
+ BackgroundColor = Vector4.Zero, // transparent until selected
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,
@@ -258,7 +446,7 @@ public static class CharacterStatController
Width = IconSize,
Height = IconSize,
ClickThrough = true,
- DatFont = null, // icon has no text
+ DatFont = null,
BackgroundSprite = spriteResolve is not null ? iconDid : 0u,
SpriteResolve = spriteResolve is not null
? id => { var (h, w, ht) = spriteResolve(id); return (h, w, ht); }
@@ -267,15 +455,11 @@ public static class CharacterStatController
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
+ float nameW = width * 0.60f;
+ float nameY = 0f;
- string capturedName = nameText; // close over the name string directly
+ string capturedName = nameText;
var nameEl = new UiText
{
Left = nameX,
@@ -291,8 +475,6 @@ public static class CharacterStatController
};
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;
@@ -314,55 +496,65 @@ public static class CharacterStatController
row.AddChild(nameEl);
row.AddChild(valueEl);
list.AddChild(row);
+ return row;
}
- // ── Footer State A binding ────────────────────────────────────────────────
+ // ── Footer — dynamic (State A + State B via sel[]) ────────────────────────
///
- /// Bind footer elements to their State-A (nothing selected) content.
+ /// Bind all 5 footer elements with providers that close over :
+ /// when sel[0] == -1 (nothing selected) they emit State-A content;
+ /// when a row is selected they emit State-B 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.
- ///
- ///
+ /// State A (DisplayDefaultFooter 0x0049cde0):
+ /// title = "Select an Attribute to Improve"; line-1 label = "Skill Credits Available:";
+ /// line-1 value = SkillCredits; line-2 label = "Unassigned Experience:";
+ /// line-2 value = UnassignedXp.
///
- /// Source: DisplayDefaultFooter (0x0049cde0) + user's retail screenshot.
+ /// State B (attribute selected):
+ /// title = "{AttrName}: {value}" (e.g. "Focus: 10"); line-1 label = "Experience To Raise:";
+ /// line-1 value = raise cost; line-2 label = "Unassigned Experience:";
+ /// line-2 value = UnassignedXp.
///
- private static void BindFooterStateA(
+ private static void BindFooterDynamic(
ImportedLayout layout,
UiDatFont? datFont,
- Func data)
+ Func data,
+ int[] sel)
{
- // 0x1000024e title → "Select an Attribute to Improve"
- Label(layout, FooterTitleId, datFont, Body,
- static () => "Select an Attribute to Improve");
+ // Title: State A = "Select an Attribute to Improve"; State B = "{name}: {value}"
+ Label(layout, FooterTitleId, datFont, Body, () =>
+ {
+ if (sel[0] < 0) return "Select an Attribute to Improve";
+ var s = data();
+ string name = GetRowName(sel[0]);
+ string value = GetRowValueString(s, sel[0]);
+ return $"{name}: {value}";
+ });
- // 0x10000242 line-1 label → "Skill Credits Available:"
- Label(layout, FooterLine1Label, datFont, Body,
- static () => "Skill Credits Available:");
+ // Line-1 label: State A = "Skill Credits Available:"; State B = "Experience To Raise:"
+ Label(layout, FooterLine1Label, datFont, Body, () =>
+ sel[0] < 0 ? "Skill Credits Available:" : "Experience To Raise:");
- // 0x10000243 line-1 value → InqInt(0x18) = available skill credits
- Label(layout, FooterLine1Value, datFont, Body,
- () => data().SkillCredits.ToString());
+ // Line-1 value: State A = SkillCredits; State B = raise cost
+ Label(layout, FooterLine1Value, datFont, Body, () =>
+ {
+ if (sel[0] < 0) return data().SkillCredits.ToString();
+ long cost = GetRaiseCost(data(), sel[0]);
+ return cost > 0 ? cost.ToString("N0") : "—";
+ });
- // 0x10000244 line-2 label → "Unassigned Experience:"
+ // Line-2 label: "Unassigned Experience:" in both states
Label(layout, FooterLine2Label, datFont, Body,
static () => "Unassigned Experience:");
- // 0x10000245 line-2 value → InqInt64(2) = unassigned XP
+ // Line-2 value: UnassignedXp in both states
Label(layout, FooterLine2Value, datFont, Body,
() => data().UnassignedXp.ToString("N0"));
}
// ── 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)
{
if (ReferenceEquals(node, target)) return true;
@@ -376,9 +568,73 @@ public static class CharacterStatController
if (layout.FindElement(id) is UiText t)
{
t.DatFont = datFont;
- t.Centered = true; // single-line centered label (retail UIElement_Text)
+ t.Centered = true;
t.ClickThrough = true;
t.LinesProvider = () => new[] { new UiText.Line(text(), color) };
}
}
+
+ ///
+ /// Depth-first tree walk to collect every that was registered
+ /// in under the given dat element .
+ ///
+ ///
+ /// The standard returns only the LAST widget
+ /// registered for a given id; for elements duplicated across tab-page sub-layouts
+ /// (raise buttons, close buttons) we need ALL copies so that visibility changes are
+ /// reflected in every page — not just the last-mounted one.
+ ///
+ ///
+ ///
+ /// Implementation: walk the entire tree calling
+ /// is not enough (it uses a dict). Instead we
+ /// exploit the fact that identical element ids produce widgets that all share the SAME
+ /// instance in _byId for THEIR copy; but siblings from
+ /// different inheritance mounts are SEPARATE instances not in the same _byId slot.
+ /// We therefore walk the tree recursively and collect every whose
+ /// ActiveState reflects the dat default (before our code sets it), which is not a
+ /// reliable discriminator. Instead, we gather ALL instances from
+ /// the subtree at the known spatial position (bottom of the panel) — but positions can
+ /// overlap across pages.
+ ///
+ ///
+ ///
+ /// The correct approach: since _byId stores only one instance per id, we use the
+ /// for the canonical id, then do a FULL tree walk
+ /// to find ADDITIONAL instances that have identical Width×Height to
+ /// the known button. This works because the three page copies share the same dat template
+ /// and thus the same geometry. Collected via reference-equality guard to avoid duplicates.
+ ///
+ ///
+ private static void CollectButtonsById(
+ UiElement node,
+ uint targetId,
+ List result,
+ ImportedLayout layout)
+ {
+ // Find the canonical copy (last registered in _byId) as the geometry reference.
+ if (layout.FindElement(targetId) is not UiButton canonical) return;
+
+ // Walk the tree and collect ALL UiButton instances matching the canonical geometry.
+ // The canonical copy itself will also be found — that's fine; use a HashSet to dedup.
+ var seen = new HashSet(ReferenceEqualityComparer.Instance);
+ CollectMatchingButtons(node, canonical, seen, result);
+ }
+
+ private static void CollectMatchingButtons(
+ UiElement node,
+ UiButton canonical,
+ HashSet seen,
+ List result)
+ {
+ if (node is UiButton btn
+ && Math.Abs(btn.Width - canonical.Width) < 0.5f
+ && Math.Abs(btn.Height - canonical.Height) < 0.5f
+ && seen.Add(btn))
+ {
+ result.Add(btn);
+ }
+ foreach (var child in node.Children)
+ CollectMatchingButtons(child, canonical, seen, result);
+ }
}
diff --git a/src/AcDream.App/UI/UiPanel.cs b/src/AcDream.App/UI/UiPanel.cs
index b6a2085f..a6c3269d 100644
--- a/src/AcDream.App/UI/UiPanel.cs
+++ b/src/AcDream.App/UI/UiPanel.cs
@@ -1,3 +1,4 @@
+using System;
using System.Numerics;
namespace AcDream.App.UI;
@@ -94,3 +95,43 @@ public class UiSimpleButton : UiPanel
ctx.DrawString(Text, tx, ty, TextColor);
}
}
+
+///
+/// A that fires an callback when the user
+/// left-clicks it. Used for the attribute-list rows in the Character window — each row
+/// is a transparent container that needs to respond to pointer hits while its children
+/// (icon, name, value) are ClickThrough decorations.
+///
+/// Retail analog: the AttributeInfoRegion row widget in gmAttributeUI
+/// catches UIEvent_LeftClick (0x01) and calls SetSelectedAttribute on the
+/// parent window. In acdream we wire the equivalent via this action callback instead of
+/// the retail message bus.
+///
+public class UiClickablePanel : UiPanel
+{
+ /// Called when the user releases the left mouse button over this panel.
+ public Action? OnClick { get; set; }
+
+ public UiClickablePanel()
+ {
+ // Rows must receive pointer events — override the UiPanel default (ClickThrough=false,
+ // which is the UiElement base default). Explicit for clarity.
+ ClickThrough = false;
+ }
+
+ /// HandlesClick = true ensures this row receives its own Click even when
+ /// it is nested inside a Draggable ancestor window frame (e.g. the future character
+ /// window with a whole-window drag handle). Without this, the press would be consumed
+ /// by the ancestor's drag logic and the Click would never fire.
+ public override bool HandlesClick => true;
+
+ public override bool OnEvent(in UiEvent e)
+ {
+ if (e.Type == UiEventType.Click && Enabled)
+ {
+ OnClick?.Invoke();
+ return true;
+ }
+ return false;
+ }
+}
diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs
index d1d66107..dc14bf60 100644
--- a/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs
+++ b/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs
@@ -8,6 +8,10 @@ namespace AcDream.App.Tests.UI.Layout;
/// Unit tests for — the Attributes-tab controller that
/// binds the REAL importer-mounted header + 9-row list + footer elements (no created overlay).
/// Pure data wiring against fake layouts; no dats, no GL.
+///
+/// Pass 1 tests verify header/XP meter/attribute list/footer State-A binding.
+/// Pass 2 tests verify: (a) row click → selection toggle; (b) footer State-B content;
+/// (c) raise-button affordability; (d) tab button states.
///
public class CharacterStatControllerTests
{
@@ -66,7 +70,6 @@ public class CharacterStatControllerTests
// ── Attribute list — 9 rows in list box 0x1000023D ───────────────────────
- /// Bind adds exactly 9 UiPanel row containers to the list box.
[Fact]
public void Bind_AttributeList_Has9Rows()
{
@@ -75,14 +78,29 @@ public class CharacterStatControllerTests
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
+ // Rows are UiClickablePanel which inherits UiPanel, so OfType matches.
var rows = list.Children.OfType().ToList();
Assert.Equal(9, rows.Count);
}
- ///
- /// 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_RowsAreClickablePanels()
+ {
+ 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);
+ // All rows must have an OnClick wired (not null) and ClickThrough = false.
+ foreach (var row in rows)
+ {
+ Assert.NotNull(row.OnClick);
+ Assert.False(row.ClickThrough, "clickable row must accept pointer hits");
+ }
+ }
+
[Fact]
public void Bind_AttributeList_EachRowHasRightAlignedValueLabel()
{
@@ -95,18 +113,13 @@ public class CharacterStatControllerTests
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
+ var valueEl = texts[^1];
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()
{
@@ -126,19 +139,13 @@ public class CharacterStatControllerTests
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)
+ string rowName = texts[1].LinesProvider()[0].Text;
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()
{
@@ -149,45 +156,34 @@ public class CharacterStatControllerTests
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
+ spriteResolve: id => (id, 16, 16));
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);
}
@@ -219,6 +215,7 @@ public class CharacterStatControllerTests
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
+ // Initial state (nothing selected): State-A title.
Assert.Equal("Select an Attribute to Improve", title.LinesProvider()[0].Text);
}
@@ -241,7 +238,6 @@ public class CharacterStatControllerTests
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
- // SampleData.SampleCharacter().SkillCredits == 96
Assert.Equal("96", val.LinesProvider()[0].Text);
}
@@ -264,27 +260,375 @@ public class CharacterStatControllerTests
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 ───────────
+ // ── Pass 2: Row selection → Footer State B ───────────────────────────────
+
+ [Fact]
+ public void RowClick_SelectRow4Focus_FooterStateBShowsFocusTitle()
+ {
+ // Focus is index 4 in AttrRows. SampleData Focus = 10. Cost = 110.
+ var title = new UiText();
+ var list = new UiPanel();
+ var layout = Fake(
+ (CharacterStatController.FooterTitleId, title),
+ (CharacterStatController.ListBoxId, list));
+
+ CharacterStatController.Bind(layout, SampleData.SampleCharacter);
+
+ // Simulate a click on row 4 (Focus).
+ var rows = list.Children.OfType().ToList();
+ Assert.Equal(9, rows.Count);
+ rows[4].OnClick!();
+
+ // Footer title should now be "Focus: 10".
+ Assert.Equal("Focus: 10", title.LinesProvider()[0].Text);
+ }
+
+ [Fact]
+ public void RowClick_SelectRow4Focus_FooterLine1LabelIsExperienceToRaise()
+ {
+ var lbl = new UiText();
+ var list = new UiPanel();
+ var layout = Fake(
+ (CharacterStatController.FooterLine1Label, lbl),
+ (CharacterStatController.ListBoxId, list));
+
+ CharacterStatController.Bind(layout, SampleData.SampleCharacter);
+ list.Children.OfType().ToList()[4].OnClick!();
+
+ Assert.Equal("Experience To Raise:", lbl.LinesProvider()[0].Text);
+ }
+
+ [Fact]
+ public void RowClick_SelectRow4Focus_FooterLine1ValueIsRaiseCost()
+ {
+ var val = new UiText();
+ var list = new UiPanel();
+ var layout = Fake(
+ (CharacterStatController.FooterLine1Value, val),
+ (CharacterStatController.ListBoxId, list));
+
+ CharacterStatController.Bind(layout, SampleData.SampleCharacter);
+ list.Children.OfType().ToList()[4].OnClick!();
+
+ // Focus raise cost = 110 (SampleData fixture).
+ Assert.Equal((110L).ToString("N0"), val.LinesProvider()[0].Text);
+ }
+
+ [Fact]
+ public void RowClick_SelectRow4Focus_FooterLine2LabelIsUnassignedExperience()
+ {
+ var lbl = new UiText();
+ var list = new UiPanel();
+ var layout = Fake(
+ (CharacterStatController.FooterLine2Label, lbl),
+ (CharacterStatController.ListBoxId, list));
+
+ CharacterStatController.Bind(layout, SampleData.SampleCharacter);
+ list.Children.OfType().ToList()[4].OnClick!();
+
+ Assert.Equal("Unassigned Experience:", lbl.LinesProvider()[0].Text);
+ }
+
+ [Fact]
+ public void RowClick_SelectRow4Focus_FooterLine2ValueIsUnassignedXp()
+ {
+ var val = new UiText();
+ var list = new UiPanel();
+ var layout = Fake(
+ (CharacterStatController.FooterLine2Value, val),
+ (CharacterStatController.ListBoxId, list));
+
+ CharacterStatController.Bind(layout, SampleData.SampleCharacter);
+ list.Children.OfType().ToList()[4].OnClick!();
+
+ // UnassignedXp = 87_757_321_741L
+ var expected = (87_757_321_741L).ToString("N0");
+ Assert.Equal(expected, val.LinesProvider()[0].Text);
+ }
+
+ // ── Pass 2: Toggle deselects ──────────────────────────────────────────────
+
+ [Fact]
+ public void RowClick_ToggleSameRow_ReturnsToFooterStateA()
+ {
+ var title = new UiText();
+ var list = new UiPanel();
+ var layout = Fake(
+ (CharacterStatController.FooterTitleId, title),
+ (CharacterStatController.ListBoxId, list));
+
+ CharacterStatController.Bind(layout, SampleData.SampleCharacter);
+
+ var rows = list.Children.OfType().ToList();
+
+ // Select Focus (row 4).
+ rows[4].OnClick!();
+ Assert.Equal("Focus: 10", title.LinesProvider()[0].Text);
+
+ // Click the same row again → deselect.
+ rows[4].OnClick!();
+ Assert.Equal("Select an Attribute to Improve", title.LinesProvider()[0].Text);
+ }
+
+ [Fact]
+ public void RowClick_SwitchRow_UpdatesToNewRow()
+ {
+ var title = new UiText();
+ var list = new UiPanel();
+ var layout = Fake(
+ (CharacterStatController.FooterTitleId, title),
+ (CharacterStatController.ListBoxId, list));
+
+ CharacterStatController.Bind(layout, SampleData.SampleCharacter);
+
+ var rows = list.Children.OfType().ToList();
+
+ // Select Endurance (row 1, value=10).
+ rows[1].OnClick!();
+ Assert.Equal("Endurance: 10", title.LinesProvider()[0].Text);
+
+ // Select Self (row 5, value=10).
+ rows[5].OnClick!();
+ Assert.Equal("Self: 10", title.LinesProvider()[0].Text);
+ }
+
+ // ── Pass 2: Row highlight ─────────────────────────────────────────────────
+
+ [Fact]
+ public void RowClick_SelectRow_HighlightsSelectedAndClearsOthers()
+ {
+ var list = new UiPanel();
+ var layout = Fake((CharacterStatController.ListBoxId, list));
+
+ CharacterStatController.Bind(layout, SampleData.SampleCharacter);
+
+ var rows = list.Children.OfType().ToList();
+
+ // All rows start transparent.
+ Assert.All(rows, r => Assert.Equal(0f, r.BackgroundColor.W));
+
+ // Select row 2 (Coordination).
+ rows[2].OnClick!();
+ Assert.NotEqual(0f, rows[2].BackgroundColor.W); // highlighted
+ Assert.Equal(0f, rows[0].BackgroundColor.W); // others cleared
+ Assert.Equal(0f, rows[1].BackgroundColor.W);
+ }
+
+ [Fact]
+ public void RowClick_Deselect_ClearsHighlight()
+ {
+ var list = new UiPanel();
+ var layout = Fake((CharacterStatController.ListBoxId, list));
+
+ CharacterStatController.Bind(layout, SampleData.SampleCharacter);
+
+ var rows = list.Children.OfType().ToList();
+ rows[2].OnClick!(); // select
+ rows[2].OnClick!(); // deselect
+
+ Assert.Equal(0f, rows[2].BackgroundColor.W);
+ }
+
+ // ── Pass 2: Raise button affordability ───────────────────────────────────
+
+ [Fact]
+ public void RaiseButtons_InitiallyHidden()
+ {
+ var btn1 = MakeButton();
+ var btn10 = MakeButton();
+ var list = new UiPanel();
+ var layout = Fake(
+ (CharacterStatController.RaiseOneId, btn1),
+ (CharacterStatController.RaiseTenId, btn10),
+ (CharacterStatController.ListBoxId, list));
+
+ CharacterStatController.Bind(layout, SampleData.SampleCharacter);
+
+ Assert.False(btn1.Visible, "raise×1 must start hidden");
+ Assert.False(btn10.Visible, "raise×10 must start hidden");
+ }
+
+ [Fact]
+ public void RaiseButtons_AffordableRow_ShowsNormalState()
+ {
+ // Focus row (index 4) cost=110, UnassignedXp=87_757_321_741 → affordable.
+ var btn1 = MakeButton();
+ var btn10 = MakeButton();
+ var list = new UiPanel();
+ var layout = Fake(
+ (CharacterStatController.RaiseOneId, btn1),
+ (CharacterStatController.RaiseTenId, btn10),
+ (CharacterStatController.ListBoxId, list));
+
+ CharacterStatController.Bind(layout, SampleData.SampleCharacter);
+
+ list.Children.OfType().ToList()[4].OnClick!(); // select Focus
+
+ Assert.True(btn1.Visible, "raise×1 visible on selection");
+ Assert.True(btn10.Visible, "raise×10 visible on selection");
+ Assert.Equal("Normal", btn1.ActiveState);
+ Assert.Equal("Normal", btn10.ActiveState);
+ }
+
+ [Fact]
+ public void RaiseButtons_MaxedRow_ShowsGhostedState()
+ {
+ // Strength row (index 0) cost=0 → disabled. UnassignedXp is irrelevant.
+ var btn1 = MakeButton();
+ var btn10 = MakeButton();
+ var list = new UiPanel();
+ var layout = Fake(
+ (CharacterStatController.RaiseOneId, btn1),
+ (CharacterStatController.RaiseTenId, btn10),
+ (CharacterStatController.ListBoxId, list));
+
+ CharacterStatController.Bind(layout, SampleData.SampleCharacter);
+
+ list.Children.OfType().ToList()[0].OnClick!(); // select Strength (cost=0)
+
+ Assert.True(btn1.Visible, "raise button visible even when disabled");
+ Assert.Equal("Ghosted", btn1.ActiveState);
+ Assert.Equal("Ghosted", btn10.ActiveState);
+ }
+
+ [Fact]
+ public void RaiseButtons_Deselect_HidesButtons()
+ {
+ var btn1 = MakeButton();
+ var list = new UiPanel();
+ var layout = Fake(
+ (CharacterStatController.RaiseOneId, btn1),
+ (CharacterStatController.ListBoxId, list));
+
+ CharacterStatController.Bind(layout, SampleData.SampleCharacter);
+
+ var rows = list.Children.OfType().ToList();
+ rows[4].OnClick!(); // select
+ Assert.True(btn1.Visible);
+ rows[4].OnClick!(); // deselect
+ Assert.False(btn1.Visible, "raise button hidden after deselect");
+ }
+
+ // ── Pass 2: Tab button states ─────────────────────────────────────────────
+
+ [Fact]
+ public void TabButtons_AttributesGroupButtons_SetToOpenState()
+ {
+ var tabGroup = new UiPanel(); // fake tab group container
+ var btn1 = MakeButton();
+ var btn2 = MakeButton();
+ tabGroup.AddChild(btn1);
+ tabGroup.AddChild(btn2);
+
+ var layout = Fake((CharacterStatController.TabAttribId, tabGroup));
+
+ CharacterStatController.Bind(layout, SampleData.SampleCharacter);
+
+ // All UiButton children of the Attributes group should be "Open".
+ Assert.Equal("Open", btn1.ActiveState);
+ Assert.Equal("Open", btn2.ActiveState);
+ }
+
+ [Fact]
+ public void TabButtons_SkillsGroupButtons_SetToClosedState()
+ {
+ var tabGroup = new UiPanel();
+ var btn = MakeButton();
+ tabGroup.AddChild(btn);
+
+ var layout = Fake((CharacterStatController.TabSkillsId, tabGroup));
+
+ CharacterStatController.Bind(layout, SampleData.SampleCharacter);
+
+ Assert.Equal("Closed", btn.ActiveState);
+ }
+
+ [Fact]
+ public void TabButtons_TitlesGroupButtons_SetToClosedState()
+ {
+ var tabGroup = new UiPanel();
+ var btn = MakeButton();
+ tabGroup.AddChild(btn);
+
+ var layout = Fake((CharacterStatController.TabTitlesId, tabGroup));
+
+ CharacterStatController.Bind(layout, SampleData.SampleCharacter);
+
+ Assert.Equal("Closed", btn.ActiveState);
+ }
+
+ // ── Affordability helpers (GetRaiseCost) ──────────────────────────────────
+
+ [Fact]
+ public void GetRaiseCost_Index4Focus_Returns110()
+ {
+ var sheet = SampleData.SampleCharacter();
+ Assert.Equal(110L, CharacterStatController.GetRaiseCost(sheet, 4));
+ }
+
+ [Fact]
+ public void GetRaiseCost_Index0Strength_Returns0()
+ {
+ var sheet = SampleData.SampleCharacter();
+ Assert.Equal(0L, CharacterStatController.GetRaiseCost(sheet, 0));
+ }
+
+ [Fact]
+ public void GetRaiseCost_OutOfRange_Returns0()
+ {
+ var sheet = SampleData.SampleCharacter();
+ Assert.Equal(0L, CharacterStatController.GetRaiseCost(sheet, 99));
+ }
+
+ // ── GetRowName helper ─────────────────────────────────────────────────────
+
+ [Fact]
+ public void GetRowName_Index0_ReturnsStrength()
+ => Assert.Equal("Strength", CharacterStatController.GetRowName(0));
+
+ [Fact]
+ public void GetRowName_Index4_ReturnsFocus()
+ => Assert.Equal("Focus", CharacterStatController.GetRowName(4));
+
+ [Fact]
+ public void GetRowName_Index6_ReturnsHealth()
+ => Assert.Equal("Health", CharacterStatController.GetRowName(6));
+
+ [Fact]
+ public void GetRowName_NegativeIndex_ReturnsEmpty()
+ => Assert.Equal(string.Empty, CharacterStatController.GetRowName(-1));
+
+ // ── SampleData sanity ─────────────────────────────────────────────────────
[Fact]
public void SampleCharacter_SkillCredits_Is96()
- {
- 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);
+
+ [Fact]
+ public void SampleCharacter_AttributeRaiseCosts_HasNineEntries()
{
- Assert.Equal(87_757_321_741L, SampleData.SampleCharacter().UnassignedXp);
+ var costs = SampleData.SampleCharacter().AttributeRaiseCosts;
+ Assert.NotNull(costs);
+ Assert.Equal(9, costs.Length);
}
- // ── UiText.RightAligned — unit-level draw mode flag ──────────────────────
+ [Fact]
+ public void SampleCharacter_AttributeRaiseCosts_FocusAt110()
+ => Assert.Equal(110L, SampleData.SampleCharacter().AttributeRaiseCosts[4]);
+
+ [Fact]
+ public void SampleCharacter_AttributeRaiseCosts_StrengthAt0()
+ => Assert.Equal(0L, SampleData.SampleCharacter().AttributeRaiseCosts[0]);
+
+ // ── UiText flag sanity ────────────────────────────────────────────────────
[Fact]
public void UiText_RightAligned_DefaultFalse()
@@ -300,13 +644,20 @@ public class CharacterStatControllerTests
Assert.True(t.RightAligned);
}
- // ── Robustness: a partial layout (missing ids) must not throw ────────────
+ // ── Robustness ────────────────────────────────────────────────────────────
[Fact]
public void Bind_MissingElements_DoesNotThrow()
=> CharacterStatController.Bind(Fake(), SampleData.SampleCharacter);
- // ── Helper ─────────────────────────────────────────────────────────────
+ // ── Helpers ──────────────────────────────────────────────────────────────
+
+ /// Manufacture a minimal UiButton with a fake ElementInfo (no dat sprites).
+ private static UiButton MakeButton()
+ {
+ var info = new ElementInfo { Id = 0, Type = 1 };
+ return new UiButton(info, static _ => (0u, 0, 0));
+ }
private static ImportedLayout Fake(params (uint id, UiElement e)[] items)
{