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:
parent
a31fd631ad
commit
d1c60df946
6 changed files with 292 additions and 10 deletions
|
|
@ -0,0 +1,141 @@
|
|||
using System.IO;
|
||||
using AcDream.App.UI;
|
||||
using AcDream.App.UI.Layout;
|
||||
using DatReaderWriter;
|
||||
using DatReaderWriter.Options;
|
||||
|
||||
namespace AcDream.App.Tests.UI.Layout;
|
||||
|
||||
/// <summary>
|
||||
/// #382 (Campaign OP gate 4, 2026-08-11): the main chat window's four
|
||||
/// floating-window indicator buttons (<c>0x10000522</c>-<c>0x10000525</c>,
|
||||
/// <c>ChatWindowController</c>'s own <c>Indicator1Id</c>..<c>Indicator4Id</c>)
|
||||
/// rendered NOTHING at rest — only a mouse hover revealed the correct orange
|
||||
/// numbered-button art.
|
||||
///
|
||||
/// <para>
|
||||
/// <b>ROOT CAUSE</b> (found via a reference-identity-verified live-DAT probe,
|
||||
/// not a guess): the buttons' OWN construction resolves <c>ActiveState=
|
||||
/// "Normal"</c> correctly (DefaultStateName="Normal", StateMedia has both
|
||||
/// "Normal"/"Highlight"). The BUG fires immediately afterward, still inside
|
||||
/// the SAME <c>LayoutImporter.Build</c> call: the indicator column's backing
|
||||
/// panel (<c>0x10000600</c>) authors <c>PassToChildren=true</c> on its OWN
|
||||
/// empty DirectState (verified live: <c>States[0xFFFFFFFF].PassToChildren ==
|
||||
/// true</c>, presumably intended to route a DIFFERENT pair of named states —
|
||||
/// HideDetail/ShowDetail — to an unrelated sibling, not Normal/Highlight to
|
||||
/// these buttons). <c>LayoutImporter.BuildWidget</c> reapplies every
|
||||
/// widget's own default state AFTER its children are attached (so retained
|
||||
/// PassToChildren TABS get their authored Open/Closed child media); when the
|
||||
/// PANEL's OWN reapply runs, <c>UiDatElement.TrySetRetailState</c> cascades
|
||||
/// its DirectStateId to EVERY <c>IUiDatStateful</c> child — including the
|
||||
/// ALREADY-CORRECTLY-RESOLVED buttons. <c>UiButton.TrySetRetailState</c>'s
|
||||
/// DirectStateId branch used to accept that cascade because
|
||||
/// <c>_mediaInfo.States</c> structurally carries a DirectStateId entry on
|
||||
/// EVERY button (it is the property bag for ToggleBehavior/RolloverEnabled/
|
||||
/// etc, independent of whether the button authors any blank sprite — see
|
||||
/// <c>UiButtonTests.AddBoolProperty</c>), so <c>TryFindState(DirectStateId)</c>
|
||||
/// found that entry and blanked <c>ActiveState</c> to "" even though
|
||||
/// <c>StateMedia</c> has no "" key at all. A synthetic hover "fixed" it only
|
||||
/// because <c>UiButtonStateMachine.RequestedState</c> resolves to the SAME
|
||||
/// canonical Normal id regardless of PointerOver when RolloverEnabled is
|
||||
/// false, so the NEXT <c>UpdateVisualState()</c> call (from the hover event)
|
||||
/// re-picks "Normal" from <c>_availableStates</c> — the construction-time
|
||||
/// blanking was a one-shot event, not a persistent "always blank" state.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Fix</b> (<see cref="UiButton.TrySetRetailState"/>): the DirectStateId
|
||||
/// branch now requires REAL "" media (<c>HasStateMedia("")</c>) before
|
||||
/// accepting the transition — a structurally-present-but-media-less States
|
||||
/// entry no longer counts. This is scoped to <c>UiButton</c> only; the
|
||||
/// cascade mechanism itself and <c>LayoutImporter.BuildWidget</c>'s reapply
|
||||
/// ordering are UNCHANGED, so <c>CharacterStatController</c>'s own
|
||||
/// PassToChildren-driven chrome children (which rely on the SAME cascade,
|
||||
/// see its own class doc) are unaffected.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class ChatIndicatorButtonLiveMountProbeTests
|
||||
{
|
||||
[Fact]
|
||||
public void IndicatorButtons_ResolveNormalStateAtRest_ThroughTheLiveImportPath()
|
||||
{
|
||||
if (Environment.GetEnvironmentVariable("ACDREAM_PROBE_LIVE_MOUNT") != "1")
|
||||
return;
|
||||
|
||||
var datDir = Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR")
|
||||
?? Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
|
||||
"Documents",
|
||||
"Asheron's Call");
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
var strings = new DatStringResolver(dats);
|
||||
|
||||
// The PRODUCTION main chat window layout (ChatWindowController.LayoutId),
|
||||
// not the standalone popup-catalog id VendorUiController's own doc cites
|
||||
// for a DIFFERENT purpose (the channel menu's popup catalog).
|
||||
ElementInfo? rootInfo = LayoutImporter.ImportInfos(dats, ChatWindowController.LayoutId);
|
||||
Assert.NotNull(rootInfo);
|
||||
|
||||
// Confirm the root cause is still live in the DAT (documents WHY this bug
|
||||
// class exists — if this ever flips false, the fix below becomes inert but
|
||||
// harmless, so this is a documentation assertion, not a fix precondition).
|
||||
ElementInfo? parentInfo = FindInfo(rootInfo!, 0x10000600u);
|
||||
Assert.NotNull(parentInfo);
|
||||
Assert.True(
|
||||
parentInfo!.States.TryGetValue(UiStateInfo.DirectStateId, out UiStateInfo? parentDirect)
|
||||
&& parentDirect.PassToChildren,
|
||||
"the indicator column's backing panel (0x10000600) no longer authors " +
|
||||
"PassToChildren on its own DirectState — the #382 root-cause premise has " +
|
||||
"changed; re-verify the fix is still needed.");
|
||||
|
||||
ImportedLayout layout = LayoutImporter.Build(
|
||||
rootInfo!,
|
||||
resolve: _ => (1u, 16, 16), // fake non-zero texture handle — sprite CONTENT doesn't matter here
|
||||
datFont: null,
|
||||
fontResolve: null,
|
||||
stringResolve: strings.Resolve);
|
||||
|
||||
uint[] indicatorIds = { 0x10000522u, 0x10000523u, 0x10000524u, 0x10000525u };
|
||||
foreach (uint id in indicatorIds)
|
||||
{
|
||||
UiElement? el = layout.FindElement(id);
|
||||
Assert.True(el is UiButton, $"0x{id:X8} did not build as a UiButton (was {el?.GetType().Name ?? "null"}).");
|
||||
var button = (UiButton)el!;
|
||||
|
||||
// The regression: this used to read "" immediately after import, because
|
||||
// the parent panel's PassToChildren cascade (confirmed above) blanked it
|
||||
// AFTER the button's own construction had already resolved "Normal".
|
||||
Assert.Equal("Normal", button.ActiveState);
|
||||
|
||||
// TrySetRetailState(Normal) — the SAME call LayoutImporter's own reapply
|
||||
// makes — must still work normally now that ToggleBehavior routes it
|
||||
// through the Selected setter.
|
||||
Assert.True(button.TrySetRetailState(UiButtonStateMachine.Normal));
|
||||
Assert.Equal("Normal", button.ActiveState);
|
||||
|
||||
// A cascaded DirectStateId (what the parent's reapply actually sends)
|
||||
// must now be REJECTED rather than silently blanking an already-good
|
||||
// state — the heart of the fix.
|
||||
Assert.False(button.TrySetRetailState(UiStateInfo.DirectStateId));
|
||||
Assert.Equal("Normal", button.ActiveState);
|
||||
|
||||
// Hover/leave still behave exactly as before (RolloverEnabled=false means
|
||||
// PointerOver never changes the resolved state).
|
||||
button.OnEvent(new UiEvent(0, button, UiEventType.HoverEnter));
|
||||
Assert.Equal("Normal", button.ActiveState);
|
||||
button.OnEvent(new UiEvent(0, button, UiEventType.HoverLeave));
|
||||
Assert.Equal("Normal", button.ActiveState);
|
||||
}
|
||||
}
|
||||
|
||||
private static ElementInfo? FindInfo(ElementInfo node, uint id)
|
||||
{
|
||||
if (node.Id == id) return node;
|
||||
foreach (ElementInfo child in node.Children)
|
||||
{
|
||||
ElementInfo? found = FindInfo(child, id);
|
||||
if (found is not null) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
@ -162,6 +162,46 @@ public class UiButtonTests
|
|||
Assert.Equal("Normal", b.ActiveState);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DirectStateCascade_WithoutRealMedia_DoesNotBlankAnAlreadyResolvedState()
|
||||
{
|
||||
// #382: LayoutImporter.BuildWidget's post-attach state reapply cascades a
|
||||
// PARENT's PassToChildren DirectState to EVERY IUiDatStateful child (the
|
||||
// chat window's indicator-button backing panel, 0x10000600, authors exactly
|
||||
// this). Every button structurally carries a DirectStateId entry in its own
|
||||
// States dict purely as the property bag for ToggleBehavior/RolloverEnabled/
|
||||
// etc (see AddBoolProperty below) — that structural presence must NOT be
|
||||
// enough to accept a DirectState transition when the button has no real ""
|
||||
// media, or an ancestor's unrelated cascade blanks an already-correct
|
||||
// "Normal" resolution before first paint.
|
||||
var info = ButtonInfo("Normal", "Highlight");
|
||||
AddBoolProperty(info, 0x13u, true); // RolloverEnabled — populates States[DirectStateId]
|
||||
var b = CreateButton(info);
|
||||
Assert.Equal("Normal", b.ActiveState);
|
||||
|
||||
bool ok = b.TrySetRetailState(UiStateInfo.DirectStateId);
|
||||
|
||||
Assert.False(ok);
|
||||
Assert.Equal("Normal", b.ActiveState);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DirectStateTransition_WithRealMedia_StillSucceeds()
|
||||
{
|
||||
// The companion positive case: a button that legitimately authors ""
|
||||
// (DirectState) media must still be able to transition to it explicitly —
|
||||
// the fix narrows the check to "has real media", it does not disable the
|
||||
// DirectState branch outright.
|
||||
var info = ButtonInfo("Normal");
|
||||
info.StateMedia[""] = (7u, 1);
|
||||
var b = CreateButton(info);
|
||||
|
||||
bool ok = b.TrySetRetailState(UiStateInfo.DirectStateId);
|
||||
|
||||
Assert.True(ok);
|
||||
Assert.Equal("", b.ActiveState);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HotClick_FiresImmediatelyRepeatsAndSuppressesReleaseClick()
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue