Skip to content

Conversation

@zrckr
Copy link

@zrckr zrckr commented Jan 18, 2026

There's big of PR, based on 78d611e

Essentially, the following changes were made:

  1. The Mod class was split into CodeMod and AssetMod to separate responsibilities.
  2. Loading from Mod was moved to... well... to the HAT mod loader class itself (I think it makes sense to put that here)
  3. Dependency resolving between CodeMods now to done it the separate utility class with a graph structure. I found the old ModDepedency class ugly, since all it have to do is to give correct loading order of mods.
  4. Assembly resolving stayed the same as before.

It was also decided to refactor the HAT mod loader class itself, but leave the public interface unchanged. The reason to make the loading process simpler to track changes in the external debugger.

Loading now occurs in stages:

  1. Obtain the proxy files.
  2. Load only metadata and filter out with blacklist.
  3. Load AssetMods based on metadata and sort them by priority list.
  4. Load CodeMods based on metadata into the dependency graph.
  5. Resolve the graph and load CodeMods in the correct order.

The only question that arises is the rid of minor methods for main initialization. On the one hand, it is useful to see potential errors in the exception window. On the other hand, if you have an external debugger, there is no need for separation if the code is more or less correctly decompiled.

Krzyhau and others added 8 commits January 14, 2026 00:20
…olver

- 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
Source/Hat.cs Outdated
Comment on lines 37 to 41
_mods ??= _assetMods.Concat<IMod>(_codeMods)
.GroupBy(p => p.FileProxy)
.Select(g => g.First())
.ToList();
return _mods;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does that mean publicly accessible Mods list only has either code mod or asset mod, whichever happens to be sorted in a group first? Not sure if I like that.

I'd suggest making a higher level ModIdentity structure, containing references to both asset mod and code mod, as well as to its metadata and file proxy (this way you could minimize stuff that has to be passed to each asset and code mod on initialization).

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good suggestion. The "glueing" with interface looks not that nice, although it was straight to implement

Source/Hat.cs Outdated
DirectoryFileProxy.EnumerateInDirectory(modsDir),
ZipFileProxy.EnumerateInDirectory(modsDir),
Logger.Log("HAT", LogSeverity.Warning,
"Main mods directory not found. Creating and skipping mod loading process...");
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Flaw inherited from previous implementation - we're continuing with seeking mods and reading the lists even though the log says otherwise. I'd change the log, since I think it's still worth doing the mod loading process. The result is the same either way.

Source/Hat.cs Outdated
_assetMods = [];
foreach (var meta in metas)
{
if (TryLoadAssets(meta.Key, meta.Value, out var assetMod))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You moved it to Hat.cs, leaving AssetMod empty. I'm very much not a fan of creating a super-class like that. Now Hat.cs contains implementation of loading asset mods, loading code mods, reading metadata, handling ignore- and prioritylist... at least some of these things could be separated into their respective modules.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To re-iterate - I think Hat.cs should only have implementation for managing the mods and the process of loading them. That process itself should be dealt with somewhere else.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, I'll consider to move loading methods back to their classes.

Source/Hat.cs Outdated
}

private void LogLoadedMods()
private static bool TryLoadMetadata(IFileProxy proxy, out Metadata metadata)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Considering my suggestion for implementing ModIdentity structure, this could be neatly implemented in there instead of here.

Source/Hat.cs Outdated
var priorityIndex1 = GetPriorityIndexOfMod(mod1);
var priorityIndex2 = GetPriorityIndexOfMod(mod2);
var priorityIndex1 = GetPriorityIndex(mod1.FileProxy);
var priorityIndex2 = GetPriorityIndex(mod2.FileProxy);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be worth to consider using dependency ordering as a secondary sorting comparison for asset mods. Likewise, priority index could be used as a secondary sorting comparison for code mods.

Copy link
Author

@zrckr zrckr Jan 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On the one hand, if code mod goes with asset mod, it is better to load assets through the graph alone, so we will be sure that the code will use the correct assets.
On the other hand, adding priority file to the process can complicate everything, because there may be a completely different order of priorities to mess up the graph resolver result.

{
private List<string> ignoredModNames = new();
private List<string> priorityModNamesList = new();
public static readonly Version Version = new("1.2.1");
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd still like some kind of indication that a debug version of HAT is being used, but it's a minor request and you can ignore it.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For that case there's VersionString getter for prefixing or suffixing the main version string

Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This pull request refactors the HAT mod loader architecture by splitting the monolithic Mod class into separate CodeMod and AssetMod classes and introducing a graph-based dependency resolution system. The changes aim to improve the separation of concerns and make the loading process more maintainable with clearly defined stages.

Changes:

  • Split Mod class into CodeMod and AssetMod with a common IMod interface
  • Introduced ModDependencyResolver with graph-based topological sorting for dependency resolution
  • Refactored Hat class to use a staged loading process (file proxies → metadata → assets → code mods → dependency resolution)
  • Implemented a new assembly resolving system with AssemblyResolverRegistry and pluggable resolvers
  • Replaced ModMetadata with a new Metadata struct that uses System.Version for better version handling

Reviewed changes

Copilot reviewed 24 out of 24 changed files in this pull request and generated 25 comments.

Show a summary per file
File Description
Source/ModDefinition/Metadata.cs New metadata struct with Version-based properties replacing string-based version handling
Source/ModDefinition/CodeMod.cs New class for mods with executable code, handles assembly loading and component initialization
Source/ModDefinition/AssetMod.cs New class for asset-only mods
Source/ModDefinition/IMod.cs Common interface for both mod types
Source/ModDefinition/ModDependencyResolver.cs Graph-based dependency resolution with topological sorting
Source/ModDefinition/ModDependencyGraph.cs Graph data structure for dependency relationships
Source/ModDefinition/ModDependencyStatus.cs Removed 'None' enum value
Source/Hat.cs Refactored with staged loading process and new mod management
Source/AssemblyResolving/*.cs New assembly resolution system with registry pattern
Patches/*.cs Updated to use new API (Metadata instead of Info, VersionString instead of Version)
FEZ.HAT.mm.csproj Disabled nullable reference types

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

var name = mod.Metadata.Name;
if (_nodes.TryGetValue(name, out var existing))
{
if (mod.Metadata.Version > existing.Mod.Metadata.Version)
Copy link

Copilot AI Jan 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The string comparison operator for Version is being used directly without null checking. If either version is null (which could happen if TryParse fails), this will throw a NullReferenceException. Consider adding null checks before the comparison.

Suggested change
if (mod.Metadata.Version > existing.Mod.Metadata.Version)
var newVersion = mod.Metadata.Version;
var existingVersion = existing.Mod.Metadata.Version;
if (newVersion != null && existingVersion != null && newVersion > existingVersion)

Copilot uses AI. Check for mistakes.
return ModDependencyStatus.InvalidNotFound;
}

if (hatDependency.MinimumVersion != null && Hat.Version < hatDependency.MinimumVersion)
Copy link

Copilot AI Jan 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The version comparison operators are being used directly without null checking. If either mod.Metadata.Version or hatDependency.MinimumVersion is null, this will throw a NullReferenceException. Consider adding null checks before the comparison.

Copilot uses AI. Check for mistakes.
Comment on lines +87 to +91
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;
Copy link

Copilot AI Jan 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The version comparison operators are being used directly without null checking. If either dep.MinimumVersion or depNode.Mod.Metadata.Version is null, this will throw a NullReferenceException. Consider adding null checks before the comparison.

Suggested change
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;
if (dep.MinimumVersion != null)
{
var actualVersion = depNode.Mod.Metadata.Version;
if (actualVersion == null || actualVersion < dep.MinimumVersion)
{
node.MarkInvalid(ModDependencyStatus.InvalidVersion,
$"'{dep.Name}' requires >={dep.MinimumVersion}, found {actualVersion}");
return;
}

Copilot uses AI. Check for mistakes.
Comment on lines +42 to +52
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;
Copy link

Copilot AI Jan 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The variable name 'checkedVersion' is inconsistent with the naming convention. Consider using 'providedVersion' or 'availableVersion' to better describe what is being checked against the required version.

Suggested change
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;
private static bool CompareVersions(Version providedVersion, Version requiredVersion, bool allowRollForward)
{
if (allowRollForward)
{
return providedVersion >= requiredVersion;
}
return providedVersion.Major == requiredVersion.Major &&
providedVersion.Minor == requiredVersion.Minor &&
providedVersion.Build == requiredVersion.Build &&
providedVersion.Revision >= requiredVersion.Revision;

Copilot uses AI. Check for mistakes.
}
}

return null;
Copy link

Copilot AI Jan 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The method returns null when an assembly cannot be provided. According to the AssemblyResolve event documentation, returning null is correct, but the method signature could be clearer if it had a nullable return type annotation (Assembly?). Since nullable is now disabled in the project, this is less critical but still worth noting.

Copilot uses AI. Check for mistakes.
Source/Hat.cs Outdated
Comment on lines 112 to 120
_assetMods = [];
foreach (var meta in metas)
{
if (TryLoadAssets(meta.Key, meta.Value, out var assetMod))
{
_assetMods.Add(assetMod);
}
}

Copy link

Copilot AI Jan 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This foreach loop implicitly filters its target sequence - consider filtering the sequence explicitly using '.Where(...)'.

Suggested change
_assetMods = [];
foreach (var meta in metas)
{
if (TryLoadAssets(meta.Key, meta.Value, out var assetMod))
{
_assetMods.Add(assetMod);
}
}
_assetMods = metas
.SelectMany(meta =>
{
if (TryLoadAssets(meta.Key, meta.Value, out var assetMod))
{
return new[] { assetMod };
}
return Array.Empty<AssetMod>();
})
.ToList();

Copilot uses AI. Check for mistakes.
Comment on lines +47 to +55
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);
}
}
Copy link

Copilot AI Jan 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This foreach loop implicitly filters its target sequence - consider filtering the sequence explicitly using '.Where(...)'.

Copilot uses AI. Check for mistakes.
Source/Hat.cs Outdated
Comment on lines 133 to 139
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);
}
}
Copy link

Copilot AI Jan 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This foreach loop implicitly filters its target sequence - consider filtering the sequence explicitly using '.Where(...)'.

Copilot uses AI. Check for mistakes.
Comment on lines +124 to +132
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;
}
}
Copy link

Copilot AI Jan 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This foreach loop implicitly filters its target sequence - consider filtering the sequence explicitly using '.Where(...)'.

Copilot uses AI. Check for mistakes.
Comment on lines 163 to 169
foreach (var dep in node.Dependencies)
{
if (!visited.Contains(dep.Mod.Metadata.Name))
{
stack.Push(new Progress(dep, false));
}
}
Copy link

Copilot AI Jan 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This foreach loop implicitly filters its target sequence - consider filtering the sequence explicitly using '.Where(...)'.

Copilot uses AI. Check for mistakes.
zrckr added 2 commits January 19, 2026 17:02
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).
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants