acdream/tests/AcDream.App.Tests/UI/UiRenderContextAlphaTests.cs
Erik cc58289967 fix(chat): CH6c review fixes — opaque default, opacity-transition register clauses
BLOCKER: ChatSettings.DefaultOpacity shipped retail's base ChatInterface
value (0.5) as ONE shared global default applied to every
RetailWindowManager-registered window, not just the four floating chat
windows retail itself fades. That faded the whole out-of-box registered
UI (radar, vitals, toolbar, main chat, ...) to 50% opacity, including
several windows that can never take keyboard focus and so were stuck at
0.5 permanently. Fixed to gmMainChatUI's 1.0/1.0 override
(0x004CD0F0) instead — retail-identical opaque presentation for the 11
non-chat windows and the main chat window; only the four floating chat
windows now diverge from retail's 0.5-while-idle default, and the
Settings -> Chat transparency slider remains fully user-settable.

AP-190 reworded and gains two new decomp-verified clauses: (3) retail
eases opacity toward its target by 5% of the delta per tick
(ChatInterface::ListenToGlobalMessage @0x004F3840, armed from the focus
element-messages at @0x004F5275) where acdream snaps -- deferred, needs
a UI frame-tick hook the opacity controller doesn't have; (4) retail's
focus predicate is the chat ENTRY FIELD specifically
(ChatInterface::IsTextEntryFocused @0x004F30A0) where acdream uses
any-focusable-descendant. Both findings + the pre-existing UiMenu.cs
PushAlphaAbsolute(1f) popup bypass are folded into the window-shell
research doc's opacity section.

NITs: fixed the stale "text bypasses the alpha" comment in
UiElement.DrawSelfAndChildren (CH6c already routed DrawStringDat/
DrawString through the same ApplyAlpha chokepoint as sprites/rects);
added RetailWindowManager.WindowUnregistered + wired
RetailWindowOpacityController to detach and forget a window unregistered
while it held focus (previously only Dispose detached, leaking any
window unregistered mid-focus for the rest of the session); added
post-Dispose no-op guards to the three Set* opacity mutators; added a
DrawString (BitmapFont path) alpha regression test and a DrawStringDat
outline/background-pass alpha test (the existing tests only ever
exercised the foreground/fill pass).

Also fixes RuntimeSettingsControllerTests.SettingsViewModelSavePreserves
SectionAndTargetOrder's now-stale "target-chat-opacity:0.5:1" expectation
(caught by the full-suite run this fix requires) to match the new 1.0
default.

Campaign ledger CH6c row updated to APPROVE-WITH-FIXES.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 14:48:39 +02:00

277 lines
11 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;
using Xunit.Sdk;
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,
},
});
/// <summary>Same shape as <see cref="BuildFont"/> but with a non-zero
/// background (outline) atlas, so <see cref="UiRenderContext.DrawStringDat"/>'s
/// <c>outline: true</c> branch actually emits its background sprite pass.</summary>
private static UiDatFont BuildOutlinedFont() => new(
fgTex: 1, fgW: 64, fgH: 64,
bgTex: 2, bgW: 64, bgH: 64,
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,
},
});
/// <summary>Bakes a real <see cref="BitmapFont"/> from a system TTF so
/// <see cref="UiRenderContext.DrawString"/> (the BitmapFont path, distinct
/// from the dat-font <see cref="UiRenderContext.DrawStringDat"/> above) can
/// be exercised end-to-end. Skips rather than fails on a machine with none
/// of <see cref="BitmapFont.TryLoadSystemMonospaceFont"/>'s well-known
/// paths — matches the skip pattern other environment-dependent tests in
/// this suite already use (e.g. <c>RetailSelectionAssetTests</c>).</summary>
private static BitmapFont BuildBitmapFontOrSkip(IGpuDevice device)
{
byte[]? ttf = BitmapFont.TryLoadSystemMonospaceFont();
if (ttf is null)
throw SkipException.ForSkip("No system TTF font found for BitmapFont construction.");
return new BitmapFont(device, ttf, pixelHeight: 16f);
}
// -- 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);
}
[Fact]
public void HalfOpacityWindow_MultipliesDatFontOutlineAndForegroundPassAlpha()
{
// CH6c review NIT: BuildFont() above has bgTex == 0, so every existing
// DrawStringDat alpha test only ever exercised the foreground (fill)
// sprite pass. The background (outline) pass is a SEPARATE
// DrawSpriteAbsolute(applyAlpha: true) call site — this pins it too,
// with a font whose background atlas is actually present (bgTex != 0)
// and outline: true so both passes fire.
(TextRenderer renderer, UiRenderContext ctx) = Build();
UiDatFont font = BuildOutlinedFont();
ctx.PushAlpha(0.5f);
ctx.DrawStringDat(font, "A", 0, 0, new Vector4(1f, 1f, 1f, 1f), outline: true);
ctx.PopAlpha();
// Background pass (texture 2) submitted first, then foreground (texture 1) —
// both routes guarded by the same ApplyAlpha chokepoint.
Assert.Equal(2, renderer.DebugSpriteSegments.Count);
foreach (var seg in renderer.DebugSpriteSegments)
Assert.Equal(0.5f, seg.Alpha);
}
// -- DrawString (BitmapFont path): the same alpha chokepoint, guarded ---
[Fact]
public void FullOpacity_DrawString_BitmapFontPath_MatchesRequestedAlpha_Identity()
{
// CH6c review NIT: DrawStringDat (retail dat-font glyphs) had its own
// alpha regression tests above; UiRenderContext.DrawString — the
// BitmapFont path used for D.6 world-space HUD text — had none. Both
// route through the SAME private ApplyAlpha, but nothing pinned it for
// this path specifically.
var device = new RecordingGpuDevice();
using BitmapFont font = BuildBitmapFontOrSkip(device);
var renderer = new TextRenderer(device, new NullGpuFrameSource(), "unused");
renderer.Begin(new Vector2(800f, 600f));
var ctx = new UiRenderContext(renderer, new Vector2(800f, 600f));
ctx.DrawString("A", 0, 0, new Vector4(1f, 1f, 1f, 1f), font);
(int vertexCount, float alpha) = renderer.DebugTextBuffer;
Assert.True(vertexCount > 0);
Assert.Equal(1f, alpha);
}
[Fact]
public void HalfOpacityWindow_MultipliesBitmapFontGlyphAlpha()
{
var device = new RecordingGpuDevice();
using BitmapFont font = BuildBitmapFontOrSkip(device);
var renderer = new TextRenderer(device, new NullGpuFrameSource(), "unused");
renderer.Begin(new Vector2(800f, 600f));
var ctx = new UiRenderContext(renderer, new Vector2(800f, 600f));
ctx.PushAlpha(0.5f);
ctx.DrawString("A", 0, 0, new Vector4(1f, 1f, 1f, 1f), font);
ctx.PopAlpha();
(int vertexCount, float alpha) = renderer.DebugTextBuffer;
Assert.True(vertexCount > 0);
Assert.Equal(0.5f, alpha);
}
}