acdream/src/AcDream.UI.Abstractions/Panels/Settings/SettingsStore.cs
Erik d13d63d0a5 fix #389 review round: settings v3 FOV migration + live apply; AD-90
Dual-lens Opus review of 7e0c1303 (reports committed under
docs/research/). The law, gate, and vertical application are CONFIRMED
at instruction-byte level against the PDB-paired acclient.exe (the BN
text FPU-elides this whole area); the fix round addresses the findings:

- Blast MUST-FIX 1: real schema migration instead of a hand-edited dev
  file. SettingsStore v2->v3: a pre-v3 display.fieldOfView was the
  applied vertical FOV in degrees; v3 means retail's m_fGameFOV.
  LoadDisplay migrates on read - the untouched old default 60 maps to
  the retail default 90; a deliberate other value preserves its visible
  16:9 framing (x (16/9 - 0.1)), clamped to the registered [10,160];
  the next save stamps v3 and migration never reruns. The dev
  settings.json hand-edit was reverted so the migration owns it.
- Blast MUST-FIX 2 / mechanism M2: the Field of View now applies LIVE on
  Save (retail: Render::GRPCallback_OnRenderPreferenceChanged @0x0054d999
  -> SmartBox::SetDefaultFov). RuntimeSettingsTargets gains the camera
  graph and applies through ApplyDisplayWindowState - the update-phase
  seam, deliberately NOT the render-phase preview path (the review's
  WATCH-3 cull-vs-raster landmine).
- Mechanism M1 -> register row AD-90: retail's divisor aspect runs
  through the Render.AspectRatio preference (ComputeAspectForViewport
  @0x0054f150, (w/h) x pref x 0.75) - exactly raw w/h at the registered
  default, which is what acdream assumes; retail's NaN-through-the-gate
  quirk (M3) is folded into the same row as deliberately not reproduced.
- Docs: RetailFieldOfView now cites the decisive vertical proof
  (D3DXMatrixPerspectiveFovLH fovy slot @0x0059ab71), the unconditional
  SmartBox::RenderNormalMode site, and M4's exact horizontal numbers
  (89.0/83.9/80.6 deg); the Config FOV row comment updated to LIVE.
- Blast WATCH 4 disposition: the 15 replay-harness PI/3 constants stay -
  they are CAPTURE-TIME camera parameters for recorded fixtures, not
  production framing; changing them would invalidate the replays.

Tests: +6 SettingsStore migration facts, +1 live-apply fact.
App suite 4,962/3 skips; UI.Abstractions 922.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 17:27:15 +02:00

777 lines
35 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 /
/// Character; 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>
/// 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), out width)
&& int.TryParse(key.AsSpan(separator + 1), 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> 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;
}
}