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ヶ月ぶんまとめて拾い直させた。

202006-09
202106-11
202306-20
202407-03
202607-14
202907-25
203108-02
203208-09
203709-18
203809-26
この4ヶ月の network_protocol(版数)。 4桁だが西暦ではなく、パッチのたびに増える連番。上がると旧 demo は再生できなくなる――冒頭で 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ビット幅を間違えると、以降が全部崩れてしまう。

1234567HdnGSPULeeaean2mtmnctDoedkiEmETetMfevatlOrsebEeasnlndhmatetPeegLiaaseitrdss(isetheer(asrs/rvd(ceeO_vsS*etS)n)tcsheck)
復号の7段階。 一番外側の HL2DEMO ヘッダーから、最深部の全プレイヤー・エンティティまで。段を下るほどビット単位になり、ほどくのが難しくなる。

一番の難所は SendTable(どのデータが何ビットで並ぶかを決める定義表)2だった。ビットの並びに L4D2 特有のクセがあり、実データから当てるまで開かなかった。最後は、スピードラン界で使われている OSS(オープンソース)の UntitledParser にこの demo の版数を1行足してビルドし直し、自前の復号が正しいことの裏も取れた。

4ヶ月・6,443キルの戦績

Versus は、4人の生存者(survivor)チームと、それを狩る特殊感染者(Hunter や Charger などの変異ゾンビ)チームに分かれて撃ち合う対戦モードだ。イベントの層まで開けば、試合で何が起きたかがそっくり取れる。22試合・109本から集計した特殊感染者の討伐は、約 6,443キルだった。

smg_silenced2328hunting_rifle1379smg1107melee488world284shotgun_chrome213player(FF)205trigger_hurt_ghost145pistol125pumpshotgun123
武器別の討伐数。 主力は消音 SMG(2,328)、狙撃は hunting_rifle(1,379)。player(FF) は味方撃ち。
2010-0631
2010-0737
2010-0812
2010-0920
2010-109
The ParishDark CarnivalDead Center
月ごとに遊んだキャンペーン(数マップ続きのシナリオ)。 競技で使われたのは実質3つ。9〜10月に Dark Carnival へ寄っていく。

プレイの質まで、匿名の数字になる

イベントの層と usercmd(プレイヤーが毎 tick 送る操作入力)3を合わせると、キル数だけでなくプレイの質まで数字になってしまう。「こんなものまで取れるのか」という例を3つ挙げる(名前は伏せる)。

ヘッドショット率。 特殊感染者を倒したイベントには、撃った人と、その一発がヘッドショットだったかが記録されている。同じ人ごとにまとめれば、プレイヤー別のヘッドショット率が出る。ある1試合の survivor 8人ぶんがこれだ(名前は伏せて P1〜P8、撃破数の多い順):

プレイヤー撃破数ヘッドショット率
P19217.4%
P28712.6%
P38313.3%
P48116.0%
P57010.0%
P65712.3%
P75018.0%
P84427.3%

この試合は全体で570体撃破、ヘッドショット率は15.1%だった。飛び抜けて当てていたのは、撃破数が一番少ない P8(27.3%)。撃った数と当てる精度は別物だ、というのが匿名のまま見えてくる。

フレンドリーファイア率。 このモードは味方撃ちが有効なので、記録を名寄せすれば「誰が一番味方を撃ったか」まで分かる。4ヶ月を通すと、倒れた survivor の約31%は味方撃ちが原因だった――およそ3回に1回は同士討ちだ。一番の犯人は伏せるが、自分ではなかった

照準を合わせてから撃つまでの間。 これは録画者本人の視点でしか取れない。視点の動く速さを追うと、撃った瞬間には 1.49°/tick まで落ちていた。狙いを定めてから引き金を引く、人間らしい動きだ。相手側の同じ値は取れない(次の節)ので、使えるのは自分の解析までになる。

どこで戦い、どこを歩いたか

最深部にある全プレイヤーのエンティティ(座標や状態を持つゲーム内オブジェクト)を AI が開くと、座標が取れる。倒した特殊感染者の座標を、フィナーレ(最終盤の籠城戦)のマップに重ねてみると、籠城していた場所がそのまま濃くなった。

特殊感染者 86体を倒した座標(フィナーレ c2m5_concert)。左下の密集が籠城地点。色=種別、凡例クリックで絞り込み、点にカーソルで座標。

録画者のプレイヤーだけは座標が毎 tick 残るので、1マップぶんの移動経路も丸ごと復元できた。14.6分で 6.6km 歩いていた計算になる。

録画者の全移動(c2m1_highway)。色=速さ(寒色→暖色)。▶で実時間再生、スクラバで任意の時点へ。
c5m3_cemetery23.4c1m2_streets23.1c1m3_mall20.8c2m5_concert20.7c2m2_fairgrounds20.4c5m2_park19.9c5m4_quarter19.3c2m3_coaster18.3c2m1_highway17.1c2m4_barns15.9c1m1_hotel15.2c1m4_atrium14.7c5m5_bridge13c5m1_waterfront11.5
マップ別の対戦所要時間(中央値・分)。 The Parish の墓地(cemetery)が最長23分。park には最大36分の膠着戦もあった。

取れないものは取れない

AI をもってしても、取れないものははっきりある。

どうやったか

復号の一式――.dem から通信メッセージを切り出し、SendTable を実データから復元し、エンティティを1ビットずつ開くところまで――は AI が書いた。コードは scripts/excavation に置いてある(Python 標準ライブラリのみ)。自分がやったのは、16年前に demo を録って自作 Ruby でヘッダーを少し読み解いていたこと、そしてそれを Drive から掘り出して AI に渡したことだ。

当時ほしかったのは、もう一度あの試合を観ることだけだった。0x0C の数バイトを直せば、それは叶った。16年後に同じファイルを開いたら、あの夏の全員が――座標も、視線も、撃った一発までも――まだそこにいた。


  1. サーバーやクライアントの挙動を決める設定変数(console variable)。対戦のルールもこれで全員に配られる。原典: Valve Developer Community — ConVar ↩︎

  2. エンティティの各プロパティを何ビットで送るかを定義するテーブル。Source のエンティティ同期の中核で、この記事で最大の難所になった層。原典: Valve Developer Community — Networking Entities ↩︎

  3. クライアントが毎 tick サンプリングしてサーバーへ送る入力コマンド(移動・視点・発砲など)。原典: Valve Developer Community — Source Multiplayer Networking ↩︎

  4. そのプレイヤーから見える可能性のある空間だけを送る仕組み(Potentially Visible Set)。demo に他人の視界が入らない理由。原典: Valve Developer Community — PVS ↩︎