3,600行の互換レイヤーを書いて、2010年の30,716行を無改造のまま走らせた話
2010年に Windows 用に書いたシューティングゲームが、16年ぶんの埃をかぶったまま手元にあった。当時使ったライブラリはとうに開発が終わり、Windows と DirectX でしか動かない。作り直すのが普通のところを、そうしなかった。ゲームの下にある層だけを差し替えて、元のコードをそのまま動かした。
結果として App Store に並んでいるアプリは、2010年に書いたコードそのものが走っている。この記事はその作業の記録である。
ANTI-ARES は2010年に完成していた。Visual C++ 2008 で書いていた時代のものだ。Windows 用のゲームとして作り、それで満足していた。配布は個人で配るくらいしか思いつかず、そのまま置いてあった。出したくなかったわけではなく、単に時間が取れなかった、というだけの話でもある。
16年経って、これを iPhone で動かそうと考えた。ここからが本題である。
ANTI-ARES は2010年に、Dark GDK という C++ 向けのゲーム開発ライブラリで書いた。The Game Creators 社が出していたもので、DirectX を初心者向けに包み直し、dbSprite() dbPlaySound() といった単純な関数を呼ぶだけでゲームが作れた。
問題は、このライブラリが三重に手詰まりだったことだ。
Dark GDK 自体を iOS へ移植する道は、原理的に存在しない。だから選択肢は最初から2つしかなかった。
| 案 | 内容 | 書き換える行数 |
|---|---|---|
| A | Unity などのエンジンで作り直す | 30,716 |
| B | ゲームの下の層を差し替える | 0 |
Aも具体的に検討した。素材もロジックも手元にあるのだから、Unity で組み直すのは不可能ではない。外した理由は2つある。
ひとつは労力。3万行を読み直しながら別の枠組みへ移す作業は、片手間で終わる規模ではない。
もうひとつが決め手だった。作り直すと、同じ手触りになる保証がない。シューティングは、当たり判定の数ピクセル、弾速の刻み、1フレームの待ちで別物になる。移植先で「だいたい同じ」を積み上げていった結果が元と違っていたとして、それを検証する手段が無い。基準になるのは16年前の自分の記憶だけである。
互換レイヤーなら、この問題が原理的に消える。動いているのは同じコードなので、挙動が違いようがない。再現性を担保するための作業が、そもそも発生しない。
Bを選んだ。
差し替え先には SDL3 を使った。SDL(Simple DirectMedia Layer)は、OSごとに違う低レベルの処理を共通の書き方に揃えてくれるライブラリで、ウィンドウ生成、GPU への描画、入力、音声までを面倒見てくれる。オープンソースで、Windows / macOS / Linux / iOS / Android で動く。
やったことは一言で言える。Dark GDK と同じ看板を出した別人を用意した。
dbSprite(id, x, y, img) などを呼ぶDarkGDK.h … 宣言(88関数)DarkGDK.cpp … SDL3 で中身を実装TouchPad.cpp … iOS のタッチ操作
shim/DarkGDK.h は、本物とまったく同じ関数名・同じ引数を宣言している。ゲーム側は #include "DarkGDK.h" と書いて dbSprite() を呼ぶだけなので、中身が DirectX から SDL3 に入れ替わったことに気づかない。
実際、移植版のゲーム本体が include しているのは次の5つだけで、プラットフォーム分岐(#ifdef __APPLE__ のような記述)は1件もない。
DarkGDK.h Data.h Jiki.h Sound.h TouchPad.h
Dark GDK には膨大な関数があるが、ゲームが実際に呼んでいるものだけを実装した。
| 項目 | 数 |
|---|---|
シムが宣言している db* 関数 | 88 |
| うち Dark GDK 由来(=再実装したもの) | 81 |
| シム独自の追加 | 7 |
| ゲーム本体が実際に呼んでいる種類 | 81 |
「Dark GDK の完全な再実装」ではなく「この1本のゲームを動かすのに必要な最小集合」を作った。これが3,600行で済んだ理由である。
ここからが移植の実質だ。関数名が同じでも、考え方が違えばそのままでは動かない。
Dark GDK は「ID付きのスプライトを登録しておけば、あとは面倒を見てくれる」方式(retained mode)。対して SDL3 は「毎フレーム、描きたい順に自分で描く」方式(immediate mode)だ。
そこでシムはスプライトの一覧を自前で持ち、毎フレーム優先度順に並べ替えてから SDL に渡している。dbSetSpritePriority() が効くのはこの仕組みによる。
/* 比較子では at() を使う。operator[] は要素が無ければ挿入してしまい、
ソート中の挿入は rehash を招いて壊れる。 */
std::sort(ids.begin(), ids.end(), [](int a, int b) {
const Sprite &sa = gSprites.at(a);
const Sprite &sb = gSprites.at(b);
if (sa.priority != sb.priority) return sa.priority < sb.priority;
return a < b;
});
Dark GDK は「この色を透明として抜く」カラーキー方式で、このゲームは純緑 (0,255,0) を透過色に使っている。素直に SDL のカラーキー機能に任せたところ、iOS で矢印などに緑の箱が残った。
原因は素材側にあった。アセットの BMP はビット深度が混在していて(8/16/24bit)、16bit BMP では純緑が RGB565/555 に丸められ、展開すると (0,252,0) や (0,248,0) になっていた。厳密一致のキーでは抜けない。
加えて、変換時にカラーキーを alpha へ落とすかどうかは、プラットフォームとビット深度で挙動が揺れる。そこで SDL 任せをやめ、RGBA32 に変換した後でピクセルを直接見て、許容幅つきで緑を判定して alpha=0 にすることにした。ゲームは純緑を透過専用に使っており、絵に近い緑は存在しないので、誤って穴を開ける心配はない。
dbFlipSprite は名前から「上下反転」に見えるが、実際は左右反転だった。
気づいたのはタイトル画面からである。メニューの右向き矢印 ▶ は、左向き矢印 ◀ のスプライトに dbFlipSprite() を掛けて作られていた。◀ は上下対称なので、上下反転なら見た目が変わらない。▶ を得るには左右反転しかありえない。
これは地形にも効く。左右反転と上下反転はちょうど180度回転ぶん違うため、軸を取り違えるとフリップ系のタイルが全部180度ずれる。
wsprintf を、テンプレートで受けるゲームは Win32 の wsprintf を91箇所で使っていた。これも当然 iOS には無い。
template <size_t N, typename... Args>
int wsprintf(char (&buf)[N], const char *fmt, Args... args) {
return std::snprintf(buf, N, fmt, args...);
}
配列参照で受けているのが要点だ。呼び出し側でバッファサイズ N が自動的に決まるので、snprintf へ安全に委譲できる。もしポインタを渡している箇所があればコンパイルエラーになるので、見落としに気づける。
移植で一番厄介なのは、元の実装の「たまたまの挙動」に依存しているコードである。2つ踏んだ。
1. dbSprite() は可視状態を変えてはいけない。素直に実装すると「描画するのだから表示する」としたくなるが、それをやるとハッチ(蓋)が壊れた。ゲームは蓋の破壊後に HideSpriteR() で隠した直後、dbSprite() を呼んで座標だけ更新している。ここで可視に戻すと、壊したはずの蓋が残り続ける。ゲーム側は表示したいとき必ず dbShowSprite を明示的に呼んでいた(193箇所すべて確認した)。
2. dbCloneSprite() で参照が飛ぶ。次のように書くと爆発エフェクトが一切描かれなくなった。
gSprites[iDestination] = *src; // 危ない
新しいキーの挿入で unordered_map が rehash すると、src が指す要素が移動し、解放済みメモリを読むことになる。ゲームは爆発を毎回新しい ID に複製するので、この不具合がまともに出る。先に値へコピーしてから挿入する必要があった。
TouchPad.cpp の1,614行は、移植で完全に新規に書いた部分である。元のゲームはキーボード前提なので、iOS では代わりが要る。
やっていることは単純で、画面上にボタンを描き、タッチをキー入力に化けさせる。
int dbKeyState(int iKey) {
PumpForInput();
if (ScriptHolding(iKey)) return 1;
/* バーチャルキーパッド。ゲームからは実キーボードと区別がつかない。 */
if (TouchPad_IsKeyDown(iKey)) return 1;
...
}
ゲーム側は dbKeyState(44)(Zキー)を読んでいるだけで、それがキーボードなのか画面のボタンなのかを知らない。だからゲーム側を一切変えずにタッチ操作が入った。
配置は keypad.conf というテキストで定義し、画面サイズや場面(タイトル / ゲーム中 / ポーズ / クリア画面)ごとに出すボタンを変えている。
技術的には動いていても、App Store の審査は別の関門だった。中でも厄介だったのが ITMS-90683 である。
Missing purpose string: NSCameraUsageDescription
Missing purpose string: NSBluetoothAlwaysUsageDescription
ゲームはカメラも Bluetooth も使っていない。原因は静的リンクしている SDL3 側にあった。バイナリのシンボルを追って発生源を特定した。
SDL_camera_coremedia.o が AVCaptureDevice 等を参照 → カメラ判定hid.o(HIDAPI)が CBCentralManager を参照し CoreBluetooth をリンク → Bluetooth 判定Apple は「アプリが実際に使っていなくても、参照があれば用途説明が要る」という判定をする。使いもしない権限の説明文を書くより機能ごと積まない方が筋が良いので、ビルド設定で SDL_CAMERA=OFF / SDL_HIDAPI=OFF にした。
思わぬ副産物もあった。macOS 版を AddressSanitizer でビルドして走らせたところ、16年前から潜んでいたバッファオーバーランが出た。
global-buffer-overflow Jiki.cpp Jiki::BackGroundInit()
配列の大きさが 1,301 なのに、初期化ループが 2,001 回まわっていた。別の配列用のカウンタ定数をコピーして使っていたためで、700要素ぶん配列の外へ書き込んでいた。Windows では表面化せず、16年間気づかなかった。
移植そのものとは関係ないが、別の環境へ持っていくと、それまで見えなかったものが見えるという好例だった。
| 項目 | 行数 |
|---|---|
| 原典のゲームコード(2010年) | 30,716 |
互換レイヤー DarkGDK.cpp / .h | 1,900 |
タッチ操作 TouchPad.cpp / .h | 1,700 |
| 新しく書いた合計 | 3,600 |
3,600行を書くことで、30,716行を1行も書き換えずに済んだ。フレームレートは iPhone 実機で 60fps を安定して維持している。
互換レイヤーを書くのは、いつでも正解というわけではない。今回うまくいったのは条件が揃っていたからだ。
逆に、API 面が広いエンジンに深く依存していたら、この方法は割に合わなかったと思う。
それでも、作り直すより下の層を差し替えるほうが速く、しかも確実な場面はあるということは書いておきたい。とくに「元と同じであること」が品質そのものである場合、作り直しは検証できない賭けになる。少なくとも私の場合、3,600行で足りた。