Retail's ChatInterface::SetOpacity (0x004F3120) fades the WHOLE composited
window surface with one alpha; UiRenderContext.ApplyAlpha already gated
DrawSprite/DrawRect/DrawFill (since 1da697ec, pre-CH6) but DrawStringDat and
DrawString still passed applyAlpha:false, so text stayed sharp over a
translucent window. Both now route through the same chokepoint.
RetailWindowOpacityController (new) subscribes to a new
RetailWindowManager.WindowRegistered event and drives every registered
window's live Opacity from keyboard-focus state, applied to EVERY window
(chat, floaties, vitals, toolbar, ...) rather than retail's ChatInterface-only
scope — register row AP-190, retiring the stale AP-40 "fixed 0.75, no focus
transition" row in the same commit.
Verified retail's shipped opacity defaults from the decomp (constructor
literals, no cdb needed): the base ChatInterface ctor sets
DefaultOpacity=0.5/ActiveOpacity=1.0, kept unmodified by the four floating
windows; gmMainChatUI's own ctor overrides the main window to 1.0/1.0
(always fully opaque). acdream ships one shared global default (0.5/1.0)
rather than replicating the per-class override — also AP-190. The linking
invariant (raising default above active drags active UP; lowering active
below default drags default DOWN — never a clamp) is ported verbatim as
ChatOpacityLink in AcDream.UI.Abstractions, shared by the live controller
and the new Settings -> Chat tab's two linked opacity sliders.
Persistence: ChatSettings.DefaultOpacity/ActiveOpacity round-trip through
SettingsStore; Save pushes both through IRuntimeSettingsTargets.SetChatOpacity
into the live controller, no restart required.
Rider (CH6a/b re-review): strengthened the grip-media regression guard past
a bare SpriteFile != 0 check — ChatLayoutConformanceTests now drives each
live grip through a real UiRenderContext/TextRenderer (backed by the
in-memory RecordingGpuDevice test double) and asserts the draw call chain
actually queued sprite geometry, via a new TextRenderer.DebugSpriteSegments
test-only accessor.
Full Release suite 12,459 passed / 4 skipped / 0 failed (baseline
12,420/4/0). No subagents, no client launches (session hard constraints);
pending the next connected user gate for visual confirmation.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
698 lines
30 KiB
C#
698 lines
30 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 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> 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;
|
|
}
|
|
}
|