acdream/src/AcDream.App/Settings/RuntimeSettingsController.cs
Erik 07f2b3f72e fix(settings): OP9 review residuals — restore SaveAudio live-apply pin, delete dead residues
Closes the OP9 combined review's findings (docs/research/2026-08-11-op9-review.md,
APPROVE-WITH-FIXES):

- MUST-FIX 1: SaveAudio -> ApplyAudio (OP6's Config-tab live-apply) lost
  its ONLY assertion when the retired SettingsVM save-order test was
  deleted. Restored directly on the now-public seam:
  SaveAudioPersistsThenPushesLiveApplyAudioWithTheSavedSnapshot pins
  persist-then-push order + the pushed snapshot;
  SaveAudioSkipsTheLivePushWhenPersistenceFails pins the failure ordering
  (a failed persist pushes nothing and commits nothing). Also closes
  SF-5: the OP6 effective-volume comment's 'target-audio assertion above'
  reference is real again and now names the restored test.
- SF-3: dead residues deleted — RuntimeSettingsController's private
  SaveCharacter (zero callers post-371197a3), ISettingsStorage.SaveCharacter
  + its JsonRuntimeSettingsStorage/FakeStorage implementations (the deleted
  private method was the only caller), and IngressShutdownRoots.Settings
  (zero readers since the view-model shutdown stage died). SettingsStore's
  PUBLIC SaveCharacter stays: it is the tested storage-API seam, and
  per-toon entries in existing settings.json files still load through the
  live LoadCharacter path.
- SF-2: code-structure.md's presentation-seam list no longer routes the
  settings preview through 'optional SettingsVM'.
- NIT 6: AP-196's retirement note now attributes LockUI (/lockui +
  PlayerDescription SetUiLocked convergence) and UseMouseTurning
  (Gameplay-tab macro + Config-tab row) to their real channels instead of
  folding all 13 members into the Character tab.

Full Release suite: 13,077 passed / 4 skipped / 0 failed (13,075 + the two
restored tests). One unnamed App-assembly failure appeared on the first
post-fix full run and did not reproduce on the isolated assembly rerun nor
a second full run — consistent with the known #250-class parallel-load
flake, recorded here for honesty rather than silently rerun.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 13:42:39 +02:00

544 lines
20 KiB
C#

using AcDream.Core.Net.Messages;
using AcDream.UI.Abstractions.Panels.Settings;
using AcDream.UI.Abstractions.Settings;
namespace AcDream.App.Settings;
internal interface IRuntimeSettingsStorage
{
SettingsStore? LayoutStore { get; }
string Location { get; }
DisplaySettings LoadDisplay();
AudioSettings LoadAudio();
ChatSettings LoadChat();
CharacterSettings LoadCharacter(string toonKey);
/// <summary>Campaign OP slice OP3: the five client-local preferences the
/// "Use Mouse Turning Settings" Gameplay-tab macro writes.</summary>
CameraTurningSettings LoadCameraTurning();
void SaveDisplay(DisplaySettings display);
void SaveAudio(AudioSettings audio);
void SaveChat(ChatSettings chat);
void SaveCameraTurning(CameraTurningSettings cameraTurning);
}
internal sealed class JsonRuntimeSettingsStorage : IRuntimeSettingsStorage
{
private readonly SettingsStore _store;
public JsonRuntimeSettingsStorage(string path)
{
ArgumentException.ThrowIfNullOrWhiteSpace(path);
Location = path;
_store = new SettingsStore(path);
}
public SettingsStore LayoutStore => _store;
public string Location { get; }
public DisplaySettings LoadDisplay() => _store.LoadDisplay();
public AudioSettings LoadAudio() => _store.LoadAudio();
public ChatSettings LoadChat() => _store.LoadChat();
public CharacterSettings LoadCharacter(string toonKey) =>
_store.LoadCharacter(toonKey);
public CameraTurningSettings LoadCameraTurning() => _store.LoadCameraTurning();
public void SaveDisplay(DisplaySettings display) => _store.SaveDisplay(display);
public void SaveAudio(AudioSettings audio) => _store.SaveAudio(audio);
public void SaveChat(ChatSettings chat) => _store.SaveChat(chat);
public void SaveCameraTurning(CameraTurningSettings cameraTurning) =>
_store.SaveCameraTurning(cameraTurning);
}
internal sealed record RuntimeSettingsSnapshot(
DisplaySettings Display,
AudioSettings Audio,
ChatSettings Chat,
CharacterSettings Character,
QualitySettings Quality);
internal interface IRuntimeSettingsStartupTarget
{
void ApplyDisplay(DisplaySettings display);
void ApplyAudio(AudioSettings audio);
}
internal interface IRuntimeSettingsTargets
{
void ApplyDisplayWindowState(DisplaySettings display);
/// <summary>
/// Campaign OP slice OP6 (2026-08-11): pushes the CURRENT audio
/// snapshot into the live <c>OpenAlAudioEngine</c> — the SAME
/// <c>RuntimeSettingsStartupTargets.ApplyAudio</c> static helper the
/// startup path already used, now also reachable on every
/// <c>SaveAudio</c>. Before this, an Audio-tab change never took
/// effect until the next process launch; the mechanism already
/// existed, it was just never invoked outside startup.
/// </summary>
void ApplyAudio(AudioSettings audio);
void ApplyQuality(QualitySettings quality);
void ApplyUiLock(bool locked);
/// <summary>
/// Campaign CH slice CH3 (2026-08-09): the generation-gated seam
/// (matching J4.4's pattern) that publishes retail's
/// <c>SetSingleCharacterOption (0x0005)</c> for one Settings Chat
/// toggle. <paramref name="optionId"/> is an ACE
/// <c>CharacterOption</c> id (e.g. <c>ListenToGeneralChat = 0x23</c>).
/// </summary>
void SetSingleCharacterOption(uint optionId, bool value);
/// <summary>
/// Campaign CH slice CH6c (2026-08-10): pushes the Chat tab's two linked
/// transparency sliders into the live <c>RetailWindowOpacityController</c> —
/// local-only (no server round-trip, unlike <see cref="SetSingleCharacterOption"/>),
/// applies with no restart, matches retail's own
/// <c>UpdateFromPlayerModule</c> call order (default before active).
/// </summary>
void SetChatOpacity(float defaultOpacity, float activeOpacity);
}
internal interface IRuntimeSettingsPreviewSource
{
bool HasDraftPreview { get; }
DisplaySettings DisplayPreview { get; }
AudioSettings AudioPreview { get; }
}
/// <summary>
/// Owns the one persisted settings snapshot, its live mutations, and the active
/// character context. Runtime objects are borrowed through typed targets and
/// are never constructed or disposed here.
/// </summary>
internal sealed class RuntimeSettingsController :
IRuntimeSettingsPreviewSource
{
private const string DefaultToonKey = "default";
private readonly IRuntimeSettingsStorage _storage;
private readonly Func<QualityPreset, QualitySettings> _resolveQuality;
private readonly Action<string> _log;
private IRuntimeSettingsTargets? _runtimeTargets;
private CharacterSettings _defaultCharacter;
private bool _startupDisplayApplied;
private bool _startupAudioApplied;
private bool _startupApplied;
// MUST-FIX 4 (OP4 review-fix round, 2026-08-11, blast M3): the last
// `locked` value actually pushed to `_runtimeTargets.ApplyUiLock` —
// the guard `SetUiLocked` compares against. Originally decoupled from
// the retired client-local `GameplaySettings.LockUI` mirror (OP9
// deleted that record outright); this is now the ONLY store.
private bool? _lastAppliedUiLocked;
public RuntimeSettingsController(
IRuntimeSettingsStorage storage,
Func<QualityPreset, QualitySettings>? resolveQuality = null,
Action<string>? log = null)
{
_storage = storage ?? throw new ArgumentNullException(nameof(storage));
_resolveQuality = resolveQuality ?? ResolveQuality;
_log = log ?? Console.WriteLine;
Display = _storage.LoadDisplay();
Audio = _storage.LoadAudio();
Chat = _storage.LoadChat();
_defaultCharacter = _storage.LoadCharacter(DefaultToonKey);
Character = _defaultCharacter;
ResolvedQuality = _resolveQuality(Display.Quality);
Startup = new RuntimeSettingsSnapshot(
Display,
Audio,
Chat,
Character,
ResolvedQuality);
}
public RuntimeSettingsSnapshot Startup { get; }
public SettingsStore? LayoutStore => _storage.LayoutStore;
public string ActiveToonKey { get; private set; } = DefaultToonKey;
public DisplaySettings Display { get; private set; }
public AudioSettings Audio { get; private set; }
public ChatSettings Chat { get; private set; }
public CharacterSettings Character { get; private set; }
public QualitySettings ResolvedQuality { get; private set; }
// OP9: the optional developer-tools draft-preview view model
// (SettingsVM) was retired — it had zero production construction
// sites (only tests ever called CreateViewModel). HasDraftPreview is
// therefore always false in production and DisplayPreview/AudioPreview
// always mirror the committed Display/Audio snapshot; the properties
// stay on IRuntimeSettingsPreviewSource because WorldRenderFrameBuilder
// and SettingsParticleRangeSource still consume the interface.
public bool HasDraftPreview => false;
public DisplaySettings DisplayPreview => Display;
public AudioSettings AudioPreview => Audio;
public void ApplyStartup(IRuntimeSettingsStartupTarget target)
{
ArgumentNullException.ThrowIfNull(target);
if (_startupApplied)
throw new InvalidOperationException("Runtime settings startup was already applied.");
if (!_startupDisplayApplied)
{
target.ApplyDisplay(Startup.Display);
_startupDisplayApplied = true;
}
if (!_startupAudioApplied)
{
target.ApplyAudio(Startup.Audio);
_startupAudioApplied = true;
}
_startupApplied = true;
QualitySettings baseQuality = QualitySettings.From(Startup.Display.Quality);
_log(Startup.Quality.Equals(baseQuality)
? $"[QUALITY] Preset {Startup.Display.Quality} -> {Startup.Quality}"
: $"[QUALITY] Preset {Startup.Display.Quality} overridden by env vars: {Startup.Quality}");
}
/// <summary>
/// Installs the complete future-change target. Deliberately does not replay
/// startup display, quality, or UI-lock values; those were consumed by the
/// factories that created the borrowed target objects.
/// </summary>
public void BindRuntimeTargets(IRuntimeSettingsTargets targets)
{
ArgumentNullException.ThrowIfNull(targets);
if (_runtimeTargets is not null)
throw new InvalidOperationException("Runtime settings targets are already bound.");
_runtimeTargets = targets;
}
public IDisposable BindRuntimeTargetsOwned(IRuntimeSettingsTargets targets)
{
BindRuntimeTargets(targets);
return new RuntimeTargetBinding(this, targets);
}
private void UnbindRuntimeTargets(IRuntimeSettingsTargets expected)
{
if (ReferenceEquals(_runtimeTargets, expected))
_runtimeTargets = null;
}
public void UnbindRuntimeTargets() => _runtimeTargets = null;
private sealed class RuntimeTargetBinding : IDisposable
{
private RuntimeSettingsController? _owner;
private readonly IRuntimeSettingsTargets _expected;
public RuntimeTargetBinding(
RuntimeSettingsController owner,
IRuntimeSettingsTargets expected)
{
_owner = owner;
_expected = expected;
}
public void Dispose() =>
Interlocked.Exchange(ref _owner, null)?
.UnbindRuntimeTargets(_expected);
}
/// <summary>
/// OP9: rewritten off the retired client-local <c>GameplaySettings.LockUI</c>
/// mirror (MUST-FIX 4, OP4 review-fix round, 2026-08-11, blast M3) — the
/// guard now compares directly against <see cref="_lastAppliedUiLocked"/>,
/// the last value actually pushed to <see cref="_runtimeTargets"/>, with
/// no persisted store of its own left to read or write: the server bit
/// (<c>RuntimeCharacterOptionsState</c>, read through
/// <c>CharacterOptionId.LockUI</c>) is the sole authority, exactly as
/// D7's Group-C re-point already made it at OP4.
/// </summary>
public void SetUiLocked(bool locked)
{
if (_lastAppliedUiLocked == locked)
return;
_runtimeTargets?.ApplyUiLock(locked);
_lastAppliedUiLocked = locked;
}
/// <summary>
/// Retail <c>ClientCommunicationSystem::DoFrameRate @ 0x005707D0</c>
/// flips the live flag and sends the framerate-display UI notice. acdream
/// additionally persists that live value through its modern Display bag
/// (registered as AP-121).
/// </summary>
public void ToggleFrameRate()
{
Display = Display with { ShowFps = !Display.ShowFps };
try
{
_storage.SaveDisplay(Display);
}
catch (Exception ex)
{
_log($"settings: framerate display save failed: {ex.Message}");
}
}
/// <summary>
/// Campaign OP slice OP3: the five client-local preferences the "Use
/// Mouse Turning Settings" Gameplay-tab macro reads/writes. Read
/// directly through storage (no startup-snapshot cache, unlike
/// <see cref="Display"/>/<see cref="Chat"/>) —
/// this section has no UI surface of its own yet (Campaign OP slice
/// OP6's Config tab), so there is nothing today that needs a cached,
/// change-notified copy.
/// </summary>
public CameraTurningSettings LoadCameraTurning() => _storage.LoadCameraTurning();
/// <summary>Persists the camera-turning preferences. Failures are logged,
/// not thrown — matching every other <c>Set*</c> save call in this
/// class.</summary>
public void SaveCameraTurning(CameraTurningSettings cameraTurning)
{
ArgumentNullException.ThrowIfNull(cameraTurning);
try
{
_storage.SaveCameraTurning(cameraTurning);
}
catch (Exception ex)
{
_log($"settings: camera-turning save failed: {ex.Message}");
}
}
public void SetActiveCharacter(string characterName)
{
ArgumentException.ThrowIfNullOrWhiteSpace(characterName);
ActiveToonKey = characterName;
}
public void LoadCharacterContext(string characterName)
{
ArgumentException.ThrowIfNullOrWhiteSpace(characterName);
ActiveToonKey = characterName;
Character = _storage.LoadCharacter(characterName);
_log($"settings: loaded character[{characterName}] preferences");
}
public void RestoreDefaultCharacterContext()
{
Character = _defaultCharacter;
}
public void ResetActiveCharacterKey() => ActiveToonKey = DefaultToonKey;
public void ReapplyQualityPreset(QualityPreset preset)
{
QualitySettings resolved = _resolveQuality(preset);
_log($"[QUALITY] ReapplyQualityPreset: {preset} -> {resolved}");
if (resolved.MsaaSamples != ResolvedQuality.MsaaSamples)
{
_log(
$"[QUALITY] MSAA samples change ({ResolvedQuality.MsaaSamples} -> " +
$"{resolved.MsaaSamples}) requires a restart - skipped for this session.");
}
ResolvedQuality = resolved;
_runtimeTargets?.ApplyQuality(resolved);
}
/// <summary>Widened from private to public at Campaign OP slice OP6 —
/// same shape as the already-public <see cref="SaveCameraTurning"/>,
/// now also called directly by <c>ConfigOptionsPageController</c>'s
/// Display-backed rows (resolution/fullscreen/vsync/FOV/gamma/quality
/// family) rather than only through the (OP9-retired) SettingsVM
/// callback wiring.</summary>
public void SaveDisplay(DisplaySettings display)
{
try
{
_storage.SaveDisplay(display);
_log($"settings: display saved to {_storage.Location}");
_runtimeTargets?.ApplyDisplayWindowState(display);
Display = display;
ReapplyQualityPreset(display.Quality);
}
catch (Exception ex)
{
_log($"settings: display save failed: {ex.Message}");
}
}
/// <summary>Widened from private to public at Campaign OP slice OP6,
/// same reason as <see cref="SaveDisplay"/>. Also now pushes the saved
/// snapshot into the live engine via
/// <see cref="IRuntimeSettingsTargets.ApplyAudio"/> — previously this
/// method only persisted; Audio-tab changes took effect on the NEXT
/// launch only. OP6's Sound-trio sliders/toggles are the first live
/// consumer.</summary>
public void SaveAudio(AudioSettings audio)
{
try
{
_storage.SaveAudio(audio);
Audio = audio;
_runtimeTargets?.ApplyAudio(audio);
_log($"settings: audio saved to {_storage.Location}");
}
catch (Exception ex)
{
_log($"settings: audio save failed: {ex.Message}");
}
}
/// <summary>Widened from private to public at Campaign OP slice OP6,
/// same reason as <see cref="SaveDisplay"/> — the Config tab's Chat
/// Font Face/Size rows (store-only, no diff against the five wired
/// Hear*Chat fields) reuse this exact seam rather than a parallel
/// save path.</summary>
public void SaveChat(ChatSettings chat)
{
ChatSettings previous = Chat;
try
{
_storage.SaveChat(chat);
Chat = chat;
_log($"settings: chat saved to {_storage.Location}");
}
catch (Exception ex)
{
_log($"settings: chat save failed: {ex.Message}");
return;
}
// CH3 (2026-08-09): retail toggles a Hear*Chat option and pushes
// SetSingleCharacterOption (0x0005) in the same step (mirrors
// SetUiLocked's ApplyUiLock push above) — ACE's handler both flips
// the option AND joins/leaves the matching Turbine room.
PublishHearOptionChange(
previous.HearGeneralChat, chat.HearGeneralChat,
(uint)CharacterOptionId.ListenToGeneralChat);
PublishHearOptionChange(
previous.HearTradeChat, chat.HearTradeChat,
(uint)CharacterOptionId.ListenToTradeChat);
PublishHearOptionChange(
previous.HearLFGChat, chat.HearLFGChat,
(uint)CharacterOptionId.ListenToLFGChat);
PublishHearOptionChange(
previous.HearRoleplayChat, chat.HearRoleplayChat,
(uint)CharacterOptionId.ListenToRoleplayChat);
PublishHearOptionChange(
previous.HearSocietyChat, chat.HearSocietyChat,
(uint)CharacterOptionId.ListenToSocietyChat);
// CH6c: local-only live apply — unlike the Hear* options above, this never
// touches the wire (retail's 0x1000008C blob remains unparsed, per the
// window-shell research doc §4.4/§6.1). Always pushed (not diffed) so the
// linking invariant self-heals even if only one field nominally changed.
_runtimeTargets?.SetChatOpacity(chat.DefaultOpacity, chat.ActiveOpacity);
}
private void PublishHearOptionChange(bool previous, bool current, uint optionId)
{
if (previous == current)
return;
_runtimeTargets?.SetSingleCharacterOption(optionId, current);
}
/// <summary>
/// CH3 (2026-08-09): reseed the persisted + draft Chat snapshot from the
/// server's own <c>CharacterOptions2</c> bitfield (already parsed out of
/// PlayerDescription) — called whenever a fresh description lands. N4
/// (CH3 Opus review, 2026-08-09) aligned <see cref="ChatSettings.Default"/>
/// to ACE's real default (Roleplay/Society start OFF server-side), but
/// this sync remains the only way the checkbox reflects truth for a
/// character whose PERSISTED settings.json diverges from the server —
/// an older save, or a character whose allegiance/society membership
/// changed since the file was last written. The server is always
/// authoritative, regardless of what the local default or a stale save
/// says.
/// </summary>
public void SyncChatFromServerOptions(uint options2)
{
// Reseed function applied to the persisted snapshot below so an
// unsaved edit to an unrelated field (font size, timestamps, ...)
// survives the reseed instead of being clobbered by a value
// computed once against a stale snapshot.
ChatSettings Reseed(ChatSettings current) => current with
{
HearGeneralChat = (options2
& (uint)PlayerDescriptionParser.CharacterOptions2.HearGeneralChat) != 0u,
HearTradeChat = (options2
& (uint)PlayerDescriptionParser.CharacterOptions2.HearTradeChat) != 0u,
HearLFGChat = (options2
& (uint)PlayerDescriptionParser.CharacterOptions2.HearLFGChat) != 0u,
HearRoleplayChat = (options2
& (uint)PlayerDescriptionParser.CharacterOptions2.HearRoleplayChat) != 0u,
HearSocietyChat = (options2
& (uint)PlayerDescriptionParser.CharacterOptions2.HearSocietyChat) != 0u,
};
ChatSettings synced = Reseed(Chat);
if (synced == Chat)
return;
Chat = synced;
try
{
_storage.SaveChat(synced);
_log($"settings: chat synced from server options2=0x{options2:X8}");
}
catch (Exception ex)
{
_log($"settings: chat sync save failed: {ex.Message}");
}
}
/// <summary>
/// OP4 re-review R2 (2026-08-11): assigned by the retained-UI
/// composition; fired (via <see cref="NotifyServerOptionsSeeded"/>) from
/// the same PlayerDescription seed hook that drives
/// <see cref="SyncChatFromServerOptions"/>/<c>SetUiLocked</c>, so OPEN
/// option-bearing panels (the Options panel's active page, the Combat
/// panel's three LEDs) re-read live bits at every seed — login AND
/// reconnect — instead of holding stale rows and a stale undo baseline
/// until their next show.
/// </summary>
public Action? ServerOptionsSeeded { get; set; }
/// <inheritdoc cref="ServerOptionsSeeded"/>
public void NotifyServerOptionsSeeded() => ServerOptionsSeeded?.Invoke();
private static QualitySettings ResolveQuality(QualityPreset preset) =>
QualitySettings.WithEnvOverrides(QualitySettings.From(preset));
}