diff --git a/FEZ.HAT.mm.csproj b/FEZ.HAT.mm.csproj index 61842d3..3c80fbc 100644 --- a/FEZ.HAT.mm.csproj +++ b/FEZ.HAT.mm.csproj @@ -10,7 +10,7 @@ HatModLoader enable - enable + disable true diff --git a/Installers/ModMenuInstaller.cs b/Installers/ModMenuInstaller.cs index f754c9d..abf3a61 100644 --- a/Installers/ModMenuInstaller.cs +++ b/Installers/ModMenuInstaller.cs @@ -105,10 +105,10 @@ private static void CreateAndAddModLevel(object MenuBase) else { AddInactiveStringItem(null, null); - AddInactiveStringItem(null, () => Hat.Instance.Mods[modMenuCurrentIndex].Info.Name); - AddInactiveStringItem(null, () => Hat.Instance.Mods[modMenuCurrentIndex].Info.Description); - AddInactiveStringItem(null, () => $"made by {Hat.Instance.Mods[modMenuCurrentIndex].Info.Author}"); - AddInactiveStringItem(null, () => $"version {Hat.Instance.Mods[modMenuCurrentIndex].Info.Version}"); + AddInactiveStringItem(null, () => Hat.Instance.Mods[modMenuCurrentIndex].Metadata.Name); + AddInactiveStringItem(null, () => Hat.Instance.Mods[modMenuCurrentIndex].Metadata.Description); + AddInactiveStringItem(null, () => $"made by {Hat.Instance.Mods[modMenuCurrentIndex].Metadata.Author}"); + AddInactiveStringItem(null, () => $"version {Hat.Instance.Mods[modMenuCurrentIndex].Metadata.Version}"); } // add created menu level to the main menu diff --git a/Patches/Fez.cs b/Patches/Fez.cs index bce376d..2a050a8 100644 --- a/Patches/Fez.cs +++ b/Patches/Fez.cs @@ -31,7 +31,7 @@ public void ctor() protected override void Initialize() { HatML = new Hat(this); - HatML.InitalizeAssemblies(); + HatML.InitializeAssemblies(); //HatML.InitializeAssets(musicPass: false); orig_Initialize(); DrawingTools.Init(); @@ -44,7 +44,7 @@ internal static void LoadComponents(Fez game) bool doLoad = !ServiceHelper.FirstLoadDone; orig_LoadComponents(game); if (doLoad) { - HatML.InitalizeComponents(); + HatML.InitializeComponents(); } } diff --git a/Patches/FezLogo.cs b/Patches/FezLogo.cs index 604e9de..9faacda 100644 --- a/Patches/FezLogo.cs +++ b/Patches/FezLogo.cs @@ -146,7 +146,7 @@ public override void Draw(GameTime gameTime) Viewport viewport = DrawingTools.GetViewport(); int modCount = Hat.Instance.Mods.Count; - string hatText = $"HAT Mod Loader, version {Hat.Version}, {modCount} mod{(modCount != 1 ? "s" : "")} installed"; + string hatText = $"HAT Mod Loader, version {Hat.VersionString}, {modCount} mod{(modCount != 1 ? "s" : "")} installed"; if (modCount == 69) hatText += "... nice"; Color textColor = Color.Lerp(Color.White, Color.Black, alpha); @@ -154,7 +154,7 @@ public override void Draw(GameTime gameTime) float lineHeight = DrawingTools.DefaultFont.LineSpacing * DrawingTools.DefaultFontSize; - int invalidModCount = Hat.Instance.InvalidMods.Count; + int invalidModCount = Hat.Instance.InvalidModsCount; string invalidModsText = $"Could not load {invalidModCount} mod{(invalidModCount != 1 ? "s" : "")}. Check logs for more details."; DrawingTools.BeginBatch(); diff --git a/Patches/Program.cs b/Patches/Program.cs index 5f0af19..be8ca80 100644 --- a/Patches/Program.cs +++ b/Patches/Program.cs @@ -1,7 +1,6 @@ using Common; using HatModLoader.Source; using System.Globalization; -using System.Runtime.InteropServices; namespace FezGame { @@ -11,8 +10,8 @@ internal static class patch_Program private static void Main(string[] args) { - // Ensuring that dependency resolver is registered as soon as it's possible. - DependencyResolver.Register(); + // Ensuring that required dependencies can be resolved before anything else. + Hat.RegisterRequiredDependencyResolvers(); // Ensure uniform culture Thread.CurrentThread.CurrentCulture = CultureInfo.GetCultureInfo("en-GB"); diff --git a/Source/AssemblyResolving/AssemblyResolveCompability.cs b/Source/AssemblyResolving/AssemblyResolveCompability.cs new file mode 100644 index 0000000..7917483 --- /dev/null +++ b/Source/AssemblyResolving/AssemblyResolveCompability.cs @@ -0,0 +1,56 @@ +using System.Reflection; + +namespace HatModLoader.Source.AssemblyResolving +{ + internal static class AssemblyResolveCompability + { + public static bool MatchesRequest(this AssemblyName assemblyName, ResolveEventArgs args, bool allowRollForward) + { + var requestedName = new AssemblyName(args.Name); + + return assemblyName.Name == requestedName.Name && + assemblyName.CultureName == requestedName.CultureName && + ComparePublicKeyTokens(assemblyName.GetPublicKeyToken(), requestedName.GetPublicKeyToken()) && + CompareVersions(assemblyName.Version, requestedName.Version, allowRollForward); + } + + private static bool ComparePublicKeyTokens(byte[] tokenA, byte[] tokenB) + { + // Avoiding usage of stuff like SequenceEqual to prevent accidental dependency request at this stage. + + if (tokenA == null && tokenB == null) + { + return true; + } + + if (tokenA == null || tokenB == null || tokenA.Length != tokenB.Length) + { + return false; + } + + for (int i = 0; i < tokenA.Length; i++) + { + if (tokenA[i] != tokenB[i]) + { + return false; + } + } + + return true; + } + + private static bool CompareVersions(Version checkedVersion, Version requiredVersion, bool allowRollForward) + { + if (allowRollForward) + { + return checkedVersion >= requiredVersion; + } + + return checkedVersion.Major == requiredVersion.Major && + checkedVersion.Minor == requiredVersion.Minor && + checkedVersion.Build == requiredVersion.Build && + checkedVersion.Revision >= requiredVersion.Revision; + } + } +} + diff --git a/Source/AssemblyResolving/AssemblyResolverRegistry.cs b/Source/AssemblyResolving/AssemblyResolverRegistry.cs new file mode 100644 index 0000000..f629379 --- /dev/null +++ b/Source/AssemblyResolving/AssemblyResolverRegistry.cs @@ -0,0 +1,30 @@ +namespace HatModLoader.Source.AssemblyResolving +{ + internal static class AssemblyResolverRegistry + { + private static readonly HashSet RegisteredResolvers = new(); + + public static void Register(IAssemblyResolver resolver) + { + if (RegisteredResolvers.Contains(resolver)) + { + return; + } + + RegisteredResolvers.Add(resolver); + AppDomain.CurrentDomain.AssemblyResolve += resolver.ProvideAssembly; + } + + public static void Unregister(IAssemblyResolver resolver) + { + if (!RegisteredResolvers.Contains(resolver)) + { + return; + } + + RegisteredResolvers.Remove(resolver); + AppDomain.CurrentDomain.AssemblyResolve -= resolver.ProvideAssembly; + } + } +} + diff --git a/Source/AssemblyResolving/HatSubdirectoryAssemblyResolver.cs b/Source/AssemblyResolving/HatSubdirectoryAssemblyResolver.cs new file mode 100644 index 0000000..fa97161 --- /dev/null +++ b/Source/AssemblyResolving/HatSubdirectoryAssemblyResolver.cs @@ -0,0 +1,69 @@ +using System.Reflection; + +namespace HatModLoader.Source.AssemblyResolving +{ + internal class HatSubdirectoryAssemblyResolver : IAssemblyResolver + { + private static readonly string DependencyDirectory = "HATDependencies"; + + private readonly string _subdirectoryName; + + public HatSubdirectoryAssemblyResolver(string subdirectoryName) + { + this._subdirectoryName = subdirectoryName; + } + + + public Assembly ProvideAssembly(object sender, ResolveEventArgs args) + { + foreach (var file in EnumerateAssemblyFilesInSubdirectory()) + { + if (!TryGetAssemblyName(file, out var assemblyName)) + { + continue; + } + + if (assemblyName.MatchesRequest(args, true)) + { + return Assembly.LoadFrom(file); + } + } + + return null; + } + + private IEnumerable EnumerateAssemblyFilesInSubdirectory() + { + var path = Path.Combine(DependencyDirectory, _subdirectoryName); + + if (!Directory.Exists(path)) + { + yield break; + } + + foreach (var file in Directory.EnumerateFiles(path, "*.dll", SearchOption.TopDirectoryOnly)) + { + yield return file; + } + + foreach (var file in Directory.EnumerateFiles(path, "*.exe", SearchOption.TopDirectoryOnly)) + { + yield return file; + } + } + + private bool TryGetAssemblyName(string filePath, out AssemblyName assemblyName) + { + assemblyName = null; + try + { + assemblyName = AssemblyName.GetAssemblyName(filePath); + return true; + } + catch + { + return false; + } + } + } +} diff --git a/Source/AssemblyResolving/IAssemblyResolver.cs b/Source/AssemblyResolving/IAssemblyResolver.cs new file mode 100644 index 0000000..09df89f --- /dev/null +++ b/Source/AssemblyResolving/IAssemblyResolver.cs @@ -0,0 +1,10 @@ +using System.Reflection; + +namespace HatModLoader.Source.AssemblyResolving +{ + internal interface IAssemblyResolver + { + public Assembly ProvideAssembly(object sender, ResolveEventArgs args); + } +} + diff --git a/Source/AssemblyResolving/ModInternalAssemblyResolver.cs b/Source/AssemblyResolving/ModInternalAssemblyResolver.cs new file mode 100644 index 0000000..f365de9 --- /dev/null +++ b/Source/AssemblyResolving/ModInternalAssemblyResolver.cs @@ -0,0 +1,68 @@ +using System.Reflection; +using HatModLoader.Source.ModDefinition; +using Mono.Cecil; + +namespace HatModLoader.Source.AssemblyResolving +{ + internal class ModInternalAssemblyResolver : IAssemblyResolver + { + private readonly ModIdentity _mod; + + private readonly Dictionary _cachedAssemblyPaths = new(); + + public ModInternalAssemblyResolver(ModIdentity mod) + { + _mod = mod; + CacheAssemblyPaths(); + } + + public Assembly ProvideAssembly(object sender, ResolveEventArgs args) + { + if (_mod.CodeMod != null && _mod.CodeMod.Assembly.GetName().MatchesRequest(args, false)) + { + return _mod.CodeMod.Assembly; + } + + foreach(var assemblyName in _cachedAssemblyPaths.Keys) + { + if (assemblyName.MatchesRequest(args, false)) + { + using var assemblyData = _mod.FileProxy.OpenFile(_cachedAssemblyPaths[assemblyName]); + var assemblyBytes = new byte[assemblyData.Length]; + assemblyData.Read(assemblyBytes, 0, assemblyBytes.Length); + return Assembly.Load(assemblyBytes); + } + } + return null; + } + + private void CacheAssemblyPaths() + { + foreach (var filePath in EnumerateAssemblyFilesInMod()) + { + using var assemblyFile = _mod.FileProxy.OpenFile(filePath); + using var assemblyDef = AssemblyDefinition.ReadAssembly(assemblyFile, new ReaderParameters { ReadSymbols = false }); + var fullName = new AssemblyName(assemblyDef.Name.ToString()); + + if (!_cachedAssemblyPaths.ContainsKey(fullName)) + { + _cachedAssemblyPaths[fullName] = filePath; + } + } + } + + private IEnumerable EnumerateAssemblyFilesInMod() + { + foreach (var file in _mod.FileProxy.EnumerateFiles("")) + { + if ( + file.EndsWith(".dll", StringComparison.OrdinalIgnoreCase) || + file.EndsWith(".exe", StringComparison.OrdinalIgnoreCase) + ) { + yield return file; + } + } + } + } +} + diff --git a/Source/DependencyResolver.cs b/Source/DependencyResolver.cs deleted file mode 100644 index ab12fb1..0000000 --- a/Source/DependencyResolver.cs +++ /dev/null @@ -1,139 +0,0 @@ -using Common; -using System.Reflection; - -namespace HatModLoader.Source -{ - internal static class DependencyResolver - { - private static readonly string DependencyDirectory = "HATDependencies"; - - private static readonly Dictionary DependencyMap = new(); - private static readonly Dictionary DependencyCache = new(); - - public static void Register() - { - AppDomain.CurrentDomain.AssemblyResolve += ResolveAssembliesEventHandler; - } - - private static Assembly ResolveAssembliesEventHandler(object sender, ResolveEventArgs args) - { - FillInDependencyMap(args); - - Assembly assembly; - if (DependencyCache.TryGetValue(IsolateName(args.Name), out assembly)) return assembly; - if (TryResolveAssemblyFor("MonoMod", args, out assembly)) return assembly; - if (TryResolveAssemblyFor("FEZRepacker.Core", args, out assembly)) return assembly; - if (TryResolveModdedDependency(args, out assembly)) return assembly; - - Logger.Log("HAT", "Could not resolve assembly: \"" + args.Name + "\", required by \"" + args.RequestingAssembly?.FullName ?? "(none)" + "\""); - - return default!; - } - - private static void FillInDependencyMap(ResolveEventArgs args) - { - if (args.RequestingAssembly == null) return; - - var assemblyName = IsolateName(args.Name); - var requestingAssemblyName = IsolateName(args.RequestingAssembly?.FullName ?? ""); - - if (DependencyMap.ContainsKey(requestingAssemblyName)) - { - DependencyMap[assemblyName] = DependencyMap[requestingAssemblyName]; - } - else - { - DependencyMap[assemblyName] = args.RequestingAssembly!; - } - } - - private static bool TryResolveAssemblyFor(string assemblyName, ResolveEventArgs args, out Assembly assembly) - { - if (!ShouldResolveNamedFor(assemblyName, args)) - { - assembly = default!; - return false; - } - - var requiredAssemblyName = args.Name.Split(',')[0]; - var dependencyPath = Path.Combine(DependencyDirectory, assemblyName); - - foreach (var file in Directory.EnumerateFiles(dependencyPath)) - { - var fileName = Path.GetFileNameWithoutExtension(file); - - if (requiredAssemblyName == fileName) - { - assembly = Assembly.Load(File.ReadAllBytes(file)); - DependencyCache[requiredAssemblyName] = assembly; - return true; - } - } - assembly = default!; - return false; - } - - private static bool ShouldResolveNamedFor(string assemblyName, ResolveEventArgs args) - { - var requiredAssemblyName = IsolateName(args.Name); - var requestingAssemblyName = IsolateName(args.RequestingAssembly?.FullName ?? ""); - var mainRequestingAssemblyName = IsolateName(GetMainRequiringAssembly(args)?.FullName ?? ""); - - bool requiredAssemblyValid = requiredAssemblyName.Contains(assemblyName); - bool requestingAssemblyValid = requestingAssemblyName.Contains(assemblyName); - bool mainRequestingAssemblyValid = mainRequestingAssemblyName.Contains(assemblyName); - - return requiredAssemblyValid || requestingAssemblyValid || mainRequestingAssemblyValid; - } - - private static bool TryResolveModdedDependency(ResolveEventArgs args, out Assembly assembly) - { - assembly = default!; - - if (Hat.Instance == null) return false; - - var requestingMainAssembly = GetMainRequiringAssembly(args); - if (requestingMainAssembly == null) return false; - - var matchingAssembliesInMods = Hat.Instance.Mods - .Where(mod => mod.Assembly == requestingMainAssembly); - if (!matchingAssembliesInMods.Any()) return false; - - var requiredAssemblyName = IsolateName(args.Name); - var requiredAssemblyPath = requiredAssemblyName + ".dll"; - var fileProxy = matchingAssembliesInMods.First().FileProxy; - if (!fileProxy.FileExists(requiredAssemblyPath)) return false; - - using var assemblyData = fileProxy.OpenFile(requiredAssemblyPath); - var assemblyBytes = new byte[assemblyData.Length]; - assemblyData.Read(assemblyBytes, 0, assemblyBytes.Length); - assembly = Assembly.Load(assemblyBytes); - DependencyCache[requiredAssemblyName] = assembly; - return true; - } - - private static Assembly GetMainRequiringAssembly(ResolveEventArgs args) - { - var requestingMainAssembly = args.RequestingAssembly; - - if(requestingMainAssembly == null) - { - return default!; - } - - var requestingAssemblyName = IsolateName(requestingMainAssembly.FullName); - - if (DependencyMap.ContainsKey(requestingAssemblyName)) - { - requestingMainAssembly = DependencyMap[requestingAssemblyName]; - } - - return requestingMainAssembly; - } - - private static string IsolateName(string fullAssemblyQualifier) - { - return fullAssemblyQualifier.Split(',')[0]; - } - } -} diff --git a/Source/Hat.cs b/Source/Hat.cs index 5ee4a99..05ac080 100644 --- a/Source/Hat.cs +++ b/Source/Hat.cs @@ -1,5 +1,6 @@ using Common; using FezGame; +using HatModLoader.Source.AssemblyResolving; using HatModLoader.Source.Assets; using HatModLoader.Source.FileProxies; using HatModLoader.Source.ModDefinition; @@ -8,338 +9,203 @@ namespace HatModLoader.Source { public class Hat { - private List ignoredModNames = new(); - private List priorityModNamesList = new(); + public static readonly Version Version = new("1.2.1"); - public static Hat Instance; + private static readonly string ModsDirectory = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Mods"); - public Fez Game; - public List Mods; - public List InvalidMods; + private static readonly IList IgnoredModNames = InitializeIgnoredModsList(); - public static string Version + private static readonly IList PriorityModNames = InitializePriorityList(); + + public List Mods { get; private set; } + + public int InvalidModsCount { get; private set; } + + public static string VersionString { get { - const string version = "1.2.1"; #if DEBUG - return $"{version}-dev"; + return $"{Version}-dev"; #else - return $"{version}"; + return Version.ToString(); #endif } } + public static Hat Instance { get; private set; } + + private readonly Fez _fezGame; public Hat(Fez fez) { Instance = this; - Game = fez; - - Mods = new List(); - InvalidMods = new List(); - - Logger.Log("HAT", $"HAT Mod Loader {Version}"); - PrepareMods(); + _fezGame = fez; + Initialize(); } - - private void PrepareMods() + private void Initialize() { - LoadMods(); - - if(Mods.Count == 0) + Logger.Log("HAT", $"HAT Mod Loader {VersionString}"); + + if (CheckModsFolder()) { - Logger.Log("HAT", $"No mods have been found in the directory."); - return; + if (GetModProxies(out var proxies)) + { + if (GetModList(proxies, out var mods)) + { + ResolveDependencies(mods); + LoadMods(); + return; // HAT initialized + } + } } - - InitializeIgnoredModsList(); - InitializePriorityList(); - - RemoveBlacklistedMods(); - SortModsByPriority(); - RemoveDuplicates(); - InitializeDependencies(); - FilterOutInvalidMods(); - SortModsBasedOnDependencies(); - - LogLoadedMods(); + + Logger.Log("HAT", LogSeverity.Warning, "Skip the initialization process..."); } - private void EnsureModDirectory() + private static bool CheckModsFolder() { - if (!Directory.Exists(Mod.GetModsDirectory())) + if (!Directory.Exists(ModsDirectory)) { - Logger.Log("HAT", LogSeverity.Warning, "Main mods directory not found. Creating and skipping mod loading process..."); - Directory.CreateDirectory(Mod.GetModsDirectory()); - return; + Logger.Log("HAT", LogSeverity.Warning,"'Mods' directory not found. Creating it..."); + Directory.CreateDirectory(ModsDirectory); + return false; } + + return true; } - private void LoadMods() + private static bool GetModProxies(out IEnumerable proxies) { - Mods.Clear(); - - EnsureModDirectory(); - - var modProxies = EnumerateFileProxiesInModsDirectory(); - - foreach (var proxy in modProxies) - { - bool loadingState = Mod.TryLoad(this, proxy, out Mod mod); - if (loadingState) + proxies = new IEnumerable[] { - Mods.Add(mod); + DirectoryFileProxy.EnumerateInDirectory(ModsDirectory), + ZipFileProxy.EnumerateInDirectory(ModsDirectory), } - LogModLoadingState(mod, loadingState); - } - } + .SelectMany(x => x); - private static IEnumerable EnumerateFileProxiesInModsDirectory() - { - var modsDir = Mod.GetModsDirectory(); - - return new IEnumerable[] + if (!proxies.Any()) { - DirectoryFileProxy.EnumerateInDirectory(modsDir), - ZipFileProxy.EnumerateInDirectory(modsDir), + Logger.Log("HAT", LogSeverity.Warning, "There are no mods inside 'Mods' directory."); + return false; } - .SelectMany(x => x); + + return true; } - private void LogModLoadingState(Mod mod, bool loadState) + private static bool GetModList(in IEnumerable proxies, out IList mods) { - if (loadState) - { - var libraryInfo = "no library"; - if (mod.IsCodeMod) - { - libraryInfo = $"library \"{mod.Info.LibraryName}\""; - } - var assetsText = $"{mod.Assets.Count} asset{(mod.Assets.Count != 1 ? "s" : "")}"; - Logger.Log("HAT", $"Loaded mod \"{mod.Info.Name}\" ver. {mod.Info.Version} by {mod.Info.Author} ({assetsText} and {libraryInfo})"); - } - else + mods = new List(); + foreach (var proxy in proxies.Where(fp => !IgnoredModNames.Contains(fp.ContainerName))) { - if (mod.Info.Name == null) + if (Metadata.TryLoad(proxy, out var metadata)) { - Logger.Log("HAT", LogSeverity.Warning, $"Mod \"{mod.FileProxy.ContainerName}\" does not have a valid metadata file."); - } - else if (mod.Info.LibraryName != null && mod.Info.LibraryName.Length > 0 && !mod.IsCodeMod) - { - var info = $"Mod \"{mod.Info.Name}\" has library name defined (\"{mod.Info.LibraryName}\"), but no such library was found."; - Logger.Log("HAT", LogSeverity.Warning, info); - } - else if (!mod.IsCodeMod && !mod.IsAssetMod) - { - Logger.Log("HAT", LogSeverity.Warning, $"Mod \"{mod.Info.Name}\" is empty and will not be added."); + mods.Add(new ModIdentity(proxy, metadata)); } } - } - - private void InitializeIgnoredModsList() - { - var ignoredModsNamesFilePath = Path.Combine(Mod.GetModsDirectory(), "ignorelist.txt"); - var defaultContent = - "# List of directories and zip archives to ignore when loading mods, one per line.\n" + - "# Lines starting with # will be ignored.\n\n" + - "ExampleDirectoryModName\n" + - "ExampleZipPackageName.zip\n"; - ignoredModNames = ModsTextListLoader.LoadOrCreateDefault(ignoredModsNamesFilePath, defaultContent); - } - - private void InitializePriorityList() - { - var priorityListFilePath = Path.Combine(Mod.GetModsDirectory(), "prioritylist.txt"); - var defaultContent = - "# List of directories and zip archives to prioritize during mod loading.\n" + - "# If present on this list, the mod will be loaded before other mods not listed here or listed below it,\n" + - "# including newer versions of the same mod. However, it does not override dependency ordering.\n" + - "# Lines starting with # will be ignored.\n\n" + - "ExampleDirectoryModName\n" + - "ExampleZipPackageName.zip\n"; - priorityModNamesList = ModsTextListLoader.LoadOrCreateDefault(priorityListFilePath, defaultContent); - } - - private void RemoveBlacklistedMods() - { - Mods = Mods.Where(mod => !ignoredModNames.Contains(mod.FileProxy.ContainerName)).ToList(); - } - - private int GetPriorityIndexOfMod(Mod mod) - { - var index = priorityModNamesList.IndexOf(mod.FileProxy.ContainerName); - if (index == -1) index = int.MaxValue; - - return index; - } - private void SortModsByPriority() - { - Mods.Sort((mod1, mod2) => - { - var priorityIndex1 = GetPriorityIndexOfMod(mod1); - var priorityIndex2 = GetPriorityIndexOfMod(mod2); - return priorityIndex1.CompareTo(priorityIndex2); - }); - } - - private int CompareDuplicateMods(Mod mod1, Mod mod2) - { - var priorityIndex1 = GetPriorityIndexOfMod(mod1); - var priorityIndex2 = GetPriorityIndexOfMod(mod2); - var priorityComparison = priorityIndex1.CompareTo(priorityIndex2); - if(priorityComparison != 0) + if (mods.Count < 1) { - return priorityComparison; - } - else - { - // Newest (largest) versions should be first, hence the negative sign. - return -mod1.CompareVersionsWith(mod2); + Logger.Log("HAT", LogSeverity.Warning, "There are no mods to load. Perhaps they are all in 'ignorelist.txt'."); + return false; } + + return true; } - private void RemoveDuplicates() + private void ResolveDependencies(IList mods) { - var uniqueNames = Mods.Select(mod => mod.Info.Name).Distinct().ToList(); - foreach (var modName in uniqueNames) + var resolverResult = ModDependencyResolver.Resolve(mods, PriorityModNames); + Mods = resolverResult.LoadOrder; + InvalidModsCount = resolverResult.Invalid.Count; + + foreach (var node in resolverResult.Invalid) { - var sameNamedMods = Mods.Where(mod => mod.Info.Name == modName).ToList(); - if (sameNamedMods.Count() > 1) - { - sameNamedMods.Sort(CompareDuplicateMods); - var newestMod = sameNamedMods.First(); - Logger.Log("HAT", LogSeverity.Warning, $"Multiple instances of mod {modName} detected! Leaving version {newestMod.Info.Version}"); - - foreach (var mod in sameNamedMods) - { - if (mod == newestMod) continue; - Mods.Remove(mod); - } - } + Logger.Log("HAT", $"Mod '{node.Mod.Metadata.Name}' is invalid: {node.Details}"); } - } - - private void InitializeDependencies() - { + + Logger.Log("HAT", "The loading order of mods:"); foreach (var mod in Mods) { - mod.InitializeDependencies(); + Logger.Log("HAT", $" {mod.Metadata.Name} by {mod.Metadata.Author} version {mod.Metadata.Version}"); } - - FinalizeDependencies(); } - private void FinalizeDependencies() + private void LoadMods() { - for(int i=0;i<=Mods.Count; i++) + var assetModCount = 0; + var codeModsCount = 0; + + foreach (var mod in Mods) { - if(i == Mods.Count) + if (AssetMod.TryLoad(mod.FileProxy, mod.Metadata, out var assetMod)) { - // there's no possible way to have more dependency nesting levels than the mod count. Escape! - throw new ApplicationException("Stuck in a mod dependency finalization loop!"); + mod.AssetMod = assetMod; + assetModCount += 1; } - bool noInvalidMods = true; - foreach (var mod in Mods) - { - if (mod.TryFinalizeDependencies()) continue; - - noInvalidMods = false; - } - if (noInvalidMods) + if (CodeMod.TryLoad(mod.FileProxy, mod.Metadata, out var codeMod)) { - break; + mod.CodeMod = codeMod; + codeModsCount += 1; } } - } - - private void FilterOutInvalidMods() - { - InvalidMods = Mods.Where(mod => !mod.AreDependenciesValid()).ToList(); - foreach (var invalidMod in InvalidMods) - { - LogIssuesWithInvalidMod(invalidMod); - Mods.Remove(invalidMod); - } - } - - private void LogIssuesWithInvalidMod(Mod invalidMod) - { - var delegateIssues = invalidMod.Dependencies - .Where(dep => dep.Status != ModDependencyStatus.Valid) - .Select(dependency => $"{dependency.Info.Name} ({dependency.GetStatusString()})") - .ToList(); - - string error = $"Dependency issues in mod {invalidMod.Info.Name} found: {string.Join(", ", delegateIssues)}"; - - Logger.Log("HAT", LogSeverity.Warning, error); - } - - private void SortModsBasedOnDependencies() - { - Mods.Sort((a, b) => - { - if (a.Dependencies.Where(d => d.Instance == b).Any()) return 1; - if (b.Dependencies.Where(d => d.Instance == a).Any()) return -1; - return 0; - }); - } - - private void LogLoadedMods() - { - int codeModsCount = Mods.Count(mod => mod.IsCodeMod); - int assetModsCount = Mods.Count(mod => mod.IsAssetMod); - + var modsText = $"{Mods.Count} mod{(Mods.Count != 1 ? "s" : "")}"; var codeModsText = $"{codeModsCount} code mod{(codeModsCount != 1 ? "s" : "")}"; - var assetModsText = $"{assetModsCount} asset mod{(assetModsCount != 1 ? "s" : "")}"; - + var assetModsText = $"{assetModCount} asset mod{(assetModCount != 1 ? "s" : "")}"; Logger.Log("HAT", $"Successfully loaded {modsText} ({codeModsText} and {assetModsText})"); - - Logger.Log("HAT", $"Mods in their order of appearance:"); - - foreach (var mod in Mods) - { - Logger.Log("HAT", $" {mod.Info.Name} by {mod.Info.Author} version {mod.Info.Version}"); - } } - - internal void InitalizeAssemblies() + + public void InitializeAssemblies() { foreach (var mod in Mods) { - mod.InitializeAssembly(); - } - foreach (var mod in Mods) - { - mod.InitializeComponents(); + mod.Initialize(_fezGame); } - Logger.Log("HAT", "Assembly initialization completed!"); } - internal List GetFullAssetList() + public void InitializeComponents() { - var list = new List(); - foreach (var mod in Mods) { - list.AddRange(mod.Assets); + mod.InjectComponents(); } + } + + public List GetFullAssetList() + { + return Mods.SelectMany(x => x.GetAssets()).ToList(); + } - return list; + public static void RegisterRequiredDependencyResolvers() + { + AssemblyResolverRegistry.Register(new HatSubdirectoryAssemblyResolver("MonoMod")); + AssemblyResolverRegistry.Register(new HatSubdirectoryAssemblyResolver("FEZRepacker.Core")); } - internal void InitalizeComponents() + private static IList InitializeIgnoredModsList() { - foreach(var mod in Mods) - { - mod.InjectComponents(); - } - Logger.Log("HAT", "Component initialization completed!"); + var ignoredModsNamesFilePath = Path.Combine(ModsDirectory, "ignorelist.txt"); + const string defaultContent = + "# List of directories and zip archives to ignore when loading mods, one per line.\n" + + "# Lines starting with # will be ignored.\n\n" + + "ExampleDirectoryModName\n" + + "ExampleZipPackageName.zip\n"; + return ModsTextListLoader.LoadOrCreateDefault(ignoredModsNamesFilePath, defaultContent); } + private static IList InitializePriorityList() + { + var priorityListFilePath = Path.Combine(ModsDirectory, "prioritylist.txt"); + const string defaultContent = "# List of directories and zip archives to prioritize during mod loading.\n" + + "# If present on this list, the mod will be loaded before other mods not listed here or listed below it,\n" + + "# including newer versions of the same mod. However, it does not override dependency ordering.\n" + + "# Lines starting with # will be ignored.\n\n" + + "ExampleDirectoryModName\n" + + "ExampleZipPackageName.zip\n"; + return ModsTextListLoader.LoadOrCreateDefault(priorityListFilePath, defaultContent); + } } -} +} \ No newline at end of file diff --git a/Source/ModDefinition/AssetMod.cs b/Source/ModDefinition/AssetMod.cs new file mode 100644 index 0000000..0549afd --- /dev/null +++ b/Source/ModDefinition/AssetMod.cs @@ -0,0 +1,46 @@ +using HatModLoader.Source.Assets; +using HatModLoader.Source.FileProxies; + +namespace HatModLoader.Source.ModDefinition +{ + public class AssetMod + { + private const string AssetDirectoryName = "Assets"; + + private const string AssetPakName = AssetDirectoryName + ".pak"; + + public List Assets { get; } + + private AssetMod(List assets) + { + Assets = assets; + } + + public static bool TryLoad(IFileProxy proxy, Metadata metadata, out AssetMod assetMod) + { + var files = new Dictionary(); + foreach (var filePath in proxy.EnumerateFiles(AssetDirectoryName)) + { + var relativePath = filePath.Substring(AssetDirectoryName.Length + 1).Replace("/", "\\").ToLower(); + var fileStream = proxy.OpenFile(filePath); + files.Add(relativePath, fileStream); + } + + var assets = AssetLoaderHelper.GetListFromFileDictionary(files); + if (proxy.FileExists(AssetPakName)) + { + using var pakPackage = proxy.OpenFile(AssetPakName); + assets.AddRange(AssetLoaderHelper.LoadPakPackage(pakPackage)); + } + + if (assets.Count < 1) + { + assetMod = null; + return false; + } + + assetMod = new AssetMod(assets); + return true; + } + } +} \ No newline at end of file diff --git a/Source/ModDefinition/CodeMod.cs b/Source/ModDefinition/CodeMod.cs new file mode 100644 index 0000000..60ee85c --- /dev/null +++ b/Source/ModDefinition/CodeMod.cs @@ -0,0 +1,72 @@ +using System.Reflection; +using FezEngine.Tools; +using HatModLoader.Source.AssemblyResolving; +using HatModLoader.Source.FileProxies; +using Microsoft.Xna.Framework; + +namespace HatModLoader.Source.ModDefinition +{ + public class CodeMod + { + public byte[] RawAssembly { get; } + + public Assembly Assembly { get; private set; } + + public List Components { get; private set; } + + private CodeMod(byte[] rawAssembly) + { + RawAssembly = rawAssembly; + } + + public void Initialize(Game game) + { + if (RawAssembly == null || RawAssembly.Length < 1) + { + throw new ArgumentNullException(nameof(RawAssembly), "There's not raw assembly data."); + } + + if (Assembly != null) + { + throw new InvalidOperationException("Assembly is already loaded."); + } + + Assembly = Assembly.Load(RawAssembly); + Components = []; + + foreach (var type in Assembly.GetExportedTypes()) + { + if (typeof(GameComponent).IsAssignableFrom(type) && type.IsPublic && !type.IsAbstract) + { + // NOTE: The constructor accepting the type (Game) is defined in GameComponent + var gameComponent = (GameComponent)Activator.CreateInstance(type, [game]); + Components.Add(gameComponent); + } + } + } + + public static bool TryLoad(IFileProxy proxy, Metadata metadata, out CodeMod codeMod) + { + if (string.IsNullOrEmpty(metadata.LibraryName) || + !metadata.LibraryName.EndsWith(".dll", StringComparison.InvariantCultureIgnoreCase) || + !proxy.FileExists(metadata.LibraryName)) + { + codeMod = null; + return false; + } + + using var assemblyStream = proxy.OpenFile(metadata.LibraryName); + var rawAssembly = new byte[assemblyStream.Length]; + var count = assemblyStream.Read(rawAssembly, 0, rawAssembly.Length); + + if (rawAssembly.Length != count) + { + codeMod = null; + return false; + } + + codeMod = new CodeMod(rawAssembly); + return true; + } + } +} \ No newline at end of file diff --git a/Source/ModDefinition/Metadata.cs b/Source/ModDefinition/Metadata.cs new file mode 100644 index 0000000..9295b33 --- /dev/null +++ b/Source/ModDefinition/Metadata.cs @@ -0,0 +1,89 @@ +using System.Xml.Serialization; +using Common; +using HatModLoader.Source.FileProxies; + +namespace HatModLoader.Source.ModDefinition +{ + [Serializable] + public struct Metadata + { + private const string ModMetadataFile = "Metadata.xml"; + + public string Name { get; set; } + + public string Description { get; set; } + + public string Author { get; set; } + + [XmlIgnore] public Version Version { get; set; } + + [XmlElement("Version")] + public string VersionString + { + get => Version.ToString(); + set + { + if (!string.IsNullOrEmpty(value) && Version.TryParse(value, out var version)) + { + Version = version; + } + } + } + + public string LibraryName { get; set; } + + public DependencyInfo[] Dependencies { get; set; } + + public static bool TryLoad(IFileProxy proxy, out Metadata metadata) + { + if (!proxy.FileExists(ModMetadataFile)) + { + metadata = default; + return false; + } + + try + { + using var stream = proxy.OpenFile(ModMetadataFile); + using var reader = new StreamReader(stream); + + var serializer = new XmlSerializer(typeof(Metadata)); + metadata = (Metadata)serializer.Deserialize(reader); + if (string.IsNullOrEmpty(metadata.Name) || metadata.Version == null) + { + metadata = default; + return false; + } + + return true; + } + catch (Exception ex) + { + Logger.Log("HAT", LogSeverity.Warning, $"Failed to load mod metadata: {ex.Message}"); + metadata = default; + return false; + } + } + + [Serializable] + public struct DependencyInfo + { + [XmlAttribute] public string Name { get; set; } + + [XmlIgnore] public Version MinimumVersion { get; set; } + + [XmlAttribute("MinimumVersion")] + public string MinimumVersionString + { + get => MinimumVersion.ToString(); + set + { + if (!string.IsNullOrEmpty(value) && Version.TryParse(value, out var version)) + { + MinimumVersion = version; + } + } + } + } + } +} \ No newline at end of file diff --git a/Source/ModDefinition/Mod.cs b/Source/ModDefinition/Mod.cs deleted file mode 100644 index f862970..0000000 --- a/Source/ModDefinition/Mod.cs +++ /dev/null @@ -1,204 +0,0 @@ -using Common; -using FezEngine.Tools; -using HatModLoader.Source.Assets; -using HatModLoader.Source.FileProxies; -using Microsoft.Xna.Framework; -using System.Reflection; - -namespace HatModLoader.Source.ModDefinition -{ - public class Mod : IDisposable - { - public static readonly string ModsDirectoryName = "Mods"; - - public static readonly string AssetsDirectoryName = "Assets"; - public static readonly string ModMetadataFileName = "Metadata.xml"; - - public Hat ModLoader; - - public byte[] RawAssembly { get; private set; } - public Assembly Assembly { get; private set; } - public ModMetadata Info { get; private set; } - public IFileProxy FileProxy { get; private set; } - public List Dependencies { get; private set; } - public List Assets { get; private set; } - public List Components { get; private set; } - - public bool IsAssetMod => Assets.Count > 0; - public bool IsCodeMod => RawAssembly != null; - - public Mod(Hat modLoader, IFileProxy fileProxy) - { - ModLoader = modLoader; - - RawAssembly = null; - Assembly = null; - Assets = new List(); - Components = new List(); - Dependencies = new List(); - FileProxy = fileProxy; - } - - public void InitializeComponents() - { - if (RawAssembly == null || Assembly == null) return; - - foreach (Type type in Assembly.GetExportedTypes()) - { - if (!typeof(GameComponent).IsAssignableFrom(type) || !type.IsPublic || type.IsAbstract) continue; - //Note: The constructor accepting the type (Game) is defined in GameComponent - var gameComponent = (GameComponent)Activator.CreateInstance(type, new object[] { ModLoader.Game }); - Components.Add(gameComponent); - } - - if (Components.Count > 0) - { - var countText = $"{Components.Count} component{(Components.Count != 1 ? "s" : "")}"; - Logger.Log("HAT", $"Initialized {countText} in mod \"{Info.Name}\""); - } - } - - public void InjectComponents() - { - foreach (var component in Components) - { - ServiceHelper.AddComponent(component); - } - } - - public void InitializeAssembly() - { - if (RawAssembly == null) return; - Assembly = Assembly.Load(RawAssembly); - } - - public void Dispose() - { - // TODO: dispose assets - - foreach (var component in Components) - { - ServiceHelper.RemoveComponent(component); - } - } - - public int CompareVersionsWith(Mod mod) - { - return ModMetadata.CompareVersions(Info.Version, mod.Info.Version); - } - - public void InitializeDependencies() - { - if (Info.Dependencies == null || Info.Dependencies.Count() == 0) return; - if (Dependencies.Count() == Info.Dependencies.Length) return; - - Dependencies.Clear(); - foreach (var dependencyInfo in Info.Dependencies) - { - var matchingMod = ModLoader.Mods.FirstOrDefault(mod => mod.Info.Name == dependencyInfo.Name); - var dependency = new ModDependency(dependencyInfo, matchingMod); - Dependencies.Add(dependency); - } - } - - public bool TryFinalizeDependencies() - { - foreach (var dependency in Dependencies) - { - if (dependency.TryFinalize()) continue; - else return false; - } - return true; - } - - public bool AreDependenciesFinalized() - { - return Dependencies.All(dependency => dependency.IsFinalized); - } - - public bool AreDependenciesValid() - { - if (Info.Dependencies == null) return true; // if mod has no dependencies, they are "valid" - if (Info.Dependencies.Count() != Dependencies.Count()) return false; - - return Dependencies.All(dependency => dependency.Status == ModDependencyStatus.Valid); - } - - public static bool TryLoad(Hat modLoader, IFileProxy fileProxy, out Mod mod) - { - mod = new Mod(modLoader, fileProxy); - - if (!mod.TryLoadMetadata()) return false; - - mod.TryLoadAssets(); - mod.TryLoadAssembly(); - - return mod.IsAssetMod || mod.IsCodeMod; - } - - public static string GetModsDirectory() - { - return Path.Combine(AppDomain.CurrentDomain.BaseDirectory, ModsDirectoryName); - } - - private bool TryLoadMetadata() - { - if (!FileProxy.FileExists(ModMetadataFileName)) - { - return false; - } - - using var metadataStream = FileProxy.OpenFile(ModMetadataFileName); - if (!ModMetadata.TryLoadFrom(metadataStream, out var metadata)) - { - return false; - } - - Info = metadata; - - return true; - } - - private bool TryLoadAssets() - { - var files = new Dictionary(); - - foreach (var filePath in FileProxy.EnumerateFiles(AssetsDirectoryName)) - { - var relativePath = filePath.Substring(AssetsDirectoryName.Length + 1).Replace("/", "\\").ToLower(); - var fileStream = FileProxy.OpenFile(filePath); - files.Add(relativePath, fileStream); - } - - Assets = AssetLoaderHelper.GetListFromFileDictionary(files); - - var pakPackagePath = AssetsDirectoryName + ".pak"; - if (FileProxy.FileExists(pakPackagePath)) - { - using var pakPackage = FileProxy.OpenFile(pakPackagePath); - Assets.AddRange(AssetLoaderHelper.LoadPakPackage(pakPackage)); - } - - return Assets.Count > 0; - } - - private bool TryLoadAssembly() - { - if(!IsLibraryNameValid()) return false; - - if (!FileProxy.FileExists(Info.LibraryName)) return false; - - using var assemblyStream = FileProxy.OpenFile(Info.LibraryName); - RawAssembly = new byte[assemblyStream.Length]; - assemblyStream.Read(RawAssembly, 0, RawAssembly.Length); - - return true; - } - - private bool IsLibraryNameValid() - { - var libraryName = Info.LibraryName; - return libraryName != null && libraryName.Length > 0 && libraryName.EndsWith(".dll", StringComparison.OrdinalIgnoreCase); - } - } -} diff --git a/Source/ModDefinition/ModDependency.cs b/Source/ModDefinition/ModDependency.cs deleted file mode 100644 index 9e36e47..0000000 --- a/Source/ModDefinition/ModDependency.cs +++ /dev/null @@ -1,111 +0,0 @@ -using System.Linq; - -namespace HatModLoader.Source.ModDefinition -{ - [Serializable] - public struct ModDependency - { - public ModDependencyInfo Info; - public Mod Instance; - public ModDependencyStatus Status; - public bool IsModLoaderDependency => Info.Name == "HAT"; - public bool IsFinalized => Status != ModDependencyStatus.None; - public string DetectedVersion => IsModLoaderDependency ? Hat.Version : Instance != null ? Instance.Info.Version : null; - - - public ModDependency(ModDependencyInfo info, Mod instance) - { - Info = info; - Instance = instance; - Status = ModDependencyStatus.None; - - Initialize(); - } - public void Initialize() - { - if (IsModLoaderDependency || Instance != null) - { - if (ModMetadata.CompareVersions(DetectedVersion, Info.MinimumVersion) < 0) - { - Status = ModDependencyStatus.InvalidVersion; - } - else - { - Status = ModDependencyStatus.Valid; - } - } - - if (!IsModLoaderDependency) - { - if (Instance == null) - { - Status = ModDependencyStatus.InvalidNotFound; - } - else if (Instance.AreDependenciesValid()) - { - Status = ModDependencyStatus.Valid; - } - - if (IsRecursive()) - { - Status = ModDependencyStatus.InvalidRecursive; - } - } - } - - public bool TryFinalize() - { - if (IsModLoaderDependency) return true; - - if (!Instance.AreDependenciesFinalized()) return false; - - Status = - Instance.AreDependenciesValid() - ? ModDependencyStatus.Valid - : ModDependencyStatus.InvalidDependencyTree; - - return true; - } - - public bool IsRecursive() - { - var currentModQueue = new List() { Instance }; - - var iterationsCount = Instance.ModLoader.Mods.Count(); - - while (currentModQueue.Count > 0) - { - var newDependencyMods = currentModQueue.SelectMany(mod => mod.Dependencies).Select(dep => dep.Instance).ToList(); - if (newDependencyMods.Contains(Instance)) - { - return true; - } - - currentModQueue = newDependencyMods; - - iterationsCount--; - - if (iterationsCount <= 0) - { - break; - } - } - - return false; - } - - public string GetStatusString() - { - return Status switch - { - ModDependencyStatus.Valid => $"valid", - ModDependencyStatus.InvalidVersion => $"needs version >={Info.MinimumVersion}, found {DetectedVersion}", - ModDependencyStatus.InvalidNotFound => $"not found", - ModDependencyStatus.InvalidRecursive => $"recursive dependency - consider merging mods or separating it into modules", - ModDependencyStatus.InvalidDependencyTree => $"couldn't load its own dependencies", - _ => "unknown" - }; - } - - } -} diff --git a/Source/ModDefinition/ModDependencyGraph.cs b/Source/ModDefinition/ModDependencyGraph.cs new file mode 100644 index 0000000..4ace89c --- /dev/null +++ b/Source/ModDefinition/ModDependencyGraph.cs @@ -0,0 +1,60 @@ +namespace HatModLoader.Source.ModDefinition +{ + public class ModDependencyGraph + { + private readonly Dictionary _nodes = new(StringComparer.OrdinalIgnoreCase); + + public void AddNode(ModIdentity mod) + { + var name = mod.Metadata.Name; + if (_nodes.TryGetValue(name, out var existing)) + { + if (mod.Metadata.Version > existing.Mod.Metadata.Version) + { + _nodes[name] = new Node(mod); + } + } + else + { + _nodes[name] = new Node(mod); + } + } + + public bool TryGetNode(string name, out Node node) + { + return _nodes.TryGetValue(name, out node); + } + + public IEnumerable Nodes => _nodes.Values; + + public class Node + { + public ModIdentity Mod { get; } + + public List Dependencies { get; } = []; + + public ModDependencyStatus Status { get; private set; } = ModDependencyStatus.Valid; + + public string Details { get; private set; } + + public Node(ModIdentity mod) + { + Mod = mod; + } + + public void MarkInvalid(ModDependencyStatus status, string details) + { + if (Status == ModDependencyStatus.Valid && status != ModDependencyStatus.Valid) + { + Status = status; + Details = details; + } + } + + public override string ToString() + { + return Mod.Metadata.Name; + } + } + } +} diff --git a/Source/ModDefinition/ModDependencyInfo.cs b/Source/ModDefinition/ModDependencyInfo.cs deleted file mode 100644 index 66d966c..0000000 --- a/Source/ModDefinition/ModDependencyInfo.cs +++ /dev/null @@ -1,11 +0,0 @@ -using System.Xml.Serialization; - -namespace HatModLoader.Source.ModDefinition -{ - [Serializable] - public struct ModDependencyInfo - { - [XmlAttribute] public string Name; - [XmlAttribute] public string MinimumVersion; - } -} diff --git a/Source/ModDefinition/ModDependencyResolver.cs b/Source/ModDefinition/ModDependencyResolver.cs new file mode 100644 index 0000000..436b94b --- /dev/null +++ b/Source/ModDefinition/ModDependencyResolver.cs @@ -0,0 +1,256 @@ +using HatModLoader.Source.FileProxies; + +namespace HatModLoader.Source.ModDefinition +{ + public static class ModDependencyResolver + { + private const string HatDependencyName = "HAT"; + + public static ResolverResult Resolve(IList mods, IList priorityList) + { + var graph = new ModDependencyGraph(); + var rejected = new List(); + + // Validate HAT dependency and build graph nodes + foreach (var mod in mods) + { + var status = ValidateHatDependency(mod, out var details); + if (status != ModDependencyStatus.Valid) + { + var node = new ModDependencyGraph.Node(mod); + node.MarkInvalid(status, details); + rejected.Add(node); + } + else + { + graph.AddNode(mod); + } + } + + // Build edges and validate dependencies + foreach (var node in graph.Nodes) + { + BuildEdges(graph, node); + } + + // Topological sort with cycle detection + var loadOrder = TopologicalSort(graph, priorityList); + + // Collect invalid nodes + var invalid = rejected + .Concat(graph.Nodes.Where(n => n.Status != ModDependencyStatus.Valid)) + .ToList(); + + return new ResolverResult(loadOrder, invalid); + } + + private static ModDependencyStatus ValidateHatDependency(ModIdentity mod, out string details) + { + var deps = mod.Metadata.Dependencies; + if (deps == null) + { + details = "No dependencies declared, HAT dependency required"; + return ModDependencyStatus.InvalidNotFound; + } + + var hatDependency = deps.FirstOrDefault(d => IsHatDependency(d.Name)); + if (string.IsNullOrEmpty(hatDependency.Name)) + { + details = "HAT dependency not declared"; + return ModDependencyStatus.InvalidNotFound; + } + + if (hatDependency.MinimumVersion != null && Hat.Version < hatDependency.MinimumVersion) + { + details = $"Requires HAT >={hatDependency.MinimumVersion}, found {Hat.Version}"; + return ModDependencyStatus.InvalidVersion; + } + + details = string.Empty; + return ModDependencyStatus.Valid; + } + + private static void BuildEdges(ModDependencyGraph graph, ModDependencyGraph.Node node) + { + var deps = node.Mod.Metadata.Dependencies; + if (deps == null) + { + return; + } + + foreach (var dep in deps.Where(d => !IsHatDependency(d.Name))) + { + if (!graph.TryGetNode(dep.Name, out var depNode)) + { + node.MarkInvalid(ModDependencyStatus.InvalidNotFound, $"Missing dependency '{dep.Name}'"); + return; + } + + if (dep.MinimumVersion != null && depNode.Mod.Metadata.Version < dep.MinimumVersion) + { + node.MarkInvalid(ModDependencyStatus.InvalidVersion, + $"'{dep.Name}' requires >={dep.MinimumVersion}, found {depNode.Mod.Metadata.Version}"); + return; + } + + node.Dependencies.Add(depNode); + } + } + + private static List TopologicalSort(ModDependencyGraph graph, IList priorityList) + { + var result = new List(); + var visited = new HashSet(StringComparer.OrdinalIgnoreCase); + var path = new List(); + var pathSet = new HashSet(StringComparer.OrdinalIgnoreCase); + var stack = new Stack(); + + // Order nodes by priority list to influence traversal order + var orderedNodes = graph.Nodes + .OrderBy(n => GetPriority(n.Mod.FileProxy, priorityList)) + .ToList(); + + foreach (var startNode in orderedNodes) + { + if (startNode.Status != ModDependencyStatus.Valid || visited.Contains(startNode.Mod.Metadata.Name)) + { + continue; + } + + stack.Push(new Progress(startNode, false)); + while (stack.Count > 0) + { + var (node, processed) = stack.Pop(); + var name = node.Mod.Metadata.Name; + + if (processed) + { + path.RemoveAt(path.Count - 1); + pathSet.Remove(name); + + foreach (var dep in node.Dependencies) + { + if (dep.Status != ModDependencyStatus.Valid && node.Status == ModDependencyStatus.Valid) + { + node.MarkInvalid(ModDependencyStatus.InvalidDependencyTree, + $"Depends on '{dep.Mod.Metadata.Name}' which is invalid"); + break; + } + } + + if (node.Status == ModDependencyStatus.Valid) + { + visited.Add(name); + result.Add(node.Mod); + } + continue; + } + + if (visited.Contains(name)) + { + continue; + } + + if (node.Status != ModDependencyStatus.Valid) + { + PropagateInvalid(node, path); + continue; + } + + if (pathSet.Contains(name)) + { + MarkCycle(node, path); + continue; + } + + path.Add(node); + pathSet.Add(name); + stack.Push(new Progress(node, true)); + + var orderedDeps = node.Dependencies + .OrderByDescending(dep => GetPriority(dep.Mod.FileProxy, priorityList)) // because of LIFO + .ToList(); + + foreach (var dep in orderedDeps) + { + if (!visited.Contains(dep.Mod.Metadata.Name)) + { + stack.Push(new Progress(dep, false)); + } + } + } + } + + return result; + } + + private static int GetPriority(IFileProxy proxy, IList priorityList) + { + var index = priorityList + .Select((name, i) => new { name, i }) + .FirstOrDefault(x => string.Equals(x.name, proxy.ContainerName, StringComparison.OrdinalIgnoreCase)) + ?.i; + + return index ?? int.MaxValue; // Unlisted mods go last + } + + private static void MarkCycle(ModDependencyGraph.Node cycleNode, List path) + { + var cycleName = cycleNode.Mod.Metadata.Name; + var cycleStart = path.FindIndex(n => + string.Equals(n.Mod.Metadata.Name, cycleName, StringComparison.OrdinalIgnoreCase)); + + if (cycleStart < 0) + { + cycleStart = 0; + } + + var cyclePath = path.Skip(cycleStart).ToList(); + var cycleChain = string.Join(" -> ", cyclePath.Select(n => n.Mod.Metadata.Name)) + " -> " + cycleName; + + // Mark all nodes in the cycle + foreach (var node in cyclePath) + { + node.MarkInvalid(ModDependencyStatus.InvalidRecursive, cycleChain); + } + + // Mark nodes before the cycle as having invalid dependency tree + for (var i = 0; i < cycleStart; i++) + { + var node = path[i]; + node.MarkInvalid(ModDependencyStatus.InvalidDependencyTree, + $"Depends on '{path[i + 1].Mod.Metadata.Name}' which has a circular dependency"); + } + } + + private static void PropagateInvalid(ModDependencyGraph.Node invalidNode, List path) + { + var invalidName = invalidNode.Mod.Metadata.Name; + foreach (var node in path) + { + node.MarkInvalid(ModDependencyStatus.InvalidDependencyTree, + $"Depends on '{invalidName}' which is invalid"); + } + } + + private static bool IsHatDependency(string name) + { + return string.Equals(name, HatDependencyName, StringComparison.OrdinalIgnoreCase); + } + + private record struct Progress(ModDependencyGraph.Node Node, bool Processed); + } + + public class ResolverResult + { + public List LoadOrder { get; } + + public List Invalid { get; } + + public ResolverResult(List loadOrder, List invalid) + { + LoadOrder = loadOrder; + Invalid = invalid; + } + } +} diff --git a/Source/ModDefinition/ModDependencyStatus.cs b/Source/ModDefinition/ModDependencyStatus.cs index b4397b6..3a4dcdf 100644 --- a/Source/ModDefinition/ModDependencyStatus.cs +++ b/Source/ModDefinition/ModDependencyStatus.cs @@ -2,11 +2,10 @@ { public enum ModDependencyStatus { - None, Valid, InvalidVersion, InvalidNotFound, InvalidRecursive, InvalidDependencyTree } -} +} \ No newline at end of file diff --git a/Source/ModDefinition/ModIdentity.cs b/Source/ModDefinition/ModIdentity.cs new file mode 100644 index 0000000..422c82f --- /dev/null +++ b/Source/ModDefinition/ModIdentity.cs @@ -0,0 +1,62 @@ +using FezEngine.Tools; +using HatModLoader.Source.AssemblyResolving; +using HatModLoader.Source.Assets; +using HatModLoader.Source.FileProxies; +using Microsoft.Xna.Framework; + +namespace HatModLoader.Source.ModDefinition; + +public class ModIdentity : IDisposable +{ + public IFileProxy FileProxy { get; } + + public Metadata Metadata { get; } + + public AssetMod AssetMod { get; set; } + + public CodeMod CodeMod { get; set; } + + private IAssemblyResolver _assemblyResolver; + + public ModIdentity(IFileProxy fileProxy, Metadata metadata) + { + FileProxy = fileProxy; + Metadata = metadata; + } + + public void Initialize(Game game) + { + if (CodeMod != null) + { + _assemblyResolver = new ModInternalAssemblyResolver(this); + AssemblyResolverRegistry.Register(_assemblyResolver); + CodeMod?.Initialize(game); + } + } + + public void InjectComponents() + { + foreach (var component in CodeMod?.Components ?? []) + { + ServiceHelper.AddComponent(component); + } + } + + public List GetAssets() + { + return AssetMod?.Assets ?? []; + } + + public void Dispose() + { + foreach (var component in CodeMod?.Components ?? []) + { + ServiceHelper.RemoveComponent(component); + } + + if (_assemblyResolver != null) + { + AssemblyResolverRegistry.Unregister(_assemblyResolver); + } + } +} \ No newline at end of file diff --git a/Source/ModDefinition/ModIdentityHelper.cs b/Source/ModDefinition/ModIdentityHelper.cs deleted file mode 100644 index 57c31a9..0000000 --- a/Source/ModDefinition/ModIdentityHelper.cs +++ /dev/null @@ -1,17 +0,0 @@ -using Microsoft.Xna.Framework; - -namespace HatModLoader.Source.ModDefinition -{ - public static class ModIdentityHelper - { - public static Mod GetModByGameComponent(this Hat hat) where T : GameComponent - { - return hat.Mods.Where(mod => mod.Components.Any(component => component is T)).FirstOrDefault(); - } - - public static Mod GetOwnMod(this GameComponent gameComponent) - { - return Hat.Instance.Mods.Where(mod => mod.Components.Contains(gameComponent)).FirstOrDefault(); - } - } -} diff --git a/Source/ModDefinition/ModMetadata.cs b/Source/ModDefinition/ModMetadata.cs deleted file mode 100644 index 89b9dcd..0000000 --- a/Source/ModDefinition/ModMetadata.cs +++ /dev/null @@ -1,62 +0,0 @@ -using Common; -using System.Text.RegularExpressions; -using System.Xml.Serialization; - -namespace HatModLoader.Source.ModDefinition -{ - [Serializable] - [XmlType(TypeName = "Metadata")] - public struct ModMetadata - { - public string Name; - public string Description; - public string Author; - public string Version; - public string LibraryName; - public ModDependencyInfo[] Dependencies; - - public static bool TryLoadFrom(Stream stream, out ModMetadata metadata) - { - try - { - var serializer = new XmlSerializer(typeof(ModMetadata)); - using var reader = new StreamReader(stream); - metadata = (ModMetadata)serializer.Deserialize(reader); - - if (metadata.Name == null || metadata.Name.Length == 0) return false; - if (metadata.Version == null || metadata.Version.Length == 0) return false; - - return true; - } - catch (Exception ex) - { - Logger.Log("HAT", LogSeverity.Warning, $"Failed to load mod metadata: {ex.Message}"); - metadata = default; - return false; - } - } - - public static int CompareVersions(string ver1, string ver2) - { - string tokensPattern = @"(\d+|\D+)"; - string[] TokensVer1 = Regex.Split(ver1, tokensPattern); - string[] TokensVer2 = Regex.Split(ver2, tokensPattern); - - for (int i = 0; i < Math.Min(TokensVer1.Length, TokensVer2.Length); i++) - { - if (int.TryParse(TokensVer1[i], out int tokenInt1) && int.TryParse(TokensVer2[i], out int tokenInt2)) - { - if (tokenInt1 > tokenInt2) return 1; - if (tokenInt1 < tokenInt2) return -1; - continue; - } - int comparison = TokensVer1[i].CompareTo(TokensVer2[i]); - if (comparison < 0) return 1; - if (comparison > 0) return -1; - } - if (TokensVer1.Length > TokensVer2.Length) return 1; - if (TokensVer1.Length < TokensVer2.Length) return -1; - return 0; - } - } -}