fix(studio): Character window polish — name white, XP captions, selected-title white, Infinity! cost

- Name label color changed from gold to white (retail "Horan" is white; 2026-06-26 ref)
- Level caption now provides 2 lines ("Character" / "Level") so neither truncates in the 65px element
- "Total Experience (XP):" caption (TotalXpLabelId 0x10000234) now visible; fixed Padding=0 on
  LabelLeft so the dat-font line is not clipped by the bottom-pin scroll math in small-height elements
- "XP for next level:" caption + value injected as UiText children ON the UiMeter (0x10000236);
  the meter's ConsumesDatChildren=true only gates the importer — AddChild at runtime works fine;
  framework draws children after OnDraw so the text overlays the red bar faithfully
- Selected footer title (State B) renders WHITE per retail spec; State A title stays body/parchment
- Maxed attribute (raise cost = 0) shows "Infinity!" in Experience To Raise line per retail spec
- 5 new tests; 1 existing LevelCaption test updated to expect 2 lines; 681/683 pass (full suite green)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-26 02:16:11 +02:00
parent 31666c0e85
commit 405b0d5077
2 changed files with 190 additions and 13 deletions

View file

@ -174,14 +174,16 @@ public static class CharacterStatController
UiDatFont? datFont = null,
Func<uint, (uint handle, int w, int h)>? spriteResolve = null)
{
Label(layout, NameId, datFont, Gold, () => data().Name);
// Name: retail "Horan" is WHITE, not gold. Heritage and PK status are also white.
Label(layout, NameId, datFont, Vector4.One, () => data().Name);
Label(layout, HeritageId, datFont, Body, () => data().Heritage ?? data().Race ?? string.Empty);
Label(layout, PkStatusId, datFont, Body, () => data().PkStatus ?? string.Empty);
// ── Header captions (new — retail labels above/left of each number) ──────
// 0x1000023A = "Character Level" caption area above the level value (235,0,65×35).
// Source: dump idx=29/30; spec §Header element map.
LabelLeft(layout, LevelCaptionId, datFont, Body, static () => "Character Level");
// Retail shows 2 lines: "Character" (top) / "Level" (bottom) so neither truncates.
// Source: retail spec (2026-06-26-character-window-retail-reference.md §State 1).
LabelTwoLine(layout, LevelCaptionId, datFont, Body, "Character", "Level");
Label(layout, LevelId, datFont, Gold, () => data().Level.ToString());
@ -194,9 +196,58 @@ public static class CharacterStatController
// XP-to-level meter fill (gmStatManagementUI::UpdateExperience 0x004f0a70).
// NOTE: child elements 0x10000237 (label) and 0x10000238 (value) are consumed by
// the UiMeter and cannot be bound — FindElement returns NULL for both.
// We inject a UiText caption + value ABOVE the meter's parent container instead,
// positioned to the left and right of a thin strip above the red bar.
if (layout.FindElement(XpMeterId) is UiMeter meter)
{
meter.Fill = () => data().XpFraction;
// Inject "XP for next level:" label and value ON the meter as children.
// The retail layout puts this caption + value ON TOP of the red bar — the bar
// is behind the text (ref 2026-06-26: "value … with the red fill bar behind it").
// UiMeter.ConsumesDatChildren=true consumed the original 0x10000237/0x10000238
// children at import; we re-inject them as runtime UiText overlays at LOCAL (0,0)
// filling the meter's full rect. The caption is left-aligned; the value is right-aligned.
// Source: retail spec (2026-06-26-character-window-retail-reference.md §State 1).
{
float mW = meter.Width;
float mH = meter.Height;
var xpNextLabel = new UiText
{
Left = 0f,
Top = 0f,
Width = mW * 0.60f,
Height = mH,
DatFont = datFont,
ClickThrough = true,
Centered = false,
RightAligned = false,
Padding = 0f, // avoid scroll clip — meter bar is ~13px tall
Anchors = AnchorEdges.Left | AnchorEdges.Top,
};
xpNextLabel.LinesProvider = static () => new[] { new UiText.Line("XP for next level:", Body) };
var xpNextValue = new UiText
{
Left = 0f,
Top = 0f,
Width = mW,
Height = mH,
DatFont = datFont,
ClickThrough = true,
Centered = false,
RightAligned = true,
Padding = 0f, // avoid scroll clip
Anchors = AnchorEdges.Left | AnchorEdges.Top,
};
xpNextValue.LinesProvider = () => new[] { new UiText.Line(data().XpToNextLevel.ToString("N0"), Body) };
meter.AddChild(xpNextLabel);
meter.AddChild(xpNextValue);
}
}
// ── Tab bar sprite children (added to PANEL ROOT, not to the tab groups) ───
// The tab groups (0x10000228/229/538) are UiText (Type 12) with
// ConsumesDatChildren=true, so their 3 button children were consumed at import.
@ -665,14 +716,25 @@ public static class CharacterStatController
titleEl.Height = 18f;
titleEl.Anchors = AnchorEdges.None;
}
LabelProvider(titleEl, datFont, Body, () =>
// Title color: State A = body (parchment); State B = WHITE per retail (confirmed 2026-06-26 ref).
if (titleEl is not null)
{
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}";
});
titleEl.DatFont = datFont;
titleEl.Centered = false;
titleEl.RightAligned = false;
titleEl.ClickThrough = true;
titleEl.Padding = 0f;
titleEl.LinesProvider = () =>
{
if (sel[0] < 0)
return new[] { new UiText.Line("Select an Attribute to Improve", Body) };
var s = data();
string name = GetRowName(sel[0]);
string value = GetRowValueString(s, sel[0]);
// State B title is WHITE (retail confirmed).
return new[] { new UiText.Line($"{name}: {value}", Vector4.One) };
};
}
// Line-1 label (Top=20, Left=5): State A = "Skill Credits Available:"; State B = "Experience To Raise:"
var l1L = ByPos(20f, 5f, FooterLine1Label);
@ -680,12 +742,13 @@ public static class CharacterStatController
sel[0] < 0 ? "Skill Credits Available:" : "Experience To Raise:");
// Line-1 value (Top=20, Left=200): State A = SkillCredits; State B = raise cost
// When attribute is maxed (cost == 0), retail shows "Infinity!" (confirmed 2026-06-26 ref).
var l1V = ByPos(20f, 200f, FooterLine1Value);
LabelProvider(l1V, datFont, Body, () =>
{
if (sel[0] < 0) return data().SkillCredits.ToString();
long cost = GetRaiseCost(data(), sel[0]);
return cost > 0 ? cost.ToString("N0") : "";
return cost > 0 ? cost.ToString("N0") : "Infinity!";
});
// Line-2 label (Top=37, Left=5): "Unassigned Experience:" in both states
@ -745,7 +808,33 @@ public static class CharacterStatController
}
}
/// <summary>Left-justified label (for captions that should be left-aligned, not centered).</summary>
/// <summary>Two-line centered label. Provides TWO lines from LinesProvider so both
/// fit side-by-side in a narrow element without truncation. The scroll path in
/// <see cref="UiText"/> renders multiple lines oldest-first (top-to-bottom), so
/// line 0 = <paramref name="line1"/> (top) and line 1 = <paramref name="line2"/> (bottom).
/// This replaces the single-line "Character Level" caption which truncated in the 65px element.
/// Source: retail spec (2026-06-26-character-window-retail-reference.md §State 1 level caption).</summary>
private static void LabelTwoLine(ImportedLayout layout, uint id, UiDatFont? datFont, Vector4 color,
string line1, string line2)
{
if (layout.FindElement(id) is UiText t)
{
t.DatFont = datFont;
t.Centered = false; // non-Centered → scroll/multi-line path
t.RightAligned = false;
t.ClickThrough = true;
t.Padding = 1f;
t.LinesProvider = () => new[]
{
new UiText.Line(line1, color),
new UiText.Line(line2, color),
};
}
}
/// <summary>Left-justified label (for captions that should be left-aligned, not centered).
/// Padding=0 so a single dat-font line (≈12px) fits cleanly in a small element without
/// being clipped by the bottom-pin scroll math (top=Padding, bottom=H-Padding).</summary>
private static void LabelLeft(ImportedLayout layout, uint id, UiDatFont? datFont, Vector4 color, Func<string> text)
{
if (layout.FindElement(id) is UiText t)
@ -754,6 +843,7 @@ public static class CharacterStatController
t.Centered = false;
t.RightAligned = false;
t.ClickThrough = true;
t.Padding = 0f; // avoid scroll clip in small-height header elements
t.LinesProvider = () => new[] { new UiText.Line(text(), color) };
}
}