Show HN: Porting Terraria and Celeste to 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.
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.
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.
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.
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:
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
)
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.
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
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.
Fun side quest: how about we get the celeste multiplayer mod running
in a browser?
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.
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?






