I spent some time working on modding support for ΔV: Rings of Saturn. This hard-science-fiction physics-based asteroid mining sim is made in Godot, an open-source game engine not too unlike the proprietary but more popular Unity.
Although the game had some rudimentary modding support, as well as two mods published, the previous approach was fragile and did not allow mod interoperability. There is currently little public information about modding Godot games, which made this an interesting exploratory project. I present my findings in this article.
Because it’s difficult to meaningfully modify anything without first understanding it, reverse engineering is the first step of writing most game mods.
In case of Godot, a third-party project exists which can unpack Godot pack files, as well as decompile GDScript bytecode. The fidelity of the decompilation is surprisingly high, with the produced source code matching the original one even up to the line numbers. The comments and exact whitespace are gone, but most everything is preserved. Godot seems to offer an option to encrypt the bytecode, but ΔV does not use it.
For other kinds of resources, mileage varies. Scene files are saved as binary files, which makes grepping for identifiers more difficult. Although the Godot editor seems to happily work with binary resources, at this point a full decompile does not produce a correctly buildable Godot project.
The game’s creator, koder, graciously granted me access to the game’s source code for the purpose of working on the modding API and mods.
The mod loader is usually provided externally: some DLL loaded by the game is substituted with one which loads the original DLL, but also any installed mods. In case of ΔV, I did not have the chance to research this, as the game’s creator agreed to ship a community-supplied (and community-supported) mod loader as part of the game itself.
Godot makes it easy to load additional content, including code:
ProjectSettings.load_resource_pack plugs a
.pck file directly into the engine’s virtual filesystem. Then, it’s easy to iterate over loaded files to run any initialization code explicitly, or allowing loaded files to override the game’s files if their paths match.
Hooking game code
Unlike .NET, GDScript does not provide facilities for run-time modification. Limited reflection is possible; and, though there is an
eval equivalent (the engine runtime includes the GDScript compiler), it is not of much aid due to game code being distributed as opaque compiled bytecode.
The easiest way to modify the game’s behavior, using the loading scheme described above, is to override a game code file with a modified one, by placing it in a loaded resource pack at the exact same path as the game file. This is the approach used by the original mod loader and first mods. However, this approach does not scale far: because the mod also needs to contain a copy of the entire contents of the original game code file, the mod will break or cause the game to misbehave if a new version of the game changes the original file (because the changes will not be present in the mod’s copy). Furthermore, this scheme does not allow two mods to modify code in the same file, as there is no mechanism which could merge the modifications done by the two mods.
A better mechanism is possible thanks to two properties of GDScript:
- All script files are implicit anonymous unsealed classes, and all top-level functions are actually virtual methods of these classes.
Resource.take_over_pathAPI function allows overwriting a script resource with another, thus making any references use the new version.
Together, they allow us to do the following:
- Have a script-class in the mod extend from a game script-class;
- Have the mod script-class override a method in the game script-class, taking care to call the original method, but also adding the functionality needed by the mod;
- Early in the game’s initialization, have the mod loader compile the mod’s script, then emplace it over the game’s script resource.
This approach allows mods to be very unintrusive, with most mod files needing only a few lines of code.
As mentioned above, one unpleasant property of the old mod loading approach was that it was not possible to simultaneously load multiple mods which modified the same game file.
However, one emergent effect of the mechanism above is that the substitution in the last step also affects other scripts which attempt to inherit from the original game script-class - they will now inherit from the mod’s script-class instead. This affects not only game script-classes, but also other mods loaded in the same way.
Therefore, if the inheritance graph in the source code is as follows:
At runtime, it becomes:
This “inheritance chaining” allows mods to coexist (as long as overridden methods call the base methods at some point).
Mods loaded in this way run with the same privileges as the game itself, which is not sandboxed (unlike e.g. the GameMaker VM’s default), which usually means it has full access to the local machine.
The only security model providing any reliable guarantees in this situation would be a completely separate VM / language (e.g. Lua) and modding API, which would require a considerable implementation effort as well as limit the kinds of possible mods.
The security model we opted for, for now, is as follows:
- Mods distributed via official channels (Steam Workshop or any mod management UI that will be added in the game) will be vetted for malware or dangerous code.
- Mods can still be “side-loaded” from arbitrary sources, also to enable authoring new mods, but all documentation describing this process makes clear of the dangers of doing so.
One potential problem is game updates causing mods to become incompatible and cause the game to malfunction or crash.
This is generally handled by some form of version compatibility checks (the mod declares explicitly which game versions it’s compatible with, or the game performs some heuristic checks to guess if the mod is compatible): in any case, the user is generally presented a choice whether to continue starting the game without the mod, or try running with the mod anyway.
One complication of implementing this in ΔV’s mod loader is that mod loading (and thus version checks) can only occur very early in the game’s startup process, as mods cannot override code in script-classes which have been already loaded and instantiated. At this point, Godot’s normal UI facilities are unavailable; the only available mechanism of user interaction is
OS.alert, which does not even allow asking a yes-or-no question.
The current implementation does not attempt to resolve this problem, but two proposals were discussed:
- Do not load the mod if there is any doubt about compatibility, but upon initializing the game, prompt the user and allow them to choose to restart the game with the mod loaded.
- Load the mod, even if there is doubt about compatibility, but if the game exits uncleanly (any method other than “Exit” from the main menu, or a cleanly-handled Alt+F4), next time start the game in “safe mode”, which does not load any mods and explains to the player what happened.
- The new Godot mod loader, which is now also included in the experimental branch of ΔV.
- User and developer documentation for the above.
- A number of mods for the game using the new system.