acdream/tests/AcDream.App.Tests/UI/UiRenderContextAlphaTests.cs
Erik a819687cf0 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>
2026-08-10 14:00:55 +02:00

175 lines
6.3 KiB
C#

using System.Collections.Generic;
using System.Numerics;
using AcDream.App.Rendering;
using AcDream.App.Rendering.Gpu;
using AcDream.App.Tests.Rendering.Gpu;
using AcDream.App.UI;
using DatReaderWriter.Types;
namespace AcDream.App.Tests.UI;
/// <summary>
/// Campaign CH slice CH6c: pins the window-opacity ALPHA CHOKEPOINT —
/// <see cref="UiRenderContext"/>'s private <c>ApplyAlpha</c>, reached by every
/// public draw call (<see cref="UiRenderContext.DrawSprite"/>,
/// <see cref="UiRenderContext.DrawRect"/>/<see cref="UiRenderContext.DrawFill"/>,
/// and — new this slice — <see cref="UiRenderContext.DrawStringDat"/> and
/// <see cref="UiRenderContext.DrawString"/>). Retail's <c>ChatInterface::SetOpacity
/// @0x004F3120</c> sets ONE alpha on the whole composited window surface, chrome
/// AND text together — before this slice, <c>DrawStringDat</c> passed
/// <c>applyAlpha: false</c> so glyphs stayed opaque over a translucent window.
///
/// <para>
/// Builds a real <see cref="TextRenderer"/> over the in-memory
/// <see cref="RecordingGpuDevice"/> test double (no live GPU, no shader
/// compile — <c>RecordingGpuDevice.CreatePipeline</c> just wraps the
/// description) so <c>TextRenderer.DebugSpriteSegments</c> can be read back
/// directly instead of asserting through a GPU flush.
/// </para>
/// </summary>
public sealed class UiRenderContextAlphaTests
{
private sealed class NullGpuFrameSource : ICurrentGpuFrameSource
{
public IGpuFrame? CurrentFrame => null;
}
private static (TextRenderer renderer, UiRenderContext ctx) Build()
{
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));
return (renderer, ctx);
}
private static UiDatFont BuildFont() => new(
fgTex: 1, fgW: 64, fgH: 64,
bgTex: 0, bgW: 0, bgH: 0,
lineHeight: 16f, baselineOffset: 12f,
glyphs: new Dictionary<char, FontCharDesc>
{
['A'] = new FontCharDesc
{
Unicode = 'A',
Width = 8,
Height = 16,
OffsetX = 0,
OffsetY = 0,
HorizontalOffsetBefore = 0,
HorizontalOffsetAfter = 0,
VerticalOffsetBefore = 0,
},
});
// -- DrawSprite: full-opacity identity ---------------------------------
[Fact]
public void FullOpacity_DrawSprite_MatchesRequestedAlpha_Identity()
{
// Pin: with no window opacity pushed (AlphaMod == 1, matching every
// production window today), output is byte-identical to a tint's own
// alpha — this slice must not change ANY existing full-opacity render.
(TextRenderer renderer, UiRenderContext ctx) = Build();
Assert.Equal(1f, ctx.AlphaMod);
ctx.DrawSprite(7u, 0, 0, 10, 10, 0, 0, 1, 1, new Vector4(1f, 1f, 1f, 1f));
var seg = Assert.Single(renderer.DebugSpriteSegments);
Assert.Equal(7u, seg.Texture);
Assert.Equal(1f, seg.Alpha);
}
[Fact]
public void HalfOpacityWindow_MultipliesEverySpriteEmission()
{
(TextRenderer renderer, UiRenderContext ctx) = Build();
ctx.PushAlpha(0.5f);
Assert.Equal(0.5f, ctx.AlphaMod);
ctx.DrawSprite(7u, 0, 0, 10, 10, 0, 0, 1, 1, new Vector4(1f, 1f, 1f, 1f));
ctx.PopAlpha();
var seg = Assert.Single(renderer.DebugSpriteSegments);
Assert.Equal(0.5f, seg.Alpha);
// Pop restores full opacity for whatever draws next.
Assert.Equal(1f, ctx.AlphaMod);
}
[Fact]
public void NestedPushAlpha_ComposesMultiplicatively()
{
(TextRenderer renderer, UiRenderContext ctx) = Build();
ctx.PushAlpha(0.5f);
ctx.PushAlpha(0.4f);
Assert.Equal(0.2f, ctx.AlphaMod, 5);
ctx.DrawSprite(7u, 0, 0, 10, 10, 0, 0, 1, 1, new Vector4(1f, 1f, 1f, 1f));
ctx.PopAlpha();
ctx.PopAlpha();
var seg = Assert.Single(renderer.DebugSpriteSegments);
Assert.Equal(0.2f, seg.Alpha, 5);
}
[Fact]
public void NestedPushAlpha_MultipliesAgainstAnAlreadyTintedColor()
{
// A widget that already draws at partial alpha (e.g. a translucent
// background sprite, tint.W = 0.8) fades FURTHER when its window is
// also translucent — the two multipliers compose, they don't clobber.
(TextRenderer renderer, UiRenderContext ctx) = Build();
ctx.PushAlpha(0.5f);
ctx.DrawSprite(7u, 0, 0, 10, 10, 0, 0, 1, 1, new Vector4(1f, 1f, 1f, 0.8f));
ctx.PopAlpha();
var seg = Assert.Single(renderer.DebugSpriteSegments);
Assert.Equal(0.4f, seg.Alpha, 5);
}
[Fact]
public void PopAlpha_WithoutMatchingPush_IsANoOp()
{
(TextRenderer renderer, UiRenderContext ctx) = Build();
ctx.PopAlpha();
Assert.Equal(1f, ctx.AlphaMod);
}
// -- DrawStringDat: the CH6c fix (text now respects window alpha) -----
[Fact]
public void FullOpacity_DrawStringDat_MatchesRequestedAlpha_Identity()
{
(TextRenderer renderer, UiRenderContext ctx) = Build();
UiDatFont font = BuildFont();
ctx.DrawStringDat(font, "A", 0, 0, new Vector4(1f, 1f, 1f, 1f));
// bgTex == 0, so only the foreground (fill) pass draws — one segment
// on the font's foreground texture (id 1).
var seg = Assert.Single(renderer.DebugSpriteSegments);
Assert.Equal(1u, seg.Texture);
Assert.Equal(1f, seg.Alpha);
}
[Fact]
public void HalfOpacityWindow_MultipliesDatFontGlyphAlpha()
{
// The retail-faithful fix: ChatInterface::SetOpacity (0x004F3120) fades
// the WHOLE composited window surface, glyphs included — before this
// slice, DrawStringDat's applyAlpha:false meant text stayed sharp over
// a translucent window (see the retired class-doc comment this test
// replaces the assumption of).
(TextRenderer renderer, UiRenderContext ctx) = Build();
UiDatFont font = BuildFont();
ctx.PushAlpha(0.5f);
ctx.DrawStringDat(font, "A", 0, 0, new Vector4(1f, 1f, 1f, 1f));
ctx.PopAlpha();
var seg = Assert.Single(renderer.DebugSpriteSegments);
Assert.Equal(0.5f, seg.Alpha);
}
}