From 31672989c36a495ab1debd38c4a40ba0da00a287 Mon Sep 17 00:00:00 2001 From: Colin Tim Barndt Date: Wed, 28 Jan 2026 21:57:35 +0100 Subject: [PATCH 01/14] Migrate to System.Text.Json --- ResoniteModLoader/DebugInfo.cs | 1 - .../JsonConverters/ConfigurationConverter.cs | 199 ++++++++++++++++++ .../JsonConverters/EnumConverter.cs | 128 ++++++++--- .../ResonitePrimitiveConverter.cs | 47 +++-- ResoniteModLoader/ModConfiguration.cs | 134 ++++-------- ResoniteModLoader/ResoniteModLoader.csproj | 4 - 6 files changed, 373 insertions(+), 140 deletions(-) create mode 100644 ResoniteModLoader/JsonConverters/ConfigurationConverter.cs diff --git a/ResoniteModLoader/DebugInfo.cs b/ResoniteModLoader/DebugInfo.cs index fcbc161..5534d5b 100644 --- a/ResoniteModLoader/DebugInfo.cs +++ b/ResoniteModLoader/DebugInfo.cs @@ -11,7 +11,6 @@ internal static void Log() { Logger.DebugInternal($"Using \"{Assembly.GetEntryAssembly()?.GetCustomAttribute()?.FrameworkName}\""); Logger.MsgInternal($".NET Runtime: {RuntimeInformation.FrameworkDescription} on {RuntimeInformation.RuntimeIdentifier}"); Logger.MsgInternal($"Using Harmony v{GetAssemblyVersion(typeof(HarmonyLib.Harmony))}"); - Logger.MsgInternal($"Using Json.NET v{GetAssemblyVersion(typeof(Newtonsoft.Json.JsonSerializer))}"); } private static string? GetAssemblyVersion(Type typeFromAssembly) { diff --git a/ResoniteModLoader/JsonConverters/ConfigurationConverter.cs b/ResoniteModLoader/JsonConverters/ConfigurationConverter.cs new file mode 100644 index 0000000..14b5589 --- /dev/null +++ b/ResoniteModLoader/JsonConverters/ConfigurationConverter.cs @@ -0,0 +1,199 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +using ResoniteModLoader; + +internal sealed class ModConfigurationConverter : JsonConverter { + private const string VERSION_JSON_KEY = "version"; + private const string VALUES_JSON_KEY = "values"; + + // Thread local to prevent issues with multiple configs being + // saved from different threads at the same time. + private readonly ThreadLocal context = new(); + + private struct Context { + internal ModConfigurationDefinition? definition; + internal ResoniteMod? mod; + internal bool saveDefaultValues; + } + + internal void SetContext(ModConfigurationDefinition definition, ResoniteMod mod, bool saveDefaultValues = false) { + context.Value = new() { + definition = definition, + mod = mod, + saveDefaultValues = saveDefaultValues, + }; + } + + internal void ClearContext() { + context.Value = default; + } + + private Context TakeContext() { + var ctx = context.Value; + if (ctx.definition == null || ctx.mod == null) { + throw new InvalidOperationException("Invalid state of converter"); + } + context.Value = default; + return ctx; + } + + public override ModConfiguration Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { + var ctx = TakeContext(); + if (reader.TokenType != JsonTokenType.StartObject) { + throw new JsonException($"Expected an object, got {reader.TokenType}"); + } + reader.ReadOrThrow(); // Consume start of object + + // Read "version": "..." + + if (reader.GetString() != VERSION_JSON_KEY) { + throw new JsonException($"Expected first property to be '{VERSION_JSON_KEY}'"); + } + reader.ReadOrThrow(); + + var versionString = reader.GetString() + ?? throw new JsonException("Version string is null"); + Logger.MsgInternal($"Version: '{versionString}'"); + Version version = new(versionString); + reader.ReadOrThrow(); + + if (!AreVersionsCompatible(version, ctx.definition!.Version)) { + var handlingMode = ctx.mod!.HandleIncompatibleConfigurationVersions(ctx.definition.Version, version); + switch (handlingMode) { + case IncompatibleConfigurationHandlingOption.CLOBBER: + Logger.WarnInternal($"{ctx.mod.Name} saved config version is {version} which is incompatible with mod's definition version {ctx.definition.Version}. Clobbering old config and starting fresh."); + return new ModConfiguration(ctx.definition!); + case IncompatibleConfigurationHandlingOption.FORCELOAD: + break; + case IncompatibleConfigurationHandlingOption.ERROR: // fall through to default + default: + ctx.mod!.AllowSavingConfiguration = false; + throw new ModConfigurationException($"{ctx.mod.Name} saved config version is {version} which is incompatible with mod's definition version {ctx.definition.Version}"); + } + } + + // Read "values": { ... } + + if (reader.GetString() != VALUES_JSON_KEY) + throw new JsonException($"Expected second property to be '{VALUES_JSON_KEY}'"); + reader.ReadOrThrow(); + + if (reader.TokenType != JsonTokenType.StartObject) + throw new JsonException($"Expected an object, got {reader.TokenType}"); + reader.ReadOrThrow(); // Consume start of object + + var keys = ctx.definition.ConfigurationItemDefinitions.ToDictionary(key => key.Name); + + while (reader.TokenType != JsonTokenType.EndObject) { + var name = reader.GetString() + ?? throw new JsonException("Object key is null"); + reader.ReadOrThrow(); + + // Ignore unknown keys + if (!keys.TryGetValue(name, out var key)) { + Logger.WarnInternal($"{ctx.mod!.Name} saved config version contains entry '{name}' which does not exist in its configuration definition"); + continue; + } + + var value = ReadGeneric(ref reader, key.ValueType(), options); + key.Set(value); + reader.ReadOrThrow(); + } + reader.ReadOrThrow(); // Consume end of object + + if (reader.TokenType != JsonTokenType.EndObject) { + throw new JsonException($"Extra keys in configuration object"); + } + + // Exit on end object token + + return new(ctx.definition); + } + + public override void Write(Utf8JsonWriter writer, ModConfiguration value, JsonSerializerOptions options) { + var ctx = TakeContext(); + + writer.WriteStartObject(); + + writer.WriteString(VERSION_JSON_KEY, ctx.definition!.Version.ToString()); + + writer.WritePropertyName(VALUES_JSON_KEY); + writer.WriteStartObject(); + + foreach (var key in ctx.definition.ConfigurationItemDefinitions) { + if (key.TryGetValue(out object? writtenValue)) { + // write + } + else if (ctx.saveDefaultValues && key.TryComputeDefault(out writtenValue)) { + // write + } + else { + continue; + } + writer.WritePropertyName(key.Name); + if (writtenValue == null) { + writer.WriteNullValue(); + continue; + } + WriteGeneric(writer, key.ValueType(), writtenValue, options); + } + + writer.WriteEndObject(); + writer.WriteEndObject(); + } + + private static bool AreVersionsCompatible(Version serializedVersion, Version currentVersion) { + if (serializedVersion.Major != currentVersion.Major) { + // major version differences are hard incompatible + return false; + } + + if (serializedVersion.Minor > currentVersion.Minor) { + // if serialized config has a newer minor version than us + // in other words, someone downgraded the mod but not the config + // then we cannot load the config + return false; + } + + // none of the checks failed! + return true; + } + + private delegate object? ReadDelegate(ref Utf8JsonReader reader, JsonSerializerOptions options); + + private object? ReadGeneric(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { + var converterType = typeof(InnerConverter<>).MakeGenericType([typeToConvert]); + var read = converterType.GetMethod(nameof(InnerConverter<>.Read))!.CreateDelegate(); + + return read(ref reader, options); + } + + private delegate void WriteDelegate(Utf8JsonWriter writer, object? value, JsonSerializerOptions options); + + private void WriteGeneric(Utf8JsonWriter writer, Type typeToConvert, object? value, JsonSerializerOptions options) { + var converterType = typeof(InnerConverter<>).MakeGenericType([typeToConvert]); + var write = converterType.GetMethod(nameof(InnerConverter<>.Write))!.CreateDelegate(); + + write(writer, value, options); + } + + private static class InnerConverter { + public static object? Read(ref Utf8JsonReader reader, JsonSerializerOptions options) { + var converter = (JsonConverter)options.GetConverter(typeof(T)); + return converter.Read(ref reader, typeof(T), options); + } + + public static void Write(Utf8JsonWriter writer, object? value, JsonSerializerOptions options) { + var converter = (JsonConverter)options.GetConverter(typeof(T)); + converter.Write(writer, (T?)value, options); + } + } +} + +internal static class Utf8JsonReaderExt { + public static void ReadOrThrow(this ref Utf8JsonReader reader) { + if (!reader.Read()) + throw new JsonException(); + } +} diff --git a/ResoniteModLoader/JsonConverters/EnumConverter.cs b/ResoniteModLoader/JsonConverters/EnumConverter.cs index dc65fe0..a56b638 100644 --- a/ResoniteModLoader/JsonConverters/EnumConverter.cs +++ b/ResoniteModLoader/JsonConverters/EnumConverter.cs @@ -1,41 +1,117 @@ -using Newtonsoft.Json; +using System.Text.Json; +using System.Text.Json.Serialization; namespace ResoniteModLoader.JsonConverters; // serializes and deserializes enums as strings -internal sealed class EnumConverter : JsonConverter { - public override bool CanConvert(Type objectType) { - return objectType.IsEnum; +internal sealed class EnumConverter : JsonConverterFactory { + + public override bool CanConvert(Type typeToConvert) { + var notNullType = Nullable.GetUnderlyingType(typeToConvert); + return (notNullType ?? typeToConvert).IsEnum; } - public override object ReadJson(JsonReader reader, Type objectType, object? existingValue, JsonSerializer serializer) { - // handle old behavior where enums were serialized as underlying type - Type underlyingType = Enum.GetUnderlyingType(objectType); - if (TryConvert(reader!.Value!, underlyingType, out object? deserialized)) { - Logger.DebugFuncInternal(() => $"Deserializing a Core Element type: {objectType} from a {reader!.Value!.GetType()}"); - return deserialized!; - } + public override JsonConverter? CreateConverter(Type typeToConvert, JsonSerializerOptions options) { + if (!CanConvert(typeToConvert)) + throw new InvalidOperationException($"Cannot convert type {typeToConvert}"); - // handle new behavior where enums are serialized as strings - if (reader.Value is string serialized) { - return Enum.Parse(objectType, serialized); - } + var notNullType = Nullable.GetUnderlyingType(typeToConvert); - throw new ArgumentException($"Could not deserialize a Core Element type: {objectType} from a {reader?.Value?.GetType()}. Expected underlying type was {underlyingType}"); + var type = notNullType == null + ? typeof(Inner<>).MakeGenericType([typeToConvert]) + : typeof(InnerNullable<>).MakeGenericType([notNullType]); + return (JsonConverter?)Activator.CreateInstance(type); } - public override void WriteJson(JsonWriter writer, object? value, JsonSerializer serializer) { - string? serialized = Enum.GetName(value!.GetType(), value); - writer.WriteValue(serialized); + private sealed class Inner : JsonConverter where T : struct, Enum { + public override T Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { + switch (reader.TokenType) { + case JsonTokenType.Null: + return default; + + case JsonTokenType.String: { + var value = reader.GetString()!; + if (Enum.TryParse(value, false, out T result)) + return result; + else + throw new JsonException( + $"{typeToConvert} does not have a variant '{value}'" + ); + } + + // handle old behavior where enums were serialized as underlying type + case JsonTokenType.Number: { + Type underlyingType = Enum.GetUnderlyingType(typeToConvert); + if (underlyingType == typeof(ulong)) { + // Edge case: ulong can represent more positive values than long + var value = reader.GetUInt64(); + return (T)Enum.ToObject(typeof(T), value); + } + else { + var value = reader.GetInt64(); + return (T)Enum.ToObject(typeof(T), value); + } + } + + default: + throw new JsonException( + $"Expected string or number when parsing {typeToConvert}, found {reader.TokenType}" + ); + } + } + + public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions options) { + string? serialized = Enum.GetName(value); + writer.WriteStringValue(serialized); + } } - private static bool TryConvert(object value, Type newType, out object? converted) { - try { - converted = Convert.ChangeType(value, newType); - return true; - } catch { - converted = null; - return false; + private sealed class InnerNullable : JsonConverter where T : struct, Enum { + public override T? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { + switch (reader.TokenType) { + case JsonTokenType.Null: + return null; + + case JsonTokenType.String: { + var value = reader.GetString()!; + if (Enum.TryParse(value, false, out T result)) + return result; + else + throw new JsonException( + $"{typeToConvert} does not have a variant '{value}'" + ); + } + + // handle old behavior where enums were serialized as underlying type + case JsonTokenType.Number: { + Type underlyingType = Enum.GetUnderlyingType(typeToConvert); + if (underlyingType == typeof(ulong)) { + // Edge case: ulong can represent more positive values than long + var value = reader.GetUInt64(); + return (T?)Enum.ToObject(typeof(T), value); + } + else { + var value = reader.GetInt64(); + return (T?)Enum.ToObject(typeof(T), value); + } + } + + default: + throw new JsonException( + $"Expected string or number when parsing {typeToConvert}, found {reader.TokenType}" + ); + } + } + + public override void Write(Utf8JsonWriter writer, T? value, JsonSerializerOptions options) { + if (value == null) { + writer.WriteNullValue(); + } + else { + string? serialized = Enum.GetName((T)value); + writer.WriteStringValue(serialized); + } } } + } diff --git a/ResoniteModLoader/JsonConverters/ResonitePrimitiveConverter.cs b/ResoniteModLoader/JsonConverters/ResonitePrimitiveConverter.cs index 3c79b97..db449f2 100644 --- a/ResoniteModLoader/JsonConverters/ResonitePrimitiveConverter.cs +++ b/ResoniteModLoader/JsonConverters/ResonitePrimitiveConverter.cs @@ -1,28 +1,45 @@ -using Elements.Core; +using System.Text.Json; +using System.Text.Json.Serialization; -using Newtonsoft.Json; +using Elements.Core; namespace ResoniteModLoader.JsonConverters; -internal sealed class ResonitePrimitiveConverter : JsonConverter { +internal sealed class ResonitePrimitiveConverter : JsonConverterFactory { + private static readonly Assembly ElementsCore = typeof(floatQ).Assembly; - public override bool CanConvert(Type objectType) { - // handle all non-enum Resonite Primitives in the Elements.Core assembly - return !objectType.IsEnum && ElementsCore.Equals(objectType.Assembly) && Coder.IsEnginePrimitive(objectType); + public override bool CanConvert(Type typeToConvert) + => !typeToConvert.IsEnum + && ElementsCore.Equals(typeToConvert.Assembly) + && Coder.IsEnginePrimitive(typeToConvert); + + public override JsonConverter? CreateConverter(Type typeToConvert, JsonSerializerOptions options) { + if (!CanConvert(typeToConvert)) + throw new InvalidOperationException($"Cannot convert type {typeToConvert}"); + + var type = typeof(Inner<>).MakeGenericType([typeToConvert]); + return (JsonConverter?)Activator.CreateInstance(type); } - public override object ReadJson(JsonReader reader, Type objectType, object? existingValue, JsonSerializer serializer) { - if (reader.Value is string serialized) { - // use Resonite's built-in decoding if the value was serialized as a string - return typeof(Coder<>).MakeGenericType(objectType).GetMethod("DecodeFromString")!.Invoke(null, [serialized])!; + private sealed class Inner : JsonConverter { + public override T? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { + var value = reader.GetString(); + if (value == null) + return default; + + return Coder.DecodeFromString(value); } - throw new ArgumentException($"Could not deserialize a Core Element type: {objectType} from a {reader?.Value?.GetType()}"); + public override void Write(Utf8JsonWriter writer, T? value, JsonSerializerOptions options) { + if (value == null) { + writer.WriteNullValue(); + } + else { + var serialized = Coder.EncodeToString(value); + writer.WriteStringValue(serialized); + } + } } - public override void WriteJson(JsonWriter writer, object? value, JsonSerializer serializer) { - string serialized = (string)typeof(Coder<>).MakeGenericType(value!.GetType()).GetMethod("EncodeToString")!.Invoke(null, [value])!; - writer.WriteValue(serialized); - } } diff --git a/ResoniteModLoader/ModConfiguration.cs b/ResoniteModLoader/ModConfiguration.cs index 08d0a75..797afae 100644 --- a/ResoniteModLoader/ModConfiguration.cs +++ b/ResoniteModLoader/ModConfiguration.cs @@ -1,12 +1,11 @@ using System.Diagnostics; +using System.Text.Json; +using System.Text.Json.Serialization; using FrooxEngine; using HarmonyLib; -using Newtonsoft.Json; -using Newtonsoft.Json.Linq; - using ResoniteModLoader.JsonConverters; namespace ResoniteModLoader; @@ -123,42 +122,43 @@ public class ModConfiguration : IModConfigurationDefinition { public event ConfigurationChangedHandler? OnThisConfigurationChanged; // used to track how frequenly Save() is being called - private Stopwatch saveTimer = new(); + private readonly Stopwatch saveTimer = new(); // time that save must not be called for a save to actually go through - private int debounceMilliseconds = 3000; + private const int DEBOUNCE_MILLIS = 3000; // used to keep track of mods that spam Save(): // any mod that calls Save() for the ModConfiguration within debounceMilliseconds of the previous call to the same ModConfiguration // will be put into Ultimate Punishment Mode, and ALL their Save() calls, regardless of ModConfiguration, will be debounced. // The naughty list is global, while the actual debouncing is per-configuration. - private static HashSet naughtySavers = []; + private static readonly HashSet naughtySavers = []; // used to keep track of the debouncers for this configuration. - private Dictionary> saveActionForCallee = []; + private readonly Dictionary> saveActionForCallee = []; - private static readonly JsonSerializer jsonSerializer = CreateJsonSerializer(); + private static readonly JsonSerializerOptions jsonSerializerOptions; + private static readonly ModConfigurationConverter jsonConfigurationConverter; - private static JsonSerializer CreateJsonSerializer() { - JsonSerializerSettings settings = new() { + static ModConfiguration() { + JsonSerializerOptions options = new() { MaxDepth = 32, - ReferenceLoopHandling = ReferenceLoopHandling.Error, - DefaultValueHandling = DefaultValueHandling.IgnoreAndPopulate, - Formatting = Formatting.Indented + ReferenceHandler = ReferenceHandler.IgnoreCycles, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingDefault, + WriteIndented = true, + IndentSize = 2, }; - List converters = []; - IList defaultConverters = settings.Converters; - if (defaultConverters != null && defaultConverters.Count != 0) { - Logger.DebugFuncInternal(() => $"Using {defaultConverters.Count} default json converters"); - converters.AddRange(defaultConverters); + var converters = options.Converters; + if (converters.Count != 0) { + Logger.DebugFuncInternal(() => $"Using {converters.Count} default json converters"); } converters.Add(new EnumConverter()); converters.Add(new ResonitePrimitiveConverter()); - settings.Converters = converters; - return JsonSerializer.Create(settings); + jsonConfigurationConverter = new(); + converters.Add(jsonConfigurationConverter); + jsonSerializerOptions = options; } - private ModConfiguration(ModConfigurationDefinition definition) { + internal ModConfiguration(ModConfigurationDefinition definition) { Definition = definition; } @@ -175,23 +175,6 @@ private static string GetModConfigPath(ResoniteModBase mod) { return Path.Combine(ConfigDirectory, filename); } - private static bool AreVersionsCompatible(Version serializedVersion, Version currentVersion) { - if (serializedVersion.Major != currentVersion.Major) { - // major version differences are hard incompatible - return false; - } - - if (serializedVersion.Minor > currentVersion.Minor) { - // if serialized config has a newer minor version than us - // in other words, someone downgraded the mod but not the config - // then we cannot load the config - return false; - } - - // none of the checks failed! - return true; - } - /// /// Checks if the given key is defined in this config. /// @@ -379,42 +362,9 @@ private bool AnyValuesSet() { string configFile = GetModConfigPath(mod); try { - using StreamReader file = File.OpenText(configFile); - using JsonTextReader reader = new(file); - JObject json = JObject.Load(reader); - string? versionString = json[VERSION_JSON_KEY]?.ToObject(jsonSerializer); - if (versionString == null) { - throw new ModConfigurationException($"Missing version in config for {mod.Name}"); - } - Version version = new(versionString); - if (!AreVersionsCompatible(version, definition.Version)) { - var handlingMode = mod.HandleIncompatibleConfigurationVersions(definition.Version, version); - switch (handlingMode) { - case IncompatibleConfigurationHandlingOption.CLOBBER: - Logger.WarnInternal($"{mod.Name} saved config version is {version} which is incompatible with mod's definition version {definition.Version}. Clobbering old config and starting fresh."); - return new ModConfiguration(definition); - case IncompatibleConfigurationHandlingOption.FORCELOAD: - break; - case IncompatibleConfigurationHandlingOption.ERROR: // fall through to default - default: - mod.AllowSavingConfiguration = false; - throw new ModConfigurationException($"{mod.Name} saved config version is {version} which is incompatible with mod's definition version {definition.Version}"); - } - } - foreach (ModConfigurationKey key in definition.ConfigurationItemDefinitions) { - string keyName = key.Name; - try { - JToken? token = json[VALUES_JSON_KEY]?[keyName]; - if (token != null) { - object? value = token.ToObject(key.ValueType(), jsonSerializer); - key.Set(value); - } - } catch (Exception e) { - // I know not what exceptions the JSON library will throw, but they must be contained - mod.AllowSavingConfiguration = false; - throw new ModConfigurationException($"Error loading {key.ValueType()} config key \"{keyName}\" for {mod.Name}", e); - } - } + using var file = File.OpenRead(configFile); + jsonConfigurationConverter.SetContext(definition, mod); + return JsonSerializer.Deserialize(file, jsonSerializerOptions); } catch (FileNotFoundException) { // return early and create a new config return new ModConfiguration(definition); @@ -425,6 +375,9 @@ private bool AnyValuesSet() { Logger.ErrorInternal($"Error loading config for {mod.Name}, creating new config file (old file can be found at {backupPath}). Exception:\n{e}"); File.Move(configFile, backupPath); } + finally { + jsonConfigurationConverter.ClearContext(); + } return new ModConfiguration(definition); } @@ -457,18 +410,18 @@ internal void SaveQueue(bool saveDefaultValues = false, bool immediate = false) // get saved state for this callee if (callee != null && naughtySavers.Contains(callee.Name) && !saveActionForCallee.TryGetValue(callee.Name, out saveAction)) { // handle case where the callee was marked as naughty from a different ModConfiguration being spammed - saveAction = Util.Debounce(SaveInternal, debounceMilliseconds); + saveAction = Util.Debounce(SaveInternal, DEBOUNCE_MILLIS); saveActionForCallee.Add(callee.Name, saveAction); } if (saveTimer.IsRunning) { float elapsedMillis = saveTimer.ElapsedMilliseconds; saveTimer.Restart(); - if (elapsedMillis < debounceMilliseconds) { - Logger.WarnInternal($"ModConfiguration.Save({saveDefaultValues}) called for \"{Owner.Name}\" by \"{callee?.Name}\" from thread with id=\"{thread.ManagedThreadId}\", name=\"{thread.Name}\", bg=\"{thread.IsBackground}\", pool=\"{thread.IsThreadPoolThread}\". Last called {elapsedMillis / 1000f}s ago. This is very recent! Do not spam calls to ModConfiguration.Save()! All Save() calls by this mod are now subject to a {debounceMilliseconds}ms debouncing delay."); + if (elapsedMillis < DEBOUNCE_MILLIS) { + Logger.WarnInternal($"ModConfiguration.Save({saveDefaultValues}) called for \"{Owner.Name}\" by \"{callee?.Name}\" from thread with id=\"{thread.ManagedThreadId}\", name=\"{thread.Name}\", bg=\"{thread.IsBackground}\", pool=\"{thread.IsThreadPoolThread}\". Last called {elapsedMillis / 1000f}s ago. This is very recent! Do not spam calls to ModConfiguration.Save()! All Save() calls by this mod are now subject to a {DEBOUNCE_MILLIS}ms debouncing delay."); if (saveAction == null && callee != null) { // congrats, you've switched into Ultimate Punishment Mode where now I don't trust you and your Save() calls get debounced - saveAction = Util.Debounce(SaveInternal, debounceMilliseconds); + saveAction = Util.Debounce(SaveInternal, DEBOUNCE_MILLIS); saveActionForCallee.Add(callee.Name, saveAction); naughtySavers.Add(callee.Name); } @@ -501,24 +454,17 @@ internal void SaveQueue(bool saveDefaultValues = false, bool immediate = false) /// If true, default values will also be persisted private void SaveInternal(bool saveDefaultValues = false) { Stopwatch stopwatch = Stopwatch.StartNew(); - JObject json = new() { - [VERSION_JSON_KEY] = JToken.FromObject(Definition.Version.ToString(), jsonSerializer) - }; - - JObject valueMap = []; - foreach (ModConfigurationKey key in ConfigurationItemDefinitions) { - if (key.TryGetValue(out object? value)) { - valueMap[key.Name] = value == null ? null : JToken.FromObject(value, jsonSerializer); - } else if (saveDefaultValues && key.TryComputeDefault(out object? defaultValue)) { - valueMap[key.Name] = defaultValue == null ? null : JToken.FromObject(defaultValue, jsonSerializer); - } - } - - json[VALUES_JSON_KEY] = valueMap; - string configFile = GetModConfigPath(Owner); - File.WriteAllText(configFile, json.ToString()); + using var file = File.OpenWrite(configFile); + + jsonConfigurationConverter.SetContext(Definition, (ResoniteMod)Definition.Owner, saveDefaultValues); + try { + JsonSerializer.Serialize(file, this, jsonSerializerOptions); + } + finally { + jsonConfigurationConverter.ClearContext(); + } Logger.DebugFuncInternal(() => $"Saved ModConfiguration for \"{Owner.Name}\" in {stopwatch.ElapsedMilliseconds}ms"); } diff --git a/ResoniteModLoader/ResoniteModLoader.csproj b/ResoniteModLoader/ResoniteModLoader.csproj index beaef63..620ce6f 100644 --- a/ResoniteModLoader/ResoniteModLoader.csproj +++ b/ResoniteModLoader/ResoniteModLoader.csproj @@ -64,10 +64,6 @@ public sealed partial class ModLoader { $(ResonitePath)Elements.Data.dll False - - $(ResonitePath)Newtonsoft.Json.dll - False - From e44b370e74b70bed397ef17946fbb035acfdbbe1 Mon Sep 17 00:00:00 2001 From: Colin Tim Barndt Date: Wed, 28 Jan 2026 22:04:03 +0100 Subject: [PATCH 02/14] Make config options read only --- ResoniteModLoader/ModConfiguration.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/ResoniteModLoader/ModConfiguration.cs b/ResoniteModLoader/ModConfiguration.cs index 797afae..8dd7438 100644 --- a/ResoniteModLoader/ModConfiguration.cs +++ b/ResoniteModLoader/ModConfiguration.cs @@ -155,6 +155,7 @@ static ModConfiguration() { converters.Add(new ResonitePrimitiveConverter()); jsonConfigurationConverter = new(); converters.Add(jsonConfigurationConverter); + options.MakeReadOnly(); jsonSerializerOptions = options; } From 6865a67550c2585bca0467635c4a924d6ab07983 Mon Sep 17 00:00:00 2001 From: Colin Tim Barndt Date: Wed, 28 Jan 2026 22:14:23 +0100 Subject: [PATCH 03/14] Read config correctly in all cases --- .../JsonConverters/ConfigurationConverter.cs | 27 +++++++++---------- 1 file changed, 12 insertions(+), 15 deletions(-) diff --git a/ResoniteModLoader/JsonConverters/ConfigurationConverter.cs b/ResoniteModLoader/JsonConverters/ConfigurationConverter.cs index 14b5589..9192e9e 100644 --- a/ResoniteModLoader/JsonConverters/ConfigurationConverter.cs +++ b/ResoniteModLoader/JsonConverters/ConfigurationConverter.cs @@ -43,26 +43,30 @@ public override ModConfiguration Read(ref Utf8JsonReader reader, Type typeToConv if (reader.TokenType != JsonTokenType.StartObject) { throw new JsonException($"Expected an object, got {reader.TokenType}"); } - reader.ReadOrThrow(); // Consume start of object + reader.Read(); // Consume start of object // Read "version": "..." if (reader.GetString() != VERSION_JSON_KEY) { throw new JsonException($"Expected first property to be '{VERSION_JSON_KEY}'"); } - reader.ReadOrThrow(); + reader.Read(); var versionString = reader.GetString() ?? throw new JsonException("Version string is null"); Logger.MsgInternal($"Version: '{versionString}'"); Version version = new(versionString); - reader.ReadOrThrow(); + reader.Read(); if (!AreVersionsCompatible(version, ctx.definition!.Version)) { var handlingMode = ctx.mod!.HandleIncompatibleConfigurationVersions(ctx.definition.Version, version); switch (handlingMode) { case IncompatibleConfigurationHandlingOption.CLOBBER: Logger.WarnInternal($"{ctx.mod.Name} saved config version is {version} which is incompatible with mod's definition version {ctx.definition.Version}. Clobbering old config and starting fresh."); + + while (reader.TokenType != JsonTokenType.EndObject) + reader.Skip(); + return new ModConfiguration(ctx.definition!); case IncompatibleConfigurationHandlingOption.FORCELOAD: break; @@ -77,18 +81,18 @@ public override ModConfiguration Read(ref Utf8JsonReader reader, Type typeToConv if (reader.GetString() != VALUES_JSON_KEY) throw new JsonException($"Expected second property to be '{VALUES_JSON_KEY}'"); - reader.ReadOrThrow(); + reader.Read(); if (reader.TokenType != JsonTokenType.StartObject) throw new JsonException($"Expected an object, got {reader.TokenType}"); - reader.ReadOrThrow(); // Consume start of object + reader.Read(); // Consume start of object var keys = ctx.definition.ConfigurationItemDefinitions.ToDictionary(key => key.Name); while (reader.TokenType != JsonTokenType.EndObject) { var name = reader.GetString() ?? throw new JsonException("Object key is null"); - reader.ReadOrThrow(); + reader.Read(); // Ignore unknown keys if (!keys.TryGetValue(name, out var key)) { @@ -98,9 +102,9 @@ public override ModConfiguration Read(ref Utf8JsonReader reader, Type typeToConv var value = ReadGeneric(ref reader, key.ValueType(), options); key.Set(value); - reader.ReadOrThrow(); + reader.Read(); } - reader.ReadOrThrow(); // Consume end of object + reader.Read(); // Consume end of object if (reader.TokenType != JsonTokenType.EndObject) { throw new JsonException($"Extra keys in configuration object"); @@ -190,10 +194,3 @@ public static void Write(Utf8JsonWriter writer, object? value, JsonSerializerOpt } } } - -internal static class Utf8JsonReaderExt { - public static void ReadOrThrow(this ref Utf8JsonReader reader) { - if (!reader.Read()) - throw new JsonException(); - } -} From 386487e747d0d8f379fc37bb3582430670091e93 Mon Sep 17 00:00:00 2001 From: Colin Tim Barndt Date: Wed, 28 Jan 2026 22:15:11 +0100 Subject: [PATCH 04/14] Fixes #41 --- ResoniteModLoader/JsonConverters/ConfigurationConverter.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ResoniteModLoader/JsonConverters/ConfigurationConverter.cs b/ResoniteModLoader/JsonConverters/ConfigurationConverter.cs index 9192e9e..e052b3d 100644 --- a/ResoniteModLoader/JsonConverters/ConfigurationConverter.cs +++ b/ResoniteModLoader/JsonConverters/ConfigurationConverter.cs @@ -59,7 +59,7 @@ public override ModConfiguration Read(ref Utf8JsonReader reader, Type typeToConv reader.Read(); if (!AreVersionsCompatible(version, ctx.definition!.Version)) { - var handlingMode = ctx.mod!.HandleIncompatibleConfigurationVersions(ctx.definition.Version, version); + var handlingMode = ctx.mod!.HandleIncompatibleConfigurationVersions(version, ctx.definition.Version); switch (handlingMode) { case IncompatibleConfigurationHandlingOption.CLOBBER: Logger.WarnInternal($"{ctx.mod.Name} saved config version is {version} which is incompatible with mod's definition version {ctx.definition.Version}. Clobbering old config and starting fresh."); From 2c8f1b4d75cefe2c9c6167d906008ca68498d68b Mon Sep 17 00:00:00 2001 From: Colin Tim Barndt Date: Wed, 28 Jan 2026 22:41:31 +0100 Subject: [PATCH 05/14] Specify TypeInfoResolver --- ResoniteModLoader/ModConfiguration.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/ResoniteModLoader/ModConfiguration.cs b/ResoniteModLoader/ModConfiguration.cs index 8dd7438..9f3ad06 100644 --- a/ResoniteModLoader/ModConfiguration.cs +++ b/ResoniteModLoader/ModConfiguration.cs @@ -1,7 +1,7 @@ using System.Diagnostics; using System.Text.Json; using System.Text.Json.Serialization; - +using System.Text.Json.Serialization.Metadata; using FrooxEngine; using HarmonyLib; @@ -140,12 +140,13 @@ public class ModConfiguration : IModConfigurationDefinition { private static readonly ModConfigurationConverter jsonConfigurationConverter; static ModConfiguration() { - JsonSerializerOptions options = new() { + JsonSerializerOptions options = new(JsonSerializerDefaults.Strict) { MaxDepth = 32, ReferenceHandler = ReferenceHandler.IgnoreCycles, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingDefault, WriteIndented = true, IndentSize = 2, + TypeInfoResolver = new DefaultJsonTypeInfoResolver(), }; var converters = options.Converters; if (converters.Count != 0) { From d9a455c55ecb7f34c83b90ef94c50dd6dfa59aa8 Mon Sep 17 00:00:00 2001 From: Colin Tim Barndt Date: Wed, 28 Jan 2026 23:02:24 +0100 Subject: [PATCH 06/14] No more converter hacks --- .../JsonConverters/ConfigurationConverter.cs | 183 ++---------------- .../JsonConverters/DynamicJsonConverter.cs | 36 ++++ ResoniteModLoader/ModConfiguration.cs | 148 ++++++++++++-- 3 files changed, 182 insertions(+), 185 deletions(-) create mode 100644 ResoniteModLoader/JsonConverters/DynamicJsonConverter.cs diff --git a/ResoniteModLoader/JsonConverters/ConfigurationConverter.cs b/ResoniteModLoader/JsonConverters/ConfigurationConverter.cs index e052b3d..dbde46f 100644 --- a/ResoniteModLoader/JsonConverters/ConfigurationConverter.cs +++ b/ResoniteModLoader/JsonConverters/ConfigurationConverter.cs @@ -1,196 +1,41 @@ using System.Text.Json; using System.Text.Json.Serialization; -using ResoniteModLoader; - -internal sealed class ModConfigurationConverter : JsonConverter { - private const string VERSION_JSON_KEY = "version"; - private const string VALUES_JSON_KEY = "values"; - - // Thread local to prevent issues with multiple configs being - // saved from different threads at the same time. - private readonly ThreadLocal context = new(); - - private struct Context { - internal ModConfigurationDefinition? definition; - internal ResoniteMod? mod; - internal bool saveDefaultValues; - } - - internal void SetContext(ModConfigurationDefinition definition, ResoniteMod mod, bool saveDefaultValues = false) { - context.Value = new() { - definition = definition, - mod = mod, - saveDefaultValues = saveDefaultValues, - }; - } - - internal void ClearContext() { - context.Value = default; - } - - private Context TakeContext() { - var ctx = context.Value; - if (ctx.definition == null || ctx.mod == null) { - throw new InvalidOperationException("Invalid state of converter"); - } - context.Value = default; - return ctx; - } - - public override ModConfiguration Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { - var ctx = TakeContext(); - if (reader.TokenType != JsonTokenType.StartObject) { - throw new JsonException($"Expected an object, got {reader.TokenType}"); - } - reader.Read(); // Consume start of object - - // Read "version": "..." - - if (reader.GetString() != VERSION_JSON_KEY) { - throw new JsonException($"Expected first property to be '{VERSION_JSON_KEY}'"); - } - reader.Read(); - - var versionString = reader.GetString() - ?? throw new JsonException("Version string is null"); - Logger.MsgInternal($"Version: '{versionString}'"); - Version version = new(versionString); - reader.Read(); - - if (!AreVersionsCompatible(version, ctx.definition!.Version)) { - var handlingMode = ctx.mod!.HandleIncompatibleConfigurationVersions(version, ctx.definition.Version); - switch (handlingMode) { - case IncompatibleConfigurationHandlingOption.CLOBBER: - Logger.WarnInternal($"{ctx.mod.Name} saved config version is {version} which is incompatible with mod's definition version {ctx.definition.Version}. Clobbering old config and starting fresh."); - - while (reader.TokenType != JsonTokenType.EndObject) - reader.Skip(); - - return new ModConfiguration(ctx.definition!); - case IncompatibleConfigurationHandlingOption.FORCELOAD: - break; - case IncompatibleConfigurationHandlingOption.ERROR: // fall through to default - default: - ctx.mod!.AllowSavingConfiguration = false; - throw new ModConfigurationException($"{ctx.mod.Name} saved config version is {version} which is incompatible with mod's definition version {ctx.definition.Version}"); - } - } - - // Read "values": { ... } - - if (reader.GetString() != VALUES_JSON_KEY) - throw new JsonException($"Expected second property to be '{VALUES_JSON_KEY}'"); - reader.Read(); - - if (reader.TokenType != JsonTokenType.StartObject) - throw new JsonException($"Expected an object, got {reader.TokenType}"); - reader.Read(); // Consume start of object - - var keys = ctx.definition.ConfigurationItemDefinitions.ToDictionary(key => key.Name); - - while (reader.TokenType != JsonTokenType.EndObject) { - var name = reader.GetString() - ?? throw new JsonException("Object key is null"); - reader.Read(); - - // Ignore unknown keys - if (!keys.TryGetValue(name, out var key)) { - Logger.WarnInternal($"{ctx.mod!.Name} saved config version contains entry '{name}' which does not exist in its configuration definition"); - continue; - } - - var value = ReadGeneric(ref reader, key.ValueType(), options); - key.Set(value); - reader.Read(); - } - reader.Read(); // Consume end of object - - if (reader.TokenType != JsonTokenType.EndObject) { - throw new JsonException($"Extra keys in configuration object"); - } - - // Exit on end object token - - return new(ctx.definition); - } - - public override void Write(Utf8JsonWriter writer, ModConfiguration value, JsonSerializerOptions options) { - var ctx = TakeContext(); - - writer.WriteStartObject(); - - writer.WriteString(VERSION_JSON_KEY, ctx.definition!.Version.ToString()); - - writer.WritePropertyName(VALUES_JSON_KEY); - writer.WriteStartObject(); - - foreach (var key in ctx.definition.ConfigurationItemDefinitions) { - if (key.TryGetValue(out object? writtenValue)) { - // write - } - else if (ctx.saveDefaultValues && key.TryComputeDefault(out writtenValue)) { - // write - } - else { - continue; - } - writer.WritePropertyName(key.Name); - if (writtenValue == null) { - writer.WriteNullValue(); - continue; - } - WriteGeneric(writer, key.ValueType(), writtenValue, options); - } - - writer.WriteEndObject(); - writer.WriteEndObject(); - } - - private static bool AreVersionsCompatible(Version serializedVersion, Version currentVersion) { - if (serializedVersion.Major != currentVersion.Major) { - // major version differences are hard incompatible - return false; - } - - if (serializedVersion.Minor > currentVersion.Minor) { - // if serialized config has a newer minor version than us - // in other words, someone downgraded the mod but not the config - // then we cannot load the config - return false; - } - - // none of the checks failed! - return true; - } +namespace ResoniteModLoader.JsonConverters; +// Utility class for dynamically calling JSON converters +internal static class DynamicJsonConverter { private delegate object? ReadDelegate(ref Utf8JsonReader reader, JsonSerializerOptions options); - private object? ReadGeneric(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { + internal static object? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { var converterType = typeof(InnerConverter<>).MakeGenericType([typeToConvert]); - var read = converterType.GetMethod(nameof(InnerConverter<>.Read))!.CreateDelegate(); + var read = converterType + .GetMethod(nameof(InnerConverter<>.Read))! + .CreateDelegate(); return read(ref reader, options); } private delegate void WriteDelegate(Utf8JsonWriter writer, object? value, JsonSerializerOptions options); - private void WriteGeneric(Utf8JsonWriter writer, Type typeToConvert, object? value, JsonSerializerOptions options) { + internal static void Write(Utf8JsonWriter writer, Type typeToConvert, object? value, JsonSerializerOptions options) { var converterType = typeof(InnerConverter<>).MakeGenericType([typeToConvert]); - var write = converterType.GetMethod(nameof(InnerConverter<>.Write))!.CreateDelegate(); + var write = converterType + .GetMethod(nameof(InnerConverter<>.Write))! + .CreateDelegate(); write(writer, value, options); } private static class InnerConverter { public static object? Read(ref Utf8JsonReader reader, JsonSerializerOptions options) { - var converter = (JsonConverter)options.GetConverter(typeof(T)); + var converter = (JsonConverter)options.GetConverter(typeof(T)); return converter.Read(ref reader, typeof(T), options); } public static void Write(Utf8JsonWriter writer, object? value, JsonSerializerOptions options) { - var converter = (JsonConverter)options.GetConverter(typeof(T)); - converter.Write(writer, (T?)value, options); + var converter = (JsonConverter)options.GetConverter(typeof(T)); + converter.Write(writer, (T)value!, options); } } } diff --git a/ResoniteModLoader/JsonConverters/DynamicJsonConverter.cs b/ResoniteModLoader/JsonConverters/DynamicJsonConverter.cs new file mode 100644 index 0000000..3446029 --- /dev/null +++ b/ResoniteModLoader/JsonConverters/DynamicJsonConverter.cs @@ -0,0 +1,36 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +using ResoniteModLoader; + +internal static class DynamicJsonConverter { + private delegate object? ReadDelegate(ref Utf8JsonReader reader, JsonSerializerOptions options); + + internal static object? ReadDynamic(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { + var converterType = typeof(InnerConverter<>).MakeGenericType([typeToConvert]); + var read = converterType.GetMethod(nameof(InnerConverter<>.Read))!.CreateDelegate(); + + return read(ref reader, options); + } + + private delegate void WriteDelegate(Utf8JsonWriter writer, object? value, JsonSerializerOptions options); + + internal static void WriteDynamic(Utf8JsonWriter writer, Type typeToConvert, object? value, JsonSerializerOptions options) { + var converterType = typeof(InnerConverter<>).MakeGenericType([typeToConvert]); + var write = converterType.GetMethod(nameof(InnerConverter<>.Write))!.CreateDelegate(); + + write(writer, value, options); + } + + private static class InnerConverter { + public static object? Read(ref Utf8JsonReader reader, JsonSerializerOptions options) { + var converter = (JsonConverter)options.GetConverter(typeof(T)); + return converter.Read(ref reader, typeof(T), options); + } + + public static void Write(Utf8JsonWriter writer, object? value, JsonSerializerOptions options) { + var converter = (JsonConverter)options.GetConverter(typeof(T)); + converter.Write(writer, (T?)value, options); + } + } +} diff --git a/ResoniteModLoader/ModConfiguration.cs b/ResoniteModLoader/ModConfiguration.cs index 9f3ad06..9630dc9 100644 --- a/ResoniteModLoader/ModConfiguration.cs +++ b/ResoniteModLoader/ModConfiguration.cs @@ -137,7 +137,6 @@ public class ModConfiguration : IModConfigurationDefinition { private readonly Dictionary> saveActionForCallee = []; private static readonly JsonSerializerOptions jsonSerializerOptions; - private static readonly ModConfigurationConverter jsonConfigurationConverter; static ModConfiguration() { JsonSerializerOptions options = new(JsonSerializerDefaults.Strict) { @@ -154,8 +153,6 @@ static ModConfiguration() { } converters.Add(new EnumConverter()); converters.Add(new ResonitePrimitiveConverter()); - jsonConfigurationConverter = new(); - converters.Add(jsonConfigurationConverter); options.MakeReadOnly(); jsonSerializerOptions = options; } @@ -364,9 +361,9 @@ private bool AnyValuesSet() { string configFile = GetModConfigPath(mod); try { - using var file = File.OpenRead(configFile); - jsonConfigurationConverter.SetContext(definition, mod); - return JsonSerializer.Deserialize(file, jsonSerializerOptions); + var file = File.ReadAllBytes(configFile); + Utf8JsonReader reader = new(file); + return ReadModConfiguration(ref reader, jsonSerializerOptions, definition, mod); } catch (FileNotFoundException) { // return early and create a new config return new ModConfiguration(definition); @@ -377,13 +374,108 @@ private bool AnyValuesSet() { Logger.ErrorInternal($"Error loading config for {mod.Name}, creating new config file (old file can be found at {backupPath}). Exception:\n{e}"); File.Move(configFile, backupPath); } - finally { - jsonConfigurationConverter.ClearContext(); - } return new ModConfiguration(definition); } + private static ModConfiguration ReadModConfiguration( + ref Utf8JsonReader reader, + JsonSerializerOptions options, + ModConfigurationDefinition definition, + ResoniteMod mod + ) { + if (reader.TokenType != JsonTokenType.StartObject) { + throw new JsonException($"Expected an object, got {reader.TokenType}"); + } + reader.Read(); // Consume start of object + + // Read "version": "..." + + if (reader.GetString() != VERSION_JSON_KEY) { + throw new JsonException($"Expected first property to be '{VERSION_JSON_KEY}'"); + } + reader.Read(); + + var versionString = reader.GetString() + ?? throw new JsonException("Version string is null"); + Logger.MsgInternal($"Version: '{versionString}'"); + Version version = new(versionString); + reader.Read(); + + if (!AreVersionsCompatible(version, definition.Version)) { + var handlingMode = mod.HandleIncompatibleConfigurationVersions(version, definition.Version); + switch (handlingMode) { + case IncompatibleConfigurationHandlingOption.CLOBBER: + Logger.WarnInternal($"{mod.Name} saved config version is {version} which is incompatible with mod's definition version {definition.Version}. Clobbering old config and starting fresh."); + + while (reader.TokenType != JsonTokenType.EndObject) + reader.Skip(); + + return new ModConfiguration(definition); + case IncompatibleConfigurationHandlingOption.FORCELOAD: + break; + case IncompatibleConfigurationHandlingOption.ERROR: // fall through to default + default: + mod.AllowSavingConfiguration = false; + throw new ModConfigurationException($"{mod.Name} saved config version is {version} which is incompatible with mod's definition version {definition.Version}"); + } + } + + // Read "values": { ... } + + if (reader.GetString() != VALUES_JSON_KEY) + throw new JsonException($"Expected second property to be '{VALUES_JSON_KEY}'"); + reader.Read(); + + if (reader.TokenType != JsonTokenType.StartObject) + throw new JsonException($"Expected an object, got {reader.TokenType}"); + reader.Read(); // Consume start of object + + var keys = definition.ConfigurationItemDefinitions.ToDictionary(key => key.Name); + + while (reader.TokenType != JsonTokenType.EndObject) { + var name = reader.GetString() + ?? throw new JsonException("Object key is null"); + reader.Read(); + + // Ignore unknown keys + if (!keys.TryGetValue(name, out var key)) { + Logger.WarnInternal($"{mod.Name} saved config version contains entry '{name}' which does not exist in its configuration definition"); + continue; + } + + var value = DynamicJsonConverter.ReadDynamic(ref reader, key.ValueType(), options); + key.Set(value); + reader.Read(); + } + reader.Read(); // Consume end of object + + if (reader.TokenType != JsonTokenType.EndObject) { + throw new JsonException($"Extra keys in configuration object"); + } + + // Exit on end object token + + return new(definition); + } + + private static bool AreVersionsCompatible(Version serializedVersion, Version currentVersion) { + if (serializedVersion.Major != currentVersion.Major) { + // major version differences are hard incompatible + return false; + } + + if (serializedVersion.Minor > currentVersion.Minor) { + // if serialized config has a newer minor version than us + // in other words, someone downgraded the mod but not the config + // then we cannot load the config + return false; + } + + // none of the checks failed! + return true; + } + /// /// Persist this configuration to disk.
/// This method is not called automatically. @@ -459,16 +551,40 @@ private void SaveInternal(bool saveDefaultValues = false) { string configFile = GetModConfigPath(Owner); using var file = File.OpenWrite(configFile); + using Utf8JsonWriter writer = new(file); + WriteModConfiguration(writer, jsonSerializerOptions, saveDefaultValues); - jsonConfigurationConverter.SetContext(Definition, (ResoniteMod)Definition.Owner, saveDefaultValues); - try { - JsonSerializer.Serialize(file, this, jsonSerializerOptions); - } - finally { - jsonConfigurationConverter.ClearContext(); + Logger.DebugFuncInternal(() => $"Saved ModConfiguration for \"{Owner.Name}\" in {stopwatch.ElapsedMilliseconds}ms"); + } + + private void WriteModConfiguration(Utf8JsonWriter writer, JsonSerializerOptions options, bool saveDefaultValues) { + writer.WriteStartObject(); + + writer.WriteString(VERSION_JSON_KEY, Definition.Version.ToString()); + + writer.WritePropertyName(VALUES_JSON_KEY); + writer.WriteStartObject(); + + foreach (var key in Definition.ConfigurationItemDefinitions) { + if (key.TryGetValue(out object? writtenValue)) { + // write + } + else if (saveDefaultValues && key.TryComputeDefault(out writtenValue)) { + // write + } + else { + continue; + } + writer.WritePropertyName(key.Name); + if (writtenValue == null) { + writer.WriteNullValue(); + continue; + } + DynamicJsonConverter.WriteDynamic(writer, key.ValueType(), writtenValue, options); } - Logger.DebugFuncInternal(() => $"Saved ModConfiguration for \"{Owner.Name}\" in {stopwatch.ElapsedMilliseconds}ms"); + writer.WriteEndObject(); + writer.WriteEndObject(); } private void FireConfigurationChangedEvent(ModConfigurationKey key, string? label) { From 1ae4dae9c62fd575c86f48e24054cae20b26d858 Mon Sep 17 00:00:00 2001 From: Colin Tim Barndt Date: Wed, 28 Jan 2026 23:19:38 +0100 Subject: [PATCH 07/14] Fix read/write options missing --- ResoniteModLoader/ModConfiguration.cs | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/ResoniteModLoader/ModConfiguration.cs b/ResoniteModLoader/ModConfiguration.cs index 9630dc9..d71b29b 100644 --- a/ResoniteModLoader/ModConfiguration.cs +++ b/ResoniteModLoader/ModConfiguration.cs @@ -137,6 +137,8 @@ public class ModConfiguration : IModConfigurationDefinition { private readonly Dictionary> saveActionForCallee = []; private static readonly JsonSerializerOptions jsonSerializerOptions; + private static readonly JsonReaderOptions jsonReaderOptions; + private static readonly JsonWriterOptions jsonWriterOptions; static ModConfiguration() { JsonSerializerOptions options = new(JsonSerializerDefaults.Strict) { @@ -155,6 +157,17 @@ static ModConfiguration() { converters.Add(new ResonitePrimitiveConverter()); options.MakeReadOnly(); jsonSerializerOptions = options; + + jsonReaderOptions = new() { + MaxDepth = options.MaxDepth, + AllowMultipleValues = false, + }; + + jsonWriterOptions = new() { + Indented = options.WriteIndented, + IndentSize = options.IndentSize, + IndentCharacter = ' ', + }; } internal ModConfiguration(ModConfigurationDefinition definition) { @@ -362,7 +375,7 @@ private bool AnyValuesSet() { try { var file = File.ReadAllBytes(configFile); - Utf8JsonReader reader = new(file); + Utf8JsonReader reader = new(file, jsonReaderOptions); return ReadModConfiguration(ref reader, jsonSerializerOptions, definition, mod); } catch (FileNotFoundException) { // return early and create a new config @@ -384,6 +397,7 @@ private static ModConfiguration ReadModConfiguration( ModConfigurationDefinition definition, ResoniteMod mod ) { + reader.Read(); if (reader.TokenType != JsonTokenType.StartObject) { throw new JsonException($"Expected an object, got {reader.TokenType}"); } @@ -550,8 +564,8 @@ private void SaveInternal(bool saveDefaultValues = false) { Stopwatch stopwatch = Stopwatch.StartNew(); string configFile = GetModConfigPath(Owner); - using var file = File.OpenWrite(configFile); - using Utf8JsonWriter writer = new(file); + using var file = File.Open(configFile, FileMode.Create, FileAccess.Write); + using Utf8JsonWriter writer = new(file, jsonWriterOptions); WriteModConfiguration(writer, jsonSerializerOptions, saveDefaultValues); Logger.DebugFuncInternal(() => $"Saved ModConfiguration for \"{Owner.Name}\" in {stopwatch.ElapsedMilliseconds}ms"); From a45e1ffde1b14797207cfdcca11b40bcaa181900 Mon Sep 17 00:00:00 2001 From: Colin Tim Barndt Date: Wed, 28 Jan 2026 23:23:44 +0100 Subject: [PATCH 08/14] Remove unnecessary skip-read of incompatible config --- ResoniteModLoader/ModConfiguration.cs | 4 ---- 1 file changed, 4 deletions(-) diff --git a/ResoniteModLoader/ModConfiguration.cs b/ResoniteModLoader/ModConfiguration.cs index d71b29b..7ded9a0 100644 --- a/ResoniteModLoader/ModConfiguration.cs +++ b/ResoniteModLoader/ModConfiguration.cs @@ -421,10 +421,6 @@ ResoniteMod mod switch (handlingMode) { case IncompatibleConfigurationHandlingOption.CLOBBER: Logger.WarnInternal($"{mod.Name} saved config version is {version} which is incompatible with mod's definition version {definition.Version}. Clobbering old config and starting fresh."); - - while (reader.TokenType != JsonTokenType.EndObject) - reader.Skip(); - return new ModConfiguration(definition); case IncompatibleConfigurationHandlingOption.FORCELOAD: break; From 214656986000d65cc8939d3466f12cef8629800b Mon Sep 17 00:00:00 2001 From: Colin Tim Barndt Date: Thu, 29 Jan 2026 00:28:21 +0100 Subject: [PATCH 09/14] Remove duplicate helper class --- .../JsonConverters/ConfigurationConverter.cs | 41 ------------------- .../JsonConverters/DynamicJsonConverter.cs | 21 ++++++---- ResoniteModLoader/ModConfiguration.cs | 4 +- 3 files changed, 15 insertions(+), 51 deletions(-) delete mode 100644 ResoniteModLoader/JsonConverters/ConfigurationConverter.cs diff --git a/ResoniteModLoader/JsonConverters/ConfigurationConverter.cs b/ResoniteModLoader/JsonConverters/ConfigurationConverter.cs deleted file mode 100644 index dbde46f..0000000 --- a/ResoniteModLoader/JsonConverters/ConfigurationConverter.cs +++ /dev/null @@ -1,41 +0,0 @@ -using System.Text.Json; -using System.Text.Json.Serialization; - -namespace ResoniteModLoader.JsonConverters; - -// Utility class for dynamically calling JSON converters -internal static class DynamicJsonConverter { - private delegate object? ReadDelegate(ref Utf8JsonReader reader, JsonSerializerOptions options); - - internal static object? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { - var converterType = typeof(InnerConverter<>).MakeGenericType([typeToConvert]); - var read = converterType - .GetMethod(nameof(InnerConverter<>.Read))! - .CreateDelegate(); - - return read(ref reader, options); - } - - private delegate void WriteDelegate(Utf8JsonWriter writer, object? value, JsonSerializerOptions options); - - internal static void Write(Utf8JsonWriter writer, Type typeToConvert, object? value, JsonSerializerOptions options) { - var converterType = typeof(InnerConverter<>).MakeGenericType([typeToConvert]); - var write = converterType - .GetMethod(nameof(InnerConverter<>.Write))! - .CreateDelegate(); - - write(writer, value, options); - } - - private static class InnerConverter { - public static object? Read(ref Utf8JsonReader reader, JsonSerializerOptions options) { - var converter = (JsonConverter)options.GetConverter(typeof(T)); - return converter.Read(ref reader, typeof(T), options); - } - - public static void Write(Utf8JsonWriter writer, object? value, JsonSerializerOptions options) { - var converter = (JsonConverter)options.GetConverter(typeof(T)); - converter.Write(writer, (T)value!, options); - } - } -} diff --git a/ResoniteModLoader/JsonConverters/DynamicJsonConverter.cs b/ResoniteModLoader/JsonConverters/DynamicJsonConverter.cs index 3446029..dbde46f 100644 --- a/ResoniteModLoader/JsonConverters/DynamicJsonConverter.cs +++ b/ResoniteModLoader/JsonConverters/DynamicJsonConverter.cs @@ -1,36 +1,41 @@ using System.Text.Json; using System.Text.Json.Serialization; -using ResoniteModLoader; +namespace ResoniteModLoader.JsonConverters; +// Utility class for dynamically calling JSON converters internal static class DynamicJsonConverter { private delegate object? ReadDelegate(ref Utf8JsonReader reader, JsonSerializerOptions options); - internal static object? ReadDynamic(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { + internal static object? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { var converterType = typeof(InnerConverter<>).MakeGenericType([typeToConvert]); - var read = converterType.GetMethod(nameof(InnerConverter<>.Read))!.CreateDelegate(); + var read = converterType + .GetMethod(nameof(InnerConverter<>.Read))! + .CreateDelegate(); return read(ref reader, options); } private delegate void WriteDelegate(Utf8JsonWriter writer, object? value, JsonSerializerOptions options); - internal static void WriteDynamic(Utf8JsonWriter writer, Type typeToConvert, object? value, JsonSerializerOptions options) { + internal static void Write(Utf8JsonWriter writer, Type typeToConvert, object? value, JsonSerializerOptions options) { var converterType = typeof(InnerConverter<>).MakeGenericType([typeToConvert]); - var write = converterType.GetMethod(nameof(InnerConverter<>.Write))!.CreateDelegate(); + var write = converterType + .GetMethod(nameof(InnerConverter<>.Write))! + .CreateDelegate(); write(writer, value, options); } private static class InnerConverter { public static object? Read(ref Utf8JsonReader reader, JsonSerializerOptions options) { - var converter = (JsonConverter)options.GetConverter(typeof(T)); + var converter = (JsonConverter)options.GetConverter(typeof(T)); return converter.Read(ref reader, typeof(T), options); } public static void Write(Utf8JsonWriter writer, object? value, JsonSerializerOptions options) { - var converter = (JsonConverter)options.GetConverter(typeof(T)); - converter.Write(writer, (T?)value, options); + var converter = (JsonConverter)options.GetConverter(typeof(T)); + converter.Write(writer, (T)value!, options); } } } diff --git a/ResoniteModLoader/ModConfiguration.cs b/ResoniteModLoader/ModConfiguration.cs index 7ded9a0..6dc6c9e 100644 --- a/ResoniteModLoader/ModConfiguration.cs +++ b/ResoniteModLoader/ModConfiguration.cs @@ -454,7 +454,7 @@ ResoniteMod mod continue; } - var value = DynamicJsonConverter.ReadDynamic(ref reader, key.ValueType(), options); + var value = DynamicJsonConverter.Read(ref reader, key.ValueType(), options); key.Set(value); reader.Read(); } @@ -590,7 +590,7 @@ private void WriteModConfiguration(Utf8JsonWriter writer, JsonSerializerOptions writer.WriteNullValue(); continue; } - DynamicJsonConverter.WriteDynamic(writer, key.ValueType(), writtenValue, options); + DynamicJsonConverter.Write(writer, key.ValueType(), writtenValue, options); } writer.WriteEndObject(); From 7b0b7e4226e7658c8751753b654bee76520c02e5 Mon Sep 17 00:00:00 2001 From: Colin Tim Barndt Date: Thu, 29 Jan 2026 00:33:55 +0100 Subject: [PATCH 10/14] Remove nullable for Resonite primitive JsonConverter --- ResoniteModLoader/JsonConverters/ResonitePrimitiveConverter.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ResoniteModLoader/JsonConverters/ResonitePrimitiveConverter.cs b/ResoniteModLoader/JsonConverters/ResonitePrimitiveConverter.cs index db449f2..a4b9828 100644 --- a/ResoniteModLoader/JsonConverters/ResonitePrimitiveConverter.cs +++ b/ResoniteModLoader/JsonConverters/ResonitePrimitiveConverter.cs @@ -22,7 +22,7 @@ public override bool CanConvert(Type typeToConvert) return (JsonConverter?)Activator.CreateInstance(type); } - private sealed class Inner : JsonConverter { + private sealed class Inner : JsonConverter { public override T? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { var value = reader.GetString(); if (value == null) From 6c2ccd3e1cabc627697dbf43c7713e599b980a35 Mon Sep 17 00:00:00 2001 From: Colin Tim Barndt Date: Thu, 29 Jan 2026 00:37:16 +0100 Subject: [PATCH 11/14] Make ModConfiguration constructor private again --- ResoniteModLoader/ModConfiguration.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ResoniteModLoader/ModConfiguration.cs b/ResoniteModLoader/ModConfiguration.cs index 6dc6c9e..b65d0fd 100644 --- a/ResoniteModLoader/ModConfiguration.cs +++ b/ResoniteModLoader/ModConfiguration.cs @@ -170,7 +170,7 @@ static ModConfiguration() { }; } - internal ModConfiguration(ModConfigurationDefinition definition) { + private ModConfiguration(ModConfigurationDefinition definition) { Definition = definition; } From f840edc3ad7d37fcf8e269abf1fab309ab384d71 Mon Sep 17 00:00:00 2001 From: Colin Tim Barndt Date: Thu, 29 Jan 2026 11:53:21 +0100 Subject: [PATCH 12/14] Fix a warning for int.ToString --- ResoniteModLoader/ModConfiguration.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/ResoniteModLoader/ModConfiguration.cs b/ResoniteModLoader/ModConfiguration.cs index b65d0fd..3e5b812 100644 --- a/ResoniteModLoader/ModConfiguration.cs +++ b/ResoniteModLoader/ModConfiguration.cs @@ -1,4 +1,5 @@ using System.Diagnostics; +using System.Globalization; using System.Text.Json; using System.Text.Json.Serialization; using System.Text.Json.Serialization.Metadata; @@ -383,7 +384,8 @@ private bool AnyValuesSet() { } catch (Exception e) { // I know not what exceptions the JSON library will throw, but they must be contained mod.AllowSavingConfiguration = false; - var backupPath = configFile + "." + Convert.ToBase64String(Encoding.UTF8.GetBytes(((int)DateTimeOffset.Now.TimeOfDay.TotalSeconds).ToString("X"))) + ".bak"; //ExampleMod.json.40A4.bak, unlikely to already exist + var hex = ((int)DateTimeOffset.Now.TimeOfDay.TotalSeconds).ToString("X", CultureInfo.InvariantCulture); + var backupPath = configFile + "." + Convert.ToBase64String(Encoding.UTF8.GetBytes(hex)) + ".bak"; //ExampleMod.json.40A4.bak, unlikely to already exist Logger.ErrorInternal($"Error loading config for {mod.Name}, creating new config file (old file can be found at {backupPath}). Exception:\n{e}"); File.Move(configFile, backupPath); } From f55ea8aa882d78cef4d6df2cb314b3c08a8c8c08 Mon Sep 17 00:00:00 2001 From: Colin Tim Barndt Date: Sat, 21 Feb 2026 04:01:01 +0100 Subject: [PATCH 13/14] Use relaxed string encoding for JSON --- ResoniteModLoader/ModConfiguration.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/ResoniteModLoader/ModConfiguration.cs b/ResoniteModLoader/ModConfiguration.cs index 3e5b812..db64f9c 100644 --- a/ResoniteModLoader/ModConfiguration.cs +++ b/ResoniteModLoader/ModConfiguration.cs @@ -1,5 +1,6 @@ using System.Diagnostics; using System.Globalization; +using System.Text.Encodings.Web; using System.Text.Json; using System.Text.Json.Serialization; using System.Text.Json.Serialization.Metadata; @@ -149,6 +150,8 @@ static ModConfiguration() { WriteIndented = true, IndentSize = 2, TypeInfoResolver = new DefaultJsonTypeInfoResolver(), + // No need to escape `<`, `>`, `&`, etc. We are not interfacing with JavaScript + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, }; var converters = options.Converters; if (converters.Count != 0) { From 7e694bd90c192c412f895aed2865f94636e9d31d Mon Sep 17 00:00:00 2001 From: Colin Tim Barndt Date: Sat, 21 Feb 2026 04:38:21 +0100 Subject: [PATCH 14/14] JSON WriterOptions inherits all options from serializer --- ResoniteModLoader/ModConfiguration.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/ResoniteModLoader/ModConfiguration.cs b/ResoniteModLoader/ModConfiguration.cs index db64f9c..84fd09f 100644 --- a/ResoniteModLoader/ModConfiguration.cs +++ b/ResoniteModLoader/ModConfiguration.cs @@ -168,8 +168,10 @@ static ModConfiguration() { }; jsonWriterOptions = new() { + MaxDepth = options.MaxDepth, Indented = options.WriteIndented, IndentSize = options.IndentSize, + Encoder = options.Encoder, IndentCharacter = ' ', }; }