diff options
21 files changed, 1013 insertions, 165 deletions
diff --git a/release-notes.md b/release-notes.md index 86169cee..8a3012ed 100644 --- a/release-notes.md +++ b/release-notes.md @@ -1,5 +1,11 @@ # Release notes +## 1.3 +See [log](https://github.com/CLxS/SMAPI/compare/stable...develop). + +For players: + * You can now run most mods on any platform (e.g. run Windows mods on Linux/Mac). + ## 1.2 See [log](https://github.com/CLxS/SMAPI/compare/1.1.1...1.2). diff --git a/src/StardewModdingAPI.AssemblyRewriters/IMethodRewriter.cs b/src/StardewModdingAPI.AssemblyRewriters/IMethodRewriter.cs new file mode 100644 index 00000000..5cbb7e0d --- /dev/null +++ b/src/StardewModdingAPI.AssemblyRewriters/IMethodRewriter.cs @@ -0,0 +1,21 @@ +using Mono.Cecil; +using Mono.Cecil.Cil; + +namespace StardewModdingAPI.AssemblyRewriters +{ + /// <summary>Rewrites a method for compatibility.</summary> + public interface IMethodRewriter + { + /// <summary>Get whether the given method reference can be rewritten.</summary> + /// <param name="methodRef">The method reference.</param> + bool ShouldRewrite(MethodReference methodRef); + + /// <summary>Rewrite a method for compatibility.</summary> + /// <param name="module">The module being rewritten.</param> + /// <param name="cil">The CIL rewriter.</param> + /// <param name="callOp">The instruction which calls the method.</param> + /// <param name="methodRef">The method reference invoked by the <paramref name="callOp"/>.</param> + /// <param name="assemblyMap">Metadata for mapping assemblies to the current platform.</param> + void Rewrite(ModuleDefinition module, ILProcessor cil, Instruction callOp, MethodReference methodRef, PlatformAssemblyMap assemblyMap); + } +} diff --git a/src/StardewModdingAPI.AssemblyRewriters/Platform.cs b/src/StardewModdingAPI.AssemblyRewriters/Platform.cs new file mode 100644 index 00000000..8888a9a8 --- /dev/null +++ b/src/StardewModdingAPI.AssemblyRewriters/Platform.cs @@ -0,0 +1,12 @@ +namespace StardewModdingAPI.AssemblyRewriters +{ + /// <summary>The game's platform version.</summary> + public enum Platform + { + /// <summary>The Linux/Mac version of the game.</summary> + Mono, + + /// <summary>The Windows version of the game.</summary> + Windows + } +}
\ No newline at end of file diff --git a/src/StardewModdingAPI.AssemblyRewriters/PlatformAssemblyMap.cs b/src/StardewModdingAPI.AssemblyRewriters/PlatformAssemblyMap.cs new file mode 100644 index 00000000..f2826080 --- /dev/null +++ b/src/StardewModdingAPI.AssemblyRewriters/PlatformAssemblyMap.cs @@ -0,0 +1,57 @@ +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using Mono.Cecil; + +namespace StardewModdingAPI.AssemblyRewriters +{ + /// <summary>Metadata for mapping assemblies to the current <see cref="Platform"/>.</summary> + public class PlatformAssemblyMap + { + /********* + ** Accessors + *********/ + /**** + ** Data + ****/ + /// <summary>The target game platform.</summary> + public readonly Platform TargetPlatform; + + /// <summary>The short assembly names to remove as assembly reference, and replace with the <see cref="Targets"/>. These should be short names (like "Stardew Valley").</summary> + public readonly string[] RemoveNames; + + /// <summary>The assembly filenames to target. Equivalent types should be rewritten to use these assemblies.</summary> + + /**** + ** Metadata + ****/ + /// <summary>The assemblies to target. Equivalent types should be rewritten to use these assemblies.</summary> + public readonly Assembly[] Targets; + + /// <summary>An assembly => reference cache.</summary> + public readonly IDictionary<Assembly, AssemblyNameReference> TargetReferences; + + /// <summary>An assembly => module cache.</summary> + public readonly IDictionary<Assembly, ModuleDefinition> TargetModules; + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="targetPlatform">The target game platform.</param> + /// <param name="removeAssemblyNames">The assembly short names to remove (like <c>Stardew Valley</c>).</param> + /// <param name="targetAssemblies">The assemblies to target.</param> + public PlatformAssemblyMap(Platform targetPlatform, string[] removeAssemblyNames, Assembly[] targetAssemblies) + { + // save data + this.TargetPlatform = targetPlatform; + this.RemoveNames = removeAssemblyNames; + + // 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)); + } + } +} diff --git a/src/StardewModdingAPI.AssemblyRewriters/Properties/AssemblyInfo.cs b/src/StardewModdingAPI.AssemblyRewriters/Properties/AssemblyInfo.cs new file mode 100644 index 00000000..25fbfef9 --- /dev/null +++ b/src/StardewModdingAPI.AssemblyRewriters/Properties/AssemblyInfo.cs @@ -0,0 +1,7 @@ +using System.Reflection; +using System.Runtime.InteropServices; + +[assembly: AssemblyTitle("StardewModdingAPI.AssemblyRewriters")] +[assembly: AssemblyDescription("Contains internal SMAPI code for converting mods between Linux/Mac and Windows. This assembly is used by SMAPI internally and shouldn't be referenced directly by mods.")] +[assembly: AssemblyProduct("StardewModdingAPI.CrossplatformRewriters")] +[assembly: Guid("10db0676-9fc1-4771-a2c8-e2519f091e49")] diff --git a/src/StardewModdingAPI.AssemblyRewriters/Rewriters/BaseMethodRewriter.cs b/src/StardewModdingAPI.AssemblyRewriters/Rewriters/BaseMethodRewriter.cs new file mode 100644 index 00000000..1af6e6c4 --- /dev/null +++ b/src/StardewModdingAPI.AssemblyRewriters/Rewriters/BaseMethodRewriter.cs @@ -0,0 +1,92 @@ +using System; +using System.Linq; +using System.Reflection; +using Mono.Cecil; +using Mono.Cecil.Cil; + +namespace StardewModdingAPI.AssemblyRewriters.Rewriters +{ + /// <summary>Base class for a method rewriter.</summary> + public abstract class BaseMethodRewriter : IMethodRewriter + { + /********* + ** Public methods + *********/ + /// <summary>Get whether the given method reference can be rewritten.</summary> + /// <param name="methodRef">The method reference.</param> + public abstract bool ShouldRewrite(MethodReference methodRef); + + /// <summary>Rewrite a method for compatibility.</summary> + /// <param name="module">The module being rewritten.</param> + /// <param name="cil">The CIL rewriter.</param> + /// <param name="callOp">The instruction which calls the method.</param> + /// <param name="methodRef">The method reference invoked by the <paramref name="callOp"/>.</param> + /// <param name="assemblyMap">Metadata for mapping assemblies to the current platform.</param> + public abstract void Rewrite(ModuleDefinition module, ILProcessor cil, Instruction callOp, MethodReference methodRef, PlatformAssemblyMap assemblyMap); + + + /********* + ** Protected methods + *********/ + /// <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> + protected bool HasMatchingSignature(MethodInfo definition, MethodReference reference) + { + // same name + if (definition.Name != reference.Name) + return false; + + // same arguments + ParameterInfo[] definitionParameters = definition.GetParameters(); + ParameterDefinition[] referenceParameters = reference.Parameters.ToArray(); + if (referenceParameters.Length != definitionParameters.Length) + return false; + for (int i = 0; i < referenceParameters.Length; i++) + { + if (!this.IsMatchingType(definitionParameters[i].ParameterType, referenceParameters[i].ParameterType)) + return false; + } + return true; + } + + /// <summary>Get whether a type has a method whose signature matches the one expected by a method reference.</summary> + /// <param name="type">The type to check.</param> + /// <param name="reference">The method reference.</param> + protected bool HasMatchingSignature(Type type, MethodReference reference) + { + return type + .GetMethods(BindingFlags.Instance | BindingFlags.DeclaredOnly | BindingFlags.Public) + .Any(method => this.HasMatchingSignature(method, reference)); + } + + /// <summary>Get whether a type matches a type reference.</summary> + /// <param name="type">The defined type.</param> + /// <param name="reference">The type reference.</param> + private bool IsMatchingType(Type type, TypeReference reference) + { + // same namespace & name + if (type.Namespace != reference.Namespace || type.Name != reference.Name) + return false; + + // same generic parameters + if (type.IsGenericType) + { + if (!reference.IsGenericInstance) + return false; + + Type[] defGenerics = type.GetGenericArguments(); + TypeReference[] refGenerics = ((GenericInstanceType)reference).GenericArguments.ToArray(); + if (defGenerics.Length != refGenerics.Length) + return false; + for (int i = 0; i < defGenerics.Length; i++) + { + if (!this.IsMatchingType(defGenerics[i], refGenerics[i])) + return false; + } + } + + return true; + } + } +}
\ No newline at end of file diff --git a/src/StardewModdingAPI.AssemblyRewriters/Rewriters/SpriteBatchRewriter.cs b/src/StardewModdingAPI.AssemblyRewriters/Rewriters/SpriteBatchRewriter.cs new file mode 100644 index 00000000..1c0a5cf3 --- /dev/null +++ b/src/StardewModdingAPI.AssemblyRewriters/Rewriters/SpriteBatchRewriter.cs @@ -0,0 +1,30 @@ +using Microsoft.Xna.Framework.Graphics; +using Mono.Cecil; +using Mono.Cecil.Cil; +using StardewModdingAPI.AssemblyRewriters.Wrappers; + +namespace StardewModdingAPI.AssemblyRewriters.Rewriters +{ + /// <summary>Rewrites references to <see cref="SpriteBatch"/> to fix inconsistent method signatures between MonoGame and XNA.</summary> + /// <remarks>MonoGame has one <c>SpriteBatch.Begin</c> method with optional arguments, but XNA has multiple method overloads. Incompatible method references are rewritten to use <see cref="CompatibleSpriteBatch"/>, which redirects all method signatures to the proper compiled MonoGame/XNA method.</remarks> + public class SpriteBatchRewriter : BaseMethodRewriter + { + /// <summary>Get whether the given method reference can be rewritten.</summary> + /// <param name="methodRef">The method reference.</param> + public override bool ShouldRewrite(MethodReference methodRef) + { + return methodRef.DeclaringType.FullName == typeof(SpriteBatch).FullName && this.HasMatchingSignature(typeof(CompatibleSpriteBatch), methodRef); + } + + /// <summary>Rewrite a method for compatibility.</summary> + /// <param name="module">The module being rewritten.</param> + /// <param name="cil">The CIL rewriter.</param> + /// <param name="callOp">The instruction which calls the method.</param> + /// <param name="methodRef">The method reference invoked by the <paramref name="callOp"/>.</param> + /// <param name="assemblyMap">Metadata for mapping assemblies to the current platform.</param> + public override void Rewrite(ModuleDefinition module, ILProcessor cil, Instruction callOp, MethodReference methodRef, PlatformAssemblyMap assemblyMap) + { + methodRef.DeclaringType = module.Import(typeof(CompatibleSpriteBatch)); + } + } +}
\ No newline at end of file diff --git a/src/StardewModdingAPI.AssemblyRewriters/StardewModdingAPI.AssemblyRewriters.csproj b/src/StardewModdingAPI.AssemblyRewriters/StardewModdingAPI.AssemblyRewriters.csproj new file mode 100644 index 00000000..51a49da0 --- /dev/null +++ b/src/StardewModdingAPI.AssemblyRewriters/StardewModdingAPI.AssemblyRewriters.csproj @@ -0,0 +1,94 @@ +<?xml version="1.0" encoding="utf-8"?> +<Project ToolsVersion="14.0" DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> + <Import Project="$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props" Condition="Exists('$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props')" /> + <PropertyGroup> + <Configuration Condition=" '$(Configuration)' == '' ">Debug</Configuration> + <Platform Condition=" '$(Platform)' == '' ">AnyCPU</Platform> + <ProjectGuid>{10DB0676-9FC1-4771-A2C8-E2519F091E49}</ProjectGuid> + <OutputType>Library</OutputType> + <AppDesignerFolder>Properties</AppDesignerFolder> + <RootNamespace>StardewModdingAPI.AssemblyRewriters</RootNamespace> + <AssemblyName>StardewModdingAPI.AssemblyRewriters</AssemblyName> + <TargetFrameworkVersion>v4.5</TargetFrameworkVersion> + <FileAlignment>512</FileAlignment> + </PropertyGroup> + <PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|AnyCPU' "> + <DebugSymbols>true</DebugSymbols> + <DebugType>full</DebugType> + <Optimize>false</Optimize> + <OutputPath>bin\Debug\</OutputPath> + <DefineConstants>DEBUG;TRACE</DefineConstants> + <ErrorReport>prompt</ErrorReport> + <WarningLevel>4</WarningLevel> + </PropertyGroup> + <PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Release|AnyCPU' "> + <DebugType>pdbonly</DebugType> + <Optimize>true</Optimize> + <OutputPath>bin\Release\</OutputPath> + <DefineConstants>TRACE</DefineConstants> + <ErrorReport>prompt</ErrorReport> + <WarningLevel>4</WarningLevel> + </PropertyGroup> + <PropertyGroup Condition="'$(Configuration)|$(Platform)' == 'Debug|x86'"> + <DebugSymbols>true</DebugSymbols> + <OutputPath>bin\x86\Debug\</OutputPath> + <DefineConstants>DEBUG;TRACE;SMAPI_FOR_WINDOWS</DefineConstants> + <DebugType>full</DebugType> + <PlatformTarget>x86</PlatformTarget> + <ErrorReport>prompt</ErrorReport> + <CodeAnalysisRuleSet>MinimumRecommendedRules.ruleset</CodeAnalysisRuleSet> + </PropertyGroup> + <PropertyGroup Condition="'$(Configuration)|$(Platform)' == 'Release|x86'"> + <OutputPath>bin\x86\Release\</OutputPath> + <DefineConstants>TRACE;SMAPI_FOR_WINDOWS</DefineConstants> + <Optimize>true</Optimize> + <DebugType>pdbonly</DebugType> + <PlatformTarget>x86</PlatformTarget> + <ErrorReport>prompt</ErrorReport> + <CodeAnalysisRuleSet>MinimumRecommendedRules.ruleset</CodeAnalysisRuleSet> + </PropertyGroup> + <Import Project="$(SolutionDir)\dependencies.targets" /> + <ItemGroup> + <Reference Include="Mono.Cecil, Version=0.9.6.0, Culture=neutral, PublicKeyToken=0738eb9f132ed756, processorArchitecture=MSIL"> + <HintPath>..\packages\Mono.Cecil.0.9.6.4\lib\net45\Mono.Cecil.dll</HintPath> + <Private>True</Private> + </Reference> + <Reference Include="Mono.Cecil.Mdb, Version=0.9.6.0, Culture=neutral, PublicKeyToken=0738eb9f132ed756, processorArchitecture=MSIL"> + <HintPath>..\packages\Mono.Cecil.0.9.6.4\lib\net45\Mono.Cecil.Mdb.dll</HintPath> + <Private>True</Private> + </Reference> + <Reference Include="Mono.Cecil.Pdb, Version=0.9.6.0, Culture=neutral, PublicKeyToken=0738eb9f132ed756, processorArchitecture=MSIL"> + <HintPath>..\packages\Mono.Cecil.0.9.6.4\lib\net45\Mono.Cecil.Pdb.dll</HintPath> + <Private>True</Private> + </Reference> + <Reference Include="Mono.Cecil.Rocks, Version=0.9.6.0, Culture=neutral, PublicKeyToken=0738eb9f132ed756, processorArchitecture=MSIL"> + <HintPath>..\packages\Mono.Cecil.0.9.6.4\lib\net45\Mono.Cecil.Rocks.dll</HintPath> + <Private>True</Private> + </Reference> + <Reference Include="System" /> + </ItemGroup> + <ItemGroup> + <Compile Include="..\GlobalAssemblyInfo.cs"> + <Link>Properties\GlobalAssemblyInfo.cs</Link> + </Compile> + <Compile Include="IMethodRewriter.cs" /> + <Compile Include="Platform.cs" /> + <Compile Include="PlatformAssemblyMap.cs" /> + <Compile Include="Properties\AssemblyInfo.cs" /> + <Compile Include="Rewriters\BaseMethodRewriter.cs" /> + <Compile Include="Rewriters\SpriteBatchRewriter.cs" /> + <Compile Include="Wrappers\CompatibleSpriteBatch.cs" /> + </ItemGroup> + <ItemGroup> + <None Include="packages.config" /> + </ItemGroup> + <ItemGroup /> + <Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" /> + <!-- To modify your build process, add your task inside one of the targets below and uncomment it. + Other similar extension points exist, see Microsoft.Common.targets. + <Target Name="BeforeBuild"> + </Target> + <Target Name="AfterBuild"> + </Target> + --> +</Project>
\ No newline at end of file diff --git a/src/StardewModdingAPI.AssemblyRewriters/Wrappers/CompatibleSpriteBatch.cs b/src/StardewModdingAPI.AssemblyRewriters/Wrappers/CompatibleSpriteBatch.cs new file mode 100644 index 00000000..e28d1a68 --- /dev/null +++ b/src/StardewModdingAPI.AssemblyRewriters/Wrappers/CompatibleSpriteBatch.cs @@ -0,0 +1,52 @@ +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Graphics; + +#pragma warning disable CS0109 // Member does not hide an inherited member; new keyword is not required +namespace StardewModdingAPI.AssemblyRewriters.Wrappers +{ + /// <summary>Wraps <see cref="SpriteBatch"/> methods that are incompatible when converting compiled code between MonoGame and XNA.</summary> + public class CompatibleSpriteBatch : SpriteBatch + { + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + public CompatibleSpriteBatch(GraphicsDevice graphicsDevice) : base(graphicsDevice) { } + + /**** + ** MonoGame signatures + ****/ + public new void Begin(SpriteSortMode sortMode, BlendState blendState, SamplerState samplerState, DepthStencilState depthStencilState, RasterizerState rasterizerState, Effect effect, Matrix? matrix) + { + base.Begin(sortMode, blendState, samplerState, depthStencilState, rasterizerState, effect, matrix ?? Matrix.Identity); + } + + /**** + ** XNA signatures + ****/ + public new void Begin() + { + base.Begin(); + } + + public new void Begin(SpriteSortMode sortMode, BlendState blendState) + { + base.Begin(sortMode, blendState); + } + + public new void Begin(SpriteSortMode sortMode, BlendState blendState, SamplerState samplerState, DepthStencilState depthStencilState, RasterizerState rasterizerState) + { + base.Begin(sortMode, blendState, samplerState, depthStencilState, rasterizerState); + } + + public new void Begin(SpriteSortMode sortMode, BlendState blendState, SamplerState samplerState, DepthStencilState depthStencilState, RasterizerState rasterizerState, Effect effect) + { + base.Begin(sortMode, blendState, samplerState, depthStencilState, rasterizerState, effect); + } + + public new void Begin(SpriteSortMode sortMode, BlendState blendState, SamplerState samplerState, DepthStencilState depthStencilState, RasterizerState rasterizerState, Effect effect, Matrix transformMatrix) + { + base.Begin(sortMode, blendState, samplerState, depthStencilState, rasterizerState, effect, transformMatrix); + } + } +}
\ No newline at end of file diff --git a/src/StardewModdingAPI.AssemblyRewriters/packages.config b/src/StardewModdingAPI.AssemblyRewriters/packages.config new file mode 100644 index 00000000..88fbc79d --- /dev/null +++ b/src/StardewModdingAPI.AssemblyRewriters/packages.config @@ -0,0 +1,4 @@ +<?xml version="1.0" encoding="utf-8"?> +<packages> + <package id="Mono.Cecil" version="0.9.6.4" targetFramework="net452" /> +</packages>
\ No newline at end of file diff --git a/src/StardewModdingAPI.Installer/StardewModdingAPI.Installer.csproj b/src/StardewModdingAPI.Installer/StardewModdingAPI.Installer.csproj index 6f9ed2eb..8e4d38b3 100644 --- a/src/StardewModdingAPI.Installer/StardewModdingAPI.Installer.csproj +++ b/src/StardewModdingAPI.Installer/StardewModdingAPI.Installer.csproj @@ -67,8 +67,11 @@ <Copy SourceFiles="$(TargetDir)\readme.txt" DestinationFiles="$(CompiledInstallerPath)\readme.txt" /> <!-- copy SMAPI files for Mono --> <Copy Condition="$(OS) != 'Windows_NT'" SourceFiles="$(CompiledSmapiPath)\Newtonsoft.Json.dll" DestinationFolder="$(CompiledInstallerPath)\Mono" /> + <Copy Condition="$(OS) != 'Windows_NT'" SourceFiles="$(CompiledSmapiPath)\Mono.Cecil.dll" DestinationFolder="$(CompiledInstallerPath)\Mono" /> + <Copy Condition="$(OS) != 'Windows_NT'" SourceFiles="$(CompiledSmapiPath)\Mono.Cecil.Rocks.dll" DestinationFolder="$(CompiledInstallerPath)\Mono" /> <Copy Condition="$(OS) != 'Windows_NT'" SourceFiles="$(CompiledSmapiPath)\StardewModdingAPI.exe" DestinationFolder="$(CompiledInstallerPath)\Mono" /> <Copy Condition="$(OS) != 'Windows_NT'" SourceFiles="$(CompiledSmapiPath)\StardewModdingAPI.exe.mdb" DestinationFolder="$(CompiledInstallerPath)\Mono" /> + <Copy Condition="$(OS) != 'Windows_NT'" SourceFiles="$(CompiledSmapiPath)\StardewModdingAPI.AssemblyRewriters.dll" DestinationFolder="$(CompiledInstallerPath)\Mono" /> <Copy Condition="$(OS) != 'Windows_NT'" SourceFiles="$(CompiledSmapiPath)\StardewModdingAPI-settings.json" DestinationFolder="$(CompiledInstallerPath)\Mono" /> <Copy Condition="$(OS) != 'Windows_NT'" SourceFiles="$(CompiledSmapiPath)\System.Numerics.dll" DestinationFolder="$(CompiledInstallerPath)\Mono" /> <Copy Condition="$(OS) != 'Windows_NT'" SourceFiles="$(CompiledSmapiPath)\unix-launcher.sh" DestinationFiles="$(CompiledInstallerPath)\Mono\StardewModdingAPI" /> diff --git a/src/StardewModdingAPI.sln b/src/StardewModdingAPI.sln index 31bf5f2f..d97e4645 100644 --- a/src/StardewModdingAPI.sln +++ b/src/StardewModdingAPI.sln @@ -23,6 +23,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StardewModdingAPI.Installer {F1A573B0-F436-472C-AE29-0B91EA6B9F8F} = {F1A573B0-F436-472C-AE29-0B91EA6B9F8F} EndProjectSection EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StardewModdingAPI.AssemblyRewriters", "StardewModdingAPI.AssemblyRewriters\StardewModdingAPI.AssemblyRewriters.csproj", "{10DB0676-9FC1-4771-A2C8-E2519F091E49}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -67,6 +69,18 @@ Global {443DDF81-6AAF-420A-A610-3459F37E5575}.Release|Mixed Platforms.Build.0 = Release|Any CPU {443DDF81-6AAF-420A-A610-3459F37E5575}.Release|x86.ActiveCfg = Release|Any CPU {443DDF81-6AAF-420A-A610-3459F37E5575}.Release|x86.Build.0 = Release|Any CPU + {10DB0676-9FC1-4771-A2C8-E2519F091E49}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {10DB0676-9FC1-4771-A2C8-E2519F091E49}.Debug|Any CPU.Build.0 = Debug|Any CPU + {10DB0676-9FC1-4771-A2C8-E2519F091E49}.Debug|Mixed Platforms.ActiveCfg = Debug|Any CPU + {10DB0676-9FC1-4771-A2C8-E2519F091E49}.Debug|Mixed Platforms.Build.0 = Debug|Any CPU + {10DB0676-9FC1-4771-A2C8-E2519F091E49}.Debug|x86.ActiveCfg = Debug|Any CPU + {10DB0676-9FC1-4771-A2C8-E2519F091E49}.Debug|x86.Build.0 = Debug|Any CPU + {10DB0676-9FC1-4771-A2C8-E2519F091E49}.Release|Any CPU.ActiveCfg = Release|Any CPU + {10DB0676-9FC1-4771-A2C8-E2519F091E49}.Release|Any CPU.Build.0 = Release|Any CPU + {10DB0676-9FC1-4771-A2C8-E2519F091E49}.Release|Mixed Platforms.ActiveCfg = Release|Any CPU + {10DB0676-9FC1-4771-A2C8-E2519F091E49}.Release|Mixed Platforms.Build.0 = Release|Any CPU + {10DB0676-9FC1-4771-A2C8-E2519F091E49}.Release|x86.ActiveCfg = Release|Any CPU + {10DB0676-9FC1-4771-A2C8-E2519F091E49}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/src/StardewModdingAPI/Constants.cs b/src/StardewModdingAPI/Constants.cs index 05e410ab..815e5427 100644 --- a/src/StardewModdingAPI/Constants.cs +++ b/src/StardewModdingAPI/Constants.cs @@ -1,7 +1,10 @@ using System; +using System.Collections.Generic; using System.IO; using System.Linq; using System.Reflection; +using StardewModdingAPI.AssemblyRewriters; +using StardewModdingAPI.AssemblyRewriters.Rewriters; using StardewValley; namespace StardewModdingAPI @@ -63,6 +66,65 @@ namespace StardewModdingAPI /********* + ** Internal field + *********/ + /// <summary>Get metadata for mapping assemblies to the current platform.</summary> + /// <param name="targetPlatform">The target game platform.</param> + internal static PlatformAssemblyMap GetAssemblyMap(Platform targetPlatform) + { + // get assembly changes needed for platform + string[] removeAssemblyReferences; + Assembly[] targetAssemblies; + switch (targetPlatform) + { + case Platform.Mono: + removeAssemblyReferences = new[] + { + "Stardew Valley", + "Microsoft.Xna.Framework", + "Microsoft.Xna.Framework.Game", + "Microsoft.Xna.Framework.Graphics" + }; + targetAssemblies = new[] + { + typeof(StardewValley.Game1).Assembly, + typeof(Microsoft.Xna.Framework.Vector2).Assembly + }; + break; + + case Platform.Windows: + removeAssemblyReferences = new[] + { + "StardewValley", + "MonoGame.Framework" + }; + targetAssemblies = new[] + { + typeof(StardewValley.Game1).Assembly, + typeof(Microsoft.Xna.Framework.Vector2).Assembly, + typeof(Microsoft.Xna.Framework.Game).Assembly, + typeof(Microsoft.Xna.Framework.Graphics.SpriteBatch).Assembly + }; + break; + + default: + throw new InvalidOperationException($"Unknown target platform '{targetPlatform}'."); + } + + return new PlatformAssemblyMap(targetPlatform, removeAssemblyReferences, targetAssemblies); + } + + /// <summary>Get method rewriters which fix incompatible method calls in mod assemblies.</summary> + internal static IEnumerable<IMethodRewriter> GetMethodRewriters() + { + return new[] + { + new SpriteBatchRewriter() + }; + } + + + /********* ** Private field *********/ /// <summary>Get the name of a save directory for the current player.</summary> @@ -72,4 +134,4 @@ namespace StardewModdingAPI return $"{prefix}_{Game1.uniqueIDForThisGame}"; } } -}
\ No newline at end of file +} diff --git a/src/StardewModdingAPI/FodyWeavers.xml b/src/StardewModdingAPI/FodyWeavers.xml index 0ef30506..bd5a8e78 100644 --- a/src/StardewModdingAPI/FodyWeavers.xml +++ b/src/StardewModdingAPI/FodyWeavers.xml @@ -2,6 +2,7 @@ <Weavers> <Costura> <ExcludeAssemblies> + StardewModdingAPI.AssemblyRewriters Stardew Valley xTile Steamworks.NET diff --git a/src/StardewModdingAPI/Framework/AssemblyRewriting/AssemblyTypeRewriter.cs b/src/StardewModdingAPI/Framework/AssemblyRewriting/AssemblyTypeRewriter.cs new file mode 100644 index 00000000..1e97bdcb --- /dev/null +++ b/src/StardewModdingAPI/Framework/AssemblyRewriting/AssemblyTypeRewriter.cs @@ -0,0 +1,160 @@ +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using Mono.Cecil; +using Mono.Cecil.Cil; +using Mono.Cecil.Rocks; +using StardewModdingAPI.AssemblyRewriters; + +namespace StardewModdingAPI.Framework.AssemblyRewriting +{ + /// <summary>Rewrites type references.</summary> + internal class AssemblyTypeRewriter + { + /********* + ** Properties + *********/ + /// <summary>Metadata for mapping assemblies to the current <see cref="Platform"/>.</summary> + private readonly PlatformAssemblyMap AssemblyMap; + + /// <summary>A type => assembly lookup for types which should be rewritten.</summary> + private readonly IDictionary<string, Assembly> TypeAssemblies; + + /// <summary>Encapsulates monitoring and logging.</summary> + private readonly IMonitor Monitor; + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="assemblyMap">Metadata for mapping assemblies to the current <see cref="Platform"/>.</param> + /// <param name="monitor">Encapsulates monitoring and logging.</param> + public AssemblyTypeRewriter(PlatformAssemblyMap assemblyMap, IMonitor monitor) + { + // save config + this.AssemblyMap = assemblyMap; + this.Monitor = monitor; + + // collect type => assembly lookup + this.TypeAssemblies = new Dictionary<string, Assembly>(); + foreach (Assembly assembly in 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; + } + } + } + + /// <summary>Rewrite the types referenced by an assembly.</summary> + /// <param name="assembly">The assembly to rewrite.</param> + public void RewriteAssembly(AssemblyDefinition assembly) + { + ModuleDefinition module = assembly.Modules.Single(); // technically an assembly can have multiple modules, but none of the build tools (including MSBuild) support it; simplify by assuming one module + + // remove old assembly references + bool shouldRewrite = false; + for (int i = 0; i < module.AssemblyReferences.Count; i++) + { + if (this.AssemblyMap.RemoveNames.Any(name => module.AssemblyReferences[i].Name == name)) + { + this.Monitor.Log($"removing reference to {module.AssemblyReferences[i]}", LogLevel.Trace); + shouldRewrite = true; + module.AssemblyReferences.RemoveAt(i); + i--; + } + } + if (!shouldRewrite) + return; + + // add target assembly references + foreach (AssemblyNameReference target in this.AssemblyMap.TargetReferences.Values) + { + this.Monitor.Log($" adding reference to {target}", LogLevel.Trace); + module.AssemblyReferences.Add(target); + } + + // rewrite type scopes to use target assemblies + IEnumerable<TypeReference> typeReferences = module.GetTypeReferences().OrderBy(p => p.FullName); + string lastTypeLogged = null; + foreach (TypeReference type in typeReferences) + { + this.ChangeTypeScope(type, shouldLog: type.FullName != lastTypeLogged); + lastTypeLogged = type.FullName; + } + + // rewrite incompatible methods + IMethodRewriter[] methodRewriters = Constants.GetMethodRewriters().ToArray(); + foreach (MethodDefinition method in this.GetMethods(module)) + { + // skip methods with no rewritable method + bool hasMethodToRewrite = method.Body.Instructions.Any(op => (op.OpCode == OpCodes.Call || op.OpCode == OpCodes.Callvirt) && methodRewriters.Any(rewriter => rewriter.ShouldRewrite((MethodReference)op.Operand))); + if (!hasMethodToRewrite) + continue; + + // rewrite method references + method.Body.SimplifyMacros(); + ILProcessor cil = method.Body.GetILProcessor(); + Instruction[] instructions = cil.Body.Instructions.ToArray(); + foreach (Instruction op in instructions) + { + if (op.OpCode == OpCodes.Call || op.OpCode == OpCodes.Callvirt) + { + IMethodRewriter rewriter = methodRewriters.FirstOrDefault(p => p.ShouldRewrite((MethodReference)op.Operand)); + if (rewriter != null) + { + MethodReference methodRef = (MethodReference)op.Operand; + this.Monitor.Log($"rewriting method reference {methodRef.DeclaringType.FullName}.{methodRef.Name}"); + rewriter.Rewrite(module, cil, op, methodRef, this.AssemblyMap); + } + } + } + method.Body.OptimizeMacros(); + } + } + + + /********* + ** Private methods + *********/ + /// <summary>Get the correct reference to use for compatibility with the current platform.</summary> + /// <param name="type">The type reference to rewrite.</param> + /// <param name="shouldLog">Whether to log a message.</param> + private void ChangeTypeScope(TypeReference type, bool shouldLog) + { + // check skip conditions + if (type == null || type.FullName.StartsWith("System.")) + return; + + // get assembly + Assembly assembly; + if (!this.TypeAssemblies.TryGetValue(type.FullName, out assembly)) + return; + + // replace scope + AssemblyNameReference assemblyRef = this.AssemblyMap.TargetReferences[assembly]; + if (shouldLog) + this.Monitor.Log($"redirecting {type.FullName} from {type.Scope.Name} to {assemblyRef.Name}", LogLevel.Trace); + type.Scope = assemblyRef; + } + + /// <summary>Get all methods in a module.</summary> + /// <param name="module">The module to search.</param> + private IEnumerable<MethodDefinition> GetMethods(ModuleDefinition module) + { + return ( + from type in module.GetTypes() + where type.HasMethods + from method in type.Methods + where method.HasBody + select method + ); + } + } +} diff --git a/src/StardewModdingAPI/Framework/AssemblyRewriting/CachePaths.cs b/src/StardewModdingAPI/Framework/AssemblyRewriting/CachePaths.cs new file mode 100644 index 00000000..17c4d188 --- /dev/null +++ b/src/StardewModdingAPI/Framework/AssemblyRewriting/CachePaths.cs @@ -0,0 +1,33 @@ +namespace StardewModdingAPI.Framework.AssemblyRewriting +{ + /// <summary>Contains the paths for an assembly's cached data.</summary> + internal struct CachePaths + { + /********* + ** Accessors + *********/ + /// <summary>The directory path which contains the assembly.</summary> + public string Directory { get; } + + /// <summary>The file path of the assembly file.</summary> + public string Assembly { get; } + + /// <summary>The file path containing the MD5 hash for the assembly.</summary> + public string Hash { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="directory">The directory path which contains the assembly.</param> + /// <param name="assembly">The file path of the assembly file.</param> + /// <param name="hash">The file path containing the MD5 hash for the assembly.</param> + public CachePaths(string directory, string assembly, string hash) + { + this.Directory = directory; + this.Assembly = assembly; + this.Hash = hash; + } + } +}
\ No newline at end of file diff --git a/src/StardewModdingAPI/Framework/ModAssemblyLoader.cs b/src/StardewModdingAPI/Framework/ModAssemblyLoader.cs new file mode 100644 index 00000000..e095cde8 --- /dev/null +++ b/src/StardewModdingAPI/Framework/ModAssemblyLoader.cs @@ -0,0 +1,128 @@ +using System; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Security.Cryptography; +using Mono.Cecil; +using StardewModdingAPI.AssemblyRewriters; +using StardewModdingAPI.Framework.AssemblyRewriting; + +namespace StardewModdingAPI.Framework +{ + /// <summary>Preprocesses and loads mod assemblies.</summary> + internal class ModAssemblyLoader + { + /********* + ** Properties + *********/ + /// <summary>The directory in which to cache data.</summary> + private readonly string CacheDirPath; + + /// <summary>Metadata for mapping assemblies to the current <see cref="Platform"/>.</summary> + private readonly PlatformAssemblyMap AssemblyMap; + + /// <summary>Rewrites assembly types to match the current platform.</summary> + private readonly AssemblyTypeRewriter AssemblyTypeRewriter; + + /// <summary>Encapsulates monitoring and logging.</summary> + private readonly IMonitor Monitor; + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="cacheDirPath">The cache directory.</param> + /// <param name="targetPlatform">The current game platform.</param> + /// <param name="monitor">Encapsulates monitoring and logging.</param> + public ModAssemblyLoader(string cacheDirPath, Platform targetPlatform, IMonitor monitor) + { + this.CacheDirPath = cacheDirPath; + this.Monitor = monitor; + this.AssemblyMap = Constants.GetAssemblyMap(targetPlatform); + this.AssemblyTypeRewriter = new AssemblyTypeRewriter(this.AssemblyMap, monitor); + } + + /// <summary>Preprocess an assembly and cache the modified version.</summary> + /// <param name="assemblyPath">The assembly file path.</param> + public void ProcessAssembly(string assemblyPath) + { + // read assembly data + string assemblyFileName = Path.GetFileName(assemblyPath); + string assemblyDir = Path.GetDirectoryName(assemblyPath); + byte[] assemblyBytes = File.ReadAllBytes(assemblyPath); + byte[] hash = MD5.Create().ComputeHash(assemblyBytes); + + // check cache + CachePaths cachePaths = this.GetCacheInfo(assemblyPath); + bool canUseCache = File.Exists(cachePaths.Assembly) && File.Exists(cachePaths.Hash) && hash.SequenceEqual(File.ReadAllBytes(cachePaths.Hash)); + + // process assembly if not cached + if (!canUseCache) + { + this.Monitor.Log($"Loading {assemblyFileName} for the first time; preprocessing..."); + + // read assembly definition + AssemblyDefinition assembly; + using (Stream readStream = new MemoryStream(assemblyBytes)) + assembly = AssemblyDefinition.ReadAssembly(readStream); + + // rewrite assembly to match platform + this.AssemblyTypeRewriter.RewriteAssembly(assembly); + + // write cache + using (MemoryStream outStream = new MemoryStream()) + { + // get assembly bytes + assembly.Write(outStream); + byte[] outBytes = outStream.ToArray(); + + // write assembly data + Directory.CreateDirectory(cachePaths.Directory); + File.WriteAllBytes(cachePaths.Assembly, outBytes); + File.WriteAllBytes(cachePaths.Hash, hash); + + // copy any mdb/pdb files + foreach (string path in Directory.GetFiles(assemblyDir, "*.mdb").Concat(Directory.GetFiles(assemblyDir, "*.pdb"))) + { + string filename = Path.GetFileName(path); + File.Copy(path, Path.Combine(cachePaths.Directory, filename), overwrite: true); + } + } + } + } + + /// <summary>Load a preprocessed assembly.</summary> + /// <param name="assemblyPath">The assembly file path.</param> + public Assembly LoadCachedAssembly(string assemblyPath) + { + CachePaths cachePaths = this.GetCacheInfo(assemblyPath); + if (!File.Exists(cachePaths.Assembly)) + throw new InvalidOperationException($"The assembly {assemblyPath} doesn't exist in the preprocessed cache."); + return Assembly.UnsafeLoadFrom(cachePaths.Assembly); // unsafe load allows DLLs downloaded from the Internet without the user needing to 'unblock' them + } + + /// <summary>Resolve an assembly from its name.</summary> + /// <param name="name">The assembly name.</param> + public Assembly ResolveAssembly(string name) + { + string shortName = name.Split(new[] { ',' }, 2).First(); + return this.AssemblyMap.Targets.FirstOrDefault(p => p.GetName().Name == shortName); + } + + + /********* + ** Private methods + *********/ + /// <summary>Get the cache details for an assembly.</summary> + /// <param name="assemblyPath">The assembly file path.</param> + private CachePaths GetCacheInfo(string assemblyPath) + { + string key = Path.GetFileNameWithoutExtension(assemblyPath); + string dirPath = Path.Combine(this.CacheDirPath, new DirectoryInfo(Path.GetDirectoryName(assemblyPath)).Name); + string cacheAssemblyPath = Path.Combine(dirPath, $"{key}.dll"); + string cacheHashPath = Path.Combine(dirPath, $"{key}.hash"); + return new CachePaths(dirPath, cacheAssemblyPath, cacheHashPath); + } + } +} diff --git a/src/StardewModdingAPI/Program.cs b/src/StardewModdingAPI/Program.cs index d31a1d39..f0686039 100644 --- a/src/StardewModdingAPI/Program.cs +++ b/src/StardewModdingAPI/Program.cs @@ -9,6 +9,7 @@ using System.Windows.Forms; #endif using Microsoft.Xna.Framework.Graphics; using Newtonsoft.Json; +using StardewModdingAPI.AssemblyRewriters; using StardewModdingAPI.Events; using StardewModdingAPI.Framework; using StardewModdingAPI.Inheritance; @@ -23,16 +24,23 @@ namespace StardewModdingAPI /********* ** Properties *********/ - /// <summary>The full path to the Stardew Valley executable.</summary> + /// <summary>The target game platform.</summary> + private static readonly Platform TargetPlatform = #if SMAPI_FOR_WINDOWS - private static readonly string GameExecutablePath = Path.Combine(Constants.ExecutionPath, "Stardew Valley.exe"); + Platform.Windows; #else - private static readonly string GameExecutablePath = Path.Combine(Constants.ExecutionPath, "StardewValley.exe"); + Platform.Mono; #endif + /// <summary>The full path to the Stardew Valley executable.</summary> + private static readonly string GameExecutablePath = Path.Combine(Constants.ExecutionPath, Program.TargetPlatform == Platform.Windows ? "Stardew Valley.exe" : "StardewValley.exe"); + /// <summary>The full path to the folder containing mods.</summary> private static readonly string ModPath = Path.Combine(Constants.ExecutionPath, "Mods"); + /// <summary>The full path to the folder containing cached SMAPI data.</summary> + private static readonly string CachePath = Path.Combine(Program.ModPath, ".cache"); + /// <summary>The log file to which to write messages.</summary> private static readonly LogFileManager LogFile = new LogFileManager(Constants.LogPath); @@ -126,6 +134,7 @@ namespace StardewModdingAPI Program.Monitor.Log("Loading SMAPI..."); Console.Title = Constants.ConsoleTitle; Program.VerifyPath(Program.ModPath); + Program.VerifyPath(Program.CachePath); Program.VerifyPath(Constants.LogDir); if (!File.Exists(Program.GameExecutablePath)) { @@ -293,141 +302,173 @@ namespace StardewModdingAPI private static void LoadMods() { Program.Monitor.Log("Loading mods..."); + + ModAssemblyLoader modAssemblyLoader = new ModAssemblyLoader(Program.CachePath, Program.TargetPlatform, Program.Monitor); + AppDomain.CurrentDomain.AssemblyResolve += (sender, e) => modAssemblyLoader.ResolveAssembly(e.Name); // supplement Mono's assembly resolution which doesn't handle assembly rewrites very well + foreach (string directory in Directory.GetDirectories(Program.ModPath)) { - foreach (string manifestPath in Directory.GetFiles(directory, "manifest.json")) + // ignore internal directory + if (new DirectoryInfo(directory).Name == ".cache") + continue; + + // check for cancellation + if (Program.CancellationTokenSource.IsCancellationRequested) + { + Program.Monitor.Log("Shutdown requested; interrupting mod loading.", LogLevel.Error); + return; + } + + // get helper + IModHelper helper = new ModHelper(directory); + + // get manifest path + string manifestPath = Path.Combine(directory, "manifest.json"); + string errorPrefix = $"Couldn't load mod for manifest '{manifestPath}'"; + Manifest manifest; + try { - // check for cancellation - if (Program.CancellationTokenSource.IsCancellationRequested) + // read manifest text + string json = File.ReadAllText(manifestPath); + if (string.IsNullOrEmpty(json)) + { + Program.Monitor.Log($"{errorPrefix}: manifest is empty.", LogLevel.Error); + continue; + } + + // deserialise manifest + manifest = helper.ReadJsonFile<Manifest>("manifest.json"); + if (manifest == null) { - Program.Monitor.Log("Shutdown requested; interrupting mod loading.", LogLevel.Error); - return; + Program.Monitor.Log($"{errorPrefix}: the manifest file does not exist.", LogLevel.Error); + continue; + } + if (string.IsNullOrEmpty(manifest.EntryDll)) + { + Program.Monitor.Log($"{errorPrefix}: manifest doesn't specify an entry DLL.", LogLevel.Error); + continue; } - IModHelper helper = new ModHelper(directory); - string errorPrefix = $"Couldn't load mod for manifest '{manifestPath}'"; + // log deprecated fields + if (manifest.UsedAuthourField) + Program.DeprecationManager.Warn(manifest.Name, $"{nameof(Manifest)}.{nameof(Manifest.Authour)}", "1.0", DeprecationLevel.Notice); + } + catch (Exception ex) + { + Program.Monitor.Log($"{errorPrefix}: manifest parsing failed.\n{ex.GetLogSummary()}", LogLevel.Error); + continue; + } - // read manifest - Manifest manifest; + // validate version + if (!string.IsNullOrWhiteSpace(manifest.MinimumApiVersion)) + { try { - // read manifest text - string json = File.ReadAllText(manifestPath); - if (string.IsNullOrEmpty(json)) + Version minVersion = new Version(manifest.MinimumApiVersion); + if (minVersion.IsNewerThan(Constants.Version)) { - Program.Monitor.Log($"{errorPrefix}: manifest is empty.", LogLevel.Error); + Program.Monitor.Log($"{errorPrefix}: this mod requires SMAPI {minVersion} or later. Please update SMAPI to the latest version to use this mod.", LogLevel.Error); continue; } - - // deserialise manifest - manifest = helper.ReadJsonFile<Manifest>("manifest.json"); - if (manifest == null) - { - Program.Monitor.Log($"{errorPrefix}: the manifest file does not exist.", LogLevel.Error); - continue; - } - if (string.IsNullOrEmpty(manifest.EntryDll)) - { - Program.Monitor.Log($"{errorPrefix}: manifest doesn't specify an entry DLL.", LogLevel.Error); - continue; - } - - // log deprecated fields - if (manifest.UsedAuthourField) - Program.DeprecationManager.Warn(manifest.Name, $"{nameof(Manifest)}.{nameof(Manifest.Authour)}", "1.0", DeprecationLevel.Notice); } - catch (Exception ex) + catch (FormatException ex) when (ex.Message.Contains("not a semantic version")) { - Program.Monitor.Log($"{errorPrefix}: manifest parsing failed.\n{ex.GetLogSummary()}", LogLevel.Error); + Program.Monitor.Log($"{errorPrefix}: the mod specified an invalid minimum SMAPI version '{manifest.MinimumApiVersion}'. This should be a semantic version number like {Constants.Version}.", LogLevel.Error); continue; } + } - // validate version - if (!string.IsNullOrWhiteSpace(manifest.MinimumApiVersion)) + // create per-save directory + if (manifest.PerSaveConfigs) + { + Program.DeprecationManager.Warn(manifest.Name, $"{nameof(Manifest)}.{nameof(Manifest.PerSaveConfigs)}", "1.0", DeprecationLevel.Notice); + try { - try + string psDir = Path.Combine(directory, "psconfigs"); + Directory.CreateDirectory(psDir); + if (!Directory.Exists(psDir)) { - Version minVersion = new Version(manifest.MinimumApiVersion); - if (minVersion.IsNewerThan(Constants.Version)) - { - Program.Monitor.Log($"{errorPrefix}: this mod requires SMAPI {minVersion} or later. Please update SMAPI to the latest version to use this mod.", LogLevel.Error); - continue; - } - } - catch (FormatException ex) when (ex.Message.Contains("not a semantic version")) - { - Program.Monitor.Log($"{errorPrefix}: the mod specified an invalid minimum SMAPI version '{manifest.MinimumApiVersion}'. This should be a semantic version number like {Constants.Version}.", LogLevel.Error); + Program.Monitor.Log($"{errorPrefix}: couldn't create the per-save configuration directory ('psconfigs') requested by this mod. The failure reason is unknown.", LogLevel.Error); continue; } } + catch (Exception ex) + { + Program.Monitor.Log($"{errorPrefix}: couldn't create the per-save configuration directory ('psconfigs') requested by this mod.\n{ex.GetLogSummary()}", LogLevel.Error); + continue; + } + } - // create per-save directory - if (manifest.PerSaveConfigs) + // preprocess mod assemblies + { + bool succeeded = true; + foreach (string assemblyPath in Directory.GetFiles(directory, "*.dll")) { - Program.DeprecationManager.Warn(manifest.Name, $"{nameof(Manifest)}.{nameof(Manifest.PerSaveConfigs)}", "1.0", DeprecationLevel.Notice); try { - string psDir = Path.Combine(directory, "psconfigs"); - Directory.CreateDirectory(psDir); - if (!Directory.Exists(psDir)) - { - Program.Monitor.Log($"{errorPrefix}: couldn't create the per-save configuration directory ('psconfigs') requested by this mod. The failure reason is unknown.", LogLevel.Error); - continue; - } + modAssemblyLoader.ProcessAssembly(assemblyPath); } catch (Exception ex) { - Program.Monitor.Log($"{errorPrefix}: couldm't create the per-save configuration directory ('psconfigs') requested by this mod.\n{ex.GetLogSummary()}", LogLevel.Error); - continue; + Program.Monitor.Log($"{errorPrefix}: an error occurred while preprocessing '{Path.GetFileName(assemblyPath)}'.\n{ex.GetLogSummary()}", LogLevel.Error); + succeeded = false; + break; } } + if (!succeeded) + continue; + } - // load DLL & hook up mod - try + // load assembly + Assembly modAssembly; + try + { + string assemblyPath = Path.Combine(directory, manifest.EntryDll); + modAssembly = modAssemblyLoader.LoadCachedAssembly(assemblyPath); + if (modAssembly.DefinedTypes.Count(x => x.BaseType == typeof(Mod)) == 0) { - string assemblyPath = Path.Combine(directory, manifest.EntryDll); - if (!File.Exists(assemblyPath)) - { - Program.Monitor.Log($"{errorPrefix}: target DLL '{assemblyPath}' does not exist.", LogLevel.Error); - continue; - } - - Assembly modAssembly = Assembly.UnsafeLoadFrom(assemblyPath); - if (modAssembly.DefinedTypes.Count(x => x.BaseType == typeof(Mod)) > 0) - { - TypeInfo modEntryType = modAssembly.DefinedTypes.First(x => x.BaseType == typeof(Mod)); - Mod modEntry = (Mod)modAssembly.CreateInstance(modEntryType.ToString()); - if (modEntry != null) - { - // track mod - Program.ModRegistry.Add(manifest, modAssembly); - - // hook up mod - modEntry.Manifest = manifest; - modEntry.Helper = helper; - modEntry.Monitor = new Monitor(manifest.Name, Program.LogFile) { ShowTraceInConsole = Program.DeveloperMode }; - modEntry.PathOnDisk = directory; - Program.Monitor.Log($"Loaded mod: {modEntry.Manifest.Name} by {modEntry.Manifest.Author}, v{modEntry.Manifest.Version} | {modEntry.Manifest.Description}", LogLevel.Info); - Program.ModsLoaded += 1; - modEntry.Entry(); // deprecated since 1.0 - modEntry.Entry((ModHelper)modEntry.Helper); // deprecated since 1.1 - modEntry.Entry(modEntry.Helper); // deprecated since 1.1 - - // raise deprecation warning for old Entry() method - if (Program.DeprecationManager.IsVirtualMethodImplemented(modEntryType, typeof(Mod), nameof(Mod.Entry), new[] { typeof(object[]) })) - Program.DeprecationManager.Warn(manifest.Name, $"an old version of {nameof(Mod)}.{nameof(Mod.Entry)}", "1.0", DeprecationLevel.Notice); - if (Program.DeprecationManager.IsVirtualMethodImplemented(modEntryType, typeof(Mod), nameof(Mod.Entry), new[] { typeof(ModHelper) })) - Program.DeprecationManager.Warn(manifest.Name, $"an old version of {nameof(Mod)}.{nameof(Mod.Entry)}", "1.1", DeprecationLevel.Notice); - } - } - else - Program.Monitor.Log($"{errorPrefix}: the mod DLL does not contain an implementation of the 'Mod' class.", LogLevel.Error); + Program.Monitor.Log($"{errorPrefix}: the mod DLL does not contain an implementation of the 'Mod' class.", LogLevel.Error); + continue; } - catch (Exception ex) + } + catch (Exception ex) + { + Program.Monitor.Log($"{errorPrefix}: an error occurred while optimising the target DLL.\n{ex.GetLogSummary()}", LogLevel.Error); + continue; + } + + // hook up mod + try + { + TypeInfo modEntryType = modAssembly.DefinedTypes.First(x => x.BaseType == typeof(Mod)); + Mod modEntry = (Mod)modAssembly.CreateInstance(modEntryType.ToString()); + if (modEntry != null) { - Program.Monitor.Log($"{errorPrefix}: an error occurred while loading the target DLL.\n{ex.GetLogSummary()}", LogLevel.Error); + // track mod + Program.ModRegistry.Add(manifest, modAssembly); + + // hook up mod + modEntry.Manifest = manifest; + modEntry.Helper = helper; + modEntry.Monitor = new Monitor(manifest.Name, Program.LogFile) { ShowTraceInConsole = Program.DeveloperMode }; + modEntry.PathOnDisk = directory; + Program.Monitor.Log($"Loaded mod: {modEntry.Manifest.Name} by {modEntry.Manifest.Author}, v{modEntry.Manifest.Version} | {modEntry.Manifest.Description}", LogLevel.Info); + Program.ModsLoaded += 1; + modEntry.Entry(); // deprecated since 1.0 + modEntry.Entry((ModHelper)modEntry.Helper); // deprecated since 1.1 + modEntry.Entry(modEntry.Helper); // deprecated since 1.1 + + // raise deprecation warning for old Entry() method + if (Program.DeprecationManager.IsVirtualMethodImplemented(modEntryType, typeof(Mod), nameof(Mod.Entry), new[] { typeof(object[]) })) + Program.DeprecationManager.Warn(manifest.Name, $"an old version of {nameof(Mod)}.{nameof(Mod.Entry)}", "1.0", DeprecationLevel.Notice); + if (Program.DeprecationManager.IsVirtualMethodImplemented(modEntryType, typeof(Mod), nameof(Mod.Entry), new[] { typeof(ModHelper) })) + Program.DeprecationManager.Warn(manifest.Name, $"an old version of {nameof(Mod)}.{nameof(Mod.Entry)}", "1.1", DeprecationLevel.Notice); } } + catch (Exception ex) + { + Program.Monitor.Log($"{errorPrefix}: an error occurred while loading the target DLL.\n{ex.GetLogSummary()}", LogLevel.Error); + } } // print result diff --git a/src/StardewModdingAPI/StardewModdingAPI.csproj b/src/StardewModdingAPI/StardewModdingAPI.csproj index fda3ab3d..0f449ce4 100644 --- a/src/StardewModdingAPI/StardewModdingAPI.csproj +++ b/src/StardewModdingAPI/StardewModdingAPI.csproj @@ -79,67 +79,24 @@ <PropertyGroup> <ApplicationIcon>icon.ico</ApplicationIcon> </PropertyGroup> - <PropertyGroup> - <!-- Linux paths --> - <GamePath Condition="!Exists('$(GamePath)')">$(HOME)/GOG Games/Stardew Valley/game</GamePath> - <GamePath Condition="!Exists('$(GamePath)')">$(HOME)/.local/share/Steam/steamapps/common/Stardew Valley</GamePath> - <!-- Mac paths --> - <GamePath Condition="!Exists('$(GamePath)')">$(HOME)/Library/Application Support/Steam/steamapps/common/Stardew Valley/Contents/MacOS</GamePath> - <!-- Windows paths --> - <GamePath Condition="!Exists('$(GamePath)')">C:\Program Files (x86)\GalaxyClient\Games\Stardew Valley</GamePath> - <GamePath Condition="!Exists('$(GamePath)')">C:\Program Files (x86)\Steam\steamapps\common\Stardew Valley</GamePath> - </PropertyGroup> - <Choose> - <When Condition="$(OS) == 'Windows_NT'"> - <PropertyGroup> - <DefineConstants>$(DefineConstants);SMAPI_FOR_WINDOWS</DefineConstants> - </PropertyGroup> - <ItemGroup> - <Reference Include="Microsoft.Xna.Framework, Version=4.0.0.0, Culture=neutral, PublicKeyToken=842cf8be1de50553, processorArchitecture=x86"> - <Private>False</Private> - </Reference> - <Reference Include="Microsoft.Xna.Framework.Game, Version=4.0.0.0, Culture=neutral, PublicKeyToken=842cf8be1de50553, processorArchitecture=x86"> - <Private>False</Private> - </Reference> - <Reference Include="Microsoft.Xna.Framework.Graphics, Version=4.0.0.0, Culture=neutral, PublicKeyToken=842cf8be1de50553, processorArchitecture=x86"> - <Private>False</Private> - </Reference> - <Reference Include="Microsoft.Xna.Framework.Xact, Version=4.0.0.0, Culture=neutral, PublicKeyToken=842cf8be1de50553, processorArchitecture=x86"> - <Private>False</Private> - </Reference> - <Reference Include="Stardew Valley"> - <HintPath>$(GamePath)\Stardew Valley.exe</HintPath> - <Private>False</Private> - </Reference> - <Reference Include="xTile, Version=2.0.4.0, Culture=neutral, processorArchitecture=x86"> - <HintPath>$(GamePath)\xTile.dll</HintPath> - <Private>False</Private> - <SpecificVersion>False</SpecificVersion> - </Reference> - </ItemGroup> - </When> - <Otherwise> - <PropertyGroup> - <DefineConstants>$(DefineConstants);SMAPI_FOR_UNIX</DefineConstants> - </PropertyGroup> - <ItemGroup> - <Reference Include="MonoGame.Framework"> - <HintPath>$(GamePath)\MonoGame.Framework.dll</HintPath> - <Private>False</Private> - <SpecificVersion>False</SpecificVersion> - </Reference> - <Reference Include="StardewValley"> - <HintPath>$(GamePath)\StardewValley.exe</HintPath> - <Private>False</Private> - </Reference> - <Reference Include="xTile"> - <HintPath>$(GamePath)\xTile.dll</HintPath> - <Private>False</Private> - </Reference> - </ItemGroup> - </Otherwise> - </Choose> + <Import Project="$(SolutionDir)\dependencies.targets" /> <ItemGroup> + <Reference Include="Mono.Cecil, Version=0.9.6.0, Culture=neutral, PublicKeyToken=0738eb9f132ed756, processorArchitecture=MSIL"> + <HintPath>..\packages\Mono.Cecil.0.9.6.4\lib\net45\Mono.Cecil.dll</HintPath> + <Private>True</Private> + </Reference> + <Reference Include="Mono.Cecil.Mdb, Version=0.9.6.0, Culture=neutral, PublicKeyToken=0738eb9f132ed756, processorArchitecture=MSIL"> + <HintPath>..\packages\Mono.Cecil.0.9.6.4\lib\net45\Mono.Cecil.Mdb.dll</HintPath> + <Private>True</Private> + </Reference> + <Reference Include="Mono.Cecil.Pdb, Version=0.9.6.0, Culture=neutral, PublicKeyToken=0738eb9f132ed756, processorArchitecture=MSIL"> + <HintPath>..\packages\Mono.Cecil.0.9.6.4\lib\net45\Mono.Cecil.Pdb.dll</HintPath> + <Private>True</Private> + </Reference> + <Reference Include="Mono.Cecil.Rocks, Version=0.9.6.0, Culture=neutral, PublicKeyToken=0738eb9f132ed756, processorArchitecture=MSIL"> + <HintPath>..\packages\Mono.Cecil.0.9.6.4\lib\net45\Mono.Cecil.Rocks.dll</HintPath> + <Private>True</Private> + </Reference> <Reference Include="Newtonsoft.Json, Version=8.0.0.0, Culture=neutral, PublicKeyToken=30ad4fe6b2a6aeed, processorArchitecture=MSIL"> <HintPath>..\packages\Newtonsoft.Json.8.0.3\lib\net45\Newtonsoft.Json.dll</HintPath> <Private>True</Private> @@ -197,9 +154,12 @@ <Compile Include="Events\PlayerEvents.cs" /> <Compile Include="Events\TimeEvents.cs" /> <Compile Include="Extensions.cs" /> + <Compile Include="Framework\AssemblyRewriting\CachePaths.cs" /> + <Compile Include="Framework\AssemblyRewriting\AssemblyTypeRewriter.cs" /> <Compile Include="Framework\DeprecationLevel.cs" /> <Compile Include="Framework\DeprecationManager.cs" /> <Compile Include="Framework\InternalExtensions.cs" /> + <Compile Include="Framework\ModAssemblyLoader.cs" /> <Compile Include="IModHelper.cs" /> <Compile Include="Framework\LogFileManager.cs" /> <Compile Include="LogLevel.cs" /> @@ -256,6 +216,12 @@ <Install>false</Install> </BootstrapperPackage> </ItemGroup> + <ItemGroup> + <ProjectReference Include="..\StardewModdingAPI.AssemblyRewriters\StardewModdingAPI.AssemblyRewriters.csproj"> + <Project>{10db0676-9fc1-4771-a2c8-e2519f091e49}</Project> + <Name>StardewModdingAPI.AssemblyRewriters</Name> + </ProjectReference> + </ItemGroup> <Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" /> <PropertyGroup> <PostBuildEvent> @@ -282,8 +248,10 @@ <Target Name="AfterBuild" Condition="$(Configuration) == 'Debug'"> <Copy SourceFiles="$(TargetDir)\$(TargetName).exe" DestinationFolder="$(GamePath)" /> <Copy SourceFiles="$(TargetDir)\StardewModdingAPI.AssemblyRewriters.dll" DestinationFolder="$(GamePath)" /> - <Copy SourceFiles="$(TargetDir)\$(TargetName).pdb" DestinationFolder="$(GamePath)" Condition="$(OS) == 'Windows_NT'" /> - <Copy SourceFiles="$(TargetDir)\$(TargetName).xml" DestinationFolder="$(GamePath)" Condition="$(OS) == 'Windows_NT'" /> + <Copy SourceFiles="$(TargetDir)\$(TargetName).pdb" DestinationFolder="$(GamePath)" /> + <Copy SourceFiles="$(TargetDir)\$(TargetName).xml" DestinationFolder="$(GamePath)" /> <Copy SourceFiles="$(TargetDir)\Newtonsoft.Json.dll" DestinationFolder="$(GamePath)" /> + <Copy SourceFiles="$(TargetDir)\Mono.Cecil.dll" DestinationFolder="$(GamePath)" /> + <Copy SourceFiles="$(TargetDir)\Mono.Cecil.Rocks.dll" DestinationFolder="$(GamePath)" /> </Target> -</Project>
\ No newline at end of file +</Project> diff --git a/src/StardewModdingAPI/packages.config b/src/StardewModdingAPI/packages.config index 0aec11c6..dc07902b 100644 --- a/src/StardewModdingAPI/packages.config +++ b/src/StardewModdingAPI/packages.config @@ -2,5 +2,6 @@ <packages> <package id="Costura.Fody" version="1.3.3.0" targetFramework="net461" developmentDependency="true" /> <package id="Fody" version="1.29.4" targetFramework="net461" developmentDependency="true" /> + <package id="Mono.Cecil" version="0.9.6.4" targetFramework="net45" /> <package id="Newtonsoft.Json" version="8.0.3" targetFramework="net461" /> </packages>
\ No newline at end of file diff --git a/src/dependencies.targets b/src/dependencies.targets new file mode 100644 index 00000000..d5428967 --- /dev/null +++ b/src/dependencies.targets @@ -0,0 +1,62 @@ +<Project xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> + <PropertyGroup> + <!-- Linux paths --> + <GamePath Condition="!Exists('$(GamePath)')">$(HOME)/GOG Games/Stardew Valley/game</GamePath> + <GamePath Condition="!Exists('$(GamePath)')">$(HOME)/.local/share/Steam/steamapps/common/Stardew Valley</GamePath> + <!-- Mac paths --> + <GamePath Condition="!Exists('$(GamePath)')">$(HOME)/Library/Application Support/Steam/steamapps/common/Stardew Valley/Contents/MacOS</GamePath> + <!-- Windows paths --> + <GamePath Condition="!Exists('$(GamePath)')">C:\Program Files (x86)\GalaxyClient\Games\Stardew Valley</GamePath> + <GamePath Condition="!Exists('$(GamePath)')">C:\Program Files (x86)\Steam\steamapps\common\Stardew Valley</GamePath> + </PropertyGroup> + <Choose> + <When Condition="$(OS) == 'Windows_NT'"> + <PropertyGroup> + <DefineConstants>$(DefineConstants);SMAPI_FOR_WINDOWS</DefineConstants> + </PropertyGroup> + <ItemGroup> + <Reference Include="Microsoft.Xna.Framework, Version=4.0.0.0, Culture=neutral, PublicKeyToken=842cf8be1de50553, processorArchitecture=x86"> + <Private>False</Private> + </Reference> + <Reference Include="Microsoft.Xna.Framework.Game, Version=4.0.0.0, Culture=neutral, PublicKeyToken=842cf8be1de50553, processorArchitecture=x86"> + <Private>False</Private> + </Reference> + <Reference Include="Microsoft.Xna.Framework.Graphics, Version=4.0.0.0, Culture=neutral, PublicKeyToken=842cf8be1de50553, processorArchitecture=x86"> + <Private>False</Private> + </Reference> + <Reference Include="Microsoft.Xna.Framework.Xact, Version=4.0.0.0, Culture=neutral, PublicKeyToken=842cf8be1de50553, processorArchitecture=x86"> + <Private>False</Private> + </Reference> + <Reference Include="Stardew Valley"> + <HintPath>$(GamePath)\Stardew Valley.exe</HintPath> + <Private>False</Private> + </Reference> + <Reference Include="xTile, Version=2.0.4.0, Culture=neutral, processorArchitecture=x86"> + <HintPath>$(GamePath)\xTile.dll</HintPath> + <Private>False</Private> + <SpecificVersion>False</SpecificVersion> + </Reference> + </ItemGroup> + </When> + <Otherwise> + <PropertyGroup> + <DefineConstants>$(DefineConstants);SMAPI_FOR_UNIX</DefineConstants> + </PropertyGroup> + <ItemGroup> + <Reference Include="MonoGame.Framework"> + <HintPath>$(GamePath)\MonoGame.Framework.dll</HintPath> + <Private>False</Private> + <SpecificVersion>False</SpecificVersion> + </Reference> + <Reference Include="StardewValley"> + <HintPath>$(GamePath)\StardewValley.exe</HintPath> + <Private>False</Private> + </Reference> + <Reference Include="xTile"> + <HintPath>$(GamePath)\xTile.dll</HintPath> + <Private>False</Private> + </Reference> + </ItemGroup> + </Otherwise> + </Choose> +</Project>
\ No newline at end of file |