WebAssembly On Your Nintendo DS
Portable WebAssembly
One of the most common misconceptions about WebAssembly comes from the name itself. Although WebAssembly was originally designed for use in web browsers, it is not tied to any Web APIs at all, and is in fact a highly portable binary format that can also be run in non-web environments.
Because of WebAssembly's very simple instruction set, it is possible to build runtimes for a wide range of target architectures and host environments. Wasm3, a WebAssembly interpreter with minimal dependencies, has been shown to be easy to compile for other embedded systems such as the Arduino, which can then dynamically load and execute WASM modules.
As I'd recently been experimenting with WebAssembly in non-browser environments, running WebAssembly on the Nintendo DS (one of my favourite portable devices) seemed like a good candidate for another experiment.
Homebrew Software
I've long been interested in homebrew applications and games, as well as the community of enthusiastic developers building applications for popular devices.
Using the excellent tools developed by devkitPro, in particular devkitARM and libnds, it's possible to develop programs than can utilise most features of the Nintendo DS and run them on real devices.
An example of a very simple homebrew Nintendo DS game is Snake-DS by PolyMars, which in turn utilises NFLib on top of libnds to abstract away some of the unusual quirks of the DS's architecture.
For an excellent guide on Nintendo DS development in general, see here.
Due to the Snake-DS's low complexity, it seemed like an ideal candidate to rewrite in AssemblyScript, a special variant of TypeScript that compiles directly to WebAssembly.
Building Wasm3 for Nintendo DS
Unlike many other WASM runtimes (such as wasmer), Wasm3 is purely a WebAssembly interpreter without any AOT required nor JIT included.
While this can result in less than optimal performance at run time, it allows for much more flexibility in terms of portability. WASM modules can be dynamically loaded and swapped out at run time, and the memory profile is a lot more predictable than including a JIT - which is particularly important for systems with strict memory constraints such as the Nintendo DS.
Using the devkitARM port of GCC, it is simple to build Wasm3 as a library for use in a libnds project.
A full build pipeline and compiled library for wasm3-nds
is available here:
Communicating between C++ and WebAssembly
Loading a WASM module with Wasm3 is relatively easy. This project opted to follow a similar pattern to the wasm3-arduino
project, whereby the file is converted
to a header file containing the bytes directly encoded inline:
unsigned char build_optimized_wasm[] = {
0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00, 0x01, 0x3a, 0x0a, 0x60,
...
};
unsigned int build_optimized_wasm_len = 4409;
From here, loading the WASM module is simple:
int main(void) {
IM3Environment env = m3_NewEnvironment ();
IM3Runtime runtime = m3_NewRuntime (env, 1024, NULL);
IM3Module module;
m3_ParseModule (env, &module, build_optimized_wasm, build_optimized_wasm_len);
m3_LoadModule (runtime, module);
LinkNDSFunctions(module);
IM3Function f;
m3_FindFunction (&f, runtime, "start");
m3_CallV (f, 10);
return 0;
}
This is the simplest possible main function to load a WASM module, with any kind of error checking omitted. An important part here is LinkNDSFunctions
from nds.cpp
, which includes all the functions that should be accessible from the WebAssembly interface. For example for the basic print function, it's possible
to create a WebAssembly accessible function using the m3ApiRawFunction
macro:
m3ApiRawFunction(_print)
{
m3ApiGetArgMem (const char *, str)
iprintf(str);
m3ApiSuccess();
}
Here m3ApiGetArgMem
is setting up the pointer to point to the correct location along the WASM linear memory.
The new _print
function is then exposed to the WASM module like so:
m3_LinkRawFunction (module, "nds", "_print", "v(i)", &_print);
Here v(i)
denotes the signature of the function - in particular, this function takes an integer parameter (a pointer to a string) and returns void.
On the AssemblyScript side of things, an important thing to note is that when passing strings to the exposed C++ functions, it's necessary to byte-encode them and provide a null terminator:
const encode = (str: string): ArrayBuffer => String.UTF8.encode(str, true);
declare function _print(ptr: ArrayBuffer): void;
export function print(str: string): void {
_print(encode(str));
}
When the exposed C++ functions are called from within the WASM environment, a pointer to the byte-encoded string is passed, and the C++ functions can read the sequence of characters directly from the WASM linear memory.
The result is a general-purpose C++ main.cpp
entry point and a boilerplate linking layer nds.cpp
exposing the native APIs to the WASM environment.
The WASM module containing the game logic can be replaced by any other module and access the same exposed native API calls without any changes to the native compiled C++ code.
Building the game
As with any project with a relatively complex set of dependencies, I used (and highly recommend) VSCode's container remote feature.
For this project, I used the devkitarm docker image, a pre-configured environment for easily cross-compiling NDS roms.
The AssemblyScript is compiled to WASM, which is in turn encoded as a byte array in app.wasm.h
, and then this is compiled directly into the
final NDS rom:
While this method involves embedding WASM in the rom itself, there's no particular requirement for this to be the loading mechanism. Since the WASM is interpreted at runtime without any AOT compilation, the WASM could also be fetched dynamically over a network or read from disk and executed.
The End Result
The compiled rom can be run on either a DS emulator or a physical device. Here is the game running in DeSmuME, a Nintendo DS emulator:
The code for the game, as well as a build pipeline and compiled releases can be found here:
Attributions
- Image: Nintendo DS Lite (Modified): Evan-Amos CC BY-SA 3.0 , via Wikimedia Commons