Skip to content

Instantly share code, notes, and snippets.

@shimizu
Created July 17, 2026 05:49
Show Gist options
  • Select an option

  • Save shimizu/3359af1b00c063f860bc5d6f7a1a10f1 to your computer and use it in GitHub Desktop.

Select an option

Save shimizu/3359af1b00c063f860bc5d6f7a1a10f1 to your computer and use it in GitHub Desktop.

Emscripten 移植マニュアル

この文書は、C-Dogs SDL 2.4.0 を Emscripten/WebAssembly でブラウザへ移植した実作業をもとにした日本語の実務マニュアルである。

対象読者: フロントエンドエンジニア。JavaScript・ブラウザ・DOM・イベントループの知識はある前提。C 言語は「ポインタ、構造体、malloc が分かる」程度の基礎知識を想定する。Emscripten は初めて使う前提で、専門用語はすべて本文または用語集で説明する。

今回の移植では、ブラウザでシングルプレイを動かすことをゴールにした。LAN/UDP/ENet のマルチプレイ、キャンペーンエディタ、WebSocket/WebRTC への通信置換は対象外にした。

Emscripten とは何か

Emscripten は「C/C++ のコードを WebAssembly にコンパイルして、ブラウザで動かすためのツール一式」である。単なるコンパイラではなく、ブラウザの中にミニ OS 環境を作るランタイムまで含んでいるのがポイント。

ネイティブの C プログラムは OS の機能(ファイル読み書き、ウィンドウ、音声出力、スレッド、時計…)を前提に書かれている。ブラウザにはそれらが直接は存在しないので、Emscripten が次のように「翻訳」する。

ネイティブの世界 ブラウザでの実体(Emscripten が変換)
gcc / clang でコンパイル emcc でコンパイル(出力が .wasm になる)
fopen("data.txt") などのファイル IO メモリ上の仮想ファイルシステム(後述の MEMFS / IDBFS)
SDL2 のウィンドウ・描画 <canvas> + WebGL
SDL_mixer の音声出力 Web Audio API
キーボード・マウス入力 DOM イベント → SDL イベントに変換
while ループで回るメインループ requestAnimationFrame に載せ替え(後述)

emcc が生成する4点セット

emcc-o index.html を指定すると、次の4ファイルが生成される。それぞれの役割を知っておくとデバッグが楽になる。

  • index.html — 入口のページ。<canvas> と、ロード進捗表示・Fullscreen ボタンなどが入った「shell」と呼ばれるテンプレート HTML。
  • index.jsローダー兼ランタイム.wasm の fetch とインスタンス化、仮想ファイルシステムの構築、SDL→Web API の橋渡し(JavaScript 側の実装)がすべてここに入っている。数百 KB あるのはこのため。
  • index.wasm — C コードのコンパイル結果本体。ゲームロジックはここ。
  • index.data--preload-file で指定したゲームアセット(画像・音・マップ等)を1ファイルに固めたアーカイブ。起動時に fetch され、メモリ上の仮想ファイルシステムに展開される。

ブラウザでの起動シーケンスは「index.htmlindex.jsindex.wasmindex.data を fetch → 仮想 FS 構築 → C の main() 実行」という流れになる。

用語集

本文に登場する Emscripten / 低レイヤ用語のリファレンス。読み進めて分からない単語が出たらここに戻る。

用語 意味
WebAssembly (wasm) ブラウザで実行できるバイナリ形式。C/C++/Rust などからコンパイルされる。JS より予測可能な性能で、ゲームのロジック部分はこれで動く
emsdk Emscripten SDK。emcc 本体と依存ツール(LLVM、Node.js など)をまとめて管理するインストーラ
emcc Emscripten のコンパイラコマンド。使い方は gcc とほぼ同じ(-c-o-I-D など)で、リンク時に Web 固有のオプション(-s XXX)を足す
-s オプション emcc のリンク時設定。-s USE_SDL=2 のように、ランタイムの挙動やライブラリの選択を指定する
Emscripten port SDL2 や libpng などの有名ライブラリを Emscripten が Web 用にビルド済みで提供する仕組み。-s USE_SDL=2 と書くだけで Web 対応版 SDL2 がリンクされ、ホスト(PC 側)のライブラリは一切使わない
仮想ファイルシステム (FS) Emscripten がブラウザ内に作る POSIX 風のファイルシステム。C コードからは普通に fopen("/graphics/foo.png") で読める
MEMFS 仮想 FS のデフォルト実装。実体はただのメモリ。ページをリロードすると消える
IDBFS IndexedDB を裏に持つ仮想 FS。明示的に同期(FS.syncfs)したタイミングで IndexedDB に永続化される。セーブデータ置き場に使う
FS.syncfs(direction, callback) IDBFS と IndexedDB の同期を行う JS API。syncfs(true, ...) は IndexedDB→メモリ(読み込み)、syncfs(false, ...) はメモリ→IndexedDB(書き込み)。非同期である点に注意
--preload-file 指定したディレクトリを index.data に固めて、起動時に MEMFS へ展開するリンクオプション
メインループ (main loop) ゲームの「入力処理→更新→描画」を毎フレーム繰り返すループ。ブラウザでは C の while ループのままでは動かせない(後述)
emscripten_set_main_loop_arg C の関数を「ブラウザに毎フレーム呼んでもらう」形で登録する Emscripten API。JS の requestAnimationFrame に相当する仕組みへの登録口
ASYNCIFY C の同期的なコード(ブロッキング処理)を、一時停止・再開できる形に変換するリンクオプション。JS で言えば「同期コードを勝手に async/await 化してくれる」イメージ。イベントループを止めずに済む
__EMSCRIPTEN__ Emscripten でコンパイルしたときだけ定義されるマクロ。#ifdef __EMSCRIPTEN__ でブラウザ専用/除外コードを分岐する
EM_ASM C コードの中に JavaScript を直接埋め込むマクロ。EM_ASM( console.log('hi'); ); のように書ける。C→JS 呼び出しの最短手段
Module Emscripten ランタイムの設定・状態を持つ JS のグローバルオブジェクト。shell HTML はこれを通じて canvas やステータス表示をランタイムに渡す
shell (HTML) emcc -o index.html が生成するデフォルトのページテンプレート
SAFE_HEAP メモリアクセスを実行時チェックするデバッグオプション。不正な番地・アラインメント違反を、その場でエラーにしてくれる
ASSERTIONS Emscripten ランタイム内部の整合性チェックを有効化するデバッグオプション
source map .wasm の命令位置と C のソース行を対応付けるファイル。ブラウザの DevTools でスタックトレースが vswap.c:53 のように C の行番号で出るようになる
アラインメント (alignment) 「4バイトの整数は4の倍数のアドレスから読む」というメモリ配置の決まり。詳細は該当章で説明
packed struct #pragma pack(1) で詰め物(パディング)を除去した構造体。ファイルのバイナリレイアウトと一致させるために使われるが、アラインメント違反の温床になる

まず調査すること

最初にやるべきことは、いきなり emcc で全部をビルドすることではない。まず、元コードがブラウザに持ち込める形をしているかを確認する。

  1. 元コードのバージョンを固定する。

    • 今回は C-Dogs SDL 2.4.0 を対象にした。
    • 移植中は upstream の最新版を追い続けない。まず 1 つのタグやコミットに固定し、動く baseline を作る。
  2. 既存の Web/Emscripten 対応を探す。

    • rg -n "EMSCRIPTEN|emscripten|IDBFS|syncfs|set_main_loop|SDL_Delay" src
    • 今回の upstream には make_emscripten.sh__EMSCRIPTEN__ 分岐、IDBFS の初期化コード(セーブデータを IndexedDB に置く仕組み。後述)、ブラウザ用メインループへの対応(後述)が既に一部あった。
    • 既存コードがある場合でも、そのまま現在の emsdk で動くとは限らない。依存ツール、SDL port 指定、メモリ/スタック設定、CMake 要件が古いことがある。
  3. ネイティブ前提の依存を分類する。

    • SDL2 / SDL_image / SDL_mixer: Emscripten port で代替できる(=Web 対応版が公式に用意されている)。
    • OpenGL / editor / native UI: ブラウザ版の対象外ならビルドしない。
    • UDP / LAN scan / raw socket / ENet: ブラウザは生の UDP ソケットを一切開けないため、そのままでは動かない。今回は UI から隠して起動しない方針にした。
    • ファイルシステム: 読み込み専用のゲームデータは preload(index.data に固める)、設定やセーブは IDBFS(IndexedDB 永続化)に分ける。
  4. ビルドシステムが何を生成しているかを見る。

    • CMake や configure が本当に必要なのか、単に config.h の生成だけなのかを確認する。
    • 今回は upstream の cmake . が host SDL2、OpenGL、editor、ENet の CMake 要件を引き込んだため、CMake 全体を通すのではなく sys_config.h と YAJL header だけを生成する専用スクリプトにした。
  5. ブラウザに出す入出力を洗い出す。

    • 入力: キーボード、マウス、ゲームパッド、ポインタロック、フルスクリーン。
    • 出力: SDL2 の canvas 描画、SDL_mixer の Web Audio 出力、ログ、ブラウザ console。
    • 永続化: config、player template、score、autosave。
    • 配布物: index.html, index.js, index.wasm, index.data, favicon, OGP 画像。

移植方針

基本方針は「Web 専用の別ゲームを作らない」こと。元コードの大部分はそのまま使い、ブラウザで成立しない境界だけを __EMSCRIPTEN__(Emscripten ビルド時のみ有効になる #ifdef 分岐)で切り替える。

元コードに手を入れる必要はある。ただし、入れる場所は限定する。

  • 必要な変更:

    • ブラウザ main loop への接続。
    • ブロッキング delay(処理を止めて待つ SDL_Delay など)の削減。
    • UDP/マルチプレイ導線の無効化。
    • config/save の保存先を IDBFS に向ける。
    • wasm で顕在化する未定義動作、特にアラインメント違反の修正。
    • native linker では通っていた重複定義などの修正。
  • 避ける変更:

    • ゲームロジックを Web 用に大きく分岐する。
    • SDL2 を直接 DOM API に置き換える。
    • ネイティブ版の挙動まで変える。
    • 動かす前に asset 分割や UI 作り込みへ進む。

最初の milestone は「メインメニューが表示される」こと。次に「シングルプレイが開始できる」、その次に「音が鳴る」「保存が残る」「フルスクリーンや配信パスが壊れない」を確認する。

ビルド環境を作る

Emscripten のインストールは emsdk で行う。emsdk は emcc 本体と、その依存(LLVM、Node.js)をバージョン管理付きでまとめて入れてくれるインストーラで、nvm の Emscripten 版のようなものだと思えばよい。

今回の作業では、ホスト環境が古いことが問題になった。Emscripten 6 は Python 3.10+ を要求し(emcc の実体は Python スクリプト)、upstream の CMake 周辺も新しめの CMake を要求する。システム全体を更新するのではなく、リポジトリ配下の tools/ にローカルインストールして閉じ込めた。

確認した構成:

  • Emscripten: 6.0.3
  • SDL: Emscripten port の SDL 2.32.10
  • SDL_mixer: Emscripten port の SDL_mixer 2.8.0
  • CMake: 3.29.6
  • Python: 3.10.18

セットアップ例:

mkdir -p tools

curl -fsSL \
  https://github.com/Kitware/CMake/releases/download/v3.29.6/cmake-3.29.6-linux-x86_64.tar.gz \
  -o tools/cmake-3.29.6-linux-x86_64.tar.gz
tar -xzf tools/cmake-3.29.6-linux-x86_64.tar.gz -C tools

git clone https://github.com/emscripten-core/emsdk.git tools/emsdk
cd tools/emsdk
./emsdk install latest
./emsdk activate latest

cd ../..
mkdir -p tools/python-shim
ln -sf /usr/bin/python3.10 tools/python-shim/python3

python-shim は小技: emcc は PATH 上の python3 を使うので、python3.10 へのシンボリックリンクだけ入れたディレクトリを PATH の先頭に足せば、システムの Python を触らずに要件を満たせる。

build_wasm.sh では次のように toolchain を明示する(emsdk_env.sh を source すると emcc が PATH に入る)。

source "$ROOT_DIR/tools/emsdk/emsdk_env.sh" >/dev/null
export PATH="$ROOT_DIR/tools/python-shim:$ROOT_DIR/tools/cmake-3.29.6-linux-x86_64/bin:$PATH"

なぜ専用ビルドスクリプトにしたか

upstream の make_emscripten.shcmake . から始まる。しかし、その configure はブラウザビルドに不要な host SDL2、OpenGL、editor target、ENet の CMake 要件まで見に行く。Emscripten では SDL2/SDL_image/SDL_mixer は Emscripten port(Web 対応版のビルド済みライブラリ)を使うため、PC にインストールされた SDL を探す必要がそもそもない

今回の build_wasm.sh は CMake を丸ごと使わず、次だけを直接行う。

  1. sys_config.h.cmake から src/cdogs/sys_config.h を生成する。

    • version は 2.4.0
    • data dir は ./(仮想 FS のルート直下にアセットが展開されるため)。
    • config dir は空にして、実際の保存先は Emscripten 側の /persistent_data に寄せる。
  2. YAJL の public header を build include に配置し、yajl_version.h を生成する。

  3. 必要な C ソースを find で集めて emcc -c する(gcc -c と同じ感覚で、1ファイルずつオブジェクトファイルにコンパイル)。

    • src/*.c
    • src/base64/*.c ただし main.c は除外
    • src/cdogs/**/*.c
    • src/json/*.c
    • src/proto/*.c
    • src/proto/nanopb/*.c
  4. Emscripten port と asset preload を指定して link する。

重要な compile/link flag とその意味

-D "PB_FIELD_16BIT=1"              # nanopb の設定マクロ(Emscripten 固有ではない)
-s USE_SDL=2                       # SDL2 の Emscripten port を使う
-s USE_SDL_IMAGE=2                 # SDL2_image の port を使う
-s SDL2_IMAGE_FORMATS='["png"]'    # 画像デコーダは PNG のみ組み込む(サイズ削減)
-s USE_SDL_MIXER=2                 # SDL2_mixer の port を使う
-s SDL2_MIXER_FORMATS='["ogg"]'    # 音声フォーマットは OGG のみ
-s USE_VORBIS=1                    # OGG 再生に必要な vorbis デコーダ
-s USE_OGG=1                       # 同じく ogg コンテナ
-lidbfs.js                         # IDBFS(IndexedDB 永続化 FS)をリンクする
-s ASYNCIFY                        # ブロッキングコードをイベントループに返せるよう変換
-s STACK_SIZE=2097152              # C のスタックを 2MB に拡張(デフォルトは 64KB しかない)
-s INITIAL_MEMORY=128mb            # wasm の初期メモリ。ゲーム+アセット展開分の余裕を持たせる

特に注意すべきは STACK_SIZEネイティブのスレッドスタックは通常 8MB 程度あるが、Emscripten のデフォルトは 64KB しかない。ネイティブで普通に動いていたコードが、ブラウザでだけスタックオーバーフローで落ちる原因になる(実際に落ちた。デバッグ章参照)。

debug build では次を足す。

-O0                        # 最適化オフ(変数やスタックトレースが素直になる)
-gsource-map               # source map を生成(C の行番号で stack trace が出る)
--source-map-base ./       # source map の参照パス。サブディレクトリ配信でも解決できるよう相対に
-s ASSERTIONS=2            # ランタイム内部の整合性チェック
-s SAFE_HEAP=1             # すべてのメモリアクセスを実行時チェック
-s STACK_OVERFLOW_CHECK=2  # スタックオーバーフロー検出

SAFE_HEAP=1 は実行が目に見えて遅くなるが、wasm 移植では最重要のデバッグオプション。後述の packed struct アラインメントバグは、これを有効にしていたおかげで「どのソース行が悪いか」まで特定できた。

Asset をブラウザへ載せる

ブラウザの JavaScript にはローカルファイルを直接読む手段がない。Emscripten はこれを解決するために、ブラウザのメモリ上に POSIX 風の仮想ファイルシステムを作る。C コード側は今まで通り fopen("/graphics/foo.png", "rb") と書くだけでよく、Emscripten ランタイムがメモリ上のデータを返す。

ゲームデータをこの仮想 FS に載せる一番簡単な方法が --preload-file で、指定ディレクトリをビルド時に index.data という1つのアーカイブに固め、起動時に fetch してメモリ上(MEMFS)に展開する。

--preload-file data
--preload-file doc
--preload-file dogfights
--preload-file graphics
--preload-file missions
--preload-file music
--preload-file sounds

sys_config.h の data dir を ./ にしているため、ゲーム側は /graphics, /missions, /music のような通常のパスで読める。

今回の index.data は約 94MB。最初は大きくてもよい。まず正しく動かし、その後で editor-only asset、不要 docs、未使用音源、追加 campaign などを削る。最初から lazy load や分割配信を設計すると、「ファイルが無くて落ちているのか、移植バグで落ちているのか」の切り分けが難しくなる。

保存データをブラウザへ載せる

MEMFS はただのメモリなので、ページをリロードするとすべて消える。設定やセーブデータのように残したいものは、IndexedDB を裏に持つ IDBFS に置く。

IDBFS の考え方はフロントエンドエンジニアには馴染みやすい:

  • C コードから見ると /persistent_data という普通のディレクトリ。
  • 実体はメモリ上(MEMFS と同じ)だが、FS.syncfs() を呼んだタイミングで IndexedDB と同期される
  • FS.syncfs(true, cb) = IndexedDB → メモリ(起動時の読み戻し)。
  • FS.syncfs(false, cb) = メモリ → IndexedDB(保存後の書き出し)。
  • localStorage と違い同期は非同期で、コールバックで完了を知る。

今回の元コード側では、__EMSCRIPTEN__ のとき config dir を /persistent_data/ にした。

#ifdef __EMSCRIPTEN__
return "/persistent_data/";
#endif

起動時に IDBFS を mount し、IndexedDB から読み戻す。ここで使っている EM_ASM(...) は「C コードの中に JavaScript をそのまま書ける」Emscripten のマクロで、中身は見ての通りただの JS である。

#ifdef __EMSCRIPTEN__
EM_ASM(
  FS.mkdir('/persistent_data');                     // 仮想FSにディレクトリ作成
  FS.mount(IDBFS, {}, '/persistent_data');          // そこに IDBFS をマウント
  FS.syncfs(true, function(err) { assert(!err); }); // IndexedDB から読み戻し
);
#endif

保存後は FS.syncfs(false, ...) を呼んで IndexedDB へ書き戻す。今回のコードでは config、autosave、player template、YAJL 経由の保存処理など複数箇所から sync する。

注意点:

  • FS.syncfs は非同期。C 側の保存関数が return した時点では、まだ IndexedDB に書き終わっていない。保存直後にブラウザを閉じるケースを考慮する。
  • 複数箇所から同時に呼ぶと N FS.syncfs operations in flight 警告が出る。今回は非致命として残したが、仕上げでは debounce またはキュー化する。
  • IndexedDB は origin(プロトコル+ドメイン+ポート)ごとに分離される。配信 URL が変わると別の保存領域になり、ユーザーから見るとセーブデータが消えたように見える。

Main loop をブラウザへつなぐ

ここが Emscripten 移植の最重要ポイント。

ネイティブゲームは典型的に次のような無限ループで動く。

while (running) {
    poll_input();
    update();
    draw();
    delay();  // 次のフレームまで待つ
}

一方ブラウザの JavaScript はシングルスレッドのイベントループで動いている。wasm のコードも同じスレッドで実行されるため、C の while ループを回しっぱなしにすると JS で while(true){} を書いたのと同じ状態になる: 画面は更新されず、クリックも効かず、タブが「応答なし」になる。

そこで Emscripten では「ループを自分で回す」のをやめ、「1フレーム分の処理を関数にして、ブラウザに毎フレーム呼んでもらう」形に変える。これが emscripten_set_main_loop_arg で、JS の requestAnimationFrame(callback) を C から使うための API だと思えばよい。

#ifdef __EMSCRIPTEN__
emscripten_set_main_loop_arg(EmscriptenMainLoop, &ctx, 0, 1);
#else
while (!ctx.done)
{
    GameLoopStep(&ctx);
}
#endif

引数の意味:

  • 第1引数: 毎フレーム呼ばれる関数(1フレーム分の poll→update→draw を行う)。
  • 第2引数: その関数に渡すポインタ(JS のコールバックに this 相当を渡すイメージ)。
  • 第3引数 = FPS 指定。0 にすると requestAnimationFrame ベースになり、ディスプレイのリフレッシュに同期する。正の値を入れると setTimeout ベースになり、ブラウザから警告が出るうえフレームペーシングも悪い。基本は 0 一択。
  • 第4引数 = 1 にすると、この呼び出しから後ろへ処理を返さない(ループ登録して即座に制御をブラウザに返す)。

また、loading screen などで SDL_Delay(70)(70ms 間スレッドを止めて待つ関数)を呼んでいた箇所は __EMSCRIPTEN__ で skip した。ネイティブでは無害な短い delay でも、ブラウザではイベントループを 70ms 止めることになり、見た目以上に悪さをする。

#ifndef __EMSCRIPTEN__
SDL_Delay(70);
#endif

ASYNCIFY について

とはいえ、既存の C コードから「処理を止めて待つ」パターンを完全に排除するのは大変で、アセットのロード待ちやファイル同期待ちなどが残る。そこで -s ASYNCIFY を使う。

ASYNCIFY は、C の同期コードを一時停止・再開できる形にコンパイル時変換する仕組み。ブロッキングする箇所に来ると、C の実行状態(コールスタック)を保存してブラウザのイベントループに制御を返し、条件が整ったら保存した状態から再開する。JS で言えば「同期関数を自動で async/await に書き換えてくれる」ようなもの。

ただし ASYNCIFY は万能ではない。バイナリサイズと実行コストが増えるし、「永久ループや長時間 delay を放置してよい」という意味でもない。あくまで残ってしまったブロッキングパターンの救済策と考える。

入力をブラウザへつなぐ

SDL2 のキーボード、マウス、ゲームパッド入力は Emscripten の SDL2 port が DOM イベント(keydown / mousemove など)から SDL イベントへ変換してくれる。ゲーム側は原則として既存の SDL_PollEvent 経路をそのまま維持すればよい。

移植時に確認すること:

  • 初回クリック後に canvas が focus を持つか(focus がないとキー入力が canvas に届かない)。
  • キーボード操作でメニューを移動できるか。
  • mouse capture / pointer lock(マウスカーソルを消して相対移動だけ取る API)を使うなら、user gesture(クリック等のユーザー操作)の中で呼んでいるか。ブラウザのセキュリティ制約で、ユーザー操作起点でないと拒否される。
  • フルスクリーン切替が SDL2 port と競合しないか(下記の実例参照)。
  • mobile/touch は別タスクに切る。C-Dogs SDL のようなキーボード前提ゲームでは、まず desktop browser を成立させる。

実例: フルスクリーンで黒画面になるバグ

今回踏んだ中で一番「Emscripten らしい」バグなので詳しく書く。

症状: shell HTML の Fullscreen ボタンを押すと画面が真っ黒になる(音は鳴り続ける)。フルスクリーンを解除すると canvas ごと見えなくなる。

原因: shell の Fullscreen ボタンは Module.requestFullscreen() を呼び、Emscripten ランタイム内部の Browser.updateCanvasDimensions() という canvas リサイズ処理に入る。この処理は canvas.widthNative という独自プロパティを参照するが、SDL2 port は canvas サイズを自前で管理していてこのプロパティを設定しない。結果、undefined を使った計算で canvas.width = NaN となり、canvas に NaN を代入すると 0 に丸められるため canvas が 0x0 になる。解除時も同じ計算を通るので 0x0 のまま戻らない。

つまり「shell の Fullscreen ボタン」と「SDL2 port」は組み合わせが悪い。ランタイムのコードは直せない(生成物なので)ため、Emscripten の経路を使わず、素の Fullscreen API を canvas に直接使うよう HTML を後処理した。

<style>
#canvas:fullscreen {
  width: 100%;
  height: 100%;
  object-fit: contain;      /* アスペクト比を保ってレターボックス表示 */
  background: #000;
  image-rendering: pixelated; /* ドット絵をぼかさず拡大 */
}
</style>
<script>
addEventListener('load', () => {
  const canvas = document.getElementById('canvas');
  const btn = document.querySelector('input[value=Fullscreen]');
  if (!canvas || !btn) return;
  btn.onclick = () => {
    canvas.requestFullscreen().then(() => {
      const lock = document.getElementById('pointerLock');
      if (lock && lock.checked && canvas.requestPointerLock) {
        canvas.requestPointerLock();
      }
    }).catch(e => console.error('fullscreen failed:', e));
  };
});
</script>

ポイントは、canvas の描画バッファ(canvas.width/height、ここでは 640x480)には一切触らず、CSS だけで拡大表示すること。フルスクリーン解除時の復元もブラウザが勝手にやってくれる。SDL2 port はマウス座標を CSS サイズとの比率で変換してくれるので、クリック座標もずれない。

出力をブラウザへつなぐ

映像

SDL2 renderer は Emscripten port により WebGL/canvas に出る。ゲーム側で直接 DOM を触る必要はない。確認するのは次の点。

  • dist/index.html を HTTP で配信しているか。file:// で直接開いても動かない.wasm.data の fetch がブロックされる)。
  • canvas が表示されるか。
  • loading screen が出るか。
  • menu と gameplay の両方で画面更新されるか。
  • フルスクリーン後に canvas サイズが壊れないか。

音声

SDL_mixer は Emscripten port の Web Audio 経由で動く。今回の build では OGG を使うため、link 時に SDL_mixer formats と ogg/vorbis を指定した。

-s USE_SDL_MIXER=2
-s SDL2_MIXER_FORMATS='["ogg"]'
-s USE_VORBIS=1
-s USE_OGG=1

ブラウザには autoplay 制限(ユーザー操作より前に音を出せない制約)がある。音が鳴らない場合は、初回クリックやキー入力後に AudioContext が resume されているかを見る。今回の作業では SDL_mixer 初期化とメニュー到達を確認し、ScriptProcessorNode is deprecated の warning は Emscripten/SDL 内部由来の非致命 warning として扱った。

ログ

C の printf やロギングは、そのままブラウザの console に出る(shell HTML では画面下の textarea にも出る)。起動で詰まるときは、元コードに一時的な checkpoint log を入れる価値がある。

今回入れた観測点:

  • font loading 前後
  • loading screen 初期化
  • picture loading
  • data dir / config dir
  • campaign load

ただし、動作確認後はログを削るか debug 限定にする。

元コードへ実際に入れた変更

今回の元コード側の変更は、おおむね次のカテゴリ。

Browser main loop

  • src/game_loop.c

    • emscripten_set_main_loop_arg(EmscriptenMainLoop, &ctx, 0, 1) を使う。
    • frame rate 0 にして requestAnimationFrame に乗せる。
  • src/loading_screens.c

    • wasm では SDL_Delay(70) を skip。

Multiplayer を隠す

  • src/mainmenu.c

    • Join LAN game__EMSCRIPTEN__ では表示しない。
  • src/prep.c

    • StartServer options を __EMSCRIPTEN__ では表示しない。
    • wasm では server startup へ進まない。

方針として、ENet コードを完全削除するより、まずは compile 対象に残して UI/起動導線を塞いだ。これにより upstream との差分が小さく、シングルプレイの bring-up に集中できる。

IDBFS persistence

  • src/cdogs.c

    • 起動時に /persistent_data を作成、IDBFS mount、初期 FS.syncfs(true)
  • src/cdogs/files.c

    • Emscripten では config dir を /persistent_data/ にする。
  • src/autosave.c

  • src/cdogs/player_template.c

  • src/cdogs/config_json.c

  • src/cdogs/yajl_utils.c

    • 保存後に FS.syncfs(false, ...)

Packed struct / アラインメント違反の修正

wasm 移植で一番重要だったバグ群。まず前提知識から。

アラインメントとは: CPU がメモリから 4 バイト整数(uint32_t)を読むとき、「4 の倍数のアドレスから読む」ことが期待されている。これをアラインメント(整列)と言う。x86 系 CPU はズレたアドレス(unaligned)からの読み出しも黙って処理してくれるが、C 言語の仕様上は未定義動作であり、WebAssembly + SAFE_HEAP では実行時エラー(alignment fault)になる。

なぜゲームのファイル読み込みで起きるか: 古いゲームのバイナリファイル形式は 1 バイト単位で詰まっており、C 側では #pragma pack(1) でパディングを除去した構造体(packed struct)をファイルのバイト列にそのまま重ねて読むのが常套手段だった。例えば 6 バイトのヘッダの直後に uint32_t の配列が続くフォーマットでは:

ファイル先頭からのオフセット:  0 1 2 3 4 5 | 6 7 8 9 | ...
                              ヘッダ(6byte) | uint32_t | uint32_t ...
                                             ↑ オフセット6 = 4の倍数でない!

この「オフセット 6」のアドレスを uint32_t * にキャストして読むと、x86 では動くが wasm では alignment fault で abort する。

典型パターン:

  • file buffer 上の #pragma pack(1) struct を typed pointer として読む。
  • 6 byte header の直後を uint32_t * に cast して読む。
  • packed struct の uint16_t / uint32_t field を直接参照する。

対策: unaligned の可能性があるアドレスを直接 dereference(ポインタ経由で読む)しない。必ず memcpy でアラインされたローカル変数へコピーしてから使う。memcpy はバイト単位で動くのでアラインメントの制約がない。

static uint32_t ReadU32(const void *p)
{
    uint32_t v;
    memcpy(&v, p, sizeof v);  /* バイト単位コピーなので安全 */
    return v;
}

今回の変更箇所:

  • src/cdogs/cwolfmap/cwolfmap.h

    • CWLevelGetWidth
    • CWLevelGetHeight
  • src/cdogs/cwolfmap/cwolfmap.c

    • MapHeadMagic
    • MapHeadPtrAt
    • LevelLenAt
    • LevelOffAt
    • packed level header の field access を accessor 化。
  • src/cdogs/cwolfmap/vswap.c

    • VSWAP chunk offset / length table を aligned allocation に deep-copy。
  • src/cdogs/cwolfmap/audio.c

    • packed uint16_t music value を memcpy helper で読む。
  • src/cdogs/map_wolf.c

    • level->header.width/height 直接参照を CWLevelGetWidth/Height に置換。

これは wasm 専用 hack ではなく、C として正しい修正。strict alignment の他 platform にも効く。

Linker で出た重複定義

  • src/cdogs/cwolfmap/wad/waderrno.h
  • src/cdogs/cwolfmap/wad/waderrno.c

header に int waderrno; があり、複数の .c ファイルで同じ変数が定義される状態だった。ネイティブの linker はこれを「common symbol」として黙認することがあるが、Emscripten の linker では duplicate symbol エラーになる。

修正は C の作法どおり: header は extern int waderrno;(宣言のみ)にし、.c ファイル1つに実体定義を置く。

Debug の進め方

移植中は release build で粘らない。まず debug build を使う。

WASM_DEBUG=1 ./build_wasm.sh
python3 -m http.server 8081 --directory dist

ブラウザで開く:

http://127.0.0.1:8081/

debug build では source map が生成されるため、DevTools のスタックトレースが index.wasm:0x3258 のような wasm オフセットではなく vswap.c:53:24 のような C のソース行で表示される。この差は決定的に大きい。

毎回 hard reload する。ブラウザは index.js, index.wasm, index.data を強く cache する。修正したのに stack trace が古い、source map が効かない、wasm offset しか出ない、というときは cache が原因のことが多い。

よく出た問題と対処:

症状 原因と対処
Aborted(stack overflow ...) Emscripten のデフォルトスタック(64KB)が小さすぎる。-s STACK_SIZE=2097152 にする
Aborted(alignment fault) packed struct / raw file buffer / typed pointer cast を探し、memcpy accessor に置き換える(前章参照)
Cannot set timing mode for main loop since a main loop does not exist SDL renderer 初期化中の warning。今回の範囲では非致命
ScriptProcessorNode is deprecated SDL/Emscripten の Web Audio 実装内部由来。非致命
N FS.syncfs operations in flight 複数の保存経路から同時に IndexedDB 同期している。非致命だが、仕上げで debounce する
フルスクリーンで黒画面、解除しても戻らない shell の Fullscreen 経路が SDL2 port と非互換(入力章の実例参照)。canvas に直接 requestFullscreen() し、CSS で拡大する
音が出ない autoplay 制限。初回のユーザー操作後に AudioContext が resume されているか確認
修正が反映されない / stack trace が古い ブラウザキャッシュ。hard reload(Ctrl+Shift+R)する

配布物と配置

現在の build は dist/ 直下に出す。

dist/index.html
dist/index.js
dist/index.wasm
dist/index.data
dist/index.wasm.map  # debug build のみ
dist/favicon.ico
dist/og.jpg

すべて静的ファイルなので、配信はどの静的ホスティングでもよい(今回は GitHub Pages + gh-pages パッケージ)。

サブディレクトリ配信(https://example.com/game/ のような配置)を想定し、参照は相対パスにする。Emscripten が生成するローダーはもともと index.wasm / index.data をスクリプト位置基準の相対で解決するので、追加で注意するのは次の2点:

  • debug build の source map は --source-map-base ./ にする(/ だとドメイン直下を見に行く)。
  • favicon はブラウザがドメイン直下の /favicon.ico を見に行きがちなので、生成 HTML に明示的な相対 link を注入した。
sed -i 's|</title>|</title>\n    <link rel="icon" href="favicon.ico">|' "$DIST_DIR/index.html"

OGP の og:image は逆に、SNS のクローラーが相対パスを解決しないため、公開 URL の絶対 URL を使う。

作業順序の推奨

  1. upstream の対象 tag/commit を固定する。
  2. rg で既存の Emscripten 対応、filesystem、delay、network、SDL 初期化を調査する。
  3. host toolchain の古さを確認する。
  4. emsdk、Python 3.10+、CMake をローカルに用意する。
  5. 既存の Emscripten build script があればその失敗ログを取る。
  6. CMake/configure が本当に必要か分解する。
  7. wasm 専用の最小 build script を作る。
  8. asset はまず全部 preload して、missing file 問題を消す。
  9. WASM_DEBUG=1 で source map、SAFE_HEAP、stack check を有効にする。
  10. メインメニュー到達まで直す。
  11. シングルプレイ開始まで直す。
  12. 音声初期化と BGM/SE を確認する。
  13. config/save を IDBFS に載せ、reload 後に残るか確認する。
  14. fullscreen、resize、subdirectory hosting を確認する。
  15. 不要ログ削除、syncfs の debounce、asset 削減を後工程で行う。

完了条件

最低限の完了条件:

  • ./build_wasm.shdist/ が生成される。
  • HTTP server 経由で dist/index.html が起動する。
  • canvas に SDL2 video が表示される。
  • main menu に到達する。
  • multiplayer の join/host 導線が Web 版に出ない。
  • Quick Play または標準 campaign を開始できる。
  • SDL_mixer が初期化され、効果音または BGM が再生できる。
  • config や player template が reload 後も残る。
  • debug build で source map が効く。
  • 既知制限と build 手順が文書化されている。

参考リンク

今回の root リポジトリでは、詳しい現状メモは docs/wasm-port.md(英語)、構成図は docs/architecture.md(英語)、実際の build 手順は build_wasm.sh にある。この文書は、それらをもとに移植担当者が最初から同じ道を辿れるように、前提知識・調査順・判断基準・元コードへの介入範囲を日本語でまとめたもの。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment