Skip to content
Open
2 changes: 1 addition & 1 deletion FEZ.HAT.mm.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
<RootNamespace>HatModLoader</RootNamespace>

<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<Nullable>disable</Nullable>
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.

Disabling nullable reference types removes compile-time null safety checks that were previously enabled. This makes the codebase more prone to NullReferenceException errors. Consider whether this change is necessary, or if specific nullable warnings could be suppressed instead where needed. If the change is intentional, ensure that null checks are added manually where nullable was previously providing protection.

Suggested change
<Nullable>disable</Nullable>
<Nullable>enable</Nullable>

Copilot uses AI. Check for mistakes.
Copy link
Member

Choose a reason for hiding this comment

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

The change is preferable due to dependency issues that arise when nullable types are used. Unless there's a direct reason for these issues to occur that can be resolved in other way, this is a quick fix.


<CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies>

Expand Down
8 changes: 4 additions & 4 deletions Installers/ModMenuInstaller.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions Patches/Fez.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -44,7 +44,7 @@ internal static void LoadComponents(Fez game)
bool doLoad = !ServiceHelper.FirstLoadDone;
orig_LoadComponents(game);
if (doLoad) {
HatML.InitalizeComponents();
HatML.InitializeComponents();
}
}

Expand Down
4 changes: 2 additions & 2 deletions Patches/FezLogo.cs
Original file line number Diff line number Diff line change
Expand Up @@ -146,15 +146,15 @@ 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);
Color warningColor = Color.Lerp(Color.White, Color.Red, alpha);

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();
Expand Down
5 changes: 2 additions & 3 deletions Patches/Program.cs
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
using Common;
using HatModLoader.Source;
using System.Globalization;
using System.Runtime.InteropServices;

namespace FezGame
{
Expand All @@ -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");
Expand Down
56 changes: 56 additions & 0 deletions Source/AssemblyResolving/AssemblyResolveCompability.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
using System.Reflection;

namespace HatModLoader.Source.AssemblyResolving
{
internal static class AssemblyResolveCompability
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 class name 'AssemblyResolveCompability' contains a spelling error. It should be 'AssemblyResolveCompatibility' with an 'i'.

Suggested change
internal static class AssemblyResolveCompability
internal static class AssemblyResolveCompatibility

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

When allowRollForward is false, the version comparison logic requires exact match on Major, Minor, and Build, but allows the Revision to be greater or equal. This means version 1.0.0.2 would be considered compatible with a request for 1.0.0.1, but 1.0.1.0 would not be compatible with a request for 1.0.0.1. This may be intentional behavior for mod compatibility, but consider documenting this specific versioning policy to clarify the expectations.

Copilot uses AI. Check for mistakes.
}
}
}

30 changes: 30 additions & 0 deletions Source/AssemblyResolving/AssemblyResolverRegistry.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
namespace HatModLoader.Source.AssemblyResolving
{
internal static class AssemblyResolverRegistry
{
private static readonly HashSet<IAssemblyResolver> 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;
}
}
}

69 changes: 69 additions & 0 deletions Source/AssemblyResolving/HatSubdirectoryAssemblyResolver.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
using System.Reflection;

namespace HatModLoader.Source.AssemblyResolving
{
internal class HatSubdirectoryAssemblyResolver : IAssemblyResolver
{
private static readonly string DependencyDirectory = "HATDependencies";

private readonly string _subdirectoryName;

public HatSubdirectoryAssemblyResolver(string subdirectoryName)
{
this._subdirectoryName = subdirectoryName;
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 use of 'this.' prefix is redundant here. Consider using just '_subdirectoryName' for consistency with the rest of the codebase.

Suggested change
this._subdirectoryName = subdirectoryName;
_subdirectoryName = subdirectoryName;

Copilot uses AI. Check for mistakes.
}


public Assembly ProvideAssembly(object sender, ResolveEventArgs args)
{
foreach (var file in EnumerateAssemblyFilesInSubdirectory())
{
if (!TryGetAssemblyName(file, out var assemblyName))
{
continue;
}

if (assemblyName.MatchesRequest(args, true))
{
return Assembly.LoadFrom(file);
}
}

return null;
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.
}

private IEnumerable<string> EnumerateAssemblyFilesInSubdirectory()
{
var path = Path.Combine(DependencyDirectory, _subdirectoryName);

if (!Directory.Exists(path))
{
yield break;
}

foreach (var file in Directory.EnumerateFiles(path, "*.dll", SearchOption.TopDirectoryOnly))
{
yield return file;
}

foreach (var file in Directory.EnumerateFiles(path, "*.exe", SearchOption.TopDirectoryOnly))
{
yield return file;
}
}

private bool TryGetAssemblyName(string filePath, out AssemblyName assemblyName)
{
assemblyName = null;
try
{
assemblyName = AssemblyName.GetAssemblyName(filePath);
return true;
}
catch
{
return false;
}
}
}
}
10 changes: 10 additions & 0 deletions Source/AssemblyResolving/IAssemblyResolver.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
using System.Reflection;

namespace HatModLoader.Source.AssemblyResolving
{
internal interface IAssemblyResolver
{
public Assembly ProvideAssembly(object sender, ResolveEventArgs args);
}
}

68 changes: 68 additions & 0 deletions Source/AssemblyResolving/ModInternalAssemblyResolver.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
using System.Reflection;
using HatModLoader.Source.ModDefinition;
using Mono.Cecil;

namespace HatModLoader.Source.AssemblyResolving
{
internal class ModInternalAssemblyResolver : IAssemblyResolver
{
private readonly ModIdentity _mod;

private readonly Dictionary<AssemblyName, string> _cachedAssemblyPaths = new();

public ModInternalAssemblyResolver(ModIdentity mod)
{
_mod = mod;
CacheAssemblyPaths();
}

public Assembly ProvideAssembly(object sender, ResolveEventArgs args)
{
if (_mod.CodeMod != null && _mod.CodeMod.Assembly.GetName().MatchesRequest(args, false))
{
return _mod.CodeMod.Assembly;
}

foreach(var assemblyName in _cachedAssemblyPaths.Keys)
{
if (assemblyName.MatchesRequest(args, false))
{
using var assemblyData = _mod.FileProxy.OpenFile(_cachedAssemblyPaths[assemblyName]);
var assemblyBytes = new byte[assemblyData.Length];
assemblyData.Read(assemblyBytes, 0, assemblyBytes.Length);
return Assembly.Load(assemblyBytes);
}
}
Comment on lines +26 to +35
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.
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.
}

private void CacheAssemblyPaths()
{
foreach (var filePath in EnumerateAssemblyFilesInMod())
{
using var assemblyFile = _mod.FileProxy.OpenFile(filePath);
using var assemblyDef = AssemblyDefinition.ReadAssembly(assemblyFile, new ReaderParameters { ReadSymbols = false });
var fullName = new AssemblyName(assemblyDef.Name.ToString());
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 AssemblyName is being constructed from assemblyDef.Name.ToString(), but Mono.Cecil's AssemblyNameDefinition should be used differently. The correct approach is to use 'assemblyDef.Name.FullName' to get the full assembly name string, or construct the AssemblyName from individual properties. Using ToString() on AssemblyNameDefinition may not produce a correctly formatted assembly name string.

Suggested change
var fullName = new AssemblyName(assemblyDef.Name.ToString());
var fullName = new AssemblyName(assemblyDef.Name.FullName);

Copilot uses AI. Check for mistakes.

if (!_cachedAssemblyPaths.ContainsKey(fullName))
{
_cachedAssemblyPaths[fullName] = filePath;
}
}
}

private IEnumerable<string> EnumerateAssemblyFilesInMod()
{
foreach (var file in _mod.FileProxy.EnumerateFiles(""))
{
if (
file.EndsWith(".dll", StringComparison.OrdinalIgnoreCase) ||
file.EndsWith(".exe", StringComparison.OrdinalIgnoreCase)
) {
yield return file;
}
}
Comment on lines +56 to +64
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.
}
}
}

Loading