feat(ui): Campaign CT slice CT5 — attribute/skill row geometry + selection media

Aligns the hand-built attribute/skill rows in CharacterStatController with
the authored shared row template 0x10000248 (LayoutDesc 0x21000045,
InfoRegion::InfoRegion @0x004F1450 template index 0 — the same template
gmAttributeUI and gmSkillUI both instantiate):

- Row geometry replaced with AUTHORED PIXEL VALUES instead of derived
  fractions: icon flush left 20x20 (was 16x16 at X=4, vertically
  centered), name column X=25 W=150 fixed (was RowPadX+IconSize+IconGap
  offset with a width*0.60 fraction), value column X=175 W=100
  right-justified (its right edge sits 7px short of the row's 282px
  right edge — the authored gutter the owner reported). Row width itself
  now clamps to the authored 282px template width (RowContentWidth)
  rather than the ListBox's raw 300px container width. Attribute-row
  height fixed at 20px (was 22px, no dat basis); SkillRowHeight folded
  into the same RowHeight constant since both row kinds share H=20.

- RowHighlightSprite corrected from 0x06001397 to 0x06000F93 — CT1's
  ground-truth research sealed the verdict that gmAttributeUI::
  UpdateSelection @0x0049DEE0 (SetState(6) -> InfoRegion::SetState
  @0x004F0EE0) swaps the row's Highlight-state media (0x06000F93), a
  full-row background swap. 0x06001397 belongs to a different mechanism
  entirely (the spellbook row's UIElement_UIItem::SetSelectedState
  overlay child) and SpellbookRowStyle.cs is untouched.

- UiClickablePanel.UseSelectionBars/SelectionBarHeight retired outright
  (UiPanel.cs): they existed only to emulate 0x06001397's dark-bars art;
  the correct retail rendering is the full-panel sprite stretch the base
  UiPanel.OnDraw already performs, so the override is dead code once the
  correct sprite is used. No consumer existed outside
  CharacterStatController.

- Per-attribute/per-vital icon DIDs now resolve through the live
  DBObj::GetDIDByEnum chain (RetailDataIdResolver.Resolve, AP-235's
  unification seam) when a resolver is supplied — RetailUiRuntime.
  MountCharacter wires one under the shared DatLock — falling back to
  the hardcoded AttrRows/VitalRows column otherwise (tests, no dat).
  gmAttributeUI::PostInit @0x0049DB70 read verbatim: attributes resolve
  via category 0x10000002 (statId order 1,2,4,3,5,6, matching AttrRows'
  authored display order exactly); vitals via category 0x10000003.
  Live-DAT-verified: every hardcoded fallback value already matched the
  resolved DID byte-exact (new InstalledDat pin
  AttributeAndVitalIconDids_MatchTheRetailEnumMapperChain).

- RetailAppraisalNameResolver.ResolveHeritage's independent
  re-implementation of the 2/5/13 heritage overrides deleted; it now
  delegates straight to CharacterIdentityText.HeritageGroupDisplayName
  (which already bakes in the same overrides) — one owner, byte-identical
  behavior. AP-235's register row updated to reflect the single-owner fix
  (the underlying hardcoded-vs-live-DAT mechanism divergence itself
  stays open — out of CT5's scope).

Hand-built-vs-template ruling: rows stay HAND-BUILT rather than
converting to UiTemplateListBox instantiation. The hand-built path hits
every authored number byte-exact (proven by the CT1 InstalledDat pin
AttributeRowTemplate_IconIsFlushLeftTwentyPixels_NameAndValueAreFixedColumns),
while conversion would touch ~15 call sites (raise-button affordability,
footer State A/B, per-row tooltip, section bucketing, live-refresh,
selection-highlight) for a geometry-only slice — smaller-risk path per
the task's own judgment-call guidance.

Tests: CharacterStatControllerTests' sprite/UseSelectionBars assertions
corrected to the authored geometry; new InstalledDat pin for the icon-DID
chain. Full hermetic solution suite green (App/Core/Runtime/Headless/
Launcher/Content/etc., 0 failures) and the full InstalledDat lane green
(203 App.Tests pins, TowerAscentReplayTests' known Status=KnownFailure
case excluded per the acceptance filter).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-25 01:15:32 +02:00
parent 01e44a7018
commit f532f28c5b
7 changed files with 266 additions and 125 deletions

View file

@ -134,8 +134,14 @@ public static class CharacterStatController
// the level element authors its own pale-gold FontColor (+ Outline);
// LabelAuthoredColor reads it from the widget instead.
/// <summary>Row highlight color — semi-translucent gold, matches retail
/// UIStateId.Highlight (0x06) sprite 0x06001397 visual intent.</summary>
/// <summary>Row highlight FALLBACK color — used only when
/// <c>spriteResolve</c> is unavailable (tests, headless mode) and the
/// real <see cref="RowHighlightSprite"/> art cannot be drawn. CT5 fix
/// round: the former comment here claimed this tint "matches retail
/// sprite 0x06001397 visual intent" — that was never accurate (0x06001397
/// is the spellbook's unrelated overlay sprite, and this solid tint was
/// never tuned to any specific sprite's actual pixels either way); it is
/// a synthetic no-art placeholder, not a retail-faithful color.</summary>
private static readonly Vector4 HighlightBg = new(1f, 0.75f, 0.2f, 0.25f);
// LayoutDesc 0x2100002E, FooterTitle 0x1000024E property 0x1B:
// [0]=white, [1]=green, [2]=red, [3]=light blue (#7FFFFF).
@ -144,24 +150,73 @@ public static class CharacterStatController
private static readonly Vector4 RetailVitaeBlue = new(127f / 255f, 1f, 1f, 1f);
// ── Row layout constants ─────────────────────────────────────────────────
// RowHeight 22px + IconSize 16px: retail spec (2026-06-26) says icons ~icon-height
// and rows tighter. 16px icon fits inside 22px row with 3px vertical padding each side.
// The larger row font (0x40000001, MaxCharHeight=18) is clipped to the 22px height which
// gives a tight-but-readable line. Retail spec (2026-06-26 ref): "rows tighter, text ≈ icon height".
private const float RowHeight = 22f;
private const float IconSize = 16f;
private const float RowPadX = 4f;
private const float IconGap = 6f;
// Campaign CT slice CT5 (2026-08-25): the shared attribute/skill data-row
// template 0x10000248 (LayoutDesc 0x21000045 — InfoRegion::InfoRegion
// @0x004F1450 template index 0, used for BOTH gmAttributeUI and
// gmSkillUI rows) authors W=282 H=20 with icon 0x10000129 FLUSH LEFT
// (X=0 Y=0 W=20 H=20, full row height), name 0x1000012A at FIXED X=25
// W=150, and value 0x1000012B at FIXED X=175 W=100 right-justified —
// its right edge (275) sits 7px short of the row's own 282px right
// edge, the authored gutter the owner reported (item 2). These are
// AUTHORED PIXEL VALUES, not width-relative fractions or derived
// offsets — the former RowHeight=22/IconSize=16px-at-X=4/
// nameW=width*0.60 geometry had no dat basis at all. Ground truth +
// the explicit warning against composing a derived "gutter" formula:
// docs/research/2026-08-24-campaign-ct-dat-ground-truth.md §2, pinned
// by CharacterPanelLiveDatTests.AttributeRowTemplate_IconIsFlushLeftTwentyPixels_NameAndValueAreFixedColumns.
private const float RowHeight = 20f;
private const float RowIconX = 0f;
private const float RowIconSize = 20f;
private const float RowNameX = 25f;
private const float RowNameW = 150f;
private const float RowValueX = 175f;
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.
private const float RowPadX = 4f;
private const float SkillRowHeight = 20f;
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.
private const float SkillContentWidth = 282f;
private const uint SkillHeaderSpecializedSprite = 0x06000F90u;
private const uint SkillHeaderTrainedSprite = 0x06000F86u;
private const uint SkillHeaderUntrainedSprite = 0x06000F98u;
private const uint SkillHeaderUnusableSprite = 0x06000F89u;
private const uint RowHighlightSprite = 0x06001397u;
// CT5 SEALED VERDICT (ground-truth doc §2 "SEALED VERDICT: RowHighlightSprite
// is wrong, not merely flagged"): gmAttributeUI::UpdateSelection
// @0x0049DEE0 calls SetState(selected ? 6 : 1) on the row; InfoRegion::
// SetState @0x004F0EE0 forwards that to the row instantiated from THIS
// exact template (0x10000248), whose Highlight-state media is
// 0x06000F93 — a full-row background SWAP, not an overlay. The former
// 0x06001397 constant belongs to a DIFFERENT mechanism entirely: the
// spellbook row's separate selected-overlay CHILD element
// (UIElement_UIItem::SetSelectedState @0x004E1240, SpellbookRowStyle.cs)
// — that file and its tests are correct and must NOT be touched.
private const uint RowHighlightSprite = 0x06000F93u;
// 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
// DBObj::GetDIDByEnum(vitalEnum, category 0x10000003). Both categories
// are consumed through the shared RetailDataIdResolver.Resolve seam
// (the same master-map -> category-map -> value indirection already
// ported for the title chain and RetailKeyNames) rather than a fourth
// ad-hoc hardcoded DID table. Live-DAT-verified (2026-08-25): every
// hardcoded fallback value in AttrRows/VitalRows below already matches
// the resolved DID byte-exact — see
// CharacterPanelLiveDatTests.AttributeAndVitalIconDids_MatchTheRetailEnumMapperChain.
private const uint AttributeIconCategory = 0x10000002u;
private const uint VitalIconCategory = 0x10000003u;
// Scrollbar chrome from base layout 0x2100003E, shared with chat/
// inventory — sprite set + retail button seating live in
@ -270,6 +325,17 @@ public static class CharacterStatController
/// a (GL tex handle, pixel width, pixel height) triple. Pass <c>null</c> in tests where
/// icon rendering is not asserted.
/// </para>
///
/// <para>
/// CT5: <paramref name="iconDidResolve"/> ports <c>DBObj::GetDIDByEnum(enumValue,
/// category)</c> (master map -> category map -> value) for the per-attribute/
/// per-vital row icon DIDs — pass <c>(enumValue, category) =>
/// RetailDataIdResolver.Resolve(dats, enumValue, category)</c> under the
/// caller's dat lock. <c>null</c> (tests, or no live dat) falls back to
/// <see cref="AttrRows"/>/<see cref="VitalRows"/>' hardcoded DID column,
/// which the CT5 InstalledDat pin proves already matches the live-resolved
/// value byte-exact.
/// </para>
/// </summary>
/// <returns>
/// #431-CA5 gate fix (2026-08-24): the data-changed refresh. Retail's
@ -288,7 +354,8 @@ public static class CharacterStatController
UiDatFont? rowDatFont = null,
Func<uint, (uint handle, int w, int h)>? spriteResolve = null,
RaiseRequestHandler? onRaiseRequest = null,
Action? onClose = null)
Action? onClose = null,
Func<uint, uint, uint>? iconDidResolve = null)
{
// rowDatFont: larger font for attribute row name/value text (18px vs 16px default).
// Falls back to datFont when null (tests, or dat missing).
@ -656,7 +723,7 @@ public static class CharacterStatController
skillScrollbar.Visible = false;
}
currentAttributeRows = BuildAttributeRows(statList, rowDatFont, spriteResolve, data, attrSel,
allRaise1, allRaise10, SetFooterSelected);
allRaise1, allRaise10, SetFooterSelected, iconDidResolve);
activeListEntries.AddRange(currentAttributeRows);
}
else
@ -668,7 +735,7 @@ public static class CharacterStatController
Top = 0f,
Width = contentW,
Height = statList.Height,
LineHeight = (int)SkillRowHeight,
LineHeight = (int)RowHeight,
Anchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Bottom,
};
statList.AddChild(viewport);
@ -823,6 +890,34 @@ public static class CharacterStatController
: SkillContentWidth;
}
/// <summary>
/// CT5: the row's own rendered width is the AUTHORED row-template width
/// (282px, <see cref="SkillContentWidth"/>), never the ListBox's raw
/// container width (300px, <see cref="CharacterStatController.ListBoxId"/>'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.
/// </summary>
private static float RowContentWidth(UiElement list)
=> list.Width > 0f ? MathF.Min(list.Width, SkillContentWidth) : SkillContentWidth;
/// <summary>
/// CT5 (AP-235): resolve a row icon DID through the live
/// <c>DBObj::GetDIDByEnum</c> chain when a resolver is available,
/// falling back to the hardcoded <see cref="AttrRows"/>/<see cref="VitalRows"/>
/// column value otherwise (tests, or a resolve miss).
/// </summary>
private static uint ResolveIconDid(
Func<uint, uint, uint>? resolver,
uint enumValue,
uint category,
uint fallback)
{
if (resolver is null) return fallback;
uint resolved = resolver(enumValue, category);
return resolved != 0u ? resolved : fallback;
}
// ── 9-row attribute list ─────────────────────────────────────────────────
private static List<UiClickablePanel> BuildAttributeRows(
@ -833,9 +928,10 @@ public static class CharacterStatController
int[] sel,
List<UiButton> allRaise1,
List<UiButton> allRaise10,
Action<bool> setFooterSelected)
Action<bool> setFooterSelected,
Func<uint, uint, uint>? iconDidResolve)
{
float listW = list.Width;
float listW = RowContentWidth(list);
float y = 0f;
var rows = new List<UiClickablePanel>();
@ -846,7 +942,7 @@ public static class CharacterStatController
var row = AddRow(list, datFont, spriteResolve,
left: 0f, top: y, width: listW, height: RowHeight,
iconDid: iconDid,
iconDid: ResolveIconDid(iconDidResolve, statId, AttributeIconCategory, iconDid),
nameText: rowName,
valueProvider: () =>
{
@ -883,7 +979,7 @@ public static class CharacterStatController
var row = AddRow(list, datFont, spriteResolve,
left: 0f, top: y, width: listW, height: RowHeight,
iconDid: iconDid,
iconDid: ResolveIconDid(iconDidResolve, maxStatId, VitalIconCategory, iconDid),
nameText: rowName,
valueProvider: () =>
{
@ -922,7 +1018,7 @@ public static class CharacterStatController
Action<bool> setFooterSelected,
out List<SkillRowBinding> skillRows)
{
float listW = list.Width > 0f ? MathF.Min(list.Width, SkillContentWidth) : SkillContentWidth;
float listW = RowContentWidth(list);
float y = 0f;
var entries = new List<UiElement>();
var bindings = new List<SkillRowBinding>();
@ -951,7 +1047,7 @@ public static class CharacterStatController
CharacterSkill LiveSkill() =>
FindSkill(data(), skill.Id) ?? skill;
var row = AddRow(list, datFont, spriteResolve,
left: 0f, top: y, width: listW, height: SkillRowHeight,
left: 0f, top: y, width: listW, height: RowHeight,
iconDid: skill.IconDid,
nameText: skill.Name,
valueProvider: () => LiveSkill().CurrentLevel.ToString(),
@ -967,7 +1063,7 @@ public static class CharacterStatController
};
bindings.Add(new SkillRowBinding(row, skill));
entries.Add(row);
y += SkillRowHeight;
y += RowHeight;
}
}
}
@ -1131,10 +1227,10 @@ public static class CharacterStatController
Console.WriteLine($"[CharacterStat] Row click: index={clickedIndex} → selected={newSel} ({rowName})");
// Update highlight on all rows.
// Retail uses sprite 0x06001397 (Button state 6 — the dark horizontal bars)
// for the selected row background. When spriteResolve is available, apply the
// sprite; otherwise fall back to the translucent gold tint.
const uint HighlightSprite = 0x06001397u;
// CT5: retail's InfoRegion::SetState swaps the row's whole background
// 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.
for (int i = 0; i < rows.Count; i++)
{
var row = rows[i];
@ -1143,7 +1239,7 @@ public static class CharacterStatController
if (spriteResolve is not null)
{
row.BackgroundColor = Vector4.Zero;
row.BackgroundSprite = HighlightSprite;
row.BackgroundSprite = RowHighlightSprite;
row.SpriteResolve = spriteResolve;
}
else
@ -1201,14 +1297,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); };
row.UseSelectionBars = true;
}
else
{
row.BackgroundColor = HighlightBg;
row.BackgroundSprite = 0u;
row.SpriteResolve = null;
row.UseSelectionBars = false;
}
}
else
@ -1216,7 +1310,6 @@ public static class CharacterStatController
row.BackgroundColor = Vector4.Zero;
row.BackgroundSprite = 0u;
row.SpriteResolve = null;
row.UseSelectionBars = true;
}
}
}
@ -1615,9 +1708,24 @@ public static class CharacterStatController
}
/// <summary>
/// Add a single attribute/vital row to <paramref name="list"/> as a
/// <see cref="UiClickablePanel"/> containing icon + name + value children.
/// Returns the panel so the caller can wire <see cref="UiClickablePanel.OnClick"/>.
/// Add a single attribute/vital/skill row to <paramref name="list"/> as a
/// <see cref="UiClickablePanel"/> containing icon + name + value children,
/// laid out at the AUTHORED template 0x10000248 pixel geometry (icon
/// flush left 20x20, name at X=25 W=150, value at X=175 W=100
/// right-justified — see the row-layout-constants block above). Returns
/// the panel so the caller can wire <see cref="UiClickablePanel.OnClick"/>.
///
/// <para>CT5 hand-built-vs-template ruling: this row stays HAND-BUILT
/// (not converted to <c>UiTemplateListBox</c> row instantiation).
/// Converting would touch every one of the ~15 call sites this method's
/// row feeds — raise-button affordability, footer State A/B, per-row
/// tooltip, section-header bucketing, live-refresh-on-quality-change,
/// and the selection-highlight swap — for a purely cosmetic slice whose
/// authored numbers this hand-built path can already hit byte-exact
/// (proven by <c>CharacterPanelLiveDatTests.AttributeRowTemplate_...</c>).
/// That is a large-blast-radius rewrite for a geometry-only fix; the
/// smaller-risk path is implementing the authored constants directly
/// here, which this method now does.</para>
/// </summary>
private static UiClickablePanel AddRow(
UiElement list,
@ -1628,9 +1736,7 @@ public static class CharacterStatController
string nameText,
Func<string> valueProvider,
Func<Vector4>? valueColorProvider = null,
Vector4? nameColor = null,
uint backgroundSprite = 0u,
bool useSelectionBars = true)
Vector4? nameColor = null)
{
var row = new UiClickablePanel
{
@ -1649,23 +1755,20 @@ public static class CharacterStatController
Width = width,
Height = height,
BackgroundColor = Vector4.Zero, // transparent until selected
BackgroundSprite = spriteResolve is not null ? backgroundSprite : 0u,
SpriteResolve = spriteResolve is not null && backgroundSprite != 0u
? id => { var (h, w, ht) = spriteResolve(id); return (h, w, ht); }
: null,
BackgroundSprite = 0u,
SpriteResolve = null,
BorderColor = Vector4.Zero,
UseSelectionBars = useSelectionBars,
Anchors = AnchorEdges.Left | AnchorEdges.Top,
};
float iconY = (height - IconSize) * 0.5f;
// Icon 0x10000129: flush left (X=0), Y=0, 20x20 — full row height,
// no vertical centering math needed since RowIconSize == RowHeight.
var iconEl = new UiText
{
Left = RowPadX,
Top = iconY,
Width = IconSize,
Height = IconSize,
Left = RowIconX,
Top = 0f,
Width = RowIconSize,
Height = RowIconSize,
ClickThrough = true,
DatFont = null,
BackgroundSprite = spriteResolve is not null ? iconDid : 0u,
@ -1676,17 +1779,15 @@ public static class CharacterStatController
Anchors = AnchorEdges.Left | AnchorEdges.Top,
};
float nameX = RowPadX + IconSize + IconGap;
float nameW = width * 0.60f;
float nameY = 0f;
// Name 0x1000012A: X=25 W=150 — fixed authored pixels, not a
// width-relative fraction.
string capturedName = nameText;
Vector4 capturedNameColor = nameColor ?? Body;
var nameEl = new UiText
{
Left = nameX,
Top = nameY,
Width = nameW,
Left = RowNameX,
Top = 0f,
Width = RowNameW,
Height = height,
DatFont = datFont,
ClickThrough = true,
@ -1697,14 +1798,14 @@ public static class CharacterStatController
};
nameEl.LinesProvider = () => new[] { new UiText.Line(capturedName, capturedNameColor) };
float valueW = width - nameX - nameW - RowPadX;
float valueX = nameX + nameW;
// Value 0x1000012B: X=175 W=100, right-justified — its right edge
// (275) sits 7px short of the row's own 282px right edge, the
// authored gutter the owner reported.
var valueEl = new UiText
{
Left = valueX,
Top = nameY,
Width = valueW > 0f ? valueW : 40f,
Left = RowValueX,
Top = 0f,
Width = RowValueW,
Height = height,
DatFont = datFont,
ClickThrough = true,

View file

@ -71,15 +71,18 @@ public sealed class RetailAppraisalNameResolver
public string ResolveCreature(int creatureType)
=> _creatures.Resolve(creatureType);
// Campaign CT slice CT5 (2026-08-25): the three hardcoded overrides this
// method used to re-implement inline (2/5/13 -> "Gharu'ndim"/"Umbraen"/
// "Olthoi") are dead duplication — CharacterIdentityText.HeritageGroupDisplayName
// already bakes the identical AppraisalSystem::InqHeritageGroupDisplayName
// overrides into its own switch (including id 12, which this method used
// to leave to the fallback branch and get the same "Olthoi" answer
// anyway). One owner for the override table now; behavior is unchanged
// (byte-identical for every heritage id — verified by the existing
// GenderHeritageDisplayNameTables_MatchTheRetailEnumMapperChain pin,
// which exercises the shared table directly).
public string ResolveHeritage(int heritageGroup)
=> heritageGroup switch
{
2 => "Gharu'ndim",
5 => "Umbraen",
13 => "Olthoi",
_ => CharacterIdentityText.HeritageGroupDisplayName(heritageGroup)
?? string.Empty,
};
=> CharacterIdentityText.HeritageGroupDisplayName(heritageGroup) ?? string.Empty;
public string ResolveMaterial(int materialType)
=> materialType > 0

View file

@ -4038,6 +4038,17 @@ public sealed class RetailUiRuntime : IDisposable
}
CharacterSheetProvider provider = _bindings.Character.Provider;
CharacterSheet currentSheet = provider.BuildSheet();
// CT5 (AP-235): resolve per-attribute/per-vital row icon DIDs through
// the live DBObj::GetDIDByEnum chain (RetailDataIdResolver.Resolve)
// instead of leaving CharacterStatController's hardcoded fallback as
// the only source. DatCollection is documented not thread-safe, so
// this closure takes the same DatLock every other dat-touching
// delegate below (TitleTemplateResolver/TitleResolver) already uses.
uint IconDidResolve(uint enumValue, uint category)
{
lock (_bindings.Assets.DatLock)
return RetailDataIdResolver.Resolve(_bindings.Assets.Dats, enumValue, category);
}
Action refreshRows = CharacterStatController.Bind(
layout,
() => currentSheet,
@ -4045,7 +4056,8 @@ public sealed class RetailUiRuntime : IDisposable
_bindings.Assets.ResolveFont(0x40000001u) ?? _bindings.Assets.DefaultFont,
_bindings.Assets.ResolveSprite,
(request, completed) => HandleCharacterRaise(provider, request, completed),
() => CloseWindow(WindowNames.Character));
() => CloseWindow(WindowNames.Character),
IconDidResolve);
// #431-CA5 gate fix: rebuild the ROWS on every authoritative sheet
// change, not only on clicks — a train's skill record must move the
// row to the trained section (and un-ghost the raise controls) the

View file

@ -26,8 +26,10 @@ public class UiPanel : UiElement
/// <summary>Optional dat RenderSurface id for the panel background sprite, drawn
/// in place of (or alongside) <see cref="BackgroundColor"/>. 0 = none.
/// When set, the sprite is stretched to fill the panel rect.
/// Used by the attribute-list selected-row highlight (sprite 0x06001397 = Button state 6).</summary>
/// When set, the sprite is stretched to fill the panel rect — the same
/// full-swap semantics retail's <c>InfoRegion::SetState</c> uses. Used by
/// the character-panel attribute/skill row's selected-row highlight
/// (sprite 0x06000F93, template 0x10000248's Highlight-state media).</summary>
public uint BackgroundSprite { get; set; }
/// <summary>Resolves a dat RenderSurface id to (GL tex handle, pixel width, pixel height).
@ -191,29 +193,22 @@ public class UiSimpleButton : UiPanel
/// parent window. In acdream we wire the equivalent via this action callback instead of
/// the retail message bus.</para>
///
/// <para>When <see cref="UseSelectionBars"/> is true and <see cref="UiPanel.BackgroundSprite"/>
/// is non-zero, draws the sprite as a thin full-width bar at the TOP and BOTTOM edges of
/// the row (not stretched to fill). This matches retail's selection highlight which shows
/// a horizontal dark bar on both the top and bottom edge of the selected attribute row,
/// with NO left/right end-caps. Bar height is <see cref="SelectionBarHeight"/> pixels
/// (default 3px).</para>
/// <para>Campaign CT slice CT5 (2026-08-25): the selected-row highlight draws through the
/// inherited <see cref="UiPanel.OnDraw"/> full-panel <see cref="UiPanel.BackgroundSprite"/>
/// 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 (<c>InfoRegion::SetState</c>, media 0x06000F93) is a plain full-row
/// background SWAP — exactly what the inherited <see cref="UiPanel.OnDraw"/> already
/// draws. The bars mode was therefore retired rather than reconfigured to a wrong sprite's
/// geometry; no consumer outside <c>CharacterStatController</c> ever set it.</para>
/// </summary>
public class UiClickablePanel : UiPanel
{
/// <summary>Called when the user releases the left mouse button over this panel.</summary>
public Action? OnClick { get; set; }
/// <summary>When true and <see cref="UiPanel.BackgroundSprite"/> is non-zero, draws
/// the sprite as a thin horizontal bar at the top AND bottom edges of the panel,
/// NOT as a full-height stretched fill. Matches retail's selected-row highlight
/// (sprite 0x06001397 — 300×32 px — shown as bars, not a block fill).
/// Default false (preserves legacy full-stretch behavior).</summary>
public bool UseSelectionBars { get; set; }
/// <summary>Height in pixels of each selection bar (top and bottom). Default 3px.
/// Ignored when <see cref="UseSelectionBars"/> is false.</summary>
public float SelectionBarHeight { get; set; } = 3f;
/// <summary>Settable tooltip, surfaced through the shared
/// <see cref="UiElement.GetTooltipText"/> hover pipeline (same pattern as
/// <see cref="UiButton.TooltipText"/> / <see cref="UiCatalogSlot"/>). TS-85's
@ -250,33 +245,4 @@ public class UiClickablePanel : UiPanel
}
return false;
}
protected override void OnDraw(UiRenderContext ctx)
{
if (UseSelectionBars && BackgroundSprite != 0 && SpriteResolve is { } sr)
{
// Draw the selection highlight as a thin bar at the TOP and BOTTOM of the row.
// The sprite (0x06001397) is 300×32 px — we draw it as horizontal strips at
// native height (SelectionBarHeight), stretched to full panel width (UV tile
// horizontally). No left/right end-caps: u0=0, u1=Width/nativeW (UV repeat).
var (tex, tw, th) = sr(BackgroundSprite);
if (tex != 0 && tw > 0 && th > 0)
{
float barH = SelectionBarHeight;
float uTile = tw > 0 ? Width / tw : 1f;
// Top bar: shows the top barH px of the sprite (v = 0 → barH/th).
float vBot = th > 0 ? barH / th : 1f;
ctx.DrawSprite(tex, 0f, 0f, Width, barH, 0f, 0f, uTile, vBot, Vector4.One);
// Bottom bar: shows the bottom barH px of the sprite (v = 1barH/th → 1).
float vTop2 = th > 0 ? 1f - barH / th : 0f;
ctx.DrawSprite(tex, 0f, Height - barH, Width, barH, 0f, vTop2, uTile, 1f, Vector4.One);
}
// Selection-bar mode draws no border (rows have BorderColor=Zero by design).
}
else
{
// Default UiPanel draw: handles BackgroundSprite, BackgroundColor, AND border.
base.OnDraw(ctx);
}
}
}