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