ue-mcp 1.2.3 → 1.3.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 (255) hide show
  1. package/README.md +4 -3
  2. package/dist/action-class.js +56 -3
  3. package/dist/action-class.js.map +1 -1
  4. package/dist/bridge-timeouts.d.ts +64 -0
  5. package/dist/bridge-timeouts.js +118 -0
  6. package/dist/bridge-timeouts.js.map +1 -0
  7. package/dist/bridge.js +10 -2
  8. package/dist/bridge.js.map +1 -1
  9. package/dist/editor-control.d.ts +72 -5
  10. package/dist/editor-control.js +164 -140
  11. package/dist/editor-control.js.map +1 -1
  12. package/dist/editor-target.d.ts +55 -1
  13. package/dist/editor-target.js +107 -10
  14. package/dist/editor-target.js.map +1 -1
  15. package/dist/engine-observer.d.ts +59 -5
  16. package/dist/engine-observer.js +249 -29
  17. package/dist/engine-observer.js.map +1 -1
  18. package/dist/engine-root.d.ts +91 -0
  19. package/dist/engine-root.js +448 -0
  20. package/dist/engine-root.js.map +1 -0
  21. package/dist/epic-enrich.js +16 -1
  22. package/dist/epic-enrich.js.map +1 -1
  23. package/dist/feedback-cli.js +4 -0
  24. package/dist/feedback-cli.js.map +1 -1
  25. package/dist/feedback-deferred.d.ts +9 -0
  26. package/dist/feedback-deferred.js +2 -0
  27. package/dist/feedback-deferred.js.map +1 -1
  28. package/dist/feedback-fallback.d.ts +99 -0
  29. package/dist/feedback-fallback.js +173 -0
  30. package/dist/feedback-fallback.js.map +1 -0
  31. package/dist/flow/write-methods.js +20 -0
  32. package/dist/flow/write-methods.js.map +1 -1
  33. package/dist/index.js +41 -2
  34. package/dist/index.js.map +1 -1
  35. package/dist/lean-context.js +8 -3
  36. package/dist/lean-context.js.map +1 -1
  37. package/dist/locking.js +11 -2
  38. package/dist/locking.js.map +1 -1
  39. package/dist/plugin/loader.d.ts +4 -0
  40. package/dist/plugin/loader.js +22 -8
  41. package/dist/plugin/loader.js.map +1 -1
  42. package/dist/plugin/manifest.d.ts +72 -52
  43. package/dist/plugin/manifest.js +118 -21
  44. package/dist/plugin/manifest.js.map +1 -1
  45. package/dist/plugin-cli.js +16 -3
  46. package/dist/plugin-cli.js.map +1 -1
  47. package/dist/project.d.ts +11 -0
  48. package/dist/project.js +23 -2
  49. package/dist/project.js.map +1 -1
  50. package/dist/python-gate.d.ts +95 -0
  51. package/dist/python-gate.js +175 -0
  52. package/dist/python-gate.js.map +1 -0
  53. package/dist/tool-counts.json +16 -16
  54. package/dist/tools/animation.js +288 -24
  55. package/dist/tools/animation.js.map +1 -1
  56. package/dist/tools/asset.js +79 -14
  57. package/dist/tools/asset.js.map +1 -1
  58. package/dist/tools/audio.js +59 -5
  59. package/dist/tools/audio.js.map +1 -1
  60. package/dist/tools/blueprint.js +29 -12
  61. package/dist/tools/blueprint.js.map +1 -1
  62. package/dist/tools/chooser.js +4 -1
  63. package/dist/tools/chooser.js.map +1 -1
  64. package/dist/tools/editor.js +81 -43
  65. package/dist/tools/editor.js.map +1 -1
  66. package/dist/tools/feedback.js +214 -48
  67. package/dist/tools/feedback.js.map +1 -1
  68. package/dist/tools/foliage.js +27 -1
  69. package/dist/tools/foliage.js.map +1 -1
  70. package/dist/tools/gameplay.js +38 -11
  71. package/dist/tools/gameplay.js.map +1 -1
  72. package/dist/tools/gas.js +17 -5
  73. package/dist/tools/gas.js.map +1 -1
  74. package/dist/tools/landscape.js +22 -7
  75. package/dist/tools/landscape.js.map +1 -1
  76. package/dist/tools/level.js +322 -51
  77. package/dist/tools/level.js.map +1 -1
  78. package/dist/tools/material.js +10 -4
  79. package/dist/tools/material.js.map +1 -1
  80. package/dist/tools/niagara.js +3 -2
  81. package/dist/tools/niagara.js.map +1 -1
  82. package/dist/tools/pcg.js +6 -5
  83. package/dist/tools/pcg.js.map +1 -1
  84. package/dist/tools/plugins.js +2 -0
  85. package/dist/tools/plugins.js.map +1 -1
  86. package/dist/tools/project.js +47 -20
  87. package/dist/tools/project.js.map +1 -1
  88. package/dist/types.d.ts +24 -0
  89. package/dist/types.js +46 -3
  90. package/dist/types.js.map +1 -1
  91. package/dist/ue-mcp.default.yml +493 -125
  92. package/package.json +2 -2
  93. package/plugin/ue_mcp_bridge/NATIVE_RUNTIME_VISIBILITY.md +53 -0
  94. package/plugin/ue_mcp_bridge/NATIVE_STATIC_MESH_USAGE_PROGRESS.md +115 -0
  95. package/plugin/ue_mcp_bridge/README_MassHandlers.md +97 -0
  96. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/BridgeServer.cpp +14 -0
  97. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/GameThreadExecutor.cpp +21 -4
  98. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/GameThreadExecutor.h +9 -0
  99. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/HandlerJsonProperty.h +395 -69
  100. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/AnimationHandlers.cpp +79 -3
  101. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/AnimationHandlers.h +25 -0
  102. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/AnimationHandlers_ControlRig.cpp +3 -3
  103. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/AnimationHandlers_ControlRigSequencer.cpp +3061 -0
  104. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/AnimationHandlers_IKRetargeterAuthoring.cpp +961 -0
  105. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/AnimationHandlers_IKRigAuthoring.cpp +900 -0
  106. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/AnimationHandlers_Pose.cpp +769 -0
  107. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/AnimationHandlers_Sequence.cpp +44 -3
  108. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/AnimationHandlers_SkeletalLive.cpp +217 -11
  109. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/AnimationHandlers_StateMachine.cpp +551 -24
  110. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/AnimationHandlers_Validation.cpp +735 -0
  111. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/AssetHandlers.cpp +352 -73
  112. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/AssetHandlers.h +8 -0
  113. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/AssetHandlers_BulkProperties.cpp +1 -1
  114. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/AssetHandlers_BulkRead.cpp +503 -0
  115. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/AssetHandlers_BulkRead.h +21 -0
  116. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/AssetHandlers_BulkUpsert.cpp +2 -2
  117. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/AssetHandlers_Enum.cpp +2 -2
  118. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/AssetHandlers_Geometry.cpp +902 -0
  119. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/AssetHandlers_Geometry.h +33 -0
  120. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/AssetHandlers_Import.cpp +478 -134
  121. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/AssetHandlers_Mesh.cpp +5 -5
  122. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/AssetHandlers_MeshBoolean.cpp +933 -0
  123. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/AssetHandlers_MeshBoolean.h +35 -0
  124. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/AssetHandlers_Redirectors.cpp +478 -0
  125. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/AssetHandlers_RenderTarget.cpp +1 -1
  126. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/AssetHandlers_Sockets.cpp +4 -4
  127. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/AssetHandlers_Struct.cpp +2 -2
  128. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/AssetHandlers_Subobject.cpp +319 -0
  129. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/AudioHandlers_SoundCue.cpp +3 -3
  130. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/BlueprintHandlers.cpp +482 -35
  131. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/BlueprintHandlers.h +10 -0
  132. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/BlueprintHandlers_Collision.cpp +502 -0
  133. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/BlueprintHandlers_Collision.h +43 -0
  134. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/BlueprintHandlers_Functions.cpp +89 -7
  135. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/BlueprintHandlers_Graph.cpp +17 -51
  136. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/BlueprintHandlers_Internal.h +107 -0
  137. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/BlueprintHandlers_Properties.cpp +10 -10
  138. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/BlueprintHandlers_Search.cpp +648 -0
  139. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/DemoHandlers.cpp +5 -1
  140. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/DiffHandlers.cpp +252 -1
  141. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/DiffHandlers.h +10 -2
  142. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/EditorHandlers.cpp +252 -41
  143. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/EditorHandlers.h +4 -0
  144. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/EditorHandlers_Build.cpp +250 -27
  145. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/EditorHandlers_PIE.cpp +303 -125
  146. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/EditorHandlers_PIERuntime.cpp +73 -50
  147. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/EditorHandlers_RuntimeVisibility.cpp +757 -0
  148. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/FoliageHandlers.cpp +11 -2
  149. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/FoliageHandlers.h +3 -0
  150. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/FoliageHandlers_Batch.cpp +419 -0
  151. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/GameplayHandlers.cpp +39 -161
  152. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/GameplayHandlers.h +66 -1
  153. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/GameplayHandlers_BTAuthoring.cpp +1323 -0
  154. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/GameplayHandlers_BehaviorTree.cpp +1211 -0
  155. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/GasHandlers.cpp +2 -0
  156. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/GasHandlers.h +60 -0
  157. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/GasHandlers_Runtime.cpp +363 -12
  158. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/HandlerEditorState.h +84 -0
  159. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/LandscapeHandlers.cpp +1288 -36
  160. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/LandscapeHandlers.h +3 -0
  161. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/LandscapeHandlers_Sculpt.cpp +41 -16
  162. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/LevelHandlers.cpp +959 -213
  163. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/LevelHandlers.h +66 -0
  164. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/LevelHandlers_BatchWrite.cpp +1571 -0
  165. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/LevelHandlers_Convert.cpp +316 -0
  166. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/LevelHandlers_Inspection.cpp +280 -0
  167. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/LevelHandlers_InstanceProjection.cpp +666 -0
  168. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/LevelHandlers_InstanceProjection_Internal.h +63 -0
  169. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/LevelHandlers_Lights.cpp +43 -12
  170. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/LevelHandlers_MultiLevel.cpp +13 -47
  171. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/LevelHandlers_PostProcess.cpp +527 -0
  172. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/LevelHandlers_Query.cpp +1169 -0
  173. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/LevelHandlers_Refresh.cpp +723 -0
  174. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/LevelHandlers_Save.cpp +325 -0
  175. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/LevelHandlers_Spatial.cpp +360 -0
  176. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/LevelHandlers_Trace.cpp +178 -58
  177. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/LevelHandlers_Transient.cpp +337 -0
  178. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/LevelHandlers_Volumes.cpp +25 -12
  179. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/LevelHandlers_WorldPartition.cpp +10 -9
  180. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/LevelHandlers_WorldPartitionSettings.cpp +686 -0
  181. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/MassHandlers.cpp +467 -0
  182. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/MassHandlers.h +22 -0
  183. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/MaterialHandlers.cpp +452 -175
  184. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/MaterialHandlers.h +5 -0
  185. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/MaterialHandlers_Build.cpp +597 -0
  186. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/NiagaraHandlers.cpp +17 -15
  187. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/PCGHandlers.cpp +38 -168
  188. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/PhysicsHandlers.cpp +39 -30
  189. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/SequencerHandlers.cpp +392 -78
  190. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/SequencerHandlers.h +2 -0
  191. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/SequencerHandlers_Internal.h +83 -0
  192. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/SkeletalMeshHandlers.cpp +200 -0
  193. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/SkeletalMeshHandlers.h +15 -0
  194. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/SplineHandlers.cpp +19 -12
  195. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/StateTreeHandlers.cpp +31 -1
  196. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/WidgetHandlers.cpp +307 -69
  197. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/WidgetHandlers.h +84 -0
  198. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/WidgetHandlers_Extraction.cpp +8 -41
  199. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/WidgetHandlers_Properties.cpp +33 -52
  200. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Tests/ActorSelectorTests.cpp +344 -0
  201. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Tests/AnimationControlRigTimelineTests.cpp +60 -0
  202. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Tests/AnimationPoseTests.cpp +115 -0
  203. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Tests/AssetBulkReadTests.cpp +177 -0
  204. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Tests/AssetDeleteGuardTests.cpp +276 -0
  205. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Tests/AssetPathResolutionTests.cpp +243 -0
  206. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Tests/AssetPropertyPersistTests.cpp +213 -0
  207. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Tests/AssetSubobjectTests.cpp +235 -0
  208. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Tests/AutomationRunnerTests.cpp +72 -0
  209. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Tests/BehaviorTreeAuthoringTests.cpp +526 -0
  210. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Tests/BehaviorTreeNodeTests.cpp +334 -0
  211. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Tests/BlueprintHandlerSurfaceTests.cpp +362 -0
  212. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Tests/BridgeProtocolTests.cpp +14 -0
  213. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Tests/CollisionQueryTests.cpp +288 -0
  214. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Tests/DataTableRowWriteTests.cpp +453 -0
  215. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Tests/EditorFunctionCallTests.cpp +223 -0
  216. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Tests/FoliageBatchTests.cpp +147 -0
  217. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Tests/GameThreadGateTests.cpp +82 -0
  218. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Tests/GasLiveAttributeTests.cpp +183 -0
  219. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Tests/LandscapeSampleTests.cpp +119 -0
  220. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Tests/LevelBatchWriteTests.cpp +176 -0
  221. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Tests/LevelComponentTreeInstanceCountTests.cpp +189 -0
  222. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Tests/LevelConvertBrushesTests.cpp +100 -0
  223. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Tests/LevelInstanceProjectionTests.cpp +265 -0
  224. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Tests/LevelPostProcessOverrideTests.cpp +258 -0
  225. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Tests/LevelQueryComponentsTests.cpp +195 -0
  226. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Tests/LevelRefreshTests.cpp +142 -0
  227. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Tests/LevelSaveReportTests.cpp +125 -0
  228. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Tests/LevelScriptHandlersTests.cpp +42 -0
  229. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Tests/LevelStaticMeshUsageTests.cpp +412 -0
  230. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Tests/LevelTraceWorldScopeTests.cpp +133 -0
  231. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Tests/LevelTransientActorTests.cpp +205 -0
  232. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Tests/LevelWorldPartitionSettingsTests.cpp +168 -0
  233. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Tests/MCPInstancedStructPathTestTypes.h +51 -0
  234. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Tests/MCPInstancedStructPathTests.cpp +379 -0
  235. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Tests/MaterialBuildTests.cpp +238 -0
  236. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Tests/MeshBooleanTests.cpp +170 -0
  237. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Tests/MeshGeometryTests.cpp +116 -0
  238. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Tests/PackageSaveExtensionTests.cpp +161 -0
  239. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Tests/PackageWriteGuardTests.cpp +253 -0
  240. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Tests/RuntimeVisibilityTests.cpp +143 -0
  241. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Tests/SequencerHandlerTests.cpp +660 -0
  242. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Tests/SequencerScrubTests.cpp +50 -0
  243. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Tests/SkeletonDiffTests.cpp +685 -0
  244. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Tests/WidgetBlueprintResolveTests.cpp +145 -0
  245. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/UE_MCP_Bridge.cpp +80 -0
  246. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Public/HandlerFunctionCall.h +509 -0
  247. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Public/HandlerQuery.h +407 -0
  248. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Public/HandlerUtils.h +1008 -77
  249. package/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/UE_MCP_Bridge.Build.cs +87 -0
  250. package/plugin/ue_mcp_bridge/Source/UE_MCP_BridgeStatus/Private/MCPEngineStatus.cpp +152 -14
  251. package/plugin/ue_mcp_bridge/Source/UE_MCP_BridgeStatus/Private/Tests/MCPEngineStatusTests.cpp +65 -0
  252. package/plugin/ue_mcp_bridge/Source/UE_MCP_BridgeStatus/Public/MCPEngineStatus.h +32 -1
  253. package/plugin/ue_mcp_bridge/Source/UE_MCP_BridgeStatus/UE_MCP_BridgeStatus.Build.cs +2 -0
  254. package/skills/ue-mcp-animation/SKILL.md +154 -0
  255. package/skills/ue-mcp-blueprint/SKILL.md +71 -0
@@ -0,0 +1,1571 @@
1
+ // Level-wide writes that used to be Python loops (#984, #941, #907, #987).
2
+ //
3
+ // The read side of this problem is level(query_components): a level-wide
4
+ // question had no editor-side answer. These are the same complaint on the
5
+ // write side. Every one of them was a loop over an editor-side selector that
6
+ // the client had to reproduce for itself:
7
+ //
8
+ // #984 190 SpotLights tagged LichtTest, moved, retinted and shadow-toggled
9
+ // one call at a time. batch_translate exists but wants an explicit
10
+ // label list, and auto-numbered labels make that list a generated
11
+ // payload rather than a selector.
12
+ // #941 NavModifierComponent.AreaClass across dozens of placed
13
+ // StaticMeshActors. set_component_property is one actor per call.
14
+ // #907 230 orphaned SplineMeshComponents across 23 actors. Their names are
15
+ // auto-generated (SplineMeshComponent_0, _1, ...) and remove_component
16
+ // takes one name at a time, so there was no way to name them all.
17
+ // #987 A SpotLight at every lamp post, positioned from each lamp
18
+ // component's own bounds. place_actors_batch is StaticMesh only and
19
+ // spawn_light takes a single position.
20
+ //
21
+ // The property writes deliberately delegate to FLevelHandlers::SetActorProperty
22
+ // and ::SetComponentProperty rather than reimplementing them. Those two carry
23
+ // years of accumulated behaviour (actor-label object refs, TArray-of-actor
24
+ // refs, instanced sub-object descent, the EditDefaultsOnly override), and a
25
+ // batch that quietly meant something narrower than the single-item action
26
+ // would be worse than no batch at all.
27
+
28
+ #include "LevelHandlers.h"
29
+
30
+ #include "Components/ActorComponent.h"
31
+ #include "Components/MeshComponent.h"
32
+ #include "Components/SceneComponent.h"
33
+ #include "Components/SplineComponent.h"
34
+ #include "Dom/JsonObject.h"
35
+ #include "Dom/JsonValue.h"
36
+ #include "Editor.h"
37
+ #include "Engine/World.h"
38
+ #include "EngineUtils.h"
39
+ #include "FileHelpers.h"
40
+ #include "GameFramework/Actor.h"
41
+ #include "HandlerQuery.h"
42
+ #include "HandlerRegistry.h"
43
+ #include "HandlerUtils.h"
44
+ #include "Materials/MaterialInterface.h"
45
+ #include "ScopedTransaction.h"
46
+ // #985: bulk HLOD layer assignment. UHLODLayer has to be a complete type for
47
+ // AActor::SetHLODLayer and for the asset load.
48
+ #include "WorldPartition/HLOD/HLODLayer.h"
49
+
50
+ namespace
51
+ {
52
+ // Bounds. Each of these actions replaced an unbounded loop, and an
53
+ // unbounded batch is the same failure with fewer round trips.
54
+ constexpr int32 MCPBatchMaxActors = 5000;
55
+ constexpr int32 MCPBatchMaxProperties = 32;
56
+ constexpr int32 MCPBatchMaxResultRows = 1000;
57
+ constexpr int32 MCPBatchDefaultMaxSpawn = 500;
58
+ constexpr int32 MCPBatchHardMaxSpawn = 5000;
59
+ constexpr double MCPBatchMinSplineSpacing = 1.0;
60
+
61
+ /**
62
+ * The selector every action in this file shares.
63
+ *
64
+ * Naming rule, and it is a rule rather than a style: a parameter that says
65
+ * `label` matches the EDITOR LABEL, a parameter that says `name` matches
66
+ * the internal object name, `Prefix` is a prefix and `Contains` is a
67
+ * substring. Two level actions that took similar-sounding filters and
68
+ * matched different things is exactly how #963 happened.
69
+ */
70
+ struct FMCPBatchSelector
71
+ {
72
+ TArray<FString> ActorLabels;
73
+ FString LabelPrefix;
74
+ FString LabelContains;
75
+ FString Tag;
76
+ UClass* ActorClass = nullptr;
77
+ FString ActorClassFallback;
78
+ bool bMatchSubclasses = true;
79
+ FString FolderPath;
80
+ FString FolderPathPrefix;
81
+ bool bAny = false;
82
+ };
83
+
84
+ TSharedPtr<FJsonValue> MCPBatchReadSelector(
85
+ const TSharedPtr<FJsonObject>& Params,
86
+ FMCPBatchSelector& OutSelector)
87
+ {
88
+ const TArray<TSharedPtr<FJsonValue>>* LabelValues = nullptr;
89
+ if (Params->TryGetArrayField(TEXT("actorLabels"), LabelValues) && LabelValues)
90
+ {
91
+ OutSelector.ActorLabels = JsonArrayToStringList(LabelValues);
92
+ }
93
+ OutSelector.LabelPrefix = OptionalString(Params, TEXT("labelPrefix"));
94
+ OutSelector.LabelContains = OptionalString(Params, TEXT("labelContains"));
95
+ OutSelector.Tag = OptionalString(Params, TEXT("tag"));
96
+ OutSelector.FolderPath = OptionalString(Params, TEXT("folderPath"));
97
+ OutSelector.FolderPathPrefix = OptionalString(Params, TEXT("folderPathPrefix"));
98
+ OutSelector.bMatchSubclasses = OptionalBool(Params, TEXT("matchSubclasses"), true);
99
+
100
+ const FString ClassSpec = OptionalString(Params, TEXT("classFilter"));
101
+ if (!ClassSpec.IsEmpty())
102
+ {
103
+ OutSelector.ActorClass = MCPResolveClassOfType(ClassSpec, AActor::StaticClass(), true);
104
+ if (!OutSelector.ActorClass)
105
+ {
106
+ // Substring on the class name is what the older level actions
107
+ // accept, so a call that used to work keeps working.
108
+ OutSelector.ActorClassFallback = ClassSpec;
109
+ }
110
+ }
111
+
112
+ OutSelector.bAny =
113
+ !OutSelector.ActorLabels.IsEmpty() ||
114
+ !OutSelector.LabelPrefix.IsEmpty() ||
115
+ !OutSelector.LabelContains.IsEmpty() ||
116
+ !OutSelector.Tag.IsEmpty() ||
117
+ OutSelector.ActorClass != nullptr ||
118
+ !OutSelector.ActorClassFallback.IsEmpty() ||
119
+ !OutSelector.FolderPath.IsEmpty() ||
120
+ !OutSelector.FolderPathPrefix.IsEmpty();
121
+
122
+ return nullptr;
123
+ }
124
+
125
+ bool MCPBatchActorMatches(const FMCPBatchSelector& Selector, AActor* Actor, const FString& Label)
126
+ {
127
+ if (!Selector.ActorLabels.IsEmpty() && !Selector.ActorLabels.Contains(Label)) return false;
128
+ if (!Selector.LabelPrefix.IsEmpty() && !Label.StartsWith(Selector.LabelPrefix, ESearchCase::CaseSensitive)) return false;
129
+ if (!Selector.LabelContains.IsEmpty() && !Label.Contains(Selector.LabelContains, ESearchCase::IgnoreCase)) return false;
130
+ if (!Selector.Tag.IsEmpty() && !Actor->ActorHasTag(FName(*Selector.Tag))) return false;
131
+ if (Selector.ActorClass)
132
+ {
133
+ const bool bMatches = Selector.bMatchSubclasses
134
+ ? Actor->IsA(Selector.ActorClass)
135
+ : (Actor->GetClass() == Selector.ActorClass);
136
+ if (!bMatches) return false;
137
+ }
138
+ else if (!Selector.ActorClassFallback.IsEmpty() &&
139
+ !Actor->GetClass()->GetName().Contains(Selector.ActorClassFallback, ESearchCase::IgnoreCase))
140
+ {
141
+ return false;
142
+ }
143
+ if (!Selector.FolderPath.IsEmpty() &&
144
+ !Actor->GetFolderPath().ToString().Equals(Selector.FolderPath, ESearchCase::IgnoreCase))
145
+ {
146
+ return false;
147
+ }
148
+ if (!Selector.FolderPathPrefix.IsEmpty() &&
149
+ !Actor->GetFolderPath().ToString().StartsWith(Selector.FolderPathPrefix, ESearchCase::IgnoreCase))
150
+ {
151
+ return false;
152
+ }
153
+ return true;
154
+ }
155
+
156
+ /** Every actor the selector picks, in a deterministic order. */
157
+ void MCPBatchCollectActors(UWorld* World, const FMCPBatchSelector& Selector, TArray<AActor*>& OutActors)
158
+ {
159
+ OutActors.Reset();
160
+ for (TActorIterator<AActor> It(World); It; ++It)
161
+ {
162
+ AActor* Actor = *It;
163
+ if (!Actor) continue;
164
+ if (MCPBatchActorMatches(Selector, Actor, Actor->GetActorLabel()))
165
+ {
166
+ OutActors.Add(Actor);
167
+ }
168
+ }
169
+ OutActors.Sort([](const AActor& A, const AActor& B)
170
+ {
171
+ return A.GetPathName() < B.GetPathName();
172
+ });
173
+ }
174
+
175
+ /**
176
+ * Labels the selector asked for by exact name and did not find.
177
+ *
178
+ * A destructive or sweeping call that matched nothing must say so with the
179
+ * reason, not return success and a zero. That is #963's actual harm.
180
+ */
181
+ TArray<FString> MCPBatchMissingLabels(const FMCPBatchSelector& Selector, const TArray<AActor*>& Matched)
182
+ {
183
+ TArray<FString> Missing;
184
+ if (Selector.ActorLabels.IsEmpty())
185
+ {
186
+ return Missing;
187
+ }
188
+ TSet<FString> Found;
189
+ for (AActor* Actor : Matched)
190
+ {
191
+ Found.Add(Actor->GetActorLabel());
192
+ }
193
+ for (const FString& Label : Selector.ActorLabels)
194
+ {
195
+ if (!Found.Contains(Label))
196
+ {
197
+ Missing.Add(Label);
198
+ }
199
+ }
200
+ return Missing;
201
+ }
202
+
203
+ FVector MCPBatchReadVector(const TSharedPtr<FJsonObject>& Obj, const FVector& Default)
204
+ {
205
+ if (!Obj.IsValid()) return Default;
206
+ FVector Value = Default;
207
+ double Component = 0.0;
208
+ if (Obj->TryGetNumberField(TEXT("x"), Component)) Value.X = Component;
209
+ if (Obj->TryGetNumberField(TEXT("y"), Component)) Value.Y = Component;
210
+ if (Obj->TryGetNumberField(TEXT("z"), Component)) Value.Z = Component;
211
+ return Value;
212
+ }
213
+
214
+ FRotator MCPBatchReadRotator(const TSharedPtr<FJsonObject>& Obj, const FRotator& Default)
215
+ {
216
+ if (!Obj.IsValid()) return Default;
217
+ FRotator Value = Default;
218
+ double Component = 0.0;
219
+ if (Obj->TryGetNumberField(TEXT("pitch"), Component)) Value.Pitch = Component;
220
+ if (Obj->TryGetNumberField(TEXT("yaw"), Component)) Value.Yaw = Component;
221
+ if (Obj->TryGetNumberField(TEXT("roll"), Component)) Value.Roll = Component;
222
+ return Value;
223
+ }
224
+
225
+ /** Pull the { ok, error } verdict out of a delegated single-item handler. */
226
+ void MCPBatchReadVerdict(const TSharedPtr<FJsonValue>& Response, bool& bOutOk, FString& OutError, FString& OutPrevious)
227
+ {
228
+ bOutOk = false;
229
+ OutError.Reset();
230
+ OutPrevious.Reset();
231
+ if (!Response.IsValid() || Response->Type != EJson::Object)
232
+ {
233
+ OutError = TEXT("handler returned no result");
234
+ return;
235
+ }
236
+ const TSharedPtr<FJsonObject> Object = Response->AsObject();
237
+ bOutOk = Object->HasField(TEXT("success")) ? Object->GetBoolField(TEXT("success")) : true;
238
+ Object->TryGetStringField(TEXT("error"), OutError);
239
+ Object->TryGetStringField(TEXT("previousValue"), OutPrevious);
240
+ }
241
+
242
+ /** Resolve one named component on an actor, matching the case-insensitive
243
+ * rule set_component_property already uses for SCS components. */
244
+ UActorComponent* MCPBatchFindComponent(AActor* Actor, const FString& ComponentName)
245
+ {
246
+ if (!Actor) return nullptr;
247
+ for (UActorComponent* Component : Actor->GetComponents())
248
+ {
249
+ if (Component && Component->GetName().Equals(ComponentName, ESearchCase::IgnoreCase))
250
+ {
251
+ return Component;
252
+ }
253
+ }
254
+ return nullptr;
255
+ }
256
+ }
257
+
258
+ // ---------------------------------------------------------------------------
259
+ // batch_set_actor_properties (#984)
260
+ // ---------------------------------------------------------------------------
261
+ TSharedPtr<FJsonValue> FLevelHandlers::BatchSetActorProperties(const TSharedPtr<FJsonObject>& Params)
262
+ {
263
+ MCP_CHECK_GAME_THREAD();
264
+ REQUIRE_EDITOR_WORLD(World);
265
+
266
+ const TSharedPtr<FJsonObject>* PropertiesObject = nullptr;
267
+ if (!Params->TryGetObjectField(TEXT("properties"), PropertiesObject) ||
268
+ !PropertiesObject || !(*PropertiesObject).IsValid() || (*PropertiesObject)->Values.Num() == 0)
269
+ {
270
+ return MCPError(TEXT("Missing 'properties' (an object of propertyName -> value; dotted paths are supported)"));
271
+ }
272
+ if ((*PropertiesObject)->Values.Num() > MCPBatchMaxProperties)
273
+ {
274
+ return MCPError(FString::Printf(
275
+ TEXT("'properties' exceeds the maximum of %d entries"), MCPBatchMaxProperties));
276
+ }
277
+
278
+ FMCPBatchSelector Selector;
279
+ if (auto Err = MCPBatchReadSelector(Params, Selector)) return Err;
280
+ if (!Selector.bAny)
281
+ {
282
+ return MCPError(TEXT("Pass at least one selector: actorLabels, labelPrefix, labelContains, tag, classFilter, folderPath or folderPathPrefix"));
283
+ }
284
+
285
+ const bool bDryRun = OptionalBool(Params, TEXT("dryRun"), false);
286
+ const bool bForce = OptionalBool(Params, TEXT("force"), false);
287
+ const FString TransactionLabel = OptionalString(
288
+ Params, TEXT("transactionLabel"), TEXT("MCP batch set actor properties"));
289
+
290
+ TArray<AActor*> Actors;
291
+ MCPBatchCollectActors(World, Selector, Actors);
292
+ if (Actors.Num() > MCPBatchMaxActors)
293
+ {
294
+ return MCPError(FString::Printf(
295
+ TEXT("The selector matched %d actors, over the maximum of %d. Narrow it."),
296
+ Actors.Num(), MCPBatchMaxActors));
297
+ }
298
+
299
+ auto Result = MCPSuccess();
300
+ Result->SetBoolField(TEXT("dryRun"), bDryRun);
301
+ Result->SetNumberField(TEXT("matched"), Actors.Num());
302
+ MCPNoteLoadedOnlyEnumeration(World, Result);
303
+ {
304
+ const TArray<FString> Missing = MCPBatchMissingLabels(Selector, Actors);
305
+ if (Missing.Num() > 0)
306
+ {
307
+ Result->SetArrayField(TEXT("missingLabels"), MCPStringListToJson(Missing));
308
+ }
309
+ }
310
+ if (Actors.IsEmpty())
311
+ {
312
+ const FString Needle = !Selector.LabelPrefix.IsEmpty()
313
+ ? Selector.LabelPrefix
314
+ : Selector.LabelContains;
315
+ if (const TSharedPtr<FJsonObject> Hint = MCPDescribeZeroActorMatch(World, Needle))
316
+ {
317
+ Result->SetObjectField(TEXT("zeroMatchHint"), Hint);
318
+ }
319
+ }
320
+
321
+ TArray<TSharedPtr<FJsonValue>> Rows;
322
+ int32 Updated = 0;
323
+ int32 Failed = 0;
324
+ int32 PropertyWrites = 0;
325
+ int32 PropertyFailures = 0;
326
+
327
+ // One transaction for the whole batch, so 190 lights is one undo rather
328
+ // than 190. Skipped entirely on a dry run, which writes nothing.
329
+ TUniquePtr<FScopedTransaction> Transaction;
330
+ if (!bDryRun && !Actors.IsEmpty())
331
+ {
332
+ Transaction = MakeUnique<FScopedTransaction>(FText::FromString(TransactionLabel));
333
+ }
334
+
335
+ for (AActor* Actor : Actors)
336
+ {
337
+ const FString Label = Actor->GetActorLabel();
338
+ TSharedPtr<FJsonObject> Row = MakeShared<FJsonObject>();
339
+ Row->SetStringField(TEXT("actorLabel"), Label);
340
+ Row->SetStringField(TEXT("actorClass"), Actor->GetClass()->GetName());
341
+
342
+ TArray<TSharedPtr<FJsonValue>> PropertyRows;
343
+ bool bActorOk = true;
344
+ for (const TPair<FString, TSharedPtr<FJsonValue>>& Pair : (*PropertiesObject)->Values)
345
+ {
346
+ TSharedPtr<FJsonObject> PropertyRow = MakeShared<FJsonObject>();
347
+ PropertyRow->SetStringField(TEXT("propertyName"), Pair.Key);
348
+
349
+ if (bDryRun)
350
+ {
351
+ // A preview has to be a real check, or it is not a preview.
352
+ // Resolve the path against this actor's class and say whether
353
+ // the write would land.
354
+ FString ResolvedType;
355
+ const TSharedPtr<FJsonValue> Current =
356
+ MCPQuery::ReadDottedProperty(Actor, Pair.Key, ResolvedType);
357
+ const bool bResolvable = Current.IsValid();
358
+ PropertyRow->SetBoolField(TEXT("ok"), bResolvable);
359
+ if (bResolvable)
360
+ {
361
+ PropertyRow->SetField(TEXT("currentValue"), Current);
362
+ PropertyRow->SetStringField(TEXT("propertyType"), ResolvedType);
363
+ }
364
+ else
365
+ {
366
+ PropertyRow->SetStringField(TEXT("error"), FString::Printf(
367
+ TEXT("Property '%s' does not resolve on %s"), *Pair.Key, *Actor->GetClass()->GetName()));
368
+ bActorOk = false;
369
+ ++PropertyFailures;
370
+ }
371
+ PropertyRows.Add(MakeShared<FJsonValueObject>(PropertyRow));
372
+ continue;
373
+ }
374
+
375
+ // Delegate to the single-actor writer. Reimplementing it here
376
+ // would quietly support fewer value shapes than set_actor_property
377
+ // does, which is the worse failure: the call succeeds and means
378
+ // something narrower than the caller asked for.
379
+ TSharedPtr<FJsonObject> SubParams = MakeShared<FJsonObject>();
380
+ SubParams->SetStringField(TEXT("actorLabel"), Label);
381
+ SubParams->SetStringField(TEXT("propertyName"), Pair.Key);
382
+ SubParams->SetField(TEXT("value"), Pair.Value);
383
+ if (bForce) SubParams->SetBoolField(TEXT("force"), true);
384
+
385
+ bool bOk = false;
386
+ FString Error;
387
+ FString Previous;
388
+ MCPBatchReadVerdict(FLevelHandlers::SetActorProperty(SubParams), bOk, Error, Previous);
389
+ PropertyRow->SetBoolField(TEXT("ok"), bOk);
390
+ if (!Previous.IsEmpty()) PropertyRow->SetStringField(TEXT("previousValue"), Previous);
391
+ if (!bOk)
392
+ {
393
+ PropertyRow->SetStringField(TEXT("error"), Error);
394
+ bActorOk = false;
395
+ ++PropertyFailures;
396
+ }
397
+ else
398
+ {
399
+ ++PropertyWrites;
400
+ }
401
+ PropertyRows.Add(MakeShared<FJsonValueObject>(PropertyRow));
402
+ }
403
+
404
+ Row->SetBoolField(TEXT("ok"), bActorOk);
405
+ Row->SetArrayField(TEXT("properties"), PropertyRows);
406
+ if (bActorOk) ++Updated; else ++Failed;
407
+ if (Rows.Num() < MCPBatchMaxResultRows)
408
+ {
409
+ Rows.Add(MakeShared<FJsonValueObject>(Row));
410
+ }
411
+ }
412
+
413
+ Result->SetBoolField(TEXT("success"), Failed == 0);
414
+ if (Failed > 0)
415
+ {
416
+ Result->SetStringField(TEXT("error"), FString::Printf(
417
+ TEXT("%d of %d actors had at least one property that did not write; see results[]."),
418
+ Failed, Actors.Num()));
419
+ }
420
+ Result->SetNumberField(bDryRun ? TEXT("wouldUpdate") : TEXT("updated"), Updated);
421
+ Result->SetNumberField(TEXT("failed"), Failed);
422
+ Result->SetNumberField(TEXT("propertyWrites"), PropertyWrites);
423
+ Result->SetNumberField(TEXT("propertyFailures"), PropertyFailures);
424
+ Result->SetNumberField(TEXT("returnedResults"), Rows.Num());
425
+ Result->SetBoolField(TEXT("resultsTruncated"), Rows.Num() < Actors.Num());
426
+ Result->SetArrayField(TEXT("results"), Rows);
427
+ if (!bDryRun && Updated > 0)
428
+ {
429
+ MCPSetUpdated(Result);
430
+ Result->SetStringField(TEXT("saveNote"),
431
+ TEXT("The level is left dirty and is NOT saved. Call level(save) when you are done."));
432
+ }
433
+ return MCPResult(Result);
434
+ }
435
+
436
+ // ---------------------------------------------------------------------------
437
+ // bulk_set_component_property (#941)
438
+ // ---------------------------------------------------------------------------
439
+ TSharedPtr<FJsonValue> FLevelHandlers::BulkSetComponentProperty(const TSharedPtr<FJsonObject>& Params)
440
+ {
441
+ MCP_CHECK_GAME_THREAD();
442
+ REQUIRE_EDITOR_WORLD(World);
443
+
444
+ FString ComponentName;
445
+ if (auto Err = RequireString(Params, TEXT("componentName"), ComponentName)) return Err;
446
+ FString PropertyName;
447
+ if (auto Err = RequireString(Params, TEXT("propertyName"), PropertyName)) return Err;
448
+
449
+ const TSharedPtr<FJsonValue>* ValueField = Params->Values.Find(TEXT("value"));
450
+ if (!ValueField)
451
+ {
452
+ return MCPError(TEXT("Missing 'value' parameter (pass JSON null to clear an object reference)"));
453
+ }
454
+
455
+ FMCPBatchSelector Selector;
456
+ if (auto Err = MCPBatchReadSelector(Params, Selector)) return Err;
457
+ if (!Selector.bAny)
458
+ {
459
+ return MCPError(TEXT("Pass at least one selector: actorLabels, labelPrefix, labelContains, tag, classFilter, folderPath or folderPathPrefix"));
460
+ }
461
+
462
+ const bool bDryRun = OptionalBool(Params, TEXT("dryRun"), false);
463
+ const FString TransactionLabel = OptionalString(
464
+ Params, TEXT("transactionLabel"), TEXT("MCP bulk set component property"));
465
+
466
+ TArray<AActor*> Actors;
467
+ MCPBatchCollectActors(World, Selector, Actors);
468
+ if (Actors.Num() > MCPBatchMaxActors)
469
+ {
470
+ return MCPError(FString::Printf(
471
+ TEXT("The selector matched %d actors, over the maximum of %d. Narrow it."),
472
+ Actors.Num(), MCPBatchMaxActors));
473
+ }
474
+
475
+ auto Result = MCPSuccess();
476
+ Result->SetBoolField(TEXT("dryRun"), bDryRun);
477
+ Result->SetStringField(TEXT("componentName"), ComponentName);
478
+ Result->SetStringField(TEXT("propertyName"), PropertyName);
479
+ Result->SetNumberField(TEXT("matchedActors"), Actors.Num());
480
+ MCPNoteLoadedOnlyEnumeration(World, Result);
481
+ {
482
+ const TArray<FString> Missing = MCPBatchMissingLabels(Selector, Actors);
483
+ if (Missing.Num() > 0)
484
+ {
485
+ Result->SetArrayField(TEXT("missingLabels"), MCPStringListToJson(Missing));
486
+ }
487
+ }
488
+
489
+ TUniquePtr<FScopedTransaction> Transaction;
490
+ if (!bDryRun && !Actors.IsEmpty())
491
+ {
492
+ Transaction = MakeUnique<FScopedTransaction>(FText::FromString(TransactionLabel));
493
+ }
494
+
495
+ TArray<TSharedPtr<FJsonValue>> Rows;
496
+ int32 Updated = 0;
497
+ int32 Failed = 0;
498
+ int32 WithoutComponent = 0;
499
+
500
+ for (AActor* Actor : Actors)
501
+ {
502
+ const FString Label = Actor->GetActorLabel();
503
+ TSharedPtr<FJsonObject> Row = MakeShared<FJsonObject>();
504
+ Row->SetStringField(TEXT("actorLabel"), Label);
505
+
506
+ UActorComponent* Component = MCPBatchFindComponent(Actor, ComponentName);
507
+ if (!Component)
508
+ {
509
+ // An actor that has no such component is neither a success nor an
510
+ // error: it is a fact the caller has to be told, because a
511
+ // selector wide enough to be useful will pick some up.
512
+ ++WithoutComponent;
513
+ Row->SetBoolField(TEXT("ok"), false);
514
+ Row->SetStringField(TEXT("status"), TEXT("no_such_component"));
515
+ if (Rows.Num() < MCPBatchMaxResultRows) Rows.Add(MakeShared<FJsonValueObject>(Row));
516
+ continue;
517
+ }
518
+ Row->SetStringField(TEXT("componentClass"), Component->GetClass()->GetName());
519
+
520
+ if (bDryRun)
521
+ {
522
+ FString ResolvedType;
523
+ const TSharedPtr<FJsonValue> Current =
524
+ MCPQuery::ReadDottedProperty(Component, PropertyName, ResolvedType);
525
+ const bool bResolvable = Current.IsValid();
526
+ Row->SetBoolField(TEXT("ok"), bResolvable);
527
+ Row->SetStringField(TEXT("status"), bResolvable ? TEXT("would_write") : TEXT("property_not_found"));
528
+ if (bResolvable)
529
+ {
530
+ Row->SetField(TEXT("currentValue"), Current);
531
+ Row->SetStringField(TEXT("propertyType"), ResolvedType);
532
+ ++Updated;
533
+ }
534
+ else
535
+ {
536
+ ++Failed;
537
+ }
538
+ if (Rows.Num() < MCPBatchMaxResultRows) Rows.Add(MakeShared<FJsonValueObject>(Row));
539
+ continue;
540
+ }
541
+
542
+ TSharedPtr<FJsonObject> SubParams = MakeShared<FJsonObject>();
543
+ SubParams->SetStringField(TEXT("actorLabel"), Label);
544
+ SubParams->SetStringField(TEXT("componentName"), ComponentName);
545
+ SubParams->SetStringField(TEXT("propertyName"), PropertyName);
546
+ SubParams->SetField(TEXT("value"), *ValueField);
547
+
548
+ bool bOk = false;
549
+ FString Error;
550
+ FString Previous;
551
+ MCPBatchReadVerdict(FLevelHandlers::SetComponentProperty(SubParams), bOk, Error, Previous);
552
+ Row->SetBoolField(TEXT("ok"), bOk);
553
+ Row->SetStringField(TEXT("status"), bOk ? TEXT("written") : TEXT("failed"));
554
+ if (!Previous.IsEmpty()) Row->SetStringField(TEXT("previousValue"), Previous);
555
+ if (!bOk)
556
+ {
557
+ Row->SetStringField(TEXT("error"), Error);
558
+ ++Failed;
559
+ }
560
+ else
561
+ {
562
+ ++Updated;
563
+ }
564
+ if (Rows.Num() < MCPBatchMaxResultRows) Rows.Add(MakeShared<FJsonValueObject>(Row));
565
+ }
566
+
567
+ Result->SetBoolField(TEXT("success"), Failed == 0);
568
+ if (Failed > 0)
569
+ {
570
+ Result->SetStringField(TEXT("error"), FString::Printf(
571
+ TEXT("%d of %d matched actors did not accept the write; see results[]."), Failed, Actors.Num()));
572
+ }
573
+ Result->SetNumberField(bDryRun ? TEXT("wouldUpdate") : TEXT("updated"), Updated);
574
+ Result->SetNumberField(TEXT("failed"), Failed);
575
+ Result->SetNumberField(TEXT("actorsWithoutComponent"), WithoutComponent);
576
+ Result->SetNumberField(TEXT("returnedResults"), Rows.Num());
577
+ Result->SetBoolField(TEXT("resultsTruncated"), Rows.Num() < Actors.Num());
578
+ Result->SetArrayField(TEXT("results"), Rows);
579
+ if (!bDryRun && Updated > 0)
580
+ {
581
+ MCPSetUpdated(Result);
582
+ Result->SetStringField(TEXT("saveNote"),
583
+ TEXT("The level is left dirty and is NOT saved. Call level(save) when you are done."));
584
+ }
585
+ return MCPResult(Result);
586
+ }
587
+
588
+ // ---------------------------------------------------------------------------
589
+ // remove_components_by_class (#907)
590
+ // ---------------------------------------------------------------------------
591
+ TSharedPtr<FJsonValue> FLevelHandlers::RemoveComponentsByClass(const TSharedPtr<FJsonObject>& Params)
592
+ {
593
+ MCP_CHECK_GAME_THREAD();
594
+ REQUIRE_EDITOR_WORLD(World);
595
+
596
+ FString ComponentClassSpec;
597
+ if (auto Err = RequireString(Params, TEXT("componentClass"), ComponentClassSpec)) return Err;
598
+
599
+ UClass* ComponentClass = MCPResolveClassOfType(ComponentClassSpec, UActorComponent::StaticClass(), true);
600
+ if (!ComponentClass)
601
+ {
602
+ return MCPClassNotFoundError(ComponentClassSpec, TEXT("componentClass"));
603
+ }
604
+ const bool bMatchComponentSubclasses = OptionalBool(Params, TEXT("matchComponentSubclasses"), true);
605
+ const FString ComponentNameContains = OptionalString(Params, TEXT("componentNameContains"));
606
+
607
+ FMCPBatchSelector Selector;
608
+ if (auto Err = MCPBatchReadSelector(Params, Selector)) return Err;
609
+ // actorClassFilter is the name #907 asks for; classFilter is what every
610
+ // other level action calls it. Accept both rather than make the caller
611
+ // remember which action they are in.
612
+ const FString ActorClassFilter = OptionalString(Params, TEXT("actorClassFilter"));
613
+ if (!ActorClassFilter.IsEmpty() && !Selector.ActorClass && Selector.ActorClassFallback.IsEmpty())
614
+ {
615
+ Selector.ActorClass = MCPResolveClassOfType(ActorClassFilter, AActor::StaticClass(), true);
616
+ if (!Selector.ActorClass) Selector.ActorClassFallback = ActorClassFilter;
617
+ Selector.bAny = true;
618
+ }
619
+
620
+ // dryRun DEFAULTS TO TRUE. This deletes components whose auto-generated
621
+ // names a caller cannot enumerate, which means it is the one level action
622
+ // where the caller most needs to see the list before it happens. Same
623
+ // default, and same reason, as delete_exact_labeled_actors_in_levels.
624
+ const bool bDryRun = OptionalBool(Params, TEXT("dryRun"), true);
625
+ const bool bSave = OptionalBool(Params, TEXT("save"), false);
626
+ const FString TransactionLabel = OptionalString(
627
+ Params, TEXT("transactionLabel"), TEXT("MCP remove components by class"));
628
+
629
+ TArray<AActor*> Actors;
630
+ if (Selector.bAny)
631
+ {
632
+ MCPBatchCollectActors(World, Selector, Actors);
633
+ }
634
+ else
635
+ {
636
+ // No actor selector means every loaded actor. That is a legitimate
637
+ // request (230 orphans across 23 actors nobody had named yet), which
638
+ // is exactly why dryRun defaults to true.
639
+ for (TActorIterator<AActor> It(World); It; ++It)
640
+ {
641
+ if (AActor* Actor = *It) Actors.Add(Actor);
642
+ }
643
+ Actors.Sort([](const AActor& A, const AActor& B) { return A.GetPathName() < B.GetPathName(); });
644
+ }
645
+
646
+ auto Result = MCPSuccess();
647
+ Result->SetBoolField(TEXT("dryRun"), bDryRun);
648
+ Result->SetStringField(TEXT("componentClass"), ComponentClass->GetName());
649
+ MCPNoteLoadedOnlyEnumeration(World, Result);
650
+ {
651
+ const TArray<FString> Missing = MCPBatchMissingLabels(Selector, Actors);
652
+ if (Missing.Num() > 0)
653
+ {
654
+ Result->SetArrayField(TEXT("missingLabels"), MCPStringListToJson(Missing));
655
+ }
656
+ }
657
+
658
+ TUniquePtr<FScopedTransaction> Transaction;
659
+ if (!bDryRun)
660
+ {
661
+ Transaction = MakeUnique<FScopedTransaction>(FText::FromString(TransactionLabel));
662
+ }
663
+
664
+ TArray<TSharedPtr<FJsonValue>> Rows;
665
+ int32 ScannedActors = 0;
666
+ int32 MatchedComponents = 0;
667
+ int32 RemovedComponents = 0;
668
+ int32 SkippedNonInstance = 0;
669
+ int32 TouchedActors = 0;
670
+
671
+ for (AActor* Actor : Actors)
672
+ {
673
+ ++ScannedActors;
674
+
675
+ TArray<UActorComponent*> Targets;
676
+ for (UActorComponent* Component : Actor->GetComponents())
677
+ {
678
+ if (!Component) continue;
679
+ const bool bClassMatches = bMatchComponentSubclasses
680
+ ? Component->IsA(ComponentClass)
681
+ : (Component->GetClass() == ComponentClass);
682
+ if (!bClassMatches) continue;
683
+ if (!ComponentNameContains.IsEmpty() &&
684
+ !Component->GetName().Contains(ComponentNameContains, ESearchCase::IgnoreCase))
685
+ {
686
+ continue;
687
+ }
688
+ Targets.Add(Component);
689
+ }
690
+ if (Targets.IsEmpty())
691
+ {
692
+ continue;
693
+ }
694
+
695
+ // Deterministic order so a dry run and the commit that follows report
696
+ // the same names.
697
+ Targets.Sort([](const UActorComponent& A, const UActorComponent& B)
698
+ {
699
+ return A.GetName() < B.GetName();
700
+ });
701
+
702
+ TArray<FString> RemovedNames;
703
+ TArray<FString> SkippedNames;
704
+ for (UActorComponent* Component : Targets)
705
+ {
706
+ ++MatchedComponents;
707
+ // Only instance components can be removed. A native or SCS
708
+ // component belongs to the class, and destroying it here would
709
+ // come back on the next construction rerun looking like the
710
+ // removal silently failed.
711
+ const bool bIsInstanceComponent = Actor->GetInstanceComponents().Contains(Component);
712
+ if (!bIsInstanceComponent)
713
+ {
714
+ ++SkippedNonInstance;
715
+ SkippedNames.Add(Component->GetName());
716
+ continue;
717
+ }
718
+ if (bDryRun)
719
+ {
720
+ RemovedNames.Add(Component->GetName());
721
+ continue;
722
+ }
723
+ const FString Name = Component->GetName();
724
+ Actor->Modify();
725
+ Component->Modify();
726
+ Actor->RemoveInstanceComponent(Component);
727
+ Component->DestroyComponent();
728
+ RemovedNames.Add(Name);
729
+ ++RemovedComponents;
730
+ }
731
+
732
+ if (RemovedNames.IsEmpty() && SkippedNames.IsEmpty())
733
+ {
734
+ continue;
735
+ }
736
+ ++TouchedActors;
737
+ if (Rows.Num() < MCPBatchMaxResultRows)
738
+ {
739
+ TSharedPtr<FJsonObject> Row = MakeShared<FJsonObject>();
740
+ Row->SetStringField(TEXT("actorLabel"), Actor->GetActorLabel());
741
+ Row->SetStringField(TEXT("actorClass"), Actor->GetClass()->GetName());
742
+ Row->SetNumberField(bDryRun ? TEXT("wouldRemove") : TEXT("removed"), RemovedNames.Num());
743
+ Row->SetArrayField(TEXT("components"), MCPStringListToJson(RemovedNames));
744
+ if (SkippedNames.Num() > 0)
745
+ {
746
+ Row->SetArrayField(TEXT("skippedNonInstanceComponents"), MCPStringListToJson(SkippedNames));
747
+ }
748
+ Rows.Add(MakeShared<FJsonValueObject>(Row));
749
+ }
750
+ }
751
+
752
+ Result->SetNumberField(TEXT("scannedActors"), ScannedActors);
753
+ Result->SetNumberField(TEXT("actorsAffected"), TouchedActors);
754
+ Result->SetNumberField(TEXT("matchedComponents"), MatchedComponents);
755
+ Result->SetNumberField(
756
+ bDryRun ? TEXT("wouldRemoveComponents") : TEXT("removedComponents"),
757
+ bDryRun ? MatchedComponents - SkippedNonInstance : RemovedComponents);
758
+ Result->SetNumberField(TEXT("skippedNonInstanceComponents"), SkippedNonInstance);
759
+ Result->SetNumberField(TEXT("returnedResults"), Rows.Num());
760
+ Result->SetBoolField(TEXT("resultsTruncated"), Rows.Num() < TouchedActors);
761
+ Result->SetArrayField(TEXT("results"), Rows);
762
+
763
+ if (MatchedComponents == 0)
764
+ {
765
+ Result->SetStringField(TEXT("zeroMatchNote"), FString::Printf(
766
+ TEXT("No component of class '%s' was found on any of the %d actors enumerated. Check the class name with level(query_components, componentClass:'%s', countOnly:true) before assuming there is nothing to remove."),
767
+ *ComponentClass->GetName(), ScannedActors, *ComponentClassSpec));
768
+ }
769
+
770
+ if (!bDryRun && RemovedComponents > 0)
771
+ {
772
+ MCPSetUpdated(Result);
773
+ if (bSave)
774
+ {
775
+ const bool bSaved = FEditorFileUtils::SaveCurrentLevel();
776
+ Result->SetBoolField(TEXT("saved"), bSaved);
777
+ if (!bSaved)
778
+ {
779
+ Result->SetStringField(TEXT("saveError"),
780
+ TEXT("The removal landed but saving the level failed. The change is in memory and undoable; save from the editor or call level(save)."));
781
+ }
782
+ }
783
+ else
784
+ {
785
+ Result->SetBoolField(TEXT("saved"), false);
786
+ Result->SetStringField(TEXT("saveNote"),
787
+ TEXT("The level is left dirty and is NOT saved. Pass save=true, or call level(save), to commit it."));
788
+ }
789
+ }
790
+ else if (bDryRun)
791
+ {
792
+ Result->SetBoolField(TEXT("saved"), false);
793
+ Result->SetStringField(TEXT("dryRunNote"),
794
+ TEXT("dryRun defaults to TRUE for this action because it deletes components whose auto-generated names you cannot enumerate. Pass dryRun=false to commit."));
795
+ }
796
+
797
+ return MCPResult(Result);
798
+ }
799
+
800
+ // ---------------------------------------------------------------------------
801
+ // spawn_actors_batch (#987)
802
+ // ---------------------------------------------------------------------------
803
+ TSharedPtr<FJsonValue> FLevelHandlers::SpawnActorsBatch(const TSharedPtr<FJsonObject>& Params)
804
+ {
805
+ MCP_CHECK_GAME_THREAD();
806
+ REQUIRE_EDITOR_WORLD(World);
807
+
808
+ FString ActorClassSpec;
809
+ if (auto Err = RequireString(Params, TEXT("actorClass"), ActorClassSpec)) return Err;
810
+ UClass* ActorClass = MCPResolveClassOfType(ActorClassSpec, AActor::StaticClass(), true);
811
+ if (!ActorClass)
812
+ {
813
+ return MCPClassNotFoundError(ActorClassSpec, TEXT("actorClass"));
814
+ }
815
+ if (auto Err = MCPCheckClassUsable(ActorClassSpec, ActorClass, AActor::StaticClass()))
816
+ {
817
+ return Err;
818
+ }
819
+
820
+ const bool bDryRun = OptionalBool(Params, TEXT("dryRun"), false);
821
+ const FString LabelPrefix = OptionalString(Params, TEXT("labelPrefix"));
822
+ const int32 MaxSpawn = FMath::Clamp(
823
+ OptionalInt(Params, TEXT("maxSpawn"), MCPBatchDefaultMaxSpawn), 1, MCPBatchHardMaxSpawn);
824
+ const FString TransactionLabel = OptionalString(
825
+ Params, TEXT("transactionLabel"), TEXT("MCP spawn actors batch"));
826
+
827
+ const TSharedPtr<FJsonObject>* SharedProperties = nullptr;
828
+ Params->TryGetObjectField(TEXT("properties"), SharedProperties);
829
+
830
+ // ── Build the transform list ────────────────────────────────────────────
831
+ //
832
+ // Three sources, and exactly one of them per call. The derived ones exist
833
+ // because the transforms in #987 were a function of geometry already in
834
+ // the editor: round-tripping 190 component bounds out and 190 transforms
835
+ // back in is what made it a Python job.
836
+ struct FPlannedSpawn
837
+ {
838
+ FTransform Transform;
839
+ FString Label;
840
+ FString Source;
841
+ TSharedPtr<FJsonObject> Properties;
842
+ };
843
+ TArray<FPlannedSpawn> Planned;
844
+ FString PlanSource;
845
+
846
+ const TArray<TSharedPtr<FJsonValue>>* InstanceValues = nullptr;
847
+ const TSharedPtr<FJsonObject>* FromComponents = nullptr;
848
+ const TSharedPtr<FJsonObject>* AlongSpline = nullptr;
849
+ const bool bHasInstances = Params->TryGetArrayField(TEXT("instances"), InstanceValues) && InstanceValues;
850
+ const bool bHasFromComponents = Params->TryGetObjectField(TEXT("fromComponents"), FromComponents) && FromComponents;
851
+ const bool bHasAlongSpline = Params->TryGetObjectField(TEXT("alongSpline"), AlongSpline) && AlongSpline;
852
+
853
+ const int32 SourceCount = (bHasInstances ? 1 : 0) + (bHasFromComponents ? 1 : 0) + (bHasAlongSpline ? 1 : 0);
854
+ if (SourceCount == 0)
855
+ {
856
+ return MCPError(TEXT("Pass exactly one of 'instances' (explicit transforms), 'fromComponents' (one actor per matched component's bounds) or 'alongSpline' (scatter by distance)"));
857
+ }
858
+ if (SourceCount > 1)
859
+ {
860
+ return MCPError(TEXT("Pass only ONE of 'instances', 'fromComponents' or 'alongSpline'; combining them makes the spawn count ambiguous"));
861
+ }
862
+
863
+ if (bHasInstances)
864
+ {
865
+ PlanSource = TEXT("instances");
866
+ for (int32 Index = 0; Index < InstanceValues->Num(); ++Index)
867
+ {
868
+ const TSharedPtr<FJsonValue>& Entry = (*InstanceValues)[Index];
869
+ if (!Entry.IsValid() || Entry->Type != EJson::Object)
870
+ {
871
+ return MCPError(FString::Printf(TEXT("'instances[%d]' must be an object"), Index));
872
+ }
873
+ const TSharedPtr<FJsonObject> Row = Entry->AsObject();
874
+ const TSharedPtr<FJsonObject>* LocationObject = nullptr;
875
+ const TSharedPtr<FJsonObject>* RotationObject = nullptr;
876
+ const TSharedPtr<FJsonObject>* ScaleObject = nullptr;
877
+ Row->TryGetObjectField(TEXT("location"), LocationObject);
878
+ Row->TryGetObjectField(TEXT("rotation"), RotationObject);
879
+ Row->TryGetObjectField(TEXT("scale"), ScaleObject);
880
+
881
+ FPlannedSpawn Spawn;
882
+ Spawn.Transform = FTransform(
883
+ RotationObject ? MCPBatchReadRotator(*RotationObject, FRotator::ZeroRotator) : FRotator::ZeroRotator,
884
+ LocationObject ? MCPBatchReadVector(*LocationObject, FVector::ZeroVector) : FVector::ZeroVector,
885
+ ScaleObject ? MCPBatchReadVector(*ScaleObject, FVector::OneVector) : FVector::OneVector);
886
+ Row->TryGetStringField(TEXT("label"), Spawn.Label);
887
+ Spawn.Source = TEXT("instance");
888
+ const TSharedPtr<FJsonObject>* RowProperties = nullptr;
889
+ if (Row->TryGetObjectField(TEXT("properties"), RowProperties) && RowProperties)
890
+ {
891
+ Spawn.Properties = *RowProperties;
892
+ }
893
+ Planned.Add(MoveTemp(Spawn));
894
+ }
895
+ }
896
+ else if (bHasFromComponents)
897
+ {
898
+ PlanSource = TEXT("fromComponents");
899
+ const TSharedPtr<FJsonObject> Source = *FromComponents;
900
+
901
+ const FString ComponentClassSpec = OptionalString(Source, TEXT("componentClass"));
902
+ UClass* ComponentClass = ComponentClassSpec.IsEmpty()
903
+ ? nullptr
904
+ : MCPResolveClassOfType(ComponentClassSpec, UActorComponent::StaticClass(), true);
905
+ if (!ComponentClassSpec.IsEmpty() && !ComponentClass)
906
+ {
907
+ return MCPClassNotFoundError(ComponentClassSpec, TEXT("fromComponents.componentClass"));
908
+ }
909
+ const FString ComponentNameContains = OptionalString(Source, TEXT("componentNameContains"));
910
+
911
+ FMCPBatchSelector Selector;
912
+ if (auto Err = MCPBatchReadSelector(Source, Selector)) return Err;
913
+
914
+ // Local by default, and this is the whole point of the mode. A
915
+ // component's WORLD bounds are axis-aligned, so on a rotated lamp post
916
+ // "the top of the mesh" computed from world bounds is not on the lamp
917
+ // post. The local bounds carry the component's own orientation, and
918
+ // transforming the offset by the component transform puts the spawn
919
+ // where a human would point.
920
+ const FString Space = OptionalString(Source, TEXT("space"), TEXT("local")).ToLower();
921
+ if (Space != TEXT("local") && Space != TEXT("world"))
922
+ {
923
+ return MCPError(TEXT("'fromComponents.space' must be either 'local' (default) or 'world'"));
924
+ }
925
+ const bool bLocal = Space == TEXT("local");
926
+
927
+ const TSharedPtr<FJsonObject>* OffsetObject = nullptr;
928
+ Source->TryGetObjectField(TEXT("offset"), OffsetObject);
929
+ const FVector Offset = OffsetObject ? MCPBatchReadVector(*OffsetObject, FVector::ZeroVector) : FVector::ZeroVector;
930
+
931
+ const TSharedPtr<FJsonObject>* FractionObject = nullptr;
932
+ Source->TryGetObjectField(TEXT("extentFraction"), FractionObject);
933
+ const FVector ExtentFraction = FractionObject
934
+ ? MCPBatchReadVector(*FractionObject, FVector::ZeroVector)
935
+ : FVector::ZeroVector;
936
+
937
+ const bool bInheritRotation = OptionalBool(Source, TEXT("inheritRotation"), false);
938
+
939
+ for (TActorIterator<AActor> It(World); It; ++It)
940
+ {
941
+ AActor* Actor = *It;
942
+ if (!Actor) continue;
943
+ if (Selector.bAny && !MCPBatchActorMatches(Selector, Actor, Actor->GetActorLabel())) continue;
944
+
945
+ for (UActorComponent* Component : Actor->GetComponents())
946
+ {
947
+ USceneComponent* SceneComponent = Cast<USceneComponent>(Component);
948
+ if (!SceneComponent) continue;
949
+ if (ComponentClass && !SceneComponent->IsA(ComponentClass)) continue;
950
+ if (!ComponentNameContains.IsEmpty() &&
951
+ !SceneComponent->GetName().Contains(ComponentNameContains, ESearchCase::IgnoreCase))
952
+ {
953
+ continue;
954
+ }
955
+
956
+ const FTransform ComponentTransform = SceneComponent->GetComponentTransform();
957
+ FVector Location;
958
+ if (bLocal)
959
+ {
960
+ const FBoxSphereBounds LocalBounds = SceneComponent->GetLocalBounds();
961
+ const FVector LocalPoint =
962
+ LocalBounds.Origin + LocalBounds.BoxExtent * ExtentFraction + Offset;
963
+ Location = ComponentTransform.TransformPosition(LocalPoint);
964
+ }
965
+ else
966
+ {
967
+ const FBoxSphereBounds WorldBounds = SceneComponent->GetBounds();
968
+ Location = WorldBounds.Origin + WorldBounds.BoxExtent * ExtentFraction + Offset;
969
+ }
970
+
971
+ FPlannedSpawn Spawn;
972
+ Spawn.Transform = FTransform(
973
+ bInheritRotation ? ComponentTransform.Rotator() : FRotator::ZeroRotator,
974
+ Location,
975
+ FVector::OneVector);
976
+ Spawn.Source = FString::Printf(TEXT("%s.%s"), *Actor->GetActorLabel(), *SceneComponent->GetName());
977
+ Planned.Add(MoveTemp(Spawn));
978
+ }
979
+ }
980
+ Planned.Sort([](const FPlannedSpawn& A, const FPlannedSpawn& B) { return A.Source < B.Source; });
981
+ }
982
+ else
983
+ {
984
+ PlanSource = TEXT("alongSpline");
985
+ const TSharedPtr<FJsonObject> Source = *AlongSpline;
986
+
987
+ FString SplineActorLabel;
988
+ if (auto Err = RequireStringAlt(Source, TEXT("actorLabel"), TEXT("actorPath"), SplineActorLabel)) return Err;
989
+ // #983: the spline the batch runs along is selected the same way as
990
+ // any other actor, and a duplicated label refuses rather than picking
991
+ // a spline at the other end of the map to scatter along.
992
+ TSharedPtr<FJsonValue> SplineErr;
993
+ AActor* SplineActor = MCPResolveActor(World, Source, SplineErr);
994
+ if (!SplineActor) return SplineErr;
995
+ SplineActorLabel = SplineActor->GetActorLabel();
996
+ const FString SplineComponentName = OptionalString(Source, TEXT("componentName"));
997
+ USplineComponent* Spline = nullptr;
998
+ for (UActorComponent* Component : SplineActor->GetComponents())
999
+ {
1000
+ USplineComponent* Candidate = Cast<USplineComponent>(Component);
1001
+ if (!Candidate) continue;
1002
+ if (SplineComponentName.IsEmpty() ||
1003
+ Candidate->GetName().Equals(SplineComponentName, ESearchCase::IgnoreCase))
1004
+ {
1005
+ Spline = Candidate;
1006
+ break;
1007
+ }
1008
+ }
1009
+ if (!Spline)
1010
+ {
1011
+ return MCPError(FString::Printf(
1012
+ TEXT("No SplineComponent%s on actor '%s'"),
1013
+ SplineComponentName.IsEmpty() ? TEXT("") : *FString::Printf(TEXT(" named '%s'"), *SplineComponentName),
1014
+ *SplineActorLabel));
1015
+ }
1016
+
1017
+ const double Spacing = OptionalNumber(Source, TEXT("spacing"), 0.0);
1018
+ if (Spacing < MCPBatchMinSplineSpacing)
1019
+ {
1020
+ return MCPError(FString::Printf(
1021
+ TEXT("'alongSpline.spacing' must be at least %.0f cm; a smaller value would spawn an unbounded number of actors"),
1022
+ MCPBatchMinSplineSpacing));
1023
+ }
1024
+ const double SplineLength = Spline->GetSplineLength();
1025
+ const double StartDistance = FMath::Clamp(
1026
+ OptionalNumber(Source, TEXT("startDistance"), 0.0), 0.0, SplineLength);
1027
+ const double EndDistance = FMath::Clamp(
1028
+ OptionalNumber(Source, TEXT("endDistance"), SplineLength), StartDistance, SplineLength);
1029
+ const bool bAlignToTangent = OptionalBool(Source, TEXT("alignToTangent"), true);
1030
+
1031
+ const TSharedPtr<FJsonObject>* OffsetObject = nullptr;
1032
+ Source->TryGetObjectField(TEXT("offset"), OffsetObject);
1033
+ const FVector Offset = OffsetObject ? MCPBatchReadVector(*OffsetObject, FVector::ZeroVector) : FVector::ZeroVector;
1034
+
1035
+ for (double Distance = StartDistance; Distance <= EndDistance; Distance += Spacing)
1036
+ {
1037
+ const FRotator Rotation = Spline->GetRotationAtDistanceAlongSpline(
1038
+ Distance, ESplineCoordinateSpace::World);
1039
+ FVector Location = Spline->GetLocationAtDistanceAlongSpline(
1040
+ Distance, ESplineCoordinateSpace::World);
1041
+ // The offset follows the spline's frame when aligning to it, so
1042
+ // "20cm to the left of the path" stays to the left round a corner.
1043
+ Location += bAlignToTangent ? Rotation.RotateVector(Offset) : Offset;
1044
+
1045
+ FPlannedSpawn Spawn;
1046
+ Spawn.Transform = FTransform(
1047
+ bAlignToTangent ? Rotation : FRotator::ZeroRotator, Location, FVector::OneVector);
1048
+ Spawn.Source = FString::Printf(TEXT("%s@%.0f"), *Spline->GetName(), Distance);
1049
+ Planned.Add(MoveTemp(Spawn));
1050
+
1051
+ if (Planned.Num() > MCPBatchHardMaxSpawn)
1052
+ {
1053
+ break;
1054
+ }
1055
+ }
1056
+ }
1057
+
1058
+ auto Result = MCPSuccess();
1059
+ Result->SetBoolField(TEXT("dryRun"), bDryRun);
1060
+ Result->SetStringField(TEXT("actorClass"), ActorClass->GetPathName());
1061
+ Result->SetStringField(TEXT("source"), PlanSource);
1062
+ Result->SetNumberField(TEXT("planned"), Planned.Num());
1063
+
1064
+ if (Planned.Num() > MaxSpawn)
1065
+ {
1066
+ Result->SetBoolField(TEXT("success"), false);
1067
+ Result->SetStringField(TEXT("error"), FString::Printf(
1068
+ TEXT("The plan produced %d spawns, over maxSpawn (%d). Narrow the selector or raise maxSpawn deliberately."),
1069
+ Planned.Num(), MaxSpawn));
1070
+ Result->SetNumberField(TEXT("maxSpawn"), MaxSpawn);
1071
+ return MCPResult(Result);
1072
+ }
1073
+ if (Planned.IsEmpty())
1074
+ {
1075
+ Result->SetNumberField(TEXT("spawned"), 0);
1076
+ Result->SetArrayField(TEXT("results"), TArray<TSharedPtr<FJsonValue>>());
1077
+ Result->SetStringField(TEXT("zeroMatchNote"),
1078
+ TEXT("The plan produced no positions. For fromComponents, check componentClass and the actor selector with level(query_components, countOnly:true); for alongSpline, check the spline length against startDistance/endDistance."));
1079
+ return MCPResult(Result);
1080
+ }
1081
+
1082
+ TUniquePtr<FScopedTransaction> Transaction;
1083
+ if (!bDryRun)
1084
+ {
1085
+ Transaction = MakeUnique<FScopedTransaction>(FText::FromString(TransactionLabel));
1086
+ }
1087
+
1088
+ TArray<TSharedPtr<FJsonValue>> Rows;
1089
+ int32 Spawned = 0;
1090
+ int32 Failed = 0;
1091
+ int32 PropertyFailures = 0;
1092
+
1093
+ for (int32 Index = 0; Index < Planned.Num(); ++Index)
1094
+ {
1095
+ const FPlannedSpawn& Plan = Planned[Index];
1096
+ TSharedPtr<FJsonObject> Row = MakeShared<FJsonObject>();
1097
+ Row->SetNumberField(TEXT("index"), Index);
1098
+ Row->SetStringField(TEXT("from"), Plan.Source);
1099
+ Row->SetObjectField(TEXT("location"), MCPVec3ToJsonObject(Plan.Transform.GetLocation()));
1100
+ Row->SetObjectField(TEXT("rotation"), MCPRotatorToJsonObject(Plan.Transform.Rotator()));
1101
+ Row->SetObjectField(TEXT("scale"), MCPVec3ToJsonObject(Plan.Transform.GetScale3D()));
1102
+
1103
+ if (bDryRun)
1104
+ {
1105
+ Row->SetBoolField(TEXT("ok"), true);
1106
+ if (Rows.Num() < MCPBatchMaxResultRows) Rows.Add(MakeShared<FJsonValueObject>(Row));
1107
+ continue;
1108
+ }
1109
+
1110
+ FActorSpawnParameters SpawnParameters;
1111
+ SpawnParameters.SpawnCollisionHandlingOverride = ESpawnActorCollisionHandlingMethod::AlwaysSpawn;
1112
+ AActor* Actor = World->SpawnActor<AActor>(ActorClass, Plan.Transform, SpawnParameters);
1113
+ if (!Actor)
1114
+ {
1115
+ ++Failed;
1116
+ Row->SetBoolField(TEXT("ok"), false);
1117
+ Row->SetStringField(TEXT("error"), TEXT("SpawnActor returned null"));
1118
+ if (Rows.Num() < MCPBatchMaxResultRows) Rows.Add(MakeShared<FJsonValueObject>(Row));
1119
+ continue;
1120
+ }
1121
+ ++Spawned;
1122
+
1123
+ FString Label = Plan.Label;
1124
+ if (Label.IsEmpty() && !LabelPrefix.IsEmpty())
1125
+ {
1126
+ Label = FString::Printf(TEXT("%s%d"), *LabelPrefix, Index);
1127
+ }
1128
+ if (!Label.IsEmpty())
1129
+ {
1130
+ Actor->SetActorLabel(Label);
1131
+ }
1132
+ Row->SetStringField(TEXT("actorLabel"), Actor->GetActorLabel());
1133
+ Row->SetBoolField(TEXT("ok"), true);
1134
+
1135
+ // Per-instance properties override the shared ones, so a caller can
1136
+ // set one field per spawn without repeating the rest.
1137
+ TArray<TSharedPtr<FJsonValue>> PropertyRows;
1138
+ auto ApplyProperties = [&](const TSharedPtr<FJsonObject>& Properties)
1139
+ {
1140
+ if (!Properties.IsValid()) return;
1141
+ for (const TPair<FString, TSharedPtr<FJsonValue>>& Pair : Properties->Values)
1142
+ {
1143
+ TSharedPtr<FJsonObject> SubParams = MakeShared<FJsonObject>();
1144
+ SubParams->SetStringField(TEXT("actorLabel"), Actor->GetActorLabel());
1145
+ SubParams->SetStringField(TEXT("propertyName"), Pair.Key);
1146
+ SubParams->SetField(TEXT("value"), Pair.Value);
1147
+ SubParams->SetBoolField(TEXT("force"), true);
1148
+
1149
+ bool bOk = false;
1150
+ FString Error;
1151
+ FString Previous;
1152
+ MCPBatchReadVerdict(FLevelHandlers::SetActorProperty(SubParams), bOk, Error, Previous);
1153
+ TSharedPtr<FJsonObject> PropertyRow = MakeShared<FJsonObject>();
1154
+ PropertyRow->SetStringField(TEXT("propertyName"), Pair.Key);
1155
+ PropertyRow->SetBoolField(TEXT("ok"), bOk);
1156
+ if (!bOk)
1157
+ {
1158
+ PropertyRow->SetStringField(TEXT("error"), Error);
1159
+ ++PropertyFailures;
1160
+ }
1161
+ PropertyRows.Add(MakeShared<FJsonValueObject>(PropertyRow));
1162
+ }
1163
+ };
1164
+ ApplyProperties(SharedProperties ? *SharedProperties : nullptr);
1165
+ ApplyProperties(Plan.Properties);
1166
+ if (PropertyRows.Num() > 0)
1167
+ {
1168
+ Row->SetArrayField(TEXT("properties"), PropertyRows);
1169
+ }
1170
+
1171
+ if (Rows.Num() < MCPBatchMaxResultRows) Rows.Add(MakeShared<FJsonValueObject>(Row));
1172
+ }
1173
+
1174
+ Result->SetBoolField(TEXT("success"), Failed == 0);
1175
+ if (Failed > 0)
1176
+ {
1177
+ Result->SetStringField(TEXT("error"), FString::Printf(
1178
+ TEXT("%d of %d planned spawns failed; see results[]."), Failed, Planned.Num()));
1179
+ }
1180
+ Result->SetNumberField(bDryRun ? TEXT("wouldSpawn") : TEXT("spawned"), bDryRun ? Planned.Num() : Spawned);
1181
+ Result->SetNumberField(TEXT("failed"), Failed);
1182
+ Result->SetNumberField(TEXT("propertyFailures"), PropertyFailures);
1183
+ Result->SetNumberField(TEXT("returnedResults"), Rows.Num());
1184
+ Result->SetBoolField(TEXT("resultsTruncated"), Rows.Num() < Planned.Num());
1185
+ Result->SetArrayField(TEXT("results"), Rows);
1186
+ if (!bDryRun && Spawned > 0)
1187
+ {
1188
+ MCPSetCreated(Result);
1189
+ Result->SetStringField(TEXT("saveNote"),
1190
+ TEXT("The level is left dirty and is NOT saved. Call level(save) when you are done."));
1191
+ }
1192
+ return MCPResult(Result);
1193
+ }
1194
+
1195
+ // ---------------------------------------------------------------------------
1196
+ // set_component_materials (#946)
1197
+ //
1198
+ // material(build_material) writes the ASSET's material slots. A placed actor
1199
+ // usually needs the other thing: a per-slot COMPONENT override, which is what
1200
+ // the details panel edits and what SetMaterial writes into OverrideMaterials.
1201
+ // There was no action for that. set_actor_material does one slot, on whichever
1202
+ // primitive component it happens to find first, so dressing a placed skeletal
1203
+ // mesh meant a Python loop over get_num_materials().
1204
+ // ---------------------------------------------------------------------------
1205
+ TSharedPtr<FJsonValue> FLevelHandlers::SetComponentMaterials(const TSharedPtr<FJsonObject>& Params)
1206
+ {
1207
+ MCP_CHECK_GAME_THREAD();
1208
+ REQUIRE_EDITOR_WORLD(World);
1209
+
1210
+ FMCPBatchSelector Selector;
1211
+ if (auto Err = MCPBatchReadSelector(Params, Selector)) return Err;
1212
+ if (!Selector.bAny)
1213
+ {
1214
+ return MCPError(TEXT("Pass at least one selector: actorLabels, labelPrefix, labelContains, tag, classFilter, folderPath or folderPathPrefix"));
1215
+ }
1216
+
1217
+ const FString ComponentName = OptionalString(Params, TEXT("componentName"));
1218
+ const bool bDryRun = OptionalBool(Params, TEXT("dryRun"), false);
1219
+ const bool bClearOverrides = OptionalBool(Params, TEXT("clearOverrides"), false);
1220
+ const FString SingleMaterialPath = OptionalString(Params, TEXT("material"));
1221
+
1222
+ const TArray<TSharedPtr<FJsonValue>>* MaterialValues = nullptr;
1223
+ const bool bHasMaterialList = Params->TryGetArrayField(TEXT("materials"), MaterialValues) && MaterialValues;
1224
+
1225
+ const int32 ModeCount = (bHasMaterialList ? 1 : 0) + (SingleMaterialPath.IsEmpty() ? 0 : 1) + (bClearOverrides ? 1 : 0);
1226
+ if (ModeCount == 0)
1227
+ {
1228
+ return MCPError(TEXT("Pass 'materials' (per-slot paths), 'material' (one path applied to every slot) or clearOverrides=true"));
1229
+ }
1230
+ if (ModeCount > 1)
1231
+ {
1232
+ return MCPError(TEXT("Pass only ONE of 'materials', 'material' or clearOverrides; together they would fight over the same slots"));
1233
+ }
1234
+
1235
+ // Resolve every requested material up front. A path that does not load is
1236
+ // a caller error, and finding that out after writing half the slots on
1237
+ // half the actors is not a useful place to find it out.
1238
+ TArray<UMaterialInterface*> SlotMaterials;
1239
+ if (bHasMaterialList)
1240
+ {
1241
+ for (int32 Index = 0; Index < MaterialValues->Num(); ++Index)
1242
+ {
1243
+ const TSharedPtr<FJsonValue>& Entry = (*MaterialValues)[Index];
1244
+ FString Path;
1245
+ const bool bIsClear = !Entry.IsValid() || Entry->Type == EJson::Null ||
1246
+ (Entry->TryGetString(Path) && Path.IsEmpty());
1247
+ if (bIsClear)
1248
+ {
1249
+ // An explicit null clears that one slot's override, so a caller
1250
+ // can reset slot 2 without touching the rest.
1251
+ SlotMaterials.Add(nullptr);
1252
+ continue;
1253
+ }
1254
+ UMaterialInterface* Material = LoadObject<UMaterialInterface>(nullptr, *Path);
1255
+ if (!Material)
1256
+ {
1257
+ return MCPError(FString::Printf(
1258
+ TEXT("Material not found for 'materials[%d]': %s"), Index, *Path));
1259
+ }
1260
+ SlotMaterials.Add(Material);
1261
+ }
1262
+ }
1263
+ UMaterialInterface* SingleMaterial = nullptr;
1264
+ if (!SingleMaterialPath.IsEmpty())
1265
+ {
1266
+ SingleMaterial = LoadObject<UMaterialInterface>(nullptr, *SingleMaterialPath);
1267
+ if (!SingleMaterial)
1268
+ {
1269
+ return MCPError(FString::Printf(TEXT("Material not found: %s"), *SingleMaterialPath));
1270
+ }
1271
+ }
1272
+
1273
+ TArray<AActor*> Actors;
1274
+ MCPBatchCollectActors(World, Selector, Actors);
1275
+ if (Actors.Num() > MCPBatchMaxActors)
1276
+ {
1277
+ return MCPError(FString::Printf(
1278
+ TEXT("The selector matched %d actors, over the maximum of %d. Narrow it."),
1279
+ Actors.Num(), MCPBatchMaxActors));
1280
+ }
1281
+
1282
+ auto Result = MCPSuccess();
1283
+ Result->SetBoolField(TEXT("dryRun"), bDryRun);
1284
+ Result->SetNumberField(TEXT("matchedActors"), Actors.Num());
1285
+ MCPNoteLoadedOnlyEnumeration(World, Result);
1286
+ {
1287
+ const TArray<FString> Missing = MCPBatchMissingLabels(Selector, Actors);
1288
+ if (Missing.Num() > 0)
1289
+ {
1290
+ Result->SetArrayField(TEXT("missingLabels"), MCPStringListToJson(Missing));
1291
+ }
1292
+ }
1293
+
1294
+ TUniquePtr<FScopedTransaction> Transaction;
1295
+ if (!bDryRun && !Actors.IsEmpty())
1296
+ {
1297
+ Transaction = MakeUnique<FScopedTransaction>(FText::FromString(
1298
+ OptionalString(Params, TEXT("transactionLabel"), TEXT("MCP set component materials"))));
1299
+ }
1300
+
1301
+ TArray<TSharedPtr<FJsonValue>> Rows;
1302
+ int32 Updated = 0;
1303
+ int32 WithoutComponent = 0;
1304
+ int32 SlotWrites = 0;
1305
+ int32 OutOfRange = 0;
1306
+
1307
+ for (AActor* Actor : Actors)
1308
+ {
1309
+ TSharedPtr<FJsonObject> Row = MakeShared<FJsonObject>();
1310
+ Row->SetStringField(TEXT("actorLabel"), Actor->GetActorLabel());
1311
+
1312
+ UMeshComponent* MeshComponent = nullptr;
1313
+ if (ComponentName.IsEmpty())
1314
+ {
1315
+ MeshComponent = Actor->FindComponentByClass<UMeshComponent>();
1316
+ }
1317
+ else
1318
+ {
1319
+ MeshComponent = Cast<UMeshComponent>(MCPBatchFindComponent(Actor, ComponentName));
1320
+ }
1321
+ if (!MeshComponent)
1322
+ {
1323
+ ++WithoutComponent;
1324
+ Row->SetBoolField(TEXT("ok"), false);
1325
+ Row->SetStringField(TEXT("status"), TEXT("no_mesh_component"));
1326
+ if (Rows.Num() < MCPBatchMaxResultRows) Rows.Add(MakeShared<FJsonValueObject>(Row));
1327
+ continue;
1328
+ }
1329
+ Row->SetStringField(TEXT("componentName"), MeshComponent->GetName());
1330
+ Row->SetStringField(TEXT("componentClass"), MeshComponent->GetClass()->GetName());
1331
+
1332
+ const int32 SlotCount = MeshComponent->GetNumMaterials();
1333
+ Row->SetNumberField(TEXT("slotCount"), SlotCount);
1334
+ const TArray<FName> SlotNames = MeshComponent->GetMaterialSlotNames();
1335
+
1336
+ if (bHasMaterialList && SlotMaterials.Num() > SlotCount)
1337
+ {
1338
+ // Extra entries would silently do nothing, which reads as a
1339
+ // successful assignment that did not happen.
1340
+ ++OutOfRange;
1341
+ Row->SetBoolField(TEXT("ok"), false);
1342
+ Row->SetStringField(TEXT("status"), TEXT("too_many_materials"));
1343
+ Row->SetStringField(TEXT("error"), FString::Printf(
1344
+ TEXT("%d materials given but the component has %d slots"), SlotMaterials.Num(), SlotCount));
1345
+ if (Rows.Num() < MCPBatchMaxResultRows) Rows.Add(MakeShared<FJsonValueObject>(Row));
1346
+ continue;
1347
+ }
1348
+
1349
+ TArray<TSharedPtr<FJsonValue>> SlotRows;
1350
+ const int32 WriteCount = bHasMaterialList ? SlotMaterials.Num() : SlotCount;
1351
+ for (int32 SlotIndex = 0; SlotIndex < WriteCount; ++SlotIndex)
1352
+ {
1353
+ UMaterialInterface* Before = MeshComponent->GetMaterial(SlotIndex);
1354
+ const bool bWasOverride =
1355
+ MeshComponent->OverrideMaterials.IsValidIndex(SlotIndex) &&
1356
+ MeshComponent->OverrideMaterials[SlotIndex] != nullptr;
1357
+
1358
+ UMaterialInterface* Desired = nullptr;
1359
+ if (bHasMaterialList) Desired = SlotMaterials[SlotIndex];
1360
+ else if (SingleMaterial) Desired = SingleMaterial;
1361
+
1362
+ if (!bDryRun)
1363
+ {
1364
+ MeshComponent->Modify();
1365
+ // A null override is how the asset's own slot shows through
1366
+ // again, which is the inverse of what this action does and is
1367
+ // otherwise unreachable.
1368
+ MeshComponent->SetMaterial(SlotIndex, bClearOverrides ? nullptr : Desired);
1369
+ ++SlotWrites;
1370
+ }
1371
+
1372
+ UMaterialInterface* After = bDryRun ? Before : MeshComponent->GetMaterial(SlotIndex);
1373
+ const bool bIsOverride = bDryRun
1374
+ ? bWasOverride
1375
+ : (MeshComponent->OverrideMaterials.IsValidIndex(SlotIndex) &&
1376
+ MeshComponent->OverrideMaterials[SlotIndex] != nullptr);
1377
+
1378
+ TSharedPtr<FJsonObject> SlotRow = MakeShared<FJsonObject>();
1379
+ SlotRow->SetNumberField(TEXT("slotIndex"), SlotIndex);
1380
+ SlotRow->SetStringField(TEXT("slotName"),
1381
+ SlotNames.IsValidIndex(SlotIndex) ? SlotNames[SlotIndex].ToString() : FString());
1382
+ SlotRow->SetStringField(TEXT("previousMaterialPath"), Before ? Before->GetPathName() : FString());
1383
+ SlotRow->SetStringField(TEXT("previousSource"),
1384
+ bWasOverride ? TEXT("componentOverride") : (Before ? TEXT("meshDefault") : TEXT("none")));
1385
+ SlotRow->SetStringField(TEXT("materialPath"), After ? After->GetPathName() : FString());
1386
+ // Read back rather than echoing: the difference between "the
1387
+ // component now overrides this slot" and "the asset happens to
1388
+ // have that material" is the whole distinction #946 is about.
1389
+ SlotRow->SetStringField(TEXT("source"),
1390
+ bIsOverride ? TEXT("componentOverride") : (After ? TEXT("meshDefault") : TEXT("none")));
1391
+ SlotRows.Add(MakeShared<FJsonValueObject>(SlotRow));
1392
+ }
1393
+
1394
+ if (!bDryRun)
1395
+ {
1396
+ MeshComponent->MarkRenderStateDirty();
1397
+ Actor->MarkPackageDirty();
1398
+ ++Updated;
1399
+ }
1400
+ Row->SetBoolField(TEXT("ok"), true);
1401
+ Row->SetStringField(TEXT("status"), bDryRun ? TEXT("would_write") : TEXT("written"));
1402
+ Row->SetArrayField(TEXT("slots"), SlotRows);
1403
+ if (Rows.Num() < MCPBatchMaxResultRows) Rows.Add(MakeShared<FJsonValueObject>(Row));
1404
+ }
1405
+
1406
+ Result->SetBoolField(TEXT("success"), OutOfRange == 0);
1407
+ if (OutOfRange > 0)
1408
+ {
1409
+ Result->SetStringField(TEXT("error"), FString::Printf(
1410
+ TEXT("%d actor(s) were given more materials than their component has slots; see results[]."), OutOfRange));
1411
+ }
1412
+ Result->SetNumberField(bDryRun ? TEXT("wouldUpdate") : TEXT("updated"),
1413
+ bDryRun ? Actors.Num() - WithoutComponent : Updated);
1414
+ Result->SetNumberField(TEXT("slotWrites"), SlotWrites);
1415
+ Result->SetNumberField(TEXT("actorsWithoutMeshComponent"), WithoutComponent);
1416
+ Result->SetNumberField(TEXT("returnedResults"), Rows.Num());
1417
+ Result->SetBoolField(TEXT("resultsTruncated"), Rows.Num() < Actors.Num());
1418
+ Result->SetArrayField(TEXT("results"), Rows);
1419
+ if (!bDryRun && Updated > 0)
1420
+ {
1421
+ MCPSetUpdated(Result);
1422
+ Result->SetStringField(TEXT("saveNote"),
1423
+ TEXT("These are COMPONENT overrides on placed actors, so the LEVEL is dirty and is NOT saved. The mesh ASSET is untouched; use material(build_material) or asset(set_property) to write the asset's own slots."));
1424
+ }
1425
+ return MCPResult(Result);
1426
+ }
1427
+
1428
+ // ---------------------------------------------------------------------------
1429
+ // set_actor_hlod_layer (#985)
1430
+ // ---------------------------------------------------------------------------
1431
+ //
1432
+ // Assigning an HLODLayer override across 295 InstancedFoliageActors was a
1433
+ // Python loop, for the same reason every other action in this file exists: the
1434
+ // selector vocabulary lived on the client and every actor cost a round trip.
1435
+ //
1436
+ // This lives beside the other selector-driven batch writes rather than with the
1437
+ // World Partition settings, because the selector, the bounds, the dry run and
1438
+ // the missing-label reporting are all shared with them, and a second copy of
1439
+ // that vocabulary would be one more thing to drift.
1440
+ //
1441
+ // The write goes through AActor::SetHLODLayer rather than through the reflected
1442
+ // property. HLODLayer is a private UPROPERTY and reflection would reach it, but
1443
+ // the engine's own setter is what the HLOD system expects to have been called,
1444
+ // and a bulk write that skipped it would be the kind of edit that looks applied
1445
+ // and behaves differently from one made in the details panel.
1446
+ TSharedPtr<FJsonValue> FLevelHandlers::SetActorHLODLayer(const TSharedPtr<FJsonObject>& Params)
1447
+ {
1448
+ MCP_CHECK_GAME_THREAD();
1449
+ REQUIRE_EDITOR_WORLD(World);
1450
+
1451
+ // null clears the override, which is a legitimate request and has to be
1452
+ // distinguishable from "the parameter was omitted".
1453
+ if (!Params->HasField(TEXT("hlodLayer")))
1454
+ {
1455
+ return MCPError(TEXT("Missing 'hlodLayer': an HLODLayer asset path, or null to clear the per-actor override"));
1456
+ }
1457
+ FString LayerPath;
1458
+ Params->TryGetStringField(TEXT("hlodLayer"), LayerPath);
1459
+ LayerPath.TrimStartAndEndInline();
1460
+
1461
+ UHLODLayer* Layer = nullptr;
1462
+ if (!LayerPath.IsEmpty())
1463
+ {
1464
+ Layer = LoadAssetByPath<UHLODLayer>(LayerPath);
1465
+ if (!Layer) return MCPAssetLoadError(LayerPath, TEXT("UHLODLayer"));
1466
+ }
1467
+
1468
+ FMCPBatchSelector Selector;
1469
+ if (auto Err = MCPBatchReadSelector(Params, Selector)) return Err;
1470
+ if (!Selector.bAny)
1471
+ {
1472
+ return MCPError(TEXT("Pass at least one selector: actorLabels, labelPrefix, labelContains, tag, classFilter, folderPath or folderPathPrefix"));
1473
+ }
1474
+
1475
+ const bool bDryRun = OptionalBool(Params, TEXT("dryRun"), false);
1476
+ const bool bHasAutoLODField = Params->HasField(TEXT("enableAutoLODGeneration"));
1477
+ const bool bEnableAutoLOD = OptionalBool(Params, TEXT("enableAutoLODGeneration"), true);
1478
+ const FString TransactionLabel = OptionalString(
1479
+ Params, TEXT("transactionLabel"), TEXT("MCP set actor HLOD layer"));
1480
+
1481
+ TArray<AActor*> Actors;
1482
+ MCPBatchCollectActors(World, Selector, Actors);
1483
+ if (Actors.Num() > MCPBatchMaxActors)
1484
+ {
1485
+ return MCPError(FString::Printf(
1486
+ TEXT("The selector matched %d actors, over the maximum of %d. Narrow it."),
1487
+ Actors.Num(), MCPBatchMaxActors));
1488
+ }
1489
+
1490
+ auto Result = MCPSuccess();
1491
+ Result->SetBoolField(TEXT("dryRun"), bDryRun);
1492
+ Result->SetStringField(TEXT("hlodLayer"), Layer ? Layer->GetPathName() : FString());
1493
+ Result->SetBoolField(TEXT("clearing"), Layer == nullptr);
1494
+ Result->SetNumberField(TEXT("matched"), Actors.Num());
1495
+ MCPNoteLoadedOnlyEnumeration(World, Result);
1496
+ {
1497
+ const TArray<FString> Missing = MCPBatchMissingLabels(Selector, Actors);
1498
+ if (Missing.Num() > 0)
1499
+ {
1500
+ Result->SetArrayField(TEXT("missingLabels"), MCPStringListToJson(Missing));
1501
+ }
1502
+ }
1503
+
1504
+ TArray<TSharedPtr<FJsonValue>> Rows;
1505
+ int32 Updated = 0;
1506
+ int32 Unchanged = 0;
1507
+ {
1508
+ const FScopedTransaction Transaction(FText::FromString(TransactionLabel));
1509
+ for (AActor* Actor : Actors)
1510
+ {
1511
+ if (!Actor) continue;
1512
+ UHLODLayer* Previous = Actor->GetHLODLayer();
1513
+ const bool bWouldChange = Previous != Layer;
1514
+
1515
+ TSharedPtr<FJsonObject> Row = MakeShared<FJsonObject>();
1516
+ Row->SetStringField(TEXT("actorLabel"), Actor->GetActorLabel());
1517
+ Row->SetStringField(TEXT("actorClass"), Actor->GetClass()->GetName());
1518
+ Row->SetStringField(TEXT("previousHLODLayer"), Previous ? Previous->GetPathName() : FString());
1519
+
1520
+ if (!bWouldChange && !bHasAutoLODField)
1521
+ {
1522
+ // Already what was asked for. Reporting it as an update would
1523
+ // make a no-op look like work, which is what makes a rerun
1524
+ // impossible to reason about.
1525
+ ++Unchanged;
1526
+ Row->SetStringField(TEXT("status"), TEXT("unchanged"));
1527
+ if (Rows.Num() < MCPBatchMaxResultRows) Rows.Add(MakeShared<FJsonValueObject>(Row));
1528
+ continue;
1529
+ }
1530
+
1531
+ if (bDryRun)
1532
+ {
1533
+ Row->SetStringField(TEXT("status"), TEXT("would_write"));
1534
+ if (Rows.Num() < MCPBatchMaxResultRows) Rows.Add(MakeShared<FJsonValueObject>(Row));
1535
+ continue;
1536
+ }
1537
+
1538
+ Actor->Modify();
1539
+ Actor->SetHLODLayer(Layer);
1540
+ if (bHasAutoLODField)
1541
+ {
1542
+ // The layer only matters when the actor is allowed to build
1543
+ // HLODs at all, so the two are settable in one call.
1544
+ Actor->bEnableAutoLODGeneration = bEnableAutoLOD;
1545
+ }
1546
+ Actor->PostEditChange();
1547
+ Actor->MarkPackageDirty();
1548
+ ++Updated;
1549
+
1550
+ UHLODLayer* After = Actor->GetHLODLayer();
1551
+ Row->SetStringField(TEXT("hlodLayer"), After ? After->GetPathName() : FString());
1552
+ Row->SetBoolField(TEXT("enableAutoLODGeneration"), Actor->bEnableAutoLODGeneration);
1553
+ Row->SetStringField(TEXT("status"), TEXT("written"));
1554
+ if (Rows.Num() < MCPBatchMaxResultRows) Rows.Add(MakeShared<FJsonValueObject>(Row));
1555
+ }
1556
+ }
1557
+
1558
+ Result->SetNumberField(bDryRun ? TEXT("wouldUpdate") : TEXT("updated"),
1559
+ bDryRun ? Actors.Num() - Unchanged : Updated);
1560
+ Result->SetNumberField(TEXT("unchanged"), Unchanged);
1561
+ Result->SetNumberField(TEXT("returnedResults"), Rows.Num());
1562
+ Result->SetBoolField(TEXT("resultsTruncated"), Rows.Num() < Actors.Num());
1563
+ Result->SetArrayField(TEXT("results"), Rows);
1564
+ if (!bDryRun && Updated > 0)
1565
+ {
1566
+ MCPSetUpdated(Result);
1567
+ Result->SetStringField(TEXT("saveNote"),
1568
+ TEXT("On a World Partition map each actor lives in its own package, so this dirties one package per actor. Save them with level(save) or editor(save_dirty). HLOD assignment takes effect on the next HLOD build."));
1569
+ }
1570
+ return MCPResult(Result);
1571
+ }