From 520649bac9773fa2a455d95016b88c14f725b1e0 Mon Sep 17 00:00:00 2001 From: Chris Hausky Date: Wed, 14 Jan 2026 00:20:32 +0100 Subject: [PATCH 01/10] feat: reimplement assembly resolving --- Patches/Program.cs | 5 +- .../AssemblyResolverRegistry.cs | 30 ++++ .../HatSubdirectoryAssemblyResolver.cs | 52 +++++++ Source/AssemblyResolving/IAssemblyResolver.cs | 10 ++ .../ModInternalAssemblyResolver.cs | 66 +++++++++ Source/DependencyResolver.cs | 139 ------------------ Source/Hat.cs | 6 + Source/ModDefinition/Mod.cs | 14 +- 8 files changed, 178 insertions(+), 144 deletions(-) create mode 100644 Source/AssemblyResolving/AssemblyResolverRegistry.cs create mode 100644 Source/AssemblyResolving/HatSubdirectoryAssemblyResolver.cs create mode 100644 Source/AssemblyResolving/IAssemblyResolver.cs create mode 100644 Source/AssemblyResolving/ModInternalAssemblyResolver.cs delete mode 100644 Source/DependencyResolver.cs 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/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..9b69dee --- /dev/null +++ b/Source/AssemblyResolving/HatSubdirectoryAssemblyResolver.cs @@ -0,0 +1,52 @@ + +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()) + { + var assemblyName = AssemblyName.GetAssemblyName(file); + if (assemblyName.FullName == args.Name) + { + 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; + } + } + } +} \ No newline at end of file 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..789c198 --- /dev/null +++ b/Source/AssemblyResolving/ModInternalAssemblyResolver.cs @@ -0,0 +1,66 @@ +using System.Reflection; +using HatModLoader.Source.ModDefinition; +using Mono.Cecil; + +namespace HatModLoader.Source.AssemblyResolving +{ + internal class ModInternalAssemblyResolver : IAssemblyResolver + { + private readonly Mod _mod; + + private readonly Dictionary _cachedAssemblyPaths = new(); + + public ModInternalAssemblyResolver(Mod mod) + { + _mod = mod; + CacheAssemblyPaths(); + } + + public Assembly ProvideAssembly(object sender, ResolveEventArgs args) + { + if (_mod.Assembly?.FullName == args.Name) + { + return _mod.Assembly; + } + + if (_cachedAssemblyPaths.TryGetValue(args.Name, out var assemblyPath)) + { + using var assemblyData = _mod.FileProxy.OpenFile(assemblyPath); + 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 = 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..bfa0ad0 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; @@ -341,5 +342,10 @@ internal void InitalizeComponents() Logger.Log("HAT", "Component initialization completed!"); } + public static void RegisterRequiredDependencyResolvers() + { + AssemblyResolverRegistry.Register(new HatSubdirectoryAssemblyResolver("MonoMod")); + AssemblyResolverRegistry.Register(new HatSubdirectoryAssemblyResolver("FEZRepacker.Core")); + } } } diff --git a/Source/ModDefinition/Mod.cs b/Source/ModDefinition/Mod.cs index f862970..b7ea24d 100644 --- a/Source/ModDefinition/Mod.cs +++ b/Source/ModDefinition/Mod.cs @@ -2,6 +2,7 @@ using FezEngine.Tools; using HatModLoader.Source.Assets; using HatModLoader.Source.FileProxies; +using HatModLoader.Source.AssemblyResolving; using Microsoft.Xna.Framework; using System.Reflection; @@ -15,6 +16,8 @@ public class Mod : IDisposable public static readonly string ModMetadataFileName = "Metadata.xml"; public Hat ModLoader; + + private IAssemblyResolver _internalAssemblyResolver; public byte[] RawAssembly { get; private set; } public Assembly Assembly { get; private set; } @@ -68,8 +71,13 @@ public void InjectComponents() public void InitializeAssembly() { - if (RawAssembly == null) return; - Assembly = Assembly.Load(RawAssembly); + _internalAssemblyResolver = new ModInternalAssemblyResolver(this); + AssemblyResolverRegistry.Register(_internalAssemblyResolver); + + if (RawAssembly != null) + { + Assembly = Assembly.Load(RawAssembly); + } } public void Dispose() @@ -80,6 +88,8 @@ public void Dispose() { ServiceHelper.RemoveComponent(component); } + + AssemblyResolverRegistry.Unregister(_internalAssemblyResolver); } public int CompareVersionsWith(Mod mod) From 0e9de150e0f249ec1ce3cbe19b71820a3492be21 Mon Sep 17 00:00:00 2001 From: Chris Hausky Date: Wed, 14 Jan 2026 11:23:56 +0100 Subject: [PATCH 02/10] fix: prevent nullable type detection by monomod --- FEZ.HAT.mm.csproj | 2 +- Source/AssemblyResolving/HatSubdirectoryAssemblyResolver.cs | 2 +- Source/AssemblyResolving/ModInternalAssemblyResolver.cs | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) 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/Source/AssemblyResolving/HatSubdirectoryAssemblyResolver.cs b/Source/AssemblyResolving/HatSubdirectoryAssemblyResolver.cs index 9b69dee..4941b06 100644 --- a/Source/AssemblyResolving/HatSubdirectoryAssemblyResolver.cs +++ b/Source/AssemblyResolving/HatSubdirectoryAssemblyResolver.cs @@ -26,7 +26,7 @@ public Assembly ProvideAssembly(object sender, ResolveEventArgs args) } } - return null!; + return null; } private IEnumerable EnumerateAssemblyFilesInSubdirectory() diff --git a/Source/AssemblyResolving/ModInternalAssemblyResolver.cs b/Source/AssemblyResolving/ModInternalAssemblyResolver.cs index 789c198..e62fdf2 100644 --- a/Source/AssemblyResolving/ModInternalAssemblyResolver.cs +++ b/Source/AssemblyResolving/ModInternalAssemblyResolver.cs @@ -31,7 +31,7 @@ public Assembly ProvideAssembly(object sender, ResolveEventArgs args) return Assembly.Load(assemblyBytes); } - return null!; + return null; } private void CacheAssemblyPaths() From 78d611e62c2bd17d852979dfd675394e266b678c Mon Sep 17 00:00:00 2001 From: Chris Hausky Date: Wed, 14 Jan 2026 15:01:28 +0100 Subject: [PATCH 03/10] fix: improve assembly name search and comparison --- .../AssemblyResolveCompability.cs | 56 +++++++++++++++++++ .../HatSubdirectoryAssemblyResolver.cs | 25 +++++++-- .../ModInternalAssemblyResolver.cs | 20 ++++--- 3 files changed, 88 insertions(+), 13 deletions(-) create mode 100644 Source/AssemblyResolving/AssemblyResolveCompability.cs 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/HatSubdirectoryAssemblyResolver.cs b/Source/AssemblyResolving/HatSubdirectoryAssemblyResolver.cs index 4941b06..fa97161 100644 --- a/Source/AssemblyResolving/HatSubdirectoryAssemblyResolver.cs +++ b/Source/AssemblyResolving/HatSubdirectoryAssemblyResolver.cs @@ -1,4 +1,3 @@ - using System.Reflection; namespace HatModLoader.Source.AssemblyResolving @@ -19,8 +18,12 @@ public Assembly ProvideAssembly(object sender, ResolveEventArgs args) { foreach (var file in EnumerateAssemblyFilesInSubdirectory()) { - var assemblyName = AssemblyName.GetAssemblyName(file); - if (assemblyName.FullName == args.Name) + if (!TryGetAssemblyName(file, out var assemblyName)) + { + continue; + } + + if (assemblyName.MatchesRequest(args, true)) { return Assembly.LoadFrom(file); } @@ -48,5 +51,19 @@ private IEnumerable EnumerateAssemblyFilesInSubdirectory() yield return file; } } + + private bool TryGetAssemblyName(string filePath, out AssemblyName assemblyName) + { + assemblyName = null; + try + { + assemblyName = AssemblyName.GetAssemblyName(filePath); + return true; + } + catch + { + return false; + } + } } -} \ No newline at end of file +} diff --git a/Source/AssemblyResolving/ModInternalAssemblyResolver.cs b/Source/AssemblyResolving/ModInternalAssemblyResolver.cs index e62fdf2..5b87b80 100644 --- a/Source/AssemblyResolving/ModInternalAssemblyResolver.cs +++ b/Source/AssemblyResolving/ModInternalAssemblyResolver.cs @@ -8,7 +8,7 @@ internal class ModInternalAssemblyResolver : IAssemblyResolver { private readonly Mod _mod; - private readonly Dictionary _cachedAssemblyPaths = new(); + private readonly Dictionary _cachedAssemblyPaths = new(); public ModInternalAssemblyResolver(Mod mod) { @@ -18,19 +18,21 @@ public ModInternalAssemblyResolver(Mod mod) public Assembly ProvideAssembly(object sender, ResolveEventArgs args) { - if (_mod.Assembly?.FullName == args.Name) + if (_mod.Assembly != null && _mod.Assembly.GetName().MatchesRequest(args, false)) { return _mod.Assembly; } - if (_cachedAssemblyPaths.TryGetValue(args.Name, out var assemblyPath)) + foreach(var assemblyName in _cachedAssemblyPaths.Keys) { - using var assemblyData = _mod.FileProxy.OpenFile(assemblyPath); - var assemblyBytes = new byte[assemblyData.Length]; - assemblyData.Read(assemblyBytes, 0, assemblyBytes.Length); - return Assembly.Load(assemblyBytes); + 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; } @@ -40,7 +42,7 @@ private void CacheAssemblyPaths() { using var assemblyFile = _mod.FileProxy.OpenFile(filePath); using var assemblyDef = AssemblyDefinition.ReadAssembly(assemblyFile, new ReaderParameters { ReadSymbols = false }); - var fullName = assemblyDef.Name.ToString(); + var fullName = new AssemblyName(assemblyDef.Name.ToString()); if (!_cachedAssemblyPaths.ContainsKey(fullName)) { From 09dfcae90b50d61637ebbd42382badcbf8969af1 Mon Sep 17 00:00:00 2001 From: zerocker Date: Thu, 15 Jan 2026 23:18:28 +0900 Subject: [PATCH 04/10] fix: correct deserialization of mod dependency list --- Source/ModDefinition/ModDependencyInfo.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/Source/ModDefinition/ModDependencyInfo.cs b/Source/ModDefinition/ModDependencyInfo.cs index 66d966c..4b666f4 100644 --- a/Source/ModDefinition/ModDependencyInfo.cs +++ b/Source/ModDefinition/ModDependencyInfo.cs @@ -3,6 +3,7 @@ namespace HatModLoader.Source.ModDefinition { [Serializable] + [XmlType(TypeName = "DependencyInfo")] public struct ModDependencyInfo { [XmlAttribute] public string Name; From a40f9bef16a2333afe7a31d4747876822315c0e9 Mon Sep 17 00:00:00 2001 From: zerocker Date: Thu, 15 Jan 2026 23:25:26 +0900 Subject: [PATCH 05/10] refactor: make the mod metadata class as DTO --- .../{ModMetadata.cs => Metadata.cs} | 54 ++++++++----------- Source/ModDefinition/Mod.cs | 34 ++++++++---- Source/ModDefinition/ModDependency.cs | 6 +-- Source/ModDefinition/ModDependencyInfo.cs | 12 ----- 4 files changed, 50 insertions(+), 56 deletions(-) rename Source/ModDefinition/{ModMetadata.cs => Metadata.cs} (50%) delete mode 100644 Source/ModDefinition/ModDependencyInfo.cs diff --git a/Source/ModDefinition/ModMetadata.cs b/Source/ModDefinition/Metadata.cs similarity index 50% rename from Source/ModDefinition/ModMetadata.cs rename to Source/ModDefinition/Metadata.cs index 89b9dcd..d4fa5c2 100644 --- a/Source/ModDefinition/ModMetadata.cs +++ b/Source/ModDefinition/Metadata.cs @@ -1,40 +1,22 @@ -using Common; -using System.Text.RegularExpressions; +using System.Text.RegularExpressions; using System.Xml.Serialization; namespace HatModLoader.Source.ModDefinition { [Serializable] - [XmlType(TypeName = "Metadata")] - public struct ModMetadata + public struct Metadata { - 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 string Name { get; set; } + + public string Description { get; set; } + + public string Author { get; set; } + + public string Version { get; set; } + + public string LibraryName { get; set; } + + public DependencyInfo[] Dependencies { get; set; } public static int CompareVersions(string ver1, string ver2) { @@ -58,5 +40,15 @@ public static int CompareVersions(string ver1, string ver2) if (TokensVer1.Length < TokensVer2.Length) return -1; return 0; } + + [Serializable] + public struct DependencyInfo + { + [XmlAttribute] + public string Name { get; set; } + + [XmlAttribute] + public string MinimumVersion { get; set; } + } } } diff --git a/Source/ModDefinition/Mod.cs b/Source/ModDefinition/Mod.cs index b7ea24d..73d3290 100644 --- a/Source/ModDefinition/Mod.cs +++ b/Source/ModDefinition/Mod.cs @@ -5,6 +5,8 @@ using HatModLoader.Source.AssemblyResolving; using Microsoft.Xna.Framework; using System.Reflection; +using System.Xml.Serialization; +using FezEngine.Effects; namespace HatModLoader.Source.ModDefinition { @@ -21,7 +23,7 @@ public class Mod : IDisposable public byte[] RawAssembly { get; private set; } public Assembly Assembly { get; private set; } - public ModMetadata Info { get; private set; } + public Metadata Info { get; private set; } public IFileProxy FileProxy { get; private set; } public List Dependencies { get; private set; } public List Assets { get; private set; } @@ -94,7 +96,7 @@ public void Dispose() public int CompareVersionsWith(Mod mod) { - return ModMetadata.CompareVersions(Info.Version, mod.Info.Version); + return Metadata.CompareVersions(Info.Version, mod.Info.Version); } public void InitializeDependencies() @@ -157,16 +159,28 @@ private bool TryLoadMetadata() { return false; } - - using var metadataStream = FileProxy.OpenFile(ModMetadataFileName); - if (!ModMetadata.TryLoadFrom(metadataStream, out var metadata)) + + try { - return false; + using var stream = FileProxy.OpenFile(ModMetadataFileName); + using var reader = new StreamReader(stream); + + var serializer = new XmlSerializer(typeof(Metadata)); + var metadata = (Metadata)serializer.Deserialize(reader); + + if (!string.IsNullOrEmpty(metadata.Name) && string.IsNullOrEmpty(metadata.Version)) + { + Info = metadata; + return true; + } } - - Info = metadata; - - return true; + catch (Exception ex) + { + Logger.Log("HAT", LogSeverity.Warning, $"Failed to load mod metadata: {ex.Message}"); + Info = default; + } + + return false; } private bool TryLoadAssets() diff --git a/Source/ModDefinition/ModDependency.cs b/Source/ModDefinition/ModDependency.cs index 9e36e47..b6122b0 100644 --- a/Source/ModDefinition/ModDependency.cs +++ b/Source/ModDefinition/ModDependency.cs @@ -5,7 +5,7 @@ namespace HatModLoader.Source.ModDefinition [Serializable] public struct ModDependency { - public ModDependencyInfo Info; + public Metadata.DependencyInfo Info; public Mod Instance; public ModDependencyStatus Status; public bool IsModLoaderDependency => Info.Name == "HAT"; @@ -13,7 +13,7 @@ public struct ModDependency public string DetectedVersion => IsModLoaderDependency ? Hat.Version : Instance != null ? Instance.Info.Version : null; - public ModDependency(ModDependencyInfo info, Mod instance) + public ModDependency(Metadata.DependencyInfo info, Mod instance) { Info = info; Instance = instance; @@ -25,7 +25,7 @@ public void Initialize() { if (IsModLoaderDependency || Instance != null) { - if (ModMetadata.CompareVersions(DetectedVersion, Info.MinimumVersion) < 0) + if (Metadata.CompareVersions(DetectedVersion, Info.MinimumVersion) < 0) { Status = ModDependencyStatus.InvalidVersion; } diff --git a/Source/ModDefinition/ModDependencyInfo.cs b/Source/ModDefinition/ModDependencyInfo.cs deleted file mode 100644 index 4b666f4..0000000 --- a/Source/ModDefinition/ModDependencyInfo.cs +++ /dev/null @@ -1,12 +0,0 @@ -using System.Xml.Serialization; - -namespace HatModLoader.Source.ModDefinition -{ - [Serializable] - [XmlType(TypeName = "DependencyInfo")] - public struct ModDependencyInfo - { - [XmlAttribute] public string Name; - [XmlAttribute] public string MinimumVersion; - } -} From f27d18ac5206e6255108293fdafec92bd58b454f Mon Sep 17 00:00:00 2001 From: zerocker Date: Thu, 15 Jan 2026 23:30:40 +0900 Subject: [PATCH 06/10] refactor: use Version class for version comparisons --- Patches/FezLogo.cs | 2 +- Source/Hat.cs | 13 +++--- Source/ModDefinition/Metadata.cs | 66 +++++++++++++-------------- Source/ModDefinition/Mod.cs | 7 +-- Source/ModDefinition/ModDependency.cs | 9 ++-- 5 files changed, 45 insertions(+), 52 deletions(-) diff --git a/Patches/FezLogo.cs b/Patches/FezLogo.cs index 604e9de..ce6e5fd 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); diff --git a/Source/Hat.cs b/Source/Hat.cs index bfa0ad0..a0d720a 100644 --- a/Source/Hat.cs +++ b/Source/Hat.cs @@ -17,16 +17,17 @@ public class Hat public Fez Game; public List Mods; public List InvalidMods; + + public static readonly Version Version = new("1.2.1"); - public static string Version + 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 } } @@ -40,7 +41,7 @@ public Hat(Fez fez) Mods = new List(); InvalidMods = new List(); - Logger.Log("HAT", $"HAT Mod Loader {Version}"); + Logger.Log("HAT", $"HAT Mod Loader {VersionString}"); PrepareMods(); } @@ -198,7 +199,7 @@ private int CompareDuplicateMods(Mod mod1, Mod mod2) else { // Newest (largest) versions should be first, hence the negative sign. - return -mod1.CompareVersionsWith(mod2); + return -mod1.Info.Version.CompareTo(mod2.Info.Version); } } diff --git a/Source/ModDefinition/Metadata.cs b/Source/ModDefinition/Metadata.cs index d4fa5c2..8643746 100644 --- a/Source/ModDefinition/Metadata.cs +++ b/Source/ModDefinition/Metadata.cs @@ -1,5 +1,4 @@ -using System.Text.RegularExpressions; -using System.Xml.Serialization; +using System.Xml.Serialization; namespace HatModLoader.Source.ModDefinition { @@ -7,48 +6,49 @@ namespace HatModLoader.Source.ModDefinition public struct Metadata { public string Name { get; set; } - + public string Description { get; set; } - + public string Author { get; set; } - - public string Version { get; set; } - - public string LibraryName { get; set; } - - public DependencyInfo[] Dependencies { get; set; } - public static int CompareVersions(string ver1, string ver2) - { - string tokensPattern = @"(\d+|\D+)"; - string[] TokensVer1 = Regex.Split(ver1, tokensPattern); - string[] TokensVer2 = Regex.Split(ver2, tokensPattern); + [XmlIgnore] public Version Version { get; set; } - for (int i = 0; i < Math.Min(TokensVer1.Length, TokensVer2.Length); i++) + [XmlElement("Version")] + public string VersionString + { + get => Version.ToString(); + set { - if (int.TryParse(TokensVer1[i], out int tokenInt1) && int.TryParse(TokensVer2[i], out int tokenInt2)) + if (!string.IsNullOrEmpty(value) && Version.TryParse(value, out var version)) { - if (tokenInt1 > tokenInt2) return 1; - if (tokenInt1 < tokenInt2) return -1; - continue; + Version = version; } - 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; } - + + public string LibraryName { get; set; } + + public DependencyInfo[] Dependencies { get; set; } + [Serializable] public struct DependencyInfo { - [XmlAttribute] - public string Name { get; set; } - - [XmlAttribute] - public string MinimumVersion { get; set; } + [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 index 73d3290..a34991d 100644 --- a/Source/ModDefinition/Mod.cs +++ b/Source/ModDefinition/Mod.cs @@ -94,11 +94,6 @@ public void Dispose() AssemblyResolverRegistry.Unregister(_internalAssemblyResolver); } - public int CompareVersionsWith(Mod mod) - { - return Metadata.CompareVersions(Info.Version, mod.Info.Version); - } - public void InitializeDependencies() { if (Info.Dependencies == null || Info.Dependencies.Count() == 0) return; @@ -168,7 +163,7 @@ private bool TryLoadMetadata() var serializer = new XmlSerializer(typeof(Metadata)); var metadata = (Metadata)serializer.Deserialize(reader); - if (!string.IsNullOrEmpty(metadata.Name) && string.IsNullOrEmpty(metadata.Version)) + if (!string.IsNullOrEmpty(metadata.Name) && metadata.Version != null) { Info = metadata; return true; diff --git a/Source/ModDefinition/ModDependency.cs b/Source/ModDefinition/ModDependency.cs index b6122b0..20f302a 100644 --- a/Source/ModDefinition/ModDependency.cs +++ b/Source/ModDefinition/ModDependency.cs @@ -1,6 +1,4 @@ -using System.Linq; - -namespace HatModLoader.Source.ModDefinition +namespace HatModLoader.Source.ModDefinition { [Serializable] public struct ModDependency @@ -10,8 +8,7 @@ public struct ModDependency 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 Version DetectedVersion => IsModLoaderDependency ? Hat.Version : Instance?.Info.Version; public ModDependency(Metadata.DependencyInfo info, Mod instance) { @@ -25,7 +22,7 @@ public void Initialize() { if (IsModLoaderDependency || Instance != null) { - if (Metadata.CompareVersions(DetectedVersion, Info.MinimumVersion) < 0) + if (DetectedVersion.CompareTo(Info.MinimumVersion) < 0) { Status = ModDependencyStatus.InvalidVersion; } From e3ab31462db7673fd0e31eed8bb999a833238055 Mon Sep 17 00:00:00 2001 From: zerocker Date: Sat, 17 Jan 2026 19:21:28 +0900 Subject: [PATCH 07/10] refactor: separate mod types and introduce graph-based dependency resolver - Split Mod class into IMod interface, CodeMod and AssetMod - Add ModDependencyGraph for explicit dependency tracking - Add ModDependencyResolver with iterative topological sort - Simplify Hat initialization with clearer separation of concerns - Remove ModDependency, ModIdentityHelper and unused None status - Rename Info to Metadata, fix typos in method names --- Installers/ModMenuInstaller.cs | 8 +- Patches/Fez.cs | 4 +- Patches/FezLogo.cs | 2 +- .../ModInternalAssemblyResolver.cs | 4 +- Source/Hat.cs | 437 ++++++++---------- Source/ModDefinition/AssetMod.cs | 26 ++ Source/ModDefinition/CodeMod.cs | 76 +++ Source/ModDefinition/IMod.cs | 11 + Source/ModDefinition/Mod.cs | 223 --------- Source/ModDefinition/ModDependency.cs | 108 ----- Source/ModDefinition/ModDependencyGraph.cs | 74 +++ Source/ModDefinition/ModDependencyResolver.cs | 235 ++++++++++ Source/ModDefinition/ModDependencyStatus.cs | 3 +- Source/ModDefinition/ModIdentityHelper.cs | 17 - 14 files changed, 628 insertions(+), 600 deletions(-) create mode 100644 Source/ModDefinition/AssetMod.cs create mode 100644 Source/ModDefinition/CodeMod.cs create mode 100644 Source/ModDefinition/IMod.cs delete mode 100644 Source/ModDefinition/Mod.cs delete mode 100644 Source/ModDefinition/ModDependency.cs create mode 100644 Source/ModDefinition/ModDependencyGraph.cs create mode 100644 Source/ModDefinition/ModDependencyResolver.cs delete mode 100644 Source/ModDefinition/ModIdentityHelper.cs 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 ce6e5fd..9faacda 100644 --- a/Patches/FezLogo.cs +++ b/Patches/FezLogo.cs @@ -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/Source/AssemblyResolving/ModInternalAssemblyResolver.cs b/Source/AssemblyResolving/ModInternalAssemblyResolver.cs index 5b87b80..1ac0feb 100644 --- a/Source/AssemblyResolving/ModInternalAssemblyResolver.cs +++ b/Source/AssemblyResolving/ModInternalAssemblyResolver.cs @@ -6,11 +6,11 @@ namespace HatModLoader.Source.AssemblyResolving { internal class ModInternalAssemblyResolver : IAssemblyResolver { - private readonly Mod _mod; + private readonly CodeMod _mod; private readonly Dictionary _cachedAssemblyPaths = new(); - public ModInternalAssemblyResolver(Mod mod) + public ModInternalAssemblyResolver(CodeMod mod) { _mod = mod; CacheAssemblyPaths(); diff --git a/Source/Hat.cs b/Source/Hat.cs index a0d720a..e055f41 100644 --- a/Source/Hat.cs +++ b/Source/Hat.cs @@ -1,4 +1,5 @@ -using Common; +using System.Xml.Serialization; +using Common; using FezGame; using HatModLoader.Source.AssemblyResolving; using HatModLoader.Source.Assets; @@ -9,16 +10,39 @@ 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; - - public static readonly Version Version = new("1.2.1"); + private const string ModMetadataFile = "Metadata.xml"; + + private const string AssetDirectoryName = "Assets"; + + private const string AssetPakName = AssetDirectoryName + ".pak"; + + private static readonly IList IgnoredModNames = InitializeIgnoredModsList(); + + private static readonly IList PriorityModNames = InitializePriorityList(); + + private List _assetMods; + + private List _codeMods; + + private List _mods; + + public IList Mods + { + get + { + _mods ??= _assetMods.Concat(_codeMods) + .GroupBy(p => p.FileProxy) + .Select(g => g.First()) + .ToList(); + return _mods; + } + } + + public int InvalidModsCount { get; private set; } public static string VersionString { @@ -32,321 +56,252 @@ public static string VersionString } } + 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 {VersionString}"); - PrepareMods(); + _fezGame = fez; + Initialize(); } - - private void PrepareMods() + private void Initialize() { - LoadMods(); - - if(Mods.Count == 0) - { - Logger.Log("HAT", $"No mods have been found in the directory."); - return; - } - - InitializeIgnoredModsList(); - InitializePriorityList(); - - RemoveBlacklistedMods(); - SortModsByPriority(); - RemoveDuplicates(); - InitializeDependencies(); - FilterOutInvalidMods(); - SortModsBasedOnDependencies(); + Logger.Log("HAT", $"HAT Mod Loader {VersionString}"); - LogLoadedMods(); - } + #region Load file proxies - private void EnsureModDirectory() - { - 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, + "Main mods directory not found. Creating and skipping mod loading process..."); + Directory.CreateDirectory(ModsDirectory); } - } - - private void LoadMods() - { - Mods.Clear(); - - EnsureModDirectory(); - - var modProxies = EnumerateFileProxiesInModsDirectory(); - foreach (var proxy in modProxies) - { - bool loadingState = Mod.TryLoad(this, proxy, out Mod mod); - if (loadingState) + var 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(); + #endregion - return new IEnumerable[] - { - DirectoryFileProxy.EnumerateInDirectory(modsDir), - ZipFileProxy.EnumerateInDirectory(modsDir), - } - .SelectMany(x => x); - } + #region Load metadata and check them against blacklist - private void LogModLoadingState(Mod mod, bool loadState) - { - 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 + var metas = new Dictionary(); + foreach (var proxy in proxies) { - if (mod.Info.Name == null) - { - 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) + if (IgnoredModNames.Contains(proxy.ContainerName)) { - 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); + continue; } - else if (!mod.IsCodeMod && !mod.IsAssetMod) + + if (TryLoadMetadata(proxy, out var metadata)) { - Logger.Log("HAT", LogSeverity.Warning, $"Mod \"{mod.Info.Name}\" is empty and will not be added."); + metas.Add(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); - } + #endregion - 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); - } + #region Load asset mods first and sort them against priority list - 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; + _assetMods = []; + foreach (var meta in metas) + { + if (TryLoadAssets(meta.Key, meta.Value, out var assetMod)) + { + _assetMods.Add(assetMod); + } + } - return index; - } - private void SortModsByPriority() - { - Mods.Sort((mod1, mod2) => + _assetMods.Sort((mod1, mod2) => { - var priorityIndex1 = GetPriorityIndexOfMod(mod1); - var priorityIndex2 = GetPriorityIndexOfMod(mod2); + var priorityIndex1 = GetPriorityIndex(mod1.FileProxy); + var priorityIndex2 = GetPriorityIndex(mod2.FileProxy); return priorityIndex1.CompareTo(priorityIndex2); }); - } - private int CompareDuplicateMods(Mod mod1, Mod mod2) - { - var priorityIndex1 = GetPriorityIndexOfMod(mod1); - var priorityIndex2 = GetPriorityIndexOfMod(mod2); - var priorityComparison = priorityIndex1.CompareTo(priorityIndex2); + #endregion - if(priorityComparison != 0) - { - return priorityComparison; - } - else - { - // Newest (largest) versions should be first, hence the negative sign. - return -mod1.Info.Version.CompareTo(mod2.Info.Version); - } - } + #region Build dependency graph for code mods and load them - private void RemoveDuplicates() - { - var uniqueNames = Mods.Select(mod => mod.Info.Name).Distinct().ToList(); - foreach (var modName in uniqueNames) + var codeMods = new List(); + foreach (var meta in metas) { - var sameNamedMods = Mods.Where(mod => mod.Info.Name == modName).ToList(); - if (sameNamedMods.Count() > 1) + if (TryLoadAssembly(meta.Key, meta.Value, out var codeMod)) { - 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); - } + codeMods.Add(codeMod); } } - } - private void InitializeDependencies() - { + var resolverResult = ModDependencyResolver.Resolve(codeMods); + _codeMods = resolverResult.LoadOrder; + InvalidModsCount = resolverResult.Invalid.Count; + + #endregion + + #region Log initialization result + + foreach (var node in resolverResult.Invalid) + { + Logger.Log("HAT", $"Mod '{node.Mod.Metadata.Name}' is invalid: {node.GetStatusText()}"); + } + + var modsText = $"{Mods.Count} mod{(Mods.Count != 1 ? "s" : "")}"; + var codeModsText = $"{_codeMods.Count} code mod{(_codeMods.Count != 1 ? "s" : "")}"; + var assetModsText = $"{_assetMods.Count} asset mod{(_assetMods.Count != 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) { - mod.InitializeDependencies(); + Logger.Log("HAT", $" {mod.Metadata.Name} by {mod.Metadata.Author} version {mod.Metadata.Version}"); } - - FinalizeDependencies(); + + #endregion } - private void FinalizeDependencies() + public void InitializeAssemblies() { - for(int i=0;i<=Mods.Count; i++) + foreach (var mod in _codeMods) { - if(i == Mods.Count) - { - // 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!"); - } - - bool noInvalidMods = true; - foreach (var mod in Mods) - { - if (mod.TryFinalizeDependencies()) continue; - - noInvalidMods = false; - } - if (noInvalidMods) - { - break; - } + mod.Initialize(_fezGame); } } - private void FilterOutInvalidMods() + public void InitializeComponents() { - InvalidMods = Mods.Where(mod => !mod.AreDependenciesValid()).ToList(); - foreach (var invalidMod in InvalidMods) + foreach (var mod in _codeMods) { - LogIssuesWithInvalidMod(invalidMod); - Mods.Remove(invalidMod); + mod.InjectComponents(); } } - - private void LogIssuesWithInvalidMod(Mod invalidMod) + + public List GetFullAssetList() { - 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); + return _assetMods.SelectMany(x => x.Assets).ToList(); } - private void SortModsBasedOnDependencies() + public static void RegisterRequiredDependencyResolvers() { - 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; - }); + AssemblyResolverRegistry.Register(new HatSubdirectoryAssemblyResolver("MonoMod")); + AssemblyResolverRegistry.Register(new HatSubdirectoryAssemblyResolver("FEZRepacker.Core")); } - private void LogLoadedMods() + private static bool TryLoadMetadata(IFileProxy proxy, out Metadata metadata) { - 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" : "")}"; + if (!proxy.FileExists(ModMetadataFile)) + { + metadata = default; + return false; + } - Logger.Log("HAT", $"Successfully loaded {modsText} ({codeModsText} and {assetModsText})"); + try + { + using var stream = proxy.OpenFile(ModMetadataFile); + using var reader = new StreamReader(stream); - Logger.Log("HAT", $"Mods in their order of appearance:"); + var serializer = new XmlSerializer(typeof(Metadata)); + metadata = (Metadata)serializer.Deserialize(reader); + if (string.IsNullOrEmpty(metadata.Name) || metadata.Version == null) + { + metadata = default; + return false; + } - foreach (var mod in Mods) + return true; + } + catch (Exception ex) { - Logger.Log("HAT", $" {mod.Info.Name} by {mod.Info.Author} version {mod.Info.Version}"); + Logger.Log("HAT", LogSeverity.Warning, $"Failed to load mod metadata: {ex.Message}"); + metadata = default; + return false; } } - internal void InitalizeAssemblies() + private static bool TryLoadAssets(IFileProxy proxy, Metadata metadata, out AssetMod assetMod) { - foreach (var mod in Mods) + var files = new Dictionary(); + foreach (var filePath in proxy.EnumerateFiles(AssetDirectoryName)) { - mod.InitializeAssembly(); + var relativePath = filePath.Substring(AssetDirectoryName.Length + 1).Replace("/", "\\").ToLower(); + var fileStream = proxy.OpenFile(filePath); + files.Add(relativePath, fileStream); } - foreach (var mod in Mods) + + var assets = AssetLoaderHelper.GetListFromFileDictionary(files); + if (proxy.FileExists(AssetPakName)) { - mod.InitializeComponents(); + using var pakPackage = proxy.OpenFile(AssetPakName); + assets.AddRange(AssetLoaderHelper.LoadPakPackage(pakPackage)); } - Logger.Log("HAT", "Assembly initialization completed!"); + + if (assets.Count < 1) + { + assetMod = null; + return false; + } + + assetMod = new AssetMod(proxy, metadata, assets); + return true; } - internal List GetFullAssetList() + private static bool TryLoadAssembly(IFileProxy proxy, Metadata metadata, out CodeMod codeMod) { - var list = new List(); + if (string.IsNullOrEmpty(metadata.LibraryName) || + !metadata.LibraryName.EndsWith(".dll", StringComparison.InvariantCultureIgnoreCase) || + !proxy.FileExists(metadata.LibraryName)) + { + codeMod = null; + return false; + } - foreach (var mod in Mods) + 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) { - list.AddRange(mod.Assets); + codeMod = null; + return false; } - return list; + codeMod = new CodeMod(proxy, metadata, rawAssembly); + return true; } - 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); } - public static void RegisterRequiredDependencyResolvers() + private static IList InitializePriorityList() { - AssemblyResolverRegistry.Register(new HatSubdirectoryAssemblyResolver("MonoMod")); - AssemblyResolverRegistry.Register(new HatSubdirectoryAssemblyResolver("FEZRepacker.Core")); + 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); + } + + private static int GetPriorityIndex(IFileProxy proxy) + { + var index = PriorityModNames.IndexOf(proxy.ContainerName); + if (index == -1) index = int.MaxValue; + return index; } } -} +} \ No newline at end of file diff --git a/Source/ModDefinition/AssetMod.cs b/Source/ModDefinition/AssetMod.cs new file mode 100644 index 0000000..0239291 --- /dev/null +++ b/Source/ModDefinition/AssetMod.cs @@ -0,0 +1,26 @@ +using HatModLoader.Source.Assets; +using HatModLoader.Source.FileProxies; + +namespace HatModLoader.Source.ModDefinition +{ + public class AssetMod : IMod + { + public IFileProxy FileProxy { get; } + + public Metadata Metadata { get; } + + public List Assets { get; } + + public AssetMod(IFileProxy fileProxy, Metadata metadata, List assets) + { + FileProxy = fileProxy; + Metadata = metadata; + Assets = assets; + } + + public void Dispose() + { + // Nothing + } + } +} \ No newline at end of file diff --git a/Source/ModDefinition/CodeMod.cs b/Source/ModDefinition/CodeMod.cs new file mode 100644 index 0000000..cc18939 --- /dev/null +++ b/Source/ModDefinition/CodeMod.cs @@ -0,0 +1,76 @@ +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 : IMod + { + public IFileProxy FileProxy { get; } + + public Metadata Metadata { get; } + + public byte[] RawAssembly { get; } + + public Assembly Assembly { get; private set; } + + private List Components { get; set; } + + private IAssemblyResolver _assemblyResolver; + + public CodeMod(IFileProxy fileProxy, Metadata metadata, byte[] rawAssembly) + { + FileProxy = fileProxy; + Metadata = metadata; + 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."); + } + + _assemblyResolver = new ModInternalAssemblyResolver(this); + AssemblyResolverRegistry.Register(_assemblyResolver); + 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 void InjectComponents() + { + foreach (var component in Components) + { + ServiceHelper.AddComponent(component); + } + } + + public void Dispose() + { + foreach (var component in Components) + { + ServiceHelper.RemoveComponent(component); + } + + AssemblyResolverRegistry.Unregister(_assemblyResolver); + } + } +} \ No newline at end of file diff --git a/Source/ModDefinition/IMod.cs b/Source/ModDefinition/IMod.cs new file mode 100644 index 0000000..cf8131d --- /dev/null +++ b/Source/ModDefinition/IMod.cs @@ -0,0 +1,11 @@ +using HatModLoader.Source.FileProxies; + +namespace HatModLoader.Source.ModDefinition +{ + public interface IMod : IDisposable + { + public IFileProxy FileProxy { get; } + + public Metadata Metadata { get; } + } +} \ No newline at end of file diff --git a/Source/ModDefinition/Mod.cs b/Source/ModDefinition/Mod.cs deleted file mode 100644 index a34991d..0000000 --- a/Source/ModDefinition/Mod.cs +++ /dev/null @@ -1,223 +0,0 @@ -using Common; -using FezEngine.Tools; -using HatModLoader.Source.Assets; -using HatModLoader.Source.FileProxies; -using HatModLoader.Source.AssemblyResolving; -using Microsoft.Xna.Framework; -using System.Reflection; -using System.Xml.Serialization; -using FezEngine.Effects; - -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; - - private IAssemblyResolver _internalAssemblyResolver; - - public byte[] RawAssembly { get; private set; } - public Assembly Assembly { get; private set; } - public Metadata 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() - { - _internalAssemblyResolver = new ModInternalAssemblyResolver(this); - AssemblyResolverRegistry.Register(_internalAssemblyResolver); - - if (RawAssembly != null) - { - Assembly = Assembly.Load(RawAssembly); - } - } - - public void Dispose() - { - // TODO: dispose assets - - foreach (var component in Components) - { - ServiceHelper.RemoveComponent(component); - } - - AssemblyResolverRegistry.Unregister(_internalAssemblyResolver); - } - - 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; - } - - try - { - using var stream = FileProxy.OpenFile(ModMetadataFileName); - using var reader = new StreamReader(stream); - - var serializer = new XmlSerializer(typeof(Metadata)); - var metadata = (Metadata)serializer.Deserialize(reader); - - if (!string.IsNullOrEmpty(metadata.Name) && metadata.Version != null) - { - Info = metadata; - return true; - } - } - catch (Exception ex) - { - Logger.Log("HAT", LogSeverity.Warning, $"Failed to load mod metadata: {ex.Message}"); - Info = default; - } - - return false; - } - - 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 20f302a..0000000 --- a/Source/ModDefinition/ModDependency.cs +++ /dev/null @@ -1,108 +0,0 @@ -namespace HatModLoader.Source.ModDefinition -{ - [Serializable] - public struct ModDependency - { - public Metadata.DependencyInfo Info; - public Mod Instance; - public ModDependencyStatus Status; - public bool IsModLoaderDependency => Info.Name == "HAT"; - public bool IsFinalized => Status != ModDependencyStatus.None; - public Version DetectedVersion => IsModLoaderDependency ? Hat.Version : Instance?.Info.Version; - - public ModDependency(Metadata.DependencyInfo info, Mod instance) - { - Info = info; - Instance = instance; - Status = ModDependencyStatus.None; - - Initialize(); - } - public void Initialize() - { - if (IsModLoaderDependency || Instance != null) - { - if (DetectedVersion.CompareTo(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..fd5316d --- /dev/null +++ b/Source/ModDefinition/ModDependencyGraph.cs @@ -0,0 +1,74 @@ +namespace HatModLoader.Source.ModDefinition +{ + public class ModDependencyGraph + { + private readonly Dictionary _nodes = new(StringComparer.OrdinalIgnoreCase); + + public void AddNode(CodeMod 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 CodeMod Mod { get; } + + public List Dependencies { get; } = []; + + public ModDependencyStatus Status { get; private set; } = ModDependencyStatus.Valid; + + private string Details { get; set; } + + public Node(CodeMod mod) + { + Mod = mod; + } + + public void MarkInvalid(ModDependencyStatus status, string details) + { + if (Status == ModDependencyStatus.Valid && status != ModDependencyStatus.Valid) + { + Status = status; + Details = details; + } + } + + public string GetStatusText() + { + var statusText = Status switch + { + ModDependencyStatus.Valid => "Valid", + ModDependencyStatus.InvalidVersion => "Version mismatch", + ModDependencyStatus.InvalidNotFound => "Not found", + ModDependencyStatus.InvalidRecursive => "Circular dependency", + ModDependencyStatus.InvalidDependencyTree => "Dependency tree error", + _ => "Unknown" + }; + return $"{statusText} - {Details}"; + } + + public override string ToString() + { + return Mod.Metadata.Name; + } + } + } +} diff --git a/Source/ModDefinition/ModDependencyResolver.cs b/Source/ModDefinition/ModDependencyResolver.cs new file mode 100644 index 0000000..11a44e4 --- /dev/null +++ b/Source/ModDefinition/ModDependencyResolver.cs @@ -0,0 +1,235 @@ +namespace HatModLoader.Source.ModDefinition +{ + public static class ModDependencyResolver + { + private const string HatDependencyName = "HAT"; + + public static ResolverResult Resolve(IList mods) + { + 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); + + // 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(CodeMod 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) + { + var result = new List(); + var visited = new HashSet(StringComparer.OrdinalIgnoreCase); + var path = new List(); + var pathSet = new HashSet(StringComparer.OrdinalIgnoreCase); + var stack = new Stack(); + + foreach (var startNode in graph.Nodes) + { + 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)); + + foreach (var dep in node.Dependencies) + { + if (!visited.Contains(dep.Mod.Metadata.Name)) + { + stack.Push(new Progress(dep, false)); + } + } + } + } + + return result; + } + + 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/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(); - } - } -} From dca54b63a9cc141c485538cebb59cc03dd9f0764 Mon Sep 17 00:00:00 2001 From: zerocker Date: Sat, 17 Jan 2026 22:53:36 +0900 Subject: [PATCH 08/10] refactor: simplify the details messaging of resolver result --- Source/Hat.cs | 2 +- Source/ModDefinition/ModDependencyGraph.cs | 16 +--------------- 2 files changed, 2 insertions(+), 16 deletions(-) diff --git a/Source/Hat.cs b/Source/Hat.cs index e055f41..7435c00 100644 --- a/Source/Hat.cs +++ b/Source/Hat.cs @@ -148,7 +148,7 @@ private void Initialize() foreach (var node in resolverResult.Invalid) { - Logger.Log("HAT", $"Mod '{node.Mod.Metadata.Name}' is invalid: {node.GetStatusText()}"); + Logger.Log("HAT", $"Code mod '{node.Mod.Metadata.Name}' is invalid: {node.Details}"); } var modsText = $"{Mods.Count} mod{(Mods.Count != 1 ? "s" : "")}"; diff --git a/Source/ModDefinition/ModDependencyGraph.cs b/Source/ModDefinition/ModDependencyGraph.cs index fd5316d..3e6134f 100644 --- a/Source/ModDefinition/ModDependencyGraph.cs +++ b/Source/ModDefinition/ModDependencyGraph.cs @@ -35,7 +35,7 @@ public class Node public ModDependencyStatus Status { get; private set; } = ModDependencyStatus.Valid; - private string Details { get; set; } + public string Details { get; private set; } public Node(CodeMod mod) { @@ -51,20 +51,6 @@ public void MarkInvalid(ModDependencyStatus status, string details) } } - public string GetStatusText() - { - var statusText = Status switch - { - ModDependencyStatus.Valid => "Valid", - ModDependencyStatus.InvalidVersion => "Version mismatch", - ModDependencyStatus.InvalidNotFound => "Not found", - ModDependencyStatus.InvalidRecursive => "Circular dependency", - ModDependencyStatus.InvalidDependencyTree => "Dependency tree error", - _ => "Unknown" - }; - return $"{statusText} - {Details}"; - } - public override string ToString() { return Mod.Metadata.Name; From ccdffe738403d643a236bb325093912ffc6136fd Mon Sep 17 00:00:00 2001 From: zerocker Date: Mon, 19 Jan 2026 17:02:01 +0900 Subject: [PATCH 09/10] refactor: combine CodeMod and AssetMod with ModIdentity class Also: - Move the loading methods back to their classes. - Rework HAT.Initialize logic to add skiping the initialization process. - Remove logic for prioritizing of mods (for now). --- .../ModInternalAssemblyResolver.cs | 8 +- Source/Hat.cs | 250 ++++++------------ Source/ModDefinition/AssetMod.cs | 36 ++- Source/ModDefinition/CodeMod.cs | 46 ++-- Source/ModDefinition/IMod.cs | 11 - Source/ModDefinition/Metadata.cs | 35 +++ Source/ModDefinition/ModDependencyGraph.cs | 6 +- Source/ModDefinition/ModDependencyResolver.cs | 12 +- Source/ModDefinition/ModIdentity.cs | 62 +++++ 9 files changed, 240 insertions(+), 226 deletions(-) delete mode 100644 Source/ModDefinition/IMod.cs create mode 100644 Source/ModDefinition/ModIdentity.cs diff --git a/Source/AssemblyResolving/ModInternalAssemblyResolver.cs b/Source/AssemblyResolving/ModInternalAssemblyResolver.cs index 1ac0feb..f365de9 100644 --- a/Source/AssemblyResolving/ModInternalAssemblyResolver.cs +++ b/Source/AssemblyResolving/ModInternalAssemblyResolver.cs @@ -6,11 +6,11 @@ namespace HatModLoader.Source.AssemblyResolving { internal class ModInternalAssemblyResolver : IAssemblyResolver { - private readonly CodeMod _mod; + private readonly ModIdentity _mod; private readonly Dictionary _cachedAssemblyPaths = new(); - public ModInternalAssemblyResolver(CodeMod mod) + public ModInternalAssemblyResolver(ModIdentity mod) { _mod = mod; CacheAssemblyPaths(); @@ -18,9 +18,9 @@ public ModInternalAssemblyResolver(CodeMod mod) public Assembly ProvideAssembly(object sender, ResolveEventArgs args) { - if (_mod.Assembly != null && _mod.Assembly.GetName().MatchesRequest(args, false)) + if (_mod.CodeMod != null && _mod.CodeMod.Assembly.GetName().MatchesRequest(args, false)) { - return _mod.Assembly; + return _mod.CodeMod.Assembly; } foreach(var assemblyName in _cachedAssemblyPaths.Keys) diff --git a/Source/Hat.cs b/Source/Hat.cs index 7435c00..c38609e 100644 --- a/Source/Hat.cs +++ b/Source/Hat.cs @@ -14,33 +14,11 @@ public class Hat private static readonly string ModsDirectory = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Mods"); - private const string ModMetadataFile = "Metadata.xml"; - - private const string AssetDirectoryName = "Assets"; - - private const string AssetPakName = AssetDirectoryName + ".pak"; - private static readonly IList IgnoredModNames = InitializeIgnoredModsList(); private static readonly IList PriorityModNames = InitializePriorityList(); - private List _assetMods; - - private List _codeMods; - - private List _mods; - - public IList Mods - { - get - { - _mods ??= _assetMods.Concat(_codeMods) - .GroupBy(p => p.FileProxy) - .Select(g => g.First()) - .ToList(); - return _mods; - } - } + public List Mods { get; private set; } public int InvalidModsCount { get; private set; } @@ -70,104 +48,120 @@ public Hat(Fez fez) private void Initialize() { Logger.Log("HAT", $"HAT Mod Loader {VersionString}"); + + if (CheckModsFolder()) + { + if (GetModProxies(out var proxies)) + { + if (GetModList(proxies, out var mods)) + { + ResolveDependencies(mods); + LoadMods(); + return; // HAT initialized + } + } + } + + Logger.Log("HAT", LogSeverity.Warning, "Skip the initialization process..."); + } - #region Load file proxies - + private static bool CheckModsFolder() + { if (!Directory.Exists(ModsDirectory)) { - Logger.Log("HAT", LogSeverity.Warning, - "Main mods directory not found. Creating and skipping mod loading process..."); + Logger.Log("HAT", LogSeverity.Warning,"'Mods' directory not found. Creating it..."); Directory.CreateDirectory(ModsDirectory); + return false; } - var proxies = new IEnumerable[] + return true; + } + + private static bool GetModProxies(out IEnumerable proxies) + { + proxies = new IEnumerable[] { DirectoryFileProxy.EnumerateInDirectory(ModsDirectory), ZipFileProxy.EnumerateInDirectory(ModsDirectory), } .SelectMany(x => x); - #endregion - - #region Load metadata and check them against blacklist - - var metas = new Dictionary(); - foreach (var proxy in proxies) + if (!proxies.Any()) { - if (IgnoredModNames.Contains(proxy.ContainerName)) - { - continue; - } - - if (TryLoadMetadata(proxy, out var metadata)) - { - metas.Add(proxy, metadata); - } + Logger.Log("HAT", LogSeverity.Warning, "There are no mods inside 'Mods' directory."); + return false; } - #endregion - - #region Load asset mods first and sort them against priority list + return true; + } - _assetMods = []; - foreach (var meta in metas) + private static bool GetModList(in IEnumerable proxies, out IList mods) + { + mods = new List(); + foreach (var proxy in proxies.Where(fp => !IgnoredModNames.Contains(fp.ContainerName))) { - if (TryLoadAssets(meta.Key, meta.Value, out var assetMod)) + if (Metadata.TryLoad(proxy, out var metadata)) { - _assetMods.Add(assetMod); + mods.Add(new ModIdentity(proxy, metadata)); } } - _assetMods.Sort((mod1, mod2) => + if (mods.Count < 1) { - var priorityIndex1 = GetPriorityIndex(mod1.FileProxy); - var priorityIndex2 = GetPriorityIndex(mod2.FileProxy); - return priorityIndex1.CompareTo(priorityIndex2); - }); - - #endregion - - #region Build dependency graph for code mods and load them - - var codeMods = new List(); - foreach (var meta in metas) - { - if (TryLoadAssembly(meta.Key, meta.Value, out var codeMod)) - { - codeMods.Add(codeMod); - } + Logger.Log("HAT", LogSeverity.Warning, "There are no mods to load. Perhaps they are all in 'ignorelist.txt'."); + return false; } - var resolverResult = ModDependencyResolver.Resolve(codeMods); - _codeMods = resolverResult.LoadOrder; - InvalidModsCount = resolverResult.Invalid.Count; - - #endregion - - #region Log initialization result + return true; + } + private void ResolveDependencies(IList mods) + { + var resolverResult = ModDependencyResolver.Resolve(mods); + Mods = resolverResult.LoadOrder; + InvalidModsCount = resolverResult.Invalid.Count; + foreach (var node in resolverResult.Invalid) { - Logger.Log("HAT", $"Code mod '{node.Mod.Metadata.Name}' is invalid: {node.Details}"); + Logger.Log("HAT", $"Mod '{node.Mod.Metadata.Name}' is invalid: {node.Details}"); } - - var modsText = $"{Mods.Count} mod{(Mods.Count != 1 ? "s" : "")}"; - var codeModsText = $"{_codeMods.Count} code mod{(_codeMods.Count != 1 ? "s" : "")}"; - var assetModsText = $"{_assetMods.Count} asset mod{(_assetMods.Count != 1 ? "s" : "")}"; - Logger.Log("HAT", $"Successfully loaded {modsText} ({codeModsText} and {assetModsText})"); - - Logger.Log("HAT", "Mods in their order of appearance:"); + + Logger.Log("HAT", "The loading order of mods:"); foreach (var mod in Mods) { Logger.Log("HAT", $" {mod.Metadata.Name} by {mod.Metadata.Author} version {mod.Metadata.Version}"); } - - #endregion } + private void LoadMods() + { + var assetModCount = 0; + var codeModsCount = 0; + + foreach (var mod in Mods) + { + if (AssetMod.TryLoad(mod.FileProxy, mod.Metadata, out var assetMod)) + { + mod.AssetMod = assetMod; + assetModCount += 1; + } + + if (CodeMod.TryLoad(mod.FileProxy, mod.Metadata, out var codeMod)) + { + mod.CodeMod = codeMod; + codeModsCount += 1; + } + } + + var modsText = $"{Mods.Count} mod{(Mods.Count != 1 ? "s" : "")}"; + var codeModsText = $"{codeModsCount} code mod{(codeModsCount != 1 ? "s" : "")}"; + var assetModsText = $"{assetModCount} asset mod{(assetModCount != 1 ? "s" : "")}"; + Logger.Log("HAT", $"Successfully loaded {modsText} ({codeModsText} and {assetModsText})"); + } + public void InitializeAssemblies() { - foreach (var mod in _codeMods) + foreach (var mod in Mods) { mod.Initialize(_fezGame); } @@ -175,7 +169,7 @@ public void InitializeAssemblies() public void InitializeComponents() { - foreach (var mod in _codeMods) + foreach (var mod in Mods) { mod.InjectComponents(); } @@ -183,7 +177,7 @@ public void InitializeComponents() public List GetFullAssetList() { - return _assetMods.SelectMany(x => x.Assets).ToList(); + return Mods.SelectMany(x => x.GetAssets()).ToList(); } public static void RegisterRequiredDependencyResolvers() @@ -192,88 +186,6 @@ public static void RegisterRequiredDependencyResolvers() AssemblyResolverRegistry.Register(new HatSubdirectoryAssemblyResolver("FEZRepacker.Core")); } - private static bool TryLoadMetadata(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; - } - } - - private static bool TryLoadAssets(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(proxy, metadata, assets); - return true; - } - - private static bool TryLoadAssembly(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(proxy, metadata, rawAssembly); - return true; - } - private static IList InitializeIgnoredModsList() { var ignoredModsNamesFilePath = Path.Combine(ModsDirectory, "ignorelist.txt"); diff --git a/Source/ModDefinition/AssetMod.cs b/Source/ModDefinition/AssetMod.cs index 0239291..0549afd 100644 --- a/Source/ModDefinition/AssetMod.cs +++ b/Source/ModDefinition/AssetMod.cs @@ -3,24 +3,44 @@ namespace HatModLoader.Source.ModDefinition { - public class AssetMod : IMod + public class AssetMod { - public IFileProxy FileProxy { get; } + private const string AssetDirectoryName = "Assets"; - public Metadata Metadata { get; } + private const string AssetPakName = AssetDirectoryName + ".pak"; public List Assets { get; } - public AssetMod(IFileProxy fileProxy, Metadata metadata, List assets) + private AssetMod(List assets) { - FileProxy = fileProxy; - Metadata = metadata; Assets = assets; } - public void Dispose() + public static bool TryLoad(IFileProxy proxy, Metadata metadata, out AssetMod assetMod) { - // Nothing + 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 index cc18939..60ee85c 100644 --- a/Source/ModDefinition/CodeMod.cs +++ b/Source/ModDefinition/CodeMod.cs @@ -6,24 +6,16 @@ namespace HatModLoader.Source.ModDefinition { - public class CodeMod : IMod + public class CodeMod { - public IFileProxy FileProxy { get; } - - public Metadata Metadata { get; } - public byte[] RawAssembly { get; } - + public Assembly Assembly { get; private set; } - private List Components { get; set; } - - private IAssemblyResolver _assemblyResolver; + public List Components { get; private set; } - public CodeMod(IFileProxy fileProxy, Metadata metadata, byte[] rawAssembly) + private CodeMod(byte[] rawAssembly) { - FileProxy = fileProxy; - Metadata = metadata; RawAssembly = rawAssembly; } @@ -38,12 +30,10 @@ public void Initialize(Game game) { throw new InvalidOperationException("Assembly is already loaded."); } - - _assemblyResolver = new ModInternalAssemblyResolver(this); - AssemblyResolverRegistry.Register(_assemblyResolver); + Assembly = Assembly.Load(RawAssembly); - Components = []; + foreach (var type in Assembly.GetExportedTypes()) { if (typeof(GameComponent).IsAssignableFrom(type) && type.IsPublic && !type.IsAbstract) @@ -55,22 +45,28 @@ public void Initialize(Game game) } } - public void InjectComponents() + public static bool TryLoad(IFileProxy proxy, Metadata metadata, out CodeMod codeMod) { - foreach (var component in Components) + if (string.IsNullOrEmpty(metadata.LibraryName) || + !metadata.LibraryName.EndsWith(".dll", StringComparison.InvariantCultureIgnoreCase) || + !proxy.FileExists(metadata.LibraryName)) { - ServiceHelper.AddComponent(component); + codeMod = null; + return false; } - } - public void Dispose() - { - foreach (var component in Components) + 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) { - ServiceHelper.RemoveComponent(component); + codeMod = null; + return false; } - AssemblyResolverRegistry.Unregister(_assemblyResolver); + codeMod = new CodeMod(rawAssembly); + return true; } } } \ No newline at end of file diff --git a/Source/ModDefinition/IMod.cs b/Source/ModDefinition/IMod.cs deleted file mode 100644 index cf8131d..0000000 --- a/Source/ModDefinition/IMod.cs +++ /dev/null @@ -1,11 +0,0 @@ -using HatModLoader.Source.FileProxies; - -namespace HatModLoader.Source.ModDefinition -{ - public interface IMod : IDisposable - { - public IFileProxy FileProxy { get; } - - public Metadata Metadata { get; } - } -} \ No newline at end of file diff --git a/Source/ModDefinition/Metadata.cs b/Source/ModDefinition/Metadata.cs index 8643746..9295b33 100644 --- a/Source/ModDefinition/Metadata.cs +++ b/Source/ModDefinition/Metadata.cs @@ -1,10 +1,14 @@ 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; } @@ -29,6 +33,37 @@ public string VersionString 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 diff --git a/Source/ModDefinition/ModDependencyGraph.cs b/Source/ModDefinition/ModDependencyGraph.cs index 3e6134f..4ace89c 100644 --- a/Source/ModDefinition/ModDependencyGraph.cs +++ b/Source/ModDefinition/ModDependencyGraph.cs @@ -4,7 +4,7 @@ public class ModDependencyGraph { private readonly Dictionary _nodes = new(StringComparer.OrdinalIgnoreCase); - public void AddNode(CodeMod mod) + public void AddNode(ModIdentity mod) { var name = mod.Metadata.Name; if (_nodes.TryGetValue(name, out var existing)) @@ -29,7 +29,7 @@ public bool TryGetNode(string name, out Node node) public class Node { - public CodeMod Mod { get; } + public ModIdentity Mod { get; } public List Dependencies { get; } = []; @@ -37,7 +37,7 @@ public class Node public string Details { get; private set; } - public Node(CodeMod mod) + public Node(ModIdentity mod) { Mod = mod; } diff --git a/Source/ModDefinition/ModDependencyResolver.cs b/Source/ModDefinition/ModDependencyResolver.cs index 11a44e4..dc5d766 100644 --- a/Source/ModDefinition/ModDependencyResolver.cs +++ b/Source/ModDefinition/ModDependencyResolver.cs @@ -4,7 +4,7 @@ public static class ModDependencyResolver { private const string HatDependencyName = "HAT"; - public static ResolverResult Resolve(IList mods) + public static ResolverResult Resolve(IList mods) { var graph = new ModDependencyGraph(); var rejected = new List(); @@ -42,7 +42,7 @@ public static ResolverResult Resolve(IList mods) return new ResolverResult(loadOrder, invalid); } - private static ModDependencyStatus ValidateHatDependency(CodeMod mod, out string details) + private static ModDependencyStatus ValidateHatDependency(ModIdentity mod, out string details) { var deps = mod.Metadata.Dependencies; if (deps == null) @@ -95,9 +95,9 @@ private static void BuildEdges(ModDependencyGraph graph, ModDependencyGraph.Node } } - private static List TopologicalSort(ModDependencyGraph graph) + private static List TopologicalSort(ModDependencyGraph graph) { - var result = new List(); + var result = new List(); var visited = new HashSet(StringComparer.OrdinalIgnoreCase); var path = new List(); var pathSet = new HashSet(StringComparer.OrdinalIgnoreCase); @@ -222,11 +222,11 @@ private record struct Progress(ModDependencyGraph.Node Node, bool Processed); public class ResolverResult { - public List LoadOrder { get; } + public List LoadOrder { get; } public List Invalid { get; } - public ResolverResult(List loadOrder, List invalid) + public ResolverResult(List loadOrder, List invalid) { LoadOrder = loadOrder; Invalid = invalid; 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 From 231f7a85ac59911d35ba5669ca22b8b52cb11a2c Mon Sep 17 00:00:00 2001 From: zerocker Date: Mon, 19 Jan 2026 17:30:02 +0900 Subject: [PATCH 10/10] feat: implement priority list ordering in dependency resolver The priority list now influences the load order of mods while respecting dependency constraints. When multiple valid topological orderings exist, mods are ordered according to their position in the priority list. Dependencies are always loaded before their dependents regardless of priority. --- Source/Hat.cs | 12 ++----- Source/ModDefinition/ModDependencyResolver.cs | 31 ++++++++++++++++--- 2 files changed, 28 insertions(+), 15 deletions(-) diff --git a/Source/Hat.cs b/Source/Hat.cs index c38609e..05ac080 100644 --- a/Source/Hat.cs +++ b/Source/Hat.cs @@ -1,5 +1,4 @@ -using System.Xml.Serialization; -using Common; +using Common; using FezGame; using HatModLoader.Source.AssemblyResolving; using HatModLoader.Source.Assets; @@ -117,7 +116,7 @@ private static bool GetModList(in IEnumerable proxies, out IList mods) { - var resolverResult = ModDependencyResolver.Resolve(mods); + var resolverResult = ModDependencyResolver.Resolve(mods, PriorityModNames); Mods = resolverResult.LoadOrder; InvalidModsCount = resolverResult.Invalid.Count; @@ -208,12 +207,5 @@ private static IList InitializePriorityList() "ExampleZipPackageName.zip\n"; return ModsTextListLoader.LoadOrCreateDefault(priorityListFilePath, defaultContent); } - - private static int GetPriorityIndex(IFileProxy proxy) - { - var index = PriorityModNames.IndexOf(proxy.ContainerName); - if (index == -1) index = int.MaxValue; - return index; - } } } \ No newline at end of file diff --git a/Source/ModDefinition/ModDependencyResolver.cs b/Source/ModDefinition/ModDependencyResolver.cs index dc5d766..436b94b 100644 --- a/Source/ModDefinition/ModDependencyResolver.cs +++ b/Source/ModDefinition/ModDependencyResolver.cs @@ -1,10 +1,12 @@ +using HatModLoader.Source.FileProxies; + namespace HatModLoader.Source.ModDefinition { public static class ModDependencyResolver { private const string HatDependencyName = "HAT"; - public static ResolverResult Resolve(IList mods) + public static ResolverResult Resolve(IList mods, IList priorityList) { var graph = new ModDependencyGraph(); var rejected = new List(); @@ -32,7 +34,7 @@ public static ResolverResult Resolve(IList mods) } // Topological sort with cycle detection - var loadOrder = TopologicalSort(graph); + var loadOrder = TopologicalSort(graph, priorityList); // Collect invalid nodes var invalid = rejected @@ -95,7 +97,7 @@ private static void BuildEdges(ModDependencyGraph graph, ModDependencyGraph.Node } } - private static List TopologicalSort(ModDependencyGraph graph) + private static List TopologicalSort(ModDependencyGraph graph, IList priorityList) { var result = new List(); var visited = new HashSet(StringComparer.OrdinalIgnoreCase); @@ -103,7 +105,12 @@ private static List TopologicalSort(ModDependencyGraph graph) var pathSet = new HashSet(StringComparer.OrdinalIgnoreCase); var stack = new Stack(); - foreach (var startNode in graph.Nodes) + // 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)) { @@ -160,7 +167,11 @@ private static List TopologicalSort(ModDependencyGraph graph) pathSet.Add(name); stack.Push(new Progress(node, true)); - foreach (var dep in node.Dependencies) + 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)) { @@ -173,6 +184,16 @@ private static List TopologicalSort(ModDependencyGraph graph) 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;