BLOCKER: ChatSettings.DefaultOpacity shipped retail's base ChatInterface value (0.5) as ONE shared global default applied to every RetailWindowManager-registered window, not just the four floating chat windows retail itself fades. That faded the whole out-of-box registered UI (radar, vitals, toolbar, main chat, ...) to 50% opacity, including several windows that can never take keyboard focus and so were stuck at 0.5 permanently. Fixed to gmMainChatUI's 1.0/1.0 override (0x004CD0F0) instead — retail-identical opaque presentation for the 11 non-chat windows and the main chat window; only the four floating chat windows now diverge from retail's 0.5-while-idle default, and the Settings -> Chat transparency slider remains fully user-settable. AP-190 reworded and gains two new decomp-verified clauses: (3) retail eases opacity toward its target by 5% of the delta per tick (ChatInterface::ListenToGlobalMessage @0x004F3840, armed from the focus element-messages at @0x004F5275) where acdream snaps -- deferred, needs a UI frame-tick hook the opacity controller doesn't have; (4) retail's focus predicate is the chat ENTRY FIELD specifically (ChatInterface::IsTextEntryFocused @0x004F30A0) where acdream uses any-focusable-descendant. Both findings + the pre-existing UiMenu.cs PushAlphaAbsolute(1f) popup bypass are folded into the window-shell research doc's opacity section. NITs: fixed the stale "text bypasses the alpha" comment in UiElement.DrawSelfAndChildren (CH6c already routed DrawStringDat/ DrawString through the same ApplyAlpha chokepoint as sprites/rects); added RetailWindowManager.WindowUnregistered + wired RetailWindowOpacityController to detach and forget a window unregistered while it held focus (previously only Dispose detached, leaking any window unregistered mid-focus for the rest of the session); added post-Dispose no-op guards to the three Set* opacity mutators; added a DrawString (BitmapFont path) alpha regression test and a DrawStringDat outline/background-pass alpha test (the existing tests only ever exercised the foreground/fill pass). Also fixes RuntimeSettingsControllerTests.SettingsViewModelSavePreserves SectionAndTargetOrder's now-stale "target-chat-opacity:0.5:1" expectation (caught by the full-suite run this fix requires) to match the new 1.0 default. Campaign ledger CH6c row updated to APPROVE-WITH-FIXES. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
247 lines
10 KiB
C#
247 lines
10 KiB
C#
using AcDream.App.UI;
|
|
|
|
namespace AcDream.App.Tests.UI;
|
|
|
|
/// <summary>
|
|
/// Campaign CH slice CH6c: <see cref="RetailWindowOpacityController"/> — the live
|
|
/// per-window opacity mechanism ported from <c>ChatInterface::SetOpacity/
|
|
/// SetDefaultOpacity/SetActiveOpacity</c> (<c>0x004F3120</c>/<c>0x004F3BC0</c>/
|
|
/// <c>0x004F3C40</c>), extended to every <see cref="RetailWindowManager"/>-registered
|
|
/// window rather than retail's ChatInterface-only scope (register row AP-190).
|
|
/// </summary>
|
|
public sealed class RetailWindowOpacityControllerTests
|
|
{
|
|
private static UiRoot NewRoot() => new() { Width = 800f, Height = 600f };
|
|
|
|
/// <summary>Registers a bare window ("outer frame" + one focusable child) and
|
|
/// returns both — mirrors how a real window has a content descendant the
|
|
/// chat entry / any input field could take focus on.</summary>
|
|
private static (RetailWindowHandle handle, UiElement child) RegisterWindow(UiRoot root, string name)
|
|
{
|
|
var frame = new UiPanel { Width = 100f, Height = 100f };
|
|
var child = new UiPanel { Width = 10f, Height = 10f, AcceptsFocus = true };
|
|
frame.AddChild(child);
|
|
root.AddChild(frame);
|
|
RetailWindowHandle handle = root.WindowManager.Register(name, frame);
|
|
return (handle, child);
|
|
}
|
|
|
|
[Fact]
|
|
public void Construction_AttachesToAlreadyRegisteredWindows_AtDefaultOpacity()
|
|
{
|
|
UiRoot root = NewRoot();
|
|
(RetailWindowHandle handle, _) = RegisterWindow(root, "Vitals");
|
|
|
|
var controller = new RetailWindowOpacityController(
|
|
root.WindowManager, defaultOpacity: 0.5f, activeOpacity: 1.0f);
|
|
|
|
// No descendant has focus yet — every window starts at the DEFAULT
|
|
// (unfocused) opacity, matching retail's unfocused ChatInterface state.
|
|
Assert.Equal(0.5f, handle.Opacity);
|
|
Assert.Equal(0.5f, controller.DefaultOpacity);
|
|
Assert.Equal(1.0f, controller.ActiveOpacity);
|
|
}
|
|
|
|
[Fact]
|
|
public void WindowRegisteredAfterConstruction_PicksUpLiveOpacityImmediately()
|
|
{
|
|
UiRoot root = NewRoot();
|
|
var controller = new RetailWindowOpacityController(
|
|
root.WindowManager, defaultOpacity: 0.3f, activeOpacity: 0.9f);
|
|
|
|
// 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");
|
|
|
|
Assert.Equal(0.3f, handle.Opacity);
|
|
}
|
|
|
|
[Fact]
|
|
public void FocusEnteringAWindow_SwitchesToActiveOpacity_LeavingSwitchesBack()
|
|
{
|
|
UiRoot root = NewRoot();
|
|
(RetailWindowHandle handle, UiElement child) = RegisterWindow(root, "Chat");
|
|
var controller = new RetailWindowOpacityController(
|
|
root.WindowManager, defaultOpacity: 0.5f, activeOpacity: 1.0f);
|
|
Assert.Equal(0.5f, handle.Opacity);
|
|
|
|
root.SetKeyboardFocus(child);
|
|
Assert.Equal(1.0f, handle.Opacity);
|
|
|
|
root.SetKeyboardFocus(null);
|
|
Assert.Equal(0.5f, handle.Opacity);
|
|
GC.KeepAlive(controller);
|
|
}
|
|
|
|
[Fact]
|
|
public void OpacityFade_AppliesToEveryRegisteredWindow_NotJustChat()
|
|
{
|
|
// 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");
|
|
var controller = new RetailWindowOpacityController(
|
|
root.WindowManager, defaultOpacity: 0.4f, activeOpacity: 1.0f);
|
|
|
|
Assert.Equal(0.4f, vitals.Opacity);
|
|
Assert.Equal(0.4f, toolbar.Opacity);
|
|
|
|
root.SetKeyboardFocus(toolbarChild);
|
|
|
|
Assert.Equal(0.4f, vitals.Opacity); // unrelated window: still unfocused
|
|
Assert.Equal(1.0f, toolbar.Opacity); // the focused one: active
|
|
}
|
|
|
|
[Fact]
|
|
public void SetDefaultOpacity_AboveCurrentActive_DragsActiveUp_AndReappliesEverywhere()
|
|
{
|
|
// Decomp-verified linking (ChatInterface::SetDefaultOpacity @0x004F3BC0):
|
|
// 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");
|
|
var controller = new RetailWindowOpacityController(
|
|
root.WindowManager, defaultOpacity: 0.3f, activeOpacity: 0.5f);
|
|
root.SetKeyboardFocus(focusedChild);
|
|
Assert.Equal(0.3f, unfocused.Opacity);
|
|
Assert.Equal(0.5f, focused.Opacity);
|
|
|
|
controller.SetDefaultOpacity(0.9f);
|
|
|
|
Assert.Equal(0.9f, controller.DefaultOpacity);
|
|
Assert.Equal(0.9f, controller.ActiveOpacity);
|
|
Assert.Equal(0.9f, unfocused.Opacity);
|
|
Assert.Equal(0.9f, focused.Opacity);
|
|
}
|
|
|
|
[Fact]
|
|
public void SetActiveOpacity_BelowCurrentDefault_DragsDefaultDown_AndReappliesEverywhere()
|
|
{
|
|
// Symmetric case (ChatInterface::SetActiveOpacity @0x004F3C40).
|
|
UiRoot root = NewRoot();
|
|
(RetailWindowHandle unfocused, _) = RegisterWindow(root, "A");
|
|
(RetailWindowHandle focused, UiElement focusedChild) = RegisterWindow(root, "B");
|
|
var controller = new RetailWindowOpacityController(
|
|
root.WindowManager, defaultOpacity: 0.5f, activeOpacity: 0.7f);
|
|
root.SetKeyboardFocus(focusedChild);
|
|
|
|
controller.SetActiveOpacity(0.1f);
|
|
|
|
Assert.Equal(0.1f, controller.DefaultOpacity);
|
|
Assert.Equal(0.1f, controller.ActiveOpacity);
|
|
Assert.Equal(0.1f, unfocused.Opacity);
|
|
Assert.Equal(0.1f, focused.Opacity);
|
|
}
|
|
|
|
[Fact]
|
|
public void SetOpacity_AppliesBothInRetailsUpdateFromPlayerModuleOrder()
|
|
{
|
|
// 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");
|
|
var controller = new RetailWindowOpacityController(
|
|
root.WindowManager, defaultOpacity: 0.5f, activeOpacity: 1.0f);
|
|
|
|
controller.SetOpacity(defaultOpacity: 0.2f, activeOpacity: 0.6f);
|
|
|
|
Assert.Equal(0.2f, controller.DefaultOpacity);
|
|
Assert.Equal(0.6f, controller.ActiveOpacity);
|
|
Assert.Equal(0.2f, handle.Opacity);
|
|
}
|
|
|
|
[Fact]
|
|
public void ConstructorSeed_EnforcesTheActiveGreaterThanOrEqualDefaultInvariant()
|
|
{
|
|
// A corrupt/hand-edited settings.json could carry active < default.
|
|
// The seed collapses through the SAME link the live setters use.
|
|
UiRoot root = NewRoot();
|
|
var controller = new RetailWindowOpacityController(
|
|
root.WindowManager, defaultOpacity: 0.8f, activeOpacity: 0.2f);
|
|
|
|
Assert.True(controller.ActiveOpacity >= controller.DefaultOpacity);
|
|
Assert.Equal(0.2f, controller.DefaultOpacity);
|
|
Assert.Equal(0.2f, controller.ActiveOpacity);
|
|
}
|
|
|
|
[Fact]
|
|
public void Dispose_UnsubscribesFromFocusChanges()
|
|
{
|
|
UiRoot root = NewRoot();
|
|
(RetailWindowHandle handle, UiElement child) = RegisterWindow(root, "Chat");
|
|
var controller = new RetailWindowOpacityController(
|
|
root.WindowManager, defaultOpacity: 0.5f, activeOpacity: 1.0f);
|
|
|
|
controller.Dispose();
|
|
root.SetKeyboardFocus(child);
|
|
|
|
// Still at the last value the controller applied before disposal — a
|
|
// focus change after Dispose is not observed anymore.
|
|
Assert.Equal(0.5f, handle.Opacity);
|
|
}
|
|
|
|
[Fact]
|
|
public void WindowUnregistered_DetachesSubscription_AndForgetsFocusedState()
|
|
{
|
|
// CH6c review NIT: before this fix, the only detach point for a
|
|
// handle's DescendantFocusChanged subscription (and its membership in
|
|
// _focused) was the CONTROLLER's own Dispose — a window unregistered
|
|
// while it held focus stayed subscribed and pinned in _focused for
|
|
// the rest of the session. Prove the RetailWindowManager.WindowUnregistered
|
|
// wiring actually detaches: a stray post-unregister notification (the
|
|
// 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");
|
|
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");
|
|
|
|
// Unregister hides the outer frame, which drops keyboard focus off
|
|
// the now-invisible child — the manager's (still-live at that point)
|
|
// focus-change plumbing reapplies DefaultOpacity naturally. Expected
|
|
// either way; not itself the thing this test pins.
|
|
Assert.Equal(0.5f, handle.Opacity);
|
|
|
|
controller.SetActiveOpacity(0.7f);
|
|
|
|
// A stray post-unregister focus-gain notification (the kind a
|
|
// lingering external reference to the handle could still fire). If
|
|
// the controller were STILL subscribed, this would re-add the stale
|
|
// handle to _focused and apply the NEW active opacity (0.7).
|
|
handle.NotifyDescendantFocusChanged(child);
|
|
|
|
Assert.Equal(0.5f, handle.Opacity);
|
|
}
|
|
|
|
[Fact]
|
|
public void SetMutators_AfterDispose_AreNoOps()
|
|
{
|
|
UiRoot root = NewRoot();
|
|
(RetailWindowHandle handle, _) = RegisterWindow(root, "Chat");
|
|
var controller = new RetailWindowOpacityController(
|
|
root.WindowManager, defaultOpacity: 0.5f, activeOpacity: 1.0f);
|
|
|
|
controller.Dispose();
|
|
controller.SetDefaultOpacity(0.9f);
|
|
controller.SetActiveOpacity(0.9f);
|
|
controller.SetOpacity(0.2f, 0.3f);
|
|
|
|
// None of the three post-Dispose calls changed anything — no
|
|
// ObjectDisposedException either, matching Dispose's own idempotent
|
|
// shape.
|
|
Assert.Equal(0.5f, controller.DefaultOpacity);
|
|
Assert.Equal(1.0f, controller.ActiveOpacity);
|
|
Assert.Equal(0.5f, handle.Opacity);
|
|
}
|
|
}
|