fix #382: chat-window indicator buttons invisible until first hovered

Root cause (found via reference-identity-verified live-DAT probing, not
a guess): the four main-chat-window indicator buttons (0x10000522-
0x10000525) resolve their own correct ActiveState="Normal" at
construction, then get blanked to "" moments later in the SAME
LayoutImporter.Build call. The indicator column's backing panel
(0x10000600) authors PassToChildren=true on its own empty DirectState
(confirmed live: States[0xFFFFFFFF].PassToChildren == true); when
LayoutImporter.BuildWidget's post-attach state reapply runs for that
panel, UiDatElement.TrySetRetailState cascades its DirectStateId to
every IUiDatStateful child, including the already-correctly-resolved
buttons. UiButton.TrySetRetailState's DirectStateId branch used to
accept that cascade because every button structurally carries a
DirectStateId entry in its States dict as a property bag (ToggleBehavior/
RolloverEnabled/etc), independent of whether it authors any blank
sprite, so TryFindState(DirectStateId) found that entry and blanked
ActiveState even with no "" media. A hover "fixed" it only because
UiButtonStateMachine.RequestedState resolves to the same canonical
Normal id regardless of PointerOver when RolloverEnabled is false.

Retail's own decompiled UIElement::SetState @0x00464e70 does the exact
same unconditional-commit-plus-cascade; retail avoids this specific bug
purely through construction timing (UIElement::Initialize's SetState
call precedes child-tree construction, so a cascade fired during import
always iterates zero children). Our port's LayoutImporter.BuildWidget
deliberately reapplies in the opposite order to give retained
PassToChildren tabs their authored child media, so this literal
state-machine port needed a compensating guard.

Fix: UiButton.TrySetRetailState's DirectStateId branch now requires
REAL "" media (HasStateMedia("")) before accepting the transition.
Scoped to UiButton only; UiDatElement's parallel branch and the cascade
mechanism are unchanged, so CharacterStatController's own
PassToChildren-driven chrome children are unaffected. Register row
AP-206 records the divergence from retail's literal unconditional-
commit semantics. Regressed by two fast unit tests in UiButtonTests.cs
(DirectStateCascade_WithoutRealMedia_DoesNotBlankAnAlreadyResolvedState,
DirectStateTransition_WithRealMedia_StillSucceeds) plus a live-mount
probe confirming all four buttons resolve ActiveState="Normal"
immediately after import against the real installed DAT.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-11 23:03:31 +02:00
parent a31fd631ad
commit d1c60df946
6 changed files with 292 additions and 10 deletions

View file

@ -251,7 +251,31 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
if (stateId == UiStateInfo.DirectStateId)
{
if (!TryFindState(stateId, out _) && !HasStateMedia(""))
// #382: a DirectState transition only actually applies when the button
// has REAL "" media to show. Every button structurally carries a
// DirectStateId entry in _mediaInfo.States purely as the property bag
// for its own base-level dat properties (ToggleBehavior 0x0B, RolloverEnabled
// 0x13, etc. — see UiButtonTests.AddBoolProperty), independent of whether
// it authors any blank/unnamed sprite. TryFindState(DirectStateId) succeeding
// on that property-only entry used to be enough to accept the transition
// (the retail-decompiled UIElement::SetState @0x00464e70 commits m_curStateDesc
// unconditionally once ElementDesc::AccessStateDesc finds ANY StateDesc, media
// or not — retail's OWN buttons dodge the resulting blank draw purely through
// construction TIMING: Initialize()'s SetState(m_defaultState) call happens
// before m_children is populated, so a PassToChildren cascade from an ancestor
// can never reach an already-initialized child during import). Our port's
// LayoutImporter.BuildWidget deliberately reapplies a PARENT's default AFTER its
// children are built (so retained PassToChildren tabs get their authored
// Open/Closed child media — see that method's own comment), which means a
// chrome ancestor's structural (media-less) DirectState — e.g. the floating
// chat window's indicator-button backing panel, which authors PassToChildren=
// true on its own empty DirectState purely to route HideDetail/ShowDetail to
// an unrelated sibling — can and does reach an already-correctly-resolved
// button (ActiveState="Normal") and blank it before first paint. Requiring
// actual "" media closes that gap without touching the reapply ordering (which
// CharacterStatController's three-chrome-children PassToChildren cascade still
// depends on) or the cascade mechanism itself (both remain faithful ports).
if (!HasStateMedia(""))
return false;
ActiveState = "";
return true;