14 KiB
| title | date | draft |
|---|---|---|
| Git-powered runtime code injection | 2021-09-16T10:09:35+02:00 | false |
Motivation
I've been following the work with Handmade Hero and the Handmade Network for a while. It's hard to keep up with the hundreds of episodes of the original show, but the first 30 or so were an instant classic as far as dealing with the irks and quirks of platform layer code in videogames, and why would you even want to do so.
One of the great features that came out of the platform layer in was runtime-code injection, around episode 021. It is surprisingly easy to replicate, provided that you architect your program just right. The program basically checks if you have re-compiled it and hot-loads the new code on runtime without any delays.
HMH only checks for new code, though. I figured out it would be a cool proof of concept to write a program that could go backwards, too. Using git and its C API it could load any commit, older or newer than the current runtime, and patch the code in using a similar method to HMH. Instead of being something entirely on the backend, this would actually be a fully-fledged menu inside the program that showed all commits.
So, how did I do it?
Platform-dependent vs platform-independent code
There are three basic ways to separate our platform-dependent code, although only one gives us the cool property of being able to hot-reload code. These are the following:
-
#ifdefs: A lot of older cross-platform code is chock-full of these. It's the most ad-hoc, "just works" type of cross-platform code, and reads something like this:// In the middle of normal-looking code #ifdef __WIN32 LoadLibraryA(libname); #elif __linux__ dlopen(libname, RTLD_NOW); #endif // Back to the platform indepent code ...It doesn't take a genius to see that this gets hard to manage if you use these
ifdefstatements too much. In particular, debugging can become a pain, since the preprocessor is cutting away a lot of lines of unneeded source.
-
Abstract away the platform layer: A saner, more popular way to deal with platform boundaries is abstracting the services that it provides, and calling our own platform-independent encapsulation. It would look something like this:
// main.c int main() { InitWindow(width, height, "Title"); SetTargetFPS(60); while(!WindowShouldClose()) { // More platform-independent code here } CloseWindow(); }// common.h // These are only the signatures. Implementation goes in windows_code.c / linux_code.c void InitWindow(int height, int height, char *title); void SetTargetFPS(int target); bool WindowShouldClose(void); void CloseWindow(void);# Makefile SRC=main.c common.h ifeq ($(OS),Windows_NT) SRC+=windows_code.c else # presuppose Linux SRC+=linux_code.c endif main: $(SRC) gcc $(SRC) -o mainEach would-be platform-dependent function is abstracted away in common.h, and its implementation is conditionally compiled with either Linux or Windows-specific code. This is perfectly fine way to architect your code. It has, however, one small problem: what would happen if we had to do things in different order in another operating system? Like, for example, a platform where we need to set the framerate before we create the window.
-
Abstract away the game: This is the method used in Handmade Hero. Instead of our game calling the operative system when it needs a service, it is the platform-dependent code that serves as the main entrypoint of the program. Only after it has prepared everything needed for our program to run, does it call our game code. It looks like this:
// linux.c int main() { XOpenDisplay(NULL); // some more linux-specific code... XCreateWindow(/* params */); game_state_t game_state; bool running; while (running) { waitforvsync(); running = record_input(&game_state); game_update_and_render(&game_state); // <- the only platform-independent function we call } }// common.h // both sides need to know about this structure to work along typedef struct { // struct definition } game_state_t; void game_update_and_render(game_state_t *game_state);// game.c void game_update_and_render(game_state_t *game_state) { // the actual game code goes here }# Makefile SRC=game.c common.h ifeq ($(OS),Windows_NT) SRC+=windows.c else # presuppose Linux SRC+=linux.c endif main: $(SRC) gcc $(SRC) -o mainNotice that the only place of interaction between both layers is a single function. The platform-side code has total control over things like input, window creation, timing, etc. And the game itself becomes much simpler, only concerning itself with the details inside the game state it can see. Its only job becomes advancing the game to its next state and return control to the platform layer.
But not only that, this architecture allows some more tricks, like dynamically linking game code.
Game as a library
Since we have abstracted our game code, compiling and linking it as a dynamic library is straightforward, we just need two separate compilation units:
# ...
CFLAGS=-fPIC # you will need this to be loaded as a library
libgame.so: game.c
gcc $(CFLAGS) game.c -o libgame.so
main: linux.c libgame.so
gcc linux.c -o main
If we wanted, we could add -lgame to main's target and have the linker automagically resolve game_update_and_render for us. That would be what is called early binding, having the program already know the location of a function before it actually executes. But since we want the ability to change it on runtime, we're interested in late binding here.
The only real problem here is naming: we have to resort to preprocessor trickery, but it works:
// common.h
#define GAME_UPDATE(funcname) void funcname(game_state_t *g)
typedef GAME_UPDATE(game_update_f);
// We save the game code as function pointers so we can transparently change it
typedef struct {
game_update_f *game_update;
} game_code_t;
// game.c
// This macro just expands to "void game_update(game_state *g)"
GAME_UPDATE(game_update)
{
// The actual game code
}
You can have as many or as few entrypoints to the game code as you desire. For example, having render and update in the same of different functions are two totally valid approaches. You just have to make sure to correctly link all symbols when loading:
// linux.c
// ...
void *library_handle = dlopen("libgame.so", RTLD_NOW);
game_code_t game_code;
game_code.game_update = dlsym(library_handle, "game_update");
// ...
while(running)
{
// ...
game_code.update(game_state);
}
Linux and Windows systems have different extensions and methods for linking, but the essence remains the same. With this setup, runtime-reloading of code is pretty straightforward. The way they do it in Handmade Hero is checking the timestamp of the game library, and reloading the symbols if it changes. Essentially, they reload the game code if you recompile the game while running.
This little trick has its limitations:
- Reloading platform code is not possible for obvious reasons, so it won't help while developing the platform layer (which isn't that much of a problem, since it's a small percentage of the actual work).
- You shouldn't save intermediate game state in the game layer, things like
statickeywords inside game functions are a no-no while recompiling and relinking code. Saving everything in its designated memory takes some discipline. - There are no safety rails. For example, changing the structure of the
game_code_tis an incompatible change with previous versions of the program, and will likely crash the program if you change it on runtime. However, it is also an excellent way to test for backwards and forwards compatibility.
A git-aware game
This trick is pretty powerful by itself, but I figured combining it with version control would make it even ever. We'll be using libgit2 to retrieve commit information from the folder that the game lives in, and presenting it inside an ncurses menu (granted, ncurses is not a very platform-independent way of rendering, but it's simple enough for a proof of concept).
Libgit2's development is actually sort-of independent of the git CLI, so it's not exactly a 100% replacement, and requires a bit of work to get things going. Since it's something that both the platform and game layers need to see (for loading and rendering the menu respectively) I added it to the game_state:
struct commit_node_t;
typedef struct commit_node_t commit_node_t;
struct commit_node_t {
commit_node_t *next;
char summary[32];
char author[32];
char email[32];
char date_as_string[32];
git_oid oid; // SHA-1 hash of GIT_OID_RAWSZ (20) Bytes
git_oid tree_oid; // the hash of the tree referenced by this particular commit
char oid_as_string[41]; // 20 oid Bytes * 2 chars to represent each byte + terminating \0
};
typedef struct {
// ...
commit_node_t *commit_list;
git_oid platform_oid; // commit the platform layer is based on
git_oid game_oid; // commit of the currently loaded game layer
git_oid selected_oid; // commit we just selected on the menu
// ...
} game_state_t;
I decided to represent them as an intrusive list, but that's an arbitrary decision.
Initially populating this list is done by a revwalker:
int read_git_repo(git_repository *repo, commit_node_t **list_head)
{
git_revwalk *walker = NULL;
git_commit *commit = NULL;
// We use a revwalker starting from HEAD to retrieve commits one by one
git_revwalk_new(&walker, repo);
git_revwalk_sorting(walker, GIT_SORT_TOPOLOGICAL);
git_revwalk_push_head(walker);
while (git_revwalk_next(&oid, walker) != GIT_ITEROVER)
{
// populate list with commit data
}
}
Once a commit is selected via menu, we just need to extract its files for compilation. A quick and dirty way to do so is creating a temporal folder with mkdtemp:
char *dump_git_tree(char *dirname, git_oid oid, git_repository *repo)
{
git_tree *tree;
git_tree_lookup(&tree, repo, &oid);
mkdtemp(tempdirname);
int n = git_tree_entrycount(tree);
for (int i = 0; i < n; ++i)
{
const git_tree_entry *entry;
git_object *object;
entry = git_tree_entry_byindex(tree, i);
if (git_object_type(object) == GIT_OBJECT_BLOB)
{
git_blob *blob = (git_blob *) object;
// Construct the filename of the new file: tempdir/filename
char filepath[PATH_MAX];
strcpy(filepath, dirname);
strcat(filepath, "/");
strcat(filepath, git_tree_entry_name(entry));
// Actually write the file contents to the temp folder
FILE *fp = fopen(filepath, "w");
fwrite(git_blob_rawcontent(blob), (size_t)git_blob_rawsize(blob), 1, fp);
fclose(fp);
}
}
return tempdirname;
}
Then, we compile and load the extracted code. One way to do it is forking our process and waiting for the compilation to finish:
char *tempdir = dump_git_tree("tempXXXXXX", game_state.selected_oid, game_state.repo);
int pid = fork();
if (!pid) // we are the child process
{
char command[PATH_MAX];
strcpy(command, "--directory=");
strcat(command, "./");
strcat(command, tempdir);
// Redirect output to /dev/null to avoid messing the screen
int fd = open("/dev/null", O_WRONLY);
dup2(fd, 1);
dup2(fd, 2);
execl("/usr/bin/make", "/usr/bin/make", "-s", command, "libgame.so", (char*) NULL);
}
// Wait for compilation to finish
wait(0);
// ...
// Actually do the code injection
char libpath[PATH_MAX] = "./";
strcat(libpath, tempdir);
strcat(libpath, "/game.so");
load_functions(&game_code, libpath);
And just like that, our game_code struct has been updated with new code. The finished product looks something like this:

I pushed a GitHub repo with the complete proof of concept of this simple idea. You can edit anything in game.so, commit the changes, and play a bit changing the game code in real time. Right now it has a simple implementation of Conway's game of life rendered in ncurses.
Future work
Libgit is a bit verbose and there is quite a bit of error handling code, so I just posted this as soon as I had a minimal runnable example. There's a ton of sugar you can add to this cake, though:
Add C++ support. C++ implemented overloading via name mangling, so we'd need to ask for the mangled name in ourEdit: C++'s name mangling is platform-specific, so it's better to simply usedlsymcalls.extern "C"to avoid dealing with it. We're only loading a few non-overloaded symbols, anyhow.- Auto-detect new commits while the game is running.
- Support for git submodules and more compilation units for different subsystems could be a boon for projects with big compile times.
- Dealing with git delta representations of tree objects. Maybe test it with git lfs too.
- Parsing git tags and other metadata.
- Check compatibility via version info, either of symbols (using GCC extensions) or of commits, using git tags.
- Git fetch/pull from the own game for hot-patching.
- It may be possible to do automated testing of compatible versions, giving the program the ability to recover from version-change-induced crashes. Using things like a signal catcher to capture things like
SIGSEGVand restoring thegame_stateandgame_codestructures could work. - Obviously, this is not only limited to games. Any simulation software, or really any state-machine-like software, would be a good experiment for this.
Disclaimer
Please remember that the snippets I post are a bit simplified, there's error handling and resource freeing that I left out for the sake of clarity.