https://velzie.rip/blog/celeste-wasm
home blog contact
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.
[celeste-sj]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 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? stream = assembly.GetManifestResourceStream("Terraria.Libraries.NET.ReLogic.dll");
FileStream outstream = File.OpenWrite("ReLogic.dll");
stream.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.
Program
-sMIN_WEBGL_VERSION=2 -sWASMFS
web,worker
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.
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");
dotnet.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.
[relogic]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:
My Celeste folder? Yeah, it's in my Files app, hold on let me get
it lmfao. pic.twitter.com/Yo0lcdr3mg
-- EnergeticBark (@energeticbark) May 27, 2025
...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 {
FNA3D_PresentationParameters *presentationParameters;
uint8_t debugMode;
FNA3D_Device* *WRAP_RET;
} WRAP__struct_FNA3D_CreateDevice;
void WRAP__MAIN__FNA3D_CreateDevice(void *wrap_struct_ptr) {
WRAP__struct_FNA3D_CreateDevice *wrap_struct = (WRAP__struct_FNA3D_CreateDevice*)wrap_struct_ptr;
*(wrap_struct->WRAP_RET) = FNA3D_CreateDevice(
wrap_struct->presentationParameters,
wrap_struct->debugMode
);
}
FNA3D_Device* 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);
FNA3D_Device* wrap_ret;
WRAP__struct_FNA3D_CreateDevice wrap_struct = {
.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)) {
emscripten_run_script("console.error('wrap.fish: failed to proxy FNA3D_CreateDevice')");
assert(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.
[tmenu]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.
[tworlds]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]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)
[mod-poc]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 (target_method == NULL) {
if (calli) {
- if (method->wrapper_type != MONO_WRAPPER_NONE)
+ if (token == 0xF0F0F0F0)
+ csignature = method->signature;
+ else if (method->wrapper_type != MONO_WRAPPER_NONE)
csignature = (MonoMethodSignature *)mono_method_get_wrapper_data (method, token);
else {
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 celeste = Assembly.LoadFrom("/libsdl/Celeste.exe");
var Celeste = celeste.GetType("Celeste.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
Hook hook = new Hook(Celeste.GetMethod("ApplyScreen"), (Action orig, object self) => {
var Engine = celeste.GetType("Monocle.Engine");
var Graphics = Engine.GetProperty("Graphics", BindingFlags.Public | BindingFlags.Static);
orig(self);
GraphicsDeviceManager graphics = (GraphicsDeviceManager)Graphics.GetValue(null);
if (graphics == null) throw new Exception("Failed to get GraphicsDeviceManager");
graphics.PreferredBackBufferWidth = 1920;
graphics.PreferredBackBufferHeight = 1080;
graphics.IsFullScreen = false;
graphics.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-lo]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
[mods]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)
static gboolean
verify_class_overrides (MonoClass *klass, MonoMethod **overrides, int onum)
{
+ 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*
gboolean
mono_method_can_access_field (MonoMethod *method, MonoClassField *field)
{
+ 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.
[celeste-sj]Strawberry Jam mod running in celeste-wasm
Fun side quest: how about we get the celeste multiplayer mod running
in a browser?
[celestenet]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.
[modinstall]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.
enter higher dimension
freaky mode: [ ]
generated May 29 2025 at 23:00:02 by /usr/bin/bash
[noscript]