tModLoader [Guide] Better system for building mods with Visual Studio / msbuild

DRKV

Wall of Flesh
So, if you set up new projects often for building TModLoader mods with Visual Studio, you are probably familiar with this 12 step check list: blushiemagic/tModLoader
Now, I might be in the minority here, but I actually do this quite a lot, to test things, to play around with silly ideas, stuff like that. Because of this, I decided to make this process simpler and I ended up falling into a rather massive rabbit hole...
I came up with a new system that not only simplifies the setup process, but makes builds more efficient too. All of this information is scattered all over the place on MSDN and Stack Overflow, so I think it would worth a more thorough explanation, but I'll try to be as brief as possible...
I hope one day this information may be useful to somebody.

Visual studio uses a thing called msbuild to put together it's projects. (It's kinda like make, but Microsoft...) Msbuild uses XML files for project configuration. This is what an empty C# .net Framework class library poject created by VS2017 looks like:
Code:
<?xml version="1.0" encoding="utf-8"?>
<Project ToolsVersion="15.0" 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>3dd2d4fe-b692-4f65-bbba-dcef7280786f</ProjectGuid>
    <OutputType>Library</OutputType>
    <AppDesignerFolder>Properties</AppDesignerFolder>
    <RootNamespace>Empty</RootNamespace>
    <AssemblyName>Empty</AssemblyName>
    <TargetFrameworkVersion>v4.5</TargetFrameworkVersion>
    <FileAlignment>512</FileAlignment>
    <Deterministic>true</Deterministic>
  </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>
  <ItemGroup>
    <Reference Include="System"/>
 
    <Reference Include="System.Core"/>
    <Reference Include="System.Xml.Linq"/>
    <Reference Include="System.Data.DataSetExtensions"/>
 
 
    <Reference Include="Microsoft.CSharp"/>
 
    <Reference Include="System.Data"/>
 
    <Reference Include="System.Net.Http"/>
 
    <Reference Include="System.Xml"/>
  </ItemGroup>
  <ItemGroup>
    <Compile Include="Class1.cs" />
    <Compile Include="Properties\AssemblyInfo.cs" />
  </ItemGroup>
  <Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" />
 </Project>

The normal way to set up a project

When you normally set up a project for TModLoader, you need to deal with three main things:
  • Add references to XNA and terraria
  • Somehow convince msbuild to run TModLoader with the "-build" flag to build your .tmod file
  • Change the start action and working directory of your project for debugging
The debug settings are not actually part of the project, VS stores them in a ".csproj.user" file. Because of this, I don't think there really is a better way to deal with them, other then setting them up manually.

You normally add your references to the project manually from VS. This places the new entries into the "ItemGroup" with the existing ones. One slight issue here, is that you'll have to include the full path to your terraria installation in the project. This is not too great, if you plan to upload your mod to a source control system (like GitHub).

To run TModLoader's build code you normally call it from a "PostBuildEvent". This is basically just a batch script that gets run after a successful build. Here is what is looks like in a project:
Code:
<PropertyGroup>
    <PostBuildEvent>"C:\Program Files (x86)\Steam\steamapps\common\Terraria\tModLoaderServer.exe" -build "$(ProjectDir)\"</PostBuildEvent>
</PropertyGroup>
Other than the fact that this again needs the full path to terraria.exe, there are several problems with this, I'll get to those later.

Setup using .targets file

If you look at the bottom of that project file, you'll see the line "<Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" />". This basically makes msbuild "copy" this "Microsoft.CSharp.targets" file into the project file as it's interpreting it. These target files contain the necessary options to get msbuild to execute a certain build action. The C# compiler in this case. (These build actions are called "targets".) In order to simplify working with TModLoader, we can do the same thing.

To make our targets file work universally, we need to figure out where terraria is. We can do that by looking at the registry. We than add the reference the same way we would in a project. To run TModLoader we can set up a custom build target, that will run after the CoreBuild target. All of that looks kinda like this:
Code:
<?xml version="1.0" encoding="utf-8"?>

<!-- ===========================================================================================================================
       Targets file for compiling TModLoader Mods.
       For more information visit:
       https://forums.terraria.org/index.php?threads/guide-better-system-for-building-mods-with-visual-studio-msbuild.80009/
    =========================================================================================================================== -->

<Project xmlns="http://schemas.microsoft.com/developer/msbuild/2003">

   <PropertyGroup>
       <!-- Get Terraria install directory from registry for reference and post-build event -->
       <tmodTerrariaPath>$(Registry:HKEY_LOCAL_MACHINE\SOFTWARE\WOW6432Node\re-logic\terraria@install_path)</tmodTerrariaPath>
   </PropertyGroup>

   <ItemGroup>
       <!-- XNA Framework dlls, should be in the GAC... -->
       <Reference Include="Microsoft.Xna.Framework, Version=4.0.0.0, Culture=neutral, PublicKeyToken=842cf8be1de50553, processorArchitecture=x86" />
       <Reference Include="Microsoft.Xna.Framework.Game, Version=4.0.0.0, Culture=neutral, PublicKeyToken=842cf8be1de50553, processorArchitecture=x86" />
       <Reference Include="Microsoft.Xna.Framework.Graphics, Version=4.0.0.0, Culture=neutral, PublicKeyToken=842cf8be1de50553, processorArchitecture=x86" />
       <Reference Include="Microsoft.Xna.Framework.Xact, Version=4.0.0.0, Culture=neutral, PublicKeyToken=842cf8be1de50553, processorArchitecture=x86" />
   
       <!-- Terraria.exe from install directory -->
       <Reference Include="Terraria">
           <HintPath>$(tmodTerrariaPath)\Terraria.exe</HintPath>
       </Reference>
   </ItemGroup>
 
   <!-- Setup build target dependency -->
   <PropertyGroup>
       <BuildDependsOn>
           $(BuildDependsOn);
           tmodBuildModFile
       </BuildDependsOn>
   </PropertyGroup>
 
   <!-- Create .tmod file -->
   <Target Name="tmodBuildModFile">
       <Exec Command="&quot;$(tmodTerrariaPath)\tModLoaderServer.exe&quot; -build &quot;$(ProjectDir.TrimEnd('\'))&quot; -eac &quot;$(TargetPath.TrimEnd('\'))&quot;" />
   </Target>
 
</Project>
(For the sake of brevity I won't go into how each of these things work, but feel free to ask if you are interested.)

With this set up, all you need to do to set up a new project, is copy this file into your project directory and edit the end of you project file to look like this:
Code:
    .
    .
    <Compile Include="Properties\AssemblyInfo.cs" />
  </ItemGroup>
  <Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" />
  <Import Project="$(ProjectDir)tmodloader.targets" />
</Project>

That's it, once you reload the project in VS, the new references should be there, and the new build event should run.

Incremental builds

One problem you may have come across while using VS this way, is that if you change a file in your project that VS doesn't know about (for instance a sprite) the build will not run. VS thinks the project is up to date. VS actually tries to be really clever, and if it sees that no file has been modified since the last build it won't even run msbuild. This is when you get this message:
upload_2019-5-29_0-30-58.png
This is still an issue with the targets file approach as well. To fix it, we need to include this in our targets file:
Code:
<PropertyGroup>
       <DisableFastUpToDateCheck>true</DisableFastUpToDateCheck>
</PropertyGroup>
Yeah, pretty straight forward. However, the slight side effect is that the mod file build will ALWAYS run. Even if you really didn't change anything in your project.

Msbuild actually has ways to deal with this. After all, msbuild itself also knows when a C# source file needs to be recompiled, why couldn't it do the same with other files? To configure incremental building, we need to tell our build target it's input and output files. Msbuild will then compare the last modification time of the output files with the inputs, and only run the target if the inputs are newer. The output is easy, we know it will be at "$(userprofile)/Documents/My Games/Terraria/ModLoader/Mods/$(AssemblyName).tmod". The inputs are trickier. Msbuild doesn't really know what they are. It's basically every file in the project directory, except the ones you specified with "buildIgnores" in your "build.txt". To find the input files I wrote a little bit of C# code with some regex magic, and put it into the targets file as an "inline task". We can then use a separate target, which the original target depends on, to run this task. The task's output is then used as the "Inputs" parameter of the actual build target. The targets file looks like this now:
Code:
<?xml version="1.0" encoding="utf-8"?>

<!-- ===========================================================================================================================
       Targets file for compiling TModLoader Mods. Now with incremental build support!
       For more information visit:
       https://forums.terraria.org/index.php?threads/guide-better-system-for-building-mods-with-visual-studio-msbuild.80009/
    =========================================================================================================================== -->

<Project xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
 
   <PropertyGroup>
       <!-- We need to disable Visual Studio's up-to-date check, because we are including files in our build that VS doesn't know about -->
       <DisableFastUpToDateCheck>true</DisableFastUpToDateCheck>
 
       <!-- Get Terraria install directory from registry for reference and post-build event -->
       <tmodTerrariaPath>$(Registry:HKEY_LOCAL_MACHINE\SOFTWARE\WOW6432Node\re-logic\terraria@install_path)</tmodTerrariaPath>
   
       <!-- build.txt to parse ignores from -->
       <tmodBuildTxtPath Condition="'$(tmodBuildTxtPath)'==''">$(ProjectDir)build.txt</tmodBuildTxtPath>
   </PropertyGroup>

   <ItemGroup>
       <!-- XNA Framework dlls, should be in the GAC... -->
       <Reference Include="Microsoft.Xna.Framework, Version=4.0.0.0, Culture=neutral, PublicKeyToken=842cf8be1de50553, processorArchitecture=x86" />
       <Reference Include="Microsoft.Xna.Framework.Game, Version=4.0.0.0, Culture=neutral, PublicKeyToken=842cf8be1de50553, processorArchitecture=x86" />
       <Reference Include="Microsoft.Xna.Framework.Graphics, Version=4.0.0.0, Culture=neutral, PublicKeyToken=842cf8be1de50553, processorArchitecture=x86" />
       <Reference Include="Microsoft.Xna.Framework.Xact, Version=4.0.0.0, Culture=neutral, PublicKeyToken=842cf8be1de50553, processorArchitecture=x86" />
   
       <!-- Terraria.exe from install directory -->
       <Reference Include="Terraria">
           <HintPath>$(tmodTerrariaPath)\Terraria.exe</HintPath>
       </Reference>
   </ItemGroup>
 
   <!-- Task for listing all files that will be included in the mod file build -->
   <UsingTask TaskName="tmodParseBuildFilesTask" TaskFactory="CodeTaskFactory" AssemblyFile="$(MSBuildToolsPath)\Microsoft.Build.Tasks.Core.dll">
       <ParameterGroup>
           <ProjectPath ParameterType="System.String" Required="true" />
           <BuildTxtPath ParameterType="System.String" Required="true" />
           <Files ParameterType="Microsoft.Build.Framework.ITaskItem[]" Output="true" />
       </ParameterGroup>
       <Task>
           <Reference Include="System.Xml" />
           <Using Namespace="System" />
           <Using Namespace="System.IO" />
           <Using Namespace="System.Text.RegularExpressions" />
           <Using Namespace="Microsoft.Build.Framework" />
           <Code Type="Fragment" Language="cs"><![CDATA[
Regex ignores = null;
if (File.Exists(BuildTxtPath))
{
    string line = File.ReadAllLines(BuildTxtPath).FirstOrDefault(x => x.StartsWith("buildIgnore"));
    if (line != null)
       ignores = new Regex("^(" + Regex.Escape(line.Replace("buildIgnore", "").Replace("=", "").Replace(" ", "")).Replace("\\?", ".").Replace("\\*", ".*").Replace(",", "|") + ")$");
}
IEnumerable<string> filePaths = Directory.GetFiles(ProjectPath, "*.*", SearchOption.AllDirectories);
if (ignores != null)
   filePaths = filePaths.Where(x => !ignores.IsMatch(x.Replace(ProjectPath, "").Replace("\\", "/")));
Files = filePaths.Select(x => new TaskItem(x)).ToArray();
           ]]></Code>
       </Task>
    </UsingTask>
 
   <!-- Setup build target dependency -->
   <PropertyGroup>
       <BuildDependsOn>
           $(BuildDependsOn);
           tmodBuildModFile
       </BuildDependsOn>
   </PropertyGroup>
 
   <!-- List all files in build -->
   <Target Name="tmodParseBuildFiles">
       <tmodParseBuildFilesTask ProjectPath="$(ProjectDir)" BuildTxtPath="$(tmodBuildTxtPath)">
           <Output TaskParameter="Files" PropertyName="tmodBuildFiles" />
       </tmodParseBuildFilesTask>
   </Target>
 
   <!-- Create .tmod file -->
   <Target Name="tmodBuildModFile" DependsOnTargets="tmodParseBuildFiles" Inputs="$(tmodBuildFiles)" Outputs="$(userprofile)/Documents/My Games/Terraria/ModLoader/Mods/$(AssemblyName).tmod">
       <Exec Command="&quot;$(tmodTerrariaPath)\tModLoaderServer.exe&quot; -build &quot;$(ProjectDir.TrimEnd('\'))&quot; -eac &quot;$(TargetPath.TrimEnd('\'))&quot;" />
   </Target>
 
</Project>

Here is a build, that is actually up to date:
upload_2019-5-29_1-14-32.png

(Obviusly slightly edited...)
(Don't mind the nonsensical file path, my computer is a mess...)

And that's all the issues I could think of! I'll update this post if something else somes up. I've also incuded the finished targets file as an attachment. Feel free to use in your own mods. :)

Build logging tips

If you are interested in divining the inner workings of msbuild, here are a few pointers.
You can change the verbosity of the msbuild logs produced by Visual Studio in Tools > Options... > Project and Solutions > Build and Run. I'd suggest "detailed" for every-day use.
Text logs are hard to read though. Msbuild can make these things called binary logs. You can view them with the MSBuild Binary and Structured Log Viewer (MSBuild Log Viewer).
upload_2019-5-29_1-31-54.png
To create a binary log:
  • Start up the "Visual Studio 20xx Developer Command Prompt"
  • Navigate to your project directory
  • run this command: " msbuild -m:4 -v:diag -bl:../LOGNAMEHERE.binlog "
  • (Run on 4 threads, show text log with diagnostic versosity, put text log in parent folder and name it LOGNAMEHERE.binlog)
  • (Don't put the binary log in the project folder, becasue msbuild will try to build it, and complain that it can't get a read lock, becasue SOMEBODY is writing to it...:p)
 

Attachments

  • tmodloader.targets.zip
    1.8 KB · Views: 140
Here is a little update on this.

In the new 0.11 version of tModLoader there have been a few changes to how builds work. The game will now generate a .targets file, located at "Documents/My Games/Terraria/ModLoader/References", that contains references to all of the different dlls that tModLoader uses. I've updated tmodloader.targes to find this file and import it.
The command used to run .tmod file build now takes a few more parameters, so I've updated that as well.
The path to Documents is now fetched from the registry, in case someone moved it from %userprofile%.
Allegedly, tModLoader now also supports builds on Linux, so I've also added the Linux and OSX terraria save paths to the file. I haven't tested them yet, as I don't have a Linux or OSX setup currently, but I'm pretty sure they'll work fine.

Here is the new setup:
Code:
<?xml version="1.0" encoding="utf-8"?>

<!-- ===========================================================================================================================
       Targets file for compiling tTModLoader Mods. Now with incremental build support!
       Updated for version 0.11
       For more information visit:
       https://forums.terraria.org/index.php?threads/guide-better-system-for-building-mods-with-visual-studio-msbuild.80009/
     =========================================================================================================================== -->

<Project xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
 
   <PropertyGroup>
       <!-- We need to disable Visual Studio's up-to-date check, because we are including files in our build that VS doesn't know about -->
       <DisableFastUpToDateCheck>true</DisableFastUpToDateCheck>
  
       <!-- Default tModLoader save paths, for output folder path and finding reference .targets -->
       <tmodSavePath Condition="'$(tmodSavePath)'=='' AND $([MSBuild]::IsOsPlatform('Windows'))">$(registry:HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Explorer\User Shell Folders@Personal)\My Games\Terraria\ModLoader</tmodSavePath>
       <tmodSavePath Condition="'$(tmodSavePath)'=='' AND $([MSBuild]::IsOsPlatform('Linux')) AND '$(XDG_DATA_HOME)'!=''">$(XDG_DATA_HOME)/.local/share/Terraria/ModLoader</tmodSavePath>
       <tmodSavePath Condition="'$(tmodSavePath)'=='' AND $([MSBuild]::IsOsPlatform('Linux'))">$(HOME)/.local/share/Terraria/ModLoader</tmodSavePath>
       <tmodSavePath Condition="'$(tmodSavePath)'=='' AND $([MSBuild]::IsOsPlatform('OSX'))">$(HOME)/Library/Application Support/Terraria/ModLoader</tmodSavePath>
  
       <!-- build.txt to parse ignores from -->
       <tmodBuildTxtPath Condition="'$(tmodBuildTxtPath)'==''">$(ProjectDir)build.txt</tmodBuildTxtPath>
   </PropertyGroup>

   <!-- .targes file generated by tModLoader 0.11 and above. Contains all of the useful references. -->
   <Import Project="$(tmodSavePath)/References/tModLoader.targets" />
 
   <!-- Task for listing all files that will be included in the mod file build -->
   <UsingTask TaskName="tmodParseBuildFilesTask" TaskFactory="CodeTaskFactory" AssemblyFile="$(MSBuildToolsPath)/Microsoft.Build.Tasks.Core.dll">
       <ParameterGroup>
           <ProjectPath ParameterType="System.String" Required="true" />
           <BuildTxtPath ParameterType="System.String" Required="true" />
           <Files ParameterType="Microsoft.Build.Framework.ITaskItem[]" Output="true" />
       </ParameterGroup>
       <Task>
           <Reference Include="System.Xml" />
           <Using Namespace="System" />
           <Using Namespace="System.IO" />
           <Using Namespace="System.Text.RegularExpressions" />
           <Using Namespace="Microsoft.Build.Framework" />
           <Code Type="Fragment" Language="cs"><![CDATA[
Regex ignores = null;
if (File.Exists(BuildTxtPath))
{
       string line = File.ReadAllLines(BuildTxtPath).FirstOrDefault(x => x.StartsWith("buildIgnore"));
       if (line != null)
       ignores = new Regex("^(" + Regex.Escape(line.Replace("buildIgnore", "").Replace("=", "").Replace(" ", "")).Replace("\\?", ".").Replace("\\*", ".*").Replace(",", "|") + ")$");
}
IEnumerable<string> filePaths = Directory.GetFiles(ProjectPath, "*.*", SearchOption.AllDirectories);
if (ignores != null)
   filePaths = filePaths.Where(x => !ignores.IsMatch(x.Replace(ProjectPath, "").Replace("\\", "/")));
Files = filePaths.Select(x => new TaskItem(x)).ToArray();
           ]]></Code>
       </Task>
   </UsingTask>
 
   <!-- Setup build target dependency -->
   <PropertyGroup>
       <BuildDependsOn>
           $(BuildDependsOn);
           tmodBuildModFile
       </BuildDependsOn>
   </PropertyGroup>
 
   <!-- List all files in build -->
   <Target Name="tmodParseBuildFiles">
       <tmodParseBuildFilesTask ProjectPath="$(ProjectDir)" BuildTxtPath="$(tmodBuildTxtPath)">
           <Output TaskParameter="Files" PropertyName="tmodBuildFiles" />
       </tmodParseBuildFilesTask>
   </Target>
 
   <!-- Create .tmod file -->
   <Target Name="tmodBuildModFile" DependsOnTargets="tmodParseBuildFiles" Inputs="$(tmodBuildFiles)" Outputs="$(tmodSavePath)/Mods/$(AssemblyName).tmod">
       <!-- If the Import fails msbuild should give an error, but VS can be a weird sometimes, it's best to do a quick check -->
       <Error Condition="$(tMLBuildServerPath)==''" Text="tMLBuildServerPath not set! (Try restarting your IDE)" />
       <Exec Command="&quot;$(tMLBuildServerPath)&quot; -build $(ProjectDir) -eac $(TargetPath) -define $(DefineConstants) -unsafe $(AllowUnsafeBlocks)" />
   </Target>
 
</Project>

I'll leave keep the old version available in the first post, because the new one does not work with tModLoader 0.10, which according to the forum page is still the latest. I don't know what's up with that...
 

Attachments

  • tmodloader11.targets.zip
    1.9 KB · Views: 112
Last edited:
Back
Top Bottom