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;
}
}