WebAssembly を使うとブラウザー上でいろいろな言語のエコシステムが使えて楽しいなと、最近 Rust/WebAssembly で遊んでいたのですが、ふと C もやってみようかなと hello world 的にメガドライブエミュレーターを動かすことに挑戦してみました。
実は Emscripten はかなり前に一度挑戦していたのですが、ブラウザーで動作させる際のビルド周りがなかなか大変で、あまり大きなものは動かすことができませんでした。
再挑戦ということで調査したところ、昨今はビルド周りも整備されていてなかなかいい感じに環境ができあがっているようです。
この記事のソースコードは github で公開しています。解説よりも、ソースを見ていただいたほうが早いかもしれません。
Genesis-Plus-GX WebAssembly porting (work in progress)

Emscriten + webpack ビルド
よい製作にはよいビルドということで、JavaScript 系は webpack を使ってビルドし Emscriten/wasm を読み込むようにしています。
当初は emcc-loader という webpack の extention を使って、webpack から emcc(Emascriten のコンパイラ)を呼び出す形にしていたのですが、現在メンテナンスされていないようで Emascriten 1.39.0 では emcc が出力する JavaScript のグルーコードがエラーとなりうまく wasm をロードすることができませんでした。
Module.ENVIRONMENT has been deprecated. To force the environment, use the ENVIRONMENT compile-time option
どうやら emcc-loader が生成するコードが指定している環境変数指定が非推奨となったということのようです。emcc-loader の次の場所(ENVIRONMENT: 行)をコメントアウトすると動作させることができました。
/** * Builds a loader script. */ async buildLoaderScript(baseScriptContent : string, options : LoaderOption) { const config = { ENVIRONMENT: options.environment, };
動かせるようになったものの、emcc-loader はプログラムに修正がかかるとフルビルドになる動作となり、少し大きめのプロジェクトだと時間がかかるため、今回は emcc-loader は諦めて C の部分は通常の cmake / make でビルドするようにしています。
この場合で emcc で出力される wasm/JavaScript のグルーコードを webpack で読み込むときは、リンカーオプションを次のように指定しモジュール化してあげます。
add_compile_flags(LD "-s DEMANGLE_SUPPORT=1" "-s ALLOW_MEMORY_GROWTH=1" "-s MODULARIZE=1" )
この上で自分の JavaScript から、emcc が出力したグルーコード JS を import して次のようにすると、 wasm が async でロードされ wasm モジュールが操作できるようになります。
import wasm from './genplus.js'; let gens; wasm().then(function(module) { gens = module; gens._init(); // ... });
モジュールとなったグルーコードを wasm() などと受けて then 以下で取得します。
WebAssembly とインターフェースする C の関数は次のように定義し、JavaScript からはモジュールから _ 付の関数として呼び出しすることができます。
void EMSCRIPTEN_KEEPALIVE init(void) { // ... }
また、C側の make についてですが cmake、make ともに Emscripten のラッパーコマンドが準備されていて、これを経由して cmake / make することでコンパイルオプションなどを自動的に調整してくれるようです。
emcmake cmake .. emmake make
github のほうにビルド手順を記載してあります。
メモリーの共有
WebAssembly 側で malloc したメモリーはモジュールの HEAP*.buffer ビュー経由で JavaScript から見ることができ、JS 側の TypedArray 経由でアクセスできます。
vram = new Uint8ClampedArray(gens.HEAPU8.buffer, gens._get_frame_buffer_ref(), CANVAS_WIDTH * CANVAS_HEIGHT * 4);
uint32_t *frame_buffer; void EMSCRIPTEN_KEEPALIVE init(void) { // ... frame_buffer = malloc(sizeof(uint32_t) * VIDEO_WIDTH * VIDEO_HEIGHT); // ... } uint32_t* EMSCRIPTEN_KEEPALIVE get_frame_buffer_ref(void) { return frame_buffer; }
エミュレーターで作成した VRAM (iint32_t) を JS 側で取得して canvas にそのまま描画しています。
ちなみに、この例では C 側は uint32_t ですが、canvas は RGBA を Uint8ClampedArray ビューで扱うためエンディアンで逆になってしまい色がおかしくなりました。。ややはまり。
ソースコードデバッグ
C 側のソースですが、emcc がソースマップ出力に対応しているためブラウザ(Firefox で確認)でデバッグブレイクが可能です。ただし、変数の値などはみることはできないようです。

ソースマップを出力するためには emcc のコンパイルオプションで -g4 を指定し、–source-map-base オプションを指定します。
# source map option (but not working) # -g4 # --source-map-base src/main/c
その上で、ソースコードがブラウザーから見えるように http の領域に配置します。
devServer: { inline: true, contentBase: [ path.join(__dirname, '/docs'), // eslint-disable-line // for sourcemap - src/main/c path.join(__dirname, '/'), // eslint-disable-line ],
基本的にはこれで止まるのですが、残念ながら現在 –source-map-base オプションが wasm の場合はうまく効かず、出力がすべてフルパスになってしまうようです。(emcc-loader だといい感じに source-map がでていたので何か方法があるのかもしれません)
とりあえず、出力された source-map のフルパス部分を置換して http から見えるパスにしてあげれば動作します。
WebAssembly/Emascripten 上でプログラムを動作させると、malloc に対して境界値チェックが働くパターンがあるようです。実は移植当初 ROM のおしり 0x800000 から SRAM がマッピングされているのに気が付かず(ネイティブだと動いてしまっていた)、wasm がダウンしてしまっていたのですが、ソースコードマッピングすることで場所を特定することができました。
C 側の軽いデバッグの場合は、EM_ASM_ マクロでブラウザーのコンソールに文字列を出力できます。
#include <emscripten/emscripten.h> uint8_t *rom_buffer; void console_log() { EM_ASM_({ console.log('genplus_buffer0: ' + $0.toString(16)); }, buffer[0x100]); }
Emscriten 移植のこつ
箇条書きにて。
- C 側はネイティブでも動作を確認する環境をつくり、Emscripten でコンパイルすると切り分けが早いです。前述の境界値系のエラーが wasm ででた場合はソースをアタッチするといいと思います。
- WebAssembly の場合、イベントや入出力系は全て JavaScript 側の役目になりますので、一貫して JS -> wasm の呼び出しパターンでプログラムを構成すると処理の粒度的に分かりやすくなりそうです。
- C のプログラムを動かすというよりも、ビルドや JS とのインターフェース周りを調査するのに時間がかかりました。逆説的には、そこがクリアできれば大抵のものが動かせそうです。
というわけで、WebAssembly はいろいろできて楽しいですね。エミュレーターのほうですがまだコントローラーをつないでないので、Gamepad API で接続してみたいと思います。

ついに iOS でエミュレーターが動かせる…!