using System.Globalization; using System; using System.Collections.Generic; using System.IO; using System.Text.Json; using System.Text.Json.Nodes; using AcDream.UI.Abstractions.Settings; namespace AcDream.UI.Abstractions.Panels.Settings; /// Per-character retained-window position in screen pixels. public readonly record struct UiWindowPosition(float X, float Y); /// /// JSON-backed persistence for non-keybind settings (Display / Audio / Chat / /// CameraTurning / Character / Misc; the OP9-retired Gameplay section is no longer read or /// written — see the class's Schema note below for how a leftover /// "gameplay" block from an older settings.json is tolerated). The /// graphical host supplies a canonical portable configuration path. Coexists /// with keybinds.json, which retains its own /// path. /// /// /// Schema (current version 3 — v3 changed the MEANING of /// display.fieldOfView: it was the applied vertical FOV in degrees, /// it is now retail's m_fGameFOV in degrees (#389, the SmartboxFOV /// port). migrates pre-v3 values on read — see /// ): /// /// { /// "version": 3, /// "display": { "resolution": "1920x1080", "fullscreen": false, ... } /// "windowLayouts": { "Character": { "1920x1080": { "chat": { ... } } } } /// } /// /// Unknown top-level keys are preserved on save so future tab additions /// from a newer client don't get clobbered by an older client writing /// out only the sections it knows about — the SAME round-trip preservation /// ('s raw-JSON-text carry-forward) that keeps an /// EXISTING file's now-orphaned "gameplay" block intact forever: no /// Load* method here reads it, so it is neither parsed nor dropped, /// just silently carried along untouched on every subsequent save. /// /// public sealed class SettingsStore { private const int CurrentSchemaVersion = 3; private readonly string _path; public SettingsStore(string path) { _path = path ?? throw new ArgumentNullException(nameof(path)); } /// /// Load Display settings. Missing file → . /// Missing individual keys fall back to the corresponding default /// field, so a partial file (e.g. only resolution is set) is /// non-fatal. /// public DisplaySettings LoadDisplay() { if (!File.Exists(_path)) return DisplaySettings.Default; try { using var stream = File.OpenRead(_path); var doc = JsonDocument.Parse(stream); var root = doc.RootElement; if (!root.TryGetProperty("display", out var disp) || disp.ValueKind != JsonValueKind.Object) return DisplaySettings.Default; var d = DisplaySettings.Default; // #389 (schema v3): a pre-v3 file's stored fieldOfView was the // applied vertical FOV; the same number now means retail's // gameFOV. Migrate on read — an absent key needs none (it falls // back to the already-v3 default). float fieldOfView = ReadFloat(disp, "fieldOfView", d.FieldOfView); if (ReadSchemaVersion(root) < 3 && disp.TryGetProperty("fieldOfView", out _)) fieldOfView = MigrateLegacyVerticalFovDegrees(fieldOfView); return new DisplaySettings( Resolution: ReadString (disp, "resolution", d.Resolution), Fullscreen: ReadBool (disp, "fullscreen", d.Fullscreen), VSync: ReadBool (disp, "vsync", d.VSync), FieldOfView: fieldOfView, Gamma: ReadFloat (disp, "gamma", d.Gamma), ShowFps: ReadBool (disp, "showFps", d.ShowFps), Quality: ReadQuality (disp, "quality", d.Quality), ParticleRange: ReadParticleRange( disp, "particleRange", d.ParticleRange), ScreenBrightness: ReadFloat(disp, "screenBrightness", d.ScreenBrightness), AutomaticDegrades: ReadBool (disp, "automaticDegrades", d.AutomaticDegrades), GraphicsPerformance: ReadFloat(disp, "graphicsPerformance", d.GraphicsPerformance), DegradeDistance: ReadFloat(disp, "degradeDistance", d.DegradeDistance), LandscapeTextureDetail: ReadInt (disp, "landscapeTextureDetail", d.LandscapeTextureDetail), EnvironmentTextureDetail:ReadInt (disp, "environmentTextureDetail",d.EnvironmentTextureDetail), TextureFiltering: ReadInt (disp, "textureFiltering", d.TextureFiltering), LandscapeDrawDistance: ReadInt (disp, "landscapeDrawDistance", d.LandscapeDrawDistance), BuildingDetailTextures: ReadBool (disp, "buildingDetailTextures", d.BuildingDetailTextures), MultiPassAlpha: ReadBool (disp, "multiPassAlpha", d.MultiPassAlpha)); } catch (Exception ex) { Console.WriteLine($"settings: failed to load {_path}: {ex.Message} — using defaults"); return DisplaySettings.Default; } } /// The file's top-level schema version; a file predating the /// version key reads as 1 (the same convention the v1 radar-layout /// migration below already uses). private static int ReadSchemaVersion(JsonElement root) => root.TryGetProperty("version", out var v) && v.ValueKind == JsonValueKind.Number ? v.GetInt32() : 1; /// /// #389 blast-review MUST-FIX 1 — the v3 fieldOfView migration. The /// pre-v3 number was the applied VERTICAL FOV in degrees (written /// straight onto the cameras); the v3 number is retail's /// m_fGameFOV in degrees, run through the smartbox law. Two /// cases: /// - exactly the old default 60 → the new default 90: 60 was acdream's /// own invented constant, not a user preference, and mapping it to /// retail's registered default gives every untouched file the retail /// experience; /// - anything else was a DELIBERATE choice → preserve the user's /// visible framing at the era's default 16:9 aspect: /// gameFOV = vFOV × (16/9 − 0.1) (the smartbox law inverted at that /// aspect), clamped into the slider's registered [10,160]. /// Runs on READ for any pre-v3 file; the next save stamps version 3 and /// the value stops migrating. /// internal static float MigrateLegacyVerticalFovDegrees(float legacyVerticalFovDegrees) { if (legacyVerticalFovDegrees == 60f) return 90f; return Math.Clamp(legacyVerticalFovDegrees * (16f / 9f - 0.1f), 10f, 160f); } /// /// Save Display settings, preserving any other top-level keys the file /// already contains (e.g. an audio section written by a newer /// client). Unknown keys are round-tripped via raw JSON text so older /// builds don't silently drop them. /// public void SaveDisplay(DisplaySettings display) => SaveSection("display", BuildDisplayObject(display)); /// /// Load Audio settings. Same fall-back behaviour as /// : missing file → defaults, missing fields /// → per-field defaults, corrupt JSON → defaults. /// public AudioSettings LoadAudio() { if (!File.Exists(_path)) return AudioSettings.Default; try { using var stream = File.OpenRead(_path); var doc = JsonDocument.Parse(stream); var root = doc.RootElement; if (!root.TryGetProperty("audio", out var audio) || audio.ValueKind != JsonValueKind.Object) return AudioSettings.Default; var d = AudioSettings.Default; return new AudioSettings( Master: ReadFloat(audio, "master", d.Master), Sfx: ReadFloat(audio, "sfx", d.Sfx), Ambient: ReadFloat(audio, "ambient", d.Ambient), SoundFeatures: ReadInt (audio, "soundFeatures", d.SoundFeatures), // OP6 rework (review M2): fresh enabled-sense key names — the // rejected slice's "sfxDisabled"/"ambientDisabled"/ // "interfaceDisabled" keys never shipped in an accepted // build, so there is no legacy key to migrate. A missing key // (including every pre-OP6 settings.json) falls back to // AudioSettings.Default, which is now enabled=true — sound // ON, matching retail. SfxEnabled: ReadBool (audio, "sfxEnabled", d.SfxEnabled), AmbientEnabled: ReadBool (audio, "ambientEnabled", d.AmbientEnabled), InterfaceEnabled: ReadBool (audio, "interfaceEnabled", d.InterfaceEnabled), InterfaceVolume: ReadFloat(audio, "interfaceVolume", d.InterfaceVolume), PlaySoundOnlyWhenActive: ReadBool (audio, "playSoundOnlyWhenActive", d.PlaySoundOnlyWhenActive)); } catch (Exception ex) { Console.WriteLine($"settings: failed to load {_path}: {ex.Message} — using defaults"); return AudioSettings.Default; } } /// /// Save Audio settings, preserving every other top-level key /// (display, future gameplay/chat/character). Same round-trip /// guarantee as . /// public void SaveAudio(AudioSettings audio) => SaveSection("audio", BuildAudioObject(audio)); /// Load Chat settings. Same fall-back behaviour as . public ChatSettings LoadChat() { if (!File.Exists(_path)) return ChatSettings.Default; try { using var stream = File.OpenRead(_path); var doc = JsonDocument.Parse(stream); var root = doc.RootElement; if (!root.TryGetProperty("chat", out var chat) || chat.ValueKind != JsonValueKind.Object) return ChatSettings.Default; var d = ChatSettings.Default; return new ChatSettings( HearGeneralChat: ReadBool (chat, "hearGeneralChat", d.HearGeneralChat), HearTradeChat: ReadBool (chat, "hearTradeChat", d.HearTradeChat), HearLFGChat: ReadBool (chat, "hearLFGChat", d.HearLFGChat), HearRoleplayChat: ReadBool (chat, "hearRoleplayChat", d.HearRoleplayChat), HearSocietyChat: ReadBool (chat, "hearSocietyChat", d.HearSocietyChat), AppearOffline: ReadBool (chat, "appearOffline", d.AppearOffline), ShowTimestamps: ReadBool (chat, "showTimestamps", d.ShowTimestamps), FilterProfanity: ReadBool (chat, "filterProfanity", d.FilterProfanity), FontSize: ReadFloat(chat, "fontSize", d.FontSize), ChatWindow1Filter: ReadULong(chat, "chatWindow1Filter", d.ChatWindow1Filter), ChatWindow2Filter: ReadULong(chat, "chatWindow2Filter", d.ChatWindow2Filter), ChatWindow3Filter: ReadULong(chat, "chatWindow3Filter", d.ChatWindow3Filter), ChatWindow4Filter: ReadULong(chat, "chatWindow4Filter", d.ChatWindow4Filter), ChatWindowMainFilter: ReadULong(chat, "chatWindowMainFilter", d.ChatWindowMainFilter), DefaultOpacity: ReadFloat(chat, "defaultOpacity", d.DefaultOpacity), ActiveOpacity: ReadFloat(chat, "activeOpacity", d.ActiveOpacity), ChatFontFace: ReadInt(chat, "chatFontFace", d.ChatFontFace), ChatFontSizeIndex: ReadInt(chat, "chatFontSizeIndex", d.ChatFontSizeIndex)); } catch (Exception ex) { Console.WriteLine($"settings: failed to load {_path}: {ex.Message} — using defaults"); return ChatSettings.Default; } } /// Save Chat settings, preserving all other top-level keys. public void SaveChat(ChatSettings chat) => SaveSection("chat", BuildChatObject(chat)); /// /// Load the five client-local Camera/Input preferences the "Use Mouse /// Turning Settings" Gameplay-tab macro writes (Campaign OP slice OP3). /// Same fall-back behaviour as . /// public CameraTurningSettings LoadCameraTurning() { if (!File.Exists(_path)) return CameraTurningSettings.Default; try { using var stream = File.OpenRead(_path); var doc = JsonDocument.Parse(stream); var root = doc.RootElement; if (!root.TryGetProperty("cameraTurning", out var ct) || ct.ValueKind != JsonValueKind.Object) return CameraTurningSettings.Default; var d = CameraTurningSettings.Default; return new CameraTurningSettings( Stiffness: ReadFloat(ct, "stiffness", d.Stiffness), AdjustmentSpeed: ReadFloat(ct, "adjustmentSpeed", d.AdjustmentSpeed), MouseLookSensitivity: ReadFloat(ct, "mouseLookSensitivity", d.MouseLookSensitivity), AlignToSlope: ReadBool (ct, "alignToSlope", d.AlignToSlope), InvertMouseLookYAxis: ReadBool (ct, "invertMouseLookYAxis", d.InvertMouseLookYAxis), UseMouseTurning: ReadBool (ct, "useMouseTurning", d.UseMouseTurning)); } catch (Exception ex) { Console.WriteLine($"settings: failed to load {_path}: {ex.Message} — using defaults"); return CameraTurningSettings.Default; } } /// Save the camera-turning preferences, preserving all other /// top-level keys. public void SaveCameraTurning(CameraTurningSettings cameraTurning) => SaveSection("cameraTurning", BuildCameraTurningObject(cameraTurning)); /// /// #409: load the two Misc.* tooltip preferences. Same fall-back /// behaviour as . /// public MiscSettings LoadMisc() { if (!File.Exists(_path)) return MiscSettings.Default; try { using var stream = File.OpenRead(_path); var doc = JsonDocument.Parse(stream); var root = doc.RootElement; if (!root.TryGetProperty("misc", out var misc) || misc.ValueKind != JsonValueKind.Object) return MiscSettings.Default; var d = MiscSettings.Default; return new MiscSettings( TooltipEnable: ReadBool (misc, "tooltipEnable", d.TooltipEnable), TooltipDelaySeconds: ReadFloat(misc, "tooltipDelaySeconds", d.TooltipDelaySeconds)); } catch (Exception ex) { Console.WriteLine($"settings: failed to load {_path}: {ex.Message} — using defaults"); return MiscSettings.Default; } } /// Save the tooltip preferences, preserving all other top-level keys. public void SaveMisc(MiscSettings misc) => SaveSection("misc", BuildMiscObject(misc)); /// /// Load per-character settings keyed by . /// Missing file or missing toon entry → . /// public CharacterSettings LoadCharacter(string toonKey) { if (toonKey is null) throw new ArgumentNullException(nameof(toonKey)); if (!File.Exists(_path)) return CharacterSettings.Default; try { var root = JsonNode.Parse(File.ReadAllText(_path)) as JsonObject; var toon = root?["character"]?[toonKey] as JsonObject; if (toon is null) return CharacterSettings.Default; var d = CharacterSettings.Default; return new CharacterSettings( DefaultChatChannel: toon["defaultChatChannel"]?.GetValue() ?? d.DefaultChatChannel, AutoAttack: toon["autoAttack"]?.GetValue() ?? d.AutoAttack, ConfirmSalvage: toon["confirmSalvage"]?.GetValue() ?? d.ConfirmSalvage, ShowPickupMessages: toon["showPickupMessages"]?.GetValue() ?? d.ShowPickupMessages); } catch (Exception ex) { Console.WriteLine($"settings: failed to load {_path}: {ex.Message} — using defaults"); return CharacterSettings.Default; } } /// /// Save per-character settings under . /// Preserves every other toon's settings + every other top-level /// section. Uses rather than the raw-text /// preservation pattern of because the /// per-toon write needs to mutate a nested map, not just replace a /// top-level key. /// public void SaveCharacter(string toonKey, CharacterSettings settings) { if (toonKey is null) throw new ArgumentNullException(nameof(toonKey)); if (settings is null) throw new ArgumentNullException(nameof(settings)); JsonObject root = LoadMutableRoot(); // Build the toon's payload. var toonObj = new JsonObject { ["autoAttack"] = settings.AutoAttack, ["confirmSalvage"] = settings.ConfirmSalvage, ["defaultChatChannel"] = settings.DefaultChatChannel, ["showPickupMessages"] = settings.ShowPickupMessages, }; // Slot it under character[toonKey], creating the character map if // necessary. Other toons in the map are preserved. JsonObject characterMap = GetOrCreateObject(root, "character"); characterMap[toonKey] = toonObj; root["version"] = CurrentSchemaVersion; WriteMutableRoot(root); } /// Load a named retained-window position for one character. public UiWindowPosition? LoadWindowPosition(string toonKey, string windowName) { ArgumentException.ThrowIfNullOrWhiteSpace(toonKey); ArgumentException.ThrowIfNullOrWhiteSpace(windowName); if (!File.Exists(_path)) return null; try { var root = JsonNode.Parse(File.ReadAllText(_path)) as JsonObject; var node = root?["windowPositions"]?[toonKey]?[windowName] as JsonObject; if (node is null) return null; float? x = node["x"]?.GetValue(); float? y = node["y"]?.GetValue(); return x is not null && y is not null ? new UiWindowPosition(x.Value, y.Value) : null; } catch (Exception ex) { Console.WriteLine($"settings: failed to load window position from {_path}: {ex.Message}"); return null; } } /// /// Save a named retained-window position under /// windowPositions[toonKey][windowName], preserving all settings sections. /// public void SaveWindowPosition(string toonKey, string windowName, UiWindowPosition position) { ArgumentException.ThrowIfNullOrWhiteSpace(toonKey); ArgumentException.ThrowIfNullOrWhiteSpace(windowName); JsonObject root = LoadMutableRoot(); JsonObject positions = GetOrCreateObject(root, "windowPositions"); JsonObject toon = GetOrCreateObject(positions, toonKey); toon[windowName] = new JsonObject { ["x"] = position.X, ["y"] = position.Y, }; root["version"] = CurrentSchemaVersion; WriteMutableRoot(root); } /// /// Load complete retained-window state for one character and screen resolution. /// Missing fields in an older partial layout inherit from /// . When no modern entry exists, the legacy /// windowPositions[toonKey][windowName] radar entry is returned as a /// position-only migration layered over the same fallback. /// public UiWindowLayout? LoadWindowLayout( string toonKey, string resolutionKey, string windowName, UiWindowLayout fallback) { ArgumentException.ThrowIfNullOrWhiteSpace(toonKey); ArgumentException.ThrowIfNullOrWhiteSpace(resolutionKey); ArgumentException.ThrowIfNullOrWhiteSpace(windowName); if (!File.Exists(_path)) return null; try { var root = JsonNode.Parse(File.ReadAllText(_path)) as JsonObject; JsonObject? node = root?["windowLayouts"]?[toonKey]?[resolutionKey]?[windowName] as JsonObject; if (node is null && root?["windowLayouts"]?[toonKey] is JsonObject resolutions && TryParseResolution(resolutionKey, out int requestedWidth, out int requestedHeight)) { long bestDistance = long.MaxValue; foreach ((string key, JsonNode? value) in resolutions) { if (value?[windowName] is not JsonObject candidate || !TryParseResolution(key, out int width, out int height)) continue; long dx = width - requestedWidth; long dy = height - requestedHeight; long distance = dx * dx + dy * dy; if (distance >= bestDistance) continue; bestDistance = distance; node = candidate; } } if (node is not null) { return new UiWindowLayout( ReadNodeFloat(node, "x", fallback.X), ReadNodeFloat(node, "y", fallback.Y), ReadNodeFloat(node, "width", fallback.Width), ReadNodeFloat(node, "height", fallback.Height), ReadNodeBool(node, "visible", fallback.Visible), ReadNodeBool(node, "collapsed", fallback.Collapsed), ReadNodeBool(node, "maximized", fallback.Maximized), ReadNodeInt(node, "authoredGeometryRevision", 0)); } // Version-1 migration: radar stored only X/Y and had no resolution key. if (root?["windowPositions"]?[toonKey]?[windowName] is JsonObject legacy) { return fallback with { X = ReadNodeFloat(legacy, "x", fallback.X), Y = ReadNodeFloat(legacy, "y", fallback.Y), }; } return null; } catch (Exception ex) { Console.WriteLine($"settings: failed to load window layout from {_path}: {ex.Message}"); return null; } } /// /// Save complete retained-window state under /// windowLayouts[toonKey][resolutionKey][windowName], preserving every /// other character, resolution, window, and settings section. /// public void SaveWindowLayout( string toonKey, string resolutionKey, string windowName, UiWindowLayout layout) { ArgumentException.ThrowIfNullOrWhiteSpace(toonKey); ArgumentException.ThrowIfNullOrWhiteSpace(resolutionKey); ArgumentException.ThrowIfNullOrWhiteSpace(windowName); JsonObject root = LoadMutableRoot(); JsonObject layouts = GetOrCreateObject(root, "windowLayouts"); JsonObject toon = GetOrCreateObject(layouts, toonKey); JsonObject resolution = GetOrCreateObject(toon, resolutionKey); resolution[windowName] = new JsonObject { ["x"] = layout.X, ["y"] = layout.Y, ["width"] = layout.Width, ["height"] = layout.Height, ["visible"] = layout.Visible, ["collapsed"] = layout.Collapsed, ["maximized"] = layout.Maximized, ["authoredGeometryRevision"] = layout.AuthoredGeometryRevision, }; root["version"] = CurrentSchemaVersion; WriteMutableRoot(root); } /// /// Load one window from a user-named retail @saveui profile. /// Named profiles are intentionally independent of character and screen /// resolution, matching retail's portable UI files. /// public UiWindowLayout? LoadNamedWindowLayout( string profileName, string windowName, UiWindowLayout fallback) { ArgumentNullException.ThrowIfNull(profileName); ArgumentException.ThrowIfNullOrWhiteSpace(windowName); if (!File.Exists(_path)) return null; try { var root = JsonNode.Parse(File.ReadAllText(_path)) as JsonObject; JsonObject? node = root?["namedWindowLayouts"]?[profileName]?[windowName] as JsonObject; return node is null ? null : new UiWindowLayout( ReadNodeFloat(node, "x", fallback.X), ReadNodeFloat(node, "y", fallback.Y), ReadNodeFloat(node, "width", fallback.Width), ReadNodeFloat(node, "height", fallback.Height), ReadNodeBool(node, "visible", fallback.Visible), ReadNodeBool(node, "collapsed", fallback.Collapsed), ReadNodeBool(node, "maximized", fallback.Maximized), ReadNodeInt(node, "authoredGeometryRevision", 0)); } catch (Exception ex) { Console.WriteLine($"settings: failed to load named window layout from {_path}: {ex.Message}"); return null; } } /// Save one window to a user-named retail @saveui profile. public void SaveNamedWindowLayout( string profileName, string windowName, UiWindowLayout layout) { ArgumentNullException.ThrowIfNull(profileName); ArgumentException.ThrowIfNullOrWhiteSpace(windowName); JsonObject root = LoadMutableRoot(); JsonObject profiles = GetOrCreateObject(root, "namedWindowLayouts"); JsonObject profile = GetOrCreateObject(profiles, profileName); profile[windowName] = SerializeWindowLayout(layout); root["version"] = CurrentSchemaVersion; WriteMutableRoot(root); } private static JsonObject SerializeWindowLayout(UiWindowLayout layout) => new() { ["x"] = layout.X, ["y"] = layout.Y, ["width"] = layout.Width, ["height"] = layout.Height, ["visible"] = layout.Visible, ["collapsed"] = layout.Collapsed, ["maximized"] = layout.Maximized, ["authoredGeometryRevision"] = layout.AuthoredGeometryRevision, }; private JsonObject LoadMutableRoot() { var dir = Path.GetDirectoryName(_path); if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir); if (!File.Exists(_path)) return new JsonObject(); try { return JsonNode.Parse(File.ReadAllText(_path)) as JsonObject ?? new JsonObject(); } catch { return new JsonObject(); } } private void WriteMutableRoot(JsonObject root) => File.WriteAllText(_path, root.ToJsonString(new JsonSerializerOptions { WriteIndented = true })); private static JsonObject GetOrCreateObject(JsonObject parent, string key) { if (parent[key] is JsonObject existing) return existing; var created = new JsonObject(); parent[key] = created; return created; } private static float ReadNodeFloat(JsonObject node, string key, float fallback) { try { return node[key]?.GetValue() ?? fallback; } catch { return fallback; } } private static bool ReadNodeBool(JsonObject node, string key, bool fallback) { try { return node[key]?.GetValue() ?? fallback; } catch { return fallback; } } private static int ReadNodeInt(JsonObject node, string key, int fallback) { try { return node[key]?.GetValue() ?? fallback; } catch { return fallback; } } private static bool TryParseResolution(string key, out int width, out int height) { width = 0; height = 0; int separator = key.IndexOf('x', StringComparison.OrdinalIgnoreCase); return separator > 0 && int.TryParse( key.AsSpan(0, separator), NumberStyles.Integer, CultureInfo.InvariantCulture, out width) && int.TryParse( key.AsSpan(separator + 1), NumberStyles.Integer, CultureInfo.InvariantCulture, out height) && width > 0 && height > 0; } private static SortedDictionary BuildChatObject(ChatSettings c) => new(StringComparer.Ordinal) { ["activeOpacity"] = c.ActiveOpacity, ["appearOffline"] = c.AppearOffline, ["chatFontFace"] = c.ChatFontFace, ["chatFontSizeIndex"] = c.ChatFontSizeIndex, ["chatWindow1Filter"] = c.ChatWindow1Filter, ["chatWindow2Filter"] = c.ChatWindow2Filter, ["chatWindow3Filter"] = c.ChatWindow3Filter, ["chatWindow4Filter"] = c.ChatWindow4Filter, ["chatWindowMainFilter"] = c.ChatWindowMainFilter, ["defaultOpacity"] = c.DefaultOpacity, ["filterProfanity"] = c.FilterProfanity, ["fontSize"] = c.FontSize, ["hearGeneralChat"] = c.HearGeneralChat, ["hearLFGChat"] = c.HearLFGChat, ["hearRoleplayChat"] = c.HearRoleplayChat, ["hearSocietyChat"] = c.HearSocietyChat, ["hearTradeChat"] = c.HearTradeChat, ["showTimestamps"] = c.ShowTimestamps, }; private static SortedDictionary BuildDisplayObject(DisplaySettings d) => new(StringComparer.Ordinal) { ["automaticDegrades"] = d.AutomaticDegrades, ["buildingDetailTextures"] = d.BuildingDetailTextures, ["degradeDistance"] = d.DegradeDistance, ["environmentTextureDetail"] = d.EnvironmentTextureDetail, ["fieldOfView"] = d.FieldOfView, ["fullscreen"] = d.Fullscreen, ["gamma"] = d.Gamma, ["graphicsPerformance"] = d.GraphicsPerformance, ["landscapeDrawDistance"] = d.LandscapeDrawDistance, ["landscapeTextureDetail"] = d.LandscapeTextureDetail, ["multiPassAlpha"] = d.MultiPassAlpha, ["particleRange"] = d.ParticleRange.ToString(), ["quality"] = d.Quality.ToString(), ["resolution"] = d.Resolution, ["screenBrightness"] = d.ScreenBrightness, ["showFps"] = d.ShowFps, ["textureFiltering"] = d.TextureFiltering, ["vsync"] = d.VSync, }; private static SortedDictionary BuildMiscObject(MiscSettings m) => new(StringComparer.Ordinal) { ["tooltipDelaySeconds"] = m.TooltipDelaySeconds, ["tooltipEnable"] = m.TooltipEnable, }; private static SortedDictionary BuildCameraTurningObject(CameraTurningSettings c) => new(StringComparer.Ordinal) { ["adjustmentSpeed"] = c.AdjustmentSpeed, ["alignToSlope"] = c.AlignToSlope, ["invertMouseLookYAxis"] = c.InvertMouseLookYAxis, ["mouseLookSensitivity"] = c.MouseLookSensitivity, ["stiffness"] = c.Stiffness, ["useMouseTurning"] = c.UseMouseTurning, }; private static SortedDictionary BuildAudioObject(AudioSettings a) => new(StringComparer.Ordinal) { ["ambient"] = a.Ambient, ["ambientEnabled"] = a.AmbientEnabled, ["interfaceEnabled"] = a.InterfaceEnabled, ["interfaceVolume"] = a.InterfaceVolume, ["master"] = a.Master, ["playSoundOnlyWhenActive"] = a.PlaySoundOnlyWhenActive, ["sfx"] = a.Sfx, ["sfxEnabled"] = a.SfxEnabled, ["soundFeatures"] = a.SoundFeatures, }; /// /// Generic atomic-section save: writes the named section and preserves /// all other top-level keys from the existing file, replacing only the /// version + the targeted section. Avoids duplication between the /// per-section Save methods. /// private void SaveSection(string sectionName, SortedDictionary sectionPayload) { var dir = Path.GetDirectoryName(_path); if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir); // Preserve any non-target top-level keys from the existing file. var preservedKeys = new SortedDictionary(StringComparer.Ordinal); if (File.Exists(_path)) { try { using var stream = File.OpenRead(_path); var doc = JsonDocument.Parse(stream); foreach (var prop in doc.RootElement.EnumerateObject()) { if (prop.Name == sectionName || prop.Name == "version") continue; preservedKeys[prop.Name] = prop.Value.GetRawText(); } } catch { // Corrupt file → fully overwrite; previous content is lost // but the user's session continues with the new save. preservedKeys.Clear(); } } var sb = new System.Text.StringBuilder(); sb.Append('{').AppendLine(); // Preserved keys come first (sorted by name) then the section, then // version last. Preserves alphabetical-style top-level ordering. foreach (var kv in preservedKeys) { sb.Append(" \"").Append(kv.Key).Append("\": ") .Append(kv.Value).Append(',').AppendLine(); } sb.Append(" \"").Append(sectionName).Append("\": ") .Append(JsonSerializer.Serialize(sectionPayload, new JsonSerializerOptions { WriteIndented = true }) .Replace("\n", "\n ")) .Append(',').AppendLine(); sb.Append(" \"version\": ").Append(CurrentSchemaVersion).AppendLine(); sb.Append('}').AppendLine(); File.WriteAllText(_path, sb.ToString()); } private static string ReadString(JsonElement obj, string name, string fallback) => obj.TryGetProperty(name, out var el) && el.ValueKind == JsonValueKind.String ? (el.GetString() ?? fallback) : fallback; private static bool ReadBool(JsonElement obj, string name, bool fallback) => obj.TryGetProperty(name, out var el) && (el.ValueKind == JsonValueKind.True || el.ValueKind == JsonValueKind.False) ? el.GetBoolean() : fallback; private static float ReadFloat(JsonElement obj, string name, float fallback) => obj.TryGetProperty(name, out var el) && el.ValueKind == JsonValueKind.Number ? el.GetSingle() : fallback; private static int ReadInt(JsonElement obj, string name, int fallback) => obj.TryGetProperty(name, out var el) && el.ValueKind == JsonValueKind.Number ? el.GetInt32() : fallback; private static ulong ReadULong(JsonElement obj, string name, ulong fallback) { if (!obj.TryGetProperty(name, out var el) || el.ValueKind != JsonValueKind.Number) return fallback; return el.TryGetUInt64(out ulong value) ? value : fallback; } private static QualityPreset ReadQuality(JsonElement obj, string name, QualityPreset fallback) { if (!obj.TryGetProperty(name, out var el) || el.ValueKind != JsonValueKind.String) return fallback; var s = el.GetString(); return Enum.TryParse(s, ignoreCase: true, out var v) ? v : fallback; } private static ParticleRange ReadParticleRange( JsonElement obj, string name, ParticleRange fallback) { if (!obj.TryGetProperty(name, out var el) || el.ValueKind != JsonValueKind.String) return fallback; var value = el.GetString(); return Enum.TryParse(value, ignoreCase: true, out ParticleRange parsed) && Enum.IsDefined(parsed) ? parsed : fallback; } }