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:
parent
ccab53d9a1
commit
a819687cf0
23 changed files with 1174 additions and 38 deletions
|
|
@ -312,6 +312,7 @@ public sealed class RuntimeSettingsControllerTests
|
|||
"save-gameplay",
|
||||
"target-ui-lock:True",
|
||||
"save-chat",
|
||||
"target-chat-opacity:0.5:1",
|
||||
"save-character:default",
|
||||
],
|
||||
events);
|
||||
|
|
@ -389,6 +390,62 @@ public sealed class RuntimeSettingsControllerTests
|
|||
Assert.Empty(targets.SingleOptionCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SaveChat_PushesOpacityToRuntimeTargets_LiveApply_NoRestart()
|
||||
{
|
||||
// Campaign CH slice CH6c: unlike the Hear* options (local-only, no
|
||||
// wire), opacity is ALWAYS pushed on Save (not diffed) so the linking
|
||||
// invariant self-heals; the point of this test is that clicking Save
|
||||
// is enough — no restart, no separate "apply" step.
|
||||
var controller = CreateController();
|
||||
var targets = new FakeRuntimeTargets([]);
|
||||
controller.BindRuntimeTargets(targets);
|
||||
using InputDispatcher dispatcher = CreateDispatcher();
|
||||
SettingsVM viewModel = controller.CreateViewModel(
|
||||
new KeyBindings(),
|
||||
dispatcher,
|
||||
static _ => { });
|
||||
|
||||
viewModel.SetChat(viewModel.ChatDraft with
|
||||
{
|
||||
DefaultOpacity = 0.3f,
|
||||
ActiveOpacity = 0.6f,
|
||||
});
|
||||
viewModel.Save();
|
||||
|
||||
Assert.Equal([(0.3f, 0.6f)], targets.ChatOpacityCalls);
|
||||
Assert.Equal(0.3f, controller.Chat.DefaultOpacity);
|
||||
Assert.Equal(0.6f, controller.Chat.ActiveOpacity);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConcreteRuntimeTargetForwardsChatOpacityToTheLiveController()
|
||||
{
|
||||
// Mirrors ConcreteRuntimeTargetPublishesSetSingleCharacterOptionOntoTheBus
|
||||
// below: proves the CONCRETE RuntimeSettingsTargets.SetChatOpacity wiring,
|
||||
// not just the IRuntimeSettingsTargets interface via the fake.
|
||||
var recording = new RecordingChatOpacityTarget();
|
||||
var target = new RuntimeSettingsTargets(
|
||||
new InspectingDisplayWindowTarget(static _ => { }),
|
||||
new RecordingQualityApplicationTarget([]),
|
||||
new RecordingUiLockTarget([]),
|
||||
NullCommandBus.Instance,
|
||||
log: static _ => { },
|
||||
chatOpacity: recording);
|
||||
|
||||
target.SetChatOpacity(0.25f, 0.75f);
|
||||
|
||||
Assert.Equal((0.25f, 0.75f), Assert.Single(recording.Calls));
|
||||
}
|
||||
|
||||
private sealed class RecordingChatOpacityTarget : IRuntimeChatOpacityTarget
|
||||
{
|
||||
public List<(float DefaultOpacity, float ActiveOpacity)> Calls { get; } = [];
|
||||
|
||||
public void Apply(float defaultOpacity, float activeOpacity) =>
|
||||
Calls.Add((defaultOpacity, activeOpacity));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SyncChatFromServerOptionsReseedsPersistedAndDraft()
|
||||
{
|
||||
|
|
@ -1099,6 +1156,14 @@ public sealed class RuntimeSettingsControllerTests
|
|||
SingleOptionCalls.Add((optionId, value));
|
||||
events.Add($"target-single-option:0x{optionId:X}:{value}");
|
||||
}
|
||||
|
||||
public List<(float DefaultOpacity, float ActiveOpacity)> ChatOpacityCalls { get; } = [];
|
||||
|
||||
public void SetChatOpacity(float defaultOpacity, float activeOpacity)
|
||||
{
|
||||
ChatOpacityCalls.Add((defaultOpacity, activeOpacity));
|
||||
events.Add($"target-chat-opacity:{defaultOpacity}:{activeOpacity}");
|
||||
}
|
||||
}
|
||||
|
||||
// CH3 review S5(b): records every ICommandBus.Publish call so a test can
|
||||
|
|
|
|||
|
|
@ -1,3 +1,7 @@
|
|||
using System.Numerics;
|
||||
using AcDream.App.Rendering;
|
||||
using AcDream.App.Rendering.Gpu;
|
||||
using AcDream.App.Tests.Rendering.Gpu;
|
||||
using AcDream.App.UI;
|
||||
using AcDream.App.UI.Layout;
|
||||
using AcDream.Core.Chat;
|
||||
|
|
@ -360,6 +364,53 @@ public class ChatLayoutConformanceTests
|
|||
Assert.NotEqual(0u, grip.SpriteFile);
|
||||
}
|
||||
|
||||
private sealed class NullGpuFrameSource : ICurrentGpuFrameSource
|
||||
{
|
||||
public IGpuFrame? CurrentFrame => null;
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0x1000069Bu)] // TL corner
|
||||
[InlineData(0x1000069Du)] // TR corner
|
||||
[InlineData(0x1000069Eu)] // left edge
|
||||
[InlineData(0x1000069Fu)] // BL corner
|
||||
[InlineData(0x100006A0u)] // bottom edge
|
||||
[InlineData(0x100006A1u)] // BR corner
|
||||
[InlineData(0x100006A2u)] // right edge
|
||||
public void MountedChatWindow_LiveGrip_ActuallyEmitsASpriteDraw_NotJustResolvesSpriteFile(uint elementId)
|
||||
{
|
||||
// CH6a/b re-review rider: MountedChatWindow_LiveGrip_ResolvesNonZeroSprite
|
||||
// (above) only proves the ElementInfo carries a non-zero DirectState
|
||||
// sprite id — it never calls OnDraw, so a media-less regression (a grip
|
||||
// constructed WITHOUT its resolve delegate, or one whose resolve always
|
||||
// returns a zero handle/dimension — exactly the CH6a/b BLOCKER 1 bug)
|
||||
// would still pass it. This drives the grip through a REAL
|
||||
// UiRenderContext over a REAL TextRenderer (backed by the in-memory
|
||||
// RecordingGpuDevice test double, no live GPU) and asserts the draw
|
||||
// call chain actually queued sprite geometry for that grip's texture.
|
||||
var infos = FixtureLoader.LoadChatInfos();
|
||||
// Distinct from FixtureLoader's own null-returning resolver: echoes the
|
||||
// sprite id as a nonzero fake texture handle with nonzero dimensions,
|
||||
// so UiResizeGrip.OnDraw's `tex == 0 || tw == 0 || th == 0` guard does
|
||||
// not short-circuit before reaching ctx.DrawSprite.
|
||||
var layout = LayoutImporter.Build(infos, id => (id, 8, 8), null);
|
||||
var grip = Assert.IsType<UiResizeGrip>(layout.FindElement(elementId));
|
||||
Assert.NotEqual(0u, grip.SpriteFile);
|
||||
Assert.True(grip.Visible);
|
||||
|
||||
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));
|
||||
|
||||
grip.DrawSelfAndChildren(ctx);
|
||||
|
||||
var seg = Assert.Single(
|
||||
renderer.DebugSpriteSegments,
|
||||
s => s.Texture == grip.SpriteFile);
|
||||
Assert.True(seg.VertexCount > 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MountedChatWindow_BottomRightGrip_GrowsBothAxes_NotOnlyShrinks()
|
||||
{
|
||||
|
|
|
|||
188
tests/AcDream.App.Tests/UI/RetailWindowOpacityControllerTests.cs
Normal file
188
tests/AcDream.App.Tests/UI/RetailWindowOpacityControllerTests.cs
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
using AcDream.App.UI;
|
||||
|
||||
namespace AcDream.App.Tests.UI;
|
||||
|
||||
/// <summary>
|
||||
/// Campaign CH slice CH6c: <see cref="RetailWindowOpacityController"/> — the live
|
||||
/// per-window opacity mechanism ported from <c>ChatInterface::SetOpacity/
|
||||
/// SetDefaultOpacity/SetActiveOpacity</c> (<c>0x004F3120</c>/<c>0x004F3BC0</c>/
|
||||
/// <c>0x004F3C40</c>), extended to every <see cref="RetailWindowManager"/>-registered
|
||||
/// window rather than retail's ChatInterface-only scope (register row AP-190).
|
||||
/// </summary>
|
||||
public sealed class RetailWindowOpacityControllerTests
|
||||
{
|
||||
private static UiRoot NewRoot() => new() { Width = 800f, Height = 600f };
|
||||
|
||||
/// <summary>Registers a bare window ("outer frame" + one focusable child) and
|
||||
/// returns both — mirrors how a real window has a content descendant the
|
||||
/// chat entry / any input field could take focus on.</summary>
|
||||
private static (RetailWindowHandle handle, UiElement child) RegisterWindow(UiRoot root, string name)
|
||||
{
|
||||
var frame = new UiPanel { Width = 100f, Height = 100f };
|
||||
var child = new UiPanel { Width = 10f, Height = 10f, AcceptsFocus = true };
|
||||
frame.AddChild(child);
|
||||
root.AddChild(frame);
|
||||
RetailWindowHandle handle = root.WindowManager.Register(name, frame);
|
||||
return (handle, child);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Construction_AttachesToAlreadyRegisteredWindows_AtDefaultOpacity()
|
||||
{
|
||||
UiRoot root = NewRoot();
|
||||
(RetailWindowHandle handle, _) = RegisterWindow(root, "Vitals");
|
||||
|
||||
var controller = new RetailWindowOpacityController(
|
||||
root.WindowManager, defaultOpacity: 0.5f, activeOpacity: 1.0f);
|
||||
|
||||
// No descendant has focus yet — every window starts at the DEFAULT
|
||||
// (unfocused) opacity, matching retail's unfocused ChatInterface state.
|
||||
Assert.Equal(0.5f, handle.Opacity);
|
||||
Assert.Equal(0.5f, controller.DefaultOpacity);
|
||||
Assert.Equal(1.0f, controller.ActiveOpacity);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WindowRegisteredAfterConstruction_PicksUpLiveOpacityImmediately()
|
||||
{
|
||||
UiRoot root = NewRoot();
|
||||
var controller = new RetailWindowOpacityController(
|
||||
root.WindowManager, defaultOpacity: 0.3f, activeOpacity: 0.9f);
|
||||
|
||||
// The window is mounted AFTER the controller exists — proves the
|
||||
// RetailWindowManager.WindowRegistered subscription (not just the ctor's
|
||||
// catch-up loop over already-registered windows) is what applies retail's
|
||||
// GLOBAL opacity scope to every future Mount* call too.
|
||||
(RetailWindowHandle handle, _) = RegisterWindow(root, "Toolbar");
|
||||
|
||||
Assert.Equal(0.3f, handle.Opacity);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FocusEnteringAWindow_SwitchesToActiveOpacity_LeavingSwitchesBack()
|
||||
{
|
||||
UiRoot root = NewRoot();
|
||||
(RetailWindowHandle handle, UiElement child) = RegisterWindow(root, "Chat");
|
||||
var controller = new RetailWindowOpacityController(
|
||||
root.WindowManager, defaultOpacity: 0.5f, activeOpacity: 1.0f);
|
||||
Assert.Equal(0.5f, handle.Opacity);
|
||||
|
||||
root.SetKeyboardFocus(child);
|
||||
Assert.Equal(1.0f, handle.Opacity);
|
||||
|
||||
root.SetKeyboardFocus(null);
|
||||
Assert.Equal(0.5f, handle.Opacity);
|
||||
GC.KeepAlive(controller);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OpacityFade_AppliesToEveryRegisteredWindow_NotJustChat()
|
||||
{
|
||||
// The CH6c scope extension: retail's ChatInterface::SetOpacity only ever
|
||||
// runs on chat-derived windows. acdream applies the SAME mechanism to
|
||||
// every RetailWindowManager window — vitals, toolbar, whatever else is
|
||||
// mounted — matching the task's GLOBAL-option framing.
|
||||
UiRoot root = NewRoot();
|
||||
(RetailWindowHandle vitals, _) = RegisterWindow(root, "Vitals");
|
||||
(RetailWindowHandle toolbar, UiElement toolbarChild) = RegisterWindow(root, "Toolbar");
|
||||
var controller = new RetailWindowOpacityController(
|
||||
root.WindowManager, defaultOpacity: 0.4f, activeOpacity: 1.0f);
|
||||
|
||||
Assert.Equal(0.4f, vitals.Opacity);
|
||||
Assert.Equal(0.4f, toolbar.Opacity);
|
||||
|
||||
root.SetKeyboardFocus(toolbarChild);
|
||||
|
||||
Assert.Equal(0.4f, vitals.Opacity); // unrelated window: still unfocused
|
||||
Assert.Equal(1.0f, toolbar.Opacity); // the focused one: active
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetDefaultOpacity_AboveCurrentActive_DragsActiveUp_AndReappliesEverywhere()
|
||||
{
|
||||
// Decomp-verified linking (ChatInterface::SetDefaultOpacity @0x004F3BC0):
|
||||
// raising DEFAULT above the current ACTIVE value drags active UP to
|
||||
// match — it never clamps the default down instead.
|
||||
UiRoot root = NewRoot();
|
||||
(RetailWindowHandle unfocused, _) = RegisterWindow(root, "A");
|
||||
(RetailWindowHandle focused, UiElement focusedChild) = RegisterWindow(root, "B");
|
||||
var controller = new RetailWindowOpacityController(
|
||||
root.WindowManager, defaultOpacity: 0.3f, activeOpacity: 0.5f);
|
||||
root.SetKeyboardFocus(focusedChild);
|
||||
Assert.Equal(0.3f, unfocused.Opacity);
|
||||
Assert.Equal(0.5f, focused.Opacity);
|
||||
|
||||
controller.SetDefaultOpacity(0.9f);
|
||||
|
||||
Assert.Equal(0.9f, controller.DefaultOpacity);
|
||||
Assert.Equal(0.9f, controller.ActiveOpacity);
|
||||
Assert.Equal(0.9f, unfocused.Opacity);
|
||||
Assert.Equal(0.9f, focused.Opacity);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetActiveOpacity_BelowCurrentDefault_DragsDefaultDown_AndReappliesEverywhere()
|
||||
{
|
||||
// Symmetric case (ChatInterface::SetActiveOpacity @0x004F3C40).
|
||||
UiRoot root = NewRoot();
|
||||
(RetailWindowHandle unfocused, _) = RegisterWindow(root, "A");
|
||||
(RetailWindowHandle focused, UiElement focusedChild) = RegisterWindow(root, "B");
|
||||
var controller = new RetailWindowOpacityController(
|
||||
root.WindowManager, defaultOpacity: 0.5f, activeOpacity: 0.7f);
|
||||
root.SetKeyboardFocus(focusedChild);
|
||||
|
||||
controller.SetActiveOpacity(0.1f);
|
||||
|
||||
Assert.Equal(0.1f, controller.DefaultOpacity);
|
||||
Assert.Equal(0.1f, controller.ActiveOpacity);
|
||||
Assert.Equal(0.1f, unfocused.Opacity);
|
||||
Assert.Equal(0.1f, focused.Opacity);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetOpacity_AppliesBothInRetailsUpdateFromPlayerModuleOrder()
|
||||
{
|
||||
// UpdateFromPlayerModule (0x004CE3F0) reads/applies Default first, then
|
||||
// Active — the shape used to push a freshly loaded ChatSettings pair.
|
||||
UiRoot root = NewRoot();
|
||||
(RetailWindowHandle handle, _) = RegisterWindow(root, "Chat");
|
||||
var controller = new RetailWindowOpacityController(
|
||||
root.WindowManager, defaultOpacity: 0.5f, activeOpacity: 1.0f);
|
||||
|
||||
controller.SetOpacity(defaultOpacity: 0.2f, activeOpacity: 0.6f);
|
||||
|
||||
Assert.Equal(0.2f, controller.DefaultOpacity);
|
||||
Assert.Equal(0.6f, controller.ActiveOpacity);
|
||||
Assert.Equal(0.2f, handle.Opacity);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConstructorSeed_EnforcesTheActiveGreaterThanOrEqualDefaultInvariant()
|
||||
{
|
||||
// A corrupt/hand-edited settings.json could carry active < default.
|
||||
// The seed collapses through the SAME link the live setters use.
|
||||
UiRoot root = NewRoot();
|
||||
var controller = new RetailWindowOpacityController(
|
||||
root.WindowManager, defaultOpacity: 0.8f, activeOpacity: 0.2f);
|
||||
|
||||
Assert.True(controller.ActiveOpacity >= controller.DefaultOpacity);
|
||||
Assert.Equal(0.2f, controller.DefaultOpacity);
|
||||
Assert.Equal(0.2f, controller.ActiveOpacity);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dispose_UnsubscribesFromFocusChanges()
|
||||
{
|
||||
UiRoot root = NewRoot();
|
||||
(RetailWindowHandle handle, UiElement child) = RegisterWindow(root, "Chat");
|
||||
var controller = new RetailWindowOpacityController(
|
||||
root.WindowManager, defaultOpacity: 0.5f, activeOpacity: 1.0f);
|
||||
|
||||
controller.Dispose();
|
||||
root.SetKeyboardFocus(child);
|
||||
|
||||
// Still at the last value the controller applied before disposal — a
|
||||
// focus change after Dispose is not observed anymore.
|
||||
Assert.Equal(0.5f, handle.Opacity);
|
||||
}
|
||||
}
|
||||
175
tests/AcDream.App.Tests/UI/UiRenderContextAlphaTests.cs
Normal file
175
tests/AcDream.App.Tests/UI/UiRenderContextAlphaTests.cs
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,87 @@
|
|||
using AcDream.UI.Abstractions.Panels.Settings;
|
||||
|
||||
namespace AcDream.UI.Abstractions.Tests.Panels.Settings;
|
||||
|
||||
/// <summary>
|
||||
/// Campaign CH slice CH6c: pins <see cref="ChatOpacityLink"/>'s port of retail's
|
||||
/// linked default/active opacity invariant (<c>ChatInterface::SetDefaultOpacity
|
||||
/// @0x004F3BC0</c> / <c>SetActiveOpacity @0x004F3C40</c>) — active >= default,
|
||||
/// ALWAYS, enforced by dragging the OTHER value rather than clamping the one
|
||||
/// being set.
|
||||
/// </summary>
|
||||
public sealed class ChatOpacityLinkTests
|
||||
{
|
||||
[Fact]
|
||||
public void SetDefault_BelowCurrentActive_LeavesActiveUnchanged()
|
||||
{
|
||||
// The ordinary case: lowering the background opacity while it's already
|
||||
// below the active value doesn't need to touch active at all.
|
||||
var (def, active) = ChatOpacityLink.SetDefault(currentActive: 1.0f, newDefault: 0.3f);
|
||||
Assert.Equal(0.3f, def);
|
||||
Assert.Equal(1.0f, active);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetDefault_AboveCurrentActive_DragsActiveUp_DoesNotClampDefault()
|
||||
{
|
||||
// The decomp-verified answer to "does raising default above active drag
|
||||
// active up, or clamp default?": ChatInterface::SetDefaultOpacity always
|
||||
// WRITES this->m_fDefaultOpacity = arg2 first, THEN calls SetActiveOpacity
|
||||
// when active < default. Default is never clamped down.
|
||||
var (def, active) = ChatOpacityLink.SetDefault(currentActive: 0.4f, newDefault: 0.9f);
|
||||
Assert.Equal(0.9f, def);
|
||||
Assert.Equal(0.9f, active);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetActive_AboveCurrentDefault_LeavesDefaultUnchanged()
|
||||
{
|
||||
var (def, active) = ChatOpacityLink.SetActive(currentDefault: 0.2f, newActive: 0.9f);
|
||||
Assert.Equal(0.2f, def);
|
||||
Assert.Equal(0.9f, active);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetActive_BelowCurrentDefault_DragsDefaultDown()
|
||||
{
|
||||
// Symmetric case: ChatInterface::SetActiveOpacity writes m_fActiveOpacity
|
||||
// first, then calls SetDefaultOpacity when default > active.
|
||||
var (def, active) = ChatOpacityLink.SetActive(currentDefault: 0.7f, newActive: 0.3f);
|
||||
Assert.Equal(0.3f, def);
|
||||
Assert.Equal(0.3f, active);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(-1f, 0f)]
|
||||
[InlineData(2f, 1f)]
|
||||
public void SetDefault_ClampsInputToUnitRange(float rawInput, float expectedDefault)
|
||||
{
|
||||
var (def, _) = ChatOpacityLink.SetDefault(currentActive: 1f, newDefault: rawInput);
|
||||
Assert.Equal(expectedDefault, def);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(-1f, 0f)]
|
||||
[InlineData(2f, 1f)]
|
||||
public void SetActive_ClampsInputToUnitRange(float rawInput, float expectedActive)
|
||||
{
|
||||
var (_, active) = ChatOpacityLink.SetActive(currentDefault: 0f, newActive: rawInput);
|
||||
Assert.Equal(expectedActive, active);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetDefault_ThenSetActive_NeverProducesActiveBelowDefault()
|
||||
{
|
||||
// A short sequence exercising the invariant through several moves, the
|
||||
// way a user dragging both sliders back and forth would.
|
||||
(float def, float active) = (0.5f, 1.0f);
|
||||
(def, active) = ChatOpacityLink.SetDefault(active, 0.9f);
|
||||
Assert.True(active >= def);
|
||||
(def, active) = ChatOpacityLink.SetActive(def, 0.1f);
|
||||
Assert.True(active >= def);
|
||||
(def, active) = ChatOpacityLink.SetDefault(active, 0.6f);
|
||||
Assert.True(active >= def);
|
||||
Assert.Equal(0.6f, def);
|
||||
Assert.Equal(0.6f, active);
|
||||
}
|
||||
}
|
||||
|
|
@ -24,6 +24,14 @@ public sealed class ChatSettingsTests
|
|||
Assert.True(d.ShowTimestamps);
|
||||
Assert.True(d.FilterProfanity);
|
||||
Assert.Equal(12f, d.FontSize);
|
||||
|
||||
// Campaign CH slice CH6c: retail's base ChatInterface constructor
|
||||
// (0x004F4550) sets DefaultOpacity=0.5/ActiveOpacity=1.0 — the value
|
||||
// every gmFloatyChatUI (the four floating windows) keeps unmodified.
|
||||
// acdream ships that pair as ONE shared global default (register row
|
||||
// AP-190), rather than gmMainChatUI's own 1.0/1.0 override.
|
||||
Assert.Equal(0.5f, d.DefaultOpacity);
|
||||
Assert.Equal(1.0f, d.ActiveOpacity);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
|
|||
|
|
@ -474,6 +474,70 @@ public sealed class SettingsPanelTests
|
|||
Assert.Equal(1f, (float)masterCall.Args[3]!);
|
||||
}
|
||||
|
||||
// -- Campaign CH slice CH6c: chat tab opacity sliders -----------------
|
||||
|
||||
[Fact]
|
||||
public void Chat_tab_when_active_renders_two_linked_opacity_sliders()
|
||||
{
|
||||
var (panel, vm, _, _) = Build();
|
||||
var r = new FakePanelRenderer { ActiveTabLabel = "Chat" };
|
||||
|
||||
panel.Render(new PanelContext(0.016f, new NullBus()), r);
|
||||
|
||||
var sliders = r.Calls.Where(c => c.Method == "SliderFloat")
|
||||
.Select(c => (string)c.Args[0]!).ToList();
|
||||
Assert.Contains("Background opacity (unfocused)", sliders);
|
||||
Assert.Contains("Active opacity (typing / focused)", sliders);
|
||||
|
||||
var bgCall = r.Calls.First(
|
||||
c => c.Method == "SliderFloat" && (string)c.Args[0]! == "Background opacity (unfocused)");
|
||||
Assert.Equal(vm.ChatDraft.DefaultOpacity, (float)bgCall.Args[1]!);
|
||||
Assert.Equal(0f, (float)bgCall.Args[2]!);
|
||||
Assert.Equal(1f, (float)bgCall.Args[3]!);
|
||||
|
||||
var activeCall = r.Calls.First(
|
||||
c => c.Method == "SliderFloat" && (string)c.Args[0]! == "Active opacity (typing / focused)");
|
||||
Assert.Equal(vm.ChatDraft.ActiveOpacity, (float)activeCall.Args[1]!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Chat_tab_opacity_sliders_do_not_render_when_a_different_tab_is_active()
|
||||
{
|
||||
var (panel, _, _, _) = Build();
|
||||
var r = new FakePanelRenderer { ActiveTabLabel = "Display" };
|
||||
|
||||
panel.Render(new PanelContext(0.016f, new NullBus()), r);
|
||||
|
||||
var sliders = r.Calls.Where(c => c.Method == "SliderFloat")
|
||||
.Select(c => (string)c.Args[0]!).ToList();
|
||||
Assert.DoesNotContain("Background opacity (unfocused)", sliders);
|
||||
Assert.DoesNotContain("Active opacity (typing / focused)", sliders);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Chat_tab_dragging_active_opacity_below_background_drags_background_down_in_draft()
|
||||
{
|
||||
// FakePanelRenderer applies ONE injected value to every SliderFloat call
|
||||
// in the same Render pass and each branch's `_vm.SetChat` starts from the
|
||||
// ORIGINAL pre-render draft — so with SliderFloatNextReturn set, the
|
||||
// LAST-rendered opacity slider ("Active", rendered after "Background")
|
||||
// determines the final draft. That exercises ChatOpacityLink.SetActive's
|
||||
// drag-background-down path end-to-end through the real panel code,
|
||||
// starting from ChatSettings.Default (DefaultOpacity=0.5, ActiveOpacity=1.0).
|
||||
var (panel, vm, _, _) = Build();
|
||||
var r = new FakePanelRenderer
|
||||
{
|
||||
ActiveTabLabel = "Chat",
|
||||
SliderFloatNextReturn = true,
|
||||
SliderFloatNextValue = 0.2f,
|
||||
};
|
||||
|
||||
panel.Render(new PanelContext(0.016f, new NullBus()), r);
|
||||
|
||||
Assert.Equal(0.2f, vm.ChatDraft.DefaultOpacity);
|
||||
Assert.Equal(0.2f, vm.ChatDraft.ActiveOpacity);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Save_Cancel_buttons_render_outside_the_tab_bar()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -330,6 +330,35 @@ public sealed class SettingsStoreTests : System.IDisposable
|
|||
Assert.Equal(original.ChatWindow4Filter, loaded.ChatWindow4Filter);
|
||||
}
|
||||
|
||||
// -- Campaign CH slice CH6c: window opacity round-trip -----------------
|
||||
|
||||
[Fact]
|
||||
public void LoadChat_returns_retail_ChatInterface_opacity_defaults_when_file_is_missing()
|
||||
{
|
||||
var store = new SettingsStore(_tempPath);
|
||||
ChatSettings loaded = store.LoadChat();
|
||||
|
||||
Assert.Equal(0.5f, loaded.DefaultOpacity);
|
||||
Assert.Equal(1.0f, loaded.ActiveOpacity);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SaveChat_then_LoadChat_round_trips_opacity()
|
||||
{
|
||||
var store = new SettingsStore(_tempPath);
|
||||
var original = ChatSettings.Default with
|
||||
{
|
||||
DefaultOpacity = 0.2f,
|
||||
ActiveOpacity = 0.8f,
|
||||
};
|
||||
|
||||
store.SaveChat(original);
|
||||
ChatSettings loaded = store.LoadChat();
|
||||
|
||||
Assert.Equal(original.DefaultOpacity, loaded.DefaultOpacity);
|
||||
Assert.Equal(original.ActiveOpacity, loaded.ActiveOpacity);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void All_four_sections_coexist_in_one_settings_json()
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue