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

@ -88,6 +88,33 @@ public sealed class TextRenderer : IDisposable
/// </summary>
internal long DynamicBufferCapacityBytes => 0;
/// <summary>
/// Test-only snapshot of the current frame's queued NORMAL-layer sprite segments, in
/// submission order: (textureId, vertexCount, alpha of the segment's first vertex —
/// color.W at float index 7 of the 8-float vertex layout). Lets a unit test assert
/// that a draw call actually EMITTED sprite geometry — and with what alpha — without
/// a live GPU, constructing this renderer over the in-memory
/// <c>RecordingGpuDevice</c> test double. Campaign CH slice CH6c rider (CH6a/b
/// re-review): strengthens the grip-media regression guard past a bare
/// <c>SpriteFile != 0</c> check, which proves a sprite RESOLVED but not that
/// <see cref="DrawSprite"/> was ever called. <c>AcDream.App.Tests</c>-only via
/// <c>InternalsVisibleTo</c>.
/// </summary>
internal IReadOnlyList<(uint Texture, int VertexCount, float Alpha)> DebugSpriteSegments
{
get
{
var result = new List<(uint, int, float)>(_segUsed);
for (int i = 0; i < _segUsed; i++)
{
SpriteSeg seg = _spriteSegs[i];
float alpha = seg.Verts.Count > 0 ? seg.Verts[7] : 0f;
result.Add((seg.Texture, seg.Verts.Count / FloatsPerVertex, alpha));
}
return result;
}
}
// Overlay layer — a parallel set of buckets drawn AFTER the normal sprite/rect/text
// buckets, so open popups/menus composite on top of EVERYTHING, including translucent
// rect panel backgrounds (which otherwise always win because rects flush after