From d1c60df946e76ad65b39d072f9fe751fa8bc0fa5 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 11 Aug 2026 23:03:31 +0200 Subject: [PATCH] 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 --- docs/ISSUES.md | 87 +++++++++-- .../retail-divergence-register.md | 1 + .../2026-08-11-campaign-op-test-script.md | 7 + src/AcDream.App/UI/UiButton.cs | 26 +++- .../ChatIndicatorButtonLiveMountProbeTests.cs | 141 ++++++++++++++++++ tests/AcDream.App.Tests/UI/UiButtonTests.cs | 40 +++++ 6 files changed, 292 insertions(+), 10 deletions(-) create mode 100644 tests/AcDream.App.Tests/UI/Layout/ChatIndicatorButtonLiveMountProbeTests.cs diff --git a/docs/ISSUES.md b/docs/ISSUES.md index da0fa852..757cb467 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -26,15 +26,84 @@ What does NOT go here: ## #382 — Floating chat-window tab buttons are invisible until first hovered -**Status:** OPEN — filed 2026-08-11 at Campaign OP gate 4 (user report with -screenshots). The side chat windows' numbered tab buttons render NOTHING in -their resting state — only once the pointer hovers one does it paint its -correct orange numbered-button art (the user's third screenshot shows the -correct post-hover look). So the un-hovered/Normal state's media is not -being drawn (or the button starts in a state with no StateDesc media) while -the Highlight state works. Investigate the CH6 chat-window tab buttons' -state/sprite wiring at the live mount — evidence first (which state id the -button starts in, what media that state resolves) — before fixing. +**Status:** ROOT-CAUSED + FIXED (this commit) — pending the user's re-gate. +Filed 2026-08-11 at Campaign OP gate 4 (user report: nothing renders at +rest on the four main-chat-window indicator buttons — `0x10000522`- +`0x10000525`, `ChatWindowController.Indicator1Id`-`Indicator4Id` — hover +reveals the correct orange art). + +**A prior session's extensive static trace found no bug and left a +live-mount probe for this session** (see the earlier revision of this +entry, preserved in git history). Running that probe with reference- +identity verification (`RuntimeHelpers.GetHashCode` + `ReferenceEquals` +against a build-time-captured instance, not just re-reading a possibly- +different widget) found the actual defect: the SAME `UiButton` instance +resolves `ActiveState="Normal"` correctly at its own construction, then +gets blanked to `""` moments later — still inside the SAME +`LayoutImporter.Build` call, before the probe ever reads it. + +**ROOT CAUSE.** The indicator column's backing panel (`0x10000600`) +authors `PassToChildren=true` on its OWN empty DirectState (confirmed +live: `States[0xFFFFFFFF].PassToChildren == true` — almost certainly +intended to route the panel's OTHER 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 — see that method's own comment); +when the PANEL's reapply runs, `UiDatElement.TrySetRetailState` cascades +its DirectStateId to every `IUiDatStateful` child, including the four +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` 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 blanking was a one-shot +construction-time event, not a persistent state. + +Retail's own decompiled `UIElement::SetState @0x00464e70` does the exact +same unconditional-commit-plus-blind-cascade (`ElementDesc::AccessStateDesc` +finding ANY StateDesc, media or not, is enough to commit `m_curStateDesc`/ +`m_state` and cascade to every child when `PassToChildren` is set). +Retail avoids this specific bug purely through construction TIMING: +`UIElement::Initialize`'s `SetState(m_defaultState)` call is literally the +second operation in the function, before any child-tree construction — +so a PassToChildren cascade fired during import always iterates ZERO +children in retail. Our port's `LayoutImporter.BuildWidget` deliberately +reapplies in the opposite order (children built first, then the parent's +default is reapplied and cascades DOWN into the now-existing children), +which is what makes this literal state-machine port hit a case retail's +own timing never exercises. + +**Fix:** `UiButton.TrySetRetailState`'s DirectStateId branch now requires +REAL `""` media (`HasStateMedia("")`) before accepting the transition — a +structurally-present-but-media-less States entry no longer counts. Scoped +to `UiButton` only; `UiDatElement.TrySetRetailState`'s parallel branch and +the cascade mechanism itself are unchanged, so `CharacterStatController`'s +own three-chrome-children PassToChildren cascade (which depends on the +SAME reapply ordering) is unaffected. Register row AP-206 records the +divergence from retail's literal unconditional-commit semantics. +Regressed by two new fast unit tests in +`tests/AcDream.App.Tests/UI/UiButtonTests.cs` +(`DirectStateCascade_WithoutRealMedia_DoesNotBlankAnAlreadyResolvedState`, +`DirectStateTransition_WithRealMedia_StillSucceeds` — the companion +positive case, confirming an AUTHORED blank DirectState can still be +entered explicitly) plus a rewritten, now-asserting live-mount probe +(`ChatIndicatorButtonLiveMountProbeTests. +IndicatorButtons_ResolveNormalStateAtRest_ThroughTheLiveImportPath`, +`ACDREAM_PROBE_LIVE_MOUNT=1`) that confirms the fix against the real +installed DAT: all four buttons now resolve `ActiveState="Normal"` +immediately after import, with no hover required. + +**Re-gate:** the four floating-window indicator buttons (mail/chat-tab +style LEDs at the top-left of the main chat window) should show their +correct orange numbered art immediately on window open, with no hover +needed. ## #381 — Options-panel footer (Apply/Reset/Defaults) needs an opaque backing field; list content shows through between the buttons diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index a887cbde..26522175 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -184,6 +184,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | |---|---|---|---|---|---| +| AP-206 | **Filed 2026-08-11 at Campaign OP gate 4 (#382).** `UiButton.TrySetRetailState`'s DirectStateId branch now requires REAL `""`-keyed media (`HasStateMedia("")`) before accepting a DirectState transition; a `_mediaInfo.States` entry that exists ONLY as a property bag (every button carries one, holding ToggleBehavior/RolloverEnabled/etc regardless of whether it authors blank media) no longer counts. A reference-identity-verified live-DAT probe found the chat window's four floating-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`), and `LayoutImporter.BuildWidget`'s post-attach state reapply (needed so retained PassToChildren TABS get their authored Open/Closed child media) cascades that DirectState to every `IUiDatStateful` child — including these already-correctly-resolved buttons. Retail's own decompiled `UIElement::SetState @0x00464e70` commits its `m_curStateDesc`/`m_state` unconditionally once `ElementDesc::AccessStateDesc` finds ANY StateDesc (media or not) and does the exact same blind per-child cascade; retail avoids this exact bug purely through construction TIMING — `UIElement::Initialize`'s `SetState(m_defaultState)` call is the SECOND operation in the function, before any child-tree construction, so a PassToChildren cascade fired during import always iterates zero children in retail. Our port's `LayoutImporter.BuildWidget` deliberately reapplies AFTER children are attached (the opposite order), so this literal 1:1 state-machine port needed a compensating guard rather than a full reapply-ordering rewrite (out of scope for this fix; `CharacterStatController`'s own three-chrome-children PassToChildren cascade depends on the current ordering and is left untouched). | `src/AcDream.App/UI/UiButton.cs` (`TrySetRetailState`'s `stateId == UiStateInfo.DirectStateId` branch) | Scoped to `UiButton` only — `UiDatElement.TrySetRetailState`'s parallel DirectStateId branch (and the cascade mechanism itself) are UNCHANGED, so every existing PassToChildren consumer keeps its current behavior; the fix only stops an UNRELATED ancestor's cascade from overriding a button's OWN already-resolved, independently authored state with an empty one it never asked for. | If a future button is EVER meant to render literally blank at rest via a cascaded DirectState with no authored `""` media, this guard would reject that transition (falls back to its previous `ActiveState`) — no such button is known to exist today; `UiButtonTests.DirectStateTransition_WithRealMedia_StillSucceeds` documents that an AUTHORED blank state still works. | `UIElement::SetState @0x00464e70` (cascade + unconditional commit); `UIElement::Initialize @0x00462c90` (SetState call precedes child construction) — both in `docs/research/named-retail/acclient_2013_pseudo_c.txt` | | AP-205 | **Filed 2026-08-11 at Campaign OP gate 4 (#381).** The Apply/Reset/Defaults footer on the Character/Chat/Config tabs draws an opaque, borderless backing field (`UiSolidSpriteFill`, tiling `RetailChromeSprites.CenterFill` — the SAME panel-background sprite the Options window's own `UiNineSlicePanel` chrome already tiles behind everything) behind the three buttons. A live-DAT probe (scratch console app against `DatCollectionAdapter`, 2026-08-11) found retail authors NO such element: each page root (`0x100001F9`/`0x100001FF`/`0x1000050A`) has EXACTLY five children — the row ListBox, its scrollbar, and the three physical buttons — with zero direct-state media on the root itself. Scrolled row content therefore bled through visibly between/behind the buttons before this fix. | `src/AcDream.App/UI/UiSolidSpriteFill.cs`; `src/AcDream.App/UI/Layout/OptionsPanelController.cs` (`AddFooterBacking`) | Reusing the SAME sprite the rest of the window's chrome already draws keeps the synthesized field visually indistinguishable from an authored one rather than inventing a new color; the field is `ClickThrough=true` and z-ordered strictly behind every other child, so it cannot intercept input or occlude the buttons themselves. | A reviewer comparing a byte-exact retail screenshot to acdream will see one extra opaque rect retail never authors — cosmetically invisible (it exactly matches the surrounding chrome), so the only observable difference IS the fix (content no longer bleeding through). If a future page's footer strip ever needs a DIFFERENT background (a themed panel, a translucent tab), this hardcoded `CenterFill` reuse would need revisiting. | Live-DAT probe, 2026-08-11 (page-root child-count/direct-state-media dump against `client_local_English.dat`, LayoutDescs `0x21000028`/`0x21000029`/`0x2100005C`) — no retail element to cite since none exists | | ~~AP-201~~ | **RETIRED 2026-08-11 at the Campaign OP gate-3 fix round (closes #371).** UiScrollablePanel now marks ClipsChildren=true (the draw walk and hit-test both route through UiRenderContext.PushClip, which existed by retirement time) and its cull predicate keeps any INTERSECTING row visible - a straddling row renders its visible slice instead of vanishing whole. The user-observed symptom this row predicted (the Chat tab per-window filter blocks reading as MISSING at the default scroll offset, gate 3) is the exact acceptance evidence. Original filing follows for the record: filed at the OP5 review-fix round (S2), predates OP5 but was made user-visible by it. `UiTemplateListBox`'s internal row viewport (`UiScrollablePanel.LayoutScrollableChildren`) culls a child WHOLE — `child.Visible = top >= -0.5f && top + child.Height <= Height + 0.5f` — rather than clipping the visible portion of a row that straddles the viewport edge, because the UI renderer has no scissor stack. Retail's own `UIElement_ListBox`/scroll-region rendering clips partially-visible rows at the pixel boundary, same as any native scroll view. Every row in this viewport was 8-36px until Campaign OP slice OP5 added five self-sized filter blocks (12x20=240px / 13x20=260px, AP-195) to the Chat tab's ~560px viewport; a 240-260px block straddling the viewport edge at a given scroll offset now disappears ENTIRELY (a visible "pop") instead of clipping, where the pre-OP5 8-36px rows made the same all-or-nothing cull read as ordinary row-granular scrolling. | `src/AcDream.App/UI/UiScrollablePanel.cs:69` (the cull predicate); consumed by `src/AcDream.App/UI/UiTemplateListBox.cs` (`Viewport`) — the Character/Chat/Config Options-panel tabs and any other controller-built row list sharing this viewport | A scissor stack does not exist anywhere in the retained-UI renderer yet (class's own doc comment, `UiScrollablePanel.cs:8-12`, predates this row); whole-row culling is a correct, cheap stand-in for every list whose rows are small relative to the viewport, which was true for every consumer before OP5. | A tall block (any future row taller than roughly the viewport's own height, not just OP5's filter blocks) can vanish completely for a range of scroll offsets instead of showing a partial view — the OP5 gate script's own step 2 documents the exact symptom so it is not mistaken for a self-sizing regression (`docs/research/2026-08-11-campaign-op-test-script.md`). Scrolling further always restores the block whole; no data or state is lost, only the presentation pops. | No scissor-stack retail oracle needed — this is a stand-in for ordinary native clip-rect rendering every GUI toolkit (including retail's own) provides; issue #371 tracks adding a real per-row clip rect to `UiScrollablePanel` | | AP-202 | **Filed 2026-08-11 at Campaign OP slice OP8 (D4).** Configure Keyboard persists every rebind to `keybinds.json` only. Retail's own storage is a `\Asheron's Call\.keymap` text file (`CInputManager_WIN32::SaveKeyMap @0x00686C20`, `PFileParser`), with Load-File/Save-As buttons for NAMED keymap profiles and a `keymap` key in `UserPreferences.ini` selecting which one loads at startup (research doc §5.7). D4 chose the existing, tested `keybinds.json` schema over building a second `PFileParser`-compatible text codec + named-profile management; this row's the Load File/Save As buttons on the Configure Keyboard screen (`0x10000027`/`0x10000029`) are wired but INERT. | `src/AcDream.App/UI/Layout/KeyboardConfigController.cs` (`WireScreenButtons`'s Load/Save-As no-op); `src/AcDream.UI.Abstractions/Input/KeyBindings.cs` (`SaveToFile`/`LoadOrDefault`) | `keybinds.json` already round-trips every retail action this screen can bind (identity table + the DAT-defaults conformance test), so the ONLY capability lost is exchanging `.keymap` files with a real retail client or another acdream install by named profile — a real feature gap, not a correctness gap. | A user who expects to export/import a named `.keymap` profile (e.g. to share a control layout with a retail-client friend) cannot; every rebind still works and persists locally. | `docs/research/2026-08-10-keyboard-config-and-gameplay-tab.md` §5.7-§5.8; `CInputManager_WIN32::SaveKeyMap @0x00686C20`; `gmKeyboardUI::SaveKeymap @0x004DCF90` | diff --git a/docs/research/2026-08-11-campaign-op-test-script.md b/docs/research/2026-08-11-campaign-op-test-script.md index 4e26a8c6..08f836a8 100644 --- a/docs/research/2026-08-11-campaign-op-test-script.md +++ b/docs/research/2026-08-11-campaign-op-test-script.md @@ -476,6 +476,13 @@ regardless of the slider position. ### Filter checkboxes — live routing, per-window independence +**Gate-4 re-test note (#382):** the main chat window's own four indicator +buttons (the small LEDs that light up per floating-window 1-4 open/closed +state) previously rendered NOTHING at rest — only a mouse hover revealed +their orange numbered art. They now show their correct art immediately on +window open, with no hover needed; report if any of the four is still +blank before you've moved your mouse over it. + 8. **Open a floating chat window (Alt+1 through Alt+4, or the toolbar indicator buttons) and uncheck "Combat" in that SAME window's filter block on the Chat tab** (e.g. Floaty Chat Window 1's block if you opened diff --git a/src/AcDream.App/UI/UiButton.cs b/src/AcDream.App/UI/UiButton.cs index b0b77a2d..046f2973 100644 --- a/src/AcDream.App/UI/UiButton.cs +++ b/src/AcDream.App/UI/UiButton.cs @@ -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; diff --git a/tests/AcDream.App.Tests/UI/Layout/ChatIndicatorButtonLiveMountProbeTests.cs b/tests/AcDream.App.Tests/UI/Layout/ChatIndicatorButtonLiveMountProbeTests.cs new file mode 100644 index 00000000..57ef524b --- /dev/null +++ b/tests/AcDream.App.Tests/UI/Layout/ChatIndicatorButtonLiveMountProbeTests.cs @@ -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; + +/// +/// #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; + } +} diff --git a/tests/AcDream.App.Tests/UI/UiButtonTests.cs b/tests/AcDream.App.Tests/UI/UiButtonTests.cs index 764df993..4eab1d72 100644 --- a/tests/AcDream.App.Tests/UI/UiButtonTests.cs +++ b/tests/AcDream.App.Tests/UI/UiButtonTests.cs @@ -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() {