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.Metadata;
namespace StardewModdingAPI.Framework.ModLoading
{
/// Preprocesses and loads mod assemblies.
internal class AssemblyLoader
{
/*********
** Properties
*********/
/// 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;
/// Encapsulates monitoring and logging.
private readonly IMonitor Monitor;
/// Whether to enable developer mode logging.
private readonly bool IsDeveloperMode;
/*********
** Public methods
*********/
/// Construct an instance.
/// The current game platform.
/// Encapsulates monitoring and logging.
/// Whether to enable developer mode logging.
public AssemblyLoader(Platform targetPlatform, IMonitor monitor, bool isDeveloperMode)
{
this.Monitor = monitor;
this.IsDeveloperMode = isDeveloperMode;
this.AssemblyMap = Constants.GetAssemblyMap(targetPlatform);
// 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 path.
/// 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, string assemblyPath, bool assumeCompatible)
{
// get referenced local assemblies
AssemblyParseResult[] assemblies;
{
AssemblyDefinitionResolver resolver = new AssemblyDefinitionResolver();
HashSet visitedAssemblyNames = new HashSet(AppDomain.CurrentDomain.GetAssemblies().Select(p => p.GetName().Name)); // don't try loading assemblies that are already loaded
assemblies = this.GetReferencedLocalAssemblies(new FileInfo(assemblyPath), visitedAssemblyNames, resolver).ToArray();
}
// validate load
if (!assemblies.Any() || assemblies[0].Status == AssemblyLoadStatus.Failed)
{
throw new SAssemblyLoadFailedException(!File.Exists(assemblyPath)
? $"Could not load '{assemblyPath}' because it doesn't exist."
: $"Could not load '{assemblyPath}'."
);
}
if (assemblies.Last().Status == AssemblyLoadStatus.AlreadyLoaded) // mod assembly is last in dependency order
throw new SAssemblyLoadFailedException($"Could not load '{assemblyPath}' 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.Status == AssemblyLoadStatus.AlreadyLoaded)
continue;
bool changed = this.RewriteAssembly(mod, assembly.Definition, assumeCompatible, loggedMessages, logPrefix: " ");
if (changed)
{
if (!oneAssembly)
this.Monitor.Log($" Loading {assembly.File.Name} (rewritten in memory)...", LogLevel.Trace);
using (MemoryStream outStream = new MemoryStream())
{
assembly.Definition.Write(outStream);
byte[] bytes = outStream.ToArray();
lastAssembly = Assembly.Load(bytes);
}
}
else
{
if (!oneAssembly)
this.Monitor.Log($" Loading {assembly.File.Name}...", LogLevel.Trace);
lastAssembly = Assembly.UnsafeLoadFrom(assembly.File.FullName);
}
}
// last assembly loaded is the root
return lastAssembly;
}
/// 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 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);
}
/*********
** Private methods
*********/
/****
** 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
byte[] assemblyBytes = File.ReadAllBytes(file.FullName);
AssemblyDefinition assembly;
using (Stream readStream = new MemoryStream(assemblyBytes))
assembly = AssemblyDefinition.ReadAssembly(readStream, new ReaderParameters(ReadingMode.Deferred) { AssemblyResolver = assemblyResolver });
// skip if already visited
if (visitedAssemblyNames.Contains(assembly.Name.Name))
yield return new AssemblyParseResult(file, null, AssemblyLoadStatus.AlreadyLoaded);
visitedAssemblyNames.Add(assembly.Name.Name);
// yield referenced assemblies
foreach (AssemblyNameReference dependency in assembly.MainModule.AssemblyReferences)
{
FileInfo dependencyFile = new FileInfo(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.
/// Assume the mod is compatible, even if incompatible code is detected.
/// 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, bool assumeCompatible, 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;
for (int i = 0; i < module.AssemblyReferences.Count; i++)
{
// remove old assembly reference
if (this.AssemblyMap.RemoveNames.Any(name => module.AssemblyReferences[i].Name == name))
{
this.Monitor.LogOnce(loggedMessages, $"{logPrefix}Rewriting {filename} for OS...");
platformChanged = true;
module.AssemblyReferences.RemoveAt(i);
i--;
}
}
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);
}
// find (and optionally rewrite) incompatible instructions
bool anyRewritten = false;
IInstructionHandler[] handlers = new InstructionMetadata().GetHandlers().ToArray();
foreach (MethodDefinition method in this.GetMethods(module))
{
// check method definition
foreach (IInstructionHandler handler in handlers)
{
InstructionHandleResult result = handler.Handle(module, method, this.AssemblyMap, platformChanged);
this.ProcessInstructionHandleResult(mod, handler, result, loggedMessages, logPrefix, assumeCompatible, filename);
if (result == InstructionHandleResult.Rewritten)
anyRewritten = true;
}
// check CIL instructions
ILProcessor cil = method.Body.GetILProcessor();
foreach (Instruction instruction in cil.Body.Instructions.ToArray())
{
foreach (IInstructionHandler handler in handlers)
{
InstructionHandleResult result = handler.Handle(module, cil, instruction, this.AssemblyMap, platformChanged);
this.ProcessInstructionHandleResult(mod, handler, result, loggedMessages, logPrefix, assumeCompatible, filename);
if (result == InstructionHandleResult.Rewritten)
anyRewritten = true;
}
}
}
return platformChanged || anyRewritten;
}
/// Process the result from an instruction handler.
/// The mod being analysed.
/// The instruction handler.
/// The result returned by the handler.
/// The messages already logged for the current mod.
/// Assume the mod is compatible, even if incompatible code is detected.
/// 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, bool assumeCompatible, string filename)
{
switch (result)
{
case InstructionHandleResult.Rewritten:
this.Monitor.LogOnce(loggedMessages, $"{logPrefix}Rewrote {filename} to fix {handler.NounPhrase}...");
break;
case InstructionHandleResult.NotCompatible:
if (!assumeCompatible)
throw new IncompatibleInstructionException(handler.NounPhrase, $"Found an incompatible CIL instruction ({handler.NounPhrase}) while loading assembly {filename}.");
this.Monitor.LogOnce(loggedMessages, $"{logPrefix}Found an incompatible CIL instruction ({handler.NounPhrase}) while loading assembly {filename}, but SMAPI is configured to allow it anyway. The mod may crash or behave unexpectedly.", LogLevel.Warn);
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);
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);
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
);
break;
case InstructionHandleResult.None:
break;
default:
throw new NotSupportedException($"Unrecognised instruction handler result '{result}'.");
}
}
/// 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;
}
/// Get all methods in a module.
/// The module to search.
private IEnumerable GetMethods(ModuleDefinition module)
{
return (
from type in module.GetTypes()
where type.HasMethods
from method in type.Methods
where method.HasBody
select method
);
}
}
}