From 4c8e61c4231b3505c9f940d85d5c50222a65a4b5 Mon Sep 17 00:00:00 2001 From: Sora Date: Tue, 15 Sep 2026 23:56:58 +0200 Subject: [PATCH 01/35] Implement C++ Mod Config. --- docs/NativeMods.md | 89 +- .../Commands/Mod/ConfigureModCommand.cs | 33 +- .../Configuration/NativeConfigTypeEmitter.cs | 292 +++++++ .../Configuration/NativeConfigurableBase.cs | 251 ++++++ .../Configuration/NativeModConfigSchema.cs | 513 +++++++++++ .../Configuration/NativeModConfigurator.cs | 83 ++ source/Reloaded.Mod.Launcher.Lib/Usings.cs | 1 + .../Launcher/NativeModConfigTests.cs | 226 +++++ .../Reloaded.Mod.Loader/Mods/PluginManager.cs | 9 +- .../Mods/Structs/NativeMod.cs | 27 +- .../native/.template.config/template.json | 46 + .../templates/native/CMakeLists.txt | 17 + .../templates/native/ConfigSchema.json | 76 ++ .../templates/native/ModConfig.json | 21 + .../templates/native/README.md | 36 + .../templates/native/ReloadedModConfig.h | 814 ++++++++++++++++++ .../templates/native/main.cpp | 29 + 17 files changed, 2554 insertions(+), 9 deletions(-) create mode 100644 source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/NativeConfigTypeEmitter.cs create mode 100644 source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/NativeConfigurableBase.cs create mode 100644 source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/NativeModConfigSchema.cs create mode 100644 source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/NativeModConfigurator.cs create mode 100644 source/Reloaded.Mod.Loader.Tests/Launcher/NativeModConfigTests.cs create mode 100644 source/Reloaded.Mod.Template/templates/native/.template.config/template.json create mode 100644 source/Reloaded.Mod.Template/templates/native/CMakeLists.txt create mode 100644 source/Reloaded.Mod.Template/templates/native/ConfigSchema.json create mode 100644 source/Reloaded.Mod.Template/templates/native/ModConfig.json create mode 100644 source/Reloaded.Mod.Template/templates/native/README.md create mode 100644 source/Reloaded.Mod.Template/templates/native/ReloadedModConfig.h create mode 100644 source/Reloaded.Mod.Template/templates/native/main.cpp diff --git a/docs/NativeMods.md b/docs/NativeMods.md index e8be2045..27435731 100644 --- a/docs/NativeMods.md +++ b/docs/NativeMods.md @@ -15,18 +15,105 @@ You can control which file the mod loader will load for x64 and x86 processes us ``` To generate the config file, create a new mod from within the launcher. +## User Settings (Config Dialog) + +Native mods can expose settings in the launcher's *Configure* dialog without any C# code, through a declarative schema file. Place a `ConfigSchema.json` file next to your `ModConfig.json` describing your settings, and the launcher builds the same configuration UI used by C# mods: checkboxes, numeric boxes, sliders, dropdowns, file and folder pickers, with categories, tooltips and a Reset button. + +A minimal schema looks like this: + +```json +{ + "Configurations": [ + { + "FileName": "Config.json", + "DisplayName": "Default Config", + "Enums": [ + { + "Name": "Quality", + "Members": [ { "Name": "Low" }, { "Name": "High", "DisplayName": "High Quality" } ] + } + ], + "Properties": [ + { + "Name": "EnableThing", + "Type": "bool", + "DisplayName": "Enable Thing", + "Description": "Turns the thing on or off.", + "Category": "General", + "Order": 0, + "DefaultValue": true + }, + { + "Name": "Volume", + "Type": "int", + "DefaultValue": 75, + "Slider": { "Minimum": 0.0, "Maximum": 100.0, "SmallChange": 1.0, "LargeChange": 10.0, "TickFrequency": 10, "ShowTextField": true } + }, + { "Name": "Brightness", "Type": "float", "DefaultValue": 1.5 }, + { "Name": "Quality", "Type": "Quality", "DefaultValue": "High" }, + { "Name": "CustomFile", "Type": "string", "FilePicker": { "Title": "Choose a File" } } + ] + } + ] +} +``` + +Notes: + +- `Type` is one of `bool`, `int`, `float`, `double`, `string`, or the name of an entry in `Enums`. +- `DisplayName`, `Description`, `Category`, `Order` and `DefaultValue` mirror the attributes used by the C# mod template. +- `Slider`, `FilePicker` and `FolderPicker` mirror the `SliderControlParams`, `FilePickerParams` and `FolderPickerParams` attributes; all fields are optional. +- Each entry in `Configurations` becomes one page of the dialog, saved to its own file (`FileName`) inside the mod's user config folder (`User/Mods/`). Values missing from the file fall back to `DefaultValue`. + +The values are saved as a flat JSON file such as: + +```json +{ + "EnableThing": false, + "Volume": 10, + "Brightness": 0.25, + "Quality": "Low" +} +``` + +### Reading the Settings from C/C++ + +To read the settings inside your mod, copy `ReloadedModConfig.h` (from the [native mod template](https://github.com/Reloaded-Project/Reloaded-II/tree/master/source/Reloaded.Mod.Template/templates/native)) into your project and define `RELOADED_MOD_CONFIG_IMPL(your_start_function)` in exactly one source file. The macro exports `ReloadedStartEx`, which the loader calls with your mod's folders: + +```cpp +#include "ReloadedModConfig.h" + +static void my_start() +{ + auto& config = reloaded::config(); + bool enabled = config.get_bool("EnableThing", true); + long long volume = config.get_int("Volume", 75); + double brightness = config.get_float("Brightness", 1.5); + std::wstring file = config.get_wstring("CustomFile", L""); + + static const char* quality[] = { "Low", "High" }; + int qualityIndex = config.get_enum("Quality", quality, 2, 1); +} + +RELOADED_MOD_CONFIG_IMPL(my_start) +``` + +Missing values fall back to the schema defaults, then to the fallback argument. The header only needs the C++17 standard library (Windows APIs are used behind `_WIN32`, everything else uses `std::filesystem`), so it also works outside of Windows if you ever reuse it. `config.watch(callback)` spawns a thread that reloads the settings when the user changes them while the game is running. It hands you that thread, keep it and detach or join it, letting it go out of scope while it runs kills the process. + ## Exports **Entry Points:** Reloaded tries to start mods by using the following entry points in order: +- ReloadedStartEx - ReloadedStart - InitializeASI - Init If none of these entry points is found, the mod will not be loaded. -The exported methods should have no parameters and return `void`. + +`ReloadedStartEx` is defined as `void fn(const wchar_t* modDirectory, const wchar_t* userConfigDirectory)` and receives the mod's own folder (where `ConfigSchema.json` lives) and the folder where the launcher stores user settings. Use it (or the helper header above) if your mod reads its configuration. The other entry points should have no parameters and return `void`. **Suspend, Resume, Unload:** diff --git a/source/Reloaded.Mod.Launcher.Lib/Commands/Mod/ConfigureModCommand.cs b/source/Reloaded.Mod.Launcher.Lib/Commands/Mod/ConfigureModCommand.cs index b5c02a9e..d2f60785 100644 --- a/source/Reloaded.Mod.Launcher.Lib/Commands/Mod/ConfigureModCommand.cs +++ b/source/Reloaded.Mod.Launcher.Lib/Commands/Mod/ConfigureModCommand.cs @@ -67,10 +67,40 @@ private bool TryGetConfiguratorDisposing() private bool TryGetConfigurator(out IConfiguratorV1? configurator, out PluginLoader? loader) { var config = _modTuple!.Config; - string dllPath = config.GetManagedDllPath(_modTuple.Path); configurator = null; loader = null; + var modDirectory = Path.GetFullPath(Path.GetDirectoryName(_modTuple.Path)!); + + // Native (C/C++) mods describe their settings in a schema file, no managed code required. + if (NativeModConfigSchema.ExistsInFolder(modDirectory)) + { + // Validate upfront, a broken schema disables the button instead of failing later. + NativeModConfigSchema.Load(modDirectory); + + var nativeConfigurator = new NativeModConfigurator(modDirectory); + nativeConfigurator.SetModDirectory(modDirectory); + + if (_modUserConfigTuple != null) + { + var configDirectory = Path.GetFullPath(Path.GetDirectoryName(_modUserConfigTuple.Path)!); + nativeConfigurator.Migrate(modDirectory, configDirectory); + nativeConfigurator.SetConfigDirectory(configDirectory); + } + + nativeConfigurator.SetContext(new ConfiguratorContext() + { + Application = _applicationTuple.Config, + ModConfigPath = _modTuple.Path, + ApplicationConfigPath = _applicationTuple.Path + }); + + configurator = nativeConfigurator; + return true; + } + + string dllPath = config.GetManagedDllPath(_modTuple.Path); + if (!File.Exists(dllPath)) return false; @@ -89,7 +119,6 @@ private bool TryGetConfigurator(out IConfiguratorV1? configurator, out PluginLoa return false; configurator = (IConfiguratorV1)Activator.CreateInstance(entryPoint)!; - var modDirectory = Path.GetFullPath(Path.GetDirectoryName(_modTuple.Path)!); configurator.SetModDirectory(modDirectory); if (configurator is IConfiguratorV2 versionTwo && _modUserConfigTuple != null) diff --git a/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/NativeConfigTypeEmitter.cs b/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/NativeConfigTypeEmitter.cs new file mode 100644 index 00000000..6f9cc4f0 --- /dev/null +++ b/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/NativeConfigTypeEmitter.cs @@ -0,0 +1,292 @@ +using System.Reflection.Emit; +using Reloaded.Mod.Interfaces.Structs; +using DataAnnotations = System.ComponentModel.DataAnnotations; + +namespace Reloaded.Mod.Launcher.Lib.Models.Model.Configuration; + +/// +/// Builds .NET types out of native mod configuration schemas using Reflection.Emit. +/// The generated types subclass and carry the same +/// attributes (DisplayName, Description, Category, DefaultValue, +/// Display, SliderControlParams, ...) as a hand written C# configuration class, +/// so the launcher's PropertyGrid renders them exactly like the configuration of a C# mod. +/// +public static class NativeConfigTypeEmitter +{ + private static readonly object BuildLock = new object(); + private static ModuleBuilder? _module; + private static int _typeCounter; + + private static Dictionary TypeCache { get; } = new Dictionary(); + + /// + /// Creates an instance of the configuration type for a schema configuration. + /// Types are cached; the cache key should change whenever the schema changes. + /// + /// The configuration to build a type for. + /// Unique key identifying the (version of the) configuration. + public static NativeConfigurableBase CreateInstance(NativeConfigSchemaConfiguration configuration, string cacheKey) + { + Type type; + lock (BuildLock) + { + if (!TypeCache.TryGetValue(cacheKey, out type)) + { + type = BuildType(configuration, cacheKey); + TypeCache[cacheKey] = type; + } + } + + return (NativeConfigurableBase)Activator.CreateInstance(type)!; + } + + private static Type BuildType(NativeConfigSchemaConfiguration configuration, string cacheKey) + { + var module = GetModule(); + var typeBuilder = module.DefineType($"NativeModConfig_{Interlocked.Increment(ref _typeCounter)}_{MakeIdentifier(cacheKey)}", TypeAttributes.Public | TypeAttributes.Class | TypeAttributes.Sealed, typeof(NativeConfigurableBase)); + + // Build the enums first, so they can be used as property types. + // They are named after the config type, so two mods declaring the same enum name won't clash. + var enums = new Dictionary(StringComparer.OrdinalIgnoreCase); + var displayCtor = GetCtor(typeof(DataAnnotations.DisplayAttribute), 0); + var displayNameProperty = typeof(DataAnnotations.DisplayAttribute).GetProperty(nameof(DataAnnotations.DisplayAttribute.Name))!; + foreach (var schemaEnum in configuration.Enums) + { + var enumBuilder = module.DefineEnum($"{typeBuilder.FullName}.{MakeIdentifier(schemaEnum.Name)}", TypeAttributes.Public, typeof(int)); + for (int x = 0; x < schemaEnum.Members.Count; x++) + { + var member = schemaEnum.Members[x]; + var literal = enumBuilder.DefineLiteral(MakeIdentifier(member.Name), x); + if (member.DisplayName != null) + literal.SetCustomAttribute(new CustomAttributeBuilder(displayCtor, Array.Empty(), new[] { displayNameProperty }, new object[] { member.DisplayName })); + } + + enums[schemaEnum.Name] = enumBuilder.CreateType(); + } + + // Build the properties. + var ctor = typeBuilder.DefineConstructor(MethodAttributes.Public, CallingConventions.Standard, Type.EmptyTypes); + var ctorIl = ctor.GetILGenerator(); + var baseCtor = typeof(NativeConfigurableBase).GetConstructor(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic, binder: null, Type.EmptyTypes, modifiers: null)!; + ctorIl.Emit(OpCodes.Ldarg_0); + ctorIl.Emit(OpCodes.Call, baseCtor); + + foreach (var property in configuration.Properties) + { + var (propertyType, defaultValue) = ResolveTypeAndDefault(property, enums); + var field = typeBuilder.DefineField($"_{MakeIdentifier(property.Name)}", propertyType, FieldAttributes.Private); + EmitFieldInit(ctorIl, field, propertyType, defaultValue); + + var propertyBuilder = typeBuilder.DefineProperty(MakeIdentifier(property.Name), PropertyAttributes.None, propertyType, null); + var getter = typeBuilder.DefineMethod($"get_{MakeIdentifier(property.Name)}", MethodAttributes.Public | MethodAttributes.SpecialName | MethodAttributes.HideBySig, propertyType, Type.EmptyTypes); + var getterIl = getter.GetILGenerator(); + getterIl.Emit(OpCodes.Ldarg_0); + getterIl.Emit(OpCodes.Ldfld, field); + getterIl.Emit(OpCodes.Ret); + propertyBuilder.SetGetMethod(getter); + + var setter = typeBuilder.DefineMethod($"set_{MakeIdentifier(property.Name)}", MethodAttributes.Public | MethodAttributes.SpecialName | MethodAttributes.HideBySig, null, new[] { propertyType }); + var setterIl = setter.GetILGenerator(); + setterIl.Emit(OpCodes.Ldarg_0); + setterIl.Emit(OpCodes.Ldarg_1); + setterIl.Emit(OpCodes.Stfld, field); + setterIl.Emit(OpCodes.Ret); + propertyBuilder.SetSetMethod(setter); + + foreach (var attribute in BuildAttributes(property, propertyType, defaultValue)) + propertyBuilder.SetCustomAttribute(attribute); + } + + ctorIl.Emit(OpCodes.Ret); + return typeBuilder.CreateType()!; + } + + private static (Type propertyType, object? defaultValue) ResolveTypeAndDefault(NativeConfigSchemaProperty property, Dictionary enums) + { + switch (property.Type) + { + case NativeConfigSchemaProperty.SupportedTypes.Bool: + return (typeof(bool), property.DefaultValue is bool b ? b : false); + + case NativeConfigSchemaProperty.SupportedTypes.Int: + return (typeof(int), property.DefaultValue == null ? 0 : Convert.ToInt32(property.DefaultValue)); + + case NativeConfigSchemaProperty.SupportedTypes.Float: + return (typeof(float), property.DefaultValue == null ? 0.0f : Convert.ToSingle(property.DefaultValue)); + + case NativeConfigSchemaProperty.SupportedTypes.Double: + return (typeof(double), property.DefaultValue == null ? 0.0 : Convert.ToDouble(property.DefaultValue)); + + case NativeConfigSchemaProperty.SupportedTypes.String: + return (typeof(string), property.DefaultValue?.ToString()); + + default: + if (!enums.TryGetValue(property.Type, out var enumType)) + throw new InvalidOperationException($"Property '{property.Name}' has unknown Type '{property.Type}'. Declare an enum with this name under '{Keys.Enums}'."); + + return (enumType, GetEnumDefault(property, enumType)); + } + } + + private static object GetEnumDefault(NativeConfigSchemaProperty property, Type enumType) + { + if (property.DefaultValue is string memberName) + { + var names = Enum.GetNames(enumType); + for (int x = 0; x < names.Length; x++) + { + if (string.Equals(names[x], memberName, StringComparison.OrdinalIgnoreCase)) + return Enum.ToObject(enumType, x); + } + + throw new InvalidOperationException($"Property '{property.Name}' has DefaultValue '{memberName}' which is not a member of enum '{property.Type}'."); + } + + return Enum.ToObject(enumType, 0); + } + + private static void EmitFieldInit(ILGenerator il, FieldBuilder field, Type propertyType, object? defaultValue) + { + il.Emit(OpCodes.Ldarg_0); + if (propertyType == typeof(string)) + { + if (defaultValue == null) + il.Emit(OpCodes.Ldnull); + else + il.Emit(OpCodes.Ldstr, (string)defaultValue); + } + else if (propertyType.IsEnum) + { + il.Emit(OpCodes.Ldc_I4, (int)defaultValue!); + } + else if (propertyType == typeof(bool)) + { + il.Emit((bool)defaultValue! ? OpCodes.Ldc_I4_1 : OpCodes.Ldc_I4_0); + } + else if (propertyType == typeof(int)) + { + il.Emit(OpCodes.Ldc_I4, (int)defaultValue!); + } + else if (propertyType == typeof(float)) + { + il.Emit(OpCodes.Ldc_R4, (float)defaultValue!); + } + else if (propertyType == typeof(double)) + { + il.Emit(OpCodes.Ldc_R8, (double)defaultValue!); + } + else + { + il.Emit(OpCodes.Ldnull); + } + + il.Emit(OpCodes.Stfld, field); + } + + private static IEnumerable BuildAttributes(NativeConfigSchemaProperty property, Type propertyType, object? defaultValue) + { + if (property.DisplayName != null) + yield return new CustomAttributeBuilder(GetCtor(typeof(DisplayNameAttribute), 1), new object[] { property.DisplayName }); + + if (property.Description != null) + yield return new CustomAttributeBuilder(GetCtor(typeof(DescriptionAttribute), 1), new object[] { property.Description }); + + if (property.Category != null) + yield return new CustomAttributeBuilder(GetCtor(typeof(CategoryAttribute), 1), new object[] { property.Category }); + + if (property.Order != null) + { + var orderProperty = typeof(DataAnnotations.DisplayAttribute).GetProperty(nameof(DataAnnotations.DisplayAttribute.Order))!; + yield return new CustomAttributeBuilder(GetCtor(typeof(DataAnnotations.DisplayAttribute), 0), Array.Empty(), new[] { orderProperty }, new object[] { property.Order.Value }); + } + + // The default value backs the Reset button of the configuration dialog. + var boxedDefault = defaultValue == null && propertyType == typeof(string) ? "" : defaultValue; + var defaultValueCtor = typeof(DefaultValueAttribute).GetConstructor(new[] { typeof(object) })!; + yield return new CustomAttributeBuilder(defaultValueCtor, new[] { boxedDefault! }); + + if (property.Slider != null) + { + var slider = property.Slider; + if (!propertyType.IsEnum && propertyType != typeof(int) && propertyType != typeof(float) && propertyType != typeof(double)) + throw new InvalidOperationException($"Property '{property.Name}': sliders are only supported for int, float and double properties."); + + var tickPlacement = Enum.TryParse(slider.TickPlacement, true, out var placement) ? placement : SliderControlTickPlacement.None; + yield return new CustomAttributeBuilder(GetCtor(typeof(SliderControlParamsAttribute), 12), new object[] + { + slider.Minimum, slider.Maximum, slider.SmallChange, slider.LargeChange, + slider.TickFrequency, slider.IsSnapToTickEnabled, tickPlacement, + slider.ShowTextField, slider.IsTextFieldEditable, slider.TextValidationRegex, + slider.TextFieldFormat, slider.TickFrequencyDouble + }); + } + + if (property.FilePicker != null) + { + var file = property.FilePicker; + if (propertyType != typeof(string)) + throw new InvalidOperationException($"Property '{property.Name}': file pickers are only supported for string properties."); + + yield return new CustomAttributeBuilder(GetCtor(typeof(FilePickerParamsAttribute), 13), new object[] + { + file.InitialDirectory, (System.Environment.SpecialFolder)file.InitialFolderPath, + file.ChooseFileButtonLabel, file.UserCanEditPathText, file.Title, file.Filter, + file.FilterIndex, file.Multiselect, file.SupportMultiDottedExtensions, + file.ShowHiddenFiles, file.ShowPreview, file.RestoreDirectory, file.AddToRecent + }); + } + + if (property.FolderPicker != null) + { + var folder = property.FolderPicker; + if (propertyType != typeof(string)) + throw new InvalidOperationException($"Property '{property.Name}': folder pickers are only supported for string properties."); + + yield return new CustomAttributeBuilder(GetCtor(typeof(FolderPickerParamsAttribute), 9), new object[] + { + folder.InitialDirectory, (System.Environment.SpecialFolder)folder.InitialFolderPath, + folder.ChooseFolderButtonLabel, folder.UserCanEditPathText, folder.Title, + folder.OkButtonLabel, folder.FileNameLabel, folder.Multiselect, folder.ForceFileSystem + }); + } + } + + private static ModuleBuilder GetModule() + { + if (_module != null) + return _module; + + var assembly = AssemblyBuilder.DefineDynamicAssembly(new AssemblyName("Reloaded.NativeModConfig.Dynamic"), AssemblyBuilderAccess.Run); + _module = assembly.DefineDynamicModule("Main"); + return _module; + } + + private static ConstructorInfo GetCtor(Type type, int parameterCount) + { + var constructor = type.GetConstructors().FirstOrDefault(c => c.GetParameters().Length == parameterCount); + if (constructor == null) + throw new InvalidOperationException($"No constructor with {parameterCount} parameters found on '{type.Name}'."); + + return constructor; + } + + /// + /// Makes a schema supplied name safe for use as a .NET identifier. + /// + private static string MakeIdentifier(string name) + { + var builder = new StringBuilder(name.Length); + foreach (var character in name) + { + if (char.IsLetterOrDigit(character) || character == '_') + builder.Append(character); + else + builder.Append('_'); + } + + if (builder.Length <= 0 || char.IsDigit(builder[0])) + builder.Insert(0, '_'); + + return builder.ToString(); + } +} diff --git a/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/NativeConfigurableBase.cs b/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/NativeConfigurableBase.cs new file mode 100644 index 00000000..8c837c5d --- /dev/null +++ b/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/NativeConfigurableBase.cs @@ -0,0 +1,251 @@ +using System.Collections.Concurrent; +using System.Text.Json.Nodes; + +namespace Reloaded.Mod.Launcher.Lib.Models.Model.Configuration; + +/// +/// Base class for the configuration objects generated for native (C/C++) mods. +/// The emits one derived class per schema configuration; +/// the derived class holds the settings as properties, this class supplies the behaviour +/// (name, saving, file watching) expected by the launcher's configuration dialog. +/// Mirrors Configurable<T> of the C# mod template. +/// +public abstract class NativeConfigurableBase : IUpdatableConfigurable +{ + /// + /// Full path to the file storing the values of this configuration. + /// + [Browsable(false)] + public string? FilePath { get; private set; } + + /// + /// The name of the configuration, shown in the launcher dialog. + /// + [Browsable(false)] + public string ConfigName { get; private set; } = ""; + + /// + /// Saves the current configuration to the hard disk. + /// + [Browsable(false)] + public Action? Save { get; private set; } + + /// + /// Automatically executed when the external configuration file is updated. + /// + [Browsable(false)] + public event Action? ConfigurationUpdated; + + /// + /// Receives events on whenever the file is actively changed or updated. + /// + private FileSystemWatcher? ConfigWatcher { get; set; } + + /// + /// Safety lock for when changed event gets raised twice on file save. + /// + private static object _readLock = new object(); + + /// + /// Initializes an instance after construction, arming the file watcher and save action. + /// + /// Full path to the file storing the values. + /// Name displayed in the launcher dialog. + internal void Initialize(string filePath, string configName) + { + FilePath = filePath; + ConfigName = configName; + + MakeConfigWatcher(); + Save = OnSave; + } + + /// + /// Halts the filesystem watcher and all events associated with this instance. + /// + public void DisposeEvents() + { + ConfigWatcher?.Dispose(); + ConfigWatcher = null; + ConfigurationUpdated = null; + } + + private void MakeConfigWatcher() + { + ConfigWatcher = new FileSystemWatcher(Path.GetDirectoryName(FilePath!)!, Path.GetFileName(FilePath!)!); + ConfigWatcher.Changed += (sender, e) => OnConfigurationUpdated(); + ConfigWatcher.EnableRaisingEvents = true; + } + + private void OnConfigurationUpdated() + { + lock (_readLock) + { + // Note: External program might still be writing to file while this is being executed, so we need to keep retrying. + var newConfig = NativeConfigIO.Load(GetType(), FilePath!, ConfigName, 250, 2); + + // Load and copy events, then disable events for this instance. + newConfig.ConfigurationUpdated = ConfigurationUpdated; + DisposeEvents(); + + // Call subscribers through the new config. + newConfig.ConfigurationUpdated?.Invoke(newConfig); + } + } + + private void OnSave() => NativeConfigIO.Save(this, FilePath!); +} + +/// +/// Reads and writes the value files of native mod configurations. +/// The file format is a flat JSON object of property name to value, with enums stored as strings; +/// identical in shape to what the C# mod template writes, so C++ mods can parse it with ease. +/// +public static class NativeConfigIO +{ + private static readonly JsonSerializerOptions SerializerOptions = new() { WriteIndented = true }; + + private static readonly ConcurrentDictionary PropertyCache = new(); + + /// + /// Stores the values of a configuration instance to disk. + /// + /// Instance whose property values are written. + /// Full path of the file to write to. + public static void Save(object instance, string filePath) + { + var root = new JsonObject(); + foreach (var property in GetProperties(instance.GetType())) + { + var value = property.GetValue(instance); + if (property.PropertyType.IsEnum) + { + root[property.Name] = value?.ToString(); + continue; + } + + switch (Type.GetTypeCode(property.PropertyType)) + { + case TypeCode.Boolean: + root[property.Name] = (bool)value!; + break; + case TypeCode.Int32: + root[property.Name] = (int)value!; + break; + case TypeCode.Single: + root[property.Name] = (float)value!; + break; + case TypeCode.Double: + root[property.Name] = (double)value!; + break; + case TypeCode.String: + root[property.Name] = (string?)value; + break; + } + } + + var directory = Path.GetDirectoryName(filePath); + if (directory.Length > 0) + Directory.CreateDirectory(directory); + + File.WriteAllText(filePath, root.ToJsonString(SerializerOptions)); + } + + /// + /// Applies the values from a file onto an instance; properties missing from the file keep their current (default) values. + /// Returns false if the file could not be read. + /// + /// Instance to load the values into. + /// Full path of the file to read from. + public static bool Apply(object instance, string filePath) + { + if (!File.Exists(filePath)) + return false; + + try + { + var root = JsonNode.Parse(File.ReadAllText(filePath)) as JsonObject; + if (root == null) + return false; + + ApplyFromObject(instance, root); + return true; + } + catch (Exception e) when (e is IOException or JsonException) + { + return false; + } + } + + /// + /// Creates a new instance of the given configuration type with values loaded from disk. + /// Missing or unreadable files yield an instance with the schema default values. + /// + public static NativeConfigurableBase Load(Type type, string filePath, string configName, int timeout = 0, int retries = 1) + { + var instance = (NativeConfigurableBase)Activator.CreateInstance(type)!; + for (int x = 0; x < retries; x++) + { + if (Apply(instance, filePath)) + break; + + if (x + 1 < retries) + Thread.Sleep(timeout); + } + + instance.Initialize(filePath, configName); + return instance; + } + + private static void ApplyFromObject(object instance, JsonObject root) + { + foreach (var property in GetProperties(instance.GetType())) + { + if (!root.TryGetPropertyValue(property.Name, out var node) || node == null) + continue; + + try + { + if (property.PropertyType.IsEnum) + { + if (node.GetValueKind() == JsonValueKind.String && Enum.TryParse(property.PropertyType, node.GetValue(), true, out var enumValue)) + property.SetValue(instance, enumValue); + } + else + { + switch (Type.GetTypeCode(property.PropertyType)) + { + case TypeCode.Boolean: + property.SetValue(instance, node.GetValue()); + break; + case TypeCode.Int32: + property.SetValue(instance, (int)node.GetValue()); + break; + case TypeCode.Single: + property.SetValue(instance, (float)node.GetValue()); + break; + case TypeCode.Double: + property.SetValue(instance, node.GetValue()); + break; + case TypeCode.String: + property.SetValue(instance, node.GetValue()); + break; + } + } + } + catch (FormatException) + { + // Value doesn't fit the property; keep the current value. + } + catch (InvalidOperationException) + { + // Value has an unexpected JSON type; keep the current value. + } + } + } + + /// + /// Returns the editable settings declared by a generated configuration type. + /// + public static PropertyInfo[] GetProperties(Type type) => PropertyCache.GetOrAdd(type, static t => [.. t.GetProperties(BindingFlags.Public | BindingFlags.Instance).Where(p => p.DeclaringType == t && p.CanRead && p.CanWrite && p.GetIndexParameters().Length == 0)]); +} diff --git a/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/NativeModConfigSchema.cs b/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/NativeModConfigSchema.cs new file mode 100644 index 00000000..fb7e7891 --- /dev/null +++ b/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/NativeModConfigSchema.cs @@ -0,0 +1,513 @@ +using System.Text.Json.Nodes; + +namespace Reloaded.Mod.Launcher.Lib.Models.Model.Configuration; + +/// +/// Declarative configuration schema for native (C/C++) mods. +/// A mod declares its settings by placing a ConfigSchema.json file next to its ModConfig.json. +/// The launcher then builds a configuration UI from that schema. +/// The config file mirrors the attributes used by the C# mod template (DisplayName, Description, Category, +/// DefaultValue, Slider/File/Folder control params) so both kinds of mod look and behave the same. +/// +public class NativeModConfigSchema +{ + /// + /// Name of the config file, need to be placed inside the mod folder. + /// + public const string SchemaFileName = "ConfigSchema.json"; + + /// + /// The individual configurations (pages/files) exposed by the mod. + /// + public List Configurations { get; set; } = new(); + + /// + /// Returns true if the specified mod directory contains a config schema. + /// + /// Full path to the folder containing the mod. + public static bool ExistsInFolder(string modDirectory) => File.Exists(Path.Combine(modDirectory, SchemaFileName)); + + /// + /// Loads config schema from local disk. + /// + /// Full path to the folder containing the mod. + public static NativeModConfigSchema Load(string modDirectory) => Parse(JsonNode.Parse(File.ReadAllText(Path.Combine(modDirectory, SchemaFileName)), new JsonNodeOptions() { PropertyNameCaseInsensitive = true }) ?? throw newException(modDirectory), modDirectory); + + private static Exception newException(string modDirectory) => new InvalidOperationException($"Failed to parse {SchemaFileName} in '{modDirectory}'. The file may be empty or invalid."); + + private static NativeModConfigSchema Parse(JsonNode node, string modDirectory) + { + try + { + var schema = new NativeModConfigSchema(); + if (node[Keys.Configurations] is JsonArray configurations) + { + foreach (var configurationNode in configurations) + schema.Configurations.Add(NativeConfigSchemaConfiguration.Parse(configurationNode!)); + } + + if (schema.Configurations.Count <= 0) + throw new JsonException($"Schema requires at least one entry in '{Keys.Configurations}'."); + + return schema; + } + catch (Exception e) when (e is JsonException or InvalidOperationException or FormatException) + { + throw new InvalidOperationException($"Failed to parse {SchemaFileName} in '{modDirectory}'. Check the inner exception for details.", e); + } + } +} + +/// +/// Individual configuration of a native mod, essentially mirrors one IConfigurable from C# mod. +/// +public class NativeConfigSchemaConfiguration +{ + /// + /// Name of the config file where the values for this configuration are stored. + /// Defaults to Config.json, matching the C# template. + /// + public string FileName { get; set; } = "Config.json"; + + /// + /// Name shown in the launcher's configuration dropdown. + /// + public string? DisplayName { get; set; } + + /// + /// Enumerations available to the properties of this configuration. + /// + public List Enums { get; set; } = new(); + + /// + /// The individual settings. + /// + public List Properties { get; set; } = new(); + + public static NativeConfigSchemaConfiguration Parse(JsonNode node) + { + var configuration = new NativeConfigSchemaConfiguration + { + FileName = node.GetStringOrDefault(Keys.FileName, "Config.json")!, + DisplayName = node.GetStringOrDefault(Keys.DisplayName, null) + }; + + if (node[Keys.Enums] is JsonArray enums) + { + foreach (var enumNode in enums) + configuration.Enums.Add(NativeConfigSchemaEnum.Parse(enumNode!)); + } + + if (node[Keys.Properties] is JsonArray properties) + { + foreach (var propertyNode in properties) + configuration.Properties.Add(NativeConfigSchemaProperty.Parse(propertyNode!)); + } + + return configuration; + } +} + +/// +/// Enumeration with display names, rendered as a list in Reloaded. +/// +public class NativeConfigSchemaEnum +{ + /// + /// Name of the enum type, referenced by property Type. + /// + public string Name { get; set; } = ""; + + /// + /// The individual values of the enum. + /// + public List Members { get; set; } = new(); + + public static NativeConfigSchemaEnum Parse(JsonNode node) + { + var result = new NativeConfigSchemaEnum + { + Name = node.GetStringOrDefault(Keys.Name, "")! + }; + + if (node[Keys.Members] is JsonArray members) + { + foreach (var memberNode in members) + { + var member = NativeConfigSchemaEnumMember.Parse(memberNode!); + if (member.Name.Length > 0) + result.Members.Add(member); + } + } + + if (result.Members.Count <= 0) + throw new JsonException($"Enum '{result.Name}' requires at least one entry in '{Keys.Members}'."); + + return result; + } +} + +/// +/// An individual value of a schema enum. +/// +public class NativeConfigSchemaEnumMember +{ + /// + /// Name of the value, stored in the config file. + /// + public string Name { get; set; } = ""; + + /// + /// Name shown in the launcher UI. Falls back to . + /// + public string? DisplayName { get; set; } + + public static NativeConfigSchemaEnumMember Parse(JsonNode node) => new() + { + Name = node.GetStringOrDefault(Keys.Name, "")!, + DisplayName = node.GetStringOrDefault(Keys.DisplayName, null) + }; +} + +/// +/// An individual setting of a configuration; mirrors a property of a C# mod's config class. +/// +public class NativeConfigSchemaProperty +{ + /// + /// Supported values for . + /// + public static class SupportedTypes + { + public const string Bool = "bool"; + public const string Int = "int"; + public const string Float = "float"; + public const string Double = "double"; + public const string String = "string"; + } + + /// + /// Name of the setting, stored in the config file. + /// + public string Name { get; set; } = ""; + + /// + /// Type of the setting; one of bool, int, float, double, string + /// or the name of an enum declared in the same configuration. + /// + public string Type { get; set; } = SupportedTypes.String; + + /// + /// Friendly name shown in the launcher. Falls back to . + /// + public string? DisplayName { get; set; } + + /// + /// Tooltip description shown in the launcher. + /// + public string? Description { get; set; } + + /// + /// Category (group) the setting is displayed under. + /// + public string? Category { get; set; } + + /// + /// Sort order of the setting, lowest first. + /// + public int? Order { get; set; } + + /// + /// Default value of the setting (bool/int/float/double, string or enum member name). + /// Used when the user has not changed the setting, and by the Reset button. + /// + public object? DefaultValue { get; set; } + + /// + /// Renders this setting as a slider. Only valid for numeric types. + /// + public NativeConfigSchemaSlider? Slider { get; set; } + + /// + /// Renders this setting (string) with a file picker dialog. + /// + public NativeConfigSchemaFilePicker? FilePicker { get; set; } + + /// + /// Renders this setting (string) with a folder picker dialog. + /// + public NativeConfigSchemaFolderPicker? FolderPicker { get; set; } + + public static NativeConfigSchemaProperty Parse(JsonNode node) + { + var property = new NativeConfigSchemaProperty + { + Name = node.GetStringOrDefault(Keys.Name, "")!, + Type = node.GetStringOrDefault(Keys.Type, SupportedTypes.String)!.ToLowerInvariant(), + DisplayName = node.GetStringOrDefault(Keys.DisplayName, null), + Description = node.GetStringOrDefault(Keys.Description, null), + Category = node.GetStringOrDefault(Keys.Category, null), + Order = node.GetIntOrNull(Keys.Order), + DefaultValue = node[Keys.DefaultValue].GetValueOrNull() + }; + + if (node[Keys.Slider] is JsonNode slider) + property.Slider = NativeConfigSchemaSlider.Parse(slider); + + if (node[Keys.FilePicker] is JsonNode filePicker) + property.FilePicker = NativeConfigSchemaFilePicker.Parse(filePicker); + + if (node[Keys.FolderPicker] is JsonNode folderPicker) + property.FolderPicker = NativeConfigSchemaFolderPicker.Parse(folderPicker); + + if (property.Name.Length <= 0) + throw new JsonException($"A property in the schema has no '{Keys.Name}'."); + + return property; + } +} + +/// +/// Parameters for the slider control; mirrors SliderControlParamsAttribute of the C# interface. +/// +public class NativeConfigSchemaSlider +{ + public double Minimum { get; set; } = 0.0; + public double Maximum { get; set; } = 1.0; + public double SmallChange { get; set; } = 0.1; + public double LargeChange { get; set; } = 1.0; + public int TickFrequency { get; set; } = 10; + public bool IsSnapToTickEnabled { get; set; } = false; + public string TickPlacement { get; set; } = "None"; + public bool ShowTextField { get; set; } = false; + public bool IsTextFieldEditable { get; set; } = true; + public string TextValidationRegex { get; set; } = ".*"; + public string TextFieldFormat { get; set; } = ""; + public double TickFrequencyDouble { get; set; } = 0.0; + + public static NativeConfigSchemaSlider Parse(JsonNode node) => new() + { + Minimum = node.GetDoubleOrDefault(Keys.Minimum, 0.0), + Maximum = node.GetDoubleOrDefault(Keys.Maximum, 1.0), + SmallChange = node.GetDoubleOrDefault(Keys.SmallChange, 0.1), + LargeChange = node.GetDoubleOrDefault(Keys.LargeChange, 1.0), + TickFrequency = node.GetIntOrDefault(Keys.TickFrequency, 10), + IsSnapToTickEnabled = node.GetBoolOrDefault(Keys.IsSnapToTickEnabled, false), + TickPlacement = node.GetStringOrDefault(Keys.TickPlacement, "None")!, + ShowTextField = node.GetBoolOrDefault(Keys.ShowTextField, false), + IsTextFieldEditable = node.GetBoolOrDefault(Keys.IsTextFieldEditable, true), + TextValidationRegex = node.GetStringOrDefault(Keys.TextValidationRegex, ".*")!, + TextFieldFormat = node.GetStringOrDefault(Keys.TextFieldFormat, "")!, + TickFrequencyDouble = node.GetDoubleOrDefault(Keys.TickFrequencyDouble, 0.0) + }; +} + +/// +/// Parameters for the file picker control; mirrors FilePickerParamsAttribute of the C# interface. +/// +public class NativeConfigSchemaFilePicker +{ + public string? InitialDirectory { get; set; } + public int InitialFolderPath { get; set; } = 0x05; // Environment.SpecialFolder.Personal + public string ChooseFileButtonLabel { get; set; } = "Choose File"; + public bool UserCanEditPathText { get; set; } = true; + public string Title { get; set; } = ""; + public string Filter { get; set; } = "All files (*.*)|*.*"; + public int FilterIndex { get; set; } = 0; + public bool Multiselect { get; set; } = false; + public bool SupportMultiDottedExtensions { get; set; } = false; + public bool ShowHiddenFiles { get; set; } = false; + public bool ShowPreview { get; set; } = false; + public bool RestoreDirectory { get; set; } = false; + public bool AddToRecent { get; set; } = false; + + public static NativeConfigSchemaFilePicker Parse(JsonNode node) => new() + { + InitialDirectory = node.GetStringOrDefault(Keys.InitialDirectory, null), + InitialFolderPath = node.GetIntOrDefault(Keys.InitialFolderPath, 0x05), + ChooseFileButtonLabel = node.GetStringOrDefault(Keys.ChooseFileButtonLabel, "Choose File")!, + UserCanEditPathText = node.GetBoolOrDefault(Keys.UserCanEditPathText, true), + Title = node.GetStringOrDefault(Keys.Title, "")!, + Filter = node.GetStringOrDefault(Keys.Filter, "All files (*.*)|*.*")!, + FilterIndex = node.GetIntOrDefault(Keys.FilterIndex, 0), + Multiselect = node.GetBoolOrDefault(Keys.Multiselect, false), + SupportMultiDottedExtensions = node.GetBoolOrDefault(Keys.SupportMultiDottedExtensions, false), + ShowHiddenFiles = node.GetBoolOrDefault(Keys.ShowHiddenFiles, false), + ShowPreview = node.GetBoolOrDefault(Keys.ShowPreview, false), + RestoreDirectory = node.GetBoolOrDefault(Keys.RestoreDirectory, false), + AddToRecent = node.GetBoolOrDefault(Keys.AddToRecent, false) + }; +} + +/// +/// Parameters for the folder picker control; mirrors FolderPickerParamsAttribute of the C# interface. +/// +public class NativeConfigSchemaFolderPicker +{ + public string? InitialDirectory { get; set; } + public int InitialFolderPath { get; set; } = 0x05; // Environment.SpecialFolder.Personal + public string ChooseFolderButtonLabel { get; set; } = "Choose Folder"; + public bool UserCanEditPathText { get; set; } = true; + public string Title { get; set; } = ""; + public string OkButtonLabel { get; set; } = "Ok"; + public string FileNameLabel { get; set; } = ""; + public bool Multiselect { get; set; } = false; + public bool ForceFileSystem { get; set; } = false; + + public static NativeConfigSchemaFolderPicker Parse(JsonNode node) => new() + { + InitialDirectory = node.GetStringOrDefault(Keys.InitialDirectory, null), + InitialFolderPath = node.GetIntOrDefault(Keys.InitialFolderPath, 0x05), + ChooseFolderButtonLabel = node.GetStringOrDefault(Keys.ChooseFolderButtonLabel, "Choose Folder")!, + UserCanEditPathText = node.GetBoolOrDefault(Keys.UserCanEditPathText, true), + Title = node.GetStringOrDefault(Keys.Title, "")!, + OkButtonLabel = node.GetStringOrDefault(Keys.OkButtonLabel, "Ok")!, + FileNameLabel = node.GetStringOrDefault(Keys.FileNameLabel, "")!, + Multiselect = node.GetBoolOrDefault(Keys.Multiselect, false), + ForceFileSystem = node.GetBoolOrDefault(Keys.ForceFileSystem, false) + }; +} + +/// +/// JSON property names used by the schema file. +/// +internal static class Keys +{ + public const string Configurations = "Configurations"; + public const string FileName = "FileName"; + public const string DisplayName = "DisplayName"; + public const string Enums = "Enums"; + public const string Properties = "Properties"; + public const string Members = "Members"; + public const string Name = "Name"; + public const string Type = "Type"; + public const string Description = "Description"; + public const string Category = "Category"; + public const string Order = "Order"; + public const string DefaultValue = "DefaultValue"; + public const string Slider = "Slider"; + public const string FilePicker = "FilePicker"; + public const string FolderPicker = "FolderPicker"; + + // Control Params + public const string Minimum = "Minimum"; + public const string Maximum = "Maximum"; + public const string SmallChange = "SmallChange"; + public const string LargeChange = "LargeChange"; + public const string TickFrequency = "TickFrequency"; + public const string TickFrequencyDouble = "TickFrequencyDouble"; + public const string IsSnapToTickEnabled = "IsSnapToTickEnabled"; + public const string TickPlacement = "TickPlacement"; + public const string ShowTextField = "ShowTextField"; + public const string IsTextFieldEditable = "IsTextFieldEditable"; + public const string TextValidationRegex = "TextValidationRegex"; + public const string TextFieldFormat = "TextFieldFormat"; + public const string InitialDirectory = "InitialDirectory"; + public const string InitialFolderPath = "InitialFolderPath"; + public const string ChooseFileButtonLabel = "ChooseFileButtonLabel"; + public const string ChooseFolderButtonLabel = "ChooseFolderButtonLabel"; + public const string UserCanEditPathText = "UserCanEditPathText"; + public const string Title = "Title"; + public const string Filter = "Filter"; + public const string FilterIndex = "FilterIndex"; + public const string Multiselect = "Multiselect"; + public const string SupportMultiDottedExtensions = "SupportMultiDottedExtensions"; + public const string ShowHiddenFiles = "ShowHiddenFiles"; + public const string ShowPreview = "ShowPreview"; + public const string RestoreDirectory = "RestoreDirectory"; + public const string AddToRecent = "AddToRecent"; + public const string OkButtonLabel = "OkButtonLabel"; + public const string FileNameLabel = "FileNameLabel"; + public const string ForceFileSystem = "ForceFileSystem"; +} + +/// +/// Helper extensions for reading values out of s. +/// +internal static class JsonNodeExtensions +{ + public static string? GetStringOrDefault(this JsonNode? node, string name, string? fallback) + { + var value = node[name]; + if (value == null) + return fallback; + + return value.GetValueKind() == JsonValueKind.String ? value.GetValue() : fallback; + } + + public static int GetIntOrDefault(this JsonNode? node, string name, int fallback) + { + var value = node[name]; + if (value == null) + return fallback; + + if (value.GetValueKind() == JsonValueKind.Number) + { + var element = value.GetValue(); + if (element.TryGetInt32(out var result)) + return result; + } + + return fallback; + } + + public static int? GetIntOrNull(this JsonNode? node, string name) + { + var value = node[name]; + if (value == null) + return null; + + if (value.GetValueKind() == JsonValueKind.Number) + { + var element = value.GetValue(); + if (element.TryGetInt32(out var result)) + return result; + } + + return null; + } + + public static double GetDoubleOrDefault(this JsonNode? node, string name, double fallback) + { + var value = node[name]; + if (value == null) + return fallback; + + return value.GetValueKind() == JsonValueKind.Number ? value.GetValue().GetDouble() : fallback; + } + + public static bool GetBoolOrDefault(this JsonNode? node, string name, bool fallback) + { + var value = node[name]; + if (value == null) + return fallback; + + return value.GetValueKind() == JsonValueKind.True || value.GetValueKind() == JsonValueKind.False ? value.GetValue() : fallback; + } + + /// + /// Returns the raw boxed value of a node (bool/int/double/string) or null. + /// + public static object? GetValueOrNull(this JsonNode? node) + { + if (node == null) + return null; + + var kind = node.GetValueKind(); + if (kind == JsonValueKind.True || kind == JsonValueKind.False) + return node.GetValue(); + + if (kind == JsonValueKind.Number) + { + var element = node.GetValue(); + return element.TryGetInt32(out var i) ? i : element.GetDouble(); + } + + if (kind == JsonValueKind.String) + return node.GetValue(); + + return null; + } + + public static JsonValueKind GetValueKind(this JsonNode node) => node.GetValue().ValueKind; +} diff --git a/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/NativeModConfigurator.cs b/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/NativeModConfigurator.cs new file mode 100644 index 00000000..fa2d0061 --- /dev/null +++ b/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/NativeModConfigurator.cs @@ -0,0 +1,83 @@ +namespace Reloaded.Mod.Launcher.Lib.Models.Model.Configuration; + +/// +/// Configurator for native (C/C++) mods that declare their settings through a ConfigSchema.json file. +/// Use the same interface as a C# mod's configurator. +/// +public class NativeModConfigurator : IConfiguratorV3 +{ + private string _schemaPath; + private string _modDirectory = ""; + private string? _configDirectory; + private ConfiguratorContext _context; + + /// + /// Creates a configurator for a mod folder containing . + /// + /// Full path to the folder containing the mod. + public NativeModConfigurator(string modDirectory) + { + _modDirectory = modDirectory; + _schemaPath = Path.Combine(modDirectory, NativeModConfigSchema.SchemaFileName); + } + + /// + public void SetModDirectory(string modDirectory) + { + _modDirectory = modDirectory; + _schemaPath = Path.Combine(modDirectory, NativeModConfigSchema.SchemaFileName); + } + + /// + public IConfigurable[] GetConfigurations() + { + var schema = NativeModConfigSchema.Load(_modDirectory); + var configDirectory = _configDirectory ?? _modDirectory; + + // Include the file's last write time in the cache key, such that mod updates invalidate emitted types. + var lastWrite = File.GetLastWriteTimeUtc(_schemaPath).Ticks.ToString(); + var result = new List(schema.Configurations.Count); + foreach (var configuration in schema.Configurations) + { + var cacheKey = $"{_modDirectory}|{configuration.FileName}|{lastWrite}"; + var instance = NativeConfigTypeEmitter.CreateInstance(configuration, cacheKey); + + var valuesPath = Path.Combine(configDirectory, configuration.FileName); + NativeConfigIO.Apply(instance, valuesPath); + instance.Initialize(valuesPath, configuration.DisplayName ?? Path.GetFileNameWithoutExtension(configuration.FileName)); + result.Add(instance); + } + + return result.ToArray(); + } + + /// + public bool TryRunCustomConfiguration() => false; + + /// + public void Migrate(string oldDirectory, string newDirectory) + { + try + { + var schema = NativeModConfigSchema.Load(_modDirectory); + Directory.CreateDirectory(newDirectory); + foreach (var configuration in schema.Configurations) + { + var oldPath = Path.Combine(oldDirectory, configuration.FileName); + var newPath = Path.Combine(newDirectory, configuration.FileName); + if (File.Exists(oldPath) && !File.Exists(newPath)) + File.Move(oldPath, newPath); + } + } + catch (Exception) + { + + } + } + + /// + public void SetConfigDirectory(string configDirectory) => _configDirectory = configDirectory; + + /// + public void SetContext(in ConfiguratorContext context) => _context = context; +} diff --git a/source/Reloaded.Mod.Launcher.Lib/Usings.cs b/source/Reloaded.Mod.Launcher.Lib/Usings.cs index d5696feb..13d2d6f6 100644 --- a/source/Reloaded.Mod.Launcher.Lib/Usings.cs +++ b/source/Reloaded.Mod.Launcher.Lib/Usings.cs @@ -17,6 +17,7 @@ global using Reloaded.Mod.Launcher.Lib.Interop; global using Reloaded.Mod.Launcher.Lib.Misc; global using Reloaded.Mod.Launcher.Lib.Models.Model.Application; +global using Reloaded.Mod.Launcher.Lib.Models.Model.Configuration; global using Reloaded.Mod.Launcher.Lib.Models.Model.Dialog; global using Reloaded.Mod.Launcher.Lib.Models.Model.DownloadPackagePage; global using Reloaded.Mod.Launcher.Lib.Models.Model.Pages; diff --git a/source/Reloaded.Mod.Loader.Tests/Launcher/NativeModConfigTests.cs b/source/Reloaded.Mod.Loader.Tests/Launcher/NativeModConfigTests.cs new file mode 100644 index 00000000..df2d14f8 --- /dev/null +++ b/source/Reloaded.Mod.Loader.Tests/Launcher/NativeModConfigTests.cs @@ -0,0 +1,226 @@ +using System.ComponentModel; +using System.ComponentModel.DataAnnotations; +using System.Reflection; +using System.Text.Json.Nodes; +using Reloaded.Mod.Launcher.Lib.Models.Model.Configuration; +using Reloaded.Mod.Interfaces.Structs; +using Reloaded.Mod.Interfaces; + +namespace Reloaded.Mod.Loader.Tests.Launcher; + +/// +/// Tests the schema driven configuration of native (C/C++) mods. +/// +public class NativeModConfigTests : IDisposable +{ + private const string Schema = """ + { + "Configurations": [ + { + "FileName": "Config.json", + "DisplayName": "Default Config", + "Enums": [ + { + "Name": "SampleEnum", + "Members": [ { "Name": "NoOpinion" }, { "Name": "ILoveIt", "DisplayName": "I Love It!!!" } ] + } + ], + "Properties": [ + { "Name": "BooleanSetting", "Type": "bool", "DisplayName": "Bool", "Description": "This is a bool.", "Category": "Cat A", "Order": 1, "DefaultValue": true }, + { "Name": "IntegerSetting", "Type": "int", "DefaultValue": 42, "Order": 2 }, + { "Name": "FloatSetting", "Type": "float", "DefaultValue": 6.5 }, + { "Name": "StringSetting", "Type": "string", "DefaultValue": "hello world" }, + { "Name": "EnumSetting", "Type": "SampleEnum", "DefaultValue": "ILoveIt" }, + { + "Name": "SliderSetting", "Type": "int", "DefaultValue": 100, "Order": 0, + "Slider": { "Minimum": 0.0, "Maximum": 100.0, "SmallChange": 1.0, "LargeChange": 10.0, "TickFrequency": 10, "ShowTextField": true } + }, + { "Name": "FileSetting", "Type": "string", "DefaultValue": "", "FilePicker": { "Title": "Pick a file", "Filter": "Text (*.txt)|*.txt" } } + ] + } + ] + } + """; + + private string ModDirectory { get; } + private string ConfigDirectory { get; } + + public NativeModConfigTests() + { + ModDirectory = Path.Combine(Path.GetTempPath(), $"reloaded-native-mod-{Guid.NewGuid():N}"); + ConfigDirectory = Path.Combine(Path.GetTempPath(), $"reloaded-native-config-{Guid.NewGuid():N}"); + Directory.CreateDirectory(ModDirectory); + Directory.CreateDirectory(ConfigDirectory); + File.WriteAllText(Path.Combine(ModDirectory, NativeModConfigSchema.SchemaFileName), Schema); + } + + [Fact] + public void Schema_Is_Detected_And_Parsed() + { + Assert.True(NativeModConfigSchema.ExistsInFolder(ModDirectory)); + + var schema = NativeModConfigSchema.Load(ModDirectory); + var configuration = Assert.Single(schema.Configurations); + Assert.Equal("Config.json", configuration.FileName); + Assert.Equal("Default Config", configuration.DisplayName); + Assert.Equal(7, configuration.Properties.Count); + Assert.Single(configuration.Enums); + + var sliderProperty = configuration.Properties.First(p => p.Name == "SliderSetting"); + Assert.NotNull(sliderProperty.Slider); + Assert.Equal(0.0, sliderProperty.Slider!.Minimum); + Assert.Equal(100.0, sliderProperty.Slider!.Maximum); + + var fileProperty = configuration.Properties.First(p => p.Name == "FileSetting"); + Assert.Equal("Text (*.txt)|*.txt", fileProperty.FilePicker!.Filter); + } + + [Fact] + public void Configurator_Returns_Configurable_With_Default_Values() + { + var configurator = CreateConfigurator(); + var configurations = configurator.GetConfigurations(); + var configurable = Assert.Single(configurations); + + Assert.Equal("Default Config", configurable.ConfigName); + Assert.IsAssignableFrom(configurable); + Assert.NotNull(configurable.Save); + + Assert.True(GetProperty(configurable, "BooleanSetting")); + Assert.Equal(42, GetProperty(configurable, "IntegerSetting")); + Assert.Equal(6.5f, GetProperty(configurable, "FloatSetting")); + Assert.Equal("hello world", GetProperty(configurable, "StringSetting")); + Assert.Equal("ILoveIt", GetProperty(configurable, "EnumSetting")!.ToString()); + Assert.Equal(100, GetProperty(configurable, "SliderSetting")); + } + + [Fact] + public void Generated_Properties_Carry_UI_Attributes() + { + var configurable = Assert.Single(CreateConfigurator().GetConfigurations()); + var type = configurable.GetType(); + + var booleanProperty = type.GetProperty("BooleanSetting")!; + Assert.Equal("Bool", booleanProperty.GetCustomAttribute()!.DisplayName); + Assert.Equal("This is a bool.", booleanProperty.GetCustomAttribute()!.Description); + Assert.Equal("Cat A", booleanProperty.GetCustomAttribute()!.Category); + Assert.Equal(true, booleanProperty.GetCustomAttribute()!.Value); + + var display = type.GetProperty("SliderSetting")!.GetCustomAttribute(); + Assert.Equal(0, display!.Order); + + var slider = type.GetProperty("SliderSetting")!.GetCustomAttribute(); + Assert.NotNull(slider); + Assert.Equal(0.0, slider!.Minimum); + Assert.Equal(100.0, slider.Maximum); + Assert.Equal(10, slider.TickFrequency); + + var filePicker = type.GetProperty("FileSetting")!.GetCustomAttribute(); + Assert.NotNull(filePicker); + Assert.Equal("Text (*.txt)|*.txt", filePicker!.Filter); + + // Enum members support display names. + var enumType = type.GetProperty("EnumSetting")!.PropertyType; + Assert.True(enumType.IsEnum); + var members = enumType.GetFields(BindingFlags.Public | BindingFlags.Static); + Assert.Equal(2, members.Length); + Assert.Equal("I Love It!!!", members[1].GetCustomAttribute()?.GetName()); + } + + [Fact] + public void Save_Writes_Values_And_New_Instance_Reads_Them_Back() + { + var configurable = Assert.Single(CreateConfigurator().GetConfigurations()); + SetProperty(configurable, "BooleanSetting", false); + SetProperty(configurable, "IntegerSetting", 1337); + SetProperty(configurable, "FloatSetting", 0.25f); + SetProperty(configurable, "StringSetting", "changed"); + SetProperty(configurable, "EnumSetting", Enum.Parse(configurable.GetType().GetProperty("EnumSetting")!.PropertyType, "NoOpinion")); + configurable.Save!(); + + string valuesPath = Path.Combine(ConfigDirectory, "Config.json"); + Assert.True(File.Exists(valuesPath)); + + // The file is flat JSON with enums as strings, easy to parse from C/C++. + var json = JsonNode.Parse(File.ReadAllText(valuesPath))!; + Assert.False(json["BooleanSetting"]!.GetValue()); + Assert.Equal(1337, json["IntegerSetting"]!.GetValue()); + Assert.Equal(0.25, json["FloatSetting"]!.GetValue(), 5); + Assert.Equal("changed", json["StringSetting"]!.GetValue()); + Assert.Equal("NoOpinion", json["EnumSetting"]!.GetValue()); + + // A fresh instance starts from the saved values. + var reloaded = Assert.Single(CreateConfigurator().GetConfigurations()); + Assert.False(GetProperty(reloaded, "BooleanSetting")); + Assert.Equal(1337, GetProperty(reloaded, "IntegerSetting")); + Assert.Equal(0.25f, GetProperty(reloaded, "FloatSetting")); + Assert.Equal("changed", GetProperty(reloaded, "StringSetting")); + Assert.Equal("NoOpinion", GetProperty(reloaded, "EnumSetting")!.ToString()); + } + + [Fact] + public void Unknown_Values_In_File_Are_Ignored() + { + File.WriteAllText(Path.Combine(ConfigDirectory, "Config.json"), """{ "IntegerSetting": 5, "NotARealSetting": "abc" }"""); + var configurable = Assert.Single(CreateConfigurator().GetConfigurations()); + + Assert.Equal(5, GetProperty(configurable, "IntegerSetting")); + Assert.True(GetProperty(configurable, "BooleanSetting")); + Assert.Equal("hello world", GetProperty(configurable, "StringSetting")); + } + + [Fact] + public void Missing_Values_File_Leaves_Defaults() + { + var configurable = Assert.Single(CreateConfigurator().GetConfigurations()); + Assert.Equal(42, GetProperty(configurable, "IntegerSetting")); + Assert.False(File.Exists(Path.Combine(ConfigDirectory, "Config.json"))); + } + + [Fact] + public void Migrate_Moves_Values_File() + { + // Simulate values living in the mod folder (pre-migration). + File.WriteAllText(Path.Combine(ModDirectory, "Config.json"), """{ "IntegerSetting": 9 }"""); + + var configurator = CreateConfigurator(); + configurator.Migrate(ModDirectory, ConfigDirectory); + + Assert.False(File.Exists(Path.Combine(ModDirectory, "Config.json"))); + Assert.True(File.Exists(Path.Combine(ConfigDirectory, "Config.json"))); + + var configurable = Assert.Single(configurator.GetConfigurations()); + Assert.Equal(9, GetProperty(configurable, "IntegerSetting")); + } + + [Fact] + public void Unknown_Type_Throws_Descriptive_Error() + { + File.WriteAllText(Path.Combine(ModDirectory, NativeModConfigSchema.SchemaFileName), """ + { "Configurations": [ { "FileName": "Config.json", "Properties": [ { "Name": "Broken", "Type": "NoSuchEnum" } ] } ] } + """); + var configurator = CreateConfigurator(); + + var error = Assert.Throws(() => configurator.GetConfigurations()); + Assert.Contains("nosuchenum", error.Message, StringComparison.OrdinalIgnoreCase); + } + + private NativeModConfigurator CreateConfigurator() + { + var configurator = new NativeModConfigurator(ModDirectory); + configurator.SetModDirectory(ModDirectory); + configurator.SetConfigDirectory(ConfigDirectory); + return configurator; + } + + private static T GetProperty(IConfigurable configurable, string name) => (T)configurable.GetType().GetProperty(name)!.GetValue(configurable)!; + + private static void SetProperty(IConfigurable configurable, string name, object value) => configurable.GetType().GetProperty(name)!.SetValue(configurable, value); + + public void Dispose() + { + Directory.Delete(ModDirectory, true); + if (Directory.Exists(ConfigDirectory)) + Directory.Delete(ConfigDirectory, true); + } +} diff --git a/source/Reloaded.Mod.Loader/Mods/PluginManager.cs b/source/Reloaded.Mod.Loader/Mods/PluginManager.cs index 775d4443..af37c31f 100644 --- a/source/Reloaded.Mod.Loader/Mods/PluginManager.cs +++ b/source/Reloaded.Mod.Loader/Mods/PluginManager.cs @@ -301,12 +301,15 @@ private ModInstance PrepareNativeMod(PathTuple tuple) { var modId = tuple.Config.ModId; var dllPath = tuple.Config.GetNativeDllPath(tuple.Path); - + if (!DoesDllExist(dllPath, tuple)) return new ModInstance(tuple.Config); - + _modIdToFolder[modId] = Path.GetFullPath(Path.GetDirectoryName(tuple.Path)!); - return new ModInstance(new NativeMod(dllPath), tuple.Config); + + // Hand the mod its user config directory, needed for mods that read their configuration. + var userConfigDirectory = ModUserConfig.GetUserConfigFolderForMod(modId, _loader.LoaderConfig.GetModUserConfigDirectory()); + return new ModInstance(new NativeMod(dllPath, userConfigDirectory), tuple.Config); } private ModInstance PrepareNonDllMod(PathTuple tuple) diff --git a/source/Reloaded.Mod.Loader/Mods/Structs/NativeMod.cs b/source/Reloaded.Mod.Loader/Mods/Structs/NativeMod.cs index 869cada8..3d4c503a 100644 --- a/source/Reloaded.Mod.Loader/Mods/Structs/NativeMod.cs +++ b/source/Reloaded.Mod.Loader/Mods/Structs/NativeMod.cs @@ -13,6 +13,7 @@ public class NativeMod : IModV1 private IntPtr _moduleHandle; private ReloadedStart _start; + private ReloadedStartEx _startEx; private ReloadedSuspend _reloadedSuspend; private ReloadedResume _reloadedResume; private ReloadedUnload _reloadedUnload; @@ -21,13 +22,19 @@ public class NativeMod : IModV1 private InitializeASI _initializeAsi; private Init _init; private bool _started; + private string _modDirectory; + private string _userConfigDirectory; /// /// Creates an IMod wrapper for a native DLL. /// /// Path to the native DLL. - public NativeMod(string path) + /// Path to the directory where the mod's user configuration is stored, passed to mods exporting ReloadedStartEx. + public NativeMod(string path, string userConfigDirectory = null) { + _modDirectory = Path.GetDirectoryName(Path.GetFullPath(path))!; + _userConfigDirectory = userConfigDirectory; + // Set new DLL Directory, load library and restore. // This could probably be better optimised but isn't a hot path, would rather save on memory, so it's no big deal. var builder = new StringBuilder(4096); // ought to be enough characters given most programs break at 260 anyway. @@ -35,8 +42,9 @@ public NativeMod(string path) SetDllDirectoryW(Path.GetDirectoryName(path)); _moduleHandle = LoadLibraryW(path); SetDllDirectoryW(builder.ToString()); - + _start = GetDelegateForNativeFunction(_moduleHandle, nameof(ReloadedStart)); + _startEx = GetDelegateForNativeFunction(_moduleHandle, nameof(ReloadedStartEx)); _reloadedSuspend = GetDelegateForNativeFunction(_moduleHandle, nameof(ReloadedSuspend)); _reloadedResume = GetDelegateForNativeFunction(_moduleHandle, nameof(ReloadedResume)); _reloadedUnload = GetDelegateForNativeFunction(_moduleHandle, nameof(ReloadedUnload)); @@ -51,7 +59,16 @@ public NativeMod(string path) public void Start(IModLoaderV1 loader) { // Try Reloaded Entry point and then others. - if (_start != null) + if (_startEx != null) + { + // Extended entry point hands the mod its folders, so it can find its configuration. + if (_userConfigDirectory != null) + Directory.CreateDirectory(_userConfigDirectory); + + _startEx.Invoke(_modDirectory, _userConfigDirectory); + _started = true; + } + else if (_start != null) { _start.Invoke(); _started = true; @@ -89,6 +106,10 @@ private TDelegate GetDelegateForNativeFunction(IntPtr moduleHandle, s // Delegates for native Reloaded Exports. private delegate void ReloadedStart(); + + [UnmanagedFunctionPointer(CallingConvention.Cdecl, CharSet = CharSet.Unicode)] + private delegate void ReloadedStartEx(string modDirectory, string userConfigDirectory); + private delegate void ReloadedSuspend(); private delegate void ReloadedResume(); private delegate void ReloadedUnload(); diff --git a/source/Reloaded.Mod.Template/templates/native/.template.config/template.json b/source/Reloaded.Mod.Template/templates/native/.template.config/template.json new file mode 100644 index 00000000..1d46fce2 --- /dev/null +++ b/source/Reloaded.Mod.Template/templates/native/.template.config/template.json @@ -0,0 +1,46 @@ +{ + "$schema": "http://json.schemastore.org/template", + "author": "Sewer56", + "classifications": [ + "Common", + "Library", + "Games" + ], + "name": "Reloaded II Native Mod Template (C++)", + "description": "Template for a Reloaded-II native C/C++ modification with launcher configuration.", + "sourceName": "Reloaded.Native.Template", + "defaultName": "My Reloaded-II Native Mod", + "identity": "Reloaded.Native.Mod.Template", + "shortName": "reloaded-native", + "tags": { + "language": "C++", + "type": "project" + }, + "preferNameDirectory": true, + "symbols": { + "ModName": { + "type": "parameter", + "displayName": "Mod Name", + "description": "Name of the mod as seen in the launcher.", + "datatype": "text", + "replaces": "ModNameValue", + "defaultValue": "My Cool Native Mod" + }, + "ModDescription": { + "type": "parameter", + "displayName": "Mod Description", + "description": "Description of the mod as seen in the launcher.", + "datatype": "text", + "replaces": "ModDescriptionValue", + "defaultValue": "Description" + }, + "ModAuthor": { + "type": "parameter", + "displayName": "Mod Author", + "description": "Author of the mod as seen in the launcher.", + "datatype": "text", + "replaces": "ModAuthorValue", + "defaultValue": "Me" + } + } +} diff --git a/source/Reloaded.Mod.Template/templates/native/CMakeLists.txt b/source/Reloaded.Mod.Template/templates/native/CMakeLists.txt new file mode 100644 index 00000000..8fa31a6c --- /dev/null +++ b/source/Reloaded.Mod.Template/templates/native/CMakeLists.txt @@ -0,0 +1,17 @@ +cmake_minimum_required(VERSION 3.15) +project(Reloaded.Native.Template LANGUAGES CXX) + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) + +add_library(Reloaded.Native.Template SHARED main.cpp) + +# Match the game's architecture: +# 64-bit game: cmake -B build -A x64 +# 32-bit game: cmake -B build -A Win32 +# then build and copy the DLL next to ModConfig.json. +if (CMAKE_SIZEOF_VOID_P EQUAL 8) + set_target_properties(Reloaded.Native.Template PROPERTIES OUTPUT_NAME "Reloaded.Native.Template") +else() + set_target_properties(Reloaded.Native.Template PROPERTIES OUTPUT_NAME "Reloaded.Native.Template32") +endif() diff --git a/source/Reloaded.Mod.Template/templates/native/ConfigSchema.json b/source/Reloaded.Mod.Template/templates/native/ConfigSchema.json new file mode 100644 index 00000000..3fdea928 --- /dev/null +++ b/source/Reloaded.Mod.Template/templates/native/ConfigSchema.json @@ -0,0 +1,76 @@ +{ + "Configurations": [ + { + "FileName": "Config.json", + "DisplayName": "Default Config", + "Enums": [ + { + "Name": "Quality", + "Members": [ + { "Name": "Low", "DisplayName": "Low" }, + { "Name": "Medium", "DisplayName": "Medium" }, + { "Name": "High", "DisplayName": "High" } + ] + } + ], + "Properties": [ + { + "Name": "EnableThing", + "Type": "bool", + "DisplayName": "Enable Thing", + "Description": "Turns the thing on or off.", + "Category": "General", + "Order": 0, + "DefaultValue": true + }, + { + "Name": "Volume", + "Type": "int", + "DisplayName": "Volume", + "Description": "How loud the thing is.", + "Category": "General", + "Order": 1, + "DefaultValue": 75, + "Slider": { + "Minimum": 0.0, + "Maximum": 100.0, + "SmallChange": 1.0, + "LargeChange": 10.0, + "TickFrequency": 10, + "ShowTextField": true + } + }, + { + "Name": "Brightness", + "Type": "float", + "DisplayName": "Brightness", + "Description": "Brightness of the thing.", + "Category": "General", + "Order": 2, + "DefaultValue": 1.5 + }, + { + "Name": "Quality", + "Type": "Quality", + "DisplayName": "Quality", + "Description": "Quality of the thing.", + "Category": "General", + "Order": 3, + "DefaultValue": "High" + }, + { + "Name": "CustomFile", + "Type": "string", + "DisplayName": "Custom File", + "Description": "Extra file used by the thing.", + "Category": "Files", + "Order": 4, + "DefaultValue": "", + "FilePicker": { + "Title": "Choose a File" + } + } + ] + } + ] +} diff --git a/source/Reloaded.Mod.Template/templates/native/ModConfig.json b/source/Reloaded.Mod.Template/templates/native/ModConfig.json new file mode 100644 index 00000000..59bde5e5 --- /dev/null +++ b/source/Reloaded.Mod.Template/templates/native/ModConfig.json @@ -0,0 +1,21 @@ +{ + "ModId": "Reloaded.Native.Template", + "ModName": "ModNameValue", + "ModAuthor": "ModAuthorValue", + "ModVersion": "1.0.0", + "ModDescription": "ModDescriptionValue", + "ModDll": "", + "ModIcon": "", + "ModR2RManagedDll32": "", + "ModR2RManagedDll64": "", + "ModNativeDll32": "", + "ModNativeDll64": "Reloaded.Native.Template.dll", + "IsLibrary": false, + "ReleaseMetadataFileName": "Sewer56.Update.ReleaseMetadata.json", + "PluginData": {}, + "IsUniversalMod": false, + "ModDependencies": [], + "OptionalDependencies": [], + "SupportedAppId": [], + "ProjectUrl": "" +} diff --git a/source/Reloaded.Mod.Template/templates/native/README.md b/source/Reloaded.Mod.Template/templates/native/README.md new file mode 100644 index 00000000..71bfe811 --- /dev/null +++ b/source/Reloaded.Mod.Template/templates/native/README.md @@ -0,0 +1,36 @@ +# Reloaded II Native Mod Template + +A native (C/C++) mod for [Reloaded II](https://github.com/Reloaded-Project/Reloaded-II) +with launcher-side configuration. No C# DLL required. + +## Files + +| File | Purpose | +|---|---| +| `ModConfig.json` | Mod Info, tell the loader where to load the native DLL (`ModNativeDll32` / `ModNativeDll64`). | +| `ConfigSchema.json` | Declares the settings shown in the launcher's Configure dialog. | +| `ReloadedModConfig.h` | Header-only helper that reads the settings inside a mod. | +| `main.cpp` | Entry point. | +| `CMakeLists.txt` | Sample build script (MSVC or Clang; use `-A x64` or `-A Win32` to match the game). | + +## Workflow + +1. Build your DLL and place it next to `ModConfig.json` (path set in `ModNativeDll32/64`). +2. Edit `ConfigSchema.json` to declare your settings. +3. Read the values in C++ through `reloaded::config()` (see `main.cpp`). +4. Users change the settings in the launcher; values are saved to + `/User/Mods//Config.json` and read by your mod. + +## Entry Point + +The loader starts native mods by calling the first of these exports it finds: + +- `ReloadedStartEx(const wchar_t* modDirectory, const wchar_t* userConfigDirectory)` (recommended; provided by `RELOADED_MOD_CONFIG_IMPL`) +- `ReloadedStart` +- `InitializeASI` +- `Init` + +`ReloadedStartEx` receives the mod and user-config directories, which is how the +helper finds the schema and the values file. + +See the wiki page "Writing Native Mods" for the optional suspend/resume/unload exports. diff --git a/source/Reloaded.Mod.Template/templates/native/ReloadedModConfig.h b/source/Reloaded.Mod.Template/templates/native/ReloadedModConfig.h new file mode 100644 index 00000000..e5f8b67d --- /dev/null +++ b/source/Reloaded.Mod.Template/templates/native/ReloadedModConfig.h @@ -0,0 +1,814 @@ +/* + ReloadedModConfig.h + + Single header helper for Reloaded-II native (C/C++) mods with configuration. + Drop this file next to your sources, include it, and define the entry macro + in exactly one source file: + + #include "ReloadedModConfig.h" + + void mod_start() + { + auto& config = reloaded::config(); + bool enabled = config.get_bool("EnableThing", true); + int volume = (int)config.get_int("Volume", 100); + } + + RELOADED_MOD_CONFIG_IMPL(mod_start) + + The macro exports ReloadedStartEx, which the mod loader calls with the mod's + directories before anything else. reloaded::config() then reads the values + written by the launcher from /.json, + falling back to the defaults declared in your ConfigSchema.json. + + Requires C++17 or newer. Windows only. No external dependencies. +*/ + +#ifndef RELOADED_MOD_CONFIG_H +#define RELOADED_MOD_CONFIG_H + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifndef RELOADED_MOD_CONFIG_DEFAULT_FILE +#define RELOADED_MOD_CONFIG_DEFAULT_FILE L"Config.json" +#endif + +namespace reloaded +{ + /* + --------------- + Small JSON tree + --------------- + */ + class Json + { + public: + enum class Type { Null, Bool, Number, String, Array, Object }; + + Type type = Type::Null; + bool boolean = false; + double number = 0.0; + std::string text; + std::vector items; + std::vector> members; + + const Json* find(const char* key) const + { + if (type != Type::Object) + return nullptr; + + for (const auto& member : members) + { + if (member.first == key) + return &member.second; + } + + return nullptr; + } + + // Parses a UTF-8 JSON document. + static std::optional parse(const std::string& utf8) + { + size_t pos = 0; + Json result; + if (!parse_value(utf8, pos, result) || !skip_ws(utf8, pos) || pos != utf8.size()) + return std::nullopt; + + return result; + } + + // Parses a UTF-8 (or ASCII) JSON file. + static std::optional parse_file(const std::wstring& path) + { + std::string utf8; + if (!read_all_text(path, utf8)) + return std::nullopt; + + return parse(utf8); + } + + private: + static bool read_all_text(const std::wstring& path, std::string& out) + { + HANDLE file = CreateFileW(path.c_str(), GENERIC_READ, FILE_SHARE_READ | FILE_SHARE_WRITE, nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr); + if (file == INVALID_HANDLE_VALUE) + return false; + + out.clear(); + char buffer[8192]; + DWORD read = 0; + while (ReadFile(file, buffer, sizeof(buffer), &read, nullptr) && read > 0) + out.append(buffer, read); + + CloseHandle(file); + return true; + } + + static bool skip_ws(const std::string& s, size_t& pos) + { + while (pos < s.size() && (s[pos] == ' ' || s[pos] == '\t' || s[pos] == '\r' || s[pos] == '\n')) + pos++; + return true; + } + + static bool parse_value(const std::string& s, size_t& pos, Json& out) + { + if (!skip_ws(s, pos) || pos >= s.size()) + return false; + + char c = s[pos]; + if (c == '{') + return parse_object(s, pos, out); + if (c == '[') + return parse_array(s, pos, out); + if (c == '"') + { + out.type = Type::String; + return parse_string(s, pos, out.text); + } + if (c == 't' || c == 'f') + return parse_bool(s, pos, out); + if (c == 'n') + return parse_null(s, pos, out); + + return parse_number(s, pos, out); + } + + static bool parse_object(const std::string& s, size_t& pos, Json& out) + { + out.type = Type::Object; + pos++; // consume '{' + if (!skip_ws(s, pos)) + return false; + if (pos < s.size() && s[pos] == '}') + { + pos++; + return true; + } + + while (true) + { + if (!skip_ws(s, pos) || pos >= s.size() || s[pos] != '"') + return false; + + std::string key; + if (!parse_string(s, pos, key)) + return false; + + if (!skip_ws(s, pos) || pos >= s.size() || s[pos] != ':') + return false; + pos++; + + Json value; + if (!parse_value(s, pos, value)) + return false; + + out.members.emplace_back(std::move(key), std::move(value)); + if (!skip_ws(s, pos)) + return false; + + if (pos >= s.size()) + return false; + + if (s[pos] == ',') + { + pos++; + continue; + } + + if (s[pos] == '}') + { + pos++; + return true; + } + + return false; + } + } + + static bool parse_array(const std::string& s, size_t& pos, Json& out) + { + out.type = Type::Array; + pos++; // consume '[' + if (!skip_ws(s, pos)) + return false; + if (pos < s.size() && s[pos] == ']') + { + pos++; + return true; + } + + while (true) + { + Json value; + if (!parse_value(s, pos, value)) + return false; + + out.items.push_back(std::move(value)); + if (!skip_ws(s, pos)) + return false; + + if (pos >= s.size()) + return false; + + if (s[pos] == ',') + { + pos++; + continue; + } + + if (s[pos] == ']') + { + pos++; + return true; + } + + return false; + } + } + + static bool parse_string(const std::string& s, size_t& pos, std::string& out) + { + pos++; // consume '"' + out.clear(); + while (pos < s.size()) + { + unsigned char c = (unsigned char)s[pos]; + if (c == '"') + { + pos++; + return true; + } + + if (c == '\\') + { + pos++; + if (pos >= s.size()) + return false; + + char escape = s[pos++]; + switch (escape) + { + case '"': out += '"'; break; + case '\\': out += '\\'; break; + case '/': out += '/'; break; + case 'b': out += '\b'; break; + case 'f': out += '\f'; break; + case 'n': out += '\n'; break; + case 'r': out += '\r'; break; + case 't': out += '\t'; break; + case 'u': + { + if (pos + 4 > s.size()) + return false; + + unsigned code = 0; + for (int x = 0; x < 4; x++) + { + char hex = s[pos + x]; + code <<= 4; + if (hex >= '0' && hex <= '9') code |= (unsigned)(hex - '0'); + else if (hex >= 'a' && hex <= 'f') code |= (unsigned)(hex - 'a' + 10); + else if (hex >= 'A' && hex <= 'F') code |= (unsigned)(hex - 'A' + 10); + else return false; + } + pos += 4; + + // Surrogate pair support. + if (code >= 0xD800 && code <= 0xDBFF && pos + 6 <= s.size() && s[pos] == '\\' && s[pos + 1] == 'u') + { + unsigned low = 0; + bool valid = true; + for (int x = 0; x < 4; x++) + { + char hex = s[pos + 2 + x]; + low <<= 4; + if (hex >= '0' && hex <= '9') low |= (unsigned)(hex - '0'); + else if (hex >= 'a' && hex <= 'f') low |= (unsigned)(hex - 'a' + 10); + else if (hex >= 'A' && hex <= 'F') low |= (unsigned)(hex - 'A' + 10); + else { valid = false; break; } + } + + if (valid && low >= 0xDC00 && low <= 0xDFFF) + { + code = 0x10000 + ((code - 0xD800) << 10) + (low - 0xDC00); + pos += 6; + } + } + + append_utf8(out, code); + break; + } + default: + return false; + } + + continue; + } + + out += (char)c; + pos++; + } + + return false; + } + + static bool parse_bool(const std::string& s, size_t& pos, Json& out) + { + if (s.compare(pos, 4, "true") == 0) + { + out.type = Type::Bool; + out.boolean = true; + pos += 4; + return true; + } + + if (s.compare(pos, 5, "false") == 0) + { + out.type = Type::Bool; + out.boolean = false; + pos += 5; + return true; + } + + return false; + } + + static bool parse_null(const std::string& s, size_t& pos, Json& out) + { + if (s.compare(pos, 4, "null") == 0) + { + out.type = Type::Null; + pos += 4; + return true; + } + + return false; + } + + static bool parse_number(const std::string& s, size_t& pos, Json& out) + { + const char* start = s.c_str() + pos; + char* end = nullptr; + double value = strtod(start, &end); + if (end == start) + return false; + + out.type = Type::Number; + out.number = value; + pos += (size_t)(end - start); + return true; + } + + static void append_utf8(std::string& out, unsigned code) + { + if (code <= 0x7F) + { + out += (char)code; + } + else if (code <= 0x7FF) + { + out += (char)(0xC0 | (code >> 6)); + out += (char)(0x80 | (code & 0x3F)); + } + else if (code <= 0xFFFF) + { + out += (char)(0xE0 | (code >> 12)); + out += (char)(0x80 | ((code >> 6) & 0x3F)); + out += (char)(0x80 | (code & 0x3F)); + } + else + { + out += (char)(0xF0 | (code >> 18)); + out += (char)(0x80 | ((code >> 12) & 0x3F)); + out += (char)(0x80 | ((code >> 6) & 0x3F)); + out += (char)(0x80 | (code & 0x3F)); + } + } + }; + + /* + ---------------- + String utilities + ---------------- + */ + inline std::string utf16_to_utf8(const wchar_t* text) + { + if (text == nullptr || text[0] == L'\0') + return std::string(); + + int size = WideCharToMultiByte(CP_UTF8, 0, text, -1, nullptr, 0, nullptr, nullptr); + if (size <= 1) + return std::string(); + + std::string result((size_t)size - 1, '\0'); + WideCharToMultiByte(CP_UTF8, 0, text, -1, result.data(), size, nullptr, nullptr); + return result; + } + + inline std::wstring utf8_to_utf16(const std::string& text) + { + if (text.empty()) + return std::wstring(); + + int size = MultiByteToWideChar(CP_UTF8, 0, text.c_str(), (int)text.size(), nullptr, 0); + if (size <= 0) + return std::wstring(); + + std::wstring result((size_t)size, L'\0'); + MultiByteToWideChar(CP_UTF8, 0, text.c_str(), (int)text.size(), result.data(), size); + return result; + } + + inline std::wstring directory_of(const std::wstring& path) + { + size_t separator = path.find_last_of(L"\\/"); + if (separator == std::wstring::npos) + return std::wstring(); + + std::wstring directory = path.substr(0, separator); + if (!directory.empty() && directory.back() != L'\\' && directory.back() != L'/') + directory += L'\\'; + + return directory; + } + + // The loader hands us folders with no trailing slash, the paths built from them need one. + inline std::wstring with_trailing_separator(const std::wstring& path) + { + if (path.empty() || path.back() == L'\\' || path.back() == L'/') + return path; + + return path + L'\\'; + } + + // Folder containing the DLL (or EXE) this code was compiled into. + inline const std::wstring& this_module_directory() + { + static std::wstring directory; + if (directory.empty()) + { + HMODULE module = nullptr; + GetModuleHandleExW(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT, (LPCWSTR)&this_module_directory, &module); + + wchar_t path[MAX_PATH * 4]; + DWORD length = GetModuleFileNameW(module, path, (DWORD)std::size(path)); + directory = directory_of(std::wstring(path, length)); + } + + return directory; + } + + /* + --------------------- + Mod startup information + --------------------- + */ + struct NativeModInfo + { + std::wstring mod_directory; // Folder with the mod's own files (ConfigSchema.json, ...). + std::wstring config_directory; // Folder where the launcher writes user settings. + }; + + // Filled by the ReloadedStartEx export; safe to read after mod start. + inline NativeModInfo& native_mod_info() + { + static NativeModInfo info; + return info; + } + + /* + ----------- + ModConfig + ----------- + */ + class ModConfig + { + public: + explicit ModConfig(std::wstring values_file = RELOADED_MOD_CONFIG_DEFAULT_FILE) + : _values_file(std::move(values_file)) { + } + + // Directory of the mod itself. + const std::wstring& mod_directory() const + { + resolve_paths(); + return _mod_directory; + } + + // Directory where the launcher stores the values file. + const std::wstring& config_directory() const + { + resolve_paths(); + return _config_directory; + } + + // Full path of the values file. + std::wstring values_path() const + { + resolve_paths(); + return _config_directory + _values_file; + } + + // Full path of the schema file. + std::wstring schema_path() const + { + resolve_paths(); + return _mod_directory + L"ConfigSchema.json"; + } + + // Reads the schema defaults and the current values from disk. + bool load() + { + resolve_paths(); + auto schema = Json::parse_file(schema_path()); + if (schema) + parse_defaults(*schema); + + auto values = Json::parse_file(values_path()); + if (!values) + return false; + + _values = std::move(*values); + remember_write_time(); + return true; + } + + // True when the values file changed on disk since the last load. + bool changed_on_disk() const + { + resolve_paths(); + WIN32_FILE_ATTRIBUTE_DATA data; + if (!GetFileAttributesExW(values_path().c_str(), GetFileExInfoStandard, &data)) + return false; + + return CompareFileTime(&data.ftLastWriteTime, &_write_time) != 0; + } + + // Starts a thread that reloads the config and calls the callback on change. + // Keep the returned thread; detach it or join it on unload. + // Dropping it on the floor while it still runs kills the process. + [[nodiscard]] std::thread watch(const std::function& callback, int poll_ms = 500) + { + return std::thread([this, callback, poll_ms]() + { + while (!_stop_watching.load(std::memory_order_relaxed)) + { + Sleep((DWORD)poll_ms); + if (_stop_watching.load(std::memory_order_relaxed)) + break; + + if (changed_on_disk()) + { + load(); + callback(*this); + } + } + }); + } + + void stop_watching() + { + _stop_watching.store(true, std::memory_order_relaxed); + } + + /* + ------- + Getters + ------- + Look up a property by name; missing or invalid values fall back to + the schema default, then to the fallback argument. + */ + + bool has(const char* name) const + { + const Json* value = find_value(name); + return value != nullptr; + } + + bool get_bool(const char* name, bool fallback) const + { + const Json* value = find_value(name); + if (value != nullptr && value->type == Json::Type::Bool) + return value->boolean; + + const Json* def = find_default(name); + if (def != nullptr && def->type == Json::Type::Bool) + return def->boolean; + + return fallback; + } + + long long get_int(const char* name, long long fallback) const + { + const Json* value = find_value(name); + if (value != nullptr && value->type == Json::Type::Number) + return (long long)value->number; + + const Json* def = find_default(name); + if (def != nullptr && def->type == Json::Type::Number) + return (long long)def->number; + + return fallback; + } + + double get_float(const char* name, double fallback) const + { + const Json* value = find_value(name); + if (value != nullptr && value->type == Json::Type::Number) + return value->number; + + const Json* def = find_default(name); + if (def != nullptr && def->type == Json::Type::Number) + return def->number; + + return fallback; + } + + // Strings and enums are stored as UTF-8; enums return the member name. + std::string get_string(const char* name, const char* fallback = "") const + { + const Json* value = find_value(name); + if (value != nullptr && value->type == Json::Type::String) + return value->text; + + const Json* def = find_default(name); + if (def != nullptr && def->type == Json::Type::String) + return def->text; + + return fallback != nullptr ? fallback : ""; + } + + std::wstring get_wstring(const char* name, const wchar_t* fallback = L"") const + { + const Json* value = find_value(name); + if (value != nullptr && value->type == Json::Type::String) + return utf8_to_utf16(value->text); + + const Json* def = find_default(name); + if (def != nullptr && def->type == Json::Type::String) + return utf8_to_utf16(def->text); + + return fallback != nullptr ? fallback : L""; + } + + // Returns the index of the enum member in 'members' (order matches ConfigSchema.json), + // or 'fallback' when the value is missing or unknown. + int get_enum(const char* name, const char* const* members, int member_count, int fallback) const + { + std::string value = get_string(name, ""); + for (int x = 0; x < member_count; x++) + { + if (value == members[x]) + return x; + } + + return fallback; + } + + private: + std::wstring _values_file; + std::wstring _mod_directory; + std::wstring _config_directory; + Json _values; + Json _schema_defaults; + FILETIME _write_time = {}; + mutable std::atomic_bool _paths_resolved{ false }; + std::atomic_bool _stop_watching{ false }; + + void resolve_paths() const + { + if (_paths_resolved.load(std::memory_order_relaxed)) + return; + + // Cast away to keep the getters const; resolution happens at most once. + auto* self = const_cast(this); + const NativeModInfo& info = native_mod_info(); + if (!info.mod_directory.empty()) + { + self->_mod_directory = with_trailing_separator(info.mod_directory); + self->_config_directory = with_trailing_separator(info.config_directory.empty() ? info.mod_directory : info.config_directory); + } + else + { + // Loaded by an older loader or another injector: assume the values live next to the DLL. + const std::wstring& dll_directory = this_module_directory(); + self->_mod_directory = dll_directory; + self->_config_directory = dll_directory; + } + + self->_paths_resolved.store(true, std::memory_order_relaxed); + } + + const Json* find_value(const char* name) const + { + return _values.find(name); + } + + const Json* find_default(const char* name) const + { + return _schema_defaults.find(name); + } + + void remember_write_time() + { + WIN32_FILE_ATTRIBUTE_DATA data; + if (GetFileAttributesExW(values_path().c_str(), GetFileExInfoStandard, &data)) + _write_time = data.ftLastWriteTime; + } + + // Pulls the defaults of the matching configuration out of the schema. + void parse_defaults(const Json& schema) + { + _schema_defaults = Json(); + _schema_defaults.type = Json::Type::Object; + + const Json* configurations = schema.find("Configurations"); + if (configurations == nullptr || configurations->type != Json::Type::Array) + return; + + std::string file_name = utf16_to_utf8(_values_file.c_str()); + const Json* selected = nullptr; + for (const Json& configuration : configurations->items) + { + const Json* name = configuration.find("FileName"); + if (name == nullptr || name->text != file_name) + continue; + + selected = &configuration; + break; + } + + if (selected == nullptr && !configurations->items.empty()) + selected = &configurations->items[0]; // First config is the default one. + + if (selected == nullptr) + return; + + const Json* properties = selected->find("Properties"); + if (properties == nullptr || properties->type != Json::Type::Array) + return; + + for (const Json& property : properties->items) + { + const Json* name = property.find("Name"); + const Json* value = property.find("DefaultValue"); + if (name == nullptr || value == nullptr) + continue; + + _schema_defaults.members.emplace_back(name->text, *value); + } + } + }; + + // The config instance used by RELOADED_MOD_CONFIG_IMPL. + inline ModConfig& config() + { + static ModConfig instance; + return instance; + } +} + +/* + Implement this macro in exactly one source file of the mod. + FN is a function 'void FN()' called on start, with directories known and config loaded. +*/ +#define RELOADED_MOD_CONFIG_IMPL(FN) \ + extern "C" __declspec(dllexport) void ReloadedStartEx(const wchar_t* mod_directory, const wchar_t* user_config_directory) \ + { \ + auto& info = reloaded::native_mod_info(); \ + if (mod_directory != nullptr) \ + info.mod_directory = mod_directory; \ + if (user_config_directory != nullptr) \ + info.config_directory = user_config_directory; \ + reloaded::config().load(); \ + FN(); \ + } + +// Same as above but without a start callback, for mods driven by DllMain or other entry points. +#define RELOADED_MOD_CONFIG_IMPL_NO_START() \ + extern "C" __declspec(dllexport) void ReloadedStartEx(const wchar_t* mod_directory, const wchar_t* user_config_directory) \ + { \ + auto& info = reloaded::native_mod_info(); \ + if (mod_directory != nullptr) \ + info.mod_directory = mod_directory; \ + if (user_config_directory != nullptr) \ + info.config_directory = user_config_directory; \ + reloaded::config().load(); \ + } + +#endif // RELOADED_MOD_CONFIG_H diff --git a/source/Reloaded.Mod.Template/templates/native/main.cpp b/source/Reloaded.Mod.Template/templates/native/main.cpp new file mode 100644 index 00000000..4891ae3c --- /dev/null +++ b/source/Reloaded.Mod.Template/templates/native/main.cpp @@ -0,0 +1,29 @@ +#include +#include "ReloadedModConfig.h" + +// Called by ReloadedStartEx after the mod loader handed us the directories +static void mod_start() +{ + auto& config = reloaded::config(); + + bool enabled = config.get_bool("EnableThing", true); + long long volume = config.get_int("Volume", 75); + double brightness = config.get_float("Brightness", 1.5); + + static const char* qualityMembers[] = { "Low", "Medium", "High" }; + int quality = config.get_enum("Quality", qualityMembers, 3, 2); + + std::wstring file = config.get_wstring("CustomFile", L""); + + // Example: handle the user changing settings in the launcher while the game runs. + // auto watcher = config.watch([](reloaded::ModConfig& cfg) { ... }); + + wchar_t message[512]; + swprintf_s(message, L"[Native Template] enabled=%d volume=%lld brightness=%.2f quality=%d file='%ls'\n", + enabled ? 1 : 0, volume, brightness, quality, file.c_str()); + OutputDebugStringW(message); +} + +// Exports ReloadedStartEx and wires it to mod_start. +// The other Reloaded exports (ReloadedSuspend, ReloadedResume, ...) are optional. +RELOADED_MOD_CONFIG_IMPL(mod_start) From 8bbcc06461efee0fb12dc65436368568b41f0661 Mon Sep 17 00:00:00 2001 From: Sora Date: Wed, 16 Sep 2026 00:27:37 +0200 Subject: [PATCH 02/35] Fix typo in the doc (ended dropping crossplatform) --- docs/NativeMods.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/NativeMods.md b/docs/NativeMods.md index 27435731..86ac51db 100644 --- a/docs/NativeMods.md +++ b/docs/NativeMods.md @@ -98,7 +98,7 @@ static void my_start() RELOADED_MOD_CONFIG_IMPL(my_start) ``` -Missing values fall back to the schema defaults, then to the fallback argument. The header only needs the C++17 standard library (Windows APIs are used behind `_WIN32`, everything else uses `std::filesystem`), so it also works outside of Windows if you ever reuse it. `config.watch(callback)` spawns a thread that reloads the settings when the user changes them while the game is running. It hands you that thread, keep it and detach or join it, letting it go out of scope while it runs kills the process. +Missing values fall back to the schema defaults, then to the fallback argument. The header only needs the C++17 standard library (or later) and only work on Windows. ## Exports From 7fb4acfb4f792551e37b504f230a23533532c1d6 Mon Sep 17 00:00:00 2001 From: Sora Date: Wed, 16 Sep 2026 01:22:22 +0200 Subject: [PATCH 03/35] ModConfig: Fix missing 32 bits mod template reference. --- source/Reloaded.Mod.Template/templates/native/ModConfig.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/source/Reloaded.Mod.Template/templates/native/ModConfig.json b/source/Reloaded.Mod.Template/templates/native/ModConfig.json index 59bde5e5..7efeb883 100644 --- a/source/Reloaded.Mod.Template/templates/native/ModConfig.json +++ b/source/Reloaded.Mod.Template/templates/native/ModConfig.json @@ -8,7 +8,7 @@ "ModIcon": "", "ModR2RManagedDll32": "", "ModR2RManagedDll64": "", - "ModNativeDll32": "", + "ModNativeDll32": "Reloaded.Native.Template32.dll", "ModNativeDll64": "Reloaded.Native.Template.dll", "IsLibrary": false, "ReleaseMetadataFileName": "Sewer56.Update.ReleaseMetadata.json", From 00d437ef521a1b3b7cd874d23a80de8b9a7d51a8 Mon Sep 17 00:00:00 2001 From: Sora Date: Wed, 16 Sep 2026 01:51:05 +0200 Subject: [PATCH 04/35] Syntax moment --- .../Commands/Mod/ConfigureModCommand.cs | 2 +- .../Configuration/NativeConfigTypeEmitter.cs | 11 ++++++++--- .../Configuration/NativeConfigurableBase.cs | 2 +- .../Configuration/NativeModConfigSchema.cs | 2 +- .../Configuration/NativeModConfigurator.cs | 2 +- .../templates/native/ConfigSchema.json | 19 +++++++------------ 6 files changed, 19 insertions(+), 19 deletions(-) diff --git a/source/Reloaded.Mod.Launcher.Lib/Commands/Mod/ConfigureModCommand.cs b/source/Reloaded.Mod.Launcher.Lib/Commands/Mod/ConfigureModCommand.cs index d2f60785..5f4f91eb 100644 --- a/source/Reloaded.Mod.Launcher.Lib/Commands/Mod/ConfigureModCommand.cs +++ b/source/Reloaded.Mod.Launcher.Lib/Commands/Mod/ConfigureModCommand.cs @@ -72,7 +72,7 @@ private bool TryGetConfigurator(out IConfiguratorV1? configurator, out PluginLoa var modDirectory = Path.GetFullPath(Path.GetDirectoryName(_modTuple.Path)!); - // Native (C/C++) mods describe their settings in a schema file, no managed code required. + // Native (non .NET) mods describe their settings in a schema file, no managed code required. if (NativeModConfigSchema.ExistsInFolder(modDirectory)) { // Validate upfront, a broken schema disables the button instead of failing later. diff --git a/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/NativeConfigTypeEmitter.cs b/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/NativeConfigTypeEmitter.cs index 6f9cc4f0..a0c7bb10 100644 --- a/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/NativeConfigTypeEmitter.cs +++ b/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/NativeConfigTypeEmitter.cs @@ -7,9 +7,14 @@ namespace Reloaded.Mod.Launcher.Lib.Models.Model.Configuration; /// /// Builds .NET types out of native mod configuration schemas using Reflection.Emit. /// The generated types subclass and carry the same -/// attributes (DisplayName, Description, Category, DefaultValue, -/// Display, SliderControlParams, ...) as a hand written C# configuration class, -/// so the launcher's PropertyGrid renders them exactly like the configuration of a C# mod. +/// attributes as a hand written C# configuration class: +/// +/// , , +/// (backs the Reset button of the dialog) +/// Display (sort order) +/// SliderControlParams, FilePickerParams, FolderPickerParams (custom editors) +/// +/// This way the launcher's PropertyGrid renders them exactly like the configuration of a C# mod. /// public static class NativeConfigTypeEmitter { diff --git a/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/NativeConfigurableBase.cs b/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/NativeConfigurableBase.cs index 8c837c5d..4e120313 100644 --- a/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/NativeConfigurableBase.cs +++ b/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/NativeConfigurableBase.cs @@ -4,7 +4,7 @@ namespace Reloaded.Mod.Launcher.Lib.Models.Model.Configuration; /// -/// Base class for the configuration objects generated for native (C/C++) mods. +/// Base class for the configuration objects generated for native (non .NET) mods. /// The emits one derived class per schema configuration; /// the derived class holds the settings as properties, this class supplies the behaviour /// (name, saving, file watching) expected by the launcher's configuration dialog. diff --git a/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/NativeModConfigSchema.cs b/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/NativeModConfigSchema.cs index fb7e7891..7877f82f 100644 --- a/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/NativeModConfigSchema.cs +++ b/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/NativeModConfigSchema.cs @@ -3,7 +3,7 @@ namespace Reloaded.Mod.Launcher.Lib.Models.Model.Configuration; /// -/// Declarative configuration schema for native (C/C++) mods. +/// Declarative configuration schema for native (non .NET) mods. /// A mod declares its settings by placing a ConfigSchema.json file next to its ModConfig.json. /// The launcher then builds a configuration UI from that schema. /// The config file mirrors the attributes used by the C# mod template (DisplayName, Description, Category, diff --git a/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/NativeModConfigurator.cs b/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/NativeModConfigurator.cs index fa2d0061..a1031c9d 100644 --- a/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/NativeModConfigurator.cs +++ b/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/NativeModConfigurator.cs @@ -1,7 +1,7 @@ namespace Reloaded.Mod.Launcher.Lib.Models.Model.Configuration; /// -/// Configurator for native (C/C++) mods that declare their settings through a ConfigSchema.json file. +/// Configurator for native (non .NET) mods that declare their settings through a ConfigSchema.json file. /// Use the same interface as a C# mod's configurator. /// public class NativeModConfigurator : IConfiguratorV3 diff --git a/source/Reloaded.Mod.Template/templates/native/ConfigSchema.json b/source/Reloaded.Mod.Template/templates/native/ConfigSchema.json index 3fdea928..821ddd7d 100644 --- a/source/Reloaded.Mod.Template/templates/native/ConfigSchema.json +++ b/source/Reloaded.Mod.Template/templates/native/ConfigSchema.json @@ -3,16 +3,6 @@ { "FileName": "Config.json", "DisplayName": "Default Config", - "Enums": [ - { - "Name": "Quality", - "Members": [ - { "Name": "Low", "DisplayName": "Low" }, - { "Name": "Medium", "DisplayName": "Medium" }, - { "Name": "High", "DisplayName": "High" } - ] - } - ], "Properties": [ { "Name": "EnableThing", @@ -51,12 +41,17 @@ }, { "Name": "Quality", - "Type": "Quality", + "Type": "enum", "DisplayName": "Quality", "Description": "Quality of the thing.", "Category": "General", "Order": 3, - "DefaultValue": "High" + "DefaultValue": "High", + "Values": [ + "Low", + "Medium", + { "Name": "High", "DisplayName": "High Quality" } + ] }, { "Name": "CustomFile", From a1658e073a1bee9a202574d4cc64c8cc1b00188b Mon Sep 17 00:00:00 2001 From: Sora Date: Wed, 16 Sep 2026 02:10:19 +0200 Subject: [PATCH 05/35] Handle null case for when user config directory is null (+ rework migrate stuff) --- .../Commands/Mod/ConfigureModCommand.cs | 12 ++++++---- .../Configuration/NativeModConfigurator.cs | 24 ++++++++++++++++--- 2 files changed, 28 insertions(+), 8 deletions(-) diff --git a/source/Reloaded.Mod.Launcher.Lib/Commands/Mod/ConfigureModCommand.cs b/source/Reloaded.Mod.Launcher.Lib/Commands/Mod/ConfigureModCommand.cs index 5f4f91eb..ba9b2e31 100644 --- a/source/Reloaded.Mod.Launcher.Lib/Commands/Mod/ConfigureModCommand.cs +++ b/source/Reloaded.Mod.Launcher.Lib/Commands/Mod/ConfigureModCommand.cs @@ -81,12 +81,14 @@ private bool TryGetConfigurator(out IConfiguratorV1? configurator, out PluginLoa var nativeConfigurator = new NativeModConfigurator(modDirectory); nativeConfigurator.SetModDirectory(modDirectory); - if (_modUserConfigTuple != null) - { - var configDirectory = Path.GetFullPath(Path.GetDirectoryName(_modUserConfigTuple.Path)!); - nativeConfigurator.Migrate(modDirectory, configDirectory); + + string configDirectory = _modUserConfigTuple != null + ? Path.GetFullPath(Path.GetDirectoryName(_modUserConfigTuple.Path)!) + : ModUserConfig.GetUserConfigFolderForMod(_modTuple.Config.ModId); + + + if (nativeConfigurator.TryMigrate(modDirectory, configDirectory)) nativeConfigurator.SetConfigDirectory(configDirectory); - } nativeConfigurator.SetContext(new ConfiguratorContext() { diff --git a/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/NativeModConfigurator.cs b/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/NativeModConfigurator.cs index a1031c9d..c7c9388a 100644 --- a/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/NativeModConfigurator.cs +++ b/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/NativeModConfigurator.cs @@ -55,8 +55,17 @@ public IConfigurable[] GetConfigurations() public bool TryRunCustomConfiguration() => false; /// - public void Migrate(string oldDirectory, string newDirectory) + public void Migrate(string oldDirectory, string newDirectory) => TryMigrate(oldDirectory, newDirectory); + + /// + /// Moves value files left behind in an old directory over to a new one. + /// Returns false when the move failed; the reason is in . + /// + /// The old mod config directory, usually the mod folder. + /// The new mod config directory, usually the user config folder. + public bool TryMigrate(string oldDirectory, string newDirectory) { + MigrationError = null; try { var schema = NativeModConfigSchema.Load(_modDirectory); @@ -68,13 +77,22 @@ public void Migrate(string oldDirectory, string newDirectory) if (File.Exists(oldPath) && !File.Exists(newPath)) File.Move(oldPath, newPath); } + + return true; } - catch (Exception) + catch (Exception e) { - + // The caller keeps using the old directory when the move fails. + MigrationError = e; + return false; } } + /// + /// Exception of the last failed migration, if any. + /// + public Exception? MigrationError { get; private set; } + /// public void SetConfigDirectory(string configDirectory) => _configDirectory = configDirectory; From a423fff87e5adbc63e9b92106741ea973e1a0458 Mon Sep 17 00:00:00 2001 From: Sora Date: Wed, 16 Sep 2026 02:24:51 +0200 Subject: [PATCH 06/35] Validate filename helper + add missing "Values" for enum --- .../Configuration/NativeModConfigSchema.cs | 36 ++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/NativeModConfigSchema.cs b/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/NativeModConfigSchema.cs index 7877f82f..b184d854 100644 --- a/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/NativeModConfigSchema.cs +++ b/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/NativeModConfigSchema.cs @@ -88,7 +88,7 @@ public static NativeConfigSchemaConfiguration Parse(JsonNode node) { var configuration = new NativeConfigSchemaConfiguration { - FileName = node.GetStringOrDefault(Keys.FileName, "Config.json")!, + FileName = ValidateFileName(node.GetStringOrDefault(Keys.FileName, "Config.json")!), DisplayName = node.GetStringOrDefault(Keys.DisplayName, null) }; @@ -106,6 +106,18 @@ public static NativeConfigSchemaConfiguration Parse(JsonNode node) return configuration; } + + /// + /// The file name is used to build paths inside the user config directory, + /// so anything that is not a plain file name (rooted paths, separators) is rejected. + /// + private static string ValidateFileName(string fileName) + { + if (fileName.Length <= 0 || Path.IsPathRooted(fileName) || fileName != Path.GetFileName(fileName)) + throw new JsonException($"'{Keys.FileName}' must be a plain file name, got '{fileName}'."); + + return fileName; + } } /// @@ -238,6 +250,11 @@ public static class SupportedTypes /// public NativeConfigSchemaFolderPicker? FolderPicker { get; set; } + /// + /// Enum values declared directly on the property, for the common case where an enum is used once. + /// + public List Values { get; set; } = new(); + public static NativeConfigSchemaProperty Parse(JsonNode node) { var property = new NativeConfigSchemaProperty @@ -260,6 +277,22 @@ public static NativeConfigSchemaProperty Parse(JsonNode node) if (node[Keys.FolderPicker] is JsonNode folderPicker) property.FolderPicker = NativeConfigSchemaFolderPicker.Parse(folderPicker); + if (node[Keys.Values] is JsonArray values) + { + foreach (var valueNode in values) + { + var member = valueNode!.GetValueKind() == JsonValueKind.String + ? new NativeConfigSchemaEnumMember { Name = valueNode.GetValue() } + : NativeConfigSchemaEnumMember.Parse(valueNode); + + if (member.Name.Length > 0) + property.Values.Add(member); + } + + if (property.Values.Count > 0) + property.Type = property.Name; // inline enums borrow the property name. + } + if (property.Name.Length <= 0) throw new JsonException($"A property in the schema has no '{Keys.Name}'."); @@ -388,6 +421,7 @@ internal static class Keys public const string Slider = "Slider"; public const string FilePicker = "FilePicker"; public const string FolderPicker = "FolderPicker"; + public const string Values = "Values"; // Control Params public const string Minimum = "Minimum"; From e924738901b8e68d1806d133c537ab659c80ffca Mon Sep 17 00:00:00 2001 From: Sora Date: Wed, 16 Sep 2026 03:03:20 +0200 Subject: [PATCH 07/35] Inlined enum stuff through helper functions --- .../Configuration/NativeConfigTypeEmitter.cs | 38 ++++++-- .../Configuration/NativeModConfigSchema.cs | 2 +- .../Launcher/NativeModConfigTests.cs | 96 ++++++++++++++++++- 3 files changed, 127 insertions(+), 9 deletions(-) diff --git a/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/NativeConfigTypeEmitter.cs b/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/NativeConfigTypeEmitter.cs index a0c7bb10..7421a27d 100644 --- a/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/NativeConfigTypeEmitter.cs +++ b/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/NativeConfigTypeEmitter.cs @@ -55,7 +55,7 @@ private static Type BuildType(NativeConfigSchemaConfiguration configuration, str var enums = new Dictionary(StringComparer.OrdinalIgnoreCase); var displayCtor = GetCtor(typeof(DataAnnotations.DisplayAttribute), 0); var displayNameProperty = typeof(DataAnnotations.DisplayAttribute).GetProperty(nameof(DataAnnotations.DisplayAttribute.Name))!; - foreach (var schemaEnum in configuration.Enums) + foreach (var schemaEnum in CollectEnums(configuration)) { var enumBuilder = module.DefineEnum($"{typeBuilder.FullName}.{MakeIdentifier(schemaEnum.Name)}", TypeAttributes.Public, typeof(int)); for (int x = 0; x < schemaEnum.Members.Count; x++) @@ -106,6 +106,21 @@ private static Type BuildType(NativeConfigSchemaConfiguration configuration, str return typeBuilder.CreateType()!; } + /// + /// The enums declared by a configuration, properties with inline Values + /// + private static IEnumerable CollectEnums(NativeConfigSchemaConfiguration configuration) + { + foreach (var schemaEnum in configuration.Enums) + yield return schemaEnum; + + foreach (var property in configuration.Properties) + { + if (property.Values.Count > 0) + yield return new NativeConfigSchemaEnum() { Name = property.Name, Members = property.Values }; + } + } + private static (Type propertyType, object? defaultValue) ResolveTypeAndDefault(NativeConfigSchemaProperty property, Dictionary enums) { switch (property.Type) @@ -127,7 +142,13 @@ private static (Type propertyType, object? defaultValue) ResolveTypeAndDefault(N default: if (!enums.TryGetValue(property.Type, out var enumType)) - throw new InvalidOperationException($"Property '{property.Name}' has unknown Type '{property.Type}'. Declare an enum with this name under '{Keys.Enums}'."); + { + var hint = string.Equals(property.Type, "enum", StringComparison.OrdinalIgnoreCase) + ? $"Inline enums need a '{Keys.Values}' array on the property." + : $"Declare an enum with this name under '{Keys.Enums}'."; + + throw new InvalidOperationException($"Property '{property.Name}' has unknown Type '{property.Type}'. {hint}"); + } return (enumType, GetEnumDefault(property, enumType)); } @@ -206,14 +227,19 @@ private static IEnumerable BuildAttributes(NativeConfigS } // The default value backs the Reset button of the configuration dialog. - var boxedDefault = defaultValue == null && propertyType == typeof(string) ? "" : defaultValue; - var defaultValueCtor = typeof(DefaultValueAttribute).GetConstructor(new[] { typeof(object) })!; - yield return new CustomAttributeBuilder(defaultValueCtor, new[] { boxedDefault! }); + + // Enums are skipped + if (!propertyType.IsEnum) + { + var boxedDefault = defaultValue == null && propertyType == typeof(string) ? "" : defaultValue; + var defaultValueCtor = typeof(DefaultValueAttribute).GetConstructor(new[] { typeof(object) })!; + yield return new CustomAttributeBuilder(defaultValueCtor, new[] { boxedDefault! }); + } if (property.Slider != null) { var slider = property.Slider; - if (!propertyType.IsEnum && propertyType != typeof(int) && propertyType != typeof(float) && propertyType != typeof(double)) + if (propertyType != typeof(int) && propertyType != typeof(float) && propertyType != typeof(double)) throw new InvalidOperationException($"Property '{property.Name}': sliders are only supported for int, float and double properties."); var tickPlacement = Enum.TryParse(slider.TickPlacement, true, out var placement) ? placement : SliderControlTickPlacement.None; diff --git a/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/NativeModConfigSchema.cs b/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/NativeModConfigSchema.cs index b184d854..5f9e897e 100644 --- a/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/NativeModConfigSchema.cs +++ b/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/NativeModConfigSchema.cs @@ -290,7 +290,7 @@ public static NativeConfigSchemaProperty Parse(JsonNode node) } if (property.Values.Count > 0) - property.Type = property.Name; // inline enums borrow the property name. + property.Type = property.Name; // inline enums uses the property name. } if (property.Name.Length <= 0) diff --git a/source/Reloaded.Mod.Loader.Tests/Launcher/NativeModConfigTests.cs b/source/Reloaded.Mod.Loader.Tests/Launcher/NativeModConfigTests.cs index df2d14f8..8bf8f286 100644 --- a/source/Reloaded.Mod.Loader.Tests/Launcher/NativeModConfigTests.cs +++ b/source/Reloaded.Mod.Loader.Tests/Launcher/NativeModConfigTests.cs @@ -113,15 +113,18 @@ public void Generated_Properties_Carry_UI_Attributes() Assert.NotNull(slider); Assert.Equal(0.0, slider!.Minimum); Assert.Equal(100.0, slider.Maximum); - Assert.Equal(10, slider.TickFrequency); + Assert.Equal(10, slider.TickFrequencyDouble); var filePicker = type.GetProperty("FileSetting")!.GetCustomAttribute(); Assert.NotNull(filePicker); Assert.Equal("Text (*.txt)|*.txt", filePicker!.Filter); // Enum members support display names. - var enumType = type.GetProperty("EnumSetting")!.PropertyType; + var enumProperty = type.GetProperty("EnumSetting")!; + var enumType = enumProperty.PropertyType; Assert.True(enumType.IsEnum); + + Assert.Null(enumProperty.GetCustomAttribute()); var members = enumType.GetFields(BindingFlags.Public | BindingFlags.Static); Assert.Equal(2, members.Length); Assert.Equal("I Love It!!!", members[1].GetCustomAttribute()?.GetName()); @@ -205,6 +208,95 @@ public void Unknown_Type_Throws_Descriptive_Error() Assert.Contains("nosuchenum", error.Message, StringComparison.OrdinalIgnoreCase); } + [Fact] + public void Slider_On_Enum_Property_Throws() + { + File.WriteAllText(Path.Combine(ModDirectory, NativeModConfigSchema.SchemaFileName), """ + { + "Configurations": [ + { + "FileName": "Config.json", + "Enums": [ { "Name": "Mode", "Members": [ { "Name": "Fast" }, { "Name": "Slow" } ] } ], + "Properties": [ { "Name": "Speed", "Type": "Mode", "DefaultValue": "Fast", "Slider": { "Minimum": 0.0, "Maximum": 1.0 } } ] + }] + } + """); + var configurator = CreateConfigurator(); + + var error = Assert.Throws(() => configurator.GetConfigurations()); + Assert.Contains("sliders are only supported", error.Message); + } + + [Theory] + [InlineData("../evil.json")] + [InlineData("..\\evil.json")] + [InlineData("C:\\Windows\\Temp\\evil.json")] + [InlineData("SubFolder/Config.json")] + public void FileNames_With_Paths_Are_Rejected(string fileName) + { + File.WriteAllText(Path.Combine(ModDirectory, NativeModConfigSchema.SchemaFileName), $$""" + { "Configurations": [ { "FileName": "{{fileName.Replace("\\", "\\\\")}}", "Properties": [] } ] } + """); + + var error = Assert.Throws(() => NativeModConfigSchema.Load(ModDirectory)); + var jsonError = Assert.IsType(error.InnerException); + Assert.Contains("plain file name", jsonError.Message); + } + + [Fact] + public void TryMigrate_Reports_Failure_And_Keeps_Error() + { + var configurator = CreateConfigurator(); + + // A path with invalid characters makes creating the directory fail. + Assert.False(configurator.TryMigrate(ModDirectory, "C:\\\\")); + Assert.NotNull(configurator.MigrationError); + } + + [Fact] + public void Inline_Enum_Values_Build_A_Dropdown() + { + File.WriteAllText(Path.Combine(ModDirectory, NativeModConfigSchema.SchemaFileName), """ + { + "Configurations": [ + { + "FileName": "Config.json", + "Properties": [ + { + "Name": "Difficulty", + "Type": "enum", + "DefaultValue": "Hard", + "Values": [ "Easy", { "Name": "Hard", "DisplayName": "Very Hard" } ] + } + ] + }] + } + """); + var configurable = Assert.Single(CreateConfigurator().GetConfigurations()); + + var property = configurable.GetType().GetProperty("Difficulty")!; + var enumType = property.PropertyType; + Assert.True(enumType.IsEnum); + Assert.Equal("Hard", GetProperty(configurable, "Difficulty")!.ToString()); + + var members = enumType.GetFields(BindingFlags.Public | BindingFlags.Static); + Assert.Equal(2, members.Length); + Assert.Equal("Easy", members[0].Name); + Assert.Equal("Very Hard", members[1].GetCustomAttribute()?.GetName()); + } + + [Fact] + public void Enum_Type_Without_Values_Gives_Hint() + { + File.WriteAllText(Path.Combine(ModDirectory, NativeModConfigSchema.SchemaFileName), """ + { "Configurations": [ { "FileName": "Config.json", "Properties": [ { "Name": "Broken", "Type": "enum" } ] } ] } + """); + var configurator = CreateConfigurator(); + + var error = Assert.Throws(() => configurator.GetConfigurations()); + Assert.Contains("Values", error.Message); + } + private NativeModConfigurator CreateConfigurator() { var configurator = new NativeModConfigurator(ModDirectory); From 7308d6821d1b2987e72a8dd5807710b70a03dad1 Mon Sep 17 00:00:00 2001 From: Sora Date: Wed, 16 Sep 2026 19:45:01 +0200 Subject: [PATCH 08/35] Invoke and Migrate improvement stuff. --- .../Commands/Mod/ConfigureModCommand.cs | 5 +- .../Configuration/NativeModConfigurator.cs | 23 +++++++- .../Launcher/NativeModConfigTests.cs | 31 +++++++++- .../Mods/Structs/NativeMod.cs | 59 ++++++++++++++++++- 4 files changed, 109 insertions(+), 9 deletions(-) diff --git a/source/Reloaded.Mod.Launcher.Lib/Commands/Mod/ConfigureModCommand.cs b/source/Reloaded.Mod.Launcher.Lib/Commands/Mod/ConfigureModCommand.cs index ba9b2e31..846a1b07 100644 --- a/source/Reloaded.Mod.Launcher.Lib/Commands/Mod/ConfigureModCommand.cs +++ b/source/Reloaded.Mod.Launcher.Lib/Commands/Mod/ConfigureModCommand.cs @@ -86,9 +86,10 @@ private bool TryGetConfigurator(out IConfiguratorV1? configurator, out PluginLoa ? Path.GetFullPath(Path.GetDirectoryName(_modUserConfigTuple.Path)!) : ModUserConfig.GetUserConfigFolderForMod(_modTuple.Config.ModId); + if (!nativeConfigurator.TryMigrate(modDirectory, configDirectory)) + throw new InvalidOperationException($"Could not move the settings of '{_modTuple.Config.ModName}' from '{modDirectory}' to '{configDirectory}'.", nativeConfigurator.MigrationError); - if (nativeConfigurator.TryMigrate(modDirectory, configDirectory)) - nativeConfigurator.SetConfigDirectory(configDirectory); + nativeConfigurator.SetConfigDirectory(configDirectory); nativeConfigurator.SetContext(new ConfiguratorContext() { diff --git a/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/NativeModConfigurator.cs b/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/NativeModConfigurator.cs index c7c9388a..9fc2f5ba 100644 --- a/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/NativeModConfigurator.cs +++ b/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/NativeModConfigurator.cs @@ -59,13 +59,15 @@ public IConfigurable[] GetConfigurations() /// /// Moves value files left behind in an old directory over to a new one. - /// Returns false when the move failed; the reason is in . + /// On failure the files already moved return to + /// the old directory and the reason is in . /// /// The old mod config directory, usually the mod folder. /// The new mod config directory, usually the user config folder. public bool TryMigrate(string oldDirectory, string newDirectory) { MigrationError = null; + var moved = new List<(string OldPath, string NewPath)>(); try { var schema = NativeModConfigSchema.Load(_modDirectory); @@ -75,15 +77,32 @@ public bool TryMigrate(string oldDirectory, string newDirectory) var oldPath = Path.Combine(oldDirectory, configuration.FileName); var newPath = Path.Combine(newDirectory, configuration.FileName); if (File.Exists(oldPath) && !File.Exists(newPath)) + { File.Move(oldPath, newPath); + moved.Add((oldPath, newPath)); + } } return true; } catch (Exception e) { - // The caller keeps using the old directory when the move fails. MigrationError = e; + + // The caller keeps using the old directory, so put back what was moved. + for (int x = moved.Count - 1; x >= 0; x--) + { + try + { + if (!File.Exists(moved[x].OldPath)) + File.Move(moved[x].NewPath, moved[x].OldPath); + } + catch (Exception) + { + // MigrationError above holds the real cause. + } + } + return false; } } diff --git a/source/Reloaded.Mod.Loader.Tests/Launcher/NativeModConfigTests.cs b/source/Reloaded.Mod.Loader.Tests/Launcher/NativeModConfigTests.cs index 8bf8f286..2adef9c8 100644 --- a/source/Reloaded.Mod.Loader.Tests/Launcher/NativeModConfigTests.cs +++ b/source/Reloaded.Mod.Loader.Tests/Launcher/NativeModConfigTests.cs @@ -33,7 +33,7 @@ public class NativeModConfigTests : IDisposable { "Name": "EnumSetting", "Type": "SampleEnum", "DefaultValue": "ILoveIt" }, { "Name": "SliderSetting", "Type": "int", "DefaultValue": 100, "Order": 0, - "Slider": { "Minimum": 0.0, "Maximum": 100.0, "SmallChange": 1.0, "LargeChange": 10.0, "TickFrequency": 10, "ShowTextField": true } + "Slider": { "Minimum": 0.0, "Maximum": 100.0, "SmallChange": 1.0, "LargeChange": 10.0, "TickFrequency": 10, "TickFrequencyDouble": 2.5, "ShowTextField": true } }, { "Name": "FileSetting", "Type": "string", "DefaultValue": "", "FilePicker": { "Title": "Pick a file", "Filter": "Text (*.txt)|*.txt" } } ] @@ -113,7 +113,7 @@ public void Generated_Properties_Carry_UI_Attributes() Assert.NotNull(slider); Assert.Equal(0.0, slider!.Minimum); Assert.Equal(100.0, slider.Maximum); - Assert.Equal(10, slider.TickFrequencyDouble); + Assert.Equal(10, slider.TickFrequency); var filePicker = type.GetProperty("FileSetting")!.GetCustomAttribute(); Assert.NotNull(filePicker); @@ -253,6 +253,33 @@ public void TryMigrate_Reports_Failure_And_Keeps_Error() Assert.NotNull(configurator.MigrationError); } + [Fact] + public void TryMigrate_Rolls_Back_Moves_On_Failure() + { + // Two configs with values in the mod folder, the second move fails + // because a directory ends being where the file would land. + File.WriteAllText(Path.Combine(ModDirectory, NativeModConfigSchema.SchemaFileName), """ + { + "Configurations": [ + { "FileName": "First.json", "Properties": [] }, + { "FileName": "Second.json", "Properties": [] } + ] + } + """); + File.WriteAllText(Path.Combine(ModDirectory, "First.json"), "{ \"Value\": 1 }"); + File.WriteAllText(Path.Combine(ModDirectory, "Second.json"), "{ \"Value\": 2 }"); + Directory.CreateDirectory(Path.Combine(ConfigDirectory, "Second.json")); + + var configurator = CreateConfigurator(); + Assert.False(configurator.TryMigrate(ModDirectory, ConfigDirectory)); + Assert.NotNull(configurator.MigrationError); + + // The first file was moved before the failure: put it back in place. + Assert.True(File.Exists(Path.Combine(ModDirectory, "First.json"))); + Assert.True(File.Exists(Path.Combine(ModDirectory, "Second.json"))); + Assert.False(File.Exists(Path.Combine(ConfigDirectory, "First.json"))); + } + [Fact] public void Inline_Enum_Values_Build_A_Dropdown() { diff --git a/source/Reloaded.Mod.Loader/Mods/Structs/NativeMod.cs b/source/Reloaded.Mod.Loader/Mods/Structs/NativeMod.cs index 3d4c503a..ec2e9329 100644 --- a/source/Reloaded.Mod.Loader/Mods/Structs/NativeMod.cs +++ b/source/Reloaded.Mod.Loader/Mods/Structs/NativeMod.cs @@ -65,7 +65,7 @@ public void Start(IModLoaderV1 loader) if (_userConfigDirectory != null) Directory.CreateDirectory(_userConfigDirectory); - _startEx.Invoke(_modDirectory, _userConfigDirectory); + InvokeStartEx(); _started = true; } else if (_start != null) @@ -93,6 +93,33 @@ public void Start(IModLoaderV1 loader) public Action Disposing { get; } + /// + /// Call the ReloadedStartEx export, passing the mod its directories + /// through a versioned struct. + /// + private void InvokeStartEx() + { + var info = new NativeReloadedStartInfo() + { + ApiVersion = 1, + ModDirectory = Marshal.StringToHGlobalUni(_modDirectory), + UserConfigDirectory = Marshal.StringToHGlobalUni(_userConfigDirectory) + }; + + try + { + _startEx.Invoke(ref info); + } + finally + { + if (info.ModDirectory != IntPtr.Zero) + Marshal.FreeHGlobal(info.ModDirectory); + + if (info.UserConfigDirectory != IntPtr.Zero) + Marshal.FreeHGlobal(info.UserConfigDirectory); + } + } + // Utility Functions. private TDelegate GetDelegateForNativeFunction(IntPtr moduleHandle, string functionName) where TDelegate : Delegate { @@ -107,8 +134,8 @@ private TDelegate GetDelegateForNativeFunction(IntPtr moduleHandle, s // Delegates for native Reloaded Exports. private delegate void ReloadedStart(); - [UnmanagedFunctionPointer(CallingConvention.Cdecl, CharSet = CharSet.Unicode)] - private delegate void ReloadedStartEx(string modDirectory, string userConfigDirectory); + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + private delegate void ReloadedStartEx(ref NativeReloadedStartInfo info); private delegate void ReloadedSuspend(); private delegate void ReloadedResume(); @@ -116,6 +143,32 @@ private TDelegate GetDelegateForNativeFunction(IntPtr moduleHandle, s private delegate bool ReloadedCanUnload(); private delegate bool ReloadedCanSuspend(); + /// + /// Information handed to native mods exporting ReloadedStartEx. + /// The layout is append only, so new fields are only valid when + /// is high enough, meaning the struct stays a stable contract. + /// + [StructLayout(LayoutKind.Sequential)] + internal struct NativeReloadedStartInfo + { + /// + /// Version of the struct, starts at 1. + /// + public int ApiVersion; + + /// + /// Folder with the mod's own files (ConfigSchema.json, ...). Valid from version 1. + /// UTF-16 string, only valid for the duration of the call. + /// + public IntPtr ModDirectory; + + /// + /// Folder where the launcher stores the user settings. Valid from version 1. + /// UTF-16 string, only valid for the duration of the call. + /// + public IntPtr UserConfigDirectory; + } + #region Native Imports [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)] public static extern IntPtr LoadLibraryW(string lpFileName); From 9671e70088b38bd0d4d8826f27d185c1e9b20077 Mon Sep 17 00:00:00 2001 From: Sora Date: Thu, 17 Sep 2026 00:16:28 +0200 Subject: [PATCH 09/35] Reloaded Mod Config header: Add mutex for thread safe + API struct --- .../templates/native/ReloadedModConfig.h | 272 +++++++++++++++--- 1 file changed, 226 insertions(+), 46 deletions(-) diff --git a/source/Reloaded.Mod.Template/templates/native/ReloadedModConfig.h b/source/Reloaded.Mod.Template/templates/native/ReloadedModConfig.h index e5f8b67d..f9da3eee 100644 --- a/source/Reloaded.Mod.Template/templates/native/ReloadedModConfig.h +++ b/source/Reloaded.Mod.Template/templates/native/ReloadedModConfig.h @@ -37,12 +37,19 @@ #include #include #include +#include #include #include #include #include #include +#if defined(__cpp_lib_to_chars) && __cpp_lib_to_chars >= 201611L + #include +#else + #include +#endif + #ifndef RELOADED_MOD_CONFIG_DEFAULT_FILE #define RELOADED_MOD_CONFIG_DEFAULT_FILE L"Config.json" #endif @@ -362,15 +369,28 @@ namespace reloaded static bool parse_number(const std::string& s, size_t& pos, Json& out) { - const char* start = s.c_str() + pos; + const char* start = s.data() + pos; + const char* limit = s.data() + s.size(); + double value = 0.0; + +#if defined(__cpp_lib_to_chars) && __cpp_lib_to_chars >= 201611L + auto result = std::from_chars(start, limit, value); + if (result.ec != std::errc() && result.ec != std::errc::result_out_of_range) + return false; + + pos += (size_t)(result.ptr - start); +#else + static _locale_t c_locale = _create_locale(LC_NUMERIC, "C"); char* end = nullptr; - double value = strtod(start, &end); + value = _strtod_l(start, &end, c_locale); if (end == start) return false; + pos += (size_t)(end - start); +#endif + out.type = Type::Number; out.number = value; - pos += (size_t)(end - start); return true; } @@ -491,6 +511,43 @@ namespace reloaded return info; } + /* + ------------------------ + ReloadedStartEx contract + ------------------------ + */ + // Version of ReloadedStartInfo filled in by the loader. + #define RELOADED_START_INFO_VERSION 1 + + // Handed to ReloadedStartEx as a pointer, so the layout can grow over time. + // Fields are only valid when api_version is high enough; existing fields + // never move or change meaning, keeping the export a stable contract. + struct ReloadedStartInfo + { + unsigned int api_version; + + // v1: folder with the mod's own files (ConfigSchema.json, ...). + const wchar_t* mod_directory; + + // v1: folder where the launcher stores the user settings. + const wchar_t* user_config_directory; + }; + + // Copy what the loader passed into native_mod_info. + // Strings are only valid during the ReloadedStartEx call, so we duplicate them. + inline void store_start_info(const ReloadedStartInfo* info) + { + if (info == nullptr || info->api_version < 1) + return; + + auto& stored = native_mod_info(); + if (info->mod_directory != nullptr) + stored.mod_directory = info->mod_directory; + + if (info->user_config_directory != nullptr) + stored.config_directory = info->user_config_directory; + } + /* ----------- ModConfig @@ -504,16 +561,18 @@ namespace reloaded } // Directory of the mod itself. - const std::wstring& mod_directory() const + std::wstring mod_directory() const { resolve_paths(); + std::lock_guard guard(_lock); return _mod_directory; } // Directory where the launcher stores the values file. - const std::wstring& config_directory() const + std::wstring config_directory() const { resolve_paths(); + std::lock_guard guard(_lock); return _config_directory; } @@ -521,6 +580,7 @@ namespace reloaded std::wstring values_path() const { resolve_paths(); + std::lock_guard guard(_lock); return _config_directory + _values_file; } @@ -528,6 +588,7 @@ namespace reloaded std::wstring schema_path() const { resolve_paths(); + std::lock_guard guard(_lock); return _mod_directory + L"ConfigSchema.json"; } @@ -535,11 +596,15 @@ namespace reloaded bool load() { resolve_paths(); + + // Parse outside the lock; the file reads are the slow part. auto schema = Json::parse_file(schema_path()); + auto values = Json::parse_file(values_path()); + + std::lock_guard guard(_lock); if (schema) parse_defaults(*schema); - auto values = Json::parse_file(values_path()); if (!values) return false; @@ -551,7 +616,7 @@ namespace reloaded // True when the values file changed on disk since the last load. bool changed_on_disk() const { - resolve_paths(); + std::lock_guard guard(_lock); WIN32_FILE_ATTRIBUTE_DATA data; if (!GetFileAttributesExW(values_path().c_str(), GetFileExInfoStandard, &data)) return false; @@ -559,31 +624,40 @@ namespace reloaded return CompareFileTime(&data.ftLastWriteTime, &_write_time) != 0; } - // Starts a thread that reloads the config and calls the callback on change. + // Starts a thread that reloads the config and calls the callback when the + // values file changes. The OS wakes the thread, no polling involved. // Keep the returned thread; detach it or join it on unload. // Dropping it on the floor while it still runs kills the process. - [[nodiscard]] std::thread watch(const std::function& callback, int poll_ms = 500) + [[nodiscard]] std::thread watch(const std::function& callback) { - return std::thread([this, callback, poll_ms]() - { - while (!_stop_watching.load(std::memory_order_relaxed)) - { - Sleep((DWORD)poll_ms); - if (_stop_watching.load(std::memory_order_relaxed)) - break; - if (changed_on_disk()) - { - load(); - callback(*this); - } - } + HANDLE stop_event = GetOrCreateStopEvent(); + if (_stop_requested.load(std::memory_order_acquire) || stop_event == nullptr) + return std::thread([]() {}); // already stopped so hand back a finished thread + + return std::thread([this, callback, stop_event]() + { + WatchThread(callback, stop_event); }); } + // Wakes up any threads started by watch(); they exit soon after. void stop_watching() { - _stop_watching.store(true, std::memory_order_relaxed); + _stop_requested.store(true, std::memory_order_release); + + HANDLE stop_event = _stop_event.load(std::memory_order_acquire); + if (stop_event != nullptr) + SetEvent(stop_event); + } + + ~ModConfig() + { + stop_watching(); + + HANDLE stop_event = _stop_event.load(std::memory_order_acquire); + if (stop_event != nullptr) + CloseHandle(stop_event); } /* @@ -596,12 +670,14 @@ namespace reloaded bool has(const char* name) const { + std::lock_guard guard(_lock); const Json* value = find_value(name); return value != nullptr; } bool get_bool(const char* name, bool fallback) const { + std::lock_guard guard(_lock); const Json* value = find_value(name); if (value != nullptr && value->type == Json::Type::Bool) return value->boolean; @@ -615,6 +691,7 @@ namespace reloaded long long get_int(const char* name, long long fallback) const { + std::lock_guard guard(_lock); const Json* value = find_value(name); if (value != nullptr && value->type == Json::Type::Number) return (long long)value->number; @@ -628,6 +705,7 @@ namespace reloaded double get_float(const char* name, double fallback) const { + std::lock_guard guard(_lock); const Json* value = find_value(name); if (value != nullptr && value->type == Json::Type::Number) return value->number; @@ -642,6 +720,7 @@ namespace reloaded // Strings and enums are stored as UTF-8; enums return the member name. std::string get_string(const char* name, const char* fallback = "") const { + std::lock_guard guard(_lock); const Json* value = find_value(name); if (value != nullptr && value->type == Json::Type::String) return value->text; @@ -655,6 +734,7 @@ namespace reloaded std::wstring get_wstring(const char* name, const wchar_t* fallback = L"") const { + std::lock_guard guard(_lock); const Json* value = find_value(name); if (value != nullptr && value->type == Json::Type::String) return utf8_to_utf16(value->text); @@ -687,12 +767,16 @@ namespace reloaded Json _values; Json _schema_defaults; FILETIME _write_time = {}; - mutable std::atomic_bool _paths_resolved{ false }; - std::atomic_bool _stop_watching{ false }; + bool _paths_resolved = false; + std::atomic _stop_event{ nullptr }; + std::atomic_bool _stop_requested{ false }; + + mutable std::recursive_mutex _lock; void resolve_paths() const { - if (_paths_resolved.load(std::memory_order_relaxed)) + std::lock_guard guard(_lock); + if (_paths_resolved) return; // Cast away to keep the getters const; resolution happens at most once. @@ -711,7 +795,7 @@ namespace reloaded self->_config_directory = dll_directory; } - self->_paths_resolved.store(true, std::memory_order_relaxed); + self->_paths_resolved = true; } const Json* find_value(const char* name) const @@ -719,6 +803,110 @@ namespace reloaded return _values.find(name); } + // Body of the thread started by watch(), waits on OS change notifications. + void WatchThread(const std::function& callback, HANDLE stop_event) + { + std::wstring directory = config_directory(); + std::wstring file_name = _values_file; + + HANDLE directory_handle = CreateFileW(directory.c_str(), FILE_LIST_DIRECTORY, FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, nullptr, OPEN_EXISTING, FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OVERLAPPED, nullptr); + + if (directory_handle == INVALID_HANDLE_VALUE) + return; + + HANDLE change_event = CreateEventW(nullptr, TRUE, FALSE, nullptr); // manual reset + + if (change_event == nullptr) + { + CloseHandle(directory_handle); + return; + } + + OVERLAPPED overlapped = {}; + unsigned char buffer[64 * 1024]; + const DWORD filter = FILE_NOTIFY_CHANGE_FILE_NAME | FILE_NOTIFY_CHANGE_LAST_WRITE | FILE_NOTIFY_CHANGE_SIZE; + + while (true) + { + ResetEvent(change_event); + overlapped = {}; + overlapped.hEvent = change_event; + + if (!ReadDirectoryChangesW(directory_handle, buffer, sizeof(buffer), FALSE, filter, nullptr, &overlapped, nullptr)) + break; + + HANDLE handles[2] = { change_event, stop_event }; + DWORD wait = WaitForMultipleObjects(2, handles, FALSE, INFINITE); + if (wait != WAIT_OBJECT_0) + { + // Stopped, or something went wrong with the wait itself. + CancelIoEx(directory_handle, &overlapped); + DWORD unused = 0; + GetOverlappedResult(directory_handle, &overlapped, &unused, TRUE); + break; + } + + DWORD transferred = 0; + if (!GetOverlappedResult(directory_handle, &overlapped, &transferred, FALSE)) + break; + + if (transferred <= 0) + continue; // Buffer overflowed with too many changes, next round catches up. + + if (!IsValuesFileNotification(buffer, transferred, file_name.c_str())) + continue; + + // Give the writer a moment to finish, then load + // a partially written file is simply read again on the next event. + Sleep(50); + load(); + callback(*this); + } + + CloseHandle(change_event); + CloseHandle(directory_handle); + } + + + static bool IsValuesFileNotification(void* buffer, DWORD size, const wchar_t* file_name) + { + auto* record = (FILE_NOTIFY_INFORMATION*)buffer; + while (true) + { + std::wstring changed(record->FileName, record->FileNameLength / sizeof(wchar_t)); + bool is_our_file = _wcsicmp(changed.c_str(), file_name) == 0; + bool is_content = record->Action == FILE_ACTION_ADDED || record->Action == FILE_ACTION_MODIFIED || record->Action == FILE_ACTION_RENAMED_NEW_NAME; + if (is_our_file && is_content) + return true; + + if (record->NextEntryOffset == 0) + return false; + + record = (FILE_NOTIFY_INFORMATION*)((BYTE*)record + record->NextEntryOffset); + } + } + + HANDLE GetOrCreateStopEvent() + { + HANDLE existing = _stop_event.load(std::memory_order_acquire); + if (existing != nullptr) + return existing; + + HANDLE created = CreateEventW(nullptr, TRUE, FALSE, nullptr); + + if (created == nullptr) + return nullptr; + + HANDLE expected = nullptr; + if (!_stop_event.compare_exchange_strong(expected, created)) + { + CloseHandle(created); // Close if a thread race is happening + return expected; + } + + return created; + } + const Json* find_default(const char* name) const { return _schema_defaults.find(name); @@ -787,28 +975,20 @@ namespace reloaded Implement this macro in exactly one source file of the mod. FN is a function 'void FN()' called on start, with directories known and config loaded. */ -#define RELOADED_MOD_CONFIG_IMPL(FN) \ - extern "C" __declspec(dllexport) void ReloadedStartEx(const wchar_t* mod_directory, const wchar_t* user_config_directory) \ - { \ - auto& info = reloaded::native_mod_info(); \ - if (mod_directory != nullptr) \ - info.mod_directory = mod_directory; \ - if (user_config_directory != nullptr) \ - info.config_directory = user_config_directory; \ - reloaded::config().load(); \ - FN(); \ +#define RELOADED_MOD_CONFIG_IMPL(FN) \ + extern "C" __declspec(dllexport) void ReloadedStartEx(const reloaded::ReloadedStartInfo* info) \ + { \ + reloaded::store_start_info(info); \ + reloaded::config().load(); \ + FN(); \ } // Same as above but without a start callback, for mods driven by DllMain or other entry points. -#define RELOADED_MOD_CONFIG_IMPL_NO_START() \ - extern "C" __declspec(dllexport) void ReloadedStartEx(const wchar_t* mod_directory, const wchar_t* user_config_directory) \ - { \ - auto& info = reloaded::native_mod_info(); \ - if (mod_directory != nullptr) \ - info.mod_directory = mod_directory; \ - if (user_config_directory != nullptr) \ - info.config_directory = user_config_directory; \ - reloaded::config().load(); \ +#define RELOADED_MOD_CONFIG_IMPL_NO_START() \ + extern "C" __declspec(dllexport) void ReloadedStartEx(const reloaded::ReloadedStartInfo* info) \ + { \ + reloaded::store_start_info(info); \ + reloaded::config().load(); \ } #endif // RELOADED_MOD_CONFIG_H From 8db149dcc36e4221fae4353a4a1060fcc0295c9c Mon Sep 17 00:00:00 2001 From: Sora Date: Thu, 17 Sep 2026 01:10:43 +0200 Subject: [PATCH 10/35] Documentation fixes: formatting adjust + rework CMake to use env variable --- docs/NativeMods.md | 169 ++++++++++++------ .../templates/native/CMakeLists.txt | 19 +- .../templates/native/README.md | 28 ++- 3 files changed, 155 insertions(+), 61 deletions(-) diff --git a/docs/NativeMods.md b/docs/NativeMods.md index 86ac51db..35d6c281 100644 --- a/docs/NativeMods.md +++ b/docs/NativeMods.md @@ -15,9 +15,81 @@ You can control which file the mod loader will load for x64 and x86 processes us ``` To generate the config file, create a new mod from within the launcher. -## User Settings (Config Dialog) +## Exports + +**Entry Points:** + +Reloaded tries to start mods by using the following entry points in order: + +- [ReloadedStartEx][native-header] - `void fn(const ReloadedStartInfo* info)` +- ReloadedStart +- InitializeASI +- Init + +If none of these entry points is found, the mod will not be loaded. + +`ReloadedStartInfo` is an append only struct: `api_version` tells which fields +are filled in, and the mod's folders arrive as UTF-16 strings valid only +during the call. Use the helper header below if your mod reads its +configuration; the other entry points have no parameters and return `void`. + +**Suspend, Resume, Unload:** + +Reloaded II's *Resume*, *Suspend* and *Unload* functionalities are available for native mods. +Virtually identical to their C# counterparts in the `IMod` interface, they require the following exports: + +- ReloadedSuspend +- ReloadedResume +- ReloadedUnload +- ReloadedCanUnload +- ReloadedCanSuspend + +`CanUnload` and `CanSuspend` are defined as `bool fn()` while `Suspend`, `Resume' and 'Unload` are defined as `void fn()`. + +That said, if you are hooking/detouring functions **I would strongly advise against implementing these interfaces unless you know what you are doing.** + +Specifically, you will need to use a good hooking/detouring library that fully respects stacked function hooks. It must allow for hook deactivation in a way that avoids touching both your C++ DLL and overwriting the original prologue of the hooked function. + +Here is an example of how such a hooking library may be implemented: [Reloaded.Hooks](https://github.com/Reloaded-Project/Reloaded.Hooks/issues/2). + +## Languages + +### C/C++ + +#### Setup + +You need a C++17 compiler and CMake to build native mods: + +- Visual Studio 2022 (or newer) with the *Desktop development with C++* workload, + using the MSVC or Clang toolset. +- CMake 3.15 or newer, bundled with Visual Studio (or from [cmake.org](https://cmake.org)). + +Start from the template (`dotnet new reloaded-native`) or copy the files from +the [native mod template][native-template], it contains the mod manifest, a +sample configuration schema and `ReloadedModConfig.h`, the helper header. + +Build the DLL for your game's architecture: + +```text +cmake -B build -A x64 (64-bit game) +cmake -B build -A Win32 (32-bit game) +cmake --build build --config Release +``` + +No manual copy is needed, Reloaded sets the `RELOADEDIIMODS` +environment variable to your mods folder on first run, and the template's +CMake script deploys the DLL, `ModConfig.json` and `ConfigSchema.json` there +after each build. The mod then shows up in the launcher right away. + +#### User Settings (Config Dialog) -Native mods can expose settings in the launcher's *Configure* dialog without any C# code, through a declarative schema file. Place a `ConfigSchema.json` file next to your `ModConfig.json` describing your settings, and the launcher builds the same configuration UI used by C# mods: checkboxes, numeric boxes, sliders, dropdowns, file and folder pickers, with categories, tooltips and a Reset button. +Native mods can expose settings in the launcher's *Configure* dialog without +any C# code, through a declarative schema file. + +Place a `ConfigSchema.json` file next to your `ModConfig.json` describing your settings, and the launcher +builds the same configuration UI used by C# mods: checkboxes, numeric boxes, +sliders, dropdowns, file and folder pickers, with categories, tooltips and a +Reset button. A minimal schema looks like this: @@ -27,12 +99,6 @@ A minimal schema looks like this: { "FileName": "Config.json", "DisplayName": "Default Config", - "Enums": [ - { - "Name": "Quality", - "Members": [ { "Name": "Low" }, { "Name": "High", "DisplayName": "High Quality" } ] - } - ], "Properties": [ { "Name": "EnableThing", @@ -47,11 +113,27 @@ A minimal schema looks like this: "Name": "Volume", "Type": "int", "DefaultValue": 75, - "Slider": { "Minimum": 0.0, "Maximum": 100.0, "SmallChange": 1.0, "LargeChange": 10.0, "TickFrequency": 10, "ShowTextField": true } + "Slider": { + "Minimum": 0.0, "Maximum": 100.0, + "SmallChange": 1.0, "LargeChange": 10.0, + "TickFrequency": 10, "ShowTextField": true + } }, { "Name": "Brightness", "Type": "float", "DefaultValue": 1.5 }, - { "Name": "Quality", "Type": "Quality", "DefaultValue": "High" }, - { "Name": "CustomFile", "Type": "string", "FilePicker": { "Title": "Choose a File" } } + { + "Name": "Quality", + "Type": "enum", + "DefaultValue": "High", + "Values": [ + "Low", + { "Name": "High", "DisplayName": "High Quality" } + ] + }, + { + "Name": "CustomFile", + "Type": "string", + "FilePicker": { "Title": "Choose a File" } + } ] } ] @@ -60,10 +142,18 @@ A minimal schema looks like this: Notes: -- `Type` is one of `bool`, `int`, `float`, `double`, `string`, or the name of an entry in `Enums`. -- `DisplayName`, `Description`, `Category`, `Order` and `DefaultValue` mirror the attributes used by the C# mod template. -- `Slider`, `FilePicker` and `FolderPicker` mirror the `SliderControlParams`, `FilePickerParams` and `FolderPickerParams` attributes; all fields are optional. -- Each entry in `Configurations` becomes one page of the dialog, saved to its own file (`FileName`) inside the mod's user config folder (`User/Mods/`). Values missing from the file fall back to `DefaultValue`. +- `Type` is one of `bool`, `int`, `float`, `double`, `string`, or an enum. + Enums list their values inline under `Values`, or under a shared `Enums` + array when the same enum is used by several properties. +- `DisplayName`, `Description`, `Category`, `Order` and `DefaultValue` mirror + the attributes used by the C# mod template. +- `Slider`, `FilePicker` and `FolderPicker` mirror the `SliderControlParams`, + `FilePickerParams` and `FolderPickerParams` attributes, all fields are + optional. +- Each entry in `Configurations` becomes one page of the dialog, saved to its + own file (`FileName`) inside the mod's user config folder + (`User/Mods/`). Values missing from the file fall back to + `DefaultValue`. The values are saved as a flat JSON file such as: @@ -76,9 +166,13 @@ The values are saved as a flat JSON file such as: } ``` -### Reading the Settings from C/C++ +#### Reading the Settings -To read the settings inside your mod, copy `ReloadedModConfig.h` (from the [native mod template](https://github.com/Reloaded-Project/Reloaded-II/tree/master/source/Reloaded.Mod.Template/templates/native)) into your project and define `RELOADED_MOD_CONFIG_IMPL(your_start_function)` in exactly one source file. The macro exports `ReloadedStartEx`, which the loader calls with your mod's folders: +To read the settings inside your mod, copy `ReloadedModConfig.h` from the +[native mod template][native-template] +into your project and define `RELOADED_MOD_CONFIG_IMPL(your_start_function)` in +exactly one source file. The macro exports `ReloadedStartEx`, which the loader +calls with your mod's folders: ```cpp #include "ReloadedModConfig.h" @@ -98,41 +192,12 @@ static void my_start() RELOADED_MOD_CONFIG_IMPL(my_start) ``` -Missing values fall back to the schema defaults, then to the fallback argument. The header only needs the C++17 standard library (or later) and only work on Windows. - -## Exports - -**Entry Points:** - -Reloaded tries to start mods by using the following entry points in order: - -- ReloadedStartEx -- ReloadedStart -- InitializeASI -- Init - -If none of these entry points is found, the mod will not be loaded. - -`ReloadedStartEx` is defined as `void fn(const wchar_t* modDirectory, const wchar_t* userConfigDirectory)` and receives the mod's own folder (where `ConfigSchema.json` lives) and the folder where the launcher stores user settings. Use it (or the helper header above) if your mod reads its configuration. The other entry points should have no parameters and return `void`. - -**Suspend, Resume, Unload:** - -Reloaded II's *Resume*, *Suspend* and *Unload* functionalities are available for native mods. -Virtually identical to their C# counterparts in the `IMod` interface, they require the following exports: - -- ReloadedSuspend -- ReloadedResume -- ReloadedUnload -- ReloadedCanUnload -- ReloadedCanSuspend - -`CanUnload` and `CanSuspend` are defined as `bool fn()` while `Suspend`, `Resume' and 'Unload` are defined as `void fn()`. - -That said, if you are hooking/detouring functions **I would strongly advise against implementing these interfaces unless you know what you are doing.** - -Specifically, you will need to use a good hooking/detouring library that fully respects stacked function hooks. It must allow for hook deactivation in a way that avoids touching both your C++ DLL and overwriting the original prologue of the hooked function. - -Here is an example of how such a hooking library may be implemented: [Reloaded.Hooks](https://github.com/Reloaded-Project/Reloaded.Hooks/issues/2). +Missing values fall back to the schema defaults, then to the fallback +argument. `config.watch(callback)` reloads the settings when the user changes +them while the game is running. ## CoreRT/NativeAOT? Yes you can; mad scientist. + +[native-template]: https://github.com/Reloaded-Project/Reloaded-II/tree/master/source/Reloaded.Mod.Template/templates/native +[native-header]: https://github.com/Reloaded-Project/Reloaded-II/blob/master/source/Reloaded.Mod.Template/templates/native/ReloadedModConfig.h diff --git a/source/Reloaded.Mod.Template/templates/native/CMakeLists.txt b/source/Reloaded.Mod.Template/templates/native/CMakeLists.txt index 8fa31a6c..75f2666c 100644 --- a/source/Reloaded.Mod.Template/templates/native/CMakeLists.txt +++ b/source/Reloaded.Mod.Template/templates/native/CMakeLists.txt @@ -4,14 +4,25 @@ project(Reloaded.Native.Template LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) -add_library(Reloaded.Native.Template SHARED main.cpp) +add_library(${PROJECT_NAME} SHARED main.cpp) # Match the game's architecture: # 64-bit game: cmake -B build -A x64 # 32-bit game: cmake -B build -A Win32 -# then build and copy the DLL next to ModConfig.json. if (CMAKE_SIZEOF_VOID_P EQUAL 8) - set_target_properties(Reloaded.Native.Template PROPERTIES OUTPUT_NAME "Reloaded.Native.Template") + set_target_properties(${PROJECT_NAME} PROPERTIES OUTPUT_NAME "${PROJECT_NAME}") else() - set_target_properties(Reloaded.Native.Template PROPERTIES OUTPUT_NAME "Reloaded.Native.Template32") + set_target_properties(${PROJECT_NAME} PROPERTIES OUTPUT_NAME "${PROJECT_NAME}32") +endif() + + +if (DEFINED ENV{RELOADEDIIMODS}) + set(MOD_OUTPUT_DIR "$ENV{RELOADEDIIMODS}/${PROJECT_NAME}") + file(MAKE_DIRECTORY "${MOD_OUTPUT_DIR}") + + add_custom_command(TARGET ${PROJECT_NAME} POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_if_different "$" "${MOD_OUTPUT_DIR}/$" + COMMAND ${CMAKE_COMMAND} -E copy_if_different "${CMAKE_CURRENT_SOURCE_DIR}/ModConfig.json" "${MOD_OUTPUT_DIR}/ModConfig.json" + COMMAND ${CMAKE_COMMAND} -E copy_if_different "${CMAKE_CURRENT_SOURCE_DIR}/ConfigSchema.json" "${MOD_OUTPUT_DIR}/ConfigSchema.json" + COMMENT "Deploying mod to ${MOD_OUTPUT_DIR}") endif() diff --git a/source/Reloaded.Mod.Template/templates/native/README.md b/source/Reloaded.Mod.Template/templates/native/README.md index 71bfe811..0559e43a 100644 --- a/source/Reloaded.Mod.Template/templates/native/README.md +++ b/source/Reloaded.Mod.Template/templates/native/README.md @@ -13,19 +13,37 @@ with launcher-side configuration. No C# DLL required. | `main.cpp` | Entry point. | | `CMakeLists.txt` | Sample build script (MSVC or Clang; use `-A x64` or `-A Win32` to match the game). | +## Build + +Requires a C++17 compiler (MSVC or Clang via the Visual Studio *Desktop +development with C++* workload) and CMake 3.15+, bundled with Visual Studio. + +Build for your game's architecture: + +```text +cmake -B build -A x64 (64-bit game, makes Reloaded.Native.Template.dll) +cmake -B build -A Win32 (32-bit game, makes Reloaded.Native.Template32.dll) +cmake --build build --config Release +``` + +The Reloaded launcher sets the `RELOADEDIIMODS` environment variable to your +mods folder on first run; the CMake script deploys the DLL, `ModConfig.json` +and `ConfigSchema.json` there after each build, so the mod appears in the +launcher without manual copying. Without the variable the DLL is built into +`build/` and must be copied next to `ModConfig.json` by hand. + ## Workflow -1. Build your DLL and place it next to `ModConfig.json` (path set in `ModNativeDll32/64`). -2. Edit `ConfigSchema.json` to declare your settings. -3. Read the values in C++ through `reloaded::config()` (see `main.cpp`). -4. Users change the settings in the launcher; values are saved to +1. Edit `ConfigSchema.json` to declare your settings. +2. Read the values in C++ through `reloaded::config()` (see `main.cpp`). +3. Users change the settings in the launcher; values are saved to `/User/Mods//Config.json` and read by your mod. ## Entry Point The loader starts native mods by calling the first of these exports it finds: -- `ReloadedStartEx(const wchar_t* modDirectory, const wchar_t* userConfigDirectory)` (recommended; provided by `RELOADED_MOD_CONFIG_IMPL`) +- `ReloadedStartEx(const ReloadedStartInfo* info)` (recommended; provided by `RELOADED_MOD_CONFIG_IMPL`) - `ReloadedStart` - `InitializeASI` - `Init` From ef58379f538b2b4595354098dd7a7617bf014476 Mon Sep 17 00:00:00 2001 From: Sora Date: Fri, 18 Sep 2026 17:28:46 +0200 Subject: [PATCH 11/35] wrapper to the loader API --- docs/NativeMods.md | 11 +- .../Loader/NativeLoaderApiBridgeTests.cs | 108 ++++++++++++ .../Reloaded.Mod.Loader/Mods/PluginManager.cs | 10 +- .../Mods/Structs/NativeLoaderApiBridge.cs | 166 ++++++++++++++++++ .../Mods/Structs/NativeMod.cs | 54 +++++- .../templates/native/ReloadedModConfig.h | 119 +++++++++++-- 6 files changed, 435 insertions(+), 33 deletions(-) create mode 100644 source/Reloaded.Mod.Loader.Tests/Loader/NativeLoaderApiBridgeTests.cs create mode 100644 source/Reloaded.Mod.Loader/Mods/Structs/NativeLoaderApiBridge.cs diff --git a/docs/NativeMods.md b/docs/NativeMods.md index 35d6c281..cf86a31c 100644 --- a/docs/NativeMods.md +++ b/docs/NativeMods.md @@ -28,10 +28,13 @@ Reloaded tries to start mods by using the following entry points in order: If none of these entry points is found, the mod will not be loaded. -`ReloadedStartInfo` is an append only struct: `api_version` tells which fields -are filled in, and the mod's folders arrive as UTF-16 strings valid only -during the call. Use the helper header below if your mod reads its -configuration; the other entry points have no parameters and return `void`. +`ReloadedStartInfo` is a struct which contains: api_version, the mod's folders, the mod's +id and `ReloadedLoaderApi`, a wrapper around `IModLoader` usable to load, +unload and query other mods. + +The folders and the id are only valid during the call, so copy them if you need +them later. Strings returned by `ReloadedLoaderApi` stay valid past the call, +but the loader allocated them, so give them back to `free_string`. **Suspend, Resume, Unload:** diff --git a/source/Reloaded.Mod.Loader.Tests/Loader/NativeLoaderApiBridgeTests.cs b/source/Reloaded.Mod.Loader.Tests/Loader/NativeLoaderApiBridgeTests.cs new file mode 100644 index 00000000..aac16a10 --- /dev/null +++ b/source/Reloaded.Mod.Loader.Tests/Loader/NativeLoaderApiBridgeTests.cs @@ -0,0 +1,108 @@ +using System.Runtime.InteropServices; +using System.Text; +using Moq; +using Reloaded.Mod.Interfaces; +using Reloaded.Mod.Loader.Mods.Structs; + +namespace Reloaded.Mod.Loader.Tests.Loader; + +/// +/// Tests the native function pointer table wrapping the loader API. +/// The calls go through the raw pointers, exactly like a native mod would. +/// +public class NativeLoaderApiBridgeTests : IDisposable +{ + private readonly Mock _loader = new(); + private readonly NativeLoaderApiBridge _bridge; + + public NativeLoaderApiBridgeTests() + { + _bridge = new NativeLoaderApiBridge(_loader.Object); + } + + [Fact] + public void Table_Has_Version_And_Functions() + { + var table = ReadTable(); + Assert.Equal(1, table.ApiVersion); + Assert.NotEqual(IntPtr.Zero, table.LoadMod); + Assert.NotEqual(IntPtr.Zero, table.GetModConfigDirectory); + Assert.NotEqual(IntPtr.Zero, table.Log); + Assert.NotEqual(IntPtr.Zero, table.FreeString); + } + + [Fact] + public void GetModConfigDirectory_Returns_The_String() + { + _loader.Setup(l => l.GetModConfigDirectory("some.mod")).Returns(@"D:\User\Mods\SomeMod"); + + var getString = Marshal.GetDelegateForFunctionPointer(ReadTable().GetModConfigDirectory); + var pointer = getString(ToUtf8("some.mod")); + + // We own the memory, so it goes back through the table's free function. + Assert.Equal(@"D:\User\Mods\SomeMod", Marshal.PtrToStringUni(pointer)); + + var freeString = Marshal.GetDelegateForFunctionPointer(ReadTable().FreeString); + freeString(pointer); + } + + [Fact] + public void FreeString_Ignores_Null() + { + // Native mods may hand back whatever the getters returned, zero included. + var freeString = Marshal.GetDelegateForFunctionPointer(ReadTable().FreeString); + freeString(IntPtr.Zero); + } + + [Fact] + public void GetDirectoryForMod_Returns_Zero_Instead_Of_Throwing() + { + // Unknown mods throw inside the loader; native callers must never see that. + _loader.Setup(l => l.GetDirectoryForModId("nope.mod")).Throws(new KeyNotFoundException()); + + var getString = Marshal.GetDelegateForFunctionPointer(ReadTable().GetDirectoryForMod); + var result = getString(ToUtf8("nope.mod")); + + Assert.Equal(IntPtr.Zero, result); + } + + [Fact] + public void ModStateFunctions_Forward_To_Loader() + { + var loadMod = Marshal.GetDelegateForFunctionPointer(ReadTable().LoadMod); + var unloadMod = Marshal.GetDelegateForFunctionPointer(ReadTable().UnloadMod); + var suspendMod = Marshal.GetDelegateForFunctionPointer(ReadTable().SuspendMod); + var resumeMod = Marshal.GetDelegateForFunctionPointer(ReadTable().ResumeMod); + + loadMod(ToUtf8("some.mod")); + unloadMod(ToUtf8("some.mod")); + suspendMod(ToUtf8("some.mod")); + resumeMod(ToUtf8("some.mod")); + + _loader.Verify(l => l.LoadMod("some.mod"), Times.Once); + _loader.Verify(l => l.UnloadMod("some.mod"), Times.Once); + _loader.Verify(l => l.SuspendMod("some.mod"), Times.Once); + _loader.Verify(l => l.ResumeMod("some.mod"), Times.Once); + } + + [Fact] + public void Dispose_Releases_The_Table() + { + var pointer = _bridge.TablePointer; + _bridge.Dispose(); + Assert.Equal(IntPtr.Zero, _bridge.TablePointer); + } + + private NativeReloadedLoaderApiTable ReadTable() => Marshal.PtrToStructure(_bridge.TablePointer); + + private static IntPtr ToUtf8(string value) + { + var bytes = Encoding.UTF8.GetBytes(value); + var pointer = Marshal.AllocHGlobal(bytes.Length + 1); + Marshal.Copy(bytes, 0, pointer, bytes.Length); + Marshal.WriteByte(pointer, bytes.Length, 0); + return pointer; + } + + public void Dispose() => _bridge.Dispose(); +} diff --git a/source/Reloaded.Mod.Loader/Mods/PluginManager.cs b/source/Reloaded.Mod.Loader/Mods/PluginManager.cs index af37c31f..cebd2160 100644 --- a/source/Reloaded.Mod.Loader/Mods/PluginManager.cs +++ b/source/Reloaded.Mod.Loader/Mods/PluginManager.cs @@ -20,6 +20,7 @@ public class PluginManager : IDisposable private LoadContext _sharedContext; private readonly Loader _loader; + private readonly NativeLoaderApiBridge _nativeLoaderApi; /// /// Initializes the @@ -30,6 +31,9 @@ public PluginManager(Loader loader, LoadContext? sharedContext = null) { _loader = loader; LoaderApi = new LoaderAPI(_loader); + + // The native loader API table shared by all native mods. + _nativeLoaderApi = new NativeLoaderApiBridge(LoaderApi, _loader.Logger); _sharedContext = sharedContext ?? LoadContext.BuildSharedLoadContext(); } @@ -39,6 +43,8 @@ public void Dispose() { modification.Dispose(); } + + _nativeLoaderApi.Dispose(); } /// @@ -307,9 +313,9 @@ private ModInstance PrepareNativeMod(PathTuple tuple) _modIdToFolder[modId] = Path.GetFullPath(Path.GetDirectoryName(tuple.Path)!); - // Hand the mod its user config directory, needed for mods that read their configuration. + // Hand the mod its user config directory and the loader API table var userConfigDirectory = ModUserConfig.GetUserConfigFolderForMod(modId, _loader.LoaderConfig.GetModUserConfigDirectory()); - return new ModInstance(new NativeMod(dllPath, userConfigDirectory), tuple.Config); + return new ModInstance(new NativeMod(dllPath, userConfigDirectory, _nativeLoaderApi.TablePointer, modId), tuple.Config); } private ModInstance PrepareNonDllMod(PathTuple tuple) diff --git a/source/Reloaded.Mod.Loader/Mods/Structs/NativeLoaderApiBridge.cs b/source/Reloaded.Mod.Loader/Mods/Structs/NativeLoaderApiBridge.cs new file mode 100644 index 00000000..084e8d4a --- /dev/null +++ b/source/Reloaded.Mod.Loader/Mods/Structs/NativeLoaderApiBridge.cs @@ -0,0 +1,166 @@ +namespace Reloaded.Mod.Loader.Mods.Structs; + +/// +/// Layout of the loader API table handed for native mods, it must stay in sync +/// with ReloadedLoaderApi in ReloadedModConfig.h. +/// +[StructLayout(LayoutKind.Sequential)] +public struct NativeReloadedLoaderApiTable +{ + public int ApiVersion; + + public IntPtr LoadMod; + public IntPtr UnloadMod; + public IntPtr SuspendMod; + public IntPtr ResumeMod; + public IntPtr GetDirectoryForMod; + public IntPtr GetModConfigDirectory; + public IntPtr Log; + public IntPtr FreeString; +} + +/// +/// Wraps the managed into the native function pointer +/// table above. Shared by all mods. +/// +public sealed class NativeLoaderApiBridge : IDisposable +{ + private const int ApiVersion = 1; + + + // Native mods build as cdecl, which is the MSVC default, so every pointer in + // the table has to say so. Delegates default to stdcall instead, which would + // wreck the stack on 32 bit games. + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + public delegate void Utf8Action(IntPtr valueUtf8); + + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + public delegate IntPtr Utf8ToString(IntPtr valueUtf8); + + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + public delegate void FreeAction(IntPtr value); + + private readonly IModLoader _loader; + private readonly Logger _logger; + private IntPtr _tablePointer; + + + private readonly Utf8Action _loadMod; + private readonly Utf8Action _unloadMod; + private readonly Utf8Action _suspendMod; + private readonly Utf8Action _resumeMod; + private readonly Utf8ToString _getDirectoryForMod; + private readonly Utf8ToString _getModConfigDirectory; + private readonly Utf8Action _log; + private readonly FreeAction _freeString; + + /// + ///Wraps the loader and logger into a native API table. + /// + /// The loader API given to mods. + /// Logger writing to console and file; optional. + public NativeLoaderApiBridge(IModLoader loader, Logger logger = null) + { + _loader = loader; + _logger = logger; + + _loadMod = LoadMod; + _unloadMod = UnloadMod; + _suspendMod = SuspendMod; + _resumeMod = ResumeMod; + _getDirectoryForMod = GetDirectoryForMod; + _getModConfigDirectory = GetModConfigDirectory; + _log = Log; + _freeString = FreeString; + + var table = new NativeReloadedLoaderApiTable() + { + ApiVersion = ApiVersion, + LoadMod = Marshal.GetFunctionPointerForDelegate(_loadMod), + UnloadMod = Marshal.GetFunctionPointerForDelegate(_unloadMod), + SuspendMod = Marshal.GetFunctionPointerForDelegate(_suspendMod), + ResumeMod = Marshal.GetFunctionPointerForDelegate(_resumeMod), + GetDirectoryForMod = Marshal.GetFunctionPointerForDelegate(_getDirectoryForMod), + GetModConfigDirectory = Marshal.GetFunctionPointerForDelegate(_getModConfigDirectory), + Log = Marshal.GetFunctionPointerForDelegate(_log), + FreeString = Marshal.GetFunctionPointerForDelegate(_freeString) + }; + + _tablePointer = Marshal.AllocHGlobal(Marshal.SizeOf()); + Marshal.StructureToPtr(table, _tablePointer, fDeleteOld: false); + } + + /// + ///Pointer to the native table, placed inside ReloadedStartInfo + /// + public IntPtr TablePointer => _tablePointer; + + private void LoadMod(IntPtr modIdUtf8) + { + try { _loader.LoadMod(ReadUtf8(modIdUtf8)); } + catch (Exception e) { LogError(e, nameof(LoadMod)); } + } + + private void UnloadMod(IntPtr modIdUtf8) + { + try { _loader.UnloadMod(ReadUtf8(modIdUtf8)); } + catch (Exception e) { LogError(e, nameof(UnloadMod)); } + } + + private void SuspendMod(IntPtr modIdUtf8) + { + try { _loader.SuspendMod(ReadUtf8(modIdUtf8)); } + catch (Exception e) { LogError(e, nameof(SuspendMod)); } + } + + private void ResumeMod(IntPtr modIdUtf8) + { + try { _loader.ResumeMod(ReadUtf8(modIdUtf8)); } + catch (Exception e) { LogError(e, nameof(ResumeMod)); } + } + + private IntPtr GetDirectoryForMod(IntPtr modIdUtf8) + { + try { return Marshal.StringToHGlobalUni(_loader.GetDirectoryForModId(ReadUtf8(modIdUtf8))); } + catch (Exception e) { LogError(e, nameof(GetDirectoryForMod)); return IntPtr.Zero; } + } + + private IntPtr GetModConfigDirectory(IntPtr modIdUtf8) + { + try { return Marshal.StringToHGlobalUni(_loader.GetModConfigDirectory(ReadUtf8(modIdUtf8))); } + catch (Exception e) { LogError(e, nameof(GetModConfigDirectory)); return IntPtr.Zero; } + } + + private void Log(IntPtr textUtf8) + { + try { _logger?.WriteLine(ReadUtf8(textUtf8)); } + catch (Exception e) { LogError(e, nameof(Log)); } + } + + /// + /// Gives back a string handed out by the functions above. Mods can't free it + /// themselves, the memory comes from our side of the fence, not their CRT. + /// + private void FreeString(IntPtr value) + { + try + { + if (value != IntPtr.Zero) + Marshal.FreeHGlobal(value); + } + catch (Exception e) { LogError(e, nameof(FreeString)); } + } + + private void LogError(Exception e, string function) => _logger?.WriteLineAsync($"[NativeLoaderApi] {function} failed: {e.Message}"); + + private static string ReadUtf8(IntPtr pointer) => pointer == IntPtr.Zero ? string.Empty : Marshal.PtrToStringUTF8(pointer)!; + + public void Dispose() + { + if (_tablePointer == IntPtr.Zero) + return; + + Marshal.FreeHGlobal(_tablePointer); + _tablePointer = IntPtr.Zero; + } +} diff --git a/source/Reloaded.Mod.Loader/Mods/Structs/NativeMod.cs b/source/Reloaded.Mod.Loader/Mods/Structs/NativeMod.cs index ec2e9329..f1186737 100644 --- a/source/Reloaded.Mod.Loader/Mods/Structs/NativeMod.cs +++ b/source/Reloaded.Mod.Loader/Mods/Structs/NativeMod.cs @@ -24,20 +24,26 @@ public class NativeMod : IModV1 private bool _started; private string _modDirectory; private string _userConfigDirectory; + private string _modId; + private IntPtr _loaderApiTable; /// /// Creates an IMod wrapper for a native DLL. /// /// Path to the native DLL. /// Path to the directory where the mod's user configuration is stored, passed to mods exporting ReloadedStartEx. - public NativeMod(string path, string userConfigDirectory = null) + /// Pointer to the native loader API table shared by all mods, passed to mods exporting ReloadedStartEx. + /// Id of this mod, handed to the mod with the loader API. + public NativeMod(string path, string userConfigDirectory = null, IntPtr loaderApiTable = default, string modId = null) { _modDirectory = Path.GetDirectoryName(Path.GetFullPath(path))!; _userConfigDirectory = userConfigDirectory; + _modId = modId ?? string.Empty; + _loaderApiTable = loaderApiTable; // Set new DLL Directory, load library and restore. // This could probably be better optimised but isn't a hot path, would rather save on memory, so it's no big deal. - var builder = new StringBuilder(4096); // ought to be enough characters given most programs break at 260 anyway. + var builder = new StringBuilder(4096); // ought to be enough characters given most programs break at 260 anyway. GetDllDirectoryW(builder.Length, builder); SetDllDirectoryW(Path.GetDirectoryName(path)); _moduleHandle = LoadLibraryW(path); @@ -94,8 +100,8 @@ public void Start(IModLoaderV1 loader) public Action Disposing { get; } /// - /// Call the ReloadedStartEx export, passing the mod its directories - /// through a versioned struct. + /// Call the ReloadedStartEx export, passing the mod its directories and the + /// loader API through a versioned struct. /// private void InvokeStartEx() { @@ -103,7 +109,9 @@ private void InvokeStartEx() { ApiVersion = 1, ModDirectory = Marshal.StringToHGlobalUni(_modDirectory), - UserConfigDirectory = Marshal.StringToHGlobalUni(_userConfigDirectory) + UserConfigDirectory = Marshal.StringToHGlobalUni(_userConfigDirectory), + ModId = StringToHGlobalUTF8(_modId), + LoaderApi = _loaderApiTable }; try @@ -117,6 +125,9 @@ private void InvokeStartEx() if (info.UserConfigDirectory != IntPtr.Zero) Marshal.FreeHGlobal(info.UserConfigDirectory); + + if (info.ModId != IntPtr.Zero) + Marshal.FreeHGlobal(info.ModId); } } @@ -127,6 +138,18 @@ private TDelegate GetDelegateForNativeFunction(IntPtr moduleHandle, s return address != IntPtr.Zero ? Marshal.GetDelegateForFunctionPointer(address) : null; } + /// + /// Copies a string to unmanaged memory as UTF-8; free with . + /// + private static IntPtr StringToHGlobalUTF8(string value) + { + var bytes = Encoding.UTF8.GetBytes(value); + var pointer = Marshal.AllocHGlobal(bytes.Length + 1); + Marshal.Copy(bytes, 0, pointer, bytes.Length); + Marshal.WriteByte(pointer, bytes.Length, 0); + return pointer; + } + // Delegates for native Other Exports. private delegate void InitializeASI(); private delegate void Init(); @@ -145,8 +168,8 @@ private TDelegate GetDelegateForNativeFunction(IntPtr moduleHandle, s /// /// Information handed to native mods exporting ReloadedStartEx. - /// The layout is append only, so new fields are only valid when - /// is high enough, meaning the struct stays a stable contract. + /// New fields are only valid when is high enough, + /// (this is to make sure the struct stays a stable contract). /// [StructLayout(LayoutKind.Sequential)] internal struct NativeReloadedStartInfo @@ -157,16 +180,29 @@ internal struct NativeReloadedStartInfo public int ApiVersion; /// - /// Folder with the mod's own files (ConfigSchema.json, ...). Valid from version 1. + /// Folder with the mod's own files (ConfigSchema.json, ...). /// UTF-16 string, only valid for the duration of the call. /// public IntPtr ModDirectory; /// - /// Folder where the launcher stores the user settings. Valid from version 1. + /// Folder where the launcher stores the user settings. /// UTF-16 string, only valid for the duration of the call. /// public IntPtr UserConfigDirectory; + + /// + /// Id of the mod being started. + /// UTF-8 string, only valid for the duration of the call. + /// + public IntPtr ModId; + + /// + /// Wrapper around the loader API (), usable to load, + /// unload and query other mods. Stays valid past the call, + /// for the lifetime of the mod. + /// + public IntPtr LoaderApi; } #region Native Imports diff --git a/source/Reloaded.Mod.Template/templates/native/ReloadedModConfig.h b/source/Reloaded.Mod.Template/templates/native/ReloadedModConfig.h index f9da3eee..3e1ce11a 100644 --- a/source/Reloaded.Mod.Template/templates/native/ReloadedModConfig.h +++ b/source/Reloaded.Mod.Template/templates/native/ReloadedModConfig.h @@ -498,10 +498,14 @@ namespace reloaded Mod startup information --------------------- */ + struct ReloadedLoaderApi; + struct NativeModInfo { std::wstring mod_directory; // Folder with the mod's own files (ConfigSchema.json, ...). std::wstring config_directory; // Folder where the launcher writes user settings. + std::string mod_id; + ReloadedLoaderApi* loader = nullptr; }; // Filled by the ReloadedStartEx export; safe to read after mod start. @@ -519,6 +523,27 @@ namespace reloaded // Version of ReloadedStartInfo filled in by the loader. #define RELOADED_START_INFO_VERSION 1 + // Wrapper around the loader's IModLoader interface, handed to native mods. + // New functions are only valid when api_version + // is high enough, so it stays a stable contract. + // The returned strings are UTF-16 and belong to the loader, the memory does not + // come from your CRT, so give them back to free_string once you are done. + struct ReloadedLoaderApi + { + unsigned int api_version; + + // We use cdecl since the loader hands out cdecl pointers and a + // mod built with /Gz would otherwise read them as stdcall on 32 bit. + void (__cdecl *load_mod)(const char* mod_id); + void (__cdecl *unload_mod)(const char* mod_id); + void (__cdecl *suspend_mod)(const char* mod_id); + void (__cdecl *resume_mod)(const char* mod_id); + wchar_t* (__cdecl *get_directory_for_mod)(const char* mod_id); + wchar_t* (__cdecl *get_mod_config_directory)(const char* mod_id); + void (__cdecl *log)(const char* text); + void (__cdecl *free_string)(wchar_t* value); + }; + // Handed to ReloadedStartEx as a pointer, so the layout can grow over time. // Fields are only valid when api_version is high enough; existing fields // never move or change meaning, keeping the export a stable contract. @@ -526,15 +551,21 @@ namespace reloaded { unsigned int api_version; - // v1: folder with the mod's own files (ConfigSchema.json, ...). + // Folder with the mod's own files (ConfigSchema.json, ...). const wchar_t* mod_directory; - // v1: folder where the launcher stores the user settings. + // Folder where the launcher stores the user settings. const wchar_t* user_config_directory; + + // Id of the mod being started, UTF-8. + const char* mod_id; + + // The loader API wrapper, valid for the lifetime of the mod. + ReloadedLoaderApi* loader; }; - // Copy what the loader passed into native_mod_info. - // Strings are only valid during the ReloadedStartEx call, so we duplicate them. + // Copies what the loader passed into native_mod_info(). + // The start info strings are only valid during the ReloadedStartEx call, so they are duplicated. inline void store_start_info(const ReloadedStartInfo* info) { if (info == nullptr || info->api_version < 1) @@ -546,6 +577,34 @@ namespace reloaded if (info->user_config_directory != nullptr) stored.config_directory = info->user_config_directory; + + if (info->mod_id != nullptr) + stored.mod_id = info->mod_id; // already UTF-8, matching the loader API. + + stored.loader = info->loader; + } + + // The loader API wrapper handed to this mod, or null on older loaders. + inline ReloadedLoaderApi* loader() + { + return native_mod_info().loader; + } + + // Writes to the Reloaded log when the loader API is available. + inline void log(const char* text) + { + ReloadedLoaderApi* api = loader(); + if (api != nullptr && api->api_version >= 1 && api->log != nullptr) + api->log(text); + } + + // Give a string from the loader API back to the loader, it allocated it and + // is the only one that can free it. + inline void free_string(wchar_t* value) + { + ReloadedLoaderApi* api = loader(); + if (value != nullptr && api != nullptr && api->api_version >= 1 && api->free_string != nullptr) + api->free_string(value); } /* @@ -782,19 +841,43 @@ namespace reloaded // Cast away to keep the getters const; resolution happens at most once. auto* self = const_cast(this); const NativeModInfo& info = native_mod_info(); - if (!info.mod_directory.empty()) + + if (info.loader != nullptr && info.loader->api_version >= 1 && !info.mod_id.empty()) + { + wchar_t* mod_directory = info.loader->get_directory_for_mod(info.mod_id.c_str()); + wchar_t* config_directory = info.loader->get_mod_config_directory(info.mod_id.c_str()); + + if (mod_directory != nullptr) + { + self->_mod_directory = with_trailing_separator(mod_directory); + free_string(mod_directory); + } + + if (config_directory != nullptr) + { + self->_config_directory = with_trailing_separator(config_directory); + free_string(config_directory); + } + } + + // Fallback: the folders copied into the start info. + if (self->_mod_directory.empty() && !info.mod_directory.empty()) { self->_mod_directory = with_trailing_separator(info.mod_directory); self->_config_directory = with_trailing_separator(info.config_directory.empty() ? info.mod_directory : info.config_directory); } - else + + // Last attempt, loaded by another injector: assume the values live next to the DLL. + if (self->_mod_directory.empty()) { - // Loaded by an older loader or another injector: assume the values live next to the DLL. const std::wstring& dll_directory = this_module_directory(); self->_mod_directory = dll_directory; self->_config_directory = dll_directory; } + if (self->_config_directory.empty()) + self->_config_directory = self->_mod_directory; + self->_paths_resolved = true; } @@ -975,20 +1058,20 @@ namespace reloaded Implement this macro in exactly one source file of the mod. FN is a function 'void FN()' called on start, with directories known and config loaded. */ -#define RELOADED_MOD_CONFIG_IMPL(FN) \ - extern "C" __declspec(dllexport) void ReloadedStartEx(const reloaded::ReloadedStartInfo* info) \ - { \ - reloaded::store_start_info(info); \ - reloaded::config().load(); \ - FN(); \ +#define RELOADED_MOD_CONFIG_IMPL(FN) \ + extern "C" __declspec(dllexport) void __cdecl ReloadedStartEx(const reloaded::ReloadedStartInfo* info) \ + { \ + reloaded::store_start_info(info); \ + reloaded::config().load(); \ + FN(); \ } // Same as above but without a start callback, for mods driven by DllMain or other entry points. -#define RELOADED_MOD_CONFIG_IMPL_NO_START() \ - extern "C" __declspec(dllexport) void ReloadedStartEx(const reloaded::ReloadedStartInfo* info) \ - { \ - reloaded::store_start_info(info); \ - reloaded::config().load(); \ +#define RELOADED_MOD_CONFIG_IMPL_NO_START() \ + extern "C" __declspec(dllexport) void __cdecl ReloadedStartEx(const reloaded::ReloadedStartInfo* info) \ + { \ + reloaded::store_start_info(info); \ + reloaded::config().load(); \ } #endif // RELOADED_MOD_CONFIG_H From 3b0573ee9f63486aea628dccba9fb10a32c205ed Mon Sep 17 00:00:00 2001 From: Sewer56 Date: Sun, 20 Sep 2026 20:39:53 +0100 Subject: [PATCH 12/35] Changed: Improve/shorten NativeMods docs --- docs/NativeMods.md | 53 ++++++++++++++++++---------------------------- 1 file changed, 21 insertions(+), 32 deletions(-) diff --git a/docs/NativeMods.md b/docs/NativeMods.md index cf86a31c..4ec30955 100644 --- a/docs/NativeMods.md +++ b/docs/NativeMods.md @@ -28,13 +28,11 @@ Reloaded tries to start mods by using the following entry points in order: If none of these entry points is found, the mod will not be loaded. -`ReloadedStartInfo` is a struct which contains: api_version, the mod's folders, the mod's -id and `ReloadedLoaderApi`, a wrapper around `IModLoader` usable to load, -unload and query other mods. +In the case of `ReloadedStartInfo`, it provides a wrapper around the API that's +usually provided to .NET mods (`IModLoader`). -The folders and the id are only valid during the call, so copy them if you need -them later. Strings returned by `ReloadedLoaderApi` stay valid past the call, -but the loader allocated them, so give them back to `free_string`. +After calling any API that returns strings, you will need to call `free_string` +afterwards. **Suspend, Resume, Unload:** @@ -68,8 +66,7 @@ You need a C++17 compiler and CMake to build native mods: - CMake 3.15 or newer, bundled with Visual Studio (or from [cmake.org](https://cmake.org)). Start from the template (`dotnet new reloaded-native`) or copy the files from -the [native mod template][native-template], it contains the mod manifest, a -sample configuration schema and `ReloadedModConfig.h`, the helper header. +the [native mod template][native-template]. Build the DLL for your game's architecture: @@ -79,22 +76,19 @@ cmake -B build -A Win32 (32-bit game) cmake --build build --config Release ``` -No manual copy is needed, Reloaded sets the `RELOADEDIIMODS` -environment variable to your mods folder on first run, and the template's -CMake script deploys the DLL, `ModConfig.json` and `ConfigSchema.json` there -after each build. The mod then shows up in the launcher right away. +Upon building, the mod will automatically be copied to the right location +and show up in Reloaded-II. -#### User Settings (Config Dialog) +## Mod Configuration + +### User Settings (Config Dialog) -Native mods can expose settings in the launcher's *Configure* dialog without -any C# code, through a declarative schema file. +The Reloaded-II launcher exposes a *Configure* dialog for native mods if the +`ConfigSchema.json` file exists next to `ModConfig.json`. -Place a `ConfigSchema.json` file next to your `ModConfig.json` describing your settings, and the launcher -builds the same configuration UI used by C# mods: checkboxes, numeric boxes, -sliders, dropdowns, file and folder pickers, with categories, tooltips and a -Reset button. +The declarative schema file supports all features supported by the .NET equivalent. -A minimal schema looks like this: +Example: ```json { @@ -143,9 +137,7 @@ A minimal schema looks like this: } ``` -Notes: - -- `Type` is one of `bool`, `int`, `float`, `double`, `string`, or an enum. +- `Type` is `bool`, `int`, `float`, `double`, `string`, or an enum. Enums list their values inline under `Values`, or under a shared `Enums` array when the same enum is used by several properties. - `DisplayName`, `Description`, `Category`, `Order` and `DefaultValue` mirror @@ -169,13 +161,13 @@ The values are saved as a flat JSON file such as: } ``` -#### Reading the Settings +### Reading the Settings -To read the settings inside your mod, copy `ReloadedModConfig.h` from the -[native mod template][native-template] -into your project and define `RELOADED_MOD_CONFIG_IMPL(your_start_function)` in -exactly one source file. The macro exports `ReloadedStartEx`, which the loader -calls with your mod's folders: +#### C++ + +Using `ReloadedModConfig.h` from the [native mod template][native-template], +define `RELOADED_MOD_CONFIG_IMPL(your_start_function)` in +exactly one source file: ```cpp #include "ReloadedModConfig.h" @@ -199,8 +191,5 @@ Missing values fall back to the schema defaults, then to the fallback argument. `config.watch(callback)` reloads the settings when the user changes them while the game is running. -## CoreRT/NativeAOT? -Yes you can; mad scientist. - [native-template]: https://github.com/Reloaded-Project/Reloaded-II/tree/master/source/Reloaded.Mod.Template/templates/native [native-header]: https://github.com/Reloaded-Project/Reloaded-II/blob/master/source/Reloaded.Mod.Template/templates/native/ReloadedModConfig.h From da26497239223b6ad9125061e7498b1c0cf7563a Mon Sep 17 00:00:00 2001 From: Sewer56 Date: Sun, 20 Sep 2026 20:46:24 +0100 Subject: [PATCH 13/35] Changed: Shorten native mod template README - Replace deploy explanation with one-liner matching NativeMods docs - Drop Entry Point section; covered by main.cpp and docs/NativeMods.md - File shrunk from 54 to 38 lines --- .../templates/native/README.md | 21 ++----------------- 1 file changed, 2 insertions(+), 19 deletions(-) diff --git a/source/Reloaded.Mod.Template/templates/native/README.md b/source/Reloaded.Mod.Template/templates/native/README.md index 0559e43a..c0b00f44 100644 --- a/source/Reloaded.Mod.Template/templates/native/README.md +++ b/source/Reloaded.Mod.Template/templates/native/README.md @@ -26,11 +26,8 @@ cmake -B build -A Win32 (32-bit game, makes Reloaded.Native.Template32.dll) cmake --build build --config Release ``` -The Reloaded launcher sets the `RELOADEDIIMODS` environment variable to your -mods folder on first run; the CMake script deploys the DLL, `ModConfig.json` -and `ConfigSchema.json` there after each build, so the mod appears in the -launcher without manual copying. Without the variable the DLL is built into -`build/` and must be copied next to `ModConfig.json` by hand. +Upon building, the mod will automatically be copied to the right location +and show up in Reloaded-II. ## Workflow @@ -38,17 +35,3 @@ launcher without manual copying. Without the variable the DLL is built into 2. Read the values in C++ through `reloaded::config()` (see `main.cpp`). 3. Users change the settings in the launcher; values are saved to `/User/Mods//Config.json` and read by your mod. - -## Entry Point - -The loader starts native mods by calling the first of these exports it finds: - -- `ReloadedStartEx(const ReloadedStartInfo* info)` (recommended; provided by `RELOADED_MOD_CONFIG_IMPL`) -- `ReloadedStart` -- `InitializeASI` -- `Init` - -`ReloadedStartEx` receives the mod and user-config directories, which is how the -helper finds the schema and the values file. - -See the wiki page "Writing Native Mods" for the optional suspend/resume/unload exports. From 90552506e2ba4dc1c3a1776312214bd2b6f114ca Mon Sep 17 00:00:00 2001 From: Sewer56 Date: Sun, 20 Sep 2026 20:47:19 +0100 Subject: [PATCH 14/35] Changed: Remove unused usings from native config tests - Drop 7 unused using directives across 2 test files --- .../Launcher/NativeModConfigTests.cs | 3 --- .../Loader/NativeLoaderApiBridgeTests.cs | 4 ---- 2 files changed, 7 deletions(-) diff --git a/source/Reloaded.Mod.Loader.Tests/Launcher/NativeModConfigTests.cs b/source/Reloaded.Mod.Loader.Tests/Launcher/NativeModConfigTests.cs index 2adef9c8..d55e5173 100644 --- a/source/Reloaded.Mod.Loader.Tests/Launcher/NativeModConfigTests.cs +++ b/source/Reloaded.Mod.Loader.Tests/Launcher/NativeModConfigTests.cs @@ -1,10 +1,7 @@ -using System.ComponentModel; using System.ComponentModel.DataAnnotations; using System.Reflection; using System.Text.Json.Nodes; using Reloaded.Mod.Launcher.Lib.Models.Model.Configuration; -using Reloaded.Mod.Interfaces.Structs; -using Reloaded.Mod.Interfaces; namespace Reloaded.Mod.Loader.Tests.Launcher; diff --git a/source/Reloaded.Mod.Loader.Tests/Loader/NativeLoaderApiBridgeTests.cs b/source/Reloaded.Mod.Loader.Tests/Loader/NativeLoaderApiBridgeTests.cs index aac16a10..597640d0 100644 --- a/source/Reloaded.Mod.Loader.Tests/Loader/NativeLoaderApiBridgeTests.cs +++ b/source/Reloaded.Mod.Loader.Tests/Loader/NativeLoaderApiBridgeTests.cs @@ -1,8 +1,4 @@ using System.Runtime.InteropServices; -using System.Text; -using Moq; -using Reloaded.Mod.Interfaces; -using Reloaded.Mod.Loader.Mods.Structs; namespace Reloaded.Mod.Loader.Tests.Loader; From 98c866eff69645e81834e592563de8468507b371 Mon Sep 17 00:00:00 2001 From: Sewer56 Date: Sun, 20 Sep 2026 21:55:49 +0100 Subject: [PATCH 15/35] Changed: Polish native config docs and clear new-code warnings - Convert doc comment enumerations to plain bullet lists. - Document all public schema members: 7 Parse methods, 34 control properties. - Document Load/CreateInstance error paths and the JsonException cases. - Fix 92 CS1591 missing-doc and 16 CS86xx nullability warnings. - Fix 3 missing test usings that broke the test build (CS0246). - Make SupportedTypes internal and null-guard JsonNode lookups. --- .../Configuration/NativeConfigTypeEmitter.cs | 35 +-- .../Configuration/NativeConfigurableBase.cs | 2 +- .../Configuration/NativeModConfigSchema.cs | 245 ++++++++++++++++-- .../Launcher/NativeModConfigTests.cs | 8 +- .../Loader/NativeLoaderApiBridgeTests.cs | 1 + 5 files changed, 248 insertions(+), 43 deletions(-) diff --git a/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/NativeConfigTypeEmitter.cs b/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/NativeConfigTypeEmitter.cs index 7421a27d..3b4cca20 100644 --- a/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/NativeConfigTypeEmitter.cs +++ b/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/NativeConfigTypeEmitter.cs @@ -5,16 +5,17 @@ namespace Reloaded.Mod.Launcher.Lib.Models.Model.Configuration; /// -/// Builds .NET types out of native mod configuration schemas using Reflection.Emit. -/// The generated types subclass and carry the same -/// attributes as a hand written C# configuration class: -/// -/// , , -/// (backs the Reset button of the dialog) -/// Display (sort order) -/// SliderControlParams, FilePickerParams, FolderPickerParams (custom editors) -/// -/// This way the launcher's PropertyGrid renders them exactly like the configuration of a C# mod. +/// Builds .NET types from native mod configuration schemas using +/// Reflection.Emit. +/// The generated types subclass and carry +/// the same attributes as a hand written C# configuration class: +/// - , , +/// +/// - (backs the Reset button of the dialog) +/// - Display (sort order) +/// - SliderControlParams, FilePickerParams, +/// FolderPickerParams (custom editors) +/// The PropertyGrid renders them exactly like a C# mod's configuration. /// public static class NativeConfigTypeEmitter { @@ -30,12 +31,16 @@ public static class NativeConfigTypeEmitter /// /// The configuration to build a type for. /// Unique key identifying the (version of the) configuration. + /// + /// Thrown when a property's Type is unknown, or a control does not match + /// the property type. + /// public static NativeConfigurableBase CreateInstance(NativeConfigSchemaConfiguration configuration, string cacheKey) { Type type; lock (BuildLock) { - if (!TypeCache.TryGetValue(cacheKey, out type)) + if (!TypeCache.TryGetValue(cacheKey, out type!)) { type = BuildType(configuration, cacheKey); TypeCache[cacheKey] = type; @@ -107,7 +112,7 @@ private static Type BuildType(NativeConfigSchemaConfiguration configuration, str } /// - /// The enums declared by a configuration, properties with inline Values + /// Declared enums plus one per property with inline Values. /// private static IEnumerable CollectEnums(NativeConfigSchemaConfiguration configuration) { @@ -227,7 +232,7 @@ private static IEnumerable BuildAttributes(NativeConfigS } // The default value backs the Reset button of the configuration dialog. - + // Enums are skipped if (!propertyType.IsEnum) { @@ -260,7 +265,7 @@ private static IEnumerable BuildAttributes(NativeConfigS yield return new CustomAttributeBuilder(GetCtor(typeof(FilePickerParamsAttribute), 13), new object[] { - file.InitialDirectory, (System.Environment.SpecialFolder)file.InitialFolderPath, + file.InitialDirectory!, (System.Environment.SpecialFolder)file.InitialFolderPath, file.ChooseFileButtonLabel, file.UserCanEditPathText, file.Title, file.Filter, file.FilterIndex, file.Multiselect, file.SupportMultiDottedExtensions, file.ShowHiddenFiles, file.ShowPreview, file.RestoreDirectory, file.AddToRecent @@ -275,7 +280,7 @@ private static IEnumerable BuildAttributes(NativeConfigS yield return new CustomAttributeBuilder(GetCtor(typeof(FolderPickerParamsAttribute), 9), new object[] { - folder.InitialDirectory, (System.Environment.SpecialFolder)folder.InitialFolderPath, + folder.InitialDirectory!, (System.Environment.SpecialFolder)folder.InitialFolderPath, folder.ChooseFolderButtonLabel, folder.UserCanEditPathText, folder.Title, folder.OkButtonLabel, folder.FileNameLabel, folder.Multiselect, folder.ForceFileSystem }); diff --git a/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/NativeConfigurableBase.cs b/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/NativeConfigurableBase.cs index 4e120313..471c1fc6 100644 --- a/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/NativeConfigurableBase.cs +++ b/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/NativeConfigurableBase.cs @@ -145,7 +145,7 @@ public static void Save(object instance, string filePath) } var directory = Path.GetDirectoryName(filePath); - if (directory.Length > 0) + if (directory!.Length > 0) Directory.CreateDirectory(directory); File.WriteAllText(filePath, root.ToJsonString(SerializerOptions)); diff --git a/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/NativeModConfigSchema.cs b/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/NativeModConfigSchema.cs index 5f9e897e..110f5ca9 100644 --- a/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/NativeModConfigSchema.cs +++ b/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/NativeModConfigSchema.cs @@ -4,15 +4,19 @@ namespace Reloaded.Mod.Launcher.Lib.Models.Model.Configuration; /// /// Declarative configuration schema for native (non .NET) mods. -/// A mod declares its settings by placing a ConfigSchema.json file next to its ModConfig.json. -/// The launcher then builds a configuration UI from that schema. -/// The config file mirrors the attributes used by the C# mod template (DisplayName, Description, Category, -/// DefaultValue, Slider/File/Folder control params) so both kinds of mod look and behave the same. +/// A mod declares its settings in a ConfigSchema.json file next to +/// its ModConfig.json. The launcher builds the configuration UI +/// from that schema. +/// +/// The schema mirrors the attributes used by the C# mod template. +/// Native and C# mods therefore look and behave the same: +/// - DisplayName, Description, Category, DefaultValue +/// - Slider/File/Folder control params /// public class NativeModConfigSchema { /// - /// Name of the config file, need to be placed inside the mod folder. + /// Name of the config file to place inside the mod folder. /// public const string SchemaFileName = "ConfigSchema.json"; @@ -31,6 +35,11 @@ public class NativeModConfigSchema /// Loads config schema from local disk. /// /// Full path to the folder containing the mod. + /// + /// Thrown when the schema is empty or invalid. A missing file throws + /// ; malformed JSON, + /// . + /// public static NativeModConfigSchema Load(string modDirectory) => Parse(JsonNode.Parse(File.ReadAllText(Path.Combine(modDirectory, SchemaFileName)), new JsonNodeOptions() { PropertyNameCaseInsensitive = true }) ?? throw newException(modDirectory), modDirectory); private static Exception newException(string modDirectory) => new InvalidOperationException($"Failed to parse {SchemaFileName} in '{modDirectory}'. The file may be empty or invalid."); @@ -59,7 +68,8 @@ private static NativeModConfigSchema Parse(JsonNode node, string modDirectory) } /// -/// Individual configuration of a native mod, essentially mirrors one IConfigurable from C# mod. +/// Individual configuration of a native mod; essentially mirrors one +/// IConfigurable from the C# mod template. /// public class NativeConfigSchemaConfiguration { @@ -70,7 +80,7 @@ public class NativeConfigSchemaConfiguration public string FileName { get; set; } = "Config.json"; /// - /// Name shown in the launcher's configuration dropdown. + /// Name shown in the launcher's configuration dropdown. /// public string? DisplayName { get; set; } @@ -84,6 +94,13 @@ public class NativeConfigSchemaConfiguration /// public List Properties { get; set; } = new(); + /// + /// Reads a configuration from its JSON representation. + /// + /// Node holding the configuration's properties. + /// + /// Thrown when FileName is not a plain file name. + /// public static NativeConfigSchemaConfiguration Parse(JsonNode node) { var configuration = new NativeConfigSchemaConfiguration @@ -108,8 +125,8 @@ public static NativeConfigSchemaConfiguration Parse(JsonNode node) } /// - /// The file name is used to build paths inside the user config directory, - /// so anything that is not a plain file name (rooted paths, separators) is rejected. + /// The file name builds paths inside the user config directory, so it + /// must be a plain file name; rooted paths and separators fail validation. /// private static string ValidateFileName(string fileName) { @@ -135,6 +152,13 @@ public class NativeConfigSchemaEnum /// public List Members { get; set; } = new(); + /// + /// Reads an enum from its JSON representation. + /// + /// Node holding the enum's properties. + /// + /// Thrown when the enum declares no members. + /// public static NativeConfigSchemaEnum Parse(JsonNode node) { var result = new NativeConfigSchemaEnum @@ -174,6 +198,10 @@ public class NativeConfigSchemaEnumMember /// public string? DisplayName { get; set; } + /// + /// Reads an enum member from its JSON representation. + /// + /// Node holding the member's properties. public static NativeConfigSchemaEnumMember Parse(JsonNode node) => new() { Name = node.GetStringOrDefault(Keys.Name, "")!, @@ -182,14 +210,15 @@ public class NativeConfigSchemaEnumMember } /// -/// An individual setting of a configuration; mirrors a property of a C# mod's config class. +/// An individual setting of a configuration; mirrors a property of a +/// C# mod's config class. /// public class NativeConfigSchemaProperty { /// /// Supported values for . /// - public static class SupportedTypes + internal static class SupportedTypes { public const string Bool = "bool"; public const string Int = "int"; @@ -204,8 +233,9 @@ public static class SupportedTypes public string Name { get; set; } = ""; /// - /// Type of the setting; one of bool, int, float, double, string - /// or the name of an enum declared in the same configuration. + /// Type of the setting; one of the following: + /// - bool, int, float, double or string + /// - the name of an enum declared in the same configuration /// public string Type { get; set; } = SupportedTypes.String; @@ -230,8 +260,10 @@ public static class SupportedTypes public int? Order { get; set; } /// - /// Default value of the setting (bool/int/float/double, string or enum member name). - /// Used when the user has not changed the setting, and by the Reset button. + /// Default value of the setting; its shape matches : + /// - a bool, int, float or double literal + /// - a string or an enum member name + /// Initial value before any user change; the Reset button restores it. /// public object? DefaultValue { get; set; } @@ -251,10 +283,18 @@ public static class SupportedTypes public NativeConfigSchemaFolderPicker? FolderPicker { get; set; } /// - /// Enum values declared directly on the property, for the common case where an enum is used once. + /// Enum values declared directly on the property, for the common case + /// of an enum used by a single setting. /// public List Values { get; set; } = new(); + /// + /// Reads a property from its JSON representation. + /// + /// Node holding the setting's properties. + /// + /// Thrown when the property has no name. + /// public static NativeConfigSchemaProperty Parse(JsonNode node) { var property = new NativeConfigSchemaProperty @@ -301,23 +341,76 @@ public static NativeConfigSchemaProperty Parse(JsonNode node) } /// -/// Parameters for the slider control; mirrors SliderControlParamsAttribute of the C# interface. +/// Parameters for the slider control; mirrors +/// SliderControlParamsAttribute of the C# interface. /// public class NativeConfigSchemaSlider { + /// + /// Minimum value of the slider. + /// public double Minimum { get; set; } = 0.0; + + /// + /// Maximum value of the slider. + /// public double Maximum { get; set; } = 1.0; + + /// + /// Value change of a small step (arrow keys). + /// public double SmallChange { get; set; } = 0.1; + + /// + /// Value change of a large step (page up/down or gutter click). + /// public double LargeChange { get; set; } = 1.0; + + /// + /// Distance between tick marks. Legacy; + /// wins when greater than zero. + /// public int TickFrequency { get; set; } = 10; + + /// + /// Snap the value to the nearest tick. + /// public bool IsSnapToTickEnabled { get; set; } = false; + + /// + /// Where tick marks are drawn; a SliderControlTickPlacement name. + /// public string TickPlacement { get; set; } = "None"; + + /// + /// Show the value in a text field left of the slider. + /// public bool ShowTextField { get; set; } = false; + + /// + /// Allow typing in the text field. + /// public bool IsTextFieldEditable { get; set; } = true; + + /// + /// Regex the text field input must match. + /// public string TextValidationRegex { get; set; } = ".*"; + + /// + /// Format string applied to the text field value. + /// public string TextFieldFormat { get; set; } = ""; + + /// + /// Distance between tick marks; allows fractions. + /// public double TickFrequencyDouble { get; set; } = 0.0; + /// + /// Reads the slider parameters from their JSON representation. + /// + /// Node holding the slider's properties. public static NativeConfigSchemaSlider Parse(JsonNode node) => new() { Minimum = node.GetDoubleOrDefault(Keys.Minimum, 0.0), @@ -336,24 +429,81 @@ public class NativeConfigSchemaSlider } /// -/// Parameters for the file picker control; mirrors FilePickerParamsAttribute of the C# interface. +/// Parameters for the file picker control; mirrors +/// FilePickerParamsAttribute of the C# interface. /// public class NativeConfigSchemaFilePicker { + /// + /// Initial directory shown; null for the default. + /// public string? InitialDirectory { get; set; } + + /// + /// Fallback folder when is null, as an + /// Environment.SpecialFolder value. + /// public int InitialFolderPath { get; set; } = 0x05; // Environment.SpecialFolder.Personal + + /// + /// Label of the choose file button. + /// public string ChooseFileButtonLabel { get; set; } = "Choose File"; + + /// + /// Allow typing in the path box. + /// public bool UserCanEditPathText { get; set; } = true; + + /// + /// Title of the dialog. + /// public string Title { get; set; } = ""; + + /// + /// Filter of the dialog, e.g. All files (*.*)|*.*. + /// public string Filter { get; set; } = "All files (*.*)|*.*"; + + /// + /// Index of the filter selected at open. + /// public int FilterIndex { get; set; } = 0; + + /// + /// Allow selecting multiple files. + /// public bool Multiselect { get; set; } = false; + + /// + /// Support extensions with multiple dots, e.g. .tar.gz. + /// public bool SupportMultiDottedExtensions { get; set; } = false; + + /// + /// Show hidden files in the dialog. + /// public bool ShowHiddenFiles { get; set; } = false; + + /// + /// Show the file preview pane. + /// public bool ShowPreview { get; set; } = false; + + /// + /// Restore the working directory after the dialog closes. + /// public bool RestoreDirectory { get; set; } = false; + + /// + /// Add the chosen file to the recent documents. + /// public bool AddToRecent { get; set; } = false; + /// + /// Reads the file picker parameters from their JSON representation. + /// + /// Node holding the picker's properties. public static NativeConfigSchemaFilePicker Parse(JsonNode node) => new() { InitialDirectory = node.GetStringOrDefault(Keys.InitialDirectory, null), @@ -373,20 +523,61 @@ public class NativeConfigSchemaFilePicker } /// -/// Parameters for the folder picker control; mirrors FolderPickerParamsAttribute of the C# interface. +/// Parameters for the folder picker control; mirrors +/// FolderPickerParamsAttribute of the C# interface. /// public class NativeConfigSchemaFolderPicker { + /// + /// Initial directory shown; null for the default. + /// public string? InitialDirectory { get; set; } + + /// + /// Fallback folder when is null, as an + /// Environment.SpecialFolder value. + /// public int InitialFolderPath { get; set; } = 0x05; // Environment.SpecialFolder.Personal + + /// + /// Label of the choose folder button. + /// public string ChooseFolderButtonLabel { get; set; } = "Choose Folder"; + + /// + /// Allow typing in the path box. + /// public bool UserCanEditPathText { get; set; } = true; + + /// + /// Title of the dialog. + /// public string Title { get; set; } = ""; + + /// + /// Label of the OK button. + /// public string OkButtonLabel { get; set; } = "Ok"; + + /// + /// Label of the file name box. + /// public string FileNameLabel { get; set; } = ""; + + /// + /// Allow selecting multiple folders. + /// public bool Multiselect { get; set; } = false; + + /// + /// Only accept folders in the file system. + /// public bool ForceFileSystem { get; set; } = false; + /// + /// Reads the folder picker parameters from their JSON representation. + /// + /// Node holding the picker's properties. public static NativeConfigSchemaFolderPicker Parse(JsonNode node) => new() { InitialDirectory = node.GetStringOrDefault(Keys.InitialDirectory, null), @@ -462,7 +653,7 @@ internal static class JsonNodeExtensions { public static string? GetStringOrDefault(this JsonNode? node, string name, string? fallback) { - var value = node[name]; + var value = node?[name]; if (value == null) return fallback; @@ -471,7 +662,7 @@ internal static class JsonNodeExtensions public static int GetIntOrDefault(this JsonNode? node, string name, int fallback) { - var value = node[name]; + var value = node?[name]; if (value == null) return fallback; @@ -487,7 +678,7 @@ public static int GetIntOrDefault(this JsonNode? node, string name, int fallback public static int? GetIntOrNull(this JsonNode? node, string name) { - var value = node[name]; + var value = node?[name]; if (value == null) return null; @@ -503,7 +694,7 @@ public static int GetIntOrDefault(this JsonNode? node, string name, int fallback public static double GetDoubleOrDefault(this JsonNode? node, string name, double fallback) { - var value = node[name]; + var value = node?[name]; if (value == null) return fallback; @@ -512,7 +703,7 @@ public static double GetDoubleOrDefault(this JsonNode? node, string name, double public static bool GetBoolOrDefault(this JsonNode? node, string name, bool fallback) { - var value = node[name]; + var value = node?[name]; if (value == null) return fallback; @@ -520,7 +711,11 @@ public static bool GetBoolOrDefault(this JsonNode? node, string name, bool fallb } /// - /// Returns the raw boxed value of a node (bool/int/double/string) or null. + /// Returns the raw boxed value of a node as one of the following: + /// - bool + /// - int if it fits, else double + /// - string + /// - null for any other content /// public static object? GetValueOrNull(this JsonNode? node) { diff --git a/source/Reloaded.Mod.Loader.Tests/Launcher/NativeModConfigTests.cs b/source/Reloaded.Mod.Loader.Tests/Launcher/NativeModConfigTests.cs index d55e5173..4314abc0 100644 --- a/source/Reloaded.Mod.Loader.Tests/Launcher/NativeModConfigTests.cs +++ b/source/Reloaded.Mod.Loader.Tests/Launcher/NativeModConfigTests.cs @@ -1,6 +1,8 @@ using System.ComponentModel.DataAnnotations; using System.Reflection; using System.Text.Json.Nodes; +using Reloaded.Mod.Interfaces; +using Reloaded.Mod.Interfaces.Structs; using Reloaded.Mod.Launcher.Lib.Models.Model.Configuration; namespace Reloaded.Mod.Loader.Tests.Launcher; @@ -110,7 +112,9 @@ public void Generated_Properties_Carry_UI_Attributes() Assert.NotNull(slider); Assert.Equal(0.0, slider!.Minimum); Assert.Equal(100.0, slider.Maximum); +#pragma warning disable CS0618 // Legacy tick frequency; schemas still feed it. Assert.Equal(10, slider.TickFrequency); +#pragma warning restore CS0618 var filePicker = type.GetProperty("FileSetting")!.GetCustomAttribute(); Assert.NotNull(filePicker); @@ -253,8 +257,8 @@ public void TryMigrate_Reports_Failure_And_Keeps_Error() [Fact] public void TryMigrate_Rolls_Back_Moves_On_Failure() { - // Two configs with values in the mod folder, the second move fails - // because a directory ends being where the file would land. + // Two configs with values in the mod folder; the second move fails + // because a directory already sits where the file would land. File.WriteAllText(Path.Combine(ModDirectory, NativeModConfigSchema.SchemaFileName), """ { "Configurations": [ diff --git a/source/Reloaded.Mod.Loader.Tests/Loader/NativeLoaderApiBridgeTests.cs b/source/Reloaded.Mod.Loader.Tests/Loader/NativeLoaderApiBridgeTests.cs index 597640d0..a921cb26 100644 --- a/source/Reloaded.Mod.Loader.Tests/Loader/NativeLoaderApiBridgeTests.cs +++ b/source/Reloaded.Mod.Loader.Tests/Loader/NativeLoaderApiBridgeTests.cs @@ -1,4 +1,5 @@ using System.Runtime.InteropServices; +using Reloaded.Mod.Interfaces; namespace Reloaded.Mod.Loader.Tests.Loader; From 756a7a8fc95850cfd82b7ae9a6fabb71db2387ef Mon Sep 17 00:00:00 2001 From: Sewer56 Date: Sun, 20 Sep 2026 22:07:23 +0100 Subject: [PATCH 16/35] Changed: Group native mod config types under Native subnamespace - Move 4 native config model files into Models/Model/Configuration/Native/ - Drop redundant Native prefix from 12 type names and 4 file names - Use Native.X in callers --- .../Commands/Mod/ConfigureModCommand.cs | 8 +- .../ConfigTypeEmitter.cs} | 36 ++++----- .../ConfigurableBase.cs} | 16 ++-- .../ModConfigSchema.cs} | 78 +++++++++---------- .../ModConfigurator.cs} | 20 ++--- source/Reloaded.Mod.Launcher.Lib/Usings.cs | 1 - .../Launcher/NativeModConfigTests.cs | 26 +++---- 7 files changed, 93 insertions(+), 92 deletions(-) rename source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/{NativeConfigTypeEmitter.cs => Native/ConfigTypeEmitter.cs} (90%) rename source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/{NativeConfigurableBase.cs => Native/ConfigurableBase.cs} (93%) rename source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/{NativeModConfigSchema.cs => Native/ModConfigSchema.cs} (89%) rename source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/{NativeModConfigurator.cs => Native/ModConfigurator.cs} (85%) diff --git a/source/Reloaded.Mod.Launcher.Lib/Commands/Mod/ConfigureModCommand.cs b/source/Reloaded.Mod.Launcher.Lib/Commands/Mod/ConfigureModCommand.cs index 846a1b07..b4164f1f 100644 --- a/source/Reloaded.Mod.Launcher.Lib/Commands/Mod/ConfigureModCommand.cs +++ b/source/Reloaded.Mod.Launcher.Lib/Commands/Mod/ConfigureModCommand.cs @@ -1,3 +1,5 @@ +using Native = Reloaded.Mod.Launcher.Lib.Models.Model.Configuration.Native; + namespace Reloaded.Mod.Launcher.Lib.Commands.Mod; /// @@ -73,12 +75,12 @@ private bool TryGetConfigurator(out IConfiguratorV1? configurator, out PluginLoa var modDirectory = Path.GetFullPath(Path.GetDirectoryName(_modTuple.Path)!); // Native (non .NET) mods describe their settings in a schema file, no managed code required. - if (NativeModConfigSchema.ExistsInFolder(modDirectory)) + if (Native.ModConfigSchema.ExistsInFolder(modDirectory)) { // Validate upfront, a broken schema disables the button instead of failing later. - NativeModConfigSchema.Load(modDirectory); + Native.ModConfigSchema.Load(modDirectory); - var nativeConfigurator = new NativeModConfigurator(modDirectory); + var nativeConfigurator = new Native.ModConfigurator(modDirectory); nativeConfigurator.SetModDirectory(modDirectory); diff --git a/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/NativeConfigTypeEmitter.cs b/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/ConfigTypeEmitter.cs similarity index 90% rename from source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/NativeConfigTypeEmitter.cs rename to source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/ConfigTypeEmitter.cs index 3b4cca20..481c1daa 100644 --- a/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/NativeConfigTypeEmitter.cs +++ b/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/ConfigTypeEmitter.cs @@ -2,12 +2,12 @@ using Reloaded.Mod.Interfaces.Structs; using DataAnnotations = System.ComponentModel.DataAnnotations; -namespace Reloaded.Mod.Launcher.Lib.Models.Model.Configuration; +namespace Reloaded.Mod.Launcher.Lib.Models.Model.Configuration.Native; /// /// Builds .NET types from native mod configuration schemas using /// Reflection.Emit. -/// The generated types subclass and carry +/// The generated types subclass and carry /// the same attributes as a hand written C# configuration class: /// - , , /// @@ -17,7 +17,7 @@ namespace Reloaded.Mod.Launcher.Lib.Models.Model.Configuration; /// FolderPickerParams (custom editors) /// The PropertyGrid renders them exactly like a C# mod's configuration. /// -public static class NativeConfigTypeEmitter +public static class ConfigTypeEmitter { private static readonly object BuildLock = new object(); private static ModuleBuilder? _module; @@ -35,7 +35,7 @@ public static class NativeConfigTypeEmitter /// Thrown when a property's Type is unknown, or a control does not match /// the property type. /// - public static NativeConfigurableBase CreateInstance(NativeConfigSchemaConfiguration configuration, string cacheKey) + public static ConfigurableBase CreateInstance(ConfigSchemaConfiguration configuration, string cacheKey) { Type type; lock (BuildLock) @@ -47,13 +47,13 @@ public static NativeConfigurableBase CreateInstance(NativeConfigSchemaConfigurat } } - return (NativeConfigurableBase)Activator.CreateInstance(type)!; + return (ConfigurableBase)Activator.CreateInstance(type)!; } - private static Type BuildType(NativeConfigSchemaConfiguration configuration, string cacheKey) + private static Type BuildType(ConfigSchemaConfiguration configuration, string cacheKey) { var module = GetModule(); - var typeBuilder = module.DefineType($"NativeModConfig_{Interlocked.Increment(ref _typeCounter)}_{MakeIdentifier(cacheKey)}", TypeAttributes.Public | TypeAttributes.Class | TypeAttributes.Sealed, typeof(NativeConfigurableBase)); + var typeBuilder = module.DefineType($"NativeModConfig_{Interlocked.Increment(ref _typeCounter)}_{MakeIdentifier(cacheKey)}", TypeAttributes.Public | TypeAttributes.Class | TypeAttributes.Sealed, typeof(ConfigurableBase)); // Build the enums first, so they can be used as property types. // They are named after the config type, so two mods declaring the same enum name won't clash. @@ -77,7 +77,7 @@ private static Type BuildType(NativeConfigSchemaConfiguration configuration, str // Build the properties. var ctor = typeBuilder.DefineConstructor(MethodAttributes.Public, CallingConventions.Standard, Type.EmptyTypes); var ctorIl = ctor.GetILGenerator(); - var baseCtor = typeof(NativeConfigurableBase).GetConstructor(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic, binder: null, Type.EmptyTypes, modifiers: null)!; + var baseCtor = typeof(ConfigurableBase).GetConstructor(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic, binder: null, Type.EmptyTypes, modifiers: null)!; ctorIl.Emit(OpCodes.Ldarg_0); ctorIl.Emit(OpCodes.Call, baseCtor); @@ -114,7 +114,7 @@ private static Type BuildType(NativeConfigSchemaConfiguration configuration, str /// /// Declared enums plus one per property with inline Values. /// - private static IEnumerable CollectEnums(NativeConfigSchemaConfiguration configuration) + private static IEnumerable CollectEnums(ConfigSchemaConfiguration configuration) { foreach (var schemaEnum in configuration.Enums) yield return schemaEnum; @@ -122,27 +122,27 @@ private static IEnumerable CollectEnums(NativeConfigSche foreach (var property in configuration.Properties) { if (property.Values.Count > 0) - yield return new NativeConfigSchemaEnum() { Name = property.Name, Members = property.Values }; + yield return new ConfigSchemaEnum() { Name = property.Name, Members = property.Values }; } } - private static (Type propertyType, object? defaultValue) ResolveTypeAndDefault(NativeConfigSchemaProperty property, Dictionary enums) + private static (Type propertyType, object? defaultValue) ResolveTypeAndDefault(ConfigSchemaProperty property, Dictionary enums) { switch (property.Type) { - case NativeConfigSchemaProperty.SupportedTypes.Bool: + case ConfigSchemaProperty.SupportedTypes.Bool: return (typeof(bool), property.DefaultValue is bool b ? b : false); - case NativeConfigSchemaProperty.SupportedTypes.Int: + case ConfigSchemaProperty.SupportedTypes.Int: return (typeof(int), property.DefaultValue == null ? 0 : Convert.ToInt32(property.DefaultValue)); - case NativeConfigSchemaProperty.SupportedTypes.Float: + case ConfigSchemaProperty.SupportedTypes.Float: return (typeof(float), property.DefaultValue == null ? 0.0f : Convert.ToSingle(property.DefaultValue)); - case NativeConfigSchemaProperty.SupportedTypes.Double: + case ConfigSchemaProperty.SupportedTypes.Double: return (typeof(double), property.DefaultValue == null ? 0.0 : Convert.ToDouble(property.DefaultValue)); - case NativeConfigSchemaProperty.SupportedTypes.String: + case ConfigSchemaProperty.SupportedTypes.String: return (typeof(string), property.DefaultValue?.ToString()); default: @@ -159,7 +159,7 @@ private static (Type propertyType, object? defaultValue) ResolveTypeAndDefault(N } } - private static object GetEnumDefault(NativeConfigSchemaProperty property, Type enumType) + private static object GetEnumDefault(ConfigSchemaProperty property, Type enumType) { if (property.DefaultValue is string memberName) { @@ -214,7 +214,7 @@ private static void EmitFieldInit(ILGenerator il, FieldBuilder field, Type prope il.Emit(OpCodes.Stfld, field); } - private static IEnumerable BuildAttributes(NativeConfigSchemaProperty property, Type propertyType, object? defaultValue) + private static IEnumerable BuildAttributes(ConfigSchemaProperty property, Type propertyType, object? defaultValue) { if (property.DisplayName != null) yield return new CustomAttributeBuilder(GetCtor(typeof(DisplayNameAttribute), 1), new object[] { property.DisplayName }); diff --git a/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/NativeConfigurableBase.cs b/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/ConfigurableBase.cs similarity index 93% rename from source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/NativeConfigurableBase.cs rename to source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/ConfigurableBase.cs index 471c1fc6..6a764e87 100644 --- a/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/NativeConfigurableBase.cs +++ b/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/ConfigurableBase.cs @@ -1,16 +1,16 @@ using System.Collections.Concurrent; using System.Text.Json.Nodes; -namespace Reloaded.Mod.Launcher.Lib.Models.Model.Configuration; +namespace Reloaded.Mod.Launcher.Lib.Models.Model.Configuration.Native; /// /// Base class for the configuration objects generated for native (non .NET) mods. -/// The emits one derived class per schema configuration; +/// The emits one derived class per schema configuration; /// the derived class holds the settings as properties, this class supplies the behaviour /// (name, saving, file watching) expected by the launcher's configuration dialog. /// Mirrors Configurable<T> of the C# mod template. /// -public abstract class NativeConfigurableBase : IUpdatableConfigurable +public abstract class ConfigurableBase : IUpdatableConfigurable { /// /// Full path to the file storing the values of this configuration. @@ -82,7 +82,7 @@ private void OnConfigurationUpdated() lock (_readLock) { // Note: External program might still be writing to file while this is being executed, so we need to keep retrying. - var newConfig = NativeConfigIO.Load(GetType(), FilePath!, ConfigName, 250, 2); + var newConfig = ConfigIO.Load(GetType(), FilePath!, ConfigName, 250, 2); // Load and copy events, then disable events for this instance. newConfig.ConfigurationUpdated = ConfigurationUpdated; @@ -93,7 +93,7 @@ private void OnConfigurationUpdated() } } - private void OnSave() => NativeConfigIO.Save(this, FilePath!); + private void OnSave() => ConfigIO.Save(this, FilePath!); } /// @@ -101,7 +101,7 @@ private void OnConfigurationUpdated() /// The file format is a flat JSON object of property name to value, with enums stored as strings; /// identical in shape to what the C# mod template writes, so C++ mods can parse it with ease. /// -public static class NativeConfigIO +public static class ConfigIO { private static readonly JsonSerializerOptions SerializerOptions = new() { WriteIndented = true }; @@ -181,9 +181,9 @@ public static bool Apply(object instance, string filePath) /// Creates a new instance of the given configuration type with values loaded from disk. /// Missing or unreadable files yield an instance with the schema default values. /// - public static NativeConfigurableBase Load(Type type, string filePath, string configName, int timeout = 0, int retries = 1) + public static ConfigurableBase Load(Type type, string filePath, string configName, int timeout = 0, int retries = 1) { - var instance = (NativeConfigurableBase)Activator.CreateInstance(type)!; + var instance = (ConfigurableBase)Activator.CreateInstance(type)!; for (int x = 0; x < retries; x++) { if (Apply(instance, filePath)) diff --git a/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/NativeModConfigSchema.cs b/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/ModConfigSchema.cs similarity index 89% rename from source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/NativeModConfigSchema.cs rename to source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/ModConfigSchema.cs index 110f5ca9..7f61bab6 100644 --- a/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/NativeModConfigSchema.cs +++ b/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/ModConfigSchema.cs @@ -1,6 +1,6 @@ using System.Text.Json.Nodes; -namespace Reloaded.Mod.Launcher.Lib.Models.Model.Configuration; +namespace Reloaded.Mod.Launcher.Lib.Models.Model.Configuration.Native; /// /// Declarative configuration schema for native (non .NET) mods. @@ -13,7 +13,7 @@ namespace Reloaded.Mod.Launcher.Lib.Models.Model.Configuration; /// - DisplayName, Description, Category, DefaultValue /// - Slider/File/Folder control params /// -public class NativeModConfigSchema +public class ModConfigSchema { /// /// Name of the config file to place inside the mod folder. @@ -23,7 +23,7 @@ public class NativeModConfigSchema /// /// The individual configurations (pages/files) exposed by the mod. /// - public List Configurations { get; set; } = new(); + public List Configurations { get; set; } = new(); /// /// Returns true if the specified mod directory contains a config schema. @@ -40,19 +40,19 @@ public class NativeModConfigSchema /// ; malformed JSON, /// . /// - public static NativeModConfigSchema Load(string modDirectory) => Parse(JsonNode.Parse(File.ReadAllText(Path.Combine(modDirectory, SchemaFileName)), new JsonNodeOptions() { PropertyNameCaseInsensitive = true }) ?? throw newException(modDirectory), modDirectory); + public static ModConfigSchema Load(string modDirectory) => Parse(JsonNode.Parse(File.ReadAllText(Path.Combine(modDirectory, SchemaFileName)), new JsonNodeOptions() { PropertyNameCaseInsensitive = true }) ?? throw newException(modDirectory), modDirectory); private static Exception newException(string modDirectory) => new InvalidOperationException($"Failed to parse {SchemaFileName} in '{modDirectory}'. The file may be empty or invalid."); - private static NativeModConfigSchema Parse(JsonNode node, string modDirectory) + private static ModConfigSchema Parse(JsonNode node, string modDirectory) { try { - var schema = new NativeModConfigSchema(); + var schema = new ModConfigSchema(); if (node[Keys.Configurations] is JsonArray configurations) { foreach (var configurationNode in configurations) - schema.Configurations.Add(NativeConfigSchemaConfiguration.Parse(configurationNode!)); + schema.Configurations.Add(ConfigSchemaConfiguration.Parse(configurationNode!)); } if (schema.Configurations.Count <= 0) @@ -71,7 +71,7 @@ private static NativeModConfigSchema Parse(JsonNode node, string modDirectory) /// Individual configuration of a native mod; essentially mirrors one /// IConfigurable from the C# mod template. /// -public class NativeConfigSchemaConfiguration +public class ConfigSchemaConfiguration { /// /// Name of the config file where the values for this configuration are stored. @@ -87,12 +87,12 @@ public class NativeConfigSchemaConfiguration /// /// Enumerations available to the properties of this configuration. /// - public List Enums { get; set; } = new(); + public List Enums { get; set; } = new(); /// /// The individual settings. /// - public List Properties { get; set; } = new(); + public List Properties { get; set; } = new(); /// /// Reads a configuration from its JSON representation. @@ -101,9 +101,9 @@ public class NativeConfigSchemaConfiguration /// /// Thrown when FileName is not a plain file name. /// - public static NativeConfigSchemaConfiguration Parse(JsonNode node) + public static ConfigSchemaConfiguration Parse(JsonNode node) { - var configuration = new NativeConfigSchemaConfiguration + var configuration = new ConfigSchemaConfiguration { FileName = ValidateFileName(node.GetStringOrDefault(Keys.FileName, "Config.json")!), DisplayName = node.GetStringOrDefault(Keys.DisplayName, null) @@ -112,13 +112,13 @@ public static NativeConfigSchemaConfiguration Parse(JsonNode node) if (node[Keys.Enums] is JsonArray enums) { foreach (var enumNode in enums) - configuration.Enums.Add(NativeConfigSchemaEnum.Parse(enumNode!)); + configuration.Enums.Add(ConfigSchemaEnum.Parse(enumNode!)); } if (node[Keys.Properties] is JsonArray properties) { foreach (var propertyNode in properties) - configuration.Properties.Add(NativeConfigSchemaProperty.Parse(propertyNode!)); + configuration.Properties.Add(ConfigSchemaProperty.Parse(propertyNode!)); } return configuration; @@ -140,7 +140,7 @@ private static string ValidateFileName(string fileName) /// /// Enumeration with display names, rendered as a list in Reloaded. /// -public class NativeConfigSchemaEnum +public class ConfigSchemaEnum { /// /// Name of the enum type, referenced by property Type. @@ -150,7 +150,7 @@ public class NativeConfigSchemaEnum /// /// The individual values of the enum. /// - public List Members { get; set; } = new(); + public List Members { get; set; } = new(); /// /// Reads an enum from its JSON representation. @@ -159,9 +159,9 @@ public class NativeConfigSchemaEnum /// /// Thrown when the enum declares no members. /// - public static NativeConfigSchemaEnum Parse(JsonNode node) + public static ConfigSchemaEnum Parse(JsonNode node) { - var result = new NativeConfigSchemaEnum + var result = new ConfigSchemaEnum { Name = node.GetStringOrDefault(Keys.Name, "")! }; @@ -170,7 +170,7 @@ public static NativeConfigSchemaEnum Parse(JsonNode node) { foreach (var memberNode in members) { - var member = NativeConfigSchemaEnumMember.Parse(memberNode!); + var member = ConfigSchemaEnumMember.Parse(memberNode!); if (member.Name.Length > 0) result.Members.Add(member); } @@ -186,7 +186,7 @@ public static NativeConfigSchemaEnum Parse(JsonNode node) /// /// An individual value of a schema enum. /// -public class NativeConfigSchemaEnumMember +public class ConfigSchemaEnumMember { /// /// Name of the value, stored in the config file. @@ -202,7 +202,7 @@ public class NativeConfigSchemaEnumMember /// Reads an enum member from its JSON representation. /// /// Node holding the member's properties. - public static NativeConfigSchemaEnumMember Parse(JsonNode node) => new() + public static ConfigSchemaEnumMember Parse(JsonNode node) => new() { Name = node.GetStringOrDefault(Keys.Name, "")!, DisplayName = node.GetStringOrDefault(Keys.DisplayName, null) @@ -213,7 +213,7 @@ public class NativeConfigSchemaEnumMember /// An individual setting of a configuration; mirrors a property of a /// C# mod's config class. /// -public class NativeConfigSchemaProperty +public class ConfigSchemaProperty { /// /// Supported values for . @@ -270,23 +270,23 @@ internal static class SupportedTypes /// /// Renders this setting as a slider. Only valid for numeric types. /// - public NativeConfigSchemaSlider? Slider { get; set; } + public ConfigSchemaSlider? Slider { get; set; } /// /// Renders this setting (string) with a file picker dialog. /// - public NativeConfigSchemaFilePicker? FilePicker { get; set; } + public ConfigSchemaFilePicker? FilePicker { get; set; } /// /// Renders this setting (string) with a folder picker dialog. /// - public NativeConfigSchemaFolderPicker? FolderPicker { get; set; } + public ConfigSchemaFolderPicker? FolderPicker { get; set; } /// /// Enum values declared directly on the property, for the common case /// of an enum used by a single setting. /// - public List Values { get; set; } = new(); + public List Values { get; set; } = new(); /// /// Reads a property from its JSON representation. @@ -295,9 +295,9 @@ internal static class SupportedTypes /// /// Thrown when the property has no name. /// - public static NativeConfigSchemaProperty Parse(JsonNode node) + public static ConfigSchemaProperty Parse(JsonNode node) { - var property = new NativeConfigSchemaProperty + var property = new ConfigSchemaProperty { Name = node.GetStringOrDefault(Keys.Name, "")!, Type = node.GetStringOrDefault(Keys.Type, SupportedTypes.String)!.ToLowerInvariant(), @@ -309,21 +309,21 @@ public static NativeConfigSchemaProperty Parse(JsonNode node) }; if (node[Keys.Slider] is JsonNode slider) - property.Slider = NativeConfigSchemaSlider.Parse(slider); + property.Slider = ConfigSchemaSlider.Parse(slider); if (node[Keys.FilePicker] is JsonNode filePicker) - property.FilePicker = NativeConfigSchemaFilePicker.Parse(filePicker); + property.FilePicker = ConfigSchemaFilePicker.Parse(filePicker); if (node[Keys.FolderPicker] is JsonNode folderPicker) - property.FolderPicker = NativeConfigSchemaFolderPicker.Parse(folderPicker); + property.FolderPicker = ConfigSchemaFolderPicker.Parse(folderPicker); if (node[Keys.Values] is JsonArray values) { foreach (var valueNode in values) { var member = valueNode!.GetValueKind() == JsonValueKind.String - ? new NativeConfigSchemaEnumMember { Name = valueNode.GetValue() } - : NativeConfigSchemaEnumMember.Parse(valueNode); + ? new ConfigSchemaEnumMember { Name = valueNode.GetValue() } + : ConfigSchemaEnumMember.Parse(valueNode); if (member.Name.Length > 0) property.Values.Add(member); @@ -344,7 +344,7 @@ public static NativeConfigSchemaProperty Parse(JsonNode node) /// Parameters for the slider control; mirrors /// SliderControlParamsAttribute of the C# interface. /// -public class NativeConfigSchemaSlider +public class ConfigSchemaSlider { /// /// Minimum value of the slider. @@ -411,7 +411,7 @@ public class NativeConfigSchemaSlider /// Reads the slider parameters from their JSON representation. /// /// Node holding the slider's properties. - public static NativeConfigSchemaSlider Parse(JsonNode node) => new() + public static ConfigSchemaSlider Parse(JsonNode node) => new() { Minimum = node.GetDoubleOrDefault(Keys.Minimum, 0.0), Maximum = node.GetDoubleOrDefault(Keys.Maximum, 1.0), @@ -432,7 +432,7 @@ public class NativeConfigSchemaSlider /// Parameters for the file picker control; mirrors /// FilePickerParamsAttribute of the C# interface. /// -public class NativeConfigSchemaFilePicker +public class ConfigSchemaFilePicker { /// /// Initial directory shown; null for the default. @@ -504,7 +504,7 @@ public class NativeConfigSchemaFilePicker /// Reads the file picker parameters from their JSON representation. /// /// Node holding the picker's properties. - public static NativeConfigSchemaFilePicker Parse(JsonNode node) => new() + public static ConfigSchemaFilePicker Parse(JsonNode node) => new() { InitialDirectory = node.GetStringOrDefault(Keys.InitialDirectory, null), InitialFolderPath = node.GetIntOrDefault(Keys.InitialFolderPath, 0x05), @@ -526,7 +526,7 @@ public class NativeConfigSchemaFilePicker /// Parameters for the folder picker control; mirrors /// FolderPickerParamsAttribute of the C# interface. /// -public class NativeConfigSchemaFolderPicker +public class ConfigSchemaFolderPicker { /// /// Initial directory shown; null for the default. @@ -578,7 +578,7 @@ public class NativeConfigSchemaFolderPicker /// Reads the folder picker parameters from their JSON representation. /// /// Node holding the picker's properties. - public static NativeConfigSchemaFolderPicker Parse(JsonNode node) => new() + public static ConfigSchemaFolderPicker Parse(JsonNode node) => new() { InitialDirectory = node.GetStringOrDefault(Keys.InitialDirectory, null), InitialFolderPath = node.GetIntOrDefault(Keys.InitialFolderPath, 0x05), diff --git a/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/NativeModConfigurator.cs b/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/ModConfigurator.cs similarity index 85% rename from source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/NativeModConfigurator.cs rename to source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/ModConfigurator.cs index 9fc2f5ba..bd88c47b 100644 --- a/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/NativeModConfigurator.cs +++ b/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/ModConfigurator.cs @@ -1,10 +1,10 @@ -namespace Reloaded.Mod.Launcher.Lib.Models.Model.Configuration; +namespace Reloaded.Mod.Launcher.Lib.Models.Model.Configuration.Native; /// /// Configurator for native (non .NET) mods that declare their settings through a ConfigSchema.json file. /// Use the same interface as a C# mod's configurator. /// -public class NativeModConfigurator : IConfiguratorV3 +public class ModConfigurator : IConfiguratorV3 { private string _schemaPath; private string _modDirectory = ""; @@ -12,26 +12,26 @@ public class NativeModConfigurator : IConfiguratorV3 private ConfiguratorContext _context; /// - /// Creates a configurator for a mod folder containing . + /// Creates a configurator for a mod folder containing . /// /// Full path to the folder containing the mod. - public NativeModConfigurator(string modDirectory) + public ModConfigurator(string modDirectory) { _modDirectory = modDirectory; - _schemaPath = Path.Combine(modDirectory, NativeModConfigSchema.SchemaFileName); + _schemaPath = Path.Combine(modDirectory, ModConfigSchema.SchemaFileName); } /// public void SetModDirectory(string modDirectory) { _modDirectory = modDirectory; - _schemaPath = Path.Combine(modDirectory, NativeModConfigSchema.SchemaFileName); + _schemaPath = Path.Combine(modDirectory, ModConfigSchema.SchemaFileName); } /// public IConfigurable[] GetConfigurations() { - var schema = NativeModConfigSchema.Load(_modDirectory); + var schema = ModConfigSchema.Load(_modDirectory); var configDirectory = _configDirectory ?? _modDirectory; // Include the file's last write time in the cache key, such that mod updates invalidate emitted types. @@ -40,10 +40,10 @@ public IConfigurable[] GetConfigurations() foreach (var configuration in schema.Configurations) { var cacheKey = $"{_modDirectory}|{configuration.FileName}|{lastWrite}"; - var instance = NativeConfigTypeEmitter.CreateInstance(configuration, cacheKey); + var instance = ConfigTypeEmitter.CreateInstance(configuration, cacheKey); var valuesPath = Path.Combine(configDirectory, configuration.FileName); - NativeConfigIO.Apply(instance, valuesPath); + ConfigIO.Apply(instance, valuesPath); instance.Initialize(valuesPath, configuration.DisplayName ?? Path.GetFileNameWithoutExtension(configuration.FileName)); result.Add(instance); } @@ -70,7 +70,7 @@ public bool TryMigrate(string oldDirectory, string newDirectory) var moved = new List<(string OldPath, string NewPath)>(); try { - var schema = NativeModConfigSchema.Load(_modDirectory); + var schema = ModConfigSchema.Load(_modDirectory); Directory.CreateDirectory(newDirectory); foreach (var configuration in schema.Configurations) { diff --git a/source/Reloaded.Mod.Launcher.Lib/Usings.cs b/source/Reloaded.Mod.Launcher.Lib/Usings.cs index 13d2d6f6..d5696feb 100644 --- a/source/Reloaded.Mod.Launcher.Lib/Usings.cs +++ b/source/Reloaded.Mod.Launcher.Lib/Usings.cs @@ -17,7 +17,6 @@ global using Reloaded.Mod.Launcher.Lib.Interop; global using Reloaded.Mod.Launcher.Lib.Misc; global using Reloaded.Mod.Launcher.Lib.Models.Model.Application; -global using Reloaded.Mod.Launcher.Lib.Models.Model.Configuration; global using Reloaded.Mod.Launcher.Lib.Models.Model.Dialog; global using Reloaded.Mod.Launcher.Lib.Models.Model.DownloadPackagePage; global using Reloaded.Mod.Launcher.Lib.Models.Model.Pages; diff --git a/source/Reloaded.Mod.Loader.Tests/Launcher/NativeModConfigTests.cs b/source/Reloaded.Mod.Loader.Tests/Launcher/NativeModConfigTests.cs index 4314abc0..bf1f6e34 100644 --- a/source/Reloaded.Mod.Loader.Tests/Launcher/NativeModConfigTests.cs +++ b/source/Reloaded.Mod.Loader.Tests/Launcher/NativeModConfigTests.cs @@ -3,7 +3,7 @@ using System.Text.Json.Nodes; using Reloaded.Mod.Interfaces; using Reloaded.Mod.Interfaces.Structs; -using Reloaded.Mod.Launcher.Lib.Models.Model.Configuration; +using Native = Reloaded.Mod.Launcher.Lib.Models.Model.Configuration.Native; namespace Reloaded.Mod.Loader.Tests.Launcher; @@ -50,15 +50,15 @@ public NativeModConfigTests() ConfigDirectory = Path.Combine(Path.GetTempPath(), $"reloaded-native-config-{Guid.NewGuid():N}"); Directory.CreateDirectory(ModDirectory); Directory.CreateDirectory(ConfigDirectory); - File.WriteAllText(Path.Combine(ModDirectory, NativeModConfigSchema.SchemaFileName), Schema); + File.WriteAllText(Path.Combine(ModDirectory, Native.ModConfigSchema.SchemaFileName), Schema); } [Fact] public void Schema_Is_Detected_And_Parsed() { - Assert.True(NativeModConfigSchema.ExistsInFolder(ModDirectory)); + Assert.True(Native.ModConfigSchema.ExistsInFolder(ModDirectory)); - var schema = NativeModConfigSchema.Load(ModDirectory); + var schema = Native.ModConfigSchema.Load(ModDirectory); var configuration = Assert.Single(schema.Configurations); Assert.Equal("Config.json", configuration.FileName); Assert.Equal("Default Config", configuration.DisplayName); @@ -200,7 +200,7 @@ public void Migrate_Moves_Values_File() [Fact] public void Unknown_Type_Throws_Descriptive_Error() { - File.WriteAllText(Path.Combine(ModDirectory, NativeModConfigSchema.SchemaFileName), """ + File.WriteAllText(Path.Combine(ModDirectory, Native.ModConfigSchema.SchemaFileName), """ { "Configurations": [ { "FileName": "Config.json", "Properties": [ { "Name": "Broken", "Type": "NoSuchEnum" } ] } ] } """); var configurator = CreateConfigurator(); @@ -212,7 +212,7 @@ public void Unknown_Type_Throws_Descriptive_Error() [Fact] public void Slider_On_Enum_Property_Throws() { - File.WriteAllText(Path.Combine(ModDirectory, NativeModConfigSchema.SchemaFileName), """ + File.WriteAllText(Path.Combine(ModDirectory, Native.ModConfigSchema.SchemaFileName), """ { "Configurations": [ { @@ -235,11 +235,11 @@ public void Slider_On_Enum_Property_Throws() [InlineData("SubFolder/Config.json")] public void FileNames_With_Paths_Are_Rejected(string fileName) { - File.WriteAllText(Path.Combine(ModDirectory, NativeModConfigSchema.SchemaFileName), $$""" + File.WriteAllText(Path.Combine(ModDirectory, Native.ModConfigSchema.SchemaFileName), $$""" { "Configurations": [ { "FileName": "{{fileName.Replace("\\", "\\\\")}}", "Properties": [] } ] } """); - var error = Assert.Throws(() => NativeModConfigSchema.Load(ModDirectory)); + var error = Assert.Throws(() => Native.ModConfigSchema.Load(ModDirectory)); var jsonError = Assert.IsType(error.InnerException); Assert.Contains("plain file name", jsonError.Message); } @@ -259,7 +259,7 @@ public void TryMigrate_Rolls_Back_Moves_On_Failure() { // Two configs with values in the mod folder; the second move fails // because a directory already sits where the file would land. - File.WriteAllText(Path.Combine(ModDirectory, NativeModConfigSchema.SchemaFileName), """ + File.WriteAllText(Path.Combine(ModDirectory, Native.ModConfigSchema.SchemaFileName), """ { "Configurations": [ { "FileName": "First.json", "Properties": [] }, @@ -284,7 +284,7 @@ public void TryMigrate_Rolls_Back_Moves_On_Failure() [Fact] public void Inline_Enum_Values_Build_A_Dropdown() { - File.WriteAllText(Path.Combine(ModDirectory, NativeModConfigSchema.SchemaFileName), """ + File.WriteAllText(Path.Combine(ModDirectory, Native.ModConfigSchema.SchemaFileName), """ { "Configurations": [ { @@ -316,7 +316,7 @@ public void Inline_Enum_Values_Build_A_Dropdown() [Fact] public void Enum_Type_Without_Values_Gives_Hint() { - File.WriteAllText(Path.Combine(ModDirectory, NativeModConfigSchema.SchemaFileName), """ + File.WriteAllText(Path.Combine(ModDirectory, Native.ModConfigSchema.SchemaFileName), """ { "Configurations": [ { "FileName": "Config.json", "Properties": [ { "Name": "Broken", "Type": "enum" } ] } ] } """); var configurator = CreateConfigurator(); @@ -325,9 +325,9 @@ public void Enum_Type_Without_Values_Gives_Hint() Assert.Contains("Values", error.Message); } - private NativeModConfigurator CreateConfigurator() + private Native.ModConfigurator CreateConfigurator() { - var configurator = new NativeModConfigurator(ModDirectory); + var configurator = new Native.ModConfigurator(ModDirectory); configurator.SetModDirectory(ModDirectory); configurator.SetConfigDirectory(ConfigDirectory); return configurator; From 1f8bd02f45001055c847ec81524c6bbc95e40fa2 Mon Sep 17 00:00:00 2001 From: Sewer56 Date: Sun, 20 Sep 2026 22:19:43 +0100 Subject: [PATCH 17/35] Changed: Split native mod config schema models into Schema subnamespace - Move 7 schema models plus internal Keys/JsonNodeExtensions out of ModConfigSchema.cs into Native/Schema/, shrinking it 742 to 68 lines - Rename moved types to prefix-free names (Configuration, Property, Slider, FilePicker, FolderPicker, Enum, EnumMember) in the new Native.Schema namespace - Point ModConfigSchema and ConfigTypeEmitter at the moved types via Schema.* qualification; all other callers unchanged The renamed types were public but unused outside Launcher.Lib's Native folder, so no caller migration is needed. --- .../Configuration/Native/ConfigTypeEmitter.cs | 28 +- .../Configuration/Native/ModConfigSchema.cs | 684 +----------------- .../Native/Schema/Configuration.cs | 73 ++ .../Model/Configuration/Native/Schema/Enum.cs | 49 ++ .../Configuration/Native/Schema/EnumMember.cs | 29 + .../Configuration/Native/Schema/FilePicker.cs | 97 +++ .../Native/Schema/FolderPicker.cs | 73 ++ .../Native/Schema/JsonNodeExtensions.cs | 98 +++ .../Model/Configuration/Native/Schema/Keys.cs | 55 ++ .../Configuration/Native/Schema/Property.cs | 134 ++++ .../Configuration/Native/Schema/Slider.cs | 91 +++ 11 files changed, 719 insertions(+), 692 deletions(-) create mode 100644 source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/Schema/Configuration.cs create mode 100644 source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/Schema/Enum.cs create mode 100644 source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/Schema/EnumMember.cs create mode 100644 source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/Schema/FilePicker.cs create mode 100644 source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/Schema/FolderPicker.cs create mode 100644 source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/Schema/JsonNodeExtensions.cs create mode 100644 source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/Schema/Keys.cs create mode 100644 source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/Schema/Property.cs create mode 100644 source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/Schema/Slider.cs diff --git a/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/ConfigTypeEmitter.cs b/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/ConfigTypeEmitter.cs index 481c1daa..b9f6dfa1 100644 --- a/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/ConfigTypeEmitter.cs +++ b/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/ConfigTypeEmitter.cs @@ -35,7 +35,7 @@ public static class ConfigTypeEmitter /// Thrown when a property's Type is unknown, or a control does not match /// the property type. /// - public static ConfigurableBase CreateInstance(ConfigSchemaConfiguration configuration, string cacheKey) + public static ConfigurableBase CreateInstance(Schema.Configuration configuration, string cacheKey) { Type type; lock (BuildLock) @@ -50,7 +50,7 @@ public static ConfigurableBase CreateInstance(ConfigSchemaConfiguration configur return (ConfigurableBase)Activator.CreateInstance(type)!; } - private static Type BuildType(ConfigSchemaConfiguration configuration, string cacheKey) + private static Type BuildType(Schema.Configuration configuration, string cacheKey) { var module = GetModule(); var typeBuilder = module.DefineType($"NativeModConfig_{Interlocked.Increment(ref _typeCounter)}_{MakeIdentifier(cacheKey)}", TypeAttributes.Public | TypeAttributes.Class | TypeAttributes.Sealed, typeof(ConfigurableBase)); @@ -114,7 +114,7 @@ private static Type BuildType(ConfigSchemaConfiguration configuration, string ca /// /// Declared enums plus one per property with inline Values. /// - private static IEnumerable CollectEnums(ConfigSchemaConfiguration configuration) + private static IEnumerable CollectEnums(Schema.Configuration configuration) { foreach (var schemaEnum in configuration.Enums) yield return schemaEnum; @@ -122,35 +122,35 @@ private static IEnumerable CollectEnums(ConfigSchemaConfigurat foreach (var property in configuration.Properties) { if (property.Values.Count > 0) - yield return new ConfigSchemaEnum() { Name = property.Name, Members = property.Values }; + yield return new Schema.Enum() { Name = property.Name, Members = property.Values }; } } - private static (Type propertyType, object? defaultValue) ResolveTypeAndDefault(ConfigSchemaProperty property, Dictionary enums) + private static (Type propertyType, object? defaultValue) ResolveTypeAndDefault(Schema.Property property, Dictionary enums) { switch (property.Type) { - case ConfigSchemaProperty.SupportedTypes.Bool: + case Schema.Property.SupportedTypes.Bool: return (typeof(bool), property.DefaultValue is bool b ? b : false); - case ConfigSchemaProperty.SupportedTypes.Int: + case Schema.Property.SupportedTypes.Int: return (typeof(int), property.DefaultValue == null ? 0 : Convert.ToInt32(property.DefaultValue)); - case ConfigSchemaProperty.SupportedTypes.Float: + case Schema.Property.SupportedTypes.Float: return (typeof(float), property.DefaultValue == null ? 0.0f : Convert.ToSingle(property.DefaultValue)); - case ConfigSchemaProperty.SupportedTypes.Double: + case Schema.Property.SupportedTypes.Double: return (typeof(double), property.DefaultValue == null ? 0.0 : Convert.ToDouble(property.DefaultValue)); - case ConfigSchemaProperty.SupportedTypes.String: + case Schema.Property.SupportedTypes.String: return (typeof(string), property.DefaultValue?.ToString()); default: if (!enums.TryGetValue(property.Type, out var enumType)) { var hint = string.Equals(property.Type, "enum", StringComparison.OrdinalIgnoreCase) - ? $"Inline enums need a '{Keys.Values}' array on the property." - : $"Declare an enum with this name under '{Keys.Enums}'."; + ? $"Inline enums need a '{Schema.Keys.Values}' array on the property." + : $"Declare an enum with this name under '{Schema.Keys.Enums}'."; throw new InvalidOperationException($"Property '{property.Name}' has unknown Type '{property.Type}'. {hint}"); } @@ -159,7 +159,7 @@ private static (Type propertyType, object? defaultValue) ResolveTypeAndDefault(C } } - private static object GetEnumDefault(ConfigSchemaProperty property, Type enumType) + private static object GetEnumDefault(Schema.Property property, Type enumType) { if (property.DefaultValue is string memberName) { @@ -214,7 +214,7 @@ private static void EmitFieldInit(ILGenerator il, FieldBuilder field, Type prope il.Emit(OpCodes.Stfld, field); } - private static IEnumerable BuildAttributes(ConfigSchemaProperty property, Type propertyType, object? defaultValue) + private static IEnumerable BuildAttributes(Schema.Property property, Type propertyType, object? defaultValue) { if (property.DisplayName != null) yield return new CustomAttributeBuilder(GetCtor(typeof(DisplayNameAttribute), 1), new object[] { property.DisplayName }); diff --git a/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/ModConfigSchema.cs b/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/ModConfigSchema.cs index 7f61bab6..ab53af6b 100644 --- a/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/ModConfigSchema.cs +++ b/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/ModConfigSchema.cs @@ -12,6 +12,8 @@ namespace Reloaded.Mod.Launcher.Lib.Models.Model.Configuration.Native; /// Native and C# mods therefore look and behave the same: /// - DisplayName, Description, Category, DefaultValue /// - Slider/File/Folder control params +/// +/// The individual schema models live in the namespace. /// public class ModConfigSchema { @@ -23,7 +25,7 @@ public class ModConfigSchema /// /// The individual configurations (pages/files) exposed by the mod. /// - public List Configurations { get; set; } = new(); + public List Configurations { get; set; } = new(); /// /// Returns true if the specified mod directory contains a config schema. @@ -49,14 +51,14 @@ private static ModConfigSchema Parse(JsonNode node, string modDirectory) try { var schema = new ModConfigSchema(); - if (node[Keys.Configurations] is JsonArray configurations) + if (node[Schema.Keys.Configurations] is JsonArray configurations) { foreach (var configurationNode in configurations) - schema.Configurations.Add(ConfigSchemaConfiguration.Parse(configurationNode!)); + schema.Configurations.Add(Schema.Configuration.Parse(configurationNode!)); } if (schema.Configurations.Count <= 0) - throw new JsonException($"Schema requires at least one entry in '{Keys.Configurations}'."); + throw new JsonException($"Schema requires at least one entry in '{Schema.Keys.Configurations}'."); return schema; } @@ -66,677 +68,3 @@ private static ModConfigSchema Parse(JsonNode node, string modDirectory) } } } - -/// -/// Individual configuration of a native mod; essentially mirrors one -/// IConfigurable from the C# mod template. -/// -public class ConfigSchemaConfiguration -{ - /// - /// Name of the config file where the values for this configuration are stored. - /// Defaults to Config.json, matching the C# template. - /// - public string FileName { get; set; } = "Config.json"; - - /// - /// Name shown in the launcher's configuration dropdown. - /// - public string? DisplayName { get; set; } - - /// - /// Enumerations available to the properties of this configuration. - /// - public List Enums { get; set; } = new(); - - /// - /// The individual settings. - /// - public List Properties { get; set; } = new(); - - /// - /// Reads a configuration from its JSON representation. - /// - /// Node holding the configuration's properties. - /// - /// Thrown when FileName is not a plain file name. - /// - public static ConfigSchemaConfiguration Parse(JsonNode node) - { - var configuration = new ConfigSchemaConfiguration - { - FileName = ValidateFileName(node.GetStringOrDefault(Keys.FileName, "Config.json")!), - DisplayName = node.GetStringOrDefault(Keys.DisplayName, null) - }; - - if (node[Keys.Enums] is JsonArray enums) - { - foreach (var enumNode in enums) - configuration.Enums.Add(ConfigSchemaEnum.Parse(enumNode!)); - } - - if (node[Keys.Properties] is JsonArray properties) - { - foreach (var propertyNode in properties) - configuration.Properties.Add(ConfigSchemaProperty.Parse(propertyNode!)); - } - - return configuration; - } - - /// - /// The file name builds paths inside the user config directory, so it - /// must be a plain file name; rooted paths and separators fail validation. - /// - private static string ValidateFileName(string fileName) - { - if (fileName.Length <= 0 || Path.IsPathRooted(fileName) || fileName != Path.GetFileName(fileName)) - throw new JsonException($"'{Keys.FileName}' must be a plain file name, got '{fileName}'."); - - return fileName; - } -} - -/// -/// Enumeration with display names, rendered as a list in Reloaded. -/// -public class ConfigSchemaEnum -{ - /// - /// Name of the enum type, referenced by property Type. - /// - public string Name { get; set; } = ""; - - /// - /// The individual values of the enum. - /// - public List Members { get; set; } = new(); - - /// - /// Reads an enum from its JSON representation. - /// - /// Node holding the enum's properties. - /// - /// Thrown when the enum declares no members. - /// - public static ConfigSchemaEnum Parse(JsonNode node) - { - var result = new ConfigSchemaEnum - { - Name = node.GetStringOrDefault(Keys.Name, "")! - }; - - if (node[Keys.Members] is JsonArray members) - { - foreach (var memberNode in members) - { - var member = ConfigSchemaEnumMember.Parse(memberNode!); - if (member.Name.Length > 0) - result.Members.Add(member); - } - } - - if (result.Members.Count <= 0) - throw new JsonException($"Enum '{result.Name}' requires at least one entry in '{Keys.Members}'."); - - return result; - } -} - -/// -/// An individual value of a schema enum. -/// -public class ConfigSchemaEnumMember -{ - /// - /// Name of the value, stored in the config file. - /// - public string Name { get; set; } = ""; - - /// - /// Name shown in the launcher UI. Falls back to . - /// - public string? DisplayName { get; set; } - - /// - /// Reads an enum member from its JSON representation. - /// - /// Node holding the member's properties. - public static ConfigSchemaEnumMember Parse(JsonNode node) => new() - { - Name = node.GetStringOrDefault(Keys.Name, "")!, - DisplayName = node.GetStringOrDefault(Keys.DisplayName, null) - }; -} - -/// -/// An individual setting of a configuration; mirrors a property of a -/// C# mod's config class. -/// -public class ConfigSchemaProperty -{ - /// - /// Supported values for . - /// - internal static class SupportedTypes - { - public const string Bool = "bool"; - public const string Int = "int"; - public const string Float = "float"; - public const string Double = "double"; - public const string String = "string"; - } - - /// - /// Name of the setting, stored in the config file. - /// - public string Name { get; set; } = ""; - - /// - /// Type of the setting; one of the following: - /// - bool, int, float, double or string - /// - the name of an enum declared in the same configuration - /// - public string Type { get; set; } = SupportedTypes.String; - - /// - /// Friendly name shown in the launcher. Falls back to . - /// - public string? DisplayName { get; set; } - - /// - /// Tooltip description shown in the launcher. - /// - public string? Description { get; set; } - - /// - /// Category (group) the setting is displayed under. - /// - public string? Category { get; set; } - - /// - /// Sort order of the setting, lowest first. - /// - public int? Order { get; set; } - - /// - /// Default value of the setting; its shape matches : - /// - a bool, int, float or double literal - /// - a string or an enum member name - /// Initial value before any user change; the Reset button restores it. - /// - public object? DefaultValue { get; set; } - - /// - /// Renders this setting as a slider. Only valid for numeric types. - /// - public ConfigSchemaSlider? Slider { get; set; } - - /// - /// Renders this setting (string) with a file picker dialog. - /// - public ConfigSchemaFilePicker? FilePicker { get; set; } - - /// - /// Renders this setting (string) with a folder picker dialog. - /// - public ConfigSchemaFolderPicker? FolderPicker { get; set; } - - /// - /// Enum values declared directly on the property, for the common case - /// of an enum used by a single setting. - /// - public List Values { get; set; } = new(); - - /// - /// Reads a property from its JSON representation. - /// - /// Node holding the setting's properties. - /// - /// Thrown when the property has no name. - /// - public static ConfigSchemaProperty Parse(JsonNode node) - { - var property = new ConfigSchemaProperty - { - Name = node.GetStringOrDefault(Keys.Name, "")!, - Type = node.GetStringOrDefault(Keys.Type, SupportedTypes.String)!.ToLowerInvariant(), - DisplayName = node.GetStringOrDefault(Keys.DisplayName, null), - Description = node.GetStringOrDefault(Keys.Description, null), - Category = node.GetStringOrDefault(Keys.Category, null), - Order = node.GetIntOrNull(Keys.Order), - DefaultValue = node[Keys.DefaultValue].GetValueOrNull() - }; - - if (node[Keys.Slider] is JsonNode slider) - property.Slider = ConfigSchemaSlider.Parse(slider); - - if (node[Keys.FilePicker] is JsonNode filePicker) - property.FilePicker = ConfigSchemaFilePicker.Parse(filePicker); - - if (node[Keys.FolderPicker] is JsonNode folderPicker) - property.FolderPicker = ConfigSchemaFolderPicker.Parse(folderPicker); - - if (node[Keys.Values] is JsonArray values) - { - foreach (var valueNode in values) - { - var member = valueNode!.GetValueKind() == JsonValueKind.String - ? new ConfigSchemaEnumMember { Name = valueNode.GetValue() } - : ConfigSchemaEnumMember.Parse(valueNode); - - if (member.Name.Length > 0) - property.Values.Add(member); - } - - if (property.Values.Count > 0) - property.Type = property.Name; // inline enums uses the property name. - } - - if (property.Name.Length <= 0) - throw new JsonException($"A property in the schema has no '{Keys.Name}'."); - - return property; - } -} - -/// -/// Parameters for the slider control; mirrors -/// SliderControlParamsAttribute of the C# interface. -/// -public class ConfigSchemaSlider -{ - /// - /// Minimum value of the slider. - /// - public double Minimum { get; set; } = 0.0; - - /// - /// Maximum value of the slider. - /// - public double Maximum { get; set; } = 1.0; - - /// - /// Value change of a small step (arrow keys). - /// - public double SmallChange { get; set; } = 0.1; - - /// - /// Value change of a large step (page up/down or gutter click). - /// - public double LargeChange { get; set; } = 1.0; - - /// - /// Distance between tick marks. Legacy; - /// wins when greater than zero. - /// - public int TickFrequency { get; set; } = 10; - - /// - /// Snap the value to the nearest tick. - /// - public bool IsSnapToTickEnabled { get; set; } = false; - - /// - /// Where tick marks are drawn; a SliderControlTickPlacement name. - /// - public string TickPlacement { get; set; } = "None"; - - /// - /// Show the value in a text field left of the slider. - /// - public bool ShowTextField { get; set; } = false; - - /// - /// Allow typing in the text field. - /// - public bool IsTextFieldEditable { get; set; } = true; - - /// - /// Regex the text field input must match. - /// - public string TextValidationRegex { get; set; } = ".*"; - - /// - /// Format string applied to the text field value. - /// - public string TextFieldFormat { get; set; } = ""; - - /// - /// Distance between tick marks; allows fractions. - /// - public double TickFrequencyDouble { get; set; } = 0.0; - - /// - /// Reads the slider parameters from their JSON representation. - /// - /// Node holding the slider's properties. - public static ConfigSchemaSlider Parse(JsonNode node) => new() - { - Minimum = node.GetDoubleOrDefault(Keys.Minimum, 0.0), - Maximum = node.GetDoubleOrDefault(Keys.Maximum, 1.0), - SmallChange = node.GetDoubleOrDefault(Keys.SmallChange, 0.1), - LargeChange = node.GetDoubleOrDefault(Keys.LargeChange, 1.0), - TickFrequency = node.GetIntOrDefault(Keys.TickFrequency, 10), - IsSnapToTickEnabled = node.GetBoolOrDefault(Keys.IsSnapToTickEnabled, false), - TickPlacement = node.GetStringOrDefault(Keys.TickPlacement, "None")!, - ShowTextField = node.GetBoolOrDefault(Keys.ShowTextField, false), - IsTextFieldEditable = node.GetBoolOrDefault(Keys.IsTextFieldEditable, true), - TextValidationRegex = node.GetStringOrDefault(Keys.TextValidationRegex, ".*")!, - TextFieldFormat = node.GetStringOrDefault(Keys.TextFieldFormat, "")!, - TickFrequencyDouble = node.GetDoubleOrDefault(Keys.TickFrequencyDouble, 0.0) - }; -} - -/// -/// Parameters for the file picker control; mirrors -/// FilePickerParamsAttribute of the C# interface. -/// -public class ConfigSchemaFilePicker -{ - /// - /// Initial directory shown; null for the default. - /// - public string? InitialDirectory { get; set; } - - /// - /// Fallback folder when is null, as an - /// Environment.SpecialFolder value. - /// - public int InitialFolderPath { get; set; } = 0x05; // Environment.SpecialFolder.Personal - - /// - /// Label of the choose file button. - /// - public string ChooseFileButtonLabel { get; set; } = "Choose File"; - - /// - /// Allow typing in the path box. - /// - public bool UserCanEditPathText { get; set; } = true; - - /// - /// Title of the dialog. - /// - public string Title { get; set; } = ""; - - /// - /// Filter of the dialog, e.g. All files (*.*)|*.*. - /// - public string Filter { get; set; } = "All files (*.*)|*.*"; - - /// - /// Index of the filter selected at open. - /// - public int FilterIndex { get; set; } = 0; - - /// - /// Allow selecting multiple files. - /// - public bool Multiselect { get; set; } = false; - - /// - /// Support extensions with multiple dots, e.g. .tar.gz. - /// - public bool SupportMultiDottedExtensions { get; set; } = false; - - /// - /// Show hidden files in the dialog. - /// - public bool ShowHiddenFiles { get; set; } = false; - - /// - /// Show the file preview pane. - /// - public bool ShowPreview { get; set; } = false; - - /// - /// Restore the working directory after the dialog closes. - /// - public bool RestoreDirectory { get; set; } = false; - - /// - /// Add the chosen file to the recent documents. - /// - public bool AddToRecent { get; set; } = false; - - /// - /// Reads the file picker parameters from their JSON representation. - /// - /// Node holding the picker's properties. - public static ConfigSchemaFilePicker Parse(JsonNode node) => new() - { - InitialDirectory = node.GetStringOrDefault(Keys.InitialDirectory, null), - InitialFolderPath = node.GetIntOrDefault(Keys.InitialFolderPath, 0x05), - ChooseFileButtonLabel = node.GetStringOrDefault(Keys.ChooseFileButtonLabel, "Choose File")!, - UserCanEditPathText = node.GetBoolOrDefault(Keys.UserCanEditPathText, true), - Title = node.GetStringOrDefault(Keys.Title, "")!, - Filter = node.GetStringOrDefault(Keys.Filter, "All files (*.*)|*.*")!, - FilterIndex = node.GetIntOrDefault(Keys.FilterIndex, 0), - Multiselect = node.GetBoolOrDefault(Keys.Multiselect, false), - SupportMultiDottedExtensions = node.GetBoolOrDefault(Keys.SupportMultiDottedExtensions, false), - ShowHiddenFiles = node.GetBoolOrDefault(Keys.ShowHiddenFiles, false), - ShowPreview = node.GetBoolOrDefault(Keys.ShowPreview, false), - RestoreDirectory = node.GetBoolOrDefault(Keys.RestoreDirectory, false), - AddToRecent = node.GetBoolOrDefault(Keys.AddToRecent, false) - }; -} - -/// -/// Parameters for the folder picker control; mirrors -/// FolderPickerParamsAttribute of the C# interface. -/// -public class ConfigSchemaFolderPicker -{ - /// - /// Initial directory shown; null for the default. - /// - public string? InitialDirectory { get; set; } - - /// - /// Fallback folder when is null, as an - /// Environment.SpecialFolder value. - /// - public int InitialFolderPath { get; set; } = 0x05; // Environment.SpecialFolder.Personal - - /// - /// Label of the choose folder button. - /// - public string ChooseFolderButtonLabel { get; set; } = "Choose Folder"; - - /// - /// Allow typing in the path box. - /// - public bool UserCanEditPathText { get; set; } = true; - - /// - /// Title of the dialog. - /// - public string Title { get; set; } = ""; - - /// - /// Label of the OK button. - /// - public string OkButtonLabel { get; set; } = "Ok"; - - /// - /// Label of the file name box. - /// - public string FileNameLabel { get; set; } = ""; - - /// - /// Allow selecting multiple folders. - /// - public bool Multiselect { get; set; } = false; - - /// - /// Only accept folders in the file system. - /// - public bool ForceFileSystem { get; set; } = false; - - /// - /// Reads the folder picker parameters from their JSON representation. - /// - /// Node holding the picker's properties. - public static ConfigSchemaFolderPicker Parse(JsonNode node) => new() - { - InitialDirectory = node.GetStringOrDefault(Keys.InitialDirectory, null), - InitialFolderPath = node.GetIntOrDefault(Keys.InitialFolderPath, 0x05), - ChooseFolderButtonLabel = node.GetStringOrDefault(Keys.ChooseFolderButtonLabel, "Choose Folder")!, - UserCanEditPathText = node.GetBoolOrDefault(Keys.UserCanEditPathText, true), - Title = node.GetStringOrDefault(Keys.Title, "")!, - OkButtonLabel = node.GetStringOrDefault(Keys.OkButtonLabel, "Ok")!, - FileNameLabel = node.GetStringOrDefault(Keys.FileNameLabel, "")!, - Multiselect = node.GetBoolOrDefault(Keys.Multiselect, false), - ForceFileSystem = node.GetBoolOrDefault(Keys.ForceFileSystem, false) - }; -} - -/// -/// JSON property names used by the schema file. -/// -internal static class Keys -{ - public const string Configurations = "Configurations"; - public const string FileName = "FileName"; - public const string DisplayName = "DisplayName"; - public const string Enums = "Enums"; - public const string Properties = "Properties"; - public const string Members = "Members"; - public const string Name = "Name"; - public const string Type = "Type"; - public const string Description = "Description"; - public const string Category = "Category"; - public const string Order = "Order"; - public const string DefaultValue = "DefaultValue"; - public const string Slider = "Slider"; - public const string FilePicker = "FilePicker"; - public const string FolderPicker = "FolderPicker"; - public const string Values = "Values"; - - // Control Params - public const string Minimum = "Minimum"; - public const string Maximum = "Maximum"; - public const string SmallChange = "SmallChange"; - public const string LargeChange = "LargeChange"; - public const string TickFrequency = "TickFrequency"; - public const string TickFrequencyDouble = "TickFrequencyDouble"; - public const string IsSnapToTickEnabled = "IsSnapToTickEnabled"; - public const string TickPlacement = "TickPlacement"; - public const string ShowTextField = "ShowTextField"; - public const string IsTextFieldEditable = "IsTextFieldEditable"; - public const string TextValidationRegex = "TextValidationRegex"; - public const string TextFieldFormat = "TextFieldFormat"; - public const string InitialDirectory = "InitialDirectory"; - public const string InitialFolderPath = "InitialFolderPath"; - public const string ChooseFileButtonLabel = "ChooseFileButtonLabel"; - public const string ChooseFolderButtonLabel = "ChooseFolderButtonLabel"; - public const string UserCanEditPathText = "UserCanEditPathText"; - public const string Title = "Title"; - public const string Filter = "Filter"; - public const string FilterIndex = "FilterIndex"; - public const string Multiselect = "Multiselect"; - public const string SupportMultiDottedExtensions = "SupportMultiDottedExtensions"; - public const string ShowHiddenFiles = "ShowHiddenFiles"; - public const string ShowPreview = "ShowPreview"; - public const string RestoreDirectory = "RestoreDirectory"; - public const string AddToRecent = "AddToRecent"; - public const string OkButtonLabel = "OkButtonLabel"; - public const string FileNameLabel = "FileNameLabel"; - public const string ForceFileSystem = "ForceFileSystem"; -} - -/// -/// Helper extensions for reading values out of s. -/// -internal static class JsonNodeExtensions -{ - public static string? GetStringOrDefault(this JsonNode? node, string name, string? fallback) - { - var value = node?[name]; - if (value == null) - return fallback; - - return value.GetValueKind() == JsonValueKind.String ? value.GetValue() : fallback; - } - - public static int GetIntOrDefault(this JsonNode? node, string name, int fallback) - { - var value = node?[name]; - if (value == null) - return fallback; - - if (value.GetValueKind() == JsonValueKind.Number) - { - var element = value.GetValue(); - if (element.TryGetInt32(out var result)) - return result; - } - - return fallback; - } - - public static int? GetIntOrNull(this JsonNode? node, string name) - { - var value = node?[name]; - if (value == null) - return null; - - if (value.GetValueKind() == JsonValueKind.Number) - { - var element = value.GetValue(); - if (element.TryGetInt32(out var result)) - return result; - } - - return null; - } - - public static double GetDoubleOrDefault(this JsonNode? node, string name, double fallback) - { - var value = node?[name]; - if (value == null) - return fallback; - - return value.GetValueKind() == JsonValueKind.Number ? value.GetValue().GetDouble() : fallback; - } - - public static bool GetBoolOrDefault(this JsonNode? node, string name, bool fallback) - { - var value = node?[name]; - if (value == null) - return fallback; - - return value.GetValueKind() == JsonValueKind.True || value.GetValueKind() == JsonValueKind.False ? value.GetValue() : fallback; - } - - /// - /// Returns the raw boxed value of a node as one of the following: - /// - bool - /// - int if it fits, else double - /// - string - /// - null for any other content - /// - public static object? GetValueOrNull(this JsonNode? node) - { - if (node == null) - return null; - - var kind = node.GetValueKind(); - if (kind == JsonValueKind.True || kind == JsonValueKind.False) - return node.GetValue(); - - if (kind == JsonValueKind.Number) - { - var element = node.GetValue(); - return element.TryGetInt32(out var i) ? i : element.GetDouble(); - } - - if (kind == JsonValueKind.String) - return node.GetValue(); - - return null; - } - - public static JsonValueKind GetValueKind(this JsonNode node) => node.GetValue().ValueKind; -} diff --git a/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/Schema/Configuration.cs b/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/Schema/Configuration.cs new file mode 100644 index 00000000..dc576471 --- /dev/null +++ b/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/Schema/Configuration.cs @@ -0,0 +1,73 @@ +using System.Text.Json.Nodes; + +namespace Reloaded.Mod.Launcher.Lib.Models.Model.Configuration.Native.Schema; + +/// +/// Individual configuration of a native mod; essentially mirrors one +/// IConfigurable from the C# mod template. +/// +public class Configuration +{ + /// + /// Name of the config file where the values for this configuration are stored. + /// Defaults to Config.json, matching the C# template. + /// + public string FileName { get; set; } = "Config.json"; + + /// + /// Name shown in the launcher's configuration dropdown. + /// + public string? DisplayName { get; set; } + + /// + /// Enumerations available to the properties of this configuration. + /// + public List Enums { get; set; } = new(); + + /// + /// The individual settings. + /// + public List Properties { get; set; } = new(); + + /// + /// Reads a configuration from its JSON representation. + /// + /// Node holding the configuration's properties. + /// + /// Thrown when FileName is not a plain file name. + /// + public static Configuration Parse(JsonNode node) + { + var configuration = new Configuration + { + FileName = ValidateFileName(node.GetStringOrDefault(Keys.FileName, "Config.json")!), + DisplayName = node.GetStringOrDefault(Keys.DisplayName, null) + }; + + if (node[Keys.Enums] is JsonArray enums) + { + foreach (var enumNode in enums) + configuration.Enums.Add(Enum.Parse(enumNode!)); + } + + if (node[Keys.Properties] is JsonArray properties) + { + foreach (var propertyNode in properties) + configuration.Properties.Add(Property.Parse(propertyNode!)); + } + + return configuration; + } + + /// + /// The file name builds paths inside the user config directory, so it + /// must be a plain file name; rooted paths and separators fail validation. + /// + private static string ValidateFileName(string fileName) + { + if (fileName.Length <= 0 || Path.IsPathRooted(fileName) || fileName != Path.GetFileName(fileName)) + throw new JsonException($"'{Keys.FileName}' must be a plain file name, got '{fileName}'."); + + return fileName; + } +} diff --git a/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/Schema/Enum.cs b/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/Schema/Enum.cs new file mode 100644 index 00000000..59dbd876 --- /dev/null +++ b/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/Schema/Enum.cs @@ -0,0 +1,49 @@ +using System.Text.Json.Nodes; + +namespace Reloaded.Mod.Launcher.Lib.Models.Model.Configuration.Native.Schema; + +/// +/// Enumeration with display names, rendered as a list in Reloaded. +/// +public class Enum +{ + /// + /// Name of the enum type, referenced by property Type. + /// + public string Name { get; set; } = ""; + + /// + /// The individual values of the enum. + /// + public List Members { get; set; } = new(); + + /// + /// Reads an enum from its JSON representation. + /// + /// Node holding the enum's properties. + /// + /// Thrown when the enum declares no members. + /// + public static Enum Parse(JsonNode node) + { + var result = new Enum + { + Name = node.GetStringOrDefault(Keys.Name, "")! + }; + + if (node[Keys.Members] is JsonArray members) + { + foreach (var memberNode in members) + { + var member = EnumMember.Parse(memberNode!); + if (member.Name.Length > 0) + result.Members.Add(member); + } + } + + if (result.Members.Count <= 0) + throw new JsonException($"Enum '{result.Name}' requires at least one entry in '{Keys.Members}'."); + + return result; + } +} diff --git a/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/Schema/EnumMember.cs b/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/Schema/EnumMember.cs new file mode 100644 index 00000000..b495efee --- /dev/null +++ b/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/Schema/EnumMember.cs @@ -0,0 +1,29 @@ +using System.Text.Json.Nodes; + +namespace Reloaded.Mod.Launcher.Lib.Models.Model.Configuration.Native.Schema; + +/// +/// An individual value of a schema enum. +/// +public class EnumMember +{ + /// + /// Name of the value, stored in the config file. + /// + public string Name { get; set; } = ""; + + /// + /// Name shown in the launcher UI. Falls back to . + /// + public string? DisplayName { get; set; } + + /// + /// Reads an enum member from its JSON representation. + /// + /// Node holding the member's properties. + public static EnumMember Parse(JsonNode node) => new() + { + Name = node.GetStringOrDefault(Keys.Name, "")!, + DisplayName = node.GetStringOrDefault(Keys.DisplayName, null) + }; +} diff --git a/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/Schema/FilePicker.cs b/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/Schema/FilePicker.cs new file mode 100644 index 00000000..cbe50929 --- /dev/null +++ b/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/Schema/FilePicker.cs @@ -0,0 +1,97 @@ +using System.Text.Json.Nodes; + +namespace Reloaded.Mod.Launcher.Lib.Models.Model.Configuration.Native.Schema; + +/// +/// Parameters for the file picker control; mirrors +/// FilePickerParamsAttribute of the C# interface. +/// +public class FilePicker +{ + /// + /// Initial directory shown; null for the default. + /// + public string? InitialDirectory { get; set; } + + /// + /// Fallback folder when is null, as an + /// Environment.SpecialFolder value. + /// + public int InitialFolderPath { get; set; } = 0x05; // Environment.SpecialFolder.Personal + + /// + /// Label of the choose file button. + /// + public string ChooseFileButtonLabel { get; set; } = "Choose File"; + + /// + /// Allow typing in the path box. + /// + public bool UserCanEditPathText { get; set; } = true; + + /// + /// Title of the dialog. + /// + public string Title { get; set; } = ""; + + /// + /// Filter of the dialog, e.g. All files (*.*)|*.*. + /// + public string Filter { get; set; } = "All files (*.*)|*.*"; + + /// + /// Index of the filter selected at open. + /// + public int FilterIndex { get; set; } = 0; + + /// + /// Allow selecting multiple files. + /// + public bool Multiselect { get; set; } = false; + + /// + /// Support extensions with multiple dots, e.g. .tar.gz. + /// + public bool SupportMultiDottedExtensions { get; set; } = false; + + /// + /// Show hidden files in the dialog. + /// + public bool ShowHiddenFiles { get; set; } = false; + + /// + /// Show the file preview pane. + /// + public bool ShowPreview { get; set; } = false; + + /// + /// Restore the working directory after the dialog closes. + /// + public bool RestoreDirectory { get; set; } = false; + + /// + /// Add the chosen file to the recent documents. + /// + public bool AddToRecent { get; set; } = false; + + /// + /// Reads the file picker parameters from their JSON representation. + /// + /// Node holding the picker's properties. + public static FilePicker Parse(JsonNode node) => new() + { + InitialDirectory = node.GetStringOrDefault(Keys.InitialDirectory, null), + InitialFolderPath = node.GetIntOrDefault(Keys.InitialFolderPath, 0x05), + ChooseFileButtonLabel = node.GetStringOrDefault(Keys.ChooseFileButtonLabel, "Choose File")!, + UserCanEditPathText = node.GetBoolOrDefault(Keys.UserCanEditPathText, true), + Title = node.GetStringOrDefault(Keys.Title, "")!, + Filter = node.GetStringOrDefault(Keys.Filter, "All files (*.*)|*.*")!, + FilterIndex = node.GetIntOrDefault(Keys.FilterIndex, 0), + Multiselect = node.GetBoolOrDefault(Keys.Multiselect, false), + SupportMultiDottedExtensions = node.GetBoolOrDefault(Keys.SupportMultiDottedExtensions, false), + ShowHiddenFiles = node.GetBoolOrDefault(Keys.ShowHiddenFiles, false), + ShowPreview = node.GetBoolOrDefault(Keys.ShowPreview, false), + RestoreDirectory = node.GetBoolOrDefault(Keys.RestoreDirectory, false), + AddToRecent = node.GetBoolOrDefault(Keys.AddToRecent, false) + }; +} diff --git a/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/Schema/FolderPicker.cs b/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/Schema/FolderPicker.cs new file mode 100644 index 00000000..e00c6baf --- /dev/null +++ b/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/Schema/FolderPicker.cs @@ -0,0 +1,73 @@ +using System.Text.Json.Nodes; + +namespace Reloaded.Mod.Launcher.Lib.Models.Model.Configuration.Native.Schema; + +/// +/// Parameters for the folder picker control; mirrors +/// FolderPickerParamsAttribute of the C# interface. +/// +public class FolderPicker +{ + /// + /// Initial directory shown; null for the default. + /// + public string? InitialDirectory { get; set; } + + /// + /// Fallback folder when is null, as an + /// Environment.SpecialFolder value. + /// + public int InitialFolderPath { get; set; } = 0x05; // Environment.SpecialFolder.Personal + + /// + /// Label of the choose folder button. + /// + public string ChooseFolderButtonLabel { get; set; } = "Choose Folder"; + + /// + /// Allow typing in the path box. + /// + public bool UserCanEditPathText { get; set; } = true; + + /// + /// Title of the dialog. + /// + public string Title { get; set; } = ""; + + /// + /// Label of the OK button. + /// + public string OkButtonLabel { get; set; } = "Ok"; + + /// + /// Label of the file name box. + /// + public string FileNameLabel { get; set; } = ""; + + /// + /// Allow selecting multiple folders. + /// + public bool Multiselect { get; set; } = false; + + /// + /// Only accept folders in the file system. + /// + public bool ForceFileSystem { get; set; } = false; + + /// + /// Reads the folder picker parameters from their JSON representation. + /// + /// Node holding the picker's properties. + public static FolderPicker Parse(JsonNode node) => new() + { + InitialDirectory = node.GetStringOrDefault(Keys.InitialDirectory, null), + InitialFolderPath = node.GetIntOrDefault(Keys.InitialFolderPath, 0x05), + ChooseFolderButtonLabel = node.GetStringOrDefault(Keys.ChooseFolderButtonLabel, "Choose Folder")!, + UserCanEditPathText = node.GetBoolOrDefault(Keys.UserCanEditPathText, true), + Title = node.GetStringOrDefault(Keys.Title, "")!, + OkButtonLabel = node.GetStringOrDefault(Keys.OkButtonLabel, "Ok")!, + FileNameLabel = node.GetStringOrDefault(Keys.FileNameLabel, "")!, + Multiselect = node.GetBoolOrDefault(Keys.Multiselect, false), + ForceFileSystem = node.GetBoolOrDefault(Keys.ForceFileSystem, false) + }; +} diff --git a/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/Schema/JsonNodeExtensions.cs b/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/Schema/JsonNodeExtensions.cs new file mode 100644 index 00000000..c238d250 --- /dev/null +++ b/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/Schema/JsonNodeExtensions.cs @@ -0,0 +1,98 @@ +using System.Text.Json.Nodes; + +namespace Reloaded.Mod.Launcher.Lib.Models.Model.Configuration.Native.Schema; + +/// +/// Helper extensions for reading values out of s. +/// +internal static class JsonNodeExtensions +{ + public static string? GetStringOrDefault(this JsonNode? node, string name, string? fallback) + { + var value = node?[name]; + if (value == null) + return fallback; + + return value.GetValueKind() == JsonValueKind.String ? value.GetValue() : fallback; + } + + public static int GetIntOrDefault(this JsonNode? node, string name, int fallback) + { + var value = node?[name]; + if (value == null) + return fallback; + + if (value.GetValueKind() == JsonValueKind.Number) + { + var element = value.GetValue(); + if (element.TryGetInt32(out var result)) + return result; + } + + return fallback; + } + + public static int? GetIntOrNull(this JsonNode? node, string name) + { + var value = node?[name]; + if (value == null) + return null; + + if (value.GetValueKind() == JsonValueKind.Number) + { + var element = value.GetValue(); + if (element.TryGetInt32(out var result)) + return result; + } + + return null; + } + + public static double GetDoubleOrDefault(this JsonNode? node, string name, double fallback) + { + var value = node?[name]; + if (value == null) + return fallback; + + return value.GetValueKind() == JsonValueKind.Number ? value.GetValue().GetDouble() : fallback; + } + + public static bool GetBoolOrDefault(this JsonNode? node, string name, bool fallback) + { + var value = node?[name]; + if (value == null) + return fallback; + + return value.GetValueKind() == JsonValueKind.True || value.GetValueKind() == JsonValueKind.False ? value.GetValue() : fallback; + } + + /// + /// Returns the raw boxed value of a node as one of the following: + /// - bool + /// - int if it fits, else double + /// - string + /// - null for any other content + /// + public static object? GetValueOrNull(this JsonNode? node) + { + if (node == null) + return null; + + var kind = node.GetValueKind(); + if (kind == JsonValueKind.True || kind == JsonValueKind.False) + return node.GetValue(); + + if (kind == JsonValueKind.Number) + { + var element = node.GetValue(); + return element.TryGetInt32(out var i) ? i : element.GetDouble(); + } + + if (kind == JsonValueKind.String) + return node.GetValue(); + + return null; + } + + public static JsonValueKind GetValueKind(this JsonNode node) => node.GetValue().ValueKind; +} diff --git a/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/Schema/Keys.cs b/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/Schema/Keys.cs new file mode 100644 index 00000000..efeb5bf9 --- /dev/null +++ b/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/Schema/Keys.cs @@ -0,0 +1,55 @@ +namespace Reloaded.Mod.Launcher.Lib.Models.Model.Configuration.Native.Schema; + +/// +/// JSON property names used by the schema file. +/// +internal static class Keys +{ + public const string Configurations = "Configurations"; + public const string FileName = "FileName"; + public const string DisplayName = "DisplayName"; + public const string Enums = "Enums"; + public const string Properties = "Properties"; + public const string Members = "Members"; + public const string Name = "Name"; + public const string Type = "Type"; + public const string Description = "Description"; + public const string Category = "Category"; + public const string Order = "Order"; + public const string DefaultValue = "DefaultValue"; + public const string Slider = "Slider"; + public const string FilePicker = "FilePicker"; + public const string FolderPicker = "FolderPicker"; + public const string Values = "Values"; + + // Control Params + public const string Minimum = "Minimum"; + public const string Maximum = "Maximum"; + public const string SmallChange = "SmallChange"; + public const string LargeChange = "LargeChange"; + public const string TickFrequency = "TickFrequency"; + public const string TickFrequencyDouble = "TickFrequencyDouble"; + public const string IsSnapToTickEnabled = "IsSnapToTickEnabled"; + public const string TickPlacement = "TickPlacement"; + public const string ShowTextField = "ShowTextField"; + public const string IsTextFieldEditable = "IsTextFieldEditable"; + public const string TextValidationRegex = "TextValidationRegex"; + public const string TextFieldFormat = "TextFieldFormat"; + public const string InitialDirectory = "InitialDirectory"; + public const string InitialFolderPath = "InitialFolderPath"; + public const string ChooseFileButtonLabel = "ChooseFileButtonLabel"; + public const string ChooseFolderButtonLabel = "ChooseFolderButtonLabel"; + public const string UserCanEditPathText = "UserCanEditPathText"; + public const string Title = "Title"; + public const string Filter = "Filter"; + public const string FilterIndex = "FilterIndex"; + public const string Multiselect = "Multiselect"; + public const string SupportMultiDottedExtensions = "SupportMultiDottedExtensions"; + public const string ShowHiddenFiles = "ShowHiddenFiles"; + public const string ShowPreview = "ShowPreview"; + public const string RestoreDirectory = "RestoreDirectory"; + public const string AddToRecent = "AddToRecent"; + public const string OkButtonLabel = "OkButtonLabel"; + public const string FileNameLabel = "FileNameLabel"; + public const string ForceFileSystem = "ForceFileSystem"; +} diff --git a/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/Schema/Property.cs b/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/Schema/Property.cs new file mode 100644 index 00000000..af7da48d --- /dev/null +++ b/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/Schema/Property.cs @@ -0,0 +1,134 @@ +using System.Text.Json.Nodes; + +namespace Reloaded.Mod.Launcher.Lib.Models.Model.Configuration.Native.Schema; + +/// +/// An individual setting of a configuration; mirrors a property of a +/// C# mod's config class. +/// +public class Property +{ + /// + /// Supported values for . + /// + internal static class SupportedTypes + { + public const string Bool = "bool"; + public const string Int = "int"; + public const string Float = "float"; + public const string Double = "double"; + public const string String = "string"; + } + + /// + /// Name of the setting, stored in the config file. + /// + public string Name { get; set; } = ""; + + /// + /// Type of the setting; one of the following: + /// - bool, int, float, double or string + /// - the name of an enum declared in the same configuration + /// + public string Type { get; set; } = SupportedTypes.String; + + /// + /// Friendly name shown in the launcher. Falls back to . + /// + public string? DisplayName { get; set; } + + /// + /// Tooltip description shown in the launcher. + /// + public string? Description { get; set; } + + /// + /// Category (group) the setting is displayed under. + /// + public string? Category { get; set; } + + /// + /// Sort order of the setting, lowest first. + /// + public int? Order { get; set; } + + /// + /// Default value of the setting; its shape matches : + /// - a bool, int, float or double literal + /// - a string or an enum member name + /// Initial value before any user change; the Reset button restores it. + /// + public object? DefaultValue { get; set; } + + /// + /// Renders this setting as a slider. Only valid for numeric types. + /// + public Slider? Slider { get; set; } + + /// + /// Renders this setting (string) with a file picker dialog. + /// + public FilePicker? FilePicker { get; set; } + + /// + /// Renders this setting (string) with a folder picker dialog. + /// + public FolderPicker? FolderPicker { get; set; } + + /// + /// Enum values declared directly on the property, for the common case + /// of an enum used by a single setting. + /// + public List Values { get; set; } = new(); + + /// + /// Reads a property from its JSON representation. + /// + /// Node holding the setting's properties. + /// + /// Thrown when the property has no name. + /// + public static Property Parse(JsonNode node) + { + var property = new Property + { + Name = node.GetStringOrDefault(Keys.Name, "")!, + Type = node.GetStringOrDefault(Keys.Type, SupportedTypes.String)!.ToLowerInvariant(), + DisplayName = node.GetStringOrDefault(Keys.DisplayName, null), + Description = node.GetStringOrDefault(Keys.Description, null), + Category = node.GetStringOrDefault(Keys.Category, null), + Order = node.GetIntOrNull(Keys.Order), + DefaultValue = node[Keys.DefaultValue].GetValueOrNull() + }; + + if (node[Keys.Slider] is JsonNode slider) + property.Slider = Slider.Parse(slider); + + if (node[Keys.FilePicker] is JsonNode filePicker) + property.FilePicker = FilePicker.Parse(filePicker); + + if (node[Keys.FolderPicker] is JsonNode folderPicker) + property.FolderPicker = FolderPicker.Parse(folderPicker); + + if (node[Keys.Values] is JsonArray values) + { + foreach (var valueNode in values) + { + var member = valueNode!.GetValueKind() == JsonValueKind.String + ? new EnumMember { Name = valueNode.GetValue() } + : EnumMember.Parse(valueNode); + + if (member.Name.Length > 0) + property.Values.Add(member); + } + + if (property.Values.Count > 0) + property.Type = property.Name; // inline enums uses the property name. + } + + if (property.Name.Length <= 0) + throw new JsonException($"A property in the schema has no '{Keys.Name}'."); + + return property; + } +} diff --git a/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/Schema/Slider.cs b/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/Schema/Slider.cs new file mode 100644 index 00000000..27aa99fe --- /dev/null +++ b/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/Schema/Slider.cs @@ -0,0 +1,91 @@ +using System.Text.Json.Nodes; + +namespace Reloaded.Mod.Launcher.Lib.Models.Model.Configuration.Native.Schema; + +/// +/// Parameters for the slider control; mirrors +/// SliderControlParamsAttribute of the C# interface. +/// +public class Slider +{ + /// + /// Minimum value of the slider. + /// + public double Minimum { get; set; } = 0.0; + + /// + /// Maximum value of the slider. + /// + public double Maximum { get; set; } = 1.0; + + /// + /// Value change of a small step (arrow keys). + /// + public double SmallChange { get; set; } = 0.1; + + /// + /// Value change of a large step (page up/down or gutter click). + /// + public double LargeChange { get; set; } = 1.0; + + /// + /// Distance between tick marks. Legacy; + /// wins when greater than zero. + /// + public int TickFrequency { get; set; } = 10; + + /// + /// Snap the value to the nearest tick. + /// + public bool IsSnapToTickEnabled { get; set; } = false; + + /// + /// Where tick marks are drawn; a SliderControlTickPlacement name. + /// + public string TickPlacement { get; set; } = "None"; + + /// + /// Show the value in a text field left of the slider. + /// + public bool ShowTextField { get; set; } = false; + + /// + /// Allow typing in the text field. + /// + public bool IsTextFieldEditable { get; set; } = true; + + /// + /// Regex the text field input must match. + /// + public string TextValidationRegex { get; set; } = ".*"; + + /// + /// Format string applied to the text field value. + /// + public string TextFieldFormat { get; set; } = ""; + + /// + /// Distance between tick marks; allows fractions. + /// + public double TickFrequencyDouble { get; set; } = 0.0; + + /// + /// Reads the slider parameters from their JSON representation. + /// + /// Node holding the slider's properties. + public static Slider Parse(JsonNode node) => new() + { + Minimum = node.GetDoubleOrDefault(Keys.Minimum, 0.0), + Maximum = node.GetDoubleOrDefault(Keys.Maximum, 1.0), + SmallChange = node.GetDoubleOrDefault(Keys.SmallChange, 0.1), + LargeChange = node.GetDoubleOrDefault(Keys.LargeChange, 1.0), + TickFrequency = node.GetIntOrDefault(Keys.TickFrequency, 10), + IsSnapToTickEnabled = node.GetBoolOrDefault(Keys.IsSnapToTickEnabled, false), + TickPlacement = node.GetStringOrDefault(Keys.TickPlacement, "None")!, + ShowTextField = node.GetBoolOrDefault(Keys.ShowTextField, false), + IsTextFieldEditable = node.GetBoolOrDefault(Keys.IsTextFieldEditable, true), + TextValidationRegex = node.GetStringOrDefault(Keys.TextValidationRegex, ".*")!, + TextFieldFormat = node.GetStringOrDefault(Keys.TextFieldFormat, "")!, + TickFrequencyDouble = node.GetDoubleOrDefault(Keys.TickFrequencyDouble, 0.0) + }; +} From 405b14ceb7df22b23d9eb66e69d48e3b7aa7cf05 Mon Sep 17 00:00:00 2001 From: Sewer56 Date: Sun, 20 Sep 2026 22:36:24 +0100 Subject: [PATCH 18/35] Changed: Link native schema docs to C# interface types Our native schema mirrors the one in Reloaded.Mod.Loader.Interfaces, so we update the structs to reference these, rather than restatinc. --- .../Models/Model/Configuration/Native/ConfigurableBase.cs | 2 +- .../Models/Model/Configuration/Native/ModConfigSchema.cs | 3 ++- .../Models/Model/Configuration/Native/ModConfigurator.cs | 2 +- .../Model/Configuration/Native/Schema/Configuration.cs | 6 +++--- .../Models/Model/Configuration/Native/Schema/FilePicker.cs | 4 ++-- .../Model/Configuration/Native/Schema/FolderPicker.cs | 4 ++-- .../Models/Model/Configuration/Native/Schema/Property.cs | 4 ++-- .../Models/Model/Configuration/Native/Schema/Slider.cs | 4 ++-- 8 files changed, 15 insertions(+), 14 deletions(-) diff --git a/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/ConfigurableBase.cs b/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/ConfigurableBase.cs index 6a764e87..72d69bf0 100644 --- a/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/ConfigurableBase.cs +++ b/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/ConfigurableBase.cs @@ -8,7 +8,7 @@ namespace Reloaded.Mod.Launcher.Lib.Models.Model.Configuration.Native; /// The emits one derived class per schema configuration; /// the derived class holds the settings as properties, this class supplies the behaviour /// (name, saving, file watching) expected by the launcher's configuration dialog. -/// Mirrors Configurable<T> of the C# mod template. +/// Native equivalent of Configurable<T> in the C# mod template. /// public abstract class ConfigurableBase : IUpdatableConfigurable { diff --git a/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/ModConfigSchema.cs b/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/ModConfigSchema.cs index ab53af6b..d7f18c68 100644 --- a/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/ModConfigSchema.cs +++ b/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/ModConfigSchema.cs @@ -8,7 +8,8 @@ namespace Reloaded.Mod.Launcher.Lib.Models.Model.Configuration.Native; /// its ModConfig.json. The launcher builds the configuration UI /// from that schema. /// -/// The schema mirrors the attributes used by the C# mod template. +/// The schema is the native equivalent of the attributes C# mods declare, +/// such as . /// Native and C# mods therefore look and behave the same: /// - DisplayName, Description, Category, DefaultValue /// - Slider/File/Folder control params diff --git a/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/ModConfigurator.cs b/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/ModConfigurator.cs index bd88c47b..60a12889 100644 --- a/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/ModConfigurator.cs +++ b/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/ModConfigurator.cs @@ -2,7 +2,7 @@ namespace Reloaded.Mod.Launcher.Lib.Models.Model.Configuration.Native; /// /// Configurator for native (non .NET) mods that declare their settings through a ConfigSchema.json file. -/// Use the same interface as a C# mod's configurator. +/// Native equivalent of a C# mod's . /// public class ModConfigurator : IConfiguratorV3 { diff --git a/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/Schema/Configuration.cs b/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/Schema/Configuration.cs index dc576471..069c66ae 100644 --- a/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/Schema/Configuration.cs +++ b/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/Schema/Configuration.cs @@ -3,14 +3,14 @@ namespace Reloaded.Mod.Launcher.Lib.Models.Model.Configuration.Native.Schema; /// -/// Individual configuration of a native mod; essentially mirrors one -/// IConfigurable from the C# mod template. +/// Individual configuration of a native mod; native equivalent of one +/// . /// public class Configuration { /// /// Name of the config file where the values for this configuration are stored. - /// Defaults to Config.json, matching the C# template. + /// Defaults to Config.json. /// public string FileName { get; set; } = "Config.json"; diff --git a/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/Schema/FilePicker.cs b/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/Schema/FilePicker.cs index cbe50929..3a7d02eb 100644 --- a/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/Schema/FilePicker.cs +++ b/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/Schema/FilePicker.cs @@ -3,8 +3,8 @@ namespace Reloaded.Mod.Launcher.Lib.Models.Model.Configuration.Native.Schema; /// -/// Parameters for the file picker control; mirrors -/// FilePickerParamsAttribute of the C# interface. +/// Parameters for the file picker control; native equivalent of +/// . /// public class FilePicker { diff --git a/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/Schema/FolderPicker.cs b/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/Schema/FolderPicker.cs index e00c6baf..2e914890 100644 --- a/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/Schema/FolderPicker.cs +++ b/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/Schema/FolderPicker.cs @@ -3,8 +3,8 @@ namespace Reloaded.Mod.Launcher.Lib.Models.Model.Configuration.Native.Schema; /// -/// Parameters for the folder picker control; mirrors -/// FolderPickerParamsAttribute of the C# interface. +/// Parameters for the folder picker control; native equivalent of +/// . /// public class FolderPicker { diff --git a/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/Schema/Property.cs b/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/Schema/Property.cs index af7da48d..f5a64da3 100644 --- a/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/Schema/Property.cs +++ b/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/Schema/Property.cs @@ -3,8 +3,8 @@ namespace Reloaded.Mod.Launcher.Lib.Models.Model.Configuration.Native.Schema; /// -/// An individual setting of a configuration; mirrors a property of a -/// C# mod's config class. +/// An individual setting of a configuration; native equivalent of a +/// property on an implementation. /// public class Property { diff --git a/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/Schema/Slider.cs b/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/Schema/Slider.cs index 27aa99fe..6b562d9d 100644 --- a/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/Schema/Slider.cs +++ b/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/Schema/Slider.cs @@ -3,8 +3,8 @@ namespace Reloaded.Mod.Launcher.Lib.Models.Model.Configuration.Native.Schema; /// -/// Parameters for the slider control; mirrors -/// SliderControlParamsAttribute of the C# interface. +/// Parameters for the slider control; native equivalent of +/// . /// public class Slider { From 14c1c834fbfd4ce278b6d73f4aa99c73187e0c7f Mon Sep 17 00:00:00 2001 From: Sewer56 Date: Sun, 20 Sep 2026 22:49:24 +0100 Subject: [PATCH 19/35] Update: Doc cleanup of ConfigurableBase.cs --- .../Configuration/Native/ConfigurableBase.cs | 48 +++++++++++++++---- 1 file changed, 38 insertions(+), 10 deletions(-) diff --git a/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/ConfigurableBase.cs b/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/ConfigurableBase.cs index 72d69bf0..b1361fc0 100644 --- a/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/ConfigurableBase.cs +++ b/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/ConfigurableBase.cs @@ -5,11 +5,15 @@ namespace Reloaded.Mod.Launcher.Lib.Models.Model.Configuration.Native; /// /// Base class for the configuration objects generated for native (non .NET) mods. -/// The emits one derived class per schema configuration; -/// the derived class holds the settings as properties, this class supplies the behaviour -/// (name, saving, file watching) expected by the launcher's configuration dialog. -/// Native equivalent of Configurable<T> in the C# mod template. /// +/// +/// Each configuration in a mod's becomes one derived +/// class, emitted by . The derived class only holds +/// the settings as properties; this base class supplies what the launcher's +/// configuration dialog expects: display name, saving and file watching. +/// +/// Native equivalent of Configurable<T> in the C# mod template. +/// public abstract class ConfigurableBase : IUpdatableConfigurable { /// @@ -47,7 +51,8 @@ public abstract class ConfigurableBase : IUpdatableConfigurable private static object _readLock = new object(); /// - /// Initializes an instance after construction, arming the file watcher and save action. + /// Initializes an instance after construction, arming the file watcher + /// and save action. /// /// Full path to the file storing the values. /// Name displayed in the launcher dialog. @@ -98,9 +103,14 @@ private void OnConfigurationUpdated() /// /// Reads and writes the value files of native mod configurations. -/// The file format is a flat JSON object of property name to value, with enums stored as strings; -/// identical in shape to what the C# mod template writes, so C++ mods can parse it with ease. /// +/// +/// The file format is a flat JSON object mapping property names to values, +/// with enums stored as strings. +/// +/// It is identical in shape to what the C# mod template writes, so C++ mods +/// can parse it with ease. +/// public static class ConfigIO { private static readonly JsonSerializerOptions SerializerOptions = new() { WriteIndented = true }; @@ -152,11 +162,14 @@ public static void Save(object instance, string filePath) } /// - /// Applies the values from a file onto an instance; properties missing from the file keep their current (default) values. - /// Returns false if the file could not be read. + /// Applies the values from a file onto an instance. + /// Properties missing from the file keep their current (default) values. /// /// Instance to load the values into. /// Full path of the file to read from. + /// + /// True if loading succeeded; false if the file was missing or unreadable. + /// public static bool Apply(object instance, string filePath) { if (!File.Exists(filePath)) @@ -178,9 +191,20 @@ public static bool Apply(object instance, string filePath) } /// - /// Creates a new instance of the given configuration type with values loaded from disk. + /// Creates a new instance of the given configuration type with values + /// loaded from disk. + /// /// Missing or unreadable files yield an instance with the schema default values. /// + /// Configuration type to create an instance of. + /// Full path of the file to load the values from. + /// Name displayed in the launcher dialog. + /// Milliseconds to wait between read attempts. + /// Number of attempts made to read the file. + /// + /// The new instance, initialized and armed; values default when the file + /// is missing or unreadable. + /// public static ConfigurableBase Load(Type type, string filePath, string configName, int timeout = 0, int retries = 1) { var instance = (ConfigurableBase)Activator.CreateInstance(type)!; @@ -247,5 +271,9 @@ private static void ApplyFromObject(object instance, JsonObject root) /// /// Returns the editable settings declared by a generated configuration type. /// + /// Configuration type to list the settings of. + /// + /// The public read/write properties declared directly on the type. + /// public static PropertyInfo[] GetProperties(Type type) => PropertyCache.GetOrAdd(type, static t => [.. t.GetProperties(BindingFlags.Public | BindingFlags.Instance).Where(p => p.DeclaringType == t && p.CanRead && p.CanWrite && p.GetIndexParameters().Length == 0)]); } From 1dfb6cbaa7d8243404bc6cac066f101af983e2c3 Mon Sep 17 00:00:00 2001 From: Sewer56 Date: Sun, 20 Sep 2026 22:51:27 +0100 Subject: [PATCH 20/35] Style: ModConfigSchema added newline --- .../Models/Model/Configuration/Native/ModConfigSchema.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/ModConfigSchema.cs b/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/ModConfigSchema.cs index d7f18c68..9a87693c 100644 --- a/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/ModConfigSchema.cs +++ b/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/ModConfigSchema.cs @@ -4,6 +4,7 @@ namespace Reloaded.Mod.Launcher.Lib.Models.Model.Configuration.Native; /// /// Declarative configuration schema for native (non .NET) mods. +/// /// A mod declares its settings in a ConfigSchema.json file next to /// its ModConfig.json. The launcher builds the configuration UI /// from that schema. From a15949f6f9b861b72cf5bd88c35ad8f21a4f00a3 Mon Sep 17 00:00:00 2001 From: Sewer56 Date: Sun, 20 Sep 2026 22:53:50 +0100 Subject: [PATCH 21/35] Style: Fix initializer alignment in picker schema parsers --- .../Configuration/Native/Schema/FilePicker.cs | 24 +++++++++---------- .../Native/Schema/FolderPicker.cs | 16 ++++++------- 2 files changed, 20 insertions(+), 20 deletions(-) diff --git a/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/Schema/FilePicker.cs b/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/Schema/FilePicker.cs index 3a7d02eb..91d68d73 100644 --- a/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/Schema/FilePicker.cs +++ b/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/Schema/FilePicker.cs @@ -80,18 +80,18 @@ public class FilePicker /// Node holding the picker's properties. public static FilePicker Parse(JsonNode node) => new() { - InitialDirectory = node.GetStringOrDefault(Keys.InitialDirectory, null), - InitialFolderPath = node.GetIntOrDefault(Keys.InitialFolderPath, 0x05), - ChooseFileButtonLabel = node.GetStringOrDefault(Keys.ChooseFileButtonLabel, "Choose File")!, - UserCanEditPathText = node.GetBoolOrDefault(Keys.UserCanEditPathText, true), - Title = node.GetStringOrDefault(Keys.Title, "")!, - Filter = node.GetStringOrDefault(Keys.Filter, "All files (*.*)|*.*")!, - FilterIndex = node.GetIntOrDefault(Keys.FilterIndex, 0), - Multiselect = node.GetBoolOrDefault(Keys.Multiselect, false), + InitialDirectory = node.GetStringOrDefault(Keys.InitialDirectory, null), + InitialFolderPath = node.GetIntOrDefault(Keys.InitialFolderPath, 0x05), + ChooseFileButtonLabel = node.GetStringOrDefault(Keys.ChooseFileButtonLabel, "Choose File")!, + UserCanEditPathText = node.GetBoolOrDefault(Keys.UserCanEditPathText, true), + Title = node.GetStringOrDefault(Keys.Title, "")!, + Filter = node.GetStringOrDefault(Keys.Filter, "All files (*.*)|*.*")!, + FilterIndex = node.GetIntOrDefault(Keys.FilterIndex, 0), + Multiselect = node.GetBoolOrDefault(Keys.Multiselect, false), SupportMultiDottedExtensions = node.GetBoolOrDefault(Keys.SupportMultiDottedExtensions, false), - ShowHiddenFiles = node.GetBoolOrDefault(Keys.ShowHiddenFiles, false), - ShowPreview = node.GetBoolOrDefault(Keys.ShowPreview, false), - RestoreDirectory = node.GetBoolOrDefault(Keys.RestoreDirectory, false), - AddToRecent = node.GetBoolOrDefault(Keys.AddToRecent, false) + ShowHiddenFiles = node.GetBoolOrDefault(Keys.ShowHiddenFiles, false), + ShowPreview = node.GetBoolOrDefault(Keys.ShowPreview, false), + RestoreDirectory = node.GetBoolOrDefault(Keys.RestoreDirectory, false), + AddToRecent = node.GetBoolOrDefault(Keys.AddToRecent, false) }; } diff --git a/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/Schema/FolderPicker.cs b/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/Schema/FolderPicker.cs index 2e914890..c3bc68f2 100644 --- a/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/Schema/FolderPicker.cs +++ b/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/Schema/FolderPicker.cs @@ -60,14 +60,14 @@ public class FolderPicker /// Node holding the picker's properties. public static FolderPicker Parse(JsonNode node) => new() { - InitialDirectory = node.GetStringOrDefault(Keys.InitialDirectory, null), - InitialFolderPath = node.GetIntOrDefault(Keys.InitialFolderPath, 0x05), + InitialDirectory = node.GetStringOrDefault(Keys.InitialDirectory, null), + InitialFolderPath = node.GetIntOrDefault(Keys.InitialFolderPath, 0x05), ChooseFolderButtonLabel = node.GetStringOrDefault(Keys.ChooseFolderButtonLabel, "Choose Folder")!, - UserCanEditPathText = node.GetBoolOrDefault(Keys.UserCanEditPathText, true), - Title = node.GetStringOrDefault(Keys.Title, "")!, - OkButtonLabel = node.GetStringOrDefault(Keys.OkButtonLabel, "Ok")!, - FileNameLabel = node.GetStringOrDefault(Keys.FileNameLabel, "")!, - Multiselect = node.GetBoolOrDefault(Keys.Multiselect, false), - ForceFileSystem = node.GetBoolOrDefault(Keys.ForceFileSystem, false) + UserCanEditPathText = node.GetBoolOrDefault(Keys.UserCanEditPathText, true), + Title = node.GetStringOrDefault(Keys.Title, "")!, + OkButtonLabel = node.GetStringOrDefault(Keys.OkButtonLabel, "Ok")!, + FileNameLabel = node.GetStringOrDefault(Keys.FileNameLabel, "")!, + Multiselect = node.GetBoolOrDefault(Keys.Multiselect, false), + ForceFileSystem = node.GetBoolOrDefault(Keys.ForceFileSystem, false) }; } From eadcbad8a890a8cd2501b941feb200b9d5fd01fc Mon Sep 17 00:00:00 2001 From: Sewer56 Date: Sun, 20 Sep 2026 22:54:53 +0100 Subject: [PATCH 22/35] Fixed: Remove duplicate blank line in ConfigureModCommand --- .../Commands/Mod/ConfigureModCommand.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/source/Reloaded.Mod.Launcher.Lib/Commands/Mod/ConfigureModCommand.cs b/source/Reloaded.Mod.Launcher.Lib/Commands/Mod/ConfigureModCommand.cs index b4164f1f..5f6f9bd0 100644 --- a/source/Reloaded.Mod.Launcher.Lib/Commands/Mod/ConfigureModCommand.cs +++ b/source/Reloaded.Mod.Launcher.Lib/Commands/Mod/ConfigureModCommand.cs @@ -83,7 +83,6 @@ private bool TryGetConfigurator(out IConfiguratorV1? configurator, out PluginLoa var nativeConfigurator = new Native.ModConfigurator(modDirectory); nativeConfigurator.SetModDirectory(modDirectory); - string configDirectory = _modUserConfigTuple != null ? Path.GetFullPath(Path.GetDirectoryName(_modUserConfigTuple.Path)!) : ModUserConfig.GetUserConfigFolderForMod(_modTuple.Config.ModId); From 7b9e43e62cda13bbb934b0e2b4690d5cdbbe9229 Mon Sep 17 00:00:00 2001 From: Sewer56 Date: Sun, 20 Sep 2026 23:04:20 +0100 Subject: [PATCH 23/35] Changed: Link plain doc mentions to code members --- .../Model/Configuration/Native/ConfigTypeEmitter.cs | 8 ++++---- .../Models/Model/Configuration/Native/ModConfigSchema.cs | 6 ++++-- .../Model/Configuration/Native/Schema/Configuration.cs | 2 +- .../Models/Model/Configuration/Native/Schema/Enum.cs | 2 +- .../Model/Configuration/Native/Schema/FilePicker.cs | 2 +- .../Model/Configuration/Native/Schema/FolderPicker.cs | 2 +- .../Models/Model/Configuration/Native/Schema/Slider.cs | 3 ++- 7 files changed, 14 insertions(+), 11 deletions(-) diff --git a/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/ConfigTypeEmitter.cs b/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/ConfigTypeEmitter.cs index b9f6dfa1..66578cc5 100644 --- a/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/ConfigTypeEmitter.cs +++ b/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/ConfigTypeEmitter.cs @@ -12,9 +12,9 @@ namespace Reloaded.Mod.Launcher.Lib.Models.Model.Configuration.Native; /// - , , /// /// - (backs the Reset button of the dialog) -/// - Display (sort order) -/// - SliderControlParams, FilePickerParams, -/// FolderPickerParams (custom editors) +/// - (sort order) +/// - , , +/// (custom editors) /// The PropertyGrid renders them exactly like a C# mod's configuration. /// public static class ConfigTypeEmitter @@ -112,7 +112,7 @@ private static Type BuildType(Schema.Configuration configuration, string cacheKe } /// - /// Declared enums plus one per property with inline Values. + /// Declared enums plus one per property with inline . /// private static IEnumerable CollectEnums(Schema.Configuration configuration) { diff --git a/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/ModConfigSchema.cs b/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/ModConfigSchema.cs index 9a87693c..cc7f798a 100644 --- a/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/ModConfigSchema.cs +++ b/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/ModConfigSchema.cs @@ -12,8 +12,10 @@ namespace Reloaded.Mod.Launcher.Lib.Models.Model.Configuration.Native; /// The schema is the native equivalent of the attributes C# mods declare, /// such as . /// Native and C# mods therefore look and behave the same: -/// - DisplayName, Description, Category, DefaultValue -/// - Slider/File/Folder control params +/// - , , +/// and +/// - , and +/// control params /// /// The individual schema models live in the namespace. /// diff --git a/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/Schema/Configuration.cs b/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/Schema/Configuration.cs index 069c66ae..fc3d8439 100644 --- a/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/Schema/Configuration.cs +++ b/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/Schema/Configuration.cs @@ -34,7 +34,7 @@ public class Configuration /// /// Node holding the configuration's properties. /// - /// Thrown when FileName is not a plain file name. + /// Thrown when is not a plain file name. /// public static Configuration Parse(JsonNode node) { diff --git a/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/Schema/Enum.cs b/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/Schema/Enum.cs index 59dbd876..b19c3cec 100644 --- a/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/Schema/Enum.cs +++ b/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/Schema/Enum.cs @@ -8,7 +8,7 @@ namespace Reloaded.Mod.Launcher.Lib.Models.Model.Configuration.Native.Schema; public class Enum { /// - /// Name of the enum type, referenced by property Type. + /// Name of the enum type, referenced by property . /// public string Name { get; set; } = ""; diff --git a/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/Schema/FilePicker.cs b/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/Schema/FilePicker.cs index 91d68d73..75b5a268 100644 --- a/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/Schema/FilePicker.cs +++ b/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/Schema/FilePicker.cs @@ -15,7 +15,7 @@ public class FilePicker /// /// Fallback folder when is null, as an - /// Environment.SpecialFolder value. + /// value. /// public int InitialFolderPath { get; set; } = 0x05; // Environment.SpecialFolder.Personal diff --git a/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/Schema/FolderPicker.cs b/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/Schema/FolderPicker.cs index c3bc68f2..1ce278f9 100644 --- a/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/Schema/FolderPicker.cs +++ b/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/Schema/FolderPicker.cs @@ -15,7 +15,7 @@ public class FolderPicker /// /// Fallback folder when is null, as an - /// Environment.SpecialFolder value. + /// value. /// public int InitialFolderPath { get; set; } = 0x05; // Environment.SpecialFolder.Personal diff --git a/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/Schema/Slider.cs b/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/Schema/Slider.cs index 6b562d9d..ed50ef69 100644 --- a/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/Schema/Slider.cs +++ b/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/Schema/Slider.cs @@ -40,7 +40,8 @@ public class Slider public bool IsSnapToTickEnabled { get; set; } = false; /// - /// Where tick marks are drawn; a SliderControlTickPlacement name. + /// Where tick marks are drawn; a + /// name. /// public string TickPlacement { get; set; } = "None"; From 0fa285045c8cc89b03aacc342cd19f266e85d1f0 Mon Sep 17 00:00:00 2001 From: Sewer56 Date: Sun, 20 Sep 2026 23:34:34 +0100 Subject: [PATCH 24/35] Changed: Dedupe native and managed configurator setup - Split TryGetConfigurator into native and managed paths with a shared helper - Native mods always get a user config folder now, created when missing - ModConfigurator: GetConfigurations throws if SetConfigDirectory was skipped --- .../Commands/Mod/ConfigureModCommand.cs | 123 ++++++++++++------ .../Configuration/Native/ModConfigurator.cs | 6 +- 2 files changed, 91 insertions(+), 38 deletions(-) diff --git a/source/Reloaded.Mod.Launcher.Lib/Commands/Mod/ConfigureModCommand.cs b/source/Reloaded.Mod.Launcher.Lib/Commands/Mod/ConfigureModCommand.cs index 5f6f9bd0..802b8bb3 100644 --- a/source/Reloaded.Mod.Launcher.Lib/Commands/Mod/ConfigureModCommand.cs +++ b/source/Reloaded.Mod.Launcher.Lib/Commands/Mod/ConfigureModCommand.cs @@ -21,6 +21,13 @@ public ConfigureModCommand(PathTuple? modTuple, PathTuple + /// Full path of the mod's user config folder; null when the mod has none. + /// + private string? ExistingUserConfigFolder => _modUserConfigTuple != null + ? Path.GetFullPath(Path.GetDirectoryName(_modUserConfigTuple.Path)!) + : null; + /* ICommand */ // Disallowed inlining to ensure nothing from library can be kept alive by stack references etc. @@ -68,40 +75,53 @@ private bool TryGetConfiguratorDisposing() [MethodImpl(MethodImplOptions.NoInlining)] private bool TryGetConfigurator(out IConfiguratorV1? configurator, out PluginLoader? loader) { - var config = _modTuple!.Config; - configurator = null; - loader = null; - - var modDirectory = Path.GetFullPath(Path.GetDirectoryName(_modTuple.Path)!); + var modDirectory = Path.GetFullPath(Path.GetDirectoryName(_modTuple!.Path)!); // Native (non .NET) mods describe their settings in a schema file, no managed code required. if (Native.ModConfigSchema.ExistsInFolder(modDirectory)) { - // Validate upfront, a broken schema disables the button instead of failing later. - Native.ModConfigSchema.Load(modDirectory); + loader = null; + configurator = CreateNativeConfigurator(modDirectory); + return true; + } - var nativeConfigurator = new Native.ModConfigurator(modDirectory); - nativeConfigurator.SetModDirectory(modDirectory); + return TryGetManagedConfigurator(modDirectory, out configurator, out loader); + } - string configDirectory = _modUserConfigTuple != null - ? Path.GetFullPath(Path.GetDirectoryName(_modUserConfigTuple.Path)!) - : ModUserConfig.GetUserConfigFolderForMod(_modTuple.Config.ModId); + /// + /// Creates the configurator for a native mod. + /// + /// + /// Throws when the settings schema is broken or the settings cannot move + /// to the user config folder. + /// + private IConfiguratorV1 CreateNativeConfigurator(string modDirectory) + { + // Validate upfront, a broken schema disables the button instead of failing later. + Native.ModConfigSchema.Load(modDirectory); - if (!nativeConfigurator.TryMigrate(modDirectory, configDirectory)) - throw new InvalidOperationException($"Could not move the settings of '{_modTuple.Config.ModName}' from '{modDirectory}' to '{configDirectory}'.", nativeConfigurator.MigrationError); + // Native settings always live in the user config folder, creating the + // standard one when the mod has none yet. + string configDirectory = ExistingUserConfigFolder + ?? ModUserConfig.GetUserConfigFolderForMod(_modTuple!.Config.ModId); - nativeConfigurator.SetConfigDirectory(configDirectory); + Directory.CreateDirectory(configDirectory); - nativeConfigurator.SetContext(new ConfiguratorContext() - { - Application = _applicationTuple.Config, - ModConfigPath = _modTuple.Path, - ApplicationConfigPath = _applicationTuple.Path - }); + var nativeConfigurator = new Native.ModConfigurator(modDirectory); + ConfigureConfigurator(nativeConfigurator, modDirectory, configDirectory); - configurator = nativeConfigurator; - return true; - } + return nativeConfigurator; + } + + /// + /// Loads the configurator from the mod's .NET DLL, returning false when the + /// DLL is missing or holds no configurator. + /// + private bool TryGetManagedConfigurator(string modDirectory, out IConfiguratorV1? configurator, out PluginLoader? loader) + { + var config = _modTuple!.Config; + configurator = null; + loader = null; string dllPath = config.GetManagedDllPath(_modTuple.Path); @@ -118,33 +138,62 @@ private bool TryGetConfigurator(out IConfiguratorV1? configurator, out PluginLoa var assembly = loader.LoadDefaultAssembly(); var types = assembly.GetTypes(); var entryPoint = types.FirstOrDefault(t => typeof(IConfiguratorV1).IsAssignableFrom(t) && !t.IsAbstract); - - if (entryPoint == null) + + if (entryPoint == null) return false; configurator = (IConfiguratorV1)Activator.CreateInstance(entryPoint)!; + ConfigureConfigurator(configurator, modDirectory, ExistingUserConfigFolder); + + return true; + } + + /// + /// Sets up a freshly created configurator with its mod directory, user + /// config location and application context. + /// + /// The configurator to set up. + /// Full path to the mod's folder. + /// Full path to the mod's user config + /// folder; null skips migration and leaves the location untouched. + private void ConfigureConfigurator(IConfiguratorV1 configurator, string modDirectory, string? configDirectory) + { configurator.SetModDirectory(modDirectory); - if (configurator is IConfiguratorV2 versionTwo && _modUserConfigTuple != null) + if (configurator is IConfiguratorV2 versionTwo && configDirectory != null) { - var configDirectory = Path.GetFullPath(Path.GetDirectoryName(_modUserConfigTuple.Path)!); - versionTwo.Migrate(modDirectory, configDirectory); + MigrateConfigurator(versionTwo, modDirectory, configDirectory); versionTwo.SetConfigDirectory(configDirectory); } if (configurator is IConfiguratorV3 versionThree) + versionThree.SetContext(CreateContext()); + } + + /// + /// Moves a configurator's config files to a new folder. + /// + private void MigrateConfigurator(IConfiguratorV2 configurator, string modDirectory, string configDirectory) + { + if (configurator is Native.ModConfigurator native) { - versionThree.SetContext(new ConfiguratorContext() - { - Application = _applicationTuple.Config, - ModConfigPath = _modTuple.Path, - ApplicationConfigPath = _applicationTuple.Path - }); + if (!native.TryMigrate(modDirectory, configDirectory)) + throw new InvalidOperationException($"Could not move the settings of '{_modTuple!.Config.ModName}' from '{modDirectory}' to '{configDirectory}'.", native.MigrationError); + } + else + { + configurator.Migrate(modDirectory, configDirectory); } - - return true; } + /// Builds the application/mod context handed to V3 configurators. + private ConfiguratorContext CreateContext() => new ConfiguratorContext() + { + Application = _applicationTuple.Config, + ModConfigPath = _modTuple!.Path, + ApplicationConfigPath = _applicationTuple.Path + }; + [MethodImpl(MethodImplOptions.NoInlining)] private void Execute_Internal() { diff --git a/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/ModConfigurator.cs b/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/ModConfigurator.cs index 60a12889..267f96a3 100644 --- a/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/ModConfigurator.cs +++ b/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/ModConfigurator.cs @@ -29,10 +29,14 @@ public void SetModDirectory(string modDirectory) } /// + /// Thrown when the settings + /// folder was not set with . public IConfigurable[] GetConfigurations() { var schema = ModConfigSchema.Load(_modDirectory); - var configDirectory = _configDirectory ?? _modDirectory; + + var configDirectory = _configDirectory + ?? throw new InvalidOperationException($"Call {nameof(SetConfigDirectory)} before {nameof(GetConfigurations)}."); // Include the file's last write time in the cache key, such that mod updates invalidate emitted types. var lastWrite = File.GetLastWriteTimeUtc(_schemaPath).Ticks.ToString(); From e799103ad7e093cdf9f54562c655173773f65e0e Mon Sep 17 00:00:00 2001 From: Sewer56 Date: Sun, 20 Sep 2026 23:40:07 +0100 Subject: [PATCH 25/35] Changed: Add Arrange/Act/Assert sections to native mod tests - Annotated all 20 tests in NativeModConfigTests and NativeLoaderApiBridgeTests - Moved schema detection assert below load in Schema_Is_Detected_And_Parsed - Kept fused act+assert calls as act boundaries per repo style --- .../Launcher/NativeModConfigTests.cs | 59 +++++++++++++++++-- .../Loader/NativeLoaderApiBridgeTests.cs | 24 +++++++- 2 files changed, 77 insertions(+), 6 deletions(-) diff --git a/source/Reloaded.Mod.Loader.Tests/Launcher/NativeModConfigTests.cs b/source/Reloaded.Mod.Loader.Tests/Launcher/NativeModConfigTests.cs index bf1f6e34..d845526b 100644 --- a/source/Reloaded.Mod.Loader.Tests/Launcher/NativeModConfigTests.cs +++ b/source/Reloaded.Mod.Loader.Tests/Launcher/NativeModConfigTests.cs @@ -56,9 +56,11 @@ public NativeModConfigTests() [Fact] public void Schema_Is_Detected_And_Parsed() { - Assert.True(Native.ModConfigSchema.ExistsInFolder(ModDirectory)); - + // Act var schema = Native.ModConfigSchema.Load(ModDirectory); + + // Assert + Assert.True(Native.ModConfigSchema.ExistsInFolder(ModDirectory)); var configuration = Assert.Single(schema.Configurations); Assert.Equal("Config.json", configuration.FileName); Assert.Equal("Default Config", configuration.DisplayName); @@ -77,10 +79,12 @@ public void Schema_Is_Detected_And_Parsed() [Fact] public void Configurator_Returns_Configurable_With_Default_Values() { + // Act var configurator = CreateConfigurator(); var configurations = configurator.GetConfigurations(); var configurable = Assert.Single(configurations); + // Assert Assert.Equal("Default Config", configurable.ConfigName); Assert.IsAssignableFrom(configurable); Assert.NotNull(configurable.Save); @@ -96,9 +100,11 @@ public void Configurator_Returns_Configurable_With_Default_Values() [Fact] public void Generated_Properties_Carry_UI_Attributes() { + // Act var configurable = Assert.Single(CreateConfigurator().GetConfigurations()); var type = configurable.GetType(); + // Assert var booleanProperty = type.GetProperty("BooleanSetting")!; Assert.Equal("Bool", booleanProperty.GetCustomAttribute()!.DisplayName); Assert.Equal("This is a bool.", booleanProperty.GetCustomAttribute()!.Description); @@ -134,7 +140,10 @@ public void Generated_Properties_Carry_UI_Attributes() [Fact] public void Save_Writes_Values_And_New_Instance_Reads_Them_Back() { + // Arrange var configurable = Assert.Single(CreateConfigurator().GetConfigurations()); + + // Act SetProperty(configurable, "BooleanSetting", false); SetProperty(configurable, "IntegerSetting", 1337); SetProperty(configurable, "FloatSetting", 0.25f); @@ -142,6 +151,7 @@ public void Save_Writes_Values_And_New_Instance_Reads_Them_Back() SetProperty(configurable, "EnumSetting", Enum.Parse(configurable.GetType().GetProperty("EnumSetting")!.PropertyType, "NoOpinion")); configurable.Save!(); + // Assert string valuesPath = Path.Combine(ConfigDirectory, "Config.json"); Assert.True(File.Exists(valuesPath)); @@ -153,8 +163,11 @@ public void Save_Writes_Values_And_New_Instance_Reads_Them_Back() Assert.Equal("changed", json["StringSetting"]!.GetValue()); Assert.Equal("NoOpinion", json["EnumSetting"]!.GetValue()); + // Act // A fresh instance starts from the saved values. var reloaded = Assert.Single(CreateConfigurator().GetConfigurations()); + + // Assert Assert.False(GetProperty(reloaded, "BooleanSetting")); Assert.Equal(1337, GetProperty(reloaded, "IntegerSetting")); Assert.Equal(0.25f, GetProperty(reloaded, "FloatSetting")); @@ -165,9 +178,13 @@ public void Save_Writes_Values_And_New_Instance_Reads_Them_Back() [Fact] public void Unknown_Values_In_File_Are_Ignored() { + // Arrange File.WriteAllText(Path.Combine(ConfigDirectory, "Config.json"), """{ "IntegerSetting": 5, "NotARealSetting": "abc" }"""); + + // Act var configurable = Assert.Single(CreateConfigurator().GetConfigurations()); + // Assert Assert.Equal(5, GetProperty(configurable, "IntegerSetting")); Assert.True(GetProperty(configurable, "BooleanSetting")); Assert.Equal("hello world", GetProperty(configurable, "StringSetting")); @@ -176,7 +193,10 @@ public void Unknown_Values_In_File_Are_Ignored() [Fact] public void Missing_Values_File_Leaves_Defaults() { + // Act var configurable = Assert.Single(CreateConfigurator().GetConfigurations()); + + // Assert Assert.Equal(42, GetProperty(configurable, "IntegerSetting")); Assert.False(File.Exists(Path.Combine(ConfigDirectory, "Config.json"))); } @@ -184,12 +204,15 @@ public void Missing_Values_File_Leaves_Defaults() [Fact] public void Migrate_Moves_Values_File() { + // Arrange // Simulate values living in the mod folder (pre-migration). File.WriteAllText(Path.Combine(ModDirectory, "Config.json"), """{ "IntegerSetting": 9 }"""); - var configurator = CreateConfigurator(); + + // Act configurator.Migrate(ModDirectory, ConfigDirectory); + // Assert Assert.False(File.Exists(Path.Combine(ModDirectory, "Config.json"))); Assert.True(File.Exists(Path.Combine(ConfigDirectory, "Config.json"))); @@ -200,18 +223,23 @@ public void Migrate_Moves_Values_File() [Fact] public void Unknown_Type_Throws_Descriptive_Error() { + // Arrange File.WriteAllText(Path.Combine(ModDirectory, Native.ModConfigSchema.SchemaFileName), """ { "Configurations": [ { "FileName": "Config.json", "Properties": [ { "Name": "Broken", "Type": "NoSuchEnum" } ] } ] } """); var configurator = CreateConfigurator(); + // Act var error = Assert.Throws(() => configurator.GetConfigurations()); + + // Assert Assert.Contains("nosuchenum", error.Message, StringComparison.OrdinalIgnoreCase); } [Fact] public void Slider_On_Enum_Property_Throws() { + // Arrange File.WriteAllText(Path.Combine(ModDirectory, Native.ModConfigSchema.SchemaFileName), """ { "Configurations": [ @@ -224,7 +252,10 @@ public void Slider_On_Enum_Property_Throws() """); var configurator = CreateConfigurator(); + // Act var error = Assert.Throws(() => configurator.GetConfigurations()); + + // Assert Assert.Contains("sliders are only supported", error.Message); } @@ -235,11 +266,15 @@ public void Slider_On_Enum_Property_Throws() [InlineData("SubFolder/Config.json")] public void FileNames_With_Paths_Are_Rejected(string fileName) { + // Arrange File.WriteAllText(Path.Combine(ModDirectory, Native.ModConfigSchema.SchemaFileName), $$""" { "Configurations": [ { "FileName": "{{fileName.Replace("\\", "\\\\")}}", "Properties": [] } ] } """); + // Act var error = Assert.Throws(() => Native.ModConfigSchema.Load(ModDirectory)); + + // Assert var jsonError = Assert.IsType(error.InnerException); Assert.Contains("plain file name", jsonError.Message); } @@ -247,16 +282,21 @@ public void FileNames_With_Paths_Are_Rejected(string fileName) [Fact] public void TryMigrate_Reports_Failure_And_Keeps_Error() { + // Arrange var configurator = CreateConfigurator(); + // Act // A path with invalid characters makes creating the directory fail. Assert.False(configurator.TryMigrate(ModDirectory, "C:\\\\")); + + // Assert Assert.NotNull(configurator.MigrationError); } [Fact] public void TryMigrate_Rolls_Back_Moves_On_Failure() { + // Arrange // Two configs with values in the mod folder; the second move fails // because a directory already sits where the file would land. File.WriteAllText(Path.Combine(ModDirectory, Native.ModConfigSchema.SchemaFileName), """ @@ -270,9 +310,12 @@ public void TryMigrate_Rolls_Back_Moves_On_Failure() File.WriteAllText(Path.Combine(ModDirectory, "First.json"), "{ \"Value\": 1 }"); File.WriteAllText(Path.Combine(ModDirectory, "Second.json"), "{ \"Value\": 2 }"); Directory.CreateDirectory(Path.Combine(ConfigDirectory, "Second.json")); - var configurator = CreateConfigurator(); + + // Act Assert.False(configurator.TryMigrate(ModDirectory, ConfigDirectory)); + + // Assert Assert.NotNull(configurator.MigrationError); // The first file was moved before the failure: put it back in place. @@ -284,6 +327,7 @@ public void TryMigrate_Rolls_Back_Moves_On_Failure() [Fact] public void Inline_Enum_Values_Build_A_Dropdown() { + // Arrange File.WriteAllText(Path.Combine(ModDirectory, Native.ModConfigSchema.SchemaFileName), """ { "Configurations": [ @@ -300,8 +344,11 @@ public void Inline_Enum_Values_Build_A_Dropdown() }] } """); + + // Act var configurable = Assert.Single(CreateConfigurator().GetConfigurations()); + // Assert var property = configurable.GetType().GetProperty("Difficulty")!; var enumType = property.PropertyType; Assert.True(enumType.IsEnum); @@ -316,12 +363,16 @@ public void Inline_Enum_Values_Build_A_Dropdown() [Fact] public void Enum_Type_Without_Values_Gives_Hint() { + // Arrange File.WriteAllText(Path.Combine(ModDirectory, Native.ModConfigSchema.SchemaFileName), """ { "Configurations": [ { "FileName": "Config.json", "Properties": [ { "Name": "Broken", "Type": "enum" } ] } ] } """); var configurator = CreateConfigurator(); + // Act var error = Assert.Throws(() => configurator.GetConfigurations()); + + // Assert Assert.Contains("Values", error.Message); } diff --git a/source/Reloaded.Mod.Loader.Tests/Loader/NativeLoaderApiBridgeTests.cs b/source/Reloaded.Mod.Loader.Tests/Loader/NativeLoaderApiBridgeTests.cs index a921cb26..b1471d2f 100644 --- a/source/Reloaded.Mod.Loader.Tests/Loader/NativeLoaderApiBridgeTests.cs +++ b/source/Reloaded.Mod.Loader.Tests/Loader/NativeLoaderApiBridgeTests.cs @@ -20,7 +20,10 @@ public NativeLoaderApiBridgeTests() [Fact] public void Table_Has_Version_And_Functions() { + // Act var table = ReadTable(); + + // Assert Assert.Equal(1, table.ApiVersion); Assert.NotEqual(IntPtr.Zero, table.LoadMod); Assert.NotEqual(IntPtr.Zero, table.GetModConfigDirectory); @@ -31,11 +34,14 @@ public void Table_Has_Version_And_Functions() [Fact] public void GetModConfigDirectory_Returns_The_String() { + // Arrange _loader.Setup(l => l.GetModConfigDirectory("some.mod")).Returns(@"D:\User\Mods\SomeMod"); - var getString = Marshal.GetDelegateForFunctionPointer(ReadTable().GetModConfigDirectory); + + // Act var pointer = getString(ToUtf8("some.mod")); + // Assert // We own the memory, so it goes back through the table's free function. Assert.Equal(@"D:\User\Mods\SomeMod", Marshal.PtrToStringUni(pointer)); @@ -46,36 +52,45 @@ public void GetModConfigDirectory_Returns_The_String() [Fact] public void FreeString_Ignores_Null() { + // Arrange // Native mods may hand back whatever the getters returned, zero included. var freeString = Marshal.GetDelegateForFunctionPointer(ReadTable().FreeString); + + // Act freeString(IntPtr.Zero); } [Fact] public void GetDirectoryForMod_Returns_Zero_Instead_Of_Throwing() { + // Arrange // Unknown mods throw inside the loader; native callers must never see that. _loader.Setup(l => l.GetDirectoryForModId("nope.mod")).Throws(new KeyNotFoundException()); - var getString = Marshal.GetDelegateForFunctionPointer(ReadTable().GetDirectoryForMod); + + // Act var result = getString(ToUtf8("nope.mod")); + // Assert Assert.Equal(IntPtr.Zero, result); } [Fact] public void ModStateFunctions_Forward_To_Loader() { + // Arrange var loadMod = Marshal.GetDelegateForFunctionPointer(ReadTable().LoadMod); var unloadMod = Marshal.GetDelegateForFunctionPointer(ReadTable().UnloadMod); var suspendMod = Marshal.GetDelegateForFunctionPointer(ReadTable().SuspendMod); var resumeMod = Marshal.GetDelegateForFunctionPointer(ReadTable().ResumeMod); + // Act loadMod(ToUtf8("some.mod")); unloadMod(ToUtf8("some.mod")); suspendMod(ToUtf8("some.mod")); resumeMod(ToUtf8("some.mod")); + // Assert _loader.Verify(l => l.LoadMod("some.mod"), Times.Once); _loader.Verify(l => l.UnloadMod("some.mod"), Times.Once); _loader.Verify(l => l.SuspendMod("some.mod"), Times.Once); @@ -85,8 +100,13 @@ public void ModStateFunctions_Forward_To_Loader() [Fact] public void Dispose_Releases_The_Table() { + // Arrange var pointer = _bridge.TablePointer; + + // Act _bridge.Dispose(); + + // Assert Assert.Equal(IntPtr.Zero, _bridge.TablePointer); } From cc6ff217035ed10952a29c3cf336bbb40d87c71c Mon Sep 17 00:00:00 2001 From: Sewer56 Date: Mon, 21 Sep 2026 00:18:34 +0100 Subject: [PATCH 26/35] Changed: Removed redundant comment --- .../Models/Model/Configuration/Native/ConfigurableBase.cs | 2 -- 1 file changed, 2 deletions(-) diff --git a/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/ConfigurableBase.cs b/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/ConfigurableBase.cs index b1361fc0..1decec27 100644 --- a/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/ConfigurableBase.cs +++ b/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/ConfigurableBase.cs @@ -11,8 +11,6 @@ namespace Reloaded.Mod.Launcher.Lib.Models.Model.Configuration.Native; /// class, emitted by . The derived class only holds /// the settings as properties; this base class supplies what the launcher's /// configuration dialog expects: display name, saving and file watching. -/// -/// Native equivalent of Configurable<T> in the C# mod template. /// public abstract class ConfigurableBase : IUpdatableConfigurable { From 0aa172d98e9f1cc0290d3fb2e0a3bffc4309b184 Mon Sep 17 00:00:00 2001 From: Sora Date: Mon, 21 Sep 2026 13:21:30 +0200 Subject: [PATCH 27/35] Native Mod Config: swap to Enum + add missing json exception + validate file name check --- .../Configuration/Native/ConfigTypeEmitter.cs | 4 +- .../Configuration/Native/ConfigurableBase.cs | 26 +++++- .../Configuration/Native/ModConfigSchema.cs | 7 +- .../Native/Schema/Configuration.cs | 14 +++- .../Model/Configuration/Native/Schema/Enum.cs | 5 +- .../Configuration/Native/Schema/EnumMember.cs | 17 +++- .../Configuration/Native/Schema/FilePicker.cs | 7 +- .../Native/Schema/FolderPicker.cs | 7 +- .../Native/Schema/JsonNodeExtensions.cs | 30 +++++++ .../Configuration/Native/Schema/Property.cs | 16 +++- .../Launcher/NativeModConfigTests.cs | 79 +++++++++++++++++++ .../Loader/NativeLoaderApiBridgeTests.cs | 1 + .../Mods/Structs/NativeLoaderApiBridge.cs | 15 +++- .../templates/native/CMakeLists.txt | 6 ++ .../templates/native/ReloadedModConfig.h | 14 ++++ 15 files changed, 229 insertions(+), 19 deletions(-) diff --git a/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/ConfigTypeEmitter.cs b/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/ConfigTypeEmitter.cs index 66578cc5..300d7cdc 100644 --- a/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/ConfigTypeEmitter.cs +++ b/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/ConfigTypeEmitter.cs @@ -265,7 +265,7 @@ private static IEnumerable BuildAttributes(Schema.Proper yield return new CustomAttributeBuilder(GetCtor(typeof(FilePickerParamsAttribute), 13), new object[] { - file.InitialDirectory!, (System.Environment.SpecialFolder)file.InitialFolderPath, + file.InitialDirectory!, file.InitialFolderPath, file.ChooseFileButtonLabel, file.UserCanEditPathText, file.Title, file.Filter, file.FilterIndex, file.Multiselect, file.SupportMultiDottedExtensions, file.ShowHiddenFiles, file.ShowPreview, file.RestoreDirectory, file.AddToRecent @@ -280,7 +280,7 @@ private static IEnumerable BuildAttributes(Schema.Proper yield return new CustomAttributeBuilder(GetCtor(typeof(FolderPickerParamsAttribute), 9), new object[] { - folder.InitialDirectory!, (System.Environment.SpecialFolder)folder.InitialFolderPath, + folder.InitialDirectory!, folder.InitialFolderPath, folder.ChooseFolderButtonLabel, folder.UserCanEditPathText, folder.Title, folder.OkButtonLabel, folder.FileNameLabel, folder.Multiselect, folder.ForceFileSystem }); diff --git a/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/ConfigurableBase.cs b/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/ConfigurableBase.cs index 1decec27..7f60fcae 100644 --- a/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/ConfigurableBase.cs +++ b/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/ConfigurableBase.cs @@ -85,7 +85,9 @@ private void OnConfigurationUpdated() lock (_readLock) { // Note: External program might still be writing to file while this is being executed, so we need to keep retrying. - var newConfig = ConfigIO.Load(GetType(), FilePath!, ConfigName, 250, 2); + var newConfig = ConfigIO.TryLoad(GetType(), FilePath!, ConfigName, 250, 2); + if (newConfig == null) + return; // Load and copy events, then disable events for this instance. newConfig.ConfigurationUpdated = ConfigurationUpdated; @@ -219,6 +221,28 @@ public static ConfigurableBase Load(Type type, string filePath, string configNam return instance; } + /// + /// Attempt to load a file from disk. + /// Create a new instance of ConfigurableBase upon success. + /// + public static ConfigurableBase? TryLoad(Type type, string filePath, string configName, int timeout = 0, int retries = 1) + { + var instance = (ConfigurableBase)Activator.CreateInstance(type)!; + for (int x = 0; x < retries; x++) + { + if (Apply(instance, filePath)) + { + instance.Initialize(filePath, configName); + return instance; + } + + if (x + 1 < retries) + Thread.Sleep(timeout); + } + + return null; + } + private static void ApplyFromObject(object instance, JsonObject root) { foreach (var property in GetProperties(instance.GetType())) diff --git a/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/ModConfigSchema.cs b/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/ModConfigSchema.cs index cc7f798a..b36ac690 100644 --- a/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/ModConfigSchema.cs +++ b/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/ModConfigSchema.cs @@ -58,7 +58,12 @@ private static ModConfigSchema Parse(JsonNode node, string modDirectory) if (node[Schema.Keys.Configurations] is JsonArray configurations) { foreach (var configurationNode in configurations) - schema.Configurations.Add(Schema.Configuration.Parse(configurationNode!)); + { + if (configurationNode == null) + throw new JsonException($"'{Schema.Keys.Configurations}' has a null entry."); + + schema.Configurations.Add(Schema.Configuration.Parse(configurationNode)); + } } if (schema.Configurations.Count <= 0) diff --git a/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/Schema/Configuration.cs b/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/Schema/Configuration.cs index fc3d8439..f6d50e4f 100644 --- a/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/Schema/Configuration.cs +++ b/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/Schema/Configuration.cs @@ -47,13 +47,23 @@ public static Configuration Parse(JsonNode node) if (node[Keys.Enums] is JsonArray enums) { foreach (var enumNode in enums) - configuration.Enums.Add(Enum.Parse(enumNode!)); + { + if (enumNode == null) + throw new JsonException($"'{Keys.Enums}' has a null entry."); + + configuration.Enums.Add(Enum.Parse(enumNode)); + } } if (node[Keys.Properties] is JsonArray properties) { foreach (var propertyNode in properties) - configuration.Properties.Add(Property.Parse(propertyNode!)); + { + if (propertyNode == null) + throw new JsonException($"'{Keys.Properties}' has a null entry."); + + configuration.Properties.Add(Property.Parse(propertyNode)); + } } return configuration; diff --git a/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/Schema/Enum.cs b/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/Schema/Enum.cs index b19c3cec..35157a9c 100644 --- a/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/Schema/Enum.cs +++ b/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/Schema/Enum.cs @@ -35,7 +35,10 @@ public static Enum Parse(JsonNode node) { foreach (var memberNode in members) { - var member = EnumMember.Parse(memberNode!); + if (memberNode == null) + throw new JsonException($"Enum '{result.Name}' has a null entry in '{Keys.Members}'."); + + var member = EnumMember.Parse(memberNode); if (member.Name.Length > 0) result.Members.Add(member); } diff --git a/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/Schema/EnumMember.cs b/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/Schema/EnumMember.cs index b495efee..1779f3f1 100644 --- a/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/Schema/EnumMember.cs +++ b/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/Schema/EnumMember.cs @@ -21,9 +21,18 @@ public class EnumMember /// Reads an enum member from its JSON representation. /// /// Node holding the member's properties. - public static EnumMember Parse(JsonNode node) => new() + /// + /// Thrown when the name is not a valid identifier. + /// + public static EnumMember Parse(JsonNode node) { - Name = node.GetStringOrDefault(Keys.Name, "")!, - DisplayName = node.GetStringOrDefault(Keys.DisplayName, null) - }; + var member = new EnumMember + { + Name = node.GetStringOrDefault(Keys.Name, "")!, + DisplayName = node.GetStringOrDefault(Keys.DisplayName, null) + }; + + Property.ValidateName(member.Name, $"'{Keys.Name}' of enum value '{member.Name}'"); + return member; + } } diff --git a/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/Schema/FilePicker.cs b/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/Schema/FilePicker.cs index 75b5a268..1db7c75d 100644 --- a/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/Schema/FilePicker.cs +++ b/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/Schema/FilePicker.cs @@ -15,9 +15,10 @@ public class FilePicker /// /// Fallback folder when is null, as an - /// value. + /// value; declared in the + /// schema by name, e.g. Desktop. /// - public int InitialFolderPath { get; set; } = 0x05; // Environment.SpecialFolder.Personal + public System.Environment.SpecialFolder InitialFolderPath { get; set; } = System.Environment.SpecialFolder.Personal; /// /// Label of the choose file button. @@ -81,7 +82,7 @@ public class FilePicker public static FilePicker Parse(JsonNode node) => new() { InitialDirectory = node.GetStringOrDefault(Keys.InitialDirectory, null), - InitialFolderPath = node.GetIntOrDefault(Keys.InitialFolderPath, 0x05), + InitialFolderPath = node.GetSpecialFolderOrDefault(Keys.InitialFolderPath, System.Environment.SpecialFolder.Personal), ChooseFileButtonLabel = node.GetStringOrDefault(Keys.ChooseFileButtonLabel, "Choose File")!, UserCanEditPathText = node.GetBoolOrDefault(Keys.UserCanEditPathText, true), Title = node.GetStringOrDefault(Keys.Title, "")!, diff --git a/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/Schema/FolderPicker.cs b/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/Schema/FolderPicker.cs index 1ce278f9..86b3788e 100644 --- a/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/Schema/FolderPicker.cs +++ b/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/Schema/FolderPicker.cs @@ -15,9 +15,10 @@ public class FolderPicker /// /// Fallback folder when is null, as an - /// value. + /// value; declared in the + /// schema by name, e.g. Desktop. /// - public int InitialFolderPath { get; set; } = 0x05; // Environment.SpecialFolder.Personal + public System.Environment.SpecialFolder InitialFolderPath { get; set; } = System.Environment.SpecialFolder.Personal; /// /// Label of the choose folder button. @@ -61,7 +62,7 @@ public class FolderPicker public static FolderPicker Parse(JsonNode node) => new() { InitialDirectory = node.GetStringOrDefault(Keys.InitialDirectory, null), - InitialFolderPath = node.GetIntOrDefault(Keys.InitialFolderPath, 0x05), + InitialFolderPath = node.GetSpecialFolderOrDefault(Keys.InitialFolderPath, System.Environment.SpecialFolder.Personal), ChooseFolderButtonLabel = node.GetStringOrDefault(Keys.ChooseFolderButtonLabel, "Choose Folder")!, UserCanEditPathText = node.GetBoolOrDefault(Keys.UserCanEditPathText, true), Title = node.GetStringOrDefault(Keys.Title, "")!, diff --git a/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/Schema/JsonNodeExtensions.cs b/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/Schema/JsonNodeExtensions.cs index c238d250..134297f4 100644 --- a/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/Schema/JsonNodeExtensions.cs +++ b/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/Schema/JsonNodeExtensions.cs @@ -94,5 +94,35 @@ public static bool GetBoolOrDefault(this JsonNode? node, string name, bool fallb return null; } + /// + /// Reads a given either by name + /// (e.g. Desktop, ProgramFiles) or by numeric value. + /// Throws for unknown names. + /// + public static System.Environment.SpecialFolder GetSpecialFolderOrDefault(this JsonNode? node, string name, System.Environment.SpecialFolder fallback) + { + var value = node?[name]; + if (value == null) + return fallback; + + if (value.GetValueKind() == JsonValueKind.String) + { + var text = value.GetValue(); + if (System.Enum.TryParse(text, ignoreCase: true, out var parsed)) + return parsed; + + throw new JsonException($"'{name}' value '{text}' is not a known '{nameof(System.Environment.SpecialFolder)}' name."); + } + + if (value.GetValueKind() == JsonValueKind.Number) + { + var element = value.GetValue(); + if (element.TryGetInt32(out var number)) + return (System.Environment.SpecialFolder)number; + } + + return fallback; + } + public static JsonValueKind GetValueKind(this JsonNode node) => node.GetValue().ValueKind; } diff --git a/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/Schema/Property.cs b/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/Schema/Property.cs index f5a64da3..50e7adef 100644 --- a/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/Schema/Property.cs +++ b/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/Schema/Property.cs @@ -114,10 +114,14 @@ public static Property Parse(JsonNode node) { foreach (var valueNode in values) { - var member = valueNode!.GetValueKind() == JsonValueKind.String + if (valueNode == null) + throw new JsonException($"Property '{property.Name}' has a null entry in '{Keys.Values}'."); + + var member = valueNode.GetValueKind() == JsonValueKind.String ? new EnumMember { Name = valueNode.GetValue() } : EnumMember.Parse(valueNode); + ValidateName(member.Name, $"'{Keys.Values}' entry '{member.Name}' of property '{property.Name}'"); if (member.Name.Length > 0) property.Values.Add(member); } @@ -129,6 +133,16 @@ public static Property Parse(JsonNode node) if (property.Name.Length <= 0) throw new JsonException($"A property in the schema has no '{Keys.Name}'."); + ValidateName(property.Name, $"'{Keys.Name}' of property '{property.Name}'"); return property; } + + internal static void ValidateName(string name, string what) + { + var valid = name.Length > 0 && (char.IsLetter(name[0]) || name[0] == '_') + && name.All(c => char.IsLetterOrDigit(c) || c == '_'); + + if (!valid) + throw new JsonException($"{what} must only contain letters, digits and underscores, and start with a letter. Use '{Keys.DisplayName}' for freeform text. Got '{name}'."); + } } diff --git a/source/Reloaded.Mod.Loader.Tests/Launcher/NativeModConfigTests.cs b/source/Reloaded.Mod.Loader.Tests/Launcher/NativeModConfigTests.cs index d845526b..7f7de218 100644 --- a/source/Reloaded.Mod.Loader.Tests/Launcher/NativeModConfigTests.cs +++ b/source/Reloaded.Mod.Loader.Tests/Launcher/NativeModConfigTests.cs @@ -376,6 +376,85 @@ public void Enum_Type_Without_Values_Gives_Hint() Assert.Contains("Values", error.Message); } + [Fact] + public void Names_That_Are_Not_Identifiers_Are_Rejected() + { + // Arrange: property, inline enum value and shared enum member with spaces. + File.WriteAllText(Path.Combine(ModDirectory, Native.ModConfigSchema.SchemaFileName), """ + { + "Configurations": [ { + "FileName": "Config.json", + "Properties": [ { "Name": "My Setting", "Type": "bool" } ] + }] + } + """); + + var error = Assert.Throws(() => Native.ModConfigSchema.Load(ModDirectory)); + + Assert.Contains("letters, digits and underscores", error.InnerException!.Message); + + File.WriteAllText(Path.Combine(ModDirectory, Native.ModConfigSchema.SchemaFileName), """ + { + "Configurations": [ { + "FileName": "Config.json", + "Properties": [ { "Name": "Difficulty", "Type": "enum", "Values": [ "Very Hard" ] } ] + }] + } + """); + + // Act + Assert + Assert.Throws(() => Native.ModConfigSchema.Load(ModDirectory)); + } + + [Fact] + public void SpecialFolder_Is_Parsed_By_Name() + { + // Arrange + File.WriteAllText(Path.Combine(ModDirectory, Native.ModConfigSchema.SchemaFileName), """ + { + "Configurations": [ { + "FileName": "Config.json", + "Properties": [ { + "Name": "CustomFile", "Type": "string", "FilePicker": { "InitialFolderPath": "Desktop" } + }] + }] + } + """); + + + var configurable = Assert.Single(CreateConfigurator().GetConfigurations()); + + var picker = configurable.GetType().GetProperty("CustomFile")!.GetCustomAttribute(); + Assert.Equal(System.Environment.SpecialFolder.Desktop, picker!.InitialFolderPath); + + // Arrange: unknown folder names are rejected instead of silently falling back. + File.WriteAllText(Path.Combine(ModDirectory, Native.ModConfigSchema.SchemaFileName), """ + { + "Configurations": [ { + "FileName": "Config.json", + "Properties": [ { + "Name": "CustomFile", "Type": "string", "FilePicker": { "InitialFolderPath": "Nowhere" } + }] + }] + } + """); + + var error = Assert.Throws(() => CreateConfigurator().GetConfigurations()); + Assert.Contains("Nowhere", error.InnerException!.Message); + } + + [Fact] + public void Null_Array_Entries_Are_Rejected() + { + // Arrange + File.WriteAllText(Path.Combine(ModDirectory, Native.ModConfigSchema.SchemaFileName), """ + { "Configurations": [ null ] } + """); + + var error = Assert.Throws(() => Native.ModConfigSchema.Load(ModDirectory)); + Assert.IsType(error.InnerException); + } + private Native.ModConfigurator CreateConfigurator() { var configurator = new Native.ModConfigurator(ModDirectory); diff --git a/source/Reloaded.Mod.Loader.Tests/Loader/NativeLoaderApiBridgeTests.cs b/source/Reloaded.Mod.Loader.Tests/Loader/NativeLoaderApiBridgeTests.cs index b1471d2f..01f81055 100644 --- a/source/Reloaded.Mod.Loader.Tests/Loader/NativeLoaderApiBridgeTests.cs +++ b/source/Reloaded.Mod.Loader.Tests/Loader/NativeLoaderApiBridgeTests.cs @@ -29,6 +29,7 @@ public void Table_Has_Version_And_Functions() Assert.NotEqual(IntPtr.Zero, table.GetModConfigDirectory); Assert.NotEqual(IntPtr.Zero, table.Log); Assert.NotEqual(IntPtr.Zero, table.FreeString); + Assert.NotEqual(IntPtr.Zero, table.LogAsync); } [Fact] diff --git a/source/Reloaded.Mod.Loader/Mods/Structs/NativeLoaderApiBridge.cs b/source/Reloaded.Mod.Loader/Mods/Structs/NativeLoaderApiBridge.cs index 084e8d4a..482d3f3a 100644 --- a/source/Reloaded.Mod.Loader/Mods/Structs/NativeLoaderApiBridge.cs +++ b/source/Reloaded.Mod.Loader/Mods/Structs/NativeLoaderApiBridge.cs @@ -17,6 +17,7 @@ public struct NativeReloadedLoaderApiTable public IntPtr GetModConfigDirectory; public IntPtr Log; public IntPtr FreeString; + public IntPtr LogAsync; } /// @@ -53,6 +54,7 @@ public sealed class NativeLoaderApiBridge : IDisposable private readonly Utf8ToString _getModConfigDirectory; private readonly Utf8Action _log; private readonly FreeAction _freeString; + private readonly Utf8Action _logAsync; /// ///Wraps the loader and logger into a native API table. @@ -72,6 +74,7 @@ public NativeLoaderApiBridge(IModLoader loader, Logger logger = null) _getModConfigDirectory = GetModConfigDirectory; _log = Log; _freeString = FreeString; + _logAsync = LogAsync; var table = new NativeReloadedLoaderApiTable() { @@ -83,7 +86,8 @@ public NativeLoaderApiBridge(IModLoader loader, Logger logger = null) GetDirectoryForMod = Marshal.GetFunctionPointerForDelegate(_getDirectoryForMod), GetModConfigDirectory = Marshal.GetFunctionPointerForDelegate(_getModConfigDirectory), Log = Marshal.GetFunctionPointerForDelegate(_log), - FreeString = Marshal.GetFunctionPointerForDelegate(_freeString) + FreeString = Marshal.GetFunctionPointerForDelegate(_freeString), + LogAsync = Marshal.GetFunctionPointerForDelegate(_logAsync) }; _tablePointer = Marshal.AllocHGlobal(Marshal.SizeOf()); @@ -137,6 +141,15 @@ private void Log(IntPtr textUtf8) catch (Exception e) { LogError(e, nameof(Log)); } } + /// + /// Log with a queue system. + /// + private void LogAsync(IntPtr textUtf8) + { + try { _logger?.WriteLineAsync(ReadUtf8(textUtf8)); } + catch (Exception e) { LogError(e, nameof(LogAsync)); } + } + /// /// Gives back a string handed out by the functions above. Mods can't free it /// themselves, the memory comes from our side of the fence, not their CRT. diff --git a/source/Reloaded.Mod.Template/templates/native/CMakeLists.txt b/source/Reloaded.Mod.Template/templates/native/CMakeLists.txt index 75f2666c..86eb5d04 100644 --- a/source/Reloaded.Mod.Template/templates/native/CMakeLists.txt +++ b/source/Reloaded.Mod.Template/templates/native/CMakeLists.txt @@ -6,6 +6,12 @@ set(CMAKE_CXX_STANDARD_REQUIRED ON) add_library(${PROJECT_NAME} SHARED main.cpp) +# The Loader API expects UTF-8 strings, so compile with UTF-8 and +# execution character sets. +if (MSVC) + target_compile_options(${PROJECT_NAME} PRIVATE /utf-8) +endif() + # Match the game's architecture: # 64-bit game: cmake -B build -A x64 # 32-bit game: cmake -B build -A Win32 diff --git a/source/Reloaded.Mod.Template/templates/native/ReloadedModConfig.h b/source/Reloaded.Mod.Template/templates/native/ReloadedModConfig.h index 3e1ce11a..e0f4f768 100644 --- a/source/Reloaded.Mod.Template/templates/native/ReloadedModConfig.h +++ b/source/Reloaded.Mod.Template/templates/native/ReloadedModConfig.h @@ -29,6 +29,12 @@ #include +// The loader API takes UTF-8 strings, however MSVC encodes narrow string literals in the +// system code page (unless told otherwise), we can force literal encoding to UTF-8 as a workaround. +#if defined(_MSC_VER) && !defined(__clang__) + #pragma execution_character_set("utf-8") +#endif + #include #include #include @@ -542,6 +548,7 @@ namespace reloaded wchar_t* (__cdecl *get_mod_config_directory)(const char* mod_id); void (__cdecl *log)(const char* text); void (__cdecl *free_string)(wchar_t* value); + void (__cdecl *log_async)(const char* text); }; // Handed to ReloadedStartEx as a pointer, so the layout can grow over time. @@ -598,6 +605,13 @@ namespace reloaded api->log(text); } + inline void log_async(const char* text) + { + ReloadedLoaderApi* api = loader(); + if (api != nullptr && api->log_async != nullptr) + api->log_async(text); + } + // Give a string from the loader API back to the loader, it allocated it and // is the only one that can free it. inline void free_string(wchar_t* value) From 86c1e4ded36899aeeaa24794d823079e9a9afed1 Mon Sep 17 00:00:00 2001 From: Sora Date: Mon, 21 Sep 2026 22:47:05 +0200 Subject: [PATCH 28/35] Updated documentation to add enum property and InitialFolderPath --- docs/NativeMods.md | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/docs/NativeMods.md b/docs/NativeMods.md index 4ec30955..e2849b51 100644 --- a/docs/NativeMods.md +++ b/docs/NativeMods.md @@ -140,11 +140,15 @@ Example: - `Type` is `bool`, `int`, `float`, `double`, `string`, or an enum. Enums list their values inline under `Values`, or under a shared `Enums` array when the same enum is used by several properties. +- Property and enum value `Name`s must only contain letters, digits and + underscores. Use `DisplayName` for freeform text. - `DisplayName`, `Description`, `Category`, `Order` and `DefaultValue` mirror the attributes used by the C# mod template. - `Slider`, `FilePicker` and `FolderPicker` mirror the `SliderControlParams`, `FilePickerParams` and `FolderPickerParams` attributes, all fields are - optional. + optional. `InitialFolderPath` is one of .NET's + `Environment.SpecialFolder` names, e.g. `Desktop`, `MyDocuments`, + `ProgramFiles`, etc. - Each entry in `Configurations` becomes one page of the dialog, saved to its own file (`FileName`) inside the mod's user config folder (`User/Mods/`). Values missing from the file fall back to @@ -191,5 +195,11 @@ Missing values fall back to the schema defaults, then to the fallback argument. `config.watch(callback)` reloads the settings when the user changes them while the game is running. +`reloaded::log(text)` writes to the Reloaded log through the loader API; +`reloaded::log_async(text)` queues the write instead, prefer it from hot paths +such as game hooks. The text is UTF-8; the helper header already asks MSVC to +encode narrow literals as UTF-8, and building with `/utf-8` does the same for +the whole project. + [native-template]: https://github.com/Reloaded-Project/Reloaded-II/tree/master/source/Reloaded.Mod.Template/templates/native [native-header]: https://github.com/Reloaded-Project/Reloaded-II/blob/master/source/Reloaded.Mod.Template/templates/native/ReloadedModConfig.h From fa6bbc2b8b4d85b93b7aab7d188be28a8b1f90f5 Mon Sep 17 00:00:00 2001 From: Sora Date: Tue, 22 Sep 2026 23:17:12 +0200 Subject: [PATCH 29/35] Rewrite logger to be more like C# equivalent --- docs/NativeMods.md | 10 ++-- .../Loader/NativeLoaderApiBridgeTests.cs | 43 ++++++++++++++- .../Mods/Structs/NativeLoaderApiBridge.cs | 54 ++++++++++++++----- .../templates/native/ReloadedModConfig.h | 33 +++++++++--- 4 files changed, 111 insertions(+), 29 deletions(-) diff --git a/docs/NativeMods.md b/docs/NativeMods.md index e2849b51..299bf29e 100644 --- a/docs/NativeMods.md +++ b/docs/NativeMods.md @@ -195,11 +195,11 @@ Missing values fall back to the schema defaults, then to the fallback argument. `config.watch(callback)` reloads the settings when the user changes them while the game is running. -`reloaded::log(text)` writes to the Reloaded log through the loader API; -`reloaded::log_async(text)` queues the write instead, prefer it from hot paths -such as game hooks. The text is UTF-8; the helper header already asks MSVC to -encode narrow literals as UTF-8, and building with `/utf-8` does the same for -the whole project. +`reloaded::write_line(text)` writes a line to the Reloaded log through the +loader API and `reloaded::write_line_async(text)` queues the write instead. +`write`/`write_async` counterparts write the text without appending a newline. +The text is UTF-8; the helper header already asks MSVC to encode narrow literals as UTF-8 and +building with `/utf-8` does the same for the whole project. [native-template]: https://github.com/Reloaded-Project/Reloaded-II/tree/master/source/Reloaded.Mod.Template/templates/native [native-header]: https://github.com/Reloaded-Project/Reloaded-II/blob/master/source/Reloaded.Mod.Template/templates/native/ReloadedModConfig.h diff --git a/source/Reloaded.Mod.Loader.Tests/Loader/NativeLoaderApiBridgeTests.cs b/source/Reloaded.Mod.Loader.Tests/Loader/NativeLoaderApiBridgeTests.cs index 01f81055..fc02306a 100644 --- a/source/Reloaded.Mod.Loader.Tests/Loader/NativeLoaderApiBridgeTests.cs +++ b/source/Reloaded.Mod.Loader.Tests/Loader/NativeLoaderApiBridgeTests.cs @@ -1,5 +1,6 @@ using System.Runtime.InteropServices; using Reloaded.Mod.Interfaces; +using Reloaded.Mod.Loader.Logging; namespace Reloaded.Mod.Loader.Tests.Loader; @@ -27,9 +28,11 @@ public void Table_Has_Version_And_Functions() Assert.Equal(1, table.ApiVersion); Assert.NotEqual(IntPtr.Zero, table.LoadMod); Assert.NotEqual(IntPtr.Zero, table.GetModConfigDirectory); - Assert.NotEqual(IntPtr.Zero, table.Log); + Assert.NotEqual(IntPtr.Zero, table.Write); + Assert.NotEqual(IntPtr.Zero, table.WriteAsync); + Assert.NotEqual(IntPtr.Zero, table.WriteLine); + Assert.NotEqual(IntPtr.Zero, table.WriteLineAsync); Assert.NotEqual(IntPtr.Zero, table.FreeString); - Assert.NotEqual(IntPtr.Zero, table.LogAsync); } [Fact] @@ -98,6 +101,42 @@ public void ModStateFunctions_Forward_To_Loader() _loader.Verify(l => l.ResumeMod("some.mod"), Times.Once); } + [Fact] + public void LoggingFunctions_Forward_To_Logger() + { + // Arrange + var logger = new Logger(); + var bridge = new NativeLoaderApiBridge(_loader.Object, logger); + var table = Marshal.PtrToStructure(bridge.TablePointer); + + var write = Marshal.GetDelegateForFunctionPointer(table.Write); + var writeAsync = Marshal.GetDelegateForFunctionPointer(table.WriteAsync); + var writeLine = Marshal.GetDelegateForFunctionPointer(table.WriteLine); + var writeLineAsync = Marshal.GetDelegateForFunctionPointer(table.WriteLineAsync); + + var written = new List(); + var lines = new List(); + logger.OnWrite += (_, message) => { lock (written) { written.Add(message.text); } }; + logger.OnWriteLine += (_, message) => { lock (lines) { lines.Add(message.text); } }; + + // Act + write(ToUtf8("plain")); + writeLine(ToUtf8("line")); + writeAsync(ToUtf8("queued plain")); + writeLineAsync(ToUtf8("queued line")); + + // Assert + // The queued writes land on the logger's background thread, so give them a moment. + Assert.True(SpinWait.SpinUntil(() => IsLogged(written, "queued plain") && IsLogged(lines, "queued line"), 5000)); + Assert.Equal(new[] { "plain", "queued plain" }, written); + Assert.Equal(new[] { "line", "queued line" }, lines); + } + + private static bool IsLogged(List logged, string message) + { + lock (logged) { return logged.Contains(message); } + } + [Fact] public void Dispose_Releases_The_Table() { diff --git a/source/Reloaded.Mod.Loader/Mods/Structs/NativeLoaderApiBridge.cs b/source/Reloaded.Mod.Loader/Mods/Structs/NativeLoaderApiBridge.cs index 482d3f3a..3e3d9301 100644 --- a/source/Reloaded.Mod.Loader/Mods/Structs/NativeLoaderApiBridge.cs +++ b/source/Reloaded.Mod.Loader/Mods/Structs/NativeLoaderApiBridge.cs @@ -15,9 +15,11 @@ public struct NativeReloadedLoaderApiTable public IntPtr ResumeMod; public IntPtr GetDirectoryForMod; public IntPtr GetModConfigDirectory; - public IntPtr Log; + public IntPtr Write; + public IntPtr WriteAsync; + public IntPtr WriteLine; + public IntPtr WriteLineAsync; public IntPtr FreeString; - public IntPtr LogAsync; } /// @@ -52,9 +54,11 @@ public sealed class NativeLoaderApiBridge : IDisposable private readonly Utf8Action _resumeMod; private readonly Utf8ToString _getDirectoryForMod; private readonly Utf8ToString _getModConfigDirectory; - private readonly Utf8Action _log; + private readonly Utf8Action _write; + private readonly Utf8Action _writeAsync; + private readonly Utf8Action _writeLine; + private readonly Utf8Action _writeLineAsync; private readonly FreeAction _freeString; - private readonly Utf8Action _logAsync; /// ///Wraps the loader and logger into a native API table. @@ -72,9 +76,11 @@ public NativeLoaderApiBridge(IModLoader loader, Logger logger = null) _resumeMod = ResumeMod; _getDirectoryForMod = GetDirectoryForMod; _getModConfigDirectory = GetModConfigDirectory; - _log = Log; + _write = Write; + _writeAsync = WriteAsync; + _writeLine = WriteLine; + _writeLineAsync = WriteLineAsync; _freeString = FreeString; - _logAsync = LogAsync; var table = new NativeReloadedLoaderApiTable() { @@ -85,9 +91,11 @@ public NativeLoaderApiBridge(IModLoader loader, Logger logger = null) ResumeMod = Marshal.GetFunctionPointerForDelegate(_resumeMod), GetDirectoryForMod = Marshal.GetFunctionPointerForDelegate(_getDirectoryForMod), GetModConfigDirectory = Marshal.GetFunctionPointerForDelegate(_getModConfigDirectory), - Log = Marshal.GetFunctionPointerForDelegate(_log), - FreeString = Marshal.GetFunctionPointerForDelegate(_freeString), - LogAsync = Marshal.GetFunctionPointerForDelegate(_logAsync) + Write = Marshal.GetFunctionPointerForDelegate(_write), + WriteAsync = Marshal.GetFunctionPointerForDelegate(_writeAsync), + WriteLine = Marshal.GetFunctionPointerForDelegate(_writeLine), + WriteLineAsync = Marshal.GetFunctionPointerForDelegate(_writeLineAsync), + FreeString = Marshal.GetFunctionPointerForDelegate(_freeString) }; _tablePointer = Marshal.AllocHGlobal(Marshal.SizeOf()); @@ -135,19 +143,37 @@ private IntPtr GetModConfigDirectory(IntPtr modIdUtf8) catch (Exception e) { LogError(e, nameof(GetModConfigDirectory)); return IntPtr.Zero; } } - private void Log(IntPtr textUtf8) + /// + /// Writes text to the log without appending a newline. + /// + private void Write(IntPtr textUtf8) + { + try { _logger?.Write(ReadUtf8(textUtf8)); } + catch (Exception e) { LogError(e, nameof(Write)); } + } + + /// + /// Writes text to the log without appending a newline, with a queue system. + /// + private void WriteAsync(IntPtr textUtf8) + { + try { _logger?.WriteAsync(ReadUtf8(textUtf8)); } + catch (Exception e) { LogError(e, nameof(WriteAsync)); } + } + + private void WriteLine(IntPtr textUtf8) { try { _logger?.WriteLine(ReadUtf8(textUtf8)); } - catch (Exception e) { LogError(e, nameof(Log)); } + catch (Exception e) { LogError(e, nameof(WriteLine)); } } /// - /// Log with a queue system. + /// Writes a line to the log, with a queue system. /// - private void LogAsync(IntPtr textUtf8) + private void WriteLineAsync(IntPtr textUtf8) { try { _logger?.WriteLineAsync(ReadUtf8(textUtf8)); } - catch (Exception e) { LogError(e, nameof(LogAsync)); } + catch (Exception e) { LogError(e, nameof(WriteLineAsync)); } } /// diff --git a/source/Reloaded.Mod.Template/templates/native/ReloadedModConfig.h b/source/Reloaded.Mod.Template/templates/native/ReloadedModConfig.h index e0f4f768..bda864a4 100644 --- a/source/Reloaded.Mod.Template/templates/native/ReloadedModConfig.h +++ b/source/Reloaded.Mod.Template/templates/native/ReloadedModConfig.h @@ -546,9 +546,11 @@ namespace reloaded void (__cdecl *resume_mod)(const char* mod_id); wchar_t* (__cdecl *get_directory_for_mod)(const char* mod_id); wchar_t* (__cdecl *get_mod_config_directory)(const char* mod_id); - void (__cdecl *log)(const char* text); + void (__cdecl *write)(const char* text); + void (__cdecl *write_async)(const char* text); + void (__cdecl *write_line)(const char* text); + void (__cdecl *write_line_async)(const char* text); void (__cdecl *free_string)(wchar_t* value); - void (__cdecl *log_async)(const char* text); }; // Handed to ReloadedStartEx as a pointer, so the layout can grow over time. @@ -598,18 +600,33 @@ namespace reloaded } // Writes to the Reloaded log when the loader API is available. - inline void log(const char* text) + + inline void write(const char* text) + { + ReloadedLoaderApi* api = loader(); + if (api != nullptr && api->api_version >= 1 && api->write != nullptr) + api->write(text); + } + + inline void write_async(const char* text) + { + ReloadedLoaderApi* api = loader(); + if (api != nullptr && api->write_async != nullptr) + api->write_async(text); + } + + inline void write_line(const char* text) { ReloadedLoaderApi* api = loader(); - if (api != nullptr && api->api_version >= 1 && api->log != nullptr) - api->log(text); + if (api != nullptr && api->api_version >= 1 && api->write_line != nullptr) + api->write_line(text); } - inline void log_async(const char* text) + inline void write_line_async(const char* text) { ReloadedLoaderApi* api = loader(); - if (api != nullptr && api->log_async != nullptr) - api->log_async(text); + if (api != nullptr && api->write_line_async != nullptr) + api->write_line_async(text); } // Give a string from the loader API back to the loader, it allocated it and From adcc59a9b08c57b4426f4b5905bbab08cba77315 Mon Sep 17 00:00:00 2001 From: Sewer56 Date: Wed, 23 Sep 2026 00:01:23 +0100 Subject: [PATCH 30/35] Changed: Remove docs for wrapper funcs --- .../Mods/Structs/NativeLoaderApiBridge.cs | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/source/Reloaded.Mod.Loader/Mods/Structs/NativeLoaderApiBridge.cs b/source/Reloaded.Mod.Loader/Mods/Structs/NativeLoaderApiBridge.cs index 3e3d9301..e8d08b9c 100644 --- a/source/Reloaded.Mod.Loader/Mods/Structs/NativeLoaderApiBridge.cs +++ b/source/Reloaded.Mod.Loader/Mods/Structs/NativeLoaderApiBridge.cs @@ -103,7 +103,7 @@ public NativeLoaderApiBridge(IModLoader loader, Logger logger = null) } /// - ///Pointer to the native table, placed inside ReloadedStartInfo + /// Pointer to the native table, placed inside ReloadedStartInfo /// public IntPtr TablePointer => _tablePointer; @@ -143,18 +143,12 @@ private IntPtr GetModConfigDirectory(IntPtr modIdUtf8) catch (Exception e) { LogError(e, nameof(GetModConfigDirectory)); return IntPtr.Zero; } } - /// - /// Writes text to the log without appending a newline. - /// private void Write(IntPtr textUtf8) { try { _logger?.Write(ReadUtf8(textUtf8)); } catch (Exception e) { LogError(e, nameof(Write)); } } - /// - /// Writes text to the log without appending a newline, with a queue system. - /// private void WriteAsync(IntPtr textUtf8) { try { _logger?.WriteAsync(ReadUtf8(textUtf8)); } @@ -167,9 +161,6 @@ private void WriteLine(IntPtr textUtf8) catch (Exception e) { LogError(e, nameof(WriteLine)); } } - /// - /// Writes a line to the log, with a queue system. - /// private void WriteLineAsync(IntPtr textUtf8) { try { _logger?.WriteLineAsync(ReadUtf8(textUtf8)); } From 1a9c095e512499fc88816ee1ac7d195c2ac576c5 Mon Sep 17 00:00:00 2001 From: Sewer56 Date: Wed, 23 Sep 2026 00:12:21 +0100 Subject: [PATCH 31/35] Changed: Use nint instead of IntPtr in native mod config code - Swaps IntPtr for nint in NativeLoaderApiBridge, NativeMod and bridge tests - Style-only change; nint is a compile-time alias for IntPtr, same IL --- .../Loader/NativeLoaderApiBridgeTests.cs | 22 +++---- .../Mods/Structs/NativeLoaderApiBridge.cs | 66 +++++++++---------- .../Mods/Structs/NativeMod.cs | 30 ++++----- 3 files changed, 59 insertions(+), 59 deletions(-) diff --git a/source/Reloaded.Mod.Loader.Tests/Loader/NativeLoaderApiBridgeTests.cs b/source/Reloaded.Mod.Loader.Tests/Loader/NativeLoaderApiBridgeTests.cs index fc02306a..966e511d 100644 --- a/source/Reloaded.Mod.Loader.Tests/Loader/NativeLoaderApiBridgeTests.cs +++ b/source/Reloaded.Mod.Loader.Tests/Loader/NativeLoaderApiBridgeTests.cs @@ -26,13 +26,13 @@ public void Table_Has_Version_And_Functions() // Assert Assert.Equal(1, table.ApiVersion); - Assert.NotEqual(IntPtr.Zero, table.LoadMod); - Assert.NotEqual(IntPtr.Zero, table.GetModConfigDirectory); - Assert.NotEqual(IntPtr.Zero, table.Write); - Assert.NotEqual(IntPtr.Zero, table.WriteAsync); - Assert.NotEqual(IntPtr.Zero, table.WriteLine); - Assert.NotEqual(IntPtr.Zero, table.WriteLineAsync); - Assert.NotEqual(IntPtr.Zero, table.FreeString); + Assert.NotEqual(nint.Zero, table.LoadMod); + Assert.NotEqual(nint.Zero, table.GetModConfigDirectory); + Assert.NotEqual(nint.Zero, table.Write); + Assert.NotEqual(nint.Zero, table.WriteAsync); + Assert.NotEqual(nint.Zero, table.WriteLine); + Assert.NotEqual(nint.Zero, table.WriteLineAsync); + Assert.NotEqual(nint.Zero, table.FreeString); } [Fact] @@ -61,7 +61,7 @@ public void FreeString_Ignores_Null() var freeString = Marshal.GetDelegateForFunctionPointer(ReadTable().FreeString); // Act - freeString(IntPtr.Zero); + freeString(nint.Zero); } [Fact] @@ -76,7 +76,7 @@ public void GetDirectoryForMod_Returns_Zero_Instead_Of_Throwing() var result = getString(ToUtf8("nope.mod")); // Assert - Assert.Equal(IntPtr.Zero, result); + Assert.Equal(nint.Zero, result); } [Fact] @@ -147,12 +147,12 @@ public void Dispose_Releases_The_Table() _bridge.Dispose(); // Assert - Assert.Equal(IntPtr.Zero, _bridge.TablePointer); + Assert.Equal(nint.Zero, _bridge.TablePointer); } private NativeReloadedLoaderApiTable ReadTable() => Marshal.PtrToStructure(_bridge.TablePointer); - private static IntPtr ToUtf8(string value) + private static nint ToUtf8(string value) { var bytes = Encoding.UTF8.GetBytes(value); var pointer = Marshal.AllocHGlobal(bytes.Length + 1); diff --git a/source/Reloaded.Mod.Loader/Mods/Structs/NativeLoaderApiBridge.cs b/source/Reloaded.Mod.Loader/Mods/Structs/NativeLoaderApiBridge.cs index e8d08b9c..309e5133 100644 --- a/source/Reloaded.Mod.Loader/Mods/Structs/NativeLoaderApiBridge.cs +++ b/source/Reloaded.Mod.Loader/Mods/Structs/NativeLoaderApiBridge.cs @@ -9,17 +9,17 @@ public struct NativeReloadedLoaderApiTable { public int ApiVersion; - public IntPtr LoadMod; - public IntPtr UnloadMod; - public IntPtr SuspendMod; - public IntPtr ResumeMod; - public IntPtr GetDirectoryForMod; - public IntPtr GetModConfigDirectory; - public IntPtr Write; - public IntPtr WriteAsync; - public IntPtr WriteLine; - public IntPtr WriteLineAsync; - public IntPtr FreeString; + public nint LoadMod; + public nint UnloadMod; + public nint SuspendMod; + public nint ResumeMod; + public nint GetDirectoryForMod; + public nint GetModConfigDirectory; + public nint Write; + public nint WriteAsync; + public nint WriteLine; + public nint WriteLineAsync; + public nint FreeString; } /// @@ -35,17 +35,17 @@ public sealed class NativeLoaderApiBridge : IDisposable // the table has to say so. Delegates default to stdcall instead, which would // wreck the stack on 32 bit games. [UnmanagedFunctionPointer(CallingConvention.Cdecl)] - public delegate void Utf8Action(IntPtr valueUtf8); + public delegate void Utf8Action(nint valueUtf8); [UnmanagedFunctionPointer(CallingConvention.Cdecl)] - public delegate IntPtr Utf8ToString(IntPtr valueUtf8); + public delegate nint Utf8ToString(nint valueUtf8); [UnmanagedFunctionPointer(CallingConvention.Cdecl)] - public delegate void FreeAction(IntPtr value); + public delegate void FreeAction(nint value); private readonly IModLoader _loader; private readonly Logger _logger; - private IntPtr _tablePointer; + private nint _tablePointer; private readonly Utf8Action _loadMod; @@ -105,63 +105,63 @@ public NativeLoaderApiBridge(IModLoader loader, Logger logger = null) /// /// Pointer to the native table, placed inside ReloadedStartInfo /// - public IntPtr TablePointer => _tablePointer; + public nint TablePointer => _tablePointer; - private void LoadMod(IntPtr modIdUtf8) + private void LoadMod(nint modIdUtf8) { try { _loader.LoadMod(ReadUtf8(modIdUtf8)); } catch (Exception e) { LogError(e, nameof(LoadMod)); } } - private void UnloadMod(IntPtr modIdUtf8) + private void UnloadMod(nint modIdUtf8) { try { _loader.UnloadMod(ReadUtf8(modIdUtf8)); } catch (Exception e) { LogError(e, nameof(UnloadMod)); } } - private void SuspendMod(IntPtr modIdUtf8) + private void SuspendMod(nint modIdUtf8) { try { _loader.SuspendMod(ReadUtf8(modIdUtf8)); } catch (Exception e) { LogError(e, nameof(SuspendMod)); } } - private void ResumeMod(IntPtr modIdUtf8) + private void ResumeMod(nint modIdUtf8) { try { _loader.ResumeMod(ReadUtf8(modIdUtf8)); } catch (Exception e) { LogError(e, nameof(ResumeMod)); } } - private IntPtr GetDirectoryForMod(IntPtr modIdUtf8) + private nint GetDirectoryForMod(nint modIdUtf8) { try { return Marshal.StringToHGlobalUni(_loader.GetDirectoryForModId(ReadUtf8(modIdUtf8))); } - catch (Exception e) { LogError(e, nameof(GetDirectoryForMod)); return IntPtr.Zero; } + catch (Exception e) { LogError(e, nameof(GetDirectoryForMod)); return nint.Zero; } } - private IntPtr GetModConfigDirectory(IntPtr modIdUtf8) + private nint GetModConfigDirectory(nint modIdUtf8) { try { return Marshal.StringToHGlobalUni(_loader.GetModConfigDirectory(ReadUtf8(modIdUtf8))); } - catch (Exception e) { LogError(e, nameof(GetModConfigDirectory)); return IntPtr.Zero; } + catch (Exception e) { LogError(e, nameof(GetModConfigDirectory)); return nint.Zero; } } - private void Write(IntPtr textUtf8) + private void Write(nint textUtf8) { try { _logger?.Write(ReadUtf8(textUtf8)); } catch (Exception e) { LogError(e, nameof(Write)); } } - private void WriteAsync(IntPtr textUtf8) + private void WriteAsync(nint textUtf8) { try { _logger?.WriteAsync(ReadUtf8(textUtf8)); } catch (Exception e) { LogError(e, nameof(WriteAsync)); } } - private void WriteLine(IntPtr textUtf8) + private void WriteLine(nint textUtf8) { try { _logger?.WriteLine(ReadUtf8(textUtf8)); } catch (Exception e) { LogError(e, nameof(WriteLine)); } } - private void WriteLineAsync(IntPtr textUtf8) + private void WriteLineAsync(nint textUtf8) { try { _logger?.WriteLineAsync(ReadUtf8(textUtf8)); } catch (Exception e) { LogError(e, nameof(WriteLineAsync)); } @@ -171,11 +171,11 @@ private void WriteLineAsync(IntPtr textUtf8) /// Gives back a string handed out by the functions above. Mods can't free it /// themselves, the memory comes from our side of the fence, not their CRT. /// - private void FreeString(IntPtr value) + private void FreeString(nint value) { try { - if (value != IntPtr.Zero) + if (value != nint.Zero) Marshal.FreeHGlobal(value); } catch (Exception e) { LogError(e, nameof(FreeString)); } @@ -183,14 +183,14 @@ private void FreeString(IntPtr value) private void LogError(Exception e, string function) => _logger?.WriteLineAsync($"[NativeLoaderApi] {function} failed: {e.Message}"); - private static string ReadUtf8(IntPtr pointer) => pointer == IntPtr.Zero ? string.Empty : Marshal.PtrToStringUTF8(pointer)!; + private static string ReadUtf8(nint pointer) => pointer == nint.Zero ? string.Empty : Marshal.PtrToStringUTF8(pointer)!; public void Dispose() { - if (_tablePointer == IntPtr.Zero) + if (_tablePointer == nint.Zero) return; Marshal.FreeHGlobal(_tablePointer); - _tablePointer = IntPtr.Zero; + _tablePointer = nint.Zero; } } diff --git a/source/Reloaded.Mod.Loader/Mods/Structs/NativeMod.cs b/source/Reloaded.Mod.Loader/Mods/Structs/NativeMod.cs index f1186737..24083db6 100644 --- a/source/Reloaded.Mod.Loader/Mods/Structs/NativeMod.cs +++ b/source/Reloaded.Mod.Loader/Mods/Structs/NativeMod.cs @@ -10,7 +10,7 @@ public class NativeMod : IModV1 /// /// Handle to the native module. /// - private IntPtr _moduleHandle; + private nint _moduleHandle; private ReloadedStart _start; private ReloadedStartEx _startEx; @@ -25,7 +25,7 @@ public class NativeMod : IModV1 private string _modDirectory; private string _userConfigDirectory; private string _modId; - private IntPtr _loaderApiTable; + private nint _loaderApiTable; /// /// Creates an IMod wrapper for a native DLL. @@ -34,7 +34,7 @@ public class NativeMod : IModV1 /// Path to the directory where the mod's user configuration is stored, passed to mods exporting ReloadedStartEx. /// Pointer to the native loader API table shared by all mods, passed to mods exporting ReloadedStartEx. /// Id of this mod, handed to the mod with the loader API. - public NativeMod(string path, string userConfigDirectory = null, IntPtr loaderApiTable = default, string modId = null) + public NativeMod(string path, string userConfigDirectory = null, nint loaderApiTable = default, string modId = null) { _modDirectory = Path.GetDirectoryName(Path.GetFullPath(path))!; _userConfigDirectory = userConfigDirectory; @@ -120,28 +120,28 @@ private void InvokeStartEx() } finally { - if (info.ModDirectory != IntPtr.Zero) + if (info.ModDirectory != nint.Zero) Marshal.FreeHGlobal(info.ModDirectory); - if (info.UserConfigDirectory != IntPtr.Zero) + if (info.UserConfigDirectory != nint.Zero) Marshal.FreeHGlobal(info.UserConfigDirectory); - if (info.ModId != IntPtr.Zero) + if (info.ModId != nint.Zero) Marshal.FreeHGlobal(info.ModId); } } // Utility Functions. - private TDelegate GetDelegateForNativeFunction(IntPtr moduleHandle, string functionName) where TDelegate : Delegate + private TDelegate GetDelegateForNativeFunction(nint moduleHandle, string functionName) where TDelegate : Delegate { var address = GetProcAddress(moduleHandle, functionName); - return address != IntPtr.Zero ? Marshal.GetDelegateForFunctionPointer(address) : null; + return address != nint.Zero ? Marshal.GetDelegateForFunctionPointer(address) : null; } /// /// Copies a string to unmanaged memory as UTF-8; free with . /// - private static IntPtr StringToHGlobalUTF8(string value) + private static nint StringToHGlobalUTF8(string value) { var bytes = Encoding.UTF8.GetBytes(value); var pointer = Marshal.AllocHGlobal(bytes.Length + 1); @@ -183,34 +183,34 @@ internal struct NativeReloadedStartInfo /// Folder with the mod's own files (ConfigSchema.json, ...). /// UTF-16 string, only valid for the duration of the call. /// - public IntPtr ModDirectory; + public nint ModDirectory; /// /// Folder where the launcher stores the user settings. /// UTF-16 string, only valid for the duration of the call. /// - public IntPtr UserConfigDirectory; + public nint UserConfigDirectory; /// /// Id of the mod being started. /// UTF-8 string, only valid for the duration of the call. /// - public IntPtr ModId; + public nint ModId; /// /// Wrapper around the loader API (), usable to load, /// unload and query other mods. Stays valid past the call, /// for the lifetime of the mod. /// - public IntPtr LoaderApi; + public nint LoaderApi; } #region Native Imports [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)] - public static extern IntPtr LoadLibraryW(string lpFileName); + public static extern nint LoadLibraryW(string lpFileName); [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Ansi)] - public static extern IntPtr GetProcAddress(IntPtr hModule, string lpProcName); + public static extern nint GetProcAddress(nint hModule, string lpProcName); [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] public static extern int GetDllDirectoryW(int nBufferLength, StringBuilder lpPathName); From 5a04cc46495871e9812556cf8dd02595b2ea76fe Mon Sep 17 00:00:00 2001 From: Sewer56 Date: Wed, 23 Sep 2026 00:13:54 +0100 Subject: [PATCH 32/35] Removed: Redundant external-write retry comment in ConfigurableBase --- .../Models/Model/Configuration/Native/ConfigurableBase.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/ConfigurableBase.cs b/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/ConfigurableBase.cs index 7f60fcae..a2bce492 100644 --- a/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/ConfigurableBase.cs +++ b/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/ConfigurableBase.cs @@ -84,7 +84,6 @@ private void OnConfigurationUpdated() { lock (_readLock) { - // Note: External program might still be writing to file while this is being executed, so we need to keep retrying. var newConfig = ConfigIO.TryLoad(GetType(), FilePath!, ConfigName, 250, 2); if (newConfig == null) return; From 9e877f68ab56d04ca6e4152a2177926f503167ba Mon Sep 17 00:00:00 2001 From: Sewer56 Date: Wed, 23 Sep 2026 00:19:51 +0100 Subject: [PATCH 33/35] Changed: Use Environment alias for System.Environment in native schema - Add `using Environment = System.Environment;` to the 3 schema files. - Replace `System.Environment.SpecialFolder` with `Environment.SpecialFolder`. --- .../Model/Configuration/Native/Schema/FilePicker.cs | 7 ++++--- .../Model/Configuration/Native/Schema/FolderPicker.cs | 7 ++++--- .../Configuration/Native/Schema/JsonNodeExtensions.cs | 11 ++++++----- 3 files changed, 14 insertions(+), 11 deletions(-) diff --git a/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/Schema/FilePicker.cs b/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/Schema/FilePicker.cs index 1db7c75d..cb588874 100644 --- a/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/Schema/FilePicker.cs +++ b/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/Schema/FilePicker.cs @@ -1,4 +1,5 @@ using System.Text.Json.Nodes; +using Environment = System.Environment; namespace Reloaded.Mod.Launcher.Lib.Models.Model.Configuration.Native.Schema; @@ -15,10 +16,10 @@ public class FilePicker /// /// Fallback folder when is null, as an - /// value; declared in the + /// value; declared in the /// schema by name, e.g. Desktop. /// - public System.Environment.SpecialFolder InitialFolderPath { get; set; } = System.Environment.SpecialFolder.Personal; + public Environment.SpecialFolder InitialFolderPath { get; set; } = Environment.SpecialFolder.Personal; /// /// Label of the choose file button. @@ -82,7 +83,7 @@ public class FilePicker public static FilePicker Parse(JsonNode node) => new() { InitialDirectory = node.GetStringOrDefault(Keys.InitialDirectory, null), - InitialFolderPath = node.GetSpecialFolderOrDefault(Keys.InitialFolderPath, System.Environment.SpecialFolder.Personal), + InitialFolderPath = node.GetSpecialFolderOrDefault(Keys.InitialFolderPath, Environment.SpecialFolder.Personal), ChooseFileButtonLabel = node.GetStringOrDefault(Keys.ChooseFileButtonLabel, "Choose File")!, UserCanEditPathText = node.GetBoolOrDefault(Keys.UserCanEditPathText, true), Title = node.GetStringOrDefault(Keys.Title, "")!, diff --git a/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/Schema/FolderPicker.cs b/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/Schema/FolderPicker.cs index 86b3788e..52106d5e 100644 --- a/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/Schema/FolderPicker.cs +++ b/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/Schema/FolderPicker.cs @@ -1,4 +1,5 @@ using System.Text.Json.Nodes; +using Environment = System.Environment; namespace Reloaded.Mod.Launcher.Lib.Models.Model.Configuration.Native.Schema; @@ -15,10 +16,10 @@ public class FolderPicker /// /// Fallback folder when is null, as an - /// value; declared in the + /// value; declared in the /// schema by name, e.g. Desktop. /// - public System.Environment.SpecialFolder InitialFolderPath { get; set; } = System.Environment.SpecialFolder.Personal; + public Environment.SpecialFolder InitialFolderPath { get; set; } = Environment.SpecialFolder.Personal; /// /// Label of the choose folder button. @@ -62,7 +63,7 @@ public class FolderPicker public static FolderPicker Parse(JsonNode node) => new() { InitialDirectory = node.GetStringOrDefault(Keys.InitialDirectory, null), - InitialFolderPath = node.GetSpecialFolderOrDefault(Keys.InitialFolderPath, System.Environment.SpecialFolder.Personal), + InitialFolderPath = node.GetSpecialFolderOrDefault(Keys.InitialFolderPath, Environment.SpecialFolder.Personal), ChooseFolderButtonLabel = node.GetStringOrDefault(Keys.ChooseFolderButtonLabel, "Choose Folder")!, UserCanEditPathText = node.GetBoolOrDefault(Keys.UserCanEditPathText, true), Title = node.GetStringOrDefault(Keys.Title, "")!, diff --git a/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/Schema/JsonNodeExtensions.cs b/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/Schema/JsonNodeExtensions.cs index 134297f4..2ea32321 100644 --- a/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/Schema/JsonNodeExtensions.cs +++ b/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/Schema/JsonNodeExtensions.cs @@ -1,4 +1,5 @@ using System.Text.Json.Nodes; +using Environment = System.Environment; namespace Reloaded.Mod.Launcher.Lib.Models.Model.Configuration.Native.Schema; @@ -95,11 +96,11 @@ public static bool GetBoolOrDefault(this JsonNode? node, string name, bool fallb } /// - /// Reads a given either by name + /// Reads a given either by name /// (e.g. Desktop, ProgramFiles) or by numeric value. /// Throws for unknown names. /// - public static System.Environment.SpecialFolder GetSpecialFolderOrDefault(this JsonNode? node, string name, System.Environment.SpecialFolder fallback) + public static Environment.SpecialFolder GetSpecialFolderOrDefault(this JsonNode? node, string name, Environment.SpecialFolder fallback) { var value = node?[name]; if (value == null) @@ -108,17 +109,17 @@ public static System.Environment.SpecialFolder GetSpecialFolderOrDefault(this Js if (value.GetValueKind() == JsonValueKind.String) { var text = value.GetValue(); - if (System.Enum.TryParse(text, ignoreCase: true, out var parsed)) + if (System.Enum.TryParse(text, ignoreCase: true, out var parsed)) return parsed; - throw new JsonException($"'{name}' value '{text}' is not a known '{nameof(System.Environment.SpecialFolder)}' name."); + throw new JsonException($"'{name}' value '{text}' is not a known '{nameof(Environment.SpecialFolder)}' name."); } if (value.GetValueKind() == JsonValueKind.Number) { var element = value.GetValue(); if (element.TryGetInt32(out var number)) - return (System.Environment.SpecialFolder)number; + return (Environment.SpecialFolder)number; } return fallback; From f67f40a932fb3e6bd87b02ac40b0939444028dd4 Mon Sep 17 00:00:00 2001 From: Sewer56 Date: Wed, 23 Sep 2026 00:26:15 +0100 Subject: [PATCH 34/35] Changed: Clarify native mod documentation - Link `InitialFolderPath` to the .NET special-folder reference. - Remove redundant UTF-8 compiler guidance. --- docs/NativeMods.md | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/docs/NativeMods.md b/docs/NativeMods.md index 299bf29e..20bad757 100644 --- a/docs/NativeMods.md +++ b/docs/NativeMods.md @@ -146,9 +146,9 @@ Example: the attributes used by the C# mod template. - `Slider`, `FilePicker` and `FolderPicker` mirror the `SliderControlParams`, `FilePickerParams` and `FolderPickerParams` attributes, all fields are - optional. `InitialFolderPath` is one of .NET's - `Environment.SpecialFolder` names, e.g. `Desktop`, `MyDocuments`, - `ProgramFiles`, etc. + optional. +- Use a .NET [Environment.SpecialFolder][special-folder] name for + `InitialFolderPath`, such as `Desktop`, `MyDocuments` or `ProgramFiles`. - Each entry in `Configurations` becomes one page of the dialog, saved to its own file (`FileName`) inside the mod's user config folder (`User/Mods/`). Values missing from the file fall back to @@ -198,8 +198,7 @@ them while the game is running. `reloaded::write_line(text)` writes a line to the Reloaded log through the loader API and `reloaded::write_line_async(text)` queues the write instead. `write`/`write_async` counterparts write the text without appending a newline. -The text is UTF-8; the helper header already asks MSVC to encode narrow literals as UTF-8 and -building with `/utf-8` does the same for the whole project. [native-template]: https://github.com/Reloaded-Project/Reloaded-II/tree/master/source/Reloaded.Mod.Template/templates/native [native-header]: https://github.com/Reloaded-Project/Reloaded-II/blob/master/source/Reloaded.Mod.Template/templates/native/ReloadedModConfig.h +[special-folder]: https://learn.microsoft.com/dotnet/api/system.environment.specialfolder From e0b85500aada5cc48cc80232691c5477f362069e Mon Sep 17 00:00:00 2001 From: Sewer56 Date: Wed, 23 Sep 2026 00:31:23 +0100 Subject: [PATCH 35/35] Changed: Show config and logging usage in C++ example --- docs/NativeMods.md | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/docs/NativeMods.md b/docs/NativeMods.md index 20bad757..a0bb8c00 100644 --- a/docs/NativeMods.md +++ b/docs/NativeMods.md @@ -179,6 +179,8 @@ exactly one source file: static void my_start() { auto& config = reloaded::config(); + + // Schema defaults take precedence over these fallbacks. bool enabled = config.get_bool("EnableThing", true); long long volume = config.get_int("Volume", 75); double brightness = config.get_float("Brightness", 1.5); @@ -186,19 +188,23 @@ static void my_start() static const char* quality[] = { "Low", "High" }; int qualityIndex = config.get_enum("Quality", quality, 2, 1); + + // Reload on changes; detach the thread or retain and join it on unload. + // config.watch([](reloaded::ModConfig& changedConfig) { + // reloaded::write_line("Configuration changed"); + // }).detach(); + + // Writes go to the Reloaded log; *_line adds a newline, *_async queues. + // Prefer async except for temporary debugging. + // reloaded::write_line("Configuration loaded"); + // reloaded::write_line_async("Configuration loaded"); + // reloaded::write("Configuration loaded"); + // reloaded::write_async("Configuration loaded"); } RELOADED_MOD_CONFIG_IMPL(my_start) ``` -Missing values fall back to the schema defaults, then to the fallback -argument. `config.watch(callback)` reloads the settings when the user changes -them while the game is running. - -`reloaded::write_line(text)` writes a line to the Reloaded log through the -loader API and `reloaded::write_line_async(text)` queues the write instead. -`write`/`write_async` counterparts write the text without appending a newline. - [native-template]: https://github.com/Reloaded-Project/Reloaded-II/tree/master/source/Reloaded.Mod.Template/templates/native [native-header]: https://github.com/Reloaded-Project/Reloaded-II/blob/master/source/Reloaded.Mod.Template/templates/native/ReloadedModConfig.h [special-folder]: https://learn.microsoft.com/dotnet/api/system.environment.specialfolder