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