ultimate-unreal-engine-mcp 0.1.0

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.
Files changed (124) hide show
  1. package/README.md +729 -0
  2. package/dist/build/error-parser.js +51 -0
  3. package/dist/build/fix-suggester.js +84 -0
  4. package/dist/build/ubt-runner.js +146 -0
  5. package/dist/cli.js +13 -0
  6. package/dist/config.js +8 -0
  7. package/dist/docs/data/ue57-api.js +228 -0
  8. package/dist/docs/doc-index.js +110 -0
  9. package/dist/docs/types.js +4 -0
  10. package/dist/generators/class-generator.js +363 -0
  11. package/dist/generators/file-modifier.js +276 -0
  12. package/dist/generators/uht-validator.js +177 -0
  13. package/dist/index.js +89 -0
  14. package/dist/parsers/cpp-class-index.js +230 -0
  15. package/dist/parsers/cpp-parser.js +369 -0
  16. package/dist/parsers/ini-parser.js +216 -0
  17. package/dist/parsers/uproject-parser.js +130 -0
  18. package/dist/plugin-bridge/client.js +217 -0
  19. package/dist/plugin-bridge/protocol.js +6 -0
  20. package/dist/plugin-bridge/retry.js +23 -0
  21. package/dist/setup.js +209 -0
  22. package/dist/tools/ai-systems/index.js +247 -0
  23. package/dist/tools/ai-systems/types.js +4 -0
  24. package/dist/tools/animation/index.js +241 -0
  25. package/dist/tools/animation/types.js +4 -0
  26. package/dist/tools/audio/index.js +204 -0
  27. package/dist/tools/audio/types.js +4 -0
  28. package/dist/tools/blueprint/index.js +495 -0
  29. package/dist/tools/blueprint/types.js +4 -0
  30. package/dist/tools/build/index.js +163 -0
  31. package/dist/tools/chaos/index.js +230 -0
  32. package/dist/tools/chaos/types.js +4 -0
  33. package/dist/tools/collision-physics/index.js +211 -0
  34. package/dist/tools/config/index.js +288 -0
  35. package/dist/tools/cpp/index.js +305 -0
  36. package/dist/tools/docs/index.js +251 -0
  37. package/dist/tools/editor/index.js +242 -0
  38. package/dist/tools/gas/index.js +222 -0
  39. package/dist/tools/gas/types.js +5 -0
  40. package/dist/tools/import-export/index.js +218 -0
  41. package/dist/tools/input/index.js +146 -0
  42. package/dist/tools/known-issues/index.js +88 -0
  43. package/dist/tools/known-issues/middleware.js +55 -0
  44. package/dist/tools/known-issues/store.js +125 -0
  45. package/dist/tools/livelink/index.js +203 -0
  46. package/dist/tools/livelink/types.js +4 -0
  47. package/dist/tools/material/index.js +190 -0
  48. package/dist/tools/motion-design/index.js +251 -0
  49. package/dist/tools/motion-design/types.js +6 -0
  50. package/dist/tools/movie-render/index.js +220 -0
  51. package/dist/tools/networking/index.js +149 -0
  52. package/dist/tools/pcg/index.js +164 -0
  53. package/dist/tools/selection/index.js +180 -0
  54. package/dist/tools/sequencer/index.js +218 -0
  55. package/dist/tools/validation/index.js +183 -0
  56. package/dist/tools/validation/types.js +4 -0
  57. package/dist/tools/viewport/index.js +310 -0
  58. package/dist/tools/worldpartition/index.js +226 -0
  59. package/dist/tools/worldpartition/types.js +4 -0
  60. package/dist/utils/execFileNoThrow.js +40 -0
  61. package/dist/utils/logger.js +27 -0
  62. package/dist/utils/path-guard.js +26 -0
  63. package/package.json +40 -0
  64. package/unreal-plugin/MCPBridge/MCPBridge.uplugin +29 -0
  65. package/unreal-plugin/MCPBridge/Source/MCPBridgeEditor/MCPBridgeEditor.Build.cs +68 -0
  66. package/unreal-plugin/MCPBridge/Source/MCPBridgeEditor/Private/MCPAICommands.cpp +919 -0
  67. package/unreal-plugin/MCPBridge/Source/MCPBridgeEditor/Private/MCPAICommands.h +23 -0
  68. package/unreal-plugin/MCPBridge/Source/MCPBridgeEditor/Private/MCPActorCommands.cpp +415 -0
  69. package/unreal-plugin/MCPBridge/Source/MCPBridgeEditor/Private/MCPActorCommands.h +16 -0
  70. package/unreal-plugin/MCPBridge/Source/MCPBridgeEditor/Private/MCPAnimationCommands.cpp +653 -0
  71. package/unreal-plugin/MCPBridge/Source/MCPBridgeEditor/Private/MCPAnimationCommands.h +24 -0
  72. package/unreal-plugin/MCPBridge/Source/MCPBridgeEditor/Private/MCPAssetCommands.cpp +290 -0
  73. package/unreal-plugin/MCPBridge/Source/MCPBridgeEditor/Private/MCPAssetCommands.h +17 -0
  74. package/unreal-plugin/MCPBridge/Source/MCPBridgeEditor/Private/MCPAudioCommands.cpp +624 -0
  75. package/unreal-plugin/MCPBridge/Source/MCPBridgeEditor/Private/MCPAudioCommands.h +22 -0
  76. package/unreal-plugin/MCPBridge/Source/MCPBridgeEditor/Private/MCPBlueprintHandlers.cpp +616 -0
  77. package/unreal-plugin/MCPBridge/Source/MCPBridgeEditor/Private/MCPBlueprintHandlers.h +25 -0
  78. package/unreal-plugin/MCPBridge/Source/MCPBridgeEditor/Private/MCPBlueprintWriteHandlers.cpp +744 -0
  79. package/unreal-plugin/MCPBridge/Source/MCPBridgeEditor/Private/MCPBlueprintWriteHandlers.h +24 -0
  80. package/unreal-plugin/MCPBridge/Source/MCPBridgeEditor/Private/MCPBridgeEditor.cpp +23 -0
  81. package/unreal-plugin/MCPBridge/Source/MCPBridgeEditor/Private/MCPBridgeSubsystem.cpp +149 -0
  82. package/unreal-plugin/MCPBridge/Source/MCPBridgeEditor/Private/MCPBridgeSubsystem.h +38 -0
  83. package/unreal-plugin/MCPBridge/Source/MCPBridgeEditor/Private/MCPChaosCommands.cpp +771 -0
  84. package/unreal-plugin/MCPBridge/Source/MCPBridgeEditor/Private/MCPChaosCommands.h +22 -0
  85. package/unreal-plugin/MCPBridge/Source/MCPBridgeEditor/Private/MCPCollisionPhysicsCommands.cpp +749 -0
  86. package/unreal-plugin/MCPBridge/Source/MCPBridgeEditor/Private/MCPCollisionPhysicsCommands.h +22 -0
  87. package/unreal-plugin/MCPBridge/Source/MCPBridgeEditor/Private/MCPEditorStateCommands.cpp +172 -0
  88. package/unreal-plugin/MCPBridge/Source/MCPBridgeEditor/Private/MCPEditorStateCommands.h +16 -0
  89. package/unreal-plugin/MCPBridge/Source/MCPBridgeEditor/Private/MCPGASCommands.cpp +715 -0
  90. package/unreal-plugin/MCPBridge/Source/MCPBridgeEditor/Private/MCPGASCommands.h +22 -0
  91. package/unreal-plugin/MCPBridge/Source/MCPBridgeEditor/Private/MCPImportExportCommands.cpp +679 -0
  92. package/unreal-plugin/MCPBridge/Source/MCPBridgeEditor/Private/MCPImportExportCommands.h +22 -0
  93. package/unreal-plugin/MCPBridge/Source/MCPBridgeEditor/Private/MCPInputHandlers.cpp +381 -0
  94. package/unreal-plugin/MCPBridge/Source/MCPBridgeEditor/Private/MCPInputHandlers.h +24 -0
  95. package/unreal-plugin/MCPBridge/Source/MCPBridgeEditor/Private/MCPLiveLinkCommands.cpp +504 -0
  96. package/unreal-plugin/MCPBridge/Source/MCPBridgeEditor/Private/MCPLiveLinkCommands.h +22 -0
  97. package/unreal-plugin/MCPBridge/Source/MCPBridgeEditor/Private/MCPMaterialCommands.cpp +511 -0
  98. package/unreal-plugin/MCPBridge/Source/MCPBridgeEditor/Private/MCPMaterialCommands.h +22 -0
  99. package/unreal-plugin/MCPBridge/Source/MCPBridgeEditor/Private/MCPMotionDesignCommands.cpp +1110 -0
  100. package/unreal-plugin/MCPBridge/Source/MCPBridgeEditor/Private/MCPMotionDesignCommands.h +28 -0
  101. package/unreal-plugin/MCPBridge/Source/MCPBridgeEditor/Private/MCPMovieRenderCommands.cpp +590 -0
  102. package/unreal-plugin/MCPBridge/Source/MCPBridgeEditor/Private/MCPMovieRenderCommands.h +16 -0
  103. package/unreal-plugin/MCPBridge/Source/MCPBridgeEditor/Private/MCPNetworkingCommands.cpp +482 -0
  104. package/unreal-plugin/MCPBridge/Source/MCPBridgeEditor/Private/MCPNetworkingCommands.h +16 -0
  105. package/unreal-plugin/MCPBridge/Source/MCPBridgeEditor/Private/MCPPieCommands.cpp +338 -0
  106. package/unreal-plugin/MCPBridge/Source/MCPBridgeEditor/Private/MCPPieCommands.h +16 -0
  107. package/unreal-plugin/MCPBridge/Source/MCPBridgeEditor/Private/MCPSelectionCommands.cpp +677 -0
  108. package/unreal-plugin/MCPBridge/Source/MCPBridgeEditor/Private/MCPSelectionCommands.h +22 -0
  109. package/unreal-plugin/MCPBridge/Source/MCPBridgeEditor/Private/MCPSequencerCommands.cpp +721 -0
  110. package/unreal-plugin/MCPBridge/Source/MCPBridgeEditor/Private/MCPSequencerCommands.h +16 -0
  111. package/unreal-plugin/MCPBridge/Source/MCPBridgeEditor/Private/MCPValidationCommands.cpp +368 -0
  112. package/unreal-plugin/MCPBridge/Source/MCPBridgeEditor/Private/MCPValidationCommands.h +22 -0
  113. package/unreal-plugin/MCPBridge/Source/MCPBridgeEditor/Private/MCPViewportCommands.cpp +1208 -0
  114. package/unreal-plugin/MCPBridge/Source/MCPBridgeEditor/Private/MCPViewportCommands.h +29 -0
  115. package/unreal-plugin/MCPBridge/Source/MCPBridgeEditor/Private/MCPWorldPartitionCommands.cpp +822 -0
  116. package/unreal-plugin/MCPBridge/Source/MCPBridgeEditor/Private/MCPWorldPartitionCommands.h +23 -0
  117. package/unreal-plugin/MCPBridge/Source/MCPBridgeEditor/Public/MCPBridgeEditor.h +14 -0
  118. package/unreal-plugin/MCPBridge/Source/MCPBridgeRuntime/MCPBridgeRuntime.Build.cs +28 -0
  119. package/unreal-plugin/MCPBridge/Source/MCPBridgeRuntime/Private/MCPBridgeRuntime.cpp +22 -0
  120. package/unreal-plugin/MCPBridge/Source/MCPBridgeRuntime/Private/MCPCommandRouter.cpp +118 -0
  121. package/unreal-plugin/MCPBridge/Source/MCPBridgeRuntime/Private/MCPTcpServer.cpp +196 -0
  122. package/unreal-plugin/MCPBridge/Source/MCPBridgeRuntime/Public/MCPBridgeRuntime.h +15 -0
  123. package/unreal-plugin/MCPBridge/Source/MCPBridgeRuntime/Public/MCPCommandRouter.h +55 -0
  124. package/unreal-plugin/MCPBridge/Source/MCPBridgeRuntime/Public/MCPTcpServer.h +59 -0
@@ -0,0 +1,1208 @@
1
+ // MCPViewportCommands.cpp
2
+ // Implements eight viewport command handlers for the MCP bridge:
3
+ // viewport.screenshot -- capture the active viewport at a caller-specified resolution (PIE-05)
4
+ // viewport.camera -- set the active viewport camera position, rotation, and/or FOV (PIE-06)
5
+ // viewport.renderMode -- switch the active viewport render mode (PIE-07)
6
+ // viewport.hiresScreenshot -- trigger a high-resolution screenshot capture (PIE-08)
7
+ // viewport.lookAt -- resolve actor label and move editor camera to view it (VIS-03)
8
+ // viewport.frustumActors -- list actors near the current camera position (VIS-05)
9
+ // viewport.focusActor -- auto-frame actor from bounding box (VIS-07)
10
+ // viewport.cleanupScreenshots -- delete MCP-generated screenshots (VIS-08)
11
+ //
12
+ // All handlers run on the game thread (guaranteed by FMCPCommandRouter::Dispatch).
13
+ // Threat mitigations applied:
14
+ // T-12-05: hires resolution_multiplier clamped 1..8; final pixel dims clamped to 15360 per axis.
15
+ // T-12-06: screenshot width clamped 64..7680; height clamped 64..4320.
16
+ // T-12-07: render mode uses explicit string allowlist; never passes raw string to SetViewMode.
17
+ // T-12-08: look_at uses GetSafeNormal(); degenerate zero-vector result skips SetViewRotation.
18
+ // T-31-02: frustumActors max_actors clamped 1..200; radius clamped 1..100000.
19
+ // T-31-03: cleanupScreenshots only deletes mcp_screenshot_*.png in MCPScreenshots/; keep_last clamped 0..1000.
20
+ // T-31-05: lookAt/focusActor screenshot width clamped 64..7680; height clamped 64..4320.
21
+
22
+ #include "MCPViewportCommands.h"
23
+
24
+ #include "Editor.h"
25
+ #include "EditorViewportClient.h"
26
+ #include "LevelEditorViewport.h"
27
+ #include "UnrealClient.h"
28
+ #include "HighResScreenshot.h"
29
+ #include "Misc/Paths.h"
30
+ #include "Misc/DateTime.h"
31
+ #include "HAL/FileManager.h"
32
+ #include "Misc/FileHelper.h"
33
+ #include "Engine/World.h"
34
+ #include "GameFramework/Actor.h"
35
+ #include "EngineUtils.h"
36
+ #include "Serialization/JsonSerializer.h"
37
+ #include "Serialization/JsonWriter.h"
38
+ #include "Dom/JsonObject.h"
39
+ #include "Dom/JsonValue.h"
40
+
41
+ // ---------------------------------------------------------------------------
42
+ // Internal helpers
43
+ // ---------------------------------------------------------------------------
44
+
45
+ /** Returns a JSON success response string (without trailing newline). */
46
+ static FString BuildViewportSuccessResponse(const FString& CorrId, TSharedPtr<FJsonObject> Data)
47
+ {
48
+ TSharedPtr<FJsonObject> Obj = MakeShared<FJsonObject>();
49
+ Obj->SetBoolField(TEXT("success"), true);
50
+ Obj->SetStringField(TEXT("correlationId"), CorrId);
51
+ if (Data.IsValid())
52
+ {
53
+ Obj->SetObjectField(TEXT("data"), Data);
54
+ }
55
+
56
+ FString Output;
57
+ TSharedRef<TJsonWriter<>> Writer = TJsonWriterFactory<>::Create(&Output);
58
+ FJsonSerializer::Serialize(Obj.ToSharedRef(), Writer);
59
+ return Output;
60
+ }
61
+
62
+ /** Returns a JSON error response string (without trailing newline). */
63
+ static FString BuildViewportErrorResponse(const FString& CorrId, const FString& Error)
64
+ {
65
+ TSharedPtr<FJsonObject> Obj = MakeShared<FJsonObject>();
66
+ Obj->SetBoolField(TEXT("success"), false);
67
+ Obj->SetStringField(TEXT("correlationId"), CorrId);
68
+ Obj->SetStringField(TEXT("error"), Error);
69
+
70
+ FString Output;
71
+ TSharedRef<TJsonWriter<>> Writer = TJsonWriterFactory<>::Create(&Output);
72
+ FJsonSerializer::Serialize(Obj.ToSharedRef(), Writer);
73
+ return Output;
74
+ }
75
+
76
+ /**
77
+ * Returns the first visible level editor viewport client, or nullptr if none is open.
78
+ * A static_cast is safe here because MCPBridgeEditor only runs inside the editor and
79
+ * GetAllViewportClients() in the level editor context returns FLevelEditorViewportClient
80
+ * instances.
81
+ */
82
+ static FLevelEditorViewportClient* GetActiveViewportClient()
83
+ {
84
+ if (!GEditor)
85
+ {
86
+ return nullptr;
87
+ }
88
+
89
+ for (FEditorViewportClient* VC : GEditor->GetAllViewportClients())
90
+ {
91
+ if (!VC)
92
+ {
93
+ continue;
94
+ }
95
+ FLevelEditorViewportClient* LVC = static_cast<FLevelEditorViewportClient*>(VC);
96
+ if (LVC && LVC->IsVisible())
97
+ {
98
+ return LVC;
99
+ }
100
+ }
101
+ return nullptr;
102
+ }
103
+
104
+ /**
105
+ * Find an actor by label using case-insensitive substring matching.
106
+ *
107
+ * Resolution order:
108
+ * 1. Exact case-insensitive match -- returns immediately.
109
+ * 2. Substring match (case-insensitive) -- returns first match.
110
+ * 3. No match -- returns nullptr.
111
+ *
112
+ * SimilarLabels (optional, up to 10) is populated with substring matches
113
+ * for use in error messages.
114
+ *
115
+ * Threat T-31-01: label is compared against GetActorLabel() (editor-internal string).
116
+ * No path traversal or injection is possible.
117
+ */
118
+ static AActor* FindActorByLabelSubstring(
119
+ UWorld* World,
120
+ const FString& Label,
121
+ TArray<FString>* SimilarLabels = nullptr)
122
+ {
123
+ if (!World)
124
+ {
125
+ return nullptr;
126
+ }
127
+
128
+ const FString LabelLower = Label.ToLower();
129
+ AActor* SubstringMatch = nullptr;
130
+
131
+ for (TActorIterator<AActor> It(World); It; ++It)
132
+ {
133
+ AActor* Actor = *It;
134
+ if (!Actor)
135
+ {
136
+ continue;
137
+ }
138
+
139
+ const FString ActorLabel = Actor->GetActorLabel();
140
+ const FString ActorLabelLower = ActorLabel.ToLower();
141
+
142
+ // Exact case-insensitive match takes priority.
143
+ if (ActorLabelLower == LabelLower)
144
+ {
145
+ return Actor;
146
+ }
147
+
148
+ // Collect substring matches for error messages / fallback.
149
+ if (ActorLabelLower.Contains(LabelLower))
150
+ {
151
+ if (!SubstringMatch)
152
+ {
153
+ SubstringMatch = Actor;
154
+ }
155
+ if (SimilarLabels && SimilarLabels->Num() < 10)
156
+ {
157
+ SimilarLabels->Add(ActorLabel);
158
+ }
159
+ }
160
+ }
161
+
162
+ return SubstringMatch;
163
+ }
164
+
165
+ /**
166
+ * Returns the MCPScreenshots directory path (Saved/MCPScreenshots/).
167
+ * Creates the directory if it does not already exist.
168
+ */
169
+ static FString GetMCPScreenshotDir()
170
+ {
171
+ const FString Dir = FPaths::Combine(FPaths::ProjectSavedDir(), TEXT("MCPScreenshots"));
172
+ IFileManager::Get().MakeDirectory(*Dir, true /* bTree */);
173
+ return Dir;
174
+ }
175
+
176
+ /**
177
+ * Queues an MCP screenshot with the given dimensions and returns the target file path.
178
+ *
179
+ * Uses the naming convention mcp_screenshot_{timestamp}.png in the MCPScreenshots directory.
180
+ * Screenshot capture is asynchronous -- the file may not exist immediately after this call.
181
+ *
182
+ * Threat T-31-05: width clamped 64..7680, height clamped 64..4320 (same as T-12-06).
183
+ */
184
+ static FString TakeScreenshotToFile(int32 Width, int32 Height)
185
+ {
186
+ // Clamp to safe ranges (T-31-05).
187
+ Width = FMath::Clamp(Width, 64, 7680);
188
+ Height = FMath::Clamp(Height, 64, 4320);
189
+
190
+ const FString Timestamp = FDateTime::Now().ToString(TEXT("%Y%m%d_%H%M%S_%s"));
191
+ const FString FileName = FString::Printf(TEXT("mcp_screenshot_%s.png"), *Timestamp);
192
+ const FString FilePath = FPaths::Combine(GetMCPScreenshotDir(), FileName);
193
+
194
+ GScreenshotResolutionX = Width;
195
+ GScreenshotResolutionY = Height;
196
+ FScreenshotRequest::RequestScreenshot(FilePath, false /* bShowUI */);
197
+
198
+ return FilePath;
199
+ }
200
+
201
+ /**
202
+ * Resolve an angle preset string or custom {yaw, pitch} object into a FRotator.
203
+ *
204
+ * Presets:
205
+ * "front" -- yaw=0, pitch=0
206
+ * "back" -- yaw=180, pitch=0
207
+ * "left" -- yaw=-90, pitch=0
208
+ * "right" -- yaw=90, pitch=0
209
+ * "top" -- yaw=0, pitch=-89
210
+ * "45deg" -- yaw=45, pitch=-45
211
+ *
212
+ * If AngleValue is a string, the preset mapping is applied.
213
+ * If AngleValue is an object with yaw/pitch fields, those values are used directly.
214
+ * Defaults to "front" if AngleValue is null or unrecognised.
215
+ */
216
+ static FRotator ResolveAngle(const TSharedPtr<FJsonValue>& AngleValue)
217
+ {
218
+ float Yaw = 0.0f;
219
+ float Pitch = 0.0f;
220
+
221
+ if (AngleValue.IsValid())
222
+ {
223
+ if (AngleValue->Type == EJson::String)
224
+ {
225
+ const FString Preset = AngleValue->AsString();
226
+ if (Preset == TEXT("back")) { Yaw = 180.0f; Pitch = 0.0f; }
227
+ else if (Preset == TEXT("left")) { Yaw = -90.0f; Pitch = 0.0f; }
228
+ else if (Preset == TEXT("right")) { Yaw = 90.0f; Pitch = 0.0f; }
229
+ else if (Preset == TEXT("top")) { Yaw = 0.0f; Pitch = -89.0f; }
230
+ else if (Preset == TEXT("45deg")) { Yaw = 45.0f; Pitch = -45.0f; }
231
+ // else "front" or unknown -> defaults (yaw=0, pitch=0)
232
+ }
233
+ else if (AngleValue->Type == EJson::Object)
234
+ {
235
+ const TSharedPtr<FJsonObject> AngleObj = AngleValue->AsObject();
236
+ double DYaw = 0.0, DPitch = 0.0;
237
+ AngleObj->TryGetNumberField(TEXT("yaw"), DYaw);
238
+ AngleObj->TryGetNumberField(TEXT("pitch"), DPitch);
239
+ Yaw = static_cast<float>(DYaw);
240
+ Pitch = static_cast<float>(DPitch);
241
+ }
242
+ }
243
+
244
+ return FRotator(Pitch, Yaw, 0.0f);
245
+ }
246
+
247
+ // ---------------------------------------------------------------------------
248
+ // RegisterViewportCommands
249
+ // ---------------------------------------------------------------------------
250
+
251
+ void RegisterViewportCommands(FMCPCommandRouter& Router)
252
+ {
253
+ // -----------------------------------------------------------------------
254
+ // viewport.screenshot (PIE-05)
255
+ // Captures the active viewport at a caller-specified resolution. The UE
256
+ // screenshot system works asynchronously -- the file is queued and written
257
+ // on the next render frame. The handler returns the screenshots directory
258
+ // (FPaths::ScreenShotDir()) and the requested dimensions.
259
+ //
260
+ // Threat T-12-06: width clamped 64..7680, height clamped 64..4320.
261
+ // -----------------------------------------------------------------------
262
+ Router.RegisterHandler(TEXT("viewport.screenshot"), [](TSharedPtr<FJsonObject> Cmd, FMCPResponseSender SendResponse)
263
+ {
264
+ const FString CorrId = Cmd->GetStringField(TEXT("correlationId"));
265
+
266
+ // Extract optional payload.
267
+ TSharedPtr<FJsonObject> Payload;
268
+ const TSharedPtr<FJsonValue>* PayloadVal = Cmd->Values.Find(TEXT("payload"));
269
+ if (PayloadVal && (*PayloadVal)->Type == EJson::Object)
270
+ {
271
+ Payload = (*PayloadVal)->AsObject();
272
+ }
273
+
274
+ // Read width and height with defaults (T-12-06: clamp to safe ranges).
275
+ int32 Width = 1920;
276
+ int32 Height = 1080;
277
+
278
+ if (Payload.IsValid())
279
+ {
280
+ double RawWidth = 0.0;
281
+ if (Payload->TryGetNumberField(TEXT("width"), RawWidth))
282
+ {
283
+ Width = FMath::Clamp(static_cast<int32>(RawWidth), 64, 7680);
284
+ }
285
+ double RawHeight = 0.0;
286
+ if (Payload->TryGetNumberField(TEXT("height"), RawHeight))
287
+ {
288
+ Height = FMath::Clamp(static_cast<int32>(RawHeight), 64, 4320);
289
+ }
290
+ }
291
+
292
+ // Set global screenshot resolution and queue capture.
293
+ GScreenshotResolutionX = Width;
294
+ GScreenshotResolutionY = Height;
295
+ FScreenshotRequest::RequestScreenshot(false /* bShowUI */);
296
+
297
+ // Return the directory where UE will write the screenshot file.
298
+ const FString ShotDir = FPaths::ScreenShotDir();
299
+
300
+ TSharedPtr<FJsonObject> Data = MakeShared<FJsonObject>();
301
+ Data->SetStringField(TEXT("screenshot_dir"), ShotDir);
302
+ Data->SetNumberField(TEXT("width"), static_cast<double>(Width));
303
+ Data->SetNumberField(TEXT("height"), static_cast<double>(Height));
304
+ Data->SetBoolField(TEXT("queued"), true);
305
+
306
+ SendResponse(BuildViewportSuccessResponse(CorrId, Data) + TEXT("\n"));
307
+ });
308
+
309
+ // -----------------------------------------------------------------------
310
+ // viewport.camera (PIE-06)
311
+ // Sets the active level viewport camera's location, rotation, and/or FOV.
312
+ // Supports optional "look_at" to compute rotation from a world-space target.
313
+ //
314
+ // Payload fields (all optional):
315
+ // location {x, y, z} -- world-space camera position
316
+ // rotation {pitch, yaw, roll} -- camera rotation (overridden by look_at)
317
+ // fov float -- field of view in degrees (clamped 5..170)
318
+ // look_at {x, y, z} -- world-space target point; requires location
319
+ //
320
+ // Threat T-12-08: look_at uses GetSafeNormal() -- degenerate zero result skips SetViewRotation.
321
+ // -----------------------------------------------------------------------
322
+ Router.RegisterHandler(TEXT("viewport.camera"), [](TSharedPtr<FJsonObject> Cmd, FMCPResponseSender SendResponse)
323
+ {
324
+ const FString CorrId = Cmd->GetStringField(TEXT("correlationId"));
325
+
326
+ // Extract optional payload.
327
+ TSharedPtr<FJsonObject> Payload;
328
+ const TSharedPtr<FJsonValue>* PayloadVal = Cmd->Values.Find(TEXT("payload"));
329
+ if (PayloadVal && (*PayloadVal)->Type == EJson::Object)
330
+ {
331
+ Payload = (*PayloadVal)->AsObject();
332
+ }
333
+
334
+ FLevelEditorViewportClient* ViewportClient = GetActiveViewportClient();
335
+ if (!ViewportClient)
336
+ {
337
+ SendResponse(BuildViewportErrorResponse(CorrId, TEXT("no_active_viewport")) + TEXT("\n"));
338
+ return;
339
+ }
340
+
341
+ TSharedPtr<FJsonObject> Applied = MakeShared<FJsonObject>();
342
+
343
+ bool bHasLocation = false;
344
+ FVector NewLocation(ForceInitToZero);
345
+
346
+ // Apply location if provided.
347
+ if (Payload.IsValid())
348
+ {
349
+ const TSharedPtr<FJsonObject>* LocObj;
350
+ if (Payload->TryGetObjectField(TEXT("location"), LocObj))
351
+ {
352
+ double X = 0.0, Y = 0.0, Z = 0.0;
353
+ (*LocObj)->TryGetNumberField(TEXT("x"), X);
354
+ (*LocObj)->TryGetNumberField(TEXT("y"), Y);
355
+ (*LocObj)->TryGetNumberField(TEXT("z"), Z);
356
+ NewLocation = FVector(X, Y, Z);
357
+ bHasLocation = true;
358
+
359
+ ViewportClient->SetViewLocation(NewLocation);
360
+
361
+ TSharedPtr<FJsonObject> AppliedLoc = MakeShared<FJsonObject>();
362
+ AppliedLoc->SetNumberField(TEXT("x"), X);
363
+ AppliedLoc->SetNumberField(TEXT("y"), Y);
364
+ AppliedLoc->SetNumberField(TEXT("z"), Z);
365
+ Applied->SetObjectField(TEXT("location"), AppliedLoc);
366
+ }
367
+
368
+ // Apply explicit rotation if provided (may be overridden by look_at below).
369
+ {
370
+ const TSharedPtr<FJsonObject>* RotObj;
371
+ if (Payload->TryGetObjectField(TEXT("rotation"), RotObj))
372
+ {
373
+ double Pitch = 0.0, Yaw = 0.0, Roll = 0.0;
374
+ (*RotObj)->TryGetNumberField(TEXT("pitch"), Pitch);
375
+ (*RotObj)->TryGetNumberField(TEXT("yaw"), Yaw);
376
+ (*RotObj)->TryGetNumberField(TEXT("roll"), Roll);
377
+ const FRotator NewRot(Pitch, Yaw, Roll);
378
+ ViewportClient->SetViewRotation(NewRot);
379
+
380
+ TSharedPtr<FJsonObject> AppliedRot = MakeShared<FJsonObject>();
381
+ AppliedRot->SetNumberField(TEXT("pitch"), Pitch);
382
+ AppliedRot->SetNumberField(TEXT("yaw"), Yaw);
383
+ AppliedRot->SetNumberField(TEXT("roll"), Roll);
384
+ Applied->SetObjectField(TEXT("rotation"), AppliedRot);
385
+ }
386
+ }
387
+
388
+ // Apply FOV if provided (clamped to safe range).
389
+ {
390
+ double RawFov = 0.0;
391
+ if (Payload->TryGetNumberField(TEXT("fov"), RawFov))
392
+ {
393
+ const float ClampedFov = FMath::Clamp(static_cast<float>(RawFov), 5.0f, 170.0f);
394
+ ViewportClient->ViewFOV = ClampedFov;
395
+ Applied->SetNumberField(TEXT("fov"), static_cast<double>(ClampedFov));
396
+ }
397
+ }
398
+
399
+ // Apply look_at override (requires location to have been provided).
400
+ // T-12-08: Use GetSafeNormal() -- if result is near-zero (degenerate look_at
401
+ // that is the same as the camera position), skip SetViewRotation.
402
+ if (bHasLocation)
403
+ {
404
+ const TSharedPtr<FJsonObject>* LookAtObj;
405
+ if (Payload->TryGetObjectField(TEXT("look_at"), LookAtObj))
406
+ {
407
+ double LX = 0.0, LY = 0.0, LZ = 0.0;
408
+ (*LookAtObj)->TryGetNumberField(TEXT("x"), LX);
409
+ (*LookAtObj)->TryGetNumberField(TEXT("y"), LY);
410
+ (*LookAtObj)->TryGetNumberField(TEXT("z"), LZ);
411
+
412
+ const FVector LookAtTarget(LX, LY, LZ);
413
+ const FVector Direction = (LookAtTarget - NewLocation).GetSafeNormal();
414
+
415
+ // Only apply rotation if direction is non-degenerate (T-12-08).
416
+ if (!Direction.IsNearlyZero())
417
+ {
418
+ const FRotator LookAtRot = Direction.Rotation();
419
+ ViewportClient->SetViewRotation(LookAtRot);
420
+
421
+ TSharedPtr<FJsonObject> AppliedLookRot = MakeShared<FJsonObject>();
422
+ AppliedLookRot->SetNumberField(TEXT("pitch"), static_cast<double>(LookAtRot.Pitch));
423
+ AppliedLookRot->SetNumberField(TEXT("yaw"), static_cast<double>(LookAtRot.Yaw));
424
+ AppliedLookRot->SetNumberField(TEXT("roll"), static_cast<double>(LookAtRot.Roll));
425
+ // Override any explicitly applied rotation in the response.
426
+ Applied->SetObjectField(TEXT("rotation"), AppliedLookRot);
427
+
428
+ TSharedPtr<FJsonObject> AppliedLookAt = MakeShared<FJsonObject>();
429
+ AppliedLookAt->SetNumberField(TEXT("x"), LX);
430
+ AppliedLookAt->SetNumberField(TEXT("y"), LY);
431
+ AppliedLookAt->SetNumberField(TEXT("z"), LZ);
432
+ Applied->SetObjectField(TEXT("look_at"), AppliedLookAt);
433
+ }
434
+ }
435
+ }
436
+ }
437
+
438
+ // Force the viewport to redraw with the new camera state.
439
+ ViewportClient->Invalidate();
440
+
441
+ TSharedPtr<FJsonObject> Data = MakeShared<FJsonObject>();
442
+ Data->SetObjectField(TEXT("applied"), Applied);
443
+
444
+ SendResponse(BuildViewportSuccessResponse(CorrId, Data) + TEXT("\n"));
445
+ });
446
+
447
+ // -----------------------------------------------------------------------
448
+ // viewport.renderMode (PIE-07)
449
+ // Switches the active viewport to a named render mode.
450
+ //
451
+ // Supported modes: lit, unlit, wireframe, collision, detail_lighting
452
+ //
453
+ // Threat T-12-07: explicit string allowlist -- never passes raw user input
454
+ // to SetViewMode(). Unknown modes return "unknown_render_mode" error.
455
+ // -----------------------------------------------------------------------
456
+ Router.RegisterHandler(TEXT("viewport.renderMode"), [](TSharedPtr<FJsonObject> Cmd, FMCPResponseSender SendResponse)
457
+ {
458
+ const FString CorrId = Cmd->GetStringField(TEXT("correlationId"));
459
+
460
+ // Extract optional payload.
461
+ TSharedPtr<FJsonObject> Payload;
462
+ const TSharedPtr<FJsonValue>* PayloadVal = Cmd->Values.Find(TEXT("payload"));
463
+ if (PayloadVal && (*PayloadVal)->Type == EJson::Object)
464
+ {
465
+ Payload = (*PayloadVal)->AsObject();
466
+ }
467
+
468
+ // "mode" is required.
469
+ FString Mode;
470
+ if (!Payload.IsValid() || !Payload->TryGetStringField(TEXT("mode"), Mode) || Mode.IsEmpty())
471
+ {
472
+ SendResponse(BuildViewportErrorResponse(CorrId, TEXT("missing_mode")) + TEXT("\n"));
473
+ return;
474
+ }
475
+
476
+ FLevelEditorViewportClient* ViewportClient = GetActiveViewportClient();
477
+ if (!ViewportClient)
478
+ {
479
+ SendResponse(BuildViewportErrorResponse(CorrId, TEXT("no_active_viewport")) + TEXT("\n"));
480
+ return;
481
+ }
482
+
483
+ // Explicit allowlist mapping (T-12-07).
484
+ EViewModeIndex ModeIndex = VMI_Lit;
485
+ bool bModeKnown = false;
486
+
487
+ if (Mode == TEXT("lit"))
488
+ {
489
+ ModeIndex = VMI_Lit;
490
+ bModeKnown = true;
491
+ }
492
+ else if (Mode == TEXT("unlit"))
493
+ {
494
+ ModeIndex = VMI_Unlit;
495
+ bModeKnown = true;
496
+ }
497
+ else if (Mode == TEXT("wireframe"))
498
+ {
499
+ ModeIndex = VMI_Wireframe;
500
+ bModeKnown = true;
501
+ }
502
+ else if (Mode == TEXT("collision"))
503
+ {
504
+ ModeIndex = VMI_CollisionPawn;
505
+ bModeKnown = true;
506
+ }
507
+ else if (Mode == TEXT("detail_lighting"))
508
+ {
509
+ // VMI_Lit_DetailLighting is "Detail Lighting" mode in UE 5.7 (not VMI_LightingOnly).
510
+ ModeIndex = VMI_Lit_DetailLighting;
511
+ bModeKnown = true;
512
+ }
513
+
514
+ if (!bModeKnown)
515
+ {
516
+ // Return error with list of valid modes so the caller can correct the request.
517
+ const FString ErrMsg = FString::Printf(
518
+ TEXT("unknown_render_mode: '%s'. Valid modes: lit, unlit, wireframe, collision, detail_lighting"),
519
+ *Mode);
520
+ SendResponse(BuildViewportErrorResponse(CorrId, ErrMsg) + TEXT("\n"));
521
+ return;
522
+ }
523
+
524
+ ViewportClient->SetViewMode(ModeIndex);
525
+ ViewportClient->Invalidate();
526
+
527
+ TSharedPtr<FJsonObject> Data = MakeShared<FJsonObject>();
528
+ Data->SetStringField(TEXT("mode"), Mode);
529
+ Data->SetNumberField(TEXT("mode_index"), static_cast<double>(ModeIndex));
530
+
531
+ SendResponse(BuildViewportSuccessResponse(CorrId, Data) + TEXT("\n"));
532
+ });
533
+
534
+ // -----------------------------------------------------------------------
535
+ // viewport.hiresScreenshot (PIE-08)
536
+ // Triggers a high-resolution screenshot at a caller-specified multiplier and
537
+ // base resolution. UE writes the file asynchronously; the handler returns
538
+ // the screenshots directory and the computed final dimensions.
539
+ //
540
+ // Payload fields (all optional):
541
+ // resolution_multiplier int32 (default 2, clamped 1..8)
542
+ // width int32 (default 1920, base resolution)
543
+ // height int32 (default 1080, base resolution)
544
+ //
545
+ // Threat T-12-05: multiplier clamped 1..8; final pixel dims clamped to 15360 per axis.
546
+ // -----------------------------------------------------------------------
547
+ Router.RegisterHandler(TEXT("viewport.hiresScreenshot"), [](TSharedPtr<FJsonObject> Cmd, FMCPResponseSender SendResponse)
548
+ {
549
+ const FString CorrId = Cmd->GetStringField(TEXT("correlationId"));
550
+
551
+ // Extract optional payload.
552
+ TSharedPtr<FJsonObject> Payload;
553
+ const TSharedPtr<FJsonValue>* PayloadVal = Cmd->Values.Find(TEXT("payload"));
554
+ if (PayloadVal && (*PayloadVal)->Type == EJson::Object)
555
+ {
556
+ Payload = (*PayloadVal)->AsObject();
557
+ }
558
+
559
+ // Read parameters with defaults (T-12-05: clamp multiplier and final dims).
560
+ int32 Multiplier = 2;
561
+ int32 BaseWidth = 1920;
562
+ int32 BaseHeight = 1080;
563
+
564
+ if (Payload.IsValid())
565
+ {
566
+ double RawMult = 0.0;
567
+ if (Payload->TryGetNumberField(TEXT("resolution_multiplier"), RawMult))
568
+ {
569
+ Multiplier = FMath::Clamp(static_cast<int32>(RawMult), 1, 8);
570
+ }
571
+
572
+ double RawWidth = 0.0;
573
+ if (Payload->TryGetNumberField(TEXT("width"), RawWidth))
574
+ {
575
+ // Base width has same practical limits as the standard screenshot.
576
+ BaseWidth = FMath::Clamp(static_cast<int32>(RawWidth), 64, 7680);
577
+ }
578
+
579
+ double RawHeight = 0.0;
580
+ if (Payload->TryGetNumberField(TEXT("height"), RawHeight))
581
+ {
582
+ BaseHeight = FMath::Clamp(static_cast<int32>(RawHeight), 64, 4320);
583
+ }
584
+ }
585
+
586
+ // Compute final resolution and clamp per-axis to prevent GPU OOM (T-12-05).
587
+ constexpr int32 MaxPixelsPerAxis = 15360;
588
+ const int32 FinalWidth = FMath::Min(BaseWidth * Multiplier, MaxPixelsPerAxis);
589
+ const int32 FinalHeight = FMath::Min(BaseHeight * Multiplier, MaxPixelsPerAxis);
590
+
591
+ // Configure UE high-resolution screenshot system.
592
+ // SetResolution(X, Y, Scale) takes the final pixel dimensions and an optional scale
593
+ // factor (defaults to 1.0f meaning no additional scale applied on top).
594
+ GScreenshotResolutionX = FinalWidth;
595
+ GScreenshotResolutionY = FinalHeight;
596
+ GetHighResScreenshotConfig().SetResolution(
597
+ static_cast<uint32>(FinalWidth),
598
+ static_cast<uint32>(FinalHeight),
599
+ 1.0f);
600
+ GIsHighResScreenshot = true;
601
+
602
+ // Queue the screenshot capture.
603
+ FScreenshotRequest::RequestScreenshot(false /* bShowUI */);
604
+
605
+ const FString ShotDir = FPaths::ScreenShotDir();
606
+
607
+ TSharedPtr<FJsonObject> Data = MakeShared<FJsonObject>();
608
+ Data->SetStringField(TEXT("screenshot_dir"), ShotDir);
609
+ Data->SetNumberField(TEXT("width"), static_cast<double>(FinalWidth));
610
+ Data->SetNumberField(TEXT("height"), static_cast<double>(FinalHeight));
611
+ Data->SetNumberField(TEXT("resolution_multiplier"), static_cast<double>(Multiplier));
612
+ Data->SetBoolField(TEXT("queued"), true);
613
+
614
+ SendResponse(BuildViewportSuccessResponse(CorrId, Data) + TEXT("\n"));
615
+ });
616
+
617
+ // -----------------------------------------------------------------------
618
+ // viewport.lookAt (VIS-03)
619
+ // Resolves an actor label (or accepts an explicit world position) and moves
620
+ // the editor camera to view it from the requested distance and angle.
621
+ // Optionally takes a screenshot after moving.
622
+ //
623
+ // Payload fields:
624
+ // target string | {x,y,z} -- actor label or explicit world position (required)
625
+ // distance float -- camera distance from target (optional; auto from bounds)
626
+ // angle string | {yaw,pitch} -- preset or custom (default "front")
627
+ // screenshot bool -- take screenshot after moving (default true)
628
+ // width int -- screenshot width (default 1280, clamped 64..7680)
629
+ // height int -- screenshot height (default 720, clamped 64..4320)
630
+ //
631
+ // Threat T-31-01: actor label resolved via TActorIterator -- no injection possible.
632
+ // Threat T-31-05: screenshot dimensions clamped 64..7680 / 64..4320.
633
+ // -----------------------------------------------------------------------
634
+ Router.RegisterHandler(TEXT("viewport.lookAt"), [](TSharedPtr<FJsonObject> Cmd, FMCPResponseSender SendResponse)
635
+ {
636
+ const FString CorrId = Cmd->GetStringField(TEXT("correlationId"));
637
+
638
+ TSharedPtr<FJsonObject> Payload;
639
+ const TSharedPtr<FJsonValue>* PayloadVal = Cmd->Values.Find(TEXT("payload"));
640
+ if (PayloadVal && (*PayloadVal)->Type == EJson::Object)
641
+ {
642
+ Payload = (*PayloadVal)->AsObject();
643
+ }
644
+
645
+ if (!Payload.IsValid())
646
+ {
647
+ SendResponse(BuildViewportErrorResponse(CorrId, TEXT("missing_payload")) + TEXT("\n"));
648
+ return;
649
+ }
650
+
651
+ // Resolve target.
652
+ const TSharedPtr<FJsonValue>* TargetVal = Payload->Values.Find(TEXT("target"));
653
+ if (!TargetVal || !TargetVal->IsValid())
654
+ {
655
+ SendResponse(BuildViewportErrorResponse(CorrId, TEXT("missing_target")) + TEXT("\n"));
656
+ return;
657
+ }
658
+
659
+ FLevelEditorViewportClient* ViewportClient = GetActiveViewportClient();
660
+ if (!ViewportClient)
661
+ {
662
+ SendResponse(BuildViewportErrorResponse(CorrId, TEXT("no_active_viewport")) + TEXT("\n"));
663
+ return;
664
+ }
665
+
666
+ FVector TargetPos(ForceInitToZero);
667
+ FString ResolvedActorLabel;
668
+ FVector ActorOrigin(ForceInitToZero);
669
+ FVector ActorExtent(ForceInitToZero);
670
+ bool bHasActorBounds = false;
671
+
672
+ if ((*TargetVal)->Type == EJson::String)
673
+ {
674
+ // Resolve actor label to world position.
675
+ const FString ActorLabel = (*TargetVal)->AsString();
676
+
677
+ UWorld* World = GEditor ? GEditor->GetEditorWorldContext().World() : nullptr;
678
+ if (!World)
679
+ {
680
+ SendResponse(BuildViewportErrorResponse(CorrId, TEXT("no_editor_world")) + TEXT("\n"));
681
+ return;
682
+ }
683
+
684
+ TArray<FString> SimilarLabels;
685
+ AActor* FoundActor = FindActorByLabelSubstring(World, ActorLabel, &SimilarLabels);
686
+ if (!FoundActor)
687
+ {
688
+ // Build error with similar label suggestions.
689
+ TSharedPtr<FJsonObject> ErrData = MakeShared<FJsonObject>();
690
+ ErrData->SetStringField(TEXT("error"), TEXT("actor_not_found"));
691
+ ErrData->SetStringField(TEXT("requested_label"), ActorLabel);
692
+ TArray<TSharedPtr<FJsonValue>> SimilarArray;
693
+ for (const FString& S : SimilarLabels)
694
+ {
695
+ SimilarArray.Add(MakeShared<FJsonValueString>(S));
696
+ }
697
+ ErrData->SetArrayField(TEXT("similar_labels"), SimilarArray);
698
+
699
+ TSharedPtr<FJsonObject> ErrObj = MakeShared<FJsonObject>();
700
+ ErrObj->SetBoolField(TEXT("success"), false);
701
+ ErrObj->SetStringField(TEXT("correlationId"), CorrId);
702
+ ErrObj->SetStringField(TEXT("error"), TEXT("actor_not_found"));
703
+ ErrObj->SetObjectField(TEXT("data"), ErrData);
704
+ FString ErrOutput;
705
+ TSharedRef<TJsonWriter<>> W = TJsonWriterFactory<>::Create(&ErrOutput);
706
+ FJsonSerializer::Serialize(ErrObj.ToSharedRef(), W);
707
+ SendResponse(ErrOutput + TEXT("\n"));
708
+ return;
709
+ }
710
+
711
+ FoundActor->GetActorBounds(false, ActorOrigin, ActorExtent);
712
+ TargetPos = ActorOrigin; // Use bounds origin (centre of actor) as target.
713
+ ResolvedActorLabel = FoundActor->GetActorLabel();
714
+ bHasActorBounds = true;
715
+ }
716
+ else if ((*TargetVal)->Type == EJson::Object)
717
+ {
718
+ // Explicit world position {x, y, z}.
719
+ const TSharedPtr<FJsonObject> PosObj = (*TargetVal)->AsObject();
720
+ double X = 0.0, Y = 0.0, Z = 0.0;
721
+ PosObj->TryGetNumberField(TEXT("x"), X);
722
+ PosObj->TryGetNumberField(TEXT("y"), Y);
723
+ PosObj->TryGetNumberField(TEXT("z"), Z);
724
+ TargetPos = FVector(X, Y, Z);
725
+ }
726
+ else
727
+ {
728
+ SendResponse(BuildViewportErrorResponse(CorrId, TEXT("invalid_target_type")) + TEXT("\n"));
729
+ return;
730
+ }
731
+
732
+ // Resolve distance. If not provided and target is an actor, auto-calculate from bounds.
733
+ double RawDistance = -1.0;
734
+ Payload->TryGetNumberField(TEXT("distance"), RawDistance);
735
+
736
+ float Distance;
737
+ if (RawDistance > 0.0)
738
+ {
739
+ Distance = static_cast<float>(RawDistance);
740
+ }
741
+ else if (bHasActorBounds)
742
+ {
743
+ // Auto-calculate: enough to see the full actor (extents * 2, minimum 100).
744
+ Distance = FMath::Max(static_cast<float>(ActorExtent.Size()) * 2.0f, 100.0f);
745
+ }
746
+ else
747
+ {
748
+ Distance = 500.0f;
749
+ }
750
+
751
+ // Resolve angle preset or custom {yaw, pitch}.
752
+ const TSharedPtr<FJsonValue>* AngleVal = Payload->Values.Find(TEXT("angle"));
753
+ const FRotator AngleRot = AngleVal ? ResolveAngle(*AngleVal) : FRotator(0.0f, 0.0f, 0.0f);
754
+
755
+ // Compute camera position: target + direction_from_angle * (-distance).
756
+ // The angle rotator describes where the camera sits relative to the target.
757
+ const FVector AngleDir = AngleRot.Vector(); // unit vector pointing "forward" in that rotation
758
+ const FVector CameraPos = TargetPos + AngleDir * (-Distance);
759
+
760
+ // Compute camera rotation: look from camera toward target.
761
+ const FVector LookDir = (TargetPos - CameraPos).GetSafeNormal();
762
+
763
+ ViewportClient->SetViewLocation(CameraPos);
764
+ if (!LookDir.IsNearlyZero())
765
+ {
766
+ ViewportClient->SetViewRotation(LookDir.Rotation());
767
+ }
768
+ ViewportClient->Invalidate();
769
+
770
+ // Screenshot parameters (T-31-05: clamped).
771
+ bool bTakeScreenshot = true;
772
+ Payload->TryGetBoolField(TEXT("screenshot"), bTakeScreenshot);
773
+
774
+ double RawWidth = 1280.0, RawHeight = 720.0;
775
+ Payload->TryGetNumberField(TEXT("width"), RawWidth);
776
+ Payload->TryGetNumberField(TEXT("height"), RawHeight);
777
+ const int32 ShotWidth = FMath::Clamp(static_cast<int32>(RawWidth), 64, 7680);
778
+ const int32 ShotHeight = FMath::Clamp(static_cast<int32>(RawHeight), 64, 4320);
779
+
780
+ FString FilePath;
781
+ if (bTakeScreenshot)
782
+ {
783
+ FilePath = TakeScreenshotToFile(ShotWidth, ShotHeight);
784
+ }
785
+
786
+ // Build response data.
787
+ const FRotator CamRot = ViewportClient->GetViewRotation();
788
+
789
+ auto MakeVec = [](const FVector& V) -> TSharedPtr<FJsonObject>
790
+ {
791
+ TSharedPtr<FJsonObject> Obj = MakeShared<FJsonObject>();
792
+ Obj->SetNumberField(TEXT("x"), static_cast<double>(V.X));
793
+ Obj->SetNumberField(TEXT("y"), static_cast<double>(V.Y));
794
+ Obj->SetNumberField(TEXT("z"), static_cast<double>(V.Z));
795
+ return Obj;
796
+ };
797
+
798
+ TSharedPtr<FJsonObject> CamRotObj = MakeShared<FJsonObject>();
799
+ CamRotObj->SetNumberField(TEXT("pitch"), static_cast<double>(CamRot.Pitch));
800
+ CamRotObj->SetNumberField(TEXT("yaw"), static_cast<double>(CamRot.Yaw));
801
+ CamRotObj->SetNumberField(TEXT("roll"), static_cast<double>(CamRot.Roll));
802
+
803
+ TSharedPtr<FJsonObject> Data = MakeShared<FJsonObject>();
804
+ Data->SetObjectField(TEXT("camera_position"), MakeVec(CameraPos));
805
+ Data->SetObjectField(TEXT("camera_rotation"), CamRotObj);
806
+ Data->SetObjectField(TEXT("target_position"), MakeVec(TargetPos));
807
+
808
+ if (!ResolvedActorLabel.IsEmpty())
809
+ {
810
+ Data->SetStringField(TEXT("actor_label"), ResolvedActorLabel);
811
+
812
+ TSharedPtr<FJsonObject> BoundsObj = MakeShared<FJsonObject>();
813
+ BoundsObj->SetObjectField(TEXT("origin"), MakeVec(ActorOrigin));
814
+ BoundsObj->SetObjectField(TEXT("extent"), MakeVec(ActorExtent));
815
+ Data->SetObjectField(TEXT("actor_bounds"), BoundsObj);
816
+ }
817
+
818
+ if (!FilePath.IsEmpty())
819
+ {
820
+ Data->SetStringField(TEXT("file_path"), FilePath);
821
+ }
822
+
823
+ SendResponse(BuildViewportSuccessResponse(CorrId, Data) + TEXT("\n"));
824
+ });
825
+
826
+ // -----------------------------------------------------------------------
827
+ // viewport.frustumActors (VIS-05)
828
+ // Returns actors within a given radius of the current camera position,
829
+ // sorted by distance ascending.
830
+ //
831
+ // Payload fields (all optional):
832
+ // radius float -- max distance from camera (default 10000)
833
+ // max_actors int -- max actors to return (default 50)
834
+ //
835
+ // Threat T-31-02: max_actors clamped 1..200; radius clamped 1..100000.
836
+ // -----------------------------------------------------------------------
837
+ Router.RegisterHandler(TEXT("viewport.frustumActors"), [](TSharedPtr<FJsonObject> Cmd, FMCPResponseSender SendResponse)
838
+ {
839
+ const FString CorrId = Cmd->GetStringField(TEXT("correlationId"));
840
+
841
+ TSharedPtr<FJsonObject> Payload;
842
+ const TSharedPtr<FJsonValue>* PayloadVal = Cmd->Values.Find(TEXT("payload"));
843
+ if (PayloadVal && (*PayloadVal)->Type == EJson::Object)
844
+ {
845
+ Payload = (*PayloadVal)->AsObject();
846
+ }
847
+
848
+ FLevelEditorViewportClient* ViewportClient = GetActiveViewportClient();
849
+ if (!ViewportClient)
850
+ {
851
+ SendResponse(BuildViewportErrorResponse(CorrId, TEXT("no_active_viewport")) + TEXT("\n"));
852
+ return;
853
+ }
854
+
855
+ UWorld* World = GEditor ? GEditor->GetEditorWorldContext().World() : nullptr;
856
+ if (!World)
857
+ {
858
+ SendResponse(BuildViewportErrorResponse(CorrId, TEXT("no_editor_world")) + TEXT("\n"));
859
+ return;
860
+ }
861
+
862
+ // Read parameters with defaults and clamp (T-31-02).
863
+ double RawRadius = 10000.0;
864
+ double RawMaxActors = 50.0;
865
+ if (Payload.IsValid())
866
+ {
867
+ Payload->TryGetNumberField(TEXT("radius"), RawRadius);
868
+ Payload->TryGetNumberField(TEXT("max_actors"), RawMaxActors);
869
+ }
870
+ const float Radius = static_cast<float>(FMath::Clamp(RawRadius, 1.0, 100000.0));
871
+ const int32 MaxActors = FMath::Clamp(static_cast<int32>(RawMaxActors), 1, 200);
872
+
873
+ const FVector CameraPos = ViewportClient->GetViewLocation();
874
+
875
+ // Collect actors within radius.
876
+ struct FActorEntry
877
+ {
878
+ FString Label;
879
+ FString ClassName;
880
+ float Distance;
881
+ FVector WorldPos;
882
+ FVector BoundsOrigin;
883
+ FVector BoundsExtent;
884
+ };
885
+ TArray<FActorEntry> Entries;
886
+
887
+ for (TActorIterator<AActor> It(World); It; ++It)
888
+ {
889
+ AActor* Actor = *It;
890
+ if (!Actor)
891
+ {
892
+ continue;
893
+ }
894
+
895
+ const FVector ActorPos = Actor->GetActorLocation();
896
+ const float Dist = FVector::Dist(CameraPos, ActorPos);
897
+
898
+ if (Dist <= Radius)
899
+ {
900
+ FVector BoundsOrigin(ForceInitToZero);
901
+ FVector BoundsExtent(ForceInitToZero);
902
+ Actor->GetActorBounds(false, BoundsOrigin, BoundsExtent);
903
+
904
+ FActorEntry Entry;
905
+ Entry.Label = Actor->GetActorLabel();
906
+ Entry.ClassName = Actor->GetClass() ? Actor->GetClass()->GetName() : TEXT("Unknown");
907
+ Entry.Distance = Dist;
908
+ Entry.WorldPos = ActorPos;
909
+ Entry.BoundsOrigin = BoundsOrigin;
910
+ Entry.BoundsExtent = BoundsExtent;
911
+ Entries.Add(MoveTemp(Entry));
912
+ }
913
+ }
914
+
915
+ // Sort by distance ascending.
916
+ Entries.Sort([](const FActorEntry& A, const FActorEntry& B)
917
+ {
918
+ return A.Distance < B.Distance;
919
+ });
920
+
921
+ // Clamp to max_actors.
922
+ if (Entries.Num() > MaxActors)
923
+ {
924
+ Entries.SetNum(MaxActors);
925
+ }
926
+
927
+ auto MakeVec = [](const FVector& V) -> TSharedPtr<FJsonObject>
928
+ {
929
+ TSharedPtr<FJsonObject> Obj = MakeShared<FJsonObject>();
930
+ Obj->SetNumberField(TEXT("x"), static_cast<double>(V.X));
931
+ Obj->SetNumberField(TEXT("y"), static_cast<double>(V.Y));
932
+ Obj->SetNumberField(TEXT("z"), static_cast<double>(V.Z));
933
+ return Obj;
934
+ };
935
+
936
+ TArray<TSharedPtr<FJsonValue>> ActorArray;
937
+ for (const FActorEntry& Entry : Entries)
938
+ {
939
+ TSharedPtr<FJsonObject> BoundsObj = MakeShared<FJsonObject>();
940
+ BoundsObj->SetObjectField(TEXT("origin"), MakeVec(Entry.BoundsOrigin));
941
+ BoundsObj->SetObjectField(TEXT("extent"), MakeVec(Entry.BoundsExtent));
942
+
943
+ TSharedPtr<FJsonObject> ActorObj = MakeShared<FJsonObject>();
944
+ ActorObj->SetStringField(TEXT("label"), Entry.Label);
945
+ ActorObj->SetStringField(TEXT("class_name"), Entry.ClassName);
946
+ ActorObj->SetNumberField(TEXT("distance"), static_cast<double>(Entry.Distance));
947
+ ActorObj->SetObjectField(TEXT("world_position"), MakeVec(Entry.WorldPos));
948
+ ActorObj->SetObjectField(TEXT("bounds"), BoundsObj);
949
+
950
+ ActorArray.Add(MakeShared<FJsonValueObject>(ActorObj));
951
+ }
952
+
953
+ TSharedPtr<FJsonObject> CamPosObj = MakeShared<FJsonObject>();
954
+ CamPosObj->SetNumberField(TEXT("x"), static_cast<double>(CameraPos.X));
955
+ CamPosObj->SetNumberField(TEXT("y"), static_cast<double>(CameraPos.Y));
956
+ CamPosObj->SetNumberField(TEXT("z"), static_cast<double>(CameraPos.Z));
957
+
958
+ TSharedPtr<FJsonObject> Data = MakeShared<FJsonObject>();
959
+ Data->SetObjectField(TEXT("camera_position"), CamPosObj);
960
+ Data->SetNumberField(TEXT("actor_count"), static_cast<double>(ActorArray.Num()));
961
+ Data->SetArrayField(TEXT("actors"), ActorArray);
962
+
963
+ SendResponse(BuildViewportSuccessResponse(CorrId, Data) + TEXT("\n"));
964
+ });
965
+
966
+ // -----------------------------------------------------------------------
967
+ // viewport.focusActor (VIS-07)
968
+ // Finds an actor by label, computes ideal camera distance from bounding box,
969
+ // and frames it in the viewport with optional screenshot.
970
+ //
971
+ // Payload fields:
972
+ // actor_label string -- label of actor to frame (required)
973
+ // padding float -- multiplier on bounds for framing (default 1.5)
974
+ // angle string | {yaw,pitch} -- viewing angle preset or custom (default "front")
975
+ // screenshot bool -- take screenshot after framing (default true)
976
+ // width int -- screenshot width (default 1280, clamped 64..7680)
977
+ // height int -- screenshot height (default 720, clamped 64..4320)
978
+ //
979
+ // Threat T-31-01: actor label resolved via TActorIterator -- no injection possible.
980
+ // Threat T-31-05: screenshot dimensions clamped 64..7680 / 64..4320.
981
+ // -----------------------------------------------------------------------
982
+ Router.RegisterHandler(TEXT("viewport.focusActor"), [](TSharedPtr<FJsonObject> Cmd, FMCPResponseSender SendResponse)
983
+ {
984
+ const FString CorrId = Cmd->GetStringField(TEXT("correlationId"));
985
+
986
+ TSharedPtr<FJsonObject> Payload;
987
+ const TSharedPtr<FJsonValue>* PayloadVal = Cmd->Values.Find(TEXT("payload"));
988
+ if (PayloadVal && (*PayloadVal)->Type == EJson::Object)
989
+ {
990
+ Payload = (*PayloadVal)->AsObject();
991
+ }
992
+
993
+ FString ActorLabel;
994
+ if (!Payload.IsValid() || !Payload->TryGetStringField(TEXT("actor_label"), ActorLabel) || ActorLabel.IsEmpty())
995
+ {
996
+ SendResponse(BuildViewportErrorResponse(CorrId, TEXT("missing_actor_label")) + TEXT("\n"));
997
+ return;
998
+ }
999
+
1000
+ FLevelEditorViewportClient* ViewportClient = GetActiveViewportClient();
1001
+ if (!ViewportClient)
1002
+ {
1003
+ SendResponse(BuildViewportErrorResponse(CorrId, TEXT("no_active_viewport")) + TEXT("\n"));
1004
+ return;
1005
+ }
1006
+
1007
+ UWorld* World = GEditor ? GEditor->GetEditorWorldContext().World() : nullptr;
1008
+ if (!World)
1009
+ {
1010
+ SendResponse(BuildViewportErrorResponse(CorrId, TEXT("no_editor_world")) + TEXT("\n"));
1011
+ return;
1012
+ }
1013
+
1014
+ TArray<FString> SimilarLabels;
1015
+ AActor* FoundActor = FindActorByLabelSubstring(World, ActorLabel, &SimilarLabels);
1016
+ if (!FoundActor)
1017
+ {
1018
+ TSharedPtr<FJsonObject> ErrData = MakeShared<FJsonObject>();
1019
+ ErrData->SetStringField(TEXT("error"), TEXT("actor_not_found"));
1020
+ ErrData->SetStringField(TEXT("requested_label"), ActorLabel);
1021
+ TArray<TSharedPtr<FJsonValue>> SimilarArray;
1022
+ for (const FString& S : SimilarLabels)
1023
+ {
1024
+ SimilarArray.Add(MakeShared<FJsonValueString>(S));
1025
+ }
1026
+ ErrData->SetArrayField(TEXT("similar_labels"), SimilarArray);
1027
+
1028
+ TSharedPtr<FJsonObject> ErrObj = MakeShared<FJsonObject>();
1029
+ ErrObj->SetBoolField(TEXT("success"), false);
1030
+ ErrObj->SetStringField(TEXT("correlationId"), CorrId);
1031
+ ErrObj->SetStringField(TEXT("error"), TEXT("actor_not_found"));
1032
+ ErrObj->SetObjectField(TEXT("data"), ErrData);
1033
+ FString ErrOutput;
1034
+ TSharedRef<TJsonWriter<>> W = TJsonWriterFactory<>::Create(&ErrOutput);
1035
+ FJsonSerializer::Serialize(ErrObj.ToSharedRef(), W);
1036
+ SendResponse(ErrOutput + TEXT("\n"));
1037
+ return;
1038
+ }
1039
+
1040
+ FVector BoundsOrigin(ForceInitToZero);
1041
+ FVector BoundsExtent(ForceInitToZero);
1042
+ FoundActor->GetActorBounds(false, BoundsOrigin, BoundsExtent);
1043
+
1044
+ // Read padding with default 1.5.
1045
+ double RawPadding = 1.5;
1046
+ if (Payload.IsValid())
1047
+ {
1048
+ Payload->TryGetNumberField(TEXT("padding"), RawPadding);
1049
+ }
1050
+ const float Padding = FMath::Max(static_cast<float>(RawPadding), 0.1f); // minimum 0.1 to avoid zero distance
1051
+
1052
+ // Compute framing distance: bounds extent size * padding.
1053
+ const float FramingDistance = FMath::Max(BoundsExtent.Size() * Padding, 50.0f);
1054
+
1055
+ // Resolve angle preset or custom {yaw, pitch}.
1056
+ const TSharedPtr<FJsonValue>* AngleVal = Payload ? Payload->Values.Find(TEXT("angle")) : nullptr;
1057
+ const FRotator AngleRot = AngleVal ? ResolveAngle(*AngleVal) : FRotator(0.0f, 0.0f, 0.0f);
1058
+
1059
+ // Camera placement: from bounds origin, offset by angle direction * (-distance).
1060
+ const FVector AngleDir = AngleRot.Vector();
1061
+ const FVector CameraPos = BoundsOrigin + AngleDir * (-FramingDistance);
1062
+
1063
+ // Camera rotation: look toward bounds origin.
1064
+ const FVector LookDir = (BoundsOrigin - CameraPos).GetSafeNormal();
1065
+
1066
+ ViewportClient->SetViewLocation(CameraPos);
1067
+ if (!LookDir.IsNearlyZero())
1068
+ {
1069
+ ViewportClient->SetViewRotation(LookDir.Rotation());
1070
+ }
1071
+ ViewportClient->Invalidate();
1072
+
1073
+ // Screenshot parameters (T-31-05: clamped).
1074
+ bool bTakeScreenshot = true;
1075
+ if (Payload.IsValid())
1076
+ {
1077
+ Payload->TryGetBoolField(TEXT("screenshot"), bTakeScreenshot);
1078
+ }
1079
+
1080
+ double RawWidth = 1280.0, RawHeight = 720.0;
1081
+ if (Payload.IsValid())
1082
+ {
1083
+ Payload->TryGetNumberField(TEXT("width"), RawWidth);
1084
+ Payload->TryGetNumberField(TEXT("height"), RawHeight);
1085
+ }
1086
+ const int32 ShotWidth = FMath::Clamp(static_cast<int32>(RawWidth), 64, 7680);
1087
+ const int32 ShotHeight = FMath::Clamp(static_cast<int32>(RawHeight), 64, 4320);
1088
+
1089
+ FString FilePath;
1090
+ if (bTakeScreenshot)
1091
+ {
1092
+ FilePath = TakeScreenshotToFile(ShotWidth, ShotHeight);
1093
+ }
1094
+
1095
+ // Build response data.
1096
+ const FRotator CamRot = ViewportClient->GetViewRotation();
1097
+ const FVector ActorPos = FoundActor->GetActorLocation();
1098
+ const FString ActorClassName = FoundActor->GetClass() ? FoundActor->GetClass()->GetName() : TEXT("Unknown");
1099
+
1100
+ auto MakeVec = [](const FVector& V) -> TSharedPtr<FJsonObject>
1101
+ {
1102
+ TSharedPtr<FJsonObject> Obj = MakeShared<FJsonObject>();
1103
+ Obj->SetNumberField(TEXT("x"), static_cast<double>(V.X));
1104
+ Obj->SetNumberField(TEXT("y"), static_cast<double>(V.Y));
1105
+ Obj->SetNumberField(TEXT("z"), static_cast<double>(V.Z));
1106
+ return Obj;
1107
+ };
1108
+
1109
+ TSharedPtr<FJsonObject> CamRotObj = MakeShared<FJsonObject>();
1110
+ CamRotObj->SetNumberField(TEXT("pitch"), static_cast<double>(CamRot.Pitch));
1111
+ CamRotObj->SetNumberField(TEXT("yaw"), static_cast<double>(CamRot.Yaw));
1112
+ CamRotObj->SetNumberField(TEXT("roll"), static_cast<double>(CamRot.Roll));
1113
+
1114
+ TSharedPtr<FJsonObject> BoundsObj = MakeShared<FJsonObject>();
1115
+ BoundsObj->SetObjectField(TEXT("origin"), MakeVec(BoundsOrigin));
1116
+ BoundsObj->SetObjectField(TEXT("extent"), MakeVec(BoundsExtent));
1117
+
1118
+ TSharedPtr<FJsonObject> Data = MakeShared<FJsonObject>();
1119
+ Data->SetStringField(TEXT("actor_label"), FoundActor->GetActorLabel());
1120
+ Data->SetStringField(TEXT("actor_class"), ActorClassName);
1121
+ Data->SetObjectField(TEXT("actor_position"), MakeVec(ActorPos));
1122
+ Data->SetObjectField(TEXT("actor_bounds"), BoundsObj);
1123
+ Data->SetObjectField(TEXT("camera_position"), MakeVec(CameraPos));
1124
+ Data->SetObjectField(TEXT("camera_rotation"), CamRotObj);
1125
+ Data->SetNumberField(TEXT("framing_distance"), static_cast<double>(FramingDistance));
1126
+
1127
+ if (!FilePath.IsEmpty())
1128
+ {
1129
+ Data->SetStringField(TEXT("file_path"), FilePath);
1130
+ }
1131
+
1132
+ SendResponse(BuildViewportSuccessResponse(CorrId, Data) + TEXT("\n"));
1133
+ });
1134
+
1135
+ // -----------------------------------------------------------------------
1136
+ // viewport.cleanupScreenshots (VIS-08)
1137
+ // Deletes MCP-generated screenshots (mcp_screenshot_*.png) from the dedicated
1138
+ // MCPScreenshots/ directory. Supports keep_last to preserve the N most recent.
1139
+ //
1140
+ // Payload fields (all optional):
1141
+ // keep_last int -- number of most recent screenshots to keep (default 0, clamped 0..1000)
1142
+ //
1143
+ // Threat T-31-03: only deletes files matching mcp_screenshot_*.png in MCPScreenshots/;
1144
+ // never accepts user-provided paths; keep_last clamped 0..1000.
1145
+ // -----------------------------------------------------------------------
1146
+ Router.RegisterHandler(TEXT("viewport.cleanupScreenshots"), [](TSharedPtr<FJsonObject> Cmd, FMCPResponseSender SendResponse)
1147
+ {
1148
+ const FString CorrId = Cmd->GetStringField(TEXT("correlationId"));
1149
+
1150
+ TSharedPtr<FJsonObject> Payload;
1151
+ const TSharedPtr<FJsonValue>* PayloadVal = Cmd->Values.Find(TEXT("payload"));
1152
+ if (PayloadVal && (*PayloadVal)->Type == EJson::Object)
1153
+ {
1154
+ Payload = (*PayloadVal)->AsObject();
1155
+ }
1156
+
1157
+ // Read keep_last with default 0 and clamp to safe range (T-31-03).
1158
+ double RawKeepLast = 0.0;
1159
+ if (Payload.IsValid())
1160
+ {
1161
+ Payload->TryGetNumberField(TEXT("keep_last"), RawKeepLast);
1162
+ }
1163
+ const int32 KeepLast = FMath::Clamp(static_cast<int32>(RawKeepLast), 0, 1000);
1164
+
1165
+ const FString ScreenshotDir = GetMCPScreenshotDir();
1166
+ const FString GlobPattern = FPaths::Combine(ScreenshotDir, TEXT("mcp_screenshot_*.png"));
1167
+
1168
+ // Find all MCP screenshots.
1169
+ TArray<FString> FoundFiles;
1170
+ IFileManager::Get().FindFiles(FoundFiles, *GlobPattern, true /* bFiles */, false /* bDirs */);
1171
+
1172
+ // Sort by name ascending (timestamp-in-name ensures chronological order).
1173
+ FoundFiles.Sort();
1174
+
1175
+ // Build list of files to delete (all except the last KeepLast entries).
1176
+ int32 DeleteCount = FMath::Max(FoundFiles.Num() - KeepLast, 0);
1177
+ TArray<FString> ToDelete = TArray<FString>(FoundFiles.GetData(), DeleteCount);
1178
+ const int32 KeptCount = FoundFiles.Num() - DeleteCount;
1179
+
1180
+ int32 DeletedCount = 0;
1181
+ int64 BytesFreed = 0;
1182
+
1183
+ for (const FString& FileName : ToDelete)
1184
+ {
1185
+ const FString FullPath = FPaths::Combine(ScreenshotDir, FileName);
1186
+
1187
+ // Get file size before deleting.
1188
+ const int64 FileSize = IFileManager::Get().FileSize(*FullPath);
1189
+ if (FileSize > 0)
1190
+ {
1191
+ BytesFreed += FileSize;
1192
+ }
1193
+
1194
+ if (IFileManager::Get().Delete(*FullPath, false /* bRequireExists */, false /* bEvenReadOnly */))
1195
+ {
1196
+ ++DeletedCount;
1197
+ }
1198
+ }
1199
+
1200
+ TSharedPtr<FJsonObject> Data = MakeShared<FJsonObject>();
1201
+ Data->SetNumberField(TEXT("deleted_count"), static_cast<double>(DeletedCount));
1202
+ Data->SetNumberField(TEXT("bytes_freed"), static_cast<double>(BytesFreed));
1203
+ Data->SetNumberField(TEXT("kept_count"), static_cast<double>(KeptCount));
1204
+ Data->SetStringField(TEXT("screenshot_dir"), ScreenshotDir);
1205
+
1206
+ SendResponse(BuildViewportSuccessResponse(CorrId, Data) + TEXT("\n"));
1207
+ });
1208
+ }