Applies the Opus review of Campaign CH slice CH3 (614a1e05):
- B1: UN-9 was a phantom divergence — ACE's CharacterOptions1.cs:47
OR-sum is 0x50C4A54A (its own comment confirms 1355064650), identical
to acdream's literal. The wrong 0x50C48D4A existed only in the research
doc. Row deleted, register §5 reverted to 4 rows, research doc corrected
with dated notes.
- S1/S4: AllegianceBroadcast (0x02000000) is a server-echoing channel —
ACE's GameActionChatChannel handler includes the sender in its real-name
Allegiance.Members broadcast (retail's DoAllegianceBroadcast has no
AddTextToScroll), so the client must skip its local optimistic echo, not
keep it. ChatChannelInfo.Legacy.IsSelfEchoChannel() now returns true for
it; RouteLegacyChannel's comment corrected; Turbine.IsSelfEchoChannel()'s
backwards comment rewritten truthfully.
- S3: retail's /a stays on the legacy AllegianceBroadcast bitflag until
StartupTurbineChatSystem successfully starts Turbine chat — "never
started" (TurbineChatState.Enabled == false) now falls back to legacy in
both LiveSessionCommandRouter.RouteChat and
DirectGameRuntimeCommandAdapter.TrySendChannel, while "enabled but no
allegiance room" still correctly refuses locally.
- S5: added a LiveSessionEventRouter test proving the Options.Replace ->
OnCharacterOptionsChanged seeding order, and RuntimeSettingsTargets /
GameWindowLiveSessionOwnershipTests tests proving the concrete
ICommandBus.Publish wiring and the single LiveSessionCommandSurface
construction site.
- S6: AP-181 rewritten to name both of retail's omitted pre-send checks
(IsMessageSafe silent-drop, then IsMessageSpam) and stop misattributing
either to RouteLegacyChannel, which has no such gates.
- N1-N7: CharacterOptionId moved below SocialActions so its doc comment
re-attaches; TurbineChatMembershipGate reuses TurbineChatDisplayNames
instead of a duplicate table; the gate-to-refusal-text mapping is now
shared via TurbineChatMembershipGate.ResolveRefusalText instead of
duplicated in both hosts; ChatSettings.Default now matches ACE's real
CharacterOptions2.Default (Roleplay/Society start off); a doc-comment
clarifies only the five Hear toggles are server-backed; the register's
§3 header recounted 129 -> 128.
Suite: 11,964 passed / 4 skipped / 0 failed (baseline 11,957/4/0 + 7 new
tests). Campaign ledger CH3 review column updated to APPROVE-WITH-FIXES.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
627 lines
20 KiB
C#
627 lines
20 KiB
C#
using AcDream.App.Combat;
|
|
using AcDream.Core.Net.Messages;
|
|
using AcDream.UI.Abstractions.Input;
|
|
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();
|
|
|
|
GameplaySettings LoadGameplay();
|
|
|
|
ChatSettings LoadChat();
|
|
|
|
CharacterSettings LoadCharacter(string toonKey);
|
|
|
|
void SaveDisplay(DisplaySettings display);
|
|
|
|
void SaveAudio(AudioSettings audio);
|
|
|
|
void SaveGameplay(GameplaySettings gameplay);
|
|
|
|
void SaveChat(ChatSettings chat);
|
|
|
|
void SaveCharacter(string toonKey, CharacterSettings character);
|
|
}
|
|
|
|
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 GameplaySettings LoadGameplay() => _store.LoadGameplay();
|
|
|
|
public ChatSettings LoadChat() => _store.LoadChat();
|
|
|
|
public CharacterSettings LoadCharacter(string toonKey) =>
|
|
_store.LoadCharacter(toonKey);
|
|
|
|
public void SaveDisplay(DisplaySettings display) => _store.SaveDisplay(display);
|
|
|
|
public void SaveAudio(AudioSettings audio) => _store.SaveAudio(audio);
|
|
|
|
public void SaveGameplay(GameplaySettings gameplay) =>
|
|
_store.SaveGameplay(gameplay);
|
|
|
|
public void SaveChat(ChatSettings chat) => _store.SaveChat(chat);
|
|
|
|
public void SaveCharacter(string toonKey, CharacterSettings character) =>
|
|
_store.SaveCharacter(toonKey, character);
|
|
}
|
|
|
|
internal sealed record RuntimeSettingsSnapshot(
|
|
DisplaySettings Display,
|
|
AudioSettings Audio,
|
|
GameplaySettings Gameplay,
|
|
ChatSettings Chat,
|
|
CharacterSettings Character,
|
|
QualitySettings Quality);
|
|
|
|
/// <summary>
|
|
/// Expected-owner lease for the optional developer settings view model.
|
|
/// Failed optional composition can withdraw only the instance it installed.
|
|
/// </summary>
|
|
internal sealed class RuntimeSettingsViewModelBinding : IDisposable
|
|
{
|
|
private readonly RuntimeSettingsController _owner;
|
|
private bool _disposed;
|
|
|
|
public RuntimeSettingsViewModelBinding(
|
|
RuntimeSettingsController owner,
|
|
SettingsVM viewModel)
|
|
{
|
|
_owner = owner ?? throw new ArgumentNullException(nameof(owner));
|
|
ViewModel = viewModel ?? throw new ArgumentNullException(nameof(viewModel));
|
|
}
|
|
|
|
public SettingsVM ViewModel { get; }
|
|
|
|
public void Dispose()
|
|
{
|
|
if (_disposed)
|
|
return;
|
|
_owner.UnbindViewModel(ViewModel);
|
|
_disposed = true;
|
|
}
|
|
}
|
|
|
|
internal interface IRuntimeSettingsStartupTarget
|
|
{
|
|
void ApplyDisplay(DisplaySettings display);
|
|
|
|
void ApplyAudio(AudioSettings audio);
|
|
}
|
|
|
|
internal interface IRuntimeSettingsTargets
|
|
{
|
|
void ApplyDisplayWindowState(DisplaySettings display);
|
|
|
|
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);
|
|
}
|
|
|
|
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,
|
|
ICombatGameplaySettingsSource
|
|
{
|
|
private const string DefaultToonKey = "default";
|
|
|
|
private readonly IRuntimeSettingsStorage _storage;
|
|
private readonly Func<QualityPreset, QualitySettings> _resolveQuality;
|
|
private readonly Action<string> _log;
|
|
private IRuntimeSettingsTargets? _runtimeTargets;
|
|
private SettingsVM? _viewModel;
|
|
private CharacterSettings _defaultCharacter;
|
|
private bool _startupDisplayApplied;
|
|
private bool _startupAudioApplied;
|
|
private bool _startupApplied;
|
|
private bool _uiLockConverged = true;
|
|
|
|
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();
|
|
Gameplay = _storage.LoadGameplay();
|
|
Chat = _storage.LoadChat();
|
|
_defaultCharacter = _storage.LoadCharacter(DefaultToonKey);
|
|
Character = _defaultCharacter;
|
|
ResolvedQuality = _resolveQuality(Display.Quality);
|
|
Startup = new RuntimeSettingsSnapshot(
|
|
Display,
|
|
Audio,
|
|
Gameplay,
|
|
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 GameplaySettings Gameplay { get; private set; }
|
|
|
|
public ChatSettings Chat { get; private set; }
|
|
|
|
public CharacterSettings Character { get; private set; }
|
|
|
|
public QualitySettings ResolvedQuality { get; private set; }
|
|
|
|
public bool HasDraftPreview => _viewModel is not null;
|
|
|
|
public DisplaySettings DisplayPreview => _viewModel?.DisplayDraft ?? Display;
|
|
|
|
public AudioSettings AudioPreview => _viewModel?.AudioDraft ?? Audio;
|
|
|
|
public bool AutoTarget => Gameplay.AutoTarget;
|
|
|
|
public bool AutoRepeatAttack => Gameplay.AutoRepeatAttack;
|
|
|
|
public bool ViewCombatTarget => Gameplay.ViewCombatTarget;
|
|
|
|
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);
|
|
}
|
|
|
|
public SettingsVM CreateViewModel(
|
|
KeyBindings persistedBindings,
|
|
InputDispatcher dispatcher,
|
|
Action<KeyBindings> saveBindings)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(persistedBindings);
|
|
ArgumentNullException.ThrowIfNull(dispatcher);
|
|
ArgumentNullException.ThrowIfNull(saveBindings);
|
|
if (_viewModel is not null)
|
|
throw new InvalidOperationException("A settings view model is already bound.");
|
|
|
|
_viewModel = new SettingsVM(
|
|
persistedBindings,
|
|
dispatcher,
|
|
saveBindings,
|
|
Display,
|
|
SaveDisplay,
|
|
Audio,
|
|
SaveAudio,
|
|
Gameplay,
|
|
SaveGameplay,
|
|
Chat,
|
|
SaveChat,
|
|
Character,
|
|
SaveCharacter);
|
|
return _viewModel;
|
|
}
|
|
|
|
public RuntimeSettingsViewModelBinding CreateViewModelBinding(
|
|
KeyBindings persistedBindings,
|
|
InputDispatcher dispatcher,
|
|
Action<KeyBindings> saveBindings) =>
|
|
new(
|
|
this,
|
|
CreateViewModel(persistedBindings, dispatcher, saveBindings));
|
|
|
|
public void UnbindViewModel(SettingsVM? expected = null)
|
|
{
|
|
if (expected is null || ReferenceEquals(_viewModel, expected))
|
|
_viewModel = null;
|
|
}
|
|
|
|
public void SetUiLocked(bool locked)
|
|
{
|
|
if (Gameplay.LockUI == locked && _uiLockConverged)
|
|
return;
|
|
|
|
_uiLockConverged = false;
|
|
Gameplay = Gameplay with { LockUI = locked };
|
|
_runtimeTargets?.ApplyUiLock(locked);
|
|
_viewModel?.SetGameplay(
|
|
_viewModel.GameplayDraft with { LockUI = locked });
|
|
|
|
try
|
|
{
|
|
_storage.SaveGameplay(Gameplay);
|
|
_viewModel?.ApplyExternalGameplayChange(gameplay => gameplay with
|
|
{
|
|
LockUI = locked,
|
|
});
|
|
_uiLockConverged = true;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_log($"settings: radar lock save failed: {ex.Message}");
|
|
}
|
|
}
|
|
|
|
public void SetAcceptLootPermits(bool enabled)
|
|
{
|
|
Gameplay = Gameplay with { AcceptLootPermits = enabled };
|
|
_viewModel?.SetGameplay(
|
|
_viewModel.GameplayDraft with { AcceptLootPermits = enabled });
|
|
_storage.SaveGameplay(Gameplay);
|
|
_viewModel?.ApplyExternalGameplayChange(gameplay => gameplay with
|
|
{
|
|
AcceptLootPermits = enabled,
|
|
});
|
|
}
|
|
|
|
/// <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 };
|
|
_viewModel?.ApplyExternalDisplayChange(display => display with
|
|
{
|
|
ShowFps = Display.ShowFps,
|
|
});
|
|
|
|
try
|
|
{
|
|
_storage.SaveDisplay(Display);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_log($"settings: framerate display save failed: {ex.Message}");
|
|
}
|
|
}
|
|
|
|
public void SetCombatGameplay(GameplaySettings gameplay)
|
|
{
|
|
Gameplay = gameplay ?? throw new ArgumentNullException(nameof(gameplay));
|
|
if (_viewModel is not null)
|
|
{
|
|
_viewModel.SetGameplay(_viewModel.GameplayDraft with
|
|
{
|
|
AutoTarget = gameplay.AutoTarget,
|
|
AutoRepeatAttack = gameplay.AutoRepeatAttack,
|
|
ViewCombatTarget = gameplay.ViewCombatTarget,
|
|
});
|
|
}
|
|
|
|
try
|
|
{
|
|
_storage.SaveGameplay(gameplay);
|
|
_viewModel?.ApplyExternalGameplayChange(current => current with
|
|
{
|
|
AutoTarget = gameplay.AutoTarget,
|
|
AutoRepeatAttack = gameplay.AutoRepeatAttack,
|
|
ViewCombatTarget = gameplay.ViewCombatTarget,
|
|
});
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_log($"settings: combat option 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);
|
|
_viewModel?.LoadCharacterContext(Character);
|
|
_log($"settings: loaded character[{characterName}] preferences");
|
|
}
|
|
|
|
public void RestoreDefaultCharacterContext()
|
|
{
|
|
Character = _defaultCharacter;
|
|
_viewModel?.LoadCharacterContext(Character);
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
private 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}");
|
|
}
|
|
}
|
|
|
|
private void SaveAudio(AudioSettings audio)
|
|
{
|
|
try
|
|
{
|
|
_storage.SaveAudio(audio);
|
|
Audio = audio;
|
|
_log($"settings: audio saved to {_storage.Location}");
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_log($"settings: audio save failed: {ex.Message}");
|
|
}
|
|
}
|
|
|
|
private void SaveGameplay(GameplaySettings gameplay)
|
|
{
|
|
try
|
|
{
|
|
_storage.SaveGameplay(gameplay);
|
|
Gameplay = gameplay;
|
|
_uiLockConverged = false;
|
|
_runtimeTargets?.ApplyUiLock(gameplay.LockUI);
|
|
_uiLockConverged = true;
|
|
_log($"settings: gameplay saved to {_storage.Location}");
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_log($"settings: gameplay save failed: {ex.Message}");
|
|
}
|
|
}
|
|
|
|
private 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
|
|
// SaveGameplay'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);
|
|
}
|
|
|
|
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)
|
|
{
|
|
// Applied identically to BOTH the persisted snapshot and the live
|
|
// draft (mirrors ApplyExternalGameplayChange's own idempotent-update
|
|
// shape) so an unsaved draft edit to an unrelated field (font size,
|
|
// timestamps, ...) survives the reseed instead of being clobbered by
|
|
// a value computed once against the persisted 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;
|
|
_viewModel?.ApplyExternalChatChange(Reseed);
|
|
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}");
|
|
}
|
|
}
|
|
|
|
private void SaveCharacter(CharacterSettings character)
|
|
{
|
|
try
|
|
{
|
|
_storage.SaveCharacter(ActiveToonKey, character);
|
|
Character = character;
|
|
if (string.Equals(
|
|
ActiveToonKey,
|
|
DefaultToonKey,
|
|
StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
_defaultCharacter = character;
|
|
}
|
|
_log($"settings: character[{ActiveToonKey}] saved to {_storage.Location}");
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_log($"settings: character save failed: {ex.Message}");
|
|
}
|
|
}
|
|
|
|
private static QualitySettings ResolveQuality(QualityPreset preset) =>
|
|
QualitySettings.WithEnvOverrides(QualitySettings.From(preset));
|
|
}
|