WebAssembly On Your Nintendo DS

avatar
Martin MoxonSeptember 13, 20217 min read

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.

github
wasm3/Wasm3🚀 The fastest WebAssembly interpreter, and the most universal runtime

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.

github
PolyMarsDev/Snake-DSA simple snake clone for the Nintendo DS

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:

github
moxon6/nds-dependencies🎮 Repo for building dependencies for NDS apps

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:

app.wasm.h
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:

main.cpp
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:

nds.cpp
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:

nds.cpp
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:

nds.ts
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:

github
moxon6/snake-assemblyscript-dsSnake DS written in AssemblyScript

Attributions