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>
175 lines
6.3 KiB
C#
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);
|
|
}
|
|
}
|