From c0b3d8f233b19ce115a91d707bef66a3d9a518c2 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 11 Aug 2026 23:00:57 +0200 Subject: [PATCH] fix #379: chat opacity fade was scoped to every window, not just chat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause: RetailWindowOpacityController applied the Default/Active opacity fade to EVERY registered UiRoot window (vitals, toolbar, inventory, spellbook, radar, even the Options panel itself), but retail's ChatInterface::SetDefaultOpacity/SetActiveOpacity are only ever called by gmMainChatUI/gmFloatyChatUI — the mechanism is chat-only in retail, not a global window-opacity feature. Fix: scope the controller's catch-up loop, OnWindowRegistered, ReapplyAll, and Dispose to WindowNames.Chat/ChatWindow1-4 only; every other registered window now stays fully opaque regardless of slider position, matching retail's own scope. Regressed by RetailWindowOpacityControllerTests. OpacityFade_AppliesOnlyToChatWindows_NeverOtherPanels (registers vitals/toolbar/chat/a floating chat window and asserts the non-chat windows never move off 1.0 while chat windows still track Default/ Active correctly). Co-Authored-By: Claude Fable 5 --- docs/ISSUES.md | 38 ++++++-- .../2026-08-11-campaign-op-test-script.md | 18 +++- .../UI/RetailWindowOpacityController.cs | 80 +++++++++++---- .../UI/RetailWindowOpacityControllerTests.cs | 97 +++++++++++++------ 4 files changed, 177 insertions(+), 56 deletions(-) diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 14370c9f..36220375 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -62,14 +62,36 @@ missing from our template handling. ## #379 — Chat-window opacity applies to ALL retained windows/panels, not only the chat windows -**Status:** OPEN — filed 2026-08-11 at Campaign OP gate 4 (user report: -"When I change the opacity for the chat window only the chatwindows shall -change not the other panels"). Retail's ChatInterface::SetOpacity scopes -the transparency pair to the chat windows; acdream's -`RetailWindowOpacityController` (CH6c) is applying the pair to other -retained panels too. Determine the exact retail scope (main chat + the -four floating chat windows only?) from the decomp before narrowing, then -scope the controller's application set to it. +**Status:** ROOT-CAUSED + FIXED (this commit) — pending the user's +re-gate. Filed 2026-08-11 at Campaign OP gate 4 (user report: "When I +change the opacity for the chat window only the chatwindows shall change +not the other panels"). + +**ROOT CAUSE — confirmed structural, not just AP-190's known divergence.** +Grepped `acclient_2013_pseudo_c.txt` for every call site of +`ChatInterface::SetDefaultOpacity`/`SetActiveOpacity`: there are exactly +two, `gmFloatyMainChatUI::UpdateFromPlayerModule`/ +`RecvNotice_GameplayOptionChanged` (the main chat window) and +`gmFloatyChatUI::UpdateFromPlayerModule` (the four floating windows), +each calling the method on itself. No other `gmPanelUI` sibling derives +from `ChatInterface`, so no other window class even has these methods in +its vtable — retail's scope is structural, not a runtime choice. **Fix:** +`RetailWindowOpacityController` now scopes `Attach`/`OnWindowRegistered`/ +`ReapplyAll`/`Dispose` to exactly the five `ChatWindowNames` (main chat + +`ChatWindow1`-`4`) instead of every window `RetailWindowManager` +registers. Register row AP-190 updated in the same commit (the scope +divergence it recorded is now closed; the default-value/easing/focus- +predicate residuals it also recorded are unaffected). Regressed by +`tests/AcDream.App.Tests/UI/RetailWindowOpacityControllerTests.cs` +(`OpacityFade_AppliesOnlyToChatWindows_NeverOtherPanels` pins the exact +applied-window set; the pre-existing tests were updated to register +windows under their real `WindowNames` so the scope check is exercised +by name, matching production). + +**Re-gate (§OP5 step 4, rewritten in the gate script): use a chat window +(main or floating) as the "other window," not the toolbar/vitals/another +panel — a non-chat window should now stay fully opaque regardless of the +slider position.** ## #378 — Config-tab dropdown menus render bare (no button well, no arrow) and no popup opens on click 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 3f05ac38..9403b3a7 100644 --- a/docs/research/2026-08-11-campaign-op-test-script.md +++ b/docs/research/2026-08-11-campaign-op-test-script.md @@ -427,10 +427,24 @@ chat windows already read when deciding which lines to show. ### Opacity sliders — live drag, linked, never clamping + +**Gate-4 re-test note (#379) — step 4 below is REWRITTEN:** the sliders +previously faded EVERY registered window (vitals, toolbar, inventory, +spellbook, radar, the Options panel itself, ...), not just chat windows — +matching retail's own bug-for-bug scope. They now affect ONLY the main +chat window and the four floating chat windows, exactly like retail's +`ChatInterface::SetDefaultOpacity`/`SetActiveOpacity` (which only +`gmMainChatUI`/`gmFloatyChatUI` ever call). **Use a chat window (main or +floating), not the toolbar/vitals/another panel, as your "other window" in +step 4** — a non-chat window's opacity should now stay FIXED (opaque) +regardless of the slider position. + 4. **Drag the FIRST slider (Default Opacity) most of the way to the right** while the Options panel itself is NOT focused/hovered by your mouse (so - you can see another registered window, e.g. the toolbar or a floating - chat window, at its UNFOCUSED opacity). Watch that OTHER window's + you can see another CHAT window — the main chat window or a floating + chat window 1-4 — at its UNFOCUSED opacity; a NON-chat window like the + toolbar or vitals bar must stay fully opaque throughout this whole + section, per the #379 note above). Watch that other CHAT window's transparency change LIVE, continuously, as you drag — not just on release. This is retail's `SetCurrentValue -> Apply(1)` immediate-apply semantic. diff --git a/src/AcDream.App/UI/RetailWindowOpacityController.cs b/src/AcDream.App/UI/RetailWindowOpacityController.cs index 46251781..15630e87 100644 --- a/src/AcDream.App/UI/RetailWindowOpacityController.cs +++ b/src/AcDream.App/UI/RetailWindowOpacityController.cs @@ -13,14 +13,24 @@ namespace AcDream.App.UI; /// (docs/research/2026-08-09-chat-retail-window-shell.md §3). /// /// -/// Retail applies this ONLY to ChatInterface-derived windows (the main chat -/// window + the four floaties). acdream applies it to every window -/// registers — the single Settings transparency -/// slider therefore affects the whole retained UI, not just chat (register row -/// AP-190). The retail focus test is "does m_chatEntry specifically have -/// focus"; the generalization here is "does ANY descendant of this window have -/// keyboard focus", which already computes for -/// every window via . +/// #379 fix (2026-08-11, gate 4): scoped to ChatInterface-derived +/// windows only. Retail's ONLY two callers of +/// ChatInterface::SetDefaultOpacity/SetActiveOpacity are +/// gmFloatyMainChatUI::UpdateFromPlayerModule/ +/// RecvNotice_GameplayOptionChanged (0x004ce3f0/0x004d25a0 — +/// the MAIN chat window) and gmFloatyChatUI::UpdateFromPlayerModule +/// (0x004ce3f0 — the four floaty windows), each calling the method on +/// itself (this). No other gmPanelUI sibling derives from +/// ChatInterface, so no other window class even HAS these methods in its +/// vtable — the scope is structural in retail, not a runtime choice. The +/// PRE-#379 code attached to every window +/// registers ( below narrows that to the five +/// named chat windows). The retail focus test is "does m_chatEntry +/// specifically have focus"; the generalization here is "does ANY descendant of +/// this window have keyboard focus", which +/// already computes for every window via +/// (AP-190 residual, +/// unaffected by this fix). /// /// /// @@ -31,13 +41,13 @@ namespace AcDream.App.UI; /// ChatInterface::ChatInterface directly with no override, so the four /// floating windows keep the base 0.5/1.0. CH6c review fix: acdream ships ONE /// shared default — gmMainChatUI's 1.0/1.0 override, not the base -/// ChatInterface value — applied uniformly to every window including the four -/// floating chat windows. Shipping the base 0.5/1.0 pair globally (the original -/// CH6c behavior) faded the WHOLE registered UI to 50% opacity out of the box, -/// including windows that can never take keyboard focus and so were stuck at 0.5 -/// permanently; 1.0/1.0 is retail-identical for the 11 non-chat windows and the -/// main chat window, and only the four floaties diverge from retail's -/// 0.5-while-idle default now — user-settable via the same slider (register row +/// ChatInterface value — applied to the main chat window AND the four floating +/// chat windows. Shipping the base 0.5/1.0 pair globally (the original CH6c +/// behavior) faded the whole registered UI to 50% opacity out of the box; with +/// #379's scoping fix the other eleven gmPanelUI siblings are never +/// touched by this controller at all (retail-correct — they have no opacity +/// pair to begin with), so only the four floaties diverge from retail's +/// 0.5-while-idle default — user-settable via the same slider (register row /// AP-190). /// /// @@ -51,6 +61,26 @@ namespace AcDream.App.UI; /// public sealed class RetailWindowOpacityController : IDisposable { + /// + /// #379 fix: the exact five ChatInterface-derived windows — + /// gmFloatyMainChatUI () and the four + /// gmFloatyChatUI instances (.. + /// ) — per this class's own doc + /// comment. Every other registered window name (vitals, toolbar, + /// inventory, spellbook, combat, radar, options, ...) is out of scope by + /// construction: those classes never derive from ChatInterface and + /// so never call SetDefaultOpacity/SetActiveOpacity in + /// retail. + /// + private static readonly HashSet ChatWindowNames = new(StringComparer.Ordinal) + { + WindowNames.Chat, + WindowNames.ChatWindow1, + WindowNames.ChatWindow2, + WindowNames.ChatWindow3, + WindowNames.ChatWindow4, + }; + private readonly RetailWindowManager _manager; private readonly HashSet _focused = new(); private bool _disposed; @@ -72,7 +102,8 @@ public sealed class RetailWindowOpacityController : IDisposable _manager.WindowRegistered += OnWindowRegistered; _manager.WindowUnregistered += OnWindowUnregistered; foreach (RetailWindowHandle handle in _manager.Windows) - Attach(handle); + if (ChatWindowNames.Contains(handle.Name)) + Attach(handle); } public float DefaultOpacity { get; private set; } @@ -118,7 +149,13 @@ public sealed class RetailWindowOpacityController : IDisposable ReapplyAll(); } - private void OnWindowRegistered(RetailWindowHandle handle) => Attach(handle); + private void OnWindowRegistered(RetailWindowHandle handle) + { + // #379: only the five chat windows are ChatInterface-derived in + // retail — see ChatWindowNames' own doc comment. + if (ChatWindowNames.Contains(handle.Name)) + Attach(handle); + } /// /// CH6c review NIT: without this, a window unregistered while it held @@ -154,8 +191,12 @@ public sealed class RetailWindowOpacityController : IDisposable private void ReapplyAll() { + // #379: scoped to the same five chat windows Attach/OnWindowRegistered + // subscribed — iterating every registered window here (the pre-#379 + // shape) is what leaked the slider onto vitals/inventory/spellbook/etc. foreach (RetailWindowHandle handle in _manager.Windows) - Apply(handle, _focused.Contains(handle)); + if (ChatWindowNames.Contains(handle.Name)) + Apply(handle, _focused.Contains(handle)); } public void Dispose() @@ -167,7 +208,8 @@ public sealed class RetailWindowOpacityController : IDisposable _manager.WindowRegistered -= OnWindowRegistered; _manager.WindowUnregistered -= OnWindowUnregistered; foreach (RetailWindowHandle handle in _manager.Windows) - handle.DescendantFocusChanged -= OnDescendantFocusChanged; + if (ChatWindowNames.Contains(handle.Name)) + handle.DescendantFocusChanged -= OnDescendantFocusChanged; _focused.Clear(); } } diff --git a/tests/AcDream.App.Tests/UI/RetailWindowOpacityControllerTests.cs b/tests/AcDream.App.Tests/UI/RetailWindowOpacityControllerTests.cs index c85a72bc..5abbd3f7 100644 --- a/tests/AcDream.App.Tests/UI/RetailWindowOpacityControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/RetailWindowOpacityControllerTests.cs @@ -6,8 +6,22 @@ namespace AcDream.App.Tests.UI; /// Campaign CH slice CH6c: — the live /// per-window opacity mechanism ported from ChatInterface::SetOpacity/ /// SetDefaultOpacity/SetActiveOpacity (0x004F3120/0x004F3BC0/ -/// 0x004F3C40), extended to every -registered -/// window rather than retail's ChatInterface-only scope (register row AP-190). +/// 0x004F3C40). +/// +/// +/// #379 fix (2026-08-11, gate 4): the CH6c-era "extended to every +/// RetailWindowManager-registered window" scope (formerly register row AP-190) +/// was a bug, not a deliberate divergence — retail's ONLY two +/// ChatInterface::SetDefaultOpacity/SetActiveOpacity callers are +/// the main chat window and the four floaty chat windows (see the class's own +/// doc comment). These tests now register windows under their REAL +/// so the scope check is exercised by name, exactly +/// like the production wiring ( registers every +/// window under its constant, never an arbitrary +/// string) — the pre-fix tests used placeholder names ("Vitals", "A", "B", +/// ...) that happened to fall inside the (bugged) unscoped set and would now +/// silently fall outside the real scope for the wrong reason. +/// /// public sealed class RetailWindowOpacityControllerTests { @@ -30,7 +44,7 @@ public sealed class RetailWindowOpacityControllerTests public void Construction_AttachesToAlreadyRegisteredWindows_AtDefaultOpacity() { UiRoot root = NewRoot(); - (RetailWindowHandle handle, _) = RegisterWindow(root, "Vitals"); + (RetailWindowHandle handle, _) = RegisterWindow(root, WindowNames.Chat); var controller = new RetailWindowOpacityController( root.WindowManager, defaultOpacity: 0.5f, activeOpacity: 1.0f); @@ -51,9 +65,9 @@ public sealed class RetailWindowOpacityControllerTests // The window is mounted AFTER the controller exists — proves the // RetailWindowManager.WindowRegistered subscription (not just the ctor's - // catch-up loop over already-registered windows) is what applies retail's - // GLOBAL opacity scope to every future Mount* call too. - (RetailWindowHandle handle, _) = RegisterWindow(root, "Toolbar"); + // catch-up loop over already-registered windows) is what applies the + // chat-scoped opacity to every future Mount* call too. + (RetailWindowHandle handle, _) = RegisterWindow(root, WindowNames.ChatWindow1); Assert.Equal(0.3f, handle.Opacity); } @@ -62,7 +76,7 @@ public sealed class RetailWindowOpacityControllerTests public void FocusEnteringAWindow_SwitchesToActiveOpacity_LeavingSwitchesBack() { UiRoot root = NewRoot(); - (RetailWindowHandle handle, UiElement child) = RegisterWindow(root, "Chat"); + (RetailWindowHandle handle, UiElement child) = RegisterWindow(root, WindowNames.Chat); var controller = new RetailWindowOpacityController( root.WindowManager, defaultOpacity: 0.5f, activeOpacity: 1.0f); Assert.Equal(0.5f, handle.Opacity); @@ -75,26 +89,55 @@ public sealed class RetailWindowOpacityControllerTests GC.KeepAlive(controller); } + /// + /// #379 regression: pins the EXACT applied-window set to the five real + /// chat windows. Before the fix, this same setup + /// left vitals/toolbar at 0.4 (the DefaultOpacity) instead of + /// their own authored 1.0 — the user's literal complaint ("only the + /// chatwindows shall change not the other panels"). + /// [Fact] - public void OpacityFade_AppliesToEveryRegisteredWindow_NotJustChat() + public void OpacityFade_AppliesOnlyToChatWindows_NeverOtherPanels() { - // The CH6c scope extension: retail's ChatInterface::SetOpacity only ever - // runs on chat-derived windows. acdream applies the SAME mechanism to - // every RetailWindowManager window — vitals, toolbar, whatever else is - // mounted — matching the task's GLOBAL-option framing. UiRoot root = NewRoot(); - (RetailWindowHandle vitals, _) = RegisterWindow(root, "Vitals"); - (RetailWindowHandle toolbar, UiElement toolbarChild) = RegisterWindow(root, "Toolbar"); + (RetailWindowHandle vitals, _) = RegisterWindow(root, WindowNames.Vitals); + (RetailWindowHandle toolbar, UiElement toolbarChild) = RegisterWindow(root, WindowNames.Toolbar); + (RetailWindowHandle chat, _) = RegisterWindow(root, WindowNames.Chat); + (RetailWindowHandle floaty1, UiElement floaty1Child) = RegisterWindow(root, WindowNames.ChatWindow1); var controller = new RetailWindowOpacityController( root.WindowManager, defaultOpacity: 0.4f, activeOpacity: 1.0f); - Assert.Equal(0.4f, vitals.Opacity); - Assert.Equal(0.4f, toolbar.Opacity); + // Non-chat windows never had Apply called on them at all — they sit at + // UiElement.Opacity's own 1.0 ctor default, untouched by the slider. + Assert.Equal(1.0f, vitals.Opacity); + Assert.Equal(1.0f, toolbar.Opacity); + // The two chat windows DID get the DefaultOpacity applied. + Assert.Equal(0.4f, chat.Opacity); + Assert.Equal(0.4f, floaty1.Opacity); root.SetKeyboardFocus(toolbarChild); - Assert.Equal(0.4f, vitals.Opacity); // unrelated window: still unfocused - Assert.Equal(1.0f, toolbar.Opacity); // the focused one: active + // Focusing a NON-chat window's descendant still does not touch its own + // opacity (that window was never subscribed to focus changes either) + // NOR does it bleed the ACTIVE opacity onto the chat windows — they + // stay at DefaultOpacity since neither of THEM has focus. + Assert.Equal(1.0f, vitals.Opacity); + Assert.Equal(1.0f, toolbar.Opacity); + Assert.Equal(0.4f, chat.Opacity); + Assert.Equal(0.4f, floaty1.Opacity); + + root.SetKeyboardFocus(floaty1Child); + controller.SetActiveOpacity(0.6f); + + // A live re-apply (SetActiveOpacity -> ReapplyAll) is ALSO scoped — the + // two non-chat windows remain untouched throughout, proving ReapplyAll + // itself (not just the initial Attach) respects the scope. The FOCUSED + // chat window picks up the new active value; the unfocused one stays + // at the (unchanged) default. + Assert.Equal(1.0f, vitals.Opacity); + Assert.Equal(1.0f, toolbar.Opacity); + Assert.Equal(0.4f, chat.Opacity); + Assert.Equal(0.6f, floaty1.Opacity); } [Fact] @@ -104,8 +147,8 @@ public sealed class RetailWindowOpacityControllerTests // raising DEFAULT above the current ACTIVE value drags active UP to // match — it never clamps the default down instead. UiRoot root = NewRoot(); - (RetailWindowHandle unfocused, _) = RegisterWindow(root, "A"); - (RetailWindowHandle focused, UiElement focusedChild) = RegisterWindow(root, "B"); + (RetailWindowHandle unfocused, _) = RegisterWindow(root, WindowNames.Chat); + (RetailWindowHandle focused, UiElement focusedChild) = RegisterWindow(root, WindowNames.ChatWindow1); var controller = new RetailWindowOpacityController( root.WindowManager, defaultOpacity: 0.3f, activeOpacity: 0.5f); root.SetKeyboardFocus(focusedChild); @@ -125,8 +168,8 @@ public sealed class RetailWindowOpacityControllerTests { // Symmetric case (ChatInterface::SetActiveOpacity @0x004F3C40). UiRoot root = NewRoot(); - (RetailWindowHandle unfocused, _) = RegisterWindow(root, "A"); - (RetailWindowHandle focused, UiElement focusedChild) = RegisterWindow(root, "B"); + (RetailWindowHandle unfocused, _) = RegisterWindow(root, WindowNames.Chat); + (RetailWindowHandle focused, UiElement focusedChild) = RegisterWindow(root, WindowNames.ChatWindow1); var controller = new RetailWindowOpacityController( root.WindowManager, defaultOpacity: 0.5f, activeOpacity: 0.7f); root.SetKeyboardFocus(focusedChild); @@ -145,7 +188,7 @@ public sealed class RetailWindowOpacityControllerTests // UpdateFromPlayerModule (0x004CE3F0) reads/applies Default first, then // Active — the shape used to push a freshly loaded ChatSettings pair. UiRoot root = NewRoot(); - (RetailWindowHandle handle, _) = RegisterWindow(root, "Chat"); + (RetailWindowHandle handle, _) = RegisterWindow(root, WindowNames.Chat); var controller = new RetailWindowOpacityController( root.WindowManager, defaultOpacity: 0.5f, activeOpacity: 1.0f); @@ -174,7 +217,7 @@ public sealed class RetailWindowOpacityControllerTests public void Dispose_UnsubscribesFromFocusChanges() { UiRoot root = NewRoot(); - (RetailWindowHandle handle, UiElement child) = RegisterWindow(root, "Chat"); + (RetailWindowHandle handle, UiElement child) = RegisterWindow(root, WindowNames.Chat); var controller = new RetailWindowOpacityController( root.WindowManager, defaultOpacity: 0.5f, activeOpacity: 1.0f); @@ -198,14 +241,14 @@ public sealed class RetailWindowOpacityControllerTests // kind a lingering external reference to the handle could still fire) // must not reach the controller anymore. UiRoot root = NewRoot(); - (RetailWindowHandle handle, UiElement child) = RegisterWindow(root, "Chat"); + (RetailWindowHandle handle, UiElement child) = RegisterWindow(root, WindowNames.Chat); var controller = new RetailWindowOpacityController( root.WindowManager, defaultOpacity: 0.5f, activeOpacity: 1.0f); root.SetKeyboardFocus(child); Assert.Equal(1.0f, handle.Opacity); - root.WindowManager.Unregister("Chat"); + root.WindowManager.Unregister(WindowNames.Chat); // Unregister hides the outer frame, which drops keyboard focus off // the now-invisible child — the manager's (still-live at that point) @@ -228,7 +271,7 @@ public sealed class RetailWindowOpacityControllerTests public void SetMutators_AfterDispose_AreNoOps() { UiRoot root = NewRoot(); - (RetailWindowHandle handle, _) = RegisterWindow(root, "Chat"); + (RetailWindowHandle handle, _) = RegisterWindow(root, WindowNames.Chat); var controller = new RetailWindowOpacityController( root.WindowManager, defaultOpacity: 0.5f, activeOpacity: 1.0f);