hrxdev
日本語

#2 世界、セーブ、リプレイ、そしてセーブ形式に潜んでいたバグ

· Nick

最初の記事から、コアには物を置くための世界と、それを保存するファイル、そして2回の実行が一致することを証明する方法が加わりました。グラフィックスもキャラクターも、まだありません。今回の大半は配管のような作業ですが、その一部は、語る価値のあるバグでした。

タイルでできた世界

世界は 1×1 m のタイルのグリッドで、0から始まる離散的な階層に載っています。壁やドアは1枚のタイルです。階段は2つの階層をつなぐ特別なリンクになる予定ですが、まだ作っていません。内部では、世界は、マップ全体の平らなレイヤー(床、構造物、構造物フラグ)と、そこから導出される歩行可能性のレイヤーの集まりです。マップは、バージョンカウンタ付きの 16×16 チャンクで追跡されるので、レンダラーや経路探索は、タイルを比較せずに「この範囲は変わった?」と尋ねられます。

世界の大きさは、作成時に選びます。建設は、矩形を受け取るコマンドで行います。ある範囲に床、壁、ドアを置く、またはそれらを取り除く、というものです。矩形の一部が塞がっていても、残りは建設され、コマンドは何をスキップしたかを報告します。いまのところ、組み込みのタイルは床、壁、ドアの3種類です。マップは、今後ワールドのハッシュにもセーブにも含まれます。遅くても明らかに正しい参照モデルと、数千のランダムなコマンドで照合しています。

セーブとリプレイ

セーブとリプレイは、同じファイルコンテナを共有します。コンテナはラベル付きのセクションに分かれ、各セクションが型とバージョンを持つので、世界の新しい部分(キャラクター、欲求、人間関係)を、あとから古いファイルを壊さずに新しいセクションとして追加できます。ヘッダーと本体にはチェックサムがあり、本体は圧縮されています。すべてのサイズは、メモリを確保する前に上限と照合されるため、壊れたファイルや悪意のあるファイルでゲームが RAM を食い尽くすことはありません。タイルの種類は番号ではなく名前で保存されるので、種類の一覧が変わってもセーブは使い続けられます。

リプレイは、最初から欲しかったものです。シードとコマンドログです。記録中、コアは100ティックごとに確認用ハッシュも書き出します。再生時には、各コマンドの結果と各確認用ハッシュを途中で照合し、食い違った最初のティックで止まります。リプレイには、記録したコアのビルドも記録されているので、別のビルドのリプレイは、黙って食い違うのではなく、拒否されます。

コアは、ファイルには一切触れません。読み書きするのはストリームで、セーブの置き場所はゲーム側が決めます。この境界は、前と同じ禁止 API の仕組みで守られています。

まだできていないもの:リプレイファイルを再生するコマンドラインツール、そしてセーブのゲーム側(セーブフォルダ、オートセーブ、安全な書き込み)。

2回の実行が一致することの証明

決定論テストは、1つのシナリオを2つの別プロセスで実行するようになりました。シード、建設コマンドのスクリプト(わざと不正なものを含む)、すべての乱数ストリームから値を引く検証用システムです。各プロセスはそれを3通りで実行し、すべてのティックでハッシュを比較します。通しで実行、セーブと復元を挟んで実行、記録したバイト列のリプレイで実行、の3つです。さらに2つのプロセスは、10ティックごとにお互いのハッシュを比較します。

確実に失敗しうるテストにするため、わざと壊してみました。ティックにこっそり混ぜたシードなしの Random で、テストは赤になりました。プロセスごとに異なるシードを使う .NET のランダム化された文字列ハッシュは、1プロセスの中では気づかれず、2プロセスで比較して初めて見つかりました。2プロセスにしている理由は、まさにこれです。

私が書き留めたルールは、コアの同一ビルドが x64 と arm64 のプロセッサで同じハッシュを出さなければならない、というものです。Apple チップの Mac で録ったリプレイは、Windows PC で再生できなければなりません。固定した基準ハッシュと、サンプルのセーブ/リプレイファイルは、このチェックのためだけに存在します。浮動小数点の三角関数と指数関数はプラットフォーム間で結果が異なるため、世界に影響するものには使用禁止です。最初にそれを必要とするシステムが現れたら、決定論的な代替を用意する必要があります。これらの基準ファイルは、意図して変えるときだけ変わります。ハッシュがずれたら、受け入れる前にその理由を説明しなければなりません。

「ノー」と言えるベンチマーク

シナリオ、シード、ティック数を受け取り、JSON のレポートを出力するヘッドレスのランナーができました。レポートには、1ティックあたりの時間の中央値と99パーセンタイル、エージェント数、ワールドのハッシュ、ティックあたりに確保されたバイト数が入ります。最初のシナリオは、16階層にわたる 512×512 のマップを、約52,000の建設コマンドで作ります。

ベースラインはマシンごとに保持します。ノート PC の時間は、デスクトップでは意味がないからです。劣化と見なすのは、中央値がベースラインより10%以上高い場合、または99パーセンタイルが35%以上高い場合で、かつ増加が0.05 ms を超えるときだけです。ほぼ空のティックはタイマーの分解能の限界にあるためです。ひとつのゲートはすべてのマシンで有効です。ティックあたりのアロケーションはゼロバイト。いま何を測れているかについては、正直に言っておくべきでしょう。システムもキャラクターもない現在、1ティックは約35ナノ秒で、時間のゲートは、移動のマイルストーンで最初の本物のシステムが入るまで、ほぼ眠っています。いま仕事をしているのは、アロケーションのゲートです。

不安定ではなかった不安定なテスト

ベンチマークを作っている最中、コアは変えていないのに、一部のビルドでセーブ形式のテストが失敗するようになりました。このテストは、圧縮されたリプレイの各バイトを順に反転させ、ローダーがすべてを拒否することを要求します。あるビルドで、ファイルの終わり近くのバイトが受け入れられてしまいました。ひとつ前のビルドでは、同じテストが通っていました。

出たり消えたりした理由:リプレイには書き込んだビルドの識別子が含まれるため、圧縮後のバイト列がビルドごとに変わり、反転されるバイトも変わります。穴はずっとそこにあり、テストは、たまたまそこに当たったときにだけ気づいたのです。

穴そのもの:チェックサムは展開後のデータを対象にしていて、.NET の展開処理は寛容でした。終了を宣言しないストリームも受け入れ、終端のあとにあるものは無視します。最後のバイトのパディングビットを反転させると、別の、しかし有効なストリームになり、同じデータに展開されます。そのためチェックサムは一致し、壊れたファイルが正常なものとして読み込まれていたのです。

修正は、コンテナの新バージョンです。チェックサムはファイルに保存されたままのバイト列を対象にし、展開の前に検証されます。展開は、ヘッダーが示す位置でぴったり終わらなければなりません。空の本体は、空の入力に対して圧縮器が何も書かないため、圧縮せずに保存します。チェックは、ビルドに依存しない固定の圧縮ストリームと、壊れたファイルを使ったファジングに切り替えました。サンプルファイルは新バージョンで作り直しましたが、ワールドのハッシュは変わっていません。

そのほか

いまお読みのこのウェブサイトのトップページに、訪問者カウンターが2つ付きました。累計のユニーク訪問者数と、過去24時間のユニーク訪問者数です。集計はウェブサーバーのページログから行い、JavaScript も、Cookie も、外部サービスも使いません。アドレスはソルト付きのハッシュとしてだけ保存し、ボットはユーザーエージェントで除外します。数字は数分遅れます。同じネットワークの複数人は、1人として数えられます。

次は

最初のマイルストーン、コアの骨組みが完了しました。次は M1、Godot の建設ビューです。世界モデルからの壁、ドア、部屋、階層の切り替え表示、90° 刻みで回るカメラ、アートの代わりにただのキューブ。まだ着手していません。ロードマップをご覧ください。

← 開発ログ一覧 · 次:#3 →