feat(D.2b): Character window — value captions + tab bar sprites + footer legibility

Gap 1 — Header captions:
- 0x1000023A ("Character Level") above the level value: bound via LabelLeft().
- 0x10000234 ("Total Experience (XP):") left of total XP: bound via LabelLeft().
- 0x10000237/0x10000238 (XP-to-level label + value) are children of the UiMeter
  (0x10000236, ConsumesDatChildren=true) and cannot be bound — documented in
  code comments; XP meter fill still bound via Fill=XpFraction.

Gap 2 — Tab bar sprites:
- Tab group elements 0x10000228/229/538 are Type-12 UIElement_Text in the dat
  (ConsumesDatChildren=true), so the three button children (left-cap, center,
  right-cap) are consumed at import and absent from the widget tree. Old
  SetTabState/SetButtonStateRecursive found no UiButton children to set.
- Fix: AddTabSprites() injects three UiText sprite-children per group using
  the known RenderSurface ids confirmed from the retail UI layout dump:
  Open (active)   0x06005D92/0x06005D94/0x06005D96
  Closed (inactive) 0x06005D93/0x06005D95/0x06005D97
  Source: dump nodes 0x10000439/0x100000E9/0x10000215 in layout 0x2100002E,
  state_id 11=Closed, 12=Open per gmTabUI::SetActive(bool).

Gap 3 — Footer legibility:
- The shared footer child ids (0x1000024E etc.) appear in THREE footer-state
  groups (A/B/C). ImportedLayout._byId stores the LAST duplicate = narrower
  State B/C copies (145px labels). Fix: hide State B/C groups (footerB/footerC
  Visible=false), walk State A container (0x10000240) positionally to bind the
  wider State A labels (195px). FooterLine1Label now reads "Skill Credits
  Available:" and FooterLine2Label reads "Unassigned Experience:" at full width.

Tests: 3 old tab-state tests (SetButtonStateRecursive expectation) replaced by
4 new sprite-injection tests + 2 caption-binding tests. Full suite: 676 pass,
0 fail (was 673 pass after 3 failures).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-25 22:17:40 +02:00
parent defbde1f86
commit 99291595bb
3 changed files with 297 additions and 85 deletions

View file

@ -514,51 +514,71 @@ public class CharacterStatControllerTests
}
// ── Pass 2: Tab button states ─────────────────────────────────────────────
// Tab groups are UiText (Type 12) with ConsumesDatChildren=true; their button
// children are consumed at import time. AddTabSprites() injects 3 UiText
// sprite-children per group when a spriteResolve is provided.
[Fact]
public void TabButtons_AttributesGroupButtons_SetToOpenState()
public void TabButtons_NoSpriteResolve_AddsNoSpriteChildren()
{
var tabGroup = new UiPanel(); // fake tab group container
var btn1 = MakeButton();
var btn2 = MakeButton();
tabGroup.AddChild(btn1);
tabGroup.AddChild(btn2);
// When spriteResolve is null, AddTabSprites returns early — no children added.
var tabGroup = new UiText();
var layout = Fake((CharacterStatController.TabAttribId, tabGroup));
var layout = Fake((CharacterStatController.TabAttribId, tabGroup));
CharacterStatController.Bind(layout, SampleData.SampleCharacter, spriteResolve: null);
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);
Assert.Empty(tabGroup.Children);
}
[Fact]
public void TabButtons_SkillsGroupButtons_SetToClosedState()
public void TabButtons_AttributesGroup_AddsThreeOpenSpriteChildren()
{
var tabGroup = new UiPanel();
var btn = MakeButton();
tabGroup.AddChild(btn);
// Attributes tab (isOpen=true): three UiText sprite children with Open sprite ids.
var tabGroup = new UiText();
var layout = Fake((CharacterStatController.TabAttribId, tabGroup));
var layout = Fake((CharacterStatController.TabSkillsId, tabGroup));
CharacterStatController.Bind(layout, SampleData.SampleCharacter,
spriteResolve: id => (id, 16, 16));
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
Assert.Equal("Closed", btn.ActiveState);
var sprites = tabGroup.Children.OfType<UiText>().ToList();
Assert.Equal(3, sprites.Count);
Assert.Equal(0x06005D92u, sprites[0].BackgroundSprite); // Open left-cap
Assert.Equal(0x06005D94u, sprites[1].BackgroundSprite); // Open center
Assert.Equal(0x06005D96u, sprites[2].BackgroundSprite); // Open right-cap
}
[Fact]
public void TabButtons_TitlesGroupButtons_SetToClosedState()
public void TabButtons_SkillsGroup_AddsThreeClosedSpriteChildren()
{
var tabGroup = new UiPanel();
var btn = MakeButton();
tabGroup.AddChild(btn);
// Skills tab (isOpen=false): three UiText sprite children with Closed sprite ids.
var tabGroup = new UiText();
var layout = Fake((CharacterStatController.TabSkillsId, tabGroup));
var layout = Fake((CharacterStatController.TabTitlesId, tabGroup));
CharacterStatController.Bind(layout, SampleData.SampleCharacter,
spriteResolve: id => (id, 16, 16));
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
var sprites = tabGroup.Children.OfType<UiText>().ToList();
Assert.Equal(3, sprites.Count);
Assert.Equal(0x06005D93u, sprites[0].BackgroundSprite); // Closed left-cap
Assert.Equal(0x06005D95u, sprites[1].BackgroundSprite); // Closed center
Assert.Equal(0x06005D97u, sprites[2].BackgroundSprite); // Closed right-cap
}
Assert.Equal("Closed", btn.ActiveState);
[Fact]
public void TabButtons_TitlesGroup_AddsThreeClosedSpriteChildren()
{
// Titles tab (isOpen=false): three UiText sprite children with Closed sprite ids.
var tabGroup = new UiText();
var layout = Fake((CharacterStatController.TabTitlesId, tabGroup));
CharacterStatController.Bind(layout, SampleData.SampleCharacter,
spriteResolve: id => (id, 16, 16));
var sprites = tabGroup.Children.OfType<UiText>().ToList();
Assert.Equal(3, sprites.Count);
Assert.Equal(0x06005D93u, sprites[0].BackgroundSprite); // Closed left-cap
Assert.Equal(0x06005D95u, sprites[1].BackgroundSprite); // Closed center
Assert.Equal(0x06005D97u, sprites[2].BackgroundSprite); // Closed right-cap
}
// ── Affordability helpers (GetRaiseCost) ──────────────────────────────────
@ -644,6 +664,34 @@ public class CharacterStatControllerTests
Assert.True(t.RightAligned);
}
// ── Header captions (new — LevelCaptionId + TotalXpLabelId) ─────────────
[Fact]
public void Bind_LevelCaptionId_SetsCharacterLevelText()
{
var caption = new UiText();
var layout = Fake((CharacterStatController.LevelCaptionId, caption));
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
Assert.Equal("Character Level", caption.LinesProvider()[0].Text);
Assert.False(caption.Centered, "LevelCaption must be left-justified (Centered=false)");
Assert.False(caption.RightAligned, "LevelCaption must be left-justified (RightAligned=false)");
}
[Fact]
public void Bind_TotalXpLabelId_SetsTotalExperienceXpText()
{
var lbl = new UiText();
var layout = Fake((CharacterStatController.TotalXpLabelId, lbl));
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
Assert.Equal("Total Experience (XP):", lbl.LinesProvider()[0].Text);
Assert.False(lbl.Centered, "TotalXpLabel must be left-justified (Centered=false)");
Assert.False(lbl.RightAligned, "TotalXpLabel must be left-justified (RightAligned=false)");
}
// ── Robustness ────────────────────────────────────────────────────────────
[Fact]