- Windows 11 24H2でSkimmer水上機が消える、またはスポーン直後にプレイヤーが異常に高い空へ吹き飛ばされる問題が再現されたが、原因はOSではなくゲーム内部の古いデータ処理バグだった
- Skimmerの
vehicles.ide行では飛行機に必要なホイールスケール2個の値が欠けていたが、CFileLoader::LoadVehicleObjectはsscanfの戻り値を確認せず、初期化されていないローカル変数をそのまま使っていた
- 以前のWindows環境では直前の車両TopFunの
0.7というホイールスケール値がたまたまスタックに残っていたためSkimmerが正常に見えていたが、Windows 11 24H2でLeaveCriticalSectionのスタック使用量が変わり、その偶然が崩れた
- 誤ったホイールスケールはサスペンション計算と衝突ボックスのZ座標を汚染し、生成高度やブレード速度の計算にまで伝播して、カメラ位置の異常、burn-in効果、SilentPatch環境でのループ停止につながった
- 解決策は
vehicles.ideのSkimmer行に-1, 0.7, 0.7, -1を追加するか、次のSilentPatchホットフィックスを適用することであり、入力データ検証とコンパイル警告の管理が長期的な互換性に直接影響する
Windows 11 24H2で表面化したSkimmerの症状
- SilentPatchのイシュートラッカーに、Windows 11 24H2アップデート後にSkimmer機がゲームから完全に消えたという報告が投稿された
- トレーナーでもスポーンせず、本来のスポーン地点でも見つからなかった
- MOD入りのゲームと、SilentPatchだけを適用したバニラのコピーの両方で再現した
- GTAForumsでも2024年11月から同じ問題が報告されており、一部ユーザーはSilentPatchを疑ったが、完全な無改造ゲームでも同じ現象が発生した
- Windows 10 22H2とWindows 11 23H2ではSkimmerは正常にスポーンし、Windows 11 24H2のユーザーは同じバグに遭遇した
- 24H2仮想マシンでリモートデバッグした結果、他の飛行機やボートは正常で、Skimmerだけが消える状態だった
異常高度と終わらないブレードループ
- スクリプトでSkimmerを強制生成してCJを乗せると、プレイヤーは
1.0287648030984853e+0031m、約10.3ノニリオンメートルの高さへ吹き飛ばされる
- SilentPatchがインストールされている場合、ゲームはプレイヤーを上空へ飛ばした直後にループへ入り停止する
- SilentPatchがない場合はゲーム自体は停止しないが、カメラが無限大に近い位置へ移動するときに起こる有名なburn-in効果が現れる
- 停止箇所は
CPlane::PreRenderのローターブレード角度正規化ループだった
m_fBladeSpeed値が3.73340132e+29まで増大する
6.2831855を繰り返し引いても浮動小数点表現上は値が変化せず、ループが終わらない
- ブレード速度は飛行機の高度に比例する値から派生するため、Skimmerが最初から異常に高い位置に生成されていた手がかりになる
衝突ボックスを汚染したサスペンション計算
- スクリプト生成関数
CCarCtrl::CreateCarForScriptは、渡されたZ座標にGetDistanceFromCentreOfMassToBaseOfModelの結果を加算する
- Skimmerの衝突ボックスを確認すると、
bbox.sup.zが-4.30747210e+33のようなあり得ない値に汚染されていた
- データブレークポイントで追跡した結果、初期ロード時点の衝突ボックス値は正常だった
- 初期
bbox.sup.zは-2.21952772だった
- その後、車両が最初にスポーンするときに
SetupSuspensionLinesがサスペンション高を反映して衝突ボックスのZ座標を更新する
- 問題はサスペンションライン計算に入る入力値の1つだった
- 計算には
handling.cfgのサスペンション上限・下限と、vehicles.ideのホイールスケールが使われる
- Skimmerの
handling.cfg値は他の飛行機と大きくは違わなかった
Skimmerの短いvehicles.ide行
- Skimmerの
vehicles.ide定義は他の飛行機より短く、最後の4個のパラメータが欠けている
- 欠落した値のうち2個が前後ホイールスケールである
- ボートではこの値がなくても問題ないが、Skimmerは飛行機の中で唯一このパラメータを省略している
- SkimmerはVice Cityではボートとして定義されていたが、San Andreasで飛行機に変更される過程で新たに必要になったパラメータが追加されなかったようだ
- 欠落したパラメータを戻すとSkimmerは正常動作する
sscanfの戻り値を確認しなかったローダー
CFileLoader::LoadVehicleObjectはvehicles.ideの1行をsscanfで解析する際、すべてのパラメータが常に存在すると仮定していた
- この関数は
sscanfの戻り値を確認せず、最後のパラメータの大半にデフォルト値も設定していなかった
wheelModelIDは未初期化
frontWheelScale、rearWheelScaleも未初期化
wheelUpgradeClassだけが-1で初期化されていた
- Skimmerのように値が欠けた行では、ホイールスケール変数が未初期化のまま残り、その値が車両データへ伝播する
- SilentPatchの修正は
sscanf呼び出しをラップし、最後の4個の値にデフォルト値を与える方式である
wheelModelID = -1
frontWheelSize = 0.7f
rearWheelSize = 0.7f
wheelUpgradeClass = -1
- 修正コミットはSilentPatchリポジトリに反映されている
20年間隠れていた理由
- San Andreasは静的リンクされたCRTを使用しているため、WindowsのCRTレベルのホットフィックスが
sscanfの動作を変えたわけではない
- Windows 10では、Skimmer解析直前のローカル変数位置に
0.7という値が残っていた
- この値はSkimmerの直前に定義されているTopFunのホイールスケールと一致する
- TopFun行には
-1, 0.7, 0.7, -1が入っている
vehicles.ideは順番に読み込まれ、各行ごとにLoadVehicleObjectが呼び出される
- Windows 10では
LoadVehicleObject呼び出しの間にそのスタック位置が上書きされず、Skimmerは偶然TopFunのホイールスケールを受け継いでいた
- Windows 11 24H2では次の行を読む過程で
fgets内部のLeaveCriticalSectionがより多くのスタックスペースを使い、その結果残っていた値が上書きされた
Windows 11 24H2は引き金にすぎなかった
- 内部WinAPI関数がスタックを使う方式は契約された動作ではなく、事前告知なしに変わりうる
- Windows 11 24H2はゲームが依存していた偶然のスタック残存値を消しただけで、実際の原因はゲームの未定義動作にある
- Windows 10でもホイールスケールの直後のローカル変数はすでに
LeaveCriticalSectionによって上書きされており、ゲームは数年前からこのバグに遭遇しうる状態だった
- San AndreasはWindows 98もサポートしていたため、このバグは少なくとも10以上のWindowsバージョンと複数のWineリリースで偶然表面化しなかった
- 公式1.01 PCパッチではこのバグは修正されなかったが、元のXboxリリースにはデフォルト値
1.0を入れる修正が入っていた
- Steam 3.0、newsteam、RGLはXboxコードブランチベースのため、この修正を受け継いでいる
- War Drum StudiosのAndroid、X360、PS3リリースとDefinitive Editionも影響を受けない
SilentPatchが0.7をデフォルト値に選んだ理由
- SilentPatchはRockstarのXbox修正の
1.0ではなく、0.7をデフォルトのホイールスケールとして使う
- 選択理由は3つある
- PC版ではSkimmerはこれまで実質的にTopFunのホイールスケールである
0.7で動作してきた
- 水上に浮く他の非ボート車両であるSea SparrowとVortexもホイールスケールが
0.7である
- ゲーム内の多くの自動車のホイールスケールも
0.7である
自分で修正する方法
- コード修正は次のSilentPatchホットフィックスに含まれる予定である
- すぐに直すには、San Andreasディレクトリの
data\vehicles.ideをメモ帳で開き、460, skimmerで始まる行を置き換えればよい
- 置き換える行は次のとおり
460, skimmer, skimmer, plane, SEAPLANE, SKIMMER, null, ignore, 5, 0, 0, -1, 0.7, 0.7, -1
古いゲーム互換性が残した教訓
- この問題はSan Andreasの単純なバグであり、その関数はもともと正しく動作できないコードだった
- 内部実装のスタックレイアウト変化も、バグのあるアプリケーションが特定の挙動に偶然依存していると互換性問題につながりうる
- 類似例として、Windows 10で壊れたBully: Scholarship Editionも誤った前提に依存していたため、OSの変化で問題が表面化した
- San Andreasの根本問題は、不完全な設定行を弾けなかった入力データ検証の欠如だった
- このコードはもともとコンパイル警告を出していた可能性が高く、警告を無視または無効化すると、長期間隠れていたバグが実際のユーザー問題として表面化しうる
1件のコメント
Hacker Newsのコメント
こういう記事はRaymond Chenから期待するようなレベルで、それはものすごい褒め言葉。
なぜそうなのかまでさらに掘り下げて突き止めている点がうれしい。
個人的には、契約に含まれていない動作ならランダム化すべきだと思う。
たとえば言語がマップの走査順序を保証しないなら、意図的に順序をランダム化すべき。
そうしないと、「動いている間は問題ないが、ある日突然壊れる」脆いコードが生まれる。
-ftrivial-auto-var-initのように、未初期化変数を特定の値やランダムな値で初期化するコンパイラオプションはいくつかある。しかし関数呼び出しごとにスタック全体の内容をランダム化したりゼロ埋めしたりすると、性能低下がひどすぎるため、普通はそうしない。
デバッグ目的でこうしたことをするツールはあるが、そのモードではプログラムがずっと遅く動く。
おそらくLinuxカーネルのメンテナーがユーザー空間を絶対に壊すなとこだわる理由も、これなのだと思う。
API利用者が十分に多ければ、契約で何を約束したかは重要ではなく、システムの観測可能なあらゆる動作に誰かが依存するようになる。
ランダム化を約束すれば、誰かはそのランダム化にも依存する。
そうなると、それも永遠に取り除けなくなる。
使わない変数の初期化のような不要なオーバーヘッドを強制的に払わされない。
「コンパイル警告を無視するな」という部分について、ここでどんなコンパイラエラーを期待できるのか分からない。
scanfの戻り値が引数の数と一致するか確認していない、という程度だろうか。それ以外はコンパイラには分からないデータファイルのエラーに見える。sscanfの戻り値を確認しなくてもデフォルトでは警告が出ない。小さな例では、
g++ -Wall -Wextra -Wunused-resultを付けても警告は出なかった。だが1行全体を単一の
sscanf呼び出しでパースしているので、コンパイラの静的解析は値がすでに初期化されたと仮定するしかない。このバグを捕まえる一般的な静的解析の方法はなさそう。
ただし
scanf専用の警告を作って、事前に初期化済みの値を渡すか戻り値を確認するよう強制することはできそうだ。こういう深い技術分析記事を読むのはいつも楽しい。
AI時代にこうした記事がさらに珍しくなるのかどうかが気になる。
AIが彼らを置き換えることもないし、過去50年以上のソフトウェア開発の革新もそうできなかった。
高水準プログラミング言語の開発者の何百万人、もしかすると何千万人は、スタックとヒープの違いを学校でぼんやり習った理論程度にしか知らず、日常業務では気にする必要がないので関心もない。
このWindowsバージョンで、クリティカルセクションのロック/アンロック実装の何が変わったのかのほうが気になる。
このコードが気になるのは自分だけだろうか?
while (this->m_fBladeAngle > 6.2831855) { this->m_fBladeAngle = this->m_fBladeAngle - 6.2831855; }除算をするのが面倒で、無限ループになり得る
whileループを使ったように感じる。ただ、
sscanfでJSONをパースしてGTA5のロード時間を5分も増やせたことを考えると、あまり期待はできない。コンパイラにも、これをさらに最適化する手法があるかもしれない。
これが無限ループになる方法は実質的にない。アンダーフローはあり得るが、そのためには角度がすでに
2*piより小さくなければならず、その場合はループを抜ける。fmodをまったく知らなかったようだ。アクセスに問題がある人はこのリンクを使えばよい。
https://web.archive.org/web/20250423144746/https://cookieplm...
C/C++を知っているので、ブログの序盤からだいたい何が起きているのか、つまり未初期化変数の問題だと見当がついた。
変数を初期化しないままにしておける言語というのは驚きだ。これにより、実際に見た本番環境のバグを含め、数え切れないほどのバグが生まれており、捕まえるには追加のコンパイラフラグや静的解析ツール、Valgrindなどに頼らなければならないことが多い。
より新しい言語はデフォルトのゼロ値を使う、あるいは使用前の初期化を強制するなど別の解決策を選んでいるのに、それでも人々はC/C++に戻り続ける。
「これらすべての発見は、バグがWindows 11 24H2の問題ではないことを証明している。内部WinAPI関数のスタック使用方法のようなものは契約ではなく、事前告知なしにいつでも変わり得る」という部分で、以前読んだ素晴らしい記事を思い出した。
要点は、十分に成功したAPIには非公開APIなど存在しない、という内容だった。