position memory, unified monitor, maximized restore; AD-92
Dual-lens Opus review of e56aa511 (reports committed under
docs/research/). The consolidated corrections:
- Mechanism M1 (load-bearing): on Windows, Silk's GLFW error callback
QUEUES exceptions on a static list instead of throwing - they detonate
later at window close, which is exactly #388's original two-stage
crash shape. catch(GlfwException) was dead code here and a failed
SetWindowMonitor "succeeded". Success is now judged by the NATIVE
POST-CONDITION (GetWindowMonitor after the call) on both enter and
exit; the catches remain only for the throwing platforms.
- M2 (both lenses): same-mode fullscreen re-apply is a no-op BEFORE any
native work (new IDisplayModeSwitcher.CurrentFullscreenMode). Every
Display-backed Config row applies per change - sliders per DRAG TICK -
so without this every tick while fullscreen re-issued a real
display-mode change.
- M3/M5 (both): the remembered windowed placement is process state (two
target instances exist - startup and live-save); a fullscreen boot now
exits through either instance to the real placement, not the (60,60)
literal.
- M4 (both): the switcher resolves the WINDOW'S monitor (attached
monitor when fullscreen, else IWindow.Monitor's index into the GLFW
array - the same monitor DisplayModeCatalog enumerated), primary only
as a last resort; the offered-list/switch-target mismatch is gone.
- Blast M2b: the offered-mode validator falls back to the SAME static
ladder the dropdown falls back to - Full Screen is no longer a
permanent silent no-op on catalog-less hosts (the switcher's own
monitor-mode-list check remains the hard guard).
- Blast M3: a windowed pick on a MAXIMIZED window restores it first
(Size writes are silently ignored while maximized; the deleted
WindowState=Normal write used to do this incidentally). New
IWindowedSizeSurface.IsMaximized/Restore.
- Mechanism M5: no silent bail-outs - the unparseable-resolution
fullscreen path logs, and the failure line no longer claims "staying
windowed" when the state is unchanged (#392 noted inline).
- Q1 nit: one cached Glfw wrapper (per-call GetApi allocated + took a
native refcount); IsFullscreen/CurrentFullscreenMode guarded.
- AD-92: highest-refresh-for-WxH + refuse-and-log versus retail's
pass-through-and-error ForceDisplayResolution.
Known-open tail, filed not hidden: #392 (persisted-flag divergence on a
refused enter - needs an apply-result seam); the mechanism report's
pacing-refresh WATCH rides the same seam.
Tests: +3 (same-mode no-op, unparseable-while-fullscreen refusal,
maximized restore-before-write). App suite 4,975/3 skips.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
512 lines
20 KiB
C#
512 lines
20 KiB
C#
using System.Linq;
|
||
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);
|
||
}
|
||
|
||
/// <summary>The window properties the display apply touches — a narrow
|
||
/// seam so the #388 state machine is testable without faking all of
|
||
/// <see cref="IWindow"/> (same idiom as <c>FakePacingSurface</c>'s
|
||
/// surface).</summary>
|
||
internal interface IWindowedSizeSurface
|
||
{
|
||
Vector2D<int> Size { get; set; }
|
||
|
||
/// <summary>#388 blast M3: a maximized window silently ignores a Size
|
||
/// write — the apply un-maximizes first (the deleted
|
||
/// <c>WindowState = Normal</c> write used to do this incidentally).</summary>
|
||
bool IsMaximized { get; }
|
||
|
||
void Restore();
|
||
}
|
||
|
||
internal sealed class SilkWindowSizeSurface(IWindow window) : IWindowedSizeSurface
|
||
{
|
||
private readonly IWindow _window = window
|
||
?? throw new ArgumentNullException(nameof(window));
|
||
|
||
public Vector2D<int> Size
|
||
{
|
||
get => _window.Size;
|
||
set => _window.Size = value;
|
||
}
|
||
|
||
public bool IsMaximized => _window.WindowState == WindowState.Maximized;
|
||
|
||
public void Restore() => _window.WindowState = WindowState.Normal;
|
||
}
|
||
|
||
internal sealed class SilkRuntimeDisplayWindowTarget : IRuntimeDisplayWindowTarget
|
||
{
|
||
private readonly IWindowedSizeSurface _window;
|
||
private readonly IDisplayModeSwitcher _modeSwitcher;
|
||
private readonly Func<string, bool> _isOfferedMode;
|
||
|
||
public SilkRuntimeDisplayWindowTarget(IWindow window)
|
||
: this(
|
||
new SilkWindowSizeSurface(window),
|
||
new GlfwDisplayModeSwitcher(window),
|
||
// #391's catalog is the validation source. With no catalog
|
||
// installed, the dropdown falls back to the static preset
|
||
// ladder — the validator must fall back to the SAME list
|
||
// (blast M2: an asymmetric fallback made Full Screen a permanent
|
||
// silent no-op on catalog-less hosts). The switcher's own
|
||
// monitor-mode-list check remains the hard guard either way.
|
||
spec => (Rendering.DisplayModeCatalog.Resolutions
|
||
?? DisplaySettings.AvailableResolutions).Contains(spec))
|
||
{
|
||
}
|
||
|
||
internal SilkRuntimeDisplayWindowTarget(
|
||
IWindowedSizeSurface window,
|
||
IDisplayModeSwitcher modeSwitcher,
|
||
Func<string, bool> isOfferedMode)
|
||
{
|
||
_window = window ?? throw new ArgumentNullException(nameof(window));
|
||
_modeSwitcher = modeSwitcher
|
||
?? throw new ArgumentNullException(nameof(modeSwitcher));
|
||
_isOfferedMode = isOfferedMode
|
||
?? throw new ArgumentNullException(nameof(isOfferedMode));
|
||
}
|
||
|
||
/// <summary>
|
||
/// #388: the state-aware display apply. Windowed target = a window
|
||
/// resize (the proven #387 chain); fullscreen target = a validated
|
||
/// native display-mode switch (#376, retail's
|
||
/// <c>Device::ForceDisplayResolution</c> semantics via
|
||
/// <c>glfwSetWindowMonitor</c>). A raw <c>Size</c> write NEVER happens
|
||
/// against a fullscreen window — on GLFW that is a video-mode request
|
||
/// and an unsupported one killed the client mid-session (the 2026-08-13
|
||
/// gate crash). Every failure path logs and leaves the window in a
|
||
/// usable state instead of throwing.
|
||
/// </summary>
|
||
public void Apply(DisplaySettings display)
|
||
{
|
||
ArgumentNullException.ThrowIfNull(display);
|
||
bool haveResolution =
|
||
TryParseResolution(display.Resolution, out int width, out int height);
|
||
|
||
if (display.Fullscreen)
|
||
{
|
||
if (!haveResolution)
|
||
{
|
||
// Mechanism M5: never a SILENT bail-out — §D6's acceptance is
|
||
// "any refused/failed switch logs a line".
|
||
Console.WriteLine(
|
||
$"display: fullscreen refused — unparseable resolution '{display.Resolution}'");
|
||
return;
|
||
}
|
||
// Mechanism/blast M2: idempotence BEFORE any native work — every
|
||
// Display-backed Config row applies per change (sliders per drag
|
||
// tick), and only this guard keeps those from re-issuing a real
|
||
// display-mode change per mouse sample.
|
||
if (_modeSwitcher.CurrentFullscreenMode is (int curW, int curH)
|
||
&& curW == width && curH == height)
|
||
return;
|
||
if (!_isOfferedMode.Invoke($"{width}x{height}"))
|
||
{
|
||
Console.WriteLine(
|
||
$"display: fullscreen {width}x{height} refused — not an offered mode");
|
||
return;
|
||
}
|
||
if (!_modeSwitcher.TryEnterFullscreen(width, height, out string? error))
|
||
Console.WriteLine(
|
||
$"display: fullscreen {width}x{height} failed ({error}) — window state unchanged (#392 tracks the persisted-flag divergence)");
|
||
return;
|
||
}
|
||
|
||
// Windowed target: leave fullscreen first if needed (the native exit
|
||
// sets the client size itself), otherwise plain window resize.
|
||
if (_modeSwitcher.IsFullscreen)
|
||
{
|
||
if (!haveResolution)
|
||
{
|
||
width = _window.Size.X;
|
||
height = _window.Size.Y;
|
||
}
|
||
if (!_modeSwitcher.TryLeaveFullscreen(width, height, out string? error))
|
||
Console.WriteLine(
|
||
$"display: leaving fullscreen failed ({error})");
|
||
return;
|
||
}
|
||
|
||
if (haveResolution && (_window.Size.X != width || _window.Size.Y != height))
|
||
{
|
||
// Blast M3: a maximized window ignores Size writes — restore
|
||
// first (the deleted WindowState=Normal write did this
|
||
// incidentally; now it is explicit and only-when-needed).
|
||
if (_window.IsMaximized)
|
||
_window.Restore();
|
||
// #387 evidence line (permanent): the resolution-pick write path.
|
||
Console.WriteLine(
|
||
$"display: resolution pick {width}x{height} " +
|
||
$"(window was {_window.Size.X}x{_window.Size.Y})");
|
||
_window.Size = new Vector2D<int>(width, height);
|
||
}
|
||
}
|
||
|
||
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);
|
||
|
||
/// <summary>#389: the stored Field of View is retail's <c>m_fGameFOV</c>
|
||
/// in DEGREES (registered range [10,160], default 90 —
|
||
/// <c>gmClient::InitUIPreferences @0x004035b0</c>), converted exactly as
|
||
/// retail's option setter does (<c>× 0.017453292519943295</c>,
|
||
/// <c>0x00451e6a</c>) and applied through the smartbox law in
|
||
/// <see cref="CameraController.SetGameFov"/> — NEVER written to a camera's
|
||
/// vertical FOV directly (the pre-#389 behavior, which made the slider
|
||
/// mean a different, aspect-ignorant thing than retail's).</summary>
|
||
internal static void ApplyFieldOfView(
|
||
CameraController cameras,
|
||
float degrees)
|
||
{
|
||
cameras.SetGameFov(degrees * (MathF.PI / 180f));
|
||
}
|
||
|
||
internal static void ApplyAudio(
|
||
OpenAlAudioEngine? engine,
|
||
AudioSettings audio)
|
||
{
|
||
ArgumentNullException.ThrowIfNull(audio);
|
||
if (engine is not { IsAvailable: true })
|
||
return;
|
||
engine.MasterVolume = audio.Master;
|
||
(float sfx, float ambient) = ComputeEffectiveCategoryVolumes(audio);
|
||
engine.SfxVolume = sfx;
|
||
engine.AmbientVolume = ambient;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Campaign OP slice OP6 (2026-08-11), CORRECTED at the OP6 rework round
|
||
/// (review <c>docs/research/2026-08-11-op6-review.md</c> finding M2): the
|
||
/// Config tab's toggle halves of the Sound/Ambient volume trios
|
||
/// (<see cref="AudioSettings.SfxEnabled"/>/<see cref="AudioSettings.AmbientEnabled"/>
|
||
/// — retail's own ENABLED-sense <c>SoundManager::effect_sounds_enabled</c>/
|
||
/// <c>ambient_sounds_enabled</c> statics, see <see cref="AudioSettings"/>'s
|
||
/// class doc for the byte evidence) gate the SAME slider value: the
|
||
/// effective volume sent to the engine is the slider value when enabled,
|
||
/// zero when not — exactly as if the user dragged the slider to zero.
|
||
/// Extracted as a pure function (no <see cref="OpenAlAudioEngine"/>
|
||
/// dependency) so the mapping itself — not just that some target was
|
||
/// called — is unit-testable without OpenAL hardware/mocking (S5: the
|
||
/// rejected slice's only audio test asserted event ORDER, never the
|
||
/// VALUE that reached the engine, which is exactly how M2's muted-by-
|
||
/// default inversion shipped unnoticed).
|
||
/// </summary>
|
||
internal static (float Sfx, float Ambient) ComputeEffectiveCategoryVolumes(AudioSettings audio)
|
||
{
|
||
ArgumentNullException.ThrowIfNull(audio);
|
||
float sfx = audio.SfxEnabled ? audio.Sfx : 0f;
|
||
float ambient = audio.AmbientEnabled ? audio.Ambient : 0f;
|
||
return (sfx, 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;
|
||
private readonly OpenAlAudioEngine? _audio;
|
||
private readonly CameraController? _cameras;
|
||
|
||
public RuntimeSettingsTargets(
|
||
IRuntimeDisplayWindowTarget displayWindow,
|
||
WbDrawDispatcher? dispatcher,
|
||
TerrainAtlas? terrainAtlas,
|
||
StreamingController streaming,
|
||
WorldRenderRangeState renderRange,
|
||
UiRoot? uiRoot,
|
||
ICommandBus commands,
|
||
RetailWindowOpacityController? chatOpacity = null,
|
||
Action<string>? log = null,
|
||
// Campaign OP slice OP6: the live engine reference — see
|
||
// ApplyAudio's doc. Optional/trailing so every pre-existing
|
||
// construction site keeps compiling unchanged (matches
|
||
// chatOpacity/log's own optional-trailing shape).
|
||
OpenAlAudioEngine? audio = null,
|
||
// #389 blast-review MUST-FIX 2: retail's FOV preference applies LIVE
|
||
// (Render::GRPCallback_OnRenderPreferenceChanged @0x0054d999 →
|
||
// SmartBox::SetDefaultFov → m_fGameFOV, re-read by the smartbox
|
||
// sites every render) — the saved Field of View must reach the
|
||
// cameras on Save, not on the next launch. Null on hosts with no
|
||
// camera graph (headless / fixture callers).
|
||
CameraController? cameras = 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),
|
||
audio,
|
||
cameras)
|
||
{
|
||
}
|
||
|
||
internal RuntimeSettingsTargets(
|
||
IRuntimeDisplayWindowTarget displayWindow,
|
||
IRuntimeQualityApplicationTarget quality,
|
||
IRuntimeUiLockTarget uiLock,
|
||
ICommandBus commands,
|
||
Action<string>? log = null,
|
||
IRuntimeChatOpacityTarget? chatOpacity = null,
|
||
OpenAlAudioEngine? audio = null,
|
||
CameraController? cameras = 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;
|
||
_audio = audio;
|
||
_cameras = cameras;
|
||
}
|
||
|
||
public void ApplyDisplayWindowState(DisplaySettings display)
|
||
{
|
||
_displayWindow.Apply(display);
|
||
// #389 blast MUST-FIX 2 (see the ctor's cameras doc): the Field of
|
||
// View applies live on Save, from the update-phase save handler —
|
||
// deliberately NOT from the render-phase preview seam
|
||
// (WorldRenderFrameBuilder.Apply), whose mid-frame camera mutation
|
||
// is the review's WATCH-3 cull-vs-raster landmine.
|
||
if (_cameras is not null)
|
||
RuntimeSettingsStartupTargets.ApplyFieldOfView(_cameras, display.FieldOfView);
|
||
}
|
||
|
||
/// <summary>Campaign OP slice OP6: reuses the SAME static helper the
|
||
/// startup path (<see cref="RuntimeSettingsStartupTargets.ApplyAudio"/>)
|
||
/// already runs — one mixer-apply implementation, two call sites (once
|
||
/// at process start, now also on every <c>RuntimeSettingsController.
|
||
/// SaveAudio</c>). <see langword="null"/> engine (a headless/no-audio
|
||
/// host) is a silent no-op, matching every other optional target in
|
||
/// this class.</summary>
|
||
public void ApplyAudio(AudioSettings audio) =>
|
||
RuntimeSettingsStartupTargets.ApplyAudio(_audio, audio);
|
||
|
||
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);
|
||
}
|