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>
141 lines
7.2 KiB
C#
141 lines
7.2 KiB
C#
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;
|
|
}
|
|
}
|