Assembling a Game Boy Game with Meson
I’ve been working on a Game Boy game off-and-on for the last few months (hopefully more details about this soon!). Up until recently I was building the game with GNU Make, but I was frustrated with configuring Make’s prerequisites for accurate dependency tracking. Often I’d change a file included by my target, but Make wasn’t rebuilding appropriately.
I started looking at other build systems to solve this problem. I really liked how approachable Meson’s build rules where, and also that it supported projects composed of multiple languages. This is useful to me as parts of my toolchain for handling assets, and converting them to a format usable on the Game Boy, are written in higher level languages. If I change the code of part of that toolchain, the build system knows what assets need to processed and what parts of the game need to be relinked, automatically.
There’s just one problem: Meson doesn’t support the Game Boy. However, that’s never stopped me before!
I’ve created a fork of Meson that adds support for using RGBDS to assemble and link Game Boy games. This was surprisingly easy to do, which is a testament for both the Meson and RGBDS projects. In Meson I added a new “language” rgbds
which sets up rgbasm
as the compiler/assembler and rgblink
as the linker. A ROM can be built just by calling the executable
function.
project('rgbds-test', 'rgbds')
executable('rgbdstest.gb', 'src/main.asm',
include_directories: ['include'],
link_language: 'rgbds')
Then Meson can be initialized to assemble to the Game Boy’s CPU and the game built.1 You can notice that Meson configures rgbasm
to output dependency tracking metadata, which ninja
uses for fast rebuilds.
$ ../../../meson.py setup --cross-file crossfile.ini --wipe build
The Meson build system
Version: 1.6.0
Source dir: /home/terin/Development/github.com/mesonbuild/meson/test cases/rgbds/1 basic
Build dir: /home/terin/Development/github.com/mesonbuild/meson/test cases/rgbds/1 basic/build
Build type: cross build
Project name: rgbds-test
Project version: undefined
Rgbds compiler for the host machine: rgbasm (rgbds 0.8.0)
Rgbds linker for the host machine: rgblink rgblink 0.8.0
Compiler for language rgbds for the build machine not found.
Build machine cpu family: x86_64
Build machine cpu: x86_64
Host machine cpu family: sm83
Host machine cpu: sm8320
Target machine cpu family: sm83
Target machine cpu: sm8320
Build targets in project: 1
rgbds-test undefined
User defined options
Cross files: crossfile.ini
Found ninja-1.12.1 at /usr/bin/ninja
$ ninja -C build -v
ninja: Entering directory `build'
[1/2] rgbasm -I../include -I.. -I. -Irgbdstest.gb.p -M rgbdstest.gb.p/src_main.asm.o.d -MQ rgbdstest.gb.p/src_main.asm.o -o rgbdstest.gb.p/src_main.asm.o ../src/main.asm
[2/2] rgblink -o rgbdstest.gb rgbdstest.gb.p/src_main.asm.o
$ ninja -C build -t deps
rgbdstest.gb.p/src_main.asm.o: #deps 2, deps mtime 1730079958938870943 (VALID)
../src/main.asm
../include/hardware.inc
Game Boy ROM files have a header that needs to be set correctly so real hardware will play the cartridge, and emulators know what cartridge type to emulate. Since this header contains two checksums, usually it’s configured using the rgbfix
tool rather than being hardcoded in assembly. Unfortunately, I haven’t figured out a good way for executable
to automatically call this tool with the correct parameters after the rom is linked, but the tool can be invoked as a custom_target
.
project('rgbds-test', 'rgbds')
rgbfix = find_program('rgbfix', required: true)
rom = executable('rgbdstest', 'src/main.asm',
include_directories: ['include'],
link_language: 'rgbds')
custom_target(output: 'rgbdstest.gb',
input: rom,
command: [
rgbfix,
'--title', 'EXAMPLE',
'--old-licensee', '0x33',
'--mbc-type', 'ROM',
'--rom-version', '0',
'--non-japanese',
'--validate',
'-',
],
build_by_default: true,
capture: true,
feed: true)
This example also showcases using custom_target
to interact with tools that need to be “feed” via stdin and where the results need to be “captured” on stdout, which is convenient as otherwise rgbfix
edits the file in-place, which could cause issues later.
Since I’m not ready to release my game just yet, I’ve modified pokered to use my fork of Meson. This builds the two image converters written in C, converts all the images to a format suitable for the Game Boy, and assembles and links with RGBDS. The resulting game matches the expected checksum.2
$ ninja -C build
ninja: Entering directory `build'
[1374/1374] Generating pokered.gbc with a custom command (wrapped by meson to capture output, to feed input)
$ shasum -c --ignore-missing ../roms.sha1
pokered.gbc: OK
$ jollygood -c sameboy build/pokered.gbc
i: Core: sameboy (SameBoy 0.16.5)
i: Video: OpenGL OpenGL ES 3.2 Mesa 24.1.7
i: Audio: 48000Hz Stereo, Speex 3
i: Emulated Input 1: gameboy1, Game Boy, 0 axes, 10 buttons
I’ll be submitting these changes to the upstream Meson project, in the off chance they’re fans of the Game Boy.
Edit 29 Oct: I’ve opened a PR to submit my changes to upstream Meson.
Meson has a concept of module, which are in-tree extensions to the core language to help handle common build tasks with large libraries, such as compiling moc files in Qt projects. I’ve created a “rgbds” module which has a function run rgbfix
to patch the ROM header, instead of needing to implement the above custom_target
yourself.
rgbds = import('rgbds')
rgbds.fix('rgbdstest.gb', rom,
title: 'EXAMPLE',
mbc_type: 'ROM',
fix_spec: 'lhg')
This module also adds a function with a barebones implementation of rgbgfx
, the graphics converter from the RGBDS project. This was previously implemented as a custom_target
in the pokered fork above.
pngs = [
'bug',
'plant',
'snake',
'quadruped',
]
foreach f : pngs
gen = custom_target(output: '@0@_conv.png'.format(f),
input: '@0@.png'.format(f),
command: [rgbgfx, '-o', '@OUTPUT@', '@INPUT@'])
gfx += custom_target(output: '@0@.2bpp'.format(f),
input: gen,
command: [tools_gfx, '-o', '@OUTPUT@', '@INPUT@'])
endforeach
The inner for loop can now use the rgbds module.
pngs = [
'bug',
'plant',
'snake',
'quadruped',
]
foreach f : pngs
gen = rgbds.gfx('@0@_conv.2bpp'.format(f), '@0@.png'.format(f))
gfx += custom_target(output: '@0@.2bpp'.format(f),
input: gen,
command: [tools_gfx, '-o', '@OUTPUT@', '@INPUT@'])
endforeach
The meson branch of my pokered fork has been updated with these changes.