Porting Terraria and Celeste to the Browser with WebAssembly

May 29, 2025 - 03:15
 0  0
Porting Terraria and Celeste to the Browser with WebAssembly

Porting Terraria and Celeste to WebAssembly


(tldr: terraria in the browser here, celeste in the browser here. terraria git repository, celeste git repository)

One of my favorite genres of weird project is "thing running in the browser that should absolutely not be running in the browser". Some of my favorites are the Half Life 1 port that uses a reimplementation of goldsrc, the direct recompilation of Minecraft 1.12 from java bytecode to WebAssembly, and even an emulated Pentium 4 capable of running modern linux.

In early 2024 I came across an old post of someone running a half working copy of the game Celeste entirely in the browser. When I saw that they had never posted their work publicly, I became about as obsessed with the idea as you would expect, leading to a year long journey of bytecode hacks, runtime bugs, patch files, and horrible build systems all to create something that really should have never existed in the first place.

Strawberry Jam mod running in celeste-wasm

Credits to r58 for figuring most of this stuff out with me and bomberfish for making the neat UI.

Terraria

I knew that both Celeste and Terraria were written in C# using the FNA engine, so we should have been able to port Terraria in the same way they did for Celeste, so we set that as a goal.

The original post didn't have too many details to go off of, but we figured a good place to start was setting up a development environment for modding. In theory, all we needed to do was decompile the game, change the target to webassembly, and then recompile it.

It turns out that we were very lucky with the game being C#— since the bytecode format (referred to as MSIL or just IL) maps very closely to the original code, and the game was shipped with the .pdb symbol database for mapping function names (and local variables!), we could get decompilation output that was more or less identical to the original code.

Setting up a project

Running ilspycmd on Terraria.exe, decompilation failed because of a missing ReLogic.dll. It turned out that the library was actually embedded into the game itself as a resource.

That's.. odd, but we can extract it from the binary pretty easily. Easiest way is just to create a new c# project and dynamically load in the assembly..

= Assembly.LoadFile("Terraria.exe");

And then since all the terraria code is loaded, we can just extract it the same way the game does!

? stream = assembly.GetManifestResourceStream("Terraria.Libraries.NET.ReLogic.dll");
= File.OpenWrite("ReLogic.dll");
.CopyTo(outstream);

After putting ReLogic.dll into the library path, decompilation succeeded, and after installing all the dependencies Terraria uses, the project recompiles and launches on linux. Now that we knew the decompilation was good, I created a project file for the new code targetting WASM and configuring emscripten.

Project Sdk="Microsoft.NET.Sdk.WebAssembly">
PropertyGroup>
StartupObject>ProgramStartupObject>
EmccExtraLDFlags>-sMIN_WEBGL_VERSION=2 -sWASMFSEmccExtraLDFlags>
EmccEnvironment>web,workerEmccEnvironment>
PropertyGroup>
ItemGroup>
PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
PackageReference Include="Newtonsoft.Json.Bson" Version="1.0.3" />
PackageReference Include="DotNetZip" Version="1.16.0" />
PackageReference Include="MP3Sharp" Version="1.0.5" />
PackageReference Include="NVorbis" Version="0.10.5" />
ItemGroup>
Project>

Somewhat surprisingly, all of the project code compiles without issue, but the FNA engine is partially written in c++ and needs to be linked against its native components. The web target isn't officially supported by FNA, but its native components compile without issue under emscripten's opengl emulation layer. This process is automated by FNA-WASM-BUILD on github actions to make things slightly less painful.

The archive files from the build system can be added with and then will automatically get linked together with the rest of the runtime during emscripten compilation.


NativeFileReference Include="SDL3.a" />
NativeFileReference Include="FNA3D.a" />
NativeFileReference Include="libmojoshader.a" />
NativeFileReference Include="FAudio.a" />

After an eight minute compile step dotnet spit out the full webassembly and JS bundle, which I almost couldn't believe at first, since at this point we had just basically just pasted in the terraria source and barely had to tweak anything. Now we just had to figure out how to run the actual game.

Running the game

We ended up just gutting terraria's linux entry point and creating a minimal Init() function tagged with [JSExport] so it was callable, then writing a simple loop to drive the FNA game loop from JS.

const runtime = await dotnet.create();

const config = runtime.getConfig();
const exports = await runtime.getAssemblyExports(config.mainAssemblyName);
const canvas = document.getElementById("canvas");
.instance.Module.canvas = canvas;

console.debug("Init...");
await exports.Program.Init();

console.debug("MainLoop...");
const main = async () => {
const ret = await exports.Program.MainLoop();
requestAnimationFrame(main);

requestAnimationFrame(main);

Since we could access the save path property on the Terraria class, we didn't even need to patch the code to get all the paths correct. After launching, we started to see some signs of life from terraria code. At this point, the only thing missing was the game assets themselves.

This means we have another chance to use one of my favorite browser APIs, the Origin Private File System! Since everything goes through emscripten's filesystem emulation, we can just ask the user to select their game directory with window.showDirectoryPicker(), then copy in the assets and mount it in the emscripten filesystem.

And sure enough, after a quick patch to FNA to resolve a generics issue, the game launched.

Terraria makes it all the way to the loading splash screen!

Annoyingly, uploading files with showDirectoryPicker() does not work on Firefox. Mozilla has a negative standards position on the file system access api, and refuses to implement it. Curiously enough though, they do implement DataTransferItem.webkitGetAsEntry which allows you to do... more or less the same thing as showdirectorypicker, as long as the user drags in a folder.

Confusing decision, but convienent for us. WebKit also doesn't currently support showDirectoryPicker, so I had initially assumed that it would be impossible to upload assets on iOS Safari, but funnily enough I was sent a video proving it possible through some funny ui manuvering:

...and then immediately crashed, after trying to create a new thread, which was not supported in .NET 8.0 wasm.

Fortunately we found a clever solution: waiting about a month for NET 9.0 to get a stable release. Once it was packaged, we could just toggle the new WasmEnableThreads option.

I upgraded NET, waited for it to compile, and... FNA threw an error during initialization

It turns out that in NET threaded mode, all code runs inside web workers, not just the secondary threads. What would usually be called the "main" thread is actually running on dotnet-worker-001, referred to as the "deputy thread".

This is an issue since FNA is solely in the worker, and the can only be accessed on the DOM thread. This is solved by the browser's OffscreenCanvas API, but we were still working with SDL2, which didn't support it, and FNA didn't work with SDL3 at the time we wrote this.

FNA Proxy

If we couldn't run the game on the main thread, and we couldn't transfer the canvas over to the worker, the only option left was to proxy the OpenGL calls to the main thread.

We wrote a fish script that would automatically parse every single method from FNA3D's exported symbols (FNA's native C component), and automatically compile and export a wrapper method that would use emscripten_proxy_sync to proxy the call from dotnet-worker-001 to the DOM thread.

Let's look at the native method FNA3D_Device* FNA3D_CreateDevice(FNA3D_PresentationParameters *presentationParameters,uint8_t debugMode);, the first one that gets called in any program

The script automatically generates a C file containing the wrapper method, WRAP_FNA3D_CreateDevice

typedef struct {
*presentationParameters;
uint8_t debugMode;
* *WRAP_RET;
} WRAP__struct_FNA3D_CreateDevice;
void WRAP__MAIN__FNA3D_CreateDevice(void *wrap_struct_ptr) {
*wrap_struct = (WRAP__struct_FNA3D_CreateDevice*)wrap_struct_ptr;
*(wrap_struct->WRAP_RET) = FNA3D_CreateDevice(
->presentationParameters,
->debugMode
);
}
* WRAP_FNA3D_CreateDevice(FNA3D_PresentationParameters *presentationParameters,uint8_t debugMode)
{
// func: `FNA3D_Device* FNA3D_CreateDevice(FNA3D_PresentationParameters *presentationParameters,uint8_t debugMode)`
// ret: `FNA3D_Device*`
// name: `FNA3D_CreateDevice`
// args: `FNA3D_PresentationParameters *presentationParameters,uint8_t debugMode`
// argsargs: `presentationParameters,debugMode`
// argc: `2`
//
// return FNA3D_CreateDevice(presentationParameters,debugMode);
* wrap_ret;
= {
.presentationParameters = presentationParameters,
.debugMode = debugMode,
.WRAP_RET = &wrap_ret
};
if (!emscripten_proxy_sync(emscripten_proxy_get_system_queue(), emscripten_main_runtime_thread_id(), WRAP__MAIN__FNA3D_CreateDevice, (void*)&wrap_struct)) {
("console.error('wrap.fish: failed to proxy FNA3D_CreateDevice')");
(0);
}
return wrap_ret;
}

And the wrapper is compiled in with the rest of FNA3D.

Then in the FNA C# code, where the native C is linked to, we replace the PInvoke binding:

[DllImport(FNA3D, EntryPoint = "FNA3D_CreateDevice", CallingConvention = CallingConvention.Cdecl)]`
public static extern IntPtr FNA3D_CreateDevice(...);

...With one that calls our wrapper instead

[DllImport(FNA3D, EntryPoint = "WRAP_FNA3D_CreateDevice", CallingConvention = CallingConvention.Cdecl)]`
public static extern IntPtr FNA3D_CreateDevice(...);

Ensuring that all the native calls to SDL went through the DOM thread instead of C#'s "main" deputy thread.

Okay. That was a lot. It was worth it seeing the menu finally load though, even if it was missing localization.

Terraria main menu, minus the apparently important localization files

And then I tried entering a world, to be met with:

- Uncaught ManagedError: Cryptography_AlgorithmNotSupported, Aes

Alright. I guess AES just doesn't exist in NET wasm anymore. "write once, run everywhere" and then they stop supporting parts of the standard library for some reason. Awesome.

It's not too hard to shim the implementation though, and I could use the same block to link emscripten OpenSSL and implement the cryptography myself. And after a bit of extra tweaking, worlds could load.

My current best progressed save file (I lost my old ones)

The framerate of around 2 FPS wasn't promising, but fortunately enabling Ahead-Of-Time Compilation was able to get the performace up to being completely usable, even on low end devices.

You can play our port right now here, as long as you own a copy of the game, or check out our git repository.

Celeste

Of course, terraria wasn't enough. We also wanted to get Celeste working, since the person who shared the initial snippet had never released their work publicly. We also had some far away hopes of maybe also getting the Everest mod loader to run in the browser.

Since it's the same game engine, we just copied and pasted the existing code. At this point, the SDL3 tooling was also stable enough for us to upgrade, giving us access to OffscreenCanvas, so we would no longer need the proxy hack. Naturally, we still needed to patch emscripten to work around some bugs. nothing is ever without jank :)

We had another dependency issue though: Celeste uses the proprietary FMOD library for game audio instead of FAudio like Terraria. FMOD does provide emscripten builds, distributed as archive files, but as luck would have it- it also didn't like being run in a worker. We could use the wrap script again, but it isn't open source, so we couldn't just recompile it like we did for FNA. But, since we weren't modifying the native code itself, we could just extract the .o files from the FMOD build, and insert the codegenned c compiled as an object.

After a couple of patches that aren't worth mentioning here:

Celeste splash screen!

Base game celeste was awesome, but what I was really looking forward to was getting the strawberry jam mod to load, one of the largest and most complete level compilation in the community. This would mean supporting Everest mods.

A mod loader is generally built around two components, a patched version of the game that provides an api, and a method of loading code at runtime and modifying behavior. In Everest, both are provided by MonoMod, an instrumentation framework for c# specifically built for game modding.

The patcher part modifies the game on disk, so no problem there, it's the same in the browser. But the runtime modifications use a module called RuntimeDetour, which is essential to most mods, and very not supported on WebAssembly.

MonoMod.RuntimeDetour

We knew this would be the hardest part of the project, and had put it off for a while. Internally, as the name would suggest, RuntimeDetour is powered by function detouring, a common tool for game modding/cheating. Typically though, it's associated with unmanaged languages like c/c++. It works a little differently in a language like c#.

Oversimplifying a little, the process MonoMod uses to hook into functions on desktop is:

  • Copy the original method's IL bytecode into a new controlled method with modifications
  • Call MethodBase.GetFunctionPointer() or "thunk" the runtime to retrieve pointers to the executable regions in memory that the jit code is held in
  • Ask the OS kernel to disable write protection on the pages of memory where the jitted code is
  • Write the bytes for a long jmp (0xFF 0x25 ) into the start of the function to redirect the control flow back into MonoMod.
  • Force the JIT code for the new modified method to generate and move the control flow there.

This works because on desktop, all functions run through the CoreCLR JIT before they're executed, so all functions are guaranteed to have corresponding native code regions before they're even executed.

However, Mono WASM does not work this way. It runs in mostly interpreted mode with a limited “jit-traces” engine called the "jiterpreter", meaning not every method will have corresponding native code.

And even if it did - WebAssembly modules are read only, you can add new code at runtime, but you can't just hot patch existing code to mess with the internal state. WebAssembly is AOT compiled to native code on module instantiation, so it would be infeasible to allow runtime modification while keeping internal guarantees.

So instead of creating a detour by modifying raw assembly, what if we just disabled the jiterpreter and modified the IL bytecode? Since it's all interpreted on the fly, we should just be able to mess with the instructions loaded into memory.

To check the feasibility, I ran a simple test: run MethodBase.GetILAsByteArray(), then brute force search for those bytes in the webassembly memory and replace them with a bytecode NOP (0x00 0x2A)

Console output showing overwriting RealTargetFunc() with a NOP sequence. The function doesn't print anything when called, meaning the patch worked

Perfect! Now if we could just find the bytecode pointer programmatically...

There was the address from MethodBase.GetFunctionPointer(), but it wasn't anywhere near the code, and it definitely wasn't a native code region like on desktop. Eventually we realized that it was a pointer to the mono runtime's internal InterpMethod struct.

Since it would be easier to work with the structs in c, we added a new c file to the project with and copied in the mono headers. Sure enough, when we passed in the address from GetFunctionPointer, we could read ptr->method->name and extract metadata from the function. Even with this though, we couldn't find the actual code pointer, as it was in a hash table that we didn't have the pointer to.

Suddenly, we noticed something really cool: since everything was eventually compiling to a single .wasm file, the c program that we had just created was linked in the same step as the mono runtime itself. This meant that we could access any internal mono function or object just by name. We were more or less executing code inside the runtime itself.

With our new ability to call any internal function, we found mono_method_get_header_internal, and calling it with the pointer we found earlier finally allowed us to get to the code region.

Now we just needed to find out what bytes to inject into the method that would let us override the control flow in a way that's compatible with monomod.

By looking at the MSIL documentation and this post we were eventually able to come up with something that worked:

  • insert one ldarg.i (0xFE 0x0X) corresponding to each argument in the original method
  • call System.Reflection.Emit to generate a new dynamic function with the exact same signature as the original method
  • insert an ldc.i4 (0x20 ) and put in the delegate pointer for the function we just created
  • insert calli (0x29) to jump to the dynamic method
  • add a return (0x2A) to prevent executing the rest of the function

Once the hooked function is called, it runs our dynamic method, which will:

  • ldarg each argument and store it in a temporary array
  • call into our c method, restoring the original IL and invalidating the source method
  • run the mod's hook function and return to monomod

Calling the function would make Mono assert at runtime though. It turns out that we need to load a "metadata token" to determine the method's signature before we run calli, and since the dynamic method is technically in a different assembly, it wouldn't be able to resolve it by default.

This was a simple fix though, since the dyn method has the same signature as the original one, we just had to clone the parent method's metadata in C and insert it into the internal mono hash table. This gave us a working detour system, but it turned out that last step broke in multithreaded mode, since each thread had it's own struct that needed to be modified.

There's probably a bypass for that, but at this point we figured it would just be easier to patch the runtime itself. After all, it's not like we have to worry about a user's individual setup, it's all running on the web.

Here's a simple patch, it would just clone the caller's signature when it saw our magic token (0xF0F0F0F0), and we wouldn't need to mess with any tables

--- a/src/mono/mono/mini/interp/transform.c
+++ b/src/mono/mono/mini/interp/transform.c
@@ -3489,7 +3489,9 @@ interp_transform_call (TransformData *td, MonoMethod *method, MonoMethod *target


-           if (method->wrapper_type != MONO_WRAPPER_NONE)
+           if (token == 0xF0F0F0F0)
+               csignature = method->signature;
+           else if (method->wrapper_type != MONO_WRAPPER_NONE)

Then, after recompiling dotnet, we could just put the patched sdk into the NuGet folder and have dotnet build the webassembly with our custom runtime.

Naturally, as with Everything C, we had somehow managed to trigger some memory corruption that the runtime wasn't happy about. But we got everything polished up eventually.

Now that we had a functional detour factory that worked in WebAssembly, we could slot it into MonoMod and start compiling everest.

Everest

So far, we've just been porting games by decompiling, editing source, then making a new project with all the celeste code included and recompiling. How is that going to work with everest? It patches the celeste binary's bytecode itself, and we can't just use that as the base for decompilation because the patcher's output can't be translated to normal c#.

What we could do though is load the game binary at runtime, instead of compiling it with the project. The project we compile to wasm would just be a stub loader, and we could load any celeste binary.

= Assembly.LoadFrom("/libsdl/Celeste.exe");
var Celeste = celeste.GetType("Celeste.Celeste");
.GetType("Celeste.RunThread").GetMethod("WaitAll").Invoke(null, []);

It feels a little weird to be talking about running an exe file in the browser, but since it's really just CIL bytecode inside a PE32 container, there's no reason it shouldn't work. And since we have dependencies directly added to the loader project, the runtime will find our web FNA before the real game's desktop FNA, so the game will call our libraries with no need for patching.

Of course, the game won't work, since we needed patches in a few places to get it to run in the browser without crashing.

We just made an entire framework for patching code at runtime though, so we can just use that, we just need to instantiate a Hook for the functions we need to patch then make our changes.

Here's an example hook, we need to force the window buffer to a specific size after the screen initializes, which we can do by finding ApplyScreen on the dynamically loaded assembly and running our code after it

= new Hook(Celeste.GetMethod("ApplyScreen"), (Action<object> orig, object self) => {
var Engine = celeste.GetType("Monocle.Engine");
var Graphics = Engine.GetProperty("Graphics", BindingFlags.Public | BindingFlags.Static);
orig(self);

= (GraphicsDeviceManager)Graphics.GetValue(null);
if (graphics == null) throw new Exception("Failed to get GraphicsDeviceManager");

.PreferredBackBufferWidth = 1920;
.PreferredBackBufferHeight = 1080;
.IsFullScreen = false;
.ApplyChanges();
});

Now that the loader doesn't care where the code comes from, we can just swap out Celeste.exe with the patched version from an everest install.

Everest loading on an old version of the celeste-wasm frontend

Do mods load? Nope, apparently it's crashing after trying to patch a mod DLL with monomod.

Wait, why is the mod loader patching the mod file?

Ah. I see. Everest is using monomod to modify the mod's calls to monomod. Sure. Why not.

I think this is for compatibility reasons? Anyway there's no reason monomod.patcher shouldn't just work at runtime, it's just the thing that patches il binaries on disk. We just needed to copy all the original dependencies into the filesystem so that monomod has all the symbols. And since we're already shipping MonoMod.Patcher, we might as well just install Everest all in the browser by downloading the everest dll directly from github and running the patcher on Celeste.exe

That's a lot of patches!! Throughout the project, our patching system went from: uploading entire source code to a git repo -> automated diff generation of .patch files -> hooking functions with RuntimeDetour -> patching Celeste.exe bytecode in the browser before the game starts

And as a bonus, now we're not hosting any Celeste IP, since all proprietary code gets loaded and patched inside the user's browser.

finally, mods and custom maps work

Hyperline and Ultra Skool mods loaded into celeste-wasm with Everest

race to strawberry jam

The Strawberry Jam mod has dependencies on over 60 individual mods. Most would load fine, but a lot didn't. We had to get all of them working.

Here's a fun issue - one of the mods tries to RuntimeDetour a function that's so small that the bytes of our jump patch overflow the code buffer. For cases like these, we found out how to abuse mono's hot reload module to replace function bodies instead of directly modifying the memory.

As we progressed it became increasingly clear that apparently wasm .NET is just straight up broken in a lot of cases. It makes sense, it's a pretty niche piece of software. The main use case is the Blazor web framework, and no one really uses it, not even microsoft. A not-insignificant portion of our time was simply spent working around .NET bugs. (like this one)

Other than the runtime bugs, the rest of the mod compatibility issues were actually just subtle differences between the Mono Runtime (used for webassembly and Wine) and CoreCLR (used for most desktop applications). No one plays Celeste on Mono so no one noticed it. First issue was a mod tripping a mono error during some reflection.

Again, the easiest way to get around this was just to patch the runtime. We're already running a modified sdk anyway, one more hackfix can't hurt.

FrostHelper won't load because the class override isn't valid? Well it is now.

--- a/src/mono/mono/metadata/class-setup-vtable.c
+++ b/src/mono/mono/metadata/class-setup-vtable.c
@@ -773,6 +773,7 @@ mono_method_get_method_definition (MonoMethod *method)



+   return TRUE;

SecurityException? Who needs security anyway?

+++ b/src/mono/mono/metadata/class.c
@@ -6480,6 +6480,7 @@ can_access_member (MonoClass *access_klass, MonoClass *member_klass, MonoClass*



+   return TRUE;

And.. uhh. oh. ok

So it turns out that mono's internal implementation of System.Reflection.Module.GetTypes is Broken and does not follow spec. Since the mods we're loading have extremely excessive use of reflection, a few of them are crashing. That's not a trivial fix, but after patching the runtime again with a reimplementation of the broken icall in c, all the the mono bugs are finally fixed and we can move on.

Just kidding. Apparently static initializer order doesn't follow spec and is breaking some of our mods. Another runtime patch? Another runtime patch.

Finally, it looked like we had gotten all of the issues sorted out. 200 lines of mono patches, 53 mods, and roughly a year passed since we started the project.

Was it worth it? Probably.

Strawberry Jam mod running in celeste-wasm

Fun side quest: how about we get the celeste multiplayer mod running in a browser?

Two browser windows connected to the same celeste game through celestenet, routed through the wisp server running in the bottom terminal

The helpful [MonoModRelinkFrom] attribute lets us declare a class to replace any system one, letting us intercept CelesteNet's creation of a `System.Net.Socket` with our own class that makes TCP connections over a wisp protocol proxy.

We'll use the same wisp connection to download mods from gamebananna too, since it's normally blocked by CORS policy.

Mod installer tab, showing the featured celeste mods on gamebananna

That's about it! You can play it on our deployment here or check out the git repository.

I guess there's only one question left...

Why?

Short answer: Chromebooks! They do ship with a linux emulator, but it's slow, and a "native" version of the game is cooler.

Long Answer:

As much as I can justify it, this project is probably going to be useless to most people. Not many people are seriously going to play all of celeste or terraria in the browser, and the novel things we did discover along the way are probably too niche to be of any help to anyone else.

Even so, I can confidently say that this was one of the most fun projects I've ever worked on, having unique constraints, revolving around an interesting technology, and putting me in contact with some cool people.

Sometimes it pays off to just have fun with a project. Mess around with some obscure technologies, do something impractical on the web. And whether it was worth my time or not, you can play celeste in the browser now, and i think that's pretty cool.

What's Your Reaction?

Like Like 0
Dislike Dislike 0
Love Love 0
Funny Funny 0
Angry Angry 0
Sad Sad 0
Wow Wow 0