Show HN:将Terraria和Celeste移植到WebAssembly
Show HN: Porting Terraria and Celeste to WebAssembly

原始链接: https://velzie.rip/blog/celeste-wasm

出于对“不可能”浏览器移植的痴迷,我花了一年时间创建了Terraria和Celeste的浏览器版本,并希望能运行Everest模组加载器。我从反编译的C#代码开始,将其重新编译为WebAssembly,并利用FNA-WASM-BUILD等工具,最初成功加载了Terraria。我通过升级.NET并将OpenGL调用代理到主线程来绕过线程问题。克服了AES支持缺失和加密等进一步的障碍后,我最终实现了可玩性能。Celeste也受益于相同的代码库。 最终目标是在Celeste中运行Strawberry Jam模组。这需要让Everest(一个.NET工具框架)在浏览器中运行,我通过修补游戏基础代码的字节码并在运行时挂钩函数来进行相同的运行时修改,从而绕过了WebAssembly在运行时修改上的限制。这允许我们动态加载模组。在克服了运行Celeste模组的诸多困难后,在浏览器中玩Strawberry Jam的梦想最终实现了。

这个Hacker News帖子讨论了coolelectronics将Terraria和Celeste移植到WebAssembly (WASM)的工作。用户对能够反编译C#二进制文件并将其重新编译为WASM印象深刻。讨论涵盖了基于Web的游戏开发的各种挑战和解决方案,包括处理大型WASM文件、实现触摸控件、管理保存文件以及处理用于SharedArrayBuffer的跨源隔离标头。 几位用户分享了类似项目的经验,例如移植Quake III和Cave Story,并提供了有关优化性能和托管静态站点的技巧。一些用户报告说,Terraria演示版超过了Firebase带宽,或者由于网站的背景效果,在某些浏览器上运行缓慢。一个Celeste Classic WASM移植版本也链接在内。该帖子深入探讨了WASM的技术方面,包括使用Service Worker绕过限制以及使用WASM+OpenGL+SDL进行Web游戏开发的便捷性。

原文

(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.

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..

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 <NativeFileReference> and then will automatically get linked together with the rest of the runtime during emscripten compilation.

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!

...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 <canvas> 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

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.

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.

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 <pointer>) 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 <NativeFileReference> 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 <int32>) 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

(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.

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...

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.

联系我们 contact @ memedata.com