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>
277 lines
11 KiB
C#
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);
|
|
}
|
|
}
|