1 ポイント 投稿者 GN⁺ 2025-04-24 | 1件のコメント | WhatsAppで共有
  • Windows 11 24H2でSkimmer水上機が消える、またはスポーン直後にプレイヤーが異常に高い空へ吹き飛ばされる問題が再現されたが、原因はOSではなくゲーム内部の古いデータ処理バグだった
  • Skimmerのvehicles.ide行では飛行機に必要なホイールスケール2個の値が欠けていたが、CFileLoader::LoadVehicleObjectsscanfの戻り値を確認せず、初期化されていないローカル変数をそのまま使っていた
  • 以前の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::LoadVehicleObjectvehicles.ideの1行をsscanfで解析する際、すべてのパラメータが常に存在すると仮定していた
  • この関数はsscanfの戻り値を確認せず、最後のパラメータの大半にデフォルト値も設定していなかった
    • wheelModelIDは未初期化
    • frontWheelScalerearWheelScaleも未初期化
    • 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件のコメント

 
GN⁺ 2025-04-24
Hacker Newsのコメント
  • こういう記事はRaymond Chenから期待するようなレベルで、それはものすごい褒め言葉。
    なぜそうなのかまでさらに掘り下げて突き止めている点がうれしい。

  • 個人的には、契約に含まれていない動作ならランダム化すべきだと思う。
    たとえば言語がマップの走査順序を保証しないなら、意図的に順序をランダム化すべき。
    そうしないと、「動いている間は問題ないが、ある日突然壊れる」脆いコードが生まれる。

    • -ftrivial-auto-var-initのように、未初期化変数を特定の値やランダムな値で初期化するコンパイラオプションはいくつかある。
      しかし関数呼び出しごとにスタック全体の内容をランダム化したりゼロ埋めしたりすると、性能低下がひどすぎるため、普通はそうしない。
    • このレベルのランダム化はコストが高すぎる。
      デバッグ目的でこうしたことをするツールはあるが、そのモードではプログラムがずっと遅く動く。
    • 契約という観点では、原文にあるこの教訓もある。「互換性における興味深い教訓だ。アプリケーションにバグがあり、特定の動作に意図せず依存している場合、内部実装のスタック配置の変更でさえ互換性への影響を生み得る」
      おそらくLinuxカーネルのメンテナーがユーザー空間を絶対に壊すなとこだわる理由も、これなのだと思う。
    • いや違う。https://www.hyrumslaw.com/を思い出すべき。
      API利用者が十分に多ければ、契約で何を約束したかは重要ではなく、システムの観測可能なあらゆる動作に誰かが依存するようになる。
      ランダム化を約束すれば、誰かはそのランダム化にも依存する。
      そうなると、それも永遠に取り除けなくなる。
    • Cのような言語の利点の一つは、選んだ機能に対してだけコストを払う点だとも言える。
      使わない変数の初期化のような不要なオーバーヘッドを強制的に払わされない。
  • 「コンパイル警告を無視するな」という部分について、ここでどんなコンパイラエラーを期待できるのか分からない。
    scanfの戻り値が引数の数と一致するか確認していない、という程度だろうか。それ以外はコンパイラには分からないデータファイルのエラーに見える。

    • g++ 11.4で試すと、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ループを使ったように感じる。

    • GTAの開発者たちは、PlayStation 2のような環境で浮動小数点除算より速いからこういうハックをしたのだと信じたい。
      ただ、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など存在しない、という内容だった。

    • その記事を見つけてリンクしてくれるとうれしい。論理が気になる。
    • これに関連するXKCDの漫画があったと思う。