using System.IO; using AcDream.App.UI; using AcDream.App.UI.Layout; using DatReaderWriter; using DatReaderWriter.Options; namespace AcDream.App.Tests.UI.Layout; /// /// #382 (Campaign OP gate 4, 2026-08-11): the main chat window's four /// floating-window indicator buttons (0x10000522-0x10000525, /// ChatWindowController's own Indicator1Id..Indicator4Id) /// rendered NOTHING at rest — only a mouse hover revealed the correct orange /// numbered-button art. /// /// /// ROOT CAUSE (found via a reference-identity-verified live-DAT probe, /// not a guess): the buttons' OWN construction resolves ActiveState= /// "Normal" correctly (DefaultStateName="Normal", StateMedia has both /// "Normal"/"Highlight"). The BUG fires immediately afterward, still inside /// the SAME LayoutImporter.Build call: the indicator column's backing /// panel (0x10000600) authors PassToChildren=true on its OWN /// empty DirectState (verified live: States[0xFFFFFFFF].PassToChildren == /// true, presumably intended to route a DIFFERENT pair of named states — /// HideDetail/ShowDetail — to an unrelated sibling, not Normal/Highlight to /// these buttons). LayoutImporter.BuildWidget 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, 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 /// _mediaInfo.States 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 /// UiButtonTests.AddBoolProperty), so TryFindState(DirectStateId) /// found that entry and blanked ActiveState to "" even though /// StateMedia has no "" key at all. A synthetic hover "fixed" it only /// because UiButtonStateMachine.RequestedState resolves to the SAME /// canonical Normal id regardless of PointerOver when RolloverEnabled is /// false, so the NEXT UpdateVisualState() call (from the hover event) /// re-picks "Normal" from _availableStates — the construction-time /// blanking was a one-shot event, not a persistent "always blank" state. /// /// /// /// Fix (): the DirectStateId /// branch now requires REAL "" media (HasStateMedia("")) before /// accepting the transition — a structurally-present-but-media-less States /// entry no longer counts. This is scoped to UiButton only; the /// cascade mechanism itself and LayoutImporter.BuildWidget's reapply /// ordering are UNCHANGED, so CharacterStatController's own /// PassToChildren-driven chrome children (which rely on the SAME cascade, /// see its own class doc) are unaffected. /// /// 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; } }