diff --git a/docs/NativeMods.md b/docs/NativeMods.md index e8be2045..a0bb8c00 100644 --- a/docs/NativeMods.md +++ b/docs/NativeMods.md @@ -21,12 +21,18 @@ To generate the config file, create a new mod from within the launcher. 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. -The exported methods should have no parameters and return `void`. + +In the case of `ReloadedStartInfo`, it provides a wrapper around the API that's +usually provided to .NET mods (`IModLoader`). + +After calling any API that returns strings, you will need to call `free_string` +afterwards. **Suspend, Resume, Unload:** @@ -47,5 +53,158 @@ Specifically, you will need to use a good hooking/detouring library that fully r Here is an example of how such a hooking library may be implemented: [Reloaded.Hooks](https://github.com/Reloaded-Project/Reloaded.Hooks/issues/2). -## CoreRT/NativeAOT? -Yes you can; mad scientist. +## 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]. + +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 +``` + +Upon building, the mod will automatically be copied to the right location +and show up in Reloaded-II. + +## Mod Configuration + +### User Settings (Config Dialog) + +The Reloaded-II launcher exposes a *Configure* dialog for native mods if the +`ConfigSchema.json` file exists next to `ModConfig.json`. + +The declarative schema file supports all features supported by the .NET equivalent. + +Example: + +```json +{ + "Configurations": [ + { + "FileName": "Config.json", + "DisplayName": "Default Config", + "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": "enum", + "DefaultValue": "High", + "Values": [ + "Low", + { "Name": "High", "DisplayName": "High Quality" } + ] + }, + { + "Name": "CustomFile", + "Type": "string", + "FilePicker": { "Title": "Choose a File" } + } + ] + } + ] +} +``` + +- `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. +- 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 + `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 + +#### 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" + +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); + std::wstring file = config.get_wstring("CustomFile", L""); + + 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) +``` + +[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 diff --git a/source/Reloaded.Mod.Launcher.Lib/Commands/Mod/ConfigureModCommand.cs b/source/Reloaded.Mod.Launcher.Lib/Commands/Mod/ConfigureModCommand.cs index b5c02a9e..802b8bb3 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; /// @@ -19,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. @@ -65,12 +74,57 @@ private bool TryGetConfiguratorDisposing() // Disallowed inlining to ensure nothing from library can be kept alive by stack references etc. [MethodImpl(MethodImplOptions.NoInlining)] private bool TryGetConfigurator(out IConfiguratorV1? configurator, out PluginLoader? loader) + { + 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)) + { + loader = null; + configurator = CreateNativeConfigurator(modDirectory); + return true; + } + + return TryGetManagedConfigurator(modDirectory, out configurator, out loader); + } + + /// + /// 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); + + // 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); + + Directory.CreateDirectory(configDirectory); + + var nativeConfigurator = new Native.ModConfigurator(modDirectory); + ConfigureConfigurator(nativeConfigurator, modDirectory, configDirectory); + + 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; - string dllPath = config.GetManagedDllPath(_modTuple.Path); configurator = null; loader = null; + string dllPath = config.GetManagedDllPath(_modTuple.Path); + if (!File.Exists(dllPath)) return false; @@ -84,34 +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)!; - var modDirectory = Path.GetFullPath(Path.GetDirectoryName(_modTuple.Path)!); + 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/ConfigTypeEmitter.cs b/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/ConfigTypeEmitter.cs new file mode 100644 index 00000000..300d7cdc --- /dev/null +++ b/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/ConfigTypeEmitter.cs @@ -0,0 +1,328 @@ +using System.Reflection.Emit; +using Reloaded.Mod.Interfaces.Structs; +using DataAnnotations = System.ComponentModel.DataAnnotations; + +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 same attributes as a hand written C# configuration class: +/// - , , +/// +/// - (backs the Reset button of the dialog) +/// - (sort order) +/// - , , +/// (custom editors) +/// The PropertyGrid renders them exactly like a C# mod's configuration. +/// +public static class ConfigTypeEmitter +{ + 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. + /// + /// Thrown when a property's Type is unknown, or a control does not match + /// the property type. + /// + public static ConfigurableBase CreateInstance(Schema.Configuration configuration, string cacheKey) + { + Type type; + lock (BuildLock) + { + if (!TypeCache.TryGetValue(cacheKey, out type!)) + { + type = BuildType(configuration, cacheKey); + TypeCache[cacheKey] = type; + } + } + + return (ConfigurableBase)Activator.CreateInstance(type)!; + } + + 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)); + + // 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 CollectEnums(configuration)) + { + 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(ConfigurableBase).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()!; + } + + /// + /// Declared enums plus one per property with inline . + /// + private static IEnumerable CollectEnums(Schema.Configuration configuration) + { + foreach (var schemaEnum in configuration.Enums) + yield return schemaEnum; + + foreach (var property in configuration.Properties) + { + if (property.Values.Count > 0) + yield return new Schema.Enum() { Name = property.Name, Members = property.Values }; + } + } + + private static (Type propertyType, object? defaultValue) ResolveTypeAndDefault(Schema.Property property, Dictionary enums) + { + switch (property.Type) + { + case Schema.Property.SupportedTypes.Bool: + return (typeof(bool), property.DefaultValue is bool b ? b : false); + + case Schema.Property.SupportedTypes.Int: + return (typeof(int), property.DefaultValue == null ? 0 : Convert.ToInt32(property.DefaultValue)); + + case Schema.Property.SupportedTypes.Float: + return (typeof(float), property.DefaultValue == null ? 0.0f : Convert.ToSingle(property.DefaultValue)); + + case Schema.Property.SupportedTypes.Double: + return (typeof(double), property.DefaultValue == null ? 0.0 : Convert.ToDouble(property.DefaultValue)); + + 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 '{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}"); + } + + return (enumType, GetEnumDefault(property, enumType)); + } + } + + private static object GetEnumDefault(Schema.Property 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(Schema.Property 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. + + // 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 != 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!, 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!, 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/Native/ConfigurableBase.cs b/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/ConfigurableBase.cs new file mode 100644 index 00000000..a2bce492 --- /dev/null +++ b/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/ConfigurableBase.cs @@ -0,0 +1,300 @@ +using System.Collections.Concurrent; +using System.Text.Json.Nodes; + +namespace Reloaded.Mod.Launcher.Lib.Models.Model.Configuration.Native; + +/// +/// Base class for the configuration objects generated for native (non .NET) mods. +/// +/// +/// 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. +/// +public abstract class ConfigurableBase : 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) + { + 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; + DisposeEvents(); + + // Call subscribers through the new config. + newConfig.ConfigurationUpdated?.Invoke(newConfig); + } + } + + private void OnSave() => ConfigIO.Save(this, FilePath!); +} + +/// +/// Reads and writes the value files of native mod configurations. +/// +/// +/// 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 }; + + 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. + /// + /// 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)) + 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. + /// + /// 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)!; + 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; + } + + /// + /// 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())) + { + 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. + /// + /// 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)]); +} 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 new file mode 100644 index 00000000..b36ac690 --- /dev/null +++ b/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/ModConfigSchema.cs @@ -0,0 +1,79 @@ +using System.Text.Json.Nodes; + +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. +/// +/// The schema is the native equivalent of the attributes C# mods declare, +/// such as . +/// Native and C# mods therefore look and behave the same: +/// - , , +/// and +/// - , and +/// control params +/// +/// The individual schema models live in the namespace. +/// +public class ModConfigSchema +{ + /// + /// Name of the config file to place 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. + /// + /// Thrown when the schema is empty or invalid. A missing file throws + /// ; malformed JSON, + /// . + /// + 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 ModConfigSchema Parse(JsonNode node, string modDirectory) + { + try + { + var schema = new ModConfigSchema(); + if (node[Schema.Keys.Configurations] is JsonArray configurations) + { + foreach (var configurationNode in configurations) + { + 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) + throw new JsonException($"Schema requires at least one entry in '{Schema.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); + } + } +} 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 new file mode 100644 index 00000000..267f96a3 --- /dev/null +++ b/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/ModConfigurator.cs @@ -0,0 +1,124 @@ +namespace Reloaded.Mod.Launcher.Lib.Models.Model.Configuration.Native; + +/// +/// Configurator for native (non .NET) mods that declare their settings through a ConfigSchema.json file. +/// Native equivalent of a C# mod's . +/// +public class ModConfigurator : 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 ModConfigurator(string modDirectory) + { + _modDirectory = modDirectory; + _schemaPath = Path.Combine(modDirectory, ModConfigSchema.SchemaFileName); + } + + /// + public void SetModDirectory(string modDirectory) + { + _modDirectory = modDirectory; + _schemaPath = Path.Combine(modDirectory, ModConfigSchema.SchemaFileName); + } + + /// + /// Thrown when the settings + /// folder was not set with . + public IConfigurable[] GetConfigurations() + { + var schema = ModConfigSchema.Load(_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(); + var result = new List(schema.Configurations.Count); + foreach (var configuration in schema.Configurations) + { + var cacheKey = $"{_modDirectory}|{configuration.FileName}|{lastWrite}"; + var instance = ConfigTypeEmitter.CreateInstance(configuration, cacheKey); + + var valuesPath = Path.Combine(configDirectory, configuration.FileName); + ConfigIO.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) => TryMigrate(oldDirectory, newDirectory); + + /// + /// Moves value files left behind in an old directory over to a new one. + /// 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 = ModConfigSchema.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); + moved.Add((oldPath, newPath)); + } + } + + return true; + } + catch (Exception e) + { + 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; + } + } + + /// + /// Exception of the last failed migration, if any. + /// + public Exception? MigrationError { get; private set; } + + /// + public void SetConfigDirectory(string configDirectory) => _configDirectory = configDirectory; + + /// + public void SetContext(in ConfiguratorContext context) => _context = context; +} 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..f6d50e4f --- /dev/null +++ b/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/Schema/Configuration.cs @@ -0,0 +1,83 @@ +using System.Text.Json.Nodes; + +namespace Reloaded.Mod.Launcher.Lib.Models.Model.Configuration.Native.Schema; + +/// +/// 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. + /// + 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 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) + { + 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) + { + if (propertyNode == null) + throw new JsonException($"'{Keys.Properties}' has a null entry."); + + 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..35157a9c --- /dev/null +++ b/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/Schema/Enum.cs @@ -0,0 +1,52 @@ +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 . + /// + 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) + { + 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); + } + } + + 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..1779f3f1 --- /dev/null +++ b/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/Schema/EnumMember.cs @@ -0,0 +1,38 @@ +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. + /// + /// Thrown when the name is not a valid identifier. + /// + public static EnumMember Parse(JsonNode node) + { + 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 new file mode 100644 index 00000000..cb588874 --- /dev/null +++ b/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/Schema/FilePicker.cs @@ -0,0 +1,99 @@ +using System.Text.Json.Nodes; +using Environment = System.Environment; + +namespace Reloaded.Mod.Launcher.Lib.Models.Model.Configuration.Native.Schema; + +/// +/// Parameters for the file picker control; native equivalent of +/// . +/// +public class FilePicker +{ + /// + /// Initial directory shown; null for the default. + /// + public string? InitialDirectory { get; set; } + + /// + /// Fallback folder when is null, as an + /// value; declared in the + /// schema by name, e.g. Desktop. + /// + public Environment.SpecialFolder InitialFolderPath { get; set; } = 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.GetSpecialFolderOrDefault(Keys.InitialFolderPath, Environment.SpecialFolder.Personal), + 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..52106d5e --- /dev/null +++ b/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/Schema/FolderPicker.cs @@ -0,0 +1,75 @@ +using System.Text.Json.Nodes; +using Environment = System.Environment; + +namespace Reloaded.Mod.Launcher.Lib.Models.Model.Configuration.Native.Schema; + +/// +/// Parameters for the folder picker control; native equivalent of +/// . +/// +public class FolderPicker +{ + /// + /// Initial directory shown; null for the default. + /// + public string? InitialDirectory { get; set; } + + /// + /// Fallback folder when is null, as an + /// value; declared in the + /// schema by name, e.g. Desktop. + /// + public Environment.SpecialFolder InitialFolderPath { get; set; } = 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.GetSpecialFolderOrDefault(Keys.InitialFolderPath, Environment.SpecialFolder.Personal), + 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..2ea32321 --- /dev/null +++ b/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/Schema/JsonNodeExtensions.cs @@ -0,0 +1,129 @@ +using System.Text.Json.Nodes; +using Environment = System.Environment; + +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; + } + + /// + /// Reads a given either by name + /// (e.g. Desktop, ProgramFiles) or by numeric value. + /// Throws for unknown names. + /// + public static Environment.SpecialFolder GetSpecialFolderOrDefault(this JsonNode? node, string name, 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(Environment.SpecialFolder)}' name."); + } + + if (value.GetValueKind() == JsonValueKind.Number) + { + var element = value.GetValue(); + if (element.TryGetInt32(out var number)) + return (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/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..50e7adef --- /dev/null +++ b/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/Schema/Property.cs @@ -0,0 +1,148 @@ +using System.Text.Json.Nodes; + +namespace Reloaded.Mod.Launcher.Lib.Models.Model.Configuration.Native.Schema; + +/// +/// An individual setting of a configuration; native equivalent of a +/// property on an implementation. +/// +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) + { + 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); + } + + 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}'."); + + 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.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..ed50ef69 --- /dev/null +++ b/source/Reloaded.Mod.Launcher.Lib/Models/Model/Configuration/Native/Schema/Slider.cs @@ -0,0 +1,92 @@ +using System.Text.Json.Nodes; + +namespace Reloaded.Mod.Launcher.Lib.Models.Model.Configuration.Native.Schema; + +/// +/// Parameters for the slider control; native equivalent of +/// . +/// +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 + /// 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) + }; +} 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..7f7de218 --- /dev/null +++ b/source/Reloaded.Mod.Loader.Tests/Launcher/NativeModConfigTests.cs @@ -0,0 +1,476 @@ +using System.ComponentModel.DataAnnotations; +using System.Reflection; +using System.Text.Json.Nodes; +using Reloaded.Mod.Interfaces; +using Reloaded.Mod.Interfaces.Structs; +using Native = Reloaded.Mod.Launcher.Lib.Models.Model.Configuration.Native; + +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, "TickFrequencyDouble": 2.5, "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, Native.ModConfigSchema.SchemaFileName), Schema); + } + + [Fact] + public void Schema_Is_Detected_And_Parsed() + { + // 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); + 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() + { + // 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); + + 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() + { + // 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); + 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); +#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); + Assert.Equal("Text (*.txt)|*.txt", filePicker!.Filter); + + // Enum members support display names. + 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()); + } + + [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); + SetProperty(configurable, "StringSetting", "changed"); + 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)); + + // 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()); + + // 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")); + Assert.Equal("changed", GetProperty(reloaded, "StringSetting")); + Assert.Equal("NoOpinion", GetProperty(reloaded, "EnumSetting")!.ToString()); + } + + [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")); + } + + [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"))); + } + + [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"))); + + var configurable = Assert.Single(configurator.GetConfigurations()); + Assert.Equal(9, GetProperty(configurable, "IntegerSetting")); + } + + [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": [ + { + "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(); + + // Act + var error = Assert.Throws(() => configurator.GetConfigurations()); + + // Assert + 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) + { + // 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); + } + + [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), """ + { + "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(); + + // 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. + 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() + { + // Arrange + File.WriteAllText(Path.Combine(ModDirectory, Native.ModConfigSchema.SchemaFileName), """ + { + "Configurations": [ + { + "FileName": "Config.json", + "Properties": [ + { + "Name": "Difficulty", + "Type": "enum", + "DefaultValue": "Hard", + "Values": [ "Easy", { "Name": "Hard", "DisplayName": "Very Hard" } ] + } + ] + }] + } + """); + + // Act + var configurable = Assert.Single(CreateConfigurator().GetConfigurations()); + + // Assert + 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() + { + // 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); + } + + [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); + 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.Tests/Loader/NativeLoaderApiBridgeTests.cs b/source/Reloaded.Mod.Loader.Tests/Loader/NativeLoaderApiBridgeTests.cs new file mode 100644 index 00000000..966e511d --- /dev/null +++ b/source/Reloaded.Mod.Loader.Tests/Loader/NativeLoaderApiBridgeTests.cs @@ -0,0 +1,165 @@ +using System.Runtime.InteropServices; +using Reloaded.Mod.Interfaces; +using Reloaded.Mod.Loader.Logging; + +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() + { + // Act + var table = ReadTable(); + + // Assert + Assert.Equal(1, table.ApiVersion); + 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] + 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)); + + var freeString = Marshal.GetDelegateForFunctionPointer(ReadTable().FreeString); + freeString(pointer); + } + + [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(nint.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(nint.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); + _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() + { + // Arrange + var pointer = _bridge.TablePointer; + + // Act + _bridge.Dispose(); + + // Assert + Assert.Equal(nint.Zero, _bridge.TablePointer); + } + + private NativeReloadedLoaderApiTable ReadTable() => Marshal.PtrToStructure(_bridge.TablePointer); + + private static nint 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 775d4443..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(); } /// @@ -301,12 +307,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 and the loader API table + var userConfigDirectory = ModUserConfig.GetUserConfigFolderForMod(modId, _loader.LoaderConfig.GetModUserConfigDirectory()); + 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..309e5133 --- /dev/null +++ b/source/Reloaded.Mod.Loader/Mods/Structs/NativeLoaderApiBridge.cs @@ -0,0 +1,196 @@ +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 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; +} + +/// +/// 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(nint valueUtf8); + + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + public delegate nint Utf8ToString(nint valueUtf8); + + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + public delegate void FreeAction(nint value); + + private readonly IModLoader _loader; + private readonly Logger _logger; + private nint _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 _write; + private readonly Utf8Action _writeAsync; + private readonly Utf8Action _writeLine; + private readonly Utf8Action _writeLineAsync; + 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; + _write = Write; + _writeAsync = WriteAsync; + _writeLine = WriteLine; + _writeLineAsync = WriteLineAsync; + _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), + 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()); + Marshal.StructureToPtr(table, _tablePointer, fDeleteOld: false); + } + + /// + /// Pointer to the native table, placed inside ReloadedStartInfo + /// + public nint TablePointer => _tablePointer; + + private void LoadMod(nint modIdUtf8) + { + try { _loader.LoadMod(ReadUtf8(modIdUtf8)); } + catch (Exception e) { LogError(e, nameof(LoadMod)); } + } + + private void UnloadMod(nint modIdUtf8) + { + try { _loader.UnloadMod(ReadUtf8(modIdUtf8)); } + catch (Exception e) { LogError(e, nameof(UnloadMod)); } + } + + private void SuspendMod(nint modIdUtf8) + { + try { _loader.SuspendMod(ReadUtf8(modIdUtf8)); } + catch (Exception e) { LogError(e, nameof(SuspendMod)); } + } + + private void ResumeMod(nint modIdUtf8) + { + try { _loader.ResumeMod(ReadUtf8(modIdUtf8)); } + catch (Exception e) { LogError(e, nameof(ResumeMod)); } + } + + private nint GetDirectoryForMod(nint modIdUtf8) + { + try { return Marshal.StringToHGlobalUni(_loader.GetDirectoryForModId(ReadUtf8(modIdUtf8))); } + catch (Exception e) { LogError(e, nameof(GetDirectoryForMod)); return nint.Zero; } + } + + private nint GetModConfigDirectory(nint modIdUtf8) + { + try { return Marshal.StringToHGlobalUni(_loader.GetModConfigDirectory(ReadUtf8(modIdUtf8))); } + catch (Exception e) { LogError(e, nameof(GetModConfigDirectory)); return nint.Zero; } + } + + private void Write(nint textUtf8) + { + try { _logger?.Write(ReadUtf8(textUtf8)); } + catch (Exception e) { LogError(e, nameof(Write)); } + } + + private void WriteAsync(nint textUtf8) + { + try { _logger?.WriteAsync(ReadUtf8(textUtf8)); } + catch (Exception e) { LogError(e, nameof(WriteAsync)); } + } + + private void WriteLine(nint textUtf8) + { + try { _logger?.WriteLine(ReadUtf8(textUtf8)); } + catch (Exception e) { LogError(e, nameof(WriteLine)); } + } + + private void WriteLineAsync(nint textUtf8) + { + try { _logger?.WriteLineAsync(ReadUtf8(textUtf8)); } + catch (Exception e) { LogError(e, nameof(WriteLineAsync)); } + } + + /// + /// 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(nint value) + { + try + { + if (value != nint.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(nint pointer) => pointer == nint.Zero ? string.Empty : Marshal.PtrToStringUTF8(pointer)!; + + public void Dispose() + { + if (_tablePointer == nint.Zero) + return; + + Marshal.FreeHGlobal(_tablePointer); + _tablePointer = nint.Zero; + } +} diff --git a/source/Reloaded.Mod.Loader/Mods/Structs/NativeMod.cs b/source/Reloaded.Mod.Loader/Mods/Structs/NativeMod.cs index 869cada8..24083db6 100644 --- a/source/Reloaded.Mod.Loader/Mods/Structs/NativeMod.cs +++ b/source/Reloaded.Mod.Loader/Mods/Structs/NativeMod.cs @@ -10,9 +10,10 @@ public class NativeMod : IModV1 /// /// Handle to the native module. /// - private IntPtr _moduleHandle; + private nint _moduleHandle; private ReloadedStart _start; + private ReloadedStartEx _startEx; private ReloadedSuspend _reloadedSuspend; private ReloadedResume _reloadedResume; private ReloadedUnload _reloadedUnload; @@ -21,22 +22,35 @@ public class NativeMod : IModV1 private InitializeASI _initializeAsi; private Init _init; private bool _started; + private string _modDirectory; + private string _userConfigDirectory; + private string _modId; + private nint _loaderApiTable; /// /// 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. + /// 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, nint 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); 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 +65,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); + + InvokeStartEx(); + _started = true; + } + else if (_start != null) { _start.Invoke(); _started = true; @@ -76,11 +99,55 @@ public void Start(IModLoaderV1 loader) public Action Disposing { get; } + /// + /// Call the ReloadedStartEx export, passing the mod its directories and the + /// loader API through a versioned struct. + /// + private void InvokeStartEx() + { + var info = new NativeReloadedStartInfo() + { + ApiVersion = 1, + ModDirectory = Marshal.StringToHGlobalUni(_modDirectory), + UserConfigDirectory = Marshal.StringToHGlobalUni(_userConfigDirectory), + ModId = StringToHGlobalUTF8(_modId), + LoaderApi = _loaderApiTable + }; + + try + { + _startEx.Invoke(ref info); + } + finally + { + if (info.ModDirectory != nint.Zero) + Marshal.FreeHGlobal(info.ModDirectory); + + if (info.UserConfigDirectory != nint.Zero) + Marshal.FreeHGlobal(info.UserConfigDirectory); + + 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 nint 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. @@ -89,18 +156,61 @@ private TDelegate GetDelegateForNativeFunction(IntPtr moduleHandle, s // Delegates for native Reloaded Exports. private delegate void ReloadedStart(); + + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + private delegate void ReloadedStartEx(ref NativeReloadedStartInfo info); + private delegate void ReloadedSuspend(); private delegate void ReloadedResume(); private delegate void ReloadedUnload(); private delegate bool ReloadedCanUnload(); private delegate bool ReloadedCanSuspend(); + /// + /// Information handed to native mods exporting ReloadedStartEx. + /// 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 + { + /// + /// Version of the struct, starts at 1. + /// + public int ApiVersion; + + /// + /// Folder with the mod's own files (ConfigSchema.json, ...). + /// UTF-16 string, only valid for the duration of the call. + /// + public nint ModDirectory; + + /// + /// Folder where the launcher stores the user settings. + /// UTF-16 string, only valid for the duration of the call. + /// + public nint UserConfigDirectory; + + /// + /// Id of the mod being started. + /// UTF-8 string, only valid for the duration of the call. + /// + 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 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); 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..86eb5d04 --- /dev/null +++ b/source/Reloaded.Mod.Template/templates/native/CMakeLists.txt @@ -0,0 +1,34 @@ +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(${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 +if (CMAKE_SIZEOF_VOID_P EQUAL 8) + set_target_properties(${PROJECT_NAME} PROPERTIES OUTPUT_NAME "${PROJECT_NAME}") +else() + 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/ConfigSchema.json b/source/Reloaded.Mod.Template/templates/native/ConfigSchema.json new file mode 100644 index 00000000..821ddd7d --- /dev/null +++ b/source/Reloaded.Mod.Template/templates/native/ConfigSchema.json @@ -0,0 +1,71 @@ +{ + "Configurations": [ + { + "FileName": "Config.json", + "DisplayName": "Default Config", + "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": "enum", + "DisplayName": "Quality", + "Description": "Quality of the thing.", + "Category": "General", + "Order": 3, + "DefaultValue": "High", + "Values": [ + "Low", + "Medium", + { "Name": "High", "DisplayName": "High Quality" } + ] + }, + { + "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..7efeb883 --- /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": "Reloaded.Native.Template32.dll", + "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..c0b00f44 --- /dev/null +++ b/source/Reloaded.Mod.Template/templates/native/README.md @@ -0,0 +1,37 @@ +# 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). | + +## 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 +``` + +Upon building, the mod will automatically be copied to the right location +and show up in Reloaded-II. + +## Workflow + +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. 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..bda864a4 --- /dev/null +++ b/source/Reloaded.Mod.Template/templates/native/ReloadedModConfig.h @@ -0,0 +1,1108 @@ +/* + 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 + +// 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 +#include +#include +#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 + +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.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; + 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; + 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 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. + inline NativeModInfo& native_mod_info() + { + static NativeModInfo info; + return info; + } + + /* + ------------------------ + ReloadedStartEx contract + ------------------------ + */ + // 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 *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); + }; + + // 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; + + // Folder with the mod's own files (ConfigSchema.json, ...). + const wchar_t* mod_directory; + + // 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; + }; + + // 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) + 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; + + 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 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->write_line != nullptr) + api->write_line(text); + } + + inline void write_line_async(const char* text) + { + ReloadedLoaderApi* api = loader(); + 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 + // 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); + } + + /* + ----------- + 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. + std::wstring mod_directory() const + { + resolve_paths(); + std::lock_guard guard(_lock); + return _mod_directory; + } + + // Directory where the launcher stores the values file. + std::wstring config_directory() const + { + resolve_paths(); + std::lock_guard guard(_lock); + return _config_directory; + } + + // Full path of the values file. + std::wstring values_path() const + { + resolve_paths(); + std::lock_guard guard(_lock); + return _config_directory + _values_file; + } + + // Full path of the schema file. + std::wstring schema_path() const + { + resolve_paths(); + std::lock_guard guard(_lock); + return _mod_directory + L"ConfigSchema.json"; + } + + // Reads the schema defaults and the current values from disk. + 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); + + 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 + { + std::lock_guard guard(_lock); + 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 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) + { + + 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_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); + } + + /* + ------- + 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 + { + 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; + + 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 + { + std::lock_guard guard(_lock); + 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 + { + std::lock_guard guard(_lock); + 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 + { + std::lock_guard guard(_lock); + 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 + { + 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); + + 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 = {}; + bool _paths_resolved = false; + std::atomic _stop_event{ nullptr }; + std::atomic_bool _stop_requested{ false }; + + mutable std::recursive_mutex _lock; + + void resolve_paths() const + { + std::lock_guard guard(_lock); + if (_paths_resolved) + 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.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); + } + + // Last attempt, loaded by another injector: assume the values live next to the DLL. + if (self->_mod_directory.empty()) + { + 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; + } + + const Json* find_value(const char* name) const + { + 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); + } + + 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 __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 __cdecl ReloadedStartEx(const reloaded::ReloadedStartInfo* info) \ + { \ + reloaded::store_start_info(info); \ + 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)