[TOOL] MSBuild - Let's talk about project files!

Started by krafs, November 30, 2019, 09:32:01 AM

Previous topic - Next topic

krafs

Let's talk about MSBuild, .csproj and how we can make our mod projects better.
If you're a modder using C# you have probably come across the .csproj-file in your mod project. It can look something like this:



So, what is it?
It's a build script - an instruction for how the mod should be built. Build scripts can be made in all kinds of formats, but this particular build script is in the format of .csproj, used by a build tool called MSBuild. Whenever you click Build in your project, Visual Studio runs MSBuild with your .csproj-file. This compiles all your .cs-files into an assembly file and puts it in the output directory. A .csproj-file can also be used to do a thousand other things, but that's a topic for another time.

I remember myself being intimidated by the .csproj, and accidentally breaking things whenever I tried being clever and change something in it. This is a shame, because MSBuild is a really powerful tool, and understanding and using it can make some parts of development easier, and more fun.

This post
In this post, I'd like to go through the structure of a typical .csproj and talk about what the different things are for. I am also going to throw out the default .csproj that comes with a new project, and build a new one from the ground up - specifically for the purpose of a simple, Rimworld mod. You can do the same to an existing mod, it does not have to be a new project.
MSBuild was confusing to me before I really got into it, so I'll try to keep this post very basic.

Why should I care?
You don't have to. MSBuild, and the project file, is an advanced concept. Your mod will be just fine without learning this, or changing anything I'm covering in this post.
However, if you're interested in development and how these things work, feel free to read on. The goal is to understand how a project is set up, and make you comfortable reading and customizing it yourself. Making mods can be only about making a new gun, or a new trait. But it can also be about the community, marketing your mod, making art or sound effects - or making a good build script. None of these things are necessary, but we do them because we enjoy it.

Still with me? Ok :)

The .cspoj Structure
Simply speaking there are four different kinds of main components to a .csproj-file: Target, PropertyGroup, ItemGroup and Task. We'll not go into Tasks in this post.

- Target
Targets are actions. In this build script, there are a bunch of invisible targets that execute at different times. For example, there's Clean, Rebuild, Restore and Build. Build is an important one. It's the default Target executed by MSBuild during a build, but you can make your own, custom targets to do all kinds of things. As mentioned, Build takes all .cs-files, and compiles them to an assembly file.

- ItemGroup
This leads us onto ItemGroups. ItemGroups list all files that are needed for a Target. The Build Target needs to know which .cs-files to compile. Whenever you see something like the following:

<ItemGroup>
    <Compile Include="Source\Controller.cs" />
</ItemGroup>


it's a file used during compilation. Remove this entry from your .csproj and the file will not be compiled. Visual Studio tries its best to help with this, and will add and remove ItemGroup entries depending on the files you add or remove from the project folder. This is one of the main sources of clutter in your .csproj.

- PropertyGroup
Then we have PropertyGroups. These are for setting values and variables that are used throughout the build. For example, this tells the compiler what the name of the assembly file should be:

<PropertyGroup>
    <AssemblyName>LevelUp</AssemblyName>
</PropertyGroup>


AssemblyName is a preset property used by the compiler to set the output filename of the assembly. You can make your own properties, but let's talk about that another time.

- Condition
One last thing to mention about the .csproj structure is Condition. Both ItemGroup and PropertyGroup can have an attribute called Condition, which tells MSBuild under what conditions a particular group of properties or items should be used. Having no conditions means that the group will always be used. Here's an example of how Condition is commonly used:

<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|AnyCPU' ">
    <DebugType>full</DebugType>
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Release|AnyCPU' ">
    <DebugType>none</DebugType>
</PropertyGroup>


As you can see, there are two PropertyGroups with Conditions. These are in the same .csproj-file. The Condition attribute uses two properties that are set by the system beforehand: Configuration and Platform. Configuration depends on the kind of Build you perform: Debug or Release (or any other configuration you yourself have created). Platform is the kind of CPU the assembly should be created to run against. Note that Platform never has to be considered in Rimworld modding.

Anyway, the above shows how you can tell MSBuild to build your mod differently depending on certain conditions. This example has DebugType = 'full' if built with Debug, and 'none' if building Release. This will result in .pdb-files being created if you're debugging, but not if you're building a release version of your mod. Since we generally don't do proper debugging in Rimworld modding anyway, this could have been set to 'none' at all times.

OK, that's it for the basic components of a .csproj. Let's rebuild this .csproj from the ground up, starting at the top.

SDK-style
The top of a default .csproj, usually looks something like this:
<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')" />


It specifies what type and version of MSBuild to use for interpreting this particular .csproj, including a check to see whether all necessary files are in place. Whenever you see this particular structure you know that the project is built in what has come to be called legacy-style or traditional style. However, we are down with the times, so we are going to throw out the old legacy and replace it with the latest and greatest - SDK-style.
The main benefit of SDK-style projects is that it is a lot less verbose. The project file takes care of a lot of stuff behind the scenes, leaving us to only configure the things we actually care about. This results in a much cleaner .csproj. Not only does this make it a lot less intimidating and easier to understand, but by only including things that matter, we are much more likely to use it.

So, let's do this. Replace everything in your .csproj with this:

<?xml version="1.0" encoding="utf-8"?>
<Project Sdk="Microsoft.NET.Sdk">
    <PropertyGroup>
        <TargetFramework>net35</TargetFramework>
    </PropertyGroup>
</Project>


Congratulations! That's it. This is the bare mininum of a project that uses the SDK-style format. We tell it what framework to target, and it handles the rest. All the 92 lines of xml in the original .csproj have been replaced with 6. It cleverly looks at every file in the project directory and decides whether or not compile it. And all properties we had to specify in the legacy-style now receive default values set by the project itself. We can of course still set them explicitly, overriding their default values, but we no longer have to.

- Removing AssemblyInfo.cs
Using the SDK-style comes with an added benefit - you no longer need the Properties -> AssemblyInfo.cs.

If you'd like, you can delete the entire Properties entry, and instead specify those same properties directly in the .csproj instead. Let's do that:

<PropertyGroup>
    <TargetFramework>net35</TargetFramework>
    <AssemblyTitle>Level Up</AssemblyTitle>
    <Company>krafs</Company>
    <AssemblyVersion>1.0.9.3</AssemblyVersion>
    <FileVersion>1.0.9.3</FileVersion>
</PropertyGroup>


I personally don't see a point in specifying all these different values for a simple Rimworld mod. AssemblyVersion should be enough. But this shows you how you can replace AssemblyInfo.cs by setting the same values here, in the .csproj. However, you cannot specify the same property in both the .csproj and the AssemblyInf.cs. That will cause a compile error. Choose one.

Including AssemblyVersion, we now have this:

<?xml version="1.0" encoding="utf-8"?>
<Project Sdk="Microsoft.NET.Sdk">
    <PropertyGroup>
        <TargetFramework>net35</TargetFramework>
        <AssemblyVersion>1.0.9.3</AssemblyVersion>
    </PropertyGroup>
</Project>


- Output path
Cool! Next up, let's put the compiled assembly file someplace better. We add the following properties:

<OutputPath>..\Assemblies</OutputPath>
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>


This will put the compiled assembly one level above the project directory, in a folder called Assemblies. Many modders do this because it means they can have the entire solution in the Rimworld Mods-folder, without the need to copy the mod files over to the Mods-folder after every build. I'm not personally a fan of this approach, but it's easy, so let's stick with it here.

By default, the MSBuild puts the assembly file in a subfolder in the output path, named after the targeted framework (in our case 'net35'). So, the compiled assembly would end up in 'Assemblies/net35/'. For development in general, this is usually a good default value. Many projects build assemblies for multiple frameworks, needing different output folders for different frameworks. However, since Rimworld only targets the net35 framework, we want to bypass this. So, we add the second tag, telling MSBuild to put all our assemblies directly in the output path.

- Debug files
Next, we're gonna want to stop MSBuild from generating a .pdb-file. The .pdb-file can be valuable to people trying to debug an already compiled assembly, not having access to the source code. But since proper debugging isn't widely used in the Rimworld modding community, it's generally advised to omit it. Besides, Rimworld is going to complain if it detects a .pdb-file while loading the mod into the game. We add this:

<DebugType>none</DebugType>

- Optimize
We're almost done with the properties. This last one tells the compiler to optimize the assembly file. There are way too many small tweaks included in this concept to cover there, but suffice to say you'll usually end up with a slightly smaller .dll and it might, depending on a multitude of factors, perform better when executed. There is usually no harm in having this set to active, so modders generally do.

<Optimize>true</Optimize>

References
Having done that, let's add our references. All C# mods will require access to one, or several, Rimworld assemblies. Harmony is also often used. This is added in an ItemGroup. Traditionally, modders have been referencing them directly on their computer, often like this:

<ItemGroup>
    <Reference Include="Assembly-CSharp">
        <HintPath>..\..\Program Files (x86)\Steam\steamapps\common\RimWorld\RimWorldWin64_Data\Managed\Assembly-CSharp.dll</HintPath>
    </Reference>
    <Reference Include="UnityEngine">
        <HintPath>..\..\Program Files (x86)\Steam\steamapps\common\RimWorld\RimWorldWin64_Data\Managed\UnityEngine.dll</HintPath>
    </Reference>
    <Reference Include="UnityEngine">
        <HintPath>..\..\Program Files (x86)\Steam\steamapps\common\RimWorld\RimWorldWin64_Data\Managed\UnityEngine.dll</HintPath>
    </Reference>
    <Reference Include="0Harmony, Version=1.2.0.1, Culture=neutral, processorArchitecture=MSIL">
        <HintPath>..\packages\Lib.Harmony.1.2.0.1\lib\net35\0Harmony.dll</HintPath>
    </Reference>
</ItemGroup>


This is fine. I'd argue against it, especially the Rimworld assemblies, but include these, and you'll be good. Just make sure the paths are correct.

The better way to include references, whenever possible, is using NuGet packages. Using these, MSBuild can automatically download the references during build. This becomes especially beneficial whenever you open your mod project on a new computer. Normally, this would make the project unable to build, because it can't find the references in the path you specify. But with NuGet packages, they're just downloaded as if nothing's changed. Easy peasy.

As it so happens, all the above references are available as NuGet packages. See here for more information about Lib.Harmony and Krafs.Rimworld.Ref.
Using these packages we turn the above ItemGroup into this:

<ItemGroup>
    <PackageReference Include="Krafs.Rimworld.Ref" Version="1.0.2408" />
    <PackageReference Include="Lib.Harmony" Version="1.2.0.1" />
</ItemGroup>


This way of referencing NuGet packages is also somewhat new. It's called PackageReferences, and has become the preferred way to include packages. If you've been including packages in the old way, indicated by you having a packages.config-file in your project, you might want to consider migrating to this style instead. It's simple - right-click your packages.config-file and choose Migrate packages.config to PackageReferences.... All your existing packages will be referenced as in my example above.

If you prefer sticking to packages.config, for whatever reason, that's fine. However, some NuGet packages (like Krafs.Rimworld.Ref) only work with PackageReferences, so if you want to include that you need to migrate. There are a bunch of other benefits of the new reference style, which you can read more about here.

Result
Putting all of this together, our final .csproj looks like this:

<?xml version="1.0" encoding="utf-8"?>
<Project Sdk="Microsoft.NET.Sdk">

    <PropertyGroup>
        <TargetFramework>net35</TargetFramework>
        <AssemblyVersion>1.0.9.3</AssemblyVersion>
        <OutputPath>..\Assemblies</OutputPath>
        <AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
        <DebugType>none</DebugType>
        <Optimize>true</Optimize>
    </PropertyGroup>

    <ItemGroup>
        <PackageReference Include="Krafs.Rimworld.Ref" Version="1.0.2408" />
        <PackageReference Include="Lib.Harmony" Version="1.2.0.1" />
    </ItemGroup>

</Project>


Pretty neat, huh?

Additional comments
There are a few things left hanging that might require clarification:

Most of the properties set in .csproj PropertyGroups are set automatically when you change settings in the project Properties (right clicking your project file and choosing Properties). Changing settings in Properties will add properties to the .csproj. This is oftentimes a simpler way to change project settings, but I wanted to demystify the actual .csproj-file to make working in it less intimidating. Besides, occasionally things are mistakenly or wrongly added to the .csproj by Visual Studio. It's a good thing being able to rectify its mistakes.

Switching from legacy-style to SDK-style renames References to Dependencies in your project.

You wouldn't usually manually add references to either files or NuGet packages in the .csproj. It's much more convenient to do it by right-clicking Dependencies in the Solution Explorer choosing 'Manage NuGet packages'. This will add the PackageReference to an ItemGroup automatically.

Using a legacy-style project, in Visual Studio you're required to first unload the project in question before being able to right-click it and edit it. However, as soon as you've switched to SDK-style, you'll be able to open it up and edit it simply by double-clicking the project in the Solution. Much more convenient.

I'm personally fine with most of the default values MSBuild sets for me, so the above .csproj would suit me well in many scenarios. However, you might want to customize your build more. For example, you might want to optimize only release builds, but not debug builds. Simply add a conditional PropertyGroup that only applies in release configurations, and set optimize there. Optimize is false by default, so you don't need to specify it as false in a debug or default configuration. It'd look something like this:

<PropertyGroup>
    <TargetFramework>net35</TargetFramework>
    <AssemblyVersion>1.0.9.3</AssemblyVersion>
    <OutputPath>..\Assemblies</OutputPath>
    <AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
    <DebugType>none</DebugType>
</PropertyGroup>

<PropertyGroup Condition="$(Configuration) == Release">
    <Optimize>true</Optimize>
</PropertyGroup>


As you can see, it's possible to do all kinds of things depending on configuration, or other arbitrary variables.

There is a lot to learn about MSBuild, and I just briefly touched upon a few of them. Read more about MSBuild here.

I hope you've learned something, and that you're more comfortable with the .csproj than you were before. Or, maybe this was just a total waste of time.

Feel free to ask any questions or give feedback.