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

@ -1,3 +1,7 @@
using System.Numerics;
using AcDream.App.Rendering;
using AcDream.App.Rendering.Gpu;
using AcDream.App.Tests.Rendering.Gpu;
using AcDream.App.UI;
using AcDream.App.UI.Layout;
using AcDream.Core.Chat;
@ -360,6 +364,53 @@ public class ChatLayoutConformanceTests
Assert.NotEqual(0u, grip.SpriteFile);
}
private sealed class NullGpuFrameSource : ICurrentGpuFrameSource
{
public IGpuFrame? CurrentFrame => null;
}
[Theory]
[InlineData(0x1000069Bu)] // TL corner
[InlineData(0x1000069Du)] // TR corner
[InlineData(0x1000069Eu)] // left edge
[InlineData(0x1000069Fu)] // BL corner
[InlineData(0x100006A0u)] // bottom edge
[InlineData(0x100006A1u)] // BR corner
[InlineData(0x100006A2u)] // right edge
public void MountedChatWindow_LiveGrip_ActuallyEmitsASpriteDraw_NotJustResolvesSpriteFile(uint elementId)
{
// CH6a/b re-review rider: MountedChatWindow_LiveGrip_ResolvesNonZeroSprite
// (above) only proves the ElementInfo carries a non-zero DirectState
// sprite id — it never calls OnDraw, so a media-less regression (a grip
// constructed WITHOUT its resolve delegate, or one whose resolve always
// returns a zero handle/dimension — exactly the CH6a/b BLOCKER 1 bug)
// would still pass it. This drives the grip through a REAL
// UiRenderContext over a REAL TextRenderer (backed by the in-memory
// RecordingGpuDevice test double, no live GPU) and asserts the draw
// call chain actually queued sprite geometry for that grip's texture.
var infos = FixtureLoader.LoadChatInfos();
// Distinct from FixtureLoader's own null-returning resolver: echoes the
// sprite id as a nonzero fake texture handle with nonzero dimensions,
// so UiResizeGrip.OnDraw's `tex == 0 || tw == 0 || th == 0` guard does
// not short-circuit before reaching ctx.DrawSprite.
var layout = LayoutImporter.Build(infos, id => (id, 8, 8), null);
var grip = Assert.IsType<UiResizeGrip>(layout.FindElement(elementId));
Assert.NotEqual(0u, grip.SpriteFile);
Assert.True(grip.Visible);
var device = new RecordingGpuDevice();
var renderer = new TextRenderer(device, new NullGpuFrameSource(), "unused");
renderer.Begin(new Vector2(800f, 600f));
var ctx = new UiRenderContext(renderer, new Vector2(800f, 600f));
grip.DrawSelfAndChildren(ctx);
var seg = Assert.Single(
renderer.DebugSpriteSegments,
s => s.Texture == grip.SpriteFile);
Assert.True(seg.VertexCount > 0);
}
[Fact]
public void MountedChatWindow_BottomRightGrip_GrowsBothAxes_NotOnlyShrinks()
{