Follow-up to the retail-text fix. "Green under sv-SE" is not the same as "runs on any locale", so this establishes the latter by running the suite under cultures chosen to break different things, and fixing what they broke. ar-SA found a genuine defect the Swedish runner cannot see: the resolution parser read "1920x-1" through the ambient culture, and ar-SA's negative sign is not ASCII '-', so the parse failed and the height silently became 0 instead of -1. Both copies of that parser (App settings targets and the UI settings store) now parse invariantly. Audited every remaining culture-sensitive operation in src/ rather than fixing only what a test happened to catch: - Numeric Parse/TryParse with no IFormatProvider: 11 sites, all reading MACHINE-readable input — env vars (ACDREAM_LIGHT_DEBUG, ACDREAM_NET_DROP_*, streaming/quality knobs), CLI arguments, "1920x1080" settings keys, a chat command's price argument, and the launcher's bake thread count, which is handed straight to a child process command line. All pinned to InvariantCulture. - ToUpper()/ToLower() with no culture: none. The Turkish-I class was already clean, and tr-TR confirms it. - StartsWith/EndsWith/IndexOf(string) with no StringComparison: one — ChatInputParser's "@" prefix test, which is a culture-sensitive comparison for a single ASCII character. Now the ordinal char overload. Verified: 13,958 tests pass identically under the machine default, sv-SE, tr-TR, ar-SA, and de-DE. (The two launcher test assemblies are excluded from this run only because a running acdream-launcher.exe holds its own binary; the one launcher change here is the thread-count parse.) Dates remain on the current culture by intent, unchanged from the previous commit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
825 lines
37 KiB
C#
825 lines
37 KiB
C#
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;
|
||
|
||
/// <summary>Per-character retained-window position in screen pixels.</summary>
|
||
public readonly record struct UiWindowPosition(float X, float Y);
|
||
|
||
/// <summary>
|
||
/// JSON-backed persistence for non-keybind settings (Display / Audio / Chat /
|
||
/// CameraTurning / Character / Misc; the OP9-retired <c>Gameplay</c> section is no longer read or
|
||
/// written — see the class's Schema note below for how a leftover
|
||
/// <c>"gameplay"</c> block from an older settings.json is tolerated). The
|
||
/// graphical host supplies a canonical portable configuration path. Coexists
|
||
/// with <c>keybinds.json</c>, which retains its own
|
||
/// <see cref="Input.KeyBindings.LoadOrDefault"/> path.
|
||
///
|
||
/// <para>
|
||
/// Schema (current version 3 — v3 changed the MEANING of
|
||
/// <c>display.fieldOfView</c>: it was the applied vertical FOV in degrees,
|
||
/// it is now retail's <c>m_fGameFOV</c> in degrees (#389, the SmartboxFOV
|
||
/// port). <see cref="LoadDisplay"/> migrates pre-v3 values on read — see
|
||
/// <see cref="MigrateLegacyVerticalFovDegrees"/>):
|
||
/// <code>
|
||
/// {
|
||
/// "version": 3,
|
||
/// "display": { "resolution": "1920x1080", "fullscreen": false, ... }
|
||
/// "windowLayouts": { "Character": { "1920x1080": { "chat": { ... } } } }
|
||
/// }
|
||
/// </code>
|
||
/// 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
|
||
/// (<see cref="SaveSection"/>'s raw-JSON-text carry-forward) that keeps an
|
||
/// EXISTING file's now-orphaned <c>"gameplay"</c> block intact forever: no
|
||
/// <c>Load*</c> method here reads it, so it is neither parsed nor dropped,
|
||
/// just silently carried along untouched on every subsequent save.
|
||
/// </para>
|
||
/// </summary>
|
||
public sealed class SettingsStore
|
||
{
|
||
private const int CurrentSchemaVersion = 3;
|
||
private readonly string _path;
|
||
|
||
public SettingsStore(string path)
|
||
{
|
||
_path = path ?? throw new ArgumentNullException(nameof(path));
|
||
}
|
||
|
||
/// <summary>
|
||
/// Load Display settings. Missing file → <see cref="DisplaySettings.Default"/>.
|
||
/// Missing individual keys fall back to the corresponding default
|
||
/// field, so a partial file (e.g. only <c>resolution</c> is set) is
|
||
/// non-fatal.
|
||
/// </summary>
|
||
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;
|
||
}
|
||
}
|
||
|
||
/// <summary>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).</summary>
|
||
private static int ReadSchemaVersion(JsonElement root) =>
|
||
root.TryGetProperty("version", out var v) && v.ValueKind == JsonValueKind.Number
|
||
? v.GetInt32()
|
||
: 1;
|
||
|
||
/// <summary>
|
||
/// #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
|
||
/// <c>m_fGameFOV</c> 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.
|
||
/// </summary>
|
||
internal static float MigrateLegacyVerticalFovDegrees(float legacyVerticalFovDegrees)
|
||
{
|
||
if (legacyVerticalFovDegrees == 60f)
|
||
return 90f;
|
||
return Math.Clamp(legacyVerticalFovDegrees * (16f / 9f - 0.1f), 10f, 160f);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Save Display settings, preserving any other top-level keys the file
|
||
/// already contains (e.g. an <c>audio</c> section written by a newer
|
||
/// client). Unknown keys are round-tripped via raw JSON text so older
|
||
/// builds don't silently drop them.
|
||
/// </summary>
|
||
public void SaveDisplay(DisplaySettings display)
|
||
=> SaveSection("display", BuildDisplayObject(display));
|
||
|
||
/// <summary>
|
||
/// Load Audio settings. Same fall-back behaviour as
|
||
/// <see cref="LoadDisplay"/>: missing file → defaults, missing fields
|
||
/// → per-field defaults, corrupt JSON → defaults.
|
||
/// </summary>
|
||
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;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Save Audio settings, preserving every other top-level key
|
||
/// (display, future gameplay/chat/character). Same round-trip
|
||
/// guarantee as <see cref="SaveDisplay"/>.
|
||
/// </summary>
|
||
public void SaveAudio(AudioSettings audio)
|
||
=> SaveSection("audio", BuildAudioObject(audio));
|
||
|
||
/// <summary>Load Chat settings. Same fall-back behaviour as <see cref="LoadDisplay"/>.</summary>
|
||
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;
|
||
}
|
||
}
|
||
|
||
/// <summary>Save Chat settings, preserving all other top-level keys.</summary>
|
||
public void SaveChat(ChatSettings chat)
|
||
=> SaveSection("chat", BuildChatObject(chat));
|
||
|
||
/// <summary>
|
||
/// 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 <see cref="LoadDisplay"/>.
|
||
/// </summary>
|
||
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;
|
||
}
|
||
}
|
||
|
||
/// <summary>Save the camera-turning preferences, preserving all other
|
||
/// top-level keys.</summary>
|
||
public void SaveCameraTurning(CameraTurningSettings cameraTurning)
|
||
=> SaveSection("cameraTurning", BuildCameraTurningObject(cameraTurning));
|
||
|
||
/// <summary>
|
||
/// #409: load the two <c>Misc.*</c> tooltip preferences. Same fall-back
|
||
/// behaviour as <see cref="LoadDisplay"/>.
|
||
/// </summary>
|
||
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;
|
||
}
|
||
}
|
||
|
||
/// <summary>Save the tooltip preferences, preserving all other top-level keys.</summary>
|
||
public void SaveMisc(MiscSettings misc)
|
||
=> SaveSection("misc", BuildMiscObject(misc));
|
||
|
||
/// <summary>
|
||
/// Load per-character settings keyed by <paramref name="toonKey"/>.
|
||
/// Missing file or missing toon entry → <see cref="CharacterSettings.Default"/>.
|
||
/// </summary>
|
||
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<string>() ?? d.DefaultChatChannel,
|
||
AutoAttack: toon["autoAttack"]?.GetValue<bool>() ?? d.AutoAttack,
|
||
ConfirmSalvage: toon["confirmSalvage"]?.GetValue<bool>() ?? d.ConfirmSalvage,
|
||
ShowPickupMessages: toon["showPickupMessages"]?.GetValue<bool>() ?? d.ShowPickupMessages);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Console.WriteLine($"settings: failed to load {_path}: {ex.Message} — using defaults");
|
||
return CharacterSettings.Default;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Save per-character settings under <paramref name="toonKey"/>.
|
||
/// Preserves every other toon's settings + every other top-level
|
||
/// section. Uses <see cref="JsonNode"/> rather than the raw-text
|
||
/// preservation pattern of <see cref="SaveSection"/> because the
|
||
/// per-toon write needs to mutate a nested map, not just replace a
|
||
/// top-level key.
|
||
/// </summary>
|
||
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);
|
||
}
|
||
|
||
/// <summary>Load a named retained-window position for one character.</summary>
|
||
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>();
|
||
float? y = node["y"]?.GetValue<float>();
|
||
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;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Save a named retained-window position under
|
||
/// <c>windowPositions[toonKey][windowName]</c>, preserving all settings sections.
|
||
/// </summary>
|
||
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);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Load complete retained-window state for one character and screen resolution.
|
||
/// Missing fields in an older partial layout inherit from
|
||
/// <paramref name="fallback"/>. When no modern entry exists, the legacy
|
||
/// <c>windowPositions[toonKey][windowName]</c> radar entry is returned as a
|
||
/// position-only migration layered over the same fallback.
|
||
/// </summary>
|
||
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;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Save complete retained-window state under
|
||
/// <c>windowLayouts[toonKey][resolutionKey][windowName]</c>, preserving every
|
||
/// other character, resolution, window, and settings section.
|
||
/// </summary>
|
||
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);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Load one window from a user-named retail <c>@saveui</c> profile.
|
||
/// Named profiles are intentionally independent of character and screen
|
||
/// resolution, matching retail's portable UI files.
|
||
/// </summary>
|
||
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;
|
||
}
|
||
}
|
||
|
||
/// <summary>Save one window to a user-named retail <c>@saveui</c> profile.</summary>
|
||
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<float>() ?? fallback; }
|
||
catch { return fallback; }
|
||
}
|
||
|
||
private static bool ReadNodeBool(JsonObject node, string key, bool fallback)
|
||
{
|
||
try { return node[key]?.GetValue<bool>() ?? fallback; }
|
||
catch { return fallback; }
|
||
}
|
||
|
||
private static int ReadNodeInt(JsonObject node, string key, int fallback)
|
||
{
|
||
try { return node[key]?.GetValue<int>() ?? 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<string, object> 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<string, object> 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<string, object> BuildMiscObject(MiscSettings m)
|
||
=> new(StringComparer.Ordinal)
|
||
{
|
||
["tooltipDelaySeconds"] = m.TooltipDelaySeconds,
|
||
["tooltipEnable"] = m.TooltipEnable,
|
||
};
|
||
|
||
private static SortedDictionary<string, object> 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<string, object> 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,
|
||
};
|
||
|
||
/// <summary>
|
||
/// 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.
|
||
/// </summary>
|
||
private void SaveSection(string sectionName, SortedDictionary<string, object> 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<string, string>(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<QualityPreset>(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;
|
||
}
|
||
}
|