Categories
Programming

Reliable builds with a wobbly tech stack

Reading Time: 7 minutes

If you’ve ever worked on a large software project, you know that keeping track of your tech stack can be a full-time job. Especially when your project uses multiple languages and frameworks, it can be a painful process to set up the environment correctly.

In this article, I will explain how I use Buildout, an open-source automation tool written in Python, to create reliable builds for my game written in C++, JavaScript, and Python.

Buildout is great because you can put the steps to build your project directly into code. These steps are usually only written down in some internal wiki that has never been updated. Or worse, the information is passed by oral tradition, and the person who knows the exact steps to get the build running left the company three years ago…

Even as a solo game developer, I often simply forget the exact steps needed to build my game. Recording them as a Buildout configuration is a huge relief when I come back to my project after not being able to work on it for a while.

Besides talking about my quest for a stable build process, I also want to introduce uttl.buildout, an open-source package for Buildout that will help you smooth out the build process for your own game.

Bootstrapping the process

I’m calling this an “early build” of my game because I don’t want you to criticize my graphics. I can do that perfectly well enough on my own.

The first commit for Up There They Love was on September 13th, 2017, and it contained a Visual Studio project, a Main.cpp, and bootstrap.py. All the bootstrap script did in that first version was to copy over some required Qt libraries to the build directory.

The main goal for the script was to always get a working game by running python bootstrap.py in a console window. I knew this could become very complicated over time, so I wanted to have it written down in code straight away. Over time, I expanded the script to find tools like Visual Studio and CMake, build dependencies automatically, and package the game for release.

My tech stack is a combination of Qt, SDL, Chromium, custom JavaScript, Sass, Grunt, and Webpack. It’s quite difficult to keep track of these different technologies, I have to be mindful of switching between writing C++ and JavaScript, for example. I don’t want to then also waste my time trying to remember to copy certain files over if I want to actually run the build.

(Yes, I use both Webpack and Grunt. No, I don’t want to talk about it.)

That’s why it was so important to me to start with this bootstrap script: it helps to keep things running smoothly. When I make time to work on my game, I can run git pull && python bootstrap.py to get in the right headspace straight away.

If something isn’t working with the script, I fix it immediately. I don’t want to be in a situation where the script cannot reliably create a build. Because that effectively means I won’t remember how to build my game.

But after three years of intermittent development, this script had grown to around 2400 lines of Python. I still considered this to be a manageable size, but I was starting to experience some growing pains.

So I started to look for something better.

Breaking it down

Ultimately, my bootstrap script solves some very practical problems:

  • Find required tools on the system (Visual Studio, git, etc.)
  • Resolve third-party dependencies (Qt, Grantlee, Chromium, etc.)
  • Compile and link everything
  • Package it up
  • Look cool while doing it

This is easy enough to solve with some Python scripting. For example, I use the registry to check if CMake is installed:

def ToolsCMake():
	print('CMake:')

	with PrintScopeLock() as lock:
		try:
			key = winreg.OpenKey(winreg.HKEY_CURRENT_USER, r"SOFTWARE\Kitware\CMake", 0, winreg.KEY_READ)
			installed = winreg.QueryValueEx(key, 'installed')
		except OSError:
			print('CMake is not installed.')
			print('')

			return False
		
		if installed[0] != 1:
			print('CMake is not installed.')
			print('')

			return False

		version = None

		p = subprocess.Popen(['cmake', '--version'], stdout=subprocess.PIPE, stderr=subprocess.PIPE)
		for line in iter(p.stdout.readline, b''):
			match = re.match('.*version ([0-9]+\\.[0-9]+\\.[0-9]+)', str(line))
			if match:
				version = match.group(1)
				break

		if not version:
			print('Failed to determine CMake version.')
			print('')

			return False

		print('INSTALLED ' + version)
		print('')

		return True

But what I ran into is that the script would run very inefficiently. It would do things like building all the dependencies every time. I didn’t have an easy way to check if those steps were necessary.

It’s not like this is a novel problem, though. For example, MSBuild also has to track changes on files and only run steps when something has changed.

Intermediately awesome

A perfectly well-adjusted developer like yourself has likely never taken a look at the output in the “intermediate” folder for your projects. The compiler puts the binary blobs for the linking process there. Nothing interesting for us humans.

And that’s mostly correct, but this folder also contains “.tlog” files. These files contain instructions for the compiler on how to build your source files and are actually perfectly human-readable.

For example, the “CL.command.1.tlog” file is used for the cl.exe tool, i.e. the C++ compiler that comes with Visual Studio. It may look daunting, but it’s actually quite straightforward. All it contains are the paths to the input files and the flags required to compile them:

^C:\PROJECTS\SSSG\SOURCE\SERVER\RANDOMNESS.CPP
/c /I"C:\PROJECTS\SSSG\PARTS\CHROMIUM-EMBEDDED\\" /I"C:\PROJECTS\SSSG\PARTS\GRANTLEE-MASTER\INCLUDE" /I"C:\PROJECTS\SSSG\PARTS\QHTTP-MASTER\SRC\\" /IC:\PROJECTS\SSSG\DEPENDENCIES\QTFORWARD\ /IC:\QT\5.15.2\MSVC2015_64\INCLUDE\ /IC:\PROJECTS\SSSG\GENERATED\ /IC:\PROJECTS\SSSG\SOURCE\SERVER\ /Zi /nologo /W3 /WX- /diagnostics:classic /Ox /Ob2 /Oi /Ot /Oy /GT /GL /D QT_NO_DEBUG /D QT_DLL /D QT_CORE_LIB /D QT_GUI_LIB /D QT_SQL_LIB /D QT_NETWORK_LIB /D NDEBUG /D _SECURE_SCL=0 /D WIN32 /D _WIN32 /D _WINDOWS /D _CRT_SECURE_NO_DEPRECATE /D _MBCS /GF /Gm- /EHsc /MD /GS- /Gy /fp:fast /Zc:wchar_t- /Zc:forScope /Zc:inline /Zc:rvalueCast /GR- /Fo"C:\PROJECTS\SSSG\INTERMEDIATE\SSSG\X64RELEASE\\" /Fd"C:\PROJECTS\SSSG\BUILD\SSSGRELEASE.PDB" /Gd /TP /FC C:\PROJECTS\SSSG\SOURCE\SERVER\RANDOMNESS.CPP

The first line is always the input file as an absolute path and written in uppercase. This is to ensure no confusion about a source file and the main reason why you should never check these files into source control!

The second line lists all of the compiler flags. These come from both the Visual Studio project and from the settings specific to each file. What’s very important to know is that these flags are always in the same order.

With the information in this intermediate file, MSBuild is able to figure out when it needs to compile a source file. If a destination or intermediate file is missing, that obviously means it needs to build the source. But similarly, MSBuild can compare the compiler flags against what it has recorded. If the flags were changed, the source file needs to be rebuilt.

Buildout

All of this was a very roundabout way to say that buildout works in exactly the same way, but you don’t have to use it for compiling C++. Buildout automates anything you want, including MSBuild itself!

When you run buildout on the command line, it looks for a buildout.cfg file by default. This is an INI configuration file where you subdivide the work into sections called “parts”:

[buildout]
parts =
    server-build-dependencies
    server-build-game

[server-build-dependencies]
recipe = uttl.buildout:devenv
executable = ${devenv:path}
solution = ${buildout:directory}/SSSG_Dependencies.sln
build = Release
project = InkWrapper

[server-build-game]
recipe = uttl.buildout:devenv
executable = ${devenv:path}
solution = ${buildout:directory}/SSSG.sln
project = SSSG
build = Release
exe-path = ${buildout:directory}/build/SSSG${:build}.exe
always-install = 1

Each part is “installed” with the specified options, which means the associated recipe (script) is called by Buildout.

And of course, these settings resolve to… an intermediate file!

[server-build-dependencies]
__buildout_installed__ = C:\SSSG\parts\ink-package\ink-engine-runtime\bin\Release\netstandard2.0\ink-engine-runtime.dll
	C:\SSSG\parts\ink-package\compiler\bin\Release\netstandard2.0\ink_compiler.dll
	C:\SSSG\dependencies\InkWrapper\bin\Release\InkWrapper.dll
__buildout_signature__ = uttl.buildout-1.2.4-py3.9.egg zc.buildout-0aec1332ee57ae0a5a956ad9fcd74357 setuptools-0aec1332ee57ae0a5a956ad9fcd74357
args = C:\SSSG\SSSG_Dependencies.sln /Build Release /Project InkWrapper
build = Release
executable = C:\Program Files (x86)\Microsoft Visual Studio\2017\Community\Common7\IDE\devenv.com
project = InkWrapper
recipe = uttl.buildout:devenv
solution = C:\SSSG/SSSG_Dependencies.sln
solution-path = C:\SSSG/SSSG_Dependencies.sln
working-dir = C:\SSSG

Buildout uses the generated .installed.cfg file to track changes between runs. When the settings differ from what was seen before, the recipe is called again.

Understanding how this system worked gave me the confidence to start converting my custom-written Python script to a configuration file for Buildout.

Something found lacking

With the building blocks that Buildout provides, I’m able to run the 67 (!) configuration steps required to build my game. That’s actually a lot more steps than were in my original script because each step is a lot more fine-grained.

For example, instead of one giant Python function that takes care of building and linking Chromium, I now have seven steps in Buildout to download the package, build the different versions, and deploy files to the build directory:

[buildout]
parts +=
	chromium-package
	chromium-build-libcef-debug
	chromium-build-libcef-release
	chromium-deploy-resources
	chromium-deploy-locales
	chromium-deploy-shaders
	chromium-deploy-dlls

Crucially, this means that each step is run only when it was invalidated. In the case of Chromium, that means the library is only rebuilt when I change the version of the package that should be downloaded.

Splitting the steps saves a lot of time compared to what the old script did, which was to build the library and copy files to the game’s build folder regardless of whether they were out of date.

Don’t get me wrong, switching from a custom Python script to a Buildout configuration wasn’t easy. While the tool is incredibly flexible and boasts a vibrant community of package and extension writers, I had some pretty niche problems to solve. For example, I couldn’t find an existing recipe for executing CMake commands, which I needed to build some of my dependencies.

Here are a few Buildout packages I can definitely recommend:

Scratching an itch

Not only is PyPi a great word to say, they even host cool packages like uttl.buildout!

To both solve my own problems and give back to the Buildout community, I’ve written a package called uttl.buildout. It focuses on providing tools for developing games on Windows, so it comes with the following recipes:

  • uttl.buildout.command – Run an executable with arguments
  • uttl.buildout.copyfile – Copy files between directories
  • uttl.buildout.versioncheck – Get a versioned executable
  • uttl.buildout.cmake – Run CMake commands
  • uttl.buildout.qtdeploy – Deploy Qt libraries
  • uttl.buildout.qmake – Run QMake commands
  • uttl.buildout.devenv – Build projects with Visual Studio
  • uttl.buildout.dotnet-restore – Restore .NET packages using NuGet
  • uttl.buildout.inklecate – Compile .ink files to JSON

You can find more detailed documentation for each recipe on GitHub.

The package is free to use for personal and commercial projects. It comes with an MIT-0 license, so you don’t even have to give credit if you don’t want to.

Conclusion

Buildout allows me to consistently run the 67-step process required to build my game. And because it’s all written down as code, the computer will tell me when something goes wrong.

You can’t beat a reliable process like that!

Leave a Reply

Your email address will not be published.

This site uses Akismet to reduce spam. Learn how your comment data is processed.