English

ANTI-ARES / 開発記

16年前の自作ゲームを、
作り直さずに iPhone で動かした

3,600行の互換レイヤーを書いて、2010年の30,716行を無改造のまま走らせた話

2026 / Ogawa Akira

2010年に Windows 用に書いたシューティングゲームが、16年ぶんの埃をかぶったまま手元にあった。当時使ったライブラリはとうに開発が終わり、Windows と DirectX でしか動かない。作り直すのが普通のところを、そうしなかった。ゲームの下にある層だけを差し替えて、元のコードをそのまま動かした。

結果として App Store に並んでいるアプリは、2010年に書いたコードそのものが走っている。この記事はその作業の記録である。

出発点:16年前に完成していたゲーム

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つしかなかった。

内容書き換える行数
AUnity などのエンジンで作り直す30,716
Bゲームの下の層を差し替える0

作り直す案を、なぜ外したか

Aも具体的に検討した。素材もロジックも手元にあるのだから、Unity で組み直すのは不可能ではない。外した理由は2つある。

ひとつは労力。3万行を読み直しながら別の枠組みへ移す作業は、片手間で終わる規模ではない。

もうひとつが決め手だった。作り直すと、同じ手触りになる保証がない。シューティングは、当たり判定の数ピクセル、弾速の刻み、1フレームの待ちで別物になる。移植先で「だいたい同じ」を積み上げていった結果が元と違っていたとして、それを検証する手段が無い。基準になるのは16年前の自分の記憶だけである。

互換レイヤーなら、この問題が原理的に消える。動いているのは同じコードなので、挙動が違いようがない。再現性を担保するための作業が、そもそも発生しない。

Bを選んだ。

SDL3 という土台

差し替え先には SDL3 を使った。SDL(Simple DirectMedia Layer)は、OSごとに違う低レベルの処理を共通の書き方に揃えてくれるライブラリで、ウィンドウ生成、GPU への描画、入力、音声までを面倒見てくれる。オープンソースで、Windows / macOS / Linux / iOS / Android で動く。

SDL はゲームエンジンではない。Unity や Godot と違って、スプライト管理も当たり判定も物理も持っていない。「窓を開けて、絵を1枚描いて、入力を読む」までしかやらない。
これは今回の用途にはむしろ好都合だった。エンジンだと「エンジンの流儀」に合わせてゲームを作り直すことになるが、SDL は素材なので、その上に好きな構造を組める。今回組みたかった構造は「Dark GDK のふり」である。

設計:同じ看板を出す

やったことは一言で言える。Dark GDK と同じ看板を出した別人を用意した。

src/  ゲーム本体30,716行
2010年のコードのまま。dbSprite(id, x, y, img) などを呼ぶ
関数名・引数は Dark GDK と同一
shim/  互換レイヤー3,600行
DarkGDK.h … 宣言(88関数)
DarkGDK.cpp … SDL3 で中身を実装
TouchPad.cpp … iOS のタッチ操作
SDL の API を呼ぶ
SDL3 / SDL_image / SDL_mixer
OSごとの違いを吸収する
macOS / 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

実装したのは全 API ではない

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;
});

16bit BMP の丸めで、透過が抜けなかった

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度ずれる。

Win32 の 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 に複製するので、この不具合がまともに出る。先に値へコピーしてから挿入する必要があった。

iOS 固有:タッチをキー入力に化けさせる

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 側にあった。バイナリのシンボルを追って発生源を特定した。

Apple は「アプリが実際に使っていなくても、参照があれば用途説明が要る」という判定をする。使いもしない権限の説明文を書くより機能ごと積まない方が筋が良いので、ビルド設定で SDL_CAMERA=OFF / SDL_HIDAPI=OFF にした。

ゲームパッド対応は失われない。iOS のコントローラは GameController.framework を使う経路が担当していて、HIDAPI とは独立しているためである。実測でも、AVCapture 参照 12→0、Bluetooth 参照 1→0、GCController 参照 5→5(維持)、バイナリ 2.99MB→2.70MB となった。

移植が炙り出したもの

思わぬ副産物もあった。macOS 版を AddressSanitizer でビルドして走らせたところ、16年前から潜んでいたバッファオーバーランが出た。

global-buffer-overflow  Jiki.cpp  Jiki::BackGroundInit()

配列の大きさが 1,301 なのに、初期化ループが 2,001 回まわっていた。別の配列用のカウンタ定数をコピーして使っていたためで、700要素ぶん配列の外へ書き込んでいた。Windows では表面化せず、16年間気づかなかった。

移植そのものとは関係ないが、別の環境へ持っていくと、それまで見えなかったものが見えるという好例だった。

結果

項目行数
原典のゲームコード(2010年)30,716
互換レイヤー DarkGDK.cpp / .h1,900
タッチ操作 TouchPad.cpp / .h1,700
新しく書いた合計3,600

3,600行を書くことで、30,716行を1行も書き換えずに済んだ。フレームレートは iPhone 実機で 60fps を安定して維持している。

「今も無改造」ではない。正確に言えば「移植にあたって、ゲームロジックの書き換えは必要なかった」である。移植が済んだあと、ゲーム内容そのものには多数の変更を加えている(敵の攻撃パターン、エフェクト、当たり判定の修正など)。それらは移植とは無関係な、ゲームデザイン上の判断によるものだ。

この方法が向いている場合

互換レイヤーを書くのは、いつでも正解というわけではない。今回うまくいったのは条件が揃っていたからだ。

逆に、API 面が広いエンジンに深く依存していたら、この方法は割に合わなかったと思う。

それでも、作り直すより下の層を差し替えるほうが速く、しかも確実な場面はあるということは書いておきたい。とくに「元と同じであること」が品質そのものである場合、作り直しは検証できない賭けになる。少なくとも私の場合、3,600行で足りた。