Mounts retail's Options panel (LayoutDesc 0x2100002B resolved through host 0x2100006E slot 0x1000018D, gmPanelUI key 10) via the same catalog-import pattern CharacterController already validates, registered through RetailPanelUiController so it shares retail's "one active gmPanelUI child" mutual exclusion with every other sibling panel for free. F11 and the toolbar's options button (0x1000019B, already authoring panel id 10) both now open it; the close button fires the same ToggleOptionsPanel action. OptionPageModel (OptionPage/BoolOptionRow) ports retail's exact Apply/Reset/Defaults/visibility semantics from UIOption_Checkbox/PlayerOptionPage — LED clicks apply live immediately, Apply commits every row unconditionally + flushes the batched blob, Reset reverts only Changed rows, Defaults restores without committing, and tab-switch/window-hide revert uncommitted edits. Wired for all four tabs; this slice registers real rows on none of them (Gameplay authentically has none — a pure button list). UiTabPanel gains an ActivePageChanged event so the page model can hook every tab transition, including the initial default-tab activation. The seven Gameplay-tab buttons: Exit Game reuses the existing graceful window-close path; Exit to Character Selection gets retail's confirmation dialog and byte-verified mid-air refusal but still behaves as Exit Game (AD-74 — no pre-world character-select flow exists); Configure Keyboard and In-Game Help Files are inert this slice (AD-76 for Help — the plugin retail depends on doesn't exist); Urgent Assistance/Report Abuse short-circuit to their own byte-verified failure text through the interface-text seam instead of ShellExecute against a dead URL (AD-75); Use Mouse Turning Settings runs the pure MouseTurningSettingsMacro port, persisting five new CameraTurningSettings preferences and sending PlayerOption.UseMouseTurning — TS-74 records that acdream has no persistent mouse-turning camera mode for the bit to drive yet. Full Release suite: 12,918 passed / 4 skipped / 0 failed (baseline 12,871/4/0 — only new tests added). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
745 lines
32 KiB
C#
745 lines
32 KiB
C#
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 today; future
|
|
/// tabs Audio / Gameplay / Chat / Character will be added to the same
|
|
/// file). 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 2):
|
|
/// <code>
|
|
/// {
|
|
/// "version": 2,
|
|
/// "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.
|
|
/// </para>
|
|
/// </summary>
|
|
public sealed class SettingsStore
|
|
{
|
|
private const int CurrentSchemaVersion = 2;
|
|
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;
|
|
return new DisplaySettings(
|
|
Resolution: ReadString (disp, "resolution", d.Resolution),
|
|
Fullscreen: ReadBool (disp, "fullscreen", d.Fullscreen),
|
|
VSync: ReadBool (disp, "vsync", d.VSync),
|
|
FieldOfView: ReadFloat (disp, "fieldOfView", d.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));
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Console.WriteLine($"settings: failed to load {_path}: {ex.Message} — using defaults");
|
|
return DisplaySettings.Default;
|
|
}
|
|
}
|
|
|
|
/// <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));
|
|
}
|
|
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 Gameplay settings (subset of retail CharacterOption flags).
|
|
/// Same fall-back behaviour as <see cref="LoadDisplay"/>.
|
|
/// </summary>
|
|
public GameplaySettings LoadGameplay()
|
|
{
|
|
if (!File.Exists(_path)) return GameplaySettings.Default;
|
|
try
|
|
{
|
|
using var stream = File.OpenRead(_path);
|
|
var doc = JsonDocument.Parse(stream);
|
|
var root = doc.RootElement;
|
|
if (!root.TryGetProperty("gameplay", out var gp)
|
|
|| gp.ValueKind != JsonValueKind.Object)
|
|
return GameplaySettings.Default;
|
|
|
|
var d = GameplaySettings.Default;
|
|
return new GameplaySettings(
|
|
AutoTarget: ReadBool(gp, "autoTarget", d.AutoTarget),
|
|
AutoRepeatAttack: ReadBool(gp, "autoRepeatAttack", d.AutoRepeatAttack),
|
|
ViewCombatTarget: ReadBool(gp, "viewCombatTarget", d.ViewCombatTarget),
|
|
ToggleRun: ReadBool(gp, "toggleRun", d.ToggleRun),
|
|
AdvancedCombatUI: ReadBool(gp, "advancedCombatUI", d.AdvancedCombatUI),
|
|
ShowTooltips: ReadBool(gp, "showTooltips", d.ShowTooltips),
|
|
VividTargetingIndicator: ReadBool(gp, "vividTargetingIndicator", d.VividTargetingIndicator),
|
|
SideBySideVitals: ReadBool(gp, "sideBySideVitals", d.SideBySideVitals),
|
|
CoordinatesOnRadar: ReadBool(gp, "coordinatesOnRadar", d.CoordinatesOnRadar),
|
|
SpellDuration: ReadBool(gp, "spellDuration", d.SpellDuration),
|
|
AllowGive: ReadBool(gp, "allowGive", d.AllowGive),
|
|
ShowHelm: ReadBool(gp, "showHelm", d.ShowHelm),
|
|
ShowCloak: ReadBool(gp, "showCloak", d.ShowCloak),
|
|
LockUI: ReadBool(gp, "lockUI", d.LockUI),
|
|
UseMouseTurning: ReadBool(gp, "useMouseTurning", d.UseMouseTurning),
|
|
AcceptLootPermits: ReadBool(gp, "acceptLootPermits", d.AcceptLootPermits));
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Console.WriteLine($"settings: failed to load {_path}: {ex.Message} — using defaults");
|
|
return GameplaySettings.Default;
|
|
}
|
|
}
|
|
|
|
/// <summary>Save Gameplay settings, preserving all other top-level keys.</summary>
|
|
public void SaveGameplay(GameplaySettings gameplay)
|
|
=> SaveSection("gameplay", BuildGameplayObject(gameplay));
|
|
|
|
/// <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),
|
|
DefaultOpacity: ReadFloat(chat, "defaultOpacity", d.DefaultOpacity),
|
|
ActiveOpacity: ReadFloat(chat, "activeOpacity", d.ActiveOpacity));
|
|
}
|
|
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));
|
|
}
|
|
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,
|
|
["chatWindow1Filter"] = c.ChatWindow1Filter,
|
|
["chatWindow2Filter"] = c.ChatWindow2Filter,
|
|
["chatWindow3Filter"] = c.ChatWindow3Filter,
|
|
["chatWindow4Filter"] = c.ChatWindow4Filter,
|
|
["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> BuildGameplayObject(GameplaySettings g)
|
|
=> new(StringComparer.Ordinal)
|
|
{
|
|
["advancedCombatUI"] = g.AdvancedCombatUI,
|
|
["acceptLootPermits"] = g.AcceptLootPermits,
|
|
["allowGive"] = g.AllowGive,
|
|
["autoRepeatAttack"] = g.AutoRepeatAttack,
|
|
["autoTarget"] = g.AutoTarget,
|
|
["viewCombatTarget"] = g.ViewCombatTarget,
|
|
["coordinatesOnRadar"] = g.CoordinatesOnRadar,
|
|
["lockUI"] = g.LockUI,
|
|
["showCloak"] = g.ShowCloak,
|
|
["showHelm"] = g.ShowHelm,
|
|
["showTooltips"] = g.ShowTooltips,
|
|
["sideBySideVitals"] = g.SideBySideVitals,
|
|
["spellDuration"] = g.SpellDuration,
|
|
["toggleRun"] = g.ToggleRun,
|
|
["useMouseTurning"] = g.UseMouseTurning,
|
|
["vividTargetingIndicator"] = g.VividTargetingIndicator,
|
|
};
|
|
|
|
private static SortedDictionary<string, object> BuildDisplayObject(DisplaySettings d)
|
|
=> new(StringComparer.Ordinal)
|
|
{
|
|
["fieldOfView"] = d.FieldOfView,
|
|
["fullscreen"] = d.Fullscreen,
|
|
["gamma"] = d.Gamma,
|
|
["particleRange"] = d.ParticleRange.ToString(),
|
|
["quality"] = d.Quality.ToString(),
|
|
["resolution"] = d.Resolution,
|
|
["showFps"] = d.ShowFps,
|
|
["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,
|
|
};
|
|
|
|
private static SortedDictionary<string, object> BuildAudioObject(AudioSettings a)
|
|
=> new(StringComparer.Ordinal)
|
|
{
|
|
["ambient"] = a.Ambient,
|
|
["master"] = a.Master,
|
|
["sfx"] = a.Sfx,
|
|
};
|
|
|
|
/// <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 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;
|
|
}
|
|
}
|