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) };
}
}

View file

@ -675,7 +675,10 @@ public class CharacterStatControllerTests
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
Assert.Equal("Character Level", caption.LinesProvider()[0].Text);
// 2-line caption: "Character" (top) / "Level" (bottom) so it fits the 65px element.
var lines = caption.LinesProvider();
Assert.True(lines.Count >= 1, "LevelCaption must provide at least one line");
Assert.Equal("Character", lines[0].Text);
Assert.False(caption.Centered, "LevelCaption must be left-justified (Centered=false)");
Assert.False(caption.RightAligned, "LevelCaption must be left-justified (RightAligned=false)");
}
@ -693,6 +696,90 @@ public class CharacterStatControllerTests
Assert.False(lbl.RightAligned, "TotalXpLabel must be left-justified (RightAligned=false)");
}
// ── Polish Commit 1: name white, Infinity!, white footer title ─────────────
[Fact]
public void Bind_NameColor_IsWhite()
{
// Retail: name "Horan" is WHITE, not gold. (2026-06-26 ref)
var name = new UiText();
var layout = Fake((CharacterStatController.NameId, name));
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
var color = name.LinesProvider()[0].Color;
Assert.Equal(1f, color.X, precision: 3);
Assert.Equal(1f, color.Y, precision: 3);
Assert.Equal(1f, color.Z, precision: 3);
Assert.Equal(1f, color.W, precision: 3);
}
[Fact]
public void RowClick_MaxedRow_FooterLine1ValueIsInfinity()
{
// Strength (index 0) cost=0 → "Infinity!" per retail spec.
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<UiClickablePanel>().ToList()[0].OnClick!(); // Strength
Assert.Equal("Infinity!", val.LinesProvider()[0].Text);
}
[Fact]
public void RowClick_SelectedFooterTitle_IsWhite()
{
// State B footer title must be WHITE (retail 2026-06-26 ref).
var title = new UiText();
var list = new UiPanel();
var layout = Fake(
(CharacterStatController.FooterTitleId, title),
(CharacterStatController.ListBoxId, list));
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
list.Children.OfType<UiClickablePanel>().ToList()[4].OnClick!(); // Focus
var color = title.LinesProvider()[0].Color;
Assert.Equal(1f, color.X, precision: 3);
Assert.Equal(1f, color.Y, precision: 3);
Assert.Equal(1f, color.Z, precision: 3);
Assert.Equal(1f, color.W, precision: 3);
}
[Fact]
public void Bind_FooterStateA_TitleColor_IsBodyNotWhite()
{
// State A title is body (parchment), not white.
var title = new UiText();
var layout = Fake((CharacterStatController.FooterTitleId, title));
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
// Body = (0.92, 0.90, 0.82, 1.0) — check it's not pure white.
var color = title.LinesProvider()[0].Color;
Assert.True(color.X < 1f || color.Y < 1f || color.Z < 1f,
"State-A title should be body/parchment color, not pure white");
}
[Fact]
public void Bind_LevelCaptionId_SetsTwoLines()
{
// Retail: level caption is "Character" / "Level" on two lines (not truncated).
var caption = new UiText();
var layout = Fake((CharacterStatController.LevelCaptionId, caption));
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
var lines = caption.LinesProvider();
Assert.Equal(2, lines.Count);
Assert.Equal("Character", lines[0].Text);
Assert.Equal("Level", lines[1].Text);
}
// ── Robustness ────────────────────────────────────────────────────────────
[Fact]