2010年の夏を .dem から掘り起こす
2010年、自分は L4D2(Left 4 Dead 2、4人協力のゾンビ FPS)の対戦を demo(プレイを丸ごと記録したリプレイファイル)に録りためていた。困っていたのは、パッチが来るたびに古い demo が再生できなくなることだった。直すには、ヘッダーの 0x0C からバージョンを読み取り、その値を再生 UI に教えてやればよかった――そのときの話。目的は昔の録画をまた見ることで、中身を抜くことではない。ただ、その作業のついでに気づいた。dem ファイルには固有文字列――鯖名・録画者名・マップ名――までそのまま入っている。demo は当時から、読もうとする者に少しだけ喋るファイルだった。
あれから16年。その demo が Google Drive の奥から 185本 出てきた。当時 手で届いていた層より奥まで開けるか、AI と一緒に解析してみた。すると全プレイヤーの毎 tick(ゲーム内の細かい時間刻み。1秒に数十回)ぶん――座標も、向いていた方向も、撃った弾も――最深部まで出てきてしまった。以下は、当時 取れていたものと、AI がさらに掘り出したものの対比だ。
当時、手で取れていたもの
当時 触っていたのは、一番外側にある固定長のヘッダーだった。これを読むのに使っていたのは、自分が Ruby で書いた小物2本。ヘッダーの 0x0C からバージョンを割り出す dem_version.rb と、バイナリから読める文字列だけを抜き出す自作の strings ツール demo_extract.rb だ(strings はバイナリから文字列を拾う定番の道具。その自作版)。今回はその同じ層を、AI に4ヶ月ぶんまとめて拾い直させた。
dem_version.rb が読んでいたのがこの値だ。固有文字列のほうも、全員の名前・SteamID・鯖の IP・MOTD(接続時に出る案内)の URL まで揃って出てきた(個人が特定できるものは載せない)。
対戦のルールは dem に残っていた
接続した直後、サーバーは全員へ cvar(ゲームの設定値)1の束を配ってくる。じつはこの層も、当時の strings でそれなりに見えていた。設定名と値が隣り合って並ぶので、出力を上から眺めるだけでもペアで読めてしまうのだ。今回 AI がやったのは、それを取りこぼしなく24個ぶんそろえ直したことになる。sv_maxcmdrate 101 の高 tick レート設定から対戦用の細かな調整まで、当時どんなルールで戦っていたかがまるごとそろった。
ヘッダー・固有文字列・cvar――ここまでは名前も値も文字として並ぶので、当時の strings でも半分は読めていた。本当に取れなかったのは、この先だ。ビット列を1ビットずつ開かないと出てこない、イベント・操作入力・全プレイヤーの座標。ここからは AI だけが開けた層になる。
dem を7段階で開く
demo は Source エンジンのリプレイで、外側から内側へ入れ子になっている。奥へ行くほどビット単位のパズルで、1ビット幅を間違えると、以降が全部崩れてしまう。
一番の難所は SendTable(どのデータが何ビットで並ぶかを決める定義表)2だった。ビットの並びに L4D2 特有のクセがあり、実データから当てるまで開かなかった。最後は、スピードラン界で使われている OSS(オープンソース)の UntitledParser にこの demo の版数を1行足してビルドし直し、自前の復号が正しいことの裏も取れた。
4ヶ月・6,443キルの戦績
Versus は、4人の生存者(survivor)チームと、それを狩る特殊感染者(Hunter や Charger などの変異ゾンビ)チームに分かれて撃ち合う対戦モードだ。イベントの層まで開けば、試合で何が起きたかがそっくり取れる。22試合・109本から集計した特殊感染者の討伐は、約 6,443キルだった。
player(FF) は味方撃ち。プレイの質まで、匿名の数字になる
イベントの層と usercmd(プレイヤーが毎 tick 送る操作入力)3を合わせると、キル数だけでなくプレイの質まで数字になってしまう。「こんなものまで取れるのか」という例を3つ挙げる(名前は伏せる)。
ヘッドショット率。 特殊感染者を倒したイベントには、撃った人と、その一発がヘッドショットだったかが記録されている。同じ人ごとにまとめれば、プレイヤー別のヘッドショット率が出る。ある1試合の survivor 8人ぶんがこれだ(名前は伏せて P1〜P8、撃破数の多い順):
| プレイヤー | 撃破数 | ヘッドショット率 |
|---|---|---|
| P1 | 92 | 17.4% |
| P2 | 87 | 12.6% |
| P3 | 83 | 13.3% |
| P4 | 81 | 16.0% |
| P5 | 70 | 10.0% |
| P6 | 57 | 12.3% |
| P7 | 50 | 18.0% |
| P8 | 44 | 27.3% |
この試合は全体で570体撃破、ヘッドショット率は15.1%だった。飛び抜けて当てていたのは、撃破数が一番少ない P8(27.3%)。撃った数と当てる精度は別物だ、というのが匿名のまま見えてくる。
フレンドリーファイア率。 このモードは味方撃ちが有効なので、記録を名寄せすれば「誰が一番味方を撃ったか」まで分かる。4ヶ月を通すと、倒れた survivor の約31%は味方撃ちが原因だった――およそ3回に1回は同士討ちだ。一番の犯人は伏せるが、自分ではなかった。
照準を合わせてから撃つまでの間。 これは録画者本人の視点でしか取れない。視点の動く速さを追うと、撃った瞬間には 1.49°/tick まで落ちていた。狙いを定めてから引き金を引く、人間らしい動きだ。相手側の同じ値は取れない(次の節)ので、使えるのは自分の解析までになる。
どこで戦い、どこを歩いたか
最深部にある全プレイヤーのエンティティ(座標や状態を持つゲーム内オブジェクト)を AI が開くと、座標が取れる。倒した特殊感染者の座標を、フィナーレ(最終盤の籠城戦)のマップに重ねてみると、籠城していた場所がそのまま濃くなった。
録画者のプレイヤーだけは座標が毎 tick 残るので、1マップぶんの移動経路も丸ごと復元できた。14.6分で 6.6km 歩いていた計算になる。
取れないものは取れない
AI をもってしても、取れないものははっきりある。
- 他人の連続した視点は取れない。demo に入っているのは録画者の視界(PVS)4の中だけだ。相手が毎 tick どこを向いていたか――つまり相手の aimbot 判定――は原理的に無理で、それには相手自身の視点で録った demo が要る。前節の照準の話が自分どまりなのも、これが理由だ。
- ping は残らない。通信の受信確認は demo に記録されないので、当時の回線の遅さは復元できない。
- ゲーム内ボイスはゼロ。ボイスデータが一件も無い。当時は Skype など外部の通話を使っていた、という証拠だ。
どうやったか
復号の一式――.dem から通信メッセージを切り出し、SendTable を実データから復元し、エンティティを1ビットずつ開くところまで――は AI が書いた。コードは scripts/excavation に置いてある(Python 標準ライブラリのみ)。自分がやったのは、16年前に demo を録って自作 Ruby でヘッダーを少し読み解いていたこと、そしてそれを Drive から掘り出して AI に渡したことだ。
当時ほしかったのは、もう一度あの試合を観ることだけだった。0x0C の数バイトを直せば、それは叶った。16年後に同じファイルを開いたら、あの夏の全員が――座標も、視線も、撃った一発までも――まだそこにいた。
サーバーやクライアントの挙動を決める設定変数(console variable)。対戦のルールもこれで全員に配られる。原典: Valve Developer Community — ConVar ↩︎
エンティティの各プロパティを何ビットで送るかを定義するテーブル。Source のエンティティ同期の中核で、この記事で最大の難所になった層。原典: Valve Developer Community — Networking Entities ↩︎
クライアントが毎 tick サンプリングしてサーバーへ送る入力コマンド(移動・視点・発砲など)。原典: Valve Developer Community — Source Multiplayer Networking ↩︎
そのプレイヤーから見える可能性のある空間だけを送る仕組み(Potentially Visible Set)。demo に他人の視界が入らない理由。原典: Valve Developer Community — PVS ↩︎