feat(chat): Campaign CH slice CH6c — window opacity + transparency setting

Retail's ChatInterface::SetOpacity (0x004F3120) fades the WHOLE composited
window surface with one alpha; UiRenderContext.ApplyAlpha already gated
DrawSprite/DrawRect/DrawFill (since 1da697ec, pre-CH6) but DrawStringDat and
DrawString still passed applyAlpha:false, so text stayed sharp over a
translucent window. Both now route through the same chokepoint.

RetailWindowOpacityController (new) subscribes to a new
RetailWindowManager.WindowRegistered event and drives every registered
window's live Opacity from keyboard-focus state, applied to EVERY window
(chat, floaties, vitals, toolbar, ...) rather than retail's ChatInterface-only
scope — register row AP-190, retiring the stale AP-40 "fixed 0.75, no focus
transition" row in the same commit.

Verified retail's shipped opacity defaults from the decomp (constructor
literals, no cdb needed): the base ChatInterface ctor sets
DefaultOpacity=0.5/ActiveOpacity=1.0, kept unmodified by the four floating
windows; gmMainChatUI's own ctor overrides the main window to 1.0/1.0
(always fully opaque). acdream ships one shared global default (0.5/1.0)
rather than replicating the per-class override — also AP-190. The linking
invariant (raising default above active drags active UP; lowering active
below default drags default DOWN — never a clamp) is ported verbatim as
ChatOpacityLink in AcDream.UI.Abstractions, shared by the live controller
and the new Settings -> Chat tab's two linked opacity sliders.

Persistence: ChatSettings.DefaultOpacity/ActiveOpacity round-trip through
SettingsStore; Save pushes both through IRuntimeSettingsTargets.SetChatOpacity
into the live controller, no restart required.

Rider (CH6a/b re-review): strengthened the grip-media regression guard past
a bare SpriteFile != 0 check — ChatLayoutConformanceTests now drives each
live grip through a real UiRenderContext/TextRenderer (backed by the
in-memory RecordingGpuDevice test double) and asserts the draw call chain
actually queued sprite geometry, via a new TextRenderer.DebugSpriteSegments
test-only accessor.

Full Release suite 12,459 passed / 4 skipped / 0 failed (baseline
12,420/4/0). No subagents, no client launches (session hard constraints);
pending the next connected user gate for visual confirmation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-10 14:00:55 +02:00
parent ccab53d9a1
commit a819687cf0
23 changed files with 1174 additions and 38 deletions

View file

@ -269,6 +269,20 @@ public sealed class RetailUiRuntime : IDisposable
bindings.Host.IsWindowVisible,
bindings.Host.ShowWindow,
bindings.Host.HideWindow);
// Constructed here (before Initialize's Mount* calls run) so its
// RetailWindowManager.WindowRegistered subscription is live from the very
// first window mount — every window this runtime ever registers picks up
// the live focus-driven opacity fade, matching retail's GLOBAL option scope
// (Campaign CH slice CH6c; register row AP-190). Seeded from the persisted
// Chat settings (defaults to ChatSettings.Default's 0.5/1.0 — retail's
// ChatInterface base-constructor values, 0x004F4550 — when no store is
// wired or nothing has been saved yet).
ChatSettings chatSettings = bindings.Chat.Store?.LoadChat() ?? ChatSettings.Default;
WindowOpacity = new RetailWindowOpacityController(
bindings.Host.Root.WindowManager,
chatSettings.DefaultOpacity,
chatSettings.ActiveOpacity);
}
internal static RetailUiRuntime CreateUninitialized(
@ -348,6 +362,13 @@ public sealed class RetailUiRuntime : IDisposable
public UiHost Host => _bindings.Host;
/// <summary>
/// The live focus-driven window-opacity mechanism (Campaign CH slice CH6c).
/// <c>AcDream.App.Settings.RuntimeSettingsTargets</c> calls into this from the
/// Chat tab's Save button so the transparency sliders apply with no restart.
/// </summary>
public RetailWindowOpacityController WindowOpacity { get; }
/// <summary>
/// Shared dat/sprite/font resolvers this runtime was built with.
/// Campaign CH user-gate round 3: lets a controller built OUTSIDE this
@ -861,10 +882,11 @@ public sealed class RetailUiRuntime : IDisposable
// its own element is a Type-2 Dragbar, not a grip (research doc §2.1,
// §2.3 — the top edge authors NO Resizebar of its own; only its two
// corners do).
// Opacity: retail's whole-window alpha (ChatInterface::SetOpacity
// @0x004F3120) is CH6c's scope — UiRenderContext.AlphaMod currently has
// no draw-path consumer, so any value here is presentation-inert; leave
// the frame at the default 1f rather than assert a value with no effect.
// Opacity: leave Options.Opacity at its 1f default — WindowOpacity
// (constructed in the ctor, before this Mount call runs) overwrites it
// the instant this window registers, via RetailWindowManager.WindowRegistered
// (Campaign CH slice CH6c: retail's whole-window alpha,
// ChatInterface::SetOpacity @0x004F3120).
Controller = controller,
StateController = controller,
});
@ -2345,6 +2367,7 @@ public sealed class RetailUiRuntime : IDisposable
{
_characterSheetSubscription?.Dispose();
Host.WindowManager.WindowVisibilityChanged -= OnWindowVisibilityChanged;
WindowOpacity.Dispose();
},
() => _itemConfirmationController?.Dispose(),
() => _gameplayConfirmationController?.Dispose(),