
2026/08/31 21:01
ImHex を使った未知のファイル形式のリバースエンジニアリング
RSS: https://news.ycombinator.com/rss
要約▶
Japanese Translation:
ここで詳述される主な成就是不動の ImHex 解析ツールを用いて、FEZ の独自バイナリセーブファイル形式を完全な構造定義へと逆工学するに至ったことである。JetBrains Rider を用いてゲームの .NET コンポーネントをデコンパイルすることで、研究者は
EasyStorage ライブラリ内部にある特定のロジック、特にデータシリアライゼーションを担当する PCKsaveDevice コンポーネントを特定した。このコンポーネントは、Windows の FILETIME タイムスタンプとシリアライズされたゲームデータを含まれる 4096 バイトのバッファー内で動作する。このプロセスには、ImHex 内にカスタムのパターンを作成して複雑な内部レイアウト(7 ビット符号化文字列、OneTimeTutorials のようなキー値ペアのリスト、LevelSaveData のようなネスト構造、ActorType のような列挙体など)をマッピングする作業が含まれた。注目すべきは、FEZ のセーブファイルが OS 固有のパスに格納されながら暗号化も標準的なマジックヘッダーも含まず、今や完全にデコード可能になった点である。ImHex で設定された後、ユーザーは強調表示された Hex View を通じて生データを閲覧し、Pattern Data View を通じて編集可能な値を変更することができる。その結果、プレイヤーは公式のゲーム内ツールに依存せずにセーブファイルを独自に編集する能力を得る一方で、開発者はこのオープンな定義を用いて安全にゲーム状態を分析したり、バックアップユーティリティを作成したりできるようになる。なお、著者は秘密や終盤コンテンツに関する重いス ポイラーがあるため、FEZ をプレイしてから本文を読むことを推奨していることに注意されたい。本文
『FEZ』のセーブファイル逆エンジニアリング:ImHex パターンによる完全な解析ガイド
序論
長年、ディスコード上で特定のファイル形式の逆エンジニアリング(リバースエンジニアリング)を求めて支援を求める声を多く受けてきました。通常は「デコンパイルされたソースコードを見て遡上する」といった一般的なアドバイスしか与えられませんでした。本記事では、ゲーム『FEZ』のカスタムバイナリセーブファイルを題材に、数年かけて開発を進めてきたオープンソースヘキエディタImHexのパターン言語(Pattern Language)を用いた完全な解析プロセスを紹介します。
- ImHex の特徴: 無料のオープンソースソフトウェア。Windows/macOS/Linux またはブラウザ(ImHex Web)から利用可能。
- バージョンについて: 記事で使用される一部機能は正式リリース版より**ナイトリービルド(Nightly build)**のみ対応しています。v1.38.1 以下でお困りの場合は、最新版のビルドへのアップgrade を推奨します。
はじめに
スロワー警告
- 注意: 『FEZ』は 2012 年リリースのため体験済みの方のみ閲覧推奨です。
- リスク: 本文中に含まれるコードの一部には、ゲーム内の**スロイーヤー(重要ネタバレ)**が含まれています。未プレイの方は spoilers を避けたい場合は、記事の末尾から逆参照して「デコンパイル」および「まとめ」のセクションをご参照ください。
ファイルの入手方法
- Steam から最新版を入手: 2016 年 12 月 2 日リリースのバージョンを使用しました。
- セーブファイルの場所:
- Linux:
/home/werwolv/.local/share/FEZ/SaveSlot2 - Windows: ユーザー設定フォルダ内の異なる場所(Steam の「ローカルファイルの参照」から確認推奨)。
- Linux:
- ImHex での初期確認:
- ファイルを開くと、ヘキサデシマール表示と ASCII 文字列が見えます。
やDOT_LOCKED_D
などの明確な文字列が含まれており、暗号化も圧縮も行われていないことがわかります。DOT_NUT_NV_A- しかしファイルマジック(先頭識別子)がないため、ImHex は標準形式として直接認識しません。
ゲームのデコンパイル
逆エンジニアリングの第一歩は、ファイルを生成するソースコードを見つけることです。
正しいファイルの見つけ方
- Steam の「ローカルファイルの参照」:
- Steam ページのギアアイコン → 「管理」→「ローカルファイルの参照」。
- ゲームの実行バイナリ(
)と依存ライブラリ(.exe
)へのパスへ到達します。.dll
- ターゲットとなる DLL:
- 以下のファイルをアセンブリエクスプローラーで表示します。
FEZ.exeFezEngine.dllCommon.dllContentSerialization.dll
(重要: セーブ処理の主要部分が含まれているため)EasyStorage.dll
- 以下のファイルをアセンブリエクスプローラーで表示します。
正しい関数の見つけ方
- 対象クラス:
EasyStorage -> PCSaveDevice - 保存ロジックの特定:
- コンストラクターでファイル名
が構築されるのを確認。"SaveSlot" + (object) index
を用いたデータ書き込み関数BinaryWriter
を発見。Save
- コンストラクターでファイル名
生成コード例(C#)
public virtual bool Save(string fileName, SaveAction saveAction){ // ... byte[] buffer = new byte[40960 /*0xA000*/]; using (MemoryStream output = new MemoryStream(buffer)) { using (BinaryWriter writer = new BinaryWriter((Stream) output)) { // Windows FILETIME タイムスタンプ(1601 年からの経過時間)の書き込み writer.Write(DateTime.Now.ToFileTime()); saveAction(writer); // 固定サイズ(0xA000 バイト)へのパディング処理 if (output.Length < 40960L /*0xA000*/) { long length = 40960L /*0xA000*/ - output.Length; writer.Write(new byte[length]); } } } }
ImHex パターンの作成
始まりの小さな一歩
- 構造体の定義:
プリフィックスを用いて、パターンをファイルアドレスに配置します。@ - 初期実装: ファイルヘッダー(FILETIME タイムスタンプ)を読み取るための型を追加します。
struct FezSaveFile { long fileTime; // Windows FILETIME (s64) }; FezSaveFile saveFile @ 0x00;
標準ライブラリの活用
- ImHex の標準ライブラリには
が存在します。これを使用することで、生の 64 ビット数ではなく人間が読みやすい時刻値として自動変換されます。type.timeimport type.time; struct FezSaveFile { type::FILETIME fileTime; }; FezSaveFile saveFile @ 0x00;
[[fixed_size]]
属性の追加
[[fixed_size]]- ソースコード(
関数)から確認できる通り、ファイルは常にSave()
(40960) バイトで固定されます。0xA000 - ImHex パターンにはサイズを強制する属性が存在します。
struct FezSaveFile { type::FILETIME fileTime; }; // 実際の実装では固定サイズ属性が付与される場合があるが、ここでは簡略化して記述
実際のセーブデータ(Deep Dive)
DoSave()
関数の分析
DoSave()- セーブ処理の中核となるのは
パラメータに渡されたラムダ式です。saveAction - JetBrains Riderなどでの「Ctrl クリック」により定義元 (
) にジャンプし、以下のロジックを追跡します。GameStateManager.cs
private void SaveInternal(bool ngpBackup){ this.ActiveSaveDevice.Save( "SaveSlot" + (object) this.SaveSlot, new SaveAction(this.DoSave) ); }
- 実際のデータダンプは
→DoSave()
で処理されます。SaveFileOperations.Write()
データフィールドの特定
生成されたバイナリを解析するために、C# コード上で定義されているフィールドと型を確認します。
| ImHex パターン定義 | C# ソースからの参照 | 備考 |
|---|---|---|
| | ファイル作成時刻(パディング用) |
| 固定値 | バージョンチェック |
| ゲーム内コンテンツ作成時刻 | |
系フィールド | , など | 各フラグの有無 |
アサートによる互換性検証
- 古いバージョンのセーブファイルを読み込もうとするとエラーになります。パターン内に型定義内でアサートを行うことで、サポートされているバージョンのみを解析できる仕様にします。
import std.sys; // sys モジュールから assert をインポート struct FezSaveFile { type::FILETIME fileTime; long version; // 非対応のファイルは読み込めないようにチェック std::assert(version == 6, "Unsupported Save File Version. Only Version 6 is supported"); long creationTime; bool finished32; bool finished64; bool hasFpView; bool hasStereo3d; bool canNewGamePlus; bool isNewGamePlus; };
オブジェクトと文字列の解析
セーブファイルには、キートップ(Key-Value ペア)や文字列が含まれます。これらは可変長であるため複雑な処理が必要です。
キー・バリューペア (KeyValuePair
)
KeyValuePair- C# コード:
w.WriteObject(oneTimeTutorial.Key); w.Write(oneTimeTutorial.Value); - ImHex では、存在するかどうかのフラグ (
) と、有効なら値を入れるという構造を定義します。isValid
struct Object<T> { bool isValid; // オブジェクトが存在するか if (isValid) T value; // 存在する場合にのみ値を配置 };
文字列の符号化 (String
)
String- 実装:
を使用して、文字列の長さを可変長符号化(7 ビットエンコーディング)で書き出しています。Write7BitEncodedInt - 仕組み: 各バイトの最上位ビット(MSB)が「次のバイトがあるか」を示します。
ImHex で実装する 7 ビット符号化整数
struct SevenBitEncodedIntByte { u8 byte; if ((byte & 0x80) == 0x00) break; // MSB が 0 の場合は最後のバイト }; struct SevenBitEncodedInt { SevenBitEncodedIntByte bytes[while(true)]; // ループで読み取り続ける } [[format("transformSevenBitEncodedInt"), transform("transformSevenBitEncodedInt")]]; fn transformSevenBitEncodedInt(ref auto encodedInt) { u64 result = 0; for (u32 i = 0, i < std::core::member_count(encodedInt.bytes), i += 1) { // ビット操作で値を再構築 result |= (encodedInt.bytes[i].byte & 0x7F) << (i * 7); } return result;}
最終的な文字列型
struct String { SevenBitEncodedInt size; // 可変長の長さプレフィックス char string[size]; // 実際に読み取った文字数分の配列 } [[format("formatString")]]; fn formatString(ref auto string) { return string.string;}
リスト(List)の定義
C# の
Count プロパティと foreach ループ構造を考慮し、リスト型を定義します。
struct List<T> { int count; // アイテム数(int 32 ビット) T items[count]; // 宣言された数のアイテムを格納 }; struct KeyValuePair<Key, Value> { Key key; Value value;}
複合構造の構築
最終的なセーブファイル構造は、ネストされたリストとペアで表現されます。
struct FezSaveFile { // ... (前段の定義) ... // OneTimeTutorials の解析 List<KeyValuePair< Object<String>, // キー: 文字列(チュートリアル名) bool // 値: 真偽値(チュートリアル完了) > > oneTimeTutorials; // World (レベルデータ) の解析 List<KeyValuePair<Object<String>, LevelSaveData>> world; // ... (他のフィールド) ... };
エンューム(列挙型)の処理
ActorType などの列挙型は、整数値として保存されますが、名前を持たせると解析しやすくなります。
- C# コード:
w.Write((int) artifact); - ImHex 対応: エンューム定義をそのままコピー&ペースト可能。
public enum ActorType : int { None, Ladder, Bouncer, Sign, GoldenCube, // ... }; // ImHex ではこの型が自動変換され、数値ではなく名前として表示されます
より多くのサブタイプ(ネスト構造)
解析はさらに深く続きます。
World キーのペアリストの中に LevelSaveData という新しい構造体が格納されています。
struct LevelSaveData { // ... (レベル固有のデータ) ... };
これにより、形式の残りを自分でデコードする必要があるものすべてがカバーされます。試してみてください!
我々の労働の実果(成果)
パターンを完成させたところ、「ヘキエディタビュー」ではすべてのバイトが色分けされてハイライト表示され、**「パターンデータビュー」**では値の意味を確認したり、ダブルクリックして編集したりできます。
- [My Pattern]: 完全な ImHex パターンファイルをダウンロード可能(※本文中のリンク参照)。
- デコピエートされたセーブファイルの解析結果が視覚的に理解可能です。
まとめ
本記事を通じて、バイナリファイル形式の逆エンジニアリングの基本フローを学ぶことができます。すべてのプログラムがこのほどスムーズにデコンパイルできるわけではありませんが、以下の 4 ステップは普遍的なアプローチです。
- ファイル形式の確認:
- ImHex のマジック検出や
を利用し、既存の形式かどうかを確認します。binwalk
- ImHex のマジック検出や
- 生成コードの特定:
- ソースコードがある場合は該当箇所を検索します。ない場合はデコンパイル(JetBrains Rider, Ghidra, IDA など)を行います。
- 検索キーワード: ファイル入出力ライブラリ、ファイル名文字列など。
- データの構成要素の抽出:
- 整数、ブール値、文字列、構造体など、ファイルに格納されるデータ型を特定します。
- パターンの作成と検証:
- ImHex パターンで発見を文書化し、互換性を検証します。一度デコードするだけでなく、プロセスの各段階で理解を深めることができます。
ImHex やパターン言語に関するご質問は、ImHex Discord サーバー、DM
@werwolv、またはメール宛先にてお気軽にお問い合わせください。