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

@ -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' <c>Attribute2ndInfoRegion</c> icon (verified live: the max-vital
/// enum ids 1/3/5 that <c>CharacterStatController.VitalRows</c> 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 <c>RetailDataIdResolver.Resolve</c> master-map ->
/// category-map -> value chain — this pin proves every hardcoded fallback
/// DID in <c>AttrRows</c>/<c>VitalRows</c> 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 <c>RetailDataIdResolver.Resolve</c> master-map -> category-map
/// -> value chain — this pin proves every hardcoded fallback DID in
/// <c>AttrRows</c>/<c>VitalRows</c> (iterated directly, not re-typed)
/// already matches the live-resolved value byte-exact, the same
/// "regression guard, not a bug pin" pattern as
/// <see cref="SkillSectionHeaderTemplates_MatchExistingSpriteConstants"/>.
/// </summary>
[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);
}
}
}