diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..7830fad --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +* text=auto +*.cs text eol=lf diff --git a/.gitignore b/.gitignore index 8a30d25..4e785fc 100644 --- a/.gitignore +++ b/.gitignore @@ -327,6 +327,8 @@ paket-files/ __pycache__/ *.pyc +ResoniteHeadless/* + # Cake - Uncomment if you are using it # tools/** # !tools/packages.config @@ -396,3 +398,5 @@ FodyWeavers.xsd # JetBrains Rider *.sln.iml +# Specifically allow the example log +!/doc/example_log.log diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..0a04128 --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,165 @@ + GNU LESSER GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + + This version of the GNU Lesser General Public License incorporates +the terms and conditions of version 3 of the GNU General Public +License, supplemented by the additional permissions listed below. + + 0. Additional Definitions. + + As used herein, "this License" refers to version 3 of the GNU Lesser +General Public License, and the "GNU GPL" refers to version 3 of the GNU +General Public License. + + "The Library" refers to a covered work governed by this License, +other than an Application or a Combined Work as defined below. + + An "Application" is any work that makes use of an interface provided +by the Library, but which is not otherwise based on the Library. +Defining a subclass of a class defined by the Library is deemed a mode +of using an interface provided by the Library. + + A "Combined Work" is a work produced by combining or linking an +Application with the Library. The particular version of the Library +with which the Combined Work was made is also called the "Linked +Version". + + The "Minimal Corresponding Source" for a Combined Work means the +Corresponding Source for the Combined Work, excluding any source code +for portions of the Combined Work that, considered in isolation, are +based on the Application, and not on the Linked Version. + + The "Corresponding Application Code" for a Combined Work means the +object code and/or source code for the Application, including any data +and utility programs needed for reproducing the Combined Work from the +Application, but excluding the System Libraries of the Combined Work. + + 1. Exception to Section 3 of the GNU GPL. + + You may convey a covered work under sections 3 and 4 of this License +without being bound by section 3 of the GNU GPL. + + 2. Conveying Modified Versions. + + If you modify a copy of the Library, and, in your modifications, a +facility refers to a function or data to be supplied by an Application +that uses the facility (other than as an argument passed when the +facility is invoked), then you may convey a copy of the modified +version: + + a) under this License, provided that you make a good faith effort to + ensure that, in the event an Application does not supply the + function or data, the facility still operates, and performs + whatever part of its purpose remains meaningful, or + + b) under the GNU GPL, with none of the additional permissions of + this License applicable to that copy. + + 3. Object Code Incorporating Material from Library Header Files. + + The object code form of an Application may incorporate material from +a header file that is part of the Library. You may convey such object +code under terms of your choice, provided that, if the incorporated +material is not limited to numerical parameters, data structure +layouts and accessors, or small macros, inline functions and templates +(ten or fewer lines in length), you do both of the following: + + a) Give prominent notice with each copy of the object code that the + Library is used in it and that the Library and its use are + covered by this License. + + b) Accompany the object code with a copy of the GNU GPL and this license + document. + + 4. Combined Works. + + You may convey a Combined Work under terms of your choice that, +taken together, effectively do not restrict modification of the +portions of the Library contained in the Combined Work and reverse +engineering for debugging such modifications, if you also do each of +the following: + + a) Give prominent notice with each copy of the Combined Work that + the Library is used in it and that the Library and its use are + covered by this License. + + b) Accompany the Combined Work with a copy of the GNU GPL and this license + document. + + c) For a Combined Work that displays copyright notices during + execution, include the copyright notice for the Library among + these notices, as well as a reference directing the user to the + copies of the GNU GPL and this license document. + + d) Do one of the following: + + 0) Convey the Minimal Corresponding Source under the terms of this + License, and the Corresponding Application Code in a form + suitable for, and under terms that permit, the user to + recombine or relink the Application with a modified version of + the Linked Version to produce a modified Combined Work, in the + manner specified by section 6 of the GNU GPL for conveying + Corresponding Source. + + 1) Use a suitable shared library mechanism for linking with the + Library. A suitable mechanism is one that (a) uses at run time + a copy of the Library already present on the user's computer + system, and (b) will operate properly with a modified version + of the Library that is interface-compatible with the Linked + Version. + + e) Provide Installation Information, but only if you would otherwise + be required to provide such information under section 6 of the + GNU GPL, and only to the extent that such information is + necessary to install and execute a modified version of the + Combined Work produced by recombining or relinking the + Application with a modified version of the Linked Version. (If + you use option 4d0, the Installation Information must accompany + the Minimal Corresponding Source and Corresponding Application + Code. If you use option 4d1, you must provide the Installation + Information in the manner specified by section 6 of the GNU GPL + for conveying Corresponding Source.) + + 5. Combined Libraries. + + You may place library facilities that are a work based on the +Library side by side in a single library together with other library +facilities that are not Applications and are not covered by this +License, and convey such a combined library under terms of your +choice, if you do both of the following: + + a) Accompany the combined library with a copy of the same work based + on the Library, uncombined with any other library facilities, + conveyed under the terms of this License. + + b) Give prominent notice with the combined library that part of it + is a work based on the Library, and explaining where to find the + accompanying uncombined form of the same work. + + 6. Revised Versions of the GNU Lesser General Public License. + + The Free Software Foundation may publish revised and/or new versions +of the GNU Lesser General Public License from time to time. Such new +versions will be similar in spirit to the present version, but may +differ in detail to address new problems or concerns. + + Each version is given a distinguishing version number. If the +Library as you received it specifies that a certain numbered version +of the GNU Lesser General Public License "or any later version" +applies to it, you have the option of following the terms and +conditions either of that published version or of any later version +published by the Free Software Foundation. If the Library as you +received it does not specify a version number of the GNU Lesser +General Public License, you may choose any version of the GNU Lesser +General Public License ever published by the Free Software Foundation. + + If the Library as you received it specifies that a proxy can decide +whether future versions of the GNU Lesser General Public License shall +apply, that proxy's public statement of acceptance of any version is +permanent authorization for you to choose that version for the +Library. diff --git a/README.md b/README.md index be23ff0..f42a2d0 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,80 @@ # ResoniteModLoader -A mod loader for Resonite + +A mod loader for [Resonite](https://resonite.com/). Consider joining our community on [Discord][Resonite Modding Discord] for support, updates, and more. + +## Installation + +If you are using the Steam version of Resonite you are in the right place. If you are on Linux, read the [Linux Notes](doc/linux.md). + +1. Download [ResoniteModLoader.dll](https://github.com/resonite-modding-group/ResoniteModLoader/releases/latest/download/ResoniteModLoader.dll) to Resonite's `Libraries` folder (`C:\Program Files (x86)\Steam\steamapps\common\Resonite\Libraries`). +2. Place [0Harmony.dll](https://github.com/resonite-modding-group/ResoniteModLoader/releases/latest/download/0Harmony.dll) into a `rml_libs` folder under your Resonite install directory (`C:\Program Files (x86)\Steam\steamapps\common\Resonite\rml_libs`). You will need to create this folder. +3. Add mod DLL files to a `rml_mods` folder under your Resonite install directory (`C:\Program Files (x86)\Steam\steamapps\common\Resonite\rml_mods`). You can create the folder if it's missing, or launch Resonite once with ResoniteModLoader installed and it will be created automatically. +4. Add the following to Resonite's launch options: `-LoadAssembly Libraries\ResoniteModLoader.dll`. If you put `ResoniteModLoader.dll` somewhere else you will need to change the path. +5. Start the game. If you want to verify that ResoniteModLoader is working you can check the Resonite logs. (`C:\Program Files (x86)\Steam\steamapps\common\Resonite\Logs`). The modloader adds some very obvious logs on startup, and if they're missing something has gone wrong. Here is an [example log file](doc/example_log.log) where everything worked correctly. + +If ResoniteModLoader isn't working after following those steps, take a look at our [troubleshooting page](doc/troubleshooting.md). + +### Example Directory Structure + +Your Resonite directory should now look similar to the following. Files not related to modding are not shown. + +``` + +│ Resonite.exe +│ +├───Logs +│ +│ +├───rml_mods +| +| +├───rml_libs +│ 0Harmony.dll +| +| +├───rml_config +| +│ +└───Libraries + ResoniteModLoader.dll +``` + +Note that the libraries can also be in the root of the Resonite install directory if you prefer, but the loading of those happens outside of RML itself. + +## Finding Mods + +A list of known mods will be made available on the Resonite Mod List. New mods and updates will also announced in [our Discord][Resonite Modding Discord]. + +## Frequently Asked Questions + +Many questions about what RML is and how it works are answered on our [frequently asked questions page](doc/faq.md). + +## Making a Mod + +Check out the [Mod Creation Guide](doc/making_mods.md). + +## Configuration + +ResoniteModLoader aims to have a reasonable default configuration, but certain things can be adjusted via an [optional config file](doc/modloader_config.md). + +## Contributing + +Issues and PRs are welcome. Please read our [Contributing Guidelines](.github/CONTRIBUTING.md)! + +## Licensing and Credits + +ResoniteModLoader is licensed under the GNU Lesser General Public License (LGPL). See [LICENSE.txt](LICENSE.txt) for the full license. + +Third-party libraries distributed alongside ResoniteModLoader: + +- [LibHarmony] ([MIT License](https://github.com/pardeike/Harmony/blob/v2.2.2.0/LICENSE)) + +Third-party libraries used in source: + +- [.NET](https://github.com/dotnet) (Various licenses) +- [Resonite](https://resonite.com/) ([EULA](https://resonite.com/policies/EULA.html)) +- [Json.NET](https://github.com/JamesNK/Newtonsoft.Json) ([MIT License](https://github.com/JamesNK/Newtonsoft.Json/blob/master/LICENSE.md)) + + +[LibHarmony]: https://github.com/pardeike/Harmony +[Resonite Modding Discord]: https://discord.gg/vCDJK9xyvm diff --git a/ResoniteModLoader.sln b/ResoniteModLoader.sln new file mode 100644 index 0000000..6d8b73e --- /dev/null +++ b/ResoniteModLoader.sln @@ -0,0 +1,25 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.5.33627.172 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ResoniteModLoader", "ResoniteModLoader\ResoniteModLoader.csproj", "{D4627C7F-8091-477A-ABDC-F1465D94D8D9}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {D4627C7F-8091-477A-ABDC-F1465D94D8D9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D4627C7F-8091-477A-ABDC-F1465D94D8D9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D4627C7F-8091-477A-ABDC-F1465D94D8D9}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D4627C7F-8091-477A-ABDC-F1465D94D8D9}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {757072E6-E985-4EC2-AB38-C4D1588F6A15} + EndGlobalSection +EndGlobal diff --git a/ResoniteModLoader/AssemblyFile.cs b/ResoniteModLoader/AssemblyFile.cs new file mode 100644 index 0000000..87083ee --- /dev/null +++ b/ResoniteModLoader/AssemblyFile.cs @@ -0,0 +1,24 @@ +namespace ResoniteModLoader; + +internal sealed class AssemblyFile { + internal string File { get; } + internal Assembly Assembly { get; set; } + internal AssemblyFile(string file, Assembly assembly) { + File = file; + Assembly = assembly; + } + private string? sha256; + internal string Sha256 { + get { + if (sha256 == null) { + try { + sha256 = Util.GenerateSHA256(File); + } catch (Exception e) { + Logger.ErrorInternal($"Exception calculating sha256 hash for {File}:\n{e}"); + sha256 = "failed to generate hash"; + } + } + return sha256; + } + } +} diff --git a/ResoniteModLoader/AssemblyHider.cs b/ResoniteModLoader/AssemblyHider.cs new file mode 100644 index 0000000..3b7ecfa --- /dev/null +++ b/ResoniteModLoader/AssemblyHider.cs @@ -0,0 +1,220 @@ +using System.Diagnostics; + +using Elements.Core; +using FrooxEngine; + +using HarmonyLib; + +namespace ResoniteModLoader; + +internal static class AssemblyHider { + /// + /// Companies that indicate an assembly is part of .NET + /// This list was found by debug logging the AssemblyCompanyAttribute for all loaded assemblies. + /// + private static HashSet knownDotNetCompanies = new List() { + "Mono development team", // used by .NET stuff and Mono.Security + }.Select(company => company.ToLower()).ToHashSet(); + + /// + /// Products that indicate an assembly is part of .NET. + /// This list was found by debug logging the AssemblyProductAttribute for all loaded assemblies. + /// + private static HashSet knownDotNetProducts = new List() { + "Microsoft® .NET", // used by a few System.* assemblies + "Microsoft® .NET Framework", // used by most of the System.* assemblies + "Mono Common Language Infrastructure", // used by mscorlib stuff + }.Select(product => product.ToLower()).ToHashSet(); + + /// + /// Assemblies that were already loaded when RML started up, minus a couple known non-assemblies. + /// + private static HashSet? resoniteAssemblies; + + /// + /// Assemblies that 100% exist due to a mod + /// + private static HashSet? modAssemblies; + + /// + /// .NET assembiles we want to ignore in some cases, like the callee check for the AppDomain.GetAssemblies() patch + /// + private static HashSet? dotNetAssemblies; + + /// + /// Patch Resonite's type lookup code to not see mod-related types. This is needed, because users can pass + /// arbitrary strings to TypeHelper.FindType(), which can be used to detect if someone is running mods. + /// + /// Our RML harmony instance + /// Assemblies that were loaded when RML first started + internal static void PatchResonite(Harmony harmony, HashSet initialAssemblies) { + if (ModLoaderConfiguration.Get().HideModTypes) { + // initialize the static assembly sets that our patches will need later + resoniteAssemblies = GetResoniteAssemblies(initialAssemblies); + modAssemblies = GetModAssemblies(resoniteAssemblies); + dotNetAssemblies = resoniteAssemblies.Where(LooksLikeDotNetAssembly).ToHashSet(); + + // TypeHelper.FindType explicitly does a type search + MethodInfo findTypeTarget = AccessTools.DeclaredMethod(typeof(TypeHelper), nameof(TypeHelper.FindType), new Type[] { typeof(string) }); + MethodInfo findTypePatch = AccessTools.DeclaredMethod(typeof(AssemblyHider), nameof(FindTypePostfix)); + harmony.Patch(findTypeTarget, postfix: new HarmonyMethod(findTypePatch)); + + // WorkerManager.IsValidGenericType checks a type for validity, and if it returns `true` it reveals that the type exists + MethodInfo isValidGenericTypeTarget = AccessTools.DeclaredMethod(typeof(WorkerManager), nameof(WorkerManager.IsValidGenericType), new Type[] { typeof(Type), typeof(bool) }); + MethodInfo isValidGenericTypePatch = AccessTools.DeclaredMethod(typeof(AssemblyHider), nameof(IsValidTypePostfix)); + harmony.Patch(isValidGenericTypeTarget, postfix: new HarmonyMethod(isValidGenericTypePatch)); + + // WorkerManager.GetType uses FindType, but upon failure fails back to doing a (strangely) exhausitive reflection-based search for the type + MethodInfo getTypeTarget = AccessTools.DeclaredMethod(typeof(WorkerManager), nameof(WorkerManager.GetType), new Type[] { typeof(string) }); + MethodInfo getTypePatch = AccessTools.DeclaredMethod(typeof(AssemblyHider), nameof(FindTypePostfix)); + harmony.Patch(getTypeTarget, postfix: new HarmonyMethod(getTypePatch)); + + // FrooxEngine likes to enumerate all types in all assemblies, which is prone to issues (such as crashing FrooxCode if a type isn't loadable) + MethodInfo getAssembliesTarget = AccessTools.DeclaredMethod(typeof(AppDomain), nameof(AppDomain.GetAssemblies), Array.Empty()); + MethodInfo getAssembliesPatch = AccessTools.DeclaredMethod(typeof(AssemblyHider), nameof(GetAssembliesPostfix)); + harmony.Patch(getAssembliesTarget, postfix: new HarmonyMethod(getAssembliesPatch)); + } + } + + private static HashSet GetResoniteAssemblies(HashSet initialAssemblies) { + // Remove RML itself, as its types should be hidden but it's guaranteed to be loaded. + initialAssemblies.Remove(Assembly.GetExecutingAssembly()); + + // Remove Harmony, as users who aren't using rml_libs will already have it loaded. + initialAssemblies.Remove(typeof(Harmony).Assembly); + + return initialAssemblies; + } + + private static HashSet GetModAssemblies(HashSet resoniteAssemblies) { + // start with ALL assemblies + HashSet assemblies = AppDomain.CurrentDomain.GetAssemblies().ToHashSet(); + + // remove assemblies that we know to have come with Resonite + assemblies.ExceptWith(resoniteAssemblies); + + // what's left are assemblies that magically appeared during the mod loading process. So mods and their dependencies. + return assemblies; + } + + /// + /// Checks if an belongs to a mod or not. + /// + /// The to check. + /// Type of root check being performed. Should be "type" or "assembly". Used in logging. + /// Name of the root check being performed. Used in logging. + /// If `true`, this will emit logs. If `false`, this function will not log. + /// If `true`, then this function will always return `false` for late-loaded types + /// `true` if this assembly belongs to a mod. + private static bool IsModAssembly(Assembly assembly, string typeOrAssembly, string name, bool log, bool forceShowLate) { + if (resoniteAssemblies!.Contains(assembly)) { + // the type belongs to a Resonite assembly + return false; // don't hide the thing + } else { + if (modAssemblies!.Contains(assembly)) { + // known type from a mod assembly + if (log) { + Logger.DebugFuncInternal(() => $"Hid {typeOrAssembly} \"{name}\" from Resonite"); + } + return true; // hide the thing + } else { + // an assembly was in neither resoniteAssemblies nor modAssemblies + // this implies someone late-loaded an assembly after RML, and it was later used in-game + // this is super weird, and probably shouldn't ever happen... but if it does, I want to know about it. + // since this is an edge case users may want to handle in different ways, the HideLateTypes rml config option allows them to choose. + bool hideLate = ModLoaderConfiguration.Get().HideLateTypes; + if (log) { + Logger.WarnInternal($"The \"{name}\" {typeOrAssembly} does not appear to part of Resonite or a mod. It is unclear whether it should be hidden or not. Due to the HideLateTypes config option being {hideLate} it will be {(hideLate ? "Hidden" : "Shown")}"); + } + // if forceShowLate == true, then this function will always return `false` for late-loaded types + // if forceShowLate == false, then this function will return `true` when hideLate == true + return hideLate && !forceShowLate; + } + } + } + + /// + /// Checks if an belongs to a mod or not. + /// + /// The to check + /// If true, then this function will always return false for late-loaded types. + /// true if this belongs to a mod. + private static bool IsModAssembly(Assembly assembly, bool forceShowLate = false) { + // this generates a lot of logspam, as a single call to AppDomain.GetAssemblies() calls this many times + return IsModAssembly(assembly, "assembly", assembly.ToString(), log: false, forceShowLate); + } + + /// + /// Checks if a belongs to a mod or not. + /// + /// The to check. + /// true if this belongs to a mod. + private static bool IsModType(Type type) { + return IsModAssembly(type.Assembly, "type", type.ToString(), log: true, forceShowLate: false); + } + + // postfix for a method that searches for a type, and returns a reference to it if found (TypeHelper.FindType and WorkerManager.GetType) + private static void FindTypePostfix(ref Type? __result) { + if (__result != null) { + // we only need to think about types if the method actually returned a non-null result + if (IsModType(__result)) { + __result = null; + } + } + } + + // postfix for a method that validates a type (WorkerManager.IsValidGenericType) + private static void IsValidTypePostfix(ref bool __result, Type type) { + if (__result == true) { + // we only need to think about types if the method actually returned a true result + if (IsModType(type)) { + __result = false; + } + } + } + + private static void GetAssembliesPostfix(ref Assembly[] __result) { + Assembly? callingAssembly = GetCallingAssembly(new(1)); + if (callingAssembly != null && resoniteAssemblies!.Contains(callingAssembly)) { + // if we're being called by Resonite code, then hide mod assemblies + Logger.DebugFuncInternal(() => $"Intercepting call to AppDomain.GetAssemblies() from {callingAssembly}"); + __result = __result + .Where(assembly => !IsModAssembly(assembly, forceShowLate: true)) // it turns out Resonite itself late-loads a bunch of stuff, so we force-show late-loaded assemblies here + .ToArray(); + } + } + + /// + /// Get the calling using stack trace analysis, ignoring .NET assemblies. + /// This implementation is SPECIFICALLY for the patch and may not be valid for other use-cases. + /// + /// The stack trace captured by the callee. + /// The calling , or null if none was found. + private static Assembly? GetCallingAssembly(StackTrace stackTrace) { + for (int i = 0; i < stackTrace.FrameCount; i++) { + Assembly? assembly = stackTrace.GetFrame(i)?.GetMethod()?.DeclaringType?.Assembly; + // .NET calls AppDomain.GetAssemblies() a bunch internally, and we don't want to intercept those calls UNLESS they originated from Resonite code. + if (assembly != null && !dotNetAssemblies!.Contains(assembly)) { + return assembly; + } + } + return null; + } + + private static bool LooksLikeDotNetAssembly(Assembly assembly) { + // check the assembly's company + string? company = assembly.GetCustomAttribute()?.Company; + if (company != null && knownDotNetCompanies.Contains(company.ToLower())) { + return true; + } + + // check the assembly's product + string? product = assembly.GetCustomAttribute()?.Product; + if (product != null && knownDotNetProducts.Contains(product.ToLower())) { + return true; + } + + // nothing matched, this is probably not part of .NET + return false; + } +} diff --git a/ResoniteModLoader/AssemblyLoader.cs b/ResoniteModLoader/AssemblyLoader.cs new file mode 100644 index 0000000..5894cce --- /dev/null +++ b/ResoniteModLoader/AssemblyLoader.cs @@ -0,0 +1,65 @@ +namespace ResoniteModLoader; + +internal static class AssemblyLoader { + private static string[]? GetAssemblyPathsFromDir(string dirName) { + + string assembliesDirectory = Path.Combine(Directory.GetCurrentDirectory(), dirName); + + Logger.MsgInternal($"Loading assemblies from {dirName}"); + + string[]? assembliesToLoad = null; + try { + // Directory.GetFiles and Directory.EnumerateFiles have a fucked up API: https://learn.microsoft.com/en-us/dotnet/api/system.io.directory.getfiles?view=netframework-4.6.2#system-io-directory-getfiles(system-string-system-string-system-io-searchoption) + // long story short if I searched for "*.dll" it would unhelpfully use some incredibly inconsistent behavior and return results like "foo.dll_disabled" + // So I have to filter shit after the fact... ugh + + assembliesToLoad = Directory.EnumerateFiles(assembliesDirectory, "*.dll") + .Where(file => file.EndsWith(".dll", StringComparison.InvariantCultureIgnoreCase)) + .ToArray(); + Array.Sort(assembliesToLoad, string.CompareOrdinal); + } catch (DirectoryNotFoundException) { + Logger.MsgInternal($"{dirName} directory not found, creating it now."); + try { + Directory.CreateDirectory(assembliesDirectory); + } catch (Exception e2) { + Logger.ErrorInternal($"Error creating ${dirName} directory:\n{e2}"); + } + } catch (Exception e) { + Logger.ErrorInternal($"Error enumerating ${dirName} directory:\n{e}"); + } + return assembliesToLoad; + } + + private static Assembly? LoadAssembly(string filepath) { + string filename = Path.GetFileName(filepath); + Assembly assembly; + try { + Logger.DebugFuncInternal(() => $"load assembly {filename}"); + assembly = Assembly.LoadFrom(filepath); + } catch (Exception e) { + Logger.ErrorInternal($"Error loading assembly from {filepath}: {e}"); + return null; + } + if (assembly == null) { + Logger.ErrorInternal($"Unexpected null loading assembly from {filepath}"); + return null; + } + return assembly; + } + + internal static AssemblyFile[] LoadAssembliesFromDir(string dirName) { + List assemblyFiles = new(); + if (GetAssemblyPathsFromDir(dirName) is string[] assemblyPaths) { + foreach (string assemblyFilepath in assemblyPaths) { + try { + if (LoadAssembly(assemblyFilepath) is Assembly assembly) { + assemblyFiles.Add(new AssemblyFile(assemblyFilepath, assembly)); + } + } catch (Exception e) { + Logger.ErrorInternal($"Unexpected exception loading assembly from {assemblyFilepath}:\n{e}"); + } + } + } + return assemblyFiles.ToArray(); + } +} diff --git a/ResoniteModLoader/Attributes/AutoRegisterConfigKeyAttribute.cs b/ResoniteModLoader/Attributes/AutoRegisterConfigKeyAttribute.cs new file mode 100644 index 0000000..3a82b28 --- /dev/null +++ b/ResoniteModLoader/Attributes/AutoRegisterConfigKeyAttribute.cs @@ -0,0 +1,7 @@ +namespace ResoniteModLoader; +/// +/// Marks a field of type on a class +/// deriving from to be automatically included in that mod's configuration. +/// +[AttributeUsage(AttributeTargets.Field)] +public sealed class AutoRegisterConfigKeyAttribute : Attribute { } diff --git a/ResoniteModLoader/ConfigurationChangedEvent.cs b/ResoniteModLoader/ConfigurationChangedEvent.cs new file mode 100644 index 0000000..e6be84e --- /dev/null +++ b/ResoniteModLoader/ConfigurationChangedEvent.cs @@ -0,0 +1,26 @@ +namespace ResoniteModLoader; +/// +/// Represents the data for the and events. +/// +public class ConfigurationChangedEvent { + /// + /// The in which the change occured. + /// + public ModConfiguration Config { get; private set; } + + /// + /// The specific who's value changed. + /// + public ModConfigurationKey Key { get; private set; } + + /// + /// A custom label that may be set by whoever changed the configuration. + /// + public string? Label { get; private set; } + + internal ConfigurationChangedEvent(ModConfiguration config, ModConfigurationKey key, string? label) { + Config = config; + Key = key; + Label = label; + } +} diff --git a/ResoniteModLoader/DebugInfo.cs b/ResoniteModLoader/DebugInfo.cs new file mode 100644 index 0000000..117ae9b --- /dev/null +++ b/ResoniteModLoader/DebugInfo.cs @@ -0,0 +1,20 @@ +using System.Runtime.Versioning; + +namespace ResoniteModLoader; + +internal class DebugInfo { + internal static void Log() { + Logger.MsgInternal($"ResoniteModLoader v{ModLoader.VERSION} starting up!{(ModLoaderConfiguration.Get().Debug ? " Debug logs will be shown." : "")}"); + Logger.MsgInternal($"CLR v{Environment.Version}"); + Logger.DebugFuncInternal(() => $"Using .NET Framework: \"{AppDomain.CurrentDomain.SetupInformation.TargetFrameworkName}\""); + Logger.DebugFuncInternal(() => $"Using .NET Core: \"{Assembly.GetEntryAssembly()?.GetCustomAttribute()?.FrameworkName}\""); + Logger.MsgInternal($"Using Harmony v{GetAssemblyVersion(typeof(HarmonyLib.Harmony))}"); + Logger.MsgInternal($"Using Elements.Core v{GetAssemblyVersion(typeof(Elements.Core.floatQ))}"); + Logger.MsgInternal($"Using FrooxEngine v{GetAssemblyVersion(typeof(FrooxEngine.IComponent))}"); + Logger.MsgInternal($"Using Json.NET v{GetAssemblyVersion(typeof(Newtonsoft.Json.JsonSerializer))}"); + } + + private static string? GetAssemblyVersion(Type typeFromAssembly) { + return typeFromAssembly.Assembly.GetName()?.Version?.ToString(); + } +} diff --git a/ResoniteModLoader/DelegateExtensions.cs b/ResoniteModLoader/DelegateExtensions.cs new file mode 100644 index 0000000..19f2e60 --- /dev/null +++ b/ResoniteModLoader/DelegateExtensions.cs @@ -0,0 +1,19 @@ +namespace ResoniteModLoader; + +internal static class DelegateExtensions { + internal static void SafeInvoke(this Delegate del, params object[] args) { + var exceptions = new List(); + + foreach (var handler in del.GetInvocationList()) { + try { + handler.Method.Invoke(handler.Target, args); + } catch (Exception ex) { + exceptions.Add(ex); + } + } + + if (exceptions.Any()) { + throw new AggregateException(exceptions); + } + } +} diff --git a/ResoniteModLoader/ExecutionHook.cs b/ResoniteModLoader/ExecutionHook.cs new file mode 100644 index 0000000..af5f22d --- /dev/null +++ b/ResoniteModLoader/ExecutionHook.cs @@ -0,0 +1,48 @@ +using FrooxEngine; + +namespace ResoniteModLoader; + +[ImplementableClass(true)] +internal class ExecutionHook { +#pragma warning disable CS0169, IDE0051, CA1823 + // fields must exist due to reflective access + private static Type? __connectorType; + private static Type? __connectorTypes; + + // implementation not strictly required, but method must exist due to reflective access + private static DummyConnector InstantiateConnector() { + return new DummyConnector(); + } +#pragma warning restore CS0169, IDE0051, CA1823 + + static ExecutionHook() { + try { + HashSet initialAssemblies = AppDomain.CurrentDomain.GetAssemblies().ToHashSet(); + + AssemblyFile[] loadedAssemblies = AssemblyLoader.LoadAssembliesFromDir("rml_libs"); + // note that harmony may not be loaded until this point, so this class cannot directly import HarmonyLib. + + if (loadedAssemblies.Length != 0) { + string loadedAssemblyList = string.Join("\n", loadedAssemblies.Select(a => a.Assembly.FullName + " Sha256=" + a.Sha256)); + Logger.MsgInternal($"Loaded libraries from rml_libs:\n{loadedAssemblyList}"); + } + DebugInfo.Log(); + VersionReset.Initialize(); + HarmonyWorker.LoadModsAndHideModAssemblies(initialAssemblies); + } catch (Exception e) { + // it's important that this doesn't send exceptions back to Resonite + Logger.ErrorInternal($"Exception in execution hook!\n{e}"); + } + } + + + // type must match return type of InstantiateConnector() + private sealed class DummyConnector : IConnector { + public IImplementable? Owner { get; private set; } + public void ApplyChanges() { } + public void AssignOwner(IImplementable owner) => Owner = owner; + public void Destroy(bool destroyingWorld) { } + public void Initialize() { } + public void RemoveOwner() => Owner = null; + } +} diff --git a/ResoniteModLoader/GlobalDirectives.cs b/ResoniteModLoader/GlobalDirectives.cs new file mode 100644 index 0000000..0ade333 --- /dev/null +++ b/ResoniteModLoader/GlobalDirectives.cs @@ -0,0 +1,13 @@ +/* +// auto-generated Implicit Using Directives +global using global::System; +global using global::System.Collections.Generic; +global using global::System.IO; +global using global::System.Linq; +global using global::System.Threading; +global using global::System.Threading.Tasks; + +//System.Net.Http is a default implicit. While we are still on 4.6.2, it needs to be manually removed via csproj `` +*/ +global using System.Text; +global using System.Reflection; diff --git a/ResoniteModLoader/HarmonyWorker.cs b/ResoniteModLoader/HarmonyWorker.cs new file mode 100644 index 0000000..8300d88 --- /dev/null +++ b/ResoniteModLoader/HarmonyWorker.cs @@ -0,0 +1,12 @@ +using HarmonyLib; + +namespace ResoniteModLoader; +// this class does all the harmony-related RML work. +// this is needed to avoid importing harmony in ExecutionHook, where it may not be loaded yet. +internal class HarmonyWorker { + internal static void LoadModsAndHideModAssemblies(HashSet initialAssemblies) { + Harmony harmony = new("com.resonitemodloader"); + ModLoader.LoadMods(harmony); + AssemblyHider.PatchResonite(harmony, initialAssemblies); + } +} diff --git a/ResoniteModLoader/JsonConverters/EnumConverter.cs b/ResoniteModLoader/JsonConverters/EnumConverter.cs new file mode 100644 index 0000000..187ccf8 --- /dev/null +++ b/ResoniteModLoader/JsonConverters/EnumConverter.cs @@ -0,0 +1,41 @@ +using Newtonsoft.Json; + +namespace ResoniteModLoader.JsonConverters; + +// serializes and deserializes enums as strings +internal sealed class EnumConverter : JsonConverter { + public override bool CanConvert(Type objectType) { + return objectType.IsEnum; + } + + public override object ReadJson(JsonReader reader, Type objectType, object? existingValue, JsonSerializer serializer) { + // handle old behavior where enums were serialized as underlying type + Type underlyingType = Enum.GetUnderlyingType(objectType); + if (TryConvert(reader!.Value!, underlyingType, out object? deserialized)) { + Logger.DebugFuncInternal(() => $"Deserializing a Core Element type: {objectType} from a {reader!.Value!.GetType()}"); + return deserialized!; + } + + // handle new behavior where enums are serialized as strings + if (reader.Value is string serialized) { + return Enum.Parse(objectType, serialized); + } + + throw new ArgumentException($"Could not deserialize a Core Element type: {objectType} from a {reader?.Value?.GetType()}. Expected underlying type was {underlyingType}"); + } + + public override void WriteJson(JsonWriter writer, object? value, JsonSerializer serializer) { + string serialized = Enum.GetName(value!.GetType(), value); + writer.WriteValue(serialized); + } + + private bool TryConvert(object value, Type newType, out object? converted) { + try { + converted = Convert.ChangeType(value, newType); + return true; + } catch { + converted = null; + return false; + } + } +} diff --git a/ResoniteModLoader/JsonConverters/ResonitePrimitiveConverter.cs b/ResoniteModLoader/JsonConverters/ResonitePrimitiveConverter.cs new file mode 100644 index 0000000..83140bb --- /dev/null +++ b/ResoniteModLoader/JsonConverters/ResonitePrimitiveConverter.cs @@ -0,0 +1,28 @@ +using Elements.Core; + +using Newtonsoft.Json; + +namespace ResoniteModLoader.JsonConverters; + +internal sealed class ResonitePrimitiveConverter : JsonConverter { + private static readonly Assembly ElementsCore = typeof(floatQ).Assembly; + + public override bool CanConvert(Type objectType) { + // handle all non-enum Resonite Primitives in the Elements.Core assembly + return !objectType.IsEnum && ElementsCore.Equals(objectType.Assembly) && Coder.IsEnginePrimitive(objectType); + } + + public override object ReadJson(JsonReader reader, Type objectType, object? existingValue, JsonSerializer serializer) { + if (reader.Value is string serialized) { + // use Resonite's built-in decoding if the value was serialized as a string + return typeof(Coder<>).MakeGenericType(objectType).GetMethod("DecodeFromString").Invoke(null, new object[] { serialized }); + } + + throw new ArgumentException($"Could not deserialize a Core Element type: {objectType} from a {reader?.Value?.GetType()}"); + } + + public override void WriteJson(JsonWriter writer, object? value, JsonSerializer serializer) { + string serialized = (string)typeof(Coder<>).MakeGenericType(value!.GetType()).GetMethod("EncodeToString").Invoke(null, new object[] { value }); + writer.WriteValue(serialized); + } +} diff --git a/ResoniteModLoader/LoadedResoniteMod.cs b/ResoniteModLoader/LoadedResoniteMod.cs new file mode 100644 index 0000000..1104149 --- /dev/null +++ b/ResoniteModLoader/LoadedResoniteMod.cs @@ -0,0 +1,15 @@ +namespace ResoniteModLoader; + +internal sealed class LoadedResoniteMod { + internal LoadedResoniteMod(ResoniteMod resoniteMod, AssemblyFile modAssembly) { + ResoniteMod = resoniteMod; + ModAssembly = modAssembly; + } + + internal ResoniteMod ResoniteMod { get; private set; } + internal AssemblyFile ModAssembly { get; private set; } + internal ModConfiguration? ModConfiguration { get; set; } + internal bool AllowSavingConfiguration = true; + internal bool FinishedLoading { get => ResoniteMod.FinishedLoading; set => ResoniteMod.FinishedLoading = value; } + internal string Name => ResoniteMod.Name; +} diff --git a/ResoniteModLoader/Logger.cs b/ResoniteModLoader/Logger.cs new file mode 100644 index 0000000..9632c27 --- /dev/null +++ b/ResoniteModLoader/Logger.cs @@ -0,0 +1,85 @@ +using Elements.Core; + +using System.Diagnostics; + +namespace ResoniteModLoader; + +internal class Logger { + // logged for null objects + internal const string NULL_STRING = "null"; + + internal static bool IsDebugEnabled() { + return ModLoaderConfiguration.Get().Debug; + } + + internal static void DebugFuncInternal(Func messageProducer) { + if (IsDebugEnabled()) { + LogInternal(LogType.DEBUG, messageProducer()); + } + } + + internal static void DebugFuncExternal(Func messageProducer) { + if (IsDebugEnabled()) { + LogInternal(LogType.DEBUG, messageProducer(), SourceFromStackTrace(new(1))); + } + } + + internal static void DebugInternal(string message) { + if (IsDebugEnabled()) { + LogInternal(LogType.DEBUG, message); + } + } + + internal static void DebugExternal(object message) { + if (IsDebugEnabled()) { + LogInternal(LogType.DEBUG, message, SourceFromStackTrace(new(1))); + } + } + + internal static void DebugListExternal(object[] messages) { + if (IsDebugEnabled()) { + LogListInternal(LogType.DEBUG, messages, SourceFromStackTrace(new(1))); + } + } + + internal static void MsgInternal(string message) => LogInternal(LogType.INFO, message); + internal static void MsgExternal(object message) => LogInternal(LogType.INFO, message, SourceFromStackTrace(new(1))); + internal static void MsgListExternal(object[] messages) => LogListInternal(LogType.INFO, messages, SourceFromStackTrace(new(1))); + internal static void WarnInternal(string message) => LogInternal(LogType.WARN, message); + internal static void WarnExternal(object message) => LogInternal(LogType.WARN, message, SourceFromStackTrace(new(1))); + internal static void WarnListExternal(object[] messages) => LogListInternal(LogType.WARN, messages, SourceFromStackTrace(new(1))); + internal static void ErrorInternal(string message) => LogInternal(LogType.ERROR, message); + internal static void ErrorExternal(object message) => LogInternal(LogType.ERROR, message, SourceFromStackTrace(new(1))); + internal static void ErrorListExternal(object[] messages) => LogListInternal(LogType.ERROR, messages, SourceFromStackTrace(new(1))); + + private static void LogInternal(string logTypePrefix, object message, string? source = null) { + message ??= NULL_STRING; + if (source == null) { + UniLog.Log($"{logTypePrefix}[ResoniteModLoader] {message}"); + } else { + UniLog.Log($"{logTypePrefix}[ResoniteModLoader/{source}] {message}"); + } + } + + private static void LogListInternal(string logTypePrefix, object[] messages, string? source) { + if (messages == null) { + LogInternal(logTypePrefix, NULL_STRING, source); + } else { + foreach (object element in messages) { + LogInternal(logTypePrefix, element.ToString(), source); + } + } + } + + private static string? SourceFromStackTrace(StackTrace stackTrace) { + // MsgExternal() and Msg() are above us in the stack + return Util.ExecutingMod(stackTrace)?.Name; + } + + private static class LogType { + internal const string DEBUG = "[DEBUG]"; + internal const string INFO = "[INFO] "; + internal const string WARN = "[WARN] "; + internal const string ERROR = "[ERROR]"; + } +} diff --git a/ResoniteModLoader/ModConfiguration.cs b/ResoniteModLoader/ModConfiguration.cs new file mode 100644 index 0000000..abe87a2 --- /dev/null +++ b/ResoniteModLoader/ModConfiguration.cs @@ -0,0 +1,618 @@ +using System.Diagnostics; + +using FrooxEngine; + +using HarmonyLib; + +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +using ResoniteModLoader.JsonConverters; + +namespace ResoniteModLoader; + +/// +/// Represents an interface for mod configurations. +/// +public interface IModConfigurationDefinition { + /// + /// Gets the mod that owns this configuration definition. + /// + ResoniteModBase Owner { get; } + + /// + /// Gets the semantic version for this configuration definition. This is used to check if the defined and saved configs are compatible. + /// + Version Version { get; } + + /// + /// Gets the set of configuration keys defined in this configuration definition. + /// + ISet ConfigurationItemDefinitions { get; } +} + +/// +/// Defines a mod configuration. This should be defined by a using the method. +/// +public class ModConfigurationDefinition : IModConfigurationDefinition { + /// + public ResoniteModBase Owner { get; private set; } + + /// + public Version Version { get; private set; } + + internal bool AutoSave; + + // this is a ridiculous hack because HashSet.TryGetValue doesn't exist in .NET 4.6.2 + private Dictionary configurationItemDefinitionsSelfMap; + + /// + public ISet ConfigurationItemDefinitions { + // clone the collection because I don't trust giving public API users shallow copies one bit + get => new HashSet(configurationItemDefinitionsSelfMap.Keys); + } + + internal bool TryGetDefiningKey(ModConfigurationKey key, out ModConfigurationKey? definingKey) { + if (key.DefiningKey != null) { + // we've already cached the defining key + definingKey = key.DefiningKey; + return true; + } + + // first time we've seen this key instance: we need to hit the map + if (configurationItemDefinitionsSelfMap.TryGetValue(key, out definingKey)) { + // initialize the cache for this key + key.DefiningKey = definingKey; + return true; + } else { + // not a real key + definingKey = null; + return false; + } + + } + + internal ModConfigurationDefinition(ResoniteModBase owner, Version version, HashSet configurationItemDefinitions, bool autoSave) { + Owner = owner; + Version = version; + AutoSave = autoSave; + + configurationItemDefinitionsSelfMap = new Dictionary(configurationItemDefinitions.Count); + foreach (ModConfigurationKey key in configurationItemDefinitions) { + key.DefiningKey = key; // early init this property for the defining key itself + configurationItemDefinitionsSelfMap.Add(key, key); + } + } +} + +/// +/// The configuration for a mod. Each mod has zero or one configuration. The configuration object will never be reassigned once initialized. +/// +public class ModConfiguration : IModConfigurationDefinition { + private readonly ModConfigurationDefinition Definition; + internal LoadedResoniteMod LoadedResoniteMod { get; private set; } + + private static readonly string ConfigDirectory = Path.Combine(Directory.GetCurrentDirectory(), "rml_config"); + private static readonly string VERSION_JSON_KEY = "version"; + private static readonly string VALUES_JSON_KEY = "values"; + + /// + public ResoniteModBase Owner => Definition.Owner; + + /// + public Version Version => Definition.Version; + + /// + public ISet ConfigurationItemDefinitions => Definition.ConfigurationItemDefinitions; + + private bool AutoSave => Definition.AutoSave; + + /// + /// The delegate that is called for configuration change events. + /// + /// The event containing details about the configuration change + public delegate void ConfigurationChangedHandler(ConfigurationChangedEvent configurationChangedEvent); + + /// + /// Called if any config value for any mod changed. + /// + public static event ConfigurationChangedHandler? OnAnyConfigurationChanged; + + /// + /// Called if one of the values in this mod's config changed. + /// + public event ConfigurationChangedHandler? OnThisConfigurationChanged; + + // used to track how frequenly Save() is being called + private Stopwatch saveTimer = new(); + + // time that save must not be called for a save to actually go through + private int debounceMilliseconds = 3000; + + // used to keep track of mods that spam Save(): + // any mod that calls Save() for the ModConfiguration within debounceMilliseconds of the previous call to the same ModConfiguration + // will be put into Ultimate Punishment Mode, and ALL their Save() calls, regardless of ModConfiguration, will be debounced. + // The naughty list is global, while the actual debouncing is per-configuration. + private static ISet naughtySavers = new HashSet(); + + // used to keep track of the debouncers for this configuration. + private Dictionary> saveActionForCallee = new(); + + private static readonly JsonSerializer jsonSerializer = CreateJsonSerializer(); + + private static JsonSerializer CreateJsonSerializer() { + JsonSerializerSettings settings = new() { + MaxDepth = 32, + ReferenceLoopHandling = ReferenceLoopHandling.Error, + DefaultValueHandling = DefaultValueHandling.IgnoreAndPopulate, + Formatting = Formatting.Indented + }; + List converters = new(); + IList defaultConverters = settings.Converters; + if (defaultConverters != null && defaultConverters.Count != 0) { + Logger.DebugFuncInternal(() => $"Using {defaultConverters.Count} default json converters"); + converters.AddRange(defaultConverters); + } + converters.Add(new EnumConverter()); + converters.Add(new ResonitePrimitiveConverter()); + settings.Converters = converters; + return JsonSerializer.Create(settings); + } + + private ModConfiguration(LoadedResoniteMod loadedResoniteMod, ModConfigurationDefinition definition) { + LoadedResoniteMod = loadedResoniteMod; + Definition = definition; + } + + internal static void EnsureDirectoryExists() { + Directory.CreateDirectory(ConfigDirectory); + } + + private static string GetModConfigPath(LoadedResoniteMod mod) { + + string filename = Path.ChangeExtension(Path.GetFileName(mod.ModAssembly.File), ".json"); + return Path.Combine(ConfigDirectory, filename); + } + + private static bool AreVersionsCompatible(Version serializedVersion, Version currentVersion) { + if (serializedVersion.Major != currentVersion.Major) { + // major version differences are hard incompatible + return false; + } + + if (serializedVersion.Minor > currentVersion.Minor) { + // if serialized config has a newer minor version than us + // in other words, someone downgraded the mod but not the config + // then we cannot load the config + return false; + } + + // none of the checks failed! + return true; + } + + /// + /// Checks if the given key is defined in this config. + /// + /// The key to check. + /// true if the key is defined. + public bool IsKeyDefined(ModConfigurationKey key) { + // if a key has a non-null defining key it's guaranteed a real key. Lets check for that. + ModConfigurationKey? definingKey = key.DefiningKey; + if (definingKey != null) { + return true; + } + + // okay, the defining key was null, so lets try to get the defining key from the hashtable instead + if (Definition.TryGetDefiningKey(key, out definingKey)) { + // we might as well set this now that we have the real defining key + key.DefiningKey = definingKey; + return true; + } + + // there was no definition + return false; + } + + /// + /// Checks if the given key is the defining key. + /// + /// The key to check. + /// true if the key is the defining key. + internal bool IsKeyDefiningKey(ModConfigurationKey key) { + // a key is the defining key if and only if its DefiningKey property references itself + return ReferenceEquals(key, key.DefiningKey); // this is safe because we'll throw a NRE if key is null + } + + /// + /// Get a value, throwing a if the key is not found. + /// + /// The key to get the value for. + /// The value for the key. + /// The given key does not exist in the configuration. + public object GetValue(ModConfigurationKey key) { + if (TryGetValue(key, out object? value)) { + return value!; + } else { + throw new KeyNotFoundException($"{key.Name} not found in {LoadedResoniteMod.ResoniteMod.Name} configuration"); + } + } + + /// + /// Get a value, throwing a if the key is not found. + /// + /// The type of the key's value. + /// The key to get the value for. + /// The value for the key. + /// The given key does not exist in the configuration. + public T? GetValue(ModConfigurationKey key) { + if (TryGetValue(key, out T? value)) { + return value; + } else { + throw new KeyNotFoundException($"{key.Name} not found in {LoadedResoniteMod.ResoniteMod.Name} configuration"); + } + } + + /// + /// Tries to get a value, returning default if the key is not found. + /// + /// The key to get the value for. + /// The value if the return value is true, or default if false. + /// true if the value was read successfully. + public bool TryGetValue(ModConfigurationKey key, out object? value) { + if (!Definition.TryGetDefiningKey(key, out ModConfigurationKey? definingKey)) { + // not in definition + value = null; + return false; + } + + if (definingKey!.TryGetValue(out object? valueObject)) { + value = valueObject; + return true; + } else if (definingKey.TryComputeDefault(out value)) { + return true; + } else { + value = null; + return false; + } + } + + + /// + /// Tries to get a value, returning default() if the key is not found. + /// + /// The key to get the value for. + /// The value if the return value is true, or default if false. + /// true if the value was read successfully. + public bool TryGetValue(ModConfigurationKey key, out T? value) { + if (TryGetValue(key, out object? valueObject)) { + value = (T)valueObject!; + return true; + } else { + value = default; + return false; + } + } + + /// + /// Sets a configuration value for the given key, throwing a if the key is not found + /// or an if the value is not valid for it. + /// + /// The key to get the value for. + /// The new value to set. + /// A custom label you may assign to this change event. + /// The given key does not exist in the configuration. + /// The new value is not valid for the given key. + public void Set(ModConfigurationKey key, object? value, string? eventLabel = null) { + if (!Definition.TryGetDefiningKey(key, out ModConfigurationKey? definingKey)) { + throw new KeyNotFoundException($"{key.Name} is not defined in the config definition for {LoadedResoniteMod.ResoniteMod.Name}"); + } + + if (value == null) { + if (Util.CannotBeNull(definingKey!.ValueType())) { + throw new ArgumentException($"null cannot be assigned to {definingKey.ValueType()}"); + } + } else if (!definingKey!.ValueType().IsAssignableFrom(value.GetType())) { + throw new ArgumentException($"{value.GetType()} cannot be assigned to {definingKey.ValueType()}"); + } + + if (!definingKey!.Validate(value)) { + throw new ArgumentException($"\"{value}\" is not a valid value for \"{Owner.Name}{definingKey.Name}\""); + } + + definingKey.Set(value); + FireConfigurationChangedEvent(definingKey, eventLabel); + } + + /// + /// Sets a configuration value for the given key, throwing a if the key is not found + /// or an if the value is not valid for it. + /// + /// The type of the key's value. + /// The key to get the value for. + /// The new value to set. + /// A custom label you may assign to this change event. + /// The given key does not exist in the configuration. + /// The new value is not valid for the given key. + public void Set(ModConfigurationKey key, T value, string? eventLabel = null) { + // the reason we don't fall back to untyped Set() here is so we can skip the type check + + if (!Definition.TryGetDefiningKey(key, out ModConfigurationKey? definingKey)) { + throw new KeyNotFoundException($"{key.Name} is not defined in the config definition for {LoadedResoniteMod.ResoniteMod.Name}"); + } + + if (!definingKey!.Validate(value)) { + throw new ArgumentException($"\"{value}\" is not a valid value for \"{Owner.Name}{definingKey.Name}\""); + } + + definingKey.Set(value); + FireConfigurationChangedEvent(definingKey, eventLabel); + } + + /// + /// Removes a configuration value, throwing a if the key is not found. + /// + /// The key to remove the value for. + /// true if a value was successfully found and removed, false if there was no value to remove. + /// The given key does not exist in the configuration. + public bool Unset(ModConfigurationKey key) { + if (Definition.TryGetDefiningKey(key, out ModConfigurationKey? definingKey)) { + return definingKey!.Unset(); + } else { + throw new KeyNotFoundException($"{key.Name} is not defined in the config definition for {LoadedResoniteMod.ResoniteMod.Name}"); + } + } + + private bool AnyValuesSet() { + return ConfigurationItemDefinitions + .Where(key => key.HasValue) + .Any(); + } + + internal static ModConfiguration? LoadConfigForMod(LoadedResoniteMod mod) { + ModConfigurationDefinition? definition = mod.ResoniteMod.BuildConfigurationDefinition(); + if (definition == null) { + // if there's no definition, then there's nothing for us to do here + return null; + } + + string configFile = GetModConfigPath(mod); + + try { + using StreamReader file = File.OpenText(configFile); + using JsonTextReader reader = new(file); + JObject json = JObject.Load(reader); + Version version = new(json[VERSION_JSON_KEY]!.ToObject(jsonSerializer)); + if (!AreVersionsCompatible(version, definition.Version)) { + var handlingMode = mod.ResoniteMod.HandleIncompatibleConfigurationVersions(definition.Version, version); + switch (handlingMode) { + case IncompatibleConfigurationHandlingOption.CLOBBER: + Logger.WarnInternal($"{mod.ResoniteMod.Name} saved config version is {version} which is incompatible with mod's definition version {definition.Version}. Clobbering old config and starting fresh."); + return new ModConfiguration(mod, definition); + case IncompatibleConfigurationHandlingOption.FORCE_LOAD: + // continue processing + break; + case IncompatibleConfigurationHandlingOption.ERROR: // fall through to default + default: + mod.AllowSavingConfiguration = false; + throw new ModConfigurationException($"{mod.ResoniteMod.Name} saved config version is {version} which is incompatible with mod's definition version {definition.Version}"); + } + } + foreach (ModConfigurationKey key in definition.ConfigurationItemDefinitions) { + string keyName = key.Name; + try { + JToken? token = json[VALUES_JSON_KEY]?[keyName]; + if (token != null) { + object? value = token.ToObject(key.ValueType(), jsonSerializer); + key.Set(value); + } + } catch (Exception e) { + // I know not what exceptions the JSON library will throw, but they must be contained + mod.AllowSavingConfiguration = false; + throw new ModConfigurationException($"Error loading {key.ValueType()} config key \"{keyName}\" for {mod.ResoniteMod.Name}", e); + } + } + } catch (FileNotFoundException) { + // return early and create a new config + return new ModConfiguration(mod, definition); + } catch (Exception e) { + // I know not what exceptions the JSON library will throw, but they must be contained + mod.AllowSavingConfiguration = true; + var backupPath = configFile + "." + Convert.ToBase64String(Encoding.UTF8.GetBytes(((int)DateTimeOffset.Now.TimeOfDay.TotalSeconds).ToString("X"))) + ".bak"; //ExampleMod.json.40A4.bak, unlikely to already exist + Logger.ErrorExternal($"Error loading config for {mod.ResoniteMod.Name}, creating new config file (old file can be found at {backupPath}). Exception:\n{e}"); + File.Move(configFile, backupPath); + + //mod.AllowSavingConfiguration = false; + //throw new ModConfigurationException($"Error loading config for {mod.ResoniteMod.Name}", e); + } + + return new ModConfiguration(mod, definition); + } + + /// + /// Persist this configuration to disk.
+ /// This method is not called automatically. + ///
+ /// If true, default values will also be persisted. + /// + /// Saving too often may result in save calls being debounced, with only the latest save call being used after a delay. + /// + public void Save(bool saveDefaultValues = false) { + SaveQueue(saveDefaultValues, false); + } + + /// + /// Asynchronously persists this configuration to disk. + /// + /// If true, default values will also be persisted. + /// If true, skip the debouncing and save immediately. + /// + /// immediate isn't used anywhere nor exposed outside of internal, mods shouldn't be bypassing debounce. + /// + internal void SaveQueue(bool saveDefaultValues = false, bool immediate = false) { + Thread thread = Thread.CurrentThread; + ResoniteMod? callee = Util.ExecutingMod(new(1)); + Action? saveAction = null; + + // get saved state for this callee + if (callee != null && naughtySavers.Contains(callee.Name) && !saveActionForCallee.TryGetValue(callee.Name, out saveAction)) { + // handle case where the callee was marked as naughty from a different ModConfiguration being spammed + saveAction = Util.Debounce(SaveInternal, debounceMilliseconds); + saveActionForCallee.Add(callee.Name, saveAction); + } + + if (saveTimer.IsRunning) { + float elapsedMillis = saveTimer.ElapsedMilliseconds; + saveTimer.Restart(); + if (elapsedMillis < debounceMilliseconds) { + Logger.WarnInternal($"ModConfiguration.Save({saveDefaultValues}) called for \"{LoadedResoniteMod.ResoniteMod.Name}\" by \"{callee?.Name}\" from thread with id=\"{thread.ManagedThreadId}\", name=\"{thread.Name}\", bg=\"{thread.IsBackground}\", pool=\"{thread.IsThreadPoolThread}\". Last called {elapsedMillis / 1000f}s ago. This is very recent! Do not spam calls to ModConfiguration.Save()! All Save() calls by this mod are now subject to a {debounceMilliseconds}ms debouncing delay."); + if (saveAction == null && callee != null) { + // congrats, you've switched into Ultimate Punishment Mode where now I don't trust you and your Save() calls get debounced + saveAction = Util.Debounce(SaveInternal, debounceMilliseconds); + saveActionForCallee.Add(callee.Name, saveAction); + naughtySavers.Add(callee.Name); + } + } else { + Logger.DebugFuncInternal(() => $"ModConfiguration.Save({saveDefaultValues}) called for \"{LoadedResoniteMod.ResoniteMod.Name}\" by \"{callee?.Name}\" from thread with id=\"{thread.ManagedThreadId}\", name=\"{thread.Name}\", bg=\"{thread.IsBackground}\", pool=\"{thread.IsThreadPoolThread}\". Last called {elapsedMillis / 1000f}s ago."); + } + } else { + saveTimer.Start(); + Logger.DebugFuncInternal(() => $"ModConfiguration.Save({saveDefaultValues}) called for \"{LoadedResoniteMod.ResoniteMod.Name}\" by \"{callee?.Name}\" from thread with id=\"{thread.ManagedThreadId}\", name=\"{thread.Name}\", bg=\"{thread.IsBackground}\", pool=\"{thread.IsThreadPoolThread}\""); + } + + // prevent saving if we've determined something is amiss with the configuration + if (!LoadedResoniteMod.AllowSavingConfiguration) { + Logger.WarnInternal($"ModConfiguration for {LoadedResoniteMod.ResoniteMod.Name} will NOT be saved due to a safety check failing. This is probably due to you downgrading a mod."); + return; + } + + if (immediate || saveAction == null) { + // infrequent callers get to save immediately + Task.Run(() => SaveInternal(saveDefaultValues)); + } else { + // bad callers get debounced + saveAction(saveDefaultValues); + } + } + + /// + /// performs the actual, synchronous save + /// + /// If true, default values will also be persisted + private void SaveInternal(bool saveDefaultValues = false) { + Stopwatch stopwatch = Stopwatch.StartNew(); + JObject json = new() { + [VERSION_JSON_KEY] = JToken.FromObject(Definition.Version.ToString(), jsonSerializer) + }; + + JObject valueMap = new(); + foreach (ModConfigurationKey key in ConfigurationItemDefinitions) { + if (key.TryGetValue(out object? value)) { + // I don't need to typecheck this as there's no way to sneak a bad type past my Set() API + valueMap[key.Name] = value == null ? null : JToken.FromObject(value, jsonSerializer); + } else if (saveDefaultValues && key.TryComputeDefault(out object? defaultValue)) { + // I don't need to typecheck this as there's no way to sneak a bad type past my computeDefault API + // like and say defaultValue can't be null because the Json.Net + valueMap[key.Name] = defaultValue == null ? null : JToken.FromObject(defaultValue, jsonSerializer); + } + } + + json[VALUES_JSON_KEY] = valueMap; + + string configFile = GetModConfigPath(LoadedResoniteMod); + + /* + using FileStream file = File.OpenWrite(configFile); + using StreamWriter streamWriter = new(file); + using JsonTextWriter jsonTextWriter = new(streamWriter); + json.WriteTo(jsonTextWriter); + + // I actually cannot believe I have to truncate the file myself + file.SetLength(file.Position); + jsonTextWriter.Flush();*/ + + File.WriteAllText(configFile, json.ToString()); + + Logger.DebugFuncInternal(() => $"Saved ModConfiguration for \"{LoadedResoniteMod.ResoniteMod.Name}\" in {stopwatch.ElapsedMilliseconds}ms"); + } + + private void FireConfigurationChangedEvent(ModConfigurationKey key, string? label) { + try { + OnAnyConfigurationChanged?.SafeInvoke(new ConfigurationChangedEvent(this, key, label)); + } catch (Exception e) { + Logger.ErrorInternal($"An OnAnyConfigurationChanged event subscriber threw an exception:\n{e}"); + } + + try { + OnThisConfigurationChanged?.SafeInvoke(new ConfigurationChangedEvent(this, key, label)); + } catch (Exception e) { + Logger.ErrorInternal($"An OnThisConfigurationChanged event subscriber threw an exception:\n{e}"); + } + } + + internal static void RegisterShutdownHook(Harmony harmony) { + try { + MethodInfo shutdown = AccessTools.DeclaredMethod(typeof(Engine), nameof(Engine.RequestShutdown)); + if (shutdown == null) { + Logger.ErrorInternal("Could not find method Engine.RequestShutdown(). Will not be able to autosave configs on close!"); + return; + } + MethodInfo patch = AccessTools.DeclaredMethod(typeof(ModConfiguration), nameof(ShutdownHook)); + if (patch == null) { + Logger.ErrorInternal("Could not find method ModConfiguration.ShutdownHook(). Will not be able to autosave configs on close!"); + return; + } + harmony.Patch(shutdown, prefix: new HarmonyMethod(patch)); + } catch (Exception e) { + Logger.ErrorInternal($"Unexpected exception applying shutdown hook!\n{e}"); + } + } + + private static void ShutdownHook() { + int count = 0; + ModLoader.Mods() + .Select(mod => mod.GetConfiguration()) + .Where(config => config != null) + .Where(config => config!.AutoSave) + .Where(config => config!.AnyValuesSet()) + .Do(config => { + try { + // synchronously save the config + config!.SaveInternal(); + } catch (Exception e) { + Logger.ErrorInternal($"Error saving configuration for {config!.Owner.Name}:\n{e}"); + } + count += 1; + }); + Logger.MsgInternal($"Configs saved for {count} mods."); + } +} + +/// +/// Represents an encountered while loading a mod's configuration file. +/// +public class ModConfigurationException : Exception { + internal ModConfigurationException(string message) : base(message) { } + + internal ModConfigurationException(string message, Exception innerException) : base(message, innerException) { } +} + +/// +/// Defines options for the handling of incompatible configuration versions. +/// +public enum IncompatibleConfigurationHandlingOption { + /// + /// Fail to read the config, and block saving over the config on disk. + /// + ERROR, + + /// + /// Destroy the saved config and start over from scratch. + /// + CLOBBER, + + /// + /// Ignore the version number and attempt to load the config from disk. + /// + FORCE_LOAD, +} diff --git a/ResoniteModLoader/ModConfigurationDefinitionBuilder.cs b/ResoniteModLoader/ModConfigurationDefinitionBuilder.cs new file mode 100644 index 0000000..e1fb27c --- /dev/null +++ b/ResoniteModLoader/ModConfigurationDefinitionBuilder.cs @@ -0,0 +1,81 @@ +using HarmonyLib; + +namespace ResoniteModLoader; +/// +/// Represents a fluent configuration interface to define mod configurations. +/// +public class ModConfigurationDefinitionBuilder { + private readonly ResoniteModBase Owner; + private Version ConfigVersion = new(1, 0, 0); + private readonly HashSet Keys = new(); + private bool AutoSaveConfig = true; + + internal ModConfigurationDefinitionBuilder(ResoniteModBase owner) { + Owner = owner; + } + + /// + /// Sets the semantic version of this configuration definition. Default is 1.0.0. + /// + /// The config's semantic version. + /// This builder. + public ModConfigurationDefinitionBuilder Version(Version version) { + ConfigVersion = version; + return this; + } + + /// + /// Sets the semantic version of this configuration definition. Default is 1.0.0. + /// + /// The config's semantic version, as a string. + /// This builder. + public ModConfigurationDefinitionBuilder Version(string version) { + ConfigVersion = new Version(version); + return this; + } + + /// + /// Adds a new key to this configuration definition. + /// + /// A configuration key. + /// This builder. + public ModConfigurationDefinitionBuilder Key(ModConfigurationKey key) { + Keys.Add(key); + return this; + } + + /// + /// Sets the AutoSave property of this configuration definition. Default is true. + /// + /// If false, the config will not be autosaved on Resonite close. + /// This builder. + public ModConfigurationDefinitionBuilder AutoSave(bool autoSave) { + AutoSaveConfig = autoSave; + return this; + } + + internal void ProcessAttributes() { + var fields = AccessTools.GetDeclaredFields(Owner.GetType()); + fields + .Where(field => Attribute.GetCustomAttribute(field, typeof(AutoRegisterConfigKeyAttribute)) != null) + .Do(ProcessField); + } + + private void ProcessField(FieldInfo field) { + if (!typeof(ModConfigurationKey).IsAssignableFrom(field.FieldType)) { + // wrong type + Logger.WarnInternal($"{Owner.Name} had an [AutoRegisterConfigKey] field of the wrong type: {field}"); + return; + } + + ModConfigurationKey fieldValue = (ModConfigurationKey)field.GetValue(field.IsStatic ? null : Owner); + Keys.Add(fieldValue); + } + + internal ModConfigurationDefinition? Build() { + if (Keys.Count > 0) { + return new ModConfigurationDefinition(Owner, ConfigVersion, Keys, AutoSaveConfig); + } + return null; + } +} diff --git a/ResoniteModLoader/ModConfigurationKey.cs b/ResoniteModLoader/ModConfigurationKey.cs new file mode 100644 index 0000000..b36c6e5 --- /dev/null +++ b/ResoniteModLoader/ModConfigurationKey.cs @@ -0,0 +1,179 @@ +namespace ResoniteModLoader; + +/// +/// Represents an untyped mod configuration key. +/// +public abstract class ModConfigurationKey { + internal ModConfigurationKey(string name, string? description, bool internalAccessOnly) { + Name = name ?? throw new ArgumentNullException("Configuration key name must not be null"); + Description = description; + InternalAccessOnly = internalAccessOnly; + } + + /// + /// Gets the mod-unique name of this config item. Must be present. + /// + public string Name { get; private set; } + + /// + /// Gets the human-readable description of this config item. Should be specified by the defining mod. + /// + public string? Description { get; private set; } + + /// + /// Gets whether only the owning mod should have access to this config item. + /// + public bool InternalAccessOnly { get; private set; } + + /// + /// Get the of this key's value. + /// + /// The of this key's value. + public abstract Type ValueType(); + + /// + /// Checks if a value is valid for this configuration item. + /// + /// The value to check. + /// true if the value is valid. + public abstract bool Validate(object? value); + + /// + /// Tries to compute the default value for this key, if a default provider was set. + /// + /// The computed default value if the return value is true. Otherwise default. + /// true if the default value was successfully computed. + public abstract bool TryComputeDefault(out object? defaultValue); + + /// + /// We only care about key name for non-defining keys.
+ /// For defining keys all of the other properties (default, validator, etc.) also matter. + ///
+ /// The other object to compare against. + /// true if the other object is equal to this. + public override bool Equals(object obj) { + return obj is ModConfigurationKey key && + Name == key.Name; + } + + /// + public override int GetHashCode() { + return 539060726 + EqualityComparer.Default.GetHashCode(Name); + } + + private object? Value; + internal bool HasValue; + + /// + /// Each configuration item has exactly ONE defining key, and that is the key defined by the mod. + /// Duplicate keys can be created (they only need to share the same Name) and they'll still work + /// for reading configs. + /// + /// This is a non-null self-reference for the defining key itself as soon as the definition is done initializing. + /// + internal ModConfigurationKey? DefiningKey; + + internal bool TryGetValue(out object? value) { + if (HasValue) { + value = Value; + return true; + } else { + value = null; + return false; + } + } + + internal void Set(object? value) { + Value = value; + HasValue = true; + } + + internal bool Unset() { + bool hadValue = HasValue; + HasValue = false; + return hadValue; + } +} + +/// +/// Represents a typed mod configuration key. +/// +/// The type of this key's value. +public class ModConfigurationKey : ModConfigurationKey { + /// + /// Creates a new instance of the class with the given parameters. + /// + /// The mod-unique name of this config item. + /// The human-readable description of this config item. + /// The function that computes a default value for this key. Otherwise default() will be used. + /// If true, only the owning mod should have access to this config item. + /// The function that checks if the given value is valid for this configuration item. Otherwise everything will be accepted. + public ModConfigurationKey(string name, string? description = null, Func? computeDefault = null, bool internalAccessOnly = false, Predicate? valueValidator = null) : base(name, description, internalAccessOnly) { + ComputeDefault = computeDefault; + IsValueValid = valueValidator; + } + + private readonly Func? ComputeDefault; + private readonly Predicate? IsValueValid; + + /// + public override Type ValueType() => typeof(T); + + /// + public override bool Validate(object? value) { + if (value is T typedValue) { + // value is of the correct type + return ValidateTyped(typedValue); + } else if (value == null) { + if (Util.CanBeNull(ValueType())) { + // null is valid for T + return ValidateTyped((T?)value); + } else { + // null is not valid for T + return false; + } + } else { + // value is of the wrong type + return false; + } + } + + /// + /// Checks if a value is valid for this configuration item. + /// + /// The value to check. + /// true if the value is valid. + public bool ValidateTyped(T? value) { + if (IsValueValid == null) { + return true; + } else { + return IsValueValid(value); + } + } + + /// + public override bool TryComputeDefault(out object? defaultValue) { + if (TryComputeDefaultTyped(out T? defaultTypedValue)) { + defaultValue = defaultTypedValue; + return true; + } else { + defaultValue = null; + return false; + } + } + + /// + /// Tries to compute the default value for this key, if a default provider was set. + /// + /// The computed default value if the return value is true. Otherwise default(T). + /// true if the default value was successfully computed. + public bool TryComputeDefaultTyped(out T? defaultValue) { + if (ComputeDefault == null) { + defaultValue = default; + return false; + } else { + defaultValue = ComputeDefault(); + return true; + } + } +} diff --git a/ResoniteModLoader/ModLoader.cs b/ResoniteModLoader/ModLoader.cs new file mode 100644 index 0000000..cfd8f33 --- /dev/null +++ b/ResoniteModLoader/ModLoader.cs @@ -0,0 +1,172 @@ +using HarmonyLib; + +namespace ResoniteModLoader; + +/// +/// Contains the actual mod loader. +/// +public class ModLoader { + internal const string VERSION_CONSTANT = "2.4.0"; + /// + /// ResoniteModLoader's version + /// + public static readonly string VERSION = VERSION_CONSTANT; + private static readonly Type RESONITE_MOD_TYPE = typeof(ResoniteMod); + private static readonly List LoadedMods = new(); // used for mod enumeration + internal static readonly Dictionary AssemblyLookupMap = new(); // used for logging + private static readonly Dictionary ModNameLookupMap = new(); // used for duplicate mod checking + + /// + /// Allows reading metadata for all loaded mods + /// + /// A new list containing each loaded mod + public static IEnumerable Mods() { + return LoadedMods + .Select(m => (ResoniteModBase)m.ResoniteMod) + .ToList(); + } + + internal static void LoadMods(Harmony harmony) { + ModLoaderConfiguration config = ModLoaderConfiguration.Get(); + if (config.NoMods) { + Logger.DebugInternal("Mods will not be loaded due to configuration file"); + return; + } + + // generate list of assemblies to load + AssemblyFile[] modsToLoad; + if (AssemblyLoader.LoadAssembliesFromDir("rml_mods") is AssemblyFile[] arr) { + modsToLoad = arr; + } else { + return; + } + + ModConfiguration.EnsureDirectoryExists(); + + // Call InitializeMod() for each mod + foreach (AssemblyFile mod in modsToLoad) { + try { + LoadedResoniteMod? loaded = InitializeMod(mod); + if (loaded != null) { + // if loading succeeded, then we need to register the mod + RegisterMod(loaded); + } + } catch (ReflectionTypeLoadException reflectionTypeLoadException) { + // this exception type has some inner exceptions we must also log to gain any insight into what went wrong + StringBuilder sb = new(); + sb.AppendLine(reflectionTypeLoadException.ToString()); + foreach (Exception loaderException in reflectionTypeLoadException.LoaderExceptions) { + sb.AppendLine($"Loader Exception: {loaderException.Message}"); + if (loaderException is FileNotFoundException fileNotFoundException) { + if (!string.IsNullOrEmpty(fileNotFoundException.FusionLog)) { + sb.Append(" Fusion Log:\n "); + sb.AppendLine(fileNotFoundException.FusionLog); + } + } + } + Logger.ErrorInternal($"ReflectionTypeLoadException initializing mod from {mod.File}:\n{sb}"); + } catch (Exception e) { + Logger.ErrorInternal($"Unexpected exception initializing mod from {mod.File}:\n{e}"); + } + } + + ModConfiguration.RegisterShutdownHook(harmony); + + foreach (LoadedResoniteMod mod in LoadedMods) { + try { + HookMod(mod); + } catch (Exception e) { + Logger.ErrorInternal($"Unexpected exception in OnEngineInit() for mod {mod.ResoniteMod.Name} from {mod.ModAssembly.File}:\n{e}"); + } + } + + // Log potential conflicts + if (config.LogConflicts) { + IEnumerable patchedMethods = Harmony.GetAllPatchedMethods(); + foreach (MethodBase patchedMethod in patchedMethods) { + Patches patches = Harmony.GetPatchInfo(patchedMethod); + HashSet owners = new(patches.Owners); + if (owners.Count > 1) { + Logger.WarnInternal($"Method \"{patchedMethod.FullDescription()}\" has been patched by the following:"); + foreach (string owner in owners) { + Logger.WarnInternal($" \"{owner}\" ({TypesForOwner(patches, owner)})"); + } + } else if (config.Debug) { + string owner = owners.FirstOrDefault(); + Logger.DebugFuncInternal(() => $"Method \"{patchedMethod.FullDescription()}\" has been patched by \"{owner}\""); + } + } + } + } + + /// + /// We have a bunch of maps and things the mod needs to be registered in. This method does all that jazz. + /// + /// The successfully loaded mod to register + private static void RegisterMod(LoadedResoniteMod mod) { + try { + ModNameLookupMap.Add(mod.ResoniteMod.Name, mod); + } catch (ArgumentException) { + LoadedResoniteMod existing = ModNameLookupMap[mod.ResoniteMod.Name]; + Logger.ErrorInternal($"{mod.ModAssembly.File} declares duplicate mod {mod.ResoniteMod.Name} already declared in {existing.ModAssembly.File}. The new mod will be ignored."); + return; + } + + LoadedMods.Add(mod); + AssemblyLookupMap.Add(mod.ModAssembly.Assembly, mod.ResoniteMod); + mod.ResoniteMod.loadedResoniteMod = mod; // complete the circular reference (used to look up config) + mod.FinishedLoading = true; // used to signal that the mod is truly loaded + } + + private static string TypesForOwner(Patches patches, string owner) { + bool ownerEquals(Patch patch) => Equals(patch.owner, owner); + int prefixCount = patches.Prefixes.Where(ownerEquals).Count(); + int postfixCount = patches.Postfixes.Where(ownerEquals).Count(); + int transpilerCount = patches.Transpilers.Where(ownerEquals).Count(); + int finalizerCount = patches.Finalizers.Where(ownerEquals).Count(); + return $"prefix={prefixCount}; postfix={postfixCount}; transpiler={transpilerCount}; finalizer={finalizerCount}"; + } + + // Loads mod class and mod config + private static LoadedResoniteMod? InitializeMod(AssemblyFile mod) { + if (mod.Assembly == null) { + return null; + } + + Type[] modClasses = mod.Assembly.GetLoadableTypes(t => t.IsClass && !t.IsAbstract && RESONITE_MOD_TYPE.IsAssignableFrom(t)).ToArray(); + if (modClasses.Length == 0) { + Logger.ErrorInternal($"No loadable mod found in {mod.File}"); + return null; + } else if (modClasses.Length != 1) { + Logger.ErrorInternal($"More than one mod found in {mod.File}. File will not be loaded."); + return null; + } else { + Type modClass = modClasses[0]; + ResoniteMod? resoniteMod = null; + try { + resoniteMod = (ResoniteMod)AccessTools.CreateInstance(modClass); + } catch (Exception e) { + Logger.ErrorInternal($"Error instantiating mod {modClass.FullName} from {mod.File}:\n{e}"); + return null; + } + if (resoniteMod == null) { + Logger.ErrorInternal($"Unexpected null instantiating mod {modClass.FullName} from {mod.File}"); + return null; + } + + LoadedResoniteMod loadedMod = new(resoniteMod, mod); + Logger.MsgInternal($"Loaded mod [{resoniteMod.Name}/{resoniteMod.Version}] ({Path.GetFileName(mod.File)}) by {resoniteMod.Author} with Sha256: {mod.Sha256}"); + loadedMod.ModConfiguration = ModConfiguration.LoadConfigForMod(loadedMod); + return loadedMod; + } + } + + private static void HookMod(LoadedResoniteMod mod) { + Logger.DebugFuncInternal(() => $"calling OnEngineInit() for [{mod.ResoniteMod.Name}/{mod.ResoniteMod.Version}]"); + try { + mod.ResoniteMod.OnEngineInit(); + } catch (Exception e) { + Logger.ErrorInternal($"Mod {mod.ResoniteMod.Name} from {mod.ModAssembly.File} threw error from OnEngineInit():\n{e}"); + } + } +} diff --git a/ResoniteModLoader/ModLoaderConfiguration.cs b/ResoniteModLoader/ModLoaderConfiguration.cs new file mode 100644 index 0000000..01dc2dd --- /dev/null +++ b/ResoniteModLoader/ModLoaderConfiguration.cs @@ -0,0 +1,74 @@ +namespace ResoniteModLoader; + +internal class ModLoaderConfiguration { + private const string CONFIG_FILENAME = "ResoniteModLoader.config"; + + private static ModLoaderConfiguration? _configuration; + + internal static ModLoaderConfiguration Get() { + if (_configuration == null) { + // the config file can just sit next to the dll. Simple. + string path = Path.Combine(GetAssemblyDirectory(), CONFIG_FILENAME); + _configuration = new ModLoaderConfiguration(); + + Dictionary> keyActions = new() { + { "unsafe", (value) => _configuration.Unsafe = bool.Parse(value) }, + { "debug", (value) => _configuration.Debug = bool.Parse(value) }, + { "nomods", (value) => _configuration.NoMods = bool.Parse(value) }, + { "nolibraries", (value) => _configuration.NoLibraries = bool.Parse(value) }, + { "advertiseversion", (value) => _configuration.AdvertiseVersion = bool.Parse(value) }, + { "logconflicts", (value) => _configuration.LogConflicts = bool.Parse(value) }, + { "hidemodtypes", (value) => _configuration.HideModTypes = bool.Parse(value) }, + { "hidelatetypes", (value) => _configuration.HideLateTypes = bool.Parse(value) } + }; + + // .NET's ConfigurationManager is some hot trash to the point where I'm just done with it. + // Time to reinvent the wheel. This parses simple key=value style properties from a text file. + try { + var lines = File.ReadAllLines(path); + foreach (var line in lines) { + int splitIdx = line.IndexOf('='); + if (splitIdx != -1) { + string key = line.Substring(0, splitIdx); + string value = line.Substring(splitIdx + 1); + + if (keyActions.TryGetValue(key, out Action action)) { + try { + action(value); + } catch (Exception) { + Logger.WarnInternal($"Unable to parse value: '{value}' for config key: '{key}', this key will be ignored"); + } + } + } + } + } catch (Exception e) { + if (e is FileNotFoundException) { + Logger.MsgInternal($"{path} is missing! This is probably fine."); + } else if (e is DirectoryNotFoundException || e is IOException || e is UnauthorizedAccessException) { + Logger.WarnInternal(e.ToString()); + } else { + throw; + } + } + } + return _configuration; + } + + private static string GetAssemblyDirectory() { + string codeBase = Assembly.GetExecutingAssembly().CodeBase; + UriBuilder uri = new(codeBase); + string path = Uri.UnescapeDataString(uri.Path); + return Path.GetDirectoryName(path); + } + +#pragma warning disable CA1805 + public bool Unsafe { get; private set; } = false; + public bool Debug { get; private set; } = false; + public bool NoMods { get; private set; } = false; + public bool NoLibraries { get; private set; } = false; + public bool AdvertiseVersion { get; private set; } = false; + public bool LogConflicts { get; private set; } = true; + public bool HideModTypes { get; private set; } = true; + public bool HideLateTypes { get; private set; } = true; +#pragma warning restore CA1805 +} diff --git a/ResoniteModLoader/Properties/AssemblyInfo.cs b/ResoniteModLoader/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..582ea43 --- /dev/null +++ b/ResoniteModLoader/Properties/AssemblyInfo.cs @@ -0,0 +1,39 @@ +using System.ComponentModel; +using System.Runtime.InteropServices; + +// General Information about an assembly is controlled through the following +// set of attributes. Change these attribute values to modify the information +// associated with an assembly. +[assembly: AssemblyTitle("ResoniteModLoader")] +[assembly: AssemblyDescription("")] +[assembly: AssemblyConfiguration("")] +[assembly: AssemblyCompany("")] +[assembly: AssemblyProduct("ResoniteModLoader")] +[assembly: AssemblyCopyright("Copyright © 2023")] +[assembly: AssemblyTrademark("")] +[assembly: AssemblyCulture("")] + +// Setting ComVisible to false makes the types in this assembly not visible +// to COM components. If you need to access a type in this assembly from +// COM, set the ComVisible attribute to true on that type. +[assembly: ComVisible(false)] + +// The following GUID is for the ID of the typelib if this project is exposed to COM +[assembly: Guid("d4627c7f-8091-477a-abdc-f1465d94d8d9")] + +// Version information for an assembly consists of the following four values: +// +// Major Version +// Minor Version +// Build Number +// Revision +// +// You can specify all the values or you can default the Build and Revision Numbers +// by using the '*' as shown below: +// [assembly: AssemblyVersion("1.0.*")] +[assembly: AssemblyVersion(ResoniteModLoader.ModLoader.VERSION_CONSTANT)] +[assembly: AssemblyFileVersion(ResoniteModLoader.ModLoader.VERSION_CONSTANT)] + +// Prevent FrooxEngine.Weaver from modifying this assembly, as it doesn't need anything done to it +// This keeps Weaver from overwriting AssemblyVersionAttribute +[module: Description("FROOXENGINE_WEAVED")] diff --git a/ResoniteModLoader/ResoniteMod.cs b/ResoniteModLoader/ResoniteMod.cs new file mode 100644 index 0000000..fb1d6ee --- /dev/null +++ b/ResoniteModLoader/ResoniteMod.cs @@ -0,0 +1,102 @@ +namespace ResoniteModLoader; + +/// +/// Contains members that only the or the Mod itself are intended to access. +/// +public abstract class ResoniteMod : ResoniteModBase { + /// + /// Gets whether debug logging is enabled. + /// + /// true if debug logging is enabled. + public static bool IsDebugEnabled() => Logger.IsDebugEnabled(); + + /// + /// Logs an object as a line in the log based on the value produced by the given function if debug logging is enabled.. + /// + /// This is more efficient than passing an or a directly, + /// as it won't be generated if debug logging is disabled. + /// + /// The function generating the object to log. + public static void DebugFunc(Func messageProducer) => Logger.DebugFuncExternal(messageProducer); + + /// + /// Logs the given object as a line in the log if debug logging is enabled. + /// + /// The object to log. + public static void Debug(object message) => Logger.DebugExternal(message); + + /// + /// Logs the given objects as lines in the log if debug logging is enabled. + /// + /// The objects to log. + public static void Debug(params object[] messages) => Logger.DebugListExternal(messages); + + /// + /// Logs the given object as a regular line in the log. + /// + /// The object to log. + public static void Msg(object message) => Logger.MsgExternal(message); + + /// + /// Logs the given objects as regular lines in the log. + /// + /// The objects to log. + public static void Msg(params object[] messages) => Logger.MsgListExternal(messages); + + /// + /// Logs the given object as a warning line in the log. + /// + /// The object to log. + public static void Warn(object message) => Logger.WarnExternal(message); + + /// + /// Logs the given objects as warning lines in the log. + /// + /// The objects to log. + public static void Warn(params object[] messages) => Logger.WarnListExternal(messages); + + /// + /// Logs the given object as an error line in the log. + /// + /// The object to log. + public static void Error(object message) => Logger.ErrorExternal(message); + + /// + /// Logs the given objects as error lines in the log. + /// + /// The objects to log. + public static void Error(params object[] messages) => Logger.ErrorListExternal(messages); + + /// + /// Called once immediately after ResoniteModLoader begins execution + /// + public virtual void OnEngineInit() { } + + /// + /// Build the defined configuration for this mod. + /// + /// This mod's configuration definition. + internal ModConfigurationDefinition? BuildConfigurationDefinition() { + ModConfigurationDefinitionBuilder builder = new(this); + builder.ProcessAttributes(); + DefineConfiguration(builder); + return builder.Build(); + } + + /// + /// Define this mod's configuration via a builder + /// + /// A builder you can use to define the mod's configuration + public virtual void DefineConfiguration(ModConfigurationDefinitionBuilder builder) { + } + + /// + /// Defines handling of incompatible configuration versions + /// + /// Configuration version read from the config file + /// Configuration version defined in the mod code + /// + public virtual IncompatibleConfigurationHandlingOption HandleIncompatibleConfigurationVersions(Version serializedVersion, Version definedVersion) { + return IncompatibleConfigurationHandlingOption.ERROR; + } +} diff --git a/ResoniteModLoader/ResoniteModBase.cs b/ResoniteModLoader/ResoniteModBase.cs new file mode 100644 index 0000000..d8980f1 --- /dev/null +++ b/ResoniteModLoader/ResoniteModBase.cs @@ -0,0 +1,47 @@ +namespace ResoniteModLoader; + +/// +/// Contains public metadata about a mod. +/// +public abstract class ResoniteModBase { + /// + /// Gets the mod's name. This must be unique. + /// + public abstract string Name { get; } + + /// + /// Gets the mod's author. + /// + public abstract string Author { get; } + + /// + /// Gets the mod's semantic version. + /// + public abstract string Version { get; } + + /// + /// Gets an optional hyperlink to the mod's homepage. + /// + public virtual string? Link { get; } + + /// + /// A circular reference back to the LoadedResoniteMod that contains this ResoniteModBase. + /// The reference is set once the mod is successfully loaded, and is null before that. + /// + internal LoadedResoniteMod? loadedResoniteMod; + + /// + /// Gets this mod's current . + /// + /// This will always be the same instance. + /// + /// This mod's current configuration. + public ModConfiguration? GetConfiguration() { + if (!FinishedLoading) { + throw new ModConfigurationException($"GetConfiguration() was called before {Name} was done initializing. Consider calling GetConfiguration() from within OnEngineInit()"); + } + return loadedResoniteMod?.ModConfiguration; + } + + internal bool FinishedLoading { get; set; } +} diff --git a/ResoniteModLoader/ResoniteModLoader.csproj b/ResoniteModLoader/ResoniteModLoader.csproj new file mode 100644 index 0000000..de9caf8 --- /dev/null +++ b/ResoniteModLoader/ResoniteModLoader.csproj @@ -0,0 +1,67 @@ + + + + {D4627C7F-8091-477A-ABDC-F1465D94D8D9} + Library + Properties + ResoniteModLoader + ResoniteModLoader + + + ResoniteModLoader.dll + false + net462 + 512 + 10.0 + enable + true + True + enable + True + 6.0-recommended + + true + false + None + + + + $(MSBuildThisFileDirectory)Resonite/ + C:\Program Files (x86)\Steam\steamapps\common\Resonite\ + $(HOME)/.steam/steam/steamapps/common/Resonite/ + R:\SteamLibrary\steamapps\common\Resonite\ + + + + none + + + + embedded + + + + + + $(ResonitePath)Resonite_Data\Managed\Newtonsoft.Json.dll + False + + + $(ResonitePath)Resonite_Data\Managed\Elements.Core.dll + False + + + $(ResonitePath)Resonite_Data\Managed\FrooxEngine.dll + False + + + + + + + + diff --git a/ResoniteModLoader/Util.cs b/ResoniteModLoader/Util.cs new file mode 100644 index 0000000..e03b3e1 --- /dev/null +++ b/ResoniteModLoader/Util.cs @@ -0,0 +1,110 @@ +using System.Diagnostics; +using System.Security.Cryptography; + +namespace ResoniteModLoader; + +internal static class Util { + /// + /// Get the executing mod by stack trace analysis. + /// You may skip extra frames if you know your callers are guaranteed to be RML code. + /// + /// A stack trace captured by the callee + /// The executing mod, or null if none found + internal static ResoniteMod? ExecutingMod(StackTrace stackTrace) { + for (int i = 0; i < stackTrace.FrameCount; i++) { + Assembly? assembly = stackTrace.GetFrame(i)?.GetMethod()?.DeclaringType?.Assembly; + if (assembly != null && ModLoader.AssemblyLookupMap.TryGetValue(assembly, out ResoniteMod mod)) { + return mod; + } + } + return null; + } + + /// + /// Used to debounce calls to a given method. The given method will be called after there have been no additional calls + /// for the given number of milliseconds. + /// + /// The returned by this method has internal state used for debouncing, + /// so you will need to store and reuse the Action for each call. + /// + /// The type of the debounced method's input. + /// The method to be debounced. + /// How long to wait before a call to the debounced method gets passed through. + /// A debouncing wrapper for the given method. + // credit: https://stackoverflow.com/questions/28472205/c-sharp-event-debounce + internal static Action Debounce(this Action func, int milliseconds) { + // this variable gets embedded in the returned Action via the magic of closures + CancellationTokenSource? cancelTokenSource = null; + + return arg => { + // if there's already a scheduled call, then cancel it + cancelTokenSource?.Cancel(); + cancelTokenSource = new CancellationTokenSource(); + + // schedule a new call + Task.Delay(milliseconds, cancelTokenSource.Token) + .ContinueWith(t => { + if (t.IsCompletedSuccessfully()) { + Task.Run(() => func(arg)); + } + }, TaskScheduler.Default); + }; + } + + // Shim because this doesn't exist in .NET 4.6 + private static bool IsCompletedSuccessfully(this Task t) { + return t.IsCompleted && !t.IsFaulted && !t.IsCanceled; + } + + // credit to Delta for this method https://github.com/XDelta/ + internal static string GenerateSHA256(string filepath) { + using var hasher = SHA256.Create(); + using var stream = File.OpenRead(filepath); + var hash = hasher.ComputeHash(stream); + return BitConverter.ToString(hash).Replace("-", ""); + } + + internal static HashSet ToHashSet(this IEnumerable source, IEqualityComparer? comparer = null) { + return new HashSet(source, comparer); + } + + // Check if a type cannot possibly have null assigned + internal static bool CannotBeNull(Type t) { + return t.IsValueType && Nullable.GetUnderlyingType(t) == null; + } + + // Check if a type is allowed to have null assigned + internal static bool CanBeNull(Type t) { + return !CannotBeNull(t); + } + + internal static IEnumerable GetLoadableTypes(this Assembly assembly, Predicate predicate) { + try { + return assembly.GetTypes().Where(type => CheckType(type, predicate)); + } catch (ReflectionTypeLoadException e) { + return e.Types.Where(type => CheckType(type, predicate)); + } + } + + // Check a potentially unloadable type to see if it is (A) loadable and (B) satsifies a predicate without throwing an exception + // this does a series of increasingly aggressive checks to see if the type is unsafe to touch + private static bool CheckType(Type type, Predicate predicate) { + if (type == null) { + return false; + } + + try { + string _name = type.Name; + } catch (Exception e) { + Logger.DebugFuncInternal(() => $"Could not read the name for a type: {e}"); + return false; + } + + try { + return predicate(type); + } catch (Exception e) { + Logger.DebugFuncInternal(() => $"Could not load type \"{type}\": {e}"); + return false; + } + } +} diff --git a/ResoniteModLoader/Utility/EnumerableInjector.cs b/ResoniteModLoader/Utility/EnumerableInjector.cs new file mode 100644 index 0000000..2e65458 --- /dev/null +++ b/ResoniteModLoader/Utility/EnumerableInjector.cs @@ -0,0 +1,160 @@ +using System.Collections; + +namespace ResoniteModLoader.Utility; + +/// +/// Provides the ability to inject actions into the execution of an enumeration while transforming it.

+/// This example shows how to apply the when patching a function.
+/// Of course you typically wouldn't patch with a generic method, that's just for illustrating the Type usage. +/// +/// private static void Postfix<Original, Transformed>(ref IEnumerable<Original> __result) where Transformed : Original +/// { +/// __result = new EnumerableInjector<Original, Transformed>(__result, +/// item => { Msg("Change what the item is exactly"); return new Transformed(item); }) +/// { +/// Prefix = () => Msg("Before the first item is returned"), +/// PreItem = item => { Msg("Decide if an item gets returned"); return true; }, +/// PostItem = (original, transformed, returned) => Msg("After control would come back to the generator after a yield return"), +/// Postfix = () => Msg("When the generator stopped returning items") +/// }; +/// } +/// +///
+/// The type of the original enumeration's items. +/// The type of the transformed enumeration's items.
Must be assignable to TOriginal for compatibility.
+public class EnumerableInjector : IEnumerable + where TTransformed : TOriginal { + /// + /// Internal enumerator for iteration. + /// + private readonly IEnumerator enumerator; + + private Action postfix = () => { }; + private Action postItem = (original, transformed, returned) => { }; + private Action prefix = () => { }; + private Func preItem = item => true; + private Func transformItem = item => throw new NotImplementedException("You're supposed to insert your own transformation function here!"); + + /// + /// Gets called when the wrapped enumeration returned the last item. + /// + public Action Postfix { + get => postfix; + set => postfix = value ?? throw new ArgumentNullException(nameof(value), "Postfix can't be null!"); + } + + /// + /// Gets called for each item, with the transformed item, and whether it was passed through. + /// First thing to be called after execution returns to the enumerator after a yield return. + /// + public Action PostItem { + get => postItem; + set => postItem = value ?? throw new ArgumentNullException(nameof(value), "PostItem can't be null!"); + } + + /// + /// Gets called before the enumeration returns the first item. + /// + public Action Prefix { + get => prefix; + set => prefix = value ?? throw new ArgumentNullException(nameof(value), "Prefix can't be null!"); + } + + /// + /// Gets called for each item to determine whether it should be passed through. + /// + public Func PreItem { + get => preItem; + set => preItem = value ?? throw new ArgumentNullException(nameof(value), "PreItem can't be null!"); + } + + /// + /// Gets called for each item to transform it, even if it won't be passed through. + /// + public Func TransformItem { + get => transformItem; + set => transformItem = value ?? throw new ArgumentNullException(nameof(value), "TransformItem can't be null!"); + } + + /// + /// Creates a new instance of the class using the supplied input and transform function. + /// + /// The enumerable to inject into and transform. + /// The transformation function. + public EnumerableInjector(IEnumerable enumerable, Func transformItem) + : this(enumerable.GetEnumerator(), transformItem) { } + + /// + /// Creates a new instance of the class using the supplied input and transform function. + /// + /// The enumerator to inject into and transform. + /// The transformation function. + public EnumerableInjector(IEnumerator enumerator, Func transformItem) { + this.enumerator = enumerator; + TransformItem = transformItem; + } + + /// + /// Injects into and transforms the input enumeration. + /// + /// The injected and transformed enumeration. + public IEnumerator GetEnumerator() { + Prefix(); + + while (enumerator.MoveNext()) { + var item = enumerator.Current; + var returnItem = PreItem(item); + var transformedItem = TransformItem(item); + + if (returnItem) + yield return transformedItem; + + PostItem(item, transformedItem, returnItem); + } + + Postfix(); + } + + /// + /// Injects into and transforms the input enumeration without a generic type. + /// + /// The injected and transformed enumeration without a generic type. + IEnumerator IEnumerable.GetEnumerator() { + return GetEnumerator(); + } +} + +/// +/// Provides the ability to inject actions into the execution of an enumeration without transforming it.

+/// This example shows how to apply the when patching a function.
+/// Of course you typically wouldn't patch with a generic method, that's just for illustrating the Type usage. +/// +/// static void Postfix<T>(ref IEnumerable<T> __result) +/// { +/// __result = new EnumerableInjector<T>(__result) +/// { +/// Prefix = () => Msg("Before the first item is returned"), +/// PreItem = item => { Msg("Decide if an item gets returned"); return true; }, +/// TransformItem = item => { Msg("Change what the item is exactly"); return item; }, +/// PostItem = (original, transformed, returned) => Msg("After control would come back to the generator after a yield return"), +/// Postfix = () => Msg("When the generator stopped returning items") +/// }; +/// } +/// +///
+/// The type of the enumeration's items. +public class EnumerableInjector : EnumerableInjector { + /// + /// Creates a new instance of the class using the supplied input . + /// + /// The enumerable to inject into. + public EnumerableInjector(IEnumerable enumerable) + : this(enumerable.GetEnumerator()) { } + + /// + /// Creates a new instance of the class using the supplied input . + /// + /// The enumerator to inject into. + public EnumerableInjector(IEnumerator enumerator) + : base(enumerator, item => item) { } +} diff --git a/ResoniteModLoader/VersionReset.cs b/ResoniteModLoader/VersionReset.cs new file mode 100644 index 0000000..798c19b --- /dev/null +++ b/ResoniteModLoader/VersionReset.cs @@ -0,0 +1,260 @@ +using System.ComponentModel; +using System.Reflection.Emit; +using System.Runtime.CompilerServices; +using System.Security.Cryptography; + +using Elements.Core; + +using FrooxEngine; + +using HarmonyLib; + +namespace ResoniteModLoader; + +internal static class VersionReset { + // String used when AdvertiseVersion == true + private const string RESONITE_MOD_LOADER = "ResoniteModLoader.dll"; + + internal static void Initialize() { + ModLoaderConfiguration config = ModLoaderConfiguration.Get(); + Engine engine = Engine.Current; + + // Get the version string before we mess with it + string originalVersionString = engine.VersionString; + + List extraAssemblies = Engine.ExtraAssemblies; + string assemblyFilename = Path.GetFileName(Assembly.GetExecutingAssembly().Location); + bool rmlPresent = extraAssemblies.Remove(assemblyFilename); + + if (!rmlPresent) { + throw new Exception($"Assertion failed: Engine.ExtraAssemblies did not contain \"{assemblyFilename}\""); + } + + // Get all Weaved assemblies. This is useful, as plugins will always be Weaved. + Assembly[] weavedAssemblies = AppDomain.CurrentDomain.GetAssemblies() + .Where(IsWeaved) + .ToArray(); + + Logger.DebugFuncInternal(() => { + string potentialPlugins = weavedAssemblies + .Select(a => Path.GetFileName(a.Location)) + .Join(delimiter: ", "); + return $"Found {weavedAssemblies.Length} potential plugins: {potentialPlugins}"; + }); + + HashSet expectedWeavedAssemblies = GetExpectedWeavedAssemblies(); + + // Attempt to map the Weaved assemblies to Resonite's plugin list + Dictionary plugins = new(weavedAssemblies.Length); + Assembly[] unmatchedAssemblies = weavedAssemblies + .Where(assembly => { + string filename = Path.GetFileName(assembly.Location); + if (extraAssemblies.Contains(filename)) { + // okay, the assembly's filename is in the plugin list. It's probably a plugin. + plugins.Add(filename, assembly); + return false; + } else { + // remove certain expected assemblies from the "unmatchedAssemblies" naughty list + return !expectedWeavedAssemblies.Contains(assembly); + } + }) + .ToArray(); + + + Logger.DebugFuncInternal(() => { + string actualPlugins = plugins.Keys.Join(delimiter: ", "); + return $"Found {plugins.Count} actual plugins: {actualPlugins}"; + }); + + // Warn about assemblies that couldn't be mapped to plugins + foreach (Assembly assembly in unmatchedAssemblies) { + Logger.WarnInternal($"Unexpected Weaved assembly: \"{assembly.Location}\". If this is a plugin, then the plugin-detection code is faulty."); + } + + // Warn about plugins that couldn't be mapped to assemblies + HashSet unmatchedPlugins = new(extraAssemblies); + unmatchedPlugins.ExceptWith(plugins.Keys); // remove all matched plugins + foreach (string plugin in unmatchedPlugins) { + Logger.ErrorInternal($"Unmatched plugin: \"{plugin}\". RML could not find the assembly for this plugin, therefore RML cannot properly calculate the compatibility hash."); + } + + // Flags used later to determine how to spoof + bool includePluginsInHash = true; + + // If unsafe is true, we should pretend there are no plugins and spoof everything + if (config.Unsafe) { + if (!config.AdvertiseVersion) { + extraAssemblies.Clear(); + } + includePluginsInHash = false; + Logger.WarnInternal("Unsafe mode is enabled! Not that you had a warranty, but now it's DOUBLE void!"); + } + // else if unmatched plugins are present, we should not spoof anything + else if (unmatchedPlugins.Count != 0) { + Logger.ErrorInternal("Version spoofing was not performed due to some plugins having missing assemblies."); + return; + } + // else we should spoof normally + + // get plugin assemblies sorted in the same order Resonite sorted them. + List sortedPlugins = extraAssemblies + .Select(path => plugins[path]) + .ToList(); + + if (config.AdvertiseVersion) { + // put RML back in the version string + Logger.MsgInternal($"Adding {RESONITE_MOD_LOADER} to version string because you have AdvertiseVersion set to true."); + extraAssemblies.Insert(0, RESONITE_MOD_LOADER); + } + + // we intentionally attempt to set the version string first, so if it fails the compatibilty hash is left on the original value + // this is to prevent the case where a player simply doesn't know their version string is wrong + if (!SpoofVersionString(engine, originalVersionString)) { + Logger.WarnInternal("Version string spoofing failed"); + return; + } + + if (!SpoofCompatibilityHash(engine, sortedPlugins, includePluginsInHash)) { + Logger.WarnInternal("Compatibility hash spoofing failed"); + return; + } + + Logger.MsgInternal("Compatibility hash spoofing succeeded"); + } + + private static bool IsWeaved(Assembly assembly) { + return assembly.Modules // in practice there will only be one module, and it will have the dll's name + .SelectMany(module => module.GetCustomAttributes()) + .Where(IsWeavedAttribute) + .Any(); + } + + private static bool IsWeavedAttribute(DescriptionAttribute descriptionAttribute) { + return descriptionAttribute.Description == "FROOXENGINE_WEAVED"; + } + + // Get all the non-plugin Weaved assemblies we expect to exist + private static HashSet GetExpectedWeavedAssemblies() { + List list = new() + { + Type.GetType("FrooxEngine.IComponent, FrooxEngine")?.Assembly, + Type.GetType("ProtoFlux.Nodes.FrooxEngine.ProtoFluxMapper, ProtoFlux.Nodes.FrooxEngine")?.Assembly, + Type.GetType("ProtoFluxBindings.ProtoFluxMapper, ProtoFluxBindings")?.Assembly, + Assembly.GetExecutingAssembly(), + }; + return list + .Where(assembly => assembly != null) + .ToHashSet()!; + } + + private static bool SpoofCompatibilityHash(Engine engine, List plugins, bool includePluginsInHash) { + string vanillaCompatibilityHash; + int? vanillaProtocolVersionMaybe = GetVanillaProtocolVersion(); + if (vanillaProtocolVersionMaybe is int vanillaProtocolVersion) { + Logger.DebugFuncInternal(() => $"Vanilla protocol version is {vanillaProtocolVersion}"); + vanillaCompatibilityHash = CalculateCompatibilityHash(vanillaProtocolVersion, plugins, includePluginsInHash); + return SetCompatibilityHash(engine, vanillaCompatibilityHash); + } else { + Logger.ErrorInternal("Unable to determine vanilla protocol version"); + return false; + } + } + + private static string CalculateCompatibilityHash(int ProtocolVersion, List plugins, bool includePluginsInHash) { + using MD5CryptoServiceProvider cryptoServiceProvider = new(); + using (ConcatenatedStream inputStream = new()) { + inputStream.EnqueueStream(new MemoryStream(BitConverter.GetBytes(ProtocolVersion))); + if (includePluginsInHash) { + foreach (Assembly plugin in plugins) { + using (FileStream fileStream = File.OpenRead(plugin.Location)) { + fileStream.Seek(375L, SeekOrigin.Current); + inputStream.EnqueueStream(fileStream); + } + } + } + byte[] hash = cryptoServiceProvider.ComputeHash(inputStream); + return Convert.ToBase64String(hash); + } + } + + private static bool SetCompatibilityHash(Engine engine, string Target) { + // This is super sketchy and liable to break with new compiler versions. + // I have a good reason for doing it though... if I just called the setter it would recursively + // end up calling itself, because I'm HOOKING the CompatibilityHash setter. + FieldInfo field = AccessTools.DeclaredField(typeof(Engine), $"<{nameof(Engine.CompatibilityHash)}>k__BackingField"); + + if (field == null) { + Logger.WarnInternal("Unable to write Engine.CompatibilityHash"); + return false; + } else { + Logger.DebugFuncInternal(() => $"Changing compatibility hash from {engine.CompatibilityHash} to {Target}"); + field.SetValue(engine, Target); + return true; + } + } + + private static bool SpoofVersionString(Engine engine, string originalVersionString) { + FieldInfo field = AccessTools.DeclaredField(engine.GetType(), "_versionString"); + if (field == null) { + Logger.WarnInternal("Unable to write Engine._versionString"); + return false; + } + // null the cached value + field.SetValue(engine, null); + + Logger.DebugFuncInternal(() => $"Changing version string from {originalVersionString} to {engine.VersionString}"); + return true; + } + + // perform incredible bullshit to rip the hardcoded protocol version out of the dang IL + private static int? GetVanillaProtocolVersion() { + // raw IL immediately surrounding the number we need to find, which in this example is 12004 (0x2EE4) + + // ldc.i4 0x2EE4 + // call uint8[] [mscorlib] System.BitConverter::GetBytes(int32) + + // we're going to search for that method call, then grab the operand of the ldc.i4 that precedes it + MethodInfo targetCallee = AccessTools.DeclaredMethod(typeof(BitConverter), nameof(BitConverter.GetBytes), new Type[] { typeof(int) }); + if (targetCallee == null) { + Logger.ErrorInternal("Could not find System.BitConverter::GetBytes(System.Int32)"); + return null; + } + + //Locating 'private async Task InitializeAssemblies(LaunchOptions options)' + MethodInfo initializeShim = AccessTools.DeclaredMethod(typeof(Engine), "InitializeAssemblies", new Type[] { typeof(LaunchOptions) }); + if (initializeShim == null) { + Logger.ErrorInternal("Could not find Engine.InitializeAssemblies()"); + return null; + } + + AsyncStateMachineAttribute asyncAttribute = (AsyncStateMachineAttribute)initializeShim.GetCustomAttribute(typeof(AsyncStateMachineAttribute)); + if (asyncAttribute == null) { + Logger.ErrorInternal("Could not find AsyncStateMachine for Engine.InitializeAssemblies"); + return null; + } + + // async methods are weird. Their body is just some setup code that passes execution... elsewhere. + // The compiler generates a companion type for async methods. This companion type has some ridiculous nondeterministic name, but luckily + // we can just ask this attribute what the type is. The companion type should have a MoveNext() method that contains the actual IL we need. + + Type asyncStateMachineType = asyncAttribute.StateMachineType; + MethodInfo initializeImpl = AccessTools.DeclaredMethod(asyncStateMachineType, "MoveNext"); + if (initializeImpl == null) { + Logger.ErrorInternal("Could not find MoveNext method for Engine.Initialize"); + return null; + } + + List instructions = PatchProcessor.GetOriginalInstructions(initializeImpl); + for (int i = 1; i < instructions.Count; i++) { + if (instructions[i].Calls(targetCallee)) { + // we're guaranteed to have a previous instruction because we began iteration from 1 + CodeInstruction previous = instructions[i - 1]; + if (OpCodes.Ldc_I4.Equals(previous.opcode)) { + return (int)previous.operand; + } + } + } + + return null; + } +} diff --git a/doc/example_log.log b/doc/example_log.log new file mode 100644 index 0000000..f15d8e9 --- /dev/null +++ b/doc/example_log.log @@ -0,0 +1,60 @@ +2:12:17 AM.319 ( -1 FPS) Detecting output device +2:12:17 AM.457 ( -1 FPS) Creating FrooxEngineRunner +2:12:17 AM.633 ( -1 FPS) Initializing Engine Runner +2:12:17 AM.633 ( -1 FPS) Microphone permission authorized +2:12:17 AM.633 ( -1 FPS) External storage read permission authorized +2:12:17 AM.633 ( -1 FPS) External storage write permission authorized +2:12:17 AM.634 ( -1 FPS) DeviceName: Screen +2:12:22 AM.734 ( -1 FPS) AppPath: R:\SteamLibrary\steamapps\common\Resonite +DataPath: U:\Resonite +CachePath: U:\Resonite +2:12:22 AM.768 ( -1 FPS) Initializing App: Beta 2023.9.26.304 +Platform: Windows +HeadDevice: Screen +IsAOT: False +OS: Windows 10 (10.0.19045) 64bit +CPU: Intel(R) Core(TM) i9-9900K CPU @ 3.60GHz +GPU: NVIDIA GeForce RTX 3080 +PhysicalCores: +MemoryBytes: 63.93 GB +VRAMBytes: 11.82 GB +MaxTextureSize: 16384 +IsGPUTexturePOTByteAligned: True +UsingLinearSpace: True +XR Device Name: +XR Device Model: +StereoRenderingMode: MultiPass +Max GC Generation: 0, IsLittleEndian: True +System.Numerics.Vectors HW accelerated: False, Vector.Count: 4 +Brotli native encoding/decoding supported: True +2:12:22 AM.769 ( -1 FPS) Supported Texture Formats: Alpha8, RGB24, ARGB32, RGBA32, BGRA32, RGBAHalf, RGBAFloat, BC1, BC3, BC4, BC5, BC6H, BC7 +2:12:22 AM.770 ( -1 FPS) Processing startup commands... +2:12:22 AM.860 ( -1 FPS) Scanning locales... +2:12:22 AM.862 ( -1 FPS) Available locales: cs, de, en, en-gb, eo, es, et, fi, fr, is, ja, ko, nl, no, pl, qps-ploc, ru, sv, tr, zh-cn, zh-tw +2:12:22 AM.869 ( -1 FPS) Loading Config.json... +2:12:22 AM.869 ( -1 FPS) Computing compatibility hash... +2:12:22 AM.872 ( -1 FPS) Loaded Extra Assembly: Libraries\ResoniteModLoader.dll +2:12:22 AM.874 ( -1 FPS) Compatibility Hash: pkJrcwZuU9k9oRlXT/DANw== +2:12:22 AM.874 ( -1 FPS) Initializing FrooxEngine... +2:12:23 AM.933 ( -1 FPS) FreeImage Version: 3.18.0 +2:12:23 AM.933 ( -1 FPS) BepuPhysics Version: 2.4.0-frooxengine +2:12:23 AM.942 ( -1 FPS) FreeType Version: 2.10.4 +2:12:23 AM.942 ( -1 FPS) Opus Version: libopus 1.3.1-138-g07376903 +2:12:23 AM.967 ( -1 FPS) Supported 3D model formats: meshx, 3d, 3ds, 3mf, a3d, ac, ac3d, acc, amf, ase, ask, assbin, b3d, blend, bsp, bvh, cob, csm, dae, dxf, enff, fbx, glb, gltf, hmp, ifc, ifczip, irr, irrmesh, lwo, lws, lxo, m3d, md2, md3, md5anim, md5camera, md5mesh, mdc, mdl, mesh, mesh.xml, mot, ms3d, ndo, nff, obj, off, ogex, pk3, ply, pmx, prj, q3o, q3s, raw, scn, sib, smd, step, stl, stp, ter, uc, vta, x, xgl, xml, zae, zgl +2:12:23 AM.967 ( -1 FPS) Supported point cloud formats: pts, las, laz +2:12:23 AM.967 ( -1 FPS) Supported image formats: bmp, ico, jpg, jif, jpeg, jpe, jng, koa, iff, lbm, mng, pbm, pcd, pcx, pgm, png, ppm, ras, tga, targa, tif, tiff, wap, wbmp, wbm, psd, psb, cut, xbm, xpm, dds, gif, hdr, g3, sgi, rgb, rgba, bw, exr, j2k, j2c, jp2, pfm, pct, pict, pic, 3fr, arw, bay, bmq, cap, cine, cr2, crw, cs1, dc2, dcr, drf, dsc, dng, erf, fff, ia, iiq, k25, kc2, kdc, mdc, mef, mos, mrw, nef, nrw, orf, pef, ptx, pxn, qtk, raf, raw, rdc, rw2, rwl, rwz, sr2, srf, srw, sti, x3f, webp, jxr, wdp, hdp +2:12:23 AM.967 ( -1 FPS) Supported audio formats: wav, wave, flac, fla, ogg, aiff, aif, aifc +2:12:23 AM.967 ( -1 FPS) Supported video formats: mp4, mpeg, avi, mov, mpg, mkv, flv, webm, mts, 3gp, bik, m2v, m2s, wmv, m3u8, m3u, pls, m4a, mp3, mpeg3, aac, ac3, aif, aiff, ape, au, it, mka, mod, mp1, mp2, opus, s3m, sid, w64, wma, xm, nsf, nsfe, gbs, vgm, vgz, spc, gym +2:12:23 AM.967 ( -1 FPS) Supported font formats: ttf, otf, ttc, otc, woff +2:12:23 AM.969 ( -1 FPS) Supported subtitle formats: srt, sub, sub, ssa, ttml, vtt +2:12:25 AM.746 ( -1 FPS) [INFO] [ResoniteModLoader] Loading assemblies from rml_libs +2:12:25 AM.777 ( -1 FPS) [INFO] [ResoniteModLoader] Loaded libraries from rml_libs: +0Harmony, Version=2.2.2.0, Culture=neutral, PublicKeyToken=null Sha256=6928F117D52C0683C44683C0656CD5A345D21AA45720B11C64DC72A35771CAC5 +2:12:25 AM.778 ( -1 FPS) [INFO] [ResoniteModLoader] ResoniteModLoader v2.4.0 starting up! +2:12:25 AM.779 ( -1 FPS) [INFO] [ResoniteModLoader] CLR v4.0.30319.42000 +2:12:25 AM.779 ( -1 FPS) [INFO] [ResoniteModLoader] Using Harmony v2.2.2.0 +2:12:25 AM.779 ( -1 FPS) [INFO] [ResoniteModLoader] Using Elements.Core v1.0.0.0 +2:12:25 AM.779 ( -1 FPS) [INFO] [ResoniteModLoader] Using FrooxEngine v2023.9.26.304 +2:12:25 AM.779 ( -1 FPS) [INFO] [ResoniteModLoader] Using Json.NET v13.0.0.0 +2:12:26 AM.043 ( -1 FPS) [INFO] [ResoniteModLoader] Compatibility hash spoofing succeeded +2:12:26 AM.044 ( -1 FPS) [INFO] [ResoniteModLoader] Loading assemblies from rml_mods \ No newline at end of file diff --git a/doc/modloader_config.md b/doc/modloader_config.md new file mode 100644 index 0000000..c8f08c4 --- /dev/null +++ b/doc/modloader_config.md @@ -0,0 +1,21 @@ +# Modloader Configuration + +ResoniteModLoader aims to have a reasonable default configuration, but certain things can be adjusted via an optional config file. The config file does not create itself automatically, but you can create it yourself by making a `ResoniteModLoader.config` file in the same directory as `ResoniteModLoader.dll`. `ResoniteModLoader.config` is a simple text file that supports keys and values in the following format: + +```ini +debug=true +nomods=false +``` + +Not all keys are required to be present. Missing keys will use the defaults outlined below: + +| Configuration | Default | Description | +| ------------------ | ------- | ----------- | +| `debug` | `false` | If `true`, ResoniteMod.Debug() logs will appear in your log file. Otherwise, they are hidden. | +| `nomods` | `false` | If `true`, mods will not be loaded from `rml_mods`. | +| `nolibraries` | `false` | If `true`, extra libraries from `rml_libs` will not be loaded. | +| `advertiseversion` | `false` | If `false`, your version will be spoofed and will resemble `2023.9.26.304`. If `true`, your version will be left unaltered and will resemble `2023.9.26.304+ResoniteModLoader.dll`. This version string is visible to other players under certain circumstances. | +| `unsafe` | `false` | If `true`, the version spoofing safety check is disabled and it will still work even if you have other Resonite plugins. DO NOT load plugin components in multiplayer sessions, as it will break things and cause crashes. Plugin components should only be used in your local home or userspace. | +| `logconflicts` | `true` | If `false`, conflict logging will be disabled. If `true`, potential mod conflicts will be logged. If `debug` is also `true` this will be more verbose. | +| `hidemodtypes` | `true` | If `true`, mod-related types will be hidden in-game. If `false`, no types will be hidden, which makes RML detectable in-game. | +| `hidelatetypes` | `true` | If `true` and `hidemodtypes` is `true`, late loaded types will be hidden in-game. If `false`, late loaded types will be shown | \ No newline at end of file diff --git a/doc/start_resonite.bat b/doc/start_resonite.bat new file mode 100644 index 0000000..34f674b --- /dev/null +++ b/doc/start_resonite.bat @@ -0,0 +1 @@ +@start Resonite.exe -LoadAssembly "Libraries\ResoniteModLoader.dll" diff --git a/doc/troubleshooting.md b/doc/troubleshooting.md new file mode 100644 index 0000000..6d52c16 --- /dev/null +++ b/doc/troubleshooting.md @@ -0,0 +1,134 @@ +# Troubleshooting ResoniteModLoader + +Below we will go over some common problems and their solutions. + +## ResoniteModLoader Isn't Being Loaded + +**Symptoms:** + +- After starting the game nothing has changed, and it appears completely unmodified. +- Logs don't say anything about "ResoniteModLoader" + +**Fix:** + +If the problem is the `-LoadAssembly` setup: + +1. Check the logs (`C:\Program Files (x86)\Steam\steamapps\common\Resonite\Logs`). If you search the log for "ResoniteModLoader" you should find a section that looks like this: + + ```log + 2:12:22 AM.869 ( -1 FPS) Computing compatibility hash... + 2:12:22 AM.872 ( -1 FPS) Loaded Extra Assembly: Libraries\ResoniteModLoader.dll + 2:12:22 AM.874 ( -1 FPS) Compatibility Hash: pkJrcwZuU9k9oRlXT/DANw + ``` + + If those logs are absent it indicates you are not passing the `-LoadAssembly Libraries\ResoniteModLoader.dll` argument to Resonite correctly. +2. Double check your shortcut to Resonite. +3. If you have `ResoniteModLoader.dll` in a different folder than Libraries, you will likely need to use the absolute path like `-LoadAssembly "C:\Program Files (x86)\Steam\steamapps\common\Resonite\Libraries\ResoniteModLoader.dll"` + - Absolute paths need to be surrounded with quotation marks if they include any spaces `""` +4. Check a known-working shortcut. + 1. Navigate to the Resonite install directory. (`C:\Program Files (x86)\Steam\steamapps\common\Resonite`) + 2. Create a new text file named `start_resonite.bat` in your Resonite install directory. Make sure the file extension is `.bat` and not `.txt`. + 3. Copy the contents of the example [start_resonite.bat](start_resonite.bat) into yours. + 4. Run your `start_resonite.bat` by double-clicking it in your file explorer. + 5. Resonite should start and load ResoniteModLoader as expected. + +If the problem is the FrooxEngine.dll path on Linux: + +1. If you are on Linux, make sure you've followed the [extra Linux instructions](linux.md). + +If the problem is Windows blocking the DLL file: + +1. Right click on the ResoniteModLoader.dll file and open the properties. +2. Check the unblock checkbox, and hit OK. + ![add non-steam game screenshot](img/windows_unblock.png) +3. Repeat this process for 0Harmony.dll. + +If the problem is your antivirus: + +1. Make sure your antivirus has not quarantined or deleted ResoniteModLoader.dll or 0Harmony.dll. +2. Add an exception to your antivirus. If you're uncomfortable adding an exception, you have options: + - Don't run ResoniteModLoader. + - Change to an antivirus that has fewer false positives. + - Build ResoniteModLoader and/or Harmony yourself from source code. + +## ResoniteModLoader Loads, but Errors Out + +**Symptoms:** + +- Mods are not loading +- All of your contacts appear to be on the same version as you but showing the current versions such as `On Version 2023.9.26.304` +- All of your contacts appear to be using an incompatible version + +**Fix:** + +1. Verify that the [installation instructions](../README.md#installation) were followed correctly +2. If you are using [Linux](linux.md) builds, make sure you've followed the extra steps. +3. Check the logs (`C:\Program Files (x86)\Steam\steamapps\common\Resonite\Logs`). There are a few things you are likely to find: + +Possibility 1: Harmony is not installed correctly. + +1. Your log contains the following: + + ```log + 2:04:54 AM.013 ( -1 FPS) [ERROR][ResoniteModLoader] Exception in execution hook! +System.IO.FileNotFoundException: Could not load file or assembly '0Harmony, Version=2.2.2.0, Culture=neutral, PublicKeyToken=null' or one of its dependencies. +File name: '0Harmony, Version=2.2.2.0, Culture=neutral, PublicKeyToken=null' + at ResoniteModLoader.ExecutionHook..cctor () [0x00050] in <86a5d715b5ea4079ac09deb2b6184e56>:0 + ``` + +2. Go back to the [installation instructions](../README.md#installation) and install Harmony to the correct location. + +Possibility 2: You are using an old version of ResoniteModLoader. + +1. Check your log for a line like this: + + ```log + 3:45:43 AM.373 ( -1 FPS) [INFO] [ResoniteModLoader] ResoniteModLoader v2.4.0 starting up! + ``` + +2. Verify your ResoniteModLoader version matches [the latest release](https://github.com/resonite-modding-group/ResoniteModLoader/releases/latest). + +Possibility 3: ResoniteModLoader itself is broken, even on the latest version. This can happen in rare circumstances when Resonite updates. + +1. Please report the issue on [our Discord][Resonite Modding Discord] or in [a GitHub issue](https://github.com/resonite-modding-group/ResoniteModLoader/issues). +2. Wait for a fix. + +## Multiplayer Compatibility is Broken, but Everything Else Works + +**Symptoms:** + +- Mods are loading +- All of your contacts appear to be on the same version as you but showing the current versions such as `On Version 2023.9.26.304` +- All of your contacts appear to be using an incompatible version + +**Fix:** + +1. Make sure you are not running more than one plugin. For safety reasons, ResoniteModLoader will only spoof your version if it is the only plugin running. +2. If you absolutely need your other plugin and understand the risks there is a [configuration](modloader_config.md) available to force version spoofing. + +## A Mod is Breaking Resonite + +**Symptoms:** + +- Modded Resonite is broken or crashing unexpectedly +- Unmodified Resonite is working + +**Fix:** + +Remove the offending mod, and contact its developer so they can fix the bug. + +If you are not sure which mod is broken, follow the below steps: + +1. Check the logs (`C:\Program Files (x86)\Steam\steamapps\common\Resonite\Logs`). They should indicate which mod is failing. If the logs don't help, then continue with the following steps. +2. Disable ResoniteModLoader by removing the `-LoadAssembly Libraries\ResoniteModLoader.dll` launch option. If Resonite is still having problems while completely unmodified, you can get support on the [Resonite Discord]. **You should not ask the Resonite Discord for help with mods.** +3. If you only experience the problem while modded, try uninstalling all of your mods and re-installing them one by one. Once you find the problematic mod reach out it its developers. + - Alternatively you can re-add mods half at a time until the problem starts occuring then investigate within the smaller set of mods. +4. If the issue appears to be with ResoniteModLoader itself, please open [an issue](https://github.com/resonite-modding-group/ResoniteModLoader/issues). + +## I Need More Help + +If you are having trouble diagnosing the issue yourself, we have a #help-and-support channel in the [Resonite Modding Discord]. The first thing we're likely to ask for is your log, so please have that handy. You can find logs here: `C:\Program Files (x86)\Steam\steamapps\common\Resonite\Logs` + + +[Resonite Modding Discord]: https://discord.gg/vCDJK9xyvm +[Resonite Discord]: https://discord.gg/resonite