はじめに
自作プラグインの Automation Test で、テスト専用の USTRUCT / UCLASS(フィクスチャ)が必要になりました。
テスト対象が「リフレクションで型を列挙してポリシーを判定する」コードだったので、本物の型を使わないと検証にならなかったからです。
ここで困ったのが、UObject を含むコードは(UHT が知っている一部のマクロを除いて)#if で切れないことです。
WITH_AUTOMATION_TESTS で囲って終わり、とはいかず、結局「ファイル単位ではなくディレクトリ単位で、Build.cs の条件によってモジュールに取り込むかどうかを切り替える」という方法に落ち着きました。
使ったのは UnrealBuildTool(以下 UBT)の ConditionalAddModuleDirectory という、あまり知られていない API です。
テスト用フィクスチャ以外にも「エンジンバージョンによって UObject 派生クラスの実装を丸ごと差し替える」といった用途にも使えるので、仕組みとハマりどころをまとめておきます。
調査環境:UE 5.8(Installed Build)。
ConditionalAddModuleDirectoryの存在確認は 4.27 / 5.0 / 5.8、UHT が許容する#ifマクロの一覧は 5.1〜5.8 のエンジンソースで確認しています。
UHT は「知らないマクロ」の #if の中にある UCLASS を受け付けない
まず前提の整理です。
UnrealHeaderTool(UHT)は、#if ブロックの中に書かれた UCLASS / USTRUCT を、UHT が知っている一部のマクロを除いて処理できません。
たとえば次のコードは、ヘッダのパース時点で弾かれます。
#if WITH_AUTOMATION_TESTS // ← UHT がここで止まる
UCLASS()
class UMyTestFixture : public UObject
{
GENERATED_BODY()
};
#endif
error: 'UCLASS' must not be inside preprocessor blocks, except for WITH_EDITOR, WITH_EDITORONLY_DATA, WITH_VERSE_VM, WITH_TESTS or WITH_VERSE_BPVM
エラーメッセージのとおり、許容されるマクロは UHT 側に固定で列挙されています(UhtCompilerDirective.DefaultAllowedCheck)。バージョンごとに少しずつ増えていて、手元のエンジンでは次のとおりでした。
| エンジン |
UCLASS / USTRUCT を囲める #if
|
|---|---|
| 5.1〜5.3 |
WITH_EDITOR / WITH_EDITORONLY_DATA
|
| 5.4〜5.5 | + WITH_VERSE_VM
|
| 5.6 | + WITH_TESTS
|
| 5.7〜5.8 | + WITH_VERSE_BPVM
|
それ以外のマクロ(WITH_AUTOMATION_TESTS、自分で定義したマクロ、ENGINE_MINOR_VERSION を使った比較など)はすべて「未知の #if」として扱われ、その中の UCLASS はエラーになります。
WITH_TESTSについて補足
5.6 以降なら#if WITH_TESTSでUCLASSを囲めます。ただしWITH_TESTSは UBT が「テストをコンパイルする構成なら 1」と定義するマクロで、Development Editor でも 1 です。後述の「利用者のエディタにテスト用の型が出てしまう」問題はこれでは解決しませんし、5.5 以前では使えず、「ini で opt-in したときだけ」「エンジンバージョンが X 以上なら」のような自前の条件も表現できません。
つまり「自分で決めた条件のときだけ型が存在してほしい」をファイルの中で表現する手段がないので、ファイルそのものをコンパイル対象に含めるかどうかで切り替える必要があります。
ConditionalAddModuleDirectory とは
ModuleRules にある protected メソッドで、中身は驚くほど単純です。
// Engine/Source/Programs/UnrealBuildTool/Configuration/Rules/ModuleRules.cs(UE 5.8)
protected bool ConditionalAddModuleDirectory(DirectoryReference directory)
{
if (DirectoryReference.Exists(directory))
{
AdditionalModuleDirectories.Add(directory);
return true;
}
return false;
}
「ディレクトリが存在すればモジュールの追加ディレクトリとして登録する」だけです。
エンジンのコメントによると、本来は NotForLicensees / NoRedist のような「あれば取り込む」フォルダのための仕組みのようです。
コラム:NotForLicensees / NoRedist とは
エンジンのソースツリーには Engine/Restricted/<区分>/ という、配布範囲を制限したコードを置くための場所があります。UBT の RestrictedFolder 列挙にも定義されていて、代表的な区分は次の 2 つです。
| 区分 | 意味 | 何が入るか |
|---|---|---|
NotForLicensees(NFL) |
ライセンシーには配布しない、Epic 社内専用 | Fortnite 向けの内部コード、開発途中で外に出せないもの(Verse 関連の一部など) |
NoRedist |
再配布不可 | NDA が必要なコンソール SDK・サードパーティ製ツール(PVS-Studio 等)や、ライセンス上再配布できないもの |
Restricted/<区分>/ の下は通常のエンジンツリーと同じ構造をミラーしていて、Restricted/NotForLicensees/Plugins/... や Restricted/NoRedist/Extras/... のように配置されます。ini の階層(ConfigHierarchy)も同様で、Engine/Config/ の各層に対して Restricted/NotForLicensees/Config/ / Restricted/NoRedist/Config/ が「あれば重ねて読む」層として定義されています。
Installed Build には、これらのフォルダは含まれていません(手元の 5.8 には Engine/Restricted/ 自体がありません。GitHub のソースも同様のはずです)。Epic 社内では「あるモジュールの一部だけを NFL に置いておき、社内ビルドでは取り込むが、公開ソースでは無いので取り込まない」という運用があり、ConditionalAddModuleDirectory の「存在すれば足す、無ければ何もしない」という挙動はまさにそのために作られています。
私たちプラグイン開発者から見ると「公開ツリーには存在しないフォルダのための API」なので普段は目に入りませんが、仕組み自体は汎用なので、本記事のように別の目的にも転用できます。
重要なのは、ここで追加されたディレクトリがモジュール本体のディレクトリと完全に同じ扱いを受けることです。
UBT は GetAllModuleDirectories()(本体 + 追加分)を起点に処理を回すので、追加ディレクトリに対しても次が行われます。
| 処理 | 場所(UE 5.8) |
|---|---|
.cpp を再帰的に探してコンパイルする |
UEBuildModuleCPP.cs |
ヘッダを UHT に走査させる(USTRUCT / UCLASS の生成コードが出る) |
UHTExecution.cs |
Public/ があれば公開インクルードパスに、Private/ はモジュール内のインクルードパスに足す |
UEBuildModuleCPP.cs |
つまり、UHT を通したい型を「条件付きで」モジュールに足すことができます。
API 自体は 4.27 / 5.0 / 5.8 のいずれにも存在していたので、かなり昔から使える仕組みです。
基本の使い方
ディレクトリ配置
ポイントは、切り替えたいコードをモジュールの中に置かないことです。
UBT はモジュールのディレクトリを再帰的に全部走査するので、Private/Fixtures/ のようにモジュール内部に置くと、条件に関係なく常にコンパイルされてしまいます。
そこで、モジュールと同じ階層にディレクトリを置きます。
Plugins/MyPlugin/Source/
├─ MyModule/
│ ├─ MyModule.Build.cs
│ ├─ Public/
│ └─ Private/
└─ MyModuleFixtures/ ← .Build.cs は置かない。.uplugin にも書かない
└─ Private/
├─ MyTestFixture.h ← USTRUCT / UCLASS を含んでよい
└─ MyTestFixture.cpp
MyModuleFixtures/ には .Build.cs を置かず、.uplugin の Modules にも書きません。
UBT から見るとこれは「モジュールではない、名前がそれっぽいだけのフォルダ」で、どこかの Build.cs が畳み込んだときだけ、そのモジュールの一部としてビルドされます。
Build.cs
public class MyModule : ModuleRules
{
public MyModule(ReadOnlyTargetRules Target) : base(Target)
{
// ...
// 条件はなんでもよい
bool bWithFixtures = Target.WithAutomationTests;
DirectoryReference FixturesDirectory = DirectoryReference.Combine(
new DirectoryReference(PluginDirectory),
"Source",
"MyModuleFixtures",
"Private"
);
if (bWithFixtures)
{
if (!DirectoryReference.Exists(FixturesDirectory))
{
// 「取り込むつもりだったのに無い」は設定ミスなので、黙って素通りさせない
throw new BuildException("MyModuleFixtures is required but missing at " + FixturesDirectory.FullName);
}
ConditionalAddModuleDirectory(FixturesDirectory);
PrivateIncludePaths.Add(FixturesDirectory.FullName);
}
// マクロは必ず 0 か 1 で定義する(後述)
PrivateDefinitions.Add("WITH_MY_FIXTURES=" + (bWithFixtures ? "1" : "0"));
}
}
テスト側のコードは、このマクロで参照を囲みます。
#if WITH_MY_FIXTURES
#include "MyTestFixture.h"
#endif
マクロは「未定義」にしない
bWithFixtures が偽のときに PrivateDefinitions を何も足さないと、#if WITH_MY_FIXTURES は未定義マクロの評価になります。
MSVC では黙って 0 扱いになりますが、Clang の -Wundef はこれをエラーにします。Fab(旧 Marketplace)向けのビルドは Clang -Werror で通るので、ここで落ちると審査で初めて発覚します。
0 / 1 を必ず定義しておくのが安全です。
判定は 1 か所にまとめる
「ディレクトリを足すかどうか」と「マクロを 1 にするかどうか」は必ず同じ判定から導きます。
2 か所に同じ条件を手書きすると、いつか片方だけ変更されて「ヘッダはインクルードされたのに .cpp がコンパイルされていない」というリンクエラーになります(実際に一度やらかしました)。
用例 1:テスト用フィクスチャを開発環境でだけ取り込む
自作プラグインで実際に使っている構成です。最初は上の例のとおり Target.WithAutomationTests だけを条件にしていたのですが、これでは足りませんでした。
WithAutomationTests だけでは配布物に漏れる
Target.WithAutomationTests は Development Editor でも真です。
利用者が普段使う構成でもフィクスチャがコンパイルされてしまい、テスト専用の USTRUCT がエディタの型選択メニュー(リフレクションで型を列挙する UI)に並ぶ、という事故が起きました。
そこで判定を 4 条件に強めました。
private static bool ShouldFold(ModuleRules Rules, ReadOnlyTargetRules Target, bool bAdditionalCondition)
{
DirectoryReference PluginDirectory = new DirectoryReference(Rules.PluginDirectory);
// プラグインが .uproject のディレクトリ配下にあるか(Engine/Plugins/ 配下に置かれた場合を除外する)
bool bIsUnderProject = (
Target.ProjectFile != null &&
PluginDirectory.IsUnderDirectory(Target.ProjectFile.Directory)
);
// プロジェクトの DefaultEngine.ini にある opt-in フラグ
bool bCompileTestFixtures = false;
if (bIsUnderProject)
{
ConfigHierarchy Ini = ConfigCache.ReadHierarchy(
ConfigHierarchyType.Engine,
Target.ProjectFile.Directory,
Target.Platform
);
Ini.GetBool("MyPlugin.Build", "bCompileTestFixtures", out bCompileTestFixtures);
}
// bAdditionalCondition は呼び出し側の条件(依存するオプショナルプラグインが有効か等)
return (
Target.WithAutomationTests &&
bIsUnderProject &&
bCompileTestFixtures &&
bAdditionalCondition
);
}
| 条件 | 何を守るか |
|---|---|
Target.WithAutomationTests |
テストをコンパイルしない構成(Shipping 等)で無駄に取り込まない |
bIsUnderProject |
エンジン側(Engine/Plugins/ や Engine/Plugins/Marketplace/)に置かれたプラグインでは取り込まない。ソースビルドのエンジンではこれらもコンパイルされるため |
bCompileTestFixtures(ini) |
開発者が明示的に opt-in したときだけ取り込む。後述のとおり、配布ビルドを実際に守っているのはこの条件 |
bAdditionalCondition |
フィクスチャが依存する別プラグインが無効ならそもそも取り込まない |
これで「開発リポジトリの中で、ini にフラグを書いた人だけ」がフィクスチャを見る状態になります。
畳み込んだときはログに 1 行出すようにしておくと、「今のビルドにフィクスチャが入ったかどうか」を後から確認できて便利です。
配布物から除外する
フィクスチャのソースは配布物に含めたくないので、Config/FilterPlugin.ini で除外します。
[FilterPlugin]
-/Source/MyPlugin*Fixtures/...
ソースが無いのに ini のフラグだけ真になると、上の BuildException で止まります。「黙って型が消える」より「ビルドが止まる」ほうがずっとマシです。
用例 2:エンジンバージョンで UObject 派生の実装を差し替える
もう 1 つ、この仕組みが効く場面です。
アセットのエディタ登録は UE 5.2 から UAssetDefinition(AssetDefinition モジュール)が推奨になり、従来の IAssetTypeActions は段階的に非推奨化が進んでいます。
複数バージョンをサポートするプラグインでは「5.1 以前は IAssetTypeActions、5.2 以降は UAssetDefinition」と切り替えたくなりますが、UAssetDefinition は UObject 派生なので #if ENGINE_MINOR_VERSION >= 2 で囲めません。
これも、バージョンごとのディレクトリを用意して Build.cs で選べば解決します。
Plugins/MyPlugin/Source/
├─ MyEditorModule/
├─ MyEditorModuleAssetTypeActions/ ← 5.1 以前用
│ └─ Private/
└─ MyEditorModuleAssetDefinition/ ← 5.2 以降用(UAssetDefinition 派生の UCLASS を置く)
└─ Private/
bool bUseAssetDefinition = (
Target.Version.MajorVersion > 5 ||
(Target.Version.MajorVersion == 5 && Target.Version.MinorVersion >= 2)
);
string VariantName = (bUseAssetDefinition ? "MyEditorModuleAssetDefinition" : "MyEditorModuleAssetTypeActions");
DirectoryReference VariantDirectory = DirectoryReference.Combine(
new DirectoryReference(PluginDirectory),
"Source",
VariantName,
"Private"
);
ConditionalAddModuleDirectory(VariantDirectory);
PrivateIncludePaths.Add(VariantDirectory.FullName);
PrivateDefinitions.Add("WITH_MY_ASSET_DEFINITION=" + (bUseAssetDefinition ? "1" : "0"));
if (bUseAssetDefinition)
{
// 5.1 以前にはモジュール自体が無いので、依存も条件付きにする
PrivateDependencyModuleNames.Add("AssetDefinition");
}
StartupModule で IAssetTypeActions を登録している箇所など、常にコンパイルされる側からの参照は #if WITH_MY_ASSET_DEFINITION で囲みます。
IAssetTypeActions の実装側は UObject ではないので #if だけでも切れますが、両方をディレクトリで対称に切り替えたほうが構成としては読みやすいと思います。
用例 1 と違うのは、こちらは両方のディレクトリを配布物に含めることです。エンジンバージョンごとに使う側のソースが必要なので、FilterPlugin.ini で除外してはいけません。
ハマりどころ:配布ビルドでは「何が畳み込まれたか」が固定される
ここからが本記事で一番伝えたいところです。Fab で配布する前提だと、畳み込みの条件は利用者の環境では評価されません。
Marketplace 配下のプラグインは再コンパイルされない
Fab 経由でインストールされたプラグインは Engine/Plugins/Marketplace/ に置かれます。
Installed Build のエンジンでは、UBT はこのフォルダのモジュールを bUsePrecompiled = true として扱い、同梱のバイナリをそのまま使ってソースをコンパイルしません(RulesCompiler.cs / RulesAssembly.cs)。
Build.cs 自体は依存関係の解決のために実行されますが、どんな条件を返してもコンパイルは起きないので結果に影響しません。
つまり利用者の手元にソースがあっても、畳み込まれるかどうかは Epic 側でビルドされた時点で決まっています。
用例 2(バージョン切り替え)はこれで問題ありません。Fab 用のバイナリはエンジンバージョンごとに、そのバージョンのエンジンでビルドされるからです。Target.Version はビルドしているエンジン自身の値なので、必ず正しく決まります。
BuildPlugin の合成ホストプロジェクトには「プロジェクトの情報」が無い
Fab 用のバイナリは RunUAT BuildPlugin で作ります。このとき UAT は一時的なホストプロジェクト(HostProject.uproject)を合成してビルドするのですが、この合成プロジェクトには次の特徴があります。
-
.uprojectの中身は{ "FileVersion": 3, "Plugins": [ { "Name": "<自プラグイン>", "Enabled": true } ] }だけ(-Dependencyで渡したプラグインがあればそれも並ぶ) -
Config/フォルダが無い(プロジェクトの ini は読めない) - プラグインは
HostProject/Plugins/<プラグイン名>/に丸ごとコピーされてからビルドされる(つまりビルド中はプロジェクト配下にある)
用例 1 の 4 条件はこの性質を意図的に利用しています。Config/ が無いので ini のフラグは読めず、開発者がフラグを戻し忘れていても配布ビルドには構造的に漏れません。
ちなみに bIsUnderProject のほうは、コピー先がプロジェクト配下なので BuildPlugin では真になります。あの条件が守っているのはソースビルドのエンジンの Engine/Plugins/ に置かれた場合で、配布ビルドを守っているのは ini の条件のほうです。
ただし、同じ性質が裏目に出るケースがあります。
畳み込みや PrivateDefinitions の判定を .uproject の内容に基づかせている場合です。たとえば「オプショナルなプラグインが有効なら対応コードを取り込む」を .uproject の Plugins[] を読んで判定していると、合成ホストプロジェクトではそのエントリが存在しないので、配布ビルドでは対応コードがすべて外れた状態でバイナリが作られます。しかもビルドは成功するので、気づくのは利用者から「その機能が無い」と言われたときです。
配布ビルドで評価される判定は、次の材料だけに基づかせるのが安全です。
| 判定の材料 | 合成ホストプロジェクトでの値 |
|---|---|
Target.Version |
ビルドしているエンジンの値(正しい) |
Target.Platform / Target.Configuration / Target.Type
|
正しい |
.uplugin に書いたこと |
正しい(プラグイン自身のファイルなので) |
| エンジンにそのプラグインがインストールされているか | 正しい |
Target.ProjectFile 配下にプラグインがあるか |
真(HostProject/Plugins/ にコピーされるため。「プロジェクト配下 = 開発環境」とは限らない) |
.uproject の Plugins[]
|
自プラグインしか無い |
プロジェクトの Config/*.ini
|
存在しない |
下 2 つに基づく判定は、「開発環境でだけ真にしたいもの」にだけ使う、と割り切っておくのがよさそうです。
そのほかの注意点
-
同じ作業ツリーでエンジンを切り替えるときはフルビルドにする。どのディレクトリが畳み込まれるかが変わると UHT の生成コードの組み合わせも変わるので、差分ビルドのままだと古い生成物が残ります。
Intermediate/Build/Win64/UnrealEditor/Inc/<Module>/を消してからビルドしてください - IDE には両方のディレクトリが見える。プロジェクトファイルを再生成すると、畳み込まれていない側のディレクトリも表示されます。IntelliSense は赤線を出しますが、実際のビルドには影響しません
-
ConditionalAddModuleDirectoryの戻り値を信用しすぎない。「無ければfalseを返す」だけなので、「取り込むつもりだったのに無い」を検出したいなら自分でDirectoryReference.Existsを確認してBuildExceptionを投げる必要があります
おわりに
「UObject を含むコードは自前の条件の #if で切れない」は UE C++ を書いていると必ずぶつかる壁ですが、ディレクトリ単位なら Build.cs で切れる、というのが今回の話でした。
ConditionalAddModuleDirectory は名前のとおり「あれば足す」だけの地味な API ですが、UHT の走査まで含めてモジュール本体と同じに扱ってくれるので、テスト用フィクスチャの隔離やエンジンバージョンごとの実装差し替えにちょうどはまります。
ただし配布ビルドでは判定が固定される、という点だけは忘れないでください。
「開発環境では動いているのに配布物では機能が無い」は、ビルドが成功する分だけ気づくのが遅れます...。