[UE4][UE5] パッチに変更していないアセットが含まれる問題と対処方法

検証済バージョン：UE4.27
# 1. 導入
　ゲーム開発において、現在ほとんど必須となっているのがパッチ対応です。パッチはゲームの不具合を修正したり、コンテンツの内容をアップデートする時に利用することを目的としており、ユーザーは更新に必要なデータをダウンロードすることで適用することができます。UE4では以下のドキュメントで示すようにパッチ作成機能をサポートしており、パックファイルとチャンク機能を利用することで実現しています。

**[公式ドキュメント] パッチと DLC**
https://docs.unrealengine.com/4.27/ja/SharingAndReleasing/Patching/

**[公式ドキュメント] パッチの作成方法 (プラットフォーム非依存)**
https://docs.unrealengine.com/4.27/ja/SharingAndReleasing/Patching/GeneralPatching/HowToCreatePatch/

　上記ではプラットフォームに依存しないパッチの作成方法について記載されていますが、PCで作成する場合は Cooked Platformで指定するだけです。コンソールプラットフォームの場合は独自のアプリケーションパッケージフォーマットで配布する必要があり、プラットフォーム毎に作成方法が異なります。UE4から各プラットフォームのパッケージを作成することができますが、やっていることとしてはSDKに対する仲介を行うだけです。

# 2. 問題点
　パッチに関する良くある問題として「パッチサイズが想定よりも大きい」というものがあります。ここでの「パッチサイズ」とは「パッチとして配布するアプリケーションパッケージ」のことを指します。

　パッチはリリース時点から変更が行われた内容を配信するので、"変更が行われた差分のみ"のパッケージが作成できて配信できることが理想的です。以下はそれを図示したものですが、例えば「次のアップデートで新しいステージを追加したい」というようなパッチを作成したい場合、パッチパッケージはリリースからの変更分である「ステージ２のアセットのみ」がパッケージに含まれることを期待します。

**・理想的なパッチによるデータ更新**
![2021-08-25_17h57_01.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/364110/235cfd37-316d-514e-6831-8852a2751e8d.png)

　一方こちらは問題となるようなケースで、上記と同様に「新しいステージのみを追加したい」という状況において、パッチパッケージには何故か**変更を行っていないはずの初期データとステージ１のデータ**が含まれてしまい、結果としてパッチパッケージのサイズが大きくなることがあります。パッチパッケージのサイズが大きくなってしまうと、ユーザーがパッチをダウンロードする時間が長くなったり、ストレージを圧迫してしまうことによって満足度の低下につながります。

**・問題となるパッチによるデータ更新**
![2021-08-25_17h56_51.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/364110/e808af5c-2746-dbfb-6aed-82b2869f9808.png)

　このような事が起きないようにリリース前に確認しておく必要があるのですが、ここからはそれが起きた時の対処方法などについて説明したいと思います。

# 3. 事前知識

### 差分パッチ
　上記2章でも記載のように、パッチでは最小限の変更点を配信したいため、パッチとして配布するアプリケーションには必然的に差分のデータのみが含まれることになります。プラットフォーム毎にアプリケーションパッケージのフォーマットが異なるため、アプリケーションパッケージとして必要な共通のデータ量も異なりますが、基本的に差分として追加されるのは変更が発生したファイルのみです。これはバイナリレベルで異なるデータを検出してアプリケーションパッケージに含むということを意味しています。UE4の機能を利用してアプリケーションパッケージを作成する場合は、UE4が自動的に差分を検出してパッチに含めるように動作してくれます。

### パッケージ作成フロー
　アプリケーションパッケージが作成されるまでのビルドフローは以下の通りですが、パッチ作成の際に注目したいのが赤枠で囲っている部分です。パッチ（アプリケーションパッケージ）には最終的に実行ファイルとクックされたコンテンツを格納しますが、"ソースから実行ファイルを作成する処理"が**ビルド**、"アセットをプラットフォームで利用可能なフォーマットにコンバートする処理"が**クック**で行われます。クックしたアセットを"ファイルアクセスを効率化するためにパックファイルを作成すること"が**ステージ**のプロセスで実行されます。

![2021-08-25_18h30_51.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/364110/4dd8b696-2120-1657-d8ca-06fa9f1b46b0.png)

### パッチに含まれるデータの基本情報
　上記の2項で説明したように、UE4でパッケージを作成する場合は大きく分類して**実行ファイル**と**アセット**の2つがパッケージに含まれるデータです。もしコードやアセットを追加してパッチを作成するような場合には、これらのファイルに変更が発生すると認識をしておく必要があります。
　以下は具体的なパッチで更新されるデータの例ですが、

　ブループリントやマテリアルなどのアセットに変更がある場合はパックファイルが更新されます。
![2021-08-25_17h31_07.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/364110/d1616a24-be7c-32c7-9b01-9d5e7cb08918.png)

　C++などのコードに変更がある場合は実行ファイル(.exe)が更新されます。パックファイルはコンテンツに更新が無くても"_X_P.pak"として更新ファイルを作成しますがアセットに変更が無い場合は更新する必要はありません。
![2021-08-25_17h31_00.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/364110/16bf0cce-6d17-a9e7-15a4-5b81e18c1b52.png)

　これらは通常、同じプログラム同じアセットをパッケージ作成してもバイナリレベルで全く同じファイルが作成されることが理想的ですが、ごくまれにファイルが同一とならないことがあります。これについて調べる方法を次に示します。

# 4. 調査
　パッチを作成した時にパッケージアプリケーションのサイズが思ったよりも大きい場合は次の事を行って調べる事をおすすめします。もしかしたらパッチ更新には不要なアセットがパッチに含まれたりすることで、パッチのサイズが肥大化してしまっているかもしれません。

## 方法1: アセットの差分があるかを調べる
 2回クックを実行してアセットに差分があるかどうかを調べます。1回目のクックが完了した後に、クックアセットが保管される`/Saved/Cooked`フォルダをコピーしておいてから再度クックを実行するだけです。問題がなければ1回目と2回目のクックでは同じ結果となり、差分のファイルが発生することはありません。以下の例は「2回クックした結果をWinMergeで比較したもの」ですが、差分が発生しているような状況です。通常は何度クックしても同じ結果となるべきなので、ここで1回目と2回目で差分が発生しているのは問題です。

![2021-07-19_12h25_52.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/364110/1523dfc8-e97f-f805-a60b-feeaf1541264.png)

## 方法2: 全アセット差分の詳細情報を取得
　この方法は2回クックを行う方法1と似ていますが、「プロジェクト全体でどれくらいのアセットに差分があるかを調べたい時」や「どのようなデータに差分があるかを調べたい時」などに有用です。

　UATから実行する場合は`-additionalcookeroptions=-DiffOnly`を引数に追加してクックを行います。このオプションは、オプションを付けないケースのクック結果との差分を出力するので、最初に`-additionalcookeroptions=-DiffOnly`オプションを付けずにクックを実行し、1回目が終わったら`-additionalcookeroptions=-DiffOnly`オプションを続けてクックを行います。以下は.batファイルからプロジェクトをクックする際の差分クックを実行するコマンド例です。ProjectLauncherからクックを行う場合には`Additional Cooker Option`に`-DiffOnly`を付与します。

```batchfile:Cook-DiffOnly.bat
set ENGINE_PATH=D:\Release-4.27\Engine\Build\BatchFiles\RunUAT.bat
set PROJECT_PATH="C:\Projects\MyProject\MyProject.uproject"
%ENGINE_PATH% BuildCookRun -project=%PROJECT_PATH% -platform=Win64 -configuration=development -cook -additionalcookeroptions=-DiffOnly
```

　2つのクック結果で差分が発生しているアセットのアセット名やプロパティ名をログに出力して表示します。以下は差分が発生した時のログの一例です。1回目のクックと2回目のクックで、GameMode Blueprint の `bAllowTickBeforeBeginPlay` フラグを変更しています。ログが示すように1回目と2回目のクック結果に差分がある場合は、プロパティ名、クラス名、変更を検出したコールスタック、バイナリ差分を表示します。

```
LogArchiveDiff: Warning: C:/Projects/MyProject/Saved/Cooked/WindowsNoEditor/MyProject/Content/ThirdPersonBP/Blueprints/ThirdPersonGameMode.uasset: Size mismatch: on disk: 2732 vs memory: 2787
LogArchiveDiff: Warning: C:/Projects/MyProject/Saved/Cooked/WindowsNoEditor/MyProject/Content/ThirdPersonBP/Blueprints/ThirdPersonGameMode.uasset: 1513 difference(s) not logged (first at absolute offset: 24).
LogArchiveDiff: Warning: C:/Projects/MyProject/Saved/Cooked/WindowsNoEditor/MyProject/Content/ThirdPersonBP/Blueprints/ThirdPersonGameMode.uasset: NameMap is different (47 Names in source package vs 49 Names in dest package):
                         +[6] bAllowTickBeforeBeginPlay
                         +[8] BoolProperty

LogArchiveDiff: Warning: C:/Projects/MyProject/Saved/Cooked/WindowsNoEditor/MyProject/Content/ThirdPersonBP/Blueprints/ThirdPersonGameMode.uasset: ExportMap is different (5 Exports in source package vs 5 Exports in dest package):
                         -[1] ThirdPersonGameMode_C ThirdPersonGameMode.Default__ThirdPersonGameMode_C Super: NULL, Template: GameModeBase /Script/Engine.Default__GameModeBase, Flags: 49, Size: 37, PackageGuid: 00000000000000000000000000000000, PackageFlags: 0, ForcedExport: 0, NotForClient: 0, NotForServer: 0, NotAlwaysLoadedForEditorGame: 0, IsAsset: 0
                         +[1] ThirdPersonGameMode_C ThirdPersonGameMode.Default__ThirdPersonGameMode_C Super: NULL, Template: GameModeBase /Script/Engine.Default__GameModeBase, Flags: 57, Size: 63, PackageGuid: 00000000000000000000000000000000, PackageFlags: 0, ForcedExport: 0, NotForClient: 0, NotForServer: 0, NotAlwaysLoadedForEditorGame: 0, IsAsset: 0

LogArchiveDiff: Warning: C:/Projects/MyProject/Saved/Cooked/WindowsNoEditor/MyProject/Content/ThirdPersonBP/Blueprints/ThirdPersonGameMode.uexp: Size mismatch: on disk: 504 vs memory: 530
LogArchiveDiff: Warning: C:/Projects/MyProject/Saved/Cooked/WindowsNoEditor/MyProject/Content/ThirdPersonBP/Blueprints/ThirdPersonGameMode.uexp: Difference at offset 0 (absolute offset: 2787): byte 39 on disk, byte 41 in memory, callstack:

                         UE4Editor-CoreUObject.dll!operator<<() [D:\Release-4.27\Engine\Source\Runtime\CoreUObject\Private\UObject\PropertyTag.cpp:95]
                         UE4Editor-CoreUObject.dll!UStruct::SerializeVersionedTaggedProperties() [D:\Release-4.27\Engine\Source\Runtime\CoreUObject\Private\UObject\Class.cpp:1576]
                         UE4Editor-CoreUObject.dll!UStruct::SerializeTaggedProperties() [D:\Release-4.27\Engine\Source\Runtime\CoreUObject\Private\UObject\Class.cpp:1287]
                         UE4Editor-CoreUObject.dll!UObject::SerializeScriptProperties() [D:\Release-4.27\Engine\Source\Runtime\CoreUObject\Private\UObject\Obj.cpp:1467]
                         UE4Editor-CoreUObject.dll!UObject::Serialize() [D:\Release-4.27\Engine\Source\Runtime\CoreUObject\Private\UObject\Obj.cpp:1387]
                         UE4Editor-CoreUObject.dll!UObject::Serialize() [D:\Release-4.27\Engine\Source\Runtime\CoreUObject\Private\UObject\Obj.cpp:1272]
                         UE4Editor-CoreUObject.dll!UStruct::Serialize() [D:\Release-4.27\Engine\Source\Runtime\CoreUObject\Private\UObject\Class.cpp:1798]
                         UE4Editor-CoreUObject.dll!UClass::Serialize() [D:\Release-4.27\Engine\Source\Runtime\CoreUObject\Private\UObject\Class.cpp:4404]
                         UE4Editor-Engine.dll!UBlueprintGeneratedClass::Serialize() [D:\Release-4.27\Engine\Source\Runtime\Engine\Private\BlueprintGeneratedClass.cpp:1766]
                         UE4Editor-CoreUObject.dll!UPackage::Save() [D:\Release-4.27\Engine\Source\Runtime\CoreUObject\Private\UObject\SavePackage.cpp:3357]

                         Serialized Object: BlueprintGeneratedClass/Game/ThirdPersonBP/Blueprints/ThirdPersonGameMode.ThirdPersonGameMode_C
                         DebugDataStack:
                             SerializeScriptProperties: BlueprintGeneratedClass
                             PropertySerialize: SimpleConstructionScript

LogArchiveDiff: Display: C:/Projects/MyProject/Saved/Cooked/WindowsNoEditor/MyProject/Content/ThirdPersonBP/Blueprints/ThirdPersonGameMode.uexp: Logging 128 bytes around absolute offset: 2732 (0000000000000AAC) in the on disk (existing) package, (which corresponds to offset 2787 (0000000000000AE3) in the in-memory package)
LogArchiveDiff: Display: 0000000000000A6C: FF FF FF FF 01 00 00 00  F4 FF FF FF 01 00 00 00  F7 FF FF FF EF FF FF FF  01 00 00 00 03 00 00 00
LogArchiveDiff: Display: 0000000000000A8C: F7 FF FF FF F6 FF FF FF  EE FF FF FF 05 00 00 00  04 00 00 00 F5 FF FF FF  ED FF FF FF 01 00 00 00
LogArchiveDiff: Display: 0000000000000AAC: 27 00 00 00 00 00 00 00  22 00 00 00 00 00 00 00  04 00 00 00 00 00 00 00  00 05 00 00 00 20 00 00
LogArchiveDiff: Display: 0000000000000ACC: 00 00 00 00 00 00 00 00  00 F8 FF FF FF 00 00 00  00 01 00 00 00 22 00 00  00 00 00 00 00 19 00 00
LogArchiveDiff: Display: C:/Projects/MyProject/Saved/Cooked/WindowsNoEditor/MyProject/Content/ThirdPersonBP/Blueprints/ThirdPersonGameMode.uexp: Logging 128 bytes around absolute offset: 2787 (0000000000000AE3) in the in memory (new) package
LogArchiveDiff: Display: 0000000000000AA3: FF FF FF FF 01 00 00 00  F4 FF FF FF 01 00 00 00  F7 FF FF FF EF FF FF FF  01 00 00 00 03 00 00 00
LogArchiveDiff: Display: 0000000000000AC3: F7 FF FF FF F6 FF FF FF  EE FF FF FF 05 00 00 00  04 00 00 00 F5 FF FF FF  ED FF FF FF 01 00 00 00
LogArchiveDiff: Display: 0000000000000AE3: 29 00 00 00 00 00 00 00  24 00 00 00 00 00 00 00  04 00 00 00 00 00 00 00  00 05 00 00 00 22 00 00
LogArchiveDiff: Display: 0000000000000B03: 00 00 00 00 00 00 00 00  00 F8 FF FF FF 00 00 00  00 01 00 00 00 24 00 00  00 00 00 00 00 1B 00 00

```

## 方法3: 1アセット差分の詳細情報を取得
　この方法でも2回クックを行いますが、「特定のアセットに差分が発生している状態で他のファイルに依存しないことを確認したい時」や「全てのクックは時間が掛かるので1つのアセットに絞って調べる時」、「クック問題を修正した後に確認したい時」などに有用です。

 　UATから実行する場合は`-additionalcookeroptions=-cooksinglepackage -diffonly`を引数に追加してクックを行います。以下はバッチファイルから1つのアセットだけの差分を比較して詳細情報を表示する例です。`ASSET_PATH`には再クック時に変更を加えていなくても変更に含まれてしまうファイルを指定します。

```batchfile:Cook-SingleAssetDiff.bat
set ENGINE_PATH=D:\Release-4.27\Engine\Build\BatchFiles\RunUAT.bat
set PROJECT_PATH="C:\Projects\TP_427\TP_427.uproject"
set ASSET_PATH=/Game/Mannequin/Animations/ThirdPersonRun
%ENGINE_PATH% BuildCookRun -Project=%PROJECT_PATH% -platform=Win64 -clientconfig=development -cook -ini:Engine:[ConsoleVariables]:cook.displaymode=2 -additionalcookeroptions="-cooksinglepackage -diffonly" -map=%ASSET_PATH%
```

## 方法4: Visual Studioでデバッグしながら詳細情報を追跡する
　この方法は「Visual Studioから起動しながらクックを実行して差分が発生したタイミングでブレークを張りたい時」などに有用です。これを利用することでより具体的なコールスタックや条件を絞ることができます。

 　Visual Studioから実行する場合は`-diffonlybreakoffset`を引数に追加してクックを行います。以下はVisual Studioから起動してクックを行い差分が発生したポイントでブレークする例です。Visual Studioから実行する際には以下のようなコマンドを引数として追加して起動します。

```:コマンドライン引数 ("AbsoluteOffset":メモリ絶対位置 指定要)
C:\Projects\TP_427\TP_427.uproject -run=cook -targetplatform=WindowsNoEditor -ini:Engine:[ConsoleVariables]:cook.displaymode=2 -cooksinglepackage -diffonly -diffonlybreakoffset=<AbsoluteOffset> -map=/Game/Mannequin/Animations/ThirdPersonRun
```
![2021-07-28_11h24_32.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/364110/ae3e57cf-c95b-baf9-4277-b1e44643b955.png)

## 方法5: パックファイルから差を特定する
### IoStoreを利用していない場合 
ゲーム中に使用するアセットは.pakファイルに纏められます。そのためUnrealPakを使用して.pakファイルの比較を行います。

```batch:DiffPak.bat のサンプル
@rem UnrealPakパス
set UNREAL_PAK_PATH=D:\UnrealEngine-4.27-plus\Engine\Binaries\Win64\UnrealPak.exe
@rem 比較するパックA(.pak)ファイルパス 
set PAK_A="C:\Projects\TP_427\PakA\WindowsNoEditor\TP_427\Content\Paks\TP_427-WindowsNoEditor.pak"
@rem 比較するパックB(.pak)ファイルパス 
set PAK_B="C:\Projects\TP_427\PakB\WindowsNoEditor\TP_427\Content\Paks\TP_427-WindowsNoEditor.pak"

%UNREAL_PAK_PATH% -diff %PAK_A% %PAK_B% 
```

差分情報は `\Engine\Programs\UnrealPak\Saved\Logs` に出力されます。以下は `WB_Test` というアセットに差分があることを示しています。

```text:[出力結果] Log.txt
LogPakFile: FileEventType, FileName, Size1, Size2
LogPakFile: FilesizeDifferent, TP_427/Content/Widget/WB_Test.uasset, 6271, 6019
LogPakFile: FilesizeDifferent, TP_427/Content/Widget/WB_Test.uexp, 4297, 3931
LogPakFile: Comparison complete
```
### IoStoreを利用している場合
ゲーム中に使用するアセットは.utocファイルに纏められます。そのためCommandletを使用して.utocファイルの比較を行います。

```batch:DiffIoStore.bat のサンプル
@rem UnrealPakパス
set UNREAL_PAK_PATH=D:\UnrealEngine-4.27-plus\Engine\Binaries\Win64\UE4Editor-Cmd.exe
@rem 比較するパックAまでのディレクトリパス 
set UTOC_A="C:\Projects\TP_427\PakA\WindowsNoEditor\TP_427\Content\Paks"
@rem 比較するパックBまでのディレクトリパス 
set UTOC_B="C:\Projects\TP_427\PakB\WindowsNoEditor\TP_427\Content\Paks"
@rem 結果の出力先 
set OUT_PATH="C:\Projects\TP_427\Pak\DumpLog.log"

%UNREAL_PAK_PATH% -run=IoStore -diff -list -Source=%UTOC_A% -Target=%UTOC_B% -DumptoFile=%OUT_PATH%
```

出力結果は指定のファイルとして保存されます。こちらの場合、パックファイル毎の統計のみを出力し、差分が発生したファイルについては出力しません。
```text:[出力結果] Log.txt
------------------------------ Container Diff Summary ------------------------------
Source path 'C:\Projects\TP_427\PakA\WindowsNoEditor\TP_427\Content\Paks'
Target path 'C:\Projects\TP_427\PakB\WindowsNoEditor\TP_427\Content\Paks'

Source container file(s):

Container                                      Size (MB)          Chunks
-------------------------------------------------------------------------
global                                              1.01               3
TP_427-WindowsNoEditor                             36.46             362
-------------------------------------------------------------------------
Total of 2 container file(s)                       37.47             365

Target container file(s):

Container                                      Size (MB)          Chunks                Unmodified           Unmodified (MB)                  Modified             Modified (MB)                     Added                Added (MB)                   Removed              Removed (MB)
----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
global                                              1.01               3               3 (100.00%)            1.01 (100.00%)                 0 (0.00%)              0.00 (0.00%)                 0 (0.00%)              0.00 (0.00%)               0/3 (0.00%)              0.00 (0.00%)
TP_427-WindowsNoEditor                             36.46             362              360 (99.45%)            36.45 (99.98%)                 2 (0.55%)              0.01 (0.02%)                 0 (0.00%)              0.00 (0.00%)             0/362 (0.00%)              0.00 (0.00%)
----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
Total of 2 container file(s)                       37.47             365                       363                     37.46                         2                      0.01                         0                      0.00                         0                      0.00

```

# 5. 原因と対策
　この問題が発生すると考えられる主な原因と対処方法です。これらについては新しい情報があれば追加したいと思います。

|原因 |対策 |
|:--- |:--- |
|アセットバージョンによる差分	|サンプルコンテンツなどアセットが古いバージョンから更新されないようなケースで稀に見られます。バージョン更新と共にシリアライズデータの変更などが反映されておらず変更が発生することがあります。アセットの再保存で対応可能です。|
|コンストラクターのランダム処理	|コンストラクター（Construction Scriptも含む）でランダム値を使用してデータを保存するようなケースでは発生することがあります。これはクック時にコンストラクタが実行されてクック毎に保存するデータが変わるためです。ランダム性を削除するか、ランダムシードに基づいたランダム性を使用して、常に同じになるようにします。|
|アセットの格納	|アセットの格納される順番が不均一なことによって変更が発生する可能性があります。オープンオーダーを使用することで、アセットがコンテナに格納される順番を固定することで解決することがあります。|
|エンジンまたはプロジェクトの不具合	|何らかの要因でクック毎にデータが不一致となることがあります。殆どがシリアライズデータの不一致によるものですが、プロジェクト側での問題ではない場合はスタッフにご報告ください。|

# 6. その他

### 差分処理
　パックファイルの差分比較``PakFileUtilities.cpp`` の ``RemoveIdenticalFiles()``で実行されます。`LogPakFile` のログを有効にしておくことで、パッケージの時点で差分があった際にファイルを確認することができます。これは圧縮したパックファイルを解凍する手間を省略することができます。

```:パッチに含まれない場合
UE_LOG(LogPakFile, Display, TEXT("Source file %s matches dest file %s and will not be included in patch"), *SourceFilename, *DestFilename);

```

```:サイズ不一致でパッチに含まれる場合
UE_LOG(LogPakFile, Display, TEXT("Source file size for %s %d bytes doesn't match %s %d bytes, did find %d"), *SourceFile, SourceTotalSize, *DestFilename, DestTotalSize, Hash ? 1 : 0);

```

# 7. 既知の問題

### AssetRegistry.bin に差分が発生する
現在以下の変更で対応が可能です。
https://github.com/EpicGames/UnrealEngine/commit/6b69c8a5d92754689e6fe516c7ae4b38df090b3a

### BulkDataInfo.ubulkmanifest に差分が発生する
現在以下のチケット内の回避策が利用可能です。
https://issues.unrealengine.com/issue/UE-138846

### xxx.ushaderbytecode, xxx.assetinfo.json に差分が発生する
現在 bShareMaterialShaderCode=False (デフォルトTrue)に変更することで対応可能です。