GDPatch: a versatile Godot mod loader

It only took 15 months for me to write another interesting blog post!

posted 2026-07-23

Katie and I have been working on a brand new Godot mod loader over the past few months! It’s called GDPatch and I think it’s pretty neat. I wanted to write about some of the cool tricks up its sleeve that sets it apart from other Godot mod loaders.

GDPatch is still confidently alpha software (we just released it, after all) and there’s tons more to work on, but we’ve gotten far enough that I feel comfortable releasing this to the public and letting other modders hack on it with us. I don’t want to let my perfectionism get in the way of not releasing this project, after all…

If you’re a game modder interested in trying out GDPatch, or you’re currently using other Godot mod loaders, I suggest you give it a try! We’ve got docs, a Discord server, and hopefully a stable enough codebase that functions on any Godot 4.x game you throw at it. We’re seeking initial impressions and bug reports from developers and tinkerers.

Wait, why another one?

Long time readers may be familiar with GDWeave, my previous Godot mod loader that I developed for WEBFISHING back in 2024. To understand why GDPatch is a sorta-spiritual-successor to GDWeave, I first need to explain why I created GDWeave in the first place, and what went wrong with it.

When WEBFISHING first released and took the Internet by storm, in classic NotNite fashion, I obviously wanted to write some mods for it! It was my first time working with a Godot game, so I had no idea what the modding ecosystem was like. The only general-purpose mod loader I could find was the aptly named Godot Mod Loader, but I was let down by its somewhat weak hooks API and I disagreed with several of its design decisions:

Disappointed by the current ecosystem of Godot modding, I set out to make something myself! I already knew that Godot’s scripts were saved in a bytecode format, so I imagined “weaving” modded instructions into existing game code (hence the name), inspired by the APIs of Mixin and Harmony. I picked my beloved C# as my language of choice, as I was very familiar with it and it’s good at modding native code. After all, this was just a side project meant for myself only… right???

One function hook and file format later, I quickly learned that Godot’s “bytecode” script format unfortunately wasn’t what I expected it to be. When I first learned scripts were saved in a binary format, I was expecting it to be in the format of virtual machine instructions, similar to GameMaker’s GML VM or Lua bytecode. It turns out that Godot’s “bytecode” format is just… a list of source code tokens straight out of the tokenizer?

I’m kind of surprised by this choice, given that GDScript already has a virtual machine instruction set it could use, you still have to run the parser on the tokens anyways, and the file format is often bigger than the source form without compression… but whatever, Godot’s inexplicable design decisions aside, it meant that the hypothetical VM disassembler I dreamed of wasn’t going to happen.

When I discovered this, I was actually a little sad about it, because it meant that the project was significantly less interesting than I was hoping it would be. Still, though, I continued on and made some proof of concept mods for the game. I eventually started talking about in Godot and WEBFISHING related Discord servers, and it picked up enough traction that other modders seemed interested in using it too.

Up until this point, the mods in GDWeave were quite literally hardcoded into the project, because I was just making it for myself to mess around. Several people encouraged me to convert GDWeave into a standalone mod loader, though, and I quickly hacked together some C# assembly loading code without polishing the API surface first. This turned out to be a grave mistake, because GDWeave started picking up traction immediately after, and it was too late to make any improvements without breaking existing mods.

Soon enough, I added GDWeave support to r2modman, and things got a little out of hand very fast. Lots of new people started to install and use GDWeave, I kept having to duct tape my janky modding API together for new features, and I started throwing up in my mouth a little seeing clickbaity “INSTALL WEBFISHING MODS” YouTube videos with my name in them.

GDLeaving behind GDWeave

Eventually, a new kind of request started showing up: support for other games. I had theorized this was possible in the past, and I planned to port GDWeave to other versions of Godot, but I had never checked how feasible it was until now. I realized that porting GDWeave to newer versions had a few crucial issues. I had pretty much no support for anything outside of the specific Godot version and file format that WEBFISHING required. Other token IDs, new language constructs, or even scripts in source code form weren’t supported and weren’t easy to implement.

I realized that the flimsy function hook I was using in the engine’s code was not as reliable as I’d hoped. In other builds of Godot I looked at, the specific function I was hooking was often inlined by the compiler or modified in some way that made it impossible to hook reliably with primitive pattern scanning. Eventually someone upstreamed custom function pattern support, but this was a bandaid fix for a deeper rooted problem. I hadn’t even considered how that function would look like on other operating systems or custom engine forks…

Also, the mod API was a complete mess to use anyways! The API functioned as an IEnumerable reading tokens one-by-one, which would have worked great if Godot used actual instructions like I was originally hoping, but it was a headache to use when a single line had to be represented in 15 yield statements.

I couldn’t really change the mod API much, because there were already so many mods being published in the community, and any changes I made to the mod API would break all of them. The API was so rushed that I didn’t even put a “GDWeave ABI version” in there or anything. (As of writing, Thunderstore’s Communities tab places it at 481 packages. Funnily enough, this is 12 packages more than the other game I modded on Thunderstore; they grow up so fast…)

I eventually conceded and came to the conclusion that GDWeave was unsalvageable in its current state. There were other forces as well (I would’ve picked Rust if I knew how popular it was going to be…), but the main breaking point for me was attempting to port it to Buckshot Roulette and giving up when I saw the scripts were stored in source code form there. Time to go back to the drawing board.

Modding every permutation of Godot

First things first, I needed to draft up some mod loader internals. The user-facing API could always be refined later, but I needed something to build off of that would be less brittle than GDWeave was. If I wanted to build a new mod loader that was truly universal, it would have to handle every permutation of operating system, engine version or fork, script format, and language feature… and it’d have to do this with 100% accuracy, staying faithful to the behavior of the original engine implementation.

On top of my existing goals for functioning at runtime without modifying game files, this felt like an impossible task. How the hell was I going to patch game files in memory without being able to reliably hook anything in the engine? Unless… you hook the filesystem itself?

THAT’S RIGHT BABY we’re hooking the goddamn filesystem APIs. This was my big mental breakthrough for how to approach this Herculean task; I only have to implement hooks for the relevant filesystem APIs once, and it’ll just work on every engine build. There was also only really the POSIX standard and Win32 API to worry about, so I assumed it would be easy to port to other platforms like macOS or potentially WASM down the line.

After briefly despairing over how many antiviruses would flag it as detection-evasive behavior (especially given my track record with users), I started to look for any prior art in the greater game modding ecosystem. My primary inspiration ended up being Mod Organizer 2, which hijacks the Windows API to redirect opened files to other file paths. I wanted to take this a step further and hijack all file reads, serving a fake file handle back to the engine executable, and then redirecting its attempts to read data to my own code.

My hypothesis was that I could hijack the game opening its .pck file, “just” pre-compute the file entry table to know what files map to what offset, and use the current file offset when intercepting file reads to know what to serve to the game. This, of course, has some major drawbacks - namely having to implement accurate roundtrip parsers for the relevant file formats - but it would maybe just work.

After coming up with this idea, I decided to start working on a prototype of the idea under the name GDPatch (mostly because I couldn’t think of anything better and I liked the naming scheme). I chose Rust this time, as I think its robustness is more ideal for a project of this caliber, and being able to compile to a single dynamic library was attractive to me. I also briefly considered C#‘s NativeAOT, but I decided that Rust was a better fit.

I made my first commit to GDPatch in May of last year, and then immediately got distracted by traveling to Europe a few days later. (I also wrote the first draft of this post a few days before another annual vacation!) Once I returned in June, I finished up the first of my “usermode filesystem interception library”, which I decided to name “filesilly”.

filesilly functions by hooking the NtCreateFile function in the Windows NT API (ntdll) and intercepting certain paths to return fake file handles. These are returned back to Godot’s filesystem API, which doesn’t know any better, and treats them like any other opaque file handle. In other functions like NtReadFile, we check if the handle was created by us, and perform operations on its stream. Because we’re using Rust, we can just convert the reads/writes/seeks the engine makes into normal Read + Write + Seek trait calls.

This is easier to illustrate by showing some example code. Here’s the slightly easier to read Linux implementation of fseeko, slightly cleaned up to remove some excess logging:

unsafe extern "system" fn seek_detour(stream: *mut FILE, offset: off_t, whence: c_int) -> off64_t {
    let fd = unsafe { libc::fileno(stream) };
    let handle = FileHandle(fd);

    if handle.is_fake() {
        let from = match whence {
            libc::SEEK_SET => SeekFrom::Start(offset as u64),
            libc::SEEK_CUR => SeekFrom::Current(offset),
            libc::SEEK_END => SeekFrom::End(offset),
            _ => {
                mark_errno(libc::EINVAL);
                return -1;
            }
        };

        let handle_store = HANDLE_STORE.lock();
        if let Some(stream) = handle_store.borrow_mut().get_stream(handle) {
            let res = stream.seek(from);
            match res {
                Ok(offset) => offset as off64_t,
                Err(error) => {
                    mark_errno(libc::EINVAL);
                    -1
                }
            }
        } else {
            mark_errno(libc::EINVAL);
            -1
        }
    } else {
        unsafe { SEEK_HOOK.unwrap().call(stream, offset, whence) }
    }
}

It’s really easy to write these hooks once you’ve got the hang of it - you just translate the function arguments into whatever the Rust APIs need, obtain the handle, and Do The Thing:tm:. While testing filesilly, I also obtained some cursed knowledge that Godot reopens a file handle for every file read in a pack file:

tinkering with making another @godotengine.org modloader and I’ve discovered something horrifying: why the hell does it open a separate handle for every file in the .pck??? what’s even the point of using a virtual filesystem if you aren’t going to reuse the handle lol

[image or embed]

— NotNite (@notnite.com) 3:12 PM · Jun 22, 2025

The first game I was able to transparently run under filesilly was WEBFISHING, and I was very happy to see that it had little performance impact (a very unscientific measuring of load times ended up being pretty much equal). After this, I started looking at implementing GDScript as accurately as I could, but I immediately was burnt out when I discovered GDScript has no official spec and the parser is a multi-thousand line C++ file. Implementing this would require porting thousands of lines by hand, and I really wasn’t up for it.

As you might have noticed from the gap in dates on my blog or portfolio, the latter half of 2025 (and first half of 2026) was pretty brutal on my creative output, so I was having trouble finding the motivation to work on anything. Given that GDPatch required so much effort to get it into working order, I realigned my priorities from “get my dysfunctional ass to work on GDPatch” to “get my dysfunctional ass to work on anything” and abandoned the project for a while.

The unfinished filesystem hooks sat in a private Git repository collecting dust for a few months, always coming up in the back of my head when I saw a new Godot game, but not enough for me to obtain the motivation to work on it…

Emergence of the cicadas

One cold February morning, I received a DM from my friend Katie:

whatever happened to your other godot modloader
burnout
gg
does it half work / are the files public

Katie had heard me talk about working on GDPatch in the past, and she was curious about using it for the CICADAMATA demo that had recently released. I had previously worked on reverse engineering WEBFISHING with Katie before, so I was delighted to see her interest in working on GDPatch with me.

Random side tangent about WEBFISHING modding

I don’t know where else to include this in this blog post, but funnily enough, Katie and I actually found remote code execution in WEBFISHING! After the big RCE mass-ping scare in the WEBFISHING modding Discord server, we went looking for it and we made it happen via undocumented Godot internals and weird NodePath quirks. Here’s a screenshot of her popping calc on my game client, dated November 5th, 2024:

Windows 10's Calculator open in front of WEBFISHING

We reported this to the developer and this is patched in the latest version, but I think it’s funny that it actually existed and nobody believed it. I originally wrote a blog post about this, but I never published it because I was waiting for coordinated approval from the developer; a bug in my RSS renderer accidentally leaked the draft, though, so some people saw it anyways ;3


Katie ended up carrying a lot of the initial Godot-related work and revived my interest in the project, and I am exceptionally thankful for her efforts. When she approached me about working on GDPatch together, we agreed to target Godot 4.6 for the CICADAMATA demo first, and I dug up my old project design document in my Obsidian vault to discuss how the modding API should look like.

The first thing we did was reimplement the pack file format and test the hypothesis I made about the format earlier. Since the file entries are in the header at the beginning of the file, we can just compute the modded files to obtain their file sizes, recreate the header with new file offsets, and look up those offsets to determine what file the engine is reading. We also had to implement support for self-contained pack files that are embedded into the game executable.

We eventually finished up the pack file replacements and were ready to test our first file swap. Since we didn’t have GDScript support yet, swapping a texture or something was the easiest way to verify it worked, and since I was too lazy to make my own test texture I looked for one in the game files. I ended up finding a leftover texture of someone’s cat named “chicken”, and replaced a texture in the background of the main menu:

A photo of a hairless cat named "chicken.png" chicken.png swapped into the game files, barely visible in the background, and me freaking out about it

Up until now, I wasn’t actually sure if this pack-replacing plan would work, so I was really happy to finally have a functioning prototype after nearly a year of little progress! Now that we knew that this approach would work, we started sketching out how we’d tackle GDScript patching internally. Katie once more carried a lot of the implementation, so shoutouts to her for that.

Since we needed to patch the game’s pack file before the GDScript runtime even loaded, this means mods couldn’t patch game scripts from GDScript, so we needed another language to write patches in. I chose Lua for this, because it’s the cutest little scripting language for these kinds of things, and mlua is a wonderful crate. We decided that we wanted two APIs that mods could use to patch scripts: a simple text-based string patching, and a more complicated AST patcher reconstructed from the token stream.

We also wanted to let people use text patching on bytecode format scripts (and vice versa), primarily because text patching has the lowest barrier to entry for newcomers, and bytecode format scripts are the most common format in published Godot games. To accomplish this, we added the ability to “reconstitute” bytecode scripts to text scripts and vice versa, and then Katie wrote an overengineered test harness to verify the output against the engine itself with a custom Godot module.

While working on this, we found several bugs in the GDScript tokenizer/parser, some of which we’ve categorized in the “Arcane knowledge” page of the GDPatch docs. Did you know that Godot reads garbage data if you start a script with a tab? I guess these mistakes are bound to happen with convoluted multi-thousand-line C++ files…

After verifying we could roundtrip GDScript between source and bytecode form, we were able to make our first mod for the CICADAMATA demo! The very first thing I did was give myself speed hacks, because of course I would (FLASHING LIGHTS WARNING!!!):

GDPatch.patch_script_as_text("Scripts/Player/Player2.gdc", function(ctx, src)
  src = src:gsub("const SPEED_REGULAR = 12", "const SPEED_REGULAR = 120")
  return src
end)

(At the time of writing, CICADAMATA isn’t out yet, so consider wishlisting it on Steam given how crucial its demo was to GDPatch!)

Bugfixing the rest of the owl

It was a huge milestone that we were able to mod the first game we were testing, but unlike GDWeave, we knew we were going to expand to other games. I took a look through my Steam library and categorized the games I owned that were made in Godot 4.x, and decided my next target would be Buckshot Roulette, since I failed to mod it previously with GDWeave.

Buckshot Roulette turned out to be a bit of a challenge, as we discovered several bugs in our pack handling code for the version 2 format, and our embedded pack handling turned out to be broken. We also found several bugs in our text tokenizer that we fixed. I of course made Katie do all of this while I was playing the newly-released Tomodachi Life: Living the Dream.

There was one issue that kept stumping us, though. In Buckshot Roulette’s source code, there’s an unused test function for the multiplayer lobby system that initializes a dictionary with some “interesting” whitespace:

func SetupDictionary_test():
	# ...
	GlobalSteam.LOBBY_MEMBERS = [
	{	"steam_id": 1234,
		"steam_name": "Mike"},
	{ 	"steam_id": 111,
		"steam_name": "RAM"},
	{	"steam_id": 222,
		"steam_name": "CROM"},
	{ 	"steam_id": 333,
		"steam_name": "BIT"}
	]

GDScript’s tokenizer contains a “multiline mode” flag that conditionally ignores all whitespace. It turns out that the GDScript parser changes the value of this while reading from the tokenizer, so in order to accurately tokenize GDScript, you need to implement the entire parser and AST as well. This dictionary is intended to be parsed with the multiline mode flag toggled on and off, but we weren’t doing that, so our code was exploding. Welp.

I ended up taking some new ADHD medication and hand-ported the parser to Rust with Katie over the span of a weekend. During this process, we learned the parser does a lot of non-parser-things like handling autocomplete and validating annotation placements, which made me very angry as GDScript is already enough spaghetti under the hood. Why does it have to be stateful!?

~7000 lines of woefully-written Rust later, we were able to roundtrip game scripts (both in bytecode and text form) through our custom tokenizer and parser, and everything seemed to be working okay. There were thankfully very little bugs in our parser implementation, with the only notable one being lambdas in an indented block failing to parse. It seems the average Godot developer doesn’t care about lambdas, because we only ran into that a few games later…

My favorite bug with Buckshot Roulette was having broken language translations, but only the multiplayer language files, and only with some languages in a non-deterministic order. This ended up being caused by a recursion lock I had implemented causing specifically those files to skip filesilly’s hooks. I’m still not entirely sure why this happened, but I’m glad I fixed it, I guess…?

while debugging this issue I tried to put a backtrace in the open64() hook, which immediately deadlocked the thread because backtrace itself reads /proc/self/maps. filesystem hooking really is beautiful sometimes

[image or embed]

— NotNite (@notnite.com) 6:40 PM · May 5, 2026

Once we had Buckshot Roulette fully working, I asked a few friends for more game suggestions to try GDPatch on. Hearing marsh talk about playing Lucid Blocks nerdsniped me into buying the game and trying GDPatch on it, and I found out it didn’t run for me under Wine! Since we didn’t have cross compilation to Windows just yet, I had to install Windows 11 on a separate partition, install Steam under WSL to run the game under Proton, and then debug it using println! and my hopes and dreams. This took me like four hours and the issue was just NT Object Manager paths:

// The paths that this API receives are varyingly "normal" paths, UNC paths and NT object
// manager paths. This attempts to normalize them to make comparisons more reliable.
let path = if let Ok(path) = path.strip_prefix(r"\??\") {
    Path::new(r"\\?").join(path)
} else {
    path
};

To make sure I never had to go through that hell again, I fixed up our Windows-specific build scripts to work under x86_64-pc-windows-gnu, and tweaked some minor incompatibilities like the float formatting we output GDScript with. Of course, I continued my conquest to speedhack in every Godot game (STILL FLASHING LIGHTS WARNING!!!):

GDPatch.patch_script_as_text("main/entity/player/player.gdc", function(ctx, src)
  src = src:gsub("static_speed_modifier = 1", "static_speed_modifier = 10")
  return src
end)

When my partner Cecilia told me to play the newly released GIRLBALLS, I ran into an edge case in filesilly, but otherwise got everything working immediately. You know the formula by now, switch a constant and go so fast you end up out of bounds:

GDPatch.patch_script_as_text("scripts/girl_ball_controller.gdc", function(ctx, src)
  src = src:gsub("var ball_force: float = 10", "var ball_force: float = 100")
  return src
end)

My character in GIRLBALLS floating somewhere out of bounds in the skybox after accelerating to Mach 5

Breaking and entering into GDScript

Up until now, we’ve only had the ability to edit GDScript, but not any way to add our own. We could obviously inject our own files into the pack, but they wouldn’t be loaded by the game. GDWeave solved this approach by injecting some ugly code into the first script that loaded, but I wanted to do better and inject into Godot’s autoload system. Luckily, implementing the project settings format turned out to be pretty easy as we had already done most of the Variant parsers, but I still had to iron out some bugs I found along the way.

To communicate back to the Rust section of the codebase, we needed a reliable way to pass messages back and forth. I considered cooking up some overengineered GDExtension and trying to load that from within Godot, but I snapped out of my overengineering haze and decided to just use the filesystem hooks as a side channel. We inject a built-in GDPatch script into the autoloads, and then magically redirect the gdpatch-ipc file to our own struct, where we can intercept all the reads and writes the engine makes:

func _init() -> void:
  self.mutex = Mutex.new()
  self.file = FileAccess.open("gdpatch-ipc", FileAccess.READ_WRITE)

  var resp = self._send_command_with_response({
    "type": "GetModList"
  })
  self.mods.assign(resp.value)

  for mod in self.mods:
    self._load_mod(mod["id"])

On the Rust side, this is just a struct implementing Read + Write that holds two Cursors for both directions’ buffers. I had already used GDScript’s shitty JSON parsing for message passing when I made WEBFISHING run in the browser, so I adopted a similar approach here and introduced a sequence number to keep things in order.

Since I was using Serde for this, adding things like config reads/writes was really easy; config files are stored in TOML, so even though I’m sending and receiving JSON, I can deserialize it into a toml::Value and then pass that directly to the config logic. The config logic is also shared with the Lua patcher runtime since mlua can deserialize Lua values via Serde. Hooray!

A friend of a friend testing GDPatch reported that Road to Vostok wasn’t working, but it turns out I just accidentally broke the IPC system on Windows a few commits prior and none of us noticed. I couldn’t easily get them to test a fix, and I didn’t realize there was a demo for the game (lol), so I just bought the game for my partner (who wanted to play it) and asked her to verify my fix worked. Turns out working on a mod loader for every Godot game can get a little expensive…

Further plans

I mentioned earlier that we’re shipping GDPatch in an unstable alpha state, and I’m a bit unhappy with some big features missing, but I’m hoping I can tackle them soon:tm: now that I’m working in the public. Here’s some things I want to attempt later down the line:

Even though there’s a lot to do, I’m very proud of how far GDPatch has come, and especially and how capable it already is! Once more, I encourage modders to stop by our docs and Discord server and let us know what you think. I want to build the ultimate mod loader for the Godot ecosystem, and I can only do that with the feedback of the community.

A special thank you to the friends that helped me get GDPatch off the ground within the past year:

It feels good as hell to be working on something new again. See you next year after I throw away more blog post drafts! <3