← 記事一覧

CrazyTaxi の舞台裏:セガの Dreamcast 名作を Three.js で再構築

CrazyTaxi は、セガ Dreamcast 版『Crazy Taxi』を Three.js で再構築したものです。車、交通、カメラ、お客さん、メニューはすべて、小さなインタープリター上でゲーム自身の SH-4 コードを動かしており、逆コンパイル結果とビット単位で一致します。Agent! が 3 日と 157 コミットで作りました。ct.gokart.games でプレイできます。

CrazyTaxi:ブラウザの Arcade の街を走る黄色いタクシー。
ブラウザで動く Arcade の街。街もタクシーも交通も Dreamcast のディスクから取り出したものです。描画は Three.js、動かしているのはゲーム自身のコードです。

今すぐプレイ:ct.gokart.games。 WebGL 対応のブラウザが必要で、キーボードか Xbox(またはその他の標準的な)コントローラーで遊べます。

CrazyTaxi は、セガ Dreamcast 版 Crazy Taxi(USA、MK-51035)を Three.js で再構築したものです。最初のコミットは 10 月 22 日に入りました。10 月 24 日の夜には 157 コミットに達していました。1 日目が 93、2 日目が 41、3 日目が 23 です。すべて Agent! が書き、テスト済みのコミットをひとつずつ積み重ねました。コミットメッセージは実験ノートのようで、ほぼすべてにアドレス、数、確認結果が書かれています。

スクリーンショットを見ながら作ったリメイクではありません。画面に映るもののほぼすべて、そして挙動のほとんどが、ディスクそのものから来ています。

1 日目:ディスクからゲームを取り出す

1 日目の大半は抽出に費やされました。リポジトリにゲームデータは一切含まれていません。自分のディスクをリッピングし、ひとつのコマンドで中身をすべて取り出します。

python3 tools/extract_all.py '<Crazy Taxi (USA).7z or .chd>' --output local-assets

このコマンドの裏には tools/ にある 17 個のコンバーターがあり、どれも同じ 3 段階でコミットされています。ツール本体、そのテスト、そして判明した内容をまとめたドキュメントです。順に、次のものを扱います。

  • ディスク:生の高密度トラックから抽出し、ハッシュで検証します。extract_chd.py は FLAC デコーダーを含む独自のデコーダーで CHD v5 イメージを読み込み、CD セクターのエラー訂正を再構築します。出力は .7z のリッピングとバイト単位で一致します。
  • テクスチャとスプライト:TEXDC テクスチャバンクと、スプライトバンクです。スプライトのパレットは実行ファイルの SH-4 ローダーコードから復元しました。
  • モデルと街:POLDC モデルと BINC の街アーカイブを、テクスチャ付きの glTF に変換します。
  • キャラクターとモーション:ゲーム自身の描画ルーチンをたどって組み立てます。キーカーブのモーションと、それに必要な 2 ボーン IK も含みます。
  • 交通とタクシー:USA 版の車両テーブルとタクシーテーブルから構築し、交通車両の色はそれぞれ glTF のマテリアルバリアントにしています。
  • コリジョン、コースライン、配置オブジェクト、お客さん、停留所、目的地の画像、リプレイ。どれも実行ファイル自身のテーブルと照合しています。
  • オーディオ:ADX の音楽とボイス、そして DTPK サウンドバンクです。再生レートと音色プログラムは、Dreamcast のサウンドドライバーと同じ方法で読み取ります。

フル実行では、約 2 分で 4,744 個のファイル(255 MB)が書き出されます。

ゲーム自身のコードを動かす

その日の夜、Three.js 版のゲームが動き出しました。街、仮のタクシー、そして手作業で調整した追従カメラです。

1 時間も経たないうちに、コミット 02bf19e で仮の物理演算は捨てられました。src/sh4.js は Dreamcast の CPU である SH-4 の小さなインタープリターで、src/dc-car.js はこれを使ってゲームの 1ST_READ.BIN から init_CarMain と exec_CarMain を直接実行します。これらは、元の Dreamcast のアドレスに読み込まれたディスクのコースポイントとコリジョンデータの上で動きます。描画、サウンド、振動はスタブ化またはキャプチャされ、Three.js はゲームが要求したものをそのまま描画します。

ゲームの残りの部分も、システムごとにひとつずつインタープリターへ移されました。

  • 交通:すべての車、電車、ケーブルカー、信号はゲームイベントで、ゲーム自身の trafficControl と nlExecuteEvent が動かしています。タクシーが交通にぶつかれば、交通もぶつかり返してきます。
  • カメラ:camUpdate の追従カメラと、カウントダウン中にタクシーの周りを回り込むカメラワークです。Three.js はゲームのビュー行列をそのまま使うだけです。
  • お客さん:誰がどこで待っているか、乗車、移動中、そして降車時の評価とタイムボーナスです。
  • メニュー:タイトル画面、メインメニュー、オプション、記録、ドライバー選択。どれもゲームのコードがスプライトを描いており、src/menu-view.js がそれを canvas に配置します。

これらはすべて、逆コンパイルプロジェクト自身の SH-4 シミュレーターと照合されています。tools/car_trace.py は 1 回の乗車をまるごと記録します。カウントダウン、お客さんを乗せる、運転、降ろす、までです。両方の街で、タクシー、カメラ、すべての交通車両、ゲーム内時計、すべてのお客さんが、全フレームビット単位で一致します。

この照合で本物のバグが見つかりました。ゲームの演算のうち 2 つの異なる部分の結果が食い違っていたのですが、インタープリターの 64 ビット浮動小数点転送命令を、SH-4 が実際に使う順序でレジスタペアを格納するよう修正したところで一致しました。逆コンパイル側のシミュレーターにも、car_trace.py で同じパッチが当てられています。

Crazy Box

2 日目には Crazy Box が登場しました。ゲームのミニゲームで、全 16 種が Init_DCmini と execDCmini によって動いています。ゲームの新しい部分は、どれも同じ方法で見つけました。実行ファイルの中でルーチンを探し、インタープリターで実行し、描画するものをキャプチャします。

  • Crazy Bowling のピンは ExecSetObject から来ています。当てると飛んでいき、ゲームがその本数を数えます。
  • 緑のガイド矢印は ExecArrow から来ています。ビュー空間で描画され、ワールドに配置し直されます。
  • TRY AGAIN のウィンドウは、メニューカーソルも含めてゲーム自身の windowTryAgain です。
  • クリアはゲーム自身の CheckRanking が保存し、次のボックスが解放されます。セーブデータは Dreamcast がバックアップするのと同じ 0x25B0 バイトで、localStorage に保存されます。

この作業でのひとつの発見:carRumble という名前のルーチンは、振動だけを担当しているわけではありません。Crazy Dash も実行しています。スタブ化した状態では、Crazy Jump の最高記録は 132 m でした。動かすと、GO の瞬間のダッシュで 173 m に届きます。

60 fps と高解像度テクスチャ

正しく動くようになったら、次は速さです。街は 13,779 個のバラバラのメッシュとして読み込まれ、ブラウザは 1 フレームあたり 3,000〜4,500 回の描画呼び出しを行っていました。Auto-Pilot のブランチで、src/static-batch.js が静的なワールドを統合します。街の 374 枚のテクスチャはすべて 1 枚のアトラスにまとめられ、メッシュは 4,000 ユニットのセルごとに統合されます。これで 1 フレームあたりの描画呼び出しは約 300 回まで減りました。Mac アプリの 2560 × 1920 では安定して 60 fps(最低 58)で、以前の 37〜47 から向上しています。

H(コントローラーでは Y)を押すと高解像度テクスチャパックが有効になります。tools/match_texture_pack.py はゲーム内のすべてのテクスチャについて Flycast のカスタムテクスチャハッシュを再計算し、Flycast のテクスチャパックをゲームの 2,090 枚のテクスチャのうち 1,793 枚に対応付けます。パックを有効にすると、バッチ処理された街は 4 倍のサイズ(8192 × 8192)のアトラスから描画されます。リリース版ではこれらのテクスチャを KTX2 で配布しており、パックの JPEG と PNG が 551 MB なのに対して 51 MB です。

3 日目のその他の仕上げ:

  • TAA:ジッターをかけたフレームを再投影した履歴にブレンドし(src/taa.js)、4× MSAA と組み合わせることで、遠くの街がちらつかなくなりました。
  • ちらつきの解消:歩道に落ちる建物の影のように重なり合う同一平面上のディテールを視点側へ引き寄せ、深度の取り合いをなくしました。
  • ダウンロードの軽量化:音楽とボイスを Opus と AAC に(約 150 MB から 37 MB に)、それ以外はすべて Brotli に(転送量で 103 MB から 28 MB に)しました。
  • 新しいおまけ:Xbox コントローラー対応、目的地までの最短ルートを緑で示す街のミニマップ、そして Arcade ルールでの降車時タイムボーナスです。
  • デスクトップアプリ:署名と公証を済ませた Mac アプリと、Windows および Linux 用のランチャーです。どれも ct.gokart.games の Web 版を動かします。

どう確認したか

どれも鵜呑みにはしていません。逆コンパイルのトレースフィクスチャが、ゲームロジックをフレーム単位で確認します。変更のたびにヘッドレス Chrome が両方の街で 1 回ずつ乗車を走らせ、エラーを数えます。tools/draw-order-check.mjs はオートパイロットでタクシーを走らせ、全フレームを深度の正しいリファレンスと比較します。描画順の修正前は 95 サンプル中 66 が誤っていましたが、修正後は 565 サンプル中 0 でした。tools/mac-probe.sh は Mac アプリを無人で操作し、毎秒 fps と描画呼び出し数を出力します。

試してみよう

  • プレイ: ct.gokart.games
  • ソース: AgentiLoop/CrazyTaxi。ローカルで動かすには、自分の Crazy Taxi(USA)のディスク、Node.js、Python 3 が必要です。

お客さんの横、その円の中で止まると乗せられます。あとは矢印に従って画像の場所まで行き、そこで止まりましょう。

CrazyTaxi は独立したファンプロジェクトであり、セガとは無関係で、セガの承認も受けていません。Crazy Taxi はセガの商標です。リポジトリにゲームデータは含まれていません。