Skip to content
This repository has been archived by the owner on Feb 28, 2024. It is now read-only.

Added late loading functionality for mods. #55

Open
wants to merge 6 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 19 additions & 9 deletions NeosModLoader/AssemblyLoader.cs
Original file line number Diff line number Diff line change
Expand Up @@ -64,23 +64,33 @@ internal static class AssemblyLoader
return assembly;
}

internal static AssemblyFile? LoadAssemblyFile(string filepath)
{
try
{
if (LoadAssembly(filepath) is Assembly assembly)
{
return new AssemblyFile(filepath, assembly);
}
}
catch (Exception e)
{
Logger.ErrorInternal($"Unexpected exception loading assembly from {filepath}:\n{e}");
}
return null;
}

internal static AssemblyFile[] LoadAssembliesFromDir(string dirName)
{
List<AssemblyFile> 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)
AssemblyFile? loadedAssemblyFile = LoadAssemblyFile(assemblyFilepath);
if (loadedAssemblyFile != null)
{
Logger.ErrorInternal($"Unexpected exception loading assembly from {assemblyFilepath}:\n{e}");
assemblyFiles.Add(loadedAssemblyFile);
}
}
}
Expand Down
1 change: 1 addition & 0 deletions NeosModLoader/ExecutionHook.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ static ExecutionHook()
SplashChanger.SetCustom("Initializing");
DebugInfo.Log();
NeosVersionReset.Initialize();
ModLoader.WatchModsDirectory();
ModLoader.LoadMods();
SplashChanger.SetCustom("Loaded");
}
Expand Down
186 changes: 140 additions & 46 deletions NeosModLoader/ModLoader.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ public class ModLoader
private static readonly List<LoadedNeosMod> LoadedMods = new(); // used for mod enumeration
internal static readonly Dictionary<Assembly, NeosMod> AssemblyLookupMap = new(); // used for logging
private static readonly Dictionary<string, LoadedNeosMod> ModNameLookupMap = new(); // used for duplicate mod checking
private static FileSystemWatcher modDirWatcher = new(@"./nml_mods", "*.dll");

/// <summary>
/// Allows reading metadata for all loaded mods
Expand Down Expand Up @@ -57,38 +58,7 @@ internal static void LoadMods()
// call Initialize() each mod
foreach (AssemblyFile mod in modsToLoad)
{
try
{
LoadedNeosMod? 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}");
}
TryLoadMod(mod, true);
}

SplashChanger.SetCustom("Hooking big fish");
Expand All @@ -99,7 +69,7 @@ internal static void LoadMods()
{
try
{
HookMod(mod);
HookMod(mod, true);
}
catch (Exception e)
{
Expand All @@ -111,27 +81,84 @@ internal static void LoadMods()
if (config.LogConflicts)
{
SplashChanger.SetCustom("Looking for conflicts");
logPotentialConflicts(config);
}
}

private static void logPotentialConflicts(ModLoaderConfiguration config)
{
IEnumerable<MethodBase> patchedMethods = Harmony.GetAllPatchedMethods();
foreach (MethodBase patchedMethod in patchedMethods)
{
Patches patches = Harmony.GetPatchInfo(patchedMethod);
HashSet<string> 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}\"");
}
}
}

IEnumerable<MethodBase> patchedMethods = Harmony.GetAllPatchedMethods();
foreach (MethodBase patchedMethod in patchedMethods)
/// <summary>
/// Tries to initialize a single mod and logs any errors it encounters if it fails.
/// </summary>
/// <returns>The loaded mod or null if it failed to load it.</returns>
private static LoadedNeosMod? TryLoadMod(AssemblyFile mod, bool atStartup)
{
try
{
LoadedNeosMod? loaded = InitializeMod(mod);
if (loaded != null)
{
Patches patches = Harmony.GetPatchInfo(patchedMethod);
HashSet<string> owners = new(patches.Owners);
if (owners.Count > 1)
if (!atStartup && !loaded.NeosMod.SupportsNewOnInit())
{
Logger.WarnInternal($"method \"{patchedMethod.FullDescription()}\" has been patched by the following:");
foreach (string owner in owners)
// trying to hotload a mod that doesn't support it
ModLoaderConfiguration config = ModLoaderConfiguration.Get();
if (!config.HotloadUnsupported)
{
Logger.WarnInternal($" \"{owner}\" ({TypesForOwner(patches, owner)})");
Logger.ErrorInternal($"Cannot hotload mod that does not support it: [{loaded.NeosMod.Name}/{loaded.NeosMod.Version}]");
return null;
}
Logger.ErrorInternal($"Hotloading mod that does not support hotloading: [{loaded.NeosMod.Name}/{loaded.NeosMod.Version}]");
}
else if (config.Debug)
// if loading succeeded, then we need to register the mod
RegisterMod(loaded);
return loaded.FinishedLoading? loaded : null;
}
}
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)
{
string owner = owners.FirstOrDefault();
Logger.DebugFuncInternal(() => $"method \"{patchedMethod.FullDescription()}\" has been patched by \"{owner}\"");
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}");
}
return null;
}

/// <summary>
Expand Down Expand Up @@ -213,18 +240,85 @@ private static string TypesForOwner(Patches patches, string owner)
}
}

private static void HookMod(LoadedNeosMod mod)
private static void HookMod(LoadedNeosMod mod, bool atStartup)
{
SplashChanger.SetCustom($"Starting mod [{mod.NeosMod.Name}/{mod.NeosMod.Version}]");
Logger.DebugFuncInternal(() => $"calling OnEngineInit() for [{mod.NeosMod.Name}]");
try
{
mod.NeosMod.OnEngineInit();
// check if the mod supports OnInit()
if (mod.NeosMod.SupportsNewOnInit())
{
mod.NeosMod.OnInit(atStartup);
}
else
{
mod.NeosMod.OnEngineInit();
}
}
catch (Exception e)
{
Logger.ErrorInternal($"mod {mod.NeosMod.Name} from {mod.ModAssembly.File} threw error from OnEngineInit():\n{e}");
}
}

/// <summary>
/// Hotloads a single mod at runtime.
/// Returns whether or not the mod was sucessfully loaded.
/// </summary>
/// <param name="path">The file path to the mod's .dll</param>
public static bool LoadAndInitializeNewMod(string path)
{
ModLoaderConfiguration config = ModLoaderConfiguration.Get();
if (config.NoMods)
{
Logger.DebugInternal("Mod was not hotloaded due to configuration file");
return false;
}

AssemblyFile? mod = AssemblyLoader.LoadAssemblyFile(path);
if (mod != null)
{
LoadedNeosMod? loadedMod = TryLoadMod(mod, false);
if (loadedMod != null)
{
HookMod(loadedMod, false);

// re-log potential conflicts
if (config.LogConflicts)
{
logPotentialConflicts(config);
}

// display load success to the user
if (!config.HideVisuals)
{
FrooxEngine.LoadingIndicator.ShowMessage("Loaded New Mod", $"The mod '{loadedMod.Name}' has been loaded");
}
return true;
}
}
if (!config.HideVisuals)
{
FrooxEngine.LoadingIndicator.ShowMessage("<color=#f00>Failed to Load Mod</color>", "<color=#f00>Check log for more info</color>");
}
return false;
}

internal static void WatchModsDirectory()
{
ModLoaderConfiguration config = ModLoaderConfiguration.Get();
if (config.Hotloading)
{
modDirWatcher.Created += new FileSystemEventHandler(NewModFound);
modDirWatcher.IncludeSubdirectories = true;
modDirWatcher.EnableRaisingEvents = true;
}
}

private static void NewModFound(object sender, FileSystemEventArgs e)
{
LoadAndInitializeNewMod(e.FullPath);
}
}
}
10 changes: 10 additions & 0 deletions NeosModLoader/ModLoaderConfiguration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,14 @@ internal static ModLoaderConfiguration Get()
{
_configuration.LogConflicts = false;
}
else if ("hotloading".Equals(key) && "false".Equals(value))
{
_configuration.Hotloading = false;
}
else if ("hotloadunsupported".Equals(key) && "true".Equals(value))
{
_configuration.HotloadUnsupported = true;
}
}
}
}
Expand Down Expand Up @@ -96,5 +104,7 @@ private static string GetAssemblyDirectory()
public bool NoLibraries { get; private set; } = false;
public bool AdvertiseVersion { get; private set; } = false;
public bool LogConflicts { get; private set; } = true;
public bool Hotloading { get; private set; } = true;
public bool HotloadUnsupported { get; private set; } = false;
}
}
13 changes: 12 additions & 1 deletion NeosModLoader/NeosMod.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,21 @@ public abstract class NeosMod : NeosModBase
public static void Error(params object[] messages) => Logger.ErrorListExternal(messages);

/// <summary>
/// Called once immediately after NeosModLoader begins execution
/// Is called to initialize older mods that don't support OnInit
/// </summary>
public virtual void OnEngineInit() { }

/// <summary>
/// Called once when the mod is loaded.
/// If a mod overrides this function, it signals to the modloader that it supports hotloading.
/// </summary>
public virtual void OnInit(bool atStartup) { }

public bool SupportsNewOnInit()
{
return this.GetType().GetMethod("OnInit").DeclaringType != typeof(NeosMod);
}

/// <summary>
/// Build the defined configuration for this mod.
/// </summary>
Expand Down
20 changes: 11 additions & 9 deletions doc/modloader_config.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,14 @@ 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`, NeosMod.Debug() logs will appear in your log file. Otherwise, they are hidden. |
| `hidevisuals` | `false` | If `true`, NML won't show a loading indicator on the splash screen. |
| `nomods` | `false` | If `true`, mods will not be loaded from `nml_mods`. |
| `nolibraries` | `false` | If `true`, extra libraries from `nml_libs` will not be loaded. |
| `advertiseversion` | `false` | If `false`, your version will be spoofed and will resemble `2021.8.29.1240`. If `true`, your version will be left unaltered and will resemble `2021.8.29.1240+NeosModLoader.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 Neos 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 user space. |
| `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. |
| Configuration | Default | Description |
| -------------------- | ------- | ----------- |
| `debug` | `false` | If `true`, NeosMod.Debug() logs will appear in your log file. Otherwise, they are hidden. |
| `hidevisuals` | `false` | If `true`, NML won't show a loading indicator on the splash screen and won't show a loading indicator for mods loaded while the game is running. |
| `nomods` | `false` | If `true`, mods will not be loaded from `nml_mods`. |
| `nolibraries` | `false` | If `true`, extra libraries from `nml_libs` will not be loaded. |
| `advertiseversion` | `false` | If `false`, your version will be spoofed and will resemble `2021.8.29.1240`. If `true`, your version will be left unaltered and will resemble `2021.8.29.1240+NeosModLoader.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 Neos 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 user space. |
| `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. |
| `hotloading` | `true` | If `true`, NML will watch `nml_mods` while the game is running and load any new mods that appear. (except if `nomods` is also `true`) |
| `hotloadunsupported` | `false` | If `true`, NML will hotload mods even if they do not explicitly support it. |