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>
326 lines
11 KiB
C#
326 lines
11 KiB
C#
using AcDream.App.Audio;
|
|
using AcDream.App.Net;
|
|
using AcDream.App.Rendering;
|
|
using AcDream.App.Rendering.Wb;
|
|
using AcDream.App.Streaming;
|
|
using AcDream.App.UI;
|
|
using AcDream.UI.Abstractions;
|
|
using AcDream.UI.Abstractions.Panels.Settings;
|
|
using AcDream.UI.Abstractions.Settings;
|
|
using Silk.NET.Maths;
|
|
using Silk.NET.Windowing;
|
|
|
|
namespace AcDream.App.Settings;
|
|
|
|
internal interface IRuntimeDisplayWindowTarget
|
|
{
|
|
void Apply(DisplaySettings display);
|
|
}
|
|
|
|
internal interface IRuntimeQualityApplicationTarget
|
|
{
|
|
void SetAlphaToCoverage(bool enabled);
|
|
|
|
void SetAnisotropic(int level);
|
|
|
|
void PublishRenderRange(int nearRadius, int farRadius);
|
|
|
|
void ReconfigureStreamingRadii(int nearRadius, int farRadius);
|
|
|
|
void SetCompletionBudget(int maxCompletionsPerFrame);
|
|
}
|
|
|
|
internal interface IRuntimeUiLockTarget
|
|
{
|
|
void Apply(bool locked);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Campaign CH slice CH6c target seam for the Chat tab's transparency sliders,
|
|
/// mirroring <see cref="IRuntimeUiLockTarget"/>'s shape.
|
|
/// </summary>
|
|
internal interface IRuntimeChatOpacityTarget
|
|
{
|
|
void Apply(float defaultOpacity, float activeOpacity);
|
|
}
|
|
|
|
internal sealed class SilkRuntimeDisplayWindowTarget : IRuntimeDisplayWindowTarget
|
|
{
|
|
private readonly IWindow _window;
|
|
|
|
public SilkRuntimeDisplayWindowTarget(IWindow window)
|
|
{
|
|
_window = window ?? throw new ArgumentNullException(nameof(window));
|
|
}
|
|
|
|
public void Apply(DisplaySettings display)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(display);
|
|
if (TryParseResolution(display.Resolution, out int width, out int height)
|
|
&& (_window.Size.X != width || _window.Size.Y != height))
|
|
{
|
|
_window.Size = new Vector2D<int>(width, height);
|
|
}
|
|
|
|
WindowState desired = display.Fullscreen
|
|
? WindowState.Fullscreen
|
|
: WindowState.Normal;
|
|
if (_window.WindowState != desired)
|
|
_window.WindowState = desired;
|
|
}
|
|
|
|
internal static bool TryParseResolution(
|
|
string spec,
|
|
out int width,
|
|
out int height)
|
|
{
|
|
width = height = 0;
|
|
if (string.IsNullOrWhiteSpace(spec))
|
|
return false;
|
|
string[] parts = spec.Split('x', 2);
|
|
return parts.Length == 2
|
|
&& int.TryParse(parts[0], out width)
|
|
&& int.TryParse(parts[1], out height)
|
|
&& width > 0
|
|
&& height > 0;
|
|
}
|
|
}
|
|
|
|
internal sealed class RuntimeSettingsStartupTargets : IRuntimeSettingsStartupTarget
|
|
{
|
|
private readonly IRuntimeDisplayWindowTarget _displayWindow;
|
|
private readonly DisplayFramePacingController _pacing;
|
|
private readonly CameraController _cameras;
|
|
private readonly OpenAlAudioEngine? _audio;
|
|
|
|
public RuntimeSettingsStartupTargets(
|
|
IRuntimeDisplayWindowTarget displayWindow,
|
|
DisplayFramePacingController pacing,
|
|
CameraController cameras,
|
|
OpenAlAudioEngine? audio)
|
|
{
|
|
_displayWindow = displayWindow
|
|
?? throw new ArgumentNullException(nameof(displayWindow));
|
|
_pacing = pacing ?? throw new ArgumentNullException(nameof(pacing));
|
|
_cameras = cameras ?? throw new ArgumentNullException(nameof(cameras));
|
|
_audio = audio;
|
|
}
|
|
|
|
public void ApplyDisplay(DisplaySettings display)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(display);
|
|
_pacing.RefreshActiveMonitor();
|
|
_pacing.ApplyPreference(display.VSync);
|
|
_displayWindow.Apply(display);
|
|
ApplyFieldOfView(_cameras, display.FieldOfView);
|
|
}
|
|
|
|
public void ApplyAudio(AudioSettings audio) => ApplyAudio(_audio, audio);
|
|
|
|
internal static void ApplyFieldOfView(
|
|
CameraController cameras,
|
|
float degrees)
|
|
{
|
|
float radians = degrees * (MathF.PI / 180f);
|
|
cameras.Orbit.FovY = radians;
|
|
cameras.Fly.FovY = radians;
|
|
if (cameras.Chase is not null)
|
|
cameras.Chase.FovY = radians;
|
|
}
|
|
|
|
internal static void ApplyAudio(
|
|
OpenAlAudioEngine? engine,
|
|
AudioSettings audio)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(audio);
|
|
if (engine is not { IsAvailable: true })
|
|
return;
|
|
engine.MasterVolume = audio.Master;
|
|
engine.SfxVolume = audio.Sfx;
|
|
engine.AmbientVolume = audio.Ambient;
|
|
}
|
|
}
|
|
|
|
internal sealed class RuntimeQualityApplicationTarget
|
|
: IRuntimeQualityApplicationTarget
|
|
{
|
|
// Campaign V slice V6h: absent on a backend that composes no world
|
|
// renderers. Alpha-to-coverage and anisotropy are properties of renderers
|
|
// that do not exist there; render range and streaming radii still apply.
|
|
private readonly WbDrawDispatcher? _dispatcher;
|
|
private readonly TerrainAtlas? _terrainAtlas;
|
|
private readonly StreamingController _streaming;
|
|
private readonly WorldRenderRangeState _renderRange;
|
|
|
|
public RuntimeQualityApplicationTarget(
|
|
WbDrawDispatcher? dispatcher,
|
|
TerrainAtlas? terrainAtlas,
|
|
StreamingController streaming,
|
|
WorldRenderRangeState renderRange)
|
|
{
|
|
_dispatcher = dispatcher;
|
|
_terrainAtlas = terrainAtlas;
|
|
_streaming = streaming ?? throw new ArgumentNullException(nameof(streaming));
|
|
_renderRange = renderRange ?? throw new ArgumentNullException(nameof(renderRange));
|
|
}
|
|
|
|
public void SetAlphaToCoverage(bool enabled)
|
|
{
|
|
if (_dispatcher is not null)
|
|
_dispatcher.AlphaToCoverage = enabled;
|
|
}
|
|
|
|
public void SetAnisotropic(int level) => _terrainAtlas?.SetAnisotropic(level);
|
|
|
|
public void PublishRenderRange(int nearRadius, int farRadius)
|
|
{
|
|
_renderRange.NearRadius = nearRadius;
|
|
_renderRange.FarRadius = farRadius;
|
|
}
|
|
|
|
public void ReconfigureStreamingRadii(int nearRadius, int farRadius) =>
|
|
_streaming.ReconfigureRadii(nearRadius, farRadius);
|
|
|
|
public void SetCompletionBudget(int maxCompletionsPerFrame) =>
|
|
_streaming.MaxCompletionsPerFrame = maxCompletionsPerFrame;
|
|
}
|
|
|
|
internal sealed class RuntimeUiLockTarget(UiRoot root) : IRuntimeUiLockTarget
|
|
{
|
|
private readonly UiRoot _root = root ?? throw new ArgumentNullException(nameof(root));
|
|
|
|
public void Apply(bool locked) => _root.UiLocked = locked;
|
|
}
|
|
|
|
internal sealed class NullRuntimeUiLockTarget : IRuntimeUiLockTarget
|
|
{
|
|
public static NullRuntimeUiLockTarget Instance { get; } = new();
|
|
|
|
private NullRuntimeUiLockTarget()
|
|
{
|
|
}
|
|
|
|
public void Apply(bool locked)
|
|
{
|
|
}
|
|
}
|
|
|
|
internal sealed class RuntimeChatOpacityTarget(RetailWindowOpacityController controller)
|
|
: IRuntimeChatOpacityTarget
|
|
{
|
|
private readonly RetailWindowOpacityController _controller =
|
|
controller ?? throw new ArgumentNullException(nameof(controller));
|
|
|
|
public void Apply(float defaultOpacity, float activeOpacity) =>
|
|
_controller.SetOpacity(defaultOpacity, activeOpacity);
|
|
}
|
|
|
|
internal sealed class NullRuntimeChatOpacityTarget : IRuntimeChatOpacityTarget
|
|
{
|
|
public static NullRuntimeChatOpacityTarget Instance { get; } = new();
|
|
|
|
private NullRuntimeChatOpacityTarget()
|
|
{
|
|
}
|
|
|
|
public void Apply(float defaultOpacity, float activeOpacity)
|
|
{
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Complete late-bound target for changes made after startup. Construction and
|
|
/// binding are inert; only an explicit controller command mutates borrowers.
|
|
/// </summary>
|
|
internal sealed class RuntimeSettingsTargets : IRuntimeSettingsTargets
|
|
{
|
|
private readonly IRuntimeDisplayWindowTarget _displayWindow;
|
|
private readonly IRuntimeQualityApplicationTarget _quality;
|
|
private readonly IRuntimeUiLockTarget _uiLock;
|
|
private readonly IRuntimeChatOpacityTarget _chatOpacity;
|
|
private readonly ICommandBus _commands;
|
|
private readonly Action<string> _log;
|
|
|
|
public RuntimeSettingsTargets(
|
|
IRuntimeDisplayWindowTarget displayWindow,
|
|
WbDrawDispatcher? dispatcher,
|
|
TerrainAtlas? terrainAtlas,
|
|
StreamingController streaming,
|
|
WorldRenderRangeState renderRange,
|
|
UiRoot? uiRoot,
|
|
ICommandBus commands,
|
|
RetailWindowOpacityController? chatOpacity = null,
|
|
Action<string>? log = null)
|
|
: this(
|
|
displayWindow,
|
|
new RuntimeQualityApplicationTarget(
|
|
dispatcher,
|
|
terrainAtlas,
|
|
streaming,
|
|
renderRange),
|
|
uiRoot is null
|
|
? NullRuntimeUiLockTarget.Instance
|
|
: new RuntimeUiLockTarget(uiRoot),
|
|
commands,
|
|
log,
|
|
chatOpacity is null
|
|
? NullRuntimeChatOpacityTarget.Instance
|
|
: new RuntimeChatOpacityTarget(chatOpacity))
|
|
{
|
|
}
|
|
|
|
internal RuntimeSettingsTargets(
|
|
IRuntimeDisplayWindowTarget displayWindow,
|
|
IRuntimeQualityApplicationTarget quality,
|
|
IRuntimeUiLockTarget uiLock,
|
|
ICommandBus commands,
|
|
Action<string>? log = null,
|
|
IRuntimeChatOpacityTarget? chatOpacity = null)
|
|
{
|
|
_displayWindow = displayWindow
|
|
?? throw new ArgumentNullException(nameof(displayWindow));
|
|
_quality = quality ?? throw new ArgumentNullException(nameof(quality));
|
|
_uiLock = uiLock ?? throw new ArgumentNullException(nameof(uiLock));
|
|
_chatOpacity = chatOpacity ?? NullRuntimeChatOpacityTarget.Instance;
|
|
_commands = commands ?? throw new ArgumentNullException(nameof(commands));
|
|
_log = log ?? Console.WriteLine;
|
|
}
|
|
|
|
public void ApplyDisplayWindowState(DisplaySettings display) =>
|
|
_displayWindow.Apply(display);
|
|
|
|
public void ApplyQuality(QualitySettings quality)
|
|
{
|
|
_quality.SetAlphaToCoverage(quality.AlphaToCoverage);
|
|
_quality.SetAnisotropic(quality.AnisotropicLevel);
|
|
_quality.PublishRenderRange(quality.NearRadius, quality.FarRadius);
|
|
_quality.ReconfigureStreamingRadii(quality.NearRadius, quality.FarRadius);
|
|
_quality.SetCompletionBudget(quality.MaxCompletionsPerFrame);
|
|
_log(
|
|
$"[QUALITY] Streaming reconciled: nearRadius={quality.NearRadius}, " +
|
|
$"farRadius={quality.FarRadius}, " +
|
|
$"maxCompletions={quality.MaxCompletionsPerFrame}");
|
|
}
|
|
|
|
public void ApplyUiLock(bool locked) => _uiLock.Apply(locked);
|
|
|
|
/// <summary>
|
|
/// CH3 (2026-08-09): publishes through the SAME
|
|
/// <see cref="LiveSessionCommandRouter"/> generation-gated route every
|
|
/// other outbound Settings/chat command uses — a no-op when no route is
|
|
/// currently attached (disconnected / reconnecting), exactly like every
|
|
/// other <c>ICommandBus.Publish</c> call site. N6 (CH3 Opus review,
|
|
/// 2026-08-09): this silent drop is safe because
|
|
/// <c>RuntimeSettingsController.SaveChat</c> already wrote the toggle to
|
|
/// settings.json BEFORE calling here — the local preference is never
|
|
/// lost — and the next successful connect's PlayerDescription re-runs
|
|
/// <c>SyncChatFromServerOptions</c>, reconciling the draft/persisted
|
|
/// snapshot back to whatever the server actually has (which may or may
|
|
/// not match the dropped toggle, since the wire send never landed).
|
|
/// </summary>
|
|
public void SetSingleCharacterOption(uint optionId, bool value) =>
|
|
_commands.Publish(new SetSingleCharacterOptionRuntimeCmd(optionId, value));
|
|
|
|
public void SetChatOpacity(float defaultOpacity, float activeOpacity) =>
|
|
_chatOpacity.Apply(defaultOpacity, activeOpacity);
|
|
}
|