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

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