C# Modloader using reflection. Different behaviour when exporting

Godot Version

4.2.2 Mono

Question

Hi!

I am trying to make a mod system using reflection, but I am running into some issues.
I have my GoDot project called Game, a second .net project called ModLoader, and a third GoDot project called Mod.

ModLoader contains a public IMod interface. And a loader function which looks trough assemblies to find classes which implement this interface.
Game instances this ModLoader, and get a list of all available mods.

The Mod project has a bunch of resources and of course a class

public sealed class ModInfo : IMod

The reason the IMod interface and the loader are in a separate project are because I had issues when these where in the Game project. Most likely because GoDot does some CodeGeneration and this cause some discrepancies where it doesn’t recognize the interfaces properly.

My Loader looks as such:

    public class ModLoader
    {
        private readonly Guid _targetGuid;
        private readonly string _directory;
        public ModLoader(Guid targetGuid, string directory)
        {
            _targetGuid = targetGuid;
            _directory = directory;
        }

        public IEnumerable<LoadedMod> Load()
        {
            AppDomain.CurrentDomain.AssemblyResolve += CurrentDomain_AssemblyResolve;
            foreach (IMod mod in GetMods())
            {
                if (!mod.TargetGUID.Equals(_targetGuid))
                {
                    yield return LoadedMod.LoadFailure(mod, $"targets incorrect GUID. Expected {_targetGuid}, but was {mod.TargetGUID}");
                    continue;
                }

                yield return LoadedMod.LoadSuccess(mod);
            }
            AppDomain.CurrentDomain.AssemblyResolve -= CurrentDomain_AssemblyResolve;
        }

        private Assembly? CurrentDomain_AssemblyResolve(object? sender, ResolveEventArgs args)
        {
            var result = AppDomain.CurrentDomain.GetAssemblies().FirstOrDefault(a =>
            {
                string sourceAssembly = args.Name.Split(',').FirstOrDefault();
                string targetAssembly = a.FullName.Split(',').FirstOrDefault();
                return sourceAssembly.Equals(targetAssembly, StringComparison.OrdinalIgnoreCase);
            });
            return result;
        }

        private IEnumerable<IMod> GetMods()
        {
            foreach (var dllFile in DLLFiles())
            {
                Assembly assembly = Assembly.LoadFrom(dllFile);
                Type? type = GetMod(assembly);
                if (type != null)
                {
                    _logger?.Log(LogTypes.ModLoaderDebug, $"Creating IMod instance");
                    var result = (IMod?)Activator.CreateInstance(type);
                    if (result != null)
                    {
                        yield return result;
                    }
                }
            }
        }

        private Type? GetMod(Assembly assembly)
        {
            Type? result = null;

            foreach (Type type in assembly.GetTypes())
            {

                if (typeof(IMod).IsAssignableFrom(type) && !type.IsInterface && !type.IsAbstract)
                {
                    if (result != null)
                    {
                        return null;
                    }
                    result = type;
                }
            }

            return result;
        }
        private IEnumerable<string> DLLFiles()
        {
            return Directory.GetFiles(_directory, "*.dll", SearchOption.AllDirectories);
        }
    }

Game calls the Load function, and gets a collection of mods in return.

GetMods() iterates trough a list of DLL files. This list contains the dll file from the Mod project.
It loads this assembly, and GetMod() iterates trough all types.
If a type is assignable from IMod, it is returned as an available mod.

Now this works in GoDot when running from the editor or visual studio. But when I export the game, and start the exported binary, it does not work anymore.
It can find the types i am expecting, but for some reason typeof(IMod).IsAssignableFrom(type); returns false. When ignoring this and hardcasting it gives an InvalidCastException.

typeof(IMod).AssemblyQualifiedName and type.AssemblyQualifiedName are identical, even the versions are the same. So I see no reason this shouldn’t work.

Another thing to note is that I have made a custom Assembly Resolve function. This does some simple name checks to return an assembly. This is not entirely correct, but without it the ModLoader assembly could not be found.
But, when running the exported game, this Assembly Resolve function is not called at all. It now can find the assemblies by itself, but seemlingly there is still a mismatch.

I have messed around with different export settings, but no luck so far.
Maybe someone else has some tips to look into :slight_smile:
Thanks!

Alright, couple of hours of fidgeting later, and I seem to have figured it out thanks to Loading external DLL at runtime errors · Issue #75160 · godotengine/godot · GitHub

Instead of calling Assembly.LoadFrom(dllFile); , i’m now first retrieving the AssemblyLoadContext, and using that to load external assemblies. This also doesn’t need the custom assembly resolve function.

This works when running from de editor, from VS, and when running the exported exe.

        private IEnumerable<IMod> GetMods()
        {
            AssemblyLoadContext assemblyLoadContext = AssemblyLoadContext.GetLoadContext(Assembly.GetExecutingAssembly())
                ?? throw new NullReferenceException(nameof(assemblyLoadContext));

            foreach (var dllFile in DLLFiles())
            {
                Assembly assembly = assemblyLoadContext.LoadFromAssemblyPath(dllFile);

                Type? type = GetMod(assembly);
                if (type != null)
                {
                    var result = (IMod?)Activator.CreateInstance(type);
                    if (result != null)
                    {
                        yield return result;
                    }
                }
            }
        }

        private static IEnumerable<string> DLLFiles()
        {
            string[] relativePaths = Directory.GetFiles("Mods", "*.dll", System.IO.SearchOption.AllDirectories);

            foreach (string relativePath in relativePaths)
            {
                yield return Path.GetFullPath(relativePath);
            }
        }

There is not much info to be found on this topic in regards to GoDot, so I hope this is usefull to someone.