fix(ui): Campaign CT5 fix round — Normal-state row media, geometry test, padding, doc corrections

Opus review of CT5 (f532f28c) found 0 blockers, 4 SHOULD-FIX, 6 NOTEs; all
applied here.

SHOULD-FIX 1 (visible retail gap): unselected attribute/skill rows now draw
the row template's Normal-state media (0x06004CC2 — pinned by
CharacterPanelLiveDatTests.AttributeRowTemplate_...) instead of drawing
nothing. Independently decoded against the installed DAT: PFID_A8R8G8B8,
48x48, uniform (0,0,0,175) — a ~69%-opaque black tile the native-size
copy-or-tile blit repeats across the row. Wired at all three sites
(HandleRowClick, ApplySkillSelectionVisuals, AddRow). Selected rows keep
0x06000F93 (RowHighlightSprite) unchanged.

SHOULD-FIX 2: added Bind_AttributeRow_/Bind_VitalRow_/Bind_SkillRow_
geometry tests asserting the authored template pixels against BUILT rows
(not DAT pins) — width, height, icon/name/value column positions. The
skill-row case reproduces the real production scrollbar (X=281, per
CharacterPanelLiveDatTests.StatListBox_AuthorsFiveRowTemplatesInSharedLayout)
to prove the documented 281px clamp (scrollbar.Left - list.Left), one pixel
short of the attribute/vital rows' 282px ceiling.

SHOULD-FIX 3: AddRow's name-column Padding corrected from 1f to 0f — the
authored template carries no margin on 0x1000012A; Padding=1f re-created
the X=26 glyph-start bug this slice existed to fix.

SHOULD-FIX 4: reworded both UiPanel.BackgroundSprite doc comments — the
draw is a native-size copy-or-tile blit (UV-repeat), never a stretch.
Decoded 0x06000F93 as exactly 282x20 (matches the row natively, draws as a
plain copy) vs 0x06004CC2's 48x48 tile. Retail's UIRegion::SetImageByDID
(@0x0069F960) decompiles to a pure BlitMode selector switch — param_2==2 ->
Blit_3Alpha, ==3 -> Blit_4Alpha, else Blit_Normal — with no width/height
touched anywhere in the function, answering CT1's open "draw mode 3"
question: it's an alpha-blend selector, not a resize flag.

NOTEs:
  a. AddRow's nameEl now sets OneLine=true so the authored VJustify=Center
     takes the same single-line vertical-centering path the value column
     already uses.
  b. Tempered the "row width is 282" wording in the SkillContentWidth /
     RowContentWidth doc comments — that's a ceiling attribute/vital rows
     land on, not a fact true for skill rows (281, via SkillViewportWidth's
     scrollbar-gutter measurement).
  c. Reworded the section-header (RowPadX) comment — CT1 verified only the
     four header SPRITES; the caption label's own authored margins were
     never checked. Recorded as an open residual, not a cleared divergence.
  d. AttrRows/VitalRows are now internal (InternalsVisibleTo("AcDream.App.Tests")
     already covers AcDream.App.Tests); CharacterPanelLiveDatTests iterates
     them directly instead of a re-typed duplicate array, and now also
     asserts the vitals 2/4/6 current-enum aliasing claim the doc comment
     made but never enforced.
  e. Deleted the stale pre-CT5 0x06001397 narrative in
     CharacterPanelLiveDatTests; the pin's comment now describes the
     post-CT5 state (a regression guard, not an open bug).
  f. Unified ApplySkillSelectionVisuals' selected-branch SpriteResolve
     wrapper closure with HandleRowClick's direct assignment.

Build green; full hermetic solution suite green (Release,
Lane!=InstalledDat&...&Status!=KnownFailure filter); InstalledDat lane
green (ACDREAM_RUN_INSTALLED_DAT_TESTS=1, Lane=InstalledDat&Status!=KnownFailure).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-25 01:47:25 +02:00
parent 657b84c297
commit 0a37a28e76
4 changed files with 303 additions and 75 deletions

View file

@ -516,6 +516,115 @@ public class CharacterStatControllerTests
}
}
// ── CT5 fix round SHOULD-FIX 2: production BUILT-row geometry ────────────
// Load-bearing evidence for AddRow's own "hand-built-vs-template ruling"
// doc comment: these tests assert the authored template 0x10000248 pixel
// geometry against Bind's ACTUAL BUILT rows (not the DAT pins
// CharacterPanelLiveDatTests.AttributeRowTemplate_... already covers —
// those pin the template itself, these pin what the controller DOES
// with it).
[Fact]
public void Bind_AttributeRow_MatchesAuthoredTemplateGeometry()
{
var list = new UiPanel();
var layout = Fake((CharacterStatController.ListBoxId, list));
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
var row = list.Children.OfType<UiPanel>().First();
AssertRowGeometry(row, expectedWidth: 282f);
}
/// <summary>Vital rows (Health/Stamina/Mana) are appended after the 6
/// attribute rows by <c>BuildAttributeRows</c> — index 6 is the first
/// vital row.</summary>
[Fact]
public void Bind_VitalRow_MatchesAuthoredTemplateGeometry()
{
var list = new UiPanel();
var layout = Fake((CharacterStatController.ListBoxId, list));
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
var row = list.Children.OfType<UiPanel>().ToList()[6];
AssertRowGeometry(row, expectedWidth: 282f);
}
/// <summary>
/// Unlike the attribute/vital test above (a plain <see cref="UiPanel"/>
/// list with no dat Width, which falls back to the raw 282px template
/// ceiling — see <c>RowContentWidth</c>'s doc comment), this test
/// reproduces the REAL production geometry: the ListBox's authored
/// scrollbar (<c>0x1000023E</c>) sits at X=281
/// (<see cref="CharacterPanelLiveDatTests.StatListBox_AuthorsFiveRowTemplatesInSharedLayout"/>),
/// so <c>SkillViewportWidth</c> measures the viewport as
/// <c>scrollbar.Left list.Left = 281</c> and skill rows clamp one
/// pixel narrower than the attribute/vital rows' 282px ceiling — the
/// documented "282px row template overlaps the 281px scrollbar band by
/// 1px" fact from the ground-truth doc, asserted against the actual
/// mechanism instead of a synthetic width.
/// </summary>
[Fact]
public void Bind_SkillRow_ClampsToAuthoredScrollbarGutterWidth()
{
var root = new UiPanel { Width = 300, Height = 600 };
var page = new UiPanel { Width = 300, Height = 600 };
var name = new UiText();
var list = MakeDatElement(CharacterStatController.ListBoxId, top: 112, width: 300, height: 398);
var scrollbarShell = MakeDatElement(CharacterStatController.ListScrollbarId, top: 112, width: 16, height: 398);
scrollbarShell.Left = 281;
page.AddChild(name);
page.AddChild(list);
page.AddChild(scrollbarShell);
root.AddChild(page);
var skillsTab = MakeTab(CharacterStatController.TabSkillsId, left: 92f);
root.AddChild(skillsTab);
var layout = new ImportedLayout(root, new Dictionary<uint, UiElement>
{
[CharacterStatController.NameId] = name,
[CharacterStatController.ListBoxId] = list,
[CharacterStatController.ListScrollbarId] = scrollbarShell,
[CharacterStatController.TabSkillsId] = skillsTab,
});
CharacterStatController.Bind(layout, SampleData.SampleCharacter,
spriteResolve: id => (id, 16, 16));
ClickTab(layout, left: 92f);
var row = SkillRows(list).First();
AssertRowGeometry(row, expectedWidth: 281f);
}
private static void AssertRowGeometry(UiPanel row, float expectedWidth)
{
Assert.Equal(expectedWidth, row.Width);
Assert.Equal(20f, row.Height);
var texts = row.Children.OfType<UiText>().ToList();
Assert.True(texts.Count >= 3, "row must have icon + name + value children");
UiText icon = texts[0];
UiText name = texts[1];
UiText value = texts[2];
Assert.Equal(0f, icon.Left);
Assert.Equal(20f, icon.Width);
Assert.Equal(25f, name.Left);
Assert.Equal(150f, name.Width);
// CT5 fix round (SHOULD-FIX 3): the authored template's name column
// carries no margin — Padding=0 (the former Padding=1f re-created
// the X=26 glyph start this slice existed to remove).
Assert.Equal(0f, name.Padding);
Assert.Equal(175f, value.Left);
Assert.Equal(100f, value.Width);
Assert.True(value.RightAligned);
}
// ── Footer State A ────────────────────────────────────────────────────────
[Fact]
@ -767,8 +876,10 @@ public class CharacterStatControllerTests
Assert.Equal(0x06000F93u, rows[2].BackgroundSprite); // selected → sprite
Assert.Equal(0f, rows[2].BackgroundColor.W); // no tint
Assert.Equal(0u, rows[0].BackgroundSprite); // others cleared
Assert.Equal(0u, rows[1].BackgroundSprite);
// CT5 fix round (SHOULD-FIX 1): unselected rows draw the row
// template's Normal-state media (0x06004CC2) instead of nothing.
Assert.Equal(0x06004CC2u, rows[0].BackgroundSprite); // others: Normal-state media
Assert.Equal(0x06004CC2u, rows[1].BackgroundSprite);
}
[Fact]
@ -784,7 +895,9 @@ public class CharacterStatControllerTests
rows[2].OnClick!(); // select
rows[2].OnClick!(); // deselect
Assert.Equal(0u, rows[2].BackgroundSprite);
// CT5 fix round (SHOULD-FIX 1): deselecting returns to the
// unselected Normal-state media (0x06004CC2), not zero.
Assert.Equal(0x06004CC2u, rows[2].BackgroundSprite);
}
// ── Pass 2: Raise button affordability ───────────────────────────────────
@ -1113,7 +1226,9 @@ public class CharacterStatControllerTests
Assert.All(rows, row =>
{
Assert.Equal(Vector4.Zero, row.BackgroundColor);
Assert.Equal(0u, row.BackgroundSprite);
// CT5 fix round (SHOULD-FIX 1): fresh unselected rows draw the
// row template's Normal-state media (0x06004CC2) up front.
Assert.Equal(0x06004CC2u, row.BackgroundSprite);
});
var rowNames = rows
.Select(r => r.Children.OfType<UiText>().ToList()[1].LinesProvider()[0].Text)
@ -1337,7 +1452,9 @@ public class CharacterStatControllerTests
// needed).
Assert.Equal(0x06000F93u, rows[1].BackgroundSprite);
Assert.Equal(Vector4.Zero, rows[1].BackgroundColor);
Assert.Equal(0u, rows[0].BackgroundSprite);
// CT5 fix round (SHOULD-FIX 1): the unselected row draws the row
// template's Normal-state media (0x06004CC2), not zero.
Assert.Equal(0x06004CC2u, rows[0].BackgroundSprite);
Assert.Equal(Vector4.Zero, rows[0].BackgroundColor);
}