Retail's world-camera FOV is not a constant: the applied vertical FOV is m_fGameFOV / (viewportAspect - 0.1), recomputed on every aspect or game-FOV change (CreatureMode smartbox sites 0x00452b2f/0x00453b14), gated by Render::SetFOVRad's open (0, pi) acceptance (0x0054b2d0 - rejected results keep the previous FOV). m_fGameFOV defaults to pi/2 = 90 degrees (0x00454649) and is what the Field of View option sets in degrees (0x00451e6a; registered range [10,160] default 90 - gmClient::InitUIPreferences @0x004035b0). Net effect: the horizontal view stays ~85-90 degrees across aspect ratios; wide screens trim the vertical slice instead of ballooning the sides. acdream hardcoded FovY = pi/3 = 60 degrees on all four world cameras, aspect-independent, and the Config slider wrote raw vertical-FOV degrees. New: RetailFieldOfView (the law + gate, decomp-cited), CameraController.GameFovRadians + SetGameFov + one ApplyProjection chokepoint recomputing every camera on SetAspect/SetGameFov/ EnterChaseMode/RestoreState; ApplyFieldOfView now feeds the law; DisplaySettings.Default.FieldOfView 60 -> 90 (the retail registered default; the stored number changed MEANING with this commit). The same seam closes a second latent bug the 2026-08-13 "squished" gate report exposed: SetAspect only ever updated Orbit/Fly - the CHASE cameras (the ones the player looks through) kept their creation-time aspect across every mid-session resize, drawing the world at the old shape stretched onto the new viewport. The paperdoll camera stays outside the law by design (retail portrait mode is UseSharpMode, not smartbox - DollCamera's own doc). Tests: RetailFieldOfViewTests (golden law values at 4:3/16:9/21:9, the constant-horizontal property, the rejection gate, controller propagation incl. chase attach/restore + rejected-law aspect-still-propagates); DisplaySettingsTests + RuntimeSettingsControllerTests updated to the new semantics. App suite 4,953/3 skips; UI.Abstractions 916/0. AD-89 retired in this commit; user settings.json migrated 60->90 by hand (stale pre-port default). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
390 lines
15 KiB
C#
390 lines
15 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);
|
||
bool haveResolution =
|
||
TryParseResolution(display.Resolution, out int width, out int height);
|
||
if (haveResolution && (_window.Size.X != width || _window.Size.Y != height))
|
||
{
|
||
// #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);
|
||
}
|
||
|
||
WindowState desired = display.Fullscreen
|
||
? WindowState.Fullscreen
|
||
: WindowState.Normal;
|
||
|
||
// #374 investigation note: while FULLSCREEN, the visible resolution
|
||
// is the display's video mode, and Silk's abstract windowing API
|
||
// cannot change it (IViewProperties.VideoMode is read-only; Silk
|
||
// fullscreen is desktop-mode borderless). The Size write above is
|
||
// therefore only visible in windowed mode — a resolution pick while
|
||
// fullscreen changes what a later return to windowed restores, not
|
||
// the fullscreen mode itself. Retail's own fullscreen resolution
|
||
// switch (Device::ForceDisplayResolution) needs a native
|
||
// glfwSetWindowMonitor port — issue #376.
|
||
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);
|
||
|
||
/// <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;
|
||
|
||
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)
|
||
: 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)
|
||
{
|
||
}
|
||
|
||
internal RuntimeSettingsTargets(
|
||
IRuntimeDisplayWindowTarget displayWindow,
|
||
IRuntimeQualityApplicationTarget quality,
|
||
IRuntimeUiLockTarget uiLock,
|
||
ICommandBus commands,
|
||
Action<string>? log = null,
|
||
IRuntimeChatOpacityTarget? chatOpacity = null,
|
||
OpenAlAudioEngine? audio = 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;
|
||
}
|
||
|
||
public void ApplyDisplayWindowState(DisplaySettings display) =>
|
||
_displayWindow.Apply(display);
|
||
|
||
/// <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);
|
||
}
|