Show HN: 10年のリバースエンジニアリングを経て、DOS版Wing Commanderの協力プレイを実現
要約
開発者は10年以上の歳月をかけて、オリジナルのDOS版Wing Commanderに協力プレイ機能を実装しました。このプロジェクトでは、メモリダンプの解析、ゲームエンジンのフック、そしてWebRTCとLobbylinkを用いた現代的なネットワークインフラストラクチャの構築が行われました。これにより、ブラウザ上でオリジナルのWing Commanderを友達とオンラインでプレイできるようになりました。
全文翻訳
11年間の旅を経て、DOS版Wing Commanderで協力プレイが実現
danielrh より
子供の頃、私の兄弟であるPatrickと私は、一方が操縦桿を握り、もう一方がレーザーとアフターバーナーを担当して、宇宙戦闘機を操縦していました。私たちは常に、単一の機体で二頭のヒドラのようにではなく、ウィングメイトとして協力してWing Commanderシリーズをプレイすることを夢見ていました。
最初は、どこにいるのかを探していました
2015年10月4日、クラシックなWing CommanderをDosboxでプレイしていたときに、ビル・ゲイツが「誰にでも十分なはずだ」と(おそらくは冗談で)言ったとされる640KBのメモリ全体が、おそらくその中に収まるだろうと気づきました。もしメモリをダンプして、プレイヤーとウィングマンのX,Y,Z座標を保持している場所を特定できれば、ネットワークパケットでウィングマンを制御できるはずです。
実際の引用
“1981年にそれらの決定を下したとき、私は10年間十分な自由を提供していると感じました。つまり、64KBから640KBへの移行は、かなりの期間続くものだと感じました。まあ、そうはなりませんでした。” 出典
1MB 0000 01A2 WC.EXE ルートイメージ DS 13D3 データセグメント、64KB 640KB A000 VGA E000 EMSページフレーム FFFF:F データセグメント、dseg:0000からFFFF 0187 デッド文字列 7728 ランダムシード A9C2 位置 D248 ミッショントゥリー 8DF8 コムテキスト CAE2 速度 FFxx スタック AEB6 · B1B6 · B4B6 右、上、前 C280 勝利点
これはアドレス可能なDOSメモリの1メガバイトと、Wing Commanderがゲーム状態を格納する64KBのデータセグメントの内訳です。
これは明確な問題提起となりました:100万バイト未満のメモリから、その場所をどうやって特定するか?私は、ゲームから他のエンティティを空にすることで問題を単純化することにしました。停止したプレイヤーは座標を変えないはずです。停止時にメモリを2回スナップショットして、「バックグラウンド」メモリの変動を特定できます。その後、前進して再度測定します。もし(マウスが無効になっていて)旋回しなければ、1つの軸しか変化しないかもしれません。次の問題は、空の空間領域を見つけることでした。ウィングマンのPaladinは私たちと一緒に移動するので、彼なしで始める必要がありました。悲惨なミッションの後、邪悪な宇宙猫にPaladinを圧倒させ、宇宙船キャリアであるThe Clawに引き返した後、次のミッションは空の空間を飛行するように準備されました。私は離陸し、すべてがデスポーンするまでまっすぐ前進し、DOSBoxのMEMDUMPBINデバッグコマンドを使用してメモリのスナップショットを取りました。
[画像: Hornet cockpit on launch, the right VDU showing Auto Targetting, Target: None]
ホネットのコックピット、発進時。VDUはターゲットがない空域を示しています。
これで、私の宇宙戦闘機が元の位置にあったときに変化しなかったバイトと、私の宇宙戦闘機が移動したときに変化したバイトを特定する必要がありました。
(snapshotorigin ⊙ snapshotlater) ∧ (snapshotorigin ⊕ snapshotmoved)
A stoppedstopped5 B stopped againstopped6 C after movingstraight4 origin ⊙ later same in origin and later origin ⊕ moved different in origin and moved ∧ candidates 413 bytes 132 with chase view 1,048,323 bytes still 650 bytes moved 253 bytes churn even while stopped
停止時に253バイトの変動があり、移動中に650バイトが変化します。両方で413バイトが変化します。チェイスカメラとのクロスリファレンスにより、合計はわずか132バイトです。
データセグメントで変化したバイトには35個の異なるクラスターがありました。これらを視覚的に検査し、移動時に浮動小数点数や小さな整数値の変化を探すことで、候補となる場所を12箇所に絞り込みました。チェイスカメラビューも確認したところ、13D3:A9C2に絞り込むことができました。DOSBoxデバッガでそのアドレスの整数をSMコマンドで加算したところ、船が新しい位置に飛び出すことが確認されました。
この手法により、速度もCAE2で特定できました。なぜなら、クラフトが停止したときにゼロになる32ビット整数が3つあったからです。
[画像: The 64K data segment of the October 2015 dumps, one byte per pixel, with the candidate rows labelled: position at A9C2 and its camera copy at AC9E, set speed at B9F6, velocity at CAE2 and CDBE, the stack]
クラフトの位置、カメラの位置、設定速度、速度の変化が黄色で表示されています。
メモリエディタで船を動かすのは楽しかったです。The Clawや他のNPC船の周りでこれを再現しました。
次に、何に直面しているかを探しました
約1年間放置しましたが、2016年9月に、GUIのvbindiffだけを使うのではなく、DOSBoxのスナップショットを自動的に比較する小さなツールを書くことにしました。方向性の特定はより困難であることが判明し、さらに数ヶ月間問題から離れ、11月に再開しました。
[画像: The original 2016 diff image of a megabyte, rotated to a strip: a single bright band near the data segment and a noisy block near the EMS page frame]
旋回時の2つのDOSBoxメモリダンプの差分。明るい帯はデータセグメントです。
A9C2 position AEB6 right B1B6 up B4B6 forward CAE2 velocity 012 81018202830383D3F slot 0 player · 1 wingman · 2 Tiger's Claw · 3D camera · 3F temporary camera 0x300 apart64 × 12 bytes contiguous slot 0 → slot 0x3D: 0x2DC bytes (A9C2 → AC9E, CAE2 → CDBE) int32 x int32 y int32 z one slot = 12 bytes
64スロットの配列では、各スロットは12バイトです。スロット0はプレイヤー、0x3Dはカメラ、0x3Fは一時スロットです。カメラのコピーにより、各値が0x2DCバイト離れて2回出現しました。
回転のメモリ位置を見つけるために、もっと詳しく調べる必要がありました。偶然にも、ベクトルは4バイトのスカラーで構成されており、256が1.0、有効な正のベクトル位置は0から255の間でした。これは、ほとんどの小さな回転は1バイトしか変更しないことを意味しました。しかし、当時のプログラマーのジョークと思われるもののおかげで、その日は幸運でした。Wing Commanderはエンティティプロパティのために64個のスロットを予約していました。16進数ではこれは0x00から0x3Fまでの範囲になります。しかし、プログラマーは一時的な用途に0x3Fを使用し、カメラを0x3Dに配置することにしました。3Dグラフィックスがまだ初期段階だったため、プログラマーは3Dカメラを操作する必要があるたびに0x3Dと書くことを面白いと思ったのかもしれません。これにより、一時変数とカメラ変数が2つのスロット離れて配置されました。そして、上方向と前方方向も互いに隣接していました。これにより、1バイトの変化を見つけることができる広い領域ができました。最初に理解できたバイトは、右ベクトル(right vector)のy成分でした。その周りに配置したプリントウィンドウは、カメラのアップベクトル(up vector)のコピーをカバーしており、そこから上書き実験が始まりました。さらに、船を回転させたところ、符号拡張が発生し、隣接する3バイトすべてが0x00から0xffに切り替わりました。これらの2つの事実により、16進数ダンプの一時変数とカメラのバイトに気づきました。それらに書き込んでも、毎フレーム上書きされるため役に立ちませんでしたが、幸いなことに、次のベクトル、つまりforward[0]にもいくつかの追加バイトを書き込みました。これにより、星空に顕著な変化が生じ、コードの調査を開始できるようになりました。
end of the up table start of the forward table up[0]B1B6 the ship copied every frame up[0x3D]B492 camera copy up[0x3E]B49E empty up[0x3F]B4AA temporary forward[0]B4B6 the ship my write overwritten every frame starfield shifts 000000000001000000000000 level: (0, 256, 0), 256 = 1.0 82FFFFFF34FFFFFFA5FFFFFF rolled: (-126, -204, -91), 00 bytes became FF xyz three 4-byte scalars, little-endian
アップベクトル配列には、オフセット0x3Dにカメラのコピー、0x3Eに空のスロット、0x3Fに一時的な中間値があります。256が1.0を意味するInt32なので、ゼロでない単位ベクトルの軸はリトルエンディアンで00 01 00 00になります。
[画像: Annotated picture of changed bytes for a stopped versus yawed pair from the advanalysis dumps, with arrows to the same arrays]
停止時とヨーイング時のバイト変化をアノテーションした画像。同じ配列への矢印付き。
メモリウォッチポイントをアドレスに設定し、ペリシップ物理関数(per-ship physics function)によって呼び出されるベクトル加算関数(vector-add function)をトレースし、最終的にはメインループに到達しました。
BPM 13D3:A9C2 write watch on position[0] vector_add 329A:04DA (stub140) pos[si] += vel[si] physics per ship 2B0F:19E0 (stub141) for si in 0..3C per frame 0560:074C seg001 main loop 0560:20E3 once per frame fires in called by called by called by DOSBox hook: CS:IP == 0560:20E3 run queued calls, exchange a frame neighbours in overlay 140: sub 0568 · zero 047A · cross 09BE normalize 0A7F · orthogonalize 1163 yaw 12B1 · pitch 1280 also seen at 34A5:19E0: same overlay, another load
メモリウォッチポイント後のコールスタック。
IDAでバイトシーケンスを見つけ、ゲームにアノテーションを付け始める時が来ました。Wing Commander Multiplayerは、これらのテクニックを繰り返し、ある船を別の船の入力からどのように操作するかを理解するだけの問題になりました。
次に、どう行動するかを探しました
2016年12月、グローバルメモリへの書き込みだけでWing Commanderを操作できるという幻想は打ち砕かれました。レーザー発射やヒートシーカーの表示の詳細を、アドレスからのみ操作するのは複雑すぎました。最も正確な体験を得るためには、元のゲームの関数自体を乗っ取る必要がありました。最初の考えは、プログラムカウンターを正しい場所に「意図」することでしたが、引数を挿入し、元の場所に戻る方法が必要でした。さらに、多くの関数は様々なオーバーレイにあり、呼び出すには間接参照が必要でした。Borlandはコンパイルユニット(C++ファイル)ごとに異なるオーバーレイを持っており、プレイヤーが以前に取ったパスに応じて、それらが異なる場所に動的にロードされていました。
root image (fixed addresses) stub143 = segment 12D7 12D7:0084 EA 99 0A 31 2F jmp far 2F31:0A99 12D7:012E EA 78 29 31 2F jmp far 2F31:2978 12D7:0156 EA 76 2E 31 2F jmp far 2F31:2E76 … one entry per exported function call far 12D7:0084 overlay 143, loaded on demand this run: 2F31 · other runs: 2BBE, 34A5 … 2F31:0A99 do_damage(src, dst, qty, vec) 2F31:2978 fireGunFromShip(ship, gun) 2F31:2E76 fireAllGuns(ship) ships, damage, weapons: ovr143 in IDA via the EMS page frame DOSBox hook, isExecutingFunction(0x12D7, 0x0084) stub bytes 1..4 → live segment:offset reads matches
Borlandオーバーレイ:jmp far thunkのテーブル。
Patrickと私は、正しい引数で関数を呼び出すための良いトランポリンシステムを見つける必要がありました。私たちは、トランポリンを保持するために、13D3:0187にある「Loading WING COMMANDER」文字列と、最終的には13D3:0395の「Sorry, an error has occured while %s」[sic]を使用することにしました。
DOSBoxにCS:IPをスタックにプッシュさせ、その後、命令ポインタを上記の文字列にリロケートさせます。
00 terminates any string pointing here.
pushf; pusha the game is mid-frame; leave registers as we found them
xor si,si; push si argument: ship 0. Later thunks use mov si,imm16 for each argument
call far 12D7:0156 through the stub, so it works wherever the overlay is loaded
pop si; popa; popf cdecl: caller cleans the arguments
retf back to the CS:IP DOSBox pushed
これによりレーザー砲が起動することに喜びを見出しました。
DOSBox (host) guest CPU guest memory 1 byte in fire.fifoor a network event 2 push CS, push IP return address: right here 3 write the thunk over the dead string 4 CS:IP = 13D3:0188 resume emulation the game any CS:IP 5 thunk runs pushf pusha … call far 6 fireAllGuns 12D7:0156 → ovr143 13D3:0187 "Loading WING COMMANDER. " ← before 13D3:0187 00 9C 60 33 F6 56 9A 56 01 D7 12 5E 61 9D CB NUL · pushf · pusha · xor si,si · push si · call far 12D7:0156 · pop si · popa · popf · retf pauses here retf from the game function 7 retf → the saved CS:IP
DOSBoxはCS:IPをプッシュし、スレッドをデッド文字列の上に書き込み、CS:IPをそれにポイントさせ、スレッドのretfはゲームが中断した場所に戻ります。
2017年3月、メインループ(0560:20E3)で予測可能な場所を見つけ、ゲームの予測不可能な部分にいることを心配する必要がないようにフックしました。そして7月にはdo_damage関数を見つけ、二次プレイヤーとしてゲームがそれを呼び出すのを防ぎました。
次に、遠くのフィールドを一つにまとめようとしました
2017年8月、シンプルなTCPクライアント/サーバーアーキテクチャとprotobufゲームメッセージを使用するネットワークプロトコルを開発しました。毎フレーム、各船の位置がリーダーから送信され、さらに発射イベント、デスポーン、ダメージも送信されました。クライアントは自身の位置、レーザーが発射されたかどうか、または壊滅的なイベント(射出、着陸、死亡)が発生したかどうかを返信しました。
各プレイヤーは自身のクラフトをエンティティ0、もう一方のプレイヤーをエンティティ1として扱いました。これは、他のエミュレーターからパケットを受信した際にスワップが必要でした。
TCP · protobuf messages · one exchange per frame leader (server) the mission runs here slot 0: leader · slot 1: wingman client memory writes job queue for this frame slot 0: wingman · slot 1: leader every ship's position, every frame fire events · despawns · damages its own position · lasers fired ejected · landed · died positions → memory writes · events → job queue
位置は毎フレーム送信され、イベントはフレームごとのジョブキューに追加されます。
非常にシンプルなトランポリンから始めました。
pushf
pusha
nop
popa
popf
retf
Borlandのオーバーレイローダーがコードページを切り替えるとき、スタック上の保存されたBPフレームのチェーンをウォークし、オーバーレイを指すリターンアドレスをパッチします。手作りのスタブにはフレームがなかったため、ウォークはガベージを読み取り、ゲームはランダムな瞬間にクラッシュしました。これは10月から12月にかけて常に悩みの種でした。
修正は、トランポリンをBorland関数のように見せることでした。
push bp
mov bp, sp
nop (0x90) ; welcome to the 0x90's
pop bp
retf
frame start 0560:20E3 trampoline stub 55 push bp 8B EC mov bp,sp 90 nop ← hook point 5D pop bp CB retf job queue (DOSBox side) fire(ship 1, gun 2) · damage(src, dst, qty, seed) spawn(situation 3, ship 7) · despawn(12) · chat(…) from the network and local events game function call far stubNNN:xxxx … retf finish(): read AX jump next job? inject the call retf → the nop again queue empty: resume the frame
0x90のnopの周りに適切なBorlandフレームがあり、DOSBoxはこれを監視して次のキューに入れられた呼び出しを注入します。ジョブキューは、マルチプレイヤーシナリオでゲームを正しく機能させるために必要なものに基づいていました。
2017年10月、一時的なオブジェクトがプレイヤーごとに異なるスロットに配置されることに気づきました。DOSBoxからの新しいフックにより、関数がいつ戻ったかを判断できるようになり、新しい船や弾丸がどの数値スロットにスポーンしたかを記録できるようになりました。
一時的なオブジェクトには独自のスポーンルールがあり、61個のエンティティスロットの共有領域を占有することがありました。一時的なオブジェクトが配置される場所を変更し、スロット12から15に限定してマルチプレイヤーの位置を一致させました。
12月には、クライアントがサーバーの船IDとローカルIDのマッピングを保存するように変更し、スポーン関数の戻りを強制することをやめました。これにより、パケットがローカルIDとホストIDの間で変換できるようになりました。ついに、マルチプレイヤーでアステロイドが機能するようになりました。
server slots client slots 0 leader 1 wingman 2 Tiger's Claw 3 Dralthi 4 Dralthi 12 laser bolt 13 laser bolt 0 wingman 1 leader 2 Tiger's Claw 3 Dralthi 5 Dralthi 14 laser bolt 12 laser bolt server id → local id 0 → 1 1 → 0 2 → 2 3 → 3 4 → 5 12 → 14 13 → 12 packet ship id local slot
同じ船が2台のマシンで異なるスロットに表示されることがあります。
2018年には、爆発、ミサイル、オートパイロットのアニメーションに注力しました。
成功と敗北のための探求は続いた
2019年6月、ミッション勝利計算が不安定で気まぐれであることに気づきました。実際の勝利点集計の場所を見つけるために、バイナリサーチを行うことにしました。64KBのデータセグメント全体をネットワーク経由でコピーし、徐々に半分にカットしました。多くの二分法を経て、ミッショントゥリーを保持していた13D3:D248にある80バイトの場所を特定しました。そして10月には、全体的な勝利点カウンターを13D3:C280、ミッション番号を13D3:C255とC256で見つけ、これにより数年後にドロップダウンからプレイするミッションを選択できるようになりました。
00008000FFFF 1FAIL (0, 32768) 2OK (32768, 32768) 3OK (49152, 16384) 4OK but no landing sprite (53248, 8192) 5OK (53248, 2048) 6OK (53760, 256) 7FAIL (53760, 128) 8OK (53832, 128) 9OK (53832, 80) 10FAIL (53832, 64) dseg:D248, 80 bytes navPointState[]: 25-byte records flag byte ORed at ovr134:122F: or [bx-2DA2],dl
データセグメントを80バイトまで二分法で絞り込みました。
そしてついに、遠くからの言葉の組み合わせの喜び
2020年初頭、通信テキストを再生する関数を特定しました。これにより、ネットワーク経由でチャットを送信し、タイピングされたチャットをシステムに直接フックして、ウィングマンのVDUで再生できるようになりました。これは、ゲームの熱狂的なシーケンスのいくつかを思い出させる、非常に没入感のあるチャット方法を可能にしました。
[画像: Two browsers side by side: Maverick's cockpit shows ANGEL: You perform your duty well, mon capitaine; Angel's cockpit shows Maverick: there's one for the chalkboard!]
両方のパイロットがタイプし、相手にメッセージを送っています。
2020年のいくつかの最終作業により、ゲームはプレイ可能な状態になりましたが、友達と楽しさを共有する方法についてはアイデアが尽きていました。古いゲームのコピーを見つけ、奇妙なバージョンのDOSBoxをコンパイルまたはダウンロードし、お互いのパブリックIPを共有し、ファイアウォールを突破して一緒にプレイすることを期待するのは、残念ながらあまりにもハードルが高すぎました。
数回の家族でのプレイはありましたが、ゲームはGitHubに置かれたままで、ほとんど放置されていました。
finding data and code making the game do things 2016 2017 2018 2019 2020 Oct 2015 first dumps, position Sep 2016 snapshot compare tool Nov 2016 right/up/forward, slot 3D, 256 = 1.0 Jul 2017 do_damage Jun–Jul 2019 bisection → D248 Oct 2019 victory points, mission numbers Jan 2020 comm text Dec 2016 – Mar 2017 injected calls, main-loop hook Aug 2017 protobuf over TCP Oct–Dec 2017 trampoline, push bp, slots 12–15, ID map 2018 explosions, missiles, autopilot Mar 2020 chat in the comm panel Oct 2020 playable state
Wing Commander Multiplayer 発見タイムライン。
そして2026年、機械支援開発が私たちを一緒に笑わせてくれた
それまでは。広範なWebRTC、堅牢なEmscriptenサポート、そして私たちが構築したlobbylinkという新しいシステムの登場により、プロジェクトを復活させ、より広く共有するのに適した時期が来たと感じました。
lobbylink
私はhttps://github.com/danielrh/lobbylinkを構築しました。これは、Websocketライブラリのみに依存するシンプルなGolangのロビーシステムで、ピアツーピアのWebRTC接続をオーケストレーションします。これは任意の小さなサーバーで実行でき、プレイヤーがWebRTCと共有ルームコードを使用して互いに接続できるようにします。サーバーは静的なオリジン許可リストを持ち、ブラウザやほとんどの言語のクライアントソフトウェアをルームコードで接続させることができます。ルームコードは、同じルームコードを持つ他のプレイヤーを見つけるために使用され、そのルーム内のすべての参加者の通知を受け取ります。サーバーはcoturnも実行し、参加応答でTURN認証情報を発行するため、制限の厳しいファイアウォールを持つプレイヤーも参加できます。
pilot A's browser DOSBox (wasm) WC.EXE + hooks lobbylink client lobbylink server (Go) rooms by room code static allowlist relays WebRTC signaling pilot B's browser DOSBox (wasm) WC.EXE + hooks lobbylink client home router / NAT home router / NAT join: room code joiners in the room offer · answer · ICE join: room code joiners in the room offer · answer · ICE WebRTC data channel: the game's packets · voice TURN relay (fallback)
ロビーは、同じルームコードを持つ人々がWebRTCを使用して直接通信することを可能にします。TURNリレーは、他に選択肢がない場合に接続を確立するのに役立ちます。
pilot A's browser lobbylink server pilot B's browser join WC1-4821 joined: player 0, ICE servers join WC1-4821 joined: player 1, ICE servers player-joined: 1 offer (from the lower id) answer ICE candidates data channel open · player 0 hosts
ルームコード参加ステップ。
Lobbylinkにトランスポートを移植し、LLMにビルドスクリプトをEmscriptenターゲットに設定させ、Wing CommanderのGOG Inno Setupインストーラーパッケージを取得して解凍し、Emscriptenのデータファイルとして使用するように依頼しました。これらの組み合わせにより、GOGからWing Commanderをダウンロードし、ブラウザのクライアントサイドストレージにドラッグアンドドロップし、ルームコードを入力してプレイすることが可能になります。
ゲームはhttps://fireflyk64.github.io/dosbox/でプレイ可能で、エミュレーターはhttps://github.com/danielrh/wcmultiにあります。
さらにボーナスとして、LLMにボイスチャットを追加するように依頼しました。これにより、人々が選択すれば、セカンドWebRTCピア接続を使用してリアルタイムのボイスをゲーム内でやり取りできます。そして、私たちは一緒に飛び回り、笑うことができるようになりました。