using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
using Mono.Cecil;
using Mono.Cecil.Cil;
using StardewModdingAPI.Framework.Exceptions;
using StardewModdingAPI.Framework.ModLoading.Framework;
using StardewModdingAPI.Framework.ModLoading.Symbols;
using StardewModdingAPI.Metadata;
using StardewModdingAPI.Toolkit.Framework.ModData;
using StardewModdingAPI.Toolkit.Utilities;
namespace StardewModdingAPI.Framework.ModLoading
{
/// Preprocesses and loads mod assemblies.
internal class AssemblyLoader : IDisposable
{
/*********
** Fields
*********/
/// Encapsulates monitoring and logging.
private readonly IMonitor Monitor;
/// Whether to detect paranoid mode issues.
private readonly bool ParanoidMode;
/// Metadata for mapping assemblies to the current platform.
private readonly PlatformAssemblyMap AssemblyMap;
/// A type => assembly lookup for types which should be rewritten.
private readonly IDictionary TypeAssemblies;
/// A minimal assembly definition resolver which resolves references to known loaded assemblies.
private readonly AssemblyDefinitionResolver AssemblyDefinitionResolver;
/// Provides assembly symbol readers for Mono.Cecil.
private readonly SymbolReaderProvider SymbolReaderProvider = new();
/// Provides assembly symbol writers for Mono.Cecil.
private readonly SymbolWriterProvider SymbolWriterProvider = new();
/// The objects to dispose as part of this instance.
private readonly HashSet Disposables = new();
/// Whether to rewrite mods for compatibility.
private readonly bool RewriteMods;
/*********
** Public methods
*********/
/// Construct an instance.
/// The current game platform.
/// Encapsulates monitoring and logging.
/// Whether to detect paranoid mode issues.
/// Whether to rewrite mods for compatibility.
public AssemblyLoader(Platform targetPlatform, IMonitor monitor, bool paranoidMode, bool rewriteMods)
{
this.Monitor = monitor;
this.ParanoidMode = paranoidMode;
this.RewriteMods = rewriteMods;
this.AssemblyMap = this.TrackForDisposal(Constants.GetAssemblyMap(targetPlatform));
// init resolver
this.AssemblyDefinitionResolver = this.TrackForDisposal(new AssemblyDefinitionResolver());
Constants.ConfigureAssemblyResolver(this.AssemblyDefinitionResolver);
// generate type => assembly lookup for types which should be rewritten
this.TypeAssemblies = new Dictionary();
foreach (Assembly assembly in this.AssemblyMap.Targets)
{
ModuleDefinition module = this.AssemblyMap.TargetModules[assembly];
foreach (TypeDefinition type in module.GetTypes())
{
if (!type.IsPublic)
continue; // no need to rewrite
if (type.Namespace.Contains("<"))
continue; // ignore assembly metadata
this.TypeAssemblies[type.FullName] = assembly;
}
}
}
/// Preprocess and load an assembly.
/// The mod for which the assembly is being loaded.
/// The assembly file.
/// Assume the mod is compatible, even if incompatible code is detected.
/// Returns the rewrite metadata for the preprocessed assembly.
/// An incompatible CIL instruction was found while rewriting the assembly.
public Assembly Load(IModMetadata mod, FileInfo assemblyFile, bool assumeCompatible)
{
// get referenced local assemblies
AssemblyParseResult[] assemblies;
{
HashSet visitedAssemblyNames = new HashSet( // don't try loading assemblies that are already loaded
from assembly in AppDomain.CurrentDomain.GetAssemblies()
let name = assembly.GetName().Name
where name != null
select name
);
assemblies = this.GetReferencedLocalAssemblies(assemblyFile, visitedAssemblyNames, this.AssemblyDefinitionResolver).ToArray();
}
// validate load
if (!assemblies.Any() || assemblies[0].Status == AssemblyLoadStatus.Failed)
{
throw new SAssemblyLoadFailedException(!assemblyFile.Exists
? $"Could not load '{assemblyFile.FullName}' because it doesn't exist."
: $"Could not load '{assemblyFile.FullName}'."
);
}
if (assemblies.Last().Status == AssemblyLoadStatus.AlreadyLoaded) // mod assembly is last in dependency order
throw new SAssemblyLoadFailedException($"Could not load '{assemblyFile.FullName}' because it was already loaded. Do you have two copies of this mod?");
// rewrite & load assemblies in leaf-to-root order
bool oneAssembly = assemblies.Length == 1;
Assembly? lastAssembly = null;
HashSet loggedMessages = new HashSet();
foreach (AssemblyParseResult assembly in assemblies)
{
if (!assembly.HasDefinition)
continue;
// rewrite assembly
bool changed = this.RewriteAssembly(mod, assembly.Definition, 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($"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)...");
// load assembly
using MemoryStream outAssemblyStream = new();
using MemoryStream outSymbolStream = new();
assembly.Definition.Write(outAssemblyStream, new WriterParameters { WriteSymbols = true, SymbolStream = outSymbolStream, SymbolWriterProvider = this.SymbolWriterProvider });
byte[] bytes = outAssemblyStream.ToArray();
lastAssembly = Assembly.Load(bytes, outSymbolStream.ToArray());
}
else
{
if (!oneAssembly)
this.Monitor.Log($" Loading {assembly.File.Name}...");
lastAssembly = Assembly.UnsafeLoadFrom(assembly.File.FullName);
}
// track loaded assembly for definition resolution
this.AssemblyDefinitionResolver.Add(assembly.Definition);
}
// special case: clear legacy-DLL warnings if the mod bundles a copy
if (mod.Warnings.HasFlag(ModWarning.DetectedLegacyCachingDll))
{
if (File.Exists(Path.Combine(mod.DirectoryPath, "System.Runtime.Caching.dll")))
mod.RemoveWarning(ModWarning.DetectedLegacyCachingDll);
else
{
// remove duplicate warnings (System.Runtime.Caching.dll references these)
mod.RemoveWarning(ModWarning.DetectedLegacyConfigurationDll);
mod.RemoveWarning(ModWarning.DetectedLegacyPermissionsDll);
}
}
if (mod.Warnings.HasFlag(ModWarning.DetectedLegacyConfigurationDll))
{
if (File.Exists(Path.Combine(mod.DirectoryPath, "System.Configuration.ConfigurationManager.dll")))
mod.RemoveWarning(ModWarning.DetectedLegacyConfigurationDll);
}
if (mod.Warnings.HasFlag(ModWarning.DetectedLegacyPermissionsDll))
{
if (File.Exists(Path.Combine(mod.DirectoryPath, "System.Security.Permissions.dll")))
mod.RemoveWarning(ModWarning.DetectedLegacyPermissionsDll);
}
// throw if incompatibilities detected
if (!assumeCompatible && mod.Warnings.HasFlag(ModWarning.BrokenCodeLoaded))
throw new IncompatibleInstructionException();
// last assembly loaded is the root
return lastAssembly!;
}
/// Get whether an assembly is loaded.
/// The assembly name reference.
public bool IsAssemblyLoaded(AssemblyNameReference reference)
{
try
{
_ = this.AssemblyDefinitionResolver.Resolve(reference);
return true;
}
catch (AssemblyResolutionException)
{
return false;
}
}
/// Resolve an assembly by its name.
/// The assembly name.
///
/// This implementation returns the first loaded assembly which matches the short form of
/// the assembly name, to resolve assembly resolution issues when rewriting
/// assemblies (especially with Mono). Since this is meant to be called on ,
/// the implicit assumption is that loading the exact assembly failed.
///
public static Assembly? ResolveAssembly(string name)
{
string shortName = name.Split(new[] { ',' }, 2).First(); // get simple name (without version and culture)
return AppDomain.CurrentDomain
.GetAssemblies()
.FirstOrDefault(p => p.GetName().Name == shortName);
}
/// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.
public void Dispose()
{
foreach (IDisposable instance in this.Disposables)
instance.Dispose();
}
/*********
** Private methods
*********/
/// Track an object for disposal as part of the assembly loader.
/// The instance type.
/// The disposable instance.
private T TrackForDisposal(T instance)
where T : IDisposable
{
this.Disposables.Add(instance);
return instance;
}
/****
** Assembly parsing
****/
/// Get a list of referenced local assemblies starting from the mod assembly, ordered from leaf to root.
/// The assembly file to load.
/// The assembly names that should be skipped.
/// A resolver which resolves references to known assemblies.
/// Returns the rewrite metadata for the preprocessed assembly.
private IEnumerable GetReferencedLocalAssemblies(FileInfo file, HashSet visitedAssemblyNames, IAssemblyResolver assemblyResolver)
{
// validate
if (file.Directory == null)
throw new InvalidOperationException($"Could not get directory from file path '{file.FullName}'.");
if (!file.Exists)
yield break; // not a local assembly
// read assembly
AssemblyDefinition assembly;
{
byte[] assemblyBytes = File.ReadAllBytes(file.FullName);
Stream readStream = this.TrackForDisposal(new MemoryStream(assemblyBytes));
try
{
// read assembly with symbols
FileInfo symbolsFile = new(Path.Combine(Path.GetDirectoryName(file.FullName)!, Path.GetFileNameWithoutExtension(file.FullName)) + ".pdb");
if (symbolsFile.Exists)
this.SymbolReaderProvider.TryAddSymbolData(file.Name, () => this.TrackForDisposal(symbolsFile.OpenRead()));
assembly = this.TrackForDisposal(AssemblyDefinition.ReadAssembly(readStream, new ReaderParameters(ReadingMode.Immediate) { AssemblyResolver = assemblyResolver, InMemory = true, ReadSymbols = true, SymbolReaderProvider = this.SymbolReaderProvider }));
}
catch (SymbolsNotMatchingException ex)
{
// read assembly without symbols
this.Monitor.Log($" Failed loading PDB for '{file.Name}'. Technical details:\n{ex}");
readStream.Position = 0;
assembly = this.TrackForDisposal(AssemblyDefinition.ReadAssembly(readStream, new ReaderParameters(ReadingMode.Immediate) { AssemblyResolver = assemblyResolver, InMemory = true }));
}
}
// skip if already visited
if (visitedAssemblyNames.Contains(assembly.Name.Name))
{
yield return new AssemblyParseResult(file, null, AssemblyLoadStatus.AlreadyLoaded);
yield break;
}
visitedAssemblyNames.Add(assembly.Name.Name);
// yield referenced assemblies
foreach (AssemblyNameReference dependency in assembly.MainModule.AssemblyReferences)
{
FileInfo dependencyFile = new(Path.Combine(file.Directory.FullName, $"{dependency.Name}.dll"));
foreach (AssemblyParseResult result in this.GetReferencedLocalAssemblies(dependencyFile, visitedAssemblyNames, assemblyResolver))
yield return result;
}
// yield assembly
yield return new AssemblyParseResult(file, assembly, AssemblyLoadStatus.Okay);
}
/****
** Assembly rewriting
****/
/// Rewrite the types referenced by an assembly.
/// The mod for which the assembly is being loaded.
/// The assembly to rewrite.
/// The messages that have already been logged for this mod.
/// A string to prefix to log messages.
/// Returns whether the assembly was modified.
/// An incompatible CIL instruction was found while rewriting the assembly.
private bool RewriteAssembly(IModMetadata mod, AssemblyDefinition assembly, HashSet loggedMessages, string logPrefix)
{
ModuleDefinition module = assembly.MainModule;
string filename = $"{assembly.Name.Name}.dll";
// swap assembly references if needed (e.g. XNA => MonoGame)
bool platformChanged = false;
if (this.RewriteMods)
{
for (int i = 0; i < module.AssemblyReferences.Count; i++)
{
// remove old assembly reference
if (this.AssemblyMap.RemoveNames.Any(name => module.AssemblyReferences[i].Name == name))
{
platformChanged = true;
module.AssemblyReferences.RemoveAt(i);
i--;
this.Monitor.LogOnce(loggedMessages, $"{logPrefix}Rewrote {filename} for OS...");
}
}
if (platformChanged)
{
// add target assembly references
foreach (AssemblyNameReference target in this.AssemblyMap.TargetReferences.Values)
module.AssemblyReferences.Add(target);
// rewrite type scopes to use target assemblies
IEnumerable typeReferences = module.GetTypeReferences().OrderBy(p => p.FullName);
foreach (TypeReference type in typeReferences)
this.ChangeTypeScope(type);
// rewrite types using custom attributes
foreach (TypeDefinition type in module.GetTypes())
{
foreach (CustomAttribute attr in type.CustomAttributes)
{
foreach (CustomAttributeArgument conField in attr.ConstructorArguments)
{
if (conField.Value is TypeReference typeRef)
this.ChangeTypeScope(typeRef);
}
}
}
}
}
// find or rewrite code
IInstructionHandler[] handlers = new InstructionMetadata().GetHandlers(this.ParanoidMode, platformChanged, this.RewriteMods).ToArray();
RecursiveRewriter rewriter = new(
module: module,
rewriteModule: curModule =>
{
bool rewritten = false;
foreach (IInstructionHandler handler in handlers)
rewritten |= handler.Handle(curModule);
return rewritten;
},
rewriteType: (type, replaceWith) =>
{
bool rewritten = false;
foreach (IInstructionHandler handler in handlers)
rewritten |= handler.Handle(module, type, replaceWith);
return rewritten;
},
rewriteInstruction: (ref Instruction instruction, ILProcessor cil) =>
{
bool rewritten = false;
foreach (IInstructionHandler handler in handlers)
rewritten |= handler.Handle(module, cil, instruction);
return rewritten;
}
);
bool anyRewritten = rewriter.RewriteModule();
// handle rewrite flags
foreach (IInstructionHandler handler in handlers)
{
foreach (var flag in handler.Flags)
this.ProcessInstructionHandleResult(mod, handler, flag, loggedMessages, logPrefix, filename);
}
return platformChanged || anyRewritten;
}
/// Process the result from an instruction handler.
/// The mod being analyzed.
/// The instruction handler.
/// The result returned by the handler.
/// The messages already logged for the current mod.
/// A string to prefix to log messages.
/// The assembly filename for log messages.
private void ProcessInstructionHandleResult(IModMetadata mod, IInstructionHandler handler, InstructionHandleResult result, HashSet loggedMessages, string logPrefix, string filename)
{
// get message template
// ($phrase is replaced with the noun phrase or messages)
string? template = null;
switch (result)
{
case InstructionHandleResult.Rewritten:
template = $"{logPrefix}Rewrote {filename} to fix $phrase...";
break;
case InstructionHandleResult.NotCompatible:
template = $"{logPrefix}Broken code in {filename}: $phrase.";
mod.SetWarning(ModWarning.BrokenCodeLoaded);
break;
case InstructionHandleResult.DetectedGamePatch:
template = $"{logPrefix}Detected game patcher in assembly {filename}."; // no need for phrase, which would confusingly be 'Harmony 1.x' here
mod.SetWarning(ModWarning.PatchesGame);
break;
case InstructionHandleResult.DetectedSaveSerializer:
template = $"{logPrefix}Detected possible save serializer change ($phrase) in assembly {filename}.";
mod.SetWarning(ModWarning.ChangesSaveSerializer);
break;
case InstructionHandleResult.DetectedUnvalidatedUpdateTick:
template = $"{logPrefix}Detected reference to $phrase in assembly {filename}.";
mod.SetWarning(ModWarning.UsesUnvalidatedUpdateTick);
break;
case InstructionHandleResult.DetectedConsoleAccess:
template = $"{logPrefix}Detected direct console access ($phrase) in assembly {filename}.";
mod.SetWarning(ModWarning.AccessesConsole);
break;
case InstructionHandleResult.DetectedFilesystemAccess:
template = $"{logPrefix}Detected filesystem access ($phrase) in assembly {filename}.";
mod.SetWarning(ModWarning.AccessesFilesystem);
break;
case InstructionHandleResult.DetectedShellAccess:
template = $"{logPrefix}Detected shell or process access ($phrase) in assembly {filename}.";
mod.SetWarning(ModWarning.AccessesShell);
break;
case InstructionHandleResult.DetectedLegacyCachingDll:
template = $"{logPrefix}Detected reference to System.Runtime.Caching.dll, which will be removed in SMAPI 4.0.0.";
mod.SetWarning(ModWarning.DetectedLegacyCachingDll);
break;
case InstructionHandleResult.DetectedLegacyConfigurationDll:
template = $"{logPrefix}Detected reference to System.Configuration.ConfigurationManager.dll, which will be removed in SMAPI 4.0.0.";
mod.SetWarning(ModWarning.DetectedLegacyConfigurationDll);
break;
case InstructionHandleResult.DetectedLegacyPermissionsDll:
template = $"{logPrefix}Detected reference to System.Security.Permissions.dll, which will be removed in SMAPI 4.0.0.";
mod.SetWarning(ModWarning.DetectedLegacyPermissionsDll);
break;
case InstructionHandleResult.None:
break;
default:
throw new NotSupportedException($"Unrecognized instruction handler result '{result}'.");
}
if (template == null)
return;
// format messages
string phrase = handler.Phrases.Any()
? string.Join(", ", handler.Phrases)
: handler.DefaultPhrase;
this.Monitor.LogOnce(loggedMessages, template.Replace("$phrase", phrase));
}
/// Get the correct reference to use for compatibility with the current platform.
/// The type reference to rewrite.
private void ChangeTypeScope(TypeReference? type)
{
// check skip conditions
if (type == null || type.FullName.StartsWith("System."))
return;
// get assembly
if (!this.TypeAssemblies.TryGetValue(type.FullName, out Assembly? assembly))
return;
// replace scope
AssemblyNameReference assemblyRef = this.AssemblyMap.TargetReferences[assembly];
type.Scope = assemblyRef;
}
}
}