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

@ -312,6 +312,7 @@ public sealed class RuntimeSettingsControllerTests
"save-gameplay",
"target-ui-lock:True",
"save-chat",
"target-chat-opacity:0.5:1",
"save-character:default",
],
events);
@ -389,6 +390,62 @@ public sealed class RuntimeSettingsControllerTests
Assert.Empty(targets.SingleOptionCalls);
}
[Fact]
public void SaveChat_PushesOpacityToRuntimeTargets_LiveApply_NoRestart()
{
// Campaign CH slice CH6c: unlike the Hear* options (local-only, no
// wire), opacity is ALWAYS pushed on Save (not diffed) so the linking
// invariant self-heals; the point of this test is that clicking Save
// is enough — no restart, no separate "apply" step.
var controller = CreateController();
var targets = new FakeRuntimeTargets([]);
controller.BindRuntimeTargets(targets);
using InputDispatcher dispatcher = CreateDispatcher();
SettingsVM viewModel = controller.CreateViewModel(
new KeyBindings(),
dispatcher,
static _ => { });
viewModel.SetChat(viewModel.ChatDraft with
{
DefaultOpacity = 0.3f,
ActiveOpacity = 0.6f,
});
viewModel.Save();
Assert.Equal([(0.3f, 0.6f)], targets.ChatOpacityCalls);
Assert.Equal(0.3f, controller.Chat.DefaultOpacity);
Assert.Equal(0.6f, controller.Chat.ActiveOpacity);
}
[Fact]
public void ConcreteRuntimeTargetForwardsChatOpacityToTheLiveController()
{
// Mirrors ConcreteRuntimeTargetPublishesSetSingleCharacterOptionOntoTheBus
// below: proves the CONCRETE RuntimeSettingsTargets.SetChatOpacity wiring,
// not just the IRuntimeSettingsTargets interface via the fake.
var recording = new RecordingChatOpacityTarget();
var target = new RuntimeSettingsTargets(
new InspectingDisplayWindowTarget(static _ => { }),
new RecordingQualityApplicationTarget([]),
new RecordingUiLockTarget([]),
NullCommandBus.Instance,
log: static _ => { },
chatOpacity: recording);
target.SetChatOpacity(0.25f, 0.75f);
Assert.Equal((0.25f, 0.75f), Assert.Single(recording.Calls));
}
private sealed class RecordingChatOpacityTarget : IRuntimeChatOpacityTarget
{
public List<(float DefaultOpacity, float ActiveOpacity)> Calls { get; } = [];
public void Apply(float defaultOpacity, float activeOpacity) =>
Calls.Add((defaultOpacity, activeOpacity));
}
[Fact]
public void SyncChatFromServerOptionsReseedsPersistedAndDraft()
{
@ -1099,6 +1156,14 @@ public sealed class RuntimeSettingsControllerTests
SingleOptionCalls.Add((optionId, value));
events.Add($"target-single-option:0x{optionId:X}:{value}");
}
public List<(float DefaultOpacity, float ActiveOpacity)> ChatOpacityCalls { get; } = [];
public void SetChatOpacity(float defaultOpacity, float activeOpacity)
{
ChatOpacityCalls.Add((defaultOpacity, activeOpacity));
events.Add($"target-chat-opacity:{defaultOpacity}:{activeOpacity}");
}
}
// CH3 review S5(b): records every ICommandBus.Publish call so a test can