【お知らせ】プログラミング記事の投稿はQiitaに移行しました。

MIZU GAME for NetWalker

id:fslashtさんのMIZU GAMEC++に移植して、Interix上のクロス開発環境(id:n7shi:20091212)でNetWalker用にビルドしました。バイナリ(NetWalker/Windows)とソース(gcc/VC)を置いておきます。

実機がないため原作者のid:fslashtさんに動作確認をお願いしたところ、無事に動作したとのことです。

MIZU GAME for NetWalker動かしてみました!バッチリ動いてますよ〜。たださすがに性能不足でFPSは半分くらい・BGMが途切れがちです。とはいえ動作自体は完全移植ですね。マップエディターも動くとは。SDLUtils.cpp/hでSDL.NETとの差異を吸収ですか

【追記】id:fslasht:20091213にスクリーンショット付きでご紹介いただきました。

動機

id:fslashtさんのARM Forum 2009のレポート(id:fslasht:20091110)を読んで、今後ARM搭載デバイスが色々登場すれば面白そうだと思いました。スマートフォン以外でもARM搭載の非WindowsNetBookが出てきそうです。この手のマシンとして先陣を切ったNetWalkerをリファレンスマシンに見立てて、開発手法を確立してみたくなりました。

NetWalkerUbuntuのためクロス開発もUbuntu上で行えば簡単ですが、Ubuntuを常用していなければ敷居が高いです。Windows上でクロス開発するため、開発用パッケージを集めて(id:n7shi:20091210)、クロスコンパイラを用意(id:n7shi:20091212)しました。

せっかく環境を構築しても、実際にビルドしないと面白くありません。そこできっかけを頂いたid:fslashtさんの作品を移植してみました。将来的にはC#のネイティブコンパイラを作りたいとも考えているため、一度は手作業でのネイティブ化を経験しておこうとも思いました。そのためNetWalkerでmonoを動かせば済むという突っ込みはなしですw

常に動作確認

当初はC#のクラスをひとつずつC++/CLIに書き換えて、常に動作確認をしながら移植しようと考えていました。C#C++/CLIは基本的に同じILを別の表記にしたものなので、機械的に書き換えられます。

常に動作確認する方針の重要性についてはid:aoisomeさんの素晴らしい記事があります。

移植手順

少しずつ書き換える方針により、以下のステップを踏みました。

  1. コンパイル
  2. Managed C++
  3. SDL.NETの軽量化
  4. SDL.NETの変換
  5. SDL.NETの取り外し
  6. ネイティブ化
  7. バグ修正

ステップが多いのは、MIZU GAME本体だけでなくSDL.NETもどうにかしないといけなかったためです。

1. 逆コンパイル

C#からC++/CLIへの書き換えに着手したところ、クラス間の循環参照が多いため、ある程度一気に移植しなければ動作確認できないことに気付きました。一気に移植してから問題箇所を特定するような作業はid:n7shi:20091011で懲りていたため、別の方法に切り替えました。

C#C++/CLIはILを媒介に変換できるため、C#のバイナリを逆コンパイラC++/CLIにできそうです。コメントやローカル変数名が失われますが、元のソースがあるので必要に応じて復元すれば良いと割り切りました。

2. Managed C++

.NETの逆コンパイラとして有名な.NET Reflectorを試したところ、C++/CLIはサポートされていませんでした。Managed C++(以下MC++)はサポートされていたため、C++/CLIではなくMC++に変換しました。

生成されたコードは宣言と定義が一緒になった.cppファイルだけで、そのままではコンパイルが通りません。クラス間の循環参照があるため、Visual Studioのフォームデザイナのように全部ヘッダに書くという荒技も使えません。

仕方ないので手動で.hと.cppに分離しました。さすがに機械的に変換しただけあって、一部変換が不完全な部分を手直ししただけで動きました。

3. SDL.NETの軽量化

MIZU GAME本体だけでなく、参照しているSDL.NETも何とかしないといけません。SDL.NETはSDLの単純なラッパーではなく、クラスライブラリとして使いやすいように再構築されているため、単純に生のSDLを使うコードに書き換えることができません。

SDL.NETも逆コンパイラで変換できますが、そのままでは巨大過ぎて変換後の手直しが大変です。変換の前段階として使っていないクラスやメソッドを削りました。

4. SDL.NETの変換

必要最低限に軽量化したSDL.NETをReflectorでMC++に変換して、ビルドが通るように修正しました。

SDL.NETはP/InvokeラッパーのTao.Sdlを通してSDLを呼んでいます。Tao.Sdlは単純にP/InvokeSDLをラップしただけのもので、クラス名を取り除くだけで直接SDLを呼べるようになりました。

C++からSDLを呼ぶにはヘッダとインポートライブラリが必要のため、上記アーカイブに同梱して相対パスで参照しています。別途SDLを用意してパスを通す手間はありません。

5. SDL.NETの取り外し

SDL.NETからSDLを呼んでいるコードをMIZU GAME本体に移して、必要なくなったSDL.NETのコードを削りながら、最終的にSDL.NETを完全に取り外しました。

bitbltや文字列描画など直接SDLを呼び出すと冗長になる部分は関数を作って、id:fslashtさんのご指摘通りSDLUtils.cppにまとめました。

6. ネイティブ化

コードがMIZU GAME本体だけになったので、MC++をC++に書き換えれば完成です。__gcを外していくだけの簡単なお仕事です。

C++化に伴いGCがなくなります。MIZU GAMEではオブジェクトの所有権が明確に構造化されて寿命がはっきりしていたため、自動変数をデストラクタで解放するRAIIの手法で対処できました。もしインスタンスをあちこち参照で取り回していたら、スマートポインタを持ち出す必要があったかもしれません。

ネイティブ化により、WindowsではVC++だけでなくgccでもビルドして動くようになりました。

7. バグ修正

ARMのクロスコンパイラでもビルドできたので、これで完成かと思いました。しかし念のため手元にあったFreeBSDで動作確認したところ、問題が発生しました。

  1. 文字が1文字しか描画されない(ワイド文字問題)
  2. タイトルからゲームに移るときに落ちる(ゼロ除算問題)

今までは機械的な変換作業だけで順調でしたが、ここに来て初めて移植に伴うバグに遭遇しました。ちなみにオリジナルのC#版はFreeBSDでもmonoで動きます。

ワイド文字問題

SDL_ttfの描画関数TTF_RenderUNICODE_Blended()は文字列として16bit整数型の配列を要求します。

当初はWindows上で作業していたため、単純にL"ABC"のようにワイド文字列を渡していました。しかしFreeBSDではwchar_tが32bitのため、単純にワイド文字列を渡すと1文字目の上位ワードが'\0'として解釈され、最初の文字しか表示されません。

gccのオプション(-fshort-wchar)でwchar_tを16bitにしたところ、文字列のフォーマットに使用していたstd::wostringstreamで落ちるようになりました。libstdc++がビルドされたときのwchar_tのサイズと矛盾するのが原因です。std::basic_ostringstreamでも改善しなかったので、オプションでの対処は諦めました。

MIZU GAMEで使用する文字はUnicode基本多言語面に収まっているため、単純にwchar_tの配列を16bitの配列にコピーすることで対応しました。念のため基本多言語面に収まらない文字は'?'で置換しています。

#ifdef WIN32
	SDL_Surface *src = TTF_RenderUNICODE_Blended(font, (const Uint16 *)text, c);
#else
	int len = wcslen(text);
	Uint16 *buf = new Uint16[len + 1];
	for (int i = 0; i < len; i++)
	{
		unsigned int ch = text[i];
		buf[i] = ch > 0xffff ? '?' : ch;
	}
	buf[len] = 0;
	SDL_Surface *src = TTF_RenderUNICODE_Blended(font, buf, c);
	delete [] buf;
#endif

このコードではワイド文字列がUnicodeではない環境で問題が起きますが、今はWindowsFreeBSDNetWalkerで動けば良いので、気にしないことにします・・・。

ゼロ除算問題

gdbで追ったところ、SDL_mixerのMix_PlayMusic()内でSIGFPEが発生していました。試しにWineでWin32バイナリを実行するとDivide by zeroが発生しました。

余談ですが、Wineで落ちた後に起動するデバッガではPEとELFが混ざってとんでもないことになります。 ⇒ スクリーンショット

それでゼロ除算ですが、実は心当たりがありました。SDL.NETを取り外すときに例外処理は全部無視しましたが、Audio/Music.csに以下の記述があったのです。

public void Play(int numberOfTimes)
{
    try
    {
        MusicPlayer.CurrentMusic = this;
        MusicPlayer.Play(numberOfTimes);
    }
    catch (DivideByZeroException)
    {
        // Linux audio problem
    }
}

※MusicPlayer.Play()でMix_PlayMusic()が呼ばれています。

SDL.NETでも諦めているくらいなので、原因は調査しないで無視することにしました。

#ifndef WIN32
static jmp_buf jbuf;
static void handler(int n) { longjmp(jbuf, 1); }
#endif

int SPlayMusic(Mix_Music *music, int loops)
{
#ifdef WIN32
	return Mix_PlayMusic(music, loops);
#else
	struct sigaction sig;
	memset(&sig, 0, sizeof(sig));
	sig.sa_handler = &handler;
	sigaction(SIGFPE, &sig, NULL);
	int ret = 0;
	if (setjmp(jbuf) == 0)
	{
		ret = Mix_PlayMusic(music, loops);
	}
	sig.sa_handler = SIG_DFL;
	sigaction(SIGFPE, &sig, NULL);
	return ret;
#endif
}

この修正により、FreeBSDでも動作するようになりました。同じソースをNetWalker用にクロスコンパイルしたものも動作したそうです。

SIGFPE

SIGFPEで検索に引っ掛かるかもしれないので、参考までに試したことを書いておきます。

初めはSIGFPEをSIG_IGNで無視しようとしたのですが、SIG_IGNが無視されてコアダンプしたので大域脱出で逃げました。

検索しても無視の定石のようなものは出てこなかったのですが、ハンドラを設定すると無限に呼ばれ続けました。ハンドラからの復帰はBasicのOn Error Resume Nextではなく再試行で、エラーの原因を取り除かないと無限ループになるため、SIGFPEは無視できないようです。

本気で無視しようとするならレジスタ書き換えなどで計算結果を改竄するしかないのですが、今回はx86だけでなくARMでも動かす必要があるので諦めました。もし無視してINT_MAXを返してもSDL_mixerが誤動作するだけで意味はなさそうです。

謝辞

今回の移植では私自身色々と勉強になりました。こういう機会でもなければ経験できないことばかりです。きっかけをくださったid:fslashtさんに改めて御礼申し上げます。ありがとうございました。