diff --git a/src/AcDream.App/UI/Layout/CharacterStatController.cs b/src/AcDream.App/UI/Layout/CharacterStatController.cs
index 6e1e0703..4fc8a449 100644
--- a/src/AcDream.App/UI/Layout/CharacterStatController.cs
+++ b/src/AcDream.App/UI/Layout/CharacterStatController.cs
@@ -173,18 +173,35 @@ public static class CharacterStatController
private const float RowValueW = 100f;
// Section-header caption inset ONLY (AddSkillHeader's 0x10000249..0x1000024C
- // captions carry their own authored L5/R5 margins property, distinct from
- // the data-row template above) — retained at its pre-CT5 value; CT1 found
- // no divergence in the header captions, so this constant is out of CT5's
- // scope.
+ // captions carry their own authored geometry, distinct from the data-row
+ // template above) — retained at its pre-CT5 value. CT5 fix round
+ // (NOTE c): CT1 verified only the FOUR SPRITES for these header templates
+ // (SkillSectionHeaderTemplates_MatchExistingSpriteConstants: Width=280,
+ // Height=20, sprite match) — the caption LABEL's own authored geometry
+ // (W=280 with L5/R5 margins per the header template, versus our current
+ // header width, which comes from RowContentWidth/SkillViewportWidth same
+ // as the data rows, plus this flat 4px inset) was never pinned or
+ // cross-checked against that. This is a KNOWN, RECORDED residual gap —
+ // NOT a cleared divergence — left for a future slice to pin the caption
+ // child's authored margins the same way CT1 pinned the data-row
+ // template's icon/name/value columns; behavior is unchanged here.
private const float RowPadX = 4f;
private const float SkillHeaderHeight = 20f;
- // Authored row-template width (0x10000248 W=282) — CT5: the row's own
- // width now comes from THIS authored constant directly rather than the
- // ListBox's raw 300px Width (see RowContentWidth below); do not derive
- // a "listWidth minus gutter" formula, per the ground-truth doc's
- // explicit warning.
+ // Authored row-template CEILING width (0x10000248 W=282) — CT5: the
+ // row's own width comes from THIS authored constant as an upper bound,
+ // never the ListBox's raw 300px Width, and never a derived
+ // "listWidth minus gutter" formula (the ground-truth doc's explicit
+ // warning). CT5 fix round (NOTE b, tempered wording): this ceiling is
+ // NOT the row's width in every context. Attribute/vital rows land on it
+ // exactly (300px ListBox clamped to 282). The SKILL page instead first
+ // narrows the viewport to SkillViewportWidth's scrollbar-gutter
+ // measurement (scrollbar.Left − list.Left = 281px in the real
+ // production layout — CharacterPanelLiveDatTests.
+ // StatListBox_AuthorsFiveRowTemplatesInSharedLayout pins the scrollbar
+ // at X=281), and RowContentWidth then clamps skill rows to THAT
+ // (281 < 282) rather than the raw 282 ceiling — see
+ // Bind_SkillRow_ClampsToAuthoredScrollbarGutterWidth.
private const float SkillContentWidth = 282f;
private const uint SkillHeaderSpecializedSprite = 0x06000F90u;
@@ -204,6 +221,21 @@ public static class CharacterStatController
// — that file and its tests are correct and must NOT be touched.
private const uint RowHighlightSprite = 0x06000F93u;
+ // CT5 fix round (SHOULD-FIX 1, visible retail gap): the row template's
+ // NORMAL-state media — StateMedia[Normal] on 0x10000248, pinned byte-
+ // exact by CharacterPanelLiveDatTests.
+ // AttributeRowTemplate_IconIsFlushLeftTwentyPixels_NameAndValueAreFixedColumns
+ // (row.StateMedia["Normal"].File == 0x06004CC2). CT5 itself only wired
+ // the Highlight-state swap above; UNSELECTED rows drew fully
+ // transparent instead of this authored background. Independently
+ // decoded against the installed DAT (2026-08-25): PFID_A8R8G8B8, 48x48,
+ // a single uniform color (0,0,0,175) — i.e. a ~69%-opaque (175/255)
+ // flat black tile that retail's native-size copy-or-tile blit repeats
+ // across the row (see UiPanel.BackgroundSprite's doc comment for the
+ // tiling mechanism) to produce the dark band under every unselected
+ // attribute/skill row. Selected rows keep RowHighlightSprite above.
+ private const uint RowNormalSprite = 0x06004CC2u;
+
// CT5 (AP-235 unification, gmAttributeUI::PostInit @0x0049DB70 verbatim):
// per-attribute icon DIDs resolve via DBObj::GetDIDByEnum(statEnum,
// category 0x10000002); per-vital (Attribute2ndInfoRegion) icon DIDs via
@@ -255,7 +287,13 @@ public static class CharacterStatController
private sealed record SkillRowBinding(UiClickablePanel Panel, CharacterSkill Skill);
// ── Attribute row descriptors — retail display order per spec §1 ─────────
- private static readonly (string name, uint iconDid, uint statId)[] AttrRows = new[]
+ // CT5 fix round (NOTE d): internal (not private) so
+ // CharacterPanelLiveDatTests.AttributeAndVitalIconDids_MatchTheRetailEnumMapperChain
+ // can iterate THESE tuples directly instead of a re-typed duplicate
+ // literal array — a divergence between the two would previously have
+ // gone undetected. InternalsVisibleTo("AcDream.App.Tests") already
+ // covers this assembly (AcDream.App.csproj).
+ internal static readonly (string name, uint iconDid, uint statId)[] AttrRows = new[]
{
("Strength", 0x060002C8u, 1u),
("Endurance", 0x060002C4u, 2u),
@@ -265,7 +303,8 @@ public static class CharacterStatController
("Self", 0x060002C7u, 6u),
};
- private static readonly (string name, uint iconDid, uint maxStatId)[] VitalRows = new[]
+ // CT5 fix round (NOTE d): internal for the same reason as AttrRows above.
+ internal static readonly (string name, uint iconDid, uint maxStatId)[] VitalRows = new[]
{
("Health", 0x06004C3Bu, 1u), // max enum 1; current enum 2
("Stamina", 0x06004C3Cu, 3u), // max enum 3; current enum 4
@@ -891,12 +930,20 @@ public static class CharacterStatController
}
///
- /// CT5: the row's own rendered width is the AUTHORED row-template width
- /// (282px, ), never the ListBox's raw
- /// container width (300px, 's
- /// own dat rect) — the two numbers do not compose into a "gutter" formula
- /// (ground-truth doc §2's explicit warning); a shorter container clamps
- /// the row down, but a wider one never stretches it past 282.
+ /// CT5: the row's own rendered width is 's own
+ /// width clamped to the AUTHORED row-template CEILING
+ /// (282px, ) — the two numbers never
+ /// compose into a derived "gutter" formula (ground-truth doc §2's
+ /// explicit warning); a shorter clamps the row
+ /// down, but a wider one never stretches it past 282.
+ /// CT5 fix round (NOTE b, tempered wording): this does NOT mean
+ /// every row is 282px. Attribute/vital rows pass the raw ListBox
+ /// (300px, 's own dat
+ /// rect) and land on the 282 ceiling exactly. The skill page instead
+ /// passes the narrower SkillViewportWidth-computed viewport
+ /// (281px in production — the real scrollbar's authored gutter), so
+ /// skill rows clamp to 281, one pixel short of the template ceiling.
+ ///
///
private static float RowContentWidth(UiElement list)
=> list.Width > 0f ? MathF.Min(list.Width, SkillContentWidth) : SkillContentWidth;
@@ -1231,6 +1278,10 @@ public static class CharacterStatController
// to the template's Highlight-state media (RowHighlightSprite,
// 0x06000F93 — see its own doc comment) when spriteResolve is
// available; otherwise fall back to the translucent gold tint.
+ // CT5 fix round (SHOULD-FIX 1): the UNSELECTED branch now draws the
+ // template's Normal-state media (RowNormalSprite, 0x06004CC2 — see
+ // its own doc comment) the same way, instead of leaving the row
+ // fully transparent.
for (int i = 0; i < rows.Count; i++)
{
var row = rows[i];
@@ -1252,8 +1303,8 @@ public static class CharacterStatController
else
{
row.BackgroundColor = Vector4.Zero;
- row.BackgroundSprite = 0u;
- row.SpriteResolve = null;
+ row.BackgroundSprite = spriteResolve is not null ? RowNormalSprite : 0u;
+ row.SpriteResolve = spriteResolve;
}
}
@@ -1296,7 +1347,12 @@ public static class CharacterStatController
{
row.BackgroundColor = Vector4.Zero;
row.BackgroundSprite = RowHighlightSprite;
- row.SpriteResolve = id => { var (h, w, ht) = spriteResolve(id); return (h, w, ht); };
+ // CT5 fix round (NOTE f): unified with HandleRowClick's
+ // direct assignment — the per-row wrapper closure this
+ // used to allocate was functionally identical (same
+ // tuple shape, just differently-named elements, which
+ // the delegate conversion already accepts without it).
+ row.SpriteResolve = spriteResolve;
}
else
{
@@ -1307,9 +1363,12 @@ public static class CharacterStatController
}
else
{
+ // CT5 fix round (SHOULD-FIX 1): draw the Normal-state media
+ // (RowNormalSprite) on unselected rows — see HandleRowClick's
+ // matching comment for the full citation.
row.BackgroundColor = Vector4.Zero;
- row.BackgroundSprite = 0u;
- row.SpriteResolve = null;
+ row.BackgroundSprite = spriteResolve is not null ? RowNormalSprite : 0u;
+ row.SpriteResolve = spriteResolve;
}
}
}
@@ -1754,9 +1813,14 @@ public static class CharacterStatController
Top = top,
Width = width,
Height = height,
- BackgroundColor = Vector4.Zero, // transparent until selected
- BackgroundSprite = 0u,
- SpriteResolve = null,
+ BackgroundColor = Vector4.Zero,
+ // CT5 fix round (SHOULD-FIX 1): a freshly-built row starts
+ // unselected, so it gets the template's Normal-state media
+ // (RowNormalSprite) up front — matching HandleRowClick's/
+ // ApplySkillSelectionVisuals' unselected branch exactly, so a
+ // row never flashes transparent before its first click.
+ BackgroundSprite = spriteResolve is not null ? RowNormalSprite : 0u,
+ SpriteResolve = spriteResolve,
BorderColor = Vector4.Zero,
Anchors = AnchorEdges.Left | AnchorEdges.Top,
};
@@ -1793,7 +1857,21 @@ public static class CharacterStatController
ClickThrough = true,
Centered = false,
RightAligned = false,
- Padding = 1f,
+ // CT5 fix round (SHOULD-FIX 3): the authored template's
+ // 0x1000012A name column carries no margin property at all —
+ // Padding=1f re-created the X=26 glyph start (RowNameX + 1) this
+ // very slice existed to remove (see AttributeRowTemplate_...'s
+ // pin: name.X == 25f, no margin). 0f matches the value column's
+ // own (already-0) Padding.
+ Padding = 0f,
+ // CT5 fix round (NOTE a): both the name and value columns author
+ // VJustify=Center in the template (0x1000012A/0x1000012B share
+ // the same default UiText.VerticalJustify). OneLine=true routes
+ // this element through the same single-line vertical-centering
+ // draw path the value column below already uses instead of the
+ // multi-line/scroll path, which does not honor VerticalJustify
+ // the same way.
+ OneLine = true,
Anchors = AnchorEdges.Left | AnchorEdges.Top,
};
nameEl.LinesProvider = () => new[] { new UiText.Line(capturedName, capturedNameColor) };
diff --git a/src/AcDream.App/UI/UiPanel.cs b/src/AcDream.App/UI/UiPanel.cs
index 5ad5b063..5b5e81df 100644
--- a/src/AcDream.App/UI/UiPanel.cs
+++ b/src/AcDream.App/UI/UiPanel.cs
@@ -26,10 +26,35 @@ public class UiPanel : UiElement
/// Optional dat RenderSurface id for the panel background sprite, drawn
/// in place of (or alongside) . 0 = none.
- /// When set, the sprite is stretched to fill the panel rect — the same
- /// full-swap semantics retail's InfoRegion::SetState uses. Used by
- /// the character-panel attribute/skill row's selected-row highlight
- /// (sprite 0x06000F93, template 0x10000248's Highlight-state media).
+ /// CT5 fix round (SHOULD-FIX 4): drawn at NATIVE SIZE — retail's
+ /// copy-or-tile blit, never scaled (see UiDatElement.OnDraw's own
+ /// doc comment, ~lines 273-360, for the full ground truth). The UV
+ /// rectangle below (Width / tw, Height / th) is deliberately
+ /// UV-REPEAT, not a fixed 0..1 a stretch would use — GL_REPEAT-wrapped
+ /// UI textures tile past their native pixel size rather than scaling.
+ /// Retail's generic sprite blit (Graphic::Draw 0x00693b20 /
+ /// Graphic::PutImage 0x00693a30) has exactly two behaviors, copy
+ /// or tile, and can never scale a source image up to fill a larger
+ /// destination.
+ /// Used by the character-panel attribute/skill row's Highlight-state
+ /// swap (sprite 0x06000F93, template 0x10000248's Highlight-state
+ /// media) and CT5's Normal-state row background (0x06004CC2). The two
+ /// sprites behave differently under this same tile formula: 0x06000F93
+ /// is authored at exactly the row's own 282x20 native size (decoded
+ /// against the installed DAT, 2026-08-25: PFID_R8G8B8, 282x20), so it
+ /// draws as a plain COPY with no visible seam; 0x06004CC2 is a 48x48
+ /// uniform-color tile (PFID_A8R8G8B8, single color (0,0,0,175)) that
+ /// visibly TILES across the wider row — both are the same code path,
+ /// just different source-vs-destination ratios.
+ /// Retail's own UIRegion::SetImageByDID (@0x0069F960)
+ /// confirms its third parameter is a BlitMode COLOR-BLEND
+ /// selector, not a resize flag — the decompiled body switches purely on
+ /// that value (param_2 == 2 -> Blit_3Alpha,
+ /// == 3 -> Blit_4Alpha, else Blit_Normal) and
+ /// never touches width/height at all. This answers CT1's open "icon
+ /// draw mode 3" question (ground-truth doc §2): mode 3 selects
+ /// Blit_4Alpha, an alpha-blend variant, not a resize.
+ ///
public uint BackgroundSprite { get; set; }
/// Resolves a dat RenderSurface id to (GL tex handle, pixel width, pixel height).
@@ -195,14 +220,17 @@ public class UiSimpleButton : UiPanel
///
/// Campaign CT slice CT5 (2026-08-25): the selected-row highlight draws through the
/// inherited full-panel
-/// stretch — no override here. This class previously had its own "selection bars" draw
-/// mode (a thin top/bottom-bar rendering tuned to look like sprite 0x06001397's dark
-/// bars); CT1's ground-truth research found that sprite belongs to a DIFFERENT retail
-/// mechanism entirely (the spellbook row's overlay child), and the row's actual retail
-/// Highlight state (InfoRegion::SetState, media 0x06000F93) is a plain full-row
-/// background SWAP — exactly what the inherited already
-/// draws. The bars mode was therefore retired rather than reconfigured to a wrong sprite's
-/// geometry; no consumer outside CharacterStatController ever set it.
+/// draw (native-size copy-or-tile — see that property's own doc comment for the CT5 fix
+/// round's correction of the mechanism; NOT a stretch) — no override here. This class
+/// previously had its own "selection bars" draw mode (a thin top/bottom-bar rendering
+/// tuned to look like sprite 0x06001397's dark bars); CT1's ground-truth research found
+/// that sprite belongs to a DIFFERENT retail mechanism entirely (the spellbook row's
+/// overlay child), and the row's actual retail Highlight state (InfoRegion::SetState,
+/// media 0x06000F93) is a plain full-row background SWAP — exactly what the inherited
+/// already draws (0x06000F93 is authored at exactly the row's
+/// own 282x20 native size, so the swap needs no scaling to look right). The bars mode was
+/// therefore retired rather than reconfigured to a wrong sprite's geometry; no consumer
+/// outside CharacterStatController ever set it.
///
public class UiClickablePanel : UiPanel
{
diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterPanelLiveDatTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterPanelLiveDatTests.cs
index 3eae10da..5935e61c 100644
--- a/tests/AcDream.App.Tests/UI/Layout/CharacterPanelLiveDatTests.cs
+++ b/tests/AcDream.App.Tests/UI/Layout/CharacterPanelLiveDatTests.cs
@@ -208,14 +208,14 @@ public sealed class CharacterPanelLiveDatTests
Assert.Equal(282f, row!.Width);
Assert.Equal(20f, row.Height);
Assert.Equal(0x06004CC2u, row.StateMedia["Normal"].File);
- // Row-template Highlight sprite (0x06000F93) — CharacterStatController's
- // current RowHighlightSprite private constant is 0x06001397. CT1's
- // fix round upgraded this from a flagged divergence to a SEALED
- // VERDICT (gmAttributeUI::UpdateSelection SetState(6) ->
- // InfoRegion::SetState on this exact template); 0x06001397 belongs
- // to the spellbook row's separate selected-overlay mechanism. CT5
- // fixes RowHighlightSprite for the stat rows only; see the research
- // doc's "Corrections to the plan" §3.
+ // Row-template Highlight sprite (0x06000F93) — post-CT5 state:
+ // CharacterStatController.RowHighlightSprite already matches this
+ // (CT5 corrected it from the former 0x06001397, which belongs to
+ // the spellbook row's unrelated separate selected-overlay
+ // mechanism, per the SEALED VERDICT: gmAttributeUI::UpdateSelection
+ // SetState(6) -> InfoRegion::SetState on this exact template). This
+ // pin remains a regression guard against that fixed divergence
+ // recurring, not a still-open bug.
Assert.Equal(0x06000F93u, row.StateMedia["Highlight"].File);
ElementInfo icon = Assert.Single(row.Children, c => c.Id == 0x10000129u);
@@ -522,11 +522,14 @@ public sealed class CharacterPanelLiveDatTests
/// vitals' Attribute2ndInfoRegion icon (verified live: the max-vital
/// enum ids 1/3/5 that CharacterStatController.VitalRows already
/// stores resolve to the SAME DID as the current-vital ids 2/4/6 retail's
- /// own decomp literally passes, so either works). Both categories route
- /// through the shared RetailDataIdResolver.Resolve master-map ->
- /// category-map -> value chain — this pin proves every hardcoded fallback
- /// DID in AttrRows/VitalRows already matches the live-resolved
- /// value byte-exact, the same "regression guard, not a bug pin" pattern as
+ /// own decomp literally passes, so either works — CT5 fix round (NOTE d)
+ /// added the 2/4/6 assertions below that actually ENFORCE this claim
+ /// instead of only stating it). Both categories route through the
+ /// shared RetailDataIdResolver.Resolve master-map -> category-map
+ /// -> value chain — this pin proves every hardcoded fallback DID in
+ /// AttrRows/VitalRows (iterated directly, not re-typed)
+ /// already matches the live-resolved value byte-exact, the same
+ /// "regression guard, not a bug pin" pattern as
/// .
///
[InstalledDatFact]
@@ -535,32 +538,34 @@ public sealed class CharacterPanelLiveDatTests
using var dats = new DatCollection(DatDirectory, DatReaderWriter.Options.DatAccessType.Read);
const uint attributeIconCategory = 0x10000002u;
- (uint statId, uint expectedDid)[] attributes =
- {
- (1u, 0x060002C8u), // Strength
- (2u, 0x060002C4u), // Endurance
- (4u, 0x060002C9u), // Coordination
- (3u, 0x060002C6u), // Quickness
- (5u, 0x060002C5u), // Focus
- (6u, 0x060002C7u), // Self
- };
- foreach (var (statId, expectedDid) in attributes)
+ // CT5 fix round (NOTE d): AttrRows/VitalRows are now internal, so
+ // this pin iterates THEM directly instead of a re-typed duplicate
+ // literal array — a divergence between the two would previously
+ // have gone undetected by this test.
+ foreach (var (_, iconDid, statId) in CharacterStatController.AttrRows)
{
uint resolved = RetailDataIdResolver.Resolve(dats, statId, attributeIconCategory);
- Assert.Equal(expectedDid, resolved);
+ Assert.Equal(iconDid, resolved);
}
const uint vitalIconCategory = 0x10000003u;
- (uint maxStatId, uint expectedDid)[] vitals =
- {
- (1u, 0x06004C3Bu), // Health
- (3u, 0x06004C3Cu), // Stamina
- (5u, 0x06004C3Du), // Mana
- };
- foreach (var (maxStatId, expectedDid) in vitals)
+ foreach (var (_, iconDid, maxStatId) in CharacterStatController.VitalRows)
{
uint resolved = RetailDataIdResolver.Resolve(dats, maxStatId, vitalIconCategory);
- Assert.Equal(expectedDid, resolved);
+ Assert.Equal(iconDid, resolved);
+ }
+
+ // CT5 fix round (NOTE d): enforce the "aliasing" claim this pin's
+ // own doc comment made but never actually checked — the
+ // CURRENT-vital enum ids (2/4/6, i.e. maxStatId+1) that retail's own
+ // gmAttributeUI::PostInit decomp literally passes must resolve to
+ // the SAME DID as the MAX-vital ids (1/3/5) VitalRows actually
+ // stores, live-verified against the installed DAT.
+ foreach (var (_, iconDid, maxStatId) in CharacterStatController.VitalRows)
+ {
+ uint currentStatId = maxStatId + 1u;
+ uint resolved = RetailDataIdResolver.Resolve(dats, currentStatId, vitalIconCategory);
+ Assert.Equal(iconDid, resolved);
}
}
}
diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs
index 26ce051a..10809fb5 100644
--- a/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs
+++ b/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs
@@ -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().First();
+ AssertRowGeometry(row, expectedWidth: 282f);
+ }
+
+ /// Vital rows (Health/Stamina/Mana) are appended after the 6
+ /// attribute rows by BuildAttributeRows — index 6 is the first
+ /// vital row.
+ [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().ToList()[6];
+ AssertRowGeometry(row, expectedWidth: 282f);
+ }
+
+ ///
+ /// Unlike the attribute/vital test above (a plain
+ /// list with no dat Width, which falls back to the raw 282px template
+ /// ceiling — see RowContentWidth's doc comment), this test
+ /// reproduces the REAL production geometry: the ListBox's authored
+ /// scrollbar (0x1000023E) sits at X=281
+ /// (),
+ /// so SkillViewportWidth measures the viewport as
+ /// scrollbar.Left − list.Left = 281 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.
+ ///
+ [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
+ {
+ [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().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().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);
}