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>
544 lines
20 KiB
C#
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));
|
|
}
|