summaryrefslogtreecommitdiff
path: root/src/SMAPI/Framework/ModLoading
diff options
context:
space:
mode:
Diffstat (limited to 'src/SMAPI/Framework/ModLoading')
-rw-r--r--src/SMAPI/Framework/ModLoading/AssemblyDefinitionResolver.cs20
-rw-r--r--src/SMAPI/Framework/ModLoading/AssemblyLoader.cs90
-rw-r--r--src/SMAPI/Framework/ModLoading/Finders/ReferenceToMemberWithUnexpectedTypeFinder.cs37
-rw-r--r--src/SMAPI/Framework/ModLoading/Finders/ReferenceToMissingMemberFinder.cs11
-rw-r--r--src/SMAPI/Framework/ModLoading/Finders/TypeFinder.cs10
-rw-r--r--src/SMAPI/Framework/ModLoading/ModMetadata.cs53
-rw-r--r--src/SMAPI/Framework/ModLoading/ModResolver.cs59
-rw-r--r--src/SMAPI/Framework/ModLoading/ModWarning.cs31
-rw-r--r--src/SMAPI/Framework/ModLoading/Platform.cs12
-rw-r--r--src/SMAPI/Framework/ModLoading/PlatformAssemblyMap.cs13
-rw-r--r--src/SMAPI/Framework/ModLoading/RewriteHelper.cs18
-rw-r--r--src/SMAPI/Framework/ModLoading/Rewriters/FieldReplaceRewriter.cs2
-rw-r--r--src/SMAPI/Framework/ModLoading/Rewriters/FieldToPropertyRewriter.cs2
-rw-r--r--src/SMAPI/Framework/ModLoading/Rewriters/MethodParentRewriter.cs2
-rw-r--r--src/SMAPI/Framework/ModLoading/Rewriters/TypeReferenceRewriter.cs56
-rw-r--r--src/SMAPI/Framework/ModLoading/TypeReferenceComparer.cs201
16 files changed, 454 insertions, 163 deletions
diff --git a/src/SMAPI/Framework/ModLoading/AssemblyDefinitionResolver.cs b/src/SMAPI/Framework/ModLoading/AssemblyDefinitionResolver.cs
index d85a9a28..91c9e192 100644
--- a/src/SMAPI/Framework/ModLoading/AssemblyDefinitionResolver.cs
+++ b/src/SMAPI/Framework/ModLoading/AssemblyDefinitionResolver.cs
@@ -10,7 +10,7 @@ namespace StardewModdingAPI.Framework.ModLoading
** Properties
*********/
/// <summary>The known assemblies.</summary>
- private readonly IDictionary<string, AssemblyDefinition> Loaded = new Dictionary<string, AssemblyDefinition>();
+ private readonly IDictionary<string, AssemblyDefinition> Lookup = new Dictionary<string, AssemblyDefinition>();
/*********
@@ -22,8 +22,9 @@ namespace StardewModdingAPI.Framework.ModLoading
{
foreach (AssemblyDefinition assembly in assemblies)
{
- this.Loaded[assembly.Name.Name] = assembly;
- this.Loaded[assembly.Name.FullName] = assembly;
+ this.RegisterAssembly(assembly);
+ this.Lookup[assembly.Name.Name] = assembly;
+ this.Lookup[assembly.Name.FullName] = assembly;
}
}
@@ -36,15 +37,6 @@ namespace StardewModdingAPI.Framework.ModLoading
/// <param name="parameters">The assembly reader parameters.</param>
public override AssemblyDefinition Resolve(AssemblyNameReference name, ReaderParameters parameters) => this.ResolveName(name.Name) ?? base.Resolve(name, parameters);
- /// <summary>Resolve an assembly reference.</summary>
- /// <param name="fullName">The assembly full name (including version, etc).</param>
- public override AssemblyDefinition Resolve(string fullName) => this.ResolveName(fullName) ?? base.Resolve(fullName);
-
- /// <summary>Resolve an assembly reference.</summary>
- /// <param name="fullName">The assembly full name (including version, etc).</param>
- /// <param name="parameters">The assembly reader parameters.</param>
- public override AssemblyDefinition Resolve(string fullName, ReaderParameters parameters) => this.ResolveName(fullName) ?? base.Resolve(fullName, parameters);
-
/*********
** Private methods
@@ -53,8 +45,8 @@ namespace StardewModdingAPI.Framework.ModLoading
/// <param name="name">The assembly's short or full name.</param>
private AssemblyDefinition ResolveName(string name)
{
- return this.Loaded.ContainsKey(name)
- ? this.Loaded[name]
+ return this.Lookup.TryGetValue(name, out AssemblyDefinition match)
+ ? match
: null;
}
}
diff --git a/src/SMAPI/Framework/ModLoading/AssemblyLoader.cs b/src/SMAPI/Framework/ModLoading/AssemblyLoader.cs
index a60f63da..37b1a378 100644
--- a/src/SMAPI/Framework/ModLoading/AssemblyLoader.cs
+++ b/src/SMAPI/Framework/ModLoading/AssemblyLoader.cs
@@ -6,12 +6,13 @@ using System.Reflection;
using Mono.Cecil;
using Mono.Cecil.Cil;
using StardewModdingAPI.Framework.Exceptions;
+using StardewModdingAPI.Internal;
using StardewModdingAPI.Metadata;
namespace StardewModdingAPI.Framework.ModLoading
{
/// <summary>Preprocesses and loads mod assemblies.</summary>
- internal class AssemblyLoader
+ internal class AssemblyLoader : IDisposable
{
/*********
** Properties
@@ -19,9 +20,6 @@ namespace StardewModdingAPI.Framework.ModLoading
/// <summary>Encapsulates monitoring and logging.</summary>
private readonly IMonitor Monitor;
- /// <summary>Whether to enable developer mode logging.</summary>
- private readonly bool IsDeveloperMode;
-
/// <summary>Metadata for mapping assemblies to the current platform.</summary>
private readonly PlatformAssemblyMap AssemblyMap;
@@ -31,6 +29,9 @@ namespace StardewModdingAPI.Framework.ModLoading
/// <summary>A minimal assembly definition resolver which resolves references to known loaded assemblies.</summary>
private readonly AssemblyDefinitionResolver AssemblyDefinitionResolver;
+ /// <summary>The objects to dispose as part of this instance.</summary>
+ private readonly HashSet<IDisposable> Disposables = new HashSet<IDisposable>();
+
/*********
** Public methods
@@ -38,13 +39,12 @@ namespace StardewModdingAPI.Framework.ModLoading
/// <summary>Construct an instance.</summary>
/// <param name="targetPlatform">The current game platform.</param>
/// <param name="monitor">Encapsulates monitoring and logging.</param>
- /// <param name="isDeveloperMode">Whether to enable developer mode logging.</param>
- public AssemblyLoader(Platform targetPlatform, IMonitor monitor, bool isDeveloperMode)
+ public AssemblyLoader(Platform targetPlatform, IMonitor monitor)
{
this.Monitor = monitor;
- this.IsDeveloperMode = isDeveloperMode;
- this.AssemblyMap = Constants.GetAssemblyMap(targetPlatform);
- this.AssemblyDefinitionResolver = new AssemblyDefinitionResolver();
+ this.AssemblyMap = this.TrackForDisposal(Constants.GetAssemblyMap(targetPlatform));
+ this.AssemblyDefinitionResolver = this.TrackForDisposal(new AssemblyDefinitionResolver());
+ this.AssemblyDefinitionResolver.AddSearchDirectory(Constants.ExecutionPath);
// generate type => assembly lookup for types which should be rewritten
this.TypeAssemblies = new Dictionary<string, Assembly>();
@@ -98,13 +98,26 @@ namespace StardewModdingAPI.Framework.ModLoading
continue;
// rewrite assembly
- bool changed = this.RewriteAssembly(mod, assembly.Definition, assumeCompatible, loggedMessages, logPrefix: " ");
+ bool changed = this.RewriteAssembly(mod, assembly.Definition, assumeCompatible, loggedMessages, logPrefix: " ");
+
+ // detect broken assembly reference
+ foreach (AssemblyNameReference reference in assembly.Definition.MainModule.AssemblyReferences)
+ {
+ if (!reference.Name.StartsWith("System.") && !this.IsAssemblyLoaded(reference))
+ {
+ this.Monitor.LogOnce(loggedMessages, $" Broken code in {assembly.File.Name}: reference to missing assembly '{reference.FullName}'.");
+ if (!assumeCompatible)
+ throw new IncompatibleInstructionException($"assembly reference to {reference.FullName}", $"Found a reference to missing assembly '{reference.FullName}' while loading assembly {assembly.File.Name}.");
+ mod.SetWarning(ModWarning.BrokenCodeLoaded);
+ break;
+ }
+ }
// load assembly
if (changed)
{
if (!oneAssembly)
- this.Monitor.Log($" Loading {assembly.File.Name} (rewritten in memory)...", LogLevel.Trace);
+ this.Monitor.Log($" Loading {assembly.File.Name} (rewritten in memory)...", LogLevel.Trace);
using (MemoryStream outStream = new MemoryStream())
{
assembly.Definition.Write(outStream);
@@ -115,7 +128,7 @@ namespace StardewModdingAPI.Framework.ModLoading
else
{
if (!oneAssembly)
- this.Monitor.Log($" Loading {assembly.File.Name}...", LogLevel.Trace);
+ this.Monitor.Log($" Loading {assembly.File.Name}...", LogLevel.Trace);
lastAssembly = Assembly.UnsafeLoadFrom(assembly.File.FullName);
}
@@ -127,6 +140,20 @@ namespace StardewModdingAPI.Framework.ModLoading
return lastAssembly;
}
+ /// <summary>Get whether an assembly is loaded.</summary>
+ /// <param name="reference">The assembly name reference.</param>
+ public bool IsAssemblyLoaded(AssemblyNameReference reference)
+ {
+ try
+ {
+ return this.AssemblyDefinitionResolver.Resolve(reference) != null;
+ }
+ catch (AssemblyResolutionException)
+ {
+ return false;
+ }
+ }
+
/// <summary>Resolve an assembly by its name.</summary>
/// <param name="name">The assembly name.</param>
/// <remarks>
@@ -135,7 +162,7 @@ namespace StardewModdingAPI.Framework.ModLoading
/// assemblies (especially with Mono). Since this is meant to be called on <see cref="AppDomain.AssemblyResolve"/>,
/// the implicit assumption is that loading the exact assembly failed.
/// </remarks>
- public Assembly ResolveAssembly(string name)
+ public static Assembly ResolveAssembly(string name)
{
string shortName = name.Split(new[] { ',' }, 2).First(); // get simple name (without version and culture)
return AppDomain.CurrentDomain
@@ -143,10 +170,26 @@ namespace StardewModdingAPI.Framework.ModLoading
.FirstOrDefault(p => p.GetName().Name == shortName);
}
+ /// <summary>Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.</summary>
+ public void Dispose()
+ {
+ foreach (IDisposable instance in this.Disposables)
+ instance.Dispose();
+ }
+
/*********
** Private methods
*********/
+ /// <summary>Track an object for disposal as part of the assembly loader.</summary>
+ /// <typeparam name="T">The instance type.</typeparam>
+ /// <param name="instance">The disposable instance.</param>
+ private T TrackForDisposal<T>(T instance) where T : IDisposable
+ {
+ this.Disposables.Add(instance);
+ return instance;
+ }
+
/****
** Assembly parsing
****/
@@ -165,9 +208,8 @@ namespace StardewModdingAPI.Framework.ModLoading
// read assembly
byte[] assemblyBytes = File.ReadAllBytes(file.FullName);
- AssemblyDefinition assembly;
- using (Stream readStream = new MemoryStream(assemblyBytes))
- assembly = AssemblyDefinition.ReadAssembly(readStream, new ReaderParameters(ReadingMode.Deferred) { AssemblyResolver = assemblyResolver });
+ Stream readStream = this.TrackForDisposal(new MemoryStream(assemblyBytes));
+ AssemblyDefinition assembly = this.TrackForDisposal(AssemblyDefinition.ReadAssembly(readStream, new ReaderParameters(ReadingMode.Immediate) { AssemblyResolver = assemblyResolver, InMemory = true }));
// skip if already visited
if (visitedAssemblyNames.Contains(assembly.Name.Name))
@@ -284,33 +326,27 @@ namespace StardewModdingAPI.Framework.ModLoading
this.Monitor.LogOnce(loggedMessages, $"{logPrefix}Broken code in {filename}: {handler.NounPhrase}.");
if (!assumeCompatible)
throw new IncompatibleInstructionException(handler.NounPhrase, $"Found an incompatible CIL instruction ({handler.NounPhrase}) while loading assembly {filename}.");
- this.Monitor.LogOnce(loggedMessages, $"{logPrefix}Found broken code ({handler.NounPhrase}) while loading assembly {filename}, but SMAPI is configured to allow it anyway. The mod may crash or behave unexpectedly.", LogLevel.Warn);
+ mod.SetWarning(ModWarning.BrokenCodeLoaded);
break;
case InstructionHandleResult.DetectedGamePatch:
this.Monitor.LogOnce(loggedMessages, $"{logPrefix}Detected game patcher ({handler.NounPhrase}) in assembly {filename}.");
- this.Monitor.LogOnce(loggedMessages, $"{mod.DisplayName} patches the game, which may impact game stability. If you encounter problems, try removing this mod first.", LogLevel.Warn);
+ mod.SetWarning(ModWarning.PatchesGame);
break;
case InstructionHandleResult.DetectedSaveSerialiser:
this.Monitor.LogOnce(loggedMessages, $"{logPrefix}Detected possible save serialiser change ({handler.NounPhrase}) in assembly {filename}.");
- this.Monitor.LogOnce(loggedMessages, $"{mod.DisplayName} seems to change the save serialiser. It may change your saves in such a way that they won't work without this mod in the future.", LogLevel.Warn);
+ mod.SetWarning(ModWarning.ChangesSaveSerialiser);
break;
case InstructionHandleResult.DetectedUnvalidatedUpdateTick:
this.Monitor.LogOnce(loggedMessages, $"{logPrefix}Detected reference to {handler.NounPhrase} in assembly {filename}.");
- this.Monitor.LogOnce(loggedMessages, $"{mod.DisplayName} uses a specialised SMAPI event that may crash the game or corrupt your save file. If you encounter problems, try removing this mod first.", LogLevel.Warn);
+ mod.SetWarning(ModWarning.UsesUnvalidatedUpdateTick);
break;
case InstructionHandleResult.DetectedDynamic:
this.Monitor.LogOnce(loggedMessages, $"{logPrefix}Detected 'dynamic' keyword ({handler.NounPhrase}) in assembly {filename}.");
- this.Monitor.LogOnce(loggedMessages, $"{mod.DisplayName} uses the 'dynamic' keyword, which isn't compatible with Stardew Valley on Linux or Mac.",
-#if SMAPI_FOR_WINDOWS
- this.IsDeveloperMode ? LogLevel.Warn : LogLevel.Debug
-#else
- LogLevel.Warn
-#endif
- );
+ mod.SetWarning(ModWarning.UsesDynamic);
break;
case InstructionHandleResult.None:
diff --git a/src/SMAPI/Framework/ModLoading/Finders/ReferenceToMemberWithUnexpectedTypeFinder.cs b/src/SMAPI/Framework/ModLoading/Finders/ReferenceToMemberWithUnexpectedTypeFinder.cs
index b5e45742..cf5a3175 100644
--- a/src/SMAPI/Framework/ModLoading/Finders/ReferenceToMemberWithUnexpectedTypeFinder.cs
+++ b/src/SMAPI/Framework/ModLoading/Finders/ReferenceToMemberWithUnexpectedTypeFinder.cs
@@ -1,6 +1,5 @@
using System.Collections.Generic;
using System.Linq;
-using System.Text.RegularExpressions;
using Mono.Cecil;
using Mono.Cecil.Cil;
@@ -16,9 +15,6 @@ namespace StardewModdingAPI.Framework.ModLoading.Finders
/// <summary>The assembly names to which to heuristically detect broken references.</summary>
private readonly HashSet<string> ValidateReferencesToAssemblies;
- /// <summary>A pattern matching type name substrings to strip for display.</summary>
- private readonly Regex StripTypeNamePattern = new Regex(@"`\d+(?=<)", RegexOptions.Compiled);
-
/*********
** Accessors
@@ -59,21 +55,15 @@ namespace StardewModdingAPI.Framework.ModLoading.Finders
FieldReference fieldRef = RewriteHelper.AsFieldReference(instruction);
if (fieldRef != null && this.ShouldValidate(fieldRef.DeclaringType))
{
- // can't compare generic type parameters between definition and reference
- if (fieldRef.FieldType.IsGenericInstance || fieldRef.FieldType.IsGenericParameter)
- return InstructionHandleResult.None;
-
// get target field
FieldDefinition targetField = fieldRef.DeclaringType.Resolve()?.Fields.FirstOrDefault(p => p.Name == fieldRef.Name);
if (targetField == null)
return InstructionHandleResult.None;
// validate return type
- string actualReturnTypeID = this.GetComparableTypeID(targetField.FieldType);
- string expectedReturnTypeID = this.GetComparableTypeID(fieldRef.FieldType);
- if (actualReturnTypeID != expectedReturnTypeID)
+ if (!RewriteHelper.LooksLikeSameType(fieldRef.FieldType, targetField.FieldType))
{
- this.NounPhrase = $"reference to {fieldRef.DeclaringType.FullName}.{fieldRef.Name} (field returns {this.GetFriendlyTypeName(targetField.FieldType, actualReturnTypeID)}, not {this.GetFriendlyTypeName(fieldRef.FieldType, expectedReturnTypeID)})";
+ this.NounPhrase = $"reference to {fieldRef.DeclaringType.FullName}.{fieldRef.Name} (field returns {this.GetFriendlyTypeName(targetField.FieldType)}, not {this.GetFriendlyTypeName(fieldRef.FieldType)})";
return InstructionHandleResult.NotCompatible;
}
}
@@ -82,10 +72,6 @@ namespace StardewModdingAPI.Framework.ModLoading.Finders
MethodReference methodReference = RewriteHelper.AsMethodReference(instruction);
if (methodReference != null && this.ShouldValidate(methodReference.DeclaringType))
{
- // can't compare generic type parameters between definition and reference
- if (methodReference.ReturnType.IsGenericInstance || methodReference.ReturnType.IsGenericParameter)
- return InstructionHandleResult.None;
-
// get potential targets
MethodDefinition[] candidateMethods = methodReference.DeclaringType.Resolve()?.Methods.Where(found => found.Name == methodReference.Name).ToArray();
if (candidateMethods == null || !candidateMethods.Any())
@@ -99,10 +85,9 @@ namespace StardewModdingAPI.Framework.ModLoading.Finders
return InstructionHandleResult.NotCompatible;
}
- string expectedReturnType = this.GetComparableTypeID(methodDef.ReturnType);
- if (candidateMethods.All(method => this.GetComparableTypeID(method.ReturnType) != expectedReturnType))
+ if (candidateMethods.All(method => !RewriteHelper.LooksLikeSameType(method.ReturnType, methodDef.ReturnType)))
{
- this.NounPhrase = $"reference to {methodDef.DeclaringType.FullName}.{methodDef.Name} (no such method returns {this.GetFriendlyTypeName(methodDef.ReturnType, expectedReturnType)})";
+ this.NounPhrase = $"reference to {methodDef.DeclaringType.FullName}.{methodDef.Name} (no such method returns {this.GetFriendlyTypeName(methodDef.ReturnType)})";
return InstructionHandleResult.NotCompatible;
}
}
@@ -121,17 +106,9 @@ namespace StardewModdingAPI.Framework.ModLoading.Finders
return type != null && this.ValidateReferencesToAssemblies.Contains(type.Scope.Name);
}
- /// <summary>Get a unique string representation of a type.</summary>
- /// <param name="type">The type reference.</param>
- private string GetComparableTypeID(TypeReference type)
- {
- return this.StripTypeNamePattern.Replace(type.FullName, "");
- }
-
/// <summary>Get a shorter type name for display.</summary>
/// <param name="type">The type reference.</param>
- /// <param name="typeID">The comparable type ID from <see cref="GetComparableTypeID"/>.</param>
- private string GetFriendlyTypeName(TypeReference type, string typeID)
+ private string GetFriendlyTypeName(TypeReference type)
{
// most common built-in types
switch (type.FullName)
@@ -148,10 +125,10 @@ namespace StardewModdingAPI.Framework.ModLoading.Finders
foreach (string @namespace in new[] { "Microsoft.Xna.Framework", "Netcode", "System", "System.Collections.Generic" })
{
if (type.Namespace == @namespace)
- return typeID.Substring(@namespace.Length + 1);
+ return type.Name;
}
- return typeID;
+ return type.FullName;
}
}
}
diff --git a/src/SMAPI/Framework/ModLoading/Finders/ReferenceToMissingMemberFinder.cs b/src/SMAPI/Framework/ModLoading/Finders/ReferenceToMissingMemberFinder.cs
index f5e33313..b95dd79c 100644
--- a/src/SMAPI/Framework/ModLoading/Finders/ReferenceToMissingMemberFinder.cs
+++ b/src/SMAPI/Framework/ModLoading/Finders/ReferenceToMissingMemberFinder.cs
@@ -67,12 +67,15 @@ namespace StardewModdingAPI.Framework.ModLoading.Finders
MethodReference methodRef = RewriteHelper.AsMethodReference(instruction);
if (methodRef != null && this.ShouldValidate(methodRef.DeclaringType) && !this.IsUnsupported(methodRef))
{
- MethodDefinition target = methodRef.DeclaringType.Resolve()?.Methods.FirstOrDefault(p => p.Name == methodRef.Name);
+ MethodDefinition target = methodRef.Resolve();
if (target == null)
{
- this.NounPhrase = this.IsProperty(methodRef)
- ? $"reference to {methodRef.DeclaringType.FullName}.{methodRef.Name.Substring(4)} (no such property)"
- : $"reference to {methodRef.DeclaringType.FullName}.{methodRef.Name} (no such method)";
+ if (this.IsProperty(methodRef))
+ this.NounPhrase = $"reference to {methodRef.DeclaringType.FullName}.{methodRef.Name.Substring(4)} (no such property)";
+ else if (methodRef.Name == ".ctor")
+ this.NounPhrase = $"reference to {methodRef.DeclaringType.FullName}.{methodRef.Name} (no matching constructor)";
+ else
+ this.NounPhrase = $"reference to {methodRef.DeclaringType.FullName}.{methodRef.Name} (no such method)";
return InstructionHandleResult.NotCompatible;
}
}
diff --git a/src/SMAPI/Framework/ModLoading/Finders/TypeFinder.cs b/src/SMAPI/Framework/ModLoading/Finders/TypeFinder.cs
index 45349def..79045241 100644
--- a/src/SMAPI/Framework/ModLoading/Finders/TypeFinder.cs
+++ b/src/SMAPI/Framework/ModLoading/Finders/TypeFinder.cs
@@ -1,3 +1,4 @@
+using System;
using System.Linq;
using Mono.Cecil;
using Mono.Cecil.Cil;
@@ -16,6 +17,9 @@ namespace StardewModdingAPI.Framework.ModLoading.Finders
/// <summary>The result to return for matching instructions.</summary>
private readonly InstructionHandleResult Result;
+ /// <summary>A lambda which overrides a matched type.</summary>
+ protected readonly Func<TypeReference, bool> ShouldIgnore;
+
/*********
** Accessors
@@ -30,11 +34,13 @@ namespace StardewModdingAPI.Framework.ModLoading.Finders
/// <summary>Construct an instance.</summary>
/// <param name="fullTypeName">The full type name to match.</param>
/// <param name="result">The result to return for matching instructions.</param>
- public TypeFinder(string fullTypeName, InstructionHandleResult result)
+ /// <param name="shouldIgnore">A lambda which overrides a matched type.</param>
+ public TypeFinder(string fullTypeName, InstructionHandleResult result, Func<TypeReference, bool> shouldIgnore = null)
{
this.FullTypeName = fullTypeName;
this.Result = result;
this.NounPhrase = $"{fullTypeName} type";
+ this.ShouldIgnore = shouldIgnore ?? (p => false);
}
/// <summary>Perform the predefined logic for a method if applicable.</summary>
@@ -113,7 +119,7 @@ namespace StardewModdingAPI.Framework.ModLoading.Finders
protected bool IsMatch(TypeReference type)
{
// root type
- if (type.FullName == this.FullTypeName)
+ if (type.FullName == this.FullTypeName && !this.ShouldIgnore(type))
return true;
// generic arguments
diff --git a/src/SMAPI/Framework/ModLoading/ModMetadata.cs b/src/SMAPI/Framework/ModLoading/ModMetadata.cs
index 1a0f9994..585debb4 100644
--- a/src/SMAPI/Framework/ModLoading/ModMetadata.cs
+++ b/src/SMAPI/Framework/ModLoading/ModMetadata.cs
@@ -1,5 +1,7 @@
using System;
-using StardewModdingAPI.Framework.ModData;
+using System.Linq;
+using StardewModdingAPI.Toolkit.Framework.Clients.WebApi;
+using StardewModdingAPI.Toolkit.Framework.ModData;
namespace StardewModdingAPI.Framework.ModLoading
{
@@ -19,11 +21,14 @@ namespace StardewModdingAPI.Framework.ModLoading
public IManifest Manifest { get; }
/// <summary>Metadata about the mod from SMAPI's internal data (if any).</summary>
- public ParsedModDataRecord DataRecord { get; }
+ public ModDataRecordVersionedFields DataRecord { get; }
/// <summary>The metadata resolution status.</summary>
public ModMetadataStatus Status { get; private set; }
+ /// <summary>Indicates non-error issues with the mod.</summary>
+ public ModWarning Warnings { get; private set; }
+
/// <summary>The reason the metadata is invalid, if any.</summary>
public string Error { get; private set; }
@@ -39,6 +44,9 @@ namespace StardewModdingAPI.Framework.ModLoading
/// <summary>The mod-provided API (if any).</summary>
public object Api { get; private set; }
+ /// <summary>The update-check metadata for this mod (if any).</summary>
+ public ModEntryModel UpdateCheckData { get; private set; }
+
/// <summary>Whether the mod is a content pack.</summary>
public bool IsContentPack => this.Manifest?.ContentPackFor != null;
@@ -51,7 +59,7 @@ namespace StardewModdingAPI.Framework.ModLoading
/// <param name="directoryPath">The mod's full directory path.</param>
/// <param name="manifest">The mod manifest.</param>
/// <param name="dataRecord">Metadata about the mod from SMAPI's internal data (if any).</param>
- public ModMetadata(string displayName, string directoryPath, IManifest manifest, ParsedModDataRecord dataRecord)
+ public ModMetadata(string displayName, string directoryPath, IManifest manifest, ModDataRecordVersionedFields dataRecord)
{
this.DisplayName = displayName;
this.DirectoryPath = directoryPath;
@@ -70,6 +78,14 @@ namespace StardewModdingAPI.Framework.ModLoading
return this;
}
+ /// <summary>Set a warning flag for the mod.</summary>
+ /// <param name="warning">The warning to set.</param>
+ public IModMetadata SetWarning(ModWarning warning)
+ {
+ this.Warnings |= warning;
+ return this;
+ }
+
/// <summary>Set the mod instance.</summary>
/// <param name="mod">The mod instance to set.</param>
public IModMetadata SetMod(IMod mod)
@@ -102,5 +118,36 @@ namespace StardewModdingAPI.Framework.ModLoading
this.Api = api;
return this;
}
+
+ /// <summary>Set the update-check metadata for this mod.</summary>
+ /// <param name="data">The update-check metadata.</param>
+ public IModMetadata SetUpdateData(ModEntryModel data)
+ {
+ this.UpdateCheckData = data;
+ return this;
+ }
+
+ /// <summary>Whether the mod manifest was loaded (regardless of whether the mod itself was loaded).</summary>
+ public bool HasManifest()
+ {
+ return this.Manifest != null;
+ }
+
+ /// <summary>Whether the mod has an ID (regardless of whether the ID is valid or the mod itself was loaded).</summary>
+ public bool HasID()
+ {
+ return
+ this.HasManifest()
+ && !string.IsNullOrWhiteSpace(this.Manifest.UniqueID);
+ }
+
+ /// <summary>Whether the mod has at least one update key set.</summary>
+ public bool HasUpdateKeys()
+ {
+ return
+ this.HasManifest()
+ && this.Manifest.UpdateKeys != null
+ && this.Manifest.UpdateKeys.Any(key => !string.IsNullOrWhiteSpace(key));
+ }
}
}
diff --git a/src/SMAPI/Framework/ModLoading/ModResolver.cs b/src/SMAPI/Framework/ModLoading/ModResolver.cs
index a9896278..9ac95fd4 100644
--- a/src/SMAPI/Framework/ModLoading/ModResolver.cs
+++ b/src/SMAPI/Framework/ModLoading/ModResolver.cs
@@ -2,11 +2,12 @@ using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
-using StardewModdingAPI.Framework.Exceptions;
-using StardewModdingAPI.Framework.ModData;
-using StardewModdingAPI.Framework.Models;
-using StardewModdingAPI.Framework.Serialisation;
-using StardewModdingAPI.Framework.Utilities;
+using System.Text.RegularExpressions;
+using StardewModdingAPI.Toolkit;
+using StardewModdingAPI.Toolkit.Framework.ModData;
+using StardewModdingAPI.Toolkit.Framework.ModScanning;
+using StardewModdingAPI.Toolkit.Serialisation.Models;
+using StardewModdingAPI.Toolkit.Utilities;
namespace StardewModdingAPI.Framework.ModLoading
{
@@ -17,46 +18,25 @@ namespace StardewModdingAPI.Framework.ModLoading
** Public methods
*********/
/// <summary>Get manifest metadata for each folder in the given root path.</summary>
+ /// <param name="toolkit">The mod toolkit.</param>
/// <param name="rootPath">The root path to search for mods.</param>
- /// <param name="jsonHelper">The JSON helper with which to read manifests.</param>
/// <param name="modDatabase">Handles access to SMAPI's internal mod metadata list.</param>
/// <returns>Returns the manifests by relative folder.</returns>
- public IEnumerable<IModMetadata> ReadManifests(string rootPath, JsonHelper jsonHelper, ModDatabase modDatabase)
+ public IEnumerable<IModMetadata> ReadManifests(ModToolkit toolkit, string rootPath, ModDatabase modDatabase)
{
- foreach (DirectoryInfo modDir in this.GetModFolders(rootPath))
+ foreach (ModFolder folder in toolkit.GetModFolders(rootPath))
{
- // read file
- Manifest manifest = null;
- string path = Path.Combine(modDir.FullName, "manifest.json");
- string error = null;
- try
- {
- manifest = jsonHelper.ReadJsonFile<Manifest>(path);
- if (manifest == null)
- {
- error = File.Exists(path)
- ? "its manifest is invalid."
- : "it doesn't have a manifest.";
- }
- }
- catch (SParseException ex)
- {
- error = $"parsing its manifest failed: {ex.Message}";
- }
- catch (Exception ex)
- {
- error = $"parsing its manifest failed:\n{ex.GetLogSummary()}";
- }
+ Manifest manifest = folder.Manifest;
// parse internal data record (if any)
- ParsedModDataRecord dataRecord = modDatabase.GetParsed(manifest);
+ ModDataRecordVersionedFields dataRecord = modDatabase.Get(manifest?.UniqueID)?.GetVersionedFields(manifest);
// get display name
string displayName = manifest?.Name;
if (string.IsNullOrWhiteSpace(displayName))
displayName = dataRecord?.DisplayName;
if (string.IsNullOrWhiteSpace(displayName))
- displayName = PathUtilities.GetRelativePath(rootPath, modDir.FullName);
+ displayName = PathUtilities.GetRelativePath(rootPath, folder.ActualDirectory?.FullName ?? folder.SearchDirectory.FullName);
// apply defaults
if (manifest != null && dataRecord != null)
@@ -66,10 +46,10 @@ namespace StardewModdingAPI.Framework.ModLoading
}
// build metadata
- ModMetadataStatus status = error == null
+ ModMetadataStatus status = folder.ManifestParseError == null
? ModMetadataStatus.Found
: ModMetadataStatus.Failed;
- yield return new ModMetadata(displayName, modDir.FullName, manifest, dataRecord).SetStatus(status, error);
+ yield return new ModMetadata(displayName, folder.ActualDirectory?.FullName, manifest, dataRecord).SetStatus(status, folder.ManifestParseError);
}
}
@@ -98,7 +78,7 @@ namespace StardewModdingAPI.Framework.ModLoading
case ModStatus.AssumeBroken:
{
// get reason
- string reasonPhrase = mod.DataRecord.StatusReasonPhrase ?? "it's outdated";
+ string reasonPhrase = mod.DataRecord.StatusReasonPhrase ?? "it's no longer compatible";
// get update URLs
List<string> updateUrls = new List<string>();
@@ -194,8 +174,15 @@ namespace StardewModdingAPI.Framework.ModLoading
missingFields.Add(nameof(IManifest.UniqueID));
if (missingFields.Any())
+ {
mod.SetStatus(ModMetadataStatus.Failed, $"its manifest is missing required fields ({string.Join(", ", missingFields)}).");
+ continue;
+ }
}
+
+ // validate ID format
+ if (Regex.IsMatch(mod.Manifest.UniqueID, "[^a-z0-9_.-]", RegexOptions.IgnoreCase))
+ mod.SetStatus(ModMetadataStatus.Failed, "its manifest specifies an invalid ID (IDs must only contain letters, numbers, underscores, periods, or hyphens).");
}
// validate IDs are unique
@@ -292,7 +279,7 @@ namespace StardewModdingAPI.Framework.ModLoading
string[] failedModNames = (
from entry in dependencies
where entry.IsRequired && entry.Mod == null
- let displayName = modDatabase.GetDisplayNameFor(entry.ID) ?? entry.ID
+ let displayName = modDatabase.Get(entry.ID)?.DisplayName ?? entry.ID
let modUrl = modDatabase.GetModPageUrlFor(entry.ID)
orderby displayName
select modUrl != null
diff --git a/src/SMAPI/Framework/ModLoading/ModWarning.cs b/src/SMAPI/Framework/ModLoading/ModWarning.cs
new file mode 100644
index 00000000..0e4b2570
--- /dev/null
+++ b/src/SMAPI/Framework/ModLoading/ModWarning.cs
@@ -0,0 +1,31 @@
+using System;
+using StardewModdingAPI.Events;
+
+namespace StardewModdingAPI.Framework.ModLoading
+{
+ /// <summary>Indicates a detected non-error mod issue.</summary>
+ [Flags]
+ internal enum ModWarning
+ {
+ /// <summary>No issues detected.</summary>
+ None = 0,
+
+ /// <summary>SMAPI detected incompatible code in the mod, but was configured to load it anyway.</summary>
+ BrokenCodeLoaded = 1,
+
+ /// <summary>The mod affects the save serializer in a way that may make saves unloadable without the mod.</summary>
+ ChangesSaveSerialiser = 2,
+
+ /// <summary>The mod patches the game in a way that may impact stability.</summary>
+ PatchesGame = 4,
+
+ /// <summary>The mod uses the <c>dynamic</c> keyword which won't work on Linux/Mac.</summary>
+ UsesDynamic = 8,
+
+ /// <summary>The mod references <see cref="SpecialisedEvents.UnvalidatedUpdateTick"/> which may impact stability.</summary>
+ UsesUnvalidatedUpdateTick = 16,
+
+ /// <summary>The mod has no update keys set.</summary>
+ NoUpdateKeys = 32
+ }
+}
diff --git a/src/SMAPI/Framework/ModLoading/Platform.cs b/src/SMAPI/Framework/ModLoading/Platform.cs
deleted file mode 100644
index 45e881c4..00000000
--- a/src/SMAPI/Framework/ModLoading/Platform.cs
+++ /dev/null
@@ -1,12 +0,0 @@
-namespace StardewModdingAPI.Framework.ModLoading
-{
- /// <summary>The game's platform version.</summary>
- internal enum Platform
- {
- /// <summary>The Linux/Mac version of the game.</summary>
- Mono,
-
- /// <summary>The Windows version of the game.</summary>
- Windows
- }
-}
diff --git a/src/SMAPI/Framework/ModLoading/PlatformAssemblyMap.cs b/src/SMAPI/Framework/ModLoading/PlatformAssemblyMap.cs
index 463f45e8..01460dce 100644
--- a/src/SMAPI/Framework/ModLoading/PlatformAssemblyMap.cs
+++ b/src/SMAPI/Framework/ModLoading/PlatformAssemblyMap.cs
@@ -1,12 +1,14 @@
+using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using Mono.Cecil;
+using StardewModdingAPI.Internal;
namespace StardewModdingAPI.Framework.ModLoading
{
/// <summary>Metadata for mapping assemblies to the current <see cref="Platform"/>.</summary>
- internal class PlatformAssemblyMap
+ internal class PlatformAssemblyMap : IDisposable
{
/*********
** Accessors
@@ -49,7 +51,14 @@ namespace StardewModdingAPI.Framework.ModLoading
// cache assembly metadata
this.Targets = targetAssemblies;
this.TargetReferences = this.Targets.ToDictionary(assembly => assembly, assembly => AssemblyNameReference.Parse(assembly.FullName));
- this.TargetModules = this.Targets.ToDictionary(assembly => assembly, assembly => ModuleDefinition.ReadModule(assembly.Modules.Single().FullyQualifiedName));
+ this.TargetModules = this.Targets.ToDictionary(assembly => assembly, assembly => ModuleDefinition.ReadModule(assembly.Modules.Single().FullyQualifiedName, new ReaderParameters { InMemory = true }));
+ }
+
+ /// <summary>Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.</summary>
+ public void Dispose()
+ {
+ foreach (ModuleDefinition module in this.TargetModules.Values)
+ module.Dispose();
}
}
}
diff --git a/src/SMAPI/Framework/ModLoading/RewriteHelper.cs b/src/SMAPI/Framework/ModLoading/RewriteHelper.cs
index 56a60a72..9ff43d45 100644
--- a/src/SMAPI/Framework/ModLoading/RewriteHelper.cs
+++ b/src/SMAPI/Framework/ModLoading/RewriteHelper.cs
@@ -10,6 +10,13 @@ namespace StardewModdingAPI.Framework.ModLoading
internal static class RewriteHelper
{
/*********
+ ** Properties
+ *********/
+ /// <summary>The comparer which heuristically compares type definitions.</summary>
+ private static readonly TypeReferenceComparer TypeDefinitionComparer = new TypeReferenceComparer();
+
+
+ /*********
** Public methods
*********/
/// <summary>Get the field reference from an instruction if it matches.</summary>
@@ -25,7 +32,7 @@ namespace StardewModdingAPI.Framework.ModLoading
/// <param name="instruction">The IL instruction.</param>
public static MethodReference AsMethodReference(Instruction instruction)
{
- return instruction.OpCode == OpCodes.Call || instruction.OpCode == OpCodes.Callvirt
+ return instruction.OpCode == OpCodes.Call || instruction.OpCode == OpCodes.Callvirt || instruction.OpCode == OpCodes.Newobj
? (MethodReference)instruction.Operand
: null;
}
@@ -59,6 +66,15 @@ namespace StardewModdingAPI.Framework.ModLoading
return true;
}
+ /// <summary>Determine whether two type IDs look like the same type, accounting for placeholder values such as !0.</summary>
+ /// <param name="typeA">The type ID to compare.</param>
+ /// <param name="typeB">The other type ID to compare.</param>
+ /// <returns>true if the type IDs look like the same type, false if not.</returns>
+ public static bool LooksLikeSameType(TypeReference typeA, TypeReference typeB)
+ {
+ return RewriteHelper.TypeDefinitionComparer.Equals(typeA, typeB);
+ }
+
/// <summary>Get whether a method definition matches the signature expected by a method reference.</summary>
/// <param name="definition">The method definition.</param>
/// <param name="reference">The method reference.</param>
diff --git a/src/SMAPI/Framework/ModLoading/Rewriters/FieldReplaceRewriter.cs b/src/SMAPI/Framework/ModLoading/Rewriters/FieldReplaceRewriter.cs
index 63358b39..806a074f 100644
--- a/src/SMAPI/Framework/ModLoading/Rewriters/FieldReplaceRewriter.cs
+++ b/src/SMAPI/Framework/ModLoading/Rewriters/FieldReplaceRewriter.cs
@@ -42,7 +42,7 @@ namespace StardewModdingAPI.Framework.ModLoading.Rewriters
if (!this.IsMatch(instruction))
return InstructionHandleResult.None;
- FieldReference newRef = module.Import(this.ToField);
+ FieldReference newRef = module.ImportReference(this.ToField);
cil.Replace(instruction, cil.Create(instruction.OpCode, newRef));
return InstructionHandleResult.Rewritten;
}
diff --git a/src/SMAPI/Framework/ModLoading/Rewriters/FieldToPropertyRewriter.cs b/src/SMAPI/Framework/ModLoading/Rewriters/FieldToPropertyRewriter.cs
index b1fa377a..e6ede9e3 100644
--- a/src/SMAPI/Framework/ModLoading/Rewriters/FieldToPropertyRewriter.cs
+++ b/src/SMAPI/Framework/ModLoading/Rewriters/FieldToPropertyRewriter.cs
@@ -50,7 +50,7 @@ namespace StardewModdingAPI.Framework.ModLoading.Rewriters
return InstructionHandleResult.None;
string methodPrefix = instruction.OpCode == OpCodes.Ldsfld || instruction.OpCode == OpCodes.Ldfld ? "get" : "set";
- MethodReference propertyRef = module.Import(this.Type.GetMethod($"{methodPrefix}_{this.PropertyName}"));
+ MethodReference propertyRef = module.ImportReference(this.Type.GetMethod($"{methodPrefix}_{this.PropertyName}"));
cil.Replace(instruction, cil.Create(OpCodes.Call, propertyRef));
return InstructionHandleResult.Rewritten;
diff --git a/src/SMAPI/Framework/ModLoading/Rewriters/MethodParentRewriter.cs b/src/SMAPI/Framework/ModLoading/Rewriters/MethodParentRewriter.cs
index 974fcf4c..99bd9125 100644
--- a/src/SMAPI/Framework/ModLoading/Rewriters/MethodParentRewriter.cs
+++ b/src/SMAPI/Framework/ModLoading/Rewriters/MethodParentRewriter.cs
@@ -64,7 +64,7 @@ namespace StardewModdingAPI.Framework.ModLoading.Rewriters
return InstructionHandleResult.None;
MethodReference methodRef = (MethodReference)instruction.Operand;
- methodRef.DeclaringType = module.Import(this.ToType);
+ methodRef.DeclaringType = module.ImportReference(this.ToType);
return InstructionHandleResult.Rewritten;
}
diff --git a/src/SMAPI/Framework/ModLoading/Rewriters/TypeReferenceRewriter.cs b/src/SMAPI/Framework/ModLoading/Rewriters/TypeReferenceRewriter.cs
index 74f2fcdd..62e15731 100644
--- a/src/SMAPI/Framework/ModLoading/Rewriters/TypeReferenceRewriter.cs
+++ b/src/SMAPI/Framework/ModLoading/Rewriters/TypeReferenceRewriter.cs
@@ -24,8 +24,9 @@ namespace StardewModdingAPI.Framework.ModLoading.Rewriters
/// <summary>Construct an instance.</summary>
/// <param name="fromTypeFullName">The full type name to which to find references.</param>
/// <param name="toType">The new type to reference.</param>
- public TypeReferenceRewriter(string fromTypeFullName, Type toType)
- : base(fromTypeFullName, InstructionHandleResult.None)
+ /// <param name="shouldIgnore">A lambda which overrides a matched type.</param>
+ public TypeReferenceRewriter(string fromTypeFullName, Type toType, Func<TypeReference, bool> shouldIgnore = null)
+ : base(fromTypeFullName, InstructionHandleResult.None, shouldIgnore)
{
this.FromTypeName = fromTypeFullName;
this.ToType = toType;
@@ -43,7 +44,7 @@ namespace StardewModdingAPI.Framework.ModLoading.Rewriters
// return type
if (this.IsMatch(method.ReturnType))
{
- method.ReturnType = this.RewriteIfNeeded(module, method.ReturnType);
+ this.RewriteIfNeeded(module, method.ReturnType, newType => method.ReturnType = newType);
rewritten = true;
}
@@ -52,7 +53,7 @@ namespace StardewModdingAPI.Framework.ModLoading.Rewriters
{
if (this.IsMatch(parameter.ParameterType))
{
- parameter.ParameterType = this.RewriteIfNeeded(module, parameter.ParameterType);
+ this.RewriteIfNeeded(module, parameter.ParameterType, newType => parameter.ParameterType = newType);
rewritten = true;
}
}
@@ -63,9 +64,7 @@ namespace StardewModdingAPI.Framework.ModLoading.Rewriters
var parameter = method.GenericParameters[i];
if (this.IsMatch(parameter))
{
- TypeReference newType = this.RewriteIfNeeded(module, parameter);
- if (newType != parameter)
- method.GenericParameters[i] = new GenericParameter(parameter.Name, newType);
+ this.RewriteIfNeeded(module, parameter, newType => method.GenericParameters[i] = new GenericParameter(parameter.Name, newType));
rewritten = true;
}
}
@@ -75,7 +74,7 @@ namespace StardewModdingAPI.Framework.ModLoading.Rewriters
{
if (this.IsMatch(variable.VariableType))
{
- variable.VariableType = this.RewriteIfNeeded(module, variable.VariableType);
+ this.RewriteIfNeeded(module, variable.VariableType, newType => variable.VariableType = newType);
rewritten = true;
}
}
@@ -93,34 +92,30 @@ namespace StardewModdingAPI.Framework.ModLoading.Rewriters
/// <param name="platformChanged">Whether the mod was compiled on a different platform.</param>
public override InstructionHandleResult Handle(ModuleDefinition module, ILProcessor cil, Instruction instruction, PlatformAssemblyMap assemblyMap, bool platformChanged)
{
- if (!this.IsMatch(instruction) && !instruction.ToString().Contains(this.FromTypeName))
+ if (!this.IsMatch(instruction))
return InstructionHandleResult.None;
// field reference
FieldReference fieldRef = RewriteHelper.AsFieldReference(instruction);
if (fieldRef != null)
{
- fieldRef.DeclaringType = this.RewriteIfNeeded(module, fieldRef.DeclaringType);
- fieldRef.FieldType = this.RewriteIfNeeded(module, fieldRef.FieldType);
+ this.RewriteIfNeeded(module, fieldRef.DeclaringType, newType => fieldRef.DeclaringType = newType);
+ this.RewriteIfNeeded(module, fieldRef.FieldType, newType => fieldRef.FieldType = newType);
}
// method reference
MethodReference methodRef = RewriteHelper.AsMethodReference(instruction);
if (methodRef != null)
{
- methodRef.DeclaringType = this.RewriteIfNeeded(module, methodRef.DeclaringType);
- methodRef.ReturnType = this.RewriteIfNeeded(module, methodRef.ReturnType);
+ this.RewriteIfNeeded(module, methodRef.DeclaringType, newType => methodRef.DeclaringType = newType);
+ this.RewriteIfNeeded(module, methodRef.ReturnType, newType => methodRef.ReturnType = newType);
foreach (var parameter in methodRef.Parameters)
- parameter.ParameterType = this.RewriteIfNeeded(module, parameter.ParameterType);
+ this.RewriteIfNeeded(module, parameter.ParameterType, newType => parameter.ParameterType = newType);
}
// type reference
if (instruction.Operand is TypeReference typeRef)
- {
- TypeReference newRef = this.RewriteIfNeeded(module, typeRef);
- if (typeRef != newRef)
- cil.Replace(instruction, cil.Create(instruction.OpCode, newRef));
- }
+ this.RewriteIfNeeded(module, typeRef, newType => cil.Replace(instruction, cil.Create(instruction.OpCode, newType)));
return InstructionHandleResult.Rewritten;
}
@@ -128,27 +123,30 @@ namespace StardewModdingAPI.Framework.ModLoading.Rewriters
/*********
** Private methods
*********/
- /// <summary>Get the adjusted type reference if it matches, else the same value.</summary>
+ /// <summary>Change a type reference if needed.</summary>
/// <param name="module">The assembly module containing the instruction.</param>
/// <param name="type">The type to replace if it matches.</param>
- private TypeReference RewriteIfNeeded(ModuleDefinition module, TypeReference type)
+ /// <param name="set">Assign the new type reference.</param>
+ private void RewriteIfNeeded(ModuleDefinition module, TypeReference type, Action<TypeReference> set)
{
- // root type
+ // current type
if (type.FullName == this.FromTypeName)
- return module.Import(this.ToType);
+ {
+ if (!this.ShouldIgnore(type))
+ set(module.ImportReference(this.ToType));
+ return;
+ }
- // generic arguments
+ // recurse into generic arguments
if (type is GenericInstanceType genericType)
{
for (int i = 0; i < genericType.GenericArguments.Count; i++)
- genericType.GenericArguments[i] = this.RewriteIfNeeded(module, genericType.GenericArguments[i]);
+ this.RewriteIfNeeded(module, genericType.GenericArguments[i], typeRef => genericType.GenericArguments[i] = typeRef);
}
- // generic parameters (e.g. constraints)
+ // recurse into generic parameters (e.g. constraints)
for (int i = 0; i < type.GenericParameters.Count; i++)
- type.GenericParameters[i] = new GenericParameter(this.RewriteIfNeeded(module, type.GenericParameters[i]));
-
- return type;
+ this.RewriteIfNeeded(module, type.GenericParameters[i], typeRef => type.GenericParameters[i] = new GenericParameter(typeRef));
}
}
}
diff --git a/src/SMAPI/Framework/ModLoading/TypeReferenceComparer.cs b/src/SMAPI/Framework/ModLoading/TypeReferenceComparer.cs
new file mode 100644
index 00000000..f7497789
--- /dev/null
+++ b/src/SMAPI/Framework/ModLoading/TypeReferenceComparer.cs
@@ -0,0 +1,201 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Mono.Cecil;
+
+namespace StardewModdingAPI.Framework.ModLoading
+{
+ /// <summary>Performs heuristic equality checks for <see cref="TypeReference"/> instances.</summary>
+ /// <remarks>
+ /// This implementation compares <see cref="TypeReference"/> instances to see if they likely
+ /// refer to the same type. While the implementation is obvious for types like <c>System.Bool</c>,
+ /// this class mainly exists to handle cases like <c>System.Collections.Generic.Dictionary`2&lt;!0,Netcode.NetRoot`1&lt;!1&gt;&gt;</c>
+ /// and <c>System.Collections.Generic.Dictionary`2&lt;TKey,Netcode.NetRoot`1&lt;TValue&gt;&gt;</c>
+ /// which are compatible, but not directly comparable. It does this by splitting each type name
+ /// into its component token types, and performing placeholder substitution (e.g. <c>!0</c> to
+ /// <c>TKey</c> in the above example). If all components are equal after substitution, and the
+ /// tokens can all be mapped to the same generic type, the types are considered equal.
+ /// </remarks>
+ internal class TypeReferenceComparer : IEqualityComparer<TypeReference>
+ {
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Get whether the specified objects are equal.</summary>
+ /// <param name="a">The first object to compare.</param>
+ /// <param name="b">The second object to compare.</param>
+ public bool Equals(TypeReference a, TypeReference b)
+ {
+ if (a == null || b == null)
+ return a == b;
+
+ return
+ a == b
+ || a.FullName == b.FullName
+ || this.HeuristicallyEquals(a, b);
+ }
+
+ /// <summary>Get a hash code for the specified object.</summary>
+ /// <param name="obj">The object for which a hash code is to be returned.</param>
+ /// <exception cref="T:System.ArgumentNullException">The object type is a reference type and <paramref name="obj" /> is null.</exception>
+ public int GetHashCode(TypeReference obj)
+ {
+ return obj.GetHashCode();
+ }
+
+
+ /*********
+ ** Private methods
+ *********/
+ /// <summary>Get whether two types are heuristically equal based on generic type token substitution.</summary>
+ /// <param name="typeA">The first type to compare.</param>
+ /// <param name="typeB">The second type to compare.</param>
+ private bool HeuristicallyEquals(TypeReference typeA, TypeReference typeB)
+ {
+ bool HeuristicallyEquals(string typeNameA, string typeNameB, IDictionary<string, string> tokenMap)
+ {
+ // analyse type names
+ bool hasTokensA = typeNameA.Contains("!");
+ bool hasTokensB = typeNameB.Contains("!");
+ bool isTokenA = hasTokensA && typeNameA[0] == '!';
+ bool isTokenB = hasTokensB && typeNameB[0] == '!';
+
+ // validate
+ if (!hasTokensA && !hasTokensB)
+ return typeNameA == typeNameB; // no substitution needed
+ if (hasTokensA && hasTokensB)
+ throw new InvalidOperationException("Can't compare two type names when both contain generic type tokens.");
+
+ // perform substitution if applicable
+ if (isTokenA)
+ typeNameA = this.MapPlaceholder(placeholder: typeNameA, type: typeNameB, map: tokenMap);
+ if (isTokenB)
+ typeNameB = this.MapPlaceholder(placeholder: typeNameB, type: typeNameA, map: tokenMap);
+
+ // compare inner tokens
+ string[] symbolsA = this.GetTypeSymbols(typeNameA).ToArray();
+ string[] symbolsB = this.GetTypeSymbols(typeNameB).ToArray();
+ if (symbolsA.Length != symbolsB.Length)
+ return false;
+
+ for (int i = 0; i < symbolsA.Length; i++)
+ {
+ if (!HeuristicallyEquals(symbolsA[i], symbolsB[i], tokenMap))
+ return false;
+ }
+
+ return true;
+ }
+
+ return HeuristicallyEquals(typeA.FullName, typeB.FullName, new Dictionary<string, string>());
+ }
+
+ /// <summary>Map a generic type placeholder (like <c>!0</c>) to its actual type.</summary>
+ /// <param name="placeholder">The token placeholder.</param>
+ /// <param name="type">The actual type.</param>
+ /// <param name="map">The map of token to map substitutions.</param>
+ /// <returns>Returns the previously-mapped type if applicable, else the <paramref name="type"/>.</returns>
+ private string MapPlaceholder(string placeholder, string type, IDictionary<string, string> map)
+ {
+ if (map.TryGetValue(placeholder, out string result))
+ return result;
+
+ map[placeholder] = type;
+ return type;
+ }
+
+ /// <summary>Get the top-level type symbols in a type name (e.g. <code>List</code> and <code>NetRef&lt;T&gt;</code> in <code>List&lt;NetRef&lt;T&gt;&gt;</code>)</summary>
+ /// <param name="typeName">The full type name.</param>
+ private IEnumerable<string> GetTypeSymbols(string typeName)
+ {
+ int openGenerics = 0;
+
+ Queue<char> queue = new Queue<char>(typeName);
+ string symbol = "";
+ while (queue.Any())
+ {
+ char ch = queue.Dequeue();
+ switch (ch)
+ {
+ // skip `1 generic type identifiers
+ case '`':
+ while (int.TryParse(queue.Peek().ToString(), out int _))
+ queue.Dequeue();
+ break;
+
+ // start generic args
+ case '<':
+ switch (openGenerics)
+ {
+ // start new generic symbol
+ case 0:
+ yield return symbol;
+ symbol = "";
+ openGenerics++;
+ break;
+
+ // continue accumulating nested type symbol
+ default:
+ symbol += ch;
+ openGenerics++;
+ break;
+ }
+ break;
+
+ // generic args delimiter
+ case ',':
+ switch (openGenerics)
+ {
+ // invalid
+ case 0:
+ throw new InvalidOperationException($"Encountered unexpected comma in type name: {typeName}.");
+
+ // start next generic symbol
+ case 1:
+ yield return symbol;
+ symbol = "";
+ break;
+
+ // continue accumulating nested type symbol
+ default:
+ symbol += ch;
+ break;
+ }
+ break;
+
+
+ // end generic args
+ case '>':
+ switch (openGenerics)
+ {
+ // invalid
+ case 0:
+ throw new InvalidOperationException($"Encountered unexpected closing generic in type name: {typeName}.");
+
+ // end generic symbol
+ case 1:
+ yield return symbol;
+ symbol = "";
+ openGenerics--;
+ break;
+
+ // continue accumulating nested type symbol
+ default:
+ symbol += ch;
+ openGenerics--;
+ break;
+ }
+ break;
+
+ // continue symbol
+ default:
+ symbol += ch;
+ break;
+ }
+ }
+
+ if (symbol != "")
+ yield return symbol;
+ }
+ }
+}