メインコンテンツまでスキップ

みんなで読もう uasset

strvRiku Ishikawa
strv

みなさん、.uasset 読んでますか?

これは、皆さんにもっと UAsset と仲良くなっていただくための記事です。長い記事なので、まずはちょっと遊びから。お手元のプロジェクトの Content フォルダを開き、 .uasset ファイルを以下のエリアに Drag & Drop してみてください(完全にローカルのブラウザ上で処理されるので、どこかにアップロードされたりする心配はありません)。

.uasset ファイルをドロップ、またはクリックして選択

...楽しんでいただけたでしょうか。この記事 (とこの記事で紹介しているエンジンの実装)を理解すると、こういった遊びができるようになるかもしれません。

.uasset おさらい

.uasset は Unreal Engine のアセットファイル形式です。Blueprint、Material、StaticMesh など、エディタ上でインポート・作成・編集するほぼすべてのアセットはこの形式で保存されています。

普段の開発ではエディタがすべてを隠蔽してくれるため、中身を意識することはありません。uasset というと UE 独自の謎めいたファイルに思えるかもしれませんが、PNG や MP4 のような一般的なファイル形式と同様に、きちんと構造化されたデータを持っています。仕様を知れば読み解くことができるというわけです。

検証環境

  • Unreal Engine 5.3 ~ 5.7

本記事では uncooked(エディタで編集可能な状態)の uasset を扱います。パッケージング後の cooked アセットはプロパティのシリアライズ方式が異なったり、エディタ専用データが除去されていたりと、構造が大きく変わります。.uexp との分離や IoStore 形式についても触れません。

なぜ uasset フォーマットを知ると便利なのか

uasset の中身なんて知っていて意味があるのか?と思うかもしれません。確かに普段の開発で意識することはほとんどありませんが、知っていると役立つ場面があります。

  • アセットの解析
    • 大量のアセットから特定の情報を抽出したい、依存関係を調べたいといった場面で、フォーマットを理解していれば自作ツールで対応できる
  • 互換性問題の調査
    • エンジンバージョン間でアセットが読み込めない問題に遭遇したとき、ヘッダのバージョン情報を見れば原因の切り分けができる
  • パッケージングの理解
    • クック時やパッケージング時に何が含まれるか、なぜ特定のアセットが含まれてしまうのかは、アセット間の依存関係がどう記録されているかを理解すると見通しがよくなる
  • UE 外での簡易読み取り
    • エディタを起動せずにアセットの基本情報(クラス、依存先など)を確認したいとき

本記事では、uasset ファイルの基本的な構造について解説します。エンジンソースコードの PackageFileSummary.hLinkerLoad.cpp を参照しながら、全体像を掴んでいきましょう。

UAsset の基礎

フォーマットの詳細に入る前に、uasset が「何を」「どのような形で」保存しているのかを理解しておく必要があります。このセクションでは、エンジン内部の概念を整理します。

UPackage とは

Unreal Engine では、アセットは パッケージ(Package)という単位で管理されます。1つの .uasset ファイルは、1つのパッケージに対応しています。

パッケージはエンジン内部では UPackage クラスとして表現されます。UPackage 自体も UObject を継承しており、他のオブジェクトと同じ仕組みで扱われます。

Package.h
UCLASS(MinimalAPI, Config=Engine)
class UPackage : public UObject
{
// パッケージフラグ
std::atomic<uint32> PackageFlagsPrivate;

// ロード元のパス情報
FPackagePath LoadedPath;

// ...
};

Outer 階層

パッケージの中には複数の UObject が含まれます。これらのオブジェクトは Outer という親子関係で階層構造を形成しており、最上位の Outer は必ず UPackage になります。

たとえば Blueprint アセットの場合、以下のような階層になっています。

UPackage "/Game/MyBlueprint"
└─ UBlueprint "MyBlueprint"
└─ UBlueprintGeneratedClass "MyBlueprint_C"
└─ UFunction "ExecuteUbergraph_MyBlueprint"

この階層関係が、ファイルフォーマットでは OuterIndex というフィールドで表現されます。

パッケージに含まれる情報

UE にインポートするとあらゆるファイルが uasset になります。PNG も FBX も WAV も、すべて uasset。一体中身はどうなっているのか気になるところです。

Blueprint ならグラフ情報、Texture なら画像データ、StaticMesh ならジオメトリデータと、アセットタイプによって内容は異なりますが、実のところこれらはすべて共通のデータ構造で表現されます。

Name Map

すべての基盤となるのが Name Map です。パッケージ内で使用されるすべての FName(クラス名、プロパティ名、オブジェクト名など)が格納され、各所からはインデックスで参照されます。

Export

このパッケージ内で定義されているオブジェクトです。各エクスポートは「どのクラスのインスタンスか」「親オブジェクトは何か」「シリアライズデータはどこにあるか」といった情報を持ちます。Blueprint なら BP クラス本体や関数オブジェクト、Texture ならテクスチャオブジェクトがエクスポートとして記録されます。

Import

外部パッケージへの参照です。継承元のクラス、参照しているマテリアル、使用している他のアセットなど、このパッケージが依存しているものがインポートとして記録されます。

Bulk Data

テクスチャのピクセルデータ、オーディオの波形データなど、大きなバイナリデータです。エクスポートのシリアライズデータとは別に管理され、ファイル末尾や別ファイル(.ubulk)に格納されることがあります。

その他のメタデータ

これらに加えて、エクスポート間の依存関係(Depends Map)、アセットレジストリ用の情報、サムネイル画像なども含まれます。

具体例:Blueprint アセットの場合

UE5 の ThirdPerson テンプレートに含まれる BP_ThirdPersonCharacter.uasset を実際に解析すると、以下のようなデータが含まれています。

  • Name Map: 281 エントリ
  • Import: 87 エントリ
  • Export: 42 エントリ

Name Map(抜粋):

[  0] /Game/ThirdPerson/Blueprints/BP_ThirdPersonCharacter
[ 1] /Script/CoreUObject
[ 2] /Script/Engine
[ 33] BP_ThirdPersonCharacter
[ 34] BP_ThirdPersonCharacter_C

パッケージパス、依存パッケージ名、オブジェクト名、プロパティ名など、あらゆる文字列がここに格納されます。

Import(抜粋):

[ 40] /Script/CoreUObject.Class -> TP_ThirdPersonCharacter
[ 71] /Script/CoreUObject.Package -> /Script/Engine
[ 84] /Script/TP_ThirdPerson.TP_ThirdPersonCharacter -> Default__TP_ThirdPersonCharacter

Import 40 は継承元クラス TP_ThirdPersonCharacter への参照、Import 71 はエンジンパッケージへの参照です。

Export(抜粋):

[  1] BP_ThirdPersonCharacter : Blueprint (outer: root)
[ 2] BP_ThirdPersonCharacter_C : BlueprintGeneratedClass (outer: root)
[ 3] Default__BP_ThirdPersonCharacter_C : BP_ThirdPersonCharacter_C (outer: root)
[ 7] EventGraph : EdGraph (outer: BP_ThirdPersonCharacter)
[ 12] ExecuteUbergraph_BP_ThirdPersonCharacter : Function (outer: BP_ThirdPersonCharacter_C)

Export 1 が Blueprint アセット本体、Export 2 が生成されたクラス、Export 3 が CDO(Class Default Object)です。EventGraph は Blueprint を outer として持ち、Outer 階層を形成しています。

ここで重要なのは、「BP_ThirdPersonCharacter」などの文字列がそのまま格納されているわけではないということです。実際のファイルでは Name Map へのインデックスとして記録されており、名前を得るには Name Map を参照する必要があります。

バージョン互換性

Unreal Engine は頻繁にアップデートされ、それに伴って uasset のフォーマットも変化します。新しいプロパティ型の追加、既存構造の拡張、シリアライズ方式の変更など、変更の種類はさまざまです。

公式には「エンジンのアップグレードは1バージョンずつ」と推奨されていますが、実際にエンジンコードを読むと、uasset は意外なほどバージョン互換性に配慮した仕組みになっています。

エンジンソースには VER_UE4_OLDEST_LOADABLE_PACKAGE = 214 という定義があり、これが読み込み可能な最古のバージョンです。UE4 のバージョン enum には 500 以上のエントリがあるため、214 は UE4 初期に相当します。つまり UE5.7 でも、実装上は UE4 初期のアセットをロードできるよう互換性が維持されています。

uasset はヘッダに詳細なバージョン情報を持っています。

  • エンジンバージョン
    • UE4 / UE5 それぞれのバージョン番号
    • どのエンジンで保存されたかを特定できる
  • カスタムバージョン
    • エンジンの各サブシステム(アニメーション、マテリアル、物理など)が独自に持つバージョン番号
    • GUID をキーとして管理され、サブシステムごとに独立して進化できる
    • 50 以上のサブシステムがそれぞれ独自のバージョンを持つ

エンジンはアセットを読み込む際、これらのバージョン情報を確認し、必要に応じて古い形式から新しい形式への変換処理を行います。エンジンソースには if (Ar.CustomVer(...) >= SomeVersion) のようなバージョン分岐が大量に存在し、長年の互換性を維持しています。

ただし、uasset が読めることと、その中身が正しく動作することは別の話です。Blueprint のノードが廃止されていたり、マテリアルのパラメータが変更されていたり、アセットを使う側のコードが変わっていれば、ロードできても期待通りには動きません。「1バージョンずつ」の推奨は、こうした意味的な互換性を確認しながら進めるためのものでしょう。

UAsset のファイルフォーマット

ここまでで uasset が保持する情報を理解しました。では、それらは実際のファイルにどう格納されているのでしょうか。

uasset ファイルの全体構造

uasset ファイルは、以下のような構造を持っています。

各セクションの位置は固定ではなく、ヘッダ(Package File Summary)に格納されたオフセット値によって決定されます。これにより、バージョンアップでセクションが追加されても後方互換性を維持できる設計になっています。

本記事ではすべてのセクションを網羅的に解説することはしません(Unreal Engine の規模を考えると現実的ではありません)。その代わり、uasset を理解するうえで基盤となる Package File SummaryName MapImport MapExport Map を中心に、ThumbnailBulk Data についても説明します。

Package File Summary(ヘッダ)

Package File Summary は、uasset ファイルの目次にあたる部分です。ファイルの先頭に位置し、パッケージ全体のメタデータ(バージョン情報、パッケージフラグなど)と、後続する各セクションへのオフセットを保持しています。

uasset を読み込む際は、まずこのヘッダを解析して全体の構造を把握し、必要なセクションにジャンプしてデータを読み取ります。エンジンでは FPackageFileSummary 構造体として定義されており、シリアライズ処理は PackageFileSummary.cpp にあります。

バイナリレイアウト

雰囲気を掴むために、実際のバイナリレイアウトを見てみましょう。条件付きのフィールドはバージョンやフラグによって有無が変わります。

#サイズフィールド説明
14Tagマジックナンバー (0x9E2A83C1)
24LegacyFileVersionフォーマットバージョン (-9 が最新)
34LegacyUE3VersionUE3 互換用 (条件: ≠-4)
44FileVersionUE4UE4 バージョン番号
54FileVersionUE5UE5 バージョン番号 (条件: ≤-8)
64FileVersionLicenseeUEライセンシーバージョン
720SavedHashパッケージのハッシュ (UE5.1+)
84TotalHeaderSizeヘッダ全体のサイズ
9可変CustomVersionsサブシステム別バージョン
10可変PackageNameパッケージ名 (非推奨)
114PackageFlagsパッケージフラグ
124+4NameCount, NameOffsetName Map の情報
134+4ExportCount, ExportOffsetExport Map の情報
144+4ImportCount, ImportOffsetImport Map の情報
............
全フィールド一覧(44項目)
#サイズフィールド説明条件
14Tagマジックナンバー (0x9E2A83C1)
24LegacyFileVersionフォーマットバージョン (-9 が最新)
34LegacyUE3VersionUE3 互換用≠ -4
44FileVersionUE4UE4 バージョン番号
54FileVersionUE5UE5 バージョン番号≤ -8
64FileVersionLicenseeUEライセンシーバージョン
720SavedHashパッケージのハッシュUE5.1+
84TotalHeaderSizeヘッダ全体のサイズUE5.1+
9可変CustomVersionsサブシステム別バージョン≤ -2
104TotalHeaderSizeヘッダ全体のサイズUE5.1 未満
11可変PackageNameパッケージ名 (非推奨)
124PackageFlagsパッケージフラグ
134+4NameCount, NameOffsetName Map の件数とオフセット
144+4SoftObjectPathsCount, Offsetソフトオブジェクトパス参照UE5.1+
15可変LocalizationIdローカライズ IDUE4.14+
164+4GatherableTextDataCount, Offset収集可能テキストデータUE4.7+
174+4ExportCount, ExportOffsetExport Map の件数とオフセット
184+4ImportCount, ImportOffsetImport Map の件数とオフセット
194×4CellExport/ImportCount, OffsetVerse セル情報UE5.5+
204MetaDataOffsetメタデータのオフセットUE5.4+
214DependsOffsetDepends Map のオフセット
224+4SoftPackageRefsCount, Offsetソフトパッケージ参照UE4.13+
234SearchableNamesOffset検索可能名のオフセットUE4.15+
244ThumbnailTableOffsetサムネイルテーブルのオフセット
254+4ImportTypeHierarchiesCount, OffsetImport 型階層情報UE5.7+
2616Guidパッケージ GUIDUE5.1 未満
2716PersistentGuid永続 GUIDEditor
284GenerationCount世代情報の件数
29可変Generations[]過去バージョンの情報
30可変SavedByEngineVersion保存時のエンジンバージョンUE4.5+
31可変CompatibleWithEngineVersion互換エンジンバージョンUE4.12+
324CompressionFlags圧縮フラグ
33可変CompressedChunks[]圧縮チャンク (常に空)
344PackageSourceパッケージソース識別子
35可変AdditionalPackagesToCook[]追加クック対象 (常に空)
364NumTextureAllocationsテクスチャ割当数 (常に0)> -7
374AssetRegistryDataOffsetアセットレジストリデータのオフセット
388BulkDataStartOffsetバルクデータの開始オフセット
394WorldTileInfoDataOffsetワールドタイル情報のオフセットUE4.11+
40可変ChunkIDs[]ストリーミングチャンク IDUE4.10+
414+4PreloadDependencyCount, Offsetプリロード依存関係UE4.18+
424NamesReferencedFromExportDataCountExport データから参照される名前数UE5.0+
438PayloadTocOffsetペイロード TOC のオフセットUE5.0+
444DataResourceOffsetデータリソースのオフセットUE5.3+

CustomVersions の構造は「Count (i32) + (GUID 16bytes + Version i32) × Count」です。FString は「Length (i32) + UTF-8/UTF-16 文字列」です。

実際のパース処理では、バージョン値を見ながら条件分岐でフィールドを読み進めます。PackageFileSummary.cppoperator<< を見ると、この泥臭さがよくわかります。UE3 形式のためのコードまで多数残っているのには驚かされます。

マジックナンバー (Tag)

マジックナンバーとは、ファイル形式を識別するためにファイル先頭に配置される固定のバイト列です。PNG の 89 50 4E 47、PDF の 25 50 44 46 など、多くのファイル形式で採用されており、プログラムがファイルを読み込む際に形式を判別するために使われます。

uasset ファイルの先頭 4 バイトはマジックナンバーとして以下の値が定義されています。

ObjectVersion.h
#define PACKAGE_FILE_TAG         0x9E2A83C1
#define PACKAGE_FILE_TAG_SWAPPED 0xC1832A9E // バイトオーダー逆転版

エンジンの FLinkerLoad::LoadPackageInternal では、まずこのマジックナンバーを読み取り、正規のパッケージファイルかどうかを判定します。PACKAGE_FILE_TAG_SWAPPED は異なるエンディアンで保存されたファイルを検出するために用意されています。

バージョン情報

概要で述べた通り、uasset はバージョン互換性を強く意識しています。ヘッダにはバージョン情報が含まれており、エンジンは読み込み時にこの値に基づいて処理を分岐させます。

フィールド説明
LegacyFileVersion負の整数。新しいほど小さい値(-4, -7, -8 等)。UE5 では -8 以下
FileVersionUE4UE4 バージョン番号。UE5 アセットでも常にシリアライズされる
FileVersionUE5UE5 バージョン番号(1000 以上)。UE4 アセットでは 0
CustomVersionsサブシステムごとのカスタムバージョン(GUID + バージョン番号の配列)

FileVersionUE4FileVersionUE5FPackageFileVersion 構造体にまとめられており、どのエンジンバージョンで保存されたアセットかを判定できます。

UE5 アセットでも FileVersionUE4 がシリアライズされているのは、既存のバージョンチェックとの互換性のためです。エンジン全体に if (Version >= VER_UE4_XXX) という形式のチェックが多数存在しており、UE5 アセットではこのフィールドに UE4 の最終バージョン値が設定されます。こうすることで、すべての UE4 版チェックが自動的にパスし、UE5 固有の機能は FileVersionUE5 で判定できます。

ObjectVersion.h
struct FPackageFileVersion
{
// UE4 バージョン
int32 FileVersionUE4 = 0;
// UE5 バージョン (1000 以上が UE5)
int32 FileVersionUE5 = 0;
};

CustomVersions の具体的な使い方も見てみましょう。

DevObjectVersion.cpp
// Editor サブシステムのカスタムバージョン
const FGuid FEditorObjectVersion::GUID(0xE4B068ED, 0xF49442E9, 0xA231DA0B, 0x2E46BB41);
FDevVersionRegistration GRegisterEditorObjectVersion(
FEditorObjectVersion::GUID,
FEditorObjectVersion::LatestVersion,
TEXT("Dev-Editor")
);

代表的な CustomVersion には以下のようなものがあります。

CustomVersion用途
FEditorObjectVersionエディタ機能全般(FText、SplineComponent 等)
FReleaseObjectVersionリリースビルド向けの変更
FAnimationCustomVersionアニメーション関連
FSkeletalMeshCustomVersionSkeletalMesh の構造変更
FLandscapeCustomVersionLandscape 関連

実際の使用例として、FText のシリアライズを見てみましょう。

Text.cpp
Ar.UsingCustomVersion(FEditorObjectVersion::GUID);

if (Ar.CustomVer(FEditorObjectVersion::GUID) >= FEditorObjectVersion::TextFormatArgumentDataIsVariant)
{
// 新しい形式で読み書き
Record << SA_VALUE(TEXT("Type"), TypeAsByte);
}

Ar.CustomVer() でパッケージに記録された CustomVersion を取得し、特定のバージョン以上かどうかで処理を分岐させています。

パッケージフラグ

UPackage は様々なフラグを持っており、パッケージの性質を表しています。

フラグ説明
PKG_ContainsMapULevel/UWorld を含むパッケージ
PKG_Cookedクック済み(パッケージング済み)
PKG_EditorOnlyエディタ専用パッケージ
PKG_CompiledInC++ コードから生成されたパッケージ(/Script/Engine 等)
PKG_NewlyCreated新規作成されたパッケージ(未保存)

主要なオフセットフィールド

ヘッダには各セクションへのオフセットが格納されています。主要なものを以下に示します。

フィールド説明
TotalHeaderSizeヘッダ全体のサイズ
NameCount / NameOffsetName Map のエントリ数とオフセット
ImportCount / ImportOffsetImport Map のエントリ数とオフセット
ExportCount / ExportOffsetExport Map のエントリ数とオフセット
DependsOffsetDepends Map へのオフセット
SoftPackageReferencesCount / Offsetソフトパッケージ参照の情報
ThumbnailTableOffsetサムネイルテーブルへのオフセット
AssetRegistryDataOffsetアセットレジストリデータへのオフセット
BulkDataStartOffsetバルクデータ開始位置

エンジンはこれらのオフセットを使って、必要なセクションにジャンプして読み取りを行います。

バージョンによるフィールドの変遷

ここで注意が必要なのは、ヘッダのフィールドはバージョンによって増減するということです。エンジンソースでは EUnrealEngineObjectUE5Version として定数が定義されています。

ObjectVersion.h から抜粋
enum EUnrealEngineObjectUE5Version
{
INITIAL_VERSION = 1000,
NAMES_REFERENCED_FROM_EXPORT_DATA = 1001,
PAYLOAD_TOC = 1002,
// ...
ADD_SOFTOBJECTPATH_LIST = 1008,
// ...
METADATA_SERIALIZATION_OFFSET = 1014,
VERSE_CELLS = 1015,
PACKAGE_SAVED_HASH = 1016,
};

たとえば UE5 で追加されたフィールドには以下のようなものがあります。

バージョン追加フィールド
1008 (ADD_SOFTOBJECTPATH_LIST)SoftObjectPathsCount, SoftObjectPathsOffset
1014 (METADATA_SERIALIZATION_OFFSET)MetaDataOffset
1015 (VERSE_CELLS)CellExportCount/Offset, CellImportCount/Offset
1016 (PACKAGE_SAVED_HASH)SavedHash(GUID から FIoHash への変更)

エンジンはこれらのバージョン値を見ながら、フィールドの有無を判定してシリアライズ処理を分岐させています。こういったところを見ると、フォーマットの歴史がわかるだけでなく、これから追加されるであろう機能の名前なんかも覗き見ることができて少し面白いです。

Name Map

Name Map は、パッケージ内で使用されるすべての名前(クラス名、プロパティ名、オブジェクト名など)を集約したテーブルです。ファイル内の各所では、文字列を直接埋め込む代わりに Name Map へのインデックスを記録することで、重複を排除しファイルサイズを削減しています。

ファイル内 FName とランタイム FName の違い

ファイル内での FName は、ランタイムの FName とは本質的に異なります。

ランタイムの FName:

  • エンジン全体で共有されるグローバル NameTable へのインデックス
  • プロセス起動時に初期化され、NAME_None は常にインデックス 0
  • 文字列は一度だけ格納され、全オブジェクトで共有される

ファイル内の FName:

  • パッケージごとに独立した NameMap へのインデックス
  • 同じ文字列でもパッケージごとに異なるインデックスになる
  • ファイルにはグローバルテーブルとは無関係なローカルインデックスが記録される
ファイル内での FName 参照形式
// FName の参照は 8 バイト
int32 NameIndex; // パッケージローカル NameMap へのインデックス
int32 Number; // インスタンス番号(同名オブジェクトの区別用)

Number フィールドは、同じ名前を持つ複数のオブジェクトを区別するために使われます。たとえば MyActor_0, MyActor_1 のような名前は、同じ Name Map エントリを参照しつつ、Number で区別されます(Number が 0 なら接尾辞なし、1 以上なら _0, _1, ... が付く)。

なお、Name Map のエントリ自体をシリアライズする FNameEntrySerialized とは別物です。FNameEntrySerialized は文字列本体とハッシュを含む構造体で、Name Map のパースに使います。

ロード時のインデックス変換

パッケージをロードする際、Linker は以下の処理でファイルインデックスをランタイムインデックスに変換します。

LinkerLoad.cpp での NameMap 読み込み
FNameEntrySerialized NameEntry(ENAME_LinkerConstructor);
for (int32 Idx = 0; Idx < NameCount; ++Idx)
{
*this << NameEntry;
// ファイルから読んだ文字列をグローバル FName として登録し、
// そのグローバルインデックスを NameMap に格納
NameMap.Emplace(FName(NameEntry).GetDisplayIndex());
}

この NameMap 配列がマッピングテーブルとして機能します。ファイルインデックス N の名前を解決するには NameMap[N] を参照し、そこに格納されたグローバル FNameEntryId を使ってグローバルテーブルから文字列を取得します。

Name Map のシリアライズ

Name Map は文字列配列としてシリアライズされています。

各エントリは長さプレフィックス付きの文字列(FString)としてシリアライズされます。UE4 の特定バージョン以降(VER_UE4_NAME_HASHES_SERIALIZED)では、ハッシュ値も一緒にシリアライズされます。

FNameEntrySerialized の読み込み
// 文字列本体
FString Name;
Ar << Name;

// ハッシュ値(新しいバージョンの場合)
if (Ar.UEVer() >= VER_UE4_NAME_HASHES_SERIALIZED)
{
uint16 DummyHashes[2];
Ar << DummyHashes[0] << DummyHashes[1];
}

重要な点として、Name Map の最初のエントリ(インデックス 0)が "None" であるとは限りません。ランタイムのグローバルテーブルでは NAME_None は常にインデックス 0 ですが、ファイル内の Name Map はパッケージごとに独立しているため、"None" の位置はパッケージによって異なります。

Import Map

Import Map は、このパッケージが依存している外部オブジェクトのリストです。継承元のクラス、参照しているマテリアル、使用しているテクスチャなど、他のパッケージに存在するオブジェクトへの参照がここに記録されます。

たとえば Blueprint アセットが Actor クラスを継承している場合、/Script/Engine.Actor への参照が Import として記録されます。

Import Map のバイナリレイアウト

Import Map は Summary.ImportOffset の位置から、Summary.ImportCount 個のエントリが連続して並びます。各エントリのレイアウトは以下の通りです。

オフセットサイズフィールド説明
08ClassPackageクラスを含むパッケージ名(FName)
88ClassNameクラス名(FName)
164OuterIndexOuter への参照(FPackageIndex)
208ObjectNameオブジェクト名(FName)
288PackageName所属パッケージ名(Editor のみ、UE4.18+)
364bImportOptionalオプショナルフラグ(UE5.1+)

FName は int32 NameIndex + int32 Number の 8 バイトです。FPackageIndex は int32 の 4 バイトで、負値は Import、正値は Export を指します。bool は意外かもしれませんが 4 バイト整数としてシリアライズされます。これは UE3 以前の UBOOL 型との互換性を維持するための歴史的な仕様です。

エントリサイズはバージョンによって変わるため、固定サイズとして扱えません。エンジンは各エントリを順番にシリアライズして読み込みます。

Import は階層構造を持つことがあります。たとえば /Script/Engine.Actor という参照は、以下のような Import エントリの連鎖で表現されます。

  1. Import 0: パッケージ /Script/Engine(OuterIndex = 0)
  2. Import 1: クラス Actor(OuterIndex = Import 0 を参照)

この OuterIndexFPackageIndex という型になっており、Import と Export の両方を参照できる仕組みになっています。

FPackageIndex

FPackageIndex は、Import Map または Export Map のエントリを指すインデックスです。int32 のラッパーで、値の正負によって参照先を区別します。

FPackageIndex の解釈
// FPackageIndex の値と参照先の関係
// 0 : null(どこも指さない)
// 負の値 : Import Map へのインデックス(-1 → Import[0], -2 → Import[1], ...)
// 正の値 : Export Map へのインデックス(1 → Export[0], 2 → Export[1], ...)

bool IsNull() const { return Index == 0; }
bool IsImport() const { return Index < 0; }
bool IsExport() const { return Index > 0; }

int32 ToImport() const { return -Index - 1; } // Import[0] は -1
int32 ToExport() const { return Index - 1; } // Export[0] は 1

この仕組みにより、オブジェクト参照が外部パッケージのものか、同一パッケージ内のものかを統一的に扱えます。シンプルですが、よくできた設計ですね。

Export Map

Export Map は、このパッケージ内で定義されているオブジェクトのリストです。Import が「外部への参照」であるのに対し、Export は「このファイルが提供するもの」です。

Blueprint アセットであれば、Blueprint クラス本体、デフォルトオブジェクト、関数グラフなどが Export として記録されます。

Export Map のバイナリレイアウト

Export Map は Summary.ExportOffset の位置から、Summary.ExportCount 個のエントリが連続して並びます。Import Map よりフィールドが多く、バージョンによる差異も大きいです。

オフセットサイズフィールド説明
04ClassIndexクラスへの参照(FPackageIndex)
44SuperIndexスーパークラスへの参照(UStruct のみ)
84TemplateIndexテンプレートへの参照(UE4.14+)
124OuterIndexOuter への参照
168ObjectNameオブジェクト名(FName)
244ObjectFlagsオブジェクトフラグ
288SerialSizeシリアライズデータのサイズ
368SerialOffsetシリアライズデータの位置
444bForcedExport強制エクスポートフラグ
484bNotForClientクライアント除外フラグ
524bNotForServerサーバー除外フラグ
564PackageFlagsパッケージフラグ
604bNotAlwaysLoadedForEditorGameEditor ゲーム非ロードフラグ
644bIsAssetアセットフラグ
.........(依存関係情報等、UE4.17+ で追加)

SerialSizeSerialOffset は UE4.14 以前では 4 バイト(int32)でした。Export Map はオブジェクトのメタデータのみを格納し、実際のプロパティデータは SerialOffset が指す位置に別途格納されています。

bIsAsset フラグ

FObjectExport には bIsAsset というフラグがあり、そのエクスポートが Content Browser に表示されるオブジェクト(Blueprint なら BP 本体、Material ならマテリアル本体)かどうかを示します。

bIsAsset の確認
for (const FObjectExport& Export : ExportMap)
{
if (Export.bIsAsset)
{
// Content Browser に表示されるオブジェクト
}
}

OuterIndex が null(0)のエクスポートは「UPackage 直下のオブジェクト」を意味しますが、Blueprint パッケージのように複数のトップレベルオブジェクト(Blueprint 本体、BlueprintGeneratedClass、CDO など)が存在することがあります。bIsAsset はその中から Content Browser に表示される代表的なオブジェクトを識別するためのフラグです。

シリアライズデータへのアクセス

各 Export は SerialOffsetSerialSize によって、実際のオブジェクトデータの位置とサイズを示しています。この範囲のバイト列を読み取ることで、オブジェクトのプロパティや内部データにアクセスできます。

ただし、このバイト列の解釈にはオブジェクトの型に応じた処理が必要です。ここから先はかなり複雑になるので、本記事では扱いません。

クラスパスの解決

さて、ここまでで Import Map、Export Map、Name Map の構造を見てきました。これらを組み合わせることで、オブジェクトのクラスパスを解決できます。

たとえば、あるエクスポートの ClassIndex が Import[3] を指していて、Import の構造が以下のような場合:

Import[2]: ClassName="Package", ObjectName="/Script/Engine", OuterIndex=0
Import[3]: ClassName="Class", ObjectName="Actor", OuterIndex=-3 (→Import[2])

この場合、クラスパスは /Script/Engine.Actor となります。OuterIndex を再帰的に辿り、Name Map から名前を解決して連結することで、完全なパスを構築できます。

クラスパス解決の疑似コード
FString ResolveImportPath(int32 ImportIndex)
{
const FObjectImport& Import = ImportMap[ImportIndex];
FString Name = NameMap[Import.ObjectName.Index];

if (Import.OuterIndex.IsNull())
{
return Name;
}
else if (Import.OuterIndex.IsImport())
{
FString OuterPath = ResolveImportPath(Import.OuterIndex.ToImport());
return OuterPath + TEXT(".") + Name;
}
return Name;
}

Thumbnail

Content Browser に表示されるサムネイル画像は、uasset ファイル内に埋め込まれています。

実はこのサムネイル、エディタ上でアセットを描画した結果をそのまま PNG や JPEG として保存しています。GPU 向けの特殊なエンコーディングではなく一般的な画像フォーマットなので、uasset から該当部分を切り出せばそのまま表示できます。

複数のサムネイル

サムネイルはパッケージ内の各エクスポートオブジェクトに対して保存できる仕組みになっています。通常のアセット(Blueprint、Material 等)ではメインオブジェクトの 1 枚だけですが、フォーマット上は複数のサムネイルを格納可能です。

Thumbnail の格納位置

ThumbnailTableOffset が指す位置にサムネイルの目次(インデックス)があり、その前に各サムネイルの画像データが格納されています。

Thumbnail Table の構造

Thumbnail Table は目次と画像データの 2 つの部分からなります。

[サムネイル画像データ 0]
[サムネイル画像データ 1]
...
[ThumbnailTableOffset が指す位置]
├─ Count (int32)
├─ Entry 0: ObjectClass, ObjectPath, FileOffset
├─ Entry 1: ObjectClass, ObjectPath, FileOffset
└─ ...

目次の各エントリは以下の構造を持ちます。

オフセットサイズフィールド説明
0可変ObjectClassオブジェクトのクラス名(FString)
可変可変ObjectPathオブジェクトのパス(FString)
可変4FileOffset画像データのファイル内オフセット

サムネイル画像データ

各サムネイルの画像データは FObjectThumbnail としてシリアライズされます。

オフセットサイズフィールド説明
04ImageWidth画像の幅
44ImageHeight画像の高さ(負値なら JPEG 圧縮)
84DataLength圧縮データの長さ
12可変CompressedDataPNG または JPEG 圧縮された画像データ

ImageHeight が負の値の場合は JPEG 圧縮、正の値の場合は PNG 圧縮を示します。実際の高さは絶対値を取ります。

ファイルに保存されるのは CompressedImageData(圧縮済み画像)のみで、展開後のピクセルデータ ImageData はディスクには書き出されません。CompressedImageData は PNG/JPEG としてそのままデコード可能なバイト列です。

Bulk Data

テクスチャのミップマップやオーディオの波形など、アセットによっては大量のバイナリデータを持つことがあります。こうしたデータは Bulk Data という仕組みで管理されます。

なぜ Bulk Data を分けるのか

通常のプロパティシリアライズでは、オブジェクトを読み込む際にすべてのデータをメモリに展開します。しかし、テクスチャのピクセルデータのような巨大なバイナリを毎回すべて読み込むのは非効率です。

Bulk Data はこの問題を解決するため、ペイロード(実データ)を分離して管理します。

  • 遅延読み込み: Summary や Export Map だけ先に読み込み、ペイロードは必要になるまで読み込まない
  • ストリーミング: 非同期 IO で必要な部分だけを読み込む(テクスチャストリーミング等)
  • メモリマッピング: ファイルを直接メモリにマップし、OS のページング機構を活用

エクスポートと Bulk Data の関係

エクスポートのシリアライズ処理中、アセット固有のコードが FBulkData::Serialize を呼び出すことで Bulk Data が書き込まれます。例えば UTexture2D では、各ミップマップの FBulkData がシリアライズされます。

このとき、Bulk Data のペイロードをどこに格納するかは 2 種類あります。

格納位置説明
インラインエクスポートのシリアライズ中、その場にペイロードを書き込む
ファイル末尾シリアライズ中はメタデータのみ書き込み、ペイロードは BulkDataStartOffset 以降にまとめて格納

インラインの場合、ペイロードはエクスポートデータの一部として Export Data 領域内に存在します。ファイル末尾の場合、Export Data 内にはメタデータ(フラグ、サイズ、オフセット)だけが記録され、実データは別の領域に格納されます。

また、Uncooked Asset(Editor Asset)では通常ファイル末尾に格納されますが、クックされると .ubulk 等の別ファイルに分離されることがあります。

Content Virtualization

Content Virtualization(Virtual Assets)を有効にしている場合、Bulk Data のペイロードは Package Trailer という形式で .uasset ファイル末尾に格納され、さらにそのペイロードを外部バックエンド(Perforce、DDC 等)に移動(仮想化)できます。仮想化されたペイロードは必要時にオンデマンドでダウンロードされる仕組みです。

Bulk Data のメタデータ

各 Bulk Data はメタデータを持ち、ペイロードの位置とサイズを示します。

オフセットサイズフィールド説明
04BulkDataFlagsフラグ
44 or 8ElementCount要素数
8 or 124 or 8SizeOnDiskディスク上のサイズ
12 or 208OffsetInFileファイル内オフセット

BULKDATA_Size64Bit フラグの有無で ElementCount と SizeOnDisk のサイズが変わります。

主要なフラグ

フラグ説明
BULKDATA_PayloadAtEndOfFile0x0001ファイル末尾に格納
BULKDATA_SerializeCompressedZLIB0x0002ZLIB 圧縮
BULKDATA_ForceInlinePayload0x0040インライン格納を強制
BULKDATA_Size64Bit0x2000サイズフィールドが 64bit

ペイロードの実際のバイト列はアセット種別ごとに異なるフォーマットを持つため、汎用的なパースは困難です。

まとめ

本記事では、uasset ファイルの基本構造について解説しました。

  • UPackage と Outer 階層: uasset は UPackage をシリアライズしたファイルで、UObject の親子関係(Outer)を保持している
  • セクション構造: ファイルは複数のセクションから構成され、Package File Summary(ヘッダ)に各セクションへのオフセットが記録されている
  • バージョン互換性: FileVersionUE4/UE5 や CustomVersions により、長年のフォーマット変更に対応している
  • Name Map: パッケージ内の全文字列を集約し、各所からインデックスで参照することで重複を排除
  • Import Map / Export Map: Import は外部パッケージへの参照、Export はパッケージ内で定義されるオブジェクト。FPackageIndex で統一的に参照
  • Thumbnail: サムネイル画像は PNG/JPEG 形式でそのまま埋め込まれており、切り出して表示可能
  • Bulk Data: 大きなバイナリデータは遅延読み込み・ストリーミング・メモリマッピングのために分離管理される

この構造を理解していれば、エンジンのシリアライズ処理がどのように動作しているかの理解が深まるはずです。記事冒頭の「遊び」で表示されていた項目についても、この記事をお読みになった方ならきっと意味がよくわかるようになっていることでしょう。

今回は UAsset を一つのファイルフォーマットとして解説する試みをしてみました。構造体のシリアライズなどについては情報がありますが、意外と uasset そのものの情報は少ないので、何かのお役に立てれば幸いです。 また、Export 内部のシリアライゼーション(Tagged Property、Native、Binary など)については、この記事と同じくらいの分量で語れる内容がありますので、機会があれば記事を書きたいと思います。