ue-mcp 1.0.55 → 1.0.57
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/tool-counts.json
CHANGED
package/package.json
CHANGED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
#pragma once
|
|
2
|
+
|
|
3
|
+
// Idempotent asset-creation helper shared across create_X_asset handlers.
|
|
4
|
+
// Kept in its own header so HandlerUtils.h doesn't drag the AssetTools
|
|
5
|
+
// module into every translation unit.
|
|
6
|
+
|
|
7
|
+
#include "CoreMinimal.h"
|
|
8
|
+
#include "HandlerUtils.h"
|
|
9
|
+
#include "AssetToolsModule.h"
|
|
10
|
+
#include "IAssetTools.h"
|
|
11
|
+
#include "AssetRegistry/AssetRegistryModule.h"
|
|
12
|
+
#include "Factories/Factory.h"
|
|
13
|
+
#include "UObject/UObjectGlobals.h"
|
|
14
|
+
#include "UObject/Package.h"
|
|
15
|
+
#include "Misc/PackageName.h"
|
|
16
|
+
#include "Dom/JsonValue.h"
|
|
17
|
+
#include "Dom/JsonObject.h"
|
|
18
|
+
|
|
19
|
+
/** Outcome of an idempotent asset-create attempt.
|
|
20
|
+
* If EarlyReturn is set, the caller should just `return EarlyReturn` -
|
|
21
|
+
* it carries either the Existed result (idempotency hit) or an Error.
|
|
22
|
+
* Otherwise Asset is non-null and the caller proceeds with post-create work
|
|
23
|
+
* (saving, configuring, building the success JSON). */
|
|
24
|
+
template <typename TAsset>
|
|
25
|
+
struct FMCPAssetCreate
|
|
26
|
+
{
|
|
27
|
+
TAsset* Asset = nullptr;
|
|
28
|
+
TSharedPtr<FJsonValue> EarlyReturn;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/** Probe-then-create using AssetTools. Honors onConflict ("skip" returns
|
|
32
|
+
* the Existed record, "error" returns an MCPError). On success returns
|
|
33
|
+
* the newly created asset cast to TAsset; the caller is responsible for
|
|
34
|
+
* SaveAssetPackage() and assembling the result JSON. */
|
|
35
|
+
template <typename TAsset>
|
|
36
|
+
inline FMCPAssetCreate<TAsset> MCPCreateAssetIdempotent(
|
|
37
|
+
const FString& Name,
|
|
38
|
+
const FString& PackagePath,
|
|
39
|
+
const FString& OnConflict,
|
|
40
|
+
const FString& AssetTypeLabel,
|
|
41
|
+
UClass* AssetClass,
|
|
42
|
+
UFactory* Factory)
|
|
43
|
+
{
|
|
44
|
+
FMCPAssetCreate<TAsset> Out;
|
|
45
|
+
|
|
46
|
+
if (auto Existing = MCPCheckAssetExists(PackagePath, Name, OnConflict, AssetTypeLabel))
|
|
47
|
+
{
|
|
48
|
+
Out.EarlyReturn = Existing;
|
|
49
|
+
return Out;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (!AssetClass)
|
|
53
|
+
{
|
|
54
|
+
Out.EarlyReturn = MCPError(FString::Printf(TEXT("%s class is unavailable (plugin not loaded?)"), *AssetTypeLabel));
|
|
55
|
+
return Out;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
FAssetToolsModule& AssetToolsModule = FModuleManager::LoadModuleChecked<FAssetToolsModule>(TEXT("AssetTools"));
|
|
59
|
+
UObject* NewAsset = AssetToolsModule.Get().CreateAsset(Name, PackagePath, AssetClass, Factory);
|
|
60
|
+
if (!NewAsset)
|
|
61
|
+
{
|
|
62
|
+
Out.EarlyReturn = MCPError(FString::Printf(TEXT("Failed to create %s asset"), *AssetTypeLabel));
|
|
63
|
+
return Out;
|
|
64
|
+
}
|
|
65
|
+
Out.Asset = Cast<TAsset>(NewAsset);
|
|
66
|
+
if (!Out.Asset)
|
|
67
|
+
{
|
|
68
|
+
Out.EarlyReturn = MCPError(FString::Printf(TEXT("Created asset is not a %s"), *AssetTypeLabel));
|
|
69
|
+
return Out;
|
|
70
|
+
}
|
|
71
|
+
return Out;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Overload for the common case where TAsset's class is statically known. */
|
|
75
|
+
template <typename TAsset>
|
|
76
|
+
inline FMCPAssetCreate<TAsset> MCPCreateAssetIdempotent(
|
|
77
|
+
const FString& Name,
|
|
78
|
+
const FString& PackagePath,
|
|
79
|
+
const FString& OnConflict,
|
|
80
|
+
const FString& AssetTypeLabel,
|
|
81
|
+
UFactory* Factory)
|
|
82
|
+
{
|
|
83
|
+
return MCPCreateAssetIdempotent<TAsset>(Name, PackagePath, OnConflict, AssetTypeLabel, TAsset::StaticClass(), Factory);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Probe-then-create using direct NewObject<> on a fresh UPackage. Used by
|
|
87
|
+
* asset types whose proper factory either doesn't exist or whose Initialize
|
|
88
|
+
* / configuration must happen on the constructed object before
|
|
89
|
+
* AssetTools-style finalization (LevelSequence, AnimSequence, AnimComposite,
|
|
90
|
+
* PoseSearchDatabase, NiagaraSystem-from-spec).
|
|
91
|
+
*
|
|
92
|
+
* Calls FAssetRegistryModule::AssetCreated and marks the package dirty.
|
|
93
|
+
* Caller is responsible for any post-create configuration plus
|
|
94
|
+
* UEditorAssetLibrary::SaveLoadedAsset / SaveAssetPackage and the success
|
|
95
|
+
* JSON. */
|
|
96
|
+
template <typename TAsset>
|
|
97
|
+
inline FMCPAssetCreate<TAsset> MCPCreateAssetIdempotentNewObject(
|
|
98
|
+
const FString& Name,
|
|
99
|
+
const FString& PackagePath,
|
|
100
|
+
const FString& OnConflict,
|
|
101
|
+
const FString& AssetTypeLabel)
|
|
102
|
+
{
|
|
103
|
+
FMCPAssetCreate<TAsset> Out;
|
|
104
|
+
|
|
105
|
+
if (auto Existing = MCPCheckAssetExists(PackagePath, Name, OnConflict, AssetTypeLabel))
|
|
106
|
+
{
|
|
107
|
+
Out.EarlyReturn = Existing;
|
|
108
|
+
return Out;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const FString PkgName = PackagePath + TEXT("/") + Name;
|
|
112
|
+
UPackage* Package = CreatePackage(*PkgName);
|
|
113
|
+
if (!Package)
|
|
114
|
+
{
|
|
115
|
+
Out.EarlyReturn = MCPError(FString::Printf(TEXT("Failed to create package for %s '%s'"), *AssetTypeLabel, *PkgName));
|
|
116
|
+
return Out;
|
|
117
|
+
}
|
|
118
|
+
TAsset* NewAsset = NewObject<TAsset>(Package, TAsset::StaticClass(), *Name, RF_Public | RF_Standalone);
|
|
119
|
+
if (!NewAsset)
|
|
120
|
+
{
|
|
121
|
+
Out.EarlyReturn = MCPError(FString::Printf(TEXT("Failed to construct %s '%s'"), *AssetTypeLabel, *Name));
|
|
122
|
+
return Out;
|
|
123
|
+
}
|
|
124
|
+
FAssetRegistryModule::AssetCreated(NewAsset);
|
|
125
|
+
NewAsset->MarkPackageDirty();
|
|
126
|
+
Package->SetDirtyFlag(true);
|
|
127
|
+
Out.Asset = NewAsset;
|
|
128
|
+
return Out;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// (Rollback emission lives in HandlerUtils.h as MCPSetDeleteAssetRollback;
|
|
132
|
+
// kept there because non-create handlers also need it.)
|
|
@@ -0,0 +1,634 @@
|
|
|
1
|
+
#pragma once
|
|
2
|
+
|
|
3
|
+
#include "CoreMinimal.h"
|
|
4
|
+
#include "Runtime/Launch/Resources/Version.h"
|
|
5
|
+
#include "Dom/JsonValue.h"
|
|
6
|
+
#include "Dom/JsonObject.h"
|
|
7
|
+
#include "UObject/UObjectIterator.h"
|
|
8
|
+
#include "UObject/Package.h"
|
|
9
|
+
#include "UObject/SavePackage.h"
|
|
10
|
+
#include "Misc/PackageName.h"
|
|
11
|
+
#include "Engine/World.h"
|
|
12
|
+
#include "Engine/Blueprint.h"
|
|
13
|
+
#include "EngineUtils.h"
|
|
14
|
+
#include "GameFramework/Actor.h"
|
|
15
|
+
|
|
16
|
+
// True on UE 5.5+ (and any future 6.x). Used to gate APIs introduced in 5.5
|
|
17
|
+
// that don't exist in 5.4: StateTreeEditingSubsystem, FExpressionInputIterator,
|
|
18
|
+
// AActor::Get/SetNetUpdateFrequency, UWidgetBlueprint::WidgetVariableNameToGuidMap,
|
|
19
|
+
// UPCGEditorGraphNodeBase, UIKRetargeterController::AssignIKRigToAllOps, etc.
|
|
20
|
+
#define UE_MCP_HAS_5_5_API ((ENGINE_MAJOR_VERSION > 5) || (ENGINE_MAJOR_VERSION == 5 && ENGINE_MINOR_VERSION >= 5))
|
|
21
|
+
|
|
22
|
+
// ── Quick result builders ────────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
/** Return an error response: { success: false, error: "..." } */
|
|
25
|
+
inline TSharedPtr<FJsonValue> MCPError(const FString& Message)
|
|
26
|
+
{
|
|
27
|
+
TSharedPtr<FJsonObject> Obj = MakeShared<FJsonObject>();
|
|
28
|
+
Obj->SetBoolField(TEXT("success"), false);
|
|
29
|
+
Obj->SetStringField(TEXT("error"), Message);
|
|
30
|
+
return MakeShared<FJsonValueObject>(Obj);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Return a formatted error. Usage: MCPError(FString::Printf(TEXT("Not found: %s"), *Path)) */
|
|
34
|
+
// NOTE: Do not use a variadic template wrapper — UE 5.7's consteval format
|
|
35
|
+
// string validation requires TEXT() literals passed directly to FString::Printf.
|
|
36
|
+
|
|
37
|
+
/** Wrap a populated FJsonObject as a FJsonValue (the common return). */
|
|
38
|
+
inline TSharedPtr<FJsonValue> MCPResult(TSharedPtr<FJsonObject> Obj)
|
|
39
|
+
{
|
|
40
|
+
return MakeShared<FJsonValueObject>(Obj);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Create a fresh result object with success=true pre-set. */
|
|
44
|
+
inline TSharedPtr<FJsonObject> MCPSuccess()
|
|
45
|
+
{
|
|
46
|
+
TSharedPtr<FJsonObject> Obj = MakeShared<FJsonObject>();
|
|
47
|
+
Obj->SetBoolField(TEXT("success"), true);
|
|
48
|
+
return Obj;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Attach a rollback record to a result. The TS bridge lifts this onto
|
|
52
|
+
* TaskResult.rollback so FlowRunner can invoke it on failure. */
|
|
53
|
+
inline void MCPSetRollback(
|
|
54
|
+
TSharedPtr<FJsonObject> Result,
|
|
55
|
+
const FString& InverseMethod,
|
|
56
|
+
TSharedPtr<FJsonObject> Payload)
|
|
57
|
+
{
|
|
58
|
+
TSharedPtr<FJsonObject> Rollback = MakeShared<FJsonObject>();
|
|
59
|
+
Rollback->SetStringField(TEXT("method"), InverseMethod);
|
|
60
|
+
Rollback->SetObjectField(TEXT("payload"), Payload);
|
|
61
|
+
Result->SetObjectField(TEXT("rollback"), Rollback);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Mark a result as "already existed, nothing created" — idempotent replay. */
|
|
65
|
+
inline void MCPSetExisted(TSharedPtr<FJsonObject> Result)
|
|
66
|
+
{
|
|
67
|
+
Result->SetBoolField(TEXT("existed"), true);
|
|
68
|
+
Result->SetBoolField(TEXT("created"), false);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Mark a result as "created this time". */
|
|
72
|
+
inline void MCPSetCreated(TSharedPtr<FJsonObject> Result)
|
|
73
|
+
{
|
|
74
|
+
Result->SetBoolField(TEXT("existed"), false);
|
|
75
|
+
Result->SetBoolField(TEXT("created"), true);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Mark a result as "updated the existing entity". */
|
|
79
|
+
inline void MCPSetUpdated(TSharedPtr<FJsonObject> Result)
|
|
80
|
+
{
|
|
81
|
+
Result->SetBoolField(TEXT("updated"), true);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Check for an existing asset at `PackagePath/Name`. Returns a fully-formed
|
|
85
|
+
* "already existed" result on hit (caller can return it directly), or an
|
|
86
|
+
* unset pointer on miss so the caller proceeds to create. Also honors an
|
|
87
|
+
* optional `onConflict: "error"` to return an MCPError instead.
|
|
88
|
+
* On miss, returns a null shared pointer (check with `.IsValid()`). */
|
|
89
|
+
inline TSharedPtr<FJsonValue> MCPCheckAssetExists(
|
|
90
|
+
const FString& PackagePath,
|
|
91
|
+
const FString& Name,
|
|
92
|
+
const FString& OnConflict,
|
|
93
|
+
const FString& FriendlyType = TEXT("Asset"))
|
|
94
|
+
{
|
|
95
|
+
const FString ProbePath = PackagePath + TEXT("/") + Name + TEXT(".") + Name;
|
|
96
|
+
if (UObject* Existing = LoadObject<UObject>(nullptr, *ProbePath))
|
|
97
|
+
{
|
|
98
|
+
if (OnConflict == TEXT("error"))
|
|
99
|
+
{
|
|
100
|
+
return MCPError(FString::Printf(TEXT("%s '%s' already exists"), *FriendlyType, *ProbePath));
|
|
101
|
+
}
|
|
102
|
+
auto Res = MCPSuccess();
|
|
103
|
+
MCPSetExisted(Res);
|
|
104
|
+
Res->SetStringField(TEXT("path"), Existing->GetPathName());
|
|
105
|
+
Res->SetStringField(TEXT("name"), Name);
|
|
106
|
+
Res->SetStringField(TEXT("packagePath"), PackagePath);
|
|
107
|
+
return MCPResult(Res);
|
|
108
|
+
}
|
|
109
|
+
return TSharedPtr<FJsonValue>();
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Emit the standard delete_asset rollback record on a create result. */
|
|
113
|
+
inline void MCPSetDeleteAssetRollback(TSharedPtr<FJsonObject> Result, const FString& AssetPath)
|
|
114
|
+
{
|
|
115
|
+
TSharedPtr<FJsonObject> Payload = MakeShared<FJsonObject>();
|
|
116
|
+
Payload->SetStringField(TEXT("assetPath"), AssetPath);
|
|
117
|
+
MCPSetRollback(Result, TEXT("delete_asset"), Payload);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Find an actor by GetActorLabel(). Returns nullptr on miss. Centralises
|
|
121
|
+
* the iterator-based lookup that previously lived as a private static in
|
|
122
|
+
* several handler translation units. */
|
|
123
|
+
inline AActor* FindActorByLabel(UWorld* World, const FString& Label)
|
|
124
|
+
{
|
|
125
|
+
if (!World) return nullptr;
|
|
126
|
+
for (TActorIterator<AActor> It(World); It; ++It)
|
|
127
|
+
{
|
|
128
|
+
if (It->GetActorLabel() == Label) return *It;
|
|
129
|
+
}
|
|
130
|
+
return nullptr;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Find an actor by either editor label or internal UObject name. Used by
|
|
134
|
+
* PIE / runtime handlers where callers may pass either form. */
|
|
135
|
+
inline AActor* FindActorByLabelOrName(UWorld* World, const FString& LabelOrName)
|
|
136
|
+
{
|
|
137
|
+
if (!World) return nullptr;
|
|
138
|
+
for (TActorIterator<AActor> It(World); It; ++It)
|
|
139
|
+
{
|
|
140
|
+
if (It->GetActorLabel() == LabelOrName || It->GetName() == LabelOrName) return *It;
|
|
141
|
+
}
|
|
142
|
+
return nullptr;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Find an actor by either editor label or full object path. Used by
|
|
146
|
+
* get_actor_details / get_component_tree which accept either form. */
|
|
147
|
+
inline AActor* FindActorByLabelOrPath(UWorld* World, const FString& Label, const FString& Path)
|
|
148
|
+
{
|
|
149
|
+
if (!World) return nullptr;
|
|
150
|
+
const bool bHasLabel = !Label.IsEmpty();
|
|
151
|
+
const bool bHasPath = !Path.IsEmpty();
|
|
152
|
+
if (!bHasLabel && !bHasPath) return nullptr;
|
|
153
|
+
for (TActorIterator<AActor> It(World); It; ++It)
|
|
154
|
+
{
|
|
155
|
+
if (bHasPath && It->GetPathName() == Path) return *It;
|
|
156
|
+
if (bHasLabel && It->GetActorLabel() == Label) return *It;
|
|
157
|
+
}
|
|
158
|
+
return nullptr;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Three-way actor lookup: label, internal name, or full path. Used by
|
|
162
|
+
* EditorHandlers_PIE invoke_function which accepts any of the three. */
|
|
163
|
+
inline AActor* FindActorByLabelNameOrPath(UWorld* World, const FString& Token)
|
|
164
|
+
{
|
|
165
|
+
if (!World || Token.IsEmpty()) return nullptr;
|
|
166
|
+
for (TActorIterator<AActor> It(World); It; ++It)
|
|
167
|
+
{
|
|
168
|
+
AActor* A = *It;
|
|
169
|
+
if (A->GetName() == Token || A->GetActorLabel() == Token || A->GetPathName() == Token) return A;
|
|
170
|
+
}
|
|
171
|
+
return nullptr;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Spawn-by-label idempotency check. If World already has an actor with the
|
|
175
|
+
* given Label, returns a fully-formed "already existed" result the caller
|
|
176
|
+
* can return directly (or an MCPError when OnConflict == "error"). When
|
|
177
|
+
* Label is empty or no match exists, returns an unset shared pointer so the
|
|
178
|
+
* caller proceeds to spawn. Mirrors MCPCheckAssetExists's contract for
|
|
179
|
+
* in-world actors. */
|
|
180
|
+
inline TSharedPtr<FJsonValue> MCPCheckActorLabelExists(
|
|
181
|
+
UWorld* World,
|
|
182
|
+
const FString& Label,
|
|
183
|
+
const FString& OnConflict,
|
|
184
|
+
const FString& FriendlyType = TEXT("Actor"))
|
|
185
|
+
{
|
|
186
|
+
if (!World || Label.IsEmpty()) return TSharedPtr<FJsonValue>();
|
|
187
|
+
for (TActorIterator<AActor> It(World); It; ++It)
|
|
188
|
+
{
|
|
189
|
+
if (It->GetActorLabel() == Label)
|
|
190
|
+
{
|
|
191
|
+
if (OnConflict == TEXT("error"))
|
|
192
|
+
{
|
|
193
|
+
return MCPError(FString::Printf(TEXT("%s '%s' already exists"), *FriendlyType, *Label));
|
|
194
|
+
}
|
|
195
|
+
auto Existing = MCPSuccess();
|
|
196
|
+
MCPSetExisted(Existing);
|
|
197
|
+
Existing->SetStringField(TEXT("actorLabel"), Label);
|
|
198
|
+
Existing->SetStringField(TEXT("actorPath"), It->GetPathName());
|
|
199
|
+
return MCPResult(Existing);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return TSharedPtr<FJsonValue>();
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Load a Blueprint by path and return its CDO cast to T. Returns nullptr
|
|
206
|
+
* on miss; writes a structured error to OutError. Centralises the
|
|
207
|
+
* pattern that previously lived in NetworkingHandlers::LoadBlueprintCDO,
|
|
208
|
+
* GasHandlers, and GameplayHandlers. */
|
|
209
|
+
template <typename T = AActor>
|
|
210
|
+
inline T* LoadBlueprintCDO(const FString& BlueprintPath, TSharedPtr<FJsonValue>& OutError)
|
|
211
|
+
{
|
|
212
|
+
UBlueprint* Blueprint = LoadObject<UBlueprint>(nullptr, *BlueprintPath);
|
|
213
|
+
if (!Blueprint && !BlueprintPath.Contains(TEXT(".")))
|
|
214
|
+
{
|
|
215
|
+
// Retry in ObjectPath form ("/Game/Foo/Bar" → "/Game/Foo/Bar.Bar").
|
|
216
|
+
FString AssetName;
|
|
217
|
+
BlueprintPath.Split(TEXT("/"), nullptr, &AssetName, ESearchCase::CaseSensitive, ESearchDir::FromEnd);
|
|
218
|
+
Blueprint = LoadObject<UBlueprint>(nullptr, *(BlueprintPath + TEXT(".") + AssetName));
|
|
219
|
+
}
|
|
220
|
+
if (!Blueprint || !Blueprint->GeneratedClass)
|
|
221
|
+
{
|
|
222
|
+
OutError = MCPError(FString::Printf(TEXT("Blueprint not found or has no generated class: %s"), *BlueprintPath));
|
|
223
|
+
return nullptr;
|
|
224
|
+
}
|
|
225
|
+
T* CDO = Cast<T>(Blueprint->GeneratedClass->GetDefaultObject());
|
|
226
|
+
if (!CDO)
|
|
227
|
+
{
|
|
228
|
+
OutError = MCPError(FString::Printf(
|
|
229
|
+
TEXT("Blueprint CDO at '%s' is not a %s"),
|
|
230
|
+
*BlueprintPath,
|
|
231
|
+
*T::StaticClass()->GetName()));
|
|
232
|
+
return nullptr;
|
|
233
|
+
}
|
|
234
|
+
return CDO;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// ── Parameter extraction ─────────────────────────────────────────────────────
|
|
238
|
+
|
|
239
|
+
/** Extract a required string parameter. Returns error JSON on failure, nullptr on success. */
|
|
240
|
+
inline TSharedPtr<FJsonValue> RequireString(
|
|
241
|
+
const TSharedPtr<FJsonObject>& Params,
|
|
242
|
+
const TCHAR* Key,
|
|
243
|
+
FString& OutValue)
|
|
244
|
+
{
|
|
245
|
+
if (Params->TryGetStringField(Key, OutValue) && !OutValue.IsEmpty())
|
|
246
|
+
return nullptr;
|
|
247
|
+
return MCPError(FString::Printf(TEXT("Missing required parameter '%s'"), Key));
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** Extract a required string from either of two keys (e.g. "path" or "assetPath"). */
|
|
251
|
+
inline TSharedPtr<FJsonValue> RequireStringAlt(
|
|
252
|
+
const TSharedPtr<FJsonObject>& Params,
|
|
253
|
+
const TCHAR* Key1,
|
|
254
|
+
const TCHAR* Key2,
|
|
255
|
+
FString& OutValue)
|
|
256
|
+
{
|
|
257
|
+
if (Params->TryGetStringField(Key1, OutValue) && !OutValue.IsEmpty())
|
|
258
|
+
return nullptr;
|
|
259
|
+
if (Params->TryGetStringField(Key2, OutValue) && !OutValue.IsEmpty())
|
|
260
|
+
return nullptr;
|
|
261
|
+
return MCPError(FString::Printf(TEXT("Missing required parameter '%s' (or '%s')"), Key1, Key2));
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** Extract an optional string, returning DefaultValue if absent. */
|
|
265
|
+
inline FString OptionalString(
|
|
266
|
+
const TSharedPtr<FJsonObject>& Params,
|
|
267
|
+
const TCHAR* Key,
|
|
268
|
+
const FString& DefaultValue = TEXT(""))
|
|
269
|
+
{
|
|
270
|
+
FString Value;
|
|
271
|
+
return Params->TryGetStringField(Key, Value) ? Value : DefaultValue;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** Extract an optional int32, returning DefaultValue if absent. */
|
|
275
|
+
inline int32 OptionalInt(
|
|
276
|
+
const TSharedPtr<FJsonObject>& Params,
|
|
277
|
+
const TCHAR* Key,
|
|
278
|
+
int32 DefaultValue = 0)
|
|
279
|
+
{
|
|
280
|
+
int32 Value;
|
|
281
|
+
return Params->TryGetNumberField(Key, Value) ? Value : DefaultValue;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** Extract an optional double, returning DefaultValue if absent. */
|
|
285
|
+
inline double OptionalNumber(
|
|
286
|
+
const TSharedPtr<FJsonObject>& Params,
|
|
287
|
+
const TCHAR* Key,
|
|
288
|
+
double DefaultValue = 0.0)
|
|
289
|
+
{
|
|
290
|
+
double Value;
|
|
291
|
+
return Params->TryGetNumberField(Key, Value) ? Value : DefaultValue;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** Extract an optional bool, returning DefaultValue if absent. */
|
|
295
|
+
inline bool OptionalBool(
|
|
296
|
+
const TSharedPtr<FJsonObject>& Params,
|
|
297
|
+
const TCHAR* Key,
|
|
298
|
+
bool DefaultValue = false)
|
|
299
|
+
{
|
|
300
|
+
bool Value;
|
|
301
|
+
return Params->TryGetBoolField(Key, Value) ? Value : DefaultValue;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** Extract a JSON array of strings into a TArray<FString>. */
|
|
305
|
+
inline TArray<FString> JsonArrayToStringList(const TArray<TSharedPtr<FJsonValue>>* Arr)
|
|
306
|
+
{
|
|
307
|
+
TArray<FString> Out;
|
|
308
|
+
if (!Arr) return Out;
|
|
309
|
+
for (const TSharedPtr<FJsonValue>& V : *Arr)
|
|
310
|
+
{
|
|
311
|
+
FString S;
|
|
312
|
+
if (V.IsValid() && V->TryGetString(S)) Out.Add(S);
|
|
313
|
+
}
|
|
314
|
+
return Out;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// ── Vector/Rotator/Color/Transform extraction ────────────────────────────────
|
|
318
|
+
//
|
|
319
|
+
// Wire shape contract (matches src/schemas.ts):
|
|
320
|
+
// Vec3: { x: number, y: number, z: number }
|
|
321
|
+
// Rotator: { pitch: number, yaw: number, roll: number }
|
|
322
|
+
// Color: { r, g, b, a? } (a defaults to 1)
|
|
323
|
+
// Transform: { location: Vec3, rotation: Rotator, scale: Vec3 }
|
|
324
|
+
//
|
|
325
|
+
// Per-axis numeric fields are individually optional. Missing axes inherit
|
|
326
|
+
// from the default value passed in. Use the *Strict variants when every
|
|
327
|
+
// axis must be present.
|
|
328
|
+
|
|
329
|
+
/** Read x/y/z fields out of a JSON object into Out. Returns true if any field
|
|
330
|
+
* was present. */
|
|
331
|
+
inline bool ReadVec3Fields(const TSharedPtr<FJsonObject>& Obj, FVector& Out)
|
|
332
|
+
{
|
|
333
|
+
if (!Obj.IsValid()) return false;
|
|
334
|
+
double Tmp;
|
|
335
|
+
bool Any = false;
|
|
336
|
+
if (Obj->TryGetNumberField(TEXT("x"), Tmp)) { Out.X = Tmp; Any = true; }
|
|
337
|
+
if (Obj->TryGetNumberField(TEXT("y"), Tmp)) { Out.Y = Tmp; Any = true; }
|
|
338
|
+
if (Obj->TryGetNumberField(TEXT("z"), Tmp)) { Out.Z = Tmp; Any = true; }
|
|
339
|
+
return Any;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
inline bool ReadRotatorFields(const TSharedPtr<FJsonObject>& Obj, FRotator& Out)
|
|
343
|
+
{
|
|
344
|
+
if (!Obj.IsValid()) return false;
|
|
345
|
+
double Tmp;
|
|
346
|
+
bool Any = false;
|
|
347
|
+
if (Obj->TryGetNumberField(TEXT("pitch"), Tmp)) { Out.Pitch = Tmp; Any = true; }
|
|
348
|
+
if (Obj->TryGetNumberField(TEXT("yaw"), Tmp)) { Out.Yaw = Tmp; Any = true; }
|
|
349
|
+
if (Obj->TryGetNumberField(TEXT("roll"), Tmp)) { Out.Roll = Tmp; Any = true; }
|
|
350
|
+
return Any;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
inline bool ReadLinearColorFields(const TSharedPtr<FJsonObject>& Obj, FLinearColor& Out)
|
|
354
|
+
{
|
|
355
|
+
if (!Obj.IsValid()) return false;
|
|
356
|
+
double Tmp;
|
|
357
|
+
bool Any = false;
|
|
358
|
+
if (Obj->TryGetNumberField(TEXT("r"), Tmp)) { Out.R = Tmp; Any = true; }
|
|
359
|
+
if (Obj->TryGetNumberField(TEXT("g"), Tmp)) { Out.G = Tmp; Any = true; }
|
|
360
|
+
if (Obj->TryGetNumberField(TEXT("b"), Tmp)) { Out.B = Tmp; Any = true; }
|
|
361
|
+
if (Obj->TryGetNumberField(TEXT("a"), Tmp)) { Out.A = Tmp; Any = true; }
|
|
362
|
+
return Any;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/** Extract an optional FVector from Params[Key]. Missing or non-object: returns DefaultValue.
|
|
366
|
+
* Individual missing axes inherit from DefaultValue. */
|
|
367
|
+
inline FVector OptionalVec3(
|
|
368
|
+
const TSharedPtr<FJsonObject>& Params,
|
|
369
|
+
const TCHAR* Key,
|
|
370
|
+
const FVector& DefaultValue = FVector::ZeroVector)
|
|
371
|
+
{
|
|
372
|
+
const TSharedPtr<FJsonObject>* Obj = nullptr;
|
|
373
|
+
if (!Params->TryGetObjectField(Key, Obj) || !Obj || !(*Obj).IsValid()) return DefaultValue;
|
|
374
|
+
FVector Out = DefaultValue;
|
|
375
|
+
ReadVec3Fields(*Obj, Out);
|
|
376
|
+
return Out;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/** Extract a required FVector. Returns error JSON on miss/malformed, nullptr on success. */
|
|
380
|
+
inline TSharedPtr<FJsonValue> RequireVec3(
|
|
381
|
+
const TSharedPtr<FJsonObject>& Params,
|
|
382
|
+
const TCHAR* Key,
|
|
383
|
+
FVector& Out)
|
|
384
|
+
{
|
|
385
|
+
const TSharedPtr<FJsonObject>* Obj = nullptr;
|
|
386
|
+
if (!Params->TryGetObjectField(Key, Obj) || !Obj || !(*Obj).IsValid())
|
|
387
|
+
return MCPError(FString::Printf(TEXT("Missing required vector parameter '%s' ({x,y,z})"), Key));
|
|
388
|
+
Out = FVector::ZeroVector;
|
|
389
|
+
if (!ReadVec3Fields(*Obj, Out))
|
|
390
|
+
return MCPError(FString::Printf(TEXT("Vector '%s' has no x/y/z fields"), Key));
|
|
391
|
+
return nullptr;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
inline FRotator OptionalRotator(
|
|
395
|
+
const TSharedPtr<FJsonObject>& Params,
|
|
396
|
+
const TCHAR* Key,
|
|
397
|
+
const FRotator& DefaultValue = FRotator::ZeroRotator)
|
|
398
|
+
{
|
|
399
|
+
const TSharedPtr<FJsonObject>* Obj = nullptr;
|
|
400
|
+
if (!Params->TryGetObjectField(Key, Obj) || !Obj || !(*Obj).IsValid()) return DefaultValue;
|
|
401
|
+
FRotator Out = DefaultValue;
|
|
402
|
+
ReadRotatorFields(*Obj, Out);
|
|
403
|
+
return Out;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
inline TSharedPtr<FJsonValue> RequireRotator(
|
|
407
|
+
const TSharedPtr<FJsonObject>& Params,
|
|
408
|
+
const TCHAR* Key,
|
|
409
|
+
FRotator& Out)
|
|
410
|
+
{
|
|
411
|
+
const TSharedPtr<FJsonObject>* Obj = nullptr;
|
|
412
|
+
if (!Params->TryGetObjectField(Key, Obj) || !Obj || !(*Obj).IsValid())
|
|
413
|
+
return MCPError(FString::Printf(TEXT("Missing required rotator parameter '%s' ({pitch,yaw,roll})"), Key));
|
|
414
|
+
Out = FRotator::ZeroRotator;
|
|
415
|
+
if (!ReadRotatorFields(*Obj, Out))
|
|
416
|
+
return MCPError(FString::Printf(TEXT("Rotator '%s' has no pitch/yaw/roll fields"), Key));
|
|
417
|
+
return nullptr;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
inline FLinearColor OptionalLinearColor(
|
|
421
|
+
const TSharedPtr<FJsonObject>& Params,
|
|
422
|
+
const TCHAR* Key,
|
|
423
|
+
const FLinearColor& DefaultValue = FLinearColor::White)
|
|
424
|
+
{
|
|
425
|
+
const TSharedPtr<FJsonObject>* Obj = nullptr;
|
|
426
|
+
if (!Params->TryGetObjectField(Key, Obj) || !Obj || !(*Obj).IsValid()) return DefaultValue;
|
|
427
|
+
FLinearColor Out = DefaultValue;
|
|
428
|
+
ReadLinearColorFields(*Obj, Out);
|
|
429
|
+
return Out;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/** Inline FVector→JSON. Mirrors FMCPJsonSerializer::SerializeVector. Use this
|
|
433
|
+
* in handlers building result objects so the wire shape stays consistent. */
|
|
434
|
+
inline TSharedPtr<FJsonObject> MCPVec3ToJsonObject(const FVector& V)
|
|
435
|
+
{
|
|
436
|
+
TSharedPtr<FJsonObject> Obj = MakeShared<FJsonObject>();
|
|
437
|
+
Obj->SetNumberField(TEXT("x"), V.X);
|
|
438
|
+
Obj->SetNumberField(TEXT("y"), V.Y);
|
|
439
|
+
Obj->SetNumberField(TEXT("z"), V.Z);
|
|
440
|
+
return Obj;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
inline TSharedPtr<FJsonObject> MCPRotatorToJsonObject(const FRotator& R)
|
|
444
|
+
{
|
|
445
|
+
TSharedPtr<FJsonObject> Obj = MakeShared<FJsonObject>();
|
|
446
|
+
Obj->SetNumberField(TEXT("pitch"), R.Pitch);
|
|
447
|
+
Obj->SetNumberField(TEXT("yaw"), R.Yaw);
|
|
448
|
+
Obj->SetNumberField(TEXT("roll"), R.Roll);
|
|
449
|
+
return Obj;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
inline TSharedPtr<FJsonObject> MCPLinearColorToJsonObject(const FLinearColor& C)
|
|
453
|
+
{
|
|
454
|
+
TSharedPtr<FJsonObject> Obj = MakeShared<FJsonObject>();
|
|
455
|
+
Obj->SetNumberField(TEXT("r"), C.R);
|
|
456
|
+
Obj->SetNumberField(TEXT("g"), C.G);
|
|
457
|
+
Obj->SetNumberField(TEXT("b"), C.B);
|
|
458
|
+
Obj->SetNumberField(TEXT("a"), C.A);
|
|
459
|
+
return Obj;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/** Extract an optional FTransform from Params[Key]. Reads location/rotation/scale sub-objects.
|
|
463
|
+
* Missing entirely or non-object: returns FTransform::Identity. */
|
|
464
|
+
inline FTransform OptionalTransform(
|
|
465
|
+
const TSharedPtr<FJsonObject>& Params,
|
|
466
|
+
const TCHAR* Key)
|
|
467
|
+
{
|
|
468
|
+
const TSharedPtr<FJsonObject>* Obj = nullptr;
|
|
469
|
+
if (!Params->TryGetObjectField(Key, Obj) || !Obj || !(*Obj).IsValid()) return FTransform::Identity;
|
|
470
|
+
FVector Loc = FVector::ZeroVector;
|
|
471
|
+
FRotator Rot = FRotator::ZeroRotator;
|
|
472
|
+
FVector Scale = FVector::OneVector;
|
|
473
|
+
const TSharedPtr<FJsonObject>* Sub = nullptr;
|
|
474
|
+
if ((*Obj)->TryGetObjectField(TEXT("location"), Sub) && Sub) ReadVec3Fields(*Sub, Loc);
|
|
475
|
+
if ((*Obj)->TryGetObjectField(TEXT("rotation"), Sub) && Sub) ReadRotatorFields(*Sub, Rot);
|
|
476
|
+
if ((*Obj)->TryGetObjectField(TEXT("scale"), Sub) && Sub) ReadVec3Fields(*Sub, Scale);
|
|
477
|
+
return FTransform(Rot, Loc, Scale);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// ── Common helpers ───────────────────────────────────────────────────────────
|
|
481
|
+
|
|
482
|
+
/** Find a UClass by short name, handling A/U prefix resolution.
|
|
483
|
+
* e.g. "StaticMeshActor" finds AStaticMeshActor, "AnimInstance" finds UAnimInstance. */
|
|
484
|
+
inline UClass* FindClassByShortName(const FString& ClassName)
|
|
485
|
+
{
|
|
486
|
+
UClass* PrefixedMatch = nullptr;
|
|
487
|
+
for (TObjectIterator<UClass> It; It; ++It)
|
|
488
|
+
{
|
|
489
|
+
const FString& Name = It->GetName();
|
|
490
|
+
if (Name == ClassName) return *It;
|
|
491
|
+
if (!PrefixedMatch && (Name == TEXT("U") + ClassName || Name == TEXT("A") + ClassName))
|
|
492
|
+
{
|
|
493
|
+
PrefixedMatch = *It;
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
return PrefixedMatch;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/** Get the editor world, or nullptr if not available. */
|
|
500
|
+
inline UWorld* GetEditorWorld()
|
|
501
|
+
{
|
|
502
|
+
if (!GEditor) return nullptr;
|
|
503
|
+
return GEditor->GetEditorWorldContext().World();
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/** Get the active PIE/Game world if one is running, or nullptr. */
|
|
507
|
+
inline UWorld* GetPIEWorld()
|
|
508
|
+
{
|
|
509
|
+
if (!GEngine) return nullptr;
|
|
510
|
+
for (const FWorldContext& Ctx : GEngine->GetWorldContexts())
|
|
511
|
+
{
|
|
512
|
+
if (Ctx.WorldType == EWorldType::PIE || Ctx.WorldType == EWorldType::Game)
|
|
513
|
+
{
|
|
514
|
+
if (UWorld* W = Ctx.World()) return W;
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
return nullptr;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/** Resolve a world scope string ("editor"|"pie"|"game"|"auto") to a UWorld. "auto" prefers PIE if running. */
|
|
521
|
+
inline UWorld* ResolveWorldScope(const FString& Scope)
|
|
522
|
+
{
|
|
523
|
+
if (Scope.Equals(TEXT("pie"), ESearchCase::IgnoreCase) || Scope.Equals(TEXT("game"), ESearchCase::IgnoreCase))
|
|
524
|
+
{
|
|
525
|
+
return GetPIEWorld();
|
|
526
|
+
}
|
|
527
|
+
if (Scope.Equals(TEXT("auto"), ESearchCase::IgnoreCase))
|
|
528
|
+
{
|
|
529
|
+
if (UWorld* W = GetPIEWorld()) return W;
|
|
530
|
+
return GetEditorWorld();
|
|
531
|
+
}
|
|
532
|
+
return GetEditorWorld();
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/** Get the editor world or return an error response. */
|
|
536
|
+
#define REQUIRE_EDITOR_WORLD(WorldVar) \
|
|
537
|
+
UWorld* WorldVar = GetEditorWorld(); \
|
|
538
|
+
if (!WorldVar) return MCPError(TEXT("Editor world not available"));
|
|
539
|
+
|
|
540
|
+
/** Load an asset by path with fallback to ObjectPath format. Returns nullptr if not found. */
|
|
541
|
+
template <typename T>
|
|
542
|
+
T* LoadAssetByPath(const FString& AssetPath)
|
|
543
|
+
{
|
|
544
|
+
T* Asset = LoadObject<T>(nullptr, *AssetPath);
|
|
545
|
+
if (Asset) return Asset;
|
|
546
|
+
|
|
547
|
+
// Try ObjectPath format: "/Game/Foo/Bar" → "/Game/Foo/Bar.Bar"
|
|
548
|
+
if (!AssetPath.Contains(TEXT(".")))
|
|
549
|
+
{
|
|
550
|
+
FString AssetName;
|
|
551
|
+
AssetPath.Split(TEXT("/"), nullptr, &AssetName, ESearchCase::CaseSensitive, ESearchDir::FromEnd);
|
|
552
|
+
Asset = LoadObject<T>(nullptr, *(AssetPath + TEXT(".") + AssetName));
|
|
553
|
+
}
|
|
554
|
+
return Asset;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/** Load an asset or return an error response. Assigns to OutVar. */
|
|
558
|
+
#define REQUIRE_ASSET(Type, OutVar, AssetPath) \
|
|
559
|
+
Type* OutVar = LoadAssetByPath<Type>(AssetPath); \
|
|
560
|
+
if (!OutVar) return MCPError(FString::Printf(TEXT("%s not found: %s"), TEXT(#Type), *AssetPath));
|
|
561
|
+
|
|
562
|
+
// ── Package save ─────────────────────────────────────────────────────────────
|
|
563
|
+
|
|
564
|
+
/** Mark the asset's package dirty and save it to disk. Used by every create/
|
|
565
|
+
* mutate handler that wants changes persisted across editor restarts.
|
|
566
|
+
* No-op if Asset or its package is null. Returns true on successful save. */
|
|
567
|
+
inline bool SaveAssetPackage(UObject* Asset)
|
|
568
|
+
{
|
|
569
|
+
if (!Asset) return false;
|
|
570
|
+
UPackage* Package = Asset->GetOutermost();
|
|
571
|
+
if (!Package) return false;
|
|
572
|
+
Package->MarkPackageDirty();
|
|
573
|
+
const FString PackageFileName = FPackageName::LongPackageNameToFilename(
|
|
574
|
+
Package->GetName(), FPackageName::GetAssetPackageExtension());
|
|
575
|
+
FSavePackageArgs SaveArgs;
|
|
576
|
+
SaveArgs.TopLevelFlags = RF_Standalone;
|
|
577
|
+
return UPackage::SavePackage(Package, nullptr, *PackageFileName, SaveArgs);
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
// ── GC root RAII ─────────────────────────────────────────────────────────────
|
|
581
|
+
|
|
582
|
+
/** RAII: root a UObject on construction, unroot on scope exit. Prevents the
|
|
583
|
+
* AddToRoot/RemoveFromRoot pairs from leaking when an early return (validation
|
|
584
|
+
* error, import failure) sneaks into the middle of the pair. */
|
|
585
|
+
class FGCRootScope
|
|
586
|
+
{
|
|
587
|
+
public:
|
|
588
|
+
explicit FGCRootScope(UObject* InObject) : Object(InObject)
|
|
589
|
+
{
|
|
590
|
+
if (Object) Object->AddToRoot();
|
|
591
|
+
}
|
|
592
|
+
~FGCRootScope()
|
|
593
|
+
{
|
|
594
|
+
if (Object && Object->IsRooted()) Object->RemoveFromRoot();
|
|
595
|
+
}
|
|
596
|
+
FGCRootScope(const FGCRootScope&) = delete;
|
|
597
|
+
FGCRootScope& operator=(const FGCRootScope&) = delete;
|
|
598
|
+
private:
|
|
599
|
+
UObject* Object = nullptr;
|
|
600
|
+
};
|
|
601
|
+
|
|
602
|
+
// ── Reflection helpers ───────────────────────────────────────────────────────
|
|
603
|
+
|
|
604
|
+
/** Find a property by name and error out cleanly if missing. Returns nullptr
|
|
605
|
+
* and writes an error JSON to OutError when the property does not exist on
|
|
606
|
+
* the class, so callers get a typed response instead of a null deref. */
|
|
607
|
+
inline FProperty* FindPropertyChecked(
|
|
608
|
+
UClass* Cls,
|
|
609
|
+
const TCHAR* PropertyName,
|
|
610
|
+
TSharedPtr<FJsonValue>& OutError)
|
|
611
|
+
{
|
|
612
|
+
if (!Cls)
|
|
613
|
+
{
|
|
614
|
+
OutError = MCPError(FString::Printf(TEXT("FindPropertyChecked('%s'): null class"), PropertyName));
|
|
615
|
+
return nullptr;
|
|
616
|
+
}
|
|
617
|
+
FProperty* Prop = Cls->FindPropertyByName(FName(PropertyName));
|
|
618
|
+
if (!Prop)
|
|
619
|
+
{
|
|
620
|
+
OutError = MCPError(FString::Printf(
|
|
621
|
+
TEXT("Property '%s' not found on class '%s' - engine version drift?"),
|
|
622
|
+
PropertyName, *Cls->GetName()));
|
|
623
|
+
}
|
|
624
|
+
return Prop;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
// ── Thread context ───────────────────────────────────────────────────────────
|
|
628
|
+
|
|
629
|
+
/** Defence-in-depth: assert we are on the game thread. UObject API calls from
|
|
630
|
+
* a non-game thread can corrupt engine state. Handlers are dispatched from
|
|
631
|
+
* GameThreadExecutor, so this should always hold; when it doesn't, the
|
|
632
|
+
* assertion surfaces the bug loudly rather than producing a silent race. */
|
|
633
|
+
#define MCP_CHECK_GAME_THREAD() \
|
|
634
|
+
checkf(IsInGameThread(), TEXT("MCP handler ran off the game thread - UObject access would be racy"))
|