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); /// Campaign OP slice OP3: the five client-local preferences the /// "Use Mouse Turning Settings" Gameplay-tab macro writes. 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); /// /// Campaign OP slice OP6 (2026-08-11): pushes the CURRENT audio /// snapshot into the live OpenAlAudioEngine — the SAME /// RuntimeSettingsStartupTargets.ApplyAudio static helper the /// startup path already used, now also reachable on every /// SaveAudio. 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. /// void ApplyAudio(AudioSettings audio); void ApplyQuality(QualitySettings quality); void ApplyUiLock(bool locked); /// /// Campaign CH slice CH3 (2026-08-09): the generation-gated seam /// (matching J4.4's pattern) that publishes retail's /// SetSingleCharacterOption (0x0005) for one Settings Chat /// toggle. is an ACE /// CharacterOption id (e.g. ListenToGeneralChat = 0x23). /// void SetSingleCharacterOption(uint optionId, bool value); /// /// Campaign CH slice CH6c (2026-08-10): pushes the Chat tab's two linked /// transparency sliders into the live RetailWindowOpacityController — /// local-only (no server round-trip, unlike ), /// applies with no restart, matches retail's own /// UpdateFromPlayerModule call order (default before active). /// void SetChatOpacity(float defaultOpacity, float activeOpacity); } internal interface IRuntimeSettingsPreviewSource { bool HasDraftPreview { get; } DisplaySettings DisplayPreview { get; } AudioSettings AudioPreview { get; } } /// /// 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. /// internal sealed class RuntimeSettingsController : IRuntimeSettingsPreviewSource { private const string DefaultToonKey = "default"; private readonly IRuntimeSettingsStorage _storage; private readonly Func _resolveQuality; private readonly Action _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? resolveQuality = null, Action? 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}"); } /// /// 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. /// 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); } /// /// OP9: rewritten off the retired client-local GameplaySettings.LockUI /// mirror (MUST-FIX 4, OP4 review-fix round, 2026-08-11, blast M3) — the /// guard now compares directly against , /// the last value actually pushed to , with /// no persisted store of its own left to read or write: the server bit /// (RuntimeCharacterOptionsState, read through /// CharacterOptionId.LockUI) is the sole authority, exactly as /// D7's Group-C re-point already made it at OP4. /// public void SetUiLocked(bool locked) { if (_lastAppliedUiLocked == locked) return; _runtimeTargets?.ApplyUiLock(locked); _lastAppliedUiLocked = locked; } /// /// Retail ClientCommunicationSystem::DoFrameRate @ 0x005707D0 /// 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). /// public void ToggleFrameRate() { Display = Display with { ShowFps = !Display.ShowFps }; try { _storage.SaveDisplay(Display); } catch (Exception ex) { _log($"settings: framerate display save failed: {ex.Message}"); } } /// /// 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 /// /) — /// 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. /// public CameraTurningSettings LoadCameraTurning() => _storage.LoadCameraTurning(); /// Persists the camera-turning preferences. Failures are logged, /// not thrown — matching every other Set* save call in this /// class. 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); } /// Widened from private to public at Campaign OP slice OP6 — /// same shape as the already-public , /// now also called directly by ConfigOptionsPageController's /// Display-backed rows (resolution/fullscreen/vsync/FOV/gamma/quality /// family) rather than only through the (OP9-retired) SettingsVM /// callback wiring. 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}"); } } /// Widened from private to public at Campaign OP slice OP6, /// same reason as . Also now pushes the saved /// snapshot into the live engine via /// — 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. 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}"); } } /// Widened from private to public at Campaign OP slice OP6, /// same reason as — 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. 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); } /// /// CH3 (2026-08-09): reseed the persisted + draft Chat snapshot from the /// server's own CharacterOptions2 bitfield (already parsed out of /// PlayerDescription) — called whenever a fresh description lands. N4 /// (CH3 Opus review, 2026-08-09) aligned /// 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. /// 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}"); } } /// /// OP4 re-review R2 (2026-08-11): assigned by the retained-UI /// composition; fired (via ) from /// the same PlayerDescription seed hook that drives /// /SetUiLocked, 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. /// public Action? ServerOptionsSeeded { get; set; } /// public void NotifyServerOptionsSeeded() => ServerOptionsSeeded?.Invoke(); private static QualitySettings ResolveQuality(QualityPreset preset) => QualitySettings.WithEnvOverrides(QualitySettings.From(preset)); }